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
+11 -11
View File
@@ -150,20 +150,20 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
password := r.FormValue("password")
nextURL := r.FormValue("next")
// The counter's key is the VISITOR (clientaddr.go, R-753): through the tunnel each visitor has its own address, so a
// stranger's wrong passwords lock only the stranger. Before v0.286.0 every tunnel visitor shared cloudflared's
// address here, and five wrong tries locked the whole household out of its own dashboard for a minute.
ip := rateKey(r)
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] login attempt from %s (X-Forwarded-For: %s)", r.RemoteAddr, r.Header.Get("X-Forwarded-For"))
s.logger.Printf("[DEBUG] [web] login attempt: visitor %s (peer %s, X-Forwarded-For %q, CF-Connecting-IP %q)",
ip, r.RemoteAddr, r.Header.Get("X-Forwarded-For"), r.Header.Get("CF-Connecting-IP"))
}
if password == "" {
s.renderLogin(w, r, "Kérjük adja meg a jelszót", "")
s.renderLogin(w, r, s.msg(r, "login.msg.empty_password"), "")
return
}
// Rate limit: check failed attempts from this host. clientIP strips the ephemeral port
// (CAMPAIGN-4 F-B) so distinct direct connections from one host share a key and the counter
// actually accrues; XFF first-hop still wins for proxied clients.
ip := clientIP(r)
s.loginAttemptMu.Lock()
attempt := s.loginAttempts[ip]
if attempt != nil && time.Since(attempt.lastFail) > loginWindowDuration {
@@ -174,14 +174,14 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if attempt != nil && attempt.count >= loginMaxAttempts {
s.loginAttemptMu.Unlock()
s.logger.Printf("[WARN] [web] Login rate limited for %s (%d attempts)", ip, attempt.count)
s.renderLogin(w, r, "Túl sok sikertelen próbálkozás, próbálja újra 1 perc múlva", "")
s.renderLogin(w, r, s.msg(r, "login.msg.rate_limited"), "")
return
}
s.loginAttemptMu.Unlock()
effectiveHash := s.effectivePasswordHash()
if err := bcrypt.CompareHashAndPassword([]byte(effectiveHash), []byte(password)); err != nil {
s.logger.Printf("[WARN] [web] Failed login from %s", r.RemoteAddr)
s.logger.Printf("[WARN] [web] Failed login from %s", ip)
s.loginAttemptMu.Lock()
if s.loginAttempts[ip] == nil {
s.loginAttempts[ip] = &loginAttempt{}
@@ -189,7 +189,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
s.loginAttempts[ip].count++
s.loginAttempts[ip].lastFail = time.Now()
s.loginAttemptMu.Unlock()
s.renderLogin(w, r, "Hibás jelszó", "")
s.renderLogin(w, r, s.msg(r, "login.msg.wrong_password"), "")
return
}
@@ -219,7 +219,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
// browser is closed". The durable opt-out is a separate, explicit choice and is untouched here.
http.SetCookie(w, &http.Cookie{Name: recoveryBannerCookie, Value: "", Path: "/", MaxAge: -1})
s.logger.Printf("[INFO] [web] Login from %s", r.RemoteAddr)
s.logger.Printf("[INFO] [web] Login from %s", ip)
// Redirect to ?next= target if provided, otherwise to dashboard
redirectTo := "/"