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