package shared import ( "fmt" "net" "net/http" "strings" "github.com/tamakyi/TamaBox/internal/conf" ) // NormalizeIP 规范化 IP 字符串: // - 去除两端空白 // - 剥离端口("1.2.3.4:8080" / "[::1]:8080") // - 校验合法性,非法返回空串 // - 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 前缀后返回,形如 "2001:db8:1:2::/64"。 // // 为什么 IPv6 必须聚合:单个住宅或隧道 IPv6 分配通常就是一个 /64(2^64 个地址)。 // 若以完整地址作键,攻击者每次请求换一个源地址即可让每个键只命中一次, // 使限流与 autoban 的阈值永远无法触发,同时海量键还会持续膨胀内存。 // /64 是 IPv6 的最小实际分配单位,按此聚合可在不误伤正常用户的前提下堵住绕过。 // // ⚠️ 仅用于限流 / 封禁计数。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 列表), // 仅限“你自己的”反向代理/负载均衡基础设施(例如本机 nginx 的 127.0.0.1、私有网络 LB), // 在 conf.Init 之后调用一次。列表为空时,X-Forwarded-For / X-Real-IP 永不被信任。 // // 设计目标:避免维护 Cloudflare / EdgeOne 等上游 CDN 的庞大 CIDR 清单。 // 站点若架在 CDN 后,应配合 Cloudflare Tunnel / Authenticated Origin Pull(Cloudflare)或 // 防火墙仅放行回源网段(Cloudflare / EdgeOne)锁定源站只接受 CDN 连接,此时 // CF-Connecting-IP / EO-Client-IP 即为权威客户端 IP,无需任何 CIDR 白名单。 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 黑名单与审计记录共用。 // // 解析策略(不依赖上游 CDN 的庞大 CIDR 清单): // 1. 未开启 TrustProxy:一切以 TCP 对端 RemoteAddr 为准(不可伪造)。 // 2. 开启 TrustProxy: // a. 优先信任 CDN 权威客户端头:CF-Connecting-IP(Cloudflare)→ EO-Client-IP // (腾讯 EdgeOne)。这类头只能由对应 CDN 边缘按真实客户端写入,前提是源站已锁定 // 为仅接受这些 CDN 的回源连接(攻击者无法直连源站伪造)。 // b. 仅当 TCP 对端落在 trusted_proxies(你自己的基础设施)内时,才解析 // X-Forwarded-For / X-Real-IP,并从右向左剥离已知可信跳数取原始客户端。 // c. 否则回退到 RemoteAddr,绝不信任客户端自行提供的头。 // // ⚠️ 若 trust_proxy = true 但源站仍可被人直连(未用 Tunnel / 未锁源站),攻击者可自行 // 发送 CF-Connecting-IP / EO-Client-IP 头,此时本函数会信任之。因此生产开启 trust_proxy // 时,务必配合「Cloudflare Tunnel / Authenticated Origin Pull / 防火墙仅放行 CDN 回源 // 网段」等手段锁定源站,使伪造头无法抵达应用。 // // 双 CDN 部署(境内 EdgeOne + 境外 Cloudflare)的额外约束: // 若回源请求携带对方 CDN 的权威头(经 EdgeOne 回源的请求带 CF-Connecting-IP、经 // Cloudflare 回源的请求带 EO-Client-IP),攻击者即可借道另一家伪造身份,绕过限流 / // 自动封禁 / 单 IP 投票限制,或嫁祸他人。因此必须:① 防火墙仅放行两家 CDN 的回源网段; // ② 在各家 CDN 的规则引擎中删除入站请求里「对方 CDN」的权威头,使每个请求最终只携带 // 自己所在 CDN 的头(操作步骤见 outputs/edgeone-cloudflare-dual-cdn-guide.md)。 // Cloudflare 侧因 CF-Connecting-IP 恒被覆盖,且用 Transform Rule 删掉 EO-Client-IP 即可; // EdgeOne 侧用规则引擎删掉 CF-Connecting-IP 即可。 func ResolveClientIP(r *http.Request) string { if !conf.Security.TrustProxy { return NormalizeIP(r.RemoteAddr) } // (a) CDN 权威客户端头:CF-Connecting-IP(Cloudflare)优先,其次 EO-Client-IP(腾讯 // EdgeOne,等效于 CF-Connecting-IP 的权威头,回源时由 EdgeOne 边缘写入客户端真实 IP)。 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 }