package shared import ( "fmt" "net" "net/http" "strings" "github.com/tamakyi/TamaBox/internal/conf" ) // NormalizeIP 规范化 IP:去空白、剥端口、校验合法性(非法返回空串), // 并把 IPv4-mapped IPv6(::ffff:1.2.3.4)归一为 IPv4,便于黑名单 / 限流精确匹配。 func NormalizeIP(s string) string { s = strings.TrimSpace(s) if s == "" { return "" } if h, _, err := net.SplitHostPort(s); err == nil { s = h } ip := net.ParseIP(s) if ip == nil { return "" } if v4 := ip.To4(); v4 != nil { return v4.String() } return ip.String() } // LimitKey 把客户端 IP 归一化为「限流 / 自动封禁」用的聚合键。 // IPv4 原样;IPv6 掩码到 /64 —— 住宅 / 隧道分配通常就是一个 /64(2^64 个地址), // 若按完整地址计数,攻击者每请求换源地址即可让阈值永不触发,且键会无限膨胀内存。 // // ⚠️ 仅用于限流 / 封禁计数。IP 黑名单必须继续精确匹配(见 ipblacklist.go), // 否则会把整个 /64 段一并拉黑。 func LimitKey(ip string) string { parsed := net.ParseIP(ip) if parsed == nil { // 无法解析时原样返回,保持调用方可预期(空串由调用方自行判断)。 return ip } if v4 := parsed.To4(); v4 != nil { return v4.String() } return parsed.Mask(net.CIDRMask(64, 128)).String() + "/64" } // trustedProxyNets 缓存解析后的可信代理网段(conf.Init 之后由 InitTrustedProxies 填充)。 var trustedProxyNets []*net.IPNet // InitTrustedProxies 解析 conf.Security.TrustedProxies(逗号分隔 IP / CIDR),在 conf.Init 之后调用一次。 // 仅限"你自己的"反向代理 / LB(如本机 nginx 127.0.0.1、私有网络 LB);列表为空时 XFF / X-Real-IP 永不被信任。 // // 不为 CDN 维护庞大 CIDR 清单:站点架在 CDN 后时,应配合 Tunnel / Authenticated Origin Pull // 或防火墙仅放行回源网段锁定源站,此时 CF-Connecting-IP / EO-Client-IP 即权威客户端 IP。 func InitTrustedProxies() error { trustedProxyNets = nil for _, raw := range strings.Split(conf.Security.TrustedProxies, ",") { item := strings.TrimSpace(raw) if item == "" { continue } ipnet, err := parseTrustedProxy(item) if err != nil { return fmt.Errorf("parse trusted_proxies entry %q: %w", item, err) } trustedProxyNets = append(trustedProxyNets, ipnet) } return nil } // parseTrustedProxy 接受单 IP(自动补 /32 或 /128)或 CIDR。 func parseTrustedProxy(s string) (*net.IPNet, error) { if ip := net.ParseIP(s); ip != nil { if ip4 := ip.To4(); ip4 != nil { return &net.IPNet{IP: ip4, Mask: net.CIDRMask(32, 32)}, nil } return &net.IPNet{IP: ip, Mask: net.CIDRMask(128, 128)}, nil } _, ipnet, err := net.ParseCIDR(s) if err != nil { return nil, err } return ipnet, nil } // isTrustedProxy 判断给定 IP 是否落在可信代理清单内。 func isTrustedProxy(ipStr string) bool { ip := net.ParseIP(ipStr) if ip == nil { return false } for _, n := range trustedProxyNets { if n.Contains(ip) { return true } } return false } // ResolveClientIP 统一解析客户端真实 IP,供速率限制、IP 黑名单与审计记录共用。 // // 策略:未开 TrustProxy 时一律用不可伪造的 TCP RemoteAddr;开启后 (a) 先信 CDN 权威头 // CF-Connecting-IP → EO-Client-IP,(b) 仅当 TCP 对端落在 trusted_proxies 内才从右向左 // 解析 XFF / X-Real-IP,(c) 否则回退 RemoteAddr,绝不信任客户端自供的头。 // // ⚠️ trust_proxy=true 但源站可被直连(未用 Tunnel / 未锁源站)时,攻击者可自造 // CF-Connecting-IP / EO-Client-IP 并被本函数信任 —— 生产开启必须锁定源站只接受 CDN 回源。 // // ⚠️ 双 CDN(境内 EdgeOne + 境外 Cloudflare)时必须:① 防火墙仅放行两家回源网段; // ② 在各家规则引擎中删除入站请求里「对方 CDN」的权威头,否则攻击者可借道另一家伪造身份, // 绕过限流 / 自动封禁 / 单 IP 投票限制。步骤见 outputs/edgeone-cloudflare-dual-cdn-guide.md。 func ResolveClientIP(r *http.Request) string { if !conf.Security.TrustProxy { return NormalizeIP(r.RemoteAddr) } // (a) CDN 权威客户端头(只能由 CDN 边缘写入):Cloudflare 优先,其次腾讯 EdgeOne。 if cfIP := NormalizeIP(r.Header.Get("CF-Connecting-IP")); cfIP != "" { return cfIP } if eoIP := NormalizeIP(r.Header.Get("EO-Client-IP")); eoIP != "" { return eoIP } // (b)/(c) 仅当 TCP 对端是受信代理时才解析 XFF/X-Real-IP。 remote := NormalizeIP(r.RemoteAddr) if isTrustedProxy(remote) { if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); forwarded != "" { // 从右向左取第一个非可信代理跳:客户端的真实 IP。 parts := strings.Split(forwarded, ",") for i := len(parts) - 1; i >= 0; i-- { hop := NormalizeIP(parts[i]) if hop == "" { continue } if !isTrustedProxy(hop) { return hop } } } if realIP := NormalizeIP(r.Header.Get("X-Real-IP")); realIP != "" { return realIP } } return remote }