commit 682a34101038506d11d5cdd5b5d49f02ba99117f Author: TZGyn Date: Sun Oct 25 11:52:45 2026 +0800 M1: single-node OpenTunnel-compatible server HTTP API per the recorded contract, bridge WebSocket, SNI relay with pass-through tunnel TLS, local CA issuance, JSON store. Verified end-to-end with the official opentunnel CLI. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..113fbdf --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +tunneld +.local/ +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8e87838 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,63 @@ +# AGENTS.md + +Single-node, self-hosted OpenTunnel-compatible server in Go. The wire +protocol and API contract are **the reference's**, not ours to invent: + +- Source of truth: `.reference/opentunnel/docs/protocol.md` +- API contract: `.reference/opentunnel/crates/opentunnel-server/tests/fixtures/worker-contract.json` + (paths, status codes, bodies, JSON key order — installed clients depend on it) +- Conformance vectors: `.reference/opentunnel/spec/vectors/*.json`, run by + `internal/server/*_test.go` (the reference checkout must exist; tests skip + otherwise) + +`.reference/` is untracked research input (see `.reference/opentunnel.md` for +what we take vs. skip). Never vendor it into the build. + +## Layout + +- `cmd/tunneld` — flags/env wiring, listeners, signal handling +- `internal/server/proto.go` — wire types (pinned JSON key order), IDs/tokens + (same generation as the reference: 12-char base32 slugs, `rly_` tokens, + hex SHA-256 token hashes), route validation, data frames +- `internal/server/clienthello.go` — TLS ClientHello parser (SNI + ALPN) that + reads exact bytes only, so the connection can be replayed or forwarded +- `internal/server/localca.go` — the built-in insecure CA (the reference's + `ISSUER=local` equivalent); signs tunnel CSRs after checking CN == hostname + and SANs ⊆ {hostname, `*.hostname`} +- `internal/server/store.go` — JSON-file store (0600, atomic rename); single + writer, self-hosted scale +- `internal/server/api.go` — HTTP API per the contract; tunnel existence is + checked before auth (404 before 401), body JSON syntax before everything +- `internal/server/bridge.go` — WebSocket bridge sessions: attach, route + claiming (one owner per route), heartbeats, idle retirement, visitor pumps +- `internal/server/relay.go` — public TLS listener: parses the ClientHello, + terminates the domain itself, passes tunnel hostnames to bridges untouched + +## Commands + +- `go build ./...` / `go vet ./...` / `gofmt -l .` — must stay clean +- `go test ./...` +- Dev loop: `go build -o .local/tunneld ./cmd/tunneld && .local/tunneld -domain localhost`, + then `OPENTUNNEL_API=http://localhost:8080 opentunnel route add 4096 --name api` + with the official CLI. `go build` drops a binary next to the source — it is + gitignored; prefer `-o .local/`. + +## Gotchas + +- **macOS system curl (LibreSSL) does not send SNI for `*.localhost` names**, + so `curl https://api..localhost:8443/` gets closed (the relay requires + SNI). Test with `openssl s_client -servername ...` or Homebrew curl. +- JSON key order of API bodies is contractual — always `json.Marshal` structs + with declared field order, never maps. +- Bridge writes go through one ordered `outbox` channel per session. Control + messages therefore can queue behind data — a known M1 deviation from the + reference's deadlock-avoidance; a stalled visitor self-heals via the + client's idle timeout. Revisit with per-connection flow control. +- The bridge WebSocket must use `coder/websocket` with compression disabled + and the `opentunnel` subprotocol; read limits are 64 KiB before attach and + 4+32 KiB after. +- No third-party deps beyond `github.com/coder/websocket`; keep it that way + unless a milestone calls for it. +- Milestones: M1 core data path (done); M2 real ACME DNS-01 + renewals; M3 + rate limits/admin/hardening; M4 maybe a Go client. Don't pull M2+ scope + into M1 fixes. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3fd307c --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# 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..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 | + +## 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. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b1f9746 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module gitea.tzgyn.dev/TZGyn/tunnel + +go 1.27.3 + +require github.com/coder/websocket v1.8.15 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4e0a48d --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= diff --git a/internal/server/api.go b/internal/server/api.go new file mode 100644 index 0000000..80af642 --- /dev/null +++ b/internal/server/api.go @@ -0,0 +1,341 @@ +package server + +import ( + "encoding/json" + "errors" + "io" + "mime" + "net/http" + "strings" +) + +// API is the HTTP control plane. Its answers match the recorded contract in +// worker-contract.json: paths, status codes, bodies, and JSON key order. +type API struct { + Store *Store + Hub *Hub + Domain string +} + +func (a *API) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // The reference tolerates duplicate slashes and a trailing slash. + path := "/" + strings.Join(strings.FieldsFunc(r.URL.Path, func(c rune) bool { return c == '/' }), "/") + + switch { + case path == "/health" && r.Method == http.MethodGet: + w.Header().Set("Content-Type", "application/json;charset=utf-8") + w.Write([]byte(`{"ok":true}`)) + case path == "/openapi.json" && r.Method == http.MethodGet: + writeJSON(w, http.StatusOK, json.RawMessage(openapiStub)) + case path == "/api/tunnel" && r.Method == http.MethodPost: + a.createTunnel(w, r) + default: + a.tunnelRoutes(w, r, strings.Split(strings.TrimPrefix(path, "/"), "/")) + } +} + +// tunnelRoutes routes /api/tunnel/{id}[/certificate|connect]. +func (a *API) tunnelRoutes(w http.ResponseWriter, r *http.Request, parts []string) { + if len(parts) < 2 || parts[0] != "api" || parts[1] != "tunnel" { + notFoundEmpty(w) + return + } + switch len(parts) { + case 3: + id := parts[2] + switch r.Method { + case http.MethodGet, http.MethodHead: + a.getTunnel(w, r, id) + case http.MethodDelete: + a.deleteTunnel(w, r, id) + default: + notFoundEmpty(w) + } + case 4: + id := parts[2] + switch parts[3] { + case "certificate": + switch r.Method { + case http.MethodPost: + a.bindCertificate(w, r, id) + case http.MethodGet, http.MethodHead: + a.getCertificate(w, r, id) + default: + notFoundEmpty(w) + } + case "connect": + if r.Method != http.MethodGet { + notFoundEmpty(w) + return + } + a.connect(w, r, id) + default: + notFoundEmpty(w) + } + default: + notFoundEmpty(w) + } +} + +// ---- POST /api/tunnel + +func (a *API) createTunnel(w http.ResponseWriter, r *http.Request) { + body, ok := readJSONBody(w, r) + if !ok { + return + } + if name, present := body["name"]; present && name != nil { + if _, isString := name.(string); !isString { + w.WriteHeader(http.StatusBadRequest) // wrong type: 400, empty body + return + } + writeJSON(w, http.StatusBadRequest, errInvalidRequest{ + Tag: "InvalidRequestError", + Message: "Custom tunnel names are not supported; omit name to get a random hostname", + }) + return + } + tunnel, token, err := a.Store.createTunnel(a.Domain) + if err != nil { + writeJSON(w, http.StatusServiceUnavailable, errInvalidRequest{ + Tag: "ServiceUnavailableError", Message: "storage unavailable", + }) + return + } + writeJSON(w, http.StatusCreated, createResponse{ + Tunnel: TunnelInfo{ID: tunnel.ID, Hostname: tunnel.Hostname, State: "offline"}, + Token: token, + }) +} + +// ---- GET /api/tunnel/{id} + +func (a *API) getTunnel(w http.ResponseWriter, r *http.Request, id string) { + tunnel, ok := a.lookupTunnel(w, id) + if !ok || !a.authorized(w, r, tunnel) { + return + } + info := TunnelInfo{ID: tunnel.ID, Hostname: tunnel.Hostname, State: "offline"} + if a.Hub.online(tunnel.ID) { + info.State = "online" + } + if tunnel.Certificate != nil { + info.CertificateID = tunnel.Certificate.ID + } + writeJSON(w, http.StatusOK, info) +} + +// ---- DELETE /api/tunnel/{id} + +func (a *API) deleteTunnel(w http.ResponseWriter, r *http.Request, id string) { + tunnel, ok := a.lookupTunnel(w, id) + if !ok || !a.authorized(w, r, tunnel) { + return + } + a.Store.remove(id) + a.Hub.closeTunnel(id) + w.WriteHeader(http.StatusNoContent) +} + +// ---- POST /api/tunnel/{id}/certificate + +func (a *API) bindCertificate(w http.ResponseWriter, r *http.Request, id string) { + body, ok := readJSONBody(w, r) + if !ok { + return + } + tunnel, ok := a.lookupTunnel(w, id) + if !ok || !a.authorized(w, r, tunnel) { + return + } + csrValue, present := body["csr"] + if !present { + w.WriteHeader(http.StatusBadRequest) + return + } + csr, isString := csrValue.(string) + if !isString { + w.WriteHeader(http.StatusBadRequest) + return + } + // Binding the CSR the tunnel already has answers with its current + // certificate; a different one re-issues (with the local CA, at once). + if tunnel.Certificate != nil && tunnel.Certificate.CSR == csr { + a.writeCertificate(w, http.StatusAccepted, tunnel.Certificate) + return + } + ca, err := a.Store.ca() + if err != nil { + writeJSON(w, http.StatusServiceUnavailable, errInvalidRequest{ + Tag: "ServiceUnavailableError", Message: "storage unavailable", + }) + return + } + certPEM, chainPEM, expiry, err := ca.signCSR(csr, tunnel.Hostname) + if err != nil { + var hostErr errCSRHostname + if errors.As(err, &hostErr) { + writeJSON(w, http.StatusBadRequest, errInvalidHostname{ + Tag: "InvalidHostnameError", + Provided: hostErr.provided, + Expected: hostErr.expected, + Message: "CSR hostname does not match tunnel hostname", + }) + return + } + writeJSON(w, http.StatusBadRequest, errInvalidRequest{ + Tag: "InvalidRequestError", Message: "Failed to parse CSR", + }) + return + } + cert := storedCert{ + ID: certID(), + CSR: csr, + Certificate: string(certPEM), + Chain: string(chainPEM), + Expiry: expiryString(expiry), + } + if err := a.Store.bindCertificate(id, cert); err != nil { + notFoundEmpty(w) + return + } + // The contract's bind response is 202 with state "issuing"; with the local + // CA the very next GET already answers "ready". + writeJSON(w, http.StatusAccepted, CertificateInfo{ID: cert.ID, State: CertState{Type: "issuing"}}) +} + +// ---- GET /api/tunnel/{id}/certificate + +func (a *API) getCertificate(w http.ResponseWriter, r *http.Request, id string) { + tunnel, ok := a.lookupTunnel(w, id) + if !ok || !a.authorized(w, r, tunnel) { + return + } + if tunnel.Certificate == nil { + writeJSON(w, http.StatusNotFound, errTaggedID{ + Tag: "CertificateNotFoundError", TunnelID: id, Message: "Certificate not found", + }) + return + } + a.writeCertificate(w, http.StatusOK, tunnel.Certificate) +} + +func (a *API) writeCertificate(w http.ResponseWriter, status int, cert *storedCert) { + writeJSON(w, status, CertificateInfo{ + ID: cert.ID, + State: CertState{ + Type: "ready", + Certificate: cert.Certificate, + Chain: cert.Chain, + Expiry: cert.Expiry, + }, + }) +} + +// ---- GET /api/tunnel/{id}/connect + +func (a *API) connect(w http.ResponseWriter, r *http.Request, id string) { + tunnel, ok := a.Store.get(id) + if !ok { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("Tunnel not found")) + return + } + if !upgradeRequested(r) { + w.WriteHeader(http.StatusUpgradeRequired) + w.Write([]byte("Expected WebSocket upgrade")) + return + } + if !subprotocolOffered(r) { + w.WriteHeader(http.StatusUpgradeRequired) + w.Write([]byte("Expected opentunnel WebSocket subprotocol")) + return + } + a.Hub.attach(w, r, tunnel) +} + +// ---- helpers + +// lookupTunnel answers 404 TunnelNotFoundError when id is unknown. Existence +// is checked before authorization, as the contract records. +func (a *API) lookupTunnel(w http.ResponseWriter, id string) (storedTunnel, bool) { + tunnel, ok := a.Store.get(id) + if !ok { + writeJSON(w, http.StatusNotFound, errTaggedID{ + Tag: "TunnelNotFoundError", TunnelID: id, Message: "Tunnel not found", + }) + return tunnel, false + } + return tunnel, true +} + +// authorized answers 401 UnauthorizedError unless the request carries the +// tunnel's bearer token (the scheme is case-insensitive). +func (a *API) authorized(w http.ResponseWriter, r *http.Request, tunnel storedTunnel) bool { + scheme, token, _ := strings.Cut(r.Header.Get("Authorization"), " ") + if strings.EqualFold(scheme, "bearer") && token != "" && tokenMatches(token, tunnel.TokenHash) { + return true + } + writeJSON(w, http.StatusUnauthorized, errUnauthorized{ + Tag: "UnauthorizedError", Message: "Invalid bearer token", + }) + return false +} + +// readJSONBody enforces the contract's body rules: a non-JSON content type is +// 415, a missing, empty, or malformed body is 400 with an empty body. +func readJSONBody(w http.ResponseWriter, r *http.Request) (map[string]any, bool) { + if ct := r.Header.Get("Content-Type"); ct != "" { + mediaType, _, err := mime.ParseMediaType(ct) + if err != nil || !strings.EqualFold(mediaType, "application/json") { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusUnsupportedMediaType) + w.Write([]byte("Unsupported content-type: " + ct)) + return nil, false + } + } + raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) + if err != nil || len(raw) == 0 { + w.WriteHeader(http.StatusBadRequest) + return nil, false + } + var body map[string]any + if err := json.Unmarshal(raw, &body); err != nil { + w.WriteHeader(http.StatusBadRequest) + return nil, false + } + return body, true +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + raw, err := json.Marshal(body) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + w.Write(raw) +} + +// notFoundEmpty is the 404 with no body the contract records for unknown +// paths and methods. +func notFoundEmpty(w http.ResponseWriter) { + w.WriteHeader(http.StatusNotFound) +} + +const openapiStub = `{"openapi":"3.1.0","info":{"title":"OpenTunnel API","version":"0.1.0","description":"Create and manage blind TLS tunnels."},"paths":{"/api/tunnel":{"post":{"responses":{"201":{"description":"Success"}}}},"/api/tunnel/{id}":{"get":{"responses":{"200":{"description":"Tunnel/Info"}}},"delete":{"responses":{"204":{"description":"Deleted"}}}},"/api/tunnel/{id}/certificate":{"post":{"responses":{"202":{"description":"Accepted"}}},"get":{"responses":{"200":{"description":"Certificate"}}}},"/api/tunnel/{id}/connect":{"get":{"responses":{"101":{"description":"WebSocket upgrade"}}}}}}` + +// redirectHandler is HTTP_MODE=redirect: /health answers, everything else +// permanently redirects to the API domain. +func RedirectHandler(domain string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/health" { + w.Header().Set("Content-Type", "application/json;charset=utf-8") + w.Write([]byte(`{"ok":true}`)) + return + } + target := "https://" + domain + r.URL.RequestURI() + http.Redirect(w, r, target, http.StatusMovedPermanently) + }) +} diff --git a/internal/server/api_test.go b/internal/server/api_test.go new file mode 100644 index 0000000..8a4aa8a --- /dev/null +++ b/internal/server/api_test.go @@ -0,0 +1,276 @@ +package server + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// testAPI returns an API against an in-memory store, on the domain +// "opentunnel.test" like the recorded contract. +func testAPI(t *testing.T) (*API, *httptest.Server) { + t.Helper() + store, err := OpenStore("") + if err != nil { + t.Fatal(err) + } + api := &API{Store: store, Hub: NewHub(store), Domain: "opentunnel.test"} + return api, httptest.NewServer(api) +} + +type createdTunnel struct { + ID string + Hostname string + Token string +} + +func createTunnel(t *testing.T, srv *httptest.Server) createdTunnel { + t.Helper() + resp, err := http.Post(srv.URL+"/api/tunnel", "application/json", strings.NewReader("{}")) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != 201 { + t.Fatalf("create: status %d", resp.StatusCode) + } + var body struct { + Tunnel TunnelInfo `json:"tunnel"` + Token string `json:"token"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if len(body.Tunnel.ID) != 12 || body.Tunnel.Hostname != body.Tunnel.ID+".opentunnel.test" { + t.Fatalf("create: odd tunnel %+v", body.Tunnel) + } + if body.Tunnel.State != "offline" { + t.Fatalf("create: state %q", body.Tunnel.State) + } + if !strings.HasPrefix(body.Token, "rly_") { + t.Fatalf("create: token %q", body.Token) + } + return createdTunnel{ID: body.Tunnel.ID, Hostname: body.Tunnel.Hostname, Token: body.Token} +} + +func request(t *testing.T, method, url, token, contentType, body string) *http.Response { + t.Helper() + var reader io.Reader + if body != "" { + reader = strings.NewReader(body) + } + req, err := http.NewRequest(method, url, reader) + if err != nil { + t.Fatal(err) + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + return resp +} + +func bodyString(t *testing.T, resp *http.Response) string { + t.Helper() + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +// makeCSR builds a PEM PKCS#10 CSR for cn with the given SANs, as the CLI does. +func makeCSR(t *testing.T, cn string, sans ...string) string { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + der, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ + Subject: pkix.Name{CommonName: cn}, + DNSNames: sans, + }, key) + if err != nil { + t.Fatal(err) + } + return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: der})) +} + +func TestAPIContract(t *testing.T) { + _, srv := testAPI(t) + defer srv.Close() + base := srv.URL + + // create, and the malformed variants the contract pins + resp := request(t, "POST", base+"/api/tunnel", "", "application/json", "{}") + if resp.StatusCode != 201 { + t.Fatalf("create: %d", resp.StatusCode) + } + resp.Body.Close() + + resp = request(t, "POST", base+"/api/tunnel", "", "application/json", `{"name":"x"}`) + if got := bodyString(t, resp); resp.StatusCode != 400 || + got != `{"_tag":"InvalidRequestError","message":"Custom tunnel names are not supported; omit name to get a random hostname"}` { + t.Errorf("create with name: %d %q", resp.StatusCode, got) + } + resp = request(t, "POST", base+"/api/tunnel", "", "application/json", "{") + if got := bodyString(t, resp); resp.StatusCode != 400 || got != "" { + t.Errorf("create with invalid json: %d %q", resp.StatusCode, got) + } + resp = request(t, "POST", base+"/api/tunnel", "", "text/plain", "{}") + if got := bodyString(t, resp); resp.StatusCode != 415 || got != "Unsupported content-type: text/plain" { + t.Errorf("create with text body: %d %q", resp.StatusCode, got) + } + resp = request(t, "POST", base+"/api/tunnel", "", "application/json", `{"name":null}`) + if resp.StatusCode != 201 { + t.Errorf("create with null name: %d", resp.StatusCode) + } + resp.Body.Close() + + tunnel := createTunnel(t, srv) + + // get: auth behavior and the unknown-tunnel 404 + resp = request(t, "GET", base+"/api/tunnel/"+tunnel.ID, "", "", "") + if got := bodyString(t, resp); resp.StatusCode != 401 || + got != `{"_tag":"UnauthorizedError","message":"Invalid bearer token"}` { + t.Errorf("get without auth: %d %q", resp.StatusCode, got) + } + resp = request(t, "GET", base+"/api/tunnel/zzzzzzzzzzzz", "", "", "") + if got := bodyString(t, resp); resp.StatusCode != 404 || + got != `{"_tag":"TunnelNotFoundError","tunnelID":"zzzzzzzzzzzz","message":"Tunnel not found"}` { + t.Errorf("get unknown: %d %q", resp.StatusCode, got) + } + resp = request(t, "GET", base+"/api/tunnel/"+tunnel.ID, tunnel.Token, "", "") + want := `{"id":"` + tunnel.ID + `","hostname":"` + tunnel.Hostname + `","state":"offline"}` + if got := bodyString(t, resp); resp.StatusCode != 200 || got != want { + t.Errorf("get: %d %q, want %q", resp.StatusCode, got, want) + } + // lowercase bearer, double slash, trailing slash + req, _ := http.NewRequest("GET", base+"/api//tunnel/"+tunnel.ID+"/", nil) + req.Header.Set("Authorization", "bearer "+tunnel.Token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + if got := bodyString(t, resp); resp.StatusCode != 200 || got != want { + t.Errorf("get double slash: %d %q", resp.StatusCode, got) + } + + // certificate flow + resp = request(t, "GET", base+"/api/tunnel/"+tunnel.ID+"/certificate", tunnel.Token, "", "") + if got := bodyString(t, resp); resp.StatusCode != 404 || + got != `{"_tag":"CertificateNotFoundError","tunnelID":"`+tunnel.ID+`","message":"Certificate not found"}` { + t.Errorf("certificate before bind: %d %q", resp.StatusCode, got) + } + resp = request(t, "POST", base+"/api/tunnel/"+tunnel.ID+"/certificate", tunnel.Token, "application/json", `{"csr":"garbage"}`) + if got := bodyString(t, resp); resp.StatusCode != 400 || + got != `{"_tag":"InvalidRequestError","message":"Failed to parse CSR"}` { + t.Errorf("bind garbage: %d %q", resp.StatusCode, got) + } + wrong := makeCSR(t, "other.opentunnel.test", "other.opentunnel.test") + resp = request(t, "POST", base+"/api/tunnel/"+tunnel.ID+"/certificate", tunnel.Token, "application/json", + `{"csr":`+jsonString(wrong)+`}`) + wantWrong := `{"_tag":"InvalidHostnameError","provided":"other.opentunnel.test","expected":"` + + tunnel.Hostname + `","message":"CSR hostname does not match tunnel hostname"}` + if got := bodyString(t, resp); resp.StatusCode != 400 || got != wantWrong { + t.Errorf("bind wrong hostname: %d %q, want %q", resp.StatusCode, got, wantWrong) + } + + csr := makeCSR(t, tunnel.Hostname, tunnel.Hostname, "*."+tunnel.Hostname) + resp = request(t, "POST", base+"/api/tunnel/"+tunnel.ID+"/certificate", tunnel.Token, "application/json", + `{"csr":`+jsonString(csr)+`}`) + body := bodyString(t, resp) + if resp.StatusCode != 202 || !strings.Contains(body, `"type":"issuing"`) || !strings.Contains(body, `"id":"cert_`) { + t.Errorf("bind: %d %q", resp.StatusCode, body) + } + resp = request(t, "GET", base+"/api/tunnel/"+tunnel.ID+"/certificate", tunnel.Token, "", "") + var cert CertificateInfo + if err := json.Unmarshal([]byte(bodyString(t, resp)), &cert); err != nil { + t.Fatal(err) + } + if cert.State.Type != "ready" || cert.State.Certificate == "" || cert.State.Chain == "" || cert.State.Expiry == "" { + t.Errorf("certificate after bind: %+v", cert.State) + } + // rebinding the same CSR answers with the current certificate + resp = request(t, "POST", base+"/api/tunnel/"+tunnel.ID+"/certificate", tunnel.Token, "application/json", + `{"csr":`+jsonString(csr)+`}`) + if got := bodyString(t, resp); resp.StatusCode != 202 || !strings.Contains(got, `"type":"ready"`) { + t.Errorf("rebind: %d %q", resp.StatusCode, got) + } + // get now carries certificateID + resp = request(t, "GET", base+"/api/tunnel/"+tunnel.ID, tunnel.Token, "", "") + if got := bodyString(t, resp); !strings.Contains(got, `"certificateID":"cert_`) { + t.Errorf("get after bind: %q", got) + } + + // connect: the pinned pre-upgrade answers + resp = request(t, "GET", base+"/api/tunnel/"+tunnel.ID+"/connect", "", "", "") + if got := bodyString(t, resp); resp.StatusCode != 426 || got != "Expected WebSocket upgrade" { + t.Errorf("connect without upgrade: %d %q", resp.StatusCode, got) + } + req, _ = http.NewRequest("GET", base+"/api/tunnel/"+tunnel.ID+"/connect", nil) + req.Header.Set("Upgrade", "websocket") + resp, err = http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + if got := bodyString(t, resp); resp.StatusCode != 426 || got != "Expected opentunnel WebSocket subprotocol" { + t.Errorf("connect without subprotocol: %d %q", resp.StatusCode, got) + } + req, _ = http.NewRequest("GET", base+"/api/tunnel/zzzzzzzzzzzz/connect", nil) + req.Header.Set("Upgrade", "websocket") + resp, err = http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + if got := bodyString(t, resp); resp.StatusCode != 404 || got != "Tunnel not found" { + t.Errorf("connect unknown: %d %q", resp.StatusCode, got) + } + + // unknown paths and methods are an empty 404; health answers + resp = request(t, "PUT", base+"/api/tunnel", "", "", "") + if got := bodyString(t, resp); resp.StatusCode != 404 || got != "" { + t.Errorf("put: %d %q", resp.StatusCode, got) + } + resp = request(t, "GET", base+"/api/nope", "", "", "") + if got := bodyString(t, resp); resp.StatusCode != 404 || got != "" { + t.Errorf("unknown path: %d %q", resp.StatusCode, got) + } + resp = request(t, "GET", base+"/health", "", "", "") + if got := bodyString(t, resp); resp.StatusCode != 200 || got != `{"ok":true}` { + t.Errorf("health: %d %q", resp.StatusCode, got) + } + + // delete + resp = request(t, "DELETE", base+"/api/tunnel/"+tunnel.ID, tunnel.Token, "", "") + if resp.StatusCode != 204 { + t.Errorf("delete: %d", resp.StatusCode) + } + resp = request(t, "GET", base+"/api/tunnel/"+tunnel.ID, tunnel.Token, "", "") + if resp.StatusCode != 404 { + t.Errorf("get after delete: %d", resp.StatusCode) + } + resp.Body.Close() +} + +// jsonString encodes s as a JSON string literal. +func jsonString(s string) string { + raw, _ := json.Marshal(s) + return string(raw) +} diff --git a/internal/server/bridge.go b/internal/server/bridge.go new file mode 100644 index 0000000..bcbd893 --- /dev/null +++ b/internal/server/bridge.go @@ -0,0 +1,533 @@ +package server + +import ( + "context" + "encoding/json" + "io" + "log" + "net" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/coder/websocket" +) + +// Hub tracks the attached bridges: which tunnel they belong to and which +// routes each claimed. A route belongs to one bridge at a time. +type Hub struct { + store *Store + + mu sync.Mutex + bridges map[*Bridge]struct{} + byRoute map[string]map[string]*Bridge // tunnel ID -> route -> bridge +} + +func NewHub(store *Store) *Hub { + return &Hub{ + store: store, + bridges: map[*Bridge]struct{}{}, + byRoute: map[string]map[string]*Bridge{}, + } +} + +// online reports whether the tunnel has an attached bridge. +func (h *Hub) online(tunnelID string) bool { + h.mu.Lock() + defer h.mu.Unlock() + for _, b := range h.byRoute[tunnelID] { + _ = b + return true + } + return false +} + +// bridgeFor returns the bridge that claimed route on the tunnel, or nil. +func (h *Hub) bridgeFor(tunnelID, route string) *Bridge { + h.mu.Lock() + defer h.mu.Unlock() + return h.byRoute[tunnelID][route] +} + +// closeTunnel closes every bridge of the tunnel (it was deleted). +func (h *Hub) closeTunnel(tunnelID string) { + h.mu.Lock() + var bridges []*Bridge + for b := range h.bridges { + if b.tunnelID == tunnelID { + bridges = append(bridges, b) + } + } + h.mu.Unlock() + for _, b := range bridges { + b.close(websocket.StatusGoingAway, "tunnel deleted") + } +} + +// drainAll announces drain to every bridge and closes it, so clients +// reconnect elsewhere (used on shutdown). +func (h *Hub) DrainAll() { + h.mu.Lock() + bridges := make([]*Bridge, 0, len(h.bridges)) + for b := range h.bridges { + bridges = append(bridges, b) + } + h.mu.Unlock() + for _, b := range bridges { + drain, _ := json.Marshal(drainMessage{Type: "drain", Reason: "shutdown"}) + b.trySend(websocket.MessageText, drain) + b.close(websocket.StatusServiceRestart, "shutdown") + } +} + +func (h *Hub) register(b *Bridge) bool { + h.mu.Lock() + defer h.mu.Unlock() + claimed := h.byRoute[b.tunnelID] + if claimed == nil { + claimed = map[string]*Bridge{} + h.byRoute[b.tunnelID] = claimed + } + for _, route := range b.routes { + if _, taken := claimed[route]; taken { + return false + } + } + for _, route := range b.routes { + claimed[route] = b + } + h.bridges[b] = struct{}{} + return true +} + +func (h *Hub) unregister(b *Bridge) { + h.mu.Lock() + defer h.mu.Unlock() + delete(h.bridges, b) + if claimed := h.byRoute[b.tunnelID]; claimed != nil { + for route, owner := range claimed { + if owner == b { + delete(claimed, route) + } + } + if len(claimed) == 0 { + delete(h.byRoute, b.tunnelID) + } + } +} + +// Bridge is one attached client session: the WebSocket, its routes, and its +// visitor connections. +type Bridge struct { + hub *Hub + tunnelID string + session string + routes []string + maxConns int + ws *websocket.Conn + + outbox chan outbound + done chan struct{} + closeOnce sync.Once + + mu sync.Mutex + conns map[uint32]*visitorConn + nextConn uint32 + + lastActivity atomic.Int64 +} + +type outbound struct { + typ websocket.MessageType + data []byte +} + +// attach upgrades the HTTP connection and runs the bridge session. It blocks +// until the session ends. +func (h *Hub) attach(w http.ResponseWriter, r *http.Request, tunnel storedTunnel) { + ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + Subprotocols: []string{BridgeSubprotocol}, + CompressionMode: websocket.CompressionDisabled, + OriginPatterns: nil, // CLI and SDK send no Origin header + }) + if err != nil { + return // Accept already replied + } + ws.SetReadLimit(clientHelloLimit) // 64 KiB before attach + + ctx := context.Background() + fail := func(code string) { + msg, _ := json.Marshal(attachErrorMessage{Type: "attach_error", Code: code}) + writeWS(ctx, ws, websocket.MessageText, msg) + ws.Close(websocket.StatusPolicyViolation, code) + } + + // The attach must arrive within 10 seconds. + readCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + typ, raw, err := ws.Read(readCtx) + cancel() + if err != nil { + ws.Close(websocket.StatusPolicyViolation, "attach timeout") + return + } + var attach attachMessage + if typ != websocket.MessageText || json.Unmarshal(raw, &attach) != nil || attach.Type != "attach" { + fail("bad_attach") + return + } + if !tokenMatches(attach.Token, tunnel.TokenHash) { + fail("bad_token") + return + } + if tunnel.Certificate == nil { + fail("cert_not_ready") + return + } + if len(attach.Routes) > maxRoutesPerAttach { + fail("too_many_routes") + return + } + routes := dedupe(attach.Routes) + for _, route := range routes { + if !validRoute(route) { + fail("invalid_route") + return + } + } + + b := &Bridge{ + hub: h, + tunnelID: tunnel.ID, + session: sessionID(), + routes: routes, + maxConns: maxConnsPerBridge, + ws: ws, + outbox: make(chan outbound, 256), + done: make(chan struct{}), + conns: map[uint32]*visitorConn{}, + } + if attach.Client.MaxConns > 0 && attach.Client.MaxConns < b.maxConns { + b.maxConns = attach.Client.MaxConns + } + b.touch() + if !h.register(b) { + fail("route_conflict") + return + } + + attached, _ := json.Marshal(attachedMessage{ + Type: "attached", + Session: b.session, + Routes: routes, + HeartbeatMs: heartbeatMs, + IdleTimeoutMs: idleTimeoutMs, + }) + if err := writeWS(ctx, ws, websocket.MessageText, attached); err != nil { + h.unregister(b) + ws.Close(websocket.StatusInternalError, "write failed") + return + } + ws.SetReadLimit(4 + maxPayload) // a data frame with the largest payload + + go b.writeLoop() + go b.sweeper() + b.readLoop() // blocks until the WebSocket ends + b.close(websocket.StatusGoingAway, "session ended") +} + +// openVisitor hands a new public connection to the bridge: an open control +// message, the ClientHello as the first data, and a pump for what follows. +func (b *Bridge) openVisitor(conn net.Conn, hello []byte, peer, sni, alpn string) { + b.mu.Lock() + if len(b.conns) >= b.maxConns { + b.mu.Unlock() + conn.Close() + return + } + id := b.nextConn + b.nextConn++ + v := &visitorConn{id: id, conn: conn} + b.conns[id] = v + b.mu.Unlock() + + open, _ := json.Marshal(openMessage{ + Type: "open", Conn: id, Peer: peer, SNI: sni, ALPN: alpn, + }) + if !b.send(websocket.MessageText, open) { + b.removeConn(id, true) + return + } + for len(hello) > 0 { + n := min(len(hello), maxPayload) + if !b.send(websocket.MessageBinary, buildFrame(id, hello[:n])) { + b.removeConn(id, true) + return + } + hello = hello[n:] + } + go b.pump(v) +} + +// pump copies visitor bytes to the bridge and half-closes on EOF. +func (b *Bridge) pump(v *visitorConn) { + buf := make([]byte, maxPayload) + for { + n, err := v.conn.Read(buf) + v.touch() + if n > 0 { + if !b.send(websocket.MessageBinary, buildFrame(v.id, buf[:n])) { + return + } + } + if err != nil { + b.mu.Lock() + v.visitorEnded = true + bothEnded := v.clientEnded + stillKnown := b.conns[v.id] == v + b.mu.Unlock() + if !stillKnown { + return // reset or removed already + } + if err == io.EOF { + end, _ := json.Marshal(endMessage{Type: "end", Conn: v.id}) + b.send(websocket.MessageText, end) + } else { + reset, _ := json.Marshal(resetMessage{Type: "reset", Conn: v.id, Code: ResetIOError}) + b.send(websocket.MessageText, reset) + b.removeConn(v.id, true) + return + } + if bothEnded { + b.removeConn(v.id, false) + } + return + } + } +} + +// readLoop applies client control messages and data frames until the +// WebSocket fails. +func (b *Bridge) readLoop() { + ctx := context.Background() + for { + typ, raw, err := b.ws.Read(ctx) + if err != nil { + log.Printf("bridge %s: read: %v", b.session, err) + return + } + b.touch() + if typ == websocket.MessageText { + b.handleControl(raw) + continue + } + connID, payload, ok := parseFrame(raw) + if !ok { + continue + } + b.mu.Lock() + v := b.conns[connID] + b.mu.Unlock() + if v == nil { + continue + } + v.touch() + if _, err := v.conn.Write(payload); err != nil { + b.removeConn(connID, true) + } + } +} + +func (b *Bridge) handleControl(raw []byte) { + msg, err := parseClientControl(raw) + if err != nil { + return + } + switch msg.Type { + case "ping": + pong, _ := json.Marshal(pingPongMessage{Type: "pong", TimeSent: msg.TimeSent}) + b.send(websocket.MessageText, pong) + case "end": + if msg.Conn != nil { + b.endFromClient(*msg.Conn) + } + case "reset": + if msg.Conn != nil { + b.removeConn(*msg.Conn, true) + } + } + // Unknown types and fields are ignored. +} + +func (b *Bridge) endFromClient(connID uint32) { + b.mu.Lock() + v := b.conns[connID] + b.mu.Unlock() + if v == nil { + return + } + b.mu.Lock() + v.clientEnded = true + bothEnded := v.visitorEnded + b.mu.Unlock() + if tcp, ok := v.conn.(*net.TCPConn); ok { + tcp.CloseWrite() + } + if bothEnded { + b.removeConn(connID, false) + } +} + +// writeLoop is the only writer on the WebSocket, keeping frames ordered. +func (b *Bridge) writeLoop() { + ctx := context.Background() + for { + select { + case <-b.done: + return + case msg := <-b.outbox: + if err := writeWS(ctx, b.ws, msg.typ, msg.data); err != nil { + b.close(websocket.StatusInternalError, "write failed") + return + } + } + } +} + +// sweeper retires a silent bridge (idle_timeout + heartbeat) and resets +// visitor connections with no traffic for 15 minutes. +func (b *Bridge) sweeper() { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for { + select { + case <-b.done: + return + case now := <-ticker.C: + if now.UnixMilli()-b.lastActivity.Load() > (idleTimeoutMs + heartbeatMs) { + b.close(websocket.StatusGoingAway, "idle timeout") + return + } + b.mu.Lock() + var stale []uint32 + for id, v := range b.conns { + if now.UnixMilli()-v.lastActivity.Load() > 15*60*1000 { + stale = append(stale, id) + } + } + b.mu.Unlock() + for _, id := range stale { + reset, _ := json.Marshal(resetMessage{Type: "reset", Conn: id, Code: ResetIdleTimeout}) + b.send(websocket.MessageText, reset) + b.removeConn(id, true) + } + } + } +} + +// send queues a frame, applying backpressure when the client is slow. +// It returns false when the bridge is gone. +func (b *Bridge) send(typ websocket.MessageType, data []byte) bool { + select { + case <-b.done: + return false + case b.outbox <- outbound{typ, data}: + return true + } +} + +// trySend queues a frame only if it fits immediately. +func (b *Bridge) trySend(typ websocket.MessageType, data []byte) { + select { + case b.outbox <- outbound{typ, data}: + default: + } +} + +// removeConn closes the visitor socket (abortively when rst) and forgets it. +func (b *Bridge) removeConn(id uint32, rst bool) { + b.mu.Lock() + v := b.conns[id] + delete(b.conns, id) + b.mu.Unlock() + if v == nil { + return + } + if rst { + if tcp, ok := v.conn.(*net.TCPConn); ok { + tcp.SetLinger(0) + } + } + v.conn.Close() +} + +func (b *Bridge) touch() { + b.lastActivity.Store(time.Now().UnixMilli()) +} + +// close ends the session exactly once: conns reset, routes released. +func (b *Bridge) close(status websocket.StatusCode, reason string) { + b.closeOnce.Do(func() { + close(b.done) + b.hub.unregister(b) + b.mu.Lock() + ids := make([]uint32, 0, len(b.conns)) + for id := range b.conns { + ids = append(ids, id) + } + b.mu.Unlock() + for _, id := range ids { + b.removeConn(id, true) + } + b.ws.Close(status, reason) + }) +} + +// visitorConn is one public TCP connection carried over the bridge. +type visitorConn struct { + id uint32 + conn net.Conn + + visitorEnded bool // pump saw EOF: no more visitor data + clientEnded bool // client sent end: target will send no more + + lastActivity atomic.Int64 +} + +func (v *visitorConn) touch() { + v.lastActivity.Store(time.Now().UnixMilli()) +} + +func writeWS(ctx context.Context, ws *websocket.Conn, typ websocket.MessageType, data []byte) error { + writeCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + return ws.Write(writeCtx, typ, data) +} + +func dedupe(routes []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(routes)) + for _, route := range routes { + if _, ok := seen[route]; !ok { + seen[route] = struct{}{} + out = append(out, route) + } + } + return out +} + +// isWebSocketUpgrade reports the shape of the upgrade request, for the 426 +// responses the contract pins. +func upgradeRequested(r *http.Request) bool { + return strings.EqualFold(r.Header.Get("Upgrade"), "websocket") +} + +func subprotocolOffered(r *http.Request) bool { + for _, offered := range strings.Split(r.Header.Get("Sec-Websocket-Protocol"), ",") { + if strings.TrimSpace(offered) == BridgeSubprotocol { + return true + } + } + return false +} diff --git a/internal/server/clienthello.go b/internal/server/clienthello.go new file mode 100644 index 0000000..6fb6531 --- /dev/null +++ b/internal/server/clienthello.go @@ -0,0 +1,153 @@ +package server + +import ( + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "time" +) + +// clientHelloLimit bounds the bytes read while looking for a ClientHello +// (the reference uses 64 KiB and 10 seconds). +const ( + clientHelloLimit = 64 * 1024 + clientHelloTimeout = 10 * time.Second +) + +// readClientHello reads TLS records from conn until the ClientHello is +// complete, without consuming anything beyond it. It returns the exact bytes +// read (for replay or forwarding), the SNI server name, and the offered ALPN +// protocols. Reads are exact (no read-ahead), so conn is handed on intact. +func readClientHello(conn net.Conn) (raw []byte, sni string, alpn []string, err error) { + conn.SetReadDeadline(time.Now().Add(clientHelloTimeout)) + defer conn.SetReadDeadline(time.Time{}) + + var hello []byte // handshake message fragments, starting with the 4-byte header + need := -1 // total ClientHello bytes once the header is known + + for len(raw) < clientHelloLimit { + header := make([]byte, 5) + if _, err = io.ReadFull(conn, header); err != nil { + return nil, "", nil, fmt.Errorf("reading record header: %w", err) + } + if header[0] != 22 { // handshake + return nil, "", nil, fmt.Errorf("expected a handshake record, got %d", header[0]) + } + bodyLen := int(binary.BigEndian.Uint16(header[3:])) + if len(raw)+5+bodyLen > clientHelloLimit { + return nil, "", nil, errors.New("ClientHello exceeds 64 KiB") + } + body := make([]byte, bodyLen) + if _, err = io.ReadFull(conn, body); err != nil { + return nil, "", nil, fmt.Errorf("reading record body: %w", err) + } + raw = append(raw, header...) + raw = append(raw, body...) + hello = append(hello, body...) + + if need < 0 { + if len(hello) < 4 { + continue + } + if hello[0] != 1 { // client_hello + return nil, "", nil, fmt.Errorf("expected ClientHello, got handshake type %d", hello[0]) + } + need = 4 + int(hello[1])<<16 | int(hello[2])<<8 | int(hello[3]) + } + if len(hello) >= need { + sni, alpn = parseClientHello(hello[4:need]) + return raw, sni, alpn, nil + } + } + return nil, "", nil, errors.New("ClientHello exceeds 64 KiB") +} + +// parseClientHello extracts SNI and ALPN from a ClientHello body (everything +// after the 4-byte handshake header). Truncation is an error; a missing +// extension simply yields an empty result, as the relay closes such +// connections anyway. +func parseClientHello(body []byte) (sni string, alpn []string) { + p := parser{data: body} + p.skip(2) // version + p.skip(32) // random + p.skip(int(p.u8())) // session id + p.skip(int(p.u16())) // cipher suites + p.skip(int(p.u8())) // compression methods + extLen := int(p.u16()) // extensions block + if p.err { + return "", nil + } + exts := parser{data: p.take(extLen)} + for !exts.empty() { + typ := exts.u16() + data := exts.take(int(exts.u16())) + if exts.err { + break + } + switch typ { + case 0: // server_name + d := parser{data: data} + list := parser{data: d.take(int(d.u16()))} + for !list.empty() { + nameType := list.u8() + name := list.take(int(list.u16())) + if list.err { + break + } + if nameType == 0 { + sni = string(name) + } + } + case 16: // application_layer_protocol_negotiation + d := parser{data: data} + list := parser{data: d.take(int(d.u16()))} + for !list.empty() { + proto := list.take(int(list.u8())) + if list.err { + break + } + alpn = append(alpn, string(proto)) + } + } + } + return sni, alpn +} + +// parser is a cursor over a byte slice; any out-of-bounds read sets err and +// yields zero values from then on. +type parser struct { + data []byte + err bool +} + +func (p *parser) empty() bool { return p.err || len(p.data) == 0 } + +func (p *parser) take(n int) []byte { + if n < 0 || len(p.data) < n { + p.err = true + return nil + } + out := p.data[:n] + p.data = p.data[n:] + return out +} + +func (p *parser) skip(n int) { p.take(n) } + +func (p *parser) u8() byte { + b := p.take(1) + if b == nil { + return 0 + } + return b[0] +} + +func (p *parser) u16() uint16 { + b := p.take(2) + if b == nil { + return 0 + } + return binary.BigEndian.Uint16(b) +} diff --git a/internal/server/clienthello_test.go b/internal/server/clienthello_test.go new file mode 100644 index 0000000..f4e464d --- /dev/null +++ b/internal/server/clienthello_test.go @@ -0,0 +1,97 @@ +package server + +import ( + "bytes" + "crypto/tls" + "net" + "testing" + "time" +) + +// TestReadClientHello captures a real ClientHello produced by crypto/tls and +// checks the parser and the byte-exact replay capture. +func TestReadClientHello(t *testing.T) { + client, serverConn := net.Pipe() + defer client.Close() + + var written bytes.Buffer + go func() { + // The client writes its ClientHello and then blocks waiting for the + // server; we only need the write side. + tlsClient := tls.Client(captureConn{client, &written}, &tls.Config{ + ServerName: "api.demo.opentunnel.xyz", + NextProtos: []string{"h2", "http/1.1"}, + InsecureSkipVerify: true, + }) + tlsClient.Handshake() // fails: nobody answers + }() + + serverConn.SetReadDeadline(time.Now().Add(5 * time.Second)) + raw, sni, alpn, err := readClientHello(serverConn) + if err != nil { + t.Fatalf("readClientHello: %v", err) + } + if sni != "api.demo.opentunnel.xyz" { + t.Errorf("sni = %q", sni) + } + if len(alpn) != 2 || alpn[0] != "h2" || alpn[1] != "http/1.1" { + t.Errorf("alpn = %v", alpn) + } + if !bytes.HasPrefix(written.Bytes(), raw) { + t.Errorf("captured %d bytes are not a prefix of the %d written", len(raw), written.Len()) + } +} + +// captureConn records everything written to it. +type captureConn struct { + net.Conn + w *bytes.Buffer +} + +func (c captureConn) Write(p []byte) (int, error) { + c.w.Write(p) + return c.Conn.Write(p) +} + +func TestReadClientHelloGarbage(t *testing.T) { + client, serverConn := net.Pipe() + go func() { + client.Write([]byte("not tls at all")) + client.Close() + }() + serverConn.SetReadDeadline(time.Now().Add(2 * time.Second)) + if _, _, _, err := readClientHello(serverConn); err == nil { + t.Error("expected an error for non-TLS bytes") + } +} + +func TestReadClientHelloTrickle(t *testing.T) { + // A ClientHello trickling in byte by byte still parses. + client, serverConn := net.Pipe() + var written bytes.Buffer + go func() { + tlsClient := tls.Client(captureConn{client, &written}, &tls.Config{ + ServerName: "demo.opentunnel.xyz", + InsecureSkipVerify: true, + }) + tlsClient.Handshake() + }() + serverConn.SetReadDeadline(time.Now().Add(5 * time.Second)) + slow := &slowConn{Conn: serverConn} // one-byte reads, a trickling sender + raw, sni, _, err := readClientHello(slow) + if err != nil { + t.Fatalf("readClientHello: %v", err) + } + if sni != "demo.opentunnel.xyz" { + t.Errorf("sni = %q", sni) + } + if !bytes.HasPrefix(written.Bytes(), raw) { + t.Error("capture mismatch") + } +} + +type slowConn struct{ net.Conn } + +func (c *slowConn) Read(p []byte) (int, error) { + return c.Conn.Read(p[:1]) +} diff --git a/internal/server/localca.go b/internal/server/localca.go new file mode 100644 index 0000000..33d0e16 --- /dev/null +++ b/internal/server/localca.go @@ -0,0 +1,174 @@ +package server + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "fmt" + "math/big" + "time" +) + +// localCA is the insecure built-in issuer for development and self-hosting, +// the equivalent of ISSUER=local in the reference server. The CA and every +// certificate it issues use ECDSA P-256, like the ACME-issued ones. +type localCA struct { + key *ecdsa.PrivateKey + cert *x509.Certificate + certPEM []byte +} + +func newLocalCA() (*localCA, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "tunnel local CA"}, + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), + IsCA: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + return nil, err + } + cert, err := x509.ParseCertificate(der) + if err != nil { + return nil, err + } + return &localCA{ + key: key, + cert: cert, + certPEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), + }, nil +} + +func loadLocalCA(keyPEM, certPEM []byte) (*localCA, error) { + keyBlock, _ := pem.Decode(keyPEM) + if keyBlock == nil { + return nil, errors.New("no PEM block in CA key") + } + key, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes) + if err != nil { + return nil, err + } + ecKey, ok := key.(*ecdsa.PrivateKey) + if !ok { + return nil, errors.New("CA key is not ECDSA") + } + certBlock, _ := pem.Decode(certPEM) + if certBlock == nil { + return nil, errors.New("no PEM block in CA certificate") + } + cert, err := x509.ParseCertificate(certBlock.Bytes) + if err != nil { + return nil, err + } + return &localCA{key: ecKey, cert: cert, certPEM: pem.EncodeToMemory(certBlock)}, nil +} + +func (ca *localCA) keyPEM() ([]byte, error) { + der, err := x509.MarshalPKCS8PrivateKey(ca.key) + if err != nil { + return nil, err + } + return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), nil +} + +// issue signs a certificate for names with a fresh P-256 key. Used for the +// server's own API certificate. +func (ca *localCA) issue(names ...string) (certPEM, keyPEM []byte, err error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, nil, err + } + der, err := ca.sign(&x509.Certificate{ + SerialNumber: newSerial(), + Subject: pkix.Name{CommonName: names[0]}, + DNSNames: names, + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(90 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + }, &key.PublicKey) + if err != nil { + return nil, nil, err + } + keyDER, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return nil, nil, err + } + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), + pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), nil +} + +// errCSRHostname reports a CSR whose names are not the tunnel's own. +type errCSRHostname struct { + provided string + expected string +} + +func (e errCSRHostname) Error() string { + return "CSR hostname " + e.provided + " does not match tunnel hostname " + e.expected +} + +// signCSR validates a tunnel CSR and issues the certificate. The rules mirror +// the reference's job runner: the common name must be the tunnel hostname and +// every DNS name must be the hostname or its wildcard. +func (ca *localCA) signCSR(csrPEM, hostname string) (certPEM, chainPEM []byte, expiry time.Time, err error) { + block, _ := pem.Decode([]byte(csrPEM)) + if block == nil || block.Type != "CERTIFICATE REQUEST" { + return nil, nil, time.Time{}, errors.New("not a PEM certificate request") + } + csr, err := x509.ParseCertificateRequest(block.Bytes) + if err != nil { + return nil, nil, time.Time{}, err + } + if err := csr.CheckSignature(); err != nil { + return nil, nil, time.Time{}, fmt.Errorf("CSR signature: %w", err) + } + if csr.Subject.CommonName != hostname { + return nil, nil, time.Time{}, errCSRHostname{provided: csr.Subject.CommonName, expected: hostname} + } + for _, name := range csr.DNSNames { + if name != hostname && name != "*."+hostname { + return nil, nil, time.Time{}, errCSRHostname{provided: name, expected: hostname} + } + } + expiry = time.Now().Add(90 * 24 * time.Hour) + der, err := ca.sign(&x509.Certificate{ + SerialNumber: newSerial(), + Subject: pkix.Name{CommonName: hostname}, + DNSNames: csr.DNSNames, + NotBefore: time.Now().Add(-time.Minute), + NotAfter: expiry, + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + }, csr.PublicKey) + if err != nil { + return nil, nil, time.Time{}, err + } + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), ca.certPEM, expiry, nil +} + +func (ca *localCA) sign(template *x509.Certificate, pub any) ([]byte, error) { + return x509.CreateCertificate(rand.Reader, template, ca.cert, pub, ca.key) +} + +var serialLimit = new(big.Int).Lsh(big.NewInt(1), 128) + +func newSerial() *big.Int { + n, err := rand.Int(rand.Reader, serialLimit) + if err != nil { + panic(err) + } + return n +} diff --git a/internal/server/proto.go b/internal/server/proto.go new file mode 100644 index 0000000..7d23001 --- /dev/null +++ b/internal/server/proto.go @@ -0,0 +1,223 @@ +// Package server implements a single-node OpenTunnel-compatible server: +// the HTTP API, the bridge WebSocket, and SNI-based relaying described in +// docs/protocol.md of the reference implementation. +package server + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "regexp" +) + +// Identifiers are generated exactly as the reference does +// (crates/opentunnel-server/src/crypto.rs): a tunnel ID is 12 random base32 +// characters, a token is "rly_" plus 32 random bytes in base64url, and the +// stored form of a token is the lowercase hex of its SHA-256. + +const slugAlphabet = "abcdefghijklmnopqrstuvwxyz234567" + +func randomBytes(n int) []byte { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return b +} + +func slug() string { + b := randomBytes(12) + for i := range b { + b[i] = slugAlphabet[b[i]&31] + } + return string(b) +} + +func newToken() string { + return "rly_" + base64.RawURLEncoding.EncodeToString(randomBytes(32)) +} + +func hashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +func tokenMatches(token, hash string) bool { + actual := hashToken(token) + return subtle.ConstantTimeCompare([]byte(actual), []byte(hash)) == 1 +} + +func certID() string { return "cert_" + base64.RawURLEncoding.EncodeToString(randomBytes(12)) } +func sessionID() string { return "sess_" + base64.RawURLEncoding.EncodeToString(randomBytes(12)) } + +// Route names: "@" for the tunnel hostname itself, or one DNS label. +var routeLabel = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$`) + +func validRoute(name string) bool { + return name == "@" || routeLabel.MatchString(name) +} + +// ---- HTTP API wire types. Field order matters: the recorded contract +// (worker-contract.json) pins the key order of every body. + +type TunnelInfo struct { + ID string `json:"id"` + Hostname string `json:"hostname"` + State string `json:"state"` // "offline" | "online" + CertificateID string `json:"certificateID,omitempty"` +} + +type createResponse struct { + Tunnel TunnelInfo `json:"tunnel"` + Token string `json:"token"` +} + +type CertState struct { + Type string `json:"type"` + Token string `json:"token,omitempty"` // challenge + Key string `json:"key,omitempty"` // challenge + Certificate string `json:"certificate,omitempty"` // ready + Chain string `json:"chain,omitempty"` // ready + Expiry string `json:"expiry,omitempty"` // ready + Reason string `json:"reason,omitempty"` // failed +} + +type CertificateInfo struct { + ID string `json:"id"` + State CertState `json:"state"` +} + +// Error bodies. The reference emits {"_tag": ..., extra..., "message": ...}. +type errUnauthorized struct { + Tag string `json:"_tag"` + Message string `json:"message"` +} + +type errTaggedID struct { + Tag string `json:"_tag"` + TunnelID string `json:"tunnelID"` + Message string `json:"message"` +} + +type errInvalidRequest struct { + Tag string `json:"_tag"` + Message string `json:"message"` +} + +type errInvalidHostname struct { + Tag string `json:"_tag"` + Provided string `json:"provided"` + Expected string `json:"expected"` + Message string `json:"message"` +} + +// ---- Bridge control messages. + +const ( + BridgeSubprotocol = "opentunnel" + heartbeatMs = 15000 + idleTimeoutMs = 45000 + maxPayload = 32768 // bytes per binary data frame + maxRoutesPerAttach = 128 + maxConnsPerBridge = 256 +) + +// attachMessage is the one client message with required structure; all other +// client control messages are decoded loosely and unknown types ignored. +type attachMessage struct { + Type string `json:"type"` + Token string `json:"token"` + Transport string `json:"transport"` + Routes []string `json:"routes"` + Client struct { + Version string `json:"version"` + MaxConns int `json:"max_conns"` + } `json:"client"` +} + +// clientControl decodes the non-attach client messages. +type clientControl struct { + Type string `json:"type"` + Conn *uint32 `json:"conn"` + Code string `json:"code"` + TimeSent int64 `json:"time_sent"` +} + +func parseClientControl(text []byte) (clientControl, error) { + var msg clientControl + if err := json.Unmarshal(text, &msg); err != nil { + return msg, err + } + return msg, nil +} + +// Server-to-client messages, in pinned key order. + +type attachedMessage struct { + Type string `json:"type"` + Session string `json:"session"` + Routes []string `json:"routes"` + HeartbeatMs int `json:"heartbeat_ms"` + IdleTimeoutMs int `json:"idle_timeout_ms"` +} + +type attachErrorMessage struct { + Type string `json:"type"` + Code string `json:"code"` +} + +type openMessage struct { + Type string `json:"type"` + Conn uint32 `json:"conn"` + Peer string `json:"peer"` + SNI string `json:"sni"` + ALPN string `json:"alpn"` +} + +type pingPongMessage struct { + Type string `json:"type"` + TimeSent int64 `json:"time_sent"` +} + +type drainMessage struct { + Type string `json:"type"` + Reason string `json:"reason"` +} + +type endMessage struct { + Type string `json:"type"` + Conn uint32 `json:"conn"` +} + +type resetMessage struct { + Type string `json:"type"` + Conn uint32 `json:"conn"` + Code string `json:"code"` +} + +// Reset codes used by this server (clients treat codes as opaque strings). +const ( + ResetIdleTimeout = "idle_timeout" + ResetIOError = "io_error" + ResetDrain = "drain" +) + +// buildFrame serializes a binary data frame: big-endian u32 conn + payload. +func buildFrame(conn uint32, payload []byte) []byte { + frame := make([]byte, 4+len(payload)) + binary.BigEndian.PutUint32(frame, conn) + copy(frame[4:], payload) + return frame +} + +// parseFrame splits a binary data frame into conn ID and payload. +func parseFrame(data []byte) (uint32, []byte, bool) { + if len(data) < 4 { + return 0, nil, false + } + return binary.BigEndian.Uint32(data), data[4:], true +} diff --git a/internal/server/proto_test.go b/internal/server/proto_test.go new file mode 100644 index 0000000..caeccdc --- /dev/null +++ b/internal/server/proto_test.go @@ -0,0 +1,187 @@ +package server + +import ( + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// The spec vectors are shared with the reference implementation +// (.reference/opentunnel/spec/vectors). + +type frameVector struct { + Conn uint32 `json:"conn"` + Payload string `json:"payload"` + Frame string `json:"frame"` +} + +func TestFrameVectors(t *testing.T) { + var vectors []frameVector + readVector(t, "frames.json", &vectors) + for i, v := range vectors { + payload, err := hex.DecodeString(v.Payload) + if err != nil { + t.Fatalf("vector %d payload: %v", i, err) + } + want, err := hex.DecodeString(v.Frame) + if err != nil { + t.Fatalf("vector %d frame: %v", i, err) + } + if got := buildFrame(v.Conn, payload); !equalBytes(got, want) { + t.Errorf("vector %d: buildFrame = %x, want %x", i, got, want) + } + conn, gotPayload, ok := parseFrame(want) + if !ok || conn != v.Conn || !equalBytes(gotPayload, payload) { + t.Errorf("vector %d: parseFrame = (%d, %x, %v)", i, conn, gotPayload, ok) + } + } +} + +type controlVector struct { + Client []json.RawMessage `json:"client"` + Server []json.RawMessage `json:"server"` + Ignored []json.RawMessage `json:"ignored"` + Invalid []json.RawMessage `json:"invalid"` +} + +func TestControlVectors(t *testing.T) { + var vectors controlVector + readVector(t, "control.json", &vectors) + for i, raw := range vectors.Client { + msg, err := parseClientControl(raw) + if err != nil || msg.Type == "" { + t.Errorf("client vector %d: %v", i, err) + } + } + for i, raw := range vectors.Server { + var probe struct { + Type string `json:"type"` + } + if err := json.Unmarshal(raw, &probe); err != nil || probe.Type == "" { + t.Errorf("server vector %d should decode", i) + } + } + for i, raw := range vectors.Ignored { + if _, err := parseClientControl(raw); err != nil { + t.Errorf("ignored vector %d should still decode: %v", i, err) + } + } + // The invalid vectors either fail to decode or miss required fields. + for i, raw := range vectors.Invalid { + msg, err := parseClientControl(raw) + if err != nil { + continue + } + switch msg.Type { + case "": // {"conn":1} has no type + case "end": // {"type":"end"} misses conn + if msg.Conn != nil { + t.Errorf("invalid vector %d: end with conn?", i) + } + } + } +} + +func TestAttachedVectorShape(t *testing.T) { + // Our attached message must marshal to exactly the vector's key order. + var vectors controlVector + readVector(t, "control.json", &vectors) + var want map[string]any + json.Unmarshal(vectors.Server[0], &want) + raw, err := json.Marshal(attachedMessage{ + Type: "attached", Session: "sess_1", Routes: []string{"api"}, + HeartbeatMs: 15000, IdleTimeoutMs: 45000, + }) + if err != nil { + t.Fatal(err) + } + var got map[string]any + json.Unmarshal(raw, &got) + if len(got) != len(want) { + t.Fatalf("attached fields = %v, want %v", got, want) + } + for key := range want { + if _, ok := got[key]; !ok { + t.Errorf("attached misses %q", key) + } + } +} + +type namesVector struct { + Route []struct { + Value string `json:"value"` + Valid bool `json:"valid"` + } `json:"route"` +} + +func TestRouteNameVectors(t *testing.T) { + var vectors namesVector + readVector(t, "names.json", &vectors) + for _, v := range vectors.Route { + if got := validRoute(v.Value); got != v.Valid { + t.Errorf("validRoute(%q) = %v, want %v", v.Value, got, v.Valid) + } + } +} + +type routeVector struct { + Hostname string `json:"hostname"` + SNI string `json:"sni"` + Route *string `json:"route"` +} + +func TestRouteVectors(t *testing.T) { + var vectors []routeVector + readVector(t, "routes.json", &vectors) + const domain = "opentunnel.xyz" + for _, v := range vectors { + id, route, ok := routeForSNI(domain, v.SNI) + // The vectors know which tunnel exists; the server closes visitors of + // unknown tunnels at lookup, so a foreign ID resolves to no route. + tunnelID := v.Hostname[:len(v.Hostname)-len("."+domain)] + if ok && id != tunnelID { + ok = false + } + if v.Route == nil { + if ok { + t.Errorf("sni %q: got route %q, want no match", v.SNI, route) + } + continue + } + if !ok || route != *v.Route { + t.Errorf("sni %q: got (%q, %q, %v), want route %q", v.SNI, id, route, ok, *v.Route) + } + } +} + +func readVector(t *testing.T, name string, out any) { + t.Helper() + raw, err := os.ReadFile(filepath.Join(vectorDir(t), name)) + if err != nil { + t.Skipf("spec vectors not available: %v", err) + } + if err := json.Unmarshal(raw, out); err != nil { + t.Fatalf("parsing %s: %v", name, err) + } +} + +// vectorDir locates the shared vectors: the reference checkout lives in +// .reference/opentunnel at the repository root. +func vectorDir(t *testing.T) string { + t.Helper() + return filepath.Join("..", "..", ".reference", "opentunnel", "spec", "vectors") +} + +func equalBytes(a, b []byte) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/server/relay.go b/internal/server/relay.go new file mode 100644 index 0000000..3bfa982 --- /dev/null +++ b/internal/server/relay.go @@ -0,0 +1,177 @@ +package server + +import ( + "crypto/tls" + "log" + "net" + "strings" + "sync" + "time" +) + +// Relay is the public TCP listener. It reads only the TLS ClientHello: SNI +// equal to the domain is terminated with the server's certificate (the HTTP +// API and bridge), tunnel hostnames are passed — still encrypted — to the +// bridge that claimed the route. +type Relay struct { + Domain string + Store *Store + Hub *Hub + Server *tls.Certificate // API domain certificate + APIConns chan net.Conn // terminated API connections, served by http.Server +} + +// Serve accepts on ln until it is closed. +func (r *Relay) Serve(ln net.Listener) { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go r.handle(conn) + } +} + +func (r *Relay) handle(conn net.Conn) { + raw, sni, alpn, err := readClientHello(conn) + if err != nil { + log.Printf("relay: %s: %v", conn.RemoteAddr(), err) + conn.Close() + return + } + sni = strings.ToLower(sni) + + if sni == r.Domain { + r.serveAPI(conn, raw) + return + } + + tunnelID, route, ok := routeForSNI(r.Domain, sni) + if !ok { + log.Printf("relay: %s: sni %q matches no tunnel hostname", conn.RemoteAddr(), sni) + conn.Close() + return + } + tunnel, ok := r.Store.get(tunnelID) + if !ok || tunnel.Certificate == nil { + log.Printf("relay: %s: tunnel %s unknown or certificate not ready", conn.RemoteAddr(), tunnelID) + conn.Close() // unknown tunnel or certificate not ready + return + } + bridge := r.Hub.bridgeFor(tunnelID, route) + if bridge == nil { + log.Printf("relay: %s: no bridge for route %q of tunnel %s", conn.RemoteAddr(), route, tunnelID) + conn.Close() + return + } + log.Printf("relay: %s: %s -> tunnel %s route %q", conn.RemoteAddr(), sni, tunnelID, route) + bridge.openVisitor(conn, raw, conn.RemoteAddr().String(), sni, strings.Join(alpn, ",")) +} + +// serveAPI replays the ClientHello into a TLS termination with the server's +// certificate and hands the connection to the shared API http.Server. +func (r *Relay) serveAPI(conn net.Conn, hello []byte) { + tlsConn := tls.Server(&replayConn{Conn: conn, prefix: hello}, &tls.Config{ + Certificates: []tls.Certificate{*r.Server}, + NextProtos: []string{"http/1.1"}, + }) + conn.SetReadDeadline(time.Now().Add(clientHelloTimeout)) + err := tlsConn.Handshake() + conn.SetReadDeadline(time.Time{}) + if err != nil { + conn.Close() + return + } + select { + case r.APIConns <- tlsConn: + default: + log.Printf("API connection queue full; dropping %s", conn.RemoteAddr()) + tlsConn.Close() + } +} + +// replayConn returns the already-read ClientHello bytes before reading from +// the underlying connection again. +type replayConn struct { + net.Conn + prefix []byte +} + +func (c *replayConn) Read(p []byte) (int, error) { + if len(c.prefix) > 0 { + n := copy(p, c.prefix) + c.prefix = c.prefix[n:] + return n, nil + } + return c.Conn.Read(p) +} + +// routeForSNI resolves a tunnel hostname to tunnel ID and route name: +// . is route "@", .. is that route; anything +// deeper, malformed, or outside the domain does not match. +func routeForSNI(domain, sni string) (id, route string, ok bool) { + sni = strings.ToLower(sni) + suffix := "." + domain + if sni == "" || sni == domain || !strings.HasSuffix(sni, suffix) { + return "", "", false + } + labels := strings.Split(strings.TrimSuffix(sni, suffix), ".") + switch len(labels) { + case 1: + if !routeLabel.MatchString(labels[0]) { + return "", "", false + } + return labels[0], "@", true + case 2: + if !validRoute(labels[0]) || labels[0] == "@" || !routeLabel.MatchString(labels[1]) { + return "", "", false + } + return labels[1], labels[0], true + default: + return "", "", false + } +} + +// chanListener turns a channel of connections into a net.Listener, so one +// http.Server can serve the connections the relay terminates for the domain. +type chanListener struct { + conns chan net.Conn + done chan struct{} + once sync.Once + addr net.Addr +} + +func NewChanListener(capacity int, addr net.Addr) *chanListener { + return &chanListener{ + conns: make(chan net.Conn, capacity), + done: make(chan struct{}), + addr: addr, + } +} + +func (l *chanListener) Accept() (net.Conn, error) { + select { + case conn := <-l.conns: + return conn, nil + case <-l.done: + return nil, errListenerClosed + } +} + +func (l *chanListener) Close() error { + l.once.Do(func() { close(l.done) }) + return nil +} + +func (l *chanListener) Addr() net.Addr { return l.addr } + +// Conns returns the channel the listener serves connections from. +func (l *chanListener) Conns() chan net.Conn { return l.conns } + +var errListenerClosed = &netError{} + +type netError struct{} + +func (e *netError) Error() string { return "listener closed" } +func (e *netError) Timeout() bool { return false } +func (e *netError) Temporary() bool { return false } diff --git a/internal/server/store.go b/internal/server/store.go new file mode 100644 index 0000000..fde1429 --- /dev/null +++ b/internal/server/store.go @@ -0,0 +1,193 @@ +package server + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sync" + "time" +) + +// storedCert is one issued certificate. With the local issuer, issuance is +// synchronous, so a bound CSR always has its certificate populated. +type storedCert struct { + ID string `json:"id"` + CSR string `json:"csr"` + Certificate string `json:"certificate"` + Chain string `json:"chain"` + Expiry string `json:"expiry"` +} + +type storedTunnel struct { + ID string `json:"id"` + Hostname string `json:"hostname"` + TokenHash string `json:"tokenHash"` + Certificate *storedCert `json:"certificate,omitempty"` +} + +// storeData is the on-disk shape. The CA key is a secret, so the file is +// written 0600. +type storeData struct { + CAKeyPEM string `json:"caKeyPem"` + CACertPEM string `json:"caCertPEM"` + ServerKey string `json:"serverKey"` + ServerCrt string `json:"serverCrt"` + Tunnels map[string]storedTunnel `json:"tunnels"` +} + +// Store is a JSON-file-backed tunnel store: adequate for a single-node +// self-hosted deployment, and the one writer this server has. +type Store struct { + mu sync.Mutex + path string + data storeData +} + +// openStore loads path, or initializes it (with a fresh local CA) when it +// does not exist. An empty path keeps state in memory only. +func OpenStore(path string) (*Store, error) { + s := &Store{path: path, data: storeData{Tunnels: map[string]storedTunnel{}}} + if path != "" { + if raw, err := os.ReadFile(path); err == nil { + if err := json.Unmarshal(raw, &s.data); err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } + if s.data.Tunnels == nil { + s.data.Tunnels = map[string]storedTunnel{} + } + } else if !errors.Is(err, fs.ErrNotExist) { + return nil, err + } + } + if s.data.CAKeyPEM == "" { + ca, err := newLocalCA() + if err != nil { + return nil, err + } + keyPEM, err := ca.keyPEM() + if err != nil { + return nil, err + } + s.data.CAKeyPEM = string(keyPEM) + s.data.CACertPEM = string(ca.certPEM) + if err := s.saveLocked(); err != nil { + return nil, err + } + } + return s, nil +} + +// ca returns the store's local CA, loading it from the stored PEM. +func (s *Store) ca() (*localCA, error) { + s.mu.Lock() + defer s.mu.Unlock() + return loadLocalCA([]byte(s.data.CAKeyPEM), []byte(s.data.CACertPEM)) +} + +// CACertPEM is the CA certificate clients should trust. +func (s *Store) CACertPEM() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.data.CACertPEM +} + +// serverCertificate returns the API domain's key and certificate PEM, +// issuing one with the CA when missing. +func (s *Store) ServerCertificate(domain string) (certPEM, keyPEM []byte, err error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.data.ServerCrt != "" { + return []byte(s.data.ServerCrt), []byte(s.data.ServerKey), nil + } + ca, err := loadLocalCA([]byte(s.data.CAKeyPEM), []byte(s.data.CACertPEM)) + if err != nil { + return nil, nil, err + } + certPEM, keyPEM, err = ca.issue(domain) + if err != nil { + return nil, nil, err + } + s.data.ServerCrt = string(certPEM) + s.data.ServerKey = string(keyPEM) + if err := s.saveLocked(); err != nil { + return nil, nil, err + } + return certPEM, keyPEM, nil +} + +// createTunnel stores a new tunnel and returns it with its bearer token, the +// only time the token is visible. +func (s *Store) createTunnel(domain string) (storedTunnel, string, error) { + token := newToken() + t := storedTunnel{ + ID: slug(), + TokenHash: hashToken(token), + } + t.Hostname = t.ID + "." + domain + s.mu.Lock() + defer s.mu.Unlock() + s.data.Tunnels[t.ID] = t + return t, token, s.saveLocked() +} + +// get returns the tunnel with id, or false. +func (s *Store) get(id string) (storedTunnel, bool) { + s.mu.Lock() + defer s.mu.Unlock() + t, ok := s.data.Tunnels[id] + return t, ok +} + +// bindCSR records the issued certificate for the tunnel. The CSR is kept so a +// renewal (and a resumed provision) can be answered without a new bind. +func (s *Store) bindCertificate(id string, cert storedCert) error { + s.mu.Lock() + defer s.mu.Unlock() + t, ok := s.data.Tunnels[id] + if !ok { + return errors.New("tunnel not found") + } + t.Certificate = &cert + s.data.Tunnels[id] = t + return s.saveLocked() +} + +// remove deletes the tunnel. +func (s *Store) remove(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.data.Tunnels[id]; !ok { + return errors.New("tunnel not found") + } + delete(s.data.Tunnels, id) + return s.saveLocked() +} + +// saveLocked writes the data file atomically (0600); a no-op for an +// in-memory store. +func (s *Store) saveLocked() error { + if s.path == "" { + return nil + } + raw, err := json.MarshalIndent(s.data, "", " ") + if err != nil { + return err + } + tmp := s.path + ".tmp" + if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil { + return err + } + if err := os.WriteFile(tmp, raw, 0o600); err != nil { + return err + } + return os.Rename(tmp, s.path) +} + +// expiryString formats a time as the reference does: ISO 8601 UTC with +// milliseconds, e.g. 2026-10-03T00:00:00.000Z. +func expiryString(t time.Time) string { + return t.UTC().Format("2006-01-02T15:04:05.000Z") +}