Skip to content

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_REQUEST settings
  • 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)

ALTER TABLE target_groups
    ADD COLUMN preserve_host INTEGER NOT NULL DEFAULT 0;

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 produces headers.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 bool on TargetGroup
  • backend/internal/db/target_groups.go -- column list, INSERT, UPDATE, scan
  • backend/internal/api/target_groups.go -- request struct field, defaulted false
  • backend/internal/caddycfg/caddycfg.go -- emit headers block when set; new types reverseProxyHeaders + headerOps
  • backend/internal/caddycfg/transport_test.go -- 3 new tests + extracted helper
  • backend/internal/db/migrate_test.go -- rollback path extended; new helper
  • frontend/src/api/client.ts -- preserve_host on TargetGroup + TargetGroupInput
  • frontend/src/components/targetGroupFormValue.ts -- default false
  • frontend/src/components/TargetGroupForm.tsx -- new checkbox + tooltip
  • frontend/src/pages/TargetGroupDetail.tsx -- propagate field on Edit-modal open
  • docs/operations/troubleshooting.md -- new entry "Backend works on direct access but breaks behind argos"

Upgrade

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

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.