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

557 lines
29 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package infra renders the base-infrastructure stacks (traefik, cloudflared, filebrowser) from the
// controller's config. It is PURE: templates in, file contents out — no docker, no filesystem, no IO.
// The orchestration (write the files, create the network, compose-up) lives in
// internal/stacks/infra.go (EnsureBaseStack), which owns the side effects.
//
// The templates are lifted verbatim from scripts/docker-setup.sh (the bare-metal installer, the
// historical source of truth for these stacks); bash `${VAR}` became Go template `{{.Field}}` and the
// heredoc conditionals became `{{if}}`. Image tags are PINNED here as the single source of truth — the
// web FileBrowser sync path (internal/web/handlers.go) delegates here so the pins can never diverge.
package infra
import (
"embed"
"fmt"
"path/filepath"
"strings"
"text/template"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// Pinned image tags — NEVER ":latest" (a floating tag breaks reproducible golden bakes and lets the
// deployed version drift). Verified to resolve on Docker Hub before baking. cloudflared follows Cloudflare's security
// notices (felhom.eu `01` „Tunnel placement"): 2026.10.3 since the notice of 2026-10-20 — before 2026.10.2 the image
// trusts eight eMudhra (emSign) intermediates with weak keys; 2026.10.4 since 2026-10-27 (VULN-176088, the metrics
// server's debug endpoints). traefik v3.7.16 and the file manager 1.5.8-stable since 2026-10-27 for their security
// notices (felhom.eu R-1304, R-1305; 1.5.7-stable is skipped — it broke existing TOTP sign-ins). Each new image reaches
// a running box by the reconcile at start: TestEnsureTraefik_ANewImageRecreatesTraefik,
// TestEnsureCloudflared_ANewImageRecreatesTheTunnel, TestReconcileFileBrowserImage_MovesOnlyTheImage.
// traefik v3.7.17 since 2026-11-02: GHSA-xv5x-242w-7hf7 (high, v3.7.14–v3.7.16): an unauthenticated client with a
// Negotiate/NTLM header holds a backend transport per connection until memory runs out, NTLM backend or not.
const (
TraefikImage = "traefik:v3.7.17"
CloudflaredImage = "cloudflare/cloudflared:2026.10.4"
FileBrowserImage = "gtstef/filebrowser:1.5.8-stable"
// FileBrowserImportMount is the in-container mount point NAME for the canonical drop-zone
// (R-75): the bind lands at /srv/<this>. ASCII and space-free on purpose — it appears in a
// container path, in the generated compose, and (percent-encoded) in the deep-link URL.
FileBrowserImportMount = "beolvasas"
// FileBrowserImportLabel is the Hungarian SIDEBAR name of that source. It is the display name and
// it IS the URL identity: FileBrowser Quantum keys sources by name (SPIKE P1) and the deep-link
// template is /files/{encodeURIComponent(name)}/... (SPIKE P2). Accents round-trip correctly —
// the spike verified an accented, spaced and ampersand'd source name end to end.
FileBrowserImportLabel = "Beolvasás"
// FileBrowserKeptMount is the in-container folder NAME of the read-only „Megőrzött adatok" source
// (`09` §3 decision 36): each kept item is its own `:ro` bind under /srv/<this>/.
FileBrowserKeptMount = "megorzott"
// SambaImage is our own pinned LAN-sharing image (R-7 slice 1). Built by
// controller/scripts/build-samba-image.sh from controller/infra-images/samba/. NEVER :latest.
SambaImage = "gitea.dooplex.hu/admin/felhom-samba:1.2.0"
)
// Images returns every controller-managed infra image, derived from the pins above so there is
// exactly one place a tag is written.
//
// WHY THIS IS EXPORTED: the golden bake (felhom-agent configs/build-golden.sh) pre-pulls these into
// the appliance image so enabling an infra stack on a fresh box is near-instant instead of a silent
// multi-minute registry pull. It used to carry its OWN hand-maintained bash array of tags — which
// drifted the moment felhom-samba was added: the golden baked three of the four, so turning on
// Megosztás pulled from the registry with zero feedback. The bake now asks the controller BINARY it
// is about to bake (`felhom-controller --print-infra-images`), so the golden and the controller it
// ships cannot disagree by construction.
//
// A new infra stack is therefore two edits in this file (the const, and this slice) and zero edits
// anywhere else. If you add a const and forget the slice, TestImagesCoversEveryPin fails.
func Images() []string {
return []string{TraefikImage, CloudflaredImage, FileBrowserImage, SambaImage}
}
//go:embed templates/*.tmpl
var templateFS embed.FS
var tmpl = template.Must(template.New("infra").ParseFS(templateFS, "templates/*.tmpl"))
// FileSpec is one rendered file: its content and the mode it must be written with. The mode matters —
// the traefik .env carries the Cloudflare API token (0600), the rest are world-readable config (0644).
type FileSpec struct {
Content string
Mode uint32 // os.FileMode bits (e.g. 0o600); uint32 keeps this package IO-free
}
// The tunnel's own network (R-753, `09` §3 decision 63 Part A). cloudflared sits ALONE on it at a FIXED address, so
// traefik can trust forwarded headers from that one address and from nothing else; traefik joins it as the second
// member. 172.16.0.0/16 is private (RFC 1918), so apps that count private addresses as proxies (Tomcat's
// RemoteIpValve, for one) skip it, and it is OUTSIDE docker's default address pools (they begin at 172.17), so docker
// never hands it to an app network. A /29 holds the gateway, cloudflared and traefik. BOTH members take FIXED addresses
// and docker's own allocation is confined to TunnelIPRange: measured 2026-10-01 on 9202, traefik joining first was given
// .2 — cloudflared's address — by docker's allocator.
// Pinned by TestTunnelConstantsAgree and TestRenderTraefik_TrustsOnlyTheTunnel.
const (
TunnelNetwork = "felhom-tunnel"
TunnelSubnet = "172.16.253.0/29"
TunnelGateway = "172.16.253.1"
TunnelAddr = "172.16.253.2" // cloudflared — the ONLY address traefik believes forwarded headers from
TunnelTraefikAddr = "172.16.253.3" // traefik's own place on the tunnel network
TunnelIPRange = "172.16.253.4/30" // where docker may put anything else: never .2 or .3
// ForwardedMiddleware is the entrypoint middleware every websecure request passes (RenderForwardedHeaders).
ForwardedMiddleware = "felhom-forwarded"
// R-1033 (P1, `09` §3 decision 244): the parts of the ForwardedMiddleware CHAIN. HTTPSOnlyMiddleware sends a visitor
// who reached Cloudflare over plain http (the tunnel's trusted X-Forwarded-Proto: http) to the same https URL;
// ForwardedHeadersMiddleware is the header clean-up plus HSTS. HTTPSOnlyCatchAllRouter does the same for a name no
// app route matches (an entrypoint middleware runs only on a matched router).
ForwardedHeadersMiddleware = "felhom-forwarded-headers"
HTTPSOnlyMiddleware = "felhom-https-only"
HTTPSOnlyCatchAllRouter = "felhom-https-only-catchall"
// HSTSMaxAgeSeconds is ONE DAY on purpose (decision 244 brief): no includeSubDomains, no preload — a longer value is
// the operator's decision (register row), because a browser keeps it even if the box is ever served without https.
HSTSMaxAgeSeconds = 86400
)
// TraefikData is the per-customer input for the traefik stack. ACMEEmail empty → no Let's Encrypt
// (traefik serves self-signed); CFAPIToken empty → HTTP-01 instead of Cloudflare DNS-01, and no .env.
// (Wildcard proactive issuance is driven by the controller route, NOT here — see RenderControllerRoute:
// the entrypoint-level `http.tls.domains` does NOT trigger issuance in traefik v3, a router-level
// `tls.domains` does.)
type TraefikData struct {
ACMEEmail string
CFAPIToken string
// Tunnel: the felhom-tunnel network exists with its fixed subnet — traefik joins it and trusts forwarded headers
// from TunnelAddr only. False keeps the old shape (trusts nothing), so a box whose network could not be made still
// routes (a compose naming an absent external network would not start at all).
Tunnel bool
}
type traefikTmpl struct {
TraefikData
Image string
TunnelNetwork string
TunnelAddr string
TunnelTraefikAddr string
ForwardedMiddleware string
}
// CloudflaredData is the per-customer input for the cloudflared stack (just the tunnel token).
type CloudflaredData struct {
CFTunnelToken string
// Tunnel: put cloudflared on felhom-tunnel at TunnelAddr (and on nothing else). The caller sets it only once traefik
// is on that network too — otherwise cloudflared could not reach "traefik" and the tunnel would be down.
Tunnel bool
}
type cloudflaredTmpl struct {
CloudflaredData
Image string
TunnelNetwork string
TunnelAddr string
}
func render(name string, data any) (string, error) {
var b strings.Builder
if err := tmpl.ExecuteTemplate(&b, name, data); err != nil {
return "", fmt.Errorf("render %s: %w", name, err)
}
return b.String(), nil
}
// RenderTraefik returns the traefik stack files: traefik.yml (static config), docker-compose.yml, and
// — only when a Cloudflare API token is set — a 0600 .env carrying CF_DNS_API_TOKEN (kept out of the
// compose file). The orchestrator additionally creates dynamic/, certs/ and an empty 0600 acme.json.
func RenderTraefik(d TraefikData) (map[string]FileSpec, error) {
td := traefikTmpl{TraefikData: d, Image: TraefikImage, TunnelNetwork: TunnelNetwork, TunnelAddr: TunnelAddr,
TunnelTraefikAddr: TunnelTraefikAddr, ForwardedMiddleware: ForwardedMiddleware}
yml, err := render("traefik.yml.tmpl", td)
if err != nil {
return nil, err
}
compose, err := render("traefik-compose.yml.tmpl", td)
if err != nil {
return nil, err
}
files := map[string]FileSpec{
"traefik.yml": {Content: yml, Mode: 0o644},
"docker-compose.yml": {Content: compose, Mode: 0o644},
}
if d.CFAPIToken != "" {
env := fmt.Sprintf("# Cloudflare API token for Let's Encrypt DNS-01 challenge (Zone:DNS:Edit).\n"+
"# Managed by felhom-controller — do not edit.\nCF_DNS_API_TOKEN=%s\n", d.CFAPIToken)
files[".env"] = FileSpec{Content: env, Mode: 0o600}
}
return files, nil
}
// RenderCloudflared returns the cloudflared stack files (compose only — no bind mounts; the tunnel
// token is the entire config). Caller deploys this only when a tunnel token is configured.
func RenderCloudflared(d CloudflaredData) (map[string]FileSpec, error) {
cd := cloudflaredTmpl{CloudflaredData: d, Image: CloudflaredImage, TunnelNetwork: TunnelNetwork, TunnelAddr: TunnelAddr}
compose, err := render("cloudflared-compose.yml.tmpl", cd)
if err != nil {
return nil, err
}
return map[string]FileSpec{
"docker-compose.yml": {Content: compose, Mode: 0o644},
}, nil
}
// RenderFileBrowserCompose returns FileBrowser's docker-compose.yml for the given domain and storage
// volume-mount lines. Ported verbatim from internal/web/handlers.go (the single source of truth now
// lives here so the pinned image can't diverge between bring-up and the web storage-sync path).
// FileBrowserNetwork is FileBrowser's own network (R-1126): the controller joins it to check the default login before
// FileBrowser is put on traefik-public.
const FileBrowserNetwork = "felhom-filebrowser"
// FilesNetwork (R-1281, operator D44 #23, 2026-10-25) is the ONLY network on which traefik reaches FileBrowser: traefik
// is on it, the checked FileBrowser container is joined to it after the default-login check (by id, as R-1126 did with
// traefik-public), and no app is — except OnlyOffice while its FileBrowser link is on (it fetches and saves documents at
// http://filebrowser:80, and FileBrowser calls http://onlyoffice:80). FileBrowser is never on traefik-public, where every
// app container is: an app that learnt the secret header name could otherwise sign in as the household.
const FilesNetwork = "felhom-files"
func RenderFileBrowserCompose(domain string, storageMounts []string, groupAdd ...int) string {
// R-691 (v0.275.0): supplementary groups — the owning groups of kept folders the view must READ (a
// 0770 folder another user owns, e.g. nextcloud's www-data). Their binds are `:ro`. Absent → the
// compose is byte-for-byte what it was.
groupSection := ""
if len(groupAdd) > 0 {
groupSection = "\n # Kept data's owning groups (auto-generated): the read-only view reads a folder another user owns.\n group_add:"
for _, g := range groupAdd {
groupSection += fmt.Sprintf("\n - \"%d\"", g)
}
}
storageSection := ""
if len(storageMounts) > 0 {
storageSection = "\n # Storage paths (auto-generated by felhom-controller)\n" +
strings.Join(storageMounts, "\n")
}
return fmt.Sprintf(`# FileBrowser Quantum — Infrastructure file manager
# Domain: files.%s
# Managed by felhom-controller. WARNING: Volume mounts are auto-generated; manual edits are overwritten.
services:
filebrowser:
image: %s
container_name: filebrowser
restart: unless-stopped
# umask 002 so folders the customer creates here come out group-writable (2775 with the parent's
# setgid), letting the content apps (group 1000) write into them. The gtstef/filebrowser image is a
# single Go binary (entrypoint ./filebrowser) and does NOT honor a UMASK env (verified: -e UMASK=002
# leaves PID1 at 0022), so we wrap the entrypoint to set the process umask before exec.
entrypoint: ["sh", "-c", "umask 002; exec /home/filebrowser/filebrowser"]%s
environment:
- TZ=Europe/Budapest
- FILEBROWSER_CONFIG=/home/filebrowser/config.yaml
volumes:
- filebrowser_data:/home/filebrowser/data
- ./config.yaml:/home/filebrowser/config.yaml:ro%s
# R-1126 (D11 = A): NOT reachable from traefik at start. The box joins it to felhom-files (traefik's route to it,
# R-1281 — never traefik-public, where every app is) only after it has checked THIS run's default login is refused
# (stacks.ensureFileBrowserPublic), so files.<domain> never answers with FileBrowser's default password — a
# re-created container always starts unreachable from outside. The box reaches it for that check over its own network.
networks:
- %s
deploy:
resources:
limits:
memory: 256M
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:80/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
# No Traefik labels (v0.327.1): the route is a file-provider route (RenderFileBrowserRoute) to http://filebrowser:80
# BY NAME. Docker labels made Traefik pick FileBrowser's address once, at container start — on its own network,
# before the box joined it to traefik-public — and a later "network connect" did not refresh it (demo-hp and the
# N100 timed out after v0.327.0). By name, the address is resolved per connection, and only while FileBrowser is on
# felhom-files (R-1281; traefik-public before).
volumes:
filebrowser_data:
networks:
%s:
name: %s
`, domain, FileBrowserImage, groupSection, storageSection, FileBrowserNetwork, FileBrowserNetwork, FileBrowserNetwork)
}
// RenderFileBrowserRoute is the traefik file-provider route for files.<domain> → http://filebrowser:80 by NAME (R-1126,
// v0.327.1). The name resolves only while FileBrowser is on felhom-files (R-1281), 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).
//
// 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:
%s routers:
filebrowser:
rule: "Host(`+"`"+`files.%s`+"`"+`)"
entryPoints:
- websecure
service: filebrowser
%s tls: {}
services:
filebrowser:
loadBalancer:
servers:
- url: "http://filebrowser:80"
`, mwDef, domain, mw)
}
// RenderControllerRoute returns a traefik file-provider dynamic config routing the controller's own
// dashboard — Host(felhom.<domain>) → http://felhom-controller:8080 on websecure. This can only be
// produced POST config-pull (the v2 bootstrap.json carries no domain), which is why the controller
// wires its OWN route at bring-up instead of via a static Docker label at bootstrap time.
//
// When wildcardTLS is true (DNS-01 ACME configured = CF API token + email), this route is ALSO the
// **wildcard-issuance anchor**: its router-level `tls.domains` makes traefik proactively obtain
// `*.<domain>` + apex via Cloudflare DNS-01 at startup. Every other router (filebrowser, future apps)
// then serves that one wildcard by SNI match — no per-app certresolver labels, real cert before the
// first client connects. (Empirically, traefik v3 issues from a router-level `tls.domains` but NOT
// from the entrypoint-level `http.tls.domains` — hence this lives here, not in traefik.yml.)
// When wildcardTLS is false (no DNS-01: HTTP-01 or no ACME — wildcards need DNS-01), it emits a plain
// TLS router (traefik's self-signed default until/unless a cert exists).
func RenderControllerRoute(domain string, wildcardTLS bool) string {
tlsBlock := " tls: {}\n"
if wildcardTLS {
tlsBlock = fmt.Sprintf(` tls:
certResolver: letsencrypt
domains:
- main: "*.%s"
sans:
- "%s"
`, domain, domain)
}
return fmt.Sprintf(`# Traefik dynamic route for the felhom-controller dashboard — managed by felhom-controller.
# WARNING: auto-generated at base-infra bring-up. Manual edits are overwritten.
http:
routers:
felhom-controller:
rule: "Host(`+"`"+`felhom.%s`+"`"+`)"
entryPoints:
- websecure
service: felhom-controller
%s services:
felhom-controller:
loadBalancer:
servers:
- url: "http://felhom-controller:8080"
`, domain, tlsBlock)
}
// ServersTransportInsecure is the name of the traefik dynamic serversTransport that skips backend TLS
// verification. App services reference it by `<name>@file` (cross-provider: a docker-provider service
// pointing at a file-provider transport). It exists for backends that serve their OWN self-signed TLS
// on the internal docker bridge (e.g. Crafty on :8443) — there is no CA to verify a per-container
// self-signed cert against, and the hop never leaves the host's docker network. Verification stays the
// default (ON) for every other backend; only services that explicitly add the label opt out.
const ServersTransportInsecure = "insecure-skip-verify"
// RenderServersTransports returns the traefik file-provider dynamic config defining the named backend
// transports. Written to its OWN file under /etc/traefik/dynamic/ (NOT folded into the controller
// route) so the two concerns stay independent. Static and constant — no per-customer input.
func RenderServersTransports() string {
return fmt.Sprintf(`# Traefik dynamic config — backend transports. Managed by felhom-controller.
# WARNING: auto-generated at base-infra bring-up. Manual edits are overwritten.
# %s: for backends that serve their own self-signed TLS on the internal docker bridge
# (e.g. Crafty on :8443). Backend verification stays ON for all other backends.
http:
serversTransports:
%s:
insecureSkipVerify: true
`, ServersTransportInsecure, ServersTransportInsecure)
}
// RenderForwardedHeaders returns the dynamic file defining the entrypoint middleware every websecure request passes
// (traefik.yml names it). Since R-1033 (P1, controller v0.312.0) ForwardedMiddleware is a CHAIN of two:
//
// 1. HTTPSOnlyMiddleware — every public name answers only over https. A visitor who typed http:// reaches Cloudflare
// over plain http; the tunnel still delivers to traefik's :443 and says so in X-Forwarded-Proto: http, which traefik
// keeps because the hop is the trusted tunnel address (any other peer's X-Forwarded-Proto is replaced by traefik's
// own — measured on the bench, audits/https-only-2026-10-14/1A-6). redirectScheme honours that header: 301 to the
// same https URL, path and query kept; an https visitor (X-Forwarded-Proto: https) and a LAN visitor are unchanged.
// The redirect happens before any app sees the request, so a login page is never served — and a password never
// sent — over http. The Cloudflare-side switch („Always Use HTTPS") needs a zone permission the box keys lack.
// 2. ForwardedHeadersMiddleware — the clean-up: once traefik trusts the tunnel's address it KEEPS the forwarded headers
// that hop carries, and Cloudflare passes a client's own X-Forwarded-Host and X-Forwarded-Port through unchanged
// (measured, audits/visitors-2026-10-01/A/M2) — so this removes every header in which a client could write a host, a
// path or an address, and fixes the port: both paths reach traefik on 443. X-Forwarded-For stays (traefik appends the
// hop it saw; readers take it from the RIGHT), X-Real-Ip stays (traefik's peer — Cloudflare strips a client's,
// measured M2), CF-Connecting-IP stays (the controller believes it only when the hop is the tunnel). A request's
// Host header still says which app it is for. It also sets HSTS for HSTSMaxAgeSeconds, no subdomains, no preload.
//
// HTTPSOnlyCatchAllRouter (priority 1, only X-Forwarded-Proto: http) redirects a name no app route matches, so an
// unknown name over http is a redirect too while an unknown name over https stays traefik's 404.
// Static — no per-customer input. Pinned by TestRenderForwardedHeaders_RemovesClientWritableHeaders and
// TestRenderForwardedHeaders_HTTPSOnly (both read the chain, so removing either half fails a test).
func RenderForwardedHeaders() string {
return `# Traefik dynamic config — the chain every websecure request passes: https only (R-1033), then the forwarded-header
# clean-up + HSTS. Managed by felhom-controller.
# WARNING: auto-generated at base-infra bring-up. Manual edits are overwritten. traefik.yml names ` + ForwardedMiddleware + ` on its
# websecure entrypoint, so a missing file would break every route: it is written before traefik.yml.
# An empty value REMOVES the header (traefik headers middleware).
http:
middlewares:
` + ForwardedMiddleware + `:
chain:
middlewares:
- ` + HTTPSOnlyMiddleware + `
- ` + ForwardedHeadersMiddleware + `
` + HTTPSOnlyMiddleware + `:
redirectScheme:
scheme: https
permanent: true
` + ForwardedHeadersMiddleware + `:
headers:
stsSeconds: ` + fmt.Sprint(HSTSMaxAgeSeconds) + `
stsIncludeSubdomains: false
stsPreload: false
customRequestHeaders:
X-Forwarded-Port: "443"
X-Forwarded-Host: ""
X-Forwarded-Uri: ""
X-Forwarded-Method: ""
X-Forwarded-Prefix: ""
X-Forwarded-Tls-Client-Cert: ""
X-Forwarded-Tls-Client-Cert-Info: ""
Forwarded: ""
True-Client-Ip: ""
X-Client-Ip: ""
X-Cluster-Client-Ip: ""
Client-Ip: ""
X-Original-Forwarded-For: ""
routers:
` + HTTPSOnlyCatchAllRouter + `:
rule: "Header(` + "`X-Forwarded-Proto`, `http`" + `)"
priority: 1
entryPoints:
- websecure
service: noop@internal
tls: {}
`
}
// RenderFileBrowserConfig returns a FileBrowser Quantum config.yaml with one source per registered
// 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, "", 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), 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:
// the spike proved a nested source works but gets indexed TWICE (once as its own root, once as a
// child of the parent drive), which buys nothing over a separate bind.
if importSource {
sources += fmt.Sprintf(" - path: %q\n name: %q\n config:\n defaultEnabled: true\n",
"/srv/"+FileBrowserImportMount, FileBrowserImportLabel)
}
if len(paths) == 0 && !importSource {
sources = ` - path: "/srv"
`
} else if len(paths) > 0 {
for _, sp := range paths {
mountName := filepath.Base(sp.Path)
label := sp.Label
if label == "" {
label = mountName
}
sources += fmt.Sprintf(" - path: \"/srv/%s\"\n name: %q\n config:\n defaultEnabled: true\n", mountName, label)
}
}
if keptLabel != "" {
sources += fmt.Sprintf(" - path: %q\n name: %q\n config:\n defaultEnabled: true\n",
"/srv/"+FileBrowserKeptMount, keptLabel)
}
return fmt.Sprintf(`# FileBrowser Quantum — managed by felhom-controller
# WARNING: This file is auto-generated. Manual edits will be overwritten.
server:
port: 80
baseURL: "/"
# R-1018: the address OnlyOffice's server uses to fetch and save a document (FileBrowser's onlyOffice.go builds both
# URLs from it at gtstef/filebrowser:1.5.6-stable; it is used by integrations only). Without it FileBrowser hands
# OnlyOffice https://files.<domain> — a trip out through the tunnel and back, which failed (403) on demo-hp.
# Pinned by TestFileBrowserConfig_R1018_InternalURLIsTheContainer.
internalUrl: "http://filebrowser:80"
# The box updates FileBrowser (the pinned image); a household cannot, so its „An update is available!" is noise.
# backend/common/settings/structs.go:51 at gtstef/filebrowser:1.5.6-stable (v0.321.0).
disableUpdateCheck: true
database: "/home/filebrowser/data/database.db"
logging:
- levels: "info|warning|error"
sources:
%s%suserDefaults:
stickySidebar: true
darkMode: true
viewMode: "normal"
showHidden: false
dateFormat: false
gallerySize: 3
themeColor: "var(--blue)"
preview:
disableHideSidebar: false
highQuality: true
image: true
video: true
motionVideoPreview: true
office: true
popup: true
autoplayMedia: true
folder: true
permissions:
%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
realtime: false
delete: false
create: false
download: true
`
}
return ` api: false
admin: false
modify: true
share: true
realtime: true
delete: true
create: true
download: true
`
}