| 1 | // The status serving class (routes with Kind=="status"): the fleet status |
| 2 | // page's three published objects (see internal/status's PublishFleet), |
| 3 | // served straight out of the deployment bucket behind a per-key, |
| 4 | // short-lived in-memory cache. Modeled on static.go's staticSite but much |
| 5 | // simpler: PublishFleet overwrites the same three keys in place every |
| 6 | // publish cycle (no signed, versioned channel pointer to resolve), so there |
| 7 | // is no "which version" question — just "read the key, cache it briefly, |
| 8 | // serve it, and don't fall over when the bucket hiccups." |
| 9 | // |
| 10 | // The same host also publishes open ledgers (internal/ledger) under |
| 11 | // /ledgers/<sqid>/<app>/..., path-for-key, gated per app by that app's |
| 12 | // opt-in manifest — see serveLedger. |
| 13 | package edge |
| 14 | |
| 15 | import ( |
| 16 | "context" |
| 17 | "encoding/json" |
| 18 | "errors" |
| 19 | "log" |
| 20 | "net/http" |
| 21 | "os" |
| 22 | "path" |
| 23 | "slices" |
| 24 | "strings" |
| 25 | "sync" |
| 26 | "time" |
| 27 | |
| 28 | "github.com/carlosframework/platform/internal/ledger" |
| 29 | "github.com/carlosframework/platform/internal/release" |
| 30 | ) |
| 31 | |
| 32 | // ledgerPrefix is the one path prefix on the status host that is NOT the |
| 33 | // fixed three-object menu: open ledgers (internal/ledger) are served from |
| 34 | // here, path-for-key, gated per app by that app's opt-in manifest. |
| 35 | const ledgerPrefix = "/ledgers/" |
| 36 | |
| 37 | // statusCacheTTL is how long a fetched status object is trusted before the |
| 38 | // handler re-reads the store — mirrors staticRecheck's "trust briefly, then |
| 39 | // recheck" tradeoff, tuned loose because the backing data only changes every |
| 40 | // 5 minutes (PublishFleet's publish cadence per the design spec). |
| 41 | const statusCacheTTL = 60 * time.Second |
| 42 | |
| 43 | // statusRoutes is the fleet status page's fixed, allowlisted menu: the |
| 44 | // public path a browser requests, mapped to the bucket key PublishFleet |
| 45 | // wrote it under and the Content-Type to serve it with. Unlike static.go's |
| 46 | // staticSite (an arbitrary extracted file tree), the status route never |
| 47 | // serves anything outside this map — a request for any other path 404s, |
| 48 | // same as an unlisted key would on a real file server. |
| 49 | var statusRoutes = map[string]struct { |
| 50 | key string |
| 51 | ctype string |
| 52 | }{ |
| 53 | "/": {"status/index.html", "text/html; charset=utf-8"}, |
| 54 | "/fleet.json": {"status/fleet.json", "application/json"}, |
| 55 | "/status.css": {"status/status.css", "text/css; charset=utf-8"}, |
| 56 | } |
| 57 | |
| 58 | // statusCached is one fetched-and-cached bucket object. |
| 59 | type statusCached struct { |
| 60 | body []byte |
| 61 | ctype string |
| 62 | fetched time.Time |
| 63 | } |
| 64 | |
| 65 | // statusHandler serves the fleet status page's three files out of a |
| 66 | // release.Store, cached per key. The cache is the point of this handler, not |
| 67 | // an optimization bolted on afterward: the fleet status page exists so an |
| 68 | // operator can tell whether the fleet is healthy, which means IT must not go |
| 69 | // dark the instant the deployment bucket has a blip. So the contract is |
| 70 | // asymmetric by design — |
| 71 | // |
| 72 | // - inside the TTL: always serve the cached copy, even if the store has |
| 73 | // moved on (bounds bucket read volume/cost to once per key per TTL). |
| 74 | // - outside the TTL with a store error and a cached copy in hand: still |
| 75 | // serve the (now stale) cached copy rather than fail the request — the |
| 76 | // status page outliving a transient bucket error is the whole point; |
| 77 | // fleet.json's own staleness bands (internal/status/fleet.go) are what |
| 78 | // tell the operator the DATA is old, so this handler doesn't need to |
| 79 | // lie by omission (503) to be honest — the page itself says "stale". |
| 80 | // - outside the TTL with a store error and NO cached copy (nothing has |
| 81 | // ever been successfully fetched): 503. There is nothing honest left to |
| 82 | // serve. |
| 83 | // - one exception, for ledger objects only: outside the TTL with the |
| 84 | // object GONE (os.ErrNotExist), drop the cached copy and fail. For a |
| 85 | // ledger a deletion is the bucket's answer, not a blip: it is how an app |
| 86 | // purge reaches this cache, and without it the purged app's public |
| 87 | // ledger kept serving from every edge that had read it, to the end of |
| 88 | // the process (discussion #219, item 2). The status page's own files |
| 89 | // keep the stale-on-anything rule above; nothing deletes them on purpose. |
| 90 | type statusHandler struct { |
| 91 | store release.Store |
| 92 | // ttl and now are both overridable by tests (a tiny ttl plus a fake |
| 93 | // clock lets a TTL-expiry test run in microseconds instead of sleeping |
| 94 | // 60 real seconds); production always gets statusCacheTTL and time.Now. |
| 95 | ttl time.Duration |
| 96 | now func() time.Time |
| 97 | |
| 98 | mu sync.Mutex |
| 99 | cache map[string]statusCached |
| 100 | // misses is the negative half of the ledger gate: manifest key → when |
| 101 | // the "not servable" decision was taken. See markMiss. |
| 102 | misses map[string]time.Time |
| 103 | } |
| 104 | |
| 105 | // statusMissCacheMax bounds the negative cache. It exists because the |
| 106 | // positive cache's bound — "get only records an entry after a SUCCESSFUL |
| 107 | // store read, so only objects that exist can grow it" — is exactly the |
| 108 | // property a negative cache gives up: the prober picks the account and app |
| 109 | // segments, so the set of manifest keys they can name is unbounded. 1024 |
| 110 | // apps' worth of decisions is far more than any real deployment publishes |
| 111 | // and costs a few tens of KiB; past it the map is CLEARED rather than |
| 112 | // evicted one entry at a time, because there is no access pattern here worth |
| 113 | // approximating with an LRU — the honest read of a full map is "this is |
| 114 | // being probed", and a probe's entries are the ones worth dropping. |
| 115 | const statusMissCacheMax = 1024 |
| 116 | |
| 117 | // newStatusHandler builds the handler for one status route. store is shared |
| 118 | // with the static serving class (see EnableStatic's doc comment) — the |
| 119 | // status page has no per-route config of its own (no app, no channel), so |
| 120 | // unlike newStaticHandler this takes no registry.Route. |
| 121 | func newStatusHandler(store release.Store) *statusHandler { |
| 122 | return &statusHandler{ |
| 123 | store: store, |
| 124 | ttl: statusCacheTTL, |
| 125 | now: time.Now, |
| 126 | cache: make(map[string]statusCached), |
| 127 | misses: make(map[string]time.Time), |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | // missFresh reports whether the "not servable" decision for this manifest |
| 132 | // key was taken recently enough to reuse. |
| 133 | func (h *statusHandler) missFresh(key string) bool { |
| 134 | h.mu.Lock() |
| 135 | defer h.mu.Unlock() |
| 136 | at, ok := h.misses[key] |
| 137 | return ok && h.now().Sub(at) < h.ttl |
| 138 | } |
| 139 | |
| 140 | // markMiss records that an app is not servable — no manifest, an unparseable |
| 141 | // one, or public:false — for the same TTL the positive cache trusts a fetched |
| 142 | // object. Publishing an app therefore takes effect within TWO ticks worst |
| 143 | // case (~2×TTL), one typically: the two caches are stamped independently, so |
| 144 | // a public:false manifest read at t=0 can still be serving from the positive |
| 145 | // cache at t=59s and stamp a NEGATIVE entry then, which outlives it. Revoking |
| 146 | // has the same shape from the other side. Both are bounded and small; the |
| 147 | // alternative — invalidating across the two caches — would buy a second off a |
| 148 | // 5-minute-cadence page. |
| 149 | // |
| 150 | // The bound is the point: see statusMissCacheMax. |
| 151 | func (h *statusHandler) markMiss(key string) { |
| 152 | h.mu.Lock() |
| 153 | defer h.mu.Unlock() |
| 154 | if len(h.misses) >= statusMissCacheMax { |
| 155 | if _, ok := h.misses[key]; !ok { |
| 156 | clear(h.misses) |
| 157 | } |
| 158 | } |
| 159 | h.misses[key] = h.now() |
| 160 | } |
| 161 | |
| 162 | func (h *statusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| 163 | p := path.Clean(r.URL.Path) |
| 164 | if strings.HasPrefix(p, ledgerPrefix) { |
| 165 | h.serveLedger(w, r, p) |
| 166 | return |
| 167 | } |
| 168 | rt, ok := statusRoutes[p] |
| 169 | if !ok { |
| 170 | http.NotFound(w, r) |
| 171 | return |
| 172 | } |
| 173 | body, ctype, err := h.get(r.Context(), rt.key, rt.ctype) |
| 174 | if err != nil { |
| 175 | log.Printf("edge: status %s: %v", rt.key, err) |
| 176 | http.Error(w, "status page unavailable", http.StatusServiceUnavailable) |
| 177 | return |
| 178 | } |
| 179 | w.Header().Set("Content-Type", ctype) |
| 180 | w.Write(body) |
| 181 | } |
| 182 | |
| 183 | // serveLedger maps /ledgers/<sqid>/<app>/... 1:1 onto the bucket key — that |
| 184 | // identity is what lets `carlos ledger verify -url` walk a public chain over |
| 185 | // HTTPS with the very keys the store uses, no URL scheme to keep in sync. |
| 186 | // |
| 187 | // Everything here is fail-closed: the ONLY way to get a body out of this |
| 188 | // handler is an app whose manifest reads, parses, says public:true, and — |
| 189 | // for anything below the app level — names that ledger in its Ledgers list. |
| 190 | // No manifest, an unreadable one, an unparseable one, public:false, or a |
| 191 | // ledger the manifest doesn't name all end in the same plain 404 as an app |
| 192 | // that was never created: a caller must not be able to probe which apps |
| 193 | // exist, let alone which of their ledgers do. A missing object under a |
| 194 | // public app is a 404 too: the 503 "nothing honest left to serve" contract |
| 195 | // above belongs to the fleet page's own three objects, which are the page, |
| 196 | // not arbitrary reads. |
| 197 | // |
| 198 | // Manifest.Ledgers is read as an ALLOWLIST, not as a convenience index: an |
| 199 | // account accrues ledgers privately (the console writes chains whether or |
| 200 | // not anything is published), so "the app is public" cannot be allowed to |
| 201 | // mean "every chain it has ever written is public". Publishing is per |
| 202 | // ledger, by name; an empty list publishes nothing but the index itself. |
| 203 | // |
| 204 | // Ledger objects fall into two cache classes, because unlike the fleet |
| 205 | // page's three fixed objects a public ledger has unboundedly many: |
| 206 | // |
| 207 | // - ledgers.json and HEAD.json are MUTABLE and few — one manifest per |
| 208 | // public app, one HEAD per ledger — so they ride h.get's shared 60s |
| 209 | // cache. Revoking public therefore takes at most one TTL, and a cached |
| 210 | // public manifest keeps serving through a store outage exactly like any |
| 211 | // other cached object (get's stale-on-error half). That map cannot be |
| 212 | // grown by a stranger probing random sqids: get only writes an entry |
| 213 | // AFTER a successful store read (see its tail), so it is bounded by what |
| 214 | // actually exists. The REFUSALS a prober generates are remembered |
| 215 | // separately, in the explicitly bounded negative cache (markMiss), so a |
| 216 | // probe costs a bucket GET at most once per TTL per app rather than once |
| 217 | // per request. |
| 218 | // - entries/*.json and blobs/* are IMMUTABLE and unbounded in number |
| 219 | // (an entry per append, blobs up to ledger.MaxBlob each), so they are |
| 220 | // NOT cached in this process at all: a direct store read plus a |
| 221 | // year-long immutable Cache-Control, which puts the caching in browsers |
| 222 | // and CDNs where the memory isn't ours. That is deliberate: a public |
| 223 | // chain walker must not be able to pin arbitrary edge memory by simply |
| 224 | // reading every entry of a public ledger. |
| 225 | func (h *statusHandler) serveLedger(w http.ResponseWriter, r *http.Request, p string) { |
| 226 | // path.Clean above already collapsed dot and empty segments; re-check |
| 227 | // them anyway, because this is the one place where a public request |
| 228 | // chooses the bucket key and "the cleaner surely handled it" is how |
| 229 | // traversal bugs get written. Note ServeHTTP sees the DECODED path, so |
| 230 | // %2e%2e arrives here as ".." and dies on this check (or, once Clean |
| 231 | // has resolved it out of the /ledgers/ prefix entirely, never reaches |
| 232 | // this function at all). |
| 233 | parts := strings.Split(strings.TrimPrefix(p, "/"), "/") // ["ledgers", sqid, app, ...] |
| 234 | if len(parts) < 4 { |
| 235 | http.NotFound(w, r) |
| 236 | return |
| 237 | } |
| 238 | for _, seg := range parts { |
| 239 | if seg == "" || seg == "." || seg == ".." { |
| 240 | http.NotFound(w, r) |
| 241 | return |
| 242 | } |
| 243 | } |
| 244 | obj, ok := ledgerShape(parts[3:]) |
| 245 | if !ok { |
| 246 | http.NotFound(w, r) |
| 247 | return |
| 248 | } |
| 249 | sqid, app := parts[1], parts[2] |
| 250 | |
| 251 | // The negative half of the gate, checked BEFORE the store: a miss is |
| 252 | // never recorded by get (it only caches successful reads), and |
| 253 | // status-kind responses never enter the shared response cache, so |
| 254 | // without this every probe at a made-up account/app costs one bucket GET |
| 255 | // — and the prober writes the path. |
| 256 | mkey := ledger.ManifestKey(sqid, app) |
| 257 | if h.missFresh(mkey) { |
| 258 | http.NotFound(w, r) |
| 259 | return |
| 260 | } |
| 261 | mb, _, err := h.get(r.Context(), mkey, "application/json") |
| 262 | if err != nil { |
| 263 | // Only a genuine not-found is REMEMBERED. That is what the negative |
| 264 | // cache is for: a prober names manifests that do not exist, and the |
| 265 | // store answers os.ErrNotExist (release.Store's contract). Any other |
| 266 | // error is a blip — a throttle, an expired credential — and stamping |
| 267 | // it would take a live public app dark for the full TTL because the |
| 268 | // bucket hiccuped once while this key's positive cache happened to be |
| 269 | // cold. This request still 404s (fail-closed: nothing readable said |
| 270 | // public); the next one retries. |
| 271 | if errors.Is(err, os.ErrNotExist) { |
| 272 | h.markMiss(mkey) |
| 273 | } |
| 274 | http.NotFound(w, r) // fail-closed: no readable manifest = not public |
| 275 | return |
| 276 | } |
| 277 | var m ledger.Manifest |
| 278 | if err := json.Unmarshal(mb, &m); err != nil || !m.Public { |
| 279 | h.markMiss(mkey) |
| 280 | http.NotFound(w, r) |
| 281 | return |
| 282 | } |
| 283 | // obj.ledger is "" only for ledgers.json, the app's OWN published index, |
| 284 | // which the app is publishing by setting public:true. Everything else |
| 285 | // names a ledger and must be on the allowlist. |
| 286 | if obj.ledger != "" && !slices.Contains(m.Ledgers, obj.ledger) { |
| 287 | http.NotFound(w, r) |
| 288 | return |
| 289 | } |
| 290 | |
| 291 | key := strings.TrimPrefix(p, "/") |
| 292 | ctype := obj.ctype |
| 293 | // Sniffing is what would turn an opaque blob into something a browser |
| 294 | // executes in this origin's context; the ledger's bytes are arbitrary |
| 295 | // tenant content, so pin the declared type on every response, both |
| 296 | // classes. |
| 297 | w.Header().Set("X-Content-Type-Options", "nosniff") |
| 298 | |
| 299 | if obj.immutable { |
| 300 | // Uncached class: straight to the store, and let the caller cache |
| 301 | // it forever — an entry key commits to its own bytes (the ts-sha |
| 302 | // key) and a blob key IS its sha256, so neither can ever change |
| 303 | // meaning. A store error here is the same plain 404 as a key that |
| 304 | // was never written; nothing is logged, for the same reason the |
| 305 | // rest of this handler logs nothing. |
| 306 | body, _, err := h.store.Get(r.Context(), key) |
| 307 | if err != nil { |
| 308 | http.NotFound(w, r) |
| 309 | return |
| 310 | } |
| 311 | w.Header().Set("Content-Type", ctype) |
| 312 | w.Header().Set("Cache-Control", ledgerImmutableCacheControl) |
| 313 | w.Write(body) |
| 314 | return |
| 315 | } |
| 316 | |
| 317 | body, ctype, err := h.get(r.Context(), key, ctype) |
| 318 | if err != nil { |
| 319 | http.NotFound(w, r) |
| 320 | return |
| 321 | } |
| 322 | w.Header().Set("Content-Type", ctype) |
| 323 | w.Write(body) |
| 324 | } |
| 325 | |
| 326 | // ledgerImmutableCacheControl is what entries and blobs are served with: a |
| 327 | // year, immutable. Content-addressed keys make that safe, and it is what |
| 328 | // keeps the caching of an unbounded object set in browsers and CDNs instead |
| 329 | // of this process's memory. ledgers.json and HEAD.json deliberately get NO |
| 330 | // Cache-Control — they are the mutable head of the chain and their freshness |
| 331 | // is the 60s server-side TTL's job, not a client's. |
| 332 | const ledgerImmutableCacheControl = "public, max-age=31536000, immutable" |
| 333 | |
| 334 | // ledgerObject is everything the path shape alone decides about a servable |
| 335 | // ledger object — before any manifest is consulted. |
| 336 | type ledgerObject struct { |
| 337 | // ctype comes from the SHAPE, never from the key's suffix: a blob is |
| 338 | // named by its sha256 and a tenant may well upload one whose sha ends |
| 339 | // ".json". Blobs are opaque bytes, and the shape is what knows that. |
| 340 | ctype string |
| 341 | // immutable selects the cache class documented on serveLedger. |
| 342 | immutable bool |
| 343 | // ledger is the ledger name this object belongs to, or "" for the |
| 344 | // app-level index (ledgers.json) — the allowlist gate keys off it. |
| 345 | ledger string |
| 346 | } |
| 347 | |
| 348 | // ledgerShape is the object-shape allowlist inside one app's ledger subtree, |
| 349 | // given the path segments AFTER ledgers/<sqid>/<app>/. The ledger primitive |
| 350 | // writes exactly four shapes (internal/ledger/ledger.go's key builders) and |
| 351 | // the edge publishes exactly those: living under a public app's prefix is |
| 352 | // not, by itself, a reason to hand an object to the internet. Same allowlist |
| 353 | // spirit as statusRoutes, one level down. One switch decides shape, content |
| 354 | // type, cache class and owning ledger together, so those four cannot drift |
| 355 | // apart as shapes are added. |
| 356 | func ledgerShape(rest []string) (ledgerObject, bool) { |
| 357 | const jsonCT, blobCT = "application/json", "application/octet-stream" |
| 358 | switch { |
| 359 | case len(rest) == 1 && rest[0] == "ledgers.json": // ledgers/<sqid>/<app>/ledgers.json |
| 360 | return ledgerObject{ctype: jsonCT}, true |
| 361 | case len(rest) == 2 && rest[1] == "HEAD.json": // .../<ledger>/HEAD.json |
| 362 | return ledgerObject{ctype: jsonCT, ledger: rest[0]}, true |
| 363 | case len(rest) == 3 && rest[1] == "entries" && strings.HasSuffix(rest[2], ".json"): |
| 364 | return ledgerObject{ctype: jsonCT, immutable: true, ledger: rest[0]}, true |
| 365 | case len(rest) == 3 && rest[1] == "blobs": // .../<ledger>/blobs/<sha> |
| 366 | return ledgerObject{ctype: blobCT, immutable: true, ledger: rest[0]}, true |
| 367 | } |
| 368 | return ledgerObject{}, false |
| 369 | } |
| 370 | |
| 371 | // get implements the cache contract documented on statusHandler: fresh cache |
| 372 | // hit wins outright (no store touch at all — that's what bounds read |
| 373 | // volume); otherwise it tries the store, falling back to a stale cached copy |
| 374 | // on error, and only fails when there is nothing cached to fall back to or |
| 375 | // a ledger object is gone. |
| 376 | func (h *statusHandler) get(ctx context.Context, key, ctype string) ([]byte, string, error) { |
| 377 | h.mu.Lock() |
| 378 | c, cached := h.cache[key] |
| 379 | fresh := cached && h.now().Sub(c.fetched) < h.ttl |
| 380 | h.mu.Unlock() |
| 381 | if fresh { |
| 382 | return c.body, c.ctype, nil |
| 383 | } |
| 384 | |
| 385 | body, _, err := h.store.Get(ctx, key) |
| 386 | if err != nil { |
| 387 | if errors.Is(err, os.ErrNotExist) && strings.HasPrefix(key, "ledgers/") { |
| 388 | if cached { |
| 389 | h.mu.Lock() |
| 390 | delete(h.cache, key) |
| 391 | h.mu.Unlock() |
| 392 | } |
| 393 | return nil, "", err |
| 394 | } |
| 395 | if cached { |
| 396 | log.Printf("edge: status %s: store read failed outside TTL (%v) — serving stale copy fetched at %s", |
| 397 | key, err, c.fetched.Format(time.RFC3339)) |
| 398 | return c.body, c.ctype, nil |
| 399 | } |
| 400 | return nil, "", err |
| 401 | } |
| 402 | |
| 403 | entry := statusCached{body: body, ctype: ctype, fetched: h.now()} |
| 404 | h.mu.Lock() |
| 405 | h.cache[key] = entry |
| 406 | h.mu.Unlock() |
| 407 | return body, ctype, nil |
| 408 | } |
| 409 | |