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.