edge: the watch re-bases local holds on an alias's DNS stamp, Reload publishes its table and holds as one snapshot, ExactHome refuses a literal catch-all (Task 2 final fix round)
Astra's plan 3 final review, F1 to F3. - F1: the registry fingerprint leaves alias DNS status out, so a DNS-only stamp never reloaded and a held row's explicit alias kept its stale hold eligibility. Watch's tick is now watchTick; while the holds' rows hold a row it also reads a digest of the explicit alias rows' (Alias, Host, DNSStatus, Explicit), and when only that moved it re-lists the rows on the watch goroutine (outside the view's lock) and re-bases the holds (refreshHoldRows) without a Reload. TestHoldingAliasFollowsDNSStamp drives both transitions through the real tick. - F2: Reload published the table before the holds built from it, so a request in between could see neither. holdSnapshot now carries its table; installTable publishes both under holdsMu and resolve and Allowed read them from one pointer (servingPair). TestReloadPublishesReadyAndHoldsAtomically pauses a Reload inside its rebuild, both ways. - F3: ExactHome refuses a literal "*." name, as Allowed and KnownHost do. - Task 7's harness: a comment that its unconditional per-round Reload masked F1 (behaviour unchanged).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
5 files changed,
+329
−54
internal/e2e/selfserve/holding_world_test.go+3 −0internal/edge/decision_test.go+122 −0internal/edge/edge.go+103 −47internal/edge/holding.go+98 −6internal/edge/homemap.go+3 −1
diff --git a/internal/e2e/selfserve/holding_world_test.go b/internal/e2e/selfserve/holding_world_test.go| index 435de27e2..35d6e298a 100644 |
| --- a/internal/e2e/selfserve/holding_world_test.go |
| +++ b/internal/e2e/selfserve/holding_world_test.go |
| @@ -397,6 +397,9 @@ func (w *holdWorld) newThirdEdge(t *testing.T) *boxEdge { |
| func (w *holdWorld) converge() error { |
| w.t.Helper() |
| for _, be := range []*boxEdge{w.src, w.tgt} { |
| + // An unconditional Reload, not the watch's fingerprint decision: it |
| + // masked final review F1 (a DNS-only alias stamp the fingerprint skips); |
| + // edge's TestHoldingAliasFollowsDNSStamp drives the real watch tick. |
| if err := be.e.Reload(); err != nil { |
| w.t.Fatalf("%s: reload: %v", be.label, err) |
| } |
diff --git a/internal/edge/decision_test.go b/internal/edge/decision_test.go| index ba484e933..3de0f4e26 100644 |
| --- a/internal/edge/decision_test.go |
| +++ b/internal/edge/decision_test.go |
| @@ -248,6 +248,19 @@ func TestResolveExactVersusRequest(t *testing.T) { |
| t.Fatal("an expired hold with nothing behind it is no longer a member") |
| } |
| }) |
| + t.Run("ExactHome rejects a literal wildcard name", func(t *testing.T) { |
| + sc := newScene(t, "carol") |
| + sc.publish("betty", "2.2.2.2", `"*.example.com":{"role":"home"}`, "") |
| + if _, _, ok := sc.m.ExactHome("*.example.com"); ok { |
| + t.Fatal("the catch-all key itself is not an exact name") |
| + } |
| + if _, box, ok := sc.m.Home("shop.example.com"); !ok || box != "betty" { |
| + t.Fatalf("a concrete request host still resolves through the catch-all: %q %v", box, ok) |
| + } |
| + if d := sc.e.resolve("shop.example.com", sc.now); d.kind != decRemote || d.box != "betty" { |
| + t.Fatalf("resolve = %v %q, want betty's catch-all", d.kind, d.box) |
| + } |
| + }) |
| t.Run("Allowed rejects the wildcard label and admits the tunnel host", func(t *testing.T) { |
| sc := newScene(t, "carol") |
| sc.route("*.example.com", "local wildcard", "bab", "wild") |
| @@ -675,3 +688,112 @@ func TestLocalHoldsFollowTheViewAndTheRegistry(t *testing.T) { |
| } |
| }) |
| } |
| + |
| +// An explicit alias of a held row follows its DNS stamp through the real |
| +// registry watch (final review F1): the registry fingerprint leaves alias |
| +// DNS status out on purpose, so no Reload runs, and the watch's holds |
| +// digest is what re-lists the rows outside the view's lock. |
| +func TestHoldingAliasFollowsDNSStamp(t *testing.T) { |
| + sc := newScene(t, "annie") |
| + sc.heldRow("shop.example.com", "bab", "shop", "mv_r", 1, 1) |
| + sc.alias("shop.example.net", "shop.example.com", "pending", true) |
| + sc.view(map[string]HoldState{"shop.example.com": moveR}) |
| + var st watchState |
| + sc.e.watchTick(&st) // the first tick baselines (and reloads) |
| + var reloads int |
| + sc.e.OnReload(func() { reloads++ }) |
| + aliasHeld := func() bool { |
| + return sc.e.resolve("shop.example.net", sc.now).kind == decHolding && sc.e.Allowed("shop.example.net") |
| + } |
| + if aliasHeld() { |
| + t.Fatal("a pending alias is not held") |
| + } |
| + fp, err := sc.reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, step := range []struct { |
| + dns string |
| + want bool |
| + }{{"pointing", true}, {"pending", false}, {"pointing", true}} { |
| + if err := sc.reg.SetAliasDNSStatus("shop.example.net", step.dns, time.Now().Unix()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if now, _ := sc.reg.Fingerprint(); now != fp { |
| + t.Fatal("test bug: the fingerprint must not see a DNS stamp") |
| + } |
| + sc.e.watchTick(&st) |
| + if aliasHeld() != step.want { |
| + t.Fatalf("after the stamp turned %s: held %v, want %v", step.dns, aliasHeld(), step.want) |
| + } |
| + if step.want { |
| + wantPage(t, sc.serve("shop.example.net", "/", ordinary), "the alias once pointing") |
| + } else { |
| + wantUnknown(t, sc.serve("shop.example.net", "/", ordinary), "the alias once pending") |
| + } |
| + } |
| + if reloads != 0 { |
| + t.Fatalf("the watch reloaded %d time(s) for a DNS stamp; only the holds are rebuilt", reloads) |
| + } |
| + // A quiet tick re-lists nothing and changes nothing. |
| + sc.e.watchTick(&st) |
| + if !aliasHeld() || reloads != 0 { |
| + t.Fatal("a quiet tick changed the holds or reloaded") |
| + } |
| +} |
| + |
| +// A Reload publishes its table and the holds built from it together (final |
| +// review F2): a request between the table swap and the holds rebuild sees |
| +// the earlier pair (the ready row, or the hold), never neither. |
| +func TestReloadPublishesReadyAndHoldsAtomically(t *testing.T) { |
| + sc := newScene(t, "annie") |
| + sc.route("shop.example.com", "shop", "bab", "shop", func(r *registry.Route) { r.Incarnation, r.ServingSeq = 1, 1 }) |
| + sc.view(map[string]HoldState{"shop.example.com": moveR}) |
| + // pauseReload runs a Reload on its own goroutine, holds it inside the |
| + // holds rebuild (after its table has been built and swapped in), runs |
| + // probe there, then lets it finish. |
| + pauseReload := func(probe func()) { |
| + t.Helper() |
| + paused, release := make(chan struct{}), make(chan struct{}) |
| + var once sync.Once |
| + sc.e.holdsRead = func(*HoldSnapshot) { |
| + once.Do(func() { |
| + close(paused) |
| + <-release |
| + }) |
| + } |
| + done := make(chan error, 1) |
| + go func() { done <- sc.e.Reload() }() |
| + <-paused |
| + probe() |
| + close(release) |
| + if err := <-done; err != nil { |
| + t.Fatal(err) |
| + } |
| + sc.e.holdsRead = nil |
| + } |
| + |
| + // ready -> held: mid-reload the request still gets the ready row. |
| + if err := sc.reg.SetHold("shop.example.com", "moving", "mv_r"); err != nil { |
| + t.Fatal(err) |
| + } |
| + pauseReload(func() { |
| + wantBody(t, sc.serve("shop.example.com", "/", ordinary), "shop", "ready to held, mid-reload") |
| + }) |
| + wantPage(t, sc.serve("shop.example.com", "/", ordinary), "held, after the reload") |
| + |
| + // held -> ready: mid-reload the request still gets the page. |
| + if _, err := sc.reg.ReleaseHold("shop.example.com", "mv_r"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := sc.reg.SetStatus("shop.example.com", "ready"); err != nil { |
| + t.Fatal(err) |
| + } |
| + pauseReload(func() { |
| + wantPage(t, sc.serve("shop.example.com", "/", ordinary), "held to ready, mid-reload") |
| + if !sc.e.Allowed("shop.example.com") { |
| + t.Fatal("mid-reload the name must stay Allowed") |
| + } |
| + }) |
| + wantBody(t, sc.serve("shop.example.com", "/", ordinary), "shop", "ready, after the reload") |
| +} |
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index 8f6057575..c8b740eed 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -1012,13 +1012,13 @@ func (e *Edge) Reload() error { |
| // host being deleted is a moment early, a 503 for a host being added is |
| // simply wrong. |
| e.probers.Sync(probeSpecs) |
| - e.upstreams.Store(&m) |
| - // The local holds (holding.go) are rebuilt from the rows this reload |
| - // read: a row turning held, an alias detached, a provenance or DNS |
| - // eligibility change all land here with the view unchanged. Before the |
| - // reload hook, so the wildcard manager's re-derivation sees them. |
| - e.holdInputs.Store(&holdInputs{routes: routes, aliases: aliases, table: m}) |
| - e.rebuildHolds() |
| + // The table and the local holds (holding.go) built from the rows this |
| + // reload read are published together (installTable): a row turning |
| + // held, an alias detached, a provenance or DNS eligibility change all |
| + // land here with the view unchanged, and the request path never sees |
| + // the new table without its holds. Before the reload hook, so the |
| + // wildcard manager's re-derivation sees them. |
| + e.installTable(routes, aliases, m) |
| // Reconcile the activator with the new route set: a host un-flagged (or |
| // removed) since the last reload may still have a live child holding its |
| // socket and lease — retire it so a systemd unit can take over cleanly. |
| @@ -1190,10 +1190,11 @@ func (e *Edge) Allowed(host string) bool { |
| // moves plan 3): a held row's host and its explicit publishable aliases |
| // are registry-backed exact names this box answers with the holding |
| // page, and need their certificate through the move. |
| - if _, ok := e.current()[host]; ok { |
| + table, holds := e.servingPair() |
| + if _, ok := table[host]; ok { |
| return true |
| } |
| - return len(e.localHoldsFor(host)) > 0 |
| + return len(holds.candsFor(host)) > 0 |
| } |
| // deploymentHost reports whether host is served HERE (Allowed) or is exactly |
| @@ -1298,7 +1299,9 @@ func localReady(name string, up upstream, table map[string]upstream) candidate { |
| // losing local hold does not count) and it has the "*.<parent>" upstream, |
| // that upstream, from this same table, travels with the decision. |
| func (e *Edge) resolve(host string, now time.Time) decision { |
| - table := e.current() |
| + // The table and the local holds from one read (servingPair), so a |
| + // Reload is seen whole or not at all. |
| + table, holds := e.servingPair() |
| var remote map[string][]candidate |
| if hm := e.homes.Load(); hm != nil && hm.self != "" { |
| remote = hm.snapshot().cands |
| @@ -1308,7 +1311,7 @@ func (e *Edge) resolve(host string, now time.Time) decision { |
| if up, ok := table[name]; ok { |
| cs = append(cs, localReady(name, up, table)) |
| } |
| - cs = append(cs, e.localHoldsFor(name)...) |
| + cs = append(cs, holds.candsFor(name)...) |
| return append(cs, remote[name]...) |
| } |
| d := rank(host, gather(host), now) |
| @@ -2449,9 +2452,7 @@ func (e *Edge) Watch(stop <-chan struct{}) { |
| // triggers a reload, so a route added between the edge's startup reload |
| // and this poll can't be silently baselined away. (Eleven's version has |
| // that gap; one redundant reload at startup is the cost of closing it.) |
| - var last string |
| - errs := 0 |
| - reloadErrs := 0 |
| + var st watchState |
| tick := time.NewTicker(2 * time.Second) |
| defer tick.Stop() |
| for { |
| @@ -2460,43 +2461,98 @@ func (e *Edge) Watch(stop <-chan struct{}) { |
| return |
| case <-tick.C: |
| } |
| - fp, err := e.reg.Fingerprint() |
| - if err != nil { |
| - errs++ |
| - if errs == 1 || errs%300 == 0 { |
| - log.Printf("edge: registry-watch poll error (#%d): %v", errs, err) |
| - } |
| - continue |
| - } |
| - errs = 0 |
| - // The edge's own loop is alive, its routing table readable, and the |
| - // :443 path's shared lock free (region HA spec 2 §6, servingPathLive). |
| - // A wedged poll or a wedged request path stops beating. |
| - if e.lbHealth != nil && e.servingPathLive() { |
| - e.lbHealth.EdgeBeat(time.Now()) |
| + e.watchTick(&st) |
| + } |
| +} |
| + |
| +// watchState is what the watch carries from one tick to the next. |
| +type watchState struct { |
| + last string |
| + errs, reloadErrs int |
| + // lastHolds is the holds digest (holdAliasDigest) the holds were last |
| + // built against; holdErrs counts its read failures. |
| + lastHolds string |
| + holdErrs int |
| +} |
| + |
| +// watchTick is one poll of the watch: the fingerprint, the serving-path |
| +// beat, and a reload when the fingerprint moved. While the box holds a row, |
| +// it also watches the one input of the local holds the fingerprint leaves |
| +// out on purpose (an explicit alias's DNS stamp, holdAliasDigest): when only |
| +// that moved, it re-lists the rows here, outside the view's lock, and |
| +// re-bases the holds on them (refreshHoldRows) without a Reload. |
| +func (e *Edge) watchTick(st *watchState) { |
| + fp, err := e.reg.Fingerprint() |
| + if err != nil { |
| + st.errs++ |
| + if st.errs == 1 || st.errs%300 == 0 { |
| + log.Printf("edge: registry-watch poll error (#%d): %v", st.errs, err) |
| } |
| - if fp == last { |
| - continue |
| + return |
| + } |
| + st.errs = 0 |
| + // The edge's own loop is alive, its routing table readable, and the |
| + // :443 path's shared lock free (region HA spec 2 §6, servingPathLive). |
| + // A wedged poll or a wedged request path stops beating. |
| + if e.lbHealth != nil && e.servingPathLive() { |
| + e.lbHealth.EdgeBeat(time.Now()) |
| + } |
| + // Read before any reload: a stamp landing after it is caught on the next |
| + // tick, never baselined away. |
| + holds, aliases, armed, herr := e.holdAliasDigest() |
| + if fp == st.last { |
| + e.watchHolds(st, holds, aliases, armed, herr) |
| + return |
| + } |
| + // last is baselined only on a SUCCESSFUL reload: a fleet detach is a |
| + // revocation, and if Reload fails on this tick, the fingerprint it |
| + // was meant to apply must not be discarded — baselining it here |
| + // (before Reload runs) would make the change invisible to every |
| + // later tick until some UNRELATED write moves the fingerprint again, |
| + // silently sitting on a revocation that never took effect. Leaving |
| + // last unchanged means the very next tick sees the same fp != last |
| + // and retries. |
| + if err := e.Reload(); err != nil { |
| + st.reloadErrs++ |
| + if st.reloadErrs == 1 || st.reloadErrs%300 == 0 { |
| + log.Printf("edge: registry-watch reload failed (#%d): %v", st.reloadErrs, err) |
| } |
| - // last is baselined only on a SUCCESSFUL reload: a fleet detach is a |
| - // revocation, and if Reload fails on this tick, the fingerprint it |
| - // was meant to apply must not be discarded — baselining it here |
| - // (before Reload runs) would make the change invisible to every |
| - // later tick until some UNRELATED write moves the fingerprint again, |
| - // silently sitting on a revocation that never took effect. Leaving |
| - // last unchanged means the very next tick sees the same fp != last |
| - // and retries. |
| - if err := e.Reload(); err != nil { |
| - reloadErrs++ |
| - if reloadErrs == 1 || reloadErrs%300 == 0 { |
| - log.Printf("edge: registry-watch reload failed (#%d): %v", reloadErrs, err) |
| - } |
| - continue |
| + return |
| + } |
| + st.reloadErrs = 0 |
| + st.last = fp |
| + st.lastHolds = "" |
| + if armed && herr == nil { |
| + st.lastHolds = holds |
| + } |
| + log.Printf("edge: registry changed — reloaded (%d route(s))", len(e.current())) |
| +} |
| + |
| +// watchHolds is watchTick's holds half, on a tick whose fingerprint did not |
| +// move: a changed digest re-lists the rows and re-bases the holds. A failed |
| +// read leaves the baseline, so the next tick retries. |
| +func (e *Edge) watchHolds(st *watchState, holds string, aliases []registry.RouteAlias, armed bool, herr error) { |
| + if !armed { |
| + st.lastHolds = "" |
| + return |
| + } |
| + if herr == nil && holds == st.lastHolds { |
| + return |
| + } |
| + var routes []registry.Route |
| + if herr == nil { |
| + routes, herr = e.reg.List() |
| + } |
| + if herr != nil { |
| + st.holdErrs++ |
| + if st.holdErrs == 1 || st.holdErrs%300 == 0 { |
| + log.Printf("edge: registry-watch holds read failed (#%d): %v", st.holdErrs, herr) |
| } |
| - reloadErrs = 0 |
| - last = fp |
| - log.Printf("edge: registry changed — reloaded (%d route(s))", len(e.current())) |
| + return |
| } |
| + st.holdErrs = 0 |
| + e.refreshHoldRows(routes, aliases) |
| + st.lastHolds = holds |
| } |
| // versionHeader wraps next so every response also carries |
diff --git a/internal/edge/holding.go b/internal/edge/holding.go| index 977392a62..54c09ad0e 100644 |
| --- a/internal/edge/holding.go |
| +++ b/internal/edge/holding.go |
| @@ -2,6 +2,8 @@ package edge |
| import ( |
| "bytes" |
| + "crypto/sha256" |
| + "encoding/hex" |
| "fmt" |
| "html/template" |
| "net/http" |
| @@ -179,9 +181,16 @@ func (e *Edge) holdingAnswer(w http.ResponseWriter, r *http.Request, h *holdingV |
| // holdSnapshot is this box's own holding candidates, by name: one real |
| // candidate per held row the view knows the move of, and one alias |
| // candidate per explicit alias the edges-file writer would publish for it. |
| -// Immutable once installed; RefreshHolds is its only writer. |
| +// |
| +// table is the upstream table the holds were built against, published in |
| +// the same value (final review F2): the request path (resolve, Allowed) |
| +// reads the table and the holds from this one pointer, so a Reload that |
| +// turns a ready row held (or back) is seen whole or not at all, never as |
| +// the new table with the old holds. Immutable once installed; only |
| +// rebuildHoldsLocked writes it, under holdsMu. |
| type holdSnapshot struct { |
| cands map[string][]candidate |
| + table map[string]upstream |
| } |
| // holdInputs is the registry as the latest Reload read it: every row, every |
| @@ -210,8 +219,9 @@ func (e *Edge) SetHoldView(s HoldSnapshot) { |
| e.RefreshHolds() |
| } |
| -// RefreshHolds rebuilds the local holds from the latest view and the latest |
| -// Reload's rows, and installs them. The inputs are read under holdsMu, the |
| +// RefreshHolds rebuilds the local holds from the latest view and the rows |
| +// the latest Reload (or the watch's holds refresh, refreshHoldRows) listed, |
| +// and installs them. The inputs are read under holdsMu, the |
| // lock that installs, so rebuilds install in the order they read and none |
| // installs older inputs than one before it; no revision is compared (a |
| // view's Rev is publication order, not freshness). When the hold names that |
| @@ -230,17 +240,94 @@ func (e *Edge) RefreshHolds() { |
| func (e *Edge) rebuildHolds() bool { |
| e.holdsMu.Lock() |
| defer e.holdsMu.Unlock() |
| + return e.rebuildHoldsLocked() |
| +} |
| + |
| +// rebuildHoldsLocked is the one writer of e.holds; holdsMu is held. The |
| +// snapshot it installs carries the table its inputs name. |
| +func (e *Edge) rebuildHoldsLocked() bool { |
| view := e.holdView.Load() |
| in := e.holdInputs.Load() |
| if e.holdsRead != nil { |
| e.holdsRead(view) |
| } |
| next := localHolds(view, in) |
| + if in != nil { |
| + next.table = in.table |
| + } |
| prev := e.holds.Load() |
| e.holds.Store(next) |
| return !slices.Equal(prev.platformNames(), next.platformNames()) |
| } |
| +// installTable is Reload's publication: the new table and the holds built |
| +// from the rows that produced it, installed under holdsMu as one |
| +// holdSnapshot (the request path's pair), with the table also stored for |
| +// every other reader of current(). It reports whether the platform hold |
| +// names changed. |
| +func (e *Edge) installTable(routes []registry.Route, aliases []registry.RouteAlias, m map[string]upstream) bool { |
| + e.holdsMu.Lock() |
| + defer e.holdsMu.Unlock() |
| + e.upstreams.Store(&m) |
| + e.holdInputs.Store(&holdInputs{routes: routes, aliases: aliases, table: m}) |
| + return e.rebuildHoldsLocked() |
| +} |
| + |
| +// refreshHoldRows re-bases the holds on freshly listed rows and alias rows |
| +// against the table already published (final review F1): the watch calls it, |
| +// outside the view's lock, when the only registry change is one the |
| +// fingerprint leaves out (an alias's DNS stamp). The table is not rebuilt. |
| +// Before the first Reload there is nothing to re-base. |
| +func (e *Edge) refreshHoldRows(routes []registry.Route, aliases []registry.RouteAlias) { |
| + e.holdsMu.Lock() |
| + in := e.holdInputs.Load() |
| + changed := false |
| + if in != nil { |
| + e.holdInputs.Store(&holdInputs{routes: routes, aliases: aliases, table: in.table}) |
| + changed = e.rebuildHoldsLocked() |
| + } |
| + e.holdsMu.Unlock() |
| + if changed { |
| + if fn := e.reloadHook.Load(); fn != nil { |
| + go (*fn)() |
| + } |
| + } |
| +} |
| + |
| +// servingPair is the request path's table and local holds, from one read |
| +// (holdSnapshot). An edge that has never reloaded (a bare test edge) has no |
| +// snapshot and reads the table alone. |
| +func (e *Edge) servingPair() (map[string]upstream, *holdSnapshot) { |
| + if h := e.holds.Load(); h != nil && h.table != nil { |
| + return h.table, h |
| + } |
| + return e.current(), e.holds.Load() |
| +} |
| + |
| +// holdAliasDigest is the watch's second input (final review F1): a digest |
| +// of every explicit alias row's (Alias, Host, DNSStatus, Explicit), which a |
| +// held row's alias holds depend on and the registry fingerprint leaves out. |
| +// armed is false, and nothing is read, while the rows the holds were last |
| +// built from hold nothing: then no alias can be a local hold, and a row |
| +// turning held moves the fingerprint. |
| +func (e *Edge) holdAliasDigest() (digest string, aliases []registry.RouteAlias, armed bool, err error) { |
| + in := e.holdInputs.Load() |
| + if in == nil || !slices.ContainsFunc(in.routes, func(r registry.Route) bool { return registry.IsHold(r.Status) }) { |
| + return "", nil, false, nil |
| + } |
| + aliases, err = e.reg.ListAliases() |
| + if err != nil { |
| + return "", nil, true, err |
| + } |
| + h := sha256.New() |
| + for _, a := range aliases { |
| + if a.Explicit { |
| + fmt.Fprintf(h, "%s\x1f%s\x1f%s\x1f%t\x1e", a.Alias, a.Host, a.DNSStatus, a.Explicit) |
| + } |
| + } |
| + return hex.EncodeToString(h.Sum(nil)), aliases, true, nil |
| +} |
| + |
| // localHolds builds the box's holding candidates: none without a view, or |
| // with a failed one. A held row (registry.IsHold, not a sidecar) whose hold |
| // op is the move the view knows for its host is one real candidate, owned |
| @@ -320,10 +407,15 @@ func (h *holdSnapshot) platformNames() []string { |
| // localHoldsFor is name's local holding candidates. |
| func (e *Edge) localHoldsFor(name string) []candidate { |
| - if h := e.holds.Load(); h != nil { |
| - return h.cands[name] |
| + return e.holds.Load().candsFor(name) |
| +} |
| + |
| +// candsFor is name's candidates in h, nil-receiver-safe. |
| +func (h *holdSnapshot) candsFor(name string) []candidate { |
| + if h == nil { |
| + return nil |
| } |
| - return nil |
| + return h.cands[name] |
| } |
| // certNames is every exact name this box must keep a certificate for |
diff --git a/internal/edge/homemap.go b/internal/edge/homemap.go| index c2e80724c..9acdff4cc 100644 |
| --- a/internal/edge/homemap.go |
| +++ b/internal/edge/homemap.go |
| @@ -1083,7 +1083,9 @@ func (m *homeMap) Home(host string) (eip, box string, ok bool) { |
| // exact name before the catch-all. Fail-closed without a self label, |
| // exactly as Home is. |
| func (m *homeMap) ExactHome(host string) (eip, box string, ok bool) { |
| - if m == nil || m.self == "" { |
| + // A literal catch-all key is no exact name (Allowed's and KnownHost's |
| + // rule). |
| + if m == nil || m.self == "" || strings.HasPrefix(host, "*.") { |
| return "", "", false |
| } |
| if d := m.resolveExact(host, m.clock()); d.kind == decRemote { |