tunnelcat-sdk docs
Docs / Architecture

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.

Layer 1 · in-process

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.

Layer 2 · local IPC

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).

Layer 3 · the actual VPN wire protocol

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.