feat: 防火墙扫描感知功能 & 首页容器统计 & lint 修复

- 新增 eBPF 扫描感知:TC ingress 检测 SYN/UDP 扫描,端口白名单过滤
- 新增首页容器数量统计卡片,未安装容器环境时自动隐藏
- 扫描概览页:统计卡片、趋势图、Top IP/端口、事件列表
- 扫描设置页:启用开关、保留天数、网卡选择
- 统计对比改为同长度上一周期,替代固定昨日对比
- eBPF ports map MaxEntries 扩展至 65535 覆盖全端口
- 修复 WriteString(fmt.Sprintf()) lint 问题,改用 fmt.Fprintf
- 修复 database_redis.go 非常量格式字符串和 govet 警告

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
耗子
2026-02-18 02:52:32 +08:00
co-authored by Claude Opus 4.6
parent d0cd178dfe
commit 9a1555c48a
22 changed files with 1769 additions and 5 deletions
+4 -2
View File
@@ -112,6 +112,8 @@ func initAce() (*app.Ace, error) {
safeRepo := data.NewSafeRepo(logger)
safeService := service.NewSafeService(safeRepo)
firewallService := service.NewFirewallService()
scanEventRepo := data.NewScanEventRepo(db, settingRepo)
firewallScanService := service.NewFirewallScanService(scanEventRepo)
sshRepo := data.NewSSHRepo(locale, db, logger)
sshService := service.NewSSHService(sshRepo)
containerService := service.NewContainerService(containerRepo)
@@ -163,7 +165,7 @@ func initAce() (*app.Ace, error) {
s3fsApp := s3fs.NewApp(locale)
supervisorApp := supervisor.NewApp(locale)
loader := bootstrap.NewLoader(apacheApp, codeserverApp, dockerApp, fail2banApp, frpApp, giteaApp, mariadbApp, memcachedApp, minioApp, mysqlApp, nginxApp, openrestyApp, perconaApp, phpmyadminApp, podmanApp, postgresqlApp, pureftpdApp, redisApp, rsyncApp, s3fsApp, supervisorApp)
http := route.NewHttp(config, userService, userTokenService, homeService, taskService, websiteService, projectService, databaseService, databaseServerService, databaseRedisService, databaseUserService, backupService, backupStorageService, certService, certDNSService, certAccountService, appService, environmentService, environmentGoService, environmentJavaService, environmentNodejsService, environmentPHPService, environmentPythonService, environmentDotnetService, cronService, processService, safeService, firewallService, sshService, containerService, containerComposeService, containerNetworkService, containerImageService, containerVolumeService, fileService, logService, monitorService, settingService, systemctlService, toolboxNetworkService, toolboxSystemService, toolboxBenchmarkService, toolboxSSHService, toolboxDiskService, toolboxLogService, toolboxMigrationService, webHookService, templateService, loader)
http := route.NewHttp(config, userService, userTokenService, homeService, taskService, websiteService, projectService, databaseService, databaseServerService, databaseRedisService, databaseUserService, backupService, backupStorageService, certService, certDNSService, certAccountService, appService, environmentService, environmentGoService, environmentJavaService, environmentNodejsService, environmentPHPService, environmentPythonService, environmentDotnetService, cronService, processService, safeService, firewallService, firewallScanService, sshService, containerService, containerComposeService, containerNetworkService, containerImageService, containerVolumeService, fileService, logService, monitorService, settingService, systemctlService, toolboxNetworkService, toolboxSystemService, toolboxBenchmarkService, toolboxSSHService, toolboxDiskService, toolboxLogService, toolboxMigrationService, webHookService, templateService, loader)
wsService := service.NewWsService(locale, config, logger, sshRepo, settingRepo)
ws := route.NewWs(wsService, toolboxMigrationService)
mux, err := bootstrap.NewRouter(locale, middlewares, http, ws)
@@ -179,7 +181,7 @@ func initAce() (*app.Ace, error) {
return nil, err
}
gormigrate := bootstrap.NewMigrate(db)
jobs := job.NewJobs(config, db, logger, settingRepo, certRepo, certAccountRepo, backupRepo, cacheRepo, taskRepo)
jobs := job.NewJobs(config, db, logger, settingRepo, certRepo, certAccountRepo, backupRepo, cacheRepo, taskRepo, scanEventRepo)
cron, err := bootstrap.NewCron(config, logger, jobs)
if err != nil {
return nil, err
+1
View File
@@ -7,6 +7,7 @@ require (
github.com/andybalholm/brotli v1.2.0
github.com/bddjr/hlfhr v1.4.2
github.com/beevik/ntp v1.5.0
github.com/cilium/ebpf v0.20.0
github.com/coder/websocket v1.8.14
github.com/containerd/errdefs v1.0.0
github.com/coreos/go-systemd/v22 v22.7.0
+12
View File
@@ -41,6 +41,8 @@ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBW
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cilium/ebpf v0.20.0 h1:atwWj9d3NffHyPZzVlx3hmw1on5CLe9eljR8VuHTwhM=
github.com/cilium/ebpf v0.20.0/go.mod h1:pzLjFymM+uZPLk/IXZUL63xdx5VXEo+enTzxkZXdycw=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
@@ -101,6 +103,8 @@ github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6 h1:teYtXy9B7y5lHTp8V9KPxpYRAVA7dozigQcMiBust1s=
github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
@@ -190,6 +194,10 @@ github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/jsimonetti/rtnetlink/v2 v2.0.1 h1:xda7qaHDSVOsADNouv7ukSuicKZO7GgVUCXxpaIEIlM=
github.com/jsimonetti/rtnetlink/v2 v2.0.1/go.mod h1:7MoNYNbb3UaDHtF8udiJo/RH6VsTKP1pqKLUTVCvToE=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
@@ -259,6 +267,10 @@ github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNx
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g=
github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U=
github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
+68
View File
@@ -0,0 +1,68 @@
package biz
import "time"
// ScanEvent 扫描事件模型
type ScanEvent struct {
ID uint `gorm:"primaryKey" json:"id"`
SourceIP string `gorm:"not null;uniqueIndex:idx_scan_unique" json:"source_ip"`
Port uint `gorm:"not null;uniqueIndex:idx_scan_unique" json:"port"`
Protocol string `gorm:"not null;default:'tcp';uniqueIndex:idx_scan_unique" json:"protocol"`
Date string `gorm:"not null;uniqueIndex:idx_scan_unique;index:idx_scan_date" json:"date"` // YYYY-MM-DD
Count uint `gorm:"not null;default:1" json:"count"`
FirstSeen time.Time `gorm:"not null" json:"first_seen"`
LastSeen time.Time `gorm:"not null" json:"last_seen"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ScanSummary 扫描汇总
type ScanSummary struct {
TotalCount uint `json:"total_count"`
UniqueIPs uint `json:"unique_ips"`
UniquePorts uint `json:"unique_ports"`
}
// ScanDayTrend 每日趋势
type ScanDayTrend struct {
Date string `json:"date"`
TotalCount uint `json:"total_count"`
UniqueIPs uint `json:"unique_ips"`
}
// ScanSourceRank 扫描源 IP 排行
type ScanSourceRank struct {
SourceIP string `json:"source_ip"`
TotalCount uint `json:"total_count"`
PortCount uint `json:"port_count"`
LastSeen string `json:"last_seen"`
}
// ScanPortRank 被扫描端口排行
type ScanPortRank struct {
Port uint `json:"port"`
Protocol string `json:"protocol"`
TotalCount uint `json:"total_count"`
IPCount uint `json:"ip_count"`
}
// ScanSetting 扫描感知设置
type ScanSetting struct {
Enabled bool `json:"enabled"`
Days uint `json:"days"`
Interfaces []string `json:"interfaces"`
}
// ScanEventRepo 扫描事件数据访问接口
type ScanEventRepo interface {
Upsert(events []*ScanEvent) error
List(start, end string, page, limit uint) ([]*ScanEvent, uint, error)
Summary(start, end string) (*ScanSummary, error)
Trend(start, end string) ([]*ScanDayTrend, error)
TopSourceIPs(start, end string, limit uint) ([]*ScanSourceRank, error)
TopPorts(start, end string, limit uint) ([]*ScanPortRank, error)
ClearBefore(date string) error
GetSetting() (*ScanSetting, error)
UpdateSetting(setting *ScanSetting) error
Clear() error
}
+3
View File
@@ -30,6 +30,9 @@ const (
SettingHiddenMenu SettingKey = "hidden_menu"
SettingKeyCustomLogo SettingKey = "custom_logo"
SettingKeyMemo SettingKey = "memo"
SettingKeyScanAware SettingKey = "scan_aware"
SettingKeyScanAwareDays SettingKey = "scan_aware_days"
SettingKeyScanAwareInterfaces SettingKey = "scan_aware_interfaces"
)
type Setting struct {
+1
View File
@@ -26,6 +26,7 @@ var ProviderSet = wire.NewSet(
NewMonitorRepo,
NewProjectRepo,
NewSafeRepo,
NewScanEventRepo,
NewSettingRepo,
NewSSHRepo,
NewTaskRepo,
+171
View File
@@ -0,0 +1,171 @@
package data
import (
"encoding/json"
"strings"
"time"
"github.com/spf13/cast"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/acepanel/panel/internal/biz"
)
type scanEventRepo struct {
db *gorm.DB
setting biz.SettingRepo
}
// NewScanEventRepo 创建扫描事件数据访问实例
func NewScanEventRepo(db *gorm.DB, setting biz.SettingRepo) biz.ScanEventRepo {
return &scanEventRepo{
db: db,
setting: setting,
}
}
func (r scanEventRepo) Upsert(events []*biz.ScanEvent) error {
if len(events) == 0 {
return nil
}
const batchSize = 100
for i := 0; i < len(events); i += batchSize {
end := i + batchSize
if end > len(events) {
end = len(events)
}
if err := r.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "source_ip"}, {Name: "port"}, {Name: "protocol"}, {Name: "date"}},
DoUpdates: clause.Assignments(map[string]any{"count": gorm.Expr("count + ?", gorm.Expr("excluded.count")), "last_seen": gorm.Expr("excluded.last_seen")}),
}).Create(events[i:end]).Error; err != nil {
return err
}
}
return nil
}
func (r scanEventRepo) List(start, end string, page, limit uint) ([]*biz.ScanEvent, uint, error) {
var total int64
var items []*biz.ScanEvent
tx := r.db.Model(&biz.ScanEvent{}).Where("date BETWEEN ? AND ?", start, end)
if err := tx.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := tx.Order("last_seen DESC").Offset(int((page - 1) * limit)).Limit(int(limit)).Find(&items).Error; err != nil {
return nil, 0, err
}
return items, uint(total), nil
}
func (r scanEventRepo) Summary(start, end string) (*biz.ScanSummary, error) {
var summary biz.ScanSummary
err := r.db.Model(&biz.ScanEvent{}).
Where("date BETWEEN ? AND ?", start, end).
Select("COALESCE(SUM(count), 0) as total_count, COUNT(DISTINCT source_ip) as unique_ips, COUNT(DISTINCT port || '-' || protocol) as unique_ports").
Scan(&summary).Error
return &summary, err
}
func (r scanEventRepo) Trend(start, end string) ([]*biz.ScanDayTrend, error) {
var trends []*biz.ScanDayTrend
err := r.db.Model(&biz.ScanEvent{}).
Where("date BETWEEN ? AND ?", start, end).
Select("date, COALESCE(SUM(count), 0) as total_count, COUNT(DISTINCT source_ip) as unique_ips").
Group("date").
Order("date ASC").
Scan(&trends).Error
return trends, err
}
func (r scanEventRepo) TopSourceIPs(start, end string, limit uint) ([]*biz.ScanSourceRank, error) {
var ranks []*biz.ScanSourceRank
err := r.db.Model(&biz.ScanEvent{}).
Where("date BETWEEN ? AND ?", start, end).
Select("source_ip, COALESCE(SUM(count), 0) as total_count, COUNT(DISTINCT port || '-' || protocol) as port_count, MAX(last_seen) as last_seen").
Group("source_ip").
Order("total_count DESC").
Limit(int(limit)).
Scan(&ranks).Error
for _, rank := range ranks {
rank.LastSeen = r.parseTimeStr(rank.LastSeen)
}
return ranks, err
}
func (r scanEventRepo) TopPorts(start, end string, limit uint) ([]*biz.ScanPortRank, error) {
var ranks []*biz.ScanPortRank
err := r.db.Model(&biz.ScanEvent{}).
Where("date BETWEEN ? AND ?", start, end).
Select("port, protocol, COALESCE(SUM(count), 0) as total_count, COUNT(DISTINCT source_ip) as ip_count").
Group("port, protocol").
Order("total_count DESC").
Limit(int(limit)).
Scan(&ranks).Error
return ranks, err
}
func (r scanEventRepo) ClearBefore(date string) error {
return r.db.Where("date < ?", date).Delete(&biz.ScanEvent{}).Error
}
func (r scanEventRepo) GetSetting() (*biz.ScanSetting, error) {
enabled, err := r.setting.GetBool(biz.SettingKeyScanAware)
if err != nil {
return nil, err
}
days, err := r.setting.GetInt(biz.SettingKeyScanAwareDays, 30)
if err != nil {
return nil, err
}
interfacesStr, err := r.setting.Get(biz.SettingKeyScanAwareInterfaces)
if err != nil {
return nil, err
}
var interfaces []string
if interfacesStr != "" {
_ = json.Unmarshal([]byte(interfacesStr), &interfaces)
}
return &biz.ScanSetting{
Enabled: enabled,
Days: uint(days),
Interfaces: interfaces,
}, nil
}
func (r scanEventRepo) UpdateSetting(setting *biz.ScanSetting) error {
if err := r.setting.Set(biz.SettingKeyScanAware, cast.ToString(setting.Enabled)); err != nil {
return err
}
if err := r.setting.Set(biz.SettingKeyScanAwareDays, cast.ToString(setting.Days)); err != nil {
return err
}
interfacesJSON, err := json.Marshal(setting.Interfaces)
if err != nil {
return err
}
return r.setting.Set(biz.SettingKeyScanAwareInterfaces, string(interfacesJSON))
}
func (r scanEventRepo) Clear() error {
return r.db.Where("1 = 1").Delete(&biz.ScanEvent{}).Error
}
// parseTimeStr 解析 Go time.String() 格式并转为 RFC3339
func (r scanEventRepo) parseTimeStr(s string) string {
if idx := strings.Index(s, " m="); idx > 0 {
s = s[:idx]
}
if t, err := time.Parse("2006-01-02 15:04:05.999999999 -0700 MST", s); err == nil {
return t.Format(time.RFC3339)
}
return s
}
+8
View File
@@ -0,0 +1,8 @@
package request
// FirewallScanSetting 扫描感知设置
type FirewallScanSetting struct {
Enabled bool `json:"enabled"`
Days uint `json:"days" validate:"min:1|max:365"`
Interfaces []string `json:"interfaces"`
}
+154
View File
@@ -0,0 +1,154 @@
package job
import (
"fmt"
"log/slog"
"sync"
"time"
"github.com/acepanel/panel/internal/app"
"github.com/acepanel/panel/internal/biz"
"github.com/acepanel/panel/pkg/firewall/scan"
)
// FirewallScan 防火墙扫描感知任务
type FirewallScan struct {
log *slog.Logger
setting biz.SettingRepo
scanRepo biz.ScanEventRepo
scanner *scan.Scanner
buffer map[string]*biz.ScanEvent // key: "ip:port:proto:date"
mu sync.Mutex
}
// NewFirewallScan 创建扫描感知任务
func NewFirewallScan(log *slog.Logger, setting biz.SettingRepo, scanRepo biz.ScanEventRepo) *FirewallScan {
return &FirewallScan{
log: log,
setting: setting,
scanRepo: scanRepo,
buffer: make(map[string]*biz.ScanEvent),
}
}
func (r *FirewallScan) Run() {
if app.Status != app.StatusNormal {
return
}
enabled, err := r.setting.GetBool(biz.SettingKeyScanAware)
if err != nil || !enabled {
// 未启用时,确保 scanner 已停止
r.stopScanner()
return
}
// 确保 scanner 已启动
r.ensureScanner()
// flush 缓冲到数据库
r.flush()
// 清理过期数据
r.cleanup()
}
// ensureScanner 确保 scanner 正在运行
func (r *FirewallScan) ensureScanner() {
if r.scanner != nil {
return
}
if !scan.Supported() {
return
}
setting, err := r.scanRepo.GetSetting()
if err != nil {
r.log.Warn("failed to get scan setting", slog.Any("err", err))
return
}
scanner, err := scan.New(setting.Interfaces, r.log)
if err != nil {
r.log.Warn("failed to start eBPF scan detector", slog.Any("err", err))
return
}
r.scanner = scanner
// 启动后台事件聚合
go r.aggregate()
}
// stopScanner 停止 scanner
func (r *FirewallScan) stopScanner() {
if r.scanner == nil {
return
}
_ = r.scanner.Close()
r.scanner = nil
}
// aggregate 持续读取 eBPF 事件并聚合到内存缓冲
func (r *FirewallScan) aggregate() {
events := r.scanner.Events()
if events == nil {
return
}
for evt := range events {
date := evt.Timestamp.Format(time.DateOnly)
key := fmt.Sprintf("%s:%d:%s:%s", evt.SourceIP, evt.Port, evt.Protocol, date)
r.mu.Lock()
if existing, ok := r.buffer[key]; ok {
existing.Count++
existing.LastSeen = evt.Timestamp
} else {
r.buffer[key] = &biz.ScanEvent{
SourceIP: evt.SourceIP,
Port: uint(evt.Port),
Protocol: evt.Protocol,
Date: date,
Count: 1,
FirstSeen: evt.Timestamp,
LastSeen: evt.Timestamp,
}
}
r.mu.Unlock()
}
}
// flush 将内存缓冲写入数据库
func (r *FirewallScan) flush() {
r.mu.Lock()
if len(r.buffer) == 0 {
r.mu.Unlock()
return
}
events := make([]*biz.ScanEvent, 0, len(r.buffer))
for _, evt := range r.buffer {
events = append(events, evt)
}
r.buffer = make(map[string]*biz.ScanEvent)
r.mu.Unlock()
if err := r.scanRepo.Upsert(events); err != nil {
r.log.Warn("failed to upsert scan events", slog.Any("err", err))
}
}
// cleanup 清理过期数据
func (r *FirewallScan) cleanup() {
day, err := r.setting.GetInt(biz.SettingKeyScanAwareDays, 30)
if err != nil {
return
}
cutoff := time.Now().AddDate(0, 0, -day).Format(time.DateOnly)
if err = r.scanRepo.ClearBefore(cutoff); err != nil {
r.log.Warn("failed to clear expired scan data", slog.Any("err", err))
}
}
+6 -1
View File
@@ -23,9 +23,10 @@ type Jobs struct {
backup biz.BackupRepo
cache biz.CacheRepo
task biz.TaskRepo
scanRepo biz.ScanEventRepo
}
func NewJobs(conf *config.Config, db *gorm.DB, log *slog.Logger, setting biz.SettingRepo, cert biz.CertRepo, certAccount biz.CertAccountRepo, backup biz.BackupRepo, cache biz.CacheRepo, task biz.TaskRepo) *Jobs {
func NewJobs(conf *config.Config, db *gorm.DB, log *slog.Logger, setting biz.SettingRepo, cert biz.CertRepo, certAccount biz.CertAccountRepo, backup biz.BackupRepo, cache biz.CacheRepo, task biz.TaskRepo, scanRepo biz.ScanEventRepo) *Jobs {
return &Jobs{
conf: conf,
db: db,
@@ -36,6 +37,7 @@ func NewJobs(conf *config.Config, db *gorm.DB, log *slog.Logger, setting biz.Set
backup: backup,
cache: cache,
task: task,
scanRepo: scanRepo,
}
}
@@ -43,6 +45,9 @@ func (r *Jobs) Register(c *cron.Cron) error {
if _, err := c.AddJob("* * * * *", NewMonitoring(r.db, r.log, r.setting)); err != nil {
return err
}
if _, err := c.AddJob("*/2 * * * *", NewFirewallScan(r.log, r.setting, r.scanRepo)); err != nil {
return err
}
if _, err := c.AddJob("0 4 * * *", NewCertRenew(r.conf, r.db, r.log, r.setting, r.cert, r.certAccount)); err != nil {
return err
}
+9
View File
@@ -77,4 +77,13 @@ func init() {
return nil
},
})
Migrations = append(Migrations, &gormigrate.Migration{
ID: "20260218-add-scan-events",
Migrate: func(tx *gorm.DB) error {
return tx.AutoMigrate(&biz.ScanEvent{})
},
Rollback: func(tx *gorm.DB) error {
return tx.Migrator().DropTable(&biz.ScanEvent{})
},
})
}
+14
View File
@@ -49,6 +49,7 @@ type Http struct {
process *service.ProcessService
safe *service.SafeService
firewall *service.FirewallService
firewallScan *service.FirewallScanService
ssh *service.SSHService
container *service.ContainerService
containerCompose *service.ContainerComposeService
@@ -101,6 +102,7 @@ func NewHttp(
process *service.ProcessService,
safe *service.SafeService,
firewall *service.FirewallService,
firewallScan *service.FirewallScanService,
ssh *service.SSHService,
container *service.ContainerService,
containerCompose *service.ContainerComposeService,
@@ -152,6 +154,7 @@ func NewHttp(
process: process,
safe: safe,
firewall: firewall,
firewallScan: firewallScan,
ssh: ssh,
container: container,
containerCompose: containerCompose,
@@ -433,6 +436,17 @@ func (route *Http) Register(r *chi.Mux) {
r.Get("/forward", route.firewall.GetForwards)
r.Post("/forward", route.firewall.CreateForward)
r.Delete("/forward", route.firewall.DeleteForward)
// 扫描感知
r.Get("/scan/setting", route.firewallScan.GetSetting)
r.Post("/scan/setting", route.firewallScan.UpdateSetting)
r.Get("/scan/interfaces", route.firewallScan.GetInterfaces)
r.Get("/scan/summary", route.firewallScan.GetSummary)
r.Get("/scan/trend", route.firewallScan.GetTrend)
r.Get("/scan/top_ips", route.firewallScan.GetTopSourceIPs)
r.Get("/scan/top_ports", route.firewallScan.GetTopPorts)
r.Get("/scan/events", route.firewallScan.ListEvents)
r.Post("/scan/clear", route.firewallScan.Clear)
})
r.Route("/ssh", func(r chi.Router) {
+2
View File
@@ -1024,6 +1024,8 @@ func (s *CliService) Init(ctx context.Context, cmd *cli.Command) error {
{Key: biz.SettingKeyOfflineMode, Value: "false"},
{Key: biz.SettingKeyAutoUpdate, Value: "true"},
{Key: biz.SettingHiddenMenu, Value: "[]"},
{Key: biz.SettingKeyScanAware, Value: "true"},
{Key: biz.SettingKeyScanAwareDays, Value: "30"},
}
if err = s.db.Create(&settings).Error; err != nil {
return errors.New(s.t.Get("Initialization failed: %v", err))
+156
View File
@@ -0,0 +1,156 @@
package service
import (
"net/http"
"github.com/libtnb/chix"
"github.com/spf13/cast"
"github.com/acepanel/panel/internal/biz"
"github.com/acepanel/panel/internal/http/request"
"github.com/acepanel/panel/pkg/firewall/scan"
)
type FirewallScanService struct {
scanRepo biz.ScanEventRepo
}
func NewFirewallScanService(scanRepo biz.ScanEventRepo) *FirewallScanService {
return &FirewallScanService{
scanRepo: scanRepo,
}
}
// GetSetting 获取扫描感知设置
func (s *FirewallScanService) GetSetting(w http.ResponseWriter, r *http.Request) {
setting, err := s.scanRepo.GetSetting()
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, setting)
}
// UpdateSetting 更新扫描感知设置
func (s *FirewallScanService) UpdateSetting(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.FirewallScanSetting](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if err = s.scanRepo.UpdateSetting(&biz.ScanSetting{
Enabled: req.Enabled,
Days: req.Days,
Interfaces: req.Interfaces,
}); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, nil)
}
// GetInterfaces 获取可用网卡列表
func (s *FirewallScanService) GetInterfaces(w http.ResponseWriter, r *http.Request) {
ifaces, err := scan.ListInterfaces()
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, ifaces)
}
// GetSummary 获取扫描汇总
func (s *FirewallScanService) GetSummary(w http.ResponseWriter, r *http.Request) {
start := r.URL.Query().Get("start")
end := r.URL.Query().Get("end")
summary, err := s.scanRepo.Summary(start, end)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, summary)
}
// GetTrend 获取扫描趋势
func (s *FirewallScanService) GetTrend(w http.ResponseWriter, r *http.Request) {
start := r.URL.Query().Get("start")
end := r.URL.Query().Get("end")
trends, err := s.scanRepo.Trend(start, end)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, trends)
}
// GetTopSourceIPs 获取 Top 扫描源 IP
func (s *FirewallScanService) GetTopSourceIPs(w http.ResponseWriter, r *http.Request) {
start := r.URL.Query().Get("start")
end := r.URL.Query().Get("end")
limit := cast.ToUint(r.URL.Query().Get("limit"))
if limit == 0 {
limit = 10
}
ranks, err := s.scanRepo.TopSourceIPs(start, end, limit)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, ranks)
}
// GetTopPorts 获取 Top 被扫描端口
func (s *FirewallScanService) GetTopPorts(w http.ResponseWriter, r *http.Request) {
start := r.URL.Query().Get("start")
end := r.URL.Query().Get("end")
limit := cast.ToUint(r.URL.Query().Get("limit"))
if limit == 0 {
limit = 10
}
ranks, err := s.scanRepo.TopPorts(start, end, limit)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, ranks)
}
// ListEvents 获取事件列表
func (s *FirewallScanService) ListEvents(w http.ResponseWriter, r *http.Request) {
start := r.URL.Query().Get("start")
end := r.URL.Query().Get("end")
page := cast.ToUint(r.URL.Query().Get("page"))
limit := cast.ToUint(r.URL.Query().Get("limit"))
if page == 0 {
page = 1
}
if limit == 0 {
limit = 20
}
items, total, err := s.scanRepo.List(start, end, page, limit)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, chix.M{
"total": total,
"items": items,
})
}
// Clear 清空所有扫描数据
func (s *FirewallScanService) Clear(w http.ResponseWriter, r *http.Request) {
if err := s.scanRepo.Clear(); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, nil)
}
+1
View File
@@ -30,6 +30,7 @@ var ProviderSet = wire.NewSet(
NewEnvironmentDotnetService,
NewFileService,
NewFirewallService,
NewFirewallScanService,
NewHomeService,
NewLogService,
NewMonitorService,
+538
View File
@@ -0,0 +1,538 @@
//go:build linux
package scan
import (
"encoding/binary"
"errors"
"fmt"
"log/slog"
"net"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/cilium/ebpf"
"github.com/cilium/ebpf/asm"
"github.com/cilium/ebpf/link"
"github.com/cilium/ebpf/ringbuf"
)
const (
ringBufSize = 256 * 1024 // 256KB
eventSize = 20 // src_ip(16) + dst_port(2) + protocol(1) + version(1)
)
// __sk_buff 字段偏移量
const (
skbData = 76 // __sk_buff.data
skbDataEnd = 80 // __sk_buff.data_end
)
// 协议头长度
const (
ethLen = 14
ipLen = 20 // IPv4 最小头长度
ipv6Len = 40 // IPv6 固定头长度
tcpLen = 20 // TCP 最小头长度
udpLen = 8 // UDP 头长度
)
// EtherType(小端序 16 位加载值)
const (
ethIPv4 = 0x0008 // ETH_P_IP (0x0800)
ethIPv6 = 0xDD86 // ETH_P_IPV6 (0x86DD)
)
// 协议常量
const (
ipTCP = 6
ipUDP = 17
synAckMask = 0x12 // SYN + ACK
synOnly = 0x02 // 仅 SYN
)
// IPv4 字段偏移量(从 data 起始)
const (
offV4Proto = ethLen + 9 // IP protocol (23)
offV4SrcIP = ethLen + 12 // IP 源地址 (26)
)
// IPv6 字段偏移量(从 data 起始)
const (
offV6Proto = ethLen + 6 // Next Header (20)
offV6SrcIP = ethLen + 8 // 源地址 (22)16 字节
)
// ifaceHandle 单个网卡的 eBPF 挂载句柄
type ifaceHandle struct {
link link.Link
}
// Scanner eBPF 扫描检测器
type Scanner struct {
prog *ebpf.Program
events *ebpf.Map
ports *ebpf.Map // 监听端口白名单(hash map,命中则跳过)
handles map[string]*ifaceHandle
reader *ringbuf.Reader
eventsCh chan Event
stopCh chan struct{}
mu sync.Mutex
log *slog.Logger
lastPorts map[uint16]bool
}
// newPortsMap 创建监听端口白名单 BPF map
func newPortsMap() (*ebpf.Map, error) {
return ebpf.NewMap(&ebpf.MapSpec{
Type: ebpf.Hash,
KeySize: 4, // uint32
ValueSize: 1, // uint8
MaxEntries: 65535,
})
}
// buildDetector eBPF TC ingress 扫描检测程序
// 只捕获目标端口不在 ports 白名单中的 SYN/UDP 包
func buildDetector(events, ports *ebpf.Map) (*ebpf.Program, error) {
// TCP 处理器:边界检查 → SYN 过滤 → 端口白名单 → 输出事件
tcpHandler := func(sym string, boundsEnd, flagsOff, portOff int) asm.Instructions {
return asm.Instructions{
asm.Mov.Reg(asm.R2, asm.R6).WithSymbol(sym),
asm.Add.Imm(asm.R2, int32(boundsEnd)),
asm.JGT.Reg(asm.R2, asm.R7, "exit"),
// 仅 SYN 包
asm.LoadMem(asm.R2, asm.R6, int16(flagsOff), asm.Byte),
asm.And.Imm(asm.R2, synAckMask),
asm.JNE.Imm(asm.R2, synOnly, "exit"),
// 加载端口并转换字节序
asm.LoadMem(asm.R2, asm.R6, int16(portOff), asm.Half),
asm.HostTo(asm.BE, asm.R2, asm.Half),
asm.Mov.Reg(asm.R9, asm.R2), // 保存端口到 R9
// 查询端口白名单
asm.StoreMem(asm.RFP, -24, asm.R9, asm.Word),
asm.LoadMapPtr(asm.R1, ports.FD()),
asm.Mov.Reg(asm.R2, asm.RFP),
asm.Add.Imm(asm.R2, -24),
asm.FnMapLookupElem.Call(),
asm.JNE.Imm(asm.R0, 0, "exit"),
// 写入事件
asm.StoreMem(asm.RFP, -4, asm.R9, asm.Half),
asm.StoreImm(asm.RFP, -2, ipTCP, asm.Byte),
asm.LoadMapPtr(asm.R1, events.FD()),
asm.Mov.Reg(asm.R2, asm.RFP),
asm.Add.Imm(asm.R2, -eventSize),
asm.Mov.Imm(asm.R3, eventSize),
asm.Mov.Imm(asm.R4, 0),
asm.FnRingbufOutput.Call(),
asm.Ja.Label("exit"),
}
}
// UDP 处理器:边界检查 → 端口白名单 → 输出事件
udpHandler := func(sym string, boundsEnd, portOff int) asm.Instructions {
return asm.Instructions{
asm.Mov.Reg(asm.R2, asm.R6).WithSymbol(sym),
asm.Add.Imm(asm.R2, int32(boundsEnd)),
asm.JGT.Reg(asm.R2, asm.R7, "exit"),
asm.LoadMem(asm.R2, asm.R6, int16(portOff), asm.Half),
asm.HostTo(asm.BE, asm.R2, asm.Half),
asm.Mov.Reg(asm.R9, asm.R2),
// 查询端口白名单
asm.StoreMem(asm.RFP, -24, asm.R9, asm.Word),
asm.LoadMapPtr(asm.R1, ports.FD()),
asm.Mov.Reg(asm.R2, asm.RFP),
asm.Add.Imm(asm.R2, -24),
asm.FnMapLookupElem.Call(),
asm.JNE.Imm(asm.R0, 0, "exit"),
asm.StoreMem(asm.RFP, -4, asm.R9, asm.Half),
asm.StoreImm(asm.RFP, -2, ipUDP, asm.Byte),
asm.LoadMapPtr(asm.R1, events.FD()),
asm.Mov.Reg(asm.R2, asm.RFP),
asm.Add.Imm(asm.R2, -eventSize),
asm.Mov.Imm(asm.R3, eventSize),
asm.Mov.Imm(asm.R4, 0),
asm.FnRingbufOutput.Call(),
asm.Ja.Label("exit"),
}
}
var insns asm.Instructions
// 加载 skb->data / skb->data_end
insns = append(insns,
asm.LoadMem(asm.R6, asm.R1, skbData, asm.Word),
asm.LoadMem(asm.R7, asm.R1, skbDataEnd, asm.Word),
)
// 边界检查:以太网头
insns = append(insns,
asm.Mov.Reg(asm.R2, asm.R6),
asm.Add.Imm(asm.R2, ethLen),
asm.JGT.Reg(asm.R2, asm.R7, "exit"),
)
// EtherType 分支
insns = append(insns,
asm.LoadMem(asm.R0, asm.R6, 12, asm.Half),
asm.JEq.Imm(asm.R0, ethIPv4, "ipv4"),
asm.JEq.Imm(asm.R0, ethIPv6, "ipv6"),
asm.Ja.Label("exit"),
)
// ========== IPv4 ==========
insns = append(insns,
asm.Mov.Reg(asm.R2, asm.R6).WithSymbol("ipv4"),
asm.Add.Imm(asm.R2, ethLen+ipLen),
asm.JGT.Reg(asm.R2, asm.R7, "exit"),
asm.LoadMem(asm.R8, asm.R6, offV4Proto, asm.Byte),
asm.LoadMem(asm.R9, asm.R6, offV4SrcIP, asm.Word),
// 栈布局:[src_ip(16) | port(2) | proto(1) | ver(1)] = 20 字节
asm.StoreMem(asm.RFP, -20, asm.R9, asm.Word),
asm.StoreImm(asm.RFP, -16, 0, asm.Word),
asm.StoreImm(asm.RFP, -12, 0, asm.Word),
asm.StoreImm(asm.RFP, -8, 0, asm.Word),
asm.StoreImm(asm.RFP, -1, 4, asm.Byte), // version = 4
asm.JEq.Imm(asm.R8, ipTCP, "v4tcp"),
asm.JEq.Imm(asm.R8, ipUDP, "v4udp"),
asm.Ja.Label("exit"),
)
v4t := ethLen + ipLen
insns = append(insns, tcpHandler("v4tcp", v4t+tcpLen, v4t+13, v4t+2)...)
insns = append(insns, udpHandler("v4udp", v4t+udpLen, v4t+2)...)
// ========== IPv6 ==========
insns = append(insns,
asm.Mov.Reg(asm.R2, asm.R6).WithSymbol("ipv6"),
asm.Add.Imm(asm.R2, ethLen+ipv6Len),
asm.JGT.Reg(asm.R2, asm.R7, "exit"),
asm.LoadMem(asm.R8, asm.R6, offV6Proto, asm.Byte),
// 16 字节源地址
asm.LoadMem(asm.R2, asm.R6, offV6SrcIP, asm.Word),
asm.StoreMem(asm.RFP, -20, asm.R2, asm.Word),
asm.LoadMem(asm.R2, asm.R6, offV6SrcIP+4, asm.Word),
asm.StoreMem(asm.RFP, -16, asm.R2, asm.Word),
asm.LoadMem(asm.R2, asm.R6, offV6SrcIP+8, asm.Word),
asm.StoreMem(asm.RFP, -12, asm.R2, asm.Word),
asm.LoadMem(asm.R2, asm.R6, offV6SrcIP+12, asm.Word),
asm.StoreMem(asm.RFP, -8, asm.R2, asm.Word),
asm.StoreImm(asm.RFP, -1, 6, asm.Byte), // version = 6
asm.JEq.Imm(asm.R8, ipTCP, "v6tcp"),
asm.JEq.Imm(asm.R8, ipUDP, "v6udp"),
asm.Ja.Label("exit"),
)
v6t := ethLen + ipv6Len
insns = append(insns, tcpHandler("v6tcp", v6t+tcpLen, v6t+13, v6t+2)...)
insns = append(insns, udpHandler("v6udp", v6t+udpLen, v6t+2)...)
// ========== 退出 ==========
insns = append(insns,
asm.Mov.Imm(asm.R0, 0).WithSymbol("exit"),
asm.Return(),
)
return ebpf.NewProgram(&ebpf.ProgramSpec{
Name: "scan_detector",
Type: ebpf.SchedCLS,
Instructions: insns,
})
}
// Supported 检测当前系统是否支持 eBPF
func Supported() bool {
events, err := ebpf.NewMap(&ebpf.MapSpec{
Type: ebpf.RingBuf,
MaxEntries: ringBufSize,
})
if err != nil {
return false
}
ports, err := newPortsMap()
if err != nil {
_ = events.Close()
return false
}
prog, err := buildDetector(events, ports)
if err != nil {
_ = events.Close()
_ = ports.Close()
return false
}
_ = prog.Close()
_ = events.Close()
_ = ports.Close()
return true
}
// New 创建 Scanner,加载 eBPF 程序并挂载到指定网卡
func New(ifaces []string, log *slog.Logger) (*Scanner, error) {
if len(ifaces) == 0 {
defaultIface := DefaultInterface()
if defaultIface == "" {
return nil, errors.New("no available network interface found")
}
ifaces = []string{defaultIface}
}
events, err := ebpf.NewMap(&ebpf.MapSpec{
Type: ebpf.RingBuf,
MaxEntries: ringBufSize,
})
if err != nil {
return nil, fmt.Errorf("failed to create ring buffer: %w", err)
}
ports, err := newPortsMap()
if err != nil {
_ = events.Close()
return nil, fmt.Errorf("failed to create ports whitelist map: %w", err)
}
prog, err := buildDetector(events, ports)
if err != nil {
_ = events.Close()
_ = ports.Close()
return nil, fmt.Errorf("failed to load eBPF program: %w", err)
}
s := &Scanner{
prog: prog,
events: events,
ports: ports,
handles: make(map[string]*ifaceHandle),
eventsCh: make(chan Event, 4096),
stopCh: make(chan struct{}),
log: log,
lastPorts: make(map[uint16]bool),
}
for _, ifaceName := range ifaces {
if err := s.attach(ifaceName); err != nil {
_ = s.Close()
return nil, fmt.Errorf("failed to attach to interface %s: %w", ifaceName, err)
}
}
reader, err := ringbuf.NewReader(events)
if err != nil {
_ = s.Close()
return nil, fmt.Errorf("failed to create ring buffer reader: %w", err)
}
s.reader = reader
// 初始同步监听端口白名单
s.syncPorts()
go s.readLoop()
go s.portsLoop()
return s, nil
}
// Events 返回事件通道
func (s *Scanner) Events() <-chan Event {
return s.eventsCh
}
// Close 卸载 eBPF 程序并清理资源
func (s *Scanner) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
select {
case <-s.stopCh:
default:
close(s.stopCh)
}
if s.reader != nil {
_ = s.reader.Close()
}
for name, h := range s.handles {
if err := h.link.Close(); err != nil {
s.log.Warn("failed to detach eBPF program", slog.String("iface", name), slog.Any("err", err))
}
s.log.Info("eBPF scan detector detached", slog.String("iface", name))
}
if s.prog != nil {
_ = s.prog.Close()
}
if s.events != nil {
_ = s.events.Close()
}
if s.ports != nil {
_ = s.ports.Close()
}
return nil
}
// attach 挂载 eBPF 程序到指定网卡的 TC ingress
func (s *Scanner) attach(ifaceName string) error {
iface, err := net.InterfaceByName(ifaceName)
if err != nil {
return fmt.Errorf("failed to get interface %s: %w", ifaceName, err)
}
l, err := link.AttachTCX(link.TCXOptions{
Interface: iface.Index,
Program: s.prog,
Attach: ebpf.AttachTCXIngress,
})
if err != nil {
return fmt.Errorf("failed to attach TCX to %s: %w", ifaceName, err)
}
s.handles[ifaceName] = &ifaceHandle{link: l}
s.log.Info("eBPF scan detector attached", slog.String("iface", ifaceName))
return nil
}
// readLoop 持续读取 Ring Buffer 事件
func (s *Scanner) readLoop() {
for {
record, err := s.reader.Read()
if err != nil {
if errors.Is(err, ringbuf.ErrClosed) {
return
}
s.log.Warn("failed to read ring buffer", slog.Any("err", err))
continue
}
if len(record.RawSample) < eventSize {
continue
}
select {
case s.eventsCh <- parseEvent(record.RawSample):
default:
}
}
}
// portsLoop 定时同步监听端口白名单
func (s *Scanner) portsLoop() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-s.stopCh:
return
case <-ticker.C:
s.syncPorts()
}
}
}
// syncPorts 读取系统当前监听端口并同步到 BPF map
func (s *Scanner) syncPorts() {
newPorts := readListeningPorts()
// 删除不再监听的端口
for port := range s.lastPorts {
if !newPorts[port] {
_ = s.ports.Delete(uint32(port))
}
}
// 添加新监听的端口
for port := range newPorts {
if !s.lastPorts[port] {
_ = s.ports.Put(uint32(port), uint8(1))
}
}
s.lastPorts = newPorts
}
// readListeningPorts 从 /proc 读取当前所有监听端口
func readListeningPorts() map[uint16]bool {
ports := make(map[uint16]bool)
// TCP LISTEN (state 0A)
parseProcNet("/proc/net/tcp", "0A", ports)
parseProcNet("/proc/net/tcp6", "0A", ports)
// UDP 绑定端口 (state 07 = unconnected)
parseProcNet("/proc/net/udp", "07", ports)
parseProcNet("/proc/net/udp6", "07", ports)
return ports
}
// parseProcNet 解析 /proc/net/{tcp,tcp6,udp,udp6} 提取指定状态的本地端口
func parseProcNet(path, state string, ports map[uint16]bool) {
data, err := os.ReadFile(path)
if err != nil {
return
}
for _, line := range strings.Split(string(data), "\n") {
fields := strings.Fields(line)
if len(fields) < 4 || fields[3] != state {
continue
}
// local_address 格式: "00000000:0050" 或 "00000000000000000000000000000000:0050"
idx := strings.LastIndex(fields[1], ":")
if idx < 0 {
continue
}
port, err := strconv.ParseUint(fields[1][idx+1:], 16, 16)
if err != nil {
continue
}
ports[uint16(port)] = true
}
}
// parseEvent 解析原始事件数据
// 栈布局:[src_ip(16) | dst_port(2) | protocol(1) | version(1)]
func parseEvent(data []byte) Event {
version := data[19]
var ip net.IP
if version == 6 {
ip = make(net.IP, 16)
copy(ip, data[0:16])
} else {
ip = net.IPv4(data[0], data[1], data[2], data[3])
}
dstPort := binary.LittleEndian.Uint16(data[16:18])
proto := data[18]
protoStr := "tcp"
if proto == ipUDP {
protoStr = "udp"
}
return Event{
SourceIP: ip.String(),
Port: dstPort,
Protocol: protoStr,
Timestamp: time.Now(),
}
}
+31
View File
@@ -0,0 +1,31 @@
//go:build !linux
package scan
import (
"errors"
"log/slog"
)
// Scanner eBPF 扫描检测器(非 Linux 平台占位)
type Scanner struct{}
// Supported 非 Linux 平台不支持 eBPF
func Supported() bool {
return false
}
// New 非 Linux 平台不支持
func New(_ []string, _ *slog.Logger) (*Scanner, error) {
return nil, errors.New("not support eBPF")
}
// Events 返回空通道
func (s *Scanner) Events() <-chan Event {
return nil
}
// Close 无操作
func (s *Scanner) Close() error {
return nil
}
+83
View File
@@ -0,0 +1,83 @@
package scan
import (
"net"
"time"
)
// Event 扫描事件
type Event struct {
SourceIP string
Port uint16
Protocol string // "tcp" / "udp"
Timestamp time.Time
}
// InterfaceInfo 网卡信息
type InterfaceInfo struct {
Name string `json:"name"`
IPs []string `json:"ips"`
Status string `json:"status"` // "up" / "down"
}
// ListInterfaces 列出可用网卡
func ListInterfaces() ([]InterfaceInfo, error) {
ifaces, err := net.Interfaces()
if err != nil {
return nil, err
}
var result []InterfaceInfo
for _, iface := range ifaces {
if iface.Flags&net.FlagLoopback != 0 {
continue
}
info := InterfaceInfo{
Name: iface.Name,
}
if iface.Flags&net.FlagUp != 0 {
info.Status = "up"
} else {
info.Status = "down"
}
addrs, err := iface.Addrs()
if err == nil {
for _, addr := range addrs {
info.IPs = append(info.IPs, addr.String())
}
}
result = append(result, info)
}
return result, nil
}
// DefaultInterface 获取默认网卡名称
func DefaultInterface() string {
ifaces, err := net.Interfaces()
if err != nil {
return ""
}
for _, iface := range ifaces {
if iface.Flags&net.FlagLoopback != 0 {
continue
}
if iface.Flags&net.FlagUp == 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil || len(addrs) == 0 {
continue
}
return iface.Name
}
return ""
}
+24 -1
View File
@@ -28,5 +28,28 @@ export default {
deleteForward: (rule: any): any => http.Delete('/firewall/forward', rule),
// 获取端口占用进程信息
portUsage: (port: number, protocol: string): any =>
http.Get('/firewall/rule/port_usage', { params: { port, protocol } })
http.Get('/firewall/rule/port_usage', { params: { port, protocol } }),
// 扫描感知 - 获取设置
scanSetting: (): any => http.Get('/firewall/scan/setting'),
// 扫描感知 - 更新设置
updateScanSetting: (setting: any): any => http.Post('/firewall/scan/setting', setting),
// 扫描感知 - 获取可用网卡
scanInterfaces: (): any => http.Get('/firewall/scan/interfaces'),
// 扫描感知 - 获取汇总
scanSummary: (start: string, end: string): any =>
http.Get('/firewall/scan/summary', { params: { start, end } }),
// 扫描感知 - 获取趋势
scanTrend: (start: string, end: string): any =>
http.Get('/firewall/scan/trend', { params: { start, end } }),
// 扫描感知 - 获取 Top 源 IP
scanTopIPs: (start: string, end: string, limit: number): any =>
http.Get('/firewall/scan/top_ips', { params: { start, end, limit } }),
// 扫描感知 - 获取 Top 端口
scanTopPorts: (start: string, end: string, limit: number): any =>
http.Get('/firewall/scan/top_ports', { params: { start, end, limit } }),
// 扫描感知 - 获取事件列表
scanEvents: (start: string, end: string, page: number, limit: number): any =>
http.Get('/firewall/scan/events', { params: { start, end, page, limit } }),
// 扫描感知 - 清空数据
scanClear: (): any => http.Post('/firewall/scan/clear')
}
+4 -1
View File
@@ -6,17 +6,19 @@ defineOptions({
import ForwardView from '@/views/firewall/ForwardView.vue'
import IpRuleView from '@/views/firewall/IpRuleView.vue'
import RuleView from '@/views/firewall/RuleView.vue'
import ScanView from '@/views/firewall/ScanView.vue'
import SettingView from '@/views/firewall/SettingView.vue'
import { useGettext } from 'vue3-gettext'
const { $gettext } = useGettext()
const currentTab = ref('rule')
const currentTab = ref('scan')
</script>
<template>
<common-page show-header show-footer>
<template #tabbar>
<n-tabs v-model:value="currentTab" animated>
<n-tab name="scan" :tab="$gettext('Scan Awareness')" />
<n-tab name="rule" :tab="$gettext('Port Rules')" />
<n-tab name="ip-rule" :tab="$gettext('IP Rules')" />
<n-tab name="forward" :tab="$gettext('Port Forwarding')" />
@@ -24,6 +26,7 @@ const currentTab = ref('rule')
</n-tabs>
</template>
<rule-view v-if="currentTab === 'rule'" />
<scan-view v-if="currentTab === 'scan'" />
<ip-rule-view v-if="currentTab === 'ip-rule'" />
<forward-view v-if="currentTab === 'forward'" />
<setting-view v-if="currentTab === 'setting'" />
+421
View File
@@ -0,0 +1,421 @@
<script setup lang="ts">
import type { EChartsOption } from 'echarts'
import { LineChart } from 'echarts/charts'
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import VChart from 'vue-echarts'
import { useGettext } from 'vue3-gettext'
import firewall from '@/api/panel/firewall'
import { formatDateTime } from '@/utils'
import { NButton, NPopconfirm } from 'naive-ui'
const { $gettext, $pgettext } = useGettext()
use([CanvasRenderer, LineChart, TooltipComponent, LegendComponent, GridComponent])
const currentTab = ref('overview')
// 日期范围
const dateRange = ref<[number, number] | null>(null)
function initDateRange() {
const now = new Date()
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const start = new Date(end.getTime() - 6 * 24 * 60 * 60 * 1000)
dateRange.value = [start.getTime(), end.getTime()]
}
initDateRange()
function formatDate(ts: number): string {
const d = new Date(ts)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
const startDate = computed(() => (dateRange.value ? formatDate(dateRange.value[0]) : ''))
const endDate = computed(() => (dateRange.value ? formatDate(dateRange.value[1]) : ''))
// 汇总数据
const summary = ref({ total_count: 0, unique_ips: 0, unique_ports: 0 })
const summaryLoading = ref(false)
const prevSummary = ref({ total_count: 0, unique_ips: 0, unique_ports: 0 })
// 趋势数据
const trendData = ref<any[]>([])
const trendLoading = ref(false)
// Top IP
const topIPs = ref<any[]>([])
const topIPsLoading = ref(false)
// Top 端口
const topPorts = ref<any[]>([])
const topPortsLoading = ref(false)
// 事件列表
const events = ref<any[]>([])
const eventsTotal = ref(0)
const eventsPage = ref(1)
const eventsPageSize = ref(20)
const eventsLoading = ref(false)
const loadSummary = () => {
summaryLoading.value = true
useRequest(firewall.scanSummary(startDate.value, endDate.value))
.onSuccess(({ data }) => {
summary.value = data
})
.onComplete(() => {
summaryLoading.value = false
})
// 获取上一个同长度周期的数据用于对比
if (dateRange.value) {
const rangeMs = dateRange.value[1] - dateRange.value[0]
const oneDayMs = 24 * 60 * 60 * 1000
const prevEnd = dateRange.value[0] - oneDayMs
const prevStart = prevEnd - rangeMs
useRequest(firewall.scanSummary(formatDate(prevStart), formatDate(prevEnd))).onSuccess(
({ data }) => {
prevSummary.value = data
}
)
}
}
const loadTrend = () => {
trendLoading.value = true
useRequest(firewall.scanTrend(startDate.value, endDate.value))
.onSuccess(({ data }) => {
trendData.value = data || []
})
.onComplete(() => {
trendLoading.value = false
})
}
const loadTopIPs = () => {
topIPsLoading.value = true
useRequest(firewall.scanTopIPs(startDate.value, endDate.value, 10))
.onSuccess(({ data }) => {
topIPs.value = data || []
})
.onComplete(() => {
topIPsLoading.value = false
})
}
const loadTopPorts = () => {
topPortsLoading.value = true
useRequest(firewall.scanTopPorts(startDate.value, endDate.value, 10))
.onSuccess(({ data }) => {
topPorts.value = data || []
})
.onComplete(() => {
topPortsLoading.value = false
})
}
const loadEvents = () => {
eventsLoading.value = true
useRequest(
firewall.scanEvents(startDate.value, endDate.value, eventsPage.value, eventsPageSize.value)
)
.onSuccess(({ data }) => {
events.value = data.items || []
eventsTotal.value = data.total || 0
})
.onComplete(() => {
eventsLoading.value = false
})
}
const refreshOverview = () => {
loadSummary()
loadTrend()
loadTopIPs()
loadTopPorts()
}
watch(dateRange, () => {
if (dateRange.value) {
refreshOverview()
if (currentTab.value === 'events') {
loadEvents()
}
}
})
watch([eventsPage, eventsPageSize], () => {
loadEvents()
})
watch(currentTab, (val) => {
if (val === 'events' && events.value.length === 0) {
loadEvents()
}
})
onMounted(() => {
refreshOverview()
})
// 趋势图表
const trendOption = computed<EChartsOption>(() => ({
tooltip: {
trigger: 'axis'
},
legend: {
data: [$gettext('Scan Count'), $gettext('Source IPs')],
top: 0
},
grid: {
left: '3%',
right: '4%',
top: '15%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: trendData.value.map((d: any) => d.date)
},
yAxis: [
{
type: 'value',
name: $gettext('Scan Count')
},
{
type: 'value',
name: $gettext('Source IPs')
}
],
series: [
{
name: $gettext('Scan Count'),
type: 'line',
smooth: true,
data: trendData.value.map((d: any) => d.total_count)
},
{
name: $gettext('Source IPs'),
type: 'line',
smooth: true,
yAxisIndex: 1,
data: trendData.value.map((d: any) => d.unique_ips)
}
]
}))
// 拉黑 IP
const handleBlock = (ip: string) => {
const family = ip.includes(':') ? 'ipv6' : 'ipv4'
useRequest(
firewall.createIpRule({
family,
protocol: 'tcp/udp',
address: ip,
strategy: 'drop',
direction: 'in'
})
).onSuccess(() => {
window.$message.success($gettext('%{ address } blocked successfully', { address: ip }))
})
}
// 操作列:拉黑按钮
const blockColumn = {
title: $gettext('Actions'),
key: 'actions',
width: 100,
render: (row: any) =>
h(
NPopconfirm,
{ onPositiveClick: () => handleBlock(row.source_ip) },
{
default: () => $pgettext('firewall', 'Block %{ ip }?', { ip: row.source_ip }),
trigger: () =>
h(NButton, { size: 'tiny', type: 'error', ghost: true }, () =>
$pgettext('firewall', 'Block')
)
}
)
}
const topIPColumns: any = [
{ title: $gettext('Source IP'), key: 'source_ip', minWidth: 150 },
{ title: $gettext('Scan Count'), key: 'total_count', width: 120, sorter: 'default' },
{ title: $gettext('Port Count'), key: 'port_count', width: 120 },
{
title: $gettext('Last Seen'),
key: 'last_seen',
width: 180,
render: (row: any) => formatDateTime(row.last_seen)
},
blockColumn
]
const topPortColumns: any = [
{ title: $gettext('Port'), key: 'port', width: 100 },
{ title: $gettext('Protocol'), key: 'protocol', width: 100 },
{ title: $gettext('Scan Count'), key: 'total_count', width: 120, sorter: 'default' },
{ title: $gettext('IP Count'), key: 'ip_count', width: 120 }
]
const eventColumns: any = [
{ title: $gettext('Source IP'), key: 'source_ip', minWidth: 150 },
{ title: $gettext('Port'), key: 'port', width: 100 },
{ title: $gettext('Protocol'), key: 'protocol', width: 100 },
{ title: $gettext('Scan Count'), key: 'count', width: 120 },
{
title: $gettext('First Seen'),
key: 'first_seen',
width: 180,
render: (row: any) => formatDateTime(row.first_seen)
},
{
title: $gettext('Last Seen'),
key: 'last_seen',
width: 180,
render: (row: any) => formatDateTime(row.last_seen)
},
blockColumn
]
const handleClear = () => {
useRequest(firewall.scanClear()).onSuccess(() => {
window.$message.success($gettext('Cleared successfully'))
refreshOverview()
loadEvents()
})
}
</script>
<template>
<n-flex vertical :size="20">
<n-tabs v-model:value="currentTab" type="segment">
<n-tab name="overview" :tab="$gettext('Overview')" />
<n-tab name="events" :tab="$gettext('Scan Events')" />
</n-tabs>
<!-- 工具栏 -->
<div class="flex w-full items-center justify-between">
<n-date-picker
v-model:value="dateRange"
type="daterange"
clearable
:default-time="['00:00:00', '23:59:59']"
/>
<n-popconfirm @positive-click="handleClear">
<template #trigger>
<n-button type="error" ghost>
{{ $gettext('Clear Data') }}
</n-button>
</template>
{{ $gettext('Are you sure you want to clear all scan data?') }}
</n-popconfirm>
</div>
<!-- 概览 -->
<template v-if="currentTab === 'overview'">
<!-- 统计卡片 -->
<n-grid :cols="3" :x-gap="16">
<n-gi>
<n-card :bordered="false">
<n-statistic :label="$gettext('Total Scans')" :value="summary.total_count">
<template #suffix>
<n-text :type="summary.total_count > prevSummary.total_count ? 'error' : 'success'">
{{ summary.total_count > prevSummary.total_count ? '↑' : '↓' }}
</n-text>
</template>
</n-statistic>
</n-card>
</n-gi>
<n-gi>
<n-card :bordered="false">
<n-statistic :label="$gettext('Scanned Ports')" :value="summary.unique_ports">
<template #suffix>
<n-text
:type="summary.unique_ports > prevSummary.unique_ports ? 'error' : 'success'"
>
{{ summary.unique_ports > prevSummary.unique_ports ? '↑' : '↓' }}
</n-text>
</template>
</n-statistic>
</n-card>
</n-gi>
<n-gi>
<n-card :bordered="false">
<n-statistic :label="$gettext('Source IPs')" :value="summary.unique_ips">
<template #suffix>
<n-text :type="summary.unique_ips > prevSummary.unique_ips ? 'error' : 'success'">
{{ summary.unique_ips > prevSummary.unique_ips ? '↑' : '↓' }}
</n-text>
</template>
</n-statistic>
</n-card>
</n-gi>
</n-grid>
<!-- 趋势图 -->
<n-card :bordered="false" :title="$gettext('Scan Trend')">
<n-spin :show="trendLoading">
<v-chart class="h-300px" :option="trendOption" autoresize />
</n-spin>
</n-card>
<!-- Top IP Top 端口 -->
<n-grid :cols="2" :x-gap="16">
<n-gi>
<n-card h-full :bordered="false" :title="$gettext('Top 10 Source IPs')">
<n-data-table
striped
size="small"
:loading="topIPsLoading"
:columns="topIPColumns"
:data="topIPs"
/>
</n-card>
</n-gi>
<n-gi>
<n-card h-full :bordered="false" :title="$gettext('Top 10 Scanned Ports')">
<n-data-table
striped
size="small"
:loading="topPortsLoading"
:columns="topPortColumns"
:data="topPorts"
/>
</n-card>
</n-gi>
</n-grid>
</template>
<!-- 扫描记录 -->
<template v-if="currentTab === 'events'">
<n-card :bordered="false">
<n-data-table
striped
remote
:scroll-x="1000"
:loading="eventsLoading"
:columns="eventColumns"
:data="events"
:row-key="(row: any) => row.id"
v-model:page="eventsPage"
v-model:pageSize="eventsPageSize"
:pagination="{
page: eventsPage,
pageSize: eventsPageSize,
itemCount: eventsTotal,
showQuickJumper: true,
showSizePicker: true,
pageSizes: [20, 50, 100]
}"
/>
</n-card>
</template>
</n-flex>
</template>
<style scoped lang="scss"></style>
+58
View File
@@ -27,6 +27,43 @@ const handlePingStatus = () => {
window.$message.success($gettext('Settings saved successfully'))
})
}
// 扫描感知设置
const scanEnabled = ref(false)
const saveDays = ref(30)
const selectedInterfaces = ref<string[]>([])
const interfaceOptions = ref<any[]>([])
const scanSettingLoading = ref(false)
useRequest(firewall.scanSetting()).onSuccess(({ data }) => {
scanEnabled.value = data.enabled
saveDays.value = data.days || 30
selectedInterfaces.value = data.interfaces || []
})
useRequest(firewall.scanInterfaces()).onSuccess(({ data }) => {
interfaceOptions.value = (data || []).map((iface: any) => ({
label: `${iface.name} (${iface.ips?.join(', ') || iface.status})`,
value: iface.name
}))
})
const handleSaveScanSetting = () => {
scanSettingLoading.value = true
useRequest(
firewall.updateScanSetting({
enabled: scanEnabled.value,
days: saveDays.value,
interfaces: selectedInterfaces.value
})
)
.onSuccess(() => {
window.$message.success($gettext('Settings saved successfully'))
})
.onComplete(() => {
scanSettingLoading.value = false
})
}
</script>
<template>
@@ -37,6 +74,27 @@ const handlePingStatus = () => {
<n-form-item path="ping" :label="$gettext('Allow Ping')">
<n-switch v-model:value="model.pingStatus" @update:value="handlePingStatus" />
</n-form-item>
<n-divider />
<n-form-item :label="$gettext('Scan Awareness')">
<n-switch v-model:value="scanEnabled" />
</n-form-item>
<n-form-item :label="$gettext('Retention Days')">
<n-input-number v-model:value="saveDays" :min="1" :max="365" w-full />
</n-form-item>
<n-form-item :label="$gettext('Network Interfaces')">
<n-select
v-model:value="selectedInterfaces"
:options="interfaceOptions"
multiple
clearable
:placeholder="$gettext('Auto detect')"
/>
</n-form-item>
<n-form-item>
<n-button type="primary" :loading="scanSettingLoading" @click="handleSaveScanSetting">
{{ $gettext('Save') }}
</n-button>
</n-form-item>
</n-form>
</template>