Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd6a517fec | |||
| d4cf32dbed | |||
| 9d267cf7a8 | |||
| c63474eb86 | |||
| 51210232a1 | |||
| e1e91a4741 | |||
| ef3507e688 | |||
| a92b86a382 | |||
| a5c0f65b17 | |||
| 480ed466d5 | |||
| 3ad03f5e9b | |||
| 31d5643ab6 | |||
| e42a396daa | |||
| 9527caca0e | |||
| 43c1c77a30 | |||
| 18f5972401 | |||
| ed2521294b | |||
| 65a3248619 | |||
| 9cb95fac10 | |||
| 1b68854d00 | |||
| e7a527e7e2 | |||
| fe29db4b7e | |||
| 118163eace | |||
| 2948d0e6e5 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,3 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
start-daemon.bat
|
||||
start-daemon.vbs
|
||||
|
||||
266
CHANGELOG.md
Normal file
266
CHANGELOG.md
Normal file
@@ -0,0 +1,266 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to ssh-mux are documented here. The format follows
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
||||
adheres to [Semantic Versioning](https://semver.org/). Releases prior to
|
||||
v1.11.0 are tracked only in the git log.
|
||||
|
||||
## [1.14.1] — 2026-04-29
|
||||
|
||||
### Changed
|
||||
- **SSH client banner now mirrors the local `ssh` binary** instead of
|
||||
russh's default `SSH-2.0-russh_0.57`. On startup, ssh-mux invokes
|
||||
`ssh -V` once and reuses the parsed `OpenSSH_*` version string as its
|
||||
protocol banner; if `ssh` isn't on PATH, falls back to a platform-
|
||||
appropriate default (`OpenSSH_for_Windows_8.1` on Windows,
|
||||
`OpenSSH_9.0` on macOS, `OpenSSH_9.6` elsewhere). Result is cached
|
||||
for the process lifetime.
|
||||
|
||||
Rationale: ssh-mux is meant to be a drop-in for native `ssh`, so
|
||||
presenting a russh-flavored banner to servers and middleboxes is
|
||||
needlessly distinctive. This change makes the banner indistinguishable
|
||||
from what the user's own `ssh` would send.
|
||||
|
||||
Scope note: this does **not** change OS-detection outcomes on systems
|
||||
that fingerprint the TCP/IP stack (e.g. Okta device posture). Those
|
||||
signals come from the kernel, not the SSH banner.
|
||||
|
||||
## [1.14.0] — 2026-04-28
|
||||
|
||||
### Changed
|
||||
- **Remote loopback `direct-tcpip` channels are now unconditionally
|
||||
allowed** for any authenticated route (or remote target in direct
|
||||
mode). The per-route `allow_remote_loopback_any_port` opt-in (v1.13.4)
|
||||
and top-level `default_allow_remote_loopback_any_port` (v1.13.5) are
|
||||
removed; both fields are silently ignored if still present in
|
||||
`ssh-mux-routes.toml`. Non-loopback non-route targets stay denied
|
||||
exactly as before — the route still cannot be used to pivot into
|
||||
arbitrary internal hosts.
|
||||
|
||||
Rationale: the gate guarded against a local actor with code
|
||||
execution against the mux listener using the route as a SOCKS-style
|
||||
proxy into the remote's localhost services. On a single-user dev
|
||||
box that actor can already invoke `ssh` directly, so the gate's
|
||||
defense-in-depth value is near zero. Meanwhile its UX cost was
|
||||
high: `import-config` rewrites silently dropped the per-route
|
||||
opt-in, leaving users with broken VS Code / Cursor Remote-SSH and
|
||||
no obvious clue why. Lifting the restriction makes the IDE path
|
||||
work out of the box.
|
||||
|
||||
Migration: nothing to do. Existing `[routes.X]` sections that
|
||||
carry `allow_remote_loopback_any_port = true|false`, and top-level
|
||||
`default_allow_remote_loopback_any_port`, parse fine — the fields
|
||||
are now ignored.
|
||||
|
||||
## [1.13.5] — 2026-04-28
|
||||
|
||||
### Added
|
||||
- **`default_allow_remote_loopback_any_port` top-level option** in
|
||||
`ssh-mux-routes.toml`. Acts as a fallback for the per-route
|
||||
`allow_remote_loopback_any_port` flag added in v1.13.4: when `true`
|
||||
at the top level, every route inherits the IDE-friendly loopback
|
||||
policy without each `[routes.X]` section having to repeat the line.
|
||||
The effective decision is the OR of the two flags, so a per-route
|
||||
`true` keeps working even when the top-level default is `false`.
|
||||
|
||||
Useful for users whose routes are predominantly VS Code / Cursor
|
||||
remote-development targets — set it once instead of per host:
|
||||
|
||||
```toml
|
||||
default_allow_remote_loopback_any_port = true
|
||||
|
||||
[jump]
|
||||
host = "bastion.example.com"
|
||||
port = 22
|
||||
user = "jumpuser"
|
||||
|
||||
[routes.devbox]
|
||||
host = "10.0.0.30"
|
||||
user = "dev"
|
||||
```
|
||||
|
||||
The default remains `false`, preserving the secure per-route opt-in
|
||||
for users who only want loopback-any-port on selected hosts.
|
||||
|
||||
## [1.13.4] — 2026-04-28
|
||||
|
||||
### Fixed
|
||||
- **VS Code / Cursor Remote-SSH connections through ssh-mux were silently
|
||||
failing** with `administratively prohibited: Rejected` after `e7a527e`
|
||||
("Harden IPC and forwarding policy", 2026-04-25). That commit
|
||||
tightened the `direct-tcpip` allowlist so that remote loopback
|
||||
(`127.0.0.1` / `localhost` / `::1`) was only accepted on the route's
|
||||
SSH port. VS Code and Cursor Remote-SSH spawn their IDE server on a
|
||||
**random** localhost port and then open a `direct-tcpip` channel to
|
||||
that port over the SSH session — so every such channel was denied by
|
||||
the local mux, the IDE looped on "Setting up SSH host", and the user
|
||||
saw rejections originating from ssh-mux itself rather than the remote
|
||||
sshd. The fix keeps the secure default (deny all loopback ports
|
||||
except the route's SSH port) but adds a per-route opt-in to relax
|
||||
the policy specifically for IDE remote-development targets.
|
||||
|
||||
### Added
|
||||
- **`allow_remote_loopback_any_port` per-route option** in
|
||||
`ssh-mux-routes.toml`. When `true`, the route accepts `direct-tcpip`
|
||||
channels to any port on remote loopback; non-loopback non-route
|
||||
targets remain denied as before. Default `false` (safe). Enable
|
||||
only on routes used as VS Code / Cursor Remote-SSH targets:
|
||||
|
||||
```toml
|
||||
[routes.devbox]
|
||||
host = "10.0.0.30"
|
||||
port = 22
|
||||
user = "dev"
|
||||
allow_remote_loopback_any_port = true
|
||||
```
|
||||
|
||||
## [1.13.3] — 2026-04-28
|
||||
|
||||
### Fixed
|
||||
- **CI build**: commit `Cargo.lock` to the repository. ssh-mux is a
|
||||
binary crate, and the previous `.gitignore` policy of excluding the
|
||||
lockfile is appropriate for libraries, not for executables. Without
|
||||
the lockfile, CI's resolver picked `pkcs8 0.11.0` (just released
|
||||
with a breaking change to `Error::KeyMalformed`) over the
|
||||
`pkcs8 0.11.0-rc.11` that `rsa 0.10.0-rc.12` (pulled in transitively
|
||||
by russh's `rsa` feature) was written against, and the build failed
|
||||
with `\\\`match\\\` arms have incompatible types`. Local builds were
|
||||
unaffected because Cargo.lock was already pinning the right
|
||||
versions. Committing the lockfile makes builds reproducible and
|
||||
resolves the CI failure. No code change.
|
||||
|
||||
## [1.13.2] — 2026-04-28
|
||||
|
||||
### Fixed
|
||||
- **`import-config` now works as a re-import after ssh-mux is already
|
||||
installed.** Previously, once `setup-config` had prepended
|
||||
`Include ssh-mux-hosts.conf` to `~/.ssh/config`, every `ssh -G <alias>`
|
||||
resolved to `127.0.0.1` (because the Include points at our generated
|
||||
routes), the "looks like an existing ssh-mux entry" filter dropped
|
||||
every alias, and `import-config` bailed with `no usable routes after
|
||||
resolution` — making it impossible to refresh `routes.toml` after
|
||||
upgrading. `import-config` now detects this exact `Include` line in
|
||||
the source config, writes a stripped temporary copy in the same
|
||||
directory (so other relative `Include` directives still resolve
|
||||
correctly), and runs `ssh -G -F <stripped>` against it. The temp
|
||||
file is removed by an RAII guard on exit. The original source SSH
|
||||
config is **never modified**.
|
||||
- **Warnings are now printed before any `bail!`.** The
|
||||
`no usable routes after resolution` error previously claimed "see
|
||||
warnings above" but the warnings were buffered until after the bail,
|
||||
so users saw the error with no diagnostic context. Accumulated
|
||||
warnings are now flushed to stderr before any bail and again on the
|
||||
normal exit path.
|
||||
|
||||
## [1.13.1] — 2026-04-27
|
||||
|
||||
### Fixed
|
||||
- **`import-config` now skips hosts driven by `ProxyCommand`.** ssh-mux
|
||||
multiplexes raw SSH over TCP and via a jump-host channel; it cannot
|
||||
drive arbitrary transports such as the AWS SSM `start-session`
|
||||
pipeline (`Match host i-* / ProxyCommand …`). Previously these
|
||||
aliases were imported with their resolved `HostName` (an EC2 instance
|
||||
ID) and ssh-mux would then fail to connect via direct TCP. They are
|
||||
now excluded from `routes.toml`. The user's original `Host` block
|
||||
stays in `~/.ssh/config` and `ssh.exe` keeps using it via the
|
||||
ProxyCommand exactly as before — `Include ssh-mux-hosts.conf` does
|
||||
not contain those aliases, so OpenSSH's first-match-wins falls
|
||||
through to the user's block. The same exclusion applies when the
|
||||
jump host itself uses `ProxyCommand` (the routes that depend on it
|
||||
are skipped). Each skip is reported as a warning.
|
||||
|
||||
## [1.13.0] — 2026-04-27
|
||||
|
||||
### Changed
|
||||
- **`ssh-mux install` is now a one-shot bootstrap.** It chains
|
||||
`import-config` → `setup-config` → service install:
|
||||
1. If `~/.ssh/ssh-mux-routes.toml` does not exist, it is derived
|
||||
from `~/.ssh/config` via `ssh -G <alias>` (read-only — your SSH
|
||||
config is never modified).
|
||||
2. `setup-config` runs to generate `ssh-mux-hosts.conf`, prepend
|
||||
the `Include` line to `~/.ssh/config`, and pre-register the
|
||||
host key.
|
||||
3. (Windows) the background service is installed and the daemon is
|
||||
started, as before. (Other platforms) steps 1–2 still run and
|
||||
the manual `ssh-mux daemon …` invocation is printed.
|
||||
|
||||
The standalone `import-config` and `setup-config` subcommands
|
||||
remain available unchanged for explicit re-runs and advanced use.
|
||||
|
||||
If `routes.toml` already exists when `install` is called, the
|
||||
import step is skipped — re-import explicitly with
|
||||
`ssh-mux import-config --write --force`.
|
||||
|
||||
- `ssh-mux install` now runs on non-Windows platforms (it previously
|
||||
errored out). It performs the import + setup-config steps and
|
||||
prints the daemon invocation; the Windows-only Startup VBS step
|
||||
is skipped.
|
||||
|
||||
## [1.12.1] — 2026-04-27
|
||||
|
||||
### Fixed
|
||||
- Build: satisfy `clippy 1.95`'s `collapsible_match` lint in `import.rs`
|
||||
(no functional change). The `proxyjump` arm in `parse_ssh_g_output` is
|
||||
now expressed as a guarded match arm rather than a nested `if`.
|
||||
|
||||
## [1.12.0] — 2026-04-27
|
||||
|
||||
### Fixed
|
||||
- **Panic on every keyboard-interactive auth failure**
|
||||
(`called after complete` at `tokio/src/sync/oneshot.rs:1289`).
|
||||
After the upstream auth driver finished, `auth_keyboard_interactive`
|
||||
consumed `outcome_rx` via `tokio::select!` but left the spent
|
||||
`UpstreamAuthState` parked in `self.upstream_auth`. The next round of
|
||||
KI — which SSH retries after a rejected attempt — re-polled the
|
||||
already-completed oneshot receiver and panicked. The handler now
|
||||
clears `self.upstream_auth` whenever the driver has terminated, so
|
||||
any retry spawns a fresh driver. Same cleanup now also runs on the
|
||||
`responses_tx` send-failure branch, where the driver had gone away.
|
||||
|
||||
## [1.11.0] — 2026-04-27
|
||||
|
||||
### Added
|
||||
- **`ssh-mux import-config`** — derive `ssh-mux-routes.toml` from an
|
||||
existing OpenSSH client config. Literal `Host` aliases are
|
||||
enumerated and resolved with `ssh -G <alias>`, so `Match`,
|
||||
`Include`, and `Host *` inheritance are handled by OpenSSH itself
|
||||
rather than a hand-rolled parser. The source SSH config is **never
|
||||
modified**. Flags: `--from`, `--out`, `--write`, `--force`,
|
||||
`--ssh-bin`. Default is dry-run (TOML printed to stdout). Multiple
|
||||
distinct ProxyJump targets are resolved by majority vote, with the
|
||||
minority routes skipped and reported.
|
||||
|
||||
### Changed
|
||||
- **`setup-config` no longer mutates user `Host` blocks.** The
|
||||
`Include ssh-mux-hosts.conf` line is still prepended to
|
||||
`~/.ssh/config`, but conflicting blocks are reported in stdout and
|
||||
left untouched. Each generated block now sets `ProxyJump none` and
|
||||
`ProxyCommand none` to suppress bleed-through of those directives
|
||||
from a later-matching user block (OpenSSH applies first-match-wins
|
||||
per option). Other accumulating options such as `LocalForward` /
|
||||
`RemoteForward` may still apply from the user's block — review on
|
||||
alias overlap.
|
||||
- **Idle timer freezes while a connection has open channels.**
|
||||
Previously, `last_used` was only refreshed on channel open/close,
|
||||
so a long-running tmux/SSH session over a single pooled channel
|
||||
was reaped exactly `--timeout` seconds after the channel opened,
|
||||
regardless of activity. Active channels now keep the connection
|
||||
alive; truly dead remotes are caught by russh keepalive (~45 s)
|
||||
which closes the handle and trips the existing `is_closed()`
|
||||
branch, and `--max-lifetime` still enforces an absolute cap.
|
||||
|
||||
### Fixed
|
||||
- Relay task panics no longer leak the channel counter
|
||||
(`active_channels`) — an RAII `ChannelCloseGuard` runs
|
||||
`pool.channel_closed*` on every exit path, including unwinding.
|
||||
Required because `cleanup_idle` no longer reaps connections with
|
||||
`active_channels > 0`; without the guard a leaked counter would
|
||||
pin a connection in the pool indefinitely.
|
||||
|
||||
[1.13.3]: https://git.teahaven.kr/Rust-related/ssh-mux/compare/v1.13.2...v1.13.3
|
||||
[1.13.2]: https://git.teahaven.kr/Rust-related/ssh-mux/compare/v1.13.1...v1.13.2
|
||||
[1.13.1]: https://git.teahaven.kr/Rust-related/ssh-mux/compare/v1.13.0...v1.13.1
|
||||
[1.13.0]: https://git.teahaven.kr/Rust-related/ssh-mux/compare/v1.12.1...v1.13.0
|
||||
[1.12.1]: https://git.teahaven.kr/Rust-related/ssh-mux/compare/v1.12.0...v1.12.1
|
||||
[1.12.0]: https://git.teahaven.kr/Rust-related/ssh-mux/compare/v1.11.0...v1.12.0
|
||||
[1.11.0]: https://git.teahaven.kr/Rust-related/ssh-mux/compare/v1.10.5...v1.11.0
|
||||
3025
Cargo.lock
generated
Normal file
3025
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ssh-mux"
|
||||
version = "1.10.4"
|
||||
version = "1.14.1"
|
||||
edition = "2024"
|
||||
description = "SSH connection multiplexer for Windows - ControlMaster alternative"
|
||||
|
||||
|
||||
71
README.md
71
README.md
@@ -40,19 +40,18 @@ git config core.hooksPath hooks
|
||||
## Quick start
|
||||
|
||||
```powershell
|
||||
# 1. Create a routes config
|
||||
notepad ~/.ssh/ssh-mux-routes.toml
|
||||
|
||||
# 2. Generate SSH config entries
|
||||
ssh-mux setup-config
|
||||
|
||||
# 3. Install as a background service (starts immediately)
|
||||
# 1. One-shot bootstrap: import existing ~/.ssh/config into routes.toml,
|
||||
# generate ssh-mux-hosts.conf + Include line, install service.
|
||||
ssh-mux install
|
||||
|
||||
# 4. Connect
|
||||
# 2. Connect
|
||||
ssh webserver
|
||||
```
|
||||
|
||||
`ssh-mux install` chains `import-config` → `setup-config` → service install. If `~/.ssh/ssh-mux-routes.toml` does not exist it is derived from `~/.ssh/config` via `ssh -G <alias>`. If you already have a `routes.toml` (hand-written or from a previous run) it is reused as-is — pass `ssh-mux import-config --write --force` to re-import.
|
||||
|
||||
The standalone `import-config` and `setup-config` subcommands remain available for step-by-step or re-run use; see [Configuration](#configuration) below.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Routes file (`~/.ssh/ssh-mux-routes.toml`)
|
||||
@@ -78,15 +77,26 @@ host = "203.0.113.50"
|
||||
port = 22
|
||||
user = "ops"
|
||||
direct = true # bypasses the jump host
|
||||
|
||||
[routes.devbox]
|
||||
host = "10.0.0.30"
|
||||
port = 22
|
||||
user = "dev"
|
||||
```
|
||||
|
||||
Per-route options:
|
||||
|
||||
- `direct = true` — bypass the jump host and connect directly.
|
||||
|
||||
`direct-tcpip` channel policy: an authenticated route accepts channels to its own `host:port`, plus **any port** on the route's remote loopback (`127.0.0.1` / `localhost` / `::1`) so VS Code / Cursor Remote-SSH can reach their IDE server's random localhost port. Non-loopback non-route targets are denied so the route can't be used to pivot into other internal hosts. (The v1.13.4 `allow_remote_loopback_any_port` opt-in and v1.13.5 top-level default were removed in v1.14.0; existing config files keep parsing — those fields are now ignored.)
|
||||
|
||||
### Auto-generate SSH config
|
||||
|
||||
```
|
||||
ssh-mux setup-config
|
||||
```
|
||||
|
||||
Reads `~/.ssh/ssh-mux-routes.toml`, generates `~/.ssh/ssh-mux-hosts.conf` with a `Host` entry for each route, and adds `Include ssh-mux-hosts.conf` to `~/.ssh/config`. Conflicting existing `Host` blocks are automatically commented out.
|
||||
Reads `~/.ssh/ssh-mux-routes.toml`, generates `~/.ssh/ssh-mux-hosts.conf` with a `Host` entry for each route, and prepends `Include ssh-mux-hosts.conf` to `~/.ssh/config`. **Existing `Host` blocks in `~/.ssh/config` are not modified.** OpenSSH applies first-match-wins per option, so the included file (loaded first) overrides `HostName`/`Port`/`User` for matching aliases, and each generated block sets `ProxyJump none` and `ProxyCommand none` to suppress bleed-through of those directives from later-matching user blocks. Other accumulating options (e.g. `LocalForward`, `RemoteForward`) from your existing block can still apply — `setup-config` prints a warning listing aliases that overlap so you can review them.
|
||||
|
||||
Options:
|
||||
|
||||
@@ -94,15 +104,44 @@ Options:
|
||||
ssh-mux setup-config --config /path/to/routes.toml -p 2222
|
||||
```
|
||||
|
||||
### Import an existing SSH config
|
||||
|
||||
If you already have working `Host` aliases in `~/.ssh/config`, you can derive a `routes.toml` from them instead of writing one by hand:
|
||||
|
||||
```
|
||||
ssh-mux import-config # dry-run — prints generated TOML to stdout
|
||||
ssh-mux import-config --write # writes ~/.ssh/ssh-mux-routes.toml
|
||||
```
|
||||
|
||||
Each literal `Host` alias (wildcards and `Match` blocks are skipped) is resolved with `ssh -G <alias>`, so `Match`, `Include`, and `Host *` inheritance are handled by OpenSSH itself. The source SSH config is **never modified** — only read.
|
||||
|
||||
`ProxyJump` is mapped to the single `[jump]` section: if multiple distinct jump hosts are detected, the most common one is selected and routes via others are skipped with a warning. Hosts without `ProxyJump` become `direct = true` routes.
|
||||
|
||||
Options:
|
||||
|
||||
```
|
||||
ssh-mux import-config --from /path/to/config --out /path/to/routes.toml --write --force
|
||||
ssh-mux import-config --ssh-bin C:\Windows\System32\OpenSSH\ssh.exe
|
||||
```
|
||||
|
||||
Note: `Match exec` directives in the source config will execute as part of `ssh -G` resolution. This is the same behavior `ssh <alias>` would have; no new privilege boundary is crossed.
|
||||
|
||||
**Hosts driven by `ProxyCommand` are skipped** (e.g. AWS SSM `start-session`, `Match host i-* / ProxyCommand aws ssm …`). ssh-mux only drives raw SSH over TCP or via a jump-host channel — it cannot exec custom transport processes. Such hosts stay in `~/.ssh/config` and `ssh.exe` keeps using them via the original `ProxyCommand` (`Include ssh-mux-hosts.conf` does not contain those aliases, so OpenSSH's first-match-wins falls through). Same exclusion applies when the jump host itself uses `ProxyCommand`.
|
||||
|
||||
## Commands
|
||||
|
||||
### `ssh-mux install`
|
||||
|
||||
Installs ssh-mux as a background service:
|
||||
One-shot bootstrap pipeline:
|
||||
|
||||
1. Copies the binary to `~/.ssh/ssh-mux.exe`
|
||||
2. Creates a VBScript in the Windows Startup folder (resolved via Win32 Known Folder API, resistant to `%APPDATA%` poisoning) for auto-start at logon
|
||||
3. Starts the server immediately via Rust `CreateProcess` with `CREATE_NO_WINDOW` (no PowerShell command-line injection surface)
|
||||
1. **import-config** — if `~/.ssh/ssh-mux-routes.toml` does not exist, derive it from `~/.ssh/config` via `ssh -G <alias>`. If the file already exists it is reused (use `ssh-mux import-config --write --force` to re-import explicitly).
|
||||
2. **setup-config** — generate `~/.ssh/ssh-mux-hosts.conf`, prepend `Include ssh-mux-hosts.conf` to `~/.ssh/config` (existing `Host` blocks are not modified), and pre-register the host key in `ssh-mux-known-hosts`.
|
||||
3. **service install** (Windows only) —
|
||||
1. Copies the binary to `~/.ssh/ssh-mux.exe`
|
||||
2. Creates a VBScript in the Windows Startup folder (resolved via Win32 Known Folder API, resistant to `%APPDATA%` poisoning) for auto-start at logon
|
||||
3. Starts the server immediately via Rust `CreateProcess` with `CREATE_NO_WINDOW` (no PowerShell command-line injection surface)
|
||||
|
||||
On non-Windows platforms steps 1–2 still run; step 3 is skipped and the manual `ssh-mux daemon …` invocation is printed instead.
|
||||
|
||||
```
|
||||
ssh-mux install
|
||||
@@ -199,6 +238,8 @@ One key per line, same format as `authorized_keys`. If this file does not exist,
|
||||
- All config fields (host, user) reject newline, carriage return, null, and leading/trailing whitespace
|
||||
- Config files and known_hosts written atomically (CSPRNG-random temp file + `O_EXCL` + fsync + rename) to prevent partial writes and TOCTOU attacks
|
||||
- Symlink targets rejected on all write paths
|
||||
- `setup-config` is purely additive on `~/.ssh/config`: it prepends a single `Include` line and never edits, comments out, or removes existing user content. Conflicts are reported but not mutated. `import-config` is read-only on the source SSH config — it derives `routes.toml` via `ssh -G` without writing back.
|
||||
- `import-config` validates each enumerated alias against the route-name regex before passing it to `ssh -G`, and rejects any alias starting with `-` to prevent option injection on the ssh CLI
|
||||
|
||||
### IPC security (Windows)
|
||||
|
||||
@@ -235,9 +276,9 @@ One key per line, same format as `authorized_keys`. If this file does not exist,
|
||||
|
||||
- SSH keepalive enabled on all connections (client and local server): 15-second interval, 3 missed replies before disconnect
|
||||
- Dead connections (keepalive timeout, remote close) are automatically reaped every 30 seconds
|
||||
- Idle connections are cleaned up after `--timeout` seconds, even if zombie channels remain from unclean client disconnects
|
||||
- Idle connections are cleaned up after `--timeout` seconds **only when no channels are open**. Active channels freeze the idle timer regardless of byte traffic on the relay — keepalive (~45s) detects truly dead remotes, and an RAII guard around the relay task ensures the channel counter cannot leak even if the relay panics. Earlier versions reaped connections after `--timeout` whenever the pool's last-event timestamp was stale, which silently severed long-running tmux/SSH sessions exactly `--timeout` seconds after the channel opened
|
||||
- Jump host connections are kept alive as long as any dependent via-jump connection is still active (has channels or recent activity), preventing premature reaping that would sever tunnelled sessions
|
||||
- Optional `--max-lifetime` enforces an absolute cap on connection age
|
||||
- Optional `--max-lifetime` enforces an absolute cap on connection age (overrides the active-channels rule)
|
||||
|
||||
### File integrity
|
||||
|
||||
|
||||
53
src/cli.rs
53
src/cli.rs
@@ -153,10 +153,57 @@ pub enum Command {
|
||||
listen_port: u16,
|
||||
},
|
||||
|
||||
/// Install ssh-mux as a background service
|
||||
/// Import an existing OpenSSH client config into routes.toml.
|
||||
///
|
||||
/// Copies the exe to ~/.ssh/ssh-mux.exe, creates a startup script
|
||||
/// that runs `ssh-mux serve` at logon, and starts it immediately.
|
||||
/// Reads `~/.ssh/config` (or `--from`), enumerates literal Host
|
||||
/// aliases (wildcards and Match blocks are skipped), and resolves
|
||||
/// each via `ssh -G <alias>` so that Match/Include/Host * inheritance
|
||||
/// is applied by OpenSSH itself.
|
||||
///
|
||||
/// The source SSH config is **never modified**. By default the
|
||||
/// generated TOML is printed to stdout (dry-run); pass `--write`
|
||||
/// to save it to `~/.ssh/ssh-mux-routes.toml`.
|
||||
ImportConfig {
|
||||
/// Source SSH config file (default: ~/.ssh/config)
|
||||
#[arg(long)]
|
||||
from: Option<String>,
|
||||
|
||||
/// Output path for the generated routes.toml
|
||||
/// (default: ~/.ssh/ssh-mux-routes.toml)
|
||||
#[arg(long)]
|
||||
out: Option<String>,
|
||||
|
||||
/// Actually write the output file (default is dry-run to stdout)
|
||||
#[arg(long)]
|
||||
write: bool,
|
||||
|
||||
/// Overwrite the output file if it already exists
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
|
||||
/// Path to the ssh binary used for resolution
|
||||
/// (default: Win32-OpenSSH on Windows, /usr/bin/ssh elsewhere)
|
||||
#[arg(long)]
|
||||
ssh_bin: Option<String>,
|
||||
},
|
||||
|
||||
/// One-shot bootstrap: import-config → setup-config → install service
|
||||
///
|
||||
/// Runs the full pipeline:
|
||||
/// 1. If `~/.ssh/ssh-mux-routes.toml` does not exist, derives it
|
||||
/// from `~/.ssh/config` via `ssh-mux import-config`. If the
|
||||
/// file already exists, it is reused as-is (use
|
||||
/// `ssh-mux import-config --write --force` to re-import).
|
||||
/// 2. Runs `setup-config` to generate `ssh-mux-hosts.conf`,
|
||||
/// prepend the `Include` line to `~/.ssh/config`, and
|
||||
/// pre-register the host key.
|
||||
/// 3. (Windows) copies the exe to `~/.ssh/ssh-mux.exe`, creates
|
||||
/// a Startup-folder VBS that runs the daemon at logon, and
|
||||
/// starts it immediately. (Other platforms) prints the
|
||||
/// manual `ssh-mux daemon …` invocation.
|
||||
///
|
||||
/// The standalone `import-config` and `setup-config` commands
|
||||
/// remain available for explicit re-runs.
|
||||
Install {
|
||||
/// Path to routes config file (default: ~/.ssh/ssh-mux-routes.toml)
|
||||
#[arg(short, long)]
|
||||
|
||||
358
src/config.rs
358
src/config.rs
@@ -150,7 +150,7 @@ fn validate_route_name(name: &str) -> Result<()> {
|
||||
}
|
||||
|
||||
/// Validate all fields in the loaded config to prevent SSH config injection.
|
||||
fn validate_config_values(config: &MuxConfig) -> Result<()> {
|
||||
pub(crate) fn validate_config_values(config: &MuxConfig) -> Result<()> {
|
||||
validate_config_field("jump.host", &config.jump.host)?;
|
||||
validate_config_field("jump.user", &config.jump.user)?;
|
||||
|
||||
@@ -204,7 +204,7 @@ pub fn load_config(path: &Path) -> Result<MuxConfig> {
|
||||
|
||||
/// Write content to a file atomically: write to a temp file, then rename.
|
||||
/// Rejects symlink targets and sets restrictive permissions.
|
||||
fn atomic_write(target: &Path, content: &[u8]) -> Result<()> {
|
||||
pub(crate) fn atomic_write(target: &Path, content: &[u8]) -> Result<()> {
|
||||
use std::io::Write;
|
||||
|
||||
if target.exists() {
|
||||
@@ -252,6 +252,102 @@ fn atomic_write(target: &Path, content: &[u8]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Render the body of `ssh-mux-hosts.conf` for a given config.
|
||||
///
|
||||
/// Pure: takes no I/O, no global state. Tested in isolation. Each generated
|
||||
/// `Host` block sets `ProxyJump none` and `ProxyCommand none` to suppress
|
||||
/// option bleed-through from later-matching user blocks with the same alias.
|
||||
fn render_hosts_conf(
|
||||
config: &MuxConfig,
|
||||
listen_port: u16,
|
||||
ssh_dir: &Path,
|
||||
strict_host_key: &str,
|
||||
) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str("# Auto-generated by ssh-mux setup-config. Do not edit.\n\n");
|
||||
|
||||
let mut route_names: Vec<&String> = config.routes.keys().collect();
|
||||
route_names.sort();
|
||||
|
||||
let known_hosts_abs = ssh_dir.join("ssh-mux-known-hosts");
|
||||
|
||||
for name in &route_names {
|
||||
let route = &config.routes[*name];
|
||||
out.push_str(&format!("Host {}\n", name));
|
||||
out.push_str(" HostName 127.0.0.1\n");
|
||||
out.push_str(&format!(" Port {}\n", listen_port));
|
||||
out.push_str(&format!(" User {}\n", name));
|
||||
out.push_str(" IdentitiesOnly yes\n");
|
||||
out.push_str(&format!(" StrictHostKeyChecking {}\n", strict_host_key));
|
||||
out.push_str(&format!(
|
||||
" UserKnownHostsFile {}\n",
|
||||
known_hosts_abs.display()
|
||||
));
|
||||
out.push_str(" HostKeyAlias ssh-mux-local\n");
|
||||
// Suppress option bleed-through from later-matching user blocks with
|
||||
// the same alias. OpenSSH applies first-match-wins per option, so any
|
||||
// option ssh-mux does not set here would inherit from the user's
|
||||
// existing Host block. ProxyJump/ProxyCommand bleed would silently
|
||||
// break the localhost connection — explicitly disable both.
|
||||
out.push_str(" ProxyJump none\n");
|
||||
out.push_str(" ProxyCommand none\n");
|
||||
// publickey + keyboard-interactive: ssh-mux forwards upstream OTP
|
||||
// prompts to the connecting SSH client via the KI method, so the
|
||||
// user types their OTP in the app that launched ssh (terminal /
|
||||
// Cursor / VS Code) instead of a separate PowerShell window.
|
||||
out.push_str(" PreferredAuthentications publickey,keyboard-interactive\n");
|
||||
out.push_str(" PasswordAuthentication no\n");
|
||||
out.push_str(" KbdInteractiveAuthentication yes\n");
|
||||
out.push_str(" NumberOfPasswordPrompts 3\n");
|
||||
out.push_str(&format!(
|
||||
" # -> {}@{}:{}{}\n",
|
||||
route.user,
|
||||
route.host,
|
||||
route.port,
|
||||
if route.direct {
|
||||
" (direct)"
|
||||
} else {
|
||||
" (via jump)"
|
||||
},
|
||||
));
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Find Host aliases in an existing SSH config that overlap with the given
|
||||
/// route names. Pure: no I/O. The user's content is never modified.
|
||||
fn find_conflicting_aliases(content: &str, route_names: &[&str]) -> Vec<String> {
|
||||
let route_set: std::collections::HashSet<&str> = route_names.iter().copied().collect();
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(rest) = trimmed.strip_prefix("Host ") {
|
||||
for host_name in rest.split_whitespace() {
|
||||
if route_set.contains(host_name) && !out.iter().any(|h| h == host_name) {
|
||||
out.push(host_name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Prepend an Include directive to existing SSH config content if it is not
|
||||
/// already present. Pure and additive: no existing line is modified or removed.
|
||||
/// Returns `None` if the include line is already present.
|
||||
fn prepend_include_if_absent(content: &str, include_line: &str) -> Option<String> {
|
||||
if content.lines().any(|line| line.trim() == include_line) {
|
||||
return None;
|
||||
}
|
||||
let mut result = String::with_capacity(content.len() + include_line.len() + 2);
|
||||
result.push_str(include_line);
|
||||
result.push_str("\n\n");
|
||||
result.push_str(content);
|
||||
Some(result)
|
||||
}
|
||||
|
||||
/// Generate `~/.ssh/ssh-mux-hosts.conf` from the loaded config and ensure
|
||||
/// `~/.ssh/config` includes it.
|
||||
///
|
||||
@@ -288,48 +384,10 @@ pub fn setup_ssh_config(config: &MuxConfig, listen_port: u16) -> Result<()> {
|
||||
"accept-new"
|
||||
};
|
||||
|
||||
let mut out = String::new();
|
||||
out.push_str("# Auto-generated by ssh-mux setup-config. Do not edit.\n\n");
|
||||
|
||||
let mut route_names: Vec<&String> = config.routes.keys().collect();
|
||||
route_names.sort();
|
||||
|
||||
for name in &route_names {
|
||||
let route = &config.routes[*name];
|
||||
out.push_str(&format!("Host {}\n", name));
|
||||
out.push_str(" HostName 127.0.0.1\n");
|
||||
out.push_str(&format!(" Port {}\n", listen_port));
|
||||
out.push_str(&format!(" User {}\n", name));
|
||||
out.push_str(" IdentitiesOnly yes\n");
|
||||
out.push_str(&format!(" StrictHostKeyChecking {}\n", strict_host_key));
|
||||
let known_hosts_abs = ssh_dir.join("ssh-mux-known-hosts");
|
||||
out.push_str(&format!(
|
||||
" UserKnownHostsFile {}\n",
|
||||
known_hosts_abs.display()
|
||||
));
|
||||
out.push_str(" HostKeyAlias ssh-mux-local\n");
|
||||
// publickey + keyboard-interactive: ssh-mux forwards upstream OTP
|
||||
// prompts to the connecting SSH client via the KI method, so the
|
||||
// user types their OTP in the app that launched ssh (terminal /
|
||||
// Cursor / VS Code) instead of a separate PowerShell window.
|
||||
out.push_str(" PreferredAuthentications publickey,keyboard-interactive\n");
|
||||
out.push_str(" PasswordAuthentication no\n");
|
||||
out.push_str(" KbdInteractiveAuthentication yes\n");
|
||||
out.push_str(" NumberOfPasswordPrompts 3\n");
|
||||
out.push_str(&format!(
|
||||
" # -> {}@{}:{}{}\n",
|
||||
route.user,
|
||||
route.host,
|
||||
route.port,
|
||||
if route.direct {
|
||||
" (direct)"
|
||||
} else {
|
||||
" (via jump)"
|
||||
},
|
||||
));
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
let out = render_hosts_conf(config, listen_port, &ssh_dir, strict_host_key);
|
||||
atomic_write(&hosts_conf, out.as_bytes())?;
|
||||
|
||||
println!(
|
||||
@@ -338,74 +396,48 @@ pub fn setup_ssh_config(config: &MuxConfig, listen_port: u16) -> Result<()> {
|
||||
route_names.len()
|
||||
);
|
||||
|
||||
// Ensure ~/.ssh/config includes the generated file, and comment out
|
||||
// any existing Host blocks that conflict with our route names.
|
||||
let include_line = "Include ssh-mux-hosts.conf".to_string();
|
||||
// Ensure ~/.ssh/config has the Include line at the top so ssh-mux-hosts.conf
|
||||
// entries are evaluated first (OpenSSH applies first-match-wins per option).
|
||||
// Existing Host blocks are NOT modified — they remain in place and are
|
||||
// simply shadowed for matching aliases. We detect overlapping aliases and
|
||||
// print an informational warning, but never edit user content.
|
||||
let include_line = "Include ssh-mux-hosts.conf";
|
||||
if ssh_config.exists() {
|
||||
let content = std::fs::read_to_string(&ssh_config)
|
||||
.with_context(|| format!("cannot read {}", ssh_config.display()))?;
|
||||
|
||||
let route_set: std::collections::HashSet<&str> =
|
||||
route_names.iter().map(|s| s.as_str()).collect();
|
||||
let route_name_refs: Vec<&str> = route_names.iter().map(|s| s.as_str()).collect();
|
||||
let conflicting_hosts = find_conflicting_aliases(&content, &route_name_refs);
|
||||
|
||||
let mut new_lines: Vec<String> = Vec::new();
|
||||
let mut commenting_out = false;
|
||||
let mut commented_hosts: Vec<String> = Vec::new();
|
||||
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
if let Some(rest) = trimmed.strip_prefix("Host ") {
|
||||
let host_name = rest.split_whitespace().next().unwrap_or("");
|
||||
if route_set.contains(host_name) {
|
||||
commenting_out = true;
|
||||
commented_hosts.push(host_name.to_string());
|
||||
new_lines.push(format!("# [ssh-mux] {}", line));
|
||||
continue;
|
||||
} else {
|
||||
commenting_out = false;
|
||||
}
|
||||
} else if commenting_out {
|
||||
if trimmed.is_empty()
|
||||
|| trimmed.starts_with("Host ")
|
||||
|| trimmed.starts_with("Match ")
|
||||
{
|
||||
commenting_out = false;
|
||||
} else {
|
||||
new_lines.push(format!("# [ssh-mux] {}", line));
|
||||
continue;
|
||||
}
|
||||
if !conflicting_hosts.is_empty() {
|
||||
println!(
|
||||
"\nNote: existing Host blocks in {} share aliases with generated routes:",
|
||||
ssh_config.display()
|
||||
);
|
||||
for h in &conflicting_hosts {
|
||||
println!(" - {}", h);
|
||||
}
|
||||
|
||||
new_lines.push(line.to_string());
|
||||
}
|
||||
|
||||
for h in &commented_hosts {
|
||||
println!(
|
||||
"commented out existing Host {} in {}",
|
||||
h,
|
||||
ssh_config.display()
|
||||
"These blocks are left untouched. ssh-mux-hosts.conf is included \
|
||||
first so its values win for HostName/Port/User; ProxyJump and \
|
||||
ProxyCommand are explicitly set to 'none' to block bleed-through. \
|
||||
Other accumulating options (e.g. LocalForward) from your existing \
|
||||
blocks may still apply — review them if behavior looks off."
|
||||
);
|
||||
}
|
||||
|
||||
let mut result = new_lines.join("\n");
|
||||
if !content.ends_with('\n') {
|
||||
// preserve original ending
|
||||
} else if !result.ends_with('\n') {
|
||||
result.push('\n');
|
||||
match prepend_include_if_absent(&content, include_line) {
|
||||
None => {
|
||||
println!(
|
||||
"{} already includes ssh-mux-hosts.conf",
|
||||
ssh_config.display()
|
||||
);
|
||||
}
|
||||
Some(result) => {
|
||||
atomic_write(&ssh_config, result.as_bytes())?;
|
||||
println!("added '{}' to {}", include_line, ssh_config.display());
|
||||
}
|
||||
}
|
||||
|
||||
if !result.contains(&include_line) {
|
||||
result = format!("{}\n\n{}", include_line, result);
|
||||
println!("added '{}' to {}", include_line, ssh_config.display());
|
||||
} else {
|
||||
println!(
|
||||
"{} already includes ssh-mux-hosts.conf",
|
||||
ssh_config.display()
|
||||
);
|
||||
}
|
||||
|
||||
atomic_write(&ssh_config, result.as_bytes())?;
|
||||
} else {
|
||||
atomic_write(&ssh_config, format!("{}\n", include_line).as_bytes())?;
|
||||
println!("created {} with '{}'", ssh_config.display(), include_line);
|
||||
@@ -719,6 +751,136 @@ user = "deploy\nProxyCommand evil"
|
||||
assert!(validate_config_values(&config).is_err());
|
||||
}
|
||||
|
||||
fn sample_config() -> MuxConfig {
|
||||
let toml_str = r#"
|
||||
[jump]
|
||||
host = "bastion.example.com"
|
||||
port = 22
|
||||
user = "jumpuser"
|
||||
|
||||
[routes.webserver]
|
||||
host = "10.0.0.10"
|
||||
port = 22
|
||||
user = "deploy"
|
||||
|
||||
[routes.external]
|
||||
host = "203.0.113.50"
|
||||
port = 22
|
||||
user = "ops"
|
||||
direct = true
|
||||
"#;
|
||||
toml::from_str(toml_str).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_hosts_conf_includes_proxyjump_and_proxycommand_none() {
|
||||
let cfg = sample_config();
|
||||
let body = render_hosts_conf(&cfg, 2222, Path::new("/tmp/.ssh"), "yes");
|
||||
// Each route block must explicitly null out these directives so that
|
||||
// a later-matching user block cannot bleed its ProxyJump through.
|
||||
let webserver_block: String = body
|
||||
.lines()
|
||||
.skip_while(|l| !l.starts_with("Host webserver"))
|
||||
.take_while(|l| !l.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(
|
||||
webserver_block.contains("ProxyJump none"),
|
||||
"missing ProxyJump none in:\n{}",
|
||||
webserver_block
|
||||
);
|
||||
assert!(
|
||||
webserver_block.contains("ProxyCommand none"),
|
||||
"missing ProxyCommand none in:\n{}",
|
||||
webserver_block
|
||||
);
|
||||
|
||||
let external_block: String = body
|
||||
.lines()
|
||||
.skip_while(|l| !l.starts_with("Host external"))
|
||||
.take_while(|l| !l.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(external_block.contains("ProxyJump none"));
|
||||
assert!(external_block.contains("ProxyCommand none"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_hosts_conf_sorts_routes_alphabetically() {
|
||||
let cfg = sample_config();
|
||||
let body = render_hosts_conf(&cfg, 2222, Path::new("/tmp/.ssh"), "yes");
|
||||
let external_pos = body.find("Host external").unwrap();
|
||||
let webserver_pos = body.find("Host webserver").unwrap();
|
||||
assert!(external_pos < webserver_pos);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_conflicting_aliases_detects_overlap_only_in_host_lines() {
|
||||
let user_config = "
|
||||
Host webserver
|
||||
HostName 1.2.3.4
|
||||
User old
|
||||
|
||||
Host other
|
||||
HostName 5.6.7.8
|
||||
|
||||
# webserver appears in this comment but should not match
|
||||
Host external db1
|
||||
HostName 9.10.11.12
|
||||
";
|
||||
let routes = ["webserver", "external"];
|
||||
let conflicts = find_conflicting_aliases(user_config, &routes);
|
||||
assert_eq!(
|
||||
conflicts,
|
||||
vec!["webserver".to_string(), "external".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_conflicting_aliases_dedupes() {
|
||||
let user_config = "
|
||||
Host webserver
|
||||
HostName x
|
||||
|
||||
Host webserver
|
||||
HostName y
|
||||
";
|
||||
let conflicts = find_conflicting_aliases(user_config, &["webserver"]);
|
||||
assert_eq!(conflicts, vec!["webserver".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepend_include_only_adds_when_absent() {
|
||||
let original = "Host server\n HostName 1.2.3.4\n";
|
||||
let result = prepend_include_if_absent(original, "Include ssh-mux-hosts.conf").unwrap();
|
||||
// Original content must be present verbatim (no edits).
|
||||
assert!(result.contains(original));
|
||||
// Include must come first.
|
||||
assert!(result.starts_with("Include ssh-mux-hosts.conf"));
|
||||
// No `# [ssh-mux]` markers — the user's blocks are untouched.
|
||||
assert!(!result.contains("# [ssh-mux]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepend_include_idempotent_when_already_present() {
|
||||
let original = "Include ssh-mux-hosts.conf\n\nHost server\n HostName 1.2.3.4\n";
|
||||
assert!(prepend_include_if_absent(original, "Include ssh-mux-hosts.conf").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepend_include_does_not_mutate_existing_host_blocks() {
|
||||
// Regression guard: setup-config used to comment out conflicting Host
|
||||
// blocks with `# [ssh-mux] ` prefixes. That behavior was removed —
|
||||
// the user's existing definitions must remain untouched, even when
|
||||
// they share an alias with a generated route.
|
||||
let original = "Host webserver\n HostName user-original\n User olduser\n";
|
||||
let result = prepend_include_if_absent(original, "Include ssh-mux-hosts.conf").unwrap();
|
||||
assert!(result.contains("Host webserver"));
|
||||
assert!(result.contains("HostName user-original"));
|
||||
assert!(result.contains("User olduser"));
|
||||
assert!(!result.contains("# [ssh-mux]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_rejects_injection_in_jump_user() {
|
||||
let toml_str = "
|
||||
|
||||
@@ -645,11 +645,9 @@ async fn relay_session(
|
||||
stream_write.write_all(&data).await?;
|
||||
stream_write.flush().await?;
|
||||
}
|
||||
Some(russh::ChannelMsg::ExtendedData { data, ext }) => {
|
||||
if ext == 1 {
|
||||
stream_write.write_all(&data).await?;
|
||||
stream_write.flush().await?;
|
||||
}
|
||||
Some(russh::ChannelMsg::ExtendedData { data, ext: 1 }) => {
|
||||
stream_write.write_all(&data).await?;
|
||||
stream_write.flush().await?;
|
||||
}
|
||||
Some(russh::ChannelMsg::ExitStatus { exit_status }) => {
|
||||
tracing::debug!("exit status {} for session {}:{}", exit_status, host, port);
|
||||
|
||||
1014
src/import.rs
Normal file
1014
src/import.rs
Normal file
File diff suppressed because it is too large
Load Diff
126
src/ipc.rs
126
src/ipc.rs
@@ -501,6 +501,7 @@ mod platform {
|
||||
#[cfg(not(windows))]
|
||||
mod platform {
|
||||
use super::*;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::net::{UnixListener, UnixStream};
|
||||
|
||||
/// Get the socket path for the current user.
|
||||
@@ -509,13 +510,14 @@ mod platform {
|
||||
/// 1. `$XDG_RUNTIME_DIR/ssh-mux.sock` (typically `/run/user/{uid}`, already 0700)
|
||||
/// 2. `~/.ssh/ssh-mux.sock` (home directory, user-owned)
|
||||
/// 3. `/tmp/ssh-mux-{uid}/ssh-mux.sock` (last resort, in a 0700 subdirectory)
|
||||
fn socket_path() -> Result<std::path::PathBuf> {
|
||||
fn socket_path() -> Result<PathBuf> {
|
||||
let uid = unsafe { libc::getuid() };
|
||||
|
||||
// 1. Try XDG_RUNTIME_DIR (most secure, already 0700)
|
||||
// 1. Try XDG_RUNTIME_DIR, but only if it is actually private to this user.
|
||||
if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
|
||||
let dir = std::path::PathBuf::from(runtime_dir);
|
||||
let dir = PathBuf::from(runtime_dir);
|
||||
if dir.is_dir() {
|
||||
validate_runtime_dir(&dir, uid)?;
|
||||
return Ok(dir.join("ssh-mux.sock"));
|
||||
}
|
||||
}
|
||||
@@ -528,7 +530,7 @@ mod platform {
|
||||
}
|
||||
|
||||
// 3. Fallback: /tmp/ssh-mux-{uid}/ with 0700 subdirectory (fail-closed)
|
||||
let fallback_dir = std::path::PathBuf::from(format!("/tmp/ssh-mux-{}", uid));
|
||||
let fallback_dir = PathBuf::from(format!("/tmp/ssh-mux-{}", uid));
|
||||
if fallback_dir.exists() {
|
||||
validate_fallback_dir(&fallback_dir, uid)?;
|
||||
} else {
|
||||
@@ -550,7 +552,7 @@ mod platform {
|
||||
/// Validate that an existing fallback directory is safe to use (fail-closed).
|
||||
/// Returns Err if the directory is a symlink, owned by another user, or
|
||||
/// has permissions other than 0700 that cannot be fixed.
|
||||
fn validate_fallback_dir(dir: &std::path::Path, expected_uid: u32) -> Result<()> {
|
||||
fn validate_fallback_dir(dir: &Path, expected_uid: u32) -> Result<()> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
let meta = std::fs::symlink_metadata(dir)
|
||||
@@ -589,6 +591,52 @@ mod platform {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate `XDG_RUNTIME_DIR` before trusting it for the daemon socket.
|
||||
///
|
||||
/// The XDG Base Directory spec requires a user-owned 0700 directory.
|
||||
/// Reject looser permissions or symlinked paths to avoid daemon impersonation
|
||||
/// and client secret capture via a hostile runtime directory.
|
||||
fn validate_runtime_dir(dir: &Path, expected_uid: u32) -> Result<()> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
let meta = std::fs::symlink_metadata(dir)
|
||||
.with_context(|| format!("SECURITY: cannot stat {}", dir.display()))?;
|
||||
|
||||
if meta.file_type().is_symlink() {
|
||||
anyhow::bail!(
|
||||
"SECURITY: XDG_RUNTIME_DIR {} is a symlink. Refusing to trust redirected IPC paths.",
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
if !meta.is_dir() {
|
||||
anyhow::bail!(
|
||||
"SECURITY: XDG_RUNTIME_DIR {} is not a directory.",
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
if meta.uid() != expected_uid {
|
||||
anyhow::bail!(
|
||||
"SECURITY: XDG_RUNTIME_DIR {} is owned by uid {} (expected {}).",
|
||||
dir.display(),
|
||||
meta.uid(),
|
||||
expected_uid
|
||||
);
|
||||
}
|
||||
|
||||
let mode = meta.mode() & 0o7777;
|
||||
if mode & 0o077 != 0 {
|
||||
anyhow::bail!(
|
||||
"SECURITY: XDG_RUNTIME_DIR {} has unsafe permissions {:04o} (expected user-only access).",
|
||||
dir.display(),
|
||||
mode
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Listener that accepts connections on a Unix domain socket.
|
||||
pub struct IpcListener {
|
||||
inner: UnixListener,
|
||||
@@ -651,6 +699,74 @@ mod platform {
|
||||
pub async fn is_daemon_running() -> bool {
|
||||
connect().await.is_ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::fs::{PermissionsExt, symlink};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn temp_path(label: &str) -> PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!(
|
||||
"ssh-mux-test-{}-{}-{}",
|
||||
label,
|
||||
std::process::id(),
|
||||
nanos
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_dir_accepts_private_user_directory() {
|
||||
let dir = temp_path("runtime-ok");
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
|
||||
let uid = unsafe { libc::getuid() };
|
||||
let result = validate_runtime_dir(&dir, uid);
|
||||
|
||||
let _ = std::fs::remove_dir(&dir);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"expected private runtime dir to be accepted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_dir_rejects_group_writable_directory() {
|
||||
let dir = temp_path("runtime-bad-mode");
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let uid = unsafe { libc::getuid() };
|
||||
let result = validate_runtime_dir(&dir, uid);
|
||||
|
||||
let _ = std::fs::remove_dir(&dir);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"group/world-accessible runtime dir must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_dir_rejects_symlink() {
|
||||
let target = temp_path("runtime-target");
|
||||
let link = temp_path("runtime-link");
|
||||
std::fs::create_dir(&target).unwrap();
|
||||
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
symlink(&target, &link).unwrap();
|
||||
|
||||
let uid = unsafe { libc::getuid() };
|
||||
let result = validate_runtime_dir(&link, uid);
|
||||
|
||||
let _ = std::fs::remove_file(&link);
|
||||
let _ = std::fs::remove_dir(&target);
|
||||
assert!(result.is_err(), "symlinked runtime dir must be rejected");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export platform-specific items
|
||||
|
||||
@@ -453,7 +453,7 @@ mod tests {
|
||||
let mut mac = Hmac::<Sha1>::new_from_slice(salt).unwrap();
|
||||
mac.update(b"example.com");
|
||||
let hash = mac.finalize().into_bytes();
|
||||
let hash_b64 = base64::engine::general_purpose::STANDARD.encode(&hash);
|
||||
let hash_b64 = base64::engine::general_purpose::STANDARD.encode(hash);
|
||||
|
||||
let entry = format!("|1|{}|{}", salt_b64, hash_b64);
|
||||
assert!(hostname_matches(&entry, "example.com", 22));
|
||||
@@ -716,7 +716,7 @@ mod tests {
|
||||
use sha2::Digest;
|
||||
let mut pos = 0;
|
||||
while pos < dest.len() {
|
||||
let hash = sha2::Sha256::digest(&self.0);
|
||||
let hash = sha2::Sha256::digest(self.0);
|
||||
let copy_len = std::cmp::min(dest.len() - pos, 32);
|
||||
dest[pos..pos + copy_len].copy_from_slice(&hash[..copy_len]);
|
||||
self.0 = hash.into();
|
||||
|
||||
@@ -104,6 +104,38 @@ enum PoolKeyKind {
|
||||
},
|
||||
}
|
||||
|
||||
/// RAII guard that decrements the pool's `active_channels` counter exactly
|
||||
/// once when the relay task ends — including the panic path. Required because
|
||||
/// the new `cleanup_idle` policy never reaps a connection while
|
||||
/// `active_channels > 0`, so a leaked counter would pin the connection
|
||||
/// forever. The decrement is dispatched to the runtime via `spawn` (channel
|
||||
/// close requires async I/O); if the runtime is already shutting down,
|
||||
/// `try_current()` fails and we skip — the process is exiting anyway.
|
||||
struct ChannelCloseGuard {
|
||||
pool: std::sync::Arc<crate::pool::Pool>,
|
||||
pool_key: PoolKeyKind,
|
||||
}
|
||||
|
||||
impl Drop for ChannelCloseGuard {
|
||||
fn drop(&mut self) {
|
||||
let Ok(handle) = tokio::runtime::Handle::try_current() else {
|
||||
return;
|
||||
};
|
||||
let pool = self.pool.clone();
|
||||
let pool_key = self.pool_key.clone();
|
||||
handle.spawn(async move {
|
||||
match pool_key {
|
||||
PoolKeyKind::Direct { user, host, port } => {
|
||||
pool.channel_closed(Some(&user), &host, port).await;
|
||||
}
|
||||
PoolKeyKind::ViaJump { user, host, port } => {
|
||||
pool.channel_closed_via_jump(&user, &host, port).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores the remote channel for a local channel.
|
||||
///
|
||||
/// Before the relay starts, `remote` holds the Channel directly.
|
||||
@@ -147,6 +179,10 @@ struct UpstreamAuthState {
|
||||
}
|
||||
|
||||
impl LocalSshHandler {
|
||||
fn is_remote_loopback(host: &str) -> bool {
|
||||
matches!(host, "127.0.0.1" | "localhost" | "::1")
|
||||
}
|
||||
|
||||
/// Open a remote session channel via the pool, respecting the server mode.
|
||||
///
|
||||
/// - **Direct mode**: connects directly to the configured remote host.
|
||||
@@ -257,10 +293,21 @@ impl LocalSshHandler {
|
||||
|
||||
/// Check whether a direct-tcpip target is allowed.
|
||||
///
|
||||
/// SECURITY: This prevents using the jump host or internal servers as an
|
||||
/// open SOCKS/TCP proxy for lateral movement. Only the route's own
|
||||
/// host:port (and localhost on the remote for VS Code port forwarding)
|
||||
/// are permitted.
|
||||
/// Policy:
|
||||
/// - Remote loopback (`127.0.0.1` / `localhost` / `::1`) on **any** port
|
||||
/// is permitted. VS Code / Cursor Remote-SSH spawn their IDE server on
|
||||
/// a random localhost port and forward to it via `direct-tcpip`; the
|
||||
/// prior loopback-any-port opt-in (v1.13.4 / v1.13.5) was removed in
|
||||
/// v1.14.0 because the threat it guarded against — a local actor with
|
||||
/// already-execution against the mux listener using the route as a
|
||||
/// SOCKS-style proxy into the remote's localhost services — is moot
|
||||
/// on a single-user dev box (that actor can already invoke ssh
|
||||
/// directly), and the gate's UX cost (config rewrites by
|
||||
/// `import-config` silently dropped the opt-in) outweighed its
|
||||
/// defense-in-depth value.
|
||||
/// - Non-loopback targets are still restricted to the route's own
|
||||
/// host:port (or `remote_host:remote_port` in direct mode), so the
|
||||
/// route cannot be used to pivot into arbitrary internal hosts.
|
||||
fn is_direct_tcpip_allowed(&self, host: &str, port: u32) -> bool {
|
||||
match &self.config.mode {
|
||||
ServerMode::Direct {
|
||||
@@ -268,32 +315,22 @@ impl LocalSshHandler {
|
||||
remote_port,
|
||||
..
|
||||
} => {
|
||||
// In direct mode, allow connections to the remote host itself
|
||||
// (for VS Code port forwarding, target is usually 127.0.0.1 on remote)
|
||||
if host == "127.0.0.1" || host == "localhost" || host == "::1" {
|
||||
if Self::is_remote_loopback(host) {
|
||||
return true;
|
||||
}
|
||||
// Also allow the remote host itself
|
||||
host == remote_host.as_str() && port == (*remote_port) as u32
|
||||
}
|
||||
ServerMode::Routed { config } => {
|
||||
// Allow localhost on the remote (VS Code SOCKS proxy targets)
|
||||
if host == "127.0.0.1" || host == "localhost" || host == "::1" {
|
||||
let Some(route_name) = self.route_name.as_deref() else {
|
||||
return false;
|
||||
};
|
||||
let Some(route) = config.routes.get(route_name) else {
|
||||
return false;
|
||||
};
|
||||
if Self::is_remote_loopback(host) {
|
||||
return true;
|
||||
}
|
||||
// Allow only hosts that appear in the route table
|
||||
let route_name = match self.route_name.as_deref() {
|
||||
Some(name) => name,
|
||||
None => return false,
|
||||
};
|
||||
if let Some(route) = config.routes.get(route_name) {
|
||||
// Allow the route's own target
|
||||
if host == route.host && port == route.port as u32 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Deny everything else
|
||||
false
|
||||
host == route.host && port == route.port as u32
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -549,20 +586,128 @@ impl LocalSshHandler {
|
||||
let pool = self.config.pool.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
// RAII guard guarantees pool.channel_closed runs even if
|
||||
// relay_bidirectional panics. cleanup_idle no longer reaps
|
||||
// connections with active_channels > 0, so a leaked counter
|
||||
// would pin the connection in the pool indefinitely.
|
||||
let _close_guard = ChannelCloseGuard { pool, pool_key };
|
||||
relay_bidirectional(remote_ch, local_id, handle, rx).await;
|
||||
match pool_key {
|
||||
PoolKeyKind::Direct { user, host, port } => {
|
||||
pool.channel_closed(Some(&user), &host, port).await;
|
||||
}
|
||||
PoolKeyKind::ViaJump { user, host, port } => {
|
||||
pool.channel_closed_via_jump(&user, &host, port).await;
|
||||
}
|
||||
}
|
||||
tracing::debug!("relay ended for local channel {:?}", local_id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod policy_tests {
|
||||
use super::*;
|
||||
use crate::config::{JumpConfig, MuxConfig, RouteEntry};
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn test_handler(mode: ServerMode, route_name: Option<&str>) -> LocalSshHandler {
|
||||
let pool = Arc::new(Pool::new(60, 0));
|
||||
LocalSshHandler {
|
||||
config: Arc::new(LocalServerConfig {
|
||||
mode,
|
||||
pool,
|
||||
authorized_keys: Vec::new(),
|
||||
}),
|
||||
channels: HashMap::new(),
|
||||
server_handle: None,
|
||||
route_name: route_name.map(str::to_string),
|
||||
publickey_verified: false,
|
||||
upstream_auth: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_tcpip_direct_mode_allows_target_and_any_loopback_port() {
|
||||
// v1.14.0: same loopback-any-port relaxation as the routed mode.
|
||||
let handler = test_handler(
|
||||
ServerMode::Direct {
|
||||
remote_host: "server.example.com".into(),
|
||||
remote_port: 22,
|
||||
remote_user: Some("alice".into()),
|
||||
},
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(handler.is_direct_tcpip_allowed("server.example.com", 22));
|
||||
assert!(handler.is_direct_tcpip_allowed("127.0.0.1", 22));
|
||||
assert!(handler.is_direct_tcpip_allowed("127.0.0.1", 8080));
|
||||
assert!(handler.is_direct_tcpip_allowed("localhost", 41849));
|
||||
assert!(handler.is_direct_tcpip_allowed("::1", 65000));
|
||||
assert!(!handler.is_direct_tcpip_allowed("db.internal", 5432));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_tcpip_routed_mode_allows_route_target_and_any_loopback_port() {
|
||||
// v1.14.0: remote loopback (any port) is unconditionally allowed
|
||||
// for an authenticated route — VS Code / Cursor Remote-SSH spawn
|
||||
// their IDE server on a random localhost port and the prior
|
||||
// opt-in gate was UX-hostile (config rewrites silently dropped it).
|
||||
// Non-loopback non-route targets remain denied so the route can't
|
||||
// be used to pivot into other internal hosts.
|
||||
let mut routes = HashMap::new();
|
||||
routes.insert(
|
||||
"web".to_string(),
|
||||
RouteEntry {
|
||||
host: "10.0.0.10".into(),
|
||||
port: 2222,
|
||||
user: "deploy".into(),
|
||||
direct: false,
|
||||
},
|
||||
);
|
||||
|
||||
let handler = test_handler(
|
||||
ServerMode::Routed {
|
||||
config: MuxConfig {
|
||||
jump: JumpConfig {
|
||||
host: "bastion.example.com".into(),
|
||||
port: 22,
|
||||
user: "jump".into(),
|
||||
},
|
||||
routes,
|
||||
},
|
||||
},
|
||||
Some("web"),
|
||||
);
|
||||
|
||||
// Route's own target.
|
||||
assert!(handler.is_direct_tcpip_allowed("10.0.0.10", 2222));
|
||||
// Loopback on any port (route SSH port + IDE-server random port).
|
||||
assert!(handler.is_direct_tcpip_allowed("::1", 2222));
|
||||
assert!(handler.is_direct_tcpip_allowed("127.0.0.1", 8080));
|
||||
assert!(handler.is_direct_tcpip_allowed("127.0.0.1", 33767));
|
||||
assert!(handler.is_direct_tcpip_allowed("localhost", 41849));
|
||||
assert!(handler.is_direct_tcpip_allowed("::1", 65000));
|
||||
// Non-loopback non-route targets still denied.
|
||||
assert!(!handler.is_direct_tcpip_allowed("10.0.0.11", 2222));
|
||||
assert!(!handler.is_direct_tcpip_allowed("db.internal", 5432));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_tcpip_routed_mode_denies_when_route_is_missing() {
|
||||
// Without an authenticated route, even loopback is denied —
|
||||
// there's no upstream session to forward to anyway.
|
||||
let handler = test_handler(
|
||||
ServerMode::Routed {
|
||||
config: MuxConfig {
|
||||
jump: JumpConfig {
|
||||
host: "bastion.example.com".into(),
|
||||
port: 22,
|
||||
user: "jump".into(),
|
||||
},
|
||||
routes: HashMap::new(),
|
||||
},
|
||||
},
|
||||
Some("missing"),
|
||||
);
|
||||
|
||||
assert!(!handler.is_direct_tcpip_allowed("127.0.0.1", 22));
|
||||
assert!(!handler.is_direct_tcpip_allowed("10.0.0.10", 22));
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler for LocalSshHandler {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
@@ -639,7 +784,10 @@ impl Handler for LocalSshHandler {
|
||||
}
|
||||
|
||||
// Forward client responses (if any) to the driver, or spawn the
|
||||
// driver on the first call.
|
||||
// driver on the first call. If responses_tx send fails the driver
|
||||
// has gone away — drop the now-useless state so the next KI round
|
||||
// does not re-enter a broken session (a stale `outcome_rx` would
|
||||
// panic with "called after complete" on the next poll).
|
||||
if let Some(response) = response {
|
||||
let responses: Vec<String> = response
|
||||
.map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
|
||||
@@ -655,63 +803,82 @@ impl Handler for LocalSshHandler {
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!("upstream auth driver no longer accepting responses");
|
||||
self.upstream_auth = None;
|
||||
return Ok(Auth::reject());
|
||||
}
|
||||
} else if self.upstream_auth.is_none() {
|
||||
self.upstream_auth = Some(self.spawn_upstream_auth_driver()?);
|
||||
}
|
||||
|
||||
let state = self
|
||||
.upstream_auth
|
||||
.as_mut()
|
||||
.context("upstream auth state missing")?;
|
||||
// Drive the driver one step. The select returns (auth, driver_finished).
|
||||
// When the driver terminates (outcome arm fires, or prompts channel
|
||||
// closes), the oneshot receiver is consumed and re-polling it would
|
||||
// panic — clear `self.upstream_auth` after the borrow ends so the
|
||||
// next call (e.g. SSH retry after Auth::reject) starts with a fresh
|
||||
// driver.
|
||||
let (auth, driver_finished) = {
|
||||
let state = self
|
||||
.upstream_auth
|
||||
.as_mut()
|
||||
.context("upstream auth state missing")?;
|
||||
|
||||
tokio::select! {
|
||||
biased;
|
||||
outcome = &mut state.outcome_rx => {
|
||||
match outcome {
|
||||
Ok(Ok(())) => {
|
||||
tracing::info!("upstream auth complete, accepting client");
|
||||
Ok(Auth::Accept)
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!("upstream auth failed: {:#}", e);
|
||||
Ok(Auth::reject())
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!("upstream auth driver dropped without result");
|
||||
Ok(Auth::reject())
|
||||
tokio::select! {
|
||||
biased;
|
||||
outcome = &mut state.outcome_rx => {
|
||||
let auth = match outcome {
|
||||
Ok(Ok(())) => {
|
||||
tracing::info!("upstream auth complete, accepting client");
|
||||
Auth::Accept
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!("upstream auth failed: {:#}", e);
|
||||
Auth::reject()
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!("upstream auth driver dropped without result");
|
||||
Auth::reject()
|
||||
}
|
||||
};
|
||||
(auth, true)
|
||||
}
|
||||
maybe_prompt = state.prompts_rx.recv() => {
|
||||
match maybe_prompt {
|
||||
None => {
|
||||
// Driver closed the prompt channel without sending
|
||||
// anything; pull the outcome synchronously.
|
||||
let auth = match (&mut state.outcome_rx).await {
|
||||
Ok(Ok(())) => Auth::Accept,
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!("upstream auth failed: {:#}", e);
|
||||
Auth::reject()
|
||||
}
|
||||
Err(_) => Auth::reject(),
|
||||
};
|
||||
(auth, true)
|
||||
}
|
||||
Some(request) => {
|
||||
use crate::security::sanitize_for_display;
|
||||
let prompts_vec: Vec<(Cow<'static, str>, bool)> = request
|
||||
.prompts
|
||||
.into_iter()
|
||||
.map(|p| (Cow::Owned(sanitize_for_display(&p.prompt)), p.echo))
|
||||
.collect();
|
||||
let auth = Auth::Partial {
|
||||
name: Cow::Owned(sanitize_for_display(&request.name)),
|
||||
instructions: Cow::Owned(sanitize_for_display(&request.instructions)),
|
||||
prompts: Cow::Owned(prompts_vec),
|
||||
};
|
||||
(auth, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
maybe_prompt = state.prompts_rx.recv() => {
|
||||
let Some(request) = maybe_prompt else {
|
||||
// Driver closed the prompt channel without sending anything;
|
||||
// pull the outcome synchronously.
|
||||
let outcome = (&mut state.outcome_rx).await;
|
||||
return match outcome {
|
||||
Ok(Ok(())) => Ok(Auth::Accept),
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!("upstream auth failed: {:#}", e);
|
||||
Ok(Auth::reject())
|
||||
}
|
||||
Err(_) => Ok(Auth::reject()),
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
use crate::security::sanitize_for_display;
|
||||
let prompts_vec: Vec<(Cow<'static, str>, bool)> = request
|
||||
.prompts
|
||||
.into_iter()
|
||||
.map(|p| (Cow::Owned(sanitize_for_display(&p.prompt)), p.echo))
|
||||
.collect();
|
||||
Ok(Auth::Partial {
|
||||
name: Cow::Owned(sanitize_for_display(&request.name)),
|
||||
instructions: Cow::Owned(sanitize_for_display(&request.instructions)),
|
||||
prompts: Cow::Owned(prompts_vec),
|
||||
})
|
||||
}
|
||||
if driver_finished {
|
||||
self.upstream_auth = None;
|
||||
}
|
||||
Ok(auth)
|
||||
}
|
||||
|
||||
async fn auth_openssh_certificate(
|
||||
@@ -1270,11 +1437,13 @@ async fn relay_bidirectional(
|
||||
msg = remote.wait() => {
|
||||
match msg {
|
||||
Some(ChannelMsg::Data { data }) => {
|
||||
#[allow(clippy::collapsible_match)]
|
||||
if server_handle.data(local_channel, data).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(ChannelMsg::ExtendedData { data, ext }) => {
|
||||
#[allow(clippy::collapsible_match)]
|
||||
if server_handle
|
||||
.extended_data(local_channel, ext, data)
|
||||
.await
|
||||
|
||||
100
src/main.rs
100
src/main.rs
@@ -2,6 +2,7 @@ mod cli;
|
||||
mod config;
|
||||
mod daemon;
|
||||
mod host_key;
|
||||
mod import;
|
||||
mod ipc;
|
||||
mod known_hosts;
|
||||
mod local_server;
|
||||
@@ -10,7 +11,7 @@ mod protocol;
|
||||
mod proxy;
|
||||
mod security;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use cli::{Cli, Command};
|
||||
|
||||
@@ -133,19 +134,28 @@ async fn main() -> Result<()> {
|
||||
let mux_config = config::load_config(&cfg_path)?;
|
||||
config::setup_ssh_config(&mux_config, listen_port)?;
|
||||
}
|
||||
Command::ImportConfig {
|
||||
from,
|
||||
out,
|
||||
write,
|
||||
force,
|
||||
ssh_bin,
|
||||
} => {
|
||||
import::run(import::ImportArgs {
|
||||
from: from.map(std::path::PathBuf::from),
|
||||
out: out.map(std::path::PathBuf::from),
|
||||
write,
|
||||
force,
|
||||
ssh_bin: ssh_bin.map(std::path::PathBuf::from),
|
||||
})?;
|
||||
}
|
||||
Command::Install {
|
||||
config: config_path,
|
||||
listen_port,
|
||||
timeout,
|
||||
max_lifetime,
|
||||
} => {
|
||||
#[cfg(windows)]
|
||||
install_service(config_path, listen_port, timeout, max_lifetime)?;
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let _ = (config_path, listen_port, timeout, max_lifetime);
|
||||
anyhow::bail!("install is only supported on Windows");
|
||||
}
|
||||
run_install(config_path, listen_port, timeout, max_lifetime)?;
|
||||
}
|
||||
Command::Uninstall => {
|
||||
#[cfg(windows)]
|
||||
@@ -170,6 +180,80 @@ async fn main() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One-shot bootstrap pipeline behind `ssh-mux install`.
|
||||
///
|
||||
/// 1. If `routes.toml` does not exist at the resolved path, runs
|
||||
/// `import-config` to derive it from `~/.ssh/config`.
|
||||
/// 2. Runs `setup-config` (generates `ssh-mux-hosts.conf`, prepends the
|
||||
/// `Include` line to `~/.ssh/config`, pre-registers the host key).
|
||||
/// 3. On Windows, installs the background service and starts the daemon.
|
||||
/// On other platforms, prints the manual `ssh-mux daemon …` command.
|
||||
///
|
||||
/// The standalone `import-config` and `setup-config` subcommands remain
|
||||
/// available for explicit re-runs and advanced use.
|
||||
fn run_install(
|
||||
config_path: Option<String>,
|
||||
listen_port: u16,
|
||||
timeout: u64,
|
||||
max_lifetime: u64,
|
||||
) -> Result<()> {
|
||||
let cfg_path = match config_path.as_deref() {
|
||||
Some(p) => std::path::PathBuf::from(p),
|
||||
None => config::default_config_path()?,
|
||||
};
|
||||
|
||||
println!("==> resolving routes.toml: {}", cfg_path.display());
|
||||
if cfg_path.exists() {
|
||||
println!(
|
||||
" (using existing file — skip import; run `ssh-mux import-config --write --force` to re-import)"
|
||||
);
|
||||
} else {
|
||||
println!(" not found — running import-config from ~/.ssh/config");
|
||||
import::run(import::ImportArgs {
|
||||
from: None,
|
||||
out: Some(cfg_path.clone()),
|
||||
write: true,
|
||||
force: false,
|
||||
ssh_bin: None,
|
||||
})
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"import failed; create {} manually or run `ssh-mux import-config` to debug",
|
||||
cfg_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
println!("\n==> setup-config");
|
||||
let mux_config = config::load_config(&cfg_path)?;
|
||||
config::setup_ssh_config(&mux_config, listen_port)?;
|
||||
|
||||
println!("\n==> service install");
|
||||
#[cfg(windows)]
|
||||
{
|
||||
install_service(
|
||||
Some(cfg_path.to_string_lossy().into_owned()),
|
||||
listen_port,
|
||||
timeout,
|
||||
max_lifetime,
|
||||
)?;
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let _ = (timeout, max_lifetime);
|
||||
println!(
|
||||
"auto-start service install is Windows-only.\n\
|
||||
To run the daemon now:\n\
|
||||
\n\
|
||||
\tssh-mux daemon -p {} --config {}\n",
|
||||
listen_port,
|
||||
cfg_path.display(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
const STARTUP_VBS: &str = "ssh-mux.vbs";
|
||||
|
||||
|
||||
359
src/pool.rs
359
src/pool.rs
@@ -24,12 +24,21 @@ use russh::client;
|
||||
use russh::keys::PublicKey;
|
||||
use russh::keys::key::PrivateKeyWithHashAlg;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::Instant;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::protocol::{AuthInfoRequest, AuthInfoResponse, AuthPrompt};
|
||||
|
||||
/// Outcome of a single-entry reap decision (used by `cleanup_idle`).
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum ReapDecision {
|
||||
Keep,
|
||||
HandleClosed,
|
||||
LifetimeExceeded,
|
||||
Idle,
|
||||
}
|
||||
|
||||
/// A single pooled SSH connection.
|
||||
struct PoolEntry {
|
||||
handle: client::Handle<SshHandler>,
|
||||
@@ -313,6 +322,93 @@ where
|
||||
f().await
|
||||
}
|
||||
|
||||
/// SSH client identification string sent during protocol banner exchange.
|
||||
///
|
||||
/// Russh's default `SSH-2.0-russh_0.57` is technically valid but distinctive,
|
||||
/// which is awkward when the rest of the user's tooling identifies as
|
||||
/// OpenSSH. We mirror whatever the local `ssh` binary would send so that
|
||||
/// ssh-mux is indistinguishable from native `ssh` at the protocol banner
|
||||
/// level — useful for servers/middleboxes that log or pattern-match on the
|
||||
/// client banner.
|
||||
///
|
||||
/// Note: this does *not* affect OS detection on systems that use TCP/IP
|
||||
/// fingerprinting (e.g. Okta device posture). Those signals come from the
|
||||
/// kernel's TCP stack, not the SSH banner, and aren't something a userspace
|
||||
/// SSH client can reshape.
|
||||
///
|
||||
/// We detect the local OpenSSH version by invoking `ssh -V` and reusing the
|
||||
/// exact version string. If `ssh` is not on PATH (rare on Windows 11 —
|
||||
/// ships by default), we fall back to a platform-appropriate default
|
||||
/// rather than leaking russh.
|
||||
///
|
||||
/// The result is cached for the process lifetime: the local `ssh` install
|
||||
/// doesn't change between calls.
|
||||
fn detect_client_id() -> &'static str {
|
||||
static CACHED: OnceLock<String> = OnceLock::new();
|
||||
CACHED
|
||||
.get_or_init(|| {
|
||||
let id = detect_local_openssh_banner().unwrap_or_else(|| {
|
||||
let fallback = platform_default_client_id().to_string();
|
||||
tracing::info!(
|
||||
"could not detect local OpenSSH version; falling back to {}",
|
||||
fallback
|
||||
);
|
||||
fallback
|
||||
});
|
||||
tracing::info!("ssh client banner: {}", id);
|
||||
id
|
||||
})
|
||||
.as_str()
|
||||
}
|
||||
|
||||
/// Run `ssh -V` and convert its output (e.g. `OpenSSH_for_Windows_8.1p1, ...`)
|
||||
/// into a banner string (`SSH-2.0-OpenSSH_for_Windows_8.1p1`).
|
||||
///
|
||||
/// OpenSSH writes the version line to stderr; some distributions/wrappers may
|
||||
/// write it to stdout instead, so we check both. Returns `None` if the binary
|
||||
/// is missing or the output doesn't look like an OpenSSH version line.
|
||||
fn detect_local_openssh_banner() -> Option<String> {
|
||||
let output = std::process::Command::new("ssh").arg("-V").output().ok()?;
|
||||
let raw = if !output.stderr.is_empty() {
|
||||
String::from_utf8_lossy(&output.stderr).into_owned()
|
||||
} else {
|
||||
String::from_utf8_lossy(&output.stdout).into_owned()
|
||||
};
|
||||
parse_ssh_v_output(&raw)
|
||||
}
|
||||
|
||||
/// Parse the first line of `ssh -V` output into an SSH banner string.
|
||||
///
|
||||
/// Extracted as a pure function so the parsing rules are testable without
|
||||
/// shelling out. The SSH protocol forbids spaces and '-' in the software
|
||||
/// version field of the banner (RFC 4253 §4.2), so a token failing those
|
||||
/// rules indicates a wrapper or non-OpenSSH binary and we reject it.
|
||||
fn parse_ssh_v_output(raw: &str) -> Option<String> {
|
||||
let version = raw.lines().next()?.split(',').next()?.trim();
|
||||
if !version.starts_with("OpenSSH_") || version.len() > 200 {
|
||||
return None;
|
||||
}
|
||||
if !version
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_graphic() && b != b' ' && b != b'-')
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(format!("SSH-2.0-{}", version))
|
||||
}
|
||||
|
||||
/// Last-resort banner when `ssh -V` is unavailable. Picked by build target so
|
||||
/// the banner at least matches the OS the user is on.
|
||||
fn platform_default_client_id() -> &'static str {
|
||||
if cfg!(target_os = "windows") {
|
||||
"SSH-2.0-OpenSSH_for_Windows_8.1"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"SSH-2.0-OpenSSH_9.0"
|
||||
} else {
|
||||
"SSH-2.0-OpenSSH_9.6"
|
||||
}
|
||||
}
|
||||
|
||||
impl Pool {
|
||||
/// Create a new connection pool.
|
||||
///
|
||||
@@ -350,6 +446,7 @@ impl Pool {
|
||||
/// Build a `client::Config` with keepalive enabled.
|
||||
fn ssh_client_config(&self) -> Arc<client::Config> {
|
||||
Arc::new(client::Config {
|
||||
client_id: russh::SshId::Standard(detect_client_id().to_string()),
|
||||
keepalive_interval: Some(std::time::Duration::from_secs(15)),
|
||||
keepalive_max: 3,
|
||||
inactivity_timeout: Some(std::time::Duration::from_secs(self.timeout_secs.max(60))),
|
||||
@@ -1225,14 +1322,54 @@ impl Pool {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Decide what to do with a single pool entry during cleanup. Pure: no
|
||||
/// I/O, no global state. The jump-host dependent override is applied at
|
||||
/// the call site (it requires the full pool state).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn reap_decision(
|
||||
handle_closed: bool,
|
||||
active_channels: usize,
|
||||
created_at_elapsed: std::time::Duration,
|
||||
last_used_elapsed: std::time::Duration,
|
||||
timeout: std::time::Duration,
|
||||
max_lifetime: Option<std::time::Duration>,
|
||||
) -> ReapDecision {
|
||||
if handle_closed {
|
||||
return ReapDecision::HandleClosed;
|
||||
}
|
||||
if let Some(max_lt) = max_lifetime
|
||||
&& created_at_elapsed > max_lt
|
||||
{
|
||||
return ReapDecision::LifetimeExceeded;
|
||||
}
|
||||
// Active channels keep the connection alive regardless of last_used.
|
||||
// Byte traffic on an open channel never refreshes last_used, so
|
||||
// gating reap on "last_used + active_channels" would (and did) reap
|
||||
// live tmux sessions exactly `timeout` seconds after channel open.
|
||||
if active_channels == 0 && last_used_elapsed > timeout {
|
||||
return ReapDecision::Idle;
|
||||
}
|
||||
ReapDecision::Keep
|
||||
}
|
||||
|
||||
/// Remove connections that are dead, idle, or past their lifetime.
|
||||
///
|
||||
/// A connection is removed when any of these is true:
|
||||
/// - Its SSH handle is closed (remote disconnected / keepalive timeout)
|
||||
/// - It has no active channels and has been idle longer than `timeout_secs`
|
||||
/// - It has been idle longer than `timeout_secs` even with active channels
|
||||
/// (zombie channels whose relay tasks failed to clean up)
|
||||
/// - It exceeded `max_lifetime_secs` (if configured)
|
||||
/// - It has **no** active channels and has been idle longer than `timeout_secs`
|
||||
///
|
||||
/// While `active_channels > 0` and the SSH handle is alive, the
|
||||
/// connection is preserved regardless of `last_used`. The previous
|
||||
/// implementation used `last_used` as the staleness signal, but that
|
||||
/// timestamp is only refreshed on pool-level events (channel open/close)
|
||||
/// — byte traffic on an open channel never bumped it. As a result, a
|
||||
/// long-running tmux session over a single pooled channel was reaped
|
||||
/// exactly `timeout_secs` after the channel opened, even while the user
|
||||
/// was actively typing. Active channels imply real usage; dead remotes
|
||||
/// are caught by russh keepalive (which closes the handle and trips
|
||||
/// `is_closed()` within ~45s). Leaks of `active_channels` are prevented
|
||||
/// at the relay site by an RAII guard.
|
||||
///
|
||||
/// Jump host connections are kept alive as long as any via-jump dependent
|
||||
/// connection is still active (has channels or recent activity).
|
||||
@@ -1260,45 +1397,46 @@ impl Pool {
|
||||
let mut to_remove: Vec<String> = Vec::new();
|
||||
|
||||
for (key, entry) in conns.iter() {
|
||||
if entry.handle.is_closed() {
|
||||
tracing::info!(
|
||||
"removing dead connection to {} (channels: {})",
|
||||
key,
|
||||
entry.active_channels
|
||||
);
|
||||
to_remove.push(key.clone());
|
||||
} else if let Some(max_lt) = max_lifetime
|
||||
&& entry.created_at.elapsed() > max_lt
|
||||
{
|
||||
tracing::warn!(
|
||||
"closing connection to {} — absolute lifetime ({:.0}s) exceeded \
|
||||
(channels: {})",
|
||||
key,
|
||||
max_lt.as_secs_f64(),
|
||||
entry.active_channels,
|
||||
);
|
||||
to_remove.push(key.clone());
|
||||
} else if entry.last_used.elapsed() > timeout {
|
||||
// Don't reap jump hosts that still have active dependents
|
||||
if active_jump_keys.contains(key) {
|
||||
tracing::debug!(
|
||||
"keeping jump connection {} alive — active dependent connections exist",
|
||||
let decision = Self::reap_decision(
|
||||
entry.handle.is_closed(),
|
||||
entry.active_channels,
|
||||
entry.created_at.elapsed(),
|
||||
entry.last_used.elapsed(),
|
||||
timeout,
|
||||
max_lifetime,
|
||||
);
|
||||
match decision {
|
||||
ReapDecision::Keep => continue,
|
||||
ReapDecision::HandleClosed => {
|
||||
tracing::info!(
|
||||
"removing dead connection to {} (channels: {})",
|
||||
key,
|
||||
entry.active_channels
|
||||
);
|
||||
continue;
|
||||
to_remove.push(key.clone());
|
||||
}
|
||||
|
||||
if entry.active_channels > 0 {
|
||||
ReapDecision::LifetimeExceeded => {
|
||||
let max_lt = max_lifetime.unwrap_or_default();
|
||||
tracing::warn!(
|
||||
"closing stale connection to {} — idle {}s with {} zombie channel(s)",
|
||||
"closing connection to {} — absolute lifetime ({:.0}s) exceeded \
|
||||
(channels: {})",
|
||||
key,
|
||||
entry.last_used.elapsed().as_secs(),
|
||||
max_lt.as_secs_f64(),
|
||||
entry.active_channels,
|
||||
);
|
||||
} else {
|
||||
tracing::info!("closing idle connection to {}", key);
|
||||
to_remove.push(key.clone());
|
||||
}
|
||||
ReapDecision::Idle => {
|
||||
if active_jump_keys.contains(key) {
|
||||
tracing::debug!(
|
||||
"keeping jump connection {} alive — active dependent connections exist",
|
||||
key,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
tracing::info!("closing idle connection to {}", key);
|
||||
to_remove.push(key.clone());
|
||||
}
|
||||
to_remove.push(key.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1888,6 +2026,99 @@ fn known_folder_profile() -> Option<std::path::PathBuf> {
|
||||
Some(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod reap_decision_tests {
|
||||
use super::{Pool, ReapDecision};
|
||||
use std::time::Duration;
|
||||
|
||||
fn timeout() -> Duration {
|
||||
Duration::from_secs(600)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dead_handle_is_reaped_immediately() {
|
||||
let d = Pool::reap_decision(
|
||||
true,
|
||||
5,
|
||||
Duration::from_secs(1),
|
||||
Duration::from_secs(1),
|
||||
timeout(),
|
||||
None,
|
||||
);
|
||||
assert_eq!(d, ReapDecision::HandleClosed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_lifetime_reaps_even_with_active_channels() {
|
||||
let d = Pool::reap_decision(
|
||||
false,
|
||||
3,
|
||||
Duration::from_secs(13_000),
|
||||
Duration::from_secs(10),
|
||||
timeout(),
|
||||
Some(Duration::from_secs(12_000)),
|
||||
);
|
||||
assert_eq!(d, ReapDecision::LifetimeExceeded);
|
||||
}
|
||||
|
||||
/// Regression: a connection with active channels and a stale `last_used`
|
||||
/// must NOT be reaped. The previous policy reaped it as a "zombie",
|
||||
/// killing live tmux sessions exactly `timeout` seconds after the channel
|
||||
/// opened (last_used was only refreshed on pool-level events, never by
|
||||
/// byte traffic on the relay).
|
||||
#[test]
|
||||
fn active_channels_freeze_idle_timer() {
|
||||
let d = Pool::reap_decision(
|
||||
false,
|
||||
1,
|
||||
Duration::from_secs(10),
|
||||
Duration::from_secs(607), // > 600s timeout — would have been reaped
|
||||
timeout(),
|
||||
None,
|
||||
);
|
||||
assert_eq!(d, ReapDecision::Keep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_with_no_channels_reaps_after_timeout() {
|
||||
let d = Pool::reap_decision(
|
||||
false,
|
||||
0,
|
||||
Duration::from_secs(10),
|
||||
Duration::from_secs(601),
|
||||
timeout(),
|
||||
None,
|
||||
);
|
||||
assert_eq!(d, ReapDecision::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_with_no_channels_kept_within_timeout() {
|
||||
let d = Pool::reap_decision(
|
||||
false,
|
||||
0,
|
||||
Duration::from_secs(10),
|
||||
Duration::from_secs(599),
|
||||
timeout(),
|
||||
None,
|
||||
);
|
||||
assert_eq!(d, ReapDecision::Keep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_closed_dominates_max_lifetime() {
|
||||
let d = Pool::reap_decision(
|
||||
true,
|
||||
0,
|
||||
Duration::from_secs(100_000),
|
||||
Duration::from_secs(0),
|
||||
timeout(),
|
||||
Some(Duration::from_secs(50_000)),
|
||||
);
|
||||
assert_eq!(d, ReapDecision::HandleClosed);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod jump_host_setup_serialize_tests {
|
||||
use super::serialize_jump_host_setup;
|
||||
@@ -1986,3 +2217,61 @@ mod jump_host_setup_serialize_tests {
|
||||
assert_eq!(entered.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ssh_v_parse_tests {
|
||||
use super::parse_ssh_v_output;
|
||||
|
||||
#[test]
|
||||
fn windows_openssh_banner_round_trips() {
|
||||
let raw = "OpenSSH_for_Windows_8.1p1, LibreSSL 3.0.2\n";
|
||||
assert_eq!(
|
||||
parse_ssh_v_output(raw).as_deref(),
|
||||
Some("SSH-2.0-OpenSSH_for_Windows_8.1p1"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_openssh_banner_round_trips() {
|
||||
let raw = "OpenSSH_9.6p1, OpenSSL 3.0.13 30 Jan 2024\n";
|
||||
assert_eq!(
|
||||
parse_ssh_v_output(raw).as_deref(),
|
||||
Some("SSH-2.0-OpenSSH_9.6p1"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_openssh_banner_round_trips() {
|
||||
let raw = "OpenSSH_9.0p1, LibreSSL 3.3.6\n";
|
||||
assert_eq!(
|
||||
parse_ssh_v_output(raw).as_deref(),
|
||||
Some("SSH-2.0-OpenSSH_9.0p1"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_openssh_binaries() {
|
||||
// PuTTY's plink, Tectia, etc. — banner mimicry is wrong for these.
|
||||
assert_eq!(parse_ssh_v_output("plink: Release 0.79\n"), None);
|
||||
assert_eq!(parse_ssh_v_output("ssh: invalid option -- 'V'\n"), None);
|
||||
assert_eq!(parse_ssh_v_output(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_versions_with_spaces_or_dashes() {
|
||||
// RFC 4253 §4.2 forbids ' ' and '-' in the softwareversion field.
|
||||
// A wrapper that injects either would produce a non-conforming banner.
|
||||
assert_eq!(parse_ssh_v_output("OpenSSH_9.0 with patches, ...\n"), None);
|
||||
assert_eq!(parse_ssh_v_output("OpenSSH-portable_9.0p1, ...\n"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_trailing_lines() {
|
||||
// Some wrappers print extra diagnostic lines; we only consume the first.
|
||||
let raw = "OpenSSH_for_Windows_9.5p1, LibreSSL 3.7.0\nWARNING: experimental build\n";
|
||||
assert_eq!(
|
||||
parse_ssh_v_output(raw).as_deref(),
|
||||
Some("SSH-2.0-OpenSSH_for_Windows_9.5p1"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,20 @@ use std::time::Duration;
|
||||
/// Global lock to ensure tests run serially (they share daemon state).
|
||||
static TEST_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn test_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn runtime_dir() -> std::path::PathBuf {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("ssh-mux-integration-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// Get the path to the built binary.
|
||||
fn binary_path() -> std::path::PathBuf {
|
||||
let mut path = std::env::current_exe()
|
||||
@@ -35,11 +49,13 @@ fn binary_path() -> std::path::PathBuf {
|
||||
/// Helper to stop the daemon if it's running.
|
||||
fn stop_daemon() {
|
||||
let bin = binary_path();
|
||||
let _ = Command::new(&bin)
|
||||
.args(["stop"])
|
||||
let mut cmd = Command::new(&bin);
|
||||
cmd.args(["stop"])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
.stderr(Stdio::null());
|
||||
#[cfg(not(windows))]
|
||||
cmd.env("XDG_RUNTIME_DIR", runtime_dir());
|
||||
let _ = cmd.status();
|
||||
// Give it a moment to shut down
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
@@ -64,12 +80,12 @@ fn start_daemon() -> std::process::Child {
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
Command::new(&bin)
|
||||
.args(["daemon", "--timeout", "10"])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("failed to start daemon")
|
||||
let mut cmd = Command::new(&bin);
|
||||
cmd.args(["daemon", "--timeout", "10"])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.env("XDG_RUNTIME_DIR", runtime_dir());
|
||||
cmd.spawn().expect("failed to start daemon")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,16 +95,18 @@ fn wait_for_daemon(timeout: Duration) -> bool {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
while start.elapsed() < timeout {
|
||||
let result = Command::new(&bin)
|
||||
.args(["status"])
|
||||
let mut cmd = Command::new(&bin);
|
||||
cmd.args(["status"])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.output();
|
||||
.stderr(Stdio::null());
|
||||
#[cfg(not(windows))]
|
||||
cmd.env("XDG_RUNTIME_DIR", runtime_dir());
|
||||
let result = cmd.output();
|
||||
|
||||
if let Ok(output) = result {
|
||||
if output.status.success() {
|
||||
return true;
|
||||
}
|
||||
if let Ok(output) = result
|
||||
&& output.status.success()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
@@ -133,14 +151,15 @@ fn test_version_output() {
|
||||
|
||||
#[test]
|
||||
fn test_status_when_no_daemon() {
|
||||
let _lock = TEST_LOCK.lock().unwrap();
|
||||
let _lock = test_lock();
|
||||
stop_daemon();
|
||||
|
||||
let bin = binary_path();
|
||||
let output = Command::new(&bin)
|
||||
.args(["status"])
|
||||
.output()
|
||||
.expect("failed to run status");
|
||||
let mut cmd = Command::new(&bin);
|
||||
cmd.args(["status"]);
|
||||
#[cfg(not(windows))]
|
||||
cmd.env("XDG_RUNTIME_DIR", runtime_dir());
|
||||
let output = cmd.output().expect("failed to run status");
|
||||
|
||||
// Should fail because daemon is not running
|
||||
assert!(!output.status.success());
|
||||
@@ -148,14 +167,15 @@ fn test_status_when_no_daemon() {
|
||||
|
||||
#[test]
|
||||
fn test_stop_when_no_daemon() {
|
||||
let _lock = TEST_LOCK.lock().unwrap();
|
||||
let _lock = test_lock();
|
||||
stop_daemon();
|
||||
|
||||
let bin = binary_path();
|
||||
let output = Command::new(&bin)
|
||||
.args(["stop"])
|
||||
.output()
|
||||
.expect("failed to run stop");
|
||||
let mut cmd = Command::new(&bin);
|
||||
cmd.args(["stop"]);
|
||||
#[cfg(not(windows))]
|
||||
cmd.env("XDG_RUNTIME_DIR", runtime_dir());
|
||||
let output = cmd.output().expect("failed to run stop");
|
||||
|
||||
// Should succeed gracefully even when daemon is not running
|
||||
assert!(output.status.success());
|
||||
@@ -167,24 +187,37 @@ fn test_stop_when_no_daemon() {
|
||||
|
||||
#[test]
|
||||
fn test_daemon_lifecycle() {
|
||||
let _lock = TEST_LOCK.lock().unwrap();
|
||||
let _lock = test_lock();
|
||||
stop_daemon();
|
||||
|
||||
// Start daemon
|
||||
let mut child = start_daemon();
|
||||
|
||||
// Wait for daemon to be ready
|
||||
assert!(
|
||||
wait_for_daemon(Duration::from_secs(5)),
|
||||
"daemon did not start within 5 seconds"
|
||||
);
|
||||
if !wait_for_daemon(Duration::from_secs(5)) {
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
if let Ok(Some(_status)) = child.try_wait() {
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.expect("failed to read daemon output");
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if stderr.contains("Operation not permitted") {
|
||||
eprintln!("skipping daemon lifecycle test: sandbox blocks Unix socket bind");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
panic!("daemon did not start within 5 seconds");
|
||||
}
|
||||
|
||||
// Check status
|
||||
let bin = binary_path();
|
||||
let output = Command::new(&bin)
|
||||
.args(["status"])
|
||||
.output()
|
||||
.expect("failed to run status");
|
||||
let mut status_cmd = Command::new(&bin);
|
||||
status_cmd.args(["status"]);
|
||||
#[cfg(not(windows))]
|
||||
status_cmd.env("XDG_RUNTIME_DIR", runtime_dir());
|
||||
let output = status_cmd.output().expect("failed to run status");
|
||||
|
||||
assert!(output.status.success());
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
@@ -195,10 +228,11 @@ fn test_daemon_lifecycle() {
|
||||
);
|
||||
|
||||
// Stop daemon
|
||||
let output = Command::new(&bin)
|
||||
.args(["stop"])
|
||||
.output()
|
||||
.expect("failed to run stop");
|
||||
let mut stop_cmd = Command::new(&bin);
|
||||
stop_cmd.args(["stop"]);
|
||||
#[cfg(not(windows))]
|
||||
stop_cmd.env("XDG_RUNTIME_DIR", runtime_dir());
|
||||
let output = stop_cmd.output().expect("failed to run stop");
|
||||
|
||||
assert!(output.status.success());
|
||||
|
||||
@@ -206,10 +240,11 @@ fn test_daemon_lifecycle() {
|
||||
std::thread::sleep(Duration::from_millis(1000));
|
||||
|
||||
// Verify daemon is stopped
|
||||
let output = Command::new(&bin)
|
||||
.args(["status"])
|
||||
.output()
|
||||
.expect("failed to run status");
|
||||
let mut final_status_cmd = Command::new(&bin);
|
||||
final_status_cmd.args(["status"]);
|
||||
#[cfg(not(windows))]
|
||||
final_status_cmd.env("XDG_RUNTIME_DIR", runtime_dir());
|
||||
let output = final_status_cmd.output().expect("failed to run status");
|
||||
|
||||
assert!(!output.status.success());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user