Files
felhom-controller/controller/internal/infra/filebrowser_sso.go
T

127 lines
5.3 KiB
Go

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
}