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
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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user