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" "golang.org/x/crypto/acme/autocert" ) // 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"}, autocert.DirCache(t.TempDir()), newDNSimpleDNS("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 "chat.example.com", // 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"}, autocert.DirCache(t.TempDir()), newDNSimpleDNS("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: "chat.example.com"}); got != fallthroughCert { t.Fatal("non-wildcard host didn't fall through to autocert") } } func TestWildcardPEMRoundTripAndRenewDecision(t *testing.T) { dir := t.TempDir() cache := autocert.DirCache(dir) w := newWildcardManager([]string{"11ch.at"}, cache, newDNSimpleDNS("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)) raw, err := encodeWildcardPEM(cert, key) if err != nil { t.Fatal(err) } if err := cache.Put(context.Background(), wildcardPEMKey("11ch.at"), raw); err != nil { t.Fatal(err) } // The dir backend must keep the pre-seam on-disk contract: the historical // file name inside the acme cache dir, key material 0600. pemPath := filepath.Join(dir, "wildcard-11ch.at.pem") if fi, err := os.Stat(pemPath); 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"}, cache, newDNSimpleDNS("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"}, cache, newDNSimpleDNS("tok")) if w3.certs["bad.example"].Load() != nil { t.Fatal("junk pem loaded as a cert") } // The no-migration guarantee, from the other side: a PEM a pre-seam binary // wrote with plain file I/O at the historical path is loaded through the // cache unchanged (/data/acme must survive the upgrade + a restart). if err := os.WriteFile(filepath.Join(dir, "wildcard-old.example.pem"), raw, 0o600); err != nil { t.Fatal(err) } w4 := newWildcardManager([]string{"old.example"}, cache, newDNSimpleDNS("tok")) if w4.certs["old.example"].Load() == nil { t.Fatal("pre-seam pem file not loaded via the cache") } } // The ACME account key must round-trip through the cache: minting a fresh key // per issuance would register a new ACME account every renewal. func TestWildcardAccountKeyStableAcrossManagers(t *testing.T) { cache := autocert.DirCache(t.TempDir()) ctx := context.Background() k1, err := newWildcardManager([]string{"11ch.at"}, cache, newDNSimpleDNS("tok")).accountKey(ctx) if err != nil { t.Fatal(err) } k2, err := newWildcardManager([]string{"11ch.at"}, cache, newDNSimpleDNS("tok")).accountKey(ctx) if err != nil { t.Fatal(err) } if !k1.Equal(k2) { t.Fatal("account key not persisted across managers") } } 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") } } // The dnsProvider adapter must wire create→cleanup: present() creates the TXT // and the closure it returns deletes exactly that record. func TestDNSimplePresentAdapter(t *testing.T) { var deleted bool 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": 1}}}) }) mux.HandleFunc("POST /v2/1/zones/11ch.at/records", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, 201, map[string]any{"data": map[string]any{"id": 77}}) }) mux.HandleFunc("DELETE /v2/1/zones/11ch.at/records/77", func(w http.ResponseWriter, r *http.Request) { deleted = true w.WriteHeader(204) }) ts := httptest.NewServer(mux) defer ts.Close() d := &dnsimpleDNS{base: ts.URL, token: "t"} cleanup, err := d.present(context.Background(), "11ch.at", "_acme-challenge", "v") if err != nil { t.Fatal(err) } if err := cleanup(context.Background()); err != nil { t.Fatal(err) } if !deleted { t.Fatal("cleanup did not delete the record") } } // Provider selection: DNSimple stays the DEFAULT (and its missing-token trigger // is unchanged); Route53 is chosen only by an explicit LCHAT_DNS_PROVIDER. func TestDNSProviderSelection(t *testing.T) { // Default, no token → wildcard stays off (nil + a human reason). t.Setenv("LCHAT_DNS_PROVIDER", "") t.Setenv("LCHAT_DNSIMPLE_TOKEN", "") if p, reason := dnsProviderFromEnv(); p != nil || reason == "" { t.Fatalf("default+no-token: want nil provider + reason, got %v %q", p, reason) } // Default + token → DNSimple, the historical trigger byte-for-byte. t.Setenv("LCHAT_DNSIMPLE_TOKEN", "tok") if p, reason := dnsProviderFromEnv(); reason != "" { t.Fatalf("default+token: unexpected reason %q", reason) } else if _, ok := p.(*dnsimpleDNS); !ok { t.Fatalf("default+token: want *dnsimpleDNS, got %T", p) } // Explicit dnsimple → identical to the default. t.Setenv("LCHAT_DNS_PROVIDER", "dnsimple") if p, _ := dnsProviderFromEnv(); func() bool { _, ok := p.(*dnsimpleDNS); return !ok }() { t.Fatalf("explicit dnsimple: want *dnsimpleDNS, got %T", p) } // Explicit route53 → the Route53 backend, no DNS token required. t.Setenv("LCHAT_DNS_PROVIDER", "route53") t.Setenv("LCHAT_DNSIMPLE_TOKEN", "") if p, reason := dnsProviderFromEnv(); reason != "" { t.Fatalf("route53: unexpected reason %q", reason) } else if _, ok := p.(*route53DNS); !ok { t.Fatalf("route53: want *route53DNS, got %T", p) } // Unknown selector → nil + reason, never a silent DNSimple fallback. t.Setenv("LCHAT_DNS_PROVIDER", "bogus") if p, reason := dnsProviderFromEnv(); p != nil || reason == "" { t.Fatalf("bogus selector: want nil + reason, got %v %q", p, reason) } } 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) } }