Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ export const LOGICAL_GROUPS = [
prefixes: [
"dashboard.secure",
"dashboard.host",
"dashboard.expose_public_ip",
"dashboard.port",
"dashboard.timezone",
"dashboard.auth",
Expand Down
1 change: 1 addition & 0 deletions config.reference.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
"dashboard": {
"secure": true,
"host": "auto",
"expose_public_ip": false,
"port": "auto",
"timezone": "auto",
"data_dir": "auto",
Expand Down
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down
29 changes: 29 additions & 0 deletions docs/dev/appliance-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<build>/os/rauc/certs/cert.pem \
PITHEAD_RAUC_KEY=<build>/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
Expand Down
73 changes: 73 additions & 0 deletions pithead
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -6987,6 +7055,7 @@ generate_caddyfile() {
cat <<EOF >>"Caddyfile"
$(_site_addresses https) {
$tls_line
$(_bind_line)
$auth
$logblk
reverse_proxy 127.0.0.1:8000 {
Expand All @@ -6998,6 +7067,7 @@ EOF
log "Generating Caddyfile for HTTP ($site_hosts$port_suffix)$([ -n "$auth" ] && echo ' with login')..."
cat <<EOF >>"Caddyfile"
$(_site_addresses http) {
$(_bind_line)
$auth
$logblk
reverse_proxy 127.0.0.1:8000 {
Expand All @@ -7020,6 +7090,7 @@ EOF
cat <<EOF >>"Caddyfile"

http://${NETWORK_PREFIX}.1 {
$(_onion_bind_line)
$auth
$logblk
reverse_proxy 127.0.0.1:8000 {
Expand Down Expand Up @@ -8972,6 +9043,8 @@ control_upgrade() { # <request-file> <id> <actor> <control-dir>
_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
Expand Down
Loading