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 "bytes"
5 "context"
6 "crypto/ecdsa"
7 "crypto/elliptic"
8 "crypto/rand"
9 "crypto/tls"
10 "crypto/x509"
11 "crypto/x509/pkix"
12 "encoding/json"
13 "encoding/pem"
14 "errors"
15 "fmt"
16 "io"
17 "log"
18 "net"
19 "net/http"
20 "os"
21 "strings"
22 "sync/atomic"
23 "time"
24
25 "golang.org/x/crypto/acme"
26 "golang.org/x/crypto/acme/autocert"
27)
28
29// Wildcard TLS (ACME DNS-01) for the space base domain(s).
30//
31// Every new space at <sqid>.11ch.at used to mint its own per-host certificate
32// (autocert HTTP-01), which has a hard Let's Encrypt ceiling (~50 new certs a
33// week per registered domain) and means a never-served subdomain fails the TLS
34// handshake outright. A single wildcard cert for *.<domain>, obtained over
35// DNS-01 (a TXT record at _acme-challenge.<domain> via the DNSimple API),
36// covers every space instantly and forever-many of them.
37//
38// The wildcard only changes how a handshake GETS a certificate: routing and
39// the autocert HostPolicy are untouched, and per-host HTTP-01 remains the path
40// for every host outside the wildcard domains (and the fallback for hosts
41// inside them until the wildcard is first obtained — so a DNS-provider or ACME
42// outage degrades to exactly today's behaviour, never a dead router).
43//
44// The DNS-01 solver is PLUGGABLE (the dnsProvider seam): DNSimple stays the
45// default, fully-supported backend (LCHAT_DNSIMPLE_TOKEN), and Route53
46// (route53dns.go) is an ADDITIVE off-ramp selected by LCHAT_DNS_PROVIDER=route53
47// for boxes that would rather authenticate with an AWS IAM role than a token on
48// disk (docs/multi-edge.md). Selection lives in dnsProviderFromEnv; everything
49// below is provider-blind.
50//
51// Off unless LCHAT_WILDCARD_DOMAINS is set and a DNS provider is configured
52// (DNSimple by default, so LCHAT_DNSIMPLE_TOKEN). See
53// docs/instance-router.md § Wildcard TLS.
54
55const letsEncryptDirectory = "https://acme-v02.api.letsencrypt.org/directory"
56
57// wildcardRenewBefore is how close to expiry we re-issue. LE certs live 90
58// days; renewing with 30 left tolerates a month of provider outage.
59const wildcardRenewBefore = 30 * 24 * time.Hour
60
61// dnsProvider is the DNS-01 seam. It publishes the ACME challenge TXT and hands
62// back a closure that removes it — a closure so each backend keeps its own
63// teardown detail private (DNSimple deletes by the numeric record id it minted;
64// Route53 re-submits the record set). wildcardManager.solveDNS01 only ever calls
65// present, so it never learns which backend is in use.
66//
67// Implementations: dnsimpleDNS (the default, this file) and route53DNS
68// (route53dns.go, the additive off-ramp). Choose with dnsProviderFromEnv.
69type dnsProvider interface {
70 // present creates a TXT record named name under zone (e.g.
71 // "_acme-challenge" under "11ch.at") holding value, returning a cleanup that
72 // deletes it. cleanup runs on its own context (see solveDNS01's defer) so it
73 // still fires after the issuance context is dead.
74 present(ctx context.Context, zone, name, value string) (cleanup func(context.Context) error, err error)
75}
76
77// dnsProviderFromEnv selects the DNS-01 backend, keeping DNSimple the DEFAULT
78// with its trigger unchanged:
79//
80// - unset / LCHAT_DNS_PROVIDER=dnsimple → DNSimple, exactly as before — needs
81// LCHAT_DNSIMPLE_TOKEN (an account API token); a missing token leaves the
82// wildcard off (per-host issuance continues), the historical behaviour.
83// - LCHAT_DNS_PROVIDER=route53 → Route53 (the multi-edge off-ramp), authed by
84// the standard AWS credential chain (env vars / EC2-or-container IAM role) —
85// no DNS token on disk. The dev boxes use their zone-scoped role for
86// *.dev.11ch.at (docs/multi-edge.md).
87//
88// Returns (nil, reason) when nothing is configured, so the caller logs the human
89// reason and falls back to per-host issuance rather than failing to boot.
90func dnsProviderFromEnv() (dnsProvider, string) {
91 switch strings.ToLower(strings.TrimSpace(os.Getenv("LCHAT_DNS_PROVIDER"))) {
92 case "route53":
93 r53, err := newRoute53DNS(context.Background())
94 if err != nil {
95 return nil, fmt.Sprintf("LCHAT_DNS_PROVIDER=route53 but AWS config load failed: %v", err)
96 }
97 return r53, ""
98 case "", "dnsimple":
99 token := os.Getenv("LCHAT_DNSIMPLE_TOKEN")
100 if token == "" {
101 return nil, "LCHAT_DNSIMPLE_TOKEN missing"
102 }
103 return newDNSimpleDNS(token), ""
104 default:
105 return nil, fmt.Sprintf("unknown LCHAT_DNS_PROVIDER %q (want dnsimple or route53)", os.Getenv("LCHAT_DNS_PROVIDER"))
106 }
107}
108
109type wildcardManager struct {
110 domains []string
111 // cache is the shared cert cache (certcache.go) — the same backend
112 // autocert uses for per-host certs, so pooled routers share the wildcard
113 // too. Under the dir backend the keys land as the historical file names
114 // beside autocert's certs (wildcard-<domain>.pem, wildcard-account.key).
115 cache autocert.Cache
116 dns dnsProvider
117 directory string
118 // One atomically-swapped cert per domain, read on every 11ch.at handshake.
119 certs map[string]*atomic.Pointer[tls.Certificate]
120}
121
122func newWildcardManager(domains []string, cache autocert.Cache, dns dnsProvider) *wildcardManager {
123 w := &wildcardManager{
124 domains: domains,
125 cache: cache,
126 dns: dns,
127 directory: envOr("LCHAT_ACME_DIRECTORY", letsEncryptDirectory),
128 certs: map[string]*atomic.Pointer[tls.Certificate]{},
129 }
130 // The startup load is synchronous (runRouter wires certFor into the TLS
131 // config right after this), so bound it: a dir read is instant, but a
132 // remote cache backend must degrade to "re-obtain" rather than hang boot.
133 ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
134 defer cancel()
135 for _, d := range domains {
136 w.certs[d] = &atomic.Pointer[tls.Certificate]{}
137 raw, err := w.cache.Get(ctx, wildcardPEMKey(d))
138 if errors.Is(err, autocert.ErrCacheMiss) {
139 continue // first run for this domain — run() obtains one
140 }
141 if err == nil {
142 var cert *tls.Certificate
143 if cert, err = parseWildcardPEM(raw); err == nil {
144 w.certs[d].Store(cert)
145 log.Printf("router: wildcard *.%s loaded from cache (valid until %s)", d, cert.Leaf.NotAfter.Format(time.RFC3339))
146 continue
147 }
148 }
149 log.Printf("router: wildcard *.%s: cached cert unreadable (%v) — will re-obtain", d, err)
150 }
151 return w
152}
153
154// wildcardPEMKey is the cache key for a domain's wildcard cert+key PEM. It is
155// deliberately the pre-seam file name, so the dir backend reads the PEMs an
156// older binary wrote to the acme cache dir with no migration.
157func wildcardPEMKey(domain string) string {
158 return "wildcard-" + domain + ".pem"
159}
160
161// certFor returns the wildcard cert for serverName, or nil when the name isn't
162// exactly one label under a configured domain (a wildcard doesn't cover the
163// apex or deeper levels) or no usable cert is loaded — nil sends the handshake
164// to the per-host autocert path.
165func (w *wildcardManager) certFor(serverName string) *tls.Certificate {
166 sn := strings.ToLower(strings.TrimSuffix(serverName, "."))
167 for _, d := range w.domains {
168 sub, ok := strings.CutSuffix(sn, "."+d)
169 if !ok || sub == "" || strings.Contains(sub, ".") {
170 continue
171 }
172 cert := w.certs[d].Load()
173 if cert == nil || time.Now().After(cert.Leaf.NotAfter) {
174 // Never serve a dead cert: a wildcard whose renewal has been broken
175 // past its actual expiry falls back to per-host issuance (today's
176 // behaviour) instead of hard-failing every space handshake.
177 return nil
178 }
179 return cert
180 }
181 return nil
182}
183
184// run keeps the wildcard certs current: an immediate check at startup (first
185// issuance), then every 12h, dropping to hourly retries after a failure.
186func (w *wildcardManager) run() {
187 for {
188 delay := 12 * time.Hour
189 for _, d := range w.domains {
190 if !w.needsObtain(d, time.Now()) {
191 continue
192 }
193 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
194 err := w.obtain(ctx, d)
195 cancel()
196 if err != nil {
197 log.Printf("router: wildcard *.%s: %v — per-host issuance keeps covering; retrying in 1h", d, err)
198 delay = time.Hour
199 continue
200 }
201 cert := w.certs[d].Load()
202 log.Printf("router: wildcard *.%s obtained (valid until %s)", d, cert.Leaf.NotAfter.Format(time.RFC3339))
203 }
204 time.Sleep(delay)
205 }
206}
207
208func (w *wildcardManager) needsObtain(domain string, now time.Time) bool {
209 cert := w.certs[domain].Load()
210 return cert == nil || now.Add(wildcardRenewBefore).After(cert.Leaf.NotAfter)
211}
212
213// obtain runs one full ACME DNS-01 issuance for *.domain and swaps the result in.
214func (w *wildcardManager) obtain(ctx context.Context, domain string) error {
215 acctKey, err := w.accountKey(ctx)
216 if err != nil {
217 return fmt.Errorf("account key: %v", err)
218 }
219 client := &acme.Client{Key: acctKey, DirectoryURL: w.directory}
220 if _, err := client.Register(ctx, &acme.Account{}, acme.AcceptTOS); err != nil && err != acme.ErrAccountAlreadyExists {
221 return fmt.Errorf("acme register: %v", err)
222 }
223
224 order, err := client.AuthorizeOrder(ctx, []acme.AuthzID{{Type: "dns", Value: "*." + domain}})
225 if err != nil {
226 return fmt.Errorf("authorize order: %v", err)
227 }
228 for _, zurl := range order.AuthzURLs {
229 if err := w.solveDNS01(ctx, client, domain, zurl); err != nil {
230 return err
231 }
232 }
233
234 certKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
235 if err != nil {
236 return err
237 }
238 csr, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{
239 Subject: pkix.Name{CommonName: "*." + domain},
240 DNSNames: []string{"*." + domain},
241 }, certKey)
242 if err != nil {
243 return fmt.Errorf("csr: %v", err)
244 }
245 ders, _, err := client.CreateOrderCert(ctx, order.FinalizeURL, csr, true)
246 if err != nil {
247 return fmt.Errorf("finalize: %v", err)
248 }
249 leaf, err := x509.ParseCertificate(ders[0])
250 if err != nil {
251 return fmt.Errorf("parse issued cert: %v", err)
252 }
253 cert := &tls.Certificate{Certificate: ders, PrivateKey: certKey, Leaf: leaf}
254 raw, err := encodeWildcardPEM(cert, certKey)
255 if err != nil {
256 return fmt.Errorf("encode: %v", err)
257 }
258 if err := w.cache.Put(ctx, wildcardPEMKey(domain), raw); err != nil {
259 return fmt.Errorf("persist: %v", err)
260 }
261 w.certs[domain].Store(cert)
262 return nil
263}
264
265// solveDNS01 answers one authorization: publish the challenge TXT record,
266// wait for the CA to see it, and always clean the record up afterwards.
267func (w *wildcardManager) solveDNS01(ctx context.Context, client *acme.Client, domain, authzURL string) error {
268 authz, err := client.GetAuthorization(ctx, authzURL)
269 if err != nil {
270 return fmt.Errorf("get authorization: %v", err)
271 }
272 if authz.Status == acme.StatusValid {
273 return nil // a recent order already proved this name
274 }
275 var chal *acme.Challenge
276 for _, c := range authz.Challenges {
277 if c.Type == "dns-01" {
278 chal = c
279 break
280 }
281 }
282 if chal == nil {
283 return errors.New("CA offered no dns-01 challenge")
284 }
285 txt, err := client.DNS01ChallengeRecord(chal.Token)
286 if err != nil {
287 return err
288 }
289
290 cleanup, err := w.dns.present(ctx, domain, "_acme-challenge", txt)
291 if err != nil {
292 return fmt.Errorf("publish challenge TXT: %v", err)
293 }
294 // Clean up with a fresh context: the record must not outlive the attempt
295 // even when ctx is already dead (stale challenge TXTs break the NEXT run).
296 defer func() {
297 cctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
298 defer cancel()
299 if err := cleanup(cctx); err != nil {
300 log.Printf("router: wildcard *.%s: cleanup of challenge TXT failed: %v", domain, err)
301 }
302 }()
303
304 // Best-effort: give the record a moment to reach the authoritative NS
305 // before telling the CA to look. Proceed on timeout — the CA does its own
306 // retries, and a hard failure just comes back through the retry loop.
307 waitForTXT(ctx, "_acme-challenge."+domain, txt, time.Minute)
308
309 if _, err := client.Accept(ctx, chal); err != nil {
310 return fmt.Errorf("accept challenge: %v", err)
311 }
312 if _, err := client.WaitAuthorization(ctx, authz.URI); err != nil {
313 return fmt.Errorf("authorization: %v", err)
314 }
315 return nil
316}
317
318// wildcardAccountKeyName is the ACME account key's cache key — the pre-seam
319// file name, for the same no-migration reason as wildcardPEMKey.
320const wildcardAccountKeyName = "wildcard-account.key"
321
322// accountKey loads (or mints) the ACME account key. Separate from autocert's
323// account on purpose: this flow manages its own registration lifecycle.
324func (w *wildcardManager) accountKey(ctx context.Context) (*ecdsa.PrivateKey, error) {
325 raw, err := w.cache.Get(ctx, wildcardAccountKeyName)
326 if err == nil {
327 block, _ := pem.Decode(raw)
328 if block == nil {
329 return nil, fmt.Errorf("%s: not PEM", wildcardAccountKeyName)
330 }
331 return x509.ParseECPrivateKey(block.Bytes)
332 }
333 if !errors.Is(err, autocert.ErrCacheMiss) {
334 // Only a genuine miss mints a key: treating a backend fault as "no key
335 // yet" would register a fresh ACME account on every hiccup. Fail and
336 // let obtain()'s hourly retry come back around.
337 return nil, err
338 }
339 key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
340 if err != nil {
341 return nil, err
342 }
343 der, err := x509.MarshalECPrivateKey(key)
344 if err != nil {
345 return nil, err
346 }
347 // Crash-safety (temp + rename, 0600) is the cache backend's contract now —
348 // the dir backend inherits it from autocert.DirCache.
349 raw = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})
350 if err := w.cache.Put(ctx, wildcardAccountKeyName, raw); err != nil {
351 return nil, err
352 }
353 return key, nil
354}
355
356// waitForTXT polls DNS until name resolves to a TXT containing value, or the
357// deadline passes (best-effort — see caller).
358func waitForTXT(ctx context.Context, name, value string, timeout time.Duration) {
359 deadline := time.Now().Add(timeout)
360 for time.Now().Before(deadline) && ctx.Err() == nil {
361 if txts, err := net.DefaultResolver.LookupTXT(ctx, name); err == nil {
362 for _, t := range txts {
363 if t == value {
364 return
365 }
366 }
367 }
368 time.Sleep(3 * time.Second)
369 }
370}
371
372// ---- PEM encode/parse (key + chain in one blob; the cert cache owns
373// placement, permissions, and write atomicity) ----------------------------------
374
375func encodeWildcardPEM(cert *tls.Certificate, key *ecdsa.PrivateKey) ([]byte, error) {
376 var buf bytes.Buffer
377 der, err := x509.MarshalECPrivateKey(key)
378 if err != nil {
379 return nil, err
380 }
381 if err := pem.Encode(&buf, &pem.Block{Type: "EC PRIVATE KEY", Bytes: der}); err != nil {
382 return nil, err
383 }
384 for _, c := range cert.Certificate {
385 if err := pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: c}); err != nil {
386 return nil, err
387 }
388 }
389 return buf.Bytes(), nil
390}
391
392func parseWildcardPEM(raw []byte) (*tls.Certificate, error) {
393 cert := &tls.Certificate{}
394 for {
395 var block *pem.Block
396 block, raw = pem.Decode(raw)
397 if block == nil {
398 break
399 }
400 switch block.Type {
401 case "EC PRIVATE KEY":
402 key, err := x509.ParseECPrivateKey(block.Bytes)
403 if err != nil {
404 return nil, err
405 }
406 cert.PrivateKey = key
407 case "CERTIFICATE":
408 cert.Certificate = append(cert.Certificate, block.Bytes)
409 }
410 }
411 if cert.PrivateKey == nil || len(cert.Certificate) == 0 {
412 return nil, errors.New("missing key or certificate in PEM")
413 }
414 leaf, err := x509.ParseCertificate(cert.Certificate[0])
415 if err != nil {
416 return nil, err
417 }
418 cert.Leaf = leaf
419 return cert, nil
420}
421
422// ---- DNSimple zone records (the existing internal/bot client is registrar/
423// OAuth-only; the router needs record create/delete with a long-lived token) --
424
425type dnsimpleDNS struct {
426 base string
427 token string
428 account string // discovered via /v2/whoami on first use
429}
430
431// newDNSimpleDNS builds the default DNS-01 backend from an account API token,
432// honouring the LCHAT_DNSIMPLE_BASE override (kept here so all env reads for
433// this backend live in one place).
434func newDNSimpleDNS(token string) *dnsimpleDNS {
435 return &dnsimpleDNS{
436 base: envOr("LCHAT_DNSIMPLE_BASE", "https://api.dnsimple.com"),
437 token: token,
438 }
439}
440
441// present satisfies dnsProvider: create the challenge TXT, then hand back a
442// closure that deletes it by the record id DNSimple returned. createTXT and
443// deleteRecord are unchanged, so the DNSimple wire behaviour is byte-for-byte
444// what it was before the seam existed.
445func (d *dnsimpleDNS) present(ctx context.Context, zone, name, value string) (func(context.Context) error, error) {
446 id, err := d.createTXT(ctx, zone, name, value)
447 if err != nil {
448 return nil, fmt.Errorf("dnsimple: create TXT: %v", err)
449 }
450 return func(cctx context.Context) error { return d.deleteRecord(cctx, zone, id) }, nil
451}
452
453func (d *dnsimpleDNS) do(ctx context.Context, method, path string, body, out any) error {
454 var rdr io.Reader
455 if body != nil {
456 raw, err := json.Marshal(body)
457 if err != nil {
458 return err
459 }
460 rdr = bytes.NewReader(raw)
461 }
462 req, err := http.NewRequestWithContext(ctx, method, d.base+path, rdr)
463 if err != nil {
464 return err
465 }
466 req.Header.Set("Authorization", "Bearer "+d.token)
467 req.Header.Set("Accept", "application/json")
468 if body != nil {
469 req.Header.Set("Content-Type", "application/json")
470 }
471 client := &http.Client{Timeout: 30 * time.Second}
472 resp, err := client.Do(req)
473 if err != nil {
474 return err
475 }
476 defer resp.Body.Close()
477 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
478 msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
479 return fmt.Errorf("%s %s: %s: %s", method, path, resp.Status, strings.TrimSpace(string(msg)))
480 }
481 if out != nil {
482 return json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(out)
483 }
484 return nil
485}
486
487func (d *dnsimpleDNS) accountID(ctx context.Context) (string, error) {
488 if d.account != "" {
489 return d.account, nil
490 }
491 var who struct {
492 Data struct {
493 Account *struct {
494 ID int64 `json:"id"`
495 } `json:"account"`
496 } `json:"data"`
497 }
498 if err := d.do(ctx, "GET", "/v2/whoami", nil, &who); err != nil {
499 return "", err
500 }
501 if who.Data.Account == nil {
502 return "", errors.New("whoami: token is not an account token")
503 }
504 d.account = fmt.Sprint(who.Data.Account.ID)
505 return d.account, nil
506}
507
508func (d *dnsimpleDNS) createTXT(ctx context.Context, zone, name, content string) (int64, error) {
509 acct, err := d.accountID(ctx)
510 if err != nil {
511 return 0, err
512 }
513 var created struct {
514 Data struct {
515 ID int64 `json:"id"`
516 } `json:"data"`
517 }
518 err = d.do(ctx, "POST", "/v2/"+acct+"/zones/"+zone+"/records", map[string]any{
519 "name": name, "type": "TXT", "content": content, "ttl": 60,
520 }, &created)
521 if err != nil {
522 return 0, err
523 }
524 return created.Data.ID, nil
525}
526
527func (d *dnsimpleDNS) deleteRecord(ctx context.Context, zone string, id int64) error {
528 acct, err := d.accountID(ctx)
529 if err != nil {
530 return err
531 }
532 return d.do(ctx, "DELETE", fmt.Sprintf("/v2/%s/zones/%s/records/%d", acct, zone, id), nil, nil)
533}
534
535// splitDomains parses the LCHAT_WILDCARD_DOMAINS comma list.
536func splitDomains(s string) []string {
537 var out []string
538 for _, d := range strings.Split(s, ",") {
539 if d = strings.ToLower(strings.TrimSpace(d)); d != "" {
540 out = append(out, d)
541 }
542 }
543 return out
544}
545