0b09a799cb
Pairs with agent v0.77.0. StageEscrowSecret pushes the repo password to the agent (POST /escrow/stage-secret) at offsite-enable → EscrowState="pending". Atomicity gate: RunOffboxBackup (scheduler + handler) refuses until EscrowState="escrowed" (operator POST /backup/offbox/confirm-escrow after the escrow ceremony) — no un-recoverable offsite ciphertext can exist. DR: POST /backup/offbox/inject-password pre-places a recovered 64-hex password 0600 (honored by WriteOffboxSecrets' IsNotExist guard; refuses clobber without force). DR recipe gains non-secret offsite_restic coords (DRResticCoord); SFTP key regenerated at DR, not escrowed. New settings.OffboxTarget.EscrowState. Tests + atomicity & inject companion red-proofs green; UI gates pass. NOT yet live-validated (supervised ceremony). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
175 lines
6.9 KiB
Go
175 lines
6.9 KiB
Go
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"`
|
|
// OffsiteRestic (fork-4) is the non-secret location of the offsite restic repo, so DR knows WHERE to
|
|
// recover from. nil when offsite is not configured. Coordinates ONLY — see DRResticCoord.
|
|
OffsiteRestic *DRResticCoord `json:"offsite_restic,omitempty"`
|
|
}
|
|
|
|
// DRResticCoord is the offsite restic repo's non-secret coordinates. The repo PASSWORD rides the R-escrow
|
|
// (IdentityBundle.ResticRepoPassword); the SFTP access key is regenerated at DR (a fresh sub-account key) —
|
|
// so NEITHER appears here. All field names deliberately clear the _NoSecrets regex (no password/key/token).
|
|
type DRResticCoord struct {
|
|
Host string `json:"host"`
|
|
User string `json:"user"`
|
|
Port int `json:"port"`
|
|
RepoPath string `json:"repo_path"`
|
|
}
|
|
|
|
// 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
|
|
}
|