TZGyn bc52af33cf Rebuild the admin dashboard as a Svelte 5 + TypeScript app
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.
2026-10-25 16:34:21 +08:00

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 / (bearer ADMIN_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 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.

S
Description
Self-hosted OpenTunnel-compatible server in Go
Readme
324 KiB
tunnel 0.1.0
Latest
2026-10-25 22:40:52 +00:00
Languages
Go 94%
Svelte 3.1%
TypeScript 1.4%
CSS 0.9%
Dockerfile 0.5%
Other 0.1%