refactor: generalize Tor support to SOCKS5 proxy in network module

- Replace Tor-specific implementation with generic SOCKS5 proxy support
- Change `tor_socks` boolean to `socks_port` and `socks_wrapper` parameters
- Move Tor functionality to clan_lib.network.tor submodule
- Add connection context managers to NetworkTechnologyBase
- Improve network abstraction with proper remote() and connection() methods
- Update all callers to use new SOCKS5 proxy interface
- Fix network ping command to properly handle connection contexts

This allows for more flexible proxy configurations beyond just Tor,
while maintaining backward compatibility for Tor usage.
This commit is contained in:
lassulus
2025-07-24 22:26:36 +02:00
parent 9e85c64139
commit 1a5b77d47a
17 changed files with 504 additions and 465 deletions

View File

@@ -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(

View File

@@ -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(

View File

@@ -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."
)

View File

@@ -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 (

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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(

View File

@@ -0,0 +1,3 @@
from .network import Network, NetworkTechnologyBase, Peer
__all__ = ["Network", "NetworkTechnologyBase", "Peer"]

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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"],
)

View File

@@ -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()

View File

@@ -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)

View File

@@ -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(