Merge pull request 'networking module part 2' (#4471) from networking_2 into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4471
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()
|
||||
@@ -17,6 +17,7 @@ log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_machine_var(base_dir: str, machine_name: str, var_id: str) -> Var:
|
||||
log.debug(f"getting var: {var_id} from machine: {machine_name}")
|
||||
vars_ = get_machine_vars(base_dir=base_dir, machine_name=machine_name)
|
||||
results = []
|
||||
for var in vars_:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -721,7 +721,7 @@ class FlakeCache:
|
||||
|
||||
def load_from_file(self, path: Path) -> None:
|
||||
with path.open("r") as f:
|
||||
log.debug("Loading flake cache from file")
|
||||
log.debug(f"Loading flake cache from file {path}")
|
||||
data = json.load(f)
|
||||
self.cache = FlakeCacheEntry.from_json(data["cache"])
|
||||
|
||||
|
||||
@@ -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