Files
felhom.eu/hub/internal/web/routes.go
T
admin d62cc08b2e
gates / gates (push) Successful in 6m44s
hub v0.152.0: operator access tokens with scopes (09 §3 decision 251, operator 2026-10-15 11:38)
- Configuration → Access tokens: named, scoped (group read/write), expiring (≤365 d), shown once,
  stored SHA-256 only, every use logged by name. Bearer fhp_… on the web surface only; /api/v1 refuses it.
- Route table = the code's single source (web/routes.go), pinned to ServeHTTP's switch; session-only:
  secrets shown/minted, deletes/resets, password, tokens.
- R-952 closed: /api/v1 route table names each caller, pinned to the switch; no-credential refusal test.
- Fail-open fixed: an empty operator password served the UI open (main skipped RequireAuth). Live hub never open.
- 05 §17, 04 §6.2, 13, 09 §3 251; register 155→157 (+R-1054, R-1055, R-1056, −R-952); STATUS.
- website: prowlarr-logo.svg (byte copy from felhom.eu-media) — the site gate refused every push.
Not deployed — waits for the operator's yes.

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-15 12:20:35 +02:00

223 lines
9.8 KiB
Go

package web
import "strings"
// The operator route table (hub v0.152.0, `05` §17). EVERY case of ServeHTTP's route switch has one entry here,
// in the SAME ORDER and with the SAME match (exact / prefix / suffix / contains), and the classifier walks the
// entries first-match-wins exactly as the switch does — so the class a request gets is the class of the handler
// that serves it. TestRouteTable_MatchesServeHTTPSwitch reads the switch from server.go's syntax tree and fails
// when a case is added, removed, reordered or changed without this table following (R-952, web side).
//
// What a class means for an ACCESS TOKEN (a session and Basic+header stay full operator, unchanged):
// - sessionOnly — never by any token: a secret is shown or minted, something is deleted or reset, the password or
// the tokens themselves change, or no group fits (listed in `05` §17 for the operator to place).
// - anyToken — any valid token (static assets of the page itself).
// - exempt — not an operator route at all (/login, the public /bind/); the token is not consulted.
// - a group + read/write — the token needs "<group>:<mode>" (write includes read).
//
// GET and HEAD use the entry's get class; every other method uses post. A method the entry does not name is
// sessionOnly — the fail-safe direction, and the handler answers 405 to it anyway.
// Token scope groups. The keys are stored in api_tokens.scopes; the labels are the Configuration card's words.
const (
grpOverview = "overview"
grpFloor = "floor"
grpVouch = "vouch"
grpCustomers = "customers"
grpHosts = "hosts"
grpOffsite = "offsite"
grpOS = "os"
grpMailHold = "mailhold"
)
// scopeGroup is one row of the Configuration card. Read/Write say which ticks it offers: a tick is offered only when
// it opens at least one route (TestScopeGroups_EveryOfferedTickOpensARoute).
type scopeGroup struct {
Key, Label, ReadHelp, WriteHelp string
Read, Write bool
}
var scopeGroups = []scopeGroup{
{grpOverview, "Overview", "dashboard, customer list, hosts list, apps, off-site, system (no secrets)", "", true, false},
{grpFloor, "Releases: floor", "the floors (Configuration page, floor impact)", "the global and the per-customer floor", true, true},
{grpVouch, "Releases: vouch", "", "golden, agent, MinAgent (the vouch)", false, true},
{grpCustomers, "Customers", "config forms, DR recipe", "create, edit, block/unblock, geo, resend claim", true, true},
{grpHosts, "Hosts & logs", "host pages, log bundles, log tails", "operator actions, log requests", true, true},
{grpOffsite, "Off-site", "run the key check", "windows, re-issue, freeze", true, true},
{grpOS, "OS updates", "the fleet queue", "approve, ring, on/off", true, true},
{grpMailHold, "Mail hold", "", "release", false, true},
}
type routeClass struct {
Group string // "" unless group-scoped
Write bool
SessionOnly bool
AnyToken bool
Exempt bool
}
var (
sessionOnly = routeClass{SessionOnly: true}
anyToken = routeClass{AnyToken: true}
exempt = routeClass{Exempt: true}
)
func rd(g string) routeClass { return routeClass{Group: g} }
func wr(g string) routeClass { return routeClass{Group: g, Write: true} }
// scope is the "<group>:<mode>" string a group-scoped class needs.
func (c routeClass) scope() string {
if c.Write {
return c.Group + ":write"
}
return c.Group + ":read"
}
// routeEntry mirrors one disjunct of one switch case. bindFn marks the isPublicBindPath case.
type routeEntry struct {
Exact, Prefix, Suffix, Contains string
BindFn bool
Get, Post routeClass
}
func (e routeEntry) matches(path string) bool {
if e.BindFn {
return isPublicBindPath(path)
}
if e.Exact != "" && path != e.Exact {
return false
}
if e.Prefix != "" && !strings.HasPrefix(path, e.Prefix) {
return false
}
if e.Suffix != "" && !strings.HasSuffix(path, e.Suffix) {
return false
}
if e.Contains != "" && !strings.Contains(path, e.Contains) {
return false
}
return true
}
var none = routeClass{} // zero: the method is not served → treated as sessionOnly
// operatorRoutes is the table. Order and match are pinned to server.go's switch by the test named above.
var operatorRoutes = []routeEntry{
{Exact: "/", Get: rd(grpOverview)},
{Exact: "/style.css", Get: anyToken},
{Exact: "/static/chart.min.js", Get: anyToken},
{Prefix: "/static/fonts/", Get: anyToken},
// The Configuration page shows the floors; its POST is the asset re-seed, which fits no group.
{Exact: "/configuration", Get: rd(grpFloor), Post: sessionOnly},
{Exact: "/apps", Get: rd(grpOverview)},
{Exact: "/apps/", Get: rd(grpOverview)},
// Telemetry reset and issue dismissal change the fleet view; no group fits them.
{Prefix: "/apps/", Suffix: "/reset-telemetry", Post: sessionOnly},
{Prefix: "/apps/", Suffix: "/dismiss-issues", Post: sessionOnly},
{Prefix: "/apps/", Get: rd(grpOverview)},
{Exact: "/offsite", Get: rd(grpOverview)},
// The WireGuard endpoint record is infrastructure; no group fits a save.
{Exact: "/offsite/endpoints", Post: sessionOnly},
{Prefix: "/offsite/endpoints/", Suffix: "/delete", Post: sessionOnly},
{Exact: "/hosts", Get: rd(grpOverview)},
{Exact: "/hosts/", Get: rd(grpOverview)},
{Exact: "/system", Get: rd(grpOverview)},
// A bind delivers the household's retrieval passphrase to the box that asks — a secret delivery.
{Prefix: "/appliances/", Suffix: "/bind", Post: sessionOnly},
{Prefix: "/appliances/", Suffix: "/discard", Post: sessionOnly},
{Prefix: "/hosts/", Suffix: "/delete-impact", Get: sessionOnly},
{Prefix: "/hosts/", Suffix: "/delete", Post: sessionOnly},
{Prefix: "/hosts/", Suffix: "/reveal-recovery-credential", Post: sessionOnly},
{Prefix: "/hosts/", Suffix: "/operator-action", Post: wr(grpHosts)},
{Prefix: "/hosts/", Suffix: "/request-logs", Post: wr(grpHosts)},
{Prefix: "/hosts/", Contains: "/log-bundles/", Get: rd(grpHosts)},
{Prefix: "/hosts/", Get: rd(grpHosts)},
{Exact: "/login", Get: exempt, Post: exempt},
{BindFn: true, Get: exempt, Post: exempt},
{Prefix: "/customers/", Suffix: "/block", Post: wr(grpCustomers)},
// The self-bind link is a capability that binds a box to the household — minted here.
{Prefix: "/customers/", Suffix: "/selfbind-link", Post: sessionOnly},
{Prefix: "/customers/", Suffix: "/unblock", Post: wr(grpCustomers)},
{Prefix: "/customers/", Suffix: "/geo/disable", Post: wr(grpCustomers)},
{Prefix: "/customers/", Suffix: "/floor", Post: wr(grpFloor)},
{Prefix: "/customers/", Suffix: "/create-config", Post: wr(grpCustomers)},
{Prefix: "/customers/", Suffix: "/dr-recipe.json", Get: rd(grpCustomers)},
{Prefix: "/customers/", Suffix: "/request-log-tail", Post: wr(grpHosts)},
{Prefix: "/customers/", Contains: "/log-tail/", Get: rd(grpHosts)},
// The customer page embeds the owner passphrase and the controller API key (customer_unified.html).
{Prefix: "/customers/", Get: sessionOnly, Post: sessionOnly},
{Exact: "/configs", Get: rd(grpOverview)},
{Exact: "/configs/new", Get: rd(grpCustomers), Post: wr(grpCustomers)},
{Exact: "/configuration/global-floor/impact", Get: rd(grpFloor)},
{Exact: "/configuration/global-floor", Post: wr(grpFloor)},
{Exact: "/configuration/artifacts", Post: wr(grpVouch)},
{Exact: "/configuration/mail-hold/release", Post: wr(grpMailHold)},
{Exact: "/configuration/password", Post: sessionOnly},
{Exact: "/configuration/tokens", Post: sessionOnly},
{Prefix: "/configuration/tokens/", Suffix: "/revoke", Post: sessionOnly},
{Prefix: "/configs/", Suffix: "/delete", Get: sessionOnly, Post: sessionOnly},
{Prefix: "/configs/", Suffix: "/edit", Get: rd(grpCustomers), Post: wr(grpCustomers)},
{Prefix: "/offsite/remove-unpinned/", Post: sessionOnly},
// Cancelling a pending set-aside deletion: no group names it; until the operator places it, session only.
{Prefix: "/offsite/abandon-cancel/", Post: sessionOnly},
{Prefix: "/offsite/window-grant/", Post: wr(grpOffsite)},
{Exact: "/offsite/windows-enabled", Post: wr(grpOffsite)},
{Prefix: "/os/", Get: rd(grpOS), Post: wr(grpOS)},
// Runs the daily key check now: it reads keys and reports, it changes none.
{Exact: "/offsite/key-audit", Post: rd(grpOffsite)},
{Prefix: "/configs/", Suffix: "/offsite-reissue", Post: wr(grpOffsite)},
{Prefix: "/configs/", Suffix: "/claim-resend", Post: wr(grpCustomers)},
{Prefix: "/configs/", Suffix: "/pbsdr-reissue", Post: wr(grpOffsite)},
{Prefix: "/configs/", Suffix: "/offsite-freeze", Post: wr(grpOffsite)},
// The preview is the generated controller config — it carries the controller API key.
{Prefix: "/configs/", Suffix: "/preview", Get: sessionOnly},
{Prefix: "/configs/", Suffix: "/regen-password", Post: sessionOnly},
{Prefix: "/configs/", Suffix: "/reset", Get: sessionOnly, Post: sessionOnly},
{Prefix: "/configs/", Get: rd(grpOverview), Post: rd(grpOverview)},
}
// classifyRoute returns the class of the handler that serves (method, path). A path no entry matches is the
// switch's 404 — sessionOnly, so an unknown path never answers a token differently from a missing scope.
func classifyRoute(method, path string) routeClass {
for _, e := range operatorRoutes {
if !e.matches(path) {
continue
}
c := e.Post
if method == "GET" || method == "HEAD" {
c = e.Get
}
if c == none {
return sessionOnly
}
return c
}
return sessionOnly
}
// validScope reports whether s is a scope the Configuration card offers.
func validScope(s string) bool {
for _, g := range scopeGroups {
if (g.Read && s == g.Key+":read") || (g.Write && s == g.Key+":write") {
return true
}
}
return false
}
// tokenAllows reports whether a token holding scopes may use class c. write includes read.
func tokenAllows(scopes []string, c routeClass) bool {
switch {
case c.Exempt, c.AnyToken:
return true
case c.SessionOnly, c.Group == "":
return false
}
for _, s := range scopes {
if s == c.scope() || (!c.Write && s == c.Group+":write") {
return true
}
}
return false
}