# Why I Built an SSH Config and Tunnel Manager for macOS

> Source: <https://dev.to/malusev998/why-i-built-an-ssh-config-and-tunnel-manager-for-macos-44lj>
> Published: 2026-08-14 17:40:27+00:00

Every internal tool I need sits behind SSH. Grafana, Prometheus, the staging clusters, internal AI tooling—none of it answers on a public address, and the only door is a bastion I have a key for. That is the right setup for anything with real data behind it, and I wouldn't change it. What I *did* change is typing `ssh -N -L 3000:localhost:3000 -J bastion prod-1`

from memory four times a day across three different machines.

So one weekend, I started writing [SSH Config Manager](https://www.sshmanager.app). It is a native macOS app that edits `~/.ssh/config`

without wrecking formatting, saves tunnels as presets, and opens those tunnels in-process instead of shelling out to `ssh`

. I wrote it for my own workflow first. Putting it on the App Store came later, once it was genuinely useful to me and I figured others were struggling with the exact same friction.

The first thing people ask is: *why not run a VPN and be done with it?* Fair question, but the honest answer is that SSH is the tool I already understand inside and out.

I have configured `sshd`

enough times to know what `PermitRootLogin no`

and `PasswordAuthentication no`

actually change. When a connection stops working, I can usually name the exact line that broke it. A VPN introduces a whole second network layer underneath, complete with its own credentials, its own background daemon to keep patched, and its own unique failure modes to debug at 2:00 AM when production is down. SSH is already on every Linux server I touch and every developer machine I own — there is nothing new to roll out and nothing new to secure.

The tradeoff is real, and I would rather acknowledge it up front. Operating without a VPN means no transparent network routing: every internal service I want to reach must be explicitly forwarded to a local port in advance, and a colleague without my config reaches none of them. Still, I would far rather maintain a clean list of port forwards than maintain another background daemon.

The obvious fix for long commands is a set of shell functions. In practice, that fell apart for me.

I use a MacBook Pro for most daily tasks, but ScyllaDB work happens on Linux because every team member uses Linux and the tooling assumes it. Those environments don't agree on much—different shells, different key paths, and a different set of inventory hosts. I never got dotfile synchronization into a state where SSH aliases were cleanly shared rather than messily merged. The failure mode was predictable: an alias written on the laptop was missing on the remote machine where I needed it, or worse, pointed to a port that moved months ago.

The configuration file itself is the piece that is already portable and universally standardized. Everything reads it out of the box: `ssh`

, `scp`

, `rsync -e ssh`

, and my editor's remote development plugins. Building a tool around `~/.ssh/config`

rather than shell scripts is the core design decision everything else flows from.

Another friction point is that standard text editors treat `~/.ssh/config`

as an arbitrary blob of text. A misspelled directive isn't auto-completed or flagged—you find out at connect time when the setting you thought you configured was silently ignored. The actual definition of what a keyword does lives inside the `ssh_config(5)`

man page in a separate terminal window, which is exactly where you don't want to be toggling back and forth while editing.

To fix this, the app embeds a comprehensive keyword catalog. `KeywordRegistry.swift`

currently contains 95 entries, each specifying its canonical spelling, expected value type (string, integer, boolean, fixed enum, path, or list), category section, and a concise help string pulled directly from `ssh_config(5)`

:

```
.init(
    canonical: "IdentityFile", field: .path, category: .identity,
    help: "Private key file for public-key authentication. May be repeated."),
.init(
    canonical: "ProxyJump", field: .string, category: .connection,
    help: "Connect through one or more jump hosts, e.g. user@bastion:22."),
```

That registry powers real-time auto-completion, a searchable "Add Setting" picker, and inline field documentation—eliminating second-guessing over whether it's `IdentityFile`

or `IdentityKey`

, or whether `IdentitiesOnly`

accepts a file path.

The editor engine is completely lossless. Comments, blank lines, and custom indentation formatting stay untouched. Only the modified directive gets rewritten on save. This proved vital: my SSH configuration contains years of inline comments explaining obscure host settings, and a tool that reformats or strips comments on save is a tool I would use exactly once.

`ssh`

The most interesting architectural constraint came from Apple's App Store guidelines. Sandboxed macOS applications cannot arbitrarily spawn the system `/usr/bin/ssh`

binary. As a result, all SSH tunnels are opened in-process via `swift-nio-ssh`

inside `NIOTunnelEngine.swift`

—there is no `ssh`

subprocess anywhere in the execution path.

Implementing this required more effort than the rest of the app combined. All three forwarding modes operate over the same connection model:

`-L`

spawns a local listener bound to a `direct-tcpip`

channel at a fixed target.`-D`

drives that same channel type via a dynamic SOCKS5 proxy listener.`-R`

operates in reverse: the engine requests `tcpip-forward`

from the server, mapping each returned `forwarded-tcpip`

channel to a local port.`ProxyJump`

directives are recursively resolved into an ordered chain of hops prior to connecting. Each jump host inherits its explicit configuration (`User`

, `IdentityFile`

, nested `ProxyJump`

) exactly as OpenSSH would evaluate it. Host keys are verified against `known_hosts`

, utilizing trust-on-first-use (TOFU) for previously unseen hosts.

To achieve complete parity, two missing capabilities had to be added to `swift-nio-ssh`

:

`swift-nio-ssh`

does not ship with RSA key support by default. `NIOSSHRSA`

is registered as a custom key handler at startup so `ssh-rsa`

identities from files or SSH agents can be offered during handshakes.Reconnection logic relies on a deterministic exponential backoff implementation in `TunnelBackoff.swift`

(1s, 2s, 5s, 15s, capped with ±20% jitter). Keeping this logic in a standalone pure type allows the backoff schedule to be fully unit-tested without needing a live network server.

There are explicit trade-offs worth noting:

`ProxyCommand`

is rejected outright because executing arbitrary subprocesses is restricted inside the app sandbox.`ControlMaster`

multiplexing directives are parsed and validated but not executed, as there is no local subprocess to multiplex across.For edge cases where running the native binary is necessary, the app generates and copies the exact command string to your clipboard:

```
ssh -N -T -o ControlPath=none -L 3000:localhost:3000 prod-1
```

By passing the host alias rather than the resolved IP address, `ssh`

performs its own resolution, guaranteeing identical behavior to running the command manually in your shell.

A visual interface provides clear operational advantages. Having a clear dashboard showing active tunnels, flapping connections, resolved identity keys, and exact error diagnostics is well worth the screen real estate compared to querying terminal commands individually.

One of the most useful features turned out to be the built-in `known_hosts`

auditor. Operating via static analysis without network side-effects, it flags key issues automatically:

`~/.ssh/config`

.Hashed entries are ignored during orphan checks since hostnames cannot be reversed, and revoked key entries are preserved intentionally. While these findings aren't dramatic individually, together they transform `known_hosts`

from an ever-growing junk drawer into a file you actually understand and maintain.

Building this was my first experience submitting to the Mac App Store. The process was equal parts rewarding and tedious. Sandboxing constraints forced the creation of a custom in-process tunnel engine—a case where platform limitations ultimately led to a cleaner, more robust architecture than shelling out to binaries.

The one-time Apple Developer fee is small, and I preferred pricing the app simply so it sustains its own maintenance cost without needing a subscription model. It is a tool built out of personal necessity, and it remains valuable regardless of how many people use it.

If you deal with locked-down server architecture and a fragile SSH config, the easiest piece to adopt right away is the `known_hosts`

audit pattern. Spotting malformed, duplicate, or orphaned lines is simple to implement, and it immediately cleans up technical debt most developers overlook.
