| new file mode 100644 |
| index 000000000..4a5c621ae |
| --- /dev/null |
| +++ b/internal/edge/dns.go |
| @@ -0,0 +1,257 @@ |
| +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 |
| +// (<acct>.<apps-domain>) 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) |
| +} |
| + |
| +// 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", fqdn) |
| +} |
| + |
| +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 { |
| + if errors.Is(err, errDNSimpleNotFound) { |
| + 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") |
| + |
| +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 < 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) |
| +} |
| + |
| +// newRoute53DNS is implemented in route53dns.go (Task 2). Stub so the |
| +// dnsimple-first commit compiles; selection tests skip route53 construction. |
| +func newRoute53DNS(ctx context.Context) (dnsProvider, error) { |
| + return nil, errors.New("route53 backend not built yet") |
| +} |