fix:add acme diag

This commit is contained in:
samwaf
2026-08-12 21:51:05 +08:00
parent 668c534bf8
commit f9f6bb2388
3 changed files with 232 additions and 2 deletions
+118
View File
@@ -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/<token>: 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/<HostCode>),否则就是"写在一处、读在另一处"。
// 统一成一个函数,避免调用点各拼各的。
func acmeChallengeFilePath(hostCode, token string) string {
return utils.GetCurrentDir() + "/data/vhost/" + hostCode + "/.well-known/acme-challenge/" + token
}
// acmeTokenFromURL 从 /.well-known/acme-challenge/<token> 中取出 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
}
+70
View File
@@ -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/<HostCode>),
// 一旦两边拼法漂移,就会出现"写在一处、读在另一处"的静默失败。
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)
}
}
+44 -2
View File
@@ -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/<token>;多为扫描器请求,可忽略")
}
} 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/<token> 不符,本地校验文件这一环整个没走。
// 原实现在这里是静默跳过的,排查时看不出发生过什么。
warnACMEChallenge(weblogfrist.HOST, weblogfrist.HOST_CODE, weblogfrist.URL, "", "", resp.StatusCode,
"URL 层级与标准挑战路径不符,未尝试本地校验文件",
"标准路径形如 /.well-known/acme-challenge/<token>;多为扫描器请求,可忽略")
}
} 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 请求)