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.
This commit is contained in:
l0ng-ai
2026-07-14 02:29:35 +08:00
parent e2610f9044
commit 1859c9a645
9 changed files with 269 additions and 35 deletions
+17
View File
@@ -66,6 +66,23 @@ Download the build for your platform from
- **⌘-click links** · desktop notifications
- **Eight themes** · CJK / IME input
### SSH connection manager
A native Rust SSH stack (russh) is the default path — profiles, credentials, and
SFTP without shelling out to `ssh`.
- **QuickConnect** — type `user@host[:port]` in the palette and connect; IPv6 `[::1]:port` supported
- **Saved profiles** — full connection config with passwords / passphrases in the OS keychain, never on disk
- **GUI auth** — in-pane sheets for password, key passphrase, 2FA, and host-key confirmation (new vs. changed)
- **Built-in SFTP** — a slide-in file panel: browse, upload / download, rename / delete / chmod, drag to Finder
- **Port forwarding** — Local / Remote / Dynamic, preconfigured or added live, plus ⌘-click `localhost:PORT` to auto-forward
- **Jump hosts & proxies** — multi-hop via profile references, ProxyCommand, SOCKS5 / HTTP
| Path | When | Features |
|---|---|---|
| **Native (russh)** | Profiles + QuickConnect — the default | SFTP · keychain · GUI auth · L/R/D forwards |
| **System ssh (compat)** | `use_system_ssh` profiles · `~/.ssh/config` aliases · typed `ssh …` with flags | Frozen escape hatch — OpenSSH is the source of truth; SFTP / GUI auth / managed forwards off |
## Benchmarks
All four terminals measured back-to-back on the same machine, same day, same
+16
View File
@@ -62,6 +62,22 @@ macOS、Windows、Linux 三平台原生构建,每个 release 一起打出。
- **⌘ 点击打开链接** · 桌面通知
- **8 套主题** · CJK / 输入法输入
### SSH 连接管理器
默认走原生 Rust SSH 栈(russh)—— profile、凭据、SFTP 全部内置,无需 shell 出 `ssh`
- **QuickConnect** —— 面板里打 `user@host[:port]` 回车即连;支持 IPv6 `[::1]:port`
- **保存 profile** —— 完整连接配置,密码 / passphrase 进 OS keychain,绝不落盘
- **GUI 认证** —— pane 内 sheet 输入密码、私钥 passphrase、2FA,并确认主机密钥(新主机 vs 已变更)
- **内置 SFTP** —— 滑入式文件面板:浏览、上传 / 下载、重命名 / 删除 / chmod,可拖进 Finder
- **端口转发** —— Local / Remote / Dynamic,预配置或运行时增删,外加 ⌘ 点击 `localhost:PORT` 一键转发
- **跳板与代理** —— 经 profile 引用多跳、ProxyCommand、SOCKS5 / HTTP
| 路径 | 何时 | 功能 |
|---|---|---|
| **原生(russh** | profile + QuickConnect —— 默认 | SFTP · keychain · GUI 认证 · L/R/D 转发 |
| **系统 ssh(兼容模式)** | `use_system_ssh` profile · `~/.ssh/config` alias · 带 flag 的 `ssh …` 输入 | 冻结逃生门 —— 以 OpenSSH 为准;SFTP / GUI 认证 / 托管转发关闭 |
## 基准测试
四款终端在同一台机器上依次测完,网格统一为 155×40 —— Apple M1 Pro
+41 -3
View File
@@ -7,6 +7,10 @@ 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:** WS1WS7 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
@@ -174,12 +178,46 @@ brief §5); SFTP opens a session channel and drives the subsystem.
| 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 | `SessionPane::Leaf.ssh_spec` (secret-free) persisted; reconnection UX not built | WS6 |
| GUI auth/host-key sheets | protocol + broker ready; sheets not built | WS3 |
| known_hosts hardening | plaintext / hashed / `@revoked` / `@cert-authority`-skip + safe append implemented; wildcard/negation matching and a management UI pending | WS3 |
| 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.
+10
View File
@@ -1,3 +1,13 @@
//! Loopback port forwarding for **compat-mode** (shell-out `ssh`) panes only.
//!
//! FROZEN (PRD §3.1). This drives OpenSSH's ControlMaster socket via
//! `ssh -O forward`/`cancel`, so it is reachable exclusively from panes spawned
//! through the system-ssh compat funnel (`RemoteKind::Ssh` with a
//! `RemoteContext.control_path`). Native russh panes take the WS4 equivalent
//! (`daemon::ssh::forward` + `SshManager::ensure_loopback_forward`); the server's
//! `EnsureLoopbackForward` handler branches on `RemoteKind` so this module never
//! sees a native pane. No new features may depend on it.
use std::collections::HashMap;
use std::net::{TcpListener, TcpStream};
use std::path::PathBuf;
+6
View File
@@ -226,6 +226,12 @@ fn build_shell_command(
Ok((cmd, integration_dir))
}
/// Build the shell-out `ssh` command for a compat-mode pane.
///
/// FROZEN (PRD §3.1): the system-ssh escape hatch. It sets up the ControlMaster
/// socket that `daemon::forward` later drives for loopback forwarding. Only
/// reached via `SPAWN_MANAGED_SSH` (the compat funnel); native panes never come
/// through here. Kept working but not extended.
fn build_managed_ssh_command(
program: &str,
ssh: &SshSpec,
+5
View File
@@ -60,6 +60,11 @@ pub struct ShellSpec {
pub ssh: Option<SshSpec>,
}
/// The connect recipe for a **compat-mode** (shell-out `ssh`) pane.
///
/// FROZEN (PRD §3.1): the system-ssh escape hatch, carried by `SPAWN_MANAGED_SSH`.
/// The native russh path uses [`NativeSshSpec`] instead. Kept functional but not
/// extended — new SSH features land on the native spec, never here.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SshSpec {
/// The destination token (`host`, `user@host`, or ssh config alias).
+91 -17
View File
@@ -1152,6 +1152,13 @@ impl Tty7App {
cx.notify();
}
/// The frozen **system-ssh compat funnel** (PRD §3.1): spawn a tty7-managed
/// shell-out `ssh` tab (ControlMaster loopback forwarding, no native engine /
/// SFTP / GUI auth / vault). The only paths that reach it are the deliberate
/// escape hatches — `use_system_ssh` profiles (`connect_ssh_profile`),
/// `~/.ssh/config` aliases (`open_compat_alias`), and arg-bearing typed
/// connects (`open_typed_ssh_connect`). It is not on the default path and must
/// not gain new callers; the default is native russh (`open_native_ssh_tab`).
pub(crate) fn open_managed_ssh_spec(
&mut self,
ssh: SshSpec,
@@ -1174,6 +1181,48 @@ impl Tty7App {
);
}
/// Open a `~/.ssh/config` host alias through the frozen system-ssh compat
/// funnel (PRD §3.3). OpenSSH stays the source of truth for the alias, so it
/// runs as shell-out `ssh <alias>` — russh can't fully resolve ssh_config
/// (`Match`/canonicalize/deep `ProxyJump`), so aliases intentionally stay on
/// the compat path. Routed via [`Self::open_managed_ssh_spec`], the same
/// funnel `use_system_ssh` profiles use, rather than a bespoke spawn.
pub(crate) fn open_compat_alias(
&mut self,
alias: String,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.open_managed_ssh_spec(
SshSpec {
target: alias,
args: Vec::new(),
},
window,
cx,
);
}
/// Route a typed "SSH: Add Connection…" line to the right path (PRD §3.1/§3.3
/// path policy). A bare `user@host[:port]` target takes the **default** native
/// russh path ([`Self::quick_connect`]); anything else — a bare token that
/// only names a `~/.ssh/config` alias, or a line carrying extra ssh flags/args
/// (a system-ssh scenario) — falls to the frozen shell-out compat funnel. The
/// compat funnel is the labeled exception here, never the default.
fn open_typed_ssh_connect(&mut self, input: &str, window: &mut Window, cx: &mut Context<Self>) {
if let Some(target) = bare_ssh_target(input)
&& let Some(qc) = crate::core::ssh_profile::parse_quick_connect(&target)
{
self.quick_connect(qc, window, cx);
return;
}
// A bare config alias (unparseable as a target) or an arg-bearing `ssh …`
// line: the frozen system-ssh compat path.
if let Ok(ssh) = parse_ssh_connect_input(input) {
self.open_managed_ssh_spec(ssh, window, cx);
}
}
/// Toggle the startup update check (Settings → About). Takes effect on the
/// next launch — this only persists the preference; it doesn't run or cancel
/// an in-flight check.
@@ -1852,13 +1901,15 @@ impl Tty7App {
// Live `~/.ssh/config` aliases, marked and connected via the (frozen)
// shell-out alias path — OpenSSH stays their source of truth (PRD §3.3).
// The subtitle flags that these run on the system `ssh` compat path (no
// native engine / SFTP / GUI auth), unlike the saved profiles above.
for alias in ssh_config::discover_profiles() {
commands.push(
Command::new(
format!("SSH: {}", alias.alias),
CommandKind::OpenSshProfile(alias),
)
.with_subtitle("~/.ssh/config"),
.with_subtitle("~/.ssh/config · system ssh"),
);
}
@@ -1977,21 +2028,8 @@ impl Tty7App {
self.set_preset(&id, window, cx);
}
}
OpenSshProfile(profile) => {
self.open_managed_ssh_spec(
SshSpec {
target: profile.alias,
args: Vec::new(),
},
window,
cx,
);
}
OpenSshConnect(input) => {
if let Ok(ssh) = parse_ssh_connect_input(&input) {
self.open_managed_ssh_spec(ssh, window, cx);
}
}
OpenSshProfile(profile) => self.open_compat_alias(profile.alias, window, cx),
OpenSshConnect(input) => self.open_typed_ssh_connect(&input, window, cx),
ConnectSavedProfile(id) => self.connect_ssh_profile(id, window, cx),
EditSavedProfile(id) => self.open_ssh_profiles_for(Some(id), None, window, cx),
QuickConnect(target) => {
@@ -3416,6 +3454,21 @@ pub(crate) fn parse_ssh_option_words(input: &str) -> Result<Vec<String>, ()> {
Ok(words)
}
/// If `input` is a single bare destination token (optionally prefixed with the
/// literal `ssh`) carrying no options, return it — the candidate for the native
/// QuickConnect path. Any leading `-flag`, extra words, or an unclosed quote
/// yields `None`, marking the line as arg-bearing (a system-ssh compat input).
fn bare_ssh_target(input: &str) -> Option<String> {
let mut words = parse_ssh_option_words(input).ok()?;
if words.first().is_some_and(|w| w == "ssh") {
words.remove(0);
}
match words.as_slice() {
[only] if !only.starts_with('-') => Some(only.clone()),
_ => None,
}
}
pub(crate) fn parse_ssh_connect_input(input: &str) -> Result<SshSpec, ()> {
let mut words = parse_ssh_option_words(input)?;
if words.first().is_some_and(|word| word == "ssh") {
@@ -3473,7 +3526,28 @@ fn ssh_short_option_value_flag(word: &str) -> Option<char> {
#[cfg(test)]
mod tests {
use super::{parse_ssh_connect_input, parse_ssh_option_words};
use super::{bare_ssh_target, parse_ssh_connect_input, parse_ssh_option_words};
#[test]
fn bare_ssh_target_recognizes_single_tokens_only() {
// Bare targets (→ native QuickConnect), optional `ssh` prefix stripped.
assert_eq!(
bare_ssh_target("deploy@10.0.0.5").as_deref(),
Some("deploy@10.0.0.5")
);
assert_eq!(
bare_ssh_target("ssh host:2222").as_deref(),
Some("host:2222")
);
assert_eq!(bare_ssh_target(" myalias ").as_deref(), Some("myalias"));
// Arg-bearing or flag-led lines are NOT bare (→ compat shell-out).
assert_eq!(bare_ssh_target("dev -p 2222"), None);
assert_eq!(bare_ssh_target("-J jump host"), None);
assert_eq!(bare_ssh_target("ssh -p 2222 dev"), None);
assert_eq!(bare_ssh_target(""), None);
// An unclosed quote is a parse error, not a bare target.
assert_eq!(bare_ssh_target("'unterminated"), None);
}
#[test]
fn parses_ssh_option_words_with_quotes() {
+9
View File
@@ -295,6 +295,15 @@ impl Tty7App {
)
.child(h_flex().gap_2().child(refresh).child(close)),
)
// Compat (shell-out) pane: managed L/R/D forwards, SFTP and GUI auth
// need the native engine, so note why they're absent here (FR-C5). The
// loopback one-click list below still works via ControlMaster.
.when(!is_native, |this| {
this.child(div().text_xs().text_color(muted_foreground).child(
"System ssh compat mode — managed forwards, SFTP and GUI auth \
unavailable. Loopback links still work.",
))
})
// Managed L/R/D forwards come first for native panes; the loopback
// one-click list stays below and is shown for both pane kinds.
.when(is_native, |this| {
+74 -15
View File
@@ -1,8 +1,9 @@
//! Pane-contextual SFTP file panel (Workstream 5).
//!
//! Renders as a right-docked, slide-in panel over the terminal body area for the
//! focused **native-SSH** pane (a compat-`ssh` or PTY pane has no russh connection
//! and never shows it). Mirrors the `ui::forwards` pattern: a set of
//! focused **native-SSH** pane (a PTY pane has no remote to browse; a compat-`ssh`
//! pane has no russh connection, so it shows a short "unavailable" notice instead
//! — FR-C5). Mirrors the `ui::forwards` pattern: a set of
//! `impl Tty7App` render helpers plus a [`SftpPanelState`] held on `Tty7App`, and
//! synchronous one-shot [`RemoteTerminal`] control calls to the daemon
//! (`sftp_list` / `sftp_op` / `sftp_transfer_*`).
@@ -185,19 +186,28 @@ fn local_download_dir() -> PathBuf {
// ---------------------------------------------------------------------------
impl Tty7App {
/// Toggle the SFTP panel for the focused native-SSH pane. A no-op (with a
/// gentle close) when the focused pane isn't native-SSH.
/// Toggle the SFTP panel for the focused SSH pane. Native panes get the file
/// browser; a compat (`use_system_ssh` / alias) pane has no russh connection,
/// so it opens the panel on a compat notice (FR-C5) — a visible reason rather
/// than a silent no-op. A non-SSH focused pane closes any open panel.
pub(crate) fn toggle_sftp(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let native = self
.active_ssh_pane(window, cx)
.filter(|(_, remote)| remote.kind == RemoteKind::NativeSsh);
match native {
Some((pane_id, _)) if self.sftp_panel.open_pane_id == Some(pane_id) => {
self.close_sftp_panel(cx);
}
Some((pane_id, _)) => self.sftp_open_at(pane_id, window, cx),
// Focused pane can't do SFTP: close any panel that was open.
None => self.close_sftp_panel(cx),
let Some((pane_id, remote)) = self.active_ssh_pane(window, cx) else {
self.close_sftp_panel(cx);
return;
};
if self.sftp_panel.open_pane_id == Some(pane_id) {
self.close_sftp_panel(cx);
return;
}
if remote.kind == RemoteKind::NativeSsh {
self.sftp_open_at(pane_id, window, cx);
} else {
// Compat pane: open the panel to show the "unavailable" notice; do not
// navigate or start the poll loop (there is nothing to list).
self.sftp_panel.open_pane_id = Some(pane_id);
self.sftp_panel.editing = None;
self.sftp_panel.poll_gen = self.sftp_panel.poll_gen.wrapping_add(1);
cx.notify();
}
}
@@ -578,13 +588,18 @@ impl Tty7App {
pub(crate) fn render_sftp_overlay(
&self,
pane_id: u64,
_remote: &RemoteContext,
remote: &RemoteContext,
window: &Window,
cx: &mut Context<Self>,
) -> Option<AnyElement> {
if self.sftp_panel.open_pane_id != Some(pane_id) {
return None;
}
// Compat (shell-out) pane: no russh connection, so show why SFTP is off
// (FR-C5) instead of the file browser.
if remote.kind != RemoteKind::NativeSsh {
return Some(self.render_sftp_compat_notice(cx));
}
let popover = cx.theme().popover;
let border = cx.theme().border;
let shell_cwd = self.pane_shell_cwd(pane_id, window, cx);
@@ -616,6 +631,50 @@ impl Tty7App {
Some(panel.into_any_element())
}
/// The right-docked notice shown when SFTP is toggled on a compat-mode SSH
/// pane (FR-C5): a plain, muted explanation and a Close button — the panel
/// chrome without the browser.
fn render_sftp_compat_notice(&self, cx: &mut Context<Self>) -> AnyElement {
let theme = cx.theme();
v_flex()
.id("sftp-compat-notice")
.absolute()
.top_0()
.right_0()
.bottom_0()
.w(px(self.sftp_panel.width))
.gap_3()
.p_4()
.bg(theme.popover)
.border_l_1()
.border_color(theme.border)
.shadow_lg()
.child(
h_flex()
.items_center()
.justify_between()
.child(
div()
.font_weight(FontWeight::SEMIBOLD)
.text_color(theme.foreground)
.child("SFTP unavailable"),
)
.child(
Button::new("sftp-compat-close")
.label("Close")
.small()
.on_click(cx.listener(|this, _, _w, cx| this.close_sftp_panel(cx))),
),
)
.child(div().text_sm().text_color(theme.muted_foreground).child(
"System ssh compat mode — SFTP, GUI auth, and the managed forward \
manager are unavailable for this connection. Uncheck \u{201c}System ssh \
compat mode\u{201d} on the profile (or use QuickConnect) to connect over \
the native engine and get these features.",
))
.into_any_element()
}
fn render_sftp_header(&self, pane_id: u64, has_shell_cwd: bool, cx: &mut Context<Self>) -> Div {
let border = cx.theme().border;
let foreground = cx.theme().foreground;