Files
tunnel/README.md
T
TZGyn 682a341010 M1: single-node OpenTunnel-compatible server
HTTP API per the recorded contract, bridge WebSocket, SNI relay with
pass-through tunnel TLS, local CA issuance, JSON store. Verified
end-to-end with the official opentunnel CLI.
2026-10-25 11:52:45 +08:00

3.4 KiB

tunnel

A single-node, self-hosted OpenTunnel-compatible server in Go: blind TLS tunnels where the server routes by SNI and never sees plaintext or private keys. It speaks the exact wire protocol from docs/protocol.md, so the official opentunnel CLI and SDK work against it unchanged.

Status: M1 — core data path works end-to-end with the official CLI, using the built-in local CA for certificates.

Quick start (local development)

go build -o .local/tunneld ./cmd/tunneld
.local/tunneld -domain localhost
# tls: listening on 127.0.0.1:8443, http: listening on 127.0.0.1:8080 (mode serve)

In another terminal, with the official CLI (npm i -g opentunnel or brew install anomalyco/tap/opentunnel):

python3 -m http.server 4096 &                      # something to expose
export OPENTUNNEL_API=http://localhost:8080
opentunnel route add 4096 --name api
# https://api.<id>.localhost

The tunnel's TLS is terminated by the CLI with a certificate from the server's local CA; the CA certificate is written next to the data file (ca.pem). Test the visitor side with SNI (macOS curl skips SNI on *.localhost names, so use openssl s_client or Homebrew curl):

printf 'GET / HTTP/1.1\r\nHost: api.<id>.localhost\r\nConnection: close\r\n\r\n' \
  | openssl s_client -connect 127.0.0.1:8443 -servername api.<id>.localhost \
      -CAfile ~/.local/share/tunneld/ca.pem -quiet

Configuration

Every flag also reads an environment variable (tunneld -h):

Flag Env Default
-domain OPENTUNNEL_DOMAIN localhost API domain; tunnels are <id>.<domain>
-tls-listen TLS_LISTEN 127.0.0.1:8443 public SNI router
-http-listen HTTP_LISTEN 127.0.0.1:8080 off disables
-http-mode HTTP_MODE serve serve = plain-HTTP app (dev); redirect = 301 to the domain
-data TUNNELD_DATA $XDG_DATA_HOME/tunneld/data.json tunnel store (0600)
-ca-file LOCAL_CA_FILE <data dir>/ca.pem where the local CA cert is written

What works (M1)

  • The full HTTP API contract (paths, status codes, bodies, key order) as recorded in the reference's worker-contract.json
  • Provisioning: tunnel creation, CSR bind with the reference's hostname rules, certificates issued at once by the built-in local CA
  • The bridge WebSocket: attach/attached, route claiming with conflicts, heartbeats, idle retirement, open/end/reset, 32 KiB data frames
  • SNI routing on the TLS listener: the domain is terminated (API), tunnel hostnames are passed through still encrypted
  • Persistence: JSON store with the CA, server certificate, and tunnels
  • Graceful shutdown: drain + close to every bridge

Not yet (later milestones)

  • Real ACME issuance (DNS-01) and renewal jobs — currently local CA only
  • Rate limits, metrics, admin endpoints
  • Multi-region mesh, inbound PROXY protocol on the TLS listener
  • 60 s half-close timeout, per-connection flow control, control messages cutting ahead of queued data (single ordered outbound queue today)
  • Go client/CLI — for now use the official opentunnel CLI

Development

go build ./...
go test ./...   # includes the shared spec vectors from .reference/opentunnel

The reference implementation lives in .reference/ (untracked); see .reference/opentunnel.md for what we take from it and what we skip.