edge-purge-hosts-173
Squashed from edge-purge-hosts-173 into main.
7 files changed,
+829
−124
internal/edge/edge.go+3 −3internal/edge/purge.go+29 −0internal/edge/purge_header.go+228 −113internal/edge/purge_header_test.go+12 −7internal/edge/purge_hosts_test.go+484 −0internal/edge/purge_path_test.go+1 −1internal/edge/purge_test.go+72 −0
CI passed — run details
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index 2045b8eeb..063435f7a 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -1065,7 +1065,7 @@ func (e *Edge) lookupUpstream(host string) (upstream, bool) { |
| // 3. Purge tenancy is the TARGET's app and account (purgeModifier is built |
| // from up.route), while the response CACHE keys on the REQUESTED host. |
| // Those two disagree, and closing that is what the path-routing branch |
| -// in resolvePurgeHost exists for — see below. |
| +// in purgeHostInTenancy exists for — see below. |
| // 4. Activator keying and hibernation are the TARGET's (up.hibernating and |
| // up.route come from the same entry), so a request for a flipped prefix |
| // wakes the target instance, not the requested hostname's. Correct: the |
| @@ -1076,7 +1076,7 @@ func (e *Edge) lookupUpstream(host string) (upstream, bool) { |
| // the target's route, so a path-routed app emitting a bare Carlos-Purge still |
| // purges under go.tito.io while its entry is stored under ti.to, and drops |
| // nothing. The fix is the documented escape hatch: name the hostname the |
| -// request arrived on, Carlos-Purge-Host: ti.to. resolvePurgeHost used to |
| +// request arrived on, Carlos-Purge-Host: ti.to. The override check used to |
| // refuse that — it required the override to name a host in the SAME app, and |
| // path routing exists precisely to cross an app boundary — and now allows it |
| // when a prefix on the named host selects this route. Account is still |
| @@ -1085,7 +1085,7 @@ func (e *Edge) lookupUpstream(host string) (upstream, bool) { |
| // |
| // So an app can emit Carlos-Purge-Host set to the Host header it received, |
| // unconditionally, from shared code. The inbound Host is preserved end to end, |
| -// so on an ordinary request that equals rt.Host and takes resolvePurgeHost's |
| +// so on an ordinary request that equals rt.Host and takes resolvePurgeHosts' |
| // own-host branch; on a path-routed one it names the front hostname and takes |
| // the path-routing branch. Nothing in the app has to know which it is. |
| func (e *Edge) lookupUpstreamPath(host, urlPath string) (upstream, bool) { |
diff --git a/internal/edge/purge.go b/internal/edge/purge.go| index 50b4558c2..4e2f61466 100644 |
| --- a/internal/edge/purge.go |
| +++ b/internal/edge/purge.go |
| @@ -29,11 +29,29 @@ type ControlListener struct { |
| // keep compiling. |
| type PurgeListener = ControlListener |
| +// purgeRequest is the body of POST /purge: one exact URL, or {"path":"*"} |
| +// for every cached entry of the listener's host — the socket-channel twin of |
| +// a Carlos-Purge: * header (amadan carlos/platform#173). Without it an exec |
| +// instance had to enumerate its own paths to do what the header channel does |
| +// in one token. |
| +// |
| +// "Whole host" means the one host the listener was built for. Entries cached |
| +// under that route's aliases or custom domains are keyed by those names, and |
| +// this channel cannot name them; an app that needs them uses the header |
| +// channel's Carlos-Purge-Host list. |
| +// |
| +// "*" is a whole-host purge only on its own. With a query beside it the body |
| +// names nothing a request could be stored under, so it is refused rather than |
| +// guessed at: widening a malformed exact purge to the whole host is not a |
| +// reading the caller asked for. |
| type purgeRequest struct { |
| Path string `json:"path"` |
| Query string `json:"query"` |
| } |
| +// purgeAllPath is the purgeRequest.Path that asks for the whole host. |
| +const purgeAllPath = "*" |
| + |
| // ListenControl binds socketPath (removing any stale file left behind by a |
| // crashed previous instance) and serves this host's control verbs on it. |
| // POST /purge is registered only when cache is non-nil — a box with no edge |
| @@ -119,6 +137,17 @@ func ListenControl(socketPath, host string, cache *Cache, mount func(*http.Serve |
| http.Error(w, "bad request", http.StatusBadRequest) |
| return |
| } |
| + if req.Path == purgeAllPath { |
| + if req.Query != "" { |
| + http.Error(w, "bad request: path \"*\" takes no query", http.StatusBadRequest) |
| + return |
| + } |
| + // host is still the one this listener was built for: "*" widens |
| + // the purge to every path of that host, never to another host. |
| + cache.PurgeHost(host) |
| + w.WriteHeader(http.StatusOK) |
| + return |
| + } |
| cache.PurgeURL(host, req.Path, req.Query) |
| w.WriteHeader(http.StatusOK) |
| }) |
diff --git a/internal/edge/purge_header.go b/internal/edge/purge_header.go| index 1d8830132..9b590e2a0 100644 |
| --- a/internal/edge/purge_header.go |
| +++ b/internal/edge/purge_header.go |
| @@ -62,12 +62,24 @@ import ( |
| const PurgeHeader = "Carlos-Purge" |
| // PurgeHostHeader lets one response redirect its whole Carlos-Purge |
| -// declaration at a SIBLING route of the same app and account — spec §6.4. |
| +// declaration at one or more SIBLING routes of the same app and account — |
| +// spec §6.4, widened from one host to a list by amadan carlos/platform#173. |
| // |
| // HTTP/1.1 302 Found |
| -// Carlos-Purge-Host: acme.go.tito.io |
| +// Carlos-Purge-Host: acme.go.tito.io, tickets.acme.example |
| // Carlos-Purge: /events/42 |
| // |
| +// # Why a list (#173) |
| +// |
| +// A Tito account with custom domains serves its public pages on several hosts |
| +// at once: its canonical {slug}.go.tito.io plus any domain wired to one event |
| +// or series. With one host per declaration a write could only ever reach the |
| +// canonical one, so pages under the others stayed stale for their full |
| +// s-maxage, and the app's only defence was to stop caching them at all. A |
| +// list lets one response reach every host of its own tenancy. Each entry is |
| +// checked exactly as the single host always was; the list adds reach, never a |
| +// weaker check. |
| +// |
| // # Why the host-confinement above had to be relaxed, and along which seam |
| // |
| // Tito's production topology splits one app across two route groups: admin |
| @@ -89,17 +101,33 @@ const PurgeHeader = "Carlos-Purge" |
| // |
| // # The contract, precisely |
| // |
| -// - AT MOST ONE host. The value is a bare hostname. Several lines, or a |
| -// comma-joined list, naming more than one distinct host is a MALFORMED |
| -// declaration, not a request to purge several. |
| -// - ALL-OR-NOTHING. Any failure — unknown host, different app, different |
| -// account, malformed value — discards the ENTIRE purge declaration for |
| -// this response, targets and all. There is deliberately no fallback to the |
| -// serving host: a half-honoured purge is worse than a refused one, because |
| -// it clears something and so looks like it worked. |
| +// - A LIST of bare hostnames, comma-joined or on several lines (RFC 9110 |
| +// §5.3 — both spellings are the same declaration). Duplicates collapse. |
| +// At most maxPurgeHosts distinct hosts, and at most maxPurgeHeaderBytes |
| +// across every line; beyond either the declaration is malformed. |
| +// - It REPLACES the serving host, it does not add to it. A response that |
| +// wants its own host purged as well lists it, which is always allowed. |
| +// - ALL-OR-NOTHING on shape and tenancy. A malformed entry, an entry of a |
| +// different app or a different account, an over-long list, or a serving |
| +// route that changed while the request was in flight discards the ENTIRE |
| +// purge declaration for this response, every host and every target. |
| +// There is deliberately no fallback to the serving host, and no "honour |
| +// the good entries": a half-honoured purge is worse than a refused one, |
| +// because it clears something and so looks like it worked. |
| +// - An entry this edge does not SERVE — no ready route, exact or wildcard, |
| +// that it would route the name to — is |
| +// skipped (and logged), and the rest of the list is honoured. That is the |
| +// one exception, and it is not a half-honoured purge: an unserved host has |
| +// nothing this edge would serve from cache, and it cannot be checked |
| +// against a tenancy either, so skipping it neither misses a stale page nor |
| +// reaches outside the tenant. It matters because an app lists its hosts |
| +// from its own records, which do not say which box serves which host: a |
| +// domain still waiting on DNS, or one placed on another box, must not |
| +// cost the whole purge. A declaration whose every entry is unserved |
| +// purges nothing, exactly as a lone unknown host always did. |
| // - It redirects EVERY target in the same response, "*" and the cap-degrade |
| -// path included. The override decides WHICH host; §6.1 still decides which |
| -// entries of it. |
| +// path included, to EVERY accepted host. The override decides WHICH |
| +// hosts; §6.1 still decides which entries of each. |
| // - Resolved against the CURRENT route table at response time (e.current()), |
| // never a snapshot taken when the proxy was built — a sibling added by the |
| // very reload that rebuilt this proxy must still validate. |
| @@ -154,8 +182,16 @@ const PurgeHostHeader = "Carlos-Purge-Host" |
| // 64 targets is well above what a real write invalidates (a page, its |
| // paginated index, a couple of locale variants). 8 KiB bounds the joined |
| // header itself, since 64 targets says nothing about how long each one is. |
| +// |
| +// maxPurgeHosts bounds Carlos-Purge-Host the same way, but it cannot degrade: |
| +// over-purging past the cap would mean acting on hosts nobody validated, so a |
| +// longer list is refused outright. 32 is well above a real account's domain |
| +// count, and it keeps the worst case (32 hosts x 64 targets, two index groups |
| +// each) a few thousand map lookups. An app with more hosts than that should |
| +// not be naming them one response at a time. |
| const ( |
| maxPurgeTargets = 64 |
| + maxPurgeHosts = 32 |
| maxPurgeHeaderBytes = 8 << 10 // 8 KiB |
| ) |
| @@ -258,7 +294,7 @@ func scrubInboundPurgeHeader(pr *httputil.ProxyRequest) { |
| // time the response comes back. |
| // |
| // The OVERRIDE side is deliberately the other way round: the named host is |
| -// looked up in e.current() at response time (see resolvePurgeHost), because |
| +// looked up in e.current() at response time (see resolvePurgeHosts), because |
| // the route table is live and a sibling added since this proxy was built must |
| // still validate. e.current() is an atomic pointer load, so reading it from |
| // this hook needs no lock and cannot tear against a concurrent Reload. |
| @@ -287,100 +323,157 @@ func (e *Edge) applyPurgeHeader(rt registry.Route, h http.Header) { |
| // would silently drop every target after the first line — an under-purge, |
| // i.e. a stale page nobody notices until a customer does. |
| joined := joinHeader(h, PurgeHeader) |
| - // Every line, for the opposite reason: Carlos-Purge-Host is SINGLE-valued, |
| - // so reading only the first line would let a second line pass unnoticed — |
| - // and "the app named two hosts" must be detected, not silently resolved to |
| - // whichever came first. |
| + // Every line, for the same reason: Carlos-Purge-Host is a list too, and a |
| + // host on a second line is as much a part of the declaration as the first. |
| + // Reading only the first line would also let a malformed or cross-tenant |
| + // entry on a later line escape the all-or-nothing check. |
| declared := h.Values(PurgeHostHeader) |
| h.Del(PurgeHeader) |
| h.Del(PurgeHostHeader) |
| if joined == "" || e.cache == nil { |
| return |
| } |
| - host, ok := e.resolvePurgeHost(rt, declared) |
| + hosts, ok := e.resolvePurgeHosts(rt, declared) |
| if !ok { |
| return |
| } |
| - e.applyPurgeTargets(host, joined) |
| + e.applyPurgeTargets(rt.Host, hosts, joined) |
| } |
| -// resolvePurgeHost decides which host this response's purge targets apply to: |
| -// the serving route's own host, or the sibling a valid Carlos-Purge-Host names. |
| -// ok=false means the whole declaration is refused (see PurgeHostHeader's |
| -// all-or-nothing note) — the caller must purge nothing at all, not fall back. |
| +// resolvePurgeHosts decides which hosts this response's purge targets apply |
| +// to: the serving route's own host, or the hosts a valid Carlos-Purge-Host |
| +// lists. ok=false means the whole declaration is refused (see |
| +// PurgeHostHeader's all-or-nothing note) — the caller must purge nothing at |
| +// all, not fall back. ok=true with no hosts means every listed host was one |
| +// this edge does not serve, and there is nothing to purge. |
| // |
| // declared is every Carlos-Purge-Host LINE, already removed from the response. |
| -func (e *Edge) resolvePurgeHost(rt registry.Route, declared []string) (string, bool) { |
| +func (e *Edge) resolvePurgeHosts(rt registry.Route, declared []string) ([]string, bool) { |
| if len(declared) == 0 { |
| - return rt.Host, true |
| + return []string{rt.Host}, true |
| + } |
| + // The byte cap first, so that nothing below it — the split, the dedupe, |
| + // the log lines — does work proportional to a header the app made huge. |
| + size := 0 |
| + for _, line := range declared { |
| + size += len(line) |
| } |
| - // Distinct values across every line, comma-split (a single-valued field |
| - // arriving comma-joined is the same declaration as two lines, RFC 9110 |
| - // §5.3 — so both spellings of "two hosts" have to be refused, and both |
| - // spellings of "the same host twice" have to be tolerated). |
| + if size > maxPurgeHeaderBytes { |
| + e.logPurgeIssue("override-shape", rt.Host, fmt.Sprintf( |
| + "%s declared a %d-byte Carlos-Purge-Host (max %d) — the whole purge declaration is ignored", |
| + rt.Host, size, maxPurgeHeaderBytes)) |
| + return nil, false |
| + } |
| + // Distinct values across every line, comma-split: a comma-joined line and |
| + // several lines are the same list (RFC 9110 §5.3), and naming one host |
| + // twice is one host. |
| var vals []string |
| for _, line := range declared { |
| for _, v := range strings.Split(line, ",") { |
| - if v = strings.TrimSpace(v); v != "" && !slices.Contains(vals, v) { |
| - vals = append(vals, v) |
| + if v = strings.TrimSpace(v); v == "" || slices.Contains(vals, v) { |
| + continue |
| + } |
| + if len(vals) == maxPurgeHosts { |
| + e.logPurgeIssue("override-shape", rt.Host, fmt.Sprintf( |
| + "%s declared more than %d Carlos-Purge-Host values — the whole purge declaration is ignored", |
| + rt.Host, maxPurgeHosts)) |
| + return nil, false |
| } |
| + vals = append(vals, v) |
| } |
| } |
| - if len(vals) != 1 { |
| - // Includes the header-present-but-empty case (len 0): a declaration |
| - // that names no host is malformed, not absent. |
| - e.logPurgeIssue("override-shape", rt.Host, fmt.Sprintf( |
| - "%s declared %d Carlos-Purge-Host value(s) (exactly one host is allowed) — the whole purge declaration is ignored", |
| - rt.Host, len(vals))) |
| - return "", false |
| - } |
| - host := vals[0] |
| - if !validPurgeOverrideHost(host) { |
| + if len(vals) == 0 { |
| + // A declaration that names no host is malformed, not absent. |
| e.logPurgeIssue("override-shape", rt.Host, fmt.Sprintf( |
| - "%s declared a malformed Carlos-Purge-Host %q — the value must be a bare lowercase hostname, with no scheme, port or path; the whole purge declaration is ignored", |
| - rt.Host, truncateForLog(host))) |
| - return "", false |
| + "%s declared an empty Carlos-Purge-Host — the whole purge declaration is ignored", |
| + rt.Host)) |
| + return nil, false |
| } |
| - if host == rt.Host { |
| - // Naming your own host is trivially in-tenancy and needs no lookup, so |
| - // an app can emit the header unconditionally from shared code even on |
| - // the route whose pages it is already allowed to purge. |
| - return rt.Host, true |
| + // Every entry's SHAPE before any entry's tenancy, so a malformed entry |
| + // anywhere in the list refuses the lot regardless of where it sits. |
| + for _, host := range vals { |
| + if !validPurgeOverrideHost(host) { |
| + e.logPurgeIssue("override-shape", rt.Host, fmt.Sprintf( |
| + "%s declared a malformed Carlos-Purge-Host %q — each value must be a bare lowercase hostname, with no scheme, port or path; the whole purge declaration is ignored", |
| + rt.Host, truncateForLog(host))) |
| + return nil, false |
| + } |
| } |
| // e.current(), not a captured map: the route table is live, and a sibling |
| - // added after this proxy was built must still validate. |
| + // added after this proxy was built must still validate. ONE snapshot for |
| + // the whole list, so every entry is judged against the same table. |
| cur := e.current() |
| - // The SERVING side is a value captured when this proxy was built, and a |
| - // reload can land between that capture and this response — the request is |
| - // in flight, its proxy already resolved, while `carlos add` or an adopt |
| - // re-stamps the row. Re-read it: if the serving host has left the table, or |
| - // its App/Account no longer match what was captured, the tenancy this |
| - // declaration would be validated against is one the edge no longer serves, |
| - // and honouring it would purge a sibling on the strength of an identity |
| - // that has expired. Refuse instead — the all-or-nothing rule applied to the |
| - // capture itself. |
| - // |
| - // Only genuine cross-host overrides reach here: naming your own host |
| - // returned above, and stays exactly equivalent to omitting the header. |
| - if live, ok := cur[rt.Host]; !ok || live.route.App != rt.App || |
| - release.RouteAccount(live.route) != release.RouteAccount(rt) { |
| - e.logPurgeIssue("override-stale", rt.Host, fmt.Sprintf( |
| - "%s declared Carlos-Purge-Host %q, but the serving route changed or left the route table while the request was in flight — the whole purge declaration is ignored", |
| - rt.Host, truncateForLog(host))) |
| - return "", false |
| - } |
| - up, ok := cur[host] |
| - if !ok { |
| - e.logPurgeIssue("override-unknown", rt.Host, fmt.Sprintf( |
| - "%s declared Carlos-Purge-Host %q, which is not a route this edge serves — the whole purge declaration is ignored", |
| - rt.Host, truncateForLog(host))) |
| - return "", false |
| + hosts := make([]string, 0, len(vals)) |
| + var unserved []string |
| + servingChecked := false |
| + for _, host := range vals { |
| + if host == rt.Host { |
| + // Naming your own host is trivially in-tenancy and needs no lookup, |
| + // so an app can emit the header unconditionally from shared code |
| + // even on the route whose pages it is already allowed to purge. |
| + hosts = append(hosts, host) |
| + continue |
| + } |
| + // lookupIn, not cur[host]: a host served through a wildcard key |
| + // ("*.acme.example") has no exact entry, but the edge routes it and |
| + // caches its pages under the name the browser asked for, which is |
| + // the name listed here. An exact key, another tenant's included, |
| + // still wins over the wildcard, and either way the route found is the |
| + // one judged below. |
| + up, ok := lookupIn(cur, host) |
| + if !ok { |
| + unserved = append(unserved, host) |
| + continue |
| + } |
| + if !servingChecked { |
| + // The SERVING side is a value captured when this proxy was built, |
| + // and a reload can land between that capture and this response — |
| + // the request is in flight, its proxy already resolved, while |
| + // `carlos add` or an adopt re-stamps the row. Re-read it: if the |
| + // serving host has left the table, or its App/Account no longer |
| + // match what was captured, the tenancy this declaration would be |
| + // validated against is one the edge no longer serves, and |
| + // honouring it would purge a sibling on the strength of an |
| + // identity that has expired. Refuse instead — the all-or-nothing |
| + // rule applied to the capture itself. |
| + // |
| + // Only a genuine cross-host entry that the table serves needs it: |
| + // naming your own host stays exactly equivalent to omitting the |
| + // header, and an unserved entry is skipped without being judged. |
| + if live, ok := cur[rt.Host]; !ok || live.route.App != rt.App || |
| + release.RouteAccount(live.route) != release.RouteAccount(rt) { |
| + e.logPurgeIssue("override-stale", rt.Host, fmt.Sprintf( |
| + "%s declared Carlos-Purge-Host %q, but the serving route changed or left the route table while the request was in flight — the whole purge declaration is ignored", |
| + rt.Host, truncateForLog(host))) |
| + return nil, false |
| + } |
| + servingChecked = true |
| + } |
| + if !purgeHostInTenancy(rt, up) { |
| + e.logPurgeIssue("override-tenancy", rt.Host, fmt.Sprintf( |
| + "%s (app %q, account %q) declared Carlos-Purge-Host %q, which belongs to app %q, account %q — a purge crosses an app boundary only when %q path-routes to the serving route, and never crosses an account boundary; the whole declaration is ignored", |
| + rt.Host, rt.App, rt.Account, truncateForLog(host), up.route.App, up.route.Account, truncateForLog(host))) |
| + return nil, false |
| + } |
| + hosts = append(hosts, host) |
| } |
| - // App AND Account, both, and App must be a real one: a route with no App |
| - // has no releases and no provable tenancy, so ""=="" is not a match — two |
| - // unrelated App-less routes on one box must not be able to purge each |
| - // other. |
| - // |
| + if len(unserved) > 0 { |
| + // One line per response, not one per entry, and only after the whole |
| + // list has passed: a refused declaration logs its refusal instead. |
| + // Its own kind, apart from the refusals: a domain waiting on DNS makes |
| + // this line on every write, and sharing a throttle with the refusals |
| + // would bury the rare one an operator needs to see. |
| + e.logPurgeIssue("override-unserved", rt.Host, fmt.Sprintf( |
| + "%s declared %d Carlos-Purge-Host value(s) this edge does not serve, first %q — skipped, the rest of the declaration is honoured", |
| + rt.Host, len(unserved), truncateForLog(unserved[0]))) |
| + } |
| + return hosts, true |
| +} |
| + |
| +// purgeHostInTenancy reports whether the route the serving route rt is |
| +// purging on behalf of, up, is inside rt's tenancy. The serving host itself |
| +// never reaches here; resolvePurgeHosts admits it without a lookup. |
| +func purgeHostInTenancy(rt registry.Route, up upstream) bool { |
| // Account is compared through release.RouteAccount, not raw, because that |
| // is how the rest of the platform resolves a route's account — it is the |
| // sqid that keys the app's bucket objects. An unset account column, or a |
| @@ -389,6 +482,7 @@ func (e *Edge) resolvePurgeHost(rt registry.Route, declared []string) (string, b |
| // everywhere else on this box. A raw comparison never lets a purge cross a |
| // real account boundary, but it does split one account in two and refuse a |
| // purge between two routes that demonstrably share one key space. |
| + // |
| // The path-routing exception, and the ONLY place an override crosses an |
| // app boundary. |
| // |
| @@ -410,28 +504,22 @@ func (e *Edge) resolvePurgeHost(rt registry.Route, declared []string) (string, b |
| // from the reconcile pass that writes path rows. That pass does enforce |
| // same-account on a target, but a row written by an older binary, or by |
| // hand, must not be able to reach another tenant's cache — the same |
| - // defence-in-depth reasoning the staleness re-read above applies to the |
| - // serving side. |
| + // defence-in-depth reasoning the staleness re-read applies to the serving |
| + // side. |
| // |
| // Scoped to the route the prefix map actually names: "host path-routes |
| // somewhere" is not enough, or any app in the account could purge host by |
| // naming it. |
| if rt.App != "" && pathRoutesTo(up, rt.Host) && |
| release.RouteAccount(up.route) == release.RouteAccount(rt) { |
| - return host, true |
| + return true |
| } |
| // App AND Account, both, and App must be a real one: a route with no App |
| // has no releases and no provable tenancy, so ""=="" is not a match — two |
| // unrelated App-less routes on one box must not be able to purge each |
| // other. |
| - if rt.App == "" || up.route.App != rt.App || |
| - release.RouteAccount(up.route) != release.RouteAccount(rt) { |
| - e.logPurgeIssue("override-tenancy", rt.Host, fmt.Sprintf( |
| - "%s (app %q, account %q) declared Carlos-Purge-Host %q, which belongs to app %q, account %q — a purge crosses an app boundary only when %q path-routes to the serving route, and never crosses an account boundary; the whole declaration is ignored", |
| - rt.Host, rt.App, rt.Account, truncateForLog(host), up.route.App, up.route.Account, truncateForLog(host))) |
| - return "", false |
| - } |
| - return host, true |
| + return rt.App != "" && up.route.App == rt.App && |
| + release.RouteAccount(up.route) == release.RouteAccount(rt) |
| } |
| // pathRoutesTo reports whether any prefix on up selects the route at target. |
| @@ -479,6 +567,12 @@ func pathRoutesTo(up upstream, target string) bool { |
| // out, and "["/"]" are refused with it for consistency. Route keys are the |
| // hosts ServeHTTP matches r.Host against — DNS names, in every deployment this |
| // platform has — so the exclusion costs nothing real. |
| +// |
| +// "*" is refused too. A wildcard route key ("*.acme.example") is a routing |
| +// pattern, not a name anything is cached under: the cache keys on the host |
| +// the browser asked for. Naming the pattern would pass the tenancy check and |
| +// purge nothing, which looks like it worked. An app names the real hosts, |
| +// which resolvePurgeHosts routes through the wildcard. |
| func validPurgeOverrideHost(h string) bool { |
| if h == "" { |
| return false |
| @@ -492,28 +586,37 @@ func validPurgeOverrideHost(h string) bool { |
| return false |
| } |
| switch c { |
| - case ':', '/', '\\', '@', '?', '#', '%', '[', ']': |
| + case ':', '/', '\\', '@', '?', '#', '%', '[', ']', '*': |
| return false |
| } |
| } |
| return true |
| } |
| -// applyPurgeTargets parses one response's joined Carlos-Purge value and |
| -// applies it to host's entries. |
| +// applyPurgeTargets parses one response's joined Carlos-Purge value once and |
| +// applies it to every host in hosts. from is the serving route's host, and |
| +// keys the log lines: it is the route whose response declared the purge. |
| // |
| -// host has already been decided by resolvePurgeHost — the serving route's own |
| -// host, or the sibling a valid Carlos-Purge-Host named. Everything below, |
| +// hosts has already been decided by resolvePurgeHosts — the serving route's |
| +// own host, or the hosts a valid Carlos-Purge-Host listed. Everything below, |
| // including "*" and both cap degrades, therefore follows the override by |
| // construction: degrading to the SERVING host instead would be the worst of |
| // both, wiping a host nobody asked about while leaving the stale pages the app |
| // was trying to invalidate. |
| -func (e *Edge) applyPurgeTargets(host, joined string) { |
| +func (e *Edge) applyPurgeTargets(from string, hosts []string, joined string) { |
| + if len(hosts) == 0 { |
| + return |
| + } |
| + purgeHosts := func() { |
| + for _, host := range hosts { |
| + e.cache.PurgeHost(host) |
| + } |
| + } |
| if len(joined) > maxPurgeHeaderBytes { |
| - e.logPurgeIssue("oversize", host, fmt.Sprintf( |
| - "%s declared a %d-byte Carlos-Purge (max %d) — purging the whole host instead", |
| - host, len(joined), maxPurgeHeaderBytes)) |
| - e.cache.PurgeHost(host) |
| + e.logPurgeIssue("oversize", from, fmt.Sprintf( |
| + "%s declared a %d-byte Carlos-Purge (max %d) — purging the whole of %d host(s) instead", |
| + from, len(joined), maxPurgeHeaderBytes, len(hosts))) |
| + purgeHosts() |
| return |
| } |
| targets := make([]string, 0, 8) |
| @@ -523,18 +626,25 @@ func (e *Edge) applyPurgeTargets(host, joined string) { |
| } |
| } |
| if len(targets) > maxPurgeTargets { |
| - e.logPurgeIssue("count", host, fmt.Sprintf( |
| - "%s declared %d Carlos-Purge targets (max %d) — purging the whole host instead", |
| - host, len(targets), maxPurgeTargets)) |
| - e.cache.PurgeHost(host) |
| + e.logPurgeIssue("count", from, fmt.Sprintf( |
| + "%s declared %d Carlos-Purge targets (max %d) — purging the whole of %d host(s) instead", |
| + from, len(targets), maxPurgeTargets, len(hosts))) |
| + purgeHosts() |
| return |
| } |
| + // Parse every target before touching the cache, so the list is parsed once |
| + // however many hosts it applies to, and so "*" anywhere subsumes the rest. |
| + type key struct { |
| + path, rawQuery string |
| + exact bool |
| + } |
| + keys := make([]key, 0, len(targets)) |
| rejected, firstBad := 0, "" |
| for _, t := range targets { |
| if t == "*" { |
| - // "every cache entry for this host" — and it subsumes any other |
| + // "every cache entry for each host" — and it subsumes any other |
| // target in the same declaration, so there is nothing left to do. |
| - e.cache.PurgeHost(host) |
| + purgeHosts() |
| return |
| } |
| path, rawQuery, exact, ok := purgeTargetKey(t) |
| @@ -545,10 +655,15 @@ func (e *Edge) applyPurgeTargets(host, joined string) { |
| } |
| continue |
| } |
| - if exact { |
| - e.cache.PurgeURL(host, path, rawQuery) |
| - } else { |
| - e.cache.PurgePath(host, path) |
| + keys = append(keys, key{path, rawQuery, exact}) |
| + } |
| + for _, host := range hosts { |
| + for _, k := range keys { |
| + if k.exact { |
| + e.cache.PurgeURL(host, k.path, k.rawQuery) |
| + } else { |
| + e.cache.PurgePath(host, k.path) |
| + } |
| } |
| } |
| if rejected > 0 { |
| @@ -556,9 +671,9 @@ func (e *Edge) applyPurgeTargets(host, joined string) { |
| // otherwise turn every request into 64 log lines. The count is the |
| // signal; the first offender is the diagnostic. %q quotes any control |
| // bytes rather than emitting them into the journal. |
| - e.logPurgeIssue("malformed", host, fmt.Sprintf( |
| + e.logPurgeIssue("malformed", from, fmt.Sprintf( |
| "%s: ignored %d malformed Carlos-Purge target(s), first %q — a target must be an absolute path or \"*\", and may never carry a host", |
| - host, rejected, truncateForLog(firstBad))) |
| + from, rejected, truncateForLog(firstBad))) |
| } |
| } |
diff --git a/internal/edge/purge_header_test.go b/internal/edge/purge_header_test.go| index 07cdc7de2..ba9fbc4e5 100644 |
| --- a/internal/edge/purge_header_test.go |
| +++ b/internal/edge/purge_header_test.go |
| @@ -1316,11 +1316,13 @@ func TestPurgeHostOverrideUnknownHostIsRefused(t *testing.T) { |
| } |
| } |
| -// TestPurgeHostOverrideConflictingValuesRefused — "at most ONE host". Two |
| -// names is not "purge both", it is a malformed declaration, and neither host |
| -// is touched. The multi-value shapes are equivalent whether they arrive on |
| -// separate lines or comma-joined on one (RFC 9110 §5.3). |
| -func TestPurgeHostOverrideConflictingValuesRefused(t *testing.T) { |
| +// TestPurgeHostOverrideTwoValuesPurgeTheServedOne — this used to pin "at most ONE |
| +// host", with two names refused as malformed. #173 made the header a list, so |
| +// two names now mean "purge both" — on separate lines or comma-joined on one |
| +// (RFC 9110 §5.3). c.example.com has no route here, so it is skipped rather |
| +// than purged (see TestPurgeHostListSkipsUnservedHosts), and the serving host |
| +// a.example.com is untouched because neither spelling names it. |
| +func TestPurgeHostOverrideTwoValuesPurgeTheServedOne(t *testing.T) { |
| for _, tc := range []struct { |
| name string |
| set func(http.Header) |
| @@ -1349,9 +1351,12 @@ func TestPurgeHostOverrideConflictingValuesRefused(t *testing.T) { |
| rec := httptest.NewRecorder() |
| e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil)) |
| - for _, h := range []string{"a.example.com", "b.example.com", "c.example.com"} { |
| + if cached(e.cache, "b.example.com", "/x", "") { |
| + t.Fatal("b.example.com survived a list that named it") |
| + } |
| + for _, h := range []string{"a.example.com", "c.example.com"} { |
| if !cached(e.cache, h, "/x", "") { |
| - t.Fatalf("%s was purged by a multi-valued Carlos-Purge-Host", h) |
| + t.Fatalf("%s was purged, but it is unnamed or unserved", h) |
| } |
| } |
| }) |
diff --git a/internal/edge/purge_hosts_test.go b/internal/edge/purge_hosts_test.go| new file mode 100644 |
| index 000000000..405f7b603 |
| --- /dev/null |
| +++ b/internal/edge/purge_hosts_test.go |
| @@ -0,0 +1,484 @@ |
| +package edge |
| + |
| +import ( |
| + "bytes" |
| + "fmt" |
| + "log" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// --------------------------------------------------------------------------- |
| +// Carlos-Purge-Host as a LIST: one response reaching every host of its own |
| +// app and account (amadan carlos/platform#173). |
| +// --------------------------------------------------------------------------- |
| + |
| +// purgeHostsEdge is purgeOverrideEdge for any number of routes. The first is |
| +// the one requests are served on in these tests. |
| +func purgeHostsEdge(t *testing.T, routes []registry.Route, h http.HandlerFunc) *Edge { |
| + t.Helper() |
| + backend := httptest.NewServer(h) |
| + t.Cleanup(backend.Close) |
| + reg := openReg(t) |
| + for _, rt := range routes { |
| + rt.Kind, rt.Addr, rt.Status = "instance", backend.Listener.Addr().String(), "ready" |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatalf("upsert %s: %v", rt.Host, err) |
| + } |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| + return e |
| +} |
| + |
| +// titoAccount is the #173 topology: a canonical account host, a custom domain |
| +// wired to one event, another wired to a series, all one app and one account. |
| +func titoAccount() []registry.Route { |
| + return []registry.Route{ |
| + route("go.tito.example", "tito", "tenanta"), |
| + route("acme.go.tito.example", "tito", "tenanta"), |
| + route("tickets.acme.example", "tito", "tenanta"), |
| + route("series.acme.example", "tito", "tenanta"), |
| + } |
| +} |
| + |
| +func servePOST(e *Edge, url string) *httptest.ResponseRecorder { |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("POST", url, nil)) |
| + return rec |
| +} |
| + |
| +// TestPurgeHostListPurgesEveryListedHost is the headline of #173: an admin |
| +// write on the root domain purges the same page under the canonical host and |
| +// every custom domain at once, whichever list spelling the app uses, and the |
| +// serving host is still left alone because the list did not name it. |
| +func TestPurgeHostListPurgesEveryListedHost(t *testing.T) { |
| + listed := []string{"acme.go.tito.example", "tickets.acme.example", "series.acme.example"} |
| + for _, tc := range []struct { |
| + name string |
| + set func(http.Header) |
| + }{ |
| + {"comma joined", func(h http.Header) { |
| + h.Set("Carlos-Purge-Host", strings.Join(listed, ", ")) |
| + }}, |
| + {"several lines", func(h http.Header) { |
| + for _, host := range listed { |
| + h.Add("Carlos-Purge-Host", host) |
| + } |
| + }}, |
| + {"mixed, with a duplicate", func(h http.Header) { |
| + h.Add("Carlos-Purge-Host", listed[0]+", "+listed[1]) |
| + h.Add("Carlos-Purge-Host", listed[2]+","+listed[0]) |
| + }}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) { |
| + tc.set(w.Header()) |
| + w.Header().Set("Carlos-Purge", "/events/42") |
| + w.WriteHeader(http.StatusFound) |
| + }) |
| + for _, host := range listed { |
| + seed(t, e.cache, host, "/events/42", "", "old") |
| + seed(t, e.cache, host, "/events/43", "", "untouched") |
| + } |
| + seed(t, e.cache, "go.tito.example", "/events/42", "", "serving host") |
| + |
| + rec := servePOST(e, "http://go.tito.example/admin/events/42") |
| + |
| + for _, host := range listed { |
| + if cached(e.cache, host, "/events/42", "") { |
| + t.Fatalf("%s /events/42 survived a list that named it", host) |
| + } |
| + if !cached(e.cache, host, "/events/43", "") { |
| + t.Fatalf("%s /events/43 was purged but nothing named it", host) |
| + } |
| + } |
| + if !cached(e.cache, "go.tito.example", "/events/42", "") { |
| + t.Fatal("the SERVING host was purged — the list replaces it, and did not name it") |
| + } |
| + if got := rec.Header().Values("Carlos-Purge-Host"); len(got) != 0 { |
| + t.Fatalf("Carlos-Purge-Host reached the client: %q", got) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestPurgeHostListNamingTheServingHostPurgesItToo — the list replaces the |
| +// serving host rather than adding to it, so an app that wants both says so. |
| +func TestPurgeHostListNamingTheServingHostPurgesItToo(t *testing.T) { |
| + e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge-Host", "go.tito.example, tickets.acme.example") |
| + w.Header().Set("Carlos-Purge", "/x") |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + seed(t, e.cache, "go.tito.example", "/x", "", "serving") |
| + seed(t, e.cache, "tickets.acme.example", "/x", "", "custom") |
| + seed(t, e.cache, "series.acme.example", "/x", "", "unnamed") |
| + |
| + servePOST(e, "http://go.tito.example/write") |
| + |
| + if cached(e.cache, "go.tito.example", "/x", "") { |
| + t.Fatal("the serving host survived a list that named it") |
| + } |
| + if cached(e.cache, "tickets.acme.example", "/x", "") { |
| + t.Fatal("the listed custom domain survived") |
| + } |
| + if !cached(e.cache, "series.acme.example", "/x", "") { |
| + t.Fatal("a host the list did not name was purged") |
| + } |
| +} |
| + |
| +// TestPurgeHostListStarPurgesEveryListedHost — Tito's "every organiser write |
| +// purges the whole host" rule, reaching every host of the account in one |
| +// response. |
| +func TestPurgeHostListStarPurgesEveryListedHost(t *testing.T) { |
| + e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge-Host", "acme.go.tito.example, tickets.acme.example") |
| + w.Header().Set("Carlos-Purge", "/ignored, *") |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + for _, host := range []string{"acme.go.tito.example", "tickets.acme.example"} { |
| + seed(t, e.cache, host, "/one", "", "1") |
| + seed(t, e.cache, host, "/two", "q=1", "2") |
| + } |
| + seed(t, e.cache, "series.acme.example", "/one", "", "unnamed") |
| + |
| + servePOST(e, "http://go.tito.example/write") |
| + |
| + for _, host := range []string{"acme.go.tito.example", "tickets.acme.example"} { |
| + if cached(e.cache, host, "/one", "") || cached(e.cache, host, "/two", "q=1") { |
| + t.Fatalf("* did not purge the whole of %s", host) |
| + } |
| + } |
| + if !cached(e.cache, "series.acme.example", "/one", "") { |
| + t.Fatal("* reached a host the list did not name") |
| + } |
| +} |
| + |
| +// TestPurgeHostListCapDegradeHitsEveryListedHost — the target-count degrade |
| +// follows the list, not the serving host. |
| +func TestPurgeHostListCapDegradeHitsEveryListedHost(t *testing.T) { |
| + targets := make([]string, maxPurgeTargets+1) |
| + for i := range targets { |
| + targets[i] = fmt.Sprintf("/p/%d", i) |
| + } |
| + e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge-Host", "acme.go.tito.example, tickets.acme.example") |
| + w.Header().Set("Carlos-Purge", strings.Join(targets, ", ")) |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + for _, host := range []string{"go.tito.example", "acme.go.tito.example", "tickets.acme.example"} { |
| + seed(t, e.cache, host, "/unnamed", "", host) |
| + } |
| + |
| + servePOST(e, "http://go.tito.example/write") |
| + |
| + for _, host := range []string{"acme.go.tito.example", "tickets.acme.example"} { |
| + if cached(e.cache, host, "/unnamed", "") { |
| + t.Fatalf("the cap degrade did not purge listed host %s", host) |
| + } |
| + } |
| + if !cached(e.cache, "go.tito.example", "/unnamed", "") { |
| + t.Fatal("the cap degrade purged the SERVING host, which the list did not name") |
| + } |
| +} |
| + |
| +// TestPurgeHostListOneBadEntryRefusesTheWholeList — all-or-nothing survives |
| +// the widening. Wherever the bad entry sits in the list, and whatever is |
| +// wrong with it, no host is purged: a list that clears the good entries and |
| +// drops the bad one looks like it worked. |
| +func TestPurgeHostListOneBadEntryRefusesTheWholeList(t *testing.T) { |
| + routes := append(titoAccount(), |
| + route("other-app.example", "other", "tenanta"), |
| + route("other-account.example", "tito", "tenantb"), |
| + ) |
| + for _, tc := range []struct{ name, list string }{ |
| + {"different app, last", "acme.go.tito.example, tickets.acme.example, other-app.example"}, |
| + {"different app, first", "other-app.example, acme.go.tito.example"}, |
| + {"different account", "acme.go.tito.example, other-account.example"}, |
| + {"malformed, last", "acme.go.tito.example, tickets.acme.example:443"}, |
| + {"malformed, first", "HTTPS://acme.go.tito.example, tickets.acme.example"}, |
| + {"malformed beside an unserved one", "ghost.example, tickets.acme.example/x"}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + e := purgeHostsEdge(t, routes, func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge-Host", tc.list) |
| + w.Header().Set("Carlos-Purge", "/x") |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + all := []string{"go.tito.example", "acme.go.tito.example", "tickets.acme.example", |
| + "series.acme.example", "other-app.example", "other-account.example"} |
| + for _, host := range all { |
| + seed(t, e.cache, host, "/x", "", host) |
| + } |
| + |
| + servePOST(e, "http://go.tito.example/write") |
| + |
| + for _, host := range all { |
| + if !cached(e.cache, host, "/x", "") { |
| + t.Fatalf("%s was purged by a list carrying a bad entry (%q)", host, tc.list) |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestPurgeHostListSkipsUnservedHosts — the one exception to all-or-nothing. |
| +// An app lists its domains from its own records, which do not know which box |
| +// serves which host; a domain waiting on DNS, or placed elsewhere, must not |
| +// cost the purge of the hosts this edge does serve. The unserved host's own |
| +// (orphaned) entry is left alone: skipped means not judged, never purged. |
| +func TestPurgeHostListSkipsUnservedHosts(t *testing.T) { |
| + var buf bytes.Buffer |
| + log.SetOutput(&buf) |
| + t.Cleanup(func() { log.SetOutput(os.Stderr) }) |
| + |
| + e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge-Host", "ghost.example, tickets.acme.example") |
| + w.Header().Set("Carlos-Purge", "/x") |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + seed(t, e.cache, "ghost.example", "/x", "", "orphan") |
| + seed(t, e.cache, "tickets.acme.example", "/x", "", "custom") |
| + seed(t, e.cache, "go.tito.example", "/x", "", "serving") |
| + |
| + servePOST(e, "http://go.tito.example/write") |
| + |
| + if cached(e.cache, "tickets.acme.example", "/x", "") { |
| + t.Fatal("an unserved entry cost the purge of a served, in-tenancy host") |
| + } |
| + if !cached(e.cache, "ghost.example", "/x", "") { |
| + t.Fatal("an unserved host's entries were purged without any tenancy check") |
| + } |
| + if !cached(e.cache, "go.tito.example", "/x", "") { |
| + t.Fatal("the serving host was purged, but the list did not name it") |
| + } |
| + if !strings.Contains(buf.String(), "ghost.example") { |
| + t.Fatalf("the skipped host was not logged; log:\n%s", buf.String()) |
| + } |
| +} |
| + |
| +// TestPurgeHostListOverTheCapIsRefused — the host cap cannot degrade the way |
| +// the target cap does, because over-purging past it would mean acting on |
| +// hosts nobody validated. Distinct hosts are what count: duplicates collapse |
| +// before the cap is applied. |
| +func TestPurgeHostListOverTheCapIsRefused(t *testing.T) { |
| + names := make([]string, maxPurgeHosts) |
| + names[0] = "acme.go.tito.example" |
| + for i := 1; i < len(names); i++ { |
| + names[i] = fmt.Sprintf("unserved-%d.example", i) |
| + } |
| + |
| + t.Run("at the cap, with duplicates", func(t *testing.T) { |
| + list := strings.Join(append(names, names...), ", ") |
| + e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge-Host", list) |
| + w.Header().Set("Carlos-Purge", "/x") |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + seed(t, e.cache, "acme.go.tito.example", "/x", "", "A") |
| + servePOST(e, "http://go.tito.example/write") |
| + if cached(e.cache, "acme.go.tito.example", "/x", "") { |
| + t.Fatalf("a list of exactly %d distinct hosts was refused", maxPurgeHosts) |
| + } |
| + }) |
| + |
| + t.Run("one over", func(t *testing.T) { |
| + list := strings.Join(append(names, "one-too-many.example"), ", ") |
| + e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge-Host", list) |
| + w.Header().Set("Carlos-Purge", "/x") |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + seed(t, e.cache, "acme.go.tito.example", "/x", "", "A") |
| + servePOST(e, "http://go.tito.example/write") |
| + if !cached(e.cache, "acme.go.tito.example", "/x", "") { |
| + t.Fatalf("a list of %d distinct hosts was honoured", maxPurgeHosts+1) |
| + } |
| + }) |
| +} |
| + |
| +// TestPurgeHostListOversizeIsRefused — the byte cap bounds the header before |
| +// any splitting happens, so a huge header of duplicates cannot buy work by |
| +// staying under the distinct-host cap. |
| +func TestPurgeHostListOversizeIsRefused(t *testing.T) { |
| + list := strings.Repeat("acme.go.tito.example,", maxPurgeHeaderBytes/len("acme.go.tito.example,")+1) |
| + e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge-Host", list) |
| + w.Header().Set("Carlos-Purge", "/x") |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + seed(t, e.cache, "acme.go.tito.example", "/x", "", "A") |
| + servePOST(e, "http://go.tito.example/write") |
| + if !cached(e.cache, "acme.go.tito.example", "/x", "") { |
| + t.Fatalf("a %d-byte Carlos-Purge-Host was honoured", len(list)) |
| + } |
| +} |
| + |
| +// TestPurgeHostListEveryEntryUnservedPurgesNothing — the lone-unknown-host |
| +// case generalised: nothing served, nothing purged, no fallback. |
| +func TestPurgeHostListEveryEntryUnservedPurgesNothing(t *testing.T) { |
| + e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge-Host", "ghost-1.example, ghost-2.example") |
| + w.Header().Set("Carlos-Purge", "*") |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + seed(t, e.cache, "go.tito.example", "/x", "", "serving") |
| + servePOST(e, "http://go.tito.example/write") |
| + if !cached(e.cache, "go.tito.example", "/x", "") { |
| + t.Fatal("a list of unserved hosts fell back to the serving host") |
| + } |
| +} |
| + |
| +// TestPurgeHostListReachesAHostServedThroughAWildcard — a catch-all alias |
| +// ("*.acme.example") routes every subdomain to one route, and the cache keys |
| +// each page on the name the browser asked for. Listing that name must purge |
| +// it: the edge serves it, so skipping it as "unserved" would leave a stale |
| +// page behind a purge that cleared the rest of the list. |
| +func TestPurgeHostListReachesAHostServedThroughAWildcard(t *testing.T) { |
| + e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge-Host", "tickets.acme.example, foo.wild.example") |
| + w.Header().Set("Carlos-Purge", "/x") |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + upsertAlias(t, e.reg, "*.wild.example", "acme.go.tito.example") |
| + if err := e.Reload(); err != nil { |
| + t.Fatalf("Reload: %v", err) |
| + } |
| + if _, ok := e.current()["*.wild.example"]; !ok { |
| + t.Fatal("fixture: the wildcard alias is not in the served table, so this proves nothing") |
| + } |
| + seed(t, e.cache, "foo.wild.example", "/x", "", "wild") |
| + seed(t, e.cache, "tickets.acme.example", "/x", "", "custom") |
| + |
| + servePOST(e, "http://go.tito.example/write") |
| + |
| + if cached(e.cache, "foo.wild.example", "/x", "") { |
| + t.Fatal("a host served through a wildcard alias was skipped as unserved") |
| + } |
| + if cached(e.cache, "tickets.acme.example", "/x", "") { |
| + t.Fatal("the rest of the list was not honoured") |
| + } |
| +} |
| + |
| +// TestPurgeHostListWildcardOfAnotherTenantIsRefused — resolving through the |
| +// wildcard is still a tenancy check on the route it lands on: a name under |
| +// another account's catch-all refuses the whole list. |
| +func TestPurgeHostListWildcardOfAnotherTenantIsRefused(t *testing.T) { |
| + routes := append(titoAccount(), route("other-account.example", "tito", "tenantb")) |
| + e := purgeHostsEdge(t, routes, func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge-Host", "tickets.acme.example, foo.theirs.example") |
| + w.Header().Set("Carlos-Purge", "/x") |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + upsertAlias(t, e.reg, "*.theirs.example", "other-account.example") |
| + if err := e.Reload(); err != nil { |
| + t.Fatalf("Reload: %v", err) |
| + } |
| + if _, ok := e.current()["*.theirs.example"]; !ok { |
| + t.Fatal("fixture: the wildcard alias is not in the served table, so this proves nothing") |
| + } |
| + seed(t, e.cache, "foo.theirs.example", "/x", "", "theirs") |
| + seed(t, e.cache, "tickets.acme.example", "/x", "", "custom") |
| + |
| + servePOST(e, "http://go.tito.example/write") |
| + |
| + if !cached(e.cache, "foo.theirs.example", "/x", "") { |
| + t.Fatal("a host under ANOTHER ACCOUNT's wildcard was purged") |
| + } |
| + if !cached(e.cache, "tickets.acme.example", "/x", "") { |
| + t.Fatal("a list carrying a cross-tenant entry was partly honoured") |
| + } |
| +} |
| + |
| +// TestPurgeHostListNamingAWildcardKeyIsRefused — "*.acme.example" is a |
| +// routing pattern, not a name anything is cached under, so naming it is a |
| +// malformed entry and refuses the list rather than purging nothing quietly. |
| +func TestPurgeHostListNamingAWildcardKeyIsRefused(t *testing.T) { |
| + e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge-Host", "tickets.acme.example, *.wild.example") |
| + w.Header().Set("Carlos-Purge", "/x") |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + upsertAlias(t, e.reg, "*.wild.example", "acme.go.tito.example") |
| + if err := e.Reload(); err != nil { |
| + t.Fatalf("Reload: %v", err) |
| + } |
| + seed(t, e.cache, "tickets.acme.example", "/x", "", "custom") |
| + |
| + servePOST(e, "http://go.tito.example/write") |
| + |
| + if !cached(e.cache, "tickets.acme.example", "/x", "") { |
| + t.Fatal("a list naming a wildcard key was honoured") |
| + } |
| +} |
| + |
| +// TestPurgeHostListRefusedWhenTheServingRouteChangedSinceCapture — the |
| +// staleness re-read runs for the first SERVED cross-host entry wherever it |
| +// sits, not only when it leads the list. Own host first and an unserved host |
| +// first are the two orders that reach a served sibling without it being |
| +// first. |
| +func TestPurgeHostListRefusedWhenTheServingRouteChangedSinceCapture(t *testing.T) { |
| + for _, list := range []string{ |
| + "a.example.com, b.example.com", |
| + "ghost.example.com, b.example.com", |
| + } { |
| + t.Run(list, func(t *testing.T) { |
| + var ( |
| + e *Edge |
| + reg *registry.Registry |
| + addr string |
| + ) |
| + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + // The route table moves under the in-flight request. |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "a.example.com", App: "tito", Account: "tenantb", |
| + Kind: "instance", Addr: addr, Status: "ready", |
| + }); err != nil { |
| + t.Errorf("upsert during flight: %v", err) |
| + } |
| + if err := e.Reload(); err != nil { |
| + t.Errorf("reload during flight: %v", err) |
| + } |
| + w.Header().Set("Carlos-Purge-Host", list) |
| + w.Header().Set("Carlos-Purge", "/x") |
| + w.WriteHeader(http.StatusOK) |
| + })) |
| + t.Cleanup(backend.Close) |
| + addr = backend.Listener.Addr().String() |
| + reg = openReg(t) |
| + for _, h := range []string{"a.example.com", "b.example.com"} { |
| + if err := reg.Upsert(registry.Route{ |
| + Host: h, App: "tito", Account: "tenanta", |
| + Kind: "instance", Addr: addr, Status: "ready", |
| + }); err != nil { |
| + t.Fatalf("upsert %s: %v", h, err) |
| + } |
| + } |
| + var err error |
| + if e, err = New(reg, nil); err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| + seed(t, e.cache, "a.example.com", "/x", "", "A") |
| + seed(t, e.cache, "b.example.com", "/x", "", "B") |
| + |
| + servePOST(e, "http://a.example.com/write") |
| + |
| + if !cached(e.cache, "b.example.com", "/x", "") { |
| + t.Fatal("the sibling was purged on the strength of a STALE captured identity") |
| + } |
| + if !cached(e.cache, "a.example.com", "/x", "") { |
| + t.Fatal("a refused list was partly honoured on the serving host") |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/edge/purge_path_test.go b/internal/edge/purge_path_test.go| index 29d4d6b4d..698bafdfd 100644 |
| --- a/internal/edge/purge_path_test.go |
| +++ b/internal/edge/purge_path_test.go |
| @@ -48,7 +48,7 @@ func purgePathEdge(t *testing.T, front, target registry.Route, prefix string, h |
| // |
| // The request really is path-routed here rather than simulated: it arrives on |
| // ti.to/paulca and reaches the target through the prefix map, so this pins the |
| -// composition and not just resolvePurgeHost in isolation. |
| +// composition and not just resolvePurgeHosts in isolation. |
| func TestPurgeHostOverrideAllowedFromAPathRoutedHost(t *testing.T) { |
| e := purgePathEdge(t, |
| route("ti.to", "classic", "tenanta"), |
diff --git a/internal/edge/purge_test.go b/internal/edge/purge_test.go| index 60d86f8b1..03951d986 100644 |
| --- a/internal/edge/purge_test.go |
| +++ b/internal/edge/purge_test.go |
| @@ -183,3 +183,75 @@ func TestListenPurgeRemovesStaleSocketFile(t *testing.T) { |
| } |
| pl.Close() |
| } |
| + |
| +// TestListenPurgeStarPurgesTheWholeHost — {"path":"*"} is the socket twin of |
| +// a Carlos-Purge: * header (#173): every entry of the listener's own host, and |
| +// still never another host's. |
| +func TestListenPurgeStarPurgesTheWholeHost(t *testing.T) { |
| + sock := filepath.Join(t.TempDir(), "instance.sock") |
| + cache := NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes) |
| + put := func(host, path, query string) CacheKey { |
| + k := CacheKey{Host: host, Method: "GET", Path: path, RawQuery: query} |
| + cache.Put(k, "", &CachedResponse{ |
| + StatusCode: 200, Header: http.Header{}, Body: []byte("hi"), StoredAt: time.Now(), TTL: time.Minute, |
| + }) |
| + return k |
| + } |
| + mine := []CacheKey{put("a.example", "/one", ""), put("a.example", "/two", "q=1")} |
| + other := put("other.example", "/one", "") |
| + |
| + pl, err := ListenPurge(sock, "a.example", cache) |
| + if err != nil { |
| + t.Fatalf("ListenPurge: %v", err) |
| + } |
| + defer pl.Close() |
| + |
| + body, _ := json.Marshal(map[string]string{"path": "*"}) |
| + resp, err := dialPurgeSocket(t, sock).Post("http://unix/purge", "application/json", bytes.NewReader(body)) |
| + if err != nil { |
| + t.Fatalf("purge POST: %v", err) |
| + } |
| + resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("purge status = %d, want 200", resp.StatusCode) |
| + } |
| + for _, k := range mine { |
| + if _, ok := cache.get(k); ok { |
| + t.Fatalf("%s%s survived a whole-host purge", k.Path, k.RawQuery) |
| + } |
| + } |
| + if _, ok := cache.get(other); !ok { |
| + t.Fatal("a whole-host purge on a.example's socket reached other.example") |
| + } |
| +} |
| + |
| +// TestListenPurgeStarWithAQueryIsRefused — "*" with a query names nothing a |
| +// request could be stored under, so it is a malformed body, not a whole-host |
| +// purge and not an exact purge of a path literally called "*". |
| +func TestListenPurgeStarWithAQueryIsRefused(t *testing.T) { |
| + sock := filepath.Join(t.TempDir(), "instance.sock") |
| + cache := NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes) |
| + k := CacheKey{Host: "a.example", Method: "GET", Path: "/one"} |
| + cache.Put(k, "", &CachedResponse{ |
| + StatusCode: 200, Header: http.Header{}, Body: []byte("hi"), StoredAt: time.Now(), TTL: time.Minute, |
| + }) |
| + |
| + pl, err := ListenPurge(sock, "a.example", cache) |
| + if err != nil { |
| + t.Fatalf("ListenPurge: %v", err) |
| + } |
| + defer pl.Close() |
| + |
| + body, _ := json.Marshal(map[string]string{"path": "*", "query": "x=1"}) |
| + resp, err := dialPurgeSocket(t, sock).Post("http://unix/purge", "application/json", bytes.NewReader(body)) |
| + if err != nil { |
| + t.Fatalf("purge POST: %v", err) |
| + } |
| + resp.Body.Close() |
| + if resp.StatusCode != http.StatusBadRequest { |
| + t.Fatalf("purge status = %d, want 400", resp.StatusCode) |
| + } |
| + if _, ok := cache.get(k); !ok { |
| + t.Fatal("a refused body purged the host anyway") |
| + } |
| +} |