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
This commit is contained in:
@@ -71,6 +71,25 @@ type FileSpec struct {
|
||||
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:
|
||||
@@ -79,21 +98,34 @@ type FileSpec struct {
|
||||
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
|
||||
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
|
||||
Image string
|
||||
TunnelNetwork string
|
||||
TunnelAddr string
|
||||
}
|
||||
|
||||
func render(name string, data any) (string, error) {
|
||||
@@ -108,7 +140,8 @@ func render(name string, data any) (string, error) {
|
||||
// — 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}
|
||||
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
|
||||
@@ -132,7 +165,7 @@ func RenderTraefik(d TraefikData) (map[string]FileSpec, error) {
|
||||
// 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}
|
||||
cd := cloudflaredTmpl{CloudflaredData: d, Image: CloudflaredImage, TunnelNetwork: TunnelNetwork, TunnelAddr: TunnelAddr}
|
||||
compose, err := render("cloudflared-compose.yml.tmpl", cd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -275,6 +308,41 @@ http:
|
||||
`, 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.
|
||||
|
||||
@@ -14,9 +14,20 @@ services:
|
||||
- 8.8.8.8
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
{{- if .Tunnel}}
|
||||
# R-753: alone on felhom-tunnel at a fixed address — the one peer traefik believes forwarded headers from.
|
||||
networks:
|
||||
{{.TunnelNetwork}}:
|
||||
ipv4_address: {{.TunnelAddr}}
|
||||
|
||||
networks:
|
||||
{{.TunnelNetwork}}:
|
||||
external: true
|
||||
{{- else}}
|
||||
networks:
|
||||
- traefik-public
|
||||
|
||||
networks:
|
||||
traefik-public:
|
||||
external: true
|
||||
{{- end}}
|
||||
|
||||
@@ -22,9 +22,20 @@ services:
|
||||
- ./dynamic:/etc/traefik/dynamic:ro
|
||||
- ./acme.json:/etc/traefik/acme.json
|
||||
- ./certs:/etc/traefik/certs:ro
|
||||
{{- if .Tunnel}}
|
||||
networks:
|
||||
traefik-public: {}
|
||||
{{.TunnelNetwork}}:
|
||||
ipv4_address: {{.TunnelTraefikAddr}}
|
||||
{{- else}}
|
||||
networks:
|
||||
- traefik-public
|
||||
{{- end}}
|
||||
|
||||
networks:
|
||||
traefik-public:
|
||||
external: true
|
||||
{{- if .Tunnel}}
|
||||
{{.TunnelNetwork}}:
|
||||
external: true
|
||||
{{- end}}
|
||||
|
||||
@@ -15,8 +15,16 @@ entryPoints:
|
||||
scheme: https
|
||||
websecure:
|
||||
address: ":443"
|
||||
{{- if .ACMEEmail}}
|
||||
{{- if .Tunnel}}
|
||||
# R-753: believe X-Forwarded-* only from cloudflared's fixed address on felhom-tunnel; every other peer's are dropped.
|
||||
forwardedHeaders:
|
||||
trustedIPs:
|
||||
- "{{.TunnelAddr}}/32"
|
||||
{{- end}}
|
||||
http:
|
||||
middlewares:
|
||||
- {{.ForwardedMiddleware}}@file
|
||||
{{- if .ACMEEmail}}
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
{{- end}}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// R-753: the fixed tunnel address must sit inside its subnet, apart from the gateway, inside 172.16.0.0/12 (so apps that
|
||||
// count private addresses as proxies skip it) and OUTSIDE docker's default pools (172.17.0.0/16 … 172.31.0.0/16,
|
||||
// 192.168.0.0/16), so docker never gives it to an app network.
|
||||
func TestTunnelConstantsAgree(t *testing.T) {
|
||||
_, sub, err := net.ParseCIDR(TunnelSubnet)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr, gw, tr := net.ParseIP(TunnelAddr), net.ParseIP(TunnelGateway), net.ParseIP(TunnelTraefikAddr)
|
||||
if !sub.Contains(addr) || !sub.Contains(gw) || !sub.Contains(tr) || addr.Equal(gw) || addr.Equal(tr) || tr.Equal(gw) {
|
||||
t.Fatalf("cloudflared %s / traefik %s / gateway %s must be inside %s and differ", TunnelAddr, TunnelTraefikAddr, TunnelGateway, TunnelSubnet)
|
||||
}
|
||||
// docker's own allocation (TunnelIPRange) must never reach the two fixed addresses — measured: traefik joining
|
||||
// first was given .2 by the allocator when no range was set.
|
||||
_, rng, err := net.ParseCIDR(TunnelIPRange)
|
||||
if err != nil || rng.Contains(addr) || rng.Contains(tr) || !sub.Contains(rng.IP) {
|
||||
t.Fatalf("ip-range %s must lie in %s and exclude %s and %s", TunnelIPRange, TunnelSubnet, TunnelAddr, TunnelTraefikAddr)
|
||||
}
|
||||
_, private, _ := net.ParseCIDR("172.16.0.0/12")
|
||||
if !private.Contains(addr) {
|
||||
t.Fatalf("%s is not in 172.16.0.0/12", TunnelAddr)
|
||||
}
|
||||
for _, pool := range []string{"172.17.0.0/16", "172.18.0.0/16", "172.24.0.0/16", "172.31.0.0/16", "192.168.0.0/16"} {
|
||||
_, p, _ := net.ParseCIDR(pool)
|
||||
if p.Contains(sub.IP) {
|
||||
t.Fatalf("%s overlaps docker's default pool %s", TunnelSubnet, pool)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func staticEntry(t *testing.T, yml string) map[string]any {
|
||||
t.Helper()
|
||||
var doc map[string]any
|
||||
if err := yaml.Unmarshal([]byte(yml), &doc); err != nil {
|
||||
t.Fatalf("traefik.yml is not YAML: %v\n%s", err, yml)
|
||||
}
|
||||
return doc["entryPoints"].(map[string]any)["websecure"].(map[string]any)
|
||||
}
|
||||
|
||||
// traefik believes forwarded headers from EXACTLY the tunnel address — never `insecure`, never a range — and every
|
||||
// websecure request passes the clean-up middleware. Without the network (Tunnel false) it trusts nobody.
|
||||
func TestRenderTraefik_TrustsOnlyTheTunnel(t *testing.T) {
|
||||
for _, email := range []string{"", "owner@example.com"} {
|
||||
on, err := RenderTraefik(TraefikData{ACMEEmail: email, Tunnel: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ws := staticEntry(t, on["traefik.yml"].Content)
|
||||
fh, ok := ws["forwardedHeaders"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("Tunnel: no forwardedHeaders on websecure:\n%s", on["traefik.yml"].Content)
|
||||
}
|
||||
ips, _ := fh["trustedIPs"].([]any)
|
||||
if len(ips) != 1 || ips[0] != TunnelAddr+"/32" || fh["insecure"] != nil {
|
||||
t.Fatalf("Tunnel: trust must be exactly [%s/32], got %v (insecure %v)", TunnelAddr, ips, fh["insecure"])
|
||||
}
|
||||
mw := ws["http"].(map[string]any)["middlewares"].([]any)
|
||||
if len(mw) != 1 || mw[0] != ForwardedMiddleware+"@file" {
|
||||
t.Fatalf("websecure middlewares = %v", mw)
|
||||
}
|
||||
if (email != "") != strings.Contains(on["traefik.yml"].Content, "certResolver: letsencrypt") {
|
||||
t.Fatalf("the resolver must follow the e-mail (%q)", email)
|
||||
}
|
||||
var cdoc map[string]any
|
||||
if err := yaml.Unmarshal([]byte(on["docker-compose.yml"].Content), &cdoc); err != nil {
|
||||
t.Fatalf("traefik compose is not YAML: %v", err)
|
||||
}
|
||||
tn := cdoc["services"].(map[string]any)["traefik"].(map[string]any)["networks"].(map[string]any)
|
||||
if _, ok := tn["traefik-public"]; !ok || tn[TunnelNetwork].(map[string]any)["ipv4_address"] != TunnelTraefikAddr {
|
||||
t.Fatalf("Tunnel: traefik must keep traefik-public and sit at %s on %s, got %v", TunnelTraefikAddr, TunnelNetwork, tn)
|
||||
}
|
||||
if cdoc["networks"].(map[string]any)[TunnelNetwork].(map[string]any)["external"] != true {
|
||||
t.Fatalf("Tunnel: %s must be external", TunnelNetwork)
|
||||
}
|
||||
|
||||
off, _ := RenderTraefik(TraefikData{ACMEEmail: email})
|
||||
ws = staticEntry(t, off["traefik.yml"].Content)
|
||||
if ws["forwardedHeaders"] != nil || strings.Contains(off["docker-compose.yml"].Content, TunnelNetwork) {
|
||||
t.Fatalf("no tunnel network → no trust and no network:\n%s", off["traefik.yml"].Content)
|
||||
}
|
||||
if mw := ws["http"].(map[string]any)["middlewares"].([]any); len(mw) != 1 {
|
||||
t.Fatalf("the clean-up middleware applies without the tunnel too, got %v", mw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCloudflared_AloneOnTheTunnel(t *testing.T) {
|
||||
on, _ := RenderCloudflared(CloudflaredData{CFTunnelToken: "t", Tunnel: true})
|
||||
c := on["docker-compose.yml"].Content
|
||||
var doc map[string]any
|
||||
if err := yaml.Unmarshal([]byte(c), &doc); err != nil {
|
||||
t.Fatalf("compose is not YAML: %v\n%s", err, c)
|
||||
}
|
||||
nets := doc["services"].(map[string]any)["cloudflared"].(map[string]any)["networks"].(map[string]any)
|
||||
if len(nets) != 1 || nets[TunnelNetwork].(map[string]any)["ipv4_address"] != TunnelAddr {
|
||||
t.Fatalf("cloudflared must be ONLY on %s at %s, got %v", TunnelNetwork, TunnelAddr, nets)
|
||||
}
|
||||
off, _ := RenderCloudflared(CloudflaredData{CFTunnelToken: "t"})
|
||||
if strings.Contains(off["docker-compose.yml"].Content, TunnelNetwork) {
|
||||
t.Fatal("without Tunnel the old shape (traefik-public) must be kept")
|
||||
}
|
||||
}
|
||||
|
||||
// The clean-up removes every header a client could write a host, a path or an address into, fixes the port, and leaves
|
||||
// alone the three the readers need: X-Forwarded-For (read from the right), X-Real-Ip (traefik's peer) and
|
||||
// CF-Connecting-IP (read only when the hop is the tunnel).
|
||||
func TestRenderForwardedHeaders_RemovesClientWritableHeaders(t *testing.T) {
|
||||
var doc map[string]any
|
||||
if err := yaml.Unmarshal([]byte(RenderForwardedHeaders()), &doc); err != nil {
|
||||
t.Fatalf("not YAML: %v", err)
|
||||
}
|
||||
h := doc["http"].(map[string]any)["middlewares"].(map[string]any)[ForwardedMiddleware].(map[string]any)["headers"].(map[string]any)["customRequestHeaders"].(map[string]any)
|
||||
for _, name := range []string{"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"} {
|
||||
if v, ok := h[name]; !ok || v != "" {
|
||||
t.Errorf("%s must be removed (empty value), got %v present=%v", name, v, ok)
|
||||
}
|
||||
}
|
||||
if h["X-Forwarded-Port"] != "443" {
|
||||
t.Errorf("X-Forwarded-Port must be fixed to 443, got %v", h["X-Forwarded-Port"])
|
||||
}
|
||||
for _, keep := range []string{"X-Forwarded-For", "X-Real-Ip", "CF-Connecting-IP", "Cf-Connecting-Ip", "X-Forwarded-Proto"} {
|
||||
if _, ok := h[keep]; ok {
|
||||
t.Errorf("%s must NOT be touched", keep)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user