Expose a local TCP/HTTP service to the internet through a peer-to-peer WebRTC DataChannel, with a relay server for signaling and a built-in TURN fallback when NAT traversal is impossible.
- P2P NAT traversal via STUN/TURN — ICE negotiates a direct peer-to-peer path through most NATs; when direct connectivity is impossible, the relay's built-in TURN server carries the encrypted stream as a last resort.
- End-to-end Noise encryption — a Noise XX handshake with the tunnel token as prologue negotiates X25519 keys and encrypts every payload with ChaCha20-Poly1305. The relay is a pure signaling broker and never possesses session keys.
- Zero-config, single static binary —
CGO_ENABLED=0builds, no runtime dependencies, no account, no API key. - Termux-friendly ARM64 — ChaCha20-Poly1305 + X25519 run fast without AES-NI, so phones and ARM SBCs are first-class citizens.
- Multiplexed streams — many simultaneous TCP connections ride one DataChannel over a small wire frame:
[type:1][stream:4][payload].
Build both binaries (client + relay) for your host platform:
make buildor cross-compile for all supported platforms (linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, windows/amd64):
make build-allRun a relay on a public server (port 8080 serves both the WebSocket signaling endpoint and the TURN listener):
./bin/termtunnel-relay --addr :8080On the device hosting the local service (e.g. Termux), expose it through the tunnel:
./bin/termtunnel serve --local 127.0.0.1:8080 --relay ws://RELAY:8080The relay assigns a tunnel ID and prints a shareable URL. From anywhere else, connect to it:
./bin/termtunnel connect 'http://RELAY:8080/t/<id>#<token>' --local 8080http://RELAY:8080/t/<id> now reaches your local service. The URL fragment is the access token: it never leaves your terminal and never transits the relay.
TermTunnel is honest about its threat model: the relay is honest-but-curious during setup.
- The token never transits the relay — only its SHA-256 digest is sent during
register/join. Guessing the digest from the hash is infeasible; the digest itself is not replayable for anything except opening the tunnel. - The Noise XX handshake uses the token as prologue, binding the handshake transcript to the token. Both peers must hold the raw token to complete the handshake, so a man-in-the-middle (including the relay) cannot forge a session.
- After signaling, the relay only ever sees ChaCha20-Poly1305 ciphertext — it cannot decrypt payloads even if it behaves maliciously, unless it captures the raw token at join time and actively impersonates one peer.
- Future work: static-key pinning, so hosts can authenticate guests with a pinned long-term key instead of relying on token secrecy alone.
sequenceDiagram
participant G as Guest (browser/CLI)
participant R as Relay (signaling + TURN)
participant H as Host (Termux)
participant S as Local service
G->>R: join (tunnel id, SHA-256(token))
R->>H: ready (guest online)
H->>G: signal: SDP offer
G->>H: signal: SDP answer
G->>H: signal: ICE candidates (trickled)
H->>G: signal: ICE candidates
Note over G,H: ICE: direct P2P or TURN-relayed path
G->>H: DataChannel open
G->>H: Noise XX handshake (token prologue)
G->>H: hello "tt/1"
H->>G: hello "tt/1"
G->>H: open (stream 1)
H->>S: dial local service
H->>G: data (encrypted frames)
G->>H: data (encrypted frames)
BenchmarkMultiplexerThroughput-8 721 3161365 ns/op 331.68 MB/s
Measured with an in-memory transport: no Noise encryption, DTLS, or network in the path, 1 MiB echoed per iteration through the multiplexer. Real WebRTC/DTLS throughput is lower and varies with CPU, NAT, and network conditions — treat this number as an upper bound for the multiplexing layer, not a field guarantee.
- Multi-guest tunnels (many guests on one host).
- Public-edge mode: relay-hosted edge for guests without WebRTC support.
- Static-key pinning for host-guest authentication.
- WebRTC vs QUIC evaluation for the data transport.
relay.termtunnel.dev is a placeholder default, not a running service. Point --relay at a relay you control.