Files
tty7/docs/ssh-native-architecture.md
T
l0ng-ai 1859c9a645 feat(ssh): consolidate paths — russh default, freeze system-ssh compat (WS7)
Make native russh the default for every non-compat SSH entry point and
confine the shell-out `ssh` path to a frozen compat escape hatch (PRD §3.1).

Entry-point routing (ui::app):
- Typed "SSH: Add Connection…": a bare `user@host[:port]` now takes the
  native QuickConnect path; only arg-bearing `ssh … -flags` lines (and bare
  tokens that only name a config alias) fall to the compat shell-out.
- `~/.ssh/config` alias rows route through a documented `open_compat_alias`
  funnel (same funnel as `use_system_ssh` profiles) and their palette
  subtitle now reads `~/.ssh/config · system ssh`.
- `open_managed_ssh_spec` documented as the single compat funnel; its only
  callers are the three deliberate escape hatches.

Freeze audit: module-level freeze notes on `SshSpec`,
`build_managed_ssh_command`/`SPAWN_MANAGED_SSH`, and `daemon::forward`
(ControlMaster loopback). Verified `daemon::forward` is reachable only from
compat panes (server branches `EnsureLoopbackForward` on `RemoteKind`); no
non-compat code depends on shell-out.

FR-C5 compat gating with a visible reason: SFTP toggle on a compat pane now
opens a short "unavailable" notice instead of silently no-op'ing; the Ports
panel shows a muted compat-mode line; managed L/R/D add-form stays
native-only.

Docs: Path policy section in ssh-native-architecture.md (WS6/WS7 seams
marked resolved); SSH connection manager feature section in README +
README.zh-CN.
2026-07-14 02:29:35 +08:00

224 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Native SSH (russh) engine — architecture & integration surface
Workstream 2 (WS2) reference for the other SSH workstreams. Covers the daemon
protocol additions, the interactive prompt-broker flow, the connection-registry
API WS4/WS5 build on, and the seams intentionally left open.
Code lives under `src/daemon/ssh/` plus a backend seam in `src/daemon/pane.rs`
and wire types in `src/daemon/protocol.rs`.
> **Status:** WS1–WS7 merged. The native russh engine is the default path; the
> seams in §5 below are resolved (see the per-row notes). WS7 froze the shell-out
> path as the system-ssh compat escape hatch — see [§6 Path policy](#6-path-policy-ws7).
---
## 1. Where the bytes flow
| Layer | Local PTY pane (unchanged) | Native-SSH pane (new) |
|---|---|---|
| Byte source | `portable_pty` master | russh shell channel |
| Reader thread | blocking `Read` → ring + `Output` + OSC sniff | **same thread, unchanged** |
| Backpressure | `OutputGate` parks the PTY reader | `OutputGate` parks a bounded async→blocking channel |
| Writer | `MasterPty` writer | channel-driver command sender |
| Resize | `MasterPty::resize` (SIGWINCH) | `window-change` request |
| Kill/hangup | SIGHUP→SIGKILL process group | channel close + connection unref |
| Foreground pgid / cwd fallback | proc queries | **`None`** (OSC 133 gate is a no-op — correct for remote) |
| Exit | `read()` EOF → `Exited{None}` | driver drops data sender → `read()` EOF → `Exited{None}` |
**Key invariant:** the reader thread, replay ring, `OutputGate`, and OSC 7/133
sniffer are byte-for-byte identical for both backends. Only the handle-owning
methods differ, dispatching on `PaneBackend` (`Pty` vs `NativeSsh`).
**Bridge:** `daemon::ssh::session::make_bridge()` returns a blocking
`Read`/`Write` pair plus the async ends the channel driver takes. Output rides a
**bounded** tokio channel (depth 16) so a slow client backpressures the SSH
window (russh manages the window; we never spool unbounded). Input rides an
unbounded command channel (keystrokes are low-volume).
**Runtime:** one `tokio` multi-thread runtime owned by `SshManager` (lazy
`OnceLock`). The rest of the daemon stays std-threads and only crosses in through
the bridge channels and the broker. `russh::client::Handle` is `Send` but not
`Sync`, so `SshConnection` wraps it in a `tokio::Mutex` to stay `Send + Sync`
(required by the static manager + registry).
---
## 2. Protocol additions (`daemon::protocol`)
New kind bytes (a new spawn kind so a pre-WS2 daemon **rejects** rather than
mis-spawns):
| Direction | Constant | Byte | Message |
|---|---|---|---|
| client→daemon | `SPAWN_NATIVE_SSH` | 14 | `ClientMsg::SpawnNativeSsh { cwd, size, spec }` |
| client→daemon | `AUTH_RESPONSE` | 15 | `ClientMsg::AuthResponse { request_id, response }` |
| daemon→client | `AUTH_PROMPT` | 13 | `DaemonMsg::AuthPrompt { request_id, prompt }` |
| daemon→client | `SSH_STATUS` | 14 | `DaemonMsg::SshStatus { phase }` |
Existing daemon→client kinds `Output`/`Cwd`/`Prompt`/`Exited`/`Snapshot`/`Size`/
`RemoteContext` are reused **unchanged** — a native-SSH pane looks like any other
pane to the GUI reader.
### `NativeSshSpec` (the self-contained connect recipe)
The GUI (WS1/WS6) resolves a stored profile — including keychain secrets and any
jump-host **profile references** (into a nested `jump` chain) — into this before
sending. The daemon never reads the keychain or profile store.
- **Transport:** `host`, `port`, `user`, `proxy` (`None | Command(String) |
Socks{host,port} | Http{host,port}`), `jump: Option<Box<NativeSshSpec>>`.
ProxyCommand `%h`/`%p`/`%r` are substituted daemon-side.
- **Auth:** `auth_mode` (`Auto|Password|PublicKey|Agent|KeyboardInteractive`),
`identity_files` (`%h`/`%r` + `~` expansion), `agent_forward`, `password`
(secret), `key_passphrases: Map<path,passphrase>` (secret).
- **Session:** `keepalive_interval_s`, `keepalive_count_max`, `connect_timeout_s`,
`term`, `login_script` (lines sent verbatim + `\n` after shell start),
`skip_banner`, `verify_host_keys`, `algorithms` (kex/cipher/mac/host_key/
compression; empty = russh defaults).
- **Carried-only seams (WS4/WS5):** `forwards: Vec<SshForwardRule>`, `x11: bool`.
- **UI labels:** `display_name`, `profile_id` (never affect connection behavior).
**Secrets discipline:** hand-written `Debug` redacts `password`/`key_passphrases`
(recursively through `jump`); `AuthResponse::Secret/Secrets` redact too.
`NativeSshSpec::without_secrets()` returns a persist-safe clone (used by WS6 for
session restore).
---
## 3. Prompt-broker flow (auth / host-key ⇄ GUI)
The connect task runs async and needs decisions only the user can make. It uses
`daemon::ssh::PromptBroker`, owned by the pane and given an `emit` closure that
sends `DaemonMsg` to the pane's *current* subscriber.
```
daemon (connect task) GUI (WS3 sheets)
broker.status(Connecting) ───SshStatus────────► status line
broker.prompt(HostKeyUnknown{..}) ─AuthPrompt{id}► host-key sheet
(awaits oneshot, 120s timeout) ◄─AuthResponse{id, HostKeyDecision}
broker.prompt(Password{user,host}) ─AuthPrompt{id}► password sheet
◄─AuthResponse{id, Secret(pw)}
broker.banner(text) ───AuthPrompt{Banner}──────► banner (no reply awaited)
broker.status(Connected) ──SshStatus───────────►
… Output frames begin …
```
- **`request_id`** matches a `DaemonMsg::AuthPrompt` to its
`ClientMsg::AuthResponse`. `run_stream` routes the response via
`DaemonPane::deliver_auth_response` → `PromptBroker::deliver`.
- **Prompt kinds:** `Password{user,host}`, `KeyPassphrase{key_path,comment}`,
`KeyboardInteractive{name,instructions,prompts:[{text,echo}]}`,
`HostKeyUnknown{host,port,algorithm,fingerprint_sha256}`,
`HostKeyChanged{..,old_fingerprint_sha256}`, `Banner{text}` (fire-and-forget).
- **Responses:** `Secret(String)`, `Secrets(Vec<String>)`,
`HostKeyDecision{accept,remember}`, `Cancelled`.
- **Timeouts / cancel / no-GUI** all fail the auth step cleanly (→ `Cancelled`),
never hang the connection. Prompt delivery retries only while no subscriber has
attached yet (so it never duplicates a sheet).
- **`SshStatus` phases:** `Connecting | Authenticating | Connected | Failed{reason}`.
A failed connect also writes a red diagnostic line into the output stream and
EOFs the pane, so it surfaces as a normal `Exited` even before WS3 renders the
status.
### Auth ordering (Tabby-derived, `daemon::ssh::auth`)
`none` probe (learns server's remaining methods) → for `Auto`: publickey (each
identity file, then agent) → password → keyboard-interactive. Non-`Auto` modes
restrict to one family. The server's advertised remaining-methods set gates and
is refreshed after each failure (only when non-empty). `.pub` misconfig is
detected (parse-as-public-key first) and skipped; encrypted keys prompt for a
passphrase; RSA keys are offered with SHA-256; keyboard-interactive auto-fills
password-type prompts from the provided password and handles the zero-prompt
quirk.
---
## 4. Connection registry & reuse (FR-C2) — the WS4/WS5 API
`SshManager::global()` holds the runtime and a registry keyed by
`ConnectionKey` (host/port/user/proxy + recursive jump chain). A spawn for a key
with a **live** connection reuses its authenticated `Handle` — a new tab opens a
new *channel*, never re-authenticates. Per-key establishment is serialized by an
async mutex, so concurrent spawns for a new key don't double-connect.
**Blast radius:** all channels for a key share one `SshConnection`. If the
transport drops, every channel EOFs → every pane sharing it hits the existing
`Exited` path together. The registry holds a `Weak`; when the last shell/forward/
SFTP holding an `Arc<SshConnection>` drops, the connection disconnects. A shell's
`Arc` is held by its channel-driver task for the shell's lifetime.
### API WS4 (forwards) and WS5 (SFTP) consume
On `SshConnection` (obtain via a pane's connection — see the seam below):
| Method | Use |
|---|---|
| `open_session_channel() -> Channel<Msg>` | WS5 SFTP subsystem channel; also shells |
| `open_direct_tcpip(host, port) -> Channel<Msg>` | WS4 Local/Dynamic forwards; also the jump-host transport |
| `is_alive() -> bool` | reuse / reconnect decision |
| `key() -> &ConnectionKey` | identity |
**Seam to reach a pane's connection:**
`DaemonPane::ssh_connection() -> Option<Arc<SshConnection>>` returns the pane's
live connection (the connect task publishes it as a `Weak` the accessor upgrades;
`None` for a PTY pane, a still-authenticating pane, or a dropped connection).
Local/Dynamic forwards then open `direct-tcpip` channels on it and bridge
socket↔channel (replicate the bidirectional EOF/close propagation from the Tabby
brief §5); SFTP opens a session channel and drives the subsystem.
---
## 5. Seams left open (do NOT assume implemented)
| Seam | State in WS2 | Owner |
|---|---|---|
| Port forwards (L/R/D) | **DONE (WS4)** — `daemon::ssh::forward` (`SshForwardRegistry`): Local/Dynamic TCP listeners + `open_direct_tcpip`, Remote via `tcpip_forward` + `RemoteForwardTable` in the handler; preconfigured forwards established post-auth in `run_session`; protocol `AddForward`/`RemoveForward`/`ListForwards` (client kinds 20–22) → `ForwardList` (daemon kind 20) | WS4 |
| `RemoteContext.control_path` | always `None` for native; native loopback (FR-F4) now goes through `SshManager::ensure_loopback_forward` (a Local `direct-tcpip`), server-side branch on `RemoteKind::NativeSsh` | WS4 |
| X11 forwarding | `NativeSshSpec.x11` carried only; **seam documented** in `daemon::ssh::handler` (P1, deferred — needs `request_x11` + `server_channel_open_x11` + `$DISPLAY` bridge) | WS4/WS5 |
| SFTP | none; `open_session_channel` provided for the subsystem | WS5 |
| Agent forwarding channels | `agent_forward` requests `auth-agent-req` on the shell channel; incoming agent-channel bridging to `SSH_AUTH_SOCK` not wired | WS4/WS5 |
| Session restore respawn | **DONE (WS6)** — `SessionPane::Leaf.ssh_spec` (secret-free) persisted; `restart_ssh_session` / `resolve_persisted_ssh_spec` re-resolve keychain secrets from the named profile and respawn in place (FR-E4) | WS6 |
| GUI auth/host-key sheets | **DONE (WS3)** — password / passphrase / keyboard-interactive / host-key sheets render in-pane over the prompting pane | WS3 |
| known_hosts hardening | plaintext / hashed / `@revoked` / `@cert-authority`-skip + safe append implemented; management UI shipped in Settings → SSH (WS3) | WS3 |
**Session restore note:** a *live* native-SSH pane reattaches for free (the
daemon + russh connection stay up across GUI restarts). Only a *dead* pane needs
respawn, for which WS6 persists `Leaf.ssh_spec` via `without_secrets()`. (Kept a
leaf field per the WS2 brief; the four `SessionPane::Leaf` literals in `ui/` got
a mechanical `ssh_spec: None`, no UI behavior changed.)
---
## 6. Path policy (WS7)
Native russh is the default and only path with the full manager feature set;
the shell-out `ssh` path is a **frozen** compat escape hatch (PRD §3.1). Every
SSH entry point resolves to exactly one of the two backends:
| Entry point | Routes to | Backend |
|---|---|---|
| Saved profile, `use_system_ssh` **off** (`connect_ssh_profile`) | `open_native_ssh_tab` | native russh |
| Typed `user@host[:port]` (`OpenSshConnect` bare target) / palette QuickConnect row | `quick_connect` → `open_native_ssh_tab` | native russh |
| Saved profile, `use_system_ssh` **on** | `compat_ssh_spec` → `open_managed_ssh_spec` | compat shell-out |
| `~/.ssh/config` alias row (`OpenSshProfile`) | `open_compat_alias` → `open_managed_ssh_spec` | compat shell-out |
| Typed line carrying ssh flags/args (`ssh … -p … -J …`) | `parse_ssh_connect_input` → `open_managed_ssh_spec` | compat shell-out |
**Compat funnel.** `Tty7App::open_managed_ssh_spec` is the single funnel onto
`SPAWN_MANAGED_SSH`; its only callers are the three escape hatches above. No
default path reaches it.
**Frozen surface** (kept working, never extended — module-level notes cite this
policy): `daemon::protocol::SshSpec`, `daemon::pane::build_managed_ssh_command`
(`SPAWN_MANAGED_SSH`), and `daemon::forward` (ControlMaster `ssh -O forward`
loopback). The `EnsureLoopbackForward` server handler branches on `RemoteKind`
so `daemon::forward` only ever sees compat panes; native panes take
`daemon::ssh::forward` + `SshManager::ensure_loopback_forward` (direct-tcpip).
**Compat feature gating (FR-C5).** A compat pane has no russh connection, so
SFTP, the managed L/R/D forward add-form, and GUI auth/host-key sheets are
unavailable — each surfaces a visible reason (SFTP panel notice, a muted line in
the Ports panel, and the profile editor's compat-mode switch note), never a
silent miss. Loopback one-click forwards (FR-F4) and `~/.ssh/config` forwards
still work on compat panes via ControlMaster.