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
+2 -23
View File
@@ -10,7 +10,6 @@ import (
"fmt"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"io"
"net"
"net/http"
"os"
"strings"
@@ -217,27 +216,7 @@ func (s *Server) claimNow() time.Time {
return time.Now()
}
// clientIP returns the client IP used as the rate-limiter key. Order: the X-Forwarded-For first
// hop (set by the traefik/Cloudflare proxy) wins; otherwise the HOST portion of RemoteAddr with the
// ephemeral PORT stripped (net.SplitHostPort). This is the CAMPAIGN-4 F-B fix: keying on the raw
// RemoteAddr (IP:PORT) meant every fresh direct connection from one host got a distinct ephemeral
// port → a distinct key → the failed-attempt counter never accrued, so a direct-to-controller
// (LAN/guest, non-proxied) path had NO brute-force protection. A RemoteAddr with no port
// (tests/edge) or an IPv6 form is handled by SplitHostPort, falling back to the raw value.
//
// Accepted limitation (out of scope here): X-Forwarded-For is attacker-controlled on a direct path,
// so a client rotating the first hop still evades the per-IP counter. This fix only closes the
// port-in-key bug so the proxied / stable-source-IP case — the real deployment — works; it does NOT
// attempt to establish XFF trust.
func clientIP(r *http.Request) string {
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
return strings.TrimSpace(strings.Split(fwd, ",")[0])
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return strings.TrimSpace(r.RemoteAddr)
}
// clientIP and rateKey live in clientaddr.go (R-753).
// ── the pages ────────────────────────────────────────────────────────────────────────────────
@@ -316,7 +295,7 @@ func (s *Server) handleClaimSubmit(w http.ResponseWriter, r *http.Request) {
return
}
wasReset := s.authEnabled() // a password already set → this is a reset, not a first-claim
ip := clientIP(r)
ip := rateKey(r)
if locked, _ := s.claimRateLocked(); locked {
s.handleClaimPage(w, r, s.msg(r, "claim.msg.too_many"), "")