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>
ssh-mux
SSH connection multiplexer for Windows — a ControlMaster / ControlPersist alternative.
Pools SSH connections and exposes a local SSH server proxy so that tools like ssh.exe and VS Code Remote-SSH can reuse a single authenticated session without repeated OTP prompts.
Features
- Local SSH server on
127.0.0.1with publickey-only authentication - Connection pooling with configurable idle timeout and absolute lifetime
- SSH keepalive (15s interval) with automatic dead-connection reaping
- Keyboard-interactive (OTP) authentication via Named Pipe IPC with a dedicated PowerShell prompt window (input masked via
SecureString) - Interactive host key verification on first contact (fingerprint displayed, yes/no prompt)
- Bidirectional channel relay (shell, exec, SFTP, port forwarding)
- Jump host (bastion) support with TOML route configuration
- Auto-generated SSH config (
setup-config) - Pool lock/unlock to freeze sessions (e.g. before leaving a workstation)
- Background service with
install/uninstall(Windows Startup folder) - Persistent file logging (
ssh-mux.log) with automatic rotation and panic hook - Works with VS Code Remote-SSH out of the box
Build
Requires Rust 2024 edition (1.85+).
cargo build --release
The binary is at target/release/ssh-mux.exe.
Pre-commit hook
Enable the pre-commit hook (runs the same fmt, clippy, test checks as CI):
git config core.hooksPath hooks
Quick start
# 1. One-shot bootstrap: import existing ~/.ssh/config into routes.toml,
# generate ssh-mux-hosts.conf + Include line, install service.
ssh-mux install
# 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 below.
Configuration
Routes file (~/.ssh/ssh-mux-routes.toml)
[jump]
host = "bastion.example.com"
port = 22
user = "jumpuser"
[routes.webserver]
host = "10.0.0.10"
port = 22
user = "deploy"
[routes.dbserver]
host = "10.0.0.20"
port = 22
user = "admin"
[routes.external]
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"
allow_remote_loopback_any_port = true # opt-in: needed for VS Code / Cursor Remote-SSH
Per-route options:
direct = true— bypass the jump host and connect directly.allow_remote_loopback_any_port = true— allowdirect-tcpipchannels to any port on the route's remote loopback (127.0.0.1/localhost/::1). VS Code and Cursor Remote-SSH spawn the IDE server on a random localhost port and forward to it viadirect-tcpip; without this flag those channels are denied (administratively prohibited) and Remote-SSH fails to connect. Defaultfalse. Enable only on routes you use as IDE remote-development targets — leaving it off keeps the route from being used as an open SOCKS-style proxy into the remote's localhost services.
Top-level options (set above any [jump] / [routes.*] table):
default_allow_remote_loopback_any_port = true— fallback for the per-route flag above. When set, every route accepts any port on remote loopback unless the per-route flag overrides it. Use this when most routes are IDE remote-dev targets and per-route repetition is just noise; leave it off (the default) when only selected hosts should permit loopback-any-port.
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 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:
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
One-shot bootstrap pipeline:
-
import-config — if
~/.ssh/ssh-mux-routes.tomldoes not exist, derive it from~/.ssh/configviassh -G <alias>. If the file already exists it is reused (usessh-mux import-config --write --forceto re-import explicitly). -
setup-config — generate
~/.ssh/ssh-mux-hosts.conf, prependInclude ssh-mux-hosts.confto~/.ssh/config(existingHostblocks are not modified), and pre-register the host key inssh-mux-known-hosts. -
service install (Windows only) —
- Copies the binary to
~/.ssh/ssh-mux.exe - Creates a VBScript in the Windows Startup folder (resolved via Win32 Known Folder API, resistant to
%APPDATA%poisoning) for auto-start at logon - Starts the server immediately via Rust
CreateProcesswithCREATE_NO_WINDOW(no PowerShell command-line injection surface)
On non-Windows platforms steps 1–2 still run; step 3 is skipped and the manual
ssh-mux daemon …invocation is printed instead. - Copies the binary to
ssh-mux install
ssh-mux install --config /path/to/routes.toml -p 2222 --timeout 600
ssh-mux uninstall
Stops ssh-mux and removes the background service:
ssh-mux uninstall
ssh-mux serve
Starts the local SSH server in the foreground. Two modes:
Direct mode — single remote host:
ssh-mux serve -p 2222 --remote example.com:22 -u myuser
Routed mode — multiple servers via jump host:
ssh-mux serve -p 2222 --config ~/.ssh/ssh-mux-routes.toml
Options:
--timeout <secs>— idle timeout before closing unused connections (default: 600)--max-lifetime <secs>— absolute maximum lifetime per connection, 0 = unlimited (default: 43200 = 12 hours)
ssh-mux status / ssh-mux stop
Show active connections or stop the daemon.
ssh-mux lock / ssh-mux unlock
Freeze the connection pool to reject new sessions while keeping existing ones active. Useful before leaving a workstation.
ssh-mux lock # reject new sessions
ssh-mux unlock # resume accepting sessions
Daemon mode (ProxyCommand)
ssh-mux daemon --timeout 600 --max-lifetime 3600
ssh-mux connect user@host:22
Authorized keys
The local server only accepts publickey authentication. Place allowed public keys in:
~/.ssh/ssh-mux-authorized-keys
One key per line, same format as authorized_keys. If this file does not exist, ~/.ssh/*.pub files are used as a fallback.
Security
Network isolation
- Local-only binding (
127.0.0.1) — not exposed to the network PIPE_REJECT_REMOTE_CLIENTSon all Named Pipes (IPC and OTP) blocks SMB access- Agent forwarding, X11, and remote port forwarding are explicitly denied
direct-tcpiptarget allowlist prevents lateral movement- Environment variable allowlist blocks dangerous variables
Authentication and host keys
- Publickey-only auth for the local server (password/keyboard-interactive rejected)
- Generated SSH config forces
PreferredAuthentications publickeywithPasswordAuthentication noandKbdInteractiveAuthentication no— prevents phishing via port-squatting on the local server - Interactive host key verification via IPC (fail-closed
StrictHostKeyChecking=ask): unknown host keys are presented to the user with fingerprint for yes/no confirmation. If IPC is unavailable when interactive verification was intended, the connection is refused (fail-closed). Falls back toaccept-newonly when no interactive channel exists by design (e.g. internal servers via jump host) - Host key pinning is mandatory: if
known_hostscannot be written after acceptance, the connection is refused - Host key pre-registration:
setup-configwrites the local server's host key to~/.ssh/ssh-mux-known-hostsand setsStrictHostKeyChecking yes(falls back toaccept-newif key is not yet available) - Host key changes are always rejected
- Host key generated in-process via CSPRNG (no external
ssh-keygen— avoids PATH hijack / binary planting) - PowerShell invoked via absolute path resolved through the Win32
GetSystemDirectoryWAPI (not the%SystemRoot%environment variable, which could be poisoned) to prevent local binary hijacking; bare"powershell"PATH fallback removed - Immediate daemon launch at install uses Rust
CreateProcesswithCREATE_NO_WINDOW | DETACHED_PROCESS— no PowerShell one-liner, eliminating quoting / command injection risks from paths containing',;, or other shell metacharacters - Windows Startup folder resolved via Win32 Known Folder API (
FOLDERID_Startup), resistant to%APPDATA%environment variable poisoning; falls back to env var only if the API fails - Proxy-side auth prompts sanitized to strip all terminal escape sequences (CSI, OSC, DCS, PM, APC, SOS) and control characters before display — prevents clipboard injection, title spoofing, and other terminal attacks from malicious remote servers
- Connection pool keys include
user@host:port— prevents cross-user connection reuse
Config generation safety
- Route names validated against
^[A-Za-z0-9._-]+$to prevent SSH config syntax injection - All config fields (host, user) reject newline, carriage return, null, and leading/trailing whitespace
- Config files and known_hosts written atomically (CSPRNG-random temp file +
O_EXCL+ fsync + rename) to prevent partial writes and TOCTOU attacks - Symlink targets rejected on all write paths
setup-configis purely additive on~/.ssh/config: it prepends a singleIncludeline and never edits, comments out, or removes existing user content. Conflicts are reported but not mutated.import-configis read-only on the source SSH config — it derivesroutes.tomlviassh -Gwithout writing back.import-configvalidates each enumerated alias against the route-name regex before passing it tossh -G, and rejects any alias starting with-to prevent option injection on the ssh CLI
IPC security (Windows)
- Named Pipe protected by explicit user-SID DACL (
D:P(A;;GA;;;{user_sid})) — fail-closed: if SID resolution fails, the daemon refuses to start rather than falling back to a weaker DACL - Anti-squatting: pipe name includes a CSPRNG-generated token stored in
{LocalAppData}\ssh-mux\daemon_token(path resolved via Win32 Known Folder API, resistant to%LOCALAPPDATA%env-var poisoning); token file and directory DACL is validated on both daemon and client side to prevent unauthorized read/write; newly created token files are DACL-checked immediately after write and deleted if unsafe permissions are inherited (fail-closed) - OTP prompt pipes use
FILE_FLAG_FIRST_PIPE_INSTANCEwith user-SID DACL and CSPRNG-generated pipe names; SID resolution failure also aborts OTP pipe creation (fail-closed) - OTP prompt pipes use overlapped I/O with child-process monitoring: if the user closes the PowerShell OTP window, pending pipe operations are cancelled immediately instead of blocking the daemon
- OTP prompt window title stays as
ssh-mux OTP; the target host name is displayed prominently in the window body (not in the title bar / taskbar tab) - OTP prompts exchanged via Named Pipe IPC (
SecureStringin PowerShell); remote-supplied fields parsed from JSON after transfer rather than interpolated into script source - IPC protocol reads have a 30-second timeout and enforced 8 KiB per-line limit (
bounded_read_lineviafill_buf), preventing memory exhaustion DoS from a local client sending unbounded data without a newline terminator
IPC security (Unix)
- Unix domain socket with 0600 permissions
- Fallback directory
/tmp/ssh-mux-{uid}validated on use with fail-closed policy: owner, permissions (0700), and symlink checks — validation failure returns an error (graceful exit) instead of panicking
Host key verification scope
known_hostsparser supports: plain hostnames, bracketed[host]:port, multiple hostnames, hashed hostnames (|1|salt|hash),@revokedmarkers, wildcard patterns (*,?), negation (!pattern), comments, and blank lines- Unsupported markers (e.g.
@cert-authority) are explicitly warned and skipped — they are not silently treated as regular entries, preventing false confidence in verification coverage @cert-authorityguard: when a host has@cert-authorityentries inknown_hosts(including wildcard hostnames like*.example.com), non-interactiveaccept-newis blocked (fail-closed) to prevent silently downgrading the CA trust model. Interactive mode warns about the CA downgrade and requires explicit confirmation
Exit code integrity
- In-band exit codes are tagged with a per-session CSPRNG nonce exchanged over the trusted IPC channel, preventing remote servers from spoofing exit status via terminal output
Operational resilience
- File-based logging (
ssh-mux.log) in daemon/serve modes so crashes and errors are diagnosable even when the process runs in a hidden window - Panic hook writes the panic message to the log file before process exit
- Automatic log rotation at 5 MB prevents unbounded disk usage
Connection lifecycle
- SSH keepalive enabled on all connections (client and local server): 15-second interval, 3 missed replies before disconnect
- Dead connections (keepalive timeout, remote close) are automatically reaped every 30 seconds
- Idle connections are cleaned up after
--timeoutseconds 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--timeoutwhenever the pool's last-event timestamp was stale, which silently severed long-running tmux/SSH sessions exactly--timeoutseconds 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-lifetimeenforces an absolute cap on connection age (overrides the active-channels rule)
File integrity
- All security-sensitive paths (authorized keys, host key, config, known_hosts, daemon token,
SSH_MUX_SSH_DIR) checked for symlinks/reparse points on all platforms, walking the full ancestor chain from target through every parent to the filesystem root - Windows home directory (
~/.sshbase) resolved via Win32 Known Folder API (FOLDERID_Profile), resistant to%USERPROFILE%environment variable poisoning; falls back to env var only if the API fails SSH_MUX_SSH_DIRoverride validated: reparse-point check on all ancestors, directory ownership/permissions verified and enforced (Unix: must be owned by current user or root, group/world-writable rejected — override is ignored on failure; Windows: fail-closed if directory does not exist — directory must be created manually with proper permissions before use, preventing unsafe DACL inheritance from permissive parent directories; reparse-point rejection plus DACL check — owner must be current user/SYSTEM/Administrators, unauthorized write/delete permissions rejected includingFILE_DELETE_CHILD)- Unix: group/world-writable files rejected (StrictModes); directories also reject group-writable (
0o022mask, matching OpenSSH StrictModes); directory ownership verified against current uid (must be current user or root) - Windows: all DACL checks go through a single centralized
check_dacl_permissionsimplementation with per-context mask constants (FILE_DANGEROUS_MASK,DIR_DANGEROUS_MASK,HOST_KEY_DANGEROUS_MASK,TOKEN_DANGEROUS_MASK), ensuring consistent ACE handling across all paths; file ownership verified viaGetSecurityInfo(must be current user, SYSTEM, or Administrators); NULL DACLs rejected (fail-closed — NULL DACL grants full access to everyone); DACL inspected to reject write/read-class permissions includingGENERIC_WRITE,GENERIC_ALLgranted to unauthorized SIDs;ACCESS_ALLOWED_OBJECT_ACE_TYPE(type 5) fail-closed — its variable-length layout (optional Flags + GUIDs before SID) prevents safe SID extraction, so any object ACE with dangerous permissions is rejected rather than incorrectly parsed;GetAclInformation/GetAcefailures are fail-closed; reparse points (junctions) rejected on all read and write paths - Host key private file: Windows DACL check additionally rejects
GENERIC_READandFILE_READ_DATAfrom non-trusted SIDs (stricter than general file checks) - Authorized keys: fallback from dedicated allowlist (
ssh-mux-authorized-keys) toid_*.pubscan emits a prominent security downgrade warning, indicating potential file-deletion-based policy bypass
Custom SSH directory
If your SSH config and keys live outside ~/.ssh (e.g. C:\dev\.ssh), set:
SSH_MUX_SSH_DIR— absolute path to your SSH directory. All ssh-mux paths (config, keys, host key, authorized keys, known_hosts, install exe) use this directory instead of~/.ssh.
Example (PowerShell, current user):
$env:SSH_MUX_SSH_DIR = "C:\dev\.ssh"
Then run ssh-mux install, setup-config, etc. as usual; they will use C:\dev\.ssh.
Cursor / VS Code Remote-SSH
To make Cursor (or VS Code) use the same SSH config and keys:
- Set
remote.SSH.configFileto your config path, e.g.C:\dev\.ssh\config(orC:\Users\<you>\.cursor\settings.json/ User settings). - Ensure the SSH extension uses that config: it will read
configand key paths from that directory when connecting.
In Cursor: File > Preferences > Settings (or Ctrl+,), search for remote.SSH.configFile, and set it to your config path (e.g. C:\dev\.ssh\config).
What ssh-mux controls: Interactive host key prompts and OTP for remote servers are handled by ssh-mux's own IPC only when you connect through the mux (e.g. ProxyCommand ssh-mux proxy ... to 127.0.0.1:2222). If your Host entry talks to the real server directly (ssh user@ec2... with no mux ProxyCommand), Remote-SSH uses Windows' ssh.exe + Cursor's askpass for that hop -- that path never goes through ssh-mux code.
Unicode usernames and askpass (CreateProcessW failed error:2)
If your Windows username contains non-ASCII characters (e.g. Korean, Japanese, Chinese), Cursor's askpass script lives under C:\Users\<name>\.cursor\... -- a path with Unicode characters. Win32-OpenSSH's posix_spawnp passes this path through narrow-string CreateProcessW, which fails to resolve it (error:2 = file not found). Symptoms:
- Terminal SSH works fine (TTY prompts don't use askpass)
- Remote-SSH fails with
CreateProcessW failed error:2/ssh_askpass: posix_spawnp: No such file or directory/Host key verification failed - Host key verification dialogs never appear in Cursor
Fix: setup-config automatically generates wrapper scripts in your SSH dir (which should be an ASCII path like C:\dev\.ssh). Point Cursor at the wrapper:
- Run
ssh-mux setup-config-- it createscursor-ssh.cmdandcursor-askpass.cmdin your SSH dir. - Settings > search
remote.SSH.path> set to thecursor-ssh.cmdpath printed by setup-config (e.g.C:\dev\.ssh\cursor-ssh.cmd).
The wrapper intercepts SSH_ASKPASS, redirecting it to the ASCII-path proxy before calling ssh.exe, so posix_spawnp can find and execute the askpass program.
Logging
In daemon and serve modes, ssh-mux logs to ssh-mux.log in the SSH directory (~/.ssh/ssh-mux.log or $SSH_MUX_SSH_DIR/ssh-mux.log) in addition to stderr. This makes it possible to diagnose crashes and connection issues even when the process runs in a hidden window.
- Log rotation: the file is automatically rotated (renamed to
ssh-mux.log.old) when it exceeds 5 MB - Panic hook: if the process panics, the panic message is written to the log file before exit
- The log file path is printed in the
ssh-mux installoutput summary
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)
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 deniedand 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 above. Run ssh-mux setup-config and set remote.SSH.path to the generated wrapper.
Permission denied (publickey,hostbased,keyboard-interactive) and ssh_askpass: posix_spawnp: No such file or directory / CreateProcessW failed error:2
When connecting through the local ssh-mux server (127.0.0.1 / mux Host), this usually means two things:
-
Wrong username
The local ssh-mux server uses the SSH username as the route name. If you seehome@127.0.0.1(or any username that is not a route name), the client is not using the right Host from your config.
Fix: In Cursor/VS Code Remote-SSH, connect using the Host alias from your ssh-mux setup (e.g.webserver,dbserver), not127.0.0.1and not a host that uses a differentUser. The config generated bysetup-configdefines one Host per route; use that Host name so the client sends the correctUser(the route name). -
Public key not allowed
The local server only accepts keys listed inssh-mux-authorized-keys(or, if that file is missing,~/.ssh/id_*.pubin your SSH dir). If the key the client offers is not there, the server rejects the connection. The client may then try keyboard-interactive and run askpass, which on Windows can fail with "CreateProcessW error:2" if the askpass program is missing.
Fix: Add the same public key you use for the jump host (or for the remote) to your SSH dir'sssh-mux-authorized-keysfile (one key per line, OpenSSH format). Use the SSH directory that ssh-mux uses (SSH_MUX_SSH_DIRor~/.ssh).
Summary: use the Host alias (route name) in Cursor's remote target, and ensure your public key is in ssh-mux-authorized-keys in the same SSH directory.
channel 0: open failed: administratively prohibited: Rejected (after successful auth)
Authentication to the local ssh-mux server succeeded, but opening the session channel was rejected. This means the server could not open the corresponding session to the remote (jump host or target).
Common causes:
-
Route name not in config
The SSH username (e.g.homewhen you runssh home) must match a route name inssh-mux-routes.toml. If there is no[routes.home](or whatever name you use), the server rejects the channel.
Fix: Add a route with that name in your routes file and restart the daemon, or use a Host that corresponds to an existing route (e.g.ssh webserverif you have[routes.webserver]). -
Backend connection failure
The route exists but connecting to the jump host or target fails (unreachable, auth failure, etc.).
Fix: Check the ssh-mux daemon logs (the process that runsssh-mux serveorssh-mux daemon). Look for a line likechannel open rejected for route "home": remote session failed: ...to see the real error (e.g. "unknown route", connection refused, auth failed). Fix the route config or network/keys so the daemon can reach the jump/target.
License
MIT