Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
122 changes: 122 additions & 0 deletions crates/sshwarden-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ pub struct Config {
#[serde(default)]
pub socket: SocketConfig,
#[serde(default)]
pub ssh_config: SshConfigConfig,
#[serde(default)]
pub storage: StorageConfig,
}

Expand Down Expand Up @@ -241,6 +243,17 @@ pub struct SocketConfig {
pub path: Option<String>,
}

#[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<String>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct StorageConfig {
/// Keep user data beside the executable instead of platform-standard storage.
Expand Down Expand Up @@ -420,6 +433,68 @@ pub fn default_control_socket_path() -> anyhow::Result<PathBuf> {
}
}

pub fn managed_ssh_config_path(config: &Config) -> anyhow::Result<PathBuf> {
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(),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

pub fn default_managed_ssh_config_path() -> anyhow::Result<PathBuf> {
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<PathBuf> {
Ok(home_dir_any()?.join(".ssh").join("sshwarden_config"))
}

pub fn user_ssh_config_path() -> anyhow::Result<PathBuf> {
Ok(home_dir_any()?.join(".ssh").join("config"))
}

pub fn expand_config_path(path: &str) -> anyhow::Result<PathBuf> {
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<PathBuf> {
expand_home_path_with_home(path, &home_dir_any()?)
}

fn expand_home_path_with_home(path: &str, home: &std::path::Path) -> anyhow::Result<PathBuf> {
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<PathBuf> {
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<PathBuf> {
std::env::var_os(name)
.filter(|value| !value.is_empty())
Expand Down Expand Up @@ -474,3 +549,50 @@ fn home_dir() -> anyhow::Result<PathBuf> {
.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")
);
}
}
8 changes: 4 additions & 4 deletions docs/adr/0023-host-bindings-and-managed-ssh-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

Expand All @@ -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.
14 changes: 7 additions & 7 deletions docs/host-bindings-followup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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.

---

Expand All @@ -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 →
Expand Down
21 changes: 14 additions & 7 deletions docs/host-bindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<exe-dir>/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:
Expand All @@ -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 "<resolved-managed-path>"
```

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.
Comment thread
Hureru marked this conversation as resolved.

## Quickstart

Expand Down Expand Up @@ -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.
- `<exe-dir>/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

Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion llmdoc/guides/how-to-use-cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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),目标为 `<exe> run --background`,WorkingDirectory 设为 exe 同目录;若本机尚无 Remembered Device 会拒绝并提示先 `login`。`sshwarden startup disable` 移除自启动项。

Expand Down
Loading
Loading