diff --git a/pkgs/clan-cli/clan_cli/machines/install.py b/pkgs/clan-cli/clan_cli/machines/install.py index 6791ba425..99a5cc61e 100644 --- a/pkgs/clan-cli/clan_cli/machines/install.py +++ b/pkgs/clan-cli/clan_cli/machines/install.py @@ -35,7 +35,7 @@ def install_command(args: argparse.Namespace) -> None: use_tor = False if deploy_info: host = find_reachable_host(deploy_info) - if host is None or host.tor_socks: + if host is None or host.socks_port: use_tor = True target_host_str = deploy_info.tor.target else: @@ -74,7 +74,9 @@ def install_command(args: argparse.Namespace) -> None: target_host = target_host.override(password=password) if use_tor: - target_host = target_host.override(tor_socks=True) + target_host = target_host.override( + socks_port=9050, socks_wrapper=["torify"] + ) return run_machine_install( InstallOptions( diff --git a/pkgs/clan-cli/clan_cli/network/ping.py b/pkgs/clan-cli/clan_cli/network/ping.py index 942d25c5e..767a96f1d 100644 --- a/pkgs/clan-cli/clan_cli/network/ping.py +++ b/pkgs/clan-cli/clan_cli/network/ping.py @@ -27,28 +27,27 @@ def ping_command(args: argparse.Namespace) -> None: networks_to_check = sorted(networks.items(), key=lambda x: -x[1].priority) found = False - results = [] for net_name, network in networks_to_check: if machine in network.peers: found = True - # Check if network technology is running - if not network.is_running(): - results.append(f"{machine} ({net_name}): network not running") - continue - - # Check if peer is online - ping = network.ping(machine) - results.append(f"{machine} ({net_name}): {ping}") + with network.module.connection(network) as network: + log.info(f"Pinging '{machine}' in network '{net_name}' ...") + res = "" + # Check if peer is online + ping = network.ping(machine) + if ping is None: + res = "not reachable" + log.info(f"{machine} ({net_name}): {res}") + else: + res = f"reachable, ping: {ping:.2f} ms" + log.info(f"{machine} ({net_name}): {res}") + break if not found: msg = f"Machine '{machine}' not found in any network" raise ClanError(msg) - # Print all results - for result in results: - print(result) - def register_ping_parser(parser: argparse.ArgumentParser) -> None: parser.add_argument( diff --git a/pkgs/clan-cli/clan_cli/ssh/deploy_info.py b/pkgs/clan-cli/clan_cli/ssh/deploy_info.py index 9688a5c57..42f35fdb7 100644 --- a/pkgs/clan-cli/clan_cli/ssh/deploy_info.py +++ b/pkgs/clan-cli/clan_cli/ssh/deploy_info.py @@ -1,4 +1,5 @@ import argparse +import contextlib import json import logging import textwrap @@ -9,6 +10,7 @@ from typing import Any, get_args from clan_lib.cmd import run from clan_lib.errors import ClanError from clan_lib.machines.machines import Machine +from clan_lib.network.tor.lib import spawn_tor from clan_lib.nix import nix_shell from clan_lib.ssh.remote import HostKeyCheck, Remote @@ -16,7 +18,6 @@ from clan_cli.completions import ( add_dynamic_completer, complete_machines, ) -from clan_cli.ssh.tor import TorTarget, spawn_tor, ssh_tor_reachable log = logging.getLogger(__name__) @@ -27,15 +28,15 @@ class DeployInfo: @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] + """Return a list of Remote objects that are configured for SOCKS5 proxy.""" + addrs = [addr for addr in self.addrs if addr.socks_port] if not addrs: - msg = "No tor address provided, please provide a tor address." + msg = "No socks5 proxy address provided, please provide a socks5 proxy address." raise ClanError(msg) if len(addrs) > 1: - msg = "Multiple tor addresses provided, expected only one." + msg = "Multiple socks5 proxy addresses provided, expected only one." raise ClanError(msg) return addrs[0] @@ -76,7 +77,12 @@ class DeployInfo: remote = Remote.from_ssh_uri( machine_name="clan-installer", address=tor_addr, - ).override(host_key_check=host_key_check, tor_socks=True, password=password) + ).override( + host_key_check=host_key_check, + socks_port=9050, + socks_wrapper=["torify"], + password=password, + ) addrs.append(remote) return DeployInfo(addrs=addrs) @@ -103,7 +109,8 @@ def find_reachable_host(deploy_info: DeployInfo) -> Remote | None: return deploy_info.addrs[0] for addr in deploy_info.addrs: - if addr.check_machine_ssh_reachable(): + with contextlib.suppress(ClanError): + addr.check_machine_ssh_reachable() return addr return None @@ -129,7 +136,7 @@ def ssh_shell_from_deploy( 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] + tor_addrs = [addr for addr in deploy_info.addrs if addr.socks_port] if not tor_addrs: msg = "No tor address provided, please provide a tor address." raise ClanError(msg) @@ -137,11 +144,10 @@ def ssh_shell_from_deploy( 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 - ) - ): + + with contextlib.suppress(ClanError): + tor_addr.check_machine_ssh_reachable() + log.info( "Host reachable via tor address, starting interactive ssh session." ) diff --git a/pkgs/clan-cli/clan_cli/ssh/test_deploy_info.py b/pkgs/clan-cli/clan_cli/ssh/test_deploy_info.py index 30d758311..10e7ecc8a 100644 --- a/pkgs/clan-cli/clan_cli/ssh/test_deploy_info.py +++ b/pkgs/clan-cli/clan_cli/ssh/test_deploy_info.py @@ -37,7 +37,7 @@ def test_qrcode_scan(temp_dir: Path) -> None: tor_host.address == "qjeerm4r6t55hcfum4pinnvscn5njlw2g3k7ilqfuu7cdt3ahaxhsbid.onion" ) - assert tor_host.tor_socks is True + assert tor_host.socks_port == 9050 assert tor_host.password == "scabbed-defender-headlock" assert tor_host.user == "root" assert ( @@ -59,7 +59,7 @@ def test_from_json() -> None: tor_host.address == "qjeerm4r6t55hcfum4pinnvscn5njlw2g3k7ilqfuu7cdt3ahaxhsbid.onion" ) - assert tor_host.tor_socks is True + assert tor_host.socks_port == 9050 assert tor_host.password == "scabbed-defender-headlock" assert tor_host.user == "root" assert ( diff --git a/pkgs/clan-cli/clan_cli/ssh/tor.py b/pkgs/clan-cli/clan_cli/ssh/tor.py deleted file mode 100755 index 992e731f5..000000000 --- a/pkgs/clan-cli/clan_cli/ssh/tor.py +++ /dev/null @@ -1,233 +0,0 @@ -#!/usr/bin/env python3 - -import argparse -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.errors import TorConnectionError, TorSocksError -from clan_lib.nix import nix_shell - -log = logging.getLogger(__name__) - - -@dataclass -class TorTarget: - onion: str - port: int - proxy_host: str = "127.0.0.1" - proxy_port: int = 9050 - - -def connect_to_tor_socks(sock: socket.socket, target: TorTarget) -> socket.socket: - """ - Connects to a .onion host through Tor's SOCKS5 proxy using the standard library. - - Args: - target (TorTarget): An object containing the .onion address, port, proxy host, and proxy port. - - Returns: - socket.socket: A socket connected to the .onion address via Tor. - """ - try: - # 1. Create a socket to the Tor SOCKS proxy - sock.connect((target.proxy_host, target.proxy_port)) - except ConnectionRefusedError as ex: - msg = f"Failed to connect to Tor SOCKS proxy at {target.proxy_host}:{target.proxy_port}: {ex}" - raise TorSocksError(msg) from ex - - # 2. SOCKS5 handshake - sock.sendall( - b"\x05\x01\x00" - ) # SOCKS version (0x05), number of authentication methods (0x01), no-authentication (0x00) - response = sock.recv(2) - - # Validate the SOCKS5 handshake response - if response != b"\x05\x00": # SOCKS version = 0x05, no-authentication = 0x00 - msg = f"SOCKS5 handshake failed, unexpected response: {response.hex()}" - raise TorSocksError(msg) - - # 3. Connection request - request = ( - b"\x05\x01\x00\x03" # SOCKS version, connect command, reserved, address type = domainname - + bytes([len(target.onion)]) - + target.onion.encode("utf-8") # Add domain name length and domain name - + struct.pack(">H", target.port) # Add destination port in network byte order - ) - sock.sendall(request) - - # Read the connection request response - response = sock.recv(10) - if response[1] != 0x00: # 0x00 indicates success - msg = f".onion address not reachable: {response[1]}" - raise TorConnectionError(msg) - - return sock - - -def fetch_onion_content(target: TorTarget) -> str: - """ - Fetches the HTTP response from a .onion service through a Tor SOCKS5 proxy. - - Args: - target (TorTarget): An object containing the .onion address, port, proxy host, and proxy port. - - Returns: - str: The HTTP response text, or an error message if something goes wrong. - """ - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - # Connect to the .onion service via the SOCKS proxy - sock = connect_to_tor_socks(sock, target) - - # 1. Send an HTTP GET request - request = f"GET / HTTP/1.1\r\nHost: {target.onion}\r\nConnection: close\r\n\r\n" - sock.sendall(request.encode("utf-8")) - - # 2. Read the HTTP response - response = b"" - while True: - chunk = sock.recv(4096) - if not chunk: - break - response += chunk - - return response.decode("utf-8", errors="replace") - - -def is_tor_running() -> bool: - """Checks if Tor is online.""" - try: - tor_online_test() - except TorSocksError: - return False - else: - return True - - -@contextmanager -def spawn_tor() -> Iterator[None]: - """ - Spawns a Tor process using `nix-shell` if Tor is not already running. - """ - - # 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") - - -def tor_online_test() -> bool: - """ - Tests if Tor is online by attempting to fetch content from a known .onion service. - Returns True if successful, False otherwise. - """ - target = TorTarget( - onion="duckduckgogg42xjoc72x3sjasowoarfbgcmvfimaftt6twagswzczad.onion", port=80 - ) - try: - response = fetch_onion_content(target) - except TorConnectionError: - return False - else: - return "duckduckgo" in response - - -def ssh_tor_reachable(target: TorTarget) -> bool: - """ - Tests if SSH is reachable via Tor by attempting to connect to a known .onion service. - Returns True if successful, False otherwise. - """ - try: - response = fetch_onion_content(target) - except TorConnectionError: - return False - else: - return "SSH-" in response - - -def main() -> None: - """ - Main function to handle command-line arguments and execute the script. - """ - parser = argparse.ArgumentParser( - description="Interact with a .onion service through Tor SOCKS5 proxy." - ) - parser.add_argument( - "onion_url", type=str, help=".onion URL to connect to (e.g., 'example.onion')" - ) - parser.add_argument( - "--port", type=int, help="Port to connect to on the .onion URL (default: 80)" - ) - parser.add_argument( - "--proxy-host", - type=str, - default="127.0.0.1", - help="Address of the Tor SOCKS5 proxy (default: 127.0.0.1)", - ) - parser.add_argument( - "--proxy-port", - type=int, - default=9050, - help="Port of the Tor SOCKS5 proxy (default: 9050)", - ) - parser.add_argument( - "--ssh-tor-reachable", - action="store_true", - help="Test if SSH is reachable via Tor", - ) - - args = parser.parse_args() - default_port = 22 if args.ssh_tor_reachable else 80 - - # Create a TorTarget instance - target = TorTarget( - onion=args.onion_url, - port=args.port or default_port, - proxy_host=args.proxy_host, - proxy_port=args.proxy_port, - ) - - if args.ssh_tor_reachable: - print(f"Testing if SSH is reachable via Tor for {target.onion}...") - reachable = ssh_tor_reachable(target) - print(f"SSH is {'reachable' if reachable else 'not reachable'} via Tor.") - return - - print( - f"Connecting to {target.onion} on port {target.port} via proxy {target.proxy_host}:{target.proxy_port}..." - ) - try: - response = fetch_onion_content(target) - print("Response:") - print(response) - except TorSocksError: - log.error("Failed to connect to the Tor SOCKS proxy.") - log.error( - "Is Tor running? If not, you can start it by running 'tor' in a nix-shell." - ) - except TorConnectionError: - log.error("The onion address is not reachable via Tor.") - - -if __name__ == "__main__": - main() diff --git a/pkgs/clan-cli/clan_lib/errors/__init__.py b/pkgs/clan-cli/clan_lib/errors/__init__.py index 31b3a489e..1c22a0c4c 100644 --- a/pkgs/clan-cli/clan_lib/errors/__init__.py +++ b/pkgs/clan-cli/clan_lib/errors/__init__.py @@ -189,13 +189,3 @@ class ClanCmdError(ClanError): def __repr__(self) -> str: return f"ClanCmdError({self.cmd})" - - -class TorSocksError(ClanError): - def __init__(self, msg: str) -> None: - super().__init__(msg) - - -class TorConnectionError(ClanError): - def __init__(self, msg: str) -> None: - super().__init__(msg) diff --git a/pkgs/clan-cli/clan_lib/import_utils/import_utils_test.py b/pkgs/clan-cli/clan_lib/import_utils/import_utils_test.py index fd34b8c71..7a0579212 100644 --- a/pkgs/clan-cli/clan_lib/import_utils/import_utils_test.py +++ b/pkgs/clan-cli/clan_lib/import_utils/import_utils_test.py @@ -22,7 +22,13 @@ def test_import_with_source(tmp_path: Path) -> None: test_module_path = module_dir / "test_tech.py" test_module_path.write_text( dedent(""" - from clan_lib.network.network import NetworkTechnologyBase + from clan_lib.network.network import NetworkTechnologyBase, Peer, Network + from contextlib import contextmanager + from collections.abc import Iterator + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from clan_lib.ssh.remote import Remote class NetworkTechnology(NetworkTechnologyBase): def __init__(self, source): @@ -31,6 +37,17 @@ def test_import_with_source(tmp_path: Path) -> None: def is_running(self) -> bool: return True + + def remote(self, peer: Peer) -> "Remote": + from clan_lib.ssh.remote import Remote + return Remote(host=peer.host) + + def ping(self, peer: Peer) -> None | float: + return 0.1 + + @contextmanager + def connection(self, network: Network) -> Iterator[Network]: + yield network """) ) @@ -57,7 +74,7 @@ def test_import_with_source(tmp_path: Path) -> None: assert instance.source.module_name == "test_module.test_tech" assert instance.source.file_path.name == "test_tech.py" assert instance.source.object_name == "NetworkTechnology" - assert instance.source.line_number == 4 # Line where class is defined + assert instance.source.line_number == 10 # Line where class is defined # Test string representations str_repr = str(instance) @@ -81,7 +98,13 @@ def test_import_with_source_with_args() -> None: with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: f.write( dedent(""" - from clan_lib.network.network import NetworkTechnologyBase + from clan_lib.network.network import NetworkTechnologyBase, Peer, Network + from contextlib import contextmanager + from collections.abc import Iterator + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from clan_lib.ssh.remote import Remote class NetworkTechnology(NetworkTechnologyBase): def __init__(self, source, extra_arg, keyword_arg=None): @@ -91,6 +114,17 @@ def test_import_with_source_with_args() -> None: def is_running(self) -> bool: return False + + def remote(self, peer: Peer) -> "Remote": + from clan_lib.ssh.remote import Remote + return Remote(host=peer.host) + + def ping(self, peer: Peer) -> None | float: + return None + + @contextmanager + def connection(self, network: Network) -> Iterator[Network]: + yield network """) ) temp_file = Path(f.name) diff --git a/pkgs/clan-cli/clan_lib/machines/install.py b/pkgs/clan-cli/clan_lib/machines/install.py index 949f826ad..6a758a1b2 100644 --- a/pkgs/clan-cli/clan_lib/machines/install.py +++ b/pkgs/clan-cli/clan_lib/machines/install.py @@ -133,16 +133,15 @@ def run_machine_install(opts: InstallOptions, target_host: Remote) -> None: cmd.extend(opts.machine.flake.nix_options or []) cmd.append(target_host.target) - if target_host.tor_socks: - # nix copy does not support tor socks proxy - # cmd.append("--ssh-option") - # cmd.append("ProxyCommand=nc -x 127.0.0.1:9050 -X 5 %h %p") + if target_host.socks_port: + # nix copy does not support socks5 proxy, use wrapper command + wrapper_cmd = target_host.socks_wrapper or ["torify"] cmd = nix_shell( [ "nixos-anywhere", - "tor", + *wrapper_cmd, ], - ["torify", *cmd], + [*wrapper_cmd, *cmd], ) else: cmd = nix_shell( diff --git a/pkgs/clan-cli/clan_lib/network/__init__.py b/pkgs/clan-cli/clan_lib/network/__init__.py new file mode 100644 index 000000000..416376463 --- /dev/null +++ b/pkgs/clan-cli/clan_lib/network/__init__.py @@ -0,0 +1,3 @@ +from .network import Network, NetworkTechnologyBase, Peer + +__all__ = ["Network", "NetworkTechnologyBase", "Peer"] diff --git a/pkgs/clan-cli/clan_lib/network/check.py b/pkgs/clan-cli/clan_lib/network/check.py new file mode 100644 index 000000000..44af02fce --- /dev/null +++ b/pkgs/clan-cli/clan_lib/network/check.py @@ -0,0 +1,131 @@ +import logging +from dataclasses import dataclass + +from clan_lib.api import API +from clan_lib.cmd import ClanCmdError, ClanCmdTimeoutError, RunOpts, run +from clan_lib.errors import ClanError # Assuming these are available +from clan_lib.ssh.remote import Remote + +cmdlog = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class ConnectionOptions: + timeout: int = 20 + + +@API.register +def check_machine_ssh_login( + remote: Remote, opts: ConnectionOptions | None = None +) -> None: + """Checks if a remote machine is reachable via SSH by attempting to run a simple command. + Args: + remote (Remote): The remote host to check for SSH login. + opts (ConnectionOptions, optional): Connection options such as timeout. + If not provided, default values are used. + Usage: + result = check_machine_ssh_login(remote) + if result.ok: + print("SSH login successful") + else: + print(f"SSH login failed: {result.reason}") + Raises: + ClanError: If the SSH login fails. + """ + if opts is None: + opts = ConnectionOptions() + + with remote.ssh_control_master() as ssh: + try: + ssh.run( + ["true"], + RunOpts(timeout=opts.timeout, needs_user_terminal=True), + ) + return + except ClanCmdTimeoutError as e: + msg = f"SSH login timeout after {opts.timeout}s" + raise ClanError(msg) from e + except ClanCmdError as e: + if "Host key verification failed." in e.cmd.stderr: + raise ClanError(e.cmd.stderr.strip()) from e + msg = f"SSH login failed: {e}" + raise ClanError(msg) from e + + +@API.register +def check_machine_ssh_reachable( + remote: Remote, opts: ConnectionOptions | None = None +) -> None: + """ + Checks if a remote machine is reachable via SSH by attempting to open a TCP connection + to the specified address and port. + Args: + remote (Remote): The remote host to check for SSH reachability. + opts (ConnectionOptions, optional): Connection options such as timeout. + If not provided, default values are used. + Returns: + CheckResult: An object indicating whether the SSH port is reachable (`ok=True`) or not (`ok=False`), + and a reason if the check failed. + Usage: + result = check_machine_ssh_reachable(remote) + if result.ok: + print("SSH port is reachable") + print(f"SSH port is not reachable: {result.reason}") + """ + if opts is None: + opts = ConnectionOptions() + + cmdlog.debug( + f"Checking SSH reachability for {remote.target} on port {remote.port or 22}", + ) + + # Use ssh with ProxyCommand to check through SOCKS5 + cmd = [ + "ssh", + ] + + # If using SOCKS5 proxy, add ProxyCommand + if remote.socks_port: + cmd.extend( + [ + "-o", + f"ProxyCommand=nc -X 5 -x localhost:{remote.socks_port} %h %p", + ] + ) + + cmd.extend( + [ + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + f"ConnectTimeout={opts.timeout}", + "-o", + "PreferredAuthentications=none", + "-p", + str(remote.port or 22), + f"dummy@{remote.address.strip()}", + "true", + ] + ) + + try: + res = run(cmd, options=RunOpts(timeout=opts.timeout, check=False)) + + # SSH will fail with authentication error if server is reachable + # Check for SSH-related errors in stderr + if ( + "Permission denied" in res.stderr + or "No supported authentication" in res.stderr + ): + return # Server is reachable, auth failed as expected + + msg = "Connection failed: SSH server not reachable" + raise ClanError(msg) + + except ClanCmdTimeoutError as e: + msg = f"Connection timeout after {opts.timeout}s" + raise ClanError(msg) from e diff --git a/pkgs/clan-cli/clan_lib/network/direct.py b/pkgs/clan-cli/clan_lib/network/direct.py index 489c98329..94fd5bd36 100644 --- a/pkgs/clan-cli/clan_lib/network/direct.py +++ b/pkgs/clan-cli/clan_lib/network/direct.py @@ -1,9 +1,47 @@ -from clan_lib.network.network import NetworkTechnologyBase +import logging +import time +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass + +from clan_lib.errors import ClanError +from clan_lib.network.network import Network, NetworkTechnologyBase, Peer +from clan_lib.ssh.remote import Remote + +log = logging.getLogger(__name__) +@dataclass(frozen=True) class NetworkTechnology(NetworkTechnologyBase): """Direct network connection technology - checks SSH connectivity""" def is_running(self) -> bool: """Direct connections are always 'running' as they don't require a daemon""" return True + + def ping(self, peer: Peer) -> None | float: + if self.is_running(): + try: + # Parse the peer's host address to create a Remote object, use peer here since we don't have the machine_name here + remote = Remote.from_ssh_uri(machine_name="peer", address=peer.host) + + # Use the existing SSH reachability check + now = time.time() + + remote.check_machine_ssh_reachable() + + return (time.time() - now) * 1000 + + except ClanError as e: + log.debug(f"Error checking peer {peer.host}: {e}") + return None + return None + + @contextmanager + def connection(self, network: Network) -> Iterator[Network]: + # direct connections are always online and don't use SOCKS, so we just return the original network + # TODO maybe we want to setup jumphosts for network access? but sounds complicated + yield network + + def remote(self, peer: Peer) -> Remote: + return Remote.from_ssh_uri(machine_name=peer.name, address=peer.host) diff --git a/pkgs/clan-cli/clan_lib/network/network.py b/pkgs/clan-cli/clan_lib/network/network.py index 477aaed4e..b61f38da9 100644 --- a/pkgs/clan-cli/clan_lib/network/network.py +++ b/pkgs/clan-cli/clan_lib/network/network.py @@ -1,23 +1,27 @@ import logging import textwrap -import time from abc import ABC, abstractmethod +from collections.abc import Iterator +from contextlib import contextmanager from dataclasses import dataclass from functools import cached_property -from typing import Any +from typing import TYPE_CHECKING, Any from clan_cli.vars.get import get_machine_var + from clan_lib.errors import ClanError from clan_lib.flake import Flake from clan_lib.import_utils import ClassSource, import_with_source -from clan_lib.ssh.parse import parse_ssh_uri -from clan_lib.ssh.remote import Remote, check_machine_ssh_reachable + +if TYPE_CHECKING: + from clan_lib.ssh.remote import Remote log = logging.getLogger(__name__) @dataclass(frozen=True) class Peer: + name: str _host: dict[str, str | dict[str, str]] flake: Flake @@ -30,7 +34,9 @@ class Peer: machine_name = _var["machine"] generator = _var["generator"] var = get_machine_var( - str(self.flake), + str( + self.flake + ), # TODO we should really pass the flake instance here instead of a str representation machine_name, f"{generator}/{_var['file']}", ) @@ -50,35 +56,6 @@ class Peer: raise ClanError(msg) -@dataclass -class NetworkTechnologyBase(ABC): - source: ClassSource - - @abstractmethod - def is_running(self) -> bool: - pass - - # TODO this will depend on the network implementation if we do user networking at some point, so it should be abstractmethod - def ping(self, peer: Peer) -> None | float: - if self.is_running(): - try: - # Parse the peer's host address to create a Remote object, use peer here since we don't have the machine_name here - remote = parse_ssh_uri(machine_name="peer", address=peer.host) - - # Use the existing SSH reachability check - now = time.time() - result = check_machine_ssh_reachable(remote) - - if result.ok: - return (time.time() - now) * 1000 - return None - - except Exception as e: - log.debug(f"Error checking peer {peer.host}: {e}") - return None - return None - - @dataclass(frozen=True) class Network: peers: dict[str, Peer] @@ -86,7 +63,7 @@ class Network: priority: int = 1000 @cached_property - def module(self) -> NetworkTechnologyBase: + def module(self) -> "NetworkTechnologyBase": res = import_with_source( self.module_name, "NetworkTechnology", @@ -100,15 +77,49 @@ class Network: def ping(self, peer: str) -> float | None: return self.module.ping(self.peers[peer]) + def remote(self, peer: str) -> "Remote": + # TODO raise exception if peer is not in peers + return self.module.remote(self.peers[peer]) + + +@dataclass(frozen=True) +class NetworkTechnologyBase(ABC): + source: ClassSource + + @abstractmethod + def is_running(self) -> bool: + pass + + @abstractmethod + def remote(self, peer: Peer) -> "Remote": + pass + + @abstractmethod + def ping(self, peer: Peer) -> None | float: + pass + + @contextmanager + @abstractmethod + def connection(self, network: Network) -> Iterator[Network]: + pass + def networks_from_flake(flake: Flake) -> dict[str, Network]: + # TODO more precaching, for example for vars + flake.precache( + [ + "clan.exports.instances.*.networking", + ] + ) networks: dict[str, Network] = {} networks_ = flake.select("clan.exports.instances.*.networking") for network_name, network in networks_.items(): if network: peers: dict[str, Peer] = {} for _peer in network["peers"].values(): - peers[_peer["name"]] = Peer(_host=_peer["host"], flake=flake) + peers[_peer["name"]] = Peer( + name=_peer["name"], _host=_peer["host"], flake=flake + ) networks[network_name] = Network( peers=peers, module_name=network["module"], @@ -117,17 +128,14 @@ def networks_from_flake(flake: Flake) -> dict[str, Network]: return networks -def get_best_remote(machine_name: str, networks: dict[str, Network]) -> Remote | None: +def get_best_network(machine_name: str, networks: dict[str, Network]) -> Network | None: for network_name, network in sorted( networks.items(), key=lambda network: -network[1].priority ): if machine_name in network.peers: if network.is_running() and network.ping(machine_name): print(f"connecting via {network_name}") - return Remote.from_ssh_uri( - machine_name=machine_name, - address=network.peers[machine_name].host, - ) + return network return None @@ -137,20 +145,19 @@ def get_network_overview(networks: dict[str, Network]) -> dict: result[network_name] = {} result[network_name]["status"] = None result[network_name]["peers"] = {} - network_online = False module = network.module log.debug(f"Using network module: {module}") if module.is_running(): result[network_name]["status"] = True - network_online = True - for peer_name in network.peers: - if network_online: - try: - result[network_name]["peers"][peer_name] = network.ping(peer_name) - except ClanError: - log.warning( - f"getting host for machine: {peer_name} in network: {network_name} failed" - ) - else: - result[network_name]["peers"][peer_name] = None + else: + with module.connection(network) as network: + for peer_name in network.peers: + try: + result[network_name]["peers"][peer_name] = network.ping( + peer_name + ) + except ClanError: + log.warning( + f"getting host for machine: {peer_name} in network: {network_name} failed" + ) return result diff --git a/pkgs/clan-cli/clan_lib/network/tor.py b/pkgs/clan-cli/clan_lib/network/tor.py deleted file mode 100644 index 9aedf01e3..000000000 --- a/pkgs/clan-cli/clan_lib/network/tor.py +++ /dev/null @@ -1,20 +0,0 @@ -from urllib.error import HTTPError -from urllib.request import urlopen - -from .network import NetworkTechnologyBase - - -class NetworkTechnology(NetworkTechnologyBase): - socks_port: int - command_port: int - - def is_running(self) -> bool: - """Check if Tor is running by sending HTTP request to SOCKS port.""" - try: - response = urlopen("http://127.0.0.1:9050", timeout=5) - content = response.read().decode("utf-8", errors="ignore") - return "tor" in content.lower() - except HTTPError as e: - return "tor" in str(e).lower() - except Exception: - return False diff --git a/pkgs/clan-cli/clan_lib/network/tor/__init__.py b/pkgs/clan-cli/clan_lib/network/tor/__init__.py new file mode 100644 index 000000000..120f6741d --- /dev/null +++ b/pkgs/clan-cli/clan_lib/network/tor/__init__.py @@ -0,0 +1,62 @@ +import logging +import time +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from clan_lib.errors import ClanError +from clan_lib.network import Network, NetworkTechnologyBase, Peer +from clan_lib.network.tor.lib import is_tor_running, spawn_tor + +if TYPE_CHECKING: + from clan_lib.ssh.remote import Remote + + +log = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class NetworkTechnology(NetworkTechnologyBase): + @property + def proxy(self) -> int: + """Return the SOCKS5 proxy port for this network technology.""" + return 9050 + + def is_running(self) -> bool: + """Check if Tor is running by sending HTTP request to SOCKS port.""" + return is_tor_running(self.proxy) + + def ping(self, peer: Peer) -> None | float: + if self.is_running(): + try: + remote = self.remote(peer) + + # Use the existing SSH reachability check + now = time.time() + remote.check_machine_ssh_reachable() + + return (time.time() - now) * 1000 + + except ClanError as e: + log.debug(f"Error checking peer {peer.host}: {e}") + return None + return None + + @contextmanager + def connection(self, network: Network) -> Iterator[Network]: + if self.is_running(): + yield network + else: + with spawn_tor() as _: + yield network + + def remote(self, peer: Peer) -> "Remote": + from clan_lib.ssh.remote import Remote + + return Remote( + address=peer.host, + command_prefix=peer.name, + socks_port=self.proxy, + socks_wrapper=["torify"], + ) diff --git a/pkgs/clan-cli/clan_lib/network/tor/lib.py b/pkgs/clan-cli/clan_lib/network/tor/lib.py new file mode 100755 index 000000000..6b0ea0df7 --- /dev/null +++ b/pkgs/clan-cli/clan_lib/network/tor/lib.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 + +import logging +import time +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from subprocess import Popen + +from clan_lib.errors import ClanError +from clan_lib.nix import nix_shell + +log = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class TorTarget: + onion: str + port: int + + +def is_tor_running(proxy_port: int | None = None) -> bool: + """Checks if Tor is online.""" + if proxy_port is None: + proxy_port = 9050 + try: + tor_online_test(proxy_port) + except ClanError as err: + log.debug(f"Tor is not running: {err}") + return False + + return True + + +# TODO: Move this to network technology tor module +@contextmanager +def spawn_tor() -> Iterator[None]: + """ + Spawns a Tor process using `nix-shell` if Tor is not already running. + """ + + # 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") + + +@dataclass(frozen=True) +class TorCheck: + onion: str + expected_content: str + port: int = 80 + + +def tor_online_test(proxy_port: int) -> None: + """ + Tests if Tor is online by checking if we can establish a SOCKS5 connection. + """ + import socket + + # Try to establish a SOCKS5 handshake with the Tor proxy + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(2.0) # Short timeout for local connection + + try: + # Connect to the SOCKS5 proxy + sock.connect(("localhost", proxy_port)) + + # Send SOCKS5 handshake + sock.sendall(b"\x05\x01\x00") # SOCKS5, 1 auth method, no auth + response = sock.recv(2) + + # Check if we got a valid SOCKS5 response + if response == b"\x05\x00": # SOCKS5, no auth accepted + return + msg = f"Invalid SOCKS5 response from Tor: {response.hex()}" + raise ClanError(msg) + + except (TimeoutError, ConnectionRefusedError, OSError) as e: + msg = f"Cannot connect to Tor SOCKS5 proxy at localhost:{proxy_port}: {e}" + raise ClanError(msg) from e + finally: + sock.close() diff --git a/pkgs/clan-cli/clan_lib/ssh/remote.py b/pkgs/clan-cli/clan_lib/ssh/remote.py index b850fd8b1..b7cdb74e4 100644 --- a/pkgs/clan-cli/clan_lib/ssh/remote.py +++ b/pkgs/clan-cli/clan_lib/ssh/remote.py @@ -2,19 +2,17 @@ import ipaddress import logging import os import shlex -import socket import subprocess import sys -import time from collections.abc import Iterator from contextlib import contextmanager from dataclasses import dataclass, field from pathlib import Path from shlex import quote from tempfile import TemporaryDirectory +from typing import TYPE_CHECKING -from clan_lib.api import API -from clan_lib.cmd import ClanCmdError, ClanCmdTimeoutError, CmdOut, RunOpts, run +from clan_lib.cmd import CmdOut, RunOpts, run from clan_lib.colors import AnsiColor from clan_lib.errors import ClanError, indent_command # Assuming these are available from clan_lib.nix import nix_shell @@ -22,6 +20,9 @@ from clan_lib.ssh.host_key import HostKeyCheck, hostkey_to_ssh_opts from clan_lib.ssh.parse import parse_ssh_uri from clan_lib.ssh.sudo_askpass_proxy import SudoAskpassProxy +if TYPE_CHECKING: + from clan_lib.network.check import ConnectionOptions + cmdlog = logging.getLogger(__name__) # Seconds until a message is printed when _run produces no output. @@ -40,7 +41,8 @@ class Remote: host_key_check: HostKeyCheck = "ask" verbose_ssh: bool = False ssh_options: dict[str, str] = field(default_factory=dict) - tor_socks: bool = False + socks_port: int | None = None + socks_wrapper: list[str] | None = None _control_path_dir: Path | None = None _askpass_path: str | None = None @@ -60,7 +62,8 @@ class Remote: host_key_check: HostKeyCheck | None = None, private_key: Path | None = None, password: str | None = None, - tor_socks: bool | None = None, + socks_port: int | None = None, + socks_wrapper: list[str] | None = None, command_prefix: str | None = None, port: int | None = None, ssh_options: dict[str, str] | None = None, @@ -81,7 +84,10 @@ class Remote: ), verbose_ssh=self.verbose_ssh, ssh_options=ssh_options or self.ssh_options, - tor_socks=tor_socks if tor_socks is not None else self.tor_socks, + socks_port=socks_port if socks_port is not None else self.socks_port, + socks_wrapper=socks_wrapper + if socks_wrapper is not None + else self.socks_wrapper, _control_path_dir=self._control_path_dir, _askpass_path=self._askpass_path, ) @@ -152,7 +158,7 @@ class Remote: host_key_check=self.host_key_check, verbose_ssh=self.verbose_ssh, ssh_options=self.ssh_options, - tor_socks=self.tor_socks, + socks_port=self.socks_port, _control_path_dir=Path(temp_dir), _askpass_path=self._askpass_path, ) @@ -220,7 +226,7 @@ class Remote: host_key_check=self.host_key_check, verbose_ssh=self.verbose_ssh, ssh_options=self.ssh_options, - tor_socks=self.tor_socks, + socks_port=self.socks_port, _control_path_dir=self._control_path_dir, _askpass_path=askpass_path, ) @@ -373,10 +379,13 @@ class Remote: if tty: current_ssh_opts.extend(["-t"]) - if self.tor_socks: + if self.socks_port: packages.append("netcat") current_ssh_opts.extend( - ["-o", "ProxyCommand=nc -x 127.0.0.1:9050 -X 5 %h %p"] + [ + "-o", + f"ProxyCommand=nc -x localhost:{self.socks_port} -X 5 %h %p", + ] ) cmd = [ @@ -447,100 +456,14 @@ class Remote: if self.password: self.check_sshpass_errorcode(res) - def check_machine_ssh_reachable(self) -> bool: - return check_machine_ssh_reachable(self).ok + def check_machine_ssh_reachable( + self, opts: "ConnectionOptions | None" = None + ) -> None: + from clan_lib.network.check import check_machine_ssh_reachable + return check_machine_ssh_reachable(self, opts) -@dataclass(frozen=True) -class ConnectionOptions: - timeout: int = 2 - retries: int = 5 + def check_machine_ssh_login(self) -> None: + from clan_lib.network.check import check_machine_ssh_login - -@dataclass -class CheckResult: - ok: bool - reason: str | None = None - - -@API.register -def check_machine_ssh_login( - remote: Remote, opts: ConnectionOptions | None = None -) -> CheckResult: - """Checks if a remote machine is reachable via SSH by attempting to run a simple command. - Args: - remote (Remote): The remote host to check for SSH login. - opts (ConnectionOptions, optional): Connection options such as timeout and number of retries. - If not provided, default values are used. - Returns: - CheckResult: An object indicating whether the SSH login is successful (`ok=True`) or not (`ok=False`), - and a reason if the check failed. - Usage: - result = check_machine_ssh_login(remote) - if result.ok: - print("SSH login successful") - else: - print(f"SSH login failed: {result.reason}") - """ - if opts is None: - opts = ConnectionOptions() - - for _ in range(opts.retries): - with remote.ssh_control_master() as ssh: - try: - res = ssh.run( - ["true"], - RunOpts(timeout=opts.timeout, needs_user_terminal=True), - ) - return CheckResult(True) - except ClanCmdTimeoutError: - pass - except ClanCmdError as e: - if "Host key verification failed." in e.cmd.stderr: - raise ClanError(res.stderr.strip()) from e - else: - time.sleep(opts.timeout) - - return CheckResult(False, f"failed after {opts.retries} attempts") - - -@API.register -def check_machine_ssh_reachable( - remote: Remote, opts: ConnectionOptions | None = None -) -> CheckResult: - """ - Checks if a remote machine is reachable via SSH by attempting to open a TCP connection - to the specified address and port. - Args: - remote (Remote): The remote host to check for SSH reachability. - opts (ConnectionOptions, optional): Connection options such as timeout and number of retries. - If not provided, default values are used. - Returns: - CheckResult: An object indicating whether the SSH port is reachable (`ok=True`) or not (`ok=False`), - and a reason if the check failed. - Usage: - result = check_machine_ssh_reachable(remote) - if result.ok: - print("SSH port is reachable") - print(f"SSH port is not reachable: {result.reason}") - """ - if opts is None: - opts = ConnectionOptions() - - cmdlog.debug( - f"Checking SSH reachability for {remote.target} on port {remote.port or 22}", - ) - - address_family = socket.AF_INET6 if remote.is_ipv6() else socket.AF_INET - for _ in range(opts.retries): - with socket.socket(address_family, socket.SOCK_STREAM) as sock: - sock.settimeout(opts.timeout) - try: - sock.connect((remote.address, remote.port or 22)) - return CheckResult(True) - except (TimeoutError, OSError): - pass - else: - time.sleep(opts.timeout) - - return CheckResult(False, f"failed after {opts.retries} attempts") + return check_machine_ssh_login(self) diff --git a/pkgs/clan-cli/clan_lib/tests/test_create.py b/pkgs/clan-cli/clan_lib/tests/test_create.py index d5b1498e8..f0d4df399 100644 --- a/pkgs/clan-cli/clan_lib/tests/test_create.py +++ b/pkgs/clan-cli/clan_lib/tests/test_create.py @@ -32,7 +32,7 @@ from clan_lib.nix_models.clan import InventoryMachineDeploy as MachineDeploy from clan_lib.persist.inventory_store import InventoryStore from clan_lib.persist.util import set_value_by_path from clan_lib.services.modules import list_service_modules -from clan_lib.ssh.remote import Remote, check_machine_ssh_login +from clan_lib.ssh.remote import Remote from clan_lib.templates.disk import hw_main_disk_options, set_machine_disk_schema log = logging.getLogger(__name__) @@ -189,9 +189,9 @@ def test_clan_create_api( target_host = machine.target_host().override( private_key=private_key, host_key_check="none" ) - assert check_machine_ssh_login(target_host).ok, ( - f"Machine {machine.name} is not online" - ) + + target_host.check_machine_ssh_reachable() + target_host.check_machine_ssh_login() ssh_keys = [ SSHKeyPair(