1e8d045815
- 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
258 lines
11 KiB
Go
258 lines
11 KiB
Go
package stacks
|
|
|
|
import (
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
|
|
)
|
|
|
|
// R-753 (`09` §3 decision 63, Part A): the base stack moves cloudflared onto its own network at a fixed address and
|
|
// makes traefik trust forwarded headers from that address only. These tests drive EnsureBaseStack's pieces against a
|
|
// STUB docker on PATH (never the real one — R-650) and a recorded compose seam, and assert the consequence on disk and
|
|
// in the commands run.
|
|
|
|
// stubDocker writes a fake `docker` into a temp dir on PATH. State lives in files under state/:
|
|
//
|
|
// running-<name> → `docker inspect --format {{.State.Running}} <name>` prints true
|
|
// nets-<name> → the networks `containerOnNetwork` sees (one per line)
|
|
// net-<network> → `docker network inspect --format … <network>` prints the file (else exit 1)
|
|
//
|
|
// `docker network create … --subnet S … <network>` writes net-<network> = S unless state/create-fails exists.
|
|
// Every call is appended to state/calls.
|
|
func stubDocker(t *testing.T) (state string) {
|
|
t.Helper()
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("the stub docker is a shell script")
|
|
}
|
|
dir := t.TempDir()
|
|
state = filepath.Join(dir, "state")
|
|
if err := os.MkdirAll(state, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
script := `#!/bin/sh
|
|
S="` + state + `"
|
|
echo "$*" >> "$S/calls"
|
|
case "$1" in
|
|
inspect)
|
|
last=""; for a in "$@"; do last="$a"; done
|
|
case "$*" in
|
|
*State.Running*) if [ -f "$S/running-$last" ]; then echo true; else echo false; fi; exit 0;;
|
|
*NetworkSettings.Networks*) [ -f "$S/nets-$last" ] && cat "$S/nets-$last"; exit 0;;
|
|
esac;;
|
|
network)
|
|
last=""; for a in "$@"; do last="$a"; done
|
|
case "$2" in
|
|
inspect) if [ -f "$S/net-$last" ]; then cat "$S/net-$last"; exit 0; fi; echo "Error: no such network: $last" >&2; exit 1;;
|
|
create)
|
|
if [ -f "$S/create-fails" ]; then echo "Error response from daemon: Pool overlaps with other one on this address space" >&2; exit 1; fi
|
|
sub=""; prev=""; for a in "$@"; do [ "$prev" = "--subnet" ] && sub="$a"; prev="$a"; done
|
|
if [ -n "$sub" ]; then echo "$sub" > "$S/net-$last"; else echo "auto" > "$S/net-$last"; fi; exit 0;;
|
|
connect) exit 0;;
|
|
esac;;
|
|
esac
|
|
exit 0
|
|
`
|
|
if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
return state
|
|
}
|
|
|
|
func touch(t *testing.T, path, content string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
type composeCall struct {
|
|
dir string
|
|
args string
|
|
}
|
|
|
|
func tunnelTestManager(t *testing.T, email string) (*Manager, *[]composeCall) {
|
|
t.Helper()
|
|
cfg := &config.Config{}
|
|
cfg.Customer.Email = email
|
|
cfg.Infrastructure.CFTunnelToken = "tok-test"
|
|
m := &Manager{cfg: cfg, logger: log.New(io.Discard, "", 0)}
|
|
var calls []composeCall
|
|
m.composeExecFn = func(dir string, env map[string]string, args ...string) (string, error) {
|
|
calls = append(calls, composeCall{dir: dir, args: strings.Join(args, " ")})
|
|
return "", nil
|
|
}
|
|
return m, &calls
|
|
}
|
|
|
|
// The network is created with its FIXED subnet — the address traefik trusts must be one docker cannot hand elsewhere.
|
|
func TestEnsureTunnelNetwork_CreatesFixedSubnet(t *testing.T) {
|
|
state := stubDocker(t)
|
|
m, _ := tunnelTestManager(t, "")
|
|
if err := m.ensureTunnelNetwork(); err != nil {
|
|
t.Fatalf("ensureTunnelNetwork: %v", err)
|
|
}
|
|
got, _ := os.ReadFile(filepath.Join(state, "net-"+infra.TunnelNetwork))
|
|
if strings.TrimSpace(string(got)) != infra.TunnelSubnet {
|
|
t.Fatalf("network created with subnet %q, want %s", got, infra.TunnelSubnet)
|
|
}
|
|
calls, _ := os.ReadFile(filepath.Join(state, "calls"))
|
|
if !strings.Contains(string(calls), "--gateway "+infra.TunnelGateway) || !strings.Contains(string(calls), "--ip-range "+infra.TunnelIPRange) {
|
|
t.Fatalf("create did not fix the gateway: %s", calls)
|
|
}
|
|
}
|
|
|
|
// A network of that name with another subnet is NOT trusted and NOT touched: an error, and the caller keeps the old shape.
|
|
func TestEnsureTunnelNetwork_WrongSubnetIsAnError(t *testing.T) {
|
|
state := stubDocker(t)
|
|
touch(t, filepath.Join(state, "net-"+infra.TunnelNetwork), "172.30.0.0/16\n")
|
|
m, _ := tunnelTestManager(t, "")
|
|
err := m.ensureTunnelNetwork()
|
|
if err == nil || !strings.Contains(err.Error(), "172.30.0.0/16") {
|
|
t.Fatalf("want an error naming the wrong subnet, got %v", err)
|
|
}
|
|
calls, _ := os.ReadFile(filepath.Join(state, "calls"))
|
|
if strings.Contains(string(calls), "network create") || strings.Contains(string(calls), " rm ") {
|
|
t.Fatalf("a wrong-subnet network must be left alone; calls: %s", calls)
|
|
}
|
|
}
|
|
|
|
// THE CONSEQUENCE on a box that already runs traefik (every installed box): the new release rewrites traefik.yml with the
|
|
// tunnel trust and RECREATES traefik (static config is read only at start). A second tick with nothing changed does nothing.
|
|
func TestEnsureTraefik_ReconcilesARunningTraefik(t *testing.T) {
|
|
state := stubDocker(t)
|
|
touch(t, filepath.Join(state, "running-traefik"), "")
|
|
m, calls := tunnelTestManager(t, "owner@example.com")
|
|
dir := t.TempDir()
|
|
old, err := infra.RenderTraefik(infra.TraefikData{ACMEEmail: "owner@example.com"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// the pre-R-753 file: no trust, no middleware
|
|
oldYML := strings.Replace(old["traefik.yml"].Content, " http:\n middlewares:\n - "+infra.ForwardedMiddleware+"@file\n", " http:\n", 1)
|
|
touch(t, filepath.Join(dir, "traefik.yml"), oldYML)
|
|
touch(t, filepath.Join(dir, "docker-compose.yml"), old["docker-compose.yml"].Content)
|
|
|
|
if err := m.ensureTraefik(dir, true); err != nil {
|
|
t.Fatalf("ensureTraefik: %v", err)
|
|
}
|
|
yml, _ := os.ReadFile(filepath.Join(dir, "traefik.yml"))
|
|
if !strings.Contains(string(yml), `- "`+infra.TunnelAddr+`/32"`) {
|
|
t.Fatalf("traefik.yml was not rewritten with the tunnel trust:\n%s", yml)
|
|
}
|
|
cmp, _ := os.ReadFile(filepath.Join(dir, "docker-compose.yml"))
|
|
if !strings.Contains(string(cmp), "ipv4_address: "+infra.TunnelTraefikAddr) {
|
|
t.Fatalf("traefik's compose does not join %s:\n%s", infra.TunnelNetwork, cmp)
|
|
}
|
|
if len(*calls) != 1 || (*calls)[0].args != "up -d --force-recreate" {
|
|
t.Fatalf("want ONE `up -d --force-recreate`, got %+v", *calls)
|
|
}
|
|
if err := m.ensureTraefik(dir, true); err != nil {
|
|
t.Fatalf("second ensureTraefik: %v", err)
|
|
}
|
|
if len(*calls) != 1 {
|
|
t.Fatalf("an unchanged config must not recreate traefik again; calls %+v", *calls)
|
|
}
|
|
}
|
|
|
|
// A rewrite that would DROP the certificate resolver the running file has is refused (no e-mail in the config).
|
|
func TestEnsureTraefik_RefusesToDropTheCertResolver(t *testing.T) {
|
|
state := stubDocker(t)
|
|
touch(t, filepath.Join(state, "running-traefik"), "")
|
|
m, calls := tunnelTestManager(t, "") // no customer e-mail → the render has no resolver
|
|
dir := t.TempDir()
|
|
withACME, _ := infra.RenderTraefik(infra.TraefikData{ACMEEmail: "owner@example.com"})
|
|
touch(t, filepath.Join(dir, "traefik.yml"), withACME["traefik.yml"].Content)
|
|
if err := m.ensureTraefik(dir, true); err != nil {
|
|
t.Fatalf("ensureTraefik: %v", err)
|
|
}
|
|
yml, _ := os.ReadFile(filepath.Join(dir, "traefik.yml"))
|
|
if string(yml) != withACME["traefik.yml"].Content || len(*calls) != 0 {
|
|
t.Fatalf("the running file with a resolver must be left alone; calls %+v", *calls)
|
|
}
|
|
}
|
|
|
|
// cloudflared moves to the tunnel network at the fixed address — and is recreated by compose — only when asked.
|
|
func TestEnsureCloudflared_MovesToTheTunnelNetwork(t *testing.T) {
|
|
state := stubDocker(t)
|
|
touch(t, filepath.Join(state, "running-cloudflared"), "")
|
|
m, calls := tunnelTestManager(t, "")
|
|
dir := t.TempDir()
|
|
old, _ := infra.RenderCloudflared(infra.CloudflaredData{CFTunnelToken: "tok-test"})
|
|
touch(t, filepath.Join(dir, "docker-compose.yml"), old["docker-compose.yml"].Content)
|
|
|
|
if err := m.ensureCloudflared(dir, false); err != nil || len(*calls) != 0 {
|
|
t.Fatalf("unchanged old shape must do nothing; err %v calls %+v", err, *calls)
|
|
}
|
|
if err := m.ensureCloudflared(dir, true); err != nil {
|
|
t.Fatalf("ensureCloudflared: %v", err)
|
|
}
|
|
cmp, _ := os.ReadFile(filepath.Join(dir, "docker-compose.yml"))
|
|
if !strings.Contains(string(cmp), "ipv4_address: "+infra.TunnelAddr) || strings.Contains(string(cmp), "traefik-public") {
|
|
t.Fatalf("cloudflared must be ALONE on %s at %s:\n%s", infra.TunnelNetwork, infra.TunnelAddr, cmp)
|
|
}
|
|
if len(*calls) != 1 || (*calls)[0].args != "up -d" {
|
|
t.Fatalf("want one `up -d`, got %+v", *calls)
|
|
}
|
|
}
|
|
|
|
// The whole bring-up, in order: network → the header clean-up file → traefik (recreated with the trust) → cloudflared
|
|
// moved only because traefik is on the tunnel network. And when the network cannot be made, NOTHING moves and traefik
|
|
// keeps trusting nobody.
|
|
func TestEnsureBaseStack_TunnelOrder(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
createFail bool
|
|
}{{"network made", false}, {"network refused", true}} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
state := stubDocker(t)
|
|
touch(t, filepath.Join(state, "net-traefik-public"), "172.18.0.0/16\n")
|
|
for _, c := range []string{"traefik", "cloudflared", "filebrowser", "felhom-controller"} {
|
|
touch(t, filepath.Join(state, "running-"+c), "")
|
|
}
|
|
if tc.createFail {
|
|
touch(t, filepath.Join(state, "create-fails"), "")
|
|
touch(t, filepath.Join(state, "nets-traefik"), "traefik-public\n")
|
|
} else {
|
|
touch(t, filepath.Join(state, "nets-traefik"), "traefik-public\n"+infra.TunnelNetwork+"\n")
|
|
}
|
|
touch(t, filepath.Join(state, "nets-felhom-controller"), "traefik-public\n")
|
|
m, calls := tunnelTestManager(t, "owner@example.com")
|
|
m.cfg.Paths.StacksDir = t.TempDir()
|
|
_ = m.EnsureBaseStack()
|
|
|
|
traefikDir := filepath.Join(m.cfg.Paths.StacksDir, "traefik")
|
|
if _, err := os.Stat(filepath.Join(traefikDir, "dynamic", "forwarded.yml")); err != nil {
|
|
t.Fatalf("the forwarded-header file must be written in either case: %v", err)
|
|
}
|
|
yml, _ := os.ReadFile(filepath.Join(traefikDir, "traefik.yml"))
|
|
cf, _ := os.ReadFile(filepath.Join(m.cfg.Paths.StacksDir, "cloudflared", "docker-compose.yml"))
|
|
trusts := strings.Contains(string(yml), "trustedIPs")
|
|
moved := strings.Contains(string(cf), "ipv4_address: "+infra.TunnelAddr)
|
|
if tc.createFail {
|
|
if trusts || moved {
|
|
t.Fatalf("no network → no trust and no move; trust %v moved %v", trusts, moved)
|
|
}
|
|
return
|
|
}
|
|
if !trusts || !moved {
|
|
t.Fatalf("network made → traefik trusts the tunnel and cloudflared moved; trust %v moved %v", trusts, moved)
|
|
}
|
|
var order []string
|
|
for _, c := range *calls {
|
|
order = append(order, filepath.Base(c.dir)+":"+c.args)
|
|
}
|
|
if len(order) < 2 || order[0] != "traefik:up -d --force-recreate" || order[1] != "cloudflared:up -d" {
|
|
t.Fatalf("traefik must be recreated BEFORE cloudflared moves; compose calls %v", order)
|
|
}
|
|
})
|
|
}
|
|
}
|