d62cc08b2e
gates / gates (push) Successful in 6m44s
- 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
223 lines
9.8 KiB
Go
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
|
|
}
|