A macOS menu-bar SSH tunnel manager — a rewrite of STM (tynsoe "SSH Tunnel Manager"). Bring a host's tunnels up/down from the menu bar and see their health, with no per-app state: your ssh config is the complete source of truth.
See DESIGN.md for the reasoning behind the model (especially why the unit of control is the host).
Grab the latest universal (Apple Silicon + Intel) .dmg from the
Releases page, open it,
and drag Tunnels.app to Applications. Each v* tag builds and publishes a DMG
automatically (see .github/workflows/release.yml).
The build is ad-hoc signed for now, so on first launch right-click the app →
Open (or xattr -dr com.apple.quarantine /Applications/Tunnels.app).
A "tunnel" is a host plus the forwards declared for it in ssh config. The app
connects/disconnects a host as a unit — connecting brings up the host's whole
configured forward set in one ControlMaster process; disconnecting drops them
together. The app stores nothing of its own: it reads config via ssh -G,
authenticates once, and monitors health. ssh <host> works on its own, exactly
as the config says — the app never disagrees with it.
TunnelKit— the engine (the future MenuBarExtra app depends only on this):SSHConfig.swift— read a host's forwards viassh -G(display + health).Connection.swift—HostConnection:connect/disconnect/isConnected.PortProbe.swift— is the local port listening? (checks IPv4 and IPv6).ProcessRunner.swift—Processwrapper (temp-file capture, no pipe deadlock).
tunnels-askpass— GUI askpass helper (passphrase / password / MFA / host-key).tunnelctl— CLI driver to exercise the engine before any UI exists.
swift buildBinaries land in .build/debug/. tunnelctl finds tunnels-askpass as a sibling.
Real ssh directives in ~/.ssh/config (or an Included file). Forwards are
genuine LocalForward/RemoteForward/DynamicForward directives, so a plain
ssh <host> tunnels exactly as the app does.
Group a host's forwards into independently-togglable units: one alias per
group, with the shared connection settings written once in a Host <host>*
wildcard block. Use %n (the alias) — not %C — in ControlPath, so each group
gets its own connection (%C hashes host/user/port, which are identical across
the aliases, and would collapse them onto one socket):
Host myhost-vnc
LocalForward 5901 localhost:5900
Host myhost-dev
LocalForward 3000 localhost:3000
LocalForward 3001 localhost:3001
Host myhost* # shared settings — written once
HostName myhost.example.com
User alice
IdentityFile ~/.ssh/id_ed25519
ControlMaster auto
ControlPath ~/.ssh/cm-%n # %n = alias → a separate connection per group
ControlPersist 10m
The app lists each concrete alias (myhost-vnc, myhost-dev) as its own
on/off row; the myhost* wildcard is settings-only and isn't shown. A plain
ssh myhost matches the wildcard too — clean shell, no forwards.
tunnelctl up myhost # connect: brings up ALL of myhost's forwards (askpass if the key is locked)
tunnelctl status myhost # connected? which local ports are listening?
tunnelctl down myhost # disconnect: drops all of myhost's forwards
tunnelctl -F <file> … # read connection/config from a specific fileThere is intentionally no per-forward on/off — see DESIGN.md §"The decision".
- Connect:
ssh -M -N -f -o ControlMaster=yes -o ControlPath=… <host>opens a master that applies the host's configured forwards.ExitOnForwardFailure=yesmakes a busy local port fail the whole connect (all-or-nothing) instead of coming up half-forwarded. Auth happens here, once. - Disconnect:
ssh -O exit <host>— the master dies, all forwards with it. - Health:
ssh -O check(is the host connected?) plus a local-port probe per forward. The probe checks both127.0.0.1and[::1](alocalhostforward binds both). - Auth without a TTY:
SSH_ASKPASS+SSH_ASKPASS_REQUIRE=force(OpenSSH 8.4+) route passphrase/password/MFA/host-key prompts to a native dialog — so a menu-bar app with no terminal can still drive interactive logins.
ssh -Gemits resolvedlocalforward/remoteforward/dynamicforwardlines (connect host bracketed, e.g.[localhost]:5432; the reader normalizes that).- ControlMaster gives one authenticated connection carrying all the host's forwards.
sshexpands~/.ssh/configfrom the passwd DB, not$HOME— use-Fto point elsewhere (also howtunnelctl -Ftargets a specific file).- A
localhostforward binds both IPv4 and IPv6 — health must probe both.
- The MenuBarExtra app on top of
TunnelKit(host list + health dot + on/off; expand a host to show its forwards read-only, like STM). - Reconnect-on-drop / wake-from-sleep policy.
- Import the other STM hosts (host-a, host-b, …) into the config.
Apache License 2.0 — see LICENSE.