{"slug": "why-i-built-an-ssh-config-and-tunnel-manager-for-macos", "title": "Why I Built an SSH Config and Tunnel Manager for macOS", "summary": "A developer built SSH Config Manager, a native macOS app that edits ~/.ssh/config without wrecking formatting, saves tunnels as presets, and opens tunnels in-process. The app embeds a keyword catalog of 95 entries to help users avoid misspelled directives, and the developer chose SSH over VPN for its simplicity and universal standardization.", "body_md": "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`\n\nfrom memory four times a day across three different machines.\n\nSo one weekend, I started writing [SSH Config Manager](https://www.sshmanager.app). It is a native macOS app that edits `~/.ssh/config`\n\nwithout wrecking formatting, saves tunnels as presets, and opens those tunnels in-process instead of shelling out to `ssh`\n\n. 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.\n\nThe 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.\n\nI have configured `sshd`\n\nenough times to know what `PermitRootLogin no`\n\nand `PasswordAuthentication no`\n\nactually 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.\n\nThe 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.\n\nThe obvious fix for long commands is a set of shell functions. In practice, that fell apart for me.\n\nI 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.\n\nThe configuration file itself is the piece that is already portable and universally standardized. Everything reads it out of the box: `ssh`\n\n, `scp`\n\n, `rsync -e ssh`\n\n, and my editor's remote development plugins. Building a tool around `~/.ssh/config`\n\nrather than shell scripts is the core design decision everything else flows from.\n\nAnother friction point is that standard text editors treat `~/.ssh/config`\n\nas 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)`\n\nman page in a separate terminal window, which is exactly where you don't want to be toggling back and forth while editing.\n\nTo fix this, the app embeds a comprehensive keyword catalog. `KeywordRegistry.swift`\n\ncurrently 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)`\n\n:\n\n```\n.init(\n    canonical: \"IdentityFile\", field: .path, category: .identity,\n    help: \"Private key file for public-key authentication. May be repeated.\"),\n.init(\n    canonical: \"ProxyJump\", field: .string, category: .connection,\n    help: \"Connect through one or more jump hosts, e.g. user@bastion:22.\"),\n```\n\nThat registry powers real-time auto-completion, a searchable \"Add Setting\" picker, and inline field documentation—eliminating second-guessing over whether it's `IdentityFile`\n\nor `IdentityKey`\n\n, or whether `IdentitiesOnly`\n\naccepts a file path.\n\nThe 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.\n\n`ssh`\n\nThe most interesting architectural constraint came from Apple's App Store guidelines. Sandboxed macOS applications cannot arbitrarily spawn the system `/usr/bin/ssh`\n\nbinary. As a result, all SSH tunnels are opened in-process via `swift-nio-ssh`\n\ninside `NIOTunnelEngine.swift`\n\n—there is no `ssh`\n\nsubprocess anywhere in the execution path.\n\nImplementing this required more effort than the rest of the app combined. All three forwarding modes operate over the same connection model:\n\n`-L`\n\nspawns a local listener bound to a `direct-tcpip`\n\nchannel at a fixed target.`-D`\n\ndrives that same channel type via a dynamic SOCKS5 proxy listener.`-R`\n\noperates in reverse: the engine requests `tcpip-forward`\n\nfrom the server, mapping each returned `forwarded-tcpip`\n\nchannel to a local port.`ProxyJump`\n\ndirectives are recursively resolved into an ordered chain of hops prior to connecting. Each jump host inherits its explicit configuration (`User`\n\n, `IdentityFile`\n\n, nested `ProxyJump`\n\n) exactly as OpenSSH would evaluate it. Host keys are verified against `known_hosts`\n\n, utilizing trust-on-first-use (TOFU) for previously unseen hosts.\n\nTo achieve complete parity, two missing capabilities had to be added to `swift-nio-ssh`\n\n:\n\n`swift-nio-ssh`\n\ndoes not ship with RSA key support by default. `NIOSSHRSA`\n\nis registered as a custom key handler at startup so `ssh-rsa`\n\nidentities from files or SSH agents can be offered during handshakes.Reconnection logic relies on a deterministic exponential backoff implementation in `TunnelBackoff.swift`\n\n(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.\n\nThere are explicit trade-offs worth noting:\n\n`ProxyCommand`\n\nis rejected outright because executing arbitrary subprocesses is restricted inside the app sandbox.`ControlMaster`\n\nmultiplexing 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:\n\n```\nssh -N -T -o ControlPath=none -L 3000:localhost:3000 prod-1\n```\n\nBy passing the host alias rather than the resolved IP address, `ssh`\n\nperforms its own resolution, guaranteeing identical behavior to running the command manually in your shell.\n\nA 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.\n\nOne of the most useful features turned out to be the built-in `known_hosts`\n\nauditor. Operating via static analysis without network side-effects, it flags key issues automatically:\n\n`~/.ssh/config`\n\n.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`\n\nfrom an ever-growing junk drawer into a file you actually understand and maintain.\n\nBuilding 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.\n\nThe 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.\n\nIf you deal with locked-down server architecture and a fragile SSH config, the easiest piece to adopt right away is the `known_hosts`\n\naudit pattern. Spotting malformed, duplicate, or orphaned lines is simple to implement, and it immediately cleans up technical debt most developers overlook.", "url": "https://wpnews.pro/news/why-i-built-an-ssh-config-and-tunnel-manager-for-macos", "canonical_source": "https://dev.to/malusev998/why-i-built-an-ssh-config-and-tunnel-manager-for-macos-44lj", "published_at": "2026-08-14 17:40:27+00:00", "updated_at": "2026-08-14 18:06:17.604251+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["SSH Config Manager", "macOS", "App Store", "ScyllaDB", "MacBook Pro", "Linux"], "alternates": {"html": "https://wpnews.pro/news/why-i-built-an-ssh-config-and-tunnel-manager-for-macos", "markdown": "https://wpnews.pro/news/why-i-built-an-ssh-config-and-tunnel-manager-for-macos.md", "text": "https://wpnews.pro/news/why-i-built-an-ssh-config-and-tunnel-manager-for-macos.txt", "jsonld": "https://wpnews.pro/news/why-i-built-an-ssh-config-and-tunnel-manager-for-macos.jsonld"}}