396 lines
15 KiB
Go
396 lines
15 KiB
Go
package stacks
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/crypto"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
)
|
|
|
|
// R-513 — FileBrowser accepted admin/admin on every box. The fake below behaves like the measured
|
|
// Quantum 1.3.3 API (evidence-p1fixes-2026-09-15/B1): login by X-Password, PUT /api/users with the
|
|
// current password in X-Password.
|
|
|
|
type fakeFB struct {
|
|
mu sync.Mutex
|
|
password string
|
|
puts int
|
|
}
|
|
|
|
func (f *fakeFB) handler() http.Handler {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/auth/login", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if r.Header.Get("X-Password") != f.password {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
io.WriteString(w, "tok-123")
|
|
})
|
|
mux.HandleFunc("/api/users", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if r.Header.Get("X-Auth") != "tok-123" {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
io.WriteString(w, `{"id":1,"username":"admin"}`)
|
|
case http.MethodPut:
|
|
if r.Header.Get("X-Password") != f.password {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
var body struct {
|
|
Which []string `json:"which"`
|
|
Data struct {
|
|
Password string `json:"password"`
|
|
} `json:"data"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
f.password = body.Data.Password
|
|
f.puts++
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
})
|
|
return mux
|
|
}
|
|
|
|
func fbManager(t *testing.T, base string) (*Manager, *settings.Settings, []byte) {
|
|
t.Helper()
|
|
st, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
key := make([]byte, 32)
|
|
for i := range key {
|
|
key[i] = byte(i + 1)
|
|
}
|
|
return &Manager{logger: log.New(io.Discard, "", 0), settings: st, encKey: key, fbBaseURL: base}, st, key
|
|
}
|
|
|
|
// The consequence: after one tick admin/admin no longer logs in, the stored (decrypted) password
|
|
// does, and the state is "generated". A second tick changes nothing.
|
|
//
|
|
// RED-PROOF (run 2026-09-15, recorded in REPORT.md): with EnsureFileBrowserAdminPassword returning
|
|
// nil before the PUT, admin/admin stayed valid and this failed at "admin/admin still logs in".
|
|
func TestFileBrowserAdmin_DefaultLoginReplaced(t *testing.T) {
|
|
fb := &fakeFB{password: "admin"}
|
|
srv := httptest.NewServer(fb.handler())
|
|
defer srv.Close()
|
|
m, st, key := fbManager(t, srv.URL)
|
|
|
|
if err := m.EnsureFileBrowserAdminPassword(); err != nil {
|
|
t.Fatalf("ensure: %v", err)
|
|
}
|
|
if _, c, _ := fbLogin(srv.Client().Do, srv.URL, "admin"); c == http.StatusOK {
|
|
t.Fatalf("admin/admin still logs in — R-513 not fixed")
|
|
}
|
|
state, enc, at := st.GetFileBrowserAdmin()
|
|
if state != settings.FileBrowserAdminGenerated || enc == "" || at == "" {
|
|
t.Fatalf("decision not recorded: state=%q enc=%v at=%q", state, enc != "", at)
|
|
}
|
|
if enc == fb.password {
|
|
t.Fatal("the password was stored in plaintext")
|
|
}
|
|
pw, err := crypto.Decrypt(key, enc)
|
|
if err != nil || len(pw) != 16 {
|
|
t.Fatalf("stored password does not decrypt to a 16-char value (len=%d err=%v)", len(pw), err)
|
|
}
|
|
if _, c, _ := fbLogin(srv.Client().Do, srv.URL, pw); c != http.StatusOK {
|
|
t.Fatalf("the stored password does not log in (HTTP %d)", c)
|
|
}
|
|
_ = m.EnsureFileBrowserAdminPassword()
|
|
if fb.puts != 1 {
|
|
t.Fatalf("a recorded decision was acted on again (puts=%d)", fb.puts)
|
|
}
|
|
}
|
|
|
|
// A password set by hand (admin/admin refused) is NEVER overwritten.
|
|
func TestFileBrowserAdmin_HandSetPasswordLeftAlone(t *testing.T) {
|
|
fb := &fakeFB{password: "operator-set-by-hand"}
|
|
srv := httptest.NewServer(fb.handler())
|
|
defer srv.Close()
|
|
m, st, _ := fbManager(t, srv.URL)
|
|
if err := m.EnsureFileBrowserAdminPassword(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if fb.puts != 0 || fb.password != "operator-set-by-hand" {
|
|
t.Fatalf("hand-set password was changed (puts=%d)", fb.puts)
|
|
}
|
|
if state, enc, _ := st.GetFileBrowserAdmin(); state != settings.FileBrowserAdminOperator || enc != "" {
|
|
t.Fatalf("want state operator with no stored value, got %q enc=%v", state, enc != "")
|
|
}
|
|
}
|
|
|
|
// FileBrowser not reachable → nothing recorded, so the next tick tries again.
|
|
func TestFileBrowserAdmin_UnreachableRecordsNothing(t *testing.T) {
|
|
srv := httptest.NewServer(http.NotFoundHandler())
|
|
url := srv.URL
|
|
srv.Close()
|
|
m, st, _ := fbManager(t, url)
|
|
if err := m.EnsureFileBrowserAdminPassword(); err == nil {
|
|
t.Fatal("want an error (for the log) when FileBrowser is unreachable")
|
|
}
|
|
if state, _, _ := st.GetFileBrowserAdmin(); state != "" {
|
|
t.Fatalf("an unreachable FileBrowser recorded a decision: %q", state)
|
|
}
|
|
}
|
|
|
|
// R-977: the decision is no longer once per box. A FileBrowser whose data was re-created (a new run whose database
|
|
// starts on admin/admin again) is reset at its first tick; the same run is never probed twice; a new run on another
|
|
// password (the household's own) leaves the recorded decision alone.
|
|
//
|
|
// RED-PROOF (2026-10-17): with the old early return (`if state != "" { return nil }`) the second run keeps
|
|
// admin/admin and this fails at „the default login came back on a new run and was left".
|
|
func TestFileBrowserAdmin_R977_DefaultBackOnANewRunIsReplaced(t *testing.T) {
|
|
fb := &fakeFB{password: "admin"}
|
|
srv := httptest.NewServer(fb.handler())
|
|
defer srv.Close()
|
|
m, st, key := fbManager(t, srv.URL)
|
|
run := "2026-10-17T06:54:56Z"
|
|
m.fbStarted = func() string { return run }
|
|
|
|
if err := m.EnsureFileBrowserAdminPassword(); err != nil { // first run: a new box
|
|
t.Fatal(err)
|
|
}
|
|
_, enc1, _ := st.GetFileBrowserAdmin()
|
|
|
|
// The data volume is re-created: FileBrowser starts again on its default.
|
|
fb.mu.Lock()
|
|
fb.password = "admin"
|
|
fb.mu.Unlock()
|
|
if err := m.EnsureFileBrowserAdminPassword(); err != nil { // the SAME run: not probed again
|
|
t.Fatal(err)
|
|
}
|
|
if fb.puts != 1 {
|
|
t.Fatalf("the same FileBrowser run was probed and changed again (puts=%d)", fb.puts)
|
|
}
|
|
run = "2026-10-17T09:12:00Z" // the box brought FileBrowser back: a new run
|
|
if err := m.EnsureFileBrowserAdminPassword(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, c, _ := fbLogin(srv.Client().Do, srv.URL, "admin"); c == http.StatusOK {
|
|
t.Fatal("the default login came back on a new run and was left")
|
|
}
|
|
state, enc2, _ := st.GetFileBrowserAdmin()
|
|
if state != settings.FileBrowserAdminGenerated || enc2 == "" || enc2 == enc1 {
|
|
t.Fatalf("a new generated password must be recorded: state=%q changed=%v", state, enc2 != enc1)
|
|
}
|
|
pw, err := crypto.Decrypt(key, enc2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, c, _ := fbLogin(srv.Client().Do, srv.URL, pw); c != http.StatusOK {
|
|
t.Fatalf("the recorded password does not log in (HTTP %d)", c)
|
|
}
|
|
|
|
// The household changes it in FileBrowser; a later run on THEIR password changes nothing.
|
|
fb.mu.Lock()
|
|
fb.password = "the-households-own"
|
|
fb.mu.Unlock()
|
|
run = "2026-10-18T02:30:00Z"
|
|
puts := fb.puts
|
|
if err := m.EnsureFileBrowserAdminPassword(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if fb.puts != puts || fb.password != "the-households-own" {
|
|
t.Fatalf("a run on the household's own password was changed (puts %d -> %d)", puts, fb.puts)
|
|
}
|
|
if s, e, _ := st.GetFileBrowserAdmin(); s != settings.FileBrowserAdminGenerated || e != enc2 {
|
|
t.Fatalf("the recorded decision moved on a run that is not on the default: state=%q", s)
|
|
}
|
|
}
|
|
|
|
// R-977, measured live: the tick that brings FileBrowser back probes it while it is still starting, and without a wait
|
|
// the default login stayed open until the next round (5 minutes on 9202). A NEW run is given a short wait.
|
|
// RED-PROOF (2026-10-17): with one try per tick this fails at „the default stayed open".
|
|
func TestFileBrowserAdmin_R977_NewRunIsWaitedForInTheSameTick(t *testing.T) {
|
|
fb := &fakeFB{password: "admin"}
|
|
starting := 3 // the first three answers: FileBrowser not ready yet
|
|
inner := fb.handler()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
fb.mu.Lock()
|
|
notReady := starting > 0
|
|
if notReady {
|
|
starting--
|
|
}
|
|
fb.mu.Unlock()
|
|
if notReady {
|
|
w.WriteHeader(http.StatusBadGateway)
|
|
return
|
|
}
|
|
inner.ServeHTTP(w, r)
|
|
}))
|
|
defer srv.Close()
|
|
m, st, _ := fbManager(t, srv.URL)
|
|
m.fbStarted = func() string { return "2026-10-17T09:24:35Z" }
|
|
if err := m.EnsureFileBrowserAdminPassword(); err != nil {
|
|
t.Fatalf("one tick: %v", err)
|
|
}
|
|
if _, c, _ := fbLogin(srv.Client().Do, srv.URL, "admin"); c == http.StatusOK {
|
|
t.Fatal("the default stayed open after the tick that started FileBrowser")
|
|
}
|
|
if s, _, _ := st.GetFileBrowserAdmin(); s != settings.FileBrowserAdminGenerated {
|
|
t.Fatalf("state %q, want generated", s)
|
|
}
|
|
}
|
|
|
|
// R-1126 (D11 = A): files.<domain> is published (FileBrowser joined to traefik-public) only AFTER this run's default
|
|
// login was checked; a check that cannot finish leaves it unpublished; a later run already published is not touched.
|
|
// RED-PROOF (2026-10-17): ensureFileBrowserPublic without the „this run was checked" test → the unreachable case fails.
|
|
func TestFileBrowserGate_PublishedOnlyAfterTheCheck(t *testing.T) {
|
|
var events []string
|
|
fb := &fakeFB{password: "admin"}
|
|
inner := fb.handler()
|
|
down := false
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if down {
|
|
w.WriteHeader(http.StatusBadGateway)
|
|
return
|
|
}
|
|
if r.Method == http.MethodPut {
|
|
events = append(events, "password replaced")
|
|
}
|
|
inner.ServeHTTP(w, r)
|
|
}))
|
|
defer srv.Close()
|
|
m, _, _ := fbManager(t, srv.URL)
|
|
run := "run-1"
|
|
m.fbStarted = func() string { return run }
|
|
nets := map[string]bool{}
|
|
m.fbNetOnFn = func(c, n string) bool { return nets[c+"@"+n] }
|
|
m.fbNetConnectFn = func(c, n string) error {
|
|
nets[c+"@"+n] = true
|
|
events = append(events, "connect "+c+" "+n)
|
|
return nil
|
|
}
|
|
|
|
// A new run whose FileBrowser does not answer: nothing is published.
|
|
down = true
|
|
m.fileBrowserGateLocked()
|
|
if nets["filebrowser@traefik-public"] || nets["filebrowser@felhom-files"] {
|
|
t.Fatal("published while this run's default login was never checked")
|
|
}
|
|
// It answers, on its default: replaced first, published after.
|
|
down = false
|
|
m.fileBrowserGateLocked()
|
|
// R-1281: published on the files network (traefik joins it first), never on traefik-public.
|
|
want := []string{"connect felhom-controller felhom-filebrowser", "password replaced", "connect traefik felhom-files", "connect filebrowser felhom-files"}
|
|
if strings.Join(events, " | ") != strings.Join(want, " | ") {
|
|
t.Fatalf("order:\n got %v\n want %v", events, want)
|
|
}
|
|
// A new run (a restart) on the box's own password: checked, nothing changes, nothing connected twice.
|
|
run = "run-2"
|
|
m.fileBrowserGateLocked()
|
|
if len(events) != 4 {
|
|
t.Fatalf("a checked restart did more: %v", events)
|
|
}
|
|
}
|
|
|
|
// R-1126 TOCTOU: the gate connects the container whose run it checked, by ID — so a FileBrowser re-created between the
|
|
// check and the connect (web.SyncFileBrowserMounts runs outside infraMu) is never the one published.
|
|
// RED-PROOF (2026-10-17): connecting by the name „filebrowser" fails this test.
|
|
func TestFileBrowserGate_ConnectsTheCheckedContainerById(t *testing.T) {
|
|
fb := &fakeFB{password: "admin"}
|
|
srv := httptest.NewServer(fb.handler())
|
|
defer srv.Close()
|
|
m, _, _ := fbManager(t, srv.URL)
|
|
m.fbStarted = func() string { return "3f2a9c 2026-10-17T11:20:00Z" }
|
|
var connected []string
|
|
m.fbNetOnFn = func(c, n string) bool { return false }
|
|
m.fbNetConnectFn = func(c, n string) error {
|
|
connected = append(connected, c+"@"+n)
|
|
return nil
|
|
}
|
|
m.fileBrowserGateLocked()
|
|
if len(connected) != 3 || connected[2] != "3f2a9c@felhom-files" {
|
|
t.Fatalf("the checked container must be published by its id, got %v", connected)
|
|
}
|
|
}
|
|
|
|
// R-1281 (D44 #23): every app container is on traefik-public, so FileBrowser on it could be reached directly — measured
|
|
// 2026-10-18: from an app container, a forged sign-in header got the drive's listing. The checked FileBrowser is served
|
|
// on the files network only, and one an earlier controller put on traefik-public leaves it.
|
|
// RED-PROOF: skip the traefik-public disconnect → it stays on the app network; connect to traefik-public as before →
|
|
// it is on it.
|
|
func TestR1281_FileBrowserLeavesTheAppNetworkAndIsServedOnTheFilesNetwork(t *testing.T) {
|
|
fb := &fakeFB{password: "admin"}
|
|
srv := httptest.NewServer(fb.handler())
|
|
defer srv.Close()
|
|
m, _, _ := fbManager(t, srv.URL)
|
|
m.fbStarted = func() string { return "3f2a9c 2026-10-25T20:00:00Z" }
|
|
nets := map[string]bool{"3f2a9c@traefik-public": true} // as a 0.343.0 controller left it
|
|
m.fbNetOnFn = func(c, n string) bool { return nets[c+"@"+n] }
|
|
m.fbNetConnectFn = func(c, n string) error { nets[c+"@"+n] = true; return nil }
|
|
m.fbNetDisconnectFn = func(c, n string) error { delete(nets, c+"@"+n); return nil }
|
|
m.fbNetEnsureFn = func(string) error { return nil }
|
|
m.fbOfficeLinkedFn = func() (bool, bool) { return false, false }
|
|
m.fileBrowserGateLocked()
|
|
if nets["3f2a9c@traefik-public"] {
|
|
t.Fatal("FileBrowser is still on traefik-public — every app container reaches it")
|
|
}
|
|
if !nets["3f2a9c@felhom-files"] || !nets["traefik@felhom-files"] {
|
|
t.Fatalf("FileBrowser and traefik must share the files network: %v", nets)
|
|
}
|
|
}
|
|
|
|
// The files network keeps R-1126's guarantee: an unchecked run joins nothing, so a re-created FileBrowser (default
|
|
// password) is not reachable from outside.
|
|
func TestR1281_AnUncheckedRunJoinsNothing(t *testing.T) {
|
|
fb := &fakeFB{password: "admin"}
|
|
srv := httptest.NewServer(fb.handler())
|
|
defer srv.Close()
|
|
m, _, _ := fbManager(t, srv.URL)
|
|
m.fbStarted = func() string { return "" } // the run cannot be read
|
|
var joined []string
|
|
m.fbNetOnFn = func(string, string) bool { return false }
|
|
m.fbNetConnectFn = func(c, n string) error { joined = append(joined, c+"@"+n); return nil }
|
|
m.fbNetEnsureFn = func(string) error { return nil }
|
|
m.fileBrowserGateLocked()
|
|
for _, j := range joined {
|
|
if strings.HasSuffix(j, "@felhom-files") || strings.HasSuffix(j, "@traefik-public") {
|
|
t.Fatalf("an unchecked run joined %s", j)
|
|
}
|
|
}
|
|
}
|
|
|
|
// OnlyOffice shares the files network while — and only while — its FileBrowser link is on.
|
|
// RED-PROOF: drop ensureOfficeOnFilesNetwork → the linked case never joins.
|
|
func TestR1281_OnlyOfficeJoinsTheFilesNetworkOnlyWhileLinked(t *testing.T) {
|
|
fb := &fakeFB{password: "admin"}
|
|
srv := httptest.NewServer(fb.handler())
|
|
defer srv.Close()
|
|
m, _, _ := fbManager(t, srv.URL)
|
|
m.fbStarted = func() string { return "3f2a9c 2026-10-25T20:00:00Z" }
|
|
nets := map[string]bool{}
|
|
m.fbNetOnFn = func(c, n string) bool { return nets[c+"@"+n] }
|
|
m.fbNetConnectFn = func(c, n string) error { nets[c+"@"+n] = true; return nil }
|
|
m.fbNetDisconnectFn = func(c, n string) error { delete(nets, c+"@"+n); return nil }
|
|
m.fbNetEnsureFn = func(string) error { return nil }
|
|
linked := true
|
|
m.fbOfficeLinkedFn = func() (bool, bool) { return linked, true }
|
|
m.fileBrowserGateLocked()
|
|
if !nets["onlyoffice@felhom-files"] {
|
|
t.Fatalf("a linked OnlyOffice must reach FileBrowser: %v", nets)
|
|
}
|
|
linked = false
|
|
m.fileBrowserGateLocked()
|
|
if nets["onlyoffice@felhom-files"] {
|
|
t.Fatal("an unlinked OnlyOffice must leave the files network")
|
|
}
|
|
if nets["onlyoffice@traefik-public"] || nets["3f2a9c@traefik-public"] {
|
|
t.Fatalf("nothing joins traefik-public here: %v", nets)
|
|
}
|
|
}
|