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:
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
3
pkgs/clan-cli/clan_lib/network/__init__.py
Normal file
3
pkgs/clan-cli/clan_lib/network/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .network import Network, NetworkTechnologyBase, Peer
|
||||
|
||||
__all__ = ["Network", "NetworkTechnologyBase", "Peer"]
|
||||
131
pkgs/clan-cli/clan_lib/network/check.py
Normal file
131
pkgs/clan-cli/clan_lib/network/check.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
62
pkgs/clan-cli/clan_lib/network/tor/__init__.py
Normal file
62
pkgs/clan-cli/clan_lib/network/tor/__init__.py
Normal 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"],
|
||||
)
|
||||
98
pkgs/clan-cli/clan_lib/network/tor/lib.py
Executable file
98
pkgs/clan-cli/clan_lib/network/tor/lib.py
Executable 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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user