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

This commit is contained in:
2026-10-16 22:46:00 +02:00
parent 00a0d91976
commit f75b3aa778
212 changed files with 2414 additions and 398 deletions
+3 -3
View File
@@ -49,9 +49,9 @@ RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
# cannot swap the binary. Pinned by TestR1105_ResticIsPinnedOutsideTheOS.
FROM debian:13.7-slim@sha256:a29215f6a35e51e22adffa17f89e9d2ef06214e64a2bad10d765c46aea49f11f AS restic
ARG TARGETARCH
ARG RESTIC_VERSION=0.18.1
ARG RESTIC_SHA256_AMD64=680838f19d67151adba227e1570cdd8af12c19cf1735783ed1ba928bc41f363d
ARG RESTIC_SHA256_ARM64=87f53fddde38764095e9c058a3b31834052c37e5826d2acf34e18923c006bd45
ARG RESTIC_VERSION=0.19.1
ARG RESTIC_SHA256_AMD64=f415415624dcc452f2a02b8c33641791a8c6d6d3b65bbb3543fcf9a25151585c
ARG RESTIC_SHA256_ARM64=a5f64aaab53d51e311fa3829124c5b703f2d14cf187d8640b6be3b2b49376465
COPY third_party/restic/restic-release-key.asc /tmp/restic-release-key.asc
RUN set -eu; \
apt-get update && apt-get install -y --no-install-recommends ca-certificates curl gnupg gpgv bzip2; \
+6
View File
@@ -3262,6 +3262,12 @@ func (a *stackAdapter) RecreateStackDefinitionFromUnit(name, composeSrcDir strin
return nil
}
// 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 {
return a.mgr.SetStackSecretValues(name, values)
}
// StartStackServices brings up only the named compose services (the DB-only replay window, R-47).
func (a *stackAdapter) StartStackServices(name string, services []string) error {
return a.mgr.StartStackServices(name, services)
+119
View File
@@ -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
}
+1 -1
View File
@@ -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])
}
+6 -4
View File
@@ -12,10 +12,12 @@ import (
// TierOffsite capture set (recovery unit + MANDATORY userdata only) into the extra absolute paths
// appended to the app's restic snapshot, plus the Hungarian customer warnings for LOUD capture gaps.
//
// SP-3.4 is law here: restic does NOT error on a missing source path — it skips with a warning, exits 0,
// and silently writes a partial snapshot (0.14.0, and re-measured on 0.18.1 for R-1105, 2026-10-16). So a skipped/missing MANDATORY path is detected in
// THIS function (the structural-guard Skipped list + an os.Stat filter) and surfaced in BOTH the
// English log and the Hungarian LastWarning. A restic exit code proves nothing about a missing path.
// SP-3.4 is law here: restic skips a missing source path with a warning and still writes a partial snapshot.
// 0.14.0 and 0.18.1 then exit 0; 0.19.x exits 3 (restic #4467 — measured 2026-10-16, R-1107), which the run reads
// as that app's failure. Either way the filter below keeps a declared-but-absent path out of argv, so only a path
// that vanishes between the stat and restic reaches it — and on 0.19 that race is now loud instead of silent.
// So a skipped/missing MANDATORY path is detected in THIS function (the structural-guard Skipped list + an os.Stat
// filter) and surfaced in BOTH the English log and the Hungarian LastWarning — never left to restic's exit code.
// offboxBlocked records an app whose enlarged (userdata-carrying) push was refused by the pre-push
// quota gate. The unit-only push still proceeds (never a protection regression). estBytes is the
@@ -828,12 +828,31 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
}
}
// --- THE DATABASE PASSWORD COMES BACK WITH THE DATA (R-1106, `07` §7.4, decision 1A) ---------
// When the app keeps its own definition (same version), its app.yaml still holds the CURRENT install's database
// password — after a remove + reinstall, a freshly generated one that cannot open the data directory this restore
// is about to put back (measured 2026-10-16: Rallly crash-looped on auth, Grimmory was held). So when the snapshot
// replays a database service's data VOLUME, the snapshot's password is written into the app's settings before
// anything starts. A snapshot that replays only a logical dump leaves the current password alone — the dump goes
// into the current data directory, which the current password opens. Decided before the first mutation; a
// refusal touches nothing. (The other branch already gets this through unitRestoreEnv.)
var credOverlay map[string]string
if !defineFromSnapshot {
ov, oErr := m.offsiteCredentialOverlay(stack, scratchUnit, scratchCompose, man)
if oErr != nil {
return res, oErr
}
credOverlay = ov
}
definitionMoved := defineFromSnapshot || len(credOverlay) > 0
// --- THE LIVE DEFINITION, KEPT FOR A FAILED REPLAY (R-893, `09` §3 decision 192) -------------
// When the snapshot's definition is about to be written, keep the LIVE one first, so a failed
// replay below can write it back before the app is held. Taken before the first mutation; a
// failure here refuses with nothing touched — without it the failure branch could not be undone.
// R-1106: the same when only the database password is about to move.
var liveDef *liveDefinition
if defineFromSnapshot {
if definitionMoved {
ld, ldErr := m.captureLiveDefinition(stack)
if ldErr != nil {
m.logger.Printf("[ERROR] [offbox] Restore REFUSED for %s: could not keep the live definition: %v — nothing was touched", stack, ldErr)
@@ -919,6 +938,14 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
}
return res, fmt.Errorf("restoring %s: writing the snapshot's definition failed: %w", stack, err)
}
} else if len(credOverlay) > 0 {
if err := m.stackProvider.(secretValueSetter).SetStackSecretValues(stack, credOverlay); err != nil {
if sErr := restartStack(); sErr != nil {
m.logger.Printf("[WARN] [offbox] %s: restart after a failed password write also failed: %v", stack, sErr)
}
return res, fmt.Errorf("restoring %s: writing the backup's database password failed: %w", stack, err)
}
m.logger.Printf("[INFO] [offbox] %s: the backup's database password(s) %v written into the app's settings before the data (R-1106)", stack, credNames(credOverlay))
}
copier := m.fullPlaceCopier()
for _, pl := range placements {
@@ -943,7 +970,7 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
if cErr != nil {
// R-893 option C (security review 2026-10-08): once the snapshot's (possibly older) definition
// is written, a start would run that version on the live data — HOLD the app instead.
if defineFromSnapshot {
if definitionMoved {
m.holdAppAfterMixedRestore(stack, cErr, liveDef)
return res, util.MsgError("err.backup.restore_failed_held_mixed", stack)
}
@@ -977,7 +1004,7 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
// already be the snapshot's — HOLD the app rather than start it on a mix.
// R-893 option C (security review 2026-10-08): a volume already replaced, or the snapshot's
// definition written → HOLD (the volume detail reaches the operator through the hold notice).
if defineFromSnapshot || nVols > 0 {
if definitionMoved || nVols > 0 {
m.holdAppAfterMixedRestore(stack, vErr, liveDef)
return res, util.MsgError("err.backup.restore_failed_held_mixed", stack)
}
@@ -997,7 +1024,7 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
if hasDB {
if err := m.stackProvider.StartStackServices(stack, dbServices); err != nil {
// R-893 option C (security review 2026-10-08): volumes or the definition already moved → HOLD.
if defineFromSnapshot || res.VolumesReplayed > 0 {
if definitionMoved || res.VolumesReplayed > 0 {
m.holdAppAfterMixedRestore(stack, err, liveDef)
return res, util.MsgError("err.backup.restore_failed_held_mixed", stack)
}
@@ -1044,7 +1071,7 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
// Pinned by r893_mixed_restore_test.go; the plain case keeps TestR379_ScenarioA.
// Placed FILES alone do not hold (option C as ruled): that mix is what „put back exactly as it
// was" (option A, the next slice) removes.
if defineFromSnapshot || res.VolumesReplayed > 0 {
if definitionMoved || res.VolumesReplayed > 0 {
m.holdAppAfterMixedRestore(stack, iErr, liveDef)
return res, util.MsgError("err.backup.db_restore_failed_held_mixed", stack)
}
@@ -12,13 +12,13 @@ import (
"time"
)
// R-1105: the off-site code was measured against restic 0.14.0; v0.323.0 ships 0.18.1. The fixtures in
// R-1105: the off-site code was measured against restic 0.14.0; v0.323.0 shipped 0.18.1, v0.324.0 ships 0.19.1 (R-1107). The fixtures in
// testdata/restic/<version>/ are REAL outputs of both official binaries, captured 2026-10-16 on one shared scratch
// repository (made by 0.14.0, so both versions read the same snapshot set; paths rewritten to /data/x). Each
// repository (made by 0.14.0, so every version reads the same snapshot set — recaptured for all three on 2026-10-16 20:10 UTC; paths rewritten to /data/x). Each
// assumption the code makes about restic's output is asserted for BOTH versions through the code's own functions —
// so a future restic that rewords one of them fails here, by name, instead of moving a failure to `unknown` on a
// customer's box (07-backup-architecture.md §6.7, §6.8).
var r1105Versions = []string{"0.14.0", "0.18.1"}
var r1105Versions = []string{"0.14.0", "0.18.1", "0.19.1"} // [0] is the measured baseline; v0.324.0 ships the last
func r1105Fixture(t *testing.T, version, name string) []byte {
t.Helper()
@@ -162,7 +162,7 @@ func TestR1105_TheJSONTheCodeReadsStillParses(t *testing.T) {
t.Errorf("restic %s: no status line carried the totals the progress bar shows", v)
}
}
if counts["0.14.0"] != counts["0.18.1"] {
if counts["0.14.0"] != counts["0.18.1"] || counts["0.14.0"] != counts["0.19.1"] {
t.Errorf("the two versions list a different number of snapshots of ONE repository: %v", counts)
}
}
@@ -213,40 +213,42 @@ func TestR1105_TheForgetPlanOnlyEverKeepsMore(t *testing.T) {
t.Errorf("restic %s: the guard refuses the honest plan: %s", v, refuse)
}
}
for id := range plans["0.18.1"] {
if !plans["0.14.0"][id] {
t.Errorf("0.18.1 removes snapshot %s, which 0.14.0 keeps — the new restic removes MORE", id)
for _, v := range r1105Versions[1:] { // every newer version against 0.14.0, the measured baseline
for id := range plans[v] {
if !plans["0.14.0"][id] {
t.Errorf("%s removes snapshot %s, which 0.14.0 keeps — the new restic removes MORE", v, id)
}
}
}
// Every snapshot only 0.14.0 removes is one 0.18.1 keeps as the „oldest" of a window with unused slots.
var groups []struct {
Reasons []struct {
Snapshot struct {
ID string `json:"id"`
} `json:"snapshot"`
Matches []string `json:"matches"`
} `json:"reasons"`
}
if err := json.Unmarshal(r1105Fixture(t, "0.18.1", "forget-dryrun.json"), &groups); err != nil {
t.Fatal(err)
}
why := map[string]string{}
for _, g := range groups {
for _, r := range g.Reasons {
why[r.Snapshot.ID] = strings.Join(r.Matches, ",")
// Every snapshot only 0.14.0 removes is one the newer version keeps as the „oldest" of a window with unused slots.
var groups []struct {
Reasons []struct {
Snapshot struct {
ID string `json:"id"`
} `json:"snapshot"`
Matches []string `json:"matches"`
} `json:"reasons"`
}
}
extra := 0
for id := range plans["0.14.0"] {
if plans["0.18.1"][id] {
continue
if err := json.Unmarshal(r1105Fixture(t, v, "forget-dryrun.json"), &groups); err != nil {
t.Fatal(err)
}
extra++
if !strings.Contains(why[id], "oldest") {
t.Errorf("0.18.1 keeps snapshot %s that 0.14.0 removes, for a reason other than “oldest”: %q", id, why[id])
why := map[string]string{}
for _, g := range groups {
for _, r := range g.Reasons {
why[r.Snapshot.ID] = strings.Join(r.Matches, ",")
}
}
extra := 0
for id := range plans["0.14.0"] {
if plans[v][id] {
continue
}
extra++
if !strings.Contains(why[id], "oldest") {
t.Errorf("%s keeps snapshot %s that 0.14.0 removes, for a reason other than “oldest”: %q", v, id, why[id])
}
}
if extra == 0 {
t.Errorf("%s: the fixture no longer shows the 0.17+ “oldest” keep — it no longer tests the difference it documents", v)
}
}
if extra == 0 {
t.Error("the fixture no longer shows the 0.17+ “oldest” keep — it no longer tests the difference it documents")
}
}
@@ -0,0 +1,301 @@
package backup
import (
"context"
"io"
"log"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
// R-1106 (`07` §7.4, operator decision 1A): a restored database comes back with the password it was saved with.
const r1106Compose = `services:
app:
image: ghcr.io/x/app:1
environment:
- DATABASE_URL=postgresql://app:${DB_PASSWORD}@app-db:5432/app
app-db:
image: postgres:18-alpine
environment:
- POSTGRES_USER=app
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
- app_db_data:/var/lib/postgresql
- ./init:/docker-entrypoint-initdb.d
maria:
image: mariadb:11.4
environment:
MARIADB_ROOT_PASSWORD: "${DB_ROOT_PASSWORD:-x}"
MARIADB_PASSWORD: literal-not-a-variable
MARIADB_DATABASE: app
volumes:
- type: volume
source: maria_data
target: /var/lib/mysql
cache:
image: redis:7-alpine
environment:
- REDIS_PASSWORD=${CACHE_PASSWORD}
`
// r1106OneDB is one Postgres fed by DB_PASSWORD — the Rallly shape.
const r1106OneDB = `services:
app:
image: ghcr.io/x/app:1
app-db:
image: postgres:18-alpine
environment:
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
- app_db_data:/var/lib/postgresql
`
func TestR1106_DBCredentialVarsAndVolumes(t *testing.T) {
p := filepath.Join(t.TempDir(), "docker-compose.yml")
mustWrite(t, p, r1106Compose)
vars, err := DBCredentialVars(p)
if err != nil {
t.Fatal(err)
}
// list form, map form with a default; the literal yields nothing; redis is not a database.
if want := []string{"DB_PASSWORD", "DB_ROOT_PASSWORD"}; !reflect.DeepEqual(vars, want) {
t.Errorf("DBCredentialVars = %v, want %v", vars, want)
}
vols, err := DBDataVolumesFor(p)
if err != nil {
t.Fatal(err)
}
if want := []string{"app_db_data", "maria_data"}; !reflect.DeepEqual(vols, want) {
t.Errorf("DBDataVolumes = %v, want %v (binds are not volumes)", vols, want)
}
}
func TestR1106_PlanDBCredentials(t *testing.T) {
data := time.Date(2026, 10, 16, 18, 0, 0, 0, time.UTC)
before, after := data.Add(-24*time.Hour), data.Add(time.Hour)
names := []string{"DB_PASSWORD"}
cases := []struct {
name string
unit map[string]string
live map[string]string
installed time.Time
dataAt time.Time
wantErr bool
wantUnit string
}{
{"the unit's value wins over a reinstall's", map[string]string{"DB_PASSWORD": "old"}, map[string]string{"DB_PASSWORD": "new"}, after, data, false, "old"},
{"unit lacks it, install OLDER than the data → kept", nil, map[string]string{"DB_PASSWORD": "same"}, before, data, false, ""},
{"unit lacks it, install NEWER than the data → refused", nil, map[string]string{"DB_PASSWORD": "new"}, after, data, true, ""},
{"unit lacks it, install time unknown → refused", nil, map[string]string{"DB_PASSWORD": "new"}, time.Time{}, data, true, ""},
{"unit lacks it, data time unknown → refused", nil, map[string]string{"DB_PASSWORD": "new"}, before, time.Time{}, true, ""},
{"nowhere at all → refused", nil, nil, before, data, true, ""},
}
for _, c := range cases {
p, err := planDBCredentials(names, c.unit, c.live, c.installed, c.dataAt)
if (err != nil) != c.wantErr {
t.Errorf("%s: err = %v, wantErr %v", c.name, err, c.wantErr)
continue
}
if p.FromUnit["DB_PASSWORD"] != c.wantUnit {
t.Errorf("%s: FromUnit = %q, want %q", c.name, p.FromUnit["DB_PASSWORD"], c.wantUnit)
}
}
}
// r1106UnitRestoreFixture: a unit whose compose has a database fed by DB_PASSWORD, a live install deployed AFTER the
// unit's data. unitPW "" = the unit carries no DB_PASSWORD.
func r1106UnitRestoreFixture(t *testing.T, unitPW string, guest map[string]string) (*Manager, *fakeRecoveryProvider, *[]string) {
t.Helper()
tmp := t.TempDir()
drive := filepath.Join(tmp, "drive")
composeDir := RecoveryUnitComposePath(drive, "app")
mustWrite(t, filepath.Join(composeDir, "docker-compose.yml"), r1106OneDB)
env := "deployed: true\nenv:\n SUBDOMAIN: trips\n"
portable := []string{}
if unitPW != "" {
env += " DB_PASSWORD: " + unitPW + "\n"
portable = []string{"DB_PASSWORD"}
}
mustWrite(t, filepath.Join(composeDir, "app.yaml"), env)
man := &RecoveryManifest{SchemaVersion: 2, AppName: "app", ControllerVer: "v",
SecretEnvVars: []string{"DB_PASSWORD"}, PortableSecretEnvVars: portable,
CreatedAt: "2026-10-16T18:00:00Z", DumpsAt: "2026-10-16T18:00:00Z"}
if err := writeManifest(RecoveryUnitManifestPath(drive, "app"), man); err != nil {
t.Fatal(err)
}
live := filepath.Join(tmp, "live")
mustWrite(t, filepath.Join(live, "docker-compose.yml"), r1106OneDB)
mustWrite(t, filepath.Join(live, "app.yaml"), "deployed: true\ndeployed_at: \"2026-10-16T19:00:00Z\"\nenv: {}\n")
fake := &fakeRecoveryProvider{hdd: drive, running: true, secrets: guest, info: RecoveryInfo{StackDir: live}}
m := &Manager{logger: log.New(io.Discard, "", 0), systemDataPath: filepath.Join(drive, "..", "sys"), stackProvider: fake}
var gen []string
m.generateSecret = func(_, envVar string) (string, bool) { gen = append(gen, envVar); return "GENERATED", true }
return m, fake, &gen
}
// The on-box restore after a reinstall: the backup's password is the one the app gets.
func TestR1106_UnitRestoreUsesTheBackupsDBPassword(t *testing.T) {
m, fake, gen := r1106UnitRestoreFixture(t, "old-pw", map[string]string{"DB_PASSWORD": "new-pw-from-reinstall"})
if _, err := m.RestoreFromRecoveryUnit("app"); err != nil {
t.Fatalf("restore: %v", err)
}
if fake.gotEnv["DB_PASSWORD"] != "old-pw" {
t.Errorf("DB_PASSWORD = %q, want the backup's value", fake.gotEnv["DB_PASSWORD"])
}
if len(*gen) != 0 {
t.Errorf("a database password was generated: %v", *gen)
}
}
// A unit without the database password, restored over a NEWER install: refused before anything changes, and never
// a generated password (O4 used to make one — the R-127 (b) trap).
func TestR1106_UnitRestoreNeverGeneratesADBPassword(t *testing.T) {
for name, guest := range map[string]map[string]string{
"no value anywhere": nil,
"only the reinstall's new value": {"DB_PASSWORD": "new-pw-from-reinstall"},
} {
m, fake, gen := r1106UnitRestoreFixture(t, "", guest)
_, err := m.RestoreFromRecoveryUnit("app")
if err == nil || !strings.Contains(err.Error(), "adatbázis jelszavát") {
t.Errorf("%s: want the refusal sentence, got %v", name, err)
}
if fake.gotEnv != nil || fake.stopped {
t.Errorf("%s: the current install was touched (recreate %v, stopped %v)", name, fake.gotEnv != nil, fake.stopped)
}
if len(*gen) != 0 {
t.Errorf("%s: a database password was generated: %v", name, *gen)
}
}
}
// credProvider wraps the reconstitute fixture's provider with the live install's secrets, its app.yaml (for the
// install time) and the narrow writer, recording WHEN the writer ran relative to the other calls.
type credProvider struct {
*recordingProvider
live map[string]string
stackDir string
written map[string]string
}
func (p *credProvider) RecoverStackSecrets(_ string, names []string) map[string]string {
out := map[string]string{}
for _, n := range names {
if v, ok := p.live[n]; ok {
out[n] = v
}
}
return out
}
func (p *credProvider) GetStackRecoveryInfo(string) (RecoveryInfo, bool) {
return RecoveryInfo{StackDir: p.stackDir}, true
}
func (p *credProvider) SetStackSecretValues(_ string, values map[string]string) error {
p.written = values
p.calls = append(p.calls, "setsecrets")
return nil
}
// r1106ReconFixture: the reconstitute fixture with a database fed by DB_PASSWORD, a reinstall NEWER than the
// snapshot, the snapshot's unit carrying unitPW (or not), and the DB volume archive in the snapshot when withVolume.
func r1106ReconFixture(t *testing.T, unitPW string, withVolume bool) (*Manager, *credProvider) {
t.Helper()
m, prov, _ := reconFixture(t, "20261016T180000Z", "2026-10-16T18:00:00Z", pgDump(1))
compose := `services:
immich-server:
image: ghcr.io/immich-app/immich-server:v3.0.3
immich-postgres:
image: ghcr.io/immich-app/postgres:16-vectorchord0.4.3-pgvectors0.2.0
environment:
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
- immich_postgres_data:/var/lib/postgresql/data
`
mustWrite(t, prov.composePath, compose)
stackDir := filepath.Dir(prov.composePath)
mustWrite(t, filepath.Join(stackDir, "app.yaml"), "deployed: true\ndeployed_at: \"2026-10-16T19:00:00Z\"\nenv: {}\n")
cp := &credProvider{recordingProvider: prov, live: map[string]string{"DB_PASSWORD": "new-pw-from-reinstall"}, stackDir: stackDir}
m.SetStackProvider(cp)
scratch, _, err := m.offboxRestoreScratchDir("immich")
if err != nil {
t.Fatal(err)
}
unit := findScratchUnitDir(scratch, "immich")
env := "deployed: true\nenv:\n SUBDOMAIN: photos\n"
portable := []string{}
if unitPW != "" {
env += " DB_PASSWORD: " + unitPW + "\n"
portable = []string{"DB_PASSWORD"}
}
mustWrite(t, filepath.Join(UnitComposeDir(unit), "app.yaml"), env)
man := &RecoveryManifest{SchemaVersion: 2, AppName: "immich", OffsiteRunID: "20261016T180000Z", DumpsAt: "2026-10-16T18:00:00Z",
SecretEnvVars: []string{"DB_PASSWORD"}, PortableSecretEnvVars: portable}
if err := writeManifest(filepath.Join(unit, "manifest.json"), man); err != nil {
t.Fatal(err)
}
if withVolume {
seedScratchVolumes(t, m, "immich", "immich_immich_postgres_data.tar")
}
m.volumeReplayFrom = func(_, dir string) (int, error) {
cp.calls = append(cp.calls, "volumes")
entries, _ := os.ReadDir(dir)
return len(entries), nil
}
return m, cp
}
// The drill's shape (Rallly, Grimmory, 2026-10-16): reinstall, then an off-site full restore that replays the DB
// volume. The backup's password must be in the app's settings BEFORE the database starts.
func TestR1106_OffsiteRestoreWritesTheBackupsDBPasswordBeforeTheDBStarts(t *testing.T) {
m, cp := r1106ReconFixture(t, "old-pw", true)
if _, err := m.ReconstituteFromOffsite(context.Background(), "immich", false); err != nil {
t.Fatalf("ReconstituteFromOffsite: %v", err)
}
if cp.written["DB_PASSWORD"] != "old-pw" {
t.Fatalf("the backup's password was not written (got %v)", cp.written)
}
set, db := -1, -1
for i, c := range cp.calls {
if c == "setsecrets" && set < 0 {
set = i
}
if strings.HasPrefix(c, "startsvc:") && db < 0 {
db = i
}
}
if set < 0 || db < 0 || set > db {
t.Errorf("the password must be written before the database service starts; calls = %v", cp.calls)
}
}
// The unit carries no password and the install is newer than the snapshot: refused, nothing stopped or written.
func TestR1106_OffsiteRestoreRefusesWithoutTheDBPassword(t *testing.T) {
m, cp := r1106ReconFixture(t, "", true)
_, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
if err == nil || !strings.Contains(err.Error(), "adatbázis jelszavát") {
t.Fatalf("want the refusal sentence, got %v", err)
}
for _, c := range cp.calls {
if c == "stop" || c == "setsecrets" || c == "volumes" {
t.Errorf("the current install was touched: calls = %v", cp.calls)
break
}
}
}
// A snapshot that replays only a logical dump (no DB volume) must leave the current password alone: the dump goes
// into the current data directory, which only the current password opens.
func TestR1106_DumpOnlySnapshotKeepsTheCurrentPassword(t *testing.T) {
m, cp := r1106ReconFixture(t, "old-pw", false)
if _, err := m.ReconstituteFromOffsite(context.Background(), "immich", false); err != nil {
t.Fatalf("ReconstituteFromOffsite: %v", err)
}
if cp.written != nil {
t.Errorf("a dump-only restore rewrote the password: %v", cp.written)
}
}
@@ -0,0 +1,194 @@
package backup
import (
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// R-1106 — A RESTORED DATABASE COMES BACK WITH THE PASSWORD IT WAS SAVED WITH (`07` §7.4, operator decision 1A,
// 2026-10-16).
//
// A database engine reads its credential variables (POSTGRES_PASSWORD, MARIADB_ROOT_PASSWORD, …) only when it
// initialises an EMPTY data directory. Once a restore has put a data directory back, the only password that opens it
// is the one it was created with. Measured 2026-10-16 on demo-hp (the drill in `audits/debian13-restic-2026-10-16/`
// part2): Rallly and Grimmory removed, reinstalled (the reinstall generated new passwords), restored from the
// off-site copy — the data came back and neither app could open it: Rallly crash-looped `P1000`, Grimmory's replay
// and its rollback failed `ERROR 1045` and it was held.
//
// The rule: for every app.yaml variable that feeds a database service's credential (appbackup.DBCredentialVars), the
// value the backup unit carries (D5 — every `type: secret` travels) WINS over the current install's. When the unit
// carries none, the current install's value is used only when that install is OLDER than the data being restored
// (so it is the value the data was written with); otherwise the restore is REFUSED before anything changes. A
// database credential is never generated for restored data (O4 no longer reaches these names).
// errRestoreNoDBCredential is the refusal; the sentence is the bundle key's (HU + EN).
var errRestoreNoDBCredential = errors.New("restore refused: the backup does not carry the database password")
// dbCredPlan is the decision for one restore: the credential values to put into the app's settings (unit values
// that differ from — or are absent in — the current install), and every credential name considered.
type dbCredPlan struct {
Names []string // every variable that feeds a DB credential in the compose that will run
FromUnit map[string]string // name → the unit's value, for every name the unit carries
KeptLocal []string // names the unit lacks whose CURRENT value predates the data (kept)
}
// planDBCredentials decides, as a PURE function, which credential each DB variable gets. installedAt is when the
// current install was deployed (zero = unknown); dataAt is when the data being restored was captured (zero =
// unknown). An unknown time on either side never licenses the current value: unknown is not "older".
func planDBCredentials(names []string, unitSecrets, liveSecrets map[string]string, installedAt, dataAt time.Time) (dbCredPlan, error) {
p := dbCredPlan{Names: append([]string(nil), names...), FromUnit: map[string]string{}}
sort.Strings(p.Names)
var missing []string
for _, n := range p.Names {
if v := unitSecrets[n]; v != "" {
p.FromUnit[n] = v
continue
}
live := liveSecrets[n]
if live != "" && !installedAt.IsZero() && !dataAt.IsZero() && installedAt.Before(dataAt) {
p.KeptLocal = append(p.KeptLocal, n)
continue
}
missing = append(missing, n)
}
if len(missing) > 0 {
return p, fmt.Errorf("%w: %v (the current install is not older than the data, or no value at all)", errRestoreNoDBCredential, missing)
}
return p, nil
}
// installedAt reads when the app's CURRENT install was deployed (app.yaml `deployed_at`). Zero when unknown.
func (m *Manager) installedAt(stack string) time.Time {
info, ok := m.stackProvider.GetStackRecoveryInfo(stack)
if !ok || info.StackDir == "" {
return time.Time{}
}
cfg := stacks.LoadAppConfig(info.StackDir)
if cfg == nil || cfg.DeployedAt == "" {
return time.Time{}
}
t, err := time.Parse(time.RFC3339, cfg.DeployedAt)
if err != nil {
return time.Time{}
}
return t
}
// dbCredentialPlanFor reads the compose that will RUN (composePath), the unit's carried secrets and the live ones,
// and returns the plan — or the refusal, as the household's sentence, with the underlying reason logged. Nothing is
// written here.
func (m *Manager) dbCredentialPlanFor(stack, composePath string, unitSecrets map[string]string, dataAt time.Time) (dbCredPlan, error) {
if !fileExists(composePath) {
// A unit without its compose (never written by a current capture; old fixtures and hand-made units): read
// the LIVE definition instead — the variable NAMES do not change between installs of one template.
live, ok := m.stackProvider.GetStackComposePath(stack)
if !ok || live == "" || !fileExists(live) {
m.logger.Printf("[WARN] [backup] Restore %s: no compose to read the database password variables from — none checked", stack)
return dbCredPlan{FromUnit: map[string]string{}}, nil
}
composePath = live
}
names, err := DBCredentialVars(composePath)
if err != nil {
// "cannot tell" is not "no database password": refuse rather than restore blind.
m.logger.Printf("[ERROR] [backup] Restore REFUSED for %s: cannot read which variables hold the database password (%s): %v", stack, filepath.Base(composePath), err)
return dbCredPlan{}, util.MsgError("err.backup.restore_no_db_password")
}
if len(names) == 0 {
return dbCredPlan{FromUnit: map[string]string{}}, nil
}
live := m.stackProvider.RecoverStackSecrets(stack, names)
plan, perr := planDBCredentials(names, unitSecrets, live, m.installedAt(stack), dataAt)
if perr != nil {
m.logger.Printf("[ERROR] [backup] Restore REFUSED for %s before any change: %v (R-1106)", stack, perr)
return plan, util.MsgError("err.backup.restore_no_db_password")
}
m.logger.Printf("[INFO] [backup] Restore %s: database password(s) %v come from the backup, %v stay as installed (older than the data) — values not logged (R-1106)",
stack, credNames(plan.FromUnit), plan.KeptLocal)
return plan, nil
}
func credNames(mp map[string]string) []string {
out := make([]string, 0, len(mp))
for k := range mp {
out = append(out, k)
}
sort.Strings(out)
return out
}
// secretValueSetter is the narrow write the off-site restore needs when the app keeps its own definition: put the
// named values into the app's settings (load-then-save, encrypted at rest) and nothing else. An optional interface
// so the many test fakes of StackProvider need no stub; a provider without it makes the restore REFUSE when a change
// is needed (fail-closed), never proceed with the wrong password.
type secretValueSetter interface {
SetStackSecretValues(name string, values map[string]string) error
}
// DBCredentialVars forwards appbackup.DBCredentialVars.
func DBCredentialVars(composePath string) ([]string, error) {
return appbackup.DBCredentialVars(composePath)
}
// offsiteCredentialOverlay (R-1106) decides, for an off-site restore that keeps the app's own definition, which
// database passwords to write into the app's settings: the snapshot's, for every DB credential whose value differs
// from the current install's — but ONLY when the snapshot replays a database service's data VOLUME (that data
// directory opens with its own password only). A dump-only snapshot changes nothing. Empty map = nothing to write.
func (m *Manager) offsiteCredentialOverlay(stack, scratchUnit, scratchCompose string, man *RecoveryManifest) (map[string]string, error) {
composeRun, ok := m.stackProvider.GetStackComposePath(stack)
if !ok || composeRun == "" {
return nil, nil // no live definition to read — the existing gates below decide (R-47's fail-closed refusal)
}
vols, err := DBDataVolumesFor(composeRun)
if err != nil {
m.logger.Printf("[ERROR] [offbox] Restore REFUSED for %s: cannot read the database volumes of the live definition: %v", stack, err)
return nil, util.MsgError("err.backup.restore_no_db_password")
}
replaysDBVolume := false
for _, v := range vols {
if fileExists(filepath.Join(scratchUnit, "volume-dumps", stack+"_"+v+".tar")) {
replaysDBVolume = true
break
}
}
if !replaysDBVolume {
return nil, nil
}
_, unitSecrets := readUnitEnv(filepath.Join(scratchCompose, "app.yaml"), man.PortableSecretEnvVars)
plan, err := m.dbCredentialPlanFor(stack, composeRun, unitSecrets, manifestDataTime(man))
if err != nil {
return nil, err
}
live := m.stackProvider.RecoverStackSecrets(stack, plan.Names)
ov := map[string]string{}
for n, v := range plan.FromUnit {
if live[n] != v {
ov[n] = v
}
}
if len(ov) > 0 {
if _, ok := m.stackProvider.(secretValueSetter); !ok {
m.logger.Printf("[ERROR] [offbox] Restore REFUSED for %s: the database password must change and this provider cannot write it", stack)
return nil, fmt.Errorf("restoring %s: the backup's database password cannot be written", stack)
}
}
return ov, nil
}
func fileExists(p string) bool {
st, err := os.Stat(p)
return err == nil && !st.IsDir()
}
// DBDataVolumesFor forwards appbackup.DBDataVolumes.
func DBDataVolumesFor(composePath string) ([]string, error) {
return appbackup.DBDataVolumes(composePath)
}
+33 -1
View File
@@ -511,6 +511,19 @@ func (m *Manager) unitRestoreEnv(stackName, composeDir string, manifest *Recover
m.logger.Printf("[ERROR] [backup] Restore REFUSED for %s: %v", stackName, err)
return nil, missing, err
}
// R-1106 (`07` §7.4, decision 1A): the database password comes back WITH the data — the unit's value, or the
// current install's only when that install is older than the data; otherwise refused here, before any change.
dbPlan, perr := m.dbCredentialPlanFor(stackName, filepath.Join(composeDir, "docker-compose.yml"), unitSecrets, manifestDataTime(manifest))
if perr != nil {
return nil, missing, perr
}
for n, v := range dbPlan.FromUnit {
fullEnv[n] = v
}
dbCredSet := make(map[string]bool, len(dbPlan.Names))
for _, n := range dbPlan.Names {
dbCredSet[n] = true
}
// O4: a missing RESETTABLE secret used to redeploy blank (compose "Defaulting to a blank
// string" → exit 1). Generate a replacement via the deploy flow's generator instead —
// RecreateStackFromUnit persists fullEnv through SaveAppConfig, so the new value lands
@@ -534,7 +547,9 @@ func (m *Manager) unitRestoreEnv(stackName, composeDir string, manifest *Recover
}
var generated, unresolved []string
for _, name := range missing {
if !dataKeySet[name] && m.generateSecret != nil {
// R-1106: never a database password — a generated one cannot open the restored data directory.
// (dbCredentialPlanFor refused already if one was unrecoverable; this is the second line.)
if !dataKeySet[name] && !dbCredSet[name] && m.generateSecret != nil {
if v, ok := m.generateSecret(stackName, name); ok && v != "" {
fullEnv[name] = v
generated = append(generated, name)
@@ -554,3 +569,20 @@ func (m *Manager) unitRestoreEnv(stackName, composeDir string, manifest *Recover
}
return fullEnv, missing, nil
}
// manifestDataTime is when the unit's DATA was captured: the data stamp (v0.275.0+), else the dump leg's end, else the
// unit's own creation time. Zero when none parses — and zero never licenses keeping the current install's password.
func manifestDataTime(man *RecoveryManifest) time.Time {
if man == nil {
return time.Time{}
}
if t, ok := man.Data.DataTime(); ok {
return t
}
for _, s := range []string{man.DumpsAt, man.CreatedAt} {
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t
}
}
return time.Time{}
}
@@ -1,2 +1,2 @@
{"message_type":"status","percent_done":0,"total_files":1,"total_bytes":41943040}
{"message_type":"status","percent_done":0.6679942601232657,"total_files":3,"files_done":2,"total_bytes":41943061,"bytes_done":28017724,"current_files":["/data/x/u/a/big.tar"]}
{"message_type":"status","percent_done":0.7110543744053397,"total_files":3,"files_done":2,"total_bytes":41943061,"bytes_done":29823797,"current_files":["/data/x/u/a/big.tar"]}
@@ -1,14 +1,14 @@
using temporary cache in /data/x/cache/restic-check-cache-279810929
using temporary cache in /data/x/cache/restic-check-cache-1162611374
create exclusive lock for repository
load indexes
check all packs
check snapshots, trees and blobs
error for tree 49327794:
decrypting blob 49327794eaad1ff31fb67ceb6bad31d812456993a7543d5fdf7ec8dad187e156 failed: ciphertext verification failed
error for tree 2dd49e74:
decrypting blob 2dd49e743c4d0b61ec5cfa2dc99f5446d5e7482635df00484aaa6a193aba98b8 failed: ciphertext verification failed
[0:00] 100.00% 82 / 82 snapshots
read all data
Pack ID does not match, want fc6016f0ad5e780ab7d800bd3012c8a602eb1dc9e291dedd61ea3d027befae72, got 39ddedb2fc2555d1e386d3eb7c440142ec3d7c02484f3d10db46105343393948
Pack ID does not match, want fd6de2a5f8204c762b7a38c35a81a60785d6a542d3506a63dadad936b0aeb160, got d22a000a356e727dc3afaeef9408a189de77107618df88519e050153a5a653a8
[0:00] 100.00% 123 / 123 packs
Fatal: repository contains errors
@@ -1,4 +1,4 @@
using temporary cache in /data/x/cache/restic-check-cache-1995998052
using temporary cache in /data/x/cache/restic-check-cache-2557640014
create exclusive lock for repository
load indexes
check all packs
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
unable to create lock in backend: repository is already locked exclusively by PID 2773051 on other-box by root (UID 0, GID 0)
lock was created at 2026-10-16 18:20:13 (1.310283004s ago)
storage ID 8cbd92f5
unable to create lock in backend: repository is already locked exclusively by PID 3688286 on other-box by root (UID 0, GID 0)
lock was created at 2026-10-16 20:02:53 (1.105027717s ago)
storage ID e0199133
the `unlock` command can be used to remove stale locks
@@ -1,4 +1,4 @@
unable to create lock in backend: repository is already locked by PID 2772696 on other-box by root (UID 0, GID 0)
lock was created at 2026-10-16 18:20:11 (1.294783283s ago)
storage ID a2cb4ce8
unable to create lock in backend: repository is already locked by PID 3687988 on other-box by root (UID 0, GID 0)
lock was created at 2026-10-16 20:02:52 (1.102036609s ago)
storage ID fd3abc66
the `unlock` command can be used to remove stale locks
@@ -1 +1 @@
[{"time":"2026-10-16T02:30:00Z","parent":"16f5cbf1aab3d6aaf201e9cf15f90b780e5db3854751f745883a6bd6e707d2dd","tree":"286f8da9b1b553de264161006b6f14be594a3eef691d3980954808cf02387f21","paths":["/data/x/u/a"],"hostname":"box","username":"root","uid":1000,"gid":1000,"tags":["felhom-offbox","a"],"id":"3e5346879326eed55c3c0ab2abcce53b9207c9f1da1a4f3ad1ee0ab831063651","short_id":"3e534687"}]
[{"time":"2026-10-16T02:30:00Z","parent":"27898187d9337190afb6154237f44817ccf2070d6be912c86af3c1ef55ed8e3d","tree":"b830b8165dc45c4b23086a81004548a5a52a00060028f0d274d8098dcc168b49","paths":["/data/x/u/a"],"hostname":"box","username":"root","uid":1000,"gid":1000,"tags":["felhom-offbox","a"],"id":"94fc37b9bfe97271d4a0b0feda4443a5e2934a3557108dd521d0bfb2f49423c8","short_id":"94fc37b9"}]
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
{"total_size":217738,"total_file_count":0,"total_blob_count":863,"snapshots_count":82}
{"total_size":217458,"total_file_count":0,"total_blob_count":863,"snapshots_count":82}
@@ -1,4 +1,4 @@
{"message_type":"status","percent_done":0.056467624048707366,"total_files":3,"files_done":2,"total_bytes":41943061,"bytes_done":2368425,"current_files":["/data/x/u/a/big.tar"]}
{"message_type":"status","percent_done":0.5571281981541595,"total_files":3,"files_done":2,"total_bytes":41943061,"bytes_done":23367662,"current_files":["/data/x/u/a/big.tar"]}
{"message_type":"status","percent_done":5.006787654339296e-7,"total_files":3,"files_done":2,"total_bytes":41943061,"bytes_done":21,"current_files":["/data/x/u/a/big.tar"]}
{"message_type":"status","percent_done":0.5636983671744893,"total_files":3,"files_done":2,"total_bytes":41943061,"bytes_done":23643235,"current_files":["/data/x/u/a/big.tar"]}
{"message_type":"status","percent_done":1,"total_files":3,"files_done":2,"total_bytes":41943061,"bytes_done":41943061}
{"message_type":"status","percent_done":1,"total_files":3,"files_done":3,"total_bytes":41943061,"bytes_done":41943061}
@@ -1,18 +1,18 @@
using temporary cache in /data/x/cache/restic-check-cache-3291579747
using temporary cache in /data/x/cache/restic-check-cache-1056767702
create exclusive lock for repository
load indexes
check all packs
check snapshots, trees and blobs
error for tree 49327794:
decrypting blob <tree/49327794> from fc6016f0 failed: ciphertext verification failed
error for tree 2dd49e74:
decrypting blob <tree/2dd49e74> from fd6de2a5 failed: ciphertext verification failed
[0:00] 100.00% 82 / 82 snapshots
read all data
pack fc6016f0ad5e780ab7d800bd3012c8a602eb1dc9e291dedd61ea3d027befae72 contains 2 errors: [blob 49327794eaad1ff31fb67ceb6bad31d812456993a7543d5fdf7ec8dad187e156: decrypting blob <tree/49327794> from fc6016f0 failed: ciphertext verification failed unexpected pack id 39ddedb2fc2555d1e386d3eb7c440142ec3d7c02484f3d10db46105343393948]
pack fd6de2a5f8204c762b7a38c35a81a60785d6a542d3506a63dadad936b0aeb160 contains 2 errors: [blob 2dd49e743c4d0b61ec5cfa2dc99f5446d5e7482635df00484aaa6a193aba98b8: decrypting blob <tree/2dd49e74> from fd6de2a5 failed: ciphertext verification failed unexpected pack id d22a000a356e727dc3afaeef9408a189de77107618df88519e050153a5a653a8]
[0:00] 100.00% 123 / 123 packs
The repository contains damaged pack files. These damaged files must be removed to repair the repository. This can be done using the following commands. Please read the troubleshooting guide at https://restic.readthedocs.io/en/stable/077_troubleshooting.html first.
restic repair packs fc6016f0ad5e780ab7d800bd3012c8a602eb1dc9e291dedd61ea3d027befae72
restic repair packs fd6de2a5f8204c762b7a38c35a81a60785d6a542d3506a63dadad936b0aeb160
restic repair snapshots --forget
Damaged pack files can be caused by backend problems, hardware problems or bugs in restic. Please open an issue at https://github.com/restic/restic/issues/new/choose for further troubleshooting!
@@ -1,4 +1,4 @@
using temporary cache in /data/x/cache/restic-check-cache-4211187088
using temporary cache in /data/x/cache/restic-check-cache-2213934293
create exclusive lock for repository
load indexes
check all packs
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
repo already locked, waiting up to 0s for the lock
unable to create lock in backend: repository is already locked exclusively by PID 2775667 on other-box by root (UID 0, GID 0)
lock was created at 2026-10-16 18:20:26 (1.312752495s ago)
storage ID be8a3f47
unable to create lock in backend: repository is already locked exclusively by PID 3690145 on other-box by root (UID 0, GID 0)
lock was created at 2026-10-16 20:03:03 (1.111629809s ago)
storage ID 3cf3b3cf
the `unlock` command can be used to remove stale locks
@@ -1,5 +1,5 @@
repo already locked, waiting up to 0s for the lock
unable to create lock in backend: repository is already locked by PID 2775299 on other-box by root (UID 0, GID 0)
lock was created at 2026-10-16 18:20:25 (1.301138628s ago)
storage ID b41afa86
unable to create lock in backend: repository is already locked by PID 3689928 on other-box by root (UID 0, GID 0)
lock was created at 2026-10-16 20:03:02 (1.097394824s ago)
storage ID 74d5af8c
the `unlock` command can be used to remove stale locks
@@ -1 +1 @@
[{"time":"2026-10-16T02:30:00Z","parent":"16f5cbf1aab3d6aaf201e9cf15f90b780e5db3854751f745883a6bd6e707d2dd","tree":"286f8da9b1b553de264161006b6f14be594a3eef691d3980954808cf02387f21","paths":["/data/x/u/a"],"hostname":"box","username":"root","uid":1000,"gid":1000,"tags":["felhom-offbox","a"],"id":"3e5346879326eed55c3c0ab2abcce53b9207c9f1da1a4f3ad1ee0ab831063651","short_id":"3e534687"}]
[{"time":"2026-10-16T02:30:00Z","parent":"27898187d9337190afb6154237f44817ccf2070d6be912c86af3c1ef55ed8e3d","tree":"b830b8165dc45c4b23086a81004548a5a52a00060028f0d274d8098dcc168b49","paths":["/data/x/u/a"],"hostname":"box","username":"root","uid":1000,"gid":1000,"tags":["felhom-offbox","a"],"id":"94fc37b9bfe97271d4a0b0feda4443a5e2934a3557108dd521d0bfb2f49423c8","short_id":"94fc37b9"}]
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
{"total_size":217738,"total_uncompressed_size":345762,"compression_ratio":1.587972701136228,"compression_progress":100,"compression_space_saving":37.02662525089513,"total_blob_count":863,"snapshots_count":82}
{"total_size":217458,"total_uncompressed_size":345270,"compression_ratio":1.5877548768037966,"compression_progress":100,"compression_space_saving":37.017985924059424,"total_blob_count":863,"snapshots_count":82}
@@ -0,0 +1 @@
{"message_type":"status","percent_done":0.3130332809996867,"total_files":3,"files_done":2,"total_bytes":41943061,"bytes_done":13129574,"current_files":["/data/x/u/a/big.tar"]}
@@ -0,0 +1,19 @@
using temporary cache in /data/x/cache/restic-check-cache-237814189
create exclusive lock for repository
load indexes
check all packs
check snapshots, trees and blobs
error for tree 2dd49e74:
decrypting blob <tree/2dd49e74> from pack fd6de2a5f8204c762b7a38c35a81a60785d6a542d3506a63dadad936b0aeb160 failed: ciphertext verification failed
[0:00] 100.00% 82 / 82 snapshots
read all data
pack fd6de2a5f8204c762b7a38c35a81a60785d6a542d3506a63dadad936b0aeb160 contains 2 errors: [blob 2dd49e743c4d0b61ec5cfa2dc99f5446d5e7482635df00484aaa6a193aba98b8: decrypting blob <tree/2dd49e74> from pack fd6de2a5f8204c762b7a38c35a81a60785d6a542d3506a63dadad936b0aeb160 failed: ciphertext verification failed unexpected pack id d22a000a356e727dc3afaeef9408a189de77107618df88519e050153a5a653a8]
[0:00] 100.00% 123 / 123 packs
The repository contains damaged pack files. These damaged files must be removed to repair the repository. This can be done using the following commands. Please read the troubleshooting guide at https://restic.readthedocs.io/en/stable/077_troubleshooting.html first.
restic repair packs fd6de2a5f8204c762b7a38c35a81a60785d6a542d3506a63dadad936b0aeb160
restic repair snapshots --forget
Damaged pack files can be caused by backend problems, hardware problems or bugs in restic. Please open an issue at https://github.com/restic/restic/issues/new/choose for further troubleshooting!
Fatal: repository contains errors
@@ -0,0 +1,7 @@
using temporary cache in /data/x/cache/restic-check-cache-2363455331
create exclusive lock for repository
load indexes
check all packs
check snapshots, trees and blobs
[0:00] 100.00% 82 / 82 snapshots
no errors were found
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
Fatal: Fatal: create repository at /data/x/repo failed: config file already exists
@@ -0,0 +1,5 @@
repo already locked, waiting up to 0s for the lock
unable to create lock in backend: repository is already locked exclusively by PID 3694515 on other-box by root (UID 0, GID 0)
lock was created at 2026-10-16 20:03:13 (1.104257918s ago)
storage ID 781ffba8
the `unlock` command can be used to remove stale locks
@@ -0,0 +1,5 @@
repo already locked, waiting up to 0s for the lock
unable to create lock in backend: repository is already locked by PID 3693859 on other-box by root (UID 0, GID 0)
lock was created at 2026-10-16 20:03:12 (1.095585673s ago)
storage ID 03a00a80
the `unlock` command can be used to remove stale locks
@@ -0,0 +1,3 @@
Fatal: repository does not exist: unable to open config file: stat /data/x/nothing-here/config: no such file or directory
Is there a repository at the following location?
/data/x/nothing-here
@@ -0,0 +1,2 @@
rclone: ssh: connect to host 127.0.0.1 port 1: Connection refused
Fatal: unable to open repository at rclone:x: error talking HTTP to rclone: exit status 255
@@ -0,0 +1,2 @@
subprocess ssh: u@127.0.0.1: Permission denied (publickey,password).
Fatal: unable to open repository at sftp:u@127.0.0.1:/x: unable to start the sftp session, error: error receiving version packet from server: server unexpectedly closed connection: unexpected EOF
@@ -0,0 +1,2 @@
subprocess ssh: ssh: connect to host 127.0.0.1 port 1: Connection refused
Fatal: unable to open repository at sftp:u@127.0.0.1:/x: unable to start the sftp session, error: error receiving version packet from server: server unexpectedly closed connection: unexpected EOF
@@ -0,0 +1,2 @@
subprocess ssh: ssh: connect to host 10.255.255.1 port 22: Connection timed out
Fatal: unable to open repository at sftp:u@10.255.255.1:/x: unable to start the sftp session, error: error receiving version packet from server: server unexpectedly closed connection: unexpected EOF
@@ -0,0 +1 @@
[{"time":"2026-10-16T02:30:00Z","parent":"27898187d9337190afb6154237f44817ccf2070d6be912c86af3c1ef55ed8e3d","tree":"b830b8165dc45c4b23086a81004548a5a52a00060028f0d274d8098dcc168b49","paths":["/data/x/u/a"],"hostname":"box","username":"root","uid":1000,"gid":1000,"tags":["felhom-offbox","a"],"id":"94fc37b9bfe97271d4a0b0feda4443a5e2934a3557108dd521d0bfb2f49423c8","short_id":"94fc37b9"}]
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"total_size":21,"total_file_count":12,"snapshots_count":1}
@@ -0,0 +1 @@
{"total_size":217458,"total_uncompressed_size":345270,"compression_ratio":1.5877548768037966,"compression_progress":100,"compression_space_saving":37.017985924059424,"total_blob_count":863,"snapshots_count":82}
@@ -0,0 +1 @@
Fatal: wrong password or no key found
+47 -2
View File
@@ -1,8 +1,9 @@
// Package family is the household's family list for the permanent family gate (`09` §3 decisions 63, 64; R-780).
//
// Each family member has their OWN name and password (never the dashboard's); the household's dashboard admin adds,
// resets and removes them. A member who signs in gets a SESSION (30 days). Every app cookie the gate mints names the
// session, so the gate asks this store on every request: a removed member, a reset password (the member's generation
// resets and removes them. A member who signs in gets a SESSION (30 days). Every app cookie the gate mints carries a
// token derived from the session for that app (never the session id itself, R-976), so the gate asks this store on
// every request (Find): a removed member, a reset password (the member's generation
// moves on) or a logout (the session is deleted) ends every app's access at the next request.
//
// A session with Member "" is the HOUSEHOLD's: minted when the dashboard's own session vouched for the browser (the
@@ -312,6 +313,50 @@ func (s *Store) Valid(id string) (Session, bool) {
return se, true
}
// Find returns the first session that match accepts and that still holds (the same test as Valid). It lets a caller
// look a session up by something DERIVED from its id — the per-app token the family gate puts in an app's cookie
// (R-976) — without that id ever leaving the dashboard host.
func (s *Store) Find(match func(id string) bool) (Session, bool) {
if s == nil || match == nil {
return Session{}, false
}
s.mu.Lock()
var found string
for id := range s.sessions {
if match(id) {
found = id
break
}
}
s.mu.Unlock()
if found == "" {
return Session{}, false
}
return s.Valid(found)
}
// EndAll deletes every session — every member's and the household's — and keeps the members (R-976: a dashboard
// password change or reset ends every way into the family apps; members sign in again with their own passwords).
// It returns how many sessions it ended.
func (s *Store) EndAll() (int, error) {
if s == nil {
return 0, nil
}
s.mu.Lock()
defer s.mu.Unlock()
n := len(s.sessions)
if n == 0 {
return 0, nil
}
// Ended in memory even when the save fails: an ending is never undone because a disk write failed (the caller
// logs the error; the sessions stay ended until a restart would read the old file back).
s.sessions = map[string]Session{}
if err := s.saveLocked(); err != nil {
return n, err
}
return n, nil
}
// EndSession deletes a session (logout). Unknown ids are not an error.
func (s *Store) EndSession(id string) error {
if s == nil {
+3 -2
View File
@@ -1287,6 +1287,7 @@
"err.backup.adatbazis_felderites_sikertelen": "finding the databases failed: %s",
"err.backup.adatbazis_masolat_csonka_nem_indult": "The database copy of %s is cut off: it does not end the way a complete copy does. The restore did not start, for safety — the app and its data are untouched.",
"err.backup.adatbazis_masolat_csonka_nem_toltve": "The database copy of %s is cut off: it does not end the way a complete copy does. That copy was not loaded — the database was not restored from it.",
"err.backup.restore_no_db_password": "This backup cannot be restored: it does not contain the database password, and without it the app could not start. The current install was left as it was.",
"err.backup.adatbazis_mentes_sikertelen": "the database backup failed (%s): %s",
"err.backup.az_adatbazis_szolgaltatas_nem_azonosithato_a": "The database service cannot be identified in %s — the restore did not start, for safety.",
"err.backup.az_alkalmazas_meghajtoja_le_van_szerelve": "the app’s drive is decommissioned",
@@ -1978,7 +1979,7 @@
"settings.notify_saved_sync_failed": "Notification settings saved on this machine. Syncing them to the central service failed: %v",
"settings.pw_mismatch": "The two passwords do not match",
"settings.pw_save_error": "Something went wrong while saving the password",
"settings.pw_too_short": "The password must be at least 8 characters long",
"settings.pw_too_short": "The password must be at least %d characters long",
"settings.pw_wrong_current": "That is not your current password",
"settings.test_email_failed": "The test email could not be sent: %v",
"settings.test_email_sent": "The test email is sent.",
@@ -2055,7 +2056,7 @@
"settings_security.jelszo_modositasa": "Change password",
"settings_security.kesz": "Done",
"settings_security.kezi_szinkronizalas": "Sync by hand",
"settings_security.legalabb_8_karakter": "At least 8 characters",
"settings_security.legalabb_karakter": "At least {{.MinPassword}} characters",
"settings_security.magyarorszag_eltavolitasa_nem_ajanlott_f": "Removing Hungary is not recommended. Continue?",
"settings_security.megerosites": "Confirm",
"settings_security.megjelenit": "Show",
+3 -2
View File
@@ -1282,6 +1282,7 @@
"err.backup.adatbazis_felderites_sikertelen": "adatbázis-felderítés sikertelen: %s",
"err.backup.adatbazis_masolat_csonka_nem_indult": "A(z) %s adatbázis-másolata csonka: nem ér véget, ahogy egy teljes másolat. A visszaállítás biztonsági okból nem indult el — az alkalmazás és az adatai érintetlenek.",
"err.backup.adatbazis_masolat_csonka_nem_toltve": "A(z) %s adatbázis-másolata csonka: nem ér véget, ahogy egy teljes másolat. Ezt a másolatot nem töltöttük vissza — az adatbázis nem ebből a másolatból állt vissza.",
"err.backup.restore_no_db_password": "Ezt a mentést nem lehet visszaállítani: nem tartalmazza az adatbázis jelszavát, és nélküle az alkalmazás nem tudna elindulni. A mostani telepítés érintetlen maradt.",
"err.backup.adatbazis_mentes_sikertelen": "adatbázis-mentés sikertelen (%s): %s",
"err.backup.az_adatbazis_szolgaltatas_nem_azonosithato_a": "Az adatbázis-szolgáltatás nem azonosítható a(z) %s alkalmazásban — a visszaállítás biztonsági okból nem indult el.",
"err.backup.az_alkalmazas_meghajtoja_le_van_szerelve": "az alkalmazás meghajtója le van szerelve",
@@ -1966,7 +1967,7 @@
"settings.notify_saved_sync_failed": "Értesítési beállítások mentve (helyi). A központi szinkronizálás sikertelen: %v",
"settings.pw_mismatch": "A két jelszó nem egyezik",
"settings.pw_save_error": "Belső hiba a jelszó mentésekor",
"settings.pw_too_short": "A jelszónak legalább 8 karakter hosszúnak kell lennie",
"settings.pw_too_short": "A jelszónak legalább %d karakter hosszúnak kell lennie",
"settings.pw_wrong_current": "Hibás jelenlegi jelszó",
"settings.test_email_failed": "Teszt email küldése sikertelen: %v",
"settings.test_email_sent": "Teszt email elküldve.",
@@ -2043,7 +2044,7 @@
"settings_security.jelszo_modositasa": "Jelszó módosítása",
"settings_security.kesz": "Kész",
"settings_security.kezi_szinkronizalas": "Kézi szinkronizálás",
"settings_security.legalabb_8_karakter": "Legalább 8 karakter",
"settings_security.legalabb_karakter": "Legalább {{.MinPassword}} karakter",
"settings_security.magyarorszag_eltavolitasa_nem_ajanlott_f": "Magyarország eltávolítása nem ajánlott. Folytatod?",
"settings_security.megerosites": "Megerősítés",
"settings_security.megjelenit": "Megjelenít",
+5 -3
View File
@@ -171,15 +171,17 @@ func hddNoteFor(removeHDDData bool, mounts, missing, userdataKept []string) stri
// userdataKept lists the folders the app binds through ${USERDATA_PATH} that exist on its drive — the household's own
// files (books, videos, photos it sees in the file browser). No remove deletes them (`09` §3 decision 67, R-800): the
// removal reads ${HDD_PATH} binds only, and ProtectedHDDPaths guards the roots. This list exists so the dialog and the
// result can SAY so, by name. USERDATA_PATH is <HDD_PATH>/userdata (withUserdataPath at deploy). Pinned by
// TestRemoveStack_R800_UserdataKeptIsNamed.
// result can SAY so, by name. USERDATA_PATH is <namespace root of HDD_PATH>/userdata (withPathVars at deploy) — on
// the system-data fallback that root is <HDD_PATH>/felhom-data, so the bare HDD_PATH is never passed to UserdataDir
// (R-203 shape, found by the R-1017 guard). Pinned by TestRemoveStack_R800_UserdataKeptIsNamed and
// TestRemoveStack_R800_UserdataKeptOnSystemDrive.
func (m *Manager) userdataKept(name, composePath string) []HDDPath {
out := []HDDPath{}
hddPath, declared := m.appHDDPath(name)
if !declared {
return out
}
for _, p := range ParseComposeUserdataMounts(composePath, appbackup.UserdataDir(hddPath)) {
for _, p := range ParseComposeUserdataMounts(composePath, appbackup.UserdataDir(appbackup.NamespaceRootFor(hddPath, m.sysDataPath))) {
clean := filepath.Clean(p)
info, err := os.Stat(clean)
if err != nil || !info.IsDir() {
@@ -456,6 +456,31 @@ func TestRemoveStack_R800_UserdataKeptIsNamed(t *testing.T) {
}
}
// R-1017 / R-203 shape: an app on the SYSTEM-DATA fallback has USERDATA_PATH = <HDD_PATH>/felhom-data/userdata (the
// deploy's withPathVars), so its kept folders live there — not under <HDD_PATH>/userdata. The dialog must name them.
// COMPANION RED-PROOF: pass the bare hddPath to UserdataDir in userdataKept again → userdata_kept is empty.
func TestRemoveStack_R800_UserdataKeptOnSystemDrive(t *testing.T) {
sys := t.TempDir()
compose := "services:\n" +
" app:\n" +
" image: nginx:1.27\n" +
" volumes:\n" +
" - ${USERDATA_PATH}/media/app:/downloads\n"
m, _, _ := newR442Manager(t, "app", compose, driveAppYAML(sys), sys)
m.sysDataPath = sys
m.cfg.Paths.SystemDataPath = sys
kept := filepath.Join(sys, "felhom-data", "userdata", "media", "app")
plantFile(t, filepath.Join(kept, "clip.mp4"))
hd, err := m.GetStackHDDData("app")
if err != nil {
t.Fatal(err)
}
if len(hd.UserdataKept) != 1 || hd.UserdataKept[0].Path != kept {
t.Fatalf("hdd-data userdata_kept = %+v, want [%s] — on the system drive the household's folder sits under felhom-data", hd.UserdataKept, kept)
}
}
// An SSD app: userdata_kept serialises as [] (never null), the "no drive data" note is unchanged.
func TestRemoveStack_R800_SSDAppUserdataKeptEmpty(t *testing.T) {
m, _, _ := newR442Manager(t, "app", ssdCompose, "deployed: true\nenv: {}\n", "")
+49
View File
@@ -10,6 +10,7 @@ import (
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
@@ -1590,3 +1591,51 @@ func restoredLoginFields(app string, meta Metadata, prior *AppConfig, env map[st
}
return out
}
// SetStackSecretValues (R-1106) writes the named values into an installed app's app.yaml and NOTHING else: load the
// file as it is (records, pin, gates, locked fields all kept — the R-697 lesson: a non-install writer loads, then
// saves), replace the named env values, save with the sensitive fields encrypted. Used by the off-site restore to put
// back the database password a restored data directory was created with. Values are never logged. The in-memory app
// config is refreshed so the next start renders the new value.
func (m *Manager) SetStackSecretValues(name string, values map[string]string) error {
stack, ok := m.GetStack(name)
if !ok {
return fmt.Errorf("stack %q not found", name)
}
stackDir := filepath.Dir(stack.ComposePath)
meta := LoadMetadata(stackDir)
cfg := LoadAppConfig(stackDir)
if cfg == nil {
return fmt.Errorf("app.yaml of %s not readable", name)
}
if cfg.Env == nil {
cfg.Env = map[string]string{}
}
names := make([]string, 0, len(values))
for k, v := range values {
cfg.Env[k] = v // plaintext here; SaveAppConfig encrypts every sensitive field
names = append(names, k)
}
sort.Strings(names)
// R-926 / R-694 on this path: the only caller writes these values because the install is NOT the one the restored
// data belongs to (a reinstall). Its `type: password` admin logins were generated for the fresh, empty app; the
// restored data carries the household's own login. So they are recorded as restored-login fields — the page then
// never shows them as the app's login and says to use the old password, exactly as after a unit restore that had
// to generate one. A login the app applies at every start (loginAppliedEveryStart) stays a real login.
for _, f := range meta.DeployFields {
if f.Type != "password" || cfg.Env[f.EnvVar] == "" || loginAppliedEveryStart[name][f.EnvVar] || containsStr(cfg.RestoredLogins, f.EnvVar) {
continue
}
cfg.RestoredLogins = append(cfg.RestoredLogins, f.EnvVar)
}
if err := SaveAppConfig(stackDir, cfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
return fmt.Errorf("saving app config: %w", err)
}
m.mu.Lock()
if s, ok := m.stacks[name]; ok {
s.AppConfig = LoadAppConfig(stackDir)
}
m.mu.Unlock()
m.logger.Printf("[INFO] [stacks] %s: secret value(s) %v replaced in app.yaml (values not logged)", name, names)
return nil
}
@@ -0,0 +1,51 @@
package stacks
import (
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
)
// R-1106 / R-926: the off-site restore over a REINSTALL writes the backup's database password and nothing else of the
// app's settings, and records the reinstall's generated admin login as NOT the app's login (the restored data carries
// the household's own). Through the production writer.
func TestR1106_SetStackSecretValues(t *testing.T) {
dir := t.TempDir()
cfg := &config.Config{}
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
cfg.Stacks.ComposeCommand = "docker compose"
app := filepath.Join(cfg.Paths.StacksDir, "bookstack")
must(t, os.MkdirAll(app, 0o755))
must(t, os.WriteFile(filepath.Join(app, "docker-compose.yml"), []byte("services:\n web:\n image: x/y:1\n"), 0o644))
must(t, os.WriteFile(filepath.Join(app, ".felhom.yml"), []byte("display_name: X\ndeploy_fields:\n"+
" - env_var: DB_PASSWORD\n label: DB\n type: secret\n"+
" - env_var: ADMIN_PASSWORD\n label: Admin\n type: password\n"), 0o644))
must(t, os.WriteFile(filepath.Join(app, "app.yaml"), []byte("deployed: true\ndeployed_at: \"2026-10-16T19:00:00Z\"\n"+
"pinned_images:\n web: x/y:1\nenv:\n DB_PASSWORD: reinstall-db\n ADMIN_PASSWORD: reinstall-admin\n SUBDOMAIN: books\n"), 0o600))
m, err := NewManager(cfg, log.New(io.Discard, "", 0))
must(t, err)
must(t, m.ScanStacks())
must(t, m.SetStackSecretValues("bookstack", map[string]string{"DB_PASSWORD": "backup-db"}))
got := LoadAppConfig(app)
if got == nil {
t.Fatal("app.yaml unreadable")
}
if got.Env["DB_PASSWORD"] != "backup-db" {
t.Errorf("DB_PASSWORD = %q, want the backup's", got.Env["DB_PASSWORD"])
}
if got.Env["SUBDOMAIN"] != "books" || got.Env["ADMIN_PASSWORD"] != "reinstall-admin" || got.DeployedAt != "2026-10-16T19:00:00Z" {
t.Errorf("other settings changed: %+v", got)
}
if len(got.PinnedImages) == 0 {
t.Error("the pin was dropped — a non-install writer must load, then save (R-697)")
}
if strings.Join(got.RestoredLogins, ",") != "ADMIN_PASSWORD" {
t.Errorf("restored_logins = %v, want the reinstall's admin login recorded as not the app's (R-926)", got.RestoredLogins)
}
}
+12 -2
View File
@@ -258,12 +258,22 @@ func (m *Manager) SetSMBPassword(password string) error {
}
dir := m.sambaDir()
data := m.sambaRenderData(smb)
if _, err := m.writeSambaFiles(dir, data); err != nil {
changed, err := m.writeSambaFiles(dir, data)
if err != nil {
return err
}
// The container must be running to accept smbpasswd. Bringing it up before a password exists is
// safe: `security = user` + `map to guest = never` means nothing is reachable until this lands.
if !m.sambaIsRunning() {
//
// R-1108: an ALREADY-running container must also be re-applied when the files changed. A share
// added while the password was unset was skipped by reconcileSambaAt (UserSet=false) and lands on
// disk only here; smb.conf is a bind-mounted FILE replaced by atomic rename, so the running
// container still holds the old inode — only `compose up -d` (which recreates, the share binds
// having changed the compose file) applies it. The next reconcile sees "unchanged + running" and
// would never do it. Pinned by TestSambaPassword_ShareAddedBeforePasswordIsApplied.
running := m.sambaIsRunning()
if !running || changed {
m.logger.Printf("[INFO] [samba] applying samba stack before password set: shares=%d config_changed=%v was_running=%v", len(data.Shares), changed, running)
if err := m.sambaUp(dir); err != nil {
return util.MsgError("err.stacks.a_megosztas_szolgaltatas_inditasa_sikertelen", err)
}
+89
View File
@@ -296,3 +296,92 @@ func TestSambaPasswordRefusedWhenDisabled(t *testing.T) {
t.Errorf("no compose call may happen while disabled, got %d", *upCalls)
}
}
// R-1108: a share added while sharing is on but the household password is NOT yet set must be
// exported once the password is set, even though the samba container is ALREADY running. Before the
// fix SetSMBPassword rewrote smb.conf + compose but called compose up only when the container was
// down, and the next reconcile saw "unchanged + running" and did nothing — the share never appeared.
// The consequence asserted: the new config is on disk AND a compose up ran after it was written.
func TestSambaPassword_ShareAddedBeforePasswordIsApplied(t *testing.T) {
m, sett, root, upCalls := newSambaManager(t)
if err := sett.SetSMBEnabled(true); err != nil {
t.Fatal(err)
}
// First password attempt: the container is brought up, then smbpasswd fails — the box is left
// with sharing on, UserSet=false and a RUNNING container holding a share-less config.
m.sambaPasswdFn = func(string) error { return os.ErrDeadlineExceeded }
if err := m.SetSMBPassword("elso"); err == nil {
t.Fatal("setup: the first password apply was meant to fail")
}
if *upCalls != 1 {
t.Fatalf("setup: expected the container brought up once, got %d", *upCalls)
}
if sett.GetSMBSettings().UserSet {
t.Fatal("setup: UserSet must stay false after a failed smbpasswd")
}
m.sambaRunFn = func() bool { return true }
// A share is added now; its reconcile is skipped because no password is set yet.
shareDir := seedShare(t, sett, root, "dokumentumok")
if err := sett.AddSMBShare(settings.SMBShare{Name: "dokumentumok", Path: shareDir}); err != nil {
t.Fatal(err)
}
if err := m.ReconcileSamba(); err != nil {
t.Fatal(err)
}
if *upCalls != 1 {
t.Fatalf("setup: reconcile without a password must not deploy, got %d up calls", *upCalls)
}
// The password is set: the share must be written AND applied to the running container.
var upAfterWrite bool
m.sambaUpFn = func(dir string) error {
*upCalls++
conf, err := os.ReadFile(filepath.Join(dir, "smb.conf"))
upAfterWrite = err == nil && strings.Contains(string(conf), "[dokumentumok]")
return nil
}
m.sambaPasswdFn = func(string) error { return nil }
if err := m.SetSMBPassword("masodik"); err != nil {
t.Fatalf("SetSMBPassword: %v", err)
}
conf, err := os.ReadFile(filepath.Join(m.sambaDir(), "smb.conf"))
if err != nil || !strings.Contains(string(conf), "[dokumentumok]") {
t.Fatalf("share missing from smb.conf after the password set: err=%v\n%s", err, conf)
}
if *upCalls != 2 || !upAfterWrite {
t.Errorf("the running container must be re-applied with the new share: up calls=%d (want 2), up saw the share=%v", *upCalls, upAfterWrite)
}
// And the follow-up reconcile is a clean no-op (nothing pending was left behind).
before := *upCalls
if err := m.ReconcileSamba(); err != nil {
t.Fatal(err)
}
if *upCalls != before {
t.Errorf("reconcile after the password set must be a no-op: up calls %d → %d", before, *upCalls)
}
}
// R-1108 companion: setting (or changing) the password on a running container whose config did NOT
// change performs no compose call — the fix must not turn every password change into a recreate.
func TestSambaPassword_RunningUnchangedNoComposeCall(t *testing.T) {
m, sett, root, upCalls := newSambaManager(t)
shareDir := seedShare(t, sett, root, "dokumentumok")
enableSMB(t, sett)
if err := sett.AddSMBShare(settings.SMBShare{Name: "dokumentumok", Path: shareDir}); err != nil {
t.Fatal(err)
}
if err := m.ReconcileSamba(); err != nil {
t.Fatal(err)
}
m.sambaRunFn = func() bool { return true }
m.sambaPasswdFn = func(string) error { return nil }
before := *upCalls
if err := m.SetSMBPassword("uj"); err != nil {
t.Fatalf("SetSMBPassword: %v", err)
}
if *upCalls != before {
t.Errorf("an unchanged config on a running container needs no compose call: %d → %d", before, *upCalls)
}
}
+77 -25
View File
@@ -2,6 +2,7 @@ package web
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"fmt"
"net/http"
@@ -164,31 +165,19 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
return
}
s.loginAttemptMu.Lock()
attempt := s.loginAttempts[ip]
if attempt != nil && time.Since(attempt.lastFail) > loginWindowDuration {
// Window expired — reset
attempt = nil
delete(s.loginAttempts, ip)
}
if attempt != nil && attempt.count >= loginMaxAttempts {
s.loginAttemptMu.Unlock()
s.logger.Printf("[WARN] [web] Login rate limited for %s (%d attempts)", ip, attempt.count)
// R-983 (v0.324.0): the try is RESERVED before the password is compared, under the one lock — check and count
// are a single step. Before, the count was taken only after the slow compare, so N parallel tries all passed the
// check and the 5-per-minute limit became "as many as arrive at once". A right password clears the counter below,
// so the household loses nothing by its own try being counted first. Pinned by TestR983_ParallelLoginsHitTheLimit.
if n, ok := s.reserveLoginAttempt(ip); !ok {
s.logger.Printf("[WARN] [web] Login rate limited for %s (%d attempts)", ip, n)
s.renderLogin(w, r, s.msg(r, "login.msg.rate_limited"), "")
return
}
s.loginAttemptMu.Unlock()
effectiveHash := s.effectivePasswordHash()
if err := bcrypt.CompareHashAndPassword([]byte(effectiveHash), []byte(password)); err != nil {
if err := s.comparePassword([]byte(effectiveHash), []byte(password)); err != nil {
s.logger.Printf("[WARN] [web] Failed login from %s", ip)
s.loginAttemptMu.Lock()
if s.loginAttempts[ip] == nil {
s.loginAttempts[ip] = &loginAttempt{}
}
s.loginAttempts[ip].count++
s.loginAttempts[ip].lastFail = time.Now()
s.loginAttemptMu.Unlock()
s.renderLogin(w, r, s.msg(r, "login.msg.wrong_password"), "")
return
}
@@ -221,14 +210,50 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
s.logger.Printf("[INFO] [web] Login from %s", ip)
// Redirect to ?next= target if provided, otherwise to dashboard
redirectTo := "/"
if nextURL != "" && strings.HasPrefix(nextURL, "/") {
redirectTo = nextURL
}
http.Redirect(w, r, redirectTo, http.StatusFound)
// Redirect to ?next= target if provided, otherwise to dashboard. R-983 (v0.324.0): through safeBackPath, the one
// guard for a destination taken from a request — a bare HasPrefix("/") let "//host" and "/\host" send a browser
// that had just signed in to another site. Pinned by TestR983_LoginNextStaysOnTheBox.
http.Redirect(w, r, safeBackPath(nextURL), http.StatusFound)
}
// reserveLoginAttempt counts one sign-in try for this visitor BEFORE the password is compared and reports whether it
// may go ahead (and the count it found). Check and count happen under one lock, so parallel tries cannot all pass a
// check that none of them has counted yet. A window that has passed starts clean.
func (s *Server) reserveLoginAttempt(ip string) (int, bool) {
s.loginAttemptMu.Lock()
defer s.loginAttemptMu.Unlock()
if s.loginAttempts == nil {
s.loginAttempts = map[string]*loginAttempt{}
}
a := s.loginAttempts[ip]
if a != nil && time.Since(a.lastFail) > loginWindowDuration {
a = nil
}
if a == nil {
a = &loginAttempt{}
s.loginAttempts[ip] = a
}
if a.count >= loginMaxAttempts {
return a.count, false
}
a.count++
a.lastFail = time.Now()
return a.count, true
}
// comparePassword is bcrypt's compare; passwordCompare replaces it in tests (a slow compare makes a race measurable).
func (s *Server) comparePassword(hash, pw []byte) error {
if s.passwordCompare != nil {
return s.passwordCompare(hash, pw)
}
return bcrypt.CompareHashAndPassword(hash, pw)
}
// handleLogout ends the presented dashboard session. It runs inside RequireAuth, BEFORE CsrfProtect, so it checks the
// session's CSRF token itself (R-983, v0.324.0): without it any page — another site, or an app on a sibling
// subdomain, which is same-site and so gets the Lax cookie — could sign the household out. A GET changes nothing and
// goes to the dashboard; the dashboard's own sign-out control is a POST form carrying the token (layout.html).
// A POST with no valid session has nothing to end and only clears the cookie. Pinned by TestR983_LogoutNeedsCSRF.
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Redirect(w, r, "/", http.StatusFound)
@@ -238,6 +263,16 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
s.logger.Printf("[DEBUG] [web] logout from %s", r.RemoteAddr)
}
if cookie, err := r.Cookie(sessionCookieName); err == nil {
if expected := s.csrfTokenForSession(cookie.Value); expected != "" {
submitted := r.FormValue(csrfFormField)
if submitted == "" {
submitted = r.Header.Get(csrfHeaderName)
}
if submitted == "" || subtle.ConstantTimeCompare([]byte(submitted), []byte(expected)) != 1 {
s.csrfReject(w, r, "logout without the session's token")
return
}
}
s.sessionsMu.Lock()
delete(s.sessions, sessionFingerprint(cookie.Value))
s.saveSessionsRevokingLocked() // R-35: a signed-out cookie stays signed out across a restart
@@ -304,6 +339,23 @@ func (s *Server) invalidateAllSessions() {
s.logger.Printf("[INFO] [web] All sessions invalidated (cleared %d)", count)
}
// endEverySession is what a dashboard password change and a claim/reset do (R-976, v0.324.0): every dashboard session
// ends (invalidateAllSessions), every family session — each member's and the household's — ends, and the gate key is
// replaced, so every setup-gate pass and every family app cookie stops opening its app at the next request. Before,
// only the dashboard sessions ended, so a session copied out of a family app kept working for up to 30 days after the
// household had changed its password. Family members keep their own passwords and sign in again; the household's
// browser passes again through its new dashboard session. Pinned by TestR976_PasswordChangeEndsFamilyAndGateSessions
// and TestR976_ClaimResetEndsFamilyAndGateSessions.
func (s *Server) endEverySession(why string) {
s.invalidateAllSessions()
n, err := s.familyStore().EndAll()
if err != nil {
s.logger.Printf("[ERROR] [web] %s: the family sessions were ended but the family list could not be saved: %v", why, err)
}
s.rotateGateKey()
s.logger.Printf("[INFO] [web] %s: every dashboard session, %d family session(s) and every app gate pass ended", why, n)
}
func (s *Server) cleanupSessions() {
ticker := time.NewTicker(15 * time.Minute)
defer ticker.Stop()
+51 -2
View File
@@ -397,7 +397,7 @@ func (s *Server) handleClaimSubmit(w http.ResponseWriter, r *http.Request) {
}
}
s.invalidateAllSessions() // reset: kill old sessions; first-claim: none exist
s.endEverySession("claim/reset") // reset: kill old sessions — dashboard, family, gate passes (R-976); first-claim: none exist
action := "claimed"
if wasReset {
@@ -428,10 +428,59 @@ func (s *Server) handleClaimRequestNewCode(w http.ResponseWriter, r *http.Reques
s.handleClaimPage(w, r, s.msg(r, "claim.msg.invalid_form"), "")
return
}
go s.requestHubResetCode() // fire-and-forget; the neutral response never reveals the outcome
// R-983 (v0.324.0): a request reaches the hub only inside the box's own limit. Each call mints a fresh code that
// replaces the one the household may be typing, and mails the household — so without a limit a stranger could do
// both at will. The answer stays the same neutral page either way (it never revealed the outcome).
if ok, why := s.allowResetRequest(rateKey(r)); !ok {
s.logger.Printf("[WARN] [web] claim: a reset-code request was not passed to the hub (%s) — visitor %s", why, rateKey(r))
} else if s.resetRequestFn != nil {
s.resetRequestFn()
} else {
go s.requestHubResetCode() // fire-and-forget; the neutral response never reveals the outcome
}
s.handleClaimPage(w, r, "", s.msg(r, "claim.msg.code_sent"))
}
// The reset-code request's limit (R-983): one hub call per visitor per resetReqVisitorEvery, and at most
// resetReqGlobalMax for the whole box in any resetReqGlobalWindow. The worst a stranger can do is hold the box's
// budget for one window — the household's code already sent stays valid meanwhile, because nothing replaces it.
const (
resetReqVisitorEvery = time.Minute
resetReqGlobalMax = 3
resetReqGlobalWindow = 15 * time.Minute
)
// allowResetRequest decides and counts in one step (under claimMu), so parallel requests cannot all pass.
func (s *Server) allowResetRequest(visitor string) (bool, string) {
s.claimMu.Lock()
defer s.claimMu.Unlock()
now := s.claimNow()
if s.resetReqVisitor == nil {
s.resetReqVisitor = map[string]time.Time{}
}
for k, at := range s.resetReqVisitor {
if now.Sub(at) >= resetReqVisitorEvery {
delete(s.resetReqVisitor, k)
}
}
keep := s.resetReqGlobal[:0]
for _, at := range s.resetReqGlobal {
if now.Sub(at) < resetReqGlobalWindow {
keep = append(keep, at)
}
}
s.resetReqGlobal = keep
if _, seen := s.resetReqVisitor[visitor]; seen {
return false, "this visitor asked less than a minute ago"
}
if len(s.resetReqGlobal) >= resetReqGlobalMax {
return false, "the box's limit for this quarter hour is used"
}
s.resetReqVisitor[visitor] = now
s.resetReqGlobal = append(s.resetReqGlobal, now)
return true, ""
}
// requestHubResetCode calls POST /api/v1/claim/reset-request with the box's own report key.
// v0.123.0 (take-two F-15): a hub ≥0.52.0 returns the freshly rotated code state in the response;
// it is applied through the SAME generation-guarded consumer as the report ACK (ClaimSync), so the
+74 -31
View File
@@ -39,8 +39,10 @@ import (
// every request).
//
// THE RULE: a family cookie never opens the dashboard. RequireAuth reads only `felhom_session`; nothing here ever sets
// it. An app cookie names a store session, so a removed member, a reset password or a logout ends access at the next
// request on every app. Pinned by internal/web/family_gate_test.go.
// it. An app cookie carries a token derived from a store session for that one app host (familyAppToken, R-976), so a
// removed member, a reset password, a logout or a dashboard password change ends access at the next request on every
// app, and what an app's backend sees cannot be turned into a pass for another app. Pinned by
// internal/web/family_gate_test.go and r983_r976_test.go.
const (
familySessionCookie = "felhom_family"
@@ -91,8 +93,10 @@ func (s *Server) familyStore() *family.Store {
return s.fam.store
}
// familyLocked reports whether this visitor or this name is out of tries. Windows slide; a success clears both.
func (s *Server) familyLocked(visitor, name string) bool {
// familyReserve checks whether this visitor or this name is out of tries and, when neither is, counts this try
// against both — one step under one lock (R-983, v0.324.0), so parallel tries cannot all pass a check that none of
// them has counted yet. Windows slide; a success clears both (familyCleared). It reports whether the try may go ahead.
func (s *Server) familyReserve(visitor, name string) bool {
s.familyStore()
now := s.gateNow()
s.fam.mu.Lock()
@@ -116,17 +120,14 @@ func (s *Server) familyLocked(visitor, name string) bool {
if name != "" {
n = prune(s.fam.name, name, familyNameWindow)
}
return v >= familyVisitorMax || n >= familyNameMax
}
func (s *Server) familyFailed(visitor, name string) {
now := s.gateNow()
s.fam.mu.Lock()
defer s.fam.mu.Unlock()
if v >= familyVisitorMax || n >= familyNameMax {
return false
}
s.fam.visitor[visitor] = append(s.fam.visitor[visitor], now)
if name != "" {
s.fam.name[name] = append(s.fam.name[name], now)
}
return true
}
func (s *Server) familyCleared(visitor, name string) {
@@ -152,9 +153,28 @@ func (s *Server) familyRDHost(rd string) (string, bool) {
return host, true
}
// familyAppToken is what an app host ever sees of a family session (R-976, v0.324.0): a token DERIVED from the
// session for that one host, never the session itself. Before, the app cookie carried the session id in clear; every
// family-gated app's backend receives its cookies, and a session id lifted from one app, set as the family session
// cookie on the dashboard host, minted passes into EVERY family app for 30 days. A derived token cannot be turned back
// into the session, and it opens only its own host. Pinned by TestR976_AnAppCookieCannotBeLiftedIntoAnotherApp.
func (s *Server) familyAppToken(host, sid string) string {
return s.gateMAC("famapp", host, sid)
}
// familySessionForAppToken finds the session an app token was derived from (still valid, as Store.Valid judges it).
func (s *Server) familySessionForAppToken(host, at string) (family.Session, bool) {
if at == "" {
return family.Session{}, false
}
return s.familyStore().Find(func(id string) bool {
return hmac.Equal([]byte(s.familyAppToken(host, id)), []byte(at))
})
}
type familyToken struct {
Host string `json:"h"`
Sid string `json:"s"`
App string `json:"a"` // the per-app token (familyAppToken), never the session id
Exp int64 `json:"e"`
Nonce string `json:"n"`
RD string `json:"r"`
@@ -164,14 +184,14 @@ type familyToken struct {
func (s *Server) mintFamilyToken(host, sid, rd string) string {
nb := make([]byte, 12)
_, _ = rand.Read(nb)
t := familyToken{Host: host, Sid: sid, Exp: s.gateNow().Add(gateTokenLife).Unix(), Nonce: hex.EncodeToString(nb), RD: rd}
t.MAC = s.gateMAC("ftoken", t.Host, t.Sid, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD)
t := familyToken{Host: host, App: s.familyAppToken(host, sid), Exp: s.gateNow().Add(gateTokenLife).Unix(), Nonce: hex.EncodeToString(nb), RD: rd}
t.MAC = s.gateMAC("ftoken", t.Host, t.App, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD)
b, _ := json.Marshal(t)
return base64.RawURLEncoding.EncodeToString(b)
}
// takeFamilyToken checks a token for host, uses it up, and returns the session and where the browser was going.
func (s *Server) takeFamilyToken(raw, host string) (sid, rd string, err error) {
// takeFamilyToken checks a token for host, uses it up, and returns the per-app token and where the browser was going.
func (s *Server) takeFamilyToken(raw, host string) (at, rd string, err error) {
b, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil {
return "", "", errors.New("malformed")
@@ -180,7 +200,7 @@ func (s *Server) takeFamilyToken(raw, host string) (sid, rd string, err error) {
if json.Unmarshal(b, &t) != nil {
return "", "", errors.New("malformed")
}
if !hmac.Equal([]byte(t.MAC), []byte(s.gateMAC("ftoken", t.Host, t.Sid, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD))) {
if !hmac.Equal([]byte(t.MAC), []byte(s.gateMAC("ftoken", t.Host, t.App, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD))) {
return "", "", errors.New("bad signature")
}
if t.Host != host {
@@ -202,11 +222,13 @@ func (s *Server) takeFamilyToken(raw, host string) (sid, rd string, err error) {
return "", "", errors.New("already used")
}
s.gate.used["f:"+t.Nonce] = time.Unix(t.Exp, 0).Add(time.Second)
return t.Sid, t.RD, nil
return t.App, t.RD, nil
}
// familyAppCookieSession: "<sid>.<unix expiry>.<mac over host+sid+expiry>" → the session, if the cookie is genuine for
// this host, unexpired, and the store still holds the session (a removed member / reset / logout fails here).
// familyAppCookieSession: "<app token>.<unix expiry>.<mac over host+token+expiry>" → the session, if the cookie is
// genuine for this host, unexpired, and the store still holds the session the token was derived from (a removed
// member / reset / logout / dashboard password change fails here). A cookie of the old "<session id>.…" shape is
// refused: the browser goes through the sign-in start once more and gets the new shape.
func (s *Server) familyAppCookieSession(r *http.Request, host string) (family.Session, bool) {
c, err := r.Cookie(familyAppCookie)
if err != nil {
@@ -223,7 +245,7 @@ func (s *Server) familyAppCookieSession(r *http.Request, host string) (family.Se
if !hmac.Equal([]byte(parts[2]), []byte(s.gateMAC("famcookie", host, parts[0], parts[1]))) {
return family.Session{}, false
}
return s.familyStore().Valid(parts[0])
return s.familySessionForAppToken(host, parts[0])
}
func familyRefuse(w http.ResponseWriter, code int) {
@@ -256,9 +278,9 @@ func (s *Server) ServeFamilyGateAuth(w http.ResponseWriter, r *http.Request) {
return
}
if u, err := url.Parse(uri); err == nil && u.Path == familyCallbackURI {
sid, rd, err := s.takeFamilyToken(u.Query().Get("t"), host)
at, rd, err := s.takeFamilyToken(u.Query().Get("t"), host)
if err == nil {
if _, ok := s.familyStore().Valid(sid); !ok {
if _, ok := s.familySessionForAppToken(host, at); !ok {
err = errors.New("the session ended")
}
}
@@ -269,7 +291,7 @@ func (s *Server) ServeFamilyGateAuth(w http.ResponseWriter, r *http.Request) {
}
exp := strconv.FormatInt(s.gateNow().Add(family.SessionLife).Unix(), 10)
http.SetCookie(w, &http.Cookie{
Name: familyAppCookie, Value: sid + "." + exp + "." + s.gateMAC("famcookie", host, sid, exp), Path: "/",
Name: familyAppCookie, Value: at + "." + exp + "." + s.gateMAC("famcookie", host, at, exp), Path: "/",
MaxAge: int(family.SessionLife.Seconds()), HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
})
s.logger.Printf("[INFO] [web] family gate %s: a signed-in browser passed — visitor %s", app, clientIP(r))
@@ -356,14 +378,16 @@ func (s *Server) ServeFamilyLogin(w http.ResponseWriter, r *http.Request) {
}
name := strings.ToLower(strings.TrimSpace(r.FormValue("name")))
visitor := rateKey(r)
if s.familyLocked(visitor, name) {
// R-983 (v0.324.0): the try is counted BEFORE the password is checked, in the same step as the lock check, so
// parallel tries cannot all pass a check none of them has counted yet. A right password clears both counts.
// Pinned by TestR983_ParallelFamilySignInsHitTheLimit.
if !s.familyReserve(visitor, name) {
s.logger.Printf("[WARN] [web] family sign-in: too many wrong tries — visitor %s, name %q", visitor, name)
s.renderFamilyPage(w, r, rd, s.msg(r, "family_gate.msg.locked"), http.StatusTooManyRequests)
return
}
st := s.familyStore()
if st == nil || !st.Verify(name, r.FormValue("password")) {
s.familyFailed(visitor, name)
s.logger.Printf("[WARN] [web] family sign-in failed — visitor %s", visitor)
s.renderFamilyPage(w, r, rd, s.msg(r, "family_gate.msg.wrong"), http.StatusUnauthorized)
return
@@ -385,12 +409,30 @@ func (s *Server) ServeFamilyLogin(w http.ResponseWriter, r *http.Request) {
s.renderFamilyPage(w, r, "", s.msg(r, "family_gate.msg.signed_in"), http.StatusOK)
}
// familyLogoutToken is the sign-out form's token, bound to the family session it ends (R-983, v0.324.0). The sign-in
// form's token (familyFormToken) is the same for every visitor, so a stranger could fetch one and use it; this one
// names the session, which another page never sees.
func (s *Server) familyLogoutToken(sid string) string {
return s.gateMAC("famlogout", sid)
}
// ServeFamilyLogout is /__family/logout: the family session ends, and every app cookie minted from it with it.
// R-983 (v0.324.0): only a POST from the sign-out form (familygate.html), carrying familyLogoutToken. Anything else —
// a GET, or a form another page built — changes nothing and shows the family page. Pinned by
// TestR983_FamilyLogoutNeedsItsToken.
func (s *Server) ServeFamilyLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(familySessionCookie); err == nil {
if err := s.familyStore().EndSession(c.Value); err != nil {
s.logger.Printf("[ERROR] [web] family sign-out: %v", err)
}
c, err := r.Cookie(familySessionCookie)
if r.Method != http.MethodPost || err != nil {
s.renderFamilyPage(w, r, "", "", http.StatusOK)
return
}
if !hmac.Equal([]byte(r.FormValue("_ft")), []byte(s.familyLogoutToken(c.Value))) {
s.logger.Printf("[WARN] [web] family sign-out refused: the form's token does not match the session — visitor %s", clientIP(r))
s.renderFamilyPage(w, r, "", s.msg(r, "family_gate.msg.form_expired"), http.StatusForbidden)
return
}
if err := s.familyStore().EndSession(c.Value); err != nil {
s.logger.Printf("[ERROR] [web] family sign-out: %v", err)
}
http.SetCookie(w, &http.Cookie{Name: familySessionCookie, Value: "", Path: familyCookiePath, MaxAge: -1, HttpOnly: true, Secure: true})
s.renderFamilyPage(w, r, "", s.msg(r, "family_gate.msg.signed_out"), http.StatusOK)
@@ -406,8 +448,9 @@ func (s *Server) renderFamilyPage(w http.ResponseWriter, r *http.Request, rd, no
}
}
}
if _, ok := s.familySessionFromCookie(r); ok {
if se, ok := s.familySessionFromCookie(r); ok {
data["SignedIn"] = true
data["LogoutToken"] = s.familyLogoutToken(se.ID)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
+3 -1
View File
@@ -213,7 +213,9 @@ func TestFamilyGate_ResetRemoveLogoutEndAccessAtOnce(t *testing.T) {
}
sess = login()
app = famPass(t, s, sess)
r := httptest.NewRequest(http.MethodPost, "https://felhom.example.hu"+familyLogoutPath, nil)
r := httptest.NewRequest(http.MethodPost, "https://felhom.example.hu"+familyLogoutPath,
strings.NewReader(url.Values{"_ft": {s.familyLogoutToken(sess.Value)}}.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(sess)
s.ServeFamilyLogout(httptest.NewRecorder(), r)
if ok(app) {
+7 -5
View File
@@ -2507,6 +2507,7 @@ func (s *Server) securityPageData() map[string]interface{} {
// EXISTS, and the value comes from POST /settings/retrieval-password/reveal, which is an
// explicit authenticated act and is logged as one.
data["HasRetrievalPassword"] = strings.TrimSpace(s.settings.GetRetrievalPassword()) != ""
data["MinPassword"] = claimMinPassword // R-983: the password form's minimum is the claim's
data["HubURL"] = s.cfg.Hub.URL
data["SupportEmail"] = "support@felhom.eu"
data["SupportURL"] = "https://felhom.eu/kapcsolat"
@@ -2848,9 +2849,10 @@ func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request)
return
}
// Validate new password length
if len(newPassword) < 8 {
data["PasswordError"] = s.msg(r, "settings.pw_too_short")
// Validate new password length. R-983 (v0.324.0): the claim's minimum (claim.go claimMinPassword), not 8 — one
// dashboard password, one rule, wherever it is set. Pinned by TestR983_SettingsPasswordMinimumIsTheClaims.
if len(newPassword) < claimMinPassword {
data["PasswordError"] = s.msg(r, "settings.pw_too_short", claimMinPassword)
s.executeTemplate(w, r, "settings_security", data)
return
}
@@ -2881,8 +2883,8 @@ func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request)
s.logger.Printf("[INFO] [web] Password changed via settings page from %s", r.RemoteAddr)
// Invalidate all sessions (force re-login)
s.invalidateAllSessions()
// Invalidate all sessions (force re-login) — the dashboard's, the family gate's and every app gate pass (R-976).
s.endEverySession("password change")
// Redirect to login with flash message
http.Redirect(w, r, "/login?"+flashQuery("flash", "flash.login.password_changed"), http.StatusFound)
@@ -276,6 +276,7 @@ func i18nCasesA() []i18nCase {
d["HasRetrievalPassword"] = true
d["CustomerID"], d["HubURL"] = "test-customer", "https://hub.example"
d["SupportEmail"], d["SupportURL"] = "support@example.hu", "https://example.hu/kapcsolat"
d["MinPassword"] = claimMinPassword // R-983 (v0.324.0): the claim's minimum, as securityPageData sets it
return d
}},
{"settings_security_noauth", "settings_security", func() map[string]interface{} {
+2 -1
View File
@@ -166,7 +166,8 @@ func i18nCasesC() []i18nCase {
return m{"AppName": "Grimmory", "Host": "books.example.hu", "RD": "https://books.example.hu/", "FormToken": "FT"}
}},
{"familygate_signed_in", "familygate", func() map[string]interface{} {
return m{"SignedIn": true, "Notice": "Beléptél. Nyisd meg újra az alkalmazást."}
// R-983 (v0.324.0): the sign-out form carries its session-bound token — re-captured for that line.
return m{"SignedIn": true, "Notice": "Beléptél. Nyisd meg újra az alkalmazást.", "LogoutToken": "LOGOUT-TOKEN"}
}},
{"catchall_unknown", "catchall", func() map[string]interface{} {
return m{"ControllerURL": "https://felhom.example.hu", "Status": "unknown", "StatusText": "Ez a cím nem tartozik alkalmazáshoz", "Host": "x.example.hu"}
+10 -1
View File
@@ -411,13 +411,22 @@ func (s *Server) applyPlexClaim(code string) error {
s.logger.Printf("[WARN] [web] plex: claimed, but opening the setup gate failed: %v", oerr)
}
s.logger.Printf("[INFO] [web] plex: claimed — the setup gate is open (R-1028)")
// R-1065: the libraries are set by the media wiring, which needs the server's own token and so could
// not run while Plex was unclaimed. Trigger it now (debounced, with its retry) instead of leaving the
// household's libraries empty until the next drive event or restart. Pinned by
// TestPlexClaim_TriggersMediaWiring.
s.logger.Printf("[INFO] [web] plex: claimed — media wiring scheduled to set the libraries (R-1065)")
s.RunMediaWiringSoon("plex")
return nil
}
time.Sleep(time.Second)
time.Sleep(plexClaimPoll)
}
return &afterStartError{Status: http.StatusConflict, MsgKey: "err.plex_claim.not_claimed"}
}
// plexClaimPoll is the wait between the „is it claimed yet" probes after a claim (a seam: tests poll without the wait).
var plexClaimPoll = time.Second
// plexHTTP is the client for the box's own calls to Plex (a seam: tests dial a fake that refuses the name „plex").
var plexHTTP = &http.Client{Timeout: 30 * time.Second}
@@ -81,3 +81,47 @@ func TestPlexDonePress_RefusedWhileUnclaimed(t *testing.T) {
t.Fatalf("„Kész” on a claimed Plex: HTTP %d, want 200", c)
}
}
// R-1065: a claim that Plex confirms schedules the media wiring (which sets the libraries — it could
// not while Plex was unclaimed); a claim Plex refuses, or one Plex never reports as claimed, does not.
// The consequence asserted is the armed wiring timer — the same one a media app coming up arms.
// COMPANION RED-PROOF: drop the RunMediaWiringSoon call in applyPlexClaim → the claimed case fails.
func TestPlexClaim_TriggersMediaWiring(t *testing.T) {
oldClaim, oldClaimed, oldPoll := plexClaim, plexClaimed, plexClaimPoll
t.Cleanup(func() { plexClaim, plexClaimed, plexClaimPoll = oldClaim, oldClaimed, oldPoll })
plexClaimPoll = 0
for _, c := range []struct {
name string
status int
claimed bool
wantErr bool
wantWiring bool
}{
{"claimed", http.StatusOK, true, false, true},
{"plex refused the code", http.StatusUnauthorized, true, true, false},
{"plex never says claimed", http.StatusOK, false, true, false},
} {
t.Run(c.name, func(t *testing.T) {
s := gateHarness(t)
t.Cleanup(func() {
s.mediaMu.Lock()
if s.mediaSoonTmr != nil {
s.mediaSoonTmr.Stop()
}
s.mediaMu.Unlock()
})
plexClaim = func(string) (int, error) { return c.status, nil }
plexClaimed = func() bool { return c.claimed }
err := s.applyPlexClaim("claim-AbCdEf123456789xyz")
if (err != nil) != c.wantErr {
t.Fatalf("applyPlexClaim err=%v, want error=%v", err, c.wantErr)
}
s.mediaMu.Lock()
armed := s.mediaSoonTmr != nil
s.mediaMu.Unlock()
if armed != c.wantWiring {
t.Errorf("media wiring scheduled=%v, want %v", armed, c.wantWiring)
}
})
}
}
@@ -48,9 +48,11 @@ import (
var secretNameRe = regexp.MustCompile(`(?i)(pass(word|phrase)|secret|token|api_?key|private_?key|recovery_?code|credential|_key$)`)
// notSecretNames are names the pattern matches that carry no secret: the CSRF tokens a form must carry
// (session-bound, useless without the session) and a password-change ERROR MESSAGE.
// (session-bound, useless without the session) and a password-change ERROR MESSAGE. LogoutToken (R-983, v0.324.0) is
// the family sign-out form's CSRF token: an HMAC naming the family session, useless without that session's cookie.
var notSecretNames = map[string]bool{
"CSRFToken": true, "CSRFField": true, "FormToken": true, "LangCSRF": true, "PasswordError": true,
"LogoutToken": true,
}
const plantedPrefix = "PLANTEDSECRET"
+452
View File
@@ -0,0 +1,452 @@
package web
import (
"encoding/base64"
"errors"
"fmt"
"html"
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
)
// R-983 (security review 2026-10-12, six remaining items) and R-976 (family and gate sessions), v0.324.0. Each test
// drives the handler the browser or traefik reaches. Red-proofs are recorded in the REPORT of the release.
// ── R-983 item 1: after sign-in the browser stays on the box ─────────────────────────────────────────────────
func TestR983_LoginNextStaysOnTheBox(t *testing.T) {
s := rateLimitTestServer(t)
login := func(next string) string {
form := url.Values{"password": {"correct-pass"}, "next": {next}}
r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.RemoteAddr = "203.0.113.7:4000"
w := httptest.NewRecorder()
s.handleLogin(w, r)
if w.Code != http.StatusFound {
t.Fatalf("next=%q: sign-in answered %d, want 302", next, w.Code)
}
return w.Header().Get("Location")
}
for _, evil := range []string{"//evil.example/x", "/\\evil.example", "https://evil.example/", "/\\/evil.example"} {
if got := login(evil); got != "/" {
t.Errorf("next=%q sent the signed-in browser to %q — want /", evil, got)
}
}
if got := login("/stacks?tab=1"); got != "/stacks?tab=1" {
t.Errorf("a page on the box must still be the destination: got %q", got)
}
}
// ── R-983 item 2: no page of the dashboard may be framed ─────────────────────────────────────────────────────
func TestR983_DashboardRefusesFraming(t *testing.T) {
s, _ := familyHarness(t)
reached := false
h := s.CatchAllMiddleware(s.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reached = true
w.WriteHeader(http.StatusOK)
})))
sess := &http.Cookie{Name: sessionCookieName, Value: newTestSession(s)}
for _, p := range []string{"/launcher", "/login", familyLoginPath, "/settings/security"} {
r := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu"+p, nil)
r.AddCookie(sess)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if got := w.Header().Get("X-Frame-Options"); got != "DENY" {
t.Errorf("%s: X-Frame-Options %q, want DENY", p, got)
}
if got := w.Header().Get("Content-Security-Policy"); got != "frame-ancestors 'none'" {
t.Errorf("%s: Content-Security-Policy %q, want frame-ancestors 'none'", p, got)
}
}
if !reached {
t.Fatal("the signed-in page was never reached — the test proved nothing about a real page")
}
// traefik's forwardAuth answers stay as they were: their headers must not ride onto an app's responses.
r := httptest.NewRequest(http.MethodGet, "http://felhom-controller:8080"+familyAuthPath, nil)
r.Header.Set("X-Forwarded-Host", "fapp.example.hu")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Header().Get("X-Frame-Options") != "" || w.Header().Get("Content-Security-Policy") != "" {
t.Error("the forwardAuth answer must carry no framing header")
}
}
// ── R-983 item 3: parallel sign-in tries are counted ─────────────────────────────────────────────────────────
// The compare is a seam that waits until every try has arrived (or a short deadline): with check-then-count all 20
// tries reach it; with the try reserved first only loginMaxAttempts do.
func TestR983_ParallelLoginsHitTheLimit(t *testing.T) {
s := rateLimitTestServer(t)
const tries = 20
var calls int32
all := make(chan struct{})
var once sync.Once
s.passwordCompare = func(hash, pw []byte) error {
if atomic.AddInt32(&calls, 1) == tries {
once.Do(func() { close(all) })
}
select {
case <-all:
case <-time.After(400 * time.Millisecond):
}
return errors.New("mismatch")
}
var wg sync.WaitGroup
start := make(chan struct{})
for i := 0; i < tries; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start
doLogin(s, fmt.Sprintf("198.51.100.9:%d", 6000+i), "", "wrong")
}(i)
}
close(start)
wg.Wait()
if got := atomic.LoadInt32(&calls); got > loginMaxAttempts {
t.Fatalf("%d parallel wrong tries reached the password compare — the limit is %d per visitor", got, loginMaxAttempts)
}
// and the household's right password is still refused only for this visitor, not for everyone
s.passwordCompare = nil
if body := doLogin(s, "203.0.113.50:7000", "", "correct-pass"); body.Code != http.StatusFound {
t.Fatalf("another visitor with the right password must get in: %d", body.Code)
}
}
func TestR983_ParallelFamilySignInsHitTheLimit(t *testing.T) {
s, st := familyHarness(t)
st.SetCost(10) // a real compare takes tens of milliseconds, so the tries overlap
if _, err := st.Add("anna"); err != nil {
t.Fatal(err)
}
const tries = 20
var wrong, locked int32
var wg sync.WaitGroup
start := make(chan struct{})
for i := 0; i < tries; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
switch famLogin(s, "198.51.100.66", "anna", "wrong", "").Code {
case http.StatusUnauthorized:
atomic.AddInt32(&wrong, 1)
case http.StatusTooManyRequests:
atomic.AddInt32(&locked, 1)
}
}()
}
close(start)
wg.Wait()
if wrong > familyVisitorMax {
t.Fatalf("%d parallel tries had their password checked — the limit is %d per visitor (locked: %d)", wrong, familyVisitorMax, locked)
}
if wrong+locked != tries {
t.Fatalf("every try must be answered as wrong or locked: %d + %d of %d", wrong, locked, tries)
}
}
// ── R-983 item 4: the settings page's minimum is the claim's ─────────────────────────────────────────────────
func TestR983_SettingsPasswordMinimumIsTheClaims(t *testing.T) {
if claimMinPassword != 12 {
t.Fatalf("the claim's minimum moved to %d — this test's literals assume 12", claimMinPassword)
}
s, _ := familyHarness(t)
tok := newTestSession(s)
change := func(pw string) *httptest.ResponseRecorder {
form := url.Values{"current_password": {"dashboard-pass"}, "new_password": {pw}, "confirm_password": {pw}}
r := httptest.NewRequest(http.MethodPost, "https://felhom.example.hu/settings/password", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tok})
w := httptest.NewRecorder()
s.settingsPasswordHandler(w, r)
return w
}
w := change("eleven-char")
if w.Code == http.StatusFound {
t.Fatal("an 11-character password was accepted — the claim requires 12")
}
if !strings.Contains(w.Body.String(), "legalább 12 karakter") {
t.Errorf("the refusal must name the claim's minimum (Hungarian)")
}
if bcrypt.CompareHashAndPassword([]byte(s.effectivePasswordHash()), []byte("dashboard-pass")) != nil {
t.Fatal("the refused change altered the password")
}
// the page's own form says the same minimum (both languages render it from one value)
page := w.Body.String()
if !strings.Contains(page, `minlength="12"`) || strings.Contains(page, `minlength="8"`) {
t.Errorf("the password form must ask for at least 12 characters")
}
if got := s.msgLang("en", "settings.pw_too_short", claimMinPassword); !strings.Contains(got, "at least 12 characters") {
t.Errorf("English refusal %q must name 12", got)
}
if w := change("twelve-chars"); w.Code != http.StatusFound {
t.Fatalf("a 12-character password must be accepted: %d", w.Code)
}
}
// ── R-983 item 5: reset-code requests are limited on the box ─────────────────────────────────────────────────
func TestR983_ResetCodeRequestsAreLimited(t *testing.T) {
s, _, _ := claimTestServer(t)
now := time.Date(2026, 10, 16, 2, 0, 0, 0, time.UTC)
s.claimClock = func() time.Time { return now }
var calls int
s.resetRequestFn = func() { calls++ }
ask := func(visitor string) {
form := url.Values{"_csrf": {s.claimCSRFToken()}}
r := httptest.NewRequest(http.MethodPost, "/claim/request-new-code", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(&http.Cookie{Name: claimCSRFCookie, Value: s.claimCSRFToken()})
r.RemoteAddr = visitor + ":5000"
w := httptest.NewRecorder()
s.handleClaimRequestNewCode(w, r)
if w.Code != http.StatusOK {
t.Fatalf("the answer must stay the neutral page: %d", w.Code)
}
}
for i := 0; i < 3; i++ {
ask("198.51.100.1")
}
if calls != 1 {
t.Fatalf("3 requests in one minute from one visitor reached the hub %d times — want 1", calls)
}
now = now.Add(resetReqVisitorEvery + time.Second)
ask("198.51.100.1")
if calls != 2 {
t.Fatalf("after a minute the same visitor must reach the hub again: %d calls", calls)
}
ask("198.51.100.2")
ask("198.51.100.3") // the box's budget for the quarter hour is used here
if calls != resetReqGlobalMax {
t.Fatalf("calls %d, want %d", calls, resetReqGlobalMax)
}
ask("198.51.100.4")
if calls != resetReqGlobalMax {
t.Fatalf("a fourth visitor in the same quarter hour reached the hub: %d calls", calls)
}
now = now.Add(resetReqGlobalWindow)
ask("198.51.100.4")
if calls != resetReqGlobalMax+1 {
t.Fatalf("after the window a request must reach the hub again (the household is not locked out for long): %d", calls)
}
}
// ── R-983 item 6: logout cannot be triggered from another page ───────────────────────────────────────────────
func TestR983_LogoutNeedsCSRF(t *testing.T) {
s, _ := familyHarness(t)
tok := newTestSession(s)
h := s.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(299) }))
out := func(method, csrf string) *httptest.ResponseRecorder {
var body *strings.Reader
if csrf != "" {
body = strings.NewReader(url.Values{csrfFormField: {csrf}}.Encode())
} else {
body = strings.NewReader("")
}
r := httptest.NewRequest(method, "https://felhom.example.hu/logout", body)
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tok})
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
if w := out(http.MethodPost, ""); w.Code != http.StatusForbidden || !s.isValidSession(tok) {
t.Fatalf("a POST without the token: %d, session valid %v — want 403 and the session kept", w.Code, s.isValidSession(tok))
}
if w := out(http.MethodPost, "not-the-token"); w.Code != http.StatusForbidden || !s.isValidSession(tok) {
t.Fatalf("a POST with a wrong token: %d — want 403 and the session kept", w.Code)
}
if w := out(http.MethodGet, ""); w.Code != http.StatusFound || w.Header().Get("Location") != "/" || !s.isValidSession(tok) {
t.Fatalf("a GET must change nothing: %d %q", w.Code, w.Header().Get("Location"))
}
// The dashboard's own control: a POST form carrying the session's token.
r := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu/settings/security", nil)
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tok})
pw := httptest.NewRecorder()
s.settingsSecurityPageHandler(pw, r)
m := regexp.MustCompile(`<form method="post" action="/logout" class="logout-form"><input type="hidden" name="_csrf" value="([0-9a-f]+)">`).FindStringSubmatch(pw.Body.String())
if m == nil {
t.Fatal("the dashboard's sign-out control must be a POST form carrying the session's token")
}
if w := out(http.MethodPost, m[1]); w.Code != http.StatusFound || w.Header().Get("Location") != "/login" || s.isValidSession(tok) {
t.Fatalf("the dashboard's own sign-out: %d %q, session valid %v — want 302 /login and the session ended", w.Code, w.Header().Get("Location"), s.isValidSession(tok))
}
}
func TestR983_FamilyLogoutNeedsItsToken(t *testing.T) {
s, st := familyHarness(t)
pw, _ := st.Add("anna")
sess := famCookie(famLogin(s, "203.0.113.10", "anna", pw, ""), familySessionCookie)
app := famPass(t, s, sess)
ok := func() bool {
return famAsk(s, "fapp.example.hu", "GET", "/", "application/json", app).Code == http.StatusOK
}
out := func(method, ft string) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, "https://felhom.example.hu"+familyLogoutPath, strings.NewReader(url.Values{"_ft": {ft}}.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(sess)
w := httptest.NewRecorder()
s.ServeFamilyLogout(w, r)
return w
}
out(http.MethodGet, "")
out(http.MethodPost, "")
out(http.MethodPost, s.familyFormToken()) // the sign-in form's token: anyone can fetch one
if !ok() {
t.Fatal("a GET, a tokenless POST or a POST with the sign-in form's token ended the family session")
}
// the page's own sign-out form carries the session's token, and that ends it
r := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu"+familyLoginPath, nil)
r.AddCookie(sess)
pg := httptest.NewRecorder()
s.ServeFamilyLogin(pg, r)
m := regexp.MustCompile(`action="/__family/logout">\s*<input type="hidden" name="_ft" value="([^"]+)">`).FindStringSubmatch(pg.Body.String())
if m == nil {
t.Fatal("the family page's sign-out form must carry its token")
}
if w := out(http.MethodPost, html.UnescapeString(m[1])); w.Code != http.StatusOK {
t.Fatalf("the page's own sign-out: %d", w.Code)
}
if ok() {
t.Fatal("the family page's own sign-out must end the session")
}
}
// ── R-976 (b): a family app cookie cannot be lifted into another app ─────────────────────────────────────────
func TestR976_AnAppCookieCannotBeLiftedIntoAnotherApp(t *testing.T) {
s, st := familyHarness(t)
pw, _ := st.Add("anna")
sess := famCookie(famLogin(s, "203.0.113.10", "anna", pw, ""), familySessionCookie)
app := famPass(t, s, sess)
if strings.Contains(app.Value, sess.Value) {
t.Fatal("the app cookie carries the family session itself — an app's backend could lift it")
}
// what an app backend sees, set as the family session on the dashboard host, must mint nothing
lifted := strings.SplitN(app.Value, ".", 2)[0]
rd := "https://fapp.example.hu/"
r := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu"+familyStartPath+"?"+url.Values{"rd": {rd}}.Encode(), nil)
r.AddCookie(&http.Cookie{Name: familySessionCookie, Value: lifted})
w := httptest.NewRecorder()
s.ServeFamilyStart(w, r)
if loc := w.Header().Get("Location"); strings.Contains(loc, familyCallbackURI) {
t.Fatalf("a value lifted from an app cookie minted a pass: %q", loc)
}
// the sign-in token in flight carries no session either
tok := s.mintFamilyToken("fapp.example.hu", sess.Value, rd)
if dec, _ := base64.RawURLEncoding.DecodeString(tok); strings.Contains(string(dec), sess.Value) {
t.Fatal("the sign-in token carries the family session id")
}
// a cookie of the old shape (the session id itself, correctly signed) is refused
exp := strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10)
old := &http.Cookie{Name: familyAppCookie, Value: sess.Value + "." + exp + "." + s.gateMAC("famcookie", "fapp.example.hu", sess.Value, exp)}
if w := famAsk(s, "fapp.example.hu", "GET", "/", "application/json", old); w.Code == http.StatusOK {
t.Fatal("an app cookie carrying the session id must not pass any more")
}
// the genuine cookie still passes its own app
if w := famAsk(s, "fapp.example.hu", "GET", "/", "application/json", app); w.Code != http.StatusOK {
t.Fatalf("the member's own app cookie must pass: %d", w.Code)
}
}
// ── R-976 (a): a password change or reset ends every family and gate session ─────────────────────────────────
// r976World signs anna in (family session + app cookie) and walks the household through the setup gate (gate pass).
func r976World(t *testing.T, s *Server) (famSess, famApp, gatePass *http.Cookie) {
t.Helper()
pw, err := s.familyStore().Add("anna")
if err != nil {
t.Fatal(err)
}
famSess = famCookie(famLogin(s, "203.0.113.10", "anna", pw, ""), familySessionCookie)
famApp = famPass(t, s, famSess)
dash := &http.Cookie{Name: sessionCookieName, Value: newTestSession(s)}
w := gateStart(s, "https://gapp.example.hu/", dash)
cb, _ := url.Parse(w.Header().Get("Location"))
w = gateAsk(s, "gapp.example.hu", "GET", cb.RequestURI(), "text/html")
for _, c := range w.Result().Cookies() {
if c.Name == gateCookieName {
gatePass = c
}
}
if gatePass == nil {
t.Fatal("setup: no gate pass")
}
r976AllOpen(t, s, famSess, famApp, gatePass, true)
return famSess, famApp, gatePass
}
func r976AllOpen(t *testing.T, s *Server, famSess, famApp, gatePass *http.Cookie, want bool) {
t.Helper()
appOK := famAsk(s, "fapp.example.hu", "GET", "/", "application/json", famApp).Code == http.StatusOK
gateOK := gateAsk(s, "gapp.example.hu", "GET", "/", "application/json", gatePass).Code == http.StatusOK
r := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu"+familyStartPath+"?"+url.Values{"rd": {"https://fapp.example.hu/"}}.Encode(), nil)
r.AddCookie(famSess)
w := httptest.NewRecorder()
s.ServeFamilyStart(w, r)
sessOK := strings.Contains(w.Header().Get("Location"), familyCallbackURI)
if appOK != want || gateOK != want || sessOK != want {
t.Fatalf("family app cookie passes %v, setup gate pass passes %v, family session mints %v — want all %v", appOK, gateOK, sessOK, want)
}
}
func TestR976_PasswordChangeEndsFamilyAndGateSessions(t *testing.T) {
s, st := familyHarness(t)
famSess, famApp, gatePass := r976World(t, s)
form := url.Values{"current_password": {"dashboard-pass"}, "new_password": {"a-new-password-12"}, "confirm_password": {"a-new-password-12"}}
r := httptest.NewRequest(http.MethodPost, "https://felhom.example.hu/settings/password", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.settingsPasswordHandler(w, r)
if w.Code != http.StatusFound {
t.Fatalf("the password change: %d", w.Code)
}
r976AllOpen(t, s, famSess, famApp, gatePass, false)
// the members stay; anna signs in again with her own password
if names := st.Names(); len(names) != 1 || names[0] != "anna" {
t.Fatalf("the family list must be kept: %v", names)
}
}
func TestR976_ClaimResetEndsFamilyAndGateSessions(t *testing.T) {
s, _ := familyHarness(t)
s.cfg.Web.SessionSecret = "test-session-secret-abcdef"
code := "alma-korte-szilva"
hash, _ := bcrypt.GenerateFromPassword([]byte(code), bcrypt.MinCost)
if err := s.settings.SetClaimCode(string(hash), 1, time.Now().UTC().Format(time.RFC3339)); err != nil {
t.Fatal(err)
}
famSess, famApp, gatePass := r976World(t, s)
form := url.Values{"_csrf": {s.claimCSRFToken()}, "code": {code}, "new_password": {"a-new-password-12"}, "confirm_password": {"a-new-password-12"}}
r := httptest.NewRequest(http.MethodPost, "/claim", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(&http.Cookie{Name: claimCSRFCookie, Value: s.claimCSRFToken()})
w := httptest.NewRecorder()
s.handleClaimSubmit(w, r)
if w.Code != http.StatusFound || w.Header().Get("Location") != "/" {
t.Fatalf("the reset: %d %q", w.Code, w.Header().Get("Location"))
}
r976AllOpen(t, s, famSess, famApp, gatePass, false)
// a restart must not bring the old passes back: the rotated key is what is on disk
s2 := &Server{cfg: s.cfg, settings: s.settings, stackMgr: s.stackMgr, logger: s.logger, version: "test",
sessions: map[string]*session{}, familyStoreOverride: s.familyStoreOverride}
s2.loadTemplates()
if w := gateAsk(s2, "gapp.example.hu", "GET", "/", "application/json", gatePass); w.Code == http.StatusOK {
t.Fatal("after a restart the old gate pass opened the app again")
}
}
+19 -2
View File
@@ -74,8 +74,11 @@ type Server struct {
sessionsMu sync.RWMutex
loginAttempts map[string]*loginAttempt
loginAttemptMu sync.Mutex
done chan struct{}
closeOnce sync.Once
// passwordCompare replaces bcrypt's compare at the dashboard sign-in in tests (R-983: a slow compare makes the
// parallel-try race measurable). nil → bcrypt.
passwordCompare func(hash, pw []byte) error
done chan struct{}
closeOnce sync.Once
// Guest launcher share (v0.165.0): its OWN per-IP brute-force limiter for the optional share
// password gate — deliberately separate from loginAttempts (the admin login), so a guest and the
@@ -89,6 +92,11 @@ type Server struct {
claimAttempts map[string]*claimAttempt
claimGlobal claimAttempt
claimClock func() time.Time
// R-983 (v0.324.0): the reset-code request's own limit, under claimMu and claimClock — when each visitor last
// reached the hub, and when the box last did (a sliding window). resetRequestFn replaces the hub call in tests.
resetReqVisitor map[string]time.Time
resetReqGlobal []time.Time
resetRequestFn func()
// Guard for FileBrowser sync — prevents concurrent file writes (H5 fix)
fileBrowserMu sync.Mutex
@@ -975,6 +983,15 @@ func (s *Server) CatchAllMiddleware(next http.Handler) http.Handler {
s.ServeFamilyGateAuth(w, r)
return
}
// R-983 (v0.324.0): no page of ours may be shown inside another page's frame. Every app on *.<domain> is
// SAME-SITE with the dashboard, so the Lax session cookie goes along into a frame an app page draws — a page
// on any app host could lay the signed-in dashboard under its own buttons. Nothing frames the dashboard
// legitimately (no iframe in the controller, the catalog, the hub or the website, checked 2026-10-16), so
// DENY rather than SAMEORIGIN. The two forwardAuth answers above are left exactly as they were: traefik
// reads them, and their headers must not leak onto an app's own responses. Pinned by
// TestR983_DashboardRefusesFraming.
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Content-Security-Policy", "frame-ancestors 'none'")
if strings.EqualFold(host, controllerHost) {
switch r.URL.Path { // v0.287.0: the family sign-in pages, outside the dashboard's own auth on purpose
case familyStartPath:
@@ -125,6 +125,7 @@ func TestR35_LogoutEndsSessionAcrossRestart(t *testing.T) {
t.Fatal("setup: the session must survive one restart first")
}
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
req.Header.Set(csrfHeaderName, s2.csrfTokenForSession(tok)) // R-983: the sign-out carries the session's token
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tok})
s2.handleLogout(httptest.NewRecorder(), req)
if s2.isValidSession(tok) {
@@ -235,6 +236,7 @@ func TestR35_FailedLogoutSaveRemovesFile(t *testing.T) {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
req.Header.Set(csrfHeaderName, s2.csrfTokenForSession(tok)) // R-983: the sign-out carries the session's token
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tok})
s2.handleLogout(httptest.NewRecorder(), req)
s2.Close()
+37 -4
View File
@@ -53,10 +53,11 @@ const (
)
type gateState struct {
once sync.Once
key []byte
mu sync.Mutex
used map[string]time.Time // token nonces already swapped, until they expire
once sync.Once
keyMu sync.RWMutex // guards key: rotateGateKey replaces it while requests read it
key []byte
mu sync.Mutex
used map[string]time.Time // token nonces already swapped, until they expire
}
// gateKey loads the gate's HMAC key from the data dir, or makes one. No data dir → a key for this run only.
@@ -82,9 +83,41 @@ func (s *Server) gateKey() []byte {
}
}
})
s.gate.keyMu.RLock()
defer s.gate.keyMu.RUnlock()
return s.gate.key
}
// rotateGateKey replaces the gate's key (R-976, v0.324.0). Every cookie and token the key signed stops opening
// anything at once: each setup-gate pass, each family app cookie, each sign-in token in flight. The household's
// browser passes again through its dashboard session. The new key is saved tmp+rename; when that fails the old file
// is REMOVED, so a restart makes a fresh key rather than bringing the old one — and every cookie it signed — back.
func (s *Server) rotateGateKey() {
s.gateKey()
k := make([]byte, 32)
_, _ = rand.Read(k)
s.gate.keyMu.Lock()
s.gate.key = k
s.gate.keyMu.Unlock()
if s.cfg == nil || s.cfg.Paths.DataDir == "" {
return
}
p := filepath.Join(s.cfg.Paths.DataDir, "setup-gate.key")
tmp := p + ".tmp"
err := os.WriteFile(tmp, []byte(hex.EncodeToString(k)), 0o600)
if err == nil {
err = os.Rename(tmp, p)
}
if err != nil {
_ = os.Remove(tmp)
if rerr := os.Remove(p); rerr != nil && !os.IsNotExist(rerr) {
s.logger.Printf("[ERROR] [web] gate key: the new key could not be saved (%v) and the old one could not be removed (%v) — a restart would accept passes signed before the password change", err, rerr)
return
}
s.logger.Printf("[WARN] [web] gate key: the new key could not be saved (%v) — the old one was removed, so a restart makes a fresh key", err)
}
}
func (s *Server) gateMAC(parts ...string) string {
m := hmac.New(sha256.New, s.gateKey())
m.Write([]byte(strings.Join(parts, "\x00")))
@@ -20,6 +20,7 @@
{{- if .SignedIn}}
<p id="family-gate-signed-in">{{T "family_gate.signed_in_note"}}</p>
<form method="post" action="/__family/logout">
<input type="hidden" name="_ft" value="{{.LogoutToken}}">
<button type="submit" class="btn btn-outline btn-full">{{T "family_gate.sign_out"}}</button>
</form>
{{- else}}
@@ -125,7 +125,7 @@
</ul>
<div class="sidebar-footer">
<span class="version">{{.Version}}</span>{{if .LangSwitch}}{{template "lang_globe" .}}{{end}}
{{if .AuthEnabled}}<a href="/logout" class="logout-link">{{T "layout.kijelentkezes"}}</a>{{end}}
{{if .AuthEnabled}}<form method="post" action="/logout" class="logout-form">{{.CSRFField}}<button type="submit" class="logout-link">{{T "layout.kijelentkezes"}}</button></form>{{end}}
</div>
</div>
</nav>
@@ -19,12 +19,12 @@
</div>
<div class="form-group">
<label for="new_password">{{T "settings_security.uj_jelszo"}}</label>
<input type="password" id="new_password" name="new_password" required minlength="8"
placeholder="{{T "settings_security.legalabb_8_karakter"}}" class="form-control">
<input type="password" id="new_password" name="new_password" required minlength="{{.MinPassword}}"
placeholder="{{T "settings_security.legalabb_karakter"}}" class="form-control">
</div>
<div class="form-group">
<label for="confirm_password">{{T "settings_security.uj_jelszo_megerositese"}}</label>
<input type="password" id="confirm_password" name="confirm_password" required minlength="8"
<input type="password" id="confirm_password" name="confirm_password" required minlength="{{.MinPassword}}"
placeholder="{{T "settings_security.jelszo_megegyszer"}}" class="form-control">
</div>
<button type="submit" class="btn btn-primary">{{T "settings_security.jelszo_modositasa"}}</button>
+2 -1
View File
@@ -150,7 +150,8 @@ body {
.lang-globe-item:hover { color: var(--blue-bright); }
.lang-globe-item.current { color: var(--text-1); font-weight: 600; }
.lang-globe-item.current::after { content: " \2713"; }
.logout-link { color: var(--text-3); text-decoration: none; transition: color 0.2s ease; }
.logout-form { display: inline; margin: 0; }
.logout-link { color: var(--text-3); text-decoration: none; transition: color 0.2s ease; background: none; border: 0; padding: 0; font: inherit; cursor: pointer; }
.logout-link:hover { color: var(--blue-bright); }
/* Mobile chrome (v0.166.0) — hidden by default so desktop (>768px) stays pixel-identical; the
≤768px block below turns the top bar on and converts the sidebar into an off-canvas drawer. */
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Alkalmazás importálása — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Alkalmazás importálása — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
+1 -1
View File
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>

Some files were not shown because too many files have changed in this diff Show More