From bf2fea24c98a9ff0c96c345907a88efc21b1068e Mon Sep 17 00:00:00 2001 From: chengqian Date: Thu, 24 Sep 2026 14:58:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=B7=BB=E5=8A=A0IP=E9=BB=91=E5=90=8D?= =?UTF-8?q?=E5=8D=95=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/admin/v1/dashboard.go | 25 +++- internal/cmd/cmd.go | 4 + internal/logic/admin/dashboard.go | 31 ++++ internal/logic/admin/settings.go | 9 +- internal/logic/common/ip_blacklist.go | 135 ++++++++++++++++++ internal/logic/common/ip_blacklist_test.go | 115 +++++++++++++++ internal/logic/common/settings_registry.go | 4 + internal/middleware/ip_blacklist.go | 113 +++++++++++++++ web/admin/src/views/DashboardPage.vue | 39 +++++ .../src/views/system/settings/SecurityTab.vue | 47 ++++++ 10 files changed, 515 insertions(+), 7 deletions(-) create mode 100644 internal/logic/common/ip_blacklist.go create mode 100644 internal/logic/common/ip_blacklist_test.go create mode 100644 internal/middleware/ip_blacklist.go diff --git a/api/admin/v1/dashboard.go b/api/admin/v1/dashboard.go index ca17aed..fd13e38 100644 --- a/api/admin/v1/dashboard.go +++ b/api/admin/v1/dashboard.go @@ -14,12 +14,25 @@ type AdminDashboardReq struct { } type AdminDashboardRes struct { - Tenants int `json:"tenants"` - Members int `json:"members"` - ActiveChannels int `json:"active_channels"` - Today *DayStats `json:"today"` - Yesterday *DayStats `json:"yesterday"` - Month *MonthStats `json:"month"` + Tenants int `json:"tenants"` + Members int `json:"members"` + ActiveChannels int `json:"active_channels"` + Today *DayStats `json:"today"` + Yesterday *DayStats `json:"yesterday"` + Month *MonthStats `json:"month"` + IpBlacklist *IpBlacklistStats `json:"ip_blacklist"` +} + +// IpBlacklistStats IP 黑名单拦截统计(数据存 Redis 缓存,不落库;读取失败时仅回 enabled) +type IpBlacklistStats struct { + Enabled bool `json:"enabled"` // 黑名单当前是否启用 + Total int64 `json:"total"` // 累计拦截总次数 + List []IpBlacklistBlockedItem `json:"list"` // 按拦截次数倒序,最多 TOP 20 +} + +type IpBlacklistBlockedItem struct { + Ip string `json:"ip"` + Count int64 `json:"count"` } type DayStats struct { diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index 8be7637..a3da38e 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -184,6 +184,10 @@ var ( s.Use(middleware.RequestId) s.Use(middleware.ServiceName) + // IP 黑名单:业务链路第一道闸门(Recovery/RequestId/ServiceName 就绪之后、 + // setup 守卫与所有路由组之前),命中黑名单的请求在任何路由之前直接 403 + s.Use(middleware.IpBlacklist) + // Setup mode guard: block all requests until initialization is complete s.Use(func(r *ghttp.Request) { if !setupHandler.IsSetupMode() { diff --git a/internal/logic/admin/dashboard.go b/internal/logic/admin/dashboard.go index a781032..6450569 100644 --- a/internal/logic/admin/dashboard.go +++ b/internal/logic/admin/dashboard.go @@ -3,6 +3,7 @@ package admin import ( "context" "fmt" + "sort" "strings" "time" @@ -144,9 +145,39 @@ func (s *sAdmin) GetDashboardStats(ctx context.Context, req *v1.AdminDashboardRe TotalCost: monthRow.TotalCost, Revenue: revenue.Total, }, + IpBlacklist: buildIpBlacklistStats(ctx), }, nil } +// buildIpBlacklistStats 汇总 IP 黑名单拦截统计(缓存数据): +// Redis 读取失败时降级为仅返回启用状态,不影响仪表盘其余统计的正常返回。 +func buildIpBlacklistStats(ctx context.Context) *v1.IpBlacklistStats { + stats := &v1.IpBlacklistStats{ + Enabled: common.Config().GetBool(ctx, common.OptionKeyIpBlacklistEnabled), + List: []v1.IpBlacklistBlockedItem{}, + } + + counts, err := common.GetIpBlacklistBlockStats(ctx) + if err != nil { + g.Log().Warningf(ctx, "buildIpBlacklistStats: read blocked stats failed: %v", err) + return stats + } + + items := make([]v1.IpBlacklistBlockedItem, 0, len(counts)) + for ip, count := range counts { + stats.Total += count + items = append(items, v1.IpBlacklistBlockedItem{Ip: ip, Count: count}) + } + sort.Slice(items, func(i, j int) bool { return items[i].Count > items[j].Count }) + // 看板只展示 TOP 20,长尾 IP 的拦截量对运营无观测价值,避免极端脏数据撑爆响应 + const topN = 20 + if len(items) > topN { + items = items[:topN] + } + stats.List = items + return stats +} + // GetDashboardTrends returns daily revenue and request trends for the past N days. func (s *sAdmin) GetDashboardTrends(ctx context.Context, req *v1.AdminDashboardTrendsReq) (*v1.AdminDashboardTrendsRes, error) { days := req.Days diff --git a/internal/logic/admin/settings.go b/internal/logic/admin/settings.go index e1be102..3286c28 100644 --- a/internal/logic/admin/settings.go +++ b/internal/logic/admin/settings.go @@ -111,8 +111,15 @@ func (s *sAdmin) validateGeneralSettings(_ context.Context, values map[string]st return nil } -// validateSecuritySettings 校验安全配置项之间的依赖:启用 Turnstile 前必须已配置密钥。 +// validateSecuritySettings 校验安全配置项之间的依赖:启用 Turnstile 前必须已配置密钥; +// IP 黑名单每个条目必须是合法 IP / CIDR(坏条目入库后中间件会静默跳过,必须保存时拦下)。 func (s *sAdmin) validateSecuritySettings(ctx context.Context, values map[string]string) error { + if raw, ok := values["ip_blacklist_list"]; ok && strings.TrimSpace(raw) != "" { + if err := common.ValidateIpBlacklistRaw(raw); err != nil { + return common.NewBadRequestError(err.Error()) + } + } + enabled, ok := values["turnstile_enabled"] if !ok || (enabled != "true" && enabled != "1") { return nil diff --git a/internal/logic/common/ip_blacklist.go b/internal/logic/common/ip_blacklist.go new file mode 100644 index 0000000..7cc2366 --- /dev/null +++ b/internal/logic/common/ip_blacklist.go @@ -0,0 +1,135 @@ +package common + +import ( + "context" + "encoding/json" + "net" + "strings" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" +) + +// IP 黑名单:配置存 sys_options(安全分类,复用通用设置读写链路,无独立表), +// 拦截计数存 Redis hash(field=IP,value=累计拦截次数)。计数仅作运营观测, +// 不落库,随 Redis 数据清空而丢失——这是有意为之的简化(需求明确缓存即可)。 +const ( + OptionKeyIpBlacklistEnabled = "ip_blacklist_enabled" + OptionKeyIpBlacklistList = "ip_blacklist_list" + + ipBlacklistBlockedRedisKey = "ip_blacklist:blocked" +) + +// IpBlacklistEntry 单条黑名单条目:精确 IP 或 CIDR 网段。 +type IpBlacklistEntry struct { + Raw string + ip net.IP // 精确 IP(CIDR 条目为 nil) + cidr *net.IPNet // CIDR 网段(精确 IP 条目为 nil) +} + +// Contains 判断客户端 IP 是否命中该条目。 +func (e *IpBlacklistEntry) Contains(ip net.IP) bool { + if ip == nil { + return false + } + if e.ip != nil { + return e.ip.Equal(ip) + } + if e.cidr != nil { + return e.cidr.Contains(ip) + } + return false +} + +// splitIpBlacklistRaw 拆分黑名单配置值为候选条目:主体格式为 JSON 字符串数组 +// (设置页保存),容错支持逗号分隔的裸文本(如手工改库后的脏格式)。 +func splitIpBlacklistRaw(raw string) ([]string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + if strings.HasPrefix(raw, "[") { + var arr []string + if err := json.Unmarshal([]byte(raw), &arr); err != nil { + return nil, gerror.New("IP 黑名单必须是 JSON 字符串数组") + } + return arr, nil + } + return strings.Split(raw, ","), nil +} + +// ParseIpBlacklistEntries 解析黑名单配置值为条目列表(去重、去空、跳过非法条目)。 +// 非法条目静默跳过:保存入口已做格式校验,此处兜底脏数据,保证中间件永不 panic。 +func ParseIpBlacklistEntries(raw string) []IpBlacklistEntry { + parts, err := splitIpBlacklistRaw(raw) + if err != nil { + return nil + } + + entries := make([]IpBlacklistEntry, 0, len(parts)) + seen := make(map[string]struct{}, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if _, dup := seen[p]; dup { + continue + } + if ip := net.ParseIP(p); ip != nil { + seen[p] = struct{}{} + entries = append(entries, IpBlacklistEntry{Raw: p, ip: ip}) + continue + } + if _, cidr, cidrErr := net.ParseCIDR(p); cidrErr == nil { + seen[p] = struct{}{} + entries = append(entries, IpBlacklistEntry{Raw: p, cidr: cidr}) + } + } + return entries +} + +// ValidateIpBlacklistRaw 校验黑名单配置值:所有条目必须是合法 IP 或 CIDR。 +// 坏条目入库后中间件会静默跳过,运营侧无从感知,必须在保存入口拦下。 +func ValidateIpBlacklistRaw(raw string) error { + parts, err := splitIpBlacklistRaw(raw) + if err != nil { + return err + } + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if net.ParseIP(p) == nil { + if _, _, cidrErr := net.ParseCIDR(p); cidrErr != nil { + return gerror.Newf("非法的 IP 或网段:%s", p) + } + } + } + return nil +} + +// IncrIpBlacklistBlocked 黑名单 IP 被拦截时累加计数(best-effort): +// 拦截决策已定,统计失败静默丢弃——不重试、不刷告警日志,避免 Redis 抖动放大成日志风暴。 +func IncrIpBlacklistBlocked(ctx context.Context, ip string) { + _, _ = g.Redis().Do(ctx, "HINCRBY", ipBlacklistBlockedRedisKey, ip, 1) +} + +// GetIpBlacklistBlockStats 读取拦截统计:返回 {ip: 累计拦截次数}。 +// Redis 异常时返回错误,由调用方降级(看板仍展示其余统计数据)。 +func GetIpBlacklistBlockStats(ctx context.Context) (map[string]int64, error) { + result, err := g.Redis().Do(ctx, "HGETALL", ipBlacklistBlockedRedisKey) + if err != nil { + return nil, err + } + if result.IsNil() || result.IsEmpty() { + return map[string]int64{}, nil + } + + counts := make(map[string]int64) + for ip, v := range result.MapStrVar() { + counts[ip] = v.Int64() + } + return counts, nil +} diff --git a/internal/logic/common/ip_blacklist_test.go b/internal/logic/common/ip_blacklist_test.go new file mode 100644 index 0000000..caffe97 --- /dev/null +++ b/internal/logic/common/ip_blacklist_test.go @@ -0,0 +1,115 @@ +package common + +import ( + "net" + "testing" +) + +func mustParseIP(t *testing.T, s string) net.IP { + t.Helper() + ip := net.ParseIP(s) + if ip == nil { + t.Fatalf("测试用例的 IP 非法: %s", s) + } + return ip +} + +func TestParseIpBlacklistEntries(t *testing.T) { + tests := []struct { + name string + raw string + wantRaw []string // 期望解析出的条目顺序 + }{ + {"空串", "", nil}, + {"JSON 空数组", "[]", nil}, + {"JSON 数组含精确 IP 与 CIDR", `["1.2.3.4", "10.0.0.0/8", "2001:db8::/32"]`, + []string{"1.2.3.4", "10.0.0.0/8", "2001:db8::/32"}}, + {"JSON 数组去重去空", `["1.2.3.4", "1.2.3.4", "", " 5.6.7.8 "]`, []string{"1.2.3.4", "5.6.7.8"}}, + {"裸文本逗号分隔(手工改库容错)", "1.2.3.4,10.0.0.0/8", []string{"1.2.3.4", "10.0.0.0/8"}}, + {"非法条目静默跳过", `["1.2.3.4", "not-an-ip", "300.1.2.3"]`, []string{"1.2.3.4"}}, + {"JSON 语法错误返回空", `["1.2.3.4"`, nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + entries := ParseIpBlacklistEntries(tt.raw) + if len(entries) != len(tt.wantRaw) { + t.Fatalf("条目数不符: got %d (%v), want %d", len(entries), entries, len(tt.wantRaw)) + } + for i, e := range entries { + if e.Raw != tt.wantRaw[i] { + t.Errorf("条目[%d] = %q, want %q", i, e.Raw, tt.wantRaw[i]) + } + } + }) + } +} + +func TestIpBlacklistEntryContains(t *testing.T) { + entries := ParseIpBlacklistEntries(`["1.2.3.4", "10.0.0.0/8", "2001:db8::/32"]`) + if len(entries) != 3 { + t.Fatalf("前置解析失败: %v", entries) + } + + tests := []struct { + name string + ip string + want bool + }{ + {"精确 IPv4 命中", "1.2.3.4", true}, + {"精确 IPv4 相邻不命中", "1.2.3.5", false}, + {"CIDR 内命中", "10.1.2.3", true}, + {"CIDR 外不命中", "11.0.0.1", false}, + {"IPv6 CIDR 命中", "2001:db8::1234", true}, + {"IPv6 CIDR 外不命中", "2001:db9::1", false}, + {"IPv4-mapped IPv6 命中 v4 CIDR", "::ffff:10.2.3.4", true}, + {"空 IP 不命中", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // 空串对应 net.ParseIP 返回 nil 的场景(客户端 IP 解析失败),验证 Contains(nil) 安全返回 false + var ip net.IP + if tt.ip != "" { + ip = mustParseIP(t, tt.ip) + } + got := false + for i := range entries { + if entries[i].Contains(ip) { + got = true + break + } + } + if got != tt.want { + t.Errorf("Contains(%s) = %v, want %v", tt.ip, got, tt.want) + } + }) + } +} + +func TestValidateIpBlacklistRaw(t *testing.T) { + valid := []string{ + "", + "[]", + `["1.2.3.4"]`, + `["1.2.3.4", "10.0.0.0/8"]`, + `["2001:db8::/32", "::1"]`, + } + for _, raw := range valid { + if err := ValidateIpBlacklistRaw(raw); err != nil { + t.Errorf("ValidateIpBlacklistRaw(%q) = %v, want nil", raw, err) + } + } + + invalid := []string{ + `["1.2.3.4", "not-an-ip"]`, + `["300.1.2.3"]`, + `["10.0.0.0/33"]`, + `["1.2.3.4"`, + } + for _, raw := range invalid { + if err := ValidateIpBlacklistRaw(raw); err == nil { + t.Errorf("ValidateIpBlacklistRaw(%q) = nil, want error", raw) + } + } +} diff --git a/internal/logic/common/settings_registry.go b/internal/logic/common/settings_registry.go index 46e18e4..68f8c18 100644 --- a/internal/logic/common/settings_registry.go +++ b/internal/logic/common/settings_registry.go @@ -130,6 +130,10 @@ var Registry = []SettingDef{ Label: "验证码有效期(秒)", Validation: "min:60,max:600"}, {Key: "register_forbidden_words", Type: SettingTypeString, Default: "admin,system,root,api,test,administrator,管理员,系统", Category: "security", Label: "注册禁用词", Description: "组织名称、组织代码、用户名中包含这些词时禁止注册(不区分大小写),多个禁用词用英文逗号分隔"}, + {Key: "ip_blacklist_enabled", Type: SettingTypeBool, Default: "false", Category: "security", + Label: "启用 IP 黑名单", Description: "开启后,命中黑名单的 IP 访问任何端点(控制台 / AI 代理)都会被直接拒绝,保存后即时生效"}, + {Key: "ip_blacklist_list", Type: SettingTypeJSON, Default: "[]", Category: "security", + Label: "IP 黑名单列表", Description: "JSON 字符串数组,支持精确 IP 与 CIDR 网段,如 [\"1.2.3.4\", \"10.0.0.0/8\"];拦截次数统计在仪表盘展示(存于缓存,不落库)"}, // ── Audit ── {Key: "audit_level", Type: SettingTypeString, Default: "full", Category: "audit", diff --git a/internal/middleware/ip_blacklist.go b/internal/middleware/ip_blacklist.go new file mode 100644 index 0000000..d3a4a53 --- /dev/null +++ b/internal/middleware/ip_blacklist.go @@ -0,0 +1,113 @@ +package middleware + +import ( + "encoding/json" + "net" + "net/http" + "strings" + "sync/atomic" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + + "github.com/qianfree/team-api/internal/consts" + "github.com/qianfree/team-api/internal/logic/common" +) + +// ipBlacklistMatcher 按配置原始串编译好的黑名单条目快照。 +// 配置经 ConfigService 的 L1 内存缓存读取本身够快,但 JSON 反解析不值得每请求重复, +// 以原始串为版本号做原子快照:配置变更(Pub/Sub 失效缓存)后的首个请求重新编译。 +type ipBlacklistMatcher struct { + raw string + entries []common.IpBlacklistEntry +} + +var ipBlacklistMatcherSnapshot atomic.Value + +// loadIpBlacklistMatcher 取黑名单快照,配置串未变化时零开销复用。 +// 并发下可能重复编译一次,原子覆盖无锁竞争,结果一致,无需消重。 +func loadIpBlacklistMatcher(raw string) *ipBlacklistMatcher { + if m, ok := ipBlacklistMatcherSnapshot.Load().(*ipBlacklistMatcher); ok && m.raw == raw { + return m + } + m := &ipBlacklistMatcher{raw: raw, entries: common.ParseIpBlacklistEntries(raw)} + ipBlacklistMatcherSnapshot.Store(m) + return m +} + +func (m *ipBlacklistMatcher) match(ip net.IP) bool { + if ip == nil || len(m.entries) == 0 { + return false + } + for i := range m.entries { + if m.entries[i].Contains(ip) { + return true + } + } + return false +} + +// IpBlacklist 全局中间件:命中黑名单 IP 的请求在任何业务路由之前直接拒绝(403)。 +// 挂在 Recovery/RequestId/ServiceName 之后(保证 panic 兜底与 request_id 就绪)、 +// setup 守卫与所有路由组之前,是请求进入业务链路的第一道闸门。 +// /api/health 豁免:存活探针来自 LB/编排器,探针 IP 一旦被误拉黑会引发实例被 +// 连锁摘流的级联故障,而健康端点本身无业务价值,不值得拦。 +func IpBlacklist(r *ghttp.Request) { + if r.URL.Path == "/api/health" { + r.Middleware.Next() + return + } + + ctx := r.Context() + if !common.Config().GetBool(ctx, common.OptionKeyIpBlacklistEnabled) { + r.Middleware.Next() + return + } + + m := loadIpBlacklistMatcher(common.Config().GetString(ctx, common.OptionKeyIpBlacklistList)) + // GetClientIp 与全站限流/审计同一信任模型(取 X-Forwarded-For 等代理头), + // 部署必须保证代理层覆写这些头,否则客户端可伪造绕过 + ipText := r.GetClientIp() + if !m.match(net.ParseIP(ipText)) { + r.Middleware.Next() + return + } + + // 拦截计数 best-effort:决策已定,统计失败不影响响应 + common.IncrIpBlacklistBlocked(ctx, ipText) + writeIpBlacklistedResponse(r) +} + +// writeIpBlacklistedResponse 按端点协议返回拒绝响应: +// AI 代理端点(/v1 /v1beta /v2 /suno)保持供应商原生错误格式(/v1/messages 用 Claude 格式, +// 其余用 OpenAI 格式),管理类端点走平台统一 JSON 格式——与 ApiMaintenance 的分流方式一致。 +func writeIpBlacklistedResponse(r *ghttp.Request) { + const message = "IP 已被列入黑名单,禁止访问" + path := r.URL.Path + + r.Response.Header().Set("Content-Type", "application/json") + r.Response.WriteHeader(http.StatusForbidden) + + switch { + case strings.HasPrefix(path, "/v1/messages"): + body, _ := json.Marshal(map[string]any{ + "type": "error", + "error": map[string]string{"type": "forbidden", "message": message}, + }) + r.Response.Write(body) + case strings.HasPrefix(path, "/v1/") || strings.HasPrefix(path, "/v1beta/") || + strings.HasPrefix(path, "/v2/") || strings.HasPrefix(path, "/suno/"): + body, _ := json.Marshal(map[string]any{ + "error": map[string]string{"type": "forbidden", "message": message}, + }) + r.Response.Write(body) + default: + r.Response.WriteJson(g.Map{ + "code": consts.CodeForbidden, + "message": message, + "data": nil, + "request_id": r.GetCtxVar("RequestId"), + }) + } + r.Exit() +} diff --git a/web/admin/src/views/DashboardPage.vue b/web/admin/src/views/DashboardPage.vue index 15e10aa..b6f31a9 100644 --- a/web/admin/src/views/DashboardPage.vue +++ b/web/admin/src/views/DashboardPage.vue @@ -29,6 +29,7 @@ const modelDist = ref([]) const channelHealth = ref([]) const recentAlerts = ref([]) const modelHourly = ref(null) +const ipBlacklist = computed(() => dashboard.value?.ip_blacklist) // === Charts === let trendChart: echarts.ECharts | null = null @@ -560,6 +561,26 @@ onBeforeUnmount(() => { + + + + +
+
+ 累计拦截 {{ formatNumber(ipBlacklist.total) }} 次 +
+
+
+ {{ item.ip }} + {{ formatNumber(item.count) }} 次 +
+
+
+ +
@@ -669,6 +690,24 @@ onBeforeUnmount(() => { white-space: nowrap; } +.ipbl-total { + font-size: 13px; + color: var(--color-text-2); + margin-bottom: 14px; +} + +.ipbl-total strong { + font-size: 18px; + color: var(--color-text-1); + margin: 0 2px; +} + +.ipbl-ip { + font-family: monospace; + font-size: 12px; + color: var(--color-text-1); +} + @keyframes cardAppear { from { opacity: 0; diff --git a/web/admin/src/views/system/settings/SecurityTab.vue b/web/admin/src/views/system/settings/SecurityTab.vue index 4ff78ec..db7fe1a 100644 --- a/web/admin/src/views/system/settings/SecurityTab.vue +++ b/web/admin/src/views/system/settings/SecurityTab.vue @@ -1,6 +1,27 @@