From f06313d5b2bb4b11e8db6ea41cba85574cd07e17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Mon, 5 May 2025 12:20:18 +0200 Subject: [PATCH] add sudo_askpass_proxy --- pkgs/clan-cli/clan_cli/machines/hardware.py | 4 +- pkgs/clan-cli/clan_cli/machines/update.py | 139 +++++++++--------- pkgs/clan-cli/clan_cli/ssh/upload.py | 9 +- pkgs/clan-cli/clan_cli/tests/sshd.py | 1 + .../clan_cli/tests/test_ssh_remote.py | 18 +++ .../clan_lib/nix/allowed-packages.json | 6 +- pkgs/clan-cli/clan_lib/ssh/remote.py | 86 +++++++++-- .../clan_lib/ssh/sudo_askpass_proxy.py | 129 ++++++++++++++++ .../clan_lib/ssh/sudo_askpass_proxy.sh | 37 +++++ pkgs/clan-cli/pyproject.toml | 1 + 10 files changed, 345 insertions(+), 85 deletions(-) create mode 100644 pkgs/clan-cli/clan_lib/ssh/sudo_askpass_proxy.py create mode 100644 pkgs/clan-cli/clan_lib/ssh/sudo_askpass_proxy.sh diff --git a/pkgs/clan-cli/clan_cli/machines/hardware.py b/pkgs/clan-cli/clan_cli/machines/hardware.py index e2dce8173..2f656afdf 100644 --- a/pkgs/clan-cli/clan_cli/machines/hardware.py +++ b/pkgs/clan-cli/clan_cli/machines/hardware.py @@ -105,8 +105,8 @@ def generate_machine_hardware_info(opts: HardwareGenerateOptions) -> HardwareCon host = opts.machine.target_host() - with host.ssh_control_master() as ssh: - out = ssh.run(config_command, become_root=True, opts=RunOpts(check=False)) + with host.ssh_control_master() as ssh, ssh.become_root() as sudo_ssh: + out = sudo_ssh.run(config_command, opts=RunOpts(check=False)) if out.returncode != 0: if "nixos-facter" in out.stderr and "not found" in out.stderr: machine.error(str(out.stderr)) diff --git a/pkgs/clan-cli/clan_cli/machines/update.py b/pkgs/clan-cli/clan_cli/machines/update.py index 161eb1354..4b3bfe090 100644 --- a/pkgs/clan-cli/clan_cli/machines/update.py +++ b/pkgs/clan-cli/clan_cli/machines/update.py @@ -5,6 +5,7 @@ import os import re import shlex import sys +from contextlib import ExitStack from clan_lib.api import API from clan_lib.async_run import AsyncContext, AsyncOpts, AsyncRuntime, is_async_cancelled @@ -109,94 +110,98 @@ def upload_sources(machine: Machine, host: Remote) -> str: @API.register def deploy_machine(machine: Machine) -> None: - target_host = machine.target_host() - build_host = machine.build_host() + with ExitStack() as stack: + target_host = stack.enter_context(machine.target_host().ssh_control_master()) + build_host = machine.build_host() + if build_host is not None: + build_host = stack.enter_context(build_host.ssh_control_master()) - host = build_host or target_host + host = build_host or target_host - generate_facts([machine], service=None, regenerate=False) - generate_vars([machine], generator_name=None, regenerate=False) + sudo_host = stack.enter_context(target_host.become_root()) - upload_secrets(machine) - upload_secret_vars(machine, target_host) + generate_facts([machine], service=None, regenerate=False) + generate_vars([machine], generator_name=None, regenerate=False) - path = upload_sources(machine, host) + upload_secrets(machine, sudo_host) + upload_secret_vars(machine, sudo_host) - nix_options = [ - "--show-trace", - "--option", - "keep-going", - "true", - "--option", - "accept-flake-config", - "true", - "-L", - *machine.nix_options, - "--flake", - f"{path}#{machine.name}", - ] + path = upload_sources(machine, sudo_host) - become_root = True - - if machine._class_ == "nixos": - nix_options += [ - "--fast", - "--build-host", - "", + nix_options = [ + "--show-trace", + "--option", + "keep-going", + "true", + "--option", + "accept-flake-config", + "true", + "-L", + *machine.nix_options, + "--flake", + f"{path}#{machine.name}", ] - if build_host: - become_root = False - nix_options += ["--target-host", target_host.target] + become_root = True - if target_host.user != "root": - nix_options += ["--use-remote-sudo"] - switch_cmd = ["nixos-rebuild", "switch", *nix_options] - elif machine._class_ == "darwin": - # use absolute path to darwin-rebuild - switch_cmd = [ - "/run/current-system/sw/bin/darwin-rebuild", - "switch", - *nix_options, - ] + if machine._class_ == "nixos": + nix_options += [ + "--fast", + "--build-host", + "", + ] - remote_env = host.nix_ssh_env(control_master=False) - ret = host.run( - switch_cmd, - RunOpts( - check=False, - log=Log.BOTH, - msg_color=MsgColor(stderr=AnsiColor.DEFAULT), - needs_user_terminal=True, - ), - extra_env=remote_env, - become_root=become_root, - control_master=False, - ) + if build_host: + become_root = False + nix_options += ["--target-host", target_host.target] - if is_async_cancelled(): - return + if target_host.user != "root": + nix_options += ["--use-remote-sudo"] + switch_cmd = ["nixos-rebuild", "switch", *nix_options] + elif machine._class_ == "darwin": + # use absolute path to darwin-rebuild + switch_cmd = [ + "/run/current-system/sw/bin/darwin-rebuild", + "switch", + *nix_options, + ] - # retry nixos-rebuild switch if the first attempt failed - if ret.returncode != 0: - is_mobile = machine.deployment.get("nixosMobileWorkaround", False) - # if the machine is mobile, we retry to deploy with the mobile workaround method - if is_mobile: - machine.info( - "Mobile machine detected, applying workaround deployment method" - ) + if become_root: + host = sudo_host + + remote_env = host.nix_ssh_env(control_master=False) ret = host.run( - ["nixos--rebuild", "test", *nix_options] if is_mobile else switch_cmd, + switch_cmd, RunOpts( + check=False, log=Log.BOTH, msg_color=MsgColor(stderr=AnsiColor.DEFAULT), needs_user_terminal=True, ), extra_env=remote_env, - become_root=become_root, - control_master=False, ) + if is_async_cancelled(): + return + + # retry nixos-rebuild switch if the first attempt failed + if ret.returncode != 0: + is_mobile = machine.deployment.get("nixosMobileWorkaround", False) + # if the machine is mobile, we retry to deploy with the mobile workaround method + if is_mobile: + machine.info( + "Mobile machine detected, applying workaround deployment method" + ) + ret = host.run( + ["nixos--rebuild", "test", *nix_options] if is_mobile else switch_cmd, + RunOpts( + log=Log.BOTH, + msg_color=MsgColor(stderr=AnsiColor.DEFAULT), + needs_user_terminal=True, + ), + extra_env=remote_env, + ) + def deploy_machines(machines: list[Machine]) -> None: """ diff --git a/pkgs/clan-cli/clan_cli/ssh/upload.py b/pkgs/clan-cli/clan_cli/ssh/upload.py index 0a5d87e07..159e732c4 100644 --- a/pkgs/clan-cli/clan_cli/ssh/upload.py +++ b/pkgs/clan-cli/clan_cli/ssh/upload.py @@ -98,8 +98,12 @@ def upload( raise ClanError(msg) # TODO accept `input` to be an IO object instead of bytes so that we don't have to read the tarfile into memory. - with tar_path.open("rb") as f, host.ssh_control_master() as ssh: - ssh.run( + with ( + tar_path.open("rb") as f, + host.ssh_control_master() as ssh, + ssh.become_root() as sudo_ssh, + ): + sudo_ssh.run( [ "bash", "-c", @@ -114,5 +118,4 @@ def upload( prefix=host.command_prefix, needs_user_terminal=True, ), - become_root=True, ) diff --git a/pkgs/clan-cli/clan_cli/tests/sshd.py b/pkgs/clan-cli/clan_cli/tests/sshd.py index caf6414f5..d92f664c4 100644 --- a/pkgs/clan-cli/clan_cli/tests/sshd.py +++ b/pkgs/clan-cli/clan_cli/tests/sshd.py @@ -80,6 +80,7 @@ exec {bash} -l "${{@}}" fake_sudo.write_text( f"""#!{bash} +shift exec "${{@}}" """ ) diff --git a/pkgs/clan-cli/clan_cli/tests/test_ssh_remote.py b/pkgs/clan-cli/clan_cli/tests/test_ssh_remote.py index 299caa0cb..3216f7dba 100644 --- a/pkgs/clan-cli/clan_cli/tests/test_ssh_remote.py +++ b/pkgs/clan-cli/clan_cli/tests/test_ssh_remote.py @@ -9,6 +9,7 @@ from clan_lib.async_run import AsyncRuntime from clan_lib.cmd import ClanCmdTimeoutError, Log, RunOpts from clan_lib.errors import ClanError, CmdOut from clan_lib.ssh.remote import Remote +from clan_lib.ssh.sudo_askpass_proxy import SudoAskpassProxy if sys.platform == "darwin": pytest.skip("preload doesn't work on darwin", allow_module_level=True) @@ -178,6 +179,23 @@ def test_run_no_shell(hosts: list[Remote], runtime: AsyncRuntime) -> None: assert proc.wait().result.stdout == "hello\n" +def test_sudo_ask_proxy(hosts: list[Remote]) -> None: + host = hosts[0] + with host.ssh_control_master() as host: + proxy = SudoAskpassProxy(host, prompt_command=["bash", "-c", "echo yes"]) + + try: + askpass_path = proxy.run() + out = host.run( + ["bash", "-c", askpass_path], + opts=RunOpts(check=False, log=Log.BOTH), + ) + assert out.returncode == 0 + assert out.stdout == "yes\n" + finally: + proxy.cleanup() + + def test_run_function(hosts: list[Remote], runtime: AsyncRuntime) -> None: def some_func(h: Remote) -> bool: with h.ssh_control_master() as ssh: diff --git a/pkgs/clan-cli/clan_lib/nix/allowed-packages.json b/pkgs/clan-cli/clan_lib/nix/allowed-packages.json index 787066ded..73ed0f459 100644 --- a/pkgs/clan-cli/clan_lib/nix/allowed-packages.json +++ b/pkgs/clan-cli/clan_lib/nix/allowed-packages.json @@ -1,12 +1,12 @@ [ "age", + "age-plugin-1p", "age-plugin-fido2-hmac", "age-plugin-ledger", "age-plugin-se", "age-plugin-sss", "age-plugin-tpm", "age-plugin-yubikey", - "age-plugin-1p", "avahi", "bash", "bubblewrap", @@ -14,6 +14,7 @@ "e2fsprogs", "git", "gnupg", + "dialog", "mypy", "netcat", "nix", @@ -30,5 +31,6 @@ "virt-viewer", "virtiofsd", "waypipe", - "zbar" + "zbar", + "zenity" ] diff --git a/pkgs/clan-cli/clan_lib/ssh/remote.py b/pkgs/clan-cli/clan_lib/ssh/remote.py index dc3c17b65..206b74631 100644 --- a/pkgs/clan-cli/clan_lib/ssh/remote.py +++ b/pkgs/clan-cli/clan_lib/ssh/remote.py @@ -19,6 +19,7 @@ from clan_lib.colors import AnsiColor from clan_lib.errors import ClanError # Assuming these are available from clan_lib.nix import nix_shell from clan_lib.ssh.parse import parse_deployment_address +from clan_lib.ssh.sudo_askpass_proxy import SudoAskpassProxy cmdlog = logging.getLogger(__name__) @@ -39,7 +40,9 @@ class Remote: verbose_ssh: bool = False ssh_options: dict[str, str] = field(default_factory=dict) tor_socks: bool = False + _control_path_dir: Path | None = None + _askpass_path: str | None = None def __str__(self) -> str: return self.target @@ -124,11 +127,61 @@ class Remote: _askpass_path=self._askpass_path, ) + @contextmanager + def become_root(self) -> Iterator["Remote"]: + """ + Context manager to set up sudo askpass proxy. + This will set up a proxy for sudo password prompts. + """ + if self.user == "root": + yield self + return + + if ( + os.environ.get("DISPLAY") + or os.environ.get("WAYLAND_DISPLAY") + or sys.platform == "darwin" + ): + command = ["zenity", "--password", "--title", "%title%"] + dependencies = ["zenity"] + else: + command = [ + "dialog", + "--stdout", + "--insecure", + "--title", + "%title%", + "--passwordbox", + "", + "10", + "50", + ] + dependencies = ["dialog"] + proxy = SudoAskpassProxy(self, nix_shell(dependencies, command)) + try: + askpass_path = proxy.run() + yield Remote( + address=self.address, + user=self.user, + command_prefix=self.command_prefix, + port=self.port, + private_key=self.private_key, + password=self.password, + forward_agent=self.forward_agent, + host_key_check=self.host_key_check, + verbose_ssh=self.verbose_ssh, + ssh_options=self.ssh_options, + tor_socks=self.tor_socks, + _control_path_dir=self._control_path_dir, + _askpass_path=askpass_path, + ) + finally: + proxy.cleanup() + def run( self, cmd: list[str], opts: RunOpts | None = None, - become_root: bool = False, extra_env: dict[str, str] | None = None, tty: bool = False, verbose_ssh: bool = False, @@ -144,9 +197,14 @@ class Remote: if opts is None: opts = RunOpts() - sudo = "" - if become_root and self.user != "root": - sudo = "sudo -- " + sudo = [] + if self._askpass_path is not None: + sudo = [ + f"SUDO_ASKPASS={shlex.quote(self._askpass_path)}", + "sudo", + "-A", + "--", + ] env_vars = [] for k, v in extra_env.items(): @@ -182,14 +240,20 @@ class Remote: else: bash_cmd += 'exec "$@"' - ssh_cmd_list = self.ssh_cmd( - verbose_ssh=verbose_ssh, tty=tty, control_master=control_master - ) - ssh_cmd_list.extend( - ["--", f"{sudo}bash -c {quote(bash_cmd)} -- {' '.join(map(quote, cmd))}"] - ) + ssh_cmd = [ + *self.ssh_cmd( + verbose_ssh=verbose_ssh, tty=tty, control_master=control_master + ), + "--", + *sudo, + "bash", + "-c", + quote(bash_cmd), + "--", + " ".join(map(quote, cmd)), + ] - return run(ssh_cmd_list, opts) + return run(ssh_cmd, opts) def nix_ssh_env( self, diff --git a/pkgs/clan-cli/clan_lib/ssh/sudo_askpass_proxy.py b/pkgs/clan-cli/clan_lib/ssh/sudo_askpass_proxy.py new file mode 100644 index 000000000..e5ffbb349 --- /dev/null +++ b/pkgs/clan-cli/clan_lib/ssh/sudo_askpass_proxy.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 + +import logging +import subprocess +import sys + +# to get the the current tty state +import termios +import threading +from pathlib import Path +from typing import TYPE_CHECKING + +from clan_lib.cmd import terminate_process +from clan_lib.errors import ClanError + +if TYPE_CHECKING: + from clan_lib.ssh.remote import Remote + +logger = logging.getLogger(__name__) + +remote_script = (Path(__file__).parent / "sudo_askpass_proxy.sh").read_text() + + +class SudoAskpassProxy: + def __init__(self, host: "Remote", prompt_command: list[str]) -> None: + self.host = host + self.password_prompt_command = prompt_command + self.ssh_process: subprocess.Popen | None = None + self.thread: threading.Thread | None = None + + def handle_password_request(self, prompt: str) -> str: + """Get password from local user""" + try: + # Run the password prompt command + password_command = [ + arg.replace("%title%", prompt) for arg in self.password_prompt_command + ] + old_settings = None + if sys.stdin.isatty(): + # If stdin is a tty, we can safely change terminal settings + old_settings = termios.tcgetattr(sys.stdin.fileno()) + try: + logger.debug( + f"Running password prompt command: {' '.join(password_command)}" + ) + password_process = subprocess.run( + password_command, text=True, check=False, stdout=subprocess.PIPE + ) + finally: # dialog messes with the terminal settings, so we need to restore them + if old_settings is not None: + termios.tcsetattr( + sys.stdin.fileno(), termios.TCSADRAIN, old_settings + ) + + if password_process.returncode != 0: + return "CANCELED" + return password_process.stdout.strip() + except ClanError as e: + msg = f"Error running password prompt command: {e}" + raise ClanError(msg) from e + + def _process(self, ssh_process: subprocess.Popen) -> None: + """Execute the remote command with password proxying""" + + # Monitor SSH output for password requests + assert ssh_process.stdout is not None, "SSH process stdout is None" + try: + for line in ssh_process.stdout: + line = line.strip() + if line.startswith("PASSWORD_REQUESTED:"): + prompt = line[len("PASSWORD_REQUESTED:") :].strip() + password = self.handle_password_request(prompt) + print(password, file=ssh_process.stdin) + assert ssh_process.stdin is not None, "SSH process stdin is None" + ssh_process.stdin.flush() + else: + print(line) + except Exception as e: + logger.error(f"Error processing passwords requests output: {e}") + + def run(self) -> str: + """Run the SSH command with password proxying. Returns the askpass script path.""" + # Create a shell script to run on the remote host + + # Start SSH process + cmd = [*self.host.ssh_cmd(), remote_script] + try: + self.ssh_process = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + text=True, + ) + except OSError as e: + msg = f"error connecting to {self.host.target}: {e}" + raise ClanError(msg) from e + + # Monitor SSH output for password requests + assert self.ssh_process.stdout is not None, "SSH process stdout is None" + + for line in self.ssh_process.stdout: + line = line.strip() + if line.startswith("ASKPASS_SCRIPT:"): + askpass_script = line[len("ASKPASS_SCRIPT:") :].strip() + break + else: + msg = f"Failed to create askpass script on {self.host.target}. Did not receive ASKPASS_SCRIPT line." + raise ClanError(msg) + + self.thread = threading.Thread( + target=self._process, name="SudoAskpassProxy", args=(self.ssh_process,) + ) + self.thread.start() + return askpass_script + + def cleanup(self) -> None: + """Terminate SSH process if still running""" + if self.ssh_process: + with terminate_process(self.ssh_process): + pass + + # Unclear why we have to close this manually, but pytest reports unclosed fd + assert self.ssh_process.stdout is not None + self.ssh_process.stdout.close() + assert self.ssh_process.stdin is not None + self.ssh_process.stdin.close() + self.ssh_process = None + if self.thread: + self.thread.join() diff --git a/pkgs/clan-cli/clan_lib/ssh/sudo_askpass_proxy.sh b/pkgs/clan-cli/clan_lib/ssh/sudo_askpass_proxy.sh new file mode 100644 index 000000000..770464483 --- /dev/null +++ b/pkgs/clan-cli/clan_lib/ssh/sudo_askpass_proxy.sh @@ -0,0 +1,37 @@ +# shellcheck shell=bash +set -euo pipefail + +# Create temporary directory +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +# Create FIFOs +prompt_fifo="$tmpdir/prompt_fifo" +password_fifo="$tmpdir/password_fifo" + +mkfifo -m600 "$prompt_fifo" +mkfifo -m600 "$password_fifo" + +# Create askpass script +askpass_script="$tmpdir/askpass.sh" + +cat >"$askpass_script" < "$prompt_fifo" +password=\$(head -n 1 "$password_fifo") +if [ "\$password" = "CANCELED" ]; then + exit 1 +fi +echo "\$password" +EOF +chmod +x "$askpass_script" +echo "ASKPASS_SCRIPT: $askpass_script" + +while read -r PROMPT < "$prompt_fifo"; do + echo "PASSWORD_REQUESTED: $PROMPT" + read -r password + echo ####################################### >&2 + echo "$password" >"$password_fifo" +done diff --git a/pkgs/clan-cli/pyproject.toml b/pkgs/clan-cli/pyproject.toml index 621200187..a56a0c3e2 100644 --- a/pkgs/clan-cli/pyproject.toml +++ b/pkgs/clan-cli/pyproject.toml @@ -28,6 +28,7 @@ clan_cli = [ clan_lib = [ "clan_core_templates/**/*", "**/allowed-packages.json", + "ssh/*.sh", ] [tool.pytest.ini_options]