diff --git a/sas.py b/sas.py index 522ea9a..e41f203 100755 --- a/sas.py +++ b/sas.py @@ -8,9 +8,9 @@ import string import subprocess import sys import tempfile +import threading import time import pwd -import threading stormuxAdmin = ("storm",) @@ -24,16 +24,41 @@ pingIntervalSeconds = 180 pingCount = 5 maxWormholeFailures = 3 +sudoRefreshIntervalSeconds = 60 sudoKeepaliveThread = None sudoKeepaliveStop = threading.Event() +sudoSessionPrepared = False +sudoAuthenticationError = "" -def speak_message(message): +def speak_message( + message, + fallbackFile=None, + wait=False, + applicationName=None, + connectionName=None, +): + command = ["spd-say"] + if applicationName: + command += ["-N", applicationName] + if connectionName: + command += ["-n", connectionName] + if wait: + command.append("-w") + command.append(message) try: - subprocess.run(["spd-say", message], check=False) + result = subprocess.run( + command, + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=wait, + ) except FileNotFoundError: - print(message, flush=True) - + print(message, file=fallbackFile or sys.stdout, flush=True) + return + if result.returncode != 0: + print(message, file=fallbackFile or sys.stdout, flush=True) def say_or_print(message, useSpeech): if useSpeech: @@ -53,16 +78,76 @@ def run_command(command, inputText=None, check=False, env=None): ) +def run_terminal_sudo_authentication(): + speak_message( + "Your sudo password will be requested next. Do not type your password yet. " + "If your microphone can carry computer speech into the support meeting, mute it " + "before continuing and unmute it afterward. Press Enter to continue. After you " + "press Enter, wait one second, then type your password and press Enter. If you " + "hear any part of your password spoken, stop immediately, do not finish it, and " + "tell the support assistant.", + wait=True, + applicationName="sas", + connectionName=f"sudo-password-{os.getpid()}", + ) + input() + return run_command(["sudo", "-v"]) + + +def authenticate_sudo(useSpeech): + global sudoAuthenticationError + sudoAuthenticationError = "" + command = ["sudo"] + if useSpeech: + result = run_terminal_sudo_authentication() + else: + result = run_command(command + ["-v"]) + if result.returncode != 0: + sudoAuthenticationError = concise_command_error(result) + return False + start_sudo_keepalive() + return True + + +def prepare_sudo_session(useSpeech): + global sudoAuthenticationError, sudoSessionPrepared + if os.geteuid() == 0: + sudoSessionPrepared = True + return True + invalidateResult = run_command(["sudo", "-k"]) + if invalidateResult.returncode != 0: + sudoAuthenticationError = concise_command_error(invalidateResult) + return False + probeEnv = os.environ.copy() + probeEnv["LC_ALL"] = "C" + probeEnv["LANG"] = "C" + probeResult = run_command(["sudo", "-n", "-v"], env=probeEnv) + if probeResult.returncode == 0: + start_sudo_keepalive() + sudoSessionPrepared = True + return True + probeError = concise_command_error(probeResult) + if "password is required" not in probeError.lower(): + sudoAuthenticationError = probeError + return False + if not authenticate_sudo(useSpeech=useSpeech): + return False + sudoSessionPrepared = True + return True + + def ensure_sudo(useSpeech): if os.geteuid() == 0: return True - if useSpeech: - speak_message("Sudo password required. Please enter your password now.") - result = run_command(["sudo", "-v"]) - if result.returncode == 0: - start_sudo_keepalive() - return True - return False + if sudoSessionPrepared: + return run_command(["sudo", "-n", "-v"]).returncode == 0 + return authenticate_sudo(useSpeech) + + +def sudo_command_prefix(): + if sudoSessionPrepared: + return ["sudo", "-n"] + return ["sudo"] def start_sudo_keepalive(): @@ -71,7 +156,7 @@ def start_sudo_keepalive(): return def keepalive_loop(): - while not sudoKeepaliveStop.wait(240): + while not sudoKeepaliveStop.wait(sudoRefreshIntervalSeconds): run_command(["sudo", "-n", "-v"]) sudoKeepaliveThread = threading.Thread(target=keepalive_loop, daemon=True) @@ -84,7 +169,7 @@ def run_privileged(command, useSpeech, inputText=None, check=True): else: if not ensure_sudo(useSpeech): raise RuntimeError("sudo authentication failed") - fullCommand = ["sudo"] + command + fullCommand = sudo_command_prefix() + command return run_command(fullCommand, inputText=inputText, check=check) @@ -94,7 +179,7 @@ def run_as_user(userName, command, useSpeech, check=True): else: if not ensure_sudo(useSpeech): raise RuntimeError("sudo authentication failed") - fullCommand = ["sudo", "-u", userName, "-H"] + command + fullCommand = sudo_command_prefix() + ["-u", userName, "-H"] + command return run_command(fullCommand, check=check) @@ -102,6 +187,15 @@ def command_error_text(result): return (result.stderr or result.stdout or "").strip() +def concise_command_error(result): + errorText = " ".join(command_error_text(result).split()) + if not errorText: + return "sudo returned no error details" + if len(errorText) > 400: + return errorText[:400] + "..." + return errorText + + def service_is_active(serviceName): result = run_command(["systemctl", "is-active", "--quiet", serviceName]) return result.returncode == 0 @@ -373,6 +467,15 @@ def main(): answer = input().strip().lower() useSpeech = answer in ("n", "no") + if useSpeech and not prepare_sudo_session(useSpeech=True): + failureMessage = ( + f"Sudo authentication failed. {sudoAuthenticationError}. " + "The support session was not started." + ) + say_or_print(failureMessage, True) + print(failureMessage, flush=True) + return 1 + shouldRemoveUser = False cleanupDone = False tempDir = tempfile.mkdtemp(prefix="sas-ii-") @@ -425,7 +528,10 @@ def main(): useSpeech, ) except Exception: - pass + say_or_print( + "Cleanup warning: failed to remove sas user. Please remove it manually.", + useSpeech, + ) sudoKeepaliveStop.set() if sudoKeepaliveThread: @@ -615,7 +721,7 @@ def main(): privateKeyPath, f"{sasUser}@{remoteHost}", ] - sshCommand = ["sudo", "-u", sasUser, "-H"] + sshCommand + sshCommand = sudo_command_prefix() + ["-u", sasUser, "-H"] + sshCommand sshProcess = subprocess.Popen(sshCommand) sshProcess.wait() say_or_print("The support connection ended. Cleaning up.", useSpeech) @@ -649,5 +755,13 @@ def remove_tree(path): pass +def command_line_main(arguments=None): + arguments = sys.argv[1:] if arguments is None else arguments + if arguments: + print("Usage: sas", file=sys.stderr) + return 2 + return main() + + if __name__ == "__main__": - sys.exit(main()) + sys.exit(command_line_main())