Merge pull request #939 from samwafgo/fix_remove_mmdb

fix:remove mmdb
This commit is contained in:
samwafgo
2026-08-12 16:55:11 +08:00
committed by GitHub
17 changed files with 1324 additions and 256 deletions
+73 -3
View File
@@ -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
View File
@@ -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()
+164
View File
@@ -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
View File
@@ -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
View File
@@ -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
+6
View File
@@ -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的值
+4 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
}
+6
View File
@@ -8,6 +8,12 @@ type IPLocationResult struct {
ISP string // 运营商
Region string // 区域/大洲
District string // 区县
// Unresolved 表示"这次查不出来",而不是"查出来是未知"。
// 对应两种情况:该 IP 类型压根没有可用的数据库后端(如去内嵌后没下载 IPv6 库),
// 或者后端查询本身报错。调用方据此决定是否让地区类规则参与判定——
// 地区不可判定时必须放行,否则 `MF.COUNTRY != "中国"` 这类规则会把访客整片误杀。
Unresolved bool
}
// ToSlice 返回兼容老格式的 []string: [国家, 区域, 省份, 城市, ISP]
+610
View File
@@ -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
}
+4
View File
@@ -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
View File
@@ -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 解析成一行可读的归属地文本(国家 省份 城市 运营商)
+36
View File
@@ -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
}
+84
View File
@@ -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)
}
}
+2 -1
View File
@@ -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,