diff --git a/cmd/ace/wire_gen.go b/cmd/ace/wire_gen.go index 752857628..dbde7526b 100644 --- a/cmd/ace/wire_gen.go +++ b/cmd/ace/wire_gen.go @@ -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 diff --git a/go.mod b/go.mod index 967ac3295..f3135e3a4 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 3c3e7f51d..4fceac506 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/biz/scan_event.go b/internal/biz/scan_event.go new file mode 100644 index 000000000..d59b93b7c --- /dev/null +++ b/internal/biz/scan_event.go @@ -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 +} diff --git a/internal/biz/setting.go b/internal/biz/setting.go index 967574434..2a10e8eb5 100644 --- a/internal/biz/setting.go +++ b/internal/biz/setting.go @@ -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 { diff --git a/internal/data/data.go b/internal/data/data.go index c0bb4a726..ec9619805 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -26,6 +26,7 @@ var ProviderSet = wire.NewSet( NewMonitorRepo, NewProjectRepo, NewSafeRepo, + NewScanEventRepo, NewSettingRepo, NewSSHRepo, NewTaskRepo, diff --git a/internal/data/scan_event.go b/internal/data/scan_event.go new file mode 100644 index 000000000..024123b58 --- /dev/null +++ b/internal/data/scan_event.go @@ -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 +} diff --git a/internal/http/request/firewall_scan.go b/internal/http/request/firewall_scan.go new file mode 100644 index 000000000..6798d7020 --- /dev/null +++ b/internal/http/request/firewall_scan.go @@ -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"` +} diff --git a/internal/job/firewall_scan.go b/internal/job/firewall_scan.go new file mode 100644 index 000000000..f56e55cd4 --- /dev/null +++ b/internal/job/firewall_scan.go @@ -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)) + } +} diff --git a/internal/job/job.go b/internal/job/job.go index cb985621a..f5d98fa18 100644 --- a/internal/job/job.go +++ b/internal/job/job.go @@ -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 } diff --git a/internal/migration/v1.go b/internal/migration/v1.go index a46f81ae7..382725334 100644 --- a/internal/migration/v1.go +++ b/internal/migration/v1.go @@ -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{}) + }, + }) } diff --git a/internal/route/http.go b/internal/route/http.go index 23f1652f1..d8f8c2b95 100644 --- a/internal/route/http.go +++ b/internal/route/http.go @@ -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) { diff --git a/internal/service/cli.go b/internal/service/cli.go index 8f119a8f7..ac839acd2 100644 --- a/internal/service/cli.go +++ b/internal/service/cli.go @@ -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)) diff --git a/internal/service/firewall_scan.go b/internal/service/firewall_scan.go new file mode 100644 index 000000000..d29d7c135 --- /dev/null +++ b/internal/service/firewall_scan.go @@ -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) +} diff --git a/internal/service/service.go b/internal/service/service.go index bef687133..33c8e164c 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -30,6 +30,7 @@ var ProviderSet = wire.NewSet( NewEnvironmentDotnetService, NewFileService, NewFirewallService, + NewFirewallScanService, NewHomeService, NewLogService, NewMonitorService, diff --git a/pkg/firewall/scan/scanner_linux.go b/pkg/firewall/scan/scanner_linux.go new file mode 100644 index 000000000..dd58c0a45 --- /dev/null +++ b/pkg/firewall/scan/scanner_linux.go @@ -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(), + } +} diff --git a/pkg/firewall/scan/scanner_other.go b/pkg/firewall/scan/scanner_other.go new file mode 100644 index 000000000..08e46f440 --- /dev/null +++ b/pkg/firewall/scan/scanner_other.go @@ -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 +} diff --git a/pkg/firewall/scan/types.go b/pkg/firewall/scan/types.go new file mode 100644 index 000000000..18d77c00c --- /dev/null +++ b/pkg/firewall/scan/types.go @@ -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 "" +} diff --git a/web/src/api/panel/firewall/index.ts b/web/src/api/panel/firewall/index.ts index e95dab3e5..1ee00513f 100644 --- a/web/src/api/panel/firewall/index.ts +++ b/web/src/api/panel/firewall/index.ts @@ -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') } diff --git a/web/src/views/firewall/IndexView.vue b/web/src/views/firewall/IndexView.vue index 361aa880b..0e318afb9 100644 --- a/web/src/views/firewall/IndexView.vue +++ b/web/src/views/firewall/IndexView.vue @@ -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')