Frontend in web/ (Vite via bun, pure CSS, no component libraries), built to internal/dashboard/dist and embedded into the binary; the Dockerfile builds it in a bun stage. Old static dashboard.html removed.
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 |
-admin-token |
ADMIN_TOKEN |
(empty) | bearer token for the admin dashboard; empty disables the admin API |
Admin dashboard
With ADMIN_TOKEN set, https://<domain>/ (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 <ADMIN_TOKEN>) 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.
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). - 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 <data dir>/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
- Admin dashboard at
/(bearerADMIN_TOKEN): tunnels, bridges, CA expiry - 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
opentunnelCLI
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.