Merge pull request #926 from samwafgo/feat_host_guard

feat:add host guard
This commit is contained in:
samwafgo
2026-08-07 13:31:30 +08:00
committed by GitHub
57 changed files with 6520 additions and 0 deletions
+4
View File
@@ -59,6 +59,8 @@ type APIGroup struct {
WafNotifySubscriptionApi
WafNotifyLogApi
WafFirewallIPBlockApi
WafHostGuardApi
WafHostConnApi
WafThreatIPApi
WafCDNIPApi
WafPluginApi
@@ -149,6 +151,8 @@ var (
wafSqlQueryService = waf_service.WafSqlQueryServiceApp
wafFirewallIPBlockService = waf_service.WafFirewallIPBlockServiceApp
wafHostGuardService = waf_service.WafHostGuardServiceApp
wafHostConnService = waf_service.WafHostConnServiceApp
wafThreatIPService = waf_service.WafThreatIPServiceApp
wafCDNIPService = waf_service.WafCDNIPServiceApp
+108
View File
@@ -0,0 +1,108 @@
package api
import (
"SamWaf/model/common/response"
"SamWaf/model/request"
"github.com/gin-gonic/gin"
)
type WafHostConnApi struct {
}
// GetListApi 远程连接列表
// @Summary 远程连接列表
// @Description 当前连接到本机的所有TCP连接,SSH/RDP 端口会被标记
// @Tags 远程连接看板
// @Accept json
// @Produce json
// @Param data body request.WafHostConnSearchReq true "查询条件"
// @Success 200 {object} response.Response
// @Security ApiKeyAuth
// @Router /hostconn/list [post]
func (w *WafHostConnApi) GetListApi(c *gin.Context) {
var req request.WafHostConnSearchReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage("解析失败: "+err.Error(), c)
return
}
normalizeHostGuardPage(&req.PageIndex, &req.PageSize)
list, total, summary, err := wafHostConnService.GetList(req)
if err != nil {
response.FailWithMessage("查询失败: "+err.Error(), c)
return
}
response.OkWithDetailed(gin.H{
"list": list,
"total": total,
"pageIndex": req.PageIndex,
"pageSize": req.PageSize,
"summary": summary,
}, "查询成功", c)
}
// GetSummaryApi 连接汇总
// @Summary 远程连接汇总
// @Tags 远程连接看板
// @Produce json
// @Success 200 {object} response.Response
// @Security ApiKeyAuth
// @Router /hostconn/summary [get]
func (w *WafHostConnApi) GetSummaryApi(c *gin.Context) {
summary, err := wafHostConnService.GetSummary()
if err != nil {
response.FailWithMessage("查询失败: "+err.Error(), c)
return
}
response.OkWithDetailed(summary, "查询成功", c)
}
// RefreshApi 强制刷新快照
// @Summary 强制刷新连接快照
// @Description 丢弃缓存立即重新采集(页面上的手工刷新按钮)
// @Tags 远程连接看板
// @Produce json
// @Success 200 {object} response.Response
// @Security ApiKeyAuth
// @Router /hostconn/refresh [get]
func (w *WafHostConnApi) RefreshApi(c *gin.Context) {
wafHostConnService.InvalidateSnapshot()
response.OkWithMessage("已刷新", c)
}
// BlockApi 从连接看板一键封禁
// @Summary 封禁某个连接来源IP
// @Description 转调主机防爆破的手工封禁,走同一套账本与解封流程
// @Tags 远程连接看板
// @Accept json
// @Produce json
// @Param data body request.WafHostGuardManualBanReq true "封禁参数"
// @Success 200 {object} response.Response
// @Security ApiKeyAuth
// @Router /hostconn/block [post]
func (w *WafHostConnApi) BlockApi(c *gin.Context) {
var req request.WafHostGuardManualBanReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage("解析失败: "+err.Error(), c)
return
}
if err := checkBanTargetIP(req.IP); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
if req.BanMinutes < 0 {
response.FailWithMessage("封禁时长不能为负数(0 表示永久封禁)", c)
return
}
if req.Reason == "" {
req.Reason = "管理员从远程连接看板手工封禁"
}
if err := wafHostGuardService.ManualBan(req); err != nil {
response.FailWithMessage("封禁失败: "+err.Error(), c)
return
}
// 封完立刻让下一次查询看到最新状态
wafHostConnService.InvalidateSnapshot()
response.OkWithMessage("已封禁", c)
}
+390
View File
@@ -0,0 +1,390 @@
package api
import (
"SamWaf/model/common/response"
"SamWaf/model/request"
"SamWaf/wafhostguard"
"errors"
"net"
"strings"
"github.com/gin-gonic/gin"
)
type WafHostGuardApi struct {
}
// normalizeHostGuardPage 兜底分页参数,避免 PageSize=0 时算出 Limit(0) 查不到数据
func normalizeHostGuardPage(pageIndex, pageSize *int) {
if *pageIndex <= 0 {
*pageIndex = 1
}
if *pageSize <= 0 {
*pageSize = 20
}
if *pageSize > 500 {
*pageSize = 500
}
}
// checkBanTargetIP 校验封禁目标。只接受单 IP 与 CIDR:
// 这些值最终会进 ipset/netsh/pfctl,通配符、区间这类 WAF 应用层写法在系统层表达不了,
// 必须在 API 层挡住而不是传给底层命令。
func checkBanTargetIP(raw string) error {
ip := strings.TrimSpace(raw)
if ip == "" {
return errors.New("IP不能为空")
}
if strings.Contains(ip, "/") {
if _, _, err := net.ParseCIDR(ip); err != nil {
return errors.New("CIDR格式不正确:" + ip)
}
return nil
}
if net.ParseIP(ip) == nil {
return errors.New("IP格式不正确(系统层封禁只支持单个IP或CIDR网段):" + ip)
}
return nil
}
// GetStatusApi 运行状态
// @Summary 主机防爆破运行状态
// @Description 返回采集是否运行、事件源、工作模式、环境能力与降级原因
// @Tags 主机防爆破
// @Produce json
// @Success 200 {object} response.Response
// @Security ApiKeyAuth
// @Router /hostguard/status [get]
func (w *WafHostGuardApi) GetStatusApi(c *gin.Context) {
response.OkWithDetailed(wafhostguard.GetStatus(), "获取成功", c)
}
// GetStatisticsApi 概览统计
// @Summary 主机防爆破概览统计
// @Tags 主机防爆破
// @Produce json
// @Success 200 {object} response.Response
// @Security ApiKeyAuth
// @Router /hostguard/statistics [get]
func (w *WafHostGuardApi) GetStatisticsApi(c *gin.Context) {
response.OkWithDetailed(wafHostGuardService.GetStatistics(), "获取成功", c)
}
// GetEventListApi 登录失败事件列表
// @Summary 主机登录失败事件列表
// @Tags 主机防爆破
// @Accept json
// @Produce json
// @Param data body request.WafHostGuardEventSearchReq true "查询条件"
// @Success 200 {object} response.Response
// @Security ApiKeyAuth
// @Router /hostguard/event/list [post]
func (w *WafHostGuardApi) GetEventListApi(c *gin.Context) {
var req request.WafHostGuardEventSearchReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage("解析失败: "+err.Error(), c)
return
}
normalizeHostGuardPage(&req.PageIndex, &req.PageSize)
list, total, err := wafHostGuardService.GetEventList(req)
if err != nil {
response.FailWithMessage("查询失败: "+err.Error(), c)
return
}
response.OkWithDetailed(response.PageResult{
List: list,
Total: total,
PageIndex: req.PageIndex,
PageSize: req.PageSize,
}, "查询成功", c)
}
// GetBanListApi 封禁列表
// @Summary 主机防爆破封禁列表
// @Tags 主机防爆破
// @Accept json
// @Produce json
// @Param data body request.WafHostGuardBanSearchReq true "查询条件"
// @Success 200 {object} response.Response
// @Security ApiKeyAuth
// @Router /hostguard/ban/list [post]
func (w *WafHostGuardApi) GetBanListApi(c *gin.Context) {
var req request.WafHostGuardBanSearchReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage("解析失败: "+err.Error(), c)
return
}
normalizeHostGuardPage(&req.PageIndex, &req.PageSize)
list, total, err := wafHostGuardService.GetBanList(req)
if err != nil {
response.FailWithMessage("查询失败: "+err.Error(), c)
return
}
response.OkWithDetailed(response.PageResult{
List: list,
Total: total,
PageIndex: req.PageIndex,
PageSize: req.PageSize,
}, "查询成功", c)
}
// ReleaseBanApi 提前解封
// @Summary 提前解封
// @Tags 主机防爆破
// @Accept json
// @Produce json
// @Param data body request.WafHostGuardBanIdReq true "封禁记录ID"
// @Success 200 {object} response.Response
// @Security ApiKeyAuth
// @Router /hostguard/ban/release [post]
func (w *WafHostGuardApi) ReleaseBanApi(c *gin.Context) {
var req request.WafHostGuardBanIdReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage("解析失败: "+err.Error(), c)
return
}
if strings.TrimSpace(req.Id) == "" {
response.FailWithMessage("参数错误:ID不能为空", c)
return
}
if err := wafHostGuardService.ReleaseBan(req.Id); err != nil {
response.FailWithMessage("解封失败: "+err.Error(), c)
return
}
response.OkWithMessage("已解封", c)
}
// PromoteBanApi 提升为永久封禁
// @Summary 提升为永久封禁
// @Tags 主机防爆破
// @Accept json
// @Produce json
// @Param data body request.WafHostGuardBanIdReq true "封禁记录ID"
// @Success 200 {object} response.Response
// @Security ApiKeyAuth
// @Router /hostguard/ban/permanent [post]
func (w *WafHostGuardApi) PromoteBanApi(c *gin.Context) {
var req request.WafHostGuardBanIdReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage("解析失败: "+err.Error(), c)
return
}
if strings.TrimSpace(req.Id) == "" {
response.FailWithMessage("参数错误:ID不能为空", c)
return
}
if err := wafHostGuardService.PromoteToPermanent(req.Id); err != nil {
response.FailWithMessage("操作失败: "+err.Error(), c)
return
}
response.OkWithMessage("已提升为永久封禁", c)
}
// ManualBanApi 手工封禁
// @Summary 手工封禁一个IP
// @Tags 主机防爆破
// @Accept json
// @Produce json
// @Param data body request.WafHostGuardManualBanReq true "封禁参数"
// @Success 200 {object} response.Response
// @Security ApiKeyAuth
// @Router /hostguard/ban/manual [post]
func (w *WafHostGuardApi) ManualBanApi(c *gin.Context) {
var req request.WafHostGuardManualBanReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage("解析失败: "+err.Error(), c)
return
}
if err := checkBanTargetIP(req.IP); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
if req.BanMinutes < 0 {
response.FailWithMessage("封禁时长不能为负数(0 表示永久封禁)", c)
return
}
if err := wafHostGuardService.ManualBan(req); err != nil {
response.FailWithMessage("封禁失败: "+err.Error(), c)
return
}
response.OkWithMessage("已封禁", c)
}
// GetOffenderListApi 攻击者档案列表
// @Summary 攻击者档案列表
// @Tags 主机防爆破
// @Accept json
// @Produce json
// @Param data body request.WafHostGuardOffenderSearchReq true "查询条件"
// @Success 200 {object} response.Response
// @Security ApiKeyAuth
// @Router /hostguard/offender/list [post]
func (w *WafHostGuardApi) GetOffenderListApi(c *gin.Context) {
var req request.WafHostGuardOffenderSearchReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage("解析失败: "+err.Error(), c)
return
}
normalizeHostGuardPage(&req.PageIndex, &req.PageSize)
list, total, err := wafHostGuardService.GetOffenderList(req)
if err != nil {
response.FailWithMessage("查询失败: "+err.Error(), c)
return
}
response.OkWithDetailed(response.PageResult{
List: list,
Total: total,
PageIndex: req.PageIndex,
PageSize: req.PageSize,
}, "查询成功", c)
}
// ResetOffenderApi 重置攻击者阶梯
// @Summary 重置攻击者阶梯
// @Description 重置后该IP下次被封从第1级重新开始
// @Tags 主机防爆破
// @Accept json
// @Produce json
// @Param data body request.WafHostGuardOffenderIdReq true "档案ID"
// @Success 200 {object} response.Response
// @Security ApiKeyAuth
// @Router /hostguard/offender/reset [post]
func (w *WafHostGuardApi) ResetOffenderApi(c *gin.Context) {
var req request.WafHostGuardOffenderIdReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage("解析失败: "+err.Error(), c)
return
}
if strings.TrimSpace(req.Id) == "" {
response.FailWithMessage("参数错误:ID不能为空", c)
return
}
if err := wafHostGuardService.ResetOffender(req.Id); err != nil {
response.FailWithMessage("重置失败: "+err.Error(), c)
return
}
response.OkWithMessage("已重置该IP的封禁阶梯", c)
}
// DelOffenderApi 删除攻击者档案
// @Summary 删除攻击者档案
// @Tags 主机防爆破
// @Accept json
// @Produce json
// @Param data body request.WafHostGuardOffenderIdReq true "档案ID"
// @Success 200 {object} response.Response
// @Security ApiKeyAuth
// @Router /hostguard/offender/del [post]
func (w *WafHostGuardApi) DelOffenderApi(c *gin.Context) {
var req request.WafHostGuardOffenderIdReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage("解析失败: "+err.Error(), c)
return
}
if strings.TrimSpace(req.Id) == "" {
response.FailWithMessage("参数错误:ID不能为空", c)
return
}
if err := wafHostGuardService.DeleteOffender(req.Id); err != nil {
response.FailWithMessage("删除失败: "+err.Error(), c)
return
}
response.OkWithMessage("已删除", c)
}
// GetLadderApi 取封禁阶梯
// @Summary 获取封禁阶梯配置
// @Tags 主机防爆破
// @Produce json
// @Success 200 {object} response.Response
// @Security ApiKeyAuth
// @Router /hostguard/ladder/list [get]
func (w *WafHostGuardApi) GetLadderApi(c *gin.Context) {
list, err := wafHostGuardService.GetLadders()
if err != nil {
response.FailWithMessage("查询失败: "+err.Error(), c)
return
}
response.OkWithDetailed(list, "查询成功", c)
}
// SaveLadderApi 保存封禁阶梯
// @Summary 保存封禁阶梯配置
// @Description 整表替换,前端一次提交全部级别
// @Tags 主机防爆破
// @Accept json
// @Produce json
// @Param data body request.WafHostGuardLadderEditReq true "阶梯列表"
// @Success 200 {object} response.Response
// @Security ApiKeyAuth
// @Router /hostguard/ladder/save [post]
func (w *WafHostGuardApi) SaveLadderApi(c *gin.Context) {
var req request.WafHostGuardLadderEditReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage("解析失败: "+err.Error(), c)
return
}
if err := wafHostGuardService.SaveLadders(req); err != nil {
response.FailWithMessage("保存失败: "+err.Error(), c)
return
}
response.OkWithMessage("保存成功", c)
}
// TestWhitelistApi 白名单自测
// @Summary 白名单自测
// @Description 输入一个IP,返回它会不会被豁免以及命中了哪一层白名单
// @Tags 主机防爆破
// @Accept json
// @Produce json
// @Param data body request.WafHostGuardWhitelistTestReq true "待测IP"
// @Success 200 {object} response.Response
// @Security ApiKeyAuth
// @Router /hostguard/whitelist/test [post]
func (w *WafHostGuardApi) TestWhitelistApi(c *gin.Context) {
var req request.WafHostGuardWhitelistTestReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage("解析失败: "+err.Error(), c)
return
}
if strings.TrimSpace(req.IP) == "" {
response.FailWithMessage("请输入要检测的IP", c)
return
}
white, reason := wafHostGuardService.TestWhitelist(req.IP)
response.OkWithDetailed(gin.H{
"ip": req.IP,
"whitelisted": white,
"reason": reason,
}, "检测完成", c)
}
// AddWhitelistApi 加入白名单
// @Summary 把IP加入白名单
// @Description 追加到白名单配置,并立即解除该IP当前的封禁
// @Tags 主机防爆破
// @Accept json
// @Produce json
// @Param data body request.WafHostGuardWhitelistAddReq true "IP"
// @Success 200 {object} response.Response
// @Security ApiKeyAuth
// @Router /hostguard/whitelist/add [post]
func (w *WafHostGuardApi) AddWhitelistApi(c *gin.Context) {
var req request.WafHostGuardWhitelistAddReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage("解析失败: "+err.Error(), c)
return
}
if err := checkBanTargetIP(req.IP); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
if err := wafHostGuardService.AddToWhitelist(req.IP); err != nil {
response.FailWithMessage("加入白名单失败: "+err.Error(), c)
return
}
response.OkWithMessage("已加入白名单,该IP当前的封禁也已解除", c)
}
+22
View File
@@ -23,6 +23,7 @@ import (
"SamWaf/wafdb"
"SamWaf/wafenginecore"
"SamWaf/wafenginecore/wafcaptcha"
"SamWaf/wafhostguard"
"SamWaf/wafinit"
"SamWaf/wafipban"
"SamWaf/wafipc"
@@ -381,6 +382,12 @@ func (m *wafSystenService) run() {
// 初始化ip ban
wafipban.InitIPBanManager(global.GCACHE_WAFCACHE)
// 主机登录失败计数器:与上面共用同一个缓存,但 key 前缀独立。
// 不共用 keyspace 是因为上面那套计数会被自定义规则的 MF.GetIPFailureCount 读取,
// 混入 SSH/RDP 的失败会静默改变用户已有 WAF 规则的语义。
wafipban.InitHostLoginFailureManager(global.GCACHE_WAFCACHE)
// 把事件落库能力注入主机防护引擎(反向注入避免 service 与 wafhostguard 循环依赖)
waf_service.WafHostGuardServiceApp.InitEventSink()
//提前初始化
global.GDATA_CURRENT_LOG_DB_MAP = map[string]*gorm.DB{}
@@ -569,6 +576,7 @@ func (m *wafSystenService) run() {
globalobj.GWAF_RUNTIME_OBJ_WAF_TaskRegistry.RegisterTask(enums.TASK_STATS_DATA_CLEANUP, waftask.TaskStatsDataCleanup)
globalobj.GWAF_RUNTIME_OBJ_WAF_TaskRegistry.RegisterTask(enums.TASK_THREAT_IP_SYNC, waftask.TaskThreatIPSync)
globalobj.GWAF_RUNTIME_OBJ_WAF_TaskRegistry.RegisterTask(enums.TASK_ACCESS_CLEAN, waftask.TaskAccessClean)
globalobj.GWAF_RUNTIME_OBJ_WAF_TaskRegistry.RegisterTask(enums.TASK_HOSTGUARD_CLEAN_EXPIRED, waftask.TaskHostGuardCleanExpired)
// 进程启动重放:把各启用威胁情报渠道的快照重新灌入系统 ipset(内存态重启会丢) 并重建 WAF 并集
go waf_service.WafThreatIPServiceApp.RestoreAllOnStartup()
@@ -587,6 +595,9 @@ func (m *wafSystenService) run() {
// cron 任务调度同样是单例(避免新旧 Worker 重复执行定时任务);takeover 模式延迟到 ACTIVATE 再启动。
if !global.GWAF_RUNTIME_IS_TAKEOVER {
globalobj.GWAF_RUNTIME_OBJ_WAF_TaskScheduler.Start()
// 主机登录防护的日志采集同样是独占单例:两个进程同时 tail 同一份 auth.log,
// 同一条失败会被计两次,而 memory 缓存下两边计数器互不可见,阈值等于被腰斩。
wafhostguard.Start()
// 独占单例已在本函数内联启动完毕,这里把 activateOnce 消费掉,
// 使后续任何 ACTIVATE 都成为空操作。Supervisor 重启后"原地认领"存活 Worker 时
// 会补发一次 ACTIVATE(它无从判断该 Worker 当初是否已接管过单例),
@@ -1024,6 +1035,14 @@ func (m *wafSystenService) stopSamWaf() {
time.Sleep(200 * time.Millisecond)
zlog.Info("SamWaf Tasks notified")
// 主机登录防护要早于 cron 与关库停:Stop 里要等事件源交还 fd / journalctl 子进程 /
// Windows 事件订阅句柄,还要把缓冲里的事件与待同步的封禁集合落地。
// 停晚了就会出现"旧 Worker 还在读日志、新 Worker 也开始读"的重复计数。
zlog.Info("Shutdown SamWaf HostGuard...")
wafhostguard.Stop()
waf_service.WafHostGuardServiceApp.FlushEvents()
zlog.Info("Shutdown SamWaf HostGuard finished")
zlog.Info("Shutdown SamWaf Cron...")
globalobj.GWAF_RUNTIME_OBJ_WAF_TaskScheduler.Stop()
zlog.Info("Shutdown SamWaf Cron finished")
@@ -1465,6 +1484,9 @@ func activateSingletons() {
if globalobj.GWAF_RUNTIME_OBJ_WAF_TaskScheduler != nil {
globalobj.GWAF_RUNTIME_OBJ_WAF_TaskScheduler.Start()
}
// 主机登录防护的日志采集也必须等旧 Worker 彻底退出后才启动,
// 否则两个进程同时读同一份 auth.log,失败次数被重复计入,阈值等于打了对折。
wafhostguard.Start()
})
}
+10
View File
@@ -29,4 +29,14 @@ const (
CACHE_ACCESS_OTPFAIL = "CACHE_ACCESS_OTPFAIL_" //OTP 失败计数,防 TOTP 爆破
CACHE_ACCESS_AUDIT = "CACHE_ACCESS_AUDIT_" //审计节流标记,防止 denied 事件把审计表刷爆
CACHE_ACCESS_NOTIFY = "CACHE_ACCESS_NOTIFY_" //通知节流标记,审计表扛得住高频,用户的钉钉/邮箱扛不住
// —— 主机远程登录爆破防护(SSH/RDP) ——
// 失败计数刻意不复用 CACHE_IP_FAILURE_PRE:那个 keyspace 会被自定义规则的
// MF.GetIPFailureCount(minutes) 读取,把 SSH 失败混进去会静默改变用户已有 WAF 规则的语义
// (一个只爆破 SSH 的 IP 会让 HTTP 规则误命中)。
CACHE_HOST_LOGIN_FAIL_PRE = "CACHE_HOST_LOGIN_FAIL_" //主机登录失败计数,键后缀是 source:ip
CACHE_HOST_GUARD_BANNED_PRE = "CACHE_HOST_GUARD_BANNED_" //已封禁去重标记,TTL 取封禁时长,防同一 IP 短时间重复下发
CACHE_HOST_GUARD_ADMIN_PRE = "CACHE_HOST_GUARD_ADMIN_" //活跃管理会话 IP,防误封的最后一道保险
CACHE_HOST_GUARD_RATE = "CACHE_HOST_GUARD_RATE" //每分钟新增封禁数,挡分布式爆破把封禁集合瞬间打满
CACHE_HOST_CONN_SNAPSHOT = "CACHE_HOST_CONN_SNAPSHOT" //远程连接快照,Linux 采集要遍历 /proc,必须带 TTL 缓存
)
+1
View File
@@ -26,4 +26,5 @@ const (
TASK_STATS_DATA_CLEANUP = "task_stats_data_cleanup" //清理统计数据(按保留策略)
TASK_THREAT_IP_SYNC = "task_threat_ip_sync" //威胁情报IP订阅同步
TASK_ACCESS_CLEAN = "task_access_clean" //统一访问认证:清理过期会话/令牌/票据与审计日志
TASK_HOSTGUARD_CLEAN_EXPIRED = "task_hostguard_clean_expired" //主机防爆破:解封到期封禁(每分钟,因最短阶梯只有5分钟)
)
+49
View File
@@ -49,6 +49,55 @@ func (fw *FireWallEngine) RestoreIPSet(setName string, ips []string) error {
return nil
}
// SupportsIncrementalIPSet macOS 支持:pfctl -T add/delete 原生就是增量操作
func (fw *FireWallEngine) SupportsIncrementalIPSet() bool { return true }
// SupportsPortScopedSet macOS 不支持端口级封禁。
// pf 的匹配规则写在 /etc/pf.conf 里(见 SetupPFRule 的提示),由用户手工维护,
// 我们只往 table 里灌地址、不去改用户的规则文件。想限端口请用户自己在 pf.conf 的
// block 规则上加 `port {22, 3389}`。
func (fw *FireWallEngine) SupportsPortScopedSet() bool { return false }
// ApplyIPSetPortScope macOS 空实现,调用方应先判 SupportsPortScopedSet 并提示用户
func (fw *FireWallEngine) ApplyIPSetPortScope(setName string, tcpPorts []int) error { return nil }
// AddToIPSet 向 pf table 增量添加
func (fw *FireWallEngine) AddToIPSet(setName string, ips []string) error {
return fw.incrementalPFTable(setName, ips, "add")
}
// DelFromIPSet 从 pf table 增量删除
func (fw *FireWallEngine) DelFromIPSet(setName string, ips []string) error {
return fw.incrementalPFTable(setName, ips, "delete")
}
// incrementalPFTable 走 stdin 批量传入,避免 IP 数量多时超出命令行长度
func (fw *FireWallEngine) incrementalPFTable(setName string, ips []string, op string) error {
if len(ips) == 0 {
return nil
}
var b strings.Builder
for _, raw := range ips {
ip := strings.TrimSpace(raw)
if ip == "" {
continue
}
b.WriteString(ip)
b.WriteByte('\n')
}
if b.Len() == 0 {
return nil
}
table := pfTableName(setName)
cmd := exec.Command("pfctl", "-t", table, "-T", op, "-f", "-")
cmd.Stdin = strings.NewReader(b.String())
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("pfctl %s 表 %s 失败: %v, 输出: %s", op, table, err, strings.TrimSpace(string(out)))
}
return nil
}
// FlushIPSet 清空 pf table
func (fw *FireWallEngine) FlushIPSet(setName string) error {
cmd := exec.Command("pfctl", "-t", pfTableName(setName), "-T", "flush")
+112
View File
@@ -108,6 +108,79 @@ func (fw *FireWallEngine) ensureDropRule(iptablesBin, setName string) {
fw.runFirewallCmd(iptablesBin, "-I", "INPUT", "1", "-m", "set", "--match-set", setName, "src", "-j", "DROP")
}
// SupportsPortScopedSet 报告本平台能否把集合的封禁范围限制在指定端口上。
// Linux 支持:iptables 的 multiport 匹配可以直接写进那条 match-set 引用规则里。
func (fw *FireWallEngine) SupportsPortScopedSet() bool { return true }
// ApplyIPSetPortScope 调整集合引用规则的作用端口。
//
// ports 为空表示恢复成"封全端口"。用途是让主机防爆破可以只封 SSH/RDP 端口——
// 一旦误封,被挡住的只是远程登录,Web、数据库、业务端口都还通着,
// 排查和自救的余地大得多。
//
// 实现上是"先删掉该集合已有的全部 INPUT 引用规则,再按新范围插一条",
// 而不是叠加:叠加会在反复改端口后留下一串旧规则,其中任何一条命中都照样 DROP,
// 用户会看到"明明改成只封22了,怎么全端口还是不通"。
func (fw *FireWallEngine) ApplyIPSetPortScope(setName string, tcpPorts []int) error {
if err := validateSetName(setName); err != nil {
return err
}
if err := fw.EnsureIPSet(setName); err != nil {
return err
}
fw.applyPortScopeOne("iptables", setName, tcpPorts)
if hasIP6tables() {
fw.applyPortScopeOne("ip6tables", v6SetName(setName), tcpPorts)
}
return nil
}
// applyPortScopeOne 对单个集合(v4 或 v6)重建引用规则
func (fw *FireWallEngine) applyPortScopeOne(iptablesBin, setName string, tcpPorts []int) {
// 反复 -D 直到删不动为止:同一条规则可能因历史操作存在多份
base := []string{"-D", "INPUT", "-m", "set", "--match-set", setName, "src", "-j", "DROP"}
for i := 0; i < 16; i++ {
if _, err := fw.runFirewallCmd(iptablesBin, base...); err != nil {
break
}
}
portSpec := formatMultiport(tcpPorts)
if portSpec != "" {
withPorts := []string{"-D", "INPUT", "-p", "tcp", "-m", "multiport", "--dports", portSpec,
"-m", "set", "--match-set", setName, "src", "-j", "DROP"}
for i := 0; i < 16; i++ {
if _, err := fw.runFirewallCmd(iptablesBin, withPorts...); err != nil {
break
}
}
}
if portSpec == "" {
fw.runFirewallCmd(iptablesBin, "-I", "INPUT", "1", "-m", "set", "--match-set", setName, "src", "-j", "DROP")
return
}
fw.runFirewallCmd(iptablesBin, "-I", "INPUT", "1", "-p", "tcp", "-m", "multiport", "--dports", portSpec,
"-m", "set", "--match-set", setName, "src", "-j", "DROP")
}
// formatMultiport 把端口列表拼成 multiport 参数。
// multiport 最多支持 15 个端口,超出的截断——SSH/RDP 场景下不可能超。
func formatMultiport(ports []int) string {
if len(ports) == 0 {
return ""
}
if len(ports) > 15 {
ports = ports[:15]
}
parts := make([]string, 0, len(ports))
for _, p := range ports {
if p > 0 && p <= 65535 {
parts = append(parts, fmt.Sprintf("%d", p))
}
}
return strings.Join(parts, ",")
}
// RestoreIPSet 用给定 IP/CIDR 列表全量原子重建集合(v4/v6 自动分流)。
// 采用 `ipset restore` 单次 fork:create 临时交换集合→逐条 add→swap 原子替换→destroy 临时集合。
func (fw *FireWallEngine) RestoreIPSet(setName string, ips []string) error {
@@ -155,6 +228,45 @@ func (fw *FireWallEngine) ipsetRestore(payload string) error {
return nil
}
// SupportsIncrementalIPSet 报告本平台是否支持集合的增量增删。
// Linux 支持:`ipset restore` 可以只灌 add/del 行,一次 fork 搞定,不需要重建整个集合。
// 这对主机防爆破很关键——那边是"来一个封一个",用 RestoreIPSet 全量重建会把开销放大成 O(n)。
func (fw *FireWallEngine) SupportsIncrementalIPSet() bool { return true }
// AddToIPSet 向集合增量添加 IP/CIDR(v4/v6 自动分流),-exist 保证重复添加不报错
func (fw *FireWallEngine) AddToIPSet(setName string, ips []string) error {
return fw.incrementalIPSet(setName, ips, "add")
}
// DelFromIPSet 从集合增量删除 IP/CIDR,-exist 保证删不存在的项不报错
func (fw *FireWallEngine) DelFromIPSet(setName string, ips []string) error {
return fw.incrementalIPSet(setName, ips, "del")
}
// incrementalIPSet 拼 restore 脚本做批量增量操作
func (fw *FireWallEngine) incrementalIPSet(setName string, ips []string, op string) error {
if len(ips) == 0 {
return nil
}
if err := fw.EnsureIPSet(setName); err != nil {
return err
}
v4, v6 := splitByIPVersion(ips)
var b strings.Builder
for _, ip := range v4 {
fmt.Fprintf(&b, "%s %s %s\n", op, setName, ip)
}
if hasIP6tables() {
for _, ip := range v6 {
fmt.Fprintf(&b, "%s %s %s\n", op, v6SetName(setName), ip)
}
}
if b.Len() == 0 {
return nil
}
return fw.ipsetRestore(b.String())
}
// FlushIPSet 清空集合内容(保留集合与引用规则)
func (fw *FireWallEngine) FlushIPSet(setName string) error {
if err := validateSetName(setName); err != nil {
+65
View File
@@ -7,7 +7,9 @@ import (
"context"
"fmt"
"os/exec"
"strconv"
"strings"
"sync"
"time"
)
@@ -50,6 +52,62 @@ func setShardRuleName(setName string, seq int) string {
// EnsureIPSet Windows 无需预建集合,空实现(规则在 RestoreIPSet 时创建)
func (fw *FireWallEngine) EnsureIPSet(setName string) error { return nil }
// SupportsIncrementalIPSet Windows 不支持增量。
//
// 分片规则模型里,一个逻辑集合被拆成若干条 netsh 规则,每条的 remoteip= 是一长串逗号
// 分隔的地址。要"删掉其中一个 IP"就得知道它在哪个分片、把那条规则整个重写,还得处理
// 删空后的分片回收——复杂度和出错面都远超收益。调用方应改用"去抖后全量 RestoreIPSet"。
func (fw *FireWallEngine) SupportsIncrementalIPSet() bool { return false }
// AddToIPSet Windows 不支持增量,调用方必须先判 SupportsIncrementalIPSet
func (fw *FireWallEngine) AddToIPSet(setName string, ips []string) error {
return errIncrementalUnsupported
}
// DelFromIPSet Windows 不支持增量,调用方必须先判 SupportsIncrementalIPSet
func (fw *FireWallEngine) DelFromIPSet(setName string, ips []string) error {
return errIncrementalUnsupported
}
var errIncrementalUnsupported = fmt.Errorf("当前系统(Windows)的防火墙不支持集合增量增删,请改用全量重建")
// SupportsPortScopedSet Windows 支持:netsh 规则本来就能带 localport
func (fw *FireWallEngine) SupportsPortScopedSet() bool { return true }
// portScope 记录各集合的端口范围。Windows 没有"独立的引用规则"可改,
// 端口是写在分片规则自身上的,所以只能记下来、在下次 RestoreIPSet 重建时带上。
var portScope sync.Map // setName -> string(逗号分隔端口)
// ApplyIPSetPortScope 设置集合的封禁端口范围。
//
// Windows 下**只记录不重建**:端口写在分片规则自身上,而这个包是无状态的,
// 手里没有当前该封哪些 IP。要让新范围立刻作用到已生效的封禁,
// 必须由持有集合镜像的调用方(wafhostguard.BanExecutor)在调完本方法后
// 再触发一次全量 RestoreIPSet —— 见 BanExecutor.ApplyPortScope。
func (fw *FireWallEngine) ApplyIPSetPortScope(setName string, tcpPorts []int) error {
spec := formatPortList(tcpPorts)
if spec == "" {
portScope.Delete(setName)
} else {
portScope.Store(setName, spec)
}
return nil
}
// formatPortList netsh 的 localport 接受逗号分隔列表
func formatPortList(ports []int) string {
if len(ports) == 0 {
return ""
}
parts := make([]string, 0, len(ports))
for _, p := range ports {
if p > 0 && p <= 65535 {
parts = append(parts, strconv.Itoa(p))
}
}
return strings.Join(parts, ",")
}
// RestoreIPSet 全量重建:先删旧分片规则,再按长度分片重建为若干 netsh block 规则
func (fw *FireWallEngine) RestoreIPSet(setName string, ips []string) error {
// 先清旧
@@ -65,6 +123,13 @@ func (fw *FireWallEngine) RestoreIPSet(setName string, ips []string) error {
ruleName := setShardRuleName(setName, seq)
args := []string{"advfirewall", "firewall", "add", "rule",
"name=" + ruleName, "dir=in", "action=block", "remoteip=" + shard}
// 设置过端口范围就只封那几个端口(主机防爆破的"仅SSH/RDP端口"模式),
// 误封时业务端口不受影响
if spec, ok := portScope.Load(setName); ok {
if s, _ := spec.(string); s != "" {
args = append(args, "protocol=TCP", "localport="+s)
}
}
cmd := exec.Command("netsh", args...)
if err, output := fw.executeCommand(cmd); err != nil {
return fmt.Errorf("添加分片规则 %s 失败: %v, 输出: %s", ruleName, err, output)
+31
View File
@@ -128,4 +128,35 @@ var (
GCONFIG_LOG_FILE_WRITE_MAX_DAYS int64 = 30 // 保留天数
GCONFIG_LOG_FILE_WRITE_COMPRESS int64 = 0 // 是否压缩历史文件 (0关闭 1开启)
// 主机远程登录爆破防护(SSH/RDP)
// 保护的是 SamWaf 所在这台机器自身,与 WAF 引擎(保护 Web 站点)是两件事。
// 默认关闭且默认观察模式 —— 这类功能一旦误封就是"自己 SSH 进不去",不能开箱即封。
GCONFIG_HOST_GUARD_ENABLED int64 = 0 // 主机防爆破总开关 1启用 0禁用
GCONFIG_HOST_GUARD_MODE string = "observe" // 工作模式 observe(只记录不封禁) / block(达阈值即封)
GCONFIG_HOST_GUARD_FIND_TIME int64 = 10 // 失败统计窗口(分钟)
GCONFIG_HOST_GUARD_MAX_RETRY int64 = 8 // 窗口内失败达该次数触发处置
GCONFIG_HOST_GUARD_OFFENDER_RESET_DAY int64 = 7 // 累犯记忆期(天),超过则下次封禁从第1级重新开始
GCONFIG_HOST_GUARD_COUNT_SOFT_FAIL int64 = 0 // 软失败(preauth断连/用户名枚举/PAM行)是否计入阈值 1计入 0不计入
GCONFIG_HOST_GUARD_WHITELIST string = "" // 永不封禁的IP/网段,逗号分隔,支持单IP/CIDR/通配符/区间
GCONFIG_HOST_GUARD_AUTO_LAN int64 = 1 // 是否自动豁免本机网卡IP、环回与常见内网段 1豁免 0不豁免
GCONFIG_HOST_GUARD_LOG_PATHS string = "" // 自定义认证日志路径(逗号分隔),留空自动探测;容器部署的逃生口
GCONFIG_HOST_GUARD_SSH_PORTS string = "" // SSH实际端口(逗号分隔),留空自动发现
GCONFIG_HOST_GUARD_RDP_PORTS string = "" // RDP实际端口(逗号分隔),留空自动发现
GCONFIG_HOST_GUARD_PORT_SCOPE string = "all" // 封禁范围 all(全端口,更安全) / detected(只封SSH/RDP端口)
GCONFIG_HOST_GUARD_EXEC_MODE string = "auto" // 执行方式 auto(平台自适应) / ipset(强制集合) / rule(强制逐条规则)
GCONFIG_HOST_GUARD_DEBOUNCE_SEC int64 = 30 // Windows 去抖合并窗口(秒),Linux/macOS 走增量不受影响
GCONFIG_HOST_GUARD_MAX_BAN_ENTRIES int64 = 10000 // 封禁集合容量上限,超限先淘汰剩余时间最短的临时封禁(永久封不淘汰)
GCONFIG_HOST_GUARD_BAN_RATE_LIMIT int64 = 200 // 每分钟最多新增封禁数,防分布式爆破把集合瞬间打满
GCONFIG_HOST_GUARD_SUBNET_AGGREGATE int64 = 0 // 网段聚合开关,同/24内被封数达阈值则升级封整段。误伤面大,默认关
GCONFIG_HOST_GUARD_SUBNET_THRESHOLD int64 = 10 // 网段聚合触发阈值
GCONFIG_HOST_GUARD_NOTIFY int64 = 1 // 触发封禁时是否发通知(复用「IP封禁」消息类型)
// GCONFIG_HOST_GUARD_FORCE_DISABLE 是自救开关,只能从 conf/config.yml 的
// security.host_guard_force_disable 或环境变量 SAMWAF_HOSTGUARD_DISABLE 设置,管理端改不了。
// 存在的意义:白名单配错把自己的管理IP也封进 iptables 后,SSH 和管理端会同时进不去,
// 那时只剩物理控制台/云厂商VNC,能操作的只有配置文件。
GCONFIG_HOST_GUARD_FORCE_DISABLE bool = false
// 远程连接看板
GCONFIG_HOST_CONN_ENABLED int64 = 1 // 连接看板开关 1启用 0禁用
GCONFIG_HOST_CONN_CACHE_SEC int64 = 3 // 连接快照缓存秒数(Linux采集需遍历/proc,建议不低于3)
)
+6
View File
@@ -8,6 +8,7 @@ import (
"SamWaf/model/common/response"
"SamWaf/service/waf_service"
"SamWaf/utils"
"SamWaf/wafhostguard"
"fmt"
"strings"
"time"
@@ -135,6 +136,11 @@ func Auth() gin.HandlerFunc {
return
}
// 记录"当前正在使用管理端的IP",供主机防爆破豁免。
// 这是防误封的最后一道保险:只要你还在用管理端,你的出口IP就永远进不了封禁名单,
// 哪怕白名单一个字都没配。放在这里是因为走到这说明令牌、指纹、IP绑定都已校验通过。
wafhostguard.TouchAdminIP(currentIP)
//刷新token时间
if global.GWAF_RELEASE == "false" {
tokenList := global.GCACHE_WAFCACHE.ListAvailableKeysWithPrefix(enums.CACHE_TOKEN)
+47
View File
@@ -0,0 +1,47 @@
package model
import (
"SamWaf/model/baseorm"
)
// 封禁状态
const (
HostBanStatusActive = "active" // 生效中
HostBanStatusExpired = "expired" // 到期自动解封
HostBanStatusReleased = "released" // 被手工提前解封
)
// 封禁执行方式
const (
HostBanExecIPSet = "ipset" // 走专用集合(Linux ipset / Windows 分片规则 / macOS pf table)
HostBanExecRule = "rule" // 走逐条防火墙规则
)
// HostGuardBan 主机防爆破的封禁账本。
//
// 刻意不复用 firewall_ip_block:那张表服务的是"手工/站点级逐条封禁",解封走
// UnblockIP 逐条删规则;而防爆破是高频、大批量、集合式封禁,解封只是从集合里删元素。
// 混在一张表里就得改已经稳定的 ClearExpiredRules 分支逻辑,风险不值当。
//
// 记录只置状态不删行:攻击历史要留作取证,也是攻击者档案的佐证。
type HostGuardBan struct {
baseorm.BaseOrm
IP string `gorm:"size:64" json:"ip"` // 被封禁的IP或网段(网段聚合时是 CIDR)
Source string `gorm:"size:16" json:"source"` // ssh / rdp
Level int `json:"level"` // 命中的阶梯级别
BanMinutes int64 `json:"ban_minutes"` // 本次封禁时长(分钟),0=永久
StartTime int64 `json:"start_time"` // 封禁开始(unix秒)
ExpireTime int64 `json:"expire_time"` // 到期时间(unix秒),0=永久
Reason string `gorm:"type:text" json:"reason"` // 封禁原因(中文,直接展示给用户)
Status string `gorm:"size:16" json:"status"` // active / expired / released
ExecMode string `gorm:"size:16" json:"exec_mode"` // ipset / rule
IsSubnet int `json:"is_subnet"` // 1=网段聚合封禁
HitCount int64 `json:"hit_count"` // 触发时窗口内失败次数
Location string `gorm:"size:128" json:"location"` // 归属地
Remarks string `gorm:"size:500" json:"remarks"` // 备注
}
// TableName 表名
func (HostGuardBan) TableName() string {
return "host_guard_ban"
}
+34
View File
@@ -0,0 +1,34 @@
package model
import (
"SamWaf/model/baseorm"
)
// HostGuardBanLadder 阶梯封禁配置。第 N 次被封就用第 N 级的时长,
// 超出最高级则一直用最高级。默认播种 5 级:5分 / 15分 / 60分 / 1天 / 永久。
//
// 做成表而不是配置项,是因为级数本身要能增删——有人只想要两级,
// 有人想要更细的爬坡,写死成几个配置项反而束手束脚。
type HostGuardBanLadder struct {
baseorm.BaseOrm
Level int `json:"level"` // 级别,从 1 开始递增
BanMinutes int64 `json:"ban_minutes"` // 该级封禁时长(分钟),0=永久
Enable int `json:"enable"` // 1=启用 0=禁用(禁用后跳过该级)
Remarks string `gorm:"size:500" json:"remarks"` // 说明
}
// TableName 表名
func (HostGuardBanLadder) TableName() string {
return "host_guard_ban_ladder"
}
// DefaultBanLadders 默认阶梯。迁移时播种,用户可在页面上改。
func DefaultBanLadders() []HostGuardBanLadder {
return []HostGuardBanLadder{
{Level: 1, BanMinutes: 5, Enable: 1, Remarks: "首次触发,多半是自己敲错密码或扫描器路过,短封即可"},
{Level: 2, BanMinutes: 15, Enable: 1, Remarks: "第二次"},
{Level: 3, BanMinutes: 60, Enable: 1, Remarks: "第三次"},
{Level: 4, BanMinutes: 1440, Enable: 1, Remarks: "第四次,封一天"},
{Level: 5, BanMinutes: 0, Enable: 1, Remarks: "惯犯母机,永久封禁(0表示永久)"},
}
}
+28
View File
@@ -0,0 +1,28 @@
package model
import (
"SamWaf/model/baseorm"
)
// HostGuardOffender 攻击者档案(累犯记忆),阶梯递进封禁的依据。
//
// **为什么用表而不是缓存**:缓存默认是进程内内存(GCACHE_TYPE=memory),重启即丢,
// 累犯次数一归零,阶梯就永远停在第 1 级,"5分→15分→60分→1天→永久"的递进形同虚设。
// 而且用户需要能直接看到"这个 IP 被我封过 7 次"。
type HostGuardOffender struct {
baseorm.BaseOrm
IP string `gorm:"size:64" json:"ip"` // 攻击者IP
Source string `gorm:"size:16" json:"source"` // 最近一次的来源 ssh / rdp
BanCount int64 `json:"ban_count"` // 历史累计封禁次数
CurrentLevel int `json:"current_level"` // 当前所处阶梯级别
FirstBanTime int64 `json:"first_ban_time"` // 首次封禁时间(unix秒)
LastBanTime int64 `json:"last_ban_time"` // 最近一次封禁时间(unix秒),记忆期从这里算
TotalFailCount int64 `json:"total_fail_count"` // 累计登录失败次数
LastReason string `gorm:"type:text" json:"last_reason"` // 最近一次封禁原因
Location string `gorm:"size:128" json:"location"` // 归属地
}
// TableName 表名
func (HostGuardOffender) TableName() string {
return "host_guard_offender"
}
+37
View File
@@ -0,0 +1,37 @@
package model
import (
"SamWaf/model/baseorm"
)
// 主机登录失败事件的处置结果
const (
HostEventActionObserve = "observe" // 观察模式:本应处置但只记录
HostEventActionCounted = "counted" // 已计入阈值,尚未触发封禁
HostEventActionSkipped = "skipped" // 白名单豁免或软失败不计数
HostEventActionBanned = "banned" // 本条触发了封禁
)
// HostLoginEvent 主机远程登录(SSH/RDP)失败事件。
//
// 放 log 库:这张表随攻击量线性增长,与 web_logs 同属"可按保留策略清理的观测数据",
// 不该和核心配置挤在一个库里。
type HostLoginEvent struct {
baseorm.BaseOrm
Source string `gorm:"size:16" json:"source"` // ssh / rdp
IP string `gorm:"size:64" json:"ip"` // 来源IP
Port int `json:"port"` // 源端口,0=未知
UserName string `gorm:"size:128" json:"user_name"` // 尝试的用户名
FailKind string `gorm:"size:32" json:"fail_kind"` // 失败类型,见 wafhostguard.FailKind
LogonType string `gorm:"size:8" json:"logon_type"` // 仅 Windows:3=Network 10=RemoteInteractive
Location string `gorm:"size:128" json:"location"` // IP归属地
Action string `gorm:"size:16" json:"action"` // observe / counted / skipped / banned
HitCount int64 `json:"hit_count"` // 触发时窗口内累计次数(仅 banned 行有意义)
RawLine string `gorm:"size:500" json:"raw_line"` // 原始日志行,已截断
EventTime int64 `json:"event_time"` // 事件时间(unix秒)
}
// TableName 表名
func (HostLoginEvent) TableName() string {
return "host_login_event"
}
+11
View File
@@ -0,0 +1,11 @@
package request
// WafHostConnSearchReq 远程连接看板查询
type WafHostConnSearchReq struct {
LocalPort int `json:"local_port" form:"local_port"` // 按本机监听端口筛选,0=不限
State string `json:"state" form:"state"` // ESTABLISHED / LISTEN / ...,留空=不限
RemoteIP string `json:"remote_ip" form:"remote_ip"` // 源IP模糊匹配
OnlyGuard int `json:"only_guard" form:"only_guard"` // 1=只看 SSH/RDP 端口
PageIndex int `json:"pageIndex" form:"pageIndex"`
PageSize int `json:"pageSize" form:"pageSize"`
}
+72
View File
@@ -0,0 +1,72 @@
package request
// WafHostGuardEventSearchReq 登录失败事件查询
type WafHostGuardEventSearchReq struct {
Source string `json:"source" form:"source"` // ssh / rdp
IP string `json:"ip" form:"ip"` // 模糊匹配
UserName string `json:"user_name" form:"user_name"` // 模糊匹配
FailKind string `json:"fail_kind" form:"fail_kind"` //
Action string `json:"action" form:"action"` // observe / counted / skipped / banned
StartTime int64 `json:"start_time" form:"start_time"` // unix秒,0=不限
EndTime int64 `json:"end_time" form:"end_time"`
PageIndex int `json:"pageIndex" form:"pageIndex"`
PageSize int `json:"pageSize" form:"pageSize"`
}
// WafHostGuardBanSearchReq 封禁记录查询
type WafHostGuardBanSearchReq struct {
IP string `json:"ip" form:"ip"`
Source string `json:"source" form:"source"`
Status string `json:"status" form:"status"` // 留空默认只看生效中的
PageIndex int `json:"pageIndex" form:"pageIndex"`
PageSize int `json:"pageSize" form:"pageSize"`
}
// WafHostGuardOffenderSearchReq 攻击者档案查询
type WafHostGuardOffenderSearchReq struct {
IP string `json:"ip" form:"ip"`
Source string `json:"source" form:"source"`
PageIndex int `json:"pageIndex" form:"pageIndex"`
PageSize int `json:"pageSize" form:"pageSize"`
}
// WafHostGuardBanIdReq 按ID操作封禁记录(解封 / 提升为永久)
type WafHostGuardBanIdReq struct {
Id string `json:"id" form:"id"`
}
// WafHostGuardManualBanReq 手工封禁一个IP
type WafHostGuardManualBanReq struct {
IP string `json:"ip" form:"ip"`
Source string `json:"source" form:"source"`
BanMinutes int64 `json:"ban_minutes" form:"ban_minutes"` // 0=永久
Reason string `json:"reason" form:"reason"`
}
// WafHostGuardOffenderIdReq 按ID操作攻击者档案(重置阶梯 / 删除)
type WafHostGuardOffenderIdReq struct {
Id string `json:"id" form:"id"`
}
// WafHostGuardWhitelistTestReq 白名单自测:输入IP看会不会被豁免
type WafHostGuardWhitelistTestReq struct {
IP string `json:"ip" form:"ip"`
}
// WafHostGuardLadderEditReq 阶梯编辑(整表替换,前端一次提交全部行)
type WafHostGuardLadderEditReq struct {
Ladders []WafHostGuardLadderItem `json:"ladders" form:"ladders"`
}
// WafHostGuardLadderItem 单级阶梯
type WafHostGuardLadderItem struct {
Level int `json:"level" form:"level"`
BanMinutes int64 `json:"ban_minutes" form:"ban_minutes"` // 0=永久
Enable int `json:"enable" form:"enable"`
Remarks string `json:"remarks" form:"remarks"`
}
// WafHostGuardWhitelistAddReq 把某IP加入白名单(从事件/封禁列表一键操作)
type WafHostGuardWhitelistAddReq struct {
IP string `json:"ip" form:"ip"`
}
+45
View File
@@ -0,0 +1,45 @@
package response
// HostConnItem 一条远程连接
type HostConnItem struct {
RemoteIP string `json:"remote_ip"` // 对端IP
RemotePort int `json:"remote_port"` // 对端端口
LocalIP string `json:"local_ip"` // 本机IP
LocalPort int `json:"local_port"` // 本机端口
State string `json:"state"` // ESTABLISHED / LISTEN / TIME_WAIT ...
Pid int32 `json:"pid"` //
ProcName string `json:"proc_name"` // 进程名,取不到为空
Location string `json:"location"` // 归属地
IsGuard bool `json:"is_guard"` // 是否命中 SSH/RDP 端口(前端高亮用)
Banned bool `json:"banned"` // 该IP当前是否已被主机防爆破封禁
}
// HostConnPortStat 按本机端口聚合
type HostConnPortStat struct {
Port int `json:"port"`
Count int `json:"count"`
IsGuard bool `json:"is_guard"`
Label string `json:"label"` // SSH / RDP / 其他
}
// HostConnIPStat 按对端IP聚合
type HostConnIPStat struct {
RemoteIP string `json:"remote_ip"`
Count int `json:"count"`
Location string `json:"location"`
}
// HostConnSummary 连接看板汇总
type HostConnSummary struct {
Total int `json:"total"` // 连接总数
Established int `json:"established"` // 已建立连接数
Listen int `json:"listen"` // 监听端口数
GuardConns int `json:"guard_conns"` // 落在 SSH/RDP 端口上的连接数
SSHPorts []int `json:"ssh_ports"` // 探测到的 SSH 端口
RDPPorts []int `json:"rdp_ports"` // 探测到的 RDP 端口
TopPorts []HostConnPortStat `json:"top_ports"` //
TopIPs []HostConnIPStat `json:"top_ips"` //
CollectMs int64 `json:"collect_ms"` // 本次采集耗时(毫秒)
FromCache bool `json:"from_cache"` // 是否命中快照缓存
Unavailable string `json:"unavailable"` // 非空表示采集不可用的中文原因
}
+2
View File
@@ -57,6 +57,8 @@ type ApiGroup struct {
NotifySubscriptionRouter
NotifyLogRouter
FirewallIPBlockRouter
HostGuardRouter
HostConnRouter
ThreatIPRouter
CDNIPRouter
PluginRouter
+20
View File
@@ -0,0 +1,20 @@
package router
import (
"SamWaf/api"
"github.com/gin-gonic/gin"
)
type HostConnRouter struct {
}
func (receiver *HostConnRouter) InitHostConnRouter(group *gin.RouterGroup) {
api := api.APIGroupAPP.WafHostConnApi
router := group.Group("")
router.POST("/api/v1/hostconn/list", api.GetListApi) // 连接列表
router.GET("/api/v1/hostconn/summary", api.GetSummaryApi) // 汇总卡片
router.GET("/api/v1/hostconn/refresh", api.RefreshApi) // 强制刷新快照
router.POST("/api/v1/hostconn/block", api.BlockApi) // 一键封禁来源IP
}
+41
View File
@@ -0,0 +1,41 @@
package router
import (
"SamWaf/api"
"github.com/gin-gonic/gin"
)
type HostGuardRouter struct {
}
func (receiver *HostGuardRouter) InitHostGuardRouter(group *gin.RouterGroup) {
api := api.APIGroupAPP.WafHostGuardApi
router := group.Group("")
// 状态与统计
router.GET("/api/v1/hostguard/status", api.GetStatusApi) // 运行状态与环境能力
router.GET("/api/v1/hostguard/statistics", api.GetStatisticsApi) // 概览统计
// 登录失败事件
router.POST("/api/v1/hostguard/event/list", api.GetEventListApi)
// 封禁账本
router.POST("/api/v1/hostguard/ban/list", api.GetBanListApi)
router.POST("/api/v1/hostguard/ban/release", api.ReleaseBanApi) // 提前解封
router.POST("/api/v1/hostguard/ban/permanent", api.PromoteBanApi) // 提升为永久
router.POST("/api/v1/hostguard/ban/manual", api.ManualBanApi) // 手工封禁
// 攻击者档案
router.POST("/api/v1/hostguard/offender/list", api.GetOffenderListApi)
router.POST("/api/v1/hostguard/offender/reset", api.ResetOffenderApi) // 重置阶梯
router.POST("/api/v1/hostguard/offender/del", api.DelOffenderApi)
// 封禁阶梯
router.GET("/api/v1/hostguard/ladder/list", api.GetLadderApi)
router.POST("/api/v1/hostguard/ladder/save", api.SaveLadderApi)
// 白名单
router.POST("/api/v1/hostguard/whitelist/test", api.TestWhitelistApi) // 自测某IP会否豁免
router.POST("/api/v1/hostguard/whitelist/add", api.AddWhitelistApi)
}
@@ -0,0 +1,326 @@
package waf_service
import (
"SamWaf/common/zlog"
"SamWaf/global"
"SamWaf/model/request"
"SamWaf/model/response"
"SamWaf/utils"
"SamWaf/wafhostguard"
"context"
"sort"
"strings"
"sync"
"time"
gnet "github.com/shirou/gopsutil/v4/net"
"github.com/shirou/gopsutil/v4/process"
)
// 远程连接看板:回答"现在谁连着我这台机器"。
//
// 与爆破防护是**两个独立子系统**——那边看日志判失败登录,这边看内核连接表。
// 之所以不用它来做爆破判定:连接表只能看出"连得频繁",分不清登录成功还是失败,
// 拿来封禁误伤面太大。
//
// **性能是这里的主要约束**:Linux 下 gopsutil 取连接表要遍历 /proc/*/fd 建立
// inode→pid 映射,上万连接时开销可达百毫秒级。所以必须按需采集 + 短 TTL 缓存,
// 绝不能做成高频后台轮询。
type WafHostConnService struct {
mu sync.Mutex
snapshot []response.HostConnItem
takenAt time.Time
collectMs int64
procMu sync.Mutex
procCache map[int32]procEntry
}
type procEntry struct {
name string
at time.Time
}
var WafHostConnServiceApp = &WafHostConnService{procCache: make(map[int32]procEntry)}
const (
// procNameTTL 进程名缓存时长。PID 复用在短时间内极少发生,60 秒足够安全,
// 又能避免每次采集都对上千个 PID 做一次系统调用。
procNameTTL = 60 * time.Second
// collectTimeout 单次采集超时
collectTimeout = 10 * time.Second
// slowCollectWarn 超过这个耗时就告警,提示用户放宽刷新间隔
slowCollectWarn = time.Second
)
// GetList 分页查询连接
func (receiver *WafHostConnService) GetList(req request.WafHostConnSearchReq) ([]response.HostConnItem, int64, response.HostConnSummary, error) {
summary := response.HostConnSummary{}
if global.GCONFIG_HOST_CONN_ENABLED != 1 {
summary.Unavailable = "远程连接看板已在系统配置中关闭"
return nil, 0, summary, nil
}
all, fromCache, err := receiver.getSnapshot()
if err != nil {
summary.Unavailable = "采集连接表失败:" + err.Error()
return nil, 0, summary, err
}
sshPorts, rdpPorts := wafhostguard.GuardPorts()
summary = receiver.buildSummary(all, sshPorts, rdpPorts, fromCache)
// 过滤
filtered := make([]response.HostConnItem, 0, len(all))
for _, item := range all {
if req.LocalPort > 0 && item.LocalPort != req.LocalPort {
continue
}
if req.State != "" && !strings.EqualFold(item.State, req.State) {
continue
}
if req.RemoteIP != "" && !strings.Contains(item.RemoteIP, req.RemoteIP) {
continue
}
if req.OnlyGuard == 1 && !item.IsGuard {
continue
}
filtered = append(filtered, item)
}
total := int64(len(filtered))
// 分页
start := req.PageSize * (req.PageIndex - 1)
if start < 0 || start >= len(filtered) {
return []response.HostConnItem{}, total, summary, nil
}
end := start + req.PageSize
if end > len(filtered) {
end = len(filtered)
}
page := filtered[start:end]
// 归属地只查当前页去重后的 IP:全量查在万级连接时会把 IP 库打爆,
// 而用户一次也只看得到一页
locCache := make(map[string]string, len(page))
out := make([]response.HostConnItem, len(page))
copy(out, page)
for i := range out {
ip := out[i].RemoteIP
if ip == "" {
continue
}
loc, ok := locCache[ip]
if !ok {
loc = utils.FormatIPLocation(ip)
locCache[ip] = loc
}
out[i].Location = loc
out[i].Banned = wafhostguard.GetBanExecutor().Contains(ip)
}
return out, total, summary, nil
}
// GetSummary 只要汇总卡片数据
func (receiver *WafHostConnService) GetSummary() (response.HostConnSummary, error) {
if global.GCONFIG_HOST_CONN_ENABLED != 1 {
return response.HostConnSummary{Unavailable: "远程连接看板已在系统配置中关闭"}, nil
}
all, fromCache, err := receiver.getSnapshot()
if err != nil {
return response.HostConnSummary{Unavailable: "采集连接表失败:" + err.Error()}, err
}
sshPorts, rdpPorts := wafhostguard.GuardPorts()
return receiver.buildSummary(all, sshPorts, rdpPorts, fromCache), nil
}
// getSnapshot 取连接快照,TTL 内直接复用
func (receiver *WafHostConnService) getSnapshot() ([]response.HostConnItem, bool, error) {
ttl := time.Duration(global.GCONFIG_HOST_CONN_CACHE_SEC) * time.Second
if ttl <= 0 {
ttl = 3 * time.Second
}
receiver.mu.Lock()
defer receiver.mu.Unlock()
if receiver.snapshot != nil && time.Since(receiver.takenAt) < ttl {
return receiver.snapshot, true, nil
}
start := time.Now()
items, err := receiver.collect()
if err != nil {
return nil, false, err
}
elapsed := time.Since(start)
receiver.snapshot = items
receiver.takenAt = time.Now()
receiver.collectMs = elapsed.Milliseconds()
if elapsed > slowCollectWarn {
zlog.Warn("[远程连接看板] 采集连接表耗时较长,本机连接数较多,建议调大缓存秒数或放宽刷新间隔",
"耗时", elapsed.Round(time.Millisecond).String(), "连接数", len(items))
}
return items, false, nil
}
// collect 真正采集一次
func (receiver *WafHostConnService) collect() ([]response.HostConnItem, error) {
ctx, cancel := context.WithTimeout(context.Background(), collectTimeout)
defer cancel()
conns, err := gnet.ConnectionsWithContext(ctx, "tcp")
if err != nil {
return nil, err
}
sshPorts, rdpPorts := wafhostguard.GuardPorts()
guard := make(map[int]bool, len(sshPorts)+len(rdpPorts))
for _, p := range sshPorts {
guard[p] = true
}
for _, p := range rdpPorts {
guard[p] = true
}
items := make([]response.HostConnItem, 0, len(conns))
for _, c := range conns {
item := response.HostConnItem{
RemoteIP: c.Raddr.IP,
RemotePort: int(c.Raddr.Port),
LocalIP: c.Laddr.IP,
LocalPort: int(c.Laddr.Port),
State: c.Status,
Pid: c.Pid,
IsGuard: guard[int(c.Laddr.Port)],
}
if c.Pid > 0 {
item.ProcName = receiver.procName(ctx, c.Pid)
}
items = append(items, item)
}
// 已建立的排前面,再按本机端口排序——用户最关心"谁正连着"
sort.SliceStable(items, func(i, j int) bool {
iEst := strings.EqualFold(items[i].State, "ESTABLISHED")
jEst := strings.EqualFold(items[j].State, "ESTABLISHED")
if iEst != jEst {
return iEst
}
if items[i].IsGuard != items[j].IsGuard {
return items[i].IsGuard
}
return items[i].LocalPort < items[j].LocalPort
})
return items, nil
}
// procName 取进程名,带 60 秒缓存
func (receiver *WafHostConnService) procName(ctx context.Context, pid int32) string {
receiver.procMu.Lock()
if e, ok := receiver.procCache[pid]; ok && time.Since(e.at) < procNameTTL {
receiver.procMu.Unlock()
return e.name
}
receiver.procMu.Unlock()
name := ""
if p, err := process.NewProcessWithContext(ctx, pid); err == nil {
if n, err := p.NameWithContext(ctx); err == nil {
name = n
}
}
receiver.procMu.Lock()
// 顺手清理过期条目,避免长期运行后 map 无限增长(PID 会一直变)
if len(receiver.procCache) > 4096 {
for k, v := range receiver.procCache {
if time.Since(v.at) >= procNameTTL {
delete(receiver.procCache, k)
}
}
}
receiver.procCache[pid] = procEntry{name: name, at: time.Now()}
receiver.procMu.Unlock()
return name
}
// buildSummary 汇总卡片
func (receiver *WafHostConnService) buildSummary(items []response.HostConnItem, sshPorts, rdpPorts []int, fromCache bool) response.HostConnSummary {
sum := response.HostConnSummary{
Total: len(items),
SSHPorts: sshPorts,
RDPPorts: rdpPorts,
CollectMs: receiver.collectMs,
FromCache: fromCache,
}
guardLabel := make(map[int]string, len(sshPorts)+len(rdpPorts))
for _, p := range sshPorts {
guardLabel[p] = "SSH"
}
for _, p := range rdpPorts {
guardLabel[p] = "RDP"
}
portCount := make(map[int]int)
ipCount := make(map[string]int)
for _, it := range items {
switch {
case strings.EqualFold(it.State, "ESTABLISHED"):
sum.Established++
case strings.EqualFold(it.State, "LISTEN"):
sum.Listen++
}
if it.IsGuard {
sum.GuardConns++
}
portCount[it.LocalPort]++
if it.RemoteIP != "" && it.RemoteIP != "0.0.0.0" && it.RemoteIP != "::" {
ipCount[it.RemoteIP]++
}
}
for port, cnt := range portCount {
label := guardLabel[port]
if label == "" {
label = "其他"
}
sum.TopPorts = append(sum.TopPorts, response.HostConnPortStat{
Port: port,
Count: cnt,
IsGuard: guardLabel[port] != "",
Label: label,
})
}
sort.Slice(sum.TopPorts, func(i, j int) bool { return sum.TopPorts[i].Count > sum.TopPorts[j].Count })
if len(sum.TopPorts) > 10 {
sum.TopPorts = sum.TopPorts[:10]
}
for ip, cnt := range ipCount {
sum.TopIPs = append(sum.TopIPs, response.HostConnIPStat{RemoteIP: ip, Count: cnt})
}
sort.Slice(sum.TopIPs, func(i, j int) bool { return sum.TopIPs[i].Count > sum.TopIPs[j].Count })
if len(sum.TopIPs) > 10 {
sum.TopIPs = sum.TopIPs[:10]
}
// 只给 Top 榜查归属地,条数固定
for i := range sum.TopIPs {
sum.TopIPs[i].Location = utils.FormatIPLocation(sum.TopIPs[i].RemoteIP)
}
return sum
}
// InvalidateSnapshot 丢弃快照,强制下次重新采集(手工刷新按钮用)
func (receiver *WafHostConnService) InvalidateSnapshot() {
receiver.mu.Lock()
receiver.snapshot = nil
receiver.mu.Unlock()
}
@@ -0,0 +1,520 @@
package waf_service
import (
"SamWaf/common/uuid"
"SamWaf/common/zlog"
"SamWaf/customtype"
"SamWaf/global"
"SamWaf/model"
"SamWaf/model/baseorm"
"SamWaf/model/request"
"SamWaf/wafdb/dialect"
"SamWaf/wafhostguard"
"errors"
"strings"
"sync"
"time"
)
// 主机防爆破的数据服务:事件、封禁账本、攻击者档案、阶梯配置。
type WafHostGuardService struct {
// 事件写入缓冲。爆破高峰下每秒几十上百条,逐条 INSERT 会把加密 SQLite
// 打成慢查询——观察模式尤其明显(所有事件都要落库,一条都不过滤)。
bufMu sync.Mutex
buf []model.HostLoginEvent
flushTimer *time.Timer
}
var WafHostGuardServiceApp = new(WafHostGuardService)
const (
// eventFlushSize 攒够这么多条就写一次
eventFlushSize = 50
// eventFlushInterval 或者最多攒这么久,保证低频时事件也能及时可见
eventFlushInterval = time.Second
)
// InitEventSink 把事件落库能力注入 wafhostguard。
//
// 用注入而不是让 wafhostguard 直接 import service:
// service 层已经引了 wafhostguard(要调封禁/白名单),反过来再引就成环了。
func (receiver *WafHostGuardService) InitEventSink() {
wafhostguard.SetEventSink(receiver.RecordEvent)
}
// RecordEvent 收一条事件进缓冲
func (receiver *WafHostGuardService) RecordEvent(ev model.HostLoginEvent) {
receiver.bufMu.Lock()
receiver.buf = append(receiver.buf, ev)
needFlush := len(receiver.buf) >= eventFlushSize
if receiver.flushTimer == nil {
receiver.flushTimer = time.AfterFunc(eventFlushInterval, func() {
receiver.FlushEvents()
})
}
receiver.bufMu.Unlock()
if needFlush {
receiver.FlushEvents()
}
}
// FlushEvents 把缓冲写库。停止采集时必须调一次,否则最后一批事件会丢。
func (receiver *WafHostGuardService) FlushEvents() {
receiver.bufMu.Lock()
if len(receiver.buf) == 0 {
if receiver.flushTimer != nil {
receiver.flushTimer.Stop()
receiver.flushTimer = nil
}
receiver.bufMu.Unlock()
return
}
batch := receiver.buf
receiver.buf = nil
if receiver.flushTimer != nil {
receiver.flushTimer.Stop()
receiver.flushTimer = nil
}
receiver.bufMu.Unlock()
if global.GWAF_LOCAL_LOG_DB == nil {
return
}
now := time.Now()
for i := range batch {
batch[i].BaseOrm = baseorm.BaseOrm{
Id: uuid.GenUUID(),
USER_CODE: global.GWAF_USER_CODE,
Tenant_ID: global.GWAF_TENANT_ID,
CREATE_TIME: customtype.JsonTime(now),
UPDATE_TIME: customtype.JsonTime(now),
}
}
if err := global.GWAF_LOCAL_LOG_DB.CreateInBatches(batch, eventFlushSize).Error; err != nil {
zlog.Warn("[主机登录防护] 写入登录失败事件失败", "条数", len(batch), "error", err.Error())
}
}
// GetEventList 分页查询登录失败事件
func (receiver *WafHostGuardService) GetEventList(req request.WafHostGuardEventSearchReq) ([]model.HostLoginEvent, int64, error) {
var list []model.HostLoginEvent
var total int64
if global.GWAF_LOCAL_LOG_DB == nil {
return list, 0, nil
}
query := global.GWAF_LOCAL_LOG_DB.Model(&model.HostLoginEvent{})
if req.Source != "" {
query = query.Where("source = ?", req.Source)
}
if req.IP != "" {
query = query.Where("ip LIKE ?", "%"+req.IP+"%")
}
if req.UserName != "" {
query = query.Where("user_name LIKE ?", "%"+req.UserName+"%")
}
if req.FailKind != "" {
query = query.Where("fail_kind = ?", req.FailKind)
}
if req.Action != "" {
query = query.Where("action = ?", req.Action)
}
if req.StartTime > 0 {
query = query.Where("event_time >= ?", req.StartTime)
}
if req.EndTime > 0 {
query = query.Where("event_time <= ?", req.EndTime)
}
query.Count(&total)
err := query.Order("event_time DESC").
Limit(req.PageSize).
Offset(req.PageSize * (req.PageIndex - 1)).
Find(&list).Error
return list, total, err
}
// GetBanList 分页查询封禁记录。status 留空默认只看生效中的——
// 用户点进"封禁列表"想看的是"现在谁被封着",而不是历史流水。
func (receiver *WafHostGuardService) GetBanList(req request.WafHostGuardBanSearchReq) ([]model.HostGuardBan, int64, error) {
var list []model.HostGuardBan
var total int64
query := global.GWAF_LOCAL_DB.Model(&model.HostGuardBan{})
status := req.Status
if status == "" {
status = model.HostBanStatusActive
}
if status != "all" {
query = query.Where(dialect.Q("status")+" = ?", status)
}
if req.IP != "" {
query = query.Where("ip LIKE ?", "%"+req.IP+"%")
}
if req.Source != "" {
query = query.Where("source = ?", req.Source)
}
query.Count(&total)
err := query.Order("start_time DESC").
Limit(req.PageSize).
Offset(req.PageSize * (req.PageIndex - 1)).
Find(&list).Error
return list, total, err
}
// ReleaseBan 手工提前解封
func (receiver *WafHostGuardService) ReleaseBan(id string) error {
var ban model.HostGuardBan
if err := global.GWAF_LOCAL_DB.Where("id = ?", id).First(&ban).Error; err != nil {
return errors.New("封禁记录不存在")
}
if ban.Status != model.HostBanStatusActive {
return errors.New("该记录已不在封禁中")
}
if err := wafhostguard.GetBanExecutor().Apply(nil, []string{ban.IP}); err != nil {
return errors.New("解除系统防火墙封禁失败:" + err.Error())
}
// Windows 走去抖,手工解封要立刻见效,这里强制刷一次
if err := wafhostguard.GetBanExecutor().FlushPending(); err != nil {
zlog.Warn("[主机登录防护] 手工解封时同步防火墙失败", "ip", ban.IP, "error", err.Error())
}
return global.GWAF_LOCAL_DB.Model(&model.HostGuardBan{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"status": model.HostBanStatusReleased,
"remarks": "管理员手工解封",
"update_time": customtype.JsonTime(time.Now()),
}).Error
}
// PromoteToPermanent 把一条临时封禁改成永久
func (receiver *WafHostGuardService) PromoteToPermanent(id string) error {
var ban model.HostGuardBan
if err := global.GWAF_LOCAL_DB.Where("id = ?", id).First(&ban).Error; err != nil {
return errors.New("封禁记录不存在")
}
if ban.Status != model.HostBanStatusActive {
return errors.New("该记录已不在封禁中")
}
return global.GWAF_LOCAL_DB.Model(&model.HostGuardBan{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"expire_time": 0,
"ban_minutes": 0,
"remarks": "管理员手工提升为永久封禁",
"update_time": customtype.JsonTime(time.Now()),
}).Error
}
// ManualBan 手工封禁一个IP(从事件列表或连接看板一键操作)
func (receiver *WafHostGuardService) ManualBan(req request.WafHostGuardManualBanReq) error {
ip := strings.TrimSpace(req.IP)
if ip == "" {
return errors.New("IP不能为空")
}
source := req.Source
if source == "" {
source = wafhostguard.SourceSSH
}
reason := req.Reason
if reason == "" {
reason = "管理员手工封禁"
}
res := wafhostguard.ApplyBan(wafhostguard.BanRequest{
IP: ip,
Source: source,
Manual: true,
ManualMin: req.BanMinutes,
Reason: reason,
}, time.Now())
if !res.Banned {
if res.Skipped != "" {
return errors.New(res.Skipped)
}
return errors.New("封禁未生效")
}
// 手工操作要立刻见效,不等去抖
if err := wafhostguard.GetBanExecutor().FlushPending(); err != nil {
zlog.Warn("[主机登录防护] 手工封禁时同步防火墙失败", "ip", ip, "error", err.Error())
}
return nil
}
// GetOffenderList 分页查询攻击者档案
func (receiver *WafHostGuardService) GetOffenderList(req request.WafHostGuardOffenderSearchReq) ([]model.HostGuardOffender, int64, error) {
var list []model.HostGuardOffender
var total int64
query := global.GWAF_LOCAL_DB.Model(&model.HostGuardOffender{})
if req.IP != "" {
query = query.Where("ip LIKE ?", "%"+req.IP+"%")
}
if req.Source != "" {
query = query.Where("source = ?", req.Source)
}
query.Count(&total)
err := query.Order("last_ban_time DESC").
Limit(req.PageSize).
Offset(req.PageSize * (req.PageIndex - 1)).
Find(&list).Error
return list, total, err
}
// ResetOffender 重置某个攻击者的阶梯(下次封禁从第1级重新开始)
func (receiver *WafHostGuardService) ResetOffender(id string) error {
return global.GWAF_LOCAL_DB.Model(&model.HostGuardOffender{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"ban_count": 0,
"current_level": 0,
"last_reason": "管理员手工重置阶梯",
"update_time": customtype.JsonTime(time.Now()),
}).Error
}
// DeleteOffender 删除攻击者档案
func (receiver *WafHostGuardService) DeleteOffender(id string) error {
return global.GWAF_LOCAL_DB.Where("id = ?", id).Delete(&model.HostGuardOffender{}).Error
}
// GetLadders 取阶梯配置(按级别升序)
func (receiver *WafHostGuardService) GetLadders() ([]model.HostGuardBanLadder, error) {
var list []model.HostGuardBanLadder
err := global.GWAF_LOCAL_DB.Order("level asc").Find(&list).Error
return list, err
}
// SaveLadders 整表替换阶梯配置。
// 用整表替换而不是逐行增删改:阶梯是一个有序整体,级别之间要连续,
// 让前端一次提交完整列表,后端校验后原子替换,比维护单行 CRUD 的一致性简单得多。
func (receiver *WafHostGuardService) SaveLadders(req request.WafHostGuardLadderEditReq) error {
if len(req.Ladders) == 0 {
return errors.New("至少要保留一级封禁阶梯")
}
// 至少要有一级是启用的,否则触发阈值后无从取时长
enabled := 0
for _, l := range req.Ladders {
if l.Enable == 1 {
enabled++
}
if l.BanMinutes < 0 {
return errors.New("封禁时长不能为负数(0 表示永久封禁)")
}
}
if enabled == 0 {
return errors.New("至少要启用一级封禁阶梯,否则达到阈值后无法确定封禁时长")
}
now := time.Now()
tx := global.GWAF_LOCAL_DB.Begin()
if err := tx.Where("1 = 1").Delete(&model.HostGuardBanLadder{}).Error; err != nil {
tx.Rollback()
return err
}
for i, item := range req.Ladders {
level := item.Level
if level <= 0 {
level = i + 1
}
ladder := model.HostGuardBanLadder{
BaseOrm: baseorm.BaseOrm{
Id: uuid.GenUUID(),
USER_CODE: global.GWAF_USER_CODE,
Tenant_ID: global.GWAF_TENANT_ID,
CREATE_TIME: customtype.JsonTime(now),
UPDATE_TIME: customtype.JsonTime(now),
},
Level: level,
BanMinutes: item.BanMinutes,
Enable: item.Enable,
Remarks: item.Remarks,
}
if err := tx.Create(&ladder).Error; err != nil {
tx.Rollback()
return err
}
}
if err := tx.Commit().Error; err != nil {
return err
}
wafhostguard.InvalidateLadder()
return nil
}
// CleanExpiredBans 解封所有到期封禁。由定时任务每分钟调用。
// 记录只置为 expired 不删行——攻击历史要留作取证,也是攻击者档案的佐证。
func (receiver *WafHostGuardService) CleanExpiredBans() (int, error) {
now := time.Now().Unix()
var expired []model.HostGuardBan
err := global.GWAF_LOCAL_DB.
Where(dialect.Q("status")+" = ? AND expire_time > 0 AND expire_time <= ?", model.HostBanStatusActive, now).
Find(&expired).Error
if err != nil {
return 0, err
}
if len(expired) == 0 {
return 0, nil
}
ips := make([]string, 0, len(expired))
ids := make([]string, 0, len(expired))
for _, b := range expired {
ips = append(ips, b.IP)
ids = append(ids, b.Id)
}
if err := wafhostguard.GetBanExecutor().Apply(nil, ips); err != nil {
return 0, err
}
// 到期解封要真的到期就生效,不能再等一个去抖窗口
if err := wafhostguard.GetBanExecutor().FlushPending(); err != nil {
zlog.Warn("[主机登录防护] 解封时同步防火墙失败", "error", err.Error())
}
err = global.GWAF_LOCAL_DB.Model(&model.HostGuardBan{}).
Where("id IN ?", ids).
Updates(map[string]interface{}{
"status": model.HostBanStatusExpired,
"update_time": customtype.JsonTime(time.Now()),
}).Error
if err != nil {
return 0, err
}
zlog.Info("[主机登录防护] 已解封到期IP", "数量", len(ips))
return len(ips), nil
}
// HostGuardStatistics 概览统计
type HostGuardStatistics struct {
Status wafhostguard.Status `json:"status"`
ActiveBans int64 `json:"active_bans"` // 当前生效封禁数
PermanentBans int64 `json:"permanent_bans"` // 其中永久封禁数
TotalBans int64 `json:"total_bans"` // 历史封禁总数
OffenderCount int64 `json:"offender_count"` // 攻击者档案数
Events24h int64 `json:"events_24h"` // 近24小时失败事件数
TopSources []HostGuardTopItem `json:"top_sources"` // Top 攻击源
HourlyTrend []HostGuardTrend `json:"hourly_trend"` // 近24小时趋势
SourceBreakout []HostGuardTopItem `json:"source_breakout"` // 按来源(ssh/rdp)分布
}
// HostGuardTopItem Top 榜条目
type HostGuardTopItem struct {
Name string `json:"name"`
Location string `json:"location"`
Level int `json:"level"`
Count int64 `json:"count"`
}
// HostGuardTrend 趋势点
type HostGuardTrend struct {
Hour string `json:"hour"`
Count int64 `json:"count"`
}
// GetStatistics 概览页数据
func (receiver *WafHostGuardService) GetStatistics() HostGuardStatistics {
st := HostGuardStatistics{Status: wafhostguard.GetStatus()}
if global.GWAF_LOCAL_DB != nil {
global.GWAF_LOCAL_DB.Model(&model.HostGuardBan{}).
Where(dialect.Q("status")+" = ?", model.HostBanStatusActive).Count(&st.ActiveBans)
global.GWAF_LOCAL_DB.Model(&model.HostGuardBan{}).
Where(dialect.Q("status")+" = ? AND expire_time = 0", model.HostBanStatusActive).Count(&st.PermanentBans)
global.GWAF_LOCAL_DB.Model(&model.HostGuardBan{}).Count(&st.TotalBans)
global.GWAF_LOCAL_DB.Model(&model.HostGuardOffender{}).Count(&st.OffenderCount)
var offenders []model.HostGuardOffender
global.GWAF_LOCAL_DB.Model(&model.HostGuardOffender{}).
Order("ban_count DESC, total_fail_count DESC").Limit(10).Find(&offenders)
for _, o := range offenders {
st.TopSources = append(st.TopSources, HostGuardTopItem{
Name: o.IP,
Location: o.Location,
Level: o.CurrentLevel,
Count: o.BanCount,
})
}
}
if global.GWAF_LOCAL_LOG_DB != nil {
since := time.Now().Add(-24 * time.Hour).Unix()
global.GWAF_LOCAL_LOG_DB.Model(&model.HostLoginEvent{}).
Where("event_time >= ?", since).Count(&st.Events24h)
// 按小时分桶。用应用层分桶而不是 SQL 的时间函数,是为了避开
// SQLite/MySQL/PostgreSQL 三种方言在时间函数上的差异。
var events []model.HostLoginEvent
global.GWAF_LOCAL_LOG_DB.Select("event_time", "source").
Where("event_time >= ?", since).Find(&events)
buckets := make(map[string]int64, 24)
bySource := make(map[string]int64, 2)
for _, e := range events {
hour := time.Unix(e.EventTime, 0).Format("2006-01-02 15:00")
buckets[hour]++
bySource[e.Source]++
}
now := time.Now()
for i := 23; i >= 0; i-- {
hour := now.Add(time.Duration(-i) * time.Hour).Format("2006-01-02 15:00")
st.HourlyTrend = append(st.HourlyTrend, HostGuardTrend{Hour: hour, Count: buckets[hour]})
}
for name, cnt := range bySource {
st.SourceBreakout = append(st.SourceBreakout, HostGuardTopItem{Name: name, Count: cnt})
}
}
return st
}
// TestWhitelist 白名单自测:告诉用户某个IP会不会被豁免、命中哪一条
func (receiver *WafHostGuardService) TestWhitelist(ip string) (bool, string) {
return wafhostguard.IsWhitelisted(strings.TrimSpace(ip))
}
// AddToWhitelist 把IP追加进白名单配置(从事件/封禁列表一键操作)。
// 顺带把该IP当前的封禁解掉——用户点"加白名单"的意思就是"这个别封了"。
func (receiver *WafHostGuardService) AddToWhitelist(ip string) error {
ip = strings.TrimSpace(ip)
if ip == "" {
return errors.New("IP不能为空")
}
current := strings.TrimSpace(global.GCONFIG_HOST_GUARD_WHITELIST)
for _, item := range strings.Split(current, ",") {
if strings.TrimSpace(item) == ip {
return errors.New("该IP已在白名单中")
}
}
newValue := ip
if current != "" {
newValue = current + "," + ip
}
err := WafSystemConfigServiceApp.ModifyByItemApi(request.WafSystemConfigEditByItemReq{
Item: "host_guard_whitelist",
Value: newValue,
})
if err != nil {
return err
}
global.GCONFIG_HOST_GUARD_WHITELIST = newValue
wafhostguard.InvalidateWhitelist()
// 已经封着的要立刻放出来
var bans []model.HostGuardBan
global.GWAF_LOCAL_DB.Where("ip = ? AND "+dialect.Q("status")+" = ?", ip, model.HostBanStatusActive).Find(&bans)
for _, b := range bans {
if err := receiver.ReleaseBan(b.Id); err != nil {
zlog.Warn("[主机登录防护] 加白名单后解封失败", "ip", ip, "error", err.Error())
}
}
return nil
}
+16
View File
@@ -283,6 +283,22 @@ func LoadAndInitConfig() {
global.GCONFIG_ACCESS_FORCE_DISABLE = true
}
//配置和提取主机防爆破的强制关闭开关(自救用)
//
//同样只能从配置文件或环境变量设置。适用场景:白名单没覆盖到自己的出口IP,
//被 iptables/netsh 封掉后 SSH 与管理端会同时进不去,那时只剩物理控制台/云厂商VNC,
//能操作的只有配置文件。容器场景走 SAMWAF_HOSTGUARD_DISABLE=1 更方便。
//注意:它只阻止「新的」封禁,已经下发的规则仍在,需要手工 ipset del / netsh delete rule 清掉。
if config.IsSet("security.host_guard_force_disable") {
global.GCONFIG_HOST_GUARD_FORCE_DISABLE = config.GetBool("security.host_guard_force_disable")
} else {
config.Set("security.host_guard_force_disable", global.GCONFIG_HOST_GUARD_FORCE_DISABLE)
configChanged = true
}
if v := strings.TrimSpace(os.Getenv("SAMWAF_HOSTGUARD_DISABLE")); v == "1" || strings.EqualFold(v, "true") {
global.GCONFIG_HOST_GUARD_FORCE_DISABLE = true
}
//配置和提取安全路径入口开关
if config.IsSet("security.entry_enable") {
global.GWAF_SECURITY_ENTRY_ENABLE = config.GetBool("security.entry_enable")
+66
View File
@@ -1658,6 +1658,72 @@ func RunCoreDBMigrations(db *gorm.DB) error {
return nil
},
},
// 迁移: 主机防爆破(SSH/RDP)三张表 —— 封禁账本 / 攻击者档案 / 阶梯配置
// 阶梯默认播种 5 级(5分→15分→60分→1天→永久),用户可在页面上增删改。
{
ID: "202608070002_add_host_guard_tables",
Migrate: func(tx *gorm.DB) error {
zlog.Info("迁移 202608070002: 创建主机防爆破相关表")
if err := tx.AutoMigrate(
&model.HostGuardBan{},
&model.HostGuardOffender{},
&model.HostGuardBanLadder{},
); err != nil {
return fmt.Errorf("创建主机防爆破表失败: %w", err)
}
// (status, expire_time):解封任务每分钟按这两列查,单列索引各只覆盖一半谓词
if err := safeCreateIndex(tx, "host_guard_ban", "idx_hgb_status_expire",
"CREATE INDEX IF NOT EXISTS idx_hgb_status_expire ON host_guard_ban(status, expire_time)"); err != nil {
zlog.Warn("创建索引 idx_hgb_status_expire 失败", "error", err.Error())
}
// (ip, status):判断某IP当前是否已被封
if err := safeCreateIndex(tx, "host_guard_ban", "idx_hgb_ip_status",
"CREATE INDEX IF NOT EXISTS idx_hgb_ip_status ON host_guard_ban(ip, status)"); err != nil {
zlog.Warn("创建索引 idx_hgb_ip_status 失败", "error", err.Error())
}
// (start_time):封禁列表按时间倒序分页
if err := safeCreateIndex(tx, "host_guard_ban", "idx_hgb_start_time",
"CREATE INDEX IF NOT EXISTS idx_hgb_start_time ON host_guard_ban(start_time)"); err != nil {
zlog.Warn("创建索引 idx_hgb_start_time 失败", "error", err.Error())
}
// 档案按 IP 查(阶梯决策每次封禁都要查一次,必须走索引)
if err := safeCreateIndex(tx, "host_guard_offender", "idx_hgo_ip",
"CREATE INDEX IF NOT EXISTS idx_hgo_ip ON host_guard_offender(ip)"); err != nil {
zlog.Warn("创建索引 idx_hgo_ip 失败", "error", err.Error())
}
// 播种默认阶梯:先计数,避免重复执行(老库回滚重跑时)把阶梯播成两份
var count int64
tx.Model(&model.HostGuardBanLadder{}).Count(&count)
if count > 0 {
zlog.Info("封禁阶梯已存在,跳过播种", "count", count)
return nil
}
for _, ladder := range model.DefaultBanLadders() {
ladder.BaseOrm = baseorm.BaseOrm{
Id: uuid.GenUUID(),
USER_CODE: global.GWAF_USER_CODE,
Tenant_ID: global.GWAF_TENANT_ID,
CREATE_TIME: customtype.JsonTime(time.Now()),
UPDATE_TIME: customtype.JsonTime(time.Now()),
}
if err := tx.Create(&ladder).Error; err != nil {
return fmt.Errorf("播种封禁阶梯(第%d级)失败: %w", ladder.Level, err)
}
}
zlog.Info("主机防爆破表创建成功,默认阶梯已播种")
return nil
},
Rollback: func(tx *gorm.DB) error {
zlog.Info("回滚 202608070002: 删除主机防爆破相关表")
return tx.Migrator().DropTable(
&model.HostGuardBan{},
&model.HostGuardOffender{},
&model.HostGuardBanLadder{},
)
},
},
})
// 执行迁移
+32
View File
@@ -317,6 +317,38 @@ func RunLogDBMigrations(db *gorm.DB) error {
)
},
},
// 迁移: 创建主机远程登录(SSH/RDP)失败事件表
// 放 log 库是因为它随攻击量线性增长,属于可按保留策略清理的观测数据。
{
ID: "202608070001_add_host_login_event_table",
Migrate: func(tx *gorm.DB) error {
zlog.Info("迁移 202608070001: 创建主机登录失败事件表")
if err := tx.AutoMigrate(&model.HostLoginEvent{}); err != nil {
return fmt.Errorf("创建主机登录失败事件表失败: %w", err)
}
// (ip, event_time):单IP攻击历史下钻
if err := safeCreateIndex(tx, "host_login_event", "idx_hle_ip_time",
"CREATE INDEX IF NOT EXISTS idx_hle_ip_time ON host_login_event(ip, event_time)"); err != nil {
zlog.Warn("创建索引 idx_hle_ip_time 失败", "error", err.Error())
}
// (source, event_time):按来源分页 + 时间范围
if err := safeCreateIndex(tx, "host_login_event", "idx_hle_src_time",
"CREATE INDEX IF NOT EXISTS idx_hle_src_time ON host_login_event(source, event_time)"); err != nil {
zlog.Warn("创建索引 idx_hle_src_time 失败", "error", err.Error())
}
// (event_time):全局时间范围与保留期清理
if err := safeCreateIndex(tx, "host_login_event", "idx_hle_event_time",
"CREATE INDEX IF NOT EXISTS idx_hle_event_time ON host_login_event(event_time)"); err != nil {
zlog.Warn("创建索引 idx_hle_event_time 失败", "error", err.Error())
}
zlog.Info("主机登录失败事件表创建成功")
return nil
},
Rollback: func(tx *gorm.DB) error {
zlog.Info("回滚 202608070001: 删除主机登录失败事件表")
return tx.Migrator().DropTable(&model.HostLoginEvent{})
},
},
})
// 执行迁移
+40
View File
@@ -543,6 +543,46 @@ func RunTaskInitMigrations(db *gorm.DB) error {
return tx.Where("task_method = ?", enums.TASK_ACCESS_CLEAN).Delete(&model.Task{}).Error
},
},
// 迁移: 主机防爆破的到期解封任务
// 1 分钟一次:阶梯最短一级只有 5 分钟,沿用防火墙那个 5 分钟粒度的话,
// 用户会看到"明明写着封5分钟,实际封了10分钟"。
{
ID: "202608070003_add_hostguard_clean_task",
Migrate: func(tx *gorm.DB) error {
zlog.Info("迁移 202608070003: 创建主机防爆破解封任务")
var count int64
tx.Model(&model.Task{}).Where("task_method = ?", enums.TASK_HOSTGUARD_CLEAN_EXPIRED).Count(&count)
if count > 0 {
zlog.Info("主机防爆破解封任务已存在,跳过", "task_method", enums.TASK_HOSTGUARD_CLEAN_EXPIRED)
return nil
}
task := model.Task{
BaseOrm: baseorm.BaseOrm{
Id: uuid.GenUUID(),
USER_CODE: global.GWAF_USER_CODE,
Tenant_ID: global.GWAF_TENANT_ID,
CREATE_TIME: customtype.JsonTime(time.Now()),
UPDATE_TIME: customtype.JsonTime(time.Now()),
},
TaskName: "每1分钟解封主机防爆破中已到期的IP",
TaskUnit: enums.TASK_MIN,
TaskValue: 1,
TaskAt: "",
TaskMethod: enums.TASK_HOSTGUARD_CLEAN_EXPIRED,
}
if err := tx.Create(&task).Error; err != nil {
return fmt.Errorf("创建主机防爆破解封任务失败: %w", err)
}
zlog.Info("主机防爆破解封任务创建成功")
return nil
},
Rollback: func(tx *gorm.DB) error {
zlog.Info("回滚 202608070003: 删除主机防爆破解封任务")
return tx.Where("task_method = ?", enums.TASK_HOSTGUARD_CLEAN_EXPIRED).Delete(&model.Task{}).Error
},
},
})
// 执行迁移
+384
View File
@@ -0,0 +1,384 @@
package wafhostguard
import (
"SamWaf/common/uuid"
"SamWaf/common/zlog"
"SamWaf/customtype"
"SamWaf/enums"
"SamWaf/global"
"SamWaf/innerbean"
"SamWaf/model"
"SamWaf/model/baseorm"
"SamWaf/utils"
"fmt"
"time"
)
// 处置执行:把"这个 IP 该封了"变成数据库记录 + 系统防火墙规则 + 一条通知。
// BanRequest 一次封禁请求
type BanRequest struct {
IP string
Source string
HitCount int64
FailKinds string // 触发时窗口内出现过的失败类型/用户名摘要,写进原因文本
Manual bool // 手工封禁(来自页面操作),不走阈值也不受观察模式限制
ManualMin int64 // 手工封禁时长(分钟),0=永久
Reason string // 手工封禁时的自定义原因
}
// BanResult 封禁结果
type BanResult struct {
Banned bool // 是否真的下发了封禁
Observed bool // 观察模式下"本应封禁但没动手"
Skipped string // 未封禁的原因(白名单/限速/容量满/已封禁)
Level int
BanMinutes int64
ExpireTime int64
IsSubnet bool
TargetIP string // 实际封禁目标(网段聚合时是 CIDR)
}
// ApplyBan 执行一次封禁决策。
//
// 顺序刻意如此:白名单 -> 已封禁去重 -> 限速 -> 容量 -> 阶梯 -> 网段聚合 -> 落库 -> 下发 -> 通知。
// 前四道闸都在"动系统防火墙"之前,任何一道拦下都不会留下需要回滚的副作用。
func ApplyBan(req BanRequest, now time.Time) BanResult {
res := BanResult{TargetIP: req.IP}
// 硬开关:conf/config.yml 或环境变量强制关闭时,连手工封禁也不放行——
// 这个开关的使用场景就是"出事了,先让它彻底别动手"。
if global.GCONFIG_HOST_GUARD_FORCE_DISABLE {
res.Skipped = "已被配置文件强制关闭主机防爆破"
return res
}
if !req.Manual {
if white, reason := IsWhitelisted(req.IP); white {
res.Skipped = "白名单豁免(" + reason + ")"
return res
}
}
exec := GetBanExecutor()
if exec.Contains(req.IP) {
res.Skipped = "该IP已在封禁中"
return res
}
if !req.Manual {
if !allowBanRate() {
res.Skipped = "已达每分钟新增封禁上限,本次跳过"
zlog.Warn("[主机登录防护] 触发封禁限速,可能正在遭遇分布式爆破",
"ip", req.IP, "上限", global.GCONFIG_HOST_GUARD_BAN_RATE_LIMIT)
return res
}
if !ensureCapacity(now) {
res.Skipped = "封禁集合已达容量上限且无可淘汰的临时封禁"
zlog.Warn("[主机登录防护] 封禁集合已满,请在页面上清理永久封禁或调大容量上限",
"上限", global.GCONFIG_HOST_GUARD_MAX_BAN_ENTRIES)
return res
}
}
// 阶梯决策
offender := loadOffender(req.IP)
var level int
var banMinutes int64
var banCount int64
if req.Manual {
level = 0
banMinutes = req.ManualMin
banCount = 1
if offender != nil {
banCount = offender.BanCount + 1
}
} else {
d := DecideLadder(offender, now)
level, banMinutes, banCount = d.Level, d.BanMinutes, d.BanCount
if d.Reset {
zlog.Debug("[主机登录防护] 该IP超过累犯记忆期,阶梯重置", "ip", req.IP)
}
}
res.Level = level
res.BanMinutes = banMinutes
// 网段聚合(默认关)
target := req.IP
if !req.Manual {
if cidr, agg := ShouldAggregateSubnet(req.IP, now); agg {
target = cidr
res.IsSubnet = true
zlog.Warn("[主机登录防护] 同网段被封IP数达到阈值,升级为整段封禁", "网段", cidr)
}
}
res.TargetIP = target
var expireTime int64
if banMinutes > 0 {
expireTime = now.Add(time.Duration(banMinutes) * time.Minute).Unix()
}
res.ExpireTime = expireTime
reason := req.Reason
if reason == "" {
reason = buildReason(req, level, banMinutes)
}
// 观察模式:到此为止,只记录不动手。
// 这是整个功能的安全底线,必须保证一行防火墙规则都不写、一条通知都不发
//(发了通知用户会以为真封了)。
if !req.Manual && global.GCONFIG_HOST_GUARD_MODE != "block" {
res.Observed = true
zlog.Info(fmt.Sprintf("[主机登录防护][观察模式] 本应封禁 %s 第%d级%s(%s),当前仅记录不处置",
target, level, humanDuration(banMinutes), reason))
return res
}
// 落库在下发之前:先有账本再有规则。
// 反过来的话,写库失败会留下一条"防火墙里有、数据库里没有"的孤儿规则,
// 到期没人解封,用户还查不到它是谁封的。
ban := model.HostGuardBan{
BaseOrm: baseorm.BaseOrm{
Id: uuid.GenUUID(),
USER_CODE: global.GWAF_USER_CODE,
Tenant_ID: global.GWAF_TENANT_ID,
CREATE_TIME: customtype.JsonTime(now),
UPDATE_TIME: customtype.JsonTime(now),
},
IP: target,
Source: req.Source,
Level: level,
BanMinutes: banMinutes,
StartTime: now.Unix(),
ExpireTime: expireTime,
Reason: reason,
Status: model.HostBanStatusActive,
ExecMode: exec.ExecMode(),
HitCount: req.HitCount,
Location: utils.FormatIPLocation(req.IP),
}
if res.IsSubnet {
ban.IsSubnet = 1
}
if global.GWAF_LOCAL_DB != nil {
if err := global.GWAF_LOCAL_DB.Create(&ban).Error; err != nil {
zlog.Error("[主机登录防护] 写入封禁记录失败,已放弃本次封禁", "ip", target, "error", err.Error())
res.Skipped = "写入封禁记录失败:" + err.Error()
return res
}
}
if err := exec.Apply([]string{target}, nil); err != nil {
// 下发失败就把账本回滚掉,不留"库里说封了、实际没封"的假象
if global.GWAF_LOCAL_DB != nil {
global.GWAF_LOCAL_DB.Model(&model.HostGuardBan{}).
Where("id = ?", ban.Id).
Updates(map[string]interface{}{"status": model.HostBanStatusReleased, "remarks": "下发系统防火墙失败:" + err.Error()})
}
zlog.Error("[主机登录防护] 下发系统防火墙失败", "ip", target, "error", err.Error())
res.Skipped = "下发系统防火墙失败:" + err.Error()
return res
}
upsertOffender(req, target, level, banCount, reason, now)
markBanned(target, banMinutes)
res.Banned = true
zlog.Info("[主机登录防护] 已封禁", "ip", target, "来源", req.Source,
"阶梯", level, "时长", humanDuration(banMinutes), "窗口内失败次数", req.HitCount)
notifyBan(target, reason, banMinutes, expireTime, now)
return res
}
// buildReason 生成给人看的封禁原因
func buildReason(req BanRequest, level int, banMinutes int64) string {
src := "SSH"
if req.Source == SourceRDP {
src = "RDP"
}
reason := fmt.Sprintf("%s登录爆破:%d分钟内失败%d次", src, global.GCONFIG_HOST_GUARD_FIND_TIME, req.HitCount)
if req.FailKinds != "" {
reason += "(" + req.FailKinds + ")"
}
reason += fmt.Sprintf(",第%d级封禁%s", level, humanDuration(banMinutes))
return reason
}
// humanDuration 把分钟数说成人话
func humanDuration(minutes int64) string {
switch {
case minutes <= 0:
return "永久"
case minutes < 60:
return fmt.Sprintf("%d分钟", minutes)
case minutes < 1440:
return fmt.Sprintf("%d小时", minutes/60)
default:
return fmt.Sprintf("%d天", minutes/1440)
}
}
// loadOffender 读攻击者档案,没有返回 nil
func loadOffender(ip string) *model.HostGuardOffender {
if global.GWAF_LOCAL_DB == nil {
return nil
}
var o model.HostGuardOffender
err := global.GWAF_LOCAL_DB.Where("ip = ?", ip).First(&o).Error
if err != nil {
return nil
}
return &o
}
// upsertOffender 更新/新建攻击者档案。累犯次数是阶梯递进的唯一依据,必须持久化。
func upsertOffender(req BanRequest, target string, level int, banCount int64, reason string, now time.Time) {
if global.GWAF_LOCAL_DB == nil {
return
}
// 档案按真实来源 IP 记,不按聚合后的网段——否则整段封一次会把网段本身
// 变成一个"攻击者",而真正那个 IP 的累犯次数反倒不增长了
ip := req.IP
existing := loadOffender(ip)
if existing == nil {
o := model.HostGuardOffender{
BaseOrm: baseorm.BaseOrm{
Id: uuid.GenUUID(),
USER_CODE: global.GWAF_USER_CODE,
Tenant_ID: global.GWAF_TENANT_ID,
CREATE_TIME: customtype.JsonTime(now),
UPDATE_TIME: customtype.JsonTime(now),
},
IP: ip,
Source: req.Source,
BanCount: banCount,
CurrentLevel: level,
FirstBanTime: now.Unix(),
LastBanTime: now.Unix(),
TotalFailCount: req.HitCount,
LastReason: reason,
Location: utils.FormatIPLocation(ip),
}
if err := global.GWAF_LOCAL_DB.Create(&o).Error; err != nil {
zlog.Warn("[主机登录防护] 创建攻击者档案失败", "ip", ip, "error", err.Error())
}
return
}
err := global.GWAF_LOCAL_DB.Model(&model.HostGuardOffender{}).
Where("id = ?", existing.Id).
Updates(map[string]interface{}{
"source": req.Source,
"ban_count": banCount,
"current_level": level,
"last_ban_time": now.Unix(),
"total_fail_count": existing.TotalFailCount + req.HitCount,
"last_reason": reason,
"update_time": customtype.JsonTime(now),
}).Error
if err != nil {
zlog.Warn("[主机登录防护] 更新攻击者档案失败", "ip", ip, "error", err.Error())
}
}
// markBanned 打去重标记,TTL 与封禁时长一致(永久封给 24 小时,靠 executor 的内存镜像兜底)
func markBanned(ip string, banMinutes int64) {
if global.GCACHE_WAFCACHE == nil {
return
}
ttl := 24 * time.Hour
if banMinutes > 0 {
ttl = time.Duration(banMinutes) * time.Minute
}
global.GCACHE_WAFCACHE.SetWithTTl(enums.CACHE_HOST_GUARD_BANNED_PRE+ip, 1, ttl)
}
// allowBanRate 每分钟新增封禁限速。
// 分布式爆破一次能带来成百上千个源 IP,不限速的话集合瞬间被打满,
// 后面真正该封的反而挤不进来。
func allowBanRate() bool {
limit := global.GCONFIG_HOST_GUARD_BAN_RATE_LIMIT
if limit <= 0 || global.GCACHE_WAFCACHE == nil {
return true
}
key := enums.CACHE_HOST_GUARD_RATE + ":" + time.Now().Format("200601021504")
curInt, _ := global.GCACHE_WAFCACHE.GetInt(key) // 键不存在时返回 0,正是想要的初值
cur := int64(curInt)
if cur >= limit {
return false
}
// 窗口是"当前这一分钟",给 2 分钟 TTL 让它自然过期
global.GCACHE_WAFCACHE.SetWithTTl(key, int(cur+1), 2*time.Minute)
return true
}
// ensureCapacity 保证集合还装得下。超限时淘汰"剩余时间最短的临时封禁"——
// 它们本来也马上就要解封了,牺牲它们的代价最小;永久封禁一律不动,
// 那是用户明确表达过"这个必须一直封着"的意思。
func ensureCapacity(now time.Time) bool {
limit := global.GCONFIG_HOST_GUARD_MAX_BAN_ENTRIES
if limit <= 0 {
return true
}
exec := GetBanExecutor()
if int64(exec.Count()) < limit {
return true
}
if global.GWAF_LOCAL_DB == nil {
return false
}
// 一次多淘汰几个,避免每封一个就查一次库
const evictBatch = 16
var victims []model.HostGuardBan
err := global.GWAF_LOCAL_DB.
Where("status = ? AND expire_time > 0", model.HostBanStatusActive).
Order("expire_time asc").
Limit(evictBatch).
Find(&victims).Error
if err != nil || len(victims) == 0 {
return false
}
ips := make([]string, 0, len(victims))
ids := make([]string, 0, len(victims))
for _, v := range victims {
ips = append(ips, v.IP)
ids = append(ids, v.Id)
}
if err := exec.Apply(nil, ips); err != nil {
zlog.Warn("[主机登录防护] 容量淘汰时解封失败", "error", err.Error())
}
global.GWAF_LOCAL_DB.Model(&model.HostGuardBan{}).
Where("id in ?", ids).
Updates(map[string]interface{}{
"status": model.HostBanStatusReleased,
"remarks": "封禁集合达到容量上限,按剩余时间最短优先淘汰",
})
zlog.Warn("[主机登录防护] 封禁集合达到容量上限,已淘汰部分临时封禁", "淘汰数", len(ips))
return true
}
// notifyBan 发封禁通知。复用已有的「IP封禁」消息类型,模板/频控/多渠道全都是现成的。
func notifyBan(ip, reason string, banMinutes, expireTime int64, now time.Time) {
if global.GCONFIG_HOST_GUARD_NOTIFY != 1 || global.GQEQUE_MESSAGE_DB == nil {
return
}
remaining := 0
if expireTime > 0 {
remaining = int(expireTime - now.Unix())
}
global.GQEQUE_MESSAGE_DB.Enqueue(innerbean.IPBanMessageInfo{
BaseMessageInfo: innerbean.BaseMessageInfo{
OperaType: "主机远程登录爆破封禁",
Server: global.GWAF_CUSTOM_SERVER_NAME,
},
Ip: ip,
Reason: reason + "。如误封需手工解除:Linux 执行 `ipset del " + BanSetName + " " + ip + "`,Windows 在高级安全防火墙中删除对应规则",
Duration: int(banMinutes),
RemainingSeconds: remaining,
Time: now.Format("2006-01-02 15:04:05"),
})
}
+83
View File
@@ -0,0 +1,83 @@
package wafhostguard
import (
"SamWaf/firewall"
"SamWaf/global"
"sync"
"time"
)
// 环境能力探测。仿 firewall/available.go 的做法:结果带 30 秒缓存,
// 返回的 reason 是**可以直接展示给用户的中文**,而不是英文错误码——
// 用户看到"当前进程无权读取 /var/log/secure,请以 root 身份运行"能立刻知道怎么办,
// 看到 "permission denied" 只会来提 issue。
const capabilityCacheTTL = 30 * time.Second
// Capability 环境能力快照
type Capability struct {
// LogReadable 能否读到认证日志(Linux)/安全事件日志(Windows)
LogReadable bool
// LogReason LogReadable=false 时的中文原因
LogReason string
// FirewallReady 系统防火墙是否可用(不可用时自动降级为观察模式)
FirewallReady bool
// FirewallReason FirewallReady=false 时的中文原因
FirewallReason string
// InContainer 是否运行在容器里(容器内看不到宿主机日志,提示文案要不一样)
InContainer bool
// CheckedAt 探测时刻
CheckedAt time.Time
}
// Degraded 报告是否处于降级状态(能采集但封不了,或压根采集不了)
func (c Capability) Degraded() bool {
return !c.LogReadable || !c.FirewallReady
}
var (
capMu sync.Mutex
capCache *Capability
capCached time.Time
)
// GetCapability 取能力快照,带 30 秒缓存
func GetCapability() Capability {
capMu.Lock()
defer capMu.Unlock()
if capCache != nil && time.Since(capCached) < capabilityCacheTTL {
return *capCache
}
c := Capability{CheckedAt: time.Now()}
c.LogReadable, c.LogReason, c.InContainer = checkLogCapability()
var fw firewall.FireWallEngine
if err := fw.CheckAvailable(); err != nil {
c.FirewallReady = false
c.FirewallReason = err.Error()
} else {
c.FirewallReady = true
}
capCache = &c
capCached = time.Now()
return c
}
// InvalidateCapability 丢弃缓存,强制下次重新探测
func InvalidateCapability() {
capMu.Lock()
capCache = nil
capMu.Unlock()
}
// globalLogPaths 读用户自定义的日志路径配置(逗号分隔)。
// 单独包一层是为了让平台文件不用直接引 global 包,测试时也好替换。
func globalLogPaths() string {
return global.GCONFIG_HOST_GUARD_LOG_PATHS
}
// checkLogCapability 由各平台文件实现(capability_linux.go / capability_windows.go /
// capability_other.go):能否读到登录失败日志。
// 返回 (可读, 不可读时的中文原因, 是否在容器内)
+84
View File
@@ -0,0 +1,84 @@
//go:build linux
package wafhostguard
import (
"os"
"os/exec"
"strings"
)
// 容器提示。与 firewall/firewall.go 的 containerHint 同源,但内容不同:
// 那边讲的是"封不了",这边讲的是"看不见"——容器里的 /var/log 是容器自己的视图,
// 宿主机上谁在爆破 SSH,容器内一无所知。
const containerLogHint = "。当前运行在容器中:容器内的 /var/log 是容器自己的视图,看不到宿主机的 SSH 登录日志。" +
"请以 `-v /var/log:/host/var/log:ro` 把宿主机日志只读挂载进来,并在【自定义日志路径】里填 /host/var/log/secure(Debian/Ubuntu 填 auth.log);" +
"若还要真正封禁,容器另需 `--cap-add=NET_ADMIN --network host`"
// checkLogCapability Linux:按 探测顺序 检查是否有可用的事件源
func checkLogCapability() (bool, string, bool) {
inContainer := isInContainer()
// 1. 用户显式指定了路径就只认这个——这是容器场景唯一的逃生口,
// 指错了要明确报错,不能悄悄回落到自动探测让用户以为配好了
if paths := splitList(globalLogPaths()); len(paths) > 0 {
for _, p := range paths {
if readable(p) {
return true, "", inContainer
}
}
msg := "已配置的自定义日志路径都不可读:" + strings.Join(paths, ", ") + ",请检查路径是否正确、当前进程是否有读权限(通常需要 root)"
if inContainer {
msg += containerLogHint
}
return false, msg, inContainer
}
// 2. 自动探测常见路径
for _, p := range defaultAuthLogPaths {
if readable(p) {
return true, "", inContainer
}
}
// 3. journald-only 的发行版(如较新的 Fedora/Arch,或裁掉了 rsyslog 的最小化镜像)
if _, err := exec.LookPath("journalctl"); err == nil {
return true, "", inContainer
}
msg := "未找到可读的系统认证日志:/var/log/secure、/var/log/auth.log 均不存在或无权读取,且系统里没有 journalctl。" +
"若 SamWaf 不是以 root 运行,请改为 root 后重试;若日志在非标准路径,请在【自定义日志路径】里指定"
if inContainer {
msg += containerLogHint
}
return false, msg, inContainer
}
// readable 文件存在且当前进程能打开读
func readable(path string) bool {
if path == "" {
return false
}
f, err := os.Open(path)
if err != nil {
return false
}
_ = f.Close()
return true
}
// isInContainer 复刻 firewall/firewall.go 的判定:/.dockerenv 或 cgroup 里带容器标记
func isInContainer() bool {
if _, err := os.Stat("/.dockerenv"); err == nil {
return true
}
data, err := os.ReadFile("/proc/1/cgroup")
if err != nil {
return false
}
content := string(data)
return strings.Contains(content, "docker") ||
strings.Contains(content, "kubepods") ||
strings.Contains(content, "containerd") ||
strings.Contains(content, "lxc")
}
+19
View File
@@ -0,0 +1,19 @@
//go:build !linux && !windows
package wafhostguard
// checkLogCapability 非 Linux/Windows 平台(主要是 macOS)不支持登录失败日志的**自动采集**。
//
// 原因:macOS 从 10.12 起用统一日志系统(os_log)取代了 syslog,sshd 的认证失败细节
// 不再写进 /var/log/system.log,要靠 `log stream --predicate 'process == "sshd"'` 取,
// 输出格式与 syslog 完全是另一套;加之 macOS 是桌面系统、"远程登录"默认关闭,
// 极少作为公网 SSH 服务器暴露,收益撑不起一套独立的采集与解析实现。
//
// **注意措辞要准确**:不能说成"整个功能不可用"。macOS 上不能用的只是"自动检测爆破",
// 而封禁执行(pf table)、手工封禁、封禁账本与到期解封、连接看板都是正常工作的——
// 说成全不可用会让用户直接放弃这个页面,那是误导。
func checkLogCapability() (bool, string, bool) {
return false, "当前系统不支持自动检测远程登录爆破:macOS 的 sshd 认证日志在统一日志系统(os_log)中," +
"格式与 Linux 的 syslog 完全不同,暂未支持采集(自动检测目前支持 Linux 的 SSH 与 Windows 的 RDP)。" +
"本页的【连接监控】、【手工封禁】、封禁列表与到期自动解封仍可正常使用。", false
}
+47
View File
@@ -0,0 +1,47 @@
//go:build windows
package wafhostguard
import (
"SamWaf/common/wafexec"
"context"
"os/exec"
"strings"
"time"
)
// probeTimeout 探测命令超时。wevtutil 在事件日志服务异常时可能挂住,
// 不能让能力探测把调用方的锁一起焊死。
const probeTimeout = 8 * time.Second
// checkLogCapability Windows:能否读取安全事件日志(4625 所在的 Security 频道)。
//
// 读 Security 频道需要管理员或 SYSTEM 权限。SamWaf 装成服务时默认是 LocalSystem,
// 有权限;但用户以普通身份双击 exe 调试时会读不到,这时要给出能直接照做的提示。
func checkLogCapability() (bool, string, bool) {
ctx, cancel := context.WithTimeout(context.Background(), probeTimeout)
defer cancel()
// 只取 1 条,纯粹验证"打得开"
cmd := exec.CommandContext(ctx, "wevtutil", "qe", "Security", "/c:1", "/rd:true", "/f:text")
out, err := wafexec.FixStdin(cmd).CombinedOutput()
if err == nil {
return true, "", false
}
detail := strings.TrimSpace(string(out))
msg := "无法读取 Windows 安全事件日志(4625 登录失败事件所在的 Security 频道)"
switch {
case ctx.Err() != nil:
msg += ":探测超时,可能是 Windows Event Log 服务异常,请检查该服务是否正在运行"
case strings.Contains(detail, "5") && strings.Contains(strings.ToLower(detail), "access"),
strings.Contains(detail, "拒绝访问"):
msg += ":权限不足。请以管理员身份运行,或把 SamWaf 安装为系统服务(服务默认以 LocalSystem 运行,有读取权限)"
default:
msg += ":" + detail
if detail == "" {
msg += err.Error()
}
}
return false, msg, false
}
+153
View File
@@ -0,0 +1,153 @@
package wafhostguard
import (
"SamWaf/common/zlog"
"SamWaf/global"
"SamWaf/model"
"SamWaf/utils"
"SamWaf/wafipban"
"fmt"
"time"
)
// 判定引擎:一条归一化事件进来,决定它要不要计数、要不要触发封禁。
//
// 闸门顺序是有讲究的,越"便宜"、越"确定不该封"的判断放在越前面:
// 硬开关 → 软失败过滤 → 白名单 → 计数 → 阈值。
// 白名单排在计数之前,是为了让自己人的失败连计数都不产生——
// 否则管理员敲错几次密码,虽然不会被封,但会在攻击者档案里留下莫名其妙的记录。
// eventSink 事件落库回调。由 engine 注入,避免 wafhostguard 直接依赖 service 层
// 造成 service -> wafhostguard -> service 的循环引用。
type eventSink func(model.HostLoginEvent)
var recordEvent eventSink
// SetEventSink 注入事件落库实现
func SetEventSink(fn eventSink) { recordEvent = fn }
// Decide 处理一条登录失败事件,返回处置结果(供调用方统计)
func Decide(ev LoginFailEvent) BanResult {
res := BanResult{TargetIP: ev.IP}
now := ev.At
if now.IsZero() {
now = time.Now()
}
action := model.HostEventActionCounted
// 1. 硬开关:连事件都不记,彻底安静
if global.GCONFIG_HOST_GUARD_FORCE_DISABLE {
res.Skipped = "已被配置文件强制关闭"
return res
}
// 2. 软失败过滤。preauth 断连/用户名枚举/PAM 行默认不计数,
// 原因见 FailKind.IsHard 的注释——它们要么是扫描噪声,要么会与密码失败行重复。
countable := ev.Kind.IsHard() || global.GCONFIG_HOST_GUARD_COUNT_SOFT_FAIL == 1
// 3. 白名单
white, whiteReason := IsWhitelisted(ev.IP)
if white {
countable = false
action = model.HostEventActionSkipped
zlog.Debug("[主机登录防护] 白名单豁免", "ip", ev.IP, "原因", whiteReason)
}
var hitCount int64
if countable {
mgr := wafipban.GetHostLoginFailureManager()
if mgr != nil {
window := global.GCONFIG_HOST_GUARD_FIND_TIME
if window <= 0 {
window = 10
}
// 事件保留窗口取统计窗口的 3 倍,让页面上还能看到"刚过窗口"的历史,
// 同时不至于无限增长
hitCount = mgr.Record(ev.Source, ev.IP, window, window*3)
}
} else if !white {
action = model.HostEventActionSkipped
}
// 4. 阈值判定
maxRetry := global.GCONFIG_HOST_GUARD_MAX_RETRY
if maxRetry <= 0 {
maxRetry = 8
}
if countable && hitCount >= maxRetry {
res = ApplyBan(BanRequest{
IP: ev.IP,
Source: ev.Source,
HitCount: hitCount,
FailKinds: describeEvent(ev),
}, now)
switch {
case res.Banned:
action = model.HostEventActionBanned
// 封禁下发后清空计数:否则解封瞬间那些旧事件还在保留窗口里,
// 下一条失败就会立刻把它顶回阈值,用户看到的是"封了 5 分钟,
// 解封后 1 秒又被封",阶梯也会被无意义地快速推高。
if mgr := wafipban.GetHostLoginFailureManager(); mgr != nil {
mgr.Clear(ev.Source, ev.IP)
}
case res.Observed:
action = model.HostEventActionObserve
default:
action = model.HostEventActionSkipped
}
}
// 5. 落库(异步批量,见 service 层)
if recordEvent != nil {
recordEvent(model.HostLoginEvent{
Source: ev.Source,
IP: ev.IP,
Port: ev.Port,
UserName: ev.User,
FailKind: string(ev.Kind),
LogonType: ev.LogonType,
Location: utils.FormatIPLocation(ev.IP),
Action: action,
HitCount: hitCount,
RawLine: ev.Raw,
EventTime: now.Unix(),
})
}
return res
}
// describeEvent 生成一句失败摘要,写进封禁原因里让用户看得懂是怎么被封的
func describeEvent(ev LoginFailEvent) string {
kindName := FailKindName(ev.Kind)
if ev.User == "" {
return kindName
}
return fmt.Sprintf("%s,最近尝试账号 %s", kindName, ev.User)
}
// FailKindName 失败类型的中文名,前端与通知文案共用
func FailKindName(k FailKind) string {
switch k {
case FailPassword:
return "密码错误"
case FailPublicKey:
return "公钥认证失败"
case FailInvalidUser:
return "用户名不存在"
case FailMaxAuthTries:
return "单连接内尝试次数超限"
case FailNotAllowed:
return "账号不在允许列表"
case FailPamAuth:
return "PAM认证失败"
case FailPreauthClose:
return "认证前断开连接"
case FailRdpLogon:
return "远程桌面登录失败"
default:
return string(k)
}
}
+273
View File
@@ -0,0 +1,273 @@
package wafhostguard
import (
"SamWaf/common/zlog"
"SamWaf/global"
"context"
"sync"
"sync/atomic"
"time"
)
// 引擎:把事件源、判定、处置串起来的地方,同时负责生命周期。
//
// **生命周期是这个模块的正确性要害**。SamWaf 是 Supervisor + Worker 双进程,
// 滚动升级时新旧 Worker 会并存最多 30 秒。如果采集器在 run() 里无条件启动,
// 这 30 秒内两个进程会同时 tail 同一份日志,同一条 Failed password 被计两次;
// 而缓存默认是进程内内存,两边计数器互相看不见,于是各自都以为"才 4 次",
// 实际用户配的 8 次阈值被腰斩成 4 次,误封概率显著上升。
// 所以 Start() 只能挂在 activateSingletons()(Supervisor 确认旧 Worker 已退出后才触发)
// 和非 takeover 的首启路径上,见 cmd/samwaf/main.go。
// eventChanSize 事件通道缓冲。爆破高峰下每秒可能几十上百条,
// 留一千多的缓冲让消费端(要查库、要写库)有喘息空间。
const eventChanSize = 1024
// Engine 主机登录防护引擎
type Engine struct {
mu sync.Mutex
running bool
cancel context.CancelFunc
wg sync.WaitGroup
events chan LoginFailEvent
// 运行态指标,供状态接口展示
startedAt time.Time
sourceNames []string
unavailable string // 非空表示当前环境采集不了,内容是可直接展示的中文原因
lastEventAt atomic.Int64
totalParsed atomic.Int64
totalBanned atomic.Int64
dropped atomic.Int64
}
var engine = &Engine{}
// GetEngine 取引擎单例
func GetEngine() *Engine { return engine }
// dropEvent 记一次因通道满而丢弃的事件。
// 采集端宁可丢事件也不能阻塞——阻塞会让 fd 里的数据越积越多,
// 最后连"现在正在发生什么"都读不到了。
func dropEvent() { engine.dropped.Add(1) }
// Start 启动采集(幂等)。调用点见 cmd/samwaf/main.go 的两处。
func Start() { engine.Start() }
// Stop 停止采集(幂等),等所有事件源退出后返回
func Stop() { engine.Stop() }
// Reload 配置变更后重新应用:开关 0→1 启动,1→0 停止
func Reload() {
if global.GCONFIG_HOST_GUARD_ENABLED == 1 {
engine.Start()
} else {
engine.Stop()
}
}
// Start 启动引擎
func (e *Engine) Start() {
e.mu.Lock()
defer e.mu.Unlock()
if e.running {
return
}
if global.GCONFIG_HOST_GUARD_FORCE_DISABLE {
zlog.Warn("[主机登录防护] 已被 conf/config.yml 的 security.host_guard_force_disable " +
"或环境变量 SAMWAF_HOSTGUARD_DISABLE 强制关闭,不启动采集")
return
}
if global.GCONFIG_HOST_GUARD_ENABLED != 1 {
return
}
srcs, reason := newSources()
if len(srcs) == 0 {
e.unavailable = reason
zlog.Warn("[主机登录防护] " + reason)
return
}
e.unavailable = ""
// 封禁范围要在重放之前设好:先建立起带端口限制的引用规则,
// 再把地址灌进集合,中间不会出现"短暂按全端口封着"的窗口
GetBanExecutor().ApplyPortScope()
// 先把库里未过期的封禁重灌回系统防火墙:ipset 是内核内存态,机器重启就空了,
// 不重放的话数据库里写着"正在封禁 200 个 IP",实际一个都没生效
if err := GetBanExecutor().ReplayOnStart(); err != nil {
zlog.Warn("[主机登录防护] 重放历史封禁失败,新封禁不受影响", "error", err.Error())
}
ctx, cancel := context.WithCancel(context.Background())
e.cancel = cancel
e.events = make(chan LoginFailEvent, eventChanSize)
e.running = true
e.startedAt = time.Now()
e.sourceNames = e.sourceNames[:0]
for _, src := range srcs {
e.sourceNames = append(e.sourceNames, src.Name())
s := src
e.wg.Add(1)
go func() {
defer e.wg.Done()
defer func() {
// 事件源里任何 panic 都不该带崩整个 WAF
if r := recover(); r != nil {
zlog.Error("[主机登录防护] 事件源异常退出", "source", s.Name(), "panic", r)
}
}()
if err := s.Run(ctx, e.events); err != nil && ctx.Err() == nil {
zlog.Warn("[主机登录防护] 事件源停止", "source", s.Name(), "error", err.Error())
}
}()
}
e.wg.Add(1)
go func() {
defer e.wg.Done()
e.consume(ctx)
}()
zlog.Info("[主机登录防护] 启动采集", "事件源", e.sourceNames,
"模式", global.GCONFIG_HOST_GUARD_MODE,
"窗口分钟", global.GCONFIG_HOST_GUARD_FIND_TIME,
"阈值", global.GCONFIG_HOST_GUARD_MAX_RETRY)
}
// Stop 停止引擎。必须在 stopSamWaf 里、关库之前调用:
// 这里要把 Windows 待同步的脏集合刷下去,也要确保 fd / journalctl 子进程被释放,
// 否则新旧 Worker 交替时旧进程不放手,就会出现双读日志重复计数。
func (e *Engine) Stop() {
e.mu.Lock()
if !e.running {
e.mu.Unlock()
return
}
e.running = false
cancel := e.cancel
e.cancel = nil
e.mu.Unlock()
if cancel != nil {
cancel()
}
// 等事件源退出,但不无限等:某个源卡死不能把整个进程的关闭流程拖住
done := make(chan struct{})
go func() {
e.wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
zlog.Warn("[主机登录防护] 等待事件源退出超时,继续关闭")
}
// Windows 去抖路径下可能还有没落地的集合变更
if err := GetBanExecutor().FlushPending(); err != nil {
zlog.Warn("[主机登录防护] 关闭前同步封禁集合失败", "error", err.Error())
}
zlog.Info("[主机登录防护] 停止采集,已释放事件源", "数量", len(e.sourceNames))
}
// consume 事件消费主循环
func (e *Engine) consume(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case ev, ok := <-e.events:
if !ok {
return
}
e.handle(ev)
}
}
}
// handle 处理单条事件,任何 panic 都不影响后续事件
func (e *Engine) handle(ev LoginFailEvent) {
defer func() {
if r := recover(); r != nil {
zlog.Error("[主机登录防护] 处理事件时异常", "ip", ev.IP, "panic", r)
}
}()
e.totalParsed.Add(1)
e.lastEventAt.Store(time.Now().Unix())
res := Decide(ev)
if res.Banned {
e.totalBanned.Add(1)
}
}
// Status 运行态快照
type Status struct {
Running bool `json:"running"`
Mode string `json:"mode"`
Sources []string `json:"sources"`
Unavailable string `json:"unavailable"`
StartedAt int64 `json:"started_at"`
LastEventAt int64 `json:"last_event_at"`
TotalParsed int64 `json:"total_parsed"`
TotalBanned int64 `json:"total_banned"`
Dropped int64 `json:"dropped"`
BanCount int `json:"ban_count"`
ExecMode string `json:"exec_mode"`
LastSyncAt int64 `json:"last_sync_at"`
LastSyncMs int64 `json:"last_sync_ms"`
Capability Capability `json:"capability"`
SSHPorts []int `json:"ssh_ports"`
RDPPorts []int `json:"rdp_ports"`
// PortScopeSupported 当前平台是否支持"只封 SSH/RDP 端口"。
// macOS 与逐条规则模式下为 false,前端据此禁用该选项。
PortScopeSupported bool `json:"port_scope_supported"`
}
// GetStatus 汇总当前状态,供概览页展示
func GetStatus() Status {
e := engine
e.mu.Lock()
running := e.running
sources := append([]string(nil), e.sourceNames...)
unavailable := e.unavailable
startedAt := e.startedAt
e.mu.Unlock()
exec := GetBanExecutor()
lastSyncAt, lastSyncMs := exec.LastSync()
sshPorts, rdpPorts := GuardPorts()
st := Status{
Running: running,
Mode: global.GCONFIG_HOST_GUARD_MODE,
Sources: sources,
Unavailable: unavailable,
LastEventAt: e.lastEventAt.Load(),
TotalParsed: e.totalParsed.Load(),
TotalBanned: e.totalBanned.Load(),
Dropped: e.dropped.Load(),
BanCount: exec.Count(),
ExecMode: exec.ExecMode(),
LastSyncMs: lastSyncMs,
Capability: GetCapability(),
SSHPorts: sshPorts,
RDPPorts: rdpPorts,
PortScopeSupported: exec.SupportsPortScope(),
}
if !startedAt.IsZero() {
st.StartedAt = startedAt.Unix()
}
if !lastSyncAt.IsZero() {
st.LastSyncAt = lastSyncAt.Unix()
}
return st
}
+95
View File
@@ -0,0 +1,95 @@
// Package wafhostguard 主机侧远程登录爆破防护。
//
// 保护的是 SamWaf 所在这台机器**自身**的 SSH / RDP,与 WAF 引擎(保护 Web 站点)
// 完全是两件事。工作方式与 fail2ban / IPBan 一致:
//
// 读系统日志 -> 归一化事件 -> 滑动窗口计数 -> 超阈值 -> 阶梯递进封禁 -> 到期解封
//
// 判定依据是**日志内容**而非端口,所以用户把 sshd 改成 22222、RDP 改成 33890
// 都不影响检测(日志里的 port 是源端口,不是服务端口)。
package wafhostguard
import (
"context"
"time"
)
// 事件来源
const (
SourceSSH = "ssh"
SourceRDP = "rdp"
)
// FailKind 失败类型,决定是否计入阈值与展示文案
type FailKind string
const (
FailPassword FailKind = "failed_password" // Failed password for [invalid user] X from IP port N
FailPublicKey FailKind = "failed_publickey" // Failed publickey for X from IP port N
FailInvalidUser FailKind = "invalid_user" // Invalid user X from IP
FailMaxAuthTries FailKind = "max_auth_tries" // maximum authentication attempts exceeded
FailNotAllowed FailKind = "not_allowed" // User X not allowed because not listed in AllowUsers
FailPamAuth FailKind = "pam_auth_failure" // pam_unix(sshd:auth): authentication failure ... rhost=IP
FailPreauthClose FailKind = "preauth_close" // Connection closed/reset by ... [preauth] <- soft
FailScanProbe FailKind = "scan_probe" // 端口扫描/协议探测,不是登录尝试 <- soft
FailRdpLogon FailKind = "rdp_logon_failed" // Windows 安全日志 4625
)
// IsHard 报告该失败类型是否计入封禁阈值。软失败默认不计数,这是防误封的第一道闸,
// 比白名单更早生效;用户确实想计的话可以打开 host_guard_count_soft_fail。
//
// 三类被判为软失败的原因各不相同:
// - FailPreauthClose:preauth 阶段断连由端口扫描、健康探针、负载均衡探活大量产生,
// 计数会误封监控系统。
// - FailInvalidUser:sshd 通常先打 `Invalid user X from IP`,紧接着再打一条
// `Failed password for invalid user X from IP`,同一次尝试出现两行。
// - FailPamAuth:RHEL 系 /var/log/secure 里 pam_unix 那行同样与 Failed password 成对出现。
// - FailScanProbe:端口扫描与协议探测(HTTP 请求打到 SSH 端口之类),压根不是登录尝试,
// 只做展示让用户知道"有人在扫我",不参与封禁判定。
//
// 中间两类若一并计数,配 8 次的阈值实际 4 次就会触发,"自己敲错几次密码"就被封了。
func (k FailKind) IsHard() bool {
switch k {
case FailPreauthClose, FailInvalidUser, FailPamAuth, FailScanProbe:
return false
default:
return true
}
}
// IsScanProbe 报告是否属于"扫描探测"而非登录失败。
// 这类事件即使用户打开了 host_guard_count_soft_fail 也不该计数——
// 它连一次登录尝试都算不上,计进去纯属噪声。
func (k FailKind) IsScanProbe() bool { return k == FailScanProbe }
// LoginFailEvent 归一化后的一次远程登录失败
type LoginFailEvent struct {
Source string // ssh | rdp
IP string // 已经 net.ParseIP 严格校验过的字符串形式
Port int // 源端口,未知为 0
User string // 尝试的用户名,可能为空
Kind FailKind //
LogonType string // 仅 Windows:3=Network 10=RemoteInteractive
Raw string // 原始行/XML 摘要,入库前截断
At time.Time // 采集时刻(不解析日志行内时间戳,见 parser_sshd.go 的说明)
}
// RawLimit 原始内容入库长度上限
const RawLimit = 500
// TruncRaw 截断原始内容,避免超长日志行把事件表撑爆
func TruncRaw(s string) string {
if len(s) <= RawLimit {
return s
}
return s[:RawLimit]
}
// Source 事件源。各实现必须做到:
// - Run 阻塞运行直到 ctx.Done(),退出前释放全部资源(fd / 子进程 / 系统句柄)
// - 内部错误不 panic,只记 zlog 并自愈重试
// - 向 out 发送时必须 select ctx.Done(),否则 Stop 时会卡死在满通道上
type Source interface {
Name() string // 展示用,如 "file:/var/log/secure"
Run(ctx context.Context, out chan<- LoginFailEvent) error
}
+379
View File
@@ -0,0 +1,379 @@
package wafhostguard
import (
"SamWaf/common/zlog"
"SamWaf/firewall"
"SamWaf/global"
"SamWaf/model"
"sync"
"time"
)
// BanSetName 防爆破专用的封禁集合名。
//
// 刻意与威胁情报订阅集合(samwaf_sub_*)和手工逐条封禁规则(SamWAF_ 前缀)完全分开:
// 三者的生命周期、更新频率、真相来源都不同,共用一个集合迟早会互相覆盖或误删。
const BanSetName = "samwaf_hostguard"
// BanExecutor 把"当前应该被封禁的 IP 集合"同步到系统防火墙。
//
// 内存里维护一份集合镜像作为唯一真相,各平台按能力选择同步方式:
// - Linux / macOS:支持增量,来一个封一个,立即生效
// - Windows:分片规则模型无法增量,只能全量重建;重建代价高(要先逐序号扫描删除旧分片),
// 所以攒够去抖窗口(默认 30 秒)再合并成一次重建。主机爆破封禁不需要秒级生效,
// 多等半分钟换来的是不会在爆破高峰把 netsh fork 成千上万次。
type BanExecutor struct {
mu sync.Mutex
current map[string]struct{}
fw firewall.FireWallEngine
dirty bool // Windows 路径:集合有变更待同步
syncTimer *time.Timer // 去抖定时器
stopCh chan struct{} // 关闭信号
lastSyncAt time.Time
lastSyncMs int64
}
var (
executorOnce sync.Once
executorInst *BanExecutor
)
// GetBanExecutor 取执行器单例
func GetBanExecutor() *BanExecutor {
executorOnce.Do(func() {
executorInst = &BanExecutor{
current: make(map[string]struct{}),
stopCh: make(chan struct{}),
}
})
return executorInst
}
// useIncremental 判断本次是否走增量路径
func (e *BanExecutor) useIncremental() bool {
switch global.GCONFIG_HOST_GUARD_EXEC_MODE {
case "rule":
// 强制逐条规则(调试用):逐条 BlockIP 本身就是增量语义
return true
case "ipset":
return e.fw.SupportsIncrementalIPSet()
default:
return e.fw.SupportsIncrementalIPSet()
}
}
// useRuleMode 是否走逐条防火墙规则而不是集合
func (e *BanExecutor) useRuleMode() bool {
if global.GCONFIG_HOST_GUARD_EXEC_MODE == "rule" {
return true
}
if global.GCONFIG_HOST_GUARD_EXEC_MODE == "ipset" {
return false
}
// auto:集合不可用时退回逐条规则(比如 Linux 上没装 ipset)
return !e.fw.SupportsIPSet()
}
// ExecMode 返回当前实际使用的执行方式,供状态展示
func (e *BanExecutor) ExecMode() string {
if e.useRuleMode() {
return model.HostBanExecRule
}
return model.HostBanExecIPSet
}
// SupportsPortScope 报告当前平台能否把封禁限制在指定端口。
// 供状态接口暴露给前端——不支持时页面要把"仅SSH/RDP端口"这个选项禁掉,
// 否则用户选了、界面提示保存成功、实际却静默按全端口封,就是个假开关。
func (e *BanExecutor) SupportsPortScope() bool {
if e.useRuleMode() {
// 逐条规则模式走 BlockIP,那是整机封禁,没有端口维度
return false
}
return e.fw.SupportsPortScopedSet()
}
// ApplyPortScope 按配置调整封禁作用端口。
//
// port_scope=detected 时只封 SSH/RDP 端口——误封的代价从"整台机器进不去"
// 降到"远程登录进不去,Web 与业务端口照常",排查余地大得多。
// 逐条规则模式(useRuleMode)下不支持,因为 BlockIP 走的是整机规则。
func (e *BanExecutor) ApplyPortScope() {
if e.useRuleMode() {
return
}
if global.GCONFIG_HOST_GUARD_PORT_SCOPE != "detected" {
// 恢复全端口
if err := e.fw.ApplyIPSetPortScope(BanSetName, nil); err != nil {
zlog.Warn("[主机登录防护] 恢复全端口封禁范围失败", "error", err.Error())
return
}
e.rebuildAfterScopeChange()
return
}
if !e.fw.SupportsPortScopedSet() {
zlog.Warn("[主机登录防护] 当前系统不支持端口级封禁,已按全端口封禁执行。" +
"macOS 请在 /etc/pf.conf 的 block 规则上自行加 port 限制")
return
}
sshPorts, rdpPorts := GuardPorts()
ports := append(append([]int{}, sshPorts...), rdpPorts...)
if len(ports) == 0 {
zlog.Warn("[主机登录防护] 未探测到 SSH/RDP 端口,端口级封禁降级为全端口")
ports = nil
}
if err := e.fw.ApplyIPSetPortScope(BanSetName, ports); err != nil {
zlog.Warn("[主机登录防护] 设置端口级封禁范围失败,已按全端口封禁执行", "error", err.Error())
return
}
e.rebuildAfterScopeChange()
if len(ports) > 0 {
zlog.Info("[主机登录防护] 封禁范围已限制在远程登录端口", "端口", ports)
}
}
// rebuildAfterScopeChange 范围改完后,让新范围立刻作用到**已经生效**的封禁上。
//
// Linux/macOS 不需要:端口写在那条独立的引用规则上,ApplyIPSetPortScope 已经把
// 规则重建过了,集合内容原封不动,新范围自动对全部已封 IP 生效。
//
// Windows 需要:那边没有独立的引用规则,端口是写在每条分片规则自身的 localport= 上的,
// ApplyIPSetPortScope 只记下了范围。不在这里补一次全量重建,用户在页面上把范围
// 从"全部端口"改成"仅SSH/RDP"之后,已经封着的 IP 仍然是全端口封禁,
// 要等下一次有新封禁或解封触发去抖重建才会变——那是个说不通的行为。
func (e *BanExecutor) rebuildAfterScopeChange() {
if e.fw.SupportsIncrementalIPSet() {
return
}
e.mu.Lock()
empty := len(e.current) == 0
if !empty {
e.dirty = true
}
e.mu.Unlock()
if empty {
return
}
if err := e.FlushPending(); err != nil {
zlog.Warn("[主机登录防护] 切换封禁范围后重建防火墙规则失败", "error", err.Error())
}
}
// Apply 增删封禁。add/del 可以同时非空(到期解封与新增封禁在同一轮里发生)。
func (e *BanExecutor) Apply(add, del []string) error {
if len(add) == 0 && len(del) == 0 {
return nil
}
e.mu.Lock()
changed := false
for _, ip := range add {
if _, ok := e.current[ip]; !ok {
e.current[ip] = struct{}{}
changed = true
}
}
for _, ip := range del {
if _, ok := e.current[ip]; ok {
delete(e.current, ip)
changed = true
}
}
if !changed {
e.mu.Unlock()
return nil
}
ruleMode := e.useRuleMode()
incremental := e.useIncremental()
e.mu.Unlock()
if ruleMode {
return e.applyByRules(add, del)
}
if incremental {
return e.applyIncremental(add, del)
}
// Windows:标脏,交给去抖协程合并重建
e.markDirty()
return nil
}
// applyByRules 逐条防火墙规则模式(Linux 无 ipset 时的退路,或用户强制指定)
func (e *BanExecutor) applyByRules(add, del []string) error {
var firstErr error
for _, ip := range add {
if err := e.fw.BlockIP(ip, "SamWaf主机防爆破"); err != nil && firstErr == nil {
firstErr = err
}
}
for _, ip := range del {
if err := e.fw.UnblockIP(ip); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
// applyIncremental Linux/macOS:直接增删集合元素
func (e *BanExecutor) applyIncremental(add, del []string) error {
start := time.Now()
var firstErr error
if len(add) > 0 {
if err := e.fw.AddToIPSet(BanSetName, add); err != nil {
firstErr = err
}
}
if len(del) > 0 {
if err := e.fw.DelFromIPSet(BanSetName, del); err != nil && firstErr == nil {
firstErr = err
}
}
e.recordSync(start)
return firstErr
}
// markDirty 标脏并启动/重置去抖定时器
func (e *BanExecutor) markDirty() {
e.mu.Lock()
defer e.mu.Unlock()
e.dirty = true
debounce := time.Duration(global.GCONFIG_HOST_GUARD_DEBOUNCE_SEC) * time.Second
if debounce <= 0 {
debounce = 30 * time.Second
}
if e.syncTimer != nil {
e.syncTimer.Stop()
}
e.syncTimer = time.AfterFunc(debounce, func() {
if err := e.FlushPending(); err != nil {
zlog.Warn("[主机登录防护] 同步封禁集合到系统防火墙失败", "error", err.Error())
}
})
}
// FlushPending 立即把待同步的集合全量重建掉。
// Stop() 时必须调用一次,否则最后一批封禁会随进程退出一起丢失。
func (e *BanExecutor) FlushPending() error {
e.mu.Lock()
if !e.dirty {
e.mu.Unlock()
return nil
}
e.dirty = false
if e.syncTimer != nil {
e.syncTimer.Stop()
e.syncTimer = nil
}
ips := e.snapshotLocked()
e.mu.Unlock()
start := time.Now()
err := e.fw.RestoreIPSet(BanSetName, ips)
e.recordSync(start)
return err
}
// ReplayOnStart 把库里未过期的封禁全量重灌进系统防火墙。
//
// **必须做**:ipset 是内核内存态,机器重启就没了;Windows 的分片规则虽然能持久化,
// 但也可能被用户手工清理或被组策略刷掉。不重放的话,重启后数据库里明明写着
// "这 200 个 IP 正在封禁中",实际却一个都没生效——用户完全无从察觉。
// 做法参照威胁情报模块的 RestoreAllOnStartup()。
func (e *BanExecutor) ReplayOnStart() error {
if global.GWAF_LOCAL_DB == nil {
return nil
}
now := time.Now().Unix()
var bans []model.HostGuardBan
err := global.GWAF_LOCAL_DB.
Where("status = ? AND (expire_time = 0 OR expire_time > ?)", model.HostBanStatusActive, now).
Find(&bans).Error
if err != nil {
return err
}
if len(bans) == 0 {
return nil
}
ips := make([]string, 0, len(bans))
e.mu.Lock()
e.current = make(map[string]struct{}, len(bans))
for _, b := range bans {
if b.IP == "" {
continue
}
e.current[b.IP] = struct{}{}
ips = append(ips, b.IP)
}
ruleMode := e.useRuleMode()
e.mu.Unlock()
start := time.Now()
if ruleMode {
// 逐条模式下 BlockIP 自身幂等(内部会查规则是否已存在)
_, failed, err := e.fw.BlockIPList(ips)
if len(failed) > 0 {
zlog.Warn("[主机登录防护] 重放封禁时部分IP失败", "失败数", len(failed))
}
e.recordSync(start)
zlog.Info("[主机登录防护] 已重放封禁规则", "数量", len(ips), "方式", "逐条规则")
return err
}
if err := e.fw.RestoreIPSet(BanSetName, ips); err != nil {
return err
}
e.recordSync(start)
zlog.Info("[主机登录防护] 已重放封禁集合", "数量", len(ips), "集合", BanSetName)
return nil
}
// Count 当前集合内的封禁条目数
func (e *BanExecutor) Count() int {
e.mu.Lock()
defer e.mu.Unlock()
return len(e.current)
}
// Contains 该 IP 当前是否在封禁集合内
func (e *BanExecutor) Contains(ip string) bool {
e.mu.Lock()
defer e.mu.Unlock()
_, ok := e.current[ip]
return ok
}
// LastSync 返回最近一次同步的时刻与耗时(毫秒),供状态页展示
func (e *BanExecutor) LastSync() (time.Time, int64) {
e.mu.Lock()
defer e.mu.Unlock()
return e.lastSyncAt, e.lastSyncMs
}
// recordSync 记录同步耗时,超过 5 秒给告警——这通常意味着集合太大或系统防火墙很慢
func (e *BanExecutor) recordSync(start time.Time) {
elapsed := time.Since(start)
e.mu.Lock()
e.lastSyncAt = time.Now()
e.lastSyncMs = elapsed.Milliseconds()
count := len(e.current)
e.mu.Unlock()
if elapsed > 5*time.Second {
zlog.Warn("[主机登录防护] 同步封禁到系统防火墙耗时过长,可考虑调大去抖窗口或收紧封禁条数",
"耗时", elapsed.Round(time.Millisecond).String(), "封禁条数", count)
}
}
// snapshotLocked 取集合快照(调用方需持锁)
func (e *BanExecutor) snapshotLocked() []string {
out := make([]string, 0, len(e.current))
for ip := range e.current {
out = append(out, ip)
}
return out
}
+115
View File
@@ -0,0 +1,115 @@
//go:build linux
package wafhostguard
import (
"SamWaf/common/wafexec"
"SamWaf/common/zlog"
"bufio"
"context"
"os/exec"
"time"
)
// journald 事件源:给那些没有 /var/log/secure、/var/log/auth.log 的发行版用
// (较新的 Fedora/Arch、裁掉 rsyslog 的最小化镜像)。
//
// **三个 identifier 都要订**:OpenSSH 9.8+(Ubuntu 24.10 / Fedora 41 起)把认证阶段
// 拆到了独立的 sshd-session 进程里,syslog identifier 随之变化。只订 sshd 的话,
// 在新发行版上一条失败日志都收不到,而且完全没有报错——功能静默失效是最糟的失效方式。
var journalIdentifiers = []string{"sshd", "sshd-session", "sshd-auth"}
// journalRestartMax 重启保护:5 分钟内超过这个次数就放弃,避免疯狂 fork
const (
journalRestartMax = 10
journalRestartWindow = 5 * time.Minute
journalRestartDelay = 3 * time.Second
)
type journalSource struct{}
func (s *journalSource) Name() string { return "journalctl" }
func (s *journalSource) Run(ctx context.Context, out chan<- LoginFailEvent) error {
restarts := 0
windowStart := time.Now()
for {
if ctx.Err() != nil {
return nil
}
if time.Since(windowStart) > journalRestartWindow {
restarts = 0
windowStart = time.Now()
}
if restarts >= journalRestartMax {
return errJournalTooManyRestarts
}
if err := s.runOnce(ctx, out); err != nil && ctx.Err() == nil {
zlog.Warn("[主机登录防护] journalctl 异常退出,稍后重启", "error", err.Error())
}
if ctx.Err() != nil {
return nil
}
restarts++
select {
case <-ctx.Done():
return nil
case <-time.After(journalRestartDelay):
}
}
}
// runOnce 拉起一次 journalctl -f 并读到它退出
func (s *journalSource) runOnce(ctx context.Context, out chan<- LoginFailEvent) error {
// -n 0:只要新产生的,不回放历史(理由同 tail 的 Seek 到末尾)
// -o cat:只输出消息正文,不带行首——解析器本来就不看行首时间戳
args := []string{"-f", "-n", "0", "-o", "cat"}
for _, id := range journalIdentifiers {
args = append(args, "-t", id)
}
cmd := wafexec.FixStdin(exec.Command("journalctl", args...))
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
// ctx 取消时杀掉子进程,否则 SamWaf 退出后会留下孤儿 journalctl 一直跑着
done := make(chan struct{})
go func() {
select {
case <-ctx.Done():
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
case <-done:
}
}()
defer close(done)
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
if ctx.Err() != nil {
break
}
if ev, ok := ParseSSHDLine(scanner.Text(), time.Now()); ok {
select {
case out <- ev:
case <-ctx.Done():
break
default:
dropEvent()
}
}
}
_ = cmd.Wait()
return scanner.Err()
}
+117
View File
@@ -0,0 +1,117 @@
package wafhostguard
import (
"SamWaf/common/zlog"
"SamWaf/global"
"SamWaf/model"
"sort"
"sync"
"time"
)
// 阶梯递进封禁。同一个 IP 第 N 次被封就用第 N 级的时长:
// 默认 5分 → 15分 → 60分 → 1天 → 永久。
//
// 之所以第一级只有 5 分钟:绝大多数"触发阈值"的其实是管理员自己敲错密码、
// 或者扫描器随手扫一下。5 分钟的代价用户等得起,而真正的爆破者会在解封后
// 立刻再来,于是被顶到第 2、3 级——惩罚自动向真正的攻击者倾斜。
const ladderCacheTTL = time.Minute
var (
ladderMu sync.RWMutex
ladderCache []model.HostGuardBanLadder
ladderLoadAt time.Time
ladderLoadErr error
)
// InvalidateLadder 丢弃阶梯缓存(页面上改完阶梯后调用)
func InvalidateLadder() {
ladderMu.Lock()
ladderCache = nil
ladderMu.Unlock()
}
// loadLadders 读取启用中的阶梯,按 Level 升序。带 1 分钟缓存——
// 每次封禁都查库没必要,但也不能缓存太久,用户改完阶梯要能较快看到效果。
func loadLadders() []model.HostGuardBanLadder {
ladderMu.RLock()
if ladderCache != nil && time.Since(ladderLoadAt) < ladderCacheTTL {
l := ladderCache
ladderMu.RUnlock()
return l
}
ladderMu.RUnlock()
ladderMu.Lock()
defer ladderMu.Unlock()
if ladderCache != nil && time.Since(ladderLoadAt) < ladderCacheTTL {
return ladderCache
}
var list []model.HostGuardBanLadder
if global.GWAF_LOCAL_DB != nil {
if err := global.GWAF_LOCAL_DB.Where("enable = ?", 1).Find(&list).Error; err != nil {
zlog.Warn("[主机登录防护] 读取封禁阶梯失败,回退到内置默认阶梯", "error", err.Error())
list = nil
}
}
if len(list) == 0 {
// 库里没有(迁移未跑/被用户清空)时用内置默认,绝不能因为阶梯为空就不封禁
list = model.DefaultBanLadders()
}
sort.Slice(list, func(i, j int) bool { return list[i].Level < list[j].Level })
ladderCache = list
ladderLoadAt = time.Now()
return list
}
// LadderDecision 阶梯决策结果
type LadderDecision struct {
Level int // 命中的级别
BanMinutes int64 // 封禁时长(分钟),0=永久
BanCount int64 // 这是该 IP 第几次被封
Reset bool // 是否因超过记忆期而重置回第一级
}
// DecideLadder 根据攻击者档案决定这次封多久。
//
// offender 为 nil 表示这个 IP 第一次被封。
// 记忆期(host_guard_offender_reset_day,默认 7 天)内没有再犯的,下次从第 1 级重新开始——
// 不然一个几个月前误触过一次的 IP,今天再犯就直接被顶到高级别,惩罚与行为不匹配。
func DecideLadder(offender *model.HostGuardOffender, now time.Time) LadderDecision {
ladders := loadLadders()
banCount := int64(1)
reset := false
if offender != nil && offender.BanCount > 0 {
resetDays := global.GCONFIG_HOST_GUARD_OFFENDER_RESET_DAY
if resetDays > 0 && offender.LastBanTime > 0 {
idleDays := now.Sub(time.Unix(offender.LastBanTime, 0)).Hours() / 24
if idleDays > float64(resetDays) {
reset = true
}
}
if !reset {
banCount = offender.BanCount + 1
}
}
// 次数超过最高级就一直用最高级
idx := int(banCount) - 1
if idx >= len(ladders) {
idx = len(ladders) - 1
}
if idx < 0 {
idx = 0
}
chosen := ladders[idx]
return LadderDecision{
Level: chosen.Level,
BanMinutes: chosen.BanMinutes,
BanCount: banCount,
Reset: reset,
}
}
+72
View File
@@ -0,0 +1,72 @@
package wafhostguard
import (
"net"
"sync"
"time"
)
// 本机地址枚举。放进白名单是"绝不把自己封掉"的基础一环:
// 服务器出网时源地址往往就是自己的某块网卡地址,某些 NAT/回环场景下
// 自己发起的连接在日志里会以本机地址出现,封了就等于自己打自己。
var (
localAddrMu sync.RWMutex
localAddrCache []string
localAddrLoadAt time.Time
localAddrCacheGC = 5 * time.Minute
)
// LocalAddrs 返回本机所有网卡上的 IP(含 IPv6),带 5 分钟缓存。
// 加缓存是因为 net.Interfaces() 要遍历系统网卡表,而白名单判定在爆破高峰下
// 每秒会被调用几十上百次。
func LocalAddrs() []string {
localAddrMu.RLock()
if localAddrCache != nil && time.Since(localAddrLoadAt) < localAddrCacheGC {
addrs := localAddrCache
localAddrMu.RUnlock()
return addrs
}
localAddrMu.RUnlock()
localAddrMu.Lock()
defer localAddrMu.Unlock()
// 双检:可能有别的 goroutine 在等锁期间已经刷新过了
if localAddrCache != nil && time.Since(localAddrLoadAt) < localAddrCacheGC {
return localAddrCache
}
addrs := make([]string, 0, 8)
ifaces, err := net.Interfaces()
if err == nil {
for _, iface := range ifaces {
ias, err := iface.Addrs()
if err != nil {
continue
}
for _, a := range ias {
var ip net.IP
switch v := a.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
if ip == nil {
continue
}
addrs = append(addrs, ip.String())
}
}
}
localAddrCache = addrs
localAddrLoadAt = time.Now()
return addrs
}
// InvalidateLocalAddrs 丢弃缓存,下次调用重新枚举(网卡变更/手工刷新时用)
func InvalidateLocalAddrs() {
localAddrMu.Lock()
localAddrCache = nil
localAddrMu.Unlock()
}
+170
View File
@@ -0,0 +1,170 @@
package wafhostguard
import (
"net"
"regexp"
"strconv"
"strings"
"time"
)
// sshd 日志行解析。纯逻辑、无 build tag、无外部依赖,可以在 Windows 开发机上
// 直接 go test 验证 Linux 日志的解析结果——这是整个模块最容易出错的部分,
// 所以刻意不让它沾任何平台特定代码。
//
// **不解析行首时间戳**:传统 syslog 行首 `Aug 6 14:55:01 host sshd[123]:` 不带年份,
// RFC5424 是 `2026-08-06T14:55:01.123+08:00 host sshd 123 - -`,而 journalctl -o cat
// 干脆没有行首。既然是近实时 tail,用"读到该行的时刻"做事件时间误差在毫秒级,
// 完全够用,还彻底绕开了时间戳格式地狱。
// ipPat 宽松抓取 IPv4 / IPv6 / IPv4-mapped,抓完一律交给 net.ParseIP 严格校验,
// 校验不过就整行丢弃——宁可漏一条,不可封错一个。
const ipPat = `([0-9a-fA-F:.]+)`
// sshdRule 一条解析规则。
// hint 用于 strings.Contains 粗筛:绝大多数日志行都不是登录失败,先做一次
// 字符串包含判断再跑正则,比无条件跑全部正则快约一个数量级。
type sshdRule struct {
kind FailKind
hint string
re *regexp.Regexp
methodIdx int // 认证方式捕获组下标(仅 Failed <method> for 规则),0=无
userIdx int // 用户名捕获组下标,0=无
ipIdx int // IP 捕获组下标,必须 > 0
portIdx int // 源端口捕获组下标,0=无
}
// sshdRules 顺序即优先级:hard(确定的认证失败)在前,soft(可能与 hard 行重复计数的)在后。
var sshdRules = []sshdRule{
// —— hard:确定的一次认证失败 ——
// 覆盖 password / publickey / none / keyboard-interactive/pam 等各种 method
{
kind: FailPassword, hint: "Failed ",
re: regexp.MustCompile(`Failed (\S+) for (invalid user )?(\S+) from ` + ipPat + ` port (\d+)`),
methodIdx: 1, userIdx: 3, ipIdx: 4, portIdx: 5,
},
// 单连接内尝试次数超过 MaxAuthTries,明确是在撞库
{
kind: FailMaxAuthTries, hint: "maximum authentication attempts exceeded",
re: regexp.MustCompile(`maximum authentication attempts exceeded for (?:invalid user )?(\S+) from ` + ipPat + ` port (\d+)`),
userIdx: 1, ipIdx: 2, portIdx: 3,
},
// 被 AllowUsers / DenyUsers / AllowGroups 挡下
{
kind: FailNotAllowed, hint: "not allowed because",
re: regexp.MustCompile(`User (\S+) from ` + ipPat + ` not allowed because`),
userIdx: 1, ipIdx: 2,
},
// —— soft:默认不计入阈值,原因见 IsHard 的说明 ——
// 用户名枚举。sshd 通常先打这行、紧接着再打一条 Failed password,
// 两条都计会让阈值实际腰斩。
{
kind: FailInvalidUser, hint: "Invalid user",
re: regexp.MustCompile(`Invalid user (\S*) from ` + ipPat + `(?: port (\d+))?`),
userIdx: 1, ipIdx: 2, portIdx: 3,
},
// PAM 层失败。RHEL 系的 /var/log/secure 会和 Failed password 成对出现,同样会重复计数;
// 但少数发行版只有这行带得出 IP,所以保留解析、交给配置决定是否计数。
{
kind: FailPamAuth, hint: "authentication failure",
re: regexp.MustCompile(`authentication failure;.*?rhost=` + ipPat + `(?:\s+user=(\S+))?`),
ipIdx: 1, userIdx: 2,
},
// preauth 阶段断连:端口扫描、健康探针、LB 探活的高发来源
{
kind: FailPreauthClose, hint: "[preauth]",
re: regexp.MustCompile(`Connection (?:closed|reset) by (?:(?:invalid|authenticating) user (\S+) )?` + ipPat + ` port (\d+)`),
userIdx: 1, ipIdx: 2, portIdx: 3,
},
{
kind: FailPreauthClose, hint: "[preauth]",
re: regexp.MustCompile(`Disconnected from (?:(?:invalid|authenticating) user (\S+) )?` + ipPat + ` port (\d+)`),
userIdx: 1, ipIdx: 2, portIdx: 3,
},
// —— 扫描探测:连登录尝试都算不上,只展示不计数 ——
// HTTP/其他协议的请求打到 SSH 端口上,典型的全网端口扫描
{
kind: FailScanProbe, hint: "Bad protocol version identification",
re: regexp.MustCompile(`Bad protocol version identification .* from ` + ipPat),
ipIdx: 1,
},
// 连上就走、不发协议标识,扫描器探活的典型特征
{
kind: FailScanProbe, hint: "Did not receive identification string",
re: regexp.MustCompile(`Did not receive identification string from ` + ipPat),
ipIdx: 1,
},
}
// listenPortRe 从 `Server listening on 0.0.0.0 port 2222.` 抓 sshd 实际监听端口,
// 用于和 portdetect 的探测结果交叉验证(用户改过默认端口时)。
var listenPortRe = regexp.MustCompile(`Server listening on \S+ port (\d+)`)
// ParseSSHDLine 解析一行 sshd 日志。
// ok=false 表示该行不是登录失败,或抓到的 IP 非法(一律丢弃,不做任何猜测)。
func ParseSSHDLine(line string, now time.Time) (LoginFailEvent, bool) {
if line == "" {
return LoginFailEvent{}, false
}
for i := range sshdRules {
r := &sshdRules[i]
if !strings.Contains(line, r.hint) {
continue
}
m := r.re.FindStringSubmatch(line)
if m == nil {
continue
}
ip := groupAt(m, r.ipIdx)
if net.ParseIP(ip) == nil {
// rhost= 可能是域名、IP 可能被日志截断——都不是能拿来封禁的东西
continue
}
ev := LoginFailEvent{
Source: SourceSSH,
IP: ip,
User: groupAt(m, r.userIdx),
Kind: r.kind,
Raw: TruncRaw(line),
At: now,
}
if p := groupAt(m, r.portIdx); p != "" {
ev.Port, _ = strconv.Atoi(p)
}
// Failed <method> 规则:按 method 细分类型,并补上 invalid user 语义
if r.methodIdx > 0 {
switch strings.ToLower(groupAt(m, r.methodIdx)) {
case "publickey":
ev.Kind = FailPublicKey
default:
ev.Kind = FailPassword
}
}
return ev, true
}
return LoginFailEvent{}, false
}
// ParseSSHDListenPort 从日志行中提取 sshd 实际监听端口,未命中返回 0
func ParseSSHDListenPort(line string) int {
if !strings.Contains(line, "Server listening on") {
return 0
}
m := listenPortRe.FindStringSubmatch(line)
if m == nil {
return 0
}
p, _ := strconv.Atoi(m[1])
return p
}
// groupAt 安全取捕获组,下标为 0 或越界时返回空串
func groupAt(m []string, idx int) string {
if idx <= 0 || idx >= len(m) {
return ""
}
return m[idx]
}
+273
View File
@@ -0,0 +1,273 @@
package wafhostguard
import (
"testing"
"time"
)
// 样本全部取自真实 sshd 日志格式。这些用例是整个模块的地基:
// 解析错了会误封无辜 IP,解析漏了等于防护形同虚设。
func TestParseSSHDLine(t *testing.T) {
now := time.Now()
cases := []struct {
name string
line string
wantOK bool
wantIP string
wantUser string
wantPort int
wantKind FailKind
wantHard bool
}{
// —— 传统 syslog 行首(不带年份) ——
{
name: "密码失败-存在的用户",
line: "Aug 6 14:55:01 myhost sshd[12345]: Failed password for root from 1.2.3.4 port 51234 ssh2",
wantOK: true,
wantIP: "1.2.3.4",
wantUser: "root",
wantPort: 51234,
wantKind: FailPassword,
wantHard: true,
},
{
name: "密码失败-不存在的用户",
line: "Aug 6 14:55:02 myhost sshd[12346]: Failed password for invalid user admin from 5.6.7.8 port 40001 ssh2",
wantOK: true,
wantIP: "5.6.7.8",
wantUser: "admin",
wantPort: 40001,
wantKind: FailPassword,
wantHard: true,
},
{
name: "公钥失败",
line: "Aug 6 14:55:03 myhost sshd[12347]: Failed publickey for git from 9.9.9.9 port 33333 ssh2: RSA SHA256:xxxx",
wantOK: true,
wantIP: "9.9.9.9",
wantUser: "git",
wantPort: 33333,
wantKind: FailPublicKey,
wantHard: true,
},
{
name: "keyboard-interactive 归为密码类",
line: "Aug 6 14:55:04 myhost sshd[12348]: Failed keyboard-interactive/pam for invalid user test from 2.2.2.2 port 111 ssh2",
wantOK: true,
wantIP: "2.2.2.2",
wantUser: "test",
wantPort: 111,
wantKind: FailPassword,
wantHard: true,
},
{
name: "单连接内超过最大尝试次数",
line: "Aug 6 14:55:05 myhost sshd[12349]: error: maximum authentication attempts exceeded for root from 3.3.3.3 port 22222 ssh2 [preauth]",
wantOK: true,
wantIP: "3.3.3.3",
wantUser: "root",
wantPort: 22222,
wantKind: FailMaxAuthTries,
wantHard: true,
},
{
name: "被 AllowUsers 挡下",
line: "Aug 6 14:55:06 myhost sshd[12350]: User root from 4.4.4.4 not allowed because not listed in AllowUsers",
wantOK: true,
wantIP: "4.4.4.4",
wantUser: "root",
wantKind: FailNotAllowed,
wantHard: true,
},
// —— 软失败:解析得出来,但默认不计入阈值 ——
{
name: "用户名枚举(软失败,会与Failed password成对出现)",
line: "Aug 6 14:55:07 myhost sshd[12351]: Invalid user oracle from 6.6.6.6 port 5555",
wantOK: true,
wantIP: "6.6.6.6",
wantUser: "oracle",
wantPort: 5555,
wantKind: FailInvalidUser,
wantHard: false,
},
{
name: "PAM认证失败(软失败)",
line: "Aug 6 14:55:08 myhost sshd[12352]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=7.7.7.7 user=root",
wantOK: true,
wantIP: "7.7.7.7",
wantUser: "root",
wantKind: FailPamAuth,
wantHard: false,
},
{
name: "preauth断连(软失败,扫描器高发)",
line: "Aug 6 14:55:09 myhost sshd[12353]: Connection closed by authenticating user root 8.8.8.8 port 44444 [preauth]",
wantOK: true,
wantIP: "8.8.8.8",
wantUser: "root",
wantPort: 44444,
wantKind: FailPreauthClose,
wantHard: false,
},
{
name: "preauth重置(软失败)",
line: "Aug 6 14:55:10 myhost sshd[12354]: Connection reset by 10.20.30.40 port 44445 [preauth]",
wantOK: true,
wantIP: "10.20.30.40",
wantPort: 44445,
wantKind: FailPreauthClose,
wantHard: false,
},
{
name: "Disconnected preauth(软失败)",
line: "Aug 6 14:55:11 myhost sshd[12355]: Disconnected from invalid user ubnt 11.22.33.44 port 44446 [preauth]",
wantOK: true,
wantIP: "11.22.33.44",
wantUser: "ubnt",
wantPort: 44446,
wantKind: FailPreauthClose,
wantHard: false,
},
// —— IPv6 ——
{
name: "IPv6 密码失败",
line: "Aug 6 14:55:12 myhost sshd[12356]: Failed password for root from 2001:db8::1 port 51234 ssh2",
wantOK: true,
wantIP: "2001:db8::1",
wantUser: "root",
wantPort: 51234,
wantKind: FailPassword,
wantHard: true,
},
{
name: "IPv4-mapped IPv6",
line: "Aug 6 14:55:13 myhost sshd[12357]: Failed password for root from ::ffff:1.2.3.4 port 51235 ssh2",
wantOK: true,
wantIP: "::ffff:1.2.3.4",
wantUser: "root",
wantPort: 51235,
wantKind: FailPassword,
wantHard: true,
},
// —— RFC5424 行首 ——
{
name: "RFC5424行首",
line: "2026-08-06T14:55:14.123456+08:00 myhost sshd 12358 - - Failed password for root from 12.13.14.15 port 51236 ssh2",
wantOK: true,
wantIP: "12.13.14.15",
wantUser: "root",
wantPort: 51236,
wantKind: FailPassword,
wantHard: true,
},
// —— journalctl -o cat:没有行首 ——
{
name: "journalctl无行首",
line: "Failed password for invalid user postgres from 16.17.18.19 port 51237 ssh2",
wantOK: true,
wantIP: "16.17.18.19",
wantUser: "postgres",
wantPort: 51237,
wantKind: FailPassword,
wantHard: true,
},
// —— 必须被丢弃的 ——
{
name: "非法IP(日志被截断)整行丢弃",
line: "Aug 6 14:55:15 myhost sshd[12359]: Failed password for root from 1.2.3 port 51238 ssh2",
wantOK: false,
},
{
name: "rhost是域名而非IP,丢弃",
line: "Aug 6 14:55:16 myhost sshd[12360]: pam_unix(sshd:auth): authentication failure; rhost=evil.example.com user=root",
wantOK: false,
},
{
name: "登录成功不是失败",
line: "Aug 6 14:55:17 myhost sshd[12361]: Accepted password for root from 1.2.3.4 port 51239 ssh2",
wantOK: false,
},
{
name: "已认证连接的正常关闭(无preauth)不算失败",
line: "Aug 6 14:55:18 myhost sshd[12362]: Connection closed by 1.2.3.4 port 51240",
wantOK: false,
},
{
name: "无关日志行",
line: "Aug 6 14:55:19 myhost systemd[1]: Started Session 123 of user root.",
wantOK: false,
},
{
name: "空行",
line: "",
wantOK: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
ev, ok := ParseSSHDLine(c.line, now)
if ok != c.wantOK {
t.Fatalf("ok = %v, want %v (ev=%+v)", ok, c.wantOK, ev)
}
if !c.wantOK {
return
}
if ev.IP != c.wantIP {
t.Errorf("IP = %q, want %q", ev.IP, c.wantIP)
}
if ev.User != c.wantUser {
t.Errorf("User = %q, want %q", ev.User, c.wantUser)
}
if ev.Port != c.wantPort {
t.Errorf("Port = %d, want %d", ev.Port, c.wantPort)
}
if ev.Kind != c.wantKind {
t.Errorf("Kind = %q, want %q", ev.Kind, c.wantKind)
}
if ev.Kind.IsHard() != c.wantHard {
t.Errorf("IsHard() = %v, want %v", ev.Kind.IsHard(), c.wantHard)
}
if ev.Source != SourceSSH {
t.Errorf("Source = %q, want %q", ev.Source, SourceSSH)
}
})
}
}
func TestParseSSHDListenPort(t *testing.T) {
cases := []struct {
line string
want int
}{
{"Aug 6 14:00:00 myhost sshd[1]: Server listening on 0.0.0.0 port 22.", 22},
{"Aug 6 14:00:00 myhost sshd[1]: Server listening on :: port 22222.", 22222},
{"Aug 6 14:00:00 myhost sshd[1]: Failed password for root from 1.2.3.4 port 51234 ssh2", 0},
{"", 0},
}
for _, c := range cases {
if got := ParseSSHDListenPort(c.line); got != c.want {
t.Errorf("ParseSSHDListenPort(%q) = %d, want %d", c.line, got, c.want)
}
}
}
// TruncRaw 必须真的截断,否则超长日志行会把事件表撑爆
func TestTruncRaw(t *testing.T) {
long := make([]byte, RawLimit+100)
for i := range long {
long[i] = 'a'
}
if got := len(TruncRaw(string(long))); got != RawLimit {
t.Errorf("len = %d, want %d", got, RawLimit)
}
if got := TruncRaw("short"); got != "short" {
t.Errorf("got %q, want %q", got, "short")
}
}
+204
View File
@@ -0,0 +1,204 @@
package wafhostguard
import (
"encoding/xml"
"net"
"regexp"
"strconv"
"strings"
"time"
)
// Windows 事件日志解析。与 sshd 解析器一样是纯逻辑、无 build tag,
// 可以在 Linux/macOS 上跑单测。
//
// 涉及两个频道:
// - Security 的 4625:登录失败。带用户名、登录类型,但**源 IP 可能是 "-"**。
// - RemoteDesktopServices-RdpCoreTS/Operational 的 131/140:RDP 连接层事件,
// 带真实客户端 IP,用来补 4625 缺失的来源。
// winEvent 只取需要的字段。EventData/UserData 的结构在不同 Windows 版本里差异很大,
// 所以 UserData 一律按原始文本处理,用正则抓 IP,而不是硬编码字段名——
// 硬编码的后果是换个系统版本就静默失效。
type winEvent struct {
XMLName xml.Name `xml:"Event"`
System struct {
EventID int `xml:"EventID"`
Channel string `xml:"Channel"`
EventRecordID uint64 `xml:"EventRecordID"`
TimeCreated struct {
SystemTime string `xml:"SystemTime,attr"`
} `xml:"TimeCreated"`
} `xml:"System"`
EventData struct {
Data []struct {
Name string `xml:"Name,attr"`
Value string `xml:",chardata"`
} `xml:"Data"`
} `xml:"EventData"`
UserData struct {
Inner string `xml:",innerxml"`
} `xml:"UserData"`
}
// winIPRe 从任意文本里抓形似 IP 的片段,抓完交 net.ParseIP 严格校验
var winIPRe = regexp.MustCompile(`\b([0-9]{1,3}(?:\.[0-9]{1,3}){3})\b|\[?([0-9a-fA-F]*:[0-9a-fA-F:.]+)\]?`)
// Parse4625 解析安全日志 4625(登录失败)。
//
// needResolve=true 表示这条事件的 IpAddress 不可用("-"、空、或环回),
// 必须靠 RdpCoreTS 的 131/140 事件按时间关联补齐;补不到就只能丢弃。
func Parse4625(xmlStr string, now time.Time) (ev LoginFailEvent, needResolve bool, ok bool) {
var e winEvent
if err := xml.Unmarshal([]byte(xmlStr), &e); err != nil {
return LoginFailEvent{}, false, false
}
if e.System.EventID != 4625 {
return LoginFailEvent{}, false, false
}
data := make(map[string]string, len(e.EventData.Data))
for _, d := range e.EventData.Data {
data[d.Name] = strings.TrimSpace(d.Value)
}
logonType := data["LogonType"]
// 只关心远程登录:10=RemoteInteractive(RDP)、3=Network(SMB/IPC$ 等)。
// 2(本地交互)、4(批处理)、5(服务)这些是本机行为,封了毫无意义还可能误伤。
if logonType != "10" && logonType != "3" {
return LoginFailEvent{}, false, false
}
at := now
if t, err := time.Parse(time.RFC3339, e.System.TimeCreated.SystemTime); err == nil {
at = t
}
ev = LoginFailEvent{
Source: SourceRDP,
User: data["TargetUserName"],
Kind: FailRdpLogon,
LogonType: logonType,
Raw: TruncRaw(summarize4625(data, e.System.EventRecordID)),
At: at,
}
if p := data["IpPort"]; p != "" {
ev.Port, _ = strconv.Atoi(p)
}
ip := data["IpAddress"]
if !usableRemoteIP(ip) {
return ev, true, true
}
ev.IP = normalizeIP(ip)
return ev, false, true
}
// ParseRdpCoreTSIP 从 RdpCoreTS 131/140 事件里抓客户端 IP。
//
// 140:无法从客户端 <IP> 建立连接(认证失败/协议错误)
// 131:服务器接受了来自 <IP:port> 的新 TCP 连接
//
// 字段名随版本变化(IPString / ClientIP / Param1 ...),所以直接扫 UserData 的
// 全部文本找第一个合法 IP,不依赖具体字段名。
func ParseRdpCoreTSIP(xmlStr string, now time.Time) (ip string, at time.Time, ok bool) {
var e winEvent
if err := xml.Unmarshal([]byte(xmlStr), &e); err != nil {
return "", time.Time{}, false
}
if e.System.EventID != 131 && e.System.EventID != 140 {
return "", time.Time{}, false
}
at = now
if t, err := time.Parse(time.RFC3339, e.System.TimeCreated.SystemTime); err == nil {
at = t
}
if found := findFirstIP(e.UserData.Inner); found != "" {
return found, at, true
}
// 少数版本把内容放在 EventData 里
for _, d := range e.EventData.Data {
if found := findFirstIP(d.Value); found != "" {
return found, at, true
}
}
return "", at, false
}
// findFirstIP 从文本中找出第一个可用的远端 IP
func findFirstIP(s string) string {
if s == "" {
return ""
}
for _, m := range winIPRe.FindAllStringSubmatch(s, -1) {
for _, candidate := range m[1:] {
candidate = strings.Trim(strings.TrimSpace(candidate), "[]")
if candidate == "" {
continue
}
if !usableRemoteIP(candidate) {
continue
}
return normalizeIP(candidate)
}
}
return ""
}
// usableRemoteIP 判断这个字符串是不是一个能拿来封禁的远端地址。
//
// 排除掉 "-"(Windows 表示"未知")、环回、未指定地址。这些都不是攻击者,
// 封了只会伤到自己。**绝不做任何猜测性兜底**——宁可漏封一次,不可错封一个。
func usableRemoteIP(s string) bool {
s = strings.TrimSpace(s)
if s == "" || s == "-" {
return false
}
// 131 事件的 ClientIP 形如 1.2.3.4:51234 或 [::1]:5555
if host, _, err := net.SplitHostPort(s); err == nil {
s = host
}
s = strings.Trim(s, "[]")
ip := net.ParseIP(s)
if ip == nil {
return false
}
if ip.IsLoopback() || ip.IsUnspecified() {
return false
}
return true
}
// normalizeIP 去掉端口与方括号,返回规范化的 IP 文本
func normalizeIP(s string) string {
s = strings.TrimSpace(s)
if host, _, err := net.SplitHostPort(s); err == nil {
s = host
}
s = strings.Trim(s, "[]")
if ip := net.ParseIP(s); ip != nil {
return ip.String()
}
return s
}
// summarize4625 生成一行可读摘要入库,比塞整段 XML 有用得多
func summarize4625(data map[string]string, recordID uint64) string {
var b strings.Builder
b.WriteString("EventID=4625")
if recordID > 0 {
b.WriteString(" RecordID=")
b.WriteString(strconv.FormatUint(recordID, 10))
}
for _, k := range []string{"TargetUserName", "TargetDomainName", "LogonType", "IpAddress", "IpPort", "WorkstationName", "Status", "SubStatus", "FailureReason"} {
if v := data[k]; v != "" {
b.WriteString(" ")
b.WriteString(k)
b.WriteString("=")
b.WriteString(v)
}
}
return b.String()
}
+273
View File
@@ -0,0 +1,273 @@
package wafhostguard
import (
"testing"
"time"
)
// 4625 有源IP:最常见的 RDP 密码爆破
const evt4625WithIP = `<Event xmlns="http://schemas.microsoft.com/win/2004/08/events/event">
<System>
<Provider Name="Microsoft-Windows-Security-Auditing"/>
<EventID>4625</EventID>
<TimeCreated SystemTime="2026-08-06T06:55:01.1234567Z"/>
<EventRecordID>123456</EventRecordID>
<Channel>Security</Channel>
</System>
<EventData>
<Data Name="TargetUserName">administrator</Data>
<Data Name="TargetDomainName">WORKGROUP</Data>
<Data Name="LogonType">10</Data>
<Data Name="IpAddress">1.2.3.4</Data>
<Data Name="IpPort">51234</Data>
<Data Name="WorkstationName">DESKTOP-ATTACK</Data>
<Data Name="Status">0xc000006d</Data>
</EventData>
</Event>`
// 4625 无源IP:未知用户名场景,IpAddress 是 "-"
const evt4625NoIP = `<Event xmlns="http://schemas.microsoft.com/win/2004/08/events/event">
<System>
<EventID>4625</EventID>
<TimeCreated SystemTime="2026-08-06T06:55:02.0000000Z"/>
<EventRecordID>123457</EventRecordID>
<Channel>Security</Channel>
</System>
<EventData>
<Data Name="TargetUserName">nosuchuser</Data>
<Data Name="LogonType">10</Data>
<Data Name="IpAddress">-</Data>
<Data Name="IpPort">-</Data>
<Data Name="WorkstationName">DESKTOP-ATTACK</Data>
</EventData>
</Event>`
// 4625 本地登录:LogonType=2,本机行为,不该被采集
const evt4625Local = `<Event>
<System><EventID>4625</EventID><Channel>Security</Channel></System>
<EventData>
<Data Name="TargetUserName">user</Data>
<Data Name="LogonType">2</Data>
<Data Name="IpAddress">127.0.0.1</Data>
</EventData>
</Event>`
// 4625 网络登录:LogonType=3(SMB/IPC$)
const evt4625Network = `<Event>
<System><EventID>4625</EventID><Channel>Security</Channel>
<TimeCreated SystemTime="2026-08-06T06:55:03.0000000Z"/></System>
<EventData>
<Data Name="TargetUserName">baduser</Data>
<Data Name="LogonType">3</Data>
<Data Name="IpAddress">5.6.7.8</Data>
<Data Name="IpPort">44444</Data>
</EventData>
</Event>`
// 4625 IPv6 源
const evt4625IPv6 = `<Event>
<System><EventID>4625</EventID><Channel>Security</Channel>
<TimeCreated SystemTime="2026-08-06T06:55:04.0000000Z"/></System>
<EventData>
<Data Name="TargetUserName">root</Data>
<Data Name="LogonType">10</Data>
<Data Name="IpAddress">2001:db8::1</Data>
<Data Name="IpPort">51235</Data>
</EventData>
</Event>`
// 4625 环回来源:本机自己发起的,封了会伤到自己
const evt4625Loopback = `<Event>
<System><EventID>4625</EventID><Channel>Security</Channel></System>
<EventData>
<Data Name="TargetUserName">user</Data>
<Data Name="LogonType">10</Data>
<Data Name="IpAddress">127.0.0.1</Data>
</EventData>
</Event>`
// RdpCoreTS 140:无法从客户端建立连接
const evtRdp140 = `<Event xmlns="http://schemas.microsoft.com/win/2004/08/events/event">
<System>
<EventID>140</EventID>
<TimeCreated SystemTime="2026-08-06T06:55:00.5000000Z"/>
<EventRecordID>888</EventRecordID>
<Channel>Microsoft-Windows-RemoteDesktopServices-RdpCoreTS/Operational</Channel>
</System>
<UserData>
<EventXML xmlns="Event_NS"><IPString>9.8.7.6</IPString></EventXML>
</UserData>
</Event>`
// RdpCoreTS 131:新 TCP 连接,ClientIP 带端口
const evtRdp131 = `<Event>
<System>
<EventID>131</EventID>
<TimeCreated SystemTime="2026-08-06T06:55:00.1000000Z"/>
<Channel>Microsoft-Windows-RemoteDesktopServices-RdpCoreTS/Operational</Channel>
</System>
<UserData>
<EventXML xmlns="Event_NS"><ConnType>TCP</ConnType><ClientIP>11.22.33.44:59876</ClientIP></EventXML>
</UserData>
</Event>`
func TestParse4625(t *testing.T) {
now := time.Now()
t.Run("RDP密码爆破-有源IP", func(t *testing.T) {
ev, needResolve, ok := Parse4625(evt4625WithIP, now)
if !ok {
t.Fatal("应当解析成功")
}
if needResolve {
t.Error("已有源IP,不该要求补齐")
}
if ev.IP != "1.2.3.4" {
t.Errorf("IP = %q, want 1.2.3.4", ev.IP)
}
if ev.User != "administrator" {
t.Errorf("User = %q", ev.User)
}
if ev.Port != 51234 {
t.Errorf("Port = %d", ev.Port)
}
if ev.LogonType != "10" {
t.Errorf("LogonType = %q", ev.LogonType)
}
if ev.Source != SourceRDP {
t.Errorf("Source = %q", ev.Source)
}
if !ev.Kind.IsHard() {
t.Error("RDP 登录失败应当计入阈值")
}
})
t.Run("无源IP必须要求补齐而不是猜", func(t *testing.T) {
ev, needResolve, ok := Parse4625(evt4625NoIP, now)
if !ok {
t.Fatal("应当解析成功(拿到用户名等信息)")
}
if !needResolve {
t.Fatal("IpAddress 为 - 时必须要求补齐")
}
if ev.IP != "" {
t.Errorf("补齐前 IP 必须为空,不能瞎填,got %q", ev.IP)
}
})
t.Run("本地登录不采集", func(t *testing.T) {
if _, _, ok := Parse4625(evt4625Local, now); ok {
t.Error("LogonType=2 是本机交互登录,不该被当成远程爆破")
}
})
t.Run("网络登录采集", func(t *testing.T) {
ev, needResolve, ok := Parse4625(evt4625Network, now)
if !ok || needResolve {
t.Fatalf("ok=%v needResolve=%v", ok, needResolve)
}
if ev.IP != "5.6.7.8" || ev.LogonType != "3" {
t.Errorf("ev = %+v", ev)
}
})
t.Run("IPv6源", func(t *testing.T) {
ev, needResolve, ok := Parse4625(evt4625IPv6, now)
if !ok || needResolve {
t.Fatalf("ok=%v needResolve=%v", ok, needResolve)
}
if ev.IP != "2001:db8::1" {
t.Errorf("IP = %q", ev.IP)
}
})
t.Run("环回来源要求补齐而不是直接封本机", func(t *testing.T) {
_, needResolve, ok := Parse4625(evt4625Loopback, now)
if !ok {
t.Fatal("应当解析成功")
}
if !needResolve {
t.Error("环回地址不是可封禁目标,必须走补齐流程")
}
})
t.Run("非4625事件不处理", func(t *testing.T) {
if _, _, ok := Parse4625(evtRdp140, now); ok {
t.Error("140 不是 4625")
}
})
t.Run("非法XML不panic", func(t *testing.T) {
if _, _, ok := Parse4625("not xml at all", now); ok {
t.Error("非法内容不该解析成功")
}
})
}
func TestParseRdpCoreTSIP(t *testing.T) {
now := time.Now()
t.Run("140事件IPString", func(t *testing.T) {
ip, _, ok := ParseRdpCoreTSIP(evtRdp140, now)
if !ok {
t.Fatal("应当解析成功")
}
if ip != "9.8.7.6" {
t.Errorf("ip = %q, want 9.8.7.6", ip)
}
})
t.Run("131事件ClientIP带端口需去掉端口", func(t *testing.T) {
ip, _, ok := ParseRdpCoreTSIP(evtRdp131, now)
if !ok {
t.Fatal("应当解析成功")
}
if ip != "11.22.33.44" {
t.Errorf("ip = %q, want 11.22.33.44(端口必须被剥掉)", ip)
}
})
t.Run("非RdpCoreTS事件不处理", func(t *testing.T) {
if _, _, ok := ParseRdpCoreTSIP(evt4625WithIP, now); ok {
t.Error("4625 不是 131/140")
}
})
}
func TestUsableRemoteIP(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"1.2.3.4", true},
{"2001:db8::1", true},
{"1.2.3.4:5555", true},
{"[2001:db8::1]:5555", true},
{"-", false}, // Windows 表示"未知"
{"", false}, // 空
{"127.0.0.1", false}, // 环回,封了伤自己
{"::1", false},
{"0.0.0.0", false}, // 未指定地址
{"not-an-ip", false}, // 垃圾数据
{"DESKTOP-ABC", false}, // WorkstationName 不是 IP,绝不能当来源
}
for _, c := range cases {
if got := usableRemoteIP(c.in); got != c.want {
t.Errorf("usableRemoteIP(%q) = %v, want %v", c.in, got, c.want)
}
}
}
func TestNormalizeIP(t *testing.T) {
cases := []struct{ in, want string }{
{"1.2.3.4", "1.2.3.4"},
{"1.2.3.4:5555", "1.2.3.4"},
{"[2001:db8::1]:5555", "2001:db8::1"},
{"2001:DB8::1", "2001:db8::1"},
}
for _, c := range cases {
if got := normalizeIP(c.in); got != c.want {
t.Errorf("normalizeIP(%q) = %q, want %q", c.in, got, c.want)
}
}
}
+219
View File
@@ -0,0 +1,219 @@
package wafhostguard
import (
"SamWaf/common/zlog"
"SamWaf/global"
"context"
"strconv"
"strings"
"sync"
"time"
gnet "github.com/shirou/gopsutil/v4/net"
"github.com/shirou/gopsutil/v4/process"
)
// SSH/RDP 实际监听端口的自动发现。
//
// **注意:爆破检测本身完全不依赖端口。** sshd 日志里的 `port N` 是源端口,
// Windows 4625 压根不带服务端口,所以用户把 SSH 改成 22222、RDP 改成 33890
// 检测照样工作。这个文件只服务两个次要用途:
// 1. 端口级封禁(只封 SSH/RDP 端口而不是全端口,误封时杀伤面更小)
// 2. 连接看板里高亮远程管理端口
//
// 探测方式:取 LISTEN 状态的连接 + 进程名匹配。用户也可以在设置里手工指定,
// 手工值优先——自动发现只是省事,不是唯一真相。
// 进程名到来源的映射。Windows 上 RDP 由 svchost 承载 TermService,
// 进程名匹配不到,所以额外用默认端口兜底。
var (
sshProcNames = []string{"sshd", "sshd-session"}
rdpProcNames = []string{"termsrv", "svchost"}
)
// 默认端口。自动发现失败时的兜底值。
const (
defaultSSHPort = 22
defaultRDPPort = 3389
)
type portSnapshot struct {
ssh []int
rdp []int
builtAt time.Time
auto bool // 是否来自自动发现(false=用户手工配置)
}
var (
portMu sync.RWMutex
portCur *portSnapshot
portTTL = 5 * time.Minute
portDetect = detectListenPorts // 便于测试替换
)
// InvalidatePorts 丢弃端口缓存,下次取用时重新探测(配置变更时调用)
func InvalidatePorts() {
portMu.Lock()
portCur = nil
portMu.Unlock()
}
// GuardPorts 返回当前认定的 SSH / RDP 端口。
// 用户手工配置了就用手工的,否则自动探测;探测不到用默认端口兜底。
func GuardPorts() (ssh []int, rdp []int) {
portMu.RLock()
if portCur != nil && time.Since(portCur.builtAt) < portTTL {
snap := portCur
portMu.RUnlock()
return snap.ssh, snap.rdp
}
portMu.RUnlock()
portMu.Lock()
defer portMu.Unlock()
if portCur != nil && time.Since(portCur.builtAt) < portTTL {
return portCur.ssh, portCur.rdp
}
snap := &portSnapshot{builtAt: time.Now(), auto: true}
manualSSH := parsePorts(global.GCONFIG_HOST_GUARD_SSH_PORTS)
manualRDP := parsePorts(global.GCONFIG_HOST_GUARD_RDP_PORTS)
if len(manualSSH) > 0 || len(manualRDP) > 0 {
snap.auto = false
}
var autoSSH, autoRDP []int
if len(manualSSH) == 0 || len(manualRDP) == 0 {
autoSSH, autoRDP = portDetect()
}
snap.ssh = pickPorts(manualSSH, autoSSH, defaultSSHPort)
snap.rdp = pickPorts(manualRDP, autoRDP, defaultRDPPort)
portCur = snap
return snap.ssh, snap.rdp
}
// pickPorts 手工 > 自动 > 默认
func pickPorts(manual, auto []int, fallback int) []int {
if len(manual) > 0 {
return manual
}
if len(auto) > 0 {
return auto
}
return []int{fallback}
}
// detectListenPorts 用 gopsutil 找 LISTEN 端口并按进程名归类。
// 拿不到进程名(权限不足/进程已退出)时按默认端口反查兜底。
func detectListenPorts() (ssh []int, rdp []int) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conns, err := gnet.ConnectionsWithContext(ctx, "tcp")
if err != nil {
zlog.Debug("[主机登录防护] 探测监听端口失败", "error", err.Error())
return nil, nil
}
sshSet := make(map[int]struct{})
rdpSet := make(map[int]struct{})
nameCache := make(map[int32]string)
for _, c := range conns {
if !strings.EqualFold(c.Status, "LISTEN") {
continue
}
port := int(c.Laddr.Port)
if port == 0 {
continue
}
// 默认端口无条件认领,不依赖进程名——Windows 上 RDP 挂在 svchost 里认不出来
if port == defaultSSHPort {
sshSet[port] = struct{}{}
continue
}
if port == defaultRDPPort {
rdpSet[port] = struct{}{}
continue
}
if c.Pid <= 0 {
continue
}
name, ok := nameCache[c.Pid]
if !ok {
name = procName(ctx, c.Pid)
nameCache[c.Pid] = name
}
if name == "" {
continue
}
lower := strings.ToLower(name)
if matchAny(lower, sshProcNames) {
sshSet[port] = struct{}{}
} else if matchAny(lower, rdpProcNames) {
// svchost 承载着一大堆服务,不能仅凭进程名就把它的端口全当成 RDP。
// 所以这里只在进程名明确是 termsrv 时才认领;svchost 一律靠上面的默认端口分支。
if strings.Contains(lower, "termsrv") {
rdpSet[port] = struct{}{}
}
}
}
return sortedKeys(sshSet), sortedKeys(rdpSet)
}
// procName 取进程名,失败返回空串
func procName(ctx context.Context, pid int32) string {
p, err := process.NewProcessWithContext(ctx, pid)
if err != nil {
return ""
}
name, err := p.NameWithContext(ctx)
if err != nil {
return ""
}
return name
}
func matchAny(s string, candidates []string) bool {
for _, c := range candidates {
if strings.Contains(s, c) {
return true
}
}
return false
}
func sortedKeys(m map[int]struct{}) []int {
if len(m) == 0 {
return nil
}
out := make([]int, 0, len(m))
for k := range m {
out = append(out, k)
}
// 端口数量极少(通常 1~2 个),插入排序比引 sort 更直白
for i := 1; i < len(out); i++ {
for j := i; j > 0 && out[j] < out[j-1]; j-- {
out[j], out[j-1] = out[j-1], out[j]
}
}
return out
}
// parsePorts 解析逗号分隔的端口配置,忽略非法项
func parsePorts(s string) []int {
items := splitList(s)
if len(items) == 0 {
return nil
}
out := make([]int, 0, len(items))
for _, item := range items {
p, err := strconv.Atoi(item)
if err != nil || p <= 0 || p > 65535 {
continue
}
out = append(out, p)
}
return out
}
+65
View File
@@ -0,0 +1,65 @@
//go:build linux
package wafhostguard
import (
"SamWaf/common/zlog"
"errors"
"os/exec"
)
// defaultAuthLogPaths 常见发行版的认证日志位置,按探测优先级排列
var defaultAuthLogPaths = []string{
"/var/log/secure", // RHEL / CentOS / Rocky / Alma / openEuler
"/var/log/auth.log", // Debian / Ubuntu
"/var/log/messages", // 部分老系统把认证日志也写在这
}
var errJournalTooManyRestarts = errors.New("journalctl 反复异常退出,已停止重启")
// newSources Linux 事件源装配。
//
// 探测顺序刻意如此:
// 1. 用户显式配的路径 —— 容器场景唯一的逃生口,配了就只认它,
// 不能悄悄回落到自动探测,否则用户以为配好了实际读的是别的文件。
// 2. 自动探测常见路径。
// 3. journalctl 兜底。
func newSources() ([]Source, string) {
if paths := splitList(globalLogPaths()); len(paths) > 0 {
var srcs []Source
for _, p := range paths {
if readable(p) {
srcs = append(srcs, &fileTailSource{path: p})
} else {
zlog.Warn("[主机登录防护] 自定义日志路径不可读,已跳过", "path", p)
}
}
if len(srcs) > 0 {
return srcs, ""
}
ok, reason, _ := checkLogCapability()
if !ok {
return nil, reason
}
}
var srcs []Source
for _, p := range defaultAuthLogPaths {
if readable(p) {
srcs = append(srcs, &fileTailSource{path: p})
// 只取第一个命中的:RHEL 系同时存在 secure 和 messages 时,
// 两个文件里是同一批认证日志,都读会让每次失败被计两次,阈值直接腰斩
break
}
}
if len(srcs) > 0 {
return srcs, ""
}
if _, err := exec.LookPath("journalctl"); err == nil {
return []Source{&journalSource{}}, ""
}
_, reason, _ := checkLogCapability()
return nil, reason
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !linux && !windows
package wafhostguard
// newSources 非 Linux/Windows 平台(主要是 macOS)的空实现。
//
// 这个文件的存在纯粹是为了让 macOS 能编译通过 —— build-releases-mac-amd.bat /
// -arm.bat 说明 darwin 是正式构建目标,漏掉它整个 mac 版本就构建不出来。
// 返回空源 + 中文原因,引擎会据此把自己标记为"当前环境不可用"并安静待命。
func newSources() ([]Source, string) {
_, reason, _ := checkLogCapability()
return nil, reason
}
+184
View File
@@ -0,0 +1,184 @@
//go:build windows
package wafhostguard
import (
"SamWaf/common/wafexec"
"SamWaf/common/zlog"
"context"
"encoding/xml"
"os/exec"
"strings"
"time"
)
// wevtapi 绑定失败(精简版系统、DLL 缺函数、频道无权限)时的降级通道:
// 定期调 wevtutil 查最近的事件,用 EventRecordID 做书签去重。
//
// 比订阅慢(默认 5 秒一轮),但零绑定风险。宁可慢一点也不能整个功能在
// 某些系统上直接不可用。
const (
// wevtutilInterval 轮询间隔
wevtutilInterval = 5 * time.Second
// wevtutilBatch 单次最多取多少条。5 秒内 4625 超过这个数说明正在被猛攻,
// 丢掉更早的那些也无所谓——阈值早就触发了。
wevtutilBatch = 200
// wevtutilTimeout 单次命令超时
wevtutilTimeout = 15 * time.Second
)
// runWevtutil 降级模式主循环
func (s *winEventSource) runWevtutil(ctx context.Context, out chan<- LoginFailEvent) error {
var lastSecurityID uint64
var lastRdpID uint64
ticker := time.NewTicker(wevtutilInterval)
defer ticker.Stop()
// 首轮先把书签推到当前最新,避免把历史事件当成刚发生的
lastSecurityID = latestRecordID(ctx, securityChannel, security4625Qry)
lastRdpID = latestRecordID(ctx, rdpCoreChannel, rdpCoreQry)
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
}
// 先收 RdpCoreTS,让环形缓冲里先有 IP,再处理 4625 才补得上
if xmls, maxID := queryEvents(ctx, rdpCoreChannel, rdpCoreQry, lastRdpID); len(xmls) > 0 {
for _, x := range xmls {
if ip, at, ok := ParseRdpCoreTSIP(x, time.Now()); ok {
s.ring.add(ip, at)
}
}
if maxID > lastRdpID {
lastRdpID = maxID
}
}
xmls, maxID := queryEvents(ctx, securityChannel, security4625Qry, lastSecurityID)
for _, x := range xmls {
s.handle4625(ctx, x, out)
}
if maxID > lastSecurityID {
lastSecurityID = maxID
}
}
}
// queryEvents 查 afterID 之后的新事件,返回 XML 列表与本批最大 RecordID。
// 结果按时间倒序返回,这里翻转成正序处理,保证 4625 的处理顺序与发生顺序一致。
func queryEvents(ctx context.Context, channel, query string, afterID uint64) ([]string, uint64) {
raw := runWevtutilQuery(ctx, channel, query, wevtutilBatch)
if len(raw) == 0 {
return nil, afterID
}
maxID := afterID
out := make([]string, 0, len(raw))
// raw 是倒序(最新在前),倒着遍历得到正序
for i := len(raw) - 1; i >= 0; i-- {
id := recordIDOf(raw[i])
if id <= afterID {
continue
}
if id > maxID {
maxID = id
}
out = append(out, raw[i])
}
return out, maxID
}
// latestRecordID 取该频道当前最新一条的 RecordID,用作初始书签
func latestRecordID(ctx context.Context, channel, query string) uint64 {
raw := runWevtutilQuery(ctx, channel, query, 1)
if len(raw) == 0 {
return 0
}
return recordIDOf(raw[0])
}
// runWevtutil 执行查询并把输出拆成一条条 XML
func runWevtutilQuery(ctx context.Context, channel, query string, count int) []string {
cctx, cancel := context.WithTimeout(ctx, wevtutilTimeout)
defer cancel()
cmd := exec.CommandContext(cctx, "wevtutil", "qe", channel,
"/q:"+query,
"/f:RenderedXml",
"/c:"+itoa(count),
"/rd:true") // 倒序,最新的在前
out, err := wafexec.FixStdin(cmd).Output()
if err != nil {
if cctx.Err() == nil {
zlog.Debug("[主机登录防护] wevtutil 查询失败", "channel", channel, "error", err.Error())
}
return nil
}
return splitEventXML(string(out))
}
// splitEventXML 把 wevtutil 连续输出的多个 <Event>...</Event> 拆开。
// wevtutil 不输出根元素,多条事件是首尾相接的,不能整体 Unmarshal。
func splitEventXML(s string) []string {
var out []string
for {
start := strings.Index(s, "<Event ")
if start < 0 {
start = strings.Index(s, "<Event>")
}
if start < 0 {
return out
}
end := strings.Index(s[start:], "</Event>")
if end < 0 {
return out
}
end += start + len("</Event>")
out = append(out, s[start:end])
s = s[end:]
}
}
// recordIDOf 抽 EventRecordID 用作书签
func recordIDOf(xmlStr string) uint64 {
var e winEvent
if err := xml.Unmarshal([]byte(xmlStr), &e); err != nil {
return 0
}
return e.System.EventRecordID
}
func itoa(n int) string {
if n <= 0 {
return "1"
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
return string(buf[i:])
}
// newSources Windows 事件源装配:优先 wevtapi 订阅,不可用则降级 wevtutil 轮询
func newSources() ([]Source, string) {
ok, reason, _ := checkLogCapability()
if !ok {
return nil, reason
}
src := &winEventSource{ring: newRDPIPRing()}
if err := wevtAvailable(); err != nil {
src.fallback = true
zlog.Warn("[主机登录防护] wevtapi 不可用,已降级为 wevtutil 轮询(每5秒一次,实时性略差但功能不受影响)",
"error", err.Error())
}
return []Source{src}, ""
}
+230
View File
@@ -0,0 +1,230 @@
//go:build windows
package wafhostguard
import (
"SamWaf/common/zlog"
"context"
"sync"
"time"
"golang.org/x/sys/windows"
)
// Windows 事件源。订阅两个频道:
//
// Security 4625 登录失败(主信号)
// Microsoft-Windows-RemoteDesktopServices-RdpCoreTS/Operational 131/140 客户端 IP
//
// 之所以要订第二个:4625 在"用户名不存在"等场景下 IpAddress 会是 "-",
// 光凭它无法知道谁在爆破。RdpCoreTS 的 131/140 记录了 TCP 连接层的真实客户端地址,
// 按时间戳把两者关联起来就能补齐。
const (
securityChannel = "Security"
security4625Qry = "*[System[(EventID=4625)]]"
rdpCoreChannel = "Microsoft-Windows-RemoteDesktopServices-RdpCoreTS/Operational"
rdpCoreQry = "*[System[(EventID=131 or EventID=140)]]"
// 等待信号的超时,同时决定了 Stop() 的最坏响应时间
wevtWaitMs = 1000
)
// IP 关联参数
const (
// rdpRingSize 环形缓冲容量。一次 RDP 爆破里 131/140 事件来得很密,
// 256 条足够覆盖关联窗口,又不会占多少内存。
rdpRingSize = 256
// rdpRingTTL 缓冲里的条目多久算过期
rdpRingTTL = 60 * time.Second
// rdpMatchWindow 4625 与 RdpCoreTS 事件的时间容差
rdpMatchWindow = 10 * time.Second
// rdpPendingWait 4625 拿不到 IP 时最多等多久(RdpCoreTS 有时晚于 4625 落盘)
rdpPendingWait = 3 * time.Second
// rdpPendingRetry 等待期间的重试间隔
rdpPendingRetry = 500 * time.Millisecond
)
// rdpIPEntry 一条客户端 IP 记录
type rdpIPEntry struct {
ip string
at time.Time
}
// rdpIPRing 时间有序的环形缓冲,用来给 4625 补源 IP
type rdpIPRing struct {
mu sync.Mutex
entries []rdpIPEntry
}
func newRDPIPRing() *rdpIPRing {
return &rdpIPRing{entries: make([]rdpIPEntry, 0, rdpRingSize)}
}
func (r *rdpIPRing) add(ip string, at time.Time) {
r.mu.Lock()
defer r.mu.Unlock()
r.entries = append(r.entries, rdpIPEntry{ip: ip, at: at})
if len(r.entries) > rdpRingSize {
r.entries = r.entries[len(r.entries)-rdpRingSize:]
}
}
// lookup 找时间上最接近 target 的一条 IP 记录。
//
// 命中的条目**不删除**:一次 RDP 连接会产生多条 4625(用户反复输错密码),
// 它们共用同一个 TCP 连接的 131 事件,删掉的话第二条 4625 就补不到 IP 了。
func (r *rdpIPRing) lookup(target time.Time) (string, bool) {
r.mu.Lock()
defer r.mu.Unlock()
best := ""
var bestDiff time.Duration
cutoff := time.Now().Add(-rdpRingTTL)
for _, e := range r.entries {
if e.at.Before(cutoff) {
continue
}
diff := e.at.Sub(target)
if diff < 0 {
diff = -diff
}
if diff > rdpMatchWindow {
continue
}
if best == "" || diff < bestDiff {
best, bestDiff = e.ip, diff
}
}
return best, best != ""
}
// winEventSource Windows 事件源
type winEventSource struct {
ring *rdpIPRing
fallback bool // 是否已降级到 wevtutil 轮询
}
func (s *winEventSource) Name() string {
if s.fallback {
return "wevtutil(轮询)"
}
return "wevtapi(Security 4625 + RdpCoreTS 131/140)"
}
func (s *winEventSource) Run(ctx context.Context, out chan<- LoginFailEvent) error {
if s.fallback {
return s.runWevtutil(ctx, out)
}
var wg sync.WaitGroup
// RdpCoreTS 频道:只负责往环形缓冲里塞 IP。
// 这个频道在 Win7 精简版或被禁用时打不开,属于"降级但不致命"——
// 主频道的 4625 只要自带 IP 就照常工作。
wg.Add(1)
go func() {
defer wg.Done()
err := s.subscribeLoop(ctx, rdpCoreChannel, rdpCoreQry, func(xmlStr string) {
if ip, at, ok := ParseRdpCoreTSIP(xmlStr, time.Now()); ok {
s.ring.add(ip, at)
}
})
if err != nil && ctx.Err() == nil {
zlog.Warn("[主机登录防护] RDP 详细日志频道不可用,无源IP的 4625 事件将被丢弃。"+
"可在 事件查看器 → 应用程序和服务日志 → Microsoft → Windows → "+
"RemoteDesktopServices-RdpCoreTS 中启用 Operational 日志", "error", err.Error())
}
}()
// Security 频道:主信号
err := s.subscribeLoop(ctx, securityChannel, security4625Qry, func(xmlStr string) {
s.handle4625(ctx, xmlStr, out)
})
wg.Wait()
return err
}
// subscribeLoop 订阅一个频道并持续拉取,直到 ctx 取消
func (s *winEventSource) subscribeLoop(ctx context.Context, channel, query string, onEvent func(string)) error {
signal, err := windows.CreateEvent(nil, 1 /*手动重置*/, 0, nil)
if err != nil {
return err
}
defer windows.CloseHandle(signal)
sub, err := evtSubscribe(signal, channel, query)
if err != nil {
return err
}
defer evtClose(sub)
for {
if ctx.Err() != nil {
return nil
}
xmls, err := evtNextXML(sub, signal, wevtWaitMs)
if err != nil {
if ctx.Err() != nil {
return nil
}
zlog.Debug("[主机登录防护] 读取Windows事件失败", "channel", channel, "error", err.Error())
// 单次失败不退订,睡一下继续——事件日志服务偶发抖动很常见
select {
case <-ctx.Done():
return nil
case <-time.After(time.Second):
}
continue
}
for _, x := range xmls {
onEvent(x)
}
}
}
// handle4625 处理一条登录失败事件,必要时等 RdpCoreTS 补 IP
func (s *winEventSource) handle4625(ctx context.Context, xmlStr string, out chan<- LoginFailEvent) {
ev, needResolve, ok := Parse4625(xmlStr, time.Now())
if !ok {
return
}
if needResolve {
// RdpCoreTS 事件有时比 4625 晚落盘,给它一点时间
deadline := time.Now().Add(rdpPendingWait)
for {
if ip, found := s.ring.lookup(ev.At); found {
ev.IP = ip
needResolve = false
break
}
if time.Now().After(deadline) {
break
}
select {
case <-ctx.Done():
return
case <-time.After(rdpPendingRetry):
}
}
}
if needResolve || ev.IP == "" {
// 补不到就丢。绝不用 WorkstationName 反查、也绝不拿"最近一个IP"顶上——
// 那种兜底一旦猜错,被封的是一个完全无辜的地址。
dropEvent()
zlog.Debug("[主机登录防护] 4625 事件无可用源IP且无法关联 RdpCoreTS,已丢弃",
"用户", ev.User, "登录类型", ev.LogonType)
return
}
select {
case out <- ev:
case <-ctx.Done():
default:
dropEvent()
}
}
+73
View File
@@ -0,0 +1,73 @@
package wafhostguard
import (
"SamWaf/global"
"SamWaf/model"
"net"
"time"
)
// 网段聚合封禁:同一个 /24 里被封的 IP 数达到阈值时,直接封掉整段。
//
// 对僵尸网络确实有效——攻击者手上一整个 C 段的肉鸡,逐个封是打地鼠。
// 但**默认关闭**,因为误伤面太大:一个 /24 可能是整个机房、整栋写字楼的出口,
// 甚至是某运营商的 NAT 池。用户应该先在事件列表里看到"确实是同段大量来源",
// 再自己决定要不要开。
// subnetOf 返回 IPv4 地址所属的 /24 网段(CIDR 文本)。
// 只做 IPv4:IPv6 的地址空间下"同 /64 就是同一个人"这个假设不成立,
// 一个家庭宽带用户随手就有一整个 /64,聚合封禁会把无关的人全带上。
func subnetOf(ip string) (string, bool) {
parsed := net.ParseIP(ip)
if parsed == nil {
return "", false
}
v4 := parsed.To4()
if v4 == nil {
return "", false
}
masked := v4.Mask(net.CIDRMask(24, 32))
return masked.String() + "/24", true
}
// ShouldAggregateSubnet 判断是否应把这次封禁升级为整段封禁。
// 返回 (网段CIDR, 是否升级)。
func ShouldAggregateSubnet(ip string, now time.Time) (string, bool) {
if global.GCONFIG_HOST_GUARD_SUBNET_AGGREGATE != 1 {
return "", false
}
cidr, ok := subnetOf(ip)
if !ok {
return "", false
}
if global.GWAF_LOCAL_DB == nil {
return "", false
}
threshold := global.GCONFIG_HOST_GUARD_SUBNET_THRESHOLD
if threshold <= 0 {
threshold = 10
}
// 该网段已经被整段封过就不用再来一次
var subnetBanned int64
global.GWAF_LOCAL_DB.Model(&model.HostGuardBan{}).
Where("ip = ? AND status = ?", cidr, model.HostBanStatusActive).
Count(&subnetBanned)
if subnetBanned > 0 {
return cidr, false
}
// 统计该 /24 内当前处于封禁中的单 IP 数。
// 用 LIKE 前缀匹配是为了走得动索引又不依赖具体数据库的网络函数;
// 前三段固定,误匹配只可能发生在"10.1.2" 匹配到 "10.1.20.x",
// 所以前缀必须带上第四个点。
prefix := cidr[:len(cidr)-len("0/24")]
var cnt int64
global.GWAF_LOCAL_DB.Model(&model.HostGuardBan{}).
Where("status = ? AND is_subnet = 0 AND ip LIKE ?", model.HostBanStatusActive, prefix+"%").
Count(&cnt)
// +1 是把当前正要封的这个也算进去
return cidr, cnt+1 >= threshold
}
+185
View File
@@ -0,0 +1,185 @@
//go:build linux
package wafhostguard
import (
"SamWaf/common/zlog"
"bufio"
"context"
"io"
"os"
"syscall"
"time"
)
// 认证日志的 tail 实现。三个必须处理好的细节:
//
// 1. **首次打开必须 Seek 到末尾**。日志里常年积着几万条历史 Failed password,
// 从头读会把它们全当成"刚刚发生",SamWaf 一启动就把一大批早就无害的 IP 全封了。
// 2. **logrotate 的两种模式都要认**:create 模式下旧文件被 rename、新文件 inode 变了;
// copytruncate 模式下 inode 不变但文件被截断(size 变得比当前偏移小)。
// 3. **文件可能暂时不存在**:轮转的瞬间、或者 rsyslog 还没来得及创建,
// 这时不能直接报错退出,要重试等它出现。
// fileTail 单个日志文件的跟随读取器
type fileTail struct {
path string
f *os.File
rd *bufio.Reader
ino uint64
offset int64
}
// fileTailSource 实现 Source 接口
type fileTailSource struct {
path string
}
func (s *fileTailSource) Name() string { return "file:" + s.path }
func (s *fileTailSource) Run(ctx context.Context, out chan<- LoginFailEvent) error {
t := &fileTail{path: s.path}
defer t.close()
// 第一次打开从末尾开始,只关心"从现在起"发生的事
if err := t.open(true); err != nil {
zlog.Warn("[主机登录防护] 打开认证日志失败,将持续重试", "path", s.path, "error", err.Error())
}
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
}
if t.f == nil {
// 文件还没出现(或上次打开失败),重试;轮转后的新文件要从头读,
// 否则轮转瞬间写入的那几行会被跳过
if err := t.open(false); err != nil {
continue
}
zlog.Info("[主机登录防护] 认证日志已就绪", "path", s.path)
}
if err := t.readLines(ctx, out); err != nil {
if ctx.Err() != nil {
return nil
}
zlog.Debug("[主机登录防护] 读取认证日志出错,将重新打开", "path", s.path, "error", err.Error())
t.close()
continue
}
if err := t.checkRotate(); err != nil {
zlog.Debug("[主机登录防护] 检查日志轮转出错", "path", s.path, "error", err.Error())
}
}
}
// open 打开文件。fromEnd=true 时定位到末尾(仅首次),否则从头读(轮转后的新文件)
func (t *fileTail) open(fromEnd bool) error {
f, err := os.Open(t.path)
if err != nil {
return err
}
st, err := f.Stat()
if err != nil {
_ = f.Close()
return err
}
t.f = f
t.ino = inodeOf(st)
if fromEnd {
off, err := f.Seek(0, io.SeekEnd)
if err != nil {
_ = f.Close()
t.f = nil
return err
}
t.offset = off
} else {
t.offset = 0
}
t.rd = bufio.NewReader(f)
return nil
}
func (t *fileTail) close() {
if t.f != nil {
_ = t.f.Close()
t.f = nil
}
t.rd = nil
}
// readLines 读到 EOF 为止,把解析出的事件发给 out
func (t *fileTail) readLines(ctx context.Context, out chan<- LoginFailEvent) error {
for {
line, err := t.rd.ReadString('\n')
if len(line) > 0 {
t.offset += int64(len(line))
if ev, ok := ParseSSHDLine(trimNewline(line), time.Now()); ok {
select {
case out <- ev:
case <-ctx.Done():
return nil
default:
// 通道满说明消费端跟不上(极端爆破场景)。丢事件也不能阻塞采集,
// 否则 fd 里的数据积压会越来越多,最终连"现在发生了什么"都读不到。
dropEvent()
}
}
}
if err != nil {
if err == io.EOF {
return nil
}
return err
}
}
}
// checkRotate 检测轮转与截断
func (t *fileTail) checkRotate() error {
st, err := os.Stat(t.path)
if err != nil {
// 文件消失了(轮转中间态),关掉等下一轮重开
t.close()
return nil
}
// create 模式:路径指向了新 inode
if inodeOf(st) != t.ino {
zlog.Info("[主机登录防护] 检测到日志轮转(文件已更换),重新打开", "path", t.path)
t.close()
return nil
}
// copytruncate 模式:inode 没变但文件被清空了
if st.Size() < t.offset {
zlog.Info("[主机登录防护] 检测到日志被截断,从头继续读取", "path", t.path)
if _, err := t.f.Seek(0, io.SeekStart); err != nil {
t.close()
return err
}
t.offset = 0
t.rd = bufio.NewReader(t.f)
}
return nil
}
// inodeOf 取文件 inode,拿不到时返回 0(0 不会与真实 inode 冲突,最坏是多重开一次)
func inodeOf(fi os.FileInfo) uint64 {
if st, ok := fi.Sys().(*syscall.Stat_t); ok {
return st.Ino
}
return 0
}
func trimNewline(s string) string {
for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r') {
s = s[:len(s)-1]
}
return s
}
+175
View File
@@ -0,0 +1,175 @@
//go:build windows
package wafhostguard
import (
"fmt"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
// wevtapi.dll 的最小绑定,只覆盖"订阅未来事件并渲染成 XML"这一条路径。
//
// **用拉模式(SignalEvent)而不是回调模式**:回调由非 Go 线程发起,
// syscall.NewCallback 创建的回调有数量上限、且在高频事件下涉及跨线程栈切换,
// 出问题很难排查。拉模式就是普通的"等信号 -> 取一批 -> 处理",没有这些风险。
//
// 全部原语(CreateEvent / WaitForSingleObject / ResetEvent)golang.org/x/sys/windows
// 都已提供,不需要引入任何第三方依赖,也不需要 CGO。
var (
modWevtapi = windows.NewLazySystemDLL("wevtapi.dll")
procEvtSubscribe = modWevtapi.NewProc("EvtSubscribe")
procEvtNext = modWevtapi.NewProc("EvtNext")
procEvtRender = modWevtapi.NewProc("EvtRender")
procEvtClose = modWevtapi.NewProc("EvtClose")
)
const (
// EvtSubscribeToFutureEvents:只要订阅之后新产生的事件。
// 和 tail 从文件末尾开始是同一个道理——回放历史会把陈年旧事当成正在发生的攻击。
evtSubscribeToFutureEvents = 1
// EvtRenderEventXml:把事件渲染成 XML 文本
evtRenderEventXml = 1
// 单次最多取多少条事件
evtNextBatch = 16
)
// wevtAvailable 报告 wevtapi.dll 是否可加载。加载不了就走 wevtutil 降级通道。
func wevtAvailable() error {
if err := modWevtapi.Load(); err != nil {
return fmt.Errorf("加载 wevtapi.dll 失败: %w", err)
}
for _, p := range []*windows.LazyProc{procEvtSubscribe, procEvtNext, procEvtRender, procEvtClose} {
if err := p.Find(); err != nil {
return fmt.Errorf("wevtapi.dll 缺少所需函数: %w", err)
}
}
return nil
}
// evtSubscribe 订阅指定频道的未来事件。
// signalEvent 由 windows.CreateEvent 创建(手动重置),有新事件时被置位。
func evtSubscribe(signalEvent windows.Handle, channel, query string) (windows.Handle, error) {
chPtr, err := windows.UTF16PtrFromString(channel)
if err != nil {
return 0, err
}
var qPtr *uint16
if query != "" {
qPtr, err = windows.UTF16PtrFromString(query)
if err != nil {
return 0, err
}
}
r, _, e := procEvtSubscribe.Call(
0, // Session:本机
uintptr(signalEvent), // SignalEvent
uintptr(unsafe.Pointer(chPtr)), // ChannelPath
uintptr(unsafe.Pointer(qPtr)), // Query
0, // Bookmark
0, // Context
0, // Callback(拉模式不用)
uintptr(evtSubscribeToFutureEvents),
)
if r == 0 {
return 0, fmt.Errorf("EvtSubscribe(%s) 失败: %w", channel, e)
}
return windows.Handle(r), nil
}
// evtNextXML 等待信号并批量取事件,渲染成 XML 字符串返回。
//
// 返回空切片 + nil 表示"超时且无事件",这是正常情况——调用方据此回到
// select ctx.Done() 检查是否该退出。超时设成 1 秒,也就决定了 Stop() 的最坏响应时间。
func evtNextXML(sub, signal windows.Handle, timeoutMs uint32) ([]string, error) {
waitRes, err := windows.WaitForSingleObject(signal, timeoutMs)
if err != nil {
return nil, err
}
if waitRes == uint32(windows.WAIT_TIMEOUT) {
return nil, nil
}
if err := windows.ResetEvent(signal); err != nil {
return nil, err
}
var out []string
for {
handles := make([]windows.Handle, evtNextBatch)
var returned uint32
r, _, e := procEvtNext.Call(
uintptr(sub),
uintptr(evtNextBatch),
uintptr(unsafe.Pointer(&handles[0])),
uintptr(0xFFFFFFFF), // INFINITE:事件已经就绪,不会真的阻塞
0,
uintptr(unsafe.Pointer(&returned)),
)
if r == 0 {
// ERROR_NO_MORE_ITEMS 表示这一批取完了,是正常结束
if errno, ok := e.(syscall.Errno); ok && errno == windows.ERROR_NO_MORE_ITEMS {
return out, nil
}
if len(out) > 0 {
return out, nil
}
return nil, fmt.Errorf("EvtNext 失败: %w", e)
}
for i := uint32(0); i < returned; i++ {
h := handles[i]
if xmlStr, err := evtRenderXML(h); err == nil {
out = append(out, xmlStr)
}
procEvtClose.Call(uintptr(h))
}
if returned < evtNextBatch {
return out, nil
}
}
}
// evtRenderXML 把单个事件句柄渲染成 XML。
// 先用小缓冲试,不够再按系统告知的大小重来一次——事件大小差异很大,
// 一上来就分配大缓冲在高频场景下太浪费。
func evtRenderXML(h windows.Handle) (string, error) {
buf := make([]uint16, 4096)
var used, propCount uint32
render := func(b []uint16) (uintptr, error) {
r, _, e := procEvtRender.Call(
0, // Context
uintptr(h),
uintptr(evtRenderEventXml),
uintptr(len(b)*2), // BufferSize,字节数
uintptr(unsafe.Pointer(&b[0])),
uintptr(unsafe.Pointer(&used)),
uintptr(unsafe.Pointer(&propCount)),
)
return r, e
}
r, e := render(buf)
if r == 0 {
if errno, ok := e.(syscall.Errno); ok && errno == windows.ERROR_INSUFFICIENT_BUFFER {
buf = make([]uint16, used/2+1)
if r, e = render(buf); r == 0 {
return "", fmt.Errorf("EvtRender 失败: %w", e)
}
} else {
return "", fmt.Errorf("EvtRender 失败: %w", e)
}
}
return windows.UTF16ToString(buf), nil
}
// evtClose 释放订阅句柄
func evtClose(h windows.Handle) {
if h != 0 {
procEvtClose.Call(uintptr(h))
}
}
+183
View File
@@ -0,0 +1,183 @@
package wafhostguard
import (
"SamWaf/enums"
"SamWaf/global"
"SamWaf/wafenginecore/ipset"
"net"
"strings"
"sync"
"time"
)
// 白名单是这个功能里最重要的一段代码:判错了不是漏封一个攻击者,
// 而是把管理员自己锁在服务器外面。所以做了六层,任意一层命中即豁免。
// 常见内网段。爆破防护面向的是公网攻击者,内网里的失败登录绝大多数是
// 自己人敲错密码、跳板机、内部监控探活。
var lanCIDRs = []string{
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"169.254.0.0/16", // 链路本地
"100.64.0.0/10", // 运营商级 NAT / Tailscale 等
"fc00::/7", // IPv6 唯一本地地址
"fe80::/10", // IPv6 链路本地
}
// 环回永远豁免,不受任何开关控制
var loopbackCIDRs = []string{"127.0.0.0/8", "::1/128"}
// 白名单命中原因,直接展示给用户(白名单自测工具会显示这个)
const (
WhiteReasonLoopback = "环回地址"
WhiteReasonLocal = "本机网卡IP"
WhiteReasonLAN = "内网段自动豁免"
WhiteReasonConfig = "用户配置白名单"
WhiteReasonManage = "管理端IP白名单"
WhiteReasonAdminIP = "当前活跃的管理会话IP"
)
type whitelistSnapshot struct {
loopback *ipset.MatchSet
local *ipset.MatchSet
lan *ipset.MatchSet
config *ipset.MatchSet
manage *ipset.MatchSet
builtAt time.Time
}
var (
whitelistMu sync.RWMutex
whitelistCur *whitelistSnapshot
whitelistTTL = 5 * time.Minute
)
// InvalidateWhitelist 丢弃当前白名单快照,下次判定时重建。
// 用户在页面上改完白名单必须立刻生效——等 5 分钟 TTL 自然过期是不可接受的,
// 因为用户改白名单往往正是因为"我被误封了"。
func InvalidateWhitelist() {
whitelistMu.Lock()
whitelistCur = nil
whitelistMu.Unlock()
InvalidateLocalAddrs()
}
// getWhitelist 取当前快照,过期则重建
func getWhitelist() *whitelistSnapshot {
whitelistMu.RLock()
if whitelistCur != nil && time.Since(whitelistCur.builtAt) < whitelistTTL {
snap := whitelistCur
whitelistMu.RUnlock()
return snap
}
whitelistMu.RUnlock()
whitelistMu.Lock()
defer whitelistMu.Unlock()
if whitelistCur != nil && time.Since(whitelistCur.builtAt) < whitelistTTL {
return whitelistCur
}
snap := &whitelistSnapshot{
loopback: ipset.BuildMatchSet(loopbackCIDRs),
local: ipset.BuildMatchSet(LocalAddrs()),
lan: ipset.BuildMatchSet(lanCIDRs),
config: ipset.BuildMatchSet(splitList(global.GCONFIG_HOST_GUARD_WHITELIST)),
manage: ipset.BuildMatchSet(dropCatchAll(splitList(global.GWAF_IP_WHITELIST))),
builtAt: time.Now(),
}
whitelistCur = snap
return snap
}
// IsWhitelisted 判断 IP 是否豁免,返回命中的原因(供日志与自测工具展示)。
// 顺序即优先级,越靠前越"不可能是攻击者"。
func IsWhitelisted(ip string) (bool, string) {
parsed := net.ParseIP(ip)
if parsed == nil {
// 解析不出来的东西不该走到封禁流程,这里当豁免处理是保守选择
return true, "IP格式非法"
}
snap := getWhitelist()
if snap.loopback.Contains(parsed) {
return true, WhiteReasonLoopback
}
if snap.local.Contains(parsed) {
return true, WhiteReasonLocal
}
if global.GCONFIG_HOST_GUARD_AUTO_LAN == 1 && snap.lan.Contains(parsed) {
return true, WhiteReasonLAN
}
if snap.config.Contains(parsed) {
return true, WhiteReasonConfig
}
if snap.manage.Contains(parsed) {
return true, WhiteReasonManage
}
if isActiveAdminIP(ip) {
return true, WhiteReasonAdminIP
}
return false, ""
}
// adminIPTTL 活跃管理会话 IP 的记忆时长
const adminIPTTL = 30 * time.Minute
// TouchAdminIP 记录一个正在使用管理端的客户端 IP。
//
// 这是防误封的最后一道保险,也是最实用的一道:只要你还在用管理端,
// 你的出口 IP 就永远进不了封禁名单——哪怕白名单一个字都没配。
// 由 middleware 在认证通过后调用,O(1) 写缓存。
func TouchAdminIP(ip string) {
ip = strings.TrimSpace(ip)
if ip == "" || global.GCACHE_WAFCACHE == nil {
return
}
if net.ParseIP(ip) == nil {
return
}
global.GCACHE_WAFCACHE.SetWithTTlRenewTime(enums.CACHE_HOST_GUARD_ADMIN_PRE+ip, 1, adminIPTTL)
}
// isActiveAdminIP 该 IP 最近是否用过管理端
func isActiveAdminIP(ip string) bool {
if global.GCACHE_WAFCACHE == nil {
return false
}
return global.GCACHE_WAFCACHE.IsKeyExist(enums.CACHE_HOST_GUARD_ADMIN_PRE + ip)
}
// dropCatchAll 剔除覆盖全网的条目(0.0.0.0/0、::/0 等前缀长度为 0 的 CIDR)。
//
// 必须做这一步:管理端 IP 白名单 GWAF_IP_WHITELIST 的默认值就是 "0.0.0.0/0,::/0"
// (后台默认放行所有来源)。原样拿来当豁免层,默认配置下每个 IP 都会命中白名单,
// 防爆破就成了摆设。只有用户真的收窄过管理端白名单,这一层才有意义。
func dropCatchAll(items []string) []string {
out := make([]string, 0, len(items))
for _, item := range items {
if _, ipNet, err := net.ParseCIDR(item); err == nil {
if ones, _ := ipNet.Mask.Size(); ones == 0 {
continue
}
}
out = append(out, item)
}
return out
}
// splitList 拆逗号分隔配置,去空白与空项
func splitList(s string) []string {
if strings.TrimSpace(s) == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if v := strings.TrimSpace(p); v != "" {
out = append(out, v)
}
}
return out
}
+158
View File
@@ -0,0 +1,158 @@
package wafipban
import (
"SamWaf/cache"
"SamWaf/common/zlog"
"SamWaf/enums"
"time"
)
// 主机远程登录(SSH/RDP)失败计数器。
//
// 与同包的 IPFailureManager 算法同构,但**刻意不共用 keyspace**:
// IPFailureManager 的计数会被自定义规则属性 MF.GetIPFailureCount(minutes) 读取
// (见 model/rule_grl_safe.go 的 ruleAttrFailCountRegex),把 SSH/RDP 的登录失败
// 混进去,用户已经写好的 HTTP 规则语义就被静默改掉了——一个只爆破 SSH、从没碰过
// Web 的 IP 会让 `MF.GetIPFailureCount(5) > 10` 这类规则命中。
// 所以这里只共用 cache.CacheStore 与滑动窗口算法,键前缀完全独立。
// HostLoginFailureRecord 某个 (来源, IP) 的失败事件滑动窗口
type HostLoginFailureRecord struct {
Source string
IP string
Events []time.Time
Count int64
FirstTime time.Time
LastTime time.Time
}
// HostLoginFailureManager 主机登录失败计数器
type HostLoginFailureManager struct {
cache cache.CacheStore
}
var hostLoginFailureManagerInstance *HostLoginFailureManager
// InitHostLoginFailureManager 初始化单例,复用与 InitIPBanManager 相同的 CacheStore
func InitHostLoginFailureManager(wafCache cache.CacheStore) {
if hostLoginFailureManagerInstance != nil {
return
}
hostLoginFailureManagerInstance = &HostLoginFailureManager{cache: wafCache}
}
// GetHostLoginFailureManager 获取单例,未初始化时返回 nil(调用方需判空)
func GetHostLoginFailureManager() *HostLoginFailureManager {
if hostLoginFailureManagerInstance == nil {
zlog.Error("HostLoginFailureManager 未初始化,请先调用 InitHostLoginFailureManager")
}
return hostLoginFailureManagerInstance
}
// hostLoginKey 计数键:来源与 IP 分开计数,SSH 爆破不会把 RDP 的额度用掉
func hostLoginKey(source, ip string) string {
return enums.CACHE_HOST_LOGIN_FAIL_PRE + source + ":" + ip
}
// Record 记录一次失败并返回窗口内累计次数。
// 一次调用完成"读-改-写",调用方不需要再单独 GetCount,少一次缓存往返。
// retentionMinutes 是事件保留窗口,取 max(统计窗口, 封禁时长),保证解封后
// 仍能看到历史(用于展示),但又不会无限增长。
func (m *HostLoginFailureManager) Record(source, ip string, windowMinutes, retentionMinutes int64) int64 {
if m == nil || ip == "" {
return 0
}
if windowMinutes <= 0 {
windowMinutes = 10
}
if retentionMinutes < windowMinutes {
retentionMinutes = windowMinutes
}
key := hostLoginKey(source, ip)
now := time.Now()
var record *HostLoginFailureRecord
if val := m.cache.Get(key); val != nil {
if r, ok := val.(*HostLoginFailureRecord); ok {
record = r
}
}
if record == nil {
record = &HostLoginFailureRecord{
Source: source,
IP: ip,
Events: []time.Time{},
FirstTime: now,
}
}
record.Events = append(record.Events, now)
// 按保留窗口裁剪,同时统计落在统计窗口内的次数
retentionStart := now.Add(-time.Duration(retentionMinutes) * time.Minute)
windowStart := now.Add(-time.Duration(windowMinutes) * time.Minute)
valid := make([]time.Time, 0, len(record.Events))
var inWindow int64
for _, t := range record.Events {
if t.After(retentionStart) {
valid = append(valid, t)
if t.After(windowStart) {
inWindow++
}
}
}
record.Events = valid
record.Count = int64(len(valid))
if len(valid) > 0 {
record.FirstTime = valid[0]
}
record.LastTime = now
m.cache.SetWithTTlRenewTime(key, record, time.Duration(retentionMinutes)*time.Minute)
return inWindow
}
// GetCount 查询窗口内失败次数(不写入)
func (m *HostLoginFailureManager) GetCount(source, ip string, windowMinutes int64) int64 {
if m == nil || ip == "" {
return 0
}
val := m.cache.Get(hostLoginKey(source, ip))
if val == nil {
return 0
}
record, ok := val.(*HostLoginFailureRecord)
if !ok {
return 0
}
windowStart := time.Now().Add(-time.Duration(windowMinutes) * time.Minute)
var cnt int64
for _, t := range record.Events {
if t.After(windowStart) {
cnt++
}
}
return cnt
}
// Clear 清除计数。封禁下发后调用,避免解封瞬间旧事件仍在窗口内导致立刻二次封禁。
func (m *HostLoginFailureManager) Clear(source, ip string) {
if m == nil || ip == "" {
return
}
m.cache.Remove(hostLoginKey(source, ip))
}
// GetRecord 取原始记录(展示/调试用)
func (m *HostLoginFailureManager) GetRecord(source, ip string) *HostLoginFailureRecord {
if m == nil || ip == "" {
return nil
}
val := m.cache.Get(hostLoginKey(source, ip))
if val == nil {
return nil
}
record, _ := val.(*HostLoginFailureRecord)
return record
}
+2
View File
@@ -165,6 +165,8 @@ func (web *WafWebManager) initRouter(r *gin.Engine) {
router.ApiGroupApp.InitNotifySubscriptionRouter(RouterGroup)
router.ApiGroupApp.InitNotifyLogRouter(RouterGroup)
router.ApiGroupApp.InitFirewallIPBlockRouter(RouterGroup)
router.ApiGroupApp.InitHostGuardRouter(RouterGroup)
router.ApiGroupApp.InitHostConnRouter(RouterGroup)
router.ApiGroupApp.InitThreatIPRouter(RouterGroup)
router.ApiGroupApp.InitCDNIPRouter(RouterGroup)
router.ApiGroupApp.InitLogFileWriteRouter(RouterGroup)
+9
View File
@@ -11,6 +11,7 @@ import (
"SamWaf/wafsec"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
)
@@ -368,6 +369,14 @@ func handleIPBanMessage(msg innerbean.IPBanMessageInfo) {
// 2. 发送到 WebSocket(实时推送)
wsContent := fmt.Sprintf("IP %s 已被封禁,原因: %s", msg.Ip, msg.Reason)
sendToWebSocket("IP封禁通知", wsContent, nil, "Info")
// 3. 主机防爆破的封禁额外推一条带 HostGuard 命令字的消息,
// 让「远程防爆破」页面能即时刷新而不必等用户手动点。
// 单独用一个命令字而不是复用 Info:Info 是通用弹窗通知,
// 页面刷新逻辑挂上去会让所有通知都触发一次无谓的接口请求。
if strings.Contains(msg.OperaType, "主机远程登录爆破") {
sendToWebSocket("主机防爆破封禁", wsContent, msg, "HostGuard")
}
}
// sendToWebSocket 统一的 WebSocket 发送函数
+113
View File
@@ -8,6 +8,7 @@ import (
"SamWaf/model"
"SamWaf/model/request"
"SamWaf/service/waf_service"
"SamWaf/wafhostguard"
"SamWaf/wafipban"
"SamWaf/wafnotify/logfilewriter"
"SamWaf/wafowasp"
@@ -183,6 +184,52 @@ func setConfigIntValue(name string, value int64, change int) {
case "ip_failure_ban_lock_time":
global.GCONFIG_IP_FAILURE_BAN_LOCK_TIME = value
break
case "host_guard_enabled":
global.GCONFIG_HOST_GUARD_ENABLED = value
// 开关切换不需要重启进程:0→1 启动日志采集,1→0 停止采集并释放 fd/子进程/系统句柄。
// 这也是四层自救里的 L1——把开关拨回 0,最迟一个配置刷新周期就不再产生新封禁。
wafhostguard.Reload()
break
case "host_guard_find_time":
global.GCONFIG_HOST_GUARD_FIND_TIME = value
break
case "host_guard_max_retry":
global.GCONFIG_HOST_GUARD_MAX_RETRY = value
break
case "host_guard_offender_reset_day":
global.GCONFIG_HOST_GUARD_OFFENDER_RESET_DAY = value
break
case "host_guard_count_soft_fail":
global.GCONFIG_HOST_GUARD_COUNT_SOFT_FAIL = value
break
case "host_guard_auto_lan":
global.GCONFIG_HOST_GUARD_AUTO_LAN = value
wafhostguard.InvalidateWhitelist()
break
case "host_guard_debounce_sec":
global.GCONFIG_HOST_GUARD_DEBOUNCE_SEC = value
break
case "host_guard_max_ban_entries":
global.GCONFIG_HOST_GUARD_MAX_BAN_ENTRIES = value
break
case "host_guard_ban_rate_limit":
global.GCONFIG_HOST_GUARD_BAN_RATE_LIMIT = value
break
case "host_guard_subnet_aggregate":
global.GCONFIG_HOST_GUARD_SUBNET_AGGREGATE = value
break
case "host_guard_subnet_threshold":
global.GCONFIG_HOST_GUARD_SUBNET_THRESHOLD = value
break
case "host_guard_notify":
global.GCONFIG_HOST_GUARD_NOTIFY = value
break
case "host_conn_enabled":
global.GCONFIG_HOST_CONN_ENABLED = value
break
case "host_conn_cache_sec":
global.GCONFIG_HOST_CONN_CACHE_SEC = value
break
case "check_beta_version":
global.GCONFIG_CHECK_BETA_VERSION = value
break
@@ -313,6 +360,47 @@ func setConfigStringValue(name string, value string, change int) {
// 重新加载状态码配置
wafipban.GetIPFailureManager().ReloadStatusCodes()
break
case "host_guard_mode":
if value != "block" {
value = "observe"
}
global.GCONFIG_HOST_GUARD_MODE = value
break
case "host_guard_whitelist":
global.GCONFIG_HOST_GUARD_WHITELIST = value
// 白名单是防误封的主力,改完必须立刻重建,不能等下一个刷新周期
wafhostguard.InvalidateWhitelist()
break
case "host_guard_log_paths":
global.GCONFIG_HOST_GUARD_LOG_PATHS = value
// 日志路径变了要换事件源,重启采集
wafhostguard.Reload()
break
case "host_guard_ssh_ports":
global.GCONFIG_HOST_GUARD_SSH_PORTS = value
wafhostguard.InvalidatePorts()
// 端口变了,端口级封禁的规则也要跟着换
wafhostguard.GetBanExecutor().ApplyPortScope()
break
case "host_guard_rdp_ports":
global.GCONFIG_HOST_GUARD_RDP_PORTS = value
wafhostguard.InvalidatePorts()
wafhostguard.GetBanExecutor().ApplyPortScope()
break
case "host_guard_port_scope":
if value != "detected" {
value = "all"
}
global.GCONFIG_HOST_GUARD_PORT_SCOPE = value
// 封禁范围要立刻重建引用规则,否则要等下次重启才生效
wafhostguard.GetBanExecutor().ApplyPortScope()
break
case "host_guard_exec_mode":
if value != "ipset" && value != "rule" {
value = "auto"
}
global.GCONFIG_HOST_GUARD_EXEC_MODE = value
break
case "zerossl_access_key":
global.GCONFIG_ZEROSSL_ACCESS_KEY = value
break
@@ -508,6 +596,31 @@ func TaskLoadSetting(initLoad bool) {
updateConfigIntItem(initLoad, "security", "ip_failure_ban_enabled", global.GCONFIG_IP_FAILURE_BAN_ENABLED, "是否启用IP失败封禁(1启用 0禁用)", "options", "0|禁用,1|启用", configMap)
updateConfigIntItem(initLoad, "security", "ip_failure_ban_lock_time", global.GCONFIG_IP_FAILURE_BAN_LOCK_TIME, "IP失败封禁锁定时间(单位:分钟,默认10分钟)", "int", "", configMap)
// 主机远程登录爆破防护(SSH/RDP)。封禁时长不在这里配,由「封禁阶梯」表接管(5分→15分→60分→1天→永久)
updateConfigIntItem(initLoad, "hostguard", "host_guard_enabled", global.GCONFIG_HOST_GUARD_ENABLED, "主机远程登录爆破防护总开关(保护SamWaf所在机器自身的SSH/RDP。启用前请先确认白名单已包含你的管理IP,否则可能把自己锁在门外)", "options", "0|禁用,1|启用", configMap)
updateConfigStringItem(initLoad, "hostguard", "host_guard_mode", global.GCONFIG_HOST_GUARD_MODE, "工作模式:observe=只记录不封禁(建议先跑一周确认无误封),block=达到阈值即调用系统防火墙封禁", "options", "observe|观察模式,block|封禁模式", configMap)
updateConfigIntItem(initLoad, "hostguard", "host_guard_find_time", global.GCONFIG_HOST_GUARD_FIND_TIME, "失败统计窗口(单位:分钟,默认10分钟)", "int", "", configMap)
updateConfigIntItem(initLoad, "hostguard", "host_guard_max_retry", global.GCONFIG_HOST_GUARD_MAX_RETRY, "统计窗口内失败次数达到该值即触发处置(默认8次)", "int", "", configMap)
updateConfigIntItem(initLoad, "hostguard", "host_guard_offender_reset_day", global.GCONFIG_HOST_GUARD_OFFENDER_RESET_DAY, "累犯记忆期(单位:天,默认7天):超过这么久没再犯,下次封禁从第1级重新开始", "int", "", configMap)
updateConfigIntItem(initLoad, "hostguard", "host_guard_count_soft_fail", global.GCONFIG_HOST_GUARD_COUNT_SOFT_FAIL, "软失败是否计入阈值(preauth断连/用户名枚举/PAM失败行)。前者由端口扫描与健康探针大量产生,后两者会与密码失败行成对出现导致阈值腰斩,默认不计入", "options", "0|不计入,1|计入", configMap)
updateConfigStringItem(initLoad, "hostguard", "host_guard_whitelist", global.GCONFIG_HOST_GUARD_WHITELIST, "永不封禁的IP/网段白名单,逗号分隔,支持单IP/CIDR/通配符/区间(如 1.2.3.4,10.0.0.0/8,192.168.1.*)", "string", "", configMap)
updateConfigIntItem(initLoad, "hostguard", "host_guard_auto_lan", global.GCONFIG_HOST_GUARD_AUTO_LAN, "是否自动豁免本机所有网卡IP、环回地址与常见内网段(10/8、172.16/12、192.168/16、fc00::/7、100.64/10、169.254/16)", "options", "0|不豁免,1|自动豁免", configMap)
updateConfigStringItem(initLoad, "hostguard", "host_guard_log_paths", global.GCONFIG_HOST_GUARD_LOG_PATHS, "自定义系统认证日志路径(逗号分隔),留空则自动探测 /var/log/secure、/var/log/auth.log,都没有则用 journalctl。容器部署请把宿主机日志只读挂载进来并在此指定路径", "string", "", configMap)
updateConfigStringItem(initLoad, "hostguard", "host_guard_ssh_ports", global.GCONFIG_HOST_GUARD_SSH_PORTS, "SSH实际监听端口(逗号分隔),留空自动发现。注意:爆破检测本身不依赖端口,改过默认端口也能正常工作,此项仅用于端口级封禁与连接看板高亮", "string", "", configMap)
updateConfigStringItem(initLoad, "hostguard", "host_guard_rdp_ports", global.GCONFIG_HOST_GUARD_RDP_PORTS, "RDP实际监听端口(逗号分隔),留空自动发现", "string", "", configMap)
updateConfigStringItem(initLoad, "hostguard", "host_guard_port_scope", global.GCONFIG_HOST_GUARD_PORT_SCOPE, "封禁范围:all=封全端口(更安全),detected=只封SSH/RDP端口(误封时杀伤面更小)", "options", "all|全端口,detected|仅SSH/RDP端口", configMap)
updateConfigStringItem(initLoad, "hostguard", "host_guard_exec_mode", global.GCONFIG_HOST_GUARD_EXEC_MODE, "封禁执行方式:auto=平台自适应(推荐),ipset=强制走集合,rule=强制逐条防火墙规则(调试用)", "options", "auto|自动,ipset|集合,rule|逐条规则", configMap)
updateConfigIntItem(initLoad, "hostguard", "host_guard_debounce_sec", global.GCONFIG_HOST_GUARD_DEBOUNCE_SEC, "Windows封禁同步去抖窗口(单位:秒,默认30)。Windows无ipset只能全量重建规则,去抖可避免频繁封禁时反复重建;Linux/macOS走增量不受此影响", "int", "", configMap)
updateConfigIntItem(initLoad, "hostguard", "host_guard_max_ban_entries", global.GCONFIG_HOST_GUARD_MAX_BAN_ENTRIES, "封禁集合容量上限(默认10000)。超限时优先淘汰剩余时间最短的临时封禁,永久封禁不淘汰", "int", "", configMap)
updateConfigIntItem(initLoad, "hostguard", "host_guard_ban_rate_limit", global.GCONFIG_HOST_GUARD_BAN_RATE_LIMIT, "每分钟最多新增封禁数(默认200),防止分布式爆破把封禁集合瞬间打满", "int", "", configMap)
updateConfigIntItem(initLoad, "hostguard", "host_guard_subnet_aggregate", global.GCONFIG_HOST_GUARD_SUBNET_AGGREGATE, "网段聚合封禁:同一/24网段内被封IP数达到阈值时,升级为封禁整个网段。对僵尸网络有效但误伤面大(可能封掉整个机房/运营商段),默认关闭", "options", "0|禁用,1|启用", configMap)
updateConfigIntItem(initLoad, "hostguard", "host_guard_subnet_threshold", global.GCONFIG_HOST_GUARD_SUBNET_THRESHOLD, "网段聚合触发阈值(同一/24内被封IP数,默认10)", "int", "", configMap)
updateConfigIntItem(initLoad, "hostguard", "host_guard_notify", global.GCONFIG_HOST_GUARD_NOTIFY, "触发封禁时是否发送通知(复用「IP封禁」消息类型,可在通知订阅里配置渠道与频控)", "options", "0|不通知,1|通知", configMap)
// 远程连接看板
updateConfigIntItem(initLoad, "hostguard", "host_conn_enabled", global.GCONFIG_HOST_CONN_ENABLED, "远程连接看板开关(展示当前连接到本机的所有TCP连接)", "options", "0|禁用,1|启用", configMap)
updateConfigIntItem(initLoad, "hostguard", "host_conn_cache_sec", global.GCONFIG_HOST_CONN_CACHE_SEC, "连接快照缓存秒数(默认3秒)。Linux下采集需要遍历/proc建立inode到进程的映射,连接数上万时开销明显,建议不低于3秒", "int", "", configMap)
// 版本更新相关配置
updateConfigIntItem(initLoad, "system", "check_beta_version", global.GCONFIG_CHECK_BETA_VERSION, "是否检测beta版本更新(1启用 0禁用)", "options", "0|禁用,1|启用", configMap)
+35
View File
@@ -0,0 +1,35 @@
package waftask
import (
"SamWaf/common/zlog"
"SamWaf/global"
"SamWaf/service/waf_service"
)
var (
wafHostGuardService = waf_service.WafHostGuardServiceApp
)
// TaskHostGuardCleanExpired 解封主机防爆破中已到期的IP。
//
// 每分钟跑一次,而不是沿用防火墙那个 5 分钟粒度:阶梯第一级只有 5 分钟,
// 用 5 分钟粒度会让用户看到"明明写着封 5 分钟,实际封了 10 分钟"。
func TaskHostGuardCleanExpired() {
innerLogName := "TaskHostGuardCleanExpired"
// 功能没开就别去碰数据库和防火墙
if global.GCONFIG_HOST_GUARD_ENABLED != 1 {
return
}
count, err := wafHostGuardService.CleanExpiredBans()
if err != nil {
zlog.Error(innerLogName, "解封到期IP失败", "error", err.Error())
return
}
if count > 0 {
zlog.Info(innerLogName, "解封完成", "解封数量", count)
} else {
zlog.Debug(innerLogName, "无到期封禁")
}
}