From 8ce860f0d36065cac91718b0636b2705b72bd649 Mon Sep 17 00:00:00 2001 From: lassulus Date: Mon, 9 Jun 2025 11:28:15 +0200 Subject: [PATCH] Revert "clan-cli: Use Remote class in DeployInfo, add tests for qrcode parser and json parser" This reverts commit b1ef5f00bf4740959cedaaf0b4585abcb01e5447. --- clanServices/flake-module.nix | 33 +-- .../guides/getting-started/add-machines.md | 8 +- docs/site/guides/mesh-vpn.md | 5 + nixosModules/clanCore/facts/default.nix | 4 + pkgs/clan-cli/clan_cli/machines/install.py | 10 +- pkgs/clan-cli/clan_cli/machines/update.py | 2 +- pkgs/clan-cli/clan_cli/ssh/deploy_info.py | 193 ++++++++---------- .../clan-cli/clan_cli/ssh/test_deploy_info.py | 70 ------- pkgs/clan-cli/clan_cli/ssh/tor.py | 40 ++-- .../tests/data/clan_installer_qrcode.png | Bin 10492 -> 0 bytes pkgs/clan-cli/clan_lib/ssh/parse.py | 8 - pkgs/clan-cli/clan_lib/ssh/remote.py | 79 +------ 12 files changed, 151 insertions(+), 301 deletions(-) delete mode 100644 pkgs/clan-cli/clan_cli/ssh/test_deploy_info.py delete mode 100644 pkgs/clan-cli/clan_cli/tests/data/clan_installer_qrcode.png diff --git a/clanServices/flake-module.nix b/clanServices/flake-module.nix index af54d23b0..97a4c9115 100644 --- a/clanServices/flake-module.nix +++ b/clanServices/flake-module.nix @@ -1,18 +1,21 @@ { ... }: { - imports = [ - ./admin/flake-module.nix - ./deltachat/flake-module.nix - ./ergochat/flake-module.nix - ./garage/flake-module.nix - ./heisenbridge/flake-module.nix - ./importer/flake-module.nix - ./localsend/flake-module.nix - ./mycelium/flake-module.nix - ./auto-upgrade/flake-module.nix - ./hello-world/flake-module.nix - ./wifi/flake-module.nix - ./borgbackup/flake-module.nix - ./zerotier/flake-module.nix - ]; + imports = + let + # Get all subdirectories in the current directory + dirContents = builtins.readDir ./.; + + # Filter to include only directories that have a flake-module.nix file + # and exclude special directories like 'result' + validModuleDirs = builtins.filter ( + name: + name != "result" + && dirContents.${name} == "directory" + && builtins.pathExists (./. + "/${name}/flake-module.nix") + ) (builtins.attrNames dirContents); + + # Create import paths for each valid directory + imports = map (name: ./. + "/${name}/flake-module.nix") validModuleDirs; + in + imports; } diff --git a/docs/site/guides/getting-started/add-machines.md b/docs/site/guides/getting-started/add-machines.md index b7ed78fc4..2d46f3f60 100644 --- a/docs/site/guides/getting-started/add-machines.md +++ b/docs/site/guides/getting-started/add-machines.md @@ -56,12 +56,12 @@ In the `flake.nix` file: Adding or configuring a new machine requires two simple steps: ??? Machine Requirements - - RAM > 2GB + - RAM > 2GB ???+ Note "Cloud Machines" - NixOS can cause strange issues when booting in certain cloud environments. - - - If on Linode: Make sure that the system uses Direct Disk boot kernel (found in the configuration pannel) + NixOS can cause strange issues when booting in certain cloud environments. + + - If on Linode: Make sure that the system uses Direct Disk boot kernel (found in the configuration pannel) ### Step 1. Identify Target Disk-ID diff --git a/docs/site/guides/mesh-vpn.md b/docs/site/guides/mesh-vpn.md index 9d680f415..9e25448bf 100644 --- a/docs/site/guides/mesh-vpn.md +++ b/docs/site/guides/mesh-vpn.md @@ -35,6 +35,11 @@ This guide shows you how to configure `zerotier` either through `NixOS Options` - The `new_machine` machine, which is the machine we want to add to the vpn network. ## 2. Configure the Inventory + + Note: consider picking a more descriptive name for the VPN than "default". + It will be added as an altname for the Zerotier virtual ethernet interface, and + will also be visible in the Zerotier app. + ```nix clan.inventory = { services.zerotier.default = { diff --git a/nixosModules/clanCore/facts/default.nix b/nixosModules/clanCore/facts/default.nix index 98215c927..96ee73e19 100644 --- a/nixosModules/clanCore/facts/default.nix +++ b/nixosModules/clanCore/facts/default.nix @@ -5,6 +5,10 @@ ... }: { + config.warnings = lib.optionals (config.clan.core.facts.services != { }) [ + "Facts are deprecated, please migrate them to vars instead, see: https://docs.clan.lol/guides/migrations/migration-facts-vars/" + ]; + options.clan.core.facts = { secretStore = lib.mkOption { type = lib.types.enum [ diff --git a/pkgs/clan-cli/clan_cli/machines/install.py b/pkgs/clan-cli/clan_cli/machines/install.py index 8e5226903..508edda01 100644 --- a/pkgs/clan-cli/clan_cli/machines/install.py +++ b/pkgs/clan-cli/clan_cli/machines/install.py @@ -156,7 +156,7 @@ def install_machine(opts: InstallOptions) -> None: def install_command(args: argparse.Namespace) -> None: - HostKeyCheck.from_str(args.host_key_check) + host_key_check = HostKeyCheck.from_str(args.host_key_check) try: # Only if the caller did not specify a target_host via args.target_host # Find a suitable target_host that is reachable @@ -165,17 +165,17 @@ def install_command(args: argparse.Namespace) -> None: use_tor = False if deploy_info and not args.target_host: - host = find_reachable_host(deploy_info) + host = find_reachable_host(deploy_info, host_key_check) if host is None: use_tor = True - target_host = deploy_info.tor.target + target_host = f"root@{deploy_info.tor}" else: target_host = host.target if args.password: password = args.password - elif deploy_info and deploy_info.addrs[0].password: - password = deploy_info.addrs[0].password + elif deploy_info and deploy_info.pwd: + password = deploy_info.pwd else: password = None diff --git a/pkgs/clan-cli/clan_cli/machines/update.py b/pkgs/clan-cli/clan_cli/machines/update.py index ee8071757..06b341f37 100644 --- a/pkgs/clan-cli/clan_cli/machines/update.py +++ b/pkgs/clan-cli/clan_cli/machines/update.py @@ -121,7 +121,7 @@ def deploy_machine(machine: Machine) -> None: upload_secrets(machine, sudo_host) upload_secret_vars(machine, sudo_host) - path = upload_sources(machine, sudo_host) + path = upload_sources(machine, host) nix_options = [ "--show-trace", diff --git a/pkgs/clan-cli/clan_cli/ssh/deploy_info.py b/pkgs/clan-cli/clan_cli/ssh/deploy_info.py index fdb94945f..0a99988d0 100644 --- a/pkgs/clan-cli/clan_cli/ssh/deploy_info.py +++ b/pkgs/clan-cli/clan_cli/ssh/deploy_info.py @@ -1,19 +1,24 @@ import argparse +import ipaddress import json import logging from dataclasses import dataclass from pathlib import Path from typing import Any +from clan_lib.async_run import AsyncRuntime from clan_lib.cmd import run from clan_lib.errors import ClanError +from clan_lib.machines.machines import Machine from clan_lib.nix import nix_shell -from clan_lib.ssh.remote import HostKeyCheck, Remote +from clan_lib.ssh.parse import parse_deployment_address +from clan_lib.ssh.remote import Remote, is_ssh_reachable from clan_cli.completions import ( add_dynamic_completer, complete_machines, ) +from clan_cli.ssh.host_key import HostKeyCheck from clan_cli.ssh.tor import TorTarget, spawn_tor, ssh_tor_reachable log = logging.getLogger(__name__) @@ -21,148 +26,113 @@ log = logging.getLogger(__name__) @dataclass class DeployInfo: - addrs: list[Remote] - - @property - def tor(self) -> Remote: - """Return a list of Remote objects that are configured for Tor.""" - addrs = [addr for addr in self.addrs if addr.tor_socks] - - if not addrs: - msg = "No tor address provided, please provide a tor address." - raise ClanError(msg) - - if len(addrs) > 1: - msg = "Multiple tor addresses provided, expected only one." - raise ClanError(msg) - return addrs[0] + addrs: list[str] + tor: str | None = None + pwd: str | None = None @staticmethod - def from_hostnames( - hostname: list[str], host_key_check: HostKeyCheck - ) -> "DeployInfo": - remotes = [] - for host in hostname: - if not host: - msg = "Hostname cannot be empty." - raise ClanError(msg) - remote = Remote.from_deployment_address( - machine_name="clan-installer", - address=host, - host_key_check=host_key_check, - ) - remotes.append(remote) - return DeployInfo(addrs=remotes) + def from_hostname(hostname: str, args: argparse.Namespace) -> "DeployInfo": + m = Machine(hostname, flake=args.flake) + return DeployInfo(addrs=[m.target_host_address]) @staticmethod - def from_json(data: dict[str, Any], host_key_check: HostKeyCheck) -> "DeployInfo": - addrs = [] - password = data.get("pass") - - for addr in data.get("addrs", []): - if isinstance(addr, str): - remote = Remote.from_deployment_address( - machine_name="clan-installer", - address=addr, - host_key_check=host_key_check, - password=password, - ) - addrs.append(remote) - else: - msg = f"Invalid address format: {addr}" - raise ClanError(msg) - if tor_addr := data.get("tor"): - remote = Remote.from_deployment_address( - machine_name="clan-installer", - address=tor_addr, - host_key_check=host_key_check, - password=password, - tor_socks=True, - ) - addrs.append(remote) - - return DeployInfo(addrs=addrs) - - @staticmethod - def from_qr_code(picture_file: Path, host_key_check: HostKeyCheck) -> "DeployInfo": - cmd = nix_shell( - ["zbar"], - [ - "zbarimg", - "--quiet", - "--raw", - str(picture_file), - ], + def from_json(data: dict[str, Any]) -> "DeployInfo": + return DeployInfo( + tor=data.get("tor"), pwd=data.get("pass"), addrs=data.get("addrs", []) ) - res = run(cmd) - data = res.stdout.strip() - return DeployInfo.from_json(json.loads(data), host_key_check=host_key_check) -def find_reachable_host(deploy_info: DeployInfo) -> Remote | None: +def is_ipv6(ip: str) -> bool: + try: + return isinstance(ipaddress.ip_address(ip), ipaddress.IPv6Address) + except ValueError: + return False + + +def find_reachable_host( + deploy_info: DeployInfo, host_key_check: HostKeyCheck +) -> Remote | None: host = None for addr in deploy_info.addrs: - if addr.is_ssh_reachable(): - host = addr + host_addr = f"[{addr}]" if is_ipv6(addr) else addr + host_ = parse_deployment_address( + machine_name="uknown", address=host_addr, host_key_check=host_key_check + ) + if is_ssh_reachable(host_): + host = host_ break return host -def ssh_shell_from_deploy(deploy_info: DeployInfo) -> None: - if host := find_reachable_host(deploy_info): +def qrcode_scan(picture_file: Path) -> str: + cmd = nix_shell( + ["zbar"], + [ + "zbarimg", + "--quiet", + "--raw", + str(picture_file), + ], + ) + res = run(cmd) + return res.stdout.strip() + + +def parse_qr_code(picture_file: Path) -> DeployInfo: + data = qrcode_scan(picture_file) + return DeployInfo.from_json(json.loads(data)) + + +def ssh_shell_from_deploy( + deploy_info: DeployInfo, runtime: AsyncRuntime, host_key_check: HostKeyCheck +) -> None: + if host := find_reachable_host(deploy_info, host_key_check): host.interactive_ssh() - return - - log.info("Could not reach host via clearnet 'addrs'") - log.info(f"Trying to reach host via tor '{deploy_info}'") - - tor_addrs = [addr for addr in deploy_info.addrs if addr.tor_socks] - if not tor_addrs: - msg = "No tor address provided, please provide a tor address." - raise ClanError(msg) - - with spawn_tor(): - for tor_addr in tor_addrs: - log.info(f"Trying to reach host via tor address: {tor_addr}") - if ssh_tor_reachable( - TorTarget( - onion=tor_addr.address, port=tor_addr.port if tor_addr.port else 22 - ) - ): - log.info( - "Host reachable via tor address, starting interactive ssh session." - ) - tor_addr.interactive_ssh() - return - - log.error("Could not reach host via tor address.") + else: + log.info("Could not reach host via clearnet 'addrs'") + log.info(f"Trying to reach host via tor '{deploy_info.tor}'") + spawn_tor(runtime) + if not deploy_info.tor: + msg = "No tor address provided, please provide a tor address." + raise ClanError(msg) + if ssh_tor_reachable(TorTarget(onion=deploy_info.tor, port=22)): + host = Remote( + address=deploy_info.tor, + user="root", + password=deploy_info.pwd, + tor_socks=True, + command_prefix="tor", + ) + else: + msg = "Could not reach host via tor either." + raise ClanError(msg) def ssh_command_parse(args: argparse.Namespace) -> DeployInfo | None: - host_key_check = HostKeyCheck.from_str(args.host_key_check) if args.json: json_file = Path(args.json) if json_file.is_file(): data = json.loads(json_file.read_text()) - return DeployInfo.from_json(data, host_key_check) + return DeployInfo.from_json(data) data = json.loads(args.json) - return DeployInfo.from_json(data, host_key_check) + return DeployInfo.from_json(data) if args.png: - return DeployInfo.from_qr_code(Path(args.png), host_key_check) - + return parse_qr_code(Path(args.png)) if hasattr(args, "machines"): - return DeployInfo.from_hostnames(args.machines, host_key_check) + return DeployInfo.from_hostname(args.machines[0], args) return None def ssh_command(args: argparse.Namespace) -> None: + host_key_check = HostKeyCheck.from_str(args.host_key_check) deploy_info = ssh_command_parse(args) if not deploy_info: msg = "No MACHINE, --json or --png data provided" raise ClanError(msg) - ssh_shell_from_deploy(deploy_info) + with AsyncRuntime() as runtime: + ssh_shell_from_deploy(deploy_info, runtime, host_key_check) def register_parser(parser: argparse.ArgumentParser) -> None: @@ -187,10 +157,13 @@ def register_parser(parser: argparse.ArgumentParser) -> None: "--png", help="specify the json file for ssh data as the qrcode image (generated by starting the clan installer)", ) + parser.add_argument( + "--ssh_args", nargs=argparse.REMAINDER, help="additional ssh arguments" + ) parser.add_argument( "--host-key-check", choices=["strict", "ask", "tofu", "none"], - default="tofu", + default="ask", help="Host key (.ssh/known_hosts) check mode.", ) parser.set_defaults(func=ssh_command) diff --git a/pkgs/clan-cli/clan_cli/ssh/test_deploy_info.py b/pkgs/clan-cli/clan_cli/ssh/test_deploy_info.py deleted file mode 100644 index 81fb02694..000000000 --- a/pkgs/clan-cli/clan_cli/ssh/test_deploy_info.py +++ /dev/null @@ -1,70 +0,0 @@ -import json -from pathlib import Path - -import pytest -from clan_lib.ssh.remote import HostKeyCheck, Remote - -from clan_cli.ssh.deploy_info import DeployInfo, find_reachable_host - - -def test_qrcode_scan(test_root: Path) -> None: - # Create a dummy QR code image file - picture_file = test_root / "data" / "clan_installer_qrcode.png" - - # Call the qrcode_scan function - deploy_info = DeployInfo.from_qr_code(picture_file, HostKeyCheck.NONE) - - host = deploy_info.addrs[0] - assert host.address == "192.168.122.86" - assert host.user == "root" - assert host.password == "scabbed-defender-headlock" - - tor_host = deploy_info.addrs[1] - assert ( - tor_host.address - == "qjeerm4r6t55hcfum4pinnvscn5njlw2g3k7ilqfuu7cdt3ahaxhsbid.onion" - ) - assert tor_host.tor_socks is True - assert tor_host.password == "scabbed-defender-headlock" - assert tor_host.user == "root" - assert ( - tor_host.address - == "qjeerm4r6t55hcfum4pinnvscn5njlw2g3k7ilqfuu7cdt3ahaxhsbid.onion" - ) - - -def test_from_json() -> None: - data = '{"pass":"scabbed-defender-headlock","tor":"qjeerm4r6t55hcfum4pinnvscn5njlw2g3k7ilqfuu7cdt3ahaxhsbid.onion","addrs":["192.168.122.86"]}' - deploy_info = DeployInfo.from_json(json.loads(data), HostKeyCheck.NONE) - - host = deploy_info.addrs[0] - assert host.password == "scabbed-defender-headlock" - assert host.address == "192.168.122.86" - - tor_host = deploy_info.addrs[1] - assert ( - tor_host.address - == "qjeerm4r6t55hcfum4pinnvscn5njlw2g3k7ilqfuu7cdt3ahaxhsbid.onion" - ) - assert tor_host.tor_socks is True - assert tor_host.password == "scabbed-defender-headlock" - assert tor_host.user == "root" - assert ( - tor_host.address - == "qjeerm4r6t55hcfum4pinnvscn5njlw2g3k7ilqfuu7cdt3ahaxhsbid.onion" - ) - - -@pytest.mark.with_core -def test_find_reachable_host(hosts: list[Remote]) -> None: - host = hosts[0] - deploy_info = DeployInfo.from_hostnames( - ["172.19.1.2", host.ssh_url()], HostKeyCheck.NONE - ) - - assert deploy_info.addrs[0].address == "172.19.1.2" - - remote = find_reachable_host(deploy_info=deploy_info) - - assert remote is not None - assert remote.ssh_url() == host.ssh_url() diff --git a/pkgs/clan-cli/clan_cli/ssh/tor.py b/pkgs/clan-cli/clan_cli/ssh/tor.py index 992e731f5..3a87f1d11 100755 --- a/pkgs/clan-cli/clan_cli/ssh/tor.py +++ b/pkgs/clan-cli/clan_cli/ssh/tor.py @@ -5,11 +5,10 @@ import logging import socket import struct import time -from collections.abc import Iterator -from contextlib import contextmanager from dataclasses import dataclass -from subprocess import Popen +from clan_lib.async_run import AsyncRuntime +from clan_lib.cmd import Log, RunOpts, run from clan_lib.errors import TorConnectionError, TorSocksError from clan_lib.nix import nix_shell @@ -109,31 +108,32 @@ def is_tor_running() -> bool: return True -@contextmanager -def spawn_tor() -> Iterator[None]: +def spawn_tor(runtime: AsyncRuntime) -> None: """ Spawns a Tor process using `nix-shell` if Tor is not already running. """ + def start_tor() -> None: + """Starts Tor process using nix-shell.""" + cmd_args = ["tor", "--HardwareAccel", "1"] + packages = ["tor"] + cmd = nix_shell(packages, cmd_args) + runtime.async_run(None, run, cmd, RunOpts(log=Log.BOTH)) + log.debug("Attempting to start Tor") + # Check if Tor is already running if is_tor_running(): log.info("Tor is running") return - cmd_args = ["tor", "--HardwareAccel", "1"] - packages = ["tor"] - cmd = nix_shell(packages, cmd_args) - process = Popen(cmd) - try: - while not is_tor_running(): - log.debug("Waiting for Tor to start...") - time.sleep(0.2) - log.info("Tor is now running") - yield - finally: - log.info("Terminating Tor process...") - process.terminate() - process.wait() - log.info("Tor process terminated") + + # Attempt to start Tor + start_tor() + + # Continuously check if Tor has started + while not is_tor_running(): + log.debug("Waiting for Tor to start...") + time.sleep(0.2) + log.info("Tor is now running") def tor_online_test() -> bool: diff --git a/pkgs/clan-cli/clan_cli/tests/data/clan_installer_qrcode.png b/pkgs/clan-cli/clan_cli/tests/data/clan_installer_qrcode.png deleted file mode 100644 index b2550557b52b09466022a6fa1ec07cada659f34b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10492 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 F{|8+$jsgGx diff --git a/pkgs/clan-cli/clan_lib/ssh/parse.py b/pkgs/clan-cli/clan_lib/ssh/parse.py index 928763117..ccac18499 100644 --- a/pkgs/clan-cli/clan_lib/ssh/parse.py +++ b/pkgs/clan-cli/clan_lib/ssh/parse.py @@ -19,13 +19,7 @@ def parse_deployment_address( forward_agent: bool = True, meta: dict[str, Any] | None = None, private_key: Path | None = None, - password: str | None = None, - tor_socks: bool = False, ) -> "Remote": - if address.startswith("ssh://"): - # Strip the `ssh://` prefix if it exists - address = address[len("ssh://") :] - parts = address.split("?", maxsplit=1) endpoint, maybe_options = parts if len(parts) == 2 else (parts[0], "") @@ -72,10 +66,8 @@ def parse_deployment_address( user=user, port=port, private_key=private_key, - password=password, host_key_check=host_key_check, command_prefix=machine_name, forward_agent=forward_agent, ssh_options=options, - tor_socks=tor_socks, ) diff --git a/pkgs/clan-cli/clan_lib/ssh/remote.py b/pkgs/clan-cli/clan_lib/ssh/remote.py index 326a8ad50..6f9cc677e 100644 --- a/pkgs/clan-cli/clan_lib/ssh/remote.py +++ b/pkgs/clan-cli/clan_lib/ssh/remote.py @@ -1,5 +1,4 @@ # ruff: noqa: SLF001 -import ipaddress import logging import os import shlex @@ -48,12 +47,6 @@ class Remote: def __str__(self) -> str: return self.target - def is_ipv6(self) -> bool: - try: - return isinstance(ipaddress.ip_address(self.address), ipaddress.IPv6Address) - except ValueError: - return False - @property def target(self) -> str: return f"{self.user}@{self.address}" @@ -67,8 +60,6 @@ class Remote: host_key_check: HostKeyCheck, forward_agent: bool = True, private_key: Path | None = None, - password: str | None = None, - tor_socks: bool = False, ) -> "Remote": """ Parse a deployment address and return a Host object. @@ -80,8 +71,6 @@ class Remote: host_key_check=host_key_check, forward_agent=forward_agent, private_key=private_key, - password=password, - tor_socks=tor_socks, ) def run_local( @@ -308,18 +297,6 @@ class Remote: ) return ssh_opts - def ssh_url(self) -> str: - """ - Generates a standard SSH URL (ssh://[user@]host[:port]). - """ - url = "ssh://" - if self.user: - url += f"{self.user}@" - url += self.address - if self.port: - url += f":{self.port}" - return url - def ssh_cmd( self, verbose_ssh: bool = False, tty: bool = False, control_master: bool = True ) -> list[str]: @@ -349,52 +326,18 @@ class Remote: ] return nix_shell(packages, cmd) - def check_sshpass_errorcode(self, res: subprocess.CompletedProcess) -> None: - """ - Check the return code of the sshpass command and raise an error if it indicates a failure. - """ - if res.returncode == 0: - return - - match res.returncode: - case 1: - msg = "Invalid command line argument" - raise ClanError(msg) - case 2: - msg = "Conflicting arguments given" - raise ClanError(msg) - case 3: - msg = "General runtime error" - raise ClanError(msg) - case 4: - msg = "Unrecognized response from ssh (parse error)" - raise ClanError(msg) - case 5: - msg = "Invalid/incorrect password" - raise ClanError(msg) - case 6: - msg = "Host public key is unknown. sshpass exits without confirming the new key. Try using --host-key-heck none" - raise ClanError(msg) - case 7: - msg = "IP public key changed. sshpass exits without confirming the new key." - raise ClanError(msg) - case _: - msg = f"SSH command failed with return code {res.returncode}" - raise ClanError(msg) - def interactive_ssh(self) -> None: cmd_list = self.ssh_cmd(tty=True, control_master=False) - res = subprocess.run(cmd_list, check=False) + subprocess.run(cmd_list) - self.check_sshpass_errorcode(res) - def is_ssh_reachable(self) -> bool: - address_family = socket.AF_INET6 if ":" in self.address else socket.AF_INET - with socket.socket(address_family, socket.SOCK_STREAM) as sock: - sock.settimeout(2) - try: - sock.connect((self.address, self.port or 22)) - except OSError: - return False - else: - return True +def is_ssh_reachable(host: Remote) -> bool: + address_family = socket.AF_INET6 if ":" in host.address else socket.AF_INET + with socket.socket(address_family, socket.SOCK_STREAM) as sock: + sock.settimeout(2) + try: + sock.connect((host.address, host.port or 22)) + except OSError: + return False + else: + return True