carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package edge
2
3// The DNS-01 provider seam for wildcard certs (wildcard.go) — ported from
4// Eleven's wildcard.go/dnsProvider with one deliberate change: present takes
5// the full record FQDN and the backend discovers the owning zone itself.
6// Eleven's parents were always zones (11ch.at); CARLOS's per-account parents
7// (<acct>.<apps-domain>) are not, so the split into (zone, record name) is
8// the backend's job — DNSimple suffix-walks the fqdn against the zones API,
9// Route53 suffix-matches ListHostedZonesByName (route53dns.go).
10//
11// Backends: dnsimpleDNS (default — oncarlos.com's registrar; needs
12// CARLOS_DNSIMPLE_TOKEN) and route53DNS (CARLOS_DNS_PROVIDER=route53 — the
13// tito.io path; standard AWS credential chain, no token on disk). Selection
14// lives in dnsProviderFromEnv; everything in wildcard.go is provider-blind.
15
16import (
17 "bytes"
18 "context"
19 "encoding/json"
20 "errors"
21 "fmt"
22 "io"
23 "net/http"
24 "os"
25 "strings"
26 "sync"
27 "time"
28)
29
30// dnsProvider publishes the ACME DNS-01 challenge TXT and hands back a
31// closure that removes it — a closure so each backend keeps its teardown
32// detail private (DNSimple deletes by the numeric record id it minted;
33// Route53 re-submits the record set). cleanup runs on its own context (see
34// solveDNS01's defer) so it still fires after the issuance context is dead.
35type dnsProvider interface {
36 present(ctx context.Context, fqdn, value string) (cleanup func(context.Context) error, err error)
37}
38
39// errNoZone is a provider's answer that none of the zones it can write
40// holds a name. It is a configuration fact, not a transient one: the zone is
41// in another account or another provider, and asking again in an hour will
42// get the same answer.
43var errNoZone = errors.New("no zone for this name in the configured DNS provider")
44
45// zoneChecker is implemented by a provider that can say, writing nothing,
46// whether present could publish a record at fqdn. The wildcard manager asks
47// before it places an ACME order, so a parent the provider cannot answer for
48// costs a zone lookup rather than an order the CA will never see completed.
49//
50// Optional on purpose: a provider without it (a test fake) behaves as
51// before, and present's own error remains the backstop.
52type zoneChecker interface {
53 checkZone(ctx context.Context, fqdn string) error
54}
55
56// dnsProviderFromEnv selects the DNS-01 backend:
57//
58// - unset / CARLOS_DNS_PROVIDER=dnsimple → DNSimple; needs
59// CARLOS_DNSIMPLE_TOKEN (an account API token). A missing token leaves
60// the wildcard off (per-host issuance continues) — the zero-config state.
61// - CARLOS_DNS_PROVIDER=route53 → Route53, authed by the standard AWS
62// credential chain (env vars / instance IAM role) — no DNS token on disk.
63//
64// Returns (nil, reason) when nothing is configured, so the caller logs the
65// human reason and falls back to per-host issuance rather than failing boot.
66func dnsProviderFromEnv() (dnsProvider, string) {
67 switch strings.ToLower(strings.TrimSpace(os.Getenv("CARLOS_DNS_PROVIDER"))) {
68 case "route53":
69 r53, err := newRoute53DNS(context.Background())
70 if err != nil {
71 return nil, fmt.Sprintf("CARLOS_DNS_PROVIDER=route53 but AWS config load failed: %v", err)
72 }
73 return r53, ""
74 case "", "dnsimple":
75 token := os.Getenv("CARLOS_DNSIMPLE_TOKEN")
76 if token == "" {
77 return nil, "CARLOS_DNSIMPLE_TOKEN missing"
78 }
79 return newDNSimpleDNS(token), ""
80 default:
81 return nil, fmt.Sprintf("unknown CARLOS_DNS_PROVIDER %q (want dnsimple or route53)", os.Getenv("CARLOS_DNS_PROVIDER"))
82 }
83}
84
85// splitDomains parses the CARLOS_WILDCARD_DOMAINS comma list.
86func splitDomains(s string) []string {
87 var out []string
88 for _, d := range strings.Split(s, ",") {
89 if d = strings.ToLower(strings.TrimSpace(d)); d != "" {
90 out = append(out, d)
91 }
92 }
93 return out
94}
95
96// ---- DNSimple zone records --------------------------------------------------
97
98type dnsimpleDNS struct {
99 base string
100 token string
101 mu sync.Mutex
102 account string // discovered via /v2/whoami on first use
103 zones map[string]bool // zone name → known-to-exist (positive cache only)
104}
105
106// newDNSimpleDNS builds the default DNS-01 backend from an account API token,
107// honouring the CARLOS_DNSIMPLE_BASE override (kept here so all env reads for
108// this backend live in one place).
109func newDNSimpleDNS(token string) *dnsimpleDNS {
110 return &dnsimpleDNS{
111 base: envOr("CARLOS_DNSIMPLE_BASE", "https://api.dnsimple.com"),
112 token: token,
113 zones: map[string]bool{},
114 }
115}
116
117// present satisfies dnsProvider: discover the zone owning fqdn, create the
118// challenge TXT inside it, and hand back a closure that deletes it by the
119// record id DNSimple returned.
120func (d *dnsimpleDNS) present(ctx context.Context, fqdn, value string) (func(context.Context) error, error) {
121 zone, name, err := d.zoneFor(ctx, fqdn)
122 if err != nil {
123 return nil, err
124 }
125 id, err := d.createTXT(ctx, zone, name, value)
126 if err != nil {
127 return nil, fmt.Errorf("dnsimple: create TXT: %v", err)
128 }
129 return func(cctx context.Context) error { return d.deleteRecord(cctx, zone, id) }, nil
130}
131
132// zoneFor walks fqdn's parent suffixes longest-first until the zones API
133// knows one, returning (zone, record name within it). Positive results are
134// cached; misses are not (a zone might be added later).
135func (d *dnsimpleDNS) zoneFor(ctx context.Context, fqdn string) (zone, name string, err error) {
136 fqdn = strings.ToLower(strings.TrimSuffix(fqdn, "."))
137 labels := strings.Split(fqdn, ".")
138 for i := 1; i <= len(labels)-2; i++ { // a zone has at least two labels
139 cand := strings.Join(labels[i:], ".")
140 ok, err := d.zoneExists(ctx, cand)
141 if err != nil {
142 return "", "", err
143 }
144 if ok {
145 return cand, strings.Join(labels[:i], "."), nil
146 }
147 }
148 return "", "", fmt.Errorf("dnsimple: no zone found for %s: %w", fqdn, errNoZone)
149}
150
151// checkZone satisfies zoneChecker with the same suffix walk present uses.
152func (d *dnsimpleDNS) checkZone(ctx context.Context, fqdn string) error {
153 _, _, err := d.zoneFor(ctx, fqdn)
154 return err
155}
156
157func (d *dnsimpleDNS) zoneExists(ctx context.Context, zone string) (bool, error) {
158 d.mu.Lock()
159 known := d.zones[zone]
160 d.mu.Unlock()
161 if known {
162 return true, nil
163 }
164 acct, err := d.accountID(ctx)
165 if err != nil {
166 return false, err
167 }
168 err = d.do(ctx, "GET", "/v2/"+acct+"/zones/"+zone, nil, nil)
169 if err != nil {
170 // 404 (zone genuinely absent) and 403 (token's account doesn't own
171 // this candidate zone — DNSimple returns Forbidden, not NotFound, for
172 // zones outside the authenticated account) both mean "not this zone,
173 // keep walking". A 401 (bad token) is neither and falls through to
174 // the generic error below, so it aborts loudly instead of reading as
175 // "no zone found".
176 if errors.Is(err, errDNSimpleNotFound) || errors.Is(err, errDNSimpleForbidden) {
177 return false, nil
178 }
179 return false, err
180 }
181 d.mu.Lock()
182 d.zones[zone] = true
183 d.mu.Unlock()
184 return true, nil
185}
186
187// errDNSimpleNotFound distinguishes a 404 (zone genuinely absent — keep
188// walking suffixes) from transport or auth failures (stop and report).
189var errDNSimpleNotFound = errors.New("dnsimple: not found")
190
191// errDNSimpleForbidden distinguishes a 403 (zone exists but isn't owned by
192// this account's token — DNSimple's zones API answers Forbidden rather than
193// NotFound for zones outside the account) from other failures. At the
194// zoneExists call site it is treated the same as errDNSimpleNotFound: keep
195// walking suffixes. A 401 (bad token) is deliberately NOT folded into this —
196// it must abort the walk with a clear diagnostic.
197var errDNSimpleForbidden = errors.New("dnsimple: forbidden")
198
199func (d *dnsimpleDNS) do(ctx context.Context, method, path string, body, out any) error {
200 var rdr io.Reader
201 if body != nil {
202 raw, err := json.Marshal(body)
203 if err != nil {
204 return err
205 }
206 rdr = bytes.NewReader(raw)
207 }
208 req, err := http.NewRequestWithContext(ctx, method, d.base+path, rdr)
209 if err != nil {
210 return err
211 }
212 req.Header.Set("Authorization", "Bearer "+d.token)
213 req.Header.Set("Accept", "application/json")
214 if body != nil {
215 req.Header.Set("Content-Type", "application/json")
216 }
217 client := &http.Client{Timeout: 30 * time.Second}
218 resp, err := client.Do(req)
219 if err != nil {
220 return err
221 }
222 defer resp.Body.Close()
223 if resp.StatusCode == http.StatusNotFound {
224 return fmt.Errorf("%s %s: %w", method, path, errDNSimpleNotFound)
225 }
226 if resp.StatusCode == http.StatusForbidden {
227 return fmt.Errorf("%s %s: %w", method, path, errDNSimpleForbidden)
228 }
229 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
230 msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
231 return fmt.Errorf("%s %s: %s: %s", method, path, resp.Status, strings.TrimSpace(string(msg)))
232 }
233 if out != nil {
234 return json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(out)
235 }
236 return nil
237}
238
239func (d *dnsimpleDNS) accountID(ctx context.Context) (string, error) {
240 d.mu.Lock()
241 acct := d.account
242 d.mu.Unlock()
243 if acct != "" {
244 return acct, nil
245 }
246 var who struct {
247 Data struct {
248 Account *struct {
249 ID int64 `json:"id"`
250 } `json:"account"`
251 } `json:"data"`
252 }
253 if err := d.do(ctx, "GET", "/v2/whoami", nil, &who); err != nil {
254 return "", err
255 }
256 if who.Data.Account == nil {
257 return "", errors.New("whoami: token is not an account token")
258 }
259 d.mu.Lock()
260 d.account = fmt.Sprint(who.Data.Account.ID)
261 acct = d.account
262 d.mu.Unlock()
263 return acct, nil
264}
265
266func (d *dnsimpleDNS) createTXT(ctx context.Context, zone, name, content string) (int64, error) {
267 acct, err := d.accountID(ctx)
268 if err != nil {
269 return 0, err
270 }
271 var created struct {
272 Data struct {
273 ID int64 `json:"id"`
274 } `json:"data"`
275 }
276 err = d.do(ctx, "POST", "/v2/"+acct+"/zones/"+zone+"/records", map[string]any{
277 "name": name, "type": "TXT", "content": content, "ttl": 60,
278 }, &created)
279 if err != nil {
280 return 0, err
281 }
282 return created.Data.ID, nil
283}
284
285func (d *dnsimpleDNS) deleteRecord(ctx context.Context, zone string, id int64) error {
286 acct, err := d.accountID(ctx)
287 if err != nil {
288 return err
289 }
290 return d.do(ctx, "DELETE", fmt.Sprintf("/v2/%s/zones/%s/records/%d", acct, zone, id), nil, nil)
291}
292