diff --git a/README.md b/README.md index 1b7c6bf..85bb0cb 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ sshwarden keys ui # 图形绑定管理器(需 agent ```bash sshwarden env # 打印 shell 环境变量(sh/fish/powershell/cmd) sshwarden ssh-config # 查看托管 snippet 路径与 Include 状态 -sshwarden ssh-config show # 打印托管 snippet +sshwarden ssh-config show # 打印托管 snippet(默认在 exe 同目录,可用 [ssh_config].managed_path 覆盖) sshwarden ssh-config write # 从本地缓存 + 绑定离线重写 snippet 并确保 Include 行 sshwarden ssh-config remove # 从 ~/.ssh/config 移除 Include 行 sshwarden startup enable # 登录时自启动(需先 `login` 建立 Remembered Device) diff --git a/crates/sshwarden-config/src/lib.rs b/crates/sshwarden-config/src/lib.rs index d200f8a..f8ab2d0 100644 --- a/crates/sshwarden-config/src/lib.rs +++ b/crates/sshwarden-config/src/lib.rs @@ -30,6 +30,8 @@ pub struct Config { #[serde(default)] pub socket: SocketConfig, #[serde(default)] + pub ssh_config: SshConfigConfig, + #[serde(default)] pub storage: StorageConfig, } @@ -241,6 +243,17 @@ pub struct SocketConfig { pub path: Option, } +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SshConfigConfig { + /// Optional path for SSHWarden's generated OpenSSH Include snippet. + /// + /// Supports `~`, `~/...`, and `~\\...`. Relative paths are resolved under + /// SSHWarden's config directory. The default is `sshwarden_config` beside + /// the running executable, keeping this generated file out of the user's + /// device-wide `.ssh` directory unless explicitly configured otherwise. + pub managed_path: Option, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct StorageConfig { /// Keep user data beside the executable instead of platform-standard storage. @@ -420,6 +433,68 @@ pub fn default_control_socket_path() -> anyhow::Result { } } +pub fn managed_ssh_config_path(config: &Config) -> anyhow::Result { + match config.ssh_config.managed_path.as_deref().map(str::trim) { + Some("") => anyhow::bail!("ssh_config.managed_path is present but empty"), + Some(path) => expand_config_path(path), + None => default_managed_ssh_config_path(), + } +} + +pub fn default_managed_ssh_config_path() -> anyhow::Result { + Ok(executable_dir()?.join("sshwarden_config")) +} + +/// The historical managed snippet path used before SSHWarden kept the generated +/// file beside the executable by default. Used only for migration/cleanup. +pub fn legacy_home_managed_ssh_config_path() -> anyhow::Result { + Ok(home_dir_any()?.join(".ssh").join("sshwarden_config")) +} + +pub fn user_ssh_config_path() -> anyhow::Result { + Ok(home_dir_any()?.join(".ssh").join("config")) +} + +pub fn expand_config_path(path: &str) -> anyhow::Result { + let expanded = expand_home_path(path)?; + if expanded.is_absolute() { + Ok(expanded) + } else { + Ok(config_dir()?.join(expanded)) + } +} + +pub fn expand_home_path(path: &str) -> anyhow::Result { + expand_home_path_with_home(path, &home_dir_any()?) +} + +fn expand_home_path_with_home(path: &str, home: &std::path::Path) -> anyhow::Result { + let trimmed = path.trim(); + if trimmed.is_empty() { + anyhow::bail!("configured path is empty"); + } + if trimmed == "~" { + return Ok(home.to_path_buf()); + } + if let Some(rest) = trimmed + .strip_prefix("~/") + .or_else(|| trimmed.strip_prefix("~\\")) + { + return Ok(home.join(rest)); + } + if trimmed.starts_with('~') { + anyhow::bail!("only '~' and '~/...' are supported in configured paths: {trimmed:?}"); + } + Ok(PathBuf::from(trimmed)) +} + +fn home_dir_any() -> anyhow::Result { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .context("HOME or USERPROFILE environment variable not set") +} + fn env_path(name: &str) -> Option { std::env::var_os(name) .filter(|value| !value.is_empty()) @@ -474,3 +549,50 @@ fn home_dir() -> anyhow::Result { .map(PathBuf::from) .context("HOME environment variable not set") } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn expands_tilde_paths() { + let home = std::path::Path::new("/home/alice"); + assert_eq!(expand_home_path_with_home("~", home).unwrap(), home); + assert_eq!( + expand_home_path_with_home("~/sshwarden_config", home).unwrap(), + home.join("sshwarden_config") + ); + assert_eq!( + expand_home_path_with_home(r"~\sshwarden_config", home).unwrap(), + home.join("sshwarden_config") + ); + } + + #[test] + fn rejects_empty_managed_ssh_config_path() { + let mut config = Config::default(); + config.ssh_config.managed_path = Some(" \t\n ".to_string()); + + let err = managed_ssh_config_path(&config).unwrap_err(); + + assert!(err + .to_string() + .contains("ssh_config.managed_path is present but empty")); + } + + #[test] + fn rejects_tilde_user_paths() { + let home = std::path::Path::new("/home/alice"); + assert!(expand_home_path_with_home("~bob/sshwarden_config", home).is_err()); + } + + #[test] + fn leaves_non_tilde_paths_unchanged() { + let home = std::path::Path::new("/home/alice"); + assert_eq!( + expand_home_path_with_home("relative/sshwarden_config", home).unwrap(), + std::path::PathBuf::from("relative/sshwarden_config") + ); + } +} diff --git a/docs/adr/0023-host-bindings-and-managed-ssh-config.md b/docs/adr/0023-host-bindings-and-managed-ssh-config.md index 80b4354..8293415 100644 --- a/docs/adr/0023-host-bindings-and-managed-ssh-config.md +++ b/docs/adr/0023-host-bindings-and-managed-ssh-config.md @@ -12,7 +12,7 @@ The SSH agent protocol does not include the destination host in key list request ## Decision -SSHWarden stores local host bindings in `bindings.json` and generates an OpenSSH-readable managed config file at `~/.ssh/sshwarden_config`. The user's `~/.ssh/config` includes that file through a single SSHWarden-managed Include line. +SSHWarden stores local host bindings in `bindings.json` and generates an OpenSSH-readable managed config file. The default managed file location is `sshwarden_config` beside the running executable, keeping SSHWarden's generated host-binding details out of the device-wide `.ssh` directory. Users can override the location with `[ssh_config].managed_path`; `~`, `~/...`, and `~\\...` are expanded. The user's `~/.ssh/config` includes the resolved managed file through a single SSHWarden-managed Include line. Each host binding maps a vault item id to one or more OpenSSH `Host` patterns. The generated block uses a public Key Selector File and `IdentitiesOnly yes`: @@ -33,13 +33,13 @@ SSHWarden best-effort infers the target host by reading the SSH client process c - Local `bindings.json` avoids depending on Bitwarden backend schema changes and works with cached/offline key identities. - Managed OpenSSH config solves the problem at the SSH client selection layer, before the server counts failed key attempts. - Public Key Selector Files do not expose private key material. -- `~/.ssh/config` remains necessary even in portable mode because OpenSSH reads user configuration from fixed SSH paths, not SSHWarden's config directory. +- `~/.ssh/config` remains necessary because OpenSSH reads user configuration from fixed SSH paths, but the generated SSHWarden snippet does not need to live under `.ssh`. - One-click **Bind & Approve** avoids asking the user to approve the same signing request twice. - Process command-line inspection is platform- and permission-dependent, so it must remain best-effort and must not be security-critical. ## Consequences -- SSHWarden has a small OpenSSH footprint outside its config directory: `~/.ssh/config` and `~/.ssh/sshwarden_config`. +- SSHWarden's unavoidable OpenSSH footprint is the single Include line in `~/.ssh/config`; the generated managed snippet defaults to the executable directory and can be configured. - Bindings are per-device local preferences, not Bitwarden-synced state. -- SSHWarden must preserve compatibility with older unquoted Include lines and must quote generated paths so spaces in platform-standard directories work. +- SSHWarden must preserve compatibility with older unquoted Include lines and the previous `~/.ssh/sshwarden_config` default while quoting generated paths so spaces in platform-standard directories work. - Users can create broad patterns such as `*`; SSHWarden warns because such patterns can reintroduce `MaxAuthTries` failures. diff --git a/docs/host-bindings-followup.md b/docs/host-bindings-followup.md index 888747b..3e2b511 100644 --- a/docs/host-bindings-followup.md +++ b/docs/host-bindings-followup.md @@ -129,12 +129,11 @@ Implemented in `docs/adr/0023-host-bindings-and-managed-ssh-config.md`. - Why local-only `bindings.json` instead of stuffing into Bitwarden vault item metadata (avoids backend schema dependency; allows offline use). -- Why we generate `~/.ssh/sshwarden_config` and inject `Include` rather than +- Why we generate a managed OpenSSH config snippet and inject `Include` rather than filtering at `REQUEST_IDENTITIES` time (protocol-level limitation: agent doesn't know target host until sign request). -- Why we keep the snippet outside `config_dir()` in portable mode (OpenSSH - client only reads from fixed paths, so `~/.ssh/config` Include line is - unavoidable; we document the one-line footprint). +- Why `~/.ssh/config` still needs the Include line even though the generated + snippet defaults beside the executable and can be configured. - Why "Bind & Approve" is one click (UX: avoid double-prompting the user). - Why process-peek inference is best-effort (silently falls back). @@ -155,8 +154,9 @@ These are not bugs per se — flag them during a real-world soak. writer's read can lose the first writer's add (read-modify-write race). Add a file lock (`fs2::FileExt::try_lock_exclusive`) if this becomes a real-world problem. -- **`~/.ssh` permissions on Unix:** fixed for SSHWarden-created `~/.ssh`, - `~/.ssh/config`, and `~/.ssh/sshwarden_config` paths. +- **`~/.ssh` permissions on Unix:** fixed for SSHWarden-created `~/.ssh` and + `~/.ssh/config`; the managed snippet file itself is still written private, + but arbitrary configured parent directories are not chmodded. --- @@ -165,7 +165,7 @@ These are not bugs per se — flag them during a real-world soak. Currently we have unit tests for: - `bindings.rs` — 9 tests on the data model -- `main.rs` — 12 tests on SSH argv parsing +- `main.rs` — SSH argv parsing plus managed SSH config import/include tests What's missing: an end-to-end test that goes `HostBindingsFile::add_host → sync_managed_ssh_config_inner → diff --git a/docs/host-bindings.md b/docs/host-bindings.md index 84735d2..977b908 100644 --- a/docs/host-bindings.md +++ b/docs/host-bindings.md @@ -8,10 +8,17 @@ OpenSSH asks the configured SSH agent for identities and may try several keys be ## How SSHWarden solves it -SSHWarden writes public **Key Selector Files** for vault keys and can generate a managed SSH config file at: +SSHWarden writes public **Key Selector Files** for vault keys and can generate a managed SSH config file. By default this file is kept beside the running executable: ```text -~/.ssh/sshwarden_config +/sshwarden_config +``` + +You can override it in `config.toml`; `~`, `~/...`, and `~\\...` are expanded: + +```toml +[ssh_config] +managed_path = "~/private/sshwarden_config" ``` For every host binding, the managed config contains a `Host` block like: @@ -28,10 +35,10 @@ SSHWarden also adds one Include line to the user's SSH config: ```sshconfig # SSHWarden managed key selector snippets -Include "~/.ssh/sshwarden_config" +Include "" ``` -The managed file is regenerated from local bindings and the local key cache. +The managed file is regenerated from local bindings and the local key cache. You may edit the `Host` lines inside SSHWarden key blocks; SSHWarden imports those host patterns before regenerating the file. Other lines are treated as generated output. ## Quickstart @@ -68,10 +75,10 @@ Saving from this flow also approves the original signing request. - `bindings.json` — local mapping from vault item id to host patterns, stored under SSHWarden's configuration directory. - `keys/*.pub` — public Key Selector Files, stored under SSHWarden's configuration directory. -- `~/.ssh/sshwarden_config` — OpenSSH-readable managed config generated from bindings. +- `/sshwarden_config` by default, or `[ssh_config].managed_path` when configured — OpenSSH-readable managed config generated from bindings. - `~/.ssh/config` — contains one SSHWarden Include line. -In portable mode, SSHWarden's own config and selector files move to the portable directory, but OpenSSH still reads user config from fixed SSH paths. Therefore `~/.ssh/config` and `~/.ssh/sshwarden_config` remain the unavoidable OpenSSH footprint. +OpenSSH still reads the user's main config from fixed SSH paths, so `~/.ssh/config` remains the unavoidable OpenSSH footprint. The generated SSHWarden snippet no longer defaults to the device-wide `.ssh` directory. ## Uninstalling @@ -81,7 +88,7 @@ Remove the Include line: sshwarden ssh-config remove ``` -This preserves `~/.ssh/sshwarden_config` and `bindings.json`. Delete them manually if you want a fully clean setup. +This preserves the managed snippet and `bindings.json`. Delete them manually if you want a fully clean setup. ## Troubleshooting diff --git a/llmdoc/guides/how-to-use-cli-commands.md b/llmdoc/guides/how-to-use-cli-commands.md index b6955e1..9b4428c 100644 --- a/llmdoc/guides/how-to-use-cli-commands.md +++ b/llmdoc/guides/how-to-use-cli-commands.md @@ -40,7 +40,7 @@ SSHWarden 提供一个 SSH Agent 守护进程和一组 CLI 子命令。守护进 13. **Shell 环境:** `sshwarden env [--shell sh|fish|powershell|cmd]` 打印 agent 发现用的环境变量导出。 -14. **SSH config:** `sshwarden ssh-config` 显示托管 snippet 路径与 Include 状态;`ssh-config show` 打印 snippet;`ssh-config write` 从本地缓存 + 绑定离线重写 snippet 并确保 Include 行;`ssh-config remove` 移除 Include 行(snippet 文件保留)。 +14. **SSH config:** `sshwarden ssh-config` 显示托管 snippet 路径与 Include 状态;`ssh-config show` 打印 snippet;`ssh-config write` 从本地缓存 + 绑定离线重写 snippet 并确保 Include 行;`ssh-config remove` 移除 Include 行(snippet 文件保留)。snippet 默认写在 exe 同目录的 `sshwarden_config`,可用 `config.toml` 的 `[ssh_config].managed_path` 覆盖,支持 `~` 展开。 15. **开机自启动:** `sshwarden startup enable` 安装平台自启动项(Windows 启动文件夹 .lnk、Linux XDG autostart、macOS LaunchAgent),目标为 ` run --background`,WorkingDirectory 设为 exe 同目录;若本机尚无 Remembered Device 会拒绝并提示先 `login`。`sshwarden startup disable` 移除自启动项。 diff --git a/src/main.rs b/src/main.rs index fe897f9..0c921b1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1257,11 +1257,11 @@ async fn cmd_doctor( if fix && !has_include { if let Some(parent) = include_path.parent() { - if let Err(e) = create_private_dir(parent) { + if let Err(e) = create_parent_dir(parent) { checks.push(DoctorCheck::warn( "doctor.fix.ssh_config_dir", format!( - "Failed to create SSH config directory {}: {e}", + "Failed to create managed SSH config directory {}: {e}", parent.display() ), )); @@ -1765,14 +1765,122 @@ impl ManagedKey { } } +const SSHWARDEN_KEY_MARKER_PREFIX: &str = "# sshwarden:key "; + +fn load_host_bindings_importing_managed( + keys: &[ManagedKey], +) -> anyhow::Result { + let mut bindings = sshwarden_config::bindings::HostBindingsFile::load()?; + if keys.is_empty() { + return Ok(bindings); + } + + let include_path = managed_sshwarden_include_path()?; + if import_host_bindings_from_managed_file(&include_path, keys, &mut bindings)? { + bindings + .save() + .context("Failed to save host bindings imported from managed SSH config")?; + } + Ok(bindings) +} + +fn import_host_bindings_from_managed_file( + path: &std::path::Path, + keys: &[ManagedKey], + bindings: &mut sshwarden_config::bindings::HostBindingsFile, +) -> anyhow::Result { + if !path.exists() { + return Ok(false); + } + + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read managed SSH config: {}", path.display()))?; + let known_ids: std::collections::HashSet<&str> = + keys.iter().map(|key| key.cipher_id.as_str()).collect(); + let mut pending_cipher_id: Option = None; + let mut changed = false; + + for line in content.lines() { + let trimmed = line.trim(); + if let Some(cipher_id) = sshwarden_key_marker_cipher_id(trimmed) + .or_else(|| legacy_key_comment_cipher_id(trimmed)) + .filter(|id| known_ids.contains(id.as_str())) + { + pending_cipher_id = Some(cipher_id); + continue; + } + + let mut parts = trimmed.split_whitespace(); + let Some(directive) = parts.next() else { + continue; + }; + if !directive.eq_ignore_ascii_case("Host") { + continue; + } + let Some(cipher_id) = pending_cipher_id.take() else { + continue; + }; + + let hosts: Vec = parts + .filter(|host| *host != "") + .map(str::to_string) + .collect(); + if hosts.is_empty() { + continue; + } + let mut valid_hosts = true; + for host in &hosts { + if let Err(e) = sshwarden_config::bindings::validate_host_pattern(host) { + tracing::warn!( + path = %path.display(), + host = %host, + error = %e, + "Ignoring invalid manually edited SSHWarden Host pattern" + ); + valid_hosts = false; + break; + } + } + if !valid_hosts { + continue; + } + + let current = bindings + .bindings + .get(&cipher_id) + .map(|binding| binding.hosts.as_slice()); + if current != Some(hosts.as_slice()) { + bindings.set_hosts(&cipher_id, hosts)?; + changed = true; + } + } + + Ok(changed) +} + +fn sshwarden_key_marker_cipher_id(line: &str) -> Option { + line.strip_prefix(SSHWARDEN_KEY_MARKER_PREFIX) + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(String::from) +} + +fn legacy_key_comment_cipher_id(line: &str) -> Option { + let comment = line.strip_prefix("# ")?; + let (_, suffix) = comment.rsplit_once(" (")?; + suffix.strip_suffix(')').map(String::from) +} + fn ssh_config_snippet_with_bindings( keys: &[ManagedKey], bindings: &sshwarden_config::bindings::HostBindingsFile, ) -> anyhow::Result { let mut lines = vec![ - "# SSHWarden managed SSH config — DO NOT EDIT".to_string(), - "# This file is regenerated on every vault sync.".to_string(), - "# Manage bindings via `sshwarden keys bind/unbind ...`.".to_string(), + "# SSHWarden managed SSH config".to_string(), + "# You may edit Host lines in SSHWarden key blocks; they are imported before regeneration." + .to_string(), + "# Other lines are regenerated. Manage bindings via `sshwarden keys bind/unbind ...`." + .to_string(), String::new(), ]; @@ -1791,6 +1899,7 @@ fn ssh_config_snippet_with_bindings( .bindings .get(&key.cipher_id) .expect("bound_keys filtered for presence"); + lines.push(format!("{}{}", SSHWARDEN_KEY_MARKER_PREFIX, key.cipher_id)); lines.push(format!("# {} ({})", key.name, key.cipher_id)); lines.push(format!("Host {}", binding.hosts.join(" "))); lines.push(format!( @@ -1806,6 +1915,7 @@ fn ssh_config_snippet_with_bindings( lines.push(String::new()); for key in &unbound_keys { let path = selector_path_for_key(&key.name, &key.cipher_id)?; + lines.push(format!("{}{}", SSHWARDEN_KEY_MARKER_PREFIX, key.cipher_id)); lines.push(format!("# {} ({})", key.name, key.cipher_id)); lines.push("# Host ".to_string()); lines.push(format!( @@ -1838,8 +1948,8 @@ fn sync_managed_ssh_config_with_bindings( /// Inner sync: optionally force write even when no bindings + no existing file /// (used by `ssh-config write`). fn sync_managed_ssh_config_inner(keys: &[ManagedKey], force_write: bool) -> anyhow::Result<()> { - let mut bindings = sshwarden_config::bindings::HostBindingsFile::load() - .context("Failed to load host bindings")?; + let mut bindings = + load_host_bindings_importing_managed(keys).context("Failed to load host bindings")?; // UX-7/data-safety: an empty key set means we have no cache metadata to // classify orphans against — e.g. binding ahead of first sync (see // resolve_cipher_id), or the local key cache isn't loaded. Pruning against an @@ -1873,30 +1983,102 @@ fn sync_managed_ssh_config_inner(keys: &[ManagedKey], force_write: bool) -> anyh } fn managed_sshwarden_include_path() -> anyhow::Result { - let home = std::env::var_os("HOME") - .or_else(|| std::env::var_os("USERPROFILE")) - .map(std::path::PathBuf::from) - .context("Could not determine home directory")?; - Ok(home.join(".ssh").join("sshwarden_config")) + let config = sshwarden_config::Config::load()?; + let path = sshwarden_config::managed_ssh_config_path(&config)?; + let user_config = user_ssh_config_path()?; + ensure_managed_ssh_config_path_is_safe(&path, &user_config)?; + Ok(path) } fn user_ssh_config_path() -> anyhow::Result { - let home = std::env::var_os("HOME") - .or_else(|| std::env::var_os("USERPROFILE")) - .map(std::path::PathBuf::from) - .context("Could not determine home directory")?; - Ok(home.join(".ssh").join("config")) + sshwarden_config::user_ssh_config_path() } -fn create_private_dir(path: &std::path::Path) -> anyhow::Result<()> { +fn ensure_managed_ssh_config_path_is_safe( + managed_path: &std::path::Path, + user_config_path: &std::path::Path, +) -> anyhow::Result<()> { + if paths_equivalent_for_safety(managed_path, user_config_path) { + anyhow::bail!( + "managed SSH config path must not be the user's main SSH config: {}", + managed_path.display() + ); + } + Ok(()) +} + +fn paths_equivalent_for_safety(a: &std::path::Path, b: &std::path::Path) -> bool { + if let (Ok(a), Ok(b)) = (a.canonicalize(), b.canonicalize()) { + return path_values_equal_for_safety(&a, &b); + } + + let a = normalized_absolute_path_for_safety(a); + let b = normalized_absolute_path_for_safety(b); + path_values_equal_for_safety(&a, &b) +} + +fn normalized_absolute_path_for_safety(path: &std::path::Path) -> std::path::PathBuf { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else if let Ok(cwd) = std::env::current_dir() { + cwd.join(path) + } else { + path.to_path_buf() + }; + + normalize_path_components_for_safety(&absolute) +} + +fn normalize_path_components_for_safety(path: &std::path::Path) -> std::path::PathBuf { + let mut normalized = std::path::PathBuf::new(); + for component in path.components() { + match component { + std::path::Component::Prefix(_) | std::path::Component::RootDir => { + normalized.push(component.as_os_str()); + } + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + if matches!( + normalized.components().next_back(), + Some(std::path::Component::Normal(_)) + ) { + normalized.pop(); + } else if !path.is_absolute() { + normalized.push(component.as_os_str()); + } + } + std::path::Component::Normal(part) => normalized.push(part), + } + } + normalized +} + +fn path_values_equal_for_safety(a: &std::path::Path, b: &std::path::Path) -> bool { + #[cfg(windows)] + { + a.to_string_lossy() + .eq_ignore_ascii_case(&b.to_string_lossy()) + } + #[cfg(not(windows))] + { + a == b + } +} + +fn create_parent_dir(path: &std::path::Path) -> anyhow::Result<()> { std::fs::create_dir_all(path)?; + Ok(()) +} + +fn create_private_dir(path: &std::path::Path) -> anyhow::Result<()> { + create_parent_dir(path)?; sshwarden_config::ssh_config::ensure_ssh_dir_permissions(path)?; Ok(()) } fn write_private_file(path: &std::path::Path, content: impl AsRef<[u8]>) -> anyhow::Result<()> { if let Some(parent) = path.parent() { - create_private_dir(parent)?; + create_parent_dir(parent)?; } std::fs::write(path, content)?; sshwarden_config::ssh_config::ensure_private_file_permissions(path)?; @@ -1918,23 +2100,84 @@ fn write_sshwarden_include_line( let include_line = sshwarden_config::ssh_config::include_line(include_path); let existing = std::fs::read_to_string(config_path).unwrap_or_default(); - if !existing.lines().any(|line| { + if existing.lines().any(|line| { sshwarden_config::ssh_config::line_matches_sshwarden_include(line, include_path) }) { - let mut new_config = existing; + return Ok(()); + } + + const MARKER: &str = sshwarden_config::ssh_config::SSHWARDEN_INCLUDE_MARKER; + let mut rewritten: Vec = Vec::with_capacity(existing.lines().count() + 2); + let mut marker_waiting_for_include = false; + let mut replaced_marked_include = false; + + for line in existing.lines() { + let trimmed = line.trim(); + if trimmed == MARKER { + marker_waiting_for_include = true; + rewritten.push(line.to_string()); + continue; + } + if marker_waiting_for_include { + if trimmed.is_empty() { + rewritten.push(line.to_string()); + continue; + } + if is_ssh_include_directive(trimmed) + || line_matches_current_or_legacy_sshwarden_include(trimmed, include_path) + { + rewritten.push(include_line.clone()); + marker_waiting_for_include = false; + replaced_marked_include = true; + continue; + } + marker_waiting_for_include = false; + } + rewritten.push(line.to_string()); + } + + if replaced_marked_include { + let mut new_config = rewritten.join("\n"); if !new_config.is_empty() && !new_config.ends_with('\n') { new_config.push('\n'); } - new_config.push_str("\n# SSHWarden managed key selector snippets\n"); - new_config.push_str(&include_line); - new_config.push('\n'); write_private_file(config_path, &new_config) .with_context(|| format!("Failed to update SSH config: {}", config_path.display()))?; + return Ok(()); } + let mut new_config = existing; + if !new_config.is_empty() && !new_config.ends_with('\n') { + new_config.push('\n'); + } + new_config.push_str("\n# SSHWarden managed key selector snippets\n"); + new_config.push_str(&include_line); + new_config.push('\n'); + write_private_file(config_path, &new_config) + .with_context(|| format!("Failed to update SSH config: {}", config_path.display()))?; + Ok(()) } +fn is_ssh_include_directive(line: &str) -> bool { + line.split_whitespace() + .next() + .map(|directive| directive.eq_ignore_ascii_case("Include")) + .unwrap_or(false) +} + +fn line_matches_current_or_legacy_sshwarden_include( + line: &str, + include_path: &std::path::Path, +) -> bool { + if sshwarden_config::ssh_config::line_matches_sshwarden_include(line, include_path) { + return true; + } + sshwarden_config::legacy_home_managed_ssh_config_path() + .map(|legacy| sshwarden_config::ssh_config::line_matches_sshwarden_include(line, &legacy)) + .unwrap_or(false) +} + /// Remove the sshwarden `Include` line and its preceding marker comment from /// `~/.ssh/config`. Returns `true` if anything was changed. fn remove_sshwarden_include_line( @@ -1959,7 +2202,9 @@ fn remove_sshwarden_include_line( kept.push(line); continue; } - if sshwarden_config::ssh_config::line_matches_sshwarden_include(trimmed, include_path) { + if line_matches_current_or_legacy_sshwarden_include(trimmed, include_path) + || (skip_marker_for_next_include && is_ssh_include_directive(trimmed)) + { // Drop the include line and retroactively drop the marker if it was the previous kept entry. if skip_marker_for_next_include { while let Some(last) = kept.last() { @@ -1991,6 +2236,14 @@ fn remove_sshwarden_include_line( if !new_config.is_empty() && !new_config.ends_with('\n') { new_config.push('\n'); } + if let Some(parent) = config_path.parent() { + create_private_dir(parent).with_context(|| { + format!( + "Failed to prepare SSH config directory: {}", + parent.display() + ) + })?; + } write_private_file(config_path, &new_config) .with_context(|| format!("Failed to update SSH config: {}", config_path.display()))?; Ok(true) @@ -2492,7 +2745,6 @@ async fn cmd_keys_unbind(key: &str, host: Option<&str>, all: bool) -> anyhow::Re /// daemon is contacted opportunistically and its absence is not an error. async fn cmd_keys_list() -> anyhow::Result<()> { let cache = sshwarden_config::cache::LocalKeyCacheFile::load()?; - let bindings = sshwarden_config::bindings::HostBindingsFile::load().unwrap_or_default(); let Some(cache) = cache else { out_line("No keys cached yet. Run `sshwarden login` (or `sshwarden sync`) first."); @@ -2505,6 +2757,12 @@ async fn cmd_keys_list() -> anyhow::Result<()> { return Ok(()); } + let managed_keys = ManagedKey::from_cache_header(&cache.header.keys); + let bindings = load_host_bindings_importing_managed(&managed_keys).unwrap_or_else(|e| { + tracing::warn!("Failed to load host bindings: {}", e); + sshwarden_config::bindings::HostBindingsFile::default() + }); + // Best-effort live lock state; omit silently when the daemon is down. let locked: Option = match sshwarden_agent::control::send_control_command("status-json").await { @@ -2574,7 +2832,8 @@ async fn cmd_keys_list() -> anyhow::Result<()> { async fn cmd_bindings_add(key: &str, hosts: &[String]) -> anyhow::Result<()> { let cipher_id = resolve_cipher_id(key)?; - let mut bindings = sshwarden_config::bindings::HostBindingsFile::load()?; + let keys = load_managed_keys_from_cache().unwrap_or_default(); + let mut bindings = load_host_bindings_importing_managed(&keys)?; for host in hosts { if is_catch_all_host_pattern(host) { tracing::warn!( @@ -2586,7 +2845,6 @@ async fn cmd_bindings_add(key: &str, hosts: &[String]) -> anyhow::Result<()> { } bindings.save()?; - let keys = load_managed_keys_from_cache().unwrap_or_default(); // UX-7: a failed snippet regeneration means `ssh host` would route to the // wrong key (or none), so fail loudly rather than warn-and-continue. sync_managed_ssh_config_inner(&keys, true) @@ -2618,7 +2876,8 @@ fn is_catch_all_host_pattern(host: &str) -> bool { async fn cmd_bindings_remove(key: &str, host: Option<&str>, all: bool) -> anyhow::Result<()> { let cipher_id = resolve_cipher_id(key)?; - let mut bindings = sshwarden_config::bindings::HostBindingsFile::load()?; + let keys = load_managed_keys_from_cache().unwrap_or_default(); + let mut bindings = load_host_bindings_importing_managed(&keys)?; let changed = match (host, all) { (Some(_), true) => anyhow::bail!("Pass either a host argument or `--all`, not both."), @@ -2634,7 +2893,6 @@ async fn cmd_bindings_remove(key: &str, host: Option<&str>, all: bool) -> anyhow } bindings.save()?; - let keys = load_managed_keys_from_cache().unwrap_or_default(); sync_managed_ssh_config_inner(&keys, true) .context("Bindings saved but managed snippet regeneration failed")?; @@ -2692,8 +2950,13 @@ async fn cmd_sshcfg_uninstall() -> anyhow::Result<()> { async fn cmd_sshcfg_status() -> anyhow::Result<()> { let include_path = managed_sshwarden_include_path()?; let config_path = user_ssh_config_path()?; - let bindings = sshwarden_config::bindings::HostBindingsFile::load().unwrap_or_default(); let cache = sshwarden_config::cache::LocalKeyCacheFile::load()?; + let bindings = cache + .as_ref() + .map(|cache| ManagedKey::from_cache_header(&cache.header.keys)) + .map(|keys| load_host_bindings_importing_managed(&keys)) + .transpose()? + .unwrap_or_default(); let user_config_has_include = std::fs::read_to_string(&config_path) .map(|s| { @@ -5651,7 +5914,10 @@ async fn run_bind_hosts_flow( return false; } }; - let bindings = sshwarden_config::bindings::HostBindingsFile::load().unwrap_or_default(); + let bindings = load_host_bindings_importing_managed(&keys).unwrap_or_else(|e| { + tracing::warn!("Failed to load host bindings: {}", e); + sshwarden_config::bindings::HostBindingsFile::default() + }); let entries: Vec = keys .iter() .map(|k| sshwarden_ui::BindHostsKeyEntry { @@ -5725,13 +5991,13 @@ async fn persist_bind_payload( ) -> anyhow::Result<()> { let payload = payload.clone(); tokio::task::spawn_blocking(move || -> anyhow::Result<()> { - let mut bindings = sshwarden_config::bindings::HostBindingsFile::load()?; + let keys = load_managed_keys_from_cache().unwrap_or_default(); + let mut bindings = load_host_bindings_importing_managed(&keys)?; for (cipher_id, hosts) in &payload { bindings.set_hosts(cipher_id, hosts.clone())?; } bindings.save()?; - let keys = load_managed_keys_from_cache().unwrap_or_default(); sync_managed_ssh_config_inner(&keys, true)?; // CFG-2: a binding is inert unless ~/.ssh/config Includes the managed @@ -5757,7 +6023,10 @@ async fn dispatch_standalone_bind_hosts_dialog( ui_request_tx: &UIRequestTx, ) -> anyhow::Result { let keys = load_managed_keys_from_cache()?; - let bindings = sshwarden_config::bindings::HostBindingsFile::load().unwrap_or_default(); + let bindings = load_host_bindings_importing_managed(&keys).unwrap_or_else(|e| { + tracing::warn!("Failed to load host bindings: {}", e); + sshwarden_config::bindings::HostBindingsFile::default() + }); let entries: Vec = keys .iter() .map(|k| sshwarden_ui::BindHostsKeyEntry { @@ -6409,4 +6678,98 @@ mod tests { Some("host.example") ); } + + fn temp_test_path(name: &str) -> std::path::PathBuf { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!( + "sshwarden-test-{}-{}-{name}", + std::process::id(), + unique + )) + } + + #[test] + fn imports_manually_edited_host_lines_from_managed_file() { + let path = temp_test_path("managed.conf"); + let content = "# SSHWarden managed SSH config\n\ +# sshwarden:key id-1\n\ +# Github (id-1)\n\ +Host github.com gitlab.com\n\ + IdentityFile \"/tmp/github.pub\"\n\ + IdentitiesOnly yes\n"; + std::fs::write(&path, content).unwrap(); + + let keys = vec![ManagedKey { + name: "Github".to_string(), + cipher_id: "id-1".to_string(), + }]; + let mut bindings = sshwarden_config::bindings::HostBindingsFile::default(); + let changed = import_host_bindings_from_managed_file(&path, &keys, &mut bindings).unwrap(); + + assert!(changed); + assert_eq!( + bindings.bindings.get("id-1").unwrap().hosts, + vec!["github.com".to_string(), "gitlab.com".to_string()] + ); + let _ = std::fs::remove_file(path); + } + + #[test] + fn managed_ssh_config_safety_rejects_nonexistent_alias_of_user_config() { + let dir = temp_test_path("managed-safety-alias"); + let ssh_dir = dir.join(".ssh"); + let user_config = ssh_dir.join("config"); + let managed_path = ssh_dir.join("..").join(".ssh").join("config"); + + assert!(paths_equivalent_for_safety(&managed_path, &user_config)); + assert!(ensure_managed_ssh_config_path_is_safe(&managed_path, &user_config).is_err()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn include_write_replaces_marked_include_line() { + let dir = temp_test_path("include-replace"); + std::fs::create_dir_all(&dir).unwrap(); + let config_path = dir.join("config"); + let include_path = dir.join("sshwarden_config"); + std::fs::write( + &config_path, + "Host *\n AddKeysToAgent yes\n\n# SSHWarden managed key selector snippets\nInclude \"/old/sshwarden_config\"\n", + ) + .unwrap(); + + write_sshwarden_include_line(&config_path, &include_path).unwrap(); + let updated = std::fs::read_to_string(&config_path).unwrap(); + + assert!(updated.contains(&sshwarden_config::ssh_config::include_line(&include_path))); + assert!(!updated.contains("/old/sshwarden_config")); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn include_remove_drops_marked_include_line() { + let dir = temp_test_path("include-remove"); + std::fs::create_dir_all(&dir).unwrap(); + let config_path = dir.join("config"); + let include_path = dir.join("sshwarden_config"); + let include_line = sshwarden_config::ssh_config::include_line(&include_path); + std::fs::write( + &config_path, + format!( + "Host *\n AddKeysToAgent yes\n\n# SSHWarden managed key selector snippets\n{include_line}\n" + ), + ) + .unwrap(); + + assert!(remove_sshwarden_include_line(&config_path, &include_path).unwrap()); + let updated = std::fs::read_to_string(&config_path).unwrap(); + + assert!(updated.contains("Host *")); + assert!(!updated.contains("SSHWarden managed key selector snippets")); + assert!(!updated.contains(&include_line)); + let _ = std::fs::remove_dir_all(dir); + } }