From 2d6df0f6d5700cb50f49a5509031c2fa579a83af Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Fri, 26 Jun 2026 10:28:58 +0200 Subject: [PATCH] v0.83.0: Traefik scoped insecure-skip-verify serversTransport for self-signed HTTPS backends (fixes crafty 502) --- CHANGELOG.md | 24 ++++++++++++++++++++++ controller/internal/infra/infra.go | 23 +++++++++++++++++++++ controller/internal/infra/infra_test.go | 17 ++++++++++++++++ controller/internal/stacks/infra.go | 27 +++++++++++++++++++++++++ 4 files changed, 91 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02e780d..d1dd048 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ ## Changelog +### v0.83.0 — Traefik scoped serversTransport for self-signed HTTPS backends (fixes crafty 502) (2026-06-26) +- **Problem:** the crafty-controller healthcheck fix (catalog `68ce009`) un-withheld its Traefik route, + exposing a pre-existing 502 — Traefik proxied **HTTP** to Crafty's **HTTPS-only** self-signed backend + on `:8443`. Crafty is the first/only catalog app with an HTTPS backend; all others serve plain HTTP, so + Traefik's default HTTP transport works for them. +- **Fix (scoped, Option B — verification stays ON by default):** the controller now renders a Traefik + file-provider dynamic config defining a **named** `insecure-skip-verify` serversTransport + (`http.serversTransports.insecure-skip-verify.insecureSkipVerify: true`). A service opts out of backend + TLS verification only by referencing it (`serverstransport=insecure-skip-verify@file`) — no global + `insecureSkipVerify` (the rejected Option A). `insecureSkipVerify` is not settable via Docker labels in + Traefik v3, so it must live in file/static config; the matching `scheme=https` + `@file` reference + labels go on the app (catalog repo). + - `internal/infra/infra.go`: new pure `RenderServersTransports()` + exported `ServersTransportInsecure` + constant. + - `internal/stacks/infra.go`: new `ensureServersTransports(traefikDir)` writes + `dynamic/serverstransports.yml` (0644) idempotently (write-only-on-change, like `wireController`, so + the traefik file-watcher doesn't reload each self-heal tick). Called from `EnsureBaseStack` **outside** + `ensureTraefik` (which early-returns when traefik is already running) so an established node still + materializes the file on the next self-heal tick; the watcher hot-loads it (no traefik restart). + - Rationale for skip-verify: a per-container self-signed cert has no CA to verify against and the hop + never leaves the host's internal docker bridge. +- Paired with `app-catalog-felhom.eu` adding `scheme=https` + `serverstransport=insecure-skip-verify@file` + to the crafty service. Tests: `TestServersTransports` + the YAML-parse matrix. + ### v0.82.0 — FileBrowser sync no longer bounces the file UI on no-op; drop dead restic binary (2026-06-24) - **F2 — gate the FileBrowser recreate on an actual change.** `syncFileBrowserMounts` (`internal/web/handlers.go`) previously ran `docker compose up -d --force-recreate --remove-orphans` **unconditionally**, so every diff --git a/controller/internal/infra/infra.go b/controller/internal/infra/infra.go index ad762eb..a6348ca 100644 --- a/controller/internal/infra/infra.go +++ b/controller/internal/infra/infra.go @@ -210,6 +210,29 @@ http: `, domain, tlsBlock) } +// ServersTransportInsecure is the name of the traefik dynamic serversTransport that skips backend TLS +// verification. App services reference it by `@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) +} + // 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. diff --git a/controller/internal/infra/infra_test.go b/controller/internal/infra/infra_test.go index a3c91f6..1eb5edf 100644 --- a/controller/internal/infra/infra_test.go +++ b/controller/internal/infra/infra_test.go @@ -47,9 +47,26 @@ func allRendered(t *testing.T) []string { out = append(out, RenderFileBrowserCompose("example.com", nil)) out = append(out, RenderFileBrowserCompose("example.com", []string{" - /mnt/hdd_1:/srv/hdd_1"})) out = append(out, RenderFileBrowserConfig(nil)) + out = append(out, RenderServersTransports()) return out } +func TestServersTransports(t *testing.T) { + s := RenderServersTransports() + // The named transport must be defined under http.serversTransports with insecureSkipVerify, so a + // docker-provider service can reference it as `@file`. + if !strings.Contains(s, "serversTransports:") || !strings.Contains(s, ServersTransportInsecure+":") { + t.Errorf("named serversTransport %q missing:\n%s", ServersTransportInsecure, s) + } + if !strings.Contains(s, "insecureSkipVerify: true") { + t.Errorf("insecureSkipVerify not set on the named transport:\n%s", s) + } + var v any + if err := yaml.Unmarshal([]byte(s), &v); err != nil { + t.Fatalf("serversTransports config is not valid YAML: %v\n%s", err, s) + } +} + func TestNoLatestTagSurvives(t *testing.T) { for _, c := range []string{TraefikImage, CloudflaredImage, FileBrowserImage} { if strings.HasSuffix(c, ":latest") || !strings.Contains(c, ":") { diff --git a/controller/internal/stacks/infra.go b/controller/internal/stacks/infra.go index 1894470..7e53c8c 100644 --- a/controller/internal/stacks/infra.go +++ b/controller/internal/stacks/infra.go @@ -43,6 +43,13 @@ func (m *Manager) EnsureBaseStack() error { errs = append(errs, fmt.Sprintf("traefik: %v", err)) } + // Write the backend-transports dynamic file. Done OUTSIDE ensureTraefik (which early-returns when + // traefik is already running) so a self-heal tick on an established node still materializes it. + // The traefik file watcher hot-loads it — no restart needed. + if err := m.ensureServersTransports(traefikDir); err != nil { + errs = append(errs, fmt.Sprintf("servers-transports: %v", err)) + } + // Wire the controller's OWN dashboard route into traefik. Unlike filebrowser (which self-registers // via Docker labels + network membership baked into its compose), the controller is started by the // golden bootstrap before traefik-public exists and the v2 bootstrap carries no domain — so it can't @@ -147,6 +154,26 @@ func (m *Manager) ensureFileBrowser(dir string) error { return m.composeUp(dir) } +// ensureServersTransports writes the traefik file-provider dynamic config defining named backend +// transports (the insecure-skip-verify transport for self-signed HTTPS backends like Crafty). Like +// wireController, it writes only when the content changes so the traefik file watcher doesn't reload +// on every self-heal tick. Idempotent and cheap. +func (m *Manager) ensureServersTransports(traefikDir string) error { + dynDir := filepath.Join(traefikDir, "dynamic") + if err := os.MkdirAll(dynDir, 0o755); err != nil { + return fmt.Errorf("mkdir dynamic: %w", err) + } + path := filepath.Join(dynDir, "serverstransports.yml") + want := infra.RenderServersTransports() + if cur, err := os.ReadFile(path); err != nil || string(cur) != want { + if err := os.WriteFile(path, []byte(want), 0o644); err != nil { + return fmt.Errorf("write servers-transports: %w", err) + } + m.logger.Printf("[INFO] [infra] wrote backend transports → %s (%s)", path, infra.ServersTransportInsecure) + } + return nil +} + // controllerContainer is the fixed name of the in-guest controller container (set by the golden // bootstrap `docker run --name`). traefik resolves it by this name once both share traefik-public. const controllerContainer = "felhom-controller"