From 023ff78b3127a66bf8cf8644e1ca35a9dda55dda Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Sun, 13 Sep 2026 10:27:07 +0800 Subject: [PATCH 1/8] Add SSH jump routes with independent connection nodes ## Why Saved SSH sessions need reusable jump paths while preserving browser key ownership, host trust, and access to the final terminal. ## What changed - Store independently referenced route nodes and selectable Entries with revision-checked edits, migration, import remapping, and explicit cycle repair. - Connect through up to three jump hosts and reuse the final SSH transport for Terminal, Files, and Agent Tunnel without a direct fallback. - Bind signing, cancellation, and host-key retry to their connection attempts while preserving direct localhost behavior and per-node credential ownership. - Keep passwords out of settings, exports, and connection diagnostics. ## Testing - Verify shared editing, cycle repair, migration, concurrent storage writes, imported credentials, signing ownership, retry correlation, and redaction in Chromium. - Exercise real multi-hop SSH, mixed key/password authentication, per-hop trust, SFTP, reverse forwarding, Agent Tunnel, failed forwarding, and cancellation. - Pass the repository headless and 49-case browser regression suites. --- README.md | 55 ++- app.py | 135 +++++-- scripts/run_smoke_tests.py | 2 + static/js/standterm-ssh-route-editor.js | 223 +++++++++++ static/js/standterm-ssh-routes.js | 322 ++++++++++++++++ templates/index.html | 476 ++++++++++++++++++------ terminal_backends/ssh.py | 166 ++++++++- tests/agent_backend_smoke.py | 2 + tests/agent_browser_smoke.py | 18 +- tests/agent_tunnel_smoke.py | 27 ++ tests/ssh_jump_smoke.py | 266 +++++++++++++ tests/ssh_routes.test.cjs | 84 +++++ tests/ssh_routes_browser_smoke.py | 304 +++++++++++++++ tests/ssh_start_smoke.py | 113 ++++++ 14 files changed, 2032 insertions(+), 161 deletions(-) create mode 100644 static/js/standterm-ssh-route-editor.js create mode 100644 static/js/standterm-ssh-routes.js create mode 100644 tests/ssh_jump_smoke.py create mode 100644 tests/ssh_routes.test.cjs create mode 100644 tests/ssh_routes_browser_smoke.py create mode 100644 tests/ssh_start_smoke.py diff --git a/README.md b/README.md index 10d5b48..293fcd4 100644 --- a/README.md +++ b/README.md @@ -320,8 +320,8 @@ delete profiles and to clear history. Profiles and history stay in the current browser and never store passwords. For an unknown remote SSH host, Quick Connect shows its SHA256 host-key -fingerprint before authentication. Verify it independently, choose **Trust key**, -then connect again. A changed key shows both saved and received fingerprints and +fingerprint before authentication. Verify it independently and choose **Trust key** +to retry the same connection. A changed key shows both saved and received fingerprints and requires explicit replacement; **Cancel** is the default. **Forget host key...** removes only the host and port currently entered after confirmation. Existing connections remain open. These actions edit the Core execution account's @@ -329,15 +329,56 @@ connections remain open. These actions edit the Core execution account's authentication keys. Special policy records and symlinked files require manual management. The existing localhost key setup behavior is unchanged. -Locations using the same IP and port share one host identity, so switching -between them requires reviewing and replacing the saved key. Saved profile names -do not create separate host identities. +Use **Jump hosts > Edit route...** to assign an optional **Host key alias** when +different locations use the same IP and port. Each alias has its own saved trust +identity; it does not change the network address. A blank alias keeps the normal +host/port identity. Saved profile names do not create separate trust identities. + +### SSH Jump Routes + +Quick Connect supports up to **three jump hosts plus the final target**, equivalent +to an ordered SSH `-J` route. Open **Jump hosts > Edit route...**, name the Entry, +and use **Add jump host first**. The displayed path starts at the Core host and +ends at the actual target. Enter each jump password when connecting, or choose +**Browser key from:** an existing key owner in the route editor. + +Only Entries appear in the picker. Each Entry points to an independently stored +chain of nodes. **Reference route after this node** shares existing nodes; +**Copy route after this node** creates independent nodes. **Only this Entry** +copies the necessary prefix when editing a shared node. **Apply node edits to all +references** deliberately changes shared nodes; the editor lists affected Entries. +Removing a node from an Entry or deleting an Entry does not recursively delete +shared nodes. Browser key ownership is separate: a referenced key must be released +by its other routes before its owner can be deleted or the key rebound. + +Cycles are rejected by node ID, including cycles beyond the supported hop count. +Repeating an IP is allowed. For `A → B → C → B`, the editor offers `A → B` (remove +the loop) and `A → B → C` (stop before the back edge), with each final endpoint +shown explicitly. A repair creates a copy for the current Entry after review; +it never silently changes the target or cuts another Entry's shared links. + +Every hop verifies its host key before authentication. Intermediate servers need +SSH TCP forwarding; they do not need StandTerm. Hostnames after the first hop are +reached from the previous server. There is no direct fallback if a jump fails. +Localhost reached through a jump uses ordinary fingerprint confirmation, not the +Core host's localhost key setup. Terminal, Files, and Agent Tunnel all use the +final SSH transport. **Cancel connection**, closing the tab, closing all tabs, +or disconnecting the initiating browser cancels pending hops. + +Routes and keys are read from one IndexedDB snapshot for each connection attempt. +Concurrent settings saves reject stale revisions. Migration preserves the old +direct-profile record for older Core installations; subsequent route edits use +independent version 2 records. Exports include the nodes reachable from saved +Entries and recent history. Settings imports create fresh Entry/node IDs and +leave imported browser-key authentication unresolved until a key is selected. +Passwords and private keys are never saved or exported. A saved profile can explicitly generate an Ed25519 key with **Use browser key authentication**. The private `CryptoKey` is non-extractable and stays in that browser's IndexedDB. Copy the displayed OpenSSH public key to the remote account's `~/.ssh/authorized_keys`, then select the exact saved profile in Quick -Connect. **Use key** remains optional, even when the profile has a key. Browser +Connect. **Use key** remains optional for a direct target; jump routes select +authentication separately for each node. Browser key authentication is allowed only from loopback or an authorized HTTPS browser. During authentication, Python sends the SSH challenge to the initiating browser @@ -563,7 +604,7 @@ revokes this tunnel's grants and pending input without revoking local agents. Stopping preserves the SSH terminal and Files connection. An interrupted command is never replayed automatically. If SSH is already unreachable, remote temporary files may remain; their tokens are invalid. Reconnecting requires a -new tunnel and Connect Info. ProxyJump is planned separately. +new tunnel and Connect Info. SSH jump routes use the final host as the tunnel carrier. ## Agent And External Agent Mirror diff --git a/app.py b/app.py index db9916e..7b05918 100644 --- a/app.py +++ b/app.py @@ -2077,6 +2077,9 @@ def create(self, session_token, terminal_id, sid, browser_id, browser_key, chall 'timeout_seconds': self._timeout_seconds, 'expires_at': wall_now + self._timeout_seconds, } + for field in ('attempt_id', 'node_id'): + if browser_key.get(field): + payload[field] = browser_key[field] self._requests[request_id] = { 'request': payload, 'session_token': session_token, @@ -2114,6 +2117,9 @@ def resolve(self, session_token, sid, data): or not secrets.compare_digest(actual_value, expected_value) ): return 'ssh_browser_key_sign_stale' + for field in ('attempt_id', 'node_id'): + if expected.get(field) and data.get(field) != expected[field]: + return 'ssh_browser_key_sign_stale' if entry['event'].is_set(): return 'ssh_browser_key_sign_stale' if data.get('status') == 'failed': @@ -2379,6 +2385,7 @@ def request_browser_ssh_signature(bridge, signer_sid, browser_key, challenge, al socket_session_tokens.get(signer_sid) != session_token or not isinstance(browser_id, str) or not signer_allowed + or bridge._connection_cancelled.is_set() ): raise RuntimeError('Browser SSH signer is unavailable.') request_payload, error_code = browser_ssh_sign_request_store.create( @@ -2404,6 +2411,7 @@ def request_browser_ssh_signature(bridge, signer_sid, browser_key, challenge, al or socket_session_tokens.get(signer_sid) != session_token or current_identity.get('browser_id') != browser_id or not signer_still_allowed + or bridge._connection_cancelled.is_set() ): if error_message: raise RuntimeError(f'Browser SSH signing did not complete: {error_message}') @@ -2503,6 +2511,8 @@ def request_browser_ssh_signature(bridge, signer_sid, browser_key, challenge, al bridges = {} pending_terminal_starts = {} +pending_terminal_bridges = {} +pending_terminal_start_context = {} active_sessions = {} socket_session_tokens = {} socket_client_ips = {} @@ -5859,10 +5869,14 @@ def record_agent_terminal_cleanup(session_token, terminal_id, reason): def get_bridge(session_token, terminal_id): return bridges.get(session_token, {}).get(terminal_id) -def begin_terminal_start(session_token, terminal_id): +def begin_terminal_start(session_token, terminal_id, sid=None, attempt_id=None): start_token = secrets.token_urlsafe(18) + key = (session_token, terminal_id) with terminal_start_lock: - pending_terminal_starts[(session_token, terminal_id)] = start_token + previous = pending_terminal_bridges.pop(key, None) + pending_terminal_starts[key] = start_token + pending_terminal_start_context[key] = {'sid': sid, 'attempt_id': attempt_id} + close_bridge(previous) return start_token def is_current_terminal_start(session_token, terminal_id, start_token): @@ -5875,13 +5889,28 @@ def finish_terminal_start(session_token, terminal_id, start_token): if pending_terminal_starts.get(key) != start_token: return False pending_terminal_starts.pop(key, None) + pending_terminal_bridges.pop(key, None) + pending_terminal_start_context.pop(key, None) return True -def cancel_terminal_starts(session_token, terminal_id=None): +def cancel_terminal_starts(session_token, terminal_id=None, *, sid=None, attempt_id=None): + pending = [] with terminal_start_lock: for key in list(pending_terminal_starts): - if key[0] == session_token and (terminal_id is None or key[1] == terminal_id): - pending_terminal_starts.pop(key, None) + context = pending_terminal_start_context.get(key, {}) + if key[0] != session_token or (terminal_id is not None and key[1] != terminal_id): + continue + if sid is not None and context.get('sid') != sid: + continue + if attempt_id is not None and context.get('attempt_id') != attempt_id: + continue + pending_terminal_starts.pop(key, None) + pending_terminal_start_context.pop(key, None) + bridge = pending_terminal_bridges.pop(key, None) + if bridge: + pending.append(bridge) + for bridge in pending: + close_bridge(bridge) def set_bridge(session_token, terminal_id, bridge): bridges.setdefault(session_token, {})[terminal_id] = bridge @@ -6089,7 +6118,7 @@ def log_terminal_input(sid, terminal_id, data): ) def emit_connection_error(sid, message, error_code=None, action_type=None, action_message=None, - action_question=None, action_id=None, terminal_id=TERMINAL_ID_MAIN): + action_question=None, action_id=None, terminal_id=TERMINAL_ID_MAIN, attempt_id=None, route_context=None): socketio.emit( 'ssh_output', { @@ -6101,6 +6130,8 @@ def emit_connection_error(sid, message, error_code=None, action_type=None, actio 'action_message': action_message, 'action_question': action_question, 'action_id': action_id, + 'attempt_id': attempt_id, + 'route_context': route_context, }, room=sid, ) @@ -10146,9 +10177,10 @@ def on_agent_viewport_render_result(data): def start_terminal_backend(sid, session_token, payload, start_token): terminal_id = payload['terminal_id'] - if not is_current_terminal_start(session_token, terminal_id, start_token): - return - pending_backend_actions.discard(sid) + with terminal_start_lock: + if not is_current_terminal_start(session_token, terminal_id, start_token): + return + pending_backend_actions.discard(sid) replacing_existing = get_bridge(session_token, terminal_id) is not None if not replacing_existing and len(bridges.get(session_token, {})) >= MAX_TERMINALS_PER_CLIENT: emit_connection_error( @@ -10180,9 +10212,14 @@ def start_terminal_backend(sid, session_token, payload, start_token): raise TypeError('Backend did not return a terminal bridge.') if bridge.connection_type != connection_type: raise ValueError('Backend returned a bridge with a mismatched connection type.') - if payload.get('browser_key') and isinstance(bridge, SSHBridge): + if isinstance(bridge, SSHBridge): bridge.set_browser_signer_sid(sid) bridge.attach(sid) + with terminal_start_lock: + if not is_current_terminal_start(session_token, terminal_id, start_token): + close_bridge(bridge) + return + pending_terminal_bridges[(session_token, terminal_id)] = bridge success, result = plugin.connect_bridge(bridge, payload, cols, rows) except Exception as exc: log_message(f"[!] Backend start error for {connection_type}: {exc}") @@ -10197,35 +10234,45 @@ def start_terminal_backend(sid, session_token, payload, start_token): return if success: - if ( - socket_session_tokens.get(sid) != session_token - or not finish_terminal_start(session_token, terminal_id, start_token) - ): + with terminal_start_lock: + current = is_current_terminal_start(session_token, terminal_id, start_token) + authorized = socket_session_tokens.get(sid) == session_token + if current: + finish_terminal_start(session_token, terminal_id, start_token) + if current and authorized: + previous = pop_bridge(session_token, terminal_id) + close_terminal_bridge(session_token, terminal_id) + bridge.update_terminal_size(cols, rows) + set_bridge(session_token, terminal_id, bridge) + else: + previous = None + close_bridge(previous) + if not current or not authorized: close_bridge(bridge) return - close_terminal_bridge(session_token, terminal_id) - bridge.update_terminal_size(cols, rows) - set_bridge(session_token, terminal_id, bridge) connected_payload = {'message_type': 'ssh_connected'} + if payload.get('attempt_id'): + connected_payload['attempt_id'] = payload['attempt_id'] connected_payload.update(bridge.metadata()) bridge.emit_output(connected_payload) socketio.start_background_task(target=bridge.read_loop) return - failure = plugin.build_connection_failure(sid, bridge, payload, result) + with terminal_start_lock: + if (not is_current_terminal_start(session_token, terminal_id, start_token) + or socket_session_tokens.get(sid) != session_token): + finish_terminal_start(session_token, terminal_id, start_token) + failure = None + else: + failure = plugin.build_connection_failure(sid, bridge, payload, result) + finish_terminal_start(session_token, terminal_id, start_token) + emit_connection_error( + sid, failure['message'], error_code=failure.get('error_code'), + action_type=failure.get('action_type'), action_message=failure.get('action_message'), + action_question=failure.get('action_question'), action_id=failure.get('action_id'), + terminal_id=terminal_id, attempt_id=payload.get('attempt_id'), + route_context=result.get('route_context') if isinstance(result, dict) else None) close_bridge(bridge) - if not finish_terminal_start(session_token, terminal_id, start_token): - return - emit_connection_error( - sid, - failure['message'], - error_code=failure.get('error_code'), - action_type=failure.get('action_type'), - action_message=failure.get('action_message'), - action_question=failure.get('action_question'), - action_id=failure.get('action_id'), - terminal_id=terminal_id, - ) @socketio.on(SSH_BROWSER_SIGN_RESPONSE_EVENT) @@ -10987,8 +11034,8 @@ def on_start_ssh(data): return browser_ssh_sign_request_store.discard(session_token, terminal_id=payload['terminal_id']) - start_token = begin_terminal_start(session_token, payload['terminal_id']) - if payload.get('browser_key'): + start_token = begin_terminal_start(session_token, payload['terminal_id'], request.sid, payload.get('attempt_id')) + if payload.get('browser_key') or payload.get('route'): socketio.start_background_task(start_terminal_backend, request.sid, session_token, payload, start_token) return start_terminal_backend(request.sid, session_token, payload, start_token) @@ -11055,6 +11102,7 @@ def on_ssh_host_key_action(data): client_ip = socket_client_ips.get(request.sid, 'unknown') browser_authorized = socket_browser_authorized.get(request.sid, False) result = {'status': 'failed', 'message': 'Invalid SSH host key action.', 'error_code': 'ssh_host_key_invalid_action'} + action = None if not is_ssh_allowed_for_client(client_ip, browser_authorized=browser_authorized): result.update(message='SSH access requires a local client or browser authorization.', error_code='ssh_remote_unauthorized') elif data.get('operation') == 'forget': @@ -11064,6 +11112,7 @@ def on_ssh_host_key_action(data): 'terminal_id': terminal_id, 'host': data.get('host'), 'port': data.get('port'), + 'host_key_alias': data.get('host_key_alias'), }, client_ip, browser_authorized=browser_authorized) if error: result['message'] = error.get('message') if isinstance(error, dict) else error @@ -11097,8 +11146,25 @@ def on_ssh_host_key_action(data): socketio.emit('ssh_output', { 'message_type': 'host_key_result', 'terminal_id': terminal_id, 'message': result['message'], 'status': result['status'], 'error_code': result.get('error_code'), + 'action_id': data.get('action_id'), 'operation': data.get('operation'), + 'action_type': action.action_type if action else None, + 'attempt_id': action.metadata.get('attempt_id') if action else None, }, room=request.sid) +@socketio.on('cancel_ssh_start') +def on_cancel_ssh_start(data): + session_token = socket_session_tokens.get(request.sid) + terminal_id = validate_terminal_id_payload(data) + if not session_token or not terminal_id: + return + with terminal_start_lock: + context = pending_terminal_start_context.get((session_token, terminal_id)) + if not context or context.get('sid') != request.sid or context.get('attempt_id') != data.get('attempt_id'): + return + cancel_terminal_starts(session_token, terminal_id, sid=request.sid, attempt_id=data.get('attempt_id')) + browser_ssh_sign_request_store.discard(session_token, terminal_id=terminal_id) + pending_backend_actions.discard(request.sid) + @socketio.on('ssh_input') def on_ssh_input(data): session_token = socket_session_tokens.get(request.sid) @@ -11151,6 +11217,8 @@ def on_close_terminal(data): terminal_id = validate_terminal_id_payload(data) if not session_token or not terminal_id: return + cancel_terminal_starts(session_token, terminal_id, sid=request.sid) + browser_ssh_sign_request_store.discard(session_token, terminal_id=terminal_id, sid=request.sid) bridge = get_allowed_bridge(session_token, terminal_id, request.sid, emit_error=True) if bridge: bridge.emit_output({ @@ -11164,6 +11232,8 @@ def on_close_all_terminals(): session_token = socket_session_tokens.get(request.sid) if not session_token: return + cancel_terminal_starts(session_token, sid=request.sid) + browser_ssh_sign_request_store.discard(session_token, sid=request.sid) session_sids = get_session_sids(session_token) for terminal_id, bridge in list(bridges.get(session_token, {}).items()): if is_terminal_bridge_allowed_for_sid(bridge, request.sid): @@ -11188,6 +11258,7 @@ def on_disconnect(reason=None): socket_settings_admin_grant_ids.pop(request.sid, None) agent_viewer_ids.pop(request.sid, None) if session_token: + cancel_terminal_starts(session_token, sid=request.sid) for tunnel in list(agent_tunnels.values()): if tunnel.sid == request.sid: tunnel.close() diff --git a/scripts/run_smoke_tests.py b/scripts/run_smoke_tests.py index a35a524..e6f85f3 100644 --- a/scripts/run_smoke_tests.py +++ b/scripts/run_smoke_tests.py @@ -25,12 +25,14 @@ 'tests/agent_rsfile_smoke.py', 'tests/static_site_smoke.py', 'tests/terminal_read_smoke.py', + 'tests/ssh_start_smoke.py', 'tests/ime_anchor_browser_smoke.py', ] HEADLESS_SMOKE_TESTS = [ 'tests/access_window_smoke.py', 'tests/terminal_read_smoke.py', + 'tests/ssh_start_smoke.py', 'tests/server_startup_smoke.py', 'tests/external_agent_boundary_smoke.py', 'tests/agent_repl_smoke.py', diff --git a/static/js/standterm-ssh-route-editor.js b/static/js/standterm-ssh-route-editor.js new file mode 100644 index 0000000..eb041eb --- /dev/null +++ b/static/js/standterm-ssh-route-editor.js @@ -0,0 +1,223 @@ +(function() { + 'use strict'; + const routes = window.StandTermSshRoutes; + + routes.edit = function({ state, entryId, target, save, onSaved }) { + const original = routes.clone(state); + let draft = routes.clone(state); + let entry = [...draft.profiles, ...draft.history].find(item => item.id === entryId); + if (!entry) { + const node = { id: routes.id(), endpoint: routes.endpoint(target), nextNodeId: null, + hostKeyAlias: '', authentication: { method: 'password' } }; + entry = { id: routes.id(), name: `${target.username}@${target.host}`, startNodeId: node.id, + sortOrder: draft.profiles.length, keyId: null, keyTarget: null }; + draft.nodes.push(node); + draft.profiles.push(entry); + } + const dialog = document.createElement('dialog'); + dialog.id = 'ssh-route-editor'; + const title = document.createElement('h3'); + title.textContent = 'SSH route'; + const name = document.createElement('input'); + name.value = entry.name || ''; + name.placeholder = 'Entry name'; + name.maxLength = 64; + name.setAttribute('aria-label', 'Entry name'); + const scope = document.createElement('select'); + scope.setAttribute('aria-label', 'Edit scope'); + for (const [value, label] of [['entry', 'Only this Entry'], ['all', 'Apply node edits to all references']]) { + scope.add(new Option(label, value)); + } + const rows = document.createElement('div'); + const status = document.createElement('p'); + status.setAttribute('role', 'status'); + const actions = document.createElement('div'); + actions.className = 'ssh-route-buttons ssh-route-actions'; + const button = (label, callback, parent = actions) => { + const element = document.createElement('button'); + element.type = 'button'; + element.textContent = label; + element.onclick = () => { + try { Promise.resolve(callback()).catch(fail); } catch (err) { fail(err); } + }; + parent.append(element); + return element; + }; + const fail = err => { status.textContent = err.message || 'SSH route could not be saved.'; }; + let controls = []; + const labelPath = path => `Core host → ${path.map(node => `${node.endpoint.username}@${node.endpoint.host}:${node.endpoint.port}`).join(' → ')}`; + + function flush(skipIndex = -1) { + const previousPath = routes.checkedPath(draft, entry); + const updated = controls.map((control, index) => index === skipIndex ? previousPath[index] : control.read()); + updated.forEach((node, index) => { + if (JSON.stringify(node) !== JSON.stringify(previousPath[index])) { + routes.replaceNode(draft, entry, index, node, scope.value); + } + }); + entry.name = name.value.trim(); + if (!entry.name) throw new Error('Entry name is required.'); + routes.project(draft); + } + + function replacePath(path) { + entry.startNodeId = routes.copyPath(draft, path); + routes.project(draft); + render(); + } + + function offerRepair() { + const result = routes.resolve(draft, entry.startNodeId); + if (result.error !== 'cycle') return false; + status.replaceChildren(document.createTextNode( + `${labelPath(result.path)} → ${result.path[result.cycleIndex].endpoint.host}: cycle rejected. Choose an explicit target or cancel.`)); + for (const candidate of routes.repairCandidates(draft, entry)) { + const target = candidate.path.at(-1).endpoint; + button(`${candidate.rule === 'erase-loop' ? 'Remove loop' : 'Stop before back edge'}: ${labelPath(candidate.path)}; target ${target.username}@${target.host}:${target.port}`, () => { + // Keep other Entries and shared nodes exactly as they were before this edit. + const repaired = routes.clone(original); + let selected = [...repaired.profiles, ...repaired.history].find(item => item.id === entry.id); + if (!selected) { selected = { ...entry }; repaired.profiles.push(selected); } + selected.name = name.value.trim(); + selected.startNodeId = routes.copyPath(repaired, candidate.path); + draft = routes.project(repaired); + entry = selected; + routes.validate(draft); + status.textContent = 'Repair selected. Review the target, then Save route.'; + render(); + }, status); + } + return true; + } + + function render() { + controls = []; + rows.replaceChildren(); + const result = routes.resolve(draft, entry.startNodeId); + const pathText = document.createElement('p'); + pathText.textContent = labelPath(result.path); + rows.append(pathText); + result.path.forEach((node, index) => { + const fieldset = document.createElement('fieldset'); + const legend = document.createElement('legend'); + legend.textContent = index === result.path.length - 1 ? 'Target' : `Jump host ${index + 1}`; + fieldset.append(legend); + const fields = document.createElement('div'); + fields.className = 'ssh-route-fields'; + fieldset.append(fields); + const input = (label, value) => { + const wrapper = document.createElement('label'); + wrapper.textContent = label; + const field = document.createElement('input'); + field.value = value; + field.setAttribute('aria-label', `${legend.textContent} ${label}`); + wrapper.append(field); + fields.append(wrapper); + return field; + }; + const host = input('Host', node.endpoint.host); + const port = input('Port', node.endpoint.port); + const username = input('Username', node.endpoint.username); + const alias = input('Host key alias (optional)', node.hostKeyAlias); + const auth = document.createElement('select'); + auth.setAttribute('aria-label', `${legend.textContent} Authentication`); + auth.add(new Option('Password (entered when connecting)', 'password')); + const keyRefs = new Map(); + draft.profiles.filter(owner => owner.keyId).forEach(owner => { + keyRefs.set(owner.id, { ownerProfileId: owner.id, keyId: owner.keyId, + targetKey: routes.endpointKey(owner.keyTarget || owner) }); + auth.add(new Option(`Browser key from: ${owner.name}`, owner.id)); + }); + const keyRef = node.authentication.keyRef; + if (node.authentication.method === 'browser-key') { + if (keyRef && JSON.stringify(keyRefs.get(keyRef.ownerProfileId)) === JSON.stringify(keyRef)) auth.value = keyRef.ownerProfileId; + else { auth.add(new Option('Browser key unavailable — choose authentication', 'unresolved')); auth.value = 'unresolved'; } + } + const authLabel = document.createElement('label'); + authLabel.textContent = 'Authentication'; + authLabel.append(auth); + fields.append(authLabel); + const affected = document.createElement('p'); + affected.textContent = `Referenced by: ${routes.references(draft, node.id).map(item => item.name || item.host).join(', ')}`; + fieldset.append(affected); + controls.push({ read: () => routes.publicNode({ ...node, + endpoint: { host: host.value, port: port.value, username: username.value }, + hostKeyAlias: alias.value.trim(), authentication: auth.value === 'password' + ? { method: 'password' } : { method: 'browser-key', keyRef: keyRefs.get(auth.value) || null } + }) }); + const otherEntries = document.createElement('select'); + otherEntries.setAttribute('aria-label', `${legend.textContent} Next route`); + otherEntries.add(new Option('Choose the next route...', '')); + draft.profiles.forEach(item => otherEntries.add(new Option(item.name, item.id))); + fieldset.append(otherEntries); + const nodeButtons = document.createElement('div'); + nodeButtons.className = 'ssh-route-buttons'; + fieldset.append(nodeButtons); + for (const copy of [false, true]) { + button(copy ? 'Copy route after this node' : 'Reference route after this node', () => { + const selected = draft.profiles.find(item => item.id === otherEntries.value); + if (!selected) throw new Error('Choose a route to append.'); + flush(); + const previous = routes.clone(draft); + try { + const nextId = copy ? routes.copyPath(draft, routes.checkedPath(draft, selected)) : selected.startNodeId; + const current = routes.checkedPath(draft, entry); + routes.replaceNode(draft, entry, index, { ...current[index], nextNodeId: nextId }, scope.value); + if (!offerRepair()) { routes.validate(draft); routes.project(draft); render(); } + } catch (err) { + draft = previous; + entry = [...draft.profiles, ...draft.history].find(item => item.id === entry.id); + render(); + throw err; + } + }, nodeButtons); + } + if (index > 0) button('Move earlier', () => { + flush(); + const path = routes.checkedPath(draft, entry); + [path[index - 1], path[index]] = [path[index], path[index - 1]]; + replacePath(path); + }, nodeButtons); + if (result.path.length > 1) button('Remove from this Entry', () => { + flush(index); + replacePath(routes.checkedPath(draft, entry).filter((_, i) => i !== index)); + }, nodeButtons); + rows.append(fieldset); + }); + } + + button('Add jump host first', () => { + flush(); + const path = routes.checkedPath(draft, entry); + if (path.length >= routes.MAX_JUMPS + 1) throw new Error(`Use at most ${routes.MAX_JUMPS} jump hosts.`); + const node = { id: routes.id(), endpoint: { host: '', port: '22', username: target.username }, + hostKeyAlias: '', nextNodeId: entry.startNodeId, authentication: { method: 'password' } }; + draft.nodes.push(node); + entry.startNodeId = node.id; + render(); + }); + const saveButton = button('Save route', async () => { + if (offerRepair()) return; + flush(); + routes.validate(draft); + const result = await save(draft); + dialog.close(); + onSaved(result, entry.id); + }); + saveButton.className = 'primary'; + button('Cancel', () => dialog.close()); + const heading = document.createElement('div'); + heading.className = 'ssh-route-heading'; + for (const [text, field] of [['Entry name', name], ['Edit scope', scope]]) { + const label = document.createElement('label'); + label.textContent = text; + label.append(field); + heading.append(label); + } + dialog.append(title, heading, rows, status, actions); + dialog.addEventListener('close', () => dialog.remove()); + document.body.append(dialog); + render(); + dialog.showModal(); + }; +})(); diff --git a/static/js/standterm-ssh-routes.js b/static/js/standterm-ssh-routes.js new file mode 100644 index 0000000..6d83e12 --- /dev/null +++ b/static/js/standterm-ssh-routes.js @@ -0,0 +1,322 @@ +(function(root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) module.exports = api; + else root.StandTermSshRoutes = api; +})(typeof globalThis !== 'undefined' ? globalThis : this, function() { + 'use strict'; + + const MAX_JUMPS = 3; + const PREFIX = 'routes-v2:'; + const META = `${PREFIX}catalog`; + const ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; + const clone = value => JSON.parse(JSON.stringify(value)); + const id = () => `node-${typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}`; + + function endpoint(value) { + const port = Number(value.port); + const host = typeof value.host === 'string' ? value.host.trim() : ''; + const username = typeof value.username === 'string' ? value.username.trim() : ''; + if (!host || host.length > 255 || /[\s,|*?!#@\x00-\x1f\x7f]/.test(host) + || !username || username.length > 128 || /[\x00-\x1f\x7f]/.test(username) + || !Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error('Each SSH node needs a valid host, port and username.'); + } + return { host, port: String(port), username }; + } + + function endpointKey(value) { + return [value.host.toLowerCase(), String(value.port), value.username].join('\u0000'); + } + + function publicNode(value) { + if (!value || !ID_PATTERN.test(value.id || '') + || (value.nextNodeId !== null && !ID_PATTERN.test(value.nextNodeId || ''))) { + throw new Error('Invalid SSH node reference.'); + } + const alias = value.hostKeyAlias || ''; + if (typeof alias !== 'string' || alias.length > 255 || /[\s,|*?!#@\x00-\x1f\x7f]/.test(alias)) { + throw new Error('Invalid SSH host key alias.'); + } + const auth = value.authentication || { method: 'password' }; + if (!['password', 'browser-key'].includes(auth.method)) throw new Error('Invalid SSH authentication method.'); + const authentication = { method: auth.method }; + if (auth.method === 'browser-key') { + const ref = auth.keyRef; + if (ref && (!ID_PATTERN.test(ref.ownerProfileId || '') || !ID_PATTERN.test(ref.keyId || '') + || ref.targetKey !== endpointKey(endpoint(value.endpoint)))) { + throw new Error('Choose a browser key bound to this node endpoint, or choose password authentication.'); + } + authentication.keyRef = ref ? { ownerProfileId: ref.ownerProfileId, keyId: ref.keyId, targetKey: ref.targetKey } : null; + } + return { id: value.id, endpoint: endpoint(value.endpoint), authentication, + hostKeyAlias: alias, nextNodeId: value.nextNodeId }; + } + + function resolve(state, startNodeId) { + const nodes = new Map(state.nodes.map(node => [node.id, node])); + const seen = new Map(); + const path = []; + let next = startNodeId; + while (next !== null) { + if (seen.has(next)) return { path, error: 'cycle', cycleIndex: seen.get(next), repeatedId: next }; + const node = nodes.get(next); + if (!node) return { path, error: 'missing', missingId: next }; + seen.set(next, path.length); + path.push(node); + next = node.nextNodeId; + } + return { path, error: path.length > MAX_JUMPS + 1 ? 'depth' : path.length ? null : 'empty' }; + } + + function checkedPath(state, entry) { + const result = resolve(state, entry.startNodeId); + if (result.error) { + const error = new Error(result.error === 'depth' + ? `Use at most ${MAX_JUMPS} jump hosts plus the target.` + : `SSH route is invalid: ${result.error}.`); + error.code = result.error; + error.route = result; + throw error; + } + return result.path; + } + + function routeKey(path) { + return JSON.stringify(path.map(node => [node.endpoint.host.toLowerCase(), String(node.endpoint.port), + node.endpoint.username, node.hostKeyAlias || ''])); + } + + function references(state, nodeId) { + return [...state.profiles, ...state.history].filter(entry => + resolve(state, entry.startNodeId).path.some(node => node.id === nodeId)); + } + + function copyPath(state, path) { + const copies = path.map(node => ({ ...clone(node), id: id() })); + copies.forEach((node, index) => { node.nextNodeId = copies[index + 1]?.id || null; }); + state.nodes.push(...copies); + return copies[0]?.id || null; + } + + function replaceNode(state, entry, index, replacement, scope = 'entry') { + const path = checkedPath(state, entry); + if (!path[index]) throw new Error('SSH node no longer exists.'); + const updated = publicNode({ ...replacement, id: path[index].id }); + if (scope === 'all') { + state.nodes[state.nodes.findIndex(node => node.id === updated.id)] = updated; + } else { + const prefix = path.slice(0, index + 1).map(node => ({ ...clone(node), id: id() })); + prefix[index] = { ...updated, id: prefix[index].id }; + prefix.slice(0, -1).forEach((node, i) => { node.nextNodeId = prefix[i + 1].id; }); + state.nodes.push(...prefix); + entry.startNodeId = prefix[0].id; + } + } + + function repairCandidates(state, entry) { + const result = resolve(state, entry.startNodeId); + if (result.error !== 'cycle') return []; + return [ + { rule: 'erase-loop', path: result.path.slice(0, result.cycleIndex + 1) }, + { rule: 'before-back-edge', path: result.path } + ].filter(candidate => candidate.path.length <= MAX_JUMPS + 1) + .map(candidate => ({ ...candidate, revision: state.revision, entryId: entry.id })); + } + + function applyRepair(state, entry, preview) { + if (state.revision !== preview.revision || entry.id !== preview.entryId) throw new Error('Reload the changed SSH route before repairing it.'); + const candidate = repairCandidates(state, entry).find(item => item.rule === preview.rule); + if (!candidate || JSON.stringify(candidate) !== JSON.stringify(preview)) throw new Error('SSH repair preview is stale.'); + entry.startNodeId = copyPath(state, candidate.path); + checkedPath(state, entry); + } + + function validate(state) { + const seen = new Set(); + state.nodes.forEach(node => { + publicNode(node); + if (seen.has(node.id)) throw new Error('Duplicate SSH node id.'); + seen.add(node.id); + }); + // Unreachable nodes remain independently stored. Entry resolution always checks the full tail. + state.nodes.forEach(node => { + if (node.nextNodeId !== null && !seen.has(node.nextNodeId)) throw new Error('SSH node references a missing node.'); + }); + const entries = new Set(); + [...state.profiles, ...state.history].forEach(entry => { + if (!ID_PATTERN.test(entry.id || '') || entries.has(entry.id)) throw new Error('Invalid or duplicate SSH entry id.'); + entries.add(entry.id); + checkedPath(state, entry).forEach(node => { + const ref = node.authentication.keyRef; + if (!ref) return; + const owner = state.profiles.find(profile => profile.id === ref.ownerProfileId); + if (!owner || owner.keyId !== ref.keyId || endpointKey(owner.keyTarget || owner) !== ref.targetKey) { + throw new Error('An SSH route references an unavailable browser key. Edit its authentication before saving.'); + } + }); + }); + return state; + } + + function project(state) { + [...state.profiles, ...state.history].forEach(entry => { + const result = resolve(state, entry.startNodeId); + const target = result.path.at(-1); + if (target) Object.assign(entry, target.endpoint); + }); + return state; + } + + function migrate(legacy) { + const state = { version: 2, revision: 0, profiles: [], history: [], nodes: [] }; + for (const kind of ['profiles', 'history']) { + for (const old of legacy[kind] || []) { + const target = endpoint(old); + const node = { id: id(), endpoint: target, hostKeyAlias: '', nextNodeId: null, + authentication: old.keyId ? { method: 'browser-key', keyRef: { + ownerProfileId: old.id, keyId: old.keyId, targetKey: endpointKey(target) + } } : { method: 'password' } }; + state.nodes.push(node); + state[kind].push({ ...old, startNodeId: node.id, keyTarget: old.keyId ? target : null }); + } + } + return state; + } + + function sanitize(value) { + const entry = item => ({ + id: item.id, name: String(item.name || '').slice(0, 64), startNodeId: item.startNodeId, + sortOrder: Number(item.sortOrder) || 0, lastUsedAt: String(item.lastUsedAt || ''), + keyId: typeof item.keyId === 'string' ? item.keyId : null, + keyTarget: item.keyTarget ? endpoint(item.keyTarget) : null + }); + return project({ version: 2, revision: value.revision, nodes: value.nodes.map(publicNode), + profiles: value.profiles.map(entry), history: value.history.map(entry) }); + } + + function syncLegacyEdits(value) { + const state = clone(value); + for (const entry of [...state.profiles, ...state.history]) { + if (!entry.startNodeId) { + const node = { id: id(), endpoint: endpoint(entry), hostKeyAlias: '', + nextNodeId: null, authentication: { method: 'password' } }; + if (entry.keyId) { + entry.keyTarget = node.endpoint; + node.authentication = { method: 'browser-key', keyRef: { + ownerProfileId: entry.id, keyId: entry.keyId, targetKey: endpointKey(node.endpoint) + } }; + } + state.nodes.push(node); + entry.startNodeId = node.id; + } else { + const path = checkedPath(state, entry); + const target = path.at(-1); + if (endpointKey(entry) !== endpointKey(target.endpoint)) { + replaceNode(state, entry, path.length - 1, { ...target, endpoint: endpoint(entry) }); + } + } + } + return sanitize(state); + } + + function exportState(state) { + const result = sanitize(state); + const reachable = new Set([...result.profiles, ...result.history].flatMap(entry => + resolve(result, entry.startNodeId).path.map(node => node.id))); + result.nodes = result.nodes.filter(node => reachable.has(node.id)); + result.profiles.forEach(entry => { delete entry.keyId; delete entry.keyTarget; }); + [...result.profiles, ...result.history].forEach(entry => { + delete entry.host; + delete entry.port; + delete entry.username; + }); + result.nodes.forEach(node => { if (node.authentication.method === 'browser-key') node.authentication.keyRef = null; }); + return result; + } + + function importState(current, incoming) { + const source = exportState(incoming); + validate(source); + const mapping = new Map(source.nodes.map(node => [node.id, id()])); + const result = clone(current); + source.nodes.forEach(node => result.nodes.push({ ...node, id: mapping.get(node.id), + nextNodeId: node.nextNodeId === null ? null : mapping.get(node.nextNodeId) })); + for (const kind of ['profiles', 'history']) { + source[kind].forEach(entry => result[kind].push({ ...entry, id: id(), startNodeId: mapping.get(entry.startNodeId) })); + } + return validate(project(result)); + } + + async function load(openDb, normalizeLegacy, includeKeys = false) { + const db = await openDb(); + return new Promise((resolvePromise, reject) => { + const tx = db.transaction(includeKeys ? ['state', 'keys'] : ['state'], 'readonly'); + const store = tx.objectStore('state'); + const keys = store.getAllKeys(); + const values = store.getAll(); + const keyRecords = includeKeys ? tx.objectStore('keys').getAll() : null; + tx.oncomplete = () => { + db.close(); + try { + const records = new Map(keys.result.map((key, i) => [key, values.result[i]])); + const meta = records.get(META); + const finish = state => resolvePromise(includeKeys ? { state, keys: keyRecords.result } : state); + if (!meta) { finish(migrate(normalizeLegacy(records.get('current')))); return; } + const result = { version: 2, revision: meta.revision, + profiles: meta.profiles.map(key => records.get(`${PREFIX}entry:${key}`)), + history: meta.history.map(key => records.get(`${PREFIX}entry:${key}`)), + nodes: meta.nodes.map(key => records.get(`${PREFIX}node:${key}`)) }; + finish(sanitize(result)); + } catch (err) { reject(err); } + }; + tx.onabort = tx.onerror = () => { db.close(); reject(tx.error || new Error('SSH routes could not be loaded.')); }; + }); + } + + async function save(openDb, value, keyChanges = []) { + const state = validate(sanitize(value)); + const db = await openDb(); + return new Promise((resolvePromise, reject) => { + const tx = db.transaction(['state', 'keys'], 'readwrite'); + const store = tx.objectStore('state'); + let failure; + const request = store.get(META); + request.onsuccess = () => { + const previous = request.result; + if ((previous?.revision || 0) !== state.revision) { + failure = new Error('SSH settings changed in another window. Reload before saving.'); + failure.code = 'stale'; + tx.abort(); + return; + } + // Validate and publish the graph and key changes in this transaction. + const entryIds = new Set([...state.profiles, ...state.history].map(entry => entry.id)); + for (const key of [...(previous?.profiles || []), ...(previous?.history || [])]) { + if (!entryIds.has(key)) store.delete(`${PREFIX}entry:${key}`); + } + state.revision += 1; + for (const item of [...state.profiles, ...state.history]) { + const record = { ...item }; + delete record.host; + delete record.port; + delete record.username; + store.put(record, `${PREFIX}entry:${item.id}`); + } + for (const item of state.nodes) store.put(item, `${PREFIX}node:${item.id}`); + store.put({ revision: state.revision, profiles: state.profiles.map(item => item.id), + history: state.history.map(item => item.id), nodes: state.nodes.map(item => item.id) }, META); + const keyStore = tx.objectStore('keys'); + keyChanges.forEach(change => { + if (change.type === 'put') keyStore.put(change.record); + if (change.type === 'delete') keyStore.delete(change.keyId); + }); + }; + tx.oncomplete = () => { db.close(); resolvePromise(state); }; + tx.onabort = tx.onerror = () => { db.close(); reject(failure || tx.error || new Error('SSH route save was aborted.')); }; + }); + } + + return { MAX_JUMPS, id, clone, endpoint, endpointKey, publicNode, resolve, checkedPath, routeKey, + references, copyPath, replaceNode, repairCandidates, applyRepair, validate, project, + migrate, sanitize, syncLegacyEdits, exportState, importState, load, save }; +}); diff --git a/templates/index.html b/templates/index.html index 43a3d31..9cf2e79 100644 --- a/templates/index.html +++ b/templates/index.html @@ -367,6 +367,31 @@ .ssh-profile-actions button.primary { background: #0a84ff; } .ssh-profile-actions button.danger { background: #7d302b; } .ssh-profile-actions button:disabled { color: #777; cursor: default; opacity: 0.7; } + #ssh-route-editor { background: #252526; color: #ddd; border: 1px solid #555; border-radius: 8px; padding: 18px; width: min(780px, 90vw); max-height: 85vh; overflow: auto; font-size: 13px; } + #ssh-route-editor::backdrop { background: rgba(0, 0, 0, 0.55); } + #ssh-route-editor h3 { margin: 0 0 14px; color: #64a9ff; } + #ssh-route-editor label { display: grid; gap: 5px; color: #aaa; font-size: 12px; } + #ssh-route-editor input, #ssh-route-editor select { width: 100%; min-width: 0; box-sizing: border-box; padding: 7px 8px; background: #2c2c2e; color: #fff; border: 1px solid #555; border-radius: 4px; font-size: 12px; } + #ssh-route-editor input:focus, #ssh-route-editor select:focus { outline: 1px solid #0a84ff; } + #ssh-route-editor fieldset { margin: 14px 0; padding: 12px; border: 1px solid #555; border-radius: 6px; } + #ssh-route-editor legend { color: #64a9ff; padding: 0 5px; } + #ssh-route-editor p { overflow-wrap: anywhere; line-height: 1.5; } + .ssh-route-heading, .ssh-route-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 14px; } + .ssh-route-fields { grid-template-columns: minmax(0, 2fr) minmax(65px, 0.7fr) minmax(0, 1.3fr); } + .ssh-route-fields label:nth-child(4) { grid-column: span 2; } + .ssh-route-buttons { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; } + #ssh-route-editor button { padding: 7px 10px; border: 1px solid #555; border-radius: 4px; background: #3a3a3c; color: #fff; cursor: pointer; font-size: 12px; } + #ssh-route-editor button:hover { background: #48484a; } + #ssh-route-editor .primary { background: #0a84ff; border-color: #0a84ff; } + .ssh-route-actions { position: sticky; bottom: -18px; background: #252526; padding: 12px 0; } + #ssh-jump-details { margin: 10px 0; font-size: 12px; } + #ssh-jump-summary { cursor: pointer; color: #64a9ff; } + #ssh-route-path { padding: 8px 0; overflow-wrap: anywhere; line-height: 1.5; } + #ssh-hop-passwords label { display: block; margin: 8px 0; color: #bbb; } + @media (max-width: 640px) { + .ssh-route-heading, .ssh-route-fields { grid-template-columns: 1fr; } + .ssh-route-fields label:nth-child(4) { grid-column: auto; } + } .ssh-key-controls { display: grid; gap: 8px; padding: 9px; border: 1px solid #3a3a3c; border-radius: 6px; background: #171717; } .ssh-key-controls > label { display: flex; grid-template-columns: none; align-items: center; gap: 8px; color: #ddd; } .ssh-key-controls input[type="checkbox"] { width: auto; } @@ -1102,6 +1127,13 @@

StandTerm

+
+ Jump hosts · Direct +
+
+ +
+
@@ -1321,7 +1353,10 @@

Recover StandTerm session

+ + + + +