diff --git a/README.md b/README.md index 10d5b48..558cd29 100644 --- a/README.md +++ b/README.md @@ -9,34 +9,38 @@ to the StandTerm server process across page reloads. [Download and install StandTerm Desktop](#desktop-downloads-evaluation), or use the [browser-based Core quick start](#quick-start). -**Core 2.12.1** is a [source release](https://github.com/askac/standterm/releases/tag/v2.12.1). -It fixes Windows local shell startup when an executable path contains spaces. -Core 2.12 adds SSH Agent Tunnel with shared skills and helpers, Agent Panel permission -sync, Agent Info for the current tab, and SSH host fingerprint management. +**Core 2.13.0** is a [source release](https://github.com/askac/standterm/releases/tag/v2.13.0). +It adds SSH routes with up to three jump hosts, ordered node editing and per-site +login cards. Direct connections and route nodes share browser-key controls; +new keys stay temporary unless saving is selected when connecting. Existing +SSH Agent Tunnel, current-tab Agent Info and host fingerprint controls remain available. IME input-line anchoring remains an [experimental PoC](docs/ime_anchor_poc.md). -The separate Desktop 0.5.0 evaluation below bundles the formal Core 2.12.1 source. +The published Desktop 0.4.3 evaluation below predates these Core features; +newer Desktop installers are still local evaluation candidates. ![StandTerm Desktop with terminal rendering tests, local and SSH tabs, and a floating PowerShell terminal](standterm_desktop.png) -*Desktop preview. See the release notes for the current controls and validation.* +*Desktop development preview. The controls shown are newer than the published +Desktop 0.4.3 installers.* ## Desktop Downloads (Evaluation) -[StandTerm Desktop 0.5.0 / Core 2.12.1](https://github.com/askac/standterm/releases/tag/desktop-v0.5.0-2.12.1) +[StandTerm Desktop 0.4.3](https://github.com/askac/standterm/releases/tag/desktop-v0.4.3) is available as an evaluation pre-release, not a production-qualified release. | Platform | Download | Required before installation | | --- | --- | --- | -| Windows x64, including Windows + WSL | [Windows installer (.exe)](https://github.com/askac/standterm/releases/download/desktop-v0.5.0-2.12.1/StandTerm-Desktop-0.5.0-2.12.1-win32-x64-Setup.exe) | Python 3.10+ with venv/ensurepip in each selected environment; WSL mode also needs an existing WSL distribution. | -| macOS Apple Silicon | [macOS installer (.dmg)](https://github.com/askac/standterm/releases/download/desktop-v0.5.0-2.12.1/StandTerm-Desktop-0.5.0-2.12.1-mac-arm64.dmg) | Native arm64 Python 3.10+ with venv/ensurepip. Intel/Rosetta is not qualified. | +| Windows x64, including Windows + WSL | [Windows installer (.exe)](https://github.com/askac/standterm/releases/download/desktop-v0.4.3/StandTerm-Desktop-0.4.3-win32-x64-Setup.exe) | Python 3.10+ with venv/ensurepip in each selected environment; WSL mode also needs an existing WSL distribution. | +| macOS Apple Silicon | [macOS installer (.dmg)](https://github.com/askac/standterm/releases/download/desktop-v0.4.3/StandTerm-Desktop-0.4.3-mac-arm64.dmg) | Native arm64 Python 3.10+ with venv/ensurepip. Intel/Rosetta is not qualified. | Packages include Electron and Core. **Git, Node.js and npm are not required**; Python and its virtual environment are not bundled. -The optional advanced Git Core source requires Git in the selected backend -environment. Bundled Core recovery remains available without Git. +Newer Desktop source adds an optional advanced Git Core source, which requires +Git in the selected backend environment, plus bundled Core recovery without Git. +Those controls are not included in the published 0.4.3 installers. 1. Download the package for your platform and check the release's - [SHA256SUMS](https://github.com/askac/standterm/releases/download/desktop-v0.5.0-2.12.1/SHA256SUMS). + [SHA256SUMS](https://github.com/askac/standterm/releases/download/desktop-v0.4.3/SHA256SUMS). 2. On Windows, run the installer and choose **Windows only**, **Windows + WSL** or **WSL only**. Native Windows mode needs 64-bit Windows Python; installing Windows Python does not satisfy WSL mode. On macOS, copy the app to a @@ -317,34 +321,131 @@ STANDTERM_HOST=127.0.0.1 STANDTERM_PORT=5000 ./run.sh Quick Connect can load saved SSH profiles and the six most recent successful SSH targets. Use **Settings > SSH Sessions** to create, update, reorder, or 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 -requires explicit replacement; **Cancel** is the default. **Forget host key...** -removes only the host and port currently entered after confirmation. Existing +browser and never store passwords. In Direct connect, **Save session** saves the +profile and its referenced browser key when **Connect** is pressed, before SSH +starts. A failed connection does not undo that explicit save. History records +only successful connections and does not implicitly save a profile or private key. + +For an unknown remote SSH host, its login card shows the SHA256 host-key +fingerprint before authentication. Verify it independently and choose **Trust and continue** +to continue on that connection. A changed key shows both saved and received fingerprints and +requires explicit replacement; **Cancel** is the default. Open **Host identity** +in Direct connect or a route node to inspect its saved fingerprint. **Forget saved +fingerprint…** removes that identity after an inline confirmation that defaults +to **Keep fingerprint**. This takes effect immediately, even if route editing is +later cancelled. Existing connections remain open. These actions edit the Core execution account's `~/.ssh/known_hosts`, shared with other SSH clients; they do not delete browser 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 **Host identity** 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 has two exclusive panes: **Direct connect** keeps the usual host, +port, username, optional password, and direct History & Profiles picker; +**Saved routes** selects a saved path and shows its jump count and full endpoint +sequence. Expanding one collapses the other. Each keeps its own selection and +draft. The Connect button uses the expanded pane; an empty or invalid route +cannot connect. Completing an edited route selects Saved routes without replacing +the Direct connect fields. + +StandTerm supports up to **three jump hosts plus the final target**, equivalent +to an ordered SSH `-J` route. Open **Saved routes > Edit route...** to start with a +Target card. Existing routes show compact summaries; click a summary to edit one +node at a time. **Add jump node** inserts and opens a card immediately before the +Target. Cards connect from **Core** top to bottom; drag a card's handle or use the +**↑** / **↓** buttons to change the order. All cards can move, and the last card always +becomes the **Target**. Add and arrange cards before filling them in; **Done** +checks the completed route for invalid fields, cycles and the jump limit, opening +the card that needs correction. An Entry +name is optional and defaults to the final username and host. Each node has +an always-visible **Use key** checkbox, a single-line read-only public-key field, +and a Copy button. Direct connect uses the same controls: selecting Use key dims +and disables its password field. Turning it off dims the public-key field while +keeping the public key available to copy. + +An exact saved key reference takes precedence; otherwise a single compatible key +for the host, port and username is displayed automatically. Multiple candidates +require a choice. Selecting **Use key** with no compatible key creates a temporary +Ed25519 key. Its public key can be copied immediately to `authorized_keys`. A +compatible saved key does not prove that the remote account has installed it; +server fingerprints are still checked separately during SSH login. + +The editor ends with **Save route**, **Cancel**, and **Done**. Done retains only +the connection draft. **Connect** saves the route and referenced temporary keys +together only when Save route is selected; otherwise they remain temporary. +Cancelling an editor discards changes made since opening it. Temporary private +keys never enter IndexedDB and are lost when their browser context is discarded. +The **Temporary key** label makes this visible before copying a public key. + +After **Connect**, the connection form becomes a per-site login view with a +**Core → Node 1 → … → Target** progress line. Completed nodes turn green and +collapse; the current node expands when it needs a password or host-key confirmation, +while later nodes stay collapsed. Route passwords are entered here; Direct +connect also accepts an optional password before starting. A rejected password can be retried at the same +node without reconnecting completed jump hosts. The Target shows **OK** only +after its terminal opens. **Cancel connection** closes the entire pending route; +switching tabs preserves its progress without moving focus to a background prompt. +Passwords are never saved in profiles or history. + +Only Entries appear in the picker. Each Entry points to an independently stored +chain of nodes. Under **Advanced node settings**, **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**, under **Advanced sharing**, deliberately changes shared nodes; +the editor lists affected Entries. Reordering or removing cards changes only the +current Entry, even when node edits apply to all references. +Removing a node from an Entry or deleting an Entry does not recursively delete +shared nodes. Node credentials keep stable identities across copying and +reordering. Turning Use key off removes its authentication reference but retains +the credential for reuse at the same endpoint. Older profile-owned keys remain +supported: other routes must release their references before the owning profile +can be deleted or its 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 are never saved. Private keys never enter portable route data or exports; +saved non-extractable CryptoKeys live only in the separate browser key store. +History retains temporary-key authentication as unresolved, without persisting +the temporary key ID. History writes wait for pending SSH attempts to finish so +they do not invalidate another tab's signing request. 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 +account's `~/.ssh/authorized_keys`, then select the exact saved profile in Direct +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 and receives only its Ed25519 signature; the private key is never sent to the StandTerm Python process. A changed host, port, or username disables the profile -key binding. Deleting or unlinking a keyed profile permanently deletes that -browser key. These keys are protected from export, but they are not hardware +key binding. Deleting or unlinking a legacy keyed profile permanently deletes its +profile-owned key; independently created node credentials are retained. These keys are protected from export, but they are not hardware keys: script running in the same browser origin could still request signatures. ### Why This SSH Architecture Matters @@ -352,8 +453,8 @@ keys: script running in the same browser origin could still request signatures. | Design choice | Practical advantage | | --- | --- | | Non-extractable browser-owned private key | Private key bytes do not cross the browser boundary or enter Python memory, configuration files, settings exports, or terminal payloads. | -| Explicit key creation and per-connection **Use key** control | A key exists only after the user opts in for a saved profile, and password or host-side authentication remains available when key use is off. | -| Typed, short-lived signing requests | Each request is bound to the initiating browser connection, terminal, profile, key, public-key fingerprint, and challenge hash. Expired, replayed, stale, or mismatched responses fail closed. | +| Explicit key creation and per-node authentication | Users create keys in a node or saved profile; each node chooses its authentication, while Direct connect keeps an optional **Use key** control. | +| Typed, short-lived signing requests | Each request is bound to the initiating browser connection, terminal, attempt, node, credential or profile, key, public-key fingerprint, and challenge hash. Expired, replayed, stale, or mismatched responses fail closed. | | Exact host, port, and username binding | Editing a Quick Connect target cannot silently reuse a profile key for another SSH account or endpoint. | | Standard OpenSSH Ed25519 public key | The remote host only needs the copied key in `authorized_keys`; it does not need StandTerm, a browser component, or an agent. | | Separate settings and key stores | Profiles, history, and browser preferences remain portable while private keys and key identifiers stay local to the browser that created them. | @@ -361,9 +462,9 @@ keys: script running in the same browser origin could still request signatures. The signing path keeps authentication authority narrow. Paramiko passes an SSH challenge to StandTerm's browser-key adapter. StandTerm emits a structured request only to the browser connection that started that terminal. The browser -validates the active connection and exact profile binding before signing, then +validates the active connection and exact credential or profile binding before signing, then returns a 64-byte Ed25519 signature. Python verifies that signature against the -profile's public key before returning it to Paramiko. The browser uses a bounded +key's public key before returning it to Paramiko. The browser uses a bounded relative signing window, while Python enforces the authoritative monotonic deadline, so Windows/WSL wall-clock skew cannot invalidate a fresh request. A browser disconnect, timeout, changed connection draft, or stale terminal start @@ -563,7 +664,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..b926f8b 100644 --- a/app.py +++ b/app.py @@ -78,6 +78,7 @@ UARTBridge, ) from runtime_logging import log_message +from terminal_backends.ssh import SSH_BROWSER_KEY_ID_PATTERN, SSH_BROWSER_KEY_ID_MAX_LENGTH, SSH_LOGIN_MAX_PASSWORD_BYTES from session_recovery import ( SessionRecoveryCredentialStore, SessionRecoveryError, @@ -177,7 +178,7 @@ def parse_positive_int_env(name, default): SSH_TERM = 'xterm-256color' MAX_SSH_INPUT_BYTES = 65536 -MAX_PASSWORD_BYTES = 4096 +MAX_PASSWORD_BYTES = SSH_LOGIN_MAX_PASSWORD_BYTES MAX_HOST_LENGTH = 255 MAX_USERNAME_LENGTH = 128 SESSION_COOKIE_NAME = 'standterm_session' @@ -2068,7 +2069,7 @@ def create(self, session_token, terminal_id, sid, browser_id, browser_key, chall payload = { 'request_id': request_id, 'terminal_id': terminal_id, - 'profile_id': browser_key['profile_id'], + **{field: browser_key[field] for field in ('profile_id', 'credential_id') if field in browser_key}, 'key_id': browser_key['key_id'], 'public_key_fingerprint': browser_key['fingerprint'], 'algorithm': algorithm, @@ -2077,6 +2078,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, @@ -2105,7 +2109,11 @@ def resolve(self, session_token, sid, data): expected = entry['request'] if entry.get('session_token') != session_token or entry.get('sid') != sid: return 'ssh_browser_key_sign_stale' - for field_name in ('terminal_id', 'profile_id', 'key_id', 'challenge_sha256'): + owner_field = 'credential_id' if 'credential_id' in expected else 'profile_id' + other_owner = 'profile_id' if owner_field == 'credential_id' else 'credential_id' + if data.get(other_owner) is not None: + return 'ssh_browser_key_sign_stale' + for field_name in ('terminal_id', owner_field, 'key_id', 'challenge_sha256'): actual_value = data.get(field_name) expected_value = expected.get(field_name) if ( @@ -2114,6 +2122,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 +2390,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 +2416,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 +2516,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 +5874,17 @@ 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.get(key) + if isinstance(previous, SSHBridge): + previous.cancel_connection() + 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 +5897,31 @@ 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 + bridge = pending_terminal_bridges.get(key) + if isinstance(bridge, SSHBridge): + bridge.cancel_connection() + 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 +6129,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 +6141,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 +10188,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( @@ -10156,6 +10199,7 @@ def start_terminal_backend(sid, session_token, payload, start_token): 'Terminal limit reached.', error_code='terminal_limit_reached', terminal_id=terminal_id, + attempt_id=payload.get('attempt_id'), ) finish_terminal_start(session_token, terminal_id, start_token) return @@ -10168,6 +10212,7 @@ def start_terminal_backend(sid, session_token, payload, start_token): 'Connection type must be ssh, local_shell, or uart.', error_code='invalid_start_ssh_payload', terminal_id=terminal_id, + attempt_id=payload.get('attempt_id'), ) finish_terminal_start(session_token, terminal_id, start_token) return @@ -10180,9 +10225,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}") @@ -10193,39 +10243,50 @@ def start_terminal_backend(sid, session_token, payload, start_token): 'Connection failed.', error_code='backend_start_failed', terminal_id=terminal_id, + attempt_id=payload.get('attempt_id'), ) 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) @@ -10983,12 +11044,16 @@ def on_start_ssh(data): message = validation_error error_code = 'invalid_start_ssh_payload' terminal_id = validate_terminal_id_payload(data, default=TERMINAL_ID_MAIN) or TERMINAL_ID_MAIN - emit_connection_error(request.sid, message, error_code=error_code, terminal_id=terminal_id) + attempt_id = data.get('attempt_id') if isinstance(data, dict) else None + if (not isinstance(attempt_id, str) or len(attempt_id) > SSH_BROWSER_KEY_ID_MAX_LENGTH + or not SSH_BROWSER_KEY_ID_PATTERN.fullmatch(attempt_id)): + attempt_id = None + emit_connection_error(request.sid, message, error_code=error_code, terminal_id=terminal_id, attempt_id=attempt_id) 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) @@ -11037,6 +11102,8 @@ def on_setup_localhost_key_access(data): { 'message_type': 'setup_result', 'terminal_id': action.terminal_id if action else TERMINAL_ID_MAIN, + 'action_id': action_id, + 'attempt_id': action.metadata.get('attempt_id') if action else None, 'message': result['message'], 'setup_status': result['status'], 'error_code': result.get('error_code'), @@ -11044,6 +11111,57 @@ def on_setup_localhost_key_access(data): room=request.sid, ) +@socketio.on('ssh_host_identity') +def on_ssh_host_identity(data): + if not socket_session_tokens.get(request.sid) or not isinstance(data, dict): + return {'status': 'failed', 'message': 'SSH host identity request is invalid.'} + terminal_id = validate_terminal_id_payload(data) + operation = data.get('operation') + if not terminal_id or operation not in {'inspect', 'prepare_forget', 'confirm', 'cancel'}: + return {'status': 'failed', 'message': 'SSH host identity request is invalid.'} + for field in ('editor_id', 'node_id', 'request_id'): + value = data.get(field) + if not isinstance(value, str) or not SSH_BROWSER_KEY_ID_PATTERN.fullmatch(value) or len(value) > SSH_BROWSER_KEY_ID_MAX_LENGTH: + return {'status': 'failed', 'message': 'SSH host identity context is invalid.'} + payload, error = validate_start_ssh_payload({ + 'connection_type': CONNECTION_TYPE_SSH, 'terminal_id': terminal_id, + 'host': data.get('host'), 'port': data.get('port'), 'host_key_alias': data.get('host_key_alias'), + }, socket_client_ips.get(request.sid, 'unknown'), + browser_authorized=socket_browser_authorized.get(request.sid, False)) + reply = {field: data[field] for field in ('editor_id', 'node_id', 'request_id')} + if error: + return {**reply, 'status': 'failed', 'message': error.get('message') if isinstance(error, dict) else error} + context = {field: data[field] for field in ('editor_id', 'node_id')} + context.update(terminal_id=terminal_id, host=payload['host'], port=payload['port'], host_key_alias=payload['host_key_alias']) + plugin = TERMINAL_BACKEND_REGISTRY.get(CONNECTION_TYPE_SSH) + try: + if operation == 'inspect': + return {**reply, 'status': 'success', **plugin.inspect_host_key(payload)} + if operation == 'prepare_forget': + action_id, action = plugin.prepare_host_key_forget(request.sid, payload) + if not action: + return {**reply, 'status': 'success', **plugin.inspect_host_key(payload)} + action.metadata['editor_context'] = context + return {**reply, 'status': 'confirm', 'action_id': action_id, 'message': action.message, + 'question': 'Forget these saved fingerprints now? Cancelling route edits will not undo this change.'} + action_id = data.get('action_id') + if not isinstance(action_id, str): + return {**reply, 'status': 'failed', 'message': 'SSH fingerprint confirmation is stale.'} + action, _ = pending_backend_actions.get(request.sid, action_id, secrets.compare_digest) + if (not action or action.action_type != 'forget_ssh_host_key' + or action.metadata.get('editor_context') != context): + return {**reply, 'status': 'failed', 'message': 'SSH fingerprint confirmation is stale.'} + action, _ = pending_backend_actions.get(request.sid, action_id, secrets.compare_digest, consume=True) + if not action or action.metadata.get('editor_context') != context: + return {**reply, 'status': 'failed', 'message': 'SSH fingerprint confirmation is stale.'} + if operation == 'cancel': + return {**reply, 'status': 'cancelled'} + result = plugin.execute_backend_action(action) + return {**reply, **result, **(plugin.inspect_host_key(payload) if result['status'] == 'success' else {})} + except (OSError, ValueError) as exc: + return {**reply, 'status': 'failed', 'message': str(exc)} + + @socketio.on('ssh_host_key_action') def on_ssh_host_key_action(data): if not socket_session_tokens.get(request.sid): @@ -11055,6 +11173,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 +11183,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 @@ -11082,6 +11202,11 @@ def on_ssh_host_key_action(data): except (OSError, ValueError) as exc: result['message'] = str(exc) elif data.get('operation') in {'confirm', 'cancel'}: + candidate, _ = pending_backend_actions.get( + request.sid, data.get('action_id') if isinstance(data.get('action_id'), str) else '', secrets.compare_digest, + ) + if candidate and candidate.metadata.get('editor_context'): + return action, error = pending_backend_actions.get( request.sid, data.get('action_id') if isinstance(data.get('action_id'), str) else '', secrets.compare_digest, consume=True, @@ -11097,8 +11222,42 @@ 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_login_response') +def on_ssh_login_response(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 + if not is_ssh_allowed_for_client(socket_client_ips.get(request.sid, 'unknown'), + browser_authorized=socket_browser_authorized.get(request.sid, False)): + return + with terminal_start_lock: + context = pending_terminal_start_context.get((session_token, terminal_id)) + bridge = pending_terminal_bridges.get((session_token, terminal_id)) + if (not context or context.get('sid') != request.sid or context.get('attempt_id') != data.get('attempt_id') + or not isinstance(bridge, SSHBridge)): + return + bridge.resolve_login_input(request.sid, data) + @socketio.on('ssh_input') def on_ssh_input(data): session_token = socket_session_tokens.get(request.sid) @@ -11151,6 +11310,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 +11325,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 +11351,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/core_version.py b/core_version.py index 508f714..fbf66a9 100644 --- a/core_version.py +++ b/core_version.py @@ -1,3 +1,3 @@ """Core release identity, shared by source and packaged launchers.""" -CORE_VERSION = '2.12.1' +CORE_VERSION = '2.13.0' diff --git a/desktop/README.md b/desktop/README.md index e1c6c8b..2fbafa3 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -14,18 +14,23 @@ The evaluation uses ad-hoc signing, without Developer ID or notarization; it is not a Gatekeeper-qualified public release. No signing account or private key is needed for a local build. Intel/Rosetta acceptance is not implied. -New build filenames pair the independent Desktop and bundled Core versions: -`StandTerm-Desktop-0.5.0-2.12.1-mac-arm64.dmg` and -`StandTerm-Desktop-0.5.0-2.12.1-win32-x64-Setup.exe`. -The [Desktop 0.5.0 evaluation release](https://github.com/askac/standterm/releases/tag/desktop-v0.5.0-2.12.1) -contains paired packages, checksums and validation evidence. Core 2.12.1 remains -a separate source release; older Desktop releases retain their original files. +New build filenames pair the independent Desktop and bundled Core versions. +The local 0.5.0 / Core 2.12.1 candidates use that paired label. The published +[Desktop 0.4.3 evaluation](https://github.com/askac/standterm/releases/tag/desktop-v0.4.3) +retains its original filenames and does not include the later Core features. +Core source releases and Desktop installer publication are separate. + +The local Windows **0.5.1 / Core 2.13.0-dev** candidate includes ordered SSH jump +routes, per-site login cards, shared Direct/node key controls and opt-in +profile/route saving on Connect. It retains its original development identity. +The current source declares Core **2.13.0**; matching installers require a fresh +build and validation. Neither local candidate is a published Desktop release. Staging writes `release-identity.json` from the staged package/lock versions and the manifest-hashed `core_version.py`. The builder revalidates this identity and fails on missing or inconsistent inputs. Build from the printed stage, not the source Desktop directory. Package and lock versions remain Desktop-only SemVer -(`0.5.0`), so the combined label cannot change installer upgrade ordering. The +(`0.5.1`), so the combined label cannot change installer upgrade ordering. The Core qualifier is preserved, and About/Diagnostics continue to show separate versions. A label alone is not a source or Core bundle identity. @@ -73,7 +78,7 @@ Build on an Apple Silicon Mac with Node 22.12+ and the checkout's macOS venv: ```sh cd desktop npm ci -npm run stage:mac -- --core-ref v2.12.1 +npm run stage:mac -- --core-ref v2.13.0 # Change to the absolute stage directory printed above, then: npm ci npm run make:mac @@ -90,6 +95,8 @@ The Desktop package workflow builds on native `macos-15` arm64 and compares the complete staged Core payload to the formal tag, inspects the app and mounted DMG, and records native smoke results with the artifact. Automated checks do not replace Finder, Gatekeeper, IME or installer-upgrade acceptance. +The workflow pins its own `CORE_REF` and `CORE_COMMIT`; update that pair before +using it to package a newer Core release. For isolated verification, run `npm test`, `npm run smoke:capture`, the Python bootstrap/runtime/backend tests, and `test/browser-storage-smoke.cjs` with the @@ -298,7 +305,7 @@ Permanent disposal of recovery files is a separate, explicit user action. Build tools need Windows Node.js 22.12+ and Git. From the repository: ```powershell -node desktop/stage-windows.cjs --core-ref v2.12.1 +node desktop/stage-windows.cjs --core-ref v2.13.0 # Change to the exact desktop/dist/windows-build-* path printed above. cd npm ci @@ -308,13 +315,13 @@ npm run make:win The staging script creates a new directory each time. `--core-ref` selects Core files and bytes from that Git revision; the Desktop shell comes from the current checkout. Verify a release stage with `node desktop/test/core-release-inspect.cjs - v2.12.1`, then inspect the actual package with `test/package-inspect.cjs`. + v2.13.0`, then inspect the actual package with `test/package-inspect.cjs`. Without `--core-ref`, staging uses tracked working-tree Core files for development snapshots; these are not a claim that changes are already a release. Build staging never copies the development venv or `node_modules`. The new directory gets Windows build dependencies; the source checkout's Linux/WSLg `node_modules` is untouched. -The `StandTerm-Desktop-0.5.0-2.12.1-win32-x64-Setup.exe` is under `out/`; the unpacked +The `StandTerm-Desktop-0.5.1-2.13.0-win32-x64-Setup.exe` is under `out/`; the unpacked application is under `out/win-unpacked/`. Packaging uses [electron-builder's assisted NSIS target](https://www.electron.build/nsis.html), with pinned build dependencies and scoped custom installer hooks. Squirrel @@ -625,7 +632,7 @@ managed Core bundle SHA-256 identity when available. The same Core details are in Diagnostics. Core reports its version from `core_version.py`, independently of the Electron package version. Source checkouts have no managed build identity; older backends that omit version metadata show Unknown, never an inferred Git -tag. The current source pairing is Desktop 0.5.0 / Core 2.12.1. The Core source +tag. The current source pairing is Desktop 0.5.1 / Core 2.13.0. The Core source release does not publish or qualify Desktop installers. The Agent menu and expanded Core payload postdate the published 0.4.1 installer and the earlier macOS 0.4.2 candidate; they require a diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 9dc162e..b74f890 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "standterm-desktop-evaluation", - "version": "0.5.0", + "version": "0.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "standterm-desktop-evaluation", - "version": "0.5.0", + "version": "0.5.1", "license": "MIT", "devDependencies": { "electron": "44.2.0", diff --git a/desktop/package.json b/desktop/package.json index 3fb6ca5..8a67e5d 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "standterm-desktop-evaluation", - "version": "0.5.0", + "version": "0.5.1", "private": true, "productName": "StandTerm Desktop", "author": "ASKA C.", diff --git a/requirements.txt b/requirements.txt index 9f3396d..526d719 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ Flask Flask-SocketIO simple-websocket -paramiko +paramiko>=3.2 eventlet cryptography webauthn>=3,<4 diff --git a/scripts/run_smoke_tests.py b/scripts/run_smoke_tests.py index a35a524..d87a592 100644 --- a/scripts/run_smoke_tests.py +++ b/scripts/run_smoke_tests.py @@ -25,12 +25,18 @@ 'tests/agent_rsfile_smoke.py', 'tests/static_site_smoke.py', 'tests/terminal_read_smoke.py', + 'tests/ssh_start_smoke.py', + 'tests/ssh_login_smoke.py', + 'tests/ssh_node_credentials_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/ssh_login_smoke.py', + 'tests/ssh_node_credentials_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-host-identity.js b/static/js/standterm-ssh-host-identity.js new file mode 100644 index 0000000..8c742be --- /dev/null +++ b/static/js/standterm-ssh-host-identity.js @@ -0,0 +1,79 @@ +(function() { + 'use strict'; + + window.StandTermSshHostIdentity = function({ parent, read, request, editorId, nodeId }) { + const status = document.createElement('p'); + status.className = 'ssh-host-identity-status'; + status.setAttribute('role', 'status'); + const actions = document.createElement('div'); + actions.className = 'ssh-route-buttons'; + const confirmation = document.createElement('div'); + confirmation.className = 'ssh-host-identity-confirm'; + parent.append(status, actions, confirmation); + let revision = 0; + let pendingAction = null; + const button = (label, callback, container = actions) => { + const value = document.createElement('button'); + value.type = 'button'; + value.textContent = label; + value.onclick = callback; + container.append(value); + return value; + }; + const refresh = button('Check saved fingerprint', () => perform('inspect')); + const forget = button('Forget saved fingerprint…', () => perform('prepare_forget')); + function invalidate() { + revision += 1; + pendingAction = null; + confirmation.replaceChildren(); + status.textContent = 'Check the saved fingerprint for this host identity.'; + refresh.disabled = false; + forget.hidden = true; + } + function show(result) { + const fingerprints = Array.isArray(result.fingerprints) ? result.fingerprints : []; + status.textContent = `${result.identity}\n${fingerprints.length ? fingerprints.join('\n') : 'No saved fingerprint.'}\nThe server will be verified when connecting.`; + forget.hidden = !fingerprints.length; + } + async function perform(operation, action = null) { + let values; + try { values = read(); } catch (err) { status.textContent = err.message; return; } + const snapshot = JSON.stringify(values); + const token = ++revision; + const payload = { ...values, editor_id: editorId, node_id: nodeId, + request_id: StandTermSshRoutes.id(), operation, ...(action ? { action_id: action } : {}) }; + refresh.disabled = true; + forget.disabled = true; + confirmation.replaceChildren(); + status.textContent = 'Checking host identity…'; + try { + const result = await request(payload); + if (!parent.isConnected || revision !== token || JSON.stringify(read()) !== snapshot) return; + if (!result || result.request_id !== payload.request_id || result.editor_id !== editorId || result.node_id !== nodeId) { + throw new Error('SSH host identity reply is stale. Check again.'); + } + if (result.status === 'failed') throw new Error(result.message || 'SSH host identity is unavailable.'); + if (result.status === 'confirm') { + pendingAction = result.action_id; + status.textContent = result.message; + const question = document.createElement('p'); + question.textContent = result.question; + confirmation.append(question); + button('Forget now', () => { if (pendingAction === result.action_id) perform('confirm', pendingAction); }, confirmation); + const cancel = button('Keep fingerprint', () => { if (pendingAction === result.action_id) perform('cancel', pendingAction); }, confirmation); + cancel.focus(); + } else { + pendingAction = null; + if (result.status === 'cancelled') { invalidate(); perform('inspect'); } + else show(result); + } + } catch (err) { + if (parent.isConnected && revision === token) status.textContent = err.message || 'SSH host identity is unavailable.'; + } finally { + if (revision === token) { refresh.disabled = false; forget.disabled = false; } + } + } + invalidate(); + return { invalidate, inspect: () => perform('inspect') }; + }; +})(); diff --git a/static/js/standterm-ssh-login.js b/static/js/standterm-ssh-login.js new file mode 100644 index 0000000..d328691 --- /dev/null +++ b/static/js/standterm-ssh-login.js @@ -0,0 +1,158 @@ +(function() { + 'use strict'; + + window.StandTermSshLogin = function({ terminalId, attemptId, route, send, cancel, back, isActive }) { + const element = document.createElement('section'); + element.className = 'ssh-login-flow'; + const progress = document.createElement('div'); + progress.className = 'ssh-login-progress'; + progress.setAttribute('aria-label', 'SSH connection progress'); + const source = document.createElement('span'); + source.textContent = 'Core'; + source.dataset.phase = 'complete'; + progress.append(source); + element.append(progress); + const message = document.createElement('p'); + message.className = 'ssh-login-message'; + message.setAttribute('role', 'status'); + const actions = document.createElement('div'); + actions.className = 'ssh-login-actions'; + const button = (label, callback, parent) => { + const value = document.createElement('button'); + value.type = 'button'; + value.textContent = label; + value.onclick = callback; + parent.append(value); + return value; + }; + const labels = { waiting: 'Waiting', connect: 'Connecting', forward: 'Opening next hop', + verify_and_authenticate: 'Verifying host', local_keys: 'Trying local keys', host_key: 'Confirm host key', + password: 'Requires password', authenticate: 'Authenticating', authenticated: 'OK', + shell: 'Opening terminal', complete: 'OK', failed: 'Failed' }; + const nodes = route.map((node, index) => { + const role = index === route.length - 1 ? 'Target' : `Node ${index + 1}`; + const marker = document.createElement('span'); + marker.textContent = role; + marker.dataset.phase = 'waiting'; + progress.append(document.createTextNode(' → '), marker); + const card = document.createElement('section'); + card.className = 'ssh-login-card'; + card.dataset.nodeId = node.node_id; + card.dataset.phase = 'waiting'; + const heading = document.createElement('div'); + heading.className = 'ssh-login-card-heading'; + const title = document.createElement('strong'); + title.textContent = role; + const endpoint = document.createElement('span'); + endpoint.className = 'ssh-login-endpoint'; + endpoint.textContent = `${node.username}@${node.host}:${node.port}${node.host_key_alias ? ` · ${node.host_key_alias}` : ''}`; + endpoint.title = endpoint.textContent; + const status = document.createElement('span'); + status.className = 'ssh-login-card-status'; + status.textContent = labels.waiting; + heading.append(title, endpoint, status); + const body = document.createElement('div'); + body.className = 'ssh-login-card-body'; + body.hidden = true; + card.append(heading, body); + element.append(card); + return { id: node.node_id, role, card, marker, status, body, phase: 'waiting', request: null }; + }); + element.append(message, actions); + const cancelButton = button('Cancel connection', cancel, actions); + const backButton = button('Back to connection settings', back, actions); + backButton.hidden = true; + let activeIndex = -1; + let finished = false; + const requests = new Set(); + + function clearInput(node) { + node.body.querySelectorAll('input').forEach(input => { input.value = ''; }); + node.request = null; + node.body.replaceChildren(); + node.body.hidden = true; + } + + function setPhase(node, phase) { + node.phase = phase; + node.card.dataset.phase = phase; + node.marker.dataset.phase = phase; + node.status.textContent = labels[phase]; + } + + function focus() { + if (!isActive() || finished) return; + const field = nodes[activeIndex]?.body.querySelector('input, [data-default-focus]'); + if (field && !field.disabled && field.isConnected) field.focus(); + } + + function handle(data) { + if (finished || data.attempt_id !== attemptId || data.terminal_id !== terminalId) return false; + const index = nodes.findIndex(node => node.id === data.node_id); + if (index < activeIndex || index < 0 || data.hop !== index + 1 || data.total !== nodes.length) return false; + const node = nodes[index]; + if (!Object.hasOwn(labels, data.phase)) return false; + const prompt = data.message_type === 'ssh_login_prompt'; + if (prompt && (!['password', 'host_key'].includes(data.kind) || data.phase !== data.kind + || typeof data.request_id !== 'string' || requests.has(data.request_id))) return false; + if (['authenticated', 'complete'].includes(node.phase) && data.phase !== 'shell') return false; + activeIndex = index; + clearInput(node); + nodes.forEach((item, i) => { if (i !== index) clearInput(item); }); + setPhase(node, index === nodes.length - 1 && data.phase === 'authenticated' ? 'shell' : data.phase); + if (!prompt) return true; + requests.add(data.request_id); + node.request = data.request_id; + node.body.hidden = false; + const explanation = document.createElement('p'); + explanation.textContent = data.message || ''; + node.body.append(explanation); + const reply = fields => { + if (finished || !isActive() || node.request !== data.request_id) return; + const payload = { terminal_id: terminalId, attempt_id: attemptId, node_id: node.id, + request_id: data.request_id, kind: data.kind, ...fields }; + clearInput(node); + setPhase(node, data.kind === 'password' ? 'authenticate' : 'verify_and_authenticate'); + send(payload); + }; + if (data.kind === 'password') { + const form = document.createElement('form'); + const label = document.createElement('label'); + label.textContent = 'Password'; + const input = document.createElement('input'); + input.type = 'password'; + input.autocomplete = 'off'; + input.setAttribute('aria-label', `${node.role} password`); + label.append(input); + form.append(label); + const submit = button('Log in', () => {}, form); + submit.type = 'submit'; + form.onsubmit = event => { event.preventDefault(); reply({ password: input.value }); }; + node.body.append(form); + } else { + const question = document.createElement('p'); + question.textContent = data.question || 'Remember this host key?'; + node.body.append(question); + button('Trust and continue', () => reply({ accept: true }), node.body); + button('Cancel connection', cancel, node.body).dataset.defaultFocus = 'true'; + } + requestAnimationFrame(focus); + return true; + } + + function finish(success, text, context) { + finished = true; + nodes.forEach(clearInput); + if (success) nodes.forEach(node => setPhase(node, 'complete')); + else { + const failed = nodes.find(node => node.id === context?.node_id) || nodes[Math.max(0, activeIndex)]; + if (failed) setPhase(failed, 'failed'); + } + message.textContent = text || ''; + cancelButton.hidden = true; + backButton.hidden = success; + } + + return { element, handle, focus, finish, destroy() { nodes.forEach(clearInput); element.remove(); finished = true; } }; + }; +})(); diff --git a/static/js/standterm-ssh-node-auth.js b/static/js/standterm-ssh-node-auth.js new file mode 100644 index 0000000..9b6498a --- /dev/null +++ b/static/js/standterm-ssh-node-auth.js @@ -0,0 +1,142 @@ +(function() { + 'use strict'; + const routes = window.StandTermSshRoutes; + + window.StandTermSshNodeAuth = function({ parent, role, authentication, endpoint, profiles, + savedKeys, newKeys, keyAllowed, createKey, copyPublicKey, onBusy, onChange, + useKeyInput, labelElement, resetOnEndpointChange = false }) { + const panel = document.createElement('div'); + panel.className = 'ssh-node-key'; + const row = document.createElement('div'); + row.className = 'ssh-key-row'; + const label = labelElement || document.createElement('label'); + label.className = 'ssh-key-toggle'; + label.hidden = false; + const useKey = useKeyInput || document.createElement('input'); + useKey.type = 'checkbox'; + useKey.setAttribute('aria-label', `${role} Use key`); + useKey.checked = authentication.method === 'browser-key'; + label.replaceChildren(useKey, document.createTextNode('Use key')); + const publicKey = document.createElement('input'); + publicKey.type = 'text'; + publicKey.className = 'ssh-node-public-key'; + publicKey.setAttribute('aria-label', `${role} Public key`); + publicKey.readOnly = true; + publicKey.placeholder = 'No key for this endpoint'; + const copy = document.createElement('button'); + copy.type = 'button'; + copy.className = 'ssh-key-copy'; + copy.innerHTML = ''; + copy.title = 'Copy public key'; + copy.setAttribute('aria-label', 'Copy public key'); + row.append(label, publicKey, copy); + const keys = document.createElement('select'); + keys.setAttribute('aria-label', `${role} Browser key`); + const status = document.createElement('p'); + status.className = 'ssh-key-status'; + panel.append(row, keys, status); + parent.append(panel); + let selected = authentication.keyRef || null; + let generating = false; + let currentRecord = null; + let previousTarget = null; + const records = () => [...savedKeys, ...newKeys.values()]; + function reference(record) { + return { ...(record.kind === 'credential' ? { kind: 'credential' } : { ownerProfileId: record.ownerProfileId }), + keyId: record.keyId, targetKey: record.targetKey }; + } + function targetKey() { + try { return routes.endpointKey(routes.endpoint(endpoint())); } catch (_) { return null; } + } + function candidates() { + return records().filter(record => record.targetKey === targetKey() && (record.kind === 'credential' + || profiles.some(profile => profile.id === record.ownerProfileId && profile.keyId === record.keyId))); + } + function update() { + const target = targetKey(); + if (resetOnEndpointChange && previousTarget !== target) { + selected = null; + useKey.checked = false; + } + previousTarget = target; + const available = candidates(); + if (!selected && available.length === 1) selected = reference(available[0]); + currentRecord = selected && available.find(record => record.keyId === selected.keyId + && JSON.stringify(reference(record)) === JSON.stringify(selected)); + keys.replaceChildren(new Option('Choose a saved key...', '')); + for (const record of available) keys.add(new Option(record.fingerprint, record.keyId)); + if (selected && !currentRecord) keys.add(new Option('Saved key unavailable for this endpoint', selected.keyId)); + keys.value = selected?.keyId || ''; + keys.hidden = available.length < 2 && (!selected || !!currentRecord); + keys.disabled = generating || !keyAllowed; + useKey.disabled = generating || !keyAllowed || !target; + publicKey.value = currentRecord?.publicKeyOpenSsh || ''; + publicKey.title = currentRecord?.fingerprint || ''; + publicKey.classList.toggle('inactive', !useKey.checked); + copy.disabled = !currentRecord; + status.textContent = generating ? 'Creating key…' : !keyAllowed + ? 'Key authentication requires localhost or an authorized HTTPS connection.' + : currentRecord ? newKeys.has(currentRecord.keyId) + ? 'Temporary key. Save the session or route to keep it.' + : 'Saved key. Copy it to the remote account’s authorized_keys if needed.' + : selected ? 'The selected key is unavailable. Choose a saved key or turn Use key off and on to create one.' + : available.length > 1 ? 'Choose which saved key to use.' + : 'Select Use key to create a key for this endpoint.'; + } + useKey.onchange = async () => { + if (!useKey.checked) { update(); onChange(); return; } + const available = candidates(); + if (!currentRecord) selected = available.length === 1 ? reference(available[0]) : null; + update(); + onChange(); + if (currentRecord || available.length > 1 || generating || !keyAllowed) return; + let target; + try { target = routes.endpoint(endpoint()); } catch (err) { status.textContent = err.message; return; } + generating = true; + onBusy(1); + update(); + let errorMessage = ''; + try { + const isCurrent = () => panel.isConnected && targetKey() === routes.endpointKey(target); + const record = await createKey(target); + newKeys.set(record.keyId, record); + if (isCurrent()) selected = reference(record); + } catch (err) { + errorMessage = err.message || 'Browser key creation failed.'; + } finally { + generating = false; + onBusy(-1); + if (panel.isConnected) { + update(); + if (errorMessage) status.textContent = errorMessage; + onChange(); + } + } + }; + keys.onchange = () => { + const record = candidates().find(item => item.keyId === keys.value); + selected = record ? reference(record) : null; + update(); onChange(); + }; + copy.onclick = () => { + if (currentRecord && currentRecord.targetKey === targetKey()) { + copyPublicKey(currentRecord.publicKeyOpenSsh); + } + }; + update(); + return { update, record: () => currentRecord, read: () => useKey.checked + ? { method: 'browser-key', keyRef: selected } : { method: 'password' }, + configure: options => { + if (options.savedKeys) savedKeys = options.savedKeys; + if (options.profiles) profiles = options.profiles; + if (options.keyAllowed !== undefined) keyAllowed = options.keyAllowed; + update(); + if (options.authentication) { + selected = options.authentication.keyRef || null; + useKey.checked = options.authentication.method === 'browser-key'; + update(); + } + } + }; + }; +})(); diff --git a/static/js/standterm-ssh-route-editor.js b/static/js/standterm-ssh-route-editor.js new file mode 100644 index 0000000..56a9af4 --- /dev/null +++ b/static/js/standterm-ssh-route-editor.js @@ -0,0 +1,452 @@ +(function() { + 'use strict'; + const routes = window.StandTermSshRoutes; + + routes.edit = function({ state, entryId, target, onDone, keys = [], temporaryKeys = [], saveRoute = false, keyAllowed = false, + createKey, copyPublicKey, hostIdentity }) { + const original = routes.clone(state); + const savedKeys = [...keys]; + const newKeys = new Map(temporaryKeys.map(record => [record.keyId, record])); + const editorId = routes.id(); + let pendingGenerations = 0; + let draft = routes.clone(state); + let entry = [...draft.profiles, ...draft.history].find(item => item.id === entryId); + if (!entry) { + const node = { id: routes.id(), endpoint: { host: target.host || '', port: target.port || '22', username: target.username || '' }, nextNodeId: null, + hostKeyAlias: '', authentication: { method: 'password' } }; + entry = { id: routes.id(), name: '', 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'); + rows.className = 'ssh-route-cards'; + const preview = document.createElement('p'); + preview.className = 'ssh-route-preview'; + 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 = []; + let draggingIndex = null; + let expandedIndex = path().length === 1 ? 0 : -1; + const labelPath = path => `Core host → ${path.map(node => `${node.endpoint.username}@${node.endpoint.host}:${node.endpoint.port}`).join(' → ')}`; + + function path() { return routes.resolve(draft, entry.startNodeId).path; } + + function updateSaveState() { + doneButton.disabled = pendingGenerations > 0; + } + + // Keep incomplete values inside the modal. Done validates the completed draft. + function replaceDraftNode(index, replacement) { + const result = routes.resolve(draft, entry.startNodeId); + const current = result.path[index]; + if (scope.value === 'all') { + draft.nodes[draft.nodes.findIndex(node => node.id === current.id)] = { ...replacement, id: current.id }; + } else { + const prefix = result.error === 'cycle' ? result.path : result.path.slice(0, index + 1); + const copies = prefix.map(node => ({ ...routes.clone(node), id: routes.id() })); + const mapping = new Map(prefix.map((node, i) => [node.id, copies[i].id])); + copies[index] = { ...replacement, id: copies[index].id }; + copies.forEach((node, i) => { + if (i < copies.length - 1 || result.error === 'cycle') { + node.nextNodeId = mapping.get(node.nextNodeId) || node.nextNodeId; + } + }); + draft.nodes.push(...copies); + entry.startNodeId = copies[0].id; + } + } + + function flush() { + controls.forEach(control => control.card.classList.remove('invalid')); + controls.map(control => control.read()).forEach((fields, index) => { + const current = path()[index]; + const updated = { ...current, ...fields }; + if (JSON.stringify(updated) !== JSON.stringify(current)) replaceDraftNode(index, updated); + }); + entry.name = name.value.trim(); + routes.project(draft); + } + + function canChangeOrder() { + const result = routes.resolve(draft, entry.startNodeId); + if (result.error && result.error !== 'depth') { + throw new Error('Select Done to review the invalid link before changing card order.'); + } + } + + function replacePath(path, nextExpandedIndex = expandedIndex) { + entry.startNodeId = routes.copyPath(draft, path); + expandedIndex = nextExpandedIndex; + routes.project(draft); + render(); + } + + function expandCard(index) { + expandedIndex = index; + controls.forEach((control, i) => { + control.body.hidden = i !== index; + control.toggle.setAttribute('aria-expanded', String(i === index)); + }); + } + + function moveCard(from, to) { + if (from === to) return; + canChangeOrder(); + flush(); + const reordered = path(); + reordered.splice(to, 0, reordered.splice(from, 1)[0]); + let nextExpandedIndex = expandedIndex; + if (expandedIndex === from) nextExpandedIndex = to; + else if (from < expandedIndex && to >= expandedIndex) nextExpandedIndex -= 1; + else if (from > expandedIndex && to <= expandedIndex) nextExpandedIndex += 1; + replacePath(reordered, nextExpandedIndex); + controls[to].handle.focus(); + } + + function discardUnusedDraftNodes() { + // Preserve all pre-existing nodes, including stored orphans and their tails. + const keep = new Set(original.nodes.map(node => node.id)); + for (const item of [...draft.profiles, ...draft.history]) keep.add(item.startNodeId); + const nodes = new Map(draft.nodes.map(node => [node.id, node])); + for (const nodeId of keep) { + const next = nodes.get(nodeId)?.nextNodeId; + if (next) keep.add(next); + } + draft.nodes = draft.nodes.filter(node => keep.has(node.id)); + } + + 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; + status.textContent = 'Repair selected. Review the target, then select Done.'; + render(); + }, status); + } + return true; + } + + function render() { + controls = []; + rows.replaceChildren(); + const result = routes.resolve(draft, entry.startNodeId); + const source = document.createElement('div'); + source.className = 'ssh-route-source'; + source.textContent = 'Core'; + rows.append(source); + preview.textContent = labelPath(result.path); + result.path.forEach((node, index) => { + if (index === result.path.length - 1) { + const add = button('Add jump node', () => { + canChangeOrder(); + flush(); + const current = path(); + current.splice(current.length - 1, 0, { id: routes.id(), + endpoint: { host: '', port: '22', username: target.username || '' }, + hostKeyAlias: '', nextNodeId: null, authentication: { method: 'password' } }); + replacePath(current, current.length - 2); + controls[current.length - 2].host.focus(); + }, rows); + add.className = 'ssh-route-add'; + } + const fieldset = document.createElement('fieldset'); + const legend = document.createElement('legend'); + const role = index === result.path.length - 1 ? 'Target' : `Jump ${index + 1}`; + legend.textContent = role; + legend.className = 'ssh-route-accessible-label'; + fieldset.dataset.role = role; + fieldset.append(legend); + const nodeButtons = document.createElement('div'); + nodeButtons.className = 'ssh-route-card-header'; + const handle = button('≡', () => {}, nodeButtons); + handle.className = 'ssh-route-drag'; + handle.setAttribute('aria-label', `Reorder ${role}`); + handle.title = 'Drag to reorder, or use the arrow keys'; + const toggle = button('', () => expandCard(expandedIndex === index ? -1 : index), nodeButtons); + toggle.className = 'ssh-route-card-toggle'; + toggle.setAttribute('aria-label', `Edit ${role}`); + const roleLabel = document.createElement('strong'); + roleLabel.textContent = role; + const summary = document.createElement('span'); + summary.className = 'ssh-route-card-summary'; + const authSummary = document.createElement('small'); + authSummary.className = 'ssh-route-card-auth'; + toggle.append(roleLabel, summary, authSummary); + handle.draggable = true; + handle.ondragstart = event => { + draggingIndex = index; + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', String(index)); + event.dataTransfer.setDragImage(fieldset, 20, 20); + }; + handle.ondragend = () => { + draggingIndex = null; + rows.querySelectorAll('.drag-over').forEach(card => card.classList.remove('drag-over')); + }; + handle.onkeydown = event => { + const next = event.key === 'ArrowUp' ? index - 1 : event.key === 'ArrowDown' ? index + 1 : index; + if (next === index) return; + event.preventDefault(); + if (next >= 0 && next < controls.length) { + try { moveCard(index, next); } catch (err) { fail(err); } + } + }; + fieldset.ondragover = event => { + if (draggingIndex === null) return; + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + fieldset.classList.add('drag-over'); + }; + fieldset.ondragleave = event => { + if (!fieldset.contains(event.relatedTarget)) fieldset.classList.remove('drag-over'); + }; + fieldset.ondrop = event => { + if (draggingIndex === null) return; + event.preventDefault(); + const from = draggingIndex; + draggingIndex = null; + fieldset.classList.remove('drag-over'); + try { moveCard(from, index); } catch (err) { fail(err); } + }; + const earlier = button('↑', () => moveCard(index, index - 1), nodeButtons); + earlier.setAttribute('aria-label', 'Move up'); + earlier.title = 'Move up'; + earlier.disabled = index === 0; + const later = button('↓', () => moveCard(index, index + 1), nodeButtons); + later.setAttribute('aria-label', 'Move down'); + later.title = 'Move down'; + later.disabled = index === result.path.length - 1; + if (result.path.length > 1) { + const remove = button('×', () => { + canChangeOrder(); + flush(); + const remaining = path().filter((_, i) => i !== index); + const nextExpandedIndex = expandedIndex === index ? Math.min(index, remaining.length - 1) + : expandedIndex > index ? expandedIndex - 1 : expandedIndex; + replacePath(remaining, nextExpandedIndex); + }, nodeButtons); + remove.setAttribute('aria-label', 'Remove'); + remove.title = `Remove ${role}`; + } + fieldset.append(nodeButtons); + const body = document.createElement('div'); + body.className = 'ssh-route-card-body'; + body.id = `ssh-route-card-body-${index}`; + toggle.setAttribute('aria-controls', body.id); + fieldset.append(body); + const fields = document.createElement('div'); + fields.className = 'ssh-route-fields'; + body.append(fields); + const input = (label, value, parent = fields) => { + const wrapper = document.createElement('label'); + wrapper.textContent = label; + const field = document.createElement('input'); + field.value = value; + field.setAttribute('aria-label', `${role} ${label}`); + wrapper.append(field); + parent.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); + port.inputMode = 'numeric'; + const auth = StandTermSshNodeAuth({ parent: fields, role, authentication: node.authentication, + endpoint: () => ({ host: host.value, port: port.value, username: username.value }), + profiles: draft.profiles, savedKeys, newKeys, keyAllowed, createKey, copyPublicKey, + onBusy: delta => { pendingGenerations += delta; updateSaveState(); }, + onChange: () => { updateSummary(); updateSaveState(); } + }); + const identity = document.createElement('details'); + identity.className = 'ssh-host-identity'; + const identityTitle = document.createElement('summary'); + identityTitle.textContent = 'Host identity'; + identity.append(identityTitle); + body.append(identity); + const alias = input('Host key alias (optional)', node.hostKeyAlias, identity); + const identityBody = document.createElement('div'); + identity.append(identityBody); + const identityControl = hostIdentity && StandTermSshHostIdentity({ + parent: identityBody, editorId, nodeId: node.id, request: hostIdentity.request, + read: () => ({ ...routes.endpoint({ host: host.value, port: port.value, username: username.value }), + host_key_alias: alias.value.trim(), terminal_id: hostIdentity.terminalId }) + }); + identity.ontoggle = () => { if (identity.open) identityControl?.inspect(); }; + for (const field of [host, port, username, alias]) field.addEventListener('input', () => { + auth.update(); identityControl?.invalidate(); + }); + const advanced = document.createElement('details'); + const advancedTitle = document.createElement('summary'); + advancedTitle.textContent = 'Advanced node settings'; + advanced.append(advancedTitle); + body.append(advanced); + const affected = document.createElement('p'); + affected.textContent = `Referenced by: ${routes.references(draft, node.id).map(item => item.name || 'This Entry').join(', ')}`; + advanced.append(affected); + controls.push({ card: fieldset, handle, host, advanced, body, toggle, read: () => ({ + endpoint: { host: host.value, port: port.value, username: username.value }, + hostKeyAlias: alias.value.trim(), authentication: auth.read() + }) }); + const otherEntries = document.createElement('select'); + otherEntries.setAttribute('aria-label', `${role} Next route`); + otherEntries.add(new Option('Choose the next route...', '')); + draft.profiles.forEach(item => otherEntries.add(new Option(item.name, item.id))); + advanced.append(otherEntries); + const referenceButtons = document.createElement('div'); + referenceButtons.className = 'ssh-route-buttons'; + advanced.append(referenceButtons); + 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.'); + canChangeOrder(); + flush(); + const previous = routes.clone(draft); + try { + const selectedPath = routes.resolve(draft, selected.startNodeId); + if (copy && selectedPath.error && selectedPath.error !== 'depth') throw new Error('Repair the selected route before copying it.'); + const nextId = copy ? routes.copyPath(draft, selectedPath.path) : selected.startNodeId; + replaceDraftNode(index, { ...path()[index], nextNodeId: nextId }); + routes.project(draft); + status.textContent = 'Route appended. Review the final target, then select Done to check the links.'; + render(); + } catch (err) { + draft = previous; + entry = [...draft.profiles, ...draft.history].find(item => item.id === entry.id); + render(); + throw err; + } + }, referenceButtons); + } + function updateSummary() { + const endpoint = `${username.value.trim() ? `${username.value.trim()}@` : ''}${host.value.trim() || 'Enter host'}:${port.value || '22'}`; + summary.textContent = `${endpoint}${alias.value.trim() ? ` · ${alias.value.trim()}` : ''}`; + authSummary.textContent = auth.read().method === 'password' ? 'Password' : auth.read().keyRef ? 'Key' : 'Key unavailable'; + const aliasText = alias.value.trim() ? ` · Alias: ${alias.value.trim()}` : ''; + toggle.title = `${role}: ${endpoint} · ${authSummary.textContent}${aliasText}`; + } + updateSummary(); + fieldset.addEventListener('input', () => { + fieldset.classList.remove('invalid'); + updateSummary(); + preview.textContent = labelPath(controls.map(control => control.read())); + status.replaceChildren(); + }); + rows.append(fieldset); + }); + expandCard(expandedIndex < controls.length ? expandedIndex : -1); + updateScopeNotice(); + updateSaveState(); + } + + const saveRouteLabel = document.createElement('label'); + const saveRouteInput = document.createElement('input'); + saveRouteInput.type = 'checkbox'; + saveRouteInput.checked = saveRoute; + saveRouteLabel.className = 'ssh-key-toggle'; + saveRouteLabel.append(saveRouteInput, document.createTextNode('Save route on Connect')); + saveRouteInput.setAttribute('aria-label', 'Save route'); + actions.append(saveRouteLabel); + button('Cancel', () => dialog.close()); + const doneButton = button('Done', () => { + if (pendingGenerations) return; + flush(); + if (offerRepair()) return; + const current = routes.checkedPath(draft, entry); + current.forEach((node, index) => { + let validEndpoint = false; + try { + routes.endpoint(node.endpoint); + validEndpoint = true; + routes.publicNode(node); + if (node.authentication.method === 'browser-key' && !node.authentication.keyRef) { + throw new Error('Choose or create a browser key for this node.'); + } + } catch (err) { + const control = controls[index]; + control.card.classList.add('invalid'); + expandCard(index); + if (validEndpoint) control.advanced.open = true; + control.host.focus(); + throw new Error(`${index === current.length - 1 ? 'Target' : `Jump ${index + 1}`}: ${err.message}`); + } + }); + if (!entry.name) { + const endpoint = current.at(-1).endpoint; + entry.name = `${endpoint.username.trim()}@${endpoint.host.trim()}`; + } + discardUnusedDraftNodes(); + routes.validate(draft); + const used = new Set(draft.nodes.map(node => node.authentication.keyRef?.keyId)); + onDone(draft, entry.id, [...newKeys.values()].filter(record => used.has(record.keyId)), saveRouteInput.checked); + dialog.close(); + }); + doneButton.className = 'primary'; + const heading = document.createElement('div'); + heading.className = 'ssh-route-heading'; + for (const [text, field] of [['Entry name (optional)', name]]) { + const label = document.createElement('label'); + label.textContent = text; + label.append(field); + heading.append(label); + } + const advanced = document.createElement('details'); + const advancedTitle = document.createElement('summary'); + advancedTitle.textContent = 'Advanced sharing'; + advanced.append(advancedTitle, scope); + const scopeNotice = document.createElement('p'); + scopeNotice.hidden = true; + function updateScopeNotice() { + scopeNotice.hidden = scope.value !== 'all'; + scopeNotice.textContent = `Node edits also affect: ${[...new Set(path().flatMap(node => routes.references(draft, node.id).map(item => item.name || 'This Entry')))].join(', ')}. Reordering and removing cards only change this Entry.`; + } + scope.onchange = updateScopeNotice; + const help = document.createElement('p'); + help.textContent = `Connect from Core through the cards, top to bottom. The last card is the Target. Drag the handle or use ↑ / ↓. Up to ${routes.MAX_JUMPS} jumps. Done returns to connection settings. Connect saves only when Save route is selected.`; + dialog.append(title, help, heading, advanced, scopeNotice, rows, preview, 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..3fc9f27 --- /dev/null +++ b/static/js/standterm-ssh-routes.js @@ -0,0 +1,344 @@ +(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 && ((ref.kind === 'credential' ? ref.ownerProfileId !== undefined + : ref.kind !== undefined || !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 ? { ...(ref.kind === 'credential' ? { kind: 'credential' } + : { 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; + if (ref.kind === 'credential') 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; + } + const keyStore = tx.objectStore('keys'); + const keyRequest = keyStore.getAll(); + keyRequest.onsuccess = () => { + const records = new Map(keyRequest.result.map(record => [record.keyId, record])); + keyChanges.forEach(change => { + if (change.type === 'put') records.set(change.record.keyId, change.record); + if (change.type === 'delete') records.delete(change.keyId); + }); + for (const node of state.nodes) { + const ref = node.authentication.keyRef; + if (ref?.kind !== 'credential') continue; + const record = records.get(ref.keyId); + if (!record || record.kind !== 'credential' || record.ownerProfileId !== null + || record.targetKey !== ref.targetKey || record.privateKey?.extractable !== false) { + failure = new Error('An SSH node references an unavailable browser credential.'); + tx.abort(); + return; + } + } + // 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); + 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..ec673ae 100644 --- a/templates/index.html +++ b/templates/index.html @@ -367,6 +367,66 @@ .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 { position: relative; margin: 18px 0 0; padding: 6px; border: 1px solid #555; border-radius: 6px; background: #2a2a2b; min-width: 0; } + #ssh-route-editor fieldset::before { content: '↓'; position: absolute; top: -18px; left: 20px; color: #888; } + #ssh-route-editor fieldset[data-role="Target"] { border-color: #64a9ff; } + #ssh-route-editor fieldset.drag-over { outline: 2px solid #64a9ff; } + #ssh-route-editor fieldset.invalid { border-color: #ff9f0a; } + .ssh-route-accessible-label { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } + #ssh-route-editor p { overflow-wrap: anywhere; line-height: 1.5; } + #ssh-route-editor details { margin-top: 12px; } + #ssh-route-editor summary { cursor: pointer; color: #aaa; margin-bottom: 10px; } + #ssh-route-editor details > label { margin: 10px 0; } + .ssh-route-source { margin-top: 18px; color: #ddd; font-weight: 600; } + .ssh-route-preview { padding: 10px; background: #1e1e1e; border-radius: 4px; } + .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 .ssh-route-auth { grid-column: 1 / -1; } + .ssh-route-buttons { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; } + .ssh-route-card-header { display: flex; align-items: center; gap: 4px; } + .ssh-route-card-body { padding: 10px 6px 4px; } + #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:not(:disabled) { background: #48484a; } + #ssh-route-editor button:disabled { opacity: 0.4; cursor: default; } + #ssh-route-editor .ssh-route-card-header > button { flex-shrink: 0; padding: 5px 8px; border-color: transparent; background: transparent; } + #ssh-route-editor .ssh-route-drag { cursor: grab; font-size: 18px; } + #ssh-route-editor .ssh-route-card-toggle { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; text-align: left; } + .ssh-route-card-toggle strong { color: #64a9ff; min-width: 46px; } + .ssh-route-card-summary { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; } + .ssh-route-card-auth { color: #aaa; font-size: 11px; } + .ssh-route-card-toggle::after { content: '▸'; color: #aaa; } + .ssh-route-card-toggle[aria-expanded="true"]::after { content: '▾'; } + #ssh-route-editor .ssh-route-add { display: block; margin: 16px 0 0 42px; border-style: dashed; color: #64a9ff; } + #ssh-route-editor .primary { background: #0a84ff; border-color: #0a84ff; } + .ssh-route-actions { position: sticky; bottom: -18px; background: #252526; padding: 12px 0; } + .ssh-node-key { grid-column: 1 / -1; } + .ssh-node-key p { font-size: 12px; white-space: pre-wrap; overflow-wrap: anywhere; } + .ssh-key-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 8px; } + .ssh-key-toggle { display: flex; align-items: center; gap: 5px; white-space: nowrap; font-size: 12px; } + #controls .ssh-key-toggle input, #ssh-route-editor .ssh-key-toggle input { width: auto; margin: 0; padding: 0; } + #controls .ssh-node-public-key, #ssh-route-editor .ssh-node-public-key { width: 100%; min-width: 0; margin: 0; box-sizing: border-box; font: 11px monospace; } + #controls .ssh-node-public-key.inactive, #ssh-route-editor .ssh-node-public-key.inactive { background: #292929; color: #888; } + #controls .ssh-key-copy, #ssh-route-editor .ssh-key-copy { width: auto; margin: 0; padding: 7px; background: #444; } + .ssh-key-copy:disabled { opacity: 0.4; cursor: default; } + .ssh-key-status { margin: 7px 0 12px; color: #999; font-size: 11px; } + #controls input[type="password"]:disabled { background: #252525; color: #777; border-color: #3b3b3b; } + #controls input[type="password"]:disabled::placeholder { color: #666; } + #controls .ssh-node-key select { margin: 7px 0; } + #controls .ssh-node-key select[hidden] { display: none; } + @media (max-width: 640px) { + .ssh-route-heading, .ssh-route-fields { grid-template-columns: 1fr; } + #ssh-route-editor { box-sizing: border-box; width: 94vw; padding: 12px; } + #ssh-route-editor .ssh-route-card-toggle { flex-wrap: wrap; gap: 3px 6px; } + .ssh-route-card-summary { flex-basis: 100%; order: 1; } + .ssh-route-card-toggle::after { margin-left: auto; } + .ssh-route-actions { bottom: -12px; } + } .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; } @@ -393,6 +453,39 @@ #controls input, #controls select { display: block; width: 100%; margin-bottom: 15px; padding: 10px; background: #333; border: 1px solid #555; color: white; border-radius: 4px; box-sizing: border-box; } #controls button { width: 100%; padding: 12px; background: #0a84ff; border: none; color: white; border-radius: 4px; cursor: pointer; font-weight: bold; } #controls button:hover { background: #007aff; } + .ssh-preparation-pane { border: 1px solid #444; border-radius: 5px; margin: 10px 0; } + #controls .ssh-preparation-heading { display: flex; align-items: center; gap: 8px; padding: 10px; background: #292929; text-align: left; } + #controls .ssh-preparation-heading[aria-expanded="true"] { background: #233146; } + .ssh-preparation-heading small { margin-left: auto; color: #aaa; font-weight: normal; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .ssh-preparation-body { padding: 12px; } + .ssh-preparation-body[hidden] { display: none; } + .ssh-host-identity { margin-top: 10px; color: #bbb; font-size: 12px; } + .ssh-host-identity summary { cursor: pointer; margin-bottom: 8px; } + .ssh-host-identity label { display: block; } + .ssh-host-identity-status, .ssh-host-identity-confirm p { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; } + #controls .ssh-host-identity button { width: auto; padding: 7px 9px; background: #444; font-weight: normal; } + #ssh-route-path { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; line-height: 1.6; margin: 10px 0; } + #controls #ssh-edit-route { padding: 8px; background: #444; } + #controls.ssh-login-active { width: min(700px, calc(100vw - 24px)); max-height: 90vh; overflow: auto; padding: 20px; } + #controls.ssh-login-active #connection-form { display: none !important; } + .ssh-login-progress { color: #999; line-height: 1.8; margin-bottom: 14px; overflow-wrap: anywhere; } + .ssh-login-card { border: 1px solid #444; border-radius: 5px; padding: 12px; margin: 8px 0; color: #888; } + .ssh-login-card-heading { display: flex; align-items: center; gap: 10px; font-size: 12px; } + .ssh-login-card-heading strong { min-width: 44px; } + .ssh-login-endpoint { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .ssh-login-card-status { max-width: 140px; text-align: right; } + .ssh-login-card[data-phase="authenticated"], .ssh-login-card[data-phase="complete"] { color: #78ba8b; background: #203126; border-color: #395b44; } + .ssh-login-progress [data-phase="authenticated"], .ssh-login-progress [data-phase="complete"] { color: #78ba8b; } + .ssh-login-progress span:not([data-phase="waiting"]):not([data-phase="authenticated"]):not([data-phase="complete"]) { color: #0a84ff; } + .ssh-login-card:not([data-phase="waiting"]):not([data-phase="authenticated"]):not([data-phase="complete"]) { color: #ddd; border-color: #0a84ff; } + .ssh-login-card[data-phase="password"], .ssh-login-card[data-phase="host_key"] { background: #233146; } + .ssh-login-card[data-phase="failed"] { border-color: #ff9f0a !important; } + .ssh-login-card-body p, .ssh-login-message { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; line-height: 1.5; } + .ssh-login-card-body label { font-size: 12px; } + #controls .ssh-login-card-body input { margin: 6px 0 10px; } + #controls .ssh-login-card-body button { margin-top: 6px; padding: 8px 12px; } + .ssh-login-actions { margin-top: 14px; } + #controls .ssh-login-actions button { background: #444; padding: 9px 12px; } #server-availability-message { display: none; margin-bottom: 16px; padding: 12px; background: #3a2618; border: 1px solid #8a572f; border-radius: 5px; @@ -411,6 +504,7 @@ #ssh-profile-indicator { display: none; margin: -5px 0 10px; color: #64a9ff; font-size: 12px; overflow-wrap: anywhere; } .ssh-save-options { display: flex; align-items: center; gap: 18px; margin: -3px 0 12px; color: #bbb; font-size: 12px; } .ssh-save-options label { display: flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; } + .ssh-save-options label[hidden] { display: none; } #controls .ssh-save-options input { display: inline-block; width: auto; margin: 0; padding: 0; } .ssh-session-picker { position: relative; margin-bottom: 15px; } #controls #ssh-session-picker-toggle { width: 100%; padding: 9px 10px; background: #333; border: 1px solid #555; color: #ddd; border-radius: 4px; font-weight: normal; text-align: left; } @@ -1086,23 +1180,43 @@

StandTerm

-
-
- - -
- - +
+ +
+
+
+ + +
+ + +
+ +
+
+ +
+
+
+ Host identity + +
+
+
+
+
+ + +
- -
-
- -
- +
@@ -1116,6 +1230,7 @@

StandTerm

+ + + + + +