From e0b1098ba38ac76e2f380c589e4c4539c6b8c331 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sun, 16 Aug 2026 11:03:30 -0500 Subject: [PATCH 1/6] fix(appliance): keep the dashboard off globally-routable addresses (#1021) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- config.reference.json | 1 + pithead | 49 +++++++++++++++++++++++++++++++++++++ tests/stack/run.sh | 56 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+) diff --git a/config.reference.json b/config.reference.json index 39c6769d..ca0f474d 100644 --- a/config.reference.json +++ b/config.reference.json @@ -74,6 +74,7 @@ "dashboard": { "secure": true, "host": "auto", + "expose_public_ip": false, "port": "auto", "timezone": "auto", "data_dir": "auto", diff --git a/pithead b/pithead index 3b6085c0..98cbe672 100755 --- a/pithead +++ b/pithead @@ -5550,6 +5550,10 @@ parse_and_validate_config() { fi # Ensure a strict true/false string is returned, defaulting to true DASHBOARD_SECURE=$(jq -r 'if .dashboard.secure != null then .dashboard.secure | tostring else "true" end' "$CONFIG_FILE") + # Deliberate opt-in to serving the dashboard on a globally-routable address. Default false: + # the appliance auto-publishes every address it holds, and on any network passing IPv6 through + # that silently included a public one. The supported off-LAN route is the onion service. + DASHBOARD_EXPOSE_PUBLIC_IP=$(config_bool '.dashboard.expose_public_ip' false) # Timezone for the dashboard's timestamps/charts. "auto"/empty -> the host's timezone # (auto-detected; falls back to Etc/UTC). Set an IANA name (e.g. America/Chicago) to # override. Rendered into .env as DASHBOARD_TZ. @@ -6786,6 +6790,7 @@ TARI_GRPC_ADDRESS=$tari_grpc_addr TARI_GRPC_BIND=$tari_grpc_bind COMPOSE_PROFILES=$profiles DASHBOARD_SECURE=$DASHBOARD_SECURE +DASHBOARD_EXPOSE_PUBLIC_IP=$DASHBOARD_EXPOSE_PUBLIC_IP DASHBOARD_ONION_ENABLED=$DASHBOARD_ONION_ENABLED DASHBOARD_ONION_CLIENT_AUTH=$DASHBOARD_ONION_CLIENT_AUTH DASHBOARD_TZ=$DASHBOARD_TZ @@ -6916,6 +6921,17 @@ generate_caddyfile() { local extra_ip for extra_ip in $(hostname -I 2>/dev/null) localhost; do case " $site_hosts " in *" $extra_ip "*) continue ;; esac + # `hostname -I` lists EVERY address on every interface, so on any network whose router + # passes IPv6 through, a SLAAC/DHCPv6 global unicast address lands here exactly like a + # LAN one — and this list is rebuilt every boot by render_derived. That published the + # control panel on a globally-routable address, with nothing but the operator's router + # between it and the internet; the product must not depend on that. The documented way + # to reach the dashboard off-LAN is the onion service, never a routable address, so + # nothing supported regresses. Opt back in with dashboard.expose_public_ip if a + # deployment really does want it. + if [ "${DASHBOARD_EXPOSE_PUBLIC_IP:-false}" != "true" ] && is_public_ip "$extra_ip"; then + continue + fi site_hosts="$site_hosts $extra_ip" done fi @@ -6926,6 +6942,36 @@ generate_caddyfile() { done printf '%s' "$out" } + # Trimming the address list is necessary but is NOT a boundary on its own. Caddy runs with + # network_mode: host and opens ONE WILDCARD listener (verified on the bench: `ss -lnt` shows + # `*:443`, not per-address sockets), so a client that reaches the box on a global address still + # completes the connection — it only has to send a Host header naming an address that IS in the + # list, and Caddy matches on content, never on which interface the connection arrived over. + # `bind` is what actually closes the socket: Caddy then listens on these addresses only, so the + # global one is never accepted at all. + # + # LITERAL addresses only. A name would be resolved by Caddy at startup, and the appliance's + # mDNS name resolves to every address it has — including the one being excluded, which would + # re-open exactly what this closes. Loopback is always added: the host-networked dashboard and + # Caddy's own admin healthcheck both arrive that way. + _bind_line() { + local h out="" + [ "${DASHBOARD_EXPOSE_PUBLIC_IP:-false}" == "true" ] && return 0 + is_appliance && [ -z "${DASHBOARD_HOST:-}" ] || return 0 + for h in $site_hosts; do + case "$h" in + *:*) ;; # IPv6 literal + *[!0-9.]*) continue ;; # a name, not an address + esac + out="${out:+$out }$h" + done + [ -n "$out" ] || return 0 + # NO trailing newline: this is consumed as $(_bind_line) on its own heredoc line, and + # command substitution strips trailing newlines anyway — emitting one here produced + # `bind ... ::1 basic_auth {` on a single line, which Caddy will not parse. Same + # convention as $auth above. + printf ' bind %s 127.0.0.1 ::1' "$out" + } : >"Caddyfile" [ -n "$global_block" ] && printf '%s' "$global_block" >>"Caddyfile" @@ -6958,6 +7004,7 @@ generate_caddyfile() { cat <>"Caddyfile" $(_site_addresses https) { $tls_line +$(_bind_line) $auth $logblk reverse_proxy 127.0.0.1:8000 { @@ -6969,6 +7016,7 @@ EOF log "Generating Caddyfile for HTTP ($site_hosts$port_suffix)$([ -n "$auth" ] && echo ' with login')..." cat <>"Caddyfile" $(_site_addresses http) { +$(_bind_line) $auth $logblk reverse_proxy 127.0.0.1:8000 { @@ -6991,6 +7039,7 @@ EOF cat <>"Caddyfile" http://${NETWORK_PREFIX}.1 { + bind ${NETWORK_PREFIX}.1 $auth $logblk reverse_proxy 127.0.0.1:8000 { diff --git a/tests/stack/run.sh b/tests/stack/run.sh index 86ff6d33..0f815b5d 100755 --- a/tests/stack/run.sh +++ b/tests/stack/run.sh @@ -1556,6 +1556,62 @@ case "$caddy_http" in *) ok "caddyfile insecure has no TLS" ;; esac +echo "== unit: generate_caddyfile never publishes or binds a globally-routable address ==" +# The appliance auto-publishes every address `hostname -I` reports, so on any network passing IPv6 +# through, a GLOBAL unicast address was silently added — the control panel reachable from the open +# internet with nothing but the operator's router in the way. Filtering the SITE LIST is necessary +# and NOT sufficient: Caddy runs host-networked and opens ONE WILDCARD listener (`*:443`, observed +# on the bench), and it matches on Host content, never on which interface a connection arrived on +# — so a client reaching the box on the global address only has to send a Host header naming an +# address that IS listed. `bind` is the actual boundary. Addresses below are the real set from the +# physical appliance: LAN v4, two podman bridge gateways, a GLOBAL v6 (2605:) and a ULA (fd1c:). +_caddy_appliance() { # $1 = value for DASHBOARD_EXPOSE_PUBLIC_IP + # shellcheck disable=SC1090 # STACK path is dynamic by design + cd "$SANDBOX" && source "$STACK" 2>/dev/null + set +e + is_appliance() { return 0; } + appliance_tls_dir() { printf '%s' "$SANDBOX/notls"; } + appliance_mint_cert() { return 1; } + hostname() { printf '192.168.1.202 10.89.0.1 172.28.0.1 2605:59c8:cd7:ba08::1 fd1c:1d5:225c:8::1\n'; } + DASHBOARD_SECURE=true HOST_IP=pithead.local DASHBOARD_AUTH_HASH_B64="" \ + DASHBOARD_EXPOSE_PUBLIC_IP="$1" generate_caddyfile >/dev/null 2>&1 + cat Caddyfile +} +# shellcheck disable=SC1090 # STACK path is dynamic by design +caddy_default="$(_caddy_appliance false)" +case "$caddy_default" in +*2605:*) bad "the global v6 is not published as a site" "2605: appears in the Caddyfile" ;; +*) ok "the global v6 is not published as a site" ;; +esac +assert_contains "the LAN address is still published" "$caddy_default" "192.168.1.202" +assert_contains "the ULA is still published — private scope, not routable" "$caddy_default" "fd1c:1d5:225c:8::1" +assert_contains "a bind line closes the wildcard listener" "$caddy_default" " bind " +assert_contains "bind keeps loopback for the host-networked dashboard" "$caddy_default" "127.0.0.1 ::1" +# The bind line is the boundary — it specifically must not carry the global address. +bindline=$(printf '%s' "$caddy_default" | grep '^ bind ') +case "$bindline" in +*2605:*) bad "the bind line excludes the global v6" "global address present in: $bindline" ;; +*) ok "the bind line excludes the global v6" ;; +esac +# The bind directive must stand ALONE on its line. `$(...)` strips trailing newlines, so emitting +# one inside the helper silently glued the next directive on: `bind ... ::1 basic_auth {`, +# which Caddy will not parse — a config that would have taken the dashboard down. Caught on the +# bench, not here, so pin the shape: nothing may follow the last bound address. +case "$bindline" in +*"::1") ok "the bind directive stands alone on its line" ;; +*) bad "the bind directive stands alone on its line" "another directive was glued on: $bindline" ;; +esac +# Opt-in restores the old behaviour for a deployment that genuinely wants it. +# shellcheck disable=SC1090 # STACK path is dynamic by design +caddy_optin="$(_caddy_appliance true)" +assert_contains "the opt-in publishes the global v6 again" "$caddy_optin" "2605:59c8:cd7:ba08::1" +case "$caddy_optin" in +*" bind "*) bad "the opt-in leaves the listener open" "a bind line was still emitted" ;; +*) ok "the opt-in leaves the listener open" ;; +esac +unset -f _caddy_appliance +unset caddy_default caddy_optin bindline + echo "== unit: generate_caddyfile custom port (#740) ==" # A custom HOST_PORT moves the LAN vhost off the scheme default so a co-hosted reverse proxy keeps # 80/443. In HTTPS mode it also emits the global `auto_https disable_redirects` so nothing holds :80. From 6afb7506749b8d2cc3c980982011b64fc004ddcc Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sun, 16 Aug 2026 11:28:54 -0500 Subject: [PATCH 2/6] fix(dashboard): group the new expose_public_ip key in the config editor (#1021) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- build/dashboard/mining_dashboard/web/static/configlogic.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/build/dashboard/mining_dashboard/web/static/configlogic.mjs b/build/dashboard/mining_dashboard/web/static/configlogic.mjs index 29554282..638c3def 100644 --- a/build/dashboard/mining_dashboard/web/static/configlogic.mjs +++ b/build/dashboard/mining_dashboard/web/static/configlogic.mjs @@ -142,6 +142,7 @@ export const LOGICAL_GROUPS = [ prefixes: [ "dashboard.secure", "dashboard.host", + "dashboard.expose_public_ip", "dashboard.port", "dashboard.timezone", "dashboard.auth", From 1a33144cda694999f17c72c8085c8b6b88c21975 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sun, 16 Aug 2026 11:58:44 -0500 Subject: [PATCH 3/6] fix(appliance): bind from the box's own addresses, and tie the onion vhost to it (#1021) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- pithead | 52 +++++++++++++++++++++--------- tests/stack/run.sh | 80 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 16 deletions(-) diff --git a/pithead b/pithead index 98cbe672..ea945133 100755 --- a/pithead +++ b/pithead @@ -6954,23 +6954,45 @@ generate_caddyfile() { # mDNS name resolves to every address it has — including the one being excluded, which would # re-open exactly what this closes. Loopback is always added: the host-networked dashboard and # Caddy's own admin healthcheck both arrive that way. - _bind_line() { - local h out="" - [ "${DASHBOARD_EXPOSE_PUBLIC_IP:-false}" == "true" ] && return 0 - is_appliance && [ -z "${DASHBOARD_HOST:-}" ] || return 0 - for h in $site_hosts; do - case "$h" in + # Built from the BOX's own addresses, never from $site_hosts. The two are different questions: + # site_hosts is which Host values Caddy vhost-matches, this is which sockets it opens. Deriving + # the bind from site_hosts tied it to the auto-expansion, so an operator who pinned + # dashboard.host — a documented, supported choice — got a single-host site list and NO bind at + # all, which is the wildcard listener and the whole exposure, back again for exactly the + # operators who configured the box most deliberately. + local bind_addrs="" _bh + if is_appliance && [ "${DASHBOARD_EXPOSE_PUBLIC_IP:-false}" != "true" ]; then + for _bh in $(hostname -I 2>/dev/null); do + is_public_ip "$_bh" && continue + case "$_bh" in *:*) ;; # IPv6 literal - *[!0-9.]*) continue ;; # a name, not an address + *[!0-9.]*) continue ;; # not an address literal esac - out="${out:+$out }$h" + bind_addrs="${bind_addrs:+$bind_addrs }$_bh" done - [ -n "$out" ] || return 0 - # NO trailing newline: this is consumed as $(_bind_line) on its own heredoc line, and - # command substitution strips trailing newlines anyway — emitting one here produced - # `bind ... ::1 basic_auth {` on a single line, which Caddy will not parse. Same - # convention as $auth above. - printf ' bind %s 127.0.0.1 ::1' "$out" + # Loopback unconditionally, appended OUTSIDE the loop so it survives a box that reports no + # usable non-public address at render time: the host-networked dashboard and Caddy's own + # admin healthcheck both arrive this way, and a bind that dropped them would be worse than + # no bind at all. + bind_addrs="${bind_addrs:+$bind_addrs }127.0.0.1 ::1" + fi + # NO trailing newline: consumed as $(_bind_line) on its own heredoc line, and command + # substitution strips trailing newlines anyway — emitting one here produced + # `bind ... ::1 basic_auth {` on a single line, which Caddy will not parse. Same convention + # as $auth above. Empty when binding is off, which collapses to a blank line, like $auth. + _bind_line() { + [ -n "$bind_addrs" ] || return 0 + printf ' bind %s' "$bind_addrs" + } + # The onion vhost binds ONLY when the LAN vhost does. A site block with no bind asks Caddy for + # a wildcard listener, so the two must agree: mixing them puts a wildcard `:80` and a specific + # `NETWORK_PREFIX.1:80` in the same file, both claim the same port, and Caddy fails to start — + # which takes the dashboard AND the onion down together. That combination is reachable today + # (dashboard.secure:false with the onion enabled is documented and explicitly exempted from the + # insecure-transport warning), so this is tied deliberately rather than left to chance. + _onion_bind_line() { + [ -n "$bind_addrs" ] || return 0 + printf ' bind %s' "${NETWORK_PREFIX}.1" } : >"Caddyfile" @@ -7039,7 +7061,7 @@ EOF cat <>"Caddyfile" http://${NETWORK_PREFIX}.1 { - bind ${NETWORK_PREFIX}.1 +$(_onion_bind_line) $auth $logblk reverse_proxy 127.0.0.1:8000 { diff --git a/tests/stack/run.sh b/tests/stack/run.sh index 0f815b5d..00fea33f 100755 --- a/tests/stack/run.sh +++ b/tests/stack/run.sh @@ -1609,8 +1609,86 @@ case "$caddy_optin" in *" bind "*) bad "the opt-in leaves the listener open" "a bind line was still emitted" ;; *) ok "the opt-in leaves the listener open" ;; esac +# An operator who PINS dashboard.host is the most deliberately-configured box there is, and the +# first cut of this fix left exactly those boxes wide open: the bind was derived from the +# auto-expanded site list, so pinning the host produced a single-host site list and NO bind — the +# wildcard listener, and the whole exposure, back again. The bind is built from the BOX's +# addresses now, never from the site list, because they answer different questions: which Host +# values Caddy matches, versus which sockets it opens. +# shellcheck disable=SC1090 # STACK path is dynamic by design +caddy_pinned="$( + # shellcheck disable=SC1090 + cd "$SANDBOX" && source "$STACK" 2>/dev/null + set +e + is_appliance() { return 0; } + appliance_tls_dir() { printf '%s' "$SANDBOX/notls"; } + appliance_mint_cert() { return 1; } + hostname() { printf '192.168.1.202 2605:59c8:cd7:ba08::1 fd1c:1d5:225c:8::1\n'; } + DASHBOARD_SECURE=true HOST_IP=192.168.1.202 DASHBOARD_HOST=192.168.1.202 \ + DASHBOARD_AUTH_HASH_B64="" generate_caddyfile >/dev/null 2>&1 + cat Caddyfile +)" +assert_contains "a pinned dashboard.host still gets a bind" "$caddy_pinned" " bind " +case "$(printf '%s' "$caddy_pinned" | grep '^ bind ')" in +*2605:*) bad "a pinned host does not reopen the global v6" "global address is bound" ;; +*) ok "a pinned host does not reopen the global v6" ;; +esac + +# Loopback is appended outside the address loop, so a box reporting no usable non-public address +# still binds something reachable rather than silently falling back to a wildcard. +# shellcheck disable=SC1090 # STACK path is dynamic by design +caddy_noaddr="$( + # shellcheck disable=SC1090 + cd "$SANDBOX" && source "$STACK" 2>/dev/null + set +e + is_appliance() { return 0; } + appliance_tls_dir() { printf '%s' "$SANDBOX/notls"; } + appliance_mint_cert() { return 1; } + hostname() { printf '2605:59c8:cd7:ba08::1\n'; } # ONLY a public address + DASHBOARD_SECURE=true HOST_IP=pithead.local DASHBOARD_AUTH_HASH_B64="" \ + generate_caddyfile >/dev/null 2>&1 + cat Caddyfile +)" +assert_contains "a box with only a public address still binds loopback" "$caddy_noaddr" " bind 127.0.0.1 ::1" + +# The onion vhost must bind exactly when the LAN vhost does. A site block with no bind asks for a +# WILDCARD listener, so mixing the two puts a wildcard :80 and a specific NETWORK_PREFIX.1:80 in +# one file, both claiming the port — Caddy fails to start and takes the dashboard and the onion +# down together. dashboard.secure:false with the onion enabled is documented and exempted from the +# insecure-transport warning, so this combination is reachable today. +# shellcheck disable=SC1090 # STACK path is dynamic by design +caddy_onion="$( + # shellcheck disable=SC1090 + cd "$SANDBOX" && source "$STACK" 2>/dev/null + set +e + is_appliance() { return 0; } + hostname() { printf '192.168.1.202 2605:59c8:cd7:ba08::1\n'; } + DASHBOARD_SECURE=false HOST_IP=pithead.local NETWORK_PREFIX=172.28.0 \ + DASHBOARD_ONION_ENABLED=true DASHBOARD_AUTH_USER=admin \ + DASHBOARD_AUTH_HASH_B64="$(printf 'x' | openssl base64 -A)" \ + generate_caddyfile >/dev/null 2>&1 + cat Caddyfile +)" +assert_eq "insecure+onion: both vhosts bind, never one wildcard and one specific" \ + "$(printf '%s' "$caddy_onion" | grep -c '^ bind ')" "2" +# And with binding off, NEITHER may bind — the mirror of the case above. +# shellcheck disable=SC1090 # STACK path is dynamic by design +caddy_onion_off="$( + # shellcheck disable=SC1090 + cd "$SANDBOX" && source "$STACK" 2>/dev/null + set +e + is_appliance() { return 1; } # DIY: no binding at all + hostname() { printf '192.168.1.202\n'; } + DASHBOARD_SECURE=false HOST_IP=box.lan NETWORK_PREFIX=172.28.0 \ + DASHBOARD_ONION_ENABLED=true DASHBOARD_AUTH_USER=admin \ + DASHBOARD_AUTH_HASH_B64="$(printf 'x' | openssl base64 -A)" \ + generate_caddyfile >/dev/null 2>&1 + cat Caddyfile +)" +assert_eq "DIY insecure+onion: neither vhost binds — no wildcard/specific clash" \ + "$(printf '%s' "$caddy_onion_off" | grep -c '^ bind ')" "0" unset -f _caddy_appliance -unset caddy_default caddy_optin bindline +unset caddy_default caddy_optin bindline caddy_pinned caddy_noaddr caddy_onion caddy_onion_off echo "== unit: generate_caddyfile custom port (#740) ==" # A custom HOST_PORT moves the LAN vhost off the scheme default so a co-hosted reverse proxy keeps From d1eb16ae7963e91feb3362f34861293b9a940c3c Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sun, 16 Aug 2026 12:45:36 -0500 Subject: [PATCH 4/6] docs(config): document dashboard.expose_public_ip (#1021) 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 --- docs/configuration.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/configuration.md b/docs/configuration.md index 51223787..308dbefd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -155,6 +155,7 @@ control channel will commit, are unaffected either way. | `tor.auto_heal` | `false` _(off)_ | Privacy-relevant, default off. Tor can bootstrap fully and then sit on a **failing guard**: circuits time out, so everything that exits Tor to the clearnet (Healthchecks pings, the Telegram bot, XvB stats) breaks at once while mining keeps working (#424). `true` lets the dashboard probe Tor clearnet egress every 5 minutes and restart the tor container once egress has been broken for 15 minutes, so Tor reselects guards — bounded to 3 restarts per outage, 30 minutes apart, each logged; if egress stays broken (Tor network overload) it stops restarting and keeps warning. Off by default because each restart drops **every** Tor circuit, mining onions included (they rebuild in minutes). The manual equivalent is `./pithead restart tor`. See [Operations › Troubleshooting](operations.md#troubleshooting). | | `dashboard.secure` | `true` | `true` serves the dashboard over HTTPS (Caddy `tls internal`); `false` uses plain HTTP. | | `dashboard.host` | `auto` | Hostname you use to reach the dashboard. `auto` = this machine's hostname. | +| `dashboard.expose_public_ip` | `false` | Appliance only. The appliance serves the dashboard on the addresses it holds, and on a network that passes IPv6 through, one of those is a globally-routable address — which would put the control panel on the open internet, gated only by your router. By default those addresses are left out of the site list *and* out of Caddy's listener, so the dashboard answers on LAN, ULA and loopback addresses only. Reach it from outside over the onion service instead. Set `true` only if you deliberately want it published on a routable address and have your own protection in front of it. | | `dashboard.port` | `auto` | Host port Caddy binds the dashboard on. `auto` = the scheme default (443 with `dashboard.secure: true`, 80 without). Set a number (e.g. `8443`) to move it — for a host already running another reverse proxy on 80/443, so that proxy can front the stack. In HTTPS mode a custom port also drops Caddy's automatic HTTP→HTTPS redirect (which would otherwise hold port 80), so the fronting proxy owns any redirect. See [Co-hosting on a shared server](#co-hosting-on-a-shared-server). | | `dashboard.auth.username` | `admin` | Login name for the dashboard when a password is set (see below). Letters, digits, and `. _ @ -`, 1–64 chars. Ignored while `dashboard.auth.password` is empty. | | `dashboard.auth.password` | `""` _(off)_ | Optional password to open the dashboard. Turns on a Caddy [HTTP basic-auth](https://caddyserver.com/docs/caddyfile/directives/basic_auth) prompt in front of every page. `""` (default) = no login, anyone who can reach the dashboard sees it (fine for a private LAN appliance). Any 8–128-character string (no double-quotes) turns the prompt on. The plaintext lives only in your owner-only `config.json`; pithead bcrypt-hashes it with the pinned Caddy image and stores only the hash in `.env`, so the password itself is never persisted in rendered state. Basic-auth credentials travel in cleartext over HTTP, so keep `dashboard.secure: true` (the default); pithead warns if you set a password with `secure: false`. See [Exposing the dashboard safely](#exposing-the-dashboard-safely). | From ec11bda06e47eecace14ea635c41264dd83ad84c Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sun, 16 Aug 2026 12:49:12 -0500 Subject: [PATCH 5/6] docs(config): markdownlint emphasis style Co-Authored-By: Claude Opus 5 --- docs/configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index 308dbefd..288607e7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -155,7 +155,7 @@ control channel will commit, are unaffected either way. | `tor.auto_heal` | `false` _(off)_ | Privacy-relevant, default off. Tor can bootstrap fully and then sit on a **failing guard**: circuits time out, so everything that exits Tor to the clearnet (Healthchecks pings, the Telegram bot, XvB stats) breaks at once while mining keeps working (#424). `true` lets the dashboard probe Tor clearnet egress every 5 minutes and restart the tor container once egress has been broken for 15 minutes, so Tor reselects guards — bounded to 3 restarts per outage, 30 minutes apart, each logged; if egress stays broken (Tor network overload) it stops restarting and keeps warning. Off by default because each restart drops **every** Tor circuit, mining onions included (they rebuild in minutes). The manual equivalent is `./pithead restart tor`. See [Operations › Troubleshooting](operations.md#troubleshooting). | | `dashboard.secure` | `true` | `true` serves the dashboard over HTTPS (Caddy `tls internal`); `false` uses plain HTTP. | | `dashboard.host` | `auto` | Hostname you use to reach the dashboard. `auto` = this machine's hostname. | -| `dashboard.expose_public_ip` | `false` | Appliance only. The appliance serves the dashboard on the addresses it holds, and on a network that passes IPv6 through, one of those is a globally-routable address — which would put the control panel on the open internet, gated only by your router. By default those addresses are left out of the site list *and* out of Caddy's listener, so the dashboard answers on LAN, ULA and loopback addresses only. Reach it from outside over the onion service instead. Set `true` only if you deliberately want it published on a routable address and have your own protection in front of it. | +| `dashboard.expose_public_ip` | `false` | Appliance only. The appliance serves the dashboard on the addresses it holds, and on a network that passes IPv6 through, one of those is a globally-routable address — which would put the control panel on the open internet, gated only by your router. By default those addresses are left out of the site list _and_ out of Caddy's listener, so the dashboard answers on LAN, ULA and loopback addresses only. Reach it from outside over the onion service instead. Set `true` only if you deliberately want it published on a routable address and have your own protection in front of it. | | `dashboard.port` | `auto` | Host port Caddy binds the dashboard on. `auto` = the scheme default (443 with `dashboard.secure: true`, 80 without). Set a number (e.g. `8443`) to move it — for a host already running another reverse proxy on 80/443, so that proxy can front the stack. In HTTPS mode a custom port also drops Caddy's automatic HTTP→HTTPS redirect (which would otherwise hold port 80), so the fronting proxy owns any redirect. See [Co-hosting on a shared server](#co-hosting-on-a-shared-server). | | `dashboard.auth.username` | `admin` | Login name for the dashboard when a password is set (see below). Letters, digits, and `. _ @ -`, 1–64 chars. Ignored while `dashboard.auth.password` is empty. | | `dashboard.auth.password` | `""` _(off)_ | Optional password to open the dashboard. Turns on a Caddy [HTTP basic-auth](https://caddyserver.com/docs/caddyfile/directives/basic_auth) prompt in front of every page. `""` (default) = no login, anyone who can reach the dashboard sees it (fine for a private LAN appliance). Any 8–128-character string (no double-quotes) turns the prompt on. The plaintext lives only in your owner-only `config.json`; pithead bcrypt-hashes it with the pinned Caddy image and stores only the hash in `.env`, so the password itself is never persisted in rendered state. Basic-auth credentials travel in cleartext over HTTP, so keep `dashboard.secure: true` (the default); pithead warns if you set a password with `secure: false`. See [Exposing the dashboard safely](#exposing-the-dashboard-safely). | From 7165c8326c92ad2e345182e927b74f6e799ee490 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sun, 16 Aug 2026 12:53:59 -0500 Subject: [PATCH 6/6] docs(appliance): record the proven over-SSH update path and the dev-bundle signing trap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/dev/appliance-release.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/dev/appliance-release.md b/docs/dev/appliance-release.md index 9af5942a..29c648d6 100644 --- a/docs/dev/appliance-release.md +++ b/docs/dev/appliance-release.md @@ -163,6 +163,35 @@ Two build variants, chosen by one flag: The updater defaults to RAUC; an image built without it cannot take another update, and the only way to get one now is to set `PITHEAD_UPDATER` to something else on purpose. +### Updating a bench over SSH, and the signing trap that stops you + +A bench appliance can be moved to a new build entirely over SSH — copy the bundle to it and run +`pithead os-update BUNDLE`, then reboot. No USB, no console, and `/data` is never touched, so a +synced chain survives. This is proven on the physical bench: a full A/B update installed and the +machine came back on the new slot with its chain intact. + +The trap is signing. **`mkbundle.sh --dev` generates a fresh throwaway chain on every run**, so a +`--dev` bundle only installs on a machine whose keyring came from that same run. Point it at any +other bench and RAUC refuses with `signature verification failed: Verify error: self-signed +certificate`. To update a bench you built earlier, name that build's chain instead of generating +a new one: + +```sh +PITHEAD_RAUC_CERT=/os/rauc/certs/cert.pem \ +PITHEAD_RAUC_KEY=/os/rauc/certs/key.pem \ + os/rauc/mkbundle.sh # no --dev: naming the key IS the signal +``` + +Confirm before copying anything, by comparing fingerprints — the target's trusted chain is at +`/etc/rauc/keyring.pem`: + +```sh +openssl x509 -in /etc/rauc/keyring.pem -noout -fingerprint -sha256 +``` + +Check the variant first as well: an update from a debug build to a release bundle removes the SSH +channel you are driving it over, and `--yes` skips the guard that would have asked. + The rootfs Dockerfile deliberately keeps its `apt-get update` layer cached across later install steps (layer economy); on a warm builder cache that layer can outlive a mirror rotating a package, and the install then 404s on a package the stale index still thinks exists. Rerun with