From ef5f049c834a42a1965eda33c96980e0ef8d8acd Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Sat, 14 Nov 2026 09:46:31 +0100 Subject: [PATCH 01/12] Add bounded SOCKS5 protocol and network policy --- .env.example | 5 + app/network_policy.py | 57 ++++- app/socks5_client.py | 212 +++++++++++++++ config.py | 37 ++- tests/test_network_policy.py | 109 ++++++++ tests/test_production_config.py | 29 +++ tests/test_socks5_client.py | 441 ++++++++++++++++++++++++++++++++ 7 files changed, 886 insertions(+), 4 deletions(-) create mode 100644 app/socks5_client.py create mode 100644 tests/test_socks5_client.py diff --git a/.env.example b/.env.example index 30b8347b..c1d9082d 100644 --- a/.env.example +++ b/.env.example @@ -179,6 +179,10 @@ BLOCK_INTERNAL_SSH=false # Exact comma-separated hostnames a ProxyJump bastion may resolve remotely. # Leave empty unless a trusted bastion-only DNS name cannot be validated locally. # PROXY_JUMP_REMOTE_DNS_ALLOWLIST=bastion-only.internal +# Exact names a trusted SOCKS5 proxy may resolve while internal targets are blocked. +# Other names are resolved locally and sent as policy-validated IP addresses. +# This setting does not allow a blocked proxy endpoint or a loopback proxy. +# SOCKS_REMOTE_DNS_ALLOWLIST=proxy-only.internal # Interactive SSH input resource limits. Byte values are hard-bounded by the # application; defaults support normal typing and ordered large-paste chunking. @@ -262,6 +266,7 @@ SSH_KEY_STORE_MAX_BYTES=8388608 # using DATA_DIR is stopped; they cannot grow. # PROFILE_MAX_RECORDS=500 # JUMP_HOST_MAX_RECORDS=100 +# SOCKS_PROXY_MAX_RECORDS=100 # CONNECTION_STORE_MAX_BYTES=2097152 # CONNECTION_CONFIG_MAX_BYTES=4194304 # Storage backend for rate-limit counters. diff --git a/app/network_policy.py b/app/network_policy.py index 83109ccd..f17807bc 100644 --- a/app/network_policy.py +++ b/app/network_policy.py @@ -252,8 +252,8 @@ def open_validated_socket(target, timeout, *, required_interface=None): raise -def proxy_jump_remote_dns_allowed(hostname, allowlist): - """Match a remote-only ProxyJump target against an exact hostname list.""" +def remote_dns_allowed(hostname, allowlist): + """Match a remote-DNS target against an exact hostname-only allowlist.""" try: candidate = canonicalize_hostname(hostname) except ValueError: @@ -276,3 +276,56 @@ def proxy_jump_remote_dns_allowed(hostname, allowlist): if entry == candidate: return True return False + + +proxy_jump_remote_dns_allowed = remote_dns_allowed + + +def socks_destination( + host, port, remote_dns, *, allow_internal=False, + allowed_internal_networks=(), remote_dns_allowlist=(), +): + """Select a pinned address or an explicitly permitted remote-DNS name.""" + from .socks5_client import RemoteName + + if type(remote_dns) is not bool: + raise ValueError('Invalid remote DNS setting') + canonical = canonicalize_hostname(host) + if '%' in canonical: + raise ValueError('Scoped addresses cannot be used through a SOCKS5 proxy') + if isinstance(port, bool) or not isinstance(port, (int, str)): + raise ValueError('Invalid port number') + try: + port = int(port) + except (TypeError, ValueError) as error: + raise ValueError('Invalid port number') from error + if not 1 <= port <= 65535: + raise ValueError('Port must be between 1 and 65535') + try: + ipaddress.ip_address(canonical) + literal = True + except ValueError: + literal = False + + if not literal and remote_dns and ( + allow_internal or remote_dns_allowed(canonical, remote_dns_allowlist) + ): + if not allow_internal: + from .audit_logger import log_info + log_info( + 'SOCKS target admitted by remote DNS allowlist', + role='socks_target', host=canonical, port=port, + ) + return RemoteName(canonical, port) + + try: + target = resolve_allowed_target( + canonical, port, allow_internal=allow_internal, + allowed_internal_networks=allowed_internal_networks, + ) + except ValueError: + if remote_dns and not allow_internal: + raise ValueError('Connections to this address are not allowed') from None + raise + audit_internal_allowlist_use(target, 'socks_target') + return target diff --git a/app/socks5_client.py b/app/socks5_client.py new file mode 100644 index 00000000..7ff58010 --- /dev/null +++ b/app/socks5_client.py @@ -0,0 +1,212 @@ +"""Negotiate bounded SOCKS5 CONNECT tunnels over validated TCP sockets.""" + +from dataclasses import dataclass +import ipaddress +import math +import socket +import time + +from .network_policy import ResolvedTarget, canonicalize_hostname + + +@dataclass(frozen=True) +class RemoteName: + hostname: str + port: int + + +_MESSAGES = { + 'socks_proxy_failure': 'The SOCKS5 proxy reported a general failure.', + 'socks_not_allowed': 'The SOCKS5 proxy does not allow this connection.', + 'socks_network_unreachable': 'The SOCKS5 proxy cannot reach the target network.', + 'socks_host_unreachable': 'The SOCKS5 proxy cannot reach the target host.', + 'socks_connection_refused': 'The target refused the connection through the SOCKS5 proxy.', + 'socks_ttl_expired': 'The connection through the SOCKS5 proxy timed out.', + 'socks_unsupported': 'The SOCKS5 proxy does not support this request.', + 'socks_protocol_error': 'The SOCKS5 proxy sent an invalid response.', + 'socks_proxy_unreachable': 'The SOCKS5 proxy could not be reached.', + 'socks_timeout': 'The SOCKS5 proxy did not respond in time.', + 'socks_auth_required': 'The SOCKS5 proxy requires a username and password.', + 'socks_auth_rejected': 'The SOCKS5 proxy does not accept username and password login.', + 'socks_auth_failed': 'SOCKS5 proxy login failed. Check the username and password.', + 'connection_cancelled': 'Connection cancelled', +} +_REPLY_CODES = { + 1: 'socks_proxy_failure', + 2: 'socks_not_allowed', + 3: 'socks_network_unreachable', + 4: 'socks_host_unreachable', + 5: 'socks_connection_refused', + 6: 'socks_ttl_expired', + 7: 'socks_unsupported', + 8: 'socks_unsupported', +} + + +class Socks5Error(Exception): + """A stable failure code and public message without proxy-supplied details.""" + + def __init__(self, code): + self.code = code if isinstance(code, str) and code in _MESSAGES else 'socks_protocol_error' + super().__init__(_MESSAGES[self.code]) + + +def _remaining(deadline, cancelled): + if cancelled is not None: + try: + stopped = bool(cancelled()) + except Exception: + stopped = True + if stopped: + raise Socks5Error('connection_cancelled') + if not isinstance(deadline, (int, float)) or not math.isfinite(deadline): + raise Socks5Error('socks_protocol_error') + remaining = deadline - time.monotonic() + if remaining <= 0: + raise Socks5Error('socks_timeout') + return remaining + + +def _send_packet(sock, packet, deadline, cancelled): + sock.settimeout(_remaining(deadline, cancelled)) + sock.sendall(packet) + _remaining(deadline, cancelled) + + +def _recv_exact(sock, size, deadline, cancelled): + data = bytearray() + while len(data) < size: + sock.settimeout(_remaining(deadline, cancelled)) + chunk = sock.recv(size - len(data)) + _remaining(deadline, cancelled) + if not chunk: + raise Socks5Error('socks_protocol_error') + data.extend(chunk) + return bytes(data) + + +def _authentication_packet(username, password): + if username is None and password is None: + return None + if not all(isinstance(value, str) and 1 <= len(value) <= 255 + for value in (username, password)): + raise Socks5Error('socks_auth_failed') + encoded = None + try: + encoded = (username.encode('utf-8'), password.encode('utf-8')) + except UnicodeError: + pass + if encoded is None or any(len(value) > 255 for value in encoded): + raise Socks5Error('socks_auth_failed') + encoded_user, encoded_password = encoded + return bytearray( + b'\x01' + bytes([len(encoded_user)]) + encoded_user + + bytes([len(encoded_password)]) + encoded_password + ) + + +def _connect_packet(destination): + if not isinstance(destination, (ResolvedTarget, RemoteName)): + raise Socks5Error('socks_protocol_error') + if type(destination.port) is not int or not 1 <= destination.port <= 65535: + raise Socks5Error('socks_protocol_error') + if isinstance(destination, RemoteName): + hostname = canonicalize_hostname(destination.hostname).encode('ascii') + if not 1 <= len(hostname) <= 255: + raise Socks5Error('socks_protocol_error') + address = b'\x03' + bytes([len(hostname)]) + hostname + else: + address_ip = ipaddress.ip_address(destination.ip) + if address_ip.version == 4 and destination.family == socket.AF_INET: + address = b'\x01' + address_ip.packed + elif address_ip.version == 6 and destination.family == socket.AF_INET6: + # SOCKS5 has no field for an IPv6 interface scope. + if address_ip.scope_id is not None or ( + len(destination.sockaddr) > 3 and destination.sockaddr[3] + ): + raise Socks5Error('socks_protocol_error') + address = b'\x04' + address_ip.packed + else: + raise Socks5Error('socks_protocol_error') + return b'\x05\x01\x00' + address + destination.port.to_bytes(2, 'big') + + +def _erase(packet): + if packet is not None: + packet[:] = b'\x00' * len(packet) + packet.clear() + + +def open_socks5_tunnel(sock, destination, *, username=None, password=None, + deadline, cancelled=None): + """Negotiate CONNECT over an already connected socket and return it.""" + authentication = None + succeeded = False + error_code = 'socks_protocol_error' + try: + _remaining(deadline, cancelled) + authentication = _authentication_packet(username, password) + username = password = None + request = _connect_packet(destination) + method = 0 if authentication is None else 2 + _send_packet(sock, bytes([5, 1, method]), deadline, cancelled) + version, selected = _recv_exact(sock, 2, deadline, cancelled) + if version != 5: + raise Socks5Error('socks_protocol_error') + if selected == 255: + raise Socks5Error('socks_auth_required' if method == 0 else 'socks_auth_rejected') + if selected != method: + raise Socks5Error('socks_protocol_error') + + if method == 2: + try: + _send_packet(sock, authentication, deadline, cancelled) + finally: + _erase(authentication) + authentication = None + version, status = _recv_exact(sock, 2, deadline, cancelled) + if version not in (1, 5): + raise Socks5Error('socks_protocol_error') + if status != 0: + raise Socks5Error('socks_auth_failed') + + _send_packet(sock, request, deadline, cancelled) + version, reply, reserved, address_type = _recv_exact(sock, 4, deadline, cancelled) + if version != 5 or reserved != 0: + raise Socks5Error('socks_protocol_error') + if address_type == 1: + address_length = 4 + elif address_type == 4: + address_length = 16 + elif address_type == 3: + address_length = _recv_exact(sock, 1, deadline, cancelled)[0] + if not address_length: + raise Socks5Error('socks_protocol_error') + else: + raise Socks5Error('socks_protocol_error') + _recv_exact(sock, address_length, deadline, cancelled) + _recv_exact(sock, 2, deadline, cancelled) + if reply != 0: + raise Socks5Error(_REPLY_CODES.get(reply, 'socks_protocol_error')) + _remaining(deadline, cancelled) + succeeded = True + except Socks5Error as error: + error_code = error.code + except TimeoutError: + error_code = 'socks_timeout' + except Exception: + error_code = 'socks_protocol_error' + finally: + username = password = None + _erase(authentication) + authentication = None + if not succeeded: + try: + sock.close() + except OSError: + pass + + if not succeeded: + # Raising outside the handler discards raw exceptions and their locals. + raise Socks5Error(error_code) + return sock diff --git a/config.py b/config.py index ea5d459f..29e9517d 100644 --- a/config.py +++ b/config.py @@ -316,6 +316,32 @@ def parse_tailscale_ssh_target(raw_value): return canonical_host, port +def parse_remote_dns_allowlist(raw_value, name): + """Validate exact DNS names at startup without resolving them locally.""" + names = [] + for raw_entry in str(raw_value or '').split(','): + entry = raw_entry.strip() + if not entry: + continue + try: + if any(character in entry for character in ':[]%'): + raise ValueError('Only hostnames are permitted') + canonical, _port = parse_tailscale_ssh_target(entry) + try: + ipaddress.ip_address(canonical) + except ValueError: + pass + else: + raise ValueError('IP literals are not permitted') + except ValueError: + raise RuntimeError( + f'CONFIGURATION ERROR: {name} accepts only exact DNS hostnames' + ) from None + if canonical not in names: + names.append(canonical) + return tuple(names) + + LDAP_CONNECT_TIMEOUT = _bounded_int_env( 'LDAP_CONNECT_TIMEOUT', 5, 1, 15 ) @@ -637,6 +663,9 @@ def _validate_quota_pair(kind, global_limit, per_user_limit, fair_slots): JUMP_HOST_MAX_RECORDS = _bounded_int_env( 'JUMP_HOST_MAX_RECORDS', 100, 10, 1000 ) +SOCKS_PROXY_MAX_RECORDS = _bounded_int_env( + 'SOCKS_PROXY_MAX_RECORDS', 100, 10, 1000 +) CONNECTION_STORE_MAX_BYTES = _bounded_int_env( 'CONNECTION_STORE_MAX_BYTES', 2 * 1024 * 1024, @@ -657,8 +686,8 @@ def _validate_quota_pair(kind, global_limit, per_user_limit, fair_slots): ) CONNECTION_STORE_RECOVERY_MAX_RECORDS = _bounded_int_env( 'CONNECTION_STORE_RECOVERY_MAX_RECORDS', - max(10_000, PROFILE_MAX_RECORDS, JUMP_HOST_MAX_RECORDS), - max(PROFILE_MAX_RECORDS, JUMP_HOST_MAX_RECORDS), + max(10_000, PROFILE_MAX_RECORDS, JUMP_HOST_MAX_RECORDS, SOCKS_PROXY_MAX_RECORDS), + max(PROFILE_MAX_RECORDS, JUMP_HOST_MAX_RECORDS, SOCKS_PROXY_MAX_RECORDS), 100_000, ) @@ -903,6 +932,10 @@ def _cookie_name_env(name, default): ).split(',') if entry.strip() ) +# Separate trust decision: an SSH bastion's DNS permission never admits a SOCKS route. +SOCKS_REMOTE_DNS_ALLOWLIST = parse_remote_dns_allowlist( + os.environ.get('SOCKS_REMOTE_DNS_ALLOWLIST', ''), 'SOCKS_REMOTE_DNS_ALLOWLIST', +) # Private networks that stay reachable while BLOCK_INTERNAL_SSH=true. Entries # must lie inside the ranges below; loopback, link-local, multicast and other # protected addresses can never be re-enabled through this setting. diff --git a/tests/test_network_policy.py b/tests/test_network_policy.py index 5a75c018..0c6d5915 100644 --- a/tests/test_network_policy.py +++ b/tests/test_network_policy.py @@ -3,6 +3,8 @@ import pytest +from app import network_policy + from app.network_policy import ( ResolvedTarget, audit_internal_allowlist_use, @@ -18,6 +20,113 @@ def addr(family, ip, port=22, socktype=socket.SOCK_STREAM): return family, socktype, socket.IPPROTO_TCP, '', sockaddr +@pytest.mark.parametrize('remote_dns', [False, True]) +@pytest.mark.parametrize('allow_internal', [False, True]) +@pytest.mark.parametrize('ip', ['10.0.0.8', '127.0.0.1', '::1', '1.1.1.1']) +def test_socks_literal_policy_never_uses_remote_dns( + monkeypatch, remote_dns, allow_internal, ip, +): + monkeypatch.setattr(socket, 'getaddrinfo', lambda *a, **kw: pytest.fail('unexpected DNS')) + arguments = dict(allow_internal=allow_internal, remote_dns_allowlist=(ip,)) + if not allow_internal and ip != '1.1.1.1': + with pytest.raises(ValueError, match='not allowed'): + network_policy.socks_destination(ip, 2222, remote_dns, **arguments) + else: + result = network_policy.socks_destination(ip, 2222, remote_dns, **arguments) + assert isinstance(result, ResolvedTarget) + assert (result.ip, result.port) == (ip, 2222) + + +@pytest.mark.parametrize('remote_dns', [False, True]) +@pytest.mark.parametrize('allow_internal', [False, True]) +@pytest.mark.parametrize('allowlisted', [False, True]) +def test_socks_hostname_dns_policy_matrix( + monkeypatch, remote_dns, allow_internal, allowlisted, +): + calls = [] + + def resolve(host, port, **kwargs): + calls.append((host, port)) + return [addr(socket.AF_INET, '10.0.0.8', port)] + + monkeypatch.setattr(socket, 'getaddrinfo', resolve) + arguments = dict( + allow_internal=allow_internal, + remote_dns_allowlist=('xn--bcher-kva.example',) if allowlisted else (), + ) + if remote_dns and (allow_internal or allowlisted): + result = network_policy.socks_destination('BÜCHER.example.', 2222, remote_dns, **arguments) + assert (result.hostname, result.port) == ('xn--bcher-kva.example', 2222) + assert not isinstance(result, ResolvedTarget) + assert calls == [] + elif not allow_internal: + with pytest.raises(ValueError, match='not allowed'): + network_policy.socks_destination('BÜCHER.example.', 2222, remote_dns, **arguments) + assert calls == [('xn--bcher-kva.example', 2222)] + else: + result = network_policy.socks_destination('BÜCHER.example.', 2222, remote_dns, **arguments) + assert result.ip == '10.0.0.8' + assert calls == [('xn--bcher-kva.example', 2222)] + + +def test_socks_unlisted_remote_name_pins_public_address(monkeypatch): + calls = [] + + def resolve(host, port, **kwargs): + calls.append((host, port)) + return [addr(socket.AF_INET, '1.1.1.1', port)] + + monkeypatch.setattr(socket, 'getaddrinfo', resolve) + result = network_policy.socks_destination('public.example', 22, True) + assert result.ip == '1.1.1.1' + assert result.hostname == 'public.example' + assert calls == [('public.example', 22)] + + +def test_socks_remote_name_never_falls_back_after_local_dns_failure(monkeypatch): + def unavailable(*args, **kwargs): + raise socket.gaierror('private DNS detail') + + monkeypatch.setattr(socket, 'getaddrinfo', unavailable) + with pytest.raises(ValueError, match='^Connections to this address are not allowed$'): + network_policy.socks_destination('unlisted.internal', 22, True) + + +def test_socks_internal_network_exception_and_remote_dns_are_audited(monkeypatch): + from app import audit_logger + + records = [] + monkeypatch.setattr(audit_logger, 'log_info', lambda message, **kw: records.append(kw)) + target = network_policy.socks_destination( + '10.0.0.8', 22, True, + allowed_internal_networks=(ipaddress.ip_network('10.0.0.0/24'),), + ) + assert target.via_internal_allowlist + network_policy.socks_destination( + 'bastion-only.internal', 22, True, + remote_dns_allowlist=('bastion-only.internal',), + ) + assert len(records) == 2 + assert all(record['role'] == 'socks_target' for record in records) + assert records[1]['host'] == 'bastion-only.internal' + + +def test_remote_dns_matcher_retains_jump_alias_and_rejects_ips(): + assert network_policy.remote_dns_allowed is proxy_jump_remote_dns_allowed + assert network_policy.remote_dns_allowed('TARGET.', ('target',)) + assert not network_policy.remote_dns_allowed('child.target', ('target',)) + assert not network_policy.remote_dns_allowed('127.0.0.1', ('127.0.0.1',)) + + +@pytest.mark.parametrize('host,port,remote_dns', [ + ('bad*name', 22, True), ('host', 0, True), ('host', 65536, True), + ('host', True, True), ('host', 22, 'false'), ('fe80::1%eth0', 22, True), +]) +def test_socks_policy_rejects_invalid_wire_destination(host, port, remote_dns): + with pytest.raises(ValueError): + network_policy.socks_destination(host, port, remote_dns, allow_internal=True) + + class RecordingSocket: def __init__(self, family, socktype): self.family = family diff --git a/tests/test_production_config.py b/tests/test_production_config.py index c441a50a..993e788f 100644 --- a/tests/test_production_config.py +++ b/tests/test_production_config.py @@ -14,6 +14,8 @@ 'BLOCK_INTERNAL_SSH', 'BOOTSTRAP_REGISTRATION_ENABLED', 'SSH_ALLOWED_INTERNAL_NETWORKS', + 'SOCKS_REMOTE_DNS_ALLOWLIST', + 'SOCKS_PROXY_MAX_RECORDS', 'CORS_ORIGINS', 'DEBUG', 'DEPLOYMENT_PROFILE', @@ -82,6 +84,33 @@ def _production_env(**overrides): return env +@pytest.mark.parametrize('value', ['*.internal', '127.0.0.1', '::1', '[::1]', 'host:1080', 'bad name']) +def test_socks_remote_dns_allowlist_rejects_invalid_entries_at_startup(value): + result = _load_config(_production_env(SOCKS_REMOTE_DNS_ALLOWLIST=value)) + assert result.returncode != 0 + assert 'SOCKS_REMOTE_DNS_ALLOWLIST' in result.stderr + + +def test_socks_remote_dns_allowlist_is_canonical_and_separate(): + result = _load_config( + _production_env(SOCKS_REMOTE_DNS_ALLOWLIST=' BÜCHER.example.,target,target ', + PROXY_JUMP_REMOTE_DNS_ALLOWLIST='jump-only'), + 'import config, json; print(json.dumps([config.SOCKS_REMOTE_DNS_ALLOWLIST, ' + 'config.PROXY_JUMP_REMOTE_DNS_ALLOWLIST, config.SOCKS_PROXY_MAX_RECORDS]))', + ) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout.strip().splitlines()[-1]) == [ + ['xn--bcher-kva.example', 'target'], ['jump-only'], 100, + ] + + +@pytest.mark.parametrize('value', ['9', '1001', 'invalid']) +def test_socks_record_limit_rejects_invalid_configuration(value): + result = _load_config(_production_env(SOCKS_PROXY_MAX_RECORDS=value)) + assert result.returncode != 0 + assert 'SOCKS_PROXY_MAX_RECORDS' in result.stderr + + def _load_config(env, code='import config'): return subprocess.run( [sys.executable, '-c', code], diff --git a/tests/test_socks5_client.py b/tests/test_socks5_client.py new file mode 100644 index 00000000..422ca4f0 --- /dev/null +++ b/tests/test_socks5_client.py @@ -0,0 +1,441 @@ +"""Exercise SOCKS5 negotiation against a scripted loopback TCP peer.""" + +from contextlib import contextmanager +import importlib +import logging +import socket +import threading +import time + +import pytest + +from app.network_policy import ResolvedTarget + + +_IPV4_REQUEST = b'\x05\x01\x00\x01\xc0\x00\x02\x0a\x00\x16' +_SUCCESS = b'\x05\x00\x00\x01\x7f\x00\x00\x01\x12\x34' + + +def _receive(connection, size): + data = bytearray() + while len(data) < size: + chunk = connection.recv(size - len(data)) + if not chunk: + break + data.extend(chunk) + return bytes(data) + + +@contextmanager +def _peer(script): + listener = socket.socket() + listener.bind(('127.0.0.1', 0)) + listener.listen(1) + listener.settimeout(3) + errors = [] + + def serve(): + try: + connection, _ = listener.accept() + with connection: + connection.settimeout(3) + script(connection) + except Exception as error: + errors.append(error) + + thread = threading.Thread(target=serve) + thread.start() + connection = None + try: + connection = socket.create_connection(listener.getsockname(), timeout=3) + yield connection + finally: + if connection is not None: + connection.close() + listener.close() + thread.join(4) + assert not thread.is_alive(), 'SOCKS5 test peer did not stop' + if errors: + raise errors[0] + + +def _ipv4_target(): + return ResolvedTarget('target.example', 22, '192.0.2.10', socket.AF_INET) + + +def test_connects_ipv4_without_authentication_and_preserves_ssh_bytes(): + socks = importlib.import_module('app.socks5_client') + + def script(connection): + assert _receive(connection, 3) == b'\x05\x01\x00' + connection.sendall(b'\x05\x00') + assert _receive(connection, len(_IPV4_REQUEST)) == _IPV4_REQUEST + connection.sendall(_SUCCESS + b'SSH-2.0-test\r\n') + + with _peer(script) as connection: + result = socks.open_socks5_tunnel( + connection, _ipv4_target(), deadline=time.monotonic() + 2, + ) + assert result is connection + assert _receive(result, 14) == b'SSH-2.0-test\r\n' + + +def _exchange(steps): + def script(connection): + for request, response in steps: + assert _receive(connection, len(request)) == request + if response: + connection.sendall(response) + return script + + +def _assert_failure(script, code, **kwargs): + socks = importlib.import_module('app.socks5_client') + target = kwargs.pop('destination', _ipv4_target()) + with _peer(script) as connection: + with pytest.raises(socks.Socks5Error) as caught: + socks.open_socks5_tunnel( + connection, target, deadline=time.monotonic() + 2, **kwargs, + ) + assert caught.value.code == code + assert connection.fileno() == -1 + return caught.value + + +@pytest.mark.parametrize('version', [b'\x01', b'\x05']) +def test_password_authentication_uses_utf8_byte_lengths(version, caplog): + caplog.set_level(logging.DEBUG) + socks = importlib.import_module('app.socks5_client') + script = _exchange([ + (b'\x05\x01\x02', b'\x05\x02'), + (b'\x01\x05m\xc3\xbcll\x05p\xc3\xa4ss', version + b'\x00'), + (_IPV4_REQUEST, _SUCCESS), + ]) + with _peer(script) as connection: + assert socks.open_socks5_tunnel( + connection, _ipv4_target(), username='müll', password='päss', + deadline=time.monotonic() + 2, + ) is connection + assert 'müll' not in caplog.text + assert 'päss' not in caplog.text + + +@pytest.mark.parametrize('kind,expected', [ + ('ipv6', b'\x05\x01\x00\x04\x20\x01\x0d\xb8' + b'\x00' * 11 + b'\x01\xff\xff'), + ('domain', b'\x05\x01\x00\x03\x15xn--bcher-kva.example\x00\x16'), +]) +def test_encodes_ipv6_and_idna_destinations_without_local_dns(kind, expected, monkeypatch): + socks = importlib.import_module('app.socks5_client') + destination = ( + ResolvedTarget('target.example', 65535, '2001:db8::1', socket.AF_INET6) + if kind == 'ipv6' else socks.RemoteName('BÜCHER.example.', 22) + ) + script = _exchange([(b'\x05\x01\x00', b'\x05\x00'), (expected, _SUCCESS)]) + with _peer(script) as connection: + def unexpected_dns(*args, **kwargs): + pytest.fail('SOCKS5 negotiation must not resolve destinations locally') + monkeypatch.setattr(socket, 'getaddrinfo', unexpected_dns) + assert socks.open_socks5_tunnel( + connection, destination, deadline=time.monotonic() + 2, + ) is connection + + +@pytest.mark.parametrize('address', [ + b'\x01\x7f\x00\x00\x01', + b'\x04' + b'\x00' * 15 + b'\x01', + b'\x03\x03foo', + b'\x03\xff' + b'x' * 255, +]) +def test_consumes_complete_bound_address_without_consuming_tunnel_bytes(address): + socks = importlib.import_module('app.socks5_client') + script = _exchange([ + (b'\x05\x01\x00', b'\x05\x00'), + (_IPV4_REQUEST, b'\x05\x00\x00' + address + b'\x00\x00SSH'), + ]) + with _peer(script) as connection: + result = socks.open_socks5_tunnel( + connection, _ipv4_target(), deadline=time.monotonic() + 2, + ) + assert _receive(result, 3) == b'SSH' + + +@pytest.mark.parametrize('reply,code,message', [ + (1, 'socks_proxy_failure', 'The SOCKS5 proxy reported a general failure.'), + (2, 'socks_not_allowed', 'The SOCKS5 proxy does not allow this connection.'), + (3, 'socks_network_unreachable', 'The SOCKS5 proxy cannot reach the target network.'), + (4, 'socks_host_unreachable', 'The SOCKS5 proxy cannot reach the target host.'), + (5, 'socks_connection_refused', 'The target refused the connection through the SOCKS5 proxy.'), + (6, 'socks_ttl_expired', 'The connection through the SOCKS5 proxy timed out.'), + (7, 'socks_unsupported', 'The SOCKS5 proxy does not support this request.'), + (8, 'socks_unsupported', 'The SOCKS5 proxy does not support this request.'), + (9, 'socks_protocol_error', 'The SOCKS5 proxy sent an invalid response.'), + (255, 'socks_protocol_error', 'The SOCKS5 proxy sent an invalid response.'), +]) +def test_reply_errors_have_stable_safe_codes_and_close_socket(reply, code, message): + error = _assert_failure(_exchange([ + (b'\x05\x01\x00', b'\x05\x00'), + (_IPV4_REQUEST, b'\x05' + bytes([reply]) + _SUCCESS[2:]), + ]), code) + assert str(error) == message + + +@pytest.mark.parametrize('credentials,greeting,response,code', [ + ({}, b'\x05\x01\x00', b'\x05\xff', 'socks_auth_required'), + ({'username': 'user', 'password': 'secret'}, b'\x05\x01\x02', b'\x05\xff', 'socks_auth_rejected'), + ({}, b'\x05\x01\x00', b'\x05\x02', 'socks_protocol_error'), + ({'username': 'user', 'password': 'secret'}, b'\x05\x01\x02', b'\x05\x00', 'socks_protocol_error'), + ({}, b'\x05\x01\x00', b'\x04\x00', 'socks_protocol_error'), +]) +def test_rejects_unoffered_authentication_methods_and_bad_versions( + credentials, greeting, response, code, +): + _assert_failure(_exchange([(greeting, response)]), code, **credentials) + + +@pytest.mark.parametrize('response,code', [ + (b'\x01\x01', 'socks_auth_failed'), + (b'\x05\xff', 'socks_auth_failed'), + (b'\x02\x00', 'socks_protocol_error'), +]) +def test_rejects_failed_or_malformed_password_authentication(response, code, caplog): + caplog.set_level(logging.DEBUG) + error = _assert_failure(_exchange([ + (b'\x05\x01\x02', b'\x05\x02'), + (b'\x01\x0aproxy-user\x06secret', response), + ]), code, username='proxy-user', password='secret') + assert 'secret' not in repr(error) + assert 'proxy-user' not in repr(error.args) + assert 'secret' not in caplog.text + assert 'proxy-user' not in caplog.text + + +@pytest.mark.parametrize('response', [ + b'\x04\x00\x00\x01', + b'\x05\x00\x01\x01', + b'\x05\x00\x00\x05', + b'\x05\x00\x00\x03\x00\x00\x16', +]) +def test_rejects_invalid_connect_response(response): + _assert_failure(_exchange([ + (b'\x05\x01\x00', b'\x05\x00'), + (_IPV4_REQUEST, response), + ]), 'socks_protocol_error') + + +@pytest.mark.parametrize('stage,partial', [ + ('greeting', b''), ('greeting', b'\x05'), + ('auth', b''), ('auth', b'\x01'), + ('connect', b''), ('connect', b'\x05\x00\x00'), + ('connect', b'\x05\x00\x00\x01\x7f\x00'), + ('connect', b'\x05\x00\x00\x04' + b'\x00' * 15), + ('connect', b'\x05\x00\x00\x03'), + ('connect', b'\x05\x00\x00\x03\x03ab'), + ('connect', _SUCCESS[:-1]), +]) +def test_eof_in_each_response_phase_is_a_protocol_error(stage, partial): + if stage == 'auth': + steps = [(b'\x05\x01\x02', b'\x05\x02'), (b'\x01\x01u\x01p', partial)] + credentials = {'username': 'u', 'password': 'p'} + else: + steps = [(b'\x05\x01\x00', partial if stage == 'greeting' else b'\x05\x00')] + if stage == 'connect': + steps.append((_IPV4_REQUEST, partial)) + credentials = {} + _assert_failure(_exchange(steps), 'socks_protocol_error', **credentials) + + +def test_accepts_fragmented_responses_in_every_phase(): + socks = importlib.import_module('app.socks5_client') + + def script(connection): + for request, response in [ + (b'\x05\x01\x02', b'\x05\x02'), + (b'\x01\x01u\x01p', b'\x01\x00'), + (_IPV4_REQUEST, b'\x05\x00\x00\x03\x03foo\x00\x16'), + ]: + assert _receive(connection, len(request)) == request + for value in response: + connection.sendall(bytes([value])) + threading.Event().wait(0.005) + + with _peer(script) as connection: + assert socks.open_socks5_tunnel( + connection, _ipv4_target(), username='u', password='p', + deadline=time.monotonic() + 2, + ) is connection + + +@pytest.mark.parametrize('username,password', [ + ('', 'secret'), ('user', ''), (None, 'secret'), ('user', None), + ('u' * 256, 'secret'), ('user', 'p' * 256), + ('ü' * 128, 'secret'), ('user', 'ä' * 128), + (b'user', 'secret'), ('user', b'secret'), + ('\ud800', 'secret'), ('user', '\ud800'), +]) +def test_invalid_credentials_are_rejected_before_sending_bytes(username, password): + _assert_failure(_expect_closed, 'socks_auth_failed', + username=username, password=password) + + +@pytest.mark.parametrize('field', ['username', 'password']) +def test_accepts_255_byte_credential_fields(field): + socks = importlib.import_module('app.socks5_client') + credentials = {'username': 'u', 'password': 'p'} + credentials[field] = 'x' * 255 + packet = (b'\x01\xff' + b'x' * 255 + b'\x01p' if field == 'username' + else b'\x01\x01u\xff' + b'x' * 255) + script = _exchange([ + (b'\x05\x01\x02', b'\x05\x02'), (packet, b'\x01\x00'), + (_IPV4_REQUEST, _SUCCESS), + ]) + with _peer(script) as connection: + assert socks.open_socks5_tunnel( + connection, _ipv4_target(), deadline=time.monotonic() + 2, **credentials, + ) is connection + + +@pytest.mark.parametrize('hostname,port', [ + ('', 22), ('a' * 256, 22), ('bad host', 22), ('\ud800', 22), + ('target.example', 0), ('target.example', 65536), + ('target.example', -1), ('target.example', 22.5), + ('target.example', True), ('target.example', '22'), +]) +def test_invalid_remote_destination_closes_socket_before_sending(hostname, port): + socks = importlib.import_module('app.socks5_client') + _assert_failure(_expect_closed, 'socks_protocol_error', + destination=socks.RemoteName(hostname, port)) + + +@pytest.mark.parametrize('target', [ + ResolvedTarget('target', 22, '2001:db8::1', socket.AF_INET), + ResolvedTarget('target', 22, '192.0.2.10', socket.AF_INET6), + ResolvedTarget('target', 0, '192.0.2.10', socket.AF_INET), + ResolvedTarget('target', 22, 'fe80::1%1', socket.AF_INET6), + ResolvedTarget('target', 22, 'fe80::1', socket.AF_INET6, ('fe80::1', 22, 0, 1)), + object(), +]) +def test_invalid_resolved_destination_is_not_sent(target): + _assert_failure(_expect_closed, 'socks_protocol_error', destination=target) + + +def test_deadline_applies_across_slow_partial_responses(): + socks = importlib.import_module('app.socks5_client') + + def script(connection): + assert _receive(connection, 3) == b'\x05\x01\x00' + connection.sendall(b'\x05') + threading.Event().wait(0.12) + connection.sendall(b'\x00') + assert _receive(connection, len(_IPV4_REQUEST)) == _IPV4_REQUEST + connection.sendall(b'\x05') + threading.Event().wait(0.12) + _expect_closed(connection) + + with _peer(script) as connection: + started = time.monotonic() + with pytest.raises(socks.Socks5Error) as caught: + socks.open_socks5_tunnel(connection, _ipv4_target(), deadline=started + 0.2) + assert caught.value.code == 'socks_timeout' + assert connection.fileno() == -1 + assert time.monotonic() - started < 0.8 + + +def test_expired_deadline_prevents_any_handshake_bytes(): + socks = importlib.import_module('app.socks5_client') + with _peer(lambda connection: _expect_closed(connection)) as connection: + with pytest.raises(socks.Socks5Error) as caught: + socks.open_socks5_tunnel(connection, _ipv4_target(), deadline=time.monotonic() - 1) + assert caught.value.code == 'socks_timeout' + assert connection.fileno() == -1 + + +def _expect_closed(connection): + try: + assert connection.recv(1) == b'' + except (ConnectionResetError, ConnectionAbortedError): + pass + + +@pytest.mark.parametrize('stage', ['before', 'greeting', 'auth', 'connect', 'address', 'port']) +def test_cancellation_between_handshake_steps_closes_socket(stage): + stopped = threading.Event() + if stage == 'before': + stopped.set() + + def script(connection): + for step, request, response in [ + ('greeting', b'\x05\x01\x02', b'\x05\x02'), + ('auth', b'\x01\x01u\x01p', b'\x01\x00'), + ('connect', _IPV4_REQUEST, b'\x05\x00\x00\x01'), + ('address', b'', b'\x7f\x00\x00\x01'), + ('port', b'', b'\x00\x16'), + ]: + if stopped.is_set(): + _expect_closed(connection) + return + assert _receive(connection, len(request)) == request + if step == stage: + stopped.set() + connection.sendall(response) + _expect_closed(connection) + + error = _assert_failure(script, 'connection_cancelled', username='u', password='p', + cancelled=stopped.is_set) + assert str(error) == 'Connection cancelled' + + +def test_cancellation_callback_failure_is_safe_and_closes_socket(caplog): + caplog.set_level(logging.DEBUG) + def cancelled(): + raise RuntimeError('private-username/private-password') + + error = _assert_failure(_expect_closed, 'connection_cancelled', cancelled=cancelled) + assert error.__context__ is None + assert 'private-' not in repr(error) + assert 'private-' not in caplog.text + + +def test_closed_socket_error_has_no_raw_exception_details(caplog): + caplog.set_level(logging.DEBUG) + socks = importlib.import_module('app.socks5_client') + connection = socket.socket() + connection.close() + with pytest.raises(socks.Socks5Error) as caught: + socks.open_socks5_tunnel(connection, _ipv4_target(), deadline=time.monotonic() + 2) + assert caught.value.code == 'socks_protocol_error' + assert caught.value.__context__ is None + assert not caplog.text + + +@pytest.mark.parametrize('deadline', [float('nan'), float('inf'), float('-inf'), None, 'later']) +def test_invalid_deadline_closes_socket_before_negotiation(deadline): + socks = importlib.import_module('app.socks5_client') + with _peer(_expect_closed) as connection: + with pytest.raises(socks.Socks5Error) as caught: + socks.open_socks5_tunnel(connection, _ipv4_target(), deadline=deadline) + assert caught.value.code == 'socks_protocol_error' + assert connection.fileno() == -1 + + +def test_authentication_failure_does_not_retain_credentials_in_exception_frames(): + error = _assert_failure(_exchange([ + (b'\x05\x01\x02', b'\x05\x02'), + (b'\x01\x0aprivate-id\x0cprivate-pass', b'\x01\x01'), + ]), 'socks_auth_failed', username='private-id', password='private-pass') + assert error.__context__ is None + assert error.__cause__ is None + traceback = error.__traceback__ + while traceback is not None: + if traceback.tb_frame.f_globals.get('__name__') == 'app.socks5_client': + for value in traceback.tb_frame.f_locals.values(): + if isinstance(value, (str, bytes, bytearray)): + assert 'private-' not in str(value) + traceback = traceback.tb_next + + +def test_unknown_error_code_cannot_be_used_to_expose_proxy_details(): + socks = importlib.import_module('app.socks5_client') + error = socks.Socks5Error('raw-private-proxy-response') + assert error.code == 'socks_protocol_error' + assert str(error) == 'The SOCKS5 proxy sent an invalid response.' + assert 'private' not in repr(error) From 065cce01732d60b6836d403b4dc4cf939fe8775c Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Sat, 14 Nov 2026 09:52:04 +0100 Subject: [PATCH 02/12] Add user-owned SOCKS proxy route storage --- app/cli.py | 17 +- app/connection_storage_policy.py | 26 +- app/jump_host_manager.py | 4 +- app/models.py | 6 + app/profile_manager.py | 43 ++- app/socket_events.py | 72 ++++- app/socks_proxy_manager.py | 500 ++++++++++++++++++++++++++++++ app/storage_migrations.py | 9 + tests/test_socks_proxy_manager.py | 460 +++++++++++++++++++++++++++ tests/test_socks_proxy_socket.py | 92 ++++++ tests/test_storage_migrations.py | 1 + 11 files changed, 1212 insertions(+), 18 deletions(-) create mode 100644 app/socks_proxy_manager.py create mode 100644 tests/test_socks_proxy_manager.py create mode 100644 tests/test_socks_proxy_socket.py diff --git a/app/cli.py b/app/cli.py index 8a812282..a51a219e 100644 --- a/app/cli.py +++ b/app/cli.py @@ -259,12 +259,12 @@ def _connection_store_user(username): @click.option( '--kind', required=True, - type=click.Choice(('profiles', 'jump-hosts'), case_sensitive=True), + type=click.Choice(('profiles', 'jump-hosts', 'socks-proxies'), case_sensitive=True), ) @click.option('--confirm-offline', is_flag=True) def connection_store_list(username, kind, confirm_offline): """List bounded, non-secret record summaries for offline recovery.""" - from . import jump_host_manager, profile_manager + from . import jump_host_manager, profile_manager, socks_proxy_manager from .backup_coordination import OperationBusyError, operation_lock from .connection_storage_policy import ConnectionStorageLimitError from .storage_errors import StorageCorruptionError @@ -279,6 +279,8 @@ def connection_store_list(username, kind, confirm_offline): ) if error: raise click.ClickException(error) + elif kind == 'socks-proxies': + records = socks_proxy_manager.load_socks_proxy_recovery_summaries(user.id) else: records = ( jump_host_manager.load_jump_host_recovery_summaries( @@ -303,7 +305,7 @@ def connection_store_list(username, kind, confirm_offline): @click.option( '--kind', required=True, - type=click.Choice(('profiles', 'jump-hosts'), case_sensitive=True), + type=click.Choice(('profiles', 'jump-hosts', 'socks-proxies'), case_sensitive=True), ) @click.option('--selector', required=True, metavar='SELECTOR') @click.option('--confirm-offline', is_flag=True) @@ -314,7 +316,7 @@ def connection_store_delete( confirm_offline, ): """Delete one exact legacy record while WebSSH is stopped.""" - from . import jump_host_manager, profile_manager + from . import jump_host_manager, profile_manager, socks_proxy_manager from .backup_coordination import OperationBusyError, operation_lock from .storage_errors import StorageCorruptionError @@ -329,6 +331,10 @@ def connection_store_delete( selector, ) ) + elif kind == 'socks-proxies': + deleted, error, _usages = ( + socks_proxy_manager.delete_socks_proxy_recovery_record(user.id, selector) + ) else: deleted, error, _usages = ( jump_host_manager.delete_jump_host_recovery_record( @@ -346,7 +352,8 @@ def connection_store_delete( kind=kind, selector=selector, ) - label = 'profile' if kind == 'profiles' else 'jump-host' + label = {'profiles': 'profile', 'jump-hosts': 'jump-host', + 'socks-proxies': 'SOCKS-proxy'}[kind] click.echo(f'Deleted one {label} recovery record.') except (OperationBusyError, StorageCorruptionError) as exc: raise click.ClickException(str(exc)) from exc diff --git a/app/connection_storage_policy.py b/app/connection_storage_policy.py index 2f149973..444c8a4e 100644 --- a/app/connection_storage_policy.py +++ b/app/connection_storage_policy.py @@ -1,4 +1,4 @@ -"""Prospective quotas for saved connection and jump-host metadata.""" +"""Prospective quotas for saved connection and route metadata.""" from hashlib import sha256 import hmac @@ -20,6 +20,8 @@ class ConnectionStorageLimitError(ValueError): PROFILE_REFERENCE_MAX_BYTES = 128 PROFILE_STARTUP_MAX_BYTES = 64 * 1024 JUMP_HOST_NAME_MAX_BYTES = 512 +SOCKS_PROXY_NAME_MAX_BYTES = 512 +SOCKS_PROXY_USERNAME_MAX_BYTES = 255 _RECOVERY_SELECTOR_PATTERN = re.compile( r'r1:(0|[1-9][0-9]{0,9}):([0-9a-f]{64})' ) @@ -61,6 +63,7 @@ def validate_profile(profile, legacy=None): ('group', PROFILE_GROUP_MAX_BYTES, 'profile group'), ('key_id', PROFILE_REFERENCE_MAX_BYTES, 'key reference'), ('jump_host_id', PROFILE_REFERENCE_MAX_BYTES, 'jump-host reference'), + ('socks_proxy_id', PROFILE_REFERENCE_MAX_BYTES, 'SOCKS-proxy reference'), ('command_id', PROFILE_REFERENCE_MAX_BYTES, 'command reference'), ('command_set_id', PROFILE_REFERENCE_MAX_BYTES, 'command-set reference'), ('startup_commands', PROFILE_STARTUP_MAX_BYTES, 'startup commands'), @@ -99,6 +102,18 @@ def _file_size(path): return 0 +def validate_socks_proxy(proxy, legacy=None): + """Enforce UTF-8 field budgets without storing proxy credentials.""" + legacy = legacy if isinstance(legacy, dict) else {} + for field, maximum, label in ( + ('id', PROFILE_REFERENCE_MAX_BYTES, 'SOCKS-proxy id'), + ('name', SOCKS_PROXY_NAME_MAX_BYTES, 'SOCKS-proxy name'), + ('host', PROFILE_HOST_MAX_BYTES, 'SOCKS-proxy host'), + ('username', SOCKS_PROXY_USERNAME_MAX_BYTES, 'SOCKS-proxy username'), + ): + bounded_text(proxy.get(field), maximum, label, legacy.get(field)) + + def _serialize_document(document, *, compact=False): try: kwargs = {'separators': (',', ':')} if compact else {'indent': 2} @@ -181,7 +196,8 @@ def resolve_recovery_record_selector(scope, records, selector): def enforce_store_transition( *, path, - other_path, + other_path=None, + other_paths=(), prospective_document, prospective_count, previous_count, @@ -198,7 +214,11 @@ def enforce_store_transition( """ path = Path(path) current_size = _file_size(path) - other_size = _file_size(other_path) + siblings = {Path(sibling).resolve() for sibling in other_paths} + if other_path is not None: + siblings.add(Path(other_path).resolve()) + siblings.discard(path.resolve()) + other_size = sum(_file_size(sibling) for sibling in siblings) if prospective_count > maximum_count and ( previous_count is None or prospective_count > previous_count ): diff --git a/app/jump_host_manager.py b/app/jump_host_manager.py index 874525e7..da52ee4b 100644 --- a/app/jump_host_manager.py +++ b/app/jump_host_manager.py @@ -92,7 +92,7 @@ def _jump_host_migration_payload(path, document): try: return enforce_store_transition( path=path, - other_path=path.parent / 'profiles.json', + other_paths=(path.parent / 'profiles.json', path.parent / 'socks_proxies.json'), prospective_document=document, prospective_count=len(jump_hosts), previous_count=None, @@ -163,7 +163,7 @@ def save_jump_hosts( return False payload = enforce_store_transition( path=f, - other_path=f.parent / 'profiles.json', + other_paths=(f.parent / 'profiles.json', f.parent / 'socks_proxies.json'), prospective_document=document, prospective_count=len(jump_hosts), previous_count=previous_count, diff --git a/app/models.py b/app/models.py index 6e8e42eb..64e2c2d3 100644 --- a/app/models.py +++ b/app/models.py @@ -818,6 +818,8 @@ class SSHSession(db.Model): tmux_session_name = db.Column(db.String(256), nullable=True) display_name = db.Column(db.String(128), nullable=True) jump_host_id = db.Column(db.String(64), nullable=True) + socks_proxy_id = db.Column(db.String(64), nullable=True) + via_socks = db.Column(db.Boolean, nullable=False, default=False) profile_id = db.Column(db.String(128), nullable=True) via_jump = db.Column(db.String(256), nullable=True) reconnect_route_known = db.Column(db.Boolean, nullable=False, default=False) @@ -850,6 +852,10 @@ def ensure_ssh_session_columns(): additions.append("ALTER TABLE ssh_sessions ADD COLUMN display_name VARCHAR(128)") if 'jump_host_id' not in existing: additions.append("ALTER TABLE ssh_sessions ADD COLUMN jump_host_id VARCHAR(64)") + if 'socks_proxy_id' not in existing: + additions.append("ALTER TABLE ssh_sessions ADD COLUMN socks_proxy_id VARCHAR(64)") + if 'via_socks' not in existing: + additions.append("ALTER TABLE ssh_sessions ADD COLUMN via_socks BOOLEAN NOT NULL DEFAULT 0") if 'profile_id' not in existing: additions.append("ALTER TABLE ssh_sessions ADD COLUMN profile_id VARCHAR(128)") if 'via_jump' not in existing: diff --git a/app/profile_manager.py b/app/profile_manager.py index e92854f3..2f6fcae8 100644 --- a/app/profile_manager.py +++ b/app/profile_manager.py @@ -130,9 +130,16 @@ def _valid_profile(item): ): if not _optional_string(item, field): return False - for field in ('key_id', 'jump_host_id'): + for field in ('key_id', 'jump_host_id', 'socks_proxy_id'): if not _optional_string(item, field, allow_none=True): return False + socks_proxy_id = item.get('socks_proxy_id') + if socks_proxy_id and ( + len(socks_proxy_id) > 64 + or item.get('jump_host_id') + or item.get('auth_type') == 'tailscale' + ): + return False if 'port' in item and type(item['port']) is not int: return False if 'auth_type' in item and item['auth_type'] not in { @@ -173,7 +180,7 @@ def _valid_profile_document(value): _PROFILE_FIELDS = { 'id', 'name', 'host', 'port', 'username', 'auth_type', 'key_id', - 'jump_host_id', 'startup_mode', 'startup_commands', 'command_id', + 'jump_host_id', 'socks_proxy_id', 'startup_mode', 'startup_commands', 'command_id', 'command_set_id', 'parameters_override', 'use_tmux', 'group', 'favorite', 'created_at', 'updated_at', 'sort_order', 'platform_icon', 'detected_platform', 'greeting_platform', @@ -187,7 +194,7 @@ def _profile_migration_payload(profiles_file, document): try: return enforce_store_transition( path=profiles_file, - other_path=profiles_file.parent / 'jump_hosts.json', + other_paths=(profiles_file.parent / 'jump_hosts.json', profiles_file.parent / 'socks_proxies.json'), prospective_document=document, prospective_count=len(profiles), previous_count=None, @@ -328,7 +335,7 @@ def save_profiles( payload = enforce_store_transition( path=profiles_file, - other_path=profiles_file.parent / 'jump_hosts.json', + other_paths=(profiles_file.parent / 'jump_hosts.json', profiles_file.parent / 'socks_proxies.json'), prospective_document=document, prospective_count=len(stored_profiles), previous_count=previous_count, @@ -398,6 +405,15 @@ def _validate_profile_payload(user_id, payload, dependent_lock_held=False): return None, 'Invalid auth_type' if auth_type == 'tailscale' and payload.get('jump_host_id'): return None, 'Tailscale SSH cannot be used with a jump host' + socks_proxy_id = payload.get('socks_proxy_id') + if socks_proxy_id is not None and ( + not isinstance(socks_proxy_id, str) or len(socks_proxy_id) > 64 + ): + return None, 'Invalid SOCKS proxy reference' + if socks_proxy_id and payload.get('jump_host_id'): + return None, 'A host can use either a jump host or a SOCKS5 proxy, not both' + if socks_proxy_id and auth_type == 'tailscale': + return None, 'Tailscale SSH cannot be used with a SOCKS5 proxy' group, error = _normalize_group(payload.get('group', _UNSET)) if error: @@ -450,6 +466,8 @@ def _validate_profile_payload(user_id, payload, dependent_lock_held=False): jump_host_id = payload.get('jump_host_id') if jump_host_id: result['jump_host_id'] = str(jump_host_id)[:64] + if socks_proxy_id: + result['socks_proxy_id'] = socks_proxy_id return result, None @@ -488,6 +506,15 @@ def upsert_profile(user_id, payload, preserve_legacy_fallback=False, if jump_host is None: return None, 'Jump host not found' + socks_proxy_id = validated.get('socks_proxy_id') + if socks_proxy_id: + from .socks_proxy_manager import ( + _get_socks_proxy_with_coordinator_held, + ) + + if _get_socks_proxy_with_coordinator_held(user_id, socks_proxy_id) is None: + return None, 'SOCKS proxy not found' + if preserve_legacy_fallback and payload.get('startup_commands'): legacy, error = normalize_startup_commands( payload['startup_commands'] @@ -512,7 +539,7 @@ def upsert_profile(user_id, payload, preserve_legacy_fallback=False, same_name = existing.get('name', '').casefold() == validated['name'].casefold() same_endpoint = all( existing.get(field) == validated.get(field) - for field in ('host', 'port', 'username', 'jump_host_id') + for field in ('host', 'port', 'username', 'jump_host_id', 'socks_proxy_id') ) if same_name or same_endpoint: return None, 'Duplicate profile' @@ -616,7 +643,8 @@ def upsert_profile(user_id, payload, preserve_legacy_fallback=False, @user_operation def add_profile(user_id, name, host, port, username, auth_type, key_id=None, - jump_host_id=None, startup_commands=None, command_set_id=None): + jump_host_id=None, startup_commands=None, command_set_id=None, + socks_proxy_id=None): """Compatibility wrapper for callers that create legacy profile payloads.""" payload = { 'name': name, @@ -626,6 +654,7 @@ def add_profile(user_id, name, host, port, username, auth_type, key_id=None, 'auth_type': auth_type, 'key_id': key_id, 'jump_host_id': jump_host_id, + 'socks_proxy_id': socks_proxy_id, } if startup_commands is not None: payload['startup_commands'] = startup_commands @@ -963,7 +992,7 @@ def cache_greeting_platforms(user_id, observed): changed = 0 for profile in profiles: item = wanted.get(profile.get('id')) - if not item or profile.get('jump_host_id'): + if not item or profile.get('jump_host_id') or profile.get('socks_proxy_id'): continue probed, platform = item try: diff --git a/app/socket_events.py b/app/socket_events.py index 187a6dbc..a6e95e00 100644 --- a/app/socket_events.py +++ b/app/socket_events.py @@ -10,7 +10,7 @@ from functools import wraps from . import (socketio, ssh_manager, profile_manager, key_manager, sftp_handler, jump_host_manager, post_connect_manager, - session_insights, runtime_inventory, smb_share_manager) + session_insights, runtime_inventory, smb_share_manager, socks_proxy_manager) from .decorators import socket_login_required from .login_access import serialize_login_access from .auth import ( @@ -2143,6 +2143,7 @@ def initial_inventory(current_user): for stored_profile in profile_manager.load_profiles(current_user.id) ], 'jump_hosts': jump_host_manager.load_jump_hosts(current_user.id), + 'socks_proxies': socks_proxy_manager.load_socks_proxies(current_user.id), } except Exception as error: log_warning("Initial host inventory unavailable", error=str(error)) @@ -2444,6 +2445,75 @@ def handle_delete_jump_host(data, current_user=None): log_error("Failed to delete jump host", error=str(e)) emit('error', {'error': 'Failed to delete jump host'}) +@socketio.on('list_socks_proxies') +@socket_login_required +def handle_list_socks_proxies(current_user=None): + try: + limited = _connection_mutation_rate_limit(current_user) + if limited: + return limited + emit('socks_proxies_list', { + 'socks_proxies': socks_proxy_manager.load_socks_proxies(current_user.id), + }) + except ConnectionStorageLimitError as error: + return _command_set_error(str(error)) + except StorageCorruptionError as error: + return _emit_storage_error(error, current_user) + except Exception as error: + log_error('Failed to load SOCKS proxies', error_type=type(error).__name__) + return _command_set_error('Failed to load SOCKS proxies') + + +@socketio.on('save_socks_proxy') +@socket_login_required +def handle_save_socks_proxy(data, current_user=None): + try: + limited = _connection_mutation_rate_limit(current_user) + if limited: + return limited + data = data if isinstance(data, dict) else {} + proxy, error = socks_proxy_manager.add_socks_proxy( + user_id=current_user.id, name=data.get('name'), host=data.get('host'), + port=data.get('port', 1080), auth_type=data.get('auth_type', 'none'), + username=data.get('username', ''), remote_dns=data.get('remote_dns', True), + socks_proxy_id=data.get('socks_proxy_id'), + ) + if error: + return _command_set_error(error) + payload = {'success': True, 'socks_proxy': proxy} + emit('socks_proxy_saved', payload) + return payload + except StorageCorruptionError as error: + return _emit_storage_error(error, current_user) + except Exception as error: + log_error('Failed to save SOCKS proxy', error_type=type(error).__name__) + return _command_set_error('Failed to save SOCKS proxy') + + +@socketio.on('delete_socks_proxy') +@socket_login_required +def handle_delete_socks_proxy(data, current_user=None): + try: + limited = _connection_mutation_rate_limit(current_user) + if limited: + return limited + data = data if isinstance(data, dict) else {} + proxy_id = data.get('socks_proxy_id') + if not isinstance(proxy_id, str) or not proxy_id or len(proxy_id) > 64: + return _command_set_error('SOCKS proxy ID required') + success, error, usages = socks_proxy_manager.delete_socks_proxy(current_user.id, proxy_id) + if not success: + return _command_set_error(error, usages) + payload = {'success': True, 'socks_proxy_id': proxy_id} + emit('socks_proxy_deleted', payload) + return payload + except StorageCorruptionError as error: + return _emit_storage_error(error, current_user) + except Exception as error: + log_error('Failed to delete SOCKS proxy', error_type=type(error).__name__) + return _command_set_error('Failed to delete SOCKS proxy') + + def _key_summary_rate_limit(current_user): if check_socket_rate_limit( current_user.id, diff --git a/app/socks_proxy_manager.py b/app/socks_proxy_manager.py new file mode 100644 index 00000000..736bcf79 --- /dev/null +++ b/app/socks_proxy_manager.py @@ -0,0 +1,500 @@ +"""Per-user SOCKS5 route metadata; passwords belong to the credential store.""" +import uuid +from datetime import datetime, timezone + +import config + +from .audit_logger import log_error, log_info +from .connection_storage_policy import ( + ConnectionStorageLimitError, + enforce_store_read_limit, + enforce_store_recovery_limit, + enforce_store_transition, + recovery_record_selector, + resolve_recovery_record_selector, + validate_socks_proxy, +) +from .storage_errors import StorageCorruptionError +from .storage_utils import ( + atomic_write_bytes, + load_json_migrated, + safe_reference_name, + storage_lock, +) +from .storage_migrations import CURRENT_STORAGE_VERSIONS +from .data_access import user_operation +from .jump_host_manager import _is_valid_host + + +_SOCKS_PROXY_USAGE_DETAIL_LIMIT = 20 +_SOCKS_PROXY_FIELDS = { + 'id', 'name', 'host', 'port', 'auth_type', 'username', 'remote_dns', + 'created_at', +} + + +def _get_file(user_id): + from .models import User, db + user = db.session.get(User, user_id) + if not user: + return None + return user.get_data_dir() / 'socks_proxies.json' + + +def _valid_socks_proxy(item): + if not isinstance(item, dict) or set(item) != _SOCKS_PROXY_FIELDS: + return False + required_strings = ('id', 'name', 'host', 'username', 'auth_type', 'created_at') + if not all(isinstance(item.get(field), str) for field in required_strings): + return False + if not item['id'] or len(item['id']) > 64 or not item['name'].strip(): + return False + if not _is_valid_host(item['host']): + return False + if type(item.get('port')) is not int or not 1 <= item['port'] <= 65535: + return False + if item['auth_type'] not in {'none', 'password'}: + return False + if type(item.get('remote_dns')) is not bool: + return False + if item['auth_type'] == 'none' and item['username'] != '': + return False + if item['auth_type'] == 'password' and not item['username']: + return False + try: + validate_socks_proxy(item) + except ConnectionStorageLimitError: + return False + return True + + +def _valid_socks_proxy_document(value): + return ( + isinstance(value, dict) + and set(value) == {'schema_version', 'socks_proxies'} + and value.get('schema_version') == CURRENT_STORAGE_VERSIONS['socks_proxies'] + and isinstance(value.get('socks_proxies'), list) + and all(_valid_socks_proxy(item) for item in value['socks_proxies']) + and len({item['id'] for item in value['socks_proxies']}) == len(value['socks_proxies']) + ) + + +def _socks_proxy_migration_payload(path, document): + """Return a quota-safe exact migration payload, or keep it in memory.""" + socks_proxies = document['socks_proxies'] + try: + return enforce_store_transition( + path=path, + other_paths=( + path.parent / 'profiles.json', + path.parent / 'jump_hosts.json', + ), + prospective_document=document, + prospective_count=len(socks_proxies), + previous_count=None, + maximum_count=config.SOCKS_PROXY_MAX_RECORDS, + ) + except ConnectionStorageLimitError: + return None + + +def _load_socks_proxies_with_lock_held(user_id): + path = _get_file(user_id) + if path is None: + return [] + data = load_json_migrated( + path, + 'socks_proxies', + lambda: {'socks_proxies': []}, + _valid_socks_proxy_document, + migration_payload_factory=lambda document: ( + _socks_proxy_migration_payload(path, document) + ), + ) + return data['socks_proxies'] + + +def _load_socks_proxies_for_read_with_lock_held(user_id): + """Load only a response-safe SOCKS-proxy store while its lock is held.""" + path = _get_file(user_id) + if path is None: + return [] + enforce_store_read_limit(path) + socks_proxies = _load_socks_proxies_with_lock_held(user_id) + enforce_store_read_limit( + path, + record_count=len(socks_proxies), + maximum_count=config.SOCKS_PROXY_MAX_RECORDS, + ) + return socks_proxies + + +@user_operation +def load_socks_proxies(user_id): + """Load all SOCKS proxies for a user.""" + # A read can persist a schema migration. Coordinate it with route and + # profile mutations so combined byte accounting cannot observe a stale + # sibling store before the migration replaces this file. + with storage_lock(f'command-config:{user_id}'): + with storage_lock(f'socks_proxies:{user_id}'): + return _load_socks_proxies_for_read_with_lock_held(user_id) + + +@user_operation +def save_socks_proxies( + user_id, + socks_proxies, + *, + previous_count=None, + previous_document=None, + compact=False, +): + try: + f = _get_file(user_id) + document = { + 'schema_version': CURRENT_STORAGE_VERSIONS['socks_proxies'], + 'socks_proxies': socks_proxies, + } + if not f or not _valid_socks_proxy_document(document): + return False + payload = enforce_store_transition( + path=f, + other_paths=(f.parent / 'profiles.json', f.parent / 'jump_hosts.json'), + prospective_document=document, + prospective_count=len(socks_proxies), + previous_count=previous_count, + maximum_count=config.SOCKS_PROXY_MAX_RECORDS, + previous_document=previous_document, + compact=compact, + ) + f.parent.mkdir(parents=True, exist_ok=True) + atomic_write_bytes(f, payload) + return True + except ConnectionStorageLimitError: + raise + except OSError as e: + log_error("Error saving SOCKS proxies", user_id=user_id, error=str(e)) + return False + + +def _load_profile_references(user_id): + from . import profile_manager + + return profile_manager._load_profiles_for_read_with_lock_held(user_id) + + +def _load_socks_proxies_for_recovery_delete(user_id): + """Load legacy SOCKS proxies within the hard recovery ceiling.""" + path = _get_file(user_id) + if path is None: + return [] + enforce_store_recovery_limit(path) + data = load_json_migrated( + path, + 'socks_proxies', + lambda: {'socks_proxies': []}, + _valid_socks_proxy_document, + persist_migration=False, + pre_migration_check=lambda document: enforce_store_recovery_limit( + path, + record_count=( + len(document['socks_proxies']) + if isinstance(document, dict) + and isinstance(document.get('socks_proxies'), list) + else None + ), + ), + ) + socks_proxies = data['socks_proxies'] + enforce_store_recovery_limit( + path, + record_count=len(socks_proxies), + ) + return socks_proxies + + +@user_operation +def load_socks_proxy_recovery_summaries(user_id): + """Return bounded, non-secret selectors for offline legacy recovery.""" + with storage_lock(f'socks_proxies:{user_id}'): + socks_proxies = _load_socks_proxies_for_recovery_delete(user_id) + scope = f'socks-proxies:{user_id}' + return [ + { + 'selector': recovery_record_selector( + scope, + index, + socks_proxy, + ), + 'id': safe_reference_name(socks_proxy.get('id')), + 'name': safe_reference_name(socks_proxy.get('name')), + 'host': safe_reference_name(socks_proxy.get('host')), + } + for index, socks_proxy in enumerate(socks_proxies) + if isinstance(socks_proxy, dict) + ] + + +def _profile_usage_summary(profiles, socks_proxy_id): + usage_count = 0 + usages = [] + for profile in profiles: + if ( + not isinstance(profile, dict) + or profile.get('socks_proxy_id') != socks_proxy_id + ): + continue + usage_count += 1 + if len(usages) < _SOCKS_PROXY_USAGE_DETAIL_LIMIT: + usages.append(safe_reference_name(profile.get('name'))) + return usage_count, usages + + +def _socks_proxy_in_use_result(profiles, socks_proxy_id): + usage_count, usages = _profile_usage_summary(profiles, socks_proxy_id) + if not usage_count: + return None + noun = 'profile' if usage_count == 1 else 'profiles' + details = '' + if usage_count > len(usages): + details = f' (showing first {len(usages)})' + return ( + False, + f'SOCKS proxy is used by {usage_count} {noun}{details}', + usages, + ) + + +def _get_socks_proxy_with_coordinator_held(user_id, socks_proxy_id): + """Return one live SOCKS-proxy snapshot while the coordinator is held.""" + if ( + not isinstance(socks_proxy_id, str) + or not socks_proxy_id + or len(socks_proxy_id) > 64 + ): + return None + with storage_lock(f'socks_proxies:{user_id}'): + socks_proxies = _load_socks_proxies_for_read_with_lock_held(user_id) + for socks_proxy in socks_proxies: + if socks_proxy.get('id') == socks_proxy_id: + return dict(socks_proxy) + return None + + +@user_operation +def get_socks_proxy(user_id, socks_proxy_id): + """Resolve a saved SOCKS proxy from current server-side storage.""" + with storage_lock(f'command-config:{user_id}'): + return _get_socks_proxy_with_coordinator_held( + user_id, socks_proxy_id + ) + + +@user_operation +def add_socks_proxy( + user_id, name, host, port, auth_type='none', username='', + remote_dns=True, socks_proxy_id=None, on_created=None, +): + """Validate and create or update a user-owned SOCKS proxy without storing passwords.""" + try: + if not isinstance(name, str) or not name.strip(): + return None, 'Invalid SOCKS proxy name' + + if not isinstance(host, str) or not _is_valid_host(host): + return None, 'Invalid host format' + host = host.strip() + + if type(port) is not int: + return None, 'Invalid port number' + if not 1 <= port <= 65535: + return None, 'Port must be between 1 and 65535' + + if not isinstance(auth_type, str) or auth_type not in {'none', 'password'}: + return None, 'Invalid auth_type' + if not isinstance(username, str): + return None, 'Invalid SOCKS proxy username' + if auth_type == 'none' and username != '': + return None, 'Username requires password authentication' + if auth_type == 'password' and not username: + return None, 'Username required for password authentication' + if type(remote_dns) is not bool: + return None, 'remote_dns must be a boolean' + if socks_proxy_id is not None and ( + not isinstance(socks_proxy_id, str) + or not socks_proxy_id + or len(socks_proxy_id) > 64 + ): + return None, 'Invalid SOCKS proxy reference' + + # The coordinator serializes cross-store budgets and reference changes. + with storage_lock(f'command-config:{user_id}'): + socks_proxy = { + 'id': str(uuid.uuid4()), + 'name': name.strip(), + 'host': host, + 'port': port, + 'username': username, + 'auth_type': auth_type, + 'remote_dns': remote_dns, + 'created_at': datetime.now(timezone.utc).replace( + tzinfo=None + ).isoformat() + } + validate_socks_proxy(socks_proxy) + with storage_lock(f'socks_proxies:{user_id}'): + transaction_path = _get_file(user_id) + if transaction_path is None: + return None, 'User not found' + transaction_before = ( + transaction_path.read_bytes() + if on_created and transaction_path.exists() + else None + ) + socks_proxies = _load_socks_proxies_for_read_with_lock_held(user_id) + previous_count = len(socks_proxies) + if socks_proxy_id is not None: + existing = next( + (item for item in socks_proxies if item['id'] == socks_proxy_id), + None, + ) + if existing is None: + return None, "SOCKS proxy not found" + socks_proxy['id'] = existing['id'] + socks_proxy['created_at'] = existing['created_at'] + socks_proxies = [ + socks_proxy if item['id'] == socks_proxy_id else item + for item in socks_proxies + ] + else: + socks_proxies.append(socks_proxy) + if save_socks_proxies( + user_id, + socks_proxies, + previous_count=previous_count, + ): + if on_created: + from .credential_store import finish_resource_transaction + finish_resource_transaction( + transaction_path, transaction_before, socks_proxy, + on_created, + ) + log_info( + 'SOCKS proxy saved', user_id=user_id, + proxy_id=socks_proxy['id'], + ) + return socks_proxy, None + return None, "Failed to save SOCKS proxy" + except StorageCorruptionError: + raise + except ConnectionStorageLimitError as exc: + return None, str(exc) + except Exception: + log_error('Error saving SOCKS proxy', user_id=user_id) + return None, 'Failed to save SOCKS proxy' + + +@user_operation +def delete_socks_proxy(user_id, socks_proxy_id): + try: + with storage_lock(f'command-config:{user_id}'): + with storage_lock(f'profiles:{user_id}'): + profiles = _load_profile_references(user_id) + in_use = _socks_proxy_in_use_result(profiles, socks_proxy_id) + if in_use is not None: + return in_use + + with storage_lock(f'socks_proxies:{user_id}'): + socks_proxies = _load_socks_proxies_for_read_with_lock_held(user_id) + index = next( + ( + index + for index, socks_proxy in enumerate(socks_proxies) + if socks_proxy.get('id') == socks_proxy_id + ), + None, + ) + if index is None: + return False, 'SOCKS proxy not found', [] + new_list = list(socks_proxies) + new_list.pop(index) + previous_document = { + 'schema_version': CURRENT_STORAGE_VERSIONS['socks_proxies'], + 'socks_proxies': socks_proxies, + } + if save_socks_proxies( + user_id, + new_list, + previous_count=len(socks_proxies), + previous_document=previous_document, + ): + return True, None, [] + return False, 'Failed to delete SOCKS proxy', [] + except StorageCorruptionError: + raise + except ConnectionStorageLimitError as exc: + return False, str(exc), [] + except Exception as e: + log_error("Error deleting SOCKS proxy", user_id=user_id, error=str(e)) + return False, 'Failed to delete SOCKS proxy', [] + + +@user_operation +def delete_socks_proxy_recovery_record(user_id, selector): + """Delete one selector-bound SOCKS proxy from an offline recovery store.""" + try: + with storage_lock(f'command-config:{user_id}'): + from . import profile_manager + + with storage_lock(f'profiles:{user_id}'): + profiles, error = ( + profile_manager._load_profiles_for_recovery_delete(user_id) + ) + if error: + return False, error, [] + with storage_lock(f'socks_proxies:{user_id}'): + socks_proxies = _load_socks_proxies_for_recovery_delete(user_id) + index = resolve_recovery_record_selector( + f'socks-proxies:{user_id}', + socks_proxies, + selector, + ) + if index is None: + return ( + False, + 'Recovery selector not found; list the store again.', + [], + ) + socks_proxy_id = socks_proxies[index].get('id') + in_use = _socks_proxy_in_use_result( + profiles, + socks_proxy_id, + ) + if in_use is not None: + return in_use + + remaining = list(socks_proxies) + remaining.pop(index) + previous_document = { + 'schema_version': CURRENT_STORAGE_VERSIONS['socks_proxies'], + 'socks_proxies': socks_proxies, + } + if save_socks_proxies( + user_id, + remaining, + previous_count=len(socks_proxies), + previous_document=previous_document, + compact=True, + ): + return True, None, [] + return False, 'Failed to delete SOCKS proxy', [] + except StorageCorruptionError: + raise + except ConnectionStorageLimitError as exc: + return False, str(exc), [] + except Exception as exc: + log_error( + 'Error deleting recovery SOCKS proxy', + user_id=user_id, + error=str(exc), + ) + return False, 'Failed to delete SOCKS proxy', [] diff --git a/app/storage_migrations.py b/app/storage_migrations.py index 89fa3aea..bbaf617b 100644 --- a/app/storage_migrations.py +++ b/app/storage_migrations.py @@ -20,6 +20,7 @@ 'profiles': 3, 'command_sets': 2, 'jump_hosts': 2, + 'socks_proxies': 2, 'keys': 2, 'settings': 2, 'app_settings': 2, @@ -86,6 +87,14 @@ def migrate_jump_hosts_v1_to_v2(document): return _version_document(document, 2) +def migrate_socks_proxies_v0_to_v1(document): + return _version_document(document, 1) + + +def migrate_socks_proxies_v1_to_v2(document): + return _version_document(document, 2) + + def migrate_keys_v0_to_v1(document): return _version_document(document, 1) diff --git a/tests/test_socks_proxy_manager.py b/tests/test_socks_proxy_manager.py new file mode 100644 index 00000000..59565316 --- /dev/null +++ b/tests/test_socks_proxy_manager.py @@ -0,0 +1,460 @@ +"""SOCKS metadata, route ownership, and shared storage budgets.""" + +import json +import threading + +import pytest + +from app.storage_errors import StorageCorruptionError + + +def _create_user(app, name='socks-store-user'): + from app.models import User, db + + with app.app_context(): + user = User(username=name, password_hash='unused') + db.session.add(user) + db.session.commit() + return user.id + + +def _profile(**fields): + return {'name': 'Target', 'host': 'target.example', 'port': 22, + 'username': 'deploy', 'auth_type': 'password', **fields} + + +def test_socks_proxy_crud_is_owned_and_preserves_identity_on_update(app): + from app import socks_proxy_manager as manager + + user_id = _create_user(app) + other_id = _create_user(app, 'other-socks-user') + with app.app_context(): + assert manager.load_socks_proxies(user_id) == [] + proxy, error = manager.add_socks_proxy(user_id, ' Proxy ', 'proxy.example', 1080) + assert error is None + assert proxy['username'] == '' + assert proxy['remote_dns'] is True + assert manager.get_socks_proxy(other_id, proxy['id']) is None + missing, error = manager.add_socks_proxy(other_id, 'Foreign', 'other.example', 1080, + socks_proxy_id=proxy['id']) + assert missing is None and 'not found' in error + updated, error = manager.add_socks_proxy(user_id, 'Edited', 'next.example', 1081, + 'password', 'proxy-user', False, socks_proxy_id=proxy['id']) + assert error is None + assert updated['id'] == proxy['id'] + assert updated['created_at'] == proxy['created_at'] + assert manager.get_socks_proxy(user_id, proxy['id']) == updated + assert manager.delete_socks_proxy(other_id, proxy['id'])[0] is False + assert manager.delete_socks_proxy(user_id, proxy['id']) == (True, None, []) + assert manager.load_socks_proxies(user_id) == [] + + +@pytest.mark.parametrize('changes', [ + {'name': ''}, {'name': 17}, {'name': 'x' * 513}, {'host': []}, + {'host': 'https://proxy.example'}, {'port': True}, {'port': '1080'}, + {'port': 1080.0}, {'port': 0}, {'port': 65536}, {'auth_type': 'key'}, + {'auth_type': 'PASSWORD'}, {'username': None}, {'username': 'ignored'}, + {'auth_type': 'password', 'username': ''}, + {'auth_type': 'password', 'username': 'é' * 128}, + {'auth_type': 'password', 'username': '\ud800'}, + {'remote_dns': 'false'}, {'remote_dns': 1}, {'remote_dns': None}, + {'socks_proxy_id': 7}, {'socks_proxy_id': 'a' * 65}, +]) +def test_invalid_proxy_metadata_never_creates_a_store(app, changes): + from app import socks_proxy_manager as manager + + user_id = _create_user(app) + fields = {'name': 'Proxy', 'host': 'proxy.example', 'port': 1080, + 'auth_type': 'none', 'username': '', 'remote_dns': True, **changes} + with app.app_context(): + proxy, error = manager.add_socks_proxy(user_id, **fields) + assert proxy is None and error + assert not manager._get_file(user_id).exists() + + +def test_proxy_username_accepts_full_rfc1929_utf8_budget(app): + from app import socks_proxy_manager as manager + + user_id = _create_user(app) + with app.app_context(): + proxy, error = manager.add_socks_proxy(user_id, 'Proxy', '::1', 1080, + 'password', 'é' * 127 + 'x') + assert error is None + assert proxy['username'] == 'é' * 127 + 'x' + + +@pytest.mark.parametrize('changes', [ + {'password': 'never-persist'}, {'credential_id': 'secret-reference'}, + {'unknown': True}, {'auth_type': 'key'}, {'remote_dns': 1}, {'port': 0}, + {'port': True}, {'username': None}, {'host': 'bad/host'}, +]) +def test_socks_schema_rejects_invalid_records_without_overwrite(app, changes): + from app import socks_proxy_manager as manager + + user_id = _create_user(app) + with app.app_context(): + record = {'id': 'proxy-1', 'name': 'Proxy', 'host': 'proxy.example', + 'port': 1080, 'auth_type': 'none', 'username': '', + 'remote_dns': True, 'created_at': '2026-01-01', **changes} + path = manager._get_file(user_id) + raw = json.dumps({'socks_proxies': [record]}).encode() + path.write_bytes(raw) + with pytest.raises(StorageCorruptionError): + manager.add_socks_proxy(user_id, 'Another', 'next.example', 1080) + assert path.read_bytes() == raw + + +def test_corrupt_socks_json_is_preserved(app): + from app import socks_proxy_manager as manager + + user_id = _create_user(app) + with app.app_context(): + path = manager._get_file(user_id) + path.write_bytes(b'{"socks_proxies": [') + with pytest.raises(StorageCorruptionError): + manager.delete_socks_proxy(user_id, 'any') + assert path.read_bytes() == b'{"socks_proxies": [' + + +def _write_duplicate_proxy_ids(manager, user_id): + record = { + 'id': 'duplicate-proxy', 'name': 'Proxy', 'host': 'proxy.example', + 'port': 1080, 'auth_type': 'none', 'username': '', + 'remote_dns': True, 'created_at': '2026-01-01', + } + raw = json.dumps({'socks_proxies': [ + record, {**record, 'host': 'other.example'}, + ]}).encode('utf-8') + path = manager._get_file(user_id) + path.write_bytes(raw) + return path, raw + + +def test_duplicate_proxy_ids_reject_mutation_without_overwrite(app): + from app import socks_proxy_manager as manager + + user_id = _create_user(app) + with app.app_context(): + path, original = _write_duplicate_proxy_ids(manager, user_id) + with pytest.raises(StorageCorruptionError): + manager.add_socks_proxy(user_id, 'Edited', 'edited.example', 1080, + socks_proxy_id='duplicate-proxy') + assert path.read_bytes() == original + assert list(path.parent.glob('socks_proxies.json.*.bak')) == [] + + +def test_live_proxy_lookup_rejects_duplicate_ids(app): + from app import socks_proxy_manager as manager + + user_id = _create_user(app) + with app.app_context(): + path, original = _write_duplicate_proxy_ids(manager, user_id) + with pytest.raises(StorageCorruptionError): + manager.get_socks_proxy(user_id, 'duplicate-proxy') + assert path.read_bytes() == original + + +def test_proxy_callback_failure_rolls_back_storage(app): + from app import socks_proxy_manager as manager + + user_id = _create_user(app) + with app.app_context(): + original, error = manager.add_socks_proxy(user_id, 'Original', 'proxy.example', 1080) + assert error is None + path = manager._get_file(user_id) + before = path.read_bytes() + + def fail(_record): + raise ValueError('Credential transaction failed') + + result, error = manager.add_socks_proxy(user_id, 'Edited', 'next.example', 1080, + socks_proxy_id=original['id'], on_created=fail) + assert result is None and error + assert path.read_bytes() == before + + +def test_socks_count_limit_and_reference_protection(app, monkeypatch): + import config + from app import profile_manager, socks_proxy_manager as manager + + user_id = _create_user(app) + with app.app_context(): + monkeypatch.setattr(config, 'SOCKS_PROXY_MAX_RECORDS', 1) + proxy, error = manager.add_socks_proxy(user_id, 'Proxy', 'proxy.example', 1080) + assert error is None + assert manager.add_socks_proxy(user_id, 'Second', 'next.example', 1080)[0] is None + for index in range(23): + profile, error = profile_manager.upsert_profile(user_id, + _profile(name=f'Host {index}', socks_proxy_id=proxy['id'])) + assert error is None + deleted, error, usages = manager.delete_socks_proxy(user_id, proxy['id']) + assert deleted is False + assert '23 profiles' in error and 'first 20' in error + assert usages == [f'Host {index}' for index in range(20)] + assert manager.get_socks_proxy(user_id, proxy['id']) is not None + + +@pytest.mark.parametrize('route', [ + {'socks_proxy_id': 'missing'}, {'socks_proxy_id': 123}, + {'socks_proxy_id': []}, {'socks_proxy_id': 'a' * 65}, + {'socks_proxy_id': 'proxy', 'jump_host_id': 'jump'}, + {'socks_proxy_id': 'proxy', 'auth_type': 'tailscale'}, +]) +def test_profile_rejects_invalid_or_conflicting_socks_routes(app, route): + from app import profile_manager + + user_id = _create_user(app) + with app.app_context(): + for skip_duplicate in (False, True): + result, error = profile_manager.upsert_profile(user_id, _profile(**route), + skip_duplicate=skip_duplicate) + assert result is None and error + assert profile_manager.load_profiles(user_id) == [] + + +def test_profile_socks_route_roundtrip_live_ownership_and_duplicate_check(app): + from app import profile_manager, socks_proxy_manager as manager + + user_id = _create_user(app) + other_id = _create_user(app, 'other-profile-owner') + with app.app_context(): + proxy, error = manager.add_socks_proxy(user_id, 'Proxy', 'proxy.example', 1080) + assert error is None + assert profile_manager.upsert_profile(other_id, + _profile(socks_proxy_id=proxy['id'])) == (None, 'SOCKS proxy not found') + direct, error = profile_manager.upsert_profile(user_id, _profile()) + assert error is None + routed, error = profile_manager.upsert_profile(user_id, + _profile(name='Routed', socks_proxy_id=proxy['id']), skip_duplicate=True) + assert error is None + assert profile_manager.get_profile(user_id, routed['id'])['socks_proxy_id'] == proxy['id'] + assert profile_manager.upsert_profile(user_id, + _profile(name='Duplicate', socks_proxy_id=proxy['id']), skip_duplicate=True)[0] is None + assert profile_manager.cache_greeting_platforms(user_id, + {routed['id']: (('target.example', 22), 'linux')}) == 0 + cleared, error = profile_manager.upsert_profile(user_id, + _profile(id=routed['id'], name='Cleared', socks_proxy_id=None)) + assert error is None and not cleared.get('socks_proxy_id') + assert direct['id'] != routed['id'] + + +def test_all_stores_include_both_siblings_in_combined_byte_budget(app, monkeypatch): + import config + from app import profile_manager, jump_host_manager, socks_proxy_manager as manager + + user_id = _create_user(app) + with app.app_context(): + assert profile_manager.upsert_profile(user_id, _profile())[1] is None + assert jump_host_manager.add_jump_host(user_id, 'Jump', 'jump.example', 22, + 'deploy', 'password')[1] is None + assert manager.add_socks_proxy(user_id, 'Proxy', 'proxy.example', 1080)[1] is None + paths = [profile_manager.get_user_profiles_file(user_id), + jump_host_manager._get_file(user_id), manager._get_file(user_id)] + before = [path.read_bytes() for path in paths] + monkeypatch.setattr(config, 'CONNECTION_CONFIG_MAX_BYTES', sum(map(len, before))) + assert profile_manager.upsert_profile(user_id, _profile(name='Big' * 40, + host='next.example', startup_commands='x' * 1000))[0] is None + assert jump_host_manager.add_jump_host(user_id, 'Big' * 40, 'next.example', 22, + 'deploy', 'password')[0] is None + assert manager.add_socks_proxy(user_id, 'Big' * 40, 'next.example', 1080)[0] is None + assert [path.read_bytes() for path in paths] == before + + +def test_shared_budget_deduplicates_siblings_and_own_path(tmp_path, monkeypatch): + import config + from app.connection_storage_policy import enforce_store_transition + + path = tmp_path / 'own.json' + path.write_bytes(b'0' * 500) + sibling = tmp_path / 'sibling.json' + sibling.write_bytes(b'0' * 100) + monkeypatch.setattr(config, 'CONNECTION_CONFIG_MAX_BYTES', 610) + payload = enforce_store_transition(path=path, other_path=sibling, + other_paths=[sibling, tmp_path / '.' / 'sibling.json', path], + prospective_document={'data': 'x' * 490}, prospective_count=1, + previous_count=1, maximum_count=100) + assert 500 < len(payload) <= 510 + + +def test_socks_recovery_cli_bounds_output_and_deletes_oversized_store(app, monkeypatch): + import config + from app import socks_proxy_manager as manager + from app.connection_storage_policy import ConnectionStorageLimitError + + user_id = _create_user(app) + with app.app_context(): + proxy, error = manager.add_socks_proxy(user_id, 'Proxy', 'proxy.example', 1080, + 'password', 'private-username') + assert error is None + monkeypatch.setattr(config, 'CONNECTION_STORE_MAX_BYTES', 100) + with pytest.raises(ConnectionStorageLimitError): + manager.load_socks_proxies(user_id) + runner = app.test_cli_runner() + base = ['--username', 'socks-store-user', '--kind', 'socks-proxies'] + assert runner.invoke(args=['connection-store', 'list', *base]).exit_code != 0 + result = runner.invoke(args=['connection-store', 'list', *base, '--confirm-offline']) + assert result.exit_code == 0, result.output + assert 'private-username' not in result.output + summary = json.loads(result.output) + assert summary['count'] == 1 + selector = summary['records'][0]['selector'] + result = runner.invoke(args=['connection-store', 'delete', *base, + '--selector', selector, '--confirm-offline']) + assert result.exit_code == 0, result.output + with app.app_context(): + assert manager.load_socks_proxies(user_id) == [] + + +def test_session_schema_additively_preserves_legacy_rows(app): + from sqlalchemy import text + from app.models import db, ensure_ssh_session_columns + + with app.app_context(): + db.session.execute(text('DROP TABLE ssh_sessions')) + db.session.execute(text('CREATE TABLE ssh_sessions (id INTEGER PRIMARY KEY, session_id VARCHAR(36))')) + db.session.execute(text("INSERT INTO ssh_sessions (id, session_id) VALUES (1, 'legacy')")) + db.session.commit() + ensure_ssh_session_columns() + ensure_ssh_session_columns() + row = db.session.execute(text('SELECT session_id, socks_proxy_id, via_socks FROM ssh_sessions')).one() + assert tuple(row) == ('legacy', None, 0) + + +@pytest.mark.parametrize('fields', [ + {'socks_proxy_id': 'x' * 65}, + {'socks_proxy_id': 'proxy', 'jump_host_id': 'jump'}, + {'socks_proxy_id': 'proxy', 'auth_type': 'tailscale'}, +]) +def test_profile_store_rejects_malformed_socks_routes(app, fields): + from app import profile_manager + + user_id = _create_user(app) + with app.app_context(): + assert profile_manager.save_profiles(user_id, [ + _profile(id='profile', **fields) + ]) is False + assert not profile_manager.get_user_profiles_file(user_id).exists() + + +def test_socks_store_migration_rejects_unknown_document_fields(app): + from app import socks_proxy_manager as manager + + user_id = _create_user(app) + with app.app_context(): + path = manager._get_file(user_id) + original = b'{"socks_proxies": [], "password": "never-write-this"}' + path.write_bytes(original) + with pytest.raises(StorageCorruptionError): + manager.load_socks_proxies(user_id) + assert path.read_bytes() == original + + +def test_socks_recovery_reference_guard_and_stale_selector(app, monkeypatch): + import config + from app import profile_manager, socks_proxy_manager as manager + + user_id = _create_user(app) + other_id = _create_user(app, 'other-recovery-user') + with app.app_context(): + proxy, error = manager.add_socks_proxy(user_id, 'Proxy', 'proxy.example', 1080) + assert error is None + selector = manager.load_socks_proxy_recovery_summaries(user_id)[0]['selector'] + assert manager.delete_socks_proxy_recovery_record(other_id, selector)[0] is False + manager.add_socks_proxy(user_id, 'Updated', 'proxy.example', 1080, + socks_proxy_id=proxy['id']) + assert manager.delete_socks_proxy_recovery_record(user_id, selector)[0] is False + profile, error = profile_manager.add_profile(user_id, 'Target', 'target.example', + 22, 'deploy', 'password', socks_proxy_id=proxy['id']) + assert error is None and profile['socks_proxy_id'] == proxy['id'] + selector = manager.load_socks_proxy_recovery_summaries(user_id)[0]['selector'] + monkeypatch.setattr(config, 'CONNECTION_STORE_MAX_BYTES', 100) + deleted, error, usages = manager.delete_socks_proxy_recovery_record(user_id, selector) + assert deleted is False and '1 profile' in error + assert usages == ['Target'] + + +def test_socks_recovery_enforces_hard_byte_and_record_ceilings(app, monkeypatch): + import config + from app import socks_proxy_manager as manager + from app.connection_storage_policy import ConnectionStorageLimitError + + user_id = _create_user(app) + with app.app_context(): + assert manager.add_socks_proxy(user_id, 'Proxy', 'proxy.example', 1080)[1] is None + original = manager._get_file(user_id).read_bytes() + monkeypatch.setattr(config, 'CONNECTION_STORE_RECOVERY_MAX_RECORDS', 0) + with pytest.raises(ConnectionStorageLimitError): + manager.load_socks_proxy_recovery_summaries(user_id) + monkeypatch.setattr(config, 'CONNECTION_STORE_RECOVERY_MAX_RECORDS', 100) + monkeypatch.setattr(config, 'CONNECTION_STORE_RECOVERY_MAX_BYTES', len(original) - 1) + with pytest.raises(ConnectionStorageLimitError): + manager.load_socks_proxy_recovery_summaries(user_id) + assert manager._get_file(user_id).read_bytes() == original + + +def test_oversized_socks_store_cannot_grow_and_recovery_shrinks_it(app, monkeypatch): + import config + from app import socks_proxy_manager as manager + + user_id = _create_user(app) + with app.app_context(): + proxy, error = manager.add_socks_proxy(user_id, 'Proxy', 'proxy.example', 1080) + assert error is None + path = manager._get_file(user_id) + original = path.read_bytes() + monkeypatch.setattr(config, 'CONNECTION_STORE_MAX_BYTES', len(original) - 1) + result, error = manager.add_socks_proxy(user_id, 'Second', 'other.example', 1080) + assert result is None and 'quota exceeded' in error + assert path.read_bytes() == original + selector = manager.load_socks_proxy_recovery_summaries(user_id)[0]['selector'] + assert manager.delete_socks_proxy_recovery_record(user_id, selector) == (True, None, []) + assert path.stat().st_size < len(original) + assert manager.load_socks_proxies(user_id) == [] + + +def test_socks_delete_and_stale_profile_edit_are_serialized(app, monkeypatch): + from app import socks_proxy_manager as manager, profile_manager + + user_id = _create_user(app) + with app.app_context(): + proxy, error = manager.add_socks_proxy(user_id, 'Proxy', 'proxy.example', 1080) + assert error is None + references_read = threading.Event() + continue_delete = threading.Event() + profile_done = threading.Event() + results = {} + real_load = manager._load_profile_references + + def pause_after_read(value): + profiles = real_load(value) + references_read.set() + assert continue_delete.wait(timeout=3) + return profiles + + monkeypatch.setattr(manager, '_load_profile_references', pause_after_read) + + def delete(): + with app.app_context(): + results['delete'] = manager.delete_socks_proxy(user_id, proxy['id']) + + def write_profile(): + with app.app_context(): + results['profile'] = profile_manager.upsert_profile(user_id, + _profile(socks_proxy_id=proxy['id'])) + profile_done.set() + + deleter = threading.Thread(target=delete, daemon=True) + writer = threading.Thread(target=write_profile, daemon=True) + try: + deleter.start() + assert references_read.wait(timeout=3) + writer.start() + assert not profile_done.wait(timeout=0.2) + finally: + continue_delete.set() + deleter.join(timeout=3) + if writer.ident is not None: + writer.join(timeout=3) + assert not deleter.is_alive() and not writer.is_alive() + assert results['delete'] == (True, None, []) + assert results['profile'] == (None, 'SOCKS proxy not found') diff --git a/tests/test_socks_proxy_socket.py b/tests/test_socks_proxy_socket.py new file mode 100644 index 00000000..cf1e5588 --- /dev/null +++ b/tests/test_socks_proxy_socket.py @@ -0,0 +1,92 @@ +"""User-owned SOCKS resources and fail-closed connection entry points.""" + +import pytest +from flask import request + +from app import socket_events as events + +pytestmark = pytest.mark.usefixtures('direct_socket_authentication') + + +def user_socket(app, name): + from app.auth import register_user, register_socket_session + from app.models import db + with app.app_context(): + user, error = register_user(name, 'socket-password-123') + assert error is None + sid = name + '-socket' + register_socket_session(user.id, sid) + db.session.commit() + return user.id, sid + + +def call(app, monkeypatch, handler, sid, payload=None): + emitted = [] + monkeypatch.setattr(events, 'emit', lambda event, data=None, **kw: emitted.append((event, data))) + with app.test_request_context('/socket.io'): + request.sid = sid + result = handler() if payload is None else handler(payload) + return result, emitted + + +def test_socks_crud_inventory_and_user_isolation(app, monkeypatch): + from app.models import User, db + owner, sid = user_socket(app, 'socks_owner') + other, other_sid = user_socket(app, 'socks_other') + saved, emitted = call(app, monkeypatch, events.handle_save_socks_proxy, sid, { + 'name': 'Office proxy', 'host': 'proxy.example', 'port': 1080, + 'auth_type': 'password', 'username': 'proxy-user', 'remote_dns': True, + 'password': 'must-not-persist', 'user_id': other, + }) + assert saved['success'] is True + proxy = saved['socks_proxy'] + assert 'password' not in proxy and 'user_id' not in proxy + assert emitted == [('socks_proxy_saved', saved)] + _, emitted = call(app, monkeypatch, events.handle_list_socks_proxies, sid) + assert emitted == [('socks_proxies_list', {'socks_proxies': [proxy]})] + _, emitted = call(app, monkeypatch, events.handle_list_socks_proxies, other_sid) + assert emitted == [('socks_proxies_list', {'socks_proxies': []})] + with app.app_context(): + inventory = events.initial_inventory(db.session.get(User, owner)) + assert inventory['socks_proxies'] == [proxy] + assert b'must-not-persist' not in ( + db.session.get(User, owner).get_data_dir() / 'socks_proxies.json' + ).read_bytes() + denied, _ = call(app, monkeypatch, events.handle_delete_socks_proxy, other_sid, + {'socks_proxy_id': proxy['id']}) + assert denied['success'] is False + deleted, emitted = call(app, monkeypatch, events.handle_delete_socks_proxy, sid, + {'socks_proxy_id': proxy['id']}) + assert deleted == {'success': True, 'socks_proxy_id': proxy['id']} + assert emitted == [('socks_proxy_deleted', deleted)] + + +def test_socks_crud_rejects_used_proxy_and_limits_mutations(app, monkeypatch): + from app import profile_manager, socks_proxy_manager + owner, sid = user_socket(app, 'socks_usage') + with app.app_context(): + proxy, error = socks_proxy_manager.add_socks_proxy(owner, 'Proxy', 'proxy.example', 1080) + assert error is None + profile, error = profile_manager.add_profile( + owner, 'Target', 'target.example', 22, 'ops', 'password', socks_proxy_id=proxy['id'], + ) + assert error is None + denied, _ = call(app, monkeypatch, events.handle_delete_socks_proxy, sid, + {'socks_proxy_id': proxy['id']}) + assert denied['success'] is False + assert 'Target' in denied['usages'] + monkeypatch.setattr(events, '_connection_mutation_rate_limit', + lambda user: {'success': False, 'error': 'limited'}) + for handler, payload in [ + (events.handle_save_socks_proxy, {'name': 'Blocked'}), + (events.handle_delete_socks_proxy, {'socks_proxy_id': proxy['id']}), + ]: + result, _ = call(app, monkeypatch, handler, sid, payload) + assert result == {'success': False, 'error': 'limited'} + + +@pytest.mark.parametrize('payload', [{}, {'port': True}, {'remote_dns': 'false'}]) +def test_socks_crud_invalid_payload_is_safe(app, monkeypatch, payload): + _, sid = user_socket(app, 'socks_invalid') + result, _ = call(app, monkeypatch, events.handle_save_socks_proxy, sid, payload) + assert result['success'] is False diff --git a/tests/test_storage_migrations.py b/tests/test_storage_migrations.py index 3783c13c..c9f1132d 100644 --- a/tests/test_storage_migrations.py +++ b/tests/test_storage_migrations.py @@ -21,6 +21,7 @@ 'profiles': {'profiles': []}, 'command_sets': {'command_sets': []}, 'jump_hosts': {'jump_hosts': []}, + 'socks_proxies': {'socks_proxies': []}, 'keys': {'keys': []}, 'settings': {'theme': 'glass'}, 'app_settings': {'registration_enabled': True}, From aa4ac0c5c1aacaad56012d6d2e1dd7ec7e5152b0 Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Sat, 14 Nov 2026 10:08:24 +0100 Subject: [PATCH 03/12] Route SSH sessions and credentials through saved SOCKS proxies --- app/automation_routes.py | 3 + app/automation_service.py | 1 + app/automation_store.py | 6 +- app/automation_transport.py | 4 + app/credential_connections.py | 48 ++-- app/credential_service.py | 90 +++++-- app/customer_workflows.py | 7 +- app/discovery_routes.py | 3 +- app/host_context.py | 3 +- app/host_platform.py | 3 +- app/overview_routes.py | 41 +++- app/profile_exchange.py | 15 ++ app/profile_exchange_routes.py | 11 +- app/socket_events.py | 101 +++++++- app/ssh_manager.py | 76 +++++- tests/test_host_platform.py | 28 +++ tests/test_reconnect_routing.py | 24 +- tests/test_socks_connection_socket.py | 238 +++++++++++++++++++ tests/test_socks_credentials.py | 327 ++++++++++++++++++++++++++ tests/test_socks_secondary_routes.py | 255 ++++++++++++++++++++ tests/test_ssh_manager.py | 129 ++++++++++ 21 files changed, 1360 insertions(+), 53 deletions(-) create mode 100644 tests/test_socks_connection_socket.py create mode 100644 tests/test_socks_credentials.py create mode 100644 tests/test_socks_secondary_routes.py diff --git a/app/automation_routes.py b/app/automation_routes.py index 9ed7d585..97041b4c 100644 --- a/app/automation_routes.py +++ b/app/automation_routes.py @@ -123,6 +123,9 @@ def target(profile): if route is not None: entry.update(via=route['name'], route_id=route['id'], route_auth_type=route.get('auth_type'), route_credential_status=status(route)) + if profile.get('socks_proxy_id'): + entry.update(socks_proxy_id=profile['socks_proxy_id'], + unsupported_reason='SOCKS5 routes are not supported for automation yet') return entry return jsonify(enabled=True, credentials_enabled=is_credentials_enabled(), runbooks=runbooks(current_user.id), diff --git a/app/automation_service.py b/app/automation_service.py index 5ffa92f7..3e5981e9 100644 --- a/app/automation_service.py +++ b/app/automation_service.py @@ -41,6 +41,7 @@ def password_resources(targets): def supported_login(item): return (item is not None and item.get('auth_type') in {'key', 'password'} + and not item.get('socks_proxy_id') and item.get('kind') != 'socks' and bool(item.get('username')) and ':' not in item['username']) diff --git a/app/automation_store.py b/app/automation_store.py index cc1c0bab..f4e1e0dd 100644 --- a/app/automation_store.py +++ b/app/automation_store.py @@ -70,8 +70,12 @@ def profile_fingerprint(profile): )} # Preserve the existing SSH binding for v1 vaults. Namespaced resources have # explicit types and their additional security-relevant connection fields. - if profile.get('kind') in {'jump', 'smb'}: + if profile.get('socks_proxy_id'): + fields['socks_proxy_id'] = profile['socks_proxy_id'] + if profile.get('kind') in {'jump', 'smb', 'socks'}: fields['kind'] = profile['kind'] + if profile.get('kind') == 'socks': + fields['remote_dns'] = profile.get('remote_dns', True) if profile.get('kind') == 'smb': fields.update(domain=profile.get('domain', ''), share=profile.get('share')) if profile.get('route_fingerprint'): diff --git a/app/automation_transport.py b/app/automation_transport.py index 684916a1..dc6670bf 100644 --- a/app/automation_transport.py +++ b/app/automation_transport.py @@ -141,6 +141,10 @@ def connect_profile(user_id, profile, password, cancelled, own=lambda _resource: Both hops use the account's verified host keys, the SSH network policy and their own credentials; nothing is accepted on first use. """ + if profile.get('socks_proxy_id') or profile.get('kind') == 'socks' or ( + route and (route.get('kind') == 'socks' or route.get('socks_proxy_id')) + ): + raise AutomationError('unsupported_target') opened = [] def hold(resource): diff --git a/app/credential_connections.py b/app/credential_connections.py index 1f70b34a..4d1069f8 100644 --- a/app/credential_connections.py +++ b/app/credential_connections.py @@ -8,12 +8,36 @@ from paramiko.auth_strategy import AuthStrategy -def _saved_route(data): - route = data.get('proxy_jump') - if route and (not isinstance(route, dict) or not isinstance(route.get('jump_host_id'), str) - or not route['jump_host_id']): +def _saved_route(data, kind='jump'): + field, reference = ('socks_proxy', 'socks_proxy_id') if kind == 'socks' else ('proxy_jump', 'jump_host_id') + route = data.get(field) + if route is not None and (not isinstance(route, dict) or not isinstance(route.get(reference), str) + or not route[reference]): raise AutomationError('credential_target_changed') - return route.get('jump_host_id') if isinstance(route, dict) else None + return route.get(reference) if isinstance(route, dict) else None + + +def _connection_target(kind, data): + """Keep the proxy endpoint distinct from a host's nested route.""" + from .credential_service import CredentialService + connection = dict(data) + if kind in {'ssh', 'jump', 'socks'}: + connection['kind'] = kind + connection.setdefault('auth_type', 'password') + connection.setdefault('port', 1080 if kind == 'socks' else 22) + if kind == 'jump': + connection['jump_host_id'] = _saved_route(data) + connection = CredentialService._normalize_target(connection) + if kind == 'ssh': + # Only nested routes are consumed by the SSH transport. A claimed + # profile reference alone must not authorize a direct connection. + connection['jump_host_id'] = _saved_route(data) + socks_id = _saved_route(data, 'socks') + if socks_id: + connection['socks_proxy_id'] = socks_id + else: + connection.pop('socks_proxy_id', None) + return connection class SavedPasswordAuth(AuthStrategy): @@ -40,13 +64,11 @@ def __init__(self, user_id, kind, data): raise AutomationError('authentication_required') self.app = current_app._get_current_object() self.service = self.app.extensions['credentials'] - connection = deepcopy({key: value for key, value in data.items() if key in { + connection = _connection_target(kind, data) + connection = deepcopy({key: value for key, value in connection.items() if key in { 'host', 'port', 'username', 'auth_type', 'key_id', 'jump_host_id', 'domain', 'share', + 'socks_proxy_id', 'remote_dns', 'kind', }}) - if kind in {'ssh', 'jump'}: - connection.setdefault('auth_type', 'password') - connection.setdefault('port', 22) - connection['jump_host_id'] = _saved_route(data) self.target_token=data.get('credential_target_token') self.target_args=(user_id,auth.id,data['credential_id'],connection,self.target_token) self.args = (user_id, auth.id, kind, data['credential_id'], connection) @@ -91,11 +113,7 @@ def connection_password(user_id, kind, data): raise AutomationError('authentication_required') if not isinstance(reference, str) or not reference or data.get('password'): raise AutomationError('invalid_request') - connection = dict(data) - if kind in {'ssh', 'jump'}: - connection.setdefault('auth_type', 'password') - connection.setdefault('port', 22) - connection['jump_host_id'] = _saved_route(data) + connection = _connection_target(kind, data) if data.get('credential_target_token'): return current_app.extensions['credentials'].target_password(user_id,auth.id,reference,connection,data['credential_target_token']) return current_app.extensions['credentials'].connection_password( diff --git a/app/credential_service.py b/app/credential_service.py index 957b5941..1c80ff64 100644 --- a/app/credential_service.py +++ b/app/credential_service.py @@ -15,6 +15,7 @@ from .models import AuthenticationSession, User, as_naive_utc, db from .profile_manager import load_profiles from .jump_host_manager import load_jump_hosts +from .socks_proxy_manager import load_socks_proxies from .smb_share_manager import load_smb_shares def session_identity(user_id, auth_id): @@ -86,23 +87,32 @@ def resources(self, user_id, *, coordinator_held=False): if coordinator_held: from .profile_manager import _load_profiles_for_read_with_lock_held from .jump_host_manager import _load_jump_hosts_for_read_with_lock_held + from .socks_proxy_manager import _load_socks_proxies_for_read_with_lock_held from .smb_share_manager import _load_with_lock_held jumps=_load_jump_hosts_for_read_with_lock_held(user_id) + proxies=_load_socks_proxies_for_read_with_lock_held(user_id) profiles=_load_profiles_for_read_with_lock_held(user_id) shares=_load_with_lock_held(user_id) else: jumps=load_jump_hosts(user_id) + proxies=load_socks_proxies(user_id) profiles=load_profiles(user_id) shares=load_smb_shares(user_id) by_id = {item['id']: item for item in jumps} + typed_proxies = [dict(item, id='socks:'+item['id'], kind='socks') for item in proxies] + proxies_by_id = {item['id']: typed for item, typed in zip(proxies, typed_proxies)} result = [] for item in profiles: resource = dict(item, kind='ssh') if item.get('jump_host_id'): jump = by_id.get(item['jump_host_id']) resource['route_fingerprint'] = profile_fingerprint(jump or {}) + if item.get('socks_proxy_id'): + proxy = proxies_by_id.get(item['socks_proxy_id']) + resource['route_fingerprint'] = profile_fingerprint(proxy or {}) result.append(resource) result.extend(dict(item, id='jump:'+item['id'], kind='jump') for item in jumps) + result.extend(typed_proxies) result.extend(dict(item, id='smb:'+item['id'], kind='smb', port=445, auth_type='password') for item in shares) return result @@ -136,12 +146,19 @@ def _connection_resource(self, user_id, kind, resource_id, connection): if not self.app.extensions['runtime_lifecycle'].accepting_work(): raise AutomationError('unavailable') resource = self.resolve_resource(user_id, resource_id) - if (kind not in {'ssh', 'jump', 'smb'} or resource['kind'] != kind + if (kind not in {'ssh', 'jump', 'smb', 'socks'} or resource['kind'] != kind or resource.get('auth_type') != 'password' or not isinstance(connection, dict)): raise AutomationError('invalid_targets') - defaults = dict(port=445 if kind == 'smb' else 22, auth_type='password', - key_id=None, jump_host_id=None, domain='') + defaults = dict(port=445 if kind == 'smb' else 1080 if kind == 'socks' else 22, + auth_type='password', key_id=None, jump_host_id=None, + socks_proxy_id=None, remote_dns=True, domain='') fields = ('host', 'port', 'username', 'auth_type', 'key_id', 'jump_host_id') + if kind == 'ssh': + fields += ('socks_proxy_id',) + if resource.get('socks_proxy_id'): + self.resolve_resource(user_id, 'socks:'+resource['socks_proxy_id']) + if kind == 'socks': + fields += ('remote_dns',) if kind == 'smb': fields += ('domain', 'share') if any(connection.get(field, defaults.get(field)) != resource.get(field, defaults.get(field)) @@ -514,7 +531,7 @@ def authorize_target(self, uid, aid, data): rid=data.get('credential_id') record=self.named.record(self.named.read(uid),rid) from .app_settings import credentials_policy_revision - route=self.resolve_resource(uid,'jump:'+target['jump_host_id']) if target.get('jump_host_id') else None + route=self._target_route(uid, target) token=self._remember(uid,aid,dict(kind='target',target=target,credential_id=rid,version=record['version'],policy=credentials_policy_revision(),route_fingerprint=profile_fingerprint(route) if route else None)) return dict(credential_target_token=token) @@ -531,7 +548,7 @@ def target_generation(self, uid, aid, rid, connection, token): if record['version']!=intent['version']: raise AutomationError('credential_target_changed') from .app_settings import credentials_policy_revision if intent['policy']!=credentials_policy_revision(): raise AutomationError('invalid_intent') - route=self.resolve_resource(uid,'jump:'+target['jump_host_id']) if target.get('jump_host_id') else None + route=self._target_route(uid, target) if intent['route_fingerprint']!=(profile_fingerprint(route) if route else None): raise AutomationError('credential_target_changed') return dict(version=record['version'],policy=credentials_policy_revision(),session=session_identity(uid,aid)) @@ -543,24 +560,22 @@ def target_password(self, uid, aid, rid, connection, token): return decrypt(uid,rid,record['ciphertext']) def create_for_resource(self, uid, data): - from . import profile_manager, jump_host_manager, smb_share_manager + from . import profile_manager, jump_host_manager, smb_share_manager, socks_proxy_manager self._named_enabled() kind=data.get('resource_kind'); payload=data.get('resource') - if kind not in {'ssh','jump','smb'} or not isinstance(payload,dict) or payload.get('id'): + if kind not in {'ssh','jump','smb','socks'} or not isinstance(payload,dict) or payload.get('id'): raise AutomationError('invalid_request') if data.get('confirm_target') is not True: raise AutomationError('confirmation_required') if kind!='smb' and payload.get('auth_type')!='password': raise AutomationError('invalid_targets') with user_data_access(uid): result={} callback_error=[] - route=None - if kind=='ssh' and payload.get('jump_host_id'): - route=self.resolve_resource(uid,'jump:'+payload['jump_host_id']) - route=dict(route,id=payload['jump_host_id']) - route.pop('kind',None) def created(record): resource=dict(record,id=('' if kind=='ssh' else kind+':')+record['id'],kind=kind) if kind=='smb': resource.update(port=445,auth_type='password') + # The manager holds command-config through the callback, so + # route writers cannot change the approved binding mid-write. + route=self._target_route(uid, record, coordinator_held=True) if kind=='ssh' else None if route: resource['route_fingerprint']=profile_fingerprint(route) def store(doc): credential=self.named.create(uid,doc,data) @@ -572,6 +587,10 @@ def store(doc): raise if kind=='ssh': record,error=profile_manager.upsert_profile(uid,payload,on_created=created) elif kind=='smb': record,error=smb_share_manager.upsert_smb_share(uid,payload,on_created=created) + elif kind=='socks': + fields={key:payload.get(key) for key in ('name','host','port','username','auth_type')} + fields['remote_dns']=payload.get('remote_dns', True) + record,error=socks_proxy_manager.add_socks_proxy(uid,**fields,on_created=created) else: fields={key:payload.get(key) for key in ('name','host','port','username','auth_type','key_id','jump_host_id')} record,error=jump_host_manager.add_jump_host(uid,**fields,on_created=created) @@ -579,19 +598,62 @@ def store(doc): if error or not record: raise AutomationError('invalid_targets') return result + def _target_route(self, uid, target, *, coordinator_held=False): + """Resolve the live owned route, retaining the historical jump hash.""" + if target.get('socks_proxy_id'): + prefix, reference = 'socks:', target['socks_proxy_id'] + elif target.get('jump_host_id'): + prefix, reference = 'jump:', target['jump_host_id'] + else: + return None + if coordinator_held: + from .jump_host_manager import _load_jump_hosts_for_read_with_lock_held + from .socks_proxy_manager import _load_socks_proxies_for_read_with_lock_held + loader = (_load_socks_proxies_for_read_with_lock_held if prefix=='socks:' + else _load_jump_hosts_for_read_with_lock_held) + matches = [item for item in loader(uid) if item['id']==reference] + if len(matches)!=1: raise AutomationError('invalid_targets') + route = dict(matches[0]) + if prefix=='socks:': route.update(id=prefix+reference, kind='socks') + else: + route=self.resolve_resource(uid, prefix+reference) + if prefix=='jump:': + route=dict(route,id=reference) + route.pop('kind',None) + return route + @staticmethod def _normalize_target(target): from .credential_connections import _saved_route + if not isinstance(target, dict): + raise AutomationError('credential_target_changed') is_smb=bool(target.get('share')) + is_socks=target.get('kind')=='socks' jump=target.get('jump_host_id') - if target.get('proxy_jump'): + socks=None if is_socks else target.get('socks_proxy_id') + if target.get('proxy_jump') is not None: saved=_saved_route(target) if jump and jump!=saved: raise AutomationError('credential_target_changed') jump=saved - return dict(host=target.get('host'),port=target.get('port') or (445 if is_smb else 22), + if target.get('socks_proxy') is not None: + saved=_saved_route(target, 'socks') + if is_socks or (socks and socks!=saved): raise AutomationError('credential_target_changed') + socks=saved + if ((socks and (jump or is_smb or target.get('auth_type')=='tailscale')) + or (is_socks and (jump or is_smb)) + or any(value is not None and not isinstance(value, str) for value in (jump, socks))): + raise AutomationError('credential_target_changed') + result=dict(host=target.get('host'),port=target.get('port') or (445 if is_smb else 1080 if is_socks else 22), username=target.get('username'),auth_type=target.get('auth_type') or 'password', key_id=target.get('key_id'),jump_host_id=jump,domain=(target.get('domain') or '') if is_smb else None, share=target.get('share') if is_smb else None) + if socks: + result['socks_proxy_id']=socks + if is_socks: + remote_dns=target.get('remote_dns', True) + if type(remote_dns) is not bool: raise AutomationError('credential_target_changed') + result.update(kind='socks', remote_dns=remote_dns) + return result def apply_native_assignment(self, uid, aid, token, mutation): """Compare an approved destination while all destination writers are fenced.""" diff --git a/app/customer_workflows.py b/app/customer_workflows.py index 1350cf74..2abc519e 100644 --- a/app/customer_workflows.py +++ b/app/customer_workflows.py @@ -101,6 +101,7 @@ def recovery_check(): from .profile_manager import load_profiles from .key_manager import load_keys from .jump_host_manager import load_jump_hosts + from .socks_proxy_manager import load_socks_proxies from .host_key_store import HostKeyStore with user_data_access(current_user.id): if request.method == 'POST': @@ -122,6 +123,7 @@ def recovery_check(): profiles = load_profiles(current_user.id) keys = {key['id'] for key in load_keys(current_user.id)} jumps = {jump['id'] for jump in load_jump_hosts(current_user.id)} + socks = {proxy['id'] for proxy in load_socks_proxies(current_user.id)} credentials = current_app.extensions['credentials'] resources = {item['id']: item for item in credentials.resources(current_user.id) if item.get('kind') == 'ssh'} trust = HostKeyStore(current_user.id, config.KNOWN_HOSTS_FILE, config.USERS_DIR).trust_checker() @@ -134,8 +136,11 @@ def recovery_check(): credential = 'ready' if resource and credentials.credential_status(current_user.id, resource) == 'ready' else 'password_required' else: credential = 'interactive' + route = 'missing_jump' if host.get('jump_host_id') and host['jump_host_id'] not in jumps else 'configured' + if host.get('socks_proxy_id') and host['socks_proxy_id'] not in socks: + route = 'missing_socks' hosts.append(dict(id=host['id'], name=host['name'], credential=credential, - route='missing_jump' if host.get('jump_host_id') and host['jump_host_id'] not in jumps else 'configured', + route=route, host_key='trusted' if trust(host['host'], host.get('port', 22)) else 'review_required')) schedules = current_app.extensions['automation_schedules'].list(current_user.id) with storage_lock('restore-recovery-check'): diff --git a/app/discovery_routes.py b/app/discovery_routes.py index e8f710d7..59a19014 100644 --- a/app/discovery_routes.py +++ b/app/discovery_routes.py @@ -155,8 +155,9 @@ def _known_endpoints(user_id): from .data_access import user_data_access from .jump_host_manager import load_jump_hosts from .profile_manager import load_profiles + from .socks_proxy_manager import load_socks_proxies with user_data_access(user_id): - records = [*load_profiles(user_id), *load_jump_hosts(user_id)] + records = [*load_profiles(user_id), *load_jump_hosts(user_id), *load_socks_proxies(user_id)] return {(str(item.get('host') or '').lower(), int(item.get('port') or 22)) for item in records} diff --git a/app/host_context.py b/app/host_context.py index 7c4feda2..1da5327b 100644 --- a/app/host_context.py +++ b/app/host_context.py @@ -19,7 +19,7 @@ def __init__(self, code): @user_operation -def bind_profile(user_id, profile_id, host, port, username, jump_host_id, *, optional=False): +def bind_profile(user_id, profile_id, host, port, username, jump_host_id, *, socks_proxy_id=None, optional=False): """Bind only an explicitly selected, owned profile with the actual route.""" if profile_id is None: return None @@ -34,6 +34,7 @@ def bind_profile(user_id, profile_id, host, port, username, jump_host_id, *, opt and int(profile.get('port', 22)) == int(port) and profile['username'] == username and (profile.get('jump_host_id') or None) == (jump_host_id or None) + and (profile.get('socks_proxy_id') or None) == (socks_proxy_id or None) ) except (ValueError, TypeError, KeyError): matches = False diff --git a/app/host_platform.py b/app/host_platform.py index 09239acd..4cc44f08 100644 --- a/app/host_platform.py +++ b/app/host_platform.py @@ -257,7 +257,8 @@ def endpoint(profile): port = profile.get('port', 22) if not isinstance(username, str) or not username or type(port) is not int or not 1 <= port <= 65535: return None - return (host, port, username, profile.get('jump_host_id') or None) + return (host, port, username, profile.get('jump_host_id') or None, + profile.get('socks_proxy_id') or None) def needs_detection(profile, now=None): """Probe only saved hosts in automatic mode without a result or a recent service check. diff --git a/app/overview_routes.py b/app/overview_routes.py index 03e596e4..fd9bcb45 100644 --- a/app/overview_routes.py +++ b/app/overview_routes.py @@ -60,18 +60,30 @@ def _endpoint(record): def _readiness(user_id, profiles, jumps, keys): from .host_key_store import HostKeyStore + from .socks_proxy_manager import load_socks_proxies trusted = HostKeyStore(user_id, config.KNOWN_HOSTS_FILE, config.USERS_DIR).trust_checker() credentials = current_app.extensions.get('credentials') # Credential bindings are fingerprinted against the resource view, which # includes the jump route; use the same view as real connections. - resources = {item['id']: item for item in credentials.resources(user_id) if item.get('kind') == 'ssh'} if credentials else {} + resources = {item['id']: item for item in credentials.resources(user_id)} if credentials else {} key_ids = {key.get('id') for key in keys} jump_by_id = {jump.get('id'): jump for jump in jumps} + socks_by_id = {proxy['id']: proxy for proxy in load_socks_proxies(user_id)} result = [] for profile in profiles: endpoint = _endpoint(profile) jump = jump_by_id.get(profile.get('jump_host_id')) if profile.get('jump_host_id') else None route = 'direct' if not profile.get('jump_host_id') else 'jump' if jump else 'missing' + socks_proxy_id = profile.get('socks_proxy_id') + proxy = socks_by_id.get(socks_proxy_id) + socks_auth = None + if socks_proxy_id: + route = 'socks' if proxy and not profile.get('jump_host_id') and profile.get('auth_type') != 'tailscale' else 'missing' + if proxy: + resource = resources.get('socks:' + socks_proxy_id) + socks_auth = ('ready' if proxy['auth_type'] == 'none' else + 'saved' if credentials and resource and credentials.credential_status(user_id, resource) == 'ready' + else 'password_required') host_key = 'trusted' if endpoint and trusted(*endpoint) else 'unknown' if profile.get('auth_type') == 'key': auth = 'ready' if profile.get('key_id') in key_ids else 'key_missing' @@ -82,11 +94,16 @@ def _readiness(user_id, profiles, jumps, keys): auth = 'prompt' if route == 'missing': state = 'broken' - elif host_key != 'trusted' or auth in {'key_missing', 'password_changed'}: + elif host_key != 'trusted' or auth in {'key_missing', 'password_changed'} or socks_auth == 'password_required': state = 'action' else: state = 'ready' - result.append(dict(id=profile.get('id'), state=state, route=route, host_key=host_key, auth=auth)) + row = dict(id=profile.get('id'), state=state, route=route, host_key=host_key, auth=auth) + if socks_proxy_id: + row.update(socks_proxy_id=socks_proxy_id, via_socks=True) + if socks_auth is not None: + row['socks_auth'] = socks_auth + result.append(row) return result @@ -158,8 +175,10 @@ def _probe(host, port): def _plans(user_id, wanted=None): from .data_access import user_data_access + from .socks_proxy_manager import load_socks_proxies with user_data_access(user_id): profiles, jumps, _keys = _owned_inventory(user_id) + socks_by_id = {proxy['id']: proxy for proxy in load_socks_proxies(user_id)} jump_by_id = {jump.get('id'): jump for jump in jumps} plans = {} for profile in profiles: @@ -168,10 +187,17 @@ def _plans(user_id, wanted=None): if len(plans) >= MAX_PROBES: break record = jump_by_id.get(profile.get('jump_host_id')) if profile.get('jump_host_id') else profile + socks_proxy_id = profile.get('socks_proxy_id') + if socks_proxy_id: + record = socks_by_id.get(socks_proxy_id) + if profile.get('jump_host_id') or profile.get('auth_type') == 'tailscale': + record = None endpoint = _endpoint(record) if record else None - plans[profile.get('id')] = dict(endpoint=endpoint, via_jump=bool(profile.get('jump_host_id'))) + plans[profile.get('id')] = dict(endpoint=endpoint, via_jump=bool(profile.get('jump_host_id')), + via_socks=bool(socks_proxy_id), socks_proxy_id=socks_proxy_id) # A stored answer belongs to exactly this set of destinations. - signature = tuple(sorted((key, str(plan['endpoint']), plan['via_jump']) for key, plan in plans.items())) + signature = tuple(sorted((key, str(plan['endpoint']), plan['via_jump'], plan['socks_proxy_id'] or '') + for key, plan in plans.items())) return plans, signature @@ -239,7 +265,10 @@ def reachability(): for profile_id, plan in plans.items(): outcome = dict(outcomes.get(plan['endpoint']) or dict(state='unresolved')) platform = outcome.pop('platform', None) - if plan['via_jump']: + if plan['via_socks']: + # The proxy endpoint's greeting never describes the SSH target. + outcome['via_socks'] = True + elif plan['via_jump']: # The probe reached the jump host; its greeting says nothing about the target. outcome['via_jump'] = True elif platform: diff --git a/app/profile_exchange.py b/app/profile_exchange.py index f0c8a357..fb1e4af3 100644 --- a/app/profile_exchange.py +++ b/app/profile_exchange.py @@ -12,7 +12,9 @@ _FIELDS = { 'name', 'host', 'port', 'username', 'group', 'favorite', 'use_tmux', 'requires_key', 'jump_alias', 'auth_type', 'platform_icon', + 'socks_route_omitted', 'warnings', } +SOCKS_ROUTE_WARNING = 'SOCKS5 route not exported' _ALIAS = re.compile(r'^[a-zA-Z0-9_.-]{1,128}$') _KNOWN_HOST = re.compile( r'^\s*(?:@(?:cert-authority|revoked)\s+)?\S+\s+' @@ -45,6 +47,13 @@ def _username(value): def _record(values): + if 'socks_route_omitted' in values and values['socks_route_omitted'] is not True: + raise ExchangeError('Host document contains unsupported fields.') + if 'warnings' in values and ( + values.get('socks_route_omitted') is not True + or values['warnings'] != [SOCKS_ROUTE_WARNING] + ): + raise ExchangeError('Host document contains unsupported fields.') row = {field: value for field, value in values.items() if field in _FIELDS} row.setdefault('name', '') row.setdefault('host', row['name']) @@ -55,6 +64,9 @@ def _record(values): row['requires_key'] = True row['warnings'] = [] row['blocked'] = False + if row.get('socks_route_omitted'): + row['blocked'] = True + row['warnings'].append(SOCKS_ROUTE_WARNING) if ( not _text(row['name']) or not row['name'].strip() or not _text(row['host'], 253) or not _is_valid_host(row['host']) @@ -222,6 +234,9 @@ def export_profiles(profiles, jump_hosts): row['requires_key'] = profile.get('auth_type') == 'key' if profile.get('jump_host_id'): row['jump_alias'] = jumps.get(profile['jump_host_id'], 'Unresolved jump host') + if profile.get('socks_proxy_id'): + row['socks_route_omitted'] = True + row['warnings'] = [SOCKS_ROUTE_WARNING] hosts.append(row) return {'format': 'webssh-hosts', 'version': 1, 'hosts': hosts} diff --git a/app/profile_exchange_routes.py b/app/profile_exchange_routes.py index 69cac822..52dea865 100644 --- a/app/profile_exchange_routes.py +++ b/app/profile_exchange_routes.py @@ -88,15 +88,20 @@ def export(): batches = export_batches( load_profiles(current_user.id), load_jump_hosts(current_user.id), ) + omitted = sum(bool(host.get('socks_route_omitted')) for batch in batches for host in batch['hosts']) if len(batches) == 1: - return jsonify(batches[0]) + response = jsonify(batches[0]) + response.headers['X-WebSSH-SOCKS-Routes-Omitted'] = str(omitted) + return response output = io.BytesIO() with zipfile.ZipFile(output, 'w', compression=zipfile.ZIP_STORED) as archive: for number, batch in enumerate(batches, 1): archive.writestr(f'webssh-hosts-{number}.json', json.dumps(batch)) output.seek(0) - return send_file(output, mimetype='application/zip', - as_attachment=True, download_name='webssh-hosts.zip') + response = send_file(output, mimetype='application/zip', + as_attachment=True, download_name='webssh-hosts.zip') + response.headers['X-WebSSH-SOCKS-Routes-Omitted'] = str(omitted) + return response @profile_exchange_blueprint.post('/api/profiles/exchange') diff --git a/app/socket_events.py b/app/socket_events.py index a6e95e00..6aa32d7c 100644 --- a/app/socket_events.py +++ b/app/socket_events.py @@ -348,6 +348,8 @@ def is_set(self): ) def check(self): + for guard in self._credential_guards: + guard.check() if self.is_set(): raise AutomationError('vault_locked') @@ -1069,6 +1071,8 @@ def restore_user_sessions(user_id, socket_sid): 'auth_type': session.get('auth_type', db_session.auth_type), 'via_jump': session.get('via_jump'), 'jump_host_id': session.get('jump_host_id'), + 'socks_proxy_id': session.get('socks_proxy_id', db_session.socks_proxy_id), + 'via_socks': bool(session.get('via_socks', db_session.via_socks)), 'profile_id': session.get('profile_id'), 'detected_platform': session.get('detected_platform'), 'reconnect_route_known': session.get('reconnect_route_known', False), @@ -1107,6 +1111,8 @@ def restore_user_sessions(user_id, socket_sid): 'key_id': db_session.key_id, 'auth_type': db_session.auth_type, 'jump_host_id': db_session.jump_host_id, + 'socks_proxy_id': db_session.socks_proxy_id, + 'via_socks': bool(db_session.via_socks), 'profile_id': db_session.profile_id, 'via_jump': db_session.via_jump, 'reconnect_route_known': db_session.reconnect_route_known, @@ -1176,12 +1182,13 @@ def handle_ssh_connect(data, current_user=None): key_content = None bastion_password = None bastion_key_content = None + socks_password = None client_request_id = None socket_sid = request.sid client_cancel_event = threading.Event() credential_guards = [] try: - data = data if isinstance(data, dict) else {} + data = dict(data) if isinstance(data, dict) else {} client_request_id = _ssh_request_id(data) or None if not current_app.extensions[ 'runtime_lifecycle' @@ -1302,6 +1309,49 @@ def request_auth_banner_decision(banner, context): saved_jump_host_id = ( proxy_jump.get('jump_host_id') if proxy_jump else None ) + socks_proxy = None + saved_socks_proxy_id = None + if 'socks_proxy' in data or data.get('socks_proxy_id'): + socks_payload = data.get('socks_proxy') + saved_socks_proxy_id = ( + socks_payload.get('socks_proxy_id') if isinstance(socks_payload, dict) else None + ) + if (not isinstance(saved_socks_proxy_id, str) or not saved_socks_proxy_id + or len(saved_socks_proxy_id) > 64 + or (data.get('socks_proxy_id') + and data['socks_proxy_id'] != saved_socks_proxy_id)): + emit_error('SOCKS proxy reference not found') + return + if proxy_jump or data.get('jump_host_id'): + emit_error('A host can use either a jump host or a SOCKS5 proxy, not both') + return + if auth_type == 'tailscale': + emit_error('Tailscale SSH cannot be used with a SOCKS5 proxy') + return + socks_proxy = socks_proxy_manager.get_socks_proxy(current_user.id, saved_socks_proxy_id) + if socks_proxy is None: + emit_error('SOCKS proxy reference not found') + return + socks_proxy = dict(socks_proxy, kind='socks') + if socks_proxy['auth_type'] == 'password': + socks_password = socks_payload.get('password') + for field in ('credential_id', 'credential_target_token'): + if socks_payload.get(field) is not None: + socks_proxy[field] = socks_payload[field] + if not socks_password and socks_proxy.get('credential_id') is None: + emit_error('SOCKS5 proxy password required') + return + data['socks_proxy'] = {'socks_proxy_id': saved_socks_proxy_id} + socks_payload = None + + # Optional context may detach from an edited profile, but never discard + # its saved SOCKS route and thereby send SSH credentials directly. + if data.get('profile_id'): + selected_profile = profile_manager.get_profile(current_user.id, data['profile_id']) + if (selected_profile and (selected_profile.get('socks_proxy_id') or saved_socks_proxy_id) + and (selected_profile.get('socks_proxy_id') or None) != saved_socks_proxy_id): + emit_error('Saved host no longer matches this connection. Select it again or use Quick Connect.') + return if saved_jump_host_id: live_jump_host = jump_host_manager.get_jump_host( current_user.id, saved_jump_host_id @@ -1364,10 +1414,24 @@ def request_auth_banner_decision(banner, context): if proxy_jump and proxy_jump.get('credential_id') is not None: credential_guards.append(CredentialGuard(current_user.id, 'jump', proxy_jump)) bastion_password = connection_password(current_user.id, 'jump', proxy_jump) + if socks_proxy and socks_proxy.get('credential_id') is not None: + if socks_password: + raise AutomationError('invalid_request') + credential_guards.append(CredentialGuard(current_user.id, 'socks', socks_proxy)) + socks_password = connection_password(current_user.id, 'socks', socks_proxy) except AutomationError as error: emit('ssh_error', dict(credential_error(error), client_request_id=client_request_id)) return + if socks_proxy and socks_proxy['auth_type'] == 'password': + try: + valid_password = isinstance(socks_password, str) and 1 <= len(socks_password.encode('utf-8')) <= 255 + except UnicodeError: + valid_password = False + if not valid_password: + emit_error('SOCKS5 proxy password must contain 1 to 255 UTF-8 bytes') + return + if auth_type == 'key' and key_id: key_content, key_error = key_manager.read_key_content( current_user.id, key_id @@ -1390,7 +1454,7 @@ def request_auth_banner_decision(banner, context): # The target may be internal when reached via a bastion (legitimate). host, port, username, error = _validate_ssh_params( data.get('host'), data.get('port', 22), data.get('username'), - allow_internal=bool(proxy_jump), allow_gateway=auth_type != 'tailscale' + allow_internal=bool(proxy_jump or socks_proxy), allow_gateway=auth_type != 'tailscale' ) if error: emit_error(error) @@ -1401,6 +1465,7 @@ def request_auth_banner_decision(banner, context): profile_id = bind_profile( current_user.id, data.get('profile_id'), host, port, username, saved_jump_host_id, optional=data.get('profile_context_optional') is True, + socks_proxy_id=saved_socks_proxy_id, ) except ContextError: emit_error('Saved host no longer matches this connection. Select it again or use Quick Connect.') @@ -1479,6 +1544,11 @@ def request_auth_banner_decision(banner, context): is_persistent=True ).first() if existing: + if ((existing.socks_proxy_id or existing.via_socks or saved_socks_proxy_id) + and (not existing.socks_proxy_id + or existing.socks_proxy_id != saved_socks_proxy_id)): + emit_error('Saved SOCKS5 proxy route no longer matches this session') + return reconnect_tmux_name = raw_name app = current_app._get_current_object() @@ -1519,7 +1589,9 @@ def request_auth_banner_decision(banner, context): 'key_content': key_content, 'bastion_password': bastion_password, 'bastion_key_content': bastion_key_content, + 'socks_password': socks_password, } + socks_password = None if gateway_attempt is None: attempt = { 'cancel_event': client_cancel_event, @@ -1578,6 +1650,11 @@ def connect_ssh(lifecycle_cancel_event, credentials=credential_box): proxy_jump_username=bastion_username, proxy_jump_password=local_bastion_password, proxy_jump_key_content=local_bastion_key_content, + **({'socks_proxy': ssh_manager.SocksProxyRoute( + proxy_id=saved_socks_proxy_id, host=socks_proxy['host'], port=socks_proxy['port'], + auth_type=socks_proxy['auth_type'], username=socks_proxy['username'], + password=credentials.pop('socks_password', None), remote_dns=socks_proxy['remote_dns'], + )} if socks_proxy else {}), use_tmux=use_tmux, reconnect_tmux_name=reconnect_tmux_name, auth_type=auth_type, @@ -1595,6 +1672,7 @@ def connect_ssh(lifecycle_cancel_event, credentials=credential_box): hop for hop, saved in ( ('target', data.get('credential_id') is not None), ('jump', bool(proxy_jump) and proxy_jump.get('credential_id') is not None), + ('socks', bool(socks_proxy) and socks_proxy.get('credential_id') is not None), ) if saved )} if credential_guards else {}), **({'gateway_attempt': gateway_attempt} if gateway_attempt is not None else {}), @@ -1691,6 +1769,8 @@ def connect_ssh(lifecycle_cancel_event, credentials=credential_box): key_id=key_id, auth_type=auth_type, jump_host_id=saved_jump_host_id, + socks_proxy_id=saved_socks_proxy_id, + via_socks=socks_proxy is not None, profile_id=profile_id, via_jump=bastion_host, reconnect_route_known=True, @@ -1715,6 +1795,8 @@ def connect_ssh(lifecycle_cancel_event, credentials=credential_box): 'client_request_id': client_request_id, 'via_jump': bastion_host, 'jump_host_id': saved_jump_host_id, + 'socks_proxy_id': saved_socks_proxy_id, + 'via_socks': socks_proxy is not None, 'profile_id': profile_id, 'reconnect_route_known': True, 'use_tmux': actual_use_tmux, @@ -1841,6 +1923,7 @@ def connect_ssh(lifecycle_cancel_event, credentials=credential_box): key_content = None bastion_password = None bastion_key_content = None + socks_password = None @socketio.on('ssh_connect_cancel') @@ -2091,6 +2174,8 @@ def handle_ssh_disconnect(data, current_user=None): is_persistent=True, tmux_session_name=ssh_session.tmux_session_name, jump_host_id=ssh_session.jump_host_id, + socks_proxy_id=ssh_session.socks_proxy_id, + via_socks=ssh_session.via_socks, via_jump=ssh_session.via_jump, ).first() if ssh_session.is_persistent and (not preserve_tmux or replacement): @@ -4236,6 +4321,15 @@ def send_error(payload): emit('quick_connect_error', payload) try: + data = data if isinstance(data, dict) else {} + selected_profile = ( + profile_manager.get_profile(current_user.id, data['profile_id']) + if data.get('profile_id') else None + ) + if ('socks_proxy' in data or data.get('socks_proxy_id') + or (selected_profile and selected_profile.get('socks_proxy_id'))): + send_error({'error': 'Quick SFTP does not support SOCKS5 proxy routes. Connect through a terminal session.'}) + return if not current_app.extensions[ 'runtime_lifecycle' ].accepting_work(): @@ -5694,7 +5788,8 @@ def _start_gateway_quick_connect(data, user, host, port, username, password, key """Run only selector-based Quick SFTP asynchronously with bounded admission.""" request_id = _ssh_request_id(data) if (data.get('gateway_interaction') != 1 or not request_id - or data.get('proxy_jump') or data.get('auth_type') == 'tailscale'): + or data.get('proxy_jump') or data.get('auth_type') == 'tailscale' + or 'socks_proxy' in data or data.get('socks_proxy_id')): emit('quick_connect_error', {'error': 'Unsupported gateway client or route', 'client_request_id': request_id}) return diff --git a/app/ssh_manager.py b/app/ssh_manager.py index 5feaf24b..38741155 100644 --- a/app/ssh_manager.py +++ b/app/ssh_manager.py @@ -6,6 +6,7 @@ import uuid import socket import codecs +from dataclasses import dataclass, field, replace from threading import Lock, Timer import config from .audit_logger import log_info, log_warning, log_error, log_debug @@ -16,7 +17,9 @@ open_validated_socket, proxy_jump_remote_dns_allowed, resolve_allowed_target, + socks_destination, ) +from .socks5_client import Socks5Error, open_socks5_tunnel from .ssh_key_loader import load_private_key as _load_private_key from .ssh_errors import SSHConnectionError from .startup_commands import to_terminal_input @@ -37,6 +40,17 @@ SSH_AUTH_BANNER_MAX_CHARS = 16 * 1024 +@dataclass(frozen=True) +class SocksProxyRoute: + proxy_id: str + host: str + port: int + auth_type: str + username: str = field(default='', repr=False) + password: str | None = field(default=None, repr=False) + remote_dns: bool = True + + def _tmux_session_command(tmux_command, session_name, *, reconnect=False): """Attach and enable wheel input only for this exact remote session.""" attach = 'new-session -A' if reconnect else 'new-session' @@ -168,7 +182,8 @@ def create_ssh_connection(host, port, username, password=None, key_path=None, ke tailscale_authorization=None, cancel_event=None, client_request_id=None, jump_host_id=None, gateway_attempt=None, profile_id=None, - credential_guard=None, saved_password_hops=('target', 'jump')): + credential_guard=None, saved_password_hops=('target', 'jump', 'socks'), + socks_proxy=None): """ Create a new SSH connection and return session ID. @@ -207,6 +222,18 @@ def connection_cancelled(): return None, "User identity is required" user_id = host_key_store.user_id + if socks_proxy is not None: + if not isinstance(socks_proxy, SocksProxyRoute): + return None, 'SOCKS proxy reference not found' + if proxy_jump_host or jump_host_id: + return None, 'A host can use either a jump host or a SOCKS5 proxy, not both' + if auth_type == 'tailscale': + return None, 'Tailscale SSH cannot be used with a SOCKS5 proxy' + if socks_proxy.auth_type not in {'none', 'password'}: + return None, 'Invalid SOCKS5 proxy authentication method' + if socks_proxy.auth_type == 'password' and not socks_proxy.password: + return None, 'SOCKS5 proxy password required' + if auth_type == 'tailscale' and proxy_jump_host: return None, 'Tailscale SSH cannot be used with a jump host' @@ -393,6 +420,49 @@ def connection_cancelled(): error_type=type(e).__name__, ) return None, "Jump host connection failed" + elif socks_proxy is not None: + destination = socks_destination( + host, port, socks_proxy.remote_dns, + allow_internal=not config.BLOCK_INTERNAL_SSH, + allowed_internal_networks=config.SSH_ALLOWED_INTERNAL_NETWORKS, + remote_dns_allowlist=config.SOCKS_REMOTE_DNS_ALLOWLIST, + ) + host, port = destination.hostname, destination.port + proxy_target = resolve_allowed_target( + socks_proxy.host, socks_proxy.port, + allow_internal=not config.BLOCK_INTERNAL_SSH, + allowed_internal_networks=config.SSH_ALLOWED_INTERNAL_NETWORKS, + ) + audit_internal_allowlist_use(proxy_target, 'socks_proxy') + try: + validated_socket = open_validated_socket(proxy_target, config.SSH_CONNECT_TIMEOUT) + except OSError: + failure = Socks5Error('socks_proxy_unreachable') + return None, SSHConnectionError(str(failure), code=failure.code, context='socks_proxy') + if gateway_attempt is not None: + gateway_attempt.own(validated_socket) + + def socks_cancelled(): + if credential_guard is not None and 'socks' in saved_password_hops: + try: + credential_guard.check() + except Exception: + return True + return connection_cancelled() + + try: + sock = open_socks5_tunnel( + validated_socket, destination, + username=socks_proxy.username if socks_proxy.auth_type == 'password' else None, + password=socks_proxy.password if socks_proxy.auth_type == 'password' else None, + deadline=time.monotonic() + config.SSH_CONNECT_TIMEOUT, + cancelled=socks_cancelled, + ) + except Socks5Error as error: + return None, SSHConnectionError(str(error), code=error.code, context='socks_proxy') + finally: + socks_proxy = replace(socks_proxy, password=None) + log_info('SOCKS proxy connection established', proxy_id=socks_proxy.proxy_id) else: target = ( tailscale_authorization.resolved_target @@ -610,6 +680,8 @@ def connection_cancelled(): 'bastion_client': bastion_client, 'proxy_jump_host': proxy_jump_host, 'jump_host_id': jump_host_id, + 'socks_proxy_id': socks_proxy.proxy_id if socks_proxy else None, + 'via_socks': socks_proxy is not None, 'profile_id': profile_id, 'auth_type': auth_type, 'use_tmux': use_tmux, @@ -1166,6 +1238,8 @@ def get_session(session_id): 'connected': session['connected'], 'via_jump': session.get('proxy_jump_host'), 'jump_host_id': session.get('jump_host_id'), + **({'socks_proxy_id': session['socks_proxy_id'], 'via_socks': True} + if session.get('socks_proxy_id') else {}), 'profile_id': session.get('profile_id'), 'reconnect_route_known': True, 'detected_platform': session.get('detected_platform'), diff --git a/tests/test_host_platform.py b/tests/test_host_platform.py index 9a46c8b0..e2b3dcc4 100644 --- a/tests/test_host_platform.py +++ b/tests/test_host_platform.py @@ -55,6 +55,34 @@ def test_invalid_platform_override_rejected(app): assert error == 'Invalid platform icon' +def test_socks_route_change_discards_old_facts_and_rejects_late_probe(app): + from app import profile_manager, socks_proxy_manager + from tests.test_profile_manager import create_user + user_id = create_user(app) + with app.app_context(): + proxy, error = socks_proxy_manager.add_socks_proxy(user_id, 'Route', 'proxy.example', 1080) + assert error is None + payload = dict(name='Target', host='example.test', port=22, username='deploy', auth_type='password') + profile, error = profile_manager.upsert_profile(user_id, payload) + assert error is None + old_endpoint = host_platform.endpoint(profile) + assert profile_manager.cache_detected_facts( + user_id, profile['id'], old_endpoint, platform='ubuntu', services=['docker'], + ) + changed, error = profile_manager.upsert_profile( + user_id, dict(payload, id=profile['id'], socks_proxy_id=proxy['id']), + ) + assert error is None + assert 'detected_platform' not in changed + assert 'detected_services' not in changed + assert not profile_manager.cache_detected_facts( + user_id, profile['id'], old_endpoint, platform='debian', services=['nginx'], + ) + assert profile_manager.cache_detected_facts( + user_id, profile['id'], host_platform.endpoint(changed), platform='debian', + ) + + def test_detection_task_checks_session_ownership_and_uses_facts(app, monkeypatch): from app import ssh_manager, profile_manager from tests.test_profile_manager import create_user diff --git a/tests/test_reconnect_routing.py b/tests/test_reconnect_routing.py index 94d8476d..e0636cf2 100644 --- a/tests/test_reconnect_routing.py +++ b/tests/test_reconnect_routing.py @@ -4,7 +4,7 @@ from types import SimpleNamespace -@pytest.mark.parametrize('replacement_kind', ['same', 'plain', 'other_tmux', 'other_route']) +@pytest.mark.parametrize('replacement_kind', ['same', 'plain', 'other_tmux', 'other_route', 'other_socks_route']) def test_preserved_tmux_keeps_recovery_row_unless_replacement_reattached_it(app, monkeypatch, replacement_kind): import app.socket_events as events from app.auth import register_user @@ -25,6 +25,9 @@ def test_preserved_tmux_keeps_recovery_row_unless_replacement_reattached_it(app, replacement.tmux_session_name = 'different-tmux' if replacement_kind == 'other_route': replacement.jump_host_id = 'different-bastion' + if replacement_kind == 'other_socks_route': + replacement.socks_proxy_id = 'different-proxy' + replacement.via_socks = True db.session.commit() events.handle_ssh_disconnect.__wrapped__( {'session_id': 'original', 'preserve_tmux': True, 'replacement_session_id': 'replacement'}, @@ -71,7 +74,8 @@ def test_replacement_disconnect_cannot_close_another_users_session(app, monkeypa assert errors[0][0] == 'ssh_error' -def test_restore_preserves_active_and_persistent_route_metadata(app, monkeypatch): +@pytest.mark.parametrize('route', ['jump', 'socks']) +def test_restore_preserves_active_and_persistent_route_metadata(app, monkeypatch, route): from app import ssh_manager from app.auth import register_user from app.models import SSHSession, db @@ -85,7 +89,10 @@ def test_restore_preserves_active_and_persistent_route_metadata(app, monkeypatch monkeypatch.setattr(ssh_manager, 'get_output_snapshot', lambda _id: ('', 0)) monkeypatch.setattr(ssh_manager, 'get_session', lambda _id: { 'connected': True, 'use_tmux': True, 'auth_type': 'key', - 'via_jump': 'bastion.example', 'jump_host_id': 'saved-bastion', + 'via_jump': 'bastion.example' if route == 'jump' else None, + 'jump_host_id': 'saved-bastion' if route == 'jump' else None, + 'socks_proxy_id': 'saved-proxy' if route == 'socks' else None, + 'via_socks': route == 'socks', 'reconnect_route_known': True, }) with app.app_context(): @@ -97,7 +104,10 @@ def test_restore_preserves_active_and_persistent_route_metadata(app, monkeypatch host='private.example', port=22, username='alice', connected=connected, is_persistent=True, key_id='target-key', auth_type='key', tmux_session_name='persistent', - jump_host_id='saved-bastion', via_jump='bastion.example', + jump_host_id='saved-bastion' if route == 'jump' else None, + via_jump='bastion.example' if route == 'jump' else None, + socks_proxy_id='saved-proxy' if route == 'socks' else None, + via_socks=route == 'socks', reconnect_route_known=True, )) db.session.commit() @@ -107,7 +117,9 @@ def test_restore_preserves_active_and_persistent_route_metadata(app, monkeypatch 'ssh_session_restored', 'persistent_session_available'}] assert len(routes) == 2 for payload in routes: - assert payload['jump_host_id'] == 'saved-bastion' - assert payload['via_jump'] == 'bastion.example' + assert payload['jump_host_id'] == ('saved-bastion' if route == 'jump' else None) + assert payload['via_jump'] == ('bastion.example' if route == 'jump' else None) + assert payload['socks_proxy_id'] == ('saved-proxy' if route == 'socks' else None) + assert payload['via_socks'] == (route == 'socks') assert payload['reconnect_route_known'] is True assert payload['key_id'] == 'target-key' diff --git a/tests/test_socks_connection_socket.py b/tests/test_socks_connection_socket.py new file mode 100644 index 00000000..059bcb0c --- /dev/null +++ b/tests/test_socks_connection_socket.py @@ -0,0 +1,238 @@ +"""SOCKS routes stay user-bound at every interactive connection boundary.""" + +from types import SimpleNamespace + +import pytest + +from app import socket_events as events +from tests.test_socks_proxy_socket import call, user_socket + +pytestmark = pytest.mark.usefixtures('direct_socket_authentication') + + +def saved_proxy(app, owner, **fields): + from app import socks_proxy_manager + with app.app_context(): + proxy, error = socks_proxy_manager.add_socks_proxy( + owner, 'Proxy', 'live-proxy.example', 1080, **fields, + ) + assert error is None + return proxy + + +def connect_payload(**changes): + result = dict(host='target.example', port=22, username='ops', + auth_type='password', password='target-secret') + result.update(changes) + return result + + +def capture_connections(app, monkeypatch): + jobs, connections = [], [] + lifecycle = app.extensions['runtime_lifecycle'] + original = lifecycle.start_job + + def start(*args, **kwargs): + handle = original(*args, **kwargs) + jobs.append(handle) + return handle + + monkeypatch.setattr(lifecycle, 'start_job', start) + monkeypatch.setattr(events, '_validate_ssh_params', + lambda host, port, user, **kw: (host, int(port), user, None)) + monkeypatch.setattr(events.ssh_manager, 'create_ssh_connection', + lambda **kw: connections.append(kw) or (None, 'local stop')) + return jobs, connections + + +def test_connect_resolves_owned_socks_metadata_live(app, monkeypatch): + owner, sid = user_socket(app, 'socks_live') + proxy = saved_proxy(app, owner, auth_type='password', username='live-user', remote_dns=False) + jobs, connections = capture_connections(app, monkeypatch) + _, emitted = call(app, monkeypatch, events.handle_ssh_connect, sid, connect_payload(socks_proxy={ + 'socks_proxy_id': proxy['id'], 'host': 'attacker.example', 'port': 9999, + 'auth_type': 'none', 'username': 'attacker', 'remote_dns': True, + 'password': 'runtime-proxy-password', + })) + assert len(jobs) == 1, emitted + assert jobs[0].join(3) + route = connections[0]['socks_proxy'] + assert (route.proxy_id, route.host, route.port, route.auth_type, route.username, route.remote_dns) == ( + proxy['id'], 'live-proxy.example', 1080, 'password', 'live-user', False, + ) + assert route.password == 'runtime-proxy-password' + + +@pytest.mark.parametrize('proxy_payload', [False, '', [], {}, {'host': 'inline.example'}, + {'socks_proxy_id': 'missing'}, {'socks_proxy_id': ['invalid']}]) +def test_malformed_or_unknown_socks_route_never_uses_direct_path(app, monkeypatch, proxy_payload): + _, sid = user_socket(app, 'socks_missing') + jobs, connections = capture_connections(app, monkeypatch) + _, emitted = call(app, monkeypatch, events.handle_ssh_connect, sid, + connect_payload(socks_proxy=proxy_payload)) + assert not jobs and not connections + assert emitted[0][1]['error'] == 'SOCKS proxy reference not found' + + +def test_foreign_socks_proxy_is_not_found(app, monkeypatch): + owner, _ = user_socket(app, 'socks_foreign_owner') + _, sid = user_socket(app, 'socks_foreign_other') + proxy = saved_proxy(app, owner) + jobs, connections = capture_connections(app, monkeypatch) + _, emitted = call(app, monkeypatch, events.handle_ssh_connect, sid, + connect_payload(socks_proxy={'socks_proxy_id': proxy['id']})) + assert not jobs and not connections + assert emitted[0][1]['error'] == 'SOCKS proxy reference not found' + + +@pytest.mark.parametrize('extra,message', [ + ({'proxy_jump': {'host': 'jump.example'}}, 'either a jump host or a SOCKS5 proxy'), + ({'auth_type': 'tailscale'}, 'Tailscale SSH cannot be used with a SOCKS5 proxy'), +]) +def test_socket_rejects_multiple_routes(app, monkeypatch, extra, message): + owner, sid = user_socket(app, 'socks_conflict') + proxy = saved_proxy(app, owner) + jobs, _ = capture_connections(app, monkeypatch) + _, emitted = call(app, monkeypatch, events.handle_ssh_connect, sid, + connect_payload(socks_proxy={'socks_proxy_id': proxy['id']}, **extra)) + assert not jobs + assert message in emitted[0][1]['error'] + + +@pytest.mark.parametrize('password', [None, '', 'x' * 256, '\ud800']) +def test_socks_login_requires_bounded_utf8_password(app, monkeypatch, password): + owner, sid = user_socket(app, 'socks_password') + proxy = saved_proxy(app, owner, auth_type='password', username='proxy-user') + jobs, _ = capture_connections(app, monkeypatch) + _, emitted = call(app, monkeypatch, events.handle_ssh_connect, sid, + connect_payload(socks_proxy={'socks_proxy_id': proxy['id'], 'password': password})) + assert not jobs + assert 'SOCKS5 proxy password' in emitted[0][1]['error'] + + +@pytest.mark.parametrize('optional', [False, True]) +def test_saved_socks_host_cannot_drop_route_even_for_optional_context(app, monkeypatch, optional): + from app import profile_manager + owner, sid = user_socket(app, 'socks_omitted') + proxy = saved_proxy(app, owner) + with app.app_context(): + profile, error = profile_manager.add_profile( + owner, 'Saved target', 'target.example', 22, 'ops', 'password', socks_proxy_id=proxy['id'], + ) + assert error is None + jobs, _ = capture_connections(app, monkeypatch) + _, emitted = call(app, monkeypatch, events.handle_ssh_connect, sid, + connect_payload(profile_id=profile['id'], profile_context_optional=optional)) + assert not jobs + assert 'Saved host no longer matches' in emitted[0][1]['error'] + + +@pytest.mark.parametrize('extra', [{'socks_proxy': {}}, {'socks_proxy_id': 'saved-proxy'}]) +def test_quick_sftp_rejects_socks_before_any_connection(app, monkeypatch, extra): + _, sid = user_socket(app, 'socks_quick') + monkeypatch.setattr(events.connection_pool.temp_connection_pool, 'create_connection', + lambda **kw: pytest.fail('direct fallback')) + _, emitted = call(app, monkeypatch, events.handle_quick_connect, sid, connect_payload(**extra)) + assert emitted[0][0] == 'quick_connect_error' + assert 'SOCKS5' in emitted[0][1]['error'] + + +def test_quick_sftp_cannot_omit_a_saved_profiles_socks_route(app, monkeypatch): + from app import profile_manager + owner, sid = user_socket(app, 'socks_quick_profile') + proxy = saved_proxy(app, owner) + with app.app_context(): + profile, error = profile_manager.add_profile( + owner, 'Target', 'target.example', 22, 'ops', 'password', socks_proxy_id=proxy['id'], + ) + assert error is None + monkeypatch.setattr(events.connection_pool.temp_connection_pool, 'create_connection', + lambda **kw: pytest.fail('direct fallback')) + _, emitted = call(app, monkeypatch, events.handle_quick_connect, sid, + connect_payload(profile_id=profile['id'])) + assert emitted[0][0] == 'quick_connect_error' + assert 'SOCKS5' in emitted[0][1]['error'] + + +@pytest.mark.parametrize('requested_route', ['direct', 'different_proxy']) +def test_persistent_tmux_reconnect_cannot_drop_or_replace_socks_route(app, monkeypatch, requested_route): + from app.models import SSHSession, db + owner, sid = user_socket(app, 'socks_tmux') + original = saved_proxy(app, owner) + with app.app_context(): + db.session.add(SSHSession( + session_id='original-socks', user_id=owner, host='target.example', port=22, + username='ops', connected=False, is_persistent=True, tmux_session_name='saved_tmux', + socks_proxy_id=original['id'], via_socks=True, + )) + db.session.commit() + monkeypatch.setattr(events, 'runtime_feature_enabled', lambda feature: feature == 'tmux_enabled') + jobs, _ = capture_connections(app, monkeypatch) + extra = {} + if requested_route == 'different_proxy': + extra['socks_proxy'] = {'socks_proxy_id': saved_proxy(app, owner)['id']} + _, emitted = call(app, monkeypatch, events.handle_ssh_connect, sid, + connect_payload(use_tmux=True, reconnect_tmux_name='saved_tmux', **extra)) + assert not jobs + assert emitted[0][1]['error'] == 'Saved SOCKS5 proxy route no longer matches this session' + + +def test_combined_credential_check_bypasses_cached_poll_result(): + class Guard: + def is_set(self): + return False + + def check(self): + raise events.AutomationError('vault_locked') + + clear = SimpleNamespace(is_set=lambda: False) + cancellation = events._CombinedCancellation(clear, clear, credential_guards=[Guard()]) + with pytest.raises(events.AutomationError): + cancellation.check() + + +def test_socks_runtime_password_and_saved_credential_are_mutually_exclusive(app, monkeypatch): + owner, sid = user_socket(app, 'socks_ambiguous') + proxy = saved_proxy(app, owner, auth_type='password', username='proxy-user') + jobs, connections = capture_connections(app, monkeypatch) + monkeypatch.setattr(events, 'CredentialGuard', lambda *args: SimpleNamespace()) + monkeypatch.setattr(events, 'connection_password', lambda *args: 'saved-secret') + _, emitted = call(app, monkeypatch, events.handle_ssh_connect, sid, connect_payload(socks_proxy={ + 'socks_proxy_id': proxy['id'], 'password': 'runtime-secret', 'credential_id': 'saved', + })) + assert not jobs and not connections + assert emitted[0][1]['code'] == 'credential_unavailable' + + +def test_named_socks_credential_preserves_target_token_and_live_endpoint(app, monkeypatch): + from flask import request + import app.auth_assurance as assurance + owner, sid = user_socket(app, 'socks_named') + proxy = saved_proxy(app, owner, auth_type='password', username='proxy-user', remote_dns=False) + jobs, connections = capture_connections(app, monkeypatch) + monkeypatch.setattr(assurance, 'current_authentication_session', + lambda: SimpleNamespace(id=73, user_id=owner)) + generations, passwords = [], [] + app.extensions['credentials'] = SimpleNamespace( + target_generation=lambda *args: generations.append(args) or 'generation', + target_password=lambda *args: passwords.append(args) or 'saved-proxy-secret', + ) + with app.test_request_context('/socket.io', base_url='https://localhost'): + request.sid = sid + events.handle_ssh_connect(connect_payload(socks_proxy={ + 'socks_proxy_id': proxy['id'], 'credential_id': 'named-credential', + 'credential_target_token': 'bound-token', 'host': 'untrusted.example', + })) + assert len(jobs) == 1 + assert jobs[0].join(3) + assert len(passwords) == 1 + for arguments in generations + passwords: + assert arguments[:3] == (owner, 73, 'named-credential') + target = arguments[3] + assert target['kind'] == 'socks' + assert target['host'] == 'live-proxy.example' + assert target['remote_dns'] is False + assert arguments[4] == 'bound-token' + assert 'password' not in target + assert connections[0]['socks_proxy'].password == 'saved-proxy-secret' + assert set(connections[0]['saved_password_hops']) == {'socks'} diff --git a/tests/test_socks_credentials.py b/tests/test_socks_credentials.py new file mode 100644 index 00000000..517f3d35 --- /dev/null +++ b/tests/test_socks_credentials.py @@ -0,0 +1,327 @@ +"""SOCKS credential bindings preserve route identity and legacy fingerprints.""" +from types import SimpleNamespace + +import pytest + +from app.automation_store import AutomationError, profile_fingerprint +from app.credential_service import CredentialService +from tests.test_automation_service import context + + +LEGACY_PROFILE = dict(id='legacy-host', host='server.example', port=22, + username='operator', auth_type='password', key_id=None, + jump_host_id=None) + + +@pytest.mark.parametrize('extra', [{}, {'socks_proxy_id': None}, {'socks_proxy_id': ''}]) +def test_legacy_fingerprint_stays_bit_identical(extra): + assert profile_fingerprint(dict(LEGACY_PROFILE, **extra)) == ( + '959c1a1a2614870af3bd474f9bcaf0437c44a2f2d02fe38cd9eee2dc5a79f8ca' + ) + + +def test_socks_fingerprint_binds_route_and_dns_policy(): + assert profile_fingerprint(LEGACY_PROFILE) != profile_fingerprint( + dict(LEGACY_PROFILE, socks_proxy_id='proxy-one')) + proxy = dict(LEGACY_PROFILE, id='socks:proxy-one', kind='socks', port=1080) + assert profile_fingerprint(proxy) == profile_fingerprint(dict(proxy, remote_dns=True)) + assert profile_fingerprint(proxy) != profile_fingerprint(dict(proxy, remote_dns=False)) + assert profile_fingerprint(proxy) != profile_fingerprint(dict(proxy, kind='jump')) + + +def test_socks_targets_keep_type_dns_and_default_port(): + target = CredentialService._normalize_target(dict( + host='proxy.example', username='proxy-user', kind='socks', remote_dns=False)) + assert target['kind'] == 'socks' + assert target['port'] == 1080 + assert target['remote_dns'] is False + assert CredentialService._normalize_target(dict(target, remote_dns=True)) != target + + +def test_nested_socks_route_is_bound_to_saved_id(): + target = CredentialService._normalize_target(dict( + LEGACY_PROFILE, socks_proxy={'socks_proxy_id': 'proxy-one'})) + assert target['socks_proxy_id'] == 'proxy-one' + + +@pytest.mark.parametrize('changes', [ + {'socks_proxy': {'host': 'inline.example'}}, + {'socks_proxy': {'socks_proxy_id': ''}}, + {'socks_proxy': 'invalid'}, + {'socks_proxy': {}}, + {'socks_proxy': {'socks_proxy_id': 'one'}, 'socks_proxy_id': 'two'}, + {'socks_proxy_id': 'one', 'jump_host_id': 'jump-one'}, + {'socks_proxy_id': 'one', 'proxy_jump': {'jump_host_id': 'jump-one'}}, + {'socks_proxy_id': 'one', 'auth_type': 'tailscale'}, + {'socks_proxy_id': []}, + {'socks_proxy_id': False}, + {'socks_proxy_id': 0}, +]) +def test_malformed_or_conflicting_socks_targets_are_denied(changes): + with pytest.raises(AutomationError, match='credential_target_changed'): + CredentialService._normalize_target(dict(LEGACY_PROFILE, **changes)) + + +def _proxy(uid, **changes): + from app.socks_proxy_manager import add_socks_proxy + payload = dict( + name='Proxy', host='proxy.example', port=1080, username='proxy-user', + auth_type='password', remote_dns=True) + record, error = add_socks_proxy(uid, **dict(payload, **changes)) + assert error is None + return record + + +def _routed_profile(uid, profile, proxy): + from app.profile_manager import upsert_profile + result, error = upsert_profile(uid, dict(profile, socks_proxy_id=proxy['id'])) + assert error is None + return result + + +def test_named_proxy_password_uses_typed_owned_resource_and_exact_destination(app, context): + uid, aid, _, profile = context + service = app.extensions['credentials'] + proxy = _proxy(uid) + resource_id = 'socks:' + proxy['id'] + resource = service.resolve_resource(uid, resource_id) + assert resource['kind'] == 'socks' + service.create_assigned(uid, resource_id, dict(name='Proxy login', password='proxy-secret'), True) + assert service.connection_password(uid, aid, 'socks', resource_id, proxy) == 'proxy-secret' + for change in ({'host': 'changed.example'}, {'remote_dns': False}, {'port': 1081}, + {'username': 'changed'}, {'auth_type': 'none'}): + with pytest.raises(AutomationError, match='credential_target_changed'): + service.connection_password(uid, aid, 'socks', resource_id, dict(proxy, **change)) + with pytest.raises(AutomationError): + service.connection_password(uid, aid, 'ssh', resource_id, proxy) + with pytest.raises(AutomationError): + service.connection_password(uid, aid, 'socks', 'socks:missing', proxy) + + +def test_host_password_and_context_bind_exact_socks_route(app, context): + from app.host_context import ContextError, bind_profile + uid, aid, _, profile = context + service = app.extensions['credentials'] + proxy = _proxy(uid) + routed = _routed_profile(uid, profile, proxy) + service.create_assigned(uid, profile['id'], dict(name='Host login', password='host-secret'), True) + assert service.connection_password(uid, aid, 'ssh', profile['id'], routed) == 'host-secret' + assert bind_profile(uid, profile['id'], profile['host'], 22, profile['username'], None, + socks_proxy_id=proxy['id']) == profile['id'] + for route_id in (None, 'other-proxy'): + with pytest.raises(AutomationError, match='credential_target_changed'): + service.connection_password(uid, aid, 'ssh', profile['id'], dict(routed, socks_proxy_id=route_id)) + with pytest.raises(ContextError, match='invalid_profile'): + bind_profile(uid, profile['id'], profile['host'], 22, profile['username'], None, + socks_proxy_id=route_id) + assert bind_profile(uid, profile['id'], profile['host'], 22, profile['username'], None, + optional=True) is None + + +def test_socks_inline_creation_assigns_password_and_rolls_back_failure(app, context, monkeypatch): + from app.socks_proxy_manager import load_socks_proxies + uid, aid, _, _ = context + service = app.extensions['credentials'] + payload = dict(resource_kind='socks', resource=dict( + name='Proxy', host='proxy.example', port=1080, username='proxy-user', + auth_type='password', remote_dns=False), + name='Proxy login', password='proxy-secret', confirm_target=True) + result = service.create_for_resource(uid, payload) + resource = result['resource'] + assert service.connection_password(uid, aid, 'socks', 'socks:'+resource['id'], resource) == 'proxy-secret' + before = load_socks_proxies(uid) + def fail(*_args): + raise OSError('storage failure') + monkeypatch.setattr('app.credential_store.write_document', fail) + with pytest.raises(OSError, match='storage failure'): + service.create_for_resource(uid, payload) + assert load_socks_proxies(uid) == before + + +def test_proxy_without_auth_cannot_receive_named_password(app, context): + uid, _, _, _ = context + service = app.extensions['credentials'] + proxy = _proxy(uid, auth_type='none', username='') + credential = service.create_named(uid, dict(name='Unused', password='secret')) + with pytest.raises(AutomationError, match='confirmation_required'): + service.assign_named(uid, 'socks:'+proxy['id'], credential['id'], True) + + +def test_pending_socks_guard_rechecks_route_and_credential_changes(app, context, monkeypatch): + from app.credential_connections import CredentialGuard, connection_password + uid, aid, _, _ = context + service = app.extensions['credentials'] + proxy = _proxy(uid, remote_dns=False) + resource_id = 'socks:'+proxy['id'] + credential = service.create_assigned(uid, resource_id, dict(name='Proxy', password='secret'), True) + monkeypatch.setattr('app.auth_assurance.current_authentication_session', + lambda: SimpleNamespace(id=aid, user_id=uid)) + # The ID on a live proxy payload identifies the proxy itself, not a nested route. + payload = dict(proxy, socks_proxy_id=proxy['id'], credential_id=resource_id) + with app.test_request_context(base_url='https://localhost'): + guard = CredentialGuard(uid, 'socks', payload) + assert connection_password(uid, 'socks', payload) == 'secret' + guard.check() + service.update_named(uid, credential['id'], dict(password='changed')) + with pytest.raises(AutomationError, match='vault_locked'): + guard.check() + + +def test_explicit_target_token_binds_socks_route_and_live_dns_policy(app, context): + uid, aid, _, profile = context + service = app.extensions['credentials'] + proxy = _proxy(uid) + credential = service.create_named(uid, dict(name='Host', password='host-secret')) + target = dict(profile, socks_proxy={'socks_proxy_id': proxy['id']}) + token = service.authorize_target(uid, aid, dict(credential_id=credential['id'], target=target, + confirm_target=True))['credential_target_token'] + assert service.target_password(uid, aid, credential['id'], target, token) == 'host-secret' + _proxy(uid, socks_proxy_id=proxy['id'], remote_dns=False) + with pytest.raises(AutomationError, match='credential_target_changed'): + service.target_password(uid, aid, credential['id'], target, token) + with pytest.raises(AutomationError): + service.authorize_target(uid, aid, dict(credential_id=credential['id'], + target=dict(profile, socks_proxy_id='missing'), confirm_target=True)) + + +def test_host_guard_keeps_only_route_identity_and_detects_dns_change(app, context, monkeypatch): + from app.credential_connections import CredentialGuard, connection_password + uid, aid, _, profile = context + service = app.extensions['credentials'] + proxy = _proxy(uid) + routed = _routed_profile(uid, profile, proxy) + service.create_assigned(uid, routed['id'], dict(name='Host', password='host-secret'), True) + monkeypatch.setattr('app.auth_assurance.current_authentication_session', + lambda: SimpleNamespace(id=aid, user_id=uid)) + payload = dict(profile, credential_id=profile['id'], socks_proxy=dict( + proxy, socks_proxy_id=proxy['id'], password='runtime-proxy-secret')) + with app.test_request_context(base_url='https://localhost'): + guard = CredentialGuard(uid, 'ssh', payload) + assert connection_password(uid, 'ssh', payload) == 'host-secret' + assert 'runtime-proxy-secret' not in repr(guard.args) + _proxy(uid, socks_proxy_id=proxy['id'], remote_dns=False) + with pytest.raises(AutomationError, match='vault_locked'): + guard.check() + + +@pytest.mark.parametrize('kind', ['socks', 'jump']) +def test_inline_host_password_uses_same_route_fingerprint_as_resource_inventory(app, context, kind): + from app.jump_host_manager import add_jump_host + uid, aid, _, profile = context + service = app.extensions['credentials'] + if kind == 'socks': + route = _proxy(uid) + route_fields = dict(socks_proxy_id=route['id']) + else: + route, error = add_jump_host(uid, 'Jump', 'jump.example', 22, 'operator', 'password') + assert error is None + route_fields = dict(jump_host_id=route['id']) + payload = dict(name='New host', host='new.example', port=22, username='operator', + auth_type='password', **route_fields) + result = service.create_for_resource(uid, dict(resource_kind='ssh', resource=payload, + name='Host login', password='new-host-secret', confirm_target=True)) + resource = service.resolve_resource(uid, result['resource']['id']) + expected_route = dict(route, id='socks:'+route['id'], kind='socks') if kind=='socks' else route + assert resource['route_fingerprint'] == profile_fingerprint(expected_route) + assert service.connection_password(uid, aid, 'ssh', resource['id'], payload) == 'new-host-secret' + + +def test_target_token_for_proxy_endpoint_preserves_kind_in_guard(app, context, monkeypatch): + from app.credential_connections import CredentialGuard, connection_password + uid, aid, _, _ = context + service = app.extensions['credentials'] + proxy = _proxy(uid, remote_dns=False) + credential = service.create_named(uid, dict(name='Proxy', password='explicit-secret')) + target = dict(proxy, kind='socks') + token = service.authorize_target(uid, aid, dict(credential_id=credential['id'], target=target, + confirm_target=True))['credential_target_token'] + monkeypatch.setattr('app.auth_assurance.current_authentication_session', + lambda: SimpleNamespace(id=aid, user_id=uid)) + payload = dict(proxy, socks_proxy_id=proxy['id'], credential_id=credential['id'], + credential_target_token=token) + with app.test_request_context(base_url='https://localhost'): + guard = CredentialGuard(uid, 'socks', payload) + assert connection_password(uid, 'socks', payload) == 'explicit-secret' + guard.check() + with pytest.raises(AutomationError, match='credential_target_changed'): + connection_password(uid, 'socks', dict(payload, remote_dns=True)) + + +def test_foreign_and_deleted_socks_routes_never_release_host_password(app, context, monkeypatch): + from app.auth import register_user + uid, aid, _, profile = context + service = app.extensions['credentials'] + proxy = _proxy(uid) + other, error = register_user('other_proxy_owner', 'password123') + assert error is None + foreign = _proxy(other.id) + credential = service.create_named(uid, dict(name='Host', password='host-secret')) + with pytest.raises(AutomationError, match='invalid_targets'): + service.authorize_target(uid, aid, dict(credential_id=credential['id'], + target=dict(profile, socks_proxy_id=foreign['id']), confirm_target=True)) + routed = _routed_profile(uid, profile, proxy) + service.assign_named(uid, profile['id'], credential['id'], True) + monkeypatch.setattr('app.credential_service.load_socks_proxies', lambda _uid: []) + with pytest.raises(AutomationError): + service.connection_password(uid, aid, 'ssh', profile['id'], routed) + + +def test_duplicate_proxy_ids_are_not_resolved_by_list_order(app, context, monkeypatch): + uid, _, _, _ = context + service = app.extensions['credentials'] + proxy = _proxy(uid) + monkeypatch.setattr('app.credential_service.load_socks_proxies', + lambda _uid: [proxy, dict(proxy, host='different.example')]) + with pytest.raises(AutomationError, match='invalid_targets'): + service.resolve_resource(uid, 'socks:'+proxy['id']) + + +def test_incomplete_proxy_creation_fails_without_saving_credentials(app, context): + uid, _, _, _ = context + service = app.extensions['credentials'] + with pytest.raises(AutomationError, match='invalid_targets'): + service.create_for_resource(uid, dict(resource_kind='socks', resource=dict(auth_type='password'), + name='Invalid', password='secret', confirm_target=True)) + assert service.named.read(uid)['credentials'] == {} + + +def test_native_assignment_preview_rejects_proxy_dns_change(app, context): + uid, aid, _, profile = context + service = app.extensions['credentials'] + proxy = _proxy(uid) + _routed_profile(uid, profile, proxy) + credential = service.create_named(uid, dict(name='Host', password='secret')) + mutation = dict(method='assign', resource_id=profile['id'], credential_id=credential['id'], + confirm_target=True) + token = service.prepare_vault(uid, aid, dict(operation='native', mutation=mutation))['intent'] + _proxy(uid, socks_proxy_id=proxy['id'], remote_dns=False) + with pytest.raises(AutomationError, match='preview_changed'): + service.apply_native_assignment(uid, aid, token, mutation) + assert profile['id'] not in service.named.read(uid)['assignments'] + + +@pytest.mark.parametrize('kind', ['socks', 'jump']) +def test_top_level_route_reference_cannot_authorize_a_direct_connection(app, context, monkeypatch, kind): + from app.credential_connections import CredentialGuard, connection_password + from app.jump_host_manager import add_jump_host + from app.profile_manager import upsert_profile + uid, aid, _, profile = context + service = app.extensions['credentials'] + if kind == 'socks': + proxy = _proxy(uid) + routed = _routed_profile(uid, profile, proxy) + else: + jump, error = add_jump_host(uid, 'Jump', 'jump.example', 22, 'operator', 'password') + assert error is None + routed, error = upsert_profile(uid, dict(profile, jump_host_id=jump['id'])) + assert error is None + service.create_assigned(uid, profile['id'], dict(name='Host', password='secret'), True) + monkeypatch.setattr('app.auth_assurance.current_authentication_session', + lambda: SimpleNamespace(id=aid, user_id=uid)) + # Transport selects nested proxy_jump/socks_proxy; an ID alone creates no route. + data = dict(routed, credential_id=profile['id']) + with app.test_request_context(base_url='https://localhost'): + with pytest.raises(AutomationError, match='credential_target_changed'): + CredentialGuard(uid, 'ssh', data) + with pytest.raises(AutomationError, match='credential_target_changed'): + connection_password(uid, 'ssh', data) diff --git a/tests/test_socks_secondary_routes.py b/tests/test_socks_secondary_routes.py new file mode 100644 index 00000000..f4a909ee --- /dev/null +++ b/tests/test_socks_secondary_routes.py @@ -0,0 +1,255 @@ +"""SOCKS routes remain explicit in probes, automation, recovery, and exchange.""" + +from datetime import datetime, timezone +import io +import json +import zipfile + +import pytest + +from app.automation_store import AutomationError +from tests.test_automation_service import context # noqa: F401 +from tests.test_automation_schedules import scheduling, activate # noqa: F401 + + +@pytest.fixture(autouse=True) +def clear_overview_cache(): + from app.overview_routes import _CACHE + + _CACHE.clear() + yield + _CACHE.clear() + + +def _routed_profile(uid, *, auth_type='none'): + from app.socks_proxy_manager import add_socks_proxy + from app.profile_manager import add_profile + + proxy, error = add_socks_proxy(uid, 'Proxy', 'proxy.example', 1080, + auth_type, 'proxy-user' if auth_type == 'password' else '') + assert error is None + profile, error = add_profile(uid, 'Routed', 'internal.example', 22, + 'deploy', 'password', socks_proxy_id=proxy['id']) + assert error is None + return profile, proxy + + +@pytest.mark.parametrize('proxy_auth, expected', [('none', 'ready'), ('password', 'password_required')]) +def test_overview_describes_owned_socks_route_and_auth(app, client, context, proxy_auth, expected): + uid = context[0] + profile, proxy = _routed_profile(uid, auth_type=proxy_auth) + response = client.get('/api/overview/status') + assert response.status_code == 200 + row = next(item for item in response.json['hosts'] if item['id'] == profile['id']) + assert row['route'] == 'socks' + assert row['socks_proxy_id'] == proxy['id'] + assert row['via_socks'] is True + assert row['socks_auth'] == expected + + +def test_missing_proxy_is_broken_in_overview_and_restore_check(app, client, context): + from app.socks_proxy_manager import _get_file + + profile, proxy = _routed_profile(context[0]) + _get_file(context[0]).unlink() + rows = client.get('/api/overview/status').json['hosts'] + row = next(item for item in rows if item['id'] == profile['id']) + assert row['route'] == 'missing' and row['state'] == 'broken' + assert row['socks_proxy_id'] == proxy['id'] + rows = client.get('/api/account/recovery-check').json['hosts'] + row = next(item for item in rows if item['id'] == profile['id']) + assert row['route'] == 'missing_socks' + + +def test_overview_socks_auth_uses_the_proxy_credential_binding(app, client, context): + uid = context[0] + profile, proxy = _routed_profile(uid, auth_type='password') + app.extensions['credentials'].create_assigned(uid, 'socks:' + proxy['id'], + {'name': 'Proxy login', 'password': 'test-only-proxy-password'}, True) + response = client.get('/api/overview/status') + row = next(item for item in response.json['hosts'] if item['id'] == profile['id']) + assert row['socks_auth'] == 'saved' + assert row['auth'] == 'prompt' + assert 'test-only-proxy-password' not in response.get_data(as_text=True) + + +def test_socks_reachability_only_probes_proxy_and_never_caches_its_platform(app, client, context, monkeypatch): + from app import overview_routes + from app.profile_manager import get_profile + + profile, _proxy = _routed_profile(context[0]) + calls = [] + + def probe(host, port): + calls.append((host, port)) + return {'state': 'reachable', 'latency_ms': 1, 'platform': 'ubuntu'} + + monkeypatch.setattr(overview_routes, '_probe', probe) + response = client.post('/api/overview/reachability', json={'ids': [profile['id']]}) + assert response.status_code == 200 + assert calls == [('proxy.example', 1080)] + assert response.json['results'][profile['id']] == {'state': 'reachable', 'latency_ms': 1, 'via_socks': True} + assert 'greeting_platform' not in get_profile(context[0], profile['id']) + + +def test_missing_socks_reachability_never_falls_back_to_target(app, client, context, monkeypatch): + from app import overview_routes + from app.socks_proxy_manager import _get_file + + profile, _proxy = _routed_profile(context[0]) + _get_file(context[0]).unlink() + + def forbidden_probe(*_args): + pytest.fail('Missing SOCKS route must not open a direct socket') + + monkeypatch.setattr(overview_routes, '_probe', forbidden_probe) + response = client.post('/api/overview/reachability', json={'ids': [profile['id']]}) + assert response.status_code == 200 + assert response.json['results'][profile['id']] == {'state': 'unresolved', 'via_socks': True} + + +def test_probe_cache_signature_includes_proxy_identity(app, context): + from app.overview_routes import _plans + from app.profile_manager import upsert_profile + from app.socks_proxy_manager import add_socks_proxy + + profile, _proxy = _routed_profile(context[0]) + _, before = _plans(context[0]) + other, error = add_socks_proxy(context[0], 'Other proxy', 'proxy.example', 1080) + assert error is None + updated, error = upsert_profile(context[0], {**profile, 'socks_proxy_id': other['id']}) + assert error is None and updated['socks_proxy_id'] == other['id'] + _, after = _plans(context[0]) + assert before != after + + +def test_discovery_recognizes_owned_proxy_endpoints(app, context): + from app.discovery_routes import _known_endpoints + + _routed_profile(context[0]) + assert ('proxy.example', 1080) in _known_endpoints(context[0]) + assert ('proxy.example', 1080) not in _known_endpoints(context[0] + 1) + + +def test_automation_preview_and_schedule_reject_socks_targets(app, context): + uid, aid, book, _direct = context + profile, _proxy = _routed_profile(uid) + payload = {'runbook_id': book['id'], 'profile_ids': [profile['id']]} + with pytest.raises(AutomationError, match='unsupported_target'): + app.extensions['automation'].prepare_run(uid, aid, payload) + with pytest.raises(AutomationError, match='unsupported_target'): + app.extensions['automation_schedules'].prepare(uid, aid, + {**payload, 'cron': '* * * * *', 'timezone': 'UTC'}) + + +def test_automation_readiness_explains_unsupported_socks_route(app, client, context, monkeypatch): + import config + + profile, proxy = _routed_profile(context[0]) + monkeypatch.setattr(config, 'AUTOMATION_ALLOW_INSECURE_LOOPBACK', True) + response = client.get('/api/automation/state') + assert response.status_code == 200 + row = next(item for item in response.json['profiles'] if item['id'] == profile['id']) + assert row['supported'] is False + assert row['socks_proxy_id'] == proxy['id'] + assert row['unsupported_reason'] == 'SOCKS5 routes are not supported for automation yet' + + +@pytest.mark.parametrize('in_route', [False, True]) +def test_automation_transport_defensively_rejects_socks_before_network(app, context, monkeypatch, in_route): + from app import automation_transport + + profile, proxy = _routed_profile(context[0]) + + def forbidden_target(*_args): + pytest.fail('Unsupported SOCKS automation must not resolve or connect') + + monkeypatch.setattr(automation_transport, '_allowed_target', forbidden_target) + kwargs = {} + if in_route: + profile = {key: value for key, value in profile.items() if key != 'socks_proxy_id'} + kwargs['route'] = {**proxy, 'kind': 'socks'} + with pytest.raises(AutomationError, match='unsupported_target'): + with automation_transport.connect_profile(context[0], profile, None, lambda: False, **kwargs): + pytest.fail('Unsupported connection yielded a transport') + + +def test_existing_schedule_requires_review_when_host_changes_to_socks(app, scheduling, monkeypatch): + from app.profile_manager import upsert_profile + + service, uid, _aid, _book, direct = scheduling + now = datetime(2026, 11, 3, 12, 0, tzinfo=timezone.utc) + activate(scheduling, now) + _profile, proxy = _routed_profile(uid) + assert upsert_profile(uid, {**direct, 'socks_proxy_id': proxy['id']})[1] is None + dispatched = [] + monkeypatch.setattr(service.automation, 'start_scheduled', lambda grant: dispatched.append(grant)) + service.tick(now=now.replace(minute=1)) + assert dispatched == [] + assert service.list(uid)[0]['last_status'] == 'needs_review' + + +def test_socks_export_marks_route_loss_and_reimport_is_blocked(app, client, context): + profile, proxy = _routed_profile(context[0]) + response = client.get('/api/profiles/exchange') + assert response.status_code == 200 + assert response.headers['X-WebSSH-SOCKS-Routes-Omitted'] == '1' + document = response.json + row = next(item for item in document['hosts'] if item['name'] == profile['name']) + assert row['socks_route_omitted'] is True + assert row['warnings'] == ['SOCKS5 route not exported'] + assert proxy['id'] not in json.dumps(document) + preview = client.post('/api/profiles/exchange/preview', json={'format': 'json', 'source': json.dumps(document)}) + assert preview.status_code == 200 + row = next(item for item in preview.json['records'] if item['name'] == profile['name']) + assert row['blocked'] is True + assert 'SOCKS5 route not exported' in row['warnings'] + index = next(index for index, item in enumerate(document['hosts']) if item['name'] == profile['name']) + imported = client.post('/api/profiles/exchange', json={'format': 'json', 'source': json.dumps(document), + 'selections': [{'index': index}]}) + assert imported.status_code == 400 + + +def test_batched_socks_export_preserves_per_host_warning_and_download_count(app, client, context, monkeypatch): + import config + from app.profile_manager import save_profiles + from app.profile_exchange import preview_import + + profile, _proxy = _routed_profile(context[0]) + monkeypatch.setattr(config, 'PROFILE_MAX_RECORDS', 200) + assert save_profiles(context[0], [ + {**profile, 'id': f'host-{index}', 'name': f'Host {index}'} + for index in range(101) + ]) + response = client.get('/api/profiles/exchange') + assert response.status_code == 200 + assert response.mimetype == 'application/zip' + assert response.headers['X-WebSSH-SOCKS-Routes-Omitted'] == '101' + with zipfile.ZipFile(io.BytesIO(response.data)) as archive: + documents = [json.loads(archive.read(name)) for name in archive.namelist()] + assert [len(document['hosts']) for document in documents] == [100, 1] + for document in documents: + assert all(host['warnings'] == ['SOCKS5 route not exported'] for host in document['hosts']) + assert all(host['blocked'] for host in preview_import(json.dumps(document), 'json')['records']) + + +@pytest.mark.parametrize('fields', [ + {'socks_proxy_id': 'proxy'}, {'socks_proxy': {'host': 'proxy.example'}}, + {'socks_route_omitted': 'false'}, {'warnings': ['user-controlled warning']}, + {'warnings': ['SOCKS5 route not exported']}, +]) +def test_json_import_rejects_unsupported_socks_or_warning_fields(fields): + from app.profile_exchange import preview_import, ExchangeError + + with pytest.raises(ExchangeError): + preview_import(json.dumps({'format': 'webssh-hosts', 'version': 1, + 'hosts': [{'name': 'Target', 'host': 'target.example', 'username': 'root', **fields}]}), 'json') + + +def test_proxycommand_socks_import_remains_blocked_without_evaluation(): + from app.profile_exchange import preview_import + + result = preview_import('Host routed\n HostName target.example\n User deploy\n' + ' ProxyCommand nc -X 5 -x proxy.example:1080 %h %p', 'openssh') + assert result['records'][0]['blocked'] is True + assert result['warnings'] diff --git a/tests/test_ssh_manager.py b/tests/test_ssh_manager.py index 5a50d427..320f8ed1 100644 --- a/tests/test_ssh_manager.py +++ b/tests/test_ssh_manager.py @@ -239,6 +239,135 @@ def connect_target(**overrides): return ssh_manager.create_ssh_connection(**kwargs) +def socks_route(**changes): + fields = dict(proxy_id='saved-proxy', host='proxy.example', port=1080, + auth_type='none', username='', password=None, remote_dns=True) + fields.update(changes) + return ssh_manager.SocksProxyRoute(**fields) + + +def test_socks_transport_uses_proxy_socket_and_target_host_identity(monkeypatch): + clients = install_ssh_clients(monkeypatch) + monkeypatch.setattr(ssh_manager.config, 'BLOCK_INTERNAL_SSH', False) + tunnels = [] + + def tunnel(sock, destination, **kwargs): + tunnels.append((sock, destination, kwargs)) + return sock + + monkeypatch.setattr(ssh_manager, 'open_socks5_tunnel', tunnel, raising=False) + session_id, error = connect_target(password='target-secret', socks_proxy=socks_route()) + assert error is None + assert len(clients) == 1 + assert len(clients.opened_sockets) == 1 + assert clients.opened_sockets[0].host == 'proxy.example' + assert clients[0].connect_kwargs['hostname'] == 'target.example' + assert clients[0].connect_kwargs['sock'] is clients.opened_sockets[0] + assert tunnels[0][1].hostname == 'target.example' + assert ssh_manager.get_session(session_id)['socks_proxy_id'] == 'saved-proxy' + assert ssh_manager.get_session(session_id)['via_socks'] is True + assert 'password' not in ssh_manager.sessions[session_id] + assert 'socks_proxy' not in ssh_manager.sessions[session_id] + + +@pytest.mark.parametrize('extra,message', [ + ({'proxy_jump_host': 'jump.example'}, 'either a jump host or a SOCKS5 proxy'), + ({'auth_type': 'tailscale'}, 'Tailscale SSH cannot be used with a SOCKS5 proxy'), +]) +def test_socks_route_conflicts_stop_before_reservation(monkeypatch, extra, message): + monkeypatch.setattr(ssh_manager.quota_manager, 'reserve', + lambda *a, **kw: pytest.fail('invalid route reserved quota')) + session_id, error = connect_target(socks_proxy=socks_route(), **extra) + assert session_id is None + assert message in str(error) + + +@pytest.mark.parametrize('host,proxy_host', [('127.0.0.1', 'proxy.example'), + ('target.example', '127.0.0.1')]) +def test_socks_blocked_target_or_proxy_opens_no_socket(monkeypatch, host, proxy_host): + clients = install_ssh_clients(monkeypatch) + monkeypatch.setattr(ssh_manager, 'resolve_allowed_target', network_policy.resolve_allowed_target) + monkeypatch.setattr(socket, 'getaddrinfo', lambda *a, **kw: [ + (socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, '', ('1.1.1.1', 22)), + ]) + monkeypatch.setattr(ssh_manager.config, 'BLOCK_INTERNAL_SSH', True) + monkeypatch.setattr(ssh_manager.config, 'SOCKS_REMOTE_DNS_ALLOWLIST', ()) + session_id, error = connect_target(host=host, socks_proxy=socks_route(host=proxy_host)) + assert session_id is None + assert 'not allowed' in str(error) + assert clients.opened_sockets == [] + assert clients == [] + + +@pytest.mark.parametrize('code', ['socks_auth_failed', 'socks_connection_refused', + 'socks_timeout', 'socks_protocol_error']) +def test_socks_error_has_context_closes_socket_and_never_falls_back(monkeypatch, code): + from app.socks5_client import Socks5Error + clients = install_ssh_clients(monkeypatch) + + def fail(*args, **kwargs): + raise Socks5Error(code) + + monkeypatch.setattr(ssh_manager, 'open_socks5_tunnel', fail, raising=False) + released = [] + original_release = ssh_manager.release_reservation + + def release(reservation): + released.append(reservation) + original_release(reservation) + + monkeypatch.setattr(ssh_manager, 'release_reservation', release) + session_id, error = connect_target(socks_proxy=socks_route()) + assert session_id is None + assert error.code == code + assert error.context == 'socks_proxy' + assert len(clients.opened_sockets) == 1 + assert clients.opened_sockets[0].closed + assert clients == [] + assert len(released) == 1 and released[0].released + + +def test_socks_unreachable_endpoint_has_safe_error(monkeypatch): + install_ssh_clients(monkeypatch) + + def unreachable(*args, **kwargs): + raise OSError('proxy secret diagnostic') + + monkeypatch.setattr(ssh_manager, 'open_validated_socket', unreachable) + session_id, error = connect_target(socks_proxy=socks_route()) + assert session_id is None + assert error.code == 'socks_proxy_unreachable' + assert error.context == 'socks_proxy' + assert 'secret diagnostic' not in str(error) + + +def test_socks_password_lease_is_fresh_before_handshake(monkeypatch): + clients = install_ssh_clients(monkeypatch) + + class Revoked: + def is_set(self): + return False + + def check(self): + raise ValueError('revoked') + + def negotiate(sock, destination, **kwargs): + assert kwargs['cancelled']() is True + from app.socks5_client import Socks5Error + raise Socks5Error('connection_cancelled') + + monkeypatch.setattr(ssh_manager, 'open_socks5_tunnel', negotiate, raising=False) + route = socks_route(auth_type='password', username='proxy-login', password='proxy-secret') + assert 'proxy-secret' not in repr(route) + assert 'proxy-login' not in repr(route) + session_id, error = connect_target(socks_proxy=route, credential_guard=Revoked(), + saved_password_hops=('socks',)) + assert session_id is None + assert str(error) == 'Connection cancelled' + assert len(clients.opened_sockets) == 1 + assert clients.opened_sockets[0].closed + + def test_tailscale_backend_requires_exact_authorization_before_resolving( monkeypatch, ): From 8eec92caa19ff7fa3d7bb43f67ad599816891f08 Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Sat, 14 Nov 2026 10:09:49 +0100 Subject: [PATCH 04/12] Add SOCKS proxy management and routed connection UI --- static/css/webssh-2.css | 11 +- static/js/app.js | 98 +++++- static/js/automation.js | 2 +- static/js/connection-overview-classic.js | 25 +- static/js/connection-overview.js | 9 +- static/js/credential-picker.js | 6 +- static/js/host-context.js | 3 +- static/js/host-platform.js | 1 + static/js/host-resource-layout.js | 11 +- static/js/i18n.js | 366 ++++++++++++++++++++++- static/js/native-credentials.js | 8 +- static/js/overview-model.js | 22 +- static/js/overview-route-map.js | 8 +- static/js/overview-workspace.js | 37 ++- static/js/profile-exchange.js | 6 +- static/js/profile-launcher-utils.js | 20 +- static/js/profile-manager.js | 32 +- static/js/session-manager.js | 26 +- static/js/sftp-file-manager.js | 24 +- static/js/socks-proxy-manager.js | 361 ++++++++++++++++++++++ static/js/ssh-error-ui.js | 20 ++ static/js/webssh2-shell.js | 4 +- templates/index.html | 101 ++++++- tests/e2e/run_app.py | 15 +- tests/e2e/socks-proxy-routes.spec.js | 218 ++++++++++++++ tests/js/reconnect-replacement.test.js | 2 +- tests/js/session-socks-reconnect.test.js | 48 +++ tests/js/socks-proxy-routes.test.js | 59 ++++ tests/test_command_set_ui.py | 8 +- tests/test_webssh2_shell.py | 8 +- 30 files changed, 1469 insertions(+), 90 deletions(-) create mode 100644 static/js/socks-proxy-manager.js create mode 100644 tests/e2e/socks-proxy-routes.spec.js create mode 100644 tests/js/session-socks-reconnect.test.js create mode 100644 tests/js/socks-proxy-routes.test.js diff --git a/static/css/webssh-2.css b/static/css/webssh-2.css index 02d8ff22..8c428f84 100644 --- a/static/css/webssh-2.css +++ b/static/css/webssh-2.css @@ -1626,13 +1626,13 @@ body:has(.auth-container) { /* Professional resource workspaces for Hosts and Commands. */ .primary-workspace-surface #profileManagementModal > .modal-content > .modal-body, .primary-workspace-surface #keyManagementModal > .modal-content > .modal-body, -.primary-workspace-surface #jumpHostManagementModal > .modal-content > .modal-body { +.primary-workspace-surface :is(#jumpHostManagementModal, #socksProxyManagementModal) > .modal-content > .modal-body { padding: 0; } .primary-workspace-surface .profile-management-modal-body, .primary-workspace-surface #keyManagementModal > .modal-content > .modal-body, -.primary-workspace-surface #jumpHostManagementModal > .modal-content > .modal-body { +.primary-workspace-surface :is(#jumpHostManagementModal, #socksProxyManagementModal) > .modal-content > .modal-body { display: grid; grid-template-columns: 210px minmax(0, 1fr); align-items: stretch; @@ -2053,7 +2053,7 @@ body:has(.auth-container) { @media (max-width: 900px) { .primary-workspace-surface .profile-management-modal-body, .primary-workspace-surface #keyManagementModal > .modal-content > .modal-body, - .primary-workspace-surface #jumpHostManagementModal > .modal-content > .modal-body, + .primary-workspace-surface :is(#jumpHostManagementModal, #socksProxyManagementModal) > .modal-content > .modal-body, .primary-workspace-surface .command-workspace-layout { grid-template-columns: 170px minmax(0, 1fr); } @@ -2072,7 +2072,7 @@ body:has(.auth-container) { @media (max-width: 680px) { .primary-workspace-surface .profile-management-modal-body, .primary-workspace-surface #keyManagementModal > .modal-content > .modal-body, - .primary-workspace-surface #jumpHostManagementModal > .modal-content > .modal-body, + .primary-workspace-surface :is(#jumpHostManagementModal, #socksProxyManagementModal) > .modal-content > .modal-body, .primary-workspace-surface .command-workspace-layout { display: flex; flex-direction: column; @@ -5497,3 +5497,6 @@ body[data-theme="paper"]:has(.auth-access-dock) { } .session-tabs-row > .context-workspace-launcher .material-icons { width: 18px; flex: 0 0 18px; } + +.connection-route-fields { border: 1px solid var(--border-color); border-radius: 8px; padding: 12px; margin: 0 0 16px; min-width: 0; } +.connection-route-fields legend { padding: 0 6px; font-weight: 600; } diff --git a/static/js/app.js b/static/js/app.js index 6faf0b0f..b9f7747f 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -287,8 +287,10 @@ const modalByAsset = { hosts: 'profileManagementModal', 'jump-hosts': 'jumpHostManagementModal', + 'socks-proxies': 'socksProxyManagementModal', keys: 'keyManagementModal', }; + if (asset === 'socks-proxies') { window.SocksProxyManager?.load(); window.SocksProxyManager?.renderList(); } const modalId = modalByAsset[asset]; if (!modalId) return; @@ -301,6 +303,7 @@ ProfileManager.loadKeys(); if (asset === 'jump-hosts') { window.JumpHostManager?.load(); + window.SocksProxyManager?.load(); window.JumpHostManager?.renderList(); } const view = document.getElementById(modalId); @@ -310,7 +313,7 @@ } const currentModal = document.querySelector( - '#profileManagementModal.show, #jumpHostManagementModal.show, #keyManagementModal.show', + '#profileManagementModal.show, #jumpHostManagementModal.show, #socksProxyManagementModal.show, #keyManagementModal.show', ); const switchingFromHosts = currentModal?.id === 'profileManagementModal'; if (currentModal) { @@ -327,6 +330,7 @@ } if (asset === 'jump-hosts') { window.JumpHostManager?.load(); + window.SocksProxyManager?.load(); window.JumpHostManager?.renderList(); } window.ModalManager.open(document.getElementById(modalId)); @@ -346,6 +350,7 @@ if(share)window.sftpFileManager.openSMBSourceDialog(share); return; } + if (id.startsWith('socks:')) { openConnectionAssetManager('socks-proxies'); window.SocksProxyManager?.edit(id.slice(6)); return; } openConnectionAssetManager(id.startsWith('jump:') ? 'jump-hosts' : 'hosts'); if (id.startsWith('jump:')) window.JumpHostManager?.edit(id.slice(5)); else if (!id.startsWith('smb:')) window.ProfileManager?.openEditor(id); @@ -396,6 +401,12 @@ if (jumpSelect) jumpSelect.value = ''; const jumpPassword = document.getElementById('jumpHostPasswordInput'); if (jumpPassword) jumpPassword.value = ''; + const socksSelect = document.getElementById('socksProxySelect'); + if (socksSelect) socksSelect.value = ''; + const socksPassword = document.getElementById('socksProxyPasswordInput'); + if (socksPassword) socksPassword.value = ''; + window.SocksProxyManager?.updatePasswordVisibility(); + window.SocksProxyManager?.updateRouteControls(); window.JumpHostManager?.updatePasswordVisibility(); window.ConnectionCommandManager?.clear(); ProfileManager.clearLegacyCommands(); @@ -415,6 +426,23 @@ }; window.prepareSessionReconnectJump = (session, {applyToForm = false} = {}) => { + if (session.socksProxyId || session.viaSocks) { + const manager = window.SocksProxyManager; + const proxy = manager?.loaded === true ? manager.getById(session.socksProxyId) : null; + const unresolved = !proxy || Boolean(session.jumpHostId || session.viaJump) || session.reconnectRouteKnown === false; + if (applyToForm) { + selectedConnectionProfileState = {requiredSocksProxyId: session.socksProxyId || '', missingSocksProxy: unresolved}; + manager?.selectReference(document.getElementById('socksProxySelect'), session.socksProxyId); + const password = document.getElementById('socksProxyPasswordInput'); + if (password) password.value = ''; + manager?.updatePasswordVisibility(); + manager?.updateRouteControls(); + window.CredentialPicker?.restore?.('socksProxyPasswordInput'); + window.setConnectionAdvancedExpanded(true); + if (unresolved) window.showNotification?.(window.i18n?.t('connection.socksProxyMissing') || 'SOCKS5 proxy missing', 'error'); + } + return {requiresForm: unresolved || proxy?.auth_type === 'password', socksProxy: proxy && !unresolved ? {socks_proxy_id: proxy.id} : null}; + } const requiredId = session.jumpHostId || ''; const manager = window.JumpHostManager; const jump = manager?.loaded === true && requiredId ? manager.getById(requiredId) : null; @@ -1825,6 +1853,7 @@ key => window.i18n?.t?.(key), APP_ROOT, ) || { message: `SSH Error: ${data.error}`, type: 'error' }; + if (presentation.action?.asset === 'socks-proxies') presentation.action.onClick = () => openConnectionAssetManager('socks-proxies'); showNotification(presentation); if (!isCurrentRequest) { @@ -1918,6 +1947,16 @@ showNotification((window.i18n ? window.i18n.t('toast.keyDeleted') : 'SSH key deleted successfully'), 'success'); }); + socket.on('socks_proxies_list', data => window.SocksProxyManager?.setSocksProxies(data.socks_proxies)); + socket.on('socks_proxy_saved', data => { + window.SocksProxyManager?.upsertSocksProxy(data?.socks_proxy); + showNotification(window.i18n?.t('socksproxies.savedOk') || 'SOCKS5 proxy saved', 'success'); + }); + socket.on('socks_proxy_deleted', data => { + window.SocksProxyManager?.removeSocksProxy(data?.socks_proxy_id); + showNotification(window.i18n?.t('socksproxies.deleted') || 'SOCKS5 proxy deleted', 'success'); + }); + socket.on('jump_hosts_list', (data) => { if (window.JumpHostManager) window.JumpHostManager.setJumpHosts(data.jump_hosts); }); @@ -1933,6 +1972,7 @@ }); socket.on('error', (data) => { + if (window.SocksProxyManager?.handleUsageError(data)) return; if (FilePreview.handleSocketError(data)) return; if (window.sftpFileManager?.handlesSocketError?.(data)) return; showNotification(`Error: ${data.error}`, 'error'); @@ -2082,9 +2122,12 @@ window.clearConnectionProfileState(); return null; } + const requiredSocksProxyId = profile.socks_proxy_id || ''; const requiredJumpHostId = profile.jump_host_id || ''; selectedConnectionProfileState = { profileId, + requiredSocksProxyId, + missingSocksProxy: Boolean(requiredSocksProxyId && !window.SocksProxyManager?.getById(requiredSocksProxyId)), requiresKey: profile.auth_type === 'key', pendingKeyId: profile.auth_type === 'key' ? profile.key_id || null : null, missingJumpHost: Boolean( @@ -2116,11 +2159,13 @@ let focusTarget = document.getElementById('connectBtn'); if (mode === 'password') { focusTarget = document.getElementById('passwordInput'); + } else if (mode === 'socks-proxy-password') { + focusTarget = document.getElementById('socksProxyPasswordInput'); } else if (mode === 'jump-host-password') { focusTarget = document.getElementById('jumpHostPasswordInput'); } window.requestAnimationFrame(() => focusTarget?.focus()); - if (mode === 'password' || mode === 'jump-host-password') connectWithAssignedPasswords(profileId); + if (['password', 'jump-host-password', 'socks-proxy-password'].includes(mode)) connectWithAssignedPasswords(profileId); } /** @@ -2135,11 +2180,13 @@ const jump = profile.jump_host_id ? window.JumpHostManager?.getById(profile.jump_host_id) : null; const needsTarget = profile.auth_type === 'password'; const needsJump = jump?.auth_type === 'password'; - if (!needsTarget && !needsJump) return; + const needsSocks = window.SocksProxyManager?.getById(profile.socks_proxy_id)?.auth_type === 'password'; + if (!needsTarget && !needsJump && !needsSocks) return; try { await Promise.all([ needsTarget ? picker.restore('passwordInput') : null, needsJump ? picker.restore('jumpHostPasswordInput') : null, + needsSocks ? picker.restore('socksProxyPasswordInput') : null, ]); } catch { return; @@ -2149,7 +2196,8 @@ const targetReady = !needsTarget || (document.getElementById('authTypeSelect')?.value === 'saved-password' && Boolean(document.getElementById('savedPasswordSelect')?.value)); const jumpReady = !needsJump || Boolean(picker.get('jumpHostPasswordInput')); - if (targetReady && jumpReady) document.getElementById('connectionForm')?.requestSubmit(); + const socksReady = !needsSocks || Boolean(picker.get('socksProxyPasswordInput')); + if (targetReady && jumpReady && socksReady) document.getElementById('connectionForm')?.requestSubmit(); } function clearPendingPane() { @@ -2348,6 +2396,7 @@ getContext: () => ({ keys: ProfileManager.keys, jumpHosts: window.JumpHostManager?.jumpHosts || [], + socksProxies: window.SocksProxyManager?.socksProxies || [], }), getDefaultPaneIndex, isBusy: () => Boolean(currentConnectRequestId), @@ -3063,6 +3112,7 @@ } if (inventory) { window.JumpHostManager?.setJumpHosts(inventory.jump_hosts); + window.SocksProxyManager?.setSocksProxies(inventory.socks_proxies); ProfileManager.setProfiles(inventory.profiles); } else { ProfileManager.loadProfiles(); @@ -3158,6 +3208,39 @@ return; } + const socksProxyId = document.getElementById('socksProxySelect')?.value || ''; + const requiredSocksProxyId = selectedConnectionProfileState?.requiredSocksProxyId; + if ((requiredSocksProxyId && socksProxyId !== requiredSocksProxyId) + || (selectedConnectionProfileState?.missingSocksProxy && !socksProxyId)) { + showNotification(window.i18n?.t('connection.socksProxyMissing') || 'SOCKS5 proxy missing', 'error'); + return; + } + if ((socksProxyId && document.getElementById('jumpHostSelect').value) + || (authType === 'tailscale' && (socksProxyId || document.getElementById('jumpHostSelect').value))) { + showNotification(window.i18n?.t('connection.routeConflict') || 'Choose one compatible route.', 'error'); + return; + } + let socksProxy = null; + if (socksProxyId) { + const proxy = window.SocksProxyManager?.getById(socksProxyId); + if (!proxy) { showNotification(window.i18n?.t('connection.socksProxyMissing') || 'SOCKS5 proxy missing', 'error'); return; } + socksProxy = {socks_proxy_id: socksProxyId}; + if (proxy.auth_type === 'password') { + const password = document.getElementById('socksProxyPasswordInput').value; + const credential = window.CredentialPicker?.get('socksProxyPasswordInput'); + if (!password && !credential) { + showNotification(window.i18n?.t('connection.socksProxyPasswordRequired') || 'SOCKS5 proxy password is required', 'error'); + document.getElementById('socksProxyPasswordInput').focus(); + return; + } + if (credential) { + socksProxy.credential_id = credential; + const token = window.CredentialPicker?.token?.('socksProxyPasswordInput'); + if (token) socksProxy.credential_target_token = token; + } else socksProxy.password = password; + } + } + // Optional jump host (bastion) — chosen from the saved list const jumpHostId = document.getElementById('jumpHostSelect').value; let proxyJump = null; @@ -3210,6 +3293,7 @@ if (proxyJump) { connectionData.proxy_jump = proxyJump; } + if (socksProxy) connectionData.socks_proxy = socksProxy; const contextProfileId = SessionManager.pendingReconnectProfileId || window.ProfileManager?.connectionProfileId?.(connectionData); if (contextProfileId) connectionData.profile_id = contextProfileId; @@ -3252,12 +3336,18 @@ document.getElementById('passwordInput').value = ''; document.getElementById('jumpHostPasswordInput').value = ''; + document.getElementById('socksProxyPasswordInput').value = ''; }); document.getElementById('authTypeSelect').addEventListener('change', (e) => { ProfileManager.handleAuthTypeChange(e.target.value); }); + document.getElementById('socksProxySelect')?.addEventListener('change', () => { + document.getElementById('socksProxyPasswordInput').value = ''; + window.CredentialPicker?.restore?.('socksProxyPasswordInput'); + window.SocksProxyManager?.updatePasswordVisibility(); + }); document.getElementById('jumpHostSelect').addEventListener('change', () => { document.getElementById('jumpHostPasswordInput').value = ''; window.CredentialPicker?.restore?.('jumpHostPasswordInput'); diff --git a/static/js/automation.js b/static/js/automation.js index 6098ed35..71715592 100644 --- a/static/js/automation.js +++ b/static/js/automation.js @@ -604,7 +604,7 @@ label.append(node('strong', profile.name || profile.host), node('small', `${profile.username}@${profile.host}:${profile.port || 22}${profile.via ? ` · ${tr('viaJump', {name: profile.via})}` : ''}${profile.group ? ` · ${profile.group}` : ''}`)); const marker = node('span', 'dns', 'material-icons automation-host-icon'); marker.setAttribute('aria-hidden', 'true'); const statusKey = targetCredentialStatus(profile); - const status = node('span', tr(statusKey), 'automation-state'); status.dataset.credentialStatus = profile.id; + const status = node('span', profile.socks_proxy_id ? window.i18n.t('connection.socksProxyAutomation') : tr(statusKey), 'automation-state'); status.dataset.credentialStatus = profile.id; label.append(status); row.append(input, marker, label); const jumpCredential = ['jumpPasswordMissing', 'jumpCredentialChanged'].includes(statusKey); if (profile.supported && (profile.auth_type !== 'key' || jumpCredential)) { diff --git a/static/js/connection-overview-classic.js b/static/js/connection-overview-classic.js index be1970d3..3f14adfb 100644 --- a/static/js/connection-overview-classic.js +++ b/static/js/connection-overview-classic.js @@ -8,9 +8,10 @@ // Deliberately whitelist display fields. Addresses, users, credentials and // arbitrary graph relationships never enter this presentation model. - function buildAccessModel(profiles = [], jumpHosts = [], jumpsLoaded = false) { + function buildAccessModel(profiles = [], jumpHosts = [], jumpsLoaded = false, socksProxies = [], socksLoaded = false) { const jumps = new Map(jumpHosts.filter(jump => jump && typeof jump.id === 'string') .map(jump => [jump.id, {id: jump.id, name: String(jump.name || '')}])); + const socks = new Map(socksProxies.filter(proxy => proxy && typeof proxy.id === 'string').map(proxy => [proxy.id, {id: proxy.id, name: String(proxy.name || '')}])); const seen = new Set(); const hosts = profiles.filter(profile => { if (!profile || typeof profile.id !== 'string' || seen.has(profile.id)) return false; @@ -19,14 +20,16 @@ }).map(profile => { const jump = jumpsLoaded && profile.jump_host_id ? jumps.get(profile.jump_host_id) : null; + const proxy = socksLoaded && profile.socks_proxy_id ? socks.get(profile.socks_proxy_id) : null; return { id: profile.id, name: String(profile.name || ''), group: String(profile.group || ''), favorite: profile.favorite === true, order: Number(profile.sort_order) || 0, - route: profile.jump_host_id ? (jump ? 'configured' : (jumpsLoaded ? 'unavailable' : 'loading')) : 'none', + route: profile.socks_proxy_id ? (proxy ? 'configured' : (socksLoaded ? 'unavailable' : 'loading')) : profile.jump_host_id ? (jump ? 'configured' : (jumpsLoaded ? 'unavailable' : 'loading')) : 'none', jump: jump || null, + ...(profile.socks_proxy_id ? {routeKind: 'socks', socks: proxy || null} : {}), }; }); hosts.sort((a, b) => a.group.localeCompare(b.group) || a.order - b.order || a.name.localeCompare(b.name)); @@ -147,7 +150,7 @@ function orderedModel(profiles, jumps, loaded) { displayGroupOrder = currentGroupOrder(); displayGroupRanks = new Map(displayGroupOrder.map((group, index) => [group, index])); - return buildAccessModel(profiles, jumps, loaded).sort((a, b) => displayGroupRanks.get(a.group) - displayGroupRanks.get(b.group)); + return buildAccessModel(profiles, jumps, loaded, win.SocksProxyManager?.socksProxies || [], win.SocksProxyManager?.loaded).sort((a, b) => displayGroupRanks.get(a.group) - displayGroupRanks.get(b.group)); } function saveGroupOrder(next) { if (!win.ProfileManager?.profilesLoaded) return; @@ -349,7 +352,7 @@ const row = node('li', 'overview-host-entry'); if (showUnlinked) { const key = host.route === 'loading' ? 'overview.loading' - : host.route === 'unavailable' ? 'overview.missingJump' : 'overview.noJump'; + : host.route === 'unavailable' ? (host.routeKind === 'socks' ? 'connection.socksProxyMissing' : 'overview.missingJump') : 'overview.noJump'; row.append(node('span', 'overview-route-unlinked', t(key))); } row.append(hostButton(host), moveControl(host)); @@ -397,17 +400,19 @@ const branches = new Map(); const unlinked = []; hosts.forEach(host => { - if (!host.jump) { unlinked.push(host); return; } - if (!branches.has(host.jump.id)) branches.set(host.jump.id, {jump: host.jump, hosts: []}); - branches.get(host.jump.id).hosts.push(host); + const route = host.socks || host.jump; + if (!route) { unlinked.push(host); return; } + const key = (host.routeKind || 'jump') + ':' + route.id; + if (!branches.has(key)) branches.set(key, {jump: route, socks: host.routeKind === 'socks', hosts: []}); + branches.get(key).hosts.push(host); }); container.dataset.routeBranches = String(branches.size); - branches.forEach(({jump, hosts: targets}) => { + branches.forEach(({jump, socks, hosts: targets}) => { const branch = node('section', 'overview-graph-branch'); branch.dataset.jumpId = jump.id; branch.setAttribute('aria-label', `${t('overview.configuredRoute')}: ${jump.name}`); const source = node('div', 'overview-jump'); - source.append(icon('hub'), node('span', '', jump.name || t('connectionAssets.jumpHosts'))); + source.append(icon(socks ? 'lan' : 'hub'), node('span', '', jump.name || t(socks ? 'socksproxies.title' : 'connectionAssets.jumpHosts'))); const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.classList.add('overview-edges'); svg.setAttribute('aria-hidden', 'true'); @@ -662,7 +667,7 @@ byId('overviewListBtn').addEventListener('click', () => setGraph(false)); byId('overviewGraphBtn').addEventListener('click', () => setGraph(true)); byId('overviewExpandRoutes').addEventListener('click', () => setGraph(true)); - ['profile-list-change', 'jump-host-list-change', 'session-workspace-change', + ['profile-list-change', 'jump-host-list-change', 'socks-proxy-list-change', 'session-workspace-change', 'primary-workspace-change', 'languageChanged'].forEach(event => win.addEventListener(event, schedule)); ['profile-list-change', 'languageChanged', 'primary-workspace-change', 'session-workspace-change'].forEach(event => win.addEventListener(event, renderSidebar)); win.addEventListener('primary-workspace-change', updateNavigation); diff --git a/static/js/connection-overview.js b/static/js/connection-overview.js index bcd33617..29121a3a 100644 --- a/static/js/connection-overview.js +++ b/static/js/connection-overview.js @@ -8,9 +8,10 @@ // Deliberately whitelist display fields. Addresses, users, credentials and // arbitrary graph relationships never enter this presentation model. - function buildAccessModel(profiles = [], jumpHosts = [], jumpsLoaded = false) { + function buildAccessModel(profiles = [], jumpHosts = [], jumpsLoaded = false, socksProxies = [], socksLoaded = false) { const jumps = new Map(jumpHosts.filter(jump => jump && typeof jump.id === 'string') .map(jump => [jump.id, {id: jump.id, name: String(jump.name || '')}])); + const socks = new Map(socksProxies.filter(proxy => proxy && typeof proxy.id === 'string').map(proxy => [proxy.id, {id: proxy.id, name: String(proxy.name || '')}])); const seen = new Set(); const hosts = profiles.filter(profile => { if (!profile || typeof profile.id !== 'string' || seen.has(profile.id)) return false; @@ -19,6 +20,7 @@ }).map(profile => { const jump = jumpsLoaded && profile.jump_host_id ? jumps.get(profile.jump_host_id) : null; + const proxy = socksLoaded && profile.socks_proxy_id ? socks.get(profile.socks_proxy_id) : null; return { id: profile.id, name: String(profile.name || ''), @@ -26,8 +28,9 @@ favorite: profile.favorite === true, platform: globalThis.HostPlatform?.resolve(profile) || {id: 'server', source: 'fallback'}, order: Number(profile.sort_order) || 0, - routeRef: typeof profile.jump_host_id === 'string' ? profile.jump_host_id : '', - route: profile.jump_host_id ? (jump ? 'configured' : (jumpsLoaded ? 'unavailable' : 'loading')) : 'none', + routeRef: typeof profile.socks_proxy_id === 'string' ? profile.socks_proxy_id : typeof profile.jump_host_id === 'string' ? profile.jump_host_id : '', + ...(profile.socks_proxy_id ? {routeKind: 'socks', socks: proxy || null} : {}), + route: profile.socks_proxy_id ? (proxy ? 'configured' : (socksLoaded ? 'unavailable' : 'loading')) : profile.jump_host_id ? (jump ? 'configured' : (jumpsLoaded ? 'unavailable' : 'loading')) : 'none', jump: jump || null, }; }); diff --git a/static/js/credential-picker.js b/static/js/credential-picker.js index 062d0900..ccd618de 100644 --- a/static/js/credential-picker.js +++ b/static/js/credential-picker.js @@ -82,7 +82,7 @@ method.addEventListener('change', () => { binding.clear(); sync(); if (method.value === 'saved-password') load(); }); select.addEventListener('change', () => { binding.generation++; binding.id = null; binding.token = null; }); const form = input.closest('form'); - const targetFields = new Set(['hostInput', 'portInput', 'usernameInput', 'jumpHostSelect']); + const targetFields = new Set(['hostInput', 'portInput', 'usernameInput', 'jumpHostSelect', 'socksProxySelect']); const invalidate = event => { if (!targetFields.has(event.target.id)) return; const nextTarget = JSON.stringify(target()); @@ -161,7 +161,9 @@ const value = id => document.getElementById(id)?.value || ''; function install() { attach('passwordInput', () => window.ProfileManager?.selectedProfileId || null, - () => ({host: value('hostInput'), port: Number(value('portInput')) || 22, username: value('usernameInput'), auth_type: value('authTypeSelect') === 'saved-password' ? 'password' : value('authTypeSelect') || 'password', jump_host_id: value('jumpHostSelect') || null}), 'connectionModal'); + () => ({host: value('hostInput'), port: Number(value('portInput')) || 22, username: value('usernameInput'), auth_type: value('authTypeSelect') === 'saved-password' ? 'password' : value('authTypeSelect') || 'password', jump_host_id: value('jumpHostSelect') || null, socks_proxy_id: value('socksProxySelect') || null}), 'connectionModal'); + attach('socksProxyPasswordInput', () => value('socksProxySelect') ? `socks:${value('socksProxySelect')}` : null, + () => { const proxy = window.SocksProxyManager?.getById(value('socksProxySelect')); return {host: proxy?.host, port: proxy?.port, username: proxy?.username, auth_type: proxy?.auth_type, remote_dns: proxy?.remote_dns}; }, 'connectionModal'); attach('jumpHostPasswordInput', () => value('jumpHostSelect') ? `jump:${value('jumpHostSelect')}` : null, () => { const jump = window.JumpHostManager?.getById(value('jumpHostSelect')); return {host: jump?.host, port: jump?.port, username: jump?.username}; }, 'connectionModal'); attach('fmQcPassword', () => value('fmQcProfile') || null, diff --git a/static/js/host-context.js b/static/js/host-context.js index 6c232d6a..f6b61ce3 100644 --- a/static/js/host-context.js +++ b/static/js/host-context.js @@ -13,12 +13,13 @@ * the same destination, user and route; with several, the latest edited. */ function profileFor(session, profiles = []) { - if (!session) return null; + if (!session || (session.viaSocks && !session.socksProxyId)) return null; if (session.profileId) return session.profileId; const host = value => String(value || '').trim().replace(/\.+$/, '').toLowerCase(); const matches = profiles.filter(profile => profile?.id && host(profile.host) === host(session.host) && Number(profile.port || 22) === Number(session.port || 22) && profile.username === session.username + && (profile.socks_proxy_id || null) === (session.socksProxyId || null) && (profile.jump_host_id || null) === (session.jumpHostId || null)); matches.sort((a, b) => String(b.updated_at || '').localeCompare(String(a.updated_at || ''))); return matches[0]?.id || null; diff --git a/static/js/host-platform.js b/static/js/host-platform.js index 35ad66a2..88c1346d 100644 --- a/static/js/host-platform.js +++ b/static/js/host-platform.js @@ -78,6 +78,7 @@ function renderSession(session = {}, profile, doc = document) { const sameEndpoint = profile && canonicalHost(profile.host) === canonicalHost(session.host) && profile.username === session.username && Number(profile.port || 22) === Number(session.port || 22) + && (profile.socks_proxy_id || null) === (session.socksProxyId || null) && (profile.jump_host_id || null) === (session.jumpHostId || null); return render({name: profile?.name || '', platform_icon: profile?.platform_icon, detected_platform: session.detectedPlatform || (sameEndpoint ? profile?.detected_platform : undefined), diff --git a/static/js/host-resource-layout.js b/static/js/host-resource-layout.js index 128f8bc8..d4d826b6 100644 --- a/static/js/host-resource-layout.js +++ b/static/js/host-resource-layout.js @@ -34,14 +34,20 @@ root.showResourceEditor = show; const hide = () => { root.classList.remove('has-resource-editor'); add.focus(); }; add.addEventListener('click', () => { - if (kind === 'jump') { + if (kind === 'socks') { + if (!window.SocksProxyManager.canDiscard()) return; + window.SocksProxyManager.resetEditor(); + } else if (kind === 'jump') { if (!window.JumpHostManager.canDiscard()) return; window.JumpHostManager.resetEditor(); } show(); }); close.addEventListener('click', () => { - if (kind === 'jump') { + if (kind === 'socks') { + if (!window.SocksProxyManager.canDiscard()) return; + window.SocksProxyManager.resetEditor(); + } else if (kind === 'jump') { if (!window.JumpHostManager.canDiscard()) return; window.JumpHostManager.resetEditor(); } else { @@ -67,6 +73,7 @@ } document.addEventListener('DOMContentLoaded', () => { install('jumpHostManagementModal', 'jump', 'jumpHostForm', 'jumpHostsList', 'jumphosts.addNew', 'Add Jump Host'); + install('socksProxyManagementModal', 'socks', 'socksProxyForm', 'socksProxiesList', 'socksproxies.addNew', 'Add SOCKS5 Proxy'); install('keyManagementModal', 'key', 'keyUploadForm', 'keysList', 'keys.uploadNewKey', 'Upload New Key'); }); })(); diff --git a/static/js/i18n.js b/static/js/i18n.js index 5ff7cf05..d423cdec 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -1,6 +1,65 @@ const translations = { en: { + "connection.socksProxyTopologySummary": "{hosts} hosts, {direct} direct, {jumps} jump hosts, {socks} SOCKS5 proxies, {live} with an open session", + "socksproxies.title": "SOCKS5 Proxies", + "socksproxies.manage": "Manage SOCKS5 Proxies", + "socksproxies.addNew": "Add SOCKS5 Proxy", + "socksproxies.add": "Add SOCKS5 Proxy", + "socksproxies.edit": "Edit SOCKS5 Proxy", + "socksproxies.name": "Name", + "socksproxies.namePlaceholder": "Office proxy", + "socksproxies.noAuthentication": "No authentication", + "socksproxies.passwordAuthentication": "Username and password", + "socksproxies.remoteDns": "Resolve host names through the proxy", + "socksproxies.remoteDnsHint": "Recommended. Names that only exist in the proxy's network keep working. When WebSSH blocks internal targets, only names your administrator allows are sent to the proxy.", + "socksproxies.passwordHint": "Enter the proxy password when connecting, or explicitly save a reusable password.", + "socksproxies.passwordWarning": "SOCKS5 sends the proxy password unencrypted. Use password login only on networks you trust. Your SSH session itself stays encrypted.", + "socksproxies.login": "Login", + "socksproxies.noLogin": "No login", + "socksproxies.remoteDnsBadge": "Remote DNS", + "socksproxies.none": "No SOCKS5 proxies saved", + "socksproxies.emptyHint": "Add a SOCKS5 proxy, then choose it in a saved host.", + "socksproxies.saved": "Saved SOCKS5 Proxies", + "socksproxies.savedOk": "SOCKS5 proxy saved", + "socksproxies.deleted": "SOCKS5 proxy deleted", + "socksproxies.confirmDelete": "Delete this SOCKS5 proxy?", + "socksproxies.inUse": "This SOCKS5 proxy is used by {count} host(s)", + "socksproxies.saveUnknownHint": "Save result unknown. Check the saved list before trying again. Your draft is preserved.", + "socksproxies.saveFailed": "Could not save the SOCKS5 proxy.", + "socksproxies.loading": "Loading SOCKS5 proxies…", + "connection.route": "Route", + "connection.routeClearJump": "A host uses one route: clear the jump host to choose a SOCKS5 proxy.", + "connection.routeClearSocks": "A host uses one route: clear the SOCKS5 proxy to choose a jump host.", + "connection.routeTailscale": "Tailscale SSH connects directly.", + "connection.routeConflict": "Choose one compatible route.", + "connection.socksProxy": "SOCKS5 Proxy", + "connection.socksProxyNone": "None", + "connection.socksProxyManage": "Manage SOCKS5 proxies in the connection resources.", + "connection.socksProxyPassword": "SOCKS5 Proxy Password", + "connection.socksProxyPasswordHint": "This proxy uses password login. Enter its password; it is not stored unless you save it.", + "connection.socksProxyPasswordRequired": "SOCKS5 proxy password is required", + "connection.socksProxyMissing": "SOCKS5 proxy missing", + "connection.socksProxyVia": "via SOCKS5 {name}", + "connection.socksProxyQuickSftp": "Connect a terminal first to use SFTP through SOCKS5.", + "connection.socksProxyProbeHint": "Only the SOCKS5 proxy endpoint was checked, not the target.", + "connection.socksProxyAutomation": "SOCKS5 routes are not supported for automation yet", + "connection.socksProxyNotExported": "SOCKS5 route not exported", + "sshErrors.socksReview": "Review SOCKS5 proxy", + "sshErrors.socksProxyFailure": "The SOCKS5 proxy reported a general failure.", + "sshErrors.socksNotAllowed": "The SOCKS5 proxy does not allow this connection.", + "sshErrors.socksNetworkUnreachable": "The SOCKS5 proxy cannot reach the target network.", + "sshErrors.socksHostUnreachable": "The SOCKS5 proxy cannot reach the target host.", + "sshErrors.socksConnectionRefused": "The target refused the connection through the SOCKS5 proxy.", + "sshErrors.socksTtlExpired": "The connection through the SOCKS5 proxy timed out.", + "sshErrors.socksUnsupported": "The SOCKS5 proxy does not support this request.", + "sshErrors.socksProtocolError": "The SOCKS5 proxy sent an invalid response.", + "sshErrors.socksProxyUnreachable": "The SOCKS5 proxy could not be reached.", + "sshErrors.socksTimeout": "The SOCKS5 proxy did not respond in time.", + "sshErrors.socksAuthRequired": "The SOCKS5 proxy requires a username and password.", + "sshErrors.socksAuthRejected": "The SOCKS5 proxy does not accept username and password login.", + "sshErrors.socksAuthFailed": "SOCKS5 proxy login failed. Check the username and password.", + 'overview.ui.inventory': "Inventory", "tour.back": "Back", @@ -1198,7 +1257,7 @@ const translations = { 'connection.jumpHostPassword': 'Jump Host Password', 'connection.jumpHostPasswordHint': 'This bastion uses password auth — enter its password (never stored).', 'connection.advancedSettings': 'Advanced Settings', - 'connection.advancedSettingsHint': 'Jump host, post-connect actions, and persistence.', + 'connection.advancedSettingsHint': "Route, post-connect actions, and persistence.", 'connection.commandSet': 'Commands after connecting (optional)', 'connection.commandSetHint': 'Runs on the remote host after a successful connection, not in WebSSH. Not run again when reconnecting to an existing tmux session.', 'commandSets.manage': 'Command Sets', @@ -2438,6 +2497,65 @@ const translations = { vi: { + "connection.socksProxyTopologySummary": "{hosts} máy chủ, {direct} trực tiếp, {jumps} máy chủ trung chuyển, {socks} proxy SOCKS5, {live} có phiên đang mở", + "socksproxies.title": "Proxy SOCKS5", + "socksproxies.manage": "Quản lý proxy SOCKS5", + "socksproxies.addNew": "Thêm proxy SOCKS5", + "socksproxies.add": "Thêm proxy SOCKS5", + "socksproxies.edit": "Chỉnh sửa proxy SOCKS5", + "socksproxies.name": "Tên", + "socksproxies.namePlaceholder": "Proxy văn phòng", + "socksproxies.noAuthentication": "Không xác thực", + "socksproxies.passwordAuthentication": "Tên người dùng và mật khẩu", + "socksproxies.remoteDns": "Phân giải tên máy chủ qua proxy", + "socksproxies.remoteDnsHint": "Khuyến nghị. Các tên chỉ tồn tại trong mạng của proxy vẫn hoạt động. Khi WebSSH chặn đích nội bộ, chỉ các tên được quản trị viên cho phép mới được gửi tới proxy.", + "socksproxies.passwordHint": "Nhập mật khẩu proxy khi kết nối hoặc chủ động lưu mật khẩu để dùng lại.", + "socksproxies.passwordWarning": "SOCKS5 gửi mật khẩu proxy không mã hóa. Chỉ dùng đăng nhập bằng mật khẩu trên mạng đáng tin cậy. Phiên SSH vẫn được mã hóa.", + "socksproxies.login": "Có đăng nhập", + "socksproxies.noLogin": "Không đăng nhập", + "socksproxies.remoteDnsBadge": "DNS qua proxy", + "socksproxies.none": "Chưa lưu proxy SOCKS5 nào", + "socksproxies.emptyHint": "Thêm proxy SOCKS5 rồi chọn proxy đó cho máy chủ đã lưu.", + "socksproxies.saved": "Proxy SOCKS5 đã lưu", + "socksproxies.savedOk": "Đã lưu proxy SOCKS5", + "socksproxies.deleted": "Đã xóa proxy SOCKS5", + "socksproxies.confirmDelete": "Xóa proxy SOCKS5 này?", + "socksproxies.inUse": "Proxy SOCKS5 này đang được {count} máy chủ sử dụng", + "socksproxies.saveUnknownHint": "Chưa rõ kết quả lưu. Kiểm tra danh sách đã lưu trước khi thử lại. Bản nháp vẫn được giữ.", + "socksproxies.saveFailed": "Không thể lưu proxy SOCKS5.", + "socksproxies.loading": "Đang tải proxy SOCKS5…", + "connection.route": "Tuyến kết nối", + "connection.routeClearJump": "Máy chủ chỉ dùng một tuyến: bỏ máy chủ trung chuyển để chọn proxy SOCKS5.", + "connection.routeClearSocks": "Máy chủ chỉ dùng một tuyến: bỏ proxy SOCKS5 để chọn máy chủ trung chuyển.", + "connection.routeTailscale": "Tailscale SSH kết nối trực tiếp.", + "connection.routeConflict": "Chọn một tuyến kết nối tương thích.", + "connection.socksProxy": "Proxy SOCKS5", + "connection.socksProxyNone": "Không có", + "connection.socksProxyManage": "Quản lý proxy SOCKS5 trong tài nguyên kết nối.", + "connection.socksProxyPassword": "Mật khẩu proxy SOCKS5", + "connection.socksProxyPasswordHint": "Proxy này dùng mật khẩu để đăng nhập. Hãy nhập mật khẩu; mật khẩu chỉ được lưu khi bạn yêu cầu.", + "connection.socksProxyPasswordRequired": "Cần nhập mật khẩu proxy SOCKS5", + "connection.socksProxyMissing": "Thiếu proxy SOCKS5", + "connection.socksProxyVia": "qua SOCKS5 {name}", + "connection.socksProxyQuickSftp": "Kết nối một phiên terminal trước để dùng SFTP qua SOCKS5.", + "connection.socksProxyProbeHint": "Chỉ kiểm tra điểm kết nối của proxy SOCKS5, không kiểm tra máy đích.", + "connection.socksProxyAutomation": "Tự động hóa chưa hỗ trợ tuyến SOCKS5", + "connection.socksProxyNotExported": "Tuyến SOCKS5 không được xuất", + "sshErrors.socksReview": "Kiểm tra proxy SOCKS5", + "sshErrors.socksProxyFailure": "Proxy SOCKS5 báo lỗi chung.", + "sshErrors.socksNotAllowed": "Proxy SOCKS5 không cho phép kết nối này.", + "sshErrors.socksNetworkUnreachable": "Proxy SOCKS5 không thể truy cập mạng đích.", + "sshErrors.socksHostUnreachable": "Proxy SOCKS5 không thể truy cập máy chủ đích.", + "sshErrors.socksConnectionRefused": "Máy đích từ chối kết nối qua proxy SOCKS5.", + "sshErrors.socksTtlExpired": "Kết nối qua proxy SOCKS5 đã hết thời gian chờ.", + "sshErrors.socksUnsupported": "Proxy SOCKS5 không hỗ trợ yêu cầu này.", + "sshErrors.socksProtocolError": "Proxy SOCKS5 gửi phản hồi không hợp lệ.", + "sshErrors.socksProxyUnreachable": "Không thể truy cập proxy SOCKS5.", + "sshErrors.socksTimeout": "Proxy SOCKS5 không phản hồi kịp thời.", + "sshErrors.socksAuthRequired": "Proxy SOCKS5 yêu cầu tên người dùng và mật khẩu.", + "sshErrors.socksAuthRejected": "Proxy SOCKS5 không chấp nhận đăng nhập bằng tên người dùng và mật khẩu.", + "sshErrors.socksAuthFailed": "Đăng nhập proxy SOCKS5 thất bại. Kiểm tra tên người dùng và mật khẩu.", + 'overview.ui.inventory': "Kho tài nguyên", "tour.back": "Quay lại", @@ -3630,7 +3748,7 @@ const translations = { 'connection.jumpHostPassword': 'Mật khẩu máy chủ trung chuyển', 'connection.jumpHostPasswordHint': 'Bastion này sử dụng xác thực bằng mật khẩu — nhập mật khẩu (không được lưu lại).', 'connection.advancedSettings': 'Cài đặt nâng cao', - 'connection.advancedSettingsHint': 'Máy chủ trung chuyển, tác vụ sau kết nối và phiên liên tục.', + 'connection.advancedSettingsHint': "Tuyến kết nối, thao tác sau kết nối và duy trì phiên.", 'connection.commandSet': 'Lệnh sau khi kết nối (tùy chọn)', 'connection.commandSetHint': 'Chạy trên máy chủ từ xa sau khi kết nối thành công, không chạy trong WebSSH. Không chạy lại khi kết nối lại với một phiên tmux hiện có.', 'commandSets.manage': 'Bộ lệnh', @@ -4869,6 +4987,65 @@ const translations = { }, de: { + "connection.socksProxyTopologySummary": "{hosts} Hosts, {direct} direkt, {jumps} Jump Hosts, {socks} SOCKS5-Proxys, {live} mit offener Sitzung", + "socksproxies.title": "SOCKS5-Proxys", + "socksproxies.manage": "SOCKS5-Proxys verwalten", + "socksproxies.addNew": "SOCKS5-Proxy hinzufügen", + "socksproxies.add": "SOCKS5-Proxy hinzufügen", + "socksproxies.edit": "SOCKS5-Proxy bearbeiten", + "socksproxies.name": "Name", + "socksproxies.namePlaceholder": "Büro-Proxy", + "socksproxies.noAuthentication": "Keine Authentifizierung", + "socksproxies.passwordAuthentication": "Benutzername und Passwort", + "socksproxies.remoteDns": "Hostnamen über den Proxy auflösen", + "socksproxies.remoteDnsHint": "Empfohlen. Namen, die nur im Netzwerk des Proxys existieren, funktionieren weiterhin. Wenn WebSSH interne Ziele blockiert, werden nur vom Administrator erlaubte Namen an den Proxy gesendet.", + "socksproxies.passwordHint": "Gib das Proxy-Passwort beim Verbinden ein oder speichere ausdrücklich ein wiederverwendbares Passwort.", + "socksproxies.passwordWarning": "SOCKS5 überträgt das Proxy-Passwort unverschlüsselt. Verwende die Passwort-Anmeldung nur in vertrauenswürdigen Netzwerken. Die SSH-Sitzung selbst bleibt verschlüsselt.", + "socksproxies.login": "Mit Anmeldung", + "socksproxies.noLogin": "Ohne Anmeldung", + "socksproxies.remoteDnsBadge": "DNS über Proxy", + "socksproxies.none": "Keine SOCKS5-Proxys gespeichert", + "socksproxies.emptyHint": "Füge einen SOCKS5-Proxy hinzu und wähle ihn in einem gespeicherten Host aus.", + "socksproxies.saved": "Gespeicherte SOCKS5-Proxys", + "socksproxies.savedOk": "SOCKS5-Proxy gespeichert", + "socksproxies.deleted": "SOCKS5-Proxy gelöscht", + "socksproxies.confirmDelete": "Diesen SOCKS5-Proxy löschen?", + "socksproxies.inUse": "Dieser SOCKS5-Proxy wird von {count} Host(s) verwendet", + "socksproxies.saveUnknownHint": "Speicherergebnis unbekannt. Prüfe vor einem weiteren Versuch die gespeicherte Liste. Dein Entwurf bleibt erhalten.", + "socksproxies.saveFailed": "SOCKS5-Proxy konnte nicht gespeichert werden.", + "socksproxies.loading": "SOCKS5-Proxys werden geladen…", + "connection.route": "Route", + "connection.routeClearJump": "Ein Host verwendet eine Route: Entferne den Jump Host, um einen SOCKS5-Proxy zu wählen.", + "connection.routeClearSocks": "Ein Host verwendet eine Route: Entferne den SOCKS5-Proxy, um einen Jump Host zu wählen.", + "connection.routeTailscale": "Tailscale SSH verbindet sich direkt.", + "connection.routeConflict": "Wähle eine kompatible Route.", + "connection.socksProxy": "SOCKS5-Proxy", + "connection.socksProxyNone": "Keiner", + "connection.socksProxyManage": "SOCKS5-Proxys in den Verbindungsressourcen verwalten.", + "connection.socksProxyPassword": "SOCKS5-Proxy-Passwort", + "connection.socksProxyPasswordHint": "Dieser Proxy verwendet eine Passwort-Anmeldung. Gib sein Passwort ein; es wird nur auf ausdrücklichen Wunsch gespeichert.", + "connection.socksProxyPasswordRequired": "SOCKS5-Proxy-Passwort erforderlich", + "connection.socksProxyMissing": "SOCKS5-Proxy fehlt", + "connection.socksProxyVia": "über SOCKS5 {name}", + "connection.socksProxyQuickSftp": "Öffne zuerst eine Terminalverbindung, um SFTP über SOCKS5 zu nutzen.", + "connection.socksProxyProbeHint": "Geprüft wurde nur der SOCKS5-Proxy-Endpunkt, nicht das Ziel.", + "connection.socksProxyAutomation": "SOCKS5-Routen werden für Automatisierung noch nicht unterstützt", + "connection.socksProxyNotExported": "SOCKS5-Route nicht exportiert", + "sshErrors.socksReview": "SOCKS5-Proxy prüfen", + "sshErrors.socksProxyFailure": "Der SOCKS5-Proxy meldet einen allgemeinen Fehler.", + "sshErrors.socksNotAllowed": "Der SOCKS5-Proxy erlaubt diese Verbindung nicht.", + "sshErrors.socksNetworkUnreachable": "Der SOCKS5-Proxy erreicht das Zielnetzwerk nicht.", + "sshErrors.socksHostUnreachable": "Der SOCKS5-Proxy erreicht den Zielhost nicht.", + "sshErrors.socksConnectionRefused": "Das Ziel hat die Verbindung über den SOCKS5-Proxy abgelehnt.", + "sshErrors.socksTtlExpired": "Zeitüberschreitung bei der Verbindung über den SOCKS5-Proxy.", + "sshErrors.socksUnsupported": "Der SOCKS5-Proxy unterstützt diese Anfrage nicht.", + "sshErrors.socksProtocolError": "Der SOCKS5-Proxy hat eine ungültige Antwort gesendet.", + "sshErrors.socksProxyUnreachable": "Der SOCKS5-Proxy ist nicht erreichbar.", + "sshErrors.socksTimeout": "Der SOCKS5-Proxy hat nicht rechtzeitig geantwortet.", + "sshErrors.socksAuthRequired": "Der SOCKS5-Proxy benötigt Benutzername und Passwort.", + "sshErrors.socksAuthRejected": "Der SOCKS5-Proxy akzeptiert keine Anmeldung mit Benutzername und Passwort.", + "sshErrors.socksAuthFailed": "SOCKS5-Proxy-Anmeldung fehlgeschlagen. Prüfe Benutzername und Passwort.", + 'overview.ui.inventory': "Inventar", "tour.back": "Zurück", @@ -6066,7 +6243,7 @@ const translations = { 'connection.jumpHostPassword': 'Jump-Host-Passwort', 'connection.jumpHostPasswordHint': 'Diese Bastion nutzt Passwort-Auth — Passwort eingeben (wird nicht gespeichert).', 'connection.advancedSettings': 'Erweiterte Einstellungen', - 'connection.advancedSettingsHint': 'Jump Host, Aktionen nach dem Verbinden und persistente Sitzung.', + 'connection.advancedSettingsHint': "Route, Aktionen nach dem Verbinden und Persistenz.", 'connection.commandSet': 'Befehle nach dem Verbinden (optional)', 'connection.commandSetHint': 'Wird nach erfolgreicher Verbindung auf dem Remote-Host ausgeführt, nicht in WebSSH. Bei der Wiederverbindung mit einer bestehenden tmux-Sitzung nicht erneut ausgeführt.', 'commandSets.manage': 'Befehlssätze', @@ -7304,6 +7481,65 @@ const translations = { }, fr: { + "connection.socksProxyTopologySummary": "{hosts} hôtes, {direct} directs, {jumps} hôtes de rebond, {socks} proxys SOCKS5, {live} avec une session ouverte", + "socksproxies.title": "Proxys SOCKS5", + "socksproxies.manage": "Gérer les proxys SOCKS5", + "socksproxies.addNew": "Ajouter un proxy SOCKS5", + "socksproxies.add": "Ajouter un proxy SOCKS5", + "socksproxies.edit": "Modifier le proxy SOCKS5", + "socksproxies.name": "Nom", + "socksproxies.namePlaceholder": "Proxy du bureau", + "socksproxies.noAuthentication": "Sans authentification", + "socksproxies.passwordAuthentication": "Nom d’utilisateur et mot de passe", + "socksproxies.remoteDns": "Résoudre les noms d’hôte via le proxy", + "socksproxies.remoteDnsHint": "Recommandé. Les noms propres au réseau du proxy restent accessibles. Si WebSSH bloque les cibles internes, seuls les noms autorisés par votre administrateur sont transmis au proxy.", + "socksproxies.passwordHint": "Saisissez le mot de passe du proxy à la connexion ou enregistrez explicitement un mot de passe réutilisable.", + "socksproxies.passwordWarning": "SOCKS5 transmet le mot de passe du proxy sans chiffrement. N’utilisez cette connexion que sur des réseaux de confiance. La session SSH reste chiffrée.", + "socksproxies.login": "Avec connexion", + "socksproxies.noLogin": "Sans connexion", + "socksproxies.remoteDnsBadge": "DNS via proxy", + "socksproxies.none": "Aucun proxy SOCKS5 enregistré", + "socksproxies.emptyHint": "Ajoutez un proxy SOCKS5, puis sélectionnez-le pour un hôte enregistré.", + "socksproxies.saved": "Proxys SOCKS5 enregistrés", + "socksproxies.savedOk": "Proxy SOCKS5 enregistré", + "socksproxies.deleted": "Proxy SOCKS5 supprimé", + "socksproxies.confirmDelete": "Supprimer ce proxy SOCKS5 ?", + "socksproxies.inUse": "Ce proxy SOCKS5 est utilisé par {count} hôte(s)", + "socksproxies.saveUnknownHint": "Résultat inconnu. Vérifiez la liste avant de réessayer. Votre brouillon est conservé.", + "socksproxies.saveFailed": "Impossible d’enregistrer le proxy SOCKS5.", + "socksproxies.loading": "Chargement des proxys SOCKS5…", + "connection.route": "Route", + "connection.routeClearJump": "Un hôte utilise une seule route : retirez l’hôte de rebond pour choisir un proxy SOCKS5.", + "connection.routeClearSocks": "Un hôte utilise une seule route : retirez le proxy SOCKS5 pour choisir un hôte de rebond.", + "connection.routeTailscale": "Tailscale SSH se connecte directement.", + "connection.routeConflict": "Choisissez une seule route compatible.", + "connection.socksProxy": "Proxy SOCKS5", + "connection.socksProxyNone": "Aucun", + "connection.socksProxyManage": "Gérer les proxys SOCKS5 dans les ressources de connexion.", + "connection.socksProxyPassword": "Mot de passe du proxy SOCKS5", + "connection.socksProxyPasswordHint": "Ce proxy utilise un mot de passe. Saisissez-le ; il n’est conservé que si vous l’enregistrez.", + "connection.socksProxyPasswordRequired": "Le mot de passe du proxy SOCKS5 est requis", + "connection.socksProxyMissing": "Proxy SOCKS5 manquant", + "connection.socksProxyVia": "via SOCKS5 {name}", + "connection.socksProxyQuickSftp": "Connectez d’abord un terminal pour utiliser SFTP via SOCKS5.", + "connection.socksProxyProbeHint": "Seul le point de connexion du proxy SOCKS5 a été vérifié, pas la cible.", + "connection.socksProxyAutomation": "Les routes SOCKS5 ne sont pas encore prises en charge pour l’automatisation", + "connection.socksProxyNotExported": "Route SOCKS5 non exportée", + "sshErrors.socksReview": "Vérifier le proxy SOCKS5", + "sshErrors.socksProxyFailure": "Le proxy SOCKS5 a signalé une erreur générale.", + "sshErrors.socksNotAllowed": "Le proxy SOCKS5 n’autorise pas cette connexion.", + "sshErrors.socksNetworkUnreachable": "Le proxy SOCKS5 ne peut pas joindre le réseau cible.", + "sshErrors.socksHostUnreachable": "Le proxy SOCKS5 ne peut pas joindre l’hôte cible.", + "sshErrors.socksConnectionRefused": "La cible a refusé la connexion via le proxy SOCKS5.", + "sshErrors.socksTtlExpired": "La connexion via le proxy SOCKS5 a expiré.", + "sshErrors.socksUnsupported": "Le proxy SOCKS5 ne prend pas en charge cette requête.", + "sshErrors.socksProtocolError": "Le proxy SOCKS5 a envoyé une réponse invalide.", + "sshErrors.socksProxyUnreachable": "Le proxy SOCKS5 est injoignable.", + "sshErrors.socksTimeout": "Le proxy SOCKS5 n’a pas répondu à temps.", + "sshErrors.socksAuthRequired": "Le proxy SOCKS5 exige un nom d’utilisateur et un mot de passe.", + "sshErrors.socksAuthRejected": "Le proxy SOCKS5 n’accepte pas la connexion par nom d’utilisateur et mot de passe.", + "sshErrors.socksAuthFailed": "Échec de la connexion au proxy SOCKS5. Vérifiez le nom d’utilisateur et le mot de passe.", + 'overview.ui.inventory': "Inventaire", "tour.back": "Retour", @@ -8497,7 +8733,7 @@ const translations = { 'connection.jumpHostPassword': 'Mot de passe du rebond', 'connection.jumpHostPasswordHint': "Ce bastion utilise un mot de passe — saisissez-le (jamais enregistré).", 'connection.advancedSettings': 'Paramètres avancés', - 'connection.advancedSettingsHint': 'Hôte de rebond, actions après connexion et session persistante.', + 'connection.advancedSettingsHint': "Route, actions après connexion et persistance.", 'connection.commandSet': 'Commandes après la connexion (facultatif)', 'connection.commandSetHint': "Exécutées sur l'hôte distant après une connexion réussie, et non dans WebSSH. Elles ne sont pas réexécutées lors de la reconnexion à une session tmux existante.", 'commandSets.manage': 'Ensembles de commandes', @@ -9735,6 +9971,65 @@ const translations = { }, es: { + "connection.socksProxyTopologySummary": "{hosts} hosts, {direct} directos, {jumps} hosts de salto, {socks} proxies SOCKS5, {live} con una sesión abierta", + "socksproxies.title": "Proxies SOCKS5", + "socksproxies.manage": "Administrar proxies SOCKS5", + "socksproxies.addNew": "Añadir proxy SOCKS5", + "socksproxies.add": "Añadir proxy SOCKS5", + "socksproxies.edit": "Editar proxy SOCKS5", + "socksproxies.name": "Nombre", + "socksproxies.namePlaceholder": "Proxy de oficina", + "socksproxies.noAuthentication": "Sin autenticación", + "socksproxies.passwordAuthentication": "Usuario y contraseña", + "socksproxies.remoteDns": "Resolver nombres de host mediante el proxy", + "socksproxies.remoteDnsHint": "Recomendado. Los nombres que solo existen en la red del proxy siguen funcionando. Si WebSSH bloquea destinos internos, solo se envían al proxy los nombres permitidos por el administrador.", + "socksproxies.passwordHint": "Introduce la contraseña del proxy al conectar o guarda explícitamente una contraseña reutilizable.", + "socksproxies.passwordWarning": "SOCKS5 envía la contraseña del proxy sin cifrar. Usa este inicio de sesión solo en redes de confianza. La sesión SSH permanece cifrada.", + "socksproxies.login": "Con inicio de sesión", + "socksproxies.noLogin": "Sin inicio de sesión", + "socksproxies.remoteDnsBadge": "DNS remoto", + "socksproxies.none": "No hay proxies SOCKS5 guardados", + "socksproxies.emptyHint": "Añade un proxy SOCKS5 y selecciónalo en un host guardado.", + "socksproxies.saved": "Proxies SOCKS5 guardados", + "socksproxies.savedOk": "Proxy SOCKS5 guardado", + "socksproxies.deleted": "Proxy SOCKS5 eliminado", + "socksproxies.confirmDelete": "¿Eliminar este proxy SOCKS5?", + "socksproxies.inUse": "Este proxy SOCKS5 lo usan {count} hosts", + "socksproxies.saveUnknownHint": "Resultado desconocido. Revisa la lista antes de reintentarlo. Se conserva tu borrador.", + "socksproxies.saveFailed": "No se pudo guardar el proxy SOCKS5.", + "socksproxies.loading": "Cargando proxies SOCKS5…", + "connection.route": "Ruta", + "connection.routeClearJump": "Un host usa una sola ruta: quita el host de salto para elegir un proxy SOCKS5.", + "connection.routeClearSocks": "Un host usa una sola ruta: quita el proxy SOCKS5 para elegir un host de salto.", + "connection.routeTailscale": "Tailscale SSH conecta directamente.", + "connection.routeConflict": "Elige una sola ruta compatible.", + "connection.socksProxy": "Proxy SOCKS5", + "connection.socksProxyNone": "Ninguno", + "connection.socksProxyManage": "Administra los proxies SOCKS5 en los recursos de conexión.", + "connection.socksProxyPassword": "Contraseña del proxy SOCKS5", + "connection.socksProxyPasswordHint": "Este proxy usa contraseña. Introdúcela; solo se almacena si la guardas.", + "connection.socksProxyPasswordRequired": "Se requiere la contraseña del proxy SOCKS5", + "connection.socksProxyMissing": "Falta el proxy SOCKS5", + "connection.socksProxyVia": "mediante SOCKS5 {name}", + "connection.socksProxyQuickSftp": "Conecta primero un terminal para usar SFTP mediante SOCKS5.", + "connection.socksProxyProbeHint": "Solo se comprobó el extremo del proxy SOCKS5, no el destino.", + "connection.socksProxyAutomation": "Las rutas SOCKS5 aún no admiten automatización", + "connection.socksProxyNotExported": "Ruta SOCKS5 no exportada", + "sshErrors.socksReview": "Revisar proxy SOCKS5", + "sshErrors.socksProxyFailure": "El proxy SOCKS5 informó de un error general.", + "sshErrors.socksNotAllowed": "El proxy SOCKS5 no permite esta conexión.", + "sshErrors.socksNetworkUnreachable": "El proxy SOCKS5 no puede alcanzar la red de destino.", + "sshErrors.socksHostUnreachable": "El proxy SOCKS5 no puede alcanzar el host de destino.", + "sshErrors.socksConnectionRefused": "El destino rechazó la conexión mediante el proxy SOCKS5.", + "sshErrors.socksTtlExpired": "La conexión mediante el proxy SOCKS5 agotó el tiempo de espera.", + "sshErrors.socksUnsupported": "El proxy SOCKS5 no admite esta solicitud.", + "sshErrors.socksProtocolError": "El proxy SOCKS5 envió una respuesta no válida.", + "sshErrors.socksProxyUnreachable": "No se pudo alcanzar el proxy SOCKS5.", + "sshErrors.socksTimeout": "El proxy SOCKS5 no respondió a tiempo.", + "sshErrors.socksAuthRequired": "El proxy SOCKS5 requiere usuario y contraseña.", + "sshErrors.socksAuthRejected": "El proxy SOCKS5 no acepta el inicio de sesión con usuario y contraseña.", + "sshErrors.socksAuthFailed": "Falló el inicio de sesión en el proxy SOCKS5. Revisa el usuario y la contraseña.", + 'overview.ui.inventory': "Inventario", "tour.back": "Atrás", @@ -10928,7 +11223,7 @@ const translations = { 'connection.jumpHostPassword': 'Contraseña del host de salto', 'connection.jumpHostPasswordHint': 'Este bastión usa contraseña — introdúcela (nunca se guarda).', 'connection.advancedSettings': 'Configuración avanzada', - 'connection.advancedSettingsHint': 'Host de salto, acciones posteriores y sesión persistente.', + 'connection.advancedSettingsHint': "Ruta, acciones tras conectar y persistencia.", 'connection.commandSet': 'Comandos después de conectar (opcional)', 'connection.commandSetHint': 'Se ejecutan en el host remoto después de una conexión correcta, no en WebSSH. No se vuelven a ejecutar al reconectar con una sesión tmux existente.', 'commandSets.manage': 'Conjuntos de comandos', @@ -12166,6 +12461,65 @@ const translations = { }, zh: { + "connection.socksProxyTopologySummary": "{hosts} 台主机,{direct} 个直连,{jumps} 台跳板机,{socks} 个 SOCKS5 代理,{live} 个会话已打开", + "socksproxies.title": "SOCKS5 代理", + "socksproxies.manage": "管理 SOCKS5 代理", + "socksproxies.addNew": "添加 SOCKS5 代理", + "socksproxies.add": "添加 SOCKS5 代理", + "socksproxies.edit": "编辑 SOCKS5 代理", + "socksproxies.name": "名称", + "socksproxies.namePlaceholder": "办公室代理", + "socksproxies.noAuthentication": "无需身份验证", + "socksproxies.passwordAuthentication": "用户名和密码", + "socksproxies.remoteDns": "通过代理解析主机名", + "socksproxies.remoteDnsHint": "推荐启用。仅在代理网络中存在的主机名仍可正常使用。当 WebSSH 阻止内部目标时,只会将管理员允许的主机名发送给代理。", + "socksproxies.passwordHint": "连接时输入代理密码,或明确保存一个可重复使用的密码。", + "socksproxies.passwordWarning": "SOCKS5 以明文发送代理密码。请仅在可信网络中使用密码登录。SSH 会话本身仍保持加密。", + "socksproxies.login": "需要登录", + "socksproxies.noLogin": "无需登录", + "socksproxies.remoteDnsBadge": "远程 DNS", + "socksproxies.none": "尚未保存 SOCKS5 代理", + "socksproxies.emptyHint": "添加 SOCKS5 代理,然后在已保存的主机中选择它。", + "socksproxies.saved": "已保存的 SOCKS5 代理", + "socksproxies.savedOk": "SOCKS5 代理已保存", + "socksproxies.deleted": "SOCKS5 代理已删除", + "socksproxies.confirmDelete": "删除此 SOCKS5 代理?", + "socksproxies.inUse": "此 SOCKS5 代理正被 {count} 台主机使用", + "socksproxies.saveUnknownHint": "保存结果未知。再次尝试前请检查已保存列表。草稿已保留。", + "socksproxies.saveFailed": "无法保存 SOCKS5 代理。", + "socksproxies.loading": "正在加载 SOCKS5 代理…", + "connection.route": "连接路由", + "connection.routeClearJump": "一台主机只能使用一条路由:清除跳板机后才能选择 SOCKS5 代理。", + "connection.routeClearSocks": "一台主机只能使用一条路由:清除 SOCKS5 代理后才能选择跳板机。", + "connection.routeTailscale": "Tailscale SSH 直接连接。", + "connection.routeConflict": "请选择一条兼容的连接路由。", + "connection.socksProxy": "SOCKS5 代理", + "connection.socksProxyNone": "无", + "connection.socksProxyManage": "在连接资源中管理 SOCKS5 代理。", + "connection.socksProxyPassword": "SOCKS5 代理密码", + "connection.socksProxyPasswordHint": "此代理使用密码登录。请输入密码;只有明确保存时才会存储。", + "connection.socksProxyPasswordRequired": "请输入 SOCKS5 代理密码", + "connection.socksProxyMissing": "SOCKS5 代理缺失", + "connection.socksProxyVia": "通过 SOCKS5 {name}", + "connection.socksProxyQuickSftp": "请先连接终端,再通过 SOCKS5 使用 SFTP。", + "connection.socksProxyProbeHint": "仅检查了 SOCKS5 代理端点,未检查目标主机。", + "connection.socksProxyAutomation": "自动化暂不支持 SOCKS5 路由", + "connection.socksProxyNotExported": "未导出 SOCKS5 路由", + "sshErrors.socksReview": "检查 SOCKS5 代理", + "sshErrors.socksProxyFailure": "SOCKS5 代理报告了一般错误。", + "sshErrors.socksNotAllowed": "SOCKS5 代理不允许此连接。", + "sshErrors.socksNetworkUnreachable": "SOCKS5 代理无法访问目标网络。", + "sshErrors.socksHostUnreachable": "SOCKS5 代理无法访问目标主机。", + "sshErrors.socksConnectionRefused": "目标主机拒绝了通过 SOCKS5 代理建立的连接。", + "sshErrors.socksTtlExpired": "通过 SOCKS5 代理的连接已超时。", + "sshErrors.socksUnsupported": "SOCKS5 代理不支持此请求。", + "sshErrors.socksProtocolError": "SOCKS5 代理返回了无效响应。", + "sshErrors.socksProxyUnreachable": "无法访问 SOCKS5 代理。", + "sshErrors.socksTimeout": "SOCKS5 代理未及时响应。", + "sshErrors.socksAuthRequired": "SOCKS5 代理需要用户名和密码。", + "sshErrors.socksAuthRejected": "SOCKS5 代理不接受用户名和密码登录。", + "sshErrors.socksAuthFailed": "SOCKS5 代理登录失败。请检查用户名和密码。", + 'overview.ui.inventory': "资源清单", "tour.back": "上一步", @@ -13358,7 +13712,7 @@ const translations = { 'connection.jumpHostPassword': '跳板机密码', 'connection.jumpHostPasswordHint': '该堡垒机使用密码认证 — 请输入密码(不会保存)。', 'connection.advancedSettings': '高级设置', - 'connection.advancedSettingsHint': '跳板机、连接后操作和持久会话。', + 'connection.advancedSettingsHint': "连接路由、连接后操作和会话持久化。", 'connection.commandSet': '连接后运行的命令(可选)', 'connection.commandSetHint': '连接成功后在远程主机上运行,而不是在 WebSSH 中运行。重新连接到现有 tmux 会话时不会再次运行。', 'commandSets.manage': '命令集', diff --git a/static/js/native-credentials.js b/static/js/native-credentials.js index 1aae5db5..af84954c 100644 --- a/static/js/native-credentials.js +++ b/static/js/native-credentials.js @@ -120,7 +120,7 @@ const status=el('p',t('credentials.loading','Loading passwords…'));status.setAttribute('role','status');panel.append(status);container.append(panel); try{ const data=await load();if(!current()||!panel.isConnected)return; - const targets=(data.profiles||[]).filter(resource=>resource.kind==='ssh'&&(!resource.auth_type||resource.auth_type==='password')); + const targets=(data.profiles||[]).filter(resource=>['ssh','socks'].includes(resource.kind)&&(!resource.auth_type||resource.auth_type==='password')); status.textContent='';if(!targets.length){status.textContent=t('credentials.noTargets','No password-authenticated hosts are available. Create or edit a host in Hosts.');return;} const baseline=new Map(targets.map(target=>[target.id,target.credential_id===item.id])); const selected=new Set(targets.filter(target=>baseline.get(target.id)).map(target=>target.id)); @@ -204,7 +204,7 @@ function installInlineCreate(formId,prefix){ const form=document.getElementById(formId);if(!form)return; const section=el('section',null,'native-host-credentials');const label=el('label');const enable=el('input');enable.type='checkbox';enable.id=prefix+'SavePassword';label.append(enable,document.createTextNode(t('credentials.new','New password')));const fields=el('div');fields.hidden=true; - for(const [key,text,type]of [['Name',t('credentials.name','Name'),'text'],['Password',t('credentials.password','Password'),'password']]){const row=el('label',text);const input=el('input');input.type=type;input.id=prefix+key;input.className='form-control';input.autocomplete=type==='password'?'new-password':'off';row.append(input);fields.append(row);}enable.onchange=()=>fields.hidden=!enable.checked;form.addEventListener('reset',()=>{fields.hidden=true;fields.querySelector('input[type=password]').value='';});const existingLabel=el('label',t('credentials.use','Use saved password'));const existing=el('select');existing.id=prefix+'Existing';existing.className='form-control';const manual=el('option',t('credentials.manual','Enter manually'));manual.value='';existing.append(manual);existingLabel.append(existing);existing.onfocus=async()=>{try{const data=await load();const selected=existing.value;existing.replaceChildren(manual);(data.credentials||[]).forEach(item=>{const option=el('option',item.name);option.value=item.id;existing.append(option);});existing.value=selected;}catch{}};existing.onchange=()=>{if(existing.value){enable.checked=false;fields.hidden=true;const item=state?.credentials?.find(record=>record.id===existing.value);const user=document.getElementById(prefix==='jhCredential'?'jhUsernameInput':'smbSourceUsername');if(user&&!user.value&&item?.username)user.value=item.username;}};enable.addEventListener('change',()=>{if(enable.checked)existing.value='';});section.append(existingLabel,label,fields);form.querySelector('.form-actions')?.before(section);if(!section.isConnected)form.append(section);load().then(data=>{section.hidden=!data.enabled;(data.credentials||[]).forEach(item=>{const option=el('option',item.name);option.value=item.id;existing.append(option);});}).catch(()=>section.hidden=true);const modal=form.closest('.modal');if(modal)new MutationObserver(()=>{if(!modal.classList.contains('show'))fields.querySelector('input[type=password]').value='';}).observe(modal,{attributes:true,attributeFilter:['class']}); + for(const [key,text,type]of [['Name',t('credentials.name','Name'),'text'],['Password',t('credentials.password','Password'),'password']]){const row=el('label',text);const input=el('input');input.type=type;input.id=prefix+key;input.className='form-control';input.autocomplete=type==='password'?'new-password':'off';row.append(input);fields.append(row);}enable.onchange=()=>fields.hidden=!enable.checked;form.addEventListener('reset',()=>{fields.hidden=true;fields.querySelector('input[type=password]').value='';});const existingLabel=el('label',t('credentials.use','Use saved password'));const existing=el('select');existing.id=prefix+'Existing';existing.className='form-control';const manual=el('option',t('credentials.manual','Enter manually'));manual.value='';existing.append(manual);existingLabel.append(existing);existing.onfocus=async()=>{try{const data=await load();const selected=existing.value;existing.replaceChildren(manual);(data.credentials||[]).forEach(item=>{const option=el('option',item.name);option.value=item.id;existing.append(option);});existing.value=selected;}catch{}};existing.onchange=()=>{if(existing.value){enable.checked=false;fields.hidden=true;const item=state?.credentials?.find(record=>record.id===existing.value);const user=document.getElementById(prefix==='jhCredential'?'jhUsernameInput':prefix==='spCredential'?'spUsernameInput':'smbSourceUsername');if(user&&!user.value&&item?.username)user.value=item.username;}};enable.addEventListener('change',()=>{if(enable.checked)existing.value='';});section.append(existingLabel,label,fields);form.querySelector('.form-actions')?.before(section);if(!section.isConnected)form.append(section);load().then(data=>{section.hidden=!data.enabled;(data.credentials||[]).forEach(item=>{const option=el('option',item.name);option.value=item.id;existing.append(option);});}).catch(()=>section.hidden=true);const modal=form.closest('.modal');if(modal)new MutationObserver(()=>{if(!modal.classList.contains('show'))fields.querySelector('input[type=password]').value='';}).observe(modal,{attributes:true,attributeFilter:['class']}); } function inlineIdentity(prefix){return JSON.stringify(['Existing','SavePassword','Name','Password'].map(suffix=>{const input=document.getElementById(prefix+suffix);return input?.type==='checkbox'?input.checked:input?.value;}));} function inlineDraft(prefix){const existing=document.getElementById(prefix+'Existing');const selected=existing?.value;if(selected)return {credential_id:selected};if(!document.getElementById(prefix+'SavePassword')?.checked)return existing?.dataset.assigned?{credential_id:null}:null;const name=document.getElementById(prefix+'Name').value.trim(),password=document.getElementById(prefix+'Password').value;if(!name||!password)throw new Error(t('credentials.required','Enter a password name and password.'));return {name,password};} @@ -212,10 +212,10 @@ const select=document.getElementById(prefix+'Existing');if(!select)return; select.value='';select.dataset.assigned='';select.dataset.resource=resourceId||'';document.getElementById(prefix+'SavePassword').checked=false;document.getElementById(prefix+'Password').value='';document.getElementById(prefix+'Name').value='';document.getElementById(prefix+'Password').parentElement.parentElement.hidden=true; const form=select.closest('form'),snapshot=JSON.stringify(Array.from(form.querySelectorAll('input,select')).map(input=>input.type==='checkbox'||input.type==='radio'?input.checked:input.value)); - try{const data=await load();select.closest('.native-host-credentials').hidden=!data.enabled;if(select.dataset.resource!==(resourceId||'')||snapshot!==JSON.stringify(Array.from(form.querySelectorAll('input,select')).map(input=>input.type==='checkbox'||input.type==='radio'?input.checked:input.value)))return;const resource=data.profiles.find(item=>item.id===resourceId);const manual=el('option',t('credentials.manual','Enter manually'));manual.value='';select.replaceChildren(manual);data.credentials.forEach(item=>{const option=el('option',item.name);option.value=item.id;select.append(option);});select.value=resource?.credential_id||'';select.dataset.assigned=resource?.credential_id||'';select.dataset.confirmRequired=String(Boolean(resource?.credential_id&&resource?.credential_status!=='ready'));if(prefix==='jhCredential'&&window.JumpHostManager?.editingId===(resourceId?resourceId.slice(5):null))window.JumpHostManager.baseline=window.JumpHostManager.draft();}catch{} + try{const data=await load();select.closest('.native-host-credentials').hidden=!data.enabled;if(select.dataset.resource!==(resourceId||'')||snapshot!==JSON.stringify(Array.from(form.querySelectorAll('input,select')).map(input=>input.type==='checkbox'||input.type==='radio'?input.checked:input.value)))return;const resource=data.profiles.find(item=>item.id===resourceId);const manual=el('option',t('credentials.manual','Enter manually'));manual.value='';select.replaceChildren(manual);data.credentials.forEach(item=>{const option=el('option',item.name);option.value=item.id;select.append(option);});select.value=resource?.credential_id||'';select.dataset.assigned=resource?.credential_id||'';select.dataset.confirmRequired=String(Boolean(resource?.credential_id&&resource?.credential_status!=='ready'));if(prefix==='jhCredential'&&window.JumpHostManager?.editingId===(resourceId?resourceId.slice(5):null))window.JumpHostManager.baseline=window.JumpHostManager.draft();if(prefix==='spCredential'&&window.SocksProxyManager?.editingId===(resourceId?resourceId.slice(6):null))window.SocksProxyManager.baseline=window.SocksProxyManager.draft();}catch{} } async function confirmTargetReuse(target){return await question(t('credentials.confirmTarget','Reuse this saved password for the changed destination?')+' '+target.username+'@'+target.host+':'+target.port+(target.share?'/'+target.share:'')+(target.domain?' · '+target.domain:''),{confirmation:true})===true;} window.NativeCredentials={assignmentReady,reviewAssignment,api,authorizeTarget,mutate,load,mount,pick,editor,inlineDraft,inlineIdentity,confirmTargetReuse,refreshInline}; - document.addEventListener('DOMContentLoaded',()=>{installInlineCreate('jumpHostForm','jhCredential');installInlineCreate('smbSourceForm','smbCredential');}); + document.addEventListener('DOMContentLoaded',()=>{installInlineCreate('jumpHostForm','jhCredential');installInlineCreate('socksProxyForm','spCredential');window.SocksProxyManager?.updateAuthVisibility();installInlineCreate('smbSourceForm','smbCredential');}); document.addEventListener('DOMContentLoaded',installHostEditor); }()); diff --git a/static/js/overview-model.js b/static/js/overview-model.js index 14102610..e9744b64 100644 --- a/static/js/overview-model.js +++ b/static/js/overview-model.js @@ -14,7 +14,9 @@ } function sameEndpoint(entry, profile) { - return String(entry.host || '') === String(profile.host || '') + return (entry.socksProxyId || null) === (profile.socks_proxy_id || null) + && !(entry.viaSocks && !entry.socksProxyId) + && String(entry.host || '') === String(profile.host || '') && Number(entry.port || 22) === Number(profile.port || 22) && String(entry.username || '') === String(profile.username || ''); } @@ -24,14 +26,19 @@ * reachability result. State precedence: live > broken > unreachable > * action > ready. "unknown" means readiness has not loaded yet. */ - function hosts({profiles = [], jumps = [], jumpsLoaded = false, sessions = [], readiness = null, reachability = null} = {}) { + function hosts({profiles = [], jumps = [], jumpsLoaded = false, socks = [], socksLoaded = false, sessions = [], readiness = null, reachability = null} = {}) { const jumpById = new Map(jumps.map(jump => [jump.id, jump])); + const socksById = new Map(socks.map(proxy => [proxy.id, proxy])); const ready = new Map((readiness || []).map(item => [item.id, item])); const seen = new Set(); return profiles.filter(profile => profile && typeof profile.id === 'string' && !seen.has(profile.id) && seen.add(profile.id)) .map(profile => { const jump = profile.jump_host_id ? jumpById.get(profile.jump_host_id) : null; - const route = !profile.jump_host_id ? {kind: 'direct', name: null, id: null} + const proxy = socksById.get(profile.socks_proxy_id); + const route = profile.socks_proxy_id + ? proxy ? {kind: 'socks', name: String(proxy.name || proxy.host || ''), id: proxy.id} + : {kind: socksLoaded ? 'missing' : 'loading', name: null, id: profile.socks_proxy_id} + : !profile.jump_host_id ? {kind: 'direct', name: null, id: null} : jump ? {kind: 'jump', name: String(jump.name || jump.host || ''), id: jump.id} : {kind: jumpsLoaded ? 'missing' : 'loading', name: null, id: profile.jump_host_id}; const session = sessions.find(item => item.connected === true @@ -118,7 +125,7 @@ if (!connected) return {tone: 'danger', key: 'disconnected'}; // Acknowledged items no longer drive the headline. A grouped item counts every host it covers. const count = attentionItems.reduce((sum, item) => sum + (Number.isInteger(item.count) && item.count > 0 ? item.count : 1), 0); - if (attentionItems.some(item => item.kind === 'jump-missing' || item.kind === 'run-failed')) return {tone: 'danger', key: 'attention', count}; + if (attentionItems.some(item => ['jump-missing', 'socks-missing', 'run-failed'].includes(item.kind))) return {tone: 'danger', key: 'attention', count}; if (attentionItems.length) return {tone: 'warning', key: 'attention', count}; if (!hostList.length) return {tone: 'neutral', key: 'empty'}; return {tone: 'success', key: 'ready'}; @@ -129,7 +136,7 @@ const hostKeys = hostList.filter(host => host.status?.host_key === 'unknown' && host.state !== 'live'); if (hostKeys.length) items.push({kind: 'host-keys', count: hostKeys.length, hostIds: hostKeys.map(host => host.id)}); hostList.filter(host => host.state === 'broken').slice(0, 3) - .forEach(host => items.push({kind: 'jump-missing', name: host.name, hostId: host.id})); + .forEach(host => items.push({kind: host.profile?.socks_proxy_id ? 'socks-missing' : 'jump-missing', name: host.name, hostId: host.id})); const unreachable = hostList.filter(host => host.state === 'unreachable'); if (unreachable.length) items.push({kind: 'unreachable', count: unreachable.length, names: unreachable.slice(0, 3).map(host => host.name), hostIds: unreachable.map(host => host.id)}); const credentials = hostList.filter(host => ['password_changed', 'key_missing'].includes(host.status?.auth)); @@ -228,8 +235,9 @@ function topology(hostList = [], {width = 1200, height = null, perHub = 12} = {}) { const groups = new Map(); hostList.forEach(host => { - const kind = host.route.kind === 'jump' ? 'jump' : host.route.kind === 'direct' ? 'direct' : 'missing'; - const key = kind === 'jump' ? `jump:${host.route.id}` : kind; + const kind = ['jump', 'socks'].includes(host.route.kind) ? host.route.kind : host.route.kind === 'direct' ? 'direct' : 'missing'; + const key = ['jump', 'socks'].includes(kind) ? `${kind}:${host.route.id}` + : kind === 'missing' && host.profile?.socks_proxy_id ? 'socks-missing' : kind; if (!groups.has(key)) groups.set(key, {key, kind, name: host.route.name, hosts: []}); groups.get(key).hosts.push(host); }); diff --git a/static/js/overview-route-map.js b/static/js/overview-route-map.js index 5ccfe7c2..005f23fb 100644 --- a/static/js/overview-route-map.js +++ b/static/js/overview-route-map.js @@ -181,14 +181,14 @@ note.textContent=t('Saved SSH routes · not a reachability check','Gespeicherte SSH-Wege · keine Erreichbarkeitsprüfung'); if(!loaded()){count.textContent='';canvas.replaceChildren(el('p','ssh-map-empty',t('Loading hosts…','Hosts werden geladen…')));fingerprint='';return;} const all=model(),query=search.value.trim().toLocaleLowerCase(); - const hosts=all.filter(h=>!query || [h.name,h.group,h.jump?.name||''].join(' ').toLocaleLowerCase().includes(query)); + const hosts=all.filter(h=>!query || [h.name,h.group,h.socks?.name||h.jump?.name||''].join(' ').toLocaleLowerCase().includes(query)); count.textContent=hosts.length+' / '+all.length+' '+t('hosts'); const signature=JSON.stringify([hosts,query,[...expanded],window.i18n?.currentLang,dialog.open]); if(signature===fingerprint){layout();return;}fingerprint=signature; const focused=document.activeElement?.dataset.sshMapFocus; const branches=new Map(); for(const h of hosts){ - const id=h.route==='none'?'direct':h.route+':'+h.routeRef; + const id=h.route==='none'?'direct':(h.routeKind==='socks'?'socks:':'')+h.route+':'+h.routeRef; if(!branches.has(id))branches.set(id,{sample:h,groups:new Map()}); const groups=branches.get(id).groups;if(!groups.has(h.group))groups.set(h.group,[]);groups.get(h.group).push(h); } @@ -199,9 +199,9 @@ for(const [id,{sample,groups}] of branches){ const branch=el('section','ssh-route-branch');branch.dataset.route=id; const direct=sample.route==='none'; - const sourceName=direct?t('Direct SSH','Direktes SSH'):sample.jump?.name||t('Unresolved jump host','Ungeklärter Jump Host'); + const sourceName=direct?t('Direct SSH','Direktes SSH'):sample.socks?.name||sample.jump?.name||(sample.routeKind==='socks'?window.i18n.t('connection.socksProxyMissing'):t('Unresolved jump host','Ungeklärter Jump Host')); if(!direct){ - const source=el('div','ssh-route-source');source.append(icon('vpn_key'),el('strong','',sourceName),el('small','',window.i18n.t('connection.jumpHost')));movable(source,id+':gateway',sourceName);branch.append(source); + const source=el('div','ssh-route-source');source.append(icon(sample.routeKind==='socks'?'lan':'vpn_key'),el('strong','',sourceName),el('small','',window.i18n.t(sample.routeKind==='socks'?'connection.socksProxy':'connection.jumpHost')));movable(source,id+':gateway',sourceName);branch.append(source); } const items=[...groups.values()].flat(), destinations=el('div','ssh-route-destinations'); const header=el('div','ssh-destination-heading');header.append(el('strong','',direct?t('Direct connections','Direkte Verbindungen'):t('Via ','Über ')+sourceName),el('span','',items.length+' '+t('hosts')));destinations.append(header);movable(destinations,id+':hosts',direct?t('Direct connections','Direkte Verbindungen'):t('Via ','\u00dcber ')+sourceName); diff --git a/static/js/overview-workspace.js b/static/js/overview-workspace.js index 7dd81d80..f679a2fa 100644 --- a/static/js/overview-workspace.js +++ b/static/js/overview-workspace.js @@ -59,6 +59,7 @@ const snapshot = () => win.WebSSHAutomation?.getRunSnapshot?.() || {enabled: false, runs: [], schedules: []}; const hostModel = () => Model.hosts({ profiles: profiles(), jumps: win.JumpHostManager?.jumpHosts || [], jumpsLoaded: win.JumpHostManager?.loaded === true, + socks: win.SocksProxyManager?.socksProxies || [], socksLoaded: win.SocksProxyManager?.loaded === true, sessions: sessions(), readiness: state.readiness, reachability: state.reachability, }); @@ -76,6 +77,7 @@ if (!win.primaryWorkspaceController?.open('hosts', $('profileManagementModal'))) return; manager.loadKeys?.(); win.JumpHostManager?.load?.(); + win.SocksProxyManager?.load?.(); manager.openEditor(hostId, {returnAfterSave: returnToOverview}); } /** After saving from the overview, come back to it with the saved host in view. */ @@ -96,6 +98,7 @@ } manager.loadKeys?.(); win.JumpHostManager?.load?.(); + win.SocksProxyManager?.load?.(); manager.openEditor(null, {returnAfterSave: returnToOverview}); } const relative = timestamp => { @@ -309,7 +312,7 @@ const list = node('ul', 'od-route-list'); layout.hubs.forEach(hub => { const item = node('li', 'od-route-item'); - const name = hub.kind === 'direct' ? tw('routeDirect', 'Direct') : hub.kind === 'jump' ? tw('routeVia', 'via {name}', {name: hub.name}) : t('overview.missingJump', 'Saved jump host unavailable'); + const name = hub.kind === 'direct' ? tw('routeDirect', 'Direct') : ['jump', 'socks'].includes(hub.kind) ? tw('routeVia', 'via {name}', {name: hub.name}) : (hub.hosts[0]?.profile?.socks_proxy_id ? t('connection.socksProxyMissing', 'SOCKS5 proxy missing') : t('overview.missingJump', 'Saved jump host unavailable')); const dots = node('ul', 'od-route-dots'); dots.setAttribute('aria-hidden', 'true'); hub.hosts.forEach(host => { const dot = node('li'); dot.dataset.state = host.state; dot.title = host.name; dots.append(dot); }); @@ -417,7 +420,7 @@ }); layout.hubs.forEach(hub => { const group = svg('g', {class: 'od-hub', 'data-kind': hub.kind, transform: `translate(${hub.x.toFixed(1)} ${hub.y.toFixed(1)})`}); - const text = hub.kind === 'direct' ? tw('routeDirect', 'Direct') : hub.kind === 'jump' ? hub.name : t('overview.missingJump', 'Saved jump host unavailable'); + const text = hub.kind === 'direct' ? tw('routeDirect', 'Direct') : ['jump', 'socks'].includes(hub.kind) ? hub.name : (hub.hosts[0]?.profile?.socks_proxy_id ? t('connection.socksProxyMissing', 'SOCKS5 proxy missing') : t('overview.missingJump', 'Saved jump host unavailable')); const width = Math.max(70, Math.min(170, text.length * 8 + 28)); group.append(svg('rect', {x: -width / 2, y: -16, width, height: 32, rx: 16, class: 'od-hub-box'})); const label = svg('text', {y: 5, 'text-anchor': 'middle', class: 'od-hub-label'}); @@ -443,13 +446,15 @@ } function finishTopology(holder, hosts, layout) { const jumps = layout.hubs.filter(hub => hub.kind === 'jump').length; + const socks = layout.hubs.filter(hub => hub.kind === 'socks').length; holder.setAttribute('role', 'img'); - holder.setAttribute('aria-label', tw('topologySummary', '{hosts} hosts, {direct} direct, {jumps} jump hosts, {live} with an open session', { - hosts: hosts.length, direct: hosts.filter(host => host.route.kind === 'direct').length, jumps, - live: hosts.filter(host => host.state === 'live').length, - })); + const counts = {hosts: hosts.length, direct: hosts.filter(host => host.route.kind === 'direct').length, + jumps, socks, live: hosts.filter(host => host.state === 'live').length}; + holder.setAttribute('aria-label', socks + ? t('connection.socksProxyTopologySummary', '{hosts} hosts, {direct} direct, {jumps} jump hosts, {socks} SOCKS5 proxies, {live} with an open session', counts) + : tw('topologySummary', '{hosts} hosts, {direct} direct, {jumps} jump hosts, {live} with an open session', counts)); view.querySelector('.od-topology').hidden = !hosts.length; - $('overviewOpenMap').hidden = !jumps || !state.routeMap; + $('overviewOpenMap').hidden = !(jumps + socks) || !state.routeMap; } function stateLabel(value) { return { @@ -468,7 +473,8 @@ } function reasonFor(host) { if (host.state === 'live') return tw('reasonLive', 'Session open'); - if (host.state === 'broken') return t('overview.missingJump', 'Saved jump host unavailable'); + if (host.state === 'broken') return host.profile?.socks_proxy_id ? t('connection.socksProxyMissing', 'SOCKS5 proxy missing') : t('overview.missingJump', 'Saved jump host unavailable'); + if (host.state === 'unreachable' && host.reach?.via_socks) return t('sshErrors.socksProxyUnreachable', 'The SOCKS5 proxy could not be reached.'); if (host.state === 'unreachable') return host.reach?.via_jump ? tw('reasonJumpUnreachable', 'Jump host not reachable') : tw('reasonUnreachable', 'SSH port not reachable'); if (host.status?.host_key === 'unknown') return tw('reasonHostKey', 'Host key not verified yet'); if (host.status?.auth === 'key_missing') return tw('reasonKeyMissing', 'SSH key missing'); @@ -489,10 +495,10 @@ const meta = node('div', 'od-host-meta'); const route = node('span', 'od-chip'); route.dataset.route = host.route.kind; - route.append(icon(host.route.kind === 'direct' ? 'arrow_forward' : 'alt_route'), + route.append(icon(host.route.kind === 'direct' ? 'arrow_forward' : host.route.kind === 'socks' ? 'lan' : 'alt_route'), node('span', '', host.route.kind === 'direct' ? tw('routeDirect', 'Direct') - : host.route.kind === 'jump' ? tw('routeVia', 'via {name}', {name: host.route.name}) - : t('overview.missingJump', 'Saved jump host unavailable'))); + : ['jump', 'socks'].includes(host.route.kind) ? tw('routeVia', 'via {name}', {name: host.route.name}) + : host.profile?.socks_proxy_id ? t('connection.socksProxyMissing', 'SOCKS5 proxy missing') : t('overview.missingJump', 'Saved jump host unavailable'))); meta.append(route); if (host.group) { const group = node('span', 'od-chip'); @@ -569,7 +575,7 @@ node('small', '', wf('configHint', 'Configuration checks do not test login or permissions.'))); if (state.readinessAt) context.append(node('small', '', wf('configChecked', 'Configuration checked {time}', {time: new Date(state.readinessAt).toLocaleString(win.i18n?.getLanguage?.() || undefined)}))); if (host.reach && state.checkedAt) context.append(node('small', '', wf('tcpChecked', 'TCP {state} · {time}', {state: wf(host.reach.state, host.reach.state), time: new Date(state.checkedAt).toLocaleString(win.i18n?.getLanguage?.() || undefined)}))); - if (host.reach) context.append(node('small', '', wf('tcpHint', 'TCP checks do not authenticate. For jump routes only the jump host is checked.'))); + if (host.reach) context.append(node('small', '', host.reach.via_socks ? t('connection.socksProxyProbeHint', 'Only the SOCKS5 proxy endpoint was checked, not the target.') : wf('tcpHint', 'TCP checks do not authenticate. For jump routes only the jump host is checked.'))); item.append(top, meta, bottom, context); return item; } @@ -684,6 +690,7 @@ ? tw('attentionHostKeysOne', '{name}: host key not verified yet. Connect once to verify it.', {name: hostName(item.hostIds[0])}) : tw('attentionHostKeys', '{count} hosts: host key not verified yet. Connect once to verify it.', {count: item.count}), item.count === 1 ? () => focusHost(item.hostIds[0]) : () => filterHosts(item.hostIds, tw('filterHostKeys', 'Host key not verified'))]; + case 'socks-missing': return ['lan', t('connection.socksProxyMissing', 'SOCKS5 proxy missing') + ': ' + item.name, () => edit(item.hostId)]; case 'jump-missing': return ['alt_route', tw('jumpMissing', 'Jump host missing for {name}', {name: item.name}), () => edit(item.hostId)]; case 'unreachable': return ['wifi_off', item.count === 1 ? tw('attentionUnreachableOne', '{name} is not reachable', {name: item.names[0]}) @@ -927,7 +934,7 @@ } } // A result belongs to the address and route that were checked. - const reachTarget = profile => [profile.host, profile.port || 22, profile.jump_host_id || ''].join('|'); + const reachTarget = profile => [profile.host, profile.port || 22, profile.jump_host_id || '', profile.socks_proxy_id || ''].join('|'); function reachTargets() { return new Map(profiles().map(profile => [profile.id, reachTarget(profile)])); } @@ -1001,7 +1008,7 @@ return win.OverviewRouteMap.create({ root: mount, loaded: () => win.ProfileManager?.profilesLoaded === true, - model: () => win.ConnectionOverview.buildAccessModel(profiles(), win.JumpHostManager?.jumpHosts || [], win.JumpHostManager?.loaded), + model: () => win.ConnectionOverview.buildAccessModel(profiles(), win.JumpHostManager?.jumpHosts || [], win.JumpHostManager?.loaded, win.SocksProxyManager?.socksProxies || [], win.SocksProxyManager?.loaded), hostActions: host => { const actions = node('div', 'overview-host-actions'); const connectButton = button('overview-host-connect', `${t('connection.actionConnect', 'Connect')} ${host.name}`, () => { @@ -1164,7 +1171,7 @@ if (id && Array.isArray(data.files) && sessionManager?.getSession?.(id)?.connected) { fileSessions.add(id); schedule(); } }); ['session-workspace-change', 'languageChanged', 'automation-runs-updated'].forEach(name => win.addEventListener(name, schedule)); - ['profile-list-change', 'jump-host-list-change', 'host-access-change'].forEach(name => win.addEventListener(name, () => { schedule(); queueReadiness(); })); + ['profile-list-change', 'jump-host-list-change', 'socks-proxy-list-change', 'host-access-change'].forEach(name => win.addEventListener(name, () => { schedule(); queueReadiness(); })); // The first automatic check needs the saved hosts, which may load after init. win.addEventListener('profile-list-change', () => { pruneReachability(); diff --git a/static/js/profile-exchange.js b/static/js/profile-exchange.js index 34b46617..df311acd 100644 --- a/static/js/profile-exchange.js +++ b/static/js/profile-exchange.js @@ -18,6 +18,8 @@ }, ...(body ? {body: JSON.stringify(body)} : {}) }); + const omitted = Number(response.headers.get('X-WebSSH-SOCKS-Routes-Omitted')); + if (response.ok && omitted > 0) window.showNotification?.(t('connection.socksProxyNotExported') + ' (' + omitted + ')', 'warning'); if (response.ok && response.headers.get('Content-Type')?.includes('application/zip')) { return response.blob(); } @@ -152,7 +154,7 @@ if (result.warnings.length) { warnings.append(element('summary', `${t('exchange.warnings')} (${result.warnings.length})`)); const list = element('ul'); - for (const warning of result.warnings) list.append(element('li', warning)); + for (const warning of result.warnings) list.append(element('li', warning === 'SOCKS5 route not exported' ? t('connection.socksProxyNotExported') : warning)); warnings.append(list); warnings.hidden = false; } @@ -165,7 +167,7 @@ selected.checked = !row.blocked; selected.addEventListener('change', updateSubmit); selectLabel.append(selected, document.createTextNode(` ${row.name}: ${row.username || '?'}@${row.host}:${row.port}`)); - section.append(selectLabel, element('p', row.warnings.join(' '))); + section.append(selectLabel, element('p', row.warnings.map(warning => warning === 'SOCKS5 route not exported' ? t('connection.socksProxyNotExported') : warning).join(' '))); const userLabel = element('label', t('exchange.username')); const username = element('input', undefined, 'form-control'); username.value = row.username; diff --git a/static/js/profile-launcher-utils.js b/static/js/profile-launcher-utils.js index 9da9dd0d..5ab6b034 100644 --- a/static/js/profile-launcher-utils.js +++ b/static/js/profile-launcher-utils.js @@ -22,6 +22,7 @@ const keys = Array.isArray(context.keys) ? context.keys : []; const jumpHosts = Array.isArray(context.jumpHosts) ? context.jumpHosts : []; const needsTargetPassword = profile.auth_type === 'password'; + if (profile.socks_proxy_id && (profile.jump_host_id || profile.auth_type === 'tailscale')) return 'review'; if (profile.auth_type === 'key') { if (!hasKey(keys, profile.key_id)) { @@ -36,6 +37,11 @@ return 'review'; } + if (profile.socks_proxy_id) { + const proxy = (context.socksProxies || []).find(item => item?.id === profile.socks_proxy_id); + if (!proxy || !['none', 'password'].includes(proxy.auth_type)) return 'review'; + return needsTargetPassword ? 'password' : proxy.auth_type === 'password' ? 'socks-proxy-password' : 'connect'; + } if (!profile.jump_host_id) { return needsTargetPassword ? 'password' : 'connect'; } @@ -68,7 +74,8 @@ const awaitingJumpHosts = profile?.jump_host_id && context.jumpHostsLoaded === false; const awaitingKeys = context.keysLoaded === false && (profile?.auth_type === 'key' || jumpHost?.auth_type === 'key'); - if (context.connected !== true && (awaitingJumpHosts || awaitingKeys)) { + const awaitingSocks = profile?.socks_proxy_id && context.socksProxiesLoaded === false; + if (context.connected !== true && (awaitingJumpHosts || awaitingKeys || awaitingSocks)) { return { state: 'loading', launchMode: 'loading', pending: true, labelKey: 'connection.readinessLoading', label: 'Checking credentials…', @@ -83,8 +90,9 @@ const jumpHost = (context.jumpHosts || []).find(item => item?.id === profile?.jump_host_id); const missingKey = (profile?.auth_type === 'key' && !hasKey(context.keys, profile.key_id)) || (jumpHost?.auth_type === 'key' && !hasKey(context.keys, jumpHost.key_id)); - if (missingKey) state = 'key-missing'; - else if (launchMode === 'password' || launchMode === 'jump-host-password') state = 'password-needed'; + if (profile?.socks_proxy_id && !(context.socksProxies || []).some(item => item?.id === profile.socks_proxy_id)) state = 'socks-missing'; + else if (missingKey) state = 'key-missing'; + else if (['password', 'jump-host-password', 'socks-proxy-password'].includes(launchMode)) state = 'password-needed'; else if (launchMode === 'connect' && buildDirectConnectionData(profile, context)) state = 'saved'; } const labels = { @@ -92,10 +100,11 @@ saved: ['readinessSaved', 'Saved'], 'password-needed': ['readinessPassword', 'Password needed'], 'key-missing': ['readinessKey', 'Key missing or unavailable'], + 'socks-missing': ['socksProxyMissing', 'SOCKS5 proxy missing'], review: ['readinessReview', 'Review connection'], }; const [key, label] = labels[state]; - const review = state === 'key-missing' || state === 'review'; + const review = state === 'key-missing' || state === 'review' || state === 'socks-missing'; return { state, launchMode, label, labelKey: `connection.${key}`, actionKey: review ? 'connection.actionReview' : 'connection.actionConnect', @@ -166,6 +175,7 @@ if (!postConnect) return null; Object.assign(result, postConnect); + if (profile.socks_proxy_id) result.socks_proxy = {socks_proxy_id: profile.socks_proxy_id}; if (!profile.jump_host_id) return result; const jumpHost = ( @@ -214,7 +224,7 @@ || Boolean(value.command_set_id) || Boolean(String(value.startup_commands || '').trim()) ); - return Boolean(value.jump_host_id || value.use_tmux === true || hasPostConnect); + return Boolean(value.jump_host_id || value.socks_proxy_id || value.use_tmux === true || hasPostConnect); } function compareText(left, right) { diff --git a/static/js/profile-manager.js b/static/js/profile-manager.js index ffbaa686..3b95cc25 100644 --- a/static/js/profile-manager.js +++ b/static/js/profile-manager.js @@ -326,6 +326,8 @@ const ProfileManager = { return ProfileLauncherUtils.determineLaunchMode(profile, { keys: this.keys, jumpHosts: window.JumpHostManager?.jumpHosts || [], + socksProxies: window.SocksProxyManager?.socksProxies || [], + socksProxiesLoaded: window.SocksProxyManager?.loaded, }); }, @@ -436,6 +438,8 @@ const ProfileManager = { keysLoaded: this.keysLoaded, jumpHostsLoaded: window.JumpHostManager?.loaded, jumpHosts: window.JumpHostManager?.jumpHosts || [], + socksProxies: window.SocksProxyManager?.socksProxies || [], + socksProxiesLoaded: window.SocksProxyManager?.loaded, }); const mode = readiness.launchMode; const action = document.createElement('span'); @@ -785,6 +789,12 @@ const ProfileManager = { } window.CredentialPicker?.restore?.('passwordInput'); window.CredentialPicker?.restore?.('jumpHostPasswordInput'); + window.SocksProxyManager?.selectReference(document.getElementById('socksProxySelect'), profile.socks_proxy_id); + const socksPassword = document.getElementById('socksProxyPasswordInput'); + if (socksPassword) socksPassword.value = ''; + window.SocksProxyManager?.updatePasswordVisibility(); + window.SocksProxyManager?.updateRouteControls(); + window.CredentialPicker?.restore?.('socksProxyPasswordInput'); const useTmuxCheck = document.getElementById('useTmuxCheck'); if (useTmuxCheck) useTmuxCheck.checked = profile.use_tmux === true; window.setConnectionAdvancedExpanded?.( @@ -800,7 +810,8 @@ const ProfileManager = { const profile = this.profiles.find(item => item.id === this.selectedProfileId); if (!profile || profile.host !== data.host || Number(profile.port || 22) !== Number(data.port) || profile.username !== data.username - || (profile.jump_host_id || null) !== (data.proxy_jump?.jump_host_id || null)) return null; + || (profile.jump_host_id || null) !== (data.proxy_jump?.jump_host_id || null) + || (profile.socks_proxy_id || null) !== (data.socks_proxy?.socks_proxy_id || null)) return null; return profile.id; }, @@ -810,6 +821,7 @@ const ProfileManager = { }, handleAuthTypeChange(authType) { + window.SocksProxyManager?.updateRouteControls(); const passwordGroup = document.getElementById('passwordGroup'); const keyGroup = document.getElementById('keyGroup'); @@ -858,6 +870,7 @@ const ProfileManager = { this.loadProfiles(); this.loadKeys(); window.JumpHostManager?.load(); + window.SocksProxyManager?.load(); if (!window.primaryWorkspaceController?.open('hosts', modal)) { window.ModalManager?.open(modal); } @@ -1312,6 +1325,11 @@ const ProfileManager = { this.t('connection.noJumpHost', 'None (direct connection)'), jump => `${jump.name} (${jump.username}@${jump.host}:${jump.port})`, ); + const socksSelect = document.getElementById('profileEditorSocksProxySelect'); + const selectedSocks = socksSelect?.value; + this._fillSelect(socksSelect, window.SocksProxyManager?.socksProxies || [], this.t('connection.socksProxyNone', 'None'), proxy => `${proxy.name} (${proxy.host}:${proxy.port})`); + window.SocksProxyManager?.selectReference(socksSelect, selectedSocks); + window.SocksProxyManager?.updateRouteControls(); this._fillSelect( document.getElementById('profileEditorCommandSelect'), window.CommandLibrary?.commands || [], @@ -1417,6 +1435,7 @@ const ProfileManager = { document.getElementById('profileEditorKeySelect').value = profile?.key_id || ''; document.getElementById('profileEditorJumpHostSelect').value = profile?.jump_host_id || ''; + window.SocksProxyManager?.selectReference(document.getElementById('profileEditorSocksProxySelect'), profile?.socks_proxy_id); document.getElementById('profileEditorPostConnectMode').value = profile ? this.inferPostConnectMode(profile) : 'none'; @@ -1474,6 +1493,7 @@ const ProfileManager = { }, updateEditorVisibility() { + window.SocksProxyManager?.updateRouteControls(); const authType = document.getElementById('profileEditorAuthType')?.value; document.getElementById('profileEditorKeyGroup')?.classList.toggle( 'hidden', authType !== 'key' @@ -1563,8 +1583,12 @@ const ProfileManager = { auth_type: document.getElementById('profileEditorAuthType').value, key_id: document.getElementById('profileEditorKeySelect').value || null, jump_host_id: document.getElementById('profileEditorJumpHostSelect').value || null, + socks_proxy_id: document.getElementById('profileEditorSocksProxySelect')?.value || null, startup_mode: mode, }; + if ((payload.socks_proxy_id && payload.jump_host_id) || (payload.auth_type === 'tailscale' && (payload.socks_proxy_id || payload.jump_host_id))) { + window.showNotification?.(this.t('connection.routeConflict', 'Choose one compatible route.'), 'error'); return; + } const useTmux = document.getElementById('profileEditorUseTmux'); if (useTmux) payload.use_tmux = useTmux.checked; if (this.editingProfileId) payload.id = this.editingProfileId; @@ -1596,7 +1620,7 @@ const ProfileManager = { const credentialDraft = credentialChoice === '__new' ? {name:document.getElementById('profileEditorCredentialName').value.trim(),password:document.getElementById('profileEditorCredentialPassword').value} : null; const assigned = document.getElementById('profileEditorCredential')?.dataset.assigned || ''; const original = this.profiles.find(item => item.id === payload.id); - const targetChanged = original && ['host','port','username','auth_type','key_id','jump_host_id'].some(key => String(original[key] || '') !== String(payload[key] || '')); + const targetChanged = original && ['host','port','username','auth_type','key_id','jump_host_id','socks_proxy_id'].some(key => String(original[key] || '') !== String(payload[key] || '')); const confirmationRequired = document.getElementById('profileEditorCredential')?.dataset.confirmRequired === 'true'; const needsAssignment = credentialChoice === '__new' || confirmationRequired || credentialChoice !== assigned || (targetChanged && Boolean(credentialChoice)); const editorId = this.editingProfileId; @@ -1644,10 +1668,10 @@ const ProfileManager = { if (!acknowledgement?.success || !needsAssignment) { completeSave(acknowledgement); return; } try { if (credentialChoice === '__new') { - await window.NativeCredentials.mutate('create', {...credentialDraft, username: payload.username, resource_id: acknowledgement.profile.id, confirm_target: true, target: {host:payload.host,port:payload.port,username:payload.username,auth_type:payload.auth_type,key_id:payload.key_id,jump_host_id:payload.jump_host_id}}, null, current); + await window.NativeCredentials.mutate('create', {...credentialDraft, username: payload.username, resource_id: acknowledgement.profile.id, confirm_target: true, target: {host:payload.host,port:payload.port,username:payload.username,auth_type:payload.auth_type,key_id:payload.key_id,jump_host_id:payload.jump_host_id,socks_proxy_id:payload.socks_proxy_id}}, null, current); if (!current()) return; } else { - await window.NativeCredentials.mutate('assign', {resource_id: acknowledgement.profile.id, credential_id: credentialChoice || null, confirm_target: true, target: {host: payload.host, port: payload.port, username: payload.username, auth_type: payload.auth_type, jump_host_id: payload.jump_host_id, key_id: payload.key_id}}, null, current); + await window.NativeCredentials.mutate('assign', {resource_id: acknowledgement.profile.id, credential_id: credentialChoice || null, confirm_target: true, target: {host: payload.host, port: payload.port, username: payload.username, auth_type: payload.auth_type, jump_host_id: payload.jump_host_id, socks_proxy_id: payload.socks_proxy_id, key_id: payload.key_id}}, null, current); } if (!current()) return; completeSave(acknowledgement); diff --git a/static/js/session-manager.js b/static/js/session-manager.js index 21c3f837..17caf4f3 100644 --- a/static/js/session-manager.js +++ b/static/js/session-manager.js @@ -1,5 +1,9 @@ /* exported SessionManager */ const SessionManager = { + socksLabel(session) { + const name = window.SocksProxyManager?.getById(session.socksProxyId)?.name || ''; + return (window.i18n?.t('connection.socksProxyVia') || 'via SOCKS5 {name}').replace('{name}', name).trim(); + }, endpointKey(host, port, username) { return String(username).includes(':') ? 'gateway:v1:' + JSON.stringify([host, String(port), username]) @@ -124,6 +128,8 @@ const SessionManager = { auth_type: data.auth_type, via_jump: data.via_jump, jump_host_id: data.jump_host_id, + via_socks: data.via_socks, + socks_proxy_id: data.socks_proxy_id, profile_id: data.profile_id, detected_platform: data.detected_platform, reconnect_route_known: data.reconnect_route_known, @@ -181,6 +187,8 @@ const SessionManager = { displayName: display_name || null, viaJump: data.via_jump || null, jumpHostId: data.jump_host_id || null, + viaSocks: data.via_socks === true, + socksProxyId: data.socks_proxy_id || null, profileId: data.profile_id || null, reconnectRouteKnown: data.reconnect_route_known === true, useTmux: true, @@ -262,6 +270,8 @@ const SessionManager = { displayName: storedName || null, viaJump: sessionData.via_jump || null, jumpHostId: sessionData.jump_host_id || null, + viaSocks: sessionData.via_socks === true, + socksProxyId: sessionData.socks_proxy_id || null, profileId: sessionData.profile_id || null, detectedPlatform: sessionData.detected_platform, reconnectRouteKnown: sessionData.reconnect_route_known, @@ -318,6 +328,14 @@ const SessionManager = { if (window.HostPlatform) platformIcon.append(window.HostPlatform.renderSession(iconSession, iconProfile)); tab.insertBefore(platformIcon, tabLabel); const sess = this.sessions[sessionId]; + if (sess?.viaSocks || sess?.socksProxyId) { + const badge = document.createElement('span'); + badge.className = 'tab-jump-badge material-icons'; + badge.textContent = 'lan'; + badge.title = this.socksLabel(sess); + badge.setAttribute('aria-label', badge.title); + tab.appendChild(badge); + } if (sess && sess.viaJump) { const jumpBadge = document.createElement('span'); jumpBadge.className = 'tab-jump-badge material-icons'; @@ -503,7 +521,7 @@ const SessionManager = { } const route = this.reconnectRoute(session); - if (route.requiresForm || ((session.viaJump || session.jumpHostId) + if (route.requiresForm || ((session.viaJump || session.jumpHostId || session.viaSocks || session.socksProxyId) && !session.keyId && session.authType !== 'tailscale')) { this.prefillConnectionForm(sessionId, true); return; @@ -572,6 +590,7 @@ const SessionManager = { connectionData.key_id = keyId; } if (route.proxyJump) connectionData.proxy_jump = route.proxyJump; + if (route.socksProxy) connectionData.socks_proxy = route.socksProxy; window.SSHGatewayDialog?.prepare(connectionData); window.socket.emit('ssh_connect', connectionData); const message = window.i18n @@ -913,6 +932,7 @@ const SessionManager = { viaSpan.textContent = `${via} ${session.viaJump}`; notesEl.appendChild(viaSpan); } + if (session.viaSocks || session.socksProxyId) notesEl.appendChild(document.createTextNode(' · ' + this.socksLabel(session))); notesEl.classList.remove('empty'); }, @@ -1658,7 +1678,7 @@ const SessionManager = { if (window.prepareSessionReconnectJump) { return window.prepareSessionReconnectJump(session); } - return {requiresForm: Boolean(session.viaJump || session.jumpHostId + return {requiresForm: Boolean(session.viaJump || session.jumpHostId || session.viaSocks || session.socksProxyId || session.reconnectRouteKnown === false)}; }, @@ -1799,6 +1819,7 @@ const SessionManager = { connectionData.key_id = keyId; } if (route.proxyJump) connectionData.proxy_jump = route.proxyJump; + if (route.socksProxy) connectionData.socks_proxy = route.socksProxy; window.SSHGatewayDialog?.prepare(connectionData); window.socket.emit('ssh_connect', connectionData); const label = `${username}@${host}`; @@ -2497,6 +2518,7 @@ const SessionManager = { const parts = [session.displayName ? `${session.username}@${session.host}:${session.port}` : `SSH · ${session.port}`]; + if (session.viaSocks || session.socksProxyId) parts.push(this.socksLabel(session)); if (session.viaJump) { parts.push(`${this.windowText('connection.via', 'via')} ${session.viaJump}`); } diff --git a/static/js/sftp-file-manager.js b/static/js/sftp-file-manager.js index cef59d66..643d5afc 100644 --- a/static/js/sftp-file-manager.js +++ b/static/js/sftp-file-manager.js @@ -411,15 +411,17 @@ class SFTPFileManager extends window.FileDirectoryRequests { keysLoaded: window.ProfileManager?.keysLoaded, jumpHostsLoaded: window.JumpHostManager?.loaded, jumpHosts: window.JumpHostManager?.jumpHosts || [], + socksProxies: window.SocksProxyManager?.socksProxies || [], + socksProxiesLoaded: window.SocksProxyManager?.loaded, }) : { labelKey: 'connection.readinessSaved', label: 'Saved', actionKey: 'connection.actionReview', action: 'Review' }; return { key: `profile:${profile.id}`, - disabled: readiness.pending === true, + disabled: readiness.pending === true || Boolean(profile.socks_proxy_id), label: profile.name || `${profile.username}@${profile.host}`, endpoint: `${profile.username}@${profile.host}:${profile.port || 22}`, protocol: 'SFTP', - status: this.t(readiness.labelKey, readiness.label), + status: profile.socks_proxy_id ? this.t('connection.socksProxyQuickSftp', 'Connect a terminal first to use SFTP through SOCKS5.') : this.t(readiness.labelKey, readiness.label), readiness: readiness.state || 'review', favorite: profile.favorite === true, group: profile.group || '', @@ -498,6 +500,8 @@ class SFTPFileManager extends window.FileDirectoryRequests { return this.openSMBSourceDialog(source.savedSmbShare, pane); } if (source.profileId) { + const profile = (window.ProfileManager?.profiles || this.qcProfiles || []).find(item => item.id === source.profileId); + if (profile?.socks_proxy_id) { this.showNotification(this.t('connection.socksProxyQuickSftp', 'Connect a terminal first to use SFTP through SOCKS5.'), 'warning'); return null; } this.pendingQuickConnectPane = pane; this.openQuickConnect(source.profileId); return null; @@ -2880,7 +2884,7 @@ class SFTPFileManager extends window.FileDirectoryRequests { const select = document.getElementById('fmQcProfile'); select.innerHTML = ''; this.qcProfiles = data.profiles || []; - this.qcProfiles.forEach(profile => { + this.qcProfiles.filter(profile => !profile.socks_proxy_id).forEach(profile => { const option = document.createElement('option'); option.value = profile.id; option.textContent = `${profile.name} (${profile.username}@${profile.host})`; @@ -2921,6 +2925,7 @@ class SFTPFileManager extends window.FileDirectoryRequests { } onProfileSelect(profileId) { + this.pendingQuickConnectProfileId = profileId || null; window.CredentialPicker?.clear('fmQcPassword'); if (!profileId) { document.getElementById('fmQcHost').value = ''; @@ -2932,6 +2937,12 @@ class SFTPFileManager extends window.FileDirectoryRequests { const profile = (this.qcProfiles || []).find(p => p.id == profileId); if (!profile) return; + if (profile.socks_proxy_id) { + document.getElementById('fmQcHost').value = ''; + document.getElementById('fmQcPassword').value = ''; + this.showNotification(this.t('connection.socksProxyQuickSftp', 'Connect a terminal first to use SFTP through SOCKS5.'), 'warning'); + return; + } document.getElementById('fmQcHost').value = profile.host || ''; document.getElementById('fmQcPort').value = profile.port || 22; @@ -2990,6 +3001,13 @@ class SFTPFileManager extends window.FileDirectoryRequests { } submitQuickConnect() { + const selectedId = document.getElementById('fmQcProfile')?.value || this.pendingQuickConnectProfileId; + const profile = (window.ProfileManager?.profiles || this.qcProfiles || []).find(item => String(item.id) === String(selectedId)); + const selectedProfile = (this.qcProfiles || []).find(item => String(item.id) === String(selectedId)); + if (profile?.socks_proxy_id || selectedProfile?.socks_proxy_id) { + this.showNotification(this.t('connection.socksProxyQuickSftp', 'Connect a terminal first to use SFTP through SOCKS5.'), 'warning'); + return; + } const host = document.getElementById('fmQcHost').value.trim(); const port = parseInt(document.getElementById('fmQcPort').value) || 22; const rawUsername = document.getElementById('fmQcUsername').value; diff --git a/static/js/socks-proxy-manager.js b/static/js/socks-proxy-manager.js new file mode 100644 index 00000000..93281d2d --- /dev/null +++ b/static/js/socks-proxy-manager.js @@ -0,0 +1,361 @@ +window.SocksProxyManager = { + socksProxies: [], + loaded: false, + editingId: null, + pending: false, + unknownOutcome: false, + baseline: null, + + t(key, fallback) { return window.i18n ? window.i18n.t(key) : fallback; }, + + draft() { + const form = document.getElementById('socksProxyForm'); + return JSON.stringify(Array.from(form?.querySelectorAll('input, select') || []) + .map(input => ['radio', 'checkbox'].includes(input.type) ? input.checked : input.value)); + }, + + isDirty() { return this.baseline !== null && this.draft() !== this.baseline; }, + + canDiscard() { + return !this.pending && (!this.isDirty() + || window.confirm(this.t('editor.unsavedConfirm', 'Discard unsaved changes?'))); + }, + + renderEditorState() { + const title = document.getElementById('socksProxyEditorTitle'); + const submit = document.querySelector('#saveSocksProxyBtn span'); + const titleKey = this.editingId ? 'socksproxies.edit' : 'socksproxies.addNew'; + const saveKey = this.pending ? 'hostContext.saving' : this.editingId ? 'common.save' : 'socksproxies.add'; + if (title) { title.dataset.i18n = titleKey; title.textContent = this.t(titleKey, 'SOCKS5 Proxy'); } + if (submit) { submit.dataset.i18n = saveKey; submit.textContent = this.t(saveKey, 'Save'); } + const status = document.getElementById('spSaveStatus'); + if (status) { + status.hidden = !this.unknownOutcome; + status.textContent = this.t('socksproxies.saveUnknownHint', 'Save result unknown. Check the saved list before trying again. Your draft is preserved.'); + } + const cancel = document.getElementById('cancelSocksProxyEdit'); + if (cancel) { cancel.hidden = !this.editingId && !this.unknownOutcome && !this.isDirty(); cancel.disabled = this.pending; } + document.querySelectorAll('#socksProxyForm input, #socksProxyForm select').forEach(input => { input.disabled = this.pending; }); + const save = document.getElementById('saveSocksProxyBtn'); + if (save) save.disabled = this.pending || this.unknownOutcome; + }, + + resetEditor() { + this.editingId = null; + this.unknownOutcome = false; + document.getElementById('socksProxyForm')?.reset(); + window.NativeCredentials?.refreshInline?.('spCredential',null); + this.updateAuthVisibility(); + this.baseline = this.draft(); + this.renderEditorState(); + }, + + edit(id) { + if (!this.canDiscard()) return; + const host = this.getById(id); + if (!host) return; + this.editingId = id; + this.unknownOutcome = false; + for (const [field, value] of Object.entries({spNameInput: host.name, spHostInput: host.host, + spPortInput: host.port, spUsernameInput: host.username})) { + document.getElementById(field).value = value; + } + document.querySelectorAll('input[name="spAuthType"]').forEach(input => { input.checked = input.value === host.auth_type; }); + document.getElementById('spRemoteDns').checked = host.remote_dns !== false; + this.updateAuthVisibility(); + window.NativeCredentials?.refreshInline?.('spCredential','socks:'+id); + this.baseline = this.draft(); + this.renderEditorState(); + document.getElementById('socksProxyManagementModal')?.showResourceEditor?.(); + document.getElementById('spNameInput').focus(); + }, + + load() { + if (window.socket) { + window.socket.emit('list_socks_proxies'); + } + }, + + setSocksProxies(list) { + this.loaded = true; + this.socksProxies = Array.isArray(list) ? list : []; + this.renderSelect(); + this.renderList(); + window.ProfileManager?.renderEditorSelects(); + window.dispatchEvent?.(new CustomEvent('socks-proxy-list-change')); + if (typeof SessionManager !== 'undefined') { + SessionManager.refreshEmptyPanes(); + } + }, + + upsertSocksProxy(socksProxy) { + if (!socksProxy || !socksProxy.id) return; + this.setSocksProxies(this.socksProxies.some(item => item.id === socksProxy.id) + ? this.socksProxies.map(item => item.id === socksProxy.id + ? {...item, ...socksProxy} + : item) + : [...this.socksProxies, socksProxy]); + }, + + removeSocksProxy(socksProxyId) { + if (!socksProxyId) return; + this.setSocksProxies(this.socksProxies.filter(item => item.id !== socksProxyId)); + if (this.editingId === socksProxyId) this.resetEditor(); + }, + + getById(id) { + return this.socksProxies.find(j => j.id === id) || null; + }, + + selectReference(select, id) { + if (!select) return; + if (id && !Array.from(select.options).some(option => option.value === id)) { + const missing = document.createElement('option'); + missing.value = id; + missing.textContent = this.t('connection.socksProxyMissing', 'SOCKS5 proxy missing'); + select.appendChild(missing); + } + select.value = id || ''; + }, + + updateAuthVisibility() { + const password = document.querySelector('input[name="spAuthType"]:checked')?.value === 'password'; + document.getElementById('spUsernameGroup')?.classList.toggle('hidden', !password); + const username = document.getElementById('spUsernameInput'); + if (username) username.required = password; + document.querySelectorAll('[data-socks-password-hint]').forEach(element => { element.hidden = !password; }); + const credentials = document.querySelector('#socksProxyForm .native-host-credentials'); + if (credentials) credentials.classList.toggle('hidden', !password); + }, + + updateRouteControls() { + for (const prefix of ['', 'profileEditor']) { + const jump = document.getElementById(prefix ? 'profileEditorJumpHostSelect' : 'jumpHostSelect'); + const socks = document.getElementById(prefix ? 'profileEditorSocksProxySelect' : 'socksProxySelect'); + const auth = document.getElementById(prefix ? 'profileEditorAuthType' : 'authTypeSelect'); + const hint = document.getElementById(prefix ? 'profileEditorRouteHint' : 'connectionRouteHint'); + if (!jump || !socks) continue; + const tailscale = auth?.value === 'tailscale'; + jump.disabled = tailscale || Boolean(socks.value); + socks.disabled = tailscale || Boolean(jump.value); + const key = tailscale ? 'connection.routeTailscale' : jump.value ? 'connection.routeClearJump' : socks.value ? 'connection.routeClearSocks' : ''; + if (hint) { hint.hidden = !key; hint.textContent = key ? this.t(key, '') : ''; } + } + }, + + // Retain unavailable references: rebuilding a dropdown must never select a direct route. + + renderSelect() { + const select = document.getElementById('socksProxySelect'); + if (!select) { + return; + } + const current = select.value; + select.innerHTML = ''; + const none = document.createElement('option'); + none.value = ''; + none.textContent = window.i18n ? i18n.t('connection.socksProxyNone') : 'None'; + select.appendChild(none); + this.socksProxies.forEach(j => { + const opt = document.createElement('option'); + opt.value = j.id; + opt.textContent = `${j.name} (${j.host}:${j.port})`; + select.appendChild(opt); + }); + if (current) this.selectReference(select, current); + this.updatePasswordVisibility(); + this.updateRouteControls(); + }, + + // Show the proxy password field only when the chosen SOCKS5 proxy uses password auth. + updatePasswordVisibility() { + const select = document.getElementById('socksProxySelect'); + const group = document.getElementById('socksProxyPasswordGroup'); + if (!select || !group) { + return; + } + const sp = this.getById(select.value); + group.classList.toggle('hidden', !(sp && sp.auth_type === 'password')); + }, + + // Render the management modal list. + renderList() { + const container = document.getElementById('socksProxiesList'); + if (!container) { + return; + } + if (!this.loaded) { + const loading = document.createElement('p'); loading.setAttribute('role', 'status'); loading.textContent = this.t('socksproxies.loading', 'Loading SOCKS5 proxies…'); container.replaceChildren(loading); return; + } + if (!this.socksProxies.length) { + const empty = document.createElement('p'); + empty.className = 'no-items'; + empty.textContent = window.i18n ? i18n.t('socksproxies.none') : 'No SOCKS5 proxys saved'; + const hint = document.createElement('span'); + hint.className = 'empty-state-hint'; + hint.textContent = window.i18n ? i18n.t('socksproxies.emptyHint') : 'Add a SOCKS5 proxy, then choose it in a saved connection.'; + empty.appendChild(hint); + container.replaceChildren(empty); + return; + } + container.innerHTML = ''; + this.socksProxies.forEach(j => { + const item = document.createElement('div'); + item.className = 'key-item'; + + const info = document.createElement('div'); + info.className = 'key-info'; + const name = document.createElement('strong'); + name.textContent = j.name; + const addr = document.createElement('span'); + addr.className = 'key-date'; + addr.textContent = `${j.host}:${j.port}`; + const type = document.createElement('span'); + type.className = 'key-type'; + type.textContent = this.t(j.auth_type === 'password' ? 'socksproxies.login' : 'socksproxies.noLogin', j.auth_type === 'password' ? 'Login' : 'No login'); + info.appendChild(name); + info.appendChild(addr); + info.appendChild(type); + if (j.remote_dns) { const badge = document.createElement('span'); badge.className = 'key-type'; badge.textContent = this.t('socksproxies.remoteDnsBadge', 'Remote DNS'); info.appendChild(badge); } + + const del = document.createElement('button'); + del.className = 'btn btn-danger btn-sm'; + del.textContent = window.i18n ? i18n.t('common.delete') : 'Delete'; + del.addEventListener('click', () => this.delete(j.id)); + + item.appendChild(info); + const actions = document.createElement('div'); + actions.className = 'key-item-actions'; + const edit = document.createElement('button'); + edit.type = 'button'; + edit.className = 'btn btn-secondary btn-sm'; + edit.textContent = this.t('common.edit', 'Edit'); + edit.disabled = this.pending; + edit.addEventListener('click', () => this.edit(j.id)); + del.type = 'button'; + del.disabled = this.pending; + actions.append(edit, del); + item.appendChild(actions); + container.appendChild(item); + }); + }, + + async save(name, host, port, username, authType, remoteDns) { + if (this.pending || this.unknownOutcome) return; + if (!document.getElementById('socksProxyForm')?.reportValidity()) return; + if (!window.socket?.connected) { + window.showNotification?.(this.t('notes.offline', 'Offline — not saved'), 'error'); + return; + } + const payload = {name, host, port: Number(port), username: authType === 'password' ? username : '', auth_type: authType, + remote_dns: remoteDns === true}; + if (this.editingId) payload.socks_proxy_id = this.editingId; + let credential; + try { credential = authType === 'password' ? window.NativeCredentials?.inlineDraft('spCredential') : null; } + catch (error) { window.showNotification?.(error.message, 'error'); return; } + const identity=this.editingId, draft=this.draft(); + const current=()=>this.editingId===identity&&this.draft()===draft&&!document.getElementById('socksProxyManagementModal').hidden&&document.getElementById('socksProxyManagementModal').classList.contains('show'); + const original=this.getById(identity), selected=document.getElementById('spCredentialExisting'); + const changed=original&&['host','port','username','auth_type','remote_dns'].some(key=>String(original[key]||'')!==String(payload[key]||'')); + if(credential?.credential_id&&(changed||selected?.dataset.confirmRequired==='true')){if(!await window.NativeCredentials.confirmTargetReuse(payload)||!current())return;} + if(credential?.credential_id&&credential.credential_id===selected?.dataset.assigned&&!changed&&selected?.dataset.confirmRequired!=='true')credential=null; + this.pending = true; + this.renderEditorState(); + this.renderList(); + if (credential?.password && !this.editingId) { + try { + const result = await window.NativeCredentials.mutate('create_for_resource', {resource_kind:'socks', resource:payload, ...credential, username, confirm_target:true}, null, current); + if (!current()) return;this.upsertSocksProxy(result.resource);this.resetEditor(); + } catch (error) { if (error.name !== 'AbortError') window.showNotification?.(error.message, 'error'); } + finally { this.pending=false;this.renderEditorState();this.renderList(); } + return; + } + window.socket.timeout(15000).emit('save_socks_proxy', payload, async (error, result) => { + this.pending = false; + // A closed or edited form must still leave its disabled pending state. + try { + if (error) { + this.unknownOutcome = true; + window.showNotification?.(this.t('common.saveUnknownTitle', 'Save outcome unknown'), 'error'); + this.load(); + } else if (!result?.success) { + window.showNotification?.(result?.error || this.t('socksproxies.saveFailed', 'Could not save the SOCKS5 proxy.'), 'error'); + } else if (result?.success) { + if (!current()) return; + this.upsertSocksProxy(result.socks_proxy); + if (credential) { + try { await window.NativeCredentials.mutate(Object.hasOwn(credential,'credential_id')?'assign':'create',{...credential,username,resource_id:'socks:'+result.socks_proxy.id,confirm_target:true,target:{host:payload.host,port:payload.port,username:payload.username,auth_type:payload.auth_type,remote_dns:payload.remote_dns}}, null, current); } + catch (error) { if(error.name==='AbortError')return;window.showNotification?.(this.t('credentials.assignmentFailed','Host saved, but password assignment failed. Your password draft is preserved. Review the saved target and try again.'),'error');this.editingId=result.socks_proxy.id;this.renderEditorState();this.renderList();return; } + } + if (!current()) return; + this.resetEditor(); + } + } finally { + this.renderEditorState(); + this.renderList(); + } + }); + }, + + handleUsageError(data) { + const count = /^SOCKS proxy is used by (\d+)/.exec(data?.error || '')?.[1]; + if (!count || !Array.isArray(data.usages)) return false; + const message = this.t('socksproxies.inUse', 'This SOCKS5 proxy is used by {count} host(s)').replace('{count}', count); + window.showNotification?.(message + ': ' + data.usages.slice(0, 20).join(', '), 'error'); + return true; + }, + + delete(id) { + const msg = window.i18n ? i18n.t('socksproxies.confirmDelete') : 'Delete this SOCKS5 proxy?'; + if (this.pending) return; + if (!window.socket?.connected) { + window.showNotification?.(this.t('notes.offline', 'Offline — not saved'), 'error'); + return; + } + if (id === this.editingId && !this.canDiscard()) return; + if (window.confirm(msg) && window.socket?.connected) { + window.socket.emit('delete_socks_proxy', { socks_proxy_id: id }, result => { + if (result?.success) this.removeSocksProxy(id); + + }); + } + } +}; + +window.addEventListener('languageChanged', () => { + window.SocksProxyManager.renderSelect(); + window.SocksProxyManager.renderList(); + window.SocksProxyManager.renderEditorState(); + window.ProfileManager?.renderEditorSelects(); +}); + +window.addEventListener('DOMContentLoaded', () => { + const manager = window.SocksProxyManager; + document.getElementById('socksProxyForm')?.addEventListener('submit', event => { + event.preventDefault(); + const value = id => document.getElementById(id).value.trim(); + manager.save(value('spNameInput'), value('spHostInput'), value('spPortInput'), value('spUsernameInput'), + document.querySelector('input[name="spAuthType"]:checked').value, document.getElementById('spRemoteDns').checked); + }); + document.querySelectorAll('input[name="spAuthType"]').forEach(input => input.addEventListener('change', () => manager.updateAuthVisibility())); + ['jumpHostSelect', 'socksProxySelect', 'profileEditorJumpHostSelect', 'profileEditorSocksProxySelect', 'authTypeSelect', 'profileEditorAuthType'].forEach(id => { + document.getElementById(id)?.addEventListener('change', () => manager.updateRouteControls()); + }); + document.getElementById('closeSocksProxyModal')?.addEventListener('click', () => { + if (!manager.canDiscard()) return; + const modal = document.getElementById('socksProxyManagementModal'); + if (!window.primaryWorkspaceController?.close(modal)) window.ModalManager?.close(modal); + }); + manager.updateAuthVisibility(); + manager.baseline = manager.draft(); + document.getElementById('cancelSocksProxyEdit')?.addEventListener('click', () => { + if (manager.canDiscard()) { manager.resetEditor(); document.getElementById('spNameInput').focus(); } + }); + document.getElementById('socksProxyForm')?.addEventListener('input', () => manager.renderEditorState()); +}); +window.addEventListener('beforeunload', event => { + if (window.SocksProxyManager.isDirty() || window.SocksProxyManager.pending) { + event.preventDefault(); + event.returnValue = ''; + } +}); diff --git a/static/js/ssh-error-ui.js b/static/js/ssh-error-ui.js index c1b48000..c831a18f 100644 --- a/static/js/ssh-error-ui.js +++ b/static/js/ssh-error-ui.js @@ -26,6 +26,26 @@ } function describeSSHError(data, translate, appRoot = '') { + const socksErrors = { + socks_proxy_failure: ['socksProxyFailure', 'The SOCKS5 proxy reported a general failure.'], + socks_not_allowed: ['socksNotAllowed', 'The SOCKS5 proxy does not allow this connection.'], + socks_network_unreachable: ['socksNetworkUnreachable', 'The SOCKS5 proxy cannot reach the target network.'], + socks_host_unreachable: ['socksHostUnreachable', 'The SOCKS5 proxy cannot reach the target host.'], + socks_connection_refused: ['socksConnectionRefused', 'The target refused the connection through the SOCKS5 proxy.'], + socks_ttl_expired: ['socksTtlExpired', 'The connection through the SOCKS5 proxy timed out.'], + socks_unsupported: ['socksUnsupported', 'The SOCKS5 proxy does not support this request.'], + socks_protocol_error: ['socksProtocolError', 'The SOCKS5 proxy sent an invalid response.'], + socks_proxy_unreachable: ['socksProxyUnreachable', 'The SOCKS5 proxy could not be reached.'], + socks_timeout: ['socksTimeout', 'The SOCKS5 proxy did not respond in time.'], + socks_auth_required: ['socksAuthRequired', 'The SOCKS5 proxy requires a username and password.'], + socks_auth_rejected: ['socksAuthRejected', 'The SOCKS5 proxy does not accept username and password login.'], + socks_auth_failed: ['socksAuthFailed', 'SOCKS5 proxy login failed. Check the username and password.'], + }; + if (Object.hasOwn(socksErrors, data?.code)) { + const [key, fallback] = socksErrors[data.code]; + return {message: translated(translate, `sshErrors.${key}`, fallback), type: 'error', duration: 12000, + action: {label: translated(translate, 'sshErrors.socksReview', 'Review SOCKS5 proxy'), asset: 'socks-proxies'}}; + } if (data?.code === 'host_key_changed') { return { message: translated( diff --git a/static/js/webssh2-shell.js b/static/js/webssh2-shell.js index fe284099..4ba90775 100644 --- a/static/js/webssh2-shell.js +++ b/static/js/webssh2-shell.js @@ -37,7 +37,9 @@ persistence: session.useTmux ? `tmux${session.tmuxSessionName ? ` · ${session.tmuxSessionName}` : ''}` : t('workspace.standardSession', 'Standard session'), - transport: session.viaJump + transport: session.viaSocks || session.socksProxyId + ? `SSH ${SessionManager.socksLabel(session)}` + : session.viaJump ? `SSH ${t('connection.via', 'via')} ${session.viaJump}` : 'SSH' }; diff --git a/templates/index.html b/templates/index.html index 072b56c5..61adfb64 100644 --- a/templates/index.html +++ b/templates/index.html @@ -744,12 +744,13 @@

Connection Detail Advanced Settings - Jump host, post-connect actions, and persistence. + Route, post-connect actions, and persistence.
+
Route
+ +
+ + +
@@ -861,6 +877,7 @@

Hosts

Connection resources

+
@@ -961,7 +978,7 @@

Connection

Advanced Settings

-

Jump host, post-connect actions, and persistence.

+

Route, post-connect actions, and persistence.

@@ -986,10 +1003,15 @@

Ad Automatic checks platform facts after connecting over SSH. Choose an icon to override detection.

+
Route
+
+
+ +

@@ -1099,6 +1122,7 @@

Manage SSH Keys

Connection resources

+ @@ -1287,6 +1311,7 @@

Manage Jump Hosts<

Connection resources

+
@@ -1344,6 +1369,75 @@

Saved Jump Hosts

+ +