Merge pull request 'add sudo_askpass_proxy' (#3642) from sudo into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/3642
This commit is contained in:
@@ -4,6 +4,7 @@ from pathlib import Path
|
|||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
from clan_lib.machines.machines import Machine
|
from clan_lib.machines.machines import Machine
|
||||||
|
from clan_lib.ssh.remote import Remote
|
||||||
|
|
||||||
from clan_cli.completions import add_dynamic_completer, complete_machines
|
from clan_cli.completions import add_dynamic_completer, complete_machines
|
||||||
from clan_cli.ssh.upload import upload
|
from clan_cli.ssh.upload import upload
|
||||||
@@ -11,9 +12,7 @@ from clan_cli.ssh.upload import upload
|
|||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def upload_secrets(machine: Machine) -> None:
|
def upload_secrets(machine: Machine, host: Remote) -> None:
|
||||||
host = machine.target_host()
|
|
||||||
|
|
||||||
if not machine.secret_facts_store.needs_upload(host):
|
if not machine.secret_facts_store.needs_upload(host):
|
||||||
machine.info("Secrets already uploaded")
|
machine.info("Secrets already uploaded")
|
||||||
return
|
return
|
||||||
@@ -27,7 +26,8 @@ def upload_secrets(machine: Machine) -> None:
|
|||||||
|
|
||||||
def upload_command(args: argparse.Namespace) -> None:
|
def upload_command(args: argparse.Namespace) -> None:
|
||||||
machine = Machine(name=args.machine, flake=args.flake)
|
machine = Machine(name=args.machine, flake=args.flake)
|
||||||
upload_secrets(machine)
|
with machine.target_host().ssh_control_master() as host:
|
||||||
|
upload_secrets(machine, host)
|
||||||
|
|
||||||
|
|
||||||
def register_upload_parser(parser: argparse.ArgumentParser) -> None:
|
def register_upload_parser(parser: argparse.ArgumentParser) -> None:
|
||||||
|
|||||||
@@ -105,8 +105,8 @@ def generate_machine_hardware_info(opts: HardwareGenerateOptions) -> HardwareCon
|
|||||||
|
|
||||||
host = opts.machine.target_host()
|
host = opts.machine.target_host()
|
||||||
|
|
||||||
with host.ssh_control_master() as ssh:
|
with host.ssh_control_master() as ssh, ssh.become_root() as sudo_ssh:
|
||||||
out = ssh.run(config_command, become_root=True, opts=RunOpts(check=False))
|
out = sudo_ssh.run(config_command, opts=RunOpts(check=False))
|
||||||
if out.returncode != 0:
|
if out.returncode != 0:
|
||||||
if "nixos-facter" in out.stderr and "not found" in out.stderr:
|
if "nixos-facter" in out.stderr and "not found" in out.stderr:
|
||||||
machine.error(str(out.stderr))
|
machine.error(str(out.stderr))
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
import sys
|
import sys
|
||||||
|
from contextlib import ExitStack
|
||||||
|
|
||||||
from clan_lib.api import API
|
from clan_lib.api import API
|
||||||
from clan_lib.async_run import AsyncContext, AsyncOpts, AsyncRuntime, is_async_cancelled
|
from clan_lib.async_run import AsyncContext, AsyncOpts, AsyncRuntime, is_async_cancelled
|
||||||
@@ -43,160 +44,159 @@ def is_local_input(node: dict[str, dict[str, str]]) -> bool:
|
|||||||
return local
|
return local
|
||||||
|
|
||||||
|
|
||||||
def upload_sources(machine: Machine, host: Remote) -> str:
|
def upload_sources(machine: Machine, ssh: Remote) -> str:
|
||||||
with host.ssh_control_master() as ssh:
|
env = ssh.nix_ssh_env(os.environ.copy())
|
||||||
env = ssh.nix_ssh_env(os.environ.copy())
|
|
||||||
|
|
||||||
flake_url = (
|
flake_url = (
|
||||||
str(machine.flake.path)
|
str(machine.flake.path) if machine.flake.is_local else machine.flake.identifier
|
||||||
if machine.flake.is_local
|
)
|
||||||
else machine.flake.identifier
|
flake_data = nix_metadata(flake_url)
|
||||||
)
|
has_path_inputs = any(
|
||||||
flake_data = nix_metadata(flake_url)
|
is_local_input(node) for node in flake_data["locks"]["nodes"].values()
|
||||||
has_path_inputs = any(
|
)
|
||||||
is_local_input(node) for node in flake_data["locks"]["nodes"].values()
|
|
||||||
)
|
|
||||||
|
|
||||||
if not has_path_inputs:
|
if not has_path_inputs:
|
||||||
# Just copy the flake to the remote machine, we can substitute other inputs there.
|
# Just copy the flake to the remote machine, we can substitute other inputs there.
|
||||||
path = flake_data["path"]
|
path = flake_data["path"]
|
||||||
cmd = nix_command(
|
|
||||||
[
|
|
||||||
"copy",
|
|
||||||
"--to",
|
|
||||||
f"ssh://{host.target}",
|
|
||||||
"--no-check-sigs",
|
|
||||||
path,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
run(
|
|
||||||
cmd,
|
|
||||||
RunOpts(
|
|
||||||
env=env,
|
|
||||||
needs_user_terminal=True,
|
|
||||||
error_msg="failed to upload sources",
|
|
||||||
prefix=machine.name,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return path
|
|
||||||
|
|
||||||
# Slow path: we need to upload all sources to the remote machine
|
|
||||||
cmd = nix_command(
|
cmd = nix_command(
|
||||||
[
|
[
|
||||||
"flake",
|
"copy",
|
||||||
"archive",
|
|
||||||
"--to",
|
"--to",
|
||||||
f"ssh://{host.target}",
|
f"ssh://{ssh.target}",
|
||||||
"--json",
|
"--no-check-sigs",
|
||||||
flake_url,
|
path,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
proc = run(
|
run(
|
||||||
cmd,
|
cmd,
|
||||||
RunOpts(
|
RunOpts(
|
||||||
env=env, needs_user_terminal=True, error_msg="failed to upload sources"
|
env=env,
|
||||||
|
needs_user_terminal=True,
|
||||||
|
error_msg="failed to upload sources",
|
||||||
|
prefix=machine.name,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
return path
|
||||||
|
|
||||||
try:
|
# Slow path: we need to upload all sources to the remote machine
|
||||||
return json.loads(proc.stdout)["path"]
|
cmd = nix_command(
|
||||||
except (json.JSONDecodeError, OSError) as e:
|
[
|
||||||
msg = (
|
"flake",
|
||||||
f"failed to parse output of {shlex.join(cmd)}: {e}\nGot: {proc.stdout}"
|
"archive",
|
||||||
)
|
"--to",
|
||||||
raise ClanError(msg) from e
|
f"ssh://{ssh.target}",
|
||||||
|
"--json",
|
||||||
|
flake_url,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
proc = run(
|
||||||
|
cmd,
|
||||||
|
RunOpts(
|
||||||
|
env=env, needs_user_terminal=True, error_msg="failed to upload sources"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.loads(proc.stdout)["path"]
|
||||||
|
except (json.JSONDecodeError, OSError) as e:
|
||||||
|
msg = f"failed to parse output of {shlex.join(cmd)}: {e}\nGot: {proc.stdout}"
|
||||||
|
raise ClanError(msg) from e
|
||||||
|
|
||||||
|
|
||||||
@API.register
|
@API.register
|
||||||
def deploy_machine(machine: Machine) -> None:
|
def deploy_machine(machine: Machine) -> None:
|
||||||
target_host = machine.target_host()
|
with ExitStack() as stack:
|
||||||
build_host = machine.build_host()
|
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)
|
sudo_host = stack.enter_context(target_host.become_root())
|
||||||
generate_vars([machine], generator_name=None, regenerate=False)
|
|
||||||
|
|
||||||
upload_secrets(machine)
|
generate_facts([machine], service=None, regenerate=False)
|
||||||
upload_secret_vars(machine, target_host)
|
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 = [
|
path = upload_sources(machine, sudo_host)
|
||||||
"--show-trace",
|
|
||||||
"--option",
|
|
||||||
"keep-going",
|
|
||||||
"true",
|
|
||||||
"--option",
|
|
||||||
"accept-flake-config",
|
|
||||||
"true",
|
|
||||||
"-L",
|
|
||||||
*machine.nix_options,
|
|
||||||
"--flake",
|
|
||||||
f"{path}#{machine.name}",
|
|
||||||
]
|
|
||||||
|
|
||||||
become_root = True
|
nix_options = [
|
||||||
|
"--show-trace",
|
||||||
if machine._class_ == "nixos":
|
"--option",
|
||||||
nix_options += [
|
"keep-going",
|
||||||
"--fast",
|
"true",
|
||||||
"--build-host",
|
"--option",
|
||||||
"",
|
"accept-flake-config",
|
||||||
|
"true",
|
||||||
|
"-L",
|
||||||
|
*machine.nix_options,
|
||||||
|
"--flake",
|
||||||
|
f"{path}#{machine.name}",
|
||||||
]
|
]
|
||||||
|
|
||||||
if build_host:
|
become_root = True
|
||||||
become_root = False
|
|
||||||
nix_options += ["--target-host", target_host.target]
|
|
||||||
|
|
||||||
if target_host.user != "root":
|
if machine._class_ == "nixos":
|
||||||
nix_options += ["--use-remote-sudo"]
|
nix_options += [
|
||||||
switch_cmd = ["nixos-rebuild", "switch", *nix_options]
|
"--fast",
|
||||||
elif machine._class_ == "darwin":
|
"--build-host",
|
||||||
# use absolute path to darwin-rebuild
|
"",
|
||||||
switch_cmd = [
|
]
|
||||||
"/run/current-system/sw/bin/darwin-rebuild",
|
|
||||||
"switch",
|
|
||||||
*nix_options,
|
|
||||||
]
|
|
||||||
|
|
||||||
remote_env = host.nix_ssh_env(control_master=False)
|
if build_host:
|
||||||
ret = host.run(
|
become_root = False
|
||||||
switch_cmd,
|
nix_options += ["--target-host", target_host.target]
|
||||||
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():
|
if target_host.user != "root":
|
||||||
return
|
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 become_root:
|
||||||
if ret.returncode != 0:
|
host = sudo_host
|
||||||
is_mobile = machine.deployment.get("nixosMobileWorkaround", False)
|
|
||||||
# if the machine is mobile, we retry to deploy with the mobile workaround method
|
remote_env = host.nix_ssh_env(control_master=False)
|
||||||
if is_mobile:
|
|
||||||
machine.info(
|
|
||||||
"Mobile machine detected, applying workaround deployment method"
|
|
||||||
)
|
|
||||||
ret = host.run(
|
ret = host.run(
|
||||||
["nixos--rebuild", "test", *nix_options] if is_mobile else switch_cmd,
|
switch_cmd,
|
||||||
RunOpts(
|
RunOpts(
|
||||||
|
check=False,
|
||||||
log=Log.BOTH,
|
log=Log.BOTH,
|
||||||
msg_color=MsgColor(stderr=AnsiColor.DEFAULT),
|
msg_color=MsgColor(stderr=AnsiColor.DEFAULT),
|
||||||
needs_user_terminal=True,
|
needs_user_terminal=True,
|
||||||
),
|
),
|
||||||
extra_env=remote_env,
|
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:
|
def deploy_machines(machines: list[Machine]) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -88,8 +88,7 @@ def ssh_shell_from_deploy(
|
|||||||
deploy_info: DeployInfo, runtime: AsyncRuntime, host_key_check: HostKeyCheck
|
deploy_info: DeployInfo, runtime: AsyncRuntime, host_key_check: HostKeyCheck
|
||||||
) -> None:
|
) -> None:
|
||||||
if host := find_reachable_host(deploy_info, host_key_check):
|
if host := find_reachable_host(deploy_info, host_key_check):
|
||||||
with host.ssh_control_master() as ssh:
|
host.interactive_ssh()
|
||||||
ssh.interactive_ssh()
|
|
||||||
else:
|
else:
|
||||||
log.info("Could not reach host via clearnet 'addrs'")
|
log.info("Could not reach host via clearnet 'addrs'")
|
||||||
log.info(f"Trying to reach host via tor '{deploy_info.tor}'")
|
log.info(f"Trying to reach host via tor '{deploy_info.tor}'")
|
||||||
|
|||||||
@@ -98,8 +98,8 @@ def upload(
|
|||||||
raise ClanError(msg)
|
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.
|
# 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:
|
with tar_path.open("rb") as f:
|
||||||
ssh.run(
|
host.run(
|
||||||
[
|
[
|
||||||
"bash",
|
"bash",
|
||||||
"-c",
|
"-c",
|
||||||
@@ -114,5 +114,4 @@ def upload(
|
|||||||
prefix=host.command_prefix,
|
prefix=host.command_prefix,
|
||||||
needs_user_terminal=True,
|
needs_user_terminal=True,
|
||||||
),
|
),
|
||||||
become_root=True,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ exec {bash} -l "${{@}}"
|
|||||||
|
|
||||||
fake_sudo.write_text(
|
fake_sudo.write_text(
|
||||||
f"""#!{bash}
|
f"""#!{bash}
|
||||||
|
shift
|
||||||
exec "${{@}}"
|
exec "${{@}}"
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from clan_lib.ssh.remote import Remote
|
|||||||
|
|
||||||
@pytest.mark.with_core
|
@pytest.mark.with_core
|
||||||
def test_upload_single_file(
|
def test_upload_single_file(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
temporary_home: Path,
|
temporary_home: Path,
|
||||||
hosts: list[Remote],
|
hosts: list[Remote],
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -16,8 +15,8 @@ def test_upload_single_file(
|
|||||||
src_file = temporary_home / "test.txt"
|
src_file = temporary_home / "test.txt"
|
||||||
src_file.write_text("test")
|
src_file.write_text("test")
|
||||||
dest_file = temporary_home / "test_dest.txt"
|
dest_file = temporary_home / "test_dest.txt"
|
||||||
|
with host.ssh_control_master() as host:
|
||||||
upload(host, src_file, dest_file)
|
upload(host, src_file, dest_file)
|
||||||
|
|
||||||
assert dest_file.exists()
|
assert dest_file.exists()
|
||||||
assert dest_file.read_text() == "test"
|
assert dest_file.read_text() == "test"
|
||||||
|
|||||||
@@ -149,15 +149,14 @@ class SecretStore(StoreBase):
|
|||||||
|
|
||||||
def needs_upload(self, host: Remote) -> bool:
|
def needs_upload(self, host: Remote) -> bool:
|
||||||
local_hash = self.generate_hash()
|
local_hash = self.generate_hash()
|
||||||
with host.ssh_control_master() as ssh:
|
remote_hash = host.run(
|
||||||
remote_hash = ssh.run(
|
# TODO get the path to the secrets from the machine
|
||||||
# TODO get the path to the secrets from the machine
|
[
|
||||||
[
|
"cat",
|
||||||
"cat",
|
f"{self.machine.deployment['password-store']['secretLocation']}/.{self._store_backend}_info",
|
||||||
f"{self.machine.deployment['password-store']['secretLocation']}/.{self._store_backend}_info",
|
],
|
||||||
],
|
RunOpts(log=Log.STDERR, check=False),
|
||||||
RunOpts(log=Log.STDERR, check=False),
|
).stdout.strip()
|
||||||
).stdout.strip()
|
|
||||||
|
|
||||||
if not remote_hash:
|
if not remote_hash:
|
||||||
print("remote hash is empty")
|
print("remote hash is empty")
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ def upload_command(args: argparse.Namespace) -> None:
|
|||||||
populate_secret_vars(machine, directory)
|
populate_secret_vars(machine, directory)
|
||||||
return
|
return
|
||||||
|
|
||||||
host = machine.target_host()
|
with machine.target_host().ssh_control_master() as host:
|
||||||
upload_secret_vars(machine, host)
|
upload_secret_vars(machine, host)
|
||||||
|
|
||||||
|
|
||||||
def register_upload_parser(parser: argparse.ArgumentParser) -> None:
|
def register_upload_parser(parser: argparse.ArgumentParser) -> None:
|
||||||
|
|||||||
@@ -17,11 +17,10 @@ def list_provider(machine: Machine, host: Remote, provider: str) -> list[Backup]
|
|||||||
results = []
|
results = []
|
||||||
backup_metadata = machine.eval_nix("config.clan.core.backups")
|
backup_metadata = machine.eval_nix("config.clan.core.backups")
|
||||||
list_command = backup_metadata["providers"][provider]["list"]
|
list_command = backup_metadata["providers"][provider]["list"]
|
||||||
with host.ssh_control_master() as ssh:
|
proc = host.run(
|
||||||
proc = ssh.run(
|
[list_command],
|
||||||
[list_command],
|
RunOpts(log=Log.NONE, check=False),
|
||||||
RunOpts(log=Log.NONE, check=False),
|
)
|
||||||
)
|
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
# TODO this should be a warning, only raise exception if no providers succeed
|
# TODO this should be a warning, only raise exception if no providers succeed
|
||||||
msg = f"Failed to list backups for provider {provider}:"
|
msg = f"Failed to list backups for provider {provider}:"
|
||||||
@@ -44,12 +43,12 @@ def list_provider(machine: Machine, host: Remote, provider: str) -> list[Backup]
|
|||||||
def list_backups(machine: Machine, provider: str | None = None) -> list[Backup]:
|
def list_backups(machine: Machine, provider: str | None = None) -> list[Backup]:
|
||||||
backup_metadata = machine.eval_nix("config.clan.core.backups")
|
backup_metadata = machine.eval_nix("config.clan.core.backups")
|
||||||
results = []
|
results = []
|
||||||
host = machine.target_host()
|
with machine.target_host().ssh_control_master() as host:
|
||||||
if provider is None:
|
if provider is None:
|
||||||
for _provider in backup_metadata["providers"]:
|
for _provider in backup_metadata["providers"]:
|
||||||
results += list_provider(machine, host, _provider)
|
results += list_provider(machine, host, _provider)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
results += list_provider(machine, host, provider)
|
results += list_provider(machine, host, provider)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|||||||
@@ -58,8 +58,7 @@ def restore_backup(
|
|||||||
service: str | None = None,
|
service: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
errors = []
|
errors = []
|
||||||
host = machine.target_host()
|
with machine.target_host().ssh_control_master() as host:
|
||||||
with host.ssh_control_master():
|
|
||||||
if service is None:
|
if service is None:
|
||||||
backup_folders = machine.eval_nix("config.clan.core.state")
|
backup_folders = machine.eval_nix("config.clan.core.state")
|
||||||
for _service in backup_folders:
|
for _service in backup_folders:
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
pytest_plugins = [
|
pytest_plugins = [
|
||||||
"clan_cli.tests.fixtures_flakes",
|
"clan_cli.tests.fixtures_flakes",
|
||||||
|
"clan_cli.tests.hosts",
|
||||||
|
"clan_cli.tests.sshd",
|
||||||
|
"clan_cli.tests.runtime",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
[
|
[
|
||||||
"age",
|
"age",
|
||||||
|
"age-plugin-1p",
|
||||||
"age-plugin-fido2-hmac",
|
"age-plugin-fido2-hmac",
|
||||||
"age-plugin-ledger",
|
"age-plugin-ledger",
|
||||||
"age-plugin-se",
|
"age-plugin-se",
|
||||||
"age-plugin-sss",
|
"age-plugin-sss",
|
||||||
"age-plugin-tpm",
|
"age-plugin-tpm",
|
||||||
"age-plugin-yubikey",
|
"age-plugin-yubikey",
|
||||||
"age-plugin-1p",
|
|
||||||
"avahi",
|
"avahi",
|
||||||
"bash",
|
"bash",
|
||||||
"bubblewrap",
|
"bubblewrap",
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
"e2fsprogs",
|
"e2fsprogs",
|
||||||
"git",
|
"git",
|
||||||
"gnupg",
|
"gnupg",
|
||||||
|
"dialog",
|
||||||
"mypy",
|
"mypy",
|
||||||
"netcat",
|
"netcat",
|
||||||
"nix",
|
"nix",
|
||||||
@@ -30,5 +31,6 @@
|
|||||||
"virt-viewer",
|
"virt-viewer",
|
||||||
"virtiofsd",
|
"virtiofsd",
|
||||||
"waypipe",
|
"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.errors import ClanError # Assuming these are available
|
||||||
from clan_lib.nix import nix_shell
|
from clan_lib.nix import nix_shell
|
||||||
from clan_lib.ssh.parse import parse_deployment_address
|
from clan_lib.ssh.parse import parse_deployment_address
|
||||||
|
from clan_lib.ssh.sudo_askpass_proxy import SudoAskpassProxy
|
||||||
|
|
||||||
cmdlog = logging.getLogger(__name__)
|
cmdlog = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -39,7 +40,9 @@ class Remote:
|
|||||||
verbose_ssh: bool = False
|
verbose_ssh: bool = False
|
||||||
ssh_options: dict[str, str] = field(default_factory=dict)
|
ssh_options: dict[str, str] = field(default_factory=dict)
|
||||||
tor_socks: bool = False
|
tor_socks: bool = False
|
||||||
|
|
||||||
_control_path_dir: Path | None = None
|
_control_path_dir: Path | None = None
|
||||||
|
_askpass_path: str | None = None
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return self.target
|
return self.target
|
||||||
@@ -48,25 +51,6 @@ class Remote:
|
|||||||
def target(self) -> str:
|
def target(self) -> str:
|
||||||
return f"{self.user}@{self.address}"
|
return f"{self.user}@{self.address}"
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def with_user(cls, host: "Remote", user: str) -> "Remote":
|
|
||||||
"""
|
|
||||||
Return a new Remote object with the specified user.
|
|
||||||
"""
|
|
||||||
return cls(
|
|
||||||
address=host.address,
|
|
||||||
user=user,
|
|
||||||
command_prefix=host.command_prefix,
|
|
||||||
port=host.port,
|
|
||||||
private_key=host.private_key,
|
|
||||||
password=host.password,
|
|
||||||
forward_agent=host.forward_agent,
|
|
||||||
host_key_check=host.host_key_check,
|
|
||||||
verbose_ssh=host.verbose_ssh,
|
|
||||||
ssh_options=host.ssh_options,
|
|
||||||
tor_socks=host.tor_socks,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_deployment_address(
|
def from_deployment_address(
|
||||||
cls,
|
cls,
|
||||||
@@ -126,28 +110,78 @@ class Remote:
|
|||||||
"/var/folders/"
|
"/var/folders/"
|
||||||
):
|
):
|
||||||
directory = "/tmp/"
|
directory = "/tmp/"
|
||||||
temp_dir = TemporaryDirectory(prefix="clan-ssh", dir=directory)
|
with TemporaryDirectory(prefix="clan-ssh", dir=directory) as temp_dir:
|
||||||
yield Remote(
|
yield Remote(
|
||||||
address=self.address,
|
address=self.address,
|
||||||
user=self.user,
|
user=self.user,
|
||||||
command_prefix=self.command_prefix,
|
command_prefix=self.command_prefix,
|
||||||
port=self.port,
|
port=self.port,
|
||||||
private_key=self.private_key,
|
private_key=self.private_key,
|
||||||
password=self.password,
|
password=self.password,
|
||||||
forward_agent=self.forward_agent,
|
forward_agent=self.forward_agent,
|
||||||
host_key_check=self.host_key_check,
|
host_key_check=self.host_key_check,
|
||||||
verbose_ssh=self.verbose_ssh,
|
verbose_ssh=self.verbose_ssh,
|
||||||
ssh_options=self.ssh_options,
|
ssh_options=self.ssh_options,
|
||||||
tor_socks=self.tor_socks,
|
tor_socks=self.tor_socks,
|
||||||
_control_path_dir=Path(temp_dir.name),
|
_control_path_dir=Path(temp_dir),
|
||||||
)
|
_askpass_path=self._askpass_path,
|
||||||
temp_dir.cleanup()
|
)
|
||||||
|
|
||||||
|
@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(
|
def run(
|
||||||
self,
|
self,
|
||||||
cmd: list[str],
|
cmd: list[str],
|
||||||
opts: RunOpts | None = None,
|
opts: RunOpts | None = None,
|
||||||
become_root: bool = False,
|
|
||||||
extra_env: dict[str, str] | None = None,
|
extra_env: dict[str, str] | None = None,
|
||||||
tty: bool = False,
|
tty: bool = False,
|
||||||
verbose_ssh: bool = False,
|
verbose_ssh: bool = False,
|
||||||
@@ -163,9 +197,14 @@ class Remote:
|
|||||||
if opts is None:
|
if opts is None:
|
||||||
opts = RunOpts()
|
opts = RunOpts()
|
||||||
|
|
||||||
sudo = ""
|
sudo = []
|
||||||
if become_root and self.user != "root":
|
if self._askpass_path is not None:
|
||||||
sudo = "sudo -- "
|
sudo = [
|
||||||
|
f"SUDO_ASKPASS={shlex.quote(self._askpass_path)}",
|
||||||
|
"sudo",
|
||||||
|
"-A",
|
||||||
|
"--",
|
||||||
|
]
|
||||||
|
|
||||||
env_vars = []
|
env_vars = []
|
||||||
for k, v in extra_env.items():
|
for k, v in extra_env.items():
|
||||||
@@ -201,14 +240,20 @@ class Remote:
|
|||||||
else:
|
else:
|
||||||
bash_cmd += 'exec "$@"'
|
bash_cmd += 'exec "$@"'
|
||||||
|
|
||||||
ssh_cmd_list = self.ssh_cmd(
|
ssh_cmd = [
|
||||||
verbose_ssh=verbose_ssh, tty=tty, control_master=control_master
|
*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(
|
def nix_ssh_env(
|
||||||
self,
|
self,
|
||||||
@@ -230,7 +275,7 @@ class Remote:
|
|||||||
if self._control_path_dir is None and not control_master:
|
if self._control_path_dir is None and not control_master:
|
||||||
effective_control_path_dir = None
|
effective_control_path_dir = None
|
||||||
elif self._control_path_dir is None and control_master:
|
elif self._control_path_dir is None and control_master:
|
||||||
msg = "Control path directory is not set. Please with Remote.ssh_control_master() as ctx to set it."
|
msg = "Bug! Control path directory is not set. Please use Remote.ssh_control_master() or set control_master to false."
|
||||||
raise ClanError(msg)
|
raise ClanError(msg)
|
||||||
|
|
||||||
ssh_opts = ["-A"] if self.forward_agent else []
|
ssh_opts = ["-A"] if self.forward_agent else []
|
||||||
@@ -279,7 +324,7 @@ class Remote:
|
|||||||
return nix_shell(packages, cmd)
|
return nix_shell(packages, cmd)
|
||||||
|
|
||||||
def interactive_ssh(self) -> None:
|
def interactive_ssh(self) -> None:
|
||||||
cmd_list = self.ssh_cmd(tty=True)
|
cmd_list = self.ssh_cmd(tty=True, control_master=False)
|
||||||
subprocess.run(cmd_list)
|
subprocess.run(cmd_list)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ from typing import Any, NamedTuple
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from clan_cli.ssh.host_key import HostKeyCheck
|
from clan_cli.ssh.host_key import HostKeyCheck
|
||||||
|
|
||||||
from clan_lib.async_run import AsyncRuntime
|
from clan_lib.async_run import AsyncRuntime
|
||||||
from clan_lib.cmd import ClanCmdTimeoutError, Log, RunOpts
|
from clan_lib.cmd import ClanCmdTimeoutError, Log, RunOpts
|
||||||
from clan_lib.errors import ClanError, CmdOut
|
from clan_lib.errors import ClanError, CmdOut
|
||||||
from clan_lib.ssh.remote import Remote
|
from clan_lib.ssh.remote import Remote
|
||||||
|
from clan_lib.ssh.sudo_askpass_proxy import SudoAskpassProxy
|
||||||
|
|
||||||
if sys.platform == "darwin":
|
if sys.platform == "darwin":
|
||||||
pytest.skip("preload doesn't work on darwin", allow_module_level=True)
|
pytest.skip("preload doesn't work on darwin", allow_module_level=True)
|
||||||
@@ -178,6 +180,23 @@ def test_run_no_shell(hosts: list[Remote], runtime: AsyncRuntime) -> None:
|
|||||||
assert proc.wait().result.stdout == "hello\n"
|
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 test_run_function(hosts: list[Remote], runtime: AsyncRuntime) -> None:
|
||||||
def some_func(h: Remote) -> bool:
|
def some_func(h: Remote) -> bool:
|
||||||
with h.ssh_control_master() as ssh:
|
with h.ssh_control_master() as ssh:
|
||||||
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_lib = [
|
||||||
"clan_core_templates/**/*",
|
"clan_core_templates/**/*",
|
||||||
"**/allowed-packages.json",
|
"**/allowed-packages.json",
|
||||||
|
"ssh/*.sh",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
|
|||||||
Reference in New Issue
Block a user