Architecture
Three protocol layers stack on top of each other between your app and the VPN server. Knowing which layer you're touching answers most "why does this work this way" questions before they come up.
Your app ↔ SDK language binding
Normal in-process function calls — TunnelClient.connect(...), .status(),
.disconnect(). This is the only layer most consumers ever need to think about. Identical
shape in all five languages: see Python, Kotlin,
C#, or Swift.
SDK binding ↔ tunneld subprocess
Newline-delimited JSON-RPC over a local Unix domain socket. One tunneld process per tunnel
session — the binding spawns it, waits for its socket file to appear, and drives it. Full contract:
IPC Protocol. iOS skips this layer entirely (see below).
tunneld ↔ tunnelcat-server control/exit node
HTTPS POST requests carrying ChaCha20-Poly1305-encrypted frames, disguised behind two decoy-looking API
paths. This is what tunnelcat-server's Authenticator/TunnelDialer
actually speak on the wire. Full contract: Wire Protocol.
Why the split?
Layer 2 exists so that four of the five language adapters (Python, Kotlin, C#, Swift-macOS) don't need to
reimplement crypto, session handling, or SOCKS5 proxying in every target language. Instead they reuse
tunnelcat-server's own Go implementation — compiled once as cmd/tunneld — and only need
to speak a small JSON-RPC contract to it. No tunnel logic lives in any SDK adapter; every one of
them is, in their own doc comments' words, "purely a process-and-socket wrapper."
The iOS exception
iOS sandboxing forbids spawning subprocesses, so cmd/lib-ios exists as a completely different
mechanism: it's the same Go code compiled as a cgo -buildmode=c-archive and linked directly into your
app or extension target. There is no local socket and no separate process boundary — Layer 2 disappears entirely,
and your Swift code calls straight into the linked library's C ABI (TCStart, TCStop,
TCGetStatus, ...), which itself implements Layer 3 internally. This also means iOS is
synchronous call-and-poll rather than event-driven — there's nothing to push
state-change events across once there's no process boundary to push them
over. See Swift · iOS for the full build steps.
Why does the wire protocol look like ordinary web traffic?
Layer 3's data plane deliberately POSTs encrypted frames to paths like /api/media/upload and
/api/content/submit, chosen at random per request — not because those endpoints do anything special,
but to make tunnel traffic harder to distinguish from ordinary web application traffic on the wire. See
Wire Protocol · Data plane for the exact frame format this
protects.
One process per session
tunneld is deliberately not a long-running multi-session daemon: spawn a new process, with
a new socket path, per tunnel session. If your app needs to manage a session's lifecycle (start on user
login, stop on logout, restart on network change), that's a decision your app makes by starting and stopping a
TunnelClient instance — the SDK doesn't impose a lifecycle model on top of connect/disconnect.
What's out of scope
This SDK's surface is strictly single-session connect / status / disconnect / reconnect against
a control server address you already have. There is no server-list or server-picker API here — if your product
needs one, that lives in a separate control-plane API (tunnelcat-server's own manifest/discovery endpoints, signed
with Ed25519 and verified via the exported core.VerifySignedPayload helper — see
Wire Protocol · Manifest signing), not in this SDK.