eleven / ocho Public

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>
Paul Campbell 763400dbaa380a5119bc1dee30b6eb726baf32a0 parent 6d41058
5 files changed, +709 −15
  • docs/cloud-provisioner.md +6 −5
  • docs/instance-router.md +23 −9
  • router.go +27 −1
  • wildcard.go +454 −0
  • wildcard_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)
+ }
+}