I run SSH tunnels constantly. ssh -L is easy until you have a dozen of them and can't remember which host and port goes with which. I wanted a small menu bar app that remembers them for me. Everything I found was either subscription-priced or free but clunky and neglected, so I built my own. The first version took an evening of vibe coding with Claude Code. It was simple, just a wrapper around /usr/bin/ssh.
At this point I thought to myself, why not put it up on the App Store, should be easy right? It was not.
Why not just run ssh as a subprocess?
It worked fine, but the Mac App Store requires apps to follow the App Sandbox rules, and launching the system /usr/bin/ssh as a subprocess isn't allowed under them. I looked at three ways around that:
- Bundle my own
sshbinary. I could keep the subprocess approach, but a bundledsshstill couldn't read~/.ssh/config. I'd have been maintaining a parallel set of configs plus a copy of OpenSSH, so there wouldn't have been much point. - Write a helper. A helper is a separate, unsandboxed executable the app installs alongside itself and talks to over XPC (basically a local RPC call) so the helper can do things the sandbox blocks, like run
sshdirectly. I considered it and decided against it: it's a workaround at best, a pain to build and maintain, and it would have required a separate install outside of the App Store. - Go native Swift. Drop the subprocess and speak SSH from inside the app, so it runs entirely within the sandbox.
I went with the third. Citadel on top of SwiftNIO SSH was a good starting point: each server is a native SSHClient connection, and each tunnel is a NIO channel listener. It also turned out to be better than a subprocess. The app sees real connection state and auth errors instead of parsing ssh output, which made auto-reconnect straightforward to write myself. It retries every 10 seconds up to a limit you set, and it doesn't retry on auth failures.
Citadel does the heavy lifting: the SSH transport, password and key authentication, OpenSSH private key parsing (including passphrase-encrypted keys), and the channels that carry the forwarded traffic. It's a young library, though, and it only covers part of what OpenSSH does. It doesn't honor ssh_config directives, talk to an agent, or follow jump hosts, so anything beyond a direct connection with a password or key file is something I'd have to build myself. And what it does support had gaps I had to fix.
The sandbox has other restrictions as well:
- No automatic
~/.ssh/config. The sandbox only lets the app read files you explicitly pick, no path-based access, so rather than add a one-off file-chooser just for this, there's a paste-in importer instead. It pulls out each host's name, port, user and key path and ignores the rest of the directives. - Key files go through a file picker. Access is stored as security-scoped bookmarks, so there's no default key lookup.
- No SSH agent. The sandbox blocks the agent's Unix socket:
connect()fails with EPERM, even for the built-in macOS agent.
So the app supports password auth and ed25519, RSA and ECDSA key files. RSA signs with rsa-sha2-256, since OpenSSH 8.8 and later disable plain ssh-rsa (RSA with SHA-1) by default. Getting there meant forking both libraries: swift-nio-ssh needed a way for a key to name its signature algorithm, so RSA could use SHA-2, and Citadel needed the matching RSA signing fix plus ECDSA (P-256, P-384 and P-521) support in its OpenSSH key parser.
Export compliance was the worst part
Shazbot "contains encryption," even though it only does what SSH already does. That meant export compliance paperwork for multiple countries. App Store Connect required it for France too, but it was never clear what needed to be filled out, so I gave up on that one. Sorry, France.
What's next
I'm planning per-server hotkeys and notifications for dropped connections, and I'd like to keep it from growing much beyond that. I'd rather it do one job cleanly. If you try it and something breaks, let me know.