31 Commits

Author SHA1 Message Date
cd6a517fec chore: bump version to v1.14.1, soften banner-detection rationale
All checks were successful
CI / Check (fmt, clippy, test) (push) Successful in 56s
Reframe the SSH client banner change as banner consistency with native
`ssh` rather than a VPN/Okta device-detection workaround. Okta-style
device posture relies on TCP/IP stack fingerprinting from the kernel,
which a userspace SSH banner cannot influence. The change is still
worth keeping for its original goal — making ssh-mux indistinguishable
from `ssh` at the protocol banner — but the docstring and CHANGELOG
should not promise an outcome it cannot deliver.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:39:27 +09:00
d4cf32dbed feat(pool): mimic local OpenSSH banner to fix VPN/Okta device fingerprinting
All checks were successful
CI / Check (fmt, clippy, test) (push) Successful in 56s
VPN servers and Okta integrations parse the SSH client banner as a device
hint. Russh's default `SSH-2.0-russh_0.57` falls through to "Linux", which
is wrong for ssh-mux's Windows users. Detect the local `ssh -V` version
and reuse its exact banner so ssh-mux is indistinguishable from native ssh
on the same host. Falls back to a platform-appropriate default when ssh
isn't on PATH.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:14:36 +09:00
9d267cf7a8 feat!: always allow remote loopback any-port for direct-tcpip
All checks were successful
CI / Check (fmt, clippy, test) (push) Successful in 56s
Remove the per-route ``allow_remote_loopback_any_port`` opt-in (added in
v1.13.4) and the top-level ``default_allow_remote_loopback_any_port``
fallback (added in v1.13.5). Both fields silently dropped out of the
config every time ``import-config`` rewrote ``ssh-mux-routes.toml``,
which is the path ``install`` takes — so VS Code / Cursor Remote-SSH
kept breaking on every reinstall and the user had to remember to
re-add the line.

Lift the restriction entirely instead: an authenticated route now
accepts ``direct-tcpip`` to its own host:port plus **any port** on
remote loopback. Non-loopback non-route targets stay denied so the
route still cannot be used to pivot into arbitrary internal hosts.

Threat model trade: the old 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 — and its UX cost
(silent breakage on reinstall) was high.

Migration: nothing to do. ``RouteEntry`` and ``MuxConfig`` no longer
declare the fields, but serde ignores unknown keys, so existing
``allow_remote_loopback_any_port = true|false`` and
``default_allow_remote_loopback_any_port`` lines parse fine and are
simply no-ops now.

The ``feat!`` marker reflects the policy default change; behaviour
strictly relaxes (more channels allowed) so no caller-visible
breakage beyond the policy itself.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:45:45 +09:00
c63474eb86 feat(forwarding): top-level default_allow_remote_loopback_any_port
All checks were successful
CI / Check (fmt, clippy, test) (push) Successful in 1m17s
v1.13.4 added a per-route ``allow_remote_loopback_any_port`` flag so
VS Code / Cursor Remote-SSH could open ``direct-tcpip`` channels to
their random IDE-server localhost ports. For users whose routes are
predominantly IDE remote-dev targets, repeating the same opt-in line
under every ``[routes.X]`` section is just noise.

Add a top-level ``default_allow_remote_loopback_any_port`` (default
``false``, preserves the secure per-route opt-in) that acts as a
fallback for the per-route flag. 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`` — and a single top-level ``true``
covers every route at once.

The threat the opt-in guards against — a local process or other user
account using the route as a SOCKS-style proxy into the remote's
localhost services — is real on shared / multi-user machines but
mostly defense-in-depth on a single-user dev box, where any actor
with code execution against the local mux listener already has the
keys to ssh directly. Making it a single-line top-level switch keeps
the secure default the README recommends while making the IDE path
ergonomic for the common single-user case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:48:27 +09:00
51210232a1 fix(forwarding): unblock VS Code/Cursor Remote-SSH with opt-in loopback flag
All checks were successful
CI / Check (fmt, clippy, test) (push) Successful in 1m6s
Commit e7a527e ("Harden IPC and forwarding policy") restricted
direct-tcpip channels on remote loopback to the route's SSH port only.
This broke VS Code and Cursor Remote-SSH, which spawn their IDE server
on a random localhost port and forward to it via direct-tcpip — every
such channel was denied as "administratively prohibited" and the IDE
looped on "Setting up SSH host".

Add a per-route opt-in `allow_remote_loopback_any_port` (default false,
preserving the secure default) that accepts any port on the route's
remote loopback. Non-loopback non-route targets remain denied. Document
the new option in the README and CHANGELOG, and add a policy unit test
covering the opt-in path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:08:58 +09:00
e1e91a4741 chore: bump version to v1.13.3, document lockfile fix
All checks were successful
CI / Check (fmt, clippy, test) (push) Successful in 1m18s
Release v1.13.3:
- Commit Cargo.lock so CI is reproducible (no code change)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 11:56:19 +09:00
ef3507e688 build: commit Cargo.lock to fix CI
ssh-mux is a binary crate; the lockfile should be tracked so builds
are reproducible. The previous policy (lockfile in .gitignore) is
correct for library crates but wrong here.

Triggering failure: russh enables the `rsa` feature, which pulls
rsa-0.10.0-rc.12 transitively. rsa-rc.12 was written against
pkcs8-0.11.0-rc.11; pkcs8 has since cut a stable 0.11.0 with a
breaking change to `Error::KeyMalformed` (now a tuple variant).
Without a lockfile, CI's resolver picked the stable, and the build
failed with `match arms have incompatible types` deep in the rsa
crate. Local builds were unaffected because Cargo.lock pinned the
right rc.

Committing the existing Cargo.lock fixes CI. No code change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 11:56:13 +09:00
a92b86a382 chore: bump version to v1.13.2, document re-import fix
Some checks failed
CI / Check (fmt, clippy, test) (push) Failing after 56s
Release v1.13.2:
- import-config works as a re-import after ssh-mux is already installed
- warnings are now flushed before any bail (was: silent failure)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 11:51:55 +09:00
a5c0f65b17 fix(import): support re-import after Include line is in place
Once setup-config has prepended `Include ssh-mux-hosts.conf` to
~/.ssh/config, every `ssh -G <alias>` resolves to 127.0.0.1 (because
the Include points at our generated 127.0.0.1:2222 routes), the
"looks like an existing ssh-mux entry" filter drops every alias, and
import-config bails with `no usable routes after resolution`. After
an upgrade — exactly when you'd want to re-import — the workflow was
broken.

Detect the exact line we generate (`Include ssh-mux-hosts.conf`) in
the source SSH config. If found, write a stripped temporary copy to
the same directory (so other relative Include directives in the
user's config keep resolving against the right base) and run
`ssh -F <stripped> -G <alias>`. An RAII guard removes the temp file
on exit. The user's original ~/.ssh/config is never written.

Also fix a UX bug where `no usable routes after resolution; see
warnings above` was emitted with no warnings actually printed —
warnings were accumulated for emission only after build_mux_config,
which the bail bypassed. A `flush_warnings` helper is now called
before every bail and on the normal exit path.

Three new unit tests cover both helpers; total import-config tests
go from 15 to 18, total bin tests from 100 to 103.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 11:51:48 +09:00
480ed466d5 chore: bump version to v1.13.1, document ProxyCommand exclusion
All checks were successful
CI / Check (fmt, clippy, test) (push) Successful in 57s
Release v1.13.1:
- import-config skips hosts driven by ProxyCommand (e.g. AWS SSM)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 17:53:05 +09:00
3ad03f5e9b fix: import-config skips ProxyCommand hosts and ProxyCommand jumps
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 "powershell aws ssm ...").
Previously such aliases were resolved via ssh -G, the EC2 instance
ID was captured as the route's hostname, and the alias was written
into routes.toml. ssh-mux would then try to open a direct TCP
connection to "i-0c5..." and fail at DNS / connect.

Capture proxycommand in ResolvedHost (treating "none" as unset, same
as proxyjump), then in build_mux_config:

- Skip any route whose resolved proxycommand is set, with a warning
  pointing the user at the original Host block (it stays in
  ~/.ssh/config and ssh.exe keeps using it via the ProxyCommand;
  the prepended Include does not contain the alias, so OpenSSH's
  first-match-wins falls through).
- Skip any route whose jump-host resolution has a proxycommand
  (an SSM-only bastion would be just as unreachable).
- Exclude ProxyCommand hosts from the jump-host majority vote so
  they cannot influence the picked [jump].

Three new unit tests lock the behavior in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 17:52:54 +09:00
31d5643ab6 chore: bump version to v1.13.0, document install bootstrap
All checks were successful
CI / Check (fmt, clippy, test) (push) Successful in 1m21s
Release v1.13.0:
- ssh-mux install now chains import-config -> setup-config -> service
- install runs on non-Windows (skips Startup VBS, prints daemon cmd)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:23:08 +09:00
e42a396daa feat: install runs the full bootstrap (import + setup-config + service)
Previously \`ssh-mux install\` only performed the Windows service
install step, leaving \`import-config\` and \`setup-config\` for the
user to chain manually. Now \`install\` is a one-shot bootstrap:

1. If \`~/.ssh/ssh-mux-routes.toml\` does not exist, derive it from
   \`~/.ssh/config\` via \`ssh -G <alias>\` (read-only — the source
   SSH config is never modified). If the file already exists it is
   reused as-is; \`ssh-mux import-config --write --force\` re-imports
   explicitly.
2. \`setup-config\` runs unchanged: generates \`ssh-mux-hosts.conf\`,
   prepends the \`Include\` line, pre-registers the host key.
3. (Windows) install service + start daemon, as before. (Other
   platforms) print the manual \`ssh-mux daemon …\` invocation. The
   command no longer errors out on non-Windows.

The standalone \`import-config\` and \`setup-config\` subcommands
remain available unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:23:02 +09:00
9527caca0e chore: bump version to v1.12.1, add CHANGELOG covering v1.11.0–v1.12.1
All checks were successful
CI / Check (fmt, clippy, test) (push) Successful in 1m1s
Release v1.12.1:
- Build: satisfy clippy 1.95 collapsible_match lint in import.rs

Adds a CHANGELOG.md at the repository root following the
Keep-a-Changelog format. Versions before v1.11.0 remain tracked
only in the git log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:44:44 +09:00
43c1c77a30 fix(lint): satisfy clippy 1.95 collapsible_match in import.rs
The `proxyjump` match arm in `parse_ssh_g_output` nested an `if !val
.eq_ignore_ascii_case("none")` filter inside the arm body. clippy
1.95 (used by CI) flags this as `collapsible_match`. Express it as
a guarded arm instead. No functional change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:44:36 +09:00
18f5972401 chore: bump version to v1.12.0
Some checks failed
CI / Check (fmt, clippy, test) (push) Failing after 58s
Release v1.12.0:
- Fix oneshot panic on keyboard-interactive auth failure

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:35:08 +09:00
ed2521294b fix: clear upstream auth state after the KI driver terminates
When the upstream auth driver finished (success or failure),
auth_keyboard_interactive consumed `outcome_rx` via the select but
left the spent `UpstreamAuthState` parked in `self.upstream_auth`.
The next round of keyboard-interactive — which SSH retries after a
failed attempt — re-entered the same select and re-polled the
already-completed oneshot receiver, panicking with:

  panicked at tokio/src/sync/oneshot.rs:1289:
  called after complete

Symptom: one panic per keyboard-interactive failure.

Restructure the select to return `(Auth, driver_finished)`. When
the driver has terminated (outcome arm fires, or prompts channel
closes), clear `self.upstream_auth` after the borrow ends so the
next call spawns a fresh driver. Also clear state on the
responses_tx-send-failed path, where the driver has gone away and
keeping its state would leak the same trap into the next round.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:35:01 +09:00
65a3248619 chore: bump version to v1.11.0, document import-config and idle-timer fix
Some checks failed
CI / Check (fmt, clippy, test) (push) Failing after 58s
Release v1.11.0:
- New `import-config` subcommand
- `setup-config` no longer mutates existing `Host` blocks
- Idle timer no longer reaps connections with open channels

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:09:06 +09:00
9cb95fac10 fix: freeze idle timer while a connection has open channels
The pool reaper used `last_used.elapsed() > timeout` as the staleness
signal, but `last_used` is only refreshed at channel open and channel
close — byte traffic on the relay never bumped it. As a result, a
connection with one or more open channels was reaped exactly
`--timeout` seconds after the channel opened, killing live tmux/SSH
sessions even while the user was actively typing. The reaper logged
this as `closing stale connection ... idle 607s with 1 zombie
channel(s)`.

`Pool::reap_decision` now keeps any connection whose russh handle is
alive and whose `active_channels > 0`; truly dead remotes are caught
by russh keepalive (~45s detection) which closes the handle and trips
the existing `is_closed()` branch, and `--max-lifetime` still enforces
an absolute cap. To prevent the new policy from being defeated by a
leaked counter, the relay task now holds an RAII guard that fires
`pool.channel_closed*` on Drop, so a panic in `relay_bidirectional`
can no longer pin a connection in the pool.

Six new regression tests in `pool::reap_decision_tests` lock the
policy in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:08:59 +09:00
1b68854d00 feat: add import-config and stop mutating user host blocks
`setup-config` no longer comments out conflicting `Host` blocks in
`~/.ssh/config`; it only prepends the `Include ssh-mux-hosts.conf`
line and emits `ProxyJump none` + `ProxyCommand none` on every
generated block to suppress option bleed-through. Overlapping aliases
are reported but not edited.

New `import-config` subcommand derives a `routes.toml` from an
existing OpenSSH client config. Literal `Host` aliases are enumerated
and then resolved with `ssh -G <alias>`, so `Match`, `Include`, and
`Host *` inheritance are applied by OpenSSH itself instead of a
hand-rolled parser. The source SSH config is never modified.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:08:39 +09:00
e7a527e7e2 Harden IPC and forwarding policy
All checks were successful
CI / Check (fmt, clippy, test) (push) Successful in 1m18s
2026-04-25 21:01:39 +09:00
fe29db4b7e fix(lint): satisfy clippy 1.95 collapsible_match lints
All checks were successful
CI / Check (fmt, clippy, test) (push) Successful in 1m19s
Rust 1.95's clippy flagged three collapsible_match cases. Fix daemon.rs
by pattern-matching `ext: 1` directly on ExtendedData. For local_server.rs,
the nested `if` calls consume the bound `data` (CryptoVec is non-Copy), so
they cannot be collapsed into pattern guards without cloning; silence those
two arms with #[allow(clippy::collapsible_match)] instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 12:56:06 +09:00
118163eace refactor: improve condition check readability in wait_for_daemon
Some checks failed
CI / Check (fmt, clippy, test) (push) Failing after 1m1s
Refactored the condition check in the wait_for_daemon function within integration.rs to enhance readability by separating the result handling and success check into distinct lines. This change aims to clarify the logic flow and maintain consistency with recent code improvements.
2026-04-23 12:04:17 +09:00
2948d0e6e5 refactor: simplify hash encoding and improve condition check
Some checks failed
CI / Check (fmt, clippy, test) (push) Failing after 45s
Updated the hash encoding in known_hosts.rs to directly pass the hash to the base64 encoder. Additionally, streamlined the condition check in integration.rs to combine the result handling and success check into a single line for better readability.
2026-04-23 11:42:41 +09:00
e745c2666e refactor: drop toast notification on session open
Some checks failed
CI / Check (fmt, clippy, test) (push) Failing after 1m15s
Toast fired a PowerShell subprocess per channel open, which is noisy
for clients like VS Code Remote-SSH that open dozens of channels per
window. The tracing::info!("SESSION OPENED: ...") log already records
the event for troubleshooting, and with this refactor it is emitted
inline in channel_open_session instead of through a dedicated helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:09:48 +09:00
47974483fb feat: forward upstream OTP to client's SSH session via keyboard-interactive
Replace the PowerShell OTP prompt window with in-band keyboard-interactive
prompts delivered through the local russh server, so the user types the
OTP in whatever app launched ssh (terminal, Cursor, VS Code Remote-SSH)
instead of a separate console window.

Flow on the local SSH server:
- auth_publickey verifies the client key and returns FAILURE with
  methods=[keyboard-interactive], gating entry (russh 0.57 clobbers
  partial_success on the publickey path, but the method list is honored
  by OpenSSH clients).
- auth_keyboard_interactive spawns an upstream-auth driver task that
  calls pool::ensure_{direct,jump}_connected. Prompts from the upstream
  (e.g. MFA OTP on the bastion) flow back via mpsc and are surfaced to
  the client as Auth::Partial; the client's responses are forwarded
  upstream. On success, Auth::Accept leaves the upstream warm in the
  pool for subsequent channel opens (which use dead auth channels since
  no re-auth is needed).

setup-config now emits PreferredAuthentications publickey,keyboard-interactive
and KbdInteractiveAuthentication yes. Existing users must re-run
setup-config after upgrading so ssh-mux-hosts.conf picks up the new
method allowlist.

Removes ~435 lines of PowerShell OTP named-pipe code (the Windows
handle_auth_prompts_gui/_blocking pair, the Unix variant, and the
create_console_auth_channels mpsc bridge that fed them). Adds
UpstreamAuthState, spawn_upstream_auth_driver, and dead_auth_channels
to take their place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:02:20 +09:00
6a37e8341c feat(pool): add ensure_{direct,jump}_connected warm-up methods
Expose two public entry points that authenticate and pool an upstream
SSH connection without opening a channel:

- ensure_direct_connected: direct route
- ensure_jump_connected_pub: jump-host (bastion), thin wrapper over
  the existing ensure_jump_connected

Used to relocate interactive authentication (OTP) from channel-open
time to the client's auth phase: the local SSH server can now drive
the upstream login during its own keyboard-interactive handshake and
hand the pooled connection off to subsequent channel opens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:50:58 +09:00
f5f5d8e61d feat: implement serialized jump-host connection setup to prevent duplicate OTP prompts
All checks were successful
CI / Check (fmt, clippy, test) (push) Successful in 1m50s
Added a mechanism to serialize connections to jump-hosts, ensuring that concurrent sessions to different internal hosts do not initiate separate bastion logins. This change introduces a new `jump_connect_serializers` field in the `Pool` struct and a helper function `serialize_jump_host_setup` to manage the async mutex for jump-host connections. Updated the `ensure_jump_connected` method to utilize this serialization, enhancing the connection handling logic.

Includes tests to verify that only one connection attempt occurs for the same jump key, preventing race conditions that could lead to duplicate OTP prompts.

Made-with: Cursor
2026-04-15 09:27:57 +09:00
명선 최
6c51d2f4a8 fix: work around Win32-OpenSSH askpass failure on non-ASCII usernames
All checks were successful
CI / Check (fmt, clippy, test) (push) Successful in 1m27s
Win32-OpenSSH posix_spawnp passes SSH_ASKPASS through narrow-string CreateProcessW, which cannot resolve Unicode characters in the home directory path (e.g. Korean/Japanese/Chinese usernames). This causes Cursor host key verification to silently fail with CreateProcessW error:2 while terminal SSH works fine.

setup-config now generates cursor-ssh.cmd and cursor-askpass.cmd wrapper scripts in the SSH dir (typically ASCII) when the home directory contains non-ASCII characters. The wrapper redirects SSH_ASKPASS to the ASCII-path proxy before calling ssh.exe.

Bump version to 1.10.4.

Made-with: Cursor
2026-03-23 13:34:20 +09:00
명선 최
6a583abe42 chore: bump version to 1.10.3
All checks were successful
CI / Check (fmt, clippy, test) (push) Successful in 1m23s
Made-with: Cursor
2026-03-20 14:20:35 +09:00
명선 최
9894e9ee75 fix: move OTP hostname display after pipe init and embed in Read-Host prompt
All checks were successful
CI / Check (fmt, clippy, test) (push) Successful in 1m27s
Windows Terminal clears the console buffer during initialization, so

Write-Host output before pipe connection was invisible. Move the

hostname header after pipe read and embed it in the Read-Host prompt

text itself ([hostname] prompt) for guaranteed visibility. Strip

trailing colon/whitespace from SSH prompt to avoid double-colon.

Made-with: Cursor
2026-03-20 14:18:33 +09:00
15 changed files with 6079 additions and 847 deletions

1
.gitignore vendored
View File

@@ -1,4 +1,3 @@
/target
Cargo.lock
start-daemon.bat
start-daemon.vbs

266
CHANGELOG.md Normal file
View 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 12 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

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "ssh-mux"
version = "1.10.2"
version = "1.14.1"
edition = "2024"
description = "SSH connection multiplexer for Windows - ControlMaster alternative"

109
README.md
View File

@@ -15,7 +15,6 @@ Pools SSH connections and exposes a local SSH server proxy so that tools like `s
- 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)
- Windows toast notifications for new SSH sessions
- 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
@@ -41,19 +40,18 @@ git config core.hooksPath hooks
## Quick start
```powershell
# 1. Create a routes config
notepad ~/.ssh/ssh-mux-routes.toml
# 2. Generate SSH config entries
ssh-mux setup-config
# 3. Install as a background service (starts immediately)
# 1. One-shot bootstrap: import existing ~/.ssh/config into routes.toml,
# generate ssh-mux-hosts.conf + Include line, install service.
ssh-mux install
# 4. Connect
# 2. Connect
ssh webserver
```
`ssh-mux install` chains `import-config``setup-config` → service install. If `~/.ssh/ssh-mux-routes.toml` does not exist it is derived from `~/.ssh/config` via `ssh -G <alias>`. If you already have a `routes.toml` (hand-written or from a previous run) it is reused as-is — pass `ssh-mux import-config --write --force` to re-import.
The standalone `import-config` and `setup-config` subcommands remain available for step-by-step or re-run use; see [Configuration](#configuration) below.
## Configuration
### Routes file (`~/.ssh/ssh-mux-routes.toml`)
@@ -79,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:
@@ -95,15 +104,44 @@ Options:
ssh-mux setup-config --config /path/to/routes.toml -p 2222
```
### Import an existing SSH config
If you already have working `Host` aliases in `~/.ssh/config`, you can derive a `routes.toml` from them instead of writing one by hand:
```
ssh-mux import-config # dry-run — prints generated TOML to stdout
ssh-mux import-config --write # writes ~/.ssh/ssh-mux-routes.toml
```
Each literal `Host` alias (wildcards and `Match` blocks are skipped) is resolved with `ssh -G <alias>`, so `Match`, `Include`, and `Host *` inheritance are handled by OpenSSH itself. The source SSH config is **never modified** — only read.
`ProxyJump` is mapped to the single `[jump]` section: if multiple distinct jump hosts are detected, the most common one is selected and routes via others are skipped with a warning. Hosts without `ProxyJump` become `direct = true` routes.
Options:
```
ssh-mux import-config --from /path/to/config --out /path/to/routes.toml --write --force
ssh-mux import-config --ssh-bin C:\Windows\System32\OpenSSH\ssh.exe
```
Note: `Match exec` directives in the source config will execute as part of `ssh -G` resolution. This is the same behavior `ssh <alias>` would have; no new privilege boundary is crossed.
**Hosts driven by `ProxyCommand` are skipped** (e.g. AWS SSM `start-session`, `Match host i-* / ProxyCommand aws ssm …`). ssh-mux only drives raw SSH over TCP or via a jump-host channel — it cannot exec custom transport processes. Such hosts stay in `~/.ssh/config` and `ssh.exe` keeps using them via the original `ProxyCommand` (`Include ssh-mux-hosts.conf` does not contain those aliases, so OpenSSH's first-match-wins falls through). Same exclusion applies when the jump host itself uses `ProxyCommand`.
## Commands
### `ssh-mux install`
Installs ssh-mux as a background service:
One-shot bootstrap pipeline:
1. Copies the binary to `~/.ssh/ssh-mux.exe`
2. Creates a VBScript in the Windows Startup folder (resolved via Win32 Known Folder API, resistant to `%APPDATA%` poisoning) for auto-start at logon
3. Starts the server immediately via Rust `CreateProcess` with `CREATE_NO_WINDOW` (no PowerShell command-line injection surface)
1. **import-config** — if `~/.ssh/ssh-mux-routes.toml` does not exist, derive it from `~/.ssh/config` via `ssh -G <alias>`. If the file already exists it is reused (use `ssh-mux import-config --write --force` to re-import explicitly).
2. **setup-config** — generate `~/.ssh/ssh-mux-hosts.conf`, prepend `Include ssh-mux-hosts.conf` to `~/.ssh/config` (existing `Host` blocks are not modified), and pre-register the host key in `ssh-mux-known-hosts`.
3. **service install** (Windows only) —
1. Copies the binary to `~/.ssh/ssh-mux.exe`
2. Creates a VBScript in the Windows Startup folder (resolved via Win32 Known Folder API, resistant to `%APPDATA%` poisoning) for auto-start at logon
3. Starts the server immediately via Rust `CreateProcess` with `CREATE_NO_WINDOW` (no PowerShell command-line injection surface)
On non-Windows platforms steps 12 still run; step 3 is skipped and the manual `ssh-mux daemon …` invocation is printed instead.
```
ssh-mux install
@@ -200,6 +238,8 @@ One key per line, same format as `authorized_keys`. If this file does not exist,
- All config fields (host, user) reject newline, carriage return, null, and leading/trailing whitespace
- Config files and known_hosts written atomically (CSPRNG-random temp file + `O_EXCL` + fsync + rename) to prevent partial writes and TOCTOU attacks
- Symlink targets rejected on all write paths
- `setup-config` is purely additive on `~/.ssh/config`: it prepends a single `Include` line and never edits, comments out, or removes existing user content. Conflicts are reported but not mutated. `import-config` is read-only on the source SSH config — it derives `routes.toml` via `ssh -G` without writing back.
- `import-config` validates each enumerated alias against the route-name regex before passing it to `ssh -G`, and rejects any alias starting with `-` to prevent option injection on the ssh CLI
### IPC security (Windows)
@@ -236,9 +276,9 @@ One key per line, same format as `authorized_keys`. If this file does not exist,
- SSH keepalive enabled on all connections (client and local server): 15-second interval, 3 missed replies before disconnect
- Dead connections (keepalive timeout, remote close) are automatically reaped every 30 seconds
- Idle connections are cleaned up after `--timeout` seconds, even if zombie channels remain from unclean client disconnects
- Idle connections are cleaned up after `--timeout` seconds **only when no channels are open**. Active channels freeze the idle timer regardless of byte traffic on the relay — keepalive (~45s) detects truly dead remotes, and an RAII guard around the relay task ensures the channel counter cannot leak even if the relay panics. Earlier versions reaped connections after `--timeout` whenever the pool's last-event timestamp was stale, which silently severed long-running tmux/SSH sessions exactly `--timeout` seconds after the channel opened
- Jump host connections are kept alive as long as any dependent via-jump connection is still active (has channels or recent activity), preventing premature reaping that would sever tunnelled sessions
- Optional `--max-lifetime` enforces an absolute cap on connection age
- Optional `--max-lifetime` enforces an absolute cap on connection age (overrides the active-channels rule)
### File integrity
@@ -271,7 +311,24 @@ 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`).
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
@@ -283,23 +340,27 @@ In daemon and serve modes, ssh-mux logs to **`ssh-mux.log`** in the SSH director
## Environment variables
- `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))
- `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
- **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`
This usually means two things:
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.
@@ -307,9 +368,9 @@ This usually means two things:
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 dirs `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`).
**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 Cursors remote target, and ensure your public key is in **ssh-mux-authorized-keys** in the same SSH directory.
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)

View File

@@ -153,10 +153,57 @@ pub enum Command {
listen_port: u16,
},
/// Install ssh-mux as a background service
/// Import an existing OpenSSH client config into routes.toml.
///
/// Copies the exe to ~/.ssh/ssh-mux.exe, creates a startup script
/// that runs `ssh-mux serve` at logon, and starts it immediately.
/// Reads `~/.ssh/config` (or `--from`), enumerates literal Host
/// aliases (wildcards and Match blocks are skipped), and resolves
/// each via `ssh -G <alias>` so that Match/Include/Host * inheritance
/// is applied by OpenSSH itself.
///
/// The source SSH config is **never modified**. By default the
/// generated TOML is printed to stdout (dry-run); pass `--write`
/// to save it to `~/.ssh/ssh-mux-routes.toml`.
ImportConfig {
/// Source SSH config file (default: ~/.ssh/config)
#[arg(long)]
from: Option<String>,
/// Output path for the generated routes.toml
/// (default: ~/.ssh/ssh-mux-routes.toml)
#[arg(long)]
out: Option<String>,
/// Actually write the output file (default is dry-run to stdout)
#[arg(long)]
write: bool,
/// Overwrite the output file if it already exists
#[arg(long)]
force: bool,
/// Path to the ssh binary used for resolution
/// (default: Win32-OpenSSH on Windows, /usr/bin/ssh elsewhere)
#[arg(long)]
ssh_bin: Option<String>,
},
/// One-shot bootstrap: import-config → setup-config → install service
///
/// Runs the full pipeline:
/// 1. If `~/.ssh/ssh-mux-routes.toml` does not exist, derives it
/// from `~/.ssh/config` via `ssh-mux import-config`. If the
/// file already exists, it is reused as-is (use
/// `ssh-mux import-config --write --force` to re-import).
/// 2. Runs `setup-config` to generate `ssh-mux-hosts.conf`,
/// prepend the `Include` line to `~/.ssh/config`, and
/// pre-register the host key.
/// 3. (Windows) copies the exe to `~/.ssh/ssh-mux.exe`, creates
/// a Startup-folder VBS that runs the daemon at logon, and
/// starts it immediately. (Other platforms) prints the
/// manual `ssh-mux daemon …` invocation.
///
/// The standalone `import-config` and `setup-config` commands
/// remain available for explicit re-runs.
Install {
/// Path to routes config file (default: ~/.ssh/ssh-mux-routes.toml)
#[arg(short, long)]

View File

@@ -150,7 +150,7 @@ fn validate_route_name(name: &str) -> Result<()> {
}
/// Validate all fields in the loaded config to prevent SSH config injection.
fn validate_config_values(config: &MuxConfig) -> Result<()> {
pub(crate) fn validate_config_values(config: &MuxConfig) -> Result<()> {
validate_config_field("jump.host", &config.jump.host)?;
validate_config_field("jump.user", &config.jump.user)?;
@@ -204,7 +204,7 @@ pub fn load_config(path: &Path) -> Result<MuxConfig> {
/// Write content to a file atomically: write to a temp file, then rename.
/// Rejects symlink targets and sets restrictive permissions.
fn atomic_write(target: &Path, content: &[u8]) -> Result<()> {
pub(crate) fn atomic_write(target: &Path, content: &[u8]) -> Result<()> {
use std::io::Write;
if target.exists() {
@@ -252,6 +252,102 @@ fn atomic_write(target: &Path, content: &[u8]) -> Result<()> {
Ok(())
}
/// Render the body of `ssh-mux-hosts.conf` for a given config.
///
/// Pure: takes no I/O, no global state. Tested in isolation. Each generated
/// `Host` block sets `ProxyJump none` and `ProxyCommand none` to suppress
/// option bleed-through from later-matching user blocks with the same alias.
fn render_hosts_conf(
config: &MuxConfig,
listen_port: u16,
ssh_dir: &Path,
strict_host_key: &str,
) -> String {
let mut out = String::new();
out.push_str("# Auto-generated by ssh-mux setup-config. Do not edit.\n\n");
let mut route_names: Vec<&String> = config.routes.keys().collect();
route_names.sort();
let known_hosts_abs = ssh_dir.join("ssh-mux-known-hosts");
for name in &route_names {
let route = &config.routes[*name];
out.push_str(&format!("Host {}\n", name));
out.push_str(" HostName 127.0.0.1\n");
out.push_str(&format!(" Port {}\n", listen_port));
out.push_str(&format!(" User {}\n", name));
out.push_str(" IdentitiesOnly yes\n");
out.push_str(&format!(" StrictHostKeyChecking {}\n", strict_host_key));
out.push_str(&format!(
" UserKnownHostsFile {}\n",
known_hosts_abs.display()
));
out.push_str(" HostKeyAlias ssh-mux-local\n");
// Suppress option bleed-through from later-matching user blocks with
// the same alias. OpenSSH applies first-match-wins per option, so any
// option ssh-mux does not set here would inherit from the user's
// existing Host block. ProxyJump/ProxyCommand bleed would silently
// break the localhost connection — explicitly disable both.
out.push_str(" ProxyJump none\n");
out.push_str(" ProxyCommand none\n");
// publickey + keyboard-interactive: ssh-mux forwards upstream OTP
// prompts to the connecting SSH client via the KI method, so the
// user types their OTP in the app that launched ssh (terminal /
// Cursor / VS Code) instead of a separate PowerShell window.
out.push_str(" PreferredAuthentications publickey,keyboard-interactive\n");
out.push_str(" PasswordAuthentication no\n");
out.push_str(" KbdInteractiveAuthentication yes\n");
out.push_str(" NumberOfPasswordPrompts 3\n");
out.push_str(&format!(
" # -> {}@{}:{}{}\n",
route.user,
route.host,
route.port,
if route.direct {
" (direct)"
} else {
" (via jump)"
},
));
out.push('\n');
}
out
}
/// Find Host aliases in an existing SSH config that overlap with the given
/// route names. Pure: no I/O. The user's content is never modified.
fn find_conflicting_aliases(content: &str, route_names: &[&str]) -> Vec<String> {
let route_set: std::collections::HashSet<&str> = route_names.iter().copied().collect();
let mut out: Vec<String> = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("Host ") {
for host_name in rest.split_whitespace() {
if route_set.contains(host_name) && !out.iter().any(|h| h == host_name) {
out.push(host_name.to_string());
}
}
}
}
out
}
/// Prepend an Include directive to existing SSH config content if it is not
/// already present. Pure and additive: no existing line is modified or removed.
/// Returns `None` if the include line is already present.
fn prepend_include_if_absent(content: &str, include_line: &str) -> Option<String> {
if content.lines().any(|line| line.trim() == include_line) {
return None;
}
let mut result = String::with_capacity(content.len() + include_line.len() + 2);
result.push_str(include_line);
result.push_str("\n\n");
result.push_str(content);
Some(result)
}
/// Generate `~/.ssh/ssh-mux-hosts.conf` from the loaded config and ensure
/// `~/.ssh/config` includes it.
///
@@ -288,43 +384,10 @@ pub fn setup_ssh_config(config: &MuxConfig, listen_port: u16) -> Result<()> {
"accept-new"
};
let mut out = String::new();
out.push_str("# Auto-generated by ssh-mux setup-config. Do not edit.\n\n");
let mut route_names: Vec<&String> = config.routes.keys().collect();
route_names.sort();
for name in &route_names {
let route = &config.routes[*name];
out.push_str(&format!("Host {}\n", name));
out.push_str(" HostName 127.0.0.1\n");
out.push_str(&format!(" Port {}\n", listen_port));
out.push_str(&format!(" User {}\n", name));
out.push_str(" IdentitiesOnly yes\n");
out.push_str(&format!(" StrictHostKeyChecking {}\n", strict_host_key));
let known_hosts_abs = ssh_dir.join("ssh-mux-known-hosts");
out.push_str(&format!(
" UserKnownHostsFile {}\n",
known_hosts_abs.display()
));
out.push_str(" HostKeyAlias ssh-mux-local\n");
out.push_str(" PreferredAuthentications publickey\n");
out.push_str(" PasswordAuthentication no\n");
out.push_str(" KbdInteractiveAuthentication no\n");
out.push_str(&format!(
" # -> {}@{}:{}{}\n",
route.user,
route.host,
route.port,
if route.direct {
" (direct)"
} else {
" (via jump)"
},
));
out.push('\n');
}
let out = render_hosts_conf(config, listen_port, &ssh_dir, strict_host_key);
atomic_write(&hosts_conf, out.as_bytes())?;
println!(
@@ -333,74 +396,48 @@ pub fn setup_ssh_config(config: &MuxConfig, listen_port: u16) -> Result<()> {
route_names.len()
);
// Ensure ~/.ssh/config includes the generated file, and comment out
// any existing Host blocks that conflict with our route names.
let include_line = "Include ssh-mux-hosts.conf".to_string();
// Ensure ~/.ssh/config has the Include line at the top so ssh-mux-hosts.conf
// entries are evaluated first (OpenSSH applies first-match-wins per option).
// Existing Host blocks are NOT modified — they remain in place and are
// simply shadowed for matching aliases. We detect overlapping aliases and
// print an informational warning, but never edit user content.
let include_line = "Include ssh-mux-hosts.conf";
if ssh_config.exists() {
let content = std::fs::read_to_string(&ssh_config)
.with_context(|| format!("cannot read {}", ssh_config.display()))?;
let route_set: std::collections::HashSet<&str> =
route_names.iter().map(|s| s.as_str()).collect();
let route_name_refs: Vec<&str> = route_names.iter().map(|s| s.as_str()).collect();
let conflicting_hosts = find_conflicting_aliases(&content, &route_name_refs);
let mut new_lines: Vec<String> = Vec::new();
let mut commenting_out = false;
let mut commented_hosts: Vec<String> = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("Host ") {
let host_name = rest.split_whitespace().next().unwrap_or("");
if route_set.contains(host_name) {
commenting_out = true;
commented_hosts.push(host_name.to_string());
new_lines.push(format!("# [ssh-mux] {}", line));
continue;
} else {
commenting_out = false;
}
} else if commenting_out {
if trimmed.is_empty()
|| trimmed.starts_with("Host ")
|| trimmed.starts_with("Match ")
{
commenting_out = false;
} else {
new_lines.push(format!("# [ssh-mux] {}", line));
continue;
}
if !conflicting_hosts.is_empty() {
println!(
"\nNote: existing Host blocks in {} share aliases with generated routes:",
ssh_config.display()
);
for h in &conflicting_hosts {
println!(" - {}", h);
}
new_lines.push(line.to_string());
}
for h in &commented_hosts {
println!(
"commented out existing Host {} in {}",
h,
ssh_config.display()
"These blocks are left untouched. ssh-mux-hosts.conf is included \
first so its values win for HostName/Port/User; ProxyJump and \
ProxyCommand are explicitly set to 'none' to block bleed-through. \
Other accumulating options (e.g. LocalForward) from your existing \
blocks may still apply — review them if behavior looks off."
);
}
let mut result = new_lines.join("\n");
if !content.ends_with('\n') {
// preserve original ending
} else if !result.ends_with('\n') {
result.push('\n');
match prepend_include_if_absent(&content, include_line) {
None => {
println!(
"{} already includes ssh-mux-hosts.conf",
ssh_config.display()
);
}
Some(result) => {
atomic_write(&ssh_config, result.as_bytes())?;
println!("added '{}' to {}", include_line, ssh_config.display());
}
}
if !result.contains(&include_line) {
result = format!("{}\n\n{}", include_line, result);
println!("added '{}' to {}", include_line, ssh_config.display());
} else {
println!(
"{} already includes ssh-mux-hosts.conf",
ssh_config.display()
);
}
atomic_write(&ssh_config, result.as_bytes())?;
} else {
atomic_write(&ssh_config, format!("{}\n", include_line).as_bytes())?;
println!("created {} with '{}'", ssh_config.display(), include_line);
@@ -428,6 +465,23 @@ pub fn setup_ssh_config(config: &MuxConfig, listen_port: u16) -> Result<()> {
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,
@@ -436,6 +490,47 @@ 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> {
@@ -656,6 +751,136 @@ user = "deploy\nProxyCommand evil"
assert!(validate_config_values(&config).is_err());
}
fn sample_config() -> MuxConfig {
let toml_str = r#"
[jump]
host = "bastion.example.com"
port = 22
user = "jumpuser"
[routes.webserver]
host = "10.0.0.10"
port = 22
user = "deploy"
[routes.external]
host = "203.0.113.50"
port = 22
user = "ops"
direct = true
"#;
toml::from_str(toml_str).unwrap()
}
#[test]
fn render_hosts_conf_includes_proxyjump_and_proxycommand_none() {
let cfg = sample_config();
let body = render_hosts_conf(&cfg, 2222, Path::new("/tmp/.ssh"), "yes");
// Each route block must explicitly null out these directives so that
// a later-matching user block cannot bleed its ProxyJump through.
let webserver_block: String = body
.lines()
.skip_while(|l| !l.starts_with("Host webserver"))
.take_while(|l| !l.is_empty())
.collect::<Vec<_>>()
.join("\n");
assert!(
webserver_block.contains("ProxyJump none"),
"missing ProxyJump none in:\n{}",
webserver_block
);
assert!(
webserver_block.contains("ProxyCommand none"),
"missing ProxyCommand none in:\n{}",
webserver_block
);
let external_block: String = body
.lines()
.skip_while(|l| !l.starts_with("Host external"))
.take_while(|l| !l.is_empty())
.collect::<Vec<_>>()
.join("\n");
assert!(external_block.contains("ProxyJump none"));
assert!(external_block.contains("ProxyCommand none"));
}
#[test]
fn render_hosts_conf_sorts_routes_alphabetically() {
let cfg = sample_config();
let body = render_hosts_conf(&cfg, 2222, Path::new("/tmp/.ssh"), "yes");
let external_pos = body.find("Host external").unwrap();
let webserver_pos = body.find("Host webserver").unwrap();
assert!(external_pos < webserver_pos);
}
#[test]
fn find_conflicting_aliases_detects_overlap_only_in_host_lines() {
let user_config = "
Host webserver
HostName 1.2.3.4
User old
Host other
HostName 5.6.7.8
# webserver appears in this comment but should not match
Host external db1
HostName 9.10.11.12
";
let routes = ["webserver", "external"];
let conflicts = find_conflicting_aliases(user_config, &routes);
assert_eq!(
conflicts,
vec!["webserver".to_string(), "external".to_string()]
);
}
#[test]
fn find_conflicting_aliases_dedupes() {
let user_config = "
Host webserver
HostName x
Host webserver
HostName y
";
let conflicts = find_conflicting_aliases(user_config, &["webserver"]);
assert_eq!(conflicts, vec!["webserver".to_string()]);
}
#[test]
fn prepend_include_only_adds_when_absent() {
let original = "Host server\n HostName 1.2.3.4\n";
let result = prepend_include_if_absent(original, "Include ssh-mux-hosts.conf").unwrap();
// Original content must be present verbatim (no edits).
assert!(result.contains(original));
// Include must come first.
assert!(result.starts_with("Include ssh-mux-hosts.conf"));
// No `# [ssh-mux]` markers — the user's blocks are untouched.
assert!(!result.contains("# [ssh-mux]"));
}
#[test]
fn prepend_include_idempotent_when_already_present() {
let original = "Include ssh-mux-hosts.conf\n\nHost server\n HostName 1.2.3.4\n";
assert!(prepend_include_if_absent(original, "Include ssh-mux-hosts.conf").is_none());
}
#[test]
fn prepend_include_does_not_mutate_existing_host_blocks() {
// Regression guard: setup-config used to comment out conflicting Host
// blocks with `# [ssh-mux] ` prefixes. That behavior was removed —
// the user's existing definitions must remain untouched, even when
// they share an alias with a generated route.
let original = "Host webserver\n HostName user-original\n User olduser\n";
let result = prepend_include_if_absent(original, "Include ssh-mux-hosts.conf").unwrap();
assert!(result.contains("Host webserver"));
assert!(result.contains("HostName user-original"));
assert!(result.contains("User olduser"));
assert!(!result.contains("# [ssh-mux]"));
}
#[test]
fn test_validate_config_rejects_injection_in_jump_user() {
let toml_str = "

View File

@@ -645,11 +645,9 @@ async fn relay_session(
stream_write.write_all(&data).await?;
stream_write.flush().await?;
}
Some(russh::ChannelMsg::ExtendedData { data, ext }) => {
if ext == 1 {
stream_write.write_all(&data).await?;
stream_write.flush().await?;
}
Some(russh::ChannelMsg::ExtendedData { data, ext: 1 }) => {
stream_write.write_all(&data).await?;
stream_write.flush().await?;
}
Some(russh::ChannelMsg::ExitStatus { exit_status }) => {
tracing::debug!("exit status {} for session {}:{}", exit_status, host, port);

1014
src/import.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -501,6 +501,7 @@ mod platform {
#[cfg(not(windows))]
mod platform {
use super::*;
use std::path::{Path, PathBuf};
use tokio::net::{UnixListener, UnixStream};
/// Get the socket path for the current user.
@@ -509,13 +510,14 @@ mod platform {
/// 1. `$XDG_RUNTIME_DIR/ssh-mux.sock` (typically `/run/user/{uid}`, already 0700)
/// 2. `~/.ssh/ssh-mux.sock` (home directory, user-owned)
/// 3. `/tmp/ssh-mux-{uid}/ssh-mux.sock` (last resort, in a 0700 subdirectory)
fn socket_path() -> Result<std::path::PathBuf> {
fn socket_path() -> Result<PathBuf> {
let uid = unsafe { libc::getuid() };
// 1. Try XDG_RUNTIME_DIR (most secure, already 0700)
// 1. Try XDG_RUNTIME_DIR, but only if it is actually private to this user.
if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
let dir = std::path::PathBuf::from(runtime_dir);
let dir = PathBuf::from(runtime_dir);
if dir.is_dir() {
validate_runtime_dir(&dir, uid)?;
return Ok(dir.join("ssh-mux.sock"));
}
}
@@ -528,7 +530,7 @@ mod platform {
}
// 3. Fallback: /tmp/ssh-mux-{uid}/ with 0700 subdirectory (fail-closed)
let fallback_dir = std::path::PathBuf::from(format!("/tmp/ssh-mux-{}", uid));
let fallback_dir = PathBuf::from(format!("/tmp/ssh-mux-{}", uid));
if fallback_dir.exists() {
validate_fallback_dir(&fallback_dir, uid)?;
} else {
@@ -550,7 +552,7 @@ mod platform {
/// Validate that an existing fallback directory is safe to use (fail-closed).
/// Returns Err if the directory is a symlink, owned by another user, or
/// has permissions other than 0700 that cannot be fixed.
fn validate_fallback_dir(dir: &std::path::Path, expected_uid: u32) -> Result<()> {
fn validate_fallback_dir(dir: &Path, expected_uid: u32) -> Result<()> {
use std::os::unix::fs::MetadataExt;
let meta = std::fs::symlink_metadata(dir)
@@ -589,6 +591,52 @@ mod platform {
Ok(())
}
/// Validate `XDG_RUNTIME_DIR` before trusting it for the daemon socket.
///
/// The XDG Base Directory spec requires a user-owned 0700 directory.
/// Reject looser permissions or symlinked paths to avoid daemon impersonation
/// and client secret capture via a hostile runtime directory.
fn validate_runtime_dir(dir: &Path, expected_uid: u32) -> Result<()> {
use std::os::unix::fs::MetadataExt;
let meta = std::fs::symlink_metadata(dir)
.with_context(|| format!("SECURITY: cannot stat {}", dir.display()))?;
if meta.file_type().is_symlink() {
anyhow::bail!(
"SECURITY: XDG_RUNTIME_DIR {} is a symlink. Refusing to trust redirected IPC paths.",
dir.display()
);
}
if !meta.is_dir() {
anyhow::bail!(
"SECURITY: XDG_RUNTIME_DIR {} is not a directory.",
dir.display()
);
}
if meta.uid() != expected_uid {
anyhow::bail!(
"SECURITY: XDG_RUNTIME_DIR {} is owned by uid {} (expected {}).",
dir.display(),
meta.uid(),
expected_uid
);
}
let mode = meta.mode() & 0o7777;
if mode & 0o077 != 0 {
anyhow::bail!(
"SECURITY: XDG_RUNTIME_DIR {} has unsafe permissions {:04o} (expected user-only access).",
dir.display(),
mode
);
}
Ok(())
}
/// Listener that accepts connections on a Unix domain socket.
pub struct IpcListener {
inner: UnixListener,
@@ -651,6 +699,74 @@ mod platform {
pub async fn is_daemon_running() -> bool {
connect().await.is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::{PermissionsExt, symlink};
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_path(label: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!(
"ssh-mux-test-{}-{}-{}",
label,
std::process::id(),
nanos
))
}
#[test]
fn runtime_dir_accepts_private_user_directory() {
let dir = temp_path("runtime-ok");
std::fs::create_dir(&dir).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
let uid = unsafe { libc::getuid() };
let result = validate_runtime_dir(&dir, uid);
let _ = std::fs::remove_dir(&dir);
assert!(
result.is_ok(),
"expected private runtime dir to be accepted"
);
}
#[test]
fn runtime_dir_rejects_group_writable_directory() {
let dir = temp_path("runtime-bad-mode");
std::fs::create_dir(&dir).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
let uid = unsafe { libc::getuid() };
let result = validate_runtime_dir(&dir, uid);
let _ = std::fs::remove_dir(&dir);
assert!(
result.is_err(),
"group/world-accessible runtime dir must be rejected"
);
}
#[test]
fn runtime_dir_rejects_symlink() {
let target = temp_path("runtime-target");
let link = temp_path("runtime-link");
std::fs::create_dir(&target).unwrap();
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o700)).unwrap();
symlink(&target, &link).unwrap();
let uid = unsafe { libc::getuid() };
let result = validate_runtime_dir(&link, uid);
let _ = std::fs::remove_file(&link);
let _ = std::fs::remove_dir(&target);
assert!(result.is_err(), "symlinked runtime dir must be rejected");
}
}
}
// Re-export platform-specific items

View File

@@ -453,7 +453,7 @@ mod tests {
let mut mac = Hmac::<Sha1>::new_from_slice(salt).unwrap();
mac.update(b"example.com");
let hash = mac.finalize().into_bytes();
let hash_b64 = base64::engine::general_purpose::STANDARD.encode(&hash);
let hash_b64 = base64::engine::general_purpose::STANDARD.encode(hash);
let entry = format!("|1|{}|{}", salt_b64, hash_b64);
assert!(hostname_matches(&entry, "example.com", 22));
@@ -716,7 +716,7 @@ mod tests {
use sha2::Digest;
let mut pos = 0;
while pos < dest.len() {
let hash = sha2::Sha256::digest(&self.0);
let hash = sha2::Sha256::digest(self.0);
let copy_len = std::cmp::min(dest.len() - pos, 32);
dest[pos..pos + copy_len].copy_from_slice(&hash[..copy_len]);
self.0 = hash.into();

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ mod cli;
mod config;
mod daemon;
mod host_key;
mod import;
mod ipc;
mod known_hosts;
mod local_server;
@@ -10,7 +11,7 @@ mod protocol;
mod proxy;
mod security;
use anyhow::Result;
use anyhow::{Context, Result};
use clap::Parser;
use cli::{Cli, Command};
@@ -133,19 +134,28 @@ async fn main() -> Result<()> {
let mux_config = config::load_config(&cfg_path)?;
config::setup_ssh_config(&mux_config, listen_port)?;
}
Command::ImportConfig {
from,
out,
write,
force,
ssh_bin,
} => {
import::run(import::ImportArgs {
from: from.map(std::path::PathBuf::from),
out: out.map(std::path::PathBuf::from),
write,
force,
ssh_bin: ssh_bin.map(std::path::PathBuf::from),
})?;
}
Command::Install {
config: config_path,
listen_port,
timeout,
max_lifetime,
} => {
#[cfg(windows)]
install_service(config_path, listen_port, timeout, max_lifetime)?;
#[cfg(not(windows))]
{
let _ = (config_path, listen_port, timeout, max_lifetime);
anyhow::bail!("install is only supported on Windows");
}
run_install(config_path, listen_port, timeout, max_lifetime)?;
}
Command::Uninstall => {
#[cfg(windows)]
@@ -170,6 +180,80 @@ async fn main() -> Result<()> {
Ok(())
}
/// One-shot bootstrap pipeline behind `ssh-mux install`.
///
/// 1. If `routes.toml` does not exist at the resolved path, runs
/// `import-config` to derive it from `~/.ssh/config`.
/// 2. Runs `setup-config` (generates `ssh-mux-hosts.conf`, prepends the
/// `Include` line to `~/.ssh/config`, pre-registers the host key).
/// 3. On Windows, installs the background service and starts the daemon.
/// On other platforms, prints the manual `ssh-mux daemon …` command.
///
/// The standalone `import-config` and `setup-config` subcommands remain
/// available for explicit re-runs and advanced use.
fn run_install(
config_path: Option<String>,
listen_port: u16,
timeout: u64,
max_lifetime: u64,
) -> Result<()> {
let cfg_path = match config_path.as_deref() {
Some(p) => std::path::PathBuf::from(p),
None => config::default_config_path()?,
};
println!("==> resolving routes.toml: {}", cfg_path.display());
if cfg_path.exists() {
println!(
" (using existing file — skip import; run `ssh-mux import-config --write --force` to re-import)"
);
} else {
println!(" not found — running import-config from ~/.ssh/config");
import::run(import::ImportArgs {
from: None,
out: Some(cfg_path.clone()),
write: true,
force: false,
ssh_bin: None,
})
.with_context(|| {
format!(
"import failed; create {} manually or run `ssh-mux import-config` to debug",
cfg_path.display()
)
})?;
}
println!("\n==> setup-config");
let mux_config = config::load_config(&cfg_path)?;
config::setup_ssh_config(&mux_config, listen_port)?;
println!("\n==> service install");
#[cfg(windows)]
{
install_service(
Some(cfg_path.to_string_lossy().into_owned()),
listen_port,
timeout,
max_lifetime,
)?;
}
#[cfg(not(windows))]
{
let _ = (timeout, max_lifetime);
println!(
"auto-start service install is Windows-only.\n\
To run the daemon now:\n\
\n\
\tssh-mux daemon -p {} --config {}\n",
listen_port,
cfg_path.display(),
);
}
Ok(())
}
#[cfg(windows)]
const STARTUP_VBS: &str = "ssh-mux.vbs";

View File

@@ -24,12 +24,21 @@ use russh::client;
use russh::keys::PublicKey;
use russh::keys::key::PrivateKeyWithHashAlg;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::{Arc, OnceLock};
use std::time::Instant;
use tokio::sync::Mutex;
use crate::protocol::{AuthInfoRequest, AuthInfoResponse, AuthPrompt};
/// Outcome of a single-entry reap decision (used by `cleanup_idle`).
#[derive(Debug, PartialEq, Eq)]
enum ReapDecision {
Keep,
HandleClosed,
LifetimeExceeded,
Idle,
}
/// A single pooled SSH connection.
struct PoolEntry {
handle: client::Handle<SshHandler>,
@@ -47,6 +56,10 @@ struct PoolEntry {
/// The SSH connection pool.
pub struct Pool {
connections: Arc<Mutex<HashMap<String, PoolEntry>>>,
/// Serialize jump-host (bastion) setup per pool key so concurrent sessions
/// to different internal hosts do not each start a separate bastion login
/// (duplicate OTP / keyboard-interactive prompts).
jump_connect_serializers: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>,
/// Idle timeout in seconds.
timeout_secs: u64,
/// Absolute maximum lifetime per connection in seconds (0 = unlimited).
@@ -285,6 +298,117 @@ fn pool_key_jump(internal_user: &str, internal_host: &str, internal_port: u16) -
)
}
/// Runs `f` while holding an async mutex keyed by `jump_key`.
///
/// Concurrent callers with the same bastion pool key are serialized so only one
/// performs "connect + insert" at a time; different keys run in parallel.
async fn serialize_jump_host_setup<F, Fut, T>(
serializers: &Mutex<HashMap<String, Arc<Mutex<()>>>>,
jump_key: &str,
f: F,
) -> T
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = T>,
{
let gate = {
let mut gates = serializers.lock().await;
gates
.entry(jump_key.to_string())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
};
let _guard = gate.lock().await;
f().await
}
/// SSH client identification string sent during protocol banner exchange.
///
/// Russh's default `SSH-2.0-russh_0.57` is technically valid but distinctive,
/// which is awkward when the rest of the user's tooling identifies as
/// OpenSSH. We mirror whatever the local `ssh` binary would send so that
/// ssh-mux is indistinguishable from native `ssh` at the protocol banner
/// level — useful for servers/middleboxes that log or pattern-match on the
/// client banner.
///
/// Note: this does *not* affect OS detection on systems that use TCP/IP
/// fingerprinting (e.g. Okta device posture). Those signals come from the
/// kernel's TCP stack, not the SSH banner, and aren't something a userspace
/// SSH client can reshape.
///
/// We detect the local OpenSSH version by invoking `ssh -V` and reusing the
/// exact version string. If `ssh` is not on PATH (rare on Windows 11 —
/// ships by default), we fall back to a platform-appropriate default
/// rather than leaking russh.
///
/// The result is cached for the process lifetime: the local `ssh` install
/// doesn't change between calls.
fn detect_client_id() -> &'static str {
static CACHED: OnceLock<String> = OnceLock::new();
CACHED
.get_or_init(|| {
let id = detect_local_openssh_banner().unwrap_or_else(|| {
let fallback = platform_default_client_id().to_string();
tracing::info!(
"could not detect local OpenSSH version; falling back to {}",
fallback
);
fallback
});
tracing::info!("ssh client banner: {}", id);
id
})
.as_str()
}
/// Run `ssh -V` and convert its output (e.g. `OpenSSH_for_Windows_8.1p1, ...`)
/// into a banner string (`SSH-2.0-OpenSSH_for_Windows_8.1p1`).
///
/// OpenSSH writes the version line to stderr; some distributions/wrappers may
/// write it to stdout instead, so we check both. Returns `None` if the binary
/// is missing or the output doesn't look like an OpenSSH version line.
fn detect_local_openssh_banner() -> Option<String> {
let output = std::process::Command::new("ssh").arg("-V").output().ok()?;
let raw = if !output.stderr.is_empty() {
String::from_utf8_lossy(&output.stderr).into_owned()
} else {
String::from_utf8_lossy(&output.stdout).into_owned()
};
parse_ssh_v_output(&raw)
}
/// Parse the first line of `ssh -V` output into an SSH banner string.
///
/// Extracted as a pure function so the parsing rules are testable without
/// shelling out. The SSH protocol forbids spaces and '-' in the software
/// version field of the banner (RFC 4253 §4.2), so a token failing those
/// rules indicates a wrapper or non-OpenSSH binary and we reject it.
fn parse_ssh_v_output(raw: &str) -> Option<String> {
let version = raw.lines().next()?.split(',').next()?.trim();
if !version.starts_with("OpenSSH_") || version.len() > 200 {
return None;
}
if !version
.bytes()
.all(|b| b.is_ascii_graphic() && b != b' ' && b != b'-')
{
return None;
}
Some(format!("SSH-2.0-{}", version))
}
/// Last-resort banner when `ssh -V` is unavailable. Picked by build target so
/// the banner at least matches the OS the user is on.
fn platform_default_client_id() -> &'static str {
if cfg!(target_os = "windows") {
"SSH-2.0-OpenSSH_for_Windows_8.1"
} else if cfg!(target_os = "macos") {
"SSH-2.0-OpenSSH_9.0"
} else {
"SSH-2.0-OpenSSH_9.6"
}
}
impl Pool {
/// Create a new connection pool.
///
@@ -292,6 +416,7 @@ impl Pool {
pub fn new(timeout_secs: u64, max_lifetime_secs: u64) -> Self {
Self {
connections: Arc::new(Mutex::new(HashMap::new())),
jump_connect_serializers: Arc::new(Mutex::new(HashMap::new())),
timeout_secs,
max_lifetime_secs,
locked: Arc::new(std::sync::atomic::AtomicBool::new(false)),
@@ -321,6 +446,7 @@ impl Pool {
/// Build a `client::Config` with keepalive enabled.
fn ssh_client_config(&self) -> Arc<client::Config> {
Arc::new(client::Config {
client_id: russh::SshId::Standard(detect_client_id().to_string()),
keepalive_interval: Some(std::time::Duration::from_secs(15)),
keepalive_max: 3,
inactivity_timeout: Some(std::time::Duration::from_secs(self.timeout_secs.max(60))),
@@ -904,9 +1030,83 @@ impl Pool {
Err(last_err.unwrap())
}
/// Warm up a direct (non-jump) upstream connection so that subsequent
/// `open_session` / `open_direct_tcpip` calls hit the reuse branch.
///
/// Used by the local SSH server to drive upstream authentication during
/// the *client's* auth phase: the resulting pool entry has
/// `active_channels = 0` and is available for later channel opens
/// without re-auth.
pub async fn ensure_direct_connected(
&self,
host: &str,
port: u16,
user: Option<&str>,
display_name: &str,
auth_tx: tokio::sync::mpsc::Sender<AuthInfoRequest>,
auth_rx: Arc<Mutex<tokio::sync::mpsc::Receiver<AuthInfoResponse>>>,
) -> Result<()> {
self.check_locked()?;
let key = pool_key(user, host, port);
// Reuse if live
{
let mut conns = self.connections.lock().await;
if let Some(entry) = conns.get(&key) {
if !entry.handle.is_closed() {
tracing::debug!("upstream {} already connected, skipping warm-up", key);
return Ok(());
}
conns.remove(&key);
}
}
tracing::info!("warming up upstream connection to {}", key);
let handle = self
.connect_ssh(host, port, user, display_name, auth_tx, auth_rx)
.await
.with_context(|| format!("failed to establish SSH connection to {}", key))?;
let mut conns = self.connections.lock().await;
conns.insert(
key,
PoolEntry {
handle,
last_used: Instant::now(),
created_at: Instant::now(),
active_channels: 0,
parent_jump_key: None,
},
);
Ok(())
}
/// Public wrapper for `ensure_jump_connected` — used by the local SSH
/// server to warm up the bastion during the client's auth phase.
pub async fn ensure_jump_connected_pub(
&self,
jump_host: &str,
jump_port: u16,
jump_user: &str,
display_name: &str,
auth_tx: tokio::sync::mpsc::Sender<AuthInfoRequest>,
auth_rx: Arc<Mutex<tokio::sync::mpsc::Receiver<AuthInfoResponse>>>,
) -> Result<()> {
self.ensure_jump_connected(
jump_host,
jump_port,
jump_user,
display_name,
auth_tx,
auth_rx,
)
.await
.map(|_| ())
}
/// Ensure the jump host (bastion) connection exists in the pool.
///
/// The bastion connection is pooled under key `"jump:<host>:<port>"`.
/// The bastion connection is pooled under key `"jump:<user>@<host>:<port>"`.
/// OTP is only prompted on the first connection.
/// Returns the pool key for the jump host.
async fn ensure_jump_connected(
@@ -919,60 +1119,69 @@ impl Pool {
auth_rx: Arc<Mutex<tokio::sync::mpsc::Receiver<AuthInfoResponse>>>,
) -> Result<String> {
let jump_key = format!("jump:{}@{}:{}", jump_user, jump_host, jump_port);
// Separate clone for the serializer map key so the async body can move `jump_key`.
let jump_key_for_gate = jump_key.clone();
// Check if already connected and still alive
{
let mut conns = self.connections.lock().await;
if let Some(entry) = conns.get(&jump_key) {
if !entry.handle.is_closed() {
tracing::debug!("reusing existing jump host connection to {}", jump_key);
return Ok(jump_key);
serialize_jump_host_setup(
&self.jump_connect_serializers,
&jump_key_for_gate,
|| async move {
// Check if already connected and still alive (under per-jump serialization).
{
let mut conns = self.connections.lock().await;
if let Some(entry) = conns.get(&jump_key) {
if !entry.handle.is_closed() {
tracing::debug!("reusing existing jump host connection to {}", jump_key);
return Ok(jump_key.clone());
}
tracing::warn!(
"jump host connection to {} is dead (closed), removing and reconnecting",
jump_key
);
conns.remove(&jump_key);
}
}
tracing::warn!(
"jump host connection to {} is dead (closed), removing and reconnecting",
jump_key
// Create new bastion connection (may require OTP)
tracing::info!(
"connecting to jump host {}@{}:{}",
jump_user,
jump_host,
jump_port
);
conns.remove(&jump_key);
}
}
let handle = self
.connect_ssh(
jump_host,
jump_port,
Some(jump_user),
display_name,
auth_tx,
auth_rx,
)
.await
.with_context(|| {
format!("failed to connect to jump host {}:{}", jump_host, jump_port)
})?;
// Create new bastion connection (may require OTP)
tracing::info!(
"connecting to jump host {}@{}:{}",
jump_user,
jump_host,
jump_port
);
let handle = self
.connect_ssh(
jump_host,
jump_port,
Some(jump_user),
display_name,
auth_tx,
auth_rx,
)
.await
.with_context(|| {
format!("failed to connect to jump host {}:{}", jump_host, jump_port)
})?;
// Store in pool
{
let mut conns = self.connections.lock().await;
conns.insert(
jump_key.clone(),
PoolEntry {
handle,
last_used: Instant::now(),
created_at: Instant::now(),
active_channels: 0,
parent_jump_key: None,
},
);
}
// Store in pool
{
let mut conns = self.connections.lock().await;
conns.insert(
jump_key.clone(),
PoolEntry {
handle,
last_used: Instant::now(),
created_at: Instant::now(),
active_channels: 0,
parent_jump_key: None,
},
);
}
Ok(jump_key)
Ok(jump_key)
},
)
.await
}
/// Open a direct-tcpip channel through the jump host to an internal server.
@@ -1113,14 +1322,54 @@ impl Pool {
.collect()
}
/// Decide what to do with a single pool entry during cleanup. Pure: no
/// I/O, no global state. The jump-host dependent override is applied at
/// the call site (it requires the full pool state).
#[allow(clippy::too_many_arguments)]
fn reap_decision(
handle_closed: bool,
active_channels: usize,
created_at_elapsed: std::time::Duration,
last_used_elapsed: std::time::Duration,
timeout: std::time::Duration,
max_lifetime: Option<std::time::Duration>,
) -> ReapDecision {
if handle_closed {
return ReapDecision::HandleClosed;
}
if let Some(max_lt) = max_lifetime
&& created_at_elapsed > max_lt
{
return ReapDecision::LifetimeExceeded;
}
// Active channels keep the connection alive regardless of last_used.
// Byte traffic on an open channel never refreshes last_used, so
// gating reap on "last_used + active_channels" would (and did) reap
// live tmux sessions exactly `timeout` seconds after channel open.
if active_channels == 0 && last_used_elapsed > timeout {
return ReapDecision::Idle;
}
ReapDecision::Keep
}
/// Remove connections that are dead, idle, or past their lifetime.
///
/// A connection is removed when any of these is true:
/// - Its SSH handle is closed (remote disconnected / keepalive timeout)
/// - It has no active channels and has been idle longer than `timeout_secs`
/// - It has been idle longer than `timeout_secs` even with active channels
/// (zombie channels whose relay tasks failed to clean up)
/// - It exceeded `max_lifetime_secs` (if configured)
/// - It has **no** active channels and has been idle longer than `timeout_secs`
///
/// While `active_channels > 0` and the SSH handle is alive, the
/// connection is preserved regardless of `last_used`. The previous
/// implementation used `last_used` as the staleness signal, but that
/// timestamp is only refreshed on pool-level events (channel open/close)
/// — byte traffic on an open channel never bumped it. As a result, a
/// long-running tmux session over a single pooled channel was reaped
/// exactly `timeout_secs` after the channel opened, even while the user
/// was actively typing. Active channels imply real usage; dead remotes
/// are caught by russh keepalive (which closes the handle and trips
/// `is_closed()` within ~45s). Leaks of `active_channels` are prevented
/// at the relay site by an RAII guard.
///
/// Jump host connections are kept alive as long as any via-jump dependent
/// connection is still active (has channels or recent activity).
@@ -1148,45 +1397,46 @@ impl Pool {
let mut to_remove: Vec<String> = Vec::new();
for (key, entry) in conns.iter() {
if entry.handle.is_closed() {
tracing::info!(
"removing dead connection to {} (channels: {})",
key,
entry.active_channels
);
to_remove.push(key.clone());
} else if let Some(max_lt) = max_lifetime
&& entry.created_at.elapsed() > max_lt
{
tracing::warn!(
"closing connection to {} — absolute lifetime ({:.0}s) exceeded \
(channels: {})",
key,
max_lt.as_secs_f64(),
entry.active_channels,
);
to_remove.push(key.clone());
} else if entry.last_used.elapsed() > timeout {
// Don't reap jump hosts that still have active dependents
if active_jump_keys.contains(key) {
tracing::debug!(
"keeping jump connection {} alive — active dependent connections exist",
let decision = Self::reap_decision(
entry.handle.is_closed(),
entry.active_channels,
entry.created_at.elapsed(),
entry.last_used.elapsed(),
timeout,
max_lifetime,
);
match decision {
ReapDecision::Keep => continue,
ReapDecision::HandleClosed => {
tracing::info!(
"removing dead connection to {} (channels: {})",
key,
entry.active_channels
);
continue;
to_remove.push(key.clone());
}
if entry.active_channels > 0 {
ReapDecision::LifetimeExceeded => {
let max_lt = max_lifetime.unwrap_or_default();
tracing::warn!(
"closing stale connection to {} — idle {}s with {} zombie channel(s)",
"closing connection to {} — absolute lifetime ({:.0}s) exceeded \
(channels: {})",
key,
entry.last_used.elapsed().as_secs(),
max_lt.as_secs_f64(),
entry.active_channels,
);
} else {
tracing::info!("closing idle connection to {}", key);
to_remove.push(key.clone());
}
ReapDecision::Idle => {
if active_jump_keys.contains(key) {
tracing::debug!(
"keeping jump connection {} alive — active dependent connections exist",
key,
);
continue;
}
tracing::info!("closing idle connection to {}", key);
to_remove.push(key.clone());
}
to_remove.push(key.clone());
}
}
@@ -1775,3 +2025,253 @@ fn known_folder_profile() -> Option<std::path::PathBuf> {
Some(result)
}
#[cfg(test)]
mod reap_decision_tests {
use super::{Pool, ReapDecision};
use std::time::Duration;
fn timeout() -> Duration {
Duration::from_secs(600)
}
#[test]
fn dead_handle_is_reaped_immediately() {
let d = Pool::reap_decision(
true,
5,
Duration::from_secs(1),
Duration::from_secs(1),
timeout(),
None,
);
assert_eq!(d, ReapDecision::HandleClosed);
}
#[test]
fn max_lifetime_reaps_even_with_active_channels() {
let d = Pool::reap_decision(
false,
3,
Duration::from_secs(13_000),
Duration::from_secs(10),
timeout(),
Some(Duration::from_secs(12_000)),
);
assert_eq!(d, ReapDecision::LifetimeExceeded);
}
/// Regression: a connection with active channels and a stale `last_used`
/// must NOT be reaped. The previous policy reaped it as a "zombie",
/// killing live tmux sessions exactly `timeout` seconds after the channel
/// opened (last_used was only refreshed on pool-level events, never by
/// byte traffic on the relay).
#[test]
fn active_channels_freeze_idle_timer() {
let d = Pool::reap_decision(
false,
1,
Duration::from_secs(10),
Duration::from_secs(607), // > 600s timeout — would have been reaped
timeout(),
None,
);
assert_eq!(d, ReapDecision::Keep);
}
#[test]
fn idle_with_no_channels_reaps_after_timeout() {
let d = Pool::reap_decision(
false,
0,
Duration::from_secs(10),
Duration::from_secs(601),
timeout(),
None,
);
assert_eq!(d, ReapDecision::Idle);
}
#[test]
fn idle_with_no_channels_kept_within_timeout() {
let d = Pool::reap_decision(
false,
0,
Duration::from_secs(10),
Duration::from_secs(599),
timeout(),
None,
);
assert_eq!(d, ReapDecision::Keep);
}
#[test]
fn handle_closed_dominates_max_lifetime() {
let d = Pool::reap_decision(
true,
0,
Duration::from_secs(100_000),
Duration::from_secs(0),
timeout(),
Some(Duration::from_secs(50_000)),
);
assert_eq!(d, ReapDecision::HandleClosed);
}
}
#[cfg(test)]
mod jump_host_setup_serialize_tests {
use super::serialize_jump_host_setup;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use tokio::sync::{Barrier, Mutex};
/// Regression: two concurrent first connections to different internal hosts via the same
/// bastion must not both pass an empty-pool check before either inserts — that caused
/// duplicate jump-host OTP. This models that race: without serialization, `connects` can
/// exceed 1; with `serialize_jump_host_setup` it stays 1.
#[tokio::test]
async fn concurrent_same_jump_key_only_one_logical_connect() {
let serializers = Arc::new(Mutex::new(HashMap::new()));
let pool_ready = Arc::new(Mutex::new(false));
let connects = Arc::new(AtomicUsize::new(0));
let n = 24usize;
let barrier = Arc::new(Barrier::new(n));
let jump_key = "jump:user@bastion.example:22";
let mut handles = Vec::new();
for _ in 0..n {
let serializers = serializers.clone();
let pool_ready = pool_ready.clone();
let connects = connects.clone();
let barrier = barrier.clone();
handles.push(tokio::spawn(async move {
barrier.wait().await;
serialize_jump_host_setup(&serializers, jump_key, || {
let pool_ready = pool_ready.clone();
let connects = connects.clone();
async move {
let mut ready = pool_ready.lock().await;
if *ready {
return;
}
connects.fetch_add(1, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(50)).await;
*ready = true;
}
})
.await;
}));
}
for h in handles {
h.await.unwrap();
}
assert_eq!(
connects.load(Ordering::SeqCst),
1,
"expected a single serialized first-connect; duplicate OTP regression would increment this"
);
}
#[tokio::test]
async fn different_jump_keys_run_in_parallel() {
let serializers = Arc::new(Mutex::new(HashMap::new()));
let barrier = Arc::new(Barrier::new(2));
let entered = Arc::new(AtomicUsize::new(0));
let ser_a = serializers.clone();
let b_a = barrier.clone();
let e_a = entered.clone();
let t_a = tokio::spawn(async move {
b_a.wait().await;
serialize_jump_host_setup(&ser_a, "jump:u@bastion-a:22", || {
let e = e_a.clone();
async move {
e.fetch_add(1, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(60)).await;
}
})
.await;
});
let ser_b = serializers.clone();
let b_b = barrier.clone();
let e_b = entered.clone();
let t_b = tokio::spawn(async move {
b_b.wait().await;
serialize_jump_host_setup(&ser_b, "jump:u@bastion-b:22", || {
let e = e_b.clone();
async move {
e.fetch_add(1, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(60)).await;
}
})
.await;
});
let (ra, rb) = tokio::join!(t_a, t_b);
ra.unwrap();
rb.unwrap();
assert_eq!(entered.load(Ordering::SeqCst), 2);
}
}
#[cfg(test)]
mod ssh_v_parse_tests {
use super::parse_ssh_v_output;
#[test]
fn windows_openssh_banner_round_trips() {
let raw = "OpenSSH_for_Windows_8.1p1, LibreSSL 3.0.2\n";
assert_eq!(
parse_ssh_v_output(raw).as_deref(),
Some("SSH-2.0-OpenSSH_for_Windows_8.1p1"),
);
}
#[test]
fn linux_openssh_banner_round_trips() {
let raw = "OpenSSH_9.6p1, OpenSSL 3.0.13 30 Jan 2024\n";
assert_eq!(
parse_ssh_v_output(raw).as_deref(),
Some("SSH-2.0-OpenSSH_9.6p1"),
);
}
#[test]
fn macos_openssh_banner_round_trips() {
let raw = "OpenSSH_9.0p1, LibreSSL 3.3.6\n";
assert_eq!(
parse_ssh_v_output(raw).as_deref(),
Some("SSH-2.0-OpenSSH_9.0p1"),
);
}
#[test]
fn rejects_non_openssh_binaries() {
// PuTTY's plink, Tectia, etc. — banner mimicry is wrong for these.
assert_eq!(parse_ssh_v_output("plink: Release 0.79\n"), None);
assert_eq!(parse_ssh_v_output("ssh: invalid option -- 'V'\n"), None);
assert_eq!(parse_ssh_v_output(""), None);
}
#[test]
fn rejects_versions_with_spaces_or_dashes() {
// RFC 4253 §4.2 forbids ' ' and '-' in the softwareversion field.
// A wrapper that injects either would produce a non-conforming banner.
assert_eq!(parse_ssh_v_output("OpenSSH_9.0 with patches, ...\n"), None);
assert_eq!(parse_ssh_v_output("OpenSSH-portable_9.0p1, ...\n"), None);
}
#[test]
fn ignores_trailing_lines() {
// Some wrappers print extra diagnostic lines; we only consume the first.
let raw = "OpenSSH_for_Windows_9.5p1, LibreSSL 3.7.0\nWARNING: experimental build\n";
assert_eq!(
parse_ssh_v_output(raw).as_deref(),
Some("SSH-2.0-OpenSSH_for_Windows_9.5p1"),
);
}
}

View File

@@ -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());