From b5e19f08f6a1e5e404f9ca5f5c05fe49c19b30e1 Mon Sep 17 00:00:00 2001 From: v3d Date: Tue, 30 Jun 2026 17:02:54 +0200 Subject: [PATCH] Activate offline mode changes on next boot --- README.md | 2 + etc/systemd/system/pivilion-config.service | 1 + etc/systemd/system/pivilion-mode.service | 2 +- install.sh | 2 +- tests/run.sh | 13 ++++- tests/test-common.sh | 7 +++ usr/local/lib/pivilion/common.sh | 7 +++ usr/local/lib/pivilion/config-sync.sh | 19 +++++++- usr/local/lib/pivilion/mode.sh | 55 ++++++++++++++++++---- 9 files changed, 94 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index f711560..78c082d 100644 --- a/README.md +++ b/README.md @@ -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 `http://10.1.1.1/`. HTTPS interception is intentionally not attempted because a 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 `pivilion diagnose` on the Pi to verify DHCP/DNS, Apache rewrite handling, diff --git a/etc/systemd/system/pivilion-config.service b/etc/systemd/system/pivilion-config.service index 94b6b5f..4fbb74b 100644 --- a/etc/systemd/system/pivilion-config.service +++ b/etc/systemd/system/pivilion-config.service @@ -4,6 +4,7 @@ DefaultDependencies=no After=local-fs.target Before=network-pre.target NetworkManager.service dhcpcd.service pivilion-mode.service Wants=network-pre.target +RequiresMountsFor=/boot /boot/firmware [Service] Type=oneshot diff --git a/etc/systemd/system/pivilion-mode.service b/etc/systemd/system/pivilion-mode.service index d136223..6e38bcc 100644 --- a/etc/systemd/system/pivilion-mode.service +++ b/etc/systemd/system/pivilion-mode.service @@ -5,7 +5,7 @@ After=pivilion-config.service NetworkManager.service dhcpcd.service [Service] Type=oneshot -ExecStart=/usr/local/lib/pivilion/mode.sh +ExecStart=/usr/local/lib/pivilion/mode.sh --boot-selected RemainAfterExit=yes TimeoutStartSec=120 diff --git a/install.sh b/install.sh index c6aecde..4d0bd5c 100755 --- a/install.sh +++ b/install.sh @@ -1,7 +1,7 @@ #!/bin/bash set -Eeuo pipefail -INSTALL_VERSION=3 +INSTALL_VERSION=4 ACTION=install case "${1:-}" in '') ;; diff --git a/tests/run.sh b/tests/run.sh index 3f80eb9..e0409c7 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -46,7 +46,7 @@ state_contract() { 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 '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() { @@ -67,6 +67,16 @@ boot_config_contract() { 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() { 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" @@ -81,6 +91,7 @@ run "transactional installer safety contract" installer_safety run "truthful runtime state contract" state_contract run "agetty login-banner contract" tty_banner_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 [ "$failures" -eq 0 ] || { echo "$failures test group(s) failed." >&2; exit 1; } diff --git a/tests/test-common.sh b/tests/test-common.sh index 41e3f60..f7f14bf 100755 --- a/tests/test-common.sh +++ b/tests/test-common.sh @@ -23,6 +23,7 @@ EOF export PIVILION_CONFIG_PATHS="$TMP/primary.conf $TMP/fallback.conf" export PIVILION_STATE_DIR="$TMP/run" 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" PIVILION_BOOT_ROOT="$TMP/boot" export PIVILION_BOOT_ROOT @@ -47,6 +48,12 @@ EOF [ "$(pivilion_state_value active_mode)" = hotspot ] [ "$(pivilion_state_value health)" = degraded ] [ "$(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" grep -Fqx 'My IP address is \4{wlan-test}' "$TMP/pivilion.issue" diff --git a/usr/local/lib/pivilion/common.sh b/usr/local/lib/pivilion/common.sh index c7d0341..e3ae112 100644 --- a/usr/local/lib/pivilion/common.sh +++ b/usr/local/lib/pivilion/common.sh @@ -5,6 +5,7 @@ PIVILION_CONFIG="" PIVILION_STATE_DIR=${PIVILION_STATE_DIR:-/run/pivilion} 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 # distinguish configuration, hardware, service, captive portal, and Tor faults. @@ -158,6 +159,12 @@ pivilion_current_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() { [ "$(id -u)" -eq 0 ] || { echo "This operation must run as root (use sudo)." >&2; return 1; } } diff --git a/usr/local/lib/pivilion/config-sync.sh b/usr/local/lib/pivilion/config-sync.sh index af376e1..0487043 100644 --- a/usr/local/lib/pivilion/config-sync.sh +++ b/usr/local/lib/pivilion/config-sync.sh @@ -125,7 +125,7 @@ EOF } write_canonical() { - local tmp + local tmp mode_tmp require_writable_boot tmp=$(mktemp "$BOOT_ROOT/.pivilion-conf.XXXXXX") render_config > "$tmp" @@ -134,6 +134,10 @@ write_canonical() { sync -f "$BOOT_ROOT" 2>/dev/null || sync atomic_copy "$BOOT_CONFIG" "$LAST_GOOD" 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" } @@ -255,11 +259,18 @@ set_value() { case "$ACTION" in apply|initialize) + if [ "$ACTION" = apply ]; then + rm -f /run/pivilion/effective.conf "$PIVILION_SELECTED_MODE_FILE" + fi if ! load_boot_or_detect; then fail_boot "Invalid pivilion.conf; the last-known-good settings were retained" [ -r "$LAST_GOOD" ] || 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 + 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 exit 0 fi @@ -282,5 +293,9 @@ case "$ACTION" in sync-status) PIVILION_CONFIG_PATHS=$BOOT_CONFIG pivilion_load_config || exit "$PIVILION_E_CONFIG" 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 diff --git a/usr/local/lib/pivilion/mode.sh b/usr/local/lib/pivilion/mode.sh index 26b14f7..eb39e7f 100755 --- a/usr/local/lib/pivilion/mode.sh +++ b/usr/local/lib/pivilion/mode.sh @@ -5,8 +5,30 @@ source /usr/local/lib/pivilion/common.sh pivilion_require_root 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 + +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) [ -n "$IFACE" ] && [ -d "/sys/class/net/$IFACE" ] || { pivilion_write_state none none failed "Wi-Fi interface not found" @@ -32,9 +54,12 @@ refresh_login_banner() { } activation_error() { - local rc=$? line=${BASH_LINENO[0]:-unknown} + local rc=$? line=${BASH_LINENO[0]:-unknown} existing 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 logger -t pivilion-mode "Failed to activate $MODE mode at line $line (exit $rc)" 2>/dev/null || true exit "$rc" @@ -152,6 +177,15 @@ wait_for_default_route() { 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() { 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 systemctl start apache2 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 modify "$NM_HOTSPOT" 802-11-wireless.mode ap 802-11-wireless.band bg \ 802-11-wireless.channel "$PIVILION_HOTSPOT_CHANNEL" \ @@ -209,11 +244,12 @@ start_hotspot() { fi systemctl restart pivilion-dnsmasq.service 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 else - pivilion_write_state hotspot "$BACKEND" degraded "Post-start captive portal diagnostics failed" - logger -t pivilion-mode "Hotspot active but degraded; run pivilion diagnose" || true + pivilion_write_state none "$BACKEND" failed "Post-start captive portal diagnostics failed" + refresh_login_banner + return "$PIVILION_E_CAPTIVE" fi 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" @@ -235,11 +271,12 @@ start_onion() { systemctl start apache2 restart_tor 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 else - pivilion_write_state onion "$BACKEND" degraded "${failure:-Onion diagnostics failed}" - logger -t pivilion-mode "Onion mode active but degraded: ${failure:-diagnostics failed}" || true + pivilion_write_state none "$BACKEND" failed "${failure:-Onion diagnostics failed}" + refresh_login_banner + return "$PIVILION_E_TOR" fi 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"