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
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)
- 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
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. 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)
ssh-mux install
# 4. Connect
ssh webserver
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
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.
Options:
ssh-mux setup-config --config /path/to/routes.toml -p 2222
Commands
ssh-mux install
Installs ssh-mux as a background service:
- 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)
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
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, even if zombie channels remain from unclean client disconnects - 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
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