Always-on SSH port forwards to the hosts already in your ~/.ssh/config, from the macOS menu bar.
A port forward you actually depend on should not be a terminal tab you have to remember. ssh -N -L dies on a dropped connection, a network change, or a laptop sleep, and nothing tells you: the command is just gone, and the port stops answering.
ssher keeps forwards up. Each one is supervised, reconnected with backoff when it drops, restored when the app restarts, and reported with a status that is measured rather than assumed.
It also has no host list and no credential store of its own. It reads ~/.ssh/config, and it shells out to the ssh binary already on your machine, so ProxyCommand, ProxyJump, agent forwarding and known_hosts all work the way they already do for you.
It lives in the menu bar with no Dock icon. Closing the window hides it; forwards keep running.
The current source focuses on resilient SSH port forwarding. SFTP browsing and rsync folder sync were removed. The downloadable v0.1.0 is an older build that still includes those features; it does not represent the current source. Build from source below to try the current port-forwarding app.
-
Download the
.dmgfrom Releases, open it, drag ssher into Applications. -
The bundle is not code-signed (
src-tauri/tauri.conf.jsonsets no signing identity), so Gatekeeper blocks the first launch. Clear the quarantine flag:
xattr -cr /Applications/ssher.appOr right-click the app in Finder, choose Open, then Open again on the warning.
-
Launch it. The ssher icon appears in the menu bar. Click it and choose Show ssher.
-
Verify: the sidebar lists the concrete
Hostaliases from~/.ssh/config. Click + New Forward, pick a host and a port, and the ports table should show it go Active.
If a forward sits on Retrying, hover the status for the last error. A common cause is no usable key:
ssh-add ~/.ssh/id_ed25519Intel builds are not published. Build from source instead (below).
Needs a Rust toolchain, Node.js with npm, and the Tauri 2 prerequisites for macOS. At runtime the app needs ssh on PATH (macOS ships it).
git clone https://github.com/Magi-Labs/ssher.git
cd ssher
npm install
npm run tauri devnpm run tauri dev starts Vite on port 1420 (vite.config.ts, strict port) and then the Rust app with hot reload for the frontend.
~/.ssh/config forwards.json (app config dir)
| |
| host aliases | saved forwards, restored on launch
v v
+-------------------------------------------------------+
| React UI (src/App.tsx) |
| sidebar: host list main: ports table |
+-------------------------------------------------------+
| invoke() ^ listen("forward-status")
v |
+-------------------------------------------------------+
| Tauri commands (src-tauri/src/lib.rs) |
+---------------------------+---------------------------+
| ssh_config.rs | forward.rs |
| parse config, add host | one supervisor thread per |
| | forward, persisted to disk|
+---------------------------+---------------------------+
|
v
ssh -N -L/-R (one child per forward)
system ssh: ProxyCommand, ProxyJump,
known_hosts, agent
src-tauri/src/ssh_config.rsparses~/.ssh/config. It collects every concrete (non-wildcard)Hostalias, then for each alias merges the fields of every matching block, first match wins per directive, the same way OpenSSH does. OnlyHostName,User,Port,IdentityFileandProxyCommandare read.- The frontend is a single React component (
src/App.tsx). It calls Rust throughinvoke()and receives live status through theforward-statusevent, so the table updates without polling. forward.rs::start_forwardwrites the forward toforwards.jsonand hands it to a supervisor thread.stop_forwardkills the process and removes it from that file.list_forwardsreturns the current state for the initial render.- Each supervisor thread owns exactly one forward: spawn
ssh -N -L|-R <local>:<host>:<remote> <alias>, watch it, and on any failure kill it and respawn after a backoff that doubles from 1 s to a 30 s ceiling. The backoff resets only once the forward is confirmed healthy, so a forward that fails instantly on every attempt backs off instead of hot-looping. - Health is probed, not assumed. For a local (
-L) forward the supervisor TCP-connects to127.0.0.1:<local_port>every 3 s; the forward is reported Active only when that connect succeeds. A remote (-R) forward has no local listener, so only process liveness is checked. - On launch
forward::restore_on_startupreadsforwards.jsonand starts a supervisor for every saved forward, so tunnels come back by themselves. lib.rsbuilds the tray icon with Show ssher / Quit, setsActivationPolicy::Accessoryon macOS (no Dock icon), and intercepts the window close event to hide instead of quit.
forward.rs passes a specific set of options. Each one is there because of a failure it fixes, and two obvious-looking options are deliberately absent:
| Flag | Why |
|---|---|
ControlMaster=no, ControlPath=none |
With a shared master connection active, ssh -N -L hands the forward to the master and exits 0 immediately. The supervisor would read that as a crash and reconnect forever while the tunnel was actually fine. Each forward gets its own connection. |
ServerAliveInterval=10, ServerAliveCountMax=3 |
Makes a dead peer surface as a process exit within ~30 s instead of hanging on a half-open socket. |
no ExitOnForwardFailure |
It tears down the whole connection when any forward fails, including an unrelated static RemoteForward in the host's own ssh_config block that another session already holds. The TCP probe checks our forward specifically instead. |
no ClearAllForwardings |
It looks like the fix for the line above, but it clears command-line -L/-R too, leaving a live connection with no forward at all — and with process-liveness status, that reads as healthy. |
| Feature | Where | Notes |
|---|---|---|
Host list from ~/.ssh/config |
ssh_config.rs::parse_ssh_config |
Wildcard patterns like Host * contribute defaults but are not listed |
| Add New SSH Host | ssh_config.rs::add_ssh_host |
Appends a Host block; rejects empty or duplicate alias |
Local (-L) and remote (-R) forwards |
forward.rs::start_forward |
Any number, across any number of hosts, at once |
| Automatic reconnect | forward.rs::supervise |
Exponential backoff, 1 s doubling to a 30 s ceiling, indefinitely |
| Survives app restart | forward.rs::restore_on_startup |
Saved forwards are reconnected on launch |
| Live status per forward | forward-status event, App.tsx |
Connecting / Active / Retrying / Stopped, with last error and retry count |
| Real health check | forward.rs::supervise |
Active means a TCP connect to the local port succeeded, not just that a process exists |
| Ports table | App.tsx |
Every forward on every host in one view: port, host, forwarded address, status |
| Menu bar app | lib.rs |
Tray menu, no Dock icon, close hides the window |
Hosts come from ~/.ssh/config. Forwards are the one thing ssher stores itself.
| Source | Key | Default | Used for |
|---|---|---|---|
~/.ssh/config |
Host |
Alias list. Patterns with * or ? are skipped as entries but still supply defaults |
|
~/.ssh/config |
HostName, User, Port, IdentityFile, ProxyCommand |
Shown in the sidebar; applied by system ssh when a forward connects |
|
forwards.json |
[] |
Saved forwards, restored on launch. macOS: ~/Library/Application Support/ssher/forwards.json |
|
| env | SSH_AUTH_SOCK |
ssh-agent socket, used by system ssh |
|
| env | TAURI_DEV_HOST |
unset | Dev only: bind Vite to a LAN host for on-device testing (vite.config.ts) |
forward.rs |
MAX_BACKOFF_SECS |
30 | Reconnect backoff ceiling |
tauri.conf.json |
app.windows[0] |
800 x 600 | Main window size |
App.tsx |
UI defaults | forward 8080:127.0.0.1:8080, direction local |
Initial dialog values |
Two things are written to disk: forwards.json, and the Host block that add_ssh_host appends to ~/.ssh/config. No keys, passwords or session data are stored.
- One
sshchild per forward, not a library. Spawning systemsshrather than re-implementing OpenSSH meansProxyCommand,ProxyJump, agent forwarding andknown_hostswork for free, and the host's own config is honoured exactly. The cost is a process per forward and parsing behaviour out of exit codes. - Status is measured. A supervised process being alive turned out to be a poor proxy for a working tunnel: ssh will happily hold a connection open with no listener bound. Reporting Active only on a successful TCP connect to the local port is the difference between a status light and a status guess. It does mean a
-Rforward, which has no local listener, gets the weaker liveness-only check. - Backoff resets on health, not on spawn. Resetting the delay whenever a process starts successfully lets a forward that dies immediately retry roughly once a second forever. The reset is tied to the forward being confirmed healthy instead.
- Stop means delete. Stopping a forward removes it from
forwards.jsonrather than parking it in a disabled state, so a stopped forward stays stopped across restarts. There is no way to keep a forward in the list but switch it off. - Its own connection per forward, deliberately. Opting out of
ControlMastercosts a real handshake per forward and gives up connection sharing, in exchange for a process whose lifetime actually tracks the tunnel. - ssh_config merge semantics are replicated, not approximated. A host whose settings are split across a shared block and a specific block shows up once with merged fields (
ssh_config.rs, tested inmerges_fields_across_matching_blocks). Only five directives are read;Includeis not followed.
ssher/
index.html Vite entry
package.json npm scripts: dev, build, tauri
vite.config.ts port 1420, strictPort, ignores src-tauri
src/
main.tsx React root
App.tsx whole UI: sidebar, ports table, two modals
App.css
src-tauri/
Cargo.toml tauri 2 (tray-icon), dirs, uuid, serde
tauri.conf.json product name, window, bundle targets, icons
capabilities/default.json window show/hide/focus, opener, process exit
icons/ app icons used by the bundle and the tray
src/
main.rs calls ssher_lib::run()
lib.rs Tauri builder, command registry, tray, close-to-hide
ssh_config.rs ~/.ssh/config parser + add_ssh_host
forward.rs supervisor threads, health probe, persistence
Run in dev mode (Vite hot reload for the frontend, Rust rebuild on change):
npm install
npm run tauri devRust unit tests (config merge, wildcard skipping):
cd src-tauri && cargo testType-check the frontend (strict TS, unused locals and params are errors):
npx tscProduction build. Runs npm run build (tsc && vite build) into dist/, then compiles the Rust binary and bundles it:
npm run tauri buildOutput lands under src-tauri/target/release/bundle/ (.app and .dmg on macOS, since bundle.targets is "all").
There is no CI workflow and no release automation in the repo. Releases are built locally with the command above and uploaded by hand.
All of these are visible in the code today.
- Port forwarding is the whole app. Earlier versions had an SFTP file browser and an rsync folder sync; both were removed.
- Remote (
-R) forwards get liveness-only status. The TCP probe checks a local listener, which a-Rforward does not have, so a-Rforward can read Active while the remote listener is gone. - The probe confirms the tunnel's local end. If nothing is listening on the target port on the remote host, the forward is still Active — connections through it will just fail.
- Quitting does not clean up.
app.exit(0)does not kill thessh -Nchildren, so they can outlive the app and keep holding their local ports. Stop forwards before quitting, or kill the strays. - No pause. Stopping a forward deletes it; there is no disabled-but-remembered state.
ProxyJumpandIncludeare not parsed by the config reader, so such hosts may not appear in the sidebar even though systemsshwould handle them.ActivationPolicy::Accessoryis macOS only. Other platforms are not targeted even thoughbundle.targetsis"all".- The app is unsigned. Apple Silicon
.dmgonly in Releases. Cargo.tomlstill carries the Tauri templatedescription = "A Tauri App"andauthors = ["you"].
Open an issue or a PR on GitHub. Keep Rust changes covered by a unit test where the logic is parseable (see ssh_config.rs for the pattern).
MIT. See LICENSE.