From eda151065cd0f13c0513101d066d16e7819fbcc7 Mon Sep 17 00:00:00 2001 From: v3d Date: Tue, 30 Jun 2026 16:29:51 +0200 Subject: [PATCH 1/2] Add canonical boot configuration synchronization --- README.md | 18 +- etc/systemd/system/pivilion-config.service | 15 ++ etc/systemd/system/pivilion-mode.service | 4 +- install.sh | 33 ++- pivilion.conf.example | 21 +- tests/run.sh | 10 + tests/test-common.sh | 6 +- usr/local/bin/generator | 2 + usr/local/bin/hotspot | 2 +- usr/local/bin/onion | 2 +- usr/local/bin/pikey | 23 +- usr/local/lib/pivilion/common.sh | 47 +++- usr/local/lib/pivilion/config-sync.sh | 286 +++++++++++++++++++++ usr/local/lib/pivilion/mode.sh | 5 +- 14 files changed, 424 insertions(+), 50 deletions(-) create mode 100644 etc/systemd/system/pivilion-config.service create mode 100644 usr/local/lib/pivilion/config-sync.sh diff --git a/README.md b/README.md index d3e1317..f711560 100644 --- a/README.md +++ b/README.md @@ -43,10 +43,20 @@ sudo ./install.sh --repair # repeat deployment without replacing gallery conten ``` The installer defaults to the user that invoked `sudo`, not to a hard-coded -`pi` account. To customize paths, startup mode, hotspot SSID, or Wi-Fi -interface, copy `pivilion.conf.example` to `/boot/firmware/pivilion.conf` (or -`/etc/pivilion.conf`) and edit it before installing. The hotspot is intentionally -open and passwordless. +`pi` account. The installer automatically creates `pivilion.conf` at the +externally visible root of the FAT boot partition, using the current system and +saved Wi-Fi settings. Linux may mount that same root as `/boot` or +`/boot/firmware`; users never need to expose that distinction. The hotspot is +intentionally open and passwordless. + +The boot file controls startup mode, upstream Wi-Fi SSID and PSK, Wi-Fi country +and interface, hotspot name and channel, hostname, Pivilion user and paths, +generator state, and PHP upload limit. Pivilion commands keep it synchronized. +An 8–63-character Wi-Fi password entered there is replaced at boot with its +derived 64-character WPA PSK. The PSK does not reveal the original text, but it +still grants network access and must be treated as a secret. Invalid offline +edits retain the root-protected last-known-good settings and create +`pivilion-error.txt` beside the boot configuration. 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 diff --git a/etc/systemd/system/pivilion-config.service b/etc/systemd/system/pivilion-config.service new file mode 100644 index 0000000..94b6b5f --- /dev/null +++ b/etc/systemd/system/pivilion-config.service @@ -0,0 +1,15 @@ +[Unit] +Description=Apply Pivilion boot-partition configuration +DefaultDependencies=no +After=local-fs.target +Before=network-pre.target NetworkManager.service dhcpcd.service pivilion-mode.service +Wants=network-pre.target + +[Service] +Type=oneshot +ExecStart=/usr/local/lib/pivilion/config-sync.sh apply +RemainAfterExit=yes +StandardOutput=journal + +[Install] +WantedBy=multi-user.target diff --git a/etc/systemd/system/pivilion-mode.service b/etc/systemd/system/pivilion-mode.service index 7f01a33..d136223 100644 --- a/etc/systemd/system/pivilion-mode.service +++ b/etc/systemd/system/pivilion-mode.service @@ -1,7 +1,7 @@ [Unit] Description=Select Pivilion hotspot or onion networking mode -After=network-pre.target NetworkManager.service dhcpcd.service -Wants=network-pre.target +Requires=pivilion-config.service +After=pivilion-config.service NetworkManager.service dhcpcd.service [Service] Type=oneshot diff --git a/install.sh b/install.sh index 69ea6d6..c6aecde 100755 --- a/install.sh +++ b/install.sh @@ -1,7 +1,7 @@ #!/bin/bash set -Eeuo pipefail -INSTALL_VERSION=2 +INSTALL_VERSION=3 ACTION=install case "${1:-}" in '') ;; @@ -15,6 +15,8 @@ esac SOURCE_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) source "$SOURCE_DIR/usr/local/lib/pivilion/common.sh" +PIVILION_CONFIG_PATHS="$(pivilion_boot_config_path) /etc/pivilion.conf /etc/pivilion.defaults" +export PIVILION_CONFIG_PATHS pivilion_load_config || exit "$PIVILION_E_CONFIG" cat <<'PIVILION_LOGO' @@ -48,12 +50,13 @@ declare -a MANAGED_PATHS=( /etc/apache2/conf-enabled/pivilion.conf /etc/apache2/sites-enabled/000-default.conf /etc/tor/torrc /etc/tor/torrc.d/pivilion.conf /etc/systemd/system/pivilion-mode.service /etc/systemd/system/pivilion-dnsmasq.service + /etc/systemd/system/pivilion-config.service /etc/systemd/system/pivilion-hostapd.service /etc/systemd/system/pivilion-boot-greeter.service /etc/systemd/system/pivilion-login-banner.service /etc/rc.local /usr/local/lib/pivilion /usr/local/bin/pivilion /usr/local/bin/pivilion-diagnose /usr/local/bin/hotspot /usr/local/bin/onion /usr/local/bin/pikey /usr/local/bin/generator /usr/local/bin/static /usr/local/bin/hotglue /usr/local/bin/htaccess - /var/lib/pivilion/install-state + /var/lib/pivilion/install-state /var/lib/pivilion/pivilion.conf.last-good /etc/php/7.4/apache2/conf.d/99-pivilion.ini /etc/php/8.2/apache2/conf.d/99-pivilion.ini /etc/php/8.4/apache2/conf.d/99-pivilion.ini @@ -118,10 +121,11 @@ backup_managed_files() { BACKUP_DIR=$(mktemp -d /var/backups/pivilion/install-$(date +%Y%m%d-%H%M%S).XXXXXX) install -d -m 0700 "$BACKUP_DIR/files" : > "$BACKUP_DIR/present"; : > "$BACKUP_DIR/missing"; : > "$BACKUP_DIR/enabled-services"; : > "$BACKUP_DIR/active-services" - for service in apache2 pivilion-mode.service pivilion-boot-greeter.service pivilion-login-banner.service pivilion-dnsmasq.service pivilion-hostapd.service tor.service tor@default.service; do + for service in apache2 pivilion-config.service pivilion-mode.service pivilion-boot-greeter.service pivilion-login-banner.service pivilion-dnsmasq.service pivilion-hostapd.service tor.service tor@default.service; do systemctl is-enabled --quiet "$service" 2>/dev/null && printf '%s\n' "$service" >> "$BACKUP_DIR/enabled-services" || true systemctl is-active --quiet "$service" 2>/dev/null && printf '%s\n' "$service" >> "$BACKUP_DIR/active-services" || true done + MANAGED_PATHS+=("$(pivilion_boot_config_path)" "$(pivilion_boot_error_path)") for path in "${MANAGED_PATHS[@]}"; do relative=${path#/} if [ -e "$path" ] || [ -L "$path" ]; then @@ -147,9 +151,9 @@ rollback() { relative=${path#/}; rm -rf -- "$path"; install -d -m 0755 "$(dirname "$path")"; cp -a "$BACKUP_DIR/files/$relative" "$path" done < "$BACKUP_DIR/present" systemctl daemon-reload 2>/dev/null || true - systemctl disable apache2 pivilion-mode.service pivilion-boot-greeter.service pivilion-login-banner.service tor.service tor@default.service 2>/dev/null || true + systemctl disable apache2 pivilion-config.service pivilion-mode.service pivilion-boot-greeter.service pivilion-login-banner.service tor.service tor@default.service 2>/dev/null || true while IFS= read -r path; do [ -n "$path" ] && systemctl enable "$path" 2>/dev/null || true; done < "$BACKUP_DIR/enabled-services" - for service in apache2 pivilion-mode.service pivilion-boot-greeter.service pivilion-login-banner.service pivilion-dnsmasq.service pivilion-hostapd.service tor.service tor@default.service; do + for service in apache2 pivilion-config.service pivilion-mode.service pivilion-boot-greeter.service pivilion-login-banner.service pivilion-dnsmasq.service pivilion-hostapd.service tor.service tor@default.service; do grep -Fqx "$service" "$BACKUP_DIR/active-services" || systemctl stop "$service" 2>/dev/null || true done while IFS= read -r service; do [ -n "$service" ] && systemctl start "$service" 2>/dev/null || true; done < "$BACKUP_DIR/active-services" @@ -171,7 +175,7 @@ deploy_files() { log "Deploying Pivilion files" install -d -m 0755 /usr/local/lib/pivilion /usr/local/bin /etc/systemd/system /etc/tor/torrc.d /etc/profile.d /var/lib/pivilion install -m 0644 "$SOURCE_DIR/usr/local/lib/pivilion/common.sh" /usr/local/lib/pivilion/common.sh - install -m 0755 "$SOURCE_DIR/usr/local/lib/pivilion/mode.sh" "$SOURCE_DIR/usr/local/lib/pivilion/diagnose.sh" \ + install -m 0755 "$SOURCE_DIR/usr/local/lib/pivilion/mode.sh" "$SOURCE_DIR/usr/local/lib/pivilion/config-sync.sh" "$SOURCE_DIR/usr/local/lib/pivilion/diagnose.sh" \ "$SOURCE_DIR/usr/local/lib/pivilion/issue-banner.sh" /usr/local/lib/pivilion/ install -m 0755 "$SOURCE_DIR"/usr/local/bin/* /usr/local/bin/ install -m 0644 "$SOURCE_DIR"/etc/systemd/system/*.service /etc/systemd/system/ @@ -186,6 +190,14 @@ PIVILION_WEBROOT=$PIVILION_WEBROOT PIVILION_MODE=$PIVILION_MODE PIVILION_HOTSPOT_SSID=$PIVILION_HOTSPOT_SSID PIVILION_WIFI_INTERFACE=$PIVILION_WIFI_INTERFACE +PIVILION_WIFI_SSID=$PIVILION_WIFI_SSID +PIVILION_WIFI_PSK=$PIVILION_WIFI_PSK +PIVILION_WIFI_PSK_FOR_SSID=$PIVILION_WIFI_PSK_FOR_SSID +PIVILION_WIFI_COUNTRY=$PIVILION_WIFI_COUNTRY +PIVILION_HOTSPOT_CHANNEL=$PIVILION_HOTSPOT_CHANNEL +PIVILION_HOSTNAME=$PIVILION_HOSTNAME +PIVILION_GENERATOR_ENABLED=$PIVILION_GENERATOR_ENABLED +PIVILION_UPLOAD_MAX_SIZE=$PIVILION_UPLOAD_MAX_SIZE EOF install -d -m 0755 -o "$PIVILION_USER" -g "$PIVILION_GROUP" "$PIVILION_DIR" "$PIVILION_DIR/config" @@ -268,7 +280,7 @@ EOF fi migrate_legacy_console_greeter systemctl daemon-reload - systemctl enable apache2 pivilion-mode.service pivilion-login-banner.service + systemctl enable apache2 pivilion-config.service pivilion-mode.service pivilion-login-banner.service systemctl enable tor@default.service 2>/dev/null || systemctl enable tor.service systemctl disable pivilion-dnsmasq.service pivilion-hostapd.service 2>/dev/null || true /usr/local/lib/pivilion/issue-banner.sh @@ -287,6 +299,8 @@ validate_install() { grep -q '^My IP address is ' /etc/issue.d/90-pivilion.issue || failed=1 grep -q '^@@@@@@@@@@@@@@@@@@@' /etc/issue.d/90-pivilion.issue || failed=1 grep -q 'StandardOutput=null' /etc/systemd/system/pivilion-login-banner.service || failed=1 + [ -s "$(pivilion_boot_config_path)" ] || failed=1 + [ -s /var/lib/pivilion/pivilion.conf.last-good ] || failed=1 if command -v shellcheck >/dev/null 2>&1; then shellcheck /usr/local/lib/pivilion/*.sh /usr/local/bin/pivilion* /usr/local/bin/hotspot /usr/local/bin/onion /usr/local/bin/pikey || failed=1; fi [ "$failed" -eq 0 ] || return "$PIVILION_E_INSTALL" } @@ -305,8 +319,9 @@ check_install() { local failed=0 file for file in /etc/pivilion.defaults /var/lib/pivilion/install-state /etc/issue.d/90-pivilion.issue \ /usr/local/lib/pivilion/common.sh /usr/local/lib/pivilion/mode.sh /usr/local/lib/pivilion/diagnose.sh \ + /usr/local/lib/pivilion/config-sync.sh \ /usr/local/lib/pivilion/issue-banner.sh /etc/systemd/system/pivilion-mode.service \ - /etc/systemd/system/pivilion-login-banner.service; do + /etc/systemd/system/pivilion-config.service /etc/systemd/system/pivilion-login-banner.service; do if [ ! -e "$file" ]; then echo "Missing: $file" >&2; failed=1; fi done if [ "$failed" -eq 0 ]; then validate_install || failed=1; fi @@ -324,9 +339,11 @@ case "$ACTION" in esac backup_managed_files +PIVILION_COMMON_PATH="$SOURCE_DIR/usr/local/lib/pivilion/common.sh" bash "$SOURCE_DIR/usr/local/lib/pivilion/config-sync.sh" initialize install_packages deploy_files configure_services +/usr/local/lib/pivilion/config-sync.sh apply validate_install write_install_state systemctl is-active --quiet apache2 2>/dev/null && systemctl reload apache2 || true diff --git a/pivilion.conf.example b/pivilion.conf.example index 937f254..e212ca1 100644 --- a/pivilion.conf.example +++ b/pivilion.conf.example @@ -1,9 +1,18 @@ -# Copy this file to /boot/firmware/pivilion.conf or /etc/pivilion.conf. -# Values are parsed as data; this is not a shell script. -PIVILION_USER=pi -PIVILION_HOME=/home/pi -PIVILION_DIR=/home/pi/pivilion +# Optional template. The installer normally creates pivilion.conf automatically +# at the externally visible root of the FAT boot partition. +# Values are data, not shell commands. A derived PSK is credential-equivalent. +PIVILION_USER=your-user +PIVILION_HOME=/home/your-user +PIVILION_DIR=/home/your-user/pivilion PIVILION_WEBROOT=/var/www/html/pivilion PIVILION_MODE=onion +PIVILION_WIFI_SSID= +PIVILION_WIFI_PSK= +PIVILION_WIFI_PSK_FOR_SSID= +PIVILION_WIFI_COUNTRY=HR +PIVILION_WIFI_INTERFACE=wlan0 PIVILION_HOTSPOT_SSID=pivilion -# PIVILION_WIFI_INTERFACE=wlan0 +PIVILION_HOTSPOT_CHANNEL=6 +PIVILION_HOSTNAME=pivilion +PIVILION_GENERATOR_ENABLED=no +PIVILION_UPLOAD_MAX_SIZE=256M diff --git a/tests/run.sh b/tests/run.sh index fa1fadb..3f80eb9 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -58,6 +58,15 @@ tty_banner_contract() { ! grep -Eq '(^|[[:space:]])/etc/issue([[:space:]]|$)' "$ROOT/install.sh" } +boot_config_contract() { + grep -q 'pivilion_boot_config_path' "$ROOT/usr/local/lib/pivilion/config-sync.sh" && + grep -q 'pivilion.conf.last-good' "$ROOT/usr/local/lib/pivilion/config-sync.sh" && + grep -q 'derive_psk' "$ROOT/usr/local/lib/pivilion/config-sync.sh" && + grep -q 'Before=.*NetworkManager' "$ROOT/etc/systemd/system/pivilion-config.service" && + grep -q 'config-sync.sh set PIVILION_MODE' "$ROOT/usr/local/bin/hotspot" && + grep -q 'config-sync.sh set-wifi' "$ROOT/usr/local/bin/pikey" +} + 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" @@ -71,6 +80,7 @@ run "temporary HTTP-only captive rules" captive_rules 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 "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 bb5cd9f..41e3f60 100755 --- a/tests/test-common.sh +++ b/tests/test-common.sh @@ -24,6 +24,10 @@ export PIVILION_CONFIG_PATHS="$TMP/primary.conf $TMP/fallback.conf" export PIVILION_STATE_DIR="$TMP/run" export PIVILION_STATE_FILE="$TMP/run/status.tsv" source "$ROOT/usr/local/lib/pivilion/common.sh" +PIVILION_BOOT_ROOT="$TMP/boot" +export PIVILION_BOOT_ROOT +[ "$(pivilion_boot_config_path)" = "$TMP/boot/pivilion.conf" ] +unset PIVILION_BOOT_ROOT pivilion_load_config [ "$PIVILION_MODE" = hotspot ] @@ -32,7 +36,7 @@ pivilion_load_config install -d "$PIVILION_DIR" "$PIVILION_STATE_DIR" printf 'onion\n' > "$PIVILION_DIR/mode" -[ "$(pivilion_current_mode)" = onion ] +[ "$(pivilion_current_mode)" = hotspot ] cat > "$PIVILION_STATE_FILE" </dev/null systemctl reload apache2 install -d -m 2775 -o www-data -g www-data "$PIVILION_WEBROOT/gen/upload" echo "Generator enabled at http://:81/" break ;; Disable) + /usr/local/lib/pivilion/config-sync.sh set PIVILION_GENERATOR_ENABLED no a2dissite 000-upload.conf >/dev/null || true systemctl reload apache2 echo "Generator disabled." diff --git a/usr/local/bin/hotspot b/usr/local/bin/hotspot index 36a0da2..e29a8ac 100755 --- a/usr/local/bin/hotspot +++ b/usr/local/bin/hotspot @@ -3,5 +3,5 @@ set -euo pipefail source /usr/local/lib/pivilion/common.sh [ "$(id -u)" -eq 0 ] || exec sudo "$0" "$@" pivilion_load_config -pivilion_set_mode hotspot +/usr/local/lib/pivilion/config-sync.sh set PIVILION_MODE hotspot echo "Pivilion will start in open hotspot mode after the next reboot." diff --git a/usr/local/bin/onion b/usr/local/bin/onion index 7d2db00..20715d5 100755 --- a/usr/local/bin/onion +++ b/usr/local/bin/onion @@ -3,7 +3,7 @@ set -euo pipefail source /usr/local/lib/pivilion/common.sh [ "$(id -u)" -eq 0 ] || exec sudo "$0" "$@" pivilion_load_config -pivilion_set_mode onion +/usr/local/lib/pivilion/config-sync.sh set PIVILION_MODE onion echo "Pivilion will start in onion mode after the next reboot." if [ -s "$PIVILION_DIR/torname" ]; then printf 'Your onion domain is %s\n' "$(tr -d '\r\n' < "$PIVILION_DIR/torname")" diff --git a/usr/local/bin/pikey b/usr/local/bin/pikey index 23d96dc..ce81660 100755 --- a/usr/local/bin/pikey +++ b/usr/local/bin/pikey @@ -3,9 +3,6 @@ set -euo pipefail source /usr/local/lib/pivilion/common.sh [ "$(id -u)" -eq 0 ] || exec sudo "$0" "$@" pivilion_load_config -IFACE=$(pivilion_wifi_interface) -[ -n "$IFACE" ] || { echo "No Wi-Fi interface found." >&2; exit 1; } - read -r -p "Wi-Fi SSID: " ssid read -r -s -p "Wi-Fi password: " password printf '\n' @@ -14,22 +11,6 @@ printf '\n' echo "WPA password must contain 8-63 characters." >&2; exit 1; } -if pivilion_uses_networkmanager; then - nmcli connection delete pivilion-onion >/dev/null 2>&1 || true - nmcli connection add type wifi ifname "$IFACE" con-name pivilion-onion ssid "$ssid" >/dev/null - nmcli connection modify pivilion-onion 802-11-wireless.mode infrastructure \ - 802-11-wireless-security.key-mgmt wpa-psk 802-11-wireless-security.psk "$password" \ - ipv4.method auto ipv6.method auto connection.autoconnect yes -else - tmp=$(mktemp) - trap 'rm -f "$tmp"' EXIT - wpa_passphrase "$ssid" "$password" | sed '/^[[:space:]]*#psk=/d' > "$tmp" - install -d -m 0755 /etc/wpa_supplicant - install -m 0600 "$tmp" /etc/wpa_supplicant/pivilion.conf - touch /etc/wpa_supplicant/wpa_supplicant.conf - grep -Fqx 'include=/etc/wpa_supplicant/pivilion.conf' /etc/wpa_supplicant/wpa_supplicant.conf || \ - printf '\ninclude=/etc/wpa_supplicant/pivilion.conf\n' >> /etc/wpa_supplicant/wpa_supplicant.conf - chmod 0600 /etc/wpa_supplicant/wpa_supplicant.conf -fi +/usr/local/lib/pivilion/config-sync.sh set-wifi "$ssid" "$password" unset password -echo "Wi-Fi credentials saved. Run onion and reboot when ready." +echo "Wi-Fi credentials saved as a derived PSK in the boot configuration. Run onion and reboot when ready." diff --git a/usr/local/lib/pivilion/common.sh b/usr/local/lib/pivilion/common.sh index 7e83f35..c7d0341 100644 --- a/usr/local/lib/pivilion/common.sh +++ b/usr/local/lib/pivilion/common.sh @@ -25,7 +25,7 @@ _pivilion_trim() { _pivilion_config_value() { local key=$1 file line name value config_paths - config_paths=${PIVILION_CONFIG_PATHS:-/boot/firmware/pivilion.conf /boot/pivilion.conf /etc/pivilion.conf /etc/pivilion.defaults} + config_paths=${PIVILION_CONFIG_PATHS:-/run/pivilion/effective.conf $(pivilion_boot_config_path) /etc/pivilion.conf /etc/pivilion.defaults} for file in $config_paths; do [ -r "$file" ] || continue while IFS= read -r line || [ -n "$line" ]; do @@ -68,13 +68,29 @@ pivilion_load_config() { PIVILION_HOTSPOT_SSID=${configured:-pivilion} configured=$(_pivilion_config_value PIVILION_WIFI_INTERFACE || true) PIVILION_WIFI_INTERFACE=${configured:-} + configured=$(_pivilion_config_value PIVILION_WIFI_SSID || true) + PIVILION_WIFI_SSID=${configured:-} + configured=$(_pivilion_config_value PIVILION_WIFI_PSK || true) + PIVILION_WIFI_PSK=${configured:-} + configured=$(_pivilion_config_value PIVILION_WIFI_PSK_FOR_SSID || true) + PIVILION_WIFI_PSK_FOR_SSID=${configured:-} + configured=$(_pivilion_config_value PIVILION_WIFI_COUNTRY || true) + PIVILION_WIFI_COUNTRY=${configured:-HR} + configured=$(_pivilion_config_value PIVILION_HOTSPOT_CHANNEL || true) + PIVILION_HOTSPOT_CHANNEL=${configured:-6} + configured=$(_pivilion_config_value PIVILION_HOSTNAME || true) + PIVILION_HOSTNAME=${configured:-$(hostname -s 2>/dev/null || uname -n)} + configured=$(_pivilion_config_value PIVILION_GENERATOR_ENABLED || true) + PIVILION_GENERATOR_ENABLED=${configured:-no} + configured=$(_pivilion_config_value PIVILION_UPLOAD_MAX_SIZE || true) + PIVILION_UPLOAD_MAX_SIZE=${configured:-256M} case "$PIVILION_MODE" in onion|hotspot) ;; *) echo "PIVILION_MODE must be onion or hotspot" >&2; return 1 ;; esac ssid_bytes=$(LC_ALL=C printf '%s' "$PIVILION_HOTSPOT_SSID" | wc -c) [ "$ssid_bytes" -ge 1 ] && [ "$ssid_bytes" -le 32 ] || { echo "PIVILION_HOTSPOT_SSID must contain 1-32 bytes" >&2; return 1; } - case "$PIVILION_HOME$PIVILION_DIR$PIVILION_WEBROOT$PIVILION_WIFI_INTERFACE$PIVILION_HOTSPOT_SSID" in + case "$PIVILION_HOME$PIVILION_DIR$PIVILION_WEBROOT$PIVILION_WIFI_INTERFACE$PIVILION_HOTSPOT_SSID$PIVILION_WIFI_SSID$PIVILION_WIFI_PSK" in *$'\n'*|*$'\r'*) echo "Invalid control character in Pivilion configuration" >&2; return 1 ;; esac case "$PIVILION_HOME:$PIVILION_DIR:$PIVILION_WEBROOT" in @@ -86,9 +102,32 @@ pivilion_load_config() { [ -z "$PIVILION_WIFI_INTERFACE" ] || [[ "$PIVILION_WIFI_INTERFACE" =~ ^[A-Za-z0-9._:-]+$ ]] || { echo "Invalid PIVILION_WIFI_INTERFACE" >&2; return 1; } + [[ "$PIVILION_WIFI_COUNTRY" =~ ^[A-Z]{2}$ ]] || { echo "PIVILION_WIFI_COUNTRY must be a two-letter uppercase country code" >&2; return 1; } + [[ "$PIVILION_HOTSPOT_CHANNEL" =~ ^[0-9]+$ ]] && [ "$PIVILION_HOTSPOT_CHANNEL" -ge 1 ] && [ "$PIVILION_HOTSPOT_CHANNEL" -le 13 ] || { + echo "PIVILION_HOTSPOT_CHANNEL must be between 1 and 13" >&2; return 1; + } + [[ "$PIVILION_HOSTNAME" =~ ^[A-Za-z0-9][A-Za-z0-9.-]{0,62}$ ]] || { echo "Invalid PIVILION_HOSTNAME" >&2; return 1; } + case "$PIVILION_GENERATOR_ENABLED" in yes|no) ;; *) echo "PIVILION_GENERATOR_ENABLED must be yes or no" >&2; return 1 ;; esac + [[ "$PIVILION_UPLOAD_MAX_SIZE" =~ ^[1-9][0-9]*M$ ]] || { echo "PIVILION_UPLOAD_MAX_SIZE must use a value such as 256M" >&2; return 1; } + if [ -n "$PIVILION_WIFI_PSK" ] && [[ ! "$PIVILION_WIFI_PSK" =~ ^[0-9A-Fa-f]{64}$ ]] && { [ "${#PIVILION_WIFI_PSK}" -lt 8 ] || [ "${#PIVILION_WIFI_PSK}" -gt 63 ]; }; then + echo "PIVILION_WIFI_PSK must be an 8-63 character password or 64 hexadecimal PSK" >&2; return 1 + fi + [ -z "$PIVILION_WIFI_PSK" ] || [ -n "$PIVILION_WIFI_SSID" ] || { echo "PIVILION_WIFI_SSID is required when a PSK is set" >&2; return 1; } export PIVILION_USER PIVILION_GROUP PIVILION_HOME PIVILION_DIR PIVILION_WEBROOT PIVILION_MODE PIVILION_HOTSPOT_SSID PIVILION_WIFI_INTERFACE + export PIVILION_WIFI_SSID PIVILION_WIFI_PSK PIVILION_WIFI_PSK_FOR_SSID PIVILION_WIFI_COUNTRY PIVILION_HOTSPOT_CHANNEL + export PIVILION_HOSTNAME PIVILION_GENERATOR_ENABLED PIVILION_UPLOAD_MAX_SIZE } +pivilion_boot_root() { + if [ -n "${PIVILION_BOOT_ROOT:-}" ]; then printf '%s\n' "$PIVILION_BOOT_ROOT"; return; fi + if command -v mountpoint >/dev/null 2>&1 && mountpoint -q /boot/firmware; then printf '/boot/firmware\n'; return; fi + if [ -d /boot/firmware ] && { [ -e /boot/firmware/config.txt ] || [ -e /boot/firmware/cmdline.txt ]; }; then printf '/boot/firmware\n'; return; fi + printf '/boot\n' +} + +pivilion_boot_config_path() { printf '%s/pivilion.conf\n' "$(pivilion_boot_root)"; } +pivilion_boot_error_path() { printf '%s/pivilion-error.txt\n' "$(pivilion_boot_root)"; } + pivilion_wifi_interface() { local iface if [ -n "${PIVILION_WIFI_INTERFACE:-}" ]; then printf '%s\n' "$PIVILION_WIFI_INTERFACE"; return; fi @@ -116,9 +155,7 @@ pivilion_set_mode() { } pivilion_current_mode() { - local mode - mode=$(head -n1 "$PIVILION_DIR/mode" 2>/dev/null || true) - case "$mode" in onion|hotspot) printf '%s\n' "$mode" ;; *) printf '%s\n' "$PIVILION_MODE" ;; esac + printf '%s\n' "$PIVILION_MODE" } pivilion_require_root() { diff --git a/usr/local/lib/pivilion/config-sync.sh b/usr/local/lib/pivilion/config-sync.sh new file mode 100644 index 0000000..af376e1 --- /dev/null +++ b/usr/local/lib/pivilion/config-sync.sh @@ -0,0 +1,286 @@ +#!/bin/bash +set -Eeuo pipefail + +source "${PIVILION_COMMON_PATH:-/usr/local/lib/pivilion/common.sh}" +pivilion_require_root + +BOOT_ROOT=$(pivilion_boot_root) +BOOT_CONFIG=$(pivilion_boot_config_path) +BOOT_ERROR=$(pivilion_boot_error_path) +LAST_GOOD=${PIVILION_LAST_GOOD:-/var/lib/pivilion/pivilion.conf.last-good} +LOCK_FILE=${PIVILION_CONFIG_LOCK:-/run/pivilion-config.lock} +ACTION=${1:-apply} + +install -d -m 0755 "$(dirname "$LOCK_FILE")" /var/lib/pivilion +exec 9>"$LOCK_FILE" +flock -x 9 + +fail_boot() { + local message=$1 tmp + printf 'Pivilion configuration error: %s\n' "$message" >&2 + if [ -d "$BOOT_ROOT" ] && [ -w "$BOOT_ROOT" ]; then + tmp=$(mktemp "$BOOT_ROOT/.pivilion-error.XXXXXX") + printf '%s\n' "$message" > "$tmp" + mv -f "$tmp" "$BOOT_ERROR" + sync -f "$BOOT_ROOT" 2>/dev/null || sync + fi +} + +require_writable_boot() { + [ -d "$BOOT_ROOT" ] && [ -w "$BOOT_ROOT" ] && touch "$BOOT_ROOT/.pivilion-write-test.$$" 2>/dev/null || { + fail_boot "The boot partition root $BOOT_ROOT is missing or read-only" + exit "$PIVILION_E_CONFIG" + } + rm -f "$BOOT_ROOT/.pivilion-write-test.$$" +} + +atomic_copy() { + local source=$1 destination=$2 mode=${3:-0600} tmp + install -d -m 0700 "$(dirname "$destination")" + tmp=$(mktemp "$(dirname "$destination")/.pivilion.XXXXXX") + install -m "$mode" "$source" "$tmp" + mv -f "$tmp" "$destination" +} + +derive_psk() { + local ssid=$1 password=$2 + if command -v wpa_passphrase >/dev/null 2>&1; then + wpa_passphrase "$ssid" "$password" | awk -F= '/^[[:space:]]*psk=[0-9a-f]{64}$/ {print $2; exit}' + elif command -v python3 >/dev/null 2>&1; then + printf '%s\n%s\n' "$ssid" "$password" | python3 -c 'import hashlib,sys; s=sys.stdin.buffer.readline().rstrip(b"\n"); p=sys.stdin.buffer.readline().rstrip(b"\n"); print(hashlib.pbkdf2_hmac("sha1",p,s,4096,32).hex())' + else + fail_boot "wpa_passphrase or python3 is required to sanitize the Wi-Fi password" + return 1 + fi +} + +detect_wifi() { + local connection file + [ -n "${PIVILION_WIFI_SSID:-}" ] && return + if command -v nmcli >/dev/null 2>&1; then + connection=$(nmcli -t -f NAME,TYPE connection show --active 2>/dev/null | awk -F: '$2 ~ /wireless|wifi/ {print $1; exit}') + if [ -n "$connection" ]; then + PIVILION_WIFI_SSID=$(nmcli -g 802-11-wireless.ssid connection show "$connection" 2>/dev/null || true) + PIVILION_WIFI_PSK=$(nmcli --show-secrets -g 802-11-wireless-security.psk connection show "$connection" 2>/dev/null || true) + fi + fi + if [ -z "${PIVILION_WIFI_SSID:-}" ]; then + for file in /etc/wpa_supplicant/pivilion.conf /etc/wpa_supplicant/wpa_supplicant.conf; do + [ -r "$file" ] || continue + PIVILION_WIFI_SSID=$(sed -n 's/^[[:space:]]*ssid="\(.*\)"/\1/p' "$file" | head -n1) + PIVILION_WIFI_PSK=$(sed -n 's/^[[:space:]]*psk="\(.*\)"/\1/p; s/^[[:space:]]*psk=\([0-9a-fA-F]\{64\}\)$/\1/p' "$file" | head -n1) + [ -n "$PIVILION_WIFI_SSID" ] && break + done + fi +} + +sanitize_wifi() { + local derived + [ -n "$PIVILION_WIFI_SSID" ] || { PIVILION_WIFI_PSK=""; PIVILION_WIFI_PSK_FOR_SSID=""; return; } + if [[ "$PIVILION_WIFI_PSK" =~ ^[0-9A-Fa-f]{64}$ ]]; then + if [ -n "$PIVILION_WIFI_PSK_FOR_SSID" ] && [ "$PIVILION_WIFI_PSK_FOR_SSID" != "$PIVILION_WIFI_SSID" ]; then + fail_boot "Wi-Fi SSID changed but its stored derived PSK belongs to a different SSID" + return 1 + fi + PIVILION_WIFI_PSK=${PIVILION_WIFI_PSK,,} + elif [ -n "$PIVILION_WIFI_PSK" ]; then + derived=$(derive_psk "$PIVILION_WIFI_SSID" "$PIVILION_WIFI_PSK") || return 1 + [ -n "$derived" ] || { fail_boot "Could not derive the Wi-Fi PSK"; return 1; } + PIVILION_WIFI_PSK=$derived + fi + PIVILION_WIFI_PSK_FOR_SSID=$PIVILION_WIFI_SSID +} + +render_config() { + local active onion error + active=$(pivilion_state_value active_mode none) + onion=$(tr -d '\r\n' < "$PIVILION_DIR/torname" 2>/dev/null || true) + error=$(pivilion_state_value failure none) + cat < "$tmp" + chmod 0600 "$tmp" + mv -f "$tmp" "$BOOT_CONFIG" + sync -f "$BOOT_ROOT" 2>/dev/null || sync + atomic_copy "$BOOT_CONFIG" "$LAST_GOOD" 0600 + atomic_copy "$BOOT_CONFIG" /run/pivilion/effective.conf 0600 + rm -f "$BOOT_ERROR" +} + +load_boot_or_detect() { + if [ -e "$BOOT_CONFIG" ]; then + PIVILION_CONFIG_PATHS=$BOOT_CONFIG pivilion_load_config || return 1 + else + pivilion_load_config + if [ -s "$PIVILION_DIR/mode" ]; then + case "$(head -n1 "$PIVILION_DIR/mode")" in onion|hotspot) PIVILION_MODE=$(head -n1 "$PIVILION_DIR/mode") ;; esac + fi + PIVILION_WIFI_INTERFACE=${PIVILION_WIFI_INTERFACE:-$(pivilion_wifi_interface || true)} + PIVILION_HOSTNAME=${PIVILION_HOSTNAME:-$(hostname -s)} + if command -v a2query >/dev/null 2>&1 && a2query -s 000-upload >/dev/null 2>&1; then PIVILION_GENERATOR_ENABLED=yes; fi + if command -v php >/dev/null 2>&1; then + detected_upload=$(php -r 'echo ini_get("upload_max_filesize");' 2>/dev/null || true) + [[ "$detected_upload" =~ ^[1-9][0-9]*M$ ]] && PIVILION_UPLOAD_MAX_SIZE=$detected_upload + fi + detect_wifi + fi + PIVILION_WIFI_INTERFACE=${PIVILION_WIFI_INTERFACE:-$(pivilion_wifi_interface || true)} + sanitize_wifi +} + +migrate_paths() { + local old_dir old_webroot common=${PIVILION_COMMON_PATH:-/usr/local/lib/pivilion/common.sh} + [ -r "$LAST_GOOD" ] || return 0 + old_dir=$(PIVILION_CONFIG_PATHS=$LAST_GOOD bash -c 'source "$1"; pivilion_load_config >/dev/null && printf %s "$PIVILION_DIR"' _ "$common" 2>/dev/null || true) + old_webroot=$(PIVILION_CONFIG_PATHS=$LAST_GOOD bash -c 'source "$1"; pivilion_load_config >/dev/null && printf %s "$PIVILION_WEBROOT"' _ "$common" 2>/dev/null || true) + if [ -n "$old_dir" ] && [ "$old_dir" != "$PIVILION_DIR" ] && [ -d "$old_dir" ] && [ ! -e "$PIVILION_DIR" ]; then + install -d -m 0755 -o "$PIVILION_USER" -g "$PIVILION_GROUP" "$PIVILION_DIR" + cp -a "$old_dir/." "$PIVILION_DIR/" + chown -R "$PIVILION_USER:$PIVILION_GROUP" "$PIVILION_DIR" + fi + if [ -n "$old_webroot" ] && [ "$old_webroot" != "$PIVILION_WEBROOT" ] && [ -d "$old_webroot" ] && [ ! -e "$PIVILION_WEBROOT" ]; then + install -d -m 2775 -o www-data -g www-data "$PIVILION_WEBROOT" + cp -a "$old_webroot/." "$PIVILION_WEBROOT/" + fi + if [ -n "$old_webroot" ] && [ "$old_webroot" != "$PIVILION_WEBROOT" ]; then + for file in /etc/apache2/sites-available/000-default.conf /etc/apache2/sites-available/000-upload.conf /etc/apache2/conf-available/pivilion.conf; do + [ -f "$file" ] || continue + sed "s|$old_webroot|$PIVILION_WEBROOT|g" "$file" > "$file.pivilion.tmp" + install -m 0644 "$file.pivilion.tmp" "$file" + rm -f "$file.pivilion.tmp" + done + fi +} + +apply_runtime() { + local php_version post_mb escaped_ssid + migrate_paths + if [ "$(hostname -s)" != "$PIVILION_HOSTNAME" ] && command -v hostnamectl >/dev/null 2>&1; then hostnamectl set-hostname "$PIVILION_HOSTNAME"; fi + command -v iw >/dev/null 2>&1 && iw reg set "$PIVILION_WIFI_COUNTRY" 2>/dev/null || true + if [ -n "$PIVILION_WIFI_SSID" ] && [ -n "$PIVILION_WIFI_PSK" ]; then + if command -v nmcli >/dev/null 2>&1 && { systemctl is-enabled --quiet NetworkManager 2>/dev/null || systemctl is-active --quiet NetworkManager 2>/dev/null; }; then + install -d -m 0700 /etc/NetworkManager/system-connections + cat > /etc/NetworkManager/system-connections/.pivilion-onion.tmp </dev/null 2>&1; then + install -d -m 0755 /etc/wpa_supplicant + escaped_ssid=${PIVILION_WIFI_SSID//\\/\\\\}; escaped_ssid=${escaped_ssid//\"/\\\"} + printf 'country=%s\nnetwork={\n ssid="%s"\n psk=%s\n}\n' "$PIVILION_WIFI_COUNTRY" "$escaped_ssid" "$PIVILION_WIFI_PSK" > /etc/wpa_supplicant/.pivilion.tmp + install -m 0600 /etc/wpa_supplicant/.pivilion.tmp /etc/wpa_supplicant/pivilion.conf + rm -f /etc/wpa_supplicant/.pivilion.tmp + fi + fi + install -d -m 0755 -o "$PIVILION_USER" -g "$PIVILION_GROUP" "$PIVILION_DIR" + printf '%s\n' "$PIVILION_MODE" | install -m 0644 -o "$PIVILION_USER" -g "$PIVILION_GROUP" /dev/stdin "$PIVILION_DIR/mode" + if command -v php >/dev/null 2>&1; then + printf "/dev/null 2>&1; then + php_version=$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;') + post_mb=$((${PIVILION_UPLOAD_MAX_SIZE%M} + 16)) + install -d -m 0755 "/etc/php/$php_version/apache2/conf.d" + printf 'upload_max_filesize = %s\npost_max_size = %sM\nmax_file_uploads = 100\nmax_execution_time = 300\nmemory_limit = 512M\n' "$PIVILION_UPLOAD_MAX_SIZE" "$post_mb" \ + | install -m 0644 /dev/stdin "/etc/php/$php_version/apache2/conf.d/99-pivilion.ini" + fi + if command -v a2ensite >/dev/null 2>&1; then + if [ "$PIVILION_GENERATOR_ENABLED" = yes ]; then a2ensite 000-upload.conf >/dev/null; else a2dissite 000-upload.conf >/dev/null 2>&1 || true; fi + fi +} + +set_value() { + local key=${2:-} value=${3-} tmp + case "$key" in + PIVILION_MODE|PIVILION_WIFI_SSID|PIVILION_WIFI_PSK|PIVILION_WIFI_PSK_FOR_SSID|PIVILION_WIFI_COUNTRY|PIVILION_WIFI_INTERFACE|PIVILION_HOTSPOT_SSID|PIVILION_HOTSPOT_CHANNEL|PIVILION_HOSTNAME|PIVILION_USER|PIVILION_HOME|PIVILION_DIR|PIVILION_WEBROOT|PIVILION_GENERATOR_ENABLED|PIVILION_UPLOAD_MAX_SIZE) ;; + *) echo "Unsupported configuration key: $key" >&2; exit "$PIVILION_E_CONFIG" ;; + esac + [ -r "$BOOT_CONFIG" ] || { echo "Boot configuration is not initialized." >&2; exit "$PIVILION_E_CONFIG"; } + tmp=$(mktemp) + awk -v key="$key" -v value="$value" 'BEGIN{done=0} $0 ~ "^" key "=" {if(!done){print key "=" value; done=1}; next} {print} END{if(!done) print key "=" value}' "$BOOT_CONFIG" > "$tmp" + PIVILION_CONFIG_PATHS=$tmp pivilion_load_config || { rm -f "$tmp"; exit "$PIVILION_E_CONFIG"; } + sanitize_wifi || { rm -f "$tmp"; exit "$PIVILION_E_CONFIG"; } + write_canonical + rm -f "$tmp" +} + +case "$ACTION" in + apply|initialize) + 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 + [ "$ACTION" = initialize ] || apply_runtime + exit 0 + fi + [ "$ACTION" = initialize ] || apply_runtime + write_canonical ;; + set) set_value "$@" ;; + set-wifi) + [ "$#" -eq 3 ] || { echo "Usage: config-sync.sh set-wifi SSID PASSWORD" >&2; exit 2; } + [ -r "$BOOT_CONFIG" ] || { echo "Boot configuration is not initialized." >&2; exit "$PIVILION_E_CONFIG"; } + PIVILION_CONFIG_PATHS=$BOOT_CONFIG pivilion_load_config || exit "$PIVILION_E_CONFIG" + case "$2$3" in *$'\n'*|*$'\r'*) echo "Wi-Fi values may not contain newlines" >&2; exit "$PIVILION_E_CONFIG" ;; esac + ssid_bytes=$(LC_ALL=C printf '%s' "$2" | wc -c) + [ "$ssid_bytes" -ge 1 ] && [ "$ssid_bytes" -le 32 ] || { echo "Wi-Fi SSID must contain 1-32 bytes" >&2; exit "$PIVILION_E_CONFIG"; } + PIVILION_WIFI_SSID=$2 + PIVILION_WIFI_PSK=$3 + PIVILION_WIFI_PSK_FOR_SSID=$2 + sanitize_wifi || exit "$PIVILION_E_CONFIG" + write_canonical + ;; + 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 ;; +esac diff --git a/usr/local/lib/pivilion/mode.sh b/usr/local/lib/pivilion/mode.sh index 5c565a2..26b14f7 100755 --- a/usr/local/lib/pivilion/mode.sh +++ b/usr/local/lib/pivilion/mode.sh @@ -118,7 +118,7 @@ interface=$IFACE driver=nl80211 ssid=$PIVILION_HOTSPOT_SSID hw_mode=g -channel=6 +channel=$PIVILION_HOTSPOT_CHANNEL auth_algs=1 wmm_enabled=0 EOF @@ -198,6 +198,7 @@ start_hotspot() { if [ "$BACKEND" = networkmanager ]; then 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" \ ipv4.method manual ipv4.addresses 10.1.1.1/24 ipv4.never-default yes ipv6.method disabled connection.autoconnect no nmcli connection up "$NM_HOTSPOT" else @@ -215,6 +216,7 @@ start_hotspot() { logger -t pivilion-mode "Hotspot active but degraded; run pivilion diagnose" || true 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" echo "Pivilion hotspot '$PIVILION_HOTSPOT_SSID' is active on $IFACE." } @@ -240,6 +242,7 @@ start_onion() { logger -t pivilion-mode "Onion mode active but degraded: ${failure:-diagnostics failed}" || true 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" echo "Pivilion onion mode activation completed." } From b5e19f08f6a1e5e404f9ca5f5c05fe49c19b30e1 Mon Sep 17 00:00:00 2001 From: v3d Date: Tue, 30 Jun 2026 17:02:54 +0200 Subject: [PATCH 2/2] 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"