Architecture
How SSHKit is structured, how it owns libssh state, how cancellation tears connections down, and the rules that hold across releases.
Three layers
CLibSSH C Vendored libssh + OpenSSL backend + zlib.
Public headers in Sources/CLibSSH/include.
|
SSHKitObjC Obj-C Public API (SSHKit* prefix) + internal core (SSHCore* prefix).
Owns ssh_session, socket fd, worker queue, cancellation token.
|
SSHKit Swift async/await + callback API, typed errors, Sendable value types.
All three products are dynamic libraries (.library(... type: .dynamic ...)) so the libssh LGPL boundary stays an explicit, swappable boundary for applications. The Swift module is the primary surface; the Obj-C module is callable from Obj-C applications directly.
Ownership invariants
These are load-bearing rules. Permanent — not subject to change between releases.
- One TCP socket owns one libssh
ssh_session. The fd is created by SSHKit and passed to libssh throughSSH_OPTIONS_FD.SSHCoreSocketHandlemediates cross-thread fd state. - One
SSHConnectionruns at most one active high-level job at a time. The job is exactly one of: collected command (execute), streamed command (openCommand), PTY shell (openShell), SFTP subsystem (openSFTP), or tunnel (openDirectTCPChannel,start*Forward). - Concurrent SSH work requires multiple
SSHConnectioninstances. SSHKit does not multiplex channels over one session in the public model.
Threading
| Object | Queue | Socket | libssh |
|---|---|---|---|
SSHClient | none | none | none |
SSHConnection | none | none | Swift wrapper |
SSHKitConnection | none | none | Obj-C wrapper |
SSHCoreSessionWorker | one serial queue | one fd | one ssh_session |
SSHShell / SSHCommand / SFTPClient / SSHTunnelChannel | worker queue | none | one channel via worker |
Every SSHConnection owns one SSHCoreSessionWorker. That worker has one serial dispatch queue. GCD schedules the queue onto a system thread when work is active — blocking libssh calls can occupy that worker thread until completion or cancellation.
Rules:
- All libssh calls run on the worker queue.
- Mutable operation state lives on the worker queue.
- Public methods may be called from any thread.
- Public methods either enqueue work or request cancellation.
- Immutable request objects cross thread boundaries.
- Obj-C objects copy configuration values before enqueueing work.
Cancellation & close
SSHKit uses blocking libssh I/O. To unblock a libssh call from another thread it shuts down the socket beneath libssh:
- The caller invokes
cancel()orclose()from any thread (Swift Task cancellation funnels through the same path). - The public object marks the operation as cancelled or closing.
SSHCoreSocketHandle.shutdownNow()callsshutdown(fd, SHUT_RDWR).- Any blocking connect/read/write inside libssh wakes up.
- The worker queue maps the wake-up to cancellation or close.
- The worker queue performs libssh cleanup.
- The worker queue closes the fd through
takeFileDescriptorForClose().
This is a full-connection teardown. Cancelling a single SFTP read or a single tunnel write tears the whole connection down — the underlying socket is gone and any other channel on the same session goes with it. To run the next operation, open a fresh SSHConnection.
The caller thread may call shutdownNow(); only the worker queue ever calls close(fd). This avoids fd-reuse hazards. shutdown(fd, SHUT_RDWR) may report EBADF, ENOTCONN, or similar already-closed states on the cancellation path — SSHKit records those as debug diagnostics and continues cleanup.
Connection state machine
idle
-> connecting
-> ready
-> runningCommand
-> ready
-> runningShell
-> ready
-> runningSFTP
-> ready
-> runningTunnel
-> ready
-> closing
-> closed
Only one running* state is active at any time. close() is idempotent. Other invalid transitions are programmer errors.
| Current state | Call | Result |
|---|---|---|
connecting | execute | programmer failure |
runningShell | openSFTP | programmer failure |
closing | execute | programmer failure |
closed | close | idempotent success |
closed | openShell | programmer failure in debug, typed error in release |
Fail-loud policy
Caller misuse is a programming bug and SSHKit makes it loud during development.
- Debug builds. Invalid object state crashes. Swift uses
preconditionFailure; Obj-C usesNSParameterAssert,NSAssert, orNSInvalidArgumentException. Impossible libssh states crash too. - Release builds. Runtime failures surface as typed errors on throwing or callback APIs (
SSHKitError/NSError). Closed-object use surfaces as a typed error rather than a crash.close()stays idempotent.
Runtime failures — network loss, authentication rejection, host-key mismatch, server disconnect, protocol errors, SFTP status mappings — are always typed errors, never crashes. Crashes are reserved for the things you can fix in your own code. Command non-zero exit is not an error: execute returns an SSHCommandResult with the exit status; inspect it.
Host trust storage
Host trust is explicit and injectable. SSHHostKeyPolicy covers the four ways to validate the server’s key:
.knownHostsFile(path)— OpenSSH-format file..trustStore(any SSHHostTrustStore)— arbitrary store implementation..pinnedFingerprint(SSHHostKeyFingerprint)— SHA-256 match against the value you ship..insecureAcceptAnyHostKey— explicit, emits a warning log event, exposes the server fingerprint on the returned connection.
Two trust stores ship with the package — SSHMemoryHostTrustStore and SSHKeychainHostTrustStore. The Keychain store keys entries by lowercase host:port and defaults to service name wiki.qaq.sshkit. Applications may inject any conforming SSHHostTrustStore.
Algorithm profiles
SSHAlgorithmProfile wraps libssh’s comma-separated algorithm lists.
.modernkeeps libssh defaults and sets RSA minimum to 3072 bits..legacyRSAopts back intossh-rsahost-key + public-key algorithms and lowers RSA minimum to 1024 bits.- Custom profiles pass lists through libssh and may use the OpenSSH-compatible
+,-, and^modifiers (add, remove, prepend).
libssh rejects unsupported algorithm names during session configuration and algorithm inspection; SSHKit exposes those failures as typed errors. publicKeyAcceptedAlgorithms inherits libssh’s host-key defaults when the profile leaves it unset. libssh exposes minimumRSAKeySize as a set-only option, so the inspection snapshot reports the configured value when a profile supplies one and leaves it absent otherwise. The set of accepted algorithms is bounded by what the vendored libssh + OpenSSL build supports; endpoints that need algorithms outside that set require server-side changes or a custom libssh build.
Permanent architecture rules
These rules will not change between SSHKit releases:
- One TCP socket owns one libssh
ssh_session. - One
SSHConnectionruns one active high-level job at a time. - Concurrent high-level jobs use multiple
SSHConnectioninstances. - All libssh calls run on the connection worker queue.
- Public cancellation calls socket shutdown immediately.
- The worker queue owns libssh cleanup and fd close.
- Caller misuse fails loudly in debug.
- Runtime failures surface as typed Swift errors and
NSErrorvalues. - Trust storage is dependency-injectable.
- The default Keychain trust store uses service
wiki.qaq.sshkit.