Files
admin 1e8d045815 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
2026-10-01 21:01:42 +02:00

130 lines
4.9 KiB
Go

package web
import (
"context"
"net"
"net/http"
"strings"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
)
// ── The visitor's address (R-753, `09` §3 decision 63, Part A) ─────────────────────────────────────────────
//
// The rule: NEVER BELIEVE AN ADDRESS A CLIENT CAN WRITE.
//
// Two paths reach the controller, both through traefik:
// tunnel: browser → Cloudflare's edge → cloudflared (felhom-tunnel, fixed infra.TunnelAddr) → traefik → controller
// LAN: browser → traefik → controller
// traefik APPENDS the address it saw to X-Forwarded-For, and drops any chain written by a peer it does not trust — so the
// RIGHTMOST X-Forwarded-For entry is the address traefik itself saw, on either path. That entry, and only that entry,
// is believed — and only when the request's own TCP peer IS traefik (anything else that can open a connection to the
// controller can write any header it likes).
//
// - The hop is cloudflared's fixed address → the visitor is CF-Connecting-IP. Cloudflare's edge sets it on every
// request and refuses a request that carries its own (measured: HTTP 403 at the edge,
// felhom.eu/documentation/audits/visitors-2026-10-01/A/M1-status-quo.txt). On the LAN the hop is the LAN client
// itself, so a CF-Connecting-IP forged on the LAN (it does arrive — M4) is never read.
// - Any other hop → the visitor is that hop.
//
// Everything else — the LEFTMOST X-Forwarded-For entry above all (Cloudflare APPENDS to a client-sent chain, measured
// M2: "6.6.6.6,37.191.56.193, 172.18.0.5") — is client-written and ignored. The rule holds whether or not traefik
// trusts the tunnel: the setup gate's forwardAuth request carries only traefik's own hop, and the dashboard request
// carries the whole chain; both end in the hop traefik saw.
//
// If cloudflared is NOT at its fixed address (a box whose tunnel network could not be made), the hop is cloudflared's
// docker-assigned address: the visitor is that address — every tunnel visitor shares one key, as before R-753. Never a
// client-written one.
//
// Pinned by internal/web/clientaddr_test.go (TestClientIP_*), red-proven against the leftmost-hop shape.
// traefikHost is the name the controller resolves traefik by on traefik-public (docker's embedded DNS).
const traefikHost = "traefik"
// isTraefikPeer reports whether ip is traefik's address. A variable so tests can name traefik without docker DNS.
var isTraefikPeer = func(ip string) bool { return traefikPeers.has(ip) }
var traefikPeers = &peerResolver{host: traefikHost, ttl: 30 * time.Second, lookup: net.DefaultResolver.LookupHost}
// peerResolver caches the addresses a container name resolves to. A failed lookup believes nobody (fail closed: the
// TCP peer is then the visitor, which can never be client-written).
type peerResolver struct {
host string
ttl time.Duration
lookup func(ctx context.Context, host string) ([]string, error)
mu sync.Mutex
at time.Time
addrs []string
}
func (p *peerResolver) has(ip string) bool {
p.mu.Lock()
defer p.mu.Unlock()
if time.Since(p.at) > p.ttl {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
addrs, err := p.lookup(ctx, p.host)
cancel()
if err != nil {
addrs = nil
}
p.addrs, p.at = addrs, time.Now()
}
for _, a := range p.addrs {
if a == ip {
return true
}
}
return false
}
// peerHost is RemoteAddr without its port (CAMPAIGN-4 F-B: a key with the ephemeral port never accrues).
func peerHost(r *http.Request) string {
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return strings.TrimSpace(r.RemoteAddr)
}
// clientIP is the visitor's address — logged, and the key of every per-visitor counter (dashboard login, claim code,
// share password, escrow re-auth). See the block comment above for the rule.
func clientIP(r *http.Request) string {
peer := peerHost(r)
if !isTraefikPeer(peer) {
return peer
}
var hops []string
for _, v := range r.Header.Values("X-Forwarded-For") {
for _, h := range strings.Split(v, ",") {
if h = strings.TrimSpace(h); h != "" {
hops = append(hops, h)
}
}
}
if len(hops) == 0 {
return peer
}
hop := hops[len(hops)-1] // the address traefik saw
if net.ParseIP(hop) == nil {
return peer
}
if hop == infra.TunnelAddr {
if cf := strings.TrimSpace(r.Header.Get("CF-Connecting-IP")); net.ParseIP(cf) != nil {
return cf
}
}
return hop
}
// rateKey is clientIP as a counter key. An IPv6 visitor is counted per /64: one household or one attacker usually holds
// a whole /64, and per-/128 keys would let one machine step around a counter by changing its own address.
func rateKey(r *http.Request) string {
ip := clientIP(r)
if p := net.ParseIP(ip); p != nil && p.To4() == nil {
return p.Mask(net.CIDRMask(64, 128)).String() + "/64"
}
return ip
}