v0.328.0: the catalog allow-list — the box refuses container rights a template asks for and the box does not give (R-971, D2 = A)
gates / gates (push) Successful in 45s

internal/privpolicy checks the RENDERED compose (privileged, cap_add, devices, host namespaces, security_opt,
sysctls, volumes_from, build/include/extends, env_file/config files outside the stack dir, aliasing named volumes,
binds outside the box's data roots, the Docker socket, propagation, ${VAR} in a privileged key) against an
allow-list compiled into the controller (one entry: homepage's read-only socket). Asked before every compose
write: the 15-minute sync, the guarded update/ladder step, the install, both settings changes, both restores
(before the stop). A refusal writes nothing, keeps the app on its last good compose, raises the operator-only
event app_definition_refused once per definition and puts one line on the app page. The sync never copies a
template named like a controller infra stack. Structural guard TestAllowList_IsWiredIntoEveryComposeWriter.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-17 16:34:12 +02:00
parent 71fbef6bbe
commit 6d90374f63
26 changed files with 2408 additions and 9 deletions
+132
View File
@@ -0,0 +1,132 @@
package stacks
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
"gitea.dooplex.hu/admin/felhom-controller/internal/privpolicy"
)
// R-971 (operator decision D2 = A): every path that writes an app's compose asks admitCompose first, on the RENDERED
// compose, and writes nothing when it refuses. The check itself is internal/privpolicy; this file gives it the app's
// env exactly as `compose up` will see it, and makes a refusal visible — one operator event per distinct refused
// definition, and one line on the app page (Stack.DefinitionRefused) until a later definition of the app is admitted.
// The structural guard that every writer asks first: TestAllowList_IsWiredIntoEveryComposeWriter.
// defRefusal is the last refusal of one app.
type defRefusal struct {
sha string
msg string
}
// SetDefinitionRefusedHook sets what a NEW refusal raises (main.go: the operator-only hub event
// `app_definition_refused`). Called once per distinct refused definition of an app, not on every 15-minute cycle.
func (m *Manager) SetDefinitionRefusedHook(fn func(name, detail string)) { m.defRefusedHook = fn }
// policyEnv is env as `compose up` sees it: the app's values, DOMAIN, and the two derived path variables the
// controller adds itself (withPathVars — the derived values win, as they do at exec).
func (m *Manager) policyEnv(env map[string]string) map[string]string {
out := make(map[string]string, len(env)+3)
for k, v := range env {
out[k] = v
}
out["DOMAIN"] = m.cfg.Customer.Domain
if hdd := env["HDD_PATH"]; hdd != "" {
out["USERDATA_PATH"] = appbackup.UserdataDir(appbackup.NamespaceRootFor(hdd, m.sysDataPath))
}
if ir := m.GetImportRoot(); ir != "" {
out["IMPORT_PATH"] = ir
} else {
delete(out, "IMPORT_PATH")
}
return out
}
// admitCompose is the one question every compose writer asks. env is the app's env (nil → the placeholder env: a
// template checked before the app has values). A refusal is returned AND recorded; an admitted definition clears the
// app's refusal line.
func (m *Manager) admitCompose(name string, data []byte, env map[string]string) error {
e := privpolicy.PlaceholderEnv()
if env != nil {
e = m.policyEnv(env)
}
err := privpolicy.Admit(name, data, e, privpolicy.Policy{SystemDataPath: m.sysDataPath})
m.noteAdmission(name, data, err)
return err
}
// AdmitCompose is admitCompose for the callers outside this package (main.go's restore adapter).
func (m *Manager) AdmitCompose(name string, data []byte, env map[string]string) error {
return m.admitCompose(name, data, env)
}
// admitWithNewValues is the settings change's question: the live compose, rendered with the app's decrypted env and
// the changed values on top.
func (m *Manager) admitWithNewValues(name, stackDir string, values map[string]string) error {
data, err := os.ReadFile(ComposePathIn(stackDir))
if err != nil {
return err
}
env := map[string]string{}
if dec := LoadAppConfigDecrypted(stackDir, m.encKey); dec != nil {
for k, v := range dec.Env {
env[k] = v
}
}
for k, v := range values {
env[k] = v
}
return m.admitCompose(name, data, env)
}
// AdmitSyncedCompose is the sync's question (sync.Syncer.SetAdmitFn): a deployed app is checked with its own env, a
// template of an app not installed with the placeholder env (the install checks again with the real values).
func (m *Manager) AdmitSyncedCompose(name string, data []byte) error {
var env map[string]string
if st, ok := m.GetStack(name); ok && st.Deployed {
if cfg := LoadAppConfigDecrypted(filepath.Dir(st.ComposePath), m.encKey); cfg != nil {
env = cfg.Env
}
}
return m.admitCompose(name, data, env)
}
func (m *Manager) noteAdmission(name string, data []byte, err error) {
m.defRefusedMu.Lock()
if m.defRefused == nil {
m.defRefused = map[string]defRefusal{}
}
byApp := m.defRefused
if err == nil {
delete(byApp, name)
m.defRefusedMu.Unlock()
return
}
sum := sha256.Sum256(data)
sha := hex.EncodeToString(sum[:])
prev, seen := byApp[name]
hook := m.defRefusedHook
told := sha
if hook == nil {
told = "" // not wired yet (a sync before main's hook): the first wired sight still raises the event
}
byApp[name] = defRefusal{sha: told, msg: err.Error()}
m.defRefusedMu.Unlock()
m.logger.Printf("[WARN] [stacks] %s: definition REFUSED by the allow-list (R-971), nothing written: %v", name, err)
if seen && prev.sha == sha {
return // the same definition again (the next 15-minute cycle): one event per definition
}
if hook != nil {
hook(name, err.Error())
}
}
// definitionRefused is the app page's line ("" when none).
func (m *Manager) definitionRefused(name string) string {
m.defRefusedMu.Lock()
defer m.defRefusedMu.Unlock()
return m.defRefused[name].msg
}
@@ -0,0 +1,112 @@
package stacks
import (
"io"
"log"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
func allowlistMgr(t *testing.T) *Manager {
t.Helper()
m := &Manager{cfg: &config.Config{}, logger: log.New(io.Discard, "", 0), sysDataPath: "/mnt/sys_drive", stacks: map[string]*Stack{}}
return m
}
const refusedCompose = "services:\n web:\n image: x/y:1\n privileged: true\n"
// One operator event per DISTINCT refused definition — the same definition on the next 15-minute cycle raises none;
// a different one raises again; an admitted definition clears the page line. RED-PROOF: with the sha comparison
// removed, the second identical refusal raises a second event and this test fails.
func TestAllowList_OneEventPerRefusedDefinition(t *testing.T) {
m := allowlistMgr(t)
var events []string
m.SetDefinitionRefusedHook(func(name, detail string) { events = append(events, name+"|"+detail) })
env := map[string]string{"HDD_PATH": "/mnt/felhom-drives/a/felhom-data"}
for i := 0; i < 3; i++ {
if err := m.admitCompose("web", []byte(refusedCompose), env); err == nil {
t.Fatal("privileged was admitted")
}
}
if len(events) != 1 {
t.Fatalf("events = %d, want 1 for one definition seen three times: %v", len(events), events)
}
if !strings.Contains(events[0], "privileged true") {
t.Fatalf("the event does not name the refused setting: %v", events)
}
if m.definitionRefused("web") == "" {
t.Fatal("the app page line is empty while a refusal stands")
}
if err := m.admitCompose("web", []byte(strings.Replace(refusedCompose, "privileged: true", "cap_add: [NET_ADMIN]", 1)), env); err == nil {
t.Fatal("cap_add was admitted")
}
if len(events) != 2 {
t.Fatalf("a different refused definition raised %d events in all, want 2", len(events))
}
if err := m.admitCompose("web", []byte("services:\n web:\n image: x/y:2\n"), env); err != nil {
t.Fatalf("a clean definition was refused: %v", err)
}
if m.definitionRefused("web") != "" {
t.Fatal("an admitted definition did not clear the page line")
}
}
// A refusal before the hook is wired (a sync at start-up) still raises the event at the first wired sight.
func TestAllowList_UnwiredFirstSightIsNotSwallowed(t *testing.T) {
m := allowlistMgr(t)
_ = m.admitCompose("web", []byte(refusedCompose), nil)
n := 0
m.SetDefinitionRefusedHook(func(string, string) { n++ })
_ = m.admitCompose("web", []byte(refusedCompose), nil)
if n != 1 {
t.Fatalf("events after wiring = %d, want 1", n)
}
}
// The refusal text (logged, mailed to the operator) never carries an env VALUE: a secret in a privileged key or a
// non-root bind variable is shown as the template text.
func TestAllowList_RefusalNeverPrintsASecret(t *testing.T) {
m := allowlistMgr(t)
const secret = "S3cr3t-value-never-shown"
env := map[string]string{"HDD_PATH": "/mnt/felhom-drives/a/felhom-data", "DB_PASSWORD": secret}
compose := "services:\n web:\n image: x/y:1\n sysctls:\n net.x: ${DB_PASSWORD}\n volumes:\n - ${DB_PASSWORD}:/x\n env_file: /etc/${DB_PASSWORD}\n"
err := m.admitCompose("web", []byte(compose), env)
if err == nil {
t.Fatal("admitted")
}
if strings.Contains(err.Error(), secret) || strings.Contains(m.definitionRefused("web"), secret) {
t.Fatalf("the refusal printed a secret value: %v", err)
}
}
// The env the check sees is the env compose sees: the controller-derived USERDATA_PATH wins over an app value.
func TestAllowList_PolicyEnvDerivesTheRootsLikeComposeExec(t *testing.T) {
m := allowlistMgr(t)
e := m.policyEnv(map[string]string{"HDD_PATH": "/mnt/felhom-drives/a/felhom-data", "USERDATA_PATH": "/"})
if e["USERDATA_PATH"] == "/" || !strings.HasPrefix(e["USERDATA_PATH"], "/mnt/felhom-drives/a/") {
t.Fatalf("USERDATA_PATH = %q — the app's own value must not become a data root", e["USERDATA_PATH"])
}
}
// The install / settings / restore refusal is a bundle key: the reader gets it in their language, with the app and
// the refused setting named, and the sentence says nothing changed.
func TestDefinitionRefused_MessagesFollowTheReader(t *testing.T) {
err := util.MsgError("err.stacks.definition_refused", "homepage", "homepage: privileged true")
msg, ok := util.AsMsg(err)
if !ok {
t.Fatal("not a message error")
}
for lang, want := range map[string]string{"hu": "Semmi nem valtozott", "en": "Nothing was changed"} {
got := msg.Text(lang)
ascii := strings.NewReplacer("á", "a", "é", "e", "í", "i", "ó", "o", "ö", "o", "ő", "o", "ú", "u", "ü", "u", "ű", "u").Replace(got)
if !strings.Contains(ascii, want) || !strings.Contains(got, "homepage: privileged true") {
t.Errorf("[%s] %q", lang, got)
}
}
if msg.Text("hu") == msg.Text("en") {
t.Fatal("the two languages render the same text — a key is missing from one bundle")
}
}
@@ -0,0 +1,168 @@
package stacks
import (
"go/ast"
"go/parser"
"go/token"
"path/filepath"
"sort"
"strings"
"testing"
)
// TestAllowList_IsWiredIntoEveryComposeWriter (R-971) — the catalog allow-list is only a control if EVERY path that
// writes an app's compose asks it first. Walked as an AST (comments dropped, so a commented-out call does not count),
// the shape of TestAdmission_IsWiredIntoEveryProductionWriteLeg.
//
// 1. DISCOVERY: every production function that names the compose file (the literal "docker-compose.yml", a
// ComposePathIn / AppliedComposePath call, a .ComposePath field) AND calls a file write must be in `writers` —
// either GATED (its first call of the gate comes before its first write) or EXEMPT with the reason. A new writer
// fails this test until someone decides which it is.
// 2. DECISION POINTS: the paths that choose an app's env (install, the two settings changes, the two restores) ask
// the gate before their first mutation.
//
// RED-PROOF (felhom.eu audits/agent-allowlist-rotation-2026-10-17/P2/redproof-allowlist.txt): remove the admit call
// from copyTemplates → step 1 fails naming it; add a new function that writes ComposePathIn(dir) → step 1 fails as
// an unknown writer.
func TestAllowList_IsWiredIntoEveryComposeWriter(t *testing.T) {
type rule struct {
gate string // "" = exempt
why string // exempt: the reason
before string // gated: the write the gate must precede (the first call of this name)
}
writers := map[string]rule{
"internal/sync/sync.go:copyTemplates": {gate: "admit", before: "writeIfChanged"},
"internal/stacks/pin.go:advancePinTo": {gate: "admitCompose", before: "StoreAppliedDefinition"},
"cmd/controller/main.go:RecreateStackDefinitionFromUnit": {gate: "AdmitDefinitionFromUnit", before: "WriteFile"},
"internal/stacks/infra.go:ensureFileBrowser": {why: "controller-rendered infra stack — out of scope"},
"internal/stacks/infra.go:reconcileFileBrowserImage": {why: "controller-rendered infra stack — out of scope"},
"internal/stacks/install_interrupted.go:RecoverInterruptedInstalls": {why: "writes the interrupted-install marker, no compose"},
"internal/stacks/pin.go:StoreAppliedDefinition": {why: "the STORED copy; it becomes live only through copyTemplates' frozen branch, which admits it"},
"internal/stacks/update.go:runGuardedUpdate": {why: "copies the PREVIOUS compose aside (the undo); the new one is written by advancePinTo"},
"internal/stacks/update.go:restoreDefinition": {why: "puts back the bytes the app ran on before the update — an undo adds nothing"},
"internal/backup/offbox_reconstitute.go:captureLiveDefinition": {why: "a scratch copy in a temp dir, never brought up"},
"internal/backup/recovery_unit.go:captureRecoveryUnit": {why: "the backup's copy; its way back in is RecreateStackDefinitionFromUnit (gated)"},
}
decisions := []struct{ fn, gate, before string }{
{"internal/stacks/deploy.go:DeployStack", "admitCompose", "SaveAppConfig"},
{"internal/stacks/deploy.go:UpdateStackConfig", "admitWithNewValues", "SaveAppConfig"},
{"internal/stacks/deploy.go:UpdateOptionalConfig", "admitWithNewValues", "SaveAppConfig"},
{"internal/backup/restore_unit.go:RestoreFromRecoveryUnitAtWith", "admitRestoredDefinition", "StopStack"},
{"internal/backup/offbox_reconstitute.go:ReconstituteFromOffsite", "admitRestoredDefinition", "StopStack"},
}
writes := map[string]bool{"WriteFile": true, "writeIfChanged": true, "copyIfChanged": true, "atomicWrite": true,
"Rename": true, "Create": true, "OpenFile": true, "StoreAppliedDefinition": true, "copyFile": true, "CopyFile": true}
root := filepath.Join("..", "..")
calls := map[string][]string{} // "dir/file.go:Func" → called names in source order
var found []string
for _, dir := range []string{"internal/stacks", "internal/sync", "internal/backup", "internal/appexport",
"internal/web", "internal/api", "cmd/controller"} {
files, _ := filepath.Glob(filepath.Join(root, dir, "*.go"))
if len(files) == 0 {
t.Fatalf("no Go files under %s — the walk cannot see the code it guards", dir)
}
for _, f := range files {
if strings.HasSuffix(f, "_test.go") {
continue
}
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, f, nil, 0)
if err != nil {
t.Fatal(err)
}
for _, d := range file.Decls {
fd, ok := d.(*ast.FuncDecl)
if !ok || fd.Body == nil {
continue
}
id := dir + "/" + filepath.Base(f) + ":" + fd.Name.Name
names, compose, writesFile := []string{}, false, false
ast.Inspect(fd.Body, func(n ast.Node) bool {
switch v := n.(type) {
case *ast.SelectorExpr:
if v.Sel.Name == "ComposePath" {
compose = true
}
case *ast.BasicLit:
if v.Value == `"docker-compose.yml"` {
compose = true
}
case *ast.CallExpr:
name := ""
switch fn := v.Fun.(type) {
case *ast.Ident:
name = fn.Name
case *ast.SelectorExpr:
name = fn.Sel.Name
}
if name == "ComposePathIn" || name == "AppliedComposePath" {
compose = true
}
if writes[name] {
writesFile = true
}
if name != "" {
names = append(names, name)
}
}
return true
})
calls[id] = names
if compose && writesFile {
found = append(found, id)
}
}
}
}
// 1. Discovery.
sort.Strings(found)
for _, id := range found {
r, known := writers[id]
if !known {
t.Errorf("%s writes a file and names the compose file, and the allow-list guard does not know it — gate it "+
"(admitCompose before the write) or add it to `writers` as exempt with the reason", id)
continue
}
if r.gate == "" {
continue
}
assertCalledBefore(t, id, calls[id], r.gate, r.before)
}
for id := range writers {
if _, ok := calls[id]; !ok {
t.Errorf("fixture drift: %s no longer exists — this guard can no longer see that writer", id)
}
}
// 2. Decision points.
for _, d := range decisions {
c, ok := calls[d.fn]
if !ok {
t.Errorf("fixture drift: %s no longer exists", d.fn)
continue
}
assertCalledBefore(t, d.fn, c, d.gate, d.before)
}
}
func assertCalledBefore(t *testing.T, id string, calls []string, gate, act string) {
t.Helper()
gi, ai := -1, -1
for i, c := range calls {
if c == gate && gi < 0 {
gi = i
}
if c == act && ai < 0 {
ai = i
}
}
switch {
case gi < 0:
t.Errorf("%s: the allow-list gate %q is never called", id, gate)
case ai < 0:
t.Errorf("fixture drift: %s no longer calls %q — the guard cannot order the gate against it", id, act)
case gi > ai:
t.Errorf("%s: %q first appears after %q — the check must come before the write", id, gate, act)
}
}
+18
View File
@@ -464,6 +464,16 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
m.logger.Printf("[ERROR] [stacks] Deploy %s REFUSED: %v", req.StackName, err)
return "", util.MsgError("err.stacks.needs_newer_controller", err.Error())
}
// R-971: the compose, rendered with THIS install's values, must pass the box's catalog allow-list — refused before
// anything is written.
if data, rerr := os.ReadFile(stack.ComposePath); rerr != nil {
clearDeploying()
return "", fmt.Errorf("reading the compose file of %s: %w", req.StackName, rerr)
} else if aerr := m.admitCompose(req.StackName, data, env); aerr != nil {
clearDeploying()
m.logger.Printf("[ERROR] [stacks] Deploy %s REFUSED by the allow-list: %v", req.StackName, aerr)
return "", util.MsgError("err.stacks.definition_refused", req.StackName, aerr.Error())
}
// `09` §3 decision 46: a gated template is installed CLOSED, and the gate's traefik file is written BEFORE
// the first start (spike F2). Cannot write it → the install is refused: never published open.
var gate *SetupGateRecord
@@ -746,6 +756,10 @@ func (m *Manager) UpdateStackConfig(name string, values map[string]string) error
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] UpdateStackConfig %s: changed keys: [%s], locked keys: %d", name, strings.Join(changedKeys, ", "), len(lockedSet))
}
// R-971: the compose, rendered with the NEW values, must still pass the box's allow-list — refused before the save.
if err := m.admitWithNewValues(name, stackDir, values); err != nil {
return util.MsgError("err.stacks.definition_refused", name, err.Error())
}
if err := SaveAppConfig(stackDir, appCfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
return fmt.Errorf("saving updated config: %w", err)
@@ -989,6 +1003,10 @@ func (m *Manager) UpdateOptionalConfig(stackName string, values map[string]strin
if !changed {
return nil
}
// R-971: the compose, rendered with the NEW values, must still pass the box's allow-list — refused before the save.
if err := m.admitWithNewValues(stackName, stackDir, values); err != nil {
return util.MsgError("err.stacks.definition_refused", stackName, err.Error())
}
// Save app.yaml
meta := LoadMetadata(stackDir)
+12 -3
View File
@@ -189,9 +189,12 @@ type Stack struct {
HoldNoWholeCopy bool `json:"hold_no_whole_copy,omitempty"`
// HoldKind (v0.269.0): which hold — "update_failed", "unhealthy_stop" (decision 28: the page offers
// Start, not a restore), "restore"; "" when none.
HoldKind string `json:"hold_kind,omitempty"`
HealthProbe *HealthProbeResult `json:"health_probe,omitempty"` // controller-side probe result
LastUpdated time.Time `json:"last_updated"`
HoldKind string `json:"hold_kind,omitempty"`
// DefinitionRefused (R-971) is the box's refusal of the app's newest catalog definition — the app keeps its last
// good compose. Filled on every read (allowlist.go); "" when none.
DefinitionRefused string `json:"definition_refused,omitempty"`
HealthProbe *HealthProbeResult `json:"health_probe,omitempty"` // controller-side probe result
LastUpdated time.Time `json:"last_updated"`
// RestartingSince (C9-F2) is when this stack was FIRST observed in StateRestarting during the
// current restarting run; zero whenever the stack is in any other state. It is what turns a brief
// restart (normal: deploy, update, quiesce restart) into a distinguishable crash loop — see
@@ -267,6 +270,10 @@ type Manager struct {
// this and nothing else: it used to be sent beside the 202 that merely accepted the request, so
// an install interrupted five seconds later stayed on the timeline as a completed one.
deployDoneHook func(name string, ok bool, detail string)
// defRefusedHook (R-971) raises the operator event for a definition the allow-list refused (allowlist.go).
defRefusedHook func(name, detail string)
defRefusedMu sync.Mutex
defRefused map[string]defRefusal
testSeams *migSeams // nil in production; tests inject fakes
// R-51: docker restart policies for DOWN members of mixed stacks. Keyed by
// containerName+"|"+state so a transitioned or recreated container re-reads rather than
@@ -1123,6 +1130,7 @@ func (m *Manager) GetStacks() []Stack {
m.mu.RUnlock()
for i := range result {
fillHoldReason(g, &result[i])
result[i].DefinitionRefused = m.definitionRefused(result[i].Name)
}
// Sort alphabetically by display name for consistent UI ordering
@@ -1143,6 +1151,7 @@ func (m *Manager) GetStack(name string) (*Stack, bool) {
}
cp := deepCopyStack(s)
fillHoldReason(m.updateGuards, &cp)
cp.DefinitionRefused = m.definitionRefused(name)
return &cp, true
}
+12
View File
@@ -348,6 +348,18 @@ func (m *Manager) advancePinTo(name, stackDir, src, metaSrc string) error {
// v0.269.0 (`09` §6.4 part 6): run EXACTLY the tested images — the ladder entry's digests for these refs.
data = RenderWithLadderDigests(filepath.Dir(m.CatalogTemplatePath(name, "docker-compose.yml")), data)
// R-971: the new definition must pass the box's allow-list before anything is stored — a refused update leaves
// the app on its last good compose, running.
var env map[string]string
if dec := LoadAppConfigDecrypted(stackDir, m.encKey); dec != nil {
env = dec.Env
}
if env == nil {
env = map[string]string{}
}
if err := m.admitCompose(name, data, env); err != nil {
return err
}
live := ComposePathIn(stackDir)
if err := StoreAppliedDefinition(stackDir, data); err != nil {
return fmt.Errorf("storing the new applied definition for %s: %w", name, err)