명선 최 6c51d2f4a8
All checks were successful
CI / Check (fmt, clippy, test) (push) Successful in 1m27s
fix: work around Win32-OpenSSH askpass failure on non-ASCII usernames
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
2026-02-26 15:07:09 +09:00
2026-02-24 13:39:09 +09:00

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.1 with 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:

  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)
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_CLIENTS on all Named Pipes (IPC and OTP) blocks SMB access
  • Agent forwarding, X11, and remote port forwarding are explicitly denied
  • direct-tcpip target allowlist prevents lateral movement
  • Environment variable allowlist blocks dangerous variables

Authentication and host keys

  • Publickey-only auth for the local server (password/keyboard-interactive rejected)
  • Generated SSH config forces PreferredAuthentications publickey with PasswordAuthentication no and KbdInteractiveAuthentication no — prevents phishing via port-squatting on the local server
  • Interactive host key verification via IPC (fail-closed StrictHostKeyChecking=ask): unknown host keys are presented to the user with fingerprint for yes/no confirmation. If IPC is unavailable when interactive verification was intended, the connection is refused (fail-closed). Falls back to accept-new only when no interactive channel exists by design (e.g. internal servers via jump host)
  • Host key pinning is mandatory: if known_hosts cannot be written after acceptance, the connection is refused
  • Host key pre-registration: setup-config writes the local server's host key to ~/.ssh/ssh-mux-known-hosts and sets StrictHostKeyChecking yes (falls back to accept-new if key is not yet available)
  • Host key changes are always rejected
  • Host key generated in-process via CSPRNG (no external ssh-keygen — avoids PATH hijack / binary planting)
  • PowerShell invoked via absolute path resolved through the Win32 GetSystemDirectoryW API (not the %SystemRoot% environment variable, which could be poisoned) to prevent local binary hijacking; bare "powershell" PATH fallback removed
  • Immediate daemon launch at install uses Rust CreateProcess with CREATE_NO_WINDOW | DETACHED_PROCESS — no PowerShell one-liner, eliminating quoting / command injection risks from paths containing ', ;, or other shell metacharacters
  • Windows Startup folder resolved via Win32 Known Folder API (FOLDERID_Startup), resistant to %APPDATA% environment variable poisoning; falls back to env var only if the API fails
  • Proxy-side auth prompts sanitized to strip all terminal escape sequences (CSI, OSC, DCS, PM, APC, SOS) and control characters before display — prevents clipboard injection, title spoofing, and other terminal attacks from malicious remote servers
  • Connection pool keys include user@host:port — prevents cross-user connection reuse

Config generation safety

  • Route names validated against ^[A-Za-z0-9._-]+$ to prevent SSH config syntax injection
  • All config fields (host, user) reject newline, carriage return, null, and leading/trailing whitespace
  • Config files and known_hosts written atomically (CSPRNG-random temp file + O_EXCL + fsync + rename) to prevent partial writes and TOCTOU attacks
  • Symlink targets rejected on all write paths

IPC security (Windows)

  • Named Pipe protected by explicit user-SID DACL (D:P(A;;GA;;;{user_sid})) — fail-closed: if SID resolution fails, the daemon refuses to start rather than falling back to a weaker DACL
  • Anti-squatting: pipe name includes a CSPRNG-generated token stored in {LocalAppData}\ssh-mux\daemon_token (path resolved via Win32 Known Folder API, resistant to %LOCALAPPDATA% env-var poisoning); token file and directory DACL is validated on both daemon and client side to prevent unauthorized read/write; newly created token files are DACL-checked immediately after write and deleted if unsafe permissions are inherited (fail-closed)
  • OTP prompt pipes use FILE_FLAG_FIRST_PIPE_INSTANCE with user-SID DACL and CSPRNG-generated pipe names; SID resolution failure also aborts OTP pipe creation (fail-closed)
  • OTP prompt pipes use overlapped I/O with child-process monitoring: if the user closes the PowerShell OTP window, pending pipe operations are cancelled immediately instead of blocking the daemon
  • OTP prompt window title stays as ssh-mux OTP; the target host name is displayed prominently in the window body (not in the title bar / taskbar tab)
  • OTP prompts exchanged via Named Pipe IPC (SecureString in PowerShell); remote-supplied fields parsed from JSON after transfer rather than interpolated into script source
  • IPC protocol reads have a 30-second timeout and enforced 8 KiB per-line limit (bounded_read_line via fill_buf), preventing memory exhaustion DoS from a local client sending unbounded data without a newline terminator

IPC security (Unix)

  • Unix domain socket with 0600 permissions
  • Fallback directory /tmp/ssh-mux-{uid} validated on use with fail-closed policy: owner, permissions (0700), and symlink checks — validation failure returns an error (graceful exit) instead of panicking

Host key verification scope

  • known_hosts parser supports: plain hostnames, bracketed [host]:port, multiple hostnames, hashed hostnames (|1|salt|hash), @revoked markers, wildcard patterns (*, ?), negation (!pattern), comments, and blank lines
  • Unsupported markers (e.g. @cert-authority) are explicitly warned and skipped — they are not silently treated as regular entries, preventing false confidence in verification coverage
  • @cert-authority guard: when a host has @cert-authority entries in known_hosts (including wildcard hostnames like *.example.com), non-interactive accept-new is blocked (fail-closed) to prevent silently downgrading the CA trust model. Interactive mode warns about the CA downgrade and requires explicit confirmation

Exit code integrity

  • In-band exit codes are tagged with a per-session CSPRNG nonce exchanged over the trusted IPC channel, preventing remote servers from spoofing exit status via terminal output

Operational resilience

  • File-based logging (ssh-mux.log) in daemon/serve modes so crashes and errors are diagnosable even when the process runs in a hidden window
  • Panic hook writes the panic message to the log file before process exit
  • Automatic log rotation at 5 MB prevents unbounded disk usage

Connection lifecycle

  • SSH keepalive enabled on all connections (client and local server): 15-second interval, 3 missed replies before disconnect
  • Dead connections (keepalive timeout, remote close) are automatically reaped every 30 seconds
  • Idle connections are cleaned up after --timeout seconds, 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-lifetime enforces 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 (~/.ssh base) resolved via Win32 Known Folder API (FOLDERID_Profile), resistant to %USERPROFILE% environment variable poisoning; falls back to env var only if the API fails
  • SSH_MUX_SSH_DIR override validated: reparse-point check on all ancestors, directory ownership/permissions verified and enforced (Unix: must be owned by current user or root, group/world-writable rejected — override is ignored on failure; Windows: fail-closed if directory does not exist — directory must be created manually with proper permissions before use, preventing unsafe DACL inheritance from permissive parent directories; reparse-point rejection plus DACL check — owner must be current user/SYSTEM/Administrators, unauthorized write/delete permissions rejected including FILE_DELETE_CHILD)
  • Unix: group/world-writable files rejected (StrictModes); directories also reject group-writable (0o022 mask, matching OpenSSH StrictModes); directory ownership verified against current uid (must be current user or root)
  • Windows: all DACL checks go through a single centralized check_dacl_permissions implementation with per-context mask constants (FILE_DANGEROUS_MASK, DIR_DANGEROUS_MASK, HOST_KEY_DANGEROUS_MASK, TOKEN_DANGEROUS_MASK), ensuring consistent ACE handling across all paths; file ownership verified via GetSecurityInfo (must be current user, SYSTEM, or Administrators); NULL DACLs rejected (fail-closed — NULL DACL grants full access to everyone); DACL inspected to reject write/read-class permissions including GENERIC_WRITE, GENERIC_ALL granted to unauthorized SIDs; ACCESS_ALLOWED_OBJECT_ACE_TYPE (type 5) fail-closed — its variable-length layout (optional Flags + GUIDs before SID) prevents safe SID extraction, so any object ACE with dangerous permissions is rejected rather than incorrectly parsed; GetAclInformation/GetAce failures are fail-closed; reparse points (junctions) rejected on all read and write paths
  • Host key private file: Windows DACL check additionally rejects GENERIC_READ and FILE_READ_DATA from non-trusted SIDs (stricter than general file checks)
  • Authorized keys: fallback from dedicated allowlist (ssh-mux-authorized-keys) to id_*.pub scan emits a prominent security downgrade warning, indicating potential file-deletion-based policy bypass

Custom SSH directory

If your SSH config and keys live outside ~/.ssh (e.g. C:\dev\.ssh), set:

  • SSH_MUX_SSH_DIR — absolute path to your SSH directory. All ssh-mux paths (config, keys, host key, authorized keys, known_hosts, install exe) use this directory instead of ~/.ssh.

Example (PowerShell, current user):

$env:SSH_MUX_SSH_DIR = "C:\dev\.ssh"

Then run ssh-mux install, setup-config, etc. as usual; they will use C:\dev\.ssh.

Cursor / VS Code Remote-SSH

To make Cursor (or VS Code) use the same SSH config and keys:

  1. Set remote.SSH.configFile to your config path, e.g. C:\dev\.ssh\config (or C:\Users\<you>\.cursor\settings.json / User settings).
  2. Ensure the SSH extension uses that config: it will read config and key paths from that directory when connecting.

In Cursor: File > Preferences > Settings (or Ctrl+,), search for remote.SSH.configFile, and set it to your config path (e.g. C:\dev\.ssh\config).

What ssh-mux controls: Interactive host key prompts and OTP for remote servers are handled by ssh-mux's own IPC only when you connect through the mux (e.g. ProxyCommand ssh-mux proxy ... to 127.0.0.1:2222). If your Host entry talks to the real server directly (ssh user@ec2... with no mux ProxyCommand), Remote-SSH uses Windows' ssh.exe + Cursor's askpass for that hop -- that path never goes through ssh-mux code.

Unicode usernames and askpass (CreateProcessW failed error:2)

If your Windows username contains non-ASCII characters (e.g. Korean, Japanese, Chinese), Cursor's askpass script lives under C:\Users\<name>\.cursor\... -- a path with Unicode characters. Win32-OpenSSH's posix_spawnp passes this path through narrow-string CreateProcessW, which fails to resolve it (error:2 = file not found). Symptoms:

  • Terminal SSH works fine (TTY prompts don't use askpass)
  • Remote-SSH fails with CreateProcessW failed error:2 / ssh_askpass: posix_spawnp: No such file or directory / Host key verification failed
  • Host key verification dialogs never appear in Cursor

Fix: setup-config automatically generates wrapper scripts in your SSH dir (which should be an ASCII path like C:\dev\.ssh). Point Cursor at the wrapper:

  1. Run ssh-mux setup-config -- it creates cursor-ssh.cmd and cursor-askpass.cmd in your SSH dir.
  2. Settings > search remote.SSH.path > set to the cursor-ssh.cmd path printed by setup-config (e.g. C:\dev\.ssh\cursor-ssh.cmd).

The wrapper intercepts SSH_ASKPASS, redirecting it to the ASCII-path proxy before calling ssh.exe, so posix_spawnp can find and execute the askpass program.

Logging

In daemon and serve modes, ssh-mux logs to ssh-mux.log in the SSH directory (~/.ssh/ssh-mux.log or $SSH_MUX_SSH_DIR/ssh-mux.log) in addition to stderr. This makes it possible to diagnose crashes and connection issues even when the process runs in a hidden window.

  • Log rotation: the file is automatically rotated (renamed to ssh-mux.log.old) when it exceeds 5 MB
  • Panic hook: if the process panics, the panic message is written to the log file before exit
  • The log file path is printed in the ssh-mux install output summary

Environment variables

  • RUST_LOG -- controls log verbosity (e.g. RUST_LOG=debug ssh-mux serve ...)
  • 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 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 above. Run ssh-mux setup-config and set remote.SSH.path to the generated wrapper.

Permission denied (publickey,hostbased,keyboard-interactive) and ssh_askpass: posix_spawnp: No such file or directory / CreateProcessW failed error:2

When connecting through the local ssh-mux server (127.0.0.1 / mux Host), this usually means two things:

  1. Wrong username
    The local ssh-mux server uses the SSH username as the route name. If you see home@127.0.0.1 (or any username that is not a route name), the client is not using the right Host from your config.
    Fix: In Cursor/VS Code Remote-SSH, connect using the Host alias from your ssh-mux setup (e.g. webserver, dbserver), not 127.0.0.1 and not a host that uses a different User. The config generated by setup-config defines one Host per route; use that Host name so the client sends the correct User (the route name).

  2. Public key not allowed
    The local server only accepts keys listed in ssh-mux-authorized-keys (or, if that file is missing, ~/.ssh/id_*.pub in your SSH dir). If the key the client offers is not there, the server rejects the connection. The client may then try keyboard-interactive and run askpass, which on Windows can fail with "CreateProcessW error:2" if the askpass program is missing.
    Fix: Add the same public key you use for the jump host (or for the remote) to your SSH dir's ssh-mux-authorized-keys file (one key per line, OpenSSH format). Use the SSH directory that ssh-mux uses (SSH_MUX_SSH_DIR or ~/.ssh).

Summary: use the Host alias (route name) in Cursor's remote target, and ensure your public key is in ssh-mux-authorized-keys in the same SSH directory.

channel 0: open failed: administratively prohibited: Rejected (after successful auth)

Authentication to the local ssh-mux server succeeded, but opening the session channel was rejected. This means the server could not open the corresponding session to the remote (jump host or target).

Common causes:

  1. Route name not in config
    The SSH username (e.g. home when you run ssh home) must match a route name in ssh-mux-routes.toml. If there is no [routes.home] (or whatever name you use), the server rejects the channel.
    Fix: Add a route with that name in your routes file and restart the daemon, or use a Host that corresponds to an existing route (e.g. ssh webserver if you have [routes.webserver]).

  2. Backend connection failure
    The route exists but connecting to the jump host or target fails (unreachable, auth failure, etc.).
    Fix: Check the ssh-mux daemon logs (the process that runs ssh-mux serve or ssh-mux daemon). Look for a line like channel open rejected for route "home": remote session failed: ... to see the real error (e.g. "unknown route", connection refused, auth failed). Fix the route config or network/keys so the daemon can reach the jump/target.

License

MIT

Description
No description provided
Readme 2.3 MiB
2026-04-28 11:56:39 +09:00
Languages
Rust 99.9%
Shell 0.1%