eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
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.
24func 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
48func 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
86func 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
109func 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.
166func 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
182func 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.
233func 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.
264func 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
302func 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