v1.3.16 — preserve_host toggle for hostname-bound backends¶
The second half of the WebSocket-fix story that started with v1.3.14. v1.3.14 unblocked HTTP/1.1 negotiation so the WS upgrade could ride a compatible connection. Some backends still broke afterwards: their WS endpoints were reachable but the auth check inside the upgrade rejected because the upstream saw Host: <dialed-IP>:<port> instead of the original hostname. v1.3.16 adds an opt-in toggle to forward the original Host.
Bug shape (operator-confirmed via isolation test)¶
UniFi Network Controller fronted by argos:
GET /-- HTTP 200, shell renders.GET /api/ws/system(WS upgrade) -- HTTP 500.GET /api/ws/webrtc/local(WS upgrade) -- HTTP 500.- Browser shows the navigation chrome but the dashboards stay blank because their realtime data never arrives.
The same browser, same UniFi backend, with a /etc/hosts entry pointing the public hostname directly at the LAN IP (bypassing argos): WS endpoints return 101 and the dashboards populate. Other reverse proxies in the homelab space (Zoraxy, NPM, sometimes Traefik) preserve Host by default and "just work"; Caddy doesn't.
The same shape repeats on:
- Authentik / Authelia with specific cookie-domain or
AUTH_SET_HEADERS_FROM_REQUESTsettings - Virtual-hosted apps where the hostname IS the routing key (Mastodon, Misskey, GoToSocial, Matrix Synapse with strict
server_name) - Some self-hosted git forges with strict CSRF / Origin checking (Gitea
[server] DOMAIN, Forgejo)
Fix¶
A new per-target-group preserve_host boolean. Default false to keep every existing target group on the pre-v1.3.16 behaviour at upgrade time -- the toggle is opt-in.
When enabled, argos's Caddy emitter adds:
{
"handler": "reverse_proxy",
"upstreams": [...],
"transport": {...},
"headers": {
"request": {
"set": {
"Host": ["{http.request.host}"]
}
}
}
}
{http.request.host} is Caddy's placeholder for the original client-supplied Host (resolved before any internal rewrites). Together with the v1.3.14 transport block, this is the two-part recipe for a hostname-bound WebSocket backend behind argos.
How operators reach it¶
Edit target group modal -> advanced section -> "Preserve Host header (forward original hostname)" checkbox.
Tooltip:
Required for backends that bind sessions to hostname (UniFi Network Controller, some auth proxies, virtual-hosted apps). Enable if backend works with direct access but breaks behind argos.
Save triggers the standard reconcile path; Caddy admin API takes the new config; no restart required.
Schema change (migration 026)¶
Down migration drops the column. Modernc/sqlite ships SQLite >= 3.49 so DROP COLUMN works directly. Idempotent: if the column already exists (operator manually added it before upgrade) the up-migration fails fast and the panel logs the error -- no silent state.
Why off by default¶
Forwarding the original Host can confuse upstreams that expect the dialed address. Two real cases where preserve_host=true would actively break things:
- Multi-tenant apps that route by Host on the upstream side (e.g. one upstream serving many distinct hostnames): the upstream's internal router needs the dialed-as-canonical Host to dispatch to the right tenant.
- Cloud-native LBs (cloud Run, etc.) that use the dialed Host for tenant resolution and ignore X-Forwarded-Host.
Pre-v1.3.16, every target group ran without Host forwarding and most homelab backends were happy. The minority that required it had no easy fix; v1.3.16 gives them one without breaking the rest.
Tests¶
3 new in internal/caddycfg/transport_test.go:
TestPreserveHostEmitsHeaderForwarding-- preserve_host=true producesheaders.request.set.Host = ["{http.request.host}"].TestPreserveHostFalseOmitsHeaders-- preserve_host=false produces NO headers block (no regression for existing target groups; the v1.3.14 transport-only smoke result still holds).TestPreserveHostCoexistsWithHTTPSAndInsecure-- HTTPS upstream + verify_tls=false + preserve_host=true all coexist cleanly with no field collision.
internal/db/migrate_test.go > TestRollbackLastMigration extended to roll back 026 first (asserting target_groups.preserve_host is gone) before reaching the existing 025 invariants. New helper tableHasColumn factored out; the prior hostsHasColumn becomes a thin wrapper.
Smoke verification (real prod stack)¶
Before (preserve_host=0):
reverse_proxy[archive...].headers : (absent)
UPDATE target_groups SET preserve_host=1 WHERE id=1;
docker restart argos-prod-panel # triggers reconcile
After (preserve_host=1):
reverse_proxy[archive...].headers : {
request: { set: { Host: ["{http.request.host}"] } }
}
curl http://app.example.com/ -> HTTP 302 (still works)
UPDATE target_groups SET preserve_host=0 WHERE id=1;
docker restart argos-prod-panel
curl http://app.example.com/ -> HTTP 302 (restored, still works)
Live UniFi-against-argos confirmation lives outside this test stack; the per-handler unit tests + the live emit smoke together cover the wire format. Operators with Host-bound backends should flip the toggle and refresh; previously-broken WS endpoints should now return 101 instead of 500.
Files changed¶
backend/migrations/026_target_groups_preserve_host.up.sql(new)backend/migrations/026_target_groups_preserve_host.down.sql(new)backend/internal/models/models.go--PreserveHost boolonTargetGroupbackend/internal/db/target_groups.go-- column list, INSERT, UPDATE, scanbackend/internal/api/target_groups.go-- request struct field, defaulted falsebackend/internal/caddycfg/caddycfg.go-- emit headers block when set; new typesreverseProxyHeaders+headerOpsbackend/internal/caddycfg/transport_test.go-- 3 new tests + extracted helperbackend/internal/db/migrate_test.go-- rollback path extended; new helperfrontend/src/api/client.ts--preserve_hostonTargetGroup+TargetGroupInputfrontend/src/components/targetGroupFormValue.ts-- default falsefrontend/src/components/TargetGroupForm.tsx-- new checkbox + tooltipfrontend/src/pages/TargetGroupDetail.tsx-- propagate field on Edit-modal opendocs/operations/troubleshooting.md-- new entry "Backend works on direct access but breaks behind argos"
Upgrade¶
Migration 026 applies automatically on first boot. Every existing target group gets preserve_host=false -- behaviour is identical to pre-v1.3.16 until the operator flips a specific group via the Edit modal.
Not changed¶
- v1.3.14's
transport.versions: ["1.1", "2"]-- still emitted on every reverse_proxy. preserve_host is the second axis (Host header) on top of that first axis (HTTP version). - AppSec, target-health, CrowdSec wiring -- untouched.
- No env var, no compose surface, no admin-API contract changes.