feat:添加IP黑名单功能

This commit is contained in:
chengqian committed 2026-09-24 14:58:37 +08:00
1 parent 4ce2fdf55e
commit bf2fea24c9
10 files changed
+515 -7

No files matched your search

+19 -6
View File
@@ -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 {
+4
View File
@@ -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() {
+31
View File
@@ -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
+8 -1
View File
@@ -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
+135
View File
@@ -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
}
+115
View File
@@ -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)
}
}
}
@@ -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",
+113
View File
@@ -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()
}
+39
View File
@@ -29,6 +29,7 @@ const modelDist = ref<any[]>([])
const channelHealth = ref<any[]>([])
const recentAlerts = ref<any[]>([])
const modelHourly = ref<any>(null)
const ipBlacklist = computed(() => dashboard.value?.ip_blacklist)
// === Charts ===
let trendChart: echarts.ECharts | null = null
@@ -560,6 +561,26 @@ onBeforeUnmount(() => {
<AEmpty v-else description="暂无告警" />
</ACard>
</div>
<!-- Row 5: IP 黑名单拦截统计(数据存 Redis 缓存) -->
<ACard :bordered="false" class="mb-6" title="IP 黑名单拦截">
<template #extra>
<ATag v-if="ipBlacklist?.enabled" color="red" size="small">黑名单已启用</ATag>
<ATag v-else color="gray" size="small">黑名单未启用</ATag>
</template>
<div v-if="ipBlacklist && (ipBlacklist.total > 0 || (ipBlacklist.list && ipBlacklist.list.length > 0))">
<div class="ipbl-total">
累计拦截 <strong>{{ formatNumber(ipBlacklist.total) }}</strong> 次
</div>
<div class="info-list">
<div v-for="item in ipBlacklist.list" :key="item.ip" class="info-item">
<span class="ipbl-ip">{{ item.ip }}</span>
<ATag color="red" size="small">{{ formatNumber(item.count) }} 次</ATag>
</div>
</div>
</div>
<AEmpty v-else description="暂无拦截记录" />
</ACard>
</div>
</ASpin>
</template>
@@ -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;
@@ -1,6 +1,27 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useFormValues } from './useSettings'
const values = useFormValues()
// 黑名单列表在表单里以「每行一个 IP/网段」编辑,存库为 JSON 字符串数组
const ipBlacklistText = computed<string>({
get: () => {
const raw = (values['ip_blacklist_list'] as string) || '[]'
try {
const arr = JSON.parse(raw)
return Array.isArray(arr) ? arr.join('\n') : String(raw)
} catch {
return String(raw)
}
},
set: (v: string) => {
const arr = v
.split(/\r?\n|,/)
.map(s => s.trim())
.filter(Boolean)
values['ip_blacklist_list'] = JSON.stringify(arr)
},
})
</script>
<template>
@@ -85,6 +106,32 @@ const values = useFormValues()
</AFormItem>
</div>
</div>
<!-- IP 黑名单 -->
<div class="section">
<div class="section-title">IP 黑名单</div>
<div class="section-desc">
开启后,命中黑名单的 IP 访问任何端点(管理后台 / 租户控制台 / AI 代理)都会被直接拒绝,保存后即时生效。
支持精确 IP 与 CIDR 网段;拦截次数统计在仪表盘展示(存于缓存,不落库)
</div>
<div class="section-grid">
<AFormItem label="启用 IP 黑名单">
<ASwitch
:model-value="!!values['ip_blacklist_enabled']"
@change="(v: string | number | boolean) => values['ip_blacklist_enabled'] = v"
/>
</AFormItem>
</div>
<div class="section-grid" style="grid-template-columns: 1fr">
<AFormItem label="黑名单列表">
<ATextarea
v-model="ipBlacklistText"
:placeholder="'1.2.3.4\n10.0.0.0/8\n2001:db8::/32'"
:auto-size="{ minRows: 4, maxRows: 12 }"
/>
</AFormItem>
</div>
</div>
</div>
</template>