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. router. const FilesSSOMiddleware = "felhom-files-sso" // FilesAuthURL is the controller's forwardAuth answer for files. (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 }