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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user