# 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: **M2** — core data path works end-to-end with the official CLI, and tunnels get publicly trusted certificates through ACME DNS-01 (Let's Encrypt + Porkbun) with automatic renewal. The built-in local CA remains the default when no DNS provider is configured. ## 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 | | `-admin-token` | `ADMIN_TOKEN` | *(empty)* | bearer token for the admin dashboard; empty disables the admin API | | `-dns-provider` | `DNS_PROVIDER` | *(auto)* | `porkbun` or `challtestsrv`; defaults to Porkbun when its keys are set | | `-porkbun-api-key` | `PORKBUN_API_KEY` | *(empty)* | Porkbun API key for DNS-01 records | | `-porkbun-secret-api-key` | `PORKBUN_SECRET_API_KEY` | *(empty)* | Porkbun secret API key | | `-dns-zone` | `DNS_ZONE` | derived from `-domain` | zone holding the challenge records (`tzgyn.dev` for `tunnel.tzgyn.dev`) | | `-acme-directory` | `ACME_DIRECTORY_URL` | Let's Encrypt production | ACME directory URL (use staging for tests) | | `-acme-email` | `ACME_EMAIL` | *(empty)* | contact email registered with the ACME account | | `-acme-propagation-seconds` | `ACME_PROPAGATION_TIMEOUT_SECONDS` | `60` | how long to wait for challenge TXT records; `0` skips the check | | `-acme-ca-bundle` | `ACME_CA_BUNDLE` | *(empty)* | extra roots for the ACME server itself (Pebble) | | `-challtestsrv-url` | `CHALLTESTSRV_URL` | `http://127.0.0.1:8055` | pebble-challtestsrv management URL (dev only) | ## Certificates Certificates have two modes, selected at startup: - **Local CA** (default): signs instantly and needs no network. Clients must trust `/ca.pem` (`--ca-cert` / `OPENTUNNEL_CA_CERT`), and visitors see a certificate warning. - **ACME DNS-01** (Porkbun): when `PORKBUN_API_KEY` and `PORKBUN_SECRET_API_KEY` are set (or `DNS_PROVIDER=porkbun`), tunneld orders a per-tunnel certificate from Let's Encrypt covering `.` and, when the CSR asks for it, `*..`. The client keeps its private key: it sends a CSR and tunneld returns the certificate for that key. Issuance is asynchronous — the bind answers `issuing`, and the client polls until `ready` or `failed` (typically 20–60 seconds). ### Porkbun setup 1. Porkbun → **Account → API Access** → create an API key and secret key. Restrict the key to the zone when Porkbun offers it. 2. **Domain Management → tzgyn.dev → Details** → enable **API access** for the domain (or turn on **Opt In All Domains**). Without this, reads fail with `API_ACCESS_DISABLED`. 3. Set on the server (for example in Coolify's environment variables): `PORKBUN_API_KEY`, `PORKBUN_SECRET_API_KEY`. 4. Redeploy. Startup logs show `dns: Porkbun zone tzgyn.dev` and `certificate issuer: ACME https://acme-v02.api.letsencrypt.org/directory`. 5. Set `DNS_ZONE` only if the derived zone is wrong. For `-domain tunnel.tzgyn.dev`, the derived zone is `tzgyn.dev`. DNS-01 uses `_acme-challenge..`; the hostname and its wildcard share that name, so an attempt creates up to two TXT values there. Records are deleted when the attempt ends. The ACME account key is persisted in the data file, so restarts reuse the same account. Certificates are renewed once they have less than 30 days left, checked every 6 hours; a failed renewal keeps the old certificate serving and is retried. Switching a running server from the local CA to ACME re-issues every existing tunnel automatically, keeping the old certificate in service until the new one is ready. For testing against Let's Encrypt staging (untrusted certificates, separate rate limits), set `ACME_DIRECTORY_URL=https://acme-staging-v02.api.letsencrypt.org/directory`. The official CLI checks the certificate every 12 hours (every minute within 30 days of expiry) and applies a renewed certificate to new connections; after switching the server's issuer, restart the CLI service (`opentunnel down` / `opentunnel up`) to fetch the new certificate immediately. ## Admin dashboard With `ADMIN_TOKEN` set, `https:///` (and `/admin`) serves the admin dashboard: server uptime and version, CA expiry, tunnels with state/routes/cert expiry, and attached bridges with client version and live connection counts. It is a Svelte 5 + TypeScript app (pure CSS, no component libraries) in `web/`, built with bun and embedded into the binary; it polls `GET /api/admin/overview` (`Authorization: Bearer `) every 5 seconds and stores the token in `localStorage`. Without `ADMIN_TOKEN` the endpoint answers 503 and the page is inert; with a wrong token, 401. ```bash cd web && bun install bun run build # writes internal/dashboard/dist (gitignored), embedded by go:embed bun run check # svelte-check ``` The Dockerfile builds the frontend in a bun stage before the Go build. ## 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), and, for real certificates, `PORKBUN_API_KEY` + `PORKBUN_SECRET_API_KEY` (see [Certificates](#certificates)). - 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`. After any app update or recreation, confirm the TCP labels are still in `custom_labels`: Coolify has dropped them when regenerating labels, leaving Traefik to terminate tunnel SNI with its default certificate. - 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. Without ACME credentials, 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 Porkbun configured, every tunnel gets a publicly trusted Let's Encrypt certificate and no client or visitor trust setup is needed. ## What works (M1 + M2) - 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, asynchronous issuance states (`issuing`/`ready`/`failed`), the local CA, and ACME DNS-01 through Porkbun - Per-tunnel Let's Encrypt certificates (`.` + optional wildcard) that keep the client's key, with automatic renewal 30 days before expiry and retries on failure - 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 - Admin dashboard at `/` (bearer `ADMIN_TOKEN`): tunnels, bridges, cert state/expiry, issuer, CA expiry - Persistence: JSON store with the CA, ACME account, server certificate, and tunnels - Graceful shutdown: `drain` + close to every bridge ## Not yet (later milestones) - Issuance rate limits, CA cool-down, stale-DNS-record sweeps - Metrics, more 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.