mirror of
https://gitee.com/samwaf/SamWaf.git
synced 2026-11-03 03:31:58 +08:00
Merge pull request #926 from samwafgo/feat_host_guard
feat:add host guard
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 缓存
|
||||
)
|
||||
|
||||
@@ -26,4 +26,5 @@ const (
|
||||
TASK_STATS_DATA_CLEANUP = "task_stats_data_cleanup" //清理统计数据(按保留策略)
|
||||
TASK_THREAT_IP_SYNC = "task_threat_ip_sync" //威胁情报IP订阅同步
|
||||
TASK_ACCESS_CLEAN = "task_access_clean" //统一访问认证:清理过期会话/令牌/票据与审计日志
|
||||
TASK_HOSTGUARD_CLEAN_EXPIRED = "task_hostguard_clean_expired" //主机防爆破:解封到期封禁(每分钟,因最短阶梯只有5分钟)
|
||||
)
|
||||
|
||||
@@ -49,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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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表示永久)"},
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"` // 非空表示采集不可用的中文原因
|
||||
}
|
||||
@@ -57,6 +57,8 @@ type ApiGroup struct {
|
||||
NotifySubscriptionRouter
|
||||
NotifyLogRouter
|
||||
FirewallIPBlockRouter
|
||||
HostGuardRouter
|
||||
HostConnRouter
|
||||
ThreatIPRouter
|
||||
CDNIPRouter
|
||||
PluginRouter
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"SamWaf/api"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type HostGuardRouter struct {
|
||||
}
|
||||
|
||||
func (receiver *HostGuardRouter) InitHostGuardRouter(group *gin.RouterGroup) {
|
||||
api := api.APIGroupAPP.WafHostGuardApi
|
||||
router := group.Group("")
|
||||
|
||||
// 状态与统计
|
||||
router.GET("/api/v1/hostguard/status", api.GetStatusApi) // 运行状态与环境能力
|
||||
router.GET("/api/v1/hostguard/statistics", api.GetStatisticsApi) // 概览统计
|
||||
|
||||
// 登录失败事件
|
||||
router.POST("/api/v1/hostguard/event/list", api.GetEventListApi)
|
||||
|
||||
// 封禁账本
|
||||
router.POST("/api/v1/hostguard/ban/list", api.GetBanListApi)
|
||||
router.POST("/api/v1/hostguard/ban/release", api.ReleaseBanApi) // 提前解封
|
||||
router.POST("/api/v1/hostguard/ban/permanent", api.PromoteBanApi) // 提升为永久
|
||||
router.POST("/api/v1/hostguard/ban/manual", api.ManualBanApi) // 手工封禁
|
||||
|
||||
// 攻击者档案
|
||||
router.POST("/api/v1/hostguard/offender/list", api.GetOffenderListApi)
|
||||
router.POST("/api/v1/hostguard/offender/reset", api.ResetOffenderApi) // 重置阶梯
|
||||
router.POST("/api/v1/hostguard/offender/del", api.DelOffenderApi)
|
||||
|
||||
// 封禁阶梯
|
||||
router.GET("/api/v1/hostguard/ladder/list", api.GetLadderApi)
|
||||
router.POST("/api/v1/hostguard/ladder/save", api.SaveLadderApi)
|
||||
|
||||
// 白名单
|
||||
router.POST("/api/v1/hostguard/whitelist/test", api.TestWhitelistApi) // 自测某IP会否豁免
|
||||
router.POST("/api/v1/hostguard/whitelist/add", api.AddWhitelistApi)
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package waf_service
|
||||
|
||||
import (
|
||||
"SamWaf/common/zlog"
|
||||
"SamWaf/global"
|
||||
"SamWaf/model/request"
|
||||
"SamWaf/model/response"
|
||||
"SamWaf/utils"
|
||||
"SamWaf/wafhostguard"
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
gnet "github.com/shirou/gopsutil/v4/net"
|
||||
"github.com/shirou/gopsutil/v4/process"
|
||||
)
|
||||
|
||||
// 远程连接看板:回答"现在谁连着我这台机器"。
|
||||
//
|
||||
// 与爆破防护是**两个独立子系统**——那边看日志判失败登录,这边看内核连接表。
|
||||
// 之所以不用它来做爆破判定:连接表只能看出"连得频繁",分不清登录成功还是失败,
|
||||
// 拿来封禁误伤面太大。
|
||||
//
|
||||
// **性能是这里的主要约束**:Linux 下 gopsutil 取连接表要遍历 /proc/*/fd 建立
|
||||
// inode→pid 映射,上万连接时开销可达百毫秒级。所以必须按需采集 + 短 TTL 缓存,
|
||||
// 绝不能做成高频后台轮询。
|
||||
|
||||
type WafHostConnService struct {
|
||||
mu sync.Mutex
|
||||
snapshot []response.HostConnItem
|
||||
takenAt time.Time
|
||||
collectMs int64
|
||||
|
||||
procMu sync.Mutex
|
||||
procCache map[int32]procEntry
|
||||
}
|
||||
|
||||
type procEntry struct {
|
||||
name string
|
||||
at time.Time
|
||||
}
|
||||
|
||||
var WafHostConnServiceApp = &WafHostConnService{procCache: make(map[int32]procEntry)}
|
||||
|
||||
const (
|
||||
// procNameTTL 进程名缓存时长。PID 复用在短时间内极少发生,60 秒足够安全,
|
||||
// 又能避免每次采集都对上千个 PID 做一次系统调用。
|
||||
procNameTTL = 60 * time.Second
|
||||
// collectTimeout 单次采集超时
|
||||
collectTimeout = 10 * time.Second
|
||||
// slowCollectWarn 超过这个耗时就告警,提示用户放宽刷新间隔
|
||||
slowCollectWarn = time.Second
|
||||
)
|
||||
|
||||
// GetList 分页查询连接
|
||||
func (receiver *WafHostConnService) GetList(req request.WafHostConnSearchReq) ([]response.HostConnItem, int64, response.HostConnSummary, error) {
|
||||
summary := response.HostConnSummary{}
|
||||
if global.GCONFIG_HOST_CONN_ENABLED != 1 {
|
||||
summary.Unavailable = "远程连接看板已在系统配置中关闭"
|
||||
return nil, 0, summary, nil
|
||||
}
|
||||
|
||||
all, fromCache, err := receiver.getSnapshot()
|
||||
if err != nil {
|
||||
summary.Unavailable = "采集连接表失败:" + err.Error()
|
||||
return nil, 0, summary, err
|
||||
}
|
||||
|
||||
sshPorts, rdpPorts := wafhostguard.GuardPorts()
|
||||
summary = receiver.buildSummary(all, sshPorts, rdpPorts, fromCache)
|
||||
|
||||
// 过滤
|
||||
filtered := make([]response.HostConnItem, 0, len(all))
|
||||
for _, item := range all {
|
||||
if req.LocalPort > 0 && item.LocalPort != req.LocalPort {
|
||||
continue
|
||||
}
|
||||
if req.State != "" && !strings.EqualFold(item.State, req.State) {
|
||||
continue
|
||||
}
|
||||
if req.RemoteIP != "" && !strings.Contains(item.RemoteIP, req.RemoteIP) {
|
||||
continue
|
||||
}
|
||||
if req.OnlyGuard == 1 && !item.IsGuard {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
|
||||
total := int64(len(filtered))
|
||||
|
||||
// 分页
|
||||
start := req.PageSize * (req.PageIndex - 1)
|
||||
if start < 0 || start >= len(filtered) {
|
||||
return []response.HostConnItem{}, total, summary, nil
|
||||
}
|
||||
end := start + req.PageSize
|
||||
if end > len(filtered) {
|
||||
end = len(filtered)
|
||||
}
|
||||
page := filtered[start:end]
|
||||
|
||||
// 归属地只查当前页去重后的 IP:全量查在万级连接时会把 IP 库打爆,
|
||||
// 而用户一次也只看得到一页
|
||||
locCache := make(map[string]string, len(page))
|
||||
out := make([]response.HostConnItem, len(page))
|
||||
copy(out, page)
|
||||
for i := range out {
|
||||
ip := out[i].RemoteIP
|
||||
if ip == "" {
|
||||
continue
|
||||
}
|
||||
loc, ok := locCache[ip]
|
||||
if !ok {
|
||||
loc = utils.FormatIPLocation(ip)
|
||||
locCache[ip] = loc
|
||||
}
|
||||
out[i].Location = loc
|
||||
out[i].Banned = wafhostguard.GetBanExecutor().Contains(ip)
|
||||
}
|
||||
|
||||
return out, total, summary, nil
|
||||
}
|
||||
|
||||
// GetSummary 只要汇总卡片数据
|
||||
func (receiver *WafHostConnService) GetSummary() (response.HostConnSummary, error) {
|
||||
if global.GCONFIG_HOST_CONN_ENABLED != 1 {
|
||||
return response.HostConnSummary{Unavailable: "远程连接看板已在系统配置中关闭"}, nil
|
||||
}
|
||||
all, fromCache, err := receiver.getSnapshot()
|
||||
if err != nil {
|
||||
return response.HostConnSummary{Unavailable: "采集连接表失败:" + err.Error()}, err
|
||||
}
|
||||
sshPorts, rdpPorts := wafhostguard.GuardPorts()
|
||||
return receiver.buildSummary(all, sshPorts, rdpPorts, fromCache), nil
|
||||
}
|
||||
|
||||
// getSnapshot 取连接快照,TTL 内直接复用
|
||||
func (receiver *WafHostConnService) getSnapshot() ([]response.HostConnItem, bool, error) {
|
||||
ttl := time.Duration(global.GCONFIG_HOST_CONN_CACHE_SEC) * time.Second
|
||||
if ttl <= 0 {
|
||||
ttl = 3 * time.Second
|
||||
}
|
||||
|
||||
receiver.mu.Lock()
|
||||
defer receiver.mu.Unlock()
|
||||
|
||||
if receiver.snapshot != nil && time.Since(receiver.takenAt) < ttl {
|
||||
return receiver.snapshot, true, nil
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
items, err := receiver.collect()
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
elapsed := time.Since(start)
|
||||
|
||||
receiver.snapshot = items
|
||||
receiver.takenAt = time.Now()
|
||||
receiver.collectMs = elapsed.Milliseconds()
|
||||
|
||||
if elapsed > slowCollectWarn {
|
||||
zlog.Warn("[远程连接看板] 采集连接表耗时较长,本机连接数较多,建议调大缓存秒数或放宽刷新间隔",
|
||||
"耗时", elapsed.Round(time.Millisecond).String(), "连接数", len(items))
|
||||
}
|
||||
return items, false, nil
|
||||
}
|
||||
|
||||
// collect 真正采集一次
|
||||
func (receiver *WafHostConnService) collect() ([]response.HostConnItem, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), collectTimeout)
|
||||
defer cancel()
|
||||
|
||||
conns, err := gnet.ConnectionsWithContext(ctx, "tcp")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sshPorts, rdpPorts := wafhostguard.GuardPorts()
|
||||
guard := make(map[int]bool, len(sshPorts)+len(rdpPorts))
|
||||
for _, p := range sshPorts {
|
||||
guard[p] = true
|
||||
}
|
||||
for _, p := range rdpPorts {
|
||||
guard[p] = true
|
||||
}
|
||||
|
||||
items := make([]response.HostConnItem, 0, len(conns))
|
||||
for _, c := range conns {
|
||||
item := response.HostConnItem{
|
||||
RemoteIP: c.Raddr.IP,
|
||||
RemotePort: int(c.Raddr.Port),
|
||||
LocalIP: c.Laddr.IP,
|
||||
LocalPort: int(c.Laddr.Port),
|
||||
State: c.Status,
|
||||
Pid: c.Pid,
|
||||
IsGuard: guard[int(c.Laddr.Port)],
|
||||
}
|
||||
if c.Pid > 0 {
|
||||
item.ProcName = receiver.procName(ctx, c.Pid)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
// 已建立的排前面,再按本机端口排序——用户最关心"谁正连着"
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
iEst := strings.EqualFold(items[i].State, "ESTABLISHED")
|
||||
jEst := strings.EqualFold(items[j].State, "ESTABLISHED")
|
||||
if iEst != jEst {
|
||||
return iEst
|
||||
}
|
||||
if items[i].IsGuard != items[j].IsGuard {
|
||||
return items[i].IsGuard
|
||||
}
|
||||
return items[i].LocalPort < items[j].LocalPort
|
||||
})
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// procName 取进程名,带 60 秒缓存
|
||||
func (receiver *WafHostConnService) procName(ctx context.Context, pid int32) string {
|
||||
receiver.procMu.Lock()
|
||||
if e, ok := receiver.procCache[pid]; ok && time.Since(e.at) < procNameTTL {
|
||||
receiver.procMu.Unlock()
|
||||
return e.name
|
||||
}
|
||||
receiver.procMu.Unlock()
|
||||
|
||||
name := ""
|
||||
if p, err := process.NewProcessWithContext(ctx, pid); err == nil {
|
||||
if n, err := p.NameWithContext(ctx); err == nil {
|
||||
name = n
|
||||
}
|
||||
}
|
||||
|
||||
receiver.procMu.Lock()
|
||||
// 顺手清理过期条目,避免长期运行后 map 无限增长(PID 会一直变)
|
||||
if len(receiver.procCache) > 4096 {
|
||||
for k, v := range receiver.procCache {
|
||||
if time.Since(v.at) >= procNameTTL {
|
||||
delete(receiver.procCache, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
receiver.procCache[pid] = procEntry{name: name, at: time.Now()}
|
||||
receiver.procMu.Unlock()
|
||||
return name
|
||||
}
|
||||
|
||||
// buildSummary 汇总卡片
|
||||
func (receiver *WafHostConnService) buildSummary(items []response.HostConnItem, sshPorts, rdpPorts []int, fromCache bool) response.HostConnSummary {
|
||||
sum := response.HostConnSummary{
|
||||
Total: len(items),
|
||||
SSHPorts: sshPorts,
|
||||
RDPPorts: rdpPorts,
|
||||
CollectMs: receiver.collectMs,
|
||||
FromCache: fromCache,
|
||||
}
|
||||
|
||||
guardLabel := make(map[int]string, len(sshPorts)+len(rdpPorts))
|
||||
for _, p := range sshPorts {
|
||||
guardLabel[p] = "SSH"
|
||||
}
|
||||
for _, p := range rdpPorts {
|
||||
guardLabel[p] = "RDP"
|
||||
}
|
||||
|
||||
portCount := make(map[int]int)
|
||||
ipCount := make(map[string]int)
|
||||
for _, it := range items {
|
||||
switch {
|
||||
case strings.EqualFold(it.State, "ESTABLISHED"):
|
||||
sum.Established++
|
||||
case strings.EqualFold(it.State, "LISTEN"):
|
||||
sum.Listen++
|
||||
}
|
||||
if it.IsGuard {
|
||||
sum.GuardConns++
|
||||
}
|
||||
portCount[it.LocalPort]++
|
||||
if it.RemoteIP != "" && it.RemoteIP != "0.0.0.0" && it.RemoteIP != "::" {
|
||||
ipCount[it.RemoteIP]++
|
||||
}
|
||||
}
|
||||
|
||||
for port, cnt := range portCount {
|
||||
label := guardLabel[port]
|
||||
if label == "" {
|
||||
label = "其他"
|
||||
}
|
||||
sum.TopPorts = append(sum.TopPorts, response.HostConnPortStat{
|
||||
Port: port,
|
||||
Count: cnt,
|
||||
IsGuard: guardLabel[port] != "",
|
||||
Label: label,
|
||||
})
|
||||
}
|
||||
sort.Slice(sum.TopPorts, func(i, j int) bool { return sum.TopPorts[i].Count > sum.TopPorts[j].Count })
|
||||
if len(sum.TopPorts) > 10 {
|
||||
sum.TopPorts = sum.TopPorts[:10]
|
||||
}
|
||||
|
||||
for ip, cnt := range ipCount {
|
||||
sum.TopIPs = append(sum.TopIPs, response.HostConnIPStat{RemoteIP: ip, Count: cnt})
|
||||
}
|
||||
sort.Slice(sum.TopIPs, func(i, j int) bool { return sum.TopIPs[i].Count > sum.TopIPs[j].Count })
|
||||
if len(sum.TopIPs) > 10 {
|
||||
sum.TopIPs = sum.TopIPs[:10]
|
||||
}
|
||||
// 只给 Top 榜查归属地,条数固定
|
||||
for i := range sum.TopIPs {
|
||||
sum.TopIPs[i].Location = utils.FormatIPLocation(sum.TopIPs[i].RemoteIP)
|
||||
}
|
||||
|
||||
return sum
|
||||
}
|
||||
|
||||
// InvalidateSnapshot 丢弃快照,强制下次重新采集(手工刷新按钮用)
|
||||
func (receiver *WafHostConnService) InvalidateSnapshot() {
|
||||
receiver.mu.Lock()
|
||||
receiver.snapshot = nil
|
||||
receiver.mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
package waf_service
|
||||
|
||||
import (
|
||||
"SamWaf/common/uuid"
|
||||
"SamWaf/common/zlog"
|
||||
"SamWaf/customtype"
|
||||
"SamWaf/global"
|
||||
"SamWaf/model"
|
||||
"SamWaf/model/baseorm"
|
||||
"SamWaf/model/request"
|
||||
"SamWaf/wafdb/dialect"
|
||||
"SamWaf/wafhostguard"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 主机防爆破的数据服务:事件、封禁账本、攻击者档案、阶梯配置。
|
||||
|
||||
type WafHostGuardService struct {
|
||||
// 事件写入缓冲。爆破高峰下每秒几十上百条,逐条 INSERT 会把加密 SQLite
|
||||
// 打成慢查询——观察模式尤其明显(所有事件都要落库,一条都不过滤)。
|
||||
bufMu sync.Mutex
|
||||
buf []model.HostLoginEvent
|
||||
flushTimer *time.Timer
|
||||
}
|
||||
|
||||
var WafHostGuardServiceApp = new(WafHostGuardService)
|
||||
|
||||
const (
|
||||
// eventFlushSize 攒够这么多条就写一次
|
||||
eventFlushSize = 50
|
||||
// eventFlushInterval 或者最多攒这么久,保证低频时事件也能及时可见
|
||||
eventFlushInterval = time.Second
|
||||
)
|
||||
|
||||
// InitEventSink 把事件落库能力注入 wafhostguard。
|
||||
//
|
||||
// 用注入而不是让 wafhostguard 直接 import service:
|
||||
// service 层已经引了 wafhostguard(要调封禁/白名单),反过来再引就成环了。
|
||||
func (receiver *WafHostGuardService) InitEventSink() {
|
||||
wafhostguard.SetEventSink(receiver.RecordEvent)
|
||||
}
|
||||
|
||||
// RecordEvent 收一条事件进缓冲
|
||||
func (receiver *WafHostGuardService) RecordEvent(ev model.HostLoginEvent) {
|
||||
receiver.bufMu.Lock()
|
||||
receiver.buf = append(receiver.buf, ev)
|
||||
needFlush := len(receiver.buf) >= eventFlushSize
|
||||
if receiver.flushTimer == nil {
|
||||
receiver.flushTimer = time.AfterFunc(eventFlushInterval, func() {
|
||||
receiver.FlushEvents()
|
||||
})
|
||||
}
|
||||
receiver.bufMu.Unlock()
|
||||
|
||||
if needFlush {
|
||||
receiver.FlushEvents()
|
||||
}
|
||||
}
|
||||
|
||||
// FlushEvents 把缓冲写库。停止采集时必须调一次,否则最后一批事件会丢。
|
||||
func (receiver *WafHostGuardService) FlushEvents() {
|
||||
receiver.bufMu.Lock()
|
||||
if len(receiver.buf) == 0 {
|
||||
if receiver.flushTimer != nil {
|
||||
receiver.flushTimer.Stop()
|
||||
receiver.flushTimer = nil
|
||||
}
|
||||
receiver.bufMu.Unlock()
|
||||
return
|
||||
}
|
||||
batch := receiver.buf
|
||||
receiver.buf = nil
|
||||
if receiver.flushTimer != nil {
|
||||
receiver.flushTimer.Stop()
|
||||
receiver.flushTimer = nil
|
||||
}
|
||||
receiver.bufMu.Unlock()
|
||||
|
||||
if global.GWAF_LOCAL_LOG_DB == nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
for i := range batch {
|
||||
batch[i].BaseOrm = baseorm.BaseOrm{
|
||||
Id: uuid.GenUUID(),
|
||||
USER_CODE: global.GWAF_USER_CODE,
|
||||
Tenant_ID: global.GWAF_TENANT_ID,
|
||||
CREATE_TIME: customtype.JsonTime(now),
|
||||
UPDATE_TIME: customtype.JsonTime(now),
|
||||
}
|
||||
}
|
||||
if err := global.GWAF_LOCAL_LOG_DB.CreateInBatches(batch, eventFlushSize).Error; err != nil {
|
||||
zlog.Warn("[主机登录防护] 写入登录失败事件失败", "条数", len(batch), "error", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// GetEventList 分页查询登录失败事件
|
||||
func (receiver *WafHostGuardService) GetEventList(req request.WafHostGuardEventSearchReq) ([]model.HostLoginEvent, int64, error) {
|
||||
var list []model.HostLoginEvent
|
||||
var total int64
|
||||
if global.GWAF_LOCAL_LOG_DB == nil {
|
||||
return list, 0, nil
|
||||
}
|
||||
|
||||
query := global.GWAF_LOCAL_LOG_DB.Model(&model.HostLoginEvent{})
|
||||
if req.Source != "" {
|
||||
query = query.Where("source = ?", req.Source)
|
||||
}
|
||||
if req.IP != "" {
|
||||
query = query.Where("ip LIKE ?", "%"+req.IP+"%")
|
||||
}
|
||||
if req.UserName != "" {
|
||||
query = query.Where("user_name LIKE ?", "%"+req.UserName+"%")
|
||||
}
|
||||
if req.FailKind != "" {
|
||||
query = query.Where("fail_kind = ?", req.FailKind)
|
||||
}
|
||||
if req.Action != "" {
|
||||
query = query.Where("action = ?", req.Action)
|
||||
}
|
||||
if req.StartTime > 0 {
|
||||
query = query.Where("event_time >= ?", req.StartTime)
|
||||
}
|
||||
if req.EndTime > 0 {
|
||||
query = query.Where("event_time <= ?", req.EndTime)
|
||||
}
|
||||
|
||||
query.Count(&total)
|
||||
err := query.Order("event_time DESC").
|
||||
Limit(req.PageSize).
|
||||
Offset(req.PageSize * (req.PageIndex - 1)).
|
||||
Find(&list).Error
|
||||
return list, total, err
|
||||
}
|
||||
|
||||
// GetBanList 分页查询封禁记录。status 留空默认只看生效中的——
|
||||
// 用户点进"封禁列表"想看的是"现在谁被封着",而不是历史流水。
|
||||
func (receiver *WafHostGuardService) GetBanList(req request.WafHostGuardBanSearchReq) ([]model.HostGuardBan, int64, error) {
|
||||
var list []model.HostGuardBan
|
||||
var total int64
|
||||
|
||||
query := global.GWAF_LOCAL_DB.Model(&model.HostGuardBan{})
|
||||
status := req.Status
|
||||
if status == "" {
|
||||
status = model.HostBanStatusActive
|
||||
}
|
||||
if status != "all" {
|
||||
query = query.Where(dialect.Q("status")+" = ?", status)
|
||||
}
|
||||
if req.IP != "" {
|
||||
query = query.Where("ip LIKE ?", "%"+req.IP+"%")
|
||||
}
|
||||
if req.Source != "" {
|
||||
query = query.Where("source = ?", req.Source)
|
||||
}
|
||||
|
||||
query.Count(&total)
|
||||
err := query.Order("start_time DESC").
|
||||
Limit(req.PageSize).
|
||||
Offset(req.PageSize * (req.PageIndex - 1)).
|
||||
Find(&list).Error
|
||||
return list, total, err
|
||||
}
|
||||
|
||||
// ReleaseBan 手工提前解封
|
||||
func (receiver *WafHostGuardService) ReleaseBan(id string) error {
|
||||
var ban model.HostGuardBan
|
||||
if err := global.GWAF_LOCAL_DB.Where("id = ?", id).First(&ban).Error; err != nil {
|
||||
return errors.New("封禁记录不存在")
|
||||
}
|
||||
if ban.Status != model.HostBanStatusActive {
|
||||
return errors.New("该记录已不在封禁中")
|
||||
}
|
||||
|
||||
if err := wafhostguard.GetBanExecutor().Apply(nil, []string{ban.IP}); err != nil {
|
||||
return errors.New("解除系统防火墙封禁失败:" + err.Error())
|
||||
}
|
||||
// Windows 走去抖,手工解封要立刻见效,这里强制刷一次
|
||||
if err := wafhostguard.GetBanExecutor().FlushPending(); err != nil {
|
||||
zlog.Warn("[主机登录防护] 手工解封时同步防火墙失败", "ip", ban.IP, "error", err.Error())
|
||||
}
|
||||
|
||||
return global.GWAF_LOCAL_DB.Model(&model.HostGuardBan{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]interface{}{
|
||||
"status": model.HostBanStatusReleased,
|
||||
"remarks": "管理员手工解封",
|
||||
"update_time": customtype.JsonTime(time.Now()),
|
||||
}).Error
|
||||
}
|
||||
|
||||
// PromoteToPermanent 把一条临时封禁改成永久
|
||||
func (receiver *WafHostGuardService) PromoteToPermanent(id string) error {
|
||||
var ban model.HostGuardBan
|
||||
if err := global.GWAF_LOCAL_DB.Where("id = ?", id).First(&ban).Error; err != nil {
|
||||
return errors.New("封禁记录不存在")
|
||||
}
|
||||
if ban.Status != model.HostBanStatusActive {
|
||||
return errors.New("该记录已不在封禁中")
|
||||
}
|
||||
return global.GWAF_LOCAL_DB.Model(&model.HostGuardBan{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]interface{}{
|
||||
"expire_time": 0,
|
||||
"ban_minutes": 0,
|
||||
"remarks": "管理员手工提升为永久封禁",
|
||||
"update_time": customtype.JsonTime(time.Now()),
|
||||
}).Error
|
||||
}
|
||||
|
||||
// ManualBan 手工封禁一个IP(从事件列表或连接看板一键操作)
|
||||
func (receiver *WafHostGuardService) ManualBan(req request.WafHostGuardManualBanReq) error {
|
||||
ip := strings.TrimSpace(req.IP)
|
||||
if ip == "" {
|
||||
return errors.New("IP不能为空")
|
||||
}
|
||||
source := req.Source
|
||||
if source == "" {
|
||||
source = wafhostguard.SourceSSH
|
||||
}
|
||||
reason := req.Reason
|
||||
if reason == "" {
|
||||
reason = "管理员手工封禁"
|
||||
}
|
||||
|
||||
res := wafhostguard.ApplyBan(wafhostguard.BanRequest{
|
||||
IP: ip,
|
||||
Source: source,
|
||||
Manual: true,
|
||||
ManualMin: req.BanMinutes,
|
||||
Reason: reason,
|
||||
}, time.Now())
|
||||
if !res.Banned {
|
||||
if res.Skipped != "" {
|
||||
return errors.New(res.Skipped)
|
||||
}
|
||||
return errors.New("封禁未生效")
|
||||
}
|
||||
// 手工操作要立刻见效,不等去抖
|
||||
if err := wafhostguard.GetBanExecutor().FlushPending(); err != nil {
|
||||
zlog.Warn("[主机登录防护] 手工封禁时同步防火墙失败", "ip", ip, "error", err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOffenderList 分页查询攻击者档案
|
||||
func (receiver *WafHostGuardService) GetOffenderList(req request.WafHostGuardOffenderSearchReq) ([]model.HostGuardOffender, int64, error) {
|
||||
var list []model.HostGuardOffender
|
||||
var total int64
|
||||
|
||||
query := global.GWAF_LOCAL_DB.Model(&model.HostGuardOffender{})
|
||||
if req.IP != "" {
|
||||
query = query.Where("ip LIKE ?", "%"+req.IP+"%")
|
||||
}
|
||||
if req.Source != "" {
|
||||
query = query.Where("source = ?", req.Source)
|
||||
}
|
||||
|
||||
query.Count(&total)
|
||||
err := query.Order("last_ban_time DESC").
|
||||
Limit(req.PageSize).
|
||||
Offset(req.PageSize * (req.PageIndex - 1)).
|
||||
Find(&list).Error
|
||||
return list, total, err
|
||||
}
|
||||
|
||||
// ResetOffender 重置某个攻击者的阶梯(下次封禁从第1级重新开始)
|
||||
func (receiver *WafHostGuardService) ResetOffender(id string) error {
|
||||
return global.GWAF_LOCAL_DB.Model(&model.HostGuardOffender{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]interface{}{
|
||||
"ban_count": 0,
|
||||
"current_level": 0,
|
||||
"last_reason": "管理员手工重置阶梯",
|
||||
"update_time": customtype.JsonTime(time.Now()),
|
||||
}).Error
|
||||
}
|
||||
|
||||
// DeleteOffender 删除攻击者档案
|
||||
func (receiver *WafHostGuardService) DeleteOffender(id string) error {
|
||||
return global.GWAF_LOCAL_DB.Where("id = ?", id).Delete(&model.HostGuardOffender{}).Error
|
||||
}
|
||||
|
||||
// GetLadders 取阶梯配置(按级别升序)
|
||||
func (receiver *WafHostGuardService) GetLadders() ([]model.HostGuardBanLadder, error) {
|
||||
var list []model.HostGuardBanLadder
|
||||
err := global.GWAF_LOCAL_DB.Order("level asc").Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
// SaveLadders 整表替换阶梯配置。
|
||||
// 用整表替换而不是逐行增删改:阶梯是一个有序整体,级别之间要连续,
|
||||
// 让前端一次提交完整列表,后端校验后原子替换,比维护单行 CRUD 的一致性简单得多。
|
||||
func (receiver *WafHostGuardService) SaveLadders(req request.WafHostGuardLadderEditReq) error {
|
||||
if len(req.Ladders) == 0 {
|
||||
return errors.New("至少要保留一级封禁阶梯")
|
||||
}
|
||||
// 至少要有一级是启用的,否则触发阈值后无从取时长
|
||||
enabled := 0
|
||||
for _, l := range req.Ladders {
|
||||
if l.Enable == 1 {
|
||||
enabled++
|
||||
}
|
||||
if l.BanMinutes < 0 {
|
||||
return errors.New("封禁时长不能为负数(0 表示永久封禁)")
|
||||
}
|
||||
}
|
||||
if enabled == 0 {
|
||||
return errors.New("至少要启用一级封禁阶梯,否则达到阈值后无法确定封禁时长")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
tx := global.GWAF_LOCAL_DB.Begin()
|
||||
if err := tx.Where("1 = 1").Delete(&model.HostGuardBanLadder{}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
for i, item := range req.Ladders {
|
||||
level := item.Level
|
||||
if level <= 0 {
|
||||
level = i + 1
|
||||
}
|
||||
ladder := model.HostGuardBanLadder{
|
||||
BaseOrm: baseorm.BaseOrm{
|
||||
Id: uuid.GenUUID(),
|
||||
USER_CODE: global.GWAF_USER_CODE,
|
||||
Tenant_ID: global.GWAF_TENANT_ID,
|
||||
CREATE_TIME: customtype.JsonTime(now),
|
||||
UPDATE_TIME: customtype.JsonTime(now),
|
||||
},
|
||||
Level: level,
|
||||
BanMinutes: item.BanMinutes,
|
||||
Enable: item.Enable,
|
||||
Remarks: item.Remarks,
|
||||
}
|
||||
if err := tx.Create(&ladder).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return err
|
||||
}
|
||||
wafhostguard.InvalidateLadder()
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanExpiredBans 解封所有到期封禁。由定时任务每分钟调用。
|
||||
// 记录只置为 expired 不删行——攻击历史要留作取证,也是攻击者档案的佐证。
|
||||
func (receiver *WafHostGuardService) CleanExpiredBans() (int, error) {
|
||||
now := time.Now().Unix()
|
||||
var expired []model.HostGuardBan
|
||||
err := global.GWAF_LOCAL_DB.
|
||||
Where(dialect.Q("status")+" = ? AND expire_time > 0 AND expire_time <= ?", model.HostBanStatusActive, now).
|
||||
Find(&expired).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(expired) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
ips := make([]string, 0, len(expired))
|
||||
ids := make([]string, 0, len(expired))
|
||||
for _, b := range expired {
|
||||
ips = append(ips, b.IP)
|
||||
ids = append(ids, b.Id)
|
||||
}
|
||||
|
||||
if err := wafhostguard.GetBanExecutor().Apply(nil, ips); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// 到期解封要真的到期就生效,不能再等一个去抖窗口
|
||||
if err := wafhostguard.GetBanExecutor().FlushPending(); err != nil {
|
||||
zlog.Warn("[主机登录防护] 解封时同步防火墙失败", "error", err.Error())
|
||||
}
|
||||
|
||||
err = global.GWAF_LOCAL_DB.Model(&model.HostGuardBan{}).
|
||||
Where("id IN ?", ids).
|
||||
Updates(map[string]interface{}{
|
||||
"status": model.HostBanStatusExpired,
|
||||
"update_time": customtype.JsonTime(time.Now()),
|
||||
}).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
zlog.Info("[主机登录防护] 已解封到期IP", "数量", len(ips))
|
||||
return len(ips), nil
|
||||
}
|
||||
|
||||
// HostGuardStatistics 概览统计
|
||||
type HostGuardStatistics struct {
|
||||
Status wafhostguard.Status `json:"status"`
|
||||
ActiveBans int64 `json:"active_bans"` // 当前生效封禁数
|
||||
PermanentBans int64 `json:"permanent_bans"` // 其中永久封禁数
|
||||
TotalBans int64 `json:"total_bans"` // 历史封禁总数
|
||||
OffenderCount int64 `json:"offender_count"` // 攻击者档案数
|
||||
Events24h int64 `json:"events_24h"` // 近24小时失败事件数
|
||||
TopSources []HostGuardTopItem `json:"top_sources"` // Top 攻击源
|
||||
HourlyTrend []HostGuardTrend `json:"hourly_trend"` // 近24小时趋势
|
||||
SourceBreakout []HostGuardTopItem `json:"source_breakout"` // 按来源(ssh/rdp)分布
|
||||
}
|
||||
|
||||
// HostGuardTopItem Top 榜条目
|
||||
type HostGuardTopItem struct {
|
||||
Name string `json:"name"`
|
||||
Location string `json:"location"`
|
||||
Level int `json:"level"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// HostGuardTrend 趋势点
|
||||
type HostGuardTrend struct {
|
||||
Hour string `json:"hour"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// GetStatistics 概览页数据
|
||||
func (receiver *WafHostGuardService) GetStatistics() HostGuardStatistics {
|
||||
st := HostGuardStatistics{Status: wafhostguard.GetStatus()}
|
||||
|
||||
if global.GWAF_LOCAL_DB != nil {
|
||||
global.GWAF_LOCAL_DB.Model(&model.HostGuardBan{}).
|
||||
Where(dialect.Q("status")+" = ?", model.HostBanStatusActive).Count(&st.ActiveBans)
|
||||
global.GWAF_LOCAL_DB.Model(&model.HostGuardBan{}).
|
||||
Where(dialect.Q("status")+" = ? AND expire_time = 0", model.HostBanStatusActive).Count(&st.PermanentBans)
|
||||
global.GWAF_LOCAL_DB.Model(&model.HostGuardBan{}).Count(&st.TotalBans)
|
||||
global.GWAF_LOCAL_DB.Model(&model.HostGuardOffender{}).Count(&st.OffenderCount)
|
||||
|
||||
var offenders []model.HostGuardOffender
|
||||
global.GWAF_LOCAL_DB.Model(&model.HostGuardOffender{}).
|
||||
Order("ban_count DESC, total_fail_count DESC").Limit(10).Find(&offenders)
|
||||
for _, o := range offenders {
|
||||
st.TopSources = append(st.TopSources, HostGuardTopItem{
|
||||
Name: o.IP,
|
||||
Location: o.Location,
|
||||
Level: o.CurrentLevel,
|
||||
Count: o.BanCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if global.GWAF_LOCAL_LOG_DB != nil {
|
||||
since := time.Now().Add(-24 * time.Hour).Unix()
|
||||
global.GWAF_LOCAL_LOG_DB.Model(&model.HostLoginEvent{}).
|
||||
Where("event_time >= ?", since).Count(&st.Events24h)
|
||||
|
||||
// 按小时分桶。用应用层分桶而不是 SQL 的时间函数,是为了避开
|
||||
// SQLite/MySQL/PostgreSQL 三种方言在时间函数上的差异。
|
||||
var events []model.HostLoginEvent
|
||||
global.GWAF_LOCAL_LOG_DB.Select("event_time", "source").
|
||||
Where("event_time >= ?", since).Find(&events)
|
||||
|
||||
buckets := make(map[string]int64, 24)
|
||||
bySource := make(map[string]int64, 2)
|
||||
for _, e := range events {
|
||||
hour := time.Unix(e.EventTime, 0).Format("2006-01-02 15:00")
|
||||
buckets[hour]++
|
||||
bySource[e.Source]++
|
||||
}
|
||||
now := time.Now()
|
||||
for i := 23; i >= 0; i-- {
|
||||
hour := now.Add(time.Duration(-i) * time.Hour).Format("2006-01-02 15:00")
|
||||
st.HourlyTrend = append(st.HourlyTrend, HostGuardTrend{Hour: hour, Count: buckets[hour]})
|
||||
}
|
||||
for name, cnt := range bySource {
|
||||
st.SourceBreakout = append(st.SourceBreakout, HostGuardTopItem{Name: name, Count: cnt})
|
||||
}
|
||||
}
|
||||
|
||||
return st
|
||||
}
|
||||
|
||||
// TestWhitelist 白名单自测:告诉用户某个IP会不会被豁免、命中哪一条
|
||||
func (receiver *WafHostGuardService) TestWhitelist(ip string) (bool, string) {
|
||||
return wafhostguard.IsWhitelisted(strings.TrimSpace(ip))
|
||||
}
|
||||
|
||||
// AddToWhitelist 把IP追加进白名单配置(从事件/封禁列表一键操作)。
|
||||
// 顺带把该IP当前的封禁解掉——用户点"加白名单"的意思就是"这个别封了"。
|
||||
func (receiver *WafHostGuardService) AddToWhitelist(ip string) error {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip == "" {
|
||||
return errors.New("IP不能为空")
|
||||
}
|
||||
|
||||
current := strings.TrimSpace(global.GCONFIG_HOST_GUARD_WHITELIST)
|
||||
for _, item := range strings.Split(current, ",") {
|
||||
if strings.TrimSpace(item) == ip {
|
||||
return errors.New("该IP已在白名单中")
|
||||
}
|
||||
}
|
||||
newValue := ip
|
||||
if current != "" {
|
||||
newValue = current + "," + ip
|
||||
}
|
||||
|
||||
err := WafSystemConfigServiceApp.ModifyByItemApi(request.WafSystemConfigEditByItemReq{
|
||||
Item: "host_guard_whitelist",
|
||||
Value: newValue,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
global.GCONFIG_HOST_GUARD_WHITELIST = newValue
|
||||
wafhostguard.InvalidateWhitelist()
|
||||
|
||||
// 已经封着的要立刻放出来
|
||||
var bans []model.HostGuardBan
|
||||
global.GWAF_LOCAL_DB.Where("ip = ? AND "+dialect.Q("status")+" = ?", ip, model.HostBanStatusActive).Find(&bans)
|
||||
for _, b := range bans {
|
||||
if err := receiver.ReleaseBan(b.Id); err != nil {
|
||||
zlog.Warn("[主机登录防护] 加白名单后解封失败", "ip", ip, "error", err.Error())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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{},
|
||||
)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// 执行迁移
|
||||
|
||||
@@ -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{})
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// 执行迁移
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// 执行迁移
|
||||
|
||||
@@ -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"),
|
||||
})
|
||||
}
|
||||
@@ -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):能否读到登录失败日志。
|
||||
// 返回 (可读, 不可读时的中文原因, 是否在容器内)
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package wafhostguard
|
||||
|
||||
import (
|
||||
"net"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sshd 日志行解析。纯逻辑、无 build tag、无外部依赖,可以在 Windows 开发机上
|
||||
// 直接 go test 验证 Linux 日志的解析结果——这是整个模块最容易出错的部分,
|
||||
// 所以刻意不让它沾任何平台特定代码。
|
||||
//
|
||||
// **不解析行首时间戳**:传统 syslog 行首 `Aug 6 14:55:01 host sshd[123]:` 不带年份,
|
||||
// RFC5424 是 `2026-08-06T14:55:01.123+08:00 host sshd 123 - -`,而 journalctl -o cat
|
||||
// 干脆没有行首。既然是近实时 tail,用"读到该行的时刻"做事件时间误差在毫秒级,
|
||||
// 完全够用,还彻底绕开了时间戳格式地狱。
|
||||
|
||||
// ipPat 宽松抓取 IPv4 / IPv6 / IPv4-mapped,抓完一律交给 net.ParseIP 严格校验,
|
||||
// 校验不过就整行丢弃——宁可漏一条,不可封错一个。
|
||||
const ipPat = `([0-9a-fA-F:.]+)`
|
||||
|
||||
// sshdRule 一条解析规则。
|
||||
// hint 用于 strings.Contains 粗筛:绝大多数日志行都不是登录失败,先做一次
|
||||
// 字符串包含判断再跑正则,比无条件跑全部正则快约一个数量级。
|
||||
type sshdRule struct {
|
||||
kind FailKind
|
||||
hint string
|
||||
re *regexp.Regexp
|
||||
methodIdx int // 认证方式捕获组下标(仅 Failed <method> for 规则),0=无
|
||||
userIdx int // 用户名捕获组下标,0=无
|
||||
ipIdx int // IP 捕获组下标,必须 > 0
|
||||
portIdx int // 源端口捕获组下标,0=无
|
||||
}
|
||||
|
||||
// sshdRules 顺序即优先级:hard(确定的认证失败)在前,soft(可能与 hard 行重复计数的)在后。
|
||||
var sshdRules = []sshdRule{
|
||||
// —— hard:确定的一次认证失败 ——
|
||||
// 覆盖 password / publickey / none / keyboard-interactive/pam 等各种 method
|
||||
{
|
||||
kind: FailPassword, hint: "Failed ",
|
||||
re: regexp.MustCompile(`Failed (\S+) for (invalid user )?(\S+) from ` + ipPat + ` port (\d+)`),
|
||||
methodIdx: 1, userIdx: 3, ipIdx: 4, portIdx: 5,
|
||||
},
|
||||
// 单连接内尝试次数超过 MaxAuthTries,明确是在撞库
|
||||
{
|
||||
kind: FailMaxAuthTries, hint: "maximum authentication attempts exceeded",
|
||||
re: regexp.MustCompile(`maximum authentication attempts exceeded for (?:invalid user )?(\S+) from ` + ipPat + ` port (\d+)`),
|
||||
userIdx: 1, ipIdx: 2, portIdx: 3,
|
||||
},
|
||||
// 被 AllowUsers / DenyUsers / AllowGroups 挡下
|
||||
{
|
||||
kind: FailNotAllowed, hint: "not allowed because",
|
||||
re: regexp.MustCompile(`User (\S+) from ` + ipPat + ` not allowed because`),
|
||||
userIdx: 1, ipIdx: 2,
|
||||
},
|
||||
|
||||
// —— soft:默认不计入阈值,原因见 IsHard 的说明 ——
|
||||
// 用户名枚举。sshd 通常先打这行、紧接着再打一条 Failed password,
|
||||
// 两条都计会让阈值实际腰斩。
|
||||
{
|
||||
kind: FailInvalidUser, hint: "Invalid user",
|
||||
re: regexp.MustCompile(`Invalid user (\S*) from ` + ipPat + `(?: port (\d+))?`),
|
||||
userIdx: 1, ipIdx: 2, portIdx: 3,
|
||||
},
|
||||
// PAM 层失败。RHEL 系的 /var/log/secure 会和 Failed password 成对出现,同样会重复计数;
|
||||
// 但少数发行版只有这行带得出 IP,所以保留解析、交给配置决定是否计数。
|
||||
{
|
||||
kind: FailPamAuth, hint: "authentication failure",
|
||||
re: regexp.MustCompile(`authentication failure;.*?rhost=` + ipPat + `(?:\s+user=(\S+))?`),
|
||||
ipIdx: 1, userIdx: 2,
|
||||
},
|
||||
// preauth 阶段断连:端口扫描、健康探针、LB 探活的高发来源
|
||||
{
|
||||
kind: FailPreauthClose, hint: "[preauth]",
|
||||
re: regexp.MustCompile(`Connection (?:closed|reset) by (?:(?:invalid|authenticating) user (\S+) )?` + ipPat + ` port (\d+)`),
|
||||
userIdx: 1, ipIdx: 2, portIdx: 3,
|
||||
},
|
||||
{
|
||||
kind: FailPreauthClose, hint: "[preauth]",
|
||||
re: regexp.MustCompile(`Disconnected from (?:(?:invalid|authenticating) user (\S+) )?` + ipPat + ` port (\d+)`),
|
||||
userIdx: 1, ipIdx: 2, portIdx: 3,
|
||||
},
|
||||
|
||||
// —— 扫描探测:连登录尝试都算不上,只展示不计数 ——
|
||||
// HTTP/其他协议的请求打到 SSH 端口上,典型的全网端口扫描
|
||||
{
|
||||
kind: FailScanProbe, hint: "Bad protocol version identification",
|
||||
re: regexp.MustCompile(`Bad protocol version identification .* from ` + ipPat),
|
||||
ipIdx: 1,
|
||||
},
|
||||
// 连上就走、不发协议标识,扫描器探活的典型特征
|
||||
{
|
||||
kind: FailScanProbe, hint: "Did not receive identification string",
|
||||
re: regexp.MustCompile(`Did not receive identification string from ` + ipPat),
|
||||
ipIdx: 1,
|
||||
},
|
||||
}
|
||||
|
||||
// listenPortRe 从 `Server listening on 0.0.0.0 port 2222.` 抓 sshd 实际监听端口,
|
||||
// 用于和 portdetect 的探测结果交叉验证(用户改过默认端口时)。
|
||||
var listenPortRe = regexp.MustCompile(`Server listening on \S+ port (\d+)`)
|
||||
|
||||
// ParseSSHDLine 解析一行 sshd 日志。
|
||||
// ok=false 表示该行不是登录失败,或抓到的 IP 非法(一律丢弃,不做任何猜测)。
|
||||
func ParseSSHDLine(line string, now time.Time) (LoginFailEvent, bool) {
|
||||
if line == "" {
|
||||
return LoginFailEvent{}, false
|
||||
}
|
||||
for i := range sshdRules {
|
||||
r := &sshdRules[i]
|
||||
if !strings.Contains(line, r.hint) {
|
||||
continue
|
||||
}
|
||||
m := r.re.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
ip := groupAt(m, r.ipIdx)
|
||||
if net.ParseIP(ip) == nil {
|
||||
// rhost= 可能是域名、IP 可能被日志截断——都不是能拿来封禁的东西
|
||||
continue
|
||||
}
|
||||
|
||||
ev := LoginFailEvent{
|
||||
Source: SourceSSH,
|
||||
IP: ip,
|
||||
User: groupAt(m, r.userIdx),
|
||||
Kind: r.kind,
|
||||
Raw: TruncRaw(line),
|
||||
At: now,
|
||||
}
|
||||
if p := groupAt(m, r.portIdx); p != "" {
|
||||
ev.Port, _ = strconv.Atoi(p)
|
||||
}
|
||||
// Failed <method> 规则:按 method 细分类型,并补上 invalid user 语义
|
||||
if r.methodIdx > 0 {
|
||||
switch strings.ToLower(groupAt(m, r.methodIdx)) {
|
||||
case "publickey":
|
||||
ev.Kind = FailPublicKey
|
||||
default:
|
||||
ev.Kind = FailPassword
|
||||
}
|
||||
}
|
||||
return ev, true
|
||||
}
|
||||
return LoginFailEvent{}, false
|
||||
}
|
||||
|
||||
// ParseSSHDListenPort 从日志行中提取 sshd 实际监听端口,未命中返回 0
|
||||
func ParseSSHDListenPort(line string) int {
|
||||
if !strings.Contains(line, "Server listening on") {
|
||||
return 0
|
||||
}
|
||||
m := listenPortRe.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
p, _ := strconv.Atoi(m[1])
|
||||
return p
|
||||
}
|
||||
|
||||
// groupAt 安全取捕获组,下标为 0 或越界时返回空串
|
||||
func groupAt(m []string, idx int) string {
|
||||
if idx <= 0 || idx >= len(m) {
|
||||
return ""
|
||||
}
|
||||
return m[idx]
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package wafhostguard
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"net"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Windows 事件日志解析。与 sshd 解析器一样是纯逻辑、无 build tag,
|
||||
// 可以在 Linux/macOS 上跑单测。
|
||||
//
|
||||
// 涉及两个频道:
|
||||
// - Security 的 4625:登录失败。带用户名、登录类型,但**源 IP 可能是 "-"**。
|
||||
// - RemoteDesktopServices-RdpCoreTS/Operational 的 131/140:RDP 连接层事件,
|
||||
// 带真实客户端 IP,用来补 4625 缺失的来源。
|
||||
|
||||
// winEvent 只取需要的字段。EventData/UserData 的结构在不同 Windows 版本里差异很大,
|
||||
// 所以 UserData 一律按原始文本处理,用正则抓 IP,而不是硬编码字段名——
|
||||
// 硬编码的后果是换个系统版本就静默失效。
|
||||
type winEvent struct {
|
||||
XMLName xml.Name `xml:"Event"`
|
||||
System struct {
|
||||
EventID int `xml:"EventID"`
|
||||
Channel string `xml:"Channel"`
|
||||
EventRecordID uint64 `xml:"EventRecordID"`
|
||||
TimeCreated struct {
|
||||
SystemTime string `xml:"SystemTime,attr"`
|
||||
} `xml:"TimeCreated"`
|
||||
} `xml:"System"`
|
||||
EventData struct {
|
||||
Data []struct {
|
||||
Name string `xml:"Name,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
} `xml:"Data"`
|
||||
} `xml:"EventData"`
|
||||
UserData struct {
|
||||
Inner string `xml:",innerxml"`
|
||||
} `xml:"UserData"`
|
||||
}
|
||||
|
||||
// winIPRe 从任意文本里抓形似 IP 的片段,抓完交 net.ParseIP 严格校验
|
||||
var winIPRe = regexp.MustCompile(`\b([0-9]{1,3}(?:\.[0-9]{1,3}){3})\b|\[?([0-9a-fA-F]*:[0-9a-fA-F:.]+)\]?`)
|
||||
|
||||
// Parse4625 解析安全日志 4625(登录失败)。
|
||||
//
|
||||
// needResolve=true 表示这条事件的 IpAddress 不可用("-"、空、或环回),
|
||||
// 必须靠 RdpCoreTS 的 131/140 事件按时间关联补齐;补不到就只能丢弃。
|
||||
func Parse4625(xmlStr string, now time.Time) (ev LoginFailEvent, needResolve bool, ok bool) {
|
||||
var e winEvent
|
||||
if err := xml.Unmarshal([]byte(xmlStr), &e); err != nil {
|
||||
return LoginFailEvent{}, false, false
|
||||
}
|
||||
if e.System.EventID != 4625 {
|
||||
return LoginFailEvent{}, false, false
|
||||
}
|
||||
|
||||
data := make(map[string]string, len(e.EventData.Data))
|
||||
for _, d := range e.EventData.Data {
|
||||
data[d.Name] = strings.TrimSpace(d.Value)
|
||||
}
|
||||
|
||||
logonType := data["LogonType"]
|
||||
// 只关心远程登录:10=RemoteInteractive(RDP)、3=Network(SMB/IPC$ 等)。
|
||||
// 2(本地交互)、4(批处理)、5(服务)这些是本机行为,封了毫无意义还可能误伤。
|
||||
if logonType != "10" && logonType != "3" {
|
||||
return LoginFailEvent{}, false, false
|
||||
}
|
||||
|
||||
at := now
|
||||
if t, err := time.Parse(time.RFC3339, e.System.TimeCreated.SystemTime); err == nil {
|
||||
at = t
|
||||
}
|
||||
|
||||
ev = LoginFailEvent{
|
||||
Source: SourceRDP,
|
||||
User: data["TargetUserName"],
|
||||
Kind: FailRdpLogon,
|
||||
LogonType: logonType,
|
||||
Raw: TruncRaw(summarize4625(data, e.System.EventRecordID)),
|
||||
At: at,
|
||||
}
|
||||
if p := data["IpPort"]; p != "" {
|
||||
ev.Port, _ = strconv.Atoi(p)
|
||||
}
|
||||
|
||||
ip := data["IpAddress"]
|
||||
if !usableRemoteIP(ip) {
|
||||
return ev, true, true
|
||||
}
|
||||
ev.IP = normalizeIP(ip)
|
||||
return ev, false, true
|
||||
}
|
||||
|
||||
// ParseRdpCoreTSIP 从 RdpCoreTS 131/140 事件里抓客户端 IP。
|
||||
//
|
||||
// 140:无法从客户端 <IP> 建立连接(认证失败/协议错误)
|
||||
// 131:服务器接受了来自 <IP:port> 的新 TCP 连接
|
||||
//
|
||||
// 字段名随版本变化(IPString / ClientIP / Param1 ...),所以直接扫 UserData 的
|
||||
// 全部文本找第一个合法 IP,不依赖具体字段名。
|
||||
func ParseRdpCoreTSIP(xmlStr string, now time.Time) (ip string, at time.Time, ok bool) {
|
||||
var e winEvent
|
||||
if err := xml.Unmarshal([]byte(xmlStr), &e); err != nil {
|
||||
return "", time.Time{}, false
|
||||
}
|
||||
if e.System.EventID != 131 && e.System.EventID != 140 {
|
||||
return "", time.Time{}, false
|
||||
}
|
||||
|
||||
at = now
|
||||
if t, err := time.Parse(time.RFC3339, e.System.TimeCreated.SystemTime); err == nil {
|
||||
at = t
|
||||
}
|
||||
|
||||
if found := findFirstIP(e.UserData.Inner); found != "" {
|
||||
return found, at, true
|
||||
}
|
||||
// 少数版本把内容放在 EventData 里
|
||||
for _, d := range e.EventData.Data {
|
||||
if found := findFirstIP(d.Value); found != "" {
|
||||
return found, at, true
|
||||
}
|
||||
}
|
||||
return "", at, false
|
||||
}
|
||||
|
||||
// findFirstIP 从文本中找出第一个可用的远端 IP
|
||||
func findFirstIP(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
for _, m := range winIPRe.FindAllStringSubmatch(s, -1) {
|
||||
for _, candidate := range m[1:] {
|
||||
candidate = strings.Trim(strings.TrimSpace(candidate), "[]")
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
if !usableRemoteIP(candidate) {
|
||||
continue
|
||||
}
|
||||
return normalizeIP(candidate)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// usableRemoteIP 判断这个字符串是不是一个能拿来封禁的远端地址。
|
||||
//
|
||||
// 排除掉 "-"(Windows 表示"未知")、环回、未指定地址。这些都不是攻击者,
|
||||
// 封了只会伤到自己。**绝不做任何猜测性兜底**——宁可漏封一次,不可错封一个。
|
||||
func usableRemoteIP(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" || s == "-" {
|
||||
return false
|
||||
}
|
||||
// 131 事件的 ClientIP 形如 1.2.3.4:51234 或 [::1]:5555
|
||||
if host, _, err := net.SplitHostPort(s); err == nil {
|
||||
s = host
|
||||
}
|
||||
s = strings.Trim(s, "[]")
|
||||
ip := net.ParseIP(s)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
if ip.IsLoopback() || ip.IsUnspecified() {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// normalizeIP 去掉端口与方括号,返回规范化的 IP 文本
|
||||
func normalizeIP(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if host, _, err := net.SplitHostPort(s); err == nil {
|
||||
s = host
|
||||
}
|
||||
s = strings.Trim(s, "[]")
|
||||
if ip := net.ParseIP(s); ip != nil {
|
||||
return ip.String()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// summarize4625 生成一行可读摘要入库,比塞整段 XML 有用得多
|
||||
func summarize4625(data map[string]string, recordID uint64) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("EventID=4625")
|
||||
if recordID > 0 {
|
||||
b.WriteString(" RecordID=")
|
||||
b.WriteString(strconv.FormatUint(recordID, 10))
|
||||
}
|
||||
for _, k := range []string{"TargetUserName", "TargetDomainName", "LogonType", "IpAddress", "IpPort", "WorkstationName", "Status", "SubStatus", "FailureReason"} {
|
||||
if v := data[k]; v != "" {
|
||||
b.WriteString(" ")
|
||||
b.WriteString(k)
|
||||
b.WriteString("=")
|
||||
b.WriteString(v)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package wafhostguard
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 4625 有源IP:最常见的 RDP 密码爆破
|
||||
const evt4625WithIP = `<Event xmlns="http://schemas.microsoft.com/win/2004/08/events/event">
|
||||
<System>
|
||||
<Provider Name="Microsoft-Windows-Security-Auditing"/>
|
||||
<EventID>4625</EventID>
|
||||
<TimeCreated SystemTime="2026-08-06T06:55:01.1234567Z"/>
|
||||
<EventRecordID>123456</EventRecordID>
|
||||
<Channel>Security</Channel>
|
||||
</System>
|
||||
<EventData>
|
||||
<Data Name="TargetUserName">administrator</Data>
|
||||
<Data Name="TargetDomainName">WORKGROUP</Data>
|
||||
<Data Name="LogonType">10</Data>
|
||||
<Data Name="IpAddress">1.2.3.4</Data>
|
||||
<Data Name="IpPort">51234</Data>
|
||||
<Data Name="WorkstationName">DESKTOP-ATTACK</Data>
|
||||
<Data Name="Status">0xc000006d</Data>
|
||||
</EventData>
|
||||
</Event>`
|
||||
|
||||
// 4625 无源IP:未知用户名场景,IpAddress 是 "-"
|
||||
const evt4625NoIP = `<Event xmlns="http://schemas.microsoft.com/win/2004/08/events/event">
|
||||
<System>
|
||||
<EventID>4625</EventID>
|
||||
<TimeCreated SystemTime="2026-08-06T06:55:02.0000000Z"/>
|
||||
<EventRecordID>123457</EventRecordID>
|
||||
<Channel>Security</Channel>
|
||||
</System>
|
||||
<EventData>
|
||||
<Data Name="TargetUserName">nosuchuser</Data>
|
||||
<Data Name="LogonType">10</Data>
|
||||
<Data Name="IpAddress">-</Data>
|
||||
<Data Name="IpPort">-</Data>
|
||||
<Data Name="WorkstationName">DESKTOP-ATTACK</Data>
|
||||
</EventData>
|
||||
</Event>`
|
||||
|
||||
// 4625 本地登录:LogonType=2,本机行为,不该被采集
|
||||
const evt4625Local = `<Event>
|
||||
<System><EventID>4625</EventID><Channel>Security</Channel></System>
|
||||
<EventData>
|
||||
<Data Name="TargetUserName">user</Data>
|
||||
<Data Name="LogonType">2</Data>
|
||||
<Data Name="IpAddress">127.0.0.1</Data>
|
||||
</EventData>
|
||||
</Event>`
|
||||
|
||||
// 4625 网络登录:LogonType=3(SMB/IPC$)
|
||||
const evt4625Network = `<Event>
|
||||
<System><EventID>4625</EventID><Channel>Security</Channel>
|
||||
<TimeCreated SystemTime="2026-08-06T06:55:03.0000000Z"/></System>
|
||||
<EventData>
|
||||
<Data Name="TargetUserName">baduser</Data>
|
||||
<Data Name="LogonType">3</Data>
|
||||
<Data Name="IpAddress">5.6.7.8</Data>
|
||||
<Data Name="IpPort">44444</Data>
|
||||
</EventData>
|
||||
</Event>`
|
||||
|
||||
// 4625 IPv6 源
|
||||
const evt4625IPv6 = `<Event>
|
||||
<System><EventID>4625</EventID><Channel>Security</Channel>
|
||||
<TimeCreated SystemTime="2026-08-06T06:55:04.0000000Z"/></System>
|
||||
<EventData>
|
||||
<Data Name="TargetUserName">root</Data>
|
||||
<Data Name="LogonType">10</Data>
|
||||
<Data Name="IpAddress">2001:db8::1</Data>
|
||||
<Data Name="IpPort">51235</Data>
|
||||
</EventData>
|
||||
</Event>`
|
||||
|
||||
// 4625 环回来源:本机自己发起的,封了会伤到自己
|
||||
const evt4625Loopback = `<Event>
|
||||
<System><EventID>4625</EventID><Channel>Security</Channel></System>
|
||||
<EventData>
|
||||
<Data Name="TargetUserName">user</Data>
|
||||
<Data Name="LogonType">10</Data>
|
||||
<Data Name="IpAddress">127.0.0.1</Data>
|
||||
</EventData>
|
||||
</Event>`
|
||||
|
||||
// RdpCoreTS 140:无法从客户端建立连接
|
||||
const evtRdp140 = `<Event xmlns="http://schemas.microsoft.com/win/2004/08/events/event">
|
||||
<System>
|
||||
<EventID>140</EventID>
|
||||
<TimeCreated SystemTime="2026-08-06T06:55:00.5000000Z"/>
|
||||
<EventRecordID>888</EventRecordID>
|
||||
<Channel>Microsoft-Windows-RemoteDesktopServices-RdpCoreTS/Operational</Channel>
|
||||
</System>
|
||||
<UserData>
|
||||
<EventXML xmlns="Event_NS"><IPString>9.8.7.6</IPString></EventXML>
|
||||
</UserData>
|
||||
</Event>`
|
||||
|
||||
// RdpCoreTS 131:新 TCP 连接,ClientIP 带端口
|
||||
const evtRdp131 = `<Event>
|
||||
<System>
|
||||
<EventID>131</EventID>
|
||||
<TimeCreated SystemTime="2026-08-06T06:55:00.1000000Z"/>
|
||||
<Channel>Microsoft-Windows-RemoteDesktopServices-RdpCoreTS/Operational</Channel>
|
||||
</System>
|
||||
<UserData>
|
||||
<EventXML xmlns="Event_NS"><ConnType>TCP</ConnType><ClientIP>11.22.33.44:59876</ClientIP></EventXML>
|
||||
</UserData>
|
||||
</Event>`
|
||||
|
||||
func TestParse4625(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
t.Run("RDP密码爆破-有源IP", func(t *testing.T) {
|
||||
ev, needResolve, ok := Parse4625(evt4625WithIP, now)
|
||||
if !ok {
|
||||
t.Fatal("应当解析成功")
|
||||
}
|
||||
if needResolve {
|
||||
t.Error("已有源IP,不该要求补齐")
|
||||
}
|
||||
if ev.IP != "1.2.3.4" {
|
||||
t.Errorf("IP = %q, want 1.2.3.4", ev.IP)
|
||||
}
|
||||
if ev.User != "administrator" {
|
||||
t.Errorf("User = %q", ev.User)
|
||||
}
|
||||
if ev.Port != 51234 {
|
||||
t.Errorf("Port = %d", ev.Port)
|
||||
}
|
||||
if ev.LogonType != "10" {
|
||||
t.Errorf("LogonType = %q", ev.LogonType)
|
||||
}
|
||||
if ev.Source != SourceRDP {
|
||||
t.Errorf("Source = %q", ev.Source)
|
||||
}
|
||||
if !ev.Kind.IsHard() {
|
||||
t.Error("RDP 登录失败应当计入阈值")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("无源IP必须要求补齐而不是猜", func(t *testing.T) {
|
||||
ev, needResolve, ok := Parse4625(evt4625NoIP, now)
|
||||
if !ok {
|
||||
t.Fatal("应当解析成功(拿到用户名等信息)")
|
||||
}
|
||||
if !needResolve {
|
||||
t.Fatal("IpAddress 为 - 时必须要求补齐")
|
||||
}
|
||||
if ev.IP != "" {
|
||||
t.Errorf("补齐前 IP 必须为空,不能瞎填,got %q", ev.IP)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("本地登录不采集", func(t *testing.T) {
|
||||
if _, _, ok := Parse4625(evt4625Local, now); ok {
|
||||
t.Error("LogonType=2 是本机交互登录,不该被当成远程爆破")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("网络登录采集", func(t *testing.T) {
|
||||
ev, needResolve, ok := Parse4625(evt4625Network, now)
|
||||
if !ok || needResolve {
|
||||
t.Fatalf("ok=%v needResolve=%v", ok, needResolve)
|
||||
}
|
||||
if ev.IP != "5.6.7.8" || ev.LogonType != "3" {
|
||||
t.Errorf("ev = %+v", ev)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("IPv6源", func(t *testing.T) {
|
||||
ev, needResolve, ok := Parse4625(evt4625IPv6, now)
|
||||
if !ok || needResolve {
|
||||
t.Fatalf("ok=%v needResolve=%v", ok, needResolve)
|
||||
}
|
||||
if ev.IP != "2001:db8::1" {
|
||||
t.Errorf("IP = %q", ev.IP)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("环回来源要求补齐而不是直接封本机", func(t *testing.T) {
|
||||
_, needResolve, ok := Parse4625(evt4625Loopback, now)
|
||||
if !ok {
|
||||
t.Fatal("应当解析成功")
|
||||
}
|
||||
if !needResolve {
|
||||
t.Error("环回地址不是可封禁目标,必须走补齐流程")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("非4625事件不处理", func(t *testing.T) {
|
||||
if _, _, ok := Parse4625(evtRdp140, now); ok {
|
||||
t.Error("140 不是 4625")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("非法XML不panic", func(t *testing.T) {
|
||||
if _, _, ok := Parse4625("not xml at all", now); ok {
|
||||
t.Error("非法内容不该解析成功")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseRdpCoreTSIP(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
t.Run("140事件IPString", func(t *testing.T) {
|
||||
ip, _, ok := ParseRdpCoreTSIP(evtRdp140, now)
|
||||
if !ok {
|
||||
t.Fatal("应当解析成功")
|
||||
}
|
||||
if ip != "9.8.7.6" {
|
||||
t.Errorf("ip = %q, want 9.8.7.6", ip)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("131事件ClientIP带端口需去掉端口", func(t *testing.T) {
|
||||
ip, _, ok := ParseRdpCoreTSIP(evtRdp131, now)
|
||||
if !ok {
|
||||
t.Fatal("应当解析成功")
|
||||
}
|
||||
if ip != "11.22.33.44" {
|
||||
t.Errorf("ip = %q, want 11.22.33.44(端口必须被剥掉)", ip)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("非RdpCoreTS事件不处理", func(t *testing.T) {
|
||||
if _, _, ok := ParseRdpCoreTSIP(evt4625WithIP, now); ok {
|
||||
t.Error("4625 不是 131/140")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUsableRemoteIP(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"1.2.3.4", true},
|
||||
{"2001:db8::1", true},
|
||||
{"1.2.3.4:5555", true},
|
||||
{"[2001:db8::1]:5555", true},
|
||||
{"-", false}, // Windows 表示"未知"
|
||||
{"", false}, // 空
|
||||
{"127.0.0.1", false}, // 环回,封了伤自己
|
||||
{"::1", false},
|
||||
{"0.0.0.0", false}, // 未指定地址
|
||||
{"not-an-ip", false}, // 垃圾数据
|
||||
{"DESKTOP-ABC", false}, // WorkstationName 不是 IP,绝不能当来源
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := usableRemoteIP(c.in); got != c.want {
|
||||
t.Errorf("usableRemoteIP(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeIP(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"1.2.3.4", "1.2.3.4"},
|
||||
{"1.2.3.4:5555", "1.2.3.4"},
|
||||
{"[2001:db8::1]:5555", "2001:db8::1"},
|
||||
{"2001:DB8::1", "2001:db8::1"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := normalizeIP(c.in); got != c.want {
|
||||
t.Errorf("normalizeIP(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//go:build windows
|
||||
|
||||
package wafhostguard
|
||||
|
||||
import (
|
||||
"SamWaf/common/wafexec"
|
||||
"SamWaf/common/zlog"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// wevtapi 绑定失败(精简版系统、DLL 缺函数、频道无权限)时的降级通道:
|
||||
// 定期调 wevtutil 查最近的事件,用 EventRecordID 做书签去重。
|
||||
//
|
||||
// 比订阅慢(默认 5 秒一轮),但零绑定风险。宁可慢一点也不能整个功能在
|
||||
// 某些系统上直接不可用。
|
||||
|
||||
const (
|
||||
// wevtutilInterval 轮询间隔
|
||||
wevtutilInterval = 5 * time.Second
|
||||
// wevtutilBatch 单次最多取多少条。5 秒内 4625 超过这个数说明正在被猛攻,
|
||||
// 丢掉更早的那些也无所谓——阈值早就触发了。
|
||||
wevtutilBatch = 200
|
||||
// wevtutilTimeout 单次命令超时
|
||||
wevtutilTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
// runWevtutil 降级模式主循环
|
||||
func (s *winEventSource) runWevtutil(ctx context.Context, out chan<- LoginFailEvent) error {
|
||||
var lastSecurityID uint64
|
||||
var lastRdpID uint64
|
||||
|
||||
ticker := time.NewTicker(wevtutilInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// 首轮先把书签推到当前最新,避免把历史事件当成刚发生的
|
||||
lastSecurityID = latestRecordID(ctx, securityChannel, security4625Qry)
|
||||
lastRdpID = latestRecordID(ctx, rdpCoreChannel, rdpCoreQry)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
}
|
||||
|
||||
// 先收 RdpCoreTS,让环形缓冲里先有 IP,再处理 4625 才补得上
|
||||
if xmls, maxID := queryEvents(ctx, rdpCoreChannel, rdpCoreQry, lastRdpID); len(xmls) > 0 {
|
||||
for _, x := range xmls {
|
||||
if ip, at, ok := ParseRdpCoreTSIP(x, time.Now()); ok {
|
||||
s.ring.add(ip, at)
|
||||
}
|
||||
}
|
||||
if maxID > lastRdpID {
|
||||
lastRdpID = maxID
|
||||
}
|
||||
}
|
||||
|
||||
xmls, maxID := queryEvents(ctx, securityChannel, security4625Qry, lastSecurityID)
|
||||
for _, x := range xmls {
|
||||
s.handle4625(ctx, x, out)
|
||||
}
|
||||
if maxID > lastSecurityID {
|
||||
lastSecurityID = maxID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// queryEvents 查 afterID 之后的新事件,返回 XML 列表与本批最大 RecordID。
|
||||
// 结果按时间倒序返回,这里翻转成正序处理,保证 4625 的处理顺序与发生顺序一致。
|
||||
func queryEvents(ctx context.Context, channel, query string, afterID uint64) ([]string, uint64) {
|
||||
raw := runWevtutilQuery(ctx, channel, query, wevtutilBatch)
|
||||
if len(raw) == 0 {
|
||||
return nil, afterID
|
||||
}
|
||||
|
||||
maxID := afterID
|
||||
out := make([]string, 0, len(raw))
|
||||
// raw 是倒序(最新在前),倒着遍历得到正序
|
||||
for i := len(raw) - 1; i >= 0; i-- {
|
||||
id := recordIDOf(raw[i])
|
||||
if id <= afterID {
|
||||
continue
|
||||
}
|
||||
if id > maxID {
|
||||
maxID = id
|
||||
}
|
||||
out = append(out, raw[i])
|
||||
}
|
||||
return out, maxID
|
||||
}
|
||||
|
||||
// latestRecordID 取该频道当前最新一条的 RecordID,用作初始书签
|
||||
func latestRecordID(ctx context.Context, channel, query string) uint64 {
|
||||
raw := runWevtutilQuery(ctx, channel, query, 1)
|
||||
if len(raw) == 0 {
|
||||
return 0
|
||||
}
|
||||
return recordIDOf(raw[0])
|
||||
}
|
||||
|
||||
// runWevtutil 执行查询并把输出拆成一条条 XML
|
||||
func runWevtutilQuery(ctx context.Context, channel, query string, count int) []string {
|
||||
cctx, cancel := context.WithTimeout(ctx, wevtutilTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(cctx, "wevtutil", "qe", channel,
|
||||
"/q:"+query,
|
||||
"/f:RenderedXml",
|
||||
"/c:"+itoa(count),
|
||||
"/rd:true") // 倒序,最新的在前
|
||||
out, err := wafexec.FixStdin(cmd).Output()
|
||||
if err != nil {
|
||||
if cctx.Err() == nil {
|
||||
zlog.Debug("[主机登录防护] wevtutil 查询失败", "channel", channel, "error", err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return splitEventXML(string(out))
|
||||
}
|
||||
|
||||
// splitEventXML 把 wevtutil 连续输出的多个 <Event>...</Event> 拆开。
|
||||
// wevtutil 不输出根元素,多条事件是首尾相接的,不能整体 Unmarshal。
|
||||
func splitEventXML(s string) []string {
|
||||
var out []string
|
||||
for {
|
||||
start := strings.Index(s, "<Event ")
|
||||
if start < 0 {
|
||||
start = strings.Index(s, "<Event>")
|
||||
}
|
||||
if start < 0 {
|
||||
return out
|
||||
}
|
||||
end := strings.Index(s[start:], "</Event>")
|
||||
if end < 0 {
|
||||
return out
|
||||
}
|
||||
end += start + len("</Event>")
|
||||
out = append(out, s[start:end])
|
||||
s = s[end:]
|
||||
}
|
||||
}
|
||||
|
||||
// recordIDOf 抽 EventRecordID 用作书签
|
||||
func recordIDOf(xmlStr string) uint64 {
|
||||
var e winEvent
|
||||
if err := xml.Unmarshal([]byte(xmlStr), &e); err != nil {
|
||||
return 0
|
||||
}
|
||||
return e.System.EventRecordID
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n <= 0 {
|
||||
return "1"
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
|
||||
// newSources Windows 事件源装配:优先 wevtapi 订阅,不可用则降级 wevtutil 轮询
|
||||
func newSources() ([]Source, string) {
|
||||
ok, reason, _ := checkLogCapability()
|
||||
if !ok {
|
||||
return nil, reason
|
||||
}
|
||||
|
||||
src := &winEventSource{ring: newRDPIPRing()}
|
||||
if err := wevtAvailable(); err != nil {
|
||||
src.fallback = true
|
||||
zlog.Warn("[主机登录防护] wevtapi 不可用,已降级为 wevtutil 轮询(每5秒一次,实时性略差但功能不受影响)",
|
||||
"error", err.Error())
|
||||
}
|
||||
return []Source{src}, ""
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 发送函数
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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, "无到期封禁")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user