Skip to content

fix(appliance): keep the dashboard off globally-routable addresses (#1021) - #1036

Merged
VijitSingh97 merged 8 commits into
develop-v2from
fix/1021-lan-only-dashboard
Aug 16, 2026
Merged

fix(appliance): keep the dashboard off globally-routable addresses (#1021)#1036
VijitSingh97 merged 8 commits into
develop-v2from
fix/1021-lan-only-dashboard

Conversation

@VijitSingh97

@VijitSingh97 VijitSingh97 commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

Closes #1021. Stacked on #1031 (the sync merge) — merge that first.

The appliance built its Caddy site list from every address hostname -I reports. On any network passing IPv6 through, a SLAAC/DHCPv6 global unicast address landed there exactly like a LAN one, and render_derived rebuilds the list every boot — so the control panel was republished on a globally-routable address on every restart, with nothing but the operator's router between it and the internet.

Confirmed live on the physical bench, not just from source: the generated Caddyfile carried https://2001:db8::1 and the dashboard answered on it.

Why this needs two changes

Filtering the site list is necessary and not sufficient. Caddy runs host-networked and opens one wildcard listenerss -lnt on the appliance showed *:443, not per-address sockets — and it matches on Host content, never on which interface the connection arrived over. Trimming the list alone leaves the socket open on the public address; a client only has to send a Host header naming an address that is still listed.

bind is what actually closes it.

  • Skip public addresses via the existing is_public_ip (already classifies 2000::/3 and the v4 private ranges — it was only used for an unenforced warning), unless the new dashboard.expose_public_ip opts back in.
  • Emit bind with the surviving literal addresses plus loopback. Literal only: a name would be resolved by Caddy, and the mDNS name resolves to every address the box holds — including the one being excluded.
  • The onion vhost gets its own bind; a second site with no bind re-opens a wildcard on :80.

Verified on the physical appliance, before → after

Before After
Listeners *:443, *:80 192.168.1.10, 10.89.0.1, 172.28.0.1, fd1c:… ULA, 127.0.0.1, ::1no global-v6 socket
LAN, no creds 401 401
LAN, with creds 200 200
mDNS 401 401
Global IPv6 served the dashboard connection refused

Nothing supported regresses

docs/configuration.md documents the off-LAN route as the onion service, explicitly "rather than forwarding a port", and the one cross-host flow (xvb.standby.source) is documented as onion-or-LAN-address only.

Tests

Eight unit tests over the real address set from that box (LAN v4, two podman bridges, a global v6, a ULA), covering both the default and the opt-in.

One of them exists because the bench caught what the tests did not: $(...) strips trailing newlines, so a newline inside the helper glued basic_auth { onto the bind line and produced a Caddyfile Caddy cannot parse — a config that would have taken the dashboard down. The shape is now pinned so it cannot come back. That gap (nothing validates the generated Caddyfile against real Caddy at any tier) is worth closing separately.

make lint 0 errors · tests/stack 2342 passed, 0 failed.

dashboard.expose_public_ip is added to config.reference.json — the reference must stay a complete superset of every path pithead reads, or the control gate false-rejects.

VijitSingh97 and others added 6 commits August 16, 2026 10:04
Routine sync, 32 commits. Five conflicts, all resolved against the code rather than by
preferring a side:

- pithead / control_upgrade: UNION. The appliance refusal (v2) and the cosign precondition
  (#1023, develop) are independent preconditions and both belong. Appliance is checked FIRST:
  an appliance cannot take a tarball upgrade whatever the host holds, so that is the
  informative answer.
- docker-compose.yml / caddy: UNION. v2 added the appliance TLS mount, develop added the
  config-loaded healthcheck (#904).
- Makefile / test-patch-coverage: took develop. #1000 replaces the bare diff-cover call with
  scripts/patch-coverage.sh, which closes the vacuous pass where diff-cover exits 0 on
  "No lines with coverage information".
- web/server.py: UNION. v2 added the backup handlers (#908), develop added
  _RECORDABLE_WORKER_STATUSES and the change_type parameter (#1014). Verified no duplicate
  definition results and that the new parameter defaults, so existing 4-arg callers still work.
- docs/dev/testing-strategy.md: kept develop's new #972 row AND v2's wording of the
  double-outage row. The wording was decided by the code, not by branch preference:
  data_service readmits on monerod alone and test_readmit_ignores_tari_state_entirely pins it.

Gates on the merge result: make lint 0 errors (docs voice + operator strings clean),
stack 2333/0, dashboard 1818/0 at 96.78% coverage, frontend 375/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…1021)

The appliance built its Caddy site list from every address `hostname -I` reports. On any network
that passes IPv6 through, a SLAAC/DHCPv6 global unicast address landed there exactly like a LAN
one — and render_derived rebuilds it every boot, so the control panel was republished on a
globally-routable address on every restart, with nothing but the operator's router between it and
the internet. Confirmed live on the physical bench: the generated Caddyfile carried
`https://2605:59c8:...` and the dashboard answered on it.

Two changes, because the obvious one is not a boundary.

Filtering the site list is necessary but NOT sufficient. Caddy runs host-networked and opens ONE
WILDCARD listener — `ss -lnt` on the appliance showed `*:443`, not per-address sockets — and it
matches on Host content, never on which interface a connection arrived over. So trimming the list
alone leaves the socket open on the public address; a client only has to send a Host header naming
an address that IS still listed. `bind` is what actually closes it.

So: skip public addresses (via the existing is_public_ip, which already classifies 2000::/3 and
the v4 private ranges) unless the new dashboard.expose_public_ip opts back in, AND emit a `bind`
carrying the surviving literal addresses plus loopback. Literal addresses only — a NAME would be
resolved by Caddy, and the mDNS name resolves to every address the box holds, including the one
being excluded. The onion vhost gets its own bind for the same reason: a second site with no bind
re-opens a wildcard on :80.

Nothing supported regresses. The documented way to reach the dashboard off-LAN is the onion
service, explicitly "rather than forwarding a port"; the one cross-host flow (xvb.standby.source)
is documented as onion or LAN address only.

Verified on the physical appliance, before and after:
  listeners  *:443 and *:80              ->  192.168.1.202, 10.89.0.1, 172.28.0.1, fd1c:… ULA,
                                             127.0.0.1, ::1 — no global-v6 socket at all
  LAN no creds / creds / mDNS   401/200/401  ->  401/200/401 (unchanged)
  global IPv6                   served       ->  connection refused

Eight unit tests over the real address set from that box. One of them exists because the bench
caught what the tests did not: `$(...)` strips trailing newlines, so a newline inside the helper
glued `basic_auth {` onto the bind line and produced a Caddyfile Caddy cannot parse. The shape is
now pinned so that cannot come back.

dashboard.expose_public_ip is added to config.reference.json — the reference must stay a complete
superset of every path pithead reads or the control gate false-rejects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…or (#1021)

A new config.reference.json leaf with no LOGICAL_GROUPS prefix falls into "Other" in the config
editor, and the frontend suite pins exactly that: "every config.reference.json leaf path resolves
to a REAL logical group, not Other". Adding dashboard.expose_public_ip without this broke it.

Caught by running the frontend suite on the integration branch — the #1021 commit was gated on
`make lint` + tests/stack only, which do not cover the config editor's grouping. That is the gap,
not the key: any config addition needs the frontend suite too.

It belongs in "Dashboard & access" beside dashboard.host, which is the setting it qualifies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…vhost to it (#1021)

Three defects in the first cut of this fix, all found by adversarial review before merge. Two
would have shipped the bug the fix exists to close, or a new outage.

1. The bind was DERIVED FROM the site list and gated on the same condition as the site filter
   (is_appliance && dashboard.host unset). An operator who PINS dashboard.host — documented and
   supported, and the most deliberately-configured boxes there are — got a single-host site list
   and NO bind at all: the wildcard listener, and the entire exposure, back again. Caddy matches
   on Host content, never on arrival interface, so a client on the global address only had to send
   `Host: <the pinned LAN address>`. The bind now comes from the BOX's own addresses via
   `hostname -I` + is_public_ip, independent of the site list, because the two answer different
   questions: which Host values Caddy matches, versus which sockets it opens. The original tests
   never set DASHBOARD_HOST, which is why they passed.

2. Loopback was documented as "always added" but was appended inside the guarded path, so a box
   reporting no usable non-public address returned before reaching it — no bind at all, silently
   back to a wildcard. It is appended outside the loop now.

3. The onion vhost's bind was unconditional while the LAN vhost's was not. A site block with no
   bind asks Caddy for a WILDCARD listener, so the two must agree: with dashboard.secure:false and
   the onion enabled — documented, and explicitly exempted from the insecure-transport warning —
   the file would carry a wildcard :80 and a specific NETWORK_PREFIX.1:80, both claiming the port.
   Caddy fails to start, taking the dashboard AND the onion down together. The onion now binds
   exactly when the LAN vhost does.

Five new cases cover precisely what was missed: a pinned dashboard.host still binds and still
excludes the global address; a box holding only a public address still binds loopback; and the
insecure+onion combination binds in both blocks or neither, on appliance and DIY.

make lint 0 errors · tests/stack 2347 passed, 0 failed · frontend 375 passed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A config key shipped without documentation. The standing bar is that a change lands with its
docs; this one added dashboard.expose_public_ip to config.reference.json and said nothing about
it anywhere an operator would look.

Names what the default protects against and why the onion service is the supported way in, so
nobody flips it to true expecting it to be the normal remote-access route.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
VijitSingh97 and others added 2 commits August 16, 2026 12:53
…undle signing trap

Updating a bench entirely over SSH is proven on the physical appliance — bundle copied, installed,
rebooted onto the new slot, /data and its synced chain untouched. That was not written down
anywhere, so the next person would reach for a USB stick.

The trap that costs the time is signing: mkbundle.sh --dev mints a NEW throwaway chain every run,
so a --dev bundle only installs on the machine built from that same run. Anywhere else RAUC
refuses with 'self-signed certificate', which reads like a broken bundle rather than the wrong
key. Documents naming the original chain instead, and checking the fingerprint against the
target's keyring before copying half a gigabyte to it.

Repeats the variant-flip warning at the point of use, since --yes skips the guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@VijitSingh97
VijitSingh97 merged commit 0b3a1d4 into develop-v2 Aug 16, 2026
16 checks passed
@VijitSingh97
VijitSingh97 deleted the fix/1021-lan-only-dashboard branch August 16, 2026 18:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant