diff --git a/AGENTS.md b/AGENTS.md index 50ea043..d514485 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,14 @@ what we take vs. skip). Never vendor it into the build. - `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/issuer.go` — the issuer interface plus the local-CA and + ACME DNS-01 implementations (account persistence, order/challenge flow, + propagation check, certificate for the client's CSR) +- `internal/server/dns.go`, `dns_porkbun.go`, `dns_challtestsrv.go` — the + challenge DNS providers: Porkbun's API for production, pebble-challtestsrv + for local ACME tests +- `internal/server/renew.go` — the renewal loop: 30-day window, 6-hour checks, + retries, restart recovery, and local-CA → ACME migration - `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 @@ -101,6 +109,21 @@ what we take vs. skip). Never vendor it into the build. (`HostSNIRegexp` + `tls.passthrough=true`) → container :8443. See README "Deploy on Coolify". +- **ACME / Porkbun** (M2): with `PORKBUN_API_KEY` and + `PORKBUN_SECRET_API_KEY` set, the issuer is Let's Encrypt production and + every tunnel is reissued (the old local-CA cert keeps serving until the new + one is ready). The key used here is restricted to `tzgyn.dev`, but Porkbun + also requires **API access opt-in per domain**; without it every DNS call + fails with `API_ACCESS_DISABLED`. Challenge records are + `_acme-challenge..` (one name for hostname + wildcard, up to two + TXT values) and are deleted when the attempt ends. The ACME account key and + URL are stored in the data file, so the persistent volume matters. Failed + renewals keep the old certificate serving (`state=failed`, still `ready` + on the wire) and retry every 6 hours. Use + `ACME_DIRECTORY_URL=https://acme-staging-v02.api.letsencrypt.org/directory` + for tests; staging certs are untrusted. Local ACME E2E is Pebble + + pebble-challtestsrv with `DNS_PROVIDER=challtestsrv` and + `ACME_CA_BUNDLE=.../test/certs/pebble.minica.pem`. - **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. @@ -115,6 +138,6 @@ what we take vs. skip). Never vendor it into the build. 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. +- Milestones: M1 core data path (done); M2 real ACME DNS-01 + renewals (done); + M3 rate limits/admin/hardening; M4 maybe a Go client. Don't pull M2+ scope + into M1 fixes, or M3 scope into M2 fixes. diff --git a/README.md b/README.md index 58b04db..e7aa7fa 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,10 @@ 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. +Status: **M2** — core data path works end-to-end with the official CLI, and +tunnels get publicly trusted certificates through ACME DNS-01 (Let's Encrypt ++ Porkbun) with automatic renewal. The built-in local CA remains the default +when no DNS provider is configured. ## Quick start (local development) @@ -51,6 +53,59 @@ Every flag also reads an environment variable (`tunneld -h`): | `-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 | | `-admin-token` | `ADMIN_TOKEN` | *(empty)* | bearer token for the admin dashboard; empty disables the admin API | +| `-dns-provider` | `DNS_PROVIDER` | *(auto)* | `porkbun` or `challtestsrv`; defaults to Porkbun when its keys are set | +| `-porkbun-api-key` | `PORKBUN_API_KEY` | *(empty)* | Porkbun API key for DNS-01 records | +| `-porkbun-secret-api-key` | `PORKBUN_SECRET_API_KEY` | *(empty)* | Porkbun secret API key | +| `-dns-zone` | `DNS_ZONE` | derived from `-domain` | zone holding the challenge records (`tzgyn.dev` for `tunnel.tzgyn.dev`) | +| `-acme-directory` | `ACME_DIRECTORY_URL` | Let's Encrypt production | ACME directory URL (use staging for tests) | +| `-acme-email` | `ACME_EMAIL` | *(empty)* | contact email registered with the ACME account | +| `-acme-propagation-seconds` | `ACME_PROPAGATION_TIMEOUT_SECONDS` | `60` | how long to wait for challenge TXT records; `0` skips the check | +| `-acme-ca-bundle` | `ACME_CA_BUNDLE` | *(empty)* | extra roots for the ACME server itself (Pebble) | +| `-challtestsrv-url` | `CHALLTESTSRV_URL` | `http://127.0.0.1:8055` | pebble-challtestsrv management URL (dev only) | + +## Certificates + +Certificates have two modes, selected at startup: + +- **Local CA** (default): signs instantly and needs no network. Clients must + trust `/ca.pem` (`--ca-cert` / `OPENTUNNEL_CA_CERT`), and visitors + see a certificate warning. +- **ACME DNS-01** (Porkbun): when `PORKBUN_API_KEY` and + `PORKBUN_SECRET_API_KEY` are set (or `DNS_PROVIDER=porkbun`), tunneld orders + a per-tunnel certificate from Let's Encrypt covering `.` and, + when the CSR asks for it, `*..`. The client keeps its private + key: it sends a CSR and tunneld returns the certificate for that key. + Issuance is asynchronous — the bind answers `issuing`, and the client polls + until `ready` or `failed` (typically 20–60 seconds). + +### Porkbun setup + +1. Porkbun → **Account → API Access** → create an API key and secret key. + Restrict the key to the zone when Porkbun offers it. +2. **Domain Management → tzgyn.dev → Details** → enable **API access** for the + domain (or turn on **Opt In All Domains**). Without this, reads fail with + `API_ACCESS_DISABLED`. +3. Set on the server (for example in Coolify's environment variables): + `PORKBUN_API_KEY`, `PORKBUN_SECRET_API_KEY`. +4. Redeploy. Startup logs show `dns: Porkbun zone tzgyn.dev` and + `certificate issuer: ACME https://acme-v02.api.letsencrypt.org/directory`. +5. Set `DNS_ZONE` only if the derived zone is wrong. For + `-domain tunnel.tzgyn.dev`, the derived zone is `tzgyn.dev`. + +DNS-01 uses `_acme-challenge..`; the hostname and its wildcard +share that name, so an attempt creates up to two TXT values there. Records are +deleted when the attempt ends. The ACME account key is persisted in the data +file, so restarts reuse the same account. + +Certificates are renewed once they have less than 30 days left, checked every +6 hours; a failed renewal keeps the old certificate serving and is retried. +Switching a running server from the local CA to ACME re-issues every existing +tunnel automatically, keeping the old certificate in service until the new one +is ready. + +For testing against Let's Encrypt staging (untrusted certificates, separate +rate limits), set +`ACME_DIRECTORY_URL=https://acme-staging-v02.api.letsencrypt.org/directory`. ## Admin dashboard @@ -85,7 +140,9 @@ untouched by SNI, because visitor TLS is terminated by the clients: - Domain `https://tunnel.example.com`; `ports_exposes=8080` (Traefik routes HTTPS → the plain-HTTP API port). - Env: `OPENTUNNEL_DOMAIN=tunnel.example.com`, `HTTP_MODE=serve` (the API - sits behind Traefik; it must not redirect to HTTPS itself). + sits behind Traefik; it must not redirect to HTTPS itself), and, for real + certificates, `PORKBUN_API_KEY` + `PORKBUN_SECRET_API_KEY` (see + [Certificates](#certificates)). - Custom Traefik labels (Coolify stores them base64-encoded): `traefik.tcp.routers.tunnel-data.entrypoints=https`, `...rule=HostSNIRegexp(`^.+\.tunnel\.example\.com$`)`, @@ -98,29 +155,36 @@ untouched by SNI, because visitor TLS is terminated by the clients: serves `/health` on 80 and 301s the rest), volume on `/app/data`. No TLS-terminating proxy may sit in front of the tunnel ports. -Until M2 lands, certificates come from the server's built-in local CA: -clients need `--ca-cert /ca.pem` (or `OPENTUNNEL_CA_CERT`), and -visitors must trust that CA. With real ACME issuance (M2) everything is -publicly trusted. +Without ACME credentials, certificates come from the server's built-in local +CA: clients need `--ca-cert /ca.pem` (or `OPENTUNNEL_CA_CERT`), and +visitors must trust that CA. With Porkbun configured, every tunnel gets a +publicly trusted Let's Encrypt certificate and no client or visitor trust +setup is needed. -## What works (M1) +## What works (M1 + M2) - 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 + asynchronous issuance states (`issuing`/`ready`/`failed`), the local CA, and + ACME DNS-01 through Porkbun +- Per-tunnel Let's Encrypt certificates (`.` + optional wildcard) + that keep the client's key, with automatic renewal 30 days before expiry + and retries on failure - The bridge WebSocket: attach/attached, route claiming with conflicts, heartbeats, idle retirement, `open`/`end`/`reset`, 32 KiB data frames - SNI routing on the TLS listener: the domain is terminated (API), tunnel hostnames are passed through still encrypted -- Admin dashboard at `/` (bearer `ADMIN_TOKEN`): tunnels, bridges, CA expiry -- Persistence: JSON store with the CA, server certificate, and tunnels +- Admin dashboard at `/` (bearer `ADMIN_TOKEN`): tunnels, bridges, cert + state/expiry, issuer, CA expiry +- Persistence: JSON store with the CA, ACME account, 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 +- Issuance rate limits, CA cool-down, stale-DNS-record sweeps +- Metrics, more 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) diff --git a/cmd/tunneld/main.go b/cmd/tunneld/main.go index e72a877..ebc11c3 100644 --- a/cmd/tunneld/main.go +++ b/cmd/tunneld/main.go @@ -4,6 +4,7 @@ package main import ( + "context" "crypto/tls" "flag" "log" @@ -12,6 +13,7 @@ import ( "os" "os/signal" "path/filepath" + "strconv" "syscall" "time" @@ -29,6 +31,15 @@ func envOr(key, fallback string) string { return fallback } +func envInt(key string, fallback int) int { + if value := os.Getenv(key); value != "" { + if n, err := strconv.Atoi(value); err == nil { + return n + } + } + return fallback +} + func defaultDataDir() string { if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" { return filepath.Join(xdg, "tunneld") @@ -49,6 +60,15 @@ func main() { dataPath := flag.String("data", envOr("TUNNELD_DATA", dataDefault), "tunnel store file (created 0600)") caFile := flag.String("ca-file", envOr("LOCAL_CA_FILE", ""), "write the local CA certificate here for clients to trust") adminToken := flag.String("admin-token", envOr("ADMIN_TOKEN", ""), "bearer token for the admin dashboard at / and /api/admin/overview; empty disables the admin API") + acmeDirectory := flag.String("acme-directory", envOr("ACME_DIRECTORY_URL", "https://acme-v02.api.letsencrypt.org/directory"), "ACME directory URL") + acmeEmail := flag.String("acme-email", envOr("ACME_EMAIL", ""), "contact email registered with the ACME account") + acmeCABundle := flag.String("acme-ca-bundle", envOr("ACME_CA_BUNDLE", ""), "extra CA roots for the ACME server's TLS (Pebble)") + acmePropagation := flag.Int("acme-propagation-seconds", envInt("ACME_PROPAGATION_TIMEOUT_SECONDS", 60), "wait this long for challenge TXT records to propagate; 0 skips the check") + dnsProvider := flag.String("dns-provider", envOr("DNS_PROVIDER", ""), `challenge DNS: "porkbun" (default when its keys are set) or "challtestsrv" (Pebble)`) + porkbunKey := flag.String("porkbun-api-key", envOr("PORKBUN_API_KEY", ""), "Porkbun API key for DNS-01 challenges") + porkbunSecret := flag.String("porkbun-secret-api-key", envOr("PORKBUN_SECRET_API_KEY", ""), "Porkbun secret API key") + dnsZone := flag.String("dns-zone", envOr("DNS_ZONE", ""), "zone hosting the challenge records (default: derived from the domain)") + challtestsrvURL := flag.String("challtestsrv-url", envOr("CHALLTESTSRV_URL", ""), "pebble-challtestsrv management URL for DNS_PROVIDER=challtestsrv") flag.Parse() if *caFile == "" { @@ -75,6 +95,46 @@ func main() { } hub := server.NewHub(store) + + // Certificate issuance: ACME (DNS-01) when a challenge DNS provider is + // configured, the built-in local CA otherwise. + issuerName := "local" + var issuer server.Issuer = server.NewLocalIssuer(store) + var dns server.DNSProvider + switch { + case *dnsProvider == "challtestsrv": + dns = &server.ChallengeTestSrvDNS{BaseURL: *challtestsrvURL} + case *dnsProvider == "porkbun", *dnsProvider == "" && *porkbunKey != "" && *porkbunSecret != "": + if *porkbunKey == "" || *porkbunSecret == "" { + log.Fatal("DNS_PROVIDER=porkbun needs PORKBUN_API_KEY and PORKBUN_SECRET_API_KEY") + } + zone := *dnsZone + if zone == "" { + zone = server.DNSZone(*domain) + } + log.Printf("dns: Porkbun zone %s", zone) + dns = &server.PorkbunDNS{APIKey: *porkbunKey, SecretKey: *porkbunSecret, Zone: zone} + case *dnsProvider != "": + log.Fatalf("unknown DNS_PROVIDER %q (want porkbun or challtestsrv)", *dnsProvider) + } + if dns != nil { + acme, err := server.NewACMEIssuer(store, server.ACMEOptions{ + DirectoryURL: *acmeDirectory, + Email: *acmeEmail, + CABundleFile: *acmeCABundle, + DNS: dns, + PropagationTimeout: time.Duration(*acmePropagation) * time.Second, + }) + if err != nil { + log.Fatalf("acme issuer: %v", err) + } + issuer = acme + issuerName = *acmeDirectory + log.Printf("certificate issuer: ACME %s", *acmeDirectory) + } else { + log.Printf("certificate issuer: local CA (set PORKBUN_API_KEY and PORKBUN_SECRET_API_KEY to use ACME)") + } + api := &server.API{ Store: store, Hub: hub, @@ -82,6 +142,8 @@ func main() { AdminToken: *adminToken, StartedAt: time.Now(), Version: version, + Issuer: issuer, + IssuerName: issuerName, } if *adminToken == "" { log.Printf("admin dashboard: disabled (set ADMIN_TOKEN to enable it at /)") @@ -89,6 +151,10 @@ func main() { log.Printf("admin dashboard: enabled at /") } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + api.StartRenewal(ctx) + // Public TLS listener: SNI routing, with API terminations handed to the // shared HTTP server. ln, err := net.Listen("tcp", *tlsListen) @@ -133,6 +199,7 @@ func main() { signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) sig := <-signals log.Printf("received %s; draining bridges", sig) + cancel() hub.DrainAll() ln.Close() if httpLn != nil { diff --git a/go.mod b/go.mod index b1f9746..6b9ef95 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,7 @@ module gitea.tzgyn.dev/TZGyn/tunnel go 1.27.3 -require github.com/coder/websocket v1.8.15 +require ( + github.com/coder/websocket v1.8.15 + golang.org/x/crypto v0.58.0 +) diff --git a/go.sum b/go.sum index 4e0a48d..e9237ed 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,4 @@ github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= diff --git a/internal/server/admin.go b/internal/server/admin.go index 97c351f..7ef7763 100644 --- a/internal/server/admin.go +++ b/internal/server/admin.go @@ -18,6 +18,7 @@ type adminServer struct { Domain string `json:"domain"` Version string `json:"version"` Go string `json:"go"` + Issuer string `json:"issuer"` UptimeSeconds int64 `json:"uptime_seconds"` } @@ -39,6 +40,9 @@ type adminTunnel struct { Routes []string `json:"routes"` Visitors int `json:"visitors"` CertificateExpiry string `json:"certificate_expiry,omitempty"` + CertificateState string `json:"certificate_state,omitempty"` + CertificateIssuer string `json:"certificate_issuer,omitempty"` + CertificateError string `json:"certificate_error,omitempty"` CreatedAt string `json:"created_at,omitempty"` } @@ -98,6 +102,7 @@ func (a *API) adminOverviewHandler(w http.ResponseWriter, r *http.Request) { Domain: a.Domain, Version: a.Version, Go: runtime.Version(), + Issuer: a.issuerName(), UptimeSeconds: int64(time.Since(a.StartedAt).Seconds()), }, CA: adminCA{Expires: a.caExpiry()}, @@ -137,6 +142,9 @@ func (a *API) adminOverviewHandler(w http.ResponseWriter, r *http.Request) { } if t.Certificate != nil { entry.CertificateExpiry = t.Certificate.Expiry + entry.CertificateState = certState(t.Certificate).Type + entry.CertificateIssuer = t.Certificate.Issuer + entry.CertificateError = t.Certificate.Reason } overview.Tunnels = append(overview.Tunnels, entry) } diff --git a/internal/server/api.go b/internal/server/api.go index 932e37e..63ed1ff 100644 --- a/internal/server/api.go +++ b/internal/server/api.go @@ -1,12 +1,15 @@ package server import ( + "context" "encoding/json" "errors" "io" + "log" "mime" "net/http" "strings" + "sync" "time" ) @@ -20,6 +23,12 @@ type API struct { AdminToken string // bearer token for /api/admin/*; empty disables it StartedAt time.Time // for the uptime shown on the dashboard Version string + + Issuer Issuer // certificate issuer; defaults to the local CA + IssuerName string // "local" or the ACME directory URL + + issueMu sync.Mutex + issuing map[string]bool } func (a *API) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -167,21 +176,30 @@ func (a *API) bindCertificate(w http.ResponseWriter, r *http.Request, id string) 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 + // Binding the CSR the tunnel already has answers with its current state; + // a different CSR re-issues. A failed issuance for the same CSR retries. + cert := tunnel.Certificate + if cert != nil && cert.CSR == csr { + if certReady(cert) { + a.writeCertificate(w, http.StatusAccepted, cert) + return + } + if cert.State != "failed" { + // Same CSR, issuance incomplete: resume it (for example after a + // restart) and answer with its current state. + a.startIssue(tunnel.ID, tunnel.Hostname, csr, cert.ID) + a.writeCertificate(w, http.StatusAccepted, cert) + return + } } - ca, err := a.Store.ca() - if err != nil { - writeJSON(w, http.StatusServiceUnavailable, errInvalidRequest{ - Tag: "ServiceUnavailableError", Message: "storage unavailable", + if (cert != nil && cert.State == "issuing") || a.isIssuing(id) { + writeJSON(w, http.StatusConflict, errTaggedID{ + Tag: "CertificateInProgressError", TunnelID: id, + Message: "Certificate issuance is already in progress", }) return } - certPEM, chainPEM, expiry, err := ca.signCSR(csr, tunnel.Hostname) - if err != nil { + if _, err := parseCSR(csr, tunnel.Hostname); err != nil { var hostErr errCSRHostname if errors.As(err, &hostErr) { writeJSON(w, http.StatusBadRequest, errInvalidHostname{ @@ -197,20 +215,13 @@ func (a *API) bindCertificate(w http.ResponseWriter, r *http.Request, id string) }) 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 { + fresh := storedCert{ID: certID(), CSR: csr, State: "issuing", Issuer: a.issuerName()} + if err := a.Store.bindCertificate(id, fresh); 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"}}) + a.startIssue(tunnel.ID, tunnel.Hostname, csr, fresh.ID) + writeJSON(w, http.StatusAccepted, CertificateInfo{ID: fresh.ID, State: CertState{Type: "issuing"}}) } // ---- GET /api/tunnel/{id}/certificate @@ -230,15 +241,127 @@ func (a *API) getCertificate(w http.ResponseWriter, r *http.Request, id string) } 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, - }, - }) + writeJSON(w, status, CertificateInfo{ID: cert.ID, State: certState(cert)}) +} + +// certState maps a stored certificate to the wire state. +func certState(cert *storedCert) CertState { + switch { + case cert == nil: + return CertState{Type: "failed", Reason: "no certificate"} + case certReady(cert): + return CertState{Type: "ready", Certificate: cert.Certificate, Chain: cert.Chain, Expiry: cert.Expiry} + case cert.State == "failed": + return CertState{Type: "failed", Reason: cert.Reason} + default: + return CertState{Type: "issuing"} + } +} + +// issuer returns the configured certificate issuer (the local CA by default). +func (a *API) issuer() Issuer { + if a.Issuer != nil { + return a.Issuer + } + return NewLocalIssuer(a.Store) +} + +// issuerName identifies the current issuer, so certificates from a previous +// issuer (say the local CA) are re-issued once a real one is configured. +func (a *API) issuerName() string { + if a.IssuerName != "" { + return a.IssuerName + } + return "local" +} + +// runIssue issues one certificate and records the outcome. At most one +// issuance runs per tunnel. +func (a *API) runIssue(tunnelID, hostname, csrPEM, certID string) { + if !a.beginIssue(tunnelID) { + return + } + defer a.endIssue(tunnelID) + + // Drop an issuance whose bind was replaced before this run started (a + // renewal racing a new bind). + current, ok := a.Store.get(tunnelID) + if !ok || current.Certificate == nil || current.Certificate.ID != certID || current.Certificate.CSR != csrPEM { + log.Printf("skipping certificate issuance for %s: certificate %s is no longer current", hostname, certID) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + log.Printf("issuing certificate for %s via %s", hostname, a.issuerName()) + result, err := a.issuer().Issue(ctx, csrPEM, hostname) + + // The tunnel may have been deleted, or bound a different CSR, while the + // order ran. Storing this result would clobber the newer certificate. + current, ok = a.Store.get(tunnelID) + if !ok || current.Certificate == nil || current.Certificate.ID != certID || current.Certificate.CSR != csrPEM { + log.Printf("discarding certificate outcome for %s: certificate %s is no longer current", hostname, certID) + return + } + + cert := storedCert{ID: certID, CSR: csrPEM, Issuer: a.issuerName()} + if err != nil { + cert.State = "failed" + cert.Reason = err.Error() + if certReady(current.Certificate) { + // A renewal failed but the old certificate is still valid and + // serving: keep it, and let the renewal loop retry. + kept := *current.Certificate + kept.State = "failed" + kept.Reason = cert.Reason + cert = kept + } + log.Printf("certificate for %s failed: %v", hostname, err) + } else { + cert.State = "ready" + cert.Certificate = result.Certificate + cert.Chain = result.Chain + cert.Expiry = expiryString(result.Expiry) + log.Printf("certificate for %s ready (expires %s)", hostname, cert.Expiry) + } + if err := a.Store.bindCertificate(tunnelID, cert); err != nil { + log.Printf("storing certificate for %s: %v", hostname, err) + } +} + +func (a *API) beginIssue(tunnelID string) bool { + a.issueMu.Lock() + defer a.issueMu.Unlock() + if a.issuing == nil { + a.issuing = map[string]bool{} + } + if a.issuing[tunnelID] { + return false + } + a.issuing[tunnelID] = true + return true +} + +// startIssue runs issuance in the background for ACME, and synchronously for +// the local CA so the next GET already answers ready. +func (a *API) startIssue(tunnelID, hostname, csrPEM, certID string) { + if a.issuer().Instant() { + a.runIssue(tunnelID, hostname, csrPEM, certID) + return + } + go a.runIssue(tunnelID, hostname, csrPEM, certID) +} + +func (a *API) isIssuing(tunnelID string) bool { + a.issueMu.Lock() + defer a.issueMu.Unlock() + return a.issuing[tunnelID] +} + +func (a *API) endIssue(tunnelID string) { + a.issueMu.Lock() + defer a.issueMu.Unlock() + delete(a.issuing, tunnelID) } // ---- GET /api/tunnel/{id}/connect diff --git a/internal/server/bridge.go b/internal/server/bridge.go index eac0125..b6776c5 100644 --- a/internal/server/bridge.go +++ b/internal/server/bridge.go @@ -219,7 +219,7 @@ func (h *Hub) attach(w http.ResponseWriter, r *http.Request, tunnel storedTunnel fail("bad_token") return } - if tunnel.Certificate == nil { + if !certReady(tunnel.Certificate) { fail("cert_not_ready") return } diff --git a/internal/server/dns.go b/internal/server/dns.go new file mode 100644 index 0000000..938c2e5 --- /dev/null +++ b/internal/server/dns.go @@ -0,0 +1,42 @@ +package server + +import ( + "context" + "fmt" + "strings" +) + +// DNSProvider manages the TXT records ACME DNS-01 challenges need. +type DNSProvider interface { + // CreateTXT adds a TXT record with the given value at name. + CreateTXT(ctx context.Context, name, value string) error + // DeleteTXT removes every TXT record at name (best effort cleanup). + DeleteTXT(ctx context.Context, name string) error +} + +// challengeRecordName is the TXT record name a hostname's DNS-01 challenge +// lives at; the wildcard of a hostname shares the same record. +func challengeRecordName(hostname string) string { + return "_acme-challenge." + hostname +} + +// relativeRecordName strips the zone suffix from a record name, as DNS +// provider APIs expect names relative to the zone. +func relativeRecordName(name, zone string) (string, error) { + suffix := "." + zone + if !strings.HasSuffix(name, suffix) { + return "", fmt.Errorf("record %q is not under zone %q", name, zone) + } + return strings.TrimSuffix(name, suffix), nil +} + +// dnsZone guesses the registrable zone that hosts the tunnel hostnames: +// the domain minus its first label when it has three or more (tunnel.tzgyn.dev +// is served by the tzgyn.dev zone). Override with DNS_ZONE when wrong. +func DNSZone(domain string) string { + labels := strings.Split(domain, ".") + if len(labels) >= 3 { + return strings.Join(labels[1:], ".") + } + return domain +} diff --git a/internal/server/dns_challtestsrv.go b/internal/server/dns_challtestsrv.go new file mode 100644 index 0000000..f806054 --- /dev/null +++ b/internal/server/dns_challtestsrv.go @@ -0,0 +1,62 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// ChallengeTestSrvDNS talks to pebble-challtestsrv's management API, for +// development against a local Pebble ACME server (DNS_PROVIDER=challtestsrv). +type ChallengeTestSrvDNS struct { + BaseURL string // default http://127.0.0.1:8055 + HTTP *http.Client +} + +func (c *ChallengeTestSrvDNS) base() string { + if c.BaseURL != "" { + return c.BaseURL + } + return "http://127.0.0.1:8055" +} + +func (c *ChallengeTestSrvDNS) client() *http.Client { + if c.HTTP != nil { + return c.HTTP + } + return &http.Client{Timeout: 10 * time.Second} +} + +func (c *ChallengeTestSrvDNS) post(ctx context.Context, path string, body map[string]string) error { + raw, err := json.Marshal(body) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base()+path, bytes.NewReader(raw)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.client().Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + data, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("challtestsrv %s: HTTP %d: %s", path, resp.StatusCode, data) + } + return nil +} + +func (c *ChallengeTestSrvDNS) CreateTXT(ctx context.Context, name, value string) error { + return c.post(ctx, "/set-txt", map[string]string{"host": name, "value": value}) +} + +func (c *ChallengeTestSrvDNS) DeleteTXT(ctx context.Context, name string) error { + return c.post(ctx, "/clear-txt", map[string]string{"host": name}) +} diff --git a/internal/server/dns_challtestsrv_test.go b/internal/server/dns_challtestsrv_test.go new file mode 100644 index 0000000..2e61048 --- /dev/null +++ b/internal/server/dns_challtestsrv_test.go @@ -0,0 +1,57 @@ +package server + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestChallengeTestSrvDNS(t *testing.T) { + var setBody, clearBody map[string]string + var setPath, clearPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + switch r.URL.Path { + case "/set-txt": + setPath = r.URL.Path + _ = json.Unmarshal(body, &setBody) + case "/clear-txt": + clearPath = r.URL.Path + _ = json.Unmarshal(body, &clearBody) + default: + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := &ChallengeTestSrvDNS{BaseURL: srv.URL, HTTP: srv.Client()} + ctx := context.Background() + + if err := c.CreateTXT(ctx, "_acme-challenge.abc.localhost", "value"); err != nil { + t.Fatal(err) + } + if setPath != "/set-txt" || setBody["host"] != "_acme-challenge.abc.localhost" || setBody["value"] != "value" { + t.Errorf("set: path %q body %v", setPath, setBody) + } + + if err := c.DeleteTXT(ctx, "_acme-challenge.abc.localhost"); err != nil { + t.Fatal(err) + } + if clearPath != "/clear-txt" || clearBody["host"] != "_acme-challenge.abc.localhost" { + t.Errorf("clear: path %q body %v", clearPath, clearBody) + } + + bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "no", http.StatusInternalServerError) + })) + defer bad.Close() + c = &ChallengeTestSrvDNS{BaseURL: bad.URL, HTTP: bad.Client()} + if err := c.CreateTXT(ctx, "x", "y"); err == nil { + t.Error("expected an error for a non-200 response") + } +} diff --git a/internal/server/dns_porkbun.go b/internal/server/dns_porkbun.go new file mode 100644 index 0000000..c814704 --- /dev/null +++ b/internal/server/dns_porkbun.go @@ -0,0 +1,89 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// PorkbunDNS manages TXT records through Porkbun's DNS API +// (https://api.porkbun.com/api/json/v3). +type PorkbunDNS struct { + APIKey string + SecretKey string + Zone string + BaseURL string // default https://api.porkbun.com/api/json/v3 + HTTP *http.Client +} + +const porkbunDefaultBaseURL = "https://api.porkbun.com/api/json/v3" + +func (p *PorkbunDNS) base() string { + if p.BaseURL != "" { + return p.BaseURL + } + return porkbunDefaultBaseURL +} + +func (p *PorkbunDNS) client() *http.Client { + if p.HTTP != nil { + return p.HTTP + } + return &http.Client{Timeout: 20 * time.Second} +} + +func (p *PorkbunDNS) post(ctx context.Context, path string, body map[string]string) error { + body["apikey"] = p.APIKey + body["secretapikey"] = p.SecretKey + raw, err := json.Marshal(body) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.base()+path, bytes.NewReader(raw)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := p.client().Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("porkbun %s: HTTP %d: %s", path, resp.StatusCode, data) + } + var result struct { + Status string `json:"status"` + Message string `json:"message"` + } + if err := json.Unmarshal(data, &result); err != nil { + return fmt.Errorf("porkbun %s: unreadable response: %w", path, err) + } + if result.Status != "SUCCESS" { + return fmt.Errorf("porkbun %s: %s: %s", path, result.Status, result.Message) + } + return nil +} + +func (p *PorkbunDNS) CreateTXT(ctx context.Context, name, value string) error { + relative, err := relativeRecordName(name, p.Zone) + if err != nil { + return err + } + return p.post(ctx, "/dns/create/"+p.Zone, map[string]string{ + "name": relative, "type": "TXT", "content": value, "ttl": "600", + }) +} + +func (p *PorkbunDNS) DeleteTXT(ctx context.Context, name string) error { + relative, err := relativeRecordName(name, p.Zone) + if err != nil { + return err + } + return p.post(ctx, "/dns/deleteByNameType/"+p.Zone+"/TXT/"+relative, map[string]string{}) +} diff --git a/internal/server/dns_porkbun_test.go b/internal/server/dns_porkbun_test.go new file mode 100644 index 0000000..c450aa3 --- /dev/null +++ b/internal/server/dns_porkbun_test.go @@ -0,0 +1,74 @@ +package server + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestPorkbunDNS(t *testing.T) { + var createPath, createBody, deletePath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + switch { + case strings.HasPrefix(r.URL.Path, "/dns/create/"): + createPath, createBody = r.URL.Path, string(body) + w.Write([]byte(`{"status":"SUCCESS","id":"42"}`)) + case strings.HasPrefix(r.URL.Path, "/dns/deleteByNameType/"): + deletePath = r.URL.Path + w.Write([]byte(`{"status":"SUCCESS"}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + p := &PorkbunDNS{APIKey: "pk", SecretKey: "sk", Zone: "tzgyn.dev", BaseURL: srv.URL, HTTP: srv.Client()} + ctx := context.Background() + record := "_acme-challenge.abc123.tunnel.tzgyn.dev" + + if err := p.CreateTXT(ctx, record, "challenge-value"); err != nil { + t.Fatal(err) + } + if createPath != "/dns/create/tzgyn.dev" { + t.Errorf("create path = %q", createPath) + } + var body map[string]string + if err := json.Unmarshal([]byte(createBody), &body); err != nil { + t.Fatal(err) + } + for key, want := range map[string]string{ + "apikey": "pk", "secretapikey": "sk", + "name": "_acme-challenge.abc123.tunnel", "type": "TXT", "content": "challenge-value", + } { + if body[key] != want { + t.Errorf("create body %s = %q, want %q", key, body[key], want) + } + } + + if err := p.DeleteTXT(ctx, record); err != nil { + t.Fatal(err) + } + if deletePath != "/dns/deleteByNameType/tzgyn.dev/TXT/_acme-challenge.abc123.tunnel" { + t.Errorf("delete path = %q", deletePath) + } + + // A record outside the zone fails before any request. + if err := p.CreateTXT(ctx, "other.example.com", "v"); err == nil { + t.Error("expected an error for a record outside the zone") + } + + // API errors surface. + bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"status":"ERROR","message":"nope"}`)) + })) + defer bad.Close() + p2 := &PorkbunDNS{APIKey: "pk", SecretKey: "sk", Zone: "tzgyn.dev", BaseURL: bad.URL, HTTP: bad.Client()} + if err := p2.CreateTXT(ctx, record, "v"); err == nil || !strings.Contains(err.Error(), "nope") { + t.Errorf("expected the API error, got %v", err) + } +} diff --git a/internal/server/dns_test.go b/internal/server/dns_test.go new file mode 100644 index 0000000..9c8e471 --- /dev/null +++ b/internal/server/dns_test.go @@ -0,0 +1,33 @@ +package server + +import "testing" + +func TestChallengeRecordName(t *testing.T) { + if got := challengeRecordName("abc123.tunnel.tzgyn.dev"); got != "_acme-challenge.abc123.tunnel.tzgyn.dev" { + t.Errorf("challengeRecordName = %q", got) + } +} + +func TestRelativeRecordName(t *testing.T) { + got, err := relativeRecordName("_acme-challenge.abc123.tunnel.tzgyn.dev", "tzgyn.dev") + if err != nil || got != "_acme-challenge.abc123.tunnel" { + t.Errorf("relativeRecordName = %q, %v", got, err) + } + if _, err := relativeRecordName("_acme-challenge.other.example.com", "tzgyn.dev"); err == nil { + t.Error("expected an error for a record outside the zone") + } +} + +func TestDNSZone(t *testing.T) { + cases := map[string]string{ + "tunnel.tzgyn.dev": "tzgyn.dev", + "tzgyn.dev": "tzgyn.dev", + "a.b.example.co.uk": "b.example.co.uk", + "localhost": "localhost", + } + for domain, want := range cases { + if got := DNSZone(domain); got != want { + t.Errorf("DNSZone(%q) = %q, want %q", domain, got, want) + } + } +} diff --git a/internal/server/issue_test.go b/internal/server/issue_test.go new file mode 100644 index 0000000..6413744 --- /dev/null +++ b/internal/server/issue_test.go @@ -0,0 +1,245 @@ +package server + +import ( + "context" + "errors" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// failingIssuer returns an error immediately, for renewal-failure tests. +type failingIssuer struct{ reason string } + +func (f *failingIssuer) Instant() bool { return false } + +func (f *failingIssuer) Issue(context.Context, string, string) (IssuedCert, error) { + return IssuedCert{}, errors.New(f.reason) +} + +// blockingIssuer signals when an order starts and waits for release. +type blockingIssuer struct { + started chan struct{} + release chan struct{} + result IssuedCert +} + +func (b *blockingIssuer) Instant() bool { return false } + +func (b *blockingIssuer) Issue(ctx context.Context, _, _ string) (IssuedCert, error) { + close(b.started) + select { + case <-b.release: + return b.result, nil + case <-ctx.Done(): + return IssuedCert{}, ctx.Err() + } +} + +// fakeIssuer blocks until released, then returns a fixed result. +type fakeIssuer struct { + release chan struct{} + result IssuedCert + err error +} + +func (f *fakeIssuer) Instant() bool { return false } + +func (f *fakeIssuer) Issue(ctx context.Context, csrPEM, hostname string) (IssuedCert, error) { + select { + case <-f.release: + case <-ctx.Done(): + return IssuedCert{}, ctx.Err() + } + return f.result, f.err +} + +// countingIssuer records every Issue call and returns immediately. +type countingIssuer struct { + calls chan string +} + +func (c *countingIssuer) Instant() bool { return false } + +func (c *countingIssuer) Issue(_ context.Context, _, hostname string) (IssuedCert, error) { + c.calls <- hostname + return IssuedCert{Certificate: "CERT2", Chain: "CHAIN2", Expiry: time.Now().Add(90 * 24 * time.Hour)}, nil +} + +func TestAsyncIssuance(t *testing.T) { + store, err := OpenStore("") + if err != nil { + t.Fatal(err) + } + issuer := &fakeIssuer{ + release: make(chan struct{}), + result: IssuedCert{Certificate: "CERT", Chain: "CHAIN", Expiry: time.Now().Add(90 * 24 * time.Hour)}, + } + api := &API{ + Store: store, Hub: NewHub(store), Domain: "opentunnel.test", + StartedAt: time.Now(), Issuer: issuer, IssuerName: "fake", + } + srv := httptest.NewServer(api) + defer srv.Close() + + tunnel := createTunnel(t, srv) + csr := makeCSR(t, tunnel.Hostname, tunnel.Hostname, "*."+tunnel.Hostname) + + resp := request(t, "POST", srv.URL+"/api/tunnel/"+tunnel.ID+"/certificate", tunnel.Token, "application/json", `{"csr":`+jsonString(csr)+`}`) + if body := bodyString(t, resp); resp.StatusCode != 202 || !strings.Contains(body, `"type":"issuing"`) { + t.Fatalf("bind: %d %q", resp.StatusCode, body) + } + + // While the issuer holds, the certificate stays "issuing". + resp = request(t, "GET", srv.URL+"/api/tunnel/"+tunnel.ID+"/certificate", tunnel.Token, "", "") + if body := bodyString(t, resp); !strings.Contains(body, `"type":"issuing"`) { + t.Fatalf("before release: %q", body) + } + + close(issuer.release) + + deadline := time.Now().Add(3 * time.Second) + var last string + for time.Now().Before(deadline) { + resp = request(t, "GET", srv.URL+"/api/tunnel/"+tunnel.ID+"/certificate", tunnel.Token, "", "") + last = bodyString(t, resp) + if strings.Contains(last, `"type":"ready"`) { + if !strings.Contains(last, "CERT") || !strings.Contains(last, "CHAIN") { + t.Errorf("ready certificate incomplete: %q", last) + } + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("certificate never became ready; last: %q", last) +} + +func TestRenewalMigratesForeignCertificates(t *testing.T) { + store, err := OpenStore("") + if err != nil { + t.Fatal(err) + } + tunnel, _, err := store.createTunnel("opentunnel.test") + if err != nil { + t.Fatal(err) + } + // A ready certificate from the local CA that expires far in the future: + // only the issuer mismatch should trigger a re-issue. + if err := store.bindCertificate(tunnel.ID, storedCert{ + ID: "cert_old", CSR: "csr-pem", Issuer: "local", State: "ready", + Certificate: "OLDCERT", Chain: "OLDCHAIN", + Expiry: expiryString(time.Now().Add(300 * 24 * time.Hour)), + }); err != nil { + t.Fatal(err) + } + + issuer := &countingIssuer{calls: make(chan string, 4)} + api := &API{Store: store, Hub: NewHub(store), Domain: "opentunnel.test", StartedAt: time.Now(), Issuer: issuer, IssuerName: "fake"} + + api.renewDue(false) // synchronous: the fake returns immediately + + select { + case host := <-issuer.calls: + if host != tunnel.Hostname { + t.Errorf("issued for %q, want %q", host, tunnel.Hostname) + } + default: + t.Fatal("expected a re-issue for the foreign certificate") + } + updated, _ := store.get(tunnel.ID) + if updated.Certificate == nil || updated.Certificate.Certificate != "CERT2" || updated.Certificate.Issuer != "fake" { + t.Errorf("certificate not replaced: %+v", updated.Certificate) + } +} + +func TestFailedRenewalKeepsServingCertificate(t *testing.T) { + store, err := OpenStore("") + if err != nil { + t.Fatal(err) + } + tunnel, _, err := store.createTunnel("opentunnel.test") + if err != nil { + t.Fatal(err) + } + // Already inside the renewal window, from the current issuer. + if err := store.bindCertificate(tunnel.ID, storedCert{ + ID: "cert_old", CSR: "csr-pem", Issuer: "fake", State: "ready", + Certificate: "OLDCERT", Chain: "OLDCHAIN", + Expiry: expiryString(time.Now().Add(10 * 24 * time.Hour)), + }); err != nil { + t.Fatal(err) + } + + api := &API{ + Store: store, Hub: NewHub(store), Domain: "opentunnel.test", StartedAt: time.Now(), + Issuer: &failingIssuer{reason: "dns unavailable"}, IssuerName: "fake", + } + api.renewDue(false) + + updated, _ := store.get(tunnel.ID) + cert := updated.Certificate + if cert == nil || cert.Certificate != "OLDCERT" || cert.Chain != "OLDCHAIN" || cert.Expiry == "" { + t.Fatalf("failed renewal must keep the old certificate: %+v", cert) + } + if cert.State != "failed" || cert.Reason != "dns unavailable" { + t.Errorf("failure not recorded: %+v", cert) + } + if got := certState(cert).Type; got != "ready" { + t.Errorf("clients should still see the old certificate ready, got %q", got) + } + + // The renewal loop retries a failed renewal. + cert.State = "failed" + api.renewDue(false) + if updated, _ = store.get(tunnel.ID); updated.Certificate.Certificate != "OLDCERT" { + t.Errorf("retry lost the old certificate: %+v", updated.Certificate) + } +} + +func TestStaleIssuanceDoesNotClobberNewBind(t *testing.T) { + store, err := OpenStore("") + if err != nil { + t.Fatal(err) + } + tunnel, _, err := store.createTunnel("opentunnel.test") + if err != nil { + t.Fatal(err) + } + if err := store.bindCertificate(tunnel.ID, storedCert{ + ID: "cert_a", CSR: "CSR_A", State: "issuing", Issuer: "fake", + }); err != nil { + t.Fatal(err) + } + + issuer := &blockingIssuer{ + started: make(chan struct{}), + release: make(chan struct{}), + result: IssuedCert{Certificate: "CERTS_A", Chain: "CHAIN_A", Expiry: time.Now().Add(90 * 24 * time.Hour)}, + } + api := &API{ + Store: store, Hub: NewHub(store), Domain: "opentunnel.test", StartedAt: time.Now(), + Issuer: issuer, IssuerName: "fake", + } + done := make(chan struct{}) + go func() { + api.runIssue(tunnel.ID, tunnel.Hostname, "CSR_A", "cert_a") + close(done) + }() + <-issuer.started + + // The client binds a new key while the old order is still running. + if err := store.bindCertificate(tunnel.ID, storedCert{ + ID: "cert_b", CSR: "CSR_B", State: "ready", Issuer: "fake", + Certificate: "CERT_B", Chain: "CHAIN_B", Expiry: expiryString(time.Now().Add(90 * 24 * time.Hour)), + }); err != nil { + t.Fatal(err) + } + close(issuer.release) + <-done + + updated, _ := store.get(tunnel.ID) + if updated.Certificate == nil || updated.Certificate.ID != "cert_b" || updated.Certificate.Certificate != "CERT_B" { + t.Errorf("stale issuance overwrote the new bind: %+v", updated.Certificate) + } +} diff --git a/internal/server/issuer.go b/internal/server/issuer.go new file mode 100644 index 0000000..a1f3577 --- /dev/null +++ b/internal/server/issuer.go @@ -0,0 +1,391 @@ +package server + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "log" + "net" + "net/http" + "os" + "strings" + "sync" + "time" + + "golang.org/x/crypto/acme" +) + +// IssuedCert is a certificate produced for a tunnel's CSR. +type IssuedCert struct { + Certificate string // leaf PEM + Chain string // issuer chain PEM + Expiry time.Time +} + +// Issuer turns a tunnel's CSR into a certificate. The local CA is instant; +// ACME takes a DNS-01 round trip, so callers run those asynchronously. +type Issuer interface { + Instant() bool + Issue(ctx context.Context, csrPEM, hostname string) (IssuedCert, error) +} + +// ---- local CA issuer (development and self-hosting without ACME). + +type localIssuer struct{ store *Store } + +// NewLocalIssuer signs tunnel CSRs with the store's built-in CA. +func NewLocalIssuer(store *Store) Issuer { return &localIssuer{store: store} } + +func (l *localIssuer) Instant() bool { return true } + +func (l *localIssuer) Issue(_ context.Context, csrPEM, hostname string) (IssuedCert, error) { + ca, err := l.store.ca() + if err != nil { + return IssuedCert{}, err + } + certPEM, chainPEM, expiry, err := ca.signCSR(csrPEM, hostname) + if err != nil { + return IssuedCert{}, err + } + return IssuedCert{Certificate: string(certPEM), Chain: string(chainPEM), Expiry: expiry}, nil +} + +// ---- ACME issuer (Let's Encrypt, ZeroSSL, Pebble) with DNS-01 challenges. + +type ACMEOptions struct { + DirectoryURL string + Email string + CABundleFile string // extra roots for the ACME server's TLS (Pebble) + DNS DNSProvider + // PropagationTimeout waits until public resolvers see the challenge + // records; 0 skips the check (used with pebble-challtestsrv). + PropagationTimeout time.Duration +} + +type acmeIssuer struct { + store *Store + opts ACMEOptions + sem chan struct{} // bounds concurrent ACME orders + + accountMu sync.Mutex // serializes first-use account registration +} + +// NewACMEIssuer returns an issuer that orders certificates over ACME and +// answers DNS-01 challenges through the given DNS provider. +func NewACMEIssuer(store *Store, opts ACMEOptions) (Issuer, error) { + if opts.DirectoryURL == "" { + return nil, errors.New("acme: directory URL is required") + } + if opts.DNS == nil { + return nil, errors.New("acme: a DNS provider is required") + } + return &acmeIssuer{store: store, opts: opts, sem: make(chan struct{}, 2)}, nil +} + +func (a *acmeIssuer) Instant() bool { return false } + +// client returns an ACME client with the persisted account (registering one +// on first use). +func (a *acmeIssuer) client(ctx context.Context) (*acme.Client, error) { + a.accountMu.Lock() + defer a.accountMu.Unlock() + + keyPEM, accountURL, directory := a.store.acmeAccount() + storedKey, storedURL, storedDirectory := keyPEM, accountURL, directory + if directory != a.opts.DirectoryURL { + // The persisted account belongs to another ACME server (for example + // staging); register it with this directory instead. + accountURL = "" + } + + var key *ecdsa.PrivateKey + if keyPEM != "" { + block, _ := pem.Decode([]byte(keyPEM)) + if block == nil { + return nil, errors.New("acme: unreadable account key") + } + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("acme: account key: %w", err) + } + ec, ok := parsed.(*ecdsa.PrivateKey) + if !ok { + return nil, errors.New("acme: account key is not ECDSA") + } + key = ec + } else { + generated, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + key = generated + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return nil, err + } + keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})) + } + + httpClient := &http.Client{Timeout: 60 * time.Second} + if a.opts.CABundleFile != "" { + pool, err := x509.SystemCertPool() + if err != nil { + pool = x509.NewCertPool() + } + bundle, err := os.ReadFile(a.opts.CABundleFile) + if err != nil { + return nil, fmt.Errorf("acme: CA bundle: %w", err) + } + if !pool.AppendCertsFromPEM(bundle) { + return nil, errors.New("acme: CA bundle has no certificates") + } + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.TLSClientConfig = &tls.Config{RootCAs: pool} + httpClient.Transport = transport + } + + client := &acme.Client{Key: key, DirectoryURL: a.opts.DirectoryURL, HTTPClient: httpClient} + if accountURL != "" { + // Reuse the persisted account without a GetAccount round trip. + client.KID = acme.KeyID(accountURL) + } + if accountURL == "" { + account := &acme.Account{} + if a.opts.Email != "" { + account.Contact = []string{"mailto:" + a.opts.Email} + } + registered, err := client.Register(ctx, account, acme.AcceptTOS) + switch { + case errors.Is(err, acme.ErrAccountAlreadyExists): + // The key is already registered with this CA; Register cached the + // account URL in client.KID. + accountURL = string(client.KID) + case err != nil: + return nil, fmt.Errorf("acme: register account: %w", err) + default: + accountURL = registered.URI + } + } + if keyPEM != storedKey || accountURL != storedURL || a.opts.DirectoryURL != storedDirectory { + if err := a.store.setACMEAccount(keyPEM, accountURL, a.opts.DirectoryURL); err != nil { + return nil, err + } + } + return client, nil +} + +// Issue orders a certificate for the tunnel's names (hostname and, when the +// CSR asks for it, its wildcard) using one DNS-01 challenge record shared by +// both, then returns the certificate for the client's key. +func (a *acmeIssuer) Issue(ctx context.Context, csrPEM, hostname string) (IssuedCert, error) { + csr, err := parseCSR(csrPEM, hostname) + if err != nil { + return IssuedCert{}, err + } + + select { + case a.sem <- struct{}{}: + case <-ctx.Done(): + return IssuedCert{}, ctx.Err() + } + defer func() { <-a.sem }() + + client, err := a.client(ctx) + if err != nil { + return IssuedCert{}, err + } + + ids := []acme.AuthzID{{Type: "dns", Value: hostname}} + for _, name := range csr.DNSNames { + if name == "*."+hostname { + ids = append(ids, acme.AuthzID{Type: "dns", Value: name}) + } + } + order, err := client.AuthorizeOrder(ctx, ids) + if err != nil { + return IssuedCert{}, fmt.Errorf("acme: authorize order: %w", err) + } + if order.URI == "" || order.FinalizeURL == "" { + return IssuedCert{}, fmt.Errorf("acme: order is missing its URI or finalize URL (status %q)", order.Status) + } + + record := challengeRecordName(hostname) + var values []string + created := false + defer func() { + if !created { + return + } + // Best-effort cleanup, even when the attempt failed or the context is + // done; a leftover record would block the next attempt. + cleanCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + if err := a.opts.DNS.DeleteTXT(cleanCtx, record); err != nil { + log.Printf("acme: cleanup TXT %s: %v", record, err) + } + }() + for _, authzURL := range order.AuthzURLs { + authz, err := client.GetAuthorization(ctx, authzURL) + if err != nil { + return IssuedCert{}, fmt.Errorf("acme: read authorization: %w", err) + } + if authz.Status == acme.StatusValid { + continue + } + if authz.Status != acme.StatusPending { + return IssuedCert{}, authzFailure(authz) + } + challenge := dns01Challenge(authz) + if challenge == nil { + return IssuedCert{}, fmt.Errorf("acme: no dns-01 challenge for %s", authz.Identifier.Value) + } + value, err := client.DNS01ChallengeRecord(challenge.Token) + if err != nil { + return IssuedCert{}, fmt.Errorf("acme: challenge record: %w", err) + } + if err := a.opts.DNS.CreateTXT(ctx, record, value); err != nil { + return IssuedCert{}, fmt.Errorf("dns: create %s: %w", record, err) + } + created = true + values = append(values, value) + } + + if a.opts.PropagationTimeout > 0 { + if err := waitForTXT(ctx, record, values, a.opts.PropagationTimeout); err != nil { + return IssuedCert{}, err + } + } + + for _, authzURL := range order.AuthzURLs { + authz, err := client.GetAuthorization(ctx, authzURL) + if err != nil { + return IssuedCert{}, fmt.Errorf("acme: read authorization: %w", err) + } + if authz.Status == acme.StatusValid { + continue + } + if authz.Status != acme.StatusPending { + return IssuedCert{}, authzFailure(authz) + } + challenge := dns01Challenge(authz) + if challenge == nil { + continue + } + if _, err := client.Accept(ctx, challenge); err != nil { + return IssuedCert{}, fmt.Errorf("acme: accept challenge: %w", err) + } + if _, err := client.WaitAuthorization(ctx, authzURL); err != nil { + return IssuedCert{}, fmt.Errorf("acme: authorization: %w", err) + } + } + + // All authorizations are satisfied; wait for the CA to mark the order + // ready before submitting the CSR (Let's Encrypt rejects finalization + // while the order is still pending). + order, err = client.WaitOrder(ctx, order.URI) + if err != nil { + return IssuedCert{}, fmt.Errorf("acme: wait order: %w", err) + } + var der [][]byte + if order.Status == acme.StatusValid && order.CertURL != "" { + der, err = client.FetchCert(ctx, order.CertURL, true) + } else { + der, _, err = client.CreateCertFromOrder(ctx, order, csr.Raw, true) + } + if err != nil { + return IssuedCert{}, fmt.Errorf("acme: finalize order: %w", err) + } + if len(der) == 0 { + return IssuedCert{}, errors.New("acme: empty certificate chain") + } + leaf, err := x509.ParseCertificate(der[0]) + if err != nil { + return IssuedCert{}, fmt.Errorf("acme: parse certificate: %w", err) + } + var certPEM, chainPEM strings.Builder + for i, certDER := range der { + block := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + if i == 0 { + certPEM.Write(block) + } else { + chainPEM.Write(block) + } + } + return IssuedCert{Certificate: certPEM.String(), Chain: chainPEM.String(), Expiry: leaf.NotAfter}, nil +} + +func dns01Challenge(authz *acme.Authorization) *acme.Challenge { + for _, challenge := range authz.Challenges { + if challenge.Type == "dns-01" { + return challenge + } + } + return nil +} + +// authzFailure explains why an authorization cannot be used, including the +// CA's validation error when it reported one. +func authzFailure(authz *acme.Authorization) error { + for _, challenge := range authz.Challenges { + if challenge.Error != nil { + return fmt.Errorf("acme: authorization for %s is %s: %w", authz.Identifier.Value, authz.Status, challenge.Error) + } + } + return fmt.Errorf("acme: authorization for %s is %s", authz.Identifier.Value, authz.Status) +} + +// waitForTXT polls until every challenge value is visible at the record name. +// It tries the system resolver first, then public resolvers directly, so a +// cached negative answer on the container's resolver cannot hide a record +// that the CA will see. +func waitForTXT(ctx context.Context, record string, values []string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + resolvers := []*net.Resolver{net.DefaultResolver, resolverAt("1.1.1.1:53"), resolverAt("8.8.8.8:53")} + for { + for _, resolver := range resolvers { + queryCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + seen, err := resolver.LookupTXT(queryCtx, record) + cancel() + if err != nil { + continue + } + present := 0 + for _, want := range values { + for _, got := range seen { + if got == want { + present++ + break + } + } + } + if present == len(values) { + return nil + } + } + if time.Now().After(deadline) { + return fmt.Errorf("dns: TXT record %s did not propagate within %s", record, timeout) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): + } + } +} + +// resolverAt queries one DNS server directly instead of the system resolver. +func resolverAt(address string) *net.Resolver { + return &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, _ string) (net.Conn, error) { + dialer := net.Dialer{Timeout: 4 * time.Second} + return dialer.DialContext(ctx, network, address) + }, + } +} diff --git a/internal/server/localca.go b/internal/server/localca.go index 33d0e16..8506c02 100644 --- a/internal/server/localca.go +++ b/internal/server/localca.go @@ -120,29 +120,39 @@ func (e errCSRHostname) Error() string { return "CSR hostname " + e.provided + " does not match tunnel hostname " + e.expected } +// parseCSR decodes and validates a tunnel CSR: the common name must be the +// tunnel hostname and every DNS name must be the hostname or its wildcard. +func parseCSR(csrPEM, hostname string) (*x509.CertificateRequest, error) { + block, _ := pem.Decode([]byte(csrPEM)) + if block == nil || block.Type != "CERTIFICATE REQUEST" { + return nil, errors.New("not a PEM certificate request") + } + csr, err := x509.ParseCertificateRequest(block.Bytes) + if err != nil { + return nil, err + } + if err := csr.CheckSignature(); err != nil { + return nil, fmt.Errorf("CSR signature: %w", err) + } + if csr.Subject.CommonName != hostname { + return nil, errCSRHostname{provided: csr.Subject.CommonName, expected: hostname} + } + for _, name := range csr.DNSNames { + if name != hostname && name != "*."+hostname { + return nil, errCSRHostname{provided: name, expected: hostname} + } + } + return csr, nil +} + // 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) + csr, err := parseCSR(csrPEM, hostname) 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(), diff --git a/internal/server/relay.go b/internal/server/relay.go index 3bfa982..951693e 100644 --- a/internal/server/relay.go +++ b/internal/server/relay.go @@ -53,7 +53,7 @@ func (r *Relay) handle(conn net.Conn) { return } tunnel, ok := r.Store.get(tunnelID) - if !ok || tunnel.Certificate == nil { + if !ok || !certReady(tunnel.Certificate) { log.Printf("relay: %s: tunnel %s unknown or certificate not ready", conn.RemoteAddr(), tunnelID) conn.Close() // unknown tunnel or certificate not ready return diff --git a/internal/server/renew.go b/internal/server/renew.go new file mode 100644 index 0000000..83ad011 --- /dev/null +++ b/internal/server/renew.go @@ -0,0 +1,70 @@ +package server + +import ( + "context" + "log" + "time" +) + +// renewalWindow re-issues certificates this long before they expire. +const renewalWindow = 30 * 24 * time.Hour + +// StartRenewal keeps certificates fresh: it re-issues those close to expiry, +// retries failures, recovers issuances interrupted by a restart, and migrates +// certificates from a previous issuer (say the local CA) once a real issuer +// is configured. It runs at startup and every 6 hours. +func (a *API) StartRenewal(ctx context.Context) { + go func() { + a.renewDue(true) + ticker := time.NewTicker(6 * time.Hour) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + a.renewDue(false) + } + } + }() +} + +func (a *API) renewDue(firstRun bool) { + current := a.issuerName() + for _, tunnel := range a.Store.Tunnels() { + cert := tunnel.Certificate + if cert == nil || cert.CSR == "" { + continue + } + switch { + case cert.State == "issuing": + // An in-flight issuance cannot survive a restart; retry on the + // first pass after startup. + if firstRun { + a.runIssue(tunnel.ID, tunnel.Hostname, cert.CSR, cert.ID) + } + case certReady(cert): + needs := normalizedIssuer(cert.Issuer) != current + if !needs && cert.Expiry != "" { + if expiry, err := time.Parse(time.RFC3339, cert.Expiry); err == nil { + needs = time.Until(expiry) < renewalWindow + } + } + if needs { + log.Printf("renewing certificate for %s (issuer %q, expires %s)", tunnel.Hostname, cert.Issuer, cert.Expiry) + a.runIssue(tunnel.ID, tunnel.Hostname, cert.CSR, cert.ID) + } + case cert.State == "failed": + a.runIssue(tunnel.ID, tunnel.Hostname, cert.CSR, cert.ID) + } + } +} + +// normalizedIssuer treats certificates from before the issuer was recorded +// as local-CA certificates. +func normalizedIssuer(issuer string) string { + if issuer == "" { + return "local" + } + return issuer +} diff --git a/internal/server/store.go b/internal/server/store.go index 586aa1e..c507ac3 100644 --- a/internal/server/store.go +++ b/internal/server/store.go @@ -12,14 +12,23 @@ import ( "time" ) -// storedCert is one issued certificate. With the local issuer, issuance is -// synchronous, so a bound CSR always has its certificate populated. +// storedCert is one certificate for a tunnel. Issuance is asynchronous with +// ACME: the cert starts as "issuing", and either becomes ready (Certificate +// set) or failed with a reason. type storedCert struct { ID string `json:"id"` CSR string `json:"csr"` - Certificate string `json:"certificate"` - Chain string `json:"chain"` - Expiry string `json:"expiry"` + State string `json:"state,omitempty"` // "issuing" | "failed"; empty or Certificate set means ready + Reason string `json:"reason,omitempty"` // when State is "failed" + Issuer string `json:"issuer,omitempty"` // "local" or the ACME directory URL + Certificate string `json:"certificate,omitempty"` + Chain string `json:"chain,omitempty"` + Expiry string `json:"expiry,omitempty"` +} + +// certReady reports whether the certificate can serve TLS now. +func certReady(cert *storedCert) bool { + return cert != nil && cert.Certificate != "" } type storedTunnel struct { @@ -33,11 +42,14 @@ type storedTunnel struct { // 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"` + CAKeyPEM string `json:"caKeyPem"` + CACertPEM string `json:"caCertPEM"` + ServerKey string `json:"serverKey"` + ServerCrt string `json:"serverCrt"` + ACMEKey string `json:"acmeKey,omitempty"` + ACMEAccountURL string `json:"acmeAccountUrl,omitempty"` + ACMEDirectory string `json:"acmeDirectory,omitempty"` + Tunnels map[string]storedTunnel `json:"tunnels"` } // Store is a JSON-file-backed tunnel store: adequate for a single-node @@ -170,6 +182,24 @@ func (s *Store) bindCertificate(id string, cert storedCert) error { return s.saveLocked() } +// acmeAccount returns the persisted ACME account key (PEM), account URL, and +// the directory they belong to; any of them may be empty before first use. +func (s *Store) acmeAccount() (keyPEM, accountURL, directory string) { + s.mu.Lock() + defer s.mu.Unlock() + return s.data.ACMEKey, s.data.ACMEAccountURL, s.data.ACMEDirectory +} + +// setACMEAccount persists the ACME account key, URL, and directory. +func (s *Store) setACMEAccount(keyPEM, accountURL, directory string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.data.ACMEKey = keyPEM + s.data.ACMEAccountURL = accountURL + s.data.ACMEDirectory = directory + return s.saveLocked() +} + // remove deletes the tunnel. func (s *Store) remove(id string) error { s.mu.Lock() diff --git a/web/src/App.svelte b/web/src/App.svelte index 0ba692e..e851cac 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -4,6 +4,7 @@ import SummaryCards from "./lib/SummaryCards.svelte"; import TunnelsTable from "./lib/TunnelsTable.svelte"; import BridgesTable from "./lib/BridgesTable.svelte"; + import { fmtIssuer } from "./lib/format"; const KEY = "tunneld-admin-token"; const REFRESH_MS = 5000; @@ -56,7 +57,7 @@

tunneld admin

{#if overview} - {overview.server.domain} · v{overview.server.version} · {overview.server.go} + {overview.server.domain} · v{overview.server.version} · {overview.server.go} · {fmtIssuer(overview.server.issuer)} {/if} diff --git a/web/src/lib/SummaryCards.svelte b/web/src/lib/SummaryCards.svelte index 03a4e6d..2e893df 100644 --- a/web/src/lib/SummaryCards.svelte +++ b/web/src/lib/SummaryCards.svelte @@ -10,7 +10,7 @@
Online
{totals.online}
Bridges
{totals.bridges}
Visitors
{totals.visitors}
-
CA expires
{fmtDate(ca.expires)}
+
Local CA expires
{fmtDate(ca.expires)}
Uptime
{fmtUptime(server.uptime_seconds)}
diff --git a/web/src/lib/TunnelsTable.svelte b/web/src/lib/TunnelsTable.svelte index cc11899..0085cb0 100644 --- a/web/src/lib/TunnelsTable.svelte +++ b/web/src/lib/TunnelsTable.svelte @@ -37,7 +37,16 @@ {/if} {tunnel.visitors} - {fmtDate(tunnel.certificate_expiry)} + + {fmtDate(tunnel.certificate_expiry)} + {#if tunnel.certificate_state === "issuing"} + issuing… + {:else if tunnel.certificate_state === "failed"} + failed + {:else if tunnel.certificate_error} + retrying + {/if} + {fmtDate(tunnel.created_at)} {/each} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index aef8105..3a7d578 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -4,6 +4,7 @@ export interface ServerInfo { domain: string; version: string; go: string; + issuer: string; uptime_seconds: number; } @@ -25,6 +26,9 @@ export interface Tunnel { routes: string[] | null; visitors: number; certificate_expiry?: string; + certificate_state?: "issuing" | "ready" | "failed"; + certificate_issuer?: string; + certificate_error?: string; created_at?: string; } diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index 2438253..c6f34ce 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -16,6 +16,11 @@ export function fmtRoutes(routes: string[] | null): string { return routes && routes.length > 0 ? routes.join(", ") : "–"; } +export function fmtIssuer(issuer: string): string { + if (!issuer || issuer === "local") return "local CA"; + return issuer.replace(/^https?:\/\//, "").replace(/\/directory$/, ""); +} + // routeUrl is the public URL a route is served at: "@" is the tunnel // hostname itself, any other route is a subdomain of it. export function routeUrl(hostname: string, route: string): string {