carlos / platform Public

edge: dnsProvider seam + DNSimple DNS-01 backend

Ported from Eleven's wildcard.go with one deliberate change: present()
takes the full record FQDN and the backend discovers the owning zone
(per-account wildcard parents are not zones).
Paul Campbell 78fa1e31818c3e0e51a8782faa68ac06b67527d3 parent 3fc029a
4 files changed, +391 −0
  • go.mod +1 −0
  • go.sum +2 −0
  • internal/edge/dns.go +257 −0
  • internal/edge/dns_test.go +131 −0
diff --git a/go.mod b/go.mod
index b1bfa10f6..1997f618d 100644
--- a/go.mod
+++ b/go.mod
@@ -26,6 +26,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 // indirect
+ github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
diff --git a/go.sum b/go.sum
index 0d5615ed3..5381a37b1 100644
--- a/go.sum
+++ b/go.sum
@@ -26,6 +26,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrK
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 h1:uao4A3QZ5UmB326V6KF+qRpv9Tjz7IlnlnTbbANntlU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31/go.mod h1:I/1+z0VwL1GhQyLgkoHDlygpUZ+iTAwOQ/NsftiUL2I=
+github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1 h1:qL5ELChQ1o1ZbzHvsl8gzQfAZdbQtrDbUyzJkjkMKcg=
+github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1/go.mod h1:0hIRXFez1bZsDFMGkLZvNJbByTSVZ4sFZWpxZ39NPuM=
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0 h1:XptwLL+UHXgafYMIHTy59IRovLbhz3znkxY2uS/pbXU=
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0/go.mod h1:zdmCoFO/dSI7GlrwsPqFJI+WlFnSU4Tc8TJnlXrM1Do=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=
diff --git a/internal/edge/dns.go b/internal/edge/dns.go
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")
+}
diff --git a/internal/edge/dns_test.go b/internal/edge/dns_test.go
new file mode 100644
index 000000000..13b1b0b31
--- /dev/null
+++ b/internal/edge/dns_test.go
@@ -0,0 +1,131 @@
+package edge
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "testing"
+)
+
+func TestSplitDomains(t *testing.T) {
+ cases := []struct {
+ in string
+ want []string
+ }{
+ {"", nil},
+ {"go.tito.io", []string{"go.tito.io"}},
+ {" Go.Tito.IO , dev.oncarlos.com ,", []string{"go.tito.io", "dev.oncarlos.com"}},
+ }
+ for _, c := range cases {
+ if got := splitDomains(c.in); !reflect.DeepEqual(got, c.want) {
+ t.Errorf("splitDomains(%q) = %v, want %v", c.in, got, c.want)
+ }
+ }
+}
+
+func TestDNSProviderSelection(t *testing.T) {
+ t.Setenv("CARLOS_DNS_PROVIDER", "")
+ t.Setenv("CARLOS_DNSIMPLE_TOKEN", "")
+ if p, reason := dnsProviderFromEnv(); p != nil || reason == "" {
+ t.Fatalf("unset env: want (nil, reason), got (%v, %q)", p, reason)
+ }
+ t.Setenv("CARLOS_DNSIMPLE_TOKEN", "tok")
+ if p, reason := dnsProviderFromEnv(); p == nil {
+ t.Fatalf("token set: want dnsimple provider, got nil (%s)", reason)
+ } else if _, ok := p.(*dnsimpleDNS); !ok {
+ t.Fatalf("token set: want *dnsimpleDNS, got %T", p)
+ }
+ t.Setenv("CARLOS_DNS_PROVIDER", "bogus")
+ if p, reason := dnsProviderFromEnv(); p != nil || reason == "" {
+ t.Fatalf("bogus provider: want (nil, reason), got (%v, %q)", p, reason)
+ }
+ // route53 selection is exercised in route53dns_test.go — constructing it
+ // here would hit the AWS credential chain.
+}
+
+// fakeDNSimple is a minimal DNSimple v2 API: whoami, per-zone existence,
+// record create/delete. It records mutations for assertions.
+type fakeDNSimple struct {
+ zones map[string]bool // zone name → exists
+ created []string // "zone|name|content"
+ deleted []int64
+ nextID int64
+}
+
+func (f *fakeDNSimple) handler(t *testing.T) http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /v2/whoami", func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Authorization") != "Bearer tok" {
+ t.Errorf("whoami auth header = %q", r.Header.Get("Authorization"))
+ }
+ fmt.Fprint(w, `{"data":{"account":{"id":42}}}`)
+ })
+ mux.HandleFunc("GET /v2/42/zones/{zone}", func(w http.ResponseWriter, r *http.Request) {
+ if !f.zones[r.PathValue("zone")] {
+ http.Error(w, `{"message":"not found"}`, 404)
+ return
+ }
+ fmt.Fprintf(w, `{"data":{"name":%q}}`, r.PathValue("zone"))
+ })
+ mux.HandleFunc("POST /v2/42/zones/{zone}/records", func(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Name, Type, Content string
+ TTL int
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ t.Errorf("create record body: %v", err)
+ }
+ f.nextID++
+ f.created = append(f.created, fmt.Sprintf("%s|%s|%s", r.PathValue("zone"), body.Name, body.Content))
+ w.WriteHeader(201)
+ fmt.Fprintf(w, `{"data":{"id":%d}}`, f.nextID)
+ })
+ mux.HandleFunc("DELETE /v2/42/zones/{zone}/records/{id}", func(w http.ResponseWriter, r *http.Request) {
+ var id int64
+ fmt.Sscan(r.PathValue("id"), &id)
+ f.deleted = append(f.deleted, id)
+ w.WriteHeader(204)
+ })
+ return mux
+}
+
+// The per-account adaptation: the zone is discovered by suffix-walking fqdn
+// against the API, so a challenge under abs.oncarlos.com lands in the
+// oncarlos.com zone with the account label in the record NAME.
+func TestDNSimplePresentDiscoversZone(t *testing.T) {
+ fake := &fakeDNSimple{zones: map[string]bool{"oncarlos.com": true}}
+ srv := httptest.NewServer(fake.handler(t))
+ defer srv.Close()
+ t.Setenv("CARLOS_DNSIMPLE_BASE", srv.URL)
+
+ d := newDNSimpleDNS("tok")
+ cleanup, err := d.present(context.Background(), "_acme-challenge.abs.oncarlos.com", "txtvalue")
+ if err != nil {
+ t.Fatalf("present: %v", err)
+ }
+ want := []string{"oncarlos.com|_acme-challenge.abs|txtvalue"}
+ if !reflect.DeepEqual(fake.created, want) {
+ t.Fatalf("created = %v, want %v", fake.created, want)
+ }
+ if err := cleanup(context.Background()); err != nil {
+ t.Fatalf("cleanup: %v", err)
+ }
+ if !reflect.DeepEqual(fake.deleted, []int64{1}) {
+ t.Fatalf("deleted = %v, want [1]", fake.deleted)
+ }
+}
+
+func TestDNSimplePresentNoZone(t *testing.T) {
+ fake := &fakeDNSimple{zones: map[string]bool{}}
+ srv := httptest.NewServer(fake.handler(t))
+ defer srv.Close()
+ t.Setenv("CARLOS_DNSIMPLE_BASE", srv.URL)
+
+ d := newDNSimpleDNS("tok")
+ if _, err := d.present(context.Background(), "_acme-challenge.abs.nowhere.example", "v"); err == nil {
+ t.Fatal("present with no owning zone: want error, got nil")
+ }
+}