Merge pull request #168 from carlosframework/edge-delegated-wildcards
edge: delegated-challenge wildcard certs (customer domains, zero customer DNS knowledge)
4 files changed,
+250
−37
internal/edge/edge.go+2 −2internal/edge/wildcard.go+111 −24internal/edge/wildcard_test.go+134 −11internal/host/agent.go+3 −0
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index 219e8697e..16cbeaae6 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -1333,8 +1333,8 @@ func RunWithActivatorReady(args []string, act Activator, cache *Cache, ready fun |
| e.OnReload(w.refresh) |
| w.refresh() |
| go w.run() |
| - log.Printf("edge: wildcard certs on (static %v, apps domain %q)", |
| - splitDomains(os.Getenv("CARLOS_WILDCARD_DOMAINS")), os.Getenv("PLATFORM_APPS_DOMAIN")) |
| + log.Printf("edge: wildcard certs on (static %v, apps domain %q, delegation zone %q)", |
| + splitDomains(os.Getenv("CARLOS_WILDCARD_DOMAINS")), os.Getenv("PLATFORM_APPS_DOMAIN"), w.delegationZone) |
| } |
| // The non-ACME :80 handler. Known hosts get the classic redirect to https |
diff --git a/internal/edge/wildcard.go b/internal/edge/wildcard.go| index 251c8e0fc..32ce6e5e1 100644 |
| --- a/internal/edge/wildcard.go |
| +++ b/internal/edge/wildcard.go |
| @@ -16,8 +16,11 @@ package edge |
| // instance hosts are one label deep). |
| // |
| // Canary hosts (<name>.<app>.<acct>.…, one label too deep) and custom |
| -// domains stay per-host HTTP-01. The wildcard only changes how a handshake |
| -// GETS a certificate: routing, HostPolicy and the :80 handler are |
| +// domains stay per-host HTTP-01 — except a custom domain approved as a |
| +// delegated wildcard parent (spec §4), which gets its own *.<parent> |
| +// wildcard via a challenge TXT published in our own zone. The wildcard |
| +// only changes how a handshake GETS a certificate: routing, HostPolicy and |
| +// the :80 handler are |
| // untouched, and every failure (DNS provider outage, ACME outage, |
| // unreadable cache) degrades to exactly the per-host behaviour — never a |
| // dead edge. |
| @@ -45,6 +48,8 @@ import ( |
| "golang.org/x/crypto/acme" |
| "golang.org/x/crypto/acme/autocert" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| ) |
| const letsEncryptDirectory = "https://acme-v02.api.letsencrypt.org/directory" |
| @@ -65,6 +70,19 @@ type wildcardManager struct { |
| appsDomain string |
| hosts func() []string // live route hosts (Edge.currentHosts) |
| + // delegated lists customer domains approved for delegated-challenge |
| + // wildcard issuance (spec §4: claim-derived; nil = none). Their |
| + // challenge TXTs publish into delegationZone — our own zone — at |
| + // _acme-challenge.<parent>.<delegationZone>, which the customer has |
| + // CNAMEd _acme-challenge.<parent> to. The platform never touches, |
| + // or knows, the customer's DNS provider. |
| + delegated func() []string |
| + delegationZone string |
| + |
| + // delegatedSet is refresh()'s snapshot of delegated(), read by |
| + // challengeFQDN under mu (issuance runs on run()'s goroutine). |
| + delegatedSet map[string]bool |
| + |
| mu sync.Mutex |
| parents []string // last computed parent set (sorted) |
| certs map[string]*tls.Certificate |
| @@ -81,26 +99,31 @@ type wildcardManager struct { |
| wake chan struct{} |
| } |
| -func newWildcardManager(staticDomains []string, appsDomain string, hosts func() []string, cache autocert.Cache, dns dnsProvider) *wildcardManager { |
| +func newWildcardManager(staticDomains []string, appsDomain string, hosts func() []string, delegated func() []string, delegationZone string, cache autocert.Cache, dns dnsProvider) *wildcardManager { |
| return &wildcardManager{ |
| - cache: cache, |
| - dns: dns, |
| - directory: envOr("CARLOS_ACME_DIRECTORY", letsEncryptDirectory), |
| - staticDomains: staticDomains, |
| - appsDomain: strings.ToLower(strings.TrimSpace(appsDomain)), |
| - hosts: hosts, |
| - certs: map[string]*tls.Certificate{}, |
| - nextAttempt: map[string]time.Time{}, |
| - wake: make(chan struct{}, 1), |
| + cache: cache, |
| + dns: dns, |
| + directory: envOr("CARLOS_ACME_DIRECTORY", letsEncryptDirectory), |
| + staticDomains: staticDomains, |
| + appsDomain: strings.ToLower(strings.TrimSpace(appsDomain)), |
| + hosts: hosts, |
| + delegated: delegated, |
| + delegationZone: strings.ToLower(strings.TrimSpace(delegationZone)), |
| + certs: map[string]*tls.Certificate{}, |
| + nextAttempt: map[string]time.Time{}, |
| + delegatedSet: map[string]bool{}, |
| + wake: make(chan struct{}, 1), |
| } |
| } |
| -// parentsFor computes the wildcard parent set: the static domains plus |
| +// parentsFor computes the wildcard parent set: staticAndDelegated (the |
| +// static CARLOS_WILDCARD_DOMAINS plus any delegated parents refresh() has |
| +// already vetted — both arrive pre-merged through this one parameter) union |
| // <acct>.<appsDomain> for every route host exactly two labels under |
| // appsDomain (<app>.<acct>.<appsDomain>). Canary hosts (three labels), the |
| // apex, and foreign domains contribute nothing. Sorted and deduped so the |
| // set is deterministic for logs and tests. |
| -func parentsFor(static []string, appsDomain string, hosts []string) []string { |
| +func parentsFor(staticAndDelegated []string, appsDomain string, hosts []string) []string { |
| seen := map[string]bool{} |
| var out []string |
| add := func(p string) { |
| @@ -109,7 +132,7 @@ func parentsFor(static []string, appsDomain string, hosts []string) []string { |
| out = append(out, p) |
| } |
| } |
| - for _, d := range static { |
| + for _, d := range staticAndDelegated { |
| add(strings.ToLower(d)) |
| } |
| if appsDomain != "" { |
| @@ -129,17 +152,48 @@ func parentsFor(static []string, appsDomain string, hosts []string) []string { |
| } |
| // refresh recomputes the parent set from the live route table (called from |
| -// the reload hook on every route swap, and once at startup). If any parent |
| -// lacks a usable cert it wakes run() — that is what makes a NEW account's |
| -// wildcard start issuing the moment its first route lands, instead of on |
| -// the next 12h tick. A wake cannot bypass a failing parent's backoff: run() |
| -// still skips any parent whose nextAttempt hasn't passed, so a wake only |
| -// advances parents that aren't already backing off. |
| +// the reload hook on every route swap, and once at startup), folding in |
| +// delegated parents (spec §4) alongside the static and per-account ones. |
| +// Platform parents always win: a delegated entry that collides with a |
| +// static domain, or falls under the apps domain, is dropped here — before |
| +// it reaches parentsFor's union or delegatedSet — because letting it |
| +// through would redirect a PLATFORM parent's challenge to a delegation |
| +// target where no CNAME exists, silently degrading its wildcard renewal to |
| +// per-host. If any parent lacks a usable cert it wakes run() — that is what |
| +// makes a NEW account's wildcard start issuing the moment its first route |
| +// lands, instead of on the next 12h tick. A wake cannot bypass a failing |
| +// parent's backoff: run() still skips any parent whose nextAttempt hasn't |
| +// passed, so a wake only advances parents that aren't already backing off. |
| func (w *wildcardManager) refresh() { |
| - parents := parentsFor(w.staticDomains, w.appsDomain, w.hosts()) |
| + var delegated []string |
| + if w.delegated != nil { |
| + static := make(map[string]bool, len(w.staticDomains)) |
| + for _, d := range w.staticDomains { |
| + static[strings.ToLower(strings.TrimSpace(d))] = true |
| + } |
| + appsSuffix := "" |
| + if w.appsDomain != "" { |
| + appsSuffix = "." + w.appsDomain |
| + } |
| + for _, d := range w.delegated() { |
| + if d = strings.ToLower(strings.TrimSpace(d)); d == "" { |
| + continue |
| + } |
| + if static[d] || (appsSuffix != "" && strings.HasSuffix(d, appsSuffix)) { |
| + continue |
| + } |
| + delegated = append(delegated, d) |
| + } |
| + } |
| + parents := parentsFor(append(append([]string(nil), w.staticDomains...), delegated...), w.appsDomain, w.hosts()) |
| now := time.Now() |
| w.mu.Lock() |
| w.parents = parents |
| + set := make(map[string]bool, len(delegated)) |
| + for _, d := range delegated { |
| + set[d] = true |
| + } |
| + w.delegatedSet = set |
| need := false |
| for _, p := range parents { |
| c := w.certs[p] |
| @@ -244,6 +298,27 @@ func coversParent(cert *tls.Certificate, parent string) bool { |
| return ok && pub.Equal(cert.Leaf.PublicKey) |
| } |
| +// challengeFQDN returns where parent's DNS-01 TXT is published: the |
| +// delegation target inside our own zone for delegated parents, or |
| +// directly at _acme-challenge.<parent> otherwise. With no delegation |
| +// zone configured the direct form is used for everyone — the |
| +// misconfigured state degrades to the historical behavior and per-host |
| +// issuance keeps covering (obtain will fail on the CA's lookup, log, |
| +// and back off — never a dead edge). |
| +func (w *wildcardManager) challengeFQDN(parent string) string { |
| + if w.delegationZone != "" && w.isDelegated(parent) { |
| + return "_acme-challenge." + parent + "." + w.delegationZone |
| + } |
| + return "_acme-challenge." + parent |
| +} |
| + |
| +// isDelegated reports whether parent is in the delegated set. |
| +func (w *wildcardManager) isDelegated(parent string) bool { |
| + w.mu.Lock() |
| + defer w.mu.Unlock() |
| + return w.delegatedSet[parent] |
| +} |
| + |
| // wildcardPEMKey is the cache key for a parent's wildcard cert+key PEM — |
| // Eleven's file name, so a dir cache written by that lineage reads with no |
| // migration, and the keys sit additively beside autocert's per-host entries. |
| @@ -448,7 +523,7 @@ func (w *wildcardManager) solveDNS01(ctx context.Context, client *acme.Client, p |
| return err |
| } |
| - challengeFQDN := "_acme-challenge." + parent |
| + challengeFQDN := w.challengeFQDN(parent) |
| cleanup, err := w.dns.present(ctx, challengeFQDN, txt) |
| if err != nil { |
| return fmt.Errorf("publish challenge TXT: %v", err) |
| @@ -532,7 +607,19 @@ func newWildcardFromEnv(e *Edge, cache autocert.Cache) *wildcardManager { |
| log.Printf("edge: wildcard certs off (%s) — per-host issuance continues", reason) |
| return nil |
| } |
| - return newWildcardManager(static, appsDomain, e.currentHosts, cache, dns) |
| + // Delegated parents (spec §4): the SOURCE is claim-derived and lands |
| + // with the claim-derivation pass (Plan C); until then the zone is |
| + // configured but the set is empty — inert by construction. |
| + rawZone := os.Getenv("CARLOS_ACME_DELEGATION_ZONE") |
| + zone := strings.ToLower(strings.TrimSpace(rawZone)) |
| + if zone != "" && !provision.ValidHostShape(zone) { |
| + // Degrade, don't die: the edge must still boot on a bad zone value. |
| + // Delegation goes off; per-host and direct (own-zone) DNS-01 keep |
| + // covering everyone else. |
| + log.Printf("edge: CARLOS_ACME_DELEGATION_ZONE %q invalid — delegation off, per-host and direct DNS-01 continue", rawZone) |
| + zone = "" |
| + } |
| + return newWildcardManager(static, appsDomain, e.currentHosts, nil, zone, cache, dns) |
| } |
| // wrapGetCertificate serves the wildcard for covered names and hands |
diff --git a/internal/edge/wildcard_test.go b/internal/edge/wildcard_test.go| index 4925d6918..8ad08d544 100644 |
| --- a/internal/edge/wildcard_test.go |
| +++ b/internal/edge/wildcard_test.go |
| @@ -73,7 +73,7 @@ func TestParentsFor(t *testing.T) { |
| func TestCertForMatchesExactlyOneLabel(t *testing.T) { |
| w := newWildcardManager([]string{"go.tito.io"}, "oncarlos.com", |
| func() []string { return []string{"kass.abs.oncarlos.com"} }, |
| - autocert.DirCache(t.TempDir()), nil) |
| + nil, "", autocert.DirCache(t.TempDir()), nil) |
| w.refresh() |
| cert, _ := selfSignedWildcard(t, "abs.oncarlos.com", time.Now().Add(60*24*time.Hour)) |
| w.mu.Lock() |
| @@ -124,7 +124,7 @@ func TestWildcardPEMRoundTripAndRenewDecision(t *testing.T) { |
| t.Fatal("parseWildcardPEM(junk): want error") |
| } |
| - w := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, autocert.DirCache(t.TempDir()), nil) |
| + w := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, nil, "", autocert.DirCache(t.TempDir()), nil) |
| if !w.needsObtain("abs.oncarlos.com", time.Now()) { |
| t.Fatal("no cert loaded: needsObtain should be true") |
| } |
| @@ -152,7 +152,7 @@ func TestLoadCachedAndRefreshWake(t *testing.T) { |
| } |
| w := newWildcardManager(nil, "oncarlos.com", |
| - func() []string { return []string{"kass.abs.oncarlos.com"} }, cache, nil) |
| + func() []string { return []string{"kass.abs.oncarlos.com"} }, nil, "", cache, nil) |
| if !w.loadCached(context.Background(), "abs.oncarlos.com") { |
| t.Fatal("loadCached: want true for cached parent") |
| } |
| @@ -169,7 +169,7 @@ func TestLoadCachedAndRefreshWake(t *testing.T) { |
| default: |
| } |
| w2 := newWildcardManager(nil, "oncarlos.com", |
| - func() []string { return []string{"kass.abs.oncarlos.com"} }, autocert.DirCache(t.TempDir()), nil) |
| + func() []string { return []string{"kass.abs.oncarlos.com"} }, nil, "", autocert.DirCache(t.TempDir()), nil) |
| w2.refresh() |
| select { |
| case <-w2.wake: |
| @@ -190,7 +190,7 @@ func TestLoadCachedRejectsWrongCoverage(t *testing.T) { |
| if err := cache.Put(context.Background(), wildcardPEMKey("abs.oncarlos.com"), rawWrongParent); err != nil { |
| t.Fatal(err) |
| } |
| - w := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, cache, nil) |
| + w := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, nil, "", cache, nil) |
| if w.loadCached(context.Background(), "abs.oncarlos.com") { |
| t.Fatal("loadCached: want false for cert covering the wrong parent") |
| } |
| @@ -215,7 +215,7 @@ func TestLoadCachedRejectsWrongCoverage(t *testing.T) { |
| if err := cache2.Put(context.Background(), wildcardPEMKey("abs.oncarlos.com"), rawMismatch); err != nil { |
| t.Fatal(err) |
| } |
| - w2 := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, cache2, nil) |
| + w2 := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, nil, "", cache2, nil) |
| if w2.loadCached(context.Background(), "abs.oncarlos.com") { |
| t.Fatal("loadCached: want false for key/leaf mismatch") |
| } |
| @@ -248,12 +248,12 @@ func (f *fakeDNS) present(_ context.Context, fqdn, value string) (func(context.C |
| func TestWildcardAccountKeyStableAcrossManagers(t *testing.T) { |
| dir := t.TempDir() |
| cache := autocert.DirCache(dir) |
| - w1 := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, cache, &fakeDNS{}) |
| + w1 := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, nil, "", cache, &fakeDNS{}) |
| k1, err := w1.accountKey(context.Background()) |
| if err != nil { |
| t.Fatal(err) |
| } |
| - w2 := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, cache, &fakeDNS{}) |
| + w2 := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, nil, "", cache, &fakeDNS{}) |
| k2, err := w2.accountKey(context.Background()) |
| if err != nil { |
| t.Fatal(err) |
| @@ -274,7 +274,7 @@ func (f faultyCache) Get(context.Context, string) ([]byte, error) { |
| func TestAccountKeyBackendFaultDoesNotMint(t *testing.T) { |
| w := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, |
| - faultyCache{autocert.DirCache(t.TempDir())}, &fakeDNS{}) |
| + nil, "", faultyCache{autocert.DirCache(t.TempDir())}, &fakeDNS{}) |
| if _, err := w.accountKey(context.Background()); err == nil { |
| t.Fatal("backend fault: want error, got a (freshly minted) key") |
| } |
| @@ -286,7 +286,7 @@ func TestAccountKeyBackendFaultDoesNotMint(t *testing.T) { |
| // (delete) clears the entry entirely. |
| func TestPerParentBackoff(t *testing.T) { |
| w := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, |
| - autocert.DirCache(t.TempDir()), &fakeDNS{}) |
| + nil, "", autocert.DirCache(t.TempDir()), &fakeDNS{}) |
| if w.backingOff("abs.oncarlos.com", time.Now()) { |
| t.Fatal("no nextAttempt recorded yet: backingOff should be false") |
| @@ -321,7 +321,7 @@ func TestPerParentBackoff(t *testing.T) { |
| func TestWrappedGetCertificateFallsThrough(t *testing.T) { |
| w := newWildcardManager(nil, "oncarlos.com", |
| func() []string { return []string{"kass.abs.oncarlos.com"} }, |
| - autocert.DirCache(t.TempDir()), &fakeDNS{}) |
| + nil, "", autocert.DirCache(t.TempDir()), &fakeDNS{}) |
| w.refresh() |
| cert, _ := selfSignedWildcard(t, "abs.oncarlos.com", time.Now().Add(60*24*time.Hour)) |
| w.mu.Lock() |
| @@ -418,3 +418,126 @@ func TestCurrentHostsPlatformOnly(t *testing.T) { |
| t.Fatalf("currentHosts = %v, want %v", got, want) |
| } |
| } |
| + |
| +func TestDelegatedParentsJoinTheSet(t *testing.T) { |
| + w := newWildcardManager(nil, "oncarlos.com", func() []string { |
| + return []string{"app.bab.oncarlos.com"} |
| + }, func() []string { |
| + return []string{"woodstar.app"} |
| + }, "acme.oncarlos.com", autocert.DirCache(t.TempDir()), &fakeDNS{}) |
| + w.refresh() |
| + w.mu.Lock() |
| + parents := append([]string(nil), w.parents...) |
| + w.mu.Unlock() |
| + want := []string{"bab.oncarlos.com", "woodstar.app"} |
| + if !reflect.DeepEqual(parents, want) { |
| + t.Fatalf("parents = %v, want %v", parents, want) |
| + } |
| +} |
| + |
| +func TestChallengeFQDNDelegation(t *testing.T) { |
| + w := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, |
| + func() []string { return []string{"woodstar.app"} }, |
| + "acme.oncarlos.com", autocert.DirCache(t.TempDir()), &fakeDNS{}) |
| + w.refresh() |
| + // A delegated parent's TXT publishes into OUR zone, at the target |
| + // the customer's CNAME points to. |
| + if got, want := w.challengeFQDN("woodstar.app"), "_acme-challenge.woodstar.app.acme.oncarlos.com"; got != want { |
| + t.Errorf("delegated: %q, want %q", got, want) |
| + } |
| + // A platform-zone parent publishes directly, exactly as before. |
| + if got, want := w.challengeFQDN("bab.oncarlos.com"), "_acme-challenge.bab.oncarlos.com"; got != want { |
| + t.Errorf("own-zone: %q, want %q", got, want) |
| + } |
| +} |
| + |
| +func TestChallengeFQDNWithoutZoneNeverDelegates(t *testing.T) { |
| + // No delegation zone configured: even a listed delegated parent |
| + // publishes directly (misconfiguration degrades to the historical |
| + // behavior, which per-host issuance then covers — never a dead edge). |
| + w := newWildcardManager(nil, "", func() []string { return nil }, |
| + func() []string { return []string{"woodstar.app"} }, |
| + "", autocert.DirCache(t.TempDir()), &fakeDNS{}) |
| + w.refresh() |
| + if got, want := w.challengeFQDN("woodstar.app"), "_acme-challenge.woodstar.app"; got != want { |
| + t.Errorf("%q, want %q", got, want) |
| + } |
| +} |
| + |
| +func TestNilDelegatedSourceIsFine(t *testing.T) { |
| + // Every existing construction site passes nil — the zero-source |
| + // manager must behave exactly as before this feature existed. |
| + w := newWildcardManager([]string{"go.tito.io"}, "", func() []string { return nil }, |
| + nil, "acme.oncarlos.com", autocert.DirCache(t.TempDir()), &fakeDNS{}) |
| + w.refresh() |
| + w.mu.Lock() |
| + parents := append([]string(nil), w.parents...) |
| + w.mu.Unlock() |
| + if !reflect.DeepEqual(parents, []string{"go.tito.io"}) { |
| + t.Fatalf("parents = %v, want [go.tito.io]", parents) |
| + } |
| + if got, want := w.challengeFQDN("go.tito.io"), "_acme-challenge.go.tito.io"; got != want { |
| + t.Errorf("%q, want %q", got, want) |
| + } |
| +} |
| + |
| +// TestPlatformParentsWinOverDelegated exercises the precedence rule refresh() |
| +// enforces: a delegated entry that collides with a static domain or falls |
| +// under the apps domain is dropped before it can hijack a PLATFORM parent's |
| +// challenge — that parent's TXT must still publish directly, never into the |
| +// delegation zone where no customer CNAME exists. |
| +func TestPlatformParentsWinOverDelegated(t *testing.T) { |
| + w := newWildcardManager([]string{"go.tito.io"}, "oncarlos.com", |
| + func() []string { return []string{"app.bab.oncarlos.com"} }, |
| + func() []string { return []string{"go.tito.io", "bab.oncarlos.com", "woodstar.app"} }, |
| + "acme.oncarlos.com", autocert.DirCache(t.TempDir()), &fakeDNS{}) |
| + w.refresh() |
| + |
| + // Colliding entries lose: both the static domain and the dynamic |
| + // per-account parent publish their challenge directly, exactly as if |
| + // they had never appeared in the delegated source. |
| + if got, want := w.challengeFQDN("go.tito.io"), "_acme-challenge.go.tito.io"; got != want { |
| + t.Errorf("static collision: challengeFQDN = %q, want %q", got, want) |
| + } |
| + if got, want := w.challengeFQDN("bab.oncarlos.com"), "_acme-challenge.bab.oncarlos.com"; got != want { |
| + t.Errorf("apps-domain collision: challengeFQDN = %q, want %q", got, want) |
| + } |
| + // A genuinely foreign delegated parent still gets the delegated form. |
| + if got, want := w.challengeFQDN("woodstar.app"), "_acme-challenge.woodstar.app.acme.oncarlos.com"; got != want { |
| + t.Errorf("non-colliding delegated: challengeFQDN = %q, want %q", got, want) |
| + } |
| +} |
| + |
| +func TestNewWildcardFromEnvDelegationZone(t *testing.T) { |
| + t.Setenv("CARLOS_WILDCARD_DOMAINS", "go.tito.io") |
| + t.Setenv("CARLOS_DNS_PROVIDER", "dnsimple") |
| + t.Setenv("CARLOS_DNSIMPLE_TOKEN", "test-token") |
| + t.Setenv("CARLOS_ACME_DELEGATION_ZONE", " Acme.OnCarlos.Com ") |
| + e := &Edge{} |
| + w := newWildcardFromEnv(e, autocert.DirCache(t.TempDir())) |
| + if w == nil { |
| + t.Fatal("manager should be on") |
| + } |
| + if w.delegationZone != "acme.oncarlos.com" { |
| + t.Errorf("delegationZone = %q, want lowercased+trimmed acme.oncarlos.com", w.delegationZone) |
| + } |
| +} |
| + |
| +// TestNewWildcardFromEnvInvalidDelegationZoneDegrades covers "validate at |
| +// boot, degrade don't die": a malshaped CARLOS_ACME_DELEGATION_ZONE must |
| +// never stop the edge from booting wildcards — it just turns delegation off |
| +// while per-host and direct (own-zone) DNS-01 keep covering everyone else. |
| +func TestNewWildcardFromEnvInvalidDelegationZoneDegrades(t *testing.T) { |
| + t.Setenv("CARLOS_WILDCARD_DOMAINS", "go.tito.io") |
| + t.Setenv("CARLOS_DNS_PROVIDER", "dnsimple") |
| + t.Setenv("CARLOS_DNSIMPLE_TOKEN", "test-token") |
| + t.Setenv("CARLOS_ACME_DELEGATION_ZONE", "*.bad zone") |
| + e := &Edge{} |
| + w := newWildcardFromEnv(e, autocert.DirCache(t.TempDir())) |
| + if w == nil { |
| + t.Fatal("invalid delegation zone must not stop the edge from booting") |
| + } |
| + if w.delegationZone != "" { |
| + t.Errorf("delegationZone = %q, want empty (delegation off)", w.delegationZone) |
| + } |
| +} |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 23fb23f62..4b6c310f8 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -111,6 +111,9 @@ |
| // wildcard certs: unset/dnsimple needs the token (SSM → unit env), |
| // route53 rides the box IAM role; nothing set = wildcards off, |
| // per-host HTTP-01 only |
| +// - CARLOS_ACME_DELEGATION_ZONE — our own zone that delegated wildcard |
| +// parents' challenge TXTs publish into (spec §4); unset or an invalid |
| +// shape = delegation off, per-host and direct DNS-01 continue |
| // - PLATFORM_EDGE_IPS — comma-separated edge IPs custom-domain DNS checks |
| // compare against. Default: this box's public IPv4 from IMDS |
| // (best-effort; if neither is knowable, DNS stamping stands down |