// 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" ) //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 } // 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 } type traefikTmpl struct { TraefikData Image string } // CloudflaredData is the per-customer input for the cloudflared stack (just the tunnel token). type CloudflaredData struct { CFTunnelToken string } type cloudflaredTmpl struct { CloudflaredData Image 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} 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} 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) string { 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 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, storageSection, domain) } // RenderControllerRoute returns a traefik file-provider dynamic config routing the controller's own // dashboard — Host(felhom.) → 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 // `*.` + 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) } // 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) string { var sources string if len(paths) == 0 { sources = ` - path: "/srv" ` } else { 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) } } 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) }