From b3545254212c1649dfcdc23debc86511fd0b62f7 Mon Sep 17 00:00:00 2001 From: samwaf Date: Wed, 22 Jul 2026 13:52:53 +0800 Subject: [PATCH] feat:per-host outbound HTTP/2 switch --- model/hosts.go | 1 + model/request/waf_host_req.go | 2 + service/waf_service/waf_host.go | 2 + wafenginecore/hostssl.go | 85 +++++++++++++++ wafenginecore/hostssl_http2_test.go | 161 ++++++++++++++++++++++++++++ wafenginecore/wafengine.go | 27 ++++- 6 files changed, 274 insertions(+), 4 deletions(-) create mode 100644 wafenginecore/hostssl_http2_test.go diff --git a/model/hosts.go b/model/hosts.go index 93c3e7f..57389ba 100644 --- a/model/hosts.go +++ b/model/hosts.go @@ -54,6 +54,7 @@ type Hosts struct { TamperJSON string `gorm:"type:text" json:"tamper_json"` //网页防篡改配置 json(响应基线比对) UploadSecurityJSON string `gorm:"type:text" json:"upload_security_json"` //文件上传内容检测配置 json(扩展名/Webshell/类型/大小) IPMode string `gorm:"size:20" json:"ip_mode"` //IP提取模式: "nic" 网卡模式 或 "proxy" 代理模式 + DisableHTTP2 int `json:"disable_http2"` //对外HTTP/2开关 0启用(默认/现状) 1关闭(该站点ALPN只提供http/1.1,兼容安卓等原生WebSocket客户端) } type HostsDefense struct { diff --git a/model/request/waf_host_req.go b/model/request/waf_host_req.go index 2c54c6d..230fbab 100644 --- a/model/request/waf_host_req.go +++ b/model/request/waf_host_req.go @@ -48,6 +48,7 @@ type WafHostAddReq struct { TamperJSON string `json:"tamper_json"` //网页防篡改配置 json UploadSecurityJSON string `json:"upload_security_json"` //文件上传内容检测配置 json IPMode string `json:"ip_mode"` //IP提取模式: "nic" 网卡模式 或 "proxy" 代理模式 + DisableHTTP2 int `json:"disable_http2"` //对外HTTP/2开关 0启用 1关闭(该站点只走http/1.1,兼容原生WebSocket客户端) } type WafHostDelReq struct { @@ -104,6 +105,7 @@ type WafHostEditReq struct { TamperJSON string `json:"tamper_json"` //网页防篡改配置 json UploadSecurityJSON string `json:"upload_security_json"` //文件上传内容检测配置 json IPMode string `json:"ip_mode"` //IP提取模式: "nic" 网卡模式 或 "proxy" 代理模式 + DisableHTTP2 int `json:"disable_http2"` //对外HTTP/2开关 0启用 1关闭(该站点只走http/1.1,兼容原生WebSocket客户端) } type WafHostGuardStatusReq struct { CODE string `json:"code"` diff --git a/service/waf_service/waf_host.go b/service/waf_service/waf_host.go index 1d6981a..ef880f0 100644 --- a/service/waf_service/waf_host.go +++ b/service/waf_service/waf_host.go @@ -107,6 +107,7 @@ func (receiver *WafHostService) AddApi(wafHostAddReq request.WafHostAddReq) (str TamperJSON: wafHostAddReq.TamperJSON, UploadSecurityJSON: wafHostAddReq.UploadSecurityJSON, IPMode: wafHostAddReq.IPMode, + DisableHTTP2: wafHostAddReq.DisableHTTP2, } global.GWAF_LOCAL_DB.Create(wafHost) return wafHost.Code, nil @@ -176,6 +177,7 @@ func (receiver *WafHostService) ModifyApi(wafHostEditReq request.WafHostEditReq) "TamperJSON": wafHostEditReq.TamperJSON, "UploadSecurityJSON": wafHostEditReq.UploadSecurityJSON, "IPMode": wafHostEditReq.IPMode, + "DisableHTTP2": wafHostEditReq.DisableHTTP2, } err := global.GWAF_LOCAL_DB.Debug().Model(model.Hosts{}).Where("CODE=?", wafHostEditReq.CODE).Updates(hostMap).Error diff --git a/wafenginecore/hostssl.go b/wafenginecore/hostssl.go index 670f2f3..be084f9 100644 --- a/wafenginecore/hostssl.go +++ b/wafenginecore/hostssl.go @@ -1,8 +1,10 @@ package wafenginecore import ( + "SamWaf/common/domaintool" "SamWaf/common/zlog" "SamWaf/global" + "SamWaf/utils" "crypto/tls" "errors" "net" @@ -150,3 +152,86 @@ func (waf *WafEngine) GetCertificateFunc(clientInfo *tls.ClientHelloInfo) (*tls. } return nil, errors.New("certificate not found for domain: " + serverName) } + +// isHTTP2DisabledForServerName 按 SNI + 端口解析出站点,读其 DisableHTTP2。 +// 解析逻辑镜像 ServeHTTP 的域名匹配(宽松端口/精确/泛域名/绑定多域名/通配端口), +// 路由表 key 一律是 host:port,故这里也用 host:port 拼 key。 +// 任何无法解析的情况一律 fail-open(返回 false=保持 h2),未知/未注册 SNI 绝不误关 h2。 +func (waf *WafEngine) isHTTP2DisabledForServerName(serverName string, port string) bool { + if serverName == "" { + return false + } + pureDomain := utils.GetPureDomain(serverName) + hostKey := pureDomain + if port != "" { + hostKey = pureDomain + ":" + port + } + rt := waf.rt() + + // 1) 宽松端口:按纯域名映射到具体 host:port + if hp, ok := rt.HostTargetNoPort[pureDomain]; ok { + if target, ok := rt.HostTarget[hp]; ok && target != nil { + return target.Host.DisableHTTP2 == 1 + } + } + // 2) 精确 host:port + if target, ok := rt.HostTarget[hostKey]; ok && target != nil { + return target.Host.DisableHTTP2 == 1 + } + // 3) 泛域名 host:port + if target, ok := rt.HostTarget[domaintool.MaskSubdomain(hostKey)]; ok && target != nil { + return target.Host.DisableHTTP2 == 1 + } + // 4) 绑定多域名 domain:port -> code -> HostSafe(含泛域名兜底) + code, ok := rt.HostTargetMoreDomain[hostKey] + if !ok { + code, ok = rt.HostTargetMoreDomain[domaintool.MaskSubdomain(hostKey)] + } + if ok { + if h := rt.HostTarget[rt.HostCode[code]]; h != nil { + return h.Host.DisableHTTP2 == 1 + } + } + // 5) 不指定域名的宽松端口 "*" + if hp, ok := rt.HostTargetNoPort["*"]; ok { + if target, ok := rt.HostTarget[hp]; ok && target != nil { + return target.Host.DisableHTTP2 == 1 + } + } + // 6) 通配端口 *:port + if port != "" { + if target, ok := rt.HostTarget["*:"+port]; ok && target != nil { + return target.Host.DisableHTTP2 == 1 + } + } + return false +} + +// portFromLocalAddr 从连接的本地地址取监听端口(用于按 SNI+port 匹配路由)。 +func portFromLocalAddr(conn net.Conn) string { + if conn == nil { + return "" + } + if _, p, err := net.SplitHostPort(conn.LocalAddr().String()); err == nil { + return p + } + return "" +} + +// GetTLSConfigForClient 逐连接按 SNI 定制 ALPN: +// 默认广告 h2+http/1.1;命中的站点 DisableHTTP2==1 时只广告 http/1.1, +// 使原生 WebSocket 客户端(如安卓 uni.connectSocket)不会协商到 h2、握手成功。 +// 返回的 config 仍提供 GetCertificate 与版本范围;net/http 在 ServeTLS 时已在 svr.TLSNextProto +// 装好 "h2" 处理器,故广告了 h2 的连接仍会被正确分发到 h2。 +func (waf *WafEngine) GetTLSConfigForClient(clientInfo *tls.ClientHelloInfo) (*tls.Config, error) { + nextProtos := []string{"h2", "http/1.1"} + if waf.isHTTP2DisabledForServerName(clientInfo.ServerName, portFromLocalAddr(clientInfo.Conn)) { + nextProtos = []string{"http/1.1"} + } + return &tls.Config{ + GetCertificate: waf.GetCertificateFunc, + MinVersion: utils.ParseTLSVersion(global.GCONFIG_RECORD_SSLMinVerson), + MaxVersion: utils.ParseTLSVersion(global.GCONFIG_RECORD_SSLMaxVerson), + NextProtos: nextProtos, + }, nil +} diff --git a/wafenginecore/hostssl_http2_test.go b/wafenginecore/hostssl_http2_test.go new file mode 100644 index 0000000..a042214 --- /dev/null +++ b/wafenginecore/hostssl_http2_test.go @@ -0,0 +1,161 @@ +package wafenginecore + +import ( + "SamWaf/model" + "SamWaf/model/wafenginmodel" + "crypto/tls" + "net" + "testing" +) + +// regHost 在测试路由表里注册一个站点(主域名精确 key),返回其 HostSafe。 +// disableHTTP2: 该站点的 DisableHTTP2 取值。 +func regHost(waf *WafEngine, code, host string, port string, disableHTTP2 int) *wafenginmodel.HostSafe { + hs := &wafenginmodel.HostSafe{Host: model.Hosts{ + Code: code, + Host: host, + DisableHTTP2: disableHTTP2, + }} + waf.rt().HostTarget[host+":"+port] = hs + waf.rt().HostCode[code] = host + ":" + port + return hs +} + +// TestIsHTTP2Disabled_ExactHost 精确域名:默认(0)不关、置1则关 +func TestIsHTTP2Disabled_ExactHost(t *testing.T) { + waf := newTestWafEngine() + regHost(waf, "c_on", "on.example.com", "443", 0) + regHost(waf, "c_off", "off.example.com", "443", 1) + + if waf.isHTTP2DisabledForServerName("on.example.com", "443") { + t.Errorf("默认站点 DisableHTTP2=0 不应关 h2") + } + if !waf.isHTTP2DisabledForServerName("off.example.com", "443") { + t.Errorf("DisableHTTP2=1 的站点应关 h2") + } +} + +// TestIsHTTP2Disabled_UnknownSNI 未注册 SNI 一律 fail-open(保持 h2) +func TestIsHTTP2Disabled_UnknownSNI(t *testing.T) { + waf := newTestWafEngine() + regHost(waf, "c_off", "off.example.com", "443", 1) + + if waf.isHTTP2DisabledForServerName("stranger.example.com", "443") { + t.Errorf("未注册 SNI 应 fail-open 不关 h2") + } + if waf.isHTTP2DisabledForServerName("", "443") { + t.Errorf("空 SNI 应 fail-open 不关 h2") + } +} + +// TestIsHTTP2Disabled_Wildcard 泛域名匹配 *.example.com +func TestIsHTTP2Disabled_Wildcard(t *testing.T) { + waf := newTestWafEngine() + // 站点以泛域名形式注册(MaskSubdomain 生成 *.example.com:443) + regHost(waf, "c_wild", "*.example.com", "443", 1) + + if !waf.isHTTP2DisabledForServerName("a.example.com", "443") { + t.Errorf("一级子域名应命中泛域名站点并关 h2") + } + // MaskSubdomain 只替换首段:deep.a.example.com -> *.a.example.com,不命中 *.example.com → fail-open + if waf.isHTTP2DisabledForServerName("deep.a.example.com", "443") { + t.Errorf("多级子域不应命中 *.example.com,应 fail-open 不关 h2") + } +} + +// TestIsHTTP2Disabled_MoreDomain 绑定多域名:副域名也应生效 +func TestIsHTTP2Disabled_MoreDomain(t *testing.T) { + waf := newTestWafEngine() + hs := regHost(waf, "c_multi", "main.example.com", "443", 1) + _ = hs + // 副域名 alias.example.com:443 -> code + waf.rt().HostTargetMoreDomain["alias.example.com:443"] = "c_multi" + + if !waf.isHTTP2DisabledForServerName("main.example.com", "443") { + t.Errorf("主域名应关 h2") + } + if !waf.isHTTP2DisabledForServerName("alias.example.com", "443") { + t.Errorf("绑定的副域名应同样关 h2") + } +} + +// TestIsHTTP2Disabled_LoosePort 宽松端口:按纯域名映射,端口不敏感 +func TestIsHTTP2Disabled_LoosePort(t *testing.T) { + waf := newTestWafEngine() + hs := &wafenginmodel.HostSafe{Host: model.Hosts{Code: "c_loose", Host: "loose.example.com", DisableHTTP2: 1}} + waf.rt().HostTarget["loose.example.com:443"] = hs + waf.rt().HostCode["c_loose"] = "loose.example.com:443" + // 宽松端口映射:纯域名 -> 具体 host:port + waf.rt().HostTargetNoPort["loose.example.com"] = "loose.example.com:443" + + // 即便来源端口是 8443,也应经 NoPort 命中并关 h2 + if !waf.isHTTP2DisabledForServerName("loose.example.com", "8443") { + t.Errorf("宽松端口站点应不分端口命中并关 h2") + } +} + +// TestIsHTTP2Disabled_WildPort 通配端口 *:port +func TestIsHTTP2Disabled_WildPort(t *testing.T) { + waf := newTestWafEngine() + hs := &wafenginmodel.HostSafe{Host: model.Hosts{Code: "c_star", Host: "*", DisableHTTP2: 1}} + waf.rt().HostTarget["*:8443"] = hs + waf.rt().HostCode["c_star"] = "*:8443" + + if !waf.isHTTP2DisabledForServerName("anything.example.com", "8443") { + t.Errorf("通配端口 *:8443 应命中并关 h2") + } + if waf.isHTTP2DisabledForServerName("anything.example.com", "443") { + t.Errorf("端口不匹配(443)不应命中 *:8443") + } +} + +// mockLocalAddrConn 提供固定 LocalAddr 的假连接,用于 GetTLSConfigForClient 取端口 +type mockLocalAddrConn struct { + net.Conn + local net.Addr +} + +func (m *mockLocalAddrConn) LocalAddr() net.Addr { return m.local } + +type mockAddr struct{ s string } + +func (a mockAddr) Network() string { return "tcp" } +func (a mockAddr) String() string { return a.s } + +// TestGetTLSConfigForClient_ALPN 端到端验证:关 h2 站点 ALPN 只给 http/1.1,其余给 h2+http/1.1 +func TestGetTLSConfigForClient_ALPN(t *testing.T) { + waf := newTestWafEngine() + regHost(waf, "c_on", "on.example.com", "443", 0) + regHost(waf, "c_off", "off.example.com", "443", 1) + + conn := &mockLocalAddrConn{local: mockAddr{s: "10.0.0.1:443"}} + + // 关 h2 的站点:只 http/1.1 + cfgOff, err := waf.GetTLSConfigForClient(&tls.ClientHelloInfo{ServerName: "off.example.com", Conn: conn}) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if got := cfgOff.NextProtos; len(got) != 1 || got[0] != "http/1.1" { + t.Errorf("关 h2 站点 NextProtos 应为 [http/1.1],实际 %v", got) + } + + // 默认站点:h2 + http/1.1 + cfgOn, err := waf.GetTLSConfigForClient(&tls.ClientHelloInfo{ServerName: "on.example.com", Conn: conn}) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if got := cfgOn.NextProtos; len(got) != 2 || got[0] != "h2" || got[1] != "http/1.1" { + t.Errorf("默认站点 NextProtos 应为 [h2 http/1.1],实际 %v", got) + } + + // 未注册 SNI:fail-open 给 h2 + cfgUnknown, _ := waf.GetTLSConfigForClient(&tls.ClientHelloInfo{ServerName: "stranger.example.com", Conn: conn}) + if got := cfgUnknown.NextProtos; len(got) != 2 || got[0] != "h2" { + t.Errorf("未注册 SNI 应 fail-open 给 h2,实际 %v", got) + } + + // GetCertificate 仍被携带(cert 选择不受影响) + if cfgOff.GetCertificate == nil || cfgOn.GetCertificate == nil { + t.Errorf("返回的 TLS config 应仍提供 GetCertificate") + } +} diff --git a/wafenginecore/wafengine.go b/wafenginecore/wafengine.go index 10417c2..affd056 100644 --- a/wafenginecore/wafengine.go +++ b/wafenginecore/wafengine.go @@ -1880,8 +1880,11 @@ func (waf *WafEngine) StartProxyServer(innruntime innerbean.ServerRunTime) { Handler: waf, TLSConfig: &tls.Config{ GetCertificate: waf.GetCertificateFunc, - MinVersion: utils.ParseTLSVersion(global.GCONFIG_RECORD_SSLMinVerson), - MaxVersion: utils.ParseTLSVersion(global.GCONFIG_RECORD_SSLMaxVerson), + // 按 SNI 逐连接定制 ALPN,实现 per-host 的对外 HTTP/2 开关: + // 命中 DisableHTTP2==1 的站点只广告 http/1.1,兼容原生 WebSocket 客户端。 + GetConfigForClient: waf.GetTLSConfigForClient, + MinVersion: utils.ParseTLSVersion(global.GCONFIG_RECORD_SSLMinVerson), + MaxVersion: utils.ParseTLSVersion(global.GCONFIG_RECORD_SSLMaxVerson), }, } serclone, _ := waf.ServerOnline.Get(innruntime.Port) @@ -1892,16 +1895,32 @@ func (waf *WafEngine) StartProxyServer(innruntime innerbean.ServerRunTime) { zlog.Info("启动HTTPS 服务器" + strconv.Itoa(innruntime.Port)) if global.GCONFIG_ENABLE_HTTP3 == 1 { + // h3 用独立 TLSConfig:不挂 h2 的 GetConfigForClient,否则会把 "h3" 从 + // NextProtos 里剔掉、打断整个 HTTP/3(h3 与 h2 是独立 ALPN,互不影响)。 + h3TLS := &tls.Config{ + GetCertificate: waf.GetCertificateFunc, + MinVersion: utils.ParseTLSVersion(global.GCONFIG_RECORD_SSLMinVerson), + MaxVersion: utils.ParseTLSVersion(global.GCONFIG_RECORD_SSLMaxVerson), + } h3 := &http3.Server{ Addr: ":" + strconv.Itoa(innruntime.Port), Handler: waf, - TLSConfig: svr.TLSConfig, + TLSConfig: h3TLS, } if global.GCONFIG_ENABLE_HTTP3_BBR == 1 { h3.QUICConfig.Congestion = func() quic.SendAlgorithmWithDebugInfos { return quic.NewBBRv1(nil) } } + h3Port := strconv.Itoa(innruntime.Port) svr.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h3.SetQUICHeaders(w.Header()) + // 关了 h2 的站点不广告 h3(Alt-Svc):原生 WebSocket 客户端同样不能走 h3, + // 避免其被诱导升级到 h3 后再次握手失败,让该站点彻底只走 http/1.1。 + reqDomain := r.Host + if idx := strings.IndexByte(reqDomain, ':'); idx >= 0 { + reqDomain = reqDomain[:idx] + } + if !waf.isHTTP2DisabledForServerName(reqDomain, h3Port) { + h3.SetQUICHeaders(w.Header()) + } waf.ServeHTTP(w, r) }) go func() {