127 lines
5.3 KiB
Go
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
|
|
}
|