Files
tunnel/README.md
T
TZGyn 94bdce6809 Track cmd/tunneld; scope gitignore to the built binary
The bare 'tunneld' ignore pattern also matched the cmd/tunneld
directory, so the server's main.go was never committed and the
Coolify build failed on COPY cmd. Also update deploy docs for the
Coolify/Traefik topology (API via HTTPS router, tunnel traffic via
TCP SNI passthrough).
2026-10-25 14:36:32 +08:00

117 lines
5.0 KiB
Markdown

# 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.<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):
```bash
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. 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
- 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.