v0.83.0: Traefik scoped insecure-skip-verify serversTransport for self-signed HTTPS backends (fixes crafty 502)
This commit is contained in:
@@ -1,5 +1,29 @@
|
|||||||
## Changelog
|
## 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)
|
### 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`)
|
- **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
|
previously ran `docker compose up -d --force-recreate --remove-orphans` **unconditionally**, so every
|
||||||
|
|||||||
@@ -210,6 +210,29 @@ http:
|
|||||||
`, domain, tlsBlock)
|
`, 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)
|
||||||
|
}
|
||||||
|
|
||||||
// RenderFileBrowserConfig returns a FileBrowser Quantum config.yaml with one source per registered
|
// 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
|
// storage path (each a named sidebar entry). Empty paths → a single default /srv source. Ported
|
||||||
// verbatim from internal/web/handlers.go.
|
// verbatim from internal/web/handlers.go.
|
||||||
|
|||||||
@@ -47,9 +47,26 @@ func allRendered(t *testing.T) []string {
|
|||||||
out = append(out, RenderFileBrowserCompose("example.com", nil))
|
out = append(out, RenderFileBrowserCompose("example.com", nil))
|
||||||
out = append(out, RenderFileBrowserCompose("example.com", []string{" - /mnt/hdd_1:/srv/hdd_1"}))
|
out = append(out, RenderFileBrowserCompose("example.com", []string{" - /mnt/hdd_1:/srv/hdd_1"}))
|
||||||
out = append(out, RenderFileBrowserConfig(nil))
|
out = append(out, RenderFileBrowserConfig(nil))
|
||||||
|
out = append(out, RenderServersTransports())
|
||||||
return out
|
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 `<name>@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) {
|
func TestNoLatestTagSurvives(t *testing.T) {
|
||||||
for _, c := range []string{TraefikImage, CloudflaredImage, FileBrowserImage} {
|
for _, c := range []string{TraefikImage, CloudflaredImage, FileBrowserImage} {
|
||||||
if strings.HasSuffix(c, ":latest") || !strings.Contains(c, ":") {
|
if strings.HasSuffix(c, ":latest") || !strings.Contains(c, ":") {
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ func (m *Manager) EnsureBaseStack() error {
|
|||||||
errs = append(errs, fmt.Sprintf("traefik: %v", err))
|
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
|
// 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
|
// 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
|
// 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)
|
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
|
// 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.
|
// bootstrap `docker run --name`). traefik resolves it by this name once both share traefik-public.
|
||||||
const controllerContainer = "felhom-controller"
|
const controllerContainer = "felhom-controller"
|
||||||
|
|||||||
Reference in New Issue
Block a user