| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/ecdsa" |
| 6 | "crypto/elliptic" |
| 7 | "crypto/rand" |
| 8 | "crypto/tls" |
| 9 | "crypto/x509" |
| 10 | "crypto/x509/pkix" |
| 11 | "encoding/json" |
| 12 | "math/big" |
| 13 | "net/http" |
| 14 | "net/http/httptest" |
| 15 | "os" |
| 16 | "path/filepath" |
| 17 | "testing" |
| 18 | "time" |
| 19 | |
| 20 | "golang.org/x/crypto/acme/autocert" |
| 21 | ) |
| 22 | |
| 23 | // selfSignedWildcard mints a throwaway *.domain cert for dispatch tests. |
| 24 | func selfSignedWildcard(t *testing.T, domain string, notAfter time.Time) (*tls.Certificate, *ecdsa.PrivateKey) { |
| 25 | t.Helper() |
| 26 | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| 27 | if err != nil { |
| 28 | t.Fatal(err) |
| 29 | } |
| 30 | tmpl := &x509.Certificate{ |
| 31 | SerialNumber: big.NewInt(1), |
| 32 | Subject: pkix.Name{CommonName: "*." + domain}, |
| 33 | DNSNames: []string{"*." + domain}, |
| 34 | NotBefore: time.Now().Add(-time.Hour), |
| 35 | NotAfter: notAfter, |
| 36 | } |
| 37 | der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) |
| 38 | if err != nil { |
| 39 | t.Fatal(err) |
| 40 | } |
| 41 | leaf, err := x509.ParseCertificate(der) |
| 42 | if err != nil { |
| 43 | t.Fatal(err) |
| 44 | } |
| 45 | return &tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leaf}, key |
| 46 | } |
| 47 | |
| 48 | func TestCertForMatchesExactlyOneLabel(t *testing.T) { |
| 49 | w := newWildcardManager([]string{"11ch.at"}, autocert.DirCache(t.TempDir()), newDNSimpleDNS("tok")) |
| 50 | |
| 51 | // Nothing loaded yet: every host falls through to autocert. |
| 52 | if w.certFor("abc.11ch.at") != nil { |
| 53 | t.Fatal("cert served before any was loaded") |
| 54 | } |
| 55 | |
| 56 | cert, _ := selfSignedWildcard(t, "11ch.at", time.Now().Add(60*24*time.Hour)) |
| 57 | w.certs["11ch.at"].Store(cert) |
| 58 | |
| 59 | for _, host := range []string{"abc.11ch.at", "ABC.11ch.at", "y9h.11ch.at."} { |
| 60 | if w.certFor(host) != cert { |
| 61 | t.Errorf("%s: wildcard not served", host) |
| 62 | } |
| 63 | } |
| 64 | for _, host := range []string{ |
| 65 | "11ch.at", // apex isn't covered by *.11ch.at |
| 66 | "a.b.11ch.at", // deeper level isn't either |
| 67 | "chat.example.com", // other domains take the autocert path |
| 68 | "evil-11ch.at", // suffix must be a label boundary |
| 69 | "x.11ch.at.evil.com", // not our domain at all |
| 70 | } { |
| 71 | if w.certFor(host) != nil { |
| 72 | t.Errorf("%s: wildcard wrongly served", host) |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | // A cert past its real expiry must NOT be served — fall back to per-host |
| 77 | // issuance instead of hard-failing every space handshake (the sustained- |
| 78 | // renewal-outage case). |
| 79 | expired, _ := selfSignedWildcard(t, "11ch.at", time.Now().Add(-time.Hour)) |
| 80 | w.certs["11ch.at"].Store(expired) |
| 81 | if w.certFor("abc.11ch.at") != nil { |
| 82 | t.Fatal("expired wildcard cert still served") |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | func TestWrappedGetCertificateFallsThrough(t *testing.T) { |
| 87 | w := newWildcardManager([]string{"11ch.at"}, autocert.DirCache(t.TempDir()), newDNSimpleDNS("tok")) |
| 88 | cert, _ := selfSignedWildcard(t, "11ch.at", time.Now().Add(60*24*time.Hour)) |
| 89 | w.certs["11ch.at"].Store(cert) |
| 90 | |
| 91 | fallthroughCert, _ := selfSignedWildcard(t, "other.example", time.Now().Add(time.Hour)) |
| 92 | inner := func(h *tls.ClientHelloInfo) (*tls.Certificate, error) { return fallthroughCert, nil } |
| 93 | // The exact wrapper runRouter installs. |
| 94 | get := func(h *tls.ClientHelloInfo) (*tls.Certificate, error) { |
| 95 | if c := w.certFor(h.ServerName); c != nil { |
| 96 | return c, nil |
| 97 | } |
| 98 | return inner(h) |
| 99 | } |
| 100 | |
| 101 | if got, _ := get(&tls.ClientHelloInfo{ServerName: "space.11ch.at"}); got != cert { |
| 102 | t.Fatal("wildcard host didn't get the wildcard cert") |
| 103 | } |
| 104 | if got, _ := get(&tls.ClientHelloInfo{ServerName: "chat.example.com"}); got != fallthroughCert { |
| 105 | t.Fatal("non-wildcard host didn't fall through to autocert") |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | func TestWildcardPEMRoundTripAndRenewDecision(t *testing.T) { |
| 110 | dir := t.TempDir() |
| 111 | cache := autocert.DirCache(dir) |
| 112 | w := newWildcardManager([]string{"11ch.at"}, cache, newDNSimpleDNS("tok")) |
| 113 | |
| 114 | if !w.needsObtain("11ch.at", time.Now()) { |
| 115 | t.Fatal("no cert loaded but needsObtain is false") |
| 116 | } |
| 117 | |
| 118 | cert, key := selfSignedWildcard(t, "11ch.at", time.Now().Add(60*24*time.Hour)) |
| 119 | raw, err := encodeWildcardPEM(cert, key) |
| 120 | if err != nil { |
| 121 | t.Fatal(err) |
| 122 | } |
| 123 | if err := cache.Put(context.Background(), wildcardPEMKey("11ch.at"), raw); err != nil { |
| 124 | t.Fatal(err) |
| 125 | } |
| 126 | // The dir backend must keep the pre-seam on-disk contract: the historical |
| 127 | // file name inside the acme cache dir, key material 0600. |
| 128 | pemPath := filepath.Join(dir, "wildcard-11ch.at.pem") |
| 129 | if fi, err := os.Stat(pemPath); err != nil || fi.Mode().Perm() != 0o600 { |
| 130 | t.Fatalf("pem mode = %v, err %v (key material must be 0600)", fi.Mode(), err) |
| 131 | } |
| 132 | |
| 133 | // A fresh manager picks the persisted cert up at startup (the restart path). |
| 134 | w2 := newWildcardManager([]string{"11ch.at"}, cache, newDNSimpleDNS("tok")) |
| 135 | loaded := w2.certs["11ch.at"].Load() |
| 136 | if loaded == nil || !loaded.Leaf.NotAfter.Equal(cert.Leaf.NotAfter) { |
| 137 | t.Fatal("cached cert not loaded on startup") |
| 138 | } |
| 139 | if w2.needsObtain("11ch.at", time.Now()) { |
| 140 | t.Fatal("60-day cert flagged for renewal") |
| 141 | } |
| 142 | // Inside the 30-day window it renews; a garbage file reads as missing. |
| 143 | if !w2.needsObtain("11ch.at", cert.Leaf.NotAfter.Add(-10*24*time.Hour)) { |
| 144 | t.Fatal("cert 10 days from expiry not flagged for renewal") |
| 145 | } |
| 146 | os.WriteFile(filepath.Join(dir, "wildcard-bad.example.pem"), []byte("junk"), 0o600) |
| 147 | w3 := newWildcardManager([]string{"bad.example"}, cache, newDNSimpleDNS("tok")) |
| 148 | if w3.certs["bad.example"].Load() != nil { |
| 149 | t.Fatal("junk pem loaded as a cert") |
| 150 | } |
| 151 | |
| 152 | // The no-migration guarantee, from the other side: a PEM a pre-seam binary |
| 153 | // wrote with plain file I/O at the historical path is loaded through the |
| 154 | // cache unchanged (/data/acme must survive the upgrade + a restart). |
| 155 | if err := os.WriteFile(filepath.Join(dir, "wildcard-old.example.pem"), raw, 0o600); err != nil { |
| 156 | t.Fatal(err) |
| 157 | } |
| 158 | w4 := newWildcardManager([]string{"old.example"}, cache, newDNSimpleDNS("tok")) |
| 159 | if w4.certs["old.example"].Load() == nil { |
| 160 | t.Fatal("pre-seam pem file not loaded via the cache") |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | // The ACME account key must round-trip through the cache: minting a fresh key |
| 165 | // per issuance would register a new ACME account every renewal. |
| 166 | func TestWildcardAccountKeyStableAcrossManagers(t *testing.T) { |
| 167 | cache := autocert.DirCache(t.TempDir()) |
| 168 | ctx := context.Background() |
| 169 | k1, err := newWildcardManager([]string{"11ch.at"}, cache, newDNSimpleDNS("tok")).accountKey(ctx) |
| 170 | if err != nil { |
| 171 | t.Fatal(err) |
| 172 | } |
| 173 | k2, err := newWildcardManager([]string{"11ch.at"}, cache, newDNSimpleDNS("tok")).accountKey(ctx) |
| 174 | if err != nil { |
| 175 | t.Fatal(err) |
| 176 | } |
| 177 | if !k1.Equal(k2) { |
| 178 | t.Fatal("account key not persisted across managers") |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | func TestDNSimpleRecordsClient(t *testing.T) { |
| 183 | var gotCreate map[string]any |
| 184 | var deletedPath, createAuth string |
| 185 | mux := http.NewServeMux() |
| 186 | mux.HandleFunc("GET /v2/whoami", func(w http.ResponseWriter, r *http.Request) { |
| 187 | writeJSON(w, 200, map[string]any{"data": map[string]any{"account": map[string]any{"id": 176940}}}) |
| 188 | }) |
| 189 | mux.HandleFunc("POST /v2/176940/zones/11ch.at/records", func(w http.ResponseWriter, r *http.Request) { |
| 190 | createAuth = r.Header.Get("Authorization") |
| 191 | json.NewDecoder(r.Body).Decode(&gotCreate) |
| 192 | writeJSON(w, 201, map[string]any{"data": map[string]any{"id": 4242}}) |
| 193 | }) |
| 194 | mux.HandleFunc("DELETE /v2/176940/zones/11ch.at/records/4242", func(w http.ResponseWriter, r *http.Request) { |
| 195 | deletedPath = r.URL.Path |
| 196 | w.WriteHeader(204) |
| 197 | }) |
| 198 | ts := httptest.NewServer(mux) |
| 199 | defer ts.Close() |
| 200 | |
| 201 | d := &dnsimpleDNS{base: ts.URL, token: "tok123"} |
| 202 | ctx := context.Background() |
| 203 | id, err := d.createTXT(ctx, "11ch.at", "_acme-challenge", "challenge-value") |
| 204 | if err != nil { |
| 205 | t.Fatal(err) |
| 206 | } |
| 207 | if id != 4242 || createAuth != "Bearer tok123" { |
| 208 | t.Fatalf("id=%d auth=%q", id, createAuth) |
| 209 | } |
| 210 | if gotCreate["name"] != "_acme-challenge" || gotCreate["type"] != "TXT" || |
| 211 | gotCreate["content"] != "challenge-value" || gotCreate["ttl"] != float64(60) { |
| 212 | t.Fatalf("create body = %v", gotCreate) |
| 213 | } |
| 214 | if err := d.deleteRecord(ctx, "11ch.at", id); err != nil || deletedPath == "" { |
| 215 | t.Fatalf("delete: %v (path %q)", err, deletedPath) |
| 216 | } |
| 217 | |
| 218 | // A user (non-account) token can't manage zones — surfaced, not silent. |
| 219 | mux2 := http.NewServeMux() |
| 220 | mux2.HandleFunc("GET /v2/whoami", func(w http.ResponseWriter, r *http.Request) { |
| 221 | writeJSON(w, 200, map[string]any{"data": map[string]any{"account": nil}}) |
| 222 | }) |
| 223 | ts2 := httptest.NewServer(mux2) |
| 224 | defer ts2.Close() |
| 225 | d2 := &dnsimpleDNS{base: ts2.URL, token: "usertok"} |
| 226 | if _, err := d2.createTXT(ctx, "11ch.at", "_acme-challenge", "x"); err == nil { |
| 227 | t.Fatal("user token accepted for zone management") |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | // The dnsProvider adapter must wire create→cleanup: present() creates the TXT |
| 232 | // and the closure it returns deletes exactly that record. |
| 233 | func TestDNSimplePresentAdapter(t *testing.T) { |
| 234 | var deleted bool |
| 235 | mux := http.NewServeMux() |
| 236 | mux.HandleFunc("GET /v2/whoami", func(w http.ResponseWriter, r *http.Request) { |
| 237 | writeJSON(w, 200, map[string]any{"data": map[string]any{"account": map[string]any{"id": 1}}}) |
| 238 | }) |
| 239 | mux.HandleFunc("POST /v2/1/zones/11ch.at/records", func(w http.ResponseWriter, r *http.Request) { |
| 240 | writeJSON(w, 201, map[string]any{"data": map[string]any{"id": 77}}) |
| 241 | }) |
| 242 | mux.HandleFunc("DELETE /v2/1/zones/11ch.at/records/77", func(w http.ResponseWriter, r *http.Request) { |
| 243 | deleted = true |
| 244 | w.WriteHeader(204) |
| 245 | }) |
| 246 | ts := httptest.NewServer(mux) |
| 247 | defer ts.Close() |
| 248 | |
| 249 | d := &dnsimpleDNS{base: ts.URL, token: "t"} |
| 250 | cleanup, err := d.present(context.Background(), "11ch.at", "_acme-challenge", "v") |
| 251 | if err != nil { |
| 252 | t.Fatal(err) |
| 253 | } |
| 254 | if err := cleanup(context.Background()); err != nil { |
| 255 | t.Fatal(err) |
| 256 | } |
| 257 | if !deleted { |
| 258 | t.Fatal("cleanup did not delete the record") |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | // Provider selection: DNSimple stays the DEFAULT (and its missing-token trigger |
| 263 | // is unchanged); Route53 is chosen only by an explicit LCHAT_DNS_PROVIDER. |
| 264 | func TestDNSProviderSelection(t *testing.T) { |
| 265 | // Default, no token → wildcard stays off (nil + a human reason). |
| 266 | t.Setenv("LCHAT_DNS_PROVIDER", "") |
| 267 | t.Setenv("LCHAT_DNSIMPLE_TOKEN", "") |
| 268 | if p, reason := dnsProviderFromEnv(); p != nil || reason == "" { |
| 269 | t.Fatalf("default+no-token: want nil provider + reason, got %v %q", p, reason) |
| 270 | } |
| 271 | |
| 272 | // Default + token → DNSimple, the historical trigger byte-for-byte. |
| 273 | t.Setenv("LCHAT_DNSIMPLE_TOKEN", "tok") |
| 274 | if p, reason := dnsProviderFromEnv(); reason != "" { |
| 275 | t.Fatalf("default+token: unexpected reason %q", reason) |
| 276 | } else if _, ok := p.(*dnsimpleDNS); !ok { |
| 277 | t.Fatalf("default+token: want *dnsimpleDNS, got %T", p) |
| 278 | } |
| 279 | |
| 280 | // Explicit dnsimple → identical to the default. |
| 281 | t.Setenv("LCHAT_DNS_PROVIDER", "dnsimple") |
| 282 | if p, _ := dnsProviderFromEnv(); func() bool { _, ok := p.(*dnsimpleDNS); return !ok }() { |
| 283 | t.Fatalf("explicit dnsimple: want *dnsimpleDNS, got %T", p) |
| 284 | } |
| 285 | |
| 286 | // Explicit route53 → the Route53 backend, no DNS token required. |
| 287 | t.Setenv("LCHAT_DNS_PROVIDER", "route53") |
| 288 | t.Setenv("LCHAT_DNSIMPLE_TOKEN", "") |
| 289 | if p, reason := dnsProviderFromEnv(); reason != "" { |
| 290 | t.Fatalf("route53: unexpected reason %q", reason) |
| 291 | } else if _, ok := p.(*route53DNS); !ok { |
| 292 | t.Fatalf("route53: want *route53DNS, got %T", p) |
| 293 | } |
| 294 | |
| 295 | // Unknown selector → nil + reason, never a silent DNSimple fallback. |
| 296 | t.Setenv("LCHAT_DNS_PROVIDER", "bogus") |
| 297 | if p, reason := dnsProviderFromEnv(); p != nil || reason == "" { |
| 298 | t.Fatalf("bogus selector: want nil + reason, got %v %q", p, reason) |
| 299 | } |
| 300 | } |
| 301 | |
| 302 | func TestSplitDomains(t *testing.T) { |
| 303 | if got := splitDomains(" 11ch.at , Spaces.Example.COM ,"); len(got) != 2 || got[0] != "11ch.at" || got[1] != "spaces.example.com" { |
| 304 | t.Fatalf("splitDomains = %v", got) |
| 305 | } |
| 306 | if got := splitDomains(""); got != nil { |
| 307 | t.Fatalf("empty input = %v", got) |
| 308 | } |
| 309 | } |
| 310 | |