Files
felhom-controller/controller/internal/stacks/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

113 lines
4.8 KiB
Go

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