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