diff --git a/api/entrance.go b/api/entrance.go index c28a990..ecae0ec 100644 --- a/api/entrance.go +++ b/api/entrance.go @@ -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 diff --git a/api/waf_host_conn_api.go b/api/waf_host_conn_api.go new file mode 100644 index 0000000..f328d7c --- /dev/null +++ b/api/waf_host_conn_api.go @@ -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) +} diff --git a/api/waf_host_guard_api.go b/api/waf_host_guard_api.go new file mode 100644 index 0000000..a09fda7 --- /dev/null +++ b/api/waf_host_guard_api.go @@ -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) +} diff --git a/cmd/samwaf/main.go b/cmd/samwaf/main.go index d14ef85..a9d376f 100644 --- a/cmd/samwaf/main.go +++ b/cmd/samwaf/main.go @@ -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() }) } diff --git a/enums/cache_enum.go b/enums/cache_enum.go index c0ae5aa..34da295 100644 --- a/enums/cache_enum.go +++ b/enums/cache_enum.go @@ -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 缓存 ) diff --git a/enums/task_enum.go b/enums/task_enum.go index 98caf64..70aa596 100644 --- a/enums/task_enum.go +++ b/enums/task_enum.go @@ -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分钟) ) diff --git a/firewall/ipset_darwin.go b/firewall/ipset_darwin.go index 76e1062..7ed129f 100644 --- a/firewall/ipset_darwin.go +++ b/firewall/ipset_darwin.go @@ -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") diff --git a/firewall/ipset_linux.go b/firewall/ipset_linux.go index 0432909..a63ce58 100644 --- a/firewall/ipset_linux.go +++ b/firewall/ipset_linux.go @@ -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 { diff --git a/firewall/ipset_windows.go b/firewall/ipset_windows.go index d7b7b2f..30de9aa 100644 --- a/firewall/ipset_windows.go +++ b/firewall/ipset_windows.go @@ -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) diff --git a/global/config.go b/global/config.go index ee5bb7e..c605370 100644 --- a/global/config.go +++ b/global/config.go @@ -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) ) diff --git a/middleware/auth_api_check.go b/middleware/auth_api_check.go index 7f4efd0..6b41983 100644 --- a/middleware/auth_api_check.go +++ b/middleware/auth_api_check.go @@ -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) diff --git a/model/host_guard_ban.go b/model/host_guard_ban.go new file mode 100644 index 0000000..bb4afe1 --- /dev/null +++ b/model/host_guard_ban.go @@ -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" +} diff --git a/model/host_guard_ban_ladder.go b/model/host_guard_ban_ladder.go new file mode 100644 index 0000000..61b9214 --- /dev/null +++ b/model/host_guard_ban_ladder.go @@ -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表示永久)"}, + } +} diff --git a/model/host_guard_offender.go b/model/host_guard_offender.go new file mode 100644 index 0000000..919c029 --- /dev/null +++ b/model/host_guard_offender.go @@ -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" +} diff --git a/model/host_login_event.go b/model/host_login_event.go new file mode 100644 index 0000000..f826028 --- /dev/null +++ b/model/host_login_event.go @@ -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" +} diff --git a/model/request/waf_host_conn_req.go b/model/request/waf_host_conn_req.go new file mode 100644 index 0000000..bad5d18 --- /dev/null +++ b/model/request/waf_host_conn_req.go @@ -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"` +} diff --git a/model/request/waf_host_guard_req.go b/model/request/waf_host_guard_req.go new file mode 100644 index 0000000..c7aceb1 --- /dev/null +++ b/model/request/waf_host_guard_req.go @@ -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"` +} diff --git a/model/response/waf_host_conn_resp.go b/model/response/waf_host_conn_resp.go new file mode 100644 index 0000000..4afad22 --- /dev/null +++ b/model/response/waf_host_conn_resp.go @@ -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"` // 非空表示采集不可用的中文原因 +} diff --git a/router/entrance.go b/router/entrance.go index b26dd25..89e3d47 100644 --- a/router/entrance.go +++ b/router/entrance.go @@ -57,6 +57,8 @@ type ApiGroup struct { NotifySubscriptionRouter NotifyLogRouter FirewallIPBlockRouter + HostGuardRouter + HostConnRouter ThreatIPRouter CDNIPRouter PluginRouter diff --git a/router/waf_host_conn_router.go b/router/waf_host_conn_router.go new file mode 100644 index 0000000..548383d --- /dev/null +++ b/router/waf_host_conn_router.go @@ -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 +} diff --git a/router/waf_host_guard_router.go b/router/waf_host_guard_router.go new file mode 100644 index 0000000..d9a66c8 --- /dev/null +++ b/router/waf_host_guard_router.go @@ -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) +} diff --git a/service/waf_service/waf_host_conn_service.go b/service/waf_service/waf_host_conn_service.go new file mode 100644 index 0000000..8558c1a --- /dev/null +++ b/service/waf_service/waf_host_conn_service.go @@ -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() +} diff --git a/service/waf_service/waf_host_guard_service.go b/service/waf_service/waf_host_guard_service.go new file mode 100644 index 0000000..79a5a01 --- /dev/null +++ b/service/waf_service/waf_host_guard_service.go @@ -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 +} diff --git a/wafconfig/wafconfig.go b/wafconfig/wafconfig.go index 08086ac..fdf3720 100644 --- a/wafconfig/wafconfig.go +++ b/wafconfig/wafconfig.go @@ -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") diff --git a/wafdb/migrations_core.go b/wafdb/migrations_core.go index 8552eb9..0137fb6 100644 --- a/wafdb/migrations_core.go +++ b/wafdb/migrations_core.go @@ -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{}, + ) + }, + }, }) // 执行迁移 diff --git a/wafdb/migrations_log.go b/wafdb/migrations_log.go index 2061aa6..b0cf4d6 100644 --- a/wafdb/migrations_log.go +++ b/wafdb/migrations_log.go @@ -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{}) + }, + }, }) // 执行迁移 diff --git a/wafdb/migrations_task.go b/wafdb/migrations_task.go index caeebe7..f51e746 100644 --- a/wafdb/migrations_task.go +++ b/wafdb/migrations_task.go @@ -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 + }, + }, }) // 执行迁移 diff --git a/wafhostguard/action.go b/wafhostguard/action.go new file mode 100644 index 0000000..8a8889f --- /dev/null +++ b/wafhostguard/action.go @@ -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"), + }) +} diff --git a/wafhostguard/capability.go b/wafhostguard/capability.go new file mode 100644 index 0000000..9d15da8 --- /dev/null +++ b/wafhostguard/capability.go @@ -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):能否读到登录失败日志。 +// 返回 (可读, 不可读时的中文原因, 是否在容器内) diff --git a/wafhostguard/capability_linux.go b/wafhostguard/capability_linux.go new file mode 100644 index 0000000..05b1f04 --- /dev/null +++ b/wafhostguard/capability_linux.go @@ -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") +} diff --git a/wafhostguard/capability_other.go b/wafhostguard/capability_other.go new file mode 100644 index 0000000..c0d4b5c --- /dev/null +++ b/wafhostguard/capability_other.go @@ -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 +} diff --git a/wafhostguard/capability_windows.go b/wafhostguard/capability_windows.go new file mode 100644 index 0000000..8a7facd --- /dev/null +++ b/wafhostguard/capability_windows.go @@ -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 +} diff --git a/wafhostguard/decide.go b/wafhostguard/decide.go new file mode 100644 index 0000000..af4abca --- /dev/null +++ b/wafhostguard/decide.go @@ -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) + } +} diff --git a/wafhostguard/engine.go b/wafhostguard/engine.go new file mode 100644 index 0000000..c972e39 --- /dev/null +++ b/wafhostguard/engine.go @@ -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 +} diff --git a/wafhostguard/event.go b/wafhostguard/event.go new file mode 100644 index 0000000..9b81b3a --- /dev/null +++ b/wafhostguard/event.go @@ -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 +} diff --git a/wafhostguard/executor.go b/wafhostguard/executor.go new file mode 100644 index 0000000..36d200d --- /dev/null +++ b/wafhostguard/executor.go @@ -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 +} diff --git a/wafhostguard/journal_linux.go b/wafhostguard/journal_linux.go new file mode 100644 index 0000000..d8fd9e4 --- /dev/null +++ b/wafhostguard/journal_linux.go @@ -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() +} diff --git a/wafhostguard/ladder.go b/wafhostguard/ladder.go new file mode 100644 index 0000000..f7578e8 --- /dev/null +++ b/wafhostguard/ladder.go @@ -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, + } +} diff --git a/wafhostguard/localaddr.go b/wafhostguard/localaddr.go new file mode 100644 index 0000000..678dd1a --- /dev/null +++ b/wafhostguard/localaddr.go @@ -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() +} diff --git a/wafhostguard/parser_sshd.go b/wafhostguard/parser_sshd.go new file mode 100644 index 0000000..4fd9344 --- /dev/null +++ b/wafhostguard/parser_sshd.go @@ -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 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 细分类型,并补上 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] +} diff --git a/wafhostguard/parser_sshd_test.go b/wafhostguard/parser_sshd_test.go new file mode 100644 index 0000000..222738b --- /dev/null +++ b/wafhostguard/parser_sshd_test.go @@ -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") + } +} diff --git a/wafhostguard/parser_winevt.go b/wafhostguard/parser_winevt.go new file mode 100644 index 0000000..8bac3b5 --- /dev/null +++ b/wafhostguard/parser_winevt.go @@ -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:无法从客户端 建立连接(认证失败/协议错误) +// 131:服务器接受了来自 的新 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() +} diff --git a/wafhostguard/parser_winevt_test.go b/wafhostguard/parser_winevt_test.go new file mode 100644 index 0000000..6028cec --- /dev/null +++ b/wafhostguard/parser_winevt_test.go @@ -0,0 +1,273 @@ +package wafhostguard + +import ( + "testing" + "time" +) + +// 4625 有源IP:最常见的 RDP 密码爆破 +const evt4625WithIP = ` + + + 4625 + + 123456 + Security + + + administrator + WORKGROUP + 10 + 1.2.3.4 + 51234 + DESKTOP-ATTACK + 0xc000006d + +` + +// 4625 无源IP:未知用户名场景,IpAddress 是 "-" +const evt4625NoIP = ` + + 4625 + + 123457 + Security + + + nosuchuser + 10 + - + - + DESKTOP-ATTACK + +` + +// 4625 本地登录:LogonType=2,本机行为,不该被采集 +const evt4625Local = ` + 4625Security + + user + 2 + 127.0.0.1 + +` + +// 4625 网络登录:LogonType=3(SMB/IPC$) +const evt4625Network = ` + 4625Security + + + baduser + 3 + 5.6.7.8 + 44444 + +` + +// 4625 IPv6 源 +const evt4625IPv6 = ` + 4625Security + + + root + 10 + 2001:db8::1 + 51235 + +` + +// 4625 环回来源:本机自己发起的,封了会伤到自己 +const evt4625Loopback = ` + 4625Security + + user + 10 + 127.0.0.1 + +` + +// RdpCoreTS 140:无法从客户端建立连接 +const evtRdp140 = ` + + 140 + + 888 + Microsoft-Windows-RemoteDesktopServices-RdpCoreTS/Operational + + + 9.8.7.6 + +` + +// RdpCoreTS 131:新 TCP 连接,ClientIP 带端口 +const evtRdp131 = ` + + 131 + + Microsoft-Windows-RemoteDesktopServices-RdpCoreTS/Operational + + + TCP11.22.33.44:59876 + +` + +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) + } + } +} diff --git a/wafhostguard/portdetect.go b/wafhostguard/portdetect.go new file mode 100644 index 0000000..51bf2a8 --- /dev/null +++ b/wafhostguard/portdetect.go @@ -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 +} diff --git a/wafhostguard/source_linux.go b/wafhostguard/source_linux.go new file mode 100644 index 0000000..83f8840 --- /dev/null +++ b/wafhostguard/source_linux.go @@ -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 +} diff --git a/wafhostguard/source_other.go b/wafhostguard/source_other.go new file mode 100644 index 0000000..1660642 --- /dev/null +++ b/wafhostguard/source_other.go @@ -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 +} diff --git a/wafhostguard/source_wevtutil_windows.go b/wafhostguard/source_wevtutil_windows.go new file mode 100644 index 0000000..5074964 --- /dev/null +++ b/wafhostguard/source_wevtutil_windows.go @@ -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 连续输出的多个 ... 拆开。 +// wevtutil 不输出根元素,多条事件是首尾相接的,不能整体 Unmarshal。 +func splitEventXML(s string) []string { + var out []string + for { + start := strings.Index(s, "") + } + if start < 0 { + return out + } + end := strings.Index(s[start:], "") + if end < 0 { + return out + } + end += start + len("") + 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}, "" +} diff --git a/wafhostguard/source_windows.go b/wafhostguard/source_windows.go new file mode 100644 index 0000000..3ccae90 --- /dev/null +++ b/wafhostguard/source_windows.go @@ -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() + } +} diff --git a/wafhostguard/subnet.go b/wafhostguard/subnet.go new file mode 100644 index 0000000..f9d3823 --- /dev/null +++ b/wafhostguard/subnet.go @@ -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 +} diff --git a/wafhostguard/tail_linux.go b/wafhostguard/tail_linux.go new file mode 100644 index 0000000..a9be36b --- /dev/null +++ b/wafhostguard/tail_linux.go @@ -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 +} diff --git a/wafhostguard/wevtapi_windows.go b/wafhostguard/wevtapi_windows.go new file mode 100644 index 0000000..a1da1d6 --- /dev/null +++ b/wafhostguard/wevtapi_windows.go @@ -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)) + } +} diff --git a/wafhostguard/whitelist.go b/wafhostguard/whitelist.go new file mode 100644 index 0000000..c26bb16 --- /dev/null +++ b/wafhostguard/whitelist.go @@ -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 +} diff --git a/wafipban/host_login_failure_manager.go b/wafipban/host_login_failure_manager.go new file mode 100644 index 0000000..539770c --- /dev/null +++ b/wafipban/host_login_failure_manager.go @@ -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 +} diff --git a/wafmangeweb/localserver.go b/wafmangeweb/localserver.go index 8fd4704..8b223d4 100644 --- a/wafmangeweb/localserver.go +++ b/wafmangeweb/localserver.go @@ -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) diff --git a/wafqueue/message_queue.go b/wafqueue/message_queue.go index 193da76..27ef3da 100644 --- a/wafqueue/message_queue.go +++ b/wafqueue/message_queue.go @@ -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 发送函数 diff --git a/waftask/task_config.go b/waftask/task_config.go index 31052bb..b1fbfa2 100644 --- a/waftask/task_config.go +++ b/waftask/task_config.go @@ -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) diff --git a/waftask/task_hostguard_clean.go b/waftask/task_hostguard_clean.go new file mode 100644 index 0000000..3e7ec2b --- /dev/null +++ b/waftask/task_hostguard_clean.go @@ -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, "无到期封禁") + } +}