From f9f6bb2388c42b64b744becbc87f12e8d8efbce8 Mon Sep 17 00:00:00 2001 From: samwaf Date: Wed, 12 Aug 2026 21:51:05 +0800 Subject: [PATCH] fix:add acme diag --- wafenginecore/acme_diag.go | 118 ++++++++++++++++++++++++++++++++ wafenginecore/acme_diag_test.go | 70 +++++++++++++++++++ wafenginecore/wafengine.go | 46 ++++++++++++- 3 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 wafenginecore/acme_diag.go create mode 100644 wafenginecore/acme_diag_test.go diff --git a/wafenginecore/acme_diag.go b/wafenginecore/acme_diag.go new file mode 100644 index 0000000..66278f9 --- /dev/null +++ b/wafenginecore/acme_diag.go @@ -0,0 +1,118 @@ +package wafenginecore + +import ( + "SamWaf/common/zlog" + "SamWaf/utils" + "fmt" + "os" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" +) + +// ACME(HTTP-01) 证书校验的诊断日志。 +// +// 为什么要单独有这么一组函数:签发失败时,用户手里只有 CA 回的一句 +// "Invalid response from http://xxx/.well-known/acme-challenge/: 404", +// 看不出到底卡在哪一层。实际至少有三种完全不同的原因: +// - 本地校验文件压根没生成(申请侧的问题) +// - 生成了,但请求匹配到的是另一条站点记录,读的目录和写的目录对不上 +// - 都对,但后端对该路径返回了非 404/301/302,本地校验文件没被采用(sslhttp_check) +// + +// acmeWarnInterval 同一站点两条诊断日志之间的最小间隔(秒)。 +// warnAccessNoCenter 保持一致(atomic + CAS,不引锁)。 +const acmeWarnInterval int64 = 300 + +// acmeWarnAt 记录每个站点上一次打印诊断日志的时间戳(秒)。 +// +// key 用 host_code 而不是请求里的 Host:Host 由客户端控制,在兜底站点(GLOBAL_HOST)下 +// 攻击者换个 Host 就能把这个 map 撑大;host_code 的取值范围是已配置的站点数,有界。 +var acmeWarnAt sync.Map // host_code -> *atomic.Int64 + +// acmeChallengeFilePath 返回挑战文件应该在的绝对路径。 +// +// 拼法必须与申请时传给 lego 的 savePath 一致(见 sslorder.go 的 +// GetCurrentDir()/data/vhost/),否则就是"写在一处、读在另一处"。 +// 统一成一个函数,避免调用点各拼各的。 +func acmeChallengeFilePath(hostCode, token string) string { + return utils.GetCurrentDir() + "/data/vhost/" + hostCode + "/.well-known/acme-challenge/" + token +} + +// acmeTokenFromURL 从 /.well-known/acme-challenge/ 中取出 token; +// 取不到或不合法返回空串。 +// +// 只服务于诊断日志,不参与任何放行判定 —— 放行判定仍在各调用点原有的校验里, +// 这里放宽或收紧都不会影响安全边界。 +func acmeTokenFromURL(url string) string { + urls := strings.Split(url, "/") + if len(urls) != 4 { + return "" + } + if !utils.IsValidChallengeFile(urls[3]) { + return "" + } + return urls[3] +} + +// acmeDiagAllowed 判断当前站点是否允许再打一条诊断日志。 +func acmeDiagAllowed(hostCode string) bool { + v, _ := acmeWarnAt.LoadOrStore(hostCode, new(atomic.Int64)) + last := v.(*atomic.Int64) + now := time.Now().Unix() + prev := last.Load() + return now-prev >= acmeWarnInterval && last.CompareAndSwap(prev, now) +} + +// warnACMEChallenge 打印一条 ACME 校验诊断日志(按站点节流)。 +// +// backendStatus 传 0 表示这次没有后端响应(请求侧直接处理的场景)。 +// token / filePath 允许为空串,表示这次没能解析出来。 +func warnACMEChallenge(host, hostCode, url, token, filePath string, backendStatus int, reason, advice string) { + // 节流判定放在最前面:后面的 os.Stat 是一次系统调用, + // 必须挡在闸门之后,否则刷这个路径就等于刷磁盘。 + if !acmeDiagAllowed(hostCode) { + return + } + + fileState := "未解析出token,无法判断" + if filePath != "" { + if _, err := os.Stat(filePath); err == nil { + fileState = "存在" + } else if os.IsNotExist(err) { + fileState = "不存在" + } else { + fileState = "无法访问(" + err.Error() + ")" + } + } + + backendState := "无后端响应" + if backendStatus > 0 { + backendState = strconv.Itoa(backendStatus) + } + + zlog.Warn(fmt.Sprintf( + "ACME证书校验诊断:域名=%s 站点编码=%s 请求=%s token=%s 后端状态码=%s 本地校验文件=%s 本地文件=%s 原因=%s 处置建议=%s"+ + "(同一站点该日志%d秒内只打印一条)", + host, hostCode, url, emptyAsDash(token), backendState, emptyAsDash(filePath), fileState, reason, advice, acmeWarnInterval)) +} + +// infoACMEChallengeHit 本地校验文件被成功采用时打一条 Info。 +// 签发/续期期间才会出现,量极小,不需要节流;有这条日志才能把"成功"和"没走到"区分开。 +func infoACMEChallengeHit(host, hostCode, url, filePath string, backendStatus int) { + backendState := "无后端响应" + if backendStatus > 0 { + backendState = strconv.Itoa(backendStatus) + } + zlog.Info(fmt.Sprintf("ACME证书校验:已用本地校验文件应答 域名=%s 站点编码=%s 请求=%s 本地校验文件=%s 后端状态码=%s", + host, hostCode, url, filePath, backendState)) +} + +func emptyAsDash(s string) string { + if s == "" { + return "-" + } + return s +} diff --git a/wafenginecore/acme_diag_test.go b/wafenginecore/acme_diag_test.go new file mode 100644 index 0000000..1f67006 --- /dev/null +++ b/wafenginecore/acme_diag_test.go @@ -0,0 +1,70 @@ +package wafenginecore + +import ( + "strings" + "testing" +) + +// TestACMEDiagThrottle 钉住诊断日志的节流: +// 这条日志在热路径上,任何人都能构造 /.well-known/acme-challenge/<随机串> 打进来, +// 一旦节流失效,刷这个路径就等于刷日志文件。 +func TestACMEDiagThrottle(t *testing.T) { + const hostCode = "throttle-host-a" + + allowed := 0 + for i := 0; i < 1000; i++ { + if acmeDiagAllowed(hostCode) { + allowed++ + } + } + if allowed != 1 { + t.Errorf("同一站点连打1000次,应该只放行1条日志,实际放行 %d 条", allowed) + } + + // 另一个站点不受影响:节流是按站点独立的,否则 A 站点在签发时 + // 会把 B 站点的诊断日志顺带吞掉。 + if !acmeDiagAllowed("throttle-host-b") { + t.Error("不同站点的节流应互相独立,B站点第一条日志被误吞") + } +} + +// TestAcmeTokenFromURL token 解析只服务于诊断日志,但仍然不能把畸形路径 +// 解析成一个看起来合法的 token,否则诊断日志本身会误导排查方向。 +func TestAcmeTokenFromURL(t *testing.T) { + ok := map[string]string{ + "/.well-known/acme-challenge/2NKiiETgQdPmmjlM88mH5uo6jM98PrgWwsDslaN8": "2NKiiETgQdPmmjlM88mH5uo6jM98PrgWwsDslaN8", + "/.well-known/acme-challenge/ewiYrIoJRFMwEclGR57LLqYkoLeQ3neTNdNGciowtUw": "ewiYrIoJRFMwEclGR57LLqYkoLeQ3neTNdNGciowtUw", + "/.well-known/acme-challenge/a_b-c": "a_b-c", + } + for url, want := range ok { + if got := acmeTokenFromURL(url); got != want { + t.Errorf("acmeTokenFromURL(%s) = %q, 期望 %q", url, got, want) + } + } + + bad := []string{ + "/.well-known/acme-challenge/", // 空 token + "/.well-known/acme-challenge/../../admin", // 穿越 + "/.well-known/acme-challenge/token/extra", // 层级过深 + "/.well-known/acme-challenge/token?x=1", // 带查询串 + "/.well-known/acme-challenge/to ken", // 含空格 + "/foo/../.well-known/acme-challenge/token", // 归一化前不是标准路径 + "/admin", // 完全无关 + } + for _, url := range bad { + if got := acmeTokenFromURL(url); got != "" { + t.Errorf("acmeTokenFromURL(%s) 应返回空串,实际 %q", url, got) + } + } +} + +// TestAcmeChallengeFilePath 拼法必须和申请时传给 lego 的 savePath 一致 +// (sslorder.go: GetCurrentDir()/data/vhost/), +// 一旦两边拼法漂移,就会出现"写在一处、读在另一处"的静默失败。 +func TestAcmeChallengeFilePath(t *testing.T) { + got := acmeChallengeFilePath("host-code-1", "tokenABC") + want := "/data/vhost/host-code-1/.well-known/acme-challenge/tokenABC" + if !strings.HasSuffix(got, want) { + t.Errorf("挑战文件路径 = %q, 期望以 %q 结尾", got, want) + } +} diff --git a/wafenginecore/wafengine.go b/wafenginecore/wafengine.go index 75888d1..766de3d 100644 --- a/wafenginecore/wafengine.go +++ b/wafenginecore/wafengine.go @@ -793,16 +793,22 @@ func (waf *WafEngine) ServeHTTP(w http.ResponseWriter, r *http.Request) { challengeFile := urls[3] //检测challengeFile是否合法 if !utils.IsValidChallengeFile(challengeFile) { + warnACMEChallenge(weblogbean.HOST, weblogbean.HOST_CODE, weblogbean.URL, "", "", 0, + "URL 里的 token 不合法(静态站点模式)", + "多为扫描器构造的请求,可忽略;若签发时出现,请把完整 URL 反馈给我们") return } //当前路径 data/vhost/domain code 变量下 // 需要读取的文件路径 - filePath := utils.GetCurrentDir() + "/data/vhost/" + weblogbean.HOST_CODE + "/.well-known/acme-challenge/" + challengeFile + filePath := acmeChallengeFilePath(weblogbean.HOST_CODE, challengeFile) // 调用读取文件的函数 content, err := utils.ReadFile(filePath) if err != nil { zlog.Error("Error reading file: %v", err.Error()) + warnACMEChallenge(weblogbean.HOST, weblogbean.HOST_CODE, weblogbean.URL, challengeFile, filePath, 0, + "静态站点模式下本地校验文件读取失败:"+err.Error(), + "核对证书是否就在本站点(站点编码)发起;确认申请时该目录下已生成文件") return } if content != "" { @@ -829,8 +835,17 @@ func (waf *WafEngine) ServeHTTP(w http.ResponseWriter, r *http.Request) { weblogbean.STATUS_CODE = r.Response.StatusCode weblogbean.TASK_FLAG = 1 global.GQEQUE_LOG_DB.Enqueue(&weblogbean) + infoACMEChallengeHit(weblogbean.HOST, weblogbean.HOST_CODE, weblogbean.URL, filePath, 0) return } + // 文件为空:不返回内容,请求继续往下走(保持原有行为),但要留痕 + warnACMEChallenge(weblogbean.HOST, weblogbean.HOST_CODE, weblogbean.URL, challengeFile, filePath, 0, + "静态站点模式下本地校验文件为空", + "核对证书是否就在本站点(站点编码)发起;确认申请时该目录下已生成文件") + } else { + warnACMEChallenge(weblogbean.HOST, weblogbean.HOST_CODE, weblogbean.URL, "", "", 0, + "URL 层级与标准挑战路径不符(静态站点模式)", + "标准路径形如 /.well-known/acme-challenge/;多为扫描器请求,可忽略") } } else { waf.serveStaticFile(w, r, staticSiteConfig, &weblogbean, hostTarget) @@ -1400,17 +1415,27 @@ func (waf *WafEngine) modifyResponse() func(*http.Response) error { //检测challengeFile是否合法 if !utils.IsValidChallengeFile(challengeFile) { zlog.Error("challengeFile is invalid", challengeFile) + warnACMEChallenge(weblogfrist.HOST, weblogfrist.HOST_CODE, weblogfrist.URL, "", "", resp.StatusCode, + "URL 里的 token 不合法(只允许字母、数字、下划线、连字符)", + "多为扫描器构造的请求,可忽略;若签发时出现,请把完整 URL 反馈给我们") return nil } //当前路径 data/vhost/domain code 变量下 // 需要读取的文件路径 - filePath := utils.GetCurrentDir() + "/data/vhost/" + weblogfrist.HOST_CODE + "/.well-known/acme-challenge/" + challengeFile + filePath := acmeChallengeFilePath(weblogfrist.HOST_CODE, challengeFile) // 调用读取文件的函数 content, err := utils.ReadFile(filePath) if err != nil { zlog.Error("Error reading file: %v", err.Error()) } + if content == "" { + // 后端没有校验文件、本地也没有,等于把后端的 404 原样交给了 CA,签发必然失败。 + // 这里是两个高频工单的落点,所以要把"该去哪儿找文件"直接写出来。 + warnACMEChallenge(weblogfrist.HOST, weblogfrist.HOST_CODE, weblogfrist.URL, challengeFile, filePath, resp.StatusCode, + "本地校验文件不存在或为空,已把后端响应原样返回给 CA", + "1)核对证书是否就在本站点(站点编码)发起 2)确认该域名80端口的流量确实进了SamWaf(在网站日志里搜这个token) 3)确认申请时该目录下已生成文件") + } if content != "" { resp.StatusCode = http.StatusOK resp.Status = http.StatusText(http.StatusOK) @@ -1424,12 +1449,29 @@ func (waf *WafEngine) modifyResponse() func(*http.Response) error { weblogfrist.TASK_FLAG = 1 weblogfrist.BackendCheckCost = time.Now().UnixNano()/1e6 - backendCheckStart //响应数据处理时间 global.GQEQUE_LOG_DB.Enqueue(weblogfrist) + infoACMEChallengeHit(weblogfrist.HOST, weblogfrist.HOST_CODE, weblogfrist.URL, filePath, resp.StatusCode) } + } else { + // URL 层级与 /.well-known/acme-challenge/ 不符,本地校验文件这一环整个没走。 + // 原实现在这里是静默跳过的,排查时看不出发生过什么。 + warnACMEChallenge(weblogfrist.HOST, weblogfrist.HOST_CODE, weblogfrist.URL, "", "", resp.StatusCode, + "URL 层级与标准挑战路径不符,未尝试本地校验文件", + "标准路径形如 /.well-known/acme-challenge/;多为扫描器请求,可忽略") } } else if global.GCONFIG_RECORD_SSLHTTP_CHECK == 1 { // 当配置为检查HTTP响应码且响应不是404/301/302时,记录警告信息 zlog.Warn(fmt.Sprintf("ACME Challenge检测:域名 %s 的 URL %s 返回了非预期的状态码 %d,影响证书验证,可在系统配置里面将sslhttp_check设置成0", weblogfrist.HOST, weblogfrist.URL, resp.StatusCode)) + // 上面那条只说了状态码,用户还是不知道"本地到底有没有校验文件"。 + // 这两者组合起来才有结论:本地有文件 + 后端返回200 = 后端抢答,把后端那段配置去掉即可。 + challengeToken := acmeTokenFromURL(weblogfrist.URL) + challengeFilePath := "" + if challengeToken != "" { + challengeFilePath = acmeChallengeFilePath(weblogfrist.HOST_CODE, challengeToken) + } + warnACMEChallenge(weblogfrist.HOST, weblogfrist.HOST_CODE, weblogfrist.URL, challengeToken, challengeFilePath, resp.StatusCode, + "后端对挑战路径返回了非 404/301/302,按 sslhttp_check=1 的策略未采用本地校验文件", + "若上面显示本地文件=存在,说明是后端抢答:让后端对该路径返回404(或移除该 location),也可把系统配置 sslhttp_check 改为 0") } } else { // 检查是否需要应用自定义错误页面(非 ACME Challenge 请求)