add sudo_askpass_proxy
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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,18 +110,23 @@ def upload_sources(machine: Machine, host: Remote) -> str:
|
||||
|
||||
@API.register
|
||||
def deploy_machine(machine: Machine) -> None:
|
||||
target_host = machine.target_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
|
||||
|
||||
sudo_host = stack.enter_context(target_host.become_root())
|
||||
|
||||
generate_facts([machine], service=None, regenerate=False)
|
||||
generate_vars([machine], generator_name=None, regenerate=False)
|
||||
|
||||
upload_secrets(machine)
|
||||
upload_secret_vars(machine, target_host)
|
||||
upload_secrets(machine, sudo_host)
|
||||
upload_secret_vars(machine, sudo_host)
|
||||
|
||||
path = upload_sources(machine, host)
|
||||
path = upload_sources(machine, sudo_host)
|
||||
|
||||
nix_options = [
|
||||
"--show-trace",
|
||||
@@ -160,6 +166,9 @@ def deploy_machine(machine: Machine) -> None:
|
||||
*nix_options,
|
||||
]
|
||||
|
||||
if become_root:
|
||||
host = sudo_host
|
||||
|
||||
remote_env = host.nix_ssh_env(control_master=False)
|
||||
ret = host.run(
|
||||
switch_cmd,
|
||||
@@ -170,8 +179,6 @@ def deploy_machine(machine: Machine) -> None:
|
||||
needs_user_terminal=True,
|
||||
),
|
||||
extra_env=remote_env,
|
||||
become_root=become_root,
|
||||
control_master=False,
|
||||
)
|
||||
|
||||
if is_async_cancelled():
|
||||
@@ -193,8 +200,6 @@ def deploy_machine(machine: Machine) -> None:
|
||||
needs_user_terminal=True,
|
||||
),
|
||||
extra_env=remote_env,
|
||||
become_root=become_root,
|
||||
control_master=False,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -80,6 +80,7 @@ exec {bash} -l "${{@}}"
|
||||
|
||||
fake_sudo.write_text(
|
||||
f"""#!{bash}
|
||||
shift
|
||||
exec "${{@}}"
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
@@ -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(
|
||||
ssh_cmd = [
|
||||
*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))}"]
|
||||
)
|
||||
),
|
||||
"--",
|
||||
*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,
|
||||
|
||||
129
pkgs/clan-cli/clan_lib/ssh/sudo_askpass_proxy.py
Normal file
129
pkgs/clan-cli/clan_lib/ssh/sudo_askpass_proxy.py
Normal file
@@ -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()
|
||||
37
pkgs/clan-cli/clan_lib/ssh/sudo_askpass_proxy.sh
Normal file
37
pkgs/clan-cli/clan_lib/ssh/sudo_askpass_proxy.sh
Normal file
@@ -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" <<EOF
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
prompt="\${1:-[sudo] password:}"
|
||||
echo "\$prompt" > "$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
|
||||
@@ -28,6 +28,7 @@ clan_cli = [
|
||||
clan_lib = [
|
||||
"clan_core_templates/**/*",
|
||||
"**/allowed-packages.json",
|
||||
"ssh/*.sh",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
|
||||
Reference in New Issue
Block a user