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/README.md b/README.md index 90cf4055..6525a983 100644 --- a/README.md +++ b/README.md @@ -70,8 +70,8 @@ depth. Upgrading from 2.x? Read the - Status overview with host readiness, open sessions, automation results, reachability and recent activity, in a layout you arrange per account. -- **Needs attention** lists unverified host keys, missing jump hosts, changed - saved passwords and failed runs. +- **Needs attention** lists unverified host keys, missing jump hosts or SOCKS5 + proxies, changed saved passwords and failed runs. - **Find hosts** scans a network range you name for SSH and SFTP servers, only on request, and opens the host form prefilled. - Automation runs commands, command sets and templates now or on a cron @@ -93,6 +93,8 @@ See the [automation guide](docs/automation.md) and - Multiple SSH sessions with tabs and 1-, 2-, or 4-pane layouts. - Saved connections, jump hosts, key, password, SSH server PAM MFA, and optional Tailscale SSH authentication paths. +- Per-host [SOCKS5 proxy routes](docs/wiki/Profiles-Jump-Hosts-and-Commands.md#socks5-proxies) + for terminal sessions and their SFTP files, with optional proxy login and remote DNS. - Optional persistent remote shells through tmux. - Broadcast input, terminal search, transcripts, reconnect controls, and configurable scrollback. 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/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/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/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/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/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/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..6aa32d7c 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 ( @@ -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): @@ -2143,6 +2228,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 +2530,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, @@ -4166,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(): @@ -5624,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/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/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/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/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/app/user_settings.py b/app/user_settings.py index 9708536c..a61e0440 100644 --- a/app/user_settings.py +++ b/app/user_settings.py @@ -19,7 +19,7 @@ OVERVIEW_PANEL_WIDTHS = range(3, 13) OVERVIEW_PANEL_HEIGHTS = range(8, 161) OVERVIEW_DISMISSED_LIMIT = 100 -_DISMISSED_KINDS = frozenset({'host-keys', 'jump-missing', 'unreachable', 'credentials', 'reboot', 'security-updates', 'run-failed', 'schedule-review'}) +_DISMISSED_KINDS = frozenset({'host-keys', 'jump-missing', 'socks-missing', 'unreachable', 'credentials', 'reboot', 'security-updates', 'run-failed', 'schedule-review'}) def valid_overview_layout(value): 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/docs/automation.md b/docs/automation.md index b1c8eeac..f171acf9 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -8,6 +8,13 @@ Enable **Automation** in **Administration → Integrations**. This persisted run Save and verify SSH hosts first. Unknown, changed or revoked host keys are rejected, never automatically accepted. Direct SSH and hosts behind one saved jump host are supported with usable stored keys or assigned passwords. Each hop needs its own verified host key and credential: a password-based jump host needs its own saved password. The jump host and a target it reaches by address follow the SSH network policy; a target name that only the jump host can resolve must be on `PROXY_JUMP_REMOTE_DNS_ALLOWLIST` while internal SSH is blocked, exactly as for interactive connections. Changing the jump host invalidates reviewed runs and approved schedules. Gateways, Tailscale SSH and interactive prompts remain unsupported by this execution engine. +Hosts with a SOCKS5 proxy route are also unsupported for immediate runs, +workflows and schedules. Readiness explains that limitation, and execution +rejects the target instead of connecting directly. Changing an approved host +to a SOCKS route invalidates the existing schedule's target authorization and +requires review. Interactive terminal sessions and their SFTP source remain +available through the proxy. + ## Run now 1. Open **Automation → Workflows** in the main application. The searchable list shows saved workflows with their steps and **Execute now**, **Edit** and **Delete** actions. Choose **New** or **Edit** to open the separate **Editor** tab. Switching back to Workflows preserves the editor draft; deleting requires confirmation of the named workflow. diff --git a/docs/credentials.md b/docs/credentials.md index 876bd75f..621ffc83 100644 --- a/docs/credentials.md +++ b/docs/credentials.md @@ -4,11 +4,11 @@ Administrators enable **Save passwords** in **Administration → Integrations**. ## Create and assign -Open **Hosts → Passwords**, beside SSH Keys. Give the entry a recognizable name, optionally suggest a username, and enter its password. Assign one entry to several of your SSH hosts, jump hosts or SMB sources. You can also create a password while configuring a new connection. Saving neither connects nor changes the password on the remote server. +Open **Hosts → Passwords**, beside SSH Keys. Give the entry a recognizable name, optionally suggest a username, and enter its password. Assign one entry to several of your SSH hosts, jump hosts, SOCKS5 proxies or SMB sources. You can also create a password while configuring a new connection. Saving neither connects nor changes the password on the remote server. The host's username remains authoritative. The same assigned SSH credential serves terminal, SFTP and supported Automation connections. In Quick Connect choose **Saved password** as the authentication method, then select an entry. Connecting authorizes that exact destination without creating a permanent host. Manual one-time input remains available. -The detail view shows assigned destinations. **Assign to hosts** opens a searchable checklist of password-authenticated SSH hosts with their addresses, usernames and current assignments. Select the hosts and apply the changes; filtering preserves your selection. If a batch partly fails, completed assignments remain visible and the remaining selections are preserved for retry. You can also select the saved password in a host, jump-host or SMB editor. Save pending password edits before changing assignments. +The detail view shows assigned destinations. **Assign to hosts** opens a searchable checklist of password-authenticated SSH hosts with their addresses, usernames and current assignments. Select the hosts and apply the changes; filtering preserves your selection. If a batch partly fails, completed assignments remain visible and the remaining selections are preserved for retry. You can also select the saved password in a host, jump-host, SOCKS5-proxy or SMB editor. Save pending password edits before changing assignments. Each saved resource has exactly one password assignment. Selecting a different entry shows the previous and new password names and asks for confirmation. A checklist batch presents all replacements together. Cancel leaves the assignments unchanged; concurrent assignment changes are rejected and require a fresh review. Applying the same password to the same destination preserves existing schedule authorization. @@ -16,7 +16,7 @@ Sending a saved password to a destination it is not yet assigned to is treated l **Replace password** accepts a new value; saved passwords are never returned by the API or displayed. Renaming does not replace a secret. Remove assignments before deleting an entry in use; assignments of deleted hosts no longer count. While an administrator has disabled password storage, you can still delete your entries, which also removes their assignments. -Changing address, port, username, authentication mode, jump route, SMB domain or share invalidates its destination approval. Explicitly approve the new target before reuse. Names and groups do not change its access identity. SSH host-key verification, ownership, interactive MFA, protocol policy and quotas remain enforced. +Changing address, port, username, authentication mode, jump or SOCKS route, SMB domain or share invalidates its destination approval. Changing a SOCKS proxy's remote-DNS setting also invalidates its binding and bindings that depend on that route. Explicitly approve the new target before reuse. Names and groups do not change its access identity. SSH host-key verification, ownership, interactive MFA, protocol policy and quotas remain enforced. The password list counts assignments that need review. Each assignment in the detail view shows **Ready** or **Review required**. Use **Review and authorize @@ -26,6 +26,27 @@ verification. Cancelling leaves the assignment unusable; a concurrently changed destination or assignment requires a fresh review. This does not test the remote password or bypass host-key verification. +## SOCKS5 proxy passwords + +A proxy configured for username/password authentication needs its own password, +separate from the target's SSH password. Enter it for that connection, or assign +a named password in **SOCKS5 Proxies** or **Passwords**. The proxy's saved username +is authoritative; a proxy using no authentication cannot receive a password +assignment. No password is written to `socks_proxies.json` or `profiles.json`. + +The assignment binds the proxy address, port, username, authentication type and +remote-DNS setting. Hosts using that proxy also bind its current route identity. +Proxy edits require reviewing affected assignments before reuse; hosts without +a SOCKS route keep their existing password bindings. + +SOCKS5 sends the proxy login unencrypted between WebSSH and the proxy. HTTPS to +the WebSSH browser does not encrypt that separate connection. Use proxy password +authentication only on trusted networks or over a separately secured transport. +The tunneled SSH connection, including its target password and SFTP traffic, +retains normal SSH encryption and host-key verification. SOCKS routes are not +supported by Automation or standalone Quick SFTP; use a terminal session's +Files source for SFTP through the proxy. + ## Availability and encryption New credentials have no separate passphrase or 15-minute unlock. The server decrypts them for authorized connections and explicitly approved schedules. The browser receives references, not saved passwords. @@ -38,6 +59,11 @@ Preserve the exact original **SECRET_KEY** securely with deployment recovery mat Native backup exports exclude named password records (`credentials.json`), legacy `automation-vault.json`, SSH key directories including metadata, and quarantined `deleted_users` data. Normal restore ignores these files even in older archives and removes existing copies to prevent credential reuse by a different restored account. Recreate passwords, reimport SSH keys and reassign credentials after restore; review and re-enable schedules. +Proxy metadata in `socks_proxies.json` is included in the native backup, but its +assigned passwords are excluded with the other named credentials. Recreate and +reassign proxy passwords after restore. A backup predating proxy metadata +restores with an empty proxy store. + Historical backups are not rewritten and may still contain replaced/deleted passwords and SSH keys. Rotate credentials on the actual servers when those copies must stop working. Private internal recovery snapshots retain encrypted credentials for failed-restore or secret-rotation rollback; they are separate from downloadable exports and require restricted access and their own retention and cleanup. Backups still retain account authentication data and may retain the application secret. Encrypt them separately and store them off-host. These exclusions do not detect arbitrary secrets embedded in commands, notes or run history. Never put secrets in commands, exported profiles, URLs, browser storage or logs. diff --git a/docs/wiki/Backup-Restore-and-Secret-Rotation.md b/docs/wiki/Backup-Restore-and-Secret-Rotation.md index 34807ff6..c7b3c5dd 100644 --- a/docs/wiki/Backup-Restore-and-Secret-Rotation.md +++ b/docs/wiki/Backup-Restore-and-Secret-Rotation.md @@ -8,6 +8,11 @@ Backups remain highly sensitive even though native exports exclude saved remote The archive covers persistent `DATA_DIR` configuration and account state. Native exports exclude named password stores (`credentials.json`), legacy `automation-vault.json`, SSH key directories (both metadata and private keys), and quarantined `deleted_users` data. Transient logs, temporary uploads, incomplete transfers, and runtime-only SSH channels are also excluded. +SOCKS5 proxy metadata (`users/user_/socks_proxies.json`) and profile route +references are included. Proxy passwords are excluded with the named password +store; the proxy file contains only connection metadata. Older backups without +this file restore with an empty proxy store. + These are specific file exclusions, not general secret detection: commands, notes, profiles and retained run history can still contain manually embedded secrets. Historical archives are not rewritten and may still contain remote credentials. Protect their retention and rotate remote credentials when old copies must stop working. Current archives use format version 2. The verifier also understands legacy format version 1/schema 0 for compatibility. Validation checks the manifest, file checksums, declared sizes, member paths, member count, compression behavior, and archive-wide limits before restore. @@ -29,7 +34,7 @@ Do not rely on the short-lived browser download as retention. Move the archive i ## Restore in the web interface -Restore is deliberately disruptive and strongly confirmed. Normal web and CLI restore ignore excluded credential files even in older archives and clear existing copies to prevent accidental reuse by a different restored account. After restore, reimport SSH keys, recreate saved remote passwords and reassign credentials to hosts, jump hosts and SMB sources. Review and explicitly re-enable schedules before unattended execution. +Restore is deliberately disruptive and strongly confirmed. Normal web and CLI restore ignore excluded credential files even in older archives and clear existing copies to prevent accidental reuse by a different restored account. After restore, reimport SSH keys, recreate saved remote passwords and reassign credentials to hosts, jump hosts, SOCKS5 proxies and SMB sources. Review and explicitly re-enable schedules before unattended execution. The recovery checklist marks missing SOCKS proxies for review; it never reconnects a routed host directly or restores an excluded proxy password. Online restore is enabled only when `BACKUP_TEMP_DIR` is an absolute, private, durable directory outside `DATA_DIR` and `BACKUP_RECOVERY_DURABLE=true` records diff --git a/docs/wiki/Configuration-Reference.md b/docs/wiki/Configuration-Reference.md index 3fe58623..d74a16fe 100644 --- a/docs/wiki/Configuration-Reference.md +++ b/docs/wiki/Configuration-Reference.md @@ -50,11 +50,36 @@ Prefer `flask create-admin` for controlled deployments. Public registration is i | `BLOCK_INTERNAL_SSH` | Blocks SSH connections to loopback, private, link-local, and other protected targets according to the network policy | | `SSH_ALLOWED_INTERNAL_NETWORKS` | Comma-separated private networks (CIDR or single addresses) that stay reachable while `BLOCK_INTERNAL_SSH=true`; see below | | `PROXY_JUMP_REMOTE_DNS_ALLOWLIST` | Exact hostnames a trusted bastion may resolve remotely | +| `SOCKS_REMOTE_DNS_ALLOWLIST` | Comma-separated exact target hostnames a trusted SOCKS5 proxy may resolve when `remote_dns=true` and internal targets are blocked; empty by default | | `SERVER_OPERATIONS_ENABLED` | Optional authoritative environment override; otherwise Admin → Integrations controls reviewed, step-up protected service/container actions, process TERM and reboot. Product default: `false` | `BLOCK_INTERNAL_SSH=true` is appropriate for an Internet-facing gateway but can conflict with homelab use. Resolve and validate the exact target policy before enabling it. Host-key verification remains a separate control. -`SSH_ALLOWED_INTERNAL_NETWORKS` keeps `BLOCK_INTERNAL_SSH=true` in place and re-admits only the listed private networks, for example `10.20.0.0/16,fd00:20::/64`. It applies to direct targets, Jump Hosts and file-transfer connections, and it is accepted by the production profile. Entries must lie inside `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `100.64.0.0/10` or `fc00::/7`; host bits, broader networks or any other range stop startup. Loopback, link-local (including cloud metadata addresses), multicast, reserved and IPv4-mapped IPv6 addresses always stay blocked. Every connection admitted through the list is recorded in the audit log. +`SSH_ALLOWED_INTERNAL_NETWORKS` keeps `BLOCK_INTERNAL_SSH=true` in place and re-admits only the listed private networks, for example `10.20.0.0/16,fd00:20::/64`. It applies to direct targets, Jump Hosts, SOCKS5 proxy endpoints, locally validated SOCKS destinations and file-transfer connections, and it is accepted by the production profile. Entries must lie inside `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `100.64.0.0/10` or `fc00::/7`; host bits, broader networks or any other range stop startup. Loopback, link-local (including cloud metadata addresses), multicast, reserved and IPv4-mapped IPv6 addresses always stay blocked. Every connection admitted through the list is recorded in the audit log. + +`SOCKS_REMOTE_DNS_ALLOWLIST` is independent of the ProxyJump allowlist. Names are +canonicalized at startup; wildcards, IP literals and names with a port are +rejected. An entry permits remote resolution of that exact target name through +any saved proxy whose own endpoint passes policy. It does not permit access to +an otherwise blocked proxy endpoint. For example, an operator who trusts the +proxy at `10.20.0.10:1080` to resolve one private target could configure: + +```dotenv +BLOCK_INTERNAL_SSH=true +SSH_ALLOWED_INTERNAL_NETWORKS=10.20.0.10/32 +SOCKS_REMOTE_DNS_ALLOWLIST=db.private.example +``` + +Save the proxy separately in the UI and enable its **Resolve host names through +the proxy** option. WebSSH cannot validate the final IP chosen by remote DNS; +the example delegates that decision to the proxy. Without an exact allowlist +match, names are resolved and policy-checked locally instead. IP-literal +targets always follow address policy. See the complete +[SOCKS5 routing table](SSH-Connections-and-Host-Keys#socks5-proxies). + +Loopback proxies require `BLOCK_INTERNAL_SSH=false` and cannot be re-admitted +through `SSH_ALLOWED_INTERNAL_NETWORKS` in production. No `ALL_PROXY` or other +global proxy environment setting selects a route; hosts reference saved proxies. ## Runtime capacity @@ -189,14 +214,22 @@ reflected. |---|---:| | `PROFILE_MAX_RECORDS` | `500` | | `JUMP_HOST_MAX_RECORDS` | `100` | +| `SOCKS_PROXY_MAX_RECORDS` | `100` (supported range `10`–`1000`) | | `CONNECTION_STORE_MAX_BYTES` | `2097152` (2 MiB per store) | | `CONNECTION_CONFIG_MAX_BYTES` | `4194304` (4 MiB combined) | +The per-store byte limit applies separately to `profiles.json`, `jump_hosts.json` +and `socks_proxies.json`. The combined limit counts all three files for one user; +it is not a separate allowance for each route type. The SOCKS record limit also +contributes to the minimum permitted `CONNECTION_STORE_RECOVERY_MAX_RECORDS`. + Legacy stores above a normal limit are quarantined from the browser UI. After stopping every WebSSH process, `flask --app start connection-store list` and `connection-store delete` provide a non-secret, bounded recovery path up to the separate recovery ceilings shown above. Growth is rejected, and profile -and jump-host key references remain ownership checked. +and route/key references remain ownership checked. Recovery accepts +`--kind profiles`, `--kind jump-hosts` or `--kind socks-proxies`; route deletion remains +blocked while profiles reference the record. ## Feature switches and tmux diff --git a/docs/wiki/Data-Storage-and-Persistence.md b/docs/wiki/Data-Storage-and-Persistence.md index 7c9b8ffc..fdd252a8 100644 --- a/docs/wiki/Data-Storage-and-Persistence.md +++ b/docs/wiki/Data-Storage-and-Persistence.md @@ -23,6 +23,7 @@ DATA_DIR/ | |-- commands.json | |-- command_sets.json | |-- jump_hosts.json +| |-- socks_proxies.json | |-- settings.json | |-- notes.json | |-- host_context.json @@ -49,23 +50,64 @@ SQLite is part of the one-process architecture. Do not run multiple independent ## Per-user files -Profiles, commands, command sets, jump hosts, application settings, notes, encrypted SSH keys, and known-host trust are isolated below `users/user_/`. Every application access must also enforce authenticated user ownership; path separation alone is not the authorization control. +Profiles, commands, command sets, jump hosts, SOCKS5 proxies, application settings, notes, encrypted SSH keys, and known-host trust are isolated below `users/user_/`. Every application access must also enforce authenticated user ownership; path separation alone is not the authorization control. + +### SOCKS5 route metadata + +`socks_proxies.json` contains a `schema_version` of `2` and a `socks_proxies` +array. Each record contains a UUID `id`, `name`, `host`, integer `port`, +`auth_type` (`none` or `password`), `username`, boolean `remote_dns`, and +`created_at`. Password authentication requires a username of 1–255 UTF-8 bytes; +without authentication the username is empty. IDs are unique. Malformed records, +duplicate IDs and unknown fields are rejected without overwriting the store. + +Profiles contain only an optional `socks_proxy_id` reference. The server resolves +that reference from the owning account's live store before saving or connecting. +It cannot coexist with a jump-host route or Tailscale SSH. Proxy passwords are +not part of either JSON document: reusable passwords belong to the encrypted +`credentials.json` store, with a separate `socks:` resource assignment. + +Profile and route mutations share the `command-config:` coordinator +before their store locks. All three connection files count toward +`CONNECTION_CONFIG_MAX_BYTES`; `CONNECTION_STORE_MAX_BYTES` bounds each file. +A missing proxy file represents an empty store, including after restoring an +older backup. A corrupt file is never treated as an empty one. ## Atomic JSON updates JSON-backed state follows a full load-modify-save cycle while holding the shared storage lock. Writes use an atomic temporary-file replacement and filesystem synchronization. Corrupt JSON is not silently replaced with an empty default because doing so could turn a recoverable incident into permanent data loss. Do not edit these files while WebSSH is running. Use the UI or supported APIs. -For an oversized legacy profile or jump-host store, stop every process and use +For an oversized profile, jump-host or SOCKS5-proxy store, stop every process and use the `connection-store` Flask CLI so recovery remains inside the configured hard byte and record ceilings; avoid hand-editing JSON. +For example, list bounded proxy summaries and delete one exact record with the +selector returned by that listing: + +```bash +python -m flask --app start connection-store list \ + --username USER --kind socks-proxies --confirm-offline +python -m flask --app start connection-store delete \ + --username USER --kind socks-proxies --selector SELECTOR --confirm-offline +``` + +Recovery still refuses to delete a proxy referenced by saved hosts. It permits +shrinking an oversized store, never growing it, and enforces the separate hard +recovery ceilings before and after parsing. List again after each deletion. + ## Additive migrations -Persisted JSON schemas are migrated additively. Profiles currently use schema version 3; command sets, jump hosts, keys, settings, application settings, and SMB shares use version 2. Before changing a file, migration creates a private backup and writes the upgraded representation atomically. The profile v3 migration removes transient Tailscale launch-authorization state from disk; that state is derived from the live server policy for each response. +Persisted JSON schemas are migrated additively. Profiles currently use schema version 3; command sets, jump hosts, SOCKS5 proxies, keys, settings, application settings, and SMB shares use version 2. Before changing a file, migration creates a private backup and writes the upgraded representation atomically. The profile v3 migration removes transient Tailscale launch-authorization state from disk; that state is derived from the live server policy for each response. Database changes likewise preserve existing installations. Always take a verified native backup before upgrading across versions. +Saved SSH sessions add nullable `socks_proxy_id` and boolean `via_socks` columns. +The additive migration leaves existing rows without a proxy and sets +`via_socks=false`; it does not rewrite existing connection profiles. SOCKS proxy +JSON migrations from unversioned data through versions 1 and 2 only add or advance +the schema marker. + ### Workspace upgrade and rollback The workspace update adds a nullable `profile_id` column to saved SSH sessions. diff --git a/docs/wiki/Profiles-Jump-Hosts-and-Commands.md b/docs/wiki/Profiles-Jump-Hosts-and-Commands.md index f2a1ed38..de4481ba 100644 --- a/docs/wiki/Profiles-Jump-Hosts-and-Commands.md +++ b/docs/wiki/Profiles-Jump-Hosts-and-Commands.md @@ -1,8 +1,8 @@ # Profiles, Jump Hosts and Commands Saved connection data is private to each WebSSH account. Profiles intentionally -exclude plaintext SSH and jump-host passwords. Optional saved passwords are -encrypted separately and assigned to hosts or jump hosts; see +exclude plaintext SSH, jump-host and SOCKS5 proxy passwords. Optional saved +passwords are encrypted separately and assigned to the corresponding host or route; see [Credentials](../credentials.md). ## Find hosts in a network @@ -32,7 +32,7 @@ A profile can store: - display name; - hostname, port, and remote username; - selected stored key; -- selected jump host; +- selected jump host or SOCKS5 proxy; - tmux preference; - Tailscale authorization mode when allowed; - post-connect startup behavior; @@ -44,7 +44,7 @@ Profiles that need no password, such as a usable stored key or authorized Tailscale mode, can launch directly from an empty pane. Use **Duplicate** in the Hosts manager to create a new draft from an existing -profile. The draft copies connection, authentication-reference, jump-host, +profile. The draft copies connection, authentication-reference, route, group, and post-connect settings, adds a localized “copy” suffix to the bounded name, and has no source profile ID. Saving therefore creates a separate profile and never overwrites the original. Passwords remain excluded because @@ -57,7 +57,7 @@ session's **Notes** tools. Host notes are separate from the global notepad. Each saved profile has its own private context, even when two profiles use the same hostname. A session opened without its saved host, for example through Quick Connect, uses the context of the saved host with the same destination, -user and jump host; with several, the most recently edited one. A connection +user and route; with several, the most recently edited one. A connection that matches no saved host has no context. Context supports up to 100000 characters of notes, 20 favorite absolute remote @@ -119,9 +119,61 @@ Host; update those profiles first. Use least privilege on the bastion and target. The presence of a jump host does not bypass target network policy, session ownership, or host-key verification. -Saved profiles and jump hosts have per-field, record-count, and serialized-byte -budgets. The defaults permit 500 profiles, 100 jump hosts, 2 MiB per store, and -4 MiB combined. Mutations share a per-user rate limit. The normal UI +For bastion-only DNS names that cannot resolve locally, configure an exact +`PROXY_JUMP_REMOTE_DNS_ALLOWLIST`. Do not use wildcards. + +## SOCKS5 proxies + +Open **Hosts > SOCKS5 Proxies** to save a proxy once and reuse it for several +hosts. Enter a name, hostname or IP address, and port (default `1080`). Choose +**No authentication** or **Username and password**. Password login requires a +username of 1–255 UTF-8 bytes; the SSH target keeps its own username and +authentication method. + +In the connection dialog or host editor, choose the proxy under **Route**. +A host uses one route: direct, one jump host, or one SOCKS5 proxy. Clear an +existing jump-host selection before choosing a proxy. Tailscale SSH uses its +own direct connection and cannot be combined with either route. + +**Resolve host names through the proxy** is enabled by default. When internal +targets are blocked, only exact names in the administrator's separate +`SOCKS_REMOTE_DNS_ALLOWLIST` use remote DNS; other names must resolve locally to +an allowed address. See the [DNS decision table](SSH-Connections-and-Host-Keys#socks5-proxies) +before enabling this for a production instance. + +Enter the proxy password when connecting, or explicitly assign a saved password +when password storage is enabled. Proxy records never contain passwords. +SOCKS5 sends its username and password unencrypted between WebSSH and the proxy; +use password login only over a trusted network or a separately protected path. +SSH authentication and traffic to the target remain encrypted, and target +host-key verification still applies. + +Terminal sessions, their SFTP browsing and transfers, and terminal reconnects +retain the selected route. The session shows **via SOCKS5**. Overview +reachability checks contact only the proxy endpoint: a reachable proxy does not +prove that it can reach or authenticate to the target. A missing proxy requires +review, and a failed proxy never causes a direct connection attempt. + +**Edit** keeps the proxy's ID; dependent hosts use its current configuration on +their next connection. Existing sessions are not restarted. **Delete** requires +confirmation and is blocked while saved hosts reference the proxy; the error +lists up to 20 affected host names and the total count. If a save times out, +check the saved list before retrying because the write may already have succeeded. + +SOCKS5 routes are unsupported in standalone **Quick SFTP** and Automation runs +or schedules. They cannot be combined with Tailscale SSH or a jump-host chain. +For SFTP through a proxy, first open +a terminal session and use that session's Files source. Only SOCKS5 TCP CONNECT +with no authentication or username/password is supported; SOCKS4, HTTP CONNECT, +UDP, BIND and GSSAPI are not supported. WebSSH does not use `ALL_PROXY` or execute +`ProxyCommand`. + +## Connection storage limits and recovery + +Saved profiles, jump hosts and SOCKS5 proxies have per-field, record-count, and +serialized-byte budgets. Defaults permit 500 profiles, 100 jump hosts and +100 SOCKS5 proxies, 2 MiB per store, and 4 MiB combined across all three stores. +Mutations share a per-user rate limit. The normal UI quarantines a legacy store above those limits so a large payload cannot be expanded through Socket.IO. Stop every WebSSH process, then use the bounded recovery CLI to discover non-secret record summaries and opaque selectors, then @@ -134,17 +186,14 @@ python -m flask --app start connection-store delete \ --username USER --kind profiles --selector SELECTOR --confirm-offline ``` -Use `--kind jump-hosts` for the jump-host store. Recovery enforces the separate -byte and record ceilings before and after parsing, never prints startup -commands or unknown fields, and retains jump-host reference protection. The -opaque selector binds one record's current ordinal and content, so repeated or +Use `--kind jump-hosts` for jump hosts or `--kind socks-proxies` for SOCKS5 proxies. +Recovery enforces the separate byte and record ceilings before and after parsing, +never prints startup commands or unknown fields, and retains both route stores' +reference protection. The opaque selector binds one record's current ordinal and content, so repeated or truncated display IDs cannot delete the wrong record; list again after every successful deletion. No mutation may grow an oversized store. Stored key references must belong to the same WebSSH account. -For bastion-only DNS names that cannot resolve locally, configure an exact -`PROXY_JUMP_REMOTE_DNS_ALLOWLIST`. Do not use wildcards. - ## Command library The command library stores named commands and optional operating-system scope: @@ -276,8 +325,8 @@ behavior. ## Data integrity -Profiles, jump hosts, commands, command sets, and settings are JSON stores below -the owning user's data directory. Updates perform a complete load-modify-save +Profiles, jump hosts, SOCKS5 proxies, commands, command sets, and settings are +JSON stores below the owning user's data directory. Updates perform a complete load-modify-save cycle under a logical storage lock and replace files atomically. Corrupt JSON is reported as storage corruption instead of silently becoming an @@ -321,10 +370,19 @@ mapped again at import. Large exports download a ZIP containing bounded JSON batches; extract it and import each JSON file separately. Metadata still contains hostnames and usernames, so share it only with intended recipients. +SOCKS5 routes are not portable in this exchange format. Each affected host is +exported with `socks_route_omitted: true` and **SOCKS5 route not exported**; +proxy IDs and credentials are omitted. The import preview repeats the warning +and blocks that host, so it cannot silently become a direct connection. Recreate +the host and explicitly select an owned proxy instead. Raw `socks_proxy_id` or +inline proxy fields in an import are rejected. A `ProxyCommand` that invokes a +SOCKS client is still unsupported and is never evaluated. + ### Profile launch still asks for a password -The profile depends on a password for the target or jump host, or the selected -stored key is unavailable. Passwords are deliberately not persisted. +The profile needs a password for the target, jump host or SOCKS5 proxy, or the +selected stored key is unavailable. Passwords are not stored in profiles or +route records; explicitly saved passwords live in the separate encrypted store. ### A post-connect set did not run diff --git a/docs/wiki/SSH-Connections-and-Host-Keys.md b/docs/wiki/SSH-Connections-and-Host-Keys.md index 1ceceed2..35c2fc06 100644 --- a/docs/wiki/SSH-Connections-and-Host-Keys.md +++ b/docs/wiki/SSH-Connections-and-Host-Keys.md @@ -11,7 +11,7 @@ Open **Quick Connect** or launch a saved profile. A connection includes: - SSH port, normally `22`; - remote operating-system username; - password, stored SSH key, or authorized Tailscale SSH mode; -- optional jump host; +- optional jump host or SOCKS5 proxy; - optional persistent tmux selection; - optional post-connect command or command set. @@ -181,6 +181,66 @@ profiles on subsequent connections; existing SSH sessions are not restarted. Deletion is blocked while profiles reference it. Reassign or remove those references before deleting the Jump Host. +## SOCKS5 proxies + +Save a reusable proxy in **Hosts > SOCKS5 Proxies** and select it under the +host's **Route** settings. WebSSH first opens a policy-checked TCP connection to +that proxy, negotiates SOCKS5 CONNECT, then starts the normal SSH connection +through the tunnel. The proxy has no SSH host key. The target's canonical name +and port remain its host-key identity, including when a validated IP address is +sent through SOCKS. Trust prompts, changed-key rejection and revocation remain +unchanged. + +The proxy endpoint follows `BLOCK_INTERNAL_SSH` and +`SSH_ALLOWED_INTERNAL_NETWORKS` exactly like a direct SSH destination. There is +no loopback exception: a local `127.0.0.1:9050` or `ssh -D` listener requires +`BLOCK_INTERNAL_SSH=false`, which is incompatible with the production profile. +Inside a container, `127.0.0.1` refers to that container, not the Docker host. + +The proxy's **Resolve host names through the proxy** setting (`remote_dns`) +defaults to `true`. Target routing follows this table; local validation also +honors `SSH_ALLOWED_INTERNAL_NETWORKS` when internal addresses are blocked. + +| Target | `remote_dns` | `BLOCK_INTERNAL_SSH` | Destination sent to the proxy | +|---|---|---|---| +| IPv4 or IPv6 literal | Either | `false` | The IP address | +| IPv4 or IPv6 literal | Either | `true` | The IP address only if network policy allows it | +| Hostname | `false` | Either | A locally resolved, policy-validated IP address | +| Hostname | `true` | `false` | The canonical hostname; no local DNS query | +| Hostname exactly in `SOCKS_REMOTE_DNS_ALLOWLIST` | `true` | `true` | The canonical hostname; no local DNS query | +| Other hostname | `true` | `true` | A locally resolved, policy-validated IP address; failure rejects the connection | + +`SOCKS_REMOTE_DNS_ALLOWLIST` is separate from +`PROXY_JUMP_REMOTE_DNS_ALLOWLIST`: permission to use a bastion's DNS does not +grant permission to use a SOCKS proxy's DNS. Both lists match exact canonical +hostnames, not suffixes; wildcards and IP literals are invalid. A SOCKS allowlist +entry applies to that target name through any otherwise allowed saved proxy, +not to a single proxy ID. Review which proxies your users can reach. + +Remote DNS is an explicit trust decision. WebSSH cannot inspect the target IP +chosen by the proxy, so its local private-address policy cannot constrain that +resolution. Allow only names and proxy networks you trust; leave the allowlist +empty if this delegation is not acceptable. The proxy's returned bound address +does not grant network-policy permission. Remote-DNS allowances and private +network exceptions are audited. + +SOCKS username/password authentication is unencrypted on the WebSSH-to-proxy +connection. Use it only on a trusted network or over a separately protected +transport. The target's SSH credentials and session remain encrypted end to +end; the proxy can still observe the requested destination. Proxy passwords are +entered for a connection or explicitly saved in the encrypted +[password store](../credentials.md), never in profiles or proxy metadata. + +Terminal sessions and their SFTP channels share the tunnel. Reconnects resolve +the saved proxy again from the current user's store. Failed or missing proxies +never fall back to direct SSH. Overview checks test only the proxy endpoint, +not target reachability, SSH authentication or host trust. + +Standalone Quick SFTP and Automation do not support SOCKS routes. SOCKS cannot +be combined with a jump host or Tailscale SSH. Only SOCKS5 TCP CONNECT with no +authentication or username/password is supported; `ProxyCommand`, environment +proxy variables, SOCKS4 and HTTP CONNECT are not connection mechanisms here. + ## Connection ownership Every live SSH session is owned by one WebSSH user. Socket.IO input, resize, diff --git a/scripts/run_integration_tests.py b/scripts/run_integration_tests.py index cde2f58f..49dc9529 100644 --- a/scripts/run_integration_tests.py +++ b/scripts/run_integration_tests.py @@ -19,6 +19,7 @@ "tests/integration/test_paramiko5_socketio.py", # The Socket.IO singleton fixture owns its initial server registration. "tests/integration/test_automation_openssh.py", + "tests/integration/test_socks_openssh.py", ] @@ -85,6 +86,9 @@ def main(*, browser=False) -> int: "PARAMIKO5_PROXY_TARGET_HOST": "target", "PARAMIKO5_PROXY_TARGET_PORT": "22", "PROXY_JUMP_REMOTE_DNS_ALLOWLIST": "target", + "PARAMIKO5_SOCKS_TARGET_HOST": "target", + "PARAMIKO5_SOCKS_TARGET_PORT": "22", + "SOCKS_REMOTE_DNS_ALLOWLIST": "target", "PARAMIKO5_BASTION_HOST": "127.0.0.1", "PARAMIKO5_BASTION_PORT": "2222", "PARAMIKO5_CHANGED_HOST": "127.0.0.1", 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..8fa4f4a5 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; @@ -479,6 +507,17 @@ const label = document.createElement('span'); label.className = 'recent-conn-label'; label.textContent = `${conn.username}@${conn.host}:${conn.port}`; + if (conn.socks_proxy_id) { + const proxy = window.SocksProxyManager?.getById(conn.socks_proxy_id); + const route = proxy + ? (window.i18n?.t('connection.socksProxyVia') || 'via SOCKS5 {name}').replace('{name}', proxy.name) + : window.i18n?.t('connection.socksProxyMissing') || 'SOCKS5 proxy missing'; + label.textContent += ` · ${route}`; + } else if (conn.jump_host_id) { + const jump = window.JumpHostManager?.getById(conn.jump_host_id); + label.textContent += ` · ${window.i18n?.t('connection.jumpHost') || 'Jump Host'}${jump ? ': ' + jump.name : ''}`; + } + option.setAttribute('aria-label', label.textContent); const time = document.createElement('span'); time.className = 'recent-conn-time'; time.textContent = this.formatTime(conn.timestamp); @@ -488,6 +527,12 @@ document.getElementById('hostInput').value = conn.host; document.getElementById('portInput').value = conn.port; document.getElementById('usernameInput').value = conn.username; + if (conn.socks_proxy_id || conn.jump_host_id) { + window.prepareSessionReconnectJump({ + socksProxyId: conn.socks_proxy_id, viaSocks: Boolean(conn.socks_proxy_id), + jumpHostId: conn.jump_host_id, reconnectRouteKnown: true, + }, {applyToForm: true}); + } document.getElementById('passwordInput').focus(); }); container.appendChild(option); @@ -1785,7 +1830,10 @@ : `Connected to ${data.username}@${data.host}`; showNotification(connMsg, 'success'); - ConnectionHistory.addConnection(data.host, data.port, data.username); + ConnectionHistory.addConnection(data.host, data.port, data.username, { + socks_proxy_id: data.socks_proxy_id, via_socks: data.via_socks, + jump_host_id: data.jump_host_id, via_jump: data.via_jump, + }); }); @@ -1825,6 +1873,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 +1967,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 +1992,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 +2142,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 +2179,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 +2200,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 +2216,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 +2416,7 @@ getContext: () => ({ keys: ProfileManager.keys, jumpHosts: window.JumpHostManager?.jumpHosts || [], + socksProxies: window.SocksProxyManager?.socksProxies || [], }), getDefaultPaneIndex, isBusy: () => Boolean(currentConnectRequestId), @@ -3063,6 +3132,7 @@ } if (inventory) { window.JumpHostManager?.setJumpHosts(inventory.jump_hosts); + window.SocksProxyManager?.setSocksProxies(inventory.socks_proxies); ProfileManager.setProfiles(inventory.profiles); } else { ProfileManager.loadProfiles(); @@ -3158,6 +3228,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 +3313,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 +3356,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-history.js b/static/js/connection-history.js index 17b8a5ce..1870aeee 100644 --- a/static/js/connection-history.js +++ b/static/js/connection-history.js @@ -20,7 +20,18 @@ return null; } if (!Number.isFinite(timestamp) || timestamp <= 0) return null; - return { host, port, username, timestamp }; + const route = {}; + for (const field of ['socks_proxy_id', 'jump_host_id']) { + const value = entry[field]; + if (value === undefined || value === null || value === '') continue; + if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(value)) return null; + route[field] = value; + } + // Unknown or conflicting routes must not become direct history entries. + if ((entry.via_socks && !route.socks_proxy_id) + || (entry.via_jump && !route.jump_host_id) + || (route.socks_proxy_id && route.jump_host_id)) return null; + return { host, port, username, timestamp, ...route }; } function createConnectionHistory(options = {}) { @@ -111,15 +122,19 @@ } } - function addConnection(host, port, username) { + function addConnection(host, port, username, route = {}) { if (!storageKey) return; - const entry = normalizeEntry({ host, port, username, timestamp: now() }); + const entry = normalizeEntry({ host, port, username, timestamp: now(), + socks_proxy_id: route?.socks_proxy_id, jump_host_id: route?.jump_host_id, + via_socks: route?.via_socks, via_jump: route?.via_jump }); if (!entry) return; const history = getHistory().filter(item => !( item.host === entry.host && item.port === entry.port && item.username === entry.username + && item.socks_proxy_id === entry.socks_proxy_id + && item.jump_host_id === entry.jump_host_id )); history.unshift(entry); persist(history.slice(0, maxItems)); 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..32b98ab7 100644 --- a/static/js/credential-picker.js +++ b/static/js/credential-picker.js @@ -7,6 +7,7 @@ const native = state?.enabled && state.credentials?.some(item => item.uses?.some(use => String(use.resource_id || use.id) === String(id) && use.valid !== false)); const legacy = state?.enabled !== false && state?.vault?.unlocked && state.vault.credentials?.some(item => String(item.profile_id) === String(id) && item.valid === true); return Boolean(id && resource && (native || legacy) + && String(resource.socks_proxy_id || '') === String(target.socks_proxy_id || '') && Object.entries(target).every(([key, value]) => key === 'port' ? Number(resource[key] || 22) === Number(value || 22) : String(resource[key] || '') === String(value || ''))); @@ -82,7 +83,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 +162,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, ...(value('socksProxySelect') ? {socks_proxy_id: value('socksProxySelect')} : {})}), '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/customer-workflows-i18n.js b/static/js/customer-workflows-i18n.js index a300ec28..3c9efb6c 100644 --- a/static/js/customer-workflows-i18n.js +++ b/static/js/customer-workflows-i18n.js @@ -20,7 +20,7 @@ "customer.excluded": "Portable backups exclude saved remote passwords, SSH private keys and live sessions. Re-provision remote credentials after restore.", "customer.restoredAt": "Last restore:", "customer.recoveryKeys": "For each account, securely re-add missing SSH keys and saved remote passwords. Never paste secrets into logs or commands.", - "customer.recoveryHosts": "Review each host, authentication method and jump host. Verify host-key fingerprints through a trusted channel before accepting new trust.", + "customer.recoveryHosts": "Review each host, authentication method and jump host or SOCKS5 proxy. Verify host-key fingerprints through a trusted channel before accepting new trust.", "customer.recoverySchedules": "Review your schedules, target hosts and credentials. Complete any required approval before deliberately enabling a schedule.", "customer.recoveryTest": "Explicitly connect to a host and review authentication and host trust. Start with a harmless inspection before running changes.", "customer.editHost": "Edit host", @@ -32,6 +32,7 @@ "customer.password_required": "Remote password required", "customer.interactive": "Interactive authentication required when connecting", "customer.missingJump": "Referenced jump host is missing", + "customer.missingSocks": "Referenced SOCKS5 proxy is missing", "customer.trustReview": "Host-key trust needs review", "customer.prepared": "Configuration present; connection not tested.", "customer.presetsTitle": "Workspace presets", @@ -82,7 +83,7 @@ "customer.excluded": "Portable Backups enthalten keine gespeicherten Remote-Passwörter, privaten SSH-Schlüssel oder laufenden Sitzungen. Remote-Zugangsdaten nach dem Restore neu bereitstellen.", "customer.restoredAt": "Letzte Wiederherstellung:", "customer.recoveryKeys": "Fehlende SSH-Schlüssel und gespeicherte Remote-Passwörter für jedes Konto sicher neu hinterlegen. Geheimnisse nie in Logs oder Befehle kopieren.", - "customer.recoveryHosts": "Hosts, Anmeldeverfahren und Jump Hosts prüfen. Host-Key-Fingerprints vor neuer Vertrauensfreigabe über einen vertrauenswürdigen Kanal vergleichen.", + "customer.recoveryHosts": "Hosts, Anmeldeverfahren und Jump Hosts oder SOCKS5-Proxys prüfen. Host-Key-Fingerprints vor neuer Vertrauensfreigabe über einen vertrauenswürdigen Kanal vergleichen.", "customer.recoverySchedules": "Eigene Zeitpläne, Zielhosts und Zugangsdaten prüfen. Erforderliche Freigaben vor dem bewussten Aktivieren eines Zeitplans erneuern.", "customer.recoveryTest": "Einen Host ausdrücklich verbinden und Anmeldung sowie Host-Vertrauen prüfen. Vor Änderungen zunächst einen harmlosen Prüfbefehl verwenden.", "customer.editHost": "Host bearbeiten", @@ -94,6 +95,7 @@ "customer.password_required": "Remote-Passwort erforderlich", "customer.interactive": "Interaktive Anmeldung beim Verbinden erforderlich", "customer.missingJump": "Referenzierter Jump Host fehlt", + "customer.missingSocks": "Referenzierter SOCKS5-Proxy fehlt", "customer.trustReview": "Host-Key-Vertrauen prüfen", "customer.prepared": "Konfiguration vorhanden; Verbindung nicht getestet.", "customer.presetsTitle": "Arbeitsbereich-Vorlagen", @@ -144,7 +146,7 @@ "customer.excluded": "Les sauvegardes portables excluent les mots de passe distants enregistrés, les clés privées SSH et les sessions actives. Rétablissez les identifiants distants après restauration.", "customer.restoredAt": "Dernière restauration :", "customer.recoveryKeys": "Pour chaque compte, réajoutez de façon sûre les clés SSH et mots de passe distants manquants. Ne placez jamais de secrets dans les journaux ou commandes.", - "customer.recoveryHosts": "Vérifiez les hôtes, méthodes d’authentification et bastions. Contrôlez les empreintes des clés d’hôte par un canal fiable avant de les accepter.", + "customer.recoveryHosts": "Vérifiez les hôtes, méthodes d’authentification et bastions ou proxys SOCKS5. Contrôlez les empreintes des clés d’hôte par un canal fiable avant de les accepter.", "customer.recoverySchedules": "Vérifiez vos planifications, cibles et identifiants. Effectuez toute approbation requise avant d’activer volontairement une planification.", "customer.recoveryTest": "Connectez-vous explicitement à un hôte et vérifiez l’authentification et sa clé. Commencez par une inspection sans modification.", "customer.editHost": "Modifier l’hôte", @@ -156,6 +158,7 @@ "customer.password_required": "Mot de passe distant requis", "customer.interactive": "Authentification interactive requise à la connexion", "customer.missingJump": "Le bastion référencé est absent", + "customer.missingSocks": "Le proxy SOCKS5 référencé est absent", "customer.trustReview": "Confiance dans la clé d’hôte à vérifier", "customer.prepared": "Configuration présente ; connexion non testée.", "customer.presetsTitle": "Espaces de travail prédéfinis", @@ -206,7 +209,7 @@ "customer.excluded": "Las copias portátiles excluyen contraseñas remotas guardadas, claves privadas SSH y sesiones activas. Vuelva a configurar las credenciales remotas tras restaurar.", "customer.restoredAt": "Última restauración:", "customer.recoveryKeys": "En cada cuenta, vuelva a añadir de forma segura las claves SSH y contraseñas remotas que falten. Nunca copie secretos en registros o comandos.", - "customer.recoveryHosts": "Revise los hosts, métodos de autenticación y servidores de salto. Verifique las huellas de las claves mediante un canal fiable antes de aceptarlas.", + "customer.recoveryHosts": "Revise los hosts, métodos de autenticación y servidores de salto o proxies SOCKS5. Verifique las huellas de las claves mediante un canal fiable antes de aceptarlas.", "customer.recoverySchedules": "Revise sus programaciones, destinos y credenciales. Complete las aprobaciones necesarias antes de activar una programación de forma deliberada.", "customer.recoveryTest": "Conecte explícitamente a un host y compruebe la autenticación y su clave. Empiece por una consulta inocua antes de realizar cambios.", "customer.editHost": "Editar host", @@ -218,6 +221,7 @@ "customer.password_required": "Se requiere contraseña remota", "customer.interactive": "Se requiere autenticación interactiva al conectar", "customer.missingJump": "Falta el servidor de salto indicado", + "customer.missingSocks": "Falta el proxy SOCKS5 indicado", "customer.trustReview": "Revise la confianza en la clave del host", "customer.prepared": "Configuración disponible; conexión sin probar.", "customer.presetsTitle": "Preajustes del espacio de trabajo", @@ -268,7 +272,7 @@ "customer.excluded": "Bản sao lưu di động không gồm mật khẩu máy từ xa đã lưu, khóa riêng SSH hoặc phiên đang chạy. Cung cấp lại thông tin xác thực từ xa sau khi khôi phục.", "customer.restoredAt": "Lần khôi phục gần nhất:", "customer.recoveryKeys": "Với từng tài khoản, thêm lại an toàn các khóa SSH và mật khẩu từ xa còn thiếu. Không dán bí mật vào nhật ký hoặc lệnh.", - "customer.recoveryHosts": "Kiểm tra máy chủ, phương thức xác thực và máy chủ trung chuyển. Xác minh dấu vân tay khóa máy chủ qua kênh tin cậy trước khi chấp nhận.", + "customer.recoveryHosts": "Kiểm tra máy chủ, phương thức xác thực và máy chủ trung chuyển hoặc proxy SOCKS5. Xác minh dấu vân tay khóa máy chủ qua kênh tin cậy trước khi chấp nhận.", "customer.recoverySchedules": "Kiểm tra lịch chạy, máy đích và thông tin xác thực của bạn. Hoàn tất phê duyệt cần thiết trước khi chủ động bật lịch chạy.", "customer.recoveryTest": "Chủ động kết nối tới máy chủ và kiểm tra xác thực, độ tin cậy khóa. Bắt đầu bằng kiểm tra không gây thay đổi trước khi sửa hệ thống.", "customer.editHost": "Sửa máy chủ", @@ -280,6 +284,7 @@ "customer.password_required": "Cần mật khẩu máy từ xa", "customer.interactive": "Cần xác thực tương tác khi kết nối", "customer.missingJump": "Thiếu máy chủ trung chuyển được tham chiếu", + "customer.missingSocks": "Thiếu proxy SOCKS5 được tham chiếu", "customer.trustReview": "Cần kiểm tra độ tin cậy khóa máy chủ", "customer.prepared": "Đã có cấu hình; chưa thử kết nối.", "customer.presetsTitle": "Mẫu không gian làm việc", @@ -330,7 +335,7 @@ "customer.excluded": "可移植备份不包含已保存的远程密码、SSH 私钥和活动会话。恢复后请重新配置远程凭据。", "customer.restoredAt": "上次恢复:", "customer.recoveryKeys": "请为每个账户安全地重新添加缺失的 SSH 密钥和已保存的远程密码。切勿将秘密粘贴到日志或命令中。", - "customer.recoveryHosts": "检查各主机、认证方式和跳板机。接受新的主机密钥前,请通过可信渠道核实指纹。", + "customer.recoveryHosts": "检查各主机、认证方式及跳板机或 SOCKS5 代理。接受新的主机密钥前,请通过可信渠道核实指纹。", "customer.recoverySchedules": "检查您的计划、目标主机和凭据。请先完成所需审批,再明确启用计划。", "customer.recoveryTest": "请主动连接主机并检查认证和主机信任。执行更改前,先进行无害的检查。", "customer.editHost": "编辑主机", @@ -342,6 +347,7 @@ "customer.password_required": "需要远程密码", "customer.interactive": "连接时需要交互式认证", "customer.missingJump": "引用的跳板机不存在", + "customer.missingSocks": "引用的 SOCKS5 代理不存在", "customer.trustReview": "需要检查主机密钥信任", "customer.prepared": "配置已存在;尚未测试连接。", "customer.presetsTitle": "工作区预设", diff --git a/static/js/customer-workflows.js b/static/js/customer-workflows.js index c4a5b83c..745b16b2 100644 --- a/static/js/customer-workflows.js +++ b/static/js/customer-workflows.js @@ -71,7 +71,7 @@ modal.body.append(list,coverage); for(const host of data.hosts) { const row=el('div','', 'customer-row'); - const problems=[host.credential==='ready' ? null : t(host.credential),host.route==='missing_jump'?t('missingJump'):null,host.host_key==='review_required'?t('trustReview'):null].filter(Boolean); + const problems=[host.credential==='ready' ? null : t(host.credential),host.route==='missing_jump'?t('missingJump'):host.route==='missing_socks'?t('missingSocks'):null,host.host_key==='review_required'?t('trustReview'):null].filter(Boolean); const content=el('div');content.append(el('strong',host.name),el('p',problems.join(' · ') || t('prepared'),'customer-muted'));row.append(content); if(root.ProfileManager) row.append(button(t('editHost'),()=>{modal.close(); root.document.getElementById('manageProfilesBtn')?.click();root.ProfileManager.openEditor(host.id);})); steps.recoveryHosts.append(row); 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..ac54fa2a 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); } @@ -1300,18 +1313,26 @@ const ProfileManager = { }, renderEditorSelects() { + const keySelect = document.getElementById('profileEditorKeySelect'); + const selectedKey = keySelect?.value; this._fillSelect( - document.getElementById('profileEditorKeySelect'), + keySelect, this.keys, this.t('connection.selectSSHKey', 'Select SSH Key'), key => `${key.name} (${key.key_type})`, ); + this.selectEditorKey(selectedKey); this._fillSelect( document.getElementById('profileEditorJumpHostSelect'), window.JumpHostManager?.jumpHosts || [], 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 || [], @@ -1327,6 +1348,21 @@ const ProfileManager = { this.renderEditorCommandPreview(); }, + selectEditorKey(keyId) { + const select = document.getElementById('profileEditorKeySelect'); + if (!select) return; + if (keyId && !Array.from(select.options).some(option => option.value === keyId)) { + // Keep the saved reference while its asynchronous inventory is loading. + const option = document.createElement('option'); + option.value = keyId; + option.textContent = this.keysLoaded + ? this.t('connection.readinessKey', 'Key missing or unavailable') + : this.t('operations.loading', 'Loading…'); + select.appendChild(option); + } + select.value = keyId || ''; + }, + duplicateProfileName(name) { const suffix = this.t('profiles.copyNameSuffix', ' (copy)'); const source = String(name || '').trim(); @@ -1415,8 +1451,9 @@ const ProfileManager = { document.getElementById('profileEditorUsername').value = profile?.username || ''; document.getElementById('profileEditorAuthType').value = profile?.auth_type || 'password'; - document.getElementById('profileEditorKeySelect').value = profile?.key_id || ''; + this.selectEditorKey(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 +1511,7 @@ const ProfileManager = { }, updateEditorVisibility() { + window.SocksProxyManager?.updateRouteControls(); const authType = document.getElementById('profileEditorAuthType')?.value; document.getElementById('profileEditorKeyGroup')?.classList.toggle( 'hidden', authType !== 'key' @@ -1563,8 +1601,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 +1638,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 +1686,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 38e3bd4c..5f115a23 100644 --- a/templates/index.html +++ b/templates/index.html @@ -745,12 +745,13 @@

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

Hosts

Connection resources

+
@@ -962,7 +979,7 @@

Connection

Advanced Settings

-

Jump host, post-connect actions, and persistence.

+

Route, post-connect actions, and persistence.

@@ -987,10 +1004,15 @@

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

+
Route
+
+
+ +

@@ -1100,6 +1123,7 @@

Manage SSH Keys

Connection resources

+ @@ -1288,6 +1312,7 @@

Manage Jump Hosts<

Connection resources

+
@@ -1345,6 +1370,75 @@

Saved Jump Hosts

+ +