91736eb015
EnsureBaseStack now writes a traefik file-provider route (Host(felhom.<domain>) -> http://felhom-controller:8080) and joins the controller to traefik-public. Done post-pull (domain known) and idempotently (write-if-changed + skip-if-connected), so felhom.<domain> reaches the controller. Completes the v0.41.0 base-infra bring-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
247 lines
8.3 KiB
Go
247 lines
8.3 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"
|
|
)
|
|
|
|
//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.
|
|
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.<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. `tls: {}`
|
|
// inherits the websecure entrypoint's default certResolver (letsencrypt) when ACME is configured, and
|
|
// otherwise falls back to traefik's default self-signed cert.
|
|
func RenderControllerRoute(domain string) string {
|
|
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
|
|
tls: {}
|
|
services:
|
|
felhom-controller:
|
|
loadBalancer:
|
|
servers:
|
|
- url: "http://felhom-controller:8080"
|
|
`, domain)
|
|
}
|
|
|
|
// 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)
|
|
}
|