| 1 | package 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 | |
| 16 | import ( |
| 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. |
| 35 | type 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. |
| 43 | var 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. |
| 52 | type 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. |
| 66 | func 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. |
| 86 | func 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 | |
| 98 | type 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). |
| 109 | func 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. |
| 120 | func (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). |
| 135 | func (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. |
| 152 | func (d *dnsimpleDNS) checkZone(ctx context.Context, fqdn string) error { |
| 153 | _, _, err := d.zoneFor(ctx, fqdn) |
| 154 | return err |
| 155 | } |
| 156 | |
| 157 | func (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). |
| 189 | var 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. |
| 197 | var errDNSimpleForbidden = errors.New("dnsimple: forbidden") |
| 198 | |
| 199 | func (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 | |
| 239 | func (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 | |
| 266 | func (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 | |
| 285 | func (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 | |