fix: resolve the public status page's tenant from a trusted X-Forwarded-Host

The SSR fetch set `Host` to the visitor's hostname. `Host` is a forbidden
header name and undici discards it silently, so the Go server saw
`server:8080`, `hostSlug` returned "", `InstanceFromHost` returned false and
every public status page 404'd on every deployment. The feature did not work.

- `web/` now forwards the visitor's host as `X-Forwarded-Host`, and their
  address on `X-Forwarded-For` — without the latter gin sees a request from the
  Next pod with no XFF and every visitor of every page shares one 120/min
  bucket, tripped by exactly the traffic an outage produces.
- `publicStatusInstance` honours `X-Forwarded-Host` only when `c.RemoteIP()` is
  in `TRUSTED_PROXIES`. It is a tenant selector, so an untrusted peer must not
  be able to name one; `RemoteIP()` rather than `ClientIP()` because the latter
  is reconstructed from the very headers being judged. `TrustedProxies()` moves
  from main.go into the api package so the variable keeps one parser.
- A host naming no slug on a non-cloud deployment resolves the sole instance,
  the way bootstrap does. A self-hosted install at vantage.acme.com or an IP
  has no slug and could never serve a status page; more than one instance is a
  404 rather than a guess, and an unknown-but-well-formed slug stays a 404.
- `InstanceFromHost` gains an explicit-host variant rather than a second copy
  of the slug rules, and now caches negative lookups: an unknown host cost a
  Mongo query per anonymous request, which is also a timing oracle separating
  "no such instance" from "instance exists, page does not".
- The handler's `@Router` annotation is dropped. openapi.json declares one
  server of `/api`, so it published `/api/public/status/{pageId}` — a path that
  does not exist. The real address is described in prose instead.
This commit is contained in:
2026-08-25 09:04:47 +00:00
parent 72c9492223
commit da1dc90ac5
6 changed files with 276 additions and 97 deletions
+67 -10
View File
@@ -23,6 +23,10 @@ var (
const instanceCacheTTL = 60 * time.Second
// soleInstanceCacheKey cannot collide with a slug: a slug is [a-z0-9-] and can
// never contain a NUL.
const soleInstanceCacheKey = "\x00sole"
func appRootLabel() string {
if v := os.Getenv("APP_ROOT_LABEL"); v != "" {
return strings.ToLower(v)
@@ -50,25 +54,78 @@ func hostSlug(host string) string {
return parts[0]
}
// HostSlug exposes the slug rules to callers outside this package that need to
// distinguish "this host names no instance at all" from "this host names an
// instance that does not exist". It is a thin wrapper rather than a second
// implementation on purpose.
func HostSlug(host string) string { return hostSlug(host) }
// InstanceFromHost resolves the instance named by the request's own Host
// header. Callers that must resolve a host from somewhere else — the public
// status page reads a trusted X-Forwarded-Host — use InstanceForHost so the
// slug rules and the 60s cache stay single-implementation.
func InstanceFromHost(c *gin.Context) (*models.Instance, bool) {
slug := hostSlug(c.Request.Host)
return InstanceForHost(c.Request.Host)
}
// InstanceForHost is InstanceFromHost with the host supplied explicitly.
func InstanceForHost(host string) (*models.Instance, bool) {
slug := hostSlug(host)
if slug == "" {
return nil, false
}
instanceCacheMu.Lock()
if e, ok := instanceCache[slug]; ok && time.Since(e.at) < instanceCacheTTL {
instanceCacheMu.Unlock()
return e.instance, e.instance != nil
if inst, hit := cachedInstanceFor(slug); hit {
return inst, inst != nil
}
instanceCacheMu.Unlock()
inst, err := services.GetInstanceBySlug(slug)
if err != nil || inst == nil {
// Negative entries are cached too. Without them an unknown but
// well-formed host costs a Mongo query per anonymous request, which
// the public status page exposes to the open internet — and the
// round trip is itself a timing oracle separating "no such instance"
// from "instance exists, page does not".
storeInstance(slug, nil)
return nil, false
}
instanceCacheMu.Lock()
instanceCache[slug] = cachedInstance{instance: inst, at: time.Now()}
instanceCacheMu.Unlock()
storeInstance(slug, inst)
return inst, true
}
// SoleInstance resolves the one instance of a deployment that has exactly one.
// It is how a self-hosted install serves a host that names no slug at all —
// vantage.acme.com, status.acme.com, or a bare address. It reuses the same
// count-then-read that bootstrap uses, and refuses rather than guessing when
// more than one instance exists.
func SoleInstance() (*models.Instance, bool) {
if inst, hit := cachedInstanceFor(soleInstanceCacheKey); hit {
return inst, inst != nil
}
n, err := services.CountInstances()
if err != nil || n != 1 {
storeInstance(soleInstanceCacheKey, nil)
return nil, false
}
inst, err := services.FirstInstance()
if err != nil || inst == nil {
storeInstance(soleInstanceCacheKey, nil)
return nil, false
}
storeInstance(soleInstanceCacheKey, inst)
return inst, true
}
func cachedInstanceFor(key string) (*models.Instance, bool) {
instanceCacheMu.Lock()
defer instanceCacheMu.Unlock()
if e, ok := instanceCache[key]; ok && time.Since(e.at) < instanceCacheTTL {
return e.instance, true
}
return nil, false
}
func storeInstance(key string, inst *models.Instance) {
instanceCacheMu.Lock()
instanceCache[key] = cachedInstance{instance: inst, at: time.Now()}
instanceCacheMu.Unlock()
}