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", 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/docs/configuration.md b/docs/configuration.md index 4e08dbb2..60163ac4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -158,6 +158,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). | 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 diff --git a/pithead b/pithead index 3553f102..f5f31499 100755 --- a/pithead +++ b/pithead @@ -5579,6 +5579,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. @@ -6815,6 +6819,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 @@ -6945,6 +6950,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 @@ -6955,6 +6971,58 @@ 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. + # 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 ;; # not an address literal + esac + bind_addrs="${bind_addrs:+$bind_addrs }$_bh" + done + # 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" [ -n "$global_block" ] && printf '%s' "$global_block" >>"Caddyfile" @@ -6987,6 +7055,7 @@ generate_caddyfile() { cat <>"Caddyfile" $(_site_addresses https) { $tls_line +$(_bind_line) $auth $logblk reverse_proxy 127.0.0.1:8000 { @@ -6998,6 +7067,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 { @@ -7020,6 +7090,7 @@ EOF cat <>"Caddyfile" http://${NETWORK_PREFIX}.1 { +$(_onion_bind_line) $auth $logblk reverse_proxy 127.0.0.1:8000 { @@ -8972,6 +9043,8 @@ control_upgrade() { # _upg_reject "this install builds from source — upgrade from the host with 'git pull' then './pithead upgrade'." return 0 fi + # Ordered BEFORE the cosign precondition on purpose: an appliance cannot take a tarball + # upgrade at all, whatever the host holds, so the appliance answer is the informative one. if is_appliance; then _upg_reject "this machine is a Pithead OS appliance — it updates through signed OS images, not release tarballs, and a tarball upgrade would silently revert at the next reboot. The dashboard OS-update flow is tracked work; nothing was changed." return 0 diff --git a/tests/stack/run.sh b/tests/stack/run.sh index cfcb2347..b4e8cae3 100755 --- a/tests/stack/run.sh +++ b/tests/stack/run.sh @@ -1556,6 +1556,140 @@ 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 +# 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 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 # 80/443. In HTTPS mode it also emits the global `auto_https disable_redirects` so nothing holds :80.