diff --git a/README.md b/README.md index 3d20a1ef..74ce53f6 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ and SFTP without ever shelling out to `ssh`. There is no system-ssh compat mode. - **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 -- **`~/.ssh/config` aliases** — resolved natively (common fields, best-effort) and connected over russh +- **`~/.ssh/config` aliases** — type one to connect (resolved natively — common fields, best-effort — over russh), or import them as profiles in Settings - **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 diff --git a/README.zh-CN.md b/README.zh-CN.md index 532bfa02..ae958bed 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -68,7 +68,7 @@ macOS、Windows、Linux 三平台原生构建,每个 release 一起打出。 - **QuickConnect** —— 面板里打 `user@host[:port]` 回车即连;支持 IPv6 `[::1]:port` - **保存 profile** —— 完整连接配置,密码 / passphrase 进 OS keychain,绝不落盘 -- **`~/.ssh/config` alias** —— 原生解析(常用字段,尽力而为)后走 russh 连接 +- **`~/.ssh/config` alias** —— 直接输入 alias 即连(原生解析常用字段,尽力而为,走 russh),也可在设置页一键导入为 profile - **GUI 认证** —— pane 内 sheet 输入密码、私钥 passphrase、2FA,并确认主机密钥(新主机 vs 已变更) - **内置 SFTP** —— 滑入式文件面板:浏览、上传 / 下载、重命名 / 删除 / chmod,可拖进 Finder - **端口转发** —— Local / Remote / Dynamic,预配置或运行时增删,外加 ⌘ 点击 `localhost:PORT` 一键转发 diff --git a/docs/prd-ssh-connection-manager.md b/docs/prd-ssh-connection-manager.md index ccf251e6..17b3563c 100644 --- a/docs/prd-ssh-connection-manager.md +++ b/docs/prd-ssh-connection-manager.md @@ -17,7 +17,7 @@ tty7 当前把系统 `ssh` 二进制丢进 daemon 拥有的 PTY 运行(`daemon/p **已有可复用的地基:** - daemon 拥有全部 PTY,GUI 经 socket 镜像字节流(`terminal/remote.rs` 的 `RemoteTerminal`)——字节源已抽象,是 russh shell channel 的天然接缝 -- palette 已能实时发现 `~/.ssh/config` 的 Host alias(`core/ssh_config.rs`,仅发现不解析) +- `core/ssh_config.rs` 已能解析 `~/.ssh/config`(含 Include),供导入与按名 resolve 使用 - pane 级滑入面板(`ui/forwards.rs`)、全窗口设置页、竖向 tab 侧栏均已存在 - **完全没有的**:profile 存储、keychain/凭据、GUI 认证、SFTP、Remote/Dynamic 转发 @@ -97,7 +97,7 @@ tty7 当前把系统 `ssh` 二进制丢进 daemon 拥有的 PTY 运行(`daemon/p ### 3.3 ssh_config 处理 | 决策 | v1 做法 | |---|---| -| **alias 实时发现(已有)** | 保留 `core/ssh_config.rs` 的实时 alias 发现,palette 里 config alias 始终与文件同步,不吃"导入快照过期"的亏 | +| **alias 按名 resolve** | palette 只列保存的 profile(唯一来源,避免同一主机两套行为);config alias 不再实时列出,但在输入连接目标时按名现场 resolve(等价 `ssh `),或经导入转成 profile | | **导入为 profile(可选)** | 解析常见字段(Host/HostName/User/Port/IdentityFile/ProxyJump)导成 profile,给想要凭据/SFTP/转发管理的条目用;导入是显式动作,可重复执行(按 alias 去重更新) | | **运行时完整解析(Match/canonicalize)** | v1 不做;复杂 config 用户对该 profile 勾"系统 ssh 兼容模式"(§3.1) | @@ -194,7 +194,7 @@ tty7 当前把系统 `ssh` 二进制丢进 daemon 拥有的 PTY 运行(`daemon/p ### 5.4 Profile 管理 + 凭据(P0) - **FR-P1** Profile 存储完整字段(见 §7 数据模型),支持分组。 - **FR-P2** 密码 / passphrase 存 OS keychain(macOS Keychain;Windows Credential Manager;Linux libsecret),配置文件只存引用不存明文。 -- **FR-P3** palette 为统一入口:同框展示保存的 profile + ssh_config alias + "现连"项,按 frecency 排序。 +- **FR-P3** palette 为统一入口:SSH 列表只来自保存的 profile(按 frecency 排序)+ "现连"项;ssh_config 主机经导入成为 profile 后出现,或直接输入 alias 现连。 - **FR-P4** QuickConnect 解析 `[ssh] user@host[:port] [flags]`,支持 IPv6 `[::1]:port`。 - **FR-P5** profile → `user@host:port` 一键复制;`~/.ssh/config` 一键导入。 @@ -379,7 +379,7 @@ struct SshProfile { | **russh 兼容性长尾**(Tabby 前车之鉴:#10188/#10207/#11058) | 部分用户切换后连不上 | 系统 ssh 兼容模式逃生门 + GA 前 ≥2 周 dogfood + 认证失败信息可诊断(展示服务端拒绝原因) | | russh 特性缺口(FIDO/PKCS#11) | 部分密钥类型连不上 | 引导用户经 ssh-agent 认证(签名由 agent 完成);仍不行走兼容模式 | | GSSAPI/Kerberos 用户 | 管理器路径无法连接 | 兼容模式覆盖,文档说明 | -| ssh_config 复杂语义(Match/多跳) | 导入不完整 | 实时 alias 发现保底;v1 只导入常见字段;复杂场景引导兼容模式 | +| ssh_config 复杂语义(Match/多跳) | 导入不完整 | 输入 alias 按名 resolve 保底;v1 只导入常见字段;复杂场景引导兼容模式 | | known_hosts 格式细节 | 误报/漏报 host key | 只做明文/hashed/@revoked 的信任判定,@cert-authority 尽力而为;解析层绝不改写无关行;充分测试 | | 连接复用爆炸半径 | 一条连接挂掉拖垮多个 pane | FR-C2 明确断开语义:所有共享 pane 同步提示,一键重连全部恢复 | | X11 转发依赖 XQuartz(macOS) | 开了不生效,用户困惑 | 检测不到 X server 时提示安装 XQuartz,而非静默失败 | diff --git a/src/core/ssh_config.rs b/src/core/ssh_config.rs index 1be7c581..12444710 100644 --- a/src/core/ssh_config.rs +++ b/src/core/ssh_config.rs @@ -1,11 +1,15 @@ -//! Lightweight discovery of OpenSSH host aliases for UI pickers. +//! `~/.ssh/config` parsing: alias resolution for typed connects and the +//! Settings-page import (PRD §3.3). //! -//! tty7 does not try to resolve the final SSH configuration here. OpenSSH is the -//! source of truth for `HostName`, `User`, `Port`, `ProxyJump`, `Match`, and the -//! rest when we eventually run `ssh `. This module only finds concrete -//! `Host` aliases worth listing in the command palette. +//! Saved profiles are the app's single listed source of SSH hosts; this module +//! never feeds a UI list directly. It resolves a *named* alias on demand +//! (`resolve_alias_to_profile`, used when a typed target names a config Host) +//! and turns the whole config into managed profiles on explicit import +//! (`import_profiles` + `merge_imported`, behind Settings → SSH → "Import +//! from ~/.ssh/config"). `Match` blocks and `canonicalize` are intentionally +//! not evaluated. -use std::collections::{HashSet, VecDeque}; +use std::collections::HashSet; use std::path::{Path, PathBuf}; use crate::core::ssh_profile::{ForwardKind, ForwardRule, HostPort, SshProfile as ManagedProfile}; @@ -18,68 +22,6 @@ const MAX_CONFIG_FILES: usize = 256; /// profile's group is preserved on re-import. pub const IMPORTED_GROUP: &str = "Imported from ssh_config"; -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SshProfile { - pub alias: String, - pub source: PathBuf, -} - -pub fn discover_profiles() -> Vec { - let Some(home) = home_dir() else { - return Vec::new(); - }; - discover_profiles_from(home.join(".ssh/config"), &home) -} - -fn discover_profiles_from(root: PathBuf, home: &Path) -> Vec { - let mut profiles = Vec::new(); - let mut aliases = HashSet::new(); - let mut seen_files = HashSet::new(); - let mut queue = VecDeque::from([(root, 0usize)]); - - while let Some((path, depth)) = queue.pop_front() { - if depth > MAX_INCLUDE_DEPTH || seen_files.len() >= MAX_CONFIG_FILES { - continue; - } - let path = expand_path(&path, home); - if !seen_files.insert(path.clone()) { - continue; - } - let Ok(text) = std::fs::read_to_string(&path) else { - continue; - }; - let base = path.parent().unwrap_or(home); - for line in text.lines() { - let line = strip_comment(line).trim(); - if line.is_empty() { - continue; - } - let Some((key, rest)) = split_keyword(line) else { - continue; - }; - if key.eq_ignore_ascii_case("host") { - for token in split_words(rest) { - if concrete_host_alias(&token) && aliases.insert(token.clone()) { - profiles.push(SshProfile { - alias: token, - source: path.clone(), - }); - } - } - } else if key.eq_ignore_ascii_case("include") { - for token in split_words(rest) { - for include in expand_include(&token, base, home) { - queue.push_back((include, depth + 1)); - } - } - } - } - } - - profiles.sort_by(|a, b| a.alias.cmp(&b.alias).then_with(|| a.source.cmp(&b.source))); - profiles -} - fn home_dir() -> Option { #[cfg(windows)] { @@ -239,8 +181,7 @@ fn glob_match(pattern: &str, text: &str) -> bool { // ───────────────────────────────────────────────────────────────────────────── // ssh_config → profile import (PRD §3.3) // -// `discover_profiles` above stays untouched (live alias discovery for the -// palette). The code below resolves the russh-mappable fields of each concrete +// The code below resolves the russh-mappable fields of each concrete // `Host` alias into a [`ManagedProfile`], so a config entry can connect natively // (there is no system-ssh fallback). Scope, per PRD §3.3: // @@ -807,9 +748,9 @@ mod tests { ) .unwrap(); - let aliases: Vec<_> = discover_profiles_from(ssh.join("config"), &root) + let aliases: Vec<_> = import_profiles_from(ssh.join("config"), &root) .into_iter() - .map(|p| p.alias) + .map(|p| p.profile.name) .collect(); assert_eq!(aliases, vec!["dev", "prod", "quoted host"]); } @@ -823,9 +764,9 @@ mod tests { std::fs::write(ssh.join("conf.d/dev"), "Host dev\n").unwrap(); std::fs::write(ssh.join("conf.d/prod"), "Host prod\n").unwrap(); - let aliases: Vec<_> = discover_profiles_from(ssh.join("config"), &root) + let aliases: Vec<_> = import_profiles_from(ssh.join("config"), &root) .into_iter() - .map(|p| p.alias) + .map(|p| p.profile.name) .collect(); assert_eq!(aliases, vec!["dev", "prod", "root"]); } diff --git a/src/ui/app.rs b/src/ui/app.rs index e6541a2a..95ae90c6 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1079,43 +1079,6 @@ impl Tty7App { cx.notify(); } - /// Open a `~/.ssh/config` host alias over the native (russh) engine (PRD §3.3). - /// The alias is resolved against `~/.ssh/config` (common fields, best-effort; - /// `Match`/canonicalize unsupported with no fallback) into a transient profile, - /// its `ProxyJump` resolved into the nested jump chain. An alias unknown to the - /// config falls back to being treated as a bare hostname. - pub(crate) fn open_native_alias( - &mut self, - alias: String, - window: &mut Window, - cx: &mut Context, - ) { - let verify = cx.global::().verify_host_keys; - match ssh_config::resolve_alias_to_profile(&alias) { - Some(resolved) => { - let spec = crate::ui::ssh_connect::native_spec_from_transient_profile( - &resolved.profile, - resolved.proxy_jump, - &crate::core::keychain::OsCredentialStore, - verify, - &|a| ssh_config::resolve_alias_to_profile(a).map(|r| (r.profile, r.proxy_jump)), - ); - self.open_native_ssh_tab(Box::new(spec), window, cx); - } - // Not in ssh_config → treat the alias as a bare host (QuickConnect). - None => { - if let Some(qc) = crate::core::ssh_profile::parse_quick_connect(&alias) { - self.quick_connect(qc, window, cx); - } else { - self.push_ssh_connect_error( - format!("Can't connect to \u{201c}{alias}\u{201d}"), - cx, - ); - } - } - } - } - /// Route a typed "SSH: Add Connection…" line to the native engine (PRD §3.1/ /// §3.3). The input is parsed as best-effort into a transient profile — a /// `user@host[:port]` target plus the trivially-mappable flags (`-p`, `-i`, @@ -1124,13 +1087,35 @@ impl Tty7App { fn open_typed_ssh_connect(&mut self, input: &str, window: &mut Window, cx: &mut Context) { match parse_ssh_connect_input(input) { Ok(parsed) => { + // `ssh` semantics: a target naming a `~/.ssh/config` alias + // resolves through it, with typed flags overriding the config's + // values. (After parsing, a port of 22 is indistinguishable + // from "not given", so an explicit `-p 22` can't override a + // config port — the one caveat of this overlay.) + let (profile, proxy_jump) = + match ssh_config::resolve_alias_to_profile(&parsed.profile.host) { + Some(resolved) => { + let mut p = resolved.profile; + if !parsed.profile.user.is_empty() { + p.user = parsed.profile.user; + } + if parsed.profile.port != 22 { + p.port = parsed.profile.port; + } + if !parsed.profile.identity_files.is_empty() { + p.identity_files = parsed.profile.identity_files; + } + (p, parsed.proxy_jump.or(resolved.proxy_jump)) + } + None => (parsed.profile, parsed.proxy_jump), + }; let verify = cx.global::().verify_host_keys; let spec = crate::ui::ssh_connect::native_spec_from_transient_profile( - &parsed.profile, - parsed.proxy_jump, + &profile, + proxy_jump, &crate::core::keychain::OsCredentialStore, verify, - &|a| ssh_config::resolve_alias_to_profile(a).map(|r| (r.profile, r.proxy_jump)), + &crate::ui::ssh_connect::config_alias_resolver, ); self.open_native_ssh_tab(Box::new(spec), window, cx); } @@ -1837,17 +1822,12 @@ impl Tty7App { ); } - // Live `~/.ssh/config` aliases: resolved (common fields, best-effort) and - // connected over the native engine, same as saved profiles (PRD §3.3). - for alias in ssh_config::discover_profiles() { - commands.push( - Command::new( - format!("SSH: {}", alias.alias), - CommandKind::OpenSshProfile(alias), - ) - .with_subtitle("~/.ssh/config"), - ); - } + // Saved profiles are the palette's *only* SSH listing: `~/.ssh/config` + // hosts appear here after Settings → SSH → "Import from ~/.ssh/config" + // turns them into profiles, never as a parallel live-discovered source + // (two lists of the same hosts with different behaviors confused more + // than it helped). Typing an alias into "SSH: Add Connection…" still + // resolves it against ssh_config on the spot. for (i, tab) in self.tabs.iter().enumerate() { // Skip the active tab — "switch to the tab you're already on" is a @@ -1964,7 +1944,6 @@ impl Tty7App { self.set_preset(&id, window, cx); } } - OpenSshProfile(profile) => self.open_native_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_profile_in_settings(id, window, cx), diff --git a/src/ui/palette.rs b/src/ui/palette.rs index b67d8283..b821049f 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -22,7 +22,6 @@ use gpui_component::{ use uuid::Uuid; use crate::core::config::Config; -use crate::core::ssh_config::SshProfile; use crate::core::ssh_profile::parse_quick_connect; /// What a command actually does. Most variants map to an existing `Tty7App` @@ -70,9 +69,6 @@ pub enum CommandKind { /// Apply the preset at this index in `presets::all()`. Emitted from the /// theme sub-list. SetTheme(usize), - /// Open a native SSH tab for this discovered OpenSSH host alias (resolved - /// against `~/.ssh/config`). - OpenSshProfile(SshProfile), /// Switch to the tab at this index in `Tty7App::tabs`. ActivateTab(usize), /// Connect a saved SSH profile by id (over the native engine). @@ -140,7 +136,6 @@ impl CommandKind { | OpenSshConnectInput | OpenSshConnect(_) | SetTheme(_) - | OpenSshProfile(_) | ActivateTab(_) | ConnectSavedProfile(_) | EditSavedProfile(_) diff --git a/src/ui/ssh_connect.rs b/src/ui/ssh_connect.rs index 62255239..1c215be7 100644 --- a/src/ui/ssh_connect.rs +++ b/src/ui/ssh_connect.rs @@ -74,12 +74,35 @@ impl Tty7App { /// QuickConnect to a typed `user@host[:port]` target (PRD FR-P4), always via /// the native path. Builds a transient profile so keychain lookup by endpoint /// still applies (a QuickConnect can reuse a remembered password). + /// + /// `ssh ` semantics: a host naming a `~/.ssh/config` alias resolves + /// through it (HostName/User/Port/IdentityFile/ProxyJump), with the typed + /// `user@` / `:port` overriding the config's values. The palette lists only + /// saved profiles, so this is how a config alias connects without importing. pub(crate) fn quick_connect( &mut self, qc: crate::core::ssh_profile::QuickConnect, window: &mut gpui::Window, cx: &mut gpui::Context, ) { + if let Some(resolved) = crate::core::ssh_config::resolve_alias_to_profile(&qc.host) { + let mut profile = resolved.profile; + if let Some(user) = qc.user { + profile.user = user; + } + if let Some(port) = qc.port { + profile.port = port; + } + let spec = native_spec_from_transient_profile( + &profile, + resolved.proxy_jump, + &OsCredentialStore, + cx.global::().verify_host_keys, + &config_alias_resolver, + ); + self.open_native_ssh_tab(Box::new(spec), window, cx); + return; + } let port = qc.port_or_default(); let mut profile = SshProfile::new(qc.host.clone()); profile.host = qc.host; @@ -350,6 +373,12 @@ fn map_algorithms(a: &Algorithms) -> SshAlgorithms { /// [`crate::core::ssh_config::resolve_alias_to_profile`]). pub(crate) type AliasResolver<'a> = dyn Fn(&str) -> Option<(SshProfile, Option)> + 'a; +/// The standard [`AliasResolver`]: resolve against the live `~/.ssh/config`. +/// Shared by every typed-connect path (QuickConnect, "SSH: Add Connection…"). +pub(crate) fn config_alias_resolver(alias: &str) -> Option<(SshProfile, Option)> { + crate::core::ssh_config::resolve_alias_to_profile(alias).map(|r| (r.profile, r.proxy_jump)) +} + /// Build a [`NativeSshSpec`] from a **transient** (unsaved) profile — resolved /// from a `~/.ssh/config` alias or a typed connect line — whose jump host is a /// raw string rather than a stored profile id. The base spec is built like any