package stacks import ( "os" "path/filepath" "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.), and files. 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 serves files. only when THIS run was checked and its default login is refused: // traefik joins infra.FilesNetwork, then the checked FileBrowser container does — never traefik-public, where every app // container is (R-1281). A FileBrowser an earlier controller joined to traefik-public leaves it once it is on the files // network. OnlyOffice joins the files network only while its FileBrowser link is on. Anything less than a checked run // leaves FileBrowser unreachable from outside, as R-1126 requires. // // 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 and TestR1281_*. 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 err := m.fbNetEnsure(infra.FilesNetwork); err != nil { m.logger.Printf("[WARN] [infra] filebrowser: the %s network is not there (files. stays as it is): %v", infra.FilesNetwork, err) return } if !m.fbNetOn(traefikContainer, infra.FilesNetwork) { if err := m.fbNetConnect(traefikContainer, infra.FilesNetwork); err != nil { m.logger.Printf("[WARN] [infra] filebrowser: traefik could not join %s (files. stays as it is): %v", infra.FilesNetwork, err) return } m.logger.Printf("[INFO] [infra] filebrowser: traefik joined %s — the only route to FileBrowser (R-1281)", infra.FilesNetwork) } if !m.fbNetOn(target, infra.FilesNetwork) { if err := m.fbNetConnect(target, infra.FilesNetwork); err != nil { m.logger.Printf("[WARN] [infra] filebrowser: could not join %s (files. stays unreachable): %v", infra.FilesNetwork, err) return } m.logger.Printf("[INFO] [infra] filebrowser: default login refused for this run — joined %s, files. served (R-1126, R-1281)", infra.FilesNetwork) } if m.fbNetOn(target, traefikNetwork) { if err := m.fbNetDisconnect(target, traefikNetwork); err != nil { m.logger.Printf("[WARN] [infra] filebrowser: could not leave %s — every app can still reach it (R-1281): %v", traefikNetwork, err) } else { m.logger.Printf("[INFO] [infra] filebrowser: left %s — no app container reaches it any more (R-1281)", traefikNetwork) } } m.ensureOfficeOnFilesNetwork() } // traefikContainer is traefik's container (infra/templates/traefik-compose.yml.tmpl container_name). const traefikContainer = "traefik" // officeContainer is OnlyOffice's container (the catalog's container_name). const officeContainer = "onlyoffice" // ensureOfficeOnFilesNetwork (R-1281): OnlyOffice shares the files network while — and only while — its FileBrowser // link is on (FileBrowser's config holds the office block). Called after FileBrowser was published, so it never reaches // an unchecked FileBrowser. func (m *Manager) ensureOfficeOnFilesNetwork() { linked, running := m.fbOfficeLinked() on := m.fbNetOn(officeContainer, infra.FilesNetwork) switch { case linked && running && !on: if err := m.fbNetConnect(officeContainer, infra.FilesNetwork); err != nil { m.logger.Printf("[WARN] [infra] filebrowser: OnlyOffice could not join %s (documents do not open in the file manager): %v", infra.FilesNetwork, err) return } m.logger.Printf("[INFO] [infra] filebrowser: OnlyOffice joined %s — its FileBrowser link is on (R-1281)", infra.FilesNetwork) case !linked && on: if err := m.fbNetDisconnect(officeContainer, infra.FilesNetwork); err != nil { m.logger.Printf("[WARN] [infra] filebrowser: OnlyOffice could not leave %s: %v", infra.FilesNetwork, err) return } m.logger.Printf("[INFO] [infra] filebrowser: OnlyOffice left %s — its FileBrowser link is off (R-1281)", infra.FilesNetwork) } } // fbNetDisconnect detaches container from network (test seam: fbNetDisconnectFn; a test FileBrowser and no seam → no-op). func (m *Manager) fbNetDisconnect(container, network string) error { if m.fbNetDisconnectFn != nil { return m.fbNetDisconnectFn(container, network) } if m.fbHTTP != nil || m.fbBaseURL != "" { return nil } out, err := dockerexec.Command("docker", "network", "disconnect", network, container).CombinedOutput() if err != nil && !strings.Contains(string(out), "is not connected") { return &netConnectError{out: strings.TrimSpace(string(out)), err: err} } return nil } // fbNetEnsure creates network when it does not exist (test seam: fbNetEnsureFn; a test FileBrowser and no seam → no-op). func (m *Manager) fbNetEnsure(network string) error { if m.fbNetEnsureFn != nil { return m.fbNetEnsureFn(network) } if m.fbHTTP != nil || m.fbBaseURL != "" { return nil } if err := dockerexec.Command("docker", "network", "inspect", network).Run(); err == nil { return nil } out, err := dockerexec.Command("docker", "network", "create", network).CombinedOutput() if err != nil && !strings.Contains(string(out), "already exists") { return &netConnectError{out: strings.TrimSpace(string(out)), err: err} } return nil } // fbOfficeLinked reports whether FileBrowser's config holds the OnlyOffice block and whether OnlyOffice runs (test // seam: fbOfficeLinkedFn; a test FileBrowser and no seam → neither). func (m *Manager) fbOfficeLinked() (bool, bool) { if m.fbOfficeLinkedFn != nil { return m.fbOfficeLinkedFn() } if m.fbHTTP != nil || m.fbBaseURL != "" { return false, false } b, err := os.ReadFile(filepath.Join(m.cfg.Paths.StacksDir, "filebrowser", "config.yaml")) linked := err == nil && strings.Contains(string(b), "\nintegrations:\n office:") return linked, containerRunning(officeContainer) } // 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() }