From 32a2a62e0dc1cb4d18890bd1ac6f8f824abb6c74 Mon Sep 17 00:00:00 2001 From: anonpenguin23 Date: Thu, 14 May 2026 07:50:47 +0300 Subject: [PATCH] fix(caddy): disable HTTP/2 to keep WebSocket upgrade auth working (#249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HTTP/2 forbids the `Connection: Upgrade` and `Upgrade: websocket` headers per RFC 7540 §8.1.2.2. With h2 advertised at the listener, ALPN negotiates h2 for TLS-capable clients, the WS-upgrade request arrives at Caddy with those headers stripped, and Caddy forwards a plain HTTP/1.1 GET to the gateway. The gateway's `isWebSocketUpgrade(r)` then returns false, the `?api_key=` / `?jwt=` query-string WS-auth fallback never runs, and clients see 401. RFC 8441 ("Bootstrapping WebSockets with HTTP/2") fixes this, but iOS RN and most other mobile WS libraries don't implement it. Until they do, h1 is the only protocol that keeps WS auth working. Trade-off: lose h2 multiplexing on plain HTTP traffic. Acceptable for an API gateway whose dominant workload is REST + WebSocket — neither benefits much from h2 streams. caddy_test.go adds a regression guard so anyone re-enabling h2 in the listener protocols fails CI loud. Also (separate, was uncommitted): pkg/cli/build/builder.go now reads VERSION from the repo-root /VERSION file first, falling back to parsing the Makefile only if absent. The previous Makefile-only path broke after VERSION moved to /VERSION (Makefile got `$(shell cat ...)` which the CLI builder pulled in literally). VERSION bumped to 0.122.13. --- VERSION | 2 +- core/pkg/cli/build/builder.go | 24 ++++- .../production/installers/caddy.go | 24 ++++- .../production/installers/caddy_test.go | 99 +++++++++++++++++++ sdk/package.json | 2 +- 5 files changed, 145 insertions(+), 6 deletions(-) create mode 100644 core/pkg/environments/production/installers/caddy_test.go diff --git a/VERSION b/VERSION index 6554f10..ef85aaa 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.122.12 +0.122.13 diff --git a/core/pkg/cli/build/builder.go b/core/pkg/cli/build/builder.go index 51a32e1..3477ef0 100644 --- a/core/pkg/cli/build/builder.go +++ b/core/pkg/cli/build/builder.go @@ -648,7 +648,23 @@ func (b *Builder) crossEnv() []string { } func (b *Builder) readVersion() string { - // Try to read from Makefile + // Primary: read the repo-root VERSION file (single source of truth). + // The Makefile resolves $(shell cat ../VERSION) at make time, but this + // CLI builder is a separate Go binary that doesn't go through make, so + // we must read VERSION directly. Try ../VERSION first (when projectDir + // is core/), then VERSION in projectDir. + for _, p := range []string{ + filepath.Join(b.projectDir, "..", "VERSION"), + filepath.Join(b.projectDir, "VERSION"), + } { + if data, err := os.ReadFile(p); err == nil { + if v := strings.TrimSpace(string(data)); v != "" { + return v + } + } + } + // Fallback: parse Makefile in case someone runs an older layout where + // VERSION is still hard-coded inline. data, err := os.ReadFile(filepath.Join(b.projectDir, "Makefile")) if err != nil { return "dev" @@ -658,7 +674,11 @@ func (b *Builder) readVersion() string { if strings.HasPrefix(line, "VERSION") { parts := strings.SplitN(line, ":=", 2) if len(parts) == 2 { - return strings.TrimSpace(parts[1]) + v := strings.TrimSpace(parts[1]) + // Ignore unevaluated make expressions like $(shell ...) + if !strings.Contains(v, "$(") { + return v + } } } } diff --git a/core/pkg/environments/production/installers/caddy.go b/core/pkg/environments/production/installers/caddy.go index 4e29775..9f5632f 100644 --- a/core/pkg/environments/production/installers/caddy.go +++ b/core/pkg/environments/production/installers/caddy.go @@ -377,8 +377,28 @@ func (ci *CaddyInstaller) generateCaddyfile(domain, email, acmeEndpoint, baseDom }`, acmeEndpoint) var sb strings.Builder - // Disable HTTP/3 (QUIC) so Caddy doesn't bind UDP 443, which TURN needs for relay - sb.WriteString(fmt.Sprintf("{\n email %s\n servers {\n protocols h1 h2\n }\n}\n", email)) + // Caddy protocol restrictions: + // - HTTP/3 (QUIC) is disabled so Caddy doesn't bind UDP 443, which + // TURN needs for relay. + // - HTTP/2 is also disabled (bug #249). HTTP/2 forbids the + // `Connection: Upgrade` and `Upgrade: websocket` headers per + // RFC 7540 §8.1.2.2, so any WebSocket-upgrade request the + // client sends over an h2 connection arrives at Caddy with + // those headers stripped. Caddy then forwards a plain + // HTTP/1.1 GET to the backend gateway, which no longer + // recognises the request as a WS upgrade — its + // `isWebSocketUpgrade(r)` check fails and the + // query-string `?api_key=` / `?jwt=` WS-auth fallback is + // ignored, producing 401. RFC 8441 ("Bootstrapping WebSockets + // with HTTP/2") would fix this, but iOS RN and many other + // mobile WS libraries don't implement it. Until they do, h1 + // is the only protocol that keeps WS auth working. + // - Cost: lose h2 multiplexing on regular HTTP traffic. + // Acceptable trade-off for an API gateway whose dominant + // workload is REST + WebSocket (neither benefits much from + // h2 stream multiplexing — REST is keep-alive over h1, and + // WS is single-connection by design). + sb.WriteString(fmt.Sprintf("{\n email %s\n servers {\n protocols h1\n }\n}\n", email)) // Node domain blocks (e.g., node1.dbrs.space, *.node1.dbrs.space) sb.WriteString(fmt.Sprintf("\n*.%s {\n%s\n reverse_proxy localhost:6001\n}\n", domain, tlsBlock)) diff --git a/core/pkg/environments/production/installers/caddy_test.go b/core/pkg/environments/production/installers/caddy_test.go new file mode 100644 index 0000000..31598c5 --- /dev/null +++ b/core/pkg/environments/production/installers/caddy_test.go @@ -0,0 +1,99 @@ +package installers + +import ( + "io" + "strings" + "testing" +) + +// newTestCaddyInstaller returns a CaddyInstaller suitable for unit tests — +// no real filesystem or network dependencies. +func newTestCaddyInstaller() *CaddyInstaller { + return &CaddyInstaller{ + BaseInstaller: NewBaseInstaller("amd64", io.Discard), + oramaHome: "/nonexistent", + } +} + +// TestGenerateCaddyfile_DisablesHTTP2 is the regression guard for bug +// #249: HTTP/2 forbids the `Connection: Upgrade` and `Upgrade: websocket` +// headers per RFC 7540 §8.1.2.2, so a WebSocket-upgrade request sent +// over an h2 connection arrives at Caddy with the upgrade headers +// stripped. Caddy then forwards a plain HTTP/1.1 GET to the gateway, +// the gateway's `isWebSocketUpgrade(r)` returns false, the +// query-string `?api_key=` / `?jwt=` WS-auth fallback is ignored, and +// the client gets 401. +// +// Disabling h2 at the listener means ALPN negotiates h1 every time, so +// WS upgrades work cleanly. h3 is also disabled (so Caddy doesn't bind +// UDP 443, which TURN needs). +// +// If anyone adds `h2` back to the `protocols` line without a deliberate +// migration of every mobile-WS client to RFC 8441 ("Bootstrapping +// WebSockets with HTTP/2"), this test fails loud. +func TestGenerateCaddyfile_DisablesHTTP2(t *testing.T) { + ci := newTestCaddyInstaller() + cf := ci.generateCaddyfile("node1.dbrs.space", "admin@dbrs.space", + "http://localhost:6001/v1/internal/acme", "dbrs.space") + + if !strings.Contains(cf, "protocols h1\n") { + t.Errorf("Caddyfile must declare `protocols h1` (bug #249); got:\n%s", cf) + } + if strings.Contains(cf, "protocols h1 h2") { + t.Errorf("Caddyfile must NOT advertise h2 (bug #249 regression); got:\n%s", cf) + } + if strings.Contains(cf, "h3") { + t.Errorf("Caddyfile must NOT advertise h3 (TURN UDP 443 conflict); got:\n%s", cf) + } +} + +func TestGenerateCaddyfile_ContainsCanonicalReverseProxy(t *testing.T) { + ci := newTestCaddyInstaller() + cf := ci.generateCaddyfile("node1.dbrs.space", "admin@dbrs.space", + "http://localhost:6001/v1/internal/acme", "") + + // Sanity checks on the basics; cheap insurance against fat-finger edits. + for _, want := range []string{ + "*.node1.dbrs.space {", + "node1.dbrs.space {", + "reverse_proxy localhost:6001", + "http://*.node1.dbrs.space", + ":80 {", + } { + if !strings.Contains(cf, want) { + t.Errorf("Caddyfile missing %q; got:\n%s", want, cf) + } + } +} + +func TestGenerateCaddyfile_BaseDomainAddsSeparateBlocks(t *testing.T) { + ci := newTestCaddyInstaller() + cf := ci.generateCaddyfile("node1.dbrs.space", "admin@dbrs.space", + "http://localhost:6001/v1/internal/acme", "dbrs.space") + + // Both node-domain and base-domain blocks should be present. + for _, want := range []string{ + "*.node1.dbrs.space", + "*.dbrs.space", + "dbrs.space {", + } { + if !strings.Contains(cf, want) { + t.Errorf("Caddyfile missing %q (base-domain block); got:\n%s", want, cf) + } + } +} + +func TestGenerateCaddyfile_BaseDomainSameAsDomainOmitsDuplicates(t *testing.T) { + ci := newTestCaddyInstaller() + cf := ci.generateCaddyfile("dbrs.space", "admin@dbrs.space", + "http://localhost:6001/v1/internal/acme", "dbrs.space") + + // When base == node domain, the duplicate base blocks must be skipped: + // one TLS `*.dbrs.space { ... }` block + one HTTP `http://*.dbrs.space { + // ... }` block. The substring `*.dbrs.space {` matches both so we + // expect a count of exactly 2, not 4 (which would mean the dedupe + // guard at `if baseDomain != "" && baseDomain != domain` regressed). + if got := strings.Count(cf, "*.dbrs.space {"); got != 2 { + t.Errorf("expected exactly 2 `*.dbrs.space {` occurrences (1 TLS + 1 HTTP), got %d in:\n%s", got, cf) + } +} diff --git a/sdk/package.json b/sdk/package.json index 34b7f0a..aa7864a 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@debros/orama", - "version": "0.122.12", + "version": "0.122.13", "description": "TypeScript SDK for Orama Network - Database, PubSub, Cache, Storage, Vault, and more", "type": "module", "main": "./dist/index.js",