diff --git a/README.md b/README.md index 05be6f7..b5e3696 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,10 @@ WebSSH keeps terminal work, files, commands, diagnostics, and notes in one responsive browser workspace. It is self-hosted, multi-user, and built without a hosted control plane or runtime CDN dependencies. +After an administrator enables the optional gateway integration in Settings → Integrations, +connect through an existing SSH gateway with a user:target username. Disabled by default. See the +[Warpgate integration guide](docs/warpgate.md) for authentication and target requirements. + ## Why WebSSH - **One workspace, not a terminal tab.** Keep SSH sessions, SFTP sources, diff --git a/app/__init__.py b/app/__init__.py index c9f7244..07f679d 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -22,7 +22,8 @@ get_user_settings, save_user_settings, ) -from .app_settings import is_registration_enabled, set_registration_enabled +from .app_settings import (is_registration_enabled, set_registration_enabled, + is_ssh_gateway_enabled, set_ssh_gateway_enabled) from .storage_errors import StorageCorruptionError from .tailscale_ssh import user_can_use_tailscale_ssh from .runtime_lifecycle import RuntimeLifecycle @@ -465,6 +466,7 @@ def inject_url_prefix(): return { 'url_prefix': url_prefix, 'registration_enabled': registration_available, + 'ssh_gateway_enabled': is_ssh_gateway_enabled(), 'tmux_enabled': config.TMUX_ENABLED, 'tmux_default': config.TMUX_DEFAULT, 'admin_panel_enabled': config.ADMIN_PANEL_ENABLED, @@ -725,6 +727,12 @@ def enforce_security_feature_gate(): transfer_runtime_binding ), ) + from .ssh_connection_attempt import SSHAttemptRegistry + ssh_attempts = SSHAttemptRegistry() + app.extensions['ssh_attempt_registry'] = ssh_attempts + app.extensions['runtime_lifecycle'].register_shutdown_callback( + 'ssh_attempts', lambda _deadline: ssh_attempts.shutdown(), + ) cors_origins = config.CORS_ORIGINS if isinstance(cors_origins, str): cors_origins = [origin.strip() for origin in cors_origins.split(',') if origin.strip()] @@ -1485,7 +1493,8 @@ def admin_audit(): @admin_required @login_required def admin_get_settings(): - return jsonify({'registration_enabled': is_registration_enabled()}) + return jsonify({'registration_enabled': is_registration_enabled(), + 'ssh_gateway_enabled': is_ssh_gateway_enabled()}) @app.route('/admin/api/settings', methods=['POST']) @admin_required @@ -1495,6 +1504,9 @@ def admin_set_settings(): data = request.get_json(silent=True) if not isinstance(data, dict): return jsonify({'error': 'Invalid settings payload'}), 400 + if ('ssh_gateway_enabled' in data + and type(data['ssh_gateway_enabled']) is not bool): + return jsonify({'error': 'ssh_gateway_enabled must be a boolean'}), 400 if 'registration_enabled' in data: if type(data['registration_enabled']) is not bool: return jsonify({ @@ -1513,7 +1525,12 @@ def admin_set_settings(): val = set_registration_enabled(data['registration_enabled']) log_info("Admin changed registration setting", admin=current_user.username, registration_enabled=val) - return jsonify({'registration_enabled': is_registration_enabled()}) + if 'ssh_gateway_enabled' in data: + val = set_ssh_gateway_enabled(data['ssh_gateway_enabled']) + log_info("Admin changed SSH gateway setting", + admin=current_user.username, ssh_gateway_enabled=val) + return jsonify({'registration_enabled': is_registration_enabled(), + 'ssh_gateway_enabled': is_ssh_gateway_enabled()}) @app.route('/admin/api/security-features', methods=['GET']) @admin_required diff --git a/app/app_settings.py b/app/app_settings.py index 045b113..777bc6a 100644 --- a/app/app_settings.py +++ b/app/app_settings.py @@ -16,6 +16,10 @@ def _valid_settings(value): return ( isinstance(value, dict) and value.get('schema_version') == CURRENT_STORAGE_VERSIONS['app_settings'] + and ( + 'ssh_gateway_enabled' not in value + or type(value['ssh_gateway_enabled']) is bool + ) and ( 'registration_enabled' not in value or type(value['registration_enabled']) is bool @@ -82,6 +86,23 @@ def get_audit_backup_count(): )) +def is_ssh_gateway_enabled(): + """Gateway authentication requires an explicit, persisted admin opt-in.""" + return _load().get('ssh_gateway_enabled') is True + + +def set_ssh_gateway_enabled(value): + if type(value) is not bool: + return False + with storage_lock(f'app-settings:{_SETTINGS_FILE}'): + data = _load_with_lock_held() + data['ssh_gateway_enabled'] = value + if not _valid_settings(data): + return False + _save(data) + return value + + def set_audit_backup_count(value): if type(value) is not int or not 1 <= value <= 90: return False diff --git a/app/connection_pool.py b/app/connection_pool.py index 2b09e1c..14d868d 100644 --- a/app/connection_pool.py +++ b/app/connection_pool.py @@ -68,7 +68,7 @@ def bind_lifecycle(self, lifecycle): ) return self.cleanup_handle - def create_connection(self, host, port, username, password=None, key_path=None, key_content=None, user_id=None): + def create_connection(self, host, port, username, password=None, key_path=None, key_content=None, user_id=None, gateway_attempt=None): """ Create a temporary SSH+SFTP connection. @@ -132,7 +132,21 @@ def create_connection(self, host, port, username, password=None, key_path=None, 'allow_agent': False } - if key_content: + if gateway_attempt is not None: + from .ssh_gateway_auth import GatewayAuthStrategy, GatewayTransport + gateway_attempt.own(client) + gateway_attempt.own(validated_socket) + if key_path: + return None, "Gateway authentication requires a stored key" + connect_kwargs.pop('look_for_keys', None) + connect_kwargs.pop('allow_agent', None) + connect_kwargs['transport_factory'] = GatewayTransport + connect_kwargs['auth_strategy'] = GatewayAuthStrategy( + username, password=password, + pkey=_load_private_key(key_content) if key_content else None, + interact=gateway_attempt.challenge, check=gateway_attempt.check, + ) + elif key_content: connect_kwargs['pkey'] = _load_private_key(key_content) elif key_path: connect_kwargs['key_filename'] = key_path @@ -149,11 +163,18 @@ def create_connection(self, host, port, username, password=None, key_path=None, if transport: transport.set_keepalive(30) - sftp = open_sftp_client( - transport, - timeout=config.SSH_CONNECT_TIMEOUT, - operation_timeout=config.SFTP_OPERATION_TIMEOUT, - ) + if gateway_attempt is not None: + from .ssh_gateway_setup import prepare_sftp + sftp = prepare_sftp( + transport, gateway_attempt, + operation_timeout=config.SFTP_OPERATION_TIMEOUT, + ) + else: + sftp = open_sftp_client( + transport, + timeout=config.SSH_CONNECT_TIMEOUT, + operation_timeout=config.SFTP_OPERATION_TIMEOUT, + ) conn_id = uuid.uuid4().hex @@ -164,6 +185,8 @@ def create_connection(self, host, port, username, password=None, key_path=None, and not lifecycle.accepting_work() ): return None, "Runtime is shutting down" + if gateway_attempt is not None: + gateway_attempt.handoff(client, validated_socket, sftp) self.connections[conn_id] = { 'client': client, 'sftp': sftp, diff --git a/app/profile_manager.py b/app/profile_manager.py index 739c5a7..4b9b8d4 100644 --- a/app/profile_manager.py +++ b/app/profile_manager.py @@ -354,9 +354,21 @@ def _validate_profile_payload(user_id, payload, dependent_lock_held=False): except (ValueError, TypeError): return None, 'Invalid port number' - username = str(username).strip() - if not re.match(r'^[a-zA-Z0-9_\-\.]{1,32}$', username): - return None, 'Invalid username format' + from .ssh_gateway import parse_selector + if isinstance(username, str) and ':' in username: + from .app_settings import is_ssh_gateway_enabled + if not is_ssh_gateway_enabled(): + return None, 'SSH gateway integration is disabled by the administrator' + if auth_type == 'tailscale': + return None, 'Gateway selectors cannot use Tailscale SSH' + try: + parse_selector(username) + except ValueError as error: + return None, str(error) + else: + username = str(username).strip() + if not re.match(r'^[a-zA-Z0-9_\-\.]{1,32}$', username): + return None, 'Invalid username format' if auth_type not in {'password', 'key', 'tailscale'}: return None, 'Invalid auth_type' if auth_type == 'tailscale' and payload.get('jump_host_id'): diff --git a/app/sftp_handler.py b/app/sftp_handler.py index 7e07c7c..8e3ed46 100644 --- a/app/sftp_handler.py +++ b/app/sftp_handler.py @@ -1,3 +1,4 @@ +import errno import hashlib import os import socket @@ -227,6 +228,43 @@ class RemoteMetadataLimitExceeded(SFTPOperationError): def public_sftp_error(error, fallback=_PUBLIC_SFTP_ERROR): """Return only small, application-authored SFTP errors to clients.""" + code = getattr(error, 'errno', None) + if isinstance(error, PermissionError) or code in (errno.EACCES, errno.EPERM): + return 'Permission denied' + if isinstance(error, FileNotFoundError) or code == errno.ENOENT: + return 'File or directory not found' + if isinstance(error, NotADirectoryError) or code == errno.ENOTDIR: + return 'Not a directory' + if isinstance(error, FileExistsError) or code == errno.EEXIST: + return 'File or directory already exists' + if code == errno.EROFS: + return 'Remote file system is read-only' + if code == errno.ENOSPC: + return 'Remote file system is full' + if isinstance(error, (socket.timeout, TimeoutError)) or code == errno.ETIMEDOUT: + return 'Remote file operation timed out' + + if isinstance(error, (OSError, SFTPError, SFTPOperationError)): + message = str(error).strip().lower() + if len(message) <= _PUBLIC_SFTP_ERROR_MAX_BYTES: + if message in {'permission denied', 'access denied', + 'operation not permitted'} or any( + message.startswith(reason + ': ') + for reason in ('permission denied', 'access denied', + 'operation not permitted') + ): + return 'Permission denied' + if message in {'no such file', 'no such file or directory'}: + return 'File or directory not found' + if message == 'not a directory': + return 'Not a directory' + if message == 'file exists': + return 'File or directory already exists' + if message == 'read-only file system': + return 'Remote file system is read-only' + if message == 'no space left on device': + return 'Remote file system is full' + if isinstance(error, SFTPOperationError): message = str(error) if len(message) > _PUBLIC_SFTP_ERROR_MAX_BYTES: diff --git a/app/socket_events.py b/app/socket_events.py index d72823d..48b0ec1 100644 --- a/app/socket_events.py +++ b/app/socket_events.py @@ -38,7 +38,7 @@ from .ssh_errors import connection_error_payload from . import binary_transfer, connection_pool from .transfer_routes import prepare_transfer, transfer_manager, _terminalize -from .quota_manager import QuotaKind, quota_manager +from .quota_manager import QuotaExceeded, QuotaKind, quota_manager from .socket_capacity import socket_capacity from .ssh_input_budget import budget_from_config from .ssh_output_flow import ssh_output_flow @@ -846,7 +846,7 @@ def _is_valid_host(host_str): except ValueError: return False -def _validate_ssh_params(host, port, username, allow_internal=False): +def _validate_ssh_params(host, port, username, allow_internal=False, *, allow_gateway=False): """Validate SSH connection parameters. Returns (clean_host, clean_port, clean_username, error). ``allow_internal`` remains for compatibility with callers. DNS and address @@ -866,6 +866,16 @@ def _validate_ssh_params(host, port, username, allow_internal=False): except (ValueError, TypeError): return None, None, None, 'Invalid port number' + if allow_gateway and isinstance(username, str) and ':' in username: + from .app_settings import is_ssh_gateway_enabled + if not is_ssh_gateway_enabled(): + return None, None, None, 'SSH gateway integration is disabled by the administrator' + from .ssh_gateway import parse_selector + try: + parse_selector(username) + except ValueError as error: + return None, None, None, str(error) + return canonicalize_hostname(host), port, username, None username = (username or '').strip() if not username: return None, None, None, 'Username is required' @@ -1315,6 +1325,7 @@ def handle_disconnect(): ssh_output_flow.release_socket(socket_sid) _cancel_ssh_banner_prompts_for_socket(socket_sid) _cancel_ssh_connect_attempts_for_socket(socket_sid) + current_app.extensions['ssh_attempt_registry'].cancel_socket(socket_sid) owner_id = socket_capacity.release(socket_sid) try: user = get_user_from_socket(socket_sid) @@ -1531,14 +1542,19 @@ def handle_ssh_connect(data, current_user=None): key_id = data.get('key_id') auth_type = data.get('auth_type') or ('key' if key_id else 'password') + error_sent = False + def emit_error(message): + nonlocal error_sent + error_sent = True emit('ssh_error', connection_error_payload( message, client_request_id=client_request_id, )) def request_auth_banner_decision(banner, context): - if client_cancel_event.is_set(): + if (client_cancel_event.is_set() + or (gateway_attempt is not None and gateway_attempt.is_set())): return False prompt_id = secrets.token_urlsafe(24) decision_event = threading.Event() @@ -1551,7 +1567,8 @@ def request_auth_banner_decision(banner, context): } with _ssh_banner_prompts_lock: _ssh_banner_prompts[prompt_id] = prompt - if client_cancel_event.is_set(): + if (client_cancel_event.is_set() + or (gateway_attempt is not None and gateway_attempt.is_set())): decision_event.set() emit('ssh_auth_banner', { 'prompt_id': prompt_id, @@ -1561,7 +1578,15 @@ def request_auth_banner_decision(banner, context): 'port': bastion_port if context == 'jump_host' else port, 'client_request_id': client_request_id, }) - answered = decision_event.wait(SSH_AUTH_BANNER_DECISION_TIMEOUT) + if gateway_attempt is None: + answered = decision_event.wait(SSH_AUTH_BANNER_DECISION_TIMEOUT) + else: + answered = False + banner_deadline = time.monotonic() + SSH_AUTH_BANNER_DECISION_TIMEOUT + while time.monotonic() < banner_deadline and not gateway_attempt.is_set(): + if decision_event.wait(.1): + answered = True + break with _ssh_banner_prompts_lock: _ssh_banner_prompts.pop(prompt_id, None) accepted = answered and prompt['accepted'] is True @@ -1667,7 +1692,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_internal=bool(proxy_jump), allow_gateway=auth_type != 'tailscale' ) if error: emit_error(error) @@ -1695,7 +1720,7 @@ def request_auth_banner_decision(banner, context): emit_error(access_error) return - if auth_type == 'password' and not password: + if auth_type == 'password' and not password and ':' not in username: emit_error('Password required') return @@ -1743,39 +1768,62 @@ def request_auth_banner_decision(banner, context): app = current_app._get_current_object() lifecycle = app.extensions['runtime_lifecycle'] + registry = app.extensions['ssh_attempt_registry'] + gateway_attempt = None + attempt_key = ( + (str(current_user.id), socket_sid, client_request_id) + if client_request_id else None + ) + if ':' in username: + if data.get('gateway_interaction') != 1 or not client_request_id: + emit_error('Gateway connections require an updated interactive client') + return + from .ssh_gateway_interaction import GatewayAttempt + try: + with _ssh_connect_attempts_lock: + if attempt_key in _ssh_connect_attempts: + raise ValueError('Connection request already in progress') + gateway_attempt = registry.create( + current_user.id, socket_sid, client_request_id, + factory=GatewayAttempt, reserve=True, + emit=lambda event, payload: socketio.emit(event, payload, to=socket_sid), + ) + except (ValueError, QuotaExceeded): + emit_error('Gateway connection limit reached or request unavailable') + return credential_box = { 'password': password, 'key_content': key_content, 'bastion_password': bastion_password, 'bastion_key_content': bastion_key_content, } - attempt_key = ( - (str(current_user.id), socket_sid, client_request_id) - if client_request_id else None - ) - attempt = { - 'cancel_event': client_cancel_event, - 'commit_lock': threading.Lock(), - 'handle': None, - 'state': 'pending', - } - if attempt_key is not None: - with _ssh_connect_attempts_lock: - if attempt_key in _ssh_connect_attempts: - credential_box.clear() - emit_error('Connection request already in progress') - return - _ssh_connect_attempts[attempt_key] = attempt + if gateway_attempt is None: + attempt = { + 'cancel_event': client_cancel_event, + 'commit_lock': threading.Lock(), + 'handle': None, + 'state': 'pending', + } + if attempt_key is not None: + with _ssh_connect_attempts_lock: + if (attempt_key in _ssh_connect_attempts + or registry.get(current_user.id, socket_sid, client_request_id) is not None): + credential_box.clear() + emit_error('Connection request already in progress') + return + _ssh_connect_attempts[attempt_key] = attempt @copy_current_request_context def connect_ssh(lifecycle_cancel_event, credentials=credential_box): """Run blocking SSH setup outside the synchronous socket reader.""" - cancellation = _CombinedCancellation( - client_cancel_event, - lifecycle_cancel_event, - attempt['commit_lock'], - attempt, - ) + if gateway_attempt is None: + cancellation = _CombinedCancellation( + client_cancel_event, lifecycle_cancel_event, + attempt['commit_lock'], attempt, + ) + else: + gateway_attempt.bind_runtime(lifecycle_cancel_event) + cancellation = gateway_attempt local_password = credentials.pop('password', None) local_key_content = credentials.pop('key_content', None) local_bastion_password = credentials.pop( @@ -1812,6 +1860,7 @@ def connect_ssh(lifecycle_cancel_event, credentials=credential_box): tailscale_authorization=tailscale_authorization, cancel_event=cancellation, client_request_id=client_request_id, + **({'gateway_attempt': gateway_attempt} if gateway_attempt is not None else {}), ) if cancellation.is_set(): @@ -1966,17 +2015,28 @@ def connect_ssh(lifecycle_cancel_event, credentials=credential_box): if not cancellation.is_set(): emit_error('Connection failed') finally: + if gateway_attempt is not None: + if (not error_sent and not gateway_attempt.committed + and cancellation.is_set() + and gateway_attempt.cancel_reason not in ('user', 'disconnected') + and SocketSession.query.filter_by( + socket_sid=socket_sid, user_id=current_user.id, + ).first() is not None): + emit_error('Gateway connection failed or timed out') credentials.clear() local_password = None local_key_content = None local_bastion_password = None local_bastion_key_content = None - with attempt['commit_lock']: - attempt['state'] = 'finished' - if attempt_key is not None: - with _ssh_connect_attempts_lock: - if _ssh_connect_attempts.get(attempt_key) is attempt: - _ssh_connect_attempts.pop(attempt_key, None) + if gateway_attempt is not None: + registry.finish(gateway_attempt) + else: + with attempt['commit_lock']: + attempt['state'] = 'finished' + if attempt_key is not None: + with _ssh_connect_attempts_lock: + if _ssh_connect_attempts.get(attempt_key) is attempt: + _ssh_connect_attempts.pop(attempt_key, None) try: handle = lifecycle.start_job( @@ -1984,15 +2044,20 @@ def connect_ssh(lifecycle_cancel_event, credentials=credential_box): connect_ssh, owner_id=current_user.id, ) - attempt['handle'] = handle - if client_cancel_event.is_set(): - handle.cancel() + if gateway_attempt is not None: + gateway_attempt.attach_handle(handle) + else: + attempt['handle'] = handle + if client_cancel_event.is_set(): + handle.cancel() except Exception as error: - credential_box.clear() - if attempt_key is not None: + if gateway_attempt is not None: + registry.finish(gateway_attempt) + elif attempt_key is not None: with _ssh_connect_attempts_lock: if _ssh_connect_attempts.get(attempt_key) is attempt: _ssh_connect_attempts.pop(attempt_key, None) + credential_box.clear() log_warning( 'SSH connection job rejected', user=current_user.username, @@ -2027,22 +2092,21 @@ def handle_ssh_connect_cancel(data, current_user=None): request_id = _ssh_request_id(data) if not request_id: return {'success': False} + gateway = _gateway_attempt(data, current_user.id) + if gateway is not None: + if gateway.kind != 'terminal': + return {'success': False, 'cancelled': False, 'reason': 'not_found'} + if not gateway.cancel(): + return {'success': False, 'cancelled': False, 'reason': 'already_committed'} + _cancel_ssh_banner_prompt_for_request(current_user.id, request.sid, request_id) + return {'success': True, 'cancelled': True} attempt_key = (str(current_user.id), request.sid, request_id) with _ssh_connect_attempts_lock: attempt = _ssh_connect_attempts.get(attempt_key) if attempt is None: - return { - 'success': False, - 'cancelled': False, - 'reason': 'not_found', - } - cancelled = _try_cancel_ssh_attempt(attempt) - if not cancelled: - return { - 'success': False, - 'cancelled': False, - 'reason': 'already_committed', - } + return {'success': False, 'cancelled': False, 'reason': 'not_found'} + if not _try_cancel_ssh_attempt(attempt): + return {'success': False, 'cancelled': False, 'reason': 'already_committed'} handle = attempt.get('handle') _cancel_ssh_banner_prompt_for_request( current_user.id, @@ -4286,42 +4350,55 @@ def handle_smb_quick_connect_cancel(data, current_user=None): @socket_login_required def handle_quick_connect(data, current_user=None): """Create temporary SSH connection for file transfers without active session.""" + request_id = _ssh_request_id(data) if isinstance(data, dict) and isinstance( + data.get('username'), str + ) and ':' in data['username'] else None + + def send_error(payload): + if request_id: + payload = {**payload, 'client_request_id': request_id} + emit('quick_connect_error', payload) + try: if not current_app.extensions[ 'runtime_lifecycle' ].accepting_work(): - emit( - 'quick_connect_error', - {'error': 'Server is shutting down'}, - ) + send_error({'error': 'Server is shutting down'}) return if check_socket_rate_limit(current_user.id, 'ssh_connect', config.RATELIMIT_SSH_CONNECT): log_warning("Quick connect rate limit hit", user=current_user.username) - emit('quick_connect_error', {'error': 'Too many connection attempts. Please wait a moment.'}) + send_error({'error': 'Too many connection attempts. Please wait a moment.'}) return password = data.get('password') key_id = data.get('key_id') host, port, username, error = _validate_ssh_params( - data.get('host'), data.get('port', 22), data.get('username') + data.get('host'), data.get('port', 22), data.get('username'), + allow_gateway=True, ) if error: - emit('quick_connect_error', connection_error_payload(error)) + send_error(connection_error_payload(error)) return - if not password and not key_id: - emit('quick_connect_error', {'error': 'Password or SSH key required'}) + if not password and not key_id and ':' not in username: + send_error({'error': 'Password or SSH key required'}) return key_content = None if key_id: key_content, key_error = key_manager.read_key_content(current_user.id, key_id) if key_error: - emit('quick_connect_error', {'error': f'SSH key error: {key_error}'}) + send_error({'error': f'SSH key error: {key_error}'}) return + if ':' in username: + _start_gateway_quick_connect( + data, current_user, host, port, username, password, key_content, + ) + return + connection_id, error = connection_pool.temp_connection_pool.create_connection( host=host, port=port, @@ -4337,7 +4414,7 @@ def handle_quick_connect(data, current_user=None): key_content = None if error: - emit('quick_connect_error', connection_error_payload(error)) + send_error(connection_error_payload(error)) else: emit('quick_connect_success', { 'connection_id': connection_id, @@ -4353,7 +4430,7 @@ def handle_quick_connect(data, current_user=None): except Exception as e: log_error("Quick connect failed", error=str(e)) - emit('quick_connect_error', {'error': 'Connection failed'}) + send_error({'error': 'Connection failed'}) finally: password = None key_content = None @@ -5532,3 +5609,137 @@ def report_progress(progress): else _file_request_identity(payload) ), } + + + +def _start_gateway_quick_connect(data, user, host, port, username, password, key_content): + """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'): + emit('quick_connect_error', {'error': 'Unsupported gateway client or route', + 'client_request_id': request_id}) + return + app = current_app._get_current_object() + sid, user_id = request.sid, user.id + registry = app.extensions['ssh_attempt_registry'] + from .ssh_gateway_interaction import GatewayAttempt + try: + with _ssh_connect_attempts_lock: + if (str(user_id), sid, request_id) in _ssh_connect_attempts: + raise ValueError('Connection request already in progress') + attempt = registry.create( + user_id, sid, request_id, factory=GatewayAttempt, reserve=True, kind='quick', + emit=lambda event, payload: socketio.emit(event, payload, to=sid), + ) + except (ValueError, QuotaExceeded): + emit('quick_connect_error', {'error': 'Gateway connection limit reached', + 'client_request_id': request_id}) + return + credentials = {'password': password, 'key_content': key_content} + + @copy_current_request_context + def connect(cancel_event): + attempt.bind_runtime(cancel_event) + connection_id = None + committed = False + try: + attempt.check() + if cancel_event.is_set(): + return + connection_id, error = connection_pool.temp_connection_pool.create_connection( + host, port, username, user_id=user_id, gateway_attempt=attempt, + **credentials, + ) + credentials.clear() + attempt.check() + if cancel_event.is_set(): + return + if error: + attempt.send('quick_connect_error', **connection_error_payload(error)) + return + with attempt.condition: + attempt.check() + if SocketSession.query.filter_by(socket_sid=sid, user_id=user_id).first() is None: + return + payload = { + 'connection_id': connection_id, 'host': host, 'port': port, + 'username': username, + 'file_source': _public_file_source( + make_source_id(FileSourceKind.SFTP_QUICK, connection_id), user_id, + ), + } + if not attempt.commit_if_active(): + return + attempt.send('quick_connect_success', **payload) + committed = True + except Exception: + if (attempt.cancel_reason not in ('user', 'disconnected') + and SocketSession.query.filter_by(socket_sid=sid, user_id=user_id).first() is not None): + attempt.send('quick_connect_error', error='Gateway connection failed or timed out') + finally: + credentials.clear() + if connection_id and not committed: + connection_pool.temp_connection_pool.request_close(connection_id, user_id) + registry.finish(attempt) + try: + handle = app.extensions['runtime_lifecycle'].start_job( + 'gateway_quick_connect', connect, owner_id=user_id, + ) + attempt.attach_handle(handle) + except Exception: + credentials.clear() + registry.finish(attempt) + emit('quick_connect_error', {'error': 'Server is shutting down', + 'client_request_id': request_id}) + + +def _gateway_attempt(data, user_id): + from .ssh_gateway_interaction import GatewayAttempt + attempt = current_app.extensions['ssh_attempt_registry'].get( + user_id, request.sid, data.get('client_request_id'), + ) + return attempt if isinstance(attempt, GatewayAttempt) else None + + +@socketio.on('ssh_gateway_answer') +@socket_login_required +def handle_gateway_answer(data, current_user=None): + if not isinstance(data, dict): + return {'success': False} + attempt = _gateway_attempt(data, current_user.id) + return {'success': bool(attempt and attempt.answer( + current_user.id, request.sid, data.get('challenge_id'), data.get('answers'), + ))} + + +@socketio.on('ssh_gateway_input') +@socket_login_required +def handle_gateway_input(data, current_user=None): + if not isinstance(data, dict): + return {'success': False} + attempt = _gateway_attempt(data, current_user.id) + return {'success': bool(attempt and attempt.input(data.get('data')))} + + +@socketio.on('ssh_gateway_ack') +@socket_login_required +def handle_gateway_ack(data, current_user=None): + if not isinstance(data, dict): + return + attempt = _gateway_attempt(data, current_user.id) + if attempt: + attempt.ack(data.get('sequence')) + + +@socketio.on('ssh_gateway_quick_cancel') +@socket_login_required +def handle_gateway_quick_cancel(data, current_user=None): + if not isinstance(data, dict): + return {'success': False} + attempt = _gateway_attempt(data, current_user.id) + if not attempt or attempt.kind != 'quick': + return {'success': False, 'reason': 'not_found'} + if not attempt.cancel(): + return {'success': False, 'reason': 'already_committed'} + return {'success': True} diff --git a/app/ssh_connection_attempt.py b/app/ssh_connection_attempt.py new file mode 100644 index 0000000..b0a0c4e --- /dev/null +++ b/app/ssh_connection_attempt.py @@ -0,0 +1,212 @@ +"""Socket-owned interactive SSH setup with an atomic cancellation boundary.""" +import secrets +import threading +import time + +from .quota_manager import QuotaKind, quota_manager + + +class SSHConnectionAttempt: + """Cancellation and resource ownership for interactive SSH setup. + + A user cancellation loses after commit; disconnect and shutdown still stop + runtime work. Ordinary SSH retains its existing cancellation implementation. + """ + + timeout = None + cancellation_error = ValueError + + def __init__(self, user_id, sid, request_id, *, + reservation=None, kind='terminal'): + self.user_id = str(user_id) + self.sid = sid + self.request_id = request_id + self.kind = kind + self.reservation = reservation + self.condition = threading.Condition(threading.RLock()) + self.cancel_event = threading.Event() + self.runtime_cancel = None + self.handle = None + self.state = 'pending' + self.cancel_reason = None + self.resources = [] + self.deadline = ( + time.monotonic() + self.timeout if self.timeout else float('inf') + ) + self.guard = None + if self.timeout: + self.guard = threading.Timer( + self.timeout, self.cancel, kwargs={'reason': 'timeout'}, + ) + self.guard.daemon = True + self.guard.start() + + @property + def committed(self): + return self.state == 'committed' + + @property + def finished(self): + return self.state == 'finished' + + @property + def cancelled(self): + return self.cancel_event.is_set() + + def bind_runtime(self, cancel_event): + with self.condition: + self.runtime_cancel = cancel_event + + def attach_handle(self, handle): + with self.condition: + self.handle = handle + cancelled = self.cancelled + if cancelled: + handle.cancel() + + def check(self): + with self.condition: + if (self.cancelled or self.finished + or (self.runtime_cancel is not None and self.runtime_cancel.is_set()) + or (not self.committed and time.monotonic() >= self.deadline)): + raise self.cancellation_error() + + def is_set(self): + try: + self.check() + return False + except ValueError: + return True + + def wait(self, timeout=None): + deadline = None if timeout is None else time.monotonic() + timeout + while not self.is_set(): + remaining = .1 if deadline is None else deadline - time.monotonic() + if remaining <= 0: + return False + self.cancel_event.wait(min(remaining, .1)) + return True + + def commit_if_active(self): + with self.condition: + if self.is_set(): + return False + self.state = 'committed' + if self.guard is not None: + self.guard.cancel() + return True + + def own(self, resource): + with self.condition: + if not self.is_set(): + if resource not in self.resources: + self.resources.append(resource) + return resource + resource.close() + raise self.cancellation_error() + + def handoff(self, *resources): + with self.condition: + self.check() + for resource in resources: + if resource in self.resources: + self.resources.remove(resource) + + def _clear_pending(self): + """Protocol interactions may clear secrets while the condition is held.""" + + def cancel(self, *, reason='user'): + with self.condition: + if self.committed and reason not in ('finished', 'disconnected', 'shutdown'): + return False + if self.finished: + return False + if self.cancel_reason is None: + self.cancel_reason = reason + self.cancel_event.set() + if not self.committed: + self.state = 'cancelled' + self._clear_pending() + resources, self.resources = self.resources, [] + handle = self.handle + self.condition.notify_all() + # Closing transports can take locks; never hold the registry/attempt lock. + if handle is not None: + handle.cancel() + for resource in reversed(resources): + try: + resource.close() + except Exception: + pass + return True + + def finish(self): + if self.guard is not None: + self.guard.cancel() + self.cancel(reason='finished') + with self.condition: + if self.finished: + return + self.state = 'finished' + if self.reservation is not None: + self.reservation.release() + + +class SSHAttemptRegistry: + """App-local admission and ownership for interactive SSH attempts.""" + + def __init__(self): + self.lock = threading.Lock() + self.attempts = {} + self.stopping = False + + def create(self, user_id, sid, request_id, *, factory=SSHConnectionAttempt, + reserve=False, **kwargs): + if request_id is not None and ( + not isinstance(request_id, str) or not 1 <= len(request_id) <= 128): + raise ValueError('Invalid connection request ID') + # Legacy clients without request IDs must still be cancelled on disconnect. + key = (str(user_id), sid, request_id or secrets.token_urlsafe(24)) + with self.lock: + if self.stopping or key in self.attempts: + raise ValueError('Connection request already in progress or unavailable') + reservation = ( + quota_manager.reserve(QuotaKind.BACKGROUND_JOB, user_id) + if reserve else None + ) + try: + attempt = factory( + user_id, sid, request_id, reservation=reservation, **kwargs, + ) + attempt.registry_key = key + self.attempts[key] = attempt + return attempt + except Exception: + if reservation is not None: + reservation.release() + raise + + def get(self, user_id, sid, request_id): + if not isinstance(request_id, str): + return None + with self.lock: + return self.attempts.get((str(user_id), sid, request_id)) + + def finish(self, attempt): + attempt.finish() + with self.lock: + if self.attempts.get(attempt.registry_key) is attempt: + del self.attempts[attempt.registry_key] + + def cancel_socket(self, sid): + with self.lock: + attempts = [a for a in self.attempts.values() if a.sid == sid] + for attempt in attempts: + attempt.cancel(reason='disconnected') + + def shutdown(self): + with self.lock: + self.stopping = True + attempts = list(self.attempts.values()) + for attempt in attempts: + attempt.cancel(reason='shutdown') diff --git a/app/ssh_gateway.py b/app/ssh_gateway.py new file mode 100644 index 0000000..9e693f0 --- /dev/null +++ b/app/ssh_gateway.py @@ -0,0 +1,30 @@ +"""Explicit gateway identities; ordinary SSH validation remains unchanged.""" +import hashlib +import json +import re +import unicodedata +import uuid + + +def parse_selector(value): + """Return the exact user/target pair or reject an ambiguous selector.""" + if not isinstance(value, str) or ":" not in value: + raise ValueError("Invalid gateway selector") + if value.startswith("ticket-") or "#" in value: + raise ValueError("Gateway tickets and alternate separators are not supported") + if any(unicodedata.category(c) in {"Cc", "Cf", "Cs", "Zl", "Zp"} for c in value): + raise ValueError("Invalid gateway selector") + if len(value.encode("utf-8")) > 128: + raise ValueError("Gateway selector exceeds 128 UTF-8 bytes") + user, target = value.split(":", 1) + if not all(part and part == part.strip() for part in (user, target)): + raise ValueError("Gateway user and target are required without surrounding whitespace") + return user, target + + +def tmux_name(prefix, host, port, selector, user_id): + parse_selector(selector) + safe_prefix = re.sub(r"[^A-Za-z0-9_]", "_", prefix)[:80] + identity = json.dumps([str(user_id), host, int(port), selector], ensure_ascii=True) + digest = hashlib.sha256(identity.encode("ascii")).hexdigest()[:16] + return f"{safe_prefix}_wg_{digest}_{uuid.uuid4().hex}" diff --git a/app/ssh_gateway_auth.py b/app/ssh_gateway_auth.py new file mode 100644 index 0000000..5daa1e8 --- /dev/null +++ b/app/ssh_gateway_auth.py @@ -0,0 +1,117 @@ +"""Explicit multi-factor authentication using Paramiko's public transport API.""" +import time +import threading + +from paramiko import AuthenticationException, BadAuthenticationType, ServiceRequestingTransport +from paramiko.auth_strategy import AuthStrategy + +from .ssh_gateway import parse_selector + + +class GatewayAuthStrategy(AuthStrategy): + def __init__(self, username, *, password=None, pkey=None, interact, check=None): + super().__init__(ssh_config=None) + parse_selector(username) + self.username = username + self.password = password or None + self.pkey = pkey + self.interact = interact + self.check = check or (lambda: None) + + def authenticate(self, transport): + deadline = time.monotonic() + 180 + guard = threading.Timer(180, transport.close) + guard.daemon = True + guard.start() + + def remaining_timeout(): + self.check() + remaining = deadline - time.monotonic() + if remaining <= 0: + raise AuthenticationException("Gateway authentication timed out") + transport.auth_timeout = remaining + + method = "publickey" if self.pkey is not None else ( + "password" if self.password else "keyboard-interactive" + ) + attempted = set() + try: + for _round in range(8): + self.check() + if time.monotonic() >= deadline: + raise AuthenticationException("Gateway authentication timed out") + if method in attempted: + raise AuthenticationException("Gateway authentication did not complete") + attempted.add(method) + try: + if method == "publickey": + remaining_timeout() + methods = transport.auth_publickey(self.username, self.pkey) + elif method == "password": + password = self.password + if password is None: + answers = self.interact("", "", [("Password", False)]) + if len(answers) != 1: + raise AuthenticationException("Invalid authentication response") + password = answers[0] + try: + remaining_timeout() + methods = transport.auth_password(self.username, password, fallback=False) + finally: + password = None + else: + rounds = 0 + def handler(title, instructions, prompts): + nonlocal rounds + rounds += 1 + self.check() + if rounds > 8 or time.monotonic() >= deadline: + raise AuthenticationException("Gateway authentication limit exceeded") + return self.interact(title, instructions, prompts) + remaining_timeout() + methods = transport.auth_interactive(self.username, handler) + except BadAuthenticationType as error: + methods = error.allowed_types + self.check() + remaining_timeout() + if transport.is_authenticated(): + return + if not isinstance(methods, (list, tuple)): + raise AuthenticationException("Gateway authentication failed") + method = next((candidate for candidate in ( + "password", "keyboard-interactive" + ) if candidate in methods and candidate not in attempted), None) + if method is None: + raise AuthenticationException("Gateway authentication failed") + raise AuthenticationException("Gateway authentication limit exceeded") + finally: + guard.cancel() + self.password = None + self.pkey = None + +class GatewayTransport(ServiceRequestingTransport): + """Request ssh-userauth once, with a bounded Paramiko 5 service handshake. + + Paramiko's public ServiceRequestingTransport supports multiple factors, but + its ensure_session loop does not stop on close or timeout. This isolated + override retains its protocol behavior and bounds that initial wait. + """ + + def ensure_session(self): + from paramiko import Message, SSHException + from paramiko.common import cMSG_SERVICE_REQUEST + if not self.active or not self.initial_kex_done: + raise SSHException("No existing gateway session") + if self._service_userauth_accepted: + return + message = Message() + message.add_byte(cMSG_SERVICE_REQUEST) + message.add_string("ssh-userauth") + self._send_message(message) + deadline = time.monotonic() + min(self.auth_timeout or 10, 10) + while not self._service_userauth_accepted: + if not self.active or time.monotonic() >= deadline: + self.close() + raise AuthenticationException("Gateway authentication service unavailable") + time.sleep(.02) + self.auth_handler = self.get_auth_handler() diff --git a/app/ssh_gateway_interaction.py b/app/ssh_gateway_interaction.py new file mode 100644 index 0000000..b6f89b3 --- /dev/null +++ b/app/ssh_gateway_interaction.py @@ -0,0 +1,158 @@ +"""Bounded, socket-owned gateway interactions. No credentials are persisted.""" +from collections import deque +import secrets +import time + +from .ssh_connection_attempt import SSHConnectionAttempt + + +class GatewayCancelled(ValueError): + def __init__(self): + super().__init__("Gateway connection cancelled or timed out") + + +class GatewayAttempt(SSHConnectionAttempt): + """MFA and setup I/O shared by gateway terminals and Quick SFTP.""" + + timeout = 300 + cancellation_error = GatewayCancelled + + def __init__(self, user_id, sid, request_id, emit, *, + reservation=None, kind='terminal'): + self.emit = emit + self.auth_deadline = time.monotonic() + 180 + self.prompt = None + self.responses = None + self.phase = "auth" + self.inputs = deque() + self.input_bytes = 0 + self.output_bytes = 0 + self.sequence = 0 + self.unacked = {} + super().__init__( + user_id, sid, request_id, reservation=reservation, kind=kind, + ) + + def check(self): + with self.condition: + super().check() + if not self.committed: + from .app_settings import is_ssh_gateway_enabled + if not is_ssh_gateway_enabled(): + raise GatewayCancelled() + if (not self.committed and self.phase == 'auth' + and time.monotonic() >= self.auth_deadline): + raise GatewayCancelled() + + def _clear_pending(self): + self.responses = None + self.inputs.clear() + + def send(self, event, **data): + self.emit(event, {"client_request_id": self.request_id, **data}) + + def challenge(self, title, instructions, prompts): + if not isinstance(title, str) or not isinstance(instructions, str): + raise ValueError("Invalid gateway challenge") + if len(prompts) > 8 or any( + not isinstance(p, (tuple, list)) or len(p) != 2 or not isinstance(p[0], str) + for p in prompts + ): + raise ValueError("Invalid gateway challenge") + if sum(len(s.encode("utf-8")) for s in [title, instructions, *[p[0] for p in prompts]]) > 16384: + raise ValueError("Gateway challenge exceeds byte limit") + with self.condition: + self.check() + if self.prompt is not None: + raise ValueError("Gateway challenge already pending") + challenge_id = secrets.token_urlsafe(24) + self.prompt = (challenge_id, len(prompts)) + self.responses = None + self.send("ssh_gateway_challenge", challenge_id=challenge_id, + title=title, instructions=instructions, + prompts=[{"label": p[0]} for p in prompts]) + deadline = min(self.deadline, self.auth_deadline, time.monotonic() + 120) + try: + while self.responses is None: + self.check() + remaining = deadline - time.monotonic() + if remaining <= 0: + raise GatewayCancelled() + self.condition.wait(min(remaining, .25)) + return self.responses + finally: + self.prompt = None + self.responses = None + + def answer(self, user_id, sid, challenge_id, answers): + with self.condition: + if self.cancelled or self.finished or self.user_id != str(user_id) or self.sid != sid: + return False + if self.prompt is None or self.prompt[0] != challenge_id or self.responses is not None: + return False + if not isinstance(answers, list) or len(answers) != self.prompt[1]: + return False + if any(not isinstance(a, str) for a in answers): + return False + try: + if sum(len(a.encode("utf-8")) for a in answers) > 16384: + return False + except UnicodeError: + return False + self.responses = list(answers) + self.condition.notify_all() + return True + + def start_setup(self): + with self.condition: + self.check() + self.phase = "setup" + self.send("ssh_gateway_progress", phase="setup") + + def input(self, value): + if not isinstance(value, str): + return False + try: + data = value.encode("utf-8") + except UnicodeError: + return False + with self.condition: + if self.phase != "setup" or self.cancelled or self.finished: + return False + if not data or len(data) > 1024 or self.input_bytes + len(data) > 8192: + return False + self.input_bytes += len(data) + self.inputs.append(data) + return True + + def take_input(self): + with self.condition: + return self.inputs.popleft() if self.inputs else None + + def output(self, data): + if not isinstance(data, bytes) or len(data) > 4096: + raise ValueError("Invalid gateway output frame") + with self.condition: + self.check() + self.output_bytes += len(data) + if self.output_bytes > 8 * 1024 * 1024: + raise ValueError("Gateway output limit exceeded") + deadline = time.monotonic() + 5 + while len(self.unacked) >= 4: + self.check() + remaining = deadline - time.monotonic() + if remaining <= 0: + raise GatewayCancelled() + self.condition.wait(min(remaining, .25)) + self.sequence += 1 + self.unacked[self.sequence] = True + self.send("ssh_gateway_output", sequence=self.sequence, + data=data.decode("utf-8", errors="replace")) + + def ack(self, sequence): + with self.condition: + if type(sequence) is not int or sequence not in self.unacked: + return False + del self.unacked[sequence] + self.condition.notify_all() + return True diff --git a/app/ssh_gateway_setup.py b/app/ssh_gateway_setup.py new file mode 100644 index 0000000..c32d0ab --- /dev/null +++ b/app/ssh_gateway_setup.py @@ -0,0 +1,125 @@ +"""Prove target readiness before any terminal startup action is allowed.""" +import secrets +import threading +import time + +from . import paramiko_channels +from .ssh_gateway_interaction import GatewayCancelled + + +class ReadyLine: + def __init__(self, marker): + self.marker = marker + self.pending = b"" + self.ready = False + + def feed(self, data): + self.pending += data + while b"\n" in self.pending: + line, self.pending = self.pending.split(b"\n", 1) + if line.rstrip(b"\r") == self.marker: + self.ready = True + if len(self.pending) > 65536: + raise ValueError("Gateway readiness output exceeds byte limit") + + +def _pty(transport, attempt, deadline): + channel = attempt.own(transport.open_session(timeout=min(10, deadline-time.monotonic()))) + channel.settimeout(.2) + guard = paramiko_channels._request_guard(channel, max(.01, deadline-time.monotonic())) + try: + channel.get_pty(term="xterm", width=80, height=24) + attempt.check() + return channel + except Exception: + channel.close() + raise + finally: + guard.cancel() + + +def _pump(channel, attempt, deadline, parser=None): + attempt.check() + if time.monotonic() >= deadline: + raise GatewayCancelled() + data = attempt.take_input() + if data is not None: + channel.sendall(data) + received = False + for ready, recv in ((channel.recv_ready, channel.recv), + (channel.recv_stderr_ready, channel.recv_stderr)): + if ready(): + data = recv(4096) + received = True + if data: + attempt.output(data) + if parser is not None: + parser.feed(data) + return received + + +def prepare_terminal(transport, attempt): + attempt.start_setup() + deadline = min(attempt.deadline, time.monotonic()+180) + channel = _pty(transport, attempt, deadline) + marker = ("WEBSSH_READY_"+secrets.token_hex(16)).encode("ascii") + parser = ReadyLine(marker) + guard = paramiko_channels._request_guard(channel, max(.01, deadline-time.monotonic())) + try: + # Fixed command and generated hex nonce only; never interpolate user input. + channel.exec_command("printf '\\n%s\\n' "+marker.decode("ascii")) # nosec B601 + while True: + received = _pump(channel, attempt, deadline, parser) + if channel.exit_status_ready() and not channel.recv_ready() and not channel.recv_stderr_ready(): + if channel.recv_exit_status() != 0 or not parser.ready: + raise ValueError("Gateway target did not confirm readiness") + attempt.check() + return + if channel.closed and not received: + raise ValueError("Gateway target closed before readiness") + if not received: + time.sleep(.02) + finally: + guard.cancel() + channel.close() + + +def prepare_sftp(transport, attempt, *, operation_timeout): + attempt.start_setup() + deadline = min(attempt.deadline, time.monotonic()+180) + channel = _pty(transport, attempt, deadline) + stop = threading.Event() + errors = [] + def pump(): + try: + while not stop.is_set(): + if not _pump(channel, attempt, deadline): + stop.wait(.02) + except Exception as error: + errors.append(error) + attempt.cancel(reason="failed") + # At most one pump per admitted BACKGROUND_JOB, joined before returning. + worker = threading.Thread(target=pump, name="gateway-setup", daemon=True) + worker.start() + sftp = None + try: + sftp = attempt.own(paramiko_channels.open_sftp_client( + transport, timeout=max(.01, deadline-time.monotonic()), + operation_timeout=operation_timeout, deadline=deadline, + )) + sftp.normalize(".") + attempt.check() + if errors: + raise errors[0] + return sftp + except Exception: + if sftp is not None: + sftp.close() + raise + finally: + stop.set() + channel.close() + worker.join(1) + if worker.is_alive(): + attempt.cancel(reason="failed") + raise GatewayCancelled() diff --git a/app/ssh_manager.py b/app/ssh_manager.py index 4297b02..4330ba8 100644 --- a/app/ssh_manager.py +++ b/app/ssh_manager.py @@ -149,7 +149,7 @@ def create_ssh_connection(host, port, username, password=None, key_path=None, ke auth_banner_decision=None, tailscale_authorization=None, cancel_event=None, client_request_id=None, - jump_host_id=None): + jump_host_id=None, gateway_attempt=None): """ Create a new SSH connection and return session ID. @@ -170,6 +170,11 @@ def create_ssh_connection(host, port, username, password=None, key_path=None, ke cancel_event: Event-like cancellation signal for an in-progress setup """ def connection_cancelled(): + if gateway_attempt is not None: + try: + gateway_attempt.check() + except ValueError: + return True return cancel_event is not None and cancel_event.is_set() if connection_cancelled(): @@ -265,6 +270,9 @@ def connection_cancelled(): bastion_target, config.SSH_CONNECT_TIMEOUT ) bastion_client = paramiko.SSHClient() + if gateway_attempt is not None: + gateway_attempt.own(validated_socket) + gateway_attempt.own(bastion_client) _configure_host_key_trust(bastion_client, host_key_store) bastion_auth = { @@ -356,6 +364,12 @@ def connection_cancelled(): return None, "Connection cancelled" client = paramiko.SSHClient() + if gateway_attempt is not None: + gateway_attempt.own(client) + if validated_socket is not None: + gateway_attempt.own(validated_socket) + if bastion_client is not None: + gateway_attempt.own(bastion_client) _configure_host_key_trust(client, host_key_store) auth_kwargs = { @@ -366,7 +380,17 @@ def connection_cancelled(): 'sock': sock, } - if auth_type == 'tailscale': + if gateway_attempt is not None: + from .ssh_gateway_auth import GatewayAuthStrategy, GatewayTransport + if auth_type == 'tailscale' or key_path: + return None, 'Unsupported gateway authentication method' + auth_kwargs['transport_factory'] = GatewayTransport + auth_kwargs['auth_strategy'] = GatewayAuthStrategy( + username, password=password, + pkey=_load_private_key(key_content) if key_content else None, + interact=gateway_attempt.challenge, check=gateway_attempt.check, + ) + elif auth_type == 'tailscale': auth_kwargs['auth_strategy'] = TailscaleSSHAuthStrategy(username) else: auth_kwargs['look_for_keys'] = False @@ -399,6 +423,10 @@ def connection_cancelled(): if connection_cancelled(): return None, "Connection cancelled" + if gateway_attempt is not None: + from .ssh_gateway_setup import prepare_terminal + prepare_terminal(transport, gateway_attempt) + tmux_session_name = None if use_tmux: # Tailscale SSH does not populate locale variables. Force UTF-8 on @@ -424,7 +452,13 @@ def connection_cancelled(): ) else: unique_suffix = uuid.uuid4().hex[:8] - tmux_session_name = f"{config.TMUX_SESSION_PREFIX}_{safe_user}_{safe_host}_{port}_{unique_suffix}" + if gateway_attempt is not None: + from .ssh_gateway import tmux_name + tmux_session_name = tmux_name( + config.TMUX_SESSION_PREFIX, host, port, username, user_id, + ) + else: + tmux_session_name = f"{config.TMUX_SESSION_PREFIX}_{safe_user}_{safe_host}_{port}_{unique_suffix}" tmux_cmd = ( f'{tmux_command} new-session -s ' f'{shlex.quote(tmux_session_name)}' @@ -497,6 +531,8 @@ def connection_cancelled(): time.sleep(0.1) with sessions_lock: + if gateway_attempt is not None: + gateway_attempt.handoff(client, validated_socket, bastion_client) sessions[session_id] = { 'client': client, 'channel': channel, diff --git a/docs/warpgate.md b/docs/warpgate.md new file mode 100644 index 0000000..428b66e --- /dev/null +++ b/docs/warpgate.md @@ -0,0 +1,43 @@ +# Warpgate SSH connections + +The integration is **disabled by default**. An administrator must enable +**Settings → Administration → Integrations → Warpgate / SSH gateway** first. +This global setting applies to all WebSSH users and is saved in the existing +application settings store. Changing it requires the normal admin step-up +verification. Reload open workspaces after changing the setting. + +Disabling blocks new gateway terminal and Quick SFTP connections, gateway +profile changes, and unfinished gateway authentication. Existing profiles are +retained and established sessions are not disconnected. The server enforces +the setting even when an older browser tab still shows the enabled UI. + +Once enabled, connect to an existing Warpgate SSH endpoint using an ordinary WebSSH profile: + +1. Enter the gateway hostname and SSH port. +2. Enter the exact Warpgate selector, for example `alice@example.com:production`, as the username. +3. Choose **Password** or an existing **SSH key**. With Password, leave the password empty to start interactive authentication. +4. Respond to additional authentication prompts. Browser approval links open only when clicked. Return to WebSSH and continue after approval. +5. Review any target host-key or access-approval prompt in the temporary gateway terminal. + +No additional service, Compose setting, database migration, or dependency is required. Ordinary SSH, existing jump hosts and Tailscale connections keep their existing paths. A normal SSH jump host may precede a gateway terminal connection; Quick SFTP does not add jump-host or Tailscale support. + +When globally enabled, the integration is selected by a validated `user:target` username. +Ordinary SSH keeps its existing authentication, cancellation and banner-wait +implementation; ordinary Quick SFTP remains synchronous. Gateway terminal and +Quick SFTP attempts share their own bounded interaction lifecycle. + +## Security and compatibility boundaries + +- Selectors opt in to a separate authentication path. They do not identify a server as trustworthy; existing per-user gateway host-key checks and network restrictions still apply. +- The gateway controls access to its targets. WebSSH's network policy checks the gateway endpoint, not the destination hidden behind it. +- Passwords, additional factors and approval responses are transient. They are never stored in profiles. Additional factors are explicitly answered; the original password is never reused as an OTP. +- Each interaction belongs to one WebSSH user, socket and request. Challenges are one-shot, deadlines and byte limits are enforced, and pending gateway jobs share the existing background-job quota. +- Warpgate target host-key checks and approval policies remain enabled. There is no automatic target host-key acceptance. +- Terminal setup requires a target supporting a PTY and a bounded `printf` exec probe. Only after the nonce response and successful exit does WebSSH run normal terminal/tmux setup or configured startup commands. +- Quick SFTP uses a temporary PTY for gateway prompts and a separate SFTP channel. Targets must permit those two channels during setup; PTY-disabled or single-channel targets are not supported by this path. +- Existing profile auth types remain unchanged. Older WebSSH versions can read the profile document, but cannot connect using the new selector syntax. +- Ticket secrets, `#` selectors, automatic target discovery, gateway administration and non-SSH protocols are not supported. + +The reference implementation is tested against Warpgate 0.29.0 and Paramiko 5.0.0. The gateway-only transport uses Paramiko's `ServiceRequestingTransport` so multi-factor authentication requests the SSH authentication service once. Its initial service wait is explicitly bounded; the small compatibility override must be rechecked when upgrading Paramiko. + +Cancel closes an unfinished gateway attempt. Once the existing startup/session commit boundary has been crossed, the ordinary WebSSH cancellation contract applies. diff --git a/static/css/style.css b/static/css/style.css index 739b909..34eb371 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -6311,3 +6311,19 @@ body.keyboard-open.notepad-focused .notepad-panel { opacity: 1; color: var(--error-color, #f87171); } + +/* Transient SSH gateway interaction */ +/* Above connection forms (9000), below authentication banners (9200). */ +.gateway-modal { z-index: 9100; } +.gateway-modal .modal-content { width: min(95vw, 850px); max-height: 90vh; overflow: auto; } +.gateway-modal p { white-space: pre-wrap; overflow-wrap: anywhere; } +.gateway-challenge-title { margin: 0 0 12px; overflow-wrap: anywhere; } +.gateway-instructions { color: var(--text-secondary); line-height: 1.6; } +.gateway-modal a { color: var(--accent-color, #58a6ff); text-decoration: underline; } +.gateway-modal a:focus-visible { outline: 2px solid var(--accent-color, #58a6ff); outline-offset: 3px; } +.gateway-challenge-form { margin-top: 20px; } +.gateway-modal label { display: block; margin: 0 0 20px; overflow-wrap: anywhere; font-weight: 600; } +.gateway-modal input { display: block; width: 100%; margin-top: 8px; padding: 12px 14px; color: var(--text-primary); background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 6px; font: inherit; } +.gateway-modal input:focus { outline: 2px solid var(--accent-color, #58a6ff); outline-offset: 2px; } +.gateway-footer { display: flex; justify-content: flex-end; padding: 20px 24px; border-top: 1px solid var(--border-color); background: var(--bg-secondary); } +.gateway-terminal { margin: 12px 0; overflow: auto; } diff --git a/static/js/admin.js b/static/js/admin.js index 83fea8c..74cfe93 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -938,6 +938,11 @@ try { const data = await api('/admin/api/settings'); document.getElementById('settingRegistration').checked = !!data.registration_enabled; + const gateway = document.getElementById('settingSshGateway'); + if (gateway) { + gateway.checked = data.ssh_gateway_enabled === true; + gateway.disabled = false; + } } catch (e) { notify(e.message, 'error'); } @@ -1041,6 +1046,23 @@ await loadSecurityFeatures(); } }); + document.getElementById('settingSshGateway')?.addEventListener('change', async (e) => { + const target = e.target; + const enabled = target.checked; + target.disabled = true; + try { + const data = await stepUpApi('settings.update', 'global', '/admin/api/settings', { + method: 'POST', body: { ssh_gateway_enabled: enabled } + }); + target.checked = data.ssh_gateway_enabled === true; + notify(t('admin.settingsSaved', 'Settings saved'), 'success'); + } catch (err) { + target.checked = !enabled; + notify(err.message, 'error'); + } finally { + target.disabled = false; + } + }); document.getElementById('settingRegistration')?.addEventListener('change', async (e) => { const target = e.target; try { diff --git a/static/js/app.js b/static/js/app.js index 737f7ef..6977f2e 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1670,6 +1670,7 @@ const CONNECT_CANCEL_ACK_TIMEOUT_MS = 5000; const TRANSIENT_ID_TTL_MS = 120000; const MAX_TRANSIENT_IDS = 128; + window.SSHGatewayDialog?.init(socket, (id, done) => cancelConnectionAttempt(id, done)); function rememberTransientId(collection, value) { if (!value) return; @@ -1738,6 +1739,7 @@ } }); + window.SSHGatewayDialog?.prepare(payload); socket.emit('ssh_connect', payload); return true; } @@ -1843,6 +1845,7 @@ } function finishConnectionCancellation(requestId) { + window.SSHGatewayDialog?.close(requestId); pendingReconnectSessionMap.delete(requestId); rememberTransientId(cancelledConnectRequestIds, requestId); closeAuthBannerPrompt(requestId); @@ -1873,6 +1876,7 @@ || ( requestId !== currentConnectRequestId && !pendingRequestPaneMap.has(requestId) + && !window.SSHGatewayDialog?.has(requestId) ) ) { return false; @@ -1889,6 +1893,7 @@ const requestStillPending = ( requestId === currentConnectRequestId || pendingRequestPaneMap.has(requestId) + || window.SSHGatewayDialog?.has(requestId) ); const completedWhileCancelling = ( completedWhileCancellingRequestIds.delete(requestId) @@ -2082,8 +2087,28 @@ validation.isValidHost(hostInput.value), 'validation.host'); const validatePort = () => hint(portInput, portHint, validation.isValidPort(portInput.value), 'validation.port'); - const validateUser = () => hint(userInput, userHint, - validation.isValidUsername(userInput.value), 'validation.username'); + const validatePassword = () => { + if (!passwordInput) return; + const passwordAuth = authTypeSelect?.value === 'password'; + const gateway = passwordAuth && validation.isGateway(userInput.value); + const valid = !passwordAuth || gateway || passwordInput.value.length > 0; + setFieldState(passwordInput, passHint, + valid ? '' : i18n.t('connection.passwordRequired'), passwordAuth ? valid : null); + if (passHint && gateway && !passwordInput.value) { + passHint.textContent = i18n.t('gateway.passwordHint', 'Leave the password empty for interactive gateway authentication.'); + } + }; + let gatewayPasswordMode = false; + const validateUser = () => { + hint(userInput, userHint, + validation.isValidUsername(userInput.value, authTypeSelect?.value !== 'tailscale'), 'validation.username'); + const gateway = validation.isGateway(userInput.value); + if (gateway || gatewayPasswordMode) { + if (passwordInput) passwordInput.required = authTypeSelect?.value === 'password' && !gateway; + validatePassword(); + } + gatewayPasswordMode = gateway; + }; hostInput.addEventListener('input', validateHost); portInput.addEventListener('input', validatePort); userInput.addEventListener('input', validateUser); @@ -2091,14 +2116,11 @@ if (hostHint.textContent) validateHost(); if (portHint.textContent) validatePort(); if (userHint.textContent) validateUser(); + if (passHint?.textContent) validatePassword(); }); if (passwordInput) { - passwordInput.addEventListener('input', () => { - const value = passwordInput.value; - const isValid = value.length > 0; - setFieldState(passwordInput, passHint, isValid ? '' : i18n.t('connection.passwordRequired'), isValid); - }); + passwordInput.addEventListener('input', validatePassword); } if (keySelect) { @@ -2117,9 +2139,8 @@ if (authTypeSelect) { authTypeSelect.addEventListener('change', () => { - if (authTypeSelect.value === 'password' && passwordInput) { - setFieldState(passwordInput, passHint, passwordInput.value ? '' : i18n.t('connection.passwordRequired'), Boolean(passwordInput.value)); - } + if (validation.isGateway(userInput.value)) validateUser(); + if (authTypeSelect.value === 'password' && passwordInput) validatePassword(); if (authTypeSelect.value === 'key' && keySelect) { setFieldState(keySelect, keyHint, keySelect.value ? '' : i18n.t('connection.selectSSHKey'), Boolean(keySelect.value)); } @@ -2627,7 +2648,7 @@ return; } - if (authType === 'password' && !password) { + if (authType === 'password' && !password && !window.ConnectionValidation.isGateway(username)) { showNotification('Password is required', 'error'); document.getElementById('passwordInput').focus(); return; @@ -2928,7 +2949,7 @@ window.addEventListener('click', (e) => { if (e.target.classList.contains('modal')) { if (e.target.classList.contains('primary-workspace-view')) return; - if (e.target.id === 'sshAuthBannerModal') return; + if (e.target.id === 'sshAuthBannerModal' || e.target.id === 'sshGatewayModal') return; if (e.target.id === 'connectionModal') { dismissConnectionModal(); return; @@ -3006,6 +3027,7 @@ if ( modal.id === 'sftpFileManager' || modal.id === 'sshAuthBannerModal' + || modal.id === 'sshGatewayModal' || modal.classList.contains('primary-workspace-view') ) return; if (modal.id === 'connectionModal') { diff --git a/static/js/connection-validation.js b/static/js/connection-validation.js index 657fc47..ea807b2 100644 --- a/static/js/connection-validation.js +++ b/static/js/connection-validation.js @@ -25,11 +25,22 @@ /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(label) )); } - function isValidUsername(value) { + function isGateway(value) { + if (typeof document === 'undefined' + || document.querySelector('meta[name="ssh-gateway-enabled"]')?.content !== 'true') return false; + if (typeof value !== 'string' || !value.includes(':') || value.includes('#') + || value.startsWith('ticket-') || /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}]/u.test(value) + || new TextEncoder().encode(value).length > 128) return false; + const split = value.indexOf(':'); + return [value.slice(0, split), value.slice(split + 1)] + .every(part => part.length > 0 && part === part.trim()); + } + function isValidUsername(value, allowGateway = false) { + if (allowGateway && typeof value === 'string' && value.includes(':')) return isGateway(value); return /^[a-zA-Z0-9_.-]{1,32}$/.test(String(value || '').trim()); } function isValidPort(value) { return /^\d+$/.test(String(value)) && Number(value) >= 1 && Number(value) <= 65535; } - return {isValidHost, isValidUsername, isValidPort}; + return {isValidHost, isValidUsername, isValidPort, isGateway}; })); diff --git a/static/js/i18n.js b/static/js/i18n.js index 6a5e764..1349149 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -1,6 +1,16 @@ const translations = { en: { + 'gateway.title': "Gateway connection", + 'gateway.authentication': "Gateway authentication", + 'gateway.response': "Response", + 'gateway.continue': "Continue", + 'gateway.target': "Waiting for target access. Review gateway prompts below.", + 'admin.integrations': "Integrations", + 'admin.sshGateway': "Warpgate / SSH gateway", + 'admin.sshGatewayHint': "Allow interactive gateway connections using user:target for all users. Disabled by default. Disabling blocks new connections and unfinished gateway authentication; established sessions remain connected. Reload open workspaces after changing this setting.", + 'gateway.cancel': "Cancel connection", + 'gateway.passwordHint': "Leave the password empty for interactive gateway authentication.", 'workspace.directorySync.prompt': "Folder sync paused: an empty terminal prompt could not be confirmed. Finish your input, or turn off sync to browse Files independently.", 'workspace.directorySync.unavailable': "The terminal folder could not be checked. Leave tmux copy mode or the running application and try again.", 'workspace.directorySync.pending': "The previous folder change is still being checked. Please try again shortly.", @@ -1410,6 +1420,16 @@ const translations = { vi: { + 'gateway.title': "Kết nối cổng SSH", + 'gateway.authentication': "Xác thực cổng SSH", + 'gateway.response': "Phản hồi", + 'gateway.continue': "Tiếp tục", + 'gateway.target': "Đang chờ quyền truy cập đích. Kiểm tra yêu cầu bên dưới.", + 'admin.integrations': "Tích hợp", + 'admin.sshGateway': "Warpgate / cổng SSH", + 'admin.sshGatewayHint': "Cho phép mọi người dùng kết nối cổng tương tác bằng user:target. Mặc định tắt. Khi tắt, kết nối mới và xác thực chưa hoàn tất bị chặn; phiên đã kết nối được giữ lại. Tải lại không gian làm việc sau khi thay đổi.", + 'gateway.cancel': "Hủy kết nối", + 'gateway.passwordHint': "Để trống mật khẩu để xác thực tương tác.", 'workspace.directorySync.prompt': "Đồng bộ thư mục tạm dừng: chưa xác nhận được dòng lệnh trống. Hoàn tất nhập lệnh hoặc tắt đồng bộ để duyệt tệp độc lập.", 'workspace.directorySync.unavailable': "Không thể kiểm tra thư mục terminal. Thoát chế độ sao chép tmux hoặc ứng dụng đang chạy rồi thử lại.", 'workspace.directorySync.pending': "Đang kiểm tra lần chuyển thư mục trước. Vui lòng thử lại sau ít giây.", @@ -2818,6 +2838,16 @@ const translations = { }, de: { + 'gateway.title': "Gateway-Verbindung", + 'gateway.authentication': "Gateway-Anmeldung", + 'gateway.response': "Antwort", + 'gateway.continue': "Weiter", + 'gateway.target': "Warten auf den Zielzugriff. Bitte die Gateway-Abfragen unten prüfen.", + 'admin.integrations': "Integrationen", + 'admin.sshGateway': "Warpgate / SSH-Gateway", + 'admin.sshGatewayHint': "Interaktive Gateway-Verbindungen mit user:target für alle Benutzer erlauben. Standardmäßig deaktiviert. Abschalten sperrt neue Verbindungen und laufende Gateway-Anmeldungen; aufgebaute Sitzungen bleiben verbunden. Offene Arbeitsbereiche nach einer Änderung neu laden.", + 'gateway.cancel': "Verbindung abbrechen", + 'gateway.passwordHint': "Passwort für die interaktive Gateway-Anmeldung leer lassen.", 'workspace.directorySync.prompt': "Ordner-Sync pausiert: Eine leere Terminal-Eingabe konnte nicht bestätigt werden. Beende deine Eingabe oder schalte den Sync aus, um Dateien unabhängig zu öffnen.", 'workspace.directorySync.unavailable': "Der Terminal-Ordner konnte nicht geprüft werden. Verlasse den tmux-Kopiermodus oder das laufende Programm und versuche es erneut.", 'workspace.directorySync.pending': "Der vorherige Ordnerwechsel wird noch geprüft. Versuche es gleich erneut.", @@ -4225,6 +4255,16 @@ const translations = { }, fr: { + 'gateway.title': "Connexion à la passerelle", + 'gateway.authentication': "Authentification de la passerelle", + 'gateway.response': "Réponse", + 'gateway.continue': "Continuer", + 'gateway.target': "En attente de l'accès à la cible. Vérifiez les demandes ci-dessous.", + 'admin.integrations': "Intégrations", + 'admin.sshGateway': "Warpgate / passerelle SSH", + 'admin.sshGatewayHint': "Autoriser les connexions interactives via user:target pour tous les utilisateurs. Désactivé par défaut. La désactivation bloque les nouvelles connexions et les authentifications en cours ; les sessions établies restent connectées. Rechargez les espaces de travail après modification.", + 'gateway.cancel': "Annuler la connexion", + 'gateway.passwordHint': "Laissez le mot de passe vide pour l'authentification interactive.", 'workspace.directorySync.prompt': "Synchronisation en pause : impossible de confirmer une invite vide. Terminez votre saisie ou désactivez la synchronisation pour parcourir les fichiers indépendamment.", 'workspace.directorySync.unavailable': "Impossible de vérifier le dossier du terminal. Quittez le mode copie de tmux ou l’application en cours, puis réessayez.", 'workspace.directorySync.pending': "Le changement de dossier précédent est encore en cours de vérification. Réessayez dans un instant.", @@ -5632,6 +5672,16 @@ const translations = { }, es: { + 'gateway.title': "Conexión a la puerta de enlace", + 'gateway.authentication': "Autenticación de la puerta de enlace", + 'gateway.response': "Respuesta", + 'gateway.continue': "Continuar", + 'gateway.target': "Esperando acceso al destino. Revise las solicitudes siguientes.", + 'admin.integrations': "Integraciones", + 'admin.sshGateway': "Warpgate / puerta de enlace SSH", + 'admin.sshGatewayHint': "Permitir conexiones interactivas con user:target para todos los usuarios. Desactivado por defecto. Al desactivar se bloquean nuevas conexiones y autenticaciones pendientes; las sesiones establecidas siguen conectadas. Recargue los espacios de trabajo tras el cambio.", + 'gateway.cancel': "Cancelar conexión", + 'gateway.passwordHint': "Deje la contraseña vacía para la autenticación interactiva.", 'workspace.directorySync.prompt': "Sincronización en pausa: no se pudo confirmar una línea de comandos vacía. Termina la entrada o desactiva la sincronización para explorar archivos de forma independiente.", 'workspace.directorySync.unavailable': "No se pudo comprobar la carpeta de la terminal. Sal del modo de copia de tmux o de la aplicación en ejecución y vuelve a intentarlo.", 'workspace.directorySync.pending': "Aún se está comprobando el cambio de carpeta anterior. Vuelve a intentarlo en un momento.", @@ -7039,6 +7089,16 @@ const translations = { }, zh: { + 'gateway.title': "网关连接", + 'gateway.authentication': "网关身份验证", + 'gateway.response': "响应", + 'gateway.continue': "继续", + 'gateway.target': "正在等待目标访问。请检查下方的网关提示。", + 'admin.integrations': "集成", + 'admin.sshGateway': "Warpgate / SSH 网关", + 'admin.sshGatewayHint': "允许所有用户使用 user:target 建立交互式网关连接。默认禁用。禁用后会阻止新连接和未完成的网关认证;已建立的会话保持连接。更改后请重新加载工作区。", + 'gateway.cancel': "取消连接", + 'gateway.passwordHint': "将密码留空以使用交互式网关身份验证。", 'workspace.directorySync.prompt': "文件夹同步已暂停:无法确认终端提示符为空。请完成输入,或关闭同步以独立浏览文件。", 'workspace.directorySync.unavailable': "无法检查终端文件夹。请退出 tmux 复制模式或正在运行的程序后重试。", 'workspace.directorySync.pending': "仍在检查上一次文件夹切换。请稍后重试。", diff --git a/static/js/profile-manager.js b/static/js/profile-manager.js index fc04e37..e29ed64 100644 --- a/static/js/profile-manager.js +++ b/static/js/profile-manager.js @@ -791,7 +791,7 @@ const ProfileManager = { if (authType === 'password') { passwordGroup.classList.remove('hidden'); keyGroup.classList.add('hidden'); - document.getElementById('passwordInput').required = true; + document.getElementById('passwordInput').required = !window.ConnectionValidation?.isGateway(document.getElementById('usernameInput').value); document.getElementById('keySelect').required = false; } else if (authType === 'key') { passwordGroup.classList.add('hidden'); @@ -1350,7 +1350,9 @@ const ProfileManager = { group: document.getElementById('profileEditorGroup').value.trim(), host: document.getElementById('profileEditorHost').value.trim(), port: Number(document.getElementById('profileEditorPort').value) || 22, - username: document.getElementById('profileEditorUsername').value.trim(), + username: document.getElementById('profileEditorUsername').value.includes(':') + ? document.getElementById('profileEditorUsername').value + : document.getElementById('profileEditorUsername').value.trim(), auth_type: document.getElementById('profileEditorAuthType').value, key_id: document.getElementById('profileEditorKeySelect').value || null, jump_host_id: document.getElementById('profileEditorJumpHostSelect').value || null, diff --git a/static/js/session-manager.js b/static/js/session-manager.js index 32ddf1e..3b22f51 100644 --- a/static/js/session-manager.js +++ b/static/js/session-manager.js @@ -1,5 +1,10 @@ /* exported SessionManager */ const SessionManager = { + endpointKey(host, port, username) { + return String(username).includes(':') + ? 'gateway:v1:' + JSON.stringify([host, String(port), username]) + : `${host}:${port}:${username}`; + }, legacyDisplayNameStorageKey: 'sessionDisplayNames', displayNameStoragePrefix: 'sessionDisplayNames:', activeDisplayNameScopeKey: 'sessionDisplayNames:activeScope', @@ -178,7 +183,7 @@ const SessionManager = { // Save display name to localStorage by host:port:user key if (display_name) { const stored = this.readDisplayNames(); - const hostKey = `${host}:${port}:${username}`; + const hostKey = this.endpointKey(host, port, username); stored[hostKey] = display_name; this.writeDisplayNames(stored); } @@ -231,7 +236,7 @@ const SessionManager = { sessionBar.classList.remove('hidden'); } - const fallbackKey = `${host}:${port}:${username}`; + const fallbackKey = this.endpointKey(host, port, username); const fallbackName = this.pendingDisplayNames ? this.pendingDisplayNames[fallbackKey] : null; const storedName = display_name || this.pendingDisplayName || fallbackName || this.getStoredDisplayName(session_id, host, port, username); this.pendingDisplayName = null; @@ -507,7 +512,7 @@ const SessionManager = { this.pendingDisplayName = displayName; this.pendingDisplayNames = this.pendingDisplayNames || {}; if (displayName) { - this.pendingDisplayNames[`${host}:${port}:${username}`] = displayName; + this.pendingDisplayNames[this.endpointKey(host, port, username)] = displayName; } // Disconnect the current session (sends ssh_disconnect to server) @@ -532,6 +537,7 @@ const SessionManager = { connectionData.key_id = keyId; } if (route.proxyJump) connectionData.proxy_jump = route.proxyJump; + window.SSHGatewayDialog?.prepare(connectionData); window.socket.emit('ssh_connect', connectionData); const message = window.i18n ? i18n.t('session.reconnecting').replace('{label}', label) @@ -785,7 +791,7 @@ const SessionManager = { } // Also save by host:port:user key so it survives session ID changes. if (session) { - const hostKey = `${session.host}:${session.port}:${session.username}`; + const hostKey = this.endpointKey(session.host, session.port, session.username); if (displayName) { stored[hostKey] = displayName; } else { @@ -808,7 +814,7 @@ const SessionManager = { if (stored[sessionId]) return stored[sessionId]; // Check by host:port:user key (persists across session ID changes). if (host && port && username) { - const hostKey = `${host}:${port}:${username}`; + const hostKey = this.endpointKey(host, port, username); if (stored[hostKey]) return stored[hostKey]; } return null; @@ -1499,7 +1505,7 @@ const SessionManager = { this.pendingDisplayName = session.displayName; this.pendingDisplayNames = this.pendingDisplayNames || {}; if (session.displayName) { - this.pendingDisplayNames[`${session.host}:${session.port}:${session.username}`] = session.displayName; + this.pendingDisplayNames[this.endpointKey(session.host, session.port, session.username)] = session.displayName; } this.pendingReconnectTmux = session.useTmux ? session.tmuxSessionName : null; this.removeSessionUI(sessionId); @@ -1589,7 +1595,7 @@ const SessionManager = { // Also store by host:port:user as a fallback key if (displayName) { this.pendingDisplayNames = this.pendingDisplayNames || {}; - this.pendingDisplayNames[`${host}:${port}:${username}`] = displayName; + this.pendingDisplayNames[this.endpointKey(host, port, username)] = displayName; } // Remove the persistent candidate UI without notifying server @@ -1611,6 +1617,7 @@ const SessionManager = { connectionData.key_id = keyId; } if (route.proxyJump) connectionData.proxy_jump = route.proxyJump; + window.SSHGatewayDialog?.prepare(connectionData); window.socket.emit('ssh_connect', connectionData); const label = `${username}@${host}`; const message = window.i18n diff --git a/static/js/sftp-file-manager.js b/static/js/sftp-file-manager.js index 12b8f9d..b0e0b83 100644 --- a/static/js/sftp-file-manager.js +++ b/static/js/sftp-file-manager.js @@ -1875,7 +1875,15 @@ class SFTPFileManager { this.handleQuickConnectSuccess(data); }); + this.socket.on('disconnect', () => { + this.gatewayQuickRequestId = null; + }); + this.socket.on('quick_connect_error', (data) => { + if (data.client_request_id) { + if (data.client_request_id !== this.gatewayQuickRequestId) return; + this.gatewayQuickRequestId = null; + } const presentation = window.SSHErrorUI?.describeSSHError?.( data, key => this.t(key, key), @@ -2980,6 +2988,13 @@ class SFTPFileManager { } closeQuickConnect() { + if (this.gatewayQuickRequestId) { + const id = this.gatewayQuickRequestId; + this.socket.emit('ssh_gateway_quick_cancel', {client_request_id: id}, result => { + if (result?.success) window.SSHGatewayDialog?.close(id); + }); + this.gatewayQuickRequestId = null; + } if (window.ModalManager) { window.ModalManager.close(this.qcModal); if (this.modal.classList.contains('show')) { @@ -3000,7 +3015,8 @@ class SFTPFileManager { submitQuickConnect() { const host = document.getElementById('fmQcHost').value.trim(); const port = parseInt(document.getElementById('fmQcPort').value) || 22; - const username = document.getElementById('fmQcUsername').value.trim(); + const rawUsername = document.getElementById('fmQcUsername').value; + const username = rawUsername.includes(':') ? rawUsername : rawUsername.trim(); const authType = document.querySelector('input[name="fmQcAuth"]:checked').value; const password = document.getElementById('fmQcPassword').value; const keyId = document.getElementById('fmQcKeySelect').value; @@ -3010,7 +3026,7 @@ class SFTPFileManager { return; } - if (authType === 'password' && !password) { + if (authType === 'password' && !password && !window.ConnectionValidation.isGateway(username)) { this.showNotification(this.t('fm.qc.passwordRequired', 'Password is required'), 'warning'); return; } @@ -3027,11 +3043,26 @@ class SFTPFileManager { data.key_id = keyId; } + if (this.gatewayQuickRequestId) return; + window.SSHGatewayDialog?.prepare(data, true, () => { + if (this.gatewayQuickRequestId === data.client_request_id) { + this.gatewayQuickRequestId = null; + this.closeQuickConnect(); + } + }); + this.gatewayQuickRequestId = data.gateway_interaction ? data.client_request_id : null; this.socket.emit('quick_connect', data); this.showNotification(this.t('fm.connecting', 'Connecting...'), 'info'); } handleQuickConnectSuccess(data) { + if (data.client_request_id) { + if (data.client_request_id !== this.gatewayQuickRequestId) { + this.socket.emit('quick_disconnect', {connection_id: data.connection_id}); + return; + } + this.gatewayQuickRequestId = null; + } this.showNotification(`${this.t('fm.connected', 'Connected')}: ${data.host}`, 'success'); const qc = { diff --git a/static/js/ssh-gateway-dialog.js b/static/js/ssh-gateway-dialog.js new file mode 100644 index 0000000..2fbe722 --- /dev/null +++ b/static/js/ssh-gateway-dialog.js @@ -0,0 +1,221 @@ +/* Gateway prompts are transient and scoped to a locally initiated request. */ +(function () { + 'use strict'; + const attempts = new Map(); + let socket; + let cancelTerminal; + let modal; + let content; + let active = null; + const t = (key, fallback) => window.i18n?.t(key, fallback) || fallback; + function element(tag, text) { + const node = document.createElement(tag); + if (text !== undefined) node.textContent = text; + return node; + } + function close(id) { + const attempt = attempts.get(id); + if (!attempt) return; + attempt.terminal?.dispose(); + attempt.content?.replaceChildren(); + attempts.delete(id); + if (active === id) { + window.ModalManager.close(modal); + content.replaceChildren(); + active = null; + } + if (active === null && !document.getElementById('sshAuthBannerModal')?.classList.contains('show')) { + const next = [...attempts].find(([, pending]) => pending.content); + if (next) open(next[0]); + } + } + function cancel() { + const attempt = attempts.get(active); + if (!attempt) return; + const id = active; + if (!attempt.quick) { + cancelTerminal(id, cancelled => { if (cancelled) close(id); }); + return; + } + socket.emit(attempt.quick ? 'ssh_gateway_quick_cancel' : 'ssh_connect_cancel', + {client_request_id: id}, result => { + if (result?.success || result?.reason === 'not_found') { + close(id); + attempt.onCancel?.(); + } + }); + } + function open(id) { + const attempt = attempts.get(id); + if (!attempt) return null; + attempt.content ||= element('div'); + if (active !== id) { + content.replaceChildren(attempt.content); + active = id; + } + window.ModalManager.open(modal); + attempt.terminal?.focus(); + attempt.content.querySelector('input, button')?.focus(); + return attempt; + } + function appendInstructions(parent, text) { + // Server text remains text; only explicit HTTP(S) links are clickable. + const paragraph = element('p'); + paragraph.className = 'gateway-instructions'; + const pieces = String(text).split(/(https?:\/\/[^\s<>"']+)/g); + for (const piece of pieces) { + let url; + try { url = new URL(piece); } catch { /* Plain server text. */ } + if (url && ['https:', 'http:'].includes(url.protocol) && !url.username && !url.password) { + const link = element('a', piece); + link.href = url.href; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + paragraph.append(link); + } else { + paragraph.append(document.createTextNode(piece)); + } + } + parent.append(paragraph); + } + function challenge(data) { + const attempt = open(data?.client_request_id); + if (!attempt || !Array.isArray(data.prompts) || data.prompts.length > 8) return; + const content = attempt.content; + content.replaceChildren(); + const title = element('h3', data.title || t('gateway.authentication', 'Gateway authentication')); + title.className = 'gateway-challenge-title'; + content.append(title); + appendInstructions(content, data.instructions || ''); + const form = element('form'); + form.className = 'gateway-challenge-form'; + const inputs = []; + for (const prompt of data.prompts) { + const label = element('label', prompt.label || t('gateway.response', 'Response')); + const input = element('input'); + input.className = 'form-control'; + input.type = 'password'; + input.autocomplete = 'off'; + input.spellcheck = false; + input.maxLength = 16384; + label.append(input); + form.append(label); + inputs.push(input); + } + const submit = element('button', t('gateway.continue', 'Continue')); + submit.type = 'submit'; + submit.className = 'btn btn-primary'; + form.append(submit); + form.addEventListener('submit', event => { + event.preventDefault(); + if (submit.disabled) return; + const answers = inputs.map(input => input.value); + if (answers.reduce((total, value) => total + new TextEncoder().encode(value).length, 0) > 16384) return; + submit.disabled = true; + socket.emit('ssh_gateway_answer', { + client_request_id: data.client_request_id, + challenge_id: data.challenge_id, answers, + }, result => { + if (!result?.success && attempts.has(data.client_request_id)) submit.disabled = false; + }); + inputs.forEach(input => { input.value = ''; }); + + }); + content.append(form); + (inputs[0] || submit).focus(); + } + function setup(data) { + const attempt = open(data?.client_request_id); + if (!attempt || attempt.terminal) return; + const content = attempt.content; + content.replaceChildren(); + content.append(element('p', t('gateway.target', 'Waiting for target access. Review gateway prompts below.'))); + const terminalNode = element('div'); + terminalNode.className = 'gateway-terminal'; + content.append(terminalNode); + const terminal = new window.Terminal({ + cols: 80, rows: 16, scrollback: 200, fontSize: 13, + convertEol: true, allowProposedApi: false, + }); + attempt.terminal = terminal; + terminal.open(terminalNode); + terminal.onData(value => { + if (new TextEncoder().encode(value).length <= 1024) { + socket.emit('ssh_gateway_input', {client_request_id: data.client_request_id, data: value}); + } + }); + terminal.focus(); + } + window.SSHGatewayDialog = { + init(connection, cancelConnection) { + socket = connection; + cancelTerminal = cancelConnection; + modal = element('div'); + modal.id = 'sshGatewayModal'; + modal.className = 'modal gateway-modal'; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + modal.setAttribute('aria-label', t('gateway.title', 'Gateway connection')); + modal.setAttribute('aria-hidden', 'true'); + const box = element('div'); + box.className = 'modal-content'; + const header = element('div'); + header.className = 'modal-header'; + const heading = element('h2', t('gateway.title', 'Gateway connection')); + heading.id = 'sshGatewayTitle'; + heading.dataset.i18n = 'gateway.title'; + header.append(heading); + modal.setAttribute('aria-labelledby', heading.id); + content = element('div'); + content.className = 'modal-body'; + const footer = element('div'); + footer.className = 'gateway-footer'; + const button = element('button', t('gateway.cancel', 'Cancel connection')); + button.type = 'button'; + button.className = 'btn btn-secondary'; + button.dataset.i18n = 'gateway.cancel'; + button.addEventListener('click', cancel); + footer.append(button); + box.append(header, content, footer); + modal.append(box); + document.body.append(modal); + modal.addEventListener('keydown', event => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + cancel(); + } + }); + socket.on('ssh_auth_banner', data => { + if (data?.client_request_id === active) { + window.ModalManager.close(modal); + content.replaceChildren(); + active = null; + } + }); + socket.on('ssh_gateway_challenge', challenge); + socket.on('ssh_gateway_progress', setup); + socket.on('ssh_gateway_output', data => { + const attempt = attempts.get(data?.client_request_id); + if (!attempt?.terminal || typeof data.data !== 'string') return; + attempt.terminal.write(data.data, () => socket.emit('ssh_gateway_ack', { + client_request_id: data.client_request_id, sequence: data.sequence, + })); + }); + for (const event of ['ssh_connected', 'ssh_error', 'quick_connect_success', 'quick_connect_error']) { + socket.on(event, data => close(data?.client_request_id)); + } + socket.on('disconnect', () => [...attempts.keys()].forEach(close)); + }, + prepare(payload, quick = false, onCancel = null) { + if (!window.ConnectionValidation.isGateway(payload.username)) return; + payload.gateway_interaction = 1; + payload.client_request_id ||= 'gateway_' + window.crypto.randomUUID(); + if (!attempts.has(payload.client_request_id) && attempts.size < 8) { + attempts.set(payload.client_request_id, {quick, terminal: null, onCancel}); + } + }, + has: id => attempts.has(id), + close, + }; +}()); diff --git a/templates/admin.html b/templates/admin.html index db59105..994a8fd 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -43,6 +43,7 @@