Two SSH Stacks for One Login: Shellby's Backend Choice
A Shellby postmortem: iOS has no system SSH library. Pure Swift or C — I walked both roads.
Shellby establishes SSH connections from one Swift codebase across iPhone, iPad, and macOS. Sounds simple, but the first roadblock is: iOS has no system SSH library.
This post is about a less-than-mainstream decision: I built two SSH backends for the same “log into a server” capability — one pure Swift (Citadel, on SwiftNIO SSH), one C library (libssh2). Why two, how the upper UI stays agnostic about which one it’s using, and how it finally converged.
The problem: C libraries and Swift concurrency don’t get along
The most battle-tested choice for an SSH client is a C library like libssh2. But using it in a cross-iOS Swift app is awkward in two places:
- Cross-platform means building your own xcframework — libssh2 is C, so to use it on iOS you cross-compile it yourself, package it into an xcframework, and manage every architecture.
- It doesn’t fit Swift concurrency — libssh2 is a blocking C API, while Shellby is Swift concurrency through and through: terminal output is an
AsyncStream<Data>, and the UI only talks to async interfaces. Stuffing a blocking library into an async world means building your own bridge.
Hence another option: Citadel, a pure-Swift SSH implementation (built on SwiftNIO SSH). Pure Swift means it compiles and tests directly on iOS with no xcframework, and it’s natively NIO’s event-loop model, closer to async.
The decision: Citadel as primary, libssh2 as reference
The final architecture decision: primary backend is Citadel — compiles and tests uniformly across all three platforms (including iOS), with SFTP, port forwarding, and PTY all present. libssh2 stays as a macOS fallback and reference implementation. The rejected option was the “pure libssh2 + xcframework” road.
But there’s a process worth mentioning: early on, both backends were built. First libssh2 got the whole thing working end to end — connecting to a real sshd, handshake, fingerprint verification, public-key auth, running commands, PTY all working, proving the entire path was correct; then the cross-platform Citadel backend was added, finally reaching “the iOS app connects to a real server in the simulator, no longer needing libssh2’s xcframework.”
Use the most mature library to get the path working and verify correctness, then swap in the implementation better suited to the platform — this isn’t waste, it’s decoupling two questions (“is the path correct” and “which library”) and solving them one at a time. Now all three apps run Citadel; libssh2 lingers only in a command-line tool.
The abstraction: the upper layer knows a protocol, not a library
Two backends can coexist and swap because of a pure protocol layer in the middle (called SSHCore). It has zero UI dependencies and doesn’t leak NIO’s details — SSHClient, SSHSession, ShellChannel, SFTPClient, Forward are all protocols, data flows as AsyncStream, and errors unify into a single SSHError.
Each backend implements the same set of protocols: one CitadelSSHClient, one Libssh2Client. The app depends only on the SSHClient protocol plus a factory function — switching backends changes one line in the factory. That’s the value of layered decoupling: the upper layer always talks to “a thing that can do SSH,” not to a specific library.
This dependency rule is hard: the domain layer (those Sources/* packages) is forbidden from importing any UI framework, and dependencies only point downward. The benefit is the core layer can be unit-tested independently of the app — SSHCore’s tests cover PTY defaults, config defaults, error localization, and such; while end-to-end real-connection verification uses a standalone command-line tool to hit a real sshd and run the full handshake/auth/exec/PTY/SFTP/forwarding suite.
The bridge: dragging blocking C into async
Keeping libssh2 means solving that “blocking C API stuffed into async” problem. The approach: confine all libssh2 calls to a dedicated serial queue (shellby.ssh.io.<host>), and use withCheckedContinuation plus select/EAGAIN polling to bridge the blocking calls into async interfaces. Citadel doesn’t have this problem — it’s an event-loop model to begin with, NIO’s details are hidden below the SSHCore protocol, and the UI sees only clean async.
A small language-mode tradeoff: early on the whole project used Swift 5 language mode, specifically to lower the cost of migrating libssh2’s C interop under strict concurrency. To accommodate that C backend, the whole package’s concurrency dial was turned back a notch.
The two backends aren’t equal
Keeping a dual backend means honestly facing their unequal capabilities. The libssh2 backend has a few gaps:
- No ProxyJump multi-hop — it just throws “jump host is in milestone 3”;
- No support for raw Ed25519 private keys generated in-app — it only takes OpenSSH PEM format.
These gaps are exactly why Citadel is the primary backend: the primary has to be fully featured, a fallback/reference implementation is allowed shortcomings. Conversely Citadel has a cost — it requires macOS 15+ (withPTY / streaming execution depend on it), which is the price of choosing it.
One capability detail worth noting: Citadel’s streaming execution doesn’t expose the exit code, so the path that needs an exit code currently approximates with 0/1 — the “the underlying library doesn’t give you what you want” trap that every project depending on third-party libraries can’t dodge. What you can do is mark it clearly, so the upper layer doesn’t assume it got a precise value.
Why both backends align on OpenSSH fingerprints
There’s one place the two backends are deliberately made identical: the host fingerprint algorithm.
Whether Citadel or libssh2, the host fingerprint is computed to the same format — serialize the public key, SHA256, base64 with trailing padding stripped, prefixed with SHA256:. That’s exactly the format OpenSSH’s command line shows.
Why be so exacting? Because the fingerprint is shown to the user, who takes it to compare against somewhere else (say, ssh-keygen -l output on the server). If Shellby’s fingerprint format differed from OpenSSH’s, the user couldn’t compare, and the whole “trust on first use” mechanism would be worthless. The two backends have different implementations, but the fingerprint presented to the user must be byte-for-byte identical — a user-facing contract can’t change just because the internals swapped implementations.
Takeaways
Building two implementations for one capability sounds extravagant, but in hindsight it’s a good deal:
- Get it working with a mature library first, then swap in the fitting implementation — decouple “path correctness” from “library choice,” solve them one at a time; not waste;
- The protocol layer is the prerequisite for swappability — the upper layer knows only the
SSHClientprotocol plus a factory, swapping a backend is one line; the domain layer forbids UI dependencies so the core can be unit-tested off the app; - Dual backends aren’t equal, be honest — capability gaps, platform floors, library limits (no exit code) — mark them clearly, don’t let the upper layer step into a void;
- User-facing contracts must be consistent across implementations — the two backends use different algorithms, but the fingerprint shown to the user must be identical.
Shellby’s three apps all run Citadel now; libssh2 rests quietly in a command-line tool as a reference. But that “build both” road wasn’t wasted — it convinced me the path was correct, and left me a second implementation to check against anytime.
Comments