Compare commits
103 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 | |||
| e745c2666e | |||
| 47974483fb | |||
| 6a37e8341c | |||
| f5f5d8e61d | |||
|
|
6c51d2f4a8 | ||
|
|
6a583abe42 | ||
|
|
9894e9ee75 | ||
|
|
029045d917 | ||
|
|
3696ae93d4 | ||
|
|
90fc99dc5f | ||
|
|
fc6f3d81d6 | ||
|
|
af6ef76743 | ||
|
|
222bb217aa | ||
|
|
fb15019431 | ||
|
|
b0e3ee3b7e | ||
|
|
76162d5357 | ||
|
|
0dcc148f37 | ||
|
|
22a94eb433 | ||
|
|
b7d8137c78 | ||
|
|
d0306b2a95 | ||
|
|
99350cee4f | ||
|
|
563e584d4f | ||
|
|
12f8b78b78 | ||
|
|
40a3f7e1ff | ||
|
|
e90a1f9d40 | ||
|
|
374717a7fe | ||
|
|
e14abb46df | ||
|
|
a2e68af10c | ||
|
|
877c0969f1 | ||
|
|
ff2532018a | ||
|
|
8dc1df78c5 | ||
|
|
ba3e8ee977 | ||
|
|
2977574ef3 | ||
|
|
fb207a52bc | ||
|
|
117181fbda | ||
|
|
6d71cbe9d8 | ||
|
|
e726a82d41 | ||
|
|
f2f4db2dde | ||
|
|
9fb5057e87 | ||
|
|
1f41cb9a76 | ||
|
|
3e5c4c69ce | ||
|
|
ddab00588d | ||
|
|
1c8415717b | ||
|
|
63bfc68ee6 | ||
| b61e760044 | |||
| b88b7ef060 | |||
| a687ce2e50 | |||
| a7ef9b3816 | |||
| 811f4bc549 | |||
| c15e9f18f6 | |||
| 0cd734fc3b | |||
| a9f2b353ab | |||
| 1a30e9615b | |||
| 11ee7d8075 | |||
| ab8037c9ee | |||
| 1289d9dc6f | |||
| efc5864e90 | |||
| 132f9a50a6 | |||
| 01d446945f | |||
| 36fdd6e340 | |||
| 1831d844fb | |||
| b7736ba0ef | |||
| 7fa21c02b5 | |||
| b39d3b8024 | |||
| 5a854dd43d | |||
| 0843dedb9f | |||
| 525cffdeba | |||
| 4c0f693385 | |||
| 92f4f62475 | |||
| bea052cb68 | |||
| 854a8c2720 | |||
| d963fbac8c | |||
| 445d717881 | |||
| 64ac1cad05 | |||
| 234ae14d3d | |||
| cda7427c2d | |||
| 6d634b2eff | |||
| 1e0609504c | |||
| 8405685be3 |
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check (fmt, clippy, test, build)
|
||||
name: Check (fmt, clippy, test)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -39,5 +39,3 @@ jobs:
|
||||
- name: Run tests
|
||||
run: cargo test -- --nocapture
|
||||
|
||||
- name: Build release
|
||||
run: cargo build --release
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,2 +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.2.0"
|
||||
version = "1.14.1"
|
||||
edition = "2024"
|
||||
description = "SSH connection multiplexer for Windows - ControlMaster alternative"
|
||||
|
||||
@@ -35,6 +35,9 @@ hmac = "0.12"
|
||||
sha1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
rand = "0.8"
|
||||
ssh-key = "0.6"
|
||||
getrandom = "0.2"
|
||||
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
@@ -44,10 +47,13 @@ windows-sys = { version = "0.59", features = [
|
||||
"Win32_System_Pipes",
|
||||
"Win32_System_Memory",
|
||||
"Win32_System_Console",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_IO",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_SystemInformation",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_Foundation",
|
||||
"Win32_UI_Shell",
|
||||
] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
|
||||
271
README.md
271
README.md
@@ -7,12 +7,16 @@ Pools SSH connections and exposes a local SSH server proxy so that tools like `s
|
||||
## Features
|
||||
|
||||
- Local SSH server on `127.0.0.1` with publickey-only authentication
|
||||
- Connection pooling with configurable idle timeout
|
||||
- Keyboard-interactive (OTP) authentication via a dedicated PowerShell window (input masked)
|
||||
- Connection pooling with configurable idle timeout and absolute lifetime
|
||||
- SSH keepalive (15s interval) with automatic dead-connection reaping
|
||||
- Keyboard-interactive (OTP) authentication via Named Pipe IPC with a dedicated PowerShell prompt window (input masked via `SecureString`)
|
||||
- Interactive host key verification on first contact (fingerprint displayed, yes/no prompt)
|
||||
- Bidirectional channel relay (shell, exec, SFTP, port forwarding)
|
||||
- Jump host (bastion) support with TOML route configuration
|
||||
- Auto-generated SSH config (`setup-config`)
|
||||
- Pool lock/unlock to freeze sessions (e.g. before leaving a workstation)
|
||||
- Background service with `install` / `uninstall` (Windows Startup folder)
|
||||
- Persistent file logging (`ssh-mux.log`) with automatic rotation and panic hook
|
||||
- Works with VS Code Remote-SSH out of the box
|
||||
|
||||
## Build
|
||||
@@ -25,22 +29,29 @@ cargo build --release
|
||||
|
||||
The binary is at `target/release/ssh-mux.exe`.
|
||||
|
||||
### Pre-commit hook
|
||||
|
||||
Enable the pre-commit hook (runs the same `fmt`, `clippy`, `test` checks as CI):
|
||||
|
||||
```
|
||||
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`)
|
||||
@@ -66,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:
|
||||
|
||||
@@ -82,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 for auto-start at logon
|
||||
3. Starts the server immediately (hidden window)
|
||||
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
|
||||
@@ -121,14 +172,28 @@ ssh-mux serve -p 2222 --remote example.com:22 -u myuser
|
||||
ssh-mux serve -p 2222 --config ~/.ssh/ssh-mux-routes.toml
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
- `--timeout <secs>` — idle timeout before closing unused connections (default: 600)
|
||||
- `--max-lifetime <secs>` — absolute maximum lifetime per connection, 0 = unlimited (default: 43200 = 12 hours)
|
||||
|
||||
### `ssh-mux status` / `ssh-mux stop`
|
||||
|
||||
Show active connections or stop the daemon.
|
||||
|
||||
### `ssh-mux lock` / `ssh-mux unlock`
|
||||
|
||||
Freeze the connection pool to reject new sessions while keeping existing ones active. Useful before leaving a workstation.
|
||||
|
||||
```
|
||||
ssh-mux lock # reject new sessions
|
||||
ssh-mux unlock # resume accepting sessions
|
||||
```
|
||||
|
||||
### Daemon mode (ProxyCommand)
|
||||
|
||||
```
|
||||
ssh-mux daemon --timeout 600
|
||||
ssh-mux daemon --timeout 600 --max-lifetime 3600
|
||||
ssh-mux connect user@host:22
|
||||
```
|
||||
|
||||
@@ -144,18 +209,182 @@ One key per line, same format as `authorized_keys`. If this file does not exist,
|
||||
|
||||
## Security
|
||||
|
||||
### Network isolation
|
||||
|
||||
- Local-only binding (`127.0.0.1`) — not exposed to the network
|
||||
- Publickey-only auth for the local server (password/keyboard-interactive rejected)
|
||||
- Authorized keys file with symlink and permission checks
|
||||
- IPC pipe restricted to current user via DACL (Windows) or socket permissions (Unix)
|
||||
- OTP prompts are passed via temp files (not embedded in shell commands) to prevent injection
|
||||
- `PIPE_REJECT_REMOTE_CLIENTS` on all Named Pipes (IPC and OTP) blocks SMB access
|
||||
- Agent forwarding, X11, and remote port forwarding are explicitly denied
|
||||
- `direct-tcpip` target allowlist prevents lateral movement
|
||||
- Environment variable allowlist blocks dangerous variables
|
||||
|
||||
### Authentication and host keys
|
||||
|
||||
- Publickey-only auth for the local server (password/keyboard-interactive rejected)
|
||||
- Generated SSH config forces `PreferredAuthentications publickey` with `PasswordAuthentication no` and `KbdInteractiveAuthentication no` — prevents phishing via port-squatting on the local server
|
||||
- Interactive host key verification via IPC (fail-closed `StrictHostKeyChecking=ask`): unknown host keys are presented to the user with fingerprint for yes/no confirmation. If IPC is unavailable when interactive verification was intended, the connection is **refused** (fail-closed). Falls back to `accept-new` only when no interactive channel exists by design (e.g. internal servers via jump host)
|
||||
- Host key pinning is mandatory: if `known_hosts` cannot be written after acceptance, the connection is refused
|
||||
- Host key pre-registration: `setup-config` writes the local server's host key to `~/.ssh/ssh-mux-known-hosts` and sets `StrictHostKeyChecking yes` (falls back to `accept-new` if key is not yet available)
|
||||
- Host key changes are always rejected
|
||||
- Host key generated in-process via CSPRNG (no external `ssh-keygen` — avoids PATH hijack / binary planting)
|
||||
- PowerShell invoked via absolute path resolved through the Win32 `GetSystemDirectoryW` API (not the `%SystemRoot%` environment variable, which could be poisoned) to prevent local binary hijacking; bare `"powershell"` PATH fallback removed
|
||||
- Immediate daemon launch at install uses Rust `CreateProcess` with `CREATE_NO_WINDOW | DETACHED_PROCESS` — no PowerShell one-liner, eliminating quoting / command injection risks from paths containing `'`, `;`, or other shell metacharacters
|
||||
- Windows Startup folder resolved via Win32 Known Folder API (`FOLDERID_Startup`), resistant to `%APPDATA%` environment variable poisoning; falls back to env var only if the API fails
|
||||
- Proxy-side auth prompts sanitized to strip all terminal escape sequences (CSI, OSC, DCS, PM, APC, SOS) and control characters before display — prevents clipboard injection, title spoofing, and other terminal attacks from malicious remote servers
|
||||
- Connection pool keys include `user@host:port` — prevents cross-user connection reuse
|
||||
|
||||
### Config generation safety
|
||||
|
||||
- Route names validated against `^[A-Za-z0-9._-]+$` to prevent SSH config syntax injection
|
||||
- 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)
|
||||
|
||||
- Named Pipe protected by explicit user-SID DACL (`D:P(A;;GA;;;{user_sid})`) — **fail-closed**: if SID resolution fails, the daemon refuses to start rather than falling back to a weaker DACL
|
||||
- Anti-squatting: pipe name includes a CSPRNG-generated token stored in `{LocalAppData}\ssh-mux\daemon_token` (path resolved via Win32 Known Folder API, resistant to `%LOCALAPPDATA%` env-var poisoning); token file and directory DACL is validated on both daemon and client side to prevent unauthorized read/write; newly created token files are DACL-checked immediately after write and deleted if unsafe permissions are inherited (fail-closed)
|
||||
- OTP prompt pipes use `FILE_FLAG_FIRST_PIPE_INSTANCE` with user-SID DACL and CSPRNG-generated pipe names; SID resolution failure also aborts OTP pipe creation (fail-closed)
|
||||
- OTP prompt pipes use overlapped I/O with child-process monitoring: if the user closes the PowerShell OTP window, pending pipe operations are cancelled immediately instead of blocking the daemon
|
||||
- OTP prompt window title stays as `ssh-mux OTP`; the target host name is displayed prominently in the window body (not in the title bar / taskbar tab)
|
||||
- OTP prompts exchanged via Named Pipe IPC (`SecureString` in PowerShell); remote-supplied fields parsed from JSON after transfer rather than interpolated into script source
|
||||
- IPC protocol reads have a 30-second timeout and enforced 8 KiB per-line limit (`bounded_read_line` via `fill_buf`), preventing memory exhaustion DoS from a local client sending unbounded data without a newline terminator
|
||||
|
||||
### IPC security (Unix)
|
||||
|
||||
- Unix domain socket with 0600 permissions
|
||||
- Fallback directory `/tmp/ssh-mux-{uid}` validated on use with fail-closed policy: owner, permissions (0700), and symlink checks — validation failure returns an error (graceful exit) instead of panicking
|
||||
|
||||
### Host key verification scope
|
||||
|
||||
- `known_hosts` parser supports: plain hostnames, bracketed `[host]:port`, multiple hostnames, hashed hostnames (`|1|salt|hash`), `@revoked` markers, wildcard patterns (`*`, `?`), negation (`!pattern`), comments, and blank lines
|
||||
- Unsupported markers (e.g. `@cert-authority`) are explicitly warned and skipped — they are **not** silently treated as regular entries, preventing false confidence in verification coverage
|
||||
- `@cert-authority` guard: when a host has `@cert-authority` entries in `known_hosts` (including wildcard hostnames like `*.example.com`), non-interactive `accept-new` is **blocked** (fail-closed) to prevent silently downgrading the CA trust model. Interactive mode warns about the CA downgrade and requires explicit confirmation
|
||||
|
||||
### Exit code integrity
|
||||
|
||||
- In-band exit codes are tagged with a per-session CSPRNG nonce exchanged over the trusted IPC channel, preventing remote servers from spoofing exit status via terminal output
|
||||
|
||||
### Operational resilience
|
||||
|
||||
- File-based logging (`ssh-mux.log`) in daemon/serve modes so crashes and errors are diagnosable even when the process runs in a hidden window
|
||||
- Panic hook writes the panic message to the log file before process exit
|
||||
- Automatic log rotation at 5 MB prevents unbounded disk usage
|
||||
|
||||
### Connection lifecycle
|
||||
|
||||
- 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 **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 (overrides the active-channels rule)
|
||||
|
||||
### File integrity
|
||||
|
||||
- All security-sensitive paths (authorized keys, host key, config, known_hosts, daemon token, `SSH_MUX_SSH_DIR`) checked for symlinks/reparse points on all platforms, walking the full ancestor chain from target through every parent to the filesystem root
|
||||
- Windows home directory (`~/.ssh` base) resolved via Win32 Known Folder API (`FOLDERID_Profile`), resistant to `%USERPROFILE%` environment variable poisoning; falls back to env var only if the API fails
|
||||
- `SSH_MUX_SSH_DIR` override validated: reparse-point check on all ancestors, directory ownership/permissions verified and **enforced** (Unix: must be owned by current user or root, group/world-writable rejected — override is ignored on failure; Windows: **fail-closed if directory does not exist** — directory must be created manually with proper permissions before use, preventing unsafe DACL inheritance from permissive parent directories; reparse-point rejection **plus DACL check** — owner must be current user/SYSTEM/Administrators, unauthorized write/delete permissions rejected including `FILE_DELETE_CHILD`)
|
||||
- Unix: group/world-writable files rejected (StrictModes); directories also reject group-writable (`0o022` mask, matching OpenSSH StrictModes); directory ownership verified against current uid (must be current user or root)
|
||||
- Windows: all DACL checks go through a single centralized `check_dacl_permissions` implementation with per-context mask constants (`FILE_DANGEROUS_MASK`, `DIR_DANGEROUS_MASK`, `HOST_KEY_DANGEROUS_MASK`, `TOKEN_DANGEROUS_MASK`), ensuring consistent ACE handling across all paths; file ownership verified via `GetSecurityInfo` (must be current user, SYSTEM, or Administrators); NULL DACLs rejected (fail-closed — NULL DACL grants full access to everyone); DACL inspected to reject write/read-class permissions including `GENERIC_WRITE`, `GENERIC_ALL` granted to unauthorized SIDs; `ACCESS_ALLOWED_OBJECT_ACE_TYPE` (type 5) **fail-closed** — its variable-length layout (optional Flags + GUIDs before SID) prevents safe SID extraction, so any object ACE with dangerous permissions is rejected rather than incorrectly parsed; `GetAclInformation`/`GetAce` failures are fail-closed; reparse points (junctions) rejected on all read and write paths
|
||||
- Host key private file: Windows DACL check additionally rejects `GENERIC_READ` and `FILE_READ_DATA` from non-trusted SIDs (stricter than general file checks)
|
||||
- Authorized keys: fallback from dedicated allowlist (`ssh-mux-authorized-keys`) to `id_*.pub` scan emits a prominent security downgrade warning, indicating potential file-deletion-based policy bypass
|
||||
|
||||
## Custom SSH directory
|
||||
|
||||
If your SSH config and keys live outside `~/.ssh` (e.g. `C:\dev\.ssh`), set:
|
||||
|
||||
- **`SSH_MUX_SSH_DIR`** — absolute path to your SSH directory. All ssh-mux paths (config, keys, host key, authorized keys, known_hosts, install exe) use this directory instead of `~/.ssh`.
|
||||
|
||||
Example (PowerShell, current user):
|
||||
|
||||
```powershell
|
||||
$env:SSH_MUX_SSH_DIR = "C:\dev\.ssh"
|
||||
```
|
||||
|
||||
Then run `ssh-mux install`, `setup-config`, etc. as usual; they will use `C:\dev\.ssh`.
|
||||
|
||||
### Cursor / VS Code Remote-SSH
|
||||
|
||||
To make Cursor (or VS Code) use the same SSH config and keys:
|
||||
|
||||
1. Set **`remote.SSH.configFile`** to your config path, e.g. `C:\dev\.ssh\config` (or `C:\Users\<you>\.cursor\settings.json` / User settings).
|
||||
2. Ensure the SSH extension uses that config: it will read `config` and key paths from that directory when connecting.
|
||||
|
||||
In Cursor: **File > Preferences > Settings** (or `Ctrl+,`), search for `remote.SSH.configFile`, and set it to your config path (e.g. `C:\dev\.ssh\config`).
|
||||
|
||||
**What ssh-mux controls:** Interactive host key prompts and OTP for **remote** servers are handled by ssh-mux's own IPC only when you connect through the mux (e.g. `ProxyCommand ssh-mux proxy ...` to `127.0.0.1:2222`). If your `Host` entry talks to the real server **directly** (`ssh user@ec2...` with no mux `ProxyCommand`), Remote-SSH uses Windows' **`ssh.exe` + Cursor's askpass** for that hop -- that path never goes through ssh-mux code.
|
||||
|
||||
#### Unicode usernames and askpass (`CreateProcessW failed error:2`)
|
||||
|
||||
If your **Windows username contains non-ASCII characters** (e.g. Korean, Japanese, Chinese), Cursor's askpass script lives under `C:\Users\<name>\.cursor\...` -- a path with Unicode characters. Win32-OpenSSH's `posix_spawnp` passes this path through narrow-string `CreateProcessW`, which fails to resolve it (`error:2 = file not found`). Symptoms:
|
||||
|
||||
- Terminal SSH works fine (TTY prompts don't use askpass)
|
||||
- Remote-SSH fails with `CreateProcessW failed error:2` / `ssh_askpass: posix_spawnp: No such file or directory` / `Host key verification failed`
|
||||
- Host key verification dialogs never appear in Cursor
|
||||
|
||||
**Fix:** `setup-config` automatically generates wrapper scripts in your SSH dir (which should be an ASCII path like `C:\dev\.ssh`). Point Cursor at the wrapper:
|
||||
|
||||
1. Run `ssh-mux setup-config` -- it creates `cursor-ssh.cmd` and `cursor-askpass.cmd` in your SSH dir.
|
||||
2. Settings > search **`remote.SSH.path`** > set to the `cursor-ssh.cmd` path printed by setup-config (e.g. `C:\dev\.ssh\cursor-ssh.cmd`).
|
||||
|
||||
The wrapper intercepts `SSH_ASKPASS`, redirecting it to the ASCII-path proxy before calling `ssh.exe`, so `posix_spawnp` can find and execute the askpass program.
|
||||
|
||||
## Logging
|
||||
|
||||
In daemon and serve modes, ssh-mux logs to **`ssh-mux.log`** in the SSH directory (`~/.ssh/ssh-mux.log` or `$SSH_MUX_SSH_DIR/ssh-mux.log`) in addition to stderr. This makes it possible to diagnose crashes and connection issues even when the process runs in a hidden window.
|
||||
|
||||
- Log rotation: the file is automatically rotated (renamed to `ssh-mux.log.old`) when it exceeds 5 MB
|
||||
- Panic hook: if the process panics, the panic message is written to the log file before exit
|
||||
- The log file path is printed in the `ssh-mux install` output summary
|
||||
|
||||
## Environment variables
|
||||
|
||||
- `RUST_LOG` — controls log verbosity (e.g. `RUST_LOG=debug ssh-mux serve ...`)
|
||||
- `RUST_LOG` -- controls log verbosity (e.g. `RUST_LOG=debug ssh-mux serve ...`)
|
||||
- `SSH_MUX_SSH_DIR` -- custom SSH directory (see [Custom SSH directory](#custom-ssh-directory))
|
||||
|
||||
### OTP prompt cancelled / window closed
|
||||
|
||||
If you close the OTP PowerShell window (or it fails to launch), the daemon detects the child process exit via overlapped I/O and cancels pending pipe operations. An empty response is sent to the SSH server, which may either:
|
||||
|
||||
- **Offer another challenge** -- a new OTP window opens automatically (retry)
|
||||
- **Reject the authentication** -- the client sees `Permission denied` and exits cleanly
|
||||
|
||||
In either case the SSH terminal no longer hangs indefinitely.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Cursor / VS Code: `CreateProcessW failed error:2` / `ssh_askpass: posix_spawnp` with non-ASCII username
|
||||
|
||||
This is the **Unicode askpass** issue described in [Unicode usernames and askpass](#unicode-usernames-and-askpass-createprocessw-failed-error2) above. Run `ssh-mux setup-config` and set `remote.SSH.path` to the generated wrapper.
|
||||
|
||||
### `Permission denied (publickey,hostbased,keyboard-interactive)` and `ssh_askpass: posix_spawnp: No such file or directory` / `CreateProcessW failed error:2`
|
||||
|
||||
When connecting **through the local ssh-mux server** (`127.0.0.1` / mux `Host`), this usually means two things:
|
||||
|
||||
1. **Wrong username**
|
||||
The local ssh-mux server uses the **SSH username as the route name**. If you see `home@127.0.0.1` (or any username that is not a route name), the client is not using the right Host from your config.
|
||||
**Fix:** In Cursor/VS Code Remote-SSH, connect using the **Host alias** from your ssh-mux setup (e.g. `webserver`, `dbserver`), not `127.0.0.1` and not a host that uses a different `User`. The config generated by `setup-config` defines one Host per route; use that Host name so the client sends the correct `User` (the route name).
|
||||
|
||||
2. **Public key not allowed**
|
||||
The local server only accepts keys listed in `ssh-mux-authorized-keys` (or, if that file is missing, `~/.ssh/id_*.pub` in your SSH dir). If the key the client offers is not there, the server rejects the connection. The client may then try keyboard-interactive and run askpass, which on Windows can fail with "CreateProcessW error:2" if the askpass program is missing.
|
||||
**Fix:** Add the **same public key** you use for the jump host (or for the remote) to your SSH dir's `ssh-mux-authorized-keys` file (one key per line, OpenSSH format). Use the SSH directory that ssh-mux uses (`SSH_MUX_SSH_DIR` or `~/.ssh`).
|
||||
|
||||
Summary: use the **Host alias** (route name) in Cursor's remote target, and ensure your public key is in **ssh-mux-authorized-keys** in the same SSH directory.
|
||||
|
||||
### `channel 0: open failed: administratively prohibited: Rejected` (after successful auth)
|
||||
|
||||
Authentication to the local ssh-mux server succeeded, but opening the **session channel** was rejected. This means the server could not open the corresponding session to the **remote** (jump host or target).
|
||||
|
||||
Common causes:
|
||||
|
||||
1. **Route name not in config**
|
||||
The SSH username (e.g. `home` when you run `ssh home`) must match a route name in `ssh-mux-routes.toml`. If there is no `[routes.home]` (or whatever name you use), the server rejects the channel.
|
||||
**Fix:** Add a route with that name in your routes file and restart the daemon, or use a Host that corresponds to an existing route (e.g. `ssh webserver` if you have `[routes.webserver]`).
|
||||
|
||||
2. **Backend connection failure**
|
||||
The route exists but connecting to the jump host or target fails (unreachable, auth failure, etc.).
|
||||
**Fix:** Check the **ssh-mux daemon logs** (the process that runs `ssh-mux serve` or `ssh-mux daemon`). Look for a line like `channel open rejected for route "home": remote session failed: ...` to see the real error (e.g. "unknown route", connection refused, auth failed). Fix the route config or network/keys so the daemon can reach the jump/target.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
12
container/sshd_config
Normal file
12
container/sshd_config
Normal file
@@ -0,0 +1,12 @@
|
||||
Port 22
|
||||
PermitEmptyPasswords yes
|
||||
PasswordAuthentication yes
|
||||
PermitRootLogin no
|
||||
AllowUsers mux
|
||||
AllowTcpForwarding yes
|
||||
GatewayPorts no
|
||||
X11Forwarding no
|
||||
PrintMotd no
|
||||
AcceptEnv LANG LC_*
|
||||
Subsystem sftp none
|
||||
ForceCommand /home/mux/entrypoint.sh forward
|
||||
25
hooks/pre-commit
Normal file
25
hooks/pre-commit
Normal file
@@ -0,0 +1,25 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Pre-commit hook: runs the same checks as CI (.gitea/workflows/ci.yml)
|
||||
# 1. cargo fmt --check
|
||||
# 2. cargo clippy -- -D warnings
|
||||
# 3. cargo test
|
||||
#
|
||||
# Install: git config core.hooksPath hooks
|
||||
# Bypass: git commit --no-verify
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== pre-commit: cargo fmt --check ==="
|
||||
cargo fmt --check
|
||||
echo " OK"
|
||||
|
||||
echo "=== pre-commit: cargo clippy -- -D warnings ==="
|
||||
cargo clippy -- -D warnings
|
||||
echo " OK"
|
||||
|
||||
echo "=== pre-commit: cargo test ==="
|
||||
cargo test -- --nocapture
|
||||
echo " OK"
|
||||
|
||||
echo "=== pre-commit: all checks passed ==="
|
||||
87
src/cli.rs
87
src/cli.rs
@@ -15,7 +15,8 @@ pub struct Cli {
|
||||
pub enum Command {
|
||||
/// Start the multiplexing daemon (background connection pool)
|
||||
///
|
||||
/// Optionally also starts a local SSH server with --listen-port and --remote.
|
||||
/// Optionally also starts a local SSH server with --listen-port and --remote
|
||||
/// (direct mode) or --listen-port and --config (routed mode).
|
||||
Daemon {
|
||||
/// Idle timeout in seconds before closing unused connections (like ControlPersist)
|
||||
#[arg(short, long, default_value = "600")]
|
||||
@@ -23,20 +24,25 @@ pub enum Command {
|
||||
|
||||
/// Absolute maximum lifetime per connection in seconds (0 = unlimited).
|
||||
/// After this time, the connection is forcibly closed regardless of activity.
|
||||
#[arg(long, default_value = "0")]
|
||||
/// Default: 43200 (12 hours).
|
||||
#[arg(long, default_value = "43200")]
|
||||
max_lifetime: u64,
|
||||
|
||||
/// Also start a local SSH server on this port (requires --remote)
|
||||
#[arg(short = 'p', long)]
|
||||
listen_port: Option<u16>,
|
||||
/// Also start a local SSH server on this port
|
||||
#[arg(short = 'p', long, default_value = "2222")]
|
||||
listen_port: u16,
|
||||
|
||||
/// Remote destination in host:port format (for local SSH server)
|
||||
#[arg(short, long)]
|
||||
/// Remote destination in host:port format (direct mode)
|
||||
#[arg(short, long, conflicts_with = "config")]
|
||||
remote: Option<String>,
|
||||
|
||||
/// Remote username (for local SSH server, defaults to local username)
|
||||
#[arg(short = 'u', long)]
|
||||
/// Remote username (direct mode, defaults to local username)
|
||||
#[arg(short = 'u', long, conflicts_with = "config")]
|
||||
remote_user: Option<String>,
|
||||
|
||||
/// Path to routes config file (routed mode, default: ~/.ssh/ssh-mux-routes.toml)
|
||||
#[arg(short, long, conflicts_with = "remote")]
|
||||
config: Option<String>,
|
||||
},
|
||||
|
||||
/// Open an interactive SSH session through the daemon (reuses connections)
|
||||
@@ -123,8 +129,9 @@ pub enum Command {
|
||||
#[arg(short, long, default_value = "600")]
|
||||
timeout: u64,
|
||||
|
||||
/// Absolute maximum lifetime per connection in seconds (0 = unlimited)
|
||||
#[arg(long, default_value = "0")]
|
||||
/// Absolute maximum lifetime per connection in seconds (0 = unlimited).
|
||||
/// Default: 43200 (12 hours).
|
||||
#[arg(long, default_value = "43200")]
|
||||
max_lifetime: u64,
|
||||
},
|
||||
|
||||
@@ -146,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)]
|
||||
@@ -162,6 +216,11 @@ pub enum Command {
|
||||
/// Idle timeout in seconds
|
||||
#[arg(short, long, default_value = "600")]
|
||||
timeout: u64,
|
||||
|
||||
/// Absolute maximum lifetime per connection in seconds (0 = unlimited).
|
||||
/// Default: 43200 (12 hours).
|
||||
#[arg(long, default_value = "43200")]
|
||||
max_lifetime: u64,
|
||||
},
|
||||
|
||||
/// Remove ssh-mux background service and stop the running process
|
||||
|
||||
709
src/config.rs
709
src/config.rs
@@ -70,19 +70,21 @@ fn default_ssh_port() -> u16 {
|
||||
22
|
||||
}
|
||||
|
||||
/// Default config file path: `~/.ssh/ssh-mux-routes.toml`
|
||||
/// Default config file path: `~/.ssh/ssh-mux-routes.toml` (or SSH_MUX_SSH_DIR/ssh-mux-routes.toml)
|
||||
pub fn default_config_path() -> Result<PathBuf> {
|
||||
let home = crate::pool::get_home_dir_pub()
|
||||
.context("cannot determine home directory for config path")?;
|
||||
Ok(home.join(".ssh").join("ssh-mux-routes.toml"))
|
||||
let ssh_dir =
|
||||
crate::pool::get_ssh_dir_pub().context("cannot determine SSH directory for config path")?;
|
||||
Ok(ssh_dir.join("ssh-mux-routes.toml"))
|
||||
}
|
||||
|
||||
/// Validate that a config file is safe to read (not a symlink, proper permissions).
|
||||
fn validate_config_file(path: &Path) -> Result<()> {
|
||||
// Reject reparse points (junctions) on Windows
|
||||
crate::security::validate_path_security(path)?;
|
||||
|
||||
let meta = std::fs::symlink_metadata(path)
|
||||
.with_context(|| format!("cannot stat config file: {}", path.display()))?;
|
||||
|
||||
// SECURITY: Reject symlinks to prevent redirection attacks
|
||||
if meta.file_type().is_symlink() {
|
||||
anyhow::bail!(
|
||||
"config file is a symlink (rejected for security): {}",
|
||||
@@ -90,7 +92,6 @@ fn validate_config_file(path: &Path) -> Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
// SECURITY: On Unix, reject group/world-writable config files
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
@@ -107,9 +108,65 @@ fn validate_config_file(path: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate that a route name, username, or host field does not contain
|
||||
/// characters that could inject SSH config directives or break parsing.
|
||||
fn validate_config_field(field_name: &str, value: &str) -> Result<()> {
|
||||
if value.is_empty() {
|
||||
anyhow::bail!("config field '{}' must not be empty", field_name);
|
||||
}
|
||||
if value.contains('\n') || value.contains('\r') || value.contains('\0') {
|
||||
anyhow::bail!(
|
||||
"config field '{}' contains newline or null characters (SSH config injection risk): {:?}",
|
||||
field_name,
|
||||
value
|
||||
);
|
||||
}
|
||||
if value != value.trim() {
|
||||
anyhow::bail!(
|
||||
"config field '{}' has leading/trailing whitespace: {:?}",
|
||||
field_name,
|
||||
value
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate a route name (used as SSH Host pattern and username).
|
||||
/// Must match `^[A-Za-z0-9._-]+$` to prevent SSH config syntax issues.
|
||||
fn validate_route_name(name: &str) -> Result<()> {
|
||||
if name.is_empty() {
|
||||
anyhow::bail!("route name must not be empty");
|
||||
}
|
||||
if !name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
|
||||
{
|
||||
anyhow::bail!(
|
||||
"route name '{}' contains invalid characters (only A-Z, a-z, 0-9, '.', '_', '-' allowed)",
|
||||
name
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate all fields in the loaded config to prevent SSH config injection.
|
||||
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)?;
|
||||
|
||||
for (name, route) in &config.routes {
|
||||
validate_route_name(name).with_context(|| format!("invalid route name '{}'", name))?;
|
||||
validate_config_field(&format!("routes.{}.host", name), &route.host)?;
|
||||
validate_config_field(&format!("routes.{}.user", name), &route.user)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load configuration from a TOML file.
|
||||
///
|
||||
/// Performs security checks (symlink, permissions) before reading.
|
||||
/// Performs security checks (symlink, permissions) before reading,
|
||||
/// then validates all field values to prevent SSH config injection.
|
||||
pub fn load_config(path: &Path) -> Result<MuxConfig> {
|
||||
validate_config_file(path)?;
|
||||
|
||||
@@ -118,6 +175,8 @@ pub fn load_config(path: &Path) -> Result<MuxConfig> {
|
||||
let config: MuxConfig =
|
||||
toml::from_str(&contents).with_context(|| format!("invalid config: {}", path.display()))?;
|
||||
|
||||
validate_config_values(&config)?;
|
||||
|
||||
if config.routes.is_empty() {
|
||||
tracing::warn!("config has no routes defined");
|
||||
}
|
||||
@@ -143,25 +202,75 @@ pub fn load_config(path: &Path) -> Result<MuxConfig> {
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Generate `~/.ssh/ssh-mux-hosts.conf` from the loaded config and ensure
|
||||
/// `~/.ssh/config` includes it.
|
||||
pub fn setup_ssh_config(config: &MuxConfig, listen_port: u16) -> Result<()> {
|
||||
/// Write content to a file atomically: write to a temp file, then rename.
|
||||
/// Rejects symlink targets and sets restrictive permissions.
|
||||
pub(crate) fn atomic_write(target: &Path, content: &[u8]) -> Result<()> {
|
||||
use std::io::Write;
|
||||
|
||||
let home = crate::pool::get_home_dir_pub().context("cannot determine home directory")?;
|
||||
let ssh_dir = home.join(".ssh");
|
||||
let hosts_conf = ssh_dir.join("ssh-mux-hosts.conf");
|
||||
let ssh_config = ssh_dir.join("config");
|
||||
if target.exists() {
|
||||
let meta = std::fs::symlink_metadata(target)
|
||||
.with_context(|| format!("cannot stat {}", target.display()))?;
|
||||
if meta.file_type().is_symlink() {
|
||||
anyhow::bail!(
|
||||
"SECURITY: write target {} is a symlink (refusing to follow)",
|
||||
target.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let exe_path = std::env::current_exe().context("cannot determine ssh-mux executable path")?;
|
||||
let exe_str = exe_path.to_string_lossy();
|
||||
let parent = target.parent().context("target has no parent directory")?;
|
||||
let mut rng_bytes = [0u8; 8];
|
||||
getrandom::getrandom(&mut rng_bytes)
|
||||
.map_err(|e| anyhow::anyhow!("CSPRNG failure for temp file suffix: {}", e))?;
|
||||
let rng_hex: String = rng_bytes.iter().map(|b| format!("{:02x}", b)).collect();
|
||||
let tmp_path = parent.join(format!(
|
||||
".{}.{}.tmp",
|
||||
target.file_name().unwrap_or_default().to_string_lossy(),
|
||||
rng_hex,
|
||||
));
|
||||
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&tmp_path)
|
||||
.with_context(|| format!("cannot create temp file {}", tmp_path.display()))?;
|
||||
f.write_all(content)?;
|
||||
f.sync_all()?;
|
||||
drop(f);
|
||||
|
||||
if let Err(e) = std::fs::rename(&tmp_path, target) {
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
return Err(e).with_context(|| {
|
||||
format!(
|
||||
"cannot rename {} -> {}",
|
||||
tmp_path.display(),
|
||||
target.display()
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
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));
|
||||
@@ -169,9 +278,27 @@ pub fn setup_ssh_config(config: &MuxConfig, listen_port: u16) -> Result<()> {
|
||||
out.push_str(&format!(" Port {}\n", listen_port));
|
||||
out.push_str(&format!(" User {}\n", name));
|
||||
out.push_str(" IdentitiesOnly yes\n");
|
||||
out.push_str(" StrictHostKeyChecking accept-new\n");
|
||||
out.push_str(" UserKnownHostsFile ~/.ssh/ssh-mux-known-hosts\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,
|
||||
@@ -186,9 +313,82 @@ pub fn setup_ssh_config(config: &MuxConfig, listen_port: u16) -> Result<()> {
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
let mut f = std::fs::File::create(&hosts_conf)
|
||||
.with_context(|| format!("cannot write {}", hosts_conf.display()))?;
|
||||
f.write_all(out.as_bytes())?;
|
||||
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.
|
||||
///
|
||||
/// Also pre-registers the local server's host key into `~/.ssh/ssh-mux-known-hosts`
|
||||
/// so that `StrictHostKeyChecking yes` can be used instead of `accept-new`.
|
||||
pub fn setup_ssh_config(config: &MuxConfig, listen_port: u16) -> Result<()> {
|
||||
validate_config_values(config)?;
|
||||
|
||||
let ssh_dir = crate::pool::get_ssh_dir_pub().context("cannot determine SSH directory")?;
|
||||
let hosts_conf = ssh_dir.join("ssh-mux-hosts.conf");
|
||||
let ssh_config = ssh_dir.join("config");
|
||||
let known_hosts_path = ssh_dir.join("ssh-mux-known-hosts");
|
||||
|
||||
// Reject reparse-point redirection on write targets
|
||||
crate::security::reject_reparse_point(&ssh_dir)?;
|
||||
crate::security::validate_path_security(&hosts_conf)?;
|
||||
crate::security::validate_path_security(&ssh_config)?;
|
||||
|
||||
let exe_path = std::env::current_exe().context("cannot determine ssh-mux executable path")?;
|
||||
let exe_str = exe_path.to_string_lossy();
|
||||
|
||||
// Pre-register local server host key into ssh-mux-known-hosts
|
||||
let host_key_registered = match pre_register_host_key(&known_hosts_path, listen_port) {
|
||||
Ok(registered) => registered,
|
||||
Err(e) => {
|
||||
tracing::warn!("could not pre-register host key: {}", e);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
let strict_host_key = if host_key_registered {
|
||||
"yes"
|
||||
} else {
|
||||
"accept-new"
|
||||
};
|
||||
|
||||
let mut route_names: Vec<&String> = config.routes.keys().collect();
|
||||
route_names.sort();
|
||||
|
||||
let out = render_hosts_conf(config, listen_port, &ssh_dir, strict_host_key);
|
||||
atomic_write(&hosts_conf, out.as_bytes())?;
|
||||
|
||||
println!(
|
||||
"wrote {} ({} routes)",
|
||||
@@ -196,78 +396,50 @@ 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()
|
||||
);
|
||||
}
|
||||
|
||||
std::fs::write(&ssh_config, result)
|
||||
.with_context(|| format!("cannot write {}", ssh_config.display()))?;
|
||||
} else {
|
||||
std::fs::write(&ssh_config, format!("{}\n", include_line))
|
||||
.with_context(|| format!("cannot write {}", ssh_config.display()))?;
|
||||
atomic_write(&ssh_config, format!("{}\n", include_line).as_bytes())?;
|
||||
println!("created {} with '{}'", ssh_config.display(), include_line);
|
||||
}
|
||||
|
||||
@@ -284,6 +456,32 @@ pub fn setup_ssh_config(config: &MuxConfig, listen_port: u16) -> Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
if host_key_registered {
|
||||
println!(
|
||||
"\nHost key pre-registered in ~/.ssh/ssh-mux-known-hosts (StrictHostKeyChecking=yes)"
|
||||
);
|
||||
} else {
|
||||
println!("\nNote: host key not yet available (StrictHostKeyChecking=accept-new)");
|
||||
println!("Run setup-config again after starting the server to pin the host key.");
|
||||
}
|
||||
|
||||
// Generate askpass wrappers for Cursor/VS Code on Windows when the user's
|
||||
// home directory contains non-ASCII characters. Win32-OpenSSH's
|
||||
// posix_spawnp passes the SSH_ASKPASS path through narrow-string
|
||||
// CreateProcessW, which fails to resolve Unicode paths (error 2).
|
||||
// The wrappers live in the SSH dir (typically ASCII) and re-point
|
||||
// SSH_ASKPASS so that the .cmd file OpenSSH spawns has an ASCII path.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let needs_wrapper = ssh_dir.to_string_lossy().is_ascii()
|
||||
&& crate::pool::get_home_dir_pub()
|
||||
.map(|h: PathBuf| !h.to_string_lossy().is_ascii())
|
||||
.unwrap_or(false);
|
||||
if needs_wrapper {
|
||||
generate_askpass_wrappers(&ssh_dir)?;
|
||||
}
|
||||
}
|
||||
|
||||
println!(
|
||||
"\nMake sure ssh-mux is running: {} serve -p {} --config ~/.ssh/ssh-mux-routes.toml",
|
||||
exe_str, listen_port,
|
||||
@@ -292,6 +490,105 @@ pub fn setup_ssh_config(config: &MuxConfig, listen_port: u16) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate Cursor/VS Code askpass wrapper scripts to work around
|
||||
/// Win32-OpenSSH's inability to spawn executables at Unicode paths.
|
||||
///
|
||||
/// Creates two files in the SSH dir (which should be an ASCII path):
|
||||
/// - `cursor-askpass.cmd`: proxy that Cursor's Electron askpass env vars
|
||||
/// - `cursor-ssh.cmd`: ssh wrapper that overrides SSH_ASKPASS to the proxy
|
||||
#[cfg(windows)]
|
||||
fn generate_askpass_wrappers(ssh_dir: &Path) -> Result<()> {
|
||||
let askpass_path = ssh_dir.join("cursor-askpass.cmd");
|
||||
let ssh_wrapper_path = ssh_dir.join("cursor-ssh.cmd");
|
||||
|
||||
let askpass_content = "\
|
||||
@echo off\r\n\
|
||||
setlocal EnableExtensions\r\n\
|
||||
set ELECTRON_RUN_AS_NODE=1\r\n\
|
||||
\"%CURSOR_SSH_ELECTRON_PATH%\" \"%CURSOR_SSH_ASKPASS_JS%\" %*\r\n";
|
||||
|
||||
let ssh_wrapper_content = format!(
|
||||
"@echo off\r\n\
|
||||
if defined SSH_ASKPASS set SSH_ASKPASS={askpass}\r\n\
|
||||
C:\\Windows\\System32\\OpenSSH\\ssh.exe %*\r\n",
|
||||
askpass = askpass_path.display(),
|
||||
);
|
||||
|
||||
atomic_write(&askpass_path, askpass_content.as_bytes())?;
|
||||
atomic_write(&ssh_wrapper_path, ssh_wrapper_content.as_bytes())?;
|
||||
|
||||
println!(
|
||||
"\nGenerated Cursor SSH wrapper (Unicode askpass workaround):\
|
||||
\n {} \
|
||||
\n {}\
|
||||
\n\nSet in Cursor settings:\
|
||||
\n \"remote.SSH.path\": \"{}\"",
|
||||
askpass_path.display(),
|
||||
ssh_wrapper_path.display(),
|
||||
ssh_wrapper_path.display().to_string().replace('\\', "\\\\"),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pre-register the local SSH server's host key into `ssh-mux-known-hosts`.
|
||||
/// Returns `true` if the key was successfully registered.
|
||||
fn pre_register_host_key(known_hosts_path: &Path, _listen_port: u16) -> Result<bool> {
|
||||
use russh::keys::PublicKeyBase64;
|
||||
|
||||
let host_key = match crate::host_key::load_or_generate_host_key() {
|
||||
Ok(k) => k,
|
||||
Err(_) => return Ok(false),
|
||||
};
|
||||
|
||||
let pub_key = host_key.public_key();
|
||||
let algo = pub_key.algorithm();
|
||||
let key_base64 = pub_key.public_key_base64();
|
||||
|
||||
// HostKeyAlias causes OpenSSH to look up the alias as a plain hostname
|
||||
// (without port brackets), regardless of the actual port. So always
|
||||
// register as "ssh-mux-local <algo> <key>" — never "[ssh-mux-local]:port".
|
||||
let host_alias = "ssh-mux-local";
|
||||
let entry = format!("{} {} {}\n", host_alias, algo.as_str(), key_base64);
|
||||
|
||||
let mut existing = String::new();
|
||||
if known_hosts_path.exists() {
|
||||
existing = std::fs::read_to_string(known_hosts_path).unwrap_or_default();
|
||||
|
||||
// Check if the key is already registered in the correct format
|
||||
// (plain alias, no port brackets). Old entries may use "[ssh-mux-local]:port"
|
||||
// which doesn't work with HostKeyAlias, so always re-register.
|
||||
let correct_entry_prefix = format!("{} ", host_alias);
|
||||
let already_correct = existing.lines().any(|line| {
|
||||
let trimmed = line.trim();
|
||||
trimmed.starts_with(&correct_entry_prefix) && trimmed.contains(&key_base64)
|
||||
});
|
||||
if already_correct {
|
||||
tracing::debug!("host key already registered in ssh-mux-known-hosts");
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// Remove all old ssh-mux-local entries (both plain and bracketed)
|
||||
let filtered: Vec<&str> = existing
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
let trimmed = line.trim();
|
||||
!trimmed.starts_with("ssh-mux-local ") && !trimmed.starts_with("[ssh-mux-local]:")
|
||||
})
|
||||
.collect();
|
||||
existing = filtered.join("\n");
|
||||
if !existing.is_empty() && !existing.ends_with('\n') {
|
||||
existing.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
existing.push_str(&entry);
|
||||
atomic_write(known_hosts_path, existing.as_bytes())?;
|
||||
tracing::info!("pre-registered host key in {}", known_hosts_path.display());
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -345,4 +642,254 @@ user = "deploy"
|
||||
assert_eq!(config.jump.port, 22);
|
||||
assert_eq!(config.routes["server1"].port, 22);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_route_name_valid() {
|
||||
assert!(validate_route_name("webserver").is_ok());
|
||||
assert!(validate_route_name("web-server").is_ok());
|
||||
assert!(validate_route_name("web_server").is_ok());
|
||||
assert!(validate_route_name("web.server.1").is_ok());
|
||||
assert!(validate_route_name("Server01").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_route_name_invalid() {
|
||||
assert!(validate_route_name("").is_err());
|
||||
assert!(validate_route_name("web server").is_err());
|
||||
assert!(validate_route_name("web\nserver").is_err());
|
||||
assert!(validate_route_name("web@server").is_err());
|
||||
assert!(validate_route_name("web;server").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_field_rejects_injection() {
|
||||
assert!(validate_config_field("test", "normal").is_ok());
|
||||
assert!(validate_config_field("test", "value\nProxyCommand evil").is_err());
|
||||
assert!(validate_config_field("test", "value\rinjection").is_err());
|
||||
assert!(validate_config_field("test", "value\0null").is_err());
|
||||
assert!(validate_config_field("test", " leading").is_err());
|
||||
assert!(validate_config_field("test", "trailing ").is_err());
|
||||
assert!(validate_config_field("test", "").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_values() {
|
||||
let toml_str = r#"
|
||||
[jump]
|
||||
host = "bastion.example.com"
|
||||
port = 22
|
||||
user = "jumpuser"
|
||||
|
||||
[routes.webserver]
|
||||
host = "10.0.0.10"
|
||||
port = 22
|
||||
user = "deploy"
|
||||
"#;
|
||||
let config: MuxConfig = toml::from_str(toml_str).unwrap();
|
||||
assert!(validate_config_values(&config).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_values_rejects_newline_in_host() {
|
||||
let toml_str = "
|
||||
[jump]
|
||||
host = \"bastion\\nexample.com\"
|
||||
port = 22
|
||||
user = \"jumpuser\"
|
||||
";
|
||||
let config: MuxConfig = toml::from_str(toml_str).unwrap();
|
||||
assert!(validate_config_values(&config).is_err());
|
||||
}
|
||||
|
||||
// --- Security regression tests ---
|
||||
|
||||
#[test]
|
||||
fn test_route_name_rejects_ssh_config_injection_chars() {
|
||||
// Characters that could break SSH config syntax
|
||||
assert!(validate_route_name("web server").is_err());
|
||||
assert!(validate_route_name("web\tserver").is_err());
|
||||
assert!(validate_route_name("web\"server").is_err());
|
||||
assert!(validate_route_name("web=server").is_err());
|
||||
assert!(validate_route_name("web#server").is_err());
|
||||
assert!(validate_route_name("*").is_err());
|
||||
assert!(validate_route_name("?").is_err());
|
||||
assert!(validate_route_name("web/server").is_err());
|
||||
assert!(validate_route_name("web\\server").is_err());
|
||||
assert!(validate_route_name("web:server").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_field_rejects_null_byte() {
|
||||
assert!(validate_config_field("host", "host\0evil").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_field_rejects_carriage_return() {
|
||||
assert!(validate_config_field("host", "host\rProxyCommand evil").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_field_rejects_newline_injection() {
|
||||
assert!(validate_config_field("host", "bastion\nProxyCommand /tmp/evil").is_err());
|
||||
assert!(validate_config_field("user", "admin\n ProxyCommand /tmp/evil").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_rejects_injection_in_route_user() {
|
||||
let toml_str = r#"
|
||||
[jump]
|
||||
host = "bastion.example.com"
|
||||
port = 22
|
||||
user = "jumpuser"
|
||||
|
||||
[routes.web]
|
||||
host = "10.0.0.1"
|
||||
port = 22
|
||||
user = "deploy\nProxyCommand evil"
|
||||
"#;
|
||||
let config: MuxConfig = toml::from_str(toml_str).unwrap();
|
||||
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 = "
|
||||
[jump]
|
||||
host = \"bastion\"
|
||||
port = 22
|
||||
user = \"user\\nMatch all\"
|
||||
";
|
||||
let config: MuxConfig = toml::from_str(toml_str).unwrap();
|
||||
assert!(validate_config_values(&config).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
328
src/daemon.rs
328
src/daemon.rs
@@ -12,12 +12,20 @@ use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::sync::{Mutex, watch};
|
||||
|
||||
/// Optional local SSH server configuration for the daemon.
|
||||
pub struct LocalServerOpts {
|
||||
pub listen_port: u16,
|
||||
pub remote_host: String,
|
||||
pub remote_port: u16,
|
||||
pub remote_user: Option<String>,
|
||||
/// Local SSH server configuration for the daemon.
|
||||
pub enum LocalServerOpts {
|
||||
/// Direct mode: proxy to a single remote host.
|
||||
Direct {
|
||||
listen_port: u16,
|
||||
remote_host: String,
|
||||
remote_port: u16,
|
||||
remote_user: Option<String>,
|
||||
},
|
||||
/// Routed mode: route connections via a jump host per config file.
|
||||
Routed {
|
||||
listen_port: u16,
|
||||
config: crate::config::MuxConfig,
|
||||
},
|
||||
}
|
||||
|
||||
/// Run the daemon.
|
||||
@@ -54,9 +62,13 @@ pub async fn run(
|
||||
let pool = Arc::new(Pool::new(timeout_secs, max_lifetime_secs));
|
||||
let allowed_hosts = Arc::new(allowed_hosts);
|
||||
|
||||
register_ssh_host_aliases(&pool).await;
|
||||
|
||||
// Shutdown signal channel
|
||||
let (shutdown_tx, mut shutdown_rx) = watch::channel(false);
|
||||
|
||||
let has_local_server = local_server.is_some();
|
||||
|
||||
// Optionally start the local SSH server
|
||||
if let Some(opts) = local_server {
|
||||
let host_key = crate::host_key::load_or_generate_host_key()?;
|
||||
@@ -64,14 +76,28 @@ pub async fn run(
|
||||
let mut server_shutdown = shutdown_rx.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
result = crate::local_server::run(
|
||||
opts.listen_port,
|
||||
opts.remote_host,
|
||||
opts.remote_port,
|
||||
opts.remote_user,
|
||||
pool_clone,
|
||||
host_key,
|
||||
) => {
|
||||
result = async {
|
||||
match opts {
|
||||
LocalServerOpts::Direct { listen_port, remote_host, remote_port, remote_user } => {
|
||||
crate::local_server::run(
|
||||
listen_port,
|
||||
remote_host,
|
||||
remote_port,
|
||||
remote_user,
|
||||
pool_clone,
|
||||
host_key,
|
||||
).await
|
||||
}
|
||||
LocalServerOpts::Routed { listen_port, config } => {
|
||||
crate::local_server::run_with_config(
|
||||
listen_port,
|
||||
config,
|
||||
pool_clone,
|
||||
host_key,
|
||||
).await
|
||||
}
|
||||
}
|
||||
} => {
|
||||
if let Err(e) = result {
|
||||
tracing::error!("local SSH server error: {:#}", e);
|
||||
}
|
||||
@@ -84,7 +110,9 @@ pub async fn run(
|
||||
}
|
||||
|
||||
// Spawn idle connection cleanup task.
|
||||
// Also auto-shuts down the daemon if there are no connections for `timeout_secs`.
|
||||
// Auto-shuts down the daemon if there are no connections for `timeout_secs`,
|
||||
// but only when no local SSH server is running (the server needs the daemon
|
||||
// to stay alive to accept new connections at any time).
|
||||
let cleanup_pool = pool.clone();
|
||||
let mut cleanup_shutdown = shutdown_rx.clone();
|
||||
let cleanup_shutdown_tx = shutdown_tx.clone();
|
||||
@@ -97,19 +125,21 @@ pub async fn run(
|
||||
_ = interval.tick() => {
|
||||
cleanup_pool.cleanup_idle().await;
|
||||
|
||||
// Track how long the pool has been completely empty
|
||||
if cleanup_pool.is_empty().await {
|
||||
let idle_start = idle_since.get_or_insert_with(std::time::Instant::now);
|
||||
if idle_start.elapsed() >= daemon_timeout {
|
||||
tracing::info!(
|
||||
"no connections for {}s, daemon shutting down automatically",
|
||||
timeout_secs
|
||||
);
|
||||
let _ = cleanup_shutdown_tx.send(true);
|
||||
break;
|
||||
if !has_local_server {
|
||||
// Track how long the pool has been completely empty
|
||||
if cleanup_pool.is_empty().await {
|
||||
let idle_start = idle_since.get_or_insert_with(std::time::Instant::now);
|
||||
if idle_start.elapsed() >= daemon_timeout {
|
||||
tracing::info!(
|
||||
"no connections for {}s, daemon shutting down automatically",
|
||||
timeout_secs
|
||||
);
|
||||
let _ = cleanup_shutdown_tx.send(true);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
idle_since = None;
|
||||
}
|
||||
} else {
|
||||
idle_since = None;
|
||||
}
|
||||
}
|
||||
_ = cleanup_shutdown.changed() => {
|
||||
@@ -225,23 +255,36 @@ fn is_host_allowed(allowed: &HashSet<String>, host: &str, port: u16) -> bool {
|
||||
allowed.contains(&key) || allowed.contains(host)
|
||||
}
|
||||
|
||||
/// Read a request line from the stream (no &str across await).
|
||||
/// Read a request line from the stream with timeout (no &str across await).
|
||||
async fn read_request_line(stream: &mut IpcStream) -> Result<Request> {
|
||||
let mut buf = vec![0u8; MAX_LINE_LENGTH];
|
||||
let mut total = 0;
|
||||
|
||||
loop {
|
||||
let n = stream.read(&mut buf[total..]).await?;
|
||||
if n == 0 {
|
||||
anyhow::bail!("client disconnected before sending request");
|
||||
}
|
||||
total += n;
|
||||
if buf[..total].contains(&b'\n') {
|
||||
break;
|
||||
}
|
||||
if total >= buf.len() {
|
||||
anyhow::bail!("request line too long (>{} bytes)", MAX_LINE_LENGTH);
|
||||
let result = tokio::time::timeout(IPC_READ_TIMEOUT, async {
|
||||
loop {
|
||||
let n = stream.read(&mut buf[total..]).await?;
|
||||
if n == 0 {
|
||||
anyhow::bail!("client disconnected before sending request");
|
||||
}
|
||||
total += n;
|
||||
if buf[..total].contains(&b'\n') {
|
||||
break;
|
||||
}
|
||||
if total >= buf.len() {
|
||||
anyhow::bail!("request line too long (>{} bytes)", MAX_LINE_LENGTH);
|
||||
}
|
||||
}
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(_) => anyhow::bail!(
|
||||
"IPC request read timed out ({}s)",
|
||||
IPC_READ_TIMEOUT.as_secs()
|
||||
),
|
||||
}
|
||||
|
||||
let line = String::from_utf8_lossy(&buf[..total]).to_string();
|
||||
@@ -299,12 +342,14 @@ async fn handle_connect(
|
||||
let pool_clone = pool.clone();
|
||||
let host_clone = host.clone();
|
||||
let user_for_pool = user.clone();
|
||||
let friendly = pool.resolve_display_name(&host, port).await;
|
||||
let mut channel_task = tokio::spawn(async move {
|
||||
pool_clone
|
||||
.open_channel(
|
||||
&host_clone,
|
||||
port,
|
||||
user_for_pool.as_deref(),
|
||||
&friendly,
|
||||
auth_prompt_tx,
|
||||
auth_response_rx,
|
||||
)
|
||||
@@ -400,12 +445,14 @@ async fn handle_session(
|
||||
let pool_clone = pool.clone();
|
||||
let host_clone = host.clone();
|
||||
let user_for_pool = user.clone();
|
||||
let friendly = pool.resolve_display_name(&host, port).await;
|
||||
let mut session_task = tokio::spawn(async move {
|
||||
pool_clone
|
||||
.open_session(
|
||||
&host_clone,
|
||||
port,
|
||||
user_for_pool.as_deref(),
|
||||
&friendly,
|
||||
auth_prompt_tx,
|
||||
auth_response_rx,
|
||||
)
|
||||
@@ -459,6 +506,9 @@ async fn handle_session(
|
||||
|
||||
match session_result {
|
||||
Ok(channel) => {
|
||||
// Generate per-session nonce for exit-code framing
|
||||
let session_nonce = generate_session_nonce();
|
||||
|
||||
// Ask the proxy for the terminal window size
|
||||
stream.write_all(b"WINDOW_SIZE_REQ\n").await?;
|
||||
stream.flush().await?;
|
||||
@@ -500,8 +550,13 @@ async fn handle_session(
|
||||
}
|
||||
}
|
||||
|
||||
// Send nonce to proxy before OK so it knows the exit-code marker
|
||||
let nonce_line = format!("NONCE {}\n", session_nonce);
|
||||
stream.write_all(nonce_line.as_bytes()).await?;
|
||||
stream.flush().await?;
|
||||
|
||||
write_ok_owned(&mut stream, String::new()).await?;
|
||||
relay_session(stream, channel, host, port, user, pool).await?;
|
||||
relay_session(stream, channel, host, port, user, pool, session_nonce).await?;
|
||||
}
|
||||
Err(e) => {
|
||||
// SECURITY: Log full error internally, send generic message to client.
|
||||
@@ -513,11 +568,22 @@ async fn handle_session(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate a random nonce for exit-code framing.
|
||||
fn generate_session_nonce() -> String {
|
||||
let mut buf = [0u8; 16];
|
||||
getrandom::getrandom(&mut buf).expect("CSPRNG failure");
|
||||
buf.iter().map(|b| format!("{:02x}", b)).collect()
|
||||
}
|
||||
|
||||
/// Relay bytes between a local IPC stream and an SSH session channel.
|
||||
/// Unlike relay_channel (for direct-tcpip), this handles:
|
||||
/// - ExtendedData (stderr) from the server
|
||||
/// - ExitStatus from the server (sent back as EXIT_STATUS line)
|
||||
/// - ExitStatus from the server (sent via nonce-tagged escape sequence)
|
||||
/// - Window size changes from the client (WINDOW_SIZE lines)
|
||||
///
|
||||
/// The exit code is transmitted as `\x1b]ssh-mux;{nonce};exit=N\x07`.
|
||||
/// The nonce is exchanged over IPC (NONCE line) before data relay begins,
|
||||
/// preventing the remote server from spoofing exit codes.
|
||||
async fn relay_session(
|
||||
stream: IpcStream,
|
||||
mut channel: russh::Channel<russh::client::Msg>,
|
||||
@@ -525,6 +591,7 @@ async fn relay_session(
|
||||
port: u16,
|
||||
user: Option<String>,
|
||||
pool: Arc<Pool>,
|
||||
session_nonce: String,
|
||||
) -> Result<()> {
|
||||
let (mut stream_read, mut stream_write) = tokio::io::split(stream);
|
||||
|
||||
@@ -546,7 +613,6 @@ async fn relay_session(
|
||||
let _ = channel.eof().await;
|
||||
}
|
||||
Ok(n) => {
|
||||
// Check for in-band control messages (WINDOW_SIZE)
|
||||
let data = &buf_from_client[..n];
|
||||
if data.starts_with(b"WINDOW_SIZE ") {
|
||||
if let Ok(s) = std::str::from_utf8(data) {
|
||||
@@ -579,17 +645,13 @@ async fn relay_session(
|
||||
stream_write.write_all(&data).await?;
|
||||
stream_write.flush().await?;
|
||||
}
|
||||
Some(russh::ChannelMsg::ExtendedData { data, ext }) => {
|
||||
// ext == 1 is stderr
|
||||
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);
|
||||
// Send exit status as an in-band message
|
||||
let msg = format!("\x1b]ssh-mux;exit={}\x07", exit_status);
|
||||
let msg = format!("\x1b]ssh-mux;{};exit={}\x07", session_nonce, exit_status);
|
||||
stream_write.write_all(msg.as_bytes()).await?;
|
||||
stream_write.flush().await?;
|
||||
}
|
||||
@@ -622,24 +684,36 @@ async fn relay_session(
|
||||
/// Maximum line length for IPC protocol messages (8 KiB).
|
||||
const MAX_LINE_LENGTH: usize = 8192;
|
||||
|
||||
/// Read a single line from the IPC stream.
|
||||
/// Timeout for reading a single IPC request/response line (30 seconds).
|
||||
const IPC_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
|
||||
/// Read a single line from the IPC stream with timeout.
|
||||
async fn read_line_from_stream(stream: &mut IpcStream) -> Result<String> {
|
||||
let mut buf = Vec::with_capacity(4096);
|
||||
let mut byte = [0u8; 1];
|
||||
loop {
|
||||
let n = stream.read(&mut byte).await?;
|
||||
if n == 0 {
|
||||
anyhow::bail!("proxy disconnected");
|
||||
}
|
||||
buf.push(byte[0]);
|
||||
if byte[0] == b'\n' {
|
||||
break;
|
||||
}
|
||||
if buf.len() > MAX_LINE_LENGTH {
|
||||
anyhow::bail!("auth response line too long (>{} bytes)", MAX_LINE_LENGTH);
|
||||
let result = tokio::time::timeout(IPC_READ_TIMEOUT, async {
|
||||
let mut buf = Vec::with_capacity(4096);
|
||||
let mut byte = [0u8; 1];
|
||||
loop {
|
||||
let n = stream.read(&mut byte).await?;
|
||||
if n == 0 {
|
||||
anyhow::bail!("proxy disconnected");
|
||||
}
|
||||
buf.push(byte[0]);
|
||||
if byte[0] == b'\n' {
|
||||
break;
|
||||
}
|
||||
if buf.len() > MAX_LINE_LENGTH {
|
||||
anyhow::bail!("auth response line too long (>{} bytes)", MAX_LINE_LENGTH);
|
||||
}
|
||||
}
|
||||
Ok::<String, anyhow::Error>(String::from_utf8_lossy(&buf).to_string())
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(s)) => Ok(s),
|
||||
Ok(Err(e)) => Err(e),
|
||||
Err(_) => anyhow::bail!("IPC line read timed out ({}s)", IPC_READ_TIMEOUT.as_secs()),
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&buf).to_string())
|
||||
}
|
||||
|
||||
/// Relay bytes between a local IPC stream and an SSH channel.
|
||||
@@ -713,3 +787,127 @@ async fn relay_channel(
|
||||
tracing::debug!("relay finished for {}:{}", host, port);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse SSH config files to build a HostName→Host alias mapping and register
|
||||
/// it with the pool so OTP prompts show the friendly name.
|
||||
///
|
||||
/// When `SSH_MUX_SSH_DIR` points to a custom directory, both the custom
|
||||
/// config and the default `~/.ssh/config` are parsed so that aliases
|
||||
/// defined in the user's main config (e.g. `Host bastion`) are still
|
||||
/// available for display name resolution.
|
||||
pub async fn register_ssh_host_aliases(pool: &Pool) {
|
||||
let mut registrations = Vec::new();
|
||||
|
||||
// 1. Parse the SSH dir config (may be custom via SSH_MUX_SSH_DIR)
|
||||
if let Some(ssh_dir) = crate::pool::get_ssh_dir_pub() {
|
||||
let ssh_config = ssh_dir.join("config");
|
||||
parse_ssh_config_file(&ssh_config, &ssh_dir, &mut registrations);
|
||||
}
|
||||
|
||||
// 2. Also parse the default ~/.ssh/config if it differs from the above,
|
||||
// so that aliases like "bastion" (defined in the user's main config)
|
||||
// are registered for OTP display.
|
||||
if let Some(home) = crate::pool::get_home_dir_pub() {
|
||||
let default_ssh_dir = home.join(".ssh");
|
||||
let default_config = default_ssh_dir.join("config");
|
||||
let custom_config = crate::pool::get_ssh_dir_pub().map(|d| d.join("config"));
|
||||
if custom_config.as_deref() != Some(default_config.as_path()) {
|
||||
parse_ssh_config_file(&default_config, &default_ssh_dir, &mut registrations);
|
||||
}
|
||||
}
|
||||
|
||||
let count = registrations.len();
|
||||
for (hostname, port, alias) in registrations {
|
||||
pool.register_display_name(&hostname, port, alias).await;
|
||||
}
|
||||
if count > 0 {
|
||||
tracing::debug!("registered {} SSH host alias(es) for OTP display", count);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_ssh_config_file(
|
||||
path: &std::path::Path,
|
||||
ssh_dir: &std::path::Path,
|
||||
registrations: &mut Vec<(String, u16, String)>,
|
||||
) {
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let mut current_host: Option<String> = None;
|
||||
let mut current_hostname: Option<String> = None;
|
||||
let mut current_port: Option<u16> = None;
|
||||
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let keyword_value = |prefix: &str| -> Option<&str> {
|
||||
trimmed
|
||||
.strip_prefix(prefix)
|
||||
.filter(|r| r.starts_with(' ') || r.starts_with('\t'))
|
||||
.map(|r| r.trim())
|
||||
};
|
||||
|
||||
if let Some(rest) = keyword_value("Host") {
|
||||
flush_host(
|
||||
¤t_host,
|
||||
¤t_hostname,
|
||||
¤t_port,
|
||||
registrations,
|
||||
);
|
||||
current_host = rest.split_whitespace().next().map(|s| s.to_string());
|
||||
current_hostname = None;
|
||||
current_port = None;
|
||||
} else if let Some(val) = keyword_value("HostName") {
|
||||
current_hostname = Some(val.to_string());
|
||||
} else if let Some(val) = keyword_value("Port") {
|
||||
current_port = val.parse().ok();
|
||||
} else if let Some(pattern) = keyword_value("Include") {
|
||||
flush_host(
|
||||
¤t_host,
|
||||
¤t_hostname,
|
||||
¤t_port,
|
||||
registrations,
|
||||
);
|
||||
current_host = None;
|
||||
current_hostname = None;
|
||||
current_port = None;
|
||||
|
||||
let expanded = if let Some(rest) = pattern.strip_prefix('~') {
|
||||
let home = ssh_dir.parent().unwrap_or(ssh_dir);
|
||||
home.join(rest.trim_start_matches(['/', '\\']))
|
||||
} else if std::path::Path::new(pattern).is_relative() {
|
||||
ssh_dir.join(pattern)
|
||||
} else {
|
||||
std::path::PathBuf::from(pattern)
|
||||
};
|
||||
if expanded.is_file() {
|
||||
parse_ssh_config_file(&expanded, ssh_dir, registrations);
|
||||
}
|
||||
}
|
||||
}
|
||||
flush_host(
|
||||
¤t_host,
|
||||
¤t_hostname,
|
||||
¤t_port,
|
||||
registrations,
|
||||
);
|
||||
}
|
||||
|
||||
fn flush_host(
|
||||
host: &Option<String>,
|
||||
hostname: &Option<String>,
|
||||
port: &Option<u16>,
|
||||
registrations: &mut Vec<(String, u16, String)>,
|
||||
) {
|
||||
if let (Some(alias), Some(resolved)) = (host, hostname)
|
||||
&& !alias.contains('*')
|
||||
&& !alias.contains('?')
|
||||
{
|
||||
registrations.push((resolved.clone(), port.unwrap_or(22), alias.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Host key management for the local SSH server.
|
||||
//!
|
||||
//! Generates and persists an Ed25519 host key at `~/.ssh/ssh-mux-host-key`.
|
||||
//! The key is created on first run and reused thereafter.
|
||||
//! The key is created on first run via `PrivateKey::random()` (no external
|
||||
//! process) and reused thereafter.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use russh::keys::PrivateKey;
|
||||
@@ -9,24 +10,24 @@ use std::path::PathBuf;
|
||||
|
||||
/// Get the path to the host key file.
|
||||
fn host_key_path() -> Result<PathBuf> {
|
||||
let home = crate::pool::get_home_dir_pub().context("cannot determine home directory")?;
|
||||
Ok(home.join(".ssh").join("ssh-mux-host-key"))
|
||||
let ssh_dir = crate::pool::get_ssh_dir_pub().context("cannot determine SSH directory")?;
|
||||
Ok(ssh_dir.join("ssh-mux-host-key"))
|
||||
}
|
||||
|
||||
/// Load or generate the local SSH server's host key.
|
||||
///
|
||||
/// If the key file exists, loads it (after symlink and permission checks).
|
||||
/// Otherwise, generates a new Ed25519 key and saves it to `~/.ssh/ssh-mux-host-key`.
|
||||
/// Otherwise, generates a new Ed25519 key in-process using CSPRNG and saves
|
||||
/// it to `~/.ssh/ssh-mux-host-key`.
|
||||
///
|
||||
/// Security:
|
||||
/// - Rejects symlinks at the key path (prevents symlink attacks)
|
||||
/// - Rejects symlinks / reparse points at the key path
|
||||
/// - Verifies file permissions on Unix (must be 0600 or stricter)
|
||||
/// - Sets umask before ssh-keygen on Unix
|
||||
/// - Key generated in-process (no external `ssh-keygen` — avoids PATH hijack)
|
||||
pub fn load_or_generate_host_key() -> Result<PrivateKey> {
|
||||
let path = host_key_path()?;
|
||||
|
||||
if path.exists() {
|
||||
// Security: reject symlinks
|
||||
let meta = std::fs::symlink_metadata(&path)
|
||||
.with_context(|| format!("cannot stat {}", path.display()))?;
|
||||
if meta.file_type().is_symlink() {
|
||||
@@ -37,7 +38,6 @@ pub fn load_or_generate_host_key() -> Result<PrivateKey> {
|
||||
);
|
||||
}
|
||||
|
||||
// Security: reject reparse points (junctions) on Windows
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
@@ -49,9 +49,9 @@ pub fn load_or_generate_host_key() -> Result<PrivateKey> {
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
crate::local_server::check_host_key_dacl(&path)?;
|
||||
}
|
||||
|
||||
// Security: check permissions on Unix
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
@@ -73,7 +73,7 @@ pub fn load_or_generate_host_key() -> Result<PrivateKey> {
|
||||
return Ok(key);
|
||||
}
|
||||
|
||||
// Security: reject if path is a dangling symlink
|
||||
// Reject dangling symlinks
|
||||
if std::fs::symlink_metadata(&path).is_ok() {
|
||||
anyhow::bail!(
|
||||
"SECURITY: host key path {} exists as a dangling symlink. Remove it first.",
|
||||
@@ -83,7 +83,9 @@ pub fn load_or_generate_host_key() -> Result<PrivateKey> {
|
||||
|
||||
tracing::info!("generating new Ed25519 host key at {}", path.display());
|
||||
|
||||
// Ensure ~/.ssh directory exists with proper permissions
|
||||
// Reject reparse-point redirection on parent directory
|
||||
crate::security::validate_path_security(&path)?;
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create directory {}", parent.display()))?;
|
||||
@@ -95,51 +97,27 @@ pub fn load_or_generate_host_key() -> Result<PrivateKey> {
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a new Ed25519 host key using ssh-keygen
|
||||
// On Unix, set restrictive umask before generation
|
||||
// Generate Ed25519 key in-process using CSPRNG (no external ssh-keygen)
|
||||
let key = PrivateKey::random(&mut rand::rngs::OsRng, russh::keys::Algorithm::Ed25519)
|
||||
.map_err(|e| anyhow::anyhow!("failed to generate Ed25519 host key: {}", e))?;
|
||||
|
||||
// Write key in OpenSSH PEM format
|
||||
#[cfg(unix)]
|
||||
let _old_umask = unsafe { libc::umask(0o077) };
|
||||
|
||||
let status = std::process::Command::new("ssh-keygen")
|
||||
.args([
|
||||
"-t",
|
||||
"ed25519",
|
||||
"-f",
|
||||
&path.to_string_lossy(),
|
||||
"-N",
|
||||
"", // empty passphrase
|
||||
"-q",
|
||||
])
|
||||
.status()
|
||||
.context("failed to run ssh-keygen")?;
|
||||
key.write_openssh_file(&path, ssh_key::LineEnding::LF)
|
||||
.map_err(|e| anyhow::anyhow!("failed to write host key to {}: {}", path.display(), e))?;
|
||||
|
||||
// Restore umask on Unix
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::umask(_old_umask);
|
||||
}
|
||||
|
||||
if !status.success() {
|
||||
anyhow::bail!("ssh-keygen failed with exit code {:?}", status.code());
|
||||
}
|
||||
|
||||
// Remove the .pub file (we don't need it)
|
||||
let pub_path = path.with_extension("pub");
|
||||
if pub_path.exists() {
|
||||
let _ = std::fs::remove_file(&pub_path);
|
||||
}
|
||||
|
||||
// Load the generated key
|
||||
let key = russh::keys::load_secret_key(&path, None)
|
||||
.with_context(|| format!("failed to load generated host key from {}", path.display()))?;
|
||||
|
||||
// Verify and enforce restrictive permissions on the key file
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
|
||||
|
||||
// Verify permissions were actually set
|
||||
let meta = std::fs::metadata(&path)?;
|
||||
let mode = std::os::unix::fs::MetadataExt::mode(&meta) & 0o7777;
|
||||
if mode & 0o077 != 0 {
|
||||
|
||||
1014
src/import.rs
Normal file
1014
src/import.rs
Normal file
File diff suppressed because it is too large
Load Diff
421
src/ipc.rs
421
src/ipc.rs
@@ -4,7 +4,8 @@
|
||||
//! - PIPE_REJECT_REMOTE_CLIENTS prevents network access
|
||||
//! - Anti-squatting: pipe name includes a per-user random token stored in
|
||||
//! `%LOCALAPPDATA%\ssh-mux\daemon_token` (only readable by the owning user)
|
||||
//! - DACL: `D:P(A;;GA;;;OW)` restricts access to the pipe owner
|
||||
//! - DACL: `D:P(A;;GA;;;{user_sid})` restricts access to the current user's
|
||||
//! specific SID (fail-closed if SID resolution fails — daemon refuses to start)
|
||||
//! - Unix: Unix Domain Socket (`$XDG_RUNTIME_DIR/ssh-mux.sock` or fallback)
|
||||
//! - File permissions restrict access to current user
|
||||
|
||||
@@ -96,11 +97,14 @@ mod platform {
|
||||
|
||||
/// Directory and file for the daemon token used to prevent pipe squatting.
|
||||
///
|
||||
/// The token is a random hex string stored in `%LOCALAPPDATA%\ssh-mux\daemon_token`.
|
||||
/// Only the current user can read it (inherits LOCALAPPDATA ACL), so an
|
||||
/// attacker on the same machine under a different account cannot predict
|
||||
/// the pipe name and therefore cannot squat it.
|
||||
/// The token is a random hex string stored in `{LocalAppData}\ssh-mux\daemon_token`.
|
||||
/// Resolved via the Win32 Known Folder API (`SHGetKnownFolderPath`) to
|
||||
/// avoid `%LOCALAPPDATA%` env-var poisoning. Falls back to the env var
|
||||
/// only if the API fails.
|
||||
fn token_dir() -> std::path::PathBuf {
|
||||
if let Some(dir) = known_folder_local_app_data() {
|
||||
return dir.join("ssh-mux");
|
||||
}
|
||||
let local_app = std::env::var("LOCALAPPDATA").unwrap_or_else(|_| {
|
||||
let profile =
|
||||
std::env::var("USERPROFILE").unwrap_or_else(|_| r"C:\Users\default".into());
|
||||
@@ -109,16 +113,82 @@ mod platform {
|
||||
std::path::PathBuf::from(local_app).join("ssh-mux")
|
||||
}
|
||||
|
||||
/// Resolve LocalAppData via the Win32 Known Folder API, which is not
|
||||
/// influenced by environment variables.
|
||||
fn known_folder_local_app_data() -> Option<std::path::PathBuf> {
|
||||
use std::ffi::OsString;
|
||||
use std::os::windows::ffi::OsStringExt;
|
||||
use std::ptr;
|
||||
|
||||
// FOLDERID_LocalAppData = {F1B32785-6FBA-4FCF-9D55-7B8E7F157091}
|
||||
let folderid = windows_sys::core::GUID {
|
||||
data1: 0xF1B32785,
|
||||
data2: 0x6FBA,
|
||||
data3: 0x4FCF,
|
||||
data4: [0x9D, 0x55, 0x7B, 0x8E, 0x7F, 0x15, 0x70, 0x91],
|
||||
};
|
||||
|
||||
let mut path_ptr: *mut u16 = ptr::null_mut();
|
||||
let hr = unsafe {
|
||||
windows_sys::Win32::UI::Shell::SHGetKnownFolderPath(
|
||||
&folderid,
|
||||
0,
|
||||
ptr::null_mut(),
|
||||
&mut path_ptr,
|
||||
)
|
||||
};
|
||||
|
||||
if hr != 0 || path_ptr.is_null() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let result = unsafe {
|
||||
let mut len = 0;
|
||||
while *path_ptr.add(len) != 0 {
|
||||
len += 1;
|
||||
}
|
||||
let slice = std::slice::from_raw_parts(path_ptr, len);
|
||||
let os_str = OsString::from_wide(slice);
|
||||
windows_sys::Win32::System::Com::CoTaskMemFree(path_ptr as _);
|
||||
std::path::PathBuf::from(os_str)
|
||||
};
|
||||
|
||||
Some(result)
|
||||
}
|
||||
|
||||
fn token_path() -> std::path::PathBuf {
|
||||
token_dir().join("daemon_token")
|
||||
}
|
||||
|
||||
/// Dangerous permission mask for the daemon token file:
|
||||
/// rejects both read and write by unauthorized SIDs, since token
|
||||
/// knowledge allows pipe name prediction (squatting / phishing).
|
||||
const TOKEN_DANGEROUS_MASK: u32 = 0x0001 // FILE_READ_DATA
|
||||
| 0x0002 // FILE_WRITE_DATA
|
||||
| 0x0004 // FILE_APPEND_DATA
|
||||
| 0x00010000 // DELETE
|
||||
| 0x00040000 // WRITE_DAC
|
||||
| 0x00080000 // WRITE_OWNER
|
||||
| 0x40000000 // GENERIC_WRITE
|
||||
| 0x80000000u32 // GENERIC_READ
|
||||
| 0x10000000; // GENERIC_ALL
|
||||
|
||||
/// Load the daemon token, or generate and persist one if it doesn't exist.
|
||||
fn load_or_create_token() -> Result<String> {
|
||||
let dir = token_dir();
|
||||
let path = token_path();
|
||||
|
||||
// Reject reparse-point redirection on token dir and file
|
||||
crate::security::validate_path_security(&path)?;
|
||||
crate::security::reject_reparse_point(&dir)?;
|
||||
|
||||
if path.exists() {
|
||||
crate::security::check_dacl_permissions(
|
||||
&path,
|
||||
TOKEN_DANGEROUS_MASK,
|
||||
"daemon token file",
|
||||
)?;
|
||||
|
||||
let token = std::fs::read_to_string(&path)
|
||||
.with_context(|| format!("cannot read daemon token: {}", path.display()))?;
|
||||
let token = token.trim().to_string();
|
||||
@@ -131,9 +201,28 @@ mod platform {
|
||||
std::fs::create_dir_all(&dir)
|
||||
.with_context(|| format!("cannot create {}", dir.display()))?;
|
||||
|
||||
// Verify the directory has safe permissions after creation
|
||||
// (it may have inherited a permissive parent DACL).
|
||||
crate::security::check_dacl_permissions(
|
||||
&dir,
|
||||
crate::security::DIR_DANGEROUS_MASK,
|
||||
"daemon token directory",
|
||||
)?;
|
||||
|
||||
let token = generate_token();
|
||||
std::fs::write(&path, &token)
|
||||
.with_context(|| format!("cannot write daemon token: {}", path.display()))?;
|
||||
|
||||
// Verify the newly created file has safe permissions (fail-closed).
|
||||
if let Err(e) = crate::security::check_dacl_permissions(
|
||||
&path,
|
||||
TOKEN_DANGEROUS_MASK,
|
||||
"newly created daemon token file",
|
||||
) {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
tracing::info!("generated new daemon token at {}", path.display());
|
||||
Ok(token)
|
||||
}
|
||||
@@ -141,6 +230,15 @@ mod platform {
|
||||
/// Read an existing token (client side — never creates one).
|
||||
fn read_token() -> Result<String> {
|
||||
let path = token_path();
|
||||
|
||||
if path.exists() {
|
||||
crate::security::check_dacl_permissions(
|
||||
&path,
|
||||
TOKEN_DANGEROUS_MASK,
|
||||
"daemon token file",
|
||||
)?;
|
||||
}
|
||||
|
||||
let token = std::fs::read_to_string(&path).with_context(|| {
|
||||
format!(
|
||||
"cannot read daemon token (is the daemon running?): {}",
|
||||
@@ -154,25 +252,11 @@ mod platform {
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Generate a 64-char hex token (32 bytes) from CSPRNG.
|
||||
fn generate_token() -> String {
|
||||
use std::collections::hash_map::RandomState;
|
||||
use std::hash::{BuildHasher, Hasher};
|
||||
let s = RandomState::new();
|
||||
let mut h = s.build_hasher();
|
||||
h.write_u64(std::process::id() as u64);
|
||||
h.write_u128(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos(),
|
||||
);
|
||||
let v1 = h.finish();
|
||||
let s2 = RandomState::new();
|
||||
let mut h2 = s2.build_hasher();
|
||||
h2.write_u64(v1);
|
||||
h2.write_u64(std::process::id() as u64 ^ 0xdeadbeef);
|
||||
let v2 = h2.finish();
|
||||
format!("{:016x}{:016x}", v1, v2)
|
||||
let mut buf = [0u8; 32];
|
||||
getrandom::getrandom(&mut buf).expect("CSPRNG failure: cannot generate daemon token");
|
||||
buf.iter().map(|b| format!("{:02x}", b)).collect()
|
||||
}
|
||||
|
||||
/// Build the pipe name including the anti-squatting token.
|
||||
@@ -210,11 +294,73 @@ mod platform {
|
||||
/// Create a Named Pipe with a DACL that restricts access to the current user.
|
||||
///
|
||||
/// Uses `InitializeSecurityDescriptor` + `SetSecurityDescriptorDacl` with a
|
||||
/// NULL DACL first (which grants everyone access), then after creation uses
|
||||
/// `SetSecurityInfo` to set a proper owner-only DACL.
|
||||
/// Resolve the current user's SID as an SDDL string (e.g. `S-1-5-21-...`).
|
||||
pub(crate) fn get_current_user_sid_string() -> std::io::Result<String> {
|
||||
use std::ptr;
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, LocalFree};
|
||||
use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW;
|
||||
use windows_sys::Win32::Security::{
|
||||
GetTokenInformation, TOKEN_QUERY, TOKEN_USER, TokenUser,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
|
||||
|
||||
let mut token: HANDLE = ptr::null_mut();
|
||||
if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
|
||||
// Query required buffer size, then allocate and retry
|
||||
let mut ret_len: u32 = 0;
|
||||
unsafe {
|
||||
GetTokenInformation(token, TokenUser, ptr::null_mut(), 0, &mut ret_len);
|
||||
}
|
||||
let mut buf = vec![0u8; ret_len as usize];
|
||||
let ok = unsafe {
|
||||
GetTokenInformation(
|
||||
token,
|
||||
TokenUser,
|
||||
buf.as_mut_ptr() as *mut _,
|
||||
buf.len() as u32,
|
||||
&mut ret_len,
|
||||
)
|
||||
};
|
||||
unsafe {
|
||||
CloseHandle(token);
|
||||
}
|
||||
|
||||
if ok == 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
|
||||
let user_sid = unsafe {
|
||||
let tu = buf.as_ptr() as *const TOKEN_USER;
|
||||
(*tu).User.Sid
|
||||
};
|
||||
|
||||
let mut sid_str_ptr: *mut u16 = ptr::null_mut();
|
||||
if unsafe { ConvertSidToStringSidW(user_sid, &mut sid_str_ptr) } == 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
|
||||
let sid_string = unsafe {
|
||||
let mut len = 0;
|
||||
while *sid_str_ptr.add(len) != 0 {
|
||||
len += 1;
|
||||
}
|
||||
let slice = std::slice::from_raw_parts(sid_str_ptr, len);
|
||||
let s = String::from_utf16_lossy(slice);
|
||||
LocalFree(sid_str_ptr as _);
|
||||
s
|
||||
};
|
||||
|
||||
Ok(sid_string)
|
||||
}
|
||||
|
||||
/// Create a Named Pipe with a DACL restricted to the current user's SID.
|
||||
///
|
||||
/// Actually, the simpler approach: use `ConvertStringSecurityDescriptorToSecurityDescriptor`
|
||||
/// with an SDDL string that grants access only to the current user (owner).
|
||||
/// Uses SDDL `D:P(A;;GA;;;{user_sid})` to guarantee the DACL grants access
|
||||
/// only to the specific user account, not a group that may include other
|
||||
/// members (which can happen with `OW` when running as Administrator).
|
||||
fn create_pipe_with_dacl(
|
||||
opts: &ServerOptions,
|
||||
pipe_name: &str,
|
||||
@@ -224,12 +370,19 @@ mod platform {
|
||||
use windows_sys::Win32::Security::Authorization::ConvertStringSecurityDescriptorToSecurityDescriptorW;
|
||||
use windows_sys::Win32::Security::SECURITY_ATTRIBUTES;
|
||||
|
||||
// SDDL string: D:P(A;;GA;;;OW)
|
||||
// D:P = DACL, Protected (don't inherit from parent)
|
||||
// (A;;GA;;;OW) = Allow, Generic All, to Owner
|
||||
//
|
||||
// This means only the pipe's owner (the user who created it) has access.
|
||||
let sddl: Vec<u16> = "D:P(A;;GA;;;OW)\0".encode_utf16().collect();
|
||||
let sddl_str = match get_current_user_sid_string() {
|
||||
Ok(sid) => format!("D:P(A;;GA;;;{})\0", sid),
|
||||
Err(e) => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
format!(
|
||||
"SECURITY: cannot resolve current user SID for pipe DACL (fail-closed): {}",
|
||||
e
|
||||
),
|
||||
));
|
||||
}
|
||||
};
|
||||
let sddl: Vec<u16> = sddl_str.encode_utf16().collect();
|
||||
|
||||
let mut sd_ptr: *mut std::ffi::c_void = ptr::null_mut();
|
||||
|
||||
@@ -266,7 +419,6 @@ mod platform {
|
||||
)
|
||||
};
|
||||
|
||||
// Free the security descriptor allocated by ConvertStringSecurityDescriptor
|
||||
unsafe {
|
||||
LocalFree(sd_ptr as _);
|
||||
}
|
||||
@@ -285,7 +437,7 @@ mod platform {
|
||||
let token = load_or_create_token()?;
|
||||
let name = pipe_name_with_token(&token);
|
||||
tracing::info!(
|
||||
"daemon listening on {} (DACL: owner-only, token-secured)",
|
||||
"daemon listening on {} (DACL: user-SID-only, token-secured)",
|
||||
name
|
||||
);
|
||||
let server = create_pipe_server(&name, true)?;
|
||||
@@ -349,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.
|
||||
@@ -357,75 +510,69 @@ 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() -> 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() {
|
||||
return dir.join("ssh-mux.sock");
|
||||
validate_runtime_dir(&dir, uid)?;
|
||||
return Ok(dir.join("ssh-mux.sock"));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try ~/.ssh/ (user-owned directory)
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
let ssh_dir = std::path::PathBuf::from(home).join(".ssh");
|
||||
if ssh_dir.is_dir() {
|
||||
return ssh_dir.join("ssh-mux.sock");
|
||||
}
|
||||
// 2. Try SSH dir (~/.ssh or SSH_MUX_SSH_DIR)
|
||||
if let Some(ssh_dir) = crate::pool::get_ssh_dir_pub()
|
||||
&& ssh_dir.is_dir()
|
||||
{
|
||||
return Ok(ssh_dir.join("ssh-mux.sock"));
|
||||
}
|
||||
|
||||
// 3. Fallback: /tmp/ssh-mux-{uid}/ with 0700 subdirectory
|
||||
let fallback_dir = std::path::PathBuf::from(format!("/tmp/ssh-mux-{}", uid));
|
||||
// 3. Fallback: /tmp/ssh-mux-{uid}/ with 0700 subdirectory (fail-closed)
|
||||
let fallback_dir = PathBuf::from(format!("/tmp/ssh-mux-{}", uid));
|
||||
if fallback_dir.exists() {
|
||||
validate_fallback_dir(&fallback_dir, uid);
|
||||
} else if let Ok(()) = std::fs::create_dir(&fallback_dir) {
|
||||
validate_fallback_dir(&fallback_dir, uid)?;
|
||||
} else {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::create_dir(&fallback_dir).with_context(|| {
|
||||
format!(
|
||||
"SECURITY: failed to create {} (may be pre-created by another user)",
|
||||
fallback_dir.display()
|
||||
)
|
||||
})?;
|
||||
std::fs::set_permissions(&fallback_dir, std::fs::Permissions::from_mode(0o700))
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::error!(
|
||||
"SECURITY: failed to set 0700 on {}: {}",
|
||||
fallback_dir.display(),
|
||||
e
|
||||
);
|
||||
});
|
||||
.with_context(|| {
|
||||
format!("SECURITY: failed to set 0700 on {}", fallback_dir.display())
|
||||
})?;
|
||||
}
|
||||
fallback_dir.join("ssh-mux.sock")
|
||||
Ok(fallback_dir.join("ssh-mux.sock"))
|
||||
}
|
||||
|
||||
/// Validate that an existing fallback directory is safe to use.
|
||||
/// Checks: owner matches current uid, permissions are 0700, not a symlink.
|
||||
fn validate_fallback_dir(dir: &std::path::Path, expected_uid: u32) {
|
||||
/// 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: &Path, expected_uid: u32) -> Result<()> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
let meta = match std::fs::symlink_metadata(dir) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::error!("SECURITY: cannot stat {}: {}", dir.display(), e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let meta = std::fs::symlink_metadata(dir)
|
||||
.with_context(|| format!("SECURITY: cannot stat {}", dir.display()))?;
|
||||
|
||||
if meta.file_type().is_symlink() {
|
||||
tracing::error!(
|
||||
"SECURITY: {} is a symlink — refusing to use. \
|
||||
Remove it and restart ssh-mux.",
|
||||
anyhow::bail!(
|
||||
"SECURITY: {} is a symlink. Remove it and restart ssh-mux.",
|
||||
dir.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if meta.uid() != expected_uid {
|
||||
tracing::error!(
|
||||
anyhow::bail!(
|
||||
"SECURITY: {} is owned by uid {} (expected {}). \
|
||||
Another user may have pre-created this directory. \
|
||||
Remove it and restart ssh-mux.",
|
||||
Another user may have pre-created this directory.",
|
||||
dir.display(),
|
||||
meta.uid(),
|
||||
expected_uid
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let mode = meta.mode() & 0o7777;
|
||||
@@ -436,8 +583,58 @@ mod platform {
|
||||
mode
|
||||
);
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
|
||||
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).with_context(
|
||||
|| format!("SECURITY: failed to fix permissions on {}", dir.display()),
|
||||
)?;
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -448,7 +645,7 @@ mod platform {
|
||||
|
||||
impl IpcListener {
|
||||
pub async fn bind() -> Result<Self> {
|
||||
let path = socket_path();
|
||||
let path = socket_path()?;
|
||||
|
||||
if path.exists() {
|
||||
if UnixStream::connect(&path).await.is_ok() {
|
||||
@@ -491,7 +688,7 @@ mod platform {
|
||||
|
||||
/// Connect to the daemon's Unix socket.
|
||||
pub async fn connect() -> Result<IpcStream> {
|
||||
let path = socket_path();
|
||||
let path = socket_path()?;
|
||||
let stream = UnixStream::connect(&path)
|
||||
.await
|
||||
.with_context(|| format!("failed to connect to daemon at {}", path.display()))?;
|
||||
@@ -502,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
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
//! - Plain hostnames: `example.com ssh-ed25519 AAAA...`
|
||||
//! - Bracketed host:port: `[example.com]:2222 ssh-ed25519 AAAA...`
|
||||
//! - Multiple hostnames: `example.com,192.168.1.1 ssh-ed25519 AAAA...`
|
||||
//! - Wildcard patterns: `*`, `?` (as in OpenSSH)
|
||||
//! - Negation: `!bad.example.com,*.example.com` excludes specific hosts
|
||||
//! - Hashed hostnames: `|1|salt|hash ssh-ed25519 AAAA...`
|
||||
//! - Comments and blank lines are skipped
|
||||
//! - @revoked markers
|
||||
@@ -32,6 +34,10 @@ pub enum HostKeyCheck {
|
||||
Unknown,
|
||||
/// Key is explicitly revoked.
|
||||
Revoked,
|
||||
/// Host has `@cert-authority` entries that ssh-mux cannot verify.
|
||||
/// Non-interactive accept-new must be blocked to avoid downgrading
|
||||
/// the CA trust model.
|
||||
CertAuthorityPresent,
|
||||
}
|
||||
|
||||
/// Check a server's public key against the user's known_hosts file.
|
||||
@@ -41,6 +47,14 @@ pub fn check_known_hosts(host: &str, port: u16, server_key: &PublicKey) -> HostK
|
||||
None => return HostKeyCheck::Unknown,
|
||||
};
|
||||
|
||||
if let Err(e) = crate::security::validate_path_security(&known_hosts_path) {
|
||||
tracing::error!(
|
||||
"SECURITY: known_hosts path failed validation, treating as unknown: {:#}",
|
||||
e
|
||||
);
|
||||
return HostKeyCheck::Unknown;
|
||||
}
|
||||
|
||||
let contents = match std::fs::read_to_string(&known_hosts_path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return HostKeyCheck::Unknown,
|
||||
@@ -61,6 +75,7 @@ fn check_known_hosts_data(
|
||||
let server_key_base64 = server_key.public_key_base64();
|
||||
|
||||
let mut host_found_with_same_type = false;
|
||||
let mut cert_authority_for_host = false;
|
||||
|
||||
for line in data.lines() {
|
||||
let line = line.trim();
|
||||
@@ -73,6 +88,34 @@ fn check_known_hosts_data(
|
||||
let mut parts = line.splitn(2, ' ');
|
||||
let marker = parts.next().unwrap_or("");
|
||||
let rest = parts.next().unwrap_or("");
|
||||
|
||||
if marker == "@cert-authority" {
|
||||
// We can't verify CA entries, but we need to know if the
|
||||
// host is covered by one so we can block accept-new.
|
||||
let ca_parts: Vec<&str> = rest.splitn(3, ' ').collect();
|
||||
if ca_parts.len() >= 2 {
|
||||
let ca_hostnames = ca_parts[0];
|
||||
if hostname_matches(ca_hostnames, host, port) {
|
||||
cert_authority_for_host = true;
|
||||
}
|
||||
}
|
||||
tracing::warn!(
|
||||
"known_hosts: unsupported marker '{}' — this entry is NOT verified by ssh-mux \
|
||||
(only @revoked is supported; @cert-authority and other markers are ignored)",
|
||||
marker
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if marker != "@revoked" {
|
||||
tracing::warn!(
|
||||
"known_hosts: unsupported marker '{}' — this entry is NOT verified by ssh-mux \
|
||||
(only @revoked is supported; @cert-authority and other markers are ignored)",
|
||||
marker
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
(Some(marker), rest)
|
||||
} else {
|
||||
(None, line)
|
||||
@@ -115,41 +158,93 @@ fn check_known_hosts_data(
|
||||
|
||||
if host_found_with_same_type {
|
||||
HostKeyCheck::KeyChanged
|
||||
} else if cert_authority_for_host {
|
||||
HostKeyCheck::CertAuthorityPresent
|
||||
} else {
|
||||
HostKeyCheck::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a hostname field matches the given host:port.
|
||||
///
|
||||
/// Supports OpenSSH-style patterns:
|
||||
/// - `*` matches zero or more characters
|
||||
/// - `?` matches exactly one character
|
||||
/// - `!pattern` negates (if any negated pattern matches, the whole field
|
||||
/// does NOT match, even if a positive pattern also matches)
|
||||
/// - Comma-separated list of patterns
|
||||
fn hostname_matches(hostnames: &str, host: &str, port: u16) -> bool {
|
||||
let mut has_positive = false;
|
||||
|
||||
for entry in hostnames.split(',') {
|
||||
let entry = entry.trim();
|
||||
|
||||
if entry.starts_with("|1|") {
|
||||
// Hashed hostname
|
||||
if check_hashed_hostname(entry, host, port) {
|
||||
return true;
|
||||
}
|
||||
let (negated, entry) = if let Some(rest) = entry.strip_prefix('!') {
|
||||
(true, rest)
|
||||
} else {
|
||||
(false, entry)
|
||||
};
|
||||
|
||||
let matched = if entry.starts_with("|1|") {
|
||||
check_hashed_hostname(entry, host, port)
|
||||
} else if entry.starts_with('[') {
|
||||
// Bracketed [host]:port format
|
||||
if let Some(bracket_end) = entry.find(']') {
|
||||
let entry_host = &entry[1..bracket_end];
|
||||
let entry_port: u16 = entry[bracket_end + 1..]
|
||||
.strip_prefix(':')
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(22);
|
||||
if entry_host.eq_ignore_ascii_case(host) && entry_port == port {
|
||||
return true;
|
||||
}
|
||||
glob_match(entry_host, host) && entry_port == port
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
// Plain hostname
|
||||
if entry.eq_ignore_ascii_case(host) && port == 22 {
|
||||
return true;
|
||||
glob_match(entry, host) && port == 22
|
||||
};
|
||||
|
||||
if matched {
|
||||
if negated {
|
||||
return false;
|
||||
}
|
||||
has_positive = true;
|
||||
}
|
||||
}
|
||||
false
|
||||
|
||||
has_positive
|
||||
}
|
||||
|
||||
/// Case-insensitive glob matching with `*` and `?` wildcards.
|
||||
fn glob_match(pattern: &str, text: &str) -> bool {
|
||||
let p: Vec<char> = pattern.chars().flat_map(|c| c.to_lowercase()).collect();
|
||||
let t: Vec<char> = text.chars().flat_map(|c| c.to_lowercase()).collect();
|
||||
|
||||
let mut pi = 0;
|
||||
let mut ti = 0;
|
||||
let mut star_pi = None;
|
||||
let mut star_ti = 0;
|
||||
|
||||
while ti < t.len() {
|
||||
if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) {
|
||||
pi += 1;
|
||||
ti += 1;
|
||||
} else if pi < p.len() && p[pi] == '*' {
|
||||
star_pi = Some(pi);
|
||||
star_ti = ti;
|
||||
pi += 1;
|
||||
} else if let Some(sp) = star_pi {
|
||||
pi = sp + 1;
|
||||
star_ti += 1;
|
||||
ti = star_ti;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
while pi < p.len() && p[pi] == '*' {
|
||||
pi += 1;
|
||||
}
|
||||
|
||||
pi == p.len()
|
||||
}
|
||||
|
||||
/// Check a hashed hostname entry (|1|salt|hash format).
|
||||
@@ -211,8 +306,12 @@ pub fn fingerprint(key: &PublicKey) -> String {
|
||||
pub fn add_to_known_hosts(host: &str, port: u16, server_key: &PublicKey) -> Result<()> {
|
||||
let known_hosts_path = get_known_hosts_path().context("cannot determine known_hosts path")?;
|
||||
|
||||
// Reject reparse-point redirection on known_hosts path and parent
|
||||
crate::security::validate_path_security(&known_hosts_path)?;
|
||||
|
||||
// Ensure .ssh directory exists with proper permissions
|
||||
if let Some(parent) = known_hosts_path.parent() {
|
||||
crate::security::reject_reparse_point(parent)?;
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
|
||||
@@ -235,51 +334,56 @@ pub fn add_to_known_hosts(host: &str, port: u16, server_key: &PublicKey) -> Resu
|
||||
|
||||
let new_line = format!("{} {} {}\n", hostname, key_type, key_base64);
|
||||
|
||||
// Atomic write: read existing content, append new line, write to temp, rename
|
||||
// Atomic write: read existing, append, write to CSPRNG-random temp, rename
|
||||
let existing = std::fs::read_to_string(&known_hosts_path).unwrap_or_default();
|
||||
let mut new_content = existing;
|
||||
new_content.push_str(&new_line);
|
||||
|
||||
let tmp_path = known_hosts_path.with_extension("tmp");
|
||||
let mut rng_bytes = [0u8; 8];
|
||||
getrandom::getrandom(&mut rng_bytes)
|
||||
.map_err(|e| anyhow::anyhow!("CSPRNG failure for temp file suffix: {}", e))?;
|
||||
let rng_hex: String = rng_bytes.iter().map(|b| format!("{:02x}", b)).collect();
|
||||
let tmp_path = known_hosts_path.with_file_name(format!(".known_hosts.{}.tmp", rng_hex));
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
// Write to temp file with restrictive permissions
|
||||
// Write to temp file with create_new (O_EXCL) for TOCTOU safety
|
||||
{
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&tmp_path)
|
||||
.with_context(|| format!("failed to create temp file {}", tmp_path.display()))?;
|
||||
file.write_all(new_content.as_bytes())?;
|
||||
file.flush()?;
|
||||
file.sync_all()?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.create_new(true)
|
||||
.open(&tmp_path)
|
||||
.with_context(|| format!("failed to create temp file {}", tmp_path.display()))?;
|
||||
file.write_all(new_content.as_bytes())?;
|
||||
file.flush()?;
|
||||
file.sync_all()?;
|
||||
}
|
||||
}
|
||||
|
||||
// Atomic rename
|
||||
std::fs::rename(&tmp_path, &known_hosts_path).with_context(|| {
|
||||
format!(
|
||||
"failed to rename {} -> {}",
|
||||
tmp_path.display(),
|
||||
known_hosts_path.display()
|
||||
)
|
||||
})?;
|
||||
// Atomic rename (clean up temp on failure)
|
||||
if let Err(e) = std::fs::rename(&tmp_path, &known_hosts_path) {
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
return Err(e).with_context(|| {
|
||||
format!(
|
||||
"failed to rename {} -> {}",
|
||||
tmp_path.display(),
|
||||
known_hosts_path.display()
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure final file has correct permissions
|
||||
#[cfg(unix)]
|
||||
@@ -293,20 +397,9 @@ pub fn add_to_known_hosts(host: &str, port: u16, server_key: &PublicKey) -> Resu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the path to the known_hosts file.
|
||||
/// Get the path to the known_hosts file (~/.ssh/known_hosts or SSH_MUX_SSH_DIR/known_hosts).
|
||||
fn get_known_hosts_path() -> Option<PathBuf> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
std::env::var("USERPROFILE")
|
||||
.ok()
|
||||
.map(|h| PathBuf::from(h).join(".ssh").join("known_hosts"))
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
std::env::var("HOME")
|
||||
.ok()
|
||||
.map(|h| PathBuf::from(h).join(".ssh").join("known_hosts"))
|
||||
}
|
||||
crate::pool::get_ssh_dir_pub().map(|d| d.join("known_hosts"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -360,10 +453,282 @@ 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));
|
||||
assert!(!hostname_matches(&entry, "other.com", 22));
|
||||
}
|
||||
|
||||
// --- Security regression tests ---
|
||||
|
||||
#[test]
|
||||
fn test_cert_authority_entries_are_skipped() {
|
||||
let data = "@cert-authority *.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFake\n\
|
||||
example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIReal";
|
||||
// For a host NOT matching the @cert-authority wildcard's hostnames
|
||||
// AND NOT matching the plain entry, result should be Unknown.
|
||||
let result = check_known_hosts_data(
|
||||
"other.host.com",
|
||||
22,
|
||||
&make_test_key("AAAAC3NzaC1lZDI1NTE5AAAAIFake"),
|
||||
data,
|
||||
);
|
||||
assert_eq!(result, HostKeyCheck::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cert_authority_blocks_accept_new_for_matching_host() {
|
||||
let key = make_test_key("AAAAC3NzaC1lZDI1NTE5AAAAINewKey");
|
||||
let algo = key.algorithm();
|
||||
let data = format!(
|
||||
"@cert-authority example.com {} AAAAC3NzaC1lZDI1NTE5AAAAICAKey\n",
|
||||
algo.as_str()
|
||||
);
|
||||
let result = check_known_hosts_data("example.com", 22, &key, &data);
|
||||
assert_eq!(
|
||||
result,
|
||||
HostKeyCheck::CertAuthorityPresent,
|
||||
"host with @cert-authority entries must return CertAuthorityPresent, not Unknown"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cert_authority_does_not_affect_unmatched_host() {
|
||||
let key = make_test_key("AAAAC3NzaC1lZDI1NTE5AAAAINewKey2");
|
||||
let algo = key.algorithm();
|
||||
let data = format!(
|
||||
"@cert-authority other.com {} AAAAC3NzaC1lZDI1NTE5AAAAICAKey2\n",
|
||||
algo.as_str()
|
||||
);
|
||||
let result = check_known_hosts_data("example.com", 22, &key, &data);
|
||||
assert_eq!(
|
||||
result,
|
||||
HostKeyCheck::Unknown,
|
||||
"@cert-authority for different host must not affect this host"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cert_authority_with_trusted_key_still_returns_trusted() {
|
||||
let key = make_test_key("AAAAC3NzaC1lZDI1NTE5AAAAITrustedCA");
|
||||
let algo = key.algorithm();
|
||||
let key_b64 = russh::keys::PublicKeyBase64::public_key_base64(&key);
|
||||
let data = format!(
|
||||
"@cert-authority example.com {} AAAAC3NzaC1lZDI1NTE5AAAAICAKey3\n\
|
||||
example.com {} {}\n",
|
||||
algo.as_str(),
|
||||
algo.as_str(),
|
||||
key_b64
|
||||
);
|
||||
let result = check_known_hosts_data("example.com", 22, &key, &data);
|
||||
assert_eq!(
|
||||
result,
|
||||
HostKeyCheck::Trusted,
|
||||
"if a regular trusted entry matches, it takes priority over CertAuthorityPresent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cert_authority_wildcard_matching() {
|
||||
let key = make_test_key("AAAAC3NzaC1lZDI1NTE5AAAAIWild");
|
||||
let algo = key.algorithm();
|
||||
let data = format!(
|
||||
"@cert-authority *.example.com {} AAAAC3NzaC1lZDI1NTE5AAAAICAWild\n",
|
||||
algo.as_str()
|
||||
);
|
||||
let result = check_known_hosts_data("sub.example.com", 22, &key, &data);
|
||||
assert_eq!(
|
||||
result,
|
||||
HostKeyCheck::CertAuthorityPresent,
|
||||
"*.example.com must match sub.example.com for CA detection"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_glob_match_star() {
|
||||
assert!(glob_match("*.example.com", "sub.example.com"));
|
||||
assert!(glob_match("*.example.com", "a.b.example.com"));
|
||||
assert!(!glob_match("*.example.com", "example.com"));
|
||||
assert!(glob_match("*", "anything"));
|
||||
assert!(glob_match("host*", "hostname"));
|
||||
assert!(!glob_match("host*", "other"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_glob_match_question() {
|
||||
assert!(glob_match("??.example.com", "ab.example.com"));
|
||||
assert!(!glob_match("??.example.com", "abc.example.com"));
|
||||
assert!(glob_match("host?", "hosts"));
|
||||
assert!(!glob_match("host?", "host"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_glob_match_case_insensitive() {
|
||||
assert!(glob_match("*.EXAMPLE.COM", "sub.example.com"));
|
||||
assert!(glob_match("Host", "host"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hostname_negation() {
|
||||
assert!(!hostname_matches(
|
||||
"!bad.example.com,*.example.com",
|
||||
"bad.example.com",
|
||||
22
|
||||
));
|
||||
assert!(hostname_matches(
|
||||
"!bad.example.com,*.example.com",
|
||||
"good.example.com",
|
||||
22
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hostname_wildcard_with_port() {
|
||||
assert!(hostname_matches(
|
||||
"[*.example.com]:2222",
|
||||
"sub.example.com",
|
||||
2222
|
||||
));
|
||||
assert!(!hostname_matches(
|
||||
"[*.example.com]:2222",
|
||||
"sub.example.com",
|
||||
22
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cert_authority_wildcard_negation() {
|
||||
let key = make_test_key("AAAAC3NzaC1lZDI1NTE5AAAAINeg");
|
||||
let algo = key.algorithm();
|
||||
let data = format!(
|
||||
"@cert-authority !excluded.example.com,*.example.com {} AAAAC3NzaC1lZDI1NTE5AAAAICaNeg\n",
|
||||
algo.as_str()
|
||||
);
|
||||
let result = check_known_hosts_data("sub.example.com", 22, &key, &data);
|
||||
assert_eq!(result, HostKeyCheck::CertAuthorityPresent);
|
||||
let result2 = check_known_hosts_data("excluded.example.com", 22, &key, &data);
|
||||
assert_eq!(result2, HostKeyCheck::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_revoked_key_detected() {
|
||||
let key = make_test_key("AAAAC3NzaC1lZDI1NTE5AAAAIRevoked");
|
||||
let key_b64 = russh::keys::PublicKeyBase64::public_key_base64(&key);
|
||||
let algo = key.algorithm();
|
||||
let data = format!("@revoked example.com {} {}\n", algo.as_str(), key_b64);
|
||||
let result = check_known_hosts_data("example.com", 22, &key, &data);
|
||||
assert_eq!(result, HostKeyCheck::Revoked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_key_change_detected() {
|
||||
let known_key = make_test_key("AAAAC3NzaC1lZDI1NTE5AAAAIKnown");
|
||||
let server_key = make_test_key("AAAAC3NzaC1lZDI1NTE5AAAAIDiff");
|
||||
let algo = known_key.algorithm();
|
||||
let known_b64 = russh::keys::PublicKeyBase64::public_key_base64(&known_key);
|
||||
let data = format!("example.com {} {}\n", algo.as_str(), known_b64);
|
||||
let result = check_known_hosts_data("example.com", 22, &server_key, &data);
|
||||
assert_eq!(result, HostKeyCheck::KeyChanged);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_host() {
|
||||
let key = make_test_key("AAAAC3NzaC1lZDI1NTE5AAAAIUnknown");
|
||||
let result = check_known_hosts_data("unknown.example.com", 22, &key, "");
|
||||
assert_eq!(result, HostKeyCheck::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comments_and_blank_lines_skipped() {
|
||||
let data = "# This is a comment\n\n \n# Another comment\n";
|
||||
let key = make_test_key("AAAAC3NzaC1lZDI1NTE5AAAAITest");
|
||||
let result = check_known_hosts_data("example.com", 22, &key, data);
|
||||
assert_eq!(result, HostKeyCheck::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_malformed_lines_skipped() {
|
||||
let data = "just-a-hostname\nsingle-field\n";
|
||||
let key = make_test_key("AAAAC3NzaC1lZDI1NTE5AAAAITest");
|
||||
let result = check_known_hosts_data("example.com", 22, &key, data);
|
||||
assert_eq!(result, HostKeyCheck::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trusted_key_matches() {
|
||||
let key = make_test_key("AAAAC3NzaC1lZDI1NTE5AAAAITrusted");
|
||||
let algo = key.algorithm();
|
||||
let key_b64 = russh::keys::PublicKeyBase64::public_key_base64(&key);
|
||||
let data = format!("example.com {} {}\n", algo.as_str(), key_b64);
|
||||
let result = check_known_hosts_data("example.com", 22, &key, &data);
|
||||
assert_eq!(result, HostKeyCheck::Trusted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_port_specific_matching() {
|
||||
let key = make_test_key("AAAAC3NzaC1lZDI1NTE5AAAAIPort");
|
||||
let algo = key.algorithm();
|
||||
let key_b64 = russh::keys::PublicKeyBase64::public_key_base64(&key);
|
||||
// Bracketed entry for port 2222
|
||||
let data = format!("[example.com]:2222 {} {}\n", algo.as_str(), key_b64);
|
||||
assert_eq!(
|
||||
check_known_hosts_data("example.com", 2222, &key, &data),
|
||||
HostKeyCheck::Trusted
|
||||
);
|
||||
// Should NOT match port 22
|
||||
assert_eq!(
|
||||
check_known_hosts_data("example.com", 22, &key, &data),
|
||||
HostKeyCheck::Unknown
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a deterministic test key from a seed string.
|
||||
fn make_test_key(seed: &str) -> russh::keys::PublicKey {
|
||||
use sha2::Digest;
|
||||
let hash = sha2::Sha256::digest(seed.as_bytes());
|
||||
let secret_bytes: [u8; 32] = hash.into();
|
||||
let private_key = russh::keys::PrivateKey::random(
|
||||
&mut StableRng(secret_bytes),
|
||||
russh::keys::Algorithm::Ed25519,
|
||||
)
|
||||
.expect("key generation failed");
|
||||
private_key.public_key().clone()
|
||||
}
|
||||
|
||||
/// Deterministic RNG for reproducible test keys.
|
||||
struct StableRng([u8; 32]);
|
||||
|
||||
impl rand::RngCore for StableRng {
|
||||
fn next_u32(&mut self) -> u32 {
|
||||
let mut buf = [0u8; 4];
|
||||
self.fill_bytes(&mut buf);
|
||||
u32::from_le_bytes(buf)
|
||||
}
|
||||
|
||||
fn next_u64(&mut self) -> u64 {
|
||||
let mut buf = [0u8; 8];
|
||||
self.fill_bytes(&mut buf);
|
||||
u64::from_le_bytes(buf)
|
||||
}
|
||||
|
||||
fn fill_bytes(&mut self, dest: &mut [u8]) {
|
||||
use sha2::Digest;
|
||||
let mut pos = 0;
|
||||
while pos < dest.len() {
|
||||
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();
|
||||
pos += copy_len;
|
||||
}
|
||||
}
|
||||
|
||||
fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), rand::Error> {
|
||||
self.fill_bytes(dest);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl rand::CryptoRng for StableRng {}
|
||||
}
|
||||
|
||||
1098
src/local_server.rs
1098
src/local_server.rs
File diff suppressed because it is too large
Load Diff
407
src/main.rs
407
src/main.rs
@@ -2,31 +2,26 @@ mod cli;
|
||||
mod config;
|
||||
mod daemon;
|
||||
mod host_key;
|
||||
mod import;
|
||||
mod ipc;
|
||||
mod known_hosts;
|
||||
mod local_server;
|
||||
mod pool;
|
||||
mod protocol;
|
||||
mod proxy;
|
||||
mod security;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use cli::{Cli, Command};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let env_filter = if std::env::var("RUST_LOG").is_ok() {
|
||||
tracing_subscriber::EnvFilter::from_default_env()
|
||||
} else {
|
||||
tracing_subscriber::EnvFilter::new("ssh_mux=info")
|
||||
};
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(env_filter)
|
||||
.with_writer(std::io::stderr)
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
let is_long_running = matches!(cli.command, Command::Daemon { .. } | Command::Serve { .. });
|
||||
setup_logging(is_long_running);
|
||||
|
||||
match cli.command {
|
||||
Command::Daemon {
|
||||
timeout,
|
||||
@@ -34,24 +29,30 @@ async fn main() -> Result<()> {
|
||||
listen_port,
|
||||
remote,
|
||||
remote_user,
|
||||
config: config_path,
|
||||
} => {
|
||||
let local_server = match (listen_port, remote) {
|
||||
(Some(port), Some(remote_str)) => {
|
||||
let (host, rport) = parse_host_port(&remote_str)?;
|
||||
Some(daemon::LocalServerOpts {
|
||||
listen_port: port,
|
||||
remote_host: host,
|
||||
remote_port: rport,
|
||||
remote_user,
|
||||
let local_server = if let Some(remote_str) = remote {
|
||||
let (host, rport) = parse_host_port(&remote_str)?;
|
||||
Some(daemon::LocalServerOpts::Direct {
|
||||
listen_port,
|
||||
remote_host: host,
|
||||
remote_port: rport,
|
||||
remote_user,
|
||||
})
|
||||
} else {
|
||||
let cfg_path = match config_path {
|
||||
Some(p) => std::path::PathBuf::from(p),
|
||||
None => config::default_config_path()?,
|
||||
};
|
||||
if cfg_path.exists() {
|
||||
let mux_config = config::load_config(&cfg_path)?;
|
||||
Some(daemon::LocalServerOpts::Routed {
|
||||
listen_port,
|
||||
config: mux_config,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
(Some(_), None) => {
|
||||
anyhow::bail!("--listen-port requires --remote");
|
||||
}
|
||||
(None, Some(_)) => {
|
||||
anyhow::bail!("--remote requires --listen-port");
|
||||
}
|
||||
(None, None) => None,
|
||||
};
|
||||
daemon::run(
|
||||
timeout,
|
||||
@@ -88,6 +89,18 @@ async fn main() -> Result<()> {
|
||||
let pool = std::sync::Arc::new(pool::Pool::new(timeout, max_lifetime));
|
||||
let host_key = host_key::load_or_generate_host_key()?;
|
||||
|
||||
daemon::register_ssh_host_aliases(&pool).await;
|
||||
|
||||
// Spawn periodic cleanup so idle timeout and max lifetime are enforced.
|
||||
let cleanup_pool = pool.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
cleanup_pool.cleanup_idle().await;
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(remote_str) = remote {
|
||||
// Direct mode: single remote host
|
||||
let (remote_host, remote_port) = parse_host_port(&remote_str)?;
|
||||
@@ -121,15 +134,34 @@ 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,
|
||||
} => {
|
||||
install_service(config_path, listen_port, timeout)?;
|
||||
run_install(config_path, listen_port, timeout, max_lifetime)?;
|
||||
}
|
||||
Command::Uninstall => {
|
||||
#[cfg(windows)]
|
||||
uninstall_service()?;
|
||||
#[cfg(not(windows))]
|
||||
anyhow::bail!("uninstall is only supported on Windows");
|
||||
}
|
||||
Command::Status => {
|
||||
proxy::status().await?;
|
||||
@@ -148,10 +180,91 @@ 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";
|
||||
|
||||
#[cfg(windows)]
|
||||
fn get_startup_dir() -> Result<std::path::PathBuf> {
|
||||
use anyhow::Context;
|
||||
|
||||
if let Some(dir) = known_folder_startup() {
|
||||
return Ok(dir);
|
||||
}
|
||||
|
||||
let appdata = std::env::var("APPDATA").context("APPDATA environment variable not set")?;
|
||||
Ok(std::path::PathBuf::from(appdata)
|
||||
.join("Microsoft")
|
||||
@@ -161,7 +274,57 @@ fn get_startup_dir() -> Result<std::path::PathBuf> {
|
||||
.join("Startup"))
|
||||
}
|
||||
|
||||
fn install_service(config_path: Option<String>, listen_port: u16, timeout: u64) -> Result<()> {
|
||||
/// Resolve the Startup folder via Win32 Known Folder API (`FOLDERID_Startup`),
|
||||
/// which is immune to `%APPDATA%` environment variable poisoning.
|
||||
#[cfg(windows)]
|
||||
fn known_folder_startup() -> Option<std::path::PathBuf> {
|
||||
use std::ffi::OsString;
|
||||
use std::os::windows::ffi::OsStringExt;
|
||||
use std::ptr;
|
||||
|
||||
// FOLDERID_Startup = {B97D20BB-F46A-4C97-BA10-5E3608430854}
|
||||
let folderid = windows_sys::core::GUID {
|
||||
data1: 0xB97D20BB,
|
||||
data2: 0xF46A,
|
||||
data3: 0x4C97,
|
||||
data4: [0xBA, 0x10, 0x5E, 0x36, 0x08, 0x43, 0x08, 0x54],
|
||||
};
|
||||
|
||||
let mut path_ptr: *mut u16 = ptr::null_mut();
|
||||
let hr = unsafe {
|
||||
windows_sys::Win32::UI::Shell::SHGetKnownFolderPath(
|
||||
&folderid,
|
||||
0,
|
||||
ptr::null_mut(),
|
||||
&mut path_ptr,
|
||||
)
|
||||
};
|
||||
|
||||
if hr != 0 || path_ptr.is_null() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let result = unsafe {
|
||||
let mut len = 0;
|
||||
while *path_ptr.add(len) != 0 {
|
||||
len += 1;
|
||||
}
|
||||
let slice = std::slice::from_raw_parts(path_ptr, len);
|
||||
let os_str = OsString::from_wide(slice);
|
||||
windows_sys::Win32::System::Com::CoTaskMemFree(path_ptr as _);
|
||||
std::path::PathBuf::from(os_str)
|
||||
};
|
||||
|
||||
Some(result)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn install_service(
|
||||
config_path: Option<String>,
|
||||
listen_port: u16,
|
||||
timeout: u64,
|
||||
max_lifetime: u64,
|
||||
) -> Result<()> {
|
||||
use anyhow::Context;
|
||||
use std::io::Write;
|
||||
|
||||
@@ -178,7 +341,7 @@ fn install_service(config_path: Option<String>, listen_port: u16, timeout: u64)
|
||||
Stop-Process -Force",
|
||||
my_pid
|
||||
);
|
||||
let _ = std::process::Command::new("powershell")
|
||||
let _ = std::process::Command::new(security::powershell_path())
|
||||
.args(["-NoProfile", "-Command", &kill_script])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
@@ -188,7 +351,7 @@ fn install_service(config_path: Option<String>, listen_port: u16, timeout: u64)
|
||||
let max_wait = std::time::Duration::from_secs(10);
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
let output = std::process::Command::new("powershell")
|
||||
let output = std::process::Command::new(security::powershell_path())
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
@@ -218,8 +381,7 @@ fn install_service(config_path: Option<String>, listen_port: u16, timeout: u64)
|
||||
// Grace period for port/file handle release after process exit
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
|
||||
let home = pool::get_home_dir_pub().context("cannot determine home directory")?;
|
||||
let ssh_dir = home.join(".ssh");
|
||||
let ssh_dir = pool::get_ssh_dir_pub().context("cannot determine SSH directory")?;
|
||||
let install_exe = ssh_dir.join("ssh-mux.exe");
|
||||
|
||||
let current_exe = std::env::current_exe().context("cannot determine current exe path")?;
|
||||
@@ -261,15 +423,29 @@ fn install_service(config_path: Option<String>, listen_port: u16, timeout: u64)
|
||||
}
|
||||
}
|
||||
|
||||
// Persist SSH_MUX_SSH_DIR in the VBS startup script so the daemon
|
||||
// uses the correct SSH directory after reboot.
|
||||
let ssh_dir_str = ssh_dir.to_string_lossy();
|
||||
let vbs_env_line = if std::env::var("SSH_MUX_SSH_DIR").is_ok() {
|
||||
format!(
|
||||
"Set WshEnv = CreateObject(\"WScript.Shell\").Environment(\"Process\")\r\n\
|
||||
WshEnv(\"SSH_MUX_SSH_DIR\") = \"{}\"\r\n",
|
||||
ssh_dir_str
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let vbs_content = format!(
|
||||
"Set WshShell = CreateObject(\"WScript.Shell\")\r\n\
|
||||
"{env}Set WshShell = CreateObject(\"WScript.Shell\")\r\n\
|
||||
q = Chr(34)\r\n\
|
||||
cmd = q & \"{exe}\" & q & \" serve -p {port} --config \" & q & \"{cfg}\" & q & \" --timeout {timeout}\"\r\n\
|
||||
cmd = q & \"{exe}\" & q & \" daemon -p {port} --config \" & q & \"{cfg}\" & q & \" --timeout {timeout} --max-lifetime {max_lifetime}\"\r\n\
|
||||
WshShell.Run cmd, 0, False\r\n",
|
||||
env = vbs_env_line,
|
||||
exe = exe_str,
|
||||
port = listen_port,
|
||||
cfg = cfg_str,
|
||||
timeout = timeout,
|
||||
max_lifetime = max_lifetime,
|
||||
);
|
||||
|
||||
let mut f = std::fs::File::create(&vbs_path)
|
||||
@@ -277,20 +453,35 @@ fn install_service(config_path: Option<String>, listen_port: u16, timeout: u64)
|
||||
f.write_all(vbs_content.as_bytes())?;
|
||||
println!("created {}", vbs_path.display());
|
||||
|
||||
// Start immediately using PowerShell Start-Process with hidden window.
|
||||
// VBS is kept only for login-time auto-start; for immediate launch we
|
||||
// bypass wscript to avoid path-with-spaces issues.
|
||||
let _ = std::process::Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile", "-Command",
|
||||
&format!(
|
||||
"Start-Process -FilePath '{}' -ArgumentList 'serve -p {} --config \"{}\" --timeout {}' -WindowStyle Hidden",
|
||||
exe_str, listen_port, cfg_str, timeout
|
||||
),
|
||||
])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
// Start immediately using Rust's Command with Windows creation flags
|
||||
// for a hidden, detached process. This avoids PowerShell entirely,
|
||||
// eliminating single-quote escaping / command injection risks.
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
const DETACHED_PROCESS: u32 = 0x0000_0008;
|
||||
|
||||
let mut cmd = std::process::Command::new(&install_exe);
|
||||
cmd.args([
|
||||
"daemon",
|
||||
"-p",
|
||||
&listen_port.to_string(),
|
||||
"--config",
|
||||
&cfg_str,
|
||||
"--timeout",
|
||||
&timeout.to_string(),
|
||||
"--max-lifetime",
|
||||
&max_lifetime.to_string(),
|
||||
]);
|
||||
cmd.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS);
|
||||
if std::env::var("SSH_MUX_SSH_DIR").is_ok() {
|
||||
cmd.env("SSH_MUX_SSH_DIR", &*ssh_dir_str);
|
||||
}
|
||||
let _ = cmd.spawn();
|
||||
}
|
||||
|
||||
// Wait for the new process to actually start listening
|
||||
let start = std::time::Instant::now();
|
||||
@@ -312,27 +503,29 @@ fn install_service(config_path: Option<String>, listen_port: u16, timeout: u64)
|
||||
);
|
||||
}
|
||||
|
||||
let log_file_path = ssh_dir.join("ssh-mux.log");
|
||||
println!(
|
||||
"\nssh-mux is now installed and running.\n\
|
||||
Exe: {}\n\
|
||||
Config: {}\n\
|
||||
Port: {}\n\
|
||||
Log: {}\n\
|
||||
Startup: {}",
|
||||
install_exe.display(),
|
||||
cfg_str,
|
||||
listen_port,
|
||||
log_file_path.display(),
|
||||
vbs_path.display(),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn uninstall_service() -> Result<()> {
|
||||
use anyhow::Context;
|
||||
|
||||
// Kill other ssh-mux.exe (excluding ourselves) and wscript.exe
|
||||
let my_pid = std::process::id();
|
||||
let _ = std::process::Command::new("powershell")
|
||||
let _ = std::process::Command::new(security::powershell_path())
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
@@ -360,8 +553,10 @@ fn uninstall_service() -> Result<()> {
|
||||
}
|
||||
|
||||
// Remove installed exe
|
||||
let home = pool::get_home_dir_pub().context("cannot determine home directory")?;
|
||||
let install_exe = home.join(".ssh").join("ssh-mux.exe");
|
||||
let install_exe = match pool::get_ssh_dir_pub() {
|
||||
Some(d) => d.join("ssh-mux.exe"),
|
||||
None => return Ok(()),
|
||||
};
|
||||
if install_exe.exists() {
|
||||
match std::fs::remove_file(&install_exe) {
|
||||
Ok(_) => println!("removed {}", install_exe.display()),
|
||||
@@ -377,6 +572,112 @@ fn uninstall_service() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -- Logging -----------------------------------------------------------------
|
||||
|
||||
fn make_env_filter() -> tracing_subscriber::EnvFilter {
|
||||
if std::env::var("RUST_LOG").is_ok() {
|
||||
tracing_subscriber::EnvFilter::from_default_env()
|
||||
} else {
|
||||
tracing_subscriber::EnvFilter::new("ssh_mux=info")
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the SSH directory path without security validation.
|
||||
/// Used to determine the log file path before tracing is initialized.
|
||||
fn get_log_dir() -> Option<std::path::PathBuf> {
|
||||
if let Ok(dir) = std::env::var("SSH_MUX_SSH_DIR") {
|
||||
let trimmed = dir.trim();
|
||||
if !trimmed.is_empty() {
|
||||
let path = std::path::PathBuf::from(trimmed);
|
||||
if path.is_absolute() {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
std::env::var("USERPROFILE")
|
||||
.ok()
|
||||
.map(|h| std::path::PathBuf::from(h).join(".ssh"))
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
std::env::var("HOME")
|
||||
.ok()
|
||||
.map(|h| std::path::PathBuf::from(h).join(".ssh"))
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_LOG_SIZE: u64 = 5 * 1024 * 1024;
|
||||
|
||||
fn rotate_log_if_needed(path: &std::path::Path) {
|
||||
if let Ok(meta) = std::fs::metadata(path)
|
||||
&& meta.len() > MAX_LOG_SIZE
|
||||
{
|
||||
let old_path = path.with_file_name("ssh-mux.log.old");
|
||||
let _ = std::fs::rename(path, old_path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set up tracing subscriber. For long-running modes (daemon, serve),
|
||||
/// also logs to `ssh-mux.log` in the SSH directory and installs a panic
|
||||
/// hook that writes to the same file.
|
||||
fn setup_logging(log_to_file: bool) {
|
||||
if log_to_file && let Some(log_dir) = get_log_dir() {
|
||||
let log_path = log_dir.join("ssh-mux.log");
|
||||
rotate_log_if_needed(&log_path);
|
||||
|
||||
let panic_log_path = log_path.clone();
|
||||
let default_hook = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
use std::io::Write;
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&panic_log_path)
|
||||
{
|
||||
let _ = writeln!(f, "PANIC: {}", info);
|
||||
}
|
||||
default_hook(info);
|
||||
}));
|
||||
|
||||
if let Ok(log_file) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&log_path)
|
||||
{
|
||||
use tracing_subscriber::prelude::*;
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_writer(std::io::stderr)
|
||||
.with_filter(make_env_filter()),
|
||||
)
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_writer(std::sync::Mutex::new(log_file))
|
||||
.with_ansi(false)
|
||||
.with_filter(make_env_filter()),
|
||||
)
|
||||
.init();
|
||||
|
||||
tracing::info!(
|
||||
"ssh-mux v{} — file logging: {}",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
log_path.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(make_env_filter())
|
||||
.with_writer(std::io::stderr)
|
||||
.init();
|
||||
}
|
||||
|
||||
// -- Argument parsing --------------------------------------------------------
|
||||
|
||||
/// Parse `host:port`, `[ipv6]:port`, or `host` (defaults to port 22).
|
||||
fn parse_host_port(s: &str) -> Result<(String, u16)> {
|
||||
if let Some(rest) = s.strip_prefix('[')
|
||||
|
||||
1361
src/pool.rs
1361
src/pool.rs
File diff suppressed because it is too large
Load Diff
127
src/protocol.rs
127
src/protocol.rs
@@ -20,6 +20,11 @@ use anyhow::{Context, Result, bail};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
|
||||
/// Maximum length of a single IPC protocol line (8 KiB).
|
||||
/// Prevents memory exhaustion from a malicious client sending
|
||||
/// unbounded data without a newline terminator.
|
||||
const MAX_IPC_LINE_LEN: usize = 8192;
|
||||
|
||||
/// A request from the proxy client to the daemon.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Request {
|
||||
@@ -226,9 +231,7 @@ pub async fn write_request<W: AsyncWriteExt + Unpin>(w: &mut W, req: &Request) -
|
||||
/// Returns the response body (after OK/ERR prefix).
|
||||
#[allow(dead_code)]
|
||||
pub async fn read_response<R: tokio::io::AsyncRead + Unpin>(r: &mut R) -> Result<String> {
|
||||
let mut reader = BufReader::new(r);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).await?;
|
||||
let line = bounded_read_line(r).await?;
|
||||
let line = line.trim();
|
||||
|
||||
if let Some(rest) = line.strip_prefix("OK") {
|
||||
@@ -265,12 +268,40 @@ pub async fn write_err<W: AsyncWriteExt + Unpin>(w: &mut W, reason: &str) -> Res
|
||||
/// Read and parse a request from a client.
|
||||
#[allow(dead_code)]
|
||||
pub async fn read_request<R: tokio::io::AsyncRead + Unpin>(r: &mut R) -> Result<Request> {
|
||||
let mut reader = BufReader::new(r);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).await?;
|
||||
let line = bounded_read_line(r).await?;
|
||||
Request::from_line(&line)
|
||||
}
|
||||
|
||||
/// Read a single line from the stream, enforcing [`MAX_IPC_LINE_LEN`].
|
||||
///
|
||||
/// Reads chunk-by-chunk via `fill_buf` so the buffer never grows past
|
||||
/// the limit, even if the peer sends megabytes without a newline.
|
||||
async fn bounded_read_line<R: tokio::io::AsyncRead + Unpin>(r: &mut R) -> Result<String> {
|
||||
let mut reader = BufReader::new(r);
|
||||
let mut buf = Vec::with_capacity(256);
|
||||
loop {
|
||||
let available = reader.fill_buf().await?;
|
||||
if available.is_empty() {
|
||||
break;
|
||||
}
|
||||
if let Some(pos) = available.iter().position(|&b| b == b'\n') {
|
||||
buf.extend_from_slice(&available[..=pos]);
|
||||
reader.consume(pos + 1);
|
||||
break;
|
||||
}
|
||||
let len = available.len();
|
||||
buf.extend_from_slice(available);
|
||||
reader.consume(len);
|
||||
if buf.len() > MAX_IPC_LINE_LEN {
|
||||
bail!("IPC line exceeds {} byte limit", MAX_IPC_LINE_LEN);
|
||||
}
|
||||
}
|
||||
if buf.is_empty() {
|
||||
bail!("IPC: unexpected EOF");
|
||||
}
|
||||
String::from_utf8(buf).context("invalid UTF-8 in IPC line")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -366,4 +397,88 @@ mod tests {
|
||||
_ => panic!("expected Session"),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Security regression tests ---
|
||||
|
||||
#[test]
|
||||
fn test_reject_unknown_commands() {
|
||||
assert!(Request::from_line("EXEC rm -rf /").is_err());
|
||||
assert!(Request::from_line("SHELL").is_err());
|
||||
assert!(Request::from_line("").is_err());
|
||||
assert!(Request::from_line(" ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_invalid_port() {
|
||||
assert!(Request::from_line("CONNECT host:abc").is_err());
|
||||
assert!(Request::from_line("CONNECT host:99999").is_err());
|
||||
assert!(Request::from_line("CONNECT host:").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_no_port() {
|
||||
assert!(Request::from_line("CONNECT host").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_lock_unlock() {
|
||||
assert!(matches!(Request::from_line("LOCK"), Ok(Request::Lock)));
|
||||
assert!(matches!(Request::from_line("UNLOCK"), Ok(Request::Unlock)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_session_with_user() {
|
||||
let req = Request::Session {
|
||||
host: "server.com".into(),
|
||||
port: 2222,
|
||||
user: Some("deploy".into()),
|
||||
command: None,
|
||||
};
|
||||
let line = req.to_line();
|
||||
let parsed = Request::from_line(&line).unwrap();
|
||||
match parsed {
|
||||
Request::Session {
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
command,
|
||||
} => {
|
||||
assert_eq!(host, "server.com");
|
||||
assert_eq!(port, 2222);
|
||||
assert_eq!(user, Some("deploy".to_string()));
|
||||
assert!(command.is_none());
|
||||
}
|
||||
_ => panic!("expected Session"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_all_commands() {
|
||||
for req in [
|
||||
Request::Status,
|
||||
Request::Stop,
|
||||
Request::Lock,
|
||||
Request::Unlock,
|
||||
] {
|
||||
let line = req.to_line();
|
||||
let parsed = Request::from_line(&line).unwrap();
|
||||
assert_eq!(
|
||||
std::mem::discriminant(&req),
|
||||
std::mem::discriminant(&parsed)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_connect_with_user() {
|
||||
let req = Request::from_line("CONNECT admin@example.com:22").unwrap();
|
||||
match req {
|
||||
Request::Connect { host, port, user } => {
|
||||
assert_eq!(host, "example.com");
|
||||
assert_eq!(port, 22);
|
||||
assert_eq!(user, Some("admin".to_string()));
|
||||
}
|
||||
_ => panic!("expected Connect"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
47
src/proxy.rs
47
src/proxy.rs
@@ -80,17 +80,20 @@ pub async fn connect(
|
||||
protocol::write_request(&mut stream, &req).await?;
|
||||
|
||||
// Handle auth prompts (same as ProxyCommand mode)
|
||||
let mut session_nonce = String::new();
|
||||
loop {
|
||||
let response_line = read_response_line(&mut stream).await?;
|
||||
let trimmed = response_line.trim();
|
||||
|
||||
if trimmed.starts_with("WINDOW_SIZE_REQ") {
|
||||
// Daemon is asking for window size — send it
|
||||
let (cols, rows) = get_terminal_size();
|
||||
let line = format!("WINDOW_SIZE {} {}\n", cols, rows);
|
||||
stream.write_all(line.as_bytes()).await?;
|
||||
stream.flush().await?;
|
||||
continue;
|
||||
} else if let Some(nonce) = trimmed.strip_prefix("NONCE ") {
|
||||
session_nonce = nonce.to_string();
|
||||
continue;
|
||||
} else if let Some(_rest) = trimmed.strip_prefix("OK") {
|
||||
break;
|
||||
} else if let Some(reason) = trimmed.strip_prefix("ERR ") {
|
||||
@@ -110,7 +113,7 @@ pub async fn connect(
|
||||
}
|
||||
|
||||
// Put terminal in raw mode and relay
|
||||
let exit_code = relay_interactive(stream).await?;
|
||||
let exit_code = relay_interactive(stream, &session_nonce).await?;
|
||||
std::process::exit(exit_code);
|
||||
}
|
||||
|
||||
@@ -146,13 +149,19 @@ fn get_terminal_size() -> (u32, u32) {
|
||||
|
||||
/// Relay data between the user's terminal (in raw mode) and the daemon IPC stream.
|
||||
/// Returns the exit code from the remote process.
|
||||
async fn relay_interactive(stream: ipc::IpcStream) -> Result<i32> {
|
||||
///
|
||||
/// The `nonce` is a per-session random token exchanged over the trusted IPC channel.
|
||||
/// The daemon uses it to tag exit-code escape sequences, preventing the remote
|
||||
/// server from spoofing them (the remote cannot know the nonce).
|
||||
async fn relay_interactive(stream: ipc::IpcStream, nonce: &str) -> Result<i32> {
|
||||
let _raw_guard = RawModeGuard::enter()?;
|
||||
|
||||
let (mut stream_read, mut stream_write) = io::split(stream);
|
||||
let mut stdin = io::stdin();
|
||||
let mut stdout = io::stdout();
|
||||
|
||||
let exit_marker = build_exit_marker(nonce);
|
||||
|
||||
let mut buf_from_stdin = vec![0u8; 32768];
|
||||
let mut buf_from_daemon = vec![0u8; 32768];
|
||||
let mut exit_code: i32 = 0;
|
||||
@@ -192,11 +201,9 @@ async fn relay_interactive(stream: ipc::IpcStream) -> Result<i32> {
|
||||
}
|
||||
Ok(n) => {
|
||||
let data = &buf_from_daemon[..n];
|
||||
// Check for in-band exit status message
|
||||
if let Some(code) = extract_exit_status(data) {
|
||||
if let Some(code) = extract_exit_status(data, &exit_marker) {
|
||||
exit_code = code;
|
||||
// Write remaining data (before the escape sequence) to stdout
|
||||
if let Some(pos) = find_escape_sequence(data)
|
||||
if let Some(pos) = find_escape_sequence(data, &exit_marker)
|
||||
&& pos > 0
|
||||
{
|
||||
let _ = stdout.write_all(&data[..pos]).await;
|
||||
@@ -221,9 +228,15 @@ async fn relay_interactive(stream: ipc::IpcStream) -> Result<i32> {
|
||||
Ok(exit_code)
|
||||
}
|
||||
|
||||
/// Extract exit status from in-band escape sequence: \x1b]ssh-mux;exit=N\x07
|
||||
fn extract_exit_status(data: &[u8]) -> Option<i32> {
|
||||
let marker = b"\x1b]ssh-mux;exit=";
|
||||
/// Build the exit-code marker prefix for a given nonce.
|
||||
/// Format: `\x1b]ssh-mux;{nonce};exit=`
|
||||
fn build_exit_marker(nonce: &str) -> Vec<u8> {
|
||||
format!("\x1b]ssh-mux;{};exit=", nonce).into_bytes()
|
||||
}
|
||||
|
||||
/// Extract exit status from nonce-tagged escape sequence:
|
||||
/// `\x1b]ssh-mux;{nonce};exit=N\x07`
|
||||
fn extract_exit_status(data: &[u8], marker: &[u8]) -> Option<i32> {
|
||||
if let Some(pos) = data.windows(marker.len()).position(|w| w == marker) {
|
||||
let start = pos + marker.len();
|
||||
let end = data[start..].iter().position(|&b| b == 0x07)?;
|
||||
@@ -234,9 +247,8 @@ fn extract_exit_status(data: &[u8]) -> Option<i32> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the position of the ssh-mux escape sequence in data.
|
||||
fn find_escape_sequence(data: &[u8]) -> Option<usize> {
|
||||
let marker = b"\x1b]ssh-mux;exit=";
|
||||
/// Find the position of the nonce-tagged escape sequence in data.
|
||||
fn find_escape_sequence(data: &[u8], marker: &[u8]) -> Option<usize> {
|
||||
data.windows(marker.len()).position(|w| w == marker)
|
||||
}
|
||||
|
||||
@@ -444,18 +456,19 @@ pub(crate) fn handle_auth_prompts_blocking(request: &AuthInfoRequest) -> Result<
|
||||
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
// Display name and instructions on the console
|
||||
use crate::security::sanitize_for_display;
|
||||
|
||||
if !request.name.is_empty() {
|
||||
writeln!(tty_write, "{}", request.name)?;
|
||||
writeln!(tty_write, "{}", sanitize_for_display(&request.name))?;
|
||||
}
|
||||
if !request.instructions.is_empty() {
|
||||
writeln!(tty_write, "{}", request.instructions)?;
|
||||
writeln!(tty_write, "{}", sanitize_for_display(&request.instructions))?;
|
||||
}
|
||||
|
||||
let mut responses = Vec::with_capacity(request.prompts.len());
|
||||
|
||||
for prompt in &request.prompts {
|
||||
write!(tty_write, "{}", prompt.prompt)?;
|
||||
write!(tty_write, "{}", sanitize_for_display(&prompt.prompt))?;
|
||||
tty_write.flush()?;
|
||||
|
||||
if prompt.echo {
|
||||
|
||||
556
src/security.rs
Normal file
556
src/security.rs
Normal file
@@ -0,0 +1,556 @@
|
||||
//! Shared security utilities for path validation.
|
||||
//!
|
||||
//! Provides reparse-point (junction/mount-point) rejection and parent-directory
|
||||
//! validation that must be applied uniformly to all security-sensitive paths,
|
||||
//! both read and write.
|
||||
|
||||
use anyhow::Result;
|
||||
use std::path::Path;
|
||||
|
||||
/// Reject paths that are Windows reparse points (junctions, mount points,
|
||||
/// symlinks implemented as reparse points). No-op on non-Windows.
|
||||
pub fn reject_reparse_point(path: &Path) -> Result<()> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use anyhow::Context;
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
let meta = std::fs::symlink_metadata(path)
|
||||
.with_context(|| format!("cannot stat {}", path.display()))?;
|
||||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400;
|
||||
if meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||||
anyhow::bail!(
|
||||
"SECURITY: {} is a reparse point (symlink/junction). \
|
||||
Refusing to use redirected paths for security-sensitive files.",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let _ = path;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate a security-sensitive path and all ancestor directories against
|
||||
/// reparse-point redirection attacks. Call this before any read or write
|
||||
/// to `known_hosts`, `authorized_keys`, host keys, daemon tokens, or config
|
||||
/// files.
|
||||
///
|
||||
/// Walks up from the target path through every ancestor (stopping at the
|
||||
/// filesystem root) to catch junctions/mount-points placed anywhere in the
|
||||
/// path hierarchy.
|
||||
pub fn validate_path_security(path: &Path) -> Result<()> {
|
||||
reject_reparse_point(path)?;
|
||||
for ancestor in path.ancestors().skip(1) {
|
||||
if ancestor.as_os_str().is_empty() {
|
||||
break;
|
||||
}
|
||||
reject_reparse_point(ancestor)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the absolute path to `powershell.exe` using the Win32
|
||||
/// `GetSystemDirectoryW` API, which is not influenced by environment
|
||||
/// variables and therefore cannot be poisoned via `%SystemRoot%`.
|
||||
#[cfg(windows)]
|
||||
pub fn powershell_path() -> String {
|
||||
use std::ffi::OsString;
|
||||
use std::os::windows::ffi::OsStringExt;
|
||||
|
||||
let mut buf = [0u16; 260];
|
||||
let len = unsafe {
|
||||
windows_sys::Win32::System::SystemInformation::GetSystemDirectoryW(
|
||||
buf.as_mut_ptr(),
|
||||
buf.len() as u32,
|
||||
)
|
||||
};
|
||||
|
||||
if len > 0 && (len as usize) < buf.len() {
|
||||
let sys_dir = OsString::from_wide(&buf[..len as usize]);
|
||||
let full = format!(
|
||||
r"{}\WindowsPowerShell\v1.0\powershell.exe",
|
||||
sys_dir.to_string_lossy()
|
||||
);
|
||||
if std::path::Path::new(&full).exists() {
|
||||
return full;
|
||||
}
|
||||
}
|
||||
|
||||
r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe".to_string()
|
||||
}
|
||||
|
||||
/// Check Windows DACL permissions on a path (file or directory).
|
||||
///
|
||||
/// Verifies: (1) owner is current user, SYSTEM, or Administrators;
|
||||
/// (2) no NULL DACL; (3) no ACE grants `dangerous_mask` permissions to
|
||||
/// unauthorized SIDs. Fails closed on all Win32 API errors.
|
||||
///
|
||||
/// `context` is used in error messages (e.g. "SSH_MUX_SSH_DIR directory").
|
||||
#[cfg(windows)]
|
||||
pub fn check_dacl_permissions(path: &Path, dangerous_mask: u32, context: &str) -> Result<()> {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use std::ptr;
|
||||
use windows_sys::Win32::Foundation::{
|
||||
CloseHandle, ERROR_SUCCESS, HANDLE, INVALID_HANDLE_VALUE, LocalFree,
|
||||
};
|
||||
use windows_sys::Win32::Security::Authorization::{GetSecurityInfo, SE_FILE_OBJECT};
|
||||
use windows_sys::Win32::Security::{
|
||||
ACL as WIN_ACL, ACL_SIZE_INFORMATION, AclSizeInformation, DACL_SECURITY_INFORMATION,
|
||||
EqualSid, GetAce, GetAclInformation, GetTokenInformation, IsWellKnownSid,
|
||||
OWNER_SECURITY_INFORMATION, TOKEN_QUERY, TOKEN_USER, TokenUser,
|
||||
WinBuiltinAdministratorsSid, WinLocalSystemSid,
|
||||
};
|
||||
use windows_sys::Win32::Storage::FileSystem::CreateFileW;
|
||||
use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
|
||||
|
||||
const ACCESS_ALLOWED_ACE_TYPE: u8 = 0;
|
||||
const ACCESS_ALLOWED_OBJECT_ACE_TYPE: u8 = 5;
|
||||
|
||||
#[repr(C)]
|
||||
struct AceHeader {
|
||||
ace_type: u8,
|
||||
_ace_flags: u8,
|
||||
_ace_size: u16,
|
||||
}
|
||||
#[repr(C)]
|
||||
struct AccessAllowedAce {
|
||||
_header: AceHeader,
|
||||
mask: u32,
|
||||
sid_start: u32,
|
||||
}
|
||||
|
||||
let wide_path: Vec<u16> = path
|
||||
.as_os_str()
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
let handle: HANDLE = unsafe {
|
||||
CreateFileW(
|
||||
wide_path.as_ptr(),
|
||||
0x80000000u32, // GENERIC_READ
|
||||
1 | 2, // FILE_SHARE_READ | FILE_SHARE_WRITE
|
||||
ptr::null(),
|
||||
3, // OPEN_EXISTING
|
||||
0x02000000, // FILE_FLAG_BACKUP_SEMANTICS (needed for directories)
|
||||
ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
|
||||
if handle == INVALID_HANDLE_VALUE {
|
||||
anyhow::bail!(
|
||||
"SECURITY: cannot open {} for ACL check: {} — refusing (fail-closed, {})",
|
||||
path.display(),
|
||||
std::io::Error::last_os_error(),
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
let mut owner_sid: *mut std::ffi::c_void = ptr::null_mut();
|
||||
let mut dacl_ptr: *mut WIN_ACL = ptr::null_mut();
|
||||
let mut sd_ptr: *mut std::ffi::c_void = ptr::null_mut();
|
||||
let result = unsafe {
|
||||
GetSecurityInfo(
|
||||
handle,
|
||||
SE_FILE_OBJECT,
|
||||
OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION,
|
||||
&mut owner_sid as *mut _ as *mut _,
|
||||
ptr::null_mut(),
|
||||
&mut dacl_ptr as *mut _ as *mut _,
|
||||
ptr::null_mut(),
|
||||
&mut sd_ptr as *mut _ as *mut _,
|
||||
)
|
||||
};
|
||||
unsafe {
|
||||
CloseHandle(handle);
|
||||
}
|
||||
|
||||
// Helper: free sd_ptr before returning an error
|
||||
macro_rules! free_sd {
|
||||
() => {
|
||||
if !sd_ptr.is_null() {
|
||||
unsafe {
|
||||
LocalFree(sd_ptr as _);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if result != ERROR_SUCCESS {
|
||||
free_sd!();
|
||||
anyhow::bail!(
|
||||
"SECURITY: GetSecurityInfo failed for {} — refusing (fail-closed, {})",
|
||||
path.display(),
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
// Current user token
|
||||
let mut token: HANDLE = ptr::null_mut();
|
||||
if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 {
|
||||
free_sd!();
|
||||
anyhow::bail!(
|
||||
"SECURITY: cannot open process token for ACL check on {} (fail-closed, {})",
|
||||
path.display(),
|
||||
context,
|
||||
);
|
||||
}
|
||||
let mut token_buf = vec![0u8; 256];
|
||||
let mut ret_len: u32 = 0;
|
||||
let info_ok = unsafe {
|
||||
GetTokenInformation(
|
||||
token,
|
||||
TokenUser,
|
||||
token_buf.as_mut_ptr() as *mut _,
|
||||
token_buf.len() as u32,
|
||||
&mut ret_len,
|
||||
)
|
||||
};
|
||||
unsafe {
|
||||
CloseHandle(token);
|
||||
}
|
||||
if info_ok == 0 {
|
||||
free_sd!();
|
||||
anyhow::bail!(
|
||||
"SECURITY: cannot query token info for {} (fail-closed, {})",
|
||||
path.display(),
|
||||
context,
|
||||
);
|
||||
}
|
||||
let user_sid = unsafe {
|
||||
let tu = token_buf.as_ptr() as *const TOKEN_USER;
|
||||
(*tu).User.Sid
|
||||
};
|
||||
|
||||
// Check 1: Owner
|
||||
let owner_is_self = unsafe { EqualSid(owner_sid, user_sid) } != 0;
|
||||
let owner_is_system = unsafe { IsWellKnownSid(owner_sid as _, WinLocalSystemSid) } != 0;
|
||||
let owner_is_admin =
|
||||
unsafe { IsWellKnownSid(owner_sid as _, WinBuiltinAdministratorsSid) } != 0;
|
||||
if !owner_is_self && !owner_is_system && !owner_is_admin {
|
||||
free_sd!();
|
||||
anyhow::bail!(
|
||||
"SECURITY: {} is not owned by the current user (or SYSTEM/Administrators) — \
|
||||
refusing ({})",
|
||||
path.display(),
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
// Check 2: NULL DACL
|
||||
if dacl_ptr.is_null() {
|
||||
free_sd!();
|
||||
anyhow::bail!(
|
||||
"SECURITY: {} has a NULL DACL (grants full access to everyone) — refusing ({})",
|
||||
path.display(),
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
// Check 3: ACE permissions
|
||||
let mut acl_info: ACL_SIZE_INFORMATION = unsafe { std::mem::zeroed() };
|
||||
if unsafe {
|
||||
GetAclInformation(
|
||||
dacl_ptr as *const _,
|
||||
&mut acl_info as *mut _ as *mut _,
|
||||
std::mem::size_of::<ACL_SIZE_INFORMATION>() as u32,
|
||||
AclSizeInformation,
|
||||
)
|
||||
} == 0
|
||||
{
|
||||
free_sd!();
|
||||
anyhow::bail!(
|
||||
"SECURITY: cannot read ACL information for {} (fail-closed, {})",
|
||||
path.display(),
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
for i in 0..acl_info.AceCount {
|
||||
let mut ace_ptr: *mut std::ffi::c_void = ptr::null_mut();
|
||||
if unsafe { GetAce(dacl_ptr as *const _, i, &mut ace_ptr) } == 0 || ace_ptr.is_null() {
|
||||
free_sd!();
|
||||
anyhow::bail!(
|
||||
"SECURITY: cannot read ACE #{} from DACL of {} (fail-closed, {})",
|
||||
i,
|
||||
path.display(),
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
let header = unsafe { &*(ace_ptr as *const AceHeader) };
|
||||
|
||||
if header.ace_type == ACCESS_ALLOWED_OBJECT_ACE_TYPE {
|
||||
let ace = unsafe { &*(ace_ptr as *const AccessAllowedAce) };
|
||||
if ace.mask & dangerous_mask != 0 {
|
||||
free_sd!();
|
||||
anyhow::bail!(
|
||||
"SECURITY: {} has an Object ACE (type 5) with dangerous permissions \
|
||||
(mask 0x{:08X}) — refusing (fail-closed, {})",
|
||||
path.display(),
|
||||
ace.mask,
|
||||
context,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if header.ace_type != ACCESS_ALLOWED_ACE_TYPE {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ace = unsafe { &*(ace_ptr as *const AccessAllowedAce) };
|
||||
if ace.mask & dangerous_mask == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ace_sid = &ace.sid_start as *const u32 as *mut std::ffi::c_void;
|
||||
let is_self = unsafe { EqualSid(ace_sid, user_sid) } != 0;
|
||||
let is_system = unsafe { IsWellKnownSid(ace_sid as _, WinLocalSystemSid) } != 0;
|
||||
let is_admin = unsafe { IsWellKnownSid(ace_sid as _, WinBuiltinAdministratorsSid) } != 0;
|
||||
|
||||
if !is_self && !is_system && !is_admin {
|
||||
free_sd!();
|
||||
anyhow::bail!(
|
||||
"SECURITY: {} has a DACL entry granting dangerous permissions \
|
||||
(mask 0x{:08X}) to an unauthorized SID — refusing ({})",
|
||||
path.display(),
|
||||
ace.mask,
|
||||
context,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
free_sd!();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dangerous permission mask for directories: write, create, delete, own.
|
||||
#[cfg(windows)]
|
||||
pub const DIR_DANGEROUS_MASK: u32 = 0x0002 // FILE_ADD_FILE / FILE_WRITE_DATA
|
||||
| 0x0004 // FILE_ADD_SUBDIRECTORY / FILE_APPEND_DATA
|
||||
| 0x0040 // FILE_DELETE_CHILD
|
||||
| 0x00010000 // DELETE
|
||||
| 0x00040000 // WRITE_DAC
|
||||
| 0x00080000 // WRITE_OWNER
|
||||
| 0x40000000 // GENERIC_WRITE
|
||||
| 0x10000000; // GENERIC_ALL
|
||||
|
||||
/// Dangerous permission mask for general files (authorized keys, config):
|
||||
/// rejects write, delete, ownership changes, and generic write/all.
|
||||
#[cfg(windows)]
|
||||
pub const FILE_DANGEROUS_MASK: u32 = 0x0002 // FILE_WRITE_DATA
|
||||
| 0x0004 // FILE_APPEND_DATA
|
||||
| 0x00010000 // DELETE
|
||||
| 0x00040000 // WRITE_DAC
|
||||
| 0x00080000 // WRITE_OWNER
|
||||
| 0x40000000 // GENERIC_WRITE
|
||||
| 0x10000000; // GENERIC_ALL
|
||||
|
||||
/// Dangerous permission mask for the host private key file: additionally
|
||||
/// rejects read access from unauthorized SIDs since the private key must
|
||||
/// not be readable by other users.
|
||||
#[cfg(windows)]
|
||||
pub const HOST_KEY_DANGEROUS_MASK: u32 = 0x0001 // FILE_READ_DATA
|
||||
| 0x0002 // FILE_WRITE_DATA
|
||||
| 0x0004 // FILE_APPEND_DATA
|
||||
| 0x00010000 // DELETE
|
||||
| 0x00040000 // WRITE_DAC
|
||||
| 0x00080000 // WRITE_OWNER
|
||||
| 0x80000000u32 // GENERIC_READ
|
||||
| 0x40000000 // GENERIC_WRITE
|
||||
| 0x10000000; // GENERIC_ALL
|
||||
|
||||
/// Strip ANSI escape sequences and control characters from a string
|
||||
/// intended for display. Keeps printable ASCII, space, and common Unicode.
|
||||
///
|
||||
/// Handles: CSI (ESC [), OSC (ESC ]), DCS (ESC P), PM (ESC ^), APC (ESC _),
|
||||
/// and SOS (ESC X). All are consumed up to their string terminator (ST = ESC \
|
||||
/// or BEL for OSC).
|
||||
pub fn sanitize_for_display(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut chars = s.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '\x1b' {
|
||||
if let Some(&next) = chars.peek() {
|
||||
if next == '[' {
|
||||
// CSI sequence: consume until 0x40..=0x7E
|
||||
chars.next();
|
||||
for c2 in chars.by_ref() {
|
||||
if ('\x40'..='\x7e').contains(&c2) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if next == ']' {
|
||||
// OSC sequence: consume until ST (ESC \) or BEL
|
||||
chars.next();
|
||||
consume_until_st(&mut chars, true);
|
||||
} else if next == 'P' || next == '^' || next == '_' || next == 'X' {
|
||||
// DCS (ESC P), PM (ESC ^), APC (ESC _), SOS (ESC X):
|
||||
// consume until ST (ESC \)
|
||||
chars.next();
|
||||
consume_until_st(&mut chars, false);
|
||||
} else {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
} else if c == '\n' || c == '\r' || c == '\t' {
|
||||
out.push(c);
|
||||
} else if c.is_control() {
|
||||
// Drop other control characters (includes BEL, C1 range, etc.)
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Consume characters until the String Terminator (ST = ESC \) is found.
|
||||
/// If `allow_bel` is true, BEL (\x07) also terminates (used by OSC).
|
||||
fn consume_until_st(chars: &mut std::iter::Peekable<std::str::Chars<'_>>, allow_bel: bool) {
|
||||
while let Some(c) = chars.next() {
|
||||
if allow_bel && c == '\x07' {
|
||||
break;
|
||||
}
|
||||
if c == '\x1b' {
|
||||
if chars.peek() == Some(&'\\') {
|
||||
chars.next();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sanitize_preserves_plain_text() {
|
||||
assert_eq!(sanitize_for_display("hello world"), "hello world");
|
||||
assert_eq!(sanitize_for_display("한국어 테스트"), "한국어 테스트");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_preserves_whitespace() {
|
||||
assert_eq!(
|
||||
sanitize_for_display("line1\nline2\ttab\r"),
|
||||
"line1\nline2\ttab\r"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_strips_csi_sequences() {
|
||||
assert_eq!(sanitize_for_display("\x1b[31mred\x1b[0m"), "red");
|
||||
assert_eq!(sanitize_for_display("\x1b[1;32;40mtext\x1b[m"), "text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_strips_osc_sequences() {
|
||||
// OSC with BEL terminator (title change)
|
||||
assert_eq!(
|
||||
sanitize_for_display("\x1b]0;malicious title\x07safe"),
|
||||
"safe"
|
||||
);
|
||||
// OSC with ST terminator
|
||||
assert_eq!(
|
||||
sanitize_for_display("\x1b]52;c;base64data\x1b\\safe"),
|
||||
"safe"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_strips_dcs_sequences() {
|
||||
assert_eq!(sanitize_for_display("\x1bPdcs payload\x1b\\safe"), "safe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_strips_pm_sequences() {
|
||||
assert_eq!(sanitize_for_display("\x1b^pm payload\x1b\\safe"), "safe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_strips_apc_sequences() {
|
||||
assert_eq!(sanitize_for_display("\x1b_apc payload\x1b\\safe"), "safe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_strips_sos_sequences() {
|
||||
assert_eq!(sanitize_for_display("\x1bXsos payload\x1b\\safe"), "safe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_strips_control_characters() {
|
||||
assert_eq!(sanitize_for_display("a\x01\x02\x03b"), "ab");
|
||||
assert_eq!(sanitize_for_display("a\x07b"), "ab"); // BEL
|
||||
assert_eq!(sanitize_for_display("a\x7fb"), "ab"); // DEL
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_handles_nested_escape_attack() {
|
||||
// Attacker tries to embed escape inside escape
|
||||
let attack = "\x1b]0;\x1b[31mfake\x1b[0m\x07visible";
|
||||
let result = sanitize_for_display(attack);
|
||||
assert!(!result.contains('\x1b'));
|
||||
assert!(result.contains("visible"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_handles_incomplete_sequences() {
|
||||
assert_eq!(sanitize_for_display("\x1b"), "");
|
||||
assert_eq!(sanitize_for_display("\x1b["), "");
|
||||
assert_eq!(sanitize_for_display("text\x1b"), "text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_handles_clipboard_injection_attack() {
|
||||
// OSC 52 clipboard write attempt
|
||||
let attack = "\x1b]52;c;bWFsaWNpb3Vz\x07Enter your password: ";
|
||||
let result = sanitize_for_display(attack);
|
||||
assert!(!result.contains('\x1b'));
|
||||
assert!(result.contains("Enter your password: "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_path_security_accepts_normal_paths() {
|
||||
let result = validate_path_security(std::path::Path::new("C:\\nonexistent\\path"));
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn powershell_path_returns_absolute_existing_path() {
|
||||
let path = powershell_path();
|
||||
let p = std::path::Path::new(&path);
|
||||
assert!(
|
||||
p.is_absolute(),
|
||||
"powershell_path() must return an absolute path, got: {}",
|
||||
path
|
||||
);
|
||||
assert!(
|
||||
p.exists(),
|
||||
"powershell_path() returned non-existent path: {}",
|
||||
path
|
||||
);
|
||||
assert!(
|
||||
path.to_lowercase().contains("system32"),
|
||||
"powershell_path() should resolve via System32, got: {}",
|
||||
path
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn powershell_path_does_not_return_bare_name() {
|
||||
let path = powershell_path();
|
||||
assert_ne!(
|
||||
path, "powershell",
|
||||
"powershell_path() must not return a bare name susceptible to PATH hijacking"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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