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) } } }