v0.332.0: one sign-in for the file manager — a dashboard pass, a host-only files cookie, a per-box secret header (R-1100, operator D16)
gates / gates (push) Successful in 47s

This commit is contained in:
2026-10-19 04:56:56 +02:00
parent ba4a6f060b
commit 9bdd8f8fe8
21 changed files with 1078 additions and 54 deletions
@@ -0,0 +1,126 @@
package infra
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
// One sign-in for the file manager (R-1100, operator decision D16 = yes, 2026-10-19; design note
// felhom.eu/documentation/audits/proxyauth-spike-2026-10-18/DESIGN-NOTE.md, `02` „Logins and folders").
//
// FileBrowser trusts its proxy header blindly (`auth.methods.proxy`, „FileBrowser blindly accepts the header value as
// username") and every app container can reach filebrowser:80 directly, without traefik — measured: a forged header
// from the Nextcloud container logged in (200). So the header NAME is a per-box secret: an app that does not know it
// cannot send it, and traefik's forwardAuth strips it from every client request (it is in authResponseHeaders) and sets
// it only for a browser that holds the files pass the dashboard minted. The name is written ONLY into FileBrowser's
// config and traefik's dynamic route — never a log, a page or a report.
// FileBrowserSSOUser is the FileBrowser user the household is signed in as through the header. A separate user from
// `admin` (a password user, which FileBrowser refuses to reach by a header — ErrWrongLoginMethod), created by
// FileBrowser on the first header sign-in with the config's userDefaults.
const FileBrowserSSOUser = "felhom-household"
// FilesSSOMiddleware is the traefik forwardAuth middleware on the files.<domain> router.
const FilesSSOMiddleware = "felhom-files-sso"
// FilesAuthURL is the controller's forwardAuth answer for files.<domain> (web.ServeFilesAuth).
const FilesAuthURL = "http://felhom-controller:8080/__felhom_gate/files"
// fileBrowserSSOHeaderFile holds the header name, 0600, in the controller's data dir (like setup-gate.key).
const fileBrowserSSOHeaderFile = "filebrowser-sso-header"
var reSSOHeader = regexp.MustCompile(`^X-Felhom-User-[0-9a-f]{32}$`)
// ValidFileBrowserSSOHeader reports whether h has the shape LoadOrCreateFileBrowserSSOHeader makes.
func ValidFileBrowserSSOHeader(h string) bool { return reSSOHeader.MatchString(h) }
// LoadOrCreateFileBrowserSSOHeader returns this box's secret header name, making it once. Two writers (the stacks
// manager rendering FileBrowser's files, the web server answering forwardAuth) may ask at the same moment: the file is
// created with O_EXCL, and the loser reads the winner's value. A file that does not hold a valid name is an error, not
// a silent new name (a new name would split FileBrowser's config from traefik's route until both are rewritten).
// Pinned by TestFileBrowserSSOHeader_R1100_*.
func LoadOrCreateFileBrowserSSOHeader(dataDir string) (string, error) {
if dataDir == "" {
return "", errors.New("no data dir")
}
p := filepath.Join(dataDir, fileBrowserSSOHeaderFile)
if b, err := os.ReadFile(p); err == nil {
h := strings.TrimSpace(string(b))
if !ValidFileBrowserSSOHeader(h) {
return "", fmt.Errorf("%s does not hold a valid header name", p)
}
return h, nil
} else if !os.IsNotExist(err) {
return "", err
}
rb := make([]byte, 16)
if _, err := rand.Read(rb); err != nil {
return "", err
}
h := "X-Felhom-User-" + hex.EncodeToString(rb)
f, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
if os.IsExist(err) {
return LoadOrCreateFileBrowserSSOHeader(dataDir)
}
return "", err
}
if _, err := f.WriteString(h + "\n"); err != nil {
f.Close()
_ = os.Remove(p)
return "", err
}
if err := f.Close(); err != nil {
_ = os.Remove(p)
return "", err
}
return h, nil
}
// FileBrowserSSO is what FileBrowser's config needs for the header sign-in. Header "" = no header sign-in (the config
// is the pre-R-1100 one, byte for byte).
type FileBrowserSSO struct {
Header string // the box's secret header name
LogoutURL string // where FileBrowser's „Kijelentkezés" goes for the header user: the dashboard
}
// renderFileBrowserAuth is the config's auth block for the header sign-in. Password sign-in stays on: `admin` is the
// fallback when the pass path fails, and the operator's way in (R-513 and the R-1126 gate unchanged).
func renderFileBrowserAuth(sso FileBrowserSSO) string {
if sso.Header == "" {
return ""
}
return fmt.Sprintf(`auth:
methods:
password:
enabled: true
minLength: 5
# R-1100: the household's one sign-in. traefik sets this header only for a browser holding the dashboard's files
# pass and strips it from every client request; its name is this box's secret (an app container that reaches
# filebrowser:80 directly does not know it). Pinned by TestFileBrowserConfig_R1100_*.
proxy:
enabled: true
header: %q
logoutRedirectUrl: %q
`, sso.Header, sso.LogoutURL)
}
// FileBrowserSSOFor is the header sign-in for this box: its secret header name and the dashboard as FileBrowser's
// logout target. An error (no data dir, an unreadable or damaged name file) = no header sign-in: the caller renders
// the pre-R-1100 config and route and says so in its log; FileBrowser then asks for its own password, as before.
func FileBrowserSSOFor(dataDir, domain string) (FileBrowserSSO, error) {
if domain == "" {
return FileBrowserSSO{}, errors.New("no domain")
}
h, err := LoadOrCreateFileBrowserSSOHeader(dataDir)
if err != nil {
return FileBrowserSSO{}, err
}
return FileBrowserSSO{Header: h, LogoutURL: "https://felhom." + domain + "/"}, nil
}
@@ -0,0 +1,81 @@
package infra
import (
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
const testSSOHeader = "X-Felhom-User-0123456789abcdef0123456789abcdef"
// With the header sign-in FileBrowser trusts THIS box's secret header name (never the old public X-Felhom-User), keeps
// password sign-in on (admin: the fallback and the operator's way), and sends a header user's logout to the dashboard.
// Without it the config is the pre-R-1100 one.
//
// COMPANION RED-PROOF (observed 2026-10-19): render renderFileBrowserAuth's header as "X-Felhom-User" → this fails with
// `the auth block lacks "header: \"X-Felhom-User-0123…\""`. Restored.
func TestFileBrowserConfig_R1100_TrustsOnlyTheBoxSecret(t *testing.T) {
sso := FileBrowserSSO{Header: testSSOHeader, LogoutURL: "https://felhom.example.hu/"}
c := RenderFileBrowserConfigKept([]settings.StoragePath{{Path: "/mnt/felhom-drives/hdd_1", Label: "HDD"}}, true, "", sso)
block := c[strings.Index(c, "\nauth:\n"):]
block = block[:strings.Index(block, "\nuserDefaults:")]
for _, want := range []string{" password:\n enabled: true", " proxy:\n enabled: true", `header: "` + testSSOHeader + `"`, `logoutRedirectUrl: "https://felhom.example.hu/"`} {
if !strings.Contains(block, want) {
t.Fatalf("the auth block lacks %q:\n%s", want, block)
}
}
if strings.Count(c, "header:") != 1 || strings.Contains(c, `header: "X-Felhom-User"`) {
t.Fatal("FileBrowser trusts a header name other than the box's secret")
}
plain := RenderFileBrowserConfigKept([]settings.StoragePath{{Path: "/mnt/felhom-drives/hdd_1", Label: "HDD"}}, true, "", FileBrowserSSO{})
if strings.Contains(plain, "auth:") || strings.Contains(plain, "X-Felhom-User") {
t.Fatal("without the header sign-in the config must carry no auth block")
}
}
// The household's header user is created by FileBrowser from userDefaults, so with the header sign-in the defaults are
// the household's file rights (not FileBrowser's admin or API); without it, the old download-only defaults.
//
// COMPANION RED-PROOF (observed 2026-10-19): return the download-only block for both cases → this fails with "the
// header user could not change files: modify false". Restored.
func TestFileBrowserConfig_R1100_HeaderUserGetsTheHouseholdsRights(t *testing.T) {
with := RenderFileBrowserConfigKept(nil, false, "", FileBrowserSSO{Header: testSSOHeader, LogoutURL: "https://felhom.example.hu/"})
perms := with[strings.Index(with, " permissions:\n"):strings.Index(with, "frontend:")]
for _, k := range []string{"modify", "share", "delete", "create", "download", "realtime"} {
if !strings.Contains(perms, " "+k+": true\n") {
t.Fatalf("the header user could not change files: %s false\n%s", k, perms)
}
}
for _, k := range []string{"admin", "api"} {
if !strings.Contains(perms, " "+k+": false\n") {
t.Fatalf("the header user must not get %s", k)
}
}
without := RenderFileBrowserConfigKept(nil, false, "", FileBrowserSSO{})
if !strings.Contains(without, " modify: false\n") || !strings.Contains(without, " download: true\n") {
t.Fatal("without the header sign-in the old download-only defaults must stay")
}
}
// The route asks the controller before FileBrowser and names the header in authResponseHeaders — which is what makes
// traefik remove a client's copy of it (measured, audits/proxyauth-spike-2026-10-18). Without the header sign-in: the
// old route, no middleware.
//
// COMPANION RED-PROOF (observed 2026-10-19): drop the router's `middlewares:` line in RenderFileBrowserRoute → this fails
// with "the files router does not ask the controller first". Restored.
func TestFileBrowserRoute_R1100_AsksTheControllerAndStripsTheHeader(t *testing.T) {
r := RenderFileBrowserRoute("example.hu", testSSOHeader)
router := r[strings.Index(r, " filebrowser:\n rule:"):strings.Index(r, " services:")]
if !strings.Contains(router, " middlewares:\n - "+FilesSSOMiddleware+"\n") {
t.Fatalf("the files router does not ask the controller first:\n%s", router)
}
for _, want := range []string{" " + FilesSSOMiddleware + ":\n forwardAuth:\n", `address: "` + FilesAuthURL + `"`, "authResponseHeaders:\n - \"" + testSSOHeader + "\""} {
if !strings.Contains(r, want) {
t.Fatalf("the route lacks %q:\n%s", want, r)
}
}
if plain := RenderFileBrowserRoute("example.hu", ""); strings.Contains(plain, "middlewares") || strings.Contains(plain, "X-Felhom-User") {
t.Fatalf("without the header sign-in the route must be the old one:\n%s", plain)
}
}
+49 -14
View File
@@ -264,23 +264,35 @@ networks:
// v0.327.1). The name resolves only while FileBrowser is on traefik-public, which the box allows only after this run's
// default login was checked — so the route can stay in place and still never reach an unchecked FileBrowser (502 then).
// TLS as the old Docker label had it (`tls: {}`, the default certificate store / the wildcard).
func RenderFileBrowserRoute(domain string) string {
//
// R-1100: with ssoHeader set, every request to files.<domain> first asks the controller (forwardAuth, FilesAuthURL).
// traefik removes ssoHeader from the client's request whatever the answer (it is in authResponseHeaders — measured,
// any case, twice: audits/proxyauth-spike-2026-10-18) and sets it only from the controller's 200, which carries it only
// for a browser holding the dashboard's files pass. The same answer handles /_felhom/sso (the pass → the files cookie).
// ssoHeader "" = the route as before, byte for byte. Pinned by TestFileBrowserRoute_R1100_*.
func RenderFileBrowserRoute(domain, ssoHeader string) string {
mw, mwDef := "", ""
if ssoHeader != "" {
mw = " middlewares:\n - " + FilesSSOMiddleware + "\n"
mwDef = fmt.Sprintf(" middlewares:\n %s:\n forwardAuth:\n address: %q\n authResponseHeaders:\n - %q\n",
FilesSSOMiddleware, FilesAuthURL, ssoHeader)
}
return fmt.Sprintf(`# Traefik dynamic route for FileBrowser (files.<domain>) — managed by felhom-controller (R-1126).
# WARNING: auto-generated at base-infra bring-up. Manual edits are overwritten.
http:
routers:
%s routers:
filebrowser:
rule: "Host(`+"`"+`files.%s`+"`"+`)"
entryPoints:
- websecure
service: filebrowser
tls: {}
%s tls: {}
services:
filebrowser:
loadBalancer:
servers:
- url: "http://filebrowser:80"
`, domain)
`, mwDef, domain, mw)
}
// RenderControllerRoute returns a traefik file-provider dynamic config routing the controller's own
@@ -420,12 +432,13 @@ http:
// storage path (each a named sidebar entry). Empty paths → a single default /srv source. Ported
// verbatim from internal/web/handlers.go.
func RenderFileBrowserConfig(paths []settings.StoragePath, importSource bool) string {
return RenderFileBrowserConfigKept(paths, importSource, "")
return RenderFileBrowserConfigKept(paths, importSource, "", FileBrowserSSO{})
}
// RenderFileBrowserConfigKept is RenderFileBrowserConfig plus the read-only kept-data source, LAST,
// named keptLabel ("" = no such source: nothing is kept, or the caller predates it).
func RenderFileBrowserConfigKept(paths []settings.StoragePath, importSource bool, keptLabel string) string {
// named keptLabel ("" = no such source: nothing is kept, or the caller predates it), and the household's header
// sign-in (R-1100; a zero sso = none, the config byte for byte as before).
func RenderFileBrowserConfigKept(paths []settings.StoragePath, importSource bool, keptLabel string, sso FileBrowserSSO) string {
var sources string
// The canonical drop-zone (R-75) is FIRST and is NOT a registered storage path — it is a separate
// bind of <system namespace>/userdata/import. Separate rather than nested inside a drive source:
@@ -471,7 +484,7 @@ server:
logging:
- levels: "info|warning|error"
sources:
%suserDefaults:
%s%suserDefaults:
stickySidebar: true
darkMode: true
viewMode: "normal"
@@ -490,7 +503,23 @@ server:
autoplayMedia: true
folder: true
permissions:
api: false
%sfrontend:
# No „Install app" (PWA) prompt in the sidebar: the file manager is opened from the dashboard (v0.321.0;
# structs.go:294, sent to the page at backend/http/static.go:300).
disablePWAInstall: true
`, sources, renderFileBrowserAuth(sso), fileBrowserDefaultPermissions(sso))
}
// fileBrowserDefaultPermissions is userDefaults.permissions — what FileBrowser gives a user it creates. With the header
// sign-in, the one user it creates on its own is the household's header user (FileBrowserSSOUser, on its first sign-in),
// and that user must be able to do with the files what the household does as `admin`: change, add, delete, share,
// download — but not run FileBrowser itself (admin) or its API keys (api). FileBrowser has no per-user setting for a
// header user; the defaults are the only place the box can set its rights (R-1100). A user the household adds by hand
// in FileBrowser starts from the same ticks and the household can change them there. Without the header sign-in: the
// old download-only defaults, unchanged. Pinned by TestFileBrowserConfig_R1100_HeaderUserGetsTheHouseholdsRights.
func fileBrowserDefaultPermissions(sso FileBrowserSSO) string {
if sso.Header == "" {
return ` api: false
admin: false
modify: false
share: false
@@ -498,9 +527,15 @@ server:
delete: false
create: false
download: true
frontend:
# No „Install app" (PWA) prompt in the sidebar: the file manager is opened from the dashboard (v0.321.0;
# structs.go:294, sent to the page at backend/http/static.go:300).
disablePWAInstall: true
`, sources)
`
}
return ` api: false
admin: false
modify: true
share: true
realtime: true
delete: true
create: true
download: true
`
}
+1 -1
View File
@@ -296,7 +296,7 @@ func TestFileBrowserRender_R1126_NotPublicAtStart(t *testing.T) {
if strings.Contains(compose, "traefik.") {
t.Fatalf("FileBrowser must carry no Traefik labels (the route is the file route by name):\n%s", compose)
}
route := RenderFileBrowserRoute("demo-felhom.eu")
route := RenderFileBrowserRoute("demo-felhom.eu", "")
for _, w := range []string{"Host(`files.demo-felhom.eu`)", `url: "http://filebrowser:80"`, "websecure", "tls: {}"} {
if !strings.Contains(route, w) {
t.Errorf("the file route misses %q:\n%s", w, route)
@@ -7,10 +7,10 @@ import (
// `09` §3 decision 36 — the kept-data source appears only when named, at /srv/megorzott.
func TestFileBrowser_KeptSourceOnlyWhenNamed(t *testing.T) {
if s := RenderFileBrowserConfigKept(nil, false, ""); strings.Contains(s, FileBrowserKeptMount) {
if s := RenderFileBrowserConfigKept(nil, false, "", FileBrowserSSO{}); strings.Contains(s, FileBrowserKeptMount) {
t.Fatal("a kept source rendered with no label")
}
s := RenderFileBrowserConfigKept(nil, false, "Megőrzött adatok")
s := RenderFileBrowserConfigKept(nil, false, "Megőrzött adatok", FileBrowserSSO{})
if !strings.Contains(s, `path: "/srv/`+FileBrowserKeptMount+`"`) || !strings.Contains(s, `name: "Megőrzött adatok"`) {
t.Fatalf("kept source missing:\n%s", s)
}