diff --git a/api/waf_iplocation.go b/api/waf_iplocation.go index 9e4be477..603a0f8b 100644 --- a/api/waf_iplocation.go +++ b/api/waf_iplocation.go @@ -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 { diff --git a/cmd/samwaf/exedata/GeoLite2-Country.mmdb b/cmd/samwaf/exedata/GeoLite2-Country.mmdb deleted file mode 100644 index 9c751dc8..00000000 Binary files a/cmd/samwaf/exedata/GeoLite2-Country.mmdb and /dev/null differ diff --git a/cmd/samwaf/main.go b/cmd/samwaf/main.go index a002570b..3cd88593 100644 --- a/cmd/samwaf/main.go +++ b/cmd/samwaf/main.go @@ -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) - - // 根据配置加载 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") - } + // 注册内置数据库,供 manager 在磁盘无文件时兜底加载。 + // GeoLite2 已去内嵌,这里只剩 IPv4 的 ip2region 一份兜底。 + iplocation.SetBuiltinData(Ip2regionBytes, nil) + + // 加载优先级与热重载共用同一份实现(磁盘文件 > 内置数据 > 同类型其它来源降级)。 + // 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()) } - - // 加载 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)") + 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/ 目录") } } + 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() diff --git a/cmd/tools/pack_ipdb/main.go b/cmd/tools/pack_ipdb/main.go new file mode 100644 index 00000000..9e1baf0d --- /dev/null +++ b/cmd/tools/pack_ipdb/main.go @@ -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) +// +// 输出: +// +// //ip2region_v6.xdb 数据文件副本 +// //ip2region.xdb +// /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) +} diff --git a/docs/ipmodify.md b/docs/ipmodify.md index 834396a7..40d04bf0 100644 --- a/docs/ipmodify.md +++ b/docs/ipmodify.md @@ -1,15 +1,84 @@ # ip 库的处理 ## 机制 -1. SamWaf为了轻量化内置了ip2region.xdb。 -2. 遇到识别不准的问题就得自己构建放在 data/ip2region.xdb,重启SamWaf就可以了。 -## 如何生成 ip2region.xdb -这里使用 Ip2region(狮子的魂)。为了方便测试使用,fork了一份,生成了windows和linux的可执行文件。 +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 != "中国"` 这类规则在没有地区数据时把访客整片误拦。 -https://github.com/samwafgo/ip2region/releases +## 各数据文件对照 + +| 文件名(放在 `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.编辑 diff --git a/global/global.go b/global/global.go index 76f505c5..d22ec0a5 100644 --- a/global/global.go +++ b/global/global.go @@ -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 diff --git a/innerbean/web_log.go b/innerbean/web_log.go index 9386ff81..6fe3ec17 100644 --- a/innerbean/web_log.go +++ b/innerbean/web_log.go @@ -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的值 diff --git a/iplocation/builtin.go b/iplocation/builtin.go index 4ac2398b..52ba430d 100644 --- a/iplocation/builtin.go +++ b/iplocation/builtin.go @@ -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": diff --git a/iplocation/builtin_test.go b/iplocation/builtin_test.go index 91a43da3..5536c2cb 100644 --- a/iplocation/builtin_test.go +++ b/iplocation/builtin_test.go @@ -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") +// 这批用例覆盖 GeoLite2 去内嵌后的加载与降级行为。 +// 关键前提:程序只内置 IPv4 的 ip2region.xdb;GeoLite2 与 IPv6 库都必须来自磁盘。 + +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(ip2region, geolite2) + SetBuiltinData(b, nil) + return b +} - emptyDir := t.TempDir() // 全新安装:data 下什么都没有 +// 模拟全新安装: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.IPv4FileSize == 0 || st.IPv6FileSize == 0 { - t.Fatalf("内置数据大小应非零, got v4=%d v6=%d", st.IPv4FileSize, st.IPv6FileSize) + if !st.IPv4Builtin || st.IPv4FileSize == 0 { + t.Fatalf("IPv4 应加载内置数据, got builtin=%v size=%d", st.IPv4Builtin, st.IPv4FileSize) } - 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()) + 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()) + + // 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) + } + if m.GetStatus().IPv4Builtin { + t.Fatal("磁盘已有 ip2region.xdb,IPv4 不应标记为内置") + } +} + +// 老用户配置还是 geolite2,但 mmdb 已不再内置也不在磁盘上: +// IPv4 必须运行时降级到内置 ip2region,绝不能落到"没有地区数据"。 +func TestIPv4GeoLite2FallsBackToIp2Region(t *testing.T) { + loadBuiltinV4(t) + dataDir := t.TempDir() // 磁盘上没有 mmdb + + m := NewManager() + if err := m.ReloadFromConfig(dataDir, "geolite2", "ip2region", "legacy", "legacy"); err != nil { t.Fatalf("ReloadFromConfig 失败: %v", err) } st := m.GetStatus() - if st.IPv4Builtin { - t.Fatal("磁盘已有 ip2region.xdb,IPv4 不应标记为内置") + if st.IPv4Source != string(SourceIp2Region) { + t.Fatalf("IPv4 应降级到 ip2region, got %s", st.IPv4Source) } - if !st.IPv6Builtin { - t.Fatal("磁盘无 GeoLite2 文件,IPv6 应回落到内置") + if r := m.Lookup("8.8.8.8"); r.Unresolved { + t.Fatalf("降级后 IPv4 仍应可查: %+v", r) } } -// IPv4 选 geolite2 时必须读 mmdb,不能把 ip2region.xdb 的字节喂给 GeoLite2 解析器 -func TestIPv4GeoLite2UsesMmdbNotXdb(t *testing.T) { - ip2region, err := os.ReadFile("../cmd/samwaf/exedata/ip2region.xdb") +// 老用户配置 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.Fatal(err) + t.Skip("本机没有 data/ip2region_v6.xdb,跳过 IPv6 降级用例") } - geolite2, err := os.ReadFile("../cmd/samwaf/exedata/GeoLite2-Country.mmdb") - if err != nil { + dataDir := t.TempDir() + if err = os.WriteFile(filepath.Join(dataDir, "ip2region_v6.xdb"), v6, 0o644); err != nil { t.Fatal(err) } - SetBuiltinData(ip2region, geolite2) - // 磁盘只有 ip2region.xdb:IPv4 选 geolite2 时不能误用它,应回落到内置 mmdb + 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(dataDir+"/ip2region.xdb", ip2region, 0644); err != nil { + if err = os.WriteFile(filepath.Join(dataDir, "GeoLite2-Country.mmdb"), mmdb, 0o644); err != nil { t.Fatal(err) } m := NewManager() - if err := m.ReloadFromConfig(dataDir, "geolite2", "geolite2", "legacy", "legacy"); err != nil { + if err = m.ReloadFromConfig(dataDir, "ip2region", "geolite2", "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.IPv6Source != string(SourceGeoLite2) { + t.Fatalf("用户自备 mmdb 时 IPv6 应用 geolite2, got %s", st.IPv6Source) } - 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 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) + } } } diff --git a/iplocation/manager.go b/iplocation/manager.go index bb3389ad..6032fdf0 100644 --- a/iplocation/manager.go +++ b/iplocation/manager.go @@ -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 } - case string(SourceGeoLite2): - data, builtin := readDBFile(dataDir, "GeoLite2-Country.mmdb"), false - if data == nil { - data, builtin = builtinGeoLite2, true + 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 } - if len(data) > 0 { + case string(SourceGeoLite2): + // 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 } } diff --git a/iplocation/types.go b/iplocation/types.go index 6813ccea..f2f1e20f 100644 --- a/iplocation/types.go +++ b/iplocation/types.go @@ -8,6 +8,12 @@ type IPLocationResult struct { ISP string // 运营商 Region string // 区域/大洲 District string // 区县 + + // Unresolved 表示"这次查不出来",而不是"查出来是未知"。 + // 对应两种情况:该 IP 类型压根没有可用的数据库后端(如去内嵌后没下载 IPv6 库), + // 或者后端查询本身报错。调用方据此决定是否让地区类规则参与判定—— + // 地区不可判定时必须放行,否则 `MF.COUNTRY != "中国"` 这类规则会把访客整片误杀。 + Unresolved bool } // ToSlice 返回兼容老格式的 []string: [国家, 区域, 省份, 城市, ISP] diff --git a/iplocation/upgrader.go b/iplocation/upgrader.go new file mode 100644 index 00000000..29166f1f --- /dev/null +++ b/iplocation/upgrader.go @@ -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 +} diff --git a/router/waf_iplocation.go b/router/waf_iplocation.go index 27d38c2a..3ffe4797 100644 --- a/router/waf_iplocation.go +++ b/router/waf_iplocation.go @@ -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) } } diff --git a/utils/common.go b/utils/common.go index 82624b5d..aee1faab 100644 --- a/utils/common.go +++ b/utils/common.go @@ -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 解析成一行可读的归属地文本(国家 省份 城市 运营商) diff --git a/wafenginecore/checkrule.go b/wafenginecore/checkrule.go index 6cb43ece..2511fabf 100644 --- a/wafenginecore/checkrule.go +++ b/wafenginecore/checkrule.go @@ -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 } diff --git a/wafenginecore/checkrule_geo_test.go b/wafenginecore/checkrule_geo_test.go new file mode 100644 index 00000000..491c2914 --- /dev/null +++ b/wafenginecore/checkrule_geo_test.go @@ -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) + } +} diff --git a/wafenginecore/wafengine.go b/wafenginecore/wafengine.go index 713084ed..75888d15 100644 --- a/wafenginecore/wafengine.go +++ b/wafenginecore/wafengine.go @@ -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,