# tunnel A single-node, self-hosted [OpenTunnel](https://github.com/anomalyco/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) ```bash 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`): ```bash python3 -m http.server 4096 & # something to expose export OPENTUNNEL_API=http://localhost:8080 opentunnel route add 4096 --name api # https://api..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): ```bash printf 'GET / HTTP/1.1\r\nHost: api..localhost\r\nConnection: close\r\n\r\n' \ | openssl s_client -connect 127.0.0.1:8443 -servername api..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 `.` | | `-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` | `/ca.pem` | where the local CA cert is written | ## Deploy on Coolify (or any Docker host) The repo has a multi-stage `Dockerfile` producing a static, non-root `alpine` image. Two topologies work; both need DNS for `tunnel.example.com` **and** `*.tunnel.example.com` pointing at the host. **A. Coolify-managed host (Traefik already owns 443/80).** The API domain is served through Traefik (Let's Encrypt); tunnel traffic is passed through untouched by SNI, because visitor TLS is terminated by the clients: - App from this repo, Dockerfile build pack, private repo → deploy key. - Domain `https://tunnel.example.com`; `ports_exposes=8080` (Traefik routes HTTPS → the plain-HTTP API port). - Env: `OPENTUNNEL_DOMAIN=tunnel.example.com`, `HTTP_MODE=serve` (the API sits behind Traefik; it must not redirect to HTTPS itself). - Custom Traefik labels (Coolify stores them base64-encoded): `traefik.tcp.routers.tunnel-data.entrypoints=https`, `...rule=HostSNIRegexp(`^.+\.tunnel\.example\.com$`)`, `...tls.passthrough=true`, `traefik.tcp.services.tunnel-data.loadbalancer.server.port=8443`. - Persistent volume on `/app/data`; healthcheck on `/health`. **B. Plain Docker host.** tunneld owns the public ports itself: map host `443` → container `8443` and `80` → container `8080` (`HTTP_MODE=redirect` serves `/health` on 80 and 301s the rest), volume on `/app/data`. No TLS-terminating proxy may sit in front of the tunnel ports. Until M2 lands, certificates come from the server's built-in local CA: clients need `--ca-cert /ca.pem` (or `OPENTUNNEL_CA_CERT`), and visitors must trust that CA. With real ACME issuance (M2) everything is publicly trusted. ## 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 ```bash 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.