Files
felhom-controller/controller/internal/sync/r971_allowlist_test.go
T
admin 6d90374f63
gates / gates (push) Successful in 45s
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)
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
2026-10-17 16:34:12 +02:00

129 lines
4.8 KiB
Go

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)
}
}