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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user