diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d027fd..28aba1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,38 @@ +## v0.328.0 — the catalog allow-list: the box refuses container rights a template asks for and the box does not give (R-971, operator decision D2 = A) (2026-10-17) + +**MinAgent: 0.158.1** — the floor's, carried. Nothing here calls a new agent route. No golden (`09` §3 decision 250). +Task: „agent release, the catalog allow-list (D2 = A), break-glass rotation (D1 = A), off-site history check" (felhom.eu +`REPORT-agent-allowlist-rotation-2026-10-17.md`). **HUB DEPENDENCY:** the operator event `app_definition_refused` needs +hub ≥ v0.157.0 (`allowedEventTypes` + `operatorOnlyEvents`); an older hub answers 400 and drops the event — the box +still refuses and the app page still says so. + +- **`internal/privpolicy`** — the check, on the RENDERED compose: `privileged`, `cap_add`, `devices`, + `device_cgroup_rules`, `network_mode`/`pid`/`ipc`/`uts`/`userns_mode`/`cgroup`, `security_opt` (all but + `no-new-privileges`), `sysctls`, `volumes_from`, `build`, `include`, `extends.file`, an `env_file` / `configs` / + `secrets` file outside the stack dir, a named volume with `driver`/`driver_opts`/`external`/`name`, binds outside the + data roots the box gives (a bind source may use only `${HDD_PATH}`, `${USERDATA_PATH}`, `${IMPORT_PATH}`, as its + leading token; each root must be under `/mnt/` or the system data path), the Docker socket anywhere, mount + propagation, and `interpolated ` for a `${VAR}` in any privileged key (a catalog deploy-field default could + otherwise change it later). A refusal never prints an env value. Unreadable compose → refused (fail closed). +- **The allow-list** (`internal/privpolicy/allowlist.json`, go:embed — a change in Gitea cannot add to it): app + + service + EXACT setting. One entry: homepage's `bind /var/run/docker.sock -> /var/run/docker.sock ro`. The survey of + all 123 catalog files (templates + ladder steps) found nothing else in any class. +- **Wired into every compose writer:** the 15-minute sync (`sync.copyTemplates` — a refused app keeps its compose AND + its `.felhom.yml`, and is not reported updated), the guarded update / ladder step (`stacks.advancePinTo`), the install + (`DeployStack`), the two settings changes (`UpdateStackConfig`, `UpdateOptionalConfig`), the unit and off-site + restores (asked BEFORE the app is stopped, and again by the writer `RecreateStackDefinitionFromUnit`). Structural + guard `TestAllowList_IsWiredIntoEveryComposeWriter` (AST, comments dropped): every production function that names the + compose file and writes a file is gated or exempt with its reason; the decision points ask before their first + mutation. +- **A catalog template named like a controller infra stack is never copied** (traefik, cloudflared, filebrowser, samba, + felhom-controller) — the sync used to copy one into the infra stack's directory. +- **The refusal is visible:** the operator-only event `app_definition_refused` (once per refused definition), one line + on the app page (`app_info.definition_refused`, both languages; new parity fixture + `app_settings_definition_refused`, every other fixture byte-identical), and the plain message + `err.stacks.definition_refused` for a refused install / settings change / restore. +- Red-proofs: felhom.eu `audits/agent-allowlist-rotation-2026-10-17/P2/redproof-allowlist.txt` (six mutations, each + convicted by its test). + ## v0.327.1 — files. reaches FileBrowser again: a file route by name, not Docker labels (2026-10-17) **MinAgent: 0.158.1** — the floor's, carried. No golden. Task: as v0.327.0. diff --git a/REUSE.md b/REUSE.md index 3f814fd..bc063cd 100644 --- a/REUSE.md +++ b/REUSE.md @@ -136,6 +136,7 @@ | Symbol | File | Short signature | Use for | Gotchas | |---|---|---|---|---| +| `stacks.Manager.admitCompose` / `privpolicy.Admit` (v0.328.0, R-971) | controller/internal/stacks/allowlist.go, controller/internal/privpolicy/privpolicy.go | `admitCompose(name, data, env) error` | THE question before ANY write of an app compose (and before a settings change or restore commits an env): the box's catalog allow-list on the RENDERED compose | A new compose writer must call it first — `TestAllowList_IsWiredIntoEveryComposeWriter` fails otherwise; a refusal records the page line + one hub event per definition; the allow-list is `privpolicy/allowlist.json` (exact app+service+setting) and the catalog's `scripts/privilege-allowlist.json` must stay byte-identical | | `Manager.DeployStack` | controller/internal/stacks/deploy.go | `(req DeployRequest) (string, error)` | Full deploy flow | Sets in-memory `Deployed` BEFORE compose up (slow-pull race), reverts on failure | | `DeclaredVolumeNames` (R-658, v0.268.0) | controller/internal/stacks/undo.go | `(composePath) (own, external []string, err)` | THE app's named volumes as Docker names them: `name:` else `_`, project = top-level `name:` else the stack dir | **Never select an app's volumes by the `com.docker.compose.project` label** — a restore before v0.268.0 made volumes without it, and the undo then copied nothing (R-658). The label is a cross-check only | | `renderDigests` / `RenderWithLadderDigests` / `CarryDigests` / `StripDigest` (v0.269.0/.1) | controller/internal/stacks/digest.go | compose bytes → the same with `ref@sha256` from the ladder entry for its refs | writing a compose that RUNS (update; the sync for an app NOT yet installed) — an INSTALLED app's sync uses `CarryDigests` (v0.269.1), which keeps the digest the app runs | Pins and records are digest-free by construction (`parseComposeImagesBytes` and the installed record strip) — never compare a raw `.Config.Image` against a pin | diff --git a/controller/README.md b/controller/README.md index 8c0a556..24ee399 100644 --- a/controller/README.md +++ b/controller/README.md @@ -1182,6 +1182,26 @@ Multiple checks per app are supported (all must pass). The probe scheduler runs --- +### 1a. The catalog allow-list — container rights the box gives (v0.328.0, R-971) + +A push to the catalog's `main` reaches every installed app within about 15 minutes, so the box no longer trusts a +template's container rights. Every path that writes an app compose — the install, a settings change, the 15-minute +correction, an update or ladder step, a unit or off-site restore — first renders the compose with the app's own values +(as `compose up` will) and asks `internal/privpolicy`: `privileged`, `cap_add`, `devices`, host namespaces +(`network_mode`/`pid`/`ipc`/`uts`/`userns_mode`/`cgroup`), `security_opt` other than `no-new-privileges`, `sysctls`, +`volumes_from`, `build`/`include`/`extends` files, an `env_file` or config file outside the stack directory, a named +volume with a driver/`driver_opts`/`external`/`name`, a `${VAR}` inside any of those, and any bind outside the data +roots the box itself gives (`${HDD_PATH}`, `${USERDATA_PATH}`, `${IMPORT_PATH}`) or of the Docker socket. Such a setting +is refused unless this app's service has EXACTLY that setting on the list compiled into the controller +(`internal/privpolicy/allowlist.json` — today one entry: homepage's read-only Docker socket). A refusal writes nothing: +a refused correction or update leaves the app running on its last good compose, raises the operator-only hub event +`app_definition_refused` once per refused definition, and puts one line on the app page; a refused install, settings +change or restore is refused with a plain message („Semmi nem változott"). A catalog template named like a controller +infra stack (traefik, cloudflared, filebrowser, samba, felhom-controller) is never copied. The controller's own infra +stacks and override files are rendered by the controller and are out of scope. The catalog's `check-privileges.py` +mirrors the check in CI (a byte-identical copy of the list). The structural guard +`TestAllowList_IsWiredIntoEveryComposeWriter` fails on any new compose writer that does not ask first. + ### 2. App Export/Import (.fab bundles) Per-app export creates a self-contained `.fab` file (tar.gz, optionally encrypted) that can be stored externally or used to restore the app on the same server. Distinct from the automatic backup system — user-initiated, per-app, produces a single portable file. diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index a488fbe..6efc11b 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -418,6 +418,7 @@ func main() { // the pre-v0.235.0 product; the freeze is INERT unless it is wired here, which is why a test // walks this file's AST for the call rather than trusting the package to be correct alone. syncer.SetRenderPlanFn(stackMgr.RenderPlanFor) + syncer.SetAdmitFn(stackMgr.AdmitSyncedCompose) // R-971: the catalog allow-list, before any compose is written // Start() is deliberately NOT called here — see the comment beside the call further down, after // the pin adoption pass. defer syncer.Stop() @@ -2001,6 +2002,12 @@ func main() { // `app_deploy_started`, emitted by the API router beside its 202. // v0.279.0: the hook is set on EVERY box — after_install (decision 45) must run on a box with no hub too; // only the events need the notifier. + // R-971: a definition the catalog allow-list refused raises the operator-only event (once per definition). + stackMgr.SetDefinitionRefusedHook(func(name, detail string) { + if notifier != nil { + notifier.NotifyAppDefinitionRefused(name, detail) + } + }) stackMgr.SetDeployDoneHook(func(name string, ok bool, detail string) { display := name if s, found := stackMgr.GetStack(name); found && s.Meta.DisplayName != "" { @@ -3228,6 +3235,10 @@ func (a *stackAdapter) RecreateStackDefinitionFromUnit(name, composeSrcDir strin return fmt.Errorf("stack %q not found", name) } stackDir := filepath.Dir(s.ComposePath) + // R-971: the writer asks too (the restores asked before they stopped the app; this is the last word). + if err := a.AdmitDefinitionFromUnit(name, composeSrcDir, fullEnv); err != nil { + return err + } // Recover the app definition from the unit (compose + .felhom.yml) into the stack dir. for _, fname := range []string{"docker-compose.yml", ".felhom.yml"} { data, err := os.ReadFile(filepath.Join(composeSrcDir, fname)) @@ -3265,6 +3276,16 @@ func (a *stackAdapter) RecreateStackDefinitionFromUnit(name, composeSrcDir strin return nil } +// AdmitDefinitionFromUnit (R-971) checks a unit's compose, rendered with the restore's env, against the box's catalog +// allow-list. No compose in the unit → nothing would be written → nil. +func (a *stackAdapter) AdmitDefinitionFromUnit(name, composeSrcDir string, fullEnv map[string]string) error { + data, err := os.ReadFile(filepath.Join(composeSrcDir, "docker-compose.yml")) + if err != nil { + return nil + } + return a.mgr.AdmitCompose(name, data, fullEnv) +} + // SetStackSecretValues writes the named values into the app's app.yaml (R-1106: the off-site restore's database // password). The optional backup.secretValueSetter. func (a *stackAdapter) SetStackSecretValues(name string, values map[string]string) error { diff --git a/controller/internal/backup/allowlist_gate.go b/controller/internal/backup/allowlist_gate.go new file mode 100644 index 0000000..cd0e9b1 --- /dev/null +++ b/controller/internal/backup/allowlist_gate.go @@ -0,0 +1,16 @@ +package backup + +// definitionAdmitter is the R-971 question a restore asks BEFORE it stops the app: would the unit's (or the snapshot's) +// compose, rendered with the restore's env, pass the box's catalog allow-list? Implemented by main.go's stackAdapter; +// a provider without it (the test fakes) asks nothing. A refusal leaves the live app running and untouched. +type definitionAdmitter interface { + AdmitDefinitionFromUnit(name, composeSrcDir string, fullEnv map[string]string) error +} + +// admitRestoredDefinition asks the provider when it can answer; nil when it cannot. +func (m *Manager) admitRestoredDefinition(name, composeSrcDir string, env map[string]string) error { + if a, ok := m.stackProvider.(definitionAdmitter); ok { + return a.AdmitDefinitionFromUnit(name, composeSrcDir, env) + } + return nil +} diff --git a/controller/internal/backup/offbox_reconstitute.go b/controller/internal/backup/offbox_reconstitute.go index 2866c72..c0b9c96 100644 --- a/controller/internal/backup/offbox_reconstitute.go +++ b/controller/internal/backup/offbox_reconstitute.go @@ -905,6 +905,15 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack } } + // R-971: the snapshot's compose must pass the box's allow-list — asked before the stop, so a refusal leaves the + // live app untouched. + if defineFromSnapshot { + if err := m.admitRestoredDefinition(stack, scratchCompose, snapEnv); err != nil { + m.logger.Printf("[ERROR] [offbox] Restore REFUSED for %s: %v", stack, err) + return res, util.MsgError("err.stacks.definition_refused", stack, err.Error()) + } + } + // --- FILES ---------------------------------------------------------------------------------- // R-166: mark the stop→restore→start window BEFORE stopping. A controller killed anywhere inside // it used to leave the app down with nothing on disk recording that it was owed a restart — and a diff --git a/controller/internal/backup/restore_unit.go b/controller/internal/backup/restore_unit.go index 17189e4..12f9518 100644 --- a/controller/internal/backup/restore_unit.go +++ b/controller/internal/backup/restore_unit.go @@ -415,6 +415,13 @@ func (m *Manager) RestoreFromRecoveryUnitAtWith(stackName, unitDir string, opt U return res, util.MsgError("err.backup.adatbazis_masolat_csonka_nem_indult", stackName) } + // R-971: the unit's compose must pass the box's allow-list — asked HERE, before the stop, so a refusal leaves the + // live app running and untouched. + if err := m.admitRestoredDefinition(stackName, composeDir, fullEnv); err != nil { + m.logger.Printf("[ERROR] [backup] Restore REFUSED for %s: %v", stackName, err) + return res, util.MsgError("err.stacks.definition_refused", stackName, err.Error()) + } + // Stop, restore named-volume data, recreate the definition, replay the DB with ONLY the database // service running, and only then start the whole stack. // F17: surface a data-restore failure instead of swallowing it (we still bring the app back up). diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index f15c55a..f4960b0 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -194,6 +194,7 @@ "app_info.mire_hasznalhato": "What is it for?", "app_info.naplo": "Log", "app_info.hold_log_kept": "The app's own log from before it was stopped was kept — it shows why the new version did not start.", + "app_info.definition_refused": "The box did not accept the catalog's newer definition of this app: it asks for rights the box does not give. The app keeps running on its last good definition; the operator has been told.", "app_info.nincs_folyamatban_athelyezes": "No move in progress.", "app_info.regi_adatok_torlese": "Deleting old data", "app_info.telepites": "Install", @@ -2695,6 +2696,7 @@ "login.msg.wrong_password": "Wrong password.", "err.stacks.family_gate_failed": "The family gate could not be prepared, so the app was not installed: %s", "err.stacks.needs_newer_controller": "This app needs newer box software, so it was not installed: %s", + "err.stacks.definition_refused": "The box does not allow this: the definition of %s asks for rights on the machine that the box does not give (%s). Nothing was changed.", "family_gate.page_title": "Family sign-in", "family_gate.page_body": "Open this app with your family name and password.", "family_gate.name": "Your name", diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index 60b2859..3eaa556 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -190,6 +190,7 @@ "app_info.mire_hasznalhato": "Mire használható?", "app_info.naplo": "Napló", "app_info.hold_log_kept": "Az alkalmazás saját naplója megmaradt a leállítás előttről — ebből látszik, miért nem indult el az új változat.", + "app_info.definition_refused": "A katalógus újabb leírását ehhez az alkalmazáshoz a doboz nem fogadta el, mert olyan jogot kér, amit a doboz nem ad meg. Az alkalmazás a legutóbbi jó beállításaival fut tovább; az üzemeltetőt értesítettük.", "app_info.nincs_folyamatban_athelyezes": "Nincs folyamatban áthelyezés.", "app_info.regi_adatok_torlese": "Régi adatok törlése", "app_info.telepites": "Telepítés", @@ -2683,6 +2684,7 @@ "login.msg.wrong_password": "Hibás jelszó.", "err.stacks.family_gate_failed": "A családi kapu nem készült el, ezért nem telepítettük az alkalmazást: %s", "err.stacks.needs_newer_controller": "Ehhez az alkalmazáshoz újabb doboz-szoftver kell, ezért most nem telepítettük: %s", + "err.stacks.definition_refused": "A doboz ezt nem engedi: a(z) %s leírása olyan jogot kér a géptől, amit a doboz nem ad meg (%s). Semmi nem változott.", "family_gate.page_title": "Családi belépés", "family_gate.page_body": "Ezt az alkalmazást a családi neveddel és jelszavaddal nyithatod meg.", "family_gate.name": "Neved", diff --git a/controller/internal/notify/notifier.go b/controller/internal/notify/notifier.go index 2251fa4..12c225d 100644 --- a/controller/internal/notify/notifier.go +++ b/controller/internal/notify/notifier.go @@ -1468,6 +1468,25 @@ func (n *Notifier) NotifyAppHoldNoWholeCopy(d AppHoldNoWholeCopyDetails) { n.pushEventBoth("app_hold_no_whole_copy", "critical", msg, "", d) } +// AppDefinitionRefusedDetails is the R-971 event's structured part. +type AppDefinitionRefusedDetails struct { + App string `json:"app"` + Refused string `json:"refused"` +} + +// NotifyAppDefinitionRefused (v0.328.0, R-971; operator decision D2 = A): the box's catalog allow-list refused an +// app's definition — a catalog correction or update, a restore, a settings change. Nothing was written; the app keeps +// its last good compose. OPERATOR-ONLY on the hub (allowedEventTypes AND operatorOnlyEvents, hub >= v0.157.0): the +// household can do nothing about it — someone pushed a catalog definition the box does not trust. One event per +// distinct refused definition (stacks.Manager.noteAdmission), not one every 15 minutes. +// HUB DEPENDENCY: a hub without the type answers 400 and the event is lost; the box still refuses and the app page +// still says so. +func (n *Notifier) NotifyAppDefinitionRefused(app, detail string) { + msg := fmt.Sprintf("%s: the box REFUSED a catalog definition — it asks for container rights not on the box's allow-list. Nothing was written; the app keeps its last good compose. Check who pushed to the catalog. %s", + app, detail) + n.pushEventBoth("app_definition_refused", "warning", msg, "", AppDefinitionRefusedDetails{App: app, Refused: detail}) +} + // healthSeverity is the event severity a health status would be sent at (R-647): the disabled path // names what it drops, and "warn" is a health STATUS, not a severity the hub knows. func healthSeverity(status string) string { diff --git a/controller/internal/privpolicy/allowlist.json b/controller/internal/privpolicy/allowlist.json new file mode 100644 index 0000000..edeb2e6 --- /dev/null +++ b/controller/internal/privpolicy/allowlist.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "note": "R-971 (operator decision D2 = A, 2026-10-17). The box's catalog allow-list, compiled into the controller. Each entry allows ONE exact setting for ONE service of ONE app; a changed setting is a different one and is refused. The catalog's scripts/privilege-allowlist.json is a byte-identical copy for its CI gate. Survey: felhom.eu documentation/audits/agent-allowlist-rotation-2026-10-17/P2/survey.md", + "entries": [ + { + "app": "homepage", + "service": "homepage", + "setting": "bind /var/run/docker.sock -> /var/run/docker.sock ro", + "why": "the Docker widget and the docker service discovery list the household's containers and their state", + "doc": "https://gethomepage.dev/configs/docker/" + } + ] +} diff --git a/controller/internal/privpolicy/privpolicy.go b/controller/internal/privpolicy/privpolicy.go new file mode 100644 index 0000000..ec21961 --- /dev/null +++ b/controller/internal/privpolicy/privpolicy.go @@ -0,0 +1,581 @@ +// Package privpolicy is the box-side catalog allow-list (R-971, operator decision D2 = A, 2026-10-17; +// felhom.eu `02-controller-module-map.md` „The catalog allow-list"). +// +// A push to the catalog's `main` reaches every installed app within about 15 minutes. Before this package nothing on +// the box refused a dangerous container setting, so whoever could push to the catalog could take every guest. Now +// every path that writes an app's compose asks Admit first, on the RENDERED compose (after variable substitution), +// and writes nothing when it refuses. A setting in one of the classes below is refused unless THIS app's service has +// exactly that setting on the allow-list compiled into the controller (allowlist.json, go:embed) — a change in Gitea +// cannot add to it. An allowed setting that changes (a read-only socket becoming read-write) is a different setting, +// so it is refused too. +// +// The classes (each a Violation.Setting prefix): +// +// privileged, cap_add, device, device_cgroup_rule, network_mode, pid, ipc, uts, userns_mode, cgroup, +// security_opt (every value except no-new-privileges), sysctl, volumes_from, +// bind (a host path outside the box's data roots, the Docker socket anywhere, mount propagation), +// volume (a named volume with a driver, driver_opts, external or an explicit name — each can alias a host path or +// another stack's volume), config_file / secret_file / env_file (a host file outside the stack directory), +// build, include, extends_file, +// interpolated (a ${VAR} inside any of the keys above — a value the catalog's own deploy fields could later change) +// +// The data roots are the values the controller itself gives the app: HDD_PATH, USERDATA_PATH and IMPORT_PATH, each +// an absolute clean path under /mnt/ or under the system data path. A bind source may use only those three variables, +// and only as its leading token. +// +// The controller's own infra stacks (traefik, cloudflared, filebrowser, samba) and its override files (setup gate, +// family gate, install hold, media library) are rendered by the controller, not the catalog: out of scope. +// +// Pinned by privpolicy_test.go (one refused case per class, a positive control per class, the allow-list's exact- +// match rule) and stacks.TestAllowList_IsWiredIntoEveryComposeWriter (the structural guard). +package privpolicy + +import ( + _ "embed" + "encoding/json" + "fmt" + "path/filepath" + "regexp" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +//go:embed allowlist.json +var allowListJSON []byte + +// AllowListJSON is the compiled-in allow-list, byte for byte (the catalog keeps an identical copy for its CI gate; +// TestAllowList_CatalogCopyIsIdentical pins the two). +func AllowListJSON() []byte { return append([]byte(nil), allowListJSON...) } + +// Entry is one allowed setting. +type Entry struct { + App string `json:"app"` + Service string `json:"service"` + Setting string `json:"setting"` + Why string `json:"why"` + Doc string `json:"doc"` +} + +type allowList struct { + Version int `json:"version"` + Entries []Entry `json:"entries"` +} + +var allowed = mustLoad(allowListJSON) + +func mustLoad(b []byte) map[string]bool { + var l allowList + if err := json.Unmarshal(b, &l); err != nil { + panic("privpolicy: allowlist.json does not parse: " + err.Error()) + } + m := map[string]bool{} + for _, e := range l.Entries { + m[key(e.App, e.Service, e.Setting)] = true + } + return m +} + +func key(app, svc, setting string) string { return app + "\x00" + svc + "\x00" + setting } + +// Entries returns the allow-list (for the doc and the app page). +func Entries() []Entry { + var l allowList + _ = json.Unmarshal(allowListJSON, &l) + return l.Entries +} + +// Violation is one setting in a class. Service is "" for a top-level definition (a volume, include). +type Violation struct { + Service string + Setting string +} + +func (v Violation) String() string { + if v.Service == "" { + return v.Setting + } + return v.Service + ": " + v.Setting +} + +// RefusedError lists what was refused, in plain words. The app keeps its last good compose. +type RefusedError struct { + App string + Violations []Violation +} + +func (e *RefusedError) Error() string { + parts := make([]string, len(e.Violations)) + for i, v := range e.Violations { + parts[i] = v.String() + } + return fmt.Sprintf("the catalog definition of %s asks for container rights the box does not allow: %s", e.App, strings.Join(parts, "; ")) +} + +// Policy carries what the box knows about its own paths. +type Policy struct { + SystemDataPath string // paths.system_data_path — a data root may live under it (or under /mnt/) +} + +// RootVars are the only variables a bind source may use. +var RootVars = []string{"HDD_PATH", "USERDATA_PATH", "IMPORT_PATH"} + +// PlaceholderEnv is the env a template is checked with when no app env exists yet (the sync of an app that is not +// installed, the catalog CI). Same shape as the controller gives; the catalog gate uses the same three values. +func PlaceholderEnv() map[string]string { + return map[string]string{ + "HDD_PATH": "/mnt/felhom-drives/check/felhom-data", + "USERDATA_PATH": "/mnt/felhom-drives/check/felhom-data/userdata", + "IMPORT_PATH": "/mnt/felhom-drives/check/felhom-data/userdata/import", + "DOMAIN": "example.invalid", + "SUBDOMAIN": "check", + } +} + +// Admit is the one question every compose writer asks. nil = write it; *RefusedError = write nothing; any other +// error = the compose could not be read, which refuses too (fail closed: a definition the check cannot read is not a +// definition it has checked). +func Admit(app string, compose []byte, env map[string]string, p Policy) error { + vs, err := Check(compose, env, p) + if err != nil { + return fmt.Errorf("the catalog definition of %s could not be checked: %w", app, err) + } + var refused []Violation + for _, v := range vs { + if !allowed[key(app, v.Service, v.Setting)] { + refused = append(refused, v) + } + } + if len(refused) > 0 { + return &RefusedError{App: app, Violations: refused} + } + return nil +} + +// Check lists every setting in a class, allowed or not, sorted. +func Check(compose []byte, env map[string]string, p Policy) ([]Violation, error) { + var doc map[string]any + if err := yaml.Unmarshal(compose, &doc); err != nil { + return nil, err + } + c := &checker{env: env, p: p} + c.roots = c.validRoots() + if _, ok := doc["include"]; ok { + c.add("", "include") + } + if vols, ok := doc["volumes"].(map[string]any); ok { + names := sortedKeys(vols) + for _, n := range names { + def, _ := vols[n].(map[string]any) + for _, k := range []string{"driver", "driver_opts", "external", "name"} { + if v, has := def[k]; has && !(k == "driver" && c.render(v) == "local") { + c.add("", fmt.Sprintf("volume %s %s %s", n, k, c.canon(v))) + } + } + } + } + for _, top := range []string{"configs", "secrets"} { + defs, _ := doc[top].(map[string]any) + for _, n := range sortedKeys(defs) { + def, _ := defs[n].(map[string]any) + if f, has := def["file"]; has && !c.insideStack(c.render(f)) { + c.add("", fmt.Sprintf("%s_file %s %s", strings.TrimSuffix(top, "s"), n, c.shown(f))) + } + } + } + svcs, _ := doc["services"].(map[string]any) + for _, name := range sortedKeys(svcs) { + sd, _ := svcs[name].(map[string]any) + c.service(name, sd) + } + sort.Slice(c.out, func(i, j int) bool { + if c.out[i].Service != c.out[j].Service { + return c.out[i].Service < c.out[j].Service + } + return c.out[i].Setting < c.out[j].Setting + }) + return c.out, nil +} + +type checker struct { + env map[string]string + p Policy + roots []string + out []Violation + svc string +} + +func (c *checker) add(svc, setting string) { + c.out = append(c.out, Violation{Service: svc, Setting: setting}) +} + +// scalarKeys are the classes whose whole value is the setting. +var scalarKeys = []string{"network_mode", "pid", "ipc", "uts", "userns_mode", "cgroup"} + +// privilegedKeys are every key whose value must be literal (no ${VAR}). +var privilegedKeys = []string{"privileged", "cap_add", "devices", "device_cgroup_rules", "network_mode", "pid", "ipc", + "uts", "userns_mode", "cgroup", "security_opt", "sysctls", "volumes_from"} + +func (c *checker) service(name string, sd map[string]any) { + c.svc = name + // A ${VAR} in a privileged key is refused as such, and the key is NOT rendered: its value could be a secret, and a + // refusal names what it refused (logs, the operator event). + interp := map[string]bool{} + for _, k := range privilegedKeys { + if v, has := sd[k]; has && hasInterp(v) { + c.add(name, "interpolated "+k) + interp[k] = true + } + } + sd = withoutKeys(sd, interp) + if v, has := sd["privileged"]; has && truthy(c.render(v)) { + c.add(name, "privileged true") + } + for _, v := range list(sd["cap_add"]) { + c.add(name, "cap_add "+strings.TrimPrefix(strings.ToUpper(c.render(v)), "CAP_")) + } + for _, v := range list(sd["devices"]) { + c.add(name, "device "+c.canon(v)) + } + for _, v := range list(sd["device_cgroup_rules"]) { + c.add(name, "device_cgroup_rule "+c.render(v)) + } + for _, k := range scalarKeys { + if v, has := sd[k]; has { + c.add(name, k+" "+c.render(v)) + } + } + for _, v := range list(sd["security_opt"]) { + s := c.render(v) + if noNewPrivileges(s) { + continue // a hardening, not a privilege + } + c.add(name, "security_opt "+s) + } + switch sy := sd["sysctls"].(type) { + case map[string]any: + for _, k := range sortedKeys(sy) { + c.add(name, fmt.Sprintf("sysctl %s=%s", k, c.render(sy[k]))) + } + case []any: + for _, v := range sy { + c.add(name, "sysctl "+c.render(v)) + } + } + for _, v := range list(sd["volumes_from"]) { + c.add(name, "volumes_from "+c.render(v)) + } + if _, has := sd["build"]; has { + c.add(name, "build") + } + if ex, ok := sd["extends"].(map[string]any); ok { + if f, has := ex["file"]; has { + c.add(name, "extends_file "+c.shown(f)) + } + } + for _, v := range list(sd["env_file"]) { + raw := v + if m, ok := v.(map[string]any); ok { + raw = m["path"] + } + if !c.insideStack(c.render(raw)) { + c.add(name, "env_file "+c.shown(raw)) + } + } + for _, v := range list(sd["volumes"]) { + c.volume(name, v) + } +} + +// volume classifies one service volume entry (short or long syntax). +// withoutKeys returns sd without the named keys (a shallow copy; sd itself is not changed). +func withoutKeys(sd map[string]any, drop map[string]bool) map[string]any { + if len(drop) == 0 { + return sd + } + out := make(map[string]any, len(sd)) + for k, v := range sd { + if !drop[k] { + out[k] = v + } + } + return out +} + +func (c *checker) volume(svc string, v any) { + var rawSrc, src, dst, mode, prop string + switch t := v.(type) { + case string: + parts := strings.Split(t, ":") + if len(parts) < 2 { + return // an anonymous volume: a container path only + } + rawSrc = parts[0] + src, dst = c.render(parts[0]), c.render(parts[1]) + mode = "rw" + if len(parts) > 2 { + for _, o := range strings.Split(parts[2], ",") { + switch o { + case "ro": + mode = "ro" + case "shared", "rshared", "slave", "rslave": + prop = o + } + } + } + if !isPathSource(src) && !isPathSource(rawSrc) { + return // a named volume (its definition is checked at the top level) + } + case map[string]any: + typ := c.render(t["type"]) + rawSrc = fmt.Sprint(t["source"]) + src, dst = c.render(t["source"]), c.render(t["target"]) + mode = "rw" + if truthy(c.render(t["read_only"])) { + mode = "ro" + } + if b, ok := t["bind"].(map[string]any); ok { + if p := c.render(b["propagation"]); p == "shared" || p == "rshared" || p == "slave" || p == "rslave" { + prop = p + } + } + switch typ { + case "volume", "tmpfs", "image": + return + case "bind": + default: + c.add(svc, "volume type "+typ) + return + } + default: + return + } + shown := src + if !rootLed(rawSrc) { + shown = rawSrc // the template text: a rendered non-root variable could be a secret + } + setting := fmt.Sprintf("bind %s -> %s %s", shown, dst, mode) + if prop != "" { + setting += " " + prop + } + if !rootLed(rawSrc) || prop != "" || isSocket(src) || !c.underRoot(src) { + c.add(svc, setting) + } +} + +// rootLed reports whether a TEMPLATE bind source uses no variable, or exactly one root variable as its leading token. +func rootLed(raw string) bool { + if !strings.Contains(strings.ReplaceAll(raw, "$$", ""), "$") { + return true // a literal path (judged by underRoot) + } + for _, r := range RootVars { + for _, form := range []string{"${" + r + "}", "$" + r} { + if rest, ok := strings.CutPrefix(raw, form); ok && (rest == "" || strings.HasPrefix(rest, "/")) && + !strings.Contains(strings.ReplaceAll(rest, "$$", ""), "$") { + return true + } + } + } + return false +} + +func isPathSource(s string) bool { + return strings.HasPrefix(s, "/") || strings.HasPrefix(s, ".") || strings.HasPrefix(s, "~") || strings.HasPrefix(s, "$") +} + +func isSocket(p string) bool { + return strings.HasSuffix(p, ".sock") || strings.Contains(p, "docker.sock") +} + +// validRoots are the root values that are absolute, clean, not "/" and under /mnt/ or the system data path. +func (c *checker) validRoots() []string { + var out []string + for _, r := range RootVars { + v := c.env[r] + if v == "" || !strings.HasPrefix(v, "/") || filepath.Clean(v) != v || v == "/" || hasDotDot(v) { + continue + } + if strings.HasPrefix(v, "/mnt/") || (c.p.SystemDataPath != "" && within(v, filepath.Clean(c.p.SystemDataPath))) { + out = append(out, v) + } + } + return out +} + +func (c *checker) underRoot(p string) bool { + if !strings.HasPrefix(p, "/") || hasDotDot(p) { + return false + } + p = filepath.Clean(p) + for _, r := range c.roots { + if within(p, r) { + return true + } + } + return false +} + +// insideStack: a relative path that stays inside the stack directory. +func (c *checker) insideStack(p string) bool { + return p != "" && !strings.HasPrefix(p, "/") && !strings.HasPrefix(p, "~") && !hasDotDot(p) +} + +func within(p, root string) bool { return p == root || strings.HasPrefix(p, root+"/") } + +func hasDotDot(p string) bool { + for _, s := range strings.Split(p, "/") { + if s == ".." { + return true + } + } + return false +} + +func noNewPrivileges(s string) bool { + s = strings.ReplaceAll(strings.ReplaceAll(s, "=", ":"), " ", "") + return s == "no-new-privileges" || s == "no-new-privileges:true" +} + +func truthy(s string) bool { + switch strings.ToLower(strings.TrimSpace(s)) { + case "true", "yes", "on", "1": + return true + } + return false +} + +func list(v any) []any { + switch t := v.(type) { + case []any: + return t + case nil: + return nil + default: + return []any{t} + } +} + +func sortedKeys(m map[string]any) []string { + ks := make([]string, 0, len(m)) + for k := range m { + ks = append(ks, k) + } + sort.Strings(ks) + return ks +} + +// canon renders a scalar, or a structure as compact JSON with rendered strings (stable key order). +func (c *checker) canon(v any) string { + switch t := v.(type) { + case string: + return c.render(t) + case map[string]any, []any: + b, _ := json.Marshal(c.renderTree(t)) + return string(b) + default: + return fmt.Sprint(t) + } +} + +func (c *checker) renderTree(v any) any { + switch t := v.(type) { + case string: + return c.render(t) + case map[string]any: + m := map[string]any{} + for k, x := range t { + m[k] = c.renderTree(x) + } + return m + case []any: + out := make([]any, len(t)) + for i, x := range t { + out[i] = c.renderTree(x) + } + return out + default: + return t + } +} + +// shown is a value as a refusal may print it: rendered when it holds no variable, the template text when it does. +func (c *checker) shown(v any) string { + if hasInterp(v) { + return fmt.Sprint(v) + } + return c.render(v) +} + +func hasInterp(v any) bool { + switch t := v.(type) { + case string: + return strings.Contains(strings.ReplaceAll(t, "$$", ""), "$") + case map[string]any: + for k, x := range t { + if hasInterp(k) || hasInterp(x) { + return true + } + } + case []any: + for _, x := range t { + if hasInterp(x) { + return true + } + } + } + return false +} + +var interpRe = regexp.MustCompile(`\$\$|\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:?[-?+])([^}]*))?\}|\$([A-Za-z_][A-Za-z0-9_]*)`) + +// render substitutes compose's variable forms ($$, ${V}, ${V:-d}, ${V-d}, ${V:?e}, ${V?e}, ${V:+a}, ${V+a}, $V) from +// the env. A non-string scalar is printed as YAML would read it. +func (c *checker) render(v any) string { + s, ok := v.(string) + if !ok { + if v == nil { + return "" + } + return fmt.Sprint(v) + } + return interpRe.ReplaceAllStringFunc(s, func(m string) string { + if m == "$$" { + return "$" + } + g := interpRe.FindStringSubmatch(m) + name, op, arg := g[1], g[2], g[3] + if name == "" { + name = g[4] + } + val, set := c.env[name] + switch op { + case ":-": + if val == "" { + return arg + } + case "-": + if !set { + return arg + } + case ":+": + if val != "" { + return arg + } + return "" + case "+": + if set { + return arg + } + return "" + } + return val + }) +} + +// controllerInfra are the stacks the controller renders itself (out of this check's scope — and never a catalog +// template's to overwrite). +var controllerInfra = map[string]bool{"traefik": true, "cloudflared": true, "filebrowser": true, "samba": true, "felhom-controller": true} + +// IsControllerInfra reports whether name is one of the controller's own infra stacks. +func IsControllerInfra(name string) bool { return controllerInfra[strings.ToLower(name)] } diff --git a/controller/internal/privpolicy/privpolicy_test.go b/controller/internal/privpolicy/privpolicy_test.go new file mode 100644 index 0000000..e7a316a --- /dev/null +++ b/controller/internal/privpolicy/privpolicy_test.go @@ -0,0 +1,269 @@ +package privpolicy + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +var pol = Policy{SystemDataPath: "/var/lib/felhom"} + +func env() map[string]string { + return map[string]string{ + "HDD_PATH": "/mnt/felhom-drives/adatlemez/felhom-data", + "USERDATA_PATH": "/mnt/felhom-drives/adatlemez/felhom-data/userdata", + "IMPORT_PATH": "/var/lib/felhom/data/userdata/import", + "SUBDOMAIN": "x", "DOMAIN": "example.hu", + } +} + +// base is a real-shaped app: a root-led bind, a named volume, a hardening security_opt. +const base = `services: + app: + image: example/app:1.0 + security_opt: + - no-new-privileges:true + volumes: + - app_data:/data + - ${USERDATA_PATH}/media:/media:ro + - ${HDD_PATH}/appdata/app:/config + - ${IMPORT_PATH}/app:/consume +volumes: + app_data: +` + +func admit(t *testing.T, app, compose string, e map[string]string) error { + t.Helper() + return Admit(app, []byte(compose), e, pol) +} + +func TestAdmit_TheBaseShapeIsAdmitted(t *testing.T) { + if err := admit(t, "app", base, env()); err != nil { + t.Fatalf("a normal app was refused: %v", err) + } +} + +// One ATTACK per class, each added to the admitted base: refused, and the refusal names the setting. +// RED-PROOF (P2/redproof-privpolicy.txt): with Admit returning nil, every case fails. +func TestAdmit_EveryClassIsRefused(t *testing.T) { + cases := map[string]struct{ add, want string }{ + "privileged": {" privileged: true\n", "privileged true"}, + "cap_add": {" cap_add: [SYS_ADMIN]\n", "cap_add SYS_ADMIN"}, + "devices": {" devices: [\"/dev/sda:/dev/sda\"]\n", "device /dev/sda:/dev/sda"}, + "device_cgroup_rule": {" device_cgroup_rules: [\"b 8:* rmw\"]\n", "device_cgroup_rule b 8:* rmw"}, + "network_mode host": {" network_mode: host\n", "network_mode host"}, + "network_mode ctr": {" network_mode: \"container:felhom-controller\"\n", "network_mode container:felhom-controller"}, + "pid": {" pid: host\n", "pid host"}, + "ipc": {" ipc: host\n", "ipc host"}, + "uts": {" uts: host\n", "uts host"}, + "userns_mode": {" userns_mode: host\n", "userns_mode host"}, + "cgroup": {" cgroup: host\n", "cgroup host"}, + "seccomp off": {" security_opt: [\"seccomp=unconfined\"]\n", "security_opt seccomp=unconfined"}, + "apparmor off": {" security_opt: [\"apparmor:unconfined\"]\n", "security_opt apparmor:unconfined"}, + "sysctls": {" sysctls:\n kernel.core_pattern: x\n", "sysctl kernel.core_pattern=x"}, + "volumes_from": {" volumes_from: [\"container:felhom-controller\"]\n", "volumes_from container:felhom-controller"}, + "build": {" build: /\n", "build"}, + "env_file outside": {" env_file: /opt/docker/felhom-controller/controller.yaml\n", "env_file /opt/docker/felhom-controller/controller.yaml"}, + "extends file": {" extends:\n file: /etc/x.yml\n service: y\n", "extends_file /etc/x.yml"}, + "interpolated": {" privileged: ${PRIV}\n", "interpolated privileged"}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + compose := strings.Replace(base, " image: example/app:1.0\n", " image: example/app:1.0\n"+c.add, 1) + // security_opt is already in base: replace it rather than duplicate the key + if strings.HasPrefix(c.add, " security_opt") { + compose = strings.Replace(base, " security_opt:\n - no-new-privileges:true\n", c.add, 1) + } + err := admit(t, "app", compose, env()) + var re *RefusedError + if !errors.As(err, &re) { + t.Fatalf("not refused: %v", err) + } + if !strings.Contains(err.Error(), c.want) { + t.Fatalf("refusal %q does not name %q", err, c.want) + } + }) + } +} + +// Bind sources: the root rule, read on the TEMPLATE and on the RENDERED value. +func TestAdmit_Binds(t *testing.T) { + refuse := map[string]string{ + "host root": " - /:/host", + "docker socket": " - /var/run/docker.sock:/var/run/docker.sock", + "socket under a root": " - ${HDD_PATH}/docker.sock:/s", + "etc": " - /etc:/etc:ro", + "relative stack dir": " - ./:/stack", + "dotdot after a root": " - ${HDD_PATH}/../../../etc:/x", + "another variable": " - ${OTHER}/x:/x", + "root then a variable": " - ${HDD_PATH}${OTHER}:/x", + "rshared propagation": " - ${HDD_PATH}/x:/x:rshared", + "long syntax socket": " - type: bind\n source: /run/docker.sock\n target: /s", + "long syntax npipe": " - type: npipe\n source: x\n target: /s", + "literal outside roots": " - /mnt/other-drive/x:/x", + } + for name, line := range refuse { + t.Run("refused/"+name, func(t *testing.T) { + compose := strings.Replace(base, " - app_data:/data\n", " - app_data:/data\n"+line+"\n", 1) + if err := admit(t, "app", compose, env()); err == nil { + t.Fatalf("%s was admitted", line) + } + }) + } + // Positive controls: a literal path INSIDE a root, a long-syntax root bind, a tmpfs. + for name, line := range map[string]string{ + "literal inside a root": " - /mnt/felhom-drives/adatlemez/felhom-data/userdata/x:/x", + "long syntax root bind": " - type: bind\n source: ${USERDATA_PATH}/media\n target: /m\n read_only: true", + "tmpfs": " - type: tmpfs\n target: /tmp", + } { + t.Run("admitted/"+name, func(t *testing.T) { + compose := strings.Replace(base, " - app_data:/data\n", " - app_data:/data\n"+line+"\n", 1) + if err := admit(t, "app", compose, env()); err != nil { + t.Fatalf("%s was refused: %v", line, err) + } + }) + } +} + +// The ROOTS are checked too: a HDD_PATH of "/" (or anything not under /mnt/ or the system data path) is no root, so +// every bind under it is refused — env cannot widen the box's data roots. +func TestAdmit_ARootValueOutsideTheBoxIsNoRoot(t *testing.T) { + for _, bad := range []string{"/", "/etc", "/mnt/../etc", "relative/x", "/var/lib/felhomX"} { + e := env() + e["HDD_PATH"] = bad + if err := admit(t, "app", base, e); err == nil { + t.Fatalf("HDD_PATH=%q was accepted as a data root", bad) + } + } + e := env() + e["HDD_PATH"] = "/var/lib/felhom/data" // the system-data fallback is a root + if err := admit(t, "app", base, e); err != nil { + t.Fatalf("the system-data root was refused: %v", err) + } +} + +// Top-level definitions that can alias a host path or another stack's volume. +func TestAdmit_TopLevelAliases(t *testing.T) { + for name, tail := range map[string]string{ + "driver_opts bind": " app_data:\n driver_opts:\n type: none\n o: bind\n device: /\n", + "external": " app_data:\n external: true\n", + "explicit name": " app_data:\n name: felhom-controller_data\n", + "plugin driver": " app_data:\n driver: rexray\n", + } { + t.Run(name, func(t *testing.T) { + compose := strings.Replace(base, "volumes:\n app_data:\n", "volumes:\n"+tail, 1) + if err := admit(t, "app", compose, env()); err == nil { + t.Fatal("admitted") + } + }) + } + for name, add := range map[string]string{ + "include": "include:\n - /etc/other.yml\n", + "config file": "configs:\n c:\n file: /etc/shadow\n", + "secret file": "secrets:\n s:\n file: ../../x\n", + } { + t.Run(name, func(t *testing.T) { + if err := admit(t, "app", base+add, env()); err == nil { + t.Fatal("admitted") + } + }) + } + if err := admit(t, "app", base+"configs:\n c:\n file: ./conf/app.ini\n", env()); err != nil { + t.Fatalf("a config file inside the stack dir was refused: %v", err) + } +} + +const homepage = `services: + homepage: + image: ghcr.io/gethomepage/homepage:v1.13.2 + volumes: + - homepage_config:/app/config + - /var/run/docker.sock:/var/run/docker.sock:ro +volumes: + homepage_config: +` + +// The allow-list is EXACT: homepage's read-only socket is allowed for homepage's service only; the same socket +// read-write, under another service, or in another app is refused. +func TestAdmit_TheAllowListIsExact(t *testing.T) { + if err := admit(t, "homepage", homepage, env()); err != nil { + t.Fatalf("the allowed homepage socket was refused: %v", err) + } + for name, c := range map[string]struct{ app, compose string }{ + "read-write": {"homepage", strings.Replace(homepage, "docker.sock:ro", "docker.sock", 1)}, + "another service": {"homepage", strings.Replace(homepage, " homepage:\n image", " sidecar:\n image", 1)}, + "another app": {"glance", homepage}, + "plus privileged": {"homepage", strings.Replace(homepage, " volumes:", " privileged: true\n volumes:", 1)}, + } { + t.Run(name, func(t *testing.T) { + if err := admit(t, c.app, c.compose, env()); err == nil { + t.Fatal("admitted") + } + }) + } +} + +// The check fails CLOSED: a compose it cannot read is refused, not admitted. +func TestAdmit_UnreadableIsRefused(t *testing.T) { + if err := admit(t, "app", "services: [::", env()); err == nil { + t.Fatal("an unparseable compose was admitted") + } +} + +// The renderer follows compose's forms. +func TestRender_ComposeForms(t *testing.T) { + c := &checker{env: map[string]string{"A": "a", "E": ""}} + for in, want := range map[string]string{ + "${A}": "a", "$A/x": "a/x", "${B:-d}": "d", "${E:-d}": "d", "${E-d}": "", "${B-d}": "d", + "${A:+y}": "y", "${E:+y}": "", "${E+y}": "y", "$$A": "$A", "${A:?boom}": "a", + } { + if got := c.render(in); got != want { + t.Errorf("render(%q) = %q, want %q", in, got, want) + } + } +} + +// Every template and ladder step in the catalog passes on a placeholder env — the catalog as it is today needs +// exactly the allow-list it has. Runs where the sibling clone is present (the workspace); CI has none. +func TestAllowList_TheWholeCatalogPasses(t *testing.T) { + root := filepath.Join("..", "..", "..", "..", "app-catalog-felhom.eu", "templates") + if _, err := os.Stat(root); err != nil { + t.Skipf("no sibling catalog clone at %s — checked in the workspace, not in CI", root) + } + n := 0 + _ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + if info.Name() != "docker-compose.yml" && !(filepath.Base(filepath.Dir(p)) == "steps" && strings.HasSuffix(p, ".yml")) { + return nil + } + n++ + rel, _ := filepath.Rel(root, p) + app := strings.Split(rel, string(filepath.Separator))[0] + b, _ := os.ReadFile(p) + if err := Admit(app, b, PlaceholderEnv(), pol); err != nil { + t.Errorf("%s: %v", rel, err) + } + return nil + }) + if n < 100 { + t.Fatalf("only %d catalog files found — the walk did not see the catalog", n) + } +} + +// The catalog's CI copy of the list is byte-identical (the box's copy is the authority; the catalog's is an early +// warning). Where the sibling is absent (CI), the catalog's own gate runs the same comparison when it can. +func TestAllowList_CatalogCopyIsIdentical(t *testing.T) { + p := filepath.Join("..", "..", "..", "..", "app-catalog-felhom.eu", "scripts", "privilege-allowlist.json") + b, err := os.ReadFile(p) + if err != nil { + t.Skipf("no sibling catalog clone (%v) — checked in the workspace, not in CI", err) + } + if !bytes.Equal(b, AllowListJSON()) { + t.Fatalf("%s differs from internal/privpolicy/allowlist.json — change both in the same session", p) + } +} diff --git a/controller/internal/stacks/allowlist.go b/controller/internal/stacks/allowlist.go new file mode 100644 index 0000000..3ab13e2 --- /dev/null +++ b/controller/internal/stacks/allowlist.go @@ -0,0 +1,132 @@ +package stacks + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + + "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" + "gitea.dooplex.hu/admin/felhom-controller/internal/privpolicy" +) + +// R-971 (operator decision D2 = A): every path that writes an app's compose asks admitCompose first, on the RENDERED +// compose, and writes nothing when it refuses. The check itself is internal/privpolicy; this file gives it the app's +// env exactly as `compose up` will see it, and makes a refusal visible — one operator event per distinct refused +// definition, and one line on the app page (Stack.DefinitionRefused) until a later definition of the app is admitted. +// The structural guard that every writer asks first: TestAllowList_IsWiredIntoEveryComposeWriter. + +// defRefusal is the last refusal of one app. +type defRefusal struct { + sha string + msg string +} + +// SetDefinitionRefusedHook sets what a NEW refusal raises (main.go: the operator-only hub event +// `app_definition_refused`). Called once per distinct refused definition of an app, not on every 15-minute cycle. +func (m *Manager) SetDefinitionRefusedHook(fn func(name, detail string)) { m.defRefusedHook = fn } + +// policyEnv is env as `compose up` sees it: the app's values, DOMAIN, and the two derived path variables the +// controller adds itself (withPathVars — the derived values win, as they do at exec). +func (m *Manager) policyEnv(env map[string]string) map[string]string { + out := make(map[string]string, len(env)+3) + for k, v := range env { + out[k] = v + } + out["DOMAIN"] = m.cfg.Customer.Domain + if hdd := env["HDD_PATH"]; hdd != "" { + out["USERDATA_PATH"] = appbackup.UserdataDir(appbackup.NamespaceRootFor(hdd, m.sysDataPath)) + } + if ir := m.GetImportRoot(); ir != "" { + out["IMPORT_PATH"] = ir + } else { + delete(out, "IMPORT_PATH") + } + return out +} + +// admitCompose is the one question every compose writer asks. env is the app's env (nil → the placeholder env: a +// template checked before the app has values). A refusal is returned AND recorded; an admitted definition clears the +// app's refusal line. +func (m *Manager) admitCompose(name string, data []byte, env map[string]string) error { + e := privpolicy.PlaceholderEnv() + if env != nil { + e = m.policyEnv(env) + } + err := privpolicy.Admit(name, data, e, privpolicy.Policy{SystemDataPath: m.sysDataPath}) + m.noteAdmission(name, data, err) + return err +} + +// AdmitCompose is admitCompose for the callers outside this package (main.go's restore adapter). +func (m *Manager) AdmitCompose(name string, data []byte, env map[string]string) error { + return m.admitCompose(name, data, env) +} + +// admitWithNewValues is the settings change's question: the live compose, rendered with the app's decrypted env and +// the changed values on top. +func (m *Manager) admitWithNewValues(name, stackDir string, values map[string]string) error { + data, err := os.ReadFile(ComposePathIn(stackDir)) + if err != nil { + return err + } + env := map[string]string{} + if dec := LoadAppConfigDecrypted(stackDir, m.encKey); dec != nil { + for k, v := range dec.Env { + env[k] = v + } + } + for k, v := range values { + env[k] = v + } + return m.admitCompose(name, data, env) +} + +// AdmitSyncedCompose is the sync's question (sync.Syncer.SetAdmitFn): a deployed app is checked with its own env, a +// template of an app not installed with the placeholder env (the install checks again with the real values). +func (m *Manager) AdmitSyncedCompose(name string, data []byte) error { + var env map[string]string + if st, ok := m.GetStack(name); ok && st.Deployed { + if cfg := LoadAppConfigDecrypted(filepath.Dir(st.ComposePath), m.encKey); cfg != nil { + env = cfg.Env + } + } + return m.admitCompose(name, data, env) +} + +func (m *Manager) noteAdmission(name string, data []byte, err error) { + m.defRefusedMu.Lock() + if m.defRefused == nil { + m.defRefused = map[string]defRefusal{} + } + byApp := m.defRefused + if err == nil { + delete(byApp, name) + m.defRefusedMu.Unlock() + return + } + sum := sha256.Sum256(data) + sha := hex.EncodeToString(sum[:]) + prev, seen := byApp[name] + hook := m.defRefusedHook + told := sha + if hook == nil { + told = "" // not wired yet (a sync before main's hook): the first wired sight still raises the event + } + byApp[name] = defRefusal{sha: told, msg: err.Error()} + m.defRefusedMu.Unlock() + m.logger.Printf("[WARN] [stacks] %s: definition REFUSED by the allow-list (R-971), nothing written: %v", name, err) + if seen && prev.sha == sha { + return // the same definition again (the next 15-minute cycle): one event per definition + } + if hook != nil { + hook(name, err.Error()) + } +} + +// definitionRefused is the app page's line ("" when none). +func (m *Manager) definitionRefused(name string) string { + m.defRefusedMu.Lock() + defer m.defRefusedMu.Unlock() + return m.defRefused[name].msg +} diff --git a/controller/internal/stacks/allowlist_test.go b/controller/internal/stacks/allowlist_test.go new file mode 100644 index 0000000..ca4ab22 --- /dev/null +++ b/controller/internal/stacks/allowlist_test.go @@ -0,0 +1,112 @@ +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") + } +} diff --git a/controller/internal/stacks/allowlist_wiring_test.go b/controller/internal/stacks/allowlist_wiring_test.go new file mode 100644 index 0000000..9aa346a --- /dev/null +++ b/controller/internal/stacks/allowlist_wiring_test.go @@ -0,0 +1,168 @@ +package stacks + +import ( + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "sort" + "strings" + "testing" +) + +// TestAllowList_IsWiredIntoEveryComposeWriter (R-971) — the catalog allow-list is only a control if EVERY path that +// writes an app's compose asks it first. Walked as an AST (comments dropped, so a commented-out call does not count), +// the shape of TestAdmission_IsWiredIntoEveryProductionWriteLeg. +// +// 1. DISCOVERY: every production function that names the compose file (the literal "docker-compose.yml", a +// ComposePathIn / AppliedComposePath call, a .ComposePath field) AND calls a file write must be in `writers` — +// either GATED (its first call of the gate comes before its first write) or EXEMPT with the reason. A new writer +// fails this test until someone decides which it is. +// 2. DECISION POINTS: the paths that choose an app's env (install, the two settings changes, the two restores) ask +// the gate before their first mutation. +// +// RED-PROOF (felhom.eu audits/agent-allowlist-rotation-2026-10-17/P2/redproof-allowlist.txt): remove the admit call +// from copyTemplates → step 1 fails naming it; add a new function that writes ComposePathIn(dir) → step 1 fails as +// an unknown writer. +func TestAllowList_IsWiredIntoEveryComposeWriter(t *testing.T) { + type rule struct { + gate string // "" = exempt + why string // exempt: the reason + before string // gated: the write the gate must precede (the first call of this name) + } + writers := map[string]rule{ + "internal/sync/sync.go:copyTemplates": {gate: "admit", before: "writeIfChanged"}, + "internal/stacks/pin.go:advancePinTo": {gate: "admitCompose", before: "StoreAppliedDefinition"}, + "cmd/controller/main.go:RecreateStackDefinitionFromUnit": {gate: "AdmitDefinitionFromUnit", before: "WriteFile"}, + "internal/stacks/infra.go:ensureFileBrowser": {why: "controller-rendered infra stack — out of scope"}, + "internal/stacks/infra.go:reconcileFileBrowserImage": {why: "controller-rendered infra stack — out of scope"}, + "internal/stacks/install_interrupted.go:RecoverInterruptedInstalls": {why: "writes the interrupted-install marker, no compose"}, + "internal/stacks/pin.go:StoreAppliedDefinition": {why: "the STORED copy; it becomes live only through copyTemplates' frozen branch, which admits it"}, + "internal/stacks/update.go:runGuardedUpdate": {why: "copies the PREVIOUS compose aside (the undo); the new one is written by advancePinTo"}, + "internal/stacks/update.go:restoreDefinition": {why: "puts back the bytes the app ran on before the update — an undo adds nothing"}, + "internal/backup/offbox_reconstitute.go:captureLiveDefinition": {why: "a scratch copy in a temp dir, never brought up"}, + "internal/backup/recovery_unit.go:captureRecoveryUnit": {why: "the backup's copy; its way back in is RecreateStackDefinitionFromUnit (gated)"}, + } + decisions := []struct{ fn, gate, before string }{ + {"internal/stacks/deploy.go:DeployStack", "admitCompose", "SaveAppConfig"}, + {"internal/stacks/deploy.go:UpdateStackConfig", "admitWithNewValues", "SaveAppConfig"}, + {"internal/stacks/deploy.go:UpdateOptionalConfig", "admitWithNewValues", "SaveAppConfig"}, + {"internal/backup/restore_unit.go:RestoreFromRecoveryUnitAtWith", "admitRestoredDefinition", "StopStack"}, + {"internal/backup/offbox_reconstitute.go:ReconstituteFromOffsite", "admitRestoredDefinition", "StopStack"}, + } + + writes := map[string]bool{"WriteFile": true, "writeIfChanged": true, "copyIfChanged": true, "atomicWrite": true, + "Rename": true, "Create": true, "OpenFile": true, "StoreAppliedDefinition": true, "copyFile": true, "CopyFile": true} + root := filepath.Join("..", "..") + calls := map[string][]string{} // "dir/file.go:Func" → called names in source order + var found []string + for _, dir := range []string{"internal/stacks", "internal/sync", "internal/backup", "internal/appexport", + "internal/web", "internal/api", "cmd/controller"} { + files, _ := filepath.Glob(filepath.Join(root, dir, "*.go")) + if len(files) == 0 { + t.Fatalf("no Go files under %s — the walk cannot see the code it guards", dir) + } + for _, f := range files { + if strings.HasSuffix(f, "_test.go") { + continue + } + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, f, nil, 0) + if err != nil { + t.Fatal(err) + } + for _, d := range file.Decls { + fd, ok := d.(*ast.FuncDecl) + if !ok || fd.Body == nil { + continue + } + id := dir + "/" + filepath.Base(f) + ":" + fd.Name.Name + names, compose, writesFile := []string{}, false, false + ast.Inspect(fd.Body, func(n ast.Node) bool { + switch v := n.(type) { + case *ast.SelectorExpr: + if v.Sel.Name == "ComposePath" { + compose = true + } + case *ast.BasicLit: + if v.Value == `"docker-compose.yml"` { + compose = true + } + case *ast.CallExpr: + name := "" + switch fn := v.Fun.(type) { + case *ast.Ident: + name = fn.Name + case *ast.SelectorExpr: + name = fn.Sel.Name + } + if name == "ComposePathIn" || name == "AppliedComposePath" { + compose = true + } + if writes[name] { + writesFile = true + } + if name != "" { + names = append(names, name) + } + } + return true + }) + calls[id] = names + if compose && writesFile { + found = append(found, id) + } + } + } + } + + // 1. Discovery. + sort.Strings(found) + for _, id := range found { + r, known := writers[id] + if !known { + t.Errorf("%s writes a file and names the compose file, and the allow-list guard does not know it — gate it "+ + "(admitCompose before the write) or add it to `writers` as exempt with the reason", id) + continue + } + if r.gate == "" { + continue + } + assertCalledBefore(t, id, calls[id], r.gate, r.before) + } + for id := range writers { + if _, ok := calls[id]; !ok { + t.Errorf("fixture drift: %s no longer exists — this guard can no longer see that writer", id) + } + } + // 2. Decision points. + for _, d := range decisions { + c, ok := calls[d.fn] + if !ok { + t.Errorf("fixture drift: %s no longer exists", d.fn) + continue + } + assertCalledBefore(t, d.fn, c, d.gate, d.before) + } +} + +func assertCalledBefore(t *testing.T, id string, calls []string, gate, act string) { + t.Helper() + gi, ai := -1, -1 + for i, c := range calls { + if c == gate && gi < 0 { + gi = i + } + if c == act && ai < 0 { + ai = i + } + } + switch { + case gi < 0: + t.Errorf("%s: the allow-list gate %q is never called", id, gate) + case ai < 0: + t.Errorf("fixture drift: %s no longer calls %q — the guard cannot order the gate against it", id, act) + case gi > ai: + t.Errorf("%s: %q first appears after %q — the check must come before the write", id, gate, act) + } +} diff --git a/controller/internal/stacks/deploy.go b/controller/internal/stacks/deploy.go index 99768c6..8f8baeb 100644 --- a/controller/internal/stacks/deploy.go +++ b/controller/internal/stacks/deploy.go @@ -464,6 +464,16 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) { m.logger.Printf("[ERROR] [stacks] Deploy %s REFUSED: %v", req.StackName, err) return "", util.MsgError("err.stacks.needs_newer_controller", err.Error()) } + // R-971: the compose, rendered with THIS install's values, must pass the box's catalog allow-list — refused before + // anything is written. + if data, rerr := os.ReadFile(stack.ComposePath); rerr != nil { + clearDeploying() + return "", fmt.Errorf("reading the compose file of %s: %w", req.StackName, rerr) + } else if aerr := m.admitCompose(req.StackName, data, env); aerr != nil { + clearDeploying() + m.logger.Printf("[ERROR] [stacks] Deploy %s REFUSED by the allow-list: %v", req.StackName, aerr) + return "", util.MsgError("err.stacks.definition_refused", req.StackName, aerr.Error()) + } // `09` §3 decision 46: a gated template is installed CLOSED, and the gate's traefik file is written BEFORE // the first start (spike F2). Cannot write it → the install is refused: never published open. var gate *SetupGateRecord @@ -746,6 +756,10 @@ func (m *Manager) UpdateStackConfig(name string, values map[string]string) error if m.isDebug() { m.logger.Printf("[DEBUG] [stacks] UpdateStackConfig %s: changed keys: [%s], locked keys: %d", name, strings.Join(changedKeys, ", "), len(lockedSet)) } + // R-971: the compose, rendered with the NEW values, must still pass the box's allow-list — refused before the save. + if err := m.admitWithNewValues(name, stackDir, values); err != nil { + return util.MsgError("err.stacks.definition_refused", name, err.Error()) + } if err := SaveAppConfig(stackDir, appCfg, m.encKey, SensitiveEnvVars(&meta)); err != nil { return fmt.Errorf("saving updated config: %w", err) @@ -989,6 +1003,10 @@ func (m *Manager) UpdateOptionalConfig(stackName string, values map[string]strin if !changed { return nil } + // R-971: the compose, rendered with the NEW values, must still pass the box's allow-list — refused before the save. + if err := m.admitWithNewValues(stackName, stackDir, values); err != nil { + return util.MsgError("err.stacks.definition_refused", stackName, err.Error()) + } // Save app.yaml meta := LoadMetadata(stackDir) diff --git a/controller/internal/stacks/manager.go b/controller/internal/stacks/manager.go index 55f2322..f6ec1ab 100644 --- a/controller/internal/stacks/manager.go +++ b/controller/internal/stacks/manager.go @@ -189,9 +189,12 @@ type Stack struct { HoldNoWholeCopy bool `json:"hold_no_whole_copy,omitempty"` // HoldKind (v0.269.0): which hold — "update_failed", "unhealthy_stop" (decision 28: the page offers // Start, not a restore), "restore"; "" when none. - HoldKind string `json:"hold_kind,omitempty"` - HealthProbe *HealthProbeResult `json:"health_probe,omitempty"` // controller-side probe result - LastUpdated time.Time `json:"last_updated"` + HoldKind string `json:"hold_kind,omitempty"` + // DefinitionRefused (R-971) is the box's refusal of the app's newest catalog definition — the app keeps its last + // good compose. Filled on every read (allowlist.go); "" when none. + DefinitionRefused string `json:"definition_refused,omitempty"` + HealthProbe *HealthProbeResult `json:"health_probe,omitempty"` // controller-side probe result + LastUpdated time.Time `json:"last_updated"` // RestartingSince (C9-F2) is when this stack was FIRST observed in StateRestarting during the // current restarting run; zero whenever the stack is in any other state. It is what turns a brief // restart (normal: deploy, update, quiesce restart) into a distinguishable crash loop — see @@ -267,6 +270,10 @@ type Manager struct { // this and nothing else: it used to be sent beside the 202 that merely accepted the request, so // an install interrupted five seconds later stayed on the timeline as a completed one. deployDoneHook func(name string, ok bool, detail string) + // defRefusedHook (R-971) raises the operator event for a definition the allow-list refused (allowlist.go). + defRefusedHook func(name, detail string) + defRefusedMu sync.Mutex + defRefused map[string]defRefusal testSeams *migSeams // nil in production; tests inject fakes // R-51: docker restart policies for DOWN members of mixed stacks. Keyed by // containerName+"|"+state so a transitioned or recreated container re-reads rather than @@ -1123,6 +1130,7 @@ func (m *Manager) GetStacks() []Stack { m.mu.RUnlock() for i := range result { fillHoldReason(g, &result[i]) + result[i].DefinitionRefused = m.definitionRefused(result[i].Name) } // Sort alphabetically by display name for consistent UI ordering @@ -1143,6 +1151,7 @@ func (m *Manager) GetStack(name string) (*Stack, bool) { } cp := deepCopyStack(s) fillHoldReason(m.updateGuards, &cp) + cp.DefinitionRefused = m.definitionRefused(name) return &cp, true } diff --git a/controller/internal/stacks/pin.go b/controller/internal/stacks/pin.go index 52960ed..bd3054b 100644 --- a/controller/internal/stacks/pin.go +++ b/controller/internal/stacks/pin.go @@ -348,6 +348,18 @@ func (m *Manager) advancePinTo(name, stackDir, src, metaSrc string) error { // v0.269.0 (`09` §6.4 part 6): run EXACTLY the tested images — the ladder entry's digests for these refs. data = RenderWithLadderDigests(filepath.Dir(m.CatalogTemplatePath(name, "docker-compose.yml")), data) + // R-971: the new definition must pass the box's allow-list before anything is stored — a refused update leaves + // the app on its last good compose, running. + var env map[string]string + if dec := LoadAppConfigDecrypted(stackDir, m.encKey); dec != nil { + env = dec.Env + } + if env == nil { + env = map[string]string{} + } + if err := m.admitCompose(name, data, env); err != nil { + return err + } live := ComposePathIn(stackDir) if err := StoreAppliedDefinition(stackDir, data); err != nil { return fmt.Errorf("storing the new applied definition for %s: %w", name, err) diff --git a/controller/internal/sync/r971_allowlist_test.go b/controller/internal/sync/r971_allowlist_test.go new file mode 100644 index 0000000..19d11e2 --- /dev/null +++ b/controller/internal/sync/r971_allowlist_test.go @@ -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) + } +} diff --git a/controller/internal/sync/sync.go b/controller/internal/sync/sync.go index df4027e..ce0a018 100644 --- a/controller/internal/sync/sync.go +++ b/controller/internal/sync/sync.go @@ -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) } diff --git a/controller/internal/web/i18n_parity_test.go b/controller/internal/web/i18n_parity_test.go index 840e4a7..31ac15c 100644 --- a/controller/internal/web/i18n_parity_test.go +++ b/controller/internal/web/i18n_parity_test.go @@ -508,6 +508,18 @@ func i18nCases() []i18nCase { d["HoldLogKept"] = true return d }}) + // R-971 (v0.328.0): the box refused the catalog's newer definition of an installed app — the app page's line, + // captured when born. + base = append(base, i18nCase{"app_settings_definition_refused", "app_settings", func() map[string]interface{} { + d := i18nLayoutData("stacks", "Homepage") + st := stacks.Stack{Name: "homepage", Deployed: true, State: stacks.StateRunning, + DefinitionRefused: "the catalog definition of homepage asks for container rights the box does not allow: homepage: privileged true"} + st.Meta = stacks.Metadata{DisplayName: "Homepage", Slug: "homepage"} + d["Stack"] = st + d["Meta"] = st.Meta + d["AppInfo"] = st.Meta.AppInfo + return d + }}) // v0.320.0 — logins and folders: the infra app page (FileBrowser), the two login sentences a catalog key // chooses, and the „Fajlok" button with a media folder and a folder whose drive is away. Captured when born. infraFB := func() map[string]interface{} { diff --git a/controller/internal/web/r971_definition_refused_page_test.go b/controller/internal/web/r971_definition_refused_page_test.go new file mode 100644 index 0000000..6489d3a --- /dev/null +++ b/controller/internal/web/r971_definition_refused_page_test.go @@ -0,0 +1,35 @@ +package web + +import ( + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// R-971 (v0.328.0) — the box refused the catalog's newer definition of an installed app: the app page says so in the +// reader's language, and says nothing when there is no refusal. A render test PER BRANCH of the gate (seam-wiring). +// COMPANION RED-PROOF (controller REPORT.md): drop the `{{if .Stack.DefinitionRefused}}` block — the refused branch +// fails in both languages. +func TestR971_AppPage_SaysTheDefinitionWasRefused(t *testing.T) { + for _, refused := range []bool{true, false} { + st := ubStack(nil, map[string]string{"homepage": "ghcr.io/gethomepage/homepage:v1.13.2"}, "2026-07-18") + st.State = stacks.StateRunning + if refused { + st.DefinitionRefused = "the catalog definition of homepage asks for container rights the box does not allow: homepage: privileged true" + } + for lang, want := range map[string]string{"hu": "nem fogadta el", "en": "did not accept"} { + h := renderBackupPageLang(t, lang, "app_settings", appInfoData(st)) + has := strings.Contains(h, `data-definition-refused="true"`) + if refused && (!has || !strings.Contains(h, want)) { + t.Errorf("[%s] a refused definition is not on the page (marker %v, sentence %q)", lang, has, want) + } + if !refused && has { + t.Errorf("[%s] positive control: no refusal, yet the line is shown", lang) + } + if strings.Contains(h, "privileged true") { + t.Errorf("[%s] the household page shows the operator detail", lang) + } + } + } +} diff --git a/controller/internal/web/templates/app_shell.html b/controller/internal/web/templates/app_shell.html index beba358..4d3b81a 100644 --- a/controller/internal/web/templates/app_shell.html +++ b/controller/internal/web/templates/app_shell.html @@ -89,6 +89,8 @@
{{updateErrorText .Stack}}
{{else if .UpdateUndoneLine}}
{{.UpdateUndoneLine}}
+{{end}}{{if .Stack.DefinitionRefused}}{{/* R-971: the box refused the catalog's newer definition; the app runs on its last good one. */}} +
{{T "app_info.definition_refused"}}
{{end}}{{if and .Stack.HealthProbe .Stack.HealthProbe.NotChecked}}{{/* R-772: the declared health check could not run — said, never shown as healthy. */}}

{{T "health.no_probe_container"}}

{{end}} {{if .MissingStorageLabel}} diff --git a/controller/internal/web/testdata/i18n_parity/app_settings_definition_refused.html b/controller/internal/web/testdata/i18n_parity/app_settings_definition_refused.html new file mode 100644 index 0000000..92ac8b4 --- /dev/null +++ b/controller/internal/web/testdata/i18n_parity/app_settings_definition_refused.html @@ -0,0 +1,738 @@ + + + + + + + + Homepage — Felhom.eu + + + + + + + + +
+ + +
+ + +
+ + + + + + + +
+ +← Alkalmazások + +
+ +
+

Homepage

+ +
+ + ● Fut + + + + + + + +
+
+ + +
+ + + +
A katalógus újabb leírását ehhez az alkalmazáshoz a doboz nem fogadta el, mert olyan jogot kér, amit a doboz nem ad meg. Az alkalmazás a legutóbbi jó beállításaival fut tovább; az üzemeltetőt értesítettük.
+ + + + + + + + + + +
+ + + + + + +
+

Működés

+

Fut

+
+ + + + +
+
+ + + + +
+ + +
+

Belépés az alkalmazásba

+

Az admin fiókot az első megnyitáskor te hozod létre. A jelszót a doboz nem tudja — írd fel a jelszókezelődbe.

+
+ + +
+ + + + + + + + + + + + +
+

Cím és kulcsok

+

Ezek telepítéskor jöttek létre, és nem módosíthatók.

+ + +
+
+ + + + + + + + +
+ +
+ + + + diff --git a/controller/scripts/i18n_go_keys.json b/controller/scripts/i18n_go_keys.json index e4e27f4..b8c5673 100644 --- a/controller/scripts/i18n_go_keys.json +++ b/controller/scripts/i18n_go_keys.json @@ -157,6 +157,7 @@ "login.msg.wrong_password": "R-753 (v0.286.0) -- the dashboard login's messages moved to the informal voice on purpose (brief 2026-10-01: formal „Kérjük adja meg …\" → informal), so byte parity with the base literal cannot hold. Pinned in both languages by TestLoginMessagesFollowTheReader.", "err.stacks.family_gate_failed": "BORN AS A KEY, v0.287.0 (the family gate, decisions 63/64) -- a NEW sentence, never a Go literal. Pinned in both languages by TestFamilyGate_MessagesFollowTheReader.", "err.stacks.needs_newer_controller": "BORN AS A KEY, v0.287.0 (the family gate, decisions 63/64) -- a NEW sentence, never a Go literal. Pinned in both languages by TestFamilyGate_MessagesFollowTheReader.", + "err.stacks.definition_refused": "BORN AS A KEY, v0.328.0 (R-971, the catalog allow-list) -- a NEW sentence, never a Go literal. Pinned in both languages by TestDefinitionRefused_MessagesFollowTheReader.", "family_gate.msg.bad_name": "BORN AS A KEY, v0.287.0 (the family gate, decisions 63/64) -- a NEW sentence, never a Go literal. Pinned in both languages by TestFamilyGate_MessagesFollowTheReader.", "family_gate.msg.exists": "BORN AS A KEY, v0.287.0 (the family gate, decisions 63/64) -- a NEW sentence, never a Go literal. Pinned in both languages by TestFamilyGate_MessagesFollowTheReader.", "family_gate.msg.form_expired": "BORN AS A KEY, v0.287.0 (the family gate, decisions 63/64) -- a NEW sentence, never a Go literal. Pinned in both languages by TestFamilyGate_MessagesFollowTheReader.",