server: add Route53 as an additive DNS-01 provider for wildcard TLS (#1106)
The router's wildcard cert issuance (wildcard.go) hard-wired DNSimple as its ACME DNS-01 solver. Make the solver pluggable behind a dnsProvider seam and add Route53 as a second, ADDITIVE backend — the multi-edge off-ramp that lets a box obtain a *.dev.11ch.at wildcard from its scoped IAM role, no DNS token on disk. DNSimple stays the default and fully-supported path: it is selected whenever LCHAT_DNS_PROVIDER is unset (or =dnsimple) and LCHAT_DNSIMPLE_TOKEN is present, exactly the historical trigger, and its createTXT/deleteRecord calls are unchanged. Route53 is chosen only by an explicit LCHAT_DNS_PROVIDER=route53 and authenticates via the standard AWS credential chain (mirroring s3blobstore.go), setting/removing the _acme-challenge TXT via ChangeResourceRecordSets and waiting for the change to go INSYNC before ACME proceeds. Selection lives in dnsProviderFromEnv; solveDNS01 is provider-blind (it calls present() and defers the returned cleanup closure). A nil provider (missing token, unknown selector) keeps per-host issuance, never a dead router. Tests: provider selection (default -> DNSimple unchanged, explicit route53 -> the Route53 solver), the Route53 UPSERT/INSYNC/DELETE wiring against a fake client, hosted-zone most-specific matching, and the DNSimple present adapter. Adds github.com/aws/aws-sdk-go-v2/service/route53; go mod tidy clean.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
8 files changed,
+511
−40
docs/instance-router.md+27 −9go.mod+3 −2go.sum+2 −2route53dns.go+182 −0route53dns_test.go+120 −0router.go+7 −4wildcard.go+91 −15wildcard_test.go+79 −8
diff --git a/docs/instance-router.md b/docs/instance-router.md| index 5a63869..154b92d 100644 |
| --- a/docs/instance-router.md |
| +++ b/docs/instance-router.md |
| @@ -101,18 +101,36 @@ 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. |
| +and a DNS-01 provider (below) 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 DNS-provider/ACME outage degrades to today's behaviour |
| +rather than a dead router. |
| + |
| +**DNS-01 provider (pluggable; `LCHAT_DNS_PROVIDER`).** The solver that publishes |
| +the challenge TXT is a seam (`dnsProvider`), so the wildcard works against more |
| +than one DNS API without changing anything else: |
| + |
| +- **DNSimple — the default.** Leave `LCHAT_DNS_PROVIDER` unset (or set it to |
| + `dnsimple`) and provide `LCHAT_DNSIMPLE_TOKEN`, a DNSimple **account** API |
| + token that can manage the zone. This is the fully-supported path for |
| + self-hosters and production; nothing about it changed when the seam landed. A |
| + missing token leaves the wildcard off (per-host issuance continues). |
| +- **Route53 — the additive off-ramp** (`docs/multi-edge.md`). Set |
| + `LCHAT_DNS_PROVIDER=route53`; the solver authenticates with the **standard AWS |
| + credential chain** (env vars or an EC2/container **IAM role**), so there is *no |
| + DNS token on disk*. The role is scoped to just the wildcard domain's hosted |
| + zone — this is how the dev boxes obtain a shared `*.dev.11ch.at` cert from |
| + their instance role. Route53 is global, so no region is required (the solver |
| + defaults the signing region to `us-east-1`; an explicit `AWS_REGION` wins). |
| 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 |
| +publishes a `_acme-challenge.<domain>` TXT via the selected provider (TTL 60, |
| +always cleaned up) — DNSimple via its records API, Route53 via |
| +`ChangeResourceRecordSets` (UPSERT, then a wait for the change to go INSYNC) — |
| +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 |
diff --git a/go.mod b/go.mod| index a266950..d5ab4dd 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -8,6 +8,8 @@ require ( |
| github.com/SherClockHolmes/webpush-go v1.4.0 |
| github.com/aws/aws-sdk-go-v2 v1.42.1 |
| github.com/aws/aws-sdk-go-v2/config v1.32.6 |
| + github.com/aws/aws-sdk-go-v2/credentials v1.19.6 |
| + github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1 |
| github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0 |
| github.com/aws/smithy-go v1.27.3 |
| github.com/benbjohnson/litestream v0.5.14 |
| @@ -15,12 +17,12 @@ require ( |
| github.com/go-webauthn/webauthn v0.11.2 |
| github.com/sqids/sqids-go v0.4.1 |
| golang.org/x/crypto v0.52.0 |
| + golang.org/x/term v0.45.0 |
| modernc.org/sqlite v1.44.3 |
| ) |
| require ( |
| github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect |
| - github.com/aws/aws-sdk-go-v2/credentials v1.19.6 // indirect |
| github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect |
| github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.18 // indirect |
| github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect |
| @@ -69,7 +71,6 @@ require ( |
| golang.org/x/net v0.55.0 // indirect |
| golang.org/x/sync v0.20.0 // indirect |
| golang.org/x/sys v0.47.0 // indirect |
| - golang.org/x/term v0.45.0 // indirect |
| golang.org/x/text v0.37.0 // indirect |
| google.golang.org/protobuf v1.36.10 // indirect |
| modernc.org/libc v1.67.6 // indirect |
diff --git a/go.sum b/go.sum| index 378243b..afff804 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -48,6 +48,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrK |
| github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk= |
| github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 h1:uao4A3QZ5UmB326V6KF+qRpv9Tjz7IlnlnTbbANntlU= |
| github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31/go.mod h1:I/1+z0VwL1GhQyLgkoHDlygpUZ+iTAwOQ/NsftiUL2I= |
| +github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1 h1:qL5ELChQ1o1ZbzHvsl8gzQfAZdbQtrDbUyzJkjkMKcg= |
| +github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1/go.mod h1:0hIRXFez1bZsDFMGkLZvNJbByTSVZ4sFZWpxZ39NPuM= |
| github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0 h1:XptwLL+UHXgafYMIHTy59IRovLbhz3znkxY2uS/pbXU= |
| github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0/go.mod h1:zdmCoFO/dSI7GlrwsPqFJI+WlFnSU4Tc8TJnlXrM1Do= |
| github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ= |
| @@ -245,8 +247,6 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= |
| golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= |
| golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= |
| -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= |
| -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
| golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= |
| golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
| golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= |
diff --git a/route53dns.go b/route53dns.go| new file mode 100644 |
| index 0000000..0abf637 |
| --- /dev/null |
| +++ b/route53dns.go |
| @@ -0,0 +1,182 @@ |
| +package main |
| + |
| +// 🤖 route53DNS — the ADDITIVE Route53 DNS-01 backend (docs/multi-edge.md). It |
| +// is the off-ramp that lets a box obtain a *.dev.11ch.at (or any Route53-hosted) |
| +// wildcard cert with NO DNS token on disk: auth rides the standard AWS |
| +// credential chain (env vars or, on the dev boxes, the EC2/container IAM role |
| +// scoped to just that hosted zone). DNSimple stays the default, fully-supported |
| +// provider (wildcard.go); this NEVER replaces it — it is chosen only by an |
| +// explicit LCHAT_DNS_PROVIDER=route53 (see dnsProviderFromEnv). |
| +// |
| +// Same seam as DNSimple (the dnsProvider interface): publish a |
| +// _acme-challenge.<zone> TXT, wait for the change to go INSYNC across Route53's |
| +// servers, and hand back a closure that removes it. The AWS SDK setup mirrors |
| +// s3blobstore.go (config.LoadDefaultConfig + the default chain), so credentials |
| +// come from exactly one place fleet-wide. |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "os" |
| + "strconv" |
| + "strings" |
| + "sync" |
| + "time" |
| + |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| + awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| + "github.com/aws/aws-sdk-go-v2/service/route53" |
| + r53types "github.com/aws/aws-sdk-go-v2/service/route53/types" |
| +) |
| + |
| +// route53InSyncTimeout bounds the wait for a ChangeResourceRecordSets to reach |
| +// INSYNC. Route53 usually syncs in well under a minute; on timeout we proceed |
| +// anyway — the CA runs its own retries and waitForTXT still gates public |
| +// visibility, exactly as the DNSimple path relies on (best-effort, never fatal). |
| +const route53InSyncTimeout = 2 * time.Minute |
| + |
| +// route53API is the slice of the Route53 client route53DNS calls — an interface |
| +// so tests can substitute a fake and never reach AWS. |
| +type route53API interface { |
| + ListHostedZonesByName(ctx context.Context, in *route53.ListHostedZonesByNameInput, optFns ...func(*route53.Options)) (*route53.ListHostedZonesByNameOutput, error) |
| + ChangeResourceRecordSets(ctx context.Context, in *route53.ChangeResourceRecordSetsInput, optFns ...func(*route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error) |
| + GetChange(ctx context.Context, in *route53.GetChangeInput, optFns ...func(*route53.Options)) (*route53.GetChangeOutput, error) |
| +} |
| + |
| +type route53DNS struct { |
| + client route53API |
| + mu sync.Mutex |
| + zoneIDs map[string]string // zone apex (no trailing dot) → hosted-zone id |
| +} |
| + |
| +// newRoute53DNS builds the backend from the default AWS credential chain (env / |
| +// instance role), the same setup style as s3blobstore.go. Route53 is a global |
| +// service but the SDK still needs a signing region, so default to us-east-1 when |
| +// the environment names none — harmless, the endpoint is global either way, and |
| +// an explicit AWS_REGION / profile still wins. |
| +func newRoute53DNS(ctx context.Context) (*route53DNS, error) { |
| + var opts []func(*awsconfig.LoadOptions) error |
| + if strings.TrimSpace(os.Getenv("AWS_REGION")) == "" && strings.TrimSpace(os.Getenv("AWS_DEFAULT_REGION")) == "" { |
| + opts = append(opts, awsconfig.WithRegion("us-east-1")) |
| + } |
| + cfg, err := awsconfig.LoadDefaultConfig(ctx, opts...) |
| + if err != nil { |
| + return nil, fmt.Errorf("route53: load aws config: %w", err) |
| + } |
| + return &route53DNS{client: route53.NewFromConfig(cfg), zoneIDs: map[string]string{}}, nil |
| +} |
| + |
| +// present satisfies dnsProvider: UPSERT the challenge TXT, wait for INSYNC, and |
| +// return a closure that deletes the exact record set it created. |
| +func (r *route53DNS) present(ctx context.Context, zone, name, value string) (func(context.Context) error, error) { |
| + zoneID, err := r.hostedZoneID(ctx, zone) |
| + if err != nil { |
| + return nil, err |
| + } |
| + // Route53 stores a TXT value as a quoted character-string; the ACME token is |
| + // a bare base64url string, so wrap it. UPSERT (not CREATE) so a TXT stranded |
| + // by a crashed prior attempt is overwritten instead of colliding. |
| + rrset := &r53types.ResourceRecordSet{ |
| + Name: aws.String(dnsName(name, zone)), |
| + Type: r53types.RRTypeTxt, |
| + TTL: aws.Int64(60), |
| + ResourceRecords: []r53types.ResourceRecord{{Value: aws.String(strconv.Quote(value))}}, |
| + } |
| + out, err := r.client.ChangeResourceRecordSets(ctx, &route53.ChangeResourceRecordSetsInput{ |
| + HostedZoneId: aws.String(zoneID), |
| + ChangeBatch: &r53types.ChangeBatch{ |
| + Changes: []r53types.Change{{Action: r53types.ChangeActionUpsert, ResourceRecordSet: rrset}}, |
| + }, |
| + }) |
| + if err != nil { |
| + return nil, fmt.Errorf("route53: upsert TXT: %w", err) |
| + } |
| + // Wait (best-effort) for the change to reach all Route53 servers before the |
| + // caller tells the CA to look — the DNSimple path leans on waitForTXT for the |
| + // same reason; here we also have Route53's own INSYNC signal. |
| + if out.ChangeInfo != nil { |
| + r.waitInSync(ctx, aws.ToString(out.ChangeInfo.Id)) |
| + } |
| + cleanup := func(cctx context.Context) error { |
| + // DELETE must resubmit the same record set that was created. |
| + _, err := r.client.ChangeResourceRecordSets(cctx, &route53.ChangeResourceRecordSetsInput{ |
| + HostedZoneId: aws.String(zoneID), |
| + ChangeBatch: &r53types.ChangeBatch{ |
| + Changes: []r53types.Change{{Action: r53types.ChangeActionDelete, ResourceRecordSet: rrset}}, |
| + }, |
| + }) |
| + return err |
| + } |
| + return cleanup, nil |
| +} |
| + |
| +// hostedZoneID resolves a zone to its Route53 hosted-zone id, caching the |
| +// lookup. It expects the wildcard domain to have its OWN hosted zone (which is |
| +// how the box's IAM role is scoped — one zone, e.g. dev.11ch.at), so it takes |
| +// the most specific returned zone whose name is the zone itself or a parent of |
| +// it. Ids come back as "/hostedzone/Z123"; the change API accepts either form |
| +// but we store the bare id. |
| +func (r *route53DNS) hostedZoneID(ctx context.Context, zone string) (string, error) { |
| + r.mu.Lock() |
| + if id, ok := r.zoneIDs[zone]; ok { |
| + r.mu.Unlock() |
| + return id, nil |
| + } |
| + r.mu.Unlock() |
| + |
| + want := dnsFQDN(zone) |
| + out, err := r.client.ListHostedZonesByName(ctx, &route53.ListHostedZonesByNameInput{DNSName: aws.String(want)}) |
| + if err != nil { |
| + return "", fmt.Errorf("route53: list hosted zones: %w", err) |
| + } |
| + var bestName, bestID string |
| + for _, hz := range out.HostedZones { |
| + zn := strings.ToLower(aws.ToString(hz.Name)) |
| + // The zone must be authoritative for `want`: equal to it or a parent |
| + // suffix (".example.com" covers "sub.example.com"). Keep the longest — |
| + // the most specific — match. |
| + if zn == want || strings.HasSuffix(want, "."+zn) { |
| + if len(zn) > len(bestName) { |
| + bestName, bestID = zn, strings.TrimPrefix(aws.ToString(hz.Id), "/hostedzone/") |
| + } |
| + } |
| + } |
| + if bestID == "" { |
| + return "", fmt.Errorf("route53: no hosted zone for %s", zone) |
| + } |
| + r.mu.Lock() |
| + r.zoneIDs[zone] = bestID |
| + r.mu.Unlock() |
| + return bestID, nil |
| +} |
| + |
| +// waitInSync polls GetChange until the change is INSYNC or the timeout passes. |
| +// Best-effort, mirroring waitForTXT: a timeout is not fatal (the CA retries). |
| +func (r *route53DNS) waitInSync(ctx context.Context, changeID string) { |
| + if changeID == "" { |
| + return |
| + } |
| + deadline := time.Now().Add(route53InSyncTimeout) |
| + for time.Now().Before(deadline) && ctx.Err() == nil { |
| + out, err := r.client.GetChange(ctx, &route53.GetChangeInput{Id: aws.String(changeID)}) |
| + if err == nil && out.ChangeInfo != nil && out.ChangeInfo.Status == r53types.ChangeStatusInsync { |
| + return |
| + } |
| + time.Sleep(3 * time.Second) |
| + } |
| +} |
| + |
| +// dnsFQDN appends the root dot Route53 stores names with. |
| +func dnsFQDN(zone string) string { |
| + zone = strings.ToLower(zone) |
| + if strings.HasSuffix(zone, ".") { |
| + return zone |
| + } |
| + return zone + "." |
| +} |
| + |
| +// dnsName builds the fully-qualified record name Route53 wants, e.g. |
| +// ("_acme-challenge", "11ch.at") → "_acme-challenge.11ch.at." |
| +func dnsName(name, zone string) string { |
| + return name + "." + dnsFQDN(zone) |
| +} |
diff --git a/route53dns_test.go b/route53dns_test.go| new file mode 100644 |
| index 0000000..606f590 |
| --- /dev/null |
| +++ b/route53dns_test.go |
| @@ -0,0 +1,120 @@ |
| +package main |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| + "github.com/aws/aws-sdk-go-v2/service/route53" |
| + r53types "github.com/aws/aws-sdk-go-v2/service/route53/types" |
| +) |
| + |
| +// fakeRoute53 stands in for the Route53 client so the solver's wiring is tested |
| +// without reaching AWS. It records every ChangeResourceRecordSets call and |
| +// answers GetChange as immediately INSYNC. |
| +type fakeRoute53 struct { |
| + hostedZones []r53types.HostedZone |
| + changes []*route53.ChangeResourceRecordSetsInput |
| + getChangeN int |
| +} |
| + |
| +func (f *fakeRoute53) ListHostedZonesByName(_ context.Context, _ *route53.ListHostedZonesByNameInput, _ ...func(*route53.Options)) (*route53.ListHostedZonesByNameOutput, error) { |
| + return &route53.ListHostedZonesByNameOutput{HostedZones: f.hostedZones}, nil |
| +} |
| + |
| +func (f *fakeRoute53) ChangeResourceRecordSets(_ context.Context, in *route53.ChangeResourceRecordSetsInput, _ ...func(*route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error) { |
| + f.changes = append(f.changes, in) |
| + return &route53.ChangeResourceRecordSetsOutput{ChangeInfo: &r53types.ChangeInfo{Id: aws.String("/change/C123")}}, nil |
| +} |
| + |
| +func (f *fakeRoute53) GetChange(_ context.Context, _ *route53.GetChangeInput, _ ...func(*route53.Options)) (*route53.GetChangeOutput, error) { |
| + f.getChangeN++ |
| + return &route53.GetChangeOutput{ChangeInfo: &r53types.ChangeInfo{Status: r53types.ChangeStatusInsync}}, nil |
| +} |
| + |
| +func TestRoute53Present(t *testing.T) { |
| + fake := &fakeRoute53{hostedZones: []r53types.HostedZone{ |
| + {Name: aws.String("11ch.at."), Id: aws.String("/hostedzone/ZONEID")}, |
| + }} |
| + r := &route53DNS{client: fake, zoneIDs: map[string]string{}} |
| + |
| + cleanup, err := r.present(context.Background(), "11ch.at", "_acme-challenge", "tok-value") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(fake.changes) != 1 { |
| + t.Fatalf("want 1 change on present, got %d", len(fake.changes)) |
| + } |
| + up := fake.changes[0] |
| + if got := aws.ToString(up.HostedZoneId); got != "ZONEID" { |
| + t.Fatalf("hosted zone id = %q, want ZONEID (prefix stripped)", got) |
| + } |
| + ch := up.ChangeBatch.Changes[0] |
| + if ch.Action != r53types.ChangeActionUpsert { |
| + t.Fatalf("action = %v, want UPSERT", ch.Action) |
| + } |
| + rr := ch.ResourceRecordSet |
| + if got := aws.ToString(rr.Name); got != "_acme-challenge.11ch.at." { |
| + t.Fatalf("record name = %q", got) |
| + } |
| + if rr.Type != r53types.RRTypeTxt { |
| + t.Fatalf("record type = %v, want TXT", rr.Type) |
| + } |
| + if got := aws.ToInt64(rr.TTL); got != 60 { |
| + t.Fatalf("ttl = %d, want 60", got) |
| + } |
| + // TXT values must be quoted character-strings. |
| + if got := aws.ToString(rr.ResourceRecords[0].Value); got != `"tok-value"` { |
| + t.Fatalf("record value = %q, want quoted", got) |
| + } |
| + if fake.getChangeN == 0 { |
| + t.Fatal("present did not wait for the change to go INSYNC") |
| + } |
| + |
| + // cleanup DELETEs the exact same record set. |
| + if err := cleanup(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(fake.changes) != 2 { |
| + t.Fatalf("want 2 changes after cleanup, got %d", len(fake.changes)) |
| + } |
| + del := fake.changes[1].ChangeBatch.Changes[0] |
| + if del.Action != r53types.ChangeActionDelete { |
| + t.Fatalf("cleanup action = %v, want DELETE", del.Action) |
| + } |
| + if got := aws.ToString(del.ResourceRecordSet.Name); got != "_acme-challenge.11ch.at." { |
| + t.Fatalf("cleanup record name = %q", got) |
| + } |
| + if got := aws.ToString(del.ResourceRecordSet.ResourceRecords[0].Value); got != `"tok-value"` { |
| + t.Fatalf("cleanup record value = %q, want the created value", got) |
| + } |
| +} |
| + |
| +func TestRoute53HostedZoneMatch(t *testing.T) { |
| + // Both the parent and the delegated sub-zone exist: the record for the |
| + // sub-domain must resolve to the most specific (longest) match. |
| + fake := &fakeRoute53{hostedZones: []r53types.HostedZone{ |
| + {Name: aws.String("11ch.at."), Id: aws.String("/hostedzone/PARENT")}, |
| + {Name: aws.String("dev.11ch.at."), Id: aws.String("/hostedzone/DEV")}, |
| + }} |
| + r := &route53DNS{client: fake, zoneIDs: map[string]string{}} |
| + id, err := r.hostedZoneID(context.Background(), "dev.11ch.at") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if id != "DEV" { |
| + t.Fatalf("zone id = %q, want DEV (most specific)", id) |
| + } |
| + // Cached: a second lookup reuses the id (still correct after caching). |
| + if id2, _ := r.hostedZoneID(context.Background(), "dev.11ch.at"); id2 != "DEV" { |
| + t.Fatalf("cached zone id = %q, want DEV", id2) |
| + } |
| + |
| + // No authoritative zone for the name → a surfaced error, not a silent miss. |
| + none := &route53DNS{client: &fakeRoute53{hostedZones: []r53types.HostedZone{ |
| + {Name: aws.String("other.example."), Id: aws.String("/hostedzone/X")}, |
| + }}, zoneIDs: map[string]string{}} |
| + if _, err := none.hostedZoneID(context.Background(), "11ch.at"); err == nil { |
| + t.Fatal("want error when no hosted zone is authoritative") |
| + } |
| +} |
diff --git a/router.go b/router.go| index 490a684..a47ff3b 100644 |
| --- a/router.go |
| +++ b/router.go |
| @@ -224,7 +224,7 @@ func runRouter(args []string) { |
| 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)") |
| + "comma-separated base domains to serve a *.domain wildcard cert for, obtained via ACME DNS-01 (env: LCHAT_WILDCARD_DOMAINS; DNS-01 provider via LCHAT_DNS_PROVIDER — dnsimple (default, needs LCHAT_DNSIMPLE_TOKEN) or route53 (AWS credential chain))") |
| _ = fs.Parse(args) |
| reg, err := openRegistry(*registryPath) |
| @@ -321,10 +321,13 @@ func runRouter(args []string) { |
| // 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)") |
| + // DNSimple by default (LCHAT_DNSIMPLE_TOKEN), Route53 as the additive |
| + // off-ramp (LCHAT_DNS_PROVIDER=route53) — dnsProviderFromEnv decides. A |
| + // nil provider (missing token, bad selector) keeps per-host issuance. |
| + if dns, reason := dnsProviderFromEnv(); dns == nil { |
| + log.Printf("router: LCHAT_WILDCARD_DOMAINS set but no DNS-01 provider (%s) — wildcard certs stay off (per-host issuance continues)", reason) |
| } else { |
| - wc := newWildcardManager(doms, certCache, token) |
| + wc := newWildcardManager(doms, certCache, dns) |
| go wc.run() |
| inner := tlsCfg.GetCertificate |
| tlsCfg.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { |
diff --git a/wildcard.go b/wildcard.go| index 5e698aa..e69df85 100644 |
| --- a/wildcard.go |
| +++ b/wildcard.go |
| @@ -17,6 +17,7 @@ import ( |
| "log" |
| "net" |
| "net/http" |
| + "os" |
| "strings" |
| "sync/atomic" |
| "time" |
| @@ -37,11 +38,19 @@ import ( |
| // 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 |
| +// inside them until the wildcard is first obtained — so a DNS-provider 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. |
| +// The DNS-01 solver is PLUGGABLE (the dnsProvider seam): DNSimple stays the |
| +// default, fully-supported backend (LCHAT_DNSIMPLE_TOKEN), and Route53 |
| +// (route53dns.go) is an ADDITIVE off-ramp selected by LCHAT_DNS_PROVIDER=route53 |
| +// for boxes that would rather authenticate with an AWS IAM role than a token on |
| +// disk (docs/multi-edge.md). Selection lives in dnsProviderFromEnv; everything |
| +// below is provider-blind. |
| +// |
| +// Off unless LCHAT_WILDCARD_DOMAINS is set and a DNS provider is configured |
| +// (DNSimple by default, so LCHAT_DNSIMPLE_TOKEN). See |
| +// docs/instance-router.md § Wildcard TLS. |
| const letsEncryptDirectory = "https://acme-v02.api.letsencrypt.org/directory" |
| @@ -49,6 +58,54 @@ const letsEncryptDirectory = "https://acme-v02.api.letsencrypt.org/directory" |
| // days; renewing with 30 left tolerates a month of provider outage. |
| const wildcardRenewBefore = 30 * 24 * time.Hour |
| +// dnsProvider is the DNS-01 seam. It publishes the ACME challenge TXT and hands |
| +// back a closure that removes it — a closure so each backend keeps its own |
| +// teardown detail private (DNSimple deletes by the numeric record id it minted; |
| +// Route53 re-submits the record set). wildcardManager.solveDNS01 only ever calls |
| +// present, so it never learns which backend is in use. |
| +// |
| +// Implementations: dnsimpleDNS (the default, this file) and route53DNS |
| +// (route53dns.go, the additive off-ramp). Choose with dnsProviderFromEnv. |
| +type dnsProvider interface { |
| + // present creates a TXT record named name under zone (e.g. |
| + // "_acme-challenge" under "11ch.at") holding value, returning a cleanup that |
| + // deletes it. cleanup runs on its own context (see solveDNS01's defer) so it |
| + // still fires after the issuance context is dead. |
| + present(ctx context.Context, zone, name, value string) (cleanup func(context.Context) error, err error) |
| +} |
| + |
| +// dnsProviderFromEnv selects the DNS-01 backend, keeping DNSimple the DEFAULT |
| +// with its trigger unchanged: |
| +// |
| +// - unset / LCHAT_DNS_PROVIDER=dnsimple → DNSimple, exactly as before — needs |
| +// LCHAT_DNSIMPLE_TOKEN (an account API token); a missing token leaves the |
| +// wildcard off (per-host issuance continues), the historical behaviour. |
| +// - LCHAT_DNS_PROVIDER=route53 → Route53 (the multi-edge off-ramp), authed by |
| +// the standard AWS credential chain (env vars / EC2-or-container IAM role) — |
| +// no DNS token on disk. The dev boxes use their zone-scoped role for |
| +// *.dev.11ch.at (docs/multi-edge.md). |
| +// |
| +// Returns (nil, reason) when nothing is configured, so the caller logs the human |
| +// reason and falls back to per-host issuance rather than failing to boot. |
| +func dnsProviderFromEnv() (dnsProvider, string) { |
| + switch strings.ToLower(strings.TrimSpace(os.Getenv("LCHAT_DNS_PROVIDER"))) { |
| + case "route53": |
| + r53, err := newRoute53DNS(context.Background()) |
| + if err != nil { |
| + return nil, fmt.Sprintf("LCHAT_DNS_PROVIDER=route53 but AWS config load failed: %v", err) |
| + } |
| + return r53, "" |
| + case "", "dnsimple": |
| + token := os.Getenv("LCHAT_DNSIMPLE_TOKEN") |
| + if token == "" { |
| + return nil, "LCHAT_DNSIMPLE_TOKEN missing" |
| + } |
| + return newDNSimpleDNS(token), "" |
| + default: |
| + return nil, fmt.Sprintf("unknown LCHAT_DNS_PROVIDER %q (want dnsimple or route53)", os.Getenv("LCHAT_DNS_PROVIDER")) |
| + } |
| +} |
| + |
| type wildcardManager struct { |
| domains []string |
| // cache is the shared cert cache (certcache.go) — the same backend |
| @@ -56,20 +113,17 @@ type wildcardManager struct { |
| // too. Under the dir backend the keys land as the historical file names |
| // beside autocert's certs (wildcard-<domain>.pem, wildcard-account.key). |
| cache autocert.Cache |
| - dns *dnsimpleDNS |
| + dns dnsProvider |
| 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, cache autocert.Cache, dnsToken string) *wildcardManager { |
| +func newWildcardManager(domains []string, cache autocert.Cache, dns dnsProvider) *wildcardManager { |
| w := &wildcardManager{ |
| - domains: domains, |
| - cache: cache, |
| - dns: &dnsimpleDNS{ |
| - base: envOr("LCHAT_DNSIMPLE_BASE", "https://api.dnsimple.com"), |
| - token: dnsToken, |
| - }, |
| + domains: domains, |
| + cache: cache, |
| + dns: dns, |
| directory: envOr("LCHAT_ACME_DIRECTORY", letsEncryptDirectory), |
| certs: map[string]*atomic.Pointer[tls.Certificate]{}, |
| } |
| @@ -233,17 +287,17 @@ func (w *wildcardManager) solveDNS01(ctx context.Context, client *acme.Client, d |
| return err |
| } |
| - recID, err := w.dns.createTXT(ctx, domain, "_acme-challenge", txt) |
| + cleanup, err := w.dns.present(ctx, domain, "_acme-challenge", txt) |
| if err != nil { |
| - return fmt.Errorf("dnsimple: create TXT: %v", err) |
| + return fmt.Errorf("publish challenge 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) |
| + if err := cleanup(cctx); err != nil { |
| + log.Printf("router: wildcard *.%s: cleanup of challenge TXT failed: %v", domain, err) |
| } |
| }() |
| @@ -374,6 +428,28 @@ type dnsimpleDNS struct { |
| account string // discovered via /v2/whoami on first use |
| } |
| +// newDNSimpleDNS builds the default DNS-01 backend from an account API token, |
| +// honouring the LCHAT_DNSIMPLE_BASE override (kept here so all env reads for |
| +// this backend live in one place). |
| +func newDNSimpleDNS(token string) *dnsimpleDNS { |
| + return &dnsimpleDNS{ |
| + base: envOr("LCHAT_DNSIMPLE_BASE", "https://api.dnsimple.com"), |
| + token: token, |
| + } |
| +} |
| + |
| +// present satisfies dnsProvider: create the challenge TXT, then hand back a |
| +// closure that deletes it by the record id DNSimple returned. createTXT and |
| +// deleteRecord are unchanged, so the DNSimple wire behaviour is byte-for-byte |
| +// what it was before the seam existed. |
| +func (d *dnsimpleDNS) present(ctx context.Context, zone, name, value string) (func(context.Context) error, error) { |
| + id, err := d.createTXT(ctx, zone, name, value) |
| + if err != nil { |
| + return nil, fmt.Errorf("dnsimple: create TXT: %v", err) |
| + } |
| + return func(cctx context.Context) error { return d.deleteRecord(cctx, zone, id) }, nil |
| +} |
| + |
| func (d *dnsimpleDNS) do(ctx context.Context, method, path string, body, out any) error { |
| var rdr io.Reader |
| if body != nil { |
diff --git a/wildcard_test.go b/wildcard_test.go| index 2e70dea..73d47ef 100644 |
| --- a/wildcard_test.go |
| +++ b/wildcard_test.go |
| @@ -46,7 +46,7 @@ func selfSignedWildcard(t *testing.T, domain string, notAfter time.Time) (*tls.C |
| } |
| func TestCertForMatchesExactlyOneLabel(t *testing.T) { |
| - w := newWildcardManager([]string{"11ch.at"}, autocert.DirCache(t.TempDir()), "tok") |
| + 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 { |
| @@ -84,7 +84,7 @@ func TestCertForMatchesExactlyOneLabel(t *testing.T) { |
| } |
| func TestWrappedGetCertificateFallsThrough(t *testing.T) { |
| - w := newWildcardManager([]string{"11ch.at"}, autocert.DirCache(t.TempDir()), "tok") |
| + 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) |
| @@ -109,7 +109,7 @@ func TestWrappedGetCertificateFallsThrough(t *testing.T) { |
| func TestWildcardPEMRoundTripAndRenewDecision(t *testing.T) { |
| dir := t.TempDir() |
| cache := autocert.DirCache(dir) |
| - w := newWildcardManager([]string{"11ch.at"}, cache, "tok") |
| + w := newWildcardManager([]string{"11ch.at"}, cache, newDNSimpleDNS("tok")) |
| if !w.needsObtain("11ch.at", time.Now()) { |
| t.Fatal("no cert loaded but needsObtain is false") |
| @@ -131,7 +131,7 @@ func TestWildcardPEMRoundTripAndRenewDecision(t *testing.T) { |
| } |
| // A fresh manager picks the persisted cert up at startup (the restart path). |
| - w2 := newWildcardManager([]string{"11ch.at"}, cache, "tok") |
| + 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") |
| @@ -144,7 +144,7 @@ func TestWildcardPEMRoundTripAndRenewDecision(t *testing.T) { |
| 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, "tok") |
| + w3 := newWildcardManager([]string{"bad.example"}, cache, newDNSimpleDNS("tok")) |
| if w3.certs["bad.example"].Load() != nil { |
| t.Fatal("junk pem loaded as a cert") |
| } |
| @@ -155,7 +155,7 @@ func TestWildcardPEMRoundTripAndRenewDecision(t *testing.T) { |
| if err := os.WriteFile(filepath.Join(dir, "wildcard-old.example.pem"), raw, 0o600); err != nil { |
| t.Fatal(err) |
| } |
| - w4 := newWildcardManager([]string{"old.example"}, cache, "tok") |
| + 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") |
| } |
| @@ -166,11 +166,11 @@ func TestWildcardPEMRoundTripAndRenewDecision(t *testing.T) { |
| func TestWildcardAccountKeyStableAcrossManagers(t *testing.T) { |
| cache := autocert.DirCache(t.TempDir()) |
| ctx := context.Background() |
| - k1, err := newWildcardManager([]string{"11ch.at"}, cache, "tok").accountKey(ctx) |
| + k1, err := newWildcardManager([]string{"11ch.at"}, cache, newDNSimpleDNS("tok")).accountKey(ctx) |
| if err != nil { |
| t.Fatal(err) |
| } |
| - k2, err := newWildcardManager([]string{"11ch.at"}, cache, "tok").accountKey(ctx) |
| + k2, err := newWildcardManager([]string{"11ch.at"}, cache, newDNSimpleDNS("tok")).accountKey(ctx) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -228,6 +228,77 @@ func TestDNSimpleRecordsClient(t *testing.T) { |
| } |
| } |
| +// 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) |