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:
2026-10-01 21:01:42 +02:00
parent c1b123c649
commit 1e8d045815
19 changed files with 1006 additions and 83 deletions
+130 -20
View File
@@ -5,6 +5,7 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
"os"
"path/filepath"
"sort"
"strings"
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
@@ -39,7 +40,24 @@ func (m *Manager) EnsureBaseStack() error {
traefikDir := filepath.Join(base, "traefik")
var errs []string
if err := m.ensureTraefik(traefikDir); err != nil {
// R-753: the tunnel's own network, with cloudflared at a fixed address. Without it traefik and cloudflared keep the
// old shape (traefik trusts nothing) — the box still routes; it just cannot tell tunnel visitors apart.
tunnelOK := true
if err := m.ensureTunnelNetwork(); err != nil {
tunnelOK = false
errs = append(errs, fmt.Sprintf("tunnel network: %v", err))
}
// The forwarded-header clean-up BEFORE traefik.yml: the entrypoint names it, and a missing middleware would break
// every route on the box.
fwdOK := true
if err := m.ensureForwardedHeaders(traefikDir); err != nil {
fwdOK = false
errs = append(errs, fmt.Sprintf("forwarded headers: %v", err))
}
if !fwdOK {
m.logger.Printf("[WARN] [infra] traefik left as it is — its forwarded-header file could not be written")
} else if err := m.ensureTraefik(traefikDir, tunnelOK); err != nil {
errs = append(errs, fmt.Sprintf("traefik: %v", err))
}
@@ -60,7 +78,13 @@ func (m *Manager) EnsureBaseStack() error {
}
if m.cfg.Infrastructure.CFTunnelToken != "" {
if err := m.ensureCloudflared(filepath.Join(base, "cloudflared")); err != nil {
// cloudflared moves to the tunnel network only once traefik is on it (it reaches traefik by name there); while
// traefik is down a running cloudflared is left alone, so an outage never flips it back and forth.
traefikUp := containerRunning("traefik")
cfTunnel := tunnelOK && traefikUp && containerOnNetwork("traefik", infra.TunnelNetwork)
if !traefikUp && containerRunning("cloudflared") {
m.logger.Printf("[INFO] [infra] cloudflared left as it is while traefik is not running")
} else if err := m.ensureCloudflared(filepath.Join(base, "cloudflared"), cfTunnel); err != nil {
errs = append(errs, fmt.Sprintf("cloudflared: %v", err))
}
} else {
@@ -89,9 +113,36 @@ func (m *Manager) EnsureBaseStack() error {
return nil
}
func (m *Manager) ensureTraefik(dir string) error {
// ensureTraefik deploys traefik when it is not running, and RECONCILES a running one: when the rendered files differ
// from the ones on disk (a release changed the template — R-753 added the tunnel trust), it rewrites them and recreates
// the container, because traefik reads its static file only at start. Equal files → nothing (the healthy tick).
// A rewrite that would DROP the certificate resolver the running file has is refused (logged): the inputs that produce
// it (the customer's e-mail) are missing, and dropping it would cost the box its certificates.
// Pinned by TestEnsureTraefik_* (internal/stacks/infra_test.go).
func (m *Manager) ensureTraefik(dir string, tunnel bool) error {
files, err := infra.RenderTraefik(infra.TraefikData{
ACMEEmail: m.cfg.Customer.Email,
CFAPIToken: m.cfg.Infrastructure.CFAPIToken,
Tunnel: tunnel,
})
if err != nil {
return err
}
if containerRunning("traefik") {
return nil
changed := changedInfraFiles(dir, files)
if len(changed) == 0 {
return nil
}
if cur, err := os.ReadFile(filepath.Join(dir, "traefik.yml")); err == nil &&
strings.Contains(string(cur), "certResolver") && !strings.Contains(files["traefik.yml"].Content, "certResolver") {
m.logger.Printf("[WARN] [infra] traefik NOT reconciled: the new traefik.yml would drop the running certificate resolver (no customer e-mail in the config) — left as it is")
return nil
}
m.logger.Printf("[INFO] [infra] traefik config changed (%s, tunnel trust %v) — rewriting and recreating traefik (routing pauses a few seconds)", strings.Join(changed, ", "), tunnel)
if err := writeInfraFiles(dir, files); err != nil {
return err
}
return m.composeUp(dir, "--force-recreate")
}
m.logger.Printf("[INFO] [infra] deploying traefik → %s", dir)
if err := os.MkdirAll(filepath.Join(dir, "dynamic"), 0o755); err != nil {
@@ -110,32 +161,91 @@ func (m *Manager) ensureTraefik(dir string) error {
if err := os.Chmod(acme, 0o600); err != nil {
return fmt.Errorf("chmod acme.json: %w", err)
}
files, err := infra.RenderTraefik(infra.TraefikData{
ACMEEmail: m.cfg.Customer.Email,
CFAPIToken: m.cfg.Infrastructure.CFAPIToken,
})
if err := writeInfraFiles(dir, files); err != nil {
return err
}
return m.composeUp(dir)
}
// ensureCloudflared deploys cloudflared, or reconciles a running one whose compose changed (R-753 moved it to the tunnel
// network at a fixed address); compose recreates the container when its networks change. The tunnel reconnects in a
// few seconds. Pinned by TestEnsureCloudflared_*.
func (m *Manager) ensureCloudflared(dir string, tunnel bool) error {
files, err := infra.RenderCloudflared(infra.CloudflaredData{CFTunnelToken: m.cfg.Infrastructure.CFTunnelToken, Tunnel: tunnel})
if err != nil {
return err
}
if containerRunning("cloudflared") {
if len(changedInfraFiles(dir, files)) == 0 {
return nil
}
m.logger.Printf("[INFO] [infra] cloudflared compose changed (tunnel network %v) — recreating cloudflared (the tunnel reconnects in seconds)", tunnel)
} else {
m.logger.Printf("[INFO] [infra] deploying cloudflared → %s (tunnel network %v)", dir, tunnel)
}
if err := writeInfraFiles(dir, files); err != nil {
return err
}
return m.composeUp(dir)
}
func (m *Manager) ensureCloudflared(dir string) error {
if containerRunning("cloudflared") {
return nil
// ensureTunnelNetwork makes felhom-tunnel with its FIXED subnet (infra.TunnelSubnet). A network of that name with any
// other subnet is an error and is left alone: cloudflared could not take its address there, and traefik's trust would
// name an address nobody holds. Pinned by TestEnsureTunnelNetwork_*.
func (m *Manager) ensureTunnelNetwork() error {
inspect := func() (string, error) {
out, err := dockerexec.Command("docker", "network", "inspect", "--format",
"{{range .IPAM.Config}}{{.Subnet}} {{end}}", infra.TunnelNetwork).Output()
return strings.TrimSpace(string(out)), err
}
m.logger.Printf("[INFO] [infra] deploying cloudflared → %s", dir)
files, err := infra.RenderCloudflared(infra.CloudflaredData{CFTunnelToken: m.cfg.Infrastructure.CFTunnelToken})
if got, err := inspect(); err == nil {
if got == infra.TunnelSubnet {
return nil
}
return fmt.Errorf("docker network %s exists with subnet %q, want %s — left alone; the tunnel keeps its old shape", infra.TunnelNetwork, got, infra.TunnelSubnet)
}
m.logger.Printf("[INFO] [infra] creating docker network %s (%s)", infra.TunnelNetwork, infra.TunnelSubnet)
out, err := dockerexec.Command("docker", "network", "create", "--driver", "bridge",
"--subnet", infra.TunnelSubnet, "--ip-range", infra.TunnelIPRange, "--gateway", infra.TunnelGateway,
infra.TunnelNetwork).CombinedOutput()
if err != nil {
return err
if got, ierr := inspect(); ierr == nil && got == infra.TunnelSubnet {
return nil // created by a concurrent actor
}
return fmt.Errorf("network create %s: %s: %w", infra.TunnelNetwork, strings.TrimSpace(string(out)), err)
}
if err := writeInfraFiles(dir, files); err != nil {
return err
return nil
}
// ensureForwardedHeaders writes the forwarded-header clean-up middleware (infra.RenderForwardedHeaders) when its content
// changes. traefik.yml names it on the websecure entrypoint, so EnsureBaseStack writes it BEFORE traefik.yml.
func (m *Manager) ensureForwardedHeaders(traefikDir string) error {
dynDir := filepath.Join(traefikDir, "dynamic")
if err := os.MkdirAll(dynDir, 0o755); err != nil {
return fmt.Errorf("mkdir dynamic: %w", err)
}
return m.composeUp(dir)
path := filepath.Join(dynDir, "forwarded.yml")
want := infra.RenderForwardedHeaders()
if cur, err := os.ReadFile(path); err != nil || string(cur) != want {
if err := os.WriteFile(path, []byte(want), 0o644); err != nil {
return fmt.Errorf("write forwarded headers: %w", err)
}
m.logger.Printf("[INFO] [infra] wrote the forwarded-header clean-up → %s (%s)", path, infra.ForwardedMiddleware)
}
return nil
}
// changedInfraFiles names the rendered files whose content differs from the file on disk (absent counts as different).
func changedInfraFiles(dir string, files map[string]infra.FileSpec) []string {
var changed []string
for name, spec := range files {
cur, err := os.ReadFile(filepath.Join(dir, name))
if err != nil || string(cur) != spec.Content {
changed = append(changed, name)
}
}
sort.Strings(changed)
return changed
}
func (m *Manager) ensureFileBrowser(dir string) error {
@@ -265,9 +375,9 @@ func (m *Manager) ensureTraefikNetwork() error {
return nil
}
// composeUp runs `docker compose up -d` in dir (DOMAIN injected by composeExecWithEnv).
func (m *Manager) composeUp(dir string) error {
out, err := m.composeExecWithEnv(dir, nil, "up", "-d")
// composeUp runs `docker compose up -d [extra…]` in dir (DOMAIN injected by composeExecWithEnv).
func (m *Manager) composeUp(dir string, extra ...string) error {
out, err := m.composeExecWithEnv(dir, nil, append([]string{"up", "-d"}, extra...)...)
if err != nil {
return fmt.Errorf("compose up: %s: %w", truncateStr(strings.TrimSpace(out), 300), err)
}