mirror of
https://gitee.com/samwaf/SamWaf.git
synced 2026-11-01 18:51:45 +08:00
+73
-3
@@ -11,6 +11,7 @@ import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -189,7 +190,7 @@ func (w *WafIPLocationApi) GetIPDBConfigApi(c *gin.Context) {
|
||||
resp := IPDBConfigResp{
|
||||
Ipv4Source: getConfigOrDefault("ip_v4_source", "ip2region"),
|
||||
Ipv4Format: getConfigOrDefault("ip_v4_format", "legacy"),
|
||||
Ipv6Source: getConfigOrDefault("ip_v6_source", "geolite2"),
|
||||
Ipv6Source: getConfigOrDefault("ip_v6_source", "ip2region"),
|
||||
Ipv6Format: getConfigOrDefault("ip_v6_format", "legacy"),
|
||||
}
|
||||
response.OkWithDetailed(resp, "获取成功", c)
|
||||
@@ -259,7 +260,14 @@ func (w *WafIPLocationApi) SaveIPDBConfigApi(c *gin.Context) {
|
||||
|
||||
// UploadIPDBFileApi 上传 IP 数据库文件
|
||||
func (w *WafIPLocationApi) UploadIPDBFileApi(c *gin.Context) {
|
||||
// key 是界面上那张表的槽位标识。带了 key 就按槽位定义确定落盘文件名与允许后缀,
|
||||
// 不用再靠扩展名猜用户想传哪个库(.mmdb 到底是 IPv4 还是 IPv6 的,靠猜是猜不准的)。
|
||||
// 不带 key 时退回老的 type+扩展名逻辑,保证老前端/脚本还能用。
|
||||
slotKey := c.PostForm("key")
|
||||
ipType := c.PostForm("type")
|
||||
if slot, ok := iplocation.DatabaseByKey(slotKey); ok && ipType == "" {
|
||||
ipType = slot.UploadType
|
||||
}
|
||||
if ipType != "ipv4" && ipType != "ipv6" && ipType != "ipdb" {
|
||||
response.FailWithMessage("无效的类型参数,必须是 ipv4、ipv6 或 ipdb", c)
|
||||
return
|
||||
@@ -276,6 +284,11 @@ func (w *WafIPLocationApi) UploadIPDBFileApi(c *gin.Context) {
|
||||
response.FailWithMessage("不支持的文件类型,仅支持 .xdb、.mmdb 和 .ipdb 文件", c)
|
||||
return
|
||||
}
|
||||
// 带了 key 就用槽位声明的后缀严格卡一遍,别让用户把 mmdb 传进 ip2region 的槽
|
||||
if slot, ok := iplocation.DatabaseByKey(slotKey); ok && !strings.EqualFold(ext, slot.Accept) {
|
||||
response.FailWithMessage(fmt.Sprintf("【%s】只接受 %s 文件,请确认后重新上传", slot.Desc, slot.Accept), c)
|
||||
return
|
||||
}
|
||||
if ext == ".ipdb" && ipType != "ipdb" {
|
||||
response.FailWithMessage(".ipdb 文件请使用 type=ipdb 上传", c)
|
||||
return
|
||||
@@ -329,9 +342,11 @@ func (w *WafIPLocationApi) UploadIPDBFileApi(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// xdb / mmdb 保存路径
|
||||
// xdb / mmdb 保存路径:优先按槽位定义取,退回老的 type+扩展名推断
|
||||
var finalPath string
|
||||
if ipType == "ipv4" {
|
||||
if slot, ok := iplocation.DatabaseByKey(slotKey); ok {
|
||||
finalPath = filepath.Join(dataDir, slot.FileName)
|
||||
} else if ipType == "ipv4" {
|
||||
if ext == ".xdb" {
|
||||
finalPath = filepath.Join(dataDir, "ip2region.xdb")
|
||||
} else {
|
||||
@@ -412,6 +427,61 @@ func (w *WafIPLocationApi) ReloadIPDBApi(c *gin.Context) {
|
||||
response.OkWithMessage("数据库重新加载成功", c)
|
||||
}
|
||||
|
||||
// CheckIPDBUpgradeApi 检查可在线下载的 IP 数据库
|
||||
//
|
||||
// 只有 ip2region 系列(Apache-2.0)能由 SamWaf 转发分发;GeoLite2 与 ipdb 受各自授权限制,
|
||||
// 只能由用户自行获取后上传。
|
||||
func (w *WafIPLocationApi) CheckIPDBUpgradeApi(c *gin.Context) {
|
||||
dataDir := filepath.Join(utils.GetCurrentDir(), "data")
|
||||
info, err := iplocation.CheckUpgrade(dataDir)
|
||||
if err != nil {
|
||||
// 拿不到远端清单也要把本地状态回给前端(内网环境下这是常态),
|
||||
// 所以带着 info 一起返回,让界面能显示"本地已有哪些库"。
|
||||
response.OkWithDetailed(map[string]interface{}{
|
||||
"info": info,
|
||||
"error": err.Error(),
|
||||
}, "获取远端升级信息失败,仅返回本地状态", c)
|
||||
return
|
||||
}
|
||||
response.OkWithDetailed(map[string]interface{}{"info": info}, "获取成功", c)
|
||||
}
|
||||
|
||||
// ApplyIPDBUpgradeApi 启动下载指定的 IP 数据库(异步),下载完成后自动热加载
|
||||
//
|
||||
// IPv6 库约 35MB,慢网要几分钟,同步等会被网关掐断、用户也只能看个转圈。
|
||||
// 这里立刻返回,前端轮询 GetIPDBUpgradeProgressApi 画进度条。
|
||||
func (w *WafIPLocationApi) ApplyIPDBUpgradeApi(c *gin.Context) {
|
||||
var req struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailWithMessage("参数解析失败", c)
|
||||
return
|
||||
}
|
||||
dataDir := filepath.Join(utils.GetCurrentDir(), "data")
|
||||
if err := iplocation.StartUpgrade(dataDir, req.Key, reloadManagerByCurrentConfig); err != nil {
|
||||
response.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithMessage("已开始下载", c)
|
||||
}
|
||||
|
||||
// GetIPDBUpgradeProgressApi 查询当前下载进度,供前端轮询
|
||||
func (w *WafIPLocationApi) GetIPDBUpgradeProgressApi(c *gin.Context) {
|
||||
response.OkWithDetailed(iplocation.GetProgress(), "获取成功", c)
|
||||
}
|
||||
|
||||
// CancelIPDBUpgradeApi 取消正在进行的下载
|
||||
//
|
||||
// 官方源在部分网络下速度很差,用户等不下去时要能停,转而自己从 Gitee/GitHub 下好丢进 data 目录。
|
||||
func (w *WafIPLocationApi) CancelIPDBUpgradeApi(c *gin.Context) {
|
||||
if err := iplocation.CancelUpgrade(); err != nil {
|
||||
response.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithMessage("已取消下载", c)
|
||||
}
|
||||
|
||||
// TestIPLookupApi 测试 IP 查询
|
||||
func (w *WafIPLocationApi) TestIPLookupApi(c *gin.Context) {
|
||||
var req struct {
|
||||
|
||||
Binary file not shown.
+48
-129
@@ -41,7 +41,6 @@ import (
|
||||
"embed"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -68,8 +67,9 @@ import (
|
||||
//go:embed exedata/ip2region.xdb
|
||||
var Ip2regionBytes []byte // 当前目录,解析为[]byte类型
|
||||
|
||||
//go:embed exedata/GeoLite2-Country.mmdb
|
||||
var Ipv6CountryBytes []byte // IPv6国家解析
|
||||
// GeoLite2-Country.mmdb 自 1.3.24-beta.6 起不再内嵌,
|
||||
// 把它编进发行的二进制等同于再分发。需要 GeoLite2 的用户可自行到 MaxMind 官网下载 mmdb
|
||||
// 放进 data/ 目录,程序会照常加载。IPv6 默认改用 ip2region(Apache-2.0,可自由分发)。
|
||||
|
||||
//go:embed exedata/ldpconfig.yml
|
||||
var ldpConfig string //隐私防护ldp
|
||||
@@ -159,136 +159,30 @@ func (m *wafSystenService) run() {
|
||||
//初始化步骤[加载ip数据库]
|
||||
// 创建 IP Location Manager
|
||||
global.GIPLOCATION_MANAGER = iplocation.NewManager()
|
||||
// 注册内置数据库,供 manager 在磁盘无文件时兜底加载
|
||||
iplocation.SetBuiltinData(Ip2regionBytes, Ipv6CountryBytes)
|
||||
// 注册内置数据库,供 manager 在磁盘无文件时兜底加载。
|
||||
// GeoLite2 已去内嵌,这里只剩 IPv4 的 ip2region 一份兜底。
|
||||
iplocation.SetBuiltinData(Ip2regionBytes, nil)
|
||||
|
||||
// 根据配置加载 IPv4 数据库:每种来源读各自的文件,磁盘无文件时回落到内置数据
|
||||
if global.GCONFIG_IP_V4_SOURCE == "ip2region" {
|
||||
ip2RegionFilePath := filepath.Join(utils.GetCurrentDir(), "data", "ip2region.xdb")
|
||||
var ipv4Data []byte
|
||||
ipv4FromBuiltin := false
|
||||
if _, err := os.Stat(ip2RegionFilePath); os.IsNotExist(err) {
|
||||
// 使用内置数据
|
||||
ipv4Data = Ip2regionBytes
|
||||
ipv4FromBuiltin = true
|
||||
zlog.Info("Using embedded IPv4 ip2region database, size: ", len(ipv4Data))
|
||||
} else {
|
||||
// 读取外部文件
|
||||
fileBytes, err := ioutil.ReadFile(ip2RegionFilePath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to read IP database file ip2region.xdb: %v", err)
|
||||
}
|
||||
ipv4Data = fileBytes
|
||||
zlog.Info("IPv4 database ip2region.xdb loaded from file, size: ", len(ipv4Data), ip2RegionFilePath)
|
||||
}
|
||||
|
||||
err := global.GIPLOCATION_MANAGER.LoadV4Ip2Region(ipv4Data, iplocation.DBFormat(global.GCONFIG_IP_V4_FORMAT))
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load IPv4 ip2region database: %v", err)
|
||||
}
|
||||
global.GIPLOCATION_MANAGER.SetV4Builtin(ipv4FromBuiltin)
|
||||
zlog.Info("IPv4 ip2region database loaded successfully")
|
||||
} else if global.GCONFIG_IP_V4_SOURCE == "geolite2" {
|
||||
// GeoLite2 读的是 mmdb,与 ip2region.xdb 是两种格式,不能混用同一份字节
|
||||
ipv4GeoLitePath := filepath.Join(utils.GetCurrentDir(), "data", "GeoLite2-Country.mmdb")
|
||||
var ipv4Data []byte
|
||||
ipv4FromBuiltin := false
|
||||
if _, err := os.Stat(ipv4GeoLitePath); os.IsNotExist(err) {
|
||||
// 内置 GeoLite2-Country.mmdb,IPv4/IPv6 共用同一份
|
||||
ipv4Data = Ipv6CountryBytes
|
||||
ipv4FromBuiltin = true
|
||||
zlog.Info("Using embedded IPv4 GeoLite2 database, size: ", len(ipv4Data))
|
||||
} else {
|
||||
fileBytes, err := ioutil.ReadFile(ipv4GeoLitePath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to read IPv4 GeoLite2 database file: %v", err)
|
||||
}
|
||||
ipv4Data = fileBytes
|
||||
zlog.Info("IPv4 GeoLite2 database loaded from file, size: ", len(ipv4Data), ipv4GeoLitePath)
|
||||
}
|
||||
|
||||
err := global.GIPLOCATION_MANAGER.LoadV4GeoLite2(ipv4Data)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load IPv4 GeoLite2 database: %v", err)
|
||||
}
|
||||
global.GIPLOCATION_MANAGER.SetV4Builtin(ipv4FromBuiltin)
|
||||
zlog.Info("IPv4 GeoLite2 database loaded successfully")
|
||||
} else if global.GCONFIG_IP_V4_SOURCE == "ipdb" {
|
||||
ipdbPath := filepath.Join(utils.GetCurrentDir(), "data", "iplocation.ipdb")
|
||||
if _, err := os.Stat(ipdbPath); err == nil {
|
||||
if err2 := global.GIPLOCATION_MANAGER.LoadIpdb(ipdbPath); err2 != nil {
|
||||
zlog.Warn("Failed to load ipdb database (v4): ", err2)
|
||||
} else {
|
||||
zlog.Info("ipdb database loaded successfully (v4 source)")
|
||||
}
|
||||
} else {
|
||||
zlog.Warn("ipdb database file not found, please upload iplocation.ipdb")
|
||||
// 加载优先级与热重载共用同一份实现(磁盘文件 > 内置数据 > 同类型其它来源降级)。
|
||||
// IP 库属于「有更好、没有也得能跑」的辅助数据:任何加载失败都只告警,
|
||||
// 绝不能阻止 WAF 启动——防护能力不依赖地区库。
|
||||
if err := global.GIPLOCATION_MANAGER.ReloadFromConfig(
|
||||
filepath.Join(utils.GetCurrentDir(), "data"),
|
||||
global.GCONFIG_IP_V4_SOURCE, global.GCONFIG_IP_V6_SOURCE,
|
||||
global.GCONFIG_IP_V4_FORMAT, global.GCONFIG_IP_V6_FORMAT,
|
||||
); err != nil {
|
||||
zlog.Warn("IP地理位置数据库加载失败,地区相关功能将不可用: ", err.Error())
|
||||
}
|
||||
if st := global.GIPLOCATION_MANAGER.GetStatus(); st != nil {
|
||||
zlog.Info(fmt.Sprintf("IP库加载完成 IPv4:%s(内置:%v,%d字节) IPv6:%s(内置:%v,%d字节)",
|
||||
st.IPv4Source, st.IPv4Builtin, st.IPv4FileSize,
|
||||
st.IPv6Source, st.IPv6Builtin, st.IPv6FileSize))
|
||||
if st.IPv6FileSize == 0 {
|
||||
zlog.Warn("IPv6 地区库不可用:IPv6 访客的归属地将显示为未知,且地区类自定义规则对 IPv6 请求不生效。" +
|
||||
"可在【IP库管理】里在线下载,或自行下载 ip2region_v6.xdb 放入 data/ 目录")
|
||||
}
|
||||
}
|
||||
|
||||
// 加载 IPv6 数据库
|
||||
if global.GCONFIG_IP_V6_SOURCE == "ip2region" {
|
||||
// IPv6 ip2region 需要单独的文件
|
||||
ipv6Ip2RegionPath := filepath.Join(utils.GetCurrentDir(), "data", "ip2region_v6.xdb")
|
||||
if _, err := os.Stat(ipv6Ip2RegionPath); err == nil {
|
||||
fileBytes, err := ioutil.ReadFile(ipv6Ip2RegionPath)
|
||||
if err != nil {
|
||||
zlog.Warn("Failed to read IPv6 ip2region database file: ", err)
|
||||
} else {
|
||||
err = global.GIPLOCATION_MANAGER.LoadV6Ip2Region(fileBytes, iplocation.DBFormat(global.GCONFIG_IP_V6_FORMAT))
|
||||
if err != nil {
|
||||
zlog.Warn("Failed to load IPv6 ip2region database: ", err)
|
||||
} else {
|
||||
global.GIPLOCATION_MANAGER.SetV6Builtin(false)
|
||||
zlog.Info("IPv6 ip2region database loaded successfully, size: ", len(fileBytes))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
zlog.Warn("IPv6 ip2region database file not found, please upload ip2region_v6.xdb")
|
||||
}
|
||||
} else if global.GCONFIG_IP_V6_SOURCE == "geolite2" {
|
||||
// IPv6 GeoLite2
|
||||
ipv6GeoLitePath := filepath.Join(utils.GetCurrentDir(), "data", "GeoLite2-Country.mmdb")
|
||||
var ipv6Data []byte
|
||||
ipv6FromBuiltin := false
|
||||
if _, err := os.Stat(ipv6GeoLitePath); os.IsNotExist(err) {
|
||||
// 使用内置数据
|
||||
ipv6Data = Ipv6CountryBytes
|
||||
ipv6FromBuiltin = true
|
||||
zlog.Info("Using embedded IPv6 GeoLite2 database, size: ", len(ipv6Data))
|
||||
} else {
|
||||
// 读取外部文件
|
||||
fileBytes, err := ioutil.ReadFile(ipv6GeoLitePath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to read IPv6 GeoLite2 database file: %v", err)
|
||||
}
|
||||
ipv6Data = fileBytes
|
||||
zlog.Info("IPv6 GeoLite2 database loaded from file, size: ", len(ipv6Data), ipv6GeoLitePath)
|
||||
}
|
||||
|
||||
err := global.GIPLOCATION_MANAGER.LoadV6GeoLite2(ipv6Data)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load IPv6 GeoLite2 database: %v", err)
|
||||
}
|
||||
global.GIPLOCATION_MANAGER.SetV6Builtin(ipv6FromBuiltin)
|
||||
zlog.Info("IPv6 GeoLite2 database loaded successfully")
|
||||
} else if global.GCONFIG_IP_V6_SOURCE == "ipdb" {
|
||||
// 如果 v4 已经加载了 ipdb,跳过重复加载
|
||||
if !global.GIPLOCATION_MANAGER.IsIpdbLoaded() {
|
||||
ipdbPath := filepath.Join(utils.GetCurrentDir(), "data", "iplocation.ipdb")
|
||||
if _, err := os.Stat(ipdbPath); err == nil {
|
||||
if err2 := global.GIPLOCATION_MANAGER.LoadIpdb(ipdbPath); err2 != nil {
|
||||
zlog.Warn("Failed to load ipdb database (v6): ", err2)
|
||||
} else {
|
||||
zlog.Info("ipdb database loaded successfully (v6 source)")
|
||||
}
|
||||
} else {
|
||||
zlog.Warn("ipdb database file not found, please upload iplocation.ipdb")
|
||||
}
|
||||
} else {
|
||||
zlog.Info("ipdb database already loaded (shared with v4)")
|
||||
}
|
||||
}
|
||||
global.GWAF_DLP_CONFIG = ldpConfig
|
||||
global.GWAF_REG_PUBLIC_KEY = publicKey
|
||||
|
||||
@@ -355,6 +249,31 @@ func (m *wafSystenService) run() {
|
||||
// 创建 Snowflake 实例
|
||||
global.GWAF_SNOWFLAKE_GEN = wafsnowflake.NewSnowflake(1609459200000, 1, 1) // 设置epoch时间、机器ID和数据中心ID
|
||||
|
||||
// 注入 IP 库在线下载上下文:iplocation 不能反向依赖 global/utils(会成环),
|
||||
// 所以升级源、SSRF 安全客户端、通知回调都从这里传进去。
|
||||
iplocation.ConfigureUpgrader(iplocation.UpgradeConfig{
|
||||
UpdateVersionURL: global.GUPDATE_VERSION_URL,
|
||||
NewClient: utils.SafeHTTPClient,
|
||||
ValidateURL: utils.IsSafeOutboundURL,
|
||||
NotifyFunc: func(success bool, msg string) {
|
||||
if global.GQEQUE_MESSAGE_DB == nil {
|
||||
return
|
||||
}
|
||||
successStr := "false"
|
||||
if success {
|
||||
successStr = "true"
|
||||
}
|
||||
global.GQEQUE_MESSAGE_DB.Enqueue(innerbean.UpdateResultMessageInfo{
|
||||
BaseMessageInfo: innerbean.BaseMessageInfo{
|
||||
OperaType: "IP库下载",
|
||||
Server: global.GWAF_CUSTOM_SERVER_NAME,
|
||||
},
|
||||
Msg: msg,
|
||||
Success: successStr,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// 创建owasp 管理器(支持热重载)
|
||||
global.GWAF_OWASP_MANAGER = wafowasp.NewOwaspManager(utils.GetCurrentDir())
|
||||
global.GWAF_OWASP = global.GWAF_OWASP_MANAGER.Current()
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// pack_ipdb 把 ip2region 数据文件打成升级包,供 SamWaf 客户端在线下载。
|
||||
//
|
||||
// 背景:GeoLite2 受 MaxMind 再分发授权限制已从二进制中去内嵌,IPv6 改用 ip2region_v6.xdb;
|
||||
//
|
||||
// 用法:
|
||||
//
|
||||
// go run ./cmd/tools/pack_ipdb [flags]
|
||||
//
|
||||
// Flags:
|
||||
//
|
||||
// -version string 版本号,建议用数据日期,格式 YYYY.MM.DD(默认取当天)
|
||||
// -changelog string 本次更新说明(默认 "")
|
||||
// -source string ip2region 数据文件所在目录(需含 ip2region_v6.xdb / ip2region.xdb)
|
||||
// -output string 输出目录(默认 release/web/ipdb-dataset)
|
||||
// -base-url string 下载基础 URL(默认 https://update.samwaf.com)
|
||||
//
|
||||
// 输出:
|
||||
//
|
||||
// <output>/<version>/ip2region_v6.xdb 数据文件副本
|
||||
// <output>/<version>/ip2region.xdb
|
||||
// <output>/latest.json 升级清单
|
||||
//
|
||||
// latest.json 结构:
|
||||
//
|
||||
// {
|
||||
// "version": "2026.08.11",
|
||||
// "changelog": "...",
|
||||
// "files": {
|
||||
// "ip2region_v6": {"url": "https://update.samwaf.com/ipdb-dataset/2026.08.11/ip2region_v6.xdb",
|
||||
// "sha256": "...", "size": 36700160}
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// 数据来源(Apache-2.0,转发分发时需随包附上游 LICENSE 与署名):
|
||||
//
|
||||
// https://gitee.com/lionsoul/ip2region/tree/master/data
|
||||
// https://github.com/lionsoul2014/ip2region/tree/master/data
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// packItem 一个待打包的数据文件:key 必须与 iplocation.SupportedDownloads 里的 Key 一致。
|
||||
type packItem struct {
|
||||
Key string
|
||||
FileName string
|
||||
}
|
||||
|
||||
var items = []packItem{
|
||||
{Key: "ip2region_v6", FileName: "ip2region_v6.xdb"},
|
||||
{Key: "ip2region_v4", FileName: "ip2region.xdb"},
|
||||
}
|
||||
|
||||
type remoteFile struct {
|
||||
URL string `json:"url"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
type manifest struct {
|
||||
Version string `json:"version"`
|
||||
Changelog string `json:"changelog"`
|
||||
Files map[string]remoteFile `json:"files"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
var (
|
||||
version = flag.String("version", time.Now().Format("2006.01.02"), "版本号,建议用数据日期 YYYY.MM.DD")
|
||||
changelog = flag.String("changelog", "", "本次更新说明")
|
||||
source = flag.String("source", "data", "ip2region 数据文件所在目录")
|
||||
output = flag.String("output", filepath.Join("release", "web", "ipdb-dataset"), "输出目录")
|
||||
baseURL = flag.String("base-url", "https://update.samwaf.com", "下载基础 URL")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
verDir := filepath.Join(*output, *version)
|
||||
if err := os.MkdirAll(verDir, 0o755); err != nil {
|
||||
fatal("创建输出目录失败: %v", err)
|
||||
}
|
||||
|
||||
m := manifest{Version: *version, Changelog: *changelog, Files: map[string]remoteFile{}}
|
||||
packed := 0
|
||||
for _, it := range items {
|
||||
src := filepath.Join(*source, it.FileName)
|
||||
st, err := os.Stat(src)
|
||||
if err != nil {
|
||||
// 允许只打其中一个文件:IPv4 库仍随程序内置,通常不需要每次都发
|
||||
fmt.Printf("跳过 %s(未找到 %s)\n", it.Key, src)
|
||||
continue
|
||||
}
|
||||
dst := filepath.Join(verDir, it.FileName)
|
||||
if err = copyFile(src, dst); err != nil {
|
||||
fatal("复制 %s 失败: %v", it.FileName, err)
|
||||
}
|
||||
sum, err := fileSHA256(dst)
|
||||
if err != nil {
|
||||
fatal("计算 %s 校验和失败: %v", it.FileName, err)
|
||||
}
|
||||
m.Files[it.Key] = remoteFile{
|
||||
URL: fmt.Sprintf("%s/ipdb-dataset/%s/%s", strings.TrimRight(*baseURL, "/"), *version, it.FileName),
|
||||
SHA256: sum,
|
||||
Size: st.Size(),
|
||||
}
|
||||
packed++
|
||||
fmt.Printf("已打包 %-16s %10d 字节 sha256=%s\n", it.FileName, st.Size(), sum)
|
||||
}
|
||||
if packed == 0 {
|
||||
fatal("没有任何可打包的文件,请检查 -source=%s", *source)
|
||||
}
|
||||
|
||||
b, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
fatal("序列化清单失败: %v", err)
|
||||
}
|
||||
manifestPath := filepath.Join(*output, "latest.json")
|
||||
if err = os.WriteFile(manifestPath, b, 0o644); err != nil {
|
||||
fatal("写清单失败: %v", err)
|
||||
}
|
||||
fmt.Printf("\n清单已生成: %s\n版本: %s\n", manifestPath, *version)
|
||||
fmt.Println("提醒:上架时请把 ip2region 上游的 LICENSE 与署名一并放到 ipdb-dataset/ 目录下。")
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, in)
|
||||
return err
|
||||
}
|
||||
|
||||
func fileSHA256(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err = io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func fatal(format string, a ...interface{}) {
|
||||
fmt.Fprintf(os.Stderr, format+"\n", a...)
|
||||
os.Exit(1)
|
||||
}
|
||||
+74
-5
@@ -1,16 +1,85 @@
|
||||
# ip 库的处理
|
||||
|
||||
## 机制
|
||||
1. SamWaf为了轻量化内置了ip2region.xdb。
|
||||
2. 遇到识别不准的问题就得自己构建放在 data/ip2region.xdb,重启SamWaf就可以了。
|
||||
|
||||
## 如何生成 ip2region.xdb
|
||||
1. SamWaf 为了轻量化,**只内置了 IPv4 的 `ip2region.xdb`**(Apache-2.0)。
|
||||
2. **IPv6 地区库不随程序内置**:`ip2region_v6.xdb` 约 35MB,内嵌会让二进制过大。
|
||||
3. **GeoLite2 也不再随程序内置**:MaxMind 对 GeoLite2 再分发另有授权要求,
|
||||
把它编进发行的二进制等同于再分发。需要用 GeoLite2 的用户可自行到 MaxMind 官网下载后上传,程序照常加载。
|
||||
4. 加载优先级:**`data/` 下的文件 > 内置数据 > 同类型的其它可用来源(运行时降级)**。
|
||||
也就是说只要你把文件放进 `data/`,用的就是你的文件。
|
||||
5. 没有任何可用地区库时,程序**照常启动、照常防护**,只是归属地显示为未知;
|
||||
此时地区类自定义规则(引用 `MF.COUNTRY` / `MF.PROVINCE` / `MF.CITY` 的规则)对该请求**不生效**,
|
||||
以免 `MF.COUNTRY != "中国"` 这类规则在没有地区数据时把访客整片误拦。
|
||||
|
||||
## 各数据文件对照
|
||||
|
||||
| 文件名(放在 `data/` 下) | 用途 | 是否内置 | 获取方式 |
|
||||
|---|---|:--:|---|
|
||||
| `ip2region.xdb` | IPv4 地区 | ✅ 内置 | 内置即可用;也可自行替换 |
|
||||
| `ip2region_v6.xdb` | IPv6 地区 | ❌ | 管理端【IP库管理 → 在线下载】,或手动下载 |
|
||||
| `GeoLite2-Country.mmdb` | IPv4/IPv6 国家 | ❌ | 自行到 MaxMind 官网下载后上传 |
|
||||
| `iplocation.ipdb` | IPv4+IPv6(同一文件) | ❌ | 自行获取后上传 |
|
||||
|
||||
## 获取 IPv6 地区库
|
||||
|
||||
### 方式一:管理端在线下载(推荐)
|
||||
|
||||
【IP库管理】→【在线下载】→ 点「检查更新」→ 对 `ip2region IPv6 地区库` 点「下载并启用」。
|
||||
下载完成后自动热加载,**无需重启**。
|
||||
|
||||
### 方式二:手动下载放入 data 目录(官方源慢时推荐)
|
||||
|
||||
1. 从上游仓库下载 `ip2region_v6.xdb`(IPv6)或 `ip2region.xdb`(IPv4):
|
||||
- Gitee:https://gitee.com/lionsoul/ip2region/tree/master/data
|
||||
- GitHub:https://github.com/lionsoul2014/ip2region/tree/master/data
|
||||
2. 上传到服务器上 **SamWaf 程序目录下的 `data/`**,**文件名保持不变**。
|
||||
管理端【IP库管理 → 在线下载】页面会直接显示这台服务器上的绝对路径,可一键复制。
|
||||
3. 回到管理端点「重新加载」即可生效,**无需重启程序**。
|
||||
|
||||
内网 / 离线环境、或官方源速度不理想时都走这种方式。在线下载过程中也可以随时点「取消下载」改用手动。
|
||||
|
||||
## ⚠️ 国家名语言差异(会影响地区规则)
|
||||
|
||||
**ip2region 官方社区版数据库的国家名是英文,SamWaf 内置的 IPv4 库是中文。**
|
||||
|
||||
| 数据来源 | 国家名示例 |
|
||||
|---|---|
|
||||
| SamWaf 内置 `ip2region.xdb`(legacy 格式) | `中国`、`美国` |
|
||||
| 官方社区版 `ip2region_v6.xdb`(opensource 格式) | `China`、`United States` |
|
||||
|
||||
地区封禁是靠自定义规则实现的,官方模板长这样:
|
||||
|
||||
```
|
||||
rule Roverseas "海外访问拦截" { when MF.COUNTRY != "中国" then RF.Deny(); }
|
||||
```
|
||||
|
||||
这条规则对**内置 IPv4 库**没问题,但对**社区版 IPv6 库**会失效 ——
|
||||
中国的 IPv6 访客解析出来是 `China`,`"China" != "中国"` 成立,会被误拦。
|
||||
|
||||
**处理建议**:
|
||||
|
||||
- 先用管理端【IP库管理】顶部的「测试IP地址」查一下,确认你的库实际返回什么
|
||||
- 如需同时覆盖中英文,把规则改成两个条件都排除:
|
||||
|
||||
```
|
||||
rule Roverseas "海外访问拦截" {
|
||||
when MF.COUNTRY != "中国" && MF.COUNTRY != "China"
|
||||
then RF.Deny();
|
||||
}
|
||||
```
|
||||
|
||||
- 或者统一数据源:IPv4 也换成官方社区版 `ip2region.xdb`(对应格式选 `opensource`),
|
||||
这样中英文就不会混用了
|
||||
|
||||
## 如何自己生成 ip2region.xdb
|
||||
|
||||
遇到识别不准的问题,可以自己构建一份放在 `data/ip2region.xdb`,重启 SamWaf 即可。
|
||||
|
||||
这里使用 Ip2region(狮子的魂)。为了方便测试使用,fork了一份,生成了windows和linux的可执行文件。
|
||||
|
||||
https://github.com/samwafgo/ip2region/releases
|
||||
|
||||
|
||||
|
||||
- 1.编辑
|
||||
|
||||
下载一份原始ip数据
|
||||
|
||||
+7
-4
@@ -155,10 +155,13 @@ var (
|
||||
GCACHE_IPV6_SEARCHER *geoip2.Reader // IPV6得查询器 (已废弃,由 GIPLOCATION_MANAGER 管理)
|
||||
|
||||
// IP 数据库配置
|
||||
GCONFIG_IP_V4_SOURCE string = "ip2region" // IPv4 数据来源: ip2region / geolite2
|
||||
GCONFIG_IP_V6_SOURCE string = "geolite2" // IPv6 数据来源: ip2region / geolite2
|
||||
GCONFIG_IP_V4_FORMAT string = "legacy" // IPv4 xdb 字段格式
|
||||
GCONFIG_IP_V6_FORMAT string = "legacy" // IPv6 xdb 字段格式(仅 ip2region 时有效)
|
||||
GCONFIG_IP_V4_SOURCE string = "ip2region" // IPv4 数据来源: ip2region / geolite2(ip2region.xdb 随程序内置)
|
||||
GCONFIG_IP_V6_SOURCE string = "ip2region" // IPv6 数据来源: ip2region / geolite2
|
||||
// 注:IPv6 默认自 1.3.24-beta.6 由 geolite2 改为 ip2region —— GeoLite2 受 MaxMind 再分发授权限制,
|
||||
// 已不再随程序内置。ip2region_v6.xdb 需在【IP库管理】在线下载或自行放入 data/。
|
||||
// 只影响新装;老用户 sys_config 里已有的值不动,由 ReloadFromConfig 做运行时降级。
|
||||
GCONFIG_IP_V4_FORMAT string = "legacy" // IPv4 xdb 字段格式
|
||||
GCONFIG_IP_V6_FORMAT string = "opensource" // IPv6 xdb 字段格式(仅 ip2region 时有效)
|
||||
|
||||
// IP Location Manager 全局实例
|
||||
GIPLOCATION_MANAGER *iplocation.Manager
|
||||
|
||||
@@ -53,6 +53,12 @@ type WebLog struct {
|
||||
IsBalance int `json:"is_balance"` //是否是负载均衡 1 是 0 不是
|
||||
BalanceInfo string `gorm:"size:255" json:"balance_info"` //负载均衡IP端口信息
|
||||
AI_SCORE float64 `json:"ai_score"` //AI检测得分[0,1],0表示未经AI检测或未命中;命中(观察/拦截)时记录实际分数
|
||||
|
||||
// GeoUnresolved 本次请求的地区无法判定(没有可用的地区库,或查询失败),
|
||||
// 区别于"查出来是未知"。为 true 时规则引擎会跳过引用了 COUNTRY/PROVINCE/CITY 的规则,
|
||||
// 避免 `MF.COUNTRY != "中国"` 这类规则在 IPv6 地区库缺失时把访客整片误杀。
|
||||
// 仅运行期使用,不落库、不出接口。
|
||||
GeoUnresolved bool `gorm:"-" json:"-"`
|
||||
}
|
||||
|
||||
// GetHeaderValue 从HEADER字段中提取指定header的值
|
||||
|
||||
@@ -9,6 +9,9 @@ var (
|
||||
)
|
||||
|
||||
// SetBuiltinData 注册内置数据库字节,须在任何加载动作之前调用一次。
|
||||
//
|
||||
// geoLite2 自 1.3.24-beta.6 起由主程序传 nil —— MaxMind 对 GeoLite2 的再分发另有授权要求,
|
||||
// 不能编进发行的二进制。保留这个入参是为了让单测仍能构造"有内置 GeoLite2"的场景。
|
||||
func SetBuiltinData(ip2RegionV4, geoLite2 []byte) {
|
||||
builtinIp2RegionV4 = ip2RegionV4
|
||||
builtinGeoLite2 = geoLite2
|
||||
@@ -16,7 +19,7 @@ func SetBuiltinData(ip2RegionV4, geoLite2 []byte) {
|
||||
|
||||
// HasBuiltinFile 指定数据文件是否有内置兜底。
|
||||
// key 与状态接口 file_exists 的键一致:ip2region_v4 / ip2region_v6 / geolite2 / ipdb。
|
||||
// 目前仅内置了 IPv4 的 ip2region.xdb 和 GeoLite2-Country.mmdb。
|
||||
// 目前只内置了 IPv4 的 ip2region.xdb;geolite2 已去内嵌,主程序传 nil 后这里恒为 false。
|
||||
func HasBuiltinFile(key string) bool {
|
||||
switch key {
|
||||
case "ip2region_v4":
|
||||
|
||||
+131
-75
@@ -2,58 +2,66 @@ package iplocation
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 模拟全新安装:data 目录为空,仅有内置数据
|
||||
func TestFreshInstallUsesBuiltin(t *testing.T) {
|
||||
ip2region, err := os.ReadFile("../cmd/samwaf/exedata/ip2region.xdb")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
geolite2, err := os.ReadFile("../cmd/samwaf/exedata/GeoLite2-Country.mmdb")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
SetBuiltinData(ip2region, geolite2)
|
||||
// 这批用例覆盖 GeoLite2 去内嵌后的加载与降级行为。
|
||||
// 关键前提:程序只内置 IPv4 的 ip2region.xdb;GeoLite2 与 IPv6 库都必须来自磁盘。
|
||||
|
||||
emptyDir := t.TempDir() // 全新安装:data 下什么都没有
|
||||
const builtinV4Path = "../cmd/samwaf/exedata/ip2region.xdb"
|
||||
|
||||
// loadBuiltinV4 读取内置的 IPv4 库并注册。geolite2 传 nil,与主程序保持一致。
|
||||
func loadBuiltinV4(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(builtinV4Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
SetBuiltinData(b, nil)
|
||||
return b
|
||||
}
|
||||
|
||||
// 模拟全新安装:data 目录为空。
|
||||
// IPv4 应走内置库并能查;IPv6 无任何数据,必须是"不可判定"而不是随便给个国家名。
|
||||
func TestFreshInstallUsesBuiltin(t *testing.T) {
|
||||
loadBuiltinV4(t)
|
||||
emptyDir := t.TempDir()
|
||||
|
||||
m := NewManager()
|
||||
if err := m.ReloadFromConfig(emptyDir, "ip2region", "geolite2", "legacy", "legacy"); err != nil {
|
||||
if err := m.ReloadFromConfig(emptyDir, "ip2region", "ip2region", "legacy", "legacy"); err != nil {
|
||||
t.Fatalf("ReloadFromConfig 失败: %v", err)
|
||||
}
|
||||
|
||||
st := m.GetStatus()
|
||||
if !st.IPv4Builtin || !st.IPv6Builtin {
|
||||
t.Fatalf("应标记为内置数据, got v4=%v v6=%v", st.IPv4Builtin, st.IPv6Builtin)
|
||||
if !st.IPv4Builtin || st.IPv4FileSize == 0 {
|
||||
t.Fatalf("IPv4 应加载内置数据, got builtin=%v size=%d", st.IPv4Builtin, st.IPv4FileSize)
|
||||
}
|
||||
if st.IPv4FileSize == 0 || st.IPv6FileSize == 0 {
|
||||
t.Fatalf("内置数据大小应非零, got v4=%d v6=%d", st.IPv4FileSize, st.IPv6FileSize)
|
||||
}
|
||||
t.Logf("状态: v4=%s builtin=%v size=%d | v6=%s builtin=%v size=%d",
|
||||
st.IPv4Source, st.IPv4Builtin, st.IPv4FileSize, st.IPv6Source, st.IPv6Builtin, st.IPv6FileSize)
|
||||
|
||||
// 内置数据必须真的能查
|
||||
if r := m.Lookup("8.8.8.8"); r.Country == "" || r.Country == "未配置" || r.Country == "查询失败" {
|
||||
if r := m.Lookup("8.8.8.8"); r.Unresolved || r.Country == "" {
|
||||
t.Fatalf("IPv4 内置库查询失败: %+v", r)
|
||||
} else {
|
||||
t.Logf("8.8.8.8 -> %+v", r.ToSlice())
|
||||
}
|
||||
if r := m.Lookup("2001:4860:4860::8888"); r.Country == "" || r.Country == "未配置" || r.Country == "查询失败" {
|
||||
t.Fatalf("IPv6 内置库查询失败: %+v", r)
|
||||
} else {
|
||||
t.Logf("2001:4860:4860::8888 -> %+v", r.ToSlice())
|
||||
t.Logf("8.8.8.8 -> %v", r.ToSlice())
|
||||
}
|
||||
|
||||
// 保存配置时的可用性判定:默认来源在全新安装下必须可用
|
||||
// IPv6 没有内置数据:必须标记 Unresolved,让规则层放行而不是拿"未配置"去比对
|
||||
r6 := m.Lookup("2001:4860:4860::8888")
|
||||
if !r6.Unresolved {
|
||||
t.Fatalf("IPv6 无库时应标记 Unresolved, got %+v", r6)
|
||||
}
|
||||
if st.IPv6FileSize != 0 {
|
||||
t.Fatalf("IPv6 无库时大小应为 0, got %d", st.IPv6FileSize)
|
||||
}
|
||||
|
||||
// 可用性判定:只有 ipv4/ip2region 有内置兜底
|
||||
if !HasBuiltinSource("ipv4", "ip2region") {
|
||||
t.Fatal("ipv4/ip2region 应有内置兜底")
|
||||
}
|
||||
if !HasBuiltinSource("ipv6", "geolite2") {
|
||||
t.Fatal("ipv6/geolite2 应有内置兜底")
|
||||
if HasBuiltinSource("ipv6", "geolite2") {
|
||||
t.Fatal("GeoLite2 已去内嵌,不应再报有内置兜底")
|
||||
}
|
||||
if HasBuiltinFile("geolite2") {
|
||||
t.Fatal("HasBuiltinFile(geolite2) 应为 false")
|
||||
}
|
||||
// 无内置的来源仍应报缺失
|
||||
if HasBuiltinSource("ipv6", "ip2region") {
|
||||
t.Fatal("ipv6/ip2region 无内置数据,不应报可用")
|
||||
}
|
||||
@@ -62,67 +70,115 @@ func TestFreshInstallUsesBuiltin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 上传文件后必须覆盖内置,且不再标记为内置
|
||||
// 磁盘上的文件必须覆盖内置,且不再标记为内置
|
||||
func TestUploadedFileOverridesBuiltin(t *testing.T) {
|
||||
ip2region, err := os.ReadFile("../cmd/samwaf/exedata/ip2region.xdb")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
geolite2, err := os.ReadFile("../cmd/samwaf/exedata/GeoLite2-Country.mmdb")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
SetBuiltinData(ip2region, geolite2)
|
||||
ip2region := loadBuiltinV4(t)
|
||||
|
||||
dataDir := t.TempDir()
|
||||
if err := os.WriteFile(dataDir+"/ip2region.xdb", ip2region, 0644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(dataDir, "ip2region.xdb"), ip2region, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m := NewManager()
|
||||
if err := m.ReloadFromConfig(dataDir, "ip2region", "geolite2", "legacy", "legacy"); err != nil {
|
||||
if err := m.ReloadFromConfig(dataDir, "ip2region", "ip2region", "legacy", "legacy"); err != nil {
|
||||
t.Fatalf("ReloadFromConfig 失败: %v", err)
|
||||
}
|
||||
|
||||
st := m.GetStatus()
|
||||
if st.IPv4Builtin {
|
||||
if m.GetStatus().IPv4Builtin {
|
||||
t.Fatal("磁盘已有 ip2region.xdb,IPv4 不应标记为内置")
|
||||
}
|
||||
if !st.IPv6Builtin {
|
||||
t.Fatal("磁盘无 GeoLite2 文件,IPv6 应回落到内置")
|
||||
}
|
||||
}
|
||||
|
||||
// IPv4 选 geolite2 时必须读 mmdb,不能把 ip2region.xdb 的字节喂给 GeoLite2 解析器
|
||||
func TestIPv4GeoLite2UsesMmdbNotXdb(t *testing.T) {
|
||||
ip2region, err := os.ReadFile("../cmd/samwaf/exedata/ip2region.xdb")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
geolite2, err := os.ReadFile("../cmd/samwaf/exedata/GeoLite2-Country.mmdb")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
SetBuiltinData(ip2region, geolite2)
|
||||
|
||||
// 磁盘只有 ip2region.xdb:IPv4 选 geolite2 时不能误用它,应回落到内置 mmdb
|
||||
dataDir := t.TempDir()
|
||||
if err := os.WriteFile(dataDir+"/ip2region.xdb", ip2region, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 老用户配置还是 geolite2,但 mmdb 已不再内置也不在磁盘上:
|
||||
// IPv4 必须运行时降级到内置 ip2region,绝不能落到"没有地区数据"。
|
||||
func TestIPv4GeoLite2FallsBackToIp2Region(t *testing.T) {
|
||||
loadBuiltinV4(t)
|
||||
dataDir := t.TempDir() // 磁盘上没有 mmdb
|
||||
|
||||
m := NewManager()
|
||||
if err := m.ReloadFromConfig(dataDir, "geolite2", "geolite2", "legacy", "legacy"); err != nil {
|
||||
if err := m.ReloadFromConfig(dataDir, "geolite2", "ip2region", "legacy", "legacy"); err != nil {
|
||||
t.Fatalf("ReloadFromConfig 失败: %v", err)
|
||||
}
|
||||
|
||||
st := m.GetStatus()
|
||||
if st.IPv4Source != "geolite2" || !st.IPv4Builtin {
|
||||
t.Fatalf("IPv4 应加载内置 GeoLite2, got source=%s builtin=%v", st.IPv4Source, st.IPv4Builtin)
|
||||
if st.IPv4Source != string(SourceIp2Region) {
|
||||
t.Fatalf("IPv4 应降级到 ip2region, got %s", st.IPv4Source)
|
||||
}
|
||||
if r := m.Lookup("8.8.8.8"); r.Country == "" || r.Country == "未配置" || r.Country == "查询失败" {
|
||||
t.Fatalf("IPv4 GeoLite2 查询失败: %+v", r)
|
||||
} else {
|
||||
t.Logf("geolite2 8.8.8.8 -> %s", r.Country)
|
||||
if r := m.Lookup("8.8.8.8"); r.Unresolved {
|
||||
t.Fatalf("降级后 IPv4 仍应可查: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// 老用户配置 geolite2、磁盘无 mmdb,但已有 ip2region_v6.xdb:IPv6 应降级用上它。
|
||||
// 该文件约 35MB 不入库,本机没有就跳过。
|
||||
func TestIPv6GeoLite2FallsBackToIp2RegionV6(t *testing.T) {
|
||||
loadBuiltinV4(t)
|
||||
|
||||
v6, err := os.ReadFile("../data/ip2region_v6.xdb")
|
||||
if err != nil {
|
||||
t.Skip("本机没有 data/ip2region_v6.xdb,跳过 IPv6 降级用例")
|
||||
}
|
||||
dataDir := t.TempDir()
|
||||
if err = os.WriteFile(filepath.Join(dataDir, "ip2region_v6.xdb"), v6, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m := NewManager()
|
||||
if err = m.ReloadFromConfig(dataDir, "ip2region", "geolite2", "legacy", "legacy"); err != nil {
|
||||
t.Fatalf("ReloadFromConfig 失败: %v", err)
|
||||
}
|
||||
|
||||
st := m.GetStatus()
|
||||
if st.IPv6Source != string(SourceIp2Region) {
|
||||
t.Fatalf("IPv6 应降级到 ip2region, got %s", st.IPv6Source)
|
||||
}
|
||||
if r := m.Lookup("2001:4860:4860::8888"); r.Unresolved {
|
||||
t.Fatalf("降级后 IPv6 仍应可查: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// 用户自己把 GeoLite2-Country.mmdb 放进 data/ 时必须照常加载 —— 去内嵌只是不再分发,不是不再支持。
|
||||
// 仓库里已不带 mmdb,本机没有就跳过。
|
||||
func TestUserSuppliedGeoLite2StillWorks(t *testing.T) {
|
||||
loadBuiltinV4(t)
|
||||
|
||||
mmdb, err := os.ReadFile("../data/GeoLite2-Country.mmdb")
|
||||
if err != nil {
|
||||
t.Skip("本机没有 data/GeoLite2-Country.mmdb,跳过用户自备用例")
|
||||
}
|
||||
dataDir := t.TempDir()
|
||||
if err = os.WriteFile(filepath.Join(dataDir, "GeoLite2-Country.mmdb"), mmdb, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m := NewManager()
|
||||
if err = m.ReloadFromConfig(dataDir, "ip2region", "geolite2", "legacy", "legacy"); err != nil {
|
||||
t.Fatalf("ReloadFromConfig 失败: %v", err)
|
||||
}
|
||||
|
||||
st := m.GetStatus()
|
||||
if st.IPv6Source != string(SourceGeoLite2) {
|
||||
t.Fatalf("用户自备 mmdb 时 IPv6 应用 geolite2, got %s", st.IPv6Source)
|
||||
}
|
||||
if st.IPv6Builtin {
|
||||
t.Fatal("用户自备的文件不应标记为内置")
|
||||
}
|
||||
if r := m.Lookup("2001:4860:4860::8888"); r.Unresolved {
|
||||
t.Fatalf("用户自备 mmdb 应能查 IPv6: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// 什么库都没有时不能 panic,也不能返回一个会被规则误判的国家名
|
||||
func TestNoDataAtAllIsUnresolved(t *testing.T) {
|
||||
SetBuiltinData(nil, nil)
|
||||
defer loadBuiltinV4(t) // 还原,避免影响同包其它用例
|
||||
|
||||
m := NewManager()
|
||||
if err := m.ReloadFromConfig(t.TempDir(), "geolite2", "geolite2", "legacy", "legacy"); err != nil {
|
||||
t.Fatalf("无任何数据时不应报错, got %v", err)
|
||||
}
|
||||
for _, ip := range []string{"8.8.8.8", "2001:4860:4860::8888"} {
|
||||
if r := m.Lookup(ip); !r.Unresolved {
|
||||
t.Fatalf("%s 无库时应标记 Unresolved, got %+v", ip, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+63
-36
@@ -62,7 +62,7 @@ func (m *Manager) Lookup(ipStr string) *IPLocationResult {
|
||||
// 判断是 IPv4 还是 IPv6
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
return &IPLocationResult{Country: "无效IP"}
|
||||
return &IPLocationResult{Country: "无效IP", Unresolved: true}
|
||||
}
|
||||
|
||||
// 判断 IP 类型
|
||||
@@ -80,7 +80,7 @@ func (m *Manager) lookupV4(ipStr string) *IPLocationResult {
|
||||
if m.v4Source == SourceIp2Region && m.v4Searcher != nil {
|
||||
region, err := m.v4Searcher.SearchByStr(ipStr)
|
||||
if err != nil {
|
||||
return &IPLocationResult{Country: "查询失败"}
|
||||
return &IPLocationResult{Country: "查询失败", Unresolved: true}
|
||||
}
|
||||
if region == "" {
|
||||
return &IPLocationResult{Country: "未知"}
|
||||
@@ -90,7 +90,7 @@ func (m *Manager) lookupV4(ipStr string) *IPLocationResult {
|
||||
ip := net.ParseIP(ipStr)
|
||||
record, err := m.v4GeoReader.Country(ip)
|
||||
if err != nil {
|
||||
return &IPLocationResult{Country: "查询失败"}
|
||||
return &IPLocationResult{Country: "查询失败", Unresolved: true}
|
||||
}
|
||||
countryName := record.Country.Names["zh-CN"]
|
||||
if countryName == "" {
|
||||
@@ -100,20 +100,23 @@ func (m *Manager) lookupV4(ipStr string) *IPLocationResult {
|
||||
} else if m.v4Source == SourceIpdb && m.ipdbReader != nil {
|
||||
info, err := m.ipdbReader.FindMap(ipStr, "CN")
|
||||
if err != nil {
|
||||
return &IPLocationResult{Country: "查询失败"}
|
||||
return &IPLocationResult{Country: "查询失败", Unresolved: true}
|
||||
}
|
||||
return parseIpdbMap(info)
|
||||
}
|
||||
|
||||
return &IPLocationResult{Country: "未配置"}
|
||||
return &IPLocationResult{Country: "未配置", Unresolved: true}
|
||||
}
|
||||
|
||||
// lookupV6 查询 IPv6 地址
|
||||
func (m *Manager) lookupV6(ipStr string) *IPLocationResult {
|
||||
// IPv6 侧要留意:GeoLite2 去内嵌后,配置仍是 geolite2 但磁盘没有 mmdb 的老用户,
|
||||
// ReloadFromConfig 会把 v6Source 运行时降级成 ip2region;若连 ip2region_v6.xdb 也没有,
|
||||
// 就会落到最后的 Unresolved 分支,由调用方放行,而不是返回一个会被规则误判的国家名。
|
||||
if m.v6Source == SourceIp2Region && m.v6Searcher != nil {
|
||||
region, err := m.v6Searcher.SearchByStr(ipStr)
|
||||
if err != nil {
|
||||
return &IPLocationResult{Country: "查询失败"}
|
||||
return &IPLocationResult{Country: "查询失败", Unresolved: true}
|
||||
}
|
||||
if region == "" {
|
||||
return &IPLocationResult{Country: "未知"}
|
||||
@@ -123,7 +126,7 @@ func (m *Manager) lookupV6(ipStr string) *IPLocationResult {
|
||||
ip := net.ParseIP(ipStr)
|
||||
record, err := m.v6GeoReader.Country(ip)
|
||||
if err != nil {
|
||||
return &IPLocationResult{Country: "查询失败"}
|
||||
return &IPLocationResult{Country: "查询失败", Unresolved: true}
|
||||
}
|
||||
countryName := record.Country.Names["zh-CN"]
|
||||
if countryName == "" {
|
||||
@@ -136,12 +139,12 @@ func (m *Manager) lookupV6(ipStr string) *IPLocationResult {
|
||||
} else if m.v6Source == SourceIpdb && m.ipdbReader != nil {
|
||||
info, err := m.ipdbReader.FindMap(ipStr, "CN")
|
||||
if err != nil {
|
||||
return &IPLocationResult{Country: "查询失败"}
|
||||
return &IPLocationResult{Country: "查询失败", Unresolved: true}
|
||||
}
|
||||
return parseIpdbMap(info)
|
||||
}
|
||||
|
||||
return &IPLocationResult{Country: "未配置"}
|
||||
return &IPLocationResult{Country: "未配置", Unresolved: true}
|
||||
}
|
||||
|
||||
// LoadV4Ip2Region 加载 IPv4 ip2region 数据库
|
||||
@@ -447,8 +450,14 @@ func readDBFile(dataDir, name string) []byte {
|
||||
// 这是 manager 数据源加载的唯一入口:启动后置加载、API 保存、手动 reload 都走这里。
|
||||
// format 仅对 ip2region 源有意义,geolite2/ipdb 忽略 format。
|
||||
//
|
||||
// 加载优先级:dataDir 下的磁盘文件 > 内置数据(ip2region IPv4 / GeoLite2)。
|
||||
// 两者皆无(如 IPv6 的 ip2region、ipdb)时跳过该项,保留调用前已加载的后端。
|
||||
// 加载优先级:dataDir 下的磁盘文件 > 内置数据(仅 ip2region IPv4)> 同类型的其它可用来源。
|
||||
//
|
||||
// 注意 GeoLite2 自 v1.3.21 起不再随程序内置(MaxMind 商业再分发授权所限,见
|
||||
// SamWafTechDoc/Plan/2026-08-11-GeoLite2去内嵌-实施计划.md)。因此配置写着 geolite2
|
||||
// 但磁盘上没有 mmdb 的老用户,这里会做一次**运行时降级**:改用 ip2region 对应的库。
|
||||
// 降级只发生在内存里,不回写 sys_config —— 用户之后把 mmdb 放回 data/ 就能自动恢复。
|
||||
//
|
||||
// 所有来源都不可用时跳过该项,让 lookup 走 Unresolved 分支,由调用方放行。
|
||||
func (m *Manager) ReloadFromConfig(dataDir, v4Source, v6Source, v4Format, v6Format string) error {
|
||||
// ipdb 双栈共用,优先处理;ipdb 无内置数据,只能来自上传文件
|
||||
if v4Source == string(SourceIpdb) || v6Source == string(SourceIpdb) {
|
||||
@@ -462,51 +471,69 @@ func (m *Manager) ReloadFromConfig(dataDir, v4Source, v6Source, v4Format, v6Form
|
||||
}
|
||||
|
||||
// IPv4(非 ipdb 来源)
|
||||
switch v4Source {
|
||||
case string(SourceIp2Region):
|
||||
loadV4Ip2Region := func() (bool, error) {
|
||||
data, builtin := readDBFile(dataDir, "ip2region.xdb"), false
|
||||
if data == nil {
|
||||
data, builtin = builtinIp2RegionV4, true
|
||||
}
|
||||
if len(data) > 0 {
|
||||
if err := m.LoadV4Ip2Region(data, DBFormat(v4Format)); err != nil {
|
||||
return fmt.Errorf("重新加载 IPv4 数据库失败: %w", err)
|
||||
}
|
||||
m.SetV4Builtin(builtin)
|
||||
if len(data) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
if err := m.LoadV4Ip2Region(data, DBFormat(v4Format)); err != nil {
|
||||
return false, fmt.Errorf("重新加载 IPv4 数据库失败: %w", err)
|
||||
}
|
||||
m.SetV4Builtin(builtin)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
switch v4Source {
|
||||
case string(SourceIp2Region):
|
||||
if _, err := loadV4Ip2Region(); err != nil {
|
||||
return err
|
||||
}
|
||||
case string(SourceGeoLite2):
|
||||
data, builtin := readDBFile(dataDir, "GeoLite2-Country.mmdb"), false
|
||||
if data == nil {
|
||||
data, builtin = builtinGeoLite2, true
|
||||
}
|
||||
if len(data) > 0 {
|
||||
// GeoLite2 不再内置,只可能来自用户自己放进 data/ 的 mmdb
|
||||
if data := readDBFile(dataDir, "GeoLite2-Country.mmdb"); data != nil {
|
||||
if err := m.LoadV4GeoLite2(data); err != nil {
|
||||
return fmt.Errorf("重新加载 IPv4 数据库失败: %w", err)
|
||||
}
|
||||
m.SetV4Builtin(builtin)
|
||||
m.SetV4Builtin(false)
|
||||
} else if _, err := loadV4Ip2Region(); err != nil {
|
||||
// 降级:IPv4 恒有内置 ip2region 兜底,不会出现完全没有地区数据的情况
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// IPv6(非 ipdb 来源)
|
||||
loadV6Ip2Region := func() error {
|
||||
// IPv6 的 ip2region 无内置数据,只能来自 data/ip2region_v6.xdb
|
||||
// (在线下载或用户自行从 Gitee/GitHub 下载放入)
|
||||
data := readDBFile(dataDir, "ip2region_v6.xdb")
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
if err := m.LoadV6Ip2Region(data, DBFormat(v6Format)); err != nil {
|
||||
return fmt.Errorf("重新加载 IPv6 数据库失败: %w", err)
|
||||
}
|
||||
m.SetV6Builtin(false)
|
||||
return nil
|
||||
}
|
||||
|
||||
switch v6Source {
|
||||
case string(SourceIp2Region):
|
||||
// IPv6 的 ip2region 无内置数据,必须由用户上传 ip2region_v6.xdb
|
||||
if data := readDBFile(dataDir, "ip2region_v6.xdb"); data != nil {
|
||||
if err := m.LoadV6Ip2Region(data, DBFormat(v6Format)); err != nil {
|
||||
return fmt.Errorf("重新加载 IPv6 数据库失败: %w", err)
|
||||
}
|
||||
m.SetV6Builtin(false)
|
||||
if err := loadV6Ip2Region(); err != nil {
|
||||
return err
|
||||
}
|
||||
case string(SourceGeoLite2):
|
||||
data, builtin := readDBFile(dataDir, "GeoLite2-Country.mmdb"), false
|
||||
if data == nil {
|
||||
data, builtin = builtinGeoLite2, true
|
||||
}
|
||||
if len(data) > 0 {
|
||||
if data := readDBFile(dataDir, "GeoLite2-Country.mmdb"); data != nil {
|
||||
if err := m.LoadV6GeoLite2(data); err != nil {
|
||||
return fmt.Errorf("重新加载 IPv6 数据库失败: %w", err)
|
||||
}
|
||||
m.SetV6Builtin(builtin)
|
||||
m.SetV6Builtin(false)
|
||||
} else if err := loadV6Ip2Region(); err != nil {
|
||||
// 降级:老用户配置还是 geolite2,但 mmdb 已不再内置,改吃 ip2region v6。
|
||||
// 两者都没有时静默跳过,lookupV6 走 Unresolved,由规则层放行。
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,12 @@ type IPLocationResult struct {
|
||||
ISP string // 运营商
|
||||
Region string // 区域/大洲
|
||||
District string // 区县
|
||||
|
||||
// Unresolved 表示"这次查不出来",而不是"查出来是未知"。
|
||||
// 对应两种情况:该 IP 类型压根没有可用的数据库后端(如去内嵌后没下载 IPv6 库),
|
||||
// 或者后端查询本身报错。调用方据此决定是否让地区类规则参与判定——
|
||||
// 地区不可判定时必须放行,否则 `MF.COUNTRY != "中国"` 这类规则会把访客整片误杀。
|
||||
Unresolved bool
|
||||
}
|
||||
|
||||
// ToSlice 返回兼容老格式的 []string: [国家, 区域, 省份, 城市, ISP]
|
||||
|
||||
@@ -0,0 +1,610 @@
|
||||
package iplocation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// IP 库在线下载。
|
||||
//
|
||||
// 背景:GeoLite2 受 MaxMind 再分发授权限制已去内嵌,IPv6 改用 ip2region_v6.xdb;
|
||||
// 但该文件约 35MB,塞进二进制会毁掉 SamWaf 的轻量定位,所以做成按需下载。
|
||||
// ip2region 是 Apache-2.0,SamWaf 可以合法转发分发。
|
||||
//
|
||||
// 用户也可以完全不走这里,自己从 Gitee/GitHub 下载 xdb 放进 data/ 目录,程序照常加载:
|
||||
// https://gitee.com/lionsoul/ip2region/tree/master/data
|
||||
// https://github.com/lionsoul2014/ip2region/tree/master/data
|
||||
//
|
||||
// 本包不引用 global/utils —— global 反过来依赖 iplocation,会成环。
|
||||
// 因此升级源 URL 和带 SSRF 防护的 http.Client 都由主程序通过 ConfigureUpgrader 注入。
|
||||
|
||||
// DatabaseFile 一个数据文件槽位的静态元数据。
|
||||
//
|
||||
// 这是厂商知识的**唯一出处**:文件名、能不能在线下载、上传要带什么参数、许可证怎么说,
|
||||
// 全部在这里定义,manager / api / 前端都从这里取,避免同一份映射被抄好几遍。
|
||||
type DatabaseFile struct {
|
||||
Key string `json:"key"` // 稳定标识,与状态接口 file_exists 的键一致
|
||||
FileName string `json:"file_name"` // 落到 data/ 下的文件名(用户手工放也必须用这个名字)
|
||||
Desc string `json:"desc"` // 界面展示名
|
||||
IPType string `json:"ip_type"` // ipv4 / ipv6 / both
|
||||
Source string `json:"source"` // ip2region / geolite2 / ipdb
|
||||
Downloadable bool `json:"downloadable"` // SamWaf 能否合法转发分发(许可证决定,不是技术决定)
|
||||
UploadType string `json:"upload_type"` // 上传接口的 type 参数
|
||||
Accept string `json:"accept"` // 允许的文件后缀
|
||||
License string `json:"license"` // 展示用
|
||||
ObtainHint string `json:"obtain_hint"` // 不可在线下载时,告诉用户去哪拿
|
||||
}
|
||||
|
||||
// KnownDatabases 所有已知的数据文件槽位。
|
||||
//
|
||||
// Downloadable 只看许可证:ip2region 系列是 Apache-2.0,SamWaf 可以转发分发;
|
||||
// GeoLite2 受 MaxMind 商业再分发授权限制、ipdb 是 ipip.net 的免费库,都只能由用户自行获取后上传。
|
||||
var KnownDatabases = []DatabaseFile{
|
||||
{
|
||||
Key: "ip2region_v6", FileName: "ip2region_v6.xdb", Desc: "ip2region IPv6 地区库",
|
||||
IPType: "ipv6", Source: "ip2region", Downloadable: true,
|
||||
UploadType: "ipv6", Accept: ".xdb", License: "Apache-2.0",
|
||||
},
|
||||
{
|
||||
Key: "ip2region_v4", FileName: "ip2region.xdb", Desc: "ip2region IPv4 地区库",
|
||||
IPType: "ipv4", Source: "ip2region", Downloadable: true,
|
||||
UploadType: "ipv4", Accept: ".xdb", License: "Apache-2.0",
|
||||
},
|
||||
{
|
||||
Key: "geolite2", FileName: "GeoLite2-Country.mmdb", Desc: "GeoLite2 国家库(IPv4+IPv6)",
|
||||
IPType: "both", Source: "geolite2", Downloadable: false,
|
||||
UploadType: "ipv6", Accept: ".mmdb", License: "MaxMind EULA",
|
||||
ObtainHint: "受 MaxMind 再分发授权限制,需自行到 MaxMind 官网下载后上传",
|
||||
},
|
||||
{
|
||||
Key: "ipdb", FileName: "iplocation.ipdb", Desc: "IPDB 库(IPv4+IPv6)",
|
||||
IPType: "both", Source: "ipdb", Downloadable: false,
|
||||
UploadType: "ipdb", Accept: ".ipdb", License: "ipip.net 免费库",
|
||||
ObtainHint: "需自行从 ipip.net 获取后上传",
|
||||
},
|
||||
}
|
||||
|
||||
// DatabaseByKey 按 key 取槽位定义。
|
||||
// 同时充当白名单:外部传进来的 key 只能命中这张表,杜绝写到 data/ 之外的位置。
|
||||
func DatabaseByKey(key string) (DatabaseFile, bool) {
|
||||
for _, f := range KnownDatabases {
|
||||
if f.Key == key {
|
||||
return f, true
|
||||
}
|
||||
}
|
||||
return DatabaseFile{}, false
|
||||
}
|
||||
|
||||
// fileNameByKey 返回 key 对应的落盘文件名;不可在线下载的槽位返回空串,
|
||||
// 这样下载入口永远碰不到 GeoLite2 / ipdb。
|
||||
func fileNameByKey(key string) string {
|
||||
f, ok := DatabaseByKey(key)
|
||||
if !ok || !f.Downloadable {
|
||||
return ""
|
||||
}
|
||||
return f.FileName
|
||||
}
|
||||
|
||||
// UpgradeConfig 下载所需的外部上下文,由主程序注入。
|
||||
type UpgradeConfig struct {
|
||||
// UpdateVersionURL 升级源根 URL,例如 https://update.samwaf.com/
|
||||
UpdateVersionURL string
|
||||
// NewClient 返回一枚带 SSRF 防护的 http.Client;为 nil 时退化为普通客户端。
|
||||
// 该 client 负责跳转链上每一跳的校验,初始 URL 由 ValidateURL 把关。
|
||||
NewClient func() *http.Client
|
||||
// ValidateURL 校验初始 URL 是否可以对外请求(仅 http/https、目标为公网)。
|
||||
// 清单里的下载地址来自远端,必须先过这一关再发请求,否则升级源被篡改就成了 SSRF 跳板。
|
||||
ValidateURL func(rawURL string) (bool, string)
|
||||
// NotifyFunc 下载结果回调,用于推送 WS 消息。success=false 表示失败。
|
||||
NotifyFunc func(success bool, msg string)
|
||||
}
|
||||
|
||||
var upgradeCfg atomic.Pointer[UpgradeConfig]
|
||||
|
||||
// ConfigureUpgrader 注入下载所需上下文。main 启动阶段调用一次即可。
|
||||
func ConfigureUpgrader(c UpgradeConfig) {
|
||||
cp := c
|
||||
upgradeCfg.Store(&cp)
|
||||
}
|
||||
|
||||
func currentUpgradeCfg() UpgradeConfig {
|
||||
if c := upgradeCfg.Load(); c != nil {
|
||||
return *c
|
||||
}
|
||||
return UpgradeConfig{}
|
||||
}
|
||||
|
||||
// httpTimeout 下载超时。IPv6 库约 35MB,慢网也得给足,否则永远下不完。
|
||||
const httpTimeout = 10 * time.Minute
|
||||
|
||||
func newHTTPClient() *http.Client {
|
||||
cfg := currentUpgradeCfg()
|
||||
var c *http.Client
|
||||
if cfg.NewClient != nil {
|
||||
c = cfg.NewClient()
|
||||
}
|
||||
if c == nil {
|
||||
c = &http.Client{}
|
||||
}
|
||||
c.Timeout = httpTimeout
|
||||
return c
|
||||
}
|
||||
|
||||
// remoteFile 远端清单里单个文件的元数据。
|
||||
type remoteFile struct {
|
||||
URL string `json:"url"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// remoteManifest 远端 latest.json。
|
||||
//
|
||||
// 约定:{UpdateVersionURL}ipdb-dataset/latest.json
|
||||
//
|
||||
// {
|
||||
// "version": "2026.08.11",
|
||||
// "changelog": "...",
|
||||
// "files": {
|
||||
// "ip2region_v6": {"url": "...", "sha256": "...", "size": 36700160}
|
||||
// }
|
||||
// }
|
||||
type remoteManifest struct {
|
||||
Version string `json:"version"`
|
||||
Changelog string `json:"changelog"`
|
||||
Files map[string]remoteFile `json:"files"`
|
||||
}
|
||||
|
||||
// FileUpgradeInfo 单个槽位的完整状态:静态元数据 + 本地情况 + 远端情况。
|
||||
//
|
||||
// 界面上「在线下载」和「上传」是同一张表的两个操作列,所以这里必须把
|
||||
// 不可在线下载的槽位(GeoLite2 / ipdb)也一并返回,否则用户看不到它们的本地状态。
|
||||
type FileUpgradeInfo struct {
|
||||
DatabaseFile // 内嵌静态元数据(key/file_name/desc/downloadable/upload_type/accept/license/...)
|
||||
Builtin bool `json:"builtin"` // 该槽位有随程序内置的数据兜底
|
||||
Available bool `json:"available"` // 远端有这个文件可下
|
||||
LocalExists bool `json:"local_exists"` // 本地 data/ 下已经有了
|
||||
LocalSize int64 `json:"local_size"` // 本地文件大小
|
||||
LocalModTime string `json:"local_mod_time"` // 本地文件修改时间
|
||||
LocalVersion string `json:"local_version"` // 本地记录的版本(本模块下载时写入;用户手工放的为空)
|
||||
RemoteSize int64 `json:"remote_size"` // 远端文件大小
|
||||
LatestVersion string `json:"latest_version"` // 远端版本
|
||||
NeedUpdate bool `json:"need_update"` // 本地缺失或版本落后
|
||||
}
|
||||
|
||||
// UpgradeInfo 一次检查的整体结果。
|
||||
type UpgradeInfo struct {
|
||||
LatestVersion string `json:"latest_version"`
|
||||
Changelog string `json:"changelog"`
|
||||
LastCheckAt string `json:"last_check_at"`
|
||||
Files []FileUpgradeInfo `json:"files"`
|
||||
// DataDir 服务器上放数据文件的绝对路径。
|
||||
// 官方源在部分网络下很慢,用户自己下好文件要知道往哪放,所以直接把路径显示出来。
|
||||
DataDir string `json:"data_dir"`
|
||||
}
|
||||
|
||||
// downloading 并发保护:一次只允许一个下载流程,避免同一文件被两个请求同时写。
|
||||
var downloading atomic.Bool
|
||||
|
||||
// 下载状态机。前端按这几个状态决定进度条怎么画。
|
||||
const (
|
||||
StateIdle = "idle" // 没有正在进行的下载
|
||||
StateDownloading = "downloading" // 正在下载,有字节进度
|
||||
StateVerifying = "verifying" // 下载完了在算 sha256(35MB 也要一会儿)
|
||||
StateApplying = "applying" // 落盘 + 热加载
|
||||
StateDone = "done" // 成功
|
||||
StateFailed = "failed" // 失败,Message 里是原因
|
||||
StateCanceled = "canceled" // 用户主动取消
|
||||
)
|
||||
|
||||
// cancelCurrent 取消当前下载。官方源在部分网络下很慢,用户等不下去时要能停,
|
||||
// 转而自己从 Gitee/GitHub 下好丢进 data 目录。
|
||||
var (
|
||||
cancelMu sync.Mutex
|
||||
cancelCurrent context.CancelFunc
|
||||
)
|
||||
|
||||
func setCancelFunc(fn context.CancelFunc) {
|
||||
cancelMu.Lock()
|
||||
cancelCurrent = fn
|
||||
cancelMu.Unlock()
|
||||
}
|
||||
|
||||
// CancelUpgrade 取消正在进行的下载。没有任务在跑时返回错误。
|
||||
func CancelUpgrade() error {
|
||||
cancelMu.Lock()
|
||||
fn := cancelCurrent
|
||||
cancelMu.Unlock()
|
||||
if fn == nil || !downloading.Load() {
|
||||
return fmt.Errorf("当前没有正在进行的下载")
|
||||
}
|
||||
fn()
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadProgress 一次下载的实时进度,供前端轮询展示。
|
||||
//
|
||||
// 下载是同步长任务(IPv6 库 35MB,慢网要几分钟),如果让 HTTP 请求一直挂着,
|
||||
// 用户只能看到一个转圈,不知道下到哪了、还要多久。所以改成:
|
||||
// apply 接口起个 goroutine 立刻返回,前端轮询本结构画进度条。
|
||||
type DownloadProgress struct {
|
||||
Key string `json:"key"`
|
||||
FileName string `json:"file_name"`
|
||||
Total int64 `json:"total"` // 总字节数,取自清单的 size(Content-Length 可能缺失)
|
||||
Downloaded int64 `json:"downloaded"` // 已下载字节数
|
||||
Percent float64 `json:"percent"` // 0~100,Total 未知时恒为 0
|
||||
State string `json:"state"`
|
||||
Message string `json:"message"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
var (
|
||||
progressMu sync.RWMutex
|
||||
progress = DownloadProgress{State: StateIdle}
|
||||
)
|
||||
|
||||
// GetProgress 返回当前下载进度的快照。
|
||||
func GetProgress() DownloadProgress {
|
||||
progressMu.RLock()
|
||||
defer progressMu.RUnlock()
|
||||
return progress
|
||||
}
|
||||
|
||||
func setProgress(mutate func(p *DownloadProgress)) {
|
||||
progressMu.Lock()
|
||||
defer progressMu.Unlock()
|
||||
mutate(&progress)
|
||||
if progress.Total > 0 {
|
||||
progress.Percent = float64(progress.Downloaded) * 100 / float64(progress.Total)
|
||||
if progress.Percent > 100 {
|
||||
progress.Percent = 100
|
||||
}
|
||||
} else {
|
||||
progress.Percent = 0
|
||||
}
|
||||
progress.UpdatedAt = time.Now().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
// progressWriter 边写边记字节数。
|
||||
// 按 256KB 一档节流:35MB 下载会产生上万次 Write,每次都抢一把写锁纯属浪费。
|
||||
type progressWriter struct {
|
||||
n int64
|
||||
lastAt int64
|
||||
}
|
||||
|
||||
const progressFlushStep = 256 << 10
|
||||
|
||||
func (w *progressWriter) Write(p []byte) (int, error) {
|
||||
w.n += int64(len(p))
|
||||
if w.n-w.lastAt >= progressFlushStep {
|
||||
w.lastAt = w.n
|
||||
n := w.n
|
||||
setProgress(func(pr *DownloadProgress) { pr.Downloaded = n })
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// localVersionFile 记录本模块下载过的文件版本,供下次比对。
|
||||
// 用户手工放进 data/ 的文件不会出现在这里,此时 LocalVersion 为空、NeedUpdate 为 true,
|
||||
// 界面上表现为"可更新",点了也只是覆盖成官方版本,不会有副作用。
|
||||
const localVersionFile = "ipdb_version.json"
|
||||
|
||||
type localVersions map[string]string
|
||||
|
||||
func readLocalVersions(dataDir string) localVersions {
|
||||
v := localVersions{}
|
||||
b, err := os.ReadFile(filepath.Join(dataDir, localVersionFile))
|
||||
if err != nil {
|
||||
return v
|
||||
}
|
||||
_ = json.Unmarshal(b, &v)
|
||||
return v
|
||||
}
|
||||
|
||||
func writeLocalVersion(dataDir, key, version string) {
|
||||
v := readLocalVersions(dataDir)
|
||||
v[key] = version
|
||||
b, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(dataDir, localVersionFile), b, 0o600)
|
||||
}
|
||||
|
||||
// CheckUpgrade 查询远端清单,返回每个可下载文件的本地/远端对比结果。
|
||||
func CheckUpgrade(dataDir string) (*UpgradeInfo, error) {
|
||||
info := &UpgradeInfo{LastCheckAt: time.Now().Format(time.RFC3339), DataDir: dataDir}
|
||||
if abs, err := filepath.Abs(dataDir); err == nil {
|
||||
info.DataDir = abs
|
||||
}
|
||||
|
||||
// 先把本地状态填上:即使远端不可达(内网环境),界面也能看到本地有什么。
|
||||
// 四个槽位全都返回,包括不可在线下载的 GeoLite2 / ipdb —— 界面上它们和可下载的
|
||||
// 在同一张表里,只是操作列只有「上传」没有「下载」。
|
||||
local := readLocalVersions(dataDir)
|
||||
for _, f := range KnownDatabases {
|
||||
fi := FileUpgradeInfo{DatabaseFile: f, LocalVersion: local[f.Key]}
|
||||
fi.Builtin = HasBuiltinFile(f.Key)
|
||||
if st, err := os.Stat(filepath.Join(dataDir, f.FileName)); err == nil {
|
||||
fi.LocalExists = true
|
||||
fi.LocalSize = st.Size()
|
||||
fi.LocalModTime = st.ModTime().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
info.Files = append(info.Files, fi)
|
||||
}
|
||||
|
||||
cfg := currentUpgradeCfg()
|
||||
if cfg.UpdateVersionURL == "" {
|
||||
return info, fmt.Errorf("未配置升级源")
|
||||
}
|
||||
manifest, err := fetchManifest(context.Background(), strings.TrimRight(cfg.UpdateVersionURL, "/")+"/ipdb-dataset/latest.json")
|
||||
if err != nil {
|
||||
return info, err
|
||||
}
|
||||
info.LatestVersion = manifest.Version
|
||||
info.Changelog = manifest.Changelog
|
||||
for i := range info.Files {
|
||||
rf, ok := manifest.Files[info.Files[i].Key]
|
||||
if !ok || rf.URL == "" {
|
||||
continue
|
||||
}
|
||||
info.Files[i].Available = true
|
||||
info.Files[i].RemoteSize = rf.Size
|
||||
info.Files[i].LatestVersion = manifest.Version
|
||||
info.Files[i].NeedUpdate = !info.Files[i].LocalExists || info.Files[i].LocalVersion != manifest.Version
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// StartUpgrade 异步启动一次下载,立刻返回,进度由 GetProgress 查询。
|
||||
//
|
||||
// 之所以不让接口同步等:35MB 在慢网上要几分钟,HTTP 请求挂那么久既容易被网关掐断,
|
||||
// 用户也只能看到一个转圈,不知道下到哪、还剩多少。
|
||||
// 参数校验(key 是否支持、是否已有任务在跑)在返回前同步做完,这样前端能立刻收到错误。
|
||||
func StartUpgrade(dataDir, key string, reload func() error) error {
|
||||
fileName := fileNameByKey(key)
|
||||
if fileName == "" {
|
||||
return fmt.Errorf("不支持下载的数据文件: %s", key)
|
||||
}
|
||||
if !downloading.CompareAndSwap(false, true) {
|
||||
return fmt.Errorf("正在下载中,请稍后")
|
||||
}
|
||||
setProgress(func(p *DownloadProgress) {
|
||||
*p = DownloadProgress{Key: key, FileName: fileName, State: StateDownloading}
|
||||
})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
setCancelFunc(cancel)
|
||||
go func() {
|
||||
defer func() {
|
||||
cancel()
|
||||
setCancelFunc(nil)
|
||||
downloading.Store(false)
|
||||
}()
|
||||
if err := doUpgrade(ctx, dataDir, key, fileName, reload); err != nil {
|
||||
// 用户点了取消:不算失败,也别弹错误提示
|
||||
if ctx.Err() != nil {
|
||||
setProgress(func(p *DownloadProgress) {
|
||||
p.State = StateCanceled
|
||||
p.Message = "已取消下载"
|
||||
})
|
||||
return
|
||||
}
|
||||
msg := err.Error()
|
||||
setProgress(func(p *DownloadProgress) {
|
||||
p.State = StateFailed
|
||||
p.Message = msg
|
||||
})
|
||||
return
|
||||
}
|
||||
setProgress(func(p *DownloadProgress) {
|
||||
p.State = StateDone
|
||||
p.Downloaded = p.Total
|
||||
p.Message = fmt.Sprintf("%s 下载完成并已生效", fileName)
|
||||
})
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyUpgrade 同步下载指定 key 的数据文件并落到 dataDir。
|
||||
// 供单测与命令行场景使用;管理端界面走 StartUpgrade + GetProgress。
|
||||
func ApplyUpgrade(dataDir, key string, reload func() error) error {
|
||||
fileName := fileNameByKey(key)
|
||||
if fileName == "" {
|
||||
return fmt.Errorf("不支持下载的数据文件: %s", key)
|
||||
}
|
||||
if !downloading.CompareAndSwap(false, true) {
|
||||
return fmt.Errorf("正在下载中,请稍后")
|
||||
}
|
||||
defer downloading.Store(false)
|
||||
setProgress(func(p *DownloadProgress) {
|
||||
*p = DownloadProgress{Key: key, FileName: fileName, State: StateDownloading}
|
||||
})
|
||||
err := doUpgrade(context.Background(), dataDir, key, fileName, reload)
|
||||
if err != nil {
|
||||
msg := err.Error()
|
||||
setProgress(func(p *DownloadProgress) {
|
||||
p.State = StateFailed
|
||||
p.Message = msg
|
||||
})
|
||||
return err
|
||||
}
|
||||
setProgress(func(p *DownloadProgress) {
|
||||
p.State = StateDone
|
||||
p.Downloaded = p.Total
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// doUpgrade 真正的下载流程。
|
||||
//
|
||||
// 取清单 → 下载到 .downloading 临时文件 → sha256 校验 → 原子改名覆盖 → 记录版本 → 回调重载。
|
||||
// 校验不通过就把临时文件删掉,绝不覆盖现有可用的库——宁可保持旧数据,也不能让地区判定基于一个坏文件。
|
||||
// reload 由调用方传入(通常是 Manager.ReloadFromConfig 的包装),下载完立即生效,无需重启。
|
||||
func doUpgrade(ctx context.Context, dataDir, key, fileName string, reload func() error) error {
|
||||
cfg := currentUpgradeCfg()
|
||||
if cfg.UpdateVersionURL == "" {
|
||||
return notifyErr(fmt.Errorf("未配置升级源"))
|
||||
}
|
||||
manifest, err := fetchManifest(ctx, strings.TrimRight(cfg.UpdateVersionURL, "/")+"/ipdb-dataset/latest.json")
|
||||
if err != nil {
|
||||
return notifyErr(fmt.Errorf("获取升级清单失败: %w", err))
|
||||
}
|
||||
rf, ok := manifest.Files[key]
|
||||
if !ok || rf.URL == "" {
|
||||
return notifyErr(fmt.Errorf("升级源暂未提供 %s", fileName))
|
||||
}
|
||||
// 总大小取自清单:响应可能是分块传输、没有 Content-Length,那时就画不出百分比了
|
||||
setProgress(func(p *DownloadProgress) { p.Total = rf.Size })
|
||||
|
||||
if err = os.MkdirAll(dataDir, 0o755); err != nil {
|
||||
return notifyErr(fmt.Errorf("创建数据目录失败: %w", err))
|
||||
}
|
||||
dst := filepath.Join(dataDir, fileName)
|
||||
tmp := dst + ".downloading"
|
||||
defer os.Remove(tmp) // 成功时已改名,这里是失败路径的清理
|
||||
|
||||
if err = downloadFile(ctx, rf.URL, tmp); err != nil {
|
||||
// 取消不算失败,交给上层置成 canceled 状态,别推一条"下载失败"的告警
|
||||
if ctx.Err() != nil {
|
||||
return err
|
||||
}
|
||||
return notifyErr(fmt.Errorf("下载 %s 失败: %w", fileName, err))
|
||||
}
|
||||
|
||||
if rf.SHA256 != "" {
|
||||
setProgress(func(p *DownloadProgress) { p.State = StateVerifying })
|
||||
actual, err2 := fileSHA256(tmp)
|
||||
if err2 != nil {
|
||||
return notifyErr(fmt.Errorf("校验 %s 失败: %w", fileName, err2))
|
||||
}
|
||||
if !strings.EqualFold(actual, rf.SHA256) {
|
||||
return notifyErr(fmt.Errorf("%s 校验不通过,预期 %s 实际 %s", fileName, rf.SHA256, actual))
|
||||
}
|
||||
}
|
||||
|
||||
setProgress(func(p *DownloadProgress) { p.State = StateApplying })
|
||||
// Windows 上 os.Rename 不能覆盖已存在的文件,先删旧的。
|
||||
// 此时新文件已校验通过,删旧文件是安全的。
|
||||
_ = os.Remove(dst)
|
||||
if err = os.Rename(tmp, dst); err != nil {
|
||||
return notifyErr(fmt.Errorf("替换 %s 失败: %w", fileName, err))
|
||||
}
|
||||
writeLocalVersion(dataDir, key, manifest.Version)
|
||||
|
||||
if reload != nil {
|
||||
if err = reload(); err != nil {
|
||||
return notifyErr(fmt.Errorf("%s 已下载但重载失败: %w", fileName, err))
|
||||
}
|
||||
}
|
||||
notify(true, fmt.Sprintf("%s 下载并生效成功,版本 %s", fileName, manifest.Version))
|
||||
return nil
|
||||
}
|
||||
|
||||
func notify(success bool, msg string) {
|
||||
if f := currentUpgradeCfg().NotifyFunc; f != nil {
|
||||
f(success, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func notifyErr(err error) error {
|
||||
notify(false, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
// checkURL 用注入的校验函数把关一个对外地址。未注入时不拦(单测里用 httptest 本地地址)。
|
||||
func checkURL(rawURL string) error {
|
||||
f := currentUpgradeCfg().ValidateURL
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
if ok, reason := f(rawURL); !ok {
|
||||
return fmt.Errorf("地址不被允许(%s): %s", reason, rawURL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchManifest(ctx context.Context, url string) (*remoteManifest, error) {
|
||||
if err := checkURL(url); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := newHTTPClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("manifest HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var m remoteManifest
|
||||
if err = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func downloadFile(ctx context.Context, url, dst string) error {
|
||||
if err := checkURL(url); err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := newHTTPClient().Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
f, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// 响应带了 Content-Length 就用它修正总大小(比清单里的更准)
|
||||
if resp.ContentLength > 0 {
|
||||
total := resp.ContentLength
|
||||
setProgress(func(p *DownloadProgress) { p.Total = total })
|
||||
}
|
||||
|
||||
// 限个上限,防止升级源被替换后拿一个超大响应把磁盘写满
|
||||
pw := &progressWriter{}
|
||||
_, err = io.Copy(io.MultiWriter(f, pw), io.LimitReader(resp.Body, 512<<20))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 收尾补一次:节流会让最后不足 256KB 的部分没被记上,不补的话进度条永远差一口气
|
||||
n := pw.n
|
||||
setProgress(func(p *DownloadProgress) { p.Downloaded = n })
|
||||
return nil
|
||||
}
|
||||
|
||||
func fileSHA256(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err = io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
@@ -18,5 +18,9 @@ func (receiver *IPLocationRouter) InitIPLocationRouter(group *gin.RouterGroup) {
|
||||
router.POST("/upload", apiInstance.UploadIPDBFileApi)
|
||||
router.POST("/reload", apiInstance.ReloadIPDBApi)
|
||||
router.POST("/test", apiInstance.TestIPLookupApi)
|
||||
router.GET("/upgrade/check", apiInstance.CheckIPDBUpgradeApi)
|
||||
router.POST("/upgrade/apply", apiInstance.ApplyIPDBUpgradeApi)
|
||||
router.GET("/upgrade/progress", apiInstance.GetIPDBUpgradeProgressApi)
|
||||
router.POST("/upgrade/cancel", apiInstance.CancelIPDBUpgradeApi)
|
||||
}
|
||||
}
|
||||
|
||||
+12
-2
@@ -240,11 +240,21 @@ func GetPublicIP() string {
|
||||
}
|
||||
|
||||
func GetCountry(ip string) []string {
|
||||
region, _ := GetCountryEx(ip)
|
||||
return region
|
||||
}
|
||||
|
||||
// GetCountryEx 与 GetCountry 相同,但额外返回本次地区是否可判定。
|
||||
//
|
||||
// 第二个返回值为 false 表示"这次查不出来"(没有可用的地区库,或查询报错),
|
||||
// 而不是"查出来是未知"。两者必须区分:IPv6 地区库缺失时若只返回"未知",
|
||||
// `MF.COUNTRY != "中国"` 这类规则会把 IPv6 访客整片误杀,所以调用方需要据此放行。
|
||||
func GetCountryEx(ip string) ([]string, bool) {
|
||||
if global.GIPLOCATION_MANAGER != nil {
|
||||
result := global.GIPLOCATION_MANAGER.Lookup(ip)
|
||||
return result.ToSlice() // [国家, 区域, 省份, 城市, ISP]
|
||||
return result.ToSlice(), !result.Unresolved // [国家, 区域, 省份, 城市, ISP]
|
||||
}
|
||||
return []string{"未知", "", "", "", ""}
|
||||
return []string{"未知", "", "", "", ""}, false
|
||||
}
|
||||
|
||||
// FormatIPLocation 把 IP 解析成一行可读的归属地文本(国家 省份 城市 运营商)
|
||||
|
||||
@@ -79,6 +79,39 @@ func pickRuleAction(ruleHelper *utils.RuleHelper, ruleMatchs []*ast.RuleEntry) u
|
||||
return final
|
||||
}
|
||||
|
||||
// geoFieldRe 匹配规则里对地区字段的引用。
|
||||
//
|
||||
// 在 BuildGrlSkeleton 处理过的骨架上匹配,字符串字面量已被抹掉,
|
||||
// 所以规则描述里出现"COUNTRY"这种文字不会被误判成条件。
|
||||
//
|
||||
// 注意不能在 MF 前加 \b:ast.RuleEntry.GrlText 是去掉全部空白后的文本,
|
||||
// "when MF.COUNTRY" 会变成 "whenMF.COUNTRY",n 与 M 之间没有词边界。
|
||||
// 末尾的 \b 要保留,用来把 MF.COUNTRYCODE 这类更长的字段排除掉。
|
||||
var geoFieldRe = regexp.MustCompile(`MF\s*\.\s*(COUNTRY|PROVINCE|CITY)\b`)
|
||||
|
||||
// isGeoRule 规则是否引用了地区字段
|
||||
func isGeoRule(grlText string) bool {
|
||||
return geoFieldRe.MatchString(utils.BuildGrlSkeleton(grlText))
|
||||
}
|
||||
|
||||
// dropGeoRules 在地区不可判定时剔除引用了地区字段的命中规则。
|
||||
//
|
||||
// 地区封禁没有独立模块,走的是自定义规则,官方模板即 `MF.COUNTRY != "中国" -> RF.Deny()`。
|
||||
// 地区库缺失时 COUNTRY 会是"未知",这个不等式恒成立,会把访客整片误杀。
|
||||
// 所以地区不可判定时,地区类规则一律不参与本次判定——拦截、放行、仅记录一视同仁地剔除,
|
||||
// 保证语义是"这条规则这次不生效",而不是"这条规则这次判成了放行"。
|
||||
func dropGeoRules(ruleMatchs []*ast.RuleEntry) []*ast.RuleEntry {
|
||||
kept := ruleMatchs[:0:0]
|
||||
for _, v := range ruleMatchs {
|
||||
if isGeoRule(v.GrlText) {
|
||||
zlog.Debug("地区不可判定,跳过地区类规则: ", v.RuleName)
|
||||
continue
|
||||
}
|
||||
kept = append(kept, v)
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
// ruleMatchResult 单侧(局部/全局)规则的命中结果
|
||||
type ruleMatchResult struct {
|
||||
Matched bool
|
||||
@@ -99,6 +132,9 @@ func matchRules(ruleHelper *utils.RuleHelper, weblogbean *innerbean.WebLog, titl
|
||||
zlog.Debug("规则 ", err)
|
||||
return out
|
||||
}
|
||||
if weblogbean.GeoUnresolved {
|
||||
ruleMatchs = dropGeoRules(ruleMatchs)
|
||||
}
|
||||
if len(ruleMatchs) == 0 {
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package wafenginecore
|
||||
|
||||
import (
|
||||
"SamWaf/innerbean"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 地区封禁没有独立模块,走的是自定义规则。地区库缺失时 COUNTRY 会是"未知",
|
||||
// `MF.COUNTRY != "中国"` 恒成立,会把访客整片误杀。这批用例锁住"地区不可判定即放行"的语义。
|
||||
|
||||
func TestIsGeoRule(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
grl string
|
||||
want bool
|
||||
}{
|
||||
{"COUNTRY 比较", `rule R1 "x" { when MF.COUNTRY != "中国" then RF.Deny(); }`, true},
|
||||
{"PROVINCE 比较", `rule R1 "x" { when MF.PROVINCE == "广东" then RF.Deny(); }`, true},
|
||||
{"CITY 比较", `rule R1 "x" { when MF.CITY == "深圳" then RF.Allow(); }`, true},
|
||||
{"带空格的写法", `rule R1 "x" { when MF . COUNTRY != "中国" then RF.Deny(); }`, true},
|
||||
{"组合条件里含地区", `rule R1 "x" { when MF.COUNTRY != "中国" && MF.URL.HasPrefix("/login") == true then RF.Deny(); }`, true},
|
||||
{"与地区无关", `rule R1 "x" { when MF.URL == "/admin" then RF.Deny(); }`, false},
|
||||
// 规则描述/字符串字面量里出现 COUNTRY 只是文字,不是条件,不能算地区规则
|
||||
{"字符串里出现COUNTRY", `rule R1 "按 COUNTRY 统计" { when MF.URL == "/x?a=MF.COUNTRY" then RF.Deny(); }`, false},
|
||||
{"前缀不完整不算", `rule R1 "x" { when MF.COUNTRYCODE == "CN" then RF.Deny(); }`, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := isGeoRule(c.grl); got != c.want {
|
||||
t.Fatalf("isGeoRule=%v want=%v\n%s", got, c.want, c.grl)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 地区不可判定时,"拦截海外访问"这条官方模板规则必须不生效(放行)
|
||||
func TestGeoUnresolvedDropsOverseasDenyRule(t *testing.T) {
|
||||
rh := buildRuleHelper(t, `
|
||||
rule Rtest001 "拦截海外访问" salience 10 {
|
||||
when MF.COUNTRY != "中国"
|
||||
then RF.Deny();
|
||||
}`)
|
||||
|
||||
// 地区可判定:正常命中并拦截
|
||||
logResolved := &innerbean.WebLog{URL: "/", COUNTRY: "未知", GeoUnresolved: false}
|
||||
if res := matchRules(rh, logResolved, ""); !res.Matched {
|
||||
t.Fatal("地区可判定时应命中拦截规则")
|
||||
}
|
||||
|
||||
// 地区不可判定:同样的请求必须不再命中
|
||||
logUnresolved := &innerbean.WebLog{URL: "/", COUNTRY: "未知", GeoUnresolved: true}
|
||||
if res := matchRules(rh, logUnresolved, ""); res.Matched {
|
||||
t.Fatalf("地区不可判定时应放行,却命中了: %s", res.Title)
|
||||
}
|
||||
}
|
||||
|
||||
// 剔除只针对地区规则,非地区规则不受影响
|
||||
func TestGeoUnresolvedKeepsNonGeoRule(t *testing.T) {
|
||||
rh := buildRuleHelper(t, `
|
||||
rule Rtest001 "拦截后台路径" salience 10 {
|
||||
when MF.URL == "/admin"
|
||||
then RF.Deny();
|
||||
}`)
|
||||
|
||||
log := &innerbean.WebLog{URL: "/admin", COUNTRY: "未知", GeoUnresolved: true}
|
||||
if res := matchRules(rh, log, ""); !res.Matched {
|
||||
t.Fatal("非地区规则不应被地区标志位影响")
|
||||
}
|
||||
}
|
||||
|
||||
// 地区放行规则也一并剔除:语义是"这条规则这次不参与判定",
|
||||
// 而不是"这次判成了放行"——否则会反过来让本该被别的规则拦下的请求溜过去。
|
||||
func TestGeoUnresolvedDropsGeoAllowRuleToo(t *testing.T) {
|
||||
rh := buildRuleHelper(t, `
|
||||
rule Rtest001 "国内放行" salience 10 {
|
||||
when MF.COUNTRY == "未知"
|
||||
then RF.Allow();
|
||||
}`)
|
||||
|
||||
log := &innerbean.WebLog{URL: "/", COUNTRY: "未知", GeoUnresolved: true}
|
||||
if res := matchRules(rh, log, ""); res.Matched {
|
||||
t.Fatalf("地区不可判定时地区放行规则也应剔除,却命中了: %s", res.Title)
|
||||
}
|
||||
}
|
||||
@@ -356,7 +356,7 @@ func (waf *WafEngine) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
header := joinHeader(r.Header)
|
||||
|
||||
region := utils.GetCountry(clientIP)
|
||||
region, geoResolved := utils.GetCountryEx(clientIP)
|
||||
|
||||
currentDay, _ := strconv.Atoi(time.Now().Format("20060102"))
|
||||
|
||||
@@ -394,6 +394,7 @@ func (waf *WafEngine) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
GUEST_IDENTIFICATION: "正常访客", //访客身份识别
|
||||
TimeSpent: 0,
|
||||
NetSrcIp: utils.GetSourceClientIP(r.RemoteAddr),
|
||||
GeoUnresolved: !geoResolved,
|
||||
SrcByteBody: bodyByte,
|
||||
WebLogVersion: global.GWEBLOG_VERSION,
|
||||
Scheme: r.Proto,
|
||||
|
||||
Reference in New Issue
Block a user