TZGyn 4dc3d14a06 Add Dockerfile for Coolify deployment
Static non-root alpine image; tunneld must own host ports 443/80
directly since visitor TLS is SNI-routed and never proxy-terminated.
2026-10-25 13:53:58 +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

Deploy on Coolify (or any Docker host)

The repo has a multi-stage Dockerfile producing a static, non-root alpine image.

  1. Create the app from this repo (Dockerfile build pack).
  2. Environment: set OPENTUNNEL_DOMAIN to your domain, e.g. tunnel.example.com.
  3. DNS: point tunnel.example.com and *.tunnel.example.com (A/AAAA records) at the host.
  4. Ports: map host 443 → container 8443 and host 80 → container 8080. Do not put the app behind Coolify's proxy: visitors' TLS is routed by SNI and terminated by the clients, so a proxy that terminates TLS cannot serve it — tunneld itself must own ports 443/80.
  5. Storage: add a persistent volume on /app/data (the CA, server certificate, and tunnel records live there). A named volume works out of the box; a bind mount must be owned by uid 1000.

Until M2 lands, certificates come from the server's built-in local CA: clients need --ca-cert /app/data/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

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%