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- → `docker inspect --format {{.State.Running}} ` prints true // nets- → the networks `containerOnNetwork` sees (one per line) // net- → `docker network inspect --format … ` prints the file (else exit 1) // // `docker network create … --subnet S … ` writes net- = 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) } }) } } // R-838: a release that raises FileBrowserImage reaches a RUNNING file browser — only the image line changes (the // storage mounts SyncFileBrowserMounts wrote stay byte-for-byte) and it is recreated once. Red-proof: make // ensureFileBrowser return nil for a running container again and the first sub-step fails. func TestReconcileFileBrowserImage_MovesOnlyTheImage(t *testing.T) { state := stubDocker(t) touch(t, filepath.Join(state, "running-filebrowser"), "") m, calls := tunnelTestManager(t, "") dir := t.TempDir() old := strings.Replace(infra.RenderFileBrowserCompose("example.hu", []string{" - /mnt/felhom-drives/d1/userdata:/srv/d1"}), infra.FileBrowserImage, "gtstef/filebrowser:1.3.3-stable", 1) touch(t, filepath.Join(dir, "docker-compose.yml"), old) if err := m.ensureFileBrowser(dir); err != nil { t.Fatal(err) } got, _ := os.ReadFile(filepath.Join(dir, "docker-compose.yml")) want := strings.Replace(old, "gtstef/filebrowser:1.3.3-stable", infra.FileBrowserImage, 1) if string(got) != want { t.Fatalf("compose after the move:\n%s\nwant only the image line changed:\n%s", got, want) } if len(*calls) != 1 || (*calls)[0].args != "up -d" { t.Fatalf("want one `up -d`, got %+v", *calls) } // Same image → nothing. if err := m.ensureFileBrowser(dir); err != nil || len(*calls) != 1 { t.Fatalf("an up-to-date file browser was touched: err %v calls %+v", err, *calls) } } // R-841: the cloudflared compose carries a health check that asks cloudflared whether the tunnel is CONNECTED, and // pins the metrics address it asks. Red-proof: drop the healthcheck block from the template and this fails. func TestRenderCloudflared_HasAReadinessHealthcheck(t *testing.T) { for _, tunnel := range []bool{false, true} { f, err := infra.RenderCloudflared(infra.CloudflaredData{CFTunnelToken: "tok", Tunnel: tunnel}) if err != nil { t.Fatal(err) } c := f["docker-compose.yml"].Content for _, want := range []string{ "command: tunnel --metrics localhost:20241 run", `test: ["CMD", "cloudflared", "tunnel", "--metrics", "localhost:20241", "ready"]`, } { if !strings.Contains(c, want) { t.Fatalf("tunnel=%v: compose lacks %q:\n%s", tunnel, want, c) } } } }