Files
felhom-controller/controller/internal/stacks/filebrowser_gate.go
T
admin 8f029aa70b
gates / gates (push) Successful in 42s
R-1126: the gate publishes the container it checked, by id (TOCTOU from the commit's security review)
web.SyncFileBrowserMounts can re-create FileBrowser between the check and the connect; a connect by name would publish
the new, unchecked container. The run is now "<id> <StartedAt>" and the connect targets that id.
TestFileBrowserGate_ConnectsTheCheckedContainerById, red-proved.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-17 12:59:43 +02:00

114 lines
5.1 KiB
Go

package stacks
import (
"strings"
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
)
// R-1126 (operator decision D11 = A, 2026-10-17): FileBrowser is never reachable from outside on its default password.
//
// WHY THIS SHAPE. The tunnel runs before the claim (the claim mail links https://felhom.<domain>), and files.<domain> is
// published through it. A new FileBrowser database starts on admin/admin. FileBrowser's own admin-password setting
// (config `auth.adminPassword` / env FILEBROWSER_ADMIN_PASSWORD) is RE-APPLIED AT EVERY START (gtstef/filebrowser
// 1.5.6-stable backend/cmd/user.go:50-57, called from cmd/root.go:144 each start) — measured on scratch 9202: a password
// the household set in FileBrowser was replaced at the next restart. So the box cannot hand FileBrowser its password up
// front without overwriting the household's own (R-513's reason, re-proven). Instead FileBrowser starts OFF the
// traefik-public network (infra.RenderFileBrowserCompose) and the box joins it there only after it has checked THIS run:
// the default login refused, or replaced and verified (EnsureFileBrowserAdminPassword sets fbProbedRun). A re-created
// container — the only way a new database appears — always starts unreachable from outside, whoever re-created it.
// Pinned by TestFileBrowserGate_*.
// fbNetOn reports whether container is on network (test seam: fbNetOnFn; a test FileBrowser and no seam → false).
func (m *Manager) fbNetOn(container, network string) bool {
if m.fbNetOnFn != nil {
return m.fbNetOnFn(container, network)
}
if m.fbHTTP != nil || m.fbBaseURL != "" {
return false
}
return containerOnNetwork(container, network)
}
// fbNetConnect attaches container to network (test seam: fbNetConnectFn; a test FileBrowser and no seam → no-op).
func (m *Manager) fbNetConnect(container, network string) error {
if m.fbNetConnectFn != nil {
return m.fbNetConnectFn(container, network)
}
if m.fbHTTP != nil || m.fbBaseURL != "" {
return nil
}
out, err := dockerexec.Command("docker", "network", "connect", network, container).CombinedOutput()
if err != nil && !strings.Contains(string(out), "already exists") {
return &netConnectError{out: strings.TrimSpace(string(out)), err: err}
}
return nil
}
type netConnectError struct {
out string
err error
}
func (e *netConnectError) Error() string { return e.out + ": " + e.err.Error() }
// joinFileBrowserNetwork puts the controller on FileBrowser's own network, so the default-login check reaches it before
// FileBrowser is public. Missing network (FileBrowser not deployed yet) → nothing, the next round tries again.
func (m *Manager) joinFileBrowserNetwork() {
if m.fbNetOn(controllerContainer, infra.FileBrowserNetwork) {
return
}
if err := m.fbNetConnect(controllerContainer, infra.FileBrowserNetwork); err != nil {
if m.isDebug() {
m.logger.Printf("[DEBUG] [infra] filebrowser: controller not joined to %s yet: %v", infra.FileBrowserNetwork, err)
}
return
}
m.logger.Printf("[INFO] [infra] connected %s to %s (the FileBrowser default-login check, R-1126)", controllerContainer, infra.FileBrowserNetwork)
}
// ensureFileBrowserPublic joins FileBrowser to traefik-public only when THIS run was checked and its default login is
// refused. Anything less — the run unknown, not checked yet, the check failed — leaves it unreachable from outside.
//
// TOCTOU (found by the commit's security review, 2026-10-17): the connect targets the CONTAINER ID read with the run,
// never the name — web.SyncFileBrowserMounts can re-create FileBrowser between the check and the connect, and a
// connect by name would publish the new, unchecked container. By id, a replaced container's connect simply fails.
// Pinned by TestFileBrowserGate_ConnectsTheCheckedContainerById.
func (m *Manager) ensureFileBrowserPublic() {
run := m.fbStartedAt()
if run == "" || run != m.fbProbedRun {
return
}
target := fileBrowserContainer // tests' runs carry no id
if f := strings.Fields(run); len(f) == 2 {
target = f[0]
}
if m.fbNetOn(target, traefikNetwork) {
return
}
if err := m.fbNetConnect(target, traefikNetwork); err != nil {
m.logger.Printf("[WARN] [infra] filebrowser: could not join %s (files.<domain> stays unreachable): %v", traefikNetwork, err)
return
}
m.logger.Printf("[INFO] [infra] filebrowser: default login refused for this run — joined %s, files.<domain> served (R-1126)", traefikNetwork)
}
// EnsureFileBrowserGate is the whole check outside the base-stack round — called after web.SyncFileBrowserMounts
// re-creates FileBrowser, so a household's storage change does not leave the file browser offline until the next round.
func (m *Manager) EnsureFileBrowserGate() {
m.infraMu.Lock()
defer m.infraMu.Unlock()
m.fileBrowserGateLocked()
}
// fileBrowserGateLocked runs join → check → publish. The caller holds infraMu.
func (m *Manager) fileBrowserGateLocked() {
m.joinFileBrowserNetwork()
if err := m.EnsureFileBrowserAdminPassword(); err != nil {
// R-513: retried every round until decided. Logged, never fatal to the base stack.
m.logger.Printf("[WARN] [infra] %v", err)
}
m.ensureFileBrowserPublic()
}