0904
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
package shared
|
||||
|
||||
import "strings"
|
||||
|
||||
// BlockWordHit 检查文本是否命中箱主设置的屏蔽词表(逗号分隔),返回首个命中的词。
|
||||
//
|
||||
// 屏蔽词语义为「箱主防骚扰词表」:提问、私聊(对方发给我的消息)与投票提交的
|
||||
// 开放文本都应经过同一份词表过滤,因此把判定收敛到本函数供各入口复用,
|
||||
// 与账号设置页保存时使用的同一 CSV 格式(半角逗号分隔、已去重、去空白)。
|
||||
//
|
||||
// 存储层可能残留历史脏数据(多余空白 / 空项),这里解析时统一 TrimSpace 并跳过空项。
|
||||
func BlockWordHit(blockWordsCSV, text string) (word string, hit bool) {
|
||||
if blockWordsCSV == "" || text == "" {
|
||||
return "", false
|
||||
}
|
||||
for _, w := range strings.Split(blockWordsCSV, ",") {
|
||||
w = strings.TrimSpace(w)
|
||||
if w == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(text, w) {
|
||||
return w, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package shared
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBlockWordHit(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
words string // CSV 词表
|
||||
text string
|
||||
word string
|
||||
hit bool
|
||||
}{
|
||||
{name: "empty list", words: "", text: "广告", word: "", hit: false},
|
||||
{name: "empty text", words: "广告", text: "", word: "", hit: false},
|
||||
{name: "single hit", words: "广告,spam", text: "请勿发广告", word: "广告", hit: true},
|
||||
{name: "second word hit", words: "广告,spam", text: "buy now spam!", word: "spam", hit: true},
|
||||
{name: "no hit", words: "广告,spam", text: "你好呀", word: "", hit: false},
|
||||
{name: "dirty csv trims space", words: " 广告 , spam ", text: "这是广告", word: "广告", hit: true},
|
||||
{name: "dirty csv empty items skipped", words: "广告,,,spam", text: "spam again", word: "spam", hit: true},
|
||||
{name: "substring semantics", words: "抽奖", text: "参与抽奖活动", word: "抽奖", hit: true},
|
||||
{name: "chinese word bytes safe", words: "屏蔽", text: "你被屏蔽了吗", word: "屏蔽", hit: true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
gotWord, gotHit := BlockWordHit(c.words, c.text)
|
||||
if gotHit != c.hit || gotWord != c.word {
|
||||
t.Fatalf("BlockWordHit(%q, %q) = (%q, %v), want (%q, %v)",
|
||||
c.words, c.text, gotWord, gotHit, c.word, c.hit)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DuplicateBlocker 阻止同一来源(IP + 内容指纹)在窗口期内的重复提交,
|
||||
// 用于拦截刷屏 / 重复轰炸提问。
|
||||
//
|
||||
// 实现:内存 map,key = normalizedIP + "|" + contentHash,值为首次提交时间。
|
||||
// 窗口期内再次提交相同内容返回 false(拒绝)。
|
||||
//
|
||||
// 内存安全:条数超过 50000 时先清理过期项;窗口外的 key 由后台清理循环定时移除。
|
||||
type DuplicateBlocker struct {
|
||||
mu sync.Mutex
|
||||
items map[string]time.Time
|
||||
window time.Duration
|
||||
maxItem int
|
||||
}
|
||||
|
||||
// NewDuplicateBlocker 创建拦截器并启动后台清理循环。
|
||||
// 注意:每个实例会启动一个常驻 goroutine,应用内应使用包级单例 DuplicateGuard,
|
||||
// 不要为每次请求创建新实例。
|
||||
func NewDuplicateBlocker(window time.Duration) *DuplicateBlocker {
|
||||
d := &DuplicateBlocker{
|
||||
items: make(map[string]time.Time),
|
||||
window: window,
|
||||
maxItem: 50000,
|
||||
}
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(10 * time.Minute)
|
||||
d.cleanup()
|
||||
}
|
||||
}()
|
||||
return d
|
||||
}
|
||||
|
||||
// Allow 判断 key(IP+内容)是否允许提交:
|
||||
// - 窗口期内已存在相同 key → 返回 false(重复,拒绝)
|
||||
// - 否则记录并返回 true
|
||||
func (d *DuplicateBlocker) Allow(key string) bool {
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-d.window)
|
||||
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if t, ok := d.items[key]; ok && t.After(cutoff) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 超限时先清理过期项,避免恶意构造大量 key 撑爆内存
|
||||
if len(d.items) >= d.maxItem {
|
||||
for k, t := range d.items {
|
||||
if !t.After(cutoff) {
|
||||
delete(d.items, k)
|
||||
}
|
||||
}
|
||||
// 仍超限则整体放弃写入(宁可放行也不撑爆内存)
|
||||
if len(d.items) >= d.maxItem {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
d.items[key] = now
|
||||
return true
|
||||
}
|
||||
|
||||
func (d *DuplicateBlocker) cleanup() {
|
||||
cutoff := time.Now().Add(-d.window)
|
||||
d.mu.Lock()
|
||||
for k, t := range d.items {
|
||||
if !t.After(cutoff) {
|
||||
delete(d.items, k)
|
||||
}
|
||||
}
|
||||
d.mu.Unlock()
|
||||
}
|
||||
|
||||
// DuplicateKey 生成拦截 key:规范化 IP + 内容 MD5。
|
||||
func DuplicateKey(ip, content string) string {
|
||||
ip = NormalizeIP(ip)
|
||||
sum := md5.Sum([]byte(strings.TrimSpace(content)))
|
||||
return ip + "|" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// DuplicateGuard 全局限流实例:同 IP 在 5 分钟内提交相同内容会被拒绝。
|
||||
// 阈值合理:正常用户几乎不可能在 5 分钟内提交两条完全一致的提问。
|
||||
var DuplicateGuard = NewDuplicateBlocker(5 * time.Minute)
|
||||
@@ -0,0 +1,20 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// SafeGo 在独立 goroutine 中执行 fn 并捕获 panic,防止任一 goroutine
|
||||
// panic 未恢复导致整个进程崩溃(Go 默认行为:未恢复的 panic 终止进程)。
|
||||
//
|
||||
// 用法:shared.SafeGo(func() { ... })
|
||||
func SafeGo(fn func()) {
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logrus.WithField("panic", r).Error("goroutine panic recovered")
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
)
|
||||
|
||||
func TestResolveClientIPCloudflareHeader(t *testing.T) {
|
||||
oldTrustProxy := conf.Security.TrustProxy
|
||||
defer func() { conf.Security.TrustProxy = oldTrustProxy }()
|
||||
conf.Security.TrustProxy = true
|
||||
|
||||
r := httptest.NewRequest("GET", "https://example.com/", nil)
|
||||
r.RemoteAddr = "192.0.2.10:443"
|
||||
r.Header.Set("CF-Connecting-IP", "198.51.100.20")
|
||||
r.Header.Set("X-Real-IP", "198.51.100.30")
|
||||
if got := ResolveClientIP(r); got != "198.51.100.20" {
|
||||
t.Fatalf("ResolveClientIP() = %q, want Cloudflare client IP", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClientIPIgnoresHeadersWithoutTrust(t *testing.T) {
|
||||
oldTrustProxy := conf.Security.TrustProxy
|
||||
defer func() { conf.Security.TrustProxy = oldTrustProxy }()
|
||||
conf.Security.TrustProxy = false
|
||||
|
||||
r := httptest.NewRequest("GET", "https://example.com/", nil)
|
||||
r.RemoteAddr = "192.0.2.10:443"
|
||||
r.Header.Set("CF-Connecting-IP", "198.51.100.20")
|
||||
r.Header.Set("EO-Client-IP", "198.51.100.99")
|
||||
if got := ResolveClientIP(r); got != "192.0.2.10" {
|
||||
t.Fatalf("ResolveClientIP() = %q, want remote address", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveClientIPEdgeOneHeader 验证腾讯 EdgeOne 的权威客户端头 EO-Client-IP
|
||||
// 在 TrustProxy=true 时被信任(双 CDN 部署:境内 EdgeOne 回源不带 CF-Connecting-IP)。
|
||||
func TestResolveClientIPEdgeOneHeader(t *testing.T) {
|
||||
oldTrustProxy := conf.Security.TrustProxy
|
||||
defer func() { conf.Security.TrustProxy = oldTrustProxy }()
|
||||
conf.Security.TrustProxy = true
|
||||
|
||||
r := httptest.NewRequest("GET", "https://example.com/", nil)
|
||||
r.RemoteAddr = "203.0.113.44:443" // 模拟 EdgeOne 边缘回源
|
||||
r.Header.Set("EO-Client-IP", "198.51.100.99")
|
||||
r.Header.Set("X-Forwarded-For", "203.0.113.99")
|
||||
if got := ResolveClientIP(r); got != "198.51.100.99" {
|
||||
t.Fatalf("ResolveClientIP() = %q, want EdgeOne client IP", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveClientIPPrefersCloudflareOverEdgeOne 锁定权威头的优先级:
|
||||
// 两个头并存(配置遗漏剥离规则时的异常态)时以 CF-Connecting-IP 为准,行为可预期。
|
||||
func TestResolveClientIPPrefersCloudflareOverEdgeOne(t *testing.T) {
|
||||
oldTrustProxy := conf.Security.TrustProxy
|
||||
defer func() { conf.Security.TrustProxy = oldTrustProxy }()
|
||||
conf.Security.TrustProxy = true
|
||||
|
||||
r := httptest.NewRequest("GET", "https://example.com/", nil)
|
||||
r.RemoteAddr = "203.0.113.44:443"
|
||||
r.Header.Set("CF-Connecting-IP", "198.51.100.20")
|
||||
r.Header.Set("EO-Client-IP", "198.51.100.99")
|
||||
if got := ResolveClientIP(r); got != "198.51.100.20" {
|
||||
t.Fatalf("ResolveClientIP() = %q, want Cloudflare client IP", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveClientIPEdgeOneInvalidFallsBack 验证 EO-Client-IP 为非法值时
|
||||
// 不 panic 且回退到 RemoteAddr(与 CF-Connecting-IP 的健壮性一致)。
|
||||
func TestResolveClientIPEdgeOneInvalidFallsBack(t *testing.T) {
|
||||
oldTrustProxy := conf.Security.TrustProxy
|
||||
defer func() { conf.Security.TrustProxy = oldTrustProxy }()
|
||||
conf.Security.TrustProxy = true
|
||||
|
||||
r := httptest.NewRequest("GET", "https://example.com/", nil)
|
||||
r.RemoteAddr = "203.0.113.44:443"
|
||||
r.Header.Set("EO-Client-IP", "not-an-ip")
|
||||
if got := ResolveClientIP(r); got != "203.0.113.44" {
|
||||
t.Fatalf("ResolveClientIP() = %q, want remote address fallback", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLimitKey 是 P1-4 的回归测试:限流 / 自动封禁用的聚合键必须
|
||||
// - IPv4:原样返回(每地址独立计数);
|
||||
// - IPv6:掩码到 /64(防止单 /64 内轮换海量地址绕过阈值、并避免键无限膨胀);
|
||||
// - 非法输入:原样返回,保持调用方行为可预期。
|
||||
func TestLimitKey(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"ipv4 passthrough", "203.0.113.10", "203.0.113.10"},
|
||||
{"ipv4-mapped collapses to v4", "::ffff:203.0.113.10", "203.0.113.10"},
|
||||
{"ipv6 aggregated to /64", "2001:db8:1:2:3:4:5:6", "2001:db8:1:2::/64"},
|
||||
// LimitKey 假定输入已由 ResolveClientIP 归一化(已剥离端口)。若误传入带端口的
|
||||
// 字符串,ParseIP 失败会原样透传 —— 这是防御性约定,需锁定以免行为漂移。
|
||||
{"ipv4 with port passes through", "203.0.113.10:8080", "203.0.113.10:8080"},
|
||||
{"ipv6 with port passes through", "[2001:db8:1:2:3:4:5:6]:443", "[2001:db8:1:2:3:4:5:6]:443"},
|
||||
{"invalid input passthrough", "not-an-ip", "not-an-ip"},
|
||||
{"empty passthrough", "", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := LimitKey(tc.in); got != tc.want {
|
||||
t.Fatalf("LimitKey(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLimitKeyIPv6SamePrefixCollapses 验证:同一 /64 内任意两台主机应聚合到同一键,
|
||||
// 不同 /64 必须落到不同键 —— 这是限速 / 封禁抗 IPv6 轮换绕过的核心不变量。
|
||||
func TestLimitKeyIPv6SamePrefixCollapses(t *testing.T) {
|
||||
a := LimitKey("2001:db8:1:2:3:4:5:6")
|
||||
b := LimitKey("2001:db8:1:2:ff:fe::1") // 同一 /64,仅主机位不同
|
||||
if a != b {
|
||||
t.Fatalf("同一 /64 内地址应聚合到同一键,got %q vs %q", a, b)
|
||||
}
|
||||
|
||||
c := LimitKey("2001:db8:1:3:3:4:5:6") // 不同 /64
|
||||
if a == c {
|
||||
t.Fatalf("不同 /64 应落到不同键,两者均为 %q", a)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
)
|
||||
|
||||
// DialFirstReachable 对一组已通过安全校验的候选 IP 并发拨号,返回最先成功建立的连接。
|
||||
//
|
||||
// 适用场景:SSRF 防护型出站客户端。调用方先解析域名并过滤内网地址,再以 IP 字面量拨号
|
||||
// (无二次解析,免疫 DNS rebinding),本函数负责多 IP 容错——避免固定拨单一 IP 时,
|
||||
// 恰好抽中不可达或被劣化的边缘节点(如 Cloudflare anycast 的个别边缘)导致请求稳定超时;
|
||||
// curl 等客户端默认会尝试多个 IP,因此这里保持同等的多 IP 能力。
|
||||
//
|
||||
// 成功路径会取消其余拨号并关闭任何已建立但落选的连接,避免泄漏;全部失败时返回第一个错误。
|
||||
// maxCandidates<=0 或大于候选数时按全部候选处理;ips 为空返回错误。
|
||||
func DialFirstReachable(ctx context.Context, dialer *net.Dialer, network string, ips []net.IP, port string, maxCandidates int) (net.Conn, error) {
|
||||
if len(ips) == 0 {
|
||||
return nil, errors.New("no candidates to dial")
|
||||
}
|
||||
if maxCandidates <= 0 || maxCandidates > len(ips) {
|
||||
maxCandidates = len(ips)
|
||||
}
|
||||
|
||||
dialCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
type dialResult struct {
|
||||
conn net.Conn
|
||||
err error
|
||||
}
|
||||
results := make(chan dialResult, maxCandidates)
|
||||
for _, ip := range ips[:maxCandidates] {
|
||||
ip := ip
|
||||
go func() {
|
||||
conn, err := dialer.DialContext(dialCtx, network, net.JoinHostPort(ip.String(), port))
|
||||
results <- dialResult{conn: conn, err: err}
|
||||
}()
|
||||
}
|
||||
|
||||
var firstErr error
|
||||
remaining := maxCandidates
|
||||
for remaining > 0 {
|
||||
r := <-results
|
||||
remaining--
|
||||
if r.err == nil {
|
||||
cancel() // 通知其余拨号尽快退出
|
||||
for ; remaining > 0; remaining-- {
|
||||
if extra := <-results; extra.err == nil && extra.conn != nil {
|
||||
_ = extra.conn.Close()
|
||||
}
|
||||
}
|
||||
return r.conn, nil
|
||||
}
|
||||
if firstErr == nil {
|
||||
firstErr = r.err
|
||||
}
|
||||
}
|
||||
return nil, firstErr
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// startLoopbackListener 在 127.0.0.1 上开一个监听并返回其端口(关闭由 t.Cleanup 托管)。
|
||||
func startLoopbackListener(t *testing.T) int {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
return ln.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
func testDialer() *net.Dialer {
|
||||
return &net.Dialer{Timeout: 3 * time.Second, KeepAlive: 30 * time.Second}
|
||||
}
|
||||
|
||||
// TestDialFirstReachablePicksReachableIP 验证多候选并发拨号会跳过不可达 IP、
|
||||
// 选中可达的那个(顺序上不可达在前也不影响结果)。
|
||||
func TestDialFirstReachablePicksReachableIP(t *testing.T) {
|
||||
port := startLoopbackListener(t)
|
||||
|
||||
// 127.0.0.2 上没有任何监听 → 该候选必然拨号失败;
|
||||
// 127.0.0.1:port 有监听 → 可达。把坏 IP 放前面,验证不受顺序影响。
|
||||
conn, err := DialFirstReachable(context.Background(), testDialer(), "tcp",
|
||||
[]net.IP{net.ParseIP("127.0.0.2"), net.ParseIP("127.0.0.1")}, strconv.Itoa(port), 6)
|
||||
if err != nil {
|
||||
t.Fatalf("DialFirstReachable returned error: %v", err)
|
||||
}
|
||||
if conn == nil {
|
||||
t.Fatal("DialFirstReachable returned nil conn without error")
|
||||
}
|
||||
// 连接应落在可达的 127.0.0.1 上
|
||||
remote := conn.RemoteAddr().(*net.TCPAddr)
|
||||
if !remote.IP.Equal(net.ParseIP("127.0.0.1")) {
|
||||
t.Fatalf("expected connection to 127.0.0.1, got %s", remote)
|
||||
}
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
// TestDialFirstReachableAllFail 验证全部候选不可达时返回错误而非死等或空结果。
|
||||
func TestDialFirstReachableAllFail(t *testing.T) {
|
||||
// 先开再关,拿到一个确定无监听的端口
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
closedPort := ln.Addr().(*net.TCPAddr).Port
|
||||
_ = ln.Close()
|
||||
|
||||
start := time.Now()
|
||||
conn, err := DialFirstReachable(context.Background(), testDialer(), "tcp",
|
||||
[]net.IP{net.ParseIP("127.0.0.2"), net.ParseIP("127.0.0.1")}, strconv.Itoa(closedPort), 6)
|
||||
if err == nil {
|
||||
if conn != nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
t.Fatal("expected error when all candidates unreachable, got nil")
|
||||
}
|
||||
if conn != nil {
|
||||
_ = conn.Close()
|
||||
t.Fatal("expected nil conn on total failure")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 15*time.Second {
|
||||
t.Fatalf("all-fail path took too long: %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDialFirstReachableCtxCancel 验证父 context 取消后拨号立即返回(不泄漏、不死锁)。
|
||||
func TestDialFirstReachableCtxCancel(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // 拨号前已取消
|
||||
|
||||
start := time.Now()
|
||||
conn, err := DialFirstReachable(ctx, testDialer(), "tcp",
|
||||
[]net.IP{net.ParseIP("127.0.0.1")}, "81", 6)
|
||||
if err == nil {
|
||||
if conn != nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
t.Fatal("expected error after context cancel, got nil")
|
||||
}
|
||||
if conn != nil {
|
||||
_ = conn.Close()
|
||||
t.Fatal("expected nil conn on cancelled context")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "canceled") && !strings.Contains(err.Error(), "deadline") {
|
||||
t.Fatalf("expected context cancellation error, got: %v", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 5*time.Second {
|
||||
t.Fatalf("context cancel path took too long: %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDialFirstReachableEmptyCandidates 验证空候选返回错误而非 panic。
|
||||
func TestDialFirstReachableEmptyCandidates(t *testing.T) {
|
||||
conn, err := DialFirstReachable(context.Background(), testDialer(), "tcp", nil, "80", 6)
|
||||
if err == nil {
|
||||
if conn != nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
t.Fatal("expected error for empty candidates, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDialFirstReachableMaxCandidatesCapped 验证 maxCandidates 截断候选数不会 panic。
|
||||
func TestDialFirstReachableMaxCandidatesCapped(t *testing.T) {
|
||||
port := startLoopbackListener(t)
|
||||
conn, err := DialFirstReachable(context.Background(), testDialer(), "tcp",
|
||||
[]net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("127.0.0.1")}, strconv.Itoa(port), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("DialFirstReachable with capped candidates returned error: %v", err)
|
||||
}
|
||||
if conn == nil {
|
||||
t.Fatal("DialFirstReachable returned nil conn without error")
|
||||
}
|
||||
_ = conn.Close()
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Package shared 提供跨包共享的应用级状态。
|
||||
package shared
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||
)
|
||||
|
||||
// AppState 保存应用级共享状态。
|
||||
//
|
||||
// 多语言体系(internal/i18n)对每个请求按 cookie / 用户配置逐请求解析语言,
|
||||
// 本类型只承担一个职责:站点默认语言(兜底值)。
|
||||
// 访客既无 lang cookie、登录用户也未配置语言时,使用 AppState 中的默认语言。
|
||||
type AppState struct {
|
||||
mu sync.RWMutex
|
||||
lang string
|
||||
}
|
||||
|
||||
// NewAppState 返回默认语言为 i18n.Default(简体中文)的应用状态。
|
||||
func NewAppState() *AppState {
|
||||
return &AppState{lang: i18n.Default}
|
||||
}
|
||||
|
||||
// SetLang 设置站点默认语言。
|
||||
// 内部统一走 i18n.Resolve 归一化(zh_hk / zh-hant / en-us 等变体都会映射到规范代码),
|
||||
// 未知语言代码自动回退到 i18n.Default,保证 lang 始终是字典可查的规范代码。
|
||||
func (s *AppState) SetLang(lang string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.lang = i18n.Resolve(lang)
|
||||
}
|
||||
|
||||
// Lang 返回当前站点默认语言(始终为规范语言代码,如 "zh-CN" / "zh-TW" / "en" / "ja")。
|
||||
func (s *AppState) Lang() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.lang
|
||||
}
|
||||
|
||||
// App 是应用级共享状态单例。
|
||||
// 服务启动时(cmd/web.go)依据配置 default_lang 调用 App.SetLang 初始化;
|
||||
// 运行期也可由管理功能调用 SetLang 动态调整站点默认语言。
|
||||
var App = NewAppState()
|
||||
@@ -0,0 +1,77 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||
)
|
||||
|
||||
func TestAppState_Default(t *testing.T) {
|
||||
s := NewAppState()
|
||||
if got := s.Lang(); got != i18n.Default {
|
||||
t.Errorf("默认语言 = %q, 期望 %q", got, i18n.Default)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppState_SetLang_Normalize(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"zh-CN", i18n.LangZhCN},
|
||||
{"zh_CN", i18n.LangZhCN}, // 下划线变体
|
||||
{"zh", i18n.LangZhCN},
|
||||
{"zh-TW", i18n.LangZhTW},
|
||||
{"zh_hk", i18n.LangZhTW}, // 港繁统一映射到繁体中文
|
||||
{"zh-hant", i18n.LangZhTW},
|
||||
{"en", i18n.LangEn},
|
||||
{"en-US", i18n.LangEn},
|
||||
{"ja", i18n.LangJa},
|
||||
{"ja_JP", i18n.LangJa},
|
||||
{" En ", i18n.LangEn}, // 自动去空白
|
||||
{"fr", i18n.Default}, // 未知回退
|
||||
{"", i18n.Default}, // 空值回退
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
s := NewAppState()
|
||||
s.SetLang(tc.in)
|
||||
if got := s.Lang(); got != tc.want {
|
||||
t.Errorf("SetLang(%q) 后 Lang() = %q, 期望 %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppState_Concurrent(t *testing.T) {
|
||||
s := NewAppState()
|
||||
const n = 100
|
||||
|
||||
// 并发读写,覆盖 RWMutex 的多读单写路径。
|
||||
// 用 channel 计数替代 WaitGroup(部分受限环境对 WaitGroup 信号量支持异常)。
|
||||
done := make(chan struct{}, n)
|
||||
for i := 0; i < n; i++ {
|
||||
go func(i int) {
|
||||
defer func() { done <- struct{}{} }()
|
||||
if i%2 == 0 {
|
||||
s.SetLang("en")
|
||||
} else {
|
||||
_ = s.Lang()
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatalf("并发测试超时:仅 %d/%d 完成", i, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApp_Singleton(t *testing.T) {
|
||||
// 单例默认语言为 zh-CN
|
||||
if App.Lang() != i18n.Default {
|
||||
t.Errorf("App.Lang() = %q, 期望默认 %q", App.Lang(), i18n.Default)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user