mirror of
https://gitee.com/samwaf/SamWaf.git
synced 2026-10-31 02:01:44 +08:00
feat:per-host outbound HTTP/2 switch
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user