Files
felhom-controller/controller/internal/infra/infra.go
T
admin 1e8d045815 R-753: the box tells visitors apart — cloudflared at a fixed address, traefik trusts only it, the controller reads the hop traefik saw
- felhom-tunnel network 172.16.253.0/29 (ip-range .4/30): cloudflared alone at .2, traefik at .3; traefik's websecure
  trusts forwarded headers from 172.16.253.2/32 only, and every request passes felhom-forwarded@file, which removes
  the client-writable host/path/address headers (X-Forwarded-Host/-Uri/-Method/-Prefix, Forwarded, True-Client-Ip, …)
  and fixes X-Forwarded-Port to 443 (measured: Cloudflare passes a client's X-Forwarded-Host/-Port).
- EnsureBaseStack reconciles a RUNNING traefik/cloudflared whose rendered files changed (recreate), refuses a rewrite
  that would drop a certificate resolver, and moves cloudflared only once traefik is on the tunnel network.
- clientIP: believed only when the TCP peer is traefik; the rightmost X-Forwarded-For entry (the hop traefik saw);
  the tunnel hop → CF-Connecting-IP (the edge refuses a client-sent one, measured 403). rateKey: IPv6 per /64.
  Dashboard login, claim, share and escrow counters key on it; the setup gate logs it.
- Dashboard login messages: keys, informal voice, both languages.
Red-proofs: RP-A1 (leftmost hop), RP-A2 (shared tunnel key), RP-A3 (no reconcile) — felhom.eu audits/visitors-2026-10-01/A.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-01 21:01:42 +02:00

422 lines
19 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.
const (
TraefikImage = "traefik:v3.6.7"
CloudflaredImage = "cloudflare/cloudflared:2026.6.0"
FileBrowserImage = "gtstef/filebrowser:1.3.3-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.1.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"
)
// 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).
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
networks:
- traefik-public
deploy:
resources:
limits:
memory: 256M
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:80/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
labels:
- "traefik.enable=true"
- "traefik.http.routers.filebrowser.rule=Host(`+"`"+`files.%s`+"`"+`)"
- "traefik.http.routers.filebrowser.entrypoints=websecure"
- "traefik.http.routers.filebrowser.tls=true"
- "traefik.http.services.filebrowser.loadbalancer.server.port=80"
- "traefik.docker.network=traefik-public"
volumes:
filebrowser_data:
networks:
traefik-public:
external: true
`, domain, FileBrowserImage, groupSection, storageSection, domain)
}
// 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). 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, so an app that falls back from X-Forwarded-Host to Host gets the same name.
// Static — no per-customer input. Pinned by TestRenderForwardedHeaders_RemovesClientWritableHeaders.
func RenderForwardedHeaders() string {
return `# Traefik dynamic config — the forwarded-header clean-up every websecure request passes. Managed by felhom-controller.
# WARNING: auto-generated at base-infra bring-up. Manual edits are overwritten. traefik.yml names this middleware 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 + `:
headers:
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: ""
`
}
// 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, "")
}
// RenderFileBrowserConfigKept is RenderFileBrowserConfig plus the read-only kept-data source, LAST,
// named keptLabel ("" = no such source: nothing is kept, or the caller predates it).
func RenderFileBrowserConfigKept(paths []settings.StoragePath, importSource bool, keptLabel string) 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: "/"
database: "/home/filebrowser/data/database.db"
logging:
- levels: "info|warning|error"
sources:
%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:
api: false
admin: false
modify: false
share: false
realtime: false
delete: false
create: false
download: true
`, sources)
}