Merge pull request #936 from samwafgo/feat_iplookup

feat:add iplookup
This commit is contained in:
samwafgo
2026-08-11 11:11:45 +08:00
committed by GitHub
9 changed files with 958 additions and 0 deletions
+2
View File
@@ -63,6 +63,7 @@ type APIGroup struct {
WafHostConnApi
WafThreatIPApi
WafCDNIPApi
WafIPLookupApi
WafPluginApi
WafLogFileWriteApi
WafIPLocationApi
@@ -155,6 +156,7 @@ var (
wafHostConnService = waf_service.WafHostConnServiceApp
wafThreatIPService = waf_service.WafThreatIPServiceApp
wafCDNIPService = waf_service.WafCDNIPServiceApp
wafIPLookupService = waf_service.WafIPLookupServiceApp
wafOPlatformKeyService = waf_service.WafOPlatformKeyServiceApp
wafOPlatformLogService = waf_service.WafOPlatformLogServiceApp
+41
View File
@@ -0,0 +1,41 @@
package api
import (
"SamWaf/model/common/response"
"SamWaf/model/request"
"strings"
"github.com/gin-gonic/gin"
)
type WafIPLookupApi struct {
}
// LookupApi IP归属查询
// @Summary IP归属查询
// @Description 查一个IP当前落在哪些名单里:IP黑/白名单、IP组、威胁情报IP、IP失败封禁、CC封禁、系统防火墙封禁、CDN回源段
// @Tags 网站防护-IP归属查询
// @Produce json
// @Param ip query string true "待查询的IP"
// @Param sources query string false "只查指定来源(逗号分隔),留空查全部"
// @Success 200 {object} response.Response "查询成功"
// @Security ApiKeyAuth
// @Router /wafhost/ip/lookup [get]
func (w *WafIPLookupApi) LookupApi(c *gin.Context) {
var req request.WafIPLookupReq
if err := c.ShouldBind(&req); err != nil {
response.FailWithMessage("解析失败: "+err.Error(), c)
return
}
// sources 为空=查全部;前端为了显示进度会分批传入几个来源
var sources []string
if strings.TrimSpace(req.Sources) != "" {
sources = strings.Split(req.Sources, ",")
}
result, err := wafIPLookupService.Lookup(req.Ip, sources)
if err != nil {
response.FailWithMessage(err.Error(), c)
return
}
response.OkWithDetailed(result, "查询成功", c)
}
+9
View File
@@ -0,0 +1,9 @@
package request
// WafIPLookupReq IP 归属查询请求
type WafIPLookupReq struct {
Ip string `json:"ip" form:"ip"` // 待查的 IPv4/IPv6 地址
// Sources 只查指定来源(逗号分隔),留空=全部。
// 前端按来源分批请求,好让慢的那批(威胁情报)不挡住快的先出结果。
Sources string `json:"sources" form:"sources"`
}
+28
View File
@@ -0,0 +1,28 @@
package response
// IPLookupHit 一条命中记录:这个 IP 出现在了哪个名单/封禁源里。
type IPLookupHit struct {
Source string `json:"source"` // 来源码:ip_black/ip_white/ip_group/threat_ip/ip_failure/cc_ban/firewall/cdn
SourceName string `json:"source_name"` // 来源显示名
Scope string `json:"scope"` // 归属范围:网站名/组名/渠道名/厂商名;全局的写「全局」
Matched string `json:"matched"` // 实际命中的那条规则原文(单IP/CIDR/通配符/区间),缓存类无规则则为空
Effect string `json:"effect"` // 命中后的效果:block/allow/none
Detail string `json:"detail"` // 备注、剩余时间等补充说明
// SystemLayer 标记这条拦截是否落在系统防火墙层。
// 系统层是内核直接丢包,WAF 的 IP 白名单根本轮不到判定——前端要靠这个标记
// 提醒用户「加白也不会通」,所以必须是结构化字段,不能让前端去解析 Detail 文案。
SystemLayer bool `json:"system_layer"`
}
// IPLookupResp IP 归属查询结果。
// hits 为空表示这个 IP 目前不在任何名单里。
type IPLookupResp struct {
IP string `json:"ip"`
// QueryNote 输入被归一化时的说明(如输入网段、按其中某个IP查)。空=输入本来就是单个IP。
QueryNote string `json:"query_note"`
Location string `json:"location"` // 归属地,查不到为空
Hits []IPLookupHit `json:"hits"`
Sources []string `json:"sources"` // 本次实际查了哪些源,便于前端说明覆盖范围
Degraded []string `json:"degraded"` // 查询过程中失败/跳过的源(如快照解压失败),避免把「查不到」误报成「不在名单里」
}
+1
View File
@@ -61,6 +61,7 @@ type ApiGroup struct {
HostConnRouter
ThreatIPRouter
CDNIPRouter
IPLookupRouter
PluginRouter
LogFileWriteRouter
IPLocationRouter
+17
View File
@@ -0,0 +1,17 @@
package router
import (
"SamWaf/api"
"github.com/gin-gonic/gin"
)
type IPLookupRouter struct {
}
func (receiver *IPLookupRouter) InitIPLookupRouter(group *gin.RouterGroup) {
apiObj := api.APIGroupAPP.WafIPLookupApi
router := group.Group("")
router.GET("/api/v1/wafhost/ip/lookup", apiObj.LookupApi) // 查一个IP落在哪些名单里
}
@@ -0,0 +1,626 @@
package waf_service
import (
"SamWaf/enums"
"SamWaf/global"
"SamWaf/model"
response2 "SamWaf/model/response"
"SamWaf/utils"
"SamWaf/wafenginecore/ipset"
"SamWaf/wafipban"
"SamWaf/waftask/threatip"
"fmt"
"net"
"strings"
"sync"
"time"
)
// waf_ip_lookup_service 回答一个很具体的问题:这个 IP 现在到底在哪些名单里?
//
// 封禁/放行分散在七个地方(黑名单、白名单、IP组、威胁情报、IP失败封禁、CC封禁、系统防火墙),
// 外加 CDN 回源段。排查「为什么这个 IP 被拦了 / 为什么没被拦」时,用户得挨个页面翻,
// 还得自己心算 CIDR 包不包含——这里一次查完。
//
// 判定一律用 utils.MatchIPPattern(与引擎同一套语法:单IP/CIDR/通配符/区间),
// 不做字符串相等比较,否则 1.2.3.4 落在 1.2.3.0/24 里会被漏报。
type WafIPLookupService struct{}
var WafIPLookupServiceApp = new(WafIPLookupService)
// 大集合(威胁情报渠道、CDN回源段)编译成 CIDR trie 缓存起来,按快照 sha 失效。
//
// 不这么做的话只能逐条 utils.MatchIPPattern,十万条就是十万次调用,而共享的
// pattern 解析缓存只有 4096 条上限——一次查询就能把它塞满,于是:
// 1. 缓存满了之后每条都要重新解析,查一次要好几秒;
// 2. 更糟的是把规则引擎请求热路径在用的那份缓存给挤掉了。
//
// 编译成 trie 后单次判定是 O(1),也完全不碰那个共享缓存。
type cachedMatcher struct {
sha string
set *ipset.MatchSet
}
var (
lookupMatcherMu sync.Mutex
threatMatchers = map[string]*cachedMatcher{}
cdnMatchers = map[string]*cachedMatcher{}
)
// matcherFor 取(或重建)某个集合的编译结果。sha 变了说明快照已更新,重建。
func matcherFor(store map[string]*cachedMatcher, key, sha string, load func() []string) *ipset.MatchSet {
lookupMatcherMu.Lock()
defer lookupMatcherMu.Unlock()
if c, ok := store[key]; ok && c.sha == sha && sha != "" {
return c.set
}
items := load()
if items == nil {
return nil
}
set := ipset.BuildMatchSet(items)
store[key] = &cachedMatcher{sha: sha, set: set}
return set
}
// matchedEntryIn 命中之后再回头找出具体是哪一条规则匹配的。
// 只在确实命中时才走(命中很罕见),且直接用 ParsePatternLenient 而不是带缓存的版本,
// 免得又把一次性条目灌进共享 pattern 缓存。
func matchedEntryIn(items []string, ip net.IP) string {
for _, raw := range items {
p, err := ipset.ParsePatternLenient(strings.TrimSpace(raw))
if err != nil {
continue
}
if p.Match(ip) {
return raw
}
}
return ""
}
// 来源码,与前端组件的图例一一对应
const (
srcIPBlack = "ip_black"
srcIPWhite = "ip_white"
srcIPGroup = "ip_group"
srcThreatIP = "threat_ip"
srcIPFailure = "ip_failure"
srcCCBan = "cc_ban"
srcFirewall = "firewall"
srcCDN = "cdn"
)
// normalizeLookupInput 把用户输入归一成一个可查的 IP。
// 支持单IP、CIDR(取网络地址)、起-止区间(取起始);返回的第二个值是要告诉用户的说明,
// 空表示输入本来就是单个IP、没做任何替换。
func normalizeLookupInput(raw string) (net.IP, string) {
if ip := net.ParseIP(raw); ip != nil {
return ip, ""
}
if _, ipNet, err := net.ParseCIDR(raw); err == nil && ipNet != nil {
return ipNet.IP, fmt.Sprintf("输入的是网段 %s,已按其中的 %s 查询", raw, ipNet.IP.String())
}
if i := strings.Index(raw, "-"); i > 0 {
start := strings.TrimSpace(raw[:i])
if ip := net.ParseIP(start); ip != nil {
return ip, fmt.Sprintf("输入的是区间 %s,已按起始的 %s 查询", raw, start)
}
}
return nil, ""
}
// Lookup 查询一个 IP 的归属情况。sources 为空表示查全部来源。
func (r *WafIPLookupService) Lookup(ipStr string, sources []string) (*response2.IPLookupResp, error) {
ipStr = strings.TrimSpace(ipStr)
if ipStr == "" {
return nil, fmt.Errorf("请输入要查询的IP")
}
// 用户可能是从「查看IP」列表里点进来的,那儿是网段/区间不是单个IP。
// 直接判非法太粗暴——取其中一个代表IP来查,并如实说明查的是哪个。
parsed, note := normalizeLookupInput(ipStr)
if parsed == nil {
return nil, fmt.Errorf("不是合法的IP地址或网段:%s", ipStr)
}
if note != "" {
ipStr = parsed.String()
}
// 空表示查全部;前端为了能显示进度会分批只查其中几个
want := map[string]bool{}
for _, s := range sources {
if s = strings.TrimSpace(s); s != "" {
want[s] = true
}
}
all := len(want) == 0
pick := func(src string) bool { return all || want[src] }
resp := &response2.IPLookupResp{
IP: ipStr,
QueryNote: note,
Hits: make([]response2.IPLookupHit, 0, 4),
Sources: make([]string, 0, 8),
Degraded: make([]string, 0),
}
for _, src := range []string{
srcIPWhite, srcIPBlack, srcIPGroup, srcThreatIP,
srcIPFailure, srcCCBan, srcFirewall, srcCDN,
} {
if pick(src) {
resp.Sources = append(resp.Sources, src)
}
}
// 归属地只在查名单类时带上,分批时不必每批都查一遍
if pick(srcIPWhite) || all {
resp.Location = r.location(ipStr)
}
// 名单类共用一份站点名映射,没查名单就不用捞 hosts 表
var hostNames map[string]string
if pick(srcIPWhite) || pick(srcIPBlack) {
hostNames = r.hostNameMap()
}
// 白名单放在最前:命中白名单的 IP 即便同时在黑名单里也会被放行,
// 顺序本身就是给用户的排查提示
if pick(srcIPWhite) {
r.matchAllowList(ipStr, hostNames, resp)
}
if pick(srcIPBlack) {
r.matchBlockList(ipStr, hostNames, resp)
}
if pick(srcIPGroup) {
r.matchIPGroup(ipStr, resp)
}
if pick(srcThreatIP) {
r.matchThreatIP(ipStr, parsed, resp)
}
if pick(srcIPFailure) {
r.matchIPFailure(ipStr, resp)
}
if pick(srcCCBan) {
r.matchCCBan(ipStr, resp)
}
if pick(srcFirewall) {
r.matchFirewall(ipStr, resp)
}
if pick(srcCDN) {
r.matchCDN(ipStr, resp)
}
return resp, nil
}
// location 查归属地,查不到就留空——归属地只是辅助信息,不该让整个查询失败
func (r *WafIPLookupService) location(ipStr string) string {
if global.GIPLOCATION_MANAGER == nil {
return ""
}
res := global.GIPLOCATION_MANAGER.Lookup(ipStr)
if res == nil {
return ""
}
parts := make([]string, 0, 4)
for _, s := range []string{res.Country, res.Province, res.City, res.ISP} {
if s = strings.TrimSpace(s); s != "" && s != "0" {
parts = append(parts, s)
}
}
return strings.Join(parts, " ")
}
// hostNameMap 网站唯一码 → 域名,用于把 host_code 翻成用户认得的名字
func (r *WafIPLookupService) hostNameMap() map[string]string {
m := map[string]string{}
var hosts []model.Hosts
if err := global.GWAF_LOCAL_DB.Select("code", "host").Find(&hosts).Error; err != nil {
return m
}
for _, h := range hosts {
m[h.Code] = h.Host
}
return m
}
// hostLabel 把 host_code 翻成用户认得的站点名。
//
// 全局站点在 hosts 表里就是一条 host="全局网站" 的普通记录(code 是 uuid)
// 所以走 names 就能得到「全局网站」,不需要特殊分支——
// global.GWAF_GLOBAL_HOST_CODE 那个 "0" 是引擎路由表的 key,不是数据库 code,别拿来比。
//
// 查不到对应站点说明这条名单指向了已删除(或从来不存在)的站点,实际不会生效,
// 必须标出来,不能让用户以为它在保护什么。
func (r *WafIPLookupService) hostLabel(code string, names map[string]string) string {
if code == "" {
return "全局"
}
if n, ok := names[code]; ok && n != "" {
return n
}
return code + "(站点已不存在,该条不生效)"
}
func (r *WafIPLookupService) matchAllowList(ip string, names map[string]string, resp *response2.IPLookupResp) {
var rows []model.IPAllowList
if err := global.GWAF_LOCAL_DB.Find(&rows).Error; err != nil {
resp.Degraded = append(resp.Degraded, srcIPWhite)
return
}
for _, row := range rows {
// IpType=group 的行本身没有 IP,内容在 IP 组里,交给 matchIPGroup 统一判
if row.IpType == model.IPEntryTypeGroup || row.Ip == "" {
continue
}
if utils.MatchIPPattern(ip, row.Ip) {
resp.Hits = append(resp.Hits, response2.IPLookupHit{
Source: srcIPWhite,
SourceName: "IP白名单",
Scope: r.hostLabel(row.HostCode, names),
Matched: row.Ip,
Effect: "allow",
Detail: row.Remarks,
})
}
}
}
func (r *WafIPLookupService) matchBlockList(ip string, names map[string]string, resp *response2.IPLookupResp) {
var rows []model.IPBlockList
if err := global.GWAF_LOCAL_DB.Find(&rows).Error; err != nil {
resp.Degraded = append(resp.Degraded, srcIPBlack)
return
}
for _, row := range rows {
if row.IpType == model.IPEntryTypeGroup || row.Ip == "" {
continue
}
if utils.MatchIPPattern(ip, row.Ip) {
resp.Hits = append(resp.Hits, response2.IPLookupHit{
Source: srcIPBlack,
SourceName: "IP黑名单",
Scope: r.hostLabel(row.HostCode, names),
Matched: row.Ip,
Effect: "block",
Detail: row.Remarks,
})
}
}
}
// matchIPGroup IP 组本身不决定放行还是拦截——取决于哪个名单引用了它,
// 所以 effect 记 none,另外把引用它的名单列出来,用户才知道命中意味着什么
func (r *WafIPLookupService) matchIPGroup(ip string, resp *response2.IPLookupResp) {
var groups []model.IPGroup
if err := global.GWAF_LOCAL_DB.Find(&groups).Error; err != nil {
resp.Degraded = append(resp.Degraded, srcIPGroup)
return
}
if len(groups) == 0 {
return
}
// 一次把所有组条目捞出来按组分桶,避免每组一次查询
var items []model.IPGroupItem
if err := global.GWAF_LOCAL_DB.Find(&items).Error; err != nil {
resp.Degraded = append(resp.Degraded, srcIPGroup)
return
}
byGroup := map[string][]model.IPGroupItem{}
for _, it := range items {
byGroup[it.GroupCode] = append(byGroup[it.GroupCode], it)
}
refs := r.groupRefs()
for _, g := range groups {
for _, it := range byGroup[g.GroupCode] {
if it.Ip == "" || !utils.MatchIPPattern(ip, it.Ip) {
continue
}
effect := "none"
detail := "该组未被任何黑/白名单引用"
if ref, ok := refs[g.GroupCode]; ok {
effect = ref.effect
detail = ref.text
}
resp.Hits = append(resp.Hits, response2.IPLookupHit{
Source: srcIPGroup,
SourceName: "IP组",
Scope: g.GroupName,
Matched: it.Ip,
Effect: effect,
Detail: detail,
})
break // 同一组命中一条即可,不必把组内所有匹配行都列出来
}
}
}
type groupRef struct {
effect string
text string
}
// groupRefs 统计每个 IP 组被黑/白名单引用的情况
func (r *WafIPLookupService) groupRefs() map[string]groupRef {
refs := map[string]groupRef{}
var blockRows []model.IPBlockList
global.GWAF_LOCAL_DB.Where("ip_type = ?", model.IPEntryTypeGroup).Find(&blockRows)
for _, row := range blockRows {
if row.GroupCode == "" {
continue
}
refs[row.GroupCode] = groupRef{effect: "block", text: "被IP黑名单引用,命中即拦截"}
}
var allowRows []model.IPAllowList
global.GWAF_LOCAL_DB.Where("ip_type = ?", model.IPEntryTypeGroup).Find(&allowRows)
for _, row := range allowRows {
if row.GroupCode == "" {
continue
}
if old, ok := refs[row.GroupCode]; ok && old.effect == "block" {
// 同一个组既被黑名单又被白名单引用:白名单先判,实际效果是放行
refs[row.GroupCode] = groupRef{effect: "allow", text: "同时被黑白名单引用,白名单优先,实际放行"}
continue
}
refs[row.GroupCode] = groupRef{effect: "allow", text: "被IP白名单引用,命中即放行"}
}
return refs
}
// matchThreatIP 威胁情报动辄十万条,逐渠道解压比对很贵。
// 先用引擎那份全局并集(ipset 常数级判定)问一句「在不在」,不在就直接收工;
// 只有确实命中了才展开各渠道快照去定位是哪一家收录的。
func (r *WafIPLookupService) matchThreatIP(ip string, parsed net.IP, resp *response2.IPLookupResp) {
matcher := ipset.GetGlobalThreatMatcher()
if matcher == nil || !matcher.Contains(parsed) {
return
}
var channels []model.ThreatIPChannel
if err := global.GWAF_LOCAL_DB.Where("enable = ?", 1).Find(&channels).Error; err != nil {
resp.Degraded = append(resp.Degraded, srcThreatIP)
return
}
found := false
for _, ch := range channels {
// 只读表头拿 sha,命中缓存就完全不用解压
var meta model.ThreatIPSnapshot
if err := global.GWAF_LOCAL_DB.Select("sha256").Where("channel_code = ?", ch.Code).First(&meta).Error; err != nil {
continue
}
decodeFailed := false
set := matcherFor(threatMatchers, ch.Code, meta.Sha256, func() []string {
var snap model.ThreatIPSnapshot
if err := global.GWAF_LOCAL_DB.Where("channel_code = ?", ch.Code).First(&snap).Error; err != nil {
return nil
}
ips, derr := threatip.DecodeSnapshot(snap.Payload)
if derr != nil {
decodeFailed = true
return nil
}
return ips
})
if decodeFailed {
resp.Degraded = append(resp.Degraded, srcThreatIP)
continue
}
if set == nil || !set.Contains(parsed) {
continue
}
// 到这儿才解压一次去定位具体命中的那条规则——命中很罕见,这份开销可以接受
entry := ""
var snap model.ThreatIPSnapshot
if err := global.GWAF_LOCAL_DB.Where("channel_code = ?", ch.Code).First(&snap).Error; err == nil {
if ips, derr := threatip.DecodeSnapshot(snap.Payload); derr == nil {
entry = matchedEntryIn(ips, parsed)
}
}
found = true
resp.Hits = append(resp.Hits, response2.IPLookupHit{
Source: srcThreatIP,
SourceName: "威胁情报IP",
Scope: ch.Name,
Matched: entry,
Effect: "block",
Detail: "落地层:" + landTargetText(ch.LandTarget),
SystemLayer: landsOnSystem(ch.LandTarget),
})
}
// 并集说命中、逐渠道却找不到出处:多半是快照与已落地集合不同步(渠道刚停用/刚删)。
// 这种情况必须如实告诉用户「确实会被拦」,不能因为定位不到渠道就当没命中。
if !found {
resp.Hits = append(resp.Hits, response2.IPLookupHit{
Source: srcThreatIP,
SourceName: "威胁情报IP",
Scope: "已落地集合",
Effect: "block",
Detail: "在生效中的威胁情报集合内,但未能定位到具体渠道(快照可能正在同步)",
SystemLayer: true,
})
}
}
// landsOnSystem 落地层是否含系统防火墙(system/both)。
// 含系统层意味着内核就把包丢了,WAF 白名单救不回来。
func landsOnSystem(v string) bool {
return v == "system" || v == "both"
}
func landTargetText(v string) string {
switch v {
case "waf":
return "WAF应用层"
case "system":
return "系统防火墙"
case "both":
return "两者"
}
return v
}
// matchIPFailure IP 失败封禁是缓存态的临时封禁,键就是完整 IP,直接查
func (r *WafIPLookupService) matchIPFailure(ip string, resp *response2.IPLookupResp) {
if global.GCACHE_WAFCACHE == nil {
resp.Degraded = append(resp.Degraded, srcIPFailure)
return
}
key := enums.CACHE_IP_FAILURE_PRE + ip
if !global.GCACHE_WAFCACHE.IsKeyExist(key) {
return
}
detail := "自动封禁中"
if manager := wafipban.GetIPFailureManager(); manager != nil {
if record := manager.GetFailureInfo(ip); record != nil && record.TriggerCount > 0 {
detail = fmt.Sprintf("%d分钟内失败%d次触发封禁", record.TriggerMinutes, record.TriggerCount)
}
}
if remain := r.remainText(enums.CACHE_IP_FAILURE_PRE, ip); remain != "" {
detail += ",剩余" + remain
}
resp.Hits = append(resp.Hits, response2.IPLookupHit{
Source: srcIPFailure,
SourceName: "IP失败封禁",
Scope: "全局",
Effect: "block",
Detail: detail,
})
}
func (r *WafIPLookupService) matchCCBan(ip string, resp *response2.IPLookupResp) {
if global.GCACHE_WAFCACHE == nil {
resp.Degraded = append(resp.Degraded, srcCCBan)
return
}
if !global.GCACHE_WAFCACHE.IsKeyExist(enums.CACHE_CCVISITBAN_PRE + ip) {
return
}
detail := "CC防护触发的临时封禁"
if remain := r.remainText(enums.CACHE_CCVISITBAN_PRE, ip); remain != "" {
detail += ",剩余" + remain
}
resp.Hits = append(resp.Hits, response2.IPLookupHit{
Source: srcCCBan,
SourceName: "CC封禁",
Scope: "全局",
Effect: "block",
Detail: detail,
})
}
// remainText 取缓存剩余时间。缓存接口只给了「按前缀列出可用键」,没有单键 TTL,
// 所以这里列一次再挑出目标键。
func (r *WafIPLookupService) remainText(prefix, ip string) string {
list := global.GCACHE_WAFCACHE.ListAvailableKeysWithPrefix(prefix)
d, ok := list[prefix+ip]
if !ok || d <= 0 {
return ""
}
if d < time.Minute {
return fmt.Sprintf("%d秒", int(d.Seconds()))
}
total := int(d.Minutes())
if total < 60 {
return fmt.Sprintf("%d分钟", total)
}
return fmt.Sprintf("%d小时%d分钟", total/60, total%60)
}
func (r *WafIPLookupService) matchFirewall(ip string, resp *response2.IPLookupResp) {
var rows []model.FirewallIPBlock
if err := global.GWAF_LOCAL_DB.Where("status = ?", "active").Find(&rows).Error; err != nil {
resp.Degraded = append(resp.Degraded, srcFirewall)
return
}
now := time.Now().Unix()
for _, row := range rows {
if row.IP == "" || !utils.MatchIPPattern(ip, row.IP) {
continue
}
// 到期未清理的记录还留在表里,但实际已经不封了,不能report成生效中
if row.ExpireTime > 0 && row.ExpireTime < now {
continue
}
detail := row.Reason
if row.ExpireTime > 0 {
detail = strings.TrimSpace(detail + " 到期时间:" + time.Unix(row.ExpireTime, 0).Format("2006-01-02 15:04:05"))
}
resp.Hits = append(resp.Hits, response2.IPLookupHit{
Source: srcFirewall,
SourceName: "防火墙IP封禁",
Scope: blockTypeText(row.BlockType),
Matched: row.IP,
Effect: "block",
Detail: detail,
SystemLayer: true,
})
}
}
func blockTypeText(v string) string {
switch v {
case "manual":
return "手动封禁"
case "auto":
return "自动封禁"
case "temp":
return "临时封禁"
}
return v
}
// matchCDN CDN 回源段既不拦也不放,但命中了说明这个 IP 是 CDN 节点、
// 不是真实访客——排查「为什么日志里全是同几个 IP」时这条最关键
func (r *WafIPLookupService) matchCDN(ip string, resp *response2.IPLookupResp) {
parsed := net.ParseIP(ip)
views := WafCDNIPServiceApp.GetProviderList()
for _, v := range views {
if v.Count <= 0 {
continue
}
// 用快照 sha 当缓存键,厂商段没更新就直接复用编译结果
var row model.CDNProvider
sha := ""
if err := global.GWAF_LOCAL_DB.Select("sha256").Where("provider = ?", v.Provider).First(&row).Error; err == nil {
sha = row.Sha256
}
fetchFailed := false
set := matcherFor(cdnMatchers, v.Provider, sha, func() []string {
cidrs, err := WafCDNIPServiceApp.GetProviderCIDRs(v.Provider)
if err != nil {
fetchFailed = true
return nil
}
return cidrs
})
if fetchFailed {
resp.Degraded = append(resp.Degraded, srcCDN)
continue
}
if set == nil || !set.Contains(parsed) {
continue
}
entry := ""
if cidrs, err := WafCDNIPServiceApp.GetProviderCIDRs(v.Provider); err == nil {
entry = matchedEntryIn(cidrs, parsed)
}
resp.Hits = append(resp.Hits, response2.IPLookupHit{
Source: srcCDN,
SourceName: "CDN回源IP",
Scope: v.Name,
Matched: entry,
Effect: "none",
Detail: "该IP属于CDN回源节点,不是真实访客IP",
})
}
}
@@ -0,0 +1,233 @@
package waf_service
import (
"path/filepath"
"strings"
"testing"
"SamWaf/global"
"SamWaf/model"
"SamWaf/model/baseorm"
sqlite "github.com/samwafgo/sqlitedriver"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// setupIPLookupTestDB 建临时库并接管全局 DB,用例结束后还原
func setupIPLookupTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(
sqlite.Open(filepath.Join(t.TempDir(), "iplookup_test.db")),
&gorm.Config{Logger: logger.Default.LogMode(logger.Silent)},
)
if err != nil {
t.Fatalf("打开测试库失败: %v", err)
}
if err := db.AutoMigrate(
&model.IPGroup{}, &model.IPGroupItem{}, &model.IPBlockList{},
&model.IPAllowList{}, &model.Hosts{}, &model.FirewallIPBlock{},
); err != nil {
t.Fatalf("AutoMigrate 失败: %v", err)
}
oldDB, oldTenant, oldUser := global.GWAF_LOCAL_DB, global.GWAF_TENANT_ID, global.GWAF_USER_CODE
global.GWAF_LOCAL_DB = db
global.GWAF_TENANT_ID, global.GWAF_USER_CODE = "SamWafCom", "user-uuid-iplookup"
t.Cleanup(func() {
global.GWAF_LOCAL_DB = oldDB
global.GWAF_TENANT_ID, global.GWAF_USER_CODE = oldTenant, oldUser
if sqlDB, e := db.DB(); e == nil {
_ = sqlDB.Close()
}
})
return db
}
func newBase(id string) baseorm.BaseOrm {
return baseorm.BaseOrm{Id: id, USER_CODE: global.GWAF_USER_CODE, Tenant_ID: global.GWAF_TENANT_ID}
}
// TestLookupMatchesCIDR 网段必须能命中:用户填的是 1.2.3.0/24,查 1.2.3.4 得算命中。
// 这条最容易写错成字符串相等,一旦退化就会误报「这个IP不在黑名单里」。
func TestLookupMatchesCIDR(t *testing.T) {
db := setupIPLookupTestDB(t)
db.Create(&model.Hosts{BaseOrm: newBase("h1"), Code: "hostA", Host: "www.demo.com"})
db.Create(&model.IPBlockList{BaseOrm: newBase("b1"), HostCode: "hostA", Ip: "1.2.3.0/24", Remarks: "网段封禁"})
resp, err := WafIPLookupServiceApp.Lookup("1.2.3.4", nil)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(resp.Hits) != 1 {
t.Fatalf("期望命中1条,实际 %d 条", len(resp.Hits))
}
h := resp.Hits[0]
if h.Source != srcIPBlack || h.Effect != "block" {
t.Fatalf("来源/效果不对: %+v", h)
}
if h.Matched != "1.2.3.0/24" {
t.Fatalf("应回显实际命中的那条规则,实际 %q", h.Matched)
}
if h.Scope != "www.demo.com" {
t.Fatalf("host_code 应翻成域名,实际 %q", h.Scope)
}
}
// TestLookupGlobalScope host_code 为空表示全局网站,必须标成「全局」,
// 否则用户会以为这条只影响某一个站点
func TestLookupGlobalScope(t *testing.T) {
db := setupIPLookupTestDB(t)
db.Create(&model.IPAllowList{BaseOrm: newBase("a1"), HostCode: "", Ip: "10.0.0.1"})
resp, _ := WafIPLookupServiceApp.Lookup("10.0.0.1", nil)
if len(resp.Hits) != 1 || resp.Hits[0].Scope != "全局" {
t.Fatalf("空 host_code 应显示为全局,实际 %+v", resp.Hits)
}
// 全局站点其实是 hosts 表里一条 host="全局网站" 的普通记录,走站点名即可
db.Create(&model.Hosts{BaseOrm: newBase("hg"), Code: "uuid-global", Host: "全局网站"})
db.Create(&model.IPAllowList{BaseOrm: newBase("a2"), HostCode: "uuid-global", Ip: "10.0.0.2"})
if r2, _ := WafIPLookupServiceApp.Lookup("10.0.0.2", nil); r2.Hits[0].Scope != "全局网站" {
t.Fatalf("全局站点应显示站点名,实际 %q", r2.Hits[0].Scope)
}
// 指向已删除站点的名单不会生效,必须标出来而不是显示一串 uuid
db.Create(&model.IPAllowList{BaseOrm: newBase("a3"), HostCode: "gone-uuid", Ip: "10.0.0.3"})
if r3, _ := WafIPLookupServiceApp.Lookup("10.0.0.3", nil); !strings.Contains(r3.Hits[0].Scope, "不生效") {
t.Fatalf("站点不存在应提示不生效,实际 %q", r3.Hits[0].Scope)
}
if resp.Hits[0].Effect != "allow" {
t.Fatalf("白名单效果应为 allow,实际 %q", resp.Hits[0].Effect)
}
}
// TestLookupGroupRefEffect IP组本身不决定放行/拦截,取决于谁引用了它。
// 同时被黑白名单引用时白名单先判,实际是放行——这个结论不能报反。
func TestLookupGroupRefEffect(t *testing.T) {
db := setupIPLookupTestDB(t)
db.Create(&model.IPGroup{BaseOrm: newBase("g1"), GroupName: "办公出口", GroupCode: "office"})
db.Create(&model.IPGroupItem{BaseOrm: newBase("gi1"), GroupCode: "office", Ip: "192.168.1.0/24"})
db.Create(&model.IPBlockList{BaseOrm: newBase("b1"), HostCode: "hostA", IpType: model.IPEntryTypeGroup, GroupCode: "office"})
resp, _ := WafIPLookupServiceApp.Lookup("192.168.1.9", nil)
if len(resp.Hits) != 1 {
t.Fatalf("期望只命中IP组1条(引用行本身无IP不该重复计),实际 %+v", resp.Hits)
}
if resp.Hits[0].Source != srcIPGroup || resp.Hits[0].Effect != "block" {
t.Fatalf("被黑名单引用的组应判 block,实际 %+v", resp.Hits[0])
}
// 再加一个白名单引用,效果应翻成放行
db.Create(&model.IPAllowList{BaseOrm: newBase("a1"), HostCode: "hostA", IpType: model.IPEntryTypeGroup, GroupCode: "office"})
resp2, _ := WafIPLookupServiceApp.Lookup("192.168.1.9", nil)
if resp2.Hits[0].Effect != "allow" {
t.Fatalf("黑白名单同时引用同一组时白名单优先,应为 allow,实际 %q", resp2.Hits[0].Effect)
}
}
// TestLookupFirewallExpired 已过期但没清理的封禁记录还躺在表里,
// 不能报成「生效中」,否则用户会照着一条早失效的记录排查半天
func TestLookupFirewallExpired(t *testing.T) {
db := setupIPLookupTestDB(t)
db.Create(&model.FirewallIPBlock{
BaseOrm: newBase("f1"), IP: "8.8.8.8", Status: "active",
BlockType: "manual", ExpireTime: 1000, // 1970 年,早过期
})
db.Create(&model.FirewallIPBlock{
BaseOrm: newBase("f2"), IP: "9.9.9.9", Status: "active",
BlockType: "auto", ExpireTime: 0, // 永久
})
if resp, _ := WafIPLookupServiceApp.Lookup("8.8.8.8", nil); len(resp.Hits) != 0 {
t.Fatalf("过期封禁不该算命中,实际 %+v", resp.Hits)
}
resp, _ := WafIPLookupServiceApp.Lookup("9.9.9.9", nil)
if len(resp.Hits) != 1 || resp.Hits[0].Source != srcFirewall {
t.Fatalf("永久封禁应命中,实际 %+v", resp.Hits)
}
}
// TestLookupNoHit 干净的库里查任何IP都应返回空命中而不是报错,
// 前端靠这个区分「没查到」和「查询失败」
func TestLookupNoHit(t *testing.T) {
setupIPLookupTestDB(t)
resp, err := WafIPLookupServiceApp.Lookup("203.0.113.7", nil)
if err != nil {
t.Fatalf("空库查询不该报错: %v", err)
}
if len(resp.Hits) != 0 {
t.Fatalf("期望0条命中,实际 %+v", resp.Hits)
}
if resp.Hits == nil {
t.Fatal("hits 必须是空数组而不是 null,否则前端 .length 会炸")
}
}
// TestLookupInvalidIP 非法输入要给明确错误,不能当成空结果
func TestLookupInvalidIP(t *testing.T) {
setupIPLookupTestDB(t)
if _, err := WafIPLookupServiceApp.Lookup("not-an-ip", nil); err == nil {
t.Fatal("非法IP应返回错误")
}
if _, err := WafIPLookupServiceApp.Lookup(" ", nil); err == nil {
t.Fatal("空输入应返回错误")
}
}
// TestLookupSystemLayerFlag 防火墙封禁是内核层丢包,必须标 SystemLayer
// 前端靠它提示「加白名单也不会通」。漏标就会让用户加完白以为好了,实际还是连不上。
func TestLookupSystemLayerFlag(t *testing.T) {
db := setupIPLookupTestDB(t)
db.Create(&model.FirewallIPBlock{
BaseOrm: newBase("f1"), IP: "5.5.5.5", Status: "active", BlockType: "manual",
})
resp, _ := WafIPLookupServiceApp.Lookup("5.5.5.5", nil)
if len(resp.Hits) != 1 || !resp.Hits[0].SystemLayer {
t.Fatalf("防火墙封禁应标记为系统层,实际 %+v", resp.Hits)
}
// WAF 层的黑名单不该被误标成系统层,否则每次加白都会弹一个无关的警告
db.Create(&model.IPBlockList{BaseOrm: newBase("b1"), HostCode: "", Ip: "6.6.6.6"})
resp2, _ := WafIPLookupServiceApp.Lookup("6.6.6.6", nil)
if len(resp2.Hits) != 1 || resp2.Hits[0].SystemLayer {
t.Fatalf("IP黑名单只在WAF层,不该标系统层,实际 %+v", resp2.Hits)
}
}
// TestLandsOnSystem 落地层判定:只有 system/both 才算落到内核
func TestLandsOnSystem(t *testing.T) {
for v, want := range map[string]bool{"system": true, "both": true, "waf": false, "": false} {
if landsOnSystem(v) != want {
t.Fatalf("landsOnSystem(%q) 应为 %v", v, want)
}
}
}
// TestLookupAcceptsCIDR 从「查看IP」列表点进来时传的是网段,
// 不能直接判非法——取代表IP查,并如实说明查的是哪个
func TestLookupAcceptsCIDR(t *testing.T) {
db := setupIPLookupTestDB(t)
db.Create(&model.IPBlockList{BaseOrm: newBase("b1"), HostCode: "", Ip: "1.2.3.0/24"})
resp, err := WafIPLookupServiceApp.Lookup("1.2.3.0/24", nil)
if err != nil {
t.Fatalf("网段输入不该报错: %v", err)
}
if resp.IP != "1.2.3.0" {
t.Fatalf("应归一到网络地址,实际 %q", resp.IP)
}
if resp.QueryNote == "" {
t.Fatal("归一化了就必须告诉用户查的是哪个IP")
}
if len(resp.Hits) != 1 {
t.Fatalf("应命中该网段本身,实际 %+v", resp.Hits)
}
// 单个IP不该产生说明文案,否则每次查询都挂一条噪音
if r2, _ := WafIPLookupServiceApp.Lookup("1.2.3.9", nil); r2.QueryNote != "" {
t.Fatalf("单IP不该有归一说明,实际 %q", r2.QueryNote)
}
// 区间取起始
if r3, _ := WafIPLookupServiceApp.Lookup("1.2.3.5-1.2.3.9", nil); r3.IP != "1.2.3.5" {
t.Fatalf("区间应取起始,实际 %q", r3.IP)
}
}
+1
View File
@@ -169,6 +169,7 @@ func (web *WafWebManager) initRouter(r *gin.Engine) {
router.ApiGroupApp.InitHostConnRouter(RouterGroup)
router.ApiGroupApp.InitThreatIPRouter(RouterGroup)
router.ApiGroupApp.InitCDNIPRouter(RouterGroup)
router.ApiGroupApp.InitIPLookupRouter(RouterGroup)
router.ApiGroupApp.InitLogFileWriteRouter(RouterGroup)
router.ApiGroupApp.InitIPLocationRouter(RouterGroup)
router.ApiGroupApp.InitWafDataRetentionRouter(RouterGroup)