feat:per-host outbound HTTP/2 switch

This commit is contained in:
samwaf
2026-07-22 13:52:53 +08:00
parent 6be112a17c
commit b354525421
6 changed files with 274 additions and 4 deletions
+1
View File
@@ -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 {
+2
View File
@@ -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"`
+2
View File
@@ -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
+85
View File
@@ -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
}
+161
View File
@@ -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")
}
}
+23 -4
View File
@@ -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() {