Files
felhom-controller/controller/internal/infra/tunnel_test.go
T
admin 8bc7ec54cd
gates / gates (push) Successful in 48s
v0.312.0: every public name answers only over https; HSTS one day (R-1033, P1)
forwarded.yml: felhom-forwarded is now a chain [felhom-https-only (redirectScheme https, 301 when the trusted tunnel
says X-Forwarded-Proto: http), felhom-forwarded-headers (the clean-up + HSTS 86400, no subdomains, no preload)] plus a
priority-1 catch-all router for unknown names over http. traefik.yml unchanged; hot-loaded.
Tests red-proofed. Evidence: felhom.eu documentation/audits/https-only-2026-10-14/.
2026-10-14 22:39:58 +02:00

181 lines
8.9 KiB
Go

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)[ForwardedHeadersMiddleware].(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)
}
}
}
// R-1033 (P1): every websecure request passes the https-only redirect FIRST, then the clean-up + HSTS — read through the
// chain the entrypoint names, so a chain that drops either half (or names a middleware that is not defined) fails here.
// The CONSEQUENCE (an http visitor gets 301 to the same https URL, an https or LAN visitor is untouched, a forged header
// from an untrusted peer is ignored) was measured with traefik v3.7.13 on the bench: audits/https-only-2026-10-14/1A-6.
func TestRenderForwardedHeaders_HTTPSOnly(t *testing.T) {
var doc map[string]any
if err := yaml.Unmarshal([]byte(RenderForwardedHeaders()), &doc); err != nil {
t.Fatalf("not YAML: %v", err)
}
mws := doc["http"].(map[string]any)["middlewares"].(map[string]any)
chain, ok := mws[ForwardedMiddleware].(map[string]any)["chain"].(map[string]any)
if !ok {
t.Fatalf("%s must be a chain, got %v", ForwardedMiddleware, mws[ForwardedMiddleware])
}
parts, _ := chain["middlewares"].([]any)
if len(parts) != 2 || parts[0] != HTTPSOnlyMiddleware || parts[1] != ForwardedHeadersMiddleware {
t.Fatalf("the chain must be [%s, %s] in that order, got %v", HTTPSOnlyMiddleware, ForwardedHeadersMiddleware, parts)
}
for _, p := range parts {
if _, ok := mws[p.(string)]; !ok {
t.Errorf("the chain names %v but the file does not define it — every route on the box would break", p)
}
}
rs, ok := mws[HTTPSOnlyMiddleware].(map[string]any)["redirectScheme"].(map[string]any)
if !ok || rs["scheme"] != "https" || rs["permanent"] != true {
t.Errorf("%s must be redirectScheme{scheme: https, permanent: true}, got %v", HTTPSOnlyMiddleware, mws[HTTPSOnlyMiddleware])
}
h := mws[ForwardedHeadersMiddleware].(map[string]any)["headers"].(map[string]any)
if h["stsSeconds"] != HSTSMaxAgeSeconds || HSTSMaxAgeSeconds != 86400 {
t.Errorf("HSTS must be one day (86400) until the operator rules otherwise, got %v", h["stsSeconds"])
}
if h["stsIncludeSubdomains"] != false || h["stsPreload"] != false {
t.Errorf("HSTS must not include subdomains nor preload, got %v / %v", h["stsIncludeSubdomains"], h["stsPreload"])
}
r, ok := doc["http"].(map[string]any)["routers"].(map[string]any)[HTTPSOnlyCatchAllRouter].(map[string]any)
if !ok {
t.Fatalf("the catch-all router for unknown names over http is missing")
}
if r["rule"] != "Header(`X-Forwarded-Proto`, `http`)" || r["priority"] != 1 || r["service"] != "noop@internal" {
t.Errorf("catch-all must match ONLY X-Forwarded-Proto: http, at priority 1, to noop@internal; got %v", r)
}
}