557 lines
29 KiB
Go
557 lines
29 KiB
Go
// 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
|
||
`
|
||
}
|