v0.324.0: the database password comes back with the data (R-1106, R-127b, R-926); restic 0.19.1 (R-1107); R-1108, R-1065, R-1017; dashboard hardening (R-983, R-976)
gates / gates (push) Successful in 44s
gates / gates (push) Successful in 44s
This commit is contained in:
@@ -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", "")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user