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
+43 -6
View File
@@ -22,6 +22,7 @@ import (
// must not reach for the Manager through it: everything it needs about an app arrives through
// renderPlanFn. Importing the package rather than re-writing a compose parser is deliberate;
// a second image parser is exactly the duplication REUSE.md exists to prevent.
"gitea.dooplex.hu/admin/felhom-controller/internal/privpolicy"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
@@ -48,12 +49,16 @@ type Syncer struct {
// behaviour. Every existing test that constructs a Syncer without one keeps passing, and a
// wiring mistake degrades to the old product rather than to a broken one.
renderPlanFn func(appName string) stacks.RenderPlan
mu sync.Mutex
lastSync time.Time
lastErr error
syncing bool
stopCh chan struct{}
stopOnce sync.Once
// admitFn (R-971) is the box's catalog allow-list check for one app's compose, asked BEFORE the file is written.
// The stack manager's (stacks.Manager.AdmitSyncedCompose) renders a deployed app with its own env and raises the
// operator event; nil → the same check on the placeholder env (privpolicy.PlaceholderEnv), never "no check".
admitFn func(appName string, data []byte) error
mu sync.Mutex
lastSync time.Time
lastErr error
syncing bool
stopCh chan struct{}
stopOnce sync.Once
}
// SyncStatus holds information about the last sync operation.
@@ -94,6 +99,17 @@ func New(cfg *config.Config, logger *log.Logger, rescanFn func() error, postSync
// seam has to be optional so a Syncer without one keeps the pre-v0.235.0 behaviour exactly.
func (s *Syncer) SetRenderPlanFn(fn func(appName string) stacks.RenderPlan) { s.renderPlanFn = fn }
// SetAdmitFn injects the R-971 check (see Syncer.admitFn).
func (s *Syncer) SetAdmitFn(fn func(appName string, data []byte) error) { s.admitFn = fn }
// admit runs the R-971 check: the injected one, or the placeholder-env check when none is wired.
func (s *Syncer) admit(appName string, data []byte) error {
if s.admitFn != nil {
return s.admitFn(appName, data)
}
return privpolicy.Admit(appName, data, privpolicy.PlaceholderEnv(), privpolicy.Policy{SystemDataPath: s.cfg.Paths.SystemDataPath})
}
// isDebug returns true if the logging level is set to "debug".
func (s *Syncer) isDebug() bool { return s.cfg.Logging.Level == "debug" }
@@ -430,6 +446,12 @@ func (s *Syncer) copyTemplates() (newApps []string, updated []string, err error)
}
appName := entry.Name()
if privpolicy.IsControllerInfra(appName) {
// R-971: the controller renders its own infra stacks; a catalog template with one of their names must never
// land in their directory (traefik holds the Docker socket at the internet edge).
s.logger.Printf("[WARN] [sync] catalog template %q has the name of a controller infra stack — not copied", appName)
continue
}
srcDir := filepath.Join(templatesDir, appName)
dstDir := filepath.Join(s.cfg.Paths.StacksDir, appName)
@@ -485,7 +507,22 @@ func (s *Syncer) copyTemplates() (newApps []string, updated []string, err error)
} else {
rendered = stacks.RenderWithLadderDigests(srcDir, raw)
}
if aerr := s.admit(appName, rendered); aerr != nil {
s.logger.Printf("[WARN] [sync] %s: catalog definition REFUSED by the allow-list — the app keeps its last good compose, nothing written: %v", appName, aerr)
break // neither the compose nor the .felhom.yml of this app moves this cycle
}
changed, err = writeIfChanged(rendered, dst)
} else if filename == "docker-compose.yml" {
data, rerr := os.ReadFile(src)
if rerr != nil {
s.logger.Printf("[WARN] [sync] Failed to read %s for %s: %v", src, appName, rerr)
continue
}
if aerr := s.admit(appName, data); aerr != nil {
s.logger.Printf("[WARN] [sync] %s: stored definition REFUSED by the allow-list — the app keeps its last good compose, nothing written: %v", appName, aerr)
break
}
changed, err = writeIfChanged(data, dst)
} else {
changed, err = copyIfChanged(src, dst)
}