eleven / messenger Public

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>
Paul Campbell 1f19276dbf2ed6df629fffb914c44405cdae258e parent 02645fb
8 files changed, +511 −40
  • docs/instance-router.md +27 −9
  • go.mod +3 −2
  • go.sum +2 −2
  • route53dns.go +182 −0
  • route53dns_test.go +120 −0
  • router.go +7 −4
  • wildcard.go +91 −15
  • wildcard_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)