v0.324.0: the database password comes back with the data (R-1106, R-127b, R-926); restic 0.19.1 (R-1107); R-1108, R-1065, R-1017; dashboard hardening (R-983, R-976)
gates / gates (push) Successful in 44s
gates / gates (push) Successful in 44s
This commit is contained in:
@@ -3,6 +3,7 @@ package appbackup
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -80,3 +81,121 @@ func DBServiceNames(composePath string) ([]string, error) {
|
||||
sort.Strings(names)
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// dbCredentialKeyRe matches a database service's credential variable (POSTGRES_PASSWORD, MARIADB_ROOT_PASSWORD,
|
||||
// MYSQL_PASSWORD, …): the engine images read these ONLY when they initialise an empty data directory, so once a
|
||||
// restore puts a data directory back, the value that matches it is the one it was created with (R-1106).
|
||||
var dbCredentialKeyRe = regexp.MustCompile(`^(POSTGRES|PG|MARIADB|MYSQL)_[A-Z_]*PASSWORD$`)
|
||||
|
||||
// interpRefRe pulls the variable names out of a compose value: ${NAME}, ${NAME:-x}, ${NAME-x}, $NAME.
|
||||
var interpRefRe = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(?:[:]?[-?+][^}]*)?\}|\$([A-Za-z_][A-Za-z0-9_]*)`)
|
||||
|
||||
type composeDBEnvDoc struct {
|
||||
Services map[string]struct {
|
||||
Image string `yaml:"image"`
|
||||
Environment yaml.Node `yaml:"environment"`
|
||||
} `yaml:"services"`
|
||||
}
|
||||
|
||||
// DBCredentialVars (R-1106, `07` §7.4 — operator decision 1A, 2026-10-16) returns the sorted, unique app.yaml
|
||||
// variable names that feed a database service's credential variables in composePath — e.g. rallly's
|
||||
// `POSTGRES_PASSWORD=${DB_PASSWORD}` → DB_PASSWORD. These are the values a restore must put back WITH the data it puts
|
||||
// back: a fresh install's generated value does not open a restored data directory. A literal credential (no
|
||||
// variable) yields nothing — there is nothing to carry. Same parse and same DB predicate as DBServiceNames, so the
|
||||
// two cannot disagree about which services hold a database. Pinned by TestR1106_DBCredentialVars*.
|
||||
func DBCredentialVars(composePath string) ([]string, error) {
|
||||
data, err := os.ReadFile(composePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading compose file: %w", err)
|
||||
}
|
||||
var doc composeDBEnvDoc
|
||||
if err := yaml.Unmarshal(data, &doc); err != nil {
|
||||
return nil, fmt.Errorf("parsing compose file %s: %w", composePath, err)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
add := func(key, val string) {
|
||||
if !dbCredentialKeyRe.MatchString(key) {
|
||||
return
|
||||
}
|
||||
for _, m := range interpRefRe.FindAllStringSubmatch(val, -1) {
|
||||
n := m[1]
|
||||
if n == "" {
|
||||
n = m[2]
|
||||
}
|
||||
seen[n] = true
|
||||
}
|
||||
}
|
||||
for _, svc := range doc.Services {
|
||||
if _, ok := dbTypeForImage(svc.Image); !ok {
|
||||
continue
|
||||
}
|
||||
env := svc.Environment
|
||||
switch env.Kind {
|
||||
case yaml.SequenceNode: // - KEY=value
|
||||
for _, it := range env.Content {
|
||||
if k, v, ok := strings.Cut(it.Value, "="); ok {
|
||||
add(strings.TrimSpace(k), v)
|
||||
}
|
||||
}
|
||||
case yaml.MappingNode: // KEY: value
|
||||
for i := 0; i+1 < len(env.Content); i += 2 {
|
||||
add(env.Content[i].Value, env.Content[i+1].Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for n := range seen {
|
||||
out = append(out, n)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type composeDBVolDoc struct {
|
||||
Services map[string]struct {
|
||||
Image string `yaml:"image"`
|
||||
Volumes []interface{} `yaml:"volumes"`
|
||||
} `yaml:"services"`
|
||||
}
|
||||
|
||||
// DBDataVolumes (R-1106) returns the sorted NAMED volumes (not binds) that a database service in composePath mounts —
|
||||
// its data directory. A restore that replays one of these puts back a data directory that only its own password
|
||||
// opens; a restore that replays only a logical dump does not, and the current password stays right.
|
||||
func DBDataVolumes(composePath string) ([]string, error) {
|
||||
data, err := os.ReadFile(composePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading compose file: %w", err)
|
||||
}
|
||||
var doc composeDBVolDoc
|
||||
if err := yaml.Unmarshal(data, &doc); err != nil {
|
||||
return nil, fmt.Errorf("parsing compose file %s: %w", composePath, err)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, svc := range doc.Services {
|
||||
if _, ok := dbTypeForImage(svc.Image); !ok {
|
||||
continue
|
||||
}
|
||||
for _, v := range svc.Volumes {
|
||||
var src string
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
src, _, _ = strings.Cut(t, ":")
|
||||
case map[string]interface{}:
|
||||
if typ, _ := t["type"].(string); typ != "" && typ != "volume" {
|
||||
continue
|
||||
}
|
||||
src, _ = t["source"].(string)
|
||||
}
|
||||
if src == "" || strings.ContainsAny(src, "/.$~") {
|
||||
continue // a bind or an interpolated path — not a named volume
|
||||
}
|
||||
seen[src] = true
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for n := range seen {
|
||||
out = append(out, n)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ func IsEnrolledDrive(drivePath, systemDataPath string) bool {
|
||||
// PrimaryBackupPath, RecoveryUnitPath and AppDataDir all take a NAMESPACE ROOT. If you are holding
|
||||
// something that came out of HDD_PATH or a StoragePath, it is a DRIVE path — put it through here
|
||||
// first. `UserdataDir(bareDrivePath)` still compiles and is still wrong; TestNoBareDrivePathToUserdataDir
|
||||
// is the guard that keeps the count from growing.
|
||||
// (userdata_bare_drive_guard_test.go, R-1017) is the guard that keeps the count from growing.
|
||||
func NamespaceRootFor(drivePath, systemDataPath string) string {
|
||||
return NamespaceRoot(drivePath, IsEnrolledDrive(drivePath, systemDataPath))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
package appbackup
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestNoBareDrivePathToUserdataDir is the guard the NamespaceRootFor doc comment (paths.go) names (R-1017).
|
||||
//
|
||||
// THE COUNT: UserdataDir takes a felhom-data NAMESPACE ROOT. R-203 found five callers passing a bare DRIVE path
|
||||
// (HDD_PATH, a StoragePath) instead — identical on an enrolled drive, one `felhom-data` segment short on the
|
||||
// system-data fallback, so the app and its backup looked in different directories while the run said ok. This
|
||||
// test reads every non-test Go file of the module and checks the argument of EVERY UserdataDir call. An argument
|
||||
// is accepted only when its shape says "namespace root" by construction:
|
||||
//
|
||||
// - a call to the resolver: NamespaceRootFor / NamespaceRoot, or a provider method documented as returning
|
||||
// the namespace root (GetStackNamespaceRoot, StackNamespaceRoot, namespaceRoot);
|
||||
// - filepath.Clean of an accepted argument;
|
||||
// - an identifier named nsRoot — the contract's own parameter name. LIMIT, stated: a variable named nsRoot
|
||||
// that holds a drive path passes; the name is the declaration the reviewer reads, and a lie there is a
|
||||
// louder one than a bare `hddPath`.
|
||||
//
|
||||
// Anything else is a bare drive path until shown otherwise (fail-safe: ambiguity refuses). A reference to
|
||||
// UserdataDir that is not a direct call (a func value) cannot be checked and is refused too. Known exceptions
|
||||
// live in bareDriveAllowed with their reason; the test fails if one disappears too, so the list stays exact.
|
||||
//
|
||||
// COMPANION RED-PROOFS (R-1017): (1) a temporary `appbackup.UserdataDir(hddPath)` added to stacks/deploy.go → FAIL,
|
||||
// naming the file and function; (2) userdataKept (stacks/delete.go) reverted to the bare hddPath → FAIL.
|
||||
func TestNoBareDrivePathToUserdataDir(t *testing.T) {
|
||||
root := moduleRoot(t)
|
||||
calls, violations := scanUserdataDirCalls(t, root)
|
||||
|
||||
// Positive control (rule 3: an absent finding is not evidence): the scan MUST reach the known callers,
|
||||
// or a scope bug (wrong root, one-level walk) would turn this test green on an empty census.
|
||||
for _, want := range []string{
|
||||
"internal/stacks/deploy.go:withPathVars",
|
||||
"internal/appexport/export.go",
|
||||
"internal/web/handlers.go",
|
||||
"internal/appbackup/userdata.go",
|
||||
} {
|
||||
found := false
|
||||
for _, c := range calls {
|
||||
if strings.HasPrefix(c, want) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("the census did not reach %s — the scan is blind (calls seen: %v)", want, calls)
|
||||
}
|
||||
}
|
||||
|
||||
got := map[string]bool{}
|
||||
for _, v := range violations {
|
||||
key := v.file + ":" + v.fn
|
||||
if _, ok := bareDriveAllowed[key]; ok {
|
||||
got[key] = true
|
||||
continue
|
||||
}
|
||||
t.Errorf("%s:%d (%s): UserdataDir(%s) — the argument is not a namespace root by construction. "+
|
||||
"Put a drive path through appbackup.NamespaceRootFor(drive, systemDataPath) first (R-203).",
|
||||
v.file, v.line, v.fn, v.arg)
|
||||
}
|
||||
for key, why := range bareDriveAllowed {
|
||||
if !got[key] {
|
||||
t.Errorf("allowed exception %s no longer passes a bare path — remove it from bareDriveAllowed (the count shrank; was: %s)", key, why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bareDriveAllowed is the count. Keyed "relative/file.go:Function" (not a line number, which drifts).
|
||||
var bareDriveAllowed = map[string]string{
|
||||
"internal/stacks/media_library.go:mediaBindSource": "drive comes from GetStoragePaths (MediaDrives); the system " +
|
||||
"drive is never a registered StoragePath (R-203 changelog), so every such drive is an enrolled in-guest " +
|
||||
"mount and IS its namespace root. Latent, not live — routing it needs the system-data path here.",
|
||||
}
|
||||
|
||||
type bareCall struct {
|
||||
file, fn, arg string
|
||||
line int
|
||||
}
|
||||
|
||||
const appbackupImport = "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||
|
||||
func moduleRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir, err := filepath.Abs(".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
||||
return dir
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
t.Fatal("go.mod not found above the test directory")
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
// scanUserdataDirCalls returns every UserdataDir call site ("file:Func") and those whose argument is not a
|
||||
// namespace root by construction. Recursive walk (os.walk, not one level — the R-421 scope trap).
|
||||
func scanUserdataDirCalls(t *testing.T, root string) (calls []string, bad []bareCall) {
|
||||
t.Helper()
|
||||
fset := token.NewFileSet()
|
||||
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
n := d.Name()
|
||||
if p != root && (n == "vendor" || n == "testdata" || n == "node_modules" || strings.HasPrefix(n, ".")) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
f, perr := parser.ParseFile(fset, p, nil, 0)
|
||||
if perr != nil {
|
||||
return perr
|
||||
}
|
||||
rel, _ := filepath.Rel(root, p)
|
||||
rel = filepath.ToSlash(rel)
|
||||
c, b := scanFile(fset, f, rel)
|
||||
calls = append(calls, c...)
|
||||
bad = append(bad, b...)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walking %s: %v", root, err)
|
||||
}
|
||||
sort.Strings(calls)
|
||||
return calls, bad
|
||||
}
|
||||
|
||||
// scanFile finds the UserdataDir references of one file. Inside package appbackup the call is unqualified;
|
||||
// elsewhere it is <alias>.UserdataDir for the appbackup import (alias honoured).
|
||||
func scanFile(fset *token.FileSet, f *ast.File, rel string) (calls []string, bad []bareCall) {
|
||||
inPkg := f.Name.Name == "appbackup"
|
||||
alias := ""
|
||||
for _, imp := range f.Imports {
|
||||
if path, _ := strconv.Unquote(imp.Path.Value); path == appbackupImport {
|
||||
alias = "appbackup"
|
||||
if imp.Name != nil {
|
||||
alias = imp.Name.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
if !inPkg && alias == "" {
|
||||
return nil, nil
|
||||
}
|
||||
isUserdataDir := func(e ast.Expr) bool {
|
||||
switch x := e.(type) {
|
||||
case *ast.Ident:
|
||||
return inPkg && x.Name == "UserdataDir"
|
||||
case *ast.SelectorExpr:
|
||||
id, ok := x.X.(*ast.Ident)
|
||||
return ok && alias != "" && id.Name == alias && x.Sel.Name == "UserdataDir"
|
||||
}
|
||||
return false
|
||||
}
|
||||
isResolver := func(fun ast.Expr) bool {
|
||||
switch x := fun.(type) {
|
||||
case *ast.Ident:
|
||||
return inPkg && (x.Name == "NamespaceRootFor" || x.Name == "NamespaceRoot")
|
||||
case *ast.SelectorExpr:
|
||||
if id, ok := x.X.(*ast.Ident); ok && alias != "" && id.Name == alias {
|
||||
return x.Sel.Name == "NamespaceRootFor" || x.Sel.Name == "NamespaceRoot"
|
||||
}
|
||||
switch x.Sel.Name {
|
||||
case "GetStackNamespaceRoot", "StackNamespaceRoot", "namespaceRoot":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
var nsRootArg func(e ast.Expr) bool
|
||||
nsRootArg = func(e ast.Expr) bool {
|
||||
switch x := e.(type) {
|
||||
case *ast.ParenExpr:
|
||||
return nsRootArg(x.X)
|
||||
case *ast.Ident:
|
||||
return x.Name == "nsRoot"
|
||||
case *ast.CallExpr:
|
||||
if sel, ok := x.Fun.(*ast.SelectorExpr); ok {
|
||||
if id, ok := sel.X.(*ast.Ident); ok && id.Name == "filepath" && sel.Sel.Name == "Clean" && len(x.Args) == 1 {
|
||||
return nsRootArg(x.Args[0])
|
||||
}
|
||||
}
|
||||
return isResolver(x.Fun)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
visit := func(fn string, body ast.Node) {
|
||||
called := map[ast.Expr]bool{}
|
||||
ast.Inspect(body, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok || !isUserdataDir(call.Fun) {
|
||||
return true
|
||||
}
|
||||
called[call.Fun] = true
|
||||
calls = append(calls, rel+":"+fn)
|
||||
if len(call.Args) != 1 || !nsRootArg(call.Args[0]) {
|
||||
arg := "?"
|
||||
if len(call.Args) == 1 {
|
||||
arg = exprString(fset, call.Args[0])
|
||||
}
|
||||
bad = append(bad, bareCall{file: rel, fn: fn, arg: arg, line: fset.Position(call.Pos()).Line})
|
||||
}
|
||||
return true
|
||||
})
|
||||
// A func-value reference (f := appbackup.UserdataDir) escapes the argument check — refuse it.
|
||||
ast.Inspect(body, func(n ast.Node) bool {
|
||||
e, ok := n.(ast.Expr)
|
||||
if ok && isUserdataDir(e) && !called[e] {
|
||||
bad = append(bad, bareCall{file: rel, fn: fn, arg: "<not a direct call>", line: fset.Position(e.Pos()).Line})
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
for _, decl := range f.Decls {
|
||||
switch d := decl.(type) {
|
||||
case *ast.FuncDecl:
|
||||
if d.Body != nil {
|
||||
visit(d.Name.Name, d.Body)
|
||||
}
|
||||
case *ast.GenDecl:
|
||||
visit("<package-level>", d)
|
||||
}
|
||||
}
|
||||
return calls, bad
|
||||
}
|
||||
|
||||
func exprString(fset *token.FileSet, e ast.Expr) string {
|
||||
start, end := fset.Position(e.Pos()), fset.Position(e.End())
|
||||
b, err := os.ReadFile(start.Filename)
|
||||
if err != nil || end.Offset > len(b) {
|
||||
return "?"
|
||||
}
|
||||
return string(b[start.Offset:end.Offset])
|
||||
}
|
||||
Reference in New Issue
Block a user