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
gates / gates (push) Successful in 47s
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user