Skip to content

v1.3.14 — WebSocket upgrades work on HTTPS upstreams

Critical fix. Pre-v1.3.14 reverse_proxy did not advertise HTTP/1.1 explicitly to HTTPS upstreams; Caddy's ALPN negotiation chose HTTP/2 by default and the classic RFC 6455 WebSocket upgrade had no compatible connection to ride. Result: UniFi Network Control Plane (and every other SPA whose backend speaks HTTPS + WebSockets) loaded blank.

Bug

Reproducing flow on UniFi:

Path Pre-v1.3.14 Browser symptom
GET / HTTP/2 200 shell loads
GET /api/ws/system (WS upgrade) HTTP/2 500 dashboard never populates
GET /api/ws/webrtc/local (WS upgrade) HTTP/2 500 live console blank

The pattern repeats on any HTTPS-upstream backend whose realtime layer is a WebSocket: Home Assistant (when configured HTTPS), Jellyfin streaming, n8n editor, Vaultwarden Send. HTTP upstreams (the more common shape in the test stack) were unaffected -- Caddy never tried HTTP/2 there.

Root cause

backend/internal/caddycfg/caddycfg.go > reverseProxyFromTG emitted, for HTTPS:

{ "protocol": "http", "tls": {"insecure_skip_verify": false} }

...with no versions field. For HTTP upstreams the transport was omitted entirely (Caddy default).

When versions is absent and TLS is set, Caddy lets Go's http.Transport pick from whatever ALPN negotiates. With most modern HTTPS backends advertising both H1 and H2, the transport prefers H2. WS upgrade requires Connection: upgrade semantics that don't translate cleanly onto an H2 multiplexed stream.

Fix

reverseProxyFromTG now always emits the transport block with explicit versions: ["1.1", "2"]:

-       if tg.Protocol == models.ProtocolHTTPS {
-               t := &transport{Protocol: "http", TLS: &transportTLS{}}
-               if !tg.VerifyTLS {
-                       t.TLS.InsecureSkipVerify = true
-               }
-               rp.Transport = t
-       }
+       t := &transport{
+               Protocol: "http",
+               Versions: []string{"1.1", "2"},
+       }
+       if tg.Protocol == models.ProtocolHTTPS {
+               t.TLS = &transportTLS{}
+               if !tg.VerifyTLS {
+                       t.TLS.InsecureSkipVerify = true
+               }
+       }
+       rp.Transport = t

["1.1", "2"] (HTTP/1.1 first) means:

  • WebSocket upgrade hits HTTP/1.1 immediately. RFC 6455 semantics work. Backends with HTTPS upstream that previously 500'd now 101 cleanly.
  • Non-WS HTTP traffic still gets HTTP/2 when the upstream advertises it via ALPN. Go's transport per-request dispatches to the right protocol.
  • Plain HTTP upstreams: the "2" entry is a no-op (Go's http.Transport does not do h2c without TLS), so plaintext backends behave identically. The transport block now exists but has no functional effect on those routes.

Verification

Live config dump, post-fix:

$ docker exec argos-prod-caddy wget -qO- http://localhost:2019/config/ \
  | python3 -c '...' | grep -A 5 transport
transport: {
  "protocol": "http",
  "versions": ["1.1", "2"]
}

Manual WS handshake against an argos-fronted Home Assistant (HTTP upstream, used as a no-regression baseline):

$ curl -i -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
       -H 'Sec-WebSocket-Version: 13' \
       -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
       http://iot.example.com/api/websocket
HTTP/1.1 101 Switching Protocols
Connection: upgrade
Upgrade: websocket
Server: Caddy
Server: Python/3.14 aiohttp/3.13.5
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

Plain HTTP traffic on the same stack (regression check):

$ curl http://home.example.com/   -> HTTP 200
$ curl http://app.example.com -> HTTP 302

The HTTPS-upstream UniFi case lives outside this test stack and will be confirmed by the operator. The unit tests in transport_test.go lock in the JSON shape against future regression.

Tests (3 new, all passing)

  • TestTransportEmitsVersionsForHTTPSUpstream -- HTTPS upstream emits transport.protocol="http", versions: ["1.1", ...], and preserves the TLS sub-block.
  • TestTransportEmitsVersionsForHTTPUpstream -- HTTP upstream emits the transport block with versions starting at 1.1 and crucially NO tls sub-block (would silently break a non-TLS backend during ALPN).
  • TestTransportInsecureSkipVerifyHonoured -- verify_tls=false produces insecure_skip_verify=true so self-signed backends still work.

Files changed

  • backend/internal/caddycfg/caddycfg.go -- transport struct gains Versions []string. reverseProxyFromTG always emits the transport block.
  • backend/internal/caddycfg/transport_test.go (new).
  • docs/operations/troubleshooting.md -- new "WebSocket backend shows blank UI" entry with symptom catalog, verify command, and three post-fix escalation paths.

Upgrade

cd argos-edge
git pull
docker compose build
docker compose up -d

Caddy reload happens automatically on panel restart (the panel re-pushes the full config via the admin API). HTTPS-upstream WebSocket backends should be reachable on the next browser refresh.

Not changed

  • argos user reset-password CLI from v1.3.11 unchanged.
  • AppSec block-mode CRS coverage from v1.3.12 unchanged.
  • v1.3.13 expect_status validation messaging unchanged.
  • No DB migrations, no new env vars, no new compose surface.