mirror of
https://github.com/tnb-labs/panel.git
synced 2026-09-17 16:39:01 +08:00
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:
+4
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -26,6 +26,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewMonitorRepo,
|
||||
NewProjectRepo,
|
||||
NewSafeRepo,
|
||||
NewScanEventRepo,
|
||||
NewSettingRepo,
|
||||
NewSSHRepo,
|
||||
NewTaskRepo,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -30,6 +30,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewEnvironmentDotnetService,
|
||||
NewFileService,
|
||||
NewFirewallService,
|
||||
NewFirewallScanService,
|
||||
NewHomeService,
|
||||
NewLogService,
|
||||
NewMonitorService,
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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'" />
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user