router: wildcard TLS for space base domains (ACME DNS-01 via DNSimple) (#421)
* router: wildcard TLS for space base domains (ACME DNS-01 via DNSimple) Every new space burned one per-host Let's Encrypt cert (autocert HTTP-01), riding the ~50 certs/week/registered-domain ceiling — a hard cap on space creation that fails at the worst moment (new signups). The router can now hold a *.<domain> wildcard obtained over DNS-01 and serve it for any host exactly one label under a configured base domain. - wildcard.go: wildcardManager — renewal loop (startup + 12h, hourly retry after failure, renew <30d from expiry), ACME order via x/crypto/acme (no new deps), _acme-challenge TXT published + always cleaned up through a new DNSimple zone-records client (the internal/bot client is registrar/ OAuth-only), cert+key persisted 0600 in the acme cache dir - router.go: wraps autocert's GetCertificate — wildcard first, per-host HTTP-01 fallthrough for every other domain AND for wildcard domains until the first issuance lands (provider outage degrades to today's behaviour) - HostPolicy untouched: the wildcard changes how a handshake gets a cert, not what's served; unknown *.11ch.at now completes TLS and lands on the router fallback instead of a handshake error - off unless LCHAT_WILDCARD_DOMAINS + LCHAT_DNSIMPLE_TOKEN are set Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * router: never serve an expired wildcard cert (review fix) certFor now checks Leaf.NotAfter: a wildcard whose renewal stayed broken past real expiry falls back to per-host issuance instead of hard-failing every space handshake forever. Also temp+rename the account-key write. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+709
−15
docs/cloud-provisioner.md+6 −5docs/instance-router.md+23 −9router.go+27 −1wildcard.go+454 −0wildcard_test.go+199 −0
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md| index cb12ac7..3d4911e 100644 |
| --- a/docs/cloud-provisioner.md |
| +++ b/docs/cloud-provisioner.md |
| @@ -121,11 +121,12 @@ behind at most). The home UI keeps a spinner up for the whole call, so the |
| A wildcard record also means typo'd or retired subdomains resolve to the |
| router. Set the router's `LCHAT_FALLBACK_URL` (e.g. the apex marketing site) so |
| -those redirect somewhere useful instead of 404ing — with the caveat that over |
| -HTTPS an unknown host fails TLS before any redirect can happen (no cert is ever |
| -minted for it); the redirect covers `:80` and hosts whose cert outlived their |
| -route. Browsers that try https first still show a TLS error for garbage |
| -subdomains until the router grows wildcard-cert (DNS-01) support. |
| +those redirect somewhere useful instead of 404ing. On a per-host-cert box an |
| +unknown host fails TLS before any redirect can happen (no cert is ever minted |
| +for it), so the redirect only covers `:80` and hosts whose cert outlived their |
| +route — but with the **wildcard cert** (DNS-01, `instance-router.md § Wildcard |
| +TLS`) any subdomain of the base domain completes the handshake, so garbage |
| +subdomains land on the fallback over https too. |
| ## API |
diff --git a/docs/instance-router.md b/docs/instance-router.md| index 4234d72..821ecbe 100644 |
| --- a/docs/instance-router.md |
| +++ b/docs/instance-router.md |
| @@ -94,17 +94,31 @@ Threat model is unchanged: TLS terminates at the router, but message bodies are |
| already E2E ciphertext — the router sees exactly what the app sees, never |
| plaintext. |
| -### Optional: wildcard TLS (DNS-01) |
| +### 🤖 Wildcard TLS (DNS-01) — implemented |
| HTTP-01 issues one cert per host on first hit — simple, needs no DNS credentials, |
| -but needs inbound `:80` and can't cover a host before it's requested. When that's |
| -not enough the cert source is **pluggable**: point the router at a DNS provider and |
| -it obtains a **wildcard** (`*.example.com`) over DNS-01 instead, covering every |
| -subdomain instantly with no port-80 dependency. The two can coexist — a wildcard |
| -for the base domain, HTTP-01 on demand for custom domains outside it. `HostPolicy` |
| -still gates which hosts are actually served; the wildcard only changes how the |
| -cert is obtained. Off by default (no DNS creds required); enable it by configuring |
| -a provider + credentials. |
| +but rides the Let's Encrypt per-registered-domain rate limit (~50 new certs/week, |
| +a hard ceiling on space creation) and can't cover a host before it's requested. |
| +So the router can also hold a **wildcard** (`*.example.com`) obtained over ACME |
| +**DNS-01** (`wildcard.go`): set `LCHAT_WILDCARD_DOMAINS=example.com` (comma list) |
| +and `LCHAT_DNSIMPLE_TOKEN` (a DNSimple **account** API token that can manage the |
| +zone) in `/data/router.env` and restart. The two coexist — the wildcard serves |
| +any host exactly one label under a configured domain; everything else (other |
| +domains, the apex, deeper levels) takes the per-host HTTP-01 path, which also |
| +keeps covering the wildcard domains until the first issuance lands, so a |
| +DNSimple/ACME outage degrades to today's behaviour rather than a dead router. |
| + |
| +Mechanics: a renewal loop checks at startup and every 12h (hourly after a |
| +failure), re-issuing when the cert is missing or <30 days from expiry. Issuance |
| +publishes a `_acme-challenge.<domain>` TXT via the DNSimple records API (TTL 60, |
| +always cleaned up), waits briefly for propagation, and finalizes with a fresh |
| +ECDSA key. The cert+key persist as `wildcard-<domain>.pem` (0600) in the acme |
| +cache dir — same backup story as the autocert cache — and load synchronously on |
| +restart; the ACME account key is `wildcard-account.key` alongside. `HostPolicy` |
| +still gates which hosts are actually *served* a backend; the wildcard only |
| +changes how the handshake gets a cert. Deliberate side effect: an unknown |
| +subdomain of a wildcard domain now completes TLS and gets the router's |
| +fallback/404 instead of a handshake error. |
| ## The instance process |
diff --git a/router.go b/router.go| index d7828ca..5d4b536 100644 |
| --- a/router.go |
| +++ b/router.go |
| @@ -2,6 +2,7 @@ package main |
| import ( |
| "context" |
| + "crypto/tls" |
| "errors" |
| "flag" |
| "fmt" |
| @@ -166,6 +167,8 @@ func runRouter(args []string) { |
| httpAddr := fs.String("http", envOr("LCHAT_HTTP_ADDR", ":80"), "HTTP listen address (ACME HTTP-01 challenge + redirect to https)") |
| fallbackURL := fs.String("fallback-url", os.Getenv("LCHAT_FALLBACK_URL"), |
| "redirect requests for unknown hosts here, e.g. https://elevenmessenger.com (env: LCHAT_FALLBACK_URL); empty keeps the plain 404") |
| + wildcardDomains := fs.String("wildcard-domains", envOr("LCHAT_WILDCARD_DOMAINS", ""), |
| + "comma-separated base domains to serve a *.domain wildcard cert for, obtained via ACME DNS-01 (env: LCHAT_WILDCARD_DOMAINS; needs LCHAT_DNSIMPLE_TOKEN)") |
| _ = fs.Parse(args) |
| reg, err := openRegistry(*registryPath) |
| @@ -189,6 +192,29 @@ func runRouter(args []string) { |
| }, |
| } |
| + // Wildcard certs for the space base domain(s) (wildcard.go): any host one |
| + // label under a configured domain is served the wildcard; everything else — |
| + // and those hosts too, until the first wildcard issuance lands — takes the |
| + // per-host autocert path above. Note the deliberate side effect: an unknown |
| + // *.11ch.at host now completes the handshake and gets the fallback/404 |
| + // instead of a TLS error, since no per-host cert has to be minted first. |
| + tlsCfg := m.TLSConfig() |
| + if doms := splitDomains(*wildcardDomains); len(doms) > 0 { |
| + if token := os.Getenv("LCHAT_DNSIMPLE_TOKEN"); token == "" { |
| + log.Printf("router: LCHAT_WILDCARD_DOMAINS set but LCHAT_DNSIMPLE_TOKEN missing — wildcard certs stay off (per-host issuance continues)") |
| + } else { |
| + wc := newWildcardManager(doms, *acmeCache, token) |
| + go wc.run() |
| + inner := tlsCfg.GetCertificate |
| + tlsCfg.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { |
| + if cert := wc.certFor(hello.ServerName); cert != nil { |
| + return cert, nil |
| + } |
| + return inner(hello) |
| + } |
| + } |
| + } |
| + |
| // Reload routes on SIGHUP (add-instance/deploy will also trigger this via the |
| // control socket in a later step). |
| sighup := make(chan os.Signal, 1) |
| @@ -227,7 +253,7 @@ func runRouter(args []string) { |
| // header connection exhaustion. No ReadTimeout/WriteTimeout — those would break |
| // long-lived proxied WebSockets. |
| httpSrv := &http.Server{Addr: *httpAddr, Handler: m.HTTPHandler(httpFallback), ReadHeaderTimeout: 10 * time.Second} |
| - httpsSrv := &http.Server{Addr: *httpsAddr, Handler: rt, TLSConfig: m.TLSConfig(), ReadHeaderTimeout: 10 * time.Second} |
| + httpsSrv := &http.Server{Addr: *httpsAddr, Handler: rt, TLSConfig: tlsCfg, ReadHeaderTimeout: 10 * time.Second} |
| // :80 serves the ACME HTTP-01 challenge and redirects everything else to https. |
| go func() { |
diff --git a/wildcard.go b/wildcard.go| new file mode 100644 |
| index 0000000..06e62a6 |
| --- /dev/null |
| +++ b/wildcard.go |
| @@ -0,0 +1,454 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ecdsa" |
| + "crypto/elliptic" |
| + "crypto/rand" |
| + "crypto/tls" |
| + "crypto/x509" |
| + "crypto/x509/pkix" |
| + "encoding/json" |
| + "encoding/pem" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "log" |
| + "net" |
| + "net/http" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "sync/atomic" |
| + "time" |
| + |
| + "golang.org/x/crypto/acme" |
| +) |
| + |
| +// Wildcard TLS (ACME DNS-01) for the space base domain(s). |
| +// |
| +// Every new space at <sqid>.11ch.at used to mint its own per-host certificate |
| +// (autocert HTTP-01), which has a hard Let's Encrypt ceiling (~50 new certs a |
| +// week per registered domain) and means a never-served subdomain fails the TLS |
| +// handshake outright. A single wildcard cert for *.<domain>, obtained over |
| +// DNS-01 (a TXT record at _acme-challenge.<domain> via the DNSimple API), |
| +// covers every space instantly and forever-many of them. |
| +// |
| +// The wildcard only changes how a handshake GETS a certificate: routing and |
| +// the autocert HostPolicy are untouched, and per-host HTTP-01 remains the path |
| +// for every host outside the wildcard domains (and the fallback for hosts |
| +// inside them until the wildcard is first obtained — so a DNSimple or ACME |
| +// outage degrades to exactly today's behaviour, never a dead router). |
| +// |
| +// Off unless LCHAT_WILDCARD_DOMAINS and LCHAT_DNSIMPLE_TOKEN are both set. |
| +// See docs/instance-router.md § Wildcard TLS. |
| + |
| +const letsEncryptDirectory = "https://acme-v02.api.letsencrypt.org/directory" |
| + |
| +// wildcardRenewBefore is how close to expiry we re-issue. LE certs live 90 |
| +// days; renewing with 30 left tolerates a month of provider outage. |
| +const wildcardRenewBefore = 30 * 24 * time.Hour |
| + |
| +type wildcardManager struct { |
| + domains []string |
| + cacheDir string // the autocert cache dir; our PEMs live alongside |
| + dns *dnsimpleDNS |
| + directory string |
| + // One atomically-swapped cert per domain, read on every 11ch.at handshake. |
| + certs map[string]*atomic.Pointer[tls.Certificate] |
| +} |
| + |
| +func newWildcardManager(domains []string, cacheDir, dnsToken string) *wildcardManager { |
| + w := &wildcardManager{ |
| + domains: domains, |
| + cacheDir: cacheDir, |
| + dns: &dnsimpleDNS{ |
| + base: envOr("LCHAT_DNSIMPLE_BASE", "https://api.dnsimple.com"), |
| + token: dnsToken, |
| + }, |
| + directory: envOr("LCHAT_ACME_DIRECTORY", letsEncryptDirectory), |
| + certs: map[string]*atomic.Pointer[tls.Certificate]{}, |
| + } |
| + for _, d := range domains { |
| + w.certs[d] = &atomic.Pointer[tls.Certificate]{} |
| + if cert, err := loadWildcardPEM(w.pemPath(d)); err == nil { |
| + w.certs[d].Store(cert) |
| + log.Printf("router: wildcard *.%s loaded from cache (valid until %s)", d, cert.Leaf.NotAfter.Format(time.RFC3339)) |
| + } else if !os.IsNotExist(err) { |
| + log.Printf("router: wildcard *.%s: cached cert unreadable (%v) — will re-obtain", d, err) |
| + } |
| + } |
| + return w |
| +} |
| + |
| +func (w *wildcardManager) pemPath(domain string) string { |
| + return filepath.Join(w.cacheDir, "wildcard-"+domain+".pem") |
| +} |
| + |
| +// certFor returns the wildcard cert for serverName, or nil when the name isn't |
| +// exactly one label under a configured domain (a wildcard doesn't cover the |
| +// apex or deeper levels) or no usable cert is loaded — nil sends the handshake |
| +// to the per-host autocert path. |
| +func (w *wildcardManager) certFor(serverName string) *tls.Certificate { |
| + sn := strings.ToLower(strings.TrimSuffix(serverName, ".")) |
| + for _, d := range w.domains { |
| + sub, ok := strings.CutSuffix(sn, "."+d) |
| + if !ok || sub == "" || strings.Contains(sub, ".") { |
| + continue |
| + } |
| + cert := w.certs[d].Load() |
| + if cert == nil || time.Now().After(cert.Leaf.NotAfter) { |
| + // Never serve a dead cert: a wildcard whose renewal has been broken |
| + // past its actual expiry falls back to per-host issuance (today's |
| + // behaviour) instead of hard-failing every space handshake. |
| + return nil |
| + } |
| + return cert |
| + } |
| + return nil |
| +} |
| + |
| +// run keeps the wildcard certs current: an immediate check at startup (first |
| +// issuance), then every 12h, dropping to hourly retries after a failure. |
| +func (w *wildcardManager) run() { |
| + for { |
| + delay := 12 * time.Hour |
| + for _, d := range w.domains { |
| + if !w.needsObtain(d, time.Now()) { |
| + continue |
| + } |
| + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) |
| + err := w.obtain(ctx, d) |
| + cancel() |
| + if err != nil { |
| + log.Printf("router: wildcard *.%s: %v — per-host issuance keeps covering; retrying in 1h", d, err) |
| + delay = time.Hour |
| + continue |
| + } |
| + cert := w.certs[d].Load() |
| + log.Printf("router: wildcard *.%s obtained (valid until %s)", d, cert.Leaf.NotAfter.Format(time.RFC3339)) |
| + } |
| + time.Sleep(delay) |
| + } |
| +} |
| + |
| +func (w *wildcardManager) needsObtain(domain string, now time.Time) bool { |
| + cert := w.certs[domain].Load() |
| + return cert == nil || now.Add(wildcardRenewBefore).After(cert.Leaf.NotAfter) |
| +} |
| + |
| +// obtain runs one full ACME DNS-01 issuance for *.domain and swaps the result in. |
| +func (w *wildcardManager) obtain(ctx context.Context, domain string) error { |
| + acctKey, err := w.accountKey() |
| + if err != nil { |
| + return fmt.Errorf("account key: %v", err) |
| + } |
| + client := &acme.Client{Key: acctKey, DirectoryURL: w.directory} |
| + if _, err := client.Register(ctx, &acme.Account{}, acme.AcceptTOS); err != nil && err != acme.ErrAccountAlreadyExists { |
| + return fmt.Errorf("acme register: %v", err) |
| + } |
| + |
| + order, err := client.AuthorizeOrder(ctx, []acme.AuthzID{{Type: "dns", Value: "*." + domain}}) |
| + if err != nil { |
| + return fmt.Errorf("authorize order: %v", err) |
| + } |
| + for _, zurl := range order.AuthzURLs { |
| + if err := w.solveDNS01(ctx, client, domain, zurl); err != nil { |
| + return err |
| + } |
| + } |
| + |
| + certKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| + if err != nil { |
| + return err |
| + } |
| + csr, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ |
| + Subject: pkix.Name{CommonName: "*." + domain}, |
| + DNSNames: []string{"*." + domain}, |
| + }, certKey) |
| + if err != nil { |
| + return fmt.Errorf("csr: %v", err) |
| + } |
| + ders, _, err := client.CreateOrderCert(ctx, order.FinalizeURL, csr, true) |
| + if err != nil { |
| + return fmt.Errorf("finalize: %v", err) |
| + } |
| + leaf, err := x509.ParseCertificate(ders[0]) |
| + if err != nil { |
| + return fmt.Errorf("parse issued cert: %v", err) |
| + } |
| + cert := &tls.Certificate{Certificate: ders, PrivateKey: certKey, Leaf: leaf} |
| + if err := saveWildcardPEM(w.pemPath(domain), cert, certKey); err != nil { |
| + return fmt.Errorf("persist: %v", err) |
| + } |
| + w.certs[domain].Store(cert) |
| + return nil |
| +} |
| + |
| +// solveDNS01 answers one authorization: publish the challenge TXT record, |
| +// wait for the CA to see it, and always clean the record up afterwards. |
| +func (w *wildcardManager) solveDNS01(ctx context.Context, client *acme.Client, domain, authzURL string) error { |
| + authz, err := client.GetAuthorization(ctx, authzURL) |
| + if err != nil { |
| + return fmt.Errorf("get authorization: %v", err) |
| + } |
| + if authz.Status == acme.StatusValid { |
| + return nil // a recent order already proved this name |
| + } |
| + var chal *acme.Challenge |
| + for _, c := range authz.Challenges { |
| + if c.Type == "dns-01" { |
| + chal = c |
| + break |
| + } |
| + } |
| + if chal == nil { |
| + return errors.New("CA offered no dns-01 challenge") |
| + } |
| + txt, err := client.DNS01ChallengeRecord(chal.Token) |
| + if err != nil { |
| + return err |
| + } |
| + |
| + recID, err := w.dns.createTXT(ctx, domain, "_acme-challenge", txt) |
| + if err != nil { |
| + return fmt.Errorf("dnsimple: create TXT: %v", err) |
| + } |
| + // Clean up with a fresh context: the record must not outlive the attempt |
| + // even when ctx is already dead (stale challenge TXTs break the NEXT run). |
| + defer func() { |
| + cctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) |
| + defer cancel() |
| + if err := w.dns.deleteRecord(cctx, domain, recID); err != nil { |
| + log.Printf("router: wildcard *.%s: cleanup of challenge TXT %d failed: %v", domain, recID, err) |
| + } |
| + }() |
| + |
| + // Best-effort: give the record a moment to reach the authoritative NS |
| + // before telling the CA to look. Proceed on timeout — the CA does its own |
| + // retries, and a hard failure just comes back through the retry loop. |
| + waitForTXT(ctx, "_acme-challenge."+domain, txt, time.Minute) |
| + |
| + if _, err := client.Accept(ctx, chal); err != nil { |
| + return fmt.Errorf("accept challenge: %v", err) |
| + } |
| + if _, err := client.WaitAuthorization(ctx, authz.URI); err != nil { |
| + return fmt.Errorf("authorization: %v", err) |
| + } |
| + return nil |
| +} |
| + |
| +// accountKey loads (or mints) the ACME account key. Separate from autocert's |
| +// account on purpose: this flow manages its own registration lifecycle. |
| +func (w *wildcardManager) accountKey() (*ecdsa.PrivateKey, error) { |
| + path := filepath.Join(w.cacheDir, "wildcard-account.key") |
| + if raw, err := os.ReadFile(path); err == nil { |
| + block, _ := pem.Decode(raw) |
| + if block == nil { |
| + return nil, fmt.Errorf("%s: not PEM", path) |
| + } |
| + return x509.ParseECPrivateKey(block.Bytes) |
| + } |
| + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| + if err != nil { |
| + return nil, err |
| + } |
| + der, err := x509.MarshalECPrivateKey(key) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if err := os.MkdirAll(w.cacheDir, 0o700); err != nil { |
| + return nil, err |
| + } |
| + raw := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}) |
| + // Temp + rename, like the cert PEM: a crash mid-write must not leave a |
| + // corrupt account key that reads fine but never works. |
| + tmp := path + ".tmp" |
| + if err := os.WriteFile(tmp, raw, 0o600); err != nil { |
| + return nil, err |
| + } |
| + if err := os.Rename(tmp, path); err != nil { |
| + return nil, err |
| + } |
| + return key, nil |
| +} |
| + |
| +// waitForTXT polls DNS until name resolves to a TXT containing value, or the |
| +// deadline passes (best-effort — see caller). |
| +func waitForTXT(ctx context.Context, name, value string, timeout time.Duration) { |
| + deadline := time.Now().Add(timeout) |
| + for time.Now().Before(deadline) && ctx.Err() == nil { |
| + if txts, err := net.DefaultResolver.LookupTXT(ctx, name); err == nil { |
| + for _, t := range txts { |
| + if t == value { |
| + return |
| + } |
| + } |
| + } |
| + time.Sleep(3 * time.Second) |
| + } |
| +} |
| + |
| +// ---- PEM persistence (key + chain in one 0600 file, like the autocert cache) -- |
| + |
| +func saveWildcardPEM(path string, cert *tls.Certificate, key *ecdsa.PrivateKey) error { |
| + var buf bytes.Buffer |
| + der, err := x509.MarshalECPrivateKey(key) |
| + if err != nil { |
| + return err |
| + } |
| + if err := pem.Encode(&buf, &pem.Block{Type: "EC PRIVATE KEY", Bytes: der}); err != nil { |
| + return err |
| + } |
| + for _, c := range cert.Certificate { |
| + if err := pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: c}); err != nil { |
| + return err |
| + } |
| + } |
| + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { |
| + return err |
| + } |
| + // Temp + rename so a crash never leaves a half-written cert for load to trip on. |
| + tmp := path + ".tmp" |
| + if err := os.WriteFile(tmp, buf.Bytes(), 0o600); err != nil { |
| + return err |
| + } |
| + return os.Rename(tmp, path) |
| +} |
| + |
| +func loadWildcardPEM(path string) (*tls.Certificate, error) { |
| + raw, err := os.ReadFile(path) |
| + if err != nil { |
| + return nil, err |
| + } |
| + cert := &tls.Certificate{} |
| + for { |
| + var block *pem.Block |
| + block, raw = pem.Decode(raw) |
| + if block == nil { |
| + break |
| + } |
| + switch block.Type { |
| + case "EC PRIVATE KEY": |
| + key, err := x509.ParseECPrivateKey(block.Bytes) |
| + if err != nil { |
| + return nil, err |
| + } |
| + cert.PrivateKey = key |
| + case "CERTIFICATE": |
| + cert.Certificate = append(cert.Certificate, block.Bytes) |
| + } |
| + } |
| + if cert.PrivateKey == nil || len(cert.Certificate) == 0 { |
| + return nil, fmt.Errorf("%s: missing key or certificate", path) |
| + } |
| + leaf, err := x509.ParseCertificate(cert.Certificate[0]) |
| + if err != nil { |
| + return nil, err |
| + } |
| + cert.Leaf = leaf |
| + return cert, nil |
| +} |
| + |
| +// ---- DNSimple zone records (the existing internal/bot client is registrar/ |
| +// OAuth-only; the router needs record create/delete with a long-lived token) -- |
| + |
| +type dnsimpleDNS struct { |
| + base string |
| + token string |
| + account string // discovered via /v2/whoami on first use |
| +} |
| + |
| +func (d *dnsimpleDNS) do(ctx context.Context, method, path string, body, out any) error { |
| + var rdr io.Reader |
| + if body != nil { |
| + raw, err := json.Marshal(body) |
| + if err != nil { |
| + return err |
| + } |
| + rdr = bytes.NewReader(raw) |
| + } |
| + req, err := http.NewRequestWithContext(ctx, method, d.base+path, rdr) |
| + if err != nil { |
| + return err |
| + } |
| + req.Header.Set("Authorization", "Bearer "+d.token) |
| + req.Header.Set("Accept", "application/json") |
| + if body != nil { |
| + req.Header.Set("Content-Type", "application/json") |
| + } |
| + client := &http.Client{Timeout: 30 * time.Second} |
| + resp, err := client.Do(req) |
| + if err != nil { |
| + return err |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode < 200 || resp.StatusCode >= 300 { |
| + msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) |
| + return fmt.Errorf("%s %s: %s: %s", method, path, resp.Status, strings.TrimSpace(string(msg))) |
| + } |
| + if out != nil { |
| + return json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(out) |
| + } |
| + return nil |
| +} |
| + |
| +func (d *dnsimpleDNS) accountID(ctx context.Context) (string, error) { |
| + if d.account != "" { |
| + return d.account, nil |
| + } |
| + var who struct { |
| + Data struct { |
| + Account *struct { |
| + ID int64 `json:"id"` |
| + } `json:"account"` |
| + } `json:"data"` |
| + } |
| + if err := d.do(ctx, "GET", "/v2/whoami", nil, &who); err != nil { |
| + return "", err |
| + } |
| + if who.Data.Account == nil { |
| + return "", errors.New("whoami: token is not an account token") |
| + } |
| + d.account = fmt.Sprint(who.Data.Account.ID) |
| + return d.account, nil |
| +} |
| + |
| +func (d *dnsimpleDNS) createTXT(ctx context.Context, zone, name, content string) (int64, error) { |
| + acct, err := d.accountID(ctx) |
| + if err != nil { |
| + return 0, err |
| + } |
| + var created struct { |
| + Data struct { |
| + ID int64 `json:"id"` |
| + } `json:"data"` |
| + } |
| + err = d.do(ctx, "POST", "/v2/"+acct+"/zones/"+zone+"/records", map[string]any{ |
| + "name": name, "type": "TXT", "content": content, "ttl": 60, |
| + }, &created) |
| + if err != nil { |
| + return 0, err |
| + } |
| + return created.Data.ID, nil |
| +} |
| + |
| +func (d *dnsimpleDNS) deleteRecord(ctx context.Context, zone string, id int64) error { |
| + acct, err := d.accountID(ctx) |
| + if err != nil { |
| + return err |
| + } |
| + return d.do(ctx, "DELETE", fmt.Sprintf("/v2/%s/zones/%s/records/%d", acct, zone, id), nil, nil) |
| +} |
| + |
| +// splitDomains parses the LCHAT_WILDCARD_DOMAINS comma list. |
| +func splitDomains(s string) []string { |
| + var out []string |
| + for _, d := range strings.Split(s, ",") { |
| + if d = strings.ToLower(strings.TrimSpace(d)); d != "" { |
| + out = append(out, d) |
| + } |
| + } |
| + return out |
| +} |
diff --git a/wildcard_test.go b/wildcard_test.go| new file mode 100644 |
| index 0000000..978ad0e |
| --- /dev/null |
| +++ b/wildcard_test.go |
| @@ -0,0 +1,199 @@ |
| +package main |
| + |
| +import ( |
| + "context" |
| + "crypto/ecdsa" |
| + "crypto/elliptic" |
| + "crypto/rand" |
| + "crypto/tls" |
| + "crypto/x509" |
| + "crypto/x509/pkix" |
| + "encoding/json" |
| + "math/big" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// selfSignedWildcard mints a throwaway *.domain cert for dispatch tests. |
| +func selfSignedWildcard(t *testing.T, domain string, notAfter time.Time) (*tls.Certificate, *ecdsa.PrivateKey) { |
| + t.Helper() |
| + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + tmpl := &x509.Certificate{ |
| + SerialNumber: big.NewInt(1), |
| + Subject: pkix.Name{CommonName: "*." + domain}, |
| + DNSNames: []string{"*." + domain}, |
| + NotBefore: time.Now().Add(-time.Hour), |
| + NotAfter: notAfter, |
| + } |
| + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + leaf, err := x509.ParseCertificate(der) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return &tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leaf}, key |
| +} |
| + |
| +func TestCertForMatchesExactlyOneLabel(t *testing.T) { |
| + w := newWildcardManager([]string{"11ch.at"}, t.TempDir(), "tok") |
| + |
| + // Nothing loaded yet: every host falls through to autocert. |
| + if w.certFor("abc.11ch.at") != nil { |
| + t.Fatal("cert served before any was loaded") |
| + } |
| + |
| + cert, _ := selfSignedWildcard(t, "11ch.at", time.Now().Add(60*24*time.Hour)) |
| + w.certs["11ch.at"].Store(cert) |
| + |
| + for _, host := range []string{"abc.11ch.at", "ABC.11ch.at", "y9h.11ch.at."} { |
| + if w.certFor(host) != cert { |
| + t.Errorf("%s: wildcard not served", host) |
| + } |
| + } |
| + for _, host := range []string{ |
| + "11ch.at", // apex isn't covered by *.11ch.at |
| + "a.b.11ch.at", // deeper level isn't either |
| + "family.luan.chat", // other domains take the autocert path |
| + "evil-11ch.at", // suffix must be a label boundary |
| + "x.11ch.at.evil.com", // not our domain at all |
| + } { |
| + if w.certFor(host) != nil { |
| + t.Errorf("%s: wildcard wrongly served", host) |
| + } |
| + } |
| + |
| + // A cert past its real expiry must NOT be served — fall back to per-host |
| + // issuance instead of hard-failing every space handshake (the sustained- |
| + // renewal-outage case). |
| + expired, _ := selfSignedWildcard(t, "11ch.at", time.Now().Add(-time.Hour)) |
| + w.certs["11ch.at"].Store(expired) |
| + if w.certFor("abc.11ch.at") != nil { |
| + t.Fatal("expired wildcard cert still served") |
| + } |
| +} |
| + |
| +func TestWrappedGetCertificateFallsThrough(t *testing.T) { |
| + w := newWildcardManager([]string{"11ch.at"}, t.TempDir(), "tok") |
| + cert, _ := selfSignedWildcard(t, "11ch.at", time.Now().Add(60*24*time.Hour)) |
| + w.certs["11ch.at"].Store(cert) |
| + |
| + fallthroughCert, _ := selfSignedWildcard(t, "other.example", time.Now().Add(time.Hour)) |
| + inner := func(h *tls.ClientHelloInfo) (*tls.Certificate, error) { return fallthroughCert, nil } |
| + // The exact wrapper runRouter installs. |
| + get := func(h *tls.ClientHelloInfo) (*tls.Certificate, error) { |
| + if c := w.certFor(h.ServerName); c != nil { |
| + return c, nil |
| + } |
| + return inner(h) |
| + } |
| + |
| + if got, _ := get(&tls.ClientHelloInfo{ServerName: "space.11ch.at"}); got != cert { |
| + t.Fatal("wildcard host didn't get the wildcard cert") |
| + } |
| + if got, _ := get(&tls.ClientHelloInfo{ServerName: "family.luan.chat"}); got != fallthroughCert { |
| + t.Fatal("non-wildcard host didn't fall through to autocert") |
| + } |
| +} |
| + |
| +func TestWildcardPEMRoundTripAndRenewDecision(t *testing.T) { |
| + dir := t.TempDir() |
| + w := newWildcardManager([]string{"11ch.at"}, dir, "tok") |
| + |
| + if !w.needsObtain("11ch.at", time.Now()) { |
| + t.Fatal("no cert loaded but needsObtain is false") |
| + } |
| + |
| + cert, key := selfSignedWildcard(t, "11ch.at", time.Now().Add(60*24*time.Hour)) |
| + if err := saveWildcardPEM(w.pemPath("11ch.at"), cert, key); err != nil { |
| + t.Fatal(err) |
| + } |
| + if fi, err := os.Stat(w.pemPath("11ch.at")); err != nil || fi.Mode().Perm() != 0o600 { |
| + t.Fatalf("pem mode = %v, err %v (key material must be 0600)", fi.Mode(), err) |
| + } |
| + |
| + // A fresh manager picks the persisted cert up at startup (the restart path). |
| + w2 := newWildcardManager([]string{"11ch.at"}, dir, "tok") |
| + loaded := w2.certs["11ch.at"].Load() |
| + if loaded == nil || !loaded.Leaf.NotAfter.Equal(cert.Leaf.NotAfter) { |
| + t.Fatal("cached cert not loaded on startup") |
| + } |
| + if w2.needsObtain("11ch.at", time.Now()) { |
| + t.Fatal("60-day cert flagged for renewal") |
| + } |
| + // Inside the 30-day window it renews; a garbage file reads as missing. |
| + if !w2.needsObtain("11ch.at", cert.Leaf.NotAfter.Add(-10*24*time.Hour)) { |
| + t.Fatal("cert 10 days from expiry not flagged for renewal") |
| + } |
| + os.WriteFile(filepath.Join(dir, "wildcard-bad.example.pem"), []byte("junk"), 0o600) |
| + w3 := newWildcardManager([]string{"bad.example"}, dir, "tok") |
| + if w3.certs["bad.example"].Load() != nil { |
| + t.Fatal("junk pem loaded as a cert") |
| + } |
| +} |
| + |
| +func TestDNSimpleRecordsClient(t *testing.T) { |
| + var gotCreate map[string]any |
| + var deletedPath, createAuth string |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("GET /v2/whoami", func(w http.ResponseWriter, r *http.Request) { |
| + writeJSON(w, 200, map[string]any{"data": map[string]any{"account": map[string]any{"id": 176940}}}) |
| + }) |
| + mux.HandleFunc("POST /v2/176940/zones/11ch.at/records", func(w http.ResponseWriter, r *http.Request) { |
| + createAuth = r.Header.Get("Authorization") |
| + json.NewDecoder(r.Body).Decode(&gotCreate) |
| + writeJSON(w, 201, map[string]any{"data": map[string]any{"id": 4242}}) |
| + }) |
| + mux.HandleFunc("DELETE /v2/176940/zones/11ch.at/records/4242", func(w http.ResponseWriter, r *http.Request) { |
| + deletedPath = r.URL.Path |
| + w.WriteHeader(204) |
| + }) |
| + ts := httptest.NewServer(mux) |
| + defer ts.Close() |
| + |
| + d := &dnsimpleDNS{base: ts.URL, token: "tok123"} |
| + ctx := context.Background() |
| + id, err := d.createTXT(ctx, "11ch.at", "_acme-challenge", "challenge-value") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if id != 4242 || createAuth != "Bearer tok123" { |
| + t.Fatalf("id=%d auth=%q", id, createAuth) |
| + } |
| + if gotCreate["name"] != "_acme-challenge" || gotCreate["type"] != "TXT" || |
| + gotCreate["content"] != "challenge-value" || gotCreate["ttl"] != float64(60) { |
| + t.Fatalf("create body = %v", gotCreate) |
| + } |
| + if err := d.deleteRecord(ctx, "11ch.at", id); err != nil || deletedPath == "" { |
| + t.Fatalf("delete: %v (path %q)", err, deletedPath) |
| + } |
| + |
| + // A user (non-account) token can't manage zones — surfaced, not silent. |
| + mux2 := http.NewServeMux() |
| + mux2.HandleFunc("GET /v2/whoami", func(w http.ResponseWriter, r *http.Request) { |
| + writeJSON(w, 200, map[string]any{"data": map[string]any{"account": nil}}) |
| + }) |
| + ts2 := httptest.NewServer(mux2) |
| + defer ts2.Close() |
| + d2 := &dnsimpleDNS{base: ts2.URL, token: "usertok"} |
| + if _, err := d2.createTXT(ctx, "11ch.at", "_acme-challenge", "x"); err == nil { |
| + t.Fatal("user token accepted for zone management") |
| + } |
| +} |
| + |
| +func TestSplitDomains(t *testing.T) { |
| + if got := splitDomains(" 11ch.at , Spaces.Example.COM ,"); len(got) != 2 || got[0] != "11ch.at" || got[1] != "spaces.example.com" { |
| + t.Fatalf("splitDomains = %v", got) |
| + } |
| + if got := splitDomains(""); got != nil { |
| + t.Fatalf("empty input = %v", got) |
| + } |
| +} |