This commit is contained in:
tamakyi
2026-09-08 10:10:44 +08:00
commit 2ed27dd55e
288 changed files with 67061 additions and 0 deletions
+176
View File
@@ -0,0 +1,176 @@
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
}