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:
@@ -0,0 +1,128 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// R-971 — the 15-minute correction refuses a catalog definition the box's allow-list does not admit: the deployed
|
||||
// app keeps its last good compose AND its .felhom.yml (nothing of the app moves this cycle), read back off disk.
|
||||
|
||||
const tplOldFixedPrivileged = `services:
|
||||
web:
|
||||
image: nextcloud:31.0.14-apache
|
||||
privileged: true
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:80/status.php"]
|
||||
`
|
||||
|
||||
// RED-PROOF (P2/redproof-allowlist.txt): with the admit call removed from copyTemplates, the live compose gains
|
||||
// `privileged: true` and this test fails.
|
||||
func TestR971_RefusedCorrectionWritesNothing(t *testing.T) {
|
||||
for _, wired := range []bool{true, false} { // the manager's check, and the syncer's own placeholder-env check
|
||||
s, stackDir, _ := renderFixture(t, tplOldFixedPrivileged)
|
||||
live := filepath.Join(stackDir, "docker-compose.yml")
|
||||
meta := filepath.Join(stackDir, ".felhom.yml")
|
||||
write(t, live, tplOld)
|
||||
write(t, meta, "OLD META\n")
|
||||
write(t, stacks.AppliedComposePath(stackDir), tplOld)
|
||||
s.SetRenderPlanFn(pinnedPlan(stackDir, map[string]string{"web": "nextcloud:31.0.14-apache"}, true))
|
||||
asked := 0
|
||||
if wired {
|
||||
s.SetAdmitFn(func(app string, data []byte) error {
|
||||
asked++
|
||||
return s.admit2(app, data)
|
||||
})
|
||||
}
|
||||
_, updated, err := s.copyTemplates()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := readFile(t, live); got != tplOld {
|
||||
t.Fatalf("[wired=%v] the refused definition reached the live compose:\n%s", wired, got)
|
||||
}
|
||||
// The app is not reported updated, so the post-sync hook does not redeploy it: it keeps running as it was.
|
||||
for _, u := range updated {
|
||||
if u == "nextcloud" {
|
||||
t.Fatalf("[wired=%v] a refused app was reported updated — the post-sync redeploy would touch it", wired)
|
||||
}
|
||||
}
|
||||
if got := readFile(t, meta); got != "OLD META\n" {
|
||||
t.Fatalf("[wired=%v] the .felhom.yml of a refused app moved: %q", wired, got)
|
||||
}
|
||||
if wired && asked == 0 {
|
||||
t.Fatal("the manager's check was never asked")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// admit2 is the placeholder-env check (what an unwired syncer uses), callable from the wired case.
|
||||
func (s *Syncer) admit2(app string, data []byte) error {
|
||||
saved := s.admitFn
|
||||
s.admitFn = nil
|
||||
defer func() { s.admitFn = saved }()
|
||||
return s.admit(app, data)
|
||||
}
|
||||
|
||||
// Positive control: the same fix without the privilege flows exactly as before (Group A's assertion).
|
||||
func TestR971_AdmittedCorrectionStillFlows(t *testing.T) {
|
||||
s, stackDir, _ := renderFixture(t, tplOldFixed)
|
||||
live := filepath.Join(stackDir, "docker-compose.yml")
|
||||
write(t, live, tplOld)
|
||||
write(t, stacks.AppliedComposePath(stackDir), tplOld)
|
||||
s.SetRenderPlanFn(pinnedPlan(stackDir, map[string]string{"web": "nextcloud:31.0.14-apache"}, true))
|
||||
if _, _, err := s.copyTemplates(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := readFile(t, live); !strings.Contains(got, "status.php") {
|
||||
t.Fatalf("an admitted fix did not flow:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A catalog template named like a controller infra stack never lands in that stack's directory.
|
||||
func TestR971_CatalogNeverWritesAnInfraStack(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cfg := &config.Config{}
|
||||
cfg.Paths.DataDir = filepath.Join(root, "data")
|
||||
cfg.Paths.StacksDir = filepath.Join(root, "stacks")
|
||||
for _, name := range []string{"traefik", "cloudflared", "filebrowser", "samba", "felhom-controller"} {
|
||||
cat := filepath.Join(cfg.Paths.DataDir, "catalog-cache", "templates", name)
|
||||
dst := filepath.Join(cfg.Paths.StacksDir, name)
|
||||
for _, d := range []string{cat, dst} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write(t, filepath.Join(cat, "docker-compose.yml"), "services:\n x:\n image: evil/x:1\n")
|
||||
write(t, filepath.Join(dst, "docker-compose.yml"), "CONTROLLER RENDERED\n")
|
||||
}
|
||||
s := New(cfg, log.New(io.Discard, "", 0), func() error { return nil }, nil)
|
||||
if _, _, err := s.copyTemplates(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"traefik", "cloudflared", "filebrowser", "samba", "felhom-controller"} {
|
||||
if got := readFile(t, filepath.Join(cfg.Paths.StacksDir, name, "docker-compose.yml")); got != "CONTROLLER RENDERED\n" {
|
||||
t.Errorf("%s: the catalog overwrote the controller's own compose: %q", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An app that is not installed: its refused template is not written either (the install would refuse it again).
|
||||
func TestR971_RefusedTemplateOfAnAppNotInstalled(t *testing.T) {
|
||||
s, stackDir, _ := renderFixture(t, tplOldFixedPrivileged)
|
||||
live := filepath.Join(stackDir, "docker-compose.yml")
|
||||
write(t, live, tplOld)
|
||||
if _, _, err := s.copyTemplates(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := readFile(t, live); got != tplOld {
|
||||
t.Fatalf("a refused template was written for an app not installed:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -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