| index fee78814e..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" |
| @@ -111,12 +116,14 @@ func newWildcardManager(staticDomains []string, appsDomain string, hosts func() |
| } |
| } |
| |
| -// 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) { |
| @@ -125,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 != "" { |
| @@ -145,19 +152,37 @@ 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() { |
| 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 != "" { |
| - delegated = append(delegated, d) |
| + 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()) |
| @@ -287,9 +312,7 @@ func (w *wildcardManager) challengeFQDN(parent string) string { |
| return "_acme-challenge." + parent |
| } |
| |
| -// isDelegated checks delegatedSet membership only — if a parent is ever |
| -// both platform-derived and claim-derived, delegated status wins and its |
| -// challenge publishes to the delegation zone. |
| +// isDelegated reports whether parent is in the delegated set. |
| func (w *wildcardManager) isDelegated(parent string) bool { |
| w.mu.Lock() |
| defer w.mu.Unlock() |
| @@ -587,7 +610,16 @@ func newWildcardFromEnv(e *Edge, cache autocert.Cache) *wildcardManager { |
| // 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. |
| - return newWildcardManager(static, appsDomain, e.currentHosts, nil, os.Getenv("CARLOS_ACME_DELEGATION_ZONE"), cache, dns) |
| + 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 |