feat:添加IP黑名单功能
This commit is contained in:
1 parent
4ce2fdf55e
commit
bf2fea24c9
10 files changed
+515
-7
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user