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
|
- `internal/server/localca.go` — the built-in insecure CA (the reference's
|
||||||
`ISSUER=local` equivalent); signs tunnel CSRs after checking CN == hostname
|
`ISSUER=local` equivalent); signs tunnel CSRs after checking CN == hostname
|
||||||
and SANs ⊆ {hostname, `*.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
|
- `internal/server/store.go` — JSON-file store (0600, atomic rename); single
|
||||||
writer, self-hosted scale
|
writer, self-hosted scale
|
||||||
- `internal/server/api.go` — HTTP API per the contract; tunnel existence is
|
- `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
|
(`HostSNIRegexp` + `tls.passthrough=true`) → container :8443. See README
|
||||||
"Deploy on Coolify".
|
"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**,
|
- **macOS system curl (LibreSSL) does not send SNI for `*.localhost` names**,
|
||||||
so `curl https://api.<id>.localhost:8443/` gets closed (the relay requires
|
so `curl https://api.<id>.localhost:8443/` gets closed (the relay requires
|
||||||
SNI). Test with `openssl s_client -servername ...` or Homebrew curl.
|
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.
|
4+32 KiB after.
|
||||||
- No third-party deps beyond `github.com/coder/websocket`; keep it that way
|
- No third-party deps beyond `github.com/coder/websocket`; keep it that way
|
||||||
unless a milestone calls for it.
|
unless a milestone calls for it.
|
||||||
- Milestones: M1 core data path (done); M2 real ACME DNS-01 + renewals; M3
|
- Milestones: M1 core data path (done); M2 real ACME DNS-01 + renewals (done);
|
||||||
rate limits/admin/hardening; M4 maybe a Go client. Don't pull M2+ scope
|
M3 rate limits/admin/hardening; M4 maybe a Go client. Don't pull M2+ scope
|
||||||
into M1 fixes.
|
into M1 fixes, or M3 scope into M2 fixes.
|
||||||
|
|||||||
@@ -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
|
`docs/protocol.md`, so the official `opentunnel` CLI and SDK work against it
|
||||||
unchanged.
|
unchanged.
|
||||||
|
|
||||||
Status: **M1** — core data path works end-to-end with the official CLI,
|
Status: **M2** — core data path works end-to-end with the official CLI, and
|
||||||
using the built-in local CA for certificates.
|
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)
|
## 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) |
|
| `-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 |
|
| `-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 |
|
| `-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
|
## 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
|
- Domain `https://tunnel.example.com`; `ports_exposes=8080` (Traefik routes
|
||||||
HTTPS → the plain-HTTP API port).
|
HTTPS → the plain-HTTP API port).
|
||||||
- Env: `OPENTUNNEL_DOMAIN=tunnel.example.com`, `HTTP_MODE=serve` (the API
|
- 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):
|
- Custom Traefik labels (Coolify stores them base64-encoded):
|
||||||
`traefik.tcp.routers.tunnel-data.entrypoints=https`,
|
`traefik.tcp.routers.tunnel-data.entrypoints=https`,
|
||||||
`...rule=HostSNIRegexp(`^.+\.tunnel\.example\.com$`)`,
|
`...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
|
serves `/health` on 80 and 301s the rest), volume on `/app/data`. No
|
||||||
TLS-terminating proxy may sit in front of the tunnel ports.
|
TLS-terminating proxy may sit in front of the tunnel ports.
|
||||||
|
|
||||||
Until M2 lands, certificates come from the server's built-in local CA:
|
Without ACME credentials, certificates come from the server's built-in local
|
||||||
clients need `--ca-cert <data dir>/ca.pem` (or `OPENTUNNEL_CA_CERT`), and
|
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
|
visitors must trust that CA. With Porkbun configured, every tunnel gets a
|
||||||
publicly trusted.
|
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
|
- The full HTTP API contract (paths, status codes, bodies, key order) as
|
||||||
recorded in the reference's `worker-contract.json`
|
recorded in the reference's `worker-contract.json`
|
||||||
- Provisioning: tunnel creation, CSR bind with the reference's hostname rules,
|
- 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,
|
- The bridge WebSocket: attach/attached, route claiming with conflicts,
|
||||||
heartbeats, idle retirement, `open`/`end`/`reset`, 32 KiB data frames
|
heartbeats, idle retirement, `open`/`end`/`reset`, 32 KiB data frames
|
||||||
- SNI routing on the TLS listener: the domain is terminated (API), tunnel
|
- SNI routing on the TLS listener: the domain is terminated (API), tunnel
|
||||||
hostnames are passed through still encrypted
|
hostnames are passed through still encrypted
|
||||||
- Admin dashboard at `/` (bearer `ADMIN_TOKEN`): tunnels, bridges, CA expiry
|
- Admin dashboard at `/` (bearer `ADMIN_TOKEN`): tunnels, bridges, cert
|
||||||
- Persistence: JSON store with the CA, server certificate, and tunnels
|
state/expiry, issuer, CA expiry
|
||||||
|
- Persistence: JSON store with the CA, ACME account, server certificate, and
|
||||||
|
tunnels
|
||||||
- Graceful shutdown: `drain` + close to every bridge
|
- Graceful shutdown: `drain` + close to every bridge
|
||||||
|
|
||||||
## Not yet (later milestones)
|
## Not yet (later milestones)
|
||||||
|
|
||||||
- Real ACME issuance (DNS-01) and renewal jobs — currently local CA only
|
- Issuance rate limits, CA cool-down, stale-DNS-record sweeps
|
||||||
- Rate limits, metrics, admin endpoints
|
- Metrics, more admin endpoints
|
||||||
- Multi-region mesh, inbound PROXY protocol on the TLS listener
|
- Multi-region mesh, inbound PROXY protocol on the TLS listener
|
||||||
- 60 s half-close timeout, per-connection flow control, control messages
|
- 60 s half-close timeout, per-connection flow control, control messages
|
||||||
cutting ahead of queued data (single ordered outbound queue today)
|
cutting ahead of queued data (single ordered outbound queue today)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"flag"
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
@@ -12,6 +13,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -29,6 +31,15 @@ func envOr(key, fallback string) string {
|
|||||||
return fallback
|
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 {
|
func defaultDataDir() string {
|
||||||
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
|
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
|
||||||
return filepath.Join(xdg, "tunneld")
|
return filepath.Join(xdg, "tunneld")
|
||||||
@@ -49,6 +60,15 @@ func main() {
|
|||||||
dataPath := flag.String("data", envOr("TUNNELD_DATA", dataDefault), "tunnel store file (created 0600)")
|
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")
|
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")
|
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()
|
flag.Parse()
|
||||||
|
|
||||||
if *caFile == "" {
|
if *caFile == "" {
|
||||||
@@ -75,6 +95,46 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hub := server.NewHub(store)
|
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{
|
api := &server.API{
|
||||||
Store: store,
|
Store: store,
|
||||||
Hub: hub,
|
Hub: hub,
|
||||||
@@ -82,6 +142,8 @@ func main() {
|
|||||||
AdminToken: *adminToken,
|
AdminToken: *adminToken,
|
||||||
StartedAt: time.Now(),
|
StartedAt: time.Now(),
|
||||||
Version: version,
|
Version: version,
|
||||||
|
Issuer: issuer,
|
||||||
|
IssuerName: issuerName,
|
||||||
}
|
}
|
||||||
if *adminToken == "" {
|
if *adminToken == "" {
|
||||||
log.Printf("admin dashboard: disabled (set ADMIN_TOKEN to enable it at /)")
|
log.Printf("admin dashboard: disabled (set ADMIN_TOKEN to enable it at /)")
|
||||||
@@ -89,6 +151,10 @@ func main() {
|
|||||||
log.Printf("admin dashboard: enabled at /")
|
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
|
// Public TLS listener: SNI routing, with API terminations handed to the
|
||||||
// shared HTTP server.
|
// shared HTTP server.
|
||||||
ln, err := net.Listen("tcp", *tlsListen)
|
ln, err := net.Listen("tcp", *tlsListen)
|
||||||
@@ -133,6 +199,7 @@ func main() {
|
|||||||
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
|
||||||
sig := <-signals
|
sig := <-signals
|
||||||
log.Printf("received %s; draining bridges", sig)
|
log.Printf("received %s; draining bridges", sig)
|
||||||
|
cancel()
|
||||||
hub.DrainAll()
|
hub.DrainAll()
|
||||||
ln.Close()
|
ln.Close()
|
||||||
if httpLn != nil {
|
if httpLn != nil {
|
||||||
|
|||||||
@@ -2,4 +2,7 @@ module gitea.tzgyn.dev/TZGyn/tunnel
|
|||||||
|
|
||||||
go 1.27.3
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
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=
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type adminServer struct {
|
|||||||
Domain string `json:"domain"`
|
Domain string `json:"domain"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Go string `json:"go"`
|
Go string `json:"go"`
|
||||||
|
Issuer string `json:"issuer"`
|
||||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +40,9 @@ type adminTunnel struct {
|
|||||||
Routes []string `json:"routes"`
|
Routes []string `json:"routes"`
|
||||||
Visitors int `json:"visitors"`
|
Visitors int `json:"visitors"`
|
||||||
CertificateExpiry string `json:"certificate_expiry,omitempty"`
|
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"`
|
CreatedAt string `json:"created_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +102,7 @@ func (a *API) adminOverviewHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
Domain: a.Domain,
|
Domain: a.Domain,
|
||||||
Version: a.Version,
|
Version: a.Version,
|
||||||
Go: runtime.Version(),
|
Go: runtime.Version(),
|
||||||
|
Issuer: a.issuerName(),
|
||||||
UptimeSeconds: int64(time.Since(a.StartedAt).Seconds()),
|
UptimeSeconds: int64(time.Since(a.StartedAt).Seconds()),
|
||||||
},
|
},
|
||||||
CA: adminCA{Expires: a.caExpiry()},
|
CA: adminCA{Expires: a.caExpiry()},
|
||||||
@@ -137,6 +142,9 @@ func (a *API) adminOverviewHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
if t.Certificate != nil {
|
if t.Certificate != nil {
|
||||||
entry.CertificateExpiry = t.Certificate.Expiry
|
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)
|
overview.Tunnels = append(overview.Tunnels, entry)
|
||||||
}
|
}
|
||||||
|
|||||||
+154
-31
@@ -1,12 +1,15 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"mime"
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,6 +23,12 @@ type API struct {
|
|||||||
AdminToken string // bearer token for /api/admin/*; empty disables it
|
AdminToken string // bearer token for /api/admin/*; empty disables it
|
||||||
StartedAt time.Time // for the uptime shown on the dashboard
|
StartedAt time.Time // for the uptime shown on the dashboard
|
||||||
Version string
|
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) {
|
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)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Binding the CSR the tunnel already has answers with its current
|
// Binding the CSR the tunnel already has answers with its current state;
|
||||||
// certificate; a different one re-issues (with the local CA, at once).
|
// a different CSR re-issues. A failed issuance for the same CSR retries.
|
||||||
if tunnel.Certificate != nil && tunnel.Certificate.CSR == csr {
|
cert := tunnel.Certificate
|
||||||
a.writeCertificate(w, http.StatusAccepted, tunnel.Certificate)
|
if cert != nil && cert.CSR == csr {
|
||||||
return
|
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 (cert != nil && cert.State == "issuing") || a.isIssuing(id) {
|
||||||
if err != nil {
|
writeJSON(w, http.StatusConflict, errTaggedID{
|
||||||
writeJSON(w, http.StatusServiceUnavailable, errInvalidRequest{
|
Tag: "CertificateInProgressError", TunnelID: id,
|
||||||
Tag: "ServiceUnavailableError", Message: "storage unavailable",
|
Message: "Certificate issuance is already in progress",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
certPEM, chainPEM, expiry, err := ca.signCSR(csr, tunnel.Hostname)
|
if _, err := parseCSR(csr, tunnel.Hostname); err != nil {
|
||||||
if err != nil {
|
|
||||||
var hostErr errCSRHostname
|
var hostErr errCSRHostname
|
||||||
if errors.As(err, &hostErr) {
|
if errors.As(err, &hostErr) {
|
||||||
writeJSON(w, http.StatusBadRequest, errInvalidHostname{
|
writeJSON(w, http.StatusBadRequest, errInvalidHostname{
|
||||||
@@ -197,20 +215,13 @@ func (a *API) bindCertificate(w http.ResponseWriter, r *http.Request, id string)
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cert := storedCert{
|
fresh := storedCert{ID: certID(), CSR: csr, State: "issuing", Issuer: a.issuerName()}
|
||||||
ID: certID(),
|
if err := a.Store.bindCertificate(id, fresh); err != nil {
|
||||||
CSR: csr,
|
|
||||||
Certificate: string(certPEM),
|
|
||||||
Chain: string(chainPEM),
|
|
||||||
Expiry: expiryString(expiry),
|
|
||||||
}
|
|
||||||
if err := a.Store.bindCertificate(id, cert); err != nil {
|
|
||||||
notFoundEmpty(w)
|
notFoundEmpty(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// The contract's bind response is 202 with state "issuing"; with the local
|
a.startIssue(tunnel.ID, tunnel.Hostname, csr, fresh.ID)
|
||||||
// CA the very next GET already answers "ready".
|
writeJSON(w, http.StatusAccepted, CertificateInfo{ID: fresh.ID, State: CertState{Type: "issuing"}})
|
||||||
writeJSON(w, http.StatusAccepted, CertificateInfo{ID: cert.ID, State: CertState{Type: "issuing"}})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- GET /api/tunnel/{id}/certificate
|
// ---- 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) {
|
func (a *API) writeCertificate(w http.ResponseWriter, status int, cert *storedCert) {
|
||||||
writeJSON(w, status, CertificateInfo{
|
writeJSON(w, status, CertificateInfo{ID: cert.ID, State: certState(cert)})
|
||||||
ID: cert.ID,
|
}
|
||||||
State: CertState{
|
|
||||||
Type: "ready",
|
// certState maps a stored certificate to the wire state.
|
||||||
Certificate: cert.Certificate,
|
func certState(cert *storedCert) CertState {
|
||||||
Chain: cert.Chain,
|
switch {
|
||||||
Expiry: cert.Expiry,
|
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
|
// ---- GET /api/tunnel/{id}/connect
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ func (h *Hub) attach(w http.ResponseWriter, r *http.Request, tunnel storedTunnel
|
|||||||
fail("bad_token")
|
fail("bad_token")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if tunnel.Certificate == nil {
|
if !certReady(tunnel.Certificate) {
|
||||||
fail("cert_not_ready")
|
fail("cert_not_ready")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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{})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -120,29 +120,39 @@ func (e errCSRHostname) Error() string {
|
|||||||
return "CSR hostname " + e.provided + " does not match tunnel hostname " + e.expected
|
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
|
// 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
|
// the reference's job runner: the common name must be the tunnel hostname and
|
||||||
// every DNS name must be the hostname or its wildcard.
|
// 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) {
|
func (ca *localCA) signCSR(csrPEM, hostname string) (certPEM, chainPEM []byte, expiry time.Time, err error) {
|
||||||
block, _ := pem.Decode([]byte(csrPEM))
|
csr, err := parseCSR(csrPEM, hostname)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, time.Time{}, err
|
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)
|
expiry = time.Now().Add(90 * 24 * time.Hour)
|
||||||
der, err := ca.sign(&x509.Certificate{
|
der, err := ca.sign(&x509.Certificate{
|
||||||
SerialNumber: newSerial(),
|
SerialNumber: newSerial(),
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ func (r *Relay) handle(conn net.Conn) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
tunnel, ok := r.Store.get(tunnelID)
|
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)
|
log.Printf("relay: %s: tunnel %s unknown or certificate not ready", conn.RemoteAddr(), tunnelID)
|
||||||
conn.Close() // unknown tunnel or certificate not ready
|
conn.Close() // unknown tunnel or certificate not ready
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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
@@ -12,14 +12,23 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// storedCert is one issued certificate. With the local issuer, issuance is
|
// storedCert is one certificate for a tunnel. Issuance is asynchronous with
|
||||||
// synchronous, so a bound CSR always has its certificate populated.
|
// ACME: the cert starts as "issuing", and either becomes ready (Certificate
|
||||||
|
// set) or failed with a reason.
|
||||||
type storedCert struct {
|
type storedCert struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
CSR string `json:"csr"`
|
CSR string `json:"csr"`
|
||||||
Certificate string `json:"certificate"`
|
State string `json:"state,omitempty"` // "issuing" | "failed"; empty or Certificate set means ready
|
||||||
Chain string `json:"chain"`
|
Reason string `json:"reason,omitempty"` // when State is "failed"
|
||||||
Expiry string `json:"expiry"`
|
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 {
|
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
|
// storeData is the on-disk shape. The CA key is a secret, so the file is
|
||||||
// written 0600.
|
// written 0600.
|
||||||
type storeData struct {
|
type storeData struct {
|
||||||
CAKeyPEM string `json:"caKeyPem"`
|
CAKeyPEM string `json:"caKeyPem"`
|
||||||
CACertPEM string `json:"caCertPEM"`
|
CACertPEM string `json:"caCertPEM"`
|
||||||
ServerKey string `json:"serverKey"`
|
ServerKey string `json:"serverKey"`
|
||||||
ServerCrt string `json:"serverCrt"`
|
ServerCrt string `json:"serverCrt"`
|
||||||
Tunnels map[string]storedTunnel `json:"tunnels"`
|
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
|
// 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()
|
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.
|
// remove deletes the tunnel.
|
||||||
func (s *Store) remove(id string) error {
|
func (s *Store) remove(id string) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
|||||||
+2
-1
@@ -4,6 +4,7 @@
|
|||||||
import SummaryCards from "./lib/SummaryCards.svelte";
|
import SummaryCards from "./lib/SummaryCards.svelte";
|
||||||
import TunnelsTable from "./lib/TunnelsTable.svelte";
|
import TunnelsTable from "./lib/TunnelsTable.svelte";
|
||||||
import BridgesTable from "./lib/BridgesTable.svelte";
|
import BridgesTable from "./lib/BridgesTable.svelte";
|
||||||
|
import { fmtIssuer } from "./lib/format";
|
||||||
|
|
||||||
const KEY = "tunneld-admin-token";
|
const KEY = "tunneld-admin-token";
|
||||||
const REFRESH_MS = 5000;
|
const REFRESH_MS = 5000;
|
||||||
@@ -56,7 +57,7 @@
|
|||||||
<h1>tunneld admin</h1>
|
<h1>tunneld admin</h1>
|
||||||
<span class="meta">
|
<span class="meta">
|
||||||
{#if overview}
|
{#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}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
|
|||||||
@@ -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">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">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">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 class="card"><div class="label">Uptime</div><div class="value small">{fmtUptime(server.uptime_seconds)}</div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,16 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td>{tunnel.visitors}</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>
|
<td>{fmtDate(tunnel.created_at)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface ServerInfo {
|
|||||||
domain: string;
|
domain: string;
|
||||||
version: string;
|
version: string;
|
||||||
go: string;
|
go: string;
|
||||||
|
issuer: string;
|
||||||
uptime_seconds: number;
|
uptime_seconds: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +26,9 @@ export interface Tunnel {
|
|||||||
routes: string[] | null;
|
routes: string[] | null;
|
||||||
visitors: number;
|
visitors: number;
|
||||||
certificate_expiry?: string;
|
certificate_expiry?: string;
|
||||||
|
certificate_state?: "issuing" | "ready" | "failed";
|
||||||
|
certificate_issuer?: string;
|
||||||
|
certificate_error?: string;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ export function fmtRoutes(routes: string[] | null): string {
|
|||||||
return routes && routes.length > 0 ? routes.join(", ") : "–";
|
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
|
// routeUrl is the public URL a route is served at: "@" is the tunnel
|
||||||
// hostname itself, any other route is a subdomain of it.
|
// hostname itself, any other route is a subdomain of it.
|
||||||
export function routeUrl(hostname: string, route: string): string {
|
export function routeUrl(hostname: string, route: string): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user