feat(geoip): 添加基于国家代码的 GeoIP 访问控制功能
- 新增 GeoIPConfig 配置结构,支持 MaxMind MMDB 数据库 - 实现 GeoIPLookup 查询器,带 LRU 缓存和 TTL 支持 - AccessControl 集成 GeoIP 检查,按国家代码过滤请求 - 支持私有 IP 特殊处理策略 (allow/deny) - 添加完整的单元测试和配置验证测试 - 新增 stream-udp.conf 示例配置文档 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4f6a7be44c
commit
103e8ff0cf
110
docs/config/advanced/stream-udp.conf
Normal file
110
docs/config/advanced/stream-udp.conf
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
# ============================================================
|
||||||
|
# Lolly UDP Stream 代理配置示例
|
||||||
|
# ============================================================
|
||||||
|
#
|
||||||
|
# 功能说明:
|
||||||
|
# - UDP 四层代理(DNS、游戏服务器、VoIP 等)
|
||||||
|
# - 会话管理和超时控制
|
||||||
|
# - 负载均衡支持
|
||||||
|
#
|
||||||
|
# Lolly 对应配置:
|
||||||
|
# stream:
|
||||||
|
# - listen: ":53"
|
||||||
|
# protocol: "udp"
|
||||||
|
# timeout: 60s # 默认值
|
||||||
|
# upstream:
|
||||||
|
# targets:
|
||||||
|
# - addr: "dns1:53"
|
||||||
|
# weight: 1
|
||||||
|
# - addr: "dns2:53"
|
||||||
|
# weight: 1
|
||||||
|
# load_balance: "round_robin"
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# DNS UDP 代理配置示例
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# YAML 格式:
|
||||||
|
stream:
|
||||||
|
# DNS 服务器代理
|
||||||
|
- listen: ":53"
|
||||||
|
protocol: "udp"
|
||||||
|
timeout: 60s # 会话超时,默认 60 秒
|
||||||
|
# DNS 请求通常很快,可设置较短如 30s
|
||||||
|
upstream:
|
||||||
|
targets:
|
||||||
|
- addr: "8.8.8.8:53"
|
||||||
|
weight: 1
|
||||||
|
- addr: "8.8.4.4:53"
|
||||||
|
weight: 1
|
||||||
|
load_balance: "round_robin"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# 游戏服务器 UDP 代理配置示例
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# 游戏服务器需要长会话,建议使用 ip_hash 保持会话一致性
|
||||||
|
#
|
||||||
|
stream:
|
||||||
|
- listen: ":27015"
|
||||||
|
protocol: "udp"
|
||||||
|
timeout: 300s # 游戏会话较长,设置 5 分钟超时
|
||||||
|
upstream:
|
||||||
|
targets:
|
||||||
|
- addr: "game1:27015"
|
||||||
|
weight: 3
|
||||||
|
- addr: "game2:27015"
|
||||||
|
weight: 1
|
||||||
|
load_balance: "ip_hash" # IP Hash 保持会话一致性
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# VoIP SIP UDP 代理配置示例
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# VoIP 服务需要稳定连接
|
||||||
|
#
|
||||||
|
stream:
|
||||||
|
- listen: ":5060"
|
||||||
|
protocol: "udp"
|
||||||
|
timeout: 180s # VoIP 会话超时 3 分钟
|
||||||
|
upstream:
|
||||||
|
targets:
|
||||||
|
- addr: "sip1:5060"
|
||||||
|
weight: 1
|
||||||
|
- addr: "sip2:5060"
|
||||||
|
weight: 1
|
||||||
|
load_balance: "least_conn" # 最少连接,均匀分配
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# UDP Stream 配置参数详解
|
||||||
|
# ============================================================
|
||||||
|
#
|
||||||
|
# 1. UDP vs TCP:
|
||||||
|
# - UDP: 无连接,数据报协议,适合实时应用
|
||||||
|
# - TCP: 有连接,流协议,适合可靠传输
|
||||||
|
#
|
||||||
|
# 2. 会话管理:
|
||||||
|
# - Lolly 自动管理 UDP 会话
|
||||||
|
# - 同一客户端 IP 映射到同一后端
|
||||||
|
# - 会话空闲超时后自动清理
|
||||||
|
#
|
||||||
|
# 3. timeout 参数:
|
||||||
|
# - 默认值: 60 秒 (未配置时使用)
|
||||||
|
# - DNS: 建议 30-60 秒
|
||||||
|
# - 游戏服务器: 建议 300 秒 (5 分钟)
|
||||||
|
# - VoIP: 建议 180 秒 (3 分钟)
|
||||||
|
# - Syslog: 建议 60 秒
|
||||||
|
#
|
||||||
|
# 4. 负载均衡算法:
|
||||||
|
# - round_robin: 轮询(默认)
|
||||||
|
# - weighted_round_robin: 加权轮询
|
||||||
|
# - least_conn: 最少连接
|
||||||
|
# - ip_hash: IP 哈希(推荐游戏服务器、VoIP)
|
||||||
|
#
|
||||||
|
# 5. 适用场景:
|
||||||
|
# - DNS 服务器代理
|
||||||
|
# - 游戏服务器代理 (CS2, Minecraft 等)
|
||||||
|
# - VoIP 服务代理 (SIP, RTP)
|
||||||
|
# - 日志收集服务 (Syslog)
|
||||||
|
# - NTP 时间服务器
|
||||||
@ -275,17 +275,50 @@ type StaticConfig struct {
|
|||||||
// interval: 10s
|
// interval: 10s
|
||||||
// path: "/health"
|
// path: "/health"
|
||||||
type ProxyConfig struct {
|
type ProxyConfig struct {
|
||||||
Path string `yaml:"path"`
|
Path string `yaml:"path"`
|
||||||
LoadBalance string `yaml:"load_balance"`
|
LoadBalance string `yaml:"load_balance"`
|
||||||
HashKey string `yaml:"hash_key"`
|
HashKey string `yaml:"hash_key"`
|
||||||
ClientMaxBodySize string `yaml:"client_max_body_size"`
|
ClientMaxBodySize string `yaml:"client_max_body_size"`
|
||||||
Headers ProxyHeaders `yaml:"headers"`
|
Headers ProxyHeaders `yaml:"headers"`
|
||||||
Targets []ProxyTarget `yaml:"targets"`
|
Targets []ProxyTarget `yaml:"targets"`
|
||||||
HealthCheck HealthCheckConfig `yaml:"health_check"`
|
BalancerByLua BalancerByLuaConfig `yaml:"balancer_by_lua"`
|
||||||
NextUpstream NextUpstreamConfig `yaml:"next_upstream"`
|
HealthCheck HealthCheckConfig `yaml:"health_check"`
|
||||||
Cache ProxyCacheConfig `yaml:"cache"`
|
NextUpstream NextUpstreamConfig `yaml:"next_upstream"`
|
||||||
Timeout ProxyTimeout `yaml:"timeout"`
|
Cache ProxyCacheConfig `yaml:"cache"`
|
||||||
VirtualNodes int `yaml:"virtual_nodes"`
|
Timeout ProxyTimeout `yaml:"timeout"`
|
||||||
|
VirtualNodes int `yaml:"virtual_nodes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BalancerByLuaConfig Lua 负载均衡配置
|
||||||
|
//
|
||||||
|
// 使用 Lua 脚本动态选择后端目标,支持自定义负载均衡逻辑。
|
||||||
|
//
|
||||||
|
// 注意事项:
|
||||||
|
// - Script 为 Lua 脚本文件路径
|
||||||
|
// - Timeout 控制脚本执行超时
|
||||||
|
// - Fallback 指定 Lua 失败时的备用算法
|
||||||
|
//
|
||||||
|
// 使用示例:
|
||||||
|
//
|
||||||
|
// balancer_by_lua:
|
||||||
|
// enabled: true
|
||||||
|
// script: "/etc/lolly/scripts/balancer.lua"
|
||||||
|
// timeout: 100ms
|
||||||
|
// fallback: "round_robin"
|
||||||
|
type BalancerByLuaConfig struct {
|
||||||
|
// Script Lua 脚本路径
|
||||||
|
Script string `yaml:"script"`
|
||||||
|
|
||||||
|
// Fallback 失败时使用的默认负载均衡算法
|
||||||
|
// 默认值: "round_robin"
|
||||||
|
Fallback string `yaml:"fallback"`
|
||||||
|
|
||||||
|
// Timeout 执行超时
|
||||||
|
// 默认值: 100ms
|
||||||
|
Timeout time.Duration `yaml:"timeout"`
|
||||||
|
|
||||||
|
// Enabled 是否启用
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProxyTarget 后端目标配置。
|
// ProxyTarget 后端目标配置。
|
||||||
@ -597,12 +630,13 @@ type SecurityConfig struct {
|
|||||||
|
|
||||||
// AccessConfig IP 访问控制配置。
|
// AccessConfig IP 访问控制配置。
|
||||||
//
|
//
|
||||||
// 通过 IP 地址或 CIDR 范围控制访问权限。
|
// 通过 IP 地址或 CIDR 范围控制访问权限,支持基于 GeoIP 的国家代码访问控制。
|
||||||
//
|
//
|
||||||
// 注意事项:
|
// 注意事项:
|
||||||
// - Allow 和 Deny 列表按配置顺序匹配
|
// - Allow 和 Deny 列表按配置顺序匹配
|
||||||
// - Default 指定未匹配时的默认动作
|
// - Default 指定未匹配时的默认动作
|
||||||
// - TrustedProxies 用于正确获取客户端真实 IP
|
// - TrustedProxies 用于正确获取客户端真实 IP
|
||||||
|
// - GeoIP 配置启用后,会基于国家代码进行二次检查
|
||||||
// - 支持 IPv4 和 IPv6 地址格式
|
// - 支持 IPv4 和 IPv6 地址格式
|
||||||
//
|
//
|
||||||
// 使用示例:
|
// 使用示例:
|
||||||
@ -612,6 +646,15 @@ type SecurityConfig struct {
|
|||||||
// deny: ["192.168.1.100"]
|
// deny: ["192.168.1.100"]
|
||||||
// default: "deny"
|
// default: "deny"
|
||||||
// trusted_proxies: ["172.16.0.0/16"]
|
// trusted_proxies: ["172.16.0.0/16"]
|
||||||
|
// geoip:
|
||||||
|
// enabled: true
|
||||||
|
// database: "/var/lib/geoip/GeoIP2-Country.mmdb"
|
||||||
|
// allow_countries: ["US", "JP", "GB"]
|
||||||
|
// deny_countries: ["CN", "RU"]
|
||||||
|
// default: "deny"
|
||||||
|
// cache_size: 10000
|
||||||
|
// cache_ttl: 1h
|
||||||
|
// private_ip_behavior: "allow"
|
||||||
type AccessConfig struct {
|
type AccessConfig struct {
|
||||||
// Allow 允许的 IP/CIDR 列表
|
// Allow 允许的 IP/CIDR 列表
|
||||||
// 配置允许访问的 IP 地址或网段
|
// 配置允许访问的 IP 地址或网段
|
||||||
@ -621,13 +664,49 @@ type AccessConfig struct {
|
|||||||
// 配置拒绝访问的 IP 地址或网段
|
// 配置拒绝访问的 IP 地址或网段
|
||||||
Deny []string `yaml:"deny"`
|
Deny []string `yaml:"deny"`
|
||||||
|
|
||||||
|
// TrustedProxies 可信代理 CIDR 列表
|
||||||
|
// 用于正确解析 X-Forwarded-For 头部获取真实客户端 IP
|
||||||
|
TrustedProxies []string `yaml:"trusted_proxies"`
|
||||||
|
|
||||||
// Default 默认动作
|
// Default 默认动作
|
||||||
// 未匹配任何规则时的处理方式:allow 或 deny
|
// 未匹配任何规则时的处理方式:allow 或 deny
|
||||||
Default string `yaml:"default"`
|
Default string `yaml:"default"`
|
||||||
|
|
||||||
// TrustedProxies 可信代理 CIDR 列表
|
// GeoIP GeoIP 国家代码访问控制配置
|
||||||
// 用于正确解析 X-Forwarded-For 头部获取真实客户端 IP
|
GeoIP GeoIPConfig `yaml:"geoip"`
|
||||||
TrustedProxies []string `yaml:"trusted_proxies"`
|
}
|
||||||
|
|
||||||
|
// GeoIPConfig GeoIP 访问控制配置。
|
||||||
|
//
|
||||||
|
// 通过 MaxMind GeoIP2 数据库查询 IP 所属国家,实现基于国家代码的访问控制。
|
||||||
|
//
|
||||||
|
// 注意事项:
|
||||||
|
// - Database 为 GeoIP2 数据库文件路径(.mmdb 格式)
|
||||||
|
// - AllowCountries 和 DenyCountries 使用 ISO 3166-1 alpha-2 国家代码
|
||||||
|
// - CacheSize 设置 LRU 缓存最大条目数,0 表示使用默认值 10000
|
||||||
|
// - CacheTTL 设置缓存有效期,0 表示使用默认值 1 小时
|
||||||
|
// - PrivateIPBehavior 控制私有 IP 的处理策略
|
||||||
|
//
|
||||||
|
// 使用示例:
|
||||||
|
//
|
||||||
|
// geoip:
|
||||||
|
// enabled: true
|
||||||
|
// database: "/var/lib/geoip/GeoIP2-Country.mmdb"
|
||||||
|
// allow_countries: ["US", "JP", "GB"]
|
||||||
|
// deny_countries: ["CN", "RU"]
|
||||||
|
// default: "deny"
|
||||||
|
// cache_size: 10000
|
||||||
|
// cache_ttl: 1h
|
||||||
|
// private_ip_behavior: "allow"
|
||||||
|
type GeoIPConfig struct {
|
||||||
|
Database string `yaml:"database"`
|
||||||
|
Default string `yaml:"default"`
|
||||||
|
PrivateIPBehavior string `yaml:"private_ip_behavior"`
|
||||||
|
AllowCountries []string `yaml:"allow_countries"`
|
||||||
|
DenyCountries []string `yaml:"deny_countries"`
|
||||||
|
CacheSize int `yaml:"cache_size"`
|
||||||
|
CacheTTL time.Duration `yaml:"cache_ttl"`
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RateLimitConfig 速率限制配置。
|
// RateLimitConfig 速率限制配置。
|
||||||
@ -1536,7 +1615,7 @@ func Save(cfg *Config, path string) error {
|
|||||||
return fmt.Errorf("序列化配置失败: %w", err)
|
return fmt.Errorf("序列化配置失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||||
return fmt.Errorf("写入配置文件失败: %w", err)
|
return fmt.Errorf("写入配置文件失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -543,9 +543,89 @@ func validateAccess(a *AccessConfig) error {
|
|||||||
return fmt.Errorf("无效的 default 动作: %s(仅允许 allow 或 deny)", a.Default)
|
return fmt.Errorf("无效的 default 动作: %s(仅允许 allow 或 deny)", a.Default)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 验证 GeoIP 配置
|
||||||
|
if err := validateGeoIP(&a.GeoIP); err != nil {
|
||||||
|
return fmt.Errorf("geoip: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateGeoIP 验证 GeoIP 配置。
|
||||||
|
//
|
||||||
|
// 检查 GeoIP 数据库路径、国家代码格式、缓存设置等。
|
||||||
|
//
|
||||||
|
// 参数:
|
||||||
|
// - g: GeoIP 配置对象
|
||||||
|
//
|
||||||
|
// 返回值:
|
||||||
|
// - error: 验证失败时返回错误信息,成功返回 nil
|
||||||
|
func validateGeoIP(g *GeoIPConfig) error {
|
||||||
|
// 未启用时跳过验证
|
||||||
|
if !g.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证数据库路径
|
||||||
|
if g.Database == "" {
|
||||||
|
return errors.New("database 是必填项(启用 GeoIP 时)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证国家代码格式 (ISO 3166-1 alpha-2)
|
||||||
|
for _, c := range g.AllowCountries {
|
||||||
|
if !isValidCountryCode(c) {
|
||||||
|
return fmt.Errorf("无效的 allow_countries 国家代码: %s(应为 2 位大写字母)", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range g.DenyCountries {
|
||||||
|
if !isValidCountryCode(c) {
|
||||||
|
return fmt.Errorf("无效的 deny_countries 国家代码: %s(应为 2 位大写字母)", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 PrivateIPBehavior
|
||||||
|
validBehaviors := []string{"", "allow", "deny", "bypass"}
|
||||||
|
if !slices.Contains(validBehaviors, g.PrivateIPBehavior) {
|
||||||
|
return fmt.Errorf("无效的 private_ip_behavior: %s(仅支持 allow, deny, bypass)", g.PrivateIPBehavior)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证缓存大小
|
||||||
|
if g.CacheSize < 0 {
|
||||||
|
return errors.New("cache_size 不能为负数")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证缓存 TTL
|
||||||
|
if g.CacheTTL < 0 {
|
||||||
|
return errors.New("cache_ttl 不能为负数")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证默认动作
|
||||||
|
if g.Default != "" && g.Default != "allow" && g.Default != "deny" {
|
||||||
|
return fmt.Errorf("无效的 default 动作: %s(仅允许 allow 或 deny)", g.Default)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidCountryCode 验证 ISO 3166-1 alpha-2 国家代码。
|
||||||
|
//
|
||||||
|
// 参数:
|
||||||
|
// - code: 国家代码字符串
|
||||||
|
//
|
||||||
|
// 返回值:
|
||||||
|
// - bool: true 表示有效的国家代码
|
||||||
|
func isValidCountryCode(code string) bool {
|
||||||
|
if len(code) != 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range code {
|
||||||
|
if c < 'A' || c > 'Z' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// validateAuth 验证认证配置。
|
// validateAuth 验证认证配置。
|
||||||
//
|
//
|
||||||
// 检查认证类型、哈希算法和用户列表的有效性。
|
// 检查认证类型、哈希算法和用户列表的有效性。
|
||||||
|
|||||||
171
internal/config/validate_geoip_test.go
Normal file
171
internal/config/validate_geoip_test.go
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
// Package config 提供 YAML 配置文件的解析、验证和默认配置生成功能测试。
|
||||||
|
//
|
||||||
|
// 该文件包含 GeoIP 配置验证相关的测试。
|
||||||
|
//
|
||||||
|
// 作者:xfy
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestValidateGeoIP 测试 GeoIP 配置验证。
|
||||||
|
func TestValidateGeoIP(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
errMsg string
|
||||||
|
config GeoIPConfig
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "未启用时跳过验证",
|
||||||
|
config: GeoIPConfig{
|
||||||
|
Enabled: false,
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "启用但缺少数据库路径",
|
||||||
|
config: GeoIPConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Database: "",
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "database 是必填项",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "有效的 GeoIP 配置",
|
||||||
|
config: GeoIPConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Database: "/var/lib/geoip/GeoIP2-Country.mmdb",
|
||||||
|
AllowCountries: []string{"US", "JP"},
|
||||||
|
DenyCountries: []string{"CN"},
|
||||||
|
Default: "deny",
|
||||||
|
CacheSize: 10000,
|
||||||
|
PrivateIPBehavior: "allow",
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "无效的国家代码(小写)",
|
||||||
|
config: GeoIPConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Database: "/var/lib/geoip/GeoIP2-Country.mmdb",
|
||||||
|
AllowCountries: []string{"us"},
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "无效的 allow_countries",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "无效的国家代码(3位)",
|
||||||
|
config: GeoIPConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Database: "/var/lib/geoip/GeoIP2-Country.mmdb",
|
||||||
|
DenyCountries: []string{"USA"},
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "无效的 deny_countries",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "无效的 private_ip_behavior",
|
||||||
|
config: GeoIPConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Database: "/var/lib/geoip/GeoIP2-Country.mmdb",
|
||||||
|
PrivateIPBehavior: "invalid",
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "无效的 private_ip_behavior",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "负的 cache_size",
|
||||||
|
config: GeoIPConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Database: "/var/lib/geoip/GeoIP2-Country.mmdb",
|
||||||
|
CacheSize: -1,
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "cache_size 不能为负数",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "负的 cache_ttl",
|
||||||
|
config: GeoIPConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Database: "/var/lib/geoip/GeoIP2-Country.mmdb",
|
||||||
|
CacheTTL: -1,
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "cache_ttl 不能为负数",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "无效的 default 动作",
|
||||||
|
config: GeoIPConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Database: "/var/lib/geoip/GeoIP2-Country.mmdb",
|
||||||
|
Default: "invalid",
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "无效的 default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "有效的 private_ip_behavior: deny",
|
||||||
|
config: GeoIPConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Database: "/var/lib/geoip/GeoIP2-Country.mmdb",
|
||||||
|
PrivateIPBehavior: "deny",
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "有效的 private_ip_behavior: bypass",
|
||||||
|
config: GeoIPConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Database: "/var/lib/geoip/GeoIP2-Country.mmdb",
|
||||||
|
PrivateIPBehavior: "bypass",
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := validateGeoIP(&tt.config)
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Error(t, err)
|
||||||
|
if tt.errMsg != "" {
|
||||||
|
assert.Contains(t, err.Error(), tt.errMsg)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIsValidCountryCode 测试国家代码验证。
|
||||||
|
func TestIsValidCountryCode(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
code string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{"US", true},
|
||||||
|
{"JP", true},
|
||||||
|
{"GB", true},
|
||||||
|
{"CN", true},
|
||||||
|
{"us", false}, // 小写
|
||||||
|
{"Us", false}, // 混合大小写
|
||||||
|
{"USA", false}, // 3位
|
||||||
|
{"U", false}, // 1位
|
||||||
|
{"U1", false}, // 包含数字
|
||||||
|
{"U-", false}, // 包含连字符
|
||||||
|
{"", false}, // 空字符串
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.code, func(t *testing.T) {
|
||||||
|
result := isValidCountryCode(tt.code)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -29,6 +29,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/valyala/fasthttp"
|
"github.com/valyala/fasthttp"
|
||||||
"rua.plus/lolly/internal/config"
|
"rua.plus/lolly/internal/config"
|
||||||
@ -44,18 +45,22 @@ const (
|
|||||||
// ActionDeny 拒绝请求(返回 403 Forbidden)
|
// ActionDeny 拒绝请求(返回 403 Forbidden)
|
||||||
ActionDeny
|
ActionDeny
|
||||||
|
|
||||||
accessAllow = "allow"
|
accessAllow = "allow"
|
||||||
accessDeny = "deny"
|
accessDeny = "deny"
|
||||||
|
geoPrivateAllow = "PRIVATE_ALLOW"
|
||||||
|
geoPrivateDeny = "PRIVATE_DENY"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AccessControl 实现 IP 访问控制中间件。
|
// AccessControl 实现 IP 访问控制中间件。
|
||||||
//
|
//
|
||||||
// 根据配置的允许/拒绝 CIDR 列表检查入站请求。
|
// 根据配置的允许/拒绝 CIDR 列表和 GeoIP 国家代码检查入站请求。
|
||||||
// 支持动态更新访问控制列表。
|
// 支持动态更新访问控制列表和 GeoIP 配置。
|
||||||
type AccessControl struct {
|
type AccessControl struct {
|
||||||
|
geoip *GeoIPLookup
|
||||||
allowList []net.IPNet
|
allowList []net.IPNet
|
||||||
denyList []net.IPNet
|
denyList []net.IPNet
|
||||||
trustedProxies []net.IPNet
|
trustedProxies []net.IPNet
|
||||||
|
geoipConfig config.GeoIPConfig
|
||||||
defaultAction Action
|
defaultAction Action
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
@ -114,6 +119,31 @@ func NewAccessControl(cfg *config.AccessConfig) (*AccessControl, error) {
|
|||||||
return nil, fmt.Errorf("invalid default action: %s", cfg.Default)
|
return nil, fmt.Errorf("invalid default action: %s", cfg.Default)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 初始化 GeoIP(如果启用)
|
||||||
|
if cfg.GeoIP.Enabled && cfg.GeoIP.Database != "" {
|
||||||
|
// 设置默认值
|
||||||
|
cacheSize := cfg.GeoIP.CacheSize
|
||||||
|
if cacheSize <= 0 {
|
||||||
|
cacheSize = 10000 // 默认 10000 条
|
||||||
|
}
|
||||||
|
ttl := cfg.GeoIP.CacheTTL
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = time.Hour // 默认 1 小时
|
||||||
|
}
|
||||||
|
|
||||||
|
geoip, err := NewGeoIPLookup(
|
||||||
|
cfg.GeoIP.Database,
|
||||||
|
cacheSize,
|
||||||
|
ttl,
|
||||||
|
cfg.GeoIP.PrivateIPBehavior,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("init geoip: %w", err)
|
||||||
|
}
|
||||||
|
ac.geoip = geoip
|
||||||
|
ac.geoipConfig = cfg.GeoIP
|
||||||
|
}
|
||||||
|
|
||||||
return ac, nil
|
return ac, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -150,7 +180,12 @@ func (ac *AccessControl) Process(next fasthttp.RequestHandler) fasthttp.RequestH
|
|||||||
|
|
||||||
// Check 检查 IP 地址是否允许访问。
|
// Check 检查 IP 地址是否允许访问。
|
||||||
//
|
//
|
||||||
// 评估顺序:先检查拒绝列表,再检查允许列表,最后使用默认操作。
|
// 评估顺序:
|
||||||
|
// 1. 检查 CIDR 拒绝列表(显式拒绝优先)
|
||||||
|
// 2. 检查 GeoIP 国家拒绝(如果启用)
|
||||||
|
// 3. 检查 CIDR 允许列表
|
||||||
|
// 4. 检查 GeoIP 国家允许(如果启用)
|
||||||
|
// 5. 返回默认操作
|
||||||
//
|
//
|
||||||
// 参数:
|
// 参数:
|
||||||
// - ip: 待检查的 IP 地址
|
// - ip: 待检查的 IP 地址
|
||||||
@ -161,21 +196,55 @@ func (ac *AccessControl) Check(ip net.IP) bool {
|
|||||||
ac.mu.RLock()
|
ac.mu.RLock()
|
||||||
defer ac.mu.RUnlock()
|
defer ac.mu.RUnlock()
|
||||||
|
|
||||||
// 先检查拒绝列表(显式拒绝优先)
|
// 1. 先检查 CIDR 拒绝列表(显式拒绝优先)
|
||||||
for _, network := range ac.denyList {
|
for _, network := range ac.denyList {
|
||||||
if network.Contains(ip) {
|
if network.Contains(ip) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查允许列表
|
// 2. 检查 GeoIP 国家拒绝(如果启用)
|
||||||
|
if ac.geoip != nil && ac.geoipConfig.Enabled {
|
||||||
|
country, err := ac.geoip.LookupCountry(ip)
|
||||||
|
if err == nil {
|
||||||
|
// 处理私有 IP 特殊标记
|
||||||
|
if country == geoPrivateAllow {
|
||||||
|
// 私有 IP 自动允许,跳过国家检查
|
||||||
|
goto checkAllow
|
||||||
|
}
|
||||||
|
if country == geoPrivateDeny {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range ac.geoipConfig.DenyCountries {
|
||||||
|
if country == c {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkAllow:
|
||||||
|
// 3. 检查 CIDR 允许列表
|
||||||
for _, network := range ac.allowList {
|
for _, network := range ac.allowList {
|
||||||
if network.Contains(ip) {
|
if network.Contains(ip) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回默认操作
|
// 4. 检查 GeoIP 国家允许(如果启用)
|
||||||
|
if ac.geoip != nil && ac.geoipConfig.Enabled {
|
||||||
|
country, err := ac.geoip.LookupCountry(ip)
|
||||||
|
if err == nil && country != geoPrivateDeny {
|
||||||
|
for _, c := range ac.geoipConfig.AllowCountries {
|
||||||
|
if country == c {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 返回默认操作
|
||||||
return ac.defaultAction == ActionAllow
|
return ac.defaultAction == ActionAllow
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -428,7 +497,7 @@ func (ac *AccessControl) GetStats() AccessStats {
|
|||||||
func actionToString(action Action) string {
|
func actionToString(action Action) string {
|
||||||
switch action {
|
switch action {
|
||||||
case ActionAllow:
|
case ActionAllow:
|
||||||
return "allow"
|
return accessAllow
|
||||||
case ActionDeny:
|
case ActionDeny:
|
||||||
return accessDeny
|
return accessDeny
|
||||||
default:
|
default:
|
||||||
@ -436,5 +505,21 @@ func actionToString(action Action) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close 释放资源。
|
||||||
|
//
|
||||||
|
// 必须在服务器停止时调用,释放 GeoIP 数据库连接。
|
||||||
|
//
|
||||||
|
// 返回值:
|
||||||
|
// - error: 关闭失败时返回错误
|
||||||
|
func (ac *AccessControl) Close() error {
|
||||||
|
ac.mu.Lock()
|
||||||
|
defer ac.mu.Unlock()
|
||||||
|
|
||||||
|
if ac.geoip != nil {
|
||||||
|
return ac.geoip.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// 验证接口实现
|
// 验证接口实现
|
||||||
var _ middleware.Middleware = (*AccessControl)(nil)
|
var _ middleware.Middleware = (*AccessControl)(nil)
|
||||||
|
|||||||
241
internal/middleware/security/geoip.go
Normal file
241
internal/middleware/security/geoip.go
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
// Package security 提供安全相关的 HTTP 中间件。
|
||||||
|
//
|
||||||
|
// 该文件实现 GeoIP 查询功能,支持基于国家代码的访问控制,
|
||||||
|
// 使用 LRU 缓存提高查询性能。
|
||||||
|
//
|
||||||
|
// 使用示例:
|
||||||
|
//
|
||||||
|
// geoip, err := security.NewGeoIPLookup("/var/lib/geoip/GeoIP2-Country.mmdb", 10000, time.Hour, "allow")
|
||||||
|
// if err != nil {
|
||||||
|
// log.Fatal(err)
|
||||||
|
// }
|
||||||
|
// defer geoip.Close()
|
||||||
|
//
|
||||||
|
// country, err := geoip.LookupCountry(ip)
|
||||||
|
// if err != nil {
|
||||||
|
// log.Printf("GeoIP lookup failed: %v", err)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// 作者:xfy
|
||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
lru "github.com/hashicorp/golang-lru/v2"
|
||||||
|
"github.com/oschwald/geoip2-golang"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GeoIPLookup GeoIP 查询器(带 LRU 缓存)。
|
||||||
|
//
|
||||||
|
// 使用 MaxMind GeoIP2 数据库查询 IP 地址所属国家,
|
||||||
|
// 通过 LRU 缓存减少数据库查询次数,提高性能。
|
||||||
|
type GeoIPLookup struct {
|
||||||
|
db *geoip2.Reader
|
||||||
|
cache *lru.Cache[string, *cachedCountry]
|
||||||
|
privateIPBehavior string
|
||||||
|
ttl time.Duration
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// cachedCountry 缓存的国家代码条目。
|
||||||
|
type cachedCountry struct {
|
||||||
|
expires time.Time
|
||||||
|
country string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeoIPStats GeoIP 缓存统计信息。
|
||||||
|
type GeoIPStats struct {
|
||||||
|
CacheSize int
|
||||||
|
CacheMaxSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGeoIPLookup 创建 GeoIP 查询器。
|
||||||
|
//
|
||||||
|
// 打开 GeoIP2 数据库文件并初始化 LRU 缓存。
|
||||||
|
//
|
||||||
|
// 参数:
|
||||||
|
// - dbPath: GeoIP2 数据库文件路径(.mmdb 格式)
|
||||||
|
// - cacheSize: LRU 缓存最大条目数(硬限制)
|
||||||
|
// - ttl: 缓存条目有效期
|
||||||
|
// - privateIPBehavior: 私有 IP 处理策略("allow", "deny", "bypass")
|
||||||
|
//
|
||||||
|
// 返回值:
|
||||||
|
// - *GeoIPLookup: 查询器实例
|
||||||
|
// - error: 数据库打开失败或缓存创建失败时返回错误
|
||||||
|
func NewGeoIPLookup(dbPath string, cacheSize int, ttl time.Duration, privateIPBehavior string) (*GeoIPLookup, error) {
|
||||||
|
if dbPath == "" {
|
||||||
|
return nil, errors.New("geoip database path is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开 GeoIP2 数据库
|
||||||
|
db, err := geoip2.Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open geoip database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 LRU 缓存
|
||||||
|
cache, err := lru.New[string, *cachedCountry](cacheSize)
|
||||||
|
if err != nil {
|
||||||
|
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf("create lru cache: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认私有 IP 行为
|
||||||
|
if privateIPBehavior == "" {
|
||||||
|
privateIPBehavior = "allow"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &GeoIPLookup{
|
||||||
|
db: db,
|
||||||
|
cache: cache,
|
||||||
|
ttl: ttl,
|
||||||
|
privateIPBehavior: privateIPBehavior,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupCountry 查询 IP 所属国家。
|
||||||
|
//
|
||||||
|
// 返回 ISO 3166-1 alpha-2 国家代码(如 "CN", "US")。
|
||||||
|
// 查询结果会被缓存,减少数据库访问。
|
||||||
|
//
|
||||||
|
// 参数:
|
||||||
|
// - ip: 待查询的 IP 地址
|
||||||
|
//
|
||||||
|
// 返回值:
|
||||||
|
// - string: ISO 3166-1 alpha-2 国家代码
|
||||||
|
// - error: 查询失败时返回错误
|
||||||
|
func (g *GeoIPLookup) LookupCountry(ip net.IP) (string, error) {
|
||||||
|
// 检查私有 IP
|
||||||
|
if isPrivateIP(ip) {
|
||||||
|
switch g.privateIPBehavior {
|
||||||
|
case "allow":
|
||||||
|
return "PRIVATE_ALLOW", nil // 特殊标记,表示允许
|
||||||
|
case accessDeny:
|
||||||
|
return "PRIVATE_DENY", nil // 特殊标记,表示拒绝
|
||||||
|
case "bypass":
|
||||||
|
return "", errors.New("private IP bypassed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ipStr := ip.String()
|
||||||
|
|
||||||
|
// 检查缓存(读锁)
|
||||||
|
g.mu.RLock()
|
||||||
|
if cached, ok := g.cache.Get(ipStr); ok {
|
||||||
|
if time.Now().Before(cached.expires) {
|
||||||
|
g.mu.RUnlock()
|
||||||
|
return cached.country, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.mu.RUnlock()
|
||||||
|
|
||||||
|
// 查询数据库(写锁)
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
|
||||||
|
// 双重检查(可能已被其他 goroutine 更新)
|
||||||
|
if cached, ok := g.cache.Get(ipStr); ok {
|
||||||
|
if time.Now().Before(cached.expires) {
|
||||||
|
return cached.country, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询数据库
|
||||||
|
country, err := g.db.Country(ip)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("geoip lookup: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
isoCode := country.Country.IsoCode
|
||||||
|
if isoCode == "" {
|
||||||
|
isoCode = "UNKNOWN"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 存入缓存
|
||||||
|
g.cache.Add(ipStr, &cachedCountry{
|
||||||
|
country: isoCode,
|
||||||
|
expires: time.Now().Add(g.ttl),
|
||||||
|
})
|
||||||
|
|
||||||
|
return isoCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 关闭数据库连接。
|
||||||
|
//
|
||||||
|
// 必须在服务器停止时调用,释放 GeoIP2 数据库资源。
|
||||||
|
//
|
||||||
|
// 返回值:
|
||||||
|
// - error: 关闭失败时返回错误
|
||||||
|
func (g *GeoIPLookup) Close() error {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
|
||||||
|
if g.db != nil {
|
||||||
|
return g.db.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStats 返回缓存统计信息。
|
||||||
|
//
|
||||||
|
// 返回值:
|
||||||
|
// - GeoIPStats: 包含当前缓存大小和最大缓存大小的统计对象
|
||||||
|
func (g *GeoIPLookup) GetStats() GeoIPStats {
|
||||||
|
g.mu.RLock()
|
||||||
|
defer g.mu.RUnlock()
|
||||||
|
|
||||||
|
return GeoIPStats{
|
||||||
|
CacheSize: g.cache.Len(),
|
||||||
|
CacheMaxSize: g.cache.Len(), // LRU 缓存容量与当前大小相同(已淘汰的已被移除)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPrivateIP 检查是否为私有 IP 地址。
|
||||||
|
//
|
||||||
|
// 支持的私有地址范围:
|
||||||
|
// - 10.0.0.0/8
|
||||||
|
// - 172.16.0.0/12
|
||||||
|
// - 192.168.0.0/16
|
||||||
|
// - 127.0.0.0/8(回环)
|
||||||
|
// - IPv6 本地地址
|
||||||
|
//
|
||||||
|
// 参数:
|
||||||
|
// - ip: 待检查的 IP 地址
|
||||||
|
//
|
||||||
|
// 返回值:
|
||||||
|
// - bool: true 表示是私有 IP
|
||||||
|
func isPrivateIP(ip net.IP) bool {
|
||||||
|
// IPv4 私有地址范围
|
||||||
|
privateBlocks := []string{
|
||||||
|
"10.0.0.0/8",
|
||||||
|
"172.16.0.0/12",
|
||||||
|
"192.168.0.0/16",
|
||||||
|
"127.0.0.0/8",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, cidr := range privateBlocks {
|
||||||
|
_, network, err := net.ParseCIDR(cidr)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if network.Contains(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv6 私有地址
|
||||||
|
if ip.To4() == nil {
|
||||||
|
// IPv6 本地地址
|
||||||
|
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
204
internal/middleware/security/geoip_test.go
Normal file
204
internal/middleware/security/geoip_test.go
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
// Package security 提供安全相关的 HTTP 中间件测试。
|
||||||
|
//
|
||||||
|
// 该文件包含 GeoIP 查询功能的单元测试。
|
||||||
|
//
|
||||||
|
// 作者:xfy
|
||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestIsPrivateIP 测试私有 IP 检测功能。
|
||||||
|
func TestIsPrivateIP(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ip string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{"IPv4 私有地址 10.x.x.x", "10.0.0.1", true},
|
||||||
|
{"IPv4 私有地址 172.16.x.x", "172.16.0.1", true},
|
||||||
|
{"IPv4 私有地址 172.31.x.x", "172.31.255.1", true},
|
||||||
|
{"IPv4 私有地址 192.168.x.x", "192.168.1.1", true},
|
||||||
|
{"IPv4 回环地址", "127.0.0.1", true},
|
||||||
|
{"IPv4 公网地址", "8.8.8.8", false},
|
||||||
|
{"IPv4 公网地址", "1.1.1.1", false},
|
||||||
|
{"IPv6 回环地址", "::1", true},
|
||||||
|
{"IPv6 本地链路地址", "fe80::1", true},
|
||||||
|
{"IPv6 公网地址", "2001:4860:4860::8888", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
ip := net.ParseIP(tt.ip)
|
||||||
|
require.NotNil(t, ip, "failed to parse IP: %s", tt.ip)
|
||||||
|
result := isPrivateIP(ip)
|
||||||
|
assert.Equal(t, tt.expected, result, "isPrivateIP(%s)", tt.ip)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewGeoIPLookup_InvalidPath 测试无效数据库路径。
|
||||||
|
func TestNewGeoIPLookup_InvalidPath(t *testing.T) {
|
||||||
|
_, err := NewGeoIPLookup("", 1000, time.Hour, "allow")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "database path is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewGeoIPLookup_NonExistentDB 测试不存在的数据库文件。
|
||||||
|
func TestNewGeoIPLookup_NonExistentDB(t *testing.T) {
|
||||||
|
_, err := NewGeoIPLookup("/nonexistent/path/to/geoip.mmdb", 1000, time.Hour, "allow")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "open geoip database")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGeoIPLookup_PrivateIPBehavior 测试私有 IP 处理策略。
|
||||||
|
func TestGeoIPLookup_PrivateIPBehavior(t *testing.T) {
|
||||||
|
// 注意:这个测试需要有效的 GeoIP2 数据库文件
|
||||||
|
// 如果没有数据库文件,测试会被跳过
|
||||||
|
dbPath := "/var/lib/geoip/GeoIP2-Country.mmdb"
|
||||||
|
|
||||||
|
// 尝试创建 GeoIPLookup
|
||||||
|
geoip, err := NewGeoIPLookup(dbPath, 1000, time.Hour, "allow")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test: GeoIP database not available: %v", err)
|
||||||
|
}
|
||||||
|
defer geoip.Close()
|
||||||
|
|
||||||
|
privateIP := net.ParseIP("192.168.1.1")
|
||||||
|
|
||||||
|
// 测试 allow 策略
|
||||||
|
country, err := geoip.LookupCountry(privateIP)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "PRIVATE_ALLOW", country)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGeoIPLookup_PrivateIPBehavior_Deny 测试私有 IP deny 策略。
|
||||||
|
func TestGeoIPLookup_PrivateIPBehavior_Deny(t *testing.T) {
|
||||||
|
dbPath := "/var/lib/geoip/GeoIP2-Country.mmdb"
|
||||||
|
|
||||||
|
geoip, err := NewGeoIPLookup(dbPath, 1000, time.Hour, "deny")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test: GeoIP database not available: %v", err)
|
||||||
|
}
|
||||||
|
defer geoip.Close()
|
||||||
|
|
||||||
|
privateIP := net.ParseIP("10.0.0.1")
|
||||||
|
|
||||||
|
country, err := geoip.LookupCountry(privateIP)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "PRIVATE_DENY", country)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGeoIPLookup_PrivateIPBehavior_Bypass 测试私有 IP bypass 策略。
|
||||||
|
func TestGeoIPLookup_PrivateIPBehavior_Bypass(t *testing.T) {
|
||||||
|
dbPath := "/var/lib/geoip/GeoIP2-Country.mmdb"
|
||||||
|
|
||||||
|
geoip, err := NewGeoIPLookup(dbPath, 1000, time.Hour, "bypass")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test: GeoIP database not available: %v", err)
|
||||||
|
}
|
||||||
|
defer geoip.Close()
|
||||||
|
|
||||||
|
privateIP := net.ParseIP("172.16.0.1")
|
||||||
|
|
||||||
|
_, err = geoip.LookupCountry(privateIP)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "private IP bypassed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGeoIPLookup_DefaultPrivateIPBehavior 测试默认私有 IP 行为。
|
||||||
|
func TestGeoIPLookup_DefaultPrivateIPBehavior(t *testing.T) {
|
||||||
|
dbPath := "/var/lib/geoip/GeoIP2-Country.mmdb"
|
||||||
|
|
||||||
|
// 空字符串应该使用默认的 "allow"
|
||||||
|
geoip, err := NewGeoIPLookup(dbPath, 1000, time.Hour, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test: GeoIP database not available: %v", err)
|
||||||
|
}
|
||||||
|
defer geoip.Close()
|
||||||
|
|
||||||
|
privateIP := net.ParseIP("127.0.0.1")
|
||||||
|
|
||||||
|
country, err := geoip.LookupCountry(privateIP)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "PRIVATE_ALLOW", country)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGeoIPLookup_GetStats 测试统计信息获取。
|
||||||
|
func TestGeoIPLookup_GetStats(t *testing.T) {
|
||||||
|
dbPath := "/var/lib/geoip/GeoIP2-Country.mmdb"
|
||||||
|
|
||||||
|
geoip, err := NewGeoIPLookup(dbPath, 1000, time.Hour, "allow")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test: GeoIP database not available: %v", err)
|
||||||
|
}
|
||||||
|
defer geoip.Close()
|
||||||
|
|
||||||
|
stats := geoip.GetStats()
|
||||||
|
assert.GreaterOrEqual(t, stats.CacheSize, 0)
|
||||||
|
assert.GreaterOrEqual(t, stats.CacheMaxSize, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGeoIPLookup_CacheBehavior 测试缓存行为。
|
||||||
|
func TestGeoIPLookup_CacheBehavior(t *testing.T) {
|
||||||
|
dbPath := "/var/lib/geoip/GeoIP2-Country.mmdb"
|
||||||
|
|
||||||
|
geoip, err := NewGeoIPLookup(dbPath, 1000, time.Hour, "allow")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test: GeoIP database not available: %v", err)
|
||||||
|
}
|
||||||
|
defer geoip.Close()
|
||||||
|
|
||||||
|
// 使用公网 IP 进行测试(假设 8.8.8.8 是美国)
|
||||||
|
publicIP := net.ParseIP("8.8.8.8")
|
||||||
|
|
||||||
|
// 第一次查询
|
||||||
|
country1, err := geoip.LookupCountry(publicIP)
|
||||||
|
if err != nil {
|
||||||
|
// 数据库中可能没有该 IP 的信息
|
||||||
|
t.Skipf("Skipping test: IP not found in database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第二次查询(应该从缓存返回)
|
||||||
|
country2, err := geoip.LookupCountry(publicIP)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, country1, country2)
|
||||||
|
|
||||||
|
// 验证缓存大小
|
||||||
|
stats := geoip.GetStats()
|
||||||
|
assert.GreaterOrEqual(t, stats.CacheSize, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGeoIPLookup_TTLExpiration 测试缓存 TTL 过期。
|
||||||
|
func TestGeoIPLookup_TTLExpiration(t *testing.T) {
|
||||||
|
dbPath := "/var/lib/geoip/GeoIP2-Country.mmdb"
|
||||||
|
|
||||||
|
// 使用很短的 TTL
|
||||||
|
geoip, err := NewGeoIPLookup(dbPath, 1000, 1*time.Millisecond, "allow")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test: GeoIP database not available: %v", err)
|
||||||
|
}
|
||||||
|
defer geoip.Close()
|
||||||
|
|
||||||
|
publicIP := net.ParseIP("8.8.8.8")
|
||||||
|
|
||||||
|
// 第一次查询
|
||||||
|
_, err = geoip.LookupCountry(publicIP)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test: IP not found in database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待 TTL 过期
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
|
// 再次查询(缓存应该已过期)
|
||||||
|
_, err = geoip.LookupCountry(publicIP)
|
||||||
|
// 不应该报错,只是重新查询数据库
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user