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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
`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>
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>
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.
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.
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>
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>
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>
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
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
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
Documents new security hardenings: token file ACL at creation (fail-closed), IPC bounded_read_line (8 KiB limit), Windows home via Known Folder API, centralized DACL checks, and authorized_keys fallback downgrade warning.
Made-with: Cursor
Remove ~530 lines of duplicate Win32 ACL enumeration from local_server.rs. check_windows_dacl and check_host_key_dacl now delegate to security::check_dacl_permissions with appropriate mask constants (FILE_DANGEROUS_MASK, HOST_KEY_DANGEROUS_MASK, DIR_DANGEROUS_MASK). Ensures consistent ACE handling including Object ACE fail-closed. Also upgrades authorized_keys fallback warning to note security downgrade risk.
Made-with: Cursor
Use SHGetKnownFolderPath(FOLDERID_Profile) to determine the user
profile directory instead of relying solely on the USERPROFILE
environment variable, which can be poisoned by a local attacker.
Falls back to USERPROFILE if the API call fails.
Made-with: Cursor
Replace unbounded BufReader::read_line with bounded_read_line that
reads via fill_buf and enforces MAX_IPC_LINE_LEN (8192 bytes).
Prevents memory exhaustion DoS from a malicious local client
sending megabytes of data without a newline terminator.
Made-with: Cursor
Previously only the existing token file was DACL-checked. Now the
token directory gets DIR_DANGEROUS_MASK validation after create_dir_all
and the token file gets TOKEN_DANGEROUS_MASK validation immediately
after writing. If the newly created file inherits unsafe permissions
from its parent, it is deleted and the operation fails closed.
Made-with: Cursor
The hostname was not appearing in the OTP prompt window because the
pipe-based JSON transfer was unreliable. Embed the sanitized display
name directly in the PowerShell script string and show it in both
the window title and body before pipe I/O begins.
Made-with: Cursor
- Windows SSH_MUX_SSH_DIR: fail-closed when directory does not exist (prevents unsafe DACL inheritance from permissive parent directories)
- DIR_DANGEROUS_MASK: add FILE_DELETE_CHILD (0x0040) to prevent child-deletion attacks on the SSH directory
- Replace PowerShell Start-Process with Rust CreateProcess (CREATE_NO_WINDOW | DETACHED_PROCESS) for immediate daemon launch, eliminating single-quote command injection risk
- Resolve Startup folder via Known Folder API (FOLDERID_Startup), resistant to APPDATA env-var poisoning
- Bump version to 1.10.1
Made-with: Cursor
Documents known_hosts wildcard patterns, Windows DACL checks for SSH_MUX_SSH_DIR and daemon token, Known Folder API for token path, and Unix /tmp graceful error handling.
Made-with: Cursor
The /tmp/ssh-mux-{uid} fallback directory creation now returns errors instead of panicking, providing a graceful exit with clear error messages when another user pre-creates the directory (local DoS prevention).
Made-with: Cursor
Resolves LOCALAPPDATA via SHGetKnownFolderPath (resistant to env var poisoning) and validates token file DACL on both daemon and client side, preventing unauthorized read/write that could enable pipe squatting or phishing.
Made-with: Cursor
Validates owner and DACL permissions on the SSH directory when set via SSH_MUX_SSH_DIR, preventing unauthorized write access that could lead to host key tampering or code execution via install.
Made-with: Cursor
Prevents accept-new bypass when @cert-authority entries use wildcard hostnames like *.example.com. Previously treated as literals, these patterns now match correctly so CA detection works as expected.
Made-with: Cursor
Daemon and serve modes now log to ssh-mux.log in the SSH directory (in addition to stderr) so crashes are diagnosable even when running in a hidden window. Includes automatic 5 MB rotation and a panic hook that persists panic messages before exit.
Made-with: Cursor
known_hosts: CertAuthorityPresent for matching host, unmatched host
stays Unknown, Trusted takes priority over CA presence, wildcard
non-glob behavior verified.
security: powershell_path() returns absolute System32 path on Windows
and never returns bare 'powershell' name.
Made-with: Cursor
The module-level comment in ipc.rs still described the old behavior of
falling back to OW DACL when SID resolution fails. Updated to reflect
the current fail-closed policy.
Made-with: Cursor
If known_hosts contains @cert-authority entries for a host, the
environment uses CA-based trust. Since ssh-mux cannot verify CA-signed
host certificates, auto-accepting via accept-new would silently
downgrade the trust model. Non-interactive connections now refuse when
CA entries are present. Interactive mode warns and asks explicitly.
Made-with: Cursor