eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// πŸ€– route53DNS β€” the ADDITIVE Route53 DNS-01 backend (docs/multi-edge.md). It
4// is the off-ramp that lets a box obtain a *.dev.11ch.at (or any Route53-hosted)
5// wildcard cert with NO DNS token on disk: auth rides the standard AWS
6// credential chain (env vars or, on the dev boxes, the EC2/container IAM role
7// scoped to just that hosted zone). DNSimple stays the default, fully-supported
8// provider (wildcard.go); this NEVER replaces it β€” it is chosen only by an
9// explicit LCHAT_DNS_PROVIDER=route53 (see dnsProviderFromEnv).
10//
11// Same seam as DNSimple (the dnsProvider interface): publish a
12// _acme-challenge.<zone> TXT, wait for the change to go INSYNC across Route53's
13// servers, and hand back a closure that removes it. The AWS SDK setup mirrors
14// s3blobstore.go (config.LoadDefaultConfig + the default chain), so credentials
15// come from exactly one place fleet-wide.
16
17import (
18 "context"
19 "fmt"
20 "os"
21 "strconv"
22 "strings"
23 "sync"
24 "time"
25
26 "github.com/aws/aws-sdk-go-v2/aws"
27 awsconfig "github.com/aws/aws-sdk-go-v2/config"
28 "github.com/aws/aws-sdk-go-v2/service/route53"
29 r53types "github.com/aws/aws-sdk-go-v2/service/route53/types"
30)
31
32// route53InSyncTimeout bounds the wait for a ChangeResourceRecordSets to reach
33// INSYNC. Route53 usually syncs in well under a minute; on timeout we proceed
34// anyway β€” the CA runs its own retries and waitForTXT still gates public
35// visibility, exactly as the DNSimple path relies on (best-effort, never fatal).
36const route53InSyncTimeout = 2 * time.Minute
37
38// route53API is the slice of the Route53 client route53DNS calls β€” an interface
39// so tests can substitute a fake and never reach AWS.
40type route53API interface {
41 ListHostedZonesByName(ctx context.Context, in *route53.ListHostedZonesByNameInput, optFns ...func(*route53.Options)) (*route53.ListHostedZonesByNameOutput, error)
42 ChangeResourceRecordSets(ctx context.Context, in *route53.ChangeResourceRecordSetsInput, optFns ...func(*route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error)
43 GetChange(ctx context.Context, in *route53.GetChangeInput, optFns ...func(*route53.Options)) (*route53.GetChangeOutput, error)
44}
45
46type route53DNS struct {
47 client route53API
48 mu sync.Mutex
49 zoneIDs map[string]string // zone apex (no trailing dot) β†’ hosted-zone id
50}
51
52// newRoute53DNS builds the backend from the default AWS credential chain (env /
53// instance role), the same setup style as s3blobstore.go. Route53 is a global
54// service but the SDK still needs a signing region, so default to us-east-1 when
55// the environment names none β€” harmless, the endpoint is global either way, and
56// an explicit AWS_REGION / profile still wins.
57func newRoute53DNS(ctx context.Context) (*route53DNS, error) {
58 var opts []func(*awsconfig.LoadOptions) error
59 if strings.TrimSpace(os.Getenv("AWS_REGION")) == "" && strings.TrimSpace(os.Getenv("AWS_DEFAULT_REGION")) == "" {
60 opts = append(opts, awsconfig.WithRegion("us-east-1"))
61 }
62 cfg, err := awsconfig.LoadDefaultConfig(ctx, opts...)
63 if err != nil {
64 return nil, fmt.Errorf("route53: load aws config: %w", err)
65 }
66 return &route53DNS{client: route53.NewFromConfig(cfg), zoneIDs: map[string]string{}}, nil
67}
68
69// present satisfies dnsProvider: UPSERT the challenge TXT, wait for INSYNC, and
70// return a closure that deletes the exact record set it created.
71func (r *route53DNS) present(ctx context.Context, zone, name, value string) (func(context.Context) error, error) {
72 zoneID, err := r.hostedZoneID(ctx, zone)
73 if err != nil {
74 return nil, err
75 }
76 // Route53 stores a TXT value as a quoted character-string; the ACME token is
77 // a bare base64url string, so wrap it. UPSERT (not CREATE) so a TXT stranded
78 // by a crashed prior attempt is overwritten instead of colliding.
79 rrset := &r53types.ResourceRecordSet{
80 Name: aws.String(dnsName(name, zone)),
81 Type: r53types.RRTypeTxt,
82 TTL: aws.Int64(60),
83 ResourceRecords: []r53types.ResourceRecord{{Value: aws.String(strconv.Quote(value))}},
84 }
85 out, err := r.client.ChangeResourceRecordSets(ctx, &route53.ChangeResourceRecordSetsInput{
86 HostedZoneId: aws.String(zoneID),
87 ChangeBatch: &r53types.ChangeBatch{
88 Changes: []r53types.Change{{Action: r53types.ChangeActionUpsert, ResourceRecordSet: rrset}},
89 },
90 })
91 if err != nil {
92 return nil, fmt.Errorf("route53: upsert TXT: %w", err)
93 }
94 // Wait (best-effort) for the change to reach all Route53 servers before the
95 // caller tells the CA to look β€” the DNSimple path leans on waitForTXT for the
96 // same reason; here we also have Route53's own INSYNC signal.
97 if out.ChangeInfo != nil {
98 r.waitInSync(ctx, aws.ToString(out.ChangeInfo.Id))
99 }
100 cleanup := func(cctx context.Context) error {
101 // DELETE must resubmit the same record set that was created.
102 _, err := r.client.ChangeResourceRecordSets(cctx, &route53.ChangeResourceRecordSetsInput{
103 HostedZoneId: aws.String(zoneID),
104 ChangeBatch: &r53types.ChangeBatch{
105 Changes: []r53types.Change{{Action: r53types.ChangeActionDelete, ResourceRecordSet: rrset}},
106 },
107 })
108 return err
109 }
110 return cleanup, nil
111}
112
113// upsertA satisfies aRecordUpserter (relocate.go): point host's A record at
114// ipv4. It is the OPTIONAL, off-by-default half of relocation β€” moving the A
115// record to the destination box avoids the mesh's proxy hop, but relocation is
116// correct without it (the *.dev wildcard + proxy-to-owner already route any
117// request to the new owner; docs/multi-edge.md). UPSERT so a re-run is
118// idempotent (overwrites in place, never a duplicate-record error), TTL 60 so
119// the propagation window the proxy covers stays short, and we wait for INSYNC
120// like present so the caller can trust the record is live before returning.
121func (r *route53DNS) upsertA(ctx context.Context, zone, host, ipv4 string) error {
122 zoneID, err := r.hostedZoneID(ctx, zone)
123 if err != nil {
124 return err
125 }
126 rrset := &r53types.ResourceRecordSet{
127 Name: aws.String(dnsFQDN(host)),
128 Type: r53types.RRTypeA,
129 TTL: aws.Int64(60),
130 ResourceRecords: []r53types.ResourceRecord{{Value: aws.String(ipv4)}},
131 }
132 out, err := r.client.ChangeResourceRecordSets(ctx, &route53.ChangeResourceRecordSetsInput{
133 HostedZoneId: aws.String(zoneID),
134 ChangeBatch: &r53types.ChangeBatch{
135 Changes: []r53types.Change{{Action: r53types.ChangeActionUpsert, ResourceRecordSet: rrset}},
136 },
137 })
138 if err != nil {
139 return fmt.Errorf("route53: upsert A %s β†’ %s: %w", host, ipv4, err)
140 }
141 if out.ChangeInfo != nil {
142 r.waitInSync(ctx, aws.ToString(out.ChangeInfo.Id))
143 }
144 return nil
145}
146
147// hostedZoneID resolves a zone to its Route53 hosted-zone id, caching the
148// lookup. It expects the wildcard domain to have its OWN hosted zone (which is
149// how the box's IAM role is scoped β€” one zone, e.g. dev.11ch.at), so it takes
150// the most specific returned zone whose name is the zone itself or a parent of
151// it. Ids come back as "/hostedzone/Z123"; the change API accepts either form
152// but we store the bare id.
153func (r *route53DNS) hostedZoneID(ctx context.Context, zone string) (string, error) {
154 r.mu.Lock()
155 if id, ok := r.zoneIDs[zone]; ok {
156 r.mu.Unlock()
157 return id, nil
158 }
159 r.mu.Unlock()
160
161 want := dnsFQDN(zone)
162 out, err := r.client.ListHostedZonesByName(ctx, &route53.ListHostedZonesByNameInput{DNSName: aws.String(want)})
163 if err != nil {
164 return "", fmt.Errorf("route53: list hosted zones: %w", err)
165 }
166 var bestName, bestID string
167 for _, hz := range out.HostedZones {
168 zn := strings.ToLower(aws.ToString(hz.Name))
169 // The zone must be authoritative for `want`: equal to it or a parent
170 // suffix (".example.com" covers "sub.example.com"). Keep the longest β€”
171 // the most specific β€” match.
172 if zn == want || strings.HasSuffix(want, "."+zn) {
173 if len(zn) > len(bestName) {
174 bestName, bestID = zn, strings.TrimPrefix(aws.ToString(hz.Id), "/hostedzone/")
175 }
176 }
177 }
178 if bestID == "" {
179 return "", fmt.Errorf("route53: no hosted zone for %s", zone)
180 }
181 r.mu.Lock()
182 r.zoneIDs[zone] = bestID
183 r.mu.Unlock()
184 return bestID, nil
185}
186
187// waitInSync polls GetChange until the change is INSYNC or the timeout passes.
188// Best-effort, mirroring waitForTXT: a timeout is not fatal (the CA retries).
189func (r *route53DNS) waitInSync(ctx context.Context, changeID string) {
190 if changeID == "" {
191 return
192 }
193 deadline := time.Now().Add(route53InSyncTimeout)
194 for time.Now().Before(deadline) && ctx.Err() == nil {
195 out, err := r.client.GetChange(ctx, &route53.GetChangeInput{Id: aws.String(changeID)})
196 if err == nil && out.ChangeInfo != nil && out.ChangeInfo.Status == r53types.ChangeStatusInsync {
197 return
198 }
199 time.Sleep(3 * time.Second)
200 }
201}
202
203// dnsFQDN appends the root dot Route53 stores names with.
204func dnsFQDN(zone string) string {
205 zone = strings.ToLower(zone)
206 if strings.HasSuffix(zone, ".") {
207 return zone
208 }
209 return zone + "."
210}
211
212// dnsName builds the fully-qualified record name Route53 wants, e.g.
213// ("_acme-challenge", "11ch.at") β†’ "_acme-challenge.11ch.at."
214func dnsName(name, zone string) string {
215 return name + "." + dnsFQDN(zone)
216}
217