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
|
||||
}
|
||||
Reference in New Issue
Block a user