Issue real certificates via ACME DNS-01

Tunnels now get a per-tunnel certificate from the configured ACME CA
(Let's Encrypt by default) with DNS-01 through Porkbun, keeping the
client's CSR and key. Issuance is asynchronous (issuing/ready/failed);
the account persists in the store, renewals run in a 30-day window with
retries, and a failed renewal keeps the old certificate serving.
Switching from the local CA reissues existing tunnels automatically,
and the admin dashboard shows the issuer and certificate state.

Verified against Pebble and Let's Encrypt staging.
This commit is contained in:
2026-10-25 23:33:52 +08:00
parent 05d326f47f
commit 437a2e447e
25 changed files with 1491 additions and 79 deletions
+26 -3
View File
@@ -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.<id>.<domain>` (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.<id>.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.
+77 -13
View File
@@ -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` | `<data dir>/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 `<data dir>/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 `<id>.<domain>` and,
when the CSR asks for it, `*.<id>.<domain>`. 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.<id>.<domain>`; 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 <data dir>/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 <data dir>/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 (`<id>.<domain>` + 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)
+67
View File
@@ -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 {
+4 -1
View File
@@ -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
)
+2
View File
@@ -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=
+8
View File
@@ -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)
}
+154 -31
View File
@@ -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
+1 -1
View File
@@ -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
}
+42
View File
@@ -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
}
+62
View File
@@ -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})
}
+57
View File
@@ -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")
}
}
+89
View File
@@ -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{})
}
+74
View File
@@ -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)
}
}
+33
View File
@@ -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)
}
}
}
+245
View File
@@ -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)
}
}
+391
View File
@@ -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)
},
}
}
+26 -16
View File
@@ -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(),
+1 -1
View File
@@ -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
+70
View File
@@ -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
}
+40 -10
View File
@@ -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()
+2 -1
View File
@@ -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 @@
<h1>tunneld admin</h1>
<span class="meta">
{#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}
</span>
<span class="spacer"></span>
+1 -1
View File
@@ -10,7 +10,7 @@
<div class="card"><div class="label">Online</div><div class="value">{totals.online}</div></div>
<div class="card"><div class="label">Bridges</div><div class="value">{totals.bridges}</div></div>
<div class="card"><div class="label">Visitors</div><div class="value">{totals.visitors}</div></div>
<div class="card"><div class="label">CA expires</div><div class="value small">{fmtDate(ca.expires)}</div></div>
<div class="card"><div class="label">Local CA expires</div><div class="value small">{fmtDate(ca.expires)}</div></div>
<div class="card"><div class="label">Uptime</div><div class="value small">{fmtUptime(server.uptime_seconds)}</div></div>
</div>
+10 -1
View File
@@ -37,7 +37,16 @@
{/if}
</td>
<td>{tunnel.visitors}</td>
<td>{fmtDate(tunnel.certificate_expiry)}</td>
<td title={tunnel.certificate_error ?? ""}>
{fmtDate(tunnel.certificate_expiry)}
{#if tunnel.certificate_state === "issuing"}
<span class="dim"> issuing…</span>
{:else if tunnel.certificate_state === "failed"}
<span class="pill offline">failed</span>
{:else if tunnel.certificate_error}
<span class="dim"> retrying</span>
{/if}
</td>
<td>{fmtDate(tunnel.created_at)}</td>
</tr>
{/each}
+4
View File
@@ -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;
}
+5
View File
@@ -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 {