package edge // The DNS-01 provider seam for wildcard certs (wildcard.go) — ported from // Eleven's wildcard.go/dnsProvider with one deliberate change: present takes // the full record FQDN and the backend discovers the owning zone itself. // Eleven's parents were always zones (11ch.at); CARLOS's per-account parents // (.) are not, so the split into (zone, record name) is // the backend's job — DNSimple suffix-walks the fqdn against the zones API, // Route53 suffix-matches ListHostedZonesByName (route53dns.go). // // Backends: dnsimpleDNS (default — oncarlos.com's registrar; needs // CARLOS_DNSIMPLE_TOKEN) and route53DNS (CARLOS_DNS_PROVIDER=route53 — the // tito.io path; standard AWS credential chain, no token on disk). Selection // lives in dnsProviderFromEnv; everything in wildcard.go is provider-blind. import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "os" "strings" "sync" "time" ) // dnsProvider publishes the ACME DNS-01 challenge TXT and hands back a // closure that removes it — a closure so each backend keeps its teardown // detail private (DNSimple deletes by the numeric record id it minted; // Route53 re-submits the record set). cleanup runs on its own context (see // solveDNS01's defer) so it still fires after the issuance context is dead. type dnsProvider interface { present(ctx context.Context, fqdn, value string) (cleanup func(context.Context) error, err error) } // errNoZone is a provider's answer that none of the zones it can write // holds a name. It is a configuration fact, not a transient one: the zone is // in another account or another provider, and asking again in an hour will // get the same answer. var errNoZone = errors.New("no zone for this name in the configured DNS provider") // zoneChecker is implemented by a provider that can say, writing nothing, // whether present could publish a record at fqdn. The wildcard manager asks // before it places an ACME order, so a parent the provider cannot answer for // costs a zone lookup rather than an order the CA will never see completed. // // Optional on purpose: a provider without it (a test fake) behaves as // before, and present's own error remains the backstop. type zoneChecker interface { checkZone(ctx context.Context, fqdn string) error } // dnsProviderFromEnv selects the DNS-01 backend: // // - unset / CARLOS_DNS_PROVIDER=dnsimple → DNSimple; needs // CARLOS_DNSIMPLE_TOKEN (an account API token). A missing token leaves // the wildcard off (per-host issuance continues) — the zero-config state. // - CARLOS_DNS_PROVIDER=route53 → Route53, authed by the standard AWS // credential chain (env vars / instance IAM role) — no DNS token on disk. // // Returns (nil, reason) when nothing is configured, so the caller logs the // human reason and falls back to per-host issuance rather than failing boot. func dnsProviderFromEnv() (dnsProvider, string) { switch strings.ToLower(strings.TrimSpace(os.Getenv("CARLOS_DNS_PROVIDER"))) { case "route53": r53, err := newRoute53DNS(context.Background()) if err != nil { return nil, fmt.Sprintf("CARLOS_DNS_PROVIDER=route53 but AWS config load failed: %v", err) } return r53, "" case "", "dnsimple": token := os.Getenv("CARLOS_DNSIMPLE_TOKEN") if token == "" { return nil, "CARLOS_DNSIMPLE_TOKEN missing" } return newDNSimpleDNS(token), "" default: return nil, fmt.Sprintf("unknown CARLOS_DNS_PROVIDER %q (want dnsimple or route53)", os.Getenv("CARLOS_DNS_PROVIDER")) } } // splitDomains parses the CARLOS_WILDCARD_DOMAINS comma list. func splitDomains(s string) []string { var out []string for _, d := range strings.Split(s, ",") { if d = strings.ToLower(strings.TrimSpace(d)); d != "" { out = append(out, d) } } return out } // ---- DNSimple zone records -------------------------------------------------- type dnsimpleDNS struct { base string token string mu sync.Mutex account string // discovered via /v2/whoami on first use zones map[string]bool // zone name → known-to-exist (positive cache only) } // newDNSimpleDNS builds the default DNS-01 backend from an account API token, // honouring the CARLOS_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("CARLOS_DNSIMPLE_BASE", "https://api.dnsimple.com"), token: token, zones: map[string]bool{}, } } // present satisfies dnsProvider: discover the zone owning fqdn, create the // challenge TXT inside it, and hand back a closure that deletes it by the // record id DNSimple returned. func (d *dnsimpleDNS) present(ctx context.Context, fqdn, value string) (func(context.Context) error, error) { zone, name, err := d.zoneFor(ctx, fqdn) if err != nil { return nil, err } 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 } // zoneFor walks fqdn's parent suffixes longest-first until the zones API // knows one, returning (zone, record name within it). Positive results are // cached; misses are not (a zone might be added later). func (d *dnsimpleDNS) zoneFor(ctx context.Context, fqdn string) (zone, name string, err error) { fqdn = strings.ToLower(strings.TrimSuffix(fqdn, ".")) labels := strings.Split(fqdn, ".") for i := 1; i <= len(labels)-2; i++ { // a zone has at least two labels cand := strings.Join(labels[i:], ".") ok, err := d.zoneExists(ctx, cand) if err != nil { return "", "", err } if ok { return cand, strings.Join(labels[:i], "."), nil } } return "", "", fmt.Errorf("dnsimple: no zone found for %s: %w", fqdn, errNoZone) } // checkZone satisfies zoneChecker with the same suffix walk present uses. func (d *dnsimpleDNS) checkZone(ctx context.Context, fqdn string) error { _, _, err := d.zoneFor(ctx, fqdn) return err } func (d *dnsimpleDNS) zoneExists(ctx context.Context, zone string) (bool, error) { d.mu.Lock() known := d.zones[zone] d.mu.Unlock() if known { return true, nil } acct, err := d.accountID(ctx) if err != nil { return false, err } err = d.do(ctx, "GET", "/v2/"+acct+"/zones/"+zone, nil, nil) if err != nil { // 404 (zone genuinely absent) and 403 (token's account doesn't own // this candidate zone — DNSimple returns Forbidden, not NotFound, for // zones outside the authenticated account) both mean "not this zone, // keep walking". A 401 (bad token) is neither and falls through to // the generic error below, so it aborts loudly instead of reading as // "no zone found". if errors.Is(err, errDNSimpleNotFound) || errors.Is(err, errDNSimpleForbidden) { return false, nil } return false, err } d.mu.Lock() d.zones[zone] = true d.mu.Unlock() return true, nil } // errDNSimpleNotFound distinguishes a 404 (zone genuinely absent — keep // walking suffixes) from transport or auth failures (stop and report). var errDNSimpleNotFound = errors.New("dnsimple: not found") // errDNSimpleForbidden distinguishes a 403 (zone exists but isn't owned by // this account's token — DNSimple's zones API answers Forbidden rather than // NotFound for zones outside the account) from other failures. At the // zoneExists call site it is treated the same as errDNSimpleNotFound: keep // walking suffixes. A 401 (bad token) is deliberately NOT folded into this — // it must abort the walk with a clear diagnostic. var errDNSimpleForbidden = errors.New("dnsimple: forbidden") func (d *dnsimpleDNS) do(ctx context.Context, method, path string, body, out any) error { var rdr io.Reader if body != nil { raw, err := json.Marshal(body) if err != nil { return err } rdr = bytes.NewReader(raw) } req, err := http.NewRequestWithContext(ctx, method, d.base+path, rdr) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+d.token) req.Header.Set("Accept", "application/json") if body != nil { req.Header.Set("Content-Type", "application/json") } client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return fmt.Errorf("%s %s: %w", method, path, errDNSimpleNotFound) } if resp.StatusCode == http.StatusForbidden { return fmt.Errorf("%s %s: %w", method, path, errDNSimpleForbidden) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) return fmt.Errorf("%s %s: %s: %s", method, path, resp.Status, strings.TrimSpace(string(msg))) } if out != nil { return json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(out) } return nil } func (d *dnsimpleDNS) accountID(ctx context.Context) (string, error) { d.mu.Lock() acct := d.account d.mu.Unlock() if acct != "" { return acct, nil } var who struct { Data struct { Account *struct { ID int64 `json:"id"` } `json:"account"` } `json:"data"` } if err := d.do(ctx, "GET", "/v2/whoami", nil, &who); err != nil { return "", err } if who.Data.Account == nil { return "", errors.New("whoami: token is not an account token") } d.mu.Lock() d.account = fmt.Sprint(who.Data.Account.ID) acct = d.account d.mu.Unlock() return acct, nil } func (d *dnsimpleDNS) createTXT(ctx context.Context, zone, name, content string) (int64, error) { acct, err := d.accountID(ctx) if err != nil { return 0, err } var created struct { Data struct { ID int64 `json:"id"` } `json:"data"` } err = d.do(ctx, "POST", "/v2/"+acct+"/zones/"+zone+"/records", map[string]any{ "name": name, "type": "TXT", "content": content, "ttl": 60, }, &created) if err != nil { return 0, err } return created.Data.ID, nil } func (d *dnsimpleDNS) deleteRecord(ctx context.Context, zone string, id int64) error { acct, err := d.accountID(ctx) if err != nil { return err } return d.do(ctx, "DELETE", fmt.Sprintf("/v2/%s/zones/%s/records/%d", acct, zone, id), nil, nil) }