diff --git a/data/config.example.json b/data/config.example.json index e780c8e..86763e0 100644 --- a/data/config.example.json +++ b/data/config.example.json @@ -57,5 +57,14 @@ "Enabled": false, "Level": 4 } + }, + "Security": { + "IPBan": { + "Enabled": true, + "ErrorThreshold": 10, + "WindowMinutes": 5, + "BanDurationMinutes": 5, + "CleanupIntervalMinutes": 1 + } } } \ No newline at end of file diff --git a/internal/config/types.go b/internal/config/types.go index 59df448..56a5c88 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -7,6 +7,7 @@ import ( type Config struct { MAP map[string]PathConfig `json:"MAP"` // 路径映射配置 Compression CompressionConfig `json:"Compression"` + Security SecurityConfig `json:"Security"` // 安全配置 } type PathConfig struct { @@ -36,6 +37,18 @@ type CompressorConfig struct { Level int `json:"Level"` } +type SecurityConfig struct { + IPBan IPBanConfig `json:"IPBan"` // IP封禁配置 +} + +type IPBanConfig struct { + Enabled bool `json:"Enabled"` // 是否启用IP封禁 + ErrorThreshold int `json:"ErrorThreshold"` // 404错误阈值 + WindowMinutes int `json:"WindowMinutes"` // 统计窗口时间(分钟) + BanDurationMinutes int `json:"BanDurationMinutes"` // 封禁时长(分钟) + CleanupIntervalMinutes int `json:"CleanupIntervalMinutes"` // 清理间隔(分钟) +} + // 扩展名映射配置结构 type ExtRuleConfig struct { Extensions string `json:"Extensions"` // 逗号分隔的扩展名 diff --git a/internal/handler/security.go b/internal/handler/security.go new file mode 100644 index 0000000..2e729d7 --- /dev/null +++ b/internal/handler/security.go @@ -0,0 +1,129 @@ +package handler + +import ( + "encoding/json" + "net/http" + "proxy-go/internal/security" + "proxy-go/internal/utils" + "time" +) + +// SecurityHandler 安全管理处理器 +type SecurityHandler struct { + banManager *security.IPBanManager +} + +// NewSecurityHandler 创建安全管理处理器 +func NewSecurityHandler(banManager *security.IPBanManager) *SecurityHandler { + return &SecurityHandler{ + banManager: banManager, + } +} + +// GetBannedIPs 获取被封禁的IP列表 +func (sh *SecurityHandler) GetBannedIPs(w http.ResponseWriter, r *http.Request) { + if sh.banManager == nil { + http.Error(w, "Security manager not enabled", http.StatusServiceUnavailable) + return + } + + bannedIPs := sh.banManager.GetBannedIPs() + + // 转换为前端友好的格式 + result := make([]map[string]interface{}, 0, len(bannedIPs)) + for ip, banEndTime := range bannedIPs { + result = append(result, map[string]interface{}{ + "ip": ip, + "ban_end_time": banEndTime.Format("2006-01-02 15:04:05"), + "remaining_seconds": int64(time.Until(banEndTime).Seconds()), + }) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "banned_ips": result, + "count": len(result), + }) +} + +// UnbanIP 手动解封IP +func (sh *SecurityHandler) UnbanIP(w http.ResponseWriter, r *http.Request) { + if sh.banManager == nil { + http.Error(w, "Security manager not enabled", http.StatusServiceUnavailable) + return + } + + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + IP string `json:"ip"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.IP == "" { + http.Error(w, "IP address is required", http.StatusBadRequest) + return + } + + success := sh.banManager.UnbanIP(req.IP) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": success, + "message": func() string { + if success { + return "IP解封成功" + } + return "IP未在封禁列表中" + }(), + }) +} + +// GetSecurityStats 获取安全统计信息 +func (sh *SecurityHandler) GetSecurityStats(w http.ResponseWriter, r *http.Request) { + if sh.banManager == nil { + http.Error(w, "Security manager not enabled", http.StatusServiceUnavailable) + return + } + + stats := sh.banManager.GetStats() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(stats) +} + +// CheckIPStatus 检查IP状态 +func (sh *SecurityHandler) CheckIPStatus(w http.ResponseWriter, r *http.Request) { + if sh.banManager == nil { + http.Error(w, "Security manager not enabled", http.StatusServiceUnavailable) + return + } + + ip := r.URL.Query().Get("ip") + if ip == "" { + // 如果没有指定IP,使用请求的IP + ip = utils.GetClientIP(r) + } + + banned, banEndTime := sh.banManager.GetBanInfo(ip) + + result := map[string]interface{}{ + "ip": ip, + "banned": banned, + } + + if banned { + result["ban_end_time"] = banEndTime.Format("2006-01-02 15:04:05") + result["remaining_seconds"] = int64(time.Until(banEndTime).Seconds()) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) +} diff --git a/internal/middleware/security.go b/internal/middleware/security.go new file mode 100644 index 0000000..3666175 --- /dev/null +++ b/internal/middleware/security.go @@ -0,0 +1,85 @@ +package middleware + +import ( + "fmt" + "net/http" + "proxy-go/internal/security" + "proxy-go/internal/utils" + "time" +) + +// SecurityMiddleware 安全中间件 +type SecurityMiddleware struct { + banManager *security.IPBanManager +} + +// NewSecurityMiddleware 创建安全中间件 +func NewSecurityMiddleware(banManager *security.IPBanManager) *SecurityMiddleware { + return &SecurityMiddleware{ + banManager: banManager, + } +} + +// IPBanMiddleware IP封禁中间件 +func (sm *SecurityMiddleware) IPBanMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + clientIP := utils.GetClientIP(r) + + // 检查IP是否被封禁 + if sm.banManager.IsIPBanned(clientIP) { + banned, banEndTime := sm.banManager.GetBanInfo(clientIP) + if banned { + // 返回429状态码和封禁信息 + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Retry-After", fmt.Sprintf("%.0f", time.Until(banEndTime).Seconds())) + w.WriteHeader(http.StatusTooManyRequests) + + remainingTime := time.Until(banEndTime) + response := fmt.Sprintf(`{ + "error": "IP temporarily banned due to excessive 404 errors", + "message": "您的IP因频繁访问不存在的资源而被暂时封禁", + "ban_end_time": "%s", + "remaining_seconds": %.0f + }`, banEndTime.Format("2006-01-02 15:04:05"), remainingTime.Seconds()) + + w.Write([]byte(response)) + return + } + } + + // 创建响应写入器包装器来捕获状态码 + wrapper := &responseWrapper{ + ResponseWriter: w, + statusCode: http.StatusOK, + } + + // 继续处理请求 + next.ServeHTTP(wrapper, r) + + // 如果响应是404,记录错误 + if wrapper.statusCode == http.StatusNotFound { + sm.banManager.RecordError(clientIP) + } + }) +} + +// responseWrapper 响应包装器,用于捕获状态码 +type responseWrapper struct { + http.ResponseWriter + statusCode int +} + +// WriteHeader 重写WriteHeader方法来捕获状态码 +func (rw *responseWrapper) WriteHeader(code int) { + rw.statusCode = code + rw.ResponseWriter.WriteHeader(code) +} + +// Write 重写Write方法,确保状态码被正确设置 +func (rw *responseWrapper) Write(b []byte) (int, error) { + // 如果还没有设置状态码,默认为200 + if rw.statusCode == 0 { + rw.statusCode = http.StatusOK + } + return rw.ResponseWriter.Write(b) +} diff --git a/internal/security/rate_limiter.go b/internal/security/rate_limiter.go new file mode 100644 index 0000000..1c19901 --- /dev/null +++ b/internal/security/rate_limiter.go @@ -0,0 +1,278 @@ +package security + +import ( + "log" + "sync" + "time" +) + +// IPBanManager IP封禁管理器 +type IPBanManager struct { + // 404错误计数器 map[ip]count + errorCounts sync.Map + // IP封禁列表 map[ip]banEndTime + bannedIPs sync.Map + // 配置参数 + config *IPBanConfig + // 清理任务停止信号 + stopCleanup chan struct{} + // 清理任务等待组 + cleanupWG sync.WaitGroup +} + +// IPBanConfig IP封禁配置 +type IPBanConfig struct { + // 404错误阈值,超过此数量将被封禁 + ErrorThreshold int `json:"error_threshold"` + // 统计窗口时间(分钟) + WindowMinutes int `json:"window_minutes"` + // 封禁时长(分钟) + BanDurationMinutes int `json:"ban_duration_minutes"` + // 清理间隔(分钟) + CleanupIntervalMinutes int `json:"cleanup_interval_minutes"` +} + +// errorRecord 错误记录 +type errorRecord struct { + count int + firstTime time.Time + lastTime time.Time +} + +// DefaultIPBanConfig 默认配置 +func DefaultIPBanConfig() *IPBanConfig { + return &IPBanConfig{ + ErrorThreshold: 10, // 10次404错误 + WindowMinutes: 5, // 5分钟内 + BanDurationMinutes: 5, // 封禁5分钟 + CleanupIntervalMinutes: 1, // 每分钟清理一次 + } +} + +// NewIPBanManager 创建IP封禁管理器 +func NewIPBanManager(config *IPBanConfig) *IPBanManager { + if config == nil { + config = DefaultIPBanConfig() + } + + manager := &IPBanManager{ + config: config, + stopCleanup: make(chan struct{}), + } + + // 启动清理任务 + manager.startCleanupTask() + + log.Printf("[Security] IP封禁管理器已启动 - 阈值: %d次/%.0f分钟, 封禁时长: %.0f分钟", + config.ErrorThreshold, + float64(config.WindowMinutes), + float64(config.BanDurationMinutes)) + + return manager +} + +// RecordError 记录404错误 +func (m *IPBanManager) RecordError(ip string) { + now := time.Now() + windowStart := now.Add(-time.Duration(m.config.WindowMinutes) * time.Minute) + + // 加载或创建错误记录 + value, _ := m.errorCounts.LoadOrStore(ip, &errorRecord{ + count: 0, + firstTime: now, + lastTime: now, + }) + record := value.(*errorRecord) + + // 如果第一次记录时间超出窗口,重置计数 + if record.firstTime.Before(windowStart) { + record.count = 1 + record.firstTime = now + record.lastTime = now + } else { + record.count++ + record.lastTime = now + } + + // 检查是否需要封禁 + if record.count >= m.config.ErrorThreshold { + m.banIP(ip, now) + // 重置计数器,避免重复封禁 + record.count = 0 + record.firstTime = now + } + + log.Printf("[Security] 记录404错误 IP: %s, 当前计数: %d/%d (窗口: %.0f分钟)", + ip, record.count, m.config.ErrorThreshold, float64(m.config.WindowMinutes)) +} + +// banIP 封禁IP +func (m *IPBanManager) banIP(ip string, banTime time.Time) { + banEndTime := banTime.Add(time.Duration(m.config.BanDurationMinutes) * time.Minute) + m.bannedIPs.Store(ip, banEndTime) + + log.Printf("[Security] IP已被封禁: %s, 封禁至: %s (%.0f分钟)", + ip, banEndTime.Format("15:04:05"), float64(m.config.BanDurationMinutes)) +} + +// IsIPBanned 检查IP是否被封禁 +func (m *IPBanManager) IsIPBanned(ip string) bool { + value, exists := m.bannedIPs.Load(ip) + if !exists { + return false + } + + banEndTime := value.(time.Time) + now := time.Now() + + // 检查封禁是否已过期 + if now.After(banEndTime) { + m.bannedIPs.Delete(ip) + log.Printf("[Security] IP封禁已过期,自动解封: %s", ip) + return false + } + + return true +} + +// GetBanInfo 获取IP封禁信息 +func (m *IPBanManager) GetBanInfo(ip string) (bool, time.Time) { + value, exists := m.bannedIPs.Load(ip) + if !exists { + return false, time.Time{} + } + + banEndTime := value.(time.Time) + now := time.Now() + + if now.After(banEndTime) { + m.bannedIPs.Delete(ip) + return false, time.Time{} + } + + return true, banEndTime +} + +// UnbanIP 手动解封IP +func (m *IPBanManager) UnbanIP(ip string) bool { + _, exists := m.bannedIPs.Load(ip) + if exists { + m.bannedIPs.Delete(ip) + log.Printf("[Security] 手动解封IP: %s", ip) + return true + } + return false +} + +// GetBannedIPs 获取所有被封禁的IP列表 +func (m *IPBanManager) GetBannedIPs() map[string]time.Time { + result := make(map[string]time.Time) + now := time.Now() + + m.bannedIPs.Range(func(key, value interface{}) bool { + ip := key.(string) + banEndTime := value.(time.Time) + + // 清理过期的封禁 + if now.After(banEndTime) { + m.bannedIPs.Delete(ip) + } else { + result[ip] = banEndTime + } + return true + }) + + return result +} + +// GetStats 获取统计信息 +func (m *IPBanManager) GetStats() map[string]interface{} { + bannedCount := 0 + errorRecordCount := 0 + + m.bannedIPs.Range(func(key, value interface{}) bool { + bannedCount++ + return true + }) + + m.errorCounts.Range(func(key, value interface{}) bool { + errorRecordCount++ + return true + }) + + return map[string]interface{}{ + "banned_ips_count": bannedCount, + "error_records_count": errorRecordCount, + "config": m.config, + } +} + +// startCleanupTask 启动清理任务 +func (m *IPBanManager) startCleanupTask() { + m.cleanupWG.Add(1) + go func() { + defer m.cleanupWG.Done() + ticker := time.NewTicker(time.Duration(m.config.CleanupIntervalMinutes) * time.Minute) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + m.cleanup() + case <-m.stopCleanup: + return + } + } + }() +} + +// cleanup 清理过期数据 +func (m *IPBanManager) cleanup() { + now := time.Now() + windowStart := now.Add(-time.Duration(m.config.WindowMinutes) * time.Minute) + + // 清理过期的错误记录 + var expiredIPs []string + m.errorCounts.Range(func(key, value interface{}) bool { + ip := key.(string) + record := value.(*errorRecord) + + // 如果最后一次错误时间超出窗口,删除记录 + if record.lastTime.Before(windowStart) { + expiredIPs = append(expiredIPs, ip) + } + return true + }) + + for _, ip := range expiredIPs { + m.errorCounts.Delete(ip) + } + + // 清理过期的封禁记录 + var expiredBans []string + m.bannedIPs.Range(func(key, value interface{}) bool { + ip := key.(string) + banEndTime := value.(time.Time) + + if now.After(banEndTime) { + expiredBans = append(expiredBans, ip) + } + return true + }) + + for _, ip := range expiredBans { + m.bannedIPs.Delete(ip) + } + + if len(expiredIPs) > 0 || len(expiredBans) > 0 { + log.Printf("[Security] 清理任务完成 - 清理错误记录: %d, 清理过期封禁: %d", + len(expiredIPs), len(expiredBans)) + } +} + +// Stop 停止IP封禁管理器 +func (m *IPBanManager) Stop() { + close(m.stopCleanup) + m.cleanupWG.Wait() + log.Printf("[Security] IP封禁管理器已停止") +} diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 4d7b193..2b7dd7c 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -94,12 +94,19 @@ func GenerateRequestID() string { } func GetClientIP(r *http.Request) string { + // 优先级1: Cloudflare 提供的原始客户端 IP(最准确) + if ip := r.Header.Get("CF-Connecting-IP"); ip != "" { + return ip + } + // 优先级2: 通用的真实 IP 头 if ip := r.Header.Get("X-Real-IP"); ip != "" { return ip } + // 优先级3: 标准的转发链头部(取第一个 IP,即原始客户端 IP) if ip := r.Header.Get("X-Forwarded-For"); ip != "" { return strings.Split(ip, ",")[0] } + // 优先级4: 直连 IP(兜底方案) if ip, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { return ip } diff --git a/main.go b/main.go index 4cae754..ab44ce9 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,7 @@ import ( "proxy-go/internal/initapp" "proxy-go/internal/metrics" "proxy-go/internal/middleware" + "proxy-go/internal/security" "strings" "sync/atomic" "syscall" @@ -55,6 +56,20 @@ func main() { }) compManagerAtomic.Store(compManager) + // 创建安全管理器 + var banManager *security.IPBanManager + var securityMiddleware *middleware.SecurityMiddleware + if cfg.Security.IPBan.Enabled { + banConfig := &security.IPBanConfig{ + ErrorThreshold: cfg.Security.IPBan.ErrorThreshold, + WindowMinutes: cfg.Security.IPBan.WindowMinutes, + BanDurationMinutes: cfg.Security.IPBan.BanDurationMinutes, + CleanupIntervalMinutes: cfg.Security.IPBan.CleanupIntervalMinutes, + } + banManager = security.NewIPBanManager(banConfig) + securityMiddleware = middleware.NewSecurityMiddleware(banManager) + } + // 创建代理处理器 mirrorHandler := handler.NewMirrorProxyHandler() proxyHandler := handler.NewProxyHandler(cfg) @@ -62,6 +77,12 @@ func main() { // 创建配置处理器 configHandler := handler.NewConfigHandler(configManager) + // 创建安全管理处理器 + var securityHandler *handler.SecurityHandler + if banManager != nil { + securityHandler = handler.NewSecurityHandler(banManager) + } + // 注册压缩配置更新回调 config.RegisterUpdateCallback(func(newCfg *config.Config) { // 更新压缩管理器 @@ -92,6 +113,17 @@ func main() { {http.MethodPost, "/admin/api/cache/config", handler.NewCacheAdminHandler(proxyHandler.Cache, mirrorHandler.Cache).UpdateCacheConfig, true}, } + // 添加安全API路由(如果启用了安全功能) + if securityHandler != nil { + securityRoutes := []Route{ + {http.MethodGet, "/admin/api/security/banned-ips", securityHandler.GetBannedIPs, true}, + {http.MethodPost, "/admin/api/security/unban", securityHandler.UnbanIP, true}, + {http.MethodGet, "/admin/api/security/stats", securityHandler.GetSecurityStats, true}, + {http.MethodGet, "/admin/api/security/check-ip", securityHandler.CheckIPStatus, true}, + } + apiRoutes = append(apiRoutes, securityRoutes...) + } + // 创建路由处理器 handlers := []struct { matcher func(*http.Request) bool @@ -165,13 +197,20 @@ func main() { http.NotFound(w, r) }) - // 添加压缩中间件(使用动态压缩管理器) + // 构建中间件链 var handler http.Handler = mainHandler + + // 添加安全中间件(最外层,优先级最高) + if securityMiddleware != nil { + handler = securityMiddleware.IPBanMiddleware(handler) + } + + // 添加压缩中间件 if cfg.Compression.Gzip.Enabled || cfg.Compression.Brotli.Enabled { // 创建动态压缩中间件包装器 handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { currentCompManager := compManagerAtomic.Load().(compression.Manager) - middleware.CompressionMiddleware(currentCompManager)(mainHandler).ServeHTTP(w, r) + middleware.CompressionMiddleware(currentCompManager)(handler).ServeHTTP(w, r) }) } @@ -188,6 +227,11 @@ func main() { <-sigChan log.Println("Shutting down server...") + // 停止安全管理器 + if banManager != nil { + banManager.Stop() + } + // 停止指标存储服务 metrics.StopMetricsStorage() diff --git a/web/app/dashboard/config/page.tsx b/web/app/dashboard/config/page.tsx index 6517aaa..327434b 100644 --- a/web/app/dashboard/config/page.tsx +++ b/web/app/dashboard/config/page.tsx @@ -17,7 +17,8 @@ import { } from "@/components/ui/dialog" import { Switch } from "@/components/ui/switch" import { Slider } from "@/components/ui/slider" -import { Plus, Trash2, Edit, Download, Upload } from "lucide-react" +import { Plus, Trash2, Edit, Download, Upload, Shield } from "lucide-react" +import Link from "next/link" import { AlertDialog, AlertDialogAction, @@ -51,12 +52,23 @@ interface CompressionConfig { Level: number } +interface SecurityConfig { + IPBan: { + Enabled: boolean + ErrorThreshold: number + WindowMinutes: number + BanDurationMinutes: number + CleanupIntervalMinutes: number + } +} + interface Config { MAP: Record Compression: { Gzip: CompressionConfig Brotli: CompressionConfig } + Security: SecurityConfig } export default function ConfigPage() { @@ -163,6 +175,20 @@ export default function ConfigPage() { } const data = await response.json() + + // 确保安全配置存在 + if (!data.Security) { + data.Security = { + IPBan: { + Enabled: false, + ErrorThreshold: 10, + WindowMinutes: 5, + BanDurationMinutes: 5, + CleanupIntervalMinutes: 1 + } + } + } + isConfigFromApiRef.current = true // 标记配置来自API setConfig(data) } catch (error) { @@ -374,6 +400,31 @@ export default function ConfigPage() { updateConfig(newConfig) } + const updateSecurity = (field: keyof SecurityConfig['IPBan'], value: boolean | number) => { + if (!config) return + const newConfig = { ...config } + + // 确保安全配置存在 + if (!newConfig.Security) { + newConfig.Security = { + IPBan: { + Enabled: false, + ErrorThreshold: 10, + WindowMinutes: 5, + BanDurationMinutes: 5, + CleanupIntervalMinutes: 1 + } + } + } + + if (field === 'Enabled') { + newConfig.Security.IPBan.Enabled = value as boolean + } else { + newConfig.Security.IPBan[field] = value as number + } + updateConfig(newConfig) + } + const handleExtensionMapEdit = (path: string) => { // 将添加规则的操作重定向到handleExtensionRuleEdit handleExtensionRuleEdit(path); @@ -433,6 +484,19 @@ export default function ConfigPage() { throw new Error('配置文件压缩设置格式不正确') } + // 如果没有安全配置,添加默认配置 + if (!newConfig.Security) { + newConfig.Security = { + IPBan: { + Enabled: false, + ErrorThreshold: 10, + WindowMinutes: 5, + BanDurationMinutes: 5, + CleanupIntervalMinutes: 1 + } + } + } + // 验证路径映射 for (const [path, target] of Object.entries(newConfig.MAP)) { if (!path.startsWith('/')) { @@ -785,6 +849,7 @@ export default function ConfigPage() { 路径映射 压缩设置 + 安全策略 @@ -1015,6 +1080,94 @@ export default function ConfigPage() { + + + + + IP 封禁策略 + + + +
+
+ +

+ 当 IP 频繁访问不存在的资源时自动封禁 +

+
+ updateSecurity('Enabled', checked)} + /> +
+ + {config?.Security?.IPBan?.Enabled && ( + <> +
+ + updateSecurity('ErrorThreshold', parseInt(e.target.value) || 10)} + /> +

+ 在指定时间窗口内,IP 访问不存在资源的次数超过此值将被封禁 +

+
+ +
+ + updateSecurity('WindowMinutes', parseInt(e.target.value) || 5)} + /> +

+ 统计 404 错误的时间窗口长度 +

+
+ +
+ + updateSecurity('BanDurationMinutes', parseInt(e.target.value) || 5)} + /> +

+ IP 被封禁的持续时间 +

+
+ +
+ + updateSecurity('CleanupIntervalMinutes', parseInt(e.target.value) || 1)} + /> +

+ 清理过期记录的间隔时间 +

+
+ + )} +
+
+
diff --git a/web/app/dashboard/security/page.tsx b/web/app/dashboard/security/page.tsx new file mode 100644 index 0000000..8800667 --- /dev/null +++ b/web/app/dashboard/security/page.tsx @@ -0,0 +1,386 @@ +"use client" + +import React, { useEffect, useState, useCallback } from "react" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { useToast } from "@/components/ui/use-toast" +import { useRouter } from "next/navigation" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { Shield, Ban, Clock, Trash2, RefreshCw } from "lucide-react" + +interface BannedIP { + ip: string + ban_end_time: string + remaining_seconds: number +} + +interface SecurityStats { + banned_ips_count: number + error_records_count: number + config: { + ErrorThreshold: number + WindowMinutes: number + BanDurationMinutes: number + CleanupIntervalMinutes: number + } +} + +interface IPStatus { + ip: string + banned: boolean + ban_end_time?: string + remaining_seconds?: number +} + +export default function SecurityPage() { + const [bannedIPs, setBannedIPs] = useState([]) + const [stats, setStats] = useState(null) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + const [checkingIP, setCheckingIP] = useState("") + const [ipStatus, setIPStatus] = useState(null) + const [unbanning, setUnbanning] = useState(null) + const { toast } = useToast() + const router = useRouter() + + const fetchData = useCallback(async () => { + try { + const token = localStorage.getItem("token") + if (!token) { + router.push("/login") + return + } + + const [bannedResponse, statsResponse] = await Promise.all([ + fetch("/admin/api/security/banned-ips", { + headers: { 'Authorization': `Bearer ${token}` } + }), + fetch("/admin/api/security/stats", { + headers: { 'Authorization': `Bearer ${token}` } + }) + ]) + + if (bannedResponse.status === 401 || statsResponse.status === 401) { + localStorage.removeItem("token") + router.push("/login") + return + } + + if (bannedResponse.ok) { + const bannedData = await bannedResponse.json() + setBannedIPs(bannedData.banned_ips || []) + } + + if (statsResponse.ok) { + const statsData = await statsResponse.json() + setStats(statsData) + } + } catch (error) { + console.error("获取安全数据失败:", error) + toast({ + title: "错误", + description: "获取安全数据失败", + variant: "destructive", + }) + } finally { + setLoading(false) + setRefreshing(false) + } + }, [router, toast]) + + useEffect(() => { + fetchData() + // 每30秒自动刷新一次数据 + const interval = setInterval(fetchData, 30000) + return () => clearInterval(interval) + }, [fetchData]) + + const handleRefresh = () => { + setRefreshing(true) + fetchData() + } + + const checkIPStatus = async () => { + if (!checkingIP.trim()) return + + try { + const token = localStorage.getItem("token") + if (!token) { + router.push("/login") + return + } + + const response = await fetch(`/admin/api/security/check-ip?ip=${encodeURIComponent(checkingIP)}`, { + headers: { 'Authorization': `Bearer ${token}` } + }) + + if (response.status === 401) { + localStorage.removeItem("token") + router.push("/login") + return + } + + if (response.ok) { + const data = await response.json() + setIPStatus(data) + } else { + throw new Error("检查IP状态失败") + } + } catch { + toast({ + title: "错误", + description: "检查IP状态失败", + variant: "destructive", + }) + } + } + + const unbanIP = async (ip: string) => { + try { + const token = localStorage.getItem("token") + if (!token) { + router.push("/login") + return + } + + const response = await fetch("/admin/api/security/unban", { + method: "POST", + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ ip }) + }) + + if (response.status === 401) { + localStorage.removeItem("token") + router.push("/login") + return + } + + if (response.ok) { + const data = await response.json() + if (data.success) { + toast({ + title: "成功", + description: `IP ${ip} 已解封`, + }) + fetchData() // 刷新数据 + } else { + toast({ + title: "提示", + description: data.message, + }) + } + } else { + throw new Error("解封IP失败") + } + } catch { + toast({ + title: "错误", + description: "解封IP失败", + variant: "destructive", + }) + } finally { + setUnbanning(null) + } + } + + const formatTime = (seconds: number) => { + if (seconds <= 0) return "已过期" + const minutes = Math.floor(seconds / 60) + const remainingSeconds = seconds % 60 + if (minutes > 0) { + return `${minutes}分${remainingSeconds}秒` + } + return `${remainingSeconds}秒` + } + + if (loading) { + return ( +
+
+
加载中...
+
正在获取安全数据
+
+
+ ) + } + + return ( +
+ + + + + 安全管理 + + + + + {stats && ( +
+
+
+ +
+
{stats.banned_ips_count}
+
被封禁IP
+
+
+
+
+
+ +
+
{stats.error_records_count}
+
错误记录
+
+
+
+
+
错误阈值
+
+ {stats.config.ErrorThreshold}次/{stats.config.WindowMinutes}分钟 +
+
+
+
封禁时长
+
+ {stats.config.BanDurationMinutes}分钟 +
+
+
+ )} + +
+
+
+ +
+ setCheckingIP(e.target.value)} + /> + +
+
+
+ + {ipStatus && ( + + +
+
+ IP: {ipStatus.ip} +
+
+ {ipStatus.banned ? '已封禁' : '正常'} +
+ {ipStatus.banned && ipStatus.remaining_seconds && ipStatus.remaining_seconds > 0 && ( +
+ 剩余时间: {formatTime(ipStatus.remaining_seconds)} +
+ )} +
+
+
+ )} +
+
+
+ + + + 被封禁的IP列表 + + + {bannedIPs.length === 0 ? ( +
+ 当前没有被封禁的IP +
+ ) : ( + + + + IP地址 + 封禁结束时间 + 剩余时间 + 操作 + + + + {bannedIPs.map((bannedIP) => ( + + {bannedIP.ip} + {bannedIP.ban_end_time} + + + {formatTime(bannedIP.remaining_seconds)} + + + + + + + ))} + +
+ )} +
+
+ + !open && setUnbanning(null)}> + + + 确认解封 + + 确定要解封IP地址 “{unbanning}” 吗? + + + + 取消 + unbanning && unbanIP(unbanning)}> + 确认解封 + + + + +
+ ) +} \ No newline at end of file diff --git a/web/components/nav.tsx b/web/components/nav.tsx index ac4f273..a784925 100644 --- a/web/components/nav.tsx +++ b/web/components/nav.tsx @@ -55,6 +55,12 @@ export function Nav() { > 缓存 + + 安全 +