controller v0.73.0: DR recipe — emit secret-free customer+apps half in hub report

DR recipe slice (controller half), grounded in SPIKE-dr-recipe-2026-06-16. The
controller emitter is the BOUNDARY enforcement point: v1 ships an explicit
allowlist {catalog_ref, enabled, storage_bindings} and reads NOTHING from
AppConfig.Env, so no ENC:/token/password can leak. storage_bindings parsed from
the compose (${HDD_PATH}/${USERDATA_PATH} volume binds -> {container_path,
drive, subpath}).

Load-bearing tests: TestBuildAppRecipe_NoSecrets (synthetic-secret app -> none
leak) + TestBuildAppRecipe_AllowlistIsLoadBearing (red-proof companion) +
TestAppStorageBindings + TestBuildDRRecipeAppHalf. Red-proofed live: forcing the
emitter to dump Env makes the boundary test fail. recipe_version=1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 18:38:59 +02:00
parent 0d159d7e34
commit f3146ac7bf
7 changed files with 441 additions and 45 deletions
+5
View File
@@ -161,6 +161,11 @@ func BuildReport(
// "Inaktív" hub-side.
r.GeoRestriction = buildGeoRestrictionReport(geoRestriction)
// DR recipe app-half — customer identity + per-app {catalog_ref, enabled, storage_bindings}.
// Allowlist-only (the boundary): NO env/secret fields. The hub assembles it with the agent half.
r.DRRecipe = BuildDRRecipeAppHalf(cfg.Customer.ID, cfg.Customer.Name, cfg.Customer.Domain,
stackMgr.GetStacks(), readComposeFile)
if debug && logger != nil {
logger.Printf("[DEBUG] [report] BuildReport: complete — containers=%d, health=%s, deployed=%d, available=%d, app_telemetry=%d",
r.Containers.Total, r.Health.Status, len(r.Stacks.Deployed), len(r.Stacks.Available), len(r.AppTelemetry))
+161
View File
@@ -0,0 +1,161 @@
package report
import (
"bufio"
"os"
"path/filepath"
"strings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// readComposeFile is the production composeReader: best-effort read of a stack's docker-compose.yml
// (an unreadable file yields "" → no storage bindings for that app, never a failure).
func readComposeFile(path string) string {
data, err := os.ReadFile(path)
if err != nil {
return ""
}
return string(data)
}
// DR recipe — the controller (customer + apps) HALF of the secret-free reconstruction recipe
// (SPIKE-dr-recipe-2026-06-16). The recipe is the non-secret re-provision plan that complements escrow
// (keys) + PBS/restic (bytes). This emitter is THE BOUNDARY ENFORCEMENT POINT: the controller is the
// component that distinguishes secret from non-secret deploy fields (it encrypts ENC: secrets in
// app.yaml), so v1 ships ONLY an explicit allowlist — {catalog_ref, enabled, storage_bindings} — and
// NEVER touches AppConfig.Env. A new field cannot leak a secret because it is excluded by default
// (allowlist, not denylist). TestBuildAppRecipe_NoSecrets is the load-bearing proof.
//
// recipe_version=1; read is ignore-unknown (forward-compat). The hub assembles this half with the
// agent's storage/guest/PBS half into one customer recipe keyed by customer + recipe_version.
const DRRecipeVersion = 1
// DRRecipeAppHalf is the controller-emitted half.
type DRRecipeAppHalf struct {
RecipeVersion int `json:"recipe_version"`
Customer DRCustomer `json:"customer"`
Apps []AppRecipe `json:"apps"`
}
// DRCustomer is the customer identity — public identifiers only.
type DRCustomer struct {
ID string `json:"id"`
Display string `json:"display"`
Domain string `json:"domain"`
}
// AppRecipe is the v1 per-app allowlist. NOTHING from AppConfig.Env is emitted — the three fields below
// are the entire surface, so no env secret (ENC: value, token, password) can ride along.
type AppRecipe struct {
CatalogRef string `json:"catalog_ref"`
Enabled bool `json:"enabled"`
StorageBindings []StorageBinding `json:"storage_bindings"`
}
// StorageBinding names WHERE an app's data lives on a user-data drive: the container mount target, the
// drive name, and the path under the drive. Identifiers/paths only — e.g. romm.library → felhom-flash
// : userdata/roms. The hub correlates `drive` to the agent half's durable-id.
type StorageBinding struct {
ContainerPath string `json:"container_path"`
Drive string `json:"drive"`
Subpath string `json:"subpath"`
}
// BuildDRRecipeAppHalf assembles the controller half: customer identity + an AppRecipe per DEPLOYED,
// non-protected stack. composeReader returns a stack's docker-compose.yml content (seam: tests inject a
// fake; production passes os.ReadFile-backed). Pure given the reader → unit-tested directly.
func BuildDRRecipeAppHalf(custID, custName, domain string, all []stacks.Stack, composeReader func(path string) string) *DRRecipeAppHalf {
half := &DRRecipeAppHalf{
RecipeVersion: DRRecipeVersion,
Customer: DRCustomer{ID: custID, Display: custName, Domain: domain},
Apps: []AppRecipe{},
}
for _, s := range all {
if s.Protected || !s.Deployed {
continue
}
var composeYAML string
if composeReader != nil && s.ComposePath != "" {
composeYAML = composeReader(s.ComposePath)
}
half.Apps = append(half.Apps, buildAppRecipe(s, composeYAML))
}
return half
}
// buildAppRecipe constructs ONE app's recipe from the allowlist ONLY. This function deliberately reads
// NOTHING from s.AppConfig.Env — that is the boundary. catalog_ref = the catalog slug (falls back to the
// stack name for an orphaned app); storage_bindings parsed from the compose against the app's HDD_PATH.
func buildAppRecipe(s stacks.Stack, composeYAML string) AppRecipe {
catalogRef := s.Meta.Slug
if catalogRef == "" {
catalogRef = s.Name
}
var hddPath string
if s.AppConfig != nil {
hddPath = s.AppConfig.Env["HDD_PATH"] // a path identifier, NOT a secret (secrets are ENC: in Env)
}
return AppRecipe{
CatalogRef: catalogRef,
Enabled: s.Deployed,
StorageBindings: appStorageBindings(composeYAML, hddPath),
}
}
// appStorageBindings parses a docker-compose.yml for volume binds that land on the app's user-data drive
// (under HDD_PATH, or its USERDATA_PATH=<HDD_PATH>/userdata sibling) and returns {container_path, drive,
// subpath}. Pure (operates on the compose text), so it is unit-tested without files. The drive name is
// the basename of HDD_PATH (e.g. /mnt/felhom-drives/felhom-flash → "felhom-flash").
func appStorageBindings(composeYAML, hddPath string) []StorageBinding {
if hddPath == "" || composeYAML == "" {
return []StorageBinding{}
}
cleanHDD := filepath.ToSlash(filepath.Clean(hddPath))
userdata := cleanHDD + "/userdata"
drive := filepath.Base(cleanHDD)
bindings := []StorageBinding{}
seen := map[string]bool{}
scanner := bufio.NewScanner(strings.NewReader(composeYAML))
inVolumes := false
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "volumes:") {
inVolumes = true
continue
}
if inVolumes && !strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "#") && line != "" {
inVolumes = false
}
if !inVolumes || !strings.HasPrefix(line, "- ") {
continue
}
mountStr := strings.Trim(strings.TrimPrefix(line, "- "), "\"'")
parts := strings.SplitN(mountStr, ":", 3)
if len(parts) < 2 {
continue
}
host := parts[0]
host = strings.ReplaceAll(host, "${USERDATA_PATH}", userdata)
host = strings.ReplaceAll(host, "${HDD_PATH}", cleanHDD)
// Also tolerate the un-braced $VAR form.
host = strings.ReplaceAll(host, "$USERDATA_PATH", userdata)
host = strings.ReplaceAll(host, "$HDD_PATH", cleanHDD)
host = filepath.ToSlash(filepath.Clean(host))
if host != cleanHDD && !strings.HasPrefix(host, cleanHDD+"/") {
continue // not on this drive
}
subpath := strings.TrimPrefix(host, cleanHDD)
subpath = strings.TrimPrefix(subpath, "/")
container := parts[1]
key := container + "\x00" + subpath
if seen[key] {
continue
}
seen[key] = true
bindings = append(bindings, StorageBinding{ContainerPath: container, Drive: drive, Subpath: subpath})
}
return bindings
}
@@ -0,0 +1,201 @@
package report
import (
"encoding/json"
"regexp"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// secretNameRe matches any JSON key that smells like a credential — mirrors the agent half.
var secretNameRe = regexp.MustCompile(`(?i)(password|secret|token|hash|passphrase|api[_-]?key|\bkey\b|enc:)`)
// rommCompose is a realistic catalog compose: user-data binds via ${USERDATA_PATH}/${HDD_PATH}, plus a
// secret-laden environment section (which must NEVER reach the recipe — bindings come from volumes only).
const rommCompose = `services:
romm:
image: romm:latest
environment:
- DB_PASSWORD=${DB_PASSWORD}
- IGDB_CLIENT_SECRET=${IGDB_CLIENT_SECRET}
volumes:
- ${USERDATA_PATH}/roms:/roms
- ${HDD_PATH}/appdata/romm/resources:/romm/resources
- romm_redis_data:/data
volumes:
romm_redis_data:
`
// secretLadenStack builds a romm Stack whose persisted Env carries synthetic secrets (an ENC: value and
// a token-shaped value) — exactly what the recipe must keep out.
func secretLadenStack() stacks.Stack {
return stacks.Stack{
Name: "romm",
Meta: stacks.Metadata{Slug: "romm", DisplayName: "RomM"},
ComposePath: "/stacks/romm/docker-compose.yml",
Deployed: true,
AppConfig: &stacks.AppConfig{
Deployed: true,
Env: map[string]string{
"HDD_PATH": "/mnt/felhom-drives/felhom-flash",
"DB_PASSWORD": "ENC:U2FsdGVkX1+DEADBEEFsecret==",
"IGDB_CLIENT_SECRET": "tok_live_SUPERSECRET_must_not_leak",
"SECRET_KEY": "ENC:another_encrypted_blob",
},
},
}
}
// TestBuildAppRecipe_NoSecrets is THE load-bearing boundary test: the emitted AppRecipe for an app whose
// deploy config contains secrets (ENC: + token-shaped) carries NONE of those values and NO
// credential-shaped key — only the {catalog_ref, enabled, storage_bindings} allowlist.
func TestBuildAppRecipe_NoSecrets(t *testing.T) {
s := secretLadenStack()
rec := buildAppRecipe(s, rommCompose)
b, err := json.Marshal(rec)
if err != nil {
t.Fatal(err)
}
out := string(b)
// (1) none of the secret VALUES survived.
for _, leak := range []string{"ENC:", "U2FsdGVkX1", "DEADBEEF", "tok_live_SUPERSECRET_must_not_leak", "another_encrypted_blob"} {
if strings.Contains(out, leak) {
t.Errorf("SECRET LEAK: emitted recipe contains %q\n recipe: %s", leak, out)
}
}
// (2) no credential-shaped KEY survived (DB_PASSWORD / SECRET_KEY / IGDB_CLIENT_SECRET as keys).
assertNoSecretKeys(t, b)
// (3) positive: we DID emit the allowlisted facts (not a vacuous pass).
if rec.CatalogRef != "romm" || !rec.Enabled {
t.Errorf("expected catalog_ref=romm enabled=true, got %+v", rec)
}
if len(rec.StorageBindings) != 2 {
t.Fatalf("expected 2 storage bindings (roms + resources), got %+v", rec.StorageBindings)
}
}
// TestBuildAppRecipe_AllowlistIsLoadBearing is the companion (red-proof of the boundary test): a NAIVE
// emitter that dumps AppConfig.Env (i.e. the allowlist guard removed) WOULD leak the token — proving the
// no-secrets assertion above is real, not vacuous. The production emitter must NOT leak it.
func TestBuildAppRecipe_AllowlistIsLoadBearing(t *testing.T) {
s := secretLadenStack()
const token = "tok_live_SUPERSECRET_must_not_leak"
// The "guard removed" shape — dumping Env alongside the app. This is what the boundary forbids.
unsafe, _ := json.Marshal(map[string]any{"catalog_ref": s.Meta.Slug, "env": s.AppConfig.Env})
if !strings.Contains(string(unsafe), token) {
t.Fatal("companion is broken: the unsafe (guard-removed) shape should contain the secret token")
}
// The REAL emitter must keep it out — same fixture, allowlist intact.
real, _ := json.Marshal(buildAppRecipe(s, rommCompose))
if strings.Contains(string(real), token) {
t.Fatalf("BOUNDARY VIOLATION: production emitter leaked the token: %s", real)
}
}
func TestAppStorageBindings(t *testing.T) {
got := appStorageBindings(rommCompose, "/mnt/felhom-drives/felhom-flash")
want := map[string]StorageBinding{
"/roms": {ContainerPath: "/roms", Drive: "felhom-flash", Subpath: "userdata/roms"},
"/romm/resources": {ContainerPath: "/romm/resources", Drive: "felhom-flash", Subpath: "appdata/romm/resources"},
}
if len(got) != len(want) {
t.Fatalf("got %d bindings, want %d: %+v", len(got), len(want), got)
}
for _, b := range got {
w, ok := want[b.ContainerPath]
if !ok || b != w {
t.Errorf("binding %+v unexpected (want %+v)", b, w)
}
}
// The named volume (romm_redis_data) is NOT a drive bind → excluded.
for _, b := range got {
if strings.Contains(b.Subpath, "redis") {
t.Errorf("named volume leaked into bindings: %+v", b)
}
}
}
// TestAppStorageBindings_NoHDD: an app with no HDD_PATH (rootfs-only) yields no bindings, non-nil slice.
func TestAppStorageBindings_NoHDD(t *testing.T) {
got := appStorageBindings(rommCompose, "")
if got == nil || len(got) != 0 {
t.Errorf("no HDD_PATH should yield empty (non-nil) bindings, got %+v", got)
}
}
func TestBuildDRRecipeAppHalf(t *testing.T) {
reader := func(path string) string {
if path == "/stacks/romm/docker-compose.yml" {
return rommCompose
}
return ""
}
all := []stacks.Stack{
secretLadenStack(),
{Name: "traefik", Protected: true, Deployed: true}, // protected → excluded
{Name: "vikunja", Meta: stacks.Metadata{Slug: "vikunja"}, Deployed: false}, // not deployed → excluded
{Name: "actualbudget", Meta: stacks.Metadata{Slug: "actualbudget"}, Deployed: true}, // rootfs app, no compose path
}
half := BuildDRRecipeAppHalf("cust-demo", "Demo Customer", "demo-felhom.eu", all, reader)
if half.RecipeVersion != 1 {
t.Errorf("recipe_version=%d want 1", half.RecipeVersion)
}
if half.Customer.ID != "cust-demo" || half.Customer.Display != "Demo Customer" || half.Customer.Domain != "demo-felhom.eu" {
t.Errorf("customer = %+v", half.Customer)
}
// Only romm + actualbudget (deployed, non-protected). traefik (protected) + vikunja (not deployed) out.
if len(half.Apps) != 2 {
t.Fatalf("expected 2 apps, got %d: %+v", len(half.Apps), half.Apps)
}
byRef := map[string]AppRecipe{}
for _, a := range half.Apps {
byRef[a.CatalogRef] = a
}
if r, ok := byRef["romm"]; !ok || len(r.StorageBindings) != 2 {
t.Errorf("romm recipe wrong: %+v", r)
}
if r, ok := byRef["actualbudget"]; !ok || len(r.StorageBindings) != 0 {
t.Errorf("actualbudget (rootfs) should have 0 bindings: %+v", r)
}
// Whole-half no-secrets sweep.
b, _ := json.Marshal(half)
assertNoSecretKeys(t, b)
if strings.Contains(string(b), "tok_live_SUPERSECRET_must_not_leak") {
t.Errorf("SECRET LEAK in assembled app-half: %s", b)
}
}
// assertNoSecretKeys walks decoded JSON and fails on any object key matching secretNameRe.
func assertNoSecretKeys(t *testing.T, jsonBytes []byte) {
t.Helper()
var v any
if err := json.Unmarshal(jsonBytes, &v); err != nil {
t.Fatal(err)
}
var walk func(prefix string, node any)
walk = func(prefix string, node any) {
switch n := node.(type) {
case map[string]any:
for k, child := range n {
if secretNameRe.MatchString(k) {
t.Errorf("secret-shaped key %q at %s — the recipe must carry no credential field", k, prefix)
}
walk(prefix+"."+k, child)
}
case []any:
for _, child := range n {
walk(prefix, child)
}
}
}
walk("<root>", v)
}
+4
View File
@@ -24,6 +24,10 @@ type Report struct {
Stacks StacksReport `json:"stacks"`
AppTelemetry []AppTelemetry `json:"app_telemetry,omitempty"`
GeoRestriction *GeoRestrictionReport `json:"geo_restriction,omitempty"`
// DR recipe — the controller (customer + apps) half of the secret-free reconstruction recipe
// (SPIKE-dr-recipe-2026-06-16). The hub assembles it with the agent's storage/guest/PBS half.
DRRecipe *DRRecipeAppHalf `json:"dr_recipe,omitempty"`
}
// SystemReport holds host-level system info.