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.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
tunneld
|
||||
.local/
|
||||
.DS_Store
|
||||
@@ -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.<id>.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.
|
||||
@@ -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.<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 |
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,5 @@
|
||||
module gitea.tzgyn.dev/TZGyn/tunnel
|
||||
|
||||
go 1.27.3
|
||||
|
||||
require github.com/coder/websocket v1.8.15
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:
|
||||
// <id>.<domain> is route "@", <route>.<id>.<domain> 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 }
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user