feat: disable HTTP/2 support across the application to ensure compatibility with HTTP/1.1 hijacking semantics

Kim committed May 27, 2026 at 17:48 UTC 11dee25842aaf7b76c392a9ebf719bd241daf9b3
10 files changed +21 -13
config.toml
+1 -1
@@ -2,7 +2,7 @@
2 # Bump protocol versions only when wire-level behavior changes.
3
4 [release]
5 -version = "v2.2.3"
5 +version = "v2.2.4"
6 base_url = "https://github.com/gosuda/portal-tunnel/releases"
7
8 [protocol]
docs/src/routes/architecture/+page.md
+1 -1
@@ -173,7 +173,7 @@ UDP client
173 - `/sdk/connect`, `/sdk/renew`, and `/sdk/unregister` are authorized by lease existence plus a relay-issued lease access token.
174 - `/sdk/register` is authenticated by a SIWE challenge/response flow using the SDK identity secp256k1 key. On success, the relay issues a lease-scoped ES256K JWT access token signed by the relay identity key and used for the rest of the lease lifecycle.
175 - Relay URLs must use `https://`.
176 -- HTTP/2 is allowed on the admin/API TLS listener for ordinary API traffic; `/sdk/connect` remains HTTP/1.1-only because it depends on hijacking semantics.
176 +- HTTP/2 stays disabled on the admin/API TLS listener because `/sdk/connect` depends on HTTP/1.1 hijacking semantics.
177 - WireGuard, when enabled, is relay-to-relay overlay transport only. It carries multi-hop relay forwarding and overlay discovery, but it is not used for direct tenant TLS termination, public UDP ingress, or `/sdk/*` control-plane traffic.
178
179 ### Reverse Session Protocol
docs/static/examples/nginx-proxy/nginx.conf
+4 -4
@@ -53,9 +53,9 @@ stream {
53 # nginx terminates TLS for the root domain only, then proxies HTTP/1.1 to
54 # the portal admin/API listener on port 4017.
55 #
56 -# HTTP/2 may be enabled on this listener for ordinary admin/API traffic.
57 -# /sdk/connect clients still negotiate HTTP/1.1 and the proxy must use
58 -# HTTP/1.1 to the portal admin/API backend for connection hijacking.
56 +# HTTP/2 is intentionally disabled on this listener.
57 +# /sdk/connect depends on HTTP/1.1 connection hijacking semantics.
58 +# Do NOT add 'http2' to the listen directives below.
59 http {
60 sendfile on;
61 tcp_nopush on;
@@ -74,7 +74,7 @@ http {
74 # ── Root domain: admin/API/frontend ──────────────────────────────────────
75 server {
76 # Internal L7 listener. Receives traffic from the L4 stream block.
77 - # Add 'http2' if desired; /sdk/connect clients still use HTTP/1.1.
77 + # Do NOT add 'http2' — /sdk/connect requires HTTP/1.1 hijacking.
78 listen 8443 ssl;
79 server_name portal.example.com;
80 server_tokens off;
portal/api_server.go
+1
@@ -69,6 +69,7 @@ func (s *Server) newAPIServer(listener net.Listener, apiMux *http.ServeMux, apiT
69 apiServer := &http.Server{
70 Handler: s.apiHandler(apiMux, keylessSignerHandler),
71 ReadHeaderTimeout: 10 * time.Second,
72 + TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)),
73 }
74
75 apiCloser, err := keyless.AttachToHTTPServer(apiServer, apiTLS)
portal/discovery/refresher.go
+2 -1
@@ -38,8 +38,9 @@ func NewRefresher(relaySet *RelaySet, overlay OverlayRuntime) *Refresher {
38 httpClient: utils.NewHTTPClient(
39 utils.WithHTTPTLSConfig(&tls.Config{
40 MinVersion: tls.VersionTLS12,
41 - NextProtos: []string{"h2", "http/1.1"},
41 + NextProtos: []string{"http/1.1"},
42 }),
43 + utils.WithoutHTTP2(),
44 utils.WithHTTPTimeout(defaultRequestTimeout),
45 ),
46 overlay: overlay,
portal/keyless/tls.go
+2 -2
@@ -42,7 +42,7 @@ func AttachToHTTPServer(server *http.Server, cfg TLSMaterialConfig) (io.Closer,
42 ClientKeyPEM: cfg.Keyless.ClientKeyPEM,
43 RootCAPEM: cfg.Keyless.RootCAPEM,
44 },
45 - NextProtos: []string{"h2", "http/1.1"},
45 + NextProtos: []string{"http/1.1"},
46 MinTLSVersion: minVersion,
47 EncryptedClientHelloKeys: cfg.EncryptedClientHelloKeys,
48 })
@@ -60,7 +60,7 @@ func AttachToHTTPServer(server *http.Server, cfg TLSMaterialConfig) (io.Closer,
60 minVersion := MinTLSVersion(len(cfg.EncryptedClientHelloKeys) > 0)
61 server.TLSConfig = &tls.Config{
62 MinVersion: minVersion,
63 - NextProtos: []string{"h2", "http/1.1"},
63 + NextProtos: []string{"http/1.1"},
64 Certificates: []tls.Certificate{cert},
65 EncryptedClientHelloKeys: cfg.EncryptedClientHelloKeys,
66 }
portal/overlay/overlay.go
+1
@@ -143,6 +143,7 @@ func NewOverlay(cfg Config, handler http.Handler, streamHandler StreamHandler) (
143 utils.WithHTTPIdleConnTimeout(90*time.Second),
144 utils.WithHTTPResponseHeaderTimeout(30*time.Second),
145 utils.WithHTTPExpectContinueTimeout(1*time.Second),
146 + utils.WithoutHTTP2(),
147 )
148
149 publicCfg := cfg.Copy()
sdk/listener.go
+1 -3
@@ -575,11 +575,9 @@ func (l *listener) openReverseSession(ctx context.Context) (net.Conn, error) {
575 return nil, errors.New("relay tls config is unavailable")
576 }
577
578 - reverseTLSConfig := l.tlsConfig.Clone()
579 - reverseTLSConfig.NextProtos = []string{"http/1.1"}
578 dialer := &tls.Dialer{
579 NetDialer: &net.Dialer{Timeout: l.dialTimeout},
582 - Config: reverseTLSConfig,
580 + Config: l.tlsConfig.Clone(),
581 }
582
583 conn, err := dialer.DialContext(ctx, "tcp", utils.EnsurePort(l.relayURL.Host))
utils/http.go
+6
@@ -57,6 +57,12 @@ func WithHTTPDialContext(dialContext func(context.Context, string, string) (net.
57 }
58 }
59
60 +func WithoutHTTP2() HTTPClientOption {
61 + return func(c *http.Client) {
62 + mustTransportOf(c).ForceAttemptHTTP2 = false
63 + }
64 +}
65 +
66 func WithHTTPResponseHeaderTimeout(timeout time.Duration) HTTPClientOption {
67 return func(c *http.Client) {
68 mustTransportOf(c).ResponseHeaderTimeout = timeout
utils/tls.go
+2 -1
@@ -43,10 +43,11 @@ func NewHTTPTLSClient(ctx context.Context, relayURL *url.URL, timeout time.Durat
43 MinVersion: tls.VersionTLS12,
44 ServerName: serverName,
45 RootCAs: rootCAs,
46 - NextProtos: []string{"h2", "http/1.1"},
46 + NextProtos: []string{"http/1.1"},
47 }
48 httpClient := NewHTTPClient(
49 WithHTTPTLSConfig(rawTLSConfig), // will be cloned internally
50 + WithoutHTTP2(),
51 WithHTTPTimeout(timeout),
52 )
53 return rawTLSConfig, httpClient, mustTransportOf(httpClient), nil