hub v0.152.0: operator access tokens with scopes (09 §3 decision 251, operator 2026-10-15 11:38)
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
This commit is contained in:
admin committed 2026-10-15 12:20:35 +02:00
1 parent e92501f5a1
commit d62cc08b2e
37 files changed
+2567 -21

No files matched your search

+9
View File
@@ -245,6 +245,15 @@ func (h *Handler) requireGlobalKey(w http.ResponseWriter, r *http.Request) bool
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/v1")
// An operator access token (`05` §17, prefix fhp_) never reaches the box API: not a box route, not a global-key
// route. Refused here, before any handler, so no route can accept one by accident. Pinned by
// TestAPI_AccessTokenIsRefusedEverywhere.
if auth := r.Header.Get("Authorization"); strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(auth, "Bearer ")), store.APITokenPrefix) {
h.logger.Printf("[WARN] access token refused on the box API: %s %s", r.Method, r.URL.Path)
http.Error(w, "Forbidden: access tokens are not accepted on /api/v1", http.StatusForbidden)
return
}
switch {
case r.Method == http.MethodPost && path == "/report":
h.handleReport(w, r)
+83
View File
@@ -0,0 +1,83 @@
package api
// The box-API route table (hub v0.152.0, R-952, `05` §17.6). EVERY case of ServeHTTP's route switch has one entry,
// in the same order and with the same match, naming WHO may call it. TestAPIRouteTable_MatchesSwitch reads the
// switch from handler.go's syntax tree and fails on a route added without an entry here; TestAPIRoutes_NoCredential
// sends every non-public route a request with no credential and requires a refusal. The table documents and pins;
// each handler still does its own check (the table is not consulted at run time).
//
// An operator access token (fhp_…) is refused on EVERY route here, before the switch (handler.go ServeHTTP).
type apiAuth string
const (
authPublic apiAuth = "public" // no credential by design — the reason is on the entry
authCustomer apiAuth = "customer" // the household's controller key (or the global key)
authHost apiAuth = "host" // the box's own host key, self-scoped (or the global key)
authGlobal apiAuth = "global" // the operator/global key only
authPassphrase apiAuth = "passphrase" // X-Retrieval-Password of that household
authAppliance apiAuth = "appliance" // the appliance's own bearer token
authRecovery apiAuth = "recovery" // only while the operator has armed recovery mode for the host
)
type apiRoute struct {
Method string
Exact, Prefix, Suffix, Contains string
Auth apiAuth
Why string // for authPublic
}
var apiRoutes = []apiRoute{
{Method: "POST", Exact: "/report", Auth: authCustomer},
{Method: "GET", Exact: "/wait", Auth: authCustomer},
{Method: "POST", Exact: "/appliance/register", Auth: authPublic, Why: "a secret-free ISO box registers itself; per-IP rate-limited, answers no enumeration"},
{Method: "GET", Exact: "/appliance/poll", Auth: authAppliance},
{Method: "POST", Exact: "/host-report", Auth: authHost},
{Method: "POST", Exact: "/host-enroll", Auth: authPassphrase},
{Method: "POST", Exact: "/admin/hosts", Auth: authGlobal},
{Method: "PUT", Prefix: "/hosts/", Suffix: "/escrow", Auth: authHost},
{Method: "GET", Prefix: "/hosts/", Suffix: "/escrow", Auth: authHost},
{Method: "GET", Prefix: "/hosts/", Suffix: "/escrow/retained", Auth: authHost},
{Method: "PUT", Prefix: "/hosts/", Suffix: "/recovery-credential", Auth: authHost},
{Method: "GET", Prefix: "/admin/hosts/", Suffix: "/recovery-credential", Auth: authGlobal},
{Method: "PUT", Prefix: "/admin/hosts/", Suffix: "/recovery-mode", Auth: authGlobal},
{Method: "DELETE", Prefix: "/admin/hosts/", Suffix: "/recovery-mode", Auth: authGlobal},
{Method: "POST", Prefix: "/hosts/", Suffix: "/re-enroll", Auth: authRecovery},
{Method: "GET", Prefix: "/hosts/", Suffix: "/restore-directive", Auth: authHost},
{Method: "POST", Prefix: "/hosts/", Suffix: "/wg", Auth: authHost},
{Method: "POST", Prefix: "/hosts/", Suffix: "/pbs/consume-token", Auth: authHost},
{Method: "POST", Prefix: "/hosts/", Suffix: "/os-report", Auth: authHost},
{Method: "GET", Prefix: "/hosts/", Suffix: "/desired-state", Auth: authHost},
{Method: "GET", Prefix: "/hosts/", Suffix: "/jobs", Auth: authHost},
{Method: "DELETE", Prefix: "/hosts/", Contains: "/jobs/", Auth: authHost},
{Method: "PUT", Prefix: "/admin/hosts/", Suffix: "/desired-state", Auth: authGlobal},
{Method: "POST", Prefix: "/admin/hosts/", Suffix: "/jobs", Auth: authGlobal},
{Method: "PUT", Exact: "/admin/wg/endpoint", Auth: authGlobal},
{Method: "GET", Exact: "/admin/wg/endpoint", Auth: authGlobal},
{Method: "POST", Exact: "/admin/wg/peers", Auth: authGlobal},
{Method: "DELETE", Exact: "/admin/wg/peers", Auth: authGlobal},
{Method: "GET", Exact: "/admin/wg/peers", Auth: authGlobal},
{Method: "PUT", Exact: "/admin/wg/operator-peer", Auth: authGlobal},
{Method: "GET", Exact: "/admin/wg/operator-peer", Auth: authGlobal},
{Method: "POST", Exact: "/claim/reset-request", Auth: authCustomer},
{Method: "POST", Exact: "/event", Auth: authCustomer},
{Method: "POST", Exact: "/mail", Auth: authCustomer},
{Method: "POST", Exact: "/notify", Auth: authCustomer},
{Method: "POST", Exact: "/preferences", Auth: authCustomer},
{Method: "GET", Exact: "/customers", Auth: authGlobal},
{Method: "GET", Prefix: "/customers/", Auth: authGlobal},
{Method: "GET", Prefix: "/recovery/", Auth: authPassphrase},
{Method: "GET", Prefix: "/config/", Auth: authPassphrase},
{Method: "POST", Prefix: "/offsite/consume-password/", Auth: authCustomer},
{Method: "POST", Prefix: "/offsite/register-key/", Auth: authCustomer},
{Method: "POST", Prefix: "/offsite/confirm-key/", Auth: authCustomer},
{Method: "POST", Prefix: "/offsite/move-aside/", Auth: authCustomer},
{Method: "POST", Prefix: "/offsite/window-open/", Auth: authCustomer},
{Method: "POST", Prefix: "/offsite/window-close/", Auth: authCustomer},
{Method: "POST", Prefix: "/offsite/abandon-request/", Auth: authCustomer},
{Method: "POST", Prefix: "/offsite/abandon-cancel/", Auth: authCustomer},
{Method: "POST", Prefix: "/offsite/abandon-status/", Auth: authCustomer},
{Method: "GET", Prefix: "/artifacts/", Auth: authPassphrase},
{Method: "GET", Exact: "/assets/manifest", Auth: authCustomer},
{Method: "GET", Prefix: "/assets/file/", Auth: authCustomer},
}
+177
View File
@@ -0,0 +1,177 @@
package api
import (
"go/ast"
"go/parser"
"go/token"
"net/http"
"strconv"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// apiSwitchRoutes reads ServeHTTP's route switch from handler.go: one apiRoute (method + match) per case, in order.
func apiSwitchRoutes(t *testing.T) []apiRoute {
t.Helper()
f, err := parser.ParseFile(token.NewFileSet(), "handler.go", nil, 0)
if err != nil {
t.Fatal(err)
}
var sw *ast.SwitchStmt
for _, d := range f.Decls {
if fd, ok := d.(*ast.FuncDecl); ok && fd.Name.Name == "ServeHTTP" && fd.Recv != nil {
for _, st := range fd.Body.List {
if s, ok := st.(*ast.SwitchStmt); ok && s.Tag == nil {
sw = s
break
}
}
}
}
if sw == nil {
t.Fatal("ServeHTTP route switch not found in handler.go")
}
lit := func(e ast.Expr) string {
bl, ok := e.(*ast.BasicLit)
if !ok {
t.Fatalf("route predicate argument is not a literal at %v", e.Pos())
}
v, _ := strconv.Unquote(bl.Value)
return v
}
var conj func(e ast.Expr) []ast.Expr
conj = func(e ast.Expr) []ast.Expr {
if b, ok := e.(*ast.BinaryExpr); ok && b.Op == token.LAND {
return append(conj(b.X), conj(b.Y)...)
}
return []ast.Expr{e}
}
var out []apiRoute
for _, c := range sw.Body.List {
cc := c.(*ast.CaseClause)
if cc.List == nil {
continue // default: 404
}
if len(cc.List) != 1 {
t.Fatalf("case at %v has %d expressions; one route per case", cc.Pos(), len(cc.List))
}
var r apiRoute
for _, p := range conj(cc.List[0]) {
switch x := p.(type) {
case *ast.BinaryExpr:
if x.Op != token.EQL {
t.Fatalf("unrecognised predicate at %v", x.Pos())
}
if sel, ok := x.Y.(*ast.SelectorExpr); ok && strings.HasPrefix(sel.Sel.Name, "Method") {
r.Method = strings.ToUpper(strings.TrimPrefix(sel.Sel.Name, "Method"))
} else {
r.Exact = lit(x.Y)
}
case *ast.CallExpr:
switch x.Fun.(*ast.SelectorExpr).Sel.Name {
case "HasPrefix":
r.Prefix = lit(x.Args[1])
case "HasSuffix":
r.Suffix = lit(x.Args[1])
case "Contains":
r.Contains = lit(x.Args[1])
default:
t.Fatalf("unrecognised predicate at %v", x.Pos())
}
default:
t.Fatalf("unrecognised predicate at %v", p.Pos())
}
}
out = append(out, r)
}
return out
}
// TestAPIRouteTable_MatchesSwitch is R-952 for /api/v1: a route added to the switch without deciding who may call it
// fails here. RED-PROOF: delete the GET /customers entry from apiRoutes → FAIL at index 36.
func TestAPIRouteTable_MatchesSwitch(t *testing.T) {
sw := apiSwitchRoutes(t)
if len(sw) < 40 {
t.Fatalf("parsed only %d routes — the parser lost the switch", len(sw))
}
n := len(sw)
if len(apiRoutes) > n {
n = len(apiRoutes)
}
for i := 0; i < n; i++ {
var a, b apiRoute
if i < len(sw) {
a = sw[i]
}
if i < len(apiRoutes) {
b = apiRoutes[i]
b.Auth, b.Why = "", ""
if apiRoutes[i].Auth == "" {
t.Errorf("route %d has no Auth class", i)
}
if apiRoutes[i].Auth == authPublic && apiRoutes[i].Why == "" {
t.Errorf("public route %d has no reason", i)
}
}
if a != b {
t.Fatalf("route %d: switch has %+v, table has %+v — classify it in routes.go in the switch's order", i, a, b)
}
}
}
func apiSample(r apiRoute) string {
if r.Exact != "" {
return r.Exact
}
id := "c1"
if strings.Contains(r.Prefix, "hosts/") {
id = "h1"
}
p := r.Prefix + id
if r.Contains != "" {
p += r.Contains + "j1"
}
return p + r.Suffix
}
// TestAPIRoutes_NoCredential: every non-public route, called with no credential for a customer and a host that
// EXIST (so a 404 cannot stand in for a refusal), answers 401 or 403.
// RED-PROOF: make requireGlobalKey return true → GET /customers answers 200.
func TestAPIRoutes_NoCredential(t *testing.T) {
h, st, _ := newTestHandler(t)
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pass-phrase"})
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
n := 0
for _, r := range apiRoutes {
if r.Auth == authPublic {
continue
}
n++
// A body naming the existing household and host, so a route that reads its subject from the body
// reaches its credential check instead of a 400.
rr := do(h, r.Method, apiSample(r), "", `{"customer_id":"c1","host_id":"h1"}`)
if rr.Code != http.StatusUnauthorized && rr.Code != http.StatusForbidden {
t.Errorf("%s %s (%s) with no credential: %d %q, want 401/403", r.Method, apiSample(r), r.Auth, rr.Code, strings.TrimSpace(rr.Body.String()))
}
}
if n < 40 {
t.Fatalf("only %d routes checked", n)
}
}
// TestAPI_AccessTokenIsRefusedEverywhere: an operator access token reaches no box-API route — not a box route, not a
// global-key route, not the public one. RED-PROOF: remove the fhp_ check in ServeHTTP → the public register route
// answers something other than 403.
func TestAPI_AccessTokenIsRefusedEverywhere(t *testing.T) {
h, st, _ := newTestHandler(t)
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pass-phrase"})
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
for _, r := range apiRoutes {
rr := do(h, r.Method, apiSample(r), store.APITokenPrefix+"abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrst", "{}")
if rr.Code != http.StatusForbidden || !strings.Contains(rr.Body.String(), "access tokens are not accepted") {
t.Errorf("%s %s with an access token: %d %q", r.Method, apiSample(r), rr.Code, strings.TrimSpace(rr.Body.String()))
}
}
}