Activate offline mode changes on next boot

This commit is contained in:
v3d 2026-06-30 17:02:54 +02:00
parent eda151065c
commit b5e19f08f6
9 changed files with 94 additions and 14 deletions

View file

@ -62,6 +62,8 @@ Use `pikey` to save upstream Wi-Fi credentials, then `onion` and reboot to host
through Tor. Use `hotspot` and reboot to run the local captive gallery at through Tor. Use `hotspot` and reboot to run the local captive gallery at
`http://10.1.1.1/`. HTTPS interception is intentionally not attempted because a `http://10.1.1.1/`. HTTPS interception is intentionally not attempted because a
local captive portal cannot present valid certificates for arbitrary sites. local captive portal cannot present valid certificates for arbitrary sites.
Changing `PIVILION_MODE` in the boot-partition configuration activates either
mode during the immediately following boot; no second reboot is required.
If a phone joins but does not open its captive-login window, run If a phone joins but does not open its captive-login window, run
`pivilion diagnose` on the Pi to verify DHCP/DNS, Apache rewrite handling, `pivilion diagnose` on the Pi to verify DHCP/DNS, Apache rewrite handling,

View file

@ -4,6 +4,7 @@ DefaultDependencies=no
After=local-fs.target After=local-fs.target
Before=network-pre.target NetworkManager.service dhcpcd.service pivilion-mode.service Before=network-pre.target NetworkManager.service dhcpcd.service pivilion-mode.service
Wants=network-pre.target Wants=network-pre.target
RequiresMountsFor=/boot /boot/firmware
[Service] [Service]
Type=oneshot Type=oneshot

View file

@ -5,7 +5,7 @@ After=pivilion-config.service NetworkManager.service dhcpcd.service
[Service] [Service]
Type=oneshot Type=oneshot
ExecStart=/usr/local/lib/pivilion/mode.sh ExecStart=/usr/local/lib/pivilion/mode.sh --boot-selected
RemainAfterExit=yes RemainAfterExit=yes
TimeoutStartSec=120 TimeoutStartSec=120

View file

@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
set -Eeuo pipefail set -Eeuo pipefail
INSTALL_VERSION=3 INSTALL_VERSION=4
ACTION=install ACTION=install
case "${1:-}" in case "${1:-}" in
'') ;; '') ;;

View file

@ -46,7 +46,7 @@ state_contract() {
grep -q "printf 'desired_mode" "$ROOT/usr/local/lib/pivilion/common.sh" && grep -q "printf 'desired_mode" "$ROOT/usr/local/lib/pivilion/common.sh" &&
grep -q "printf 'active_mode" "$ROOT/usr/local/lib/pivilion/common.sh" && grep -q "printf 'active_mode" "$ROOT/usr/local/lib/pivilion/common.sh" &&
grep -q "printf 'health" "$ROOT/usr/local/lib/pivilion/common.sh" && grep -q "printf 'health" "$ROOT/usr/local/lib/pivilion/common.sh" &&
grep -q 'degraded' "$ROOT/usr/local/lib/pivilion/mode.sh" grep -q 'pivilion_write_state none.*failed' "$ROOT/usr/local/lib/pivilion/mode.sh"
} }
tty_banner_contract() { tty_banner_contract() {
@ -67,6 +67,16 @@ boot_config_contract() {
grep -q 'config-sync.sh set-wifi' "$ROOT/usr/local/bin/pikey" grep -q 'config-sync.sh set-wifi' "$ROOT/usr/local/bin/pikey"
} }
same_boot_mode_contract() {
grep -q 'ExecStart=/usr/local/lib/pivilion/mode.sh --boot-selected' "$ROOT/etc/systemd/system/pivilion-mode.service" &&
grep -q 'Requires=pivilion-config.service' "$ROOT/etc/systemd/system/pivilion-mode.service" &&
grep -q 'RequiresMountsFor=/boot /boot/firmware' "$ROOT/etc/systemd/system/pivilion-config.service" &&
grep -q 'PIVILION_SELECTED_MODE_FILE' "$ROOT/usr/local/lib/pivilion/config-sync.sh" &&
grep -q 'MODE=$(pivilion_selected_mode)' "$ROOT/usr/local/lib/pivilion/mode.sh" &&
grep -q 'wait_for_diagnostics hotspot' "$ROOT/usr/local/lib/pivilion/mode.sh" &&
grep -q 'wait_for_diagnostics onion' "$ROOT/usr/local/lib/pivilion/mode.sh"
}
shellcheck_scripts() { shellcheck_scripts() {
command -v shellcheck >/dev/null 2>&1 || return 0 command -v shellcheck >/dev/null 2>&1 || return 0
shellcheck "$ROOT/install.sh" "$ROOT"/usr/local/lib/pivilion/*.sh "$ROOT"/usr/local/bin/pivilion* "$ROOT/usr/local/bin/hotspot" "$ROOT/usr/local/bin/onion" "$ROOT/usr/local/bin/pikey" shellcheck "$ROOT/install.sh" "$ROOT"/usr/local/lib/pivilion/*.sh "$ROOT"/usr/local/bin/pivilion* "$ROOT/usr/local/bin/hotspot" "$ROOT/usr/local/bin/onion" "$ROOT/usr/local/bin/pikey"
@ -81,6 +91,7 @@ run "transactional installer safety contract" installer_safety
run "truthful runtime state contract" state_contract run "truthful runtime state contract" state_contract
run "agetty login-banner contract" tty_banner_contract run "agetty login-banner contract" tty_banner_contract
run "canonical boot configuration contract" boot_config_contract run "canonical boot configuration contract" boot_config_contract
run "same-boot hotspot and onion activation contract" same_boot_mode_contract
run "ShellCheck (when installed)" shellcheck_scripts run "ShellCheck (when installed)" shellcheck_scripts
[ "$failures" -eq 0 ] || { echo "$failures test group(s) failed." >&2; exit 1; } [ "$failures" -eq 0 ] || { echo "$failures test group(s) failed." >&2; exit 1; }

View file

@ -23,6 +23,7 @@ EOF
export PIVILION_CONFIG_PATHS="$TMP/primary.conf $TMP/fallback.conf" export PIVILION_CONFIG_PATHS="$TMP/primary.conf $TMP/fallback.conf"
export PIVILION_STATE_DIR="$TMP/run" export PIVILION_STATE_DIR="$TMP/run"
export PIVILION_STATE_FILE="$TMP/run/status.tsv" export PIVILION_STATE_FILE="$TMP/run/status.tsv"
export PIVILION_SELECTED_MODE_FILE="$TMP/run/selected-mode"
source "$ROOT/usr/local/lib/pivilion/common.sh" source "$ROOT/usr/local/lib/pivilion/common.sh"
PIVILION_BOOT_ROOT="$TMP/boot" PIVILION_BOOT_ROOT="$TMP/boot"
export PIVILION_BOOT_ROOT export PIVILION_BOOT_ROOT
@ -47,6 +48,12 @@ EOF
[ "$(pivilion_state_value active_mode)" = hotspot ] [ "$(pivilion_state_value active_mode)" = hotspot ]
[ "$(pivilion_state_value health)" = degraded ] [ "$(pivilion_state_value health)" = degraded ]
[ "$(pivilion_state_value missing fallback)" = fallback ] [ "$(pivilion_state_value missing fallback)" = fallback ]
printf 'hotspot\n' > "$PIVILION_SELECTED_MODE_FILE"
[ "$(pivilion_selected_mode)" = hotspot ]
printf 'onion\n' > "$PIVILION_SELECTED_MODE_FILE"
[ "$(pivilion_selected_mode)" = onion ]
printf 'invalid\n' > "$PIVILION_SELECTED_MODE_FILE"
if pivilion_selected_mode >/dev/null 2>&1; then exit 1; fi
pivilion_print_issue > "$TMP/pivilion.issue" pivilion_print_issue > "$TMP/pivilion.issue"
grep -Fqx 'My IP address is \4{wlan-test}' "$TMP/pivilion.issue" grep -Fqx 'My IP address is \4{wlan-test}' "$TMP/pivilion.issue"

View file

@ -5,6 +5,7 @@
PIVILION_CONFIG="" PIVILION_CONFIG=""
PIVILION_STATE_DIR=${PIVILION_STATE_DIR:-/run/pivilion} PIVILION_STATE_DIR=${PIVILION_STATE_DIR:-/run/pivilion}
PIVILION_STATE_FILE=${PIVILION_STATE_FILE:-$PIVILION_STATE_DIR/status.tsv} PIVILION_STATE_FILE=${PIVILION_STATE_FILE:-$PIVILION_STATE_DIR/status.tsv}
PIVILION_SELECTED_MODE_FILE=${PIVILION_SELECTED_MODE_FILE:-$PIVILION_STATE_DIR/selected-mode}
# Stable command exit codes. Keep these public so scripts and monitoring can # Stable command exit codes. Keep these public so scripts and monitoring can
# distinguish configuration, hardware, service, captive portal, and Tor faults. # distinguish configuration, hardware, service, captive portal, and Tor faults.
@ -158,6 +159,12 @@ pivilion_current_mode() {
printf '%s\n' "$PIVILION_MODE" printf '%s\n' "$PIVILION_MODE"
} }
pivilion_selected_mode() {
local mode
mode=$(head -n1 "$PIVILION_SELECTED_MODE_FILE" 2>/dev/null || true)
case "$mode" in hotspot|onion) printf '%s\n' "$mode" ;; *) return 1 ;; esac
}
pivilion_require_root() { pivilion_require_root() {
[ "$(id -u)" -eq 0 ] || { echo "This operation must run as root (use sudo)." >&2; return 1; } [ "$(id -u)" -eq 0 ] || { echo "This operation must run as root (use sudo)." >&2; return 1; }
} }

View file

@ -125,7 +125,7 @@ EOF
} }
write_canonical() { write_canonical() {
local tmp local tmp mode_tmp
require_writable_boot require_writable_boot
tmp=$(mktemp "$BOOT_ROOT/.pivilion-conf.XXXXXX") tmp=$(mktemp "$BOOT_ROOT/.pivilion-conf.XXXXXX")
render_config > "$tmp" render_config > "$tmp"
@ -134,6 +134,10 @@ write_canonical() {
sync -f "$BOOT_ROOT" 2>/dev/null || sync sync -f "$BOOT_ROOT" 2>/dev/null || sync
atomic_copy "$BOOT_CONFIG" "$LAST_GOOD" 0600 atomic_copy "$BOOT_CONFIG" "$LAST_GOOD" 0600
atomic_copy "$BOOT_CONFIG" /run/pivilion/effective.conf 0600 atomic_copy "$BOOT_CONFIG" /run/pivilion/effective.conf 0600
mode_tmp=$(mktemp)
printf '%s\n' "$PIVILION_MODE" > "$mode_tmp"
atomic_copy "$mode_tmp" "$PIVILION_SELECTED_MODE_FILE" 0644
rm -f "$mode_tmp"
rm -f "$BOOT_ERROR" rm -f "$BOOT_ERROR"
} }
@ -255,11 +259,18 @@ set_value() {
case "$ACTION" in case "$ACTION" in
apply|initialize) apply|initialize)
if [ "$ACTION" = apply ]; then
rm -f /run/pivilion/effective.conf "$PIVILION_SELECTED_MODE_FILE"
fi
if ! load_boot_or_detect; then if ! load_boot_or_detect; then
fail_boot "Invalid pivilion.conf; the last-known-good settings were retained" fail_boot "Invalid pivilion.conf; the last-known-good settings were retained"
[ -r "$LAST_GOOD" ] || exit "$PIVILION_E_CONFIG" [ -r "$LAST_GOOD" ] || exit "$PIVILION_E_CONFIG"
PIVILION_CONFIG_PATHS=$LAST_GOOD pivilion_load_config || exit "$PIVILION_E_CONFIG" PIVILION_CONFIG_PATHS=$LAST_GOOD pivilion_load_config || exit "$PIVILION_E_CONFIG"
atomic_copy "$LAST_GOOD" /run/pivilion/effective.conf 0600 atomic_copy "$LAST_GOOD" /run/pivilion/effective.conf 0600
mode_tmp=$(mktemp)
printf '%s\n' "$PIVILION_MODE" > "$mode_tmp"
atomic_copy "$mode_tmp" "$PIVILION_SELECTED_MODE_FILE" 0644
rm -f "$mode_tmp"
[ "$ACTION" = initialize ] || apply_runtime [ "$ACTION" = initialize ] || apply_runtime
exit 0 exit 0
fi fi
@ -282,5 +293,9 @@ case "$ACTION" in
sync-status) sync-status)
PIVILION_CONFIG_PATHS=$BOOT_CONFIG pivilion_load_config || exit "$PIVILION_E_CONFIG" PIVILION_CONFIG_PATHS=$BOOT_CONFIG pivilion_load_config || exit "$PIVILION_E_CONFIG"
write_canonical ;; write_canonical ;;
*) echo "Usage: config-sync.sh apply|initialize|set KEY VALUE|set-wifi SSID PASSWORD|sync-status" >&2; exit 2 ;; record-error)
shift
[ "$#" -gt 0 ] || { echo "Usage: config-sync.sh record-error MESSAGE" >&2; exit 2; }
fail_boot "$*" ;;
*) echo "Usage: config-sync.sh apply|initialize|set KEY VALUE|set-wifi SSID PASSWORD|sync-status|record-error MESSAGE" >&2; exit 2 ;;
esac esac

View file

@ -5,8 +5,30 @@ source /usr/local/lib/pivilion/common.sh
pivilion_require_root pivilion_require_root
pivilion_load_config || exit "$PIVILION_E_CONFIG" pivilion_load_config || exit "$PIVILION_E_CONFIG"
MODE=${1:-$(pivilion_current_mode)} if [ "${1:---boot-selected}" = --boot-selected ]; then
MODE=$(pivilion_selected_mode) || {
echo "No validated boot mode was produced by pivilion-config.service" >&2
/usr/local/lib/pivilion/config-sync.sh record-error "No validated boot mode was produced during startup" >/dev/null 2>&1 || true
exit "$PIVILION_E_CONFIG"
}
[ "$MODE" = "$(pivilion_current_mode)" ] || {
echo "Validated boot mode does not match the effective configuration" >&2
/usr/local/lib/pivilion/config-sync.sh record-error "Validated boot mode does not match the effective configuration" >/dev/null 2>&1 || true
exit "$PIVILION_E_CONFIG"
}
else
MODE=$1
fi
case "$MODE" in hotspot|onion) ;; *) echo "Unknown Pivilion mode: $MODE" >&2; exit 2 ;; esac case "$MODE" in hotspot|onion) ;; *) echo "Unknown Pivilion mode: $MODE" >&2; exit 2 ;; esac
mode_exit() {
local rc=$? failure
[ "$rc" -eq 0 ] && return
failure=$(pivilion_state_value failure "activation exited with status $rc")
/usr/local/lib/pivilion/config-sync.sh record-error "Failed to activate $MODE mode: $failure (exit $rc)" >/dev/null 2>&1 || true
logger -t pivilion-mode "Failed to activate $MODE mode: $failure (exit $rc)" 2>/dev/null || true
}
trap mode_exit EXIT
IFACE=$(pivilion_wifi_interface) IFACE=$(pivilion_wifi_interface)
[ -n "$IFACE" ] && [ -d "/sys/class/net/$IFACE" ] || { [ -n "$IFACE" ] && [ -d "/sys/class/net/$IFACE" ] || {
pivilion_write_state none none failed "Wi-Fi interface not found" pivilion_write_state none none failed "Wi-Fi interface not found"
@ -32,9 +54,12 @@ refresh_login_banner() {
} }
activation_error() { activation_error() {
local rc=$? line=${BASH_LINENO[0]:-unknown} local rc=$? line=${BASH_LINENO[0]:-unknown} existing
trap - ERR trap - ERR
pivilion_write_state none "$BACKEND" failed "Activation failed at line $line (exit $rc)" || true existing=$(pivilion_state_value failure none)
if [ "$existing" = none ] || [ "$existing" = unknown ]; then
pivilion_write_state none "$BACKEND" failed "Activation failed at line $line (exit $rc)" || true
fi
refresh_login_banner || true refresh_login_banner || true
logger -t pivilion-mode "Failed to activate $MODE mode at line $line (exit $rc)" 2>/dev/null || true logger -t pivilion-mode "Failed to activate $MODE mode at line $line (exit $rc)" 2>/dev/null || true
exit "$rc" exit "$rc"
@ -152,6 +177,15 @@ wait_for_default_route() {
return 1 return 1
} }
wait_for_diagnostics() {
local mode=$1 i
for i in $(seq 1 10); do
/usr/local/lib/pivilion/diagnose.sh --mode "$mode" --quiet && return 0
sleep 1
done
return 1
}
restart_tor() { restart_tor() {
if systemctl cat tor@default.service >/dev/null 2>&1; then systemctl restart tor@default.service; else systemctl restart tor.service; fi if systemctl cat tor@default.service >/dev/null 2>&1; then systemctl restart tor@default.service; else systemctl restart tor.service; fi
} }
@ -196,6 +230,7 @@ start_hotspot() {
rfkill unblock wifi 2>/dev/null || true rfkill unblock wifi 2>/dev/null || true
systemctl start apache2 systemctl start apache2
if [ "$BACKEND" = networkmanager ]; then if [ "$BACKEND" = networkmanager ]; then
nmcli device disconnect "$IFACE" >/dev/null 2>&1 || true
nmcli connection add type wifi ifname "$IFACE" con-name "$NM_HOTSPOT" ssid "$PIVILION_HOTSPOT_SSID" nmcli connection add type wifi ifname "$IFACE" con-name "$NM_HOTSPOT" ssid "$PIVILION_HOTSPOT_SSID"
nmcli connection modify "$NM_HOTSPOT" 802-11-wireless.mode ap 802-11-wireless.band bg \ nmcli connection modify "$NM_HOTSPOT" 802-11-wireless.mode ap 802-11-wireless.band bg \
802-11-wireless.channel "$PIVILION_HOTSPOT_CHANNEL" \ 802-11-wireless.channel "$PIVILION_HOTSPOT_CHANNEL" \
@ -209,11 +244,12 @@ start_hotspot() {
fi fi
systemctl restart pivilion-dnsmasq.service systemctl restart pivilion-dnsmasq.service
apply_firewall apply_firewall
if /usr/local/lib/pivilion/diagnose.sh --mode hotspot --quiet; then if wait_for_diagnostics hotspot; then
pivilion_write_state hotspot "$BACKEND" healthy none pivilion_write_state hotspot "$BACKEND" healthy none
else else
pivilion_write_state hotspot "$BACKEND" degraded "Post-start captive portal diagnostics failed" pivilion_write_state none "$BACKEND" failed "Post-start captive portal diagnostics failed"
logger -t pivilion-mode "Hotspot active but degraded; run pivilion diagnose" || true refresh_login_banner
return "$PIVILION_E_CAPTIVE"
fi fi
refresh_login_banner refresh_login_banner
/usr/local/lib/pivilion/config-sync.sh sync-status || logger -t pivilion-mode "Could not mirror hotspot status to the boot partition" /usr/local/lib/pivilion/config-sync.sh sync-status || logger -t pivilion-mode "Could not mirror hotspot status to the boot partition"
@ -235,11 +271,12 @@ start_onion() {
systemctl start apache2 systemctl start apache2
restart_tor restart_tor
update_torname || failure="${failure:+$failure; }Tor did not produce a valid hostname; retained previous value" update_torname || failure="${failure:+$failure; }Tor did not produce a valid hostname; retained previous value"
if [ -z "$failure" ] && /usr/local/lib/pivilion/diagnose.sh --mode onion --quiet; then if [ -z "$failure" ] && wait_for_diagnostics onion; then
pivilion_write_state onion "$BACKEND" healthy none pivilion_write_state onion "$BACKEND" healthy none
else else
pivilion_write_state onion "$BACKEND" degraded "${failure:-Onion diagnostics failed}" pivilion_write_state none "$BACKEND" failed "${failure:-Onion diagnostics failed}"
logger -t pivilion-mode "Onion mode active but degraded: ${failure:-diagnostics failed}" || true refresh_login_banner
return "$PIVILION_E_TOR"
fi fi
refresh_login_banner refresh_login_banner
/usr/local/lib/pivilion/config-sync.sh sync-status || logger -t pivilion-mode "Could not mirror onion status to the boot partition" /usr/local/lib/pivilion/config-sync.sh sync-status || logger -t pivilion-mode "Could not mirror onion status to the boot partition"