1e8d045815
- 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
130 lines
4.9 KiB
Go
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
|
|
}
|