September 20, 2026
Why SwiftServer ships its own SSH engine
Traversio is the open-source Swift SSH2 library underneath SwiftServer. What we replaced, what it implements, what was hard, and what it measures.

Every screen in SwiftServer sits on an SSH connection. The terminal, the dashboard, the Docker panel, SFTP, port forwarding, jump hosts, and proxies all reach the server the same way. For a long time we reached it through a Swift wrapper over libssh2. Today that layer is Traversio, our own SSH2 client library written in Swift, open source and usable outside the app.
The adapter told us what was wrong
The clearest signal was the shape of our own glue code.
SwiftServer offers password authentication with a fallback into keyboard-interactive, because many servers advertise one and accept the other. Under the old engine, that meant building several client configurations, connecting with the first, catching a stored lastError, and reconnecting with the next. Loading a private key meant probing the stored PEM text ourselves: try the Ed25519 loader, then RSA, then ECDSA, and keep a branch for the unsupported case.
All of that is a wrapper's control flow leaking into an app. A C engine assumes it owns the process and exits when the operation ends. SwiftServer is long-lived, driven by a UI, and has to cancel work when a user closes a window. Session handles, channel handles, and retry-oriented control flow do not map onto Swift task ownership.
When Traversio took ordered authenticationMethods on both SSHClientConfiguration and SSHProxyJumpHost, and .openSSHPrivateKey(...) learned to detect the key type from the PEM container, we deleted the retry loop, the local key-type probe, and the multiple-configuration builder. One configuration per machine, one userauth connection, one ordered candidate list. That deleted code is the argument for owning the engine.
What it implements
Key exchange: curve25519-sha256, [email protected], and ecdh-sha2-nistp256 / 384 / 521, with the ext-info-c and [email protected] markers on the initial exchange. Host keys: ssh-ed25519, ecdsa-sha2-nistp256, rsa-sha2-512, rsa-sha2-256, plus the OpenSSH certificate variants of the first two. Ciphers: aes128-ctr, aes256-ctr, [email protected], [email protected], and [email protected]. MACs: HMAC-SHA-2 and OpenSSH UMAC, in classic and encrypt-then-MAC forms.
Legacy ssh-rsa is off. A caller can enable SHA-1 RSA for one connection or one jump-host hop when a specific server still needs it, and that switch is separate from which private-key file formats the loader accepts.
Authentication covers password with password-change callbacks, keyboard-interactive, public key from Ed25519, ECDSA, and RSA SHA-2 material, callback-backed signing, and SSH agent-backed signing through SSHAgentClient. Key loading handles OpenSSH private keys including bcrypt plus AES encrypted ones, OpenSSL-style PEM containers, and encrypted traditional RSA PEM. It also generates key pairs with matching authorized-key output.
Above the transport: exec, streamed exec, named subsystems, PTY shells with environment requests, resize, and exit-signal reporting. SFTP metadata, listing, file handles, mutations, recursive transfers, resumable transfers, and progress callbacks. Single-file SCP helpers. Raw direct-tcpip and [email protected] channels, local forwarding, dynamic SOCKS forwarding, remote TCP and streamlocal listeners, SOCKS5 and HTTP CONNECT connection proxies, and ProxyJump.
Structure
The package is one Swift target with a small C target beside it, split into Transport, Wire, Algorithms, Authentication, Connection, Forwarding, SFTP, TransportProtocol, and Client.
Mutable protocol state lives in actors. SSHTransportProtocolClient is an actor and owns version exchange, key exchange, the move into encrypted packet flow, authentication sequencing, and channel lifecycle. SFTP is an actor that routes replies back to waiters by request ID inside one subsystem session, so metadata reads and handle reads on one client can overlap without a second subsystem. Forwarding listeners keep their connection tasks in small private actors.
Everything below that is value types, so it can be tested without a network. Algorithm negotiation is a struct:
// SSHClientKeyExchangePreferences.default
keyExchangeAlgorithms: [
"curve25519-sha256",
"[email protected]",
"ecdh-sha2-nistp256",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp521",
"ext-info-c",
"[email protected]",
]The markers sit in the same list as the algorithms because that is how they travel on the wire, and a later KEXINIT for a rekey filters them back out.
The transport itself is an abstraction. Traversio picks the concrete backend at runtime: the newer Network.framework API family on Apple platform release 26 and later, where a structured scope owns deterministic teardown, and a compatibility backend on older releases. The wire, auth, channel, SFTP, and forwarding layers never see an Apple networking type.
Crypto reuses Apple's primitives where the construction matches. CryptoKit handles Curve25519, Ed25519, ECDSA, and AES-GCM. It does not have OpenSSH's [email protected], which encrypts the packet length with a separate key, so that cipher, UMAC, and bcrypt_pbkdf are small C shims in TraversioCCrypto. The packet layer treats the three protection shapes uniformly:
private enum SSHPacketProtection {
case cipherAndMAC(cipher: SSHAESCTRCryptor, mac: SSHMessageAuthenticator)
case aesGCM(cipher: SSHAESGCMCryptor)
case chaChaPoly1305(cipher: SSHChaChaPoly1305Cryptor)
}The hard part was ownership
Key exchange is well specified and testable against fixtures. Routes are not.
A SwiftServer connection can go through an HTTP CONNECT proxy, then two jump hosts, then a direct-tcpip channel, then the final SSH connection. Each of those is a resource with a lifetime, and every bug we hit in that area came from two owners believing they owned the same edge.
The fix was to make the route a graph with one explicit owner per edge and child-before-parent teardown. A plan resolves where the first socket actually goes before anything connects:
private static func rootEndpoint(
finalHost: String,
finalPort: UInt16,
connectionProxy: SSHConnectionProxy?,
proxyJumpHosts: [SSHProxyJumpHost]
) -> SSHSocketEndpoint {
if let connectionProxy {
return connectionProxy.endpoint
}
if let firstHop = proxyJumpHosts.first {
return SSHSocketEndpoint(host: firstHop.host, port: firstHop.port)
}
return SSHSocketEndpoint(host: finalHost, port: finalPort)
}Two bugs show the class of problem.
Stopping a local forward in SwiftServer used to fail. The terminal, SFTP, and Docker close paths were fine. Our forwarding context was closing the SSHConnection at the same time Traversio's forwarding scope was unwinding it. The app now cancels only its own task and lets the library's scope close the connection once.
Local and dynamic forwards asked for a fixed port on localhost and silently got an assigned port. Both listener paths built the required local endpoint with NWEndpoint.Host("localhost"), a name-based endpoint that Network.framework listeners cannot bind. The fix normalizes localhost to numeric loopback at the bind boundary only. Reported host strings and remote targets are unchanged, and there are now live fixed-port tests on both listener backends.
What we measure
The deterministic suite runs 726 tests at the current release tip, plus a warnings-as-errors build, a strict-concurrency build, and a public API check. Beyond that we keep live gates against real OpenSSH servers: an algorithm gate, a close-leak gate, a network-lifecycle gate, a rekey and compression gate, and two soak runs.
The clearest number came from an SFTP download that felt slow on a long route. Against a real OpenSSH server at 269.951 ms RTT, downloads sat at 1.815 to 1.892 MB/s. SwiftServer was already asking for 64 KiB chunks with a 64-request window, so the limit sat below both the app and SFTP, in the SSH channel credit bound: a 1 MiB receive window, plus a policy of waiting for half the window to drain before returning credit. OpenSSH's source readjusts once slightly more than three maximum packets have depleted the window.
Matching OpenSSH's 2 MiB channel window and its credit cadence moved the same transfer to 5.597 to 5.634 MB/s. An 8 MiB window was about 5.5% faster and cost four times the per-channel credit bound, so we kept 2 MiB. A matched low-RTT run of 512 MiB three times per variant showed no regression from the smaller window: 126.65 MiB/s against 117.25 MiB/s for 8 MiB. CPU stayed at 12 to 15% and peak resident memory at roughly 30 to 33 MB on the high-latency path.
Mosh is a separate package
Mosh in SwiftServer 2.1.0 does not run through Traversio's protocol core. Mosh is UDP with its own state synchronization protocol, so it stays outside the SSH library. It lives in TraversioMosh, a separate Swift package under the MIT license, which owns the UDP transport, the state protocol, retransmission and heartbeats, AES-128-OCB, and terminal state. Traversio handles the SSH side: authenticate, honor the jump chain, run mosh-server new, and hand over the key and port. TraversioMosh has its own suite of 554 tests across 26 suites and a real-server compatibility matrix.
What SwiftServer got out of it
Port forwarding with local, remote, and dynamic SOCKS rules. Jump hosts as real multi-hop chains, where a machine that jumps through A, and A through C, resolves to the route C then A before the final connection. Connection proxies over SOCKS5 and HTTP CONNECT. Host-key fingerprints shown before you trust them, backed by trust-on-first-use, pinning, and known_hosts parsing.
Failures read better too. Partway through the migration, terminal and SFTP errors still arrived as bridged text like Traversio.SSHClientError error 3. They now carry typed diagnostics, including the server's authentication banner and password-change language tag. The app shows a compact summary in status text and a full report in the connect log.
The dashboard's latency reading used to open a short-lived TCP probe on a timer. It now reads the round-trip time the live SSH connection already measured from its own service-request traffic. On one live host, ICMP averaged 93.062 ms and Traversio reported 91.664 to 94.988 ms over the connection that was already open.
Traversio is AGPL-3.0-or-later, with a separate commercial license from GitSwift LLC. The source, the deterministic tests, and the protocol documentation are at github.com/GitSwiftHQ/Traversio, and the API docs are at traversio.org.
Questions about any of this: [email protected]
Related posts
July 17, 2026
SwiftServer 2.1.0: Windows Monitoring, Mosh, and a Rebuilt Docker Panel
SwiftServer 2.1.0 brings Windows server monitoring, Mosh terminal sessions, and a rebuilt Docker panel, plus a refreshed global map, IPv6 support, and a faster, steadier dashboard.
April 22, 2025
Major Update - Powerful Docker Management Comes to SwiftServer 1.1.0
Manage Docker containers, images, volumes, and compose files directly from your iOS and macOS devices with the latest SwiftServer 1.1.0 update.