Skip to content

v1.3.18 — LAN-only toggle per host

The v1.3.17 access-control guide listed a per-host LAN-only toggle as "Approach C: roadmap". v1.3.18 ships it. argos now has a native answer for the standard homelab pattern of "host exposed via public DNS + valid TLS but reachable only from inside the LAN / VPN".

What ships

hosts.lan_only column (migration 027)

ALTER TABLE hosts
    ADD COLUMN lan_only INTEGER NOT NULL DEFAULT 0;

Default false. Existing hosts upgrade with no behaviour change. Down migration drops the column.

Caddy gate

When lan_only=true for a host, argos emits a gate route at the front of that host's subroute:

{
  "match": [
    { "not": [
        { "client_ip": {
            "ranges": [
              "127.0.0.0/8", "::1/128",
              "10.0.0.0/8", "172.16.0.0/12",
              "192.168.0.0/16", "fc00::/7"
            ]
        }}
    ]}
  ],
  "handle": [
    { "handler": "static_response",
      "status_code": 403,
      "body": "Access denied: this host is restricted to local network\n",
      "headers": { "Content-Type": ["text/plain; charset=utf-8"] }
    }
  ],
  "terminal": true
}

Public sources hit the gate and get a 403 + the plain-text banner; LAN / VPN / loopback / ULA clients don't match the negation and fall through to the rest of the chain (rules, default reverse_proxy, etc.).

client_ip vs remote_ip -- a real implementation note

The gate uses client_ip, not remote_ip. Caddy v2.7 removed the forwarded option from remote_ip and split client-IP matching into its own matcher. The first implementation pass tried remote_ip and got past unit tests (which check JSON shape, not Caddy semantics) but silently failed at the live-stack smoke: every public XFF chain bypassed the gate because remote_ip matched the raw TCP peer (always the trusted Docker bridge / loopback in argos's deployment).

The fix is client_ip, which honours the same trusted_proxies chain Caddy already uses for access-log attribution. Tests now assert the gate uses client_ip specifically and explicitly fail if remote_ip ever leaks back in.

API

POST /api/hosts and PUT /api/hosts/{id} accept optional lan_only boolean. Default false on create; preserves the current value when omitted on update (same partial-update contract as auth_required). GET /api/hosts returns the field on every row.

UI

Edit Host modal -> new "Access" fieldset positioned before "Advanced":

[ Access ]
☐ LAN-only access (block requests from public IPs)
   Caddy returns 403 to clients outside RFC 1918 /
   loopback / ULA.

Tooltip on the checkbox names the typical use cases (admin panels exposed via DNS but private) and warns about the multi-hop trusted_proxies caveat.

Hosts list -> when lan_only=true, an amber LAN badge appears next to the domain in the table:

private-app.example.com    [LAN]  https  auto  ...  [edit]

Operators can spot private hosts at a glance.

Smoke

Verified end-to-end against a real prod stack with lan_only=true set on a test host:

LAN client (loopback, no XFF):
  curl http://<host>/                       -> HTTP 200

Public IP via X-Forwarded-For (Caddy resolves
    client_ip=8.8.8.8 because the loopback hop is in
    trusted_proxies):
  curl -H 'X-Forwarded-For: 8.8.8.8' .../   -> HTTP 403
  body: "Access denied: this host is restricted to local
  network"

After UPDATE hosts SET lan_only=0 + reconcile:
  curl -H 'X-Forwarded-For: 8.8.8.8' .../   -> HTTP 200

The client_ip matcher correctly resolves XFF when the TCP peer is in trusted_proxies, so the gate sees the real client IP rather than the Docker bridge address.

Tests

  • TestLanOnlyEmitsGateRouteFirst -- gate is the FIRST inner route, uses client_ip (NOT remote_ip), includes every expected private range, serves 403, marked terminal.
  • TestLanOnlyFalseOmitsGate -- existing hosts at default false continue to emit a single default route only, no gate prepended. Regression-locks the no-behaviour-change-on-upgrade promise.
  • TestRollbackLastMigration extended for migration 027.

Trusted_proxies caveat

The gate's client_ip matcher works against trusted_proxies-resolved client IPs. argos sets sensible defaults for the standard private ranges (v1.3.8) so an X-Forwarded-For chain coming from a private hop resolves correctly.

If argos sits behind ANOTHER reverse proxy or CDN whose egress IP is NOT in the standard private ranges (a CDN with public POP IPs, an ingress controller with a public LB address), that proxy IP gets seen as the "client" and the gate fires unexpectedly even for legitimate LAN traffic.

The fix path is to extend argos's trusted_proxies to cover the upstream egress range. Currently this requires editing defaultTrustedProxies in backend/internal/caddycfg/caddycfg.go and rebuilding. A settings-page hook for this is on the roadmap.

For the typical homelab shape (argos directly on the WAN, public DNS resolves to argos's IP) no extra config is needed -- the toggle works out of the box.

Documented in Access control -> Approach A and the new Troubleshooting -> Host with lan_only=true returns 403 from inside the LAN entry.

Files changed

  • backend/migrations/027_hosts_lan_only.up.sql (new)
  • backend/migrations/027_hosts_lan_only.down.sql (new)
  • backend/internal/models/models.go -- Host.LanOnly bool
  • backend/internal/db/hosts.go -- column list, INSERT, UPDATE, scan
  • backend/internal/api/hosts.go -- hostRequest.LanOnly, Create + Update flows
  • backend/internal/caddycfg/caddycfg.go -- buildLanOnlyGate, clientIPMatcher, match.ClientIP, privateRanges, prepend logic
  • backend/internal/caddycfg/transport_test.go -- 2 new tests
  • backend/internal/db/migrate_test.go -- rollback 027 before 026
  • frontend/src/api/client.ts -- lan_only on Host + HostInput
  • frontend/src/pages/Hosts.tsx -- form state, openEdit, onSubmit, Access fieldset, list badge
  • docs/operations/access-control.md -- reorder approaches, v1.3.18 toggle as Approach A
  • docs/operations/troubleshooting.md -- new entry + updated cross-link
  • CHANGELOG.md, docs/release-notes/v1.3.18.md, mkdocs.yml, backend/cmd/argos/main.go, frontend/package.json

Upgrade

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

Migration 027 applies automatically on first boot. Every existing host gets lan_only=false -- behaviour is identical to v1.3.17 until the operator flips a specific host via the Edit modal.

Not changed

  • v1.3.16's preserve_host (per target group), v1.3.14's transport.versions, AppSec block-mode CRS, target health badges, CLI password reset -- all untouched.
  • No env var, no compose surface, no admin API contract changes.