From 51ad1fc54b4d62fccfd4726efa6ecba40d187cd6 Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Wed, 23 Sep 2026 09:06:34 +0200 Subject: [PATCH 01/11] Add opt-in Warpgate SSH authentication and target readiness --- README.md | 3 + app/__init__.py | 6 + app/connection_pool.py | 37 +++- app/profile_manager.py | 13 +- app/socket_events.py | 198 +++++++++++++++++++- app/ssh_gateway.py | 39 ++++ app/ssh_gateway_auth.py | 101 +++++++++++ app/ssh_gateway_interaction.py | 242 +++++++++++++++++++++++++ app/ssh_gateway_setup.py | 126 +++++++++++++ app/ssh_manager.py | 44 ++++- docs/warpgate.md | 27 +++ static/css/style.css | 8 + static/js/app.js | 17 +- static/js/connection-validation.js | 13 +- static/js/i18n.js | 42 +++++ static/js/profile-manager.js | 6 +- static/js/session-manager.js | 29 +-- static/js/sftp-file-manager.js | 31 +++- static/js/ssh-gateway-dialog.js | 192 ++++++++++++++++++++ templates/index.html | 1 + tests/e2e/ssh-gateway.spec.js | 60 ++++++ tests/integration/test_warpgate.py | 193 ++++++++++++++++++++ tests/integration/warpgate/README.md | 51 ++++++ tests/js/connection-validation.test.js | 11 ++ tests/test_sftp_request_correlation.py | 5 + tests/test_ssh_gateway.py | 22 +++ tests/test_ssh_gateway_auth.py | 77 ++++++++ tests/test_ssh_gateway_hooks.py | 68 +++++++ tests/test_ssh_gateway_interaction.py | 112 ++++++++++++ tests/test_ssh_gateway_setup.py | 16 ++ tests/test_ssh_manager.py | 30 +++ 31 files changed, 1779 insertions(+), 41 deletions(-) create mode 100644 app/ssh_gateway.py create mode 100644 app/ssh_gateway_auth.py create mode 100644 app/ssh_gateway_interaction.py create mode 100644 app/ssh_gateway_setup.py create mode 100644 docs/warpgate.md create mode 100644 static/js/ssh-gateway-dialog.js create mode 100644 tests/e2e/ssh-gateway.spec.js create mode 100644 tests/integration/test_warpgate.py create mode 100644 tests/integration/warpgate/README.md create mode 100644 tests/test_ssh_gateway.py create mode 100644 tests/test_ssh_gateway_auth.py create mode 100644 tests/test_ssh_gateway_hooks.py create mode 100644 tests/test_ssh_gateway_interaction.py create mode 100644 tests/test_ssh_gateway_setup.py diff --git a/README.md b/README.md index 05be6f7..efceb26 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,9 @@ 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. +Connect through an existing SSH gateway with a user:target username. 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..9911549 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -725,6 +725,12 @@ def enforce_security_feature_gate(): transfer_runtime_binding ), ) + from .ssh_gateway_interaction import GatewayRegistry + gateway_registry = GatewayRegistry() + app.extensions['ssh_gateway_registry'] = gateway_registry + app.extensions['runtime_lifecycle'].register_shutdown_callback( + 'gateway_attempts', lambda _deadline: gateway_registry.shutdown(), + ) cors_origins = config.CORS_ORIGINS if isinstance(cors_origins, str): cors_origins = [origin.strip() for origin in cors_origins.split(',') if origin.strip()] 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..2ad036b 100644 --- a/app/profile_manager.py +++ b/app/profile_manager.py @@ -354,8 +354,17 @@ 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): + from .ssh_gateway import parse_selector + if isinstance(username, str) and ':' in username: + 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 in username and 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' diff --git a/app/socket_events.py b/app/socket_events.py index d72823d..51976a3 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,13 @@ 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 .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 +1322,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_gateway_registry'].cancel_socket(socket_sid) owner_id = socket_capacity.release(socket_sid) try: user = get_user_from_socket(socket_sid) @@ -1561,7 +1569,19 @@ 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: + if gateway_attempt.cancelled: + break + if client_cancel_event.is_set(): + break + 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 +1687,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 +1715,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,6 +1763,20 @@ def request_auth_banner_decision(banner, context): app = current_app._get_current_object() lifecycle = app.extensions['runtime_lifecycle'] + gateway_attempt = None + gateway_registry = app.extensions['ssh_gateway_registry'] + if ':' in username: + if data.get('gateway_interaction') != 1 or not client_request_id: + emit_error('Gateway connections require an updated interactive client') + return + try: + gateway_attempt = gateway_registry.create( + current_user.id, socket_sid, client_request_id, + 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, @@ -1763,6 +1797,8 @@ def request_auth_banner_decision(banner, context): with _ssh_connect_attempts_lock: if attempt_key in _ssh_connect_attempts: credential_box.clear() + if gateway_attempt is not None: + gateway_registry.finish(gateway_attempt) emit_error('Connection request already in progress') return _ssh_connect_attempts[attempt_key] = attempt @@ -1812,6 +1848,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,6 +2003,8 @@ 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: + gateway_registry.finish(gateway_attempt) credentials.clear() local_password = None local_key_content = None @@ -1988,6 +2027,8 @@ def connect_ssh(lifecycle_cancel_event, credentials=credential_box): if client_cancel_event.is_set(): handle.cancel() except Exception as error: + if gateway_attempt is not None: + gateway_registry.finish(gateway_attempt) credential_box.clear() if attempt_key is not None: with _ssh_connect_attempts_lock: @@ -2044,6 +2085,11 @@ def handle_ssh_connect_cancel(data, current_user=None): 'reason': 'already_committed', } handle = attempt.get('handle') + gateway = current_app.extensions['ssh_gateway_registry'].get( + current_user.id, request.sid, request_id, + ) + if gateway is not None: + gateway.cancel() _cancel_ssh_banner_prompt_for_request( current_user.id, request.sid, @@ -4305,13 +4351,14 @@ def handle_quick_connect(data, current_user=None): 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)) return - if not password and not key_id: + if not password and not key_id and ':' not in username: emit('quick_connect_error', {'error': 'Password or SSH key required'}) return @@ -4322,6 +4369,12 @@ def handle_quick_connect(data, current_user=None): emit('quick_connect_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, @@ -5532,3 +5585,134 @@ 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_gateway_registry'] + try: + attempt = registry.create( + user_id, sid, request_id, + 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 + attempt.quick = True + attempt.quick_committed = False + credentials = {'password': password, 'key_content': key_content} + + @copy_current_request_context + def connect(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, + ), + } + attempt.quick_committed = True + attempt.send('quick_connect_success', **payload) + committed = True + except Exception: + if not attempt.cancelled: + attempt.send('quick_connect_error', error='Gateway connection failed') + finally: + credentials.clear() + if connection_id and not committed: + connection_pool.temp_connection_pool.request_close(connection_id, user_id) + registry.finish(attempt) + try: + app.extensions['runtime_lifecycle'].start_job( + 'gateway_quick_connect', connect, owner_id=user_id, + ) + except Exception: + credentials.clear() + registry.finish(attempt) + emit('quick_connect_error', {'error': 'Server is shutting down', + 'client_request_id': request_id}) + + +@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 = current_app.extensions['ssh_gateway_registry'].get( + current_user.id, request.sid, data.get('client_request_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 = current_app.extensions['ssh_gateway_registry'].get( + current_user.id, request.sid, data.get('client_request_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 = current_app.extensions['ssh_gateway_registry'].get( + current_user.id, request.sid, data.get('client_request_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 = current_app.extensions['ssh_gateway_registry'].get( + current_user.id, request.sid, data.get('client_request_id'), + ) + if not attempt or not getattr(attempt, 'quick', False): + return {'success': False} + with attempt.condition: + if attempt.quick_committed: + return {'success': False, 'reason': 'already_committed'} + attempt.cancelled = True + attempt.cancel() + return {'success': True} diff --git a/app/ssh_gateway.py b/app/ssh_gateway.py new file mode 100644 index 0000000..c1fe3db --- /dev/null +++ b/app/ssh_gateway.py @@ -0,0 +1,39 @@ +"""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 is_gateway(value): + """Recognize only fully validated selectors.""" + try: + parse_selector(value) + return True + except ValueError: + return False + + +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..364e37f --- /dev/null +++ b/app/ssh_gateway_auth.py @@ -0,0 +1,101 @@ +"""Explicit multi-factor authentication using Paramiko's public transport API.""" +import time + +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 + transport.auth_timeout = 180 + 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": + 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: + 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) + methods = transport.auth_interactive(self.username, handler) + except BadAuthenticationType as error: + methods = error.allowed_types + self.check() + 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: + 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..0fc499f --- /dev/null +++ b/app/ssh_gateway_interaction.py @@ -0,0 +1,242 @@ +"""Bounded, socket-owned gateway interactions. No credentials are persisted.""" +from collections import deque +import secrets +import threading +import time + +from .quota_manager import QuotaKind, quota_manager + + +class GatewayCancelled(ValueError): + def __init__(self): + super().__init__("Gateway connection cancelled or timed out") + + +class GatewayAttempt: + def __init__(self, user_id, sid, request_id, emit, *, reservation=None): + self.user_id = str(user_id) + self.sid = sid + self.request_id = request_id + self.emit = emit + self.reservation = reservation + self.deadline = time.monotonic() + 300 + self.auth_deadline = time.monotonic() + 180 + self.condition = threading.Condition(threading.RLock()) + self.cancelled = False + self.finished = False + self.resources = [] + 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 = {} + self.guard = threading.Timer(300, self.cancel) + self.guard.daemon = True + self.guard.start() + + def check(self): + with self.condition: + if (self.cancelled or self.finished or time.monotonic() >= self.deadline + or (self.phase == 'auth' and time.monotonic() >= self.auth_deadline)): + raise GatewayCancelled() + + def own(self, resource): + with self.condition: + if not self.cancelled and not self.finished and time.monotonic() < self.deadline: + if resource not in self.resources: + self.resources.append(resource) + return resource + resource.close() + raise GatewayCancelled() + + def handoff(self, *resources): + with self.condition: + self.check() + for resource in resources: + if resource in self.resources: + self.resources.remove(resource) + + 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 + + def cancel(self): + with self.condition: + self.cancelled = True + self.responses = None + resources, self.resources = self.resources, [] + self.inputs.clear() + self.condition.notify_all() + # Paramiko close may take locks: never close under the registry lock. + for resource in reversed(resources): + try: + resource.close() + except Exception: + pass + + def finish(self): + self.guard.cancel() + self.cancel() + with self.condition: + if self.finished: + return + self.finished = True + if self.reservation is not None: + self.reservation.release() + + +class GatewayRegistry: + def __init__(self): + self.lock = threading.Lock() + self.attempts = {} + self.stopping = False + + def create(self, user_id, sid, request_id, emit): + if not isinstance(request_id, str) or not 1 <= len(request_id) <= 128: + raise ValueError("Gateway request ID is required") + key = (str(user_id), sid, request_id) + with self.lock: + if self.stopping or key in self.attempts: + raise ValueError("Gateway request is unavailable") + reservation = quota_manager.reserve(QuotaKind.BACKGROUND_JOB, user_id) + try: + attempt = GatewayAttempt(user_id, sid, request_id, emit, reservation=reservation) + self.attempts[key] = attempt + return attempt + except Exception: + 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() + key = (attempt.user_id, attempt.sid, attempt.request_id) + with self.lock: + if self.attempts.get(key) is attempt: + del self.attempts[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() + + def shutdown(self): + with self.lock: + self.stopping = True + attempts = list(self.attempts.values()) + for attempt in attempts: + attempt.cancel() diff --git a/app/ssh_gateway_setup.py b/app/ssh_gateway_setup.py new file mode 100644 index 0000000..f257e97 --- /dev/null +++ b/app/ssh_gateway_setup.py @@ -0,0 +1,126 @@ +"""Prove target readiness before any terminal startup action is allowed.""" +import secrets +import socket +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("echo "+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() + # 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() + raise GatewayCancelled() diff --git a/app/ssh_manager.py b/app/ssh_manager.py index 4297b02..e5656a8 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, @@ -877,7 +913,7 @@ def close_session(session_id, kill_tmux=False): """Close SSH session and clean up resources. kill_tmux: If True and the session uses tmux, kill the remote tmux session. - Default False — idle timeout and server restart detach only, + Default False — idle timeout and server restart detach only, leaving tmux running so the session shows up as a reconnect candidate. Pass True only from explicit user disconnect. """ diff --git a/docs/warpgate.md b/docs/warpgate.md new file mode 100644 index 0000000..f85e711 --- /dev/null +++ b/docs/warpgate.md @@ -0,0 +1,27 @@ +# Warpgate SSH connections + +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. + +## 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 `echo` 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..b1d57e2 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -6311,3 +6311,11 @@ body.keyboard-open.notepad-focused .notepad-panel { opacity: 1; color: var(--error-color, #f87171); } + +/* Transient SSH gateway interaction */ +.gateway-modal { z-index: 1100; } +.gateway-modal .modal-content { width: min(95vw, 850px); max-height: 90vh; overflow: auto; } +.gateway-modal p { white-space: pre-wrap; overflow-wrap: anywhere; } +.gateway-modal label { display: block; margin: 12px 0; } +.gateway-modal input { display: block; width: 100%; } +.gateway-terminal { margin: 12px 0; overflow: auto; } diff --git a/static/js/app.js b/static/js/app.js index 737f7ef..370965f 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); @@ -2082,8 +2085,13 @@ 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 validateUser = () => { + const gateway = authTypeSelect?.value !== 'tailscale' && validation.isGateway(userInput.value); + hint(userInput, userHint, + validation.isValidUsername(userInput.value, authTypeSelect?.value !== 'tailscale'), 'validation.username'); + if (passwordInput && authTypeSelect?.value === 'password') passwordInput.required = !gateway; + if (passHint && gateway) passHint.textContent = i18n.t('gateway.passwordHint', 'Leave the password empty for interactive gateway authentication.'); + }; hostInput.addEventListener('input', validateHost); portInput.addEventListener('input', validatePort); userInput.addEventListener('input', validateUser); @@ -2096,7 +2104,7 @@ if (passwordInput) { passwordInput.addEventListener('input', () => { const value = passwordInput.value; - const isValid = value.length > 0; + const isValid = value.length > 0 || validation.isGateway(userInput.value); setFieldState(passwordInput, passHint, isValid ? '' : i18n.t('connection.passwordRequired'), isValid); }); } @@ -2627,7 +2635,7 @@ return; } - if (authType === 'password' && !password) { + if (authType === 'password' && !password && !window.ConnectionValidation.isGateway(username)) { showNotification('Password is required', 'error'); document.getElementById('passwordInput').focus(); return; @@ -3006,6 +3014,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..06f6b02 100644 --- a/static/js/connection-validation.js +++ b/static/js/connection-validation.js @@ -25,11 +25,20 @@ /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(label) )); } - function isValidUsername(value) { + function isGateway(value) { + 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..c1d5fd7 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -1,6 +1,13 @@ 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.", + '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 +1417,13 @@ 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.", + '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 +2832,13 @@ 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.", + '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 +4246,13 @@ 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.", + '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 +5660,13 @@ 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.", + '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 +7074,13 @@ const translations = { }, zh: { + 'gateway.title': "网关连接", + 'gateway.authentication': "网关身份验证", + 'gateway.response': "响应", + 'gateway.continue': "继续", + 'gateway.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..f009c8a 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; @@ -485,7 +490,7 @@ const SessionManager = { return; } - // Active session — disconnect first, then reconnect + // Active session — disconnect first, then reconnect if (session.connected) { const message = window.i18n ? i18n.t('session.reconnectConfirm').replace('{label}', label) @@ -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) @@ -540,7 +546,7 @@ const SessionManager = { } }, 500); } else { - // No key_id — open pre-filled connection modal + // No key_id — open pre-filled connection modal setTimeout(() => { window.clearConnectionProfileState(); const hostInput = document.getElementById('hostInput'); @@ -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; @@ -834,7 +840,7 @@ const SessionManager = { notesEl.textContent = `${session.username}@${session.host}:${session.port}`; if (session.viaJump) { const via = window.i18n ? i18n.t('connection.via') : 'via'; - notesEl.appendChild(document.createTextNode(' · ')); + notesEl.appendChild(document.createTextNode(' · ')); const viaSpan = document.createElement('span'); viaSpan.className = 'session-via'; viaSpan.textContent = `${via} ${session.viaJump}`; @@ -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,7 +1617,8 @@ const SessionManager = { connectionData.key_id = keyId; } if (route.proxyJump) connectionData.proxy_jump = route.proxyJump; - window.socket.emit('ssh_connect', connectionData); + window.SSHGatewayDialog?.prepare(connectionData); + window.socket.emit('ssh_connect', connectionData); const label = `${username}@${host}`; const message = window.i18n ? i18n.t('session.reconnecting').replace('{label}', label) diff --git a/static/js/sftp-file-manager.js b/static/js/sftp-file-manager.js index 12b8f9d..9428032 100644 --- a/static/js/sftp-file-manager.js +++ b/static/js/sftp-file-manager.js @@ -1876,6 +1876,10 @@ class SFTPFileManager { }); 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 +2984,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 +3011,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 +3022,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 +3039,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..622c014 --- /dev/null +++ b/static/js/ssh-gateway-dialog.js @@ -0,0 +1,192 @@ +/* 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(); + attempts.delete(id); + if (active === id) { + window.ModalManager.close(modal); + content.replaceChildren(); + active = null; + } + } + 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) { + close(id); + attempt.onCancel?.(); + } + }); + } + function open(id) { + if (!attempts.has(id)) return null; + if (active !== id) { + content.replaceChildren(); + active = id; + } + window.ModalManager.open(modal); + return attempts.get(id); + } + function appendInstructions(parent, text) { + // Server text remains text; only explicit HTTP(S) links are clickable. + const paragraph = element('p'); + 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; + content.replaceChildren(); + content.append(element('h3', data.title || t('gateway.authentication', 'Gateway authentication'))); + appendInstructions(content, data.instructions || ''); + const form = element('form'); + const inputs = []; + for (const prompt of data.prompts) { + const label = element('label', prompt.label || t('gateway.response', 'Response')); + const input = element('input'); + 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; + 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'; + content = element('div'); + const button = element('button', t('gateway.cancel', 'Cancel connection')); + button.type = 'button'; + button.className = 'btn btn-secondary'; + button.addEventListener('click', cancel); + box.append(content, button); + 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}); + } + }, + close, + }; +}()); diff --git a/templates/index.html b/templates/index.html index d5cf975..002d38f 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1471,6 +1471,7 @@

File Preview

+ '}], + }); + }); + const modal = page.locator('#sshGatewayModal'); + await expect(modal).toHaveClass(/show/); + await expect(modal.locator('img,script')).toHaveCount(0); + await expect(modal.locator('input')).toHaveAttribute('type', 'password'); + await expect(modal.locator('a')).toHaveCount(1); + await expect(modal.locator('a')).toHaveAttribute('rel', 'noopener noreferrer'); + await modal.locator('input').fill('123456'); + await modal.locator('button[type=submit]').click(); + await expect(modal.locator('input')).toHaveValue(''); + expect(await page.evaluate(() => window.__gatewaySent[0])).toEqual({ + event: 'ssh_gateway_answer', + data: {client_request_id: 'gateway-test', challenge_id: 'one-shot', answers: ['123456']}, + }); + await page.evaluate(() => { + window.socket.listeners('ssh_error').forEach(fn => fn({ + client_request_id: 'gateway-test', error: 'cancelled', + })); + }); + await expect(modal).not.toHaveClass(/show/); + await expect(modal.locator('input')).toHaveCount(0); + assertNoExternalRequests(page); +}); + +test('ordinary passwords stay required and gateway passwords can be empty', async ({page}) => { + await login(page); + await page.evaluate(() => window.openDefaultConnectionModal()); + await page.locator('#usernameInput').fill('ordinary'); + await expect(page.locator('#passwordInput')).toHaveJSProperty('required', true); + await page.locator('#usernameInput').fill('user:target'); + await expect(page.locator('#passwordInput')).toHaveJSProperty('required', false); + await page.locator('#usernameInput').fill('ordinary'); + await expect(page.locator('#passwordInput')).toHaveJSProperty('required', true); + assertNoExternalRequests(page); +}); diff --git a/tests/integration/test_warpgate.py b/tests/integration/test_warpgate.py new file mode 100644 index 0000000..939bca7 --- /dev/null +++ b/tests/integration/test_warpgate.py @@ -0,0 +1,193 @@ +"""Opt-in tests for a disposable, loopback-only Warpgate 0.29.0 fixture. + +See tests/integration/warpgate/README.md. No external gateway is accepted. +""" +import base64 +import hashlib +import hmac +import ipaddress +import json +import os +from pathlib import Path +import ssl +import struct +import threading +import time +import urllib.request +from urllib.parse import urlsplit + +import paramiko +import pytest + +from app.ssh_gateway_auth import GatewayAuthStrategy, GatewayTransport +from app.ssh_gateway_interaction import GatewayAttempt, GatewayCancelled +from app.ssh_gateway_setup import prepare_terminal, prepare_sftp + + +@pytest.fixture +def gateway(): + filename = os.environ.get("WEBSSH_WARPGATE_FIXTURE") + if not filename: + pytest.skip("Disposable Warpgate fixture not configured") + assert "PYTEST_XDIST_WORKER" not in os.environ, "Run the disposable policy tests serially" + config = json.loads(Path(filename).read_text(encoding="utf-8")) + assert config["disposable"] is True + assert ipaddress.ip_address(config["host"]).is_loopback + api_url = urlsplit(config["api_url"]) + assert api_url.scheme == "https" and ipaddress.ip_address(api_url.hostname).is_loopback + # Trust the disposable instance's exact certificate. Its generated name need + # not match the loopback literal; this context is never used by the product. + context = ssl.create_default_context(cafile=config["certificate"]) + context.check_hostname = False + + def api(path, data=None, method=None): + request = urllib.request.Request( + config["api_url"] + path, + data=json.dumps(data).encode() if data is not None else None, + headers={"X-Warpgate-Token": config["admin_token"], "Content-Type": "application/json"}, + method=method, + ) + with urllib.request.urlopen(request, context=context, timeout=5) as response: + content = response.read() + return json.loads(content) if content else None + user_path = "/users/" + config["user_id"] + user = api(user_path) + target_path = "/targets/" + config["target_id"] + target = api(target_path) + + def policy(kinds): + api(user_path, {**user, "credential_policy": {"ssh": kinds}}, "PUT") + + try: + yield config, api, policy + finally: + api(user_path, user, "PUT") + api(target_path, target, "PUT") + + +def client_for(config): + client = paramiko.SSHClient() + key = paramiko.PKey.from_type_string( + config["host_key_type"], base64.b64decode(config["host_key"]), + ) + client.get_host_keys().add(f"[{config['host']}]:{config['port']}", key.get_name(), key) + return client + + +def connect(client, config, **auth): + client.connect(config["host"], port=config["port"], username=config["selector"], + timeout=5, transport_factory=GatewayTransport, + auth_strategy=GatewayAuthStrategy(config["selector"], **auth)) + + +def current_otp(config): + secret = bytes.fromhex(config["otp_secret_hex"]) + digest = hmac.new(secret, struct.pack(">Q", int(time.time()) // 30), hashlib.sha1).digest() + offset = digest[-1] & 15 + return str((struct.unpack(">I", digest[offset:offset+4])[0] & 0x7fffffff) % 1000000).zfill(6) + + +@pytest.mark.parametrize("kinds", [ + ["Password"], ["Password", "Totp"], ["Totp"], + ["PublicKey"], ["PublicKey", "Password"], ["PublicKey", "Password", "Totp"], +]) +def test_reference_authentication_policies(gateway, kinds): + config, api, policy = gateway + policy(kinds) + key = None + credential = None + client = client_for(config) + prompts_seen = [] + def answer(_title, _instructions, prompts): + prompts_seen.extend(label for label, _echo in prompts) + return [current_otp(config) if "One-time" in label else config["password"] + for label, _echo in prompts] + try: + if "PublicKey" in kinds: + key = paramiko.RSAKey.generate(2048) + credential = api("/users/" + config["user_id"] + "/credentials/public-keys", + {"label": "disposable-integration", "openssh_public_key": key.get_name()+" "+key.get_base64()}) + connect(client, config, pkey=key, password=( + config["password"] if "Password" in kinds and key is None else None + ), interact=answer) + assert client.get_transport().is_authenticated() + if "Totp" in kinds: + assert any("One-time" in prompt for prompt in prompts_seen) + if key and "Password" in kinds: + assert "Password" in prompts_seen + finally: + client.close() + if credential: + api("/users/"+config["user_id"]+"/credentials/public-keys/"+credential["id"], method="DELETE") + + +def test_terminal_and_sftp_target_readiness(gateway): + config, _api, policy = gateway + policy(["Password"]) + def emit(event, data): + if event == "ssh_gateway_output": + attempt.ack(data["sequence"]) + attempt = GatewayAttempt(1, "reference", "ready", emit) + client = attempt.own(client_for(config)) + try: + connect(client, config, password=config["password"], + interact=attempt.challenge, check=attempt.check) + prepare_terminal(client.get_transport(), attempt) + sftp = prepare_sftp(client.get_transport(), attempt, operation_timeout=5) + assert sftp.normalize(".") + finally: + attempt.finish() + + +def test_browser_approval_can_be_cancelled_without_authenticating(gateway): + config, _api, policy = gateway + policy(["WebUserApproval"]) + client = client_for(config) + seen = [] + def abort(_title, instructions, prompts): + assert instructions and prompts + seen.append(True) + raise GatewayCancelled() + try: + with pytest.raises((GatewayCancelled, paramiko.AuthenticationException)): + connect(client, config, interact=abort) + assert seen and not client.get_transport().is_authenticated() + finally: + client.close() + + +def test_pending_administrator_approval_blocks_target_readiness(gateway): + config, api, policy = gateway + policy(["Password"]) + path = "/targets/"+config["target_id"] + target = api(path) + api(path, {**target, "require_approval": True}, "PUT") + def emit(event, data): + if event == "ssh_gateway_output": + attempt.ack(data["sequence"]) + attempt = GatewayAttempt(1, "reference", "approval", emit) + client = attempt.own(client_for(config)) + timer = threading.Timer(.5, attempt.cancel) + try: + connect(client, config, password=config["password"], interact=attempt.challenge) + timer.start() + with pytest.raises((GatewayCancelled, paramiko.SSHException, OSError)): + prepare_terminal(client.get_transport(), attempt) + assert not client.get_transport() or not client.get_transport().is_active() + finally: + timer.cancel() + attempt.finish() + + +def test_changed_gateway_host_key_fails_before_authentication(gateway): + config, _api, _policy = gateway + client = client_for(config) + wrong = paramiko.RSAKey.generate(2048) + client.get_host_keys().clear() + client.get_host_keys().add(f"[{config['host']}]:{config['port']}", wrong.get_name(), wrong) + try: + with pytest.raises(paramiko.BadHostKeyException): + connect(client, config, password=config["password"], + interact=lambda *_args: pytest.fail("prompt before host trust")) + finally: + client.close() diff --git a/tests/integration/warpgate/README.md b/tests/integration/warpgate/README.md new file mode 100644 index 0000000..d695553 --- /dev/null +++ b/tests/integration/warpgate/README.md @@ -0,0 +1,51 @@ +# Disposable Warpgate reference tests + +Run `tests/integration/test_warpgate.py` serially against a dedicated local +Warpgate 0.29.0 instance and an OpenSSH target. The fixture must listen only on +loopback and contain synthetic accounts. Never point these tests at an existing +gateway: they change the fixture user's SSH credential policy and the target's +approval requirement, restoring both afterwards. + +No application Compose changes are needed. Test prerequisites: + +- Warpgate 0.29.0, with its published binary checksum verified. +- A local OpenSSH daemon on an unprivileged loopback port, with a disposable host + key, the gateway's public client key in a temporary authorized-keys file, + PTY/exec and SFTP enabled, and two session channels allowed. +- A Warpgate user with a synthetic password and a known TOTP secret; a role + granting that user access to the SSH target; the target's host key reviewed and + trusted in the disposable gateway. +- A test-only Warpgate admin token, its exact TLS certificate, and a pinned public + gateway SSH host key. No trust-all host-key policy is used by the suite. + +Create an ignored JSON fixture description (for example under `.test-tmp/`): + +```json +{ + "disposable": true, + "host": "127.0.0.1", + "port": 12222, + "selector": "probe:probe", + "password": "", + "api_url": "https://127.0.0.1:18443/@warpgate/admin/api", + "certificate": "", + "admin_token": "", + "user_id": "", + "target_id": "", + "otp_secret_hex": "", + "host_key_type": "ssh-ed25519", + "host_key": "" +} +``` + +Set `WEBSSH_WARPGATE_FIXTURE` to that file and run: + +```sh +python -m pytest tests/integration/test_warpgate.py -q +``` + +Without this variable, these integration tests skip. They cover password, OTP, +key and combined factor policies; terminal and SFTP readiness; pending admin and +browser approval cancellation; and changed gateway host-key rejection. Browser +approval completion through a real identity provider requires a separate +deployment-specific acceptance test. diff --git a/tests/js/connection-validation.test.js b/tests/js/connection-validation.test.js index 9c5febf..66ae710 100644 --- a/tests/js/connection-validation.test.js +++ b/tests/js/connection-validation.test.js @@ -19,3 +19,14 @@ test('username and port hints match backend-supported formats', () => { for (const port of ['0', '65536', '22x', '22.5', '1e3', '']) assert.equal(validation.isValidPort(port), false, port); for (const port of ['1', '22', '65535']) assert.equal(validation.isValidPort(port), true, port); }); + +test('gateway selectors require an explicit validation opt-in', () => { + assert.equal(validation.isValidUsername('u:t'), false); + assert.equal(validation.isValidUsername('u:t', true), true); + for (const value of ['u:t', 'a@b.test:db:22', 'Müller:Ziel', 'a b:target', 'a:'+'é'.repeat(63)]) { + assert.equal(validation.isGateway(value), true, value); + } + for (const value of [':b', 'a:', ' a:b', 'a: b', 'a:b ', 'a#b:c', 'ticket-user:host', 'a:\u202eb', 'a:\n', 'a:\ud800', 'a:'+'é'.repeat(64)]) { + assert.equal(validation.isGateway(value), false, value); + } +}); diff --git a/tests/test_sftp_request_correlation.py b/tests/test_sftp_request_correlation.py index 9434fc9..3a595e1 100644 --- a/tests/test_sftp_request_correlation.py +++ b/tests/test_sftp_request_correlation.py @@ -229,6 +229,11 @@ def test_cancel_directory_listing_does_not_reveal_invalid_or_missing_state( def test_socket_disconnect_discards_only_its_directory_snapshots(monkeypatch): + from app.ssh_gateway_interaction import GatewayRegistry + monkeypatch.setattr( + socket_events, 'current_app', + SimpleNamespace(extensions={'ssh_gateway_registry': GatewayRegistry()}), + ) discarded = [] class Query: diff --git a/tests/test_ssh_gateway.py b/tests/test_ssh_gateway.py new file mode 100644 index 0000000..8cd5311 --- /dev/null +++ b/tests/test_ssh_gateway.py @@ -0,0 +1,22 @@ +"""Gateway selectors never widen ordinary SSH identities.""" +import pytest +from app.ssh_gateway import parse_selector, tmux_name + +@pytest.mark.parametrize("value", ["alice:server", "a@b.test:db:22", "Müller:Ziel", "a b:target"]) +def test_explicit_selector_round_trips(value): + assert ":".join(parse_selector(value)) == value + +@pytest.mark.parametrize("value", ["a", "", None, ":b", "a:", " a:b", "a: b", "a:b ", "a#b:c", "ticket-user:host", "a:\u202eb", "a:\n", "a:\ud800", "a:"+"é"*64]) +def test_reject_ambiguous_or_unsafe_selector(value): + with pytest.raises(ValueError): + parse_selector(value) + +def test_byte_boundary(): + assert parse_selector("a:"+"é"*63)[1] == "é"*63 + +def test_tmux_name_is_bounded_safe_and_owner_scoped(): + import re + a=tmux_name("x"*200, "host", 22, "user:target", 1) + b=tmux_name("x"*200, "host", 22, "user:target", 2) + assert re.fullmatch(r"[A-Za-z0-9_]{1,190}", a) + assert a != b diff --git a/tests/test_ssh_gateway_auth.py b/tests/test_ssh_gateway_auth.py new file mode 100644 index 0000000..d0aa420 --- /dev/null +++ b/tests/test_ssh_gateway_auth.py @@ -0,0 +1,77 @@ +import paramiko +import pytest +from app.ssh_gateway_auth import GatewayAuthStrategy + + +class Transport: + def __init__(self): + self.authenticated = False + self.calls = [] + def is_authenticated(self): + return self.authenticated + def auth_password(self, username, password, fallback=True): + self.calls.append(("password", password, fallback)) + raise paramiko.BadAuthenticationType("More factors", ["keyboard-interactive"]) + def auth_interactive(self, username, handler): + self.calls.append(("interactive",)) + assert handler("OTP", "Enter code", [("Code", True)]) == ["123456"] + self.authenticated = True + return [] + + +def test_password_never_replayed_into_otp(): + transport = Transport() + strategy = GatewayAuthStrategy("u:t", password="initial-secret", interact=lambda *args: ["123456"]) + strategy.authenticate(transport) + assert transport.calls == [("password", "initial-secret", False), ("interactive",)] + + +def test_empty_password_starts_interactive(): + transport = Transport() + GatewayAuthStrategy("u:t", interact=lambda *args: ["123456"]).authenticate(transport) + assert transport.calls == [("interactive",)] + + +def test_no_success_without_authenticated_transport(): + transport = Transport() + transport.auth_interactive = lambda *args: [] + with pytest.raises(paramiko.AuthenticationException): + GatewayAuthStrategy("u:t", interact=lambda *args: []).authenticate(transport) + + +def test_generic_auth_failure_does_not_guess_next_method(): + transport = Transport() + def reject(*args, **kwargs): + raise paramiko.AuthenticationException("Denied") + transport.auth_password = reject + with pytest.raises(paramiko.AuthenticationException): + GatewayAuthStrategy("u:t", password="wrong", interact=lambda *args: pytest.fail()).authenticate(transport) + assert transport.calls == [] + +def test_service_request_wait_is_bounded_and_disconnect_aware(monkeypatch): + from app.ssh_gateway_auth import GatewayTransport + from paramiko import SSHException + transport = object.__new__(GatewayTransport) + transport.active = False + transport.initial_kex_done = True + transport._service_userauth_accepted = False + with pytest.raises(SSHException): + transport.ensure_session() + + +def test_service_request_is_sent_only_once(monkeypatch): + from app.ssh_gateway_auth import GatewayTransport + transport = object.__new__(GatewayTransport) + transport.active = True + transport.initial_kex_done = True + transport._service_userauth_accepted = True + transport._send_message = lambda message: pytest.fail("duplicate SSH service request") + transport.ensure_session() + +def test_key_with_password_and_otp_supplies_password_before_interactive(): + transport = Transport() + transport.auth_publickey = lambda *args: ["password", "keyboard-interactive"] + strategy = GatewayAuthStrategy("u:t", pkey=object(), + interact=lambda title, instructions, prompts: ["initial-secret" if prompts[0][0] == "Password" else "123456"]) + strategy.authenticate(transport) + assert transport.calls == [("password", "initial-secret", False), ("interactive",)] diff --git a/tests/test_ssh_gateway_hooks.py b/tests/test_ssh_gateway_hooks.py new file mode 100644 index 0000000..1c12f40 --- /dev/null +++ b/tests/test_ssh_gateway_hooks.py @@ -0,0 +1,68 @@ +import pytest +from app.socket_events import _validate_ssh_params + +def test_gateway_validation_is_explicitly_opt_in(): + assert _validate_ssh_params("host",22,"u:t")[3] + assert _validate_ssh_params("host",22,"u:t",allow_gateway=True) == ("host",22,"u:t",None) + +@pytest.mark.parametrize("value", [" u:t", "u:t ", "u: t", "ticket-user:t", "u#x:t"]) +def test_gateway_validation_does_not_trim_or_reinterpret(value): + assert _validate_ssh_params("host",22,value,allow_gateway=True)[3] + +def test_ordinary_validation_keeps_trimming(): + assert _validate_ssh_params("host",22," user ") == ("host",22,"user",None) + +def test_profile_storage_keeps_legacy_schema_with_gateway(app): + from app import profile_manager + from tests.test_command_set_socket_events import create_socket_user + user_id, _sid = create_socket_user(app, "gateway_profile") + with app.app_context(): + profile, error = profile_manager.add_profile(user_id, "Gateway", "host", 22, "u:t", "password") + assert error is None + assert profile["auth_type"] == "password" + assert profile_manager._valid_profile(profile) + + +@pytest.mark.usefixtures("direct_socket_authentication") +def test_socket_gateway_admission_and_cancel(app, monkeypatch): + import threading + from flask import request + from app import socket_events, ssh_manager + from tests.test_command_set_socket_events import create_socket_user + user_id, sid = create_socket_user(app, "gateway_cancel") + entered, finished = threading.Event(), threading.Event() + received = [] + monkeypatch.setattr(socket_events, "emit", lambda *args, **kwargs: received.append(args)) + def connect(**kwargs): + attempt = kwargs["gateway_attempt"] + assert kwargs["password"] is None + entered.set() + try: + attempt.challenge("", "", [("OTP", False)]) + except ValueError: + pass + finally: finished.set() + return None, "cancelled" + monkeypatch.setattr(ssh_manager, "create_ssh_connection", connect) + with app.test_request_context("/socket.io"): + request.sid = sid + socket_events.handle_ssh_connect({ + "host":"host", "username":"u:t", "auth_type":"password", + "client_request_id":"gateway-request", "gateway_interaction":1, + }) + assert entered.wait(2), received + with app.test_request_context("/socket.io"): + request.sid = sid + assert socket_events.handle_ssh_connect_cancel({"client_request_id":"gateway-request"})["success"] + assert finished.wait(2) + + +@pytest.mark.usefixtures("direct_socket_authentication") +def test_gateway_requires_client_capability(app, monkeypatch): + from app import socket_events, ssh_manager + from tests.test_command_set_socket_events import create_socket_user, call_socket_handler + _, sid = create_socket_user(app, "gateway_old_client") + monkeypatch.setattr(ssh_manager, "create_ssh_connection", lambda **kwargs: pytest.fail("connected")) + _, events = call_socket_handler(app, monkeypatch, socket_events.handle_ssh_connect, sid, + {"host":"host", "username":"u:t", "client_request_id":"legacy"}) + assert any("updated interactive client" in payload.get("error","") for event,payload in events) diff --git a/tests/test_ssh_gateway_interaction.py b/tests/test_ssh_gateway_interaction.py new file mode 100644 index 0000000..4cb66bd --- /dev/null +++ b/tests/test_ssh_gateway_interaction.py @@ -0,0 +1,112 @@ +import threading +import time +import pytest +from app.ssh_gateway_interaction import GatewayAttempt, GatewayCancelled + + +def test_challenge_is_owned_one_shot_and_never_replays(): + sent = [] + attempt = GatewayAttempt("1", "sid", "req", lambda event, data: sent.append((event, data))) + result = [] + worker = threading.Thread(target=lambda: result.append(attempt.challenge("Title", "", [("OTP", True)]))) + worker.start() + for _ in range(100): + if sent: break + time.sleep(.005) + prompt = sent[0][1] + assert not attempt.answer("2", "sid", prompt["challenge_id"], ["secret"]) + assert not attempt.answer("1", "other", prompt["challenge_id"], ["secret"]) + assert attempt.answer("1", "sid", prompt["challenge_id"], ["123456"]) + assert not attempt.answer("1", "sid", prompt["challenge_id"], ["replay"]) + worker.join(1) + assert result == [["123456"]] + attempt.finish() + + +def test_cancel_closes_resources_and_wakes_prompt(): + sent = [] + attempt = GatewayAttempt("1", "sid", "req", lambda *args: sent.append(args)) + closed = threading.Event() + class Resource: + def close(self): closed.set() + attempt.own(Resource()) + errors = [] + def wait(): + try: attempt.challenge("", "", [("OTP", False)]) + except GatewayCancelled: errors.append(True) + worker = threading.Thread(target=wait); worker.start() + attempt.cancel() + worker.join(1) + assert closed.is_set() and errors == [True] + attempt.finish() + + +def test_limits_reject_before_emitting_unbounded_remote_data(): + attempt = GatewayAttempt("1", "sid", "req", lambda *args: pytest.fail()) + with pytest.raises(ValueError): + attempt.challenge("x"*16385, "", []) + with pytest.raises(ValueError): + attempt.challenge("", "", [("x", False)]*9) + attempt.finish() + + +def test_handoff_prevents_late_cleanup_from_closing_owned_session(): + attempt = GatewayAttempt("1", "sid", "req", lambda *args: None) + class Resource: + def close(self): pytest.fail("closed after handoff") + resource = Resource() + attempt.own(resource) + attempt.handoff(resource) + attempt.finish() + +def test_cancel_retains_admission_until_job_finishes(): + released = [] + class Reservation: + def release(self): released.append(True) + attempt = GatewayAttempt(1, "socket", "req", lambda *args: None, reservation=Reservation()) + attempt.cancel() + assert released == [] + attempt.finish() + attempt.finish() + assert released == [True] + + +def test_output_has_a_four_frame_window_and_exact_acknowledgements(): + sent = [] + attempt = GatewayAttempt(1, "socket", "req", lambda *args: sent.append(args)) + try: + for _ in range(4): attempt.output(b"x") + assert len(attempt.unacked) == 4 + assert not attempt.ack(True) + assert not attempt.ack(5) + assert attempt.ack(1) + assert not attempt.ack(1) + attempt.output(b"next") + assert len(attempt.unacked) == 4 + assert len(sent) == 5 + finally: attempt.finish() + + +def test_setup_input_cannot_escape_phase_or_byte_limits(): + attempt = GatewayAttempt(1, "socket", "req", lambda *args: None) + try: + assert not attempt.input("y") + attempt.start_setup() + assert not attempt.input("x"*1025) + for _ in range(8): assert attempt.input("x"*1024) + assert not attempt.input("y") + attempt.cancel() + assert not attempt.input("y") + finally: attempt.finish() + + +def test_resource_registered_after_cancel_is_closed_immediately(): + closed = [] + attempt = GatewayAttempt(1, "socket", "req", lambda *args: None) + class Resource: + def close(self): closed.append(True) + attempt.cancel() + with pytest.raises(GatewayCancelled): + attempt.own(Resource()) + assert closed == [True] + attempt.finish() diff --git a/tests/test_ssh_gateway_setup.py b/tests/test_ssh_gateway_setup.py new file mode 100644 index 0000000..c17c68b --- /dev/null +++ b/tests/test_ssh_gateway_setup.py @@ -0,0 +1,16 @@ +import pytest +from app.ssh_gateway_setup import ReadyLine + +def test_ready_line_requires_exact_complete_line_across_chunks(): + parser = ReadyLine(b"WEBSSH_READY_nonce") + parser.feed(b"banner WEBSSH_READY_nonce\r\nWEBSSH_RE") + assert not parser.ready + parser.feed(b"ADY_nonce\r") + assert not parser.ready + parser.feed(b"\n") + assert parser.ready + +def test_ready_parser_bounds_unterminated_lines(): + parser = ReadyLine(b"nonce") + with pytest.raises(ValueError): + parser.feed(b"x"*65537) diff --git a/tests/test_ssh_manager.py b/tests/test_ssh_manager.py index c5dc03d..39ceb5c 100644 --- a/tests/test_ssh_manager.py +++ b/tests/test_ssh_manager.py @@ -950,3 +950,33 @@ def test_changed_target_host_key_returns_stable_fail_closed_error(monkeypatch): assert error.context == 'target' assert clients[0].closed is True assert clients.opened_sockets[0].closed is True + +def test_gateway_readiness_failure_prevents_shell_and_startup(monkeypatch): + from app.ssh_gateway_interaction import GatewayAttempt + from app import ssh_gateway_setup + clients = install_ssh_clients(monkeypatch) + attempt = GatewayAttempt(7, "socket", "req", lambda *args: None) + def reject(*args): + raise ValueError("Target not ready") + monkeypatch.setattr(ssh_gateway_setup, "prepare_terminal", reject) + try: + session_id, error = connect_target(username="u:t", gateway_attempt=attempt, startup_commands="touch forbidden") + assert session_id is None and error + assert clients[0].transport.session_channels == [] + assert clients[0].closed + assert ssh_manager.sessions == {} + finally: + attempt.finish() + + +def test_gateway_handoff_preserves_established_session(monkeypatch): + from app.ssh_gateway_interaction import GatewayAttempt + from app import ssh_gateway_setup + clients = install_ssh_clients(monkeypatch) + attempt = GatewayAttempt(7, "socket", "req", lambda *args: None) + monkeypatch.setattr(ssh_gateway_setup, "prepare_terminal", lambda *args: None) + session_id, error = connect_target(username="u:t", gateway_attempt=attempt) + assert error is None + attempt.finish() + assert not clients[0].closed + assert ssh_manager.get_session(session_id)["connected"] From b97a9b4d9bf5b32bb8bbd6c690e780f317d8759d Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Wed, 23 Sep 2026 09:27:04 +0200 Subject: [PATCH 02/11] Close gateway cancellation races and verify approval flows --- app/socket_events.py | 82 +++++++++++++----- app/ssh_gateway_auth.py | 18 +++- app/ssh_gateway_interaction.py | 22 +++-- app/ssh_gateway_setup.py | 6 +- docs/warpgate.md | 2 +- static/js/app.js | 2 + static/js/sftp-file-manager.js | 4 + static/js/ssh-gateway-dialog.js | 3 +- tests/e2e/ssh-gateway.spec.js | 47 ++++++++++ tests/integration/test_warpgate.py | 120 ++++++++++++++++++++++++++ tests/integration/warpgate/README.md | 11 ++- tests/test_ssh_gateway_auth.py | 48 +++++++++++ tests/test_ssh_gateway_hooks.py | 77 +++++++++++++++++ tests/test_ssh_gateway_interaction.py | 47 ++++++++++ 14 files changed, 453 insertions(+), 36 deletions(-) diff --git a/app/socket_events.py b/app/socket_events.py index 51976a3..ba03895 100644 --- a/app/socket_events.py +++ b/app/socket_events.py @@ -766,13 +766,22 @@ def __init__( lifecycle_cancel_event, commit_lock=None, attempt=None, + gateway=None, ): + self._gateway = gateway self._user_cancel_event = user_cancel_event self._lifecycle_cancel_event = lifecycle_cancel_event self._commit_lock = commit_lock or threading.Lock() self._attempt = attempt def is_set(self): + if self._gateway is not None: + with self._gateway.condition: + if not self._gateway.committed: + try: + self._gateway.check() + except ValueError: + return True return ( self._user_cancel_event.is_set() or self._lifecycle_cancel_event.is_set() @@ -783,7 +792,7 @@ def wait(self, timeout=None): return True if timeout is None: while not self._user_cancel_event.wait(0.1): - if self._lifecycle_cancel_event.is_set(): + if self.is_set(): return True return True deadline = time.monotonic() + timeout @@ -796,15 +805,22 @@ def wait(self, timeout=None): def commit_if_active(self): """Linearize an irreversible setup step against user cancellation.""" + from contextlib import nullcontext with self._commit_lock: - if self.is_set(): - return False - if self._attempt is not None: - state = self._attempt.get('state', 'pending') - if state == 'cancelled' or state == 'finished': + # The gateway timer uses the same condition, so timeout and commit + # have one winner even after SSH resources have been handed off. + with self._gateway.condition if self._gateway is not None else nullcontext(): + if self.is_set(): return False - self._attempt['state'] = 'committed' - return True + if self._attempt is not None: + state = self._attempt.get('state', 'pending') + if state == 'cancelled' or state == 'finished': + return False + self._attempt['state'] = 'committed' + if self._gateway is not None: + self._gateway.committed = True + self._gateway.guard.cancel() + return True def _storage_error_payload(error, *, user_id, include_success=True, **extra): @@ -1539,7 +1555,11 @@ 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, @@ -1811,6 +1831,7 @@ def connect_ssh(lifecycle_cancel_event, credentials=credential_box): lifecycle_cancel_event, attempt['commit_lock'], attempt, + gateway=gateway_attempt, ) local_password = credentials.pop('password', None) local_key_content = credentials.pop('key_content', None) @@ -2004,6 +2025,14 @@ def connect_ssh(lifecycle_cancel_event, credentials=credential_box): 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 not client_cancel_event.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') gateway_registry.finish(gateway_attempt) credentials.clear() local_password = None @@ -4332,19 +4361,25 @@ 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') @@ -4355,18 +4390,18 @@ def handle_quick_connect(data, current_user=None): 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 and ':' not in username: - emit('quick_connect_error', {'error': 'Password or SSH key required'}) + 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: @@ -4390,7 +4425,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, @@ -4406,7 +4441,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 @@ -5643,11 +5678,14 @@ def connect(cancel_event): ), } attempt.quick_committed = True + attempt.committed = True + attempt.guard.cancel() attempt.send('quick_connect_success', **payload) committed = True except Exception: - if not attempt.cancelled: - attempt.send('quick_connect_error', error='Gateway connection failed') + 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: @@ -5709,10 +5747,12 @@ def handle_gateway_quick_cancel(data, current_user=None): current_user.id, request.sid, data.get('client_request_id'), ) if not attempt or not getattr(attempt, 'quick', False): - return {'success': False} + return {'success': False, 'reason': 'not_found'} with attempt.condition: if attempt.quick_committed: return {'success': False, 'reason': 'already_committed'} attempt.cancelled = True + if attempt.cancel_reason is None: + attempt.cancel_reason = 'user' attempt.cancel() return {'success': True} diff --git a/app/ssh_gateway_auth.py b/app/ssh_gateway_auth.py index 364e37f..5daa1e8 100644 --- a/app/ssh_gateway_auth.py +++ b/app/ssh_gateway_auth.py @@ -1,5 +1,6 @@ """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 @@ -19,7 +20,17 @@ def __init__(self, username, *, password=None, pkey=None, interact, check=None): def authenticate(self, transport): deadline = time.monotonic() + 180 - transport.auth_timeout = 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" ) @@ -34,6 +45,7 @@ def authenticate(self, transport): attempted.add(method) try: if method == "publickey": + remaining_timeout() methods = transport.auth_publickey(self.username, self.pkey) elif method == "password": password = self.password @@ -43,6 +55,7 @@ def authenticate(self, transport): raise AuthenticationException("Invalid authentication response") password = answers[0] try: + remaining_timeout() methods = transport.auth_password(self.username, password, fallback=False) finally: password = None @@ -55,10 +68,12 @@ def handler(title, instructions, prompts): 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)): @@ -70,6 +85,7 @@ def handler(title, instructions, prompts): raise AuthenticationException("Gateway authentication failed") raise AuthenticationException("Gateway authentication limit exceeded") finally: + guard.cancel() self.password = None self.pkey = None diff --git a/app/ssh_gateway_interaction.py b/app/ssh_gateway_interaction.py index 0fc499f..c7e65cb 100644 --- a/app/ssh_gateway_interaction.py +++ b/app/ssh_gateway_interaction.py @@ -23,6 +23,8 @@ def __init__(self, user_id, sid, request_id, emit, *, reservation=None): self.auth_deadline = time.monotonic() + 180 self.condition = threading.Condition(threading.RLock()) self.cancelled = False + self.cancel_reason = None + self.committed = False self.finished = False self.resources = [] self.prompt = None @@ -33,14 +35,15 @@ def __init__(self, user_id, sid, request_id, emit, *, reservation=None): self.output_bytes = 0 self.sequence = 0 self.unacked = {} - self.guard = threading.Timer(300, self.cancel) + self.guard = threading.Timer(300, self.cancel, kwargs={"reason": "timeout"}) self.guard.daemon = True self.guard.start() def check(self): with self.condition: - if (self.cancelled or self.finished or time.monotonic() >= self.deadline - or (self.phase == 'auth' and time.monotonic() >= self.auth_deadline)): + if (self.cancelled or self.finished or (not self.committed and ( + time.monotonic() >= self.deadline or + (self.phase == 'auth' and time.monotonic() >= self.auth_deadline)))): raise GatewayCancelled() def own(self, resource): @@ -168,8 +171,12 @@ def ack(self, sequence): self.condition.notify_all() return True - def cancel(self): + def cancel(self, *, reason="user"): with self.condition: + if self.committed and reason not in ("finished", "disconnected", "shutdown"): + return False + if self.cancel_reason is None: + self.cancel_reason = reason self.cancelled = True self.responses = None resources, self.resources = self.resources, [] @@ -181,10 +188,11 @@ def cancel(self): resource.close() except Exception: pass + return True def finish(self): self.guard.cancel() - self.cancel() + self.cancel(reason="finished") with self.condition: if self.finished: return @@ -232,11 +240,11 @@ 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() + attempt.cancel(reason="disconnected") def shutdown(self): with self.lock: self.stopping = True attempts = list(self.attempts.values()) for attempt in attempts: - attempt.cancel() + attempt.cancel(reason="shutdown") diff --git a/app/ssh_gateway_setup.py b/app/ssh_gateway_setup.py index f257e97..3553d9d 100644 --- a/app/ssh_gateway_setup.py +++ b/app/ssh_gateway_setup.py @@ -68,7 +68,7 @@ def prepare_terminal(transport, attempt): 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("echo "+marker.decode("ascii")) # nosec B601 + 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(): @@ -98,7 +98,7 @@ def pump(): stop.wait(.02) except Exception as error: errors.append(error) - attempt.cancel() + 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() @@ -122,5 +122,5 @@ def pump(): channel.close() worker.join(1) if worker.is_alive(): - attempt.cancel() + attempt.cancel(reason="failed") raise GatewayCancelled() diff --git a/docs/warpgate.md b/docs/warpgate.md index f85e711..9451091 100644 --- a/docs/warpgate.md +++ b/docs/warpgate.md @@ -17,7 +17,7 @@ No additional service, Compose setting, database migration, or dependency is req - 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 `echo` exec probe. Only after the nonce response and successful exit does WebSSH run normal terminal/tmux setup or configured startup commands. +- 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. diff --git a/static/js/app.js b/static/js/app.js index 370965f..89a7ee8 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1876,6 +1876,7 @@ || ( requestId !== currentConnectRequestId && !pendingRequestPaneMap.has(requestId) + && !window.SSHGatewayDialog?.has(requestId) ) ) { return false; @@ -1892,6 +1893,7 @@ const requestStillPending = ( requestId === currentConnectRequestId || pendingRequestPaneMap.has(requestId) + || window.SSHGatewayDialog?.has(requestId) ); const completedWhileCancelling = ( completedWhileCancellingRequestIds.delete(requestId) diff --git a/static/js/sftp-file-manager.js b/static/js/sftp-file-manager.js index 9428032..b0e0b83 100644 --- a/static/js/sftp-file-manager.js +++ b/static/js/sftp-file-manager.js @@ -1875,6 +1875,10 @@ 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; diff --git a/static/js/ssh-gateway-dialog.js b/static/js/ssh-gateway-dialog.js index 622c014..91fa3ff 100644 --- a/static/js/ssh-gateway-dialog.js +++ b/static/js/ssh-gateway-dialog.js @@ -34,7 +34,7 @@ } socket.emit(attempt.quick ? 'ssh_gateway_quick_cancel' : 'ssh_connect_cancel', {client_request_id: id}, result => { - if (result?.success) { + if (result?.success || result?.reason === 'not_found') { close(id); attempt.onCancel?.(); } @@ -187,6 +187,7 @@ attempts.set(payload.client_request_id, {quick, terminal: null, onCancel}); } }, + has: id => attempts.has(id), close, }; }()); diff --git a/tests/e2e/ssh-gateway.spec.js b/tests/e2e/ssh-gateway.spec.js index 3965556..77fc4a5 100644 --- a/tests/e2e/ssh-gateway.spec.js +++ b/tests/e2e/ssh-gateway.spec.js @@ -58,3 +58,50 @@ test('ordinary passwords stay required and gateway passwords can be empty', asyn await expect(page.locator('#passwordInput')).toHaveJSProperty('required', true); assertNoExternalRequests(page); }); + + +for (const action of ['button', 'escape']) { + test(`gateway direct reconnect can be cancelled using ${action}`, async ({page}) => { + await login(page); + await page.evaluate(() => { + window.__gatewayCancels = []; + const original = window.socket.emit.bind(window.socket); + window.socket.emit = (event, data, ack) => { + if (event === 'ssh_connect_cancel') { + window.__gatewayCancels.push(data.client_request_id); + ack?.({success: true, cancelled: true}); + return; + } + return original(event, data, ack); + }; + window.SSHGatewayDialog.prepare({username: 'user:target', client_request_id: 'reconnect_test'}); + window.socket.listeners('ssh_gateway_challenge').forEach(fn => fn({ + client_request_id: 'reconnect_test', challenge_id: 'otp', prompts: [{label: 'OTP'}], + })); + }); + const modal = page.locator('#sshGatewayModal'); + await expect(modal).toHaveClass(/show/); + if (action === 'escape') await modal.locator('input').press('Escape'); + else await modal.locator('.btn-secondary').click(); + await expect(modal).not.toHaveClass(/show/); + expect(await page.evaluate(() => window.__gatewayCancels)).toEqual(['reconnect_test']); + }); +} + + +test('quick gateway errors and disconnect release only the matching request', async ({page}) => { + await login(page); + const result = await page.evaluate(() => { + const manager = window.getSFTPFileManager(); + const dispatch = (event, data) => window.socket.listeners(event).forEach(fn => fn(data)); + manager.gatewayQuickRequestId = 'new-request'; + dispatch('quick_connect_error', {client_request_id: 'old-request', error: 'Invalid host'}); + const afterOld = manager.gatewayQuickRequestId; + dispatch('quick_connect_error', {client_request_id: 'new-request', error: 'Invalid host'}); + const afterCurrent = manager.gatewayQuickRequestId; + manager.gatewayQuickRequestId = 'retry'; + dispatch('disconnect', 'transport close'); + return {afterOld, afterCurrent, afterDisconnect: manager.gatewayQuickRequestId}; + }); + expect(result).toEqual({afterOld: 'new-request', afterCurrent: null, afterDisconnect: null}); +}); diff --git a/tests/integration/test_warpgate.py b/tests/integration/test_warpgate.py index 939bca7..a4280ce 100644 --- a/tests/integration/test_warpgate.py +++ b/tests/integration/test_warpgate.py @@ -191,3 +191,123 @@ def test_changed_gateway_host_key_fails_before_authentication(gateway): interact=lambda *_args: pytest.fail("prompt before host trust")) finally: client.close() + + +def test_administrator_approval_allows_readiness_only_after_approval(gateway): + config, api, policy = gateway + policy(["Password"]) + path = "/targets/" + config["target_id"] + target = api(path) + api(path, {**target, "require_approval": True}, "PUT") + existing = {item["id"] for item in api("/session-approvals")} + attempt = GatewayAttempt(1, "reference", "approved", lambda event, data: + attempt.ack(data["sequence"]) if event == "ssh_gateway_output" else None) + client = attempt.own(client_for(config)) + done = threading.Event() + failures = [] + def setup(): + try: + prepare_terminal(client.get_transport(), attempt) + except Exception as error: + failures.append(error) + finally: + done.set() + worker = threading.Thread(target=setup) + try: + connect(client, config, password=config["password"], interact=attempt.challenge) + worker.start() + deadline = time.monotonic() + 10 + pending = [] + while time.monotonic() < deadline: + pending = [item for item in api("/session-approvals") + if item["id"] not in existing and item["target"] == target["name"]] + if pending: break + assert not done.wait(.05), failures + assert len(pending) == 1 and not done.is_set() + api("/session-approvals/" + pending[0]["id"] + "/approve", + {"scope": "Once", "target": target["name"]}) + assert done.wait(10), "Approved target did not become ready" + assert not failures + finally: + attempt.finish() + if worker.ident is not None: worker.join(2) + + +def test_target_host_key_requires_explicit_verified_response(gateway): + config, api, policy = gateway + policy(["Password"]) + keys = api("/targets/" + config["target_id"] + "/known-ssh-host-keys") + assert keys, "Fixture target must initially be trusted" + expected_keys = [key["key_base64"] for key in keys] + output = [] + answered = [] + def emit(event, data): + if event != "ssh_gateway_output": return + attempt.ack(data["sequence"]) + output.append(data["data"]) + text = "".join(output) + if "(y/n)" in text and not answered: + assert any(key in text for key in expected_keys) + assert attempt.input("y") + answered.append(True) + attempt = GatewayAttempt(1, "reference", "host-key", emit) + client = attempt.own(client_for(config)) + timer = threading.Timer(15, attempt.cancel) + try: + for key in keys: api("/ssh/known-hosts/" + key["id"], method="DELETE") + connect(client, config, password=config["password"], interact=attempt.challenge) + timer.start() + prepare_terminal(client.get_transport(), attempt) + assert answered == [True] + finally: + timer.cancel() + attempt.finish() + known = api("/ssh/known-hosts") + for key in keys: + if not any(all(item[field] == key[field] for field in + ("host", "port", "key_type", "key_base64")) for item in known): + api("/ssh/known-hosts", {field: key[field] for field in + ("host", "port", "key_type", "key_base64")}) + + +def test_sftp_only_target_does_not_require_exec(gateway): + config, api, policy = gateway + port = config.get("sftp_only_port") + if port is None: + pytest.skip("Disposable ForceCommand internal-sftp target not configured") + assert type(port) is int and 1024 <= port <= 65535 + policy(["Password"]) + path = "/targets/" + config["target_id"] + target = api(path) + assert ipaddress.ip_address(target["options"]["host"]).is_loopback + keys = api(path + "/known-ssh-host-keys") + assert keys + created = [] + try: + # The second disposable daemon must use the same generated host key. + for key in keys: + created.append(api("/ssh/known-hosts", { + "host": key["host"], "port": port, "key_type": key["key_type"], + "key_base64": key["key_base64"], + })) + api(path, {**target, "options": {**target["options"], "port": port}}, "PUT") + for terminal in (False, True): + attempt = GatewayAttempt(1, "reference", "sftp-only", lambda event, data: + attempt.ack(data["sequence"]) if event == "ssh_gateway_output" else None) + client = attempt.own(client_for(config)) + timer = threading.Timer(10, attempt.cancel) + try: + connect(client, config, password=config["password"], interact=attempt.challenge) + timer.start() + if terminal: + with pytest.raises(ValueError, match="did not confirm readiness"): + prepare_terminal(client.get_transport(), attempt) + else: + sftp = prepare_sftp(client.get_transport(), attempt, operation_timeout=5) + assert sftp.normalize(".") + finally: + timer.cancel() + attempt.finish() + finally: + for key in created: + api("/ssh/known-hosts/" + key["id"], method="DELETE") diff --git a/tests/integration/warpgate/README.md b/tests/integration/warpgate/README.md index d695553..2887ed8 100644 --- a/tests/integration/warpgate/README.md +++ b/tests/integration/warpgate/README.md @@ -4,7 +4,8 @@ Run `tests/integration/test_warpgate.py` serially against a dedicated local Warpgate 0.29.0 instance and an OpenSSH target. The fixture must listen only on loopback and contain synthetic accounts. Never point these tests at an existing gateway: they change the fixture user's SSH credential policy and the target's -approval requirement, restoring both afterwards. +approval requirement, restoring both afterwards. The host-key test temporarily +removes and restores only the disposable target's trusted keys. No application Compose changes are needed. Test prerequisites: @@ -25,6 +26,7 @@ Create an ignored JSON fixture description (for example under `.test-tmp/`): "disposable": true, "host": "127.0.0.1", "port": 12222, + "sftp_only_port": 12224, "selector": "probe:probe", "password": "", "api_url": "https://127.0.0.1:18443/@warpgate/admin/api", @@ -38,6 +40,10 @@ Create an ignored JSON fixture description (for example under `.test-tmp/`): } ``` +For the optional SFTP-only case, run a second loopback OpenSSH daemon on +`sftp_only_port` with the same generated host key and `ForceCommand internal-sftp`. +PTY requests and two channels must remain allowed. Omit that field to skip it. + Set `WEBSSH_WARPGATE_FIXTURE` to that file and run: ```sh @@ -46,6 +52,7 @@ python -m pytest tests/integration/test_warpgate.py -q Without this variable, these integration tests skip. They cover password, OTP, key and combined factor policies; terminal and SFTP readiness; pending admin and -browser approval cancellation; and changed gateway host-key rejection. Browser +browser approval cancellation; successful administrator approval; explicit target +host-key confirmation; SFTP-only targets; and changed gateway host-key rejection. Browser approval completion through a real identity provider requires a separate deployment-specific acceptance test. diff --git a/tests/test_ssh_gateway_auth.py b/tests/test_ssh_gateway_auth.py index d0aa420..61fab4b 100644 --- a/tests/test_ssh_gateway_auth.py +++ b/tests/test_ssh_gateway_auth.py @@ -7,6 +7,8 @@ class Transport: def __init__(self): self.authenticated = False self.calls = [] + def close(self): + self.authenticated = False def is_authenticated(self): return self.authenticated def auth_password(self, username, password, fallback=True): @@ -75,3 +77,49 @@ def test_key_with_password_and_otp_supplies_password_before_interactive(): interact=lambda title, instructions, prompts: ["initial-secret" if prompts[0][0] == "Password" else "123456"]) strategy.authenticate(transport) assert transport.calls == [("password", "initial-secret", False), ("interactive",)] + + +def test_authentication_methods_share_remaining_deadline(monkeypatch): + import app.ssh_gateway_auth as auth + now = [100.0] + monkeypatch.setattr(auth.time, "monotonic", lambda: now[0]) + transport = Transport() + transport.close = lambda: None + def key(*args): + now[0] += 110 + return ["password"] + def answer(*args): + now[0] += 20 + return ["secret"] + def password(*args, **kwargs): + assert 0 < transport.auth_timeout <= 50 + transport.authenticated = True + return [] + transport.auth_publickey = key + transport.auth_password = password + GatewayAuthStrategy("u:t", pkey=object(), interact=answer).authenticate(transport) + + +def test_shared_deadline_closes_a_blocked_authentication(monkeypatch): + import app.ssh_gateway_auth as auth + timers = [] + class Timer: + def __init__(self, seconds, callback): + assert seconds == 180 + self.callback = callback + self.cancelled = False + timers.append(self) + def start(self): pass + def cancel(self): self.cancelled = True + monkeypatch.setattr(auth.threading, "Timer", Timer) + transport = Transport() + closed = [] + transport.close = lambda: closed.append(True) + def blocked(*args): + timers[0].callback() + assert closed == [True] + raise paramiko.AuthenticationException("closed") + transport.auth_interactive = blocked + with pytest.raises(paramiko.AuthenticationException): + GatewayAuthStrategy("u:t", interact=lambda *args: []).authenticate(transport) + assert timers[0].cancelled diff --git a/tests/test_ssh_gateway_hooks.py b/tests/test_ssh_gateway_hooks.py index 1c12f40..d402da2 100644 --- a/tests/test_ssh_gateway_hooks.py +++ b/tests/test_ssh_gateway_hooks.py @@ -66,3 +66,80 @@ def test_gateway_requires_client_capability(app, monkeypatch): _, events = call_socket_handler(app, monkeypatch, socket_events.handle_ssh_connect, sid, {"host":"host", "username":"u:t", "client_request_id":"legacy"}) assert any("updated interactive client" in payload.get("error","") for event,payload in events) + + +@pytest.mark.usefixtures("direct_socket_authentication") +@pytest.mark.parametrize("failure", ["host", "key", "rate"]) +def test_gateway_quick_validation_errors_are_correlated(app, monkeypatch, failure): + from app import socket_events + from tests.test_command_set_socket_events import create_socket_user, call_socket_handler + _, sid = create_socket_user(app, "gateway_validation_" + failure) + data = {"host": "host", "username": "u:t", "client_request_id": "quick-req", "gateway_interaction": 1} + monkeypatch.setattr(socket_events, "check_socket_rate_limit", lambda *args: failure == "rate") + if failure == "host": data["host"] = "" + if failure == "key": + data["key_id"] = "missing" + monkeypatch.setattr(socket_events.key_manager, "read_key_content", lambda *args: (None, "not found")) + _, events = call_socket_handler(app, monkeypatch, socket_events.handle_quick_connect, sid, data) + errors = [payload for name, payload in events if name == "quick_connect_error"] + assert len(errors) == 1 + assert errors[0].get("client_request_id") == "quick-req" + + +@pytest.mark.usefixtures("direct_socket_authentication") +@pytest.mark.parametrize("reason", ["failed", "timeout"]) +def test_gateway_quick_internal_cancellation_emits_terminal_error(app, monkeypatch, reason): + import threading + from app import socket_events + from tests.test_command_set_socket_events import create_socket_user, call_socket_handler + _, sid = create_socket_user(app, "gateway_internal_" + reason) + done = threading.Event() + events = [] + registry = app.extensions["ssh_gateway_registry"] + original_finish = registry.finish + def finish(attempt): + original_finish(attempt) + done.set() + monkeypatch.setattr(registry, "finish", finish) + monkeypatch.setattr(socket_events.socketio, "emit", lambda event, data, **kwargs: events.append((event, data))) + def connect(*args, **kwargs): + attempt = kwargs["gateway_attempt"] + attempt.cancel(reason=reason) + return None, "failed" + monkeypatch.setattr(socket_events.connection_pool.temp_connection_pool, "create_connection", connect) + call_socket_handler(app, monkeypatch, socket_events.handle_quick_connect, sid, + {"host": "host", "username": "u:t", "client_request_id": "quick-req", "gateway_interaction": 1}) + assert done.wait(3) + errors = [payload for name, payload in events if name == "quick_connect_error"] + assert len(errors) == 1 + assert errors[0]["client_request_id"] == "quick-req" + assert not registry.attempts + + +@pytest.mark.usefixtures("direct_socket_authentication") +def test_terminal_gateway_timeout_emits_one_correlated_error(app, monkeypatch): + import threading + from app import socket_events, ssh_manager + from tests.test_command_set_socket_events import create_socket_user, call_socket_handler + _, sid = create_socket_user(app, "gateway_terminal_timeout") + done = threading.Event() + events = [] + registry = app.extensions["ssh_gateway_registry"] + original_finish = registry.finish + def finish(attempt): + original_finish(attempt) + done.set() + monkeypatch.setattr(registry, "finish", finish) + def connect(**kwargs): + kwargs["gateway_attempt"].cancel(reason="timeout") + assert not kwargs["cancel_event"].commit_if_active() + return None, "Connection cancelled" + monkeypatch.setattr(ssh_manager, "create_ssh_connection", connect) + _, events = call_socket_handler(app, monkeypatch, socket_events.handle_ssh_connect, sid, + {"host": "host", "username": "u:t", "client_request_id": "terminal-timeout", "gateway_interaction": 1}) + assert done.wait(3) + errors = [payload for name, payload in events if name == "ssh_error"] + assert len(errors) == 1 + assert errors[0]["client_request_id"] == "terminal-timeout" + assert not any(name == "ssh_connected" for name, _ in events) + assert not registry.attempts diff --git a/tests/test_ssh_gateway_interaction.py b/tests/test_ssh_gateway_interaction.py index 4cb66bd..8295c18 100644 --- a/tests/test_ssh_gateway_interaction.py +++ b/tests/test_ssh_gateway_interaction.py @@ -110,3 +110,50 @@ def close(self): closed.append(True) attempt.own(Resource()) assert closed == [True] attempt.finish() + + +@pytest.mark.parametrize("winner", ["timeout", "commit"]) +def test_gateway_timeout_is_atomic_with_terminal_commit(winner): + from app.socket_events import _CombinedCancellation + gateway = GatewayAttempt(1, "sid", "req", lambda *args: None) + state = {"state": "pending"} + cancellation = _CombinedCancellation(threading.Event(), threading.Event(), + threading.Lock(), state, gateway=gateway) + try: + if winner == "timeout": + gateway.cancel(reason="timeout") + assert not cancellation.commit_if_active() + assert cancellation.is_set() + assert state["state"] == "pending" + else: + assert cancellation.commit_if_active() + assert not gateway.cancel(reason="timeout") + assert not cancellation.is_set() + assert state["state"] == "committed" + finally: + gateway.finish() + + +def test_timeout_wins_while_commit_is_waiting_on_gateway_condition(): + from app.socket_events import _CombinedCancellation + gateway = GatewayAttempt(1, "sid", "race", lambda *args: None) + result = [] + entered = threading.Event() + state = {"state": "pending"} + cancellation = _CombinedCancellation(threading.Event(), threading.Event(), + threading.Lock(), state, gateway=gateway) + def commit(): + entered.set() + result.append(cancellation.commit_if_active()) + worker = threading.Thread(target=commit) + try: + with gateway.condition: + worker.start() + assert entered.wait(1) + gateway.cancel(reason="timeout") + worker.join(1) + assert not worker.is_alive() + assert result == [False] + assert state["state"] == "pending" + finally: + gateway.finish() From 8b4597ee89f8aea76320d66ece7777cbf9841fd7 Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Wed, 23 Sep 2026 10:17:26 +0200 Subject: [PATCH 03/11] Refresh gateway password hints and restore UTF-8 display text --- app/ssh_manager.py | 2 +- static/js/app.js | 27 ++++++++++++++++----------- static/js/session-manager.js | 8 ++++---- tests/e2e/ssh-gateway.spec.js | 13 +++++++++++++ 4 files changed, 34 insertions(+), 16 deletions(-) diff --git a/app/ssh_manager.py b/app/ssh_manager.py index e5656a8..4330ba8 100644 --- a/app/ssh_manager.py +++ b/app/ssh_manager.py @@ -913,7 +913,7 @@ def close_session(session_id, kill_tmux=False): """Close SSH session and clean up resources. kill_tmux: If True and the session uses tmux, kill the remote tmux session. - Default False — idle timeout and server restart detach only, + Default False — idle timeout and server restart detach only, leaving tmux running so the session shows up as a reconnect candidate. Pass True only from explicit user disconnect. """ diff --git a/static/js/app.js b/static/js/app.js index 89a7ee8..37f9c8d 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -2087,12 +2087,22 @@ validation.isValidHost(hostInput.value), 'validation.host'); const validatePort = () => hint(portInput, portHint, validation.isValidPort(portInput.value), 'validation.port'); + 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; + passwordInput.required = passwordAuth && !gateway; + 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.'); + } + }; const validateUser = () => { - const gateway = authTypeSelect?.value !== 'tailscale' && validation.isGateway(userInput.value); hint(userInput, userHint, validation.isValidUsername(userInput.value, authTypeSelect?.value !== 'tailscale'), 'validation.username'); - if (passwordInput && authTypeSelect?.value === 'password') passwordInput.required = !gateway; - if (passHint && gateway) passHint.textContent = i18n.t('gateway.passwordHint', 'Leave the password empty for interactive gateway authentication.'); + validatePassword(); }; hostInput.addEventListener('input', validateHost); portInput.addEventListener('input', validatePort); @@ -2101,14 +2111,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 || validation.isGateway(userInput.value); - setFieldState(passwordInput, passHint, isValid ? '' : i18n.t('connection.passwordRequired'), isValid); - }); + passwordInput.addEventListener('input', validatePassword); } if (keySelect) { @@ -2127,9 +2134,7 @@ if (authTypeSelect) { authTypeSelect.addEventListener('change', () => { - if (authTypeSelect.value === 'password' && passwordInput) { - setFieldState(passwordInput, passHint, passwordInput.value ? '' : i18n.t('connection.passwordRequired'), Boolean(passwordInput.value)); - } + validateUser(); if (authTypeSelect.value === 'key' && keySelect) { setFieldState(keySelect, keyHint, keySelect.value ? '' : i18n.t('connection.selectSSHKey'), Boolean(keySelect.value)); } diff --git a/static/js/session-manager.js b/static/js/session-manager.js index f009c8a..3b22f51 100644 --- a/static/js/session-manager.js +++ b/static/js/session-manager.js @@ -490,7 +490,7 @@ const SessionManager = { return; } - // Active session — disconnect first, then reconnect + // Active session — disconnect first, then reconnect if (session.connected) { const message = window.i18n ? i18n.t('session.reconnectConfirm').replace('{label}', label) @@ -546,7 +546,7 @@ const SessionManager = { } }, 500); } else { - // No key_id — open pre-filled connection modal + // No key_id — open pre-filled connection modal setTimeout(() => { window.clearConnectionProfileState(); const hostInput = document.getElementById('hostInput'); @@ -840,7 +840,7 @@ const SessionManager = { notesEl.textContent = `${session.username}@${session.host}:${session.port}`; if (session.viaJump) { const via = window.i18n ? i18n.t('connection.via') : 'via'; - notesEl.appendChild(document.createTextNode(' · ')); + notesEl.appendChild(document.createTextNode(' · ')); const viaSpan = document.createElement('span'); viaSpan.className = 'session-via'; viaSpan.textContent = `${via} ${session.viaJump}`; @@ -1618,7 +1618,7 @@ const SessionManager = { } if (route.proxyJump) connectionData.proxy_jump = route.proxyJump; window.SSHGatewayDialog?.prepare(connectionData); - window.socket.emit('ssh_connect', connectionData); + window.socket.emit('ssh_connect', connectionData); const label = `${username}@${host}`; const message = window.i18n ? i18n.t('session.reconnecting').replace('{label}', label) diff --git a/tests/e2e/ssh-gateway.spec.js b/tests/e2e/ssh-gateway.spec.js index 77fc4a5..e129551 100644 --- a/tests/e2e/ssh-gateway.spec.js +++ b/tests/e2e/ssh-gateway.spec.js @@ -54,8 +54,21 @@ test('ordinary passwords stay required and gateway passwords can be empty', asyn await expect(page.locator('#passwordInput')).toHaveJSProperty('required', true); await page.locator('#usernameInput').fill('user:target'); await expect(page.locator('#passwordInput')).toHaveJSProperty('required', false); + const gatewayHint = await page.evaluate(() => window.i18n.t('gateway.passwordHint')); + const requiredHint = await page.evaluate(() => window.i18n.t('connection.passwordRequired')); + await expect(page.locator('#passwordHint')).toHaveText(gatewayHint); await page.locator('#usernameInput').fill('ordinary'); await expect(page.locator('#passwordInput')).toHaveJSProperty('required', true); + await expect(page.locator('#passwordHint')).toHaveText(requiredHint); + await page.locator('#passwordInput').fill('test-password'); + await expect(page.locator('#passwordHint')).toHaveText(''); + await page.locator('#usernameInput').fill('user:target'); + await page.locator('#passwordInput').fill(''); + await expect(page.locator('#passwordHint')).toHaveText(gatewayHint); + await page.locator('#authTypeSelect').selectOption('key'); + await page.locator('#authTypeSelect').selectOption('password'); + await expect(page.locator('#passwordInput')).toHaveJSProperty('required', false); + await expect(page.locator('#passwordHint')).toHaveText(gatewayHint); assertNoExternalRequests(page); }); From 51b993ffe79f57962c9109d0d065446659c332d7 Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Wed, 23 Sep 2026 12:19:51 +0200 Subject: [PATCH 04/11] Unify SSH connection attempt ownership and cancellation --- app/__init__.py | 8 +- app/socket_events.py | 272 ++++++------------------ app/ssh_connection_attempt.py | 212 ++++++++++++++++++ app/ssh_gateway_interaction.py | 132 ++---------- tests/test_command_set_socket_events.py | 75 +++---- tests/test_sftp_request_correlation.py | 4 +- tests/test_ssh_connection_attempt.py | 166 +++++++++++++++ tests/test_ssh_gateway_hooks.py | 56 ++++- tests/test_ssh_gateway_interaction.py | 16 +- tests/test_startup_commands.py | 40 +--- 10 files changed, 561 insertions(+), 420 deletions(-) create mode 100644 app/ssh_connection_attempt.py create mode 100644 tests/test_ssh_connection_attempt.py diff --git a/app/__init__.py b/app/__init__.py index 9911549..aba6bd4 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -725,11 +725,11 @@ def enforce_security_feature_gate(): transfer_runtime_binding ), ) - from .ssh_gateway_interaction import GatewayRegistry - gateway_registry = GatewayRegistry() - app.extensions['ssh_gateway_registry'] = gateway_registry + from .ssh_connection_attempt import SSHAttemptRegistry + ssh_attempts = SSHAttemptRegistry() + app.extensions['ssh_attempt_registry'] = ssh_attempts app.extensions['runtime_lifecycle'].register_shutdown_callback( - 'gateway_attempts', lambda _deadline: gateway_registry.shutdown(), + 'ssh_attempts', lambda _deadline: ssh_attempts.shutdown(), ) cors_origins = config.CORS_ORIGINS if isinstance(cors_origins, str): diff --git a/app/socket_events.py b/app/socket_events.py index ba03895..0d6620f 100644 --- a/app/socket_events.py +++ b/app/socket_events.py @@ -133,8 +133,6 @@ _ENGINEIO_REJECTION_POLL_SECONDS = 0.01 _smb_attempts_lock = threading.RLock() _smb_attempts = {} -_ssh_connect_attempts_lock = threading.RLock() -_ssh_connect_attempts = {} _ssh_banner_prompts_lock = threading.RLock() _ssh_banner_prompts = {} SSH_AUTH_BANNER_DECISION_TIMEOUT = 60 @@ -181,49 +179,6 @@ def _cancel_ssh_banner_prompt_for_request(user_id, socket_sid, request_id): prompt['event'].set() -def _try_cancel_ssh_attempt(attempt): - """Cancel an attempt unless an irreversible connection step won first.""" - commit_lock = attempt.get('commit_lock') - if commit_lock is None: - if attempt.get('state') == 'committed': - return False - attempt['state'] = 'cancelled' - attempt['cancel_event'].set() - return True - with commit_lock: - state = attempt.get('state', 'pending') - if state == 'committed' or state == 'finished': - return False - attempt['state'] = 'cancelled' - attempt['cancel_event'].set() - return True - - -def _force_cancel_ssh_attempt(attempt): - """Cancel runtime work even after the user-visible commit boundary.""" - commit_lock = attempt.get('commit_lock') - if commit_lock is None: - attempt['cancel_event'].set() - return - with commit_lock: - attempt['cancel_event'].set() - - -def _cancel_ssh_connect_attempts_for_socket(socket_sid): - handles = [] - with _ssh_connect_attempts_lock: - for (_owner_id, owner_sid, _request_id), attempt in tuple( - _ssh_connect_attempts.items() - ): - if owner_sid != socket_sid: - continue - _force_cancel_ssh_attempt(attempt) - if attempt.get('handle') is not None: - handles.append(attempt['handle']) - for handle in handles: - handle.cancel() - - def _smb_request_id(payload): if not isinstance(payload, dict): return '' @@ -760,28 +715,11 @@ def _audit_file_source_operation( class _CombinedCancellation: """Expose user and runtime cancellation through one Event-like interface.""" - def __init__( - self, - user_cancel_event, - lifecycle_cancel_event, - commit_lock=None, - attempt=None, - gateway=None, - ): - self._gateway = gateway + def __init__(self, user_cancel_event, lifecycle_cancel_event): self._user_cancel_event = user_cancel_event self._lifecycle_cancel_event = lifecycle_cancel_event - self._commit_lock = commit_lock or threading.Lock() - self._attempt = attempt def is_set(self): - if self._gateway is not None: - with self._gateway.condition: - if not self._gateway.committed: - try: - self._gateway.check() - except ValueError: - return True return ( self._user_cancel_event.is_set() or self._lifecycle_cancel_event.is_set() @@ -803,25 +741,6 @@ def wait(self, timeout=None): self._user_cancel_event.wait(min(remaining, 0.1)) return True - def commit_if_active(self): - """Linearize an irreversible setup step against user cancellation.""" - from contextlib import nullcontext - with self._commit_lock: - # The gateway timer uses the same condition, so timeout and commit - # have one winner even after SSH resources have been handed off. - with self._gateway.condition if self._gateway is not None else nullcontext(): - if self.is_set(): - return False - if self._attempt is not None: - state = self._attempt.get('state', 'pending') - if state == 'cancelled' or state == 'finished': - return False - self._attempt['state'] = 'committed' - if self._gateway is not None: - self._gateway.committed = True - self._gateway.guard.cancel() - return True - def _storage_error_payload(error, *, user_id, include_success=True, **extra): """Log storage metadata, never contents, and build one safe client error.""" @@ -1337,8 +1256,7 @@ def handle_disconnect(): socket_sid = request.sid 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_gateway_registry'].cancel_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) @@ -1538,7 +1456,6 @@ def handle_ssh_connect(data, current_user=None): bastion_key_content = None client_request_id = None socket_sid = request.sid - client_cancel_event = threading.Event() try: data = data if isinstance(data, dict) else {} client_request_id = _ssh_request_id(data) or None @@ -1566,7 +1483,7 @@ def emit_error(message): )) def request_auth_banner_decision(banner, context): - if client_cancel_event.is_set(): + if attempt.is_set(): return False prompt_id = secrets.token_urlsafe(24) decision_event = threading.Event() @@ -1579,7 +1496,7 @@ 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 attempt.is_set(): decision_event.set() emit('ssh_auth_banner', { 'prompt_id': prompt_id, @@ -1589,19 +1506,12 @@ def request_auth_banner_decision(banner, context): 'port': bastion_port if context == 'jump_host' else port, 'client_request_id': client_request_id, }) - 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: - if gateway_attempt.cancelled: - break - if client_cancel_event.is_set(): - break - if decision_event.wait(.1): - answered = True - break + answered = False + banner_deadline = time.monotonic() + SSH_AUTH_BANNER_DECISION_TIMEOUT + while time.monotonic() < banner_deadline and not 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 @@ -1783,56 +1693,38 @@ def request_auth_banner_decision(banner, context): app = current_app._get_current_object() lifecycle = app.extensions['runtime_lifecycle'] - gateway_attempt = None - gateway_registry = app.extensions['ssh_gateway_registry'] - if ':' in username: - if data.get('gateway_interaction') != 1 or not client_request_id: - emit_error('Gateway connections require an updated interactive client') - return - try: - gateway_attempt = gateway_registry.create( - current_user.id, socket_sid, client_request_id, - lambda event, payload: socketio.emit(event, payload, to=socket_sid), - ) - except (ValueError, QuotaExceeded): - emit_error('Gateway connection limit reached or request unavailable') - return + from .ssh_connection_attempt import SSHConnectionAttempt + from .ssh_gateway_interaction import GatewayAttempt + registry = app.extensions['ssh_attempt_registry'] + gateway = ':' in username + if gateway and (data.get('gateway_interaction') != 1 or not client_request_id): + emit_error('Gateway connections require an updated interactive client') + return + try: + attempt = registry.create( + current_user.id, socket_sid, client_request_id, + factory=GatewayAttempt if gateway else SSHConnectionAttempt, + reserve=gateway, + **({'emit': lambda event, payload: socketio.emit(event, payload, to=socket_sid)} + if gateway else {}), + ) + except (ValueError, QuotaExceeded): + emit_error('Gateway connection limit reached or request unavailable' if gateway + else 'Connection request already in progress') + return + gateway_attempt = attempt if gateway else None 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() - if gateway_attempt is not None: - gateway_registry.finish(gateway_attempt) - 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, - gateway=gateway_attempt, - ) + attempt.bind_runtime(lifecycle_cancel_event) + cancellation = attempt local_password = credentials.pop('password', None) local_key_content = credentials.pop('key_content', None) local_bastion_password = credentials.pop( @@ -2027,24 +1919,17 @@ def connect_ssh(lifecycle_cancel_event, credentials=credential_box): if gateway_attempt is not None: if (not error_sent and not gateway_attempt.committed and cancellation.is_set() - and not client_cancel_event.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') - gateway_registry.finish(gateway_attempt) 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) + registry.finish(attempt) try: handle = lifecycle.start_job( @@ -2052,17 +1937,10 @@ 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() + attempt.attach_handle(handle) except Exception as error: - if gateway_attempt is not None: - gateway_registry.finish(gateway_attempt) + registry.finish(attempt) credential_box.clear() - 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) log_warning( 'SSH connection job rejected', user=current_user.username, @@ -2097,35 +1975,18 @@ def handle_ssh_connect_cancel(data, current_user=None): request_id = _ssh_request_id(data) if not request_id: return {'success': False} - 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', - } - handle = attempt.get('handle') - gateway = current_app.extensions['ssh_gateway_registry'].get( + attempt = current_app.extensions['ssh_attempt_registry'].get( current_user.id, request.sid, request_id, ) - if gateway is not None: - gateway.cancel() + if attempt is None or attempt.kind != 'terminal': + return {'success': False, 'cancelled': False, 'reason': 'not_found'} + if not attempt.cancel(): + return {'success': False, 'cancelled': False, 'reason': 'already_committed'} _cancel_ssh_banner_prompt_for_request( current_user.id, request.sid, request_id, ) - if handle is not None: - handle.cancel() return {'success': True, 'cancelled': True} @@ -5633,22 +5494,22 @@ def _start_gateway_quick_connect(data, user, host, port, username, password, key return app = current_app._get_current_object() sid, user_id = request.sid, user.id - registry = app.extensions['ssh_gateway_registry'] + registry = app.extensions['ssh_attempt_registry'] + from .ssh_gateway_interaction import GatewayAttempt try: attempt = registry.create( - user_id, sid, request_id, - lambda event, payload: socketio.emit(event, payload, to=sid), + 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 - attempt.quick = True - attempt.quick_committed = False 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: @@ -5677,9 +5538,8 @@ def connect(cancel_event): make_source_id(FileSourceKind.SFTP_QUICK, connection_id), user_id, ), } - attempt.quick_committed = True - attempt.committed = True - attempt.guard.cancel() + if not attempt.commit_if_active(): + return attempt.send('quick_connect_success', **payload) committed = True except Exception: @@ -5692,9 +5552,10 @@ def connect(cancel_event): connection_pool.temp_connection_pool.request_close(connection_id, user_id) registry.finish(attempt) try: - app.extensions['runtime_lifecycle'].start_job( + 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) @@ -5702,14 +5563,20 @@ def connect(cancel_event): '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 = current_app.extensions['ssh_gateway_registry'].get( - current_user.id, request.sid, data.get('client_request_id'), - ) + 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'), ))} @@ -5720,9 +5587,7 @@ def handle_gateway_answer(data, current_user=None): def handle_gateway_input(data, current_user=None): if not isinstance(data, dict): return {'success': False} - attempt = current_app.extensions['ssh_gateway_registry'].get( - current_user.id, request.sid, data.get('client_request_id'), - ) + attempt = _gateway_attempt(data, current_user.id) return {'success': bool(attempt and attempt.input(data.get('data')))} @@ -5731,9 +5596,7 @@ def handle_gateway_input(data, current_user=None): def handle_gateway_ack(data, current_user=None): if not isinstance(data, dict): return - attempt = current_app.extensions['ssh_gateway_registry'].get( - current_user.id, request.sid, data.get('client_request_id'), - ) + attempt = _gateway_attempt(data, current_user.id) if attempt: attempt.ack(data.get('sequence')) @@ -5743,16 +5606,9 @@ def handle_gateway_ack(data, current_user=None): def handle_gateway_quick_cancel(data, current_user=None): if not isinstance(data, dict): return {'success': False} - attempt = current_app.extensions['ssh_gateway_registry'].get( - current_user.id, request.sid, data.get('client_request_id'), - ) - if not attempt or not getattr(attempt, 'quick', False): + attempt = _gateway_attempt(data, current_user.id) + if not attempt or attempt.kind != 'quick': return {'success': False, 'reason': 'not_found'} - with attempt.condition: - if attempt.quick_committed: - return {'success': False, 'reason': 'already_committed'} - attempt.cancelled = True - if attempt.cancel_reason is None: - attempt.cancel_reason = 'user' - attempt.cancel() + 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..7dc02dd --- /dev/null +++ b/app/ssh_connection_attempt.py @@ -0,0 +1,212 @@ +"""Socket-owned SSH setup, with one atomic cancellation/commit boundary.""" +import secrets +import threading +import time + +from .quota_manager import QuotaKind, quota_manager + + +class SSHConnectionAttempt: + """Event-like cancellation shared by terminal and interactive SSH setup. + + A user cancellation loses after commit; disconnect and shutdown still stop + runtime work. Ordinary SSH keeps its existing operation-specific timeouts. + """ + + 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 every asynchronous SSH connect.""" + + 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_interaction.py b/app/ssh_gateway_interaction.py index c7e65cb..2dce0f9 100644 --- a/app/ssh_gateway_interaction.py +++ b/app/ssh_gateway_interaction.py @@ -1,10 +1,9 @@ """Bounded, socket-owned gateway interactions. No credentials are persisted.""" from collections import deque import secrets -import threading import time -from .quota_manager import QuotaKind, quota_manager +from .ssh_connection_attempt import SSHConnectionAttempt class GatewayCancelled(ValueError): @@ -12,21 +11,16 @@ def __init__(self): super().__init__("Gateway connection cancelled or timed out") -class GatewayAttempt: - def __init__(self, user_id, sid, request_id, emit, *, reservation=None): - self.user_id = str(user_id) - self.sid = sid - self.request_id = request_id +class GatewayAttempt(SSHConnectionAttempt): + """Gateway-specific MFA and setup I/O on the shared SSH lifecycle.""" + + timeout = 300 + cancellation_error = GatewayCancelled + + def __init__(self, user_id, sid, request_id, emit, *, + reservation=None, kind='terminal'): self.emit = emit - self.reservation = reservation - self.deadline = time.monotonic() + 300 self.auth_deadline = time.monotonic() + 180 - self.condition = threading.Condition(threading.RLock()) - self.cancelled = False - self.cancel_reason = None - self.committed = False - self.finished = False - self.resources = [] self.prompt = None self.responses = None self.phase = "auth" @@ -35,32 +29,20 @@ def __init__(self, user_id, sid, request_id, emit, *, reservation=None): self.output_bytes = 0 self.sequence = 0 self.unacked = {} - self.guard = threading.Timer(300, self.cancel, kwargs={"reason": "timeout"}) - self.guard.daemon = True - self.guard.start() + super().__init__( + user_id, sid, request_id, reservation=reservation, kind=kind, + ) def check(self): with self.condition: - if (self.cancelled or self.finished or (not self.committed and ( - time.monotonic() >= self.deadline or - (self.phase == 'auth' and time.monotonic() >= self.auth_deadline)))): + super().check() + if (not self.committed and self.phase == 'auth' + and time.monotonic() >= self.auth_deadline): raise GatewayCancelled() - def own(self, resource): - with self.condition: - if not self.cancelled and not self.finished and time.monotonic() < self.deadline: - if resource not in self.resources: - self.resources.append(resource) - return resource - resource.close() - raise GatewayCancelled() - - 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): + self.responses = None + self.inputs.clear() def send(self, event, **data): self.emit(event, {"client_request_id": self.request_id, **data}) @@ -170,81 +152,3 @@ def ack(self, sequence): del self.unacked[sequence] self.condition.notify_all() return True - - def cancel(self, *, reason="user"): - with self.condition: - if self.committed and reason not in ("finished", "disconnected", "shutdown"): - return False - if self.cancel_reason is None: - self.cancel_reason = reason - self.cancelled = True - self.responses = None - resources, self.resources = self.resources, [] - self.inputs.clear() - self.condition.notify_all() - # Paramiko close may take locks: never close under the registry lock. - for resource in reversed(resources): - try: - resource.close() - except Exception: - pass - return True - - def finish(self): - self.guard.cancel() - self.cancel(reason="finished") - with self.condition: - if self.finished: - return - self.finished = True - if self.reservation is not None: - self.reservation.release() - - -class GatewayRegistry: - def __init__(self): - self.lock = threading.Lock() - self.attempts = {} - self.stopping = False - - def create(self, user_id, sid, request_id, emit): - if not isinstance(request_id, str) or not 1 <= len(request_id) <= 128: - raise ValueError("Gateway request ID is required") - key = (str(user_id), sid, request_id) - with self.lock: - if self.stopping or key in self.attempts: - raise ValueError("Gateway request is unavailable") - reservation = quota_manager.reserve(QuotaKind.BACKGROUND_JOB, user_id) - try: - attempt = GatewayAttempt(user_id, sid, request_id, emit, reservation=reservation) - self.attempts[key] = attempt - return attempt - except Exception: - 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() - key = (attempt.user_id, attempt.sid, attempt.request_id) - with self.lock: - if self.attempts.get(key) is attempt: - del self.attempts[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/tests/test_command_set_socket_events.py b/tests/test_command_set_socket_events.py index 0828065..69402e5 100644 --- a/tests/test_command_set_socket_events.py +++ b/tests/test_command_set_socket_events.py @@ -815,15 +815,11 @@ def wait_for_cancellation(**kwargs): assert acknowledgement == {'success': True, 'cancelled': True} assert completed.wait(2) assert not any(event in {'ssh_connected', 'ssh_error'} for event, _ in emitted) - attempt_key = (str(_user_id), sid, 'cancel-this-request') + registry = app.extensions['ssh_attempt_registry'] deadline = time.monotonic() + 2 - while time.monotonic() < deadline: - with socket_events._ssh_connect_attempts_lock: - if attempt_key not in socket_events._ssh_connect_attempts: - break + while registry.get(_user_id, sid, 'cancel-this-request') is not None and time.monotonic() < deadline: time.sleep(0.01) - with socket_events._ssh_connect_attempts_lock: - assert attempt_key not in socket_events._ssh_connect_attempts + assert registry.get(_user_id, sid, 'cancel-this-request') is None def test_ssh_connect_cancel_is_scoped_to_user_socket_request_and_banner(app): @@ -845,24 +841,15 @@ def cancel(self): target_request = 'target-request' other_request = 'other-request' + registry = app.extensions['ssh_attempt_registry'] attempt_cases = { - 'matching': ( - (str(user_id), sid, target_request), - {'cancel_event': threading.Event(), 'handle': RecordingHandle()}, - ), - 'other_request': ( - (str(user_id), sid, other_request), - {'cancel_event': threading.Event(), 'handle': RecordingHandle()}, - ), - 'other_socket': ( - (str(user_id), other_sid, target_request), - {'cancel_event': threading.Event(), 'handle': RecordingHandle()}, - ), - 'other_user': ( - (str(other_user_id), sid, target_request), - {'cancel_event': threading.Event(), 'handle': RecordingHandle()}, - ), + 'matching': registry.create(user_id, sid, target_request), + 'other_request': registry.create(user_id, sid, other_request), + 'other_socket': registry.create(user_id, other_sid, target_request), + 'other_user': registry.create(other_user_id, sid, target_request), } + for attempt in attempt_cases.values(): + attempt.attach_handle(RecordingHandle()) banner_cases = { 'matching-banner': { 'event': threading.Event(), @@ -893,13 +880,8 @@ def cancel(self): 'client_request_id': target_request, }, } - attempt_keys = [case[0] for case in attempt_cases.values()] banner_ids = list(banner_cases) - with socket_events._ssh_connect_attempts_lock: - socket_events._ssh_connect_attempts.update( - dict(attempt_cases.values()) - ) with socket_events._ssh_banner_prompts_lock: socket_events._ssh_banner_prompts.update(banner_cases) try: @@ -910,13 +892,13 @@ def cancel(self): }) assert acknowledgement == {'success': True, 'cancelled': True} - matching_attempt = attempt_cases['matching'][1] - assert matching_attempt['cancel_event'].is_set() - assert matching_attempt['handle'].cancel_calls == 1 + matching_attempt = attempt_cases['matching'] + assert matching_attempt.cancel_event.is_set() + assert matching_attempt.handle.cancel_calls == 1 for name in ('other_request', 'other_socket', 'other_user'): - attempt = attempt_cases[name][1] - assert not attempt['cancel_event'].is_set() - assert attempt['handle'].cancel_calls == 0 + attempt = attempt_cases[name] + assert not attempt.cancel_event.is_set() + assert attempt.handle.cancel_calls == 0 matching_banner = banner_cases['matching-banner'] assert matching_banner['event'].is_set() @@ -930,9 +912,8 @@ def cancel(self): assert not banner['event'].is_set() assert banner['accepted'] is True finally: - with socket_events._ssh_connect_attempts_lock: - for attempt_key in attempt_keys: - socket_events._ssh_connect_attempts.pop(attempt_key, None) + for attempt in attempt_cases.values(): + registry.finish(attempt) with socket_events._ssh_banner_prompts_lock: for prompt_id in banner_ids: socket_events._ssh_banner_prompts.pop(prompt_id, None) @@ -953,15 +934,10 @@ def cancel(self): self.cancel_calls += 1 request_id = 'committed-request' - attempt_key = (str(user_id), sid, request_id) - attempt = { - 'cancel_event': threading.Event(), - 'commit_lock': threading.Lock(), - 'handle': RecordingHandle(), - 'state': 'committed', - } - with socket_events._ssh_connect_attempts_lock: - socket_events._ssh_connect_attempts[attempt_key] = attempt + registry = app.extensions['ssh_attempt_registry'] + attempt = registry.create(user_id, sid, request_id) + attempt.attach_handle(RecordingHandle()) + assert attempt.commit_if_active() try: with app.test_request_context('/socket.io'): request.sid = sid @@ -974,11 +950,10 @@ def cancel(self): 'cancelled': False, 'reason': 'already_committed', } - assert not attempt['cancel_event'].is_set() - assert attempt['handle'].cancel_calls == 0 + assert not attempt.cancel_event.is_set() + assert attempt.handle.cancel_calls == 0 finally: - with socket_events._ssh_connect_attempts_lock: - socket_events._ssh_connect_attempts.pop(attempt_key, None) + registry.finish(attempt) def test_cancelled_tmux_reconnect_preserves_existing_remote_session( diff --git a/tests/test_sftp_request_correlation.py b/tests/test_sftp_request_correlation.py index 3a595e1..880dc34 100644 --- a/tests/test_sftp_request_correlation.py +++ b/tests/test_sftp_request_correlation.py @@ -229,10 +229,10 @@ def test_cancel_directory_listing_does_not_reveal_invalid_or_missing_state( def test_socket_disconnect_discards_only_its_directory_snapshots(monkeypatch): - from app.ssh_gateway_interaction import GatewayRegistry + from app.ssh_connection_attempt import SSHAttemptRegistry monkeypatch.setattr( socket_events, 'current_app', - SimpleNamespace(extensions={'ssh_gateway_registry': GatewayRegistry()}), + SimpleNamespace(extensions={'ssh_attempt_registry': SSHAttemptRegistry()}), ) discarded = [] diff --git a/tests/test_ssh_connection_attempt.py b/tests/test_ssh_connection_attempt.py new file mode 100644 index 0000000..0ec9d8b --- /dev/null +++ b/tests/test_ssh_connection_attempt.py @@ -0,0 +1,166 @@ +"""Behavioral contracts shared by terminal and interactive SSH setup.""" +import threading + +import pytest + + +def make_attempt(gateway=False, **kwargs): + if gateway: + from app.ssh_gateway_interaction import GatewayAttempt + return GatewayAttempt(1, 'sid', 'req', lambda *args: None, **kwargs) + from app.ssh_connection_attempt import SSHConnectionAttempt + return SSHConnectionAttempt(1, 'sid', 'req', **kwargs) + + +@pytest.mark.parametrize('gateway', [False, True]) +@pytest.mark.parametrize('winner', ['cancel', 'commit']) +def test_cancel_and_commit_have_one_winner(gateway, winner): + attempt = make_attempt(gateway) + try: + if winner == 'cancel': + assert attempt.cancel() + assert attempt.wait(.01) + assert not attempt.commit_if_active() + else: + assert attempt.commit_if_active() + assert not attempt.cancel() + assert not attempt.is_set() + assert attempt.commit_if_active() + finally: + attempt.finish() + + +@pytest.mark.parametrize('gateway', [False, True]) +@pytest.mark.parametrize('reason', ['disconnected', 'shutdown']) +def test_runtime_cleanup_can_cancel_committed_attempt(gateway, reason): + attempt = make_attempt(gateway) + try: + assert attempt.commit_if_active() + assert attempt.cancel(reason=reason) + assert attempt.is_set() + assert not attempt.commit_if_active() + finally: + attempt.finish() + + +@pytest.mark.parametrize('gateway', [False, True]) +def test_runtime_cancellation_prevents_commit(gateway): + attempt = make_attempt(gateway) + runtime = threading.Event() + try: + attempt.bind_runtime(runtime) + runtime.set() + assert attempt.wait(.01) + assert not attempt.commit_if_active() + finally: + attempt.finish() + + +@pytest.mark.parametrize('gateway', [False, True]) +def test_cancel_before_worker_binds_is_retained(gateway): + attempt = make_attempt(gateway) + try: + attempt.cancel() + attempt.bind_runtime(threading.Event()) + assert attempt.is_set() + assert not attempt.commit_if_active() + finally: + attempt.finish() + + +def test_registry_is_scoped_and_rejects_duplicate_live_request(): + from app.ssh_connection_attempt import SSHAttemptRegistry + registry = SSHAttemptRegistry() + attempt = registry.create(1, 'sid', 'req') + try: + assert registry.get(1, 'sid', 'req') is attempt + assert registry.get(2, 'sid', 'req') is None + assert registry.get(1, 'other', 'req') is None + assert registry.get(1, 'sid', 'other') is None + with pytest.raises(ValueError): + registry.create(1, 'sid', 'req') + assert SSHAttemptRegistry().get(1, 'sid', 'req') is None + finally: + registry.finish(attempt) + replacement = registry.create(1, 'sid', 'req') + registry.finish(attempt) + assert registry.get(1, 'sid', 'req') is replacement + registry.finish(replacement) + + +def test_shutdown_cancels_all_and_prevents_new_attempts(): + from app.ssh_connection_attempt import SSHAttemptRegistry + registry = SSHAttemptRegistry() + a = registry.create(1, 'sid', None) + b = registry.create(2, 'other', 'req') + try: + registry.cancel_socket('sid') + assert a.is_set() + assert not b.is_set() + assert b.commit_if_active() + registry.shutdown() + assert b.is_set() + with pytest.raises(ValueError): + registry.create(3, 'new', 'req') + finally: + registry.finish(a) + registry.finish(b) + + +@pytest.mark.parametrize('gateway', [False, True]) +def test_cancel_retains_quota_and_handoff_preserves_session(gateway): + released, closed = [], [] + class Reservation: + def release(self): released.append(True) + class Resource: + def close(self): closed.append(self) + attempt = make_attempt(gateway, reservation=Reservation()) + session, pending = Resource(), Resource() + attempt.own(session) + attempt.own(pending) + attempt.handoff(session) + attempt.cancel() + assert released == [] + assert closed == [pending] + attempt.finish() + attempt.finish() + assert released == [True] + assert closed == [pending] + +def test_ordinary_connect_keeps_its_operation_timeouts(monkeypatch): + from app import ssh_connection_attempt + attempt = make_attempt() + started = ssh_connection_attempt.time.monotonic() + try: + monkeypatch.setattr(ssh_connection_attempt.time, 'monotonic', lambda: started + 600) + assert not attempt.is_set() + assert attempt.commit_if_active() + finally: + attempt.finish() + + +def test_interactive_admission_does_not_reduce_ordinary_connect_capacity(monkeypatch): + from app import ssh_connection_attempt + from app.quota_manager import QuotaExceeded, QuotaKind, QuotaManager + from app.ssh_gateway_interaction import GatewayAttempt + quotas = QuotaManager({kind: {'global': 2, 'per_user': 1} for kind in QuotaKind}) + monkeypatch.setattr(ssh_connection_attempt, 'quota_manager', quotas) + registry = ssh_connection_attempt.SSHAttemptRegistry() + busy = quotas.reserve(QuotaKind.BACKGROUND_JOB, 1) + ordinary = registry.create(1, 'sid', 'ordinary') + try: + with pytest.raises(QuotaExceeded): + registry.create(1, 'sid', 'gateway', factory=GatewayAttempt, + emit=lambda *args: None, reserve=True) + assert not ordinary.is_set() + assert registry.get(1, 'sid', 'gateway') is None + finally: + registry.finish(ordinary) + busy.release() + gateway = registry.create(1, 'sid', 'gateway', factory=GatewayAttempt, + emit=lambda *args: None, reserve=True) + gateway.cancel() + with pytest.raises(QuotaExceeded): + quotas.reserve(QuotaKind.BACKGROUND_JOB, 1) + registry.finish(gateway) + quotas.reserve(QuotaKind.BACKGROUND_JOB, 1).release() diff --git a/tests/test_ssh_gateway_hooks.py b/tests/test_ssh_gateway_hooks.py index d402da2..760be4f 100644 --- a/tests/test_ssh_gateway_hooks.py +++ b/tests/test_ssh_gateway_hooks.py @@ -95,7 +95,7 @@ def test_gateway_quick_internal_cancellation_emits_terminal_error(app, monkeypat _, sid = create_socket_user(app, "gateway_internal_" + reason) done = threading.Event() events = [] - registry = app.extensions["ssh_gateway_registry"] + registry = app.extensions["ssh_attempt_registry"] original_finish = registry.finish def finish(attempt): original_finish(attempt) @@ -124,7 +124,7 @@ def test_terminal_gateway_timeout_emits_one_correlated_error(app, monkeypatch): _, sid = create_socket_user(app, "gateway_terminal_timeout") done = threading.Event() events = [] - registry = app.extensions["ssh_gateway_registry"] + registry = app.extensions["ssh_attempt_registry"] original_finish = registry.finish def finish(attempt): original_finish(attempt) @@ -143,3 +143,55 @@ def connect(**kwargs): assert errors[0]["client_request_id"] == "terminal-timeout" assert not any(name == "ssh_connected" for name, _ in events) assert not registry.attempts + +@pytest.mark.usefixtures('direct_socket_authentication') +@pytest.mark.parametrize('gateway', [False, True]) +def test_rejected_connect_job_releases_attempt_and_admission(app, monkeypatch, gateway): + from app import socket_events, ssh_manager + from app import ssh_connection_attempt + from app.quota_manager import QuotaKind, QuotaManager + from tests.test_command_set_socket_events import create_socket_user, call_socket_handler + user_id, sid = create_socket_user(app, 'job_rejected_' + str(gateway)) + registry = app.extensions['ssh_attempt_registry'] + quotas = QuotaManager({kind: {'global': 2, 'per_user': 1} for kind in QuotaKind}) + monkeypatch.setattr(ssh_connection_attempt, 'quota_manager', quotas) + + def reject(*args, **kwargs): + raise RuntimeError('runtime stopped') + + monkeypatch.setattr(app.extensions['runtime_lifecycle'], 'start_job', reject) + monkeypatch.setattr(ssh_manager, 'create_ssh_connection', + lambda **kwargs: pytest.fail('network work after rejection')) + _, events = call_socket_handler(app, monkeypatch, socket_events.handle_ssh_connect, sid, { + 'host': 'host', 'username': 'u:t' if gateway else 'u', 'password': 'synthetic', + 'client_request_id': 'rejected', 'gateway_interaction': 1, + }) + assert registry.get(user_id, sid, 'rejected') is None + # The same user's only slot must be available again after job rejection. + reservation = quotas.reserve(QuotaKind.BACKGROUND_JOB, user_id) + reservation.release() + errors = [data for name, data in events if name == 'ssh_error'] + assert len(errors) == 1 + assert errors[0]['client_request_id'] == 'rejected' + + +@pytest.mark.usefixtures('direct_socket_authentication') +def test_gateway_events_cannot_mutate_an_ordinary_ssh_attempt(app, monkeypatch): + from app import socket_events + from tests.test_command_set_socket_events import create_socket_user, call_socket_handler + user_id, sid = create_socket_user(app, 'ordinary_gateway_events') + registry = app.extensions['ssh_attempt_registry'] + attempt = registry.create(user_id, sid, 'ordinary') + try: + for handler in (socket_events.handle_gateway_answer, socket_events.handle_gateway_input, + socket_events.handle_gateway_quick_cancel): + response, _ = call_socket_handler(app, monkeypatch, handler, sid, { + 'client_request_id': 'ordinary', 'challenge_id': 'x', 'answers': ['x'], 'data': 'x', + }) + assert response['success'] is False + call_socket_handler(app, monkeypatch, socket_events.handle_gateway_ack, sid, + {'client_request_id': 'ordinary', 'sequence': 1}) + assert not attempt.is_set() + assert attempt.commit_if_active() + finally: + registry.finish(attempt) diff --git a/tests/test_ssh_gateway_interaction.py b/tests/test_ssh_gateway_interaction.py index 8295c18..3ec6331 100644 --- a/tests/test_ssh_gateway_interaction.py +++ b/tests/test_ssh_gateway_interaction.py @@ -114,34 +114,28 @@ def close(self): closed.append(True) @pytest.mark.parametrize("winner", ["timeout", "commit"]) def test_gateway_timeout_is_atomic_with_terminal_commit(winner): - from app.socket_events import _CombinedCancellation gateway = GatewayAttempt(1, "sid", "req", lambda *args: None) - state = {"state": "pending"} - cancellation = _CombinedCancellation(threading.Event(), threading.Event(), - threading.Lock(), state, gateway=gateway) + cancellation = gateway try: if winner == "timeout": gateway.cancel(reason="timeout") assert not cancellation.commit_if_active() assert cancellation.is_set() - assert state["state"] == "pending" + assert not gateway.committed else: assert cancellation.commit_if_active() assert not gateway.cancel(reason="timeout") assert not cancellation.is_set() - assert state["state"] == "committed" + assert gateway.committed finally: gateway.finish() def test_timeout_wins_while_commit_is_waiting_on_gateway_condition(): - from app.socket_events import _CombinedCancellation gateway = GatewayAttempt(1, "sid", "race", lambda *args: None) result = [] entered = threading.Event() - state = {"state": "pending"} - cancellation = _CombinedCancellation(threading.Event(), threading.Event(), - threading.Lock(), state, gateway=gateway) + cancellation = gateway def commit(): entered.set() result.append(cancellation.commit_if_active()) @@ -154,6 +148,6 @@ def commit(): worker.join(1) assert not worker.is_alive() assert result == [False] - assert state["state"] == "pending" + assert not gateway.committed finally: gateway.finish() diff --git a/tests/test_startup_commands.py b/tests/test_startup_commands.py index 5bddeb3..3e9f04a 100644 --- a/tests/test_startup_commands.py +++ b/tests/test_startup_commands.py @@ -349,7 +349,6 @@ def send(self, data): def test_user_cancel_is_rejected_after_startup_delivery_commits(monkeypatch): from app import ssh_manager - import app.socket_events as socket_events send_started = threading.Event() release_send = threading.Event() @@ -362,19 +361,12 @@ def send(self, data): channel = BlockingFullSend() client = _StartupCommandClient(channel) - user_cancel = threading.Event() lifecycle_cancel = threading.Event() - attempt = { - 'cancel_event': user_cancel, - 'commit_lock': threading.Lock(), - 'state': 'pending', - } - cancellation = socket_events._CombinedCancellation( - user_cancel, - lifecycle_cancel, - attempt['commit_lock'], - attempt, - ) + from app.ssh_connection_attempt import SSHConnectionAttempt + attempt = SSHConnectionAttempt(1, 'sid', 'req') + user_cancel = attempt.cancel_event + attempt.bind_runtime(lifecycle_cancel) + cancellation = attempt result = {} monkeypatch.setattr(ssh_manager.paramiko, 'SSHClient', lambda: client) @@ -395,8 +387,8 @@ def connect(): worker.start() try: assert send_started.wait(2) - assert attempt['state'] == 'committed' - assert socket_events._try_cancel_ssh_attempt(attempt) is False + assert attempt.committed + assert attempt.cancel() is False assert not user_cancel.is_set() finally: release_send.set() @@ -412,23 +404,13 @@ def connect(): def test_user_cancel_before_startup_commit_sends_nothing(monkeypatch): from app import ssh_manager - import app.socket_events as socket_events channel = _StartupCommandChannel() client = _StartupCommandClient(channel) - user_cancel = threading.Event() - attempt = { - 'cancel_event': user_cancel, - 'commit_lock': threading.Lock(), - 'state': 'pending', - } - cancellation = socket_events._CombinedCancellation( - user_cancel, - threading.Event(), - attempt['commit_lock'], - attempt, - ) - assert socket_events._try_cancel_ssh_attempt(attempt) is True + from app.ssh_connection_attempt import SSHConnectionAttempt + attempt = SSHConnectionAttempt(1, 'sid', 'req') + cancellation = attempt + assert attempt.cancel() is True monkeypatch.setattr(ssh_manager.paramiko, 'SSHClient', lambda: client) monkeypatch.setattr(ssh_manager.time, 'sleep', lambda _seconds: None) From 7ad00784154ff00c53b1f2ff452e48f504055951 Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Wed, 23 Sep 2026 14:09:52 +0200 Subject: [PATCH 05/11] Preserve existing SSH paths alongside gateway integration --- app/profile_manager.py | 4 +- app/socket_events.py | 218 +++++++++++++++++++----- app/ssh_connection_attempt.py | 8 +- app/ssh_gateway_interaction.py | 2 +- docs/warpgate.md | 5 + static/js/app.js | 9 +- tests/e2e/ssh-gateway.spec.js | 22 ++- tests/test_command_set_socket_events.py | 75 +++++--- tests/test_profile_manager.py | 17 ++ tests/test_ssh_connection_attempt.py | 6 +- tests/test_ssh_gateway_hooks.py | 66 +++++++ tests/test_startup_commands.py | 40 +++-- 12 files changed, 376 insertions(+), 96 deletions(-) diff --git a/app/profile_manager.py b/app/profile_manager.py index 2ad036b..124ac41 100644 --- a/app/profile_manager.py +++ b/app/profile_manager.py @@ -364,8 +364,8 @@ def _validate_profile_payload(user_id, payload, dependent_lock_held=False): return None, str(error) else: username = str(username).strip() - if ':' not in username and not re.match(r'^[a-zA-Z0-9_\-\.]{1,32}$', username): - return None, 'Invalid username format' + 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/socket_events.py b/app/socket_events.py index 0d6620f..9f742a6 100644 --- a/app/socket_events.py +++ b/app/socket_events.py @@ -133,6 +133,8 @@ _ENGINEIO_REJECTION_POLL_SECONDS = 0.01 _smb_attempts_lock = threading.RLock() _smb_attempts = {} +_ssh_connect_attempts_lock = threading.RLock() +_ssh_connect_attempts = {} _ssh_banner_prompts_lock = threading.RLock() _ssh_banner_prompts = {} SSH_AUTH_BANNER_DECISION_TIMEOUT = 60 @@ -179,6 +181,49 @@ def _cancel_ssh_banner_prompt_for_request(user_id, socket_sid, request_id): prompt['event'].set() +def _try_cancel_ssh_attempt(attempt): + """Cancel an attempt unless an irreversible connection step won first.""" + commit_lock = attempt.get('commit_lock') + if commit_lock is None: + if attempt.get('state') == 'committed': + return False + attempt['state'] = 'cancelled' + attempt['cancel_event'].set() + return True + with commit_lock: + state = attempt.get('state', 'pending') + if state == 'committed' or state == 'finished': + return False + attempt['state'] = 'cancelled' + attempt['cancel_event'].set() + return True + + +def _force_cancel_ssh_attempt(attempt): + """Cancel runtime work even after the user-visible commit boundary.""" + commit_lock = attempt.get('commit_lock') + if commit_lock is None: + attempt['cancel_event'].set() + return + with commit_lock: + attempt['cancel_event'].set() + + +def _cancel_ssh_connect_attempts_for_socket(socket_sid): + handles = [] + with _ssh_connect_attempts_lock: + for (_owner_id, owner_sid, _request_id), attempt in tuple( + _ssh_connect_attempts.items() + ): + if owner_sid != socket_sid: + continue + _force_cancel_ssh_attempt(attempt) + if attempt.get('handle') is not None: + handles.append(attempt['handle']) + for handle in handles: + handle.cancel() + + def _smb_request_id(payload): if not isinstance(payload, dict): return '' @@ -715,9 +760,17 @@ def _audit_file_source_operation( class _CombinedCancellation: """Expose user and runtime cancellation through one Event-like interface.""" - def __init__(self, user_cancel_event, lifecycle_cancel_event): + def __init__( + self, + user_cancel_event, + lifecycle_cancel_event, + commit_lock=None, + attempt=None, + ): self._user_cancel_event = user_cancel_event self._lifecycle_cancel_event = lifecycle_cancel_event + self._commit_lock = commit_lock or threading.Lock() + self._attempt = attempt def is_set(self): return ( @@ -730,7 +783,7 @@ def wait(self, timeout=None): return True if timeout is None: while not self._user_cancel_event.wait(0.1): - if self.is_set(): + if self._lifecycle_cancel_event.is_set(): return True return True deadline = time.monotonic() + timeout @@ -741,6 +794,18 @@ def wait(self, timeout=None): self._user_cancel_event.wait(min(remaining, 0.1)) return True + def commit_if_active(self): + """Linearize an irreversible setup step against user cancellation.""" + with self._commit_lock: + if self.is_set(): + return False + if self._attempt is not None: + state = self._attempt.get('state', 'pending') + if state == 'cancelled' or state == 'finished': + return False + self._attempt['state'] = 'committed' + return True + def _storage_error_payload(error, *, user_id, include_success=True, **extra): """Log storage metadata, never contents, and build one safe client error.""" @@ -1256,6 +1321,7 @@ def handle_disconnect(): socket_sid = request.sid 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: @@ -1456,6 +1522,7 @@ def handle_ssh_connect(data, current_user=None): bastion_key_content = None client_request_id = None socket_sid = request.sid + client_cancel_event = threading.Event() try: data = data if isinstance(data, dict) else {} client_request_id = _ssh_request_id(data) or None @@ -1483,7 +1550,8 @@ def emit_error(message): )) def request_auth_banner_decision(banner, context): - if attempt.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() @@ -1496,7 +1564,8 @@ def request_auth_banner_decision(banner, context): } with _ssh_banner_prompts_lock: _ssh_banner_prompts[prompt_id] = prompt - if attempt.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, @@ -1506,12 +1575,15 @@ def request_auth_banner_decision(banner, context): 'port': bastion_port if context == 'jump_host' else port, 'client_request_id': client_request_id, }) - answered = False - banner_deadline = time.monotonic() + SSH_AUTH_BANNER_DECISION_TIMEOUT - while time.monotonic() < banner_deadline and not attempt.is_set(): - if decision_event.wait(.1): - answered = True - break + 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 @@ -1693,38 +1765,62 @@ def request_auth_banner_decision(banner, context): app = current_app._get_current_object() lifecycle = app.extensions['runtime_lifecycle'] - from .ssh_connection_attempt import SSHConnectionAttempt - from .ssh_gateway_interaction import GatewayAttempt registry = app.extensions['ssh_attempt_registry'] - gateway = ':' in username - if gateway and (data.get('gateway_interaction') != 1 or not client_request_id): - emit_error('Gateway connections require an updated interactive client') - return - try: - attempt = registry.create( - current_user.id, socket_sid, client_request_id, - factory=GatewayAttempt if gateway else SSHConnectionAttempt, - reserve=gateway, - **({'emit': lambda event, payload: socketio.emit(event, payload, to=socket_sid)} - if gateway else {}), - ) - except (ValueError, QuotaExceeded): - emit_error('Gateway connection limit reached or request unavailable' if gateway - else 'Connection request already in progress') - return - gateway_attempt = attempt if gateway else None + 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, } + 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.""" - attempt.bind_runtime(lifecycle_cancel_event) - cancellation = 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( @@ -1929,7 +2025,15 @@ def connect_ssh(lifecycle_cancel_event, credentials=credential_box): local_key_content = None local_bastion_password = None local_bastion_key_content = None - registry.finish(attempt) + 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( @@ -1937,9 +2041,19 @@ def connect_ssh(lifecycle_cancel_event, credentials=credential_box): connect_ssh, owner_id=current_user.id, ) - attempt.attach_handle(handle) + 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: - registry.finish(attempt) + 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', @@ -1975,18 +2089,29 @@ def handle_ssh_connect_cancel(data, current_user=None): request_id = _ssh_request_id(data) if not request_id: return {'success': False} - attempt = current_app.extensions['ssh_attempt_registry'].get( - current_user.id, request.sid, request_id, - ) - if attempt is None or attempt.kind != 'terminal': - return {'success': False, 'cancelled': False, 'reason': 'not_found'} - if not attempt.cancel(): - return {'success': False, 'cancelled': False, 'reason': 'already_committed'} + 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'} + 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, request.sid, request_id, ) + if handle is not None: + handle.cancel() return {'success': True, 'cancelled': True} @@ -5497,10 +5622,13 @@ def _start_gateway_quick_connect(data, user, host, port, username, password, key registry = app.extensions['ssh_attempt_registry'] from .ssh_gateway_interaction import GatewayAttempt try: - attempt = registry.create( - user_id, sid, request_id, factory=GatewayAttempt, reserve=True, kind='quick', - emit=lambda event, payload: socketio.emit(event, payload, to=sid), - ) + 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}) diff --git a/app/ssh_connection_attempt.py b/app/ssh_connection_attempt.py index 7dc02dd..b0a0c4e 100644 --- a/app/ssh_connection_attempt.py +++ b/app/ssh_connection_attempt.py @@ -1,4 +1,4 @@ -"""Socket-owned SSH setup, with one atomic cancellation/commit boundary.""" +"""Socket-owned interactive SSH setup with an atomic cancellation boundary.""" import secrets import threading import time @@ -7,10 +7,10 @@ class SSHConnectionAttempt: - """Event-like cancellation shared by terminal and interactive SSH setup. + """Cancellation and resource ownership for interactive SSH setup. A user cancellation loses after commit; disconnect and shutdown still stop - runtime work. Ordinary SSH keeps its existing operation-specific timeouts. + runtime work. Ordinary SSH retains its existing cancellation implementation. """ timeout = None @@ -153,7 +153,7 @@ def finish(self): class SSHAttemptRegistry: - """App-local admission and ownership for every asynchronous SSH connect.""" + """App-local admission and ownership for interactive SSH attempts.""" def __init__(self): self.lock = threading.Lock() diff --git a/app/ssh_gateway_interaction.py b/app/ssh_gateway_interaction.py index 2dce0f9..c375bd6 100644 --- a/app/ssh_gateway_interaction.py +++ b/app/ssh_gateway_interaction.py @@ -12,7 +12,7 @@ def __init__(self): class GatewayAttempt(SSHConnectionAttempt): - """Gateway-specific MFA and setup I/O on the shared SSH lifecycle.""" + """MFA and setup I/O shared by gateway terminals and Quick SFTP.""" timeout = 300 cancellation_error = GatewayCancelled diff --git a/docs/warpgate.md b/docs/warpgate.md index 9451091..f5dbfd6 100644 --- a/docs/warpgate.md +++ b/docs/warpgate.md @@ -10,6 +10,11 @@ Connect to an existing Warpgate SSH endpoint using an ordinary WebSSH profile: 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. +The integration is selected only 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. diff --git a/static/js/app.js b/static/js/app.js index 37f9c8d..3237619 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -2092,17 +2092,19 @@ const passwordAuth = authTypeSelect?.value === 'password'; const gateway = passwordAuth && validation.isGateway(userInput.value); const valid = !passwordAuth || gateway || passwordInput.value.length > 0; - passwordInput.required = passwordAuth && !gateway; 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'); - validatePassword(); + const gateway = validation.isGateway(userInput.value); + if (gateway || gatewayPasswordMode) validatePassword(); + gatewayPasswordMode = gateway; }; hostInput.addEventListener('input', validateHost); portInput.addEventListener('input', validatePort); @@ -2134,7 +2136,8 @@ if (authTypeSelect) { authTypeSelect.addEventListener('change', () => { - validateUser(); + 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)); } diff --git a/tests/e2e/ssh-gateway.spec.js b/tests/e2e/ssh-gateway.spec.js index e129551..3b2feee 100644 --- a/tests/e2e/ssh-gateway.spec.js +++ b/tests/e2e/ssh-gateway.spec.js @@ -50,15 +50,29 @@ test('gateway challenges are correlated, masked, text-only and transient', async test('ordinary passwords stay required and gateway passwords can be empty', async ({page}) => { await login(page); await page.evaluate(() => window.openDefaultConnectionModal()); + await page.locator('#hostInput').fill('example.com'); await page.locator('#usernameInput').fill('ordinary'); - await expect(page.locator('#passwordInput')).toHaveJSProperty('required', true); + await expect(page.locator('#passwordHint')).toHaveText(''); + await page.evaluate(() => { + window.__ordinaryConnects = []; + const original = window.socket.emit.bind(window.socket); + window.socket.emit = (event, data, ...args) => { + if (event === 'ssh_connect') { + window.__ordinaryConnects.push(data); + return; + } + return original(event, data, ...args); + }; + }); + await page.locator('#connectBtn').click(); + await expect(page.locator('#passwordInput')).toBeFocused(); + expect(await page.evaluate(() => window.__ordinaryConnects.length)).toBe(0); await page.locator('#usernameInput').fill('user:target'); await expect(page.locator('#passwordInput')).toHaveJSProperty('required', false); const gatewayHint = await page.evaluate(() => window.i18n.t('gateway.passwordHint')); const requiredHint = await page.evaluate(() => window.i18n.t('connection.passwordRequired')); await expect(page.locator('#passwordHint')).toHaveText(gatewayHint); await page.locator('#usernameInput').fill('ordinary'); - await expect(page.locator('#passwordInput')).toHaveJSProperty('required', true); await expect(page.locator('#passwordHint')).toHaveText(requiredHint); await page.locator('#passwordInput').fill('test-password'); await expect(page.locator('#passwordHint')).toHaveText(''); @@ -69,6 +83,10 @@ test('ordinary passwords stay required and gateway passwords can be empty', asyn await page.locator('#authTypeSelect').selectOption('password'); await expect(page.locator('#passwordInput')).toHaveJSProperty('required', false); await expect(page.locator('#passwordHint')).toHaveText(gatewayHint); + await page.locator('#usernameInput').fill('ordinary'); + await page.locator('#passwordInput').fill('test-password'); + await page.locator('#connectBtn').click(); + expect(await page.evaluate(() => window.__ordinaryConnects.map(data => data.username))).toEqual(['ordinary']); assertNoExternalRequests(page); }); diff --git a/tests/test_command_set_socket_events.py b/tests/test_command_set_socket_events.py index 69402e5..0828065 100644 --- a/tests/test_command_set_socket_events.py +++ b/tests/test_command_set_socket_events.py @@ -815,11 +815,15 @@ def wait_for_cancellation(**kwargs): assert acknowledgement == {'success': True, 'cancelled': True} assert completed.wait(2) assert not any(event in {'ssh_connected', 'ssh_error'} for event, _ in emitted) - registry = app.extensions['ssh_attempt_registry'] + attempt_key = (str(_user_id), sid, 'cancel-this-request') deadline = time.monotonic() + 2 - while registry.get(_user_id, sid, 'cancel-this-request') is not None and time.monotonic() < deadline: + while time.monotonic() < deadline: + with socket_events._ssh_connect_attempts_lock: + if attempt_key not in socket_events._ssh_connect_attempts: + break time.sleep(0.01) - assert registry.get(_user_id, sid, 'cancel-this-request') is None + with socket_events._ssh_connect_attempts_lock: + assert attempt_key not in socket_events._ssh_connect_attempts def test_ssh_connect_cancel_is_scoped_to_user_socket_request_and_banner(app): @@ -841,15 +845,24 @@ def cancel(self): target_request = 'target-request' other_request = 'other-request' - registry = app.extensions['ssh_attempt_registry'] attempt_cases = { - 'matching': registry.create(user_id, sid, target_request), - 'other_request': registry.create(user_id, sid, other_request), - 'other_socket': registry.create(user_id, other_sid, target_request), - 'other_user': registry.create(other_user_id, sid, target_request), + 'matching': ( + (str(user_id), sid, target_request), + {'cancel_event': threading.Event(), 'handle': RecordingHandle()}, + ), + 'other_request': ( + (str(user_id), sid, other_request), + {'cancel_event': threading.Event(), 'handle': RecordingHandle()}, + ), + 'other_socket': ( + (str(user_id), other_sid, target_request), + {'cancel_event': threading.Event(), 'handle': RecordingHandle()}, + ), + 'other_user': ( + (str(other_user_id), sid, target_request), + {'cancel_event': threading.Event(), 'handle': RecordingHandle()}, + ), } - for attempt in attempt_cases.values(): - attempt.attach_handle(RecordingHandle()) banner_cases = { 'matching-banner': { 'event': threading.Event(), @@ -880,8 +893,13 @@ def cancel(self): 'client_request_id': target_request, }, } + attempt_keys = [case[0] for case in attempt_cases.values()] banner_ids = list(banner_cases) + with socket_events._ssh_connect_attempts_lock: + socket_events._ssh_connect_attempts.update( + dict(attempt_cases.values()) + ) with socket_events._ssh_banner_prompts_lock: socket_events._ssh_banner_prompts.update(banner_cases) try: @@ -892,13 +910,13 @@ def cancel(self): }) assert acknowledgement == {'success': True, 'cancelled': True} - matching_attempt = attempt_cases['matching'] - assert matching_attempt.cancel_event.is_set() - assert matching_attempt.handle.cancel_calls == 1 + matching_attempt = attempt_cases['matching'][1] + assert matching_attempt['cancel_event'].is_set() + assert matching_attempt['handle'].cancel_calls == 1 for name in ('other_request', 'other_socket', 'other_user'): - attempt = attempt_cases[name] - assert not attempt.cancel_event.is_set() - assert attempt.handle.cancel_calls == 0 + attempt = attempt_cases[name][1] + assert not attempt['cancel_event'].is_set() + assert attempt['handle'].cancel_calls == 0 matching_banner = banner_cases['matching-banner'] assert matching_banner['event'].is_set() @@ -912,8 +930,9 @@ def cancel(self): assert not banner['event'].is_set() assert banner['accepted'] is True finally: - for attempt in attempt_cases.values(): - registry.finish(attempt) + with socket_events._ssh_connect_attempts_lock: + for attempt_key in attempt_keys: + socket_events._ssh_connect_attempts.pop(attempt_key, None) with socket_events._ssh_banner_prompts_lock: for prompt_id in banner_ids: socket_events._ssh_banner_prompts.pop(prompt_id, None) @@ -934,10 +953,15 @@ def cancel(self): self.cancel_calls += 1 request_id = 'committed-request' - registry = app.extensions['ssh_attempt_registry'] - attempt = registry.create(user_id, sid, request_id) - attempt.attach_handle(RecordingHandle()) - assert attempt.commit_if_active() + attempt_key = (str(user_id), sid, request_id) + attempt = { + 'cancel_event': threading.Event(), + 'commit_lock': threading.Lock(), + 'handle': RecordingHandle(), + 'state': 'committed', + } + with socket_events._ssh_connect_attempts_lock: + socket_events._ssh_connect_attempts[attempt_key] = attempt try: with app.test_request_context('/socket.io'): request.sid = sid @@ -950,10 +974,11 @@ def cancel(self): 'cancelled': False, 'reason': 'already_committed', } - assert not attempt.cancel_event.is_set() - assert attempt.handle.cancel_calls == 0 + assert not attempt['cancel_event'].is_set() + assert attempt['handle'].cancel_calls == 0 finally: - registry.finish(attempt) + with socket_events._ssh_connect_attempts_lock: + socket_events._ssh_connect_attempts.pop(attempt_key, None) def test_cancelled_tmux_reconnect_preserves_existing_remote_session( diff --git a/tests/test_profile_manager.py b/tests/test_profile_manager.py index f7da67e..5018d25 100644 --- a/tests/test_profile_manager.py +++ b/tests/test_profile_manager.py @@ -1548,3 +1548,20 @@ def test_normal_profile_save_keeps_pretty_json(app): 'schema_version': CURRENT_STORAGE_VERSIONS['profiles'], 'profiles': profiles, }, indent=2).encode('utf-8') + + +@pytest.mark.parametrize('username', [{'x': 'y'}, ['alice:target'], {'x': 'y' * 200}]) +def test_malformed_username_update_preserves_existing_profile(app, username): + from app import profile_manager + user_id = create_user(app, 'profile-validation-regression') + with app.app_context(): + payload = dict(name='Working', host='example.com', port=22, + username='deploy', auth_type='password') + profile, error = profile_manager.upsert_profile(user_id, payload) + assert error is None + updated, error = profile_manager.upsert_profile(user_id, { + **payload, 'id': profile['id'], 'username': username, + }) + assert updated is None + assert error == 'Invalid username format' + assert profile_manager.get_profile(user_id, profile['id']) == profile diff --git a/tests/test_ssh_connection_attempt.py b/tests/test_ssh_connection_attempt.py index 0ec9d8b..4a104bc 100644 --- a/tests/test_ssh_connection_attempt.py +++ b/tests/test_ssh_connection_attempt.py @@ -1,4 +1,4 @@ -"""Behavioral contracts shared by terminal and interactive SSH setup.""" +"""Cancellation contracts for interactive SSH setup and its base primitive.""" import threading import pytest @@ -127,7 +127,7 @@ def close(self): closed.append(self) assert released == [True] assert closed == [pending] -def test_ordinary_connect_keeps_its_operation_timeouts(monkeypatch): +def test_base_attempt_has_no_implicit_timeout(monkeypatch): from app import ssh_connection_attempt attempt = make_attempt() started = ssh_connection_attempt.time.monotonic() @@ -139,7 +139,7 @@ def test_ordinary_connect_keeps_its_operation_timeouts(monkeypatch): attempt.finish() -def test_interactive_admission_does_not_reduce_ordinary_connect_capacity(monkeypatch): +def test_interactive_admission_reserves_only_when_requested(monkeypatch): from app import ssh_connection_attempt from app.quota_manager import QuotaExceeded, QuotaKind, QuotaManager from app.ssh_gateway_interaction import GatewayAttempt diff --git a/tests/test_ssh_gateway_hooks.py b/tests/test_ssh_gateway_hooks.py index 760be4f..d4b8e71 100644 --- a/tests/test_ssh_gateway_hooks.py +++ b/tests/test_ssh_gateway_hooks.py @@ -195,3 +195,69 @@ def test_gateway_events_cannot_mutate_an_ordinary_ssh_attempt(app, monkeypatch): assert attempt.commit_if_active() finally: registry.finish(attempt) + + +@pytest.mark.usefixtures('direct_socket_authentication') +def test_ordinary_connect_does_not_depend_on_gateway_admission(app, monkeypatch): + import threading + from app import socket_events, ssh_manager + from tests.test_command_set_socket_events import create_socket_user, call_socket_handler + _, sid = create_socket_user(app, 'ordinary_independent') + connected = threading.Event() + app.extensions['ssh_attempt_registry'].shutdown() + def connect(**kwargs): + assert 'gateway_attempt' not in kwargs + assert kwargs['password'] == 'secret' + connected.set() + return None, 'Expected test stop' + monkeypatch.setattr(ssh_manager, 'create_ssh_connection', connect) + call_socket_handler(app, monkeypatch, socket_events.handle_ssh_connect, sid, { + 'host': 'host', 'username': 'deploy', 'password': 'secret', + 'client_request_id': 'ordinary-request', + }) + assert connected.wait(2) + + +@pytest.mark.usefixtures('direct_socket_authentication') +@pytest.mark.parametrize('first,second', [ + ('ordinary', 'gateway'), ('gateway', 'ordinary'), + ('ordinary', 'quick'), ('quick', 'ordinary'), +]) +def test_request_id_cannot_cross_ordinary_and_gateway_attempts(app, monkeypatch, first, second): + import threading + from app import socket_events, ssh_manager + from tests.test_command_set_socket_events import create_socket_user, call_socket_handler + _, sid = create_socket_user(app, 'request_collision') + entered, release, finished = threading.Event(), threading.Event(), threading.Event() + calls = [] + + def connect(*args, **kwargs): + calls.append(kwargs) + entered.set() + try: + assert release.wait(3) + return None, 'Expected test stop' + finally: + finished.set() + + monkeypatch.setattr(ssh_manager, 'create_ssh_connection', connect) + monkeypatch.setattr(socket_events.connection_pool.temp_connection_pool, 'create_connection', connect) + + def start(kind): + handler = (socket_events.handle_quick_connect if kind == 'quick' + else socket_events.handle_ssh_connect) + return call_socket_handler(app, monkeypatch, handler, sid, { + 'host': 'host', 'username': 'deploy' if kind == 'ordinary' else 'u:t', + 'password': 'secret', 'client_request_id': 'same-request', + 'gateway_interaction': 1, + }) + + try: + start(first) + assert entered.wait(2) + _, events = start(second) + assert len(calls) == 1 + assert any(name in ('ssh_error', 'quick_connect_error') for name, _ in events) + finally: + release.set() + assert finished.wait(2) diff --git a/tests/test_startup_commands.py b/tests/test_startup_commands.py index 3e9f04a..5bddeb3 100644 --- a/tests/test_startup_commands.py +++ b/tests/test_startup_commands.py @@ -349,6 +349,7 @@ def send(self, data): def test_user_cancel_is_rejected_after_startup_delivery_commits(monkeypatch): from app import ssh_manager + import app.socket_events as socket_events send_started = threading.Event() release_send = threading.Event() @@ -361,12 +362,19 @@ def send(self, data): channel = BlockingFullSend() client = _StartupCommandClient(channel) + user_cancel = threading.Event() lifecycle_cancel = threading.Event() - from app.ssh_connection_attempt import SSHConnectionAttempt - attempt = SSHConnectionAttempt(1, 'sid', 'req') - user_cancel = attempt.cancel_event - attempt.bind_runtime(lifecycle_cancel) - cancellation = attempt + attempt = { + 'cancel_event': user_cancel, + 'commit_lock': threading.Lock(), + 'state': 'pending', + } + cancellation = socket_events._CombinedCancellation( + user_cancel, + lifecycle_cancel, + attempt['commit_lock'], + attempt, + ) result = {} monkeypatch.setattr(ssh_manager.paramiko, 'SSHClient', lambda: client) @@ -387,8 +395,8 @@ def connect(): worker.start() try: assert send_started.wait(2) - assert attempt.committed - assert attempt.cancel() is False + assert attempt['state'] == 'committed' + assert socket_events._try_cancel_ssh_attempt(attempt) is False assert not user_cancel.is_set() finally: release_send.set() @@ -404,13 +412,23 @@ def connect(): def test_user_cancel_before_startup_commit_sends_nothing(monkeypatch): from app import ssh_manager + import app.socket_events as socket_events channel = _StartupCommandChannel() client = _StartupCommandClient(channel) - from app.ssh_connection_attempt import SSHConnectionAttempt - attempt = SSHConnectionAttempt(1, 'sid', 'req') - cancellation = attempt - assert attempt.cancel() is True + user_cancel = threading.Event() + attempt = { + 'cancel_event': user_cancel, + 'commit_lock': threading.Lock(), + 'state': 'pending', + } + cancellation = socket_events._CombinedCancellation( + user_cancel, + threading.Event(), + attempt['commit_lock'], + attempt, + ) + assert socket_events._try_cancel_ssh_attempt(attempt) is True monkeypatch.setattr(ssh_manager.paramiko, 'SSHClient', lambda: client) monkeypatch.setattr(ssh_manager.time, 'sleep', lambda _seconds: None) From 9becf34c67b376ddb39ae8a710d303b2707bd57e Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Wed, 23 Sep 2026 14:36:03 +0200 Subject: [PATCH 06/11] Fix gateway dialog state and interactive password validation --- static/js/app.js | 7 ++- static/js/ssh-gateway-dialog.js | 15 ++++-- tests/e2e/ssh-gateway.spec.js | 91 +++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 5 deletions(-) diff --git a/static/js/app.js b/static/js/app.js index 3237619..6977f2e 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -2103,7 +2103,10 @@ hint(userInput, userHint, validation.isValidUsername(userInput.value, authTypeSelect?.value !== 'tailscale'), 'validation.username'); const gateway = validation.isGateway(userInput.value); - if (gateway || gatewayPasswordMode) validatePassword(); + if (gateway || gatewayPasswordMode) { + if (passwordInput) passwordInput.required = authTypeSelect?.value === 'password' && !gateway; + validatePassword(); + } gatewayPasswordMode = gateway; }; hostInput.addEventListener('input', validateHost); @@ -2946,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; diff --git a/static/js/ssh-gateway-dialog.js b/static/js/ssh-gateway-dialog.js index 91fa3ff..f74fb5b 100644 --- a/static/js/ssh-gateway-dialog.js +++ b/static/js/ssh-gateway-dialog.js @@ -17,11 +17,14 @@ 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; + const next = [...attempts].find(([, pending]) => pending.content); + if (next) open(next[0]); } } function cancel() { @@ -41,13 +44,17 @@ }); } function open(id) { - if (!attempts.has(id)) return null; + const attempt = attempts.get(id); + if (!attempt) return null; + attempt.content ||= element('div'); if (active !== id) { - content.replaceChildren(); + content.replaceChildren(attempt.content); active = id; } window.ModalManager.open(modal); - return attempts.get(id); + 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. @@ -71,6 +78,7 @@ 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(); content.append(element('h3', data.title || t('gateway.authentication', 'Gateway authentication'))); appendInstructions(content, data.instructions || ''); @@ -112,6 +120,7 @@ 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'); diff --git a/tests/e2e/ssh-gateway.spec.js b/tests/e2e/ssh-gateway.spec.js index 3b2feee..d39a2bc 100644 --- a/tests/e2e/ssh-gateway.spec.js +++ b/tests/e2e/ssh-gateway.spec.js @@ -53,6 +53,9 @@ test('ordinary passwords stay required and gateway passwords can be empty', asyn await page.locator('#hostInput').fill('example.com'); await page.locator('#usernameInput').fill('ordinary'); await expect(page.locator('#passwordHint')).toHaveText(''); + await page.locator('#authTypeSelect').selectOption('key'); + await page.locator('#authTypeSelect').selectOption('password'); + await expect(page.locator('#passwordInput')).toHaveJSProperty('required', true); await page.evaluate(() => { window.__ordinaryConnects = []; const original = window.socket.emit.bind(window.socket); @@ -90,6 +93,94 @@ test('ordinary passwords stay required and gateway passwords can be empty', asyn assertNoExternalRequests(page); }); +test('concurrent gateway prompts restore the pending terminal and its input routing', async ({page}) => { + await login(page); + await page.evaluate(() => { + window.__gatewayInputs = []; + const original = window.socket.emit.bind(window.socket); + window.socket.emit = (event, data, ack) => { + if (event === 'ssh_gateway_input') { + window.__gatewayInputs.push(data); + return; + } + return original(event, data, ack); + }; + const dispatch = (event, data) => window.socket.listeners(event).forEach(fn => fn(data)); + for (const id of ['first', 'second']) { + window.SSHGatewayDialog.prepare({username: 'user:target', client_request_id: id}, id === 'second'); + dispatch('ssh_gateway_progress', {client_request_id: id, phase: 'setup'}); + } + dispatch('quick_connect_error', {client_request_id: 'second', error: 'Cancelled'}); + }); + const modal = page.locator('#sshGatewayModal'); + await expect(modal).toHaveClass(/show/); + await expect(modal.locator('.xterm')).toHaveCount(1); + await modal.locator('.xterm-helper-textarea').press('y'); + await expect.poll(() => page.evaluate(() => window.__gatewayInputs)).toEqual([ + {client_request_id: 'first', data: 'y'}, + ]); + await page.evaluate(() => window.SSHGatewayDialog.close('first')); + await expect(modal).not.toHaveClass(/show/); + await expect(modal.locator('.xterm')).toHaveCount(0); +}); + +test('concurrent gateway challenges keep their answers scoped and clear on disconnect', async ({page}) => { + await login(page); + await page.evaluate(() => { + window.__answers = []; + const original = window.socket.emit.bind(window.socket); + window.socket.emit = (event, data, ack) => { + if (event === 'ssh_gateway_answer') { + window.__answers.push(data); + ack?.({success: true}); + return; + } + return original(event, data, ack); + }; + window.SSHGatewayDialog.prepare({username: 'user:target', client_request_id: 'first'}); + window.socket.listeners('ssh_gateway_challenge').forEach(fn => fn({ + client_request_id: 'first', challenge_id: 'first-otp', prompts: [{label: 'First OTP'}], + })); + }); + const modal = page.locator('#sshGatewayModal'); + await modal.locator('input').fill('123456'); + await page.evaluate(() => { + window.SSHGatewayDialog.prepare({username: 'user:target', client_request_id: 'second'}); + window.socket.listeners('ssh_gateway_challenge').forEach(fn => fn({ + client_request_id: 'second', challenge_id: 'second-otp', prompts: [{label: 'Second OTP'}], + })); + }); + await expect(modal.locator('input')).toHaveValue(''); + await page.evaluate(() => window.SSHGatewayDialog.close('second')); + await expect(modal.locator('input')).toHaveValue('123456'); + await modal.locator('button[type=submit]').click(); + expect(await page.evaluate(() => window.__answers)).toEqual([ + {client_request_id: 'first', challenge_id: 'first-otp', answers: ['123456']}, + ]); + await page.evaluate(() => window.socket.listeners('disconnect').forEach(fn => fn('transport close'))); + await expect(modal).not.toHaveClass(/show/); + await expect(modal.locator('input')).toHaveCount(0); + expect(await page.evaluate(() => window.SSHGatewayDialog.has('first'))).toBe(false); +}); + +test('gateway backdrop click preserves the prompt and its cancel action', async ({page}) => { + await login(page); + await page.evaluate(() => { + window.SSHGatewayDialog.prepare({username: 'user:target', client_request_id: 'backdrop'}); + window.socket.listeners('ssh_gateway_challenge').forEach(fn => fn({ + client_request_id: 'backdrop', challenge_id: 'otp', prompts: [{label: 'OTP'}], + })); + }); + const modal = page.locator('#sshGatewayModal'); + await modal.locator('input').fill('123456'); + await modal.click({position: {x: 2, y: 2}}); + await expect(modal).toHaveClass(/show/); + await expect(modal.locator('input')).toHaveValue('123456'); + await expect(modal.locator('.btn-secondary')).toBeVisible(); + await page.evaluate(() => window.SSHGatewayDialog.close('backdrop')); + await expect(modal.locator('input')).toHaveCount(0); +}); + for (const action of ['button', 'escape']) { test(`gateway direct reconnect can be cancelled using ${action}`, async ({page}) => { From f4328b438be37f4876dd58fb88736e6ae72c4366 Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Wed, 23 Sep 2026 15:44:39 +0200 Subject: [PATCH 07/11] Restore pending gateway dialogs after SSH banner rejection --- static/js/ssh-gateway-dialog.js | 2 ++ tests/e2e/ssh-gateway.spec.js | 56 +++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/static/js/ssh-gateway-dialog.js b/static/js/ssh-gateway-dialog.js index f74fb5b..54be427 100644 --- a/static/js/ssh-gateway-dialog.js +++ b/static/js/ssh-gateway-dialog.js @@ -23,6 +23,8 @@ 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]); } diff --git a/tests/e2e/ssh-gateway.spec.js b/tests/e2e/ssh-gateway.spec.js index d39a2bc..402a48b 100644 --- a/tests/e2e/ssh-gateway.spec.js +++ b/tests/e2e/ssh-gateway.spec.js @@ -163,6 +163,62 @@ test('concurrent gateway challenges keep their answers scoped and clear on disco expect(await page.evaluate(() => window.SSHGatewayDialog.has('first'))).toBe(false); }); +for (const outcome of ['declined', 'timed out']) { + test(`pending gateway terminal returns after another SSH banner is ${outcome}`, async ({page}) => { + await login(page); + await page.evaluate(() => { + const dispatch = (event, data) => window.socket.listeners(event).forEach(fn => fn(data)); + window.__gatewayInputs = []; + const original = window.socket.emit.bind(window.socket); + window.socket.emit = (event, data, ...args) => { + if (event === 'ssh_gateway_input') { + window.__gatewayInputs.push(data); + return; + } + if (event === 'ssh_auth_banner_decision') { + dispatch('ssh_error', {client_request_id: 'reconnect_second', error: 'Banner declined'}); + return; + } + return original(event, data, ...args); + }; + for (const id of ['reconnect_first', 'reconnect_second', 'reconnect_third']) { + window.SSHGatewayDialog.prepare({username: 'user:target', client_request_id: id}); + } + dispatch('ssh_gateway_progress', {client_request_id: 'reconnect_first', phase: 'setup'}); + dispatch('ssh_gateway_challenge', { + client_request_id: 'reconnect_second', challenge_id: 'otp', prompts: [{label: 'OTP'}], + }); + dispatch('ssh_auth_banner', { + client_request_id: 'reconnect_second', prompt_id: 'banner', + banner: 'Access policy', host: 'gateway.local', port: 22, + }); + // An unrelated failure must not bring a gateway prompt above this banner. + dispatch('ssh_error', {client_request_id: 'reconnect_third', error: 'Connection failed'}); + }); + const gateway = page.locator('#sshGatewayModal'); + const banner = page.locator('#sshAuthBannerModal'); + await expect(banner).toHaveClass(/show/); + await expect(gateway).not.toHaveClass(/show/); + if (outcome === 'declined') { + await page.locator('#sshAuthBannerCancel').click(); + } else { + await page.evaluate(() => window.socket.listeners('ssh_error').forEach(fn => fn({ + client_request_id: 'reconnect_second', error: 'Banner timed out', + }))); + } + await expect(banner).not.toHaveClass(/show/); + await expect(gateway).toHaveClass(/show/); + await expect(gateway.locator('.xterm')).toHaveCount(1); + await gateway.locator('.xterm-helper-textarea').press('y'); + await expect.poll(() => page.evaluate(() => window.__gatewayInputs)).toEqual([ + {client_request_id: 'reconnect_first', data: 'y'}, + ]); + await page.evaluate(() => window.socket.listeners('disconnect').forEach(fn => fn('transport close'))); + await expect(gateway).not.toHaveClass(/show/); + await expect(gateway.locator('.xterm')).toHaveCount(0); + }); +} + test('gateway backdrop click preserves the prompt and its cancel action', async ({page}) => { await login(page); await page.evaluate(() => { From 47eb6f37f7ac9b91937cb4ce3e48fff2895f1113 Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Wed, 23 Sep 2026 16:41:46 +0200 Subject: [PATCH 08/11] Fix gateway dialog stacking above connection forms --- static/css/style.css | 3 ++- tests/e2e/ssh-gateway.spec.js | 12 +++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/static/css/style.css b/static/css/style.css index b1d57e2..253e1d1 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -6313,7 +6313,8 @@ body.keyboard-open.notepad-focused .notepad-panel { } /* Transient SSH gateway interaction */ -.gateway-modal { z-index: 1100; } +/* 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-modal label { display: block; margin: 12px 0; } diff --git a/tests/e2e/ssh-gateway.spec.js b/tests/e2e/ssh-gateway.spec.js index 402a48b..ea8f522 100644 --- a/tests/e2e/ssh-gateway.spec.js +++ b/tests/e2e/ssh-gateway.spec.js @@ -4,6 +4,7 @@ const {login, assertNoExternalRequests} = require('./helpers'); test('gateway challenges are correlated, masked, text-only and transient', async ({page}) => { await login(page); await page.evaluate(() => { + window.openDefaultConnectionModal(); window.__gatewaySent = []; const original = window.socket.emit.bind(window.socket); window.socket.emit = (event, data, ack) => { @@ -16,7 +17,7 @@ test('gateway challenges are correlated, masked, text-only and transient', async }; const dispatch = data => window.socket.listeners('ssh_gateway_challenge').forEach(fn => fn(data)); dispatch({client_request_id: 'unsolicited', prompts: []}); - window.SSHGatewayDialog.prepare({username: 'user:target', client_request_id: 'gateway-test'}); + window.SSHGatewayDialog.prepare({username: 'user:target', client_request_id: 'gateway-test'}, true); dispatch({ client_request_id: 'gateway-test', challenge_id: 'one-shot', title: '', @@ -37,11 +38,12 @@ test('gateway challenges are correlated, masked, text-only and transient', async event: 'ssh_gateway_answer', data: {client_request_id: 'gateway-test', challenge_id: 'one-shot', answers: ['123456']}, }); - await page.evaluate(() => { - window.socket.listeners('ssh_error').forEach(fn => fn({ - client_request_id: 'gateway-test', error: 'cancelled', - })); + await modal.locator('button[type=button]').click(); + expect(await page.evaluate(() => window.__gatewaySent[1])).toEqual({ + event: 'ssh_gateway_quick_cancel', + data: {client_request_id: 'gateway-test'}, }); + await expect(page.locator('#connectionModal')).toHaveClass(/show/); await expect(modal).not.toHaveClass(/show/); await expect(modal.locator('input')).toHaveCount(0); assertNoExternalRequests(page); From e23d5288c6292624cafeb7d8e842ae1a9488ded2 Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Wed, 23 Sep 2026 17:04:20 +0200 Subject: [PATCH 09/11] Remove unused gateway helper and import --- app/ssh_gateway.py | 9 --------- app/ssh_gateway_setup.py | 1 - 2 files changed, 10 deletions(-) diff --git a/app/ssh_gateway.py b/app/ssh_gateway.py index c1fe3db..9e693f0 100644 --- a/app/ssh_gateway.py +++ b/app/ssh_gateway.py @@ -22,15 +22,6 @@ def parse_selector(value): return user, target -def is_gateway(value): - """Recognize only fully validated selectors.""" - try: - parse_selector(value) - return True - except ValueError: - return False - - def tmux_name(prefix, host, port, selector, user_id): parse_selector(selector) safe_prefix = re.sub(r"[^A-Za-z0-9_]", "_", prefix)[:80] diff --git a/app/ssh_gateway_setup.py b/app/ssh_gateway_setup.py index 3553d9d..c32d0ab 100644 --- a/app/ssh_gateway_setup.py +++ b/app/ssh_gateway_setup.py @@ -1,6 +1,5 @@ """Prove target readiness before any terminal startup action is allowed.""" import secrets -import socket import threading import time From 35681077f6c59bfaf0a98a26038e984df71f4be1 Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Wed, 23 Sep 2026 17:10:39 +0200 Subject: [PATCH 10/11] Explain common SFTP file permission failures --- app/sftp_handler.py | 38 ++++++++++++++++++++++++++++ tests/test_sftp_handler.py | 52 +++++++++++++++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 1 deletion(-) 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/tests/test_sftp_handler.py b/tests/test_sftp_handler.py index a0a7f97..7b9fcad 100644 --- a/tests/test_sftp_handler.py +++ b/tests/test_sftp_handler.py @@ -1,4 +1,7 @@ -"""Tests for SFTP path sanitization.""" +"""Tests for SFTP path sanitization and safe error reporting.""" + +import errno +from contextlib import contextmanager import pytest @@ -49,6 +52,53 @@ def fake_session(_identifier): ) == 'application-authored error' +@pytest.mark.parametrize('failure, expected', [ + (PermissionError(errno.EACCES, 'Permission denied', '/private/key'), + 'Permission denied'), + (OSError(errno.EPERM, 'Operation not permitted', '/private/key'), + 'Permission denied'), + (FileNotFoundError(errno.ENOENT, 'No such file', '/private/key'), + 'File or directory not found'), + (NotADirectoryError(errno.ENOTDIR, 'Not a directory', '/private/key'), + 'Not a directory'), + (FileExistsError(errno.EEXIST, 'File exists', '/private/key'), + 'File or directory already exists'), + (OSError(errno.EROFS, 'Read-only file system', '/private/key'), + 'Remote file system is read-only'), + (OSError(errno.ENOSPC, 'No space left on device', '/private/key'), + 'Remote file system is full'), + (TimeoutError(errno.ETIMEDOUT, 'Timed out', '/private/key'), + 'Remote file operation timed out'), + (OSError('Permission denied: /private/key'), 'Permission denied'), +]) +def test_known_remote_file_failures_have_safe_reasons(failure, expected): + import app.sftp_handler as sftp_handler + + reason = sftp_handler.public_sftp_error(failure) + + assert reason == expected + assert '/private/key' not in reason + + +def test_directory_and_editor_save_report_permission_failure(monkeypatch): + import app.sftp_handler as sftp_handler + + @contextmanager + def denied_session(_identifier): + raise PermissionError(errno.EACCES, 'Permission denied', '/private/key') + yield + + monkeypatch.setattr(sftp_handler, 'sftp_session', denied_session) + + files, listing_error = sftp_handler.list_directory('session', '/private') + save = sftp_handler.write_file_text('session', '/private/key', 'updated') + + assert files is None + assert listing_error == 'Permission denied' + assert save.success is False + assert save.error == 'Permission denied' + + def test_paramiko_directory_parser_rejects_huge_extended_attribute_count(): import paramiko from paramiko.message import Message From 15d9802ffbbb144e7c601cf6bc5bb4768fa1fabc Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Wed, 23 Sep 2026 17:40:51 +0200 Subject: [PATCH 11/11] Require admin opt-in for SSH gateways and polish gateway dialogs --- README.md | 3 +- app/__init__.py | 17 +++++-- app/app_settings.py | 21 +++++++++ app/profile_manager.py | 3 ++ app/socket_events.py | 3 ++ app/ssh_gateway_interaction.py | 4 ++ docs/warpgate.md | 15 +++++- static/css/style.css | 11 ++++- static/js/admin.js | 22 +++++++++ static/js/connection-validation.js | 2 + static/js/i18n.js | 18 ++++++++ static/js/ssh-gateway-dialog.js | 21 ++++++++- templates/admin.html | 11 +++++ templates/index.html | 1 + templates/security.html | 1 + tests/e2e/ssh-gateway.spec.js | 55 +++++++++++++++++++++- tests/integration/test_warpgate.py | 4 +- tests/js/connection-validation.test.js | 13 ++++++ tests/test_admin_routes.py | 30 ++++++++++++ tests/test_app_settings.py | 64 ++++++++++++++++++++++++++ tests/test_ssh_connection_attempt.py | 6 +++ tests/test_ssh_gateway_hooks.py | 6 +++ tests/test_ssh_gateway_interaction.py | 22 +++++++++ tests/test_ssh_manager.py | 4 ++ 24 files changed, 345 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index efceb26..b5e3696 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,8 @@ 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. -Connect through an existing SSH gateway with a user:target username. See the +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 diff --git a/app/__init__.py b/app/__init__.py index aba6bd4..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, @@ -1491,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 @@ -1501,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({ @@ -1519,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/profile_manager.py b/app/profile_manager.py index 124ac41..4b9b8d4 100644 --- a/app/profile_manager.py +++ b/app/profile_manager.py @@ -356,6 +356,9 @@ def _validate_profile_payload(user_id, payload, dependent_lock_held=False): 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: diff --git a/app/socket_events.py b/app/socket_events.py index 9f742a6..48b0ec1 100644 --- a/app/socket_events.py +++ b/app/socket_events.py @@ -867,6 +867,9 @@ def _validate_ssh_params(host, port, username, allow_internal=False, *, allow_ga 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) diff --git a/app/ssh_gateway_interaction.py b/app/ssh_gateway_interaction.py index c375bd6..b6f89b3 100644 --- a/app/ssh_gateway_interaction.py +++ b/app/ssh_gateway_interaction.py @@ -36,6 +36,10 @@ def __init__(self, user_id, sid, request_id, emit, *, 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() diff --git a/docs/warpgate.md b/docs/warpgate.md index f5dbfd6..428b66e 100644 --- a/docs/warpgate.md +++ b/docs/warpgate.md @@ -1,6 +1,17 @@ # Warpgate SSH connections -Connect to an existing Warpgate SSH endpoint using an ordinary WebSSH profile: +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. @@ -10,7 +21,7 @@ Connect to an existing Warpgate SSH endpoint using an ordinary WebSSH profile: 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. -The integration is selected only by a validated `user:target` username. +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. diff --git a/static/css/style.css b/static/css/style.css index 253e1d1..34eb371 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -6317,6 +6317,13 @@ body.keyboard-open.notepad-focused .notepad-panel { .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-modal label { display: block; margin: 12px 0; } -.gateway-modal input { display: block; width: 100%; } +.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/connection-validation.js b/static/js/connection-validation.js index 06f6b02..ea807b2 100644 --- a/static/js/connection-validation.js +++ b/static/js/connection-validation.js @@ -26,6 +26,8 @@ )); } 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; diff --git a/static/js/i18n.js b/static/js/i18n.js index c1d5fd7..1349149 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -6,6 +6,9 @@ const translations = { '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.", @@ -1422,6 +1425,9 @@ const translations = { '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.", @@ -2837,6 +2843,9 @@ const translations = { '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.", @@ -4251,6 +4260,9 @@ const translations = { '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.", @@ -5665,6 +5677,9 @@ const translations = { '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.", @@ -7079,6 +7094,9 @@ const translations = { 'gateway.response': "响应", 'gateway.continue': "继续", 'gateway.target': "正在等待目标访问。请检查下方的网关提示。", + 'admin.integrations': "集成", + 'admin.sshGateway': "Warpgate / SSH 网关", + 'admin.sshGatewayHint': "允许所有用户使用 user:target 建立交互式网关连接。默认禁用。禁用后会阻止新连接和未完成的网关认证;已建立的会话保持连接。更改后请重新加载工作区。", 'gateway.cancel': "取消连接", 'gateway.passwordHint': "将密码留空以使用交互式网关身份验证。", 'workspace.directorySync.prompt': "文件夹同步已暂停:无法确认终端提示符为空。请完成输入,或关闭同步以独立浏览文件。", diff --git a/static/js/ssh-gateway-dialog.js b/static/js/ssh-gateway-dialog.js index 54be427..2fbe722 100644 --- a/static/js/ssh-gateway-dialog.js +++ b/static/js/ssh-gateway-dialog.js @@ -61,6 +61,7 @@ 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; @@ -82,13 +83,17 @@ if (!attempt || !Array.isArray(data.prompts) || data.prompts.length > 8) return; const content = attempt.content; content.replaceChildren(); - content.append(element('h3', data.title || t('gateway.authentication', 'Gateway authentication'))); + 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; @@ -154,12 +159,24 @@ 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); - box.append(content, button); + footer.append(button); + box.append(header, content, footer); modal.append(box); document.body.append(modal); modal.addEventListener('keydown', event => { 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 @@