From 3340108acda4e2ea43b1e8bde9a1055bed5261ea Mon Sep 17 00:00:00 2001 From: v3d Date: Wed, 1 Jul 2026 18:33:13 +0200 Subject: [PATCH] Fix NetworkManager hotspot readiness --- install.sh | 5 +- tests/run.sh | 8 +++ tests/test-networkmanager.sh | 63 ++++++++++++++++++++++++ usr/local/lib/pivilion/mode.sh | 11 ++++- usr/local/lib/pivilion/networkmanager.sh | 53 ++++++++++++++++++++ 5 files changed, 136 insertions(+), 4 deletions(-) create mode 100755 tests/test-networkmanager.sh create mode 100644 usr/local/lib/pivilion/networkmanager.sh diff --git a/install.sh b/install.sh index 5631f84..d36984b 100755 --- a/install.sh +++ b/install.sh @@ -174,7 +174,8 @@ deploy_files() { local php_version 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" "$SOURCE_DIR/usr/local/lib/pivilion/hostname.sh" /usr/local/lib/pivilion/ + install -m 0644 "$SOURCE_DIR/usr/local/lib/pivilion/common.sh" "$SOURCE_DIR/usr/local/lib/pivilion/hostname.sh" \ + "$SOURCE_DIR/usr/local/lib/pivilion/networkmanager.sh" /usr/local/lib/pivilion/ 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/ @@ -319,7 +320,7 @@ 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/hostname.sh \ + /usr/local/lib/pivilion/config-sync.sh /usr/local/lib/pivilion/hostname.sh /usr/local/lib/pivilion/networkmanager.sh \ /usr/local/lib/pivilion/issue-banner.sh /etc/systemd/system/pivilion-mode.service \ /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 diff --git a/tests/run.sh b/tests/run.sh index d3bcaac..223ec42 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -79,6 +79,12 @@ same_boot_mode_contract() { grep -q 'wait_for_diagnostics onion' "$ROOT/usr/local/lib/pivilion/mode.sh" } +networkmanager_startup_contract() { + grep -q 'pivilion_nm_prepare_interface "$IFACE"' "$ROOT/usr/local/lib/pivilion/mode.sh" && + grep -q 'pivilion_nm_connection_up "$NM_HOTSPOT" "$IFACE"' "$ROOT/usr/local/lib/pivilion/mode.sh" && + grep -q 'nmcli connection up "$connection" ifname "$iface"' "$ROOT/usr/local/lib/pivilion/networkmanager.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" @@ -95,6 +101,8 @@ 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 "NetworkManager software-radio and delayed-device handling" "$ROOT/tests/test-networkmanager.sh" +run "NetworkManager hotspot startup contract" networkmanager_startup_contract run "ShellCheck (when installed)" shellcheck_scripts [ "$failures" -eq 0 ] || { echo "$failures test group(s) failed." >&2; exit 1; } diff --git a/tests/test-networkmanager.sh b/tests/test-networkmanager.sh new file mode 100755 index 0000000..b3f01f7 --- /dev/null +++ b/tests/test-networkmanager.sh @@ -0,0 +1,63 @@ +#!/bin/bash +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +mkdir "$TMP/bin" +export MOCK_NM_LOG="$TMP/nmcli.log" +export MOCK_NM_STATE="$TMP/nm-state" +export MOCK_NM_READS="$TMP/nm-reads" + +cat > "$TMP/bin/nmcli" <<'EOF' +#!/bin/bash +printf '%s\n' "$*" >> "$MOCK_NM_LOG" +case "$*" in + 'radio wifi on') printf 'on\n' > "$MOCK_NM_STATE" ;; + 'device set wlan0 managed yes') ;; + '-g GENERAL.STATE device show wlan0') + [ "$(cat "$MOCK_NM_STATE")" = on ] || { echo '20 (unavailable)'; exit 0; } + reads=$(cat "$MOCK_NM_READS" 2>/dev/null || echo 0) + reads=$((reads + 1)); printf '%s\n' "$reads" > "$MOCK_NM_READS" + if [ "$reads" -lt "${MOCK_NM_AVAILABLE_AFTER:-3}" ]; then echo '20 (unavailable)'; else echo '30 (disconnected)'; fi ;; + 'radio') echo 'WIFI-HW WIFI'; echo 'enabled enabled' ;; + 'device status') echo 'DEVICE TYPE STATE'; echo 'wlan0 wifi unavailable' ;; + '-f GENERAL.STATE,GENERAL.REASON device show wlan0') echo 'GENERAL.STATE: 20 (unavailable)'; echo 'GENERAL.REASON: 2 (unknown)' ;; + 'connection up pivilion-hotspot ifname wlan0') ;; +esac +EOF +cat > "$TMP/bin/rfkill" <<'EOF' +#!/bin/sh +echo '0: phy0: Wireless LAN' +echo 'Soft blocked: no' +echo 'Hard blocked: no' +EOF +cat > "$TMP/bin/sleep" <<'EOF' +#!/bin/sh +exit 0 +EOF +chmod +x "$TMP/bin/"* +PATH="$TMP/bin:$PATH" +export PATH + +source "$ROOT/usr/local/lib/pivilion/networkmanager.sh" + +# The mock starts software-disabled and becomes available after a delay. +PIVILION_NM_READY_TIMEOUT=5 pivilion_nm_prepare_interface wlan0 +[ "$(cat "$MOCK_NM_STATE")" = on ] +[ "$(cat "$MOCK_NM_READS")" -eq 3 ] +sed -n '1p' "$MOCK_NM_LOG" | grep -Fqx 'radio wifi on' +sed -n '2p' "$MOCK_NM_LOG" | grep -Fqx 'device set wlan0 managed yes' +pivilion_nm_connection_up pivilion-hotspot wlan0 +grep -Fqx 'connection up pivilion-hotspot ifname wlan0' "$MOCK_NM_LOG" + +# A device that remains unavailable fails within the configured bound and logs +# the complete NetworkManager/rfkill snapshot. +: > "$MOCK_NM_LOG" +: > "$MOCK_NM_READS" +if MOCK_NM_AVAILABLE_AFTER=99 PIVILION_NM_READY_TIMEOUT=2 pivilion_nm_prepare_interface wlan0 >"$TMP/timeout.log" 2>&1; then exit 1; fi +grep -q 'Timed out after 2s' "$TMP/timeout.log" +grep -q '^-- nmcli radio --$' "$TMP/timeout.log" +grep -q '^-- nmcli device status --$' "$TMP/timeout.log" +grep -q '^-- wlan0 state and reason --$' "$TMP/timeout.log" +grep -q '^-- rfkill Wi-Fi state --$' "$TMP/timeout.log" diff --git a/usr/local/lib/pivilion/mode.sh b/usr/local/lib/pivilion/mode.sh index eb39e7f..f3c614d 100755 --- a/usr/local/lib/pivilion/mode.sh +++ b/usr/local/lib/pivilion/mode.sh @@ -2,6 +2,7 @@ set -Eeuo pipefail source /usr/local/lib/pivilion/common.sh +source /usr/local/lib/pivilion/networkmanager.sh pivilion_require_root pivilion_load_config || exit "$PIVILION_E_CONFIG" @@ -60,6 +61,7 @@ activation_error() { if [ "$existing" = none ] || [ "$existing" = unknown ]; then pivilion_write_state none "$BACKEND" failed "Activation failed at line $line (exit $rc)" || true fi + if [ "$BACKEND" = networkmanager ]; then pivilion_nm_log_diagnostics "$IFACE"; 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" @@ -208,6 +210,7 @@ update_torname() { start_hotspot() { pivilion_write_state none "$BACKEND" activating none + if [ "$BACKEND" = networkmanager ]; then pivilion_nm_prepare_interface "$IFACE"; fi validate_ap_support || { pivilion_write_state none "$BACKEND" failed "$IFACE does not advertise Wi-Fi AP support" refresh_login_banner @@ -235,7 +238,7 @@ start_hotspot() { 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" + pivilion_nm_connection_up "$NM_HOTSPOT" "$IFACE" else command -v dhcpcd >/dev/null 2>&1 && dhcpcd -k "$IFACE" 2>/dev/null || true systemctl stop "wpa_supplicant@$IFACE.service" 2>/dev/null || true @@ -259,10 +262,14 @@ start_hotspot() { start_onion() { local failure="" pivilion_write_state none "$BACKEND" activating none + if [ "$BACKEND" = networkmanager ]; then pivilion_nm_prepare_interface "$IFACE"; fi cleanup_hotspot rm -f "$CAPTIVE_LIVE" if [ "$BACKEND" = networkmanager ]; then - nmcli connection up pivilion-onion >/dev/null 2>&1 || failure="No saved Pivilion Wi-Fi connection; run pikey" + nmcli connection up pivilion-onion >/dev/null 2>&1 || { + pivilion_nm_log_diagnostics "$IFACE" + failure="No saved Pivilion Wi-Fi connection; run pikey" + } else systemctl restart dhcpcd 2>/dev/null || true systemctl restart "wpa_supplicant@$IFACE.service" 2>/dev/null || systemctl restart wpa_supplicant 2>/dev/null || true diff --git a/usr/local/lib/pivilion/networkmanager.sh b/usr/local/lib/pivilion/networkmanager.sh new file mode 100644 index 0000000..d1ca11f --- /dev/null +++ b/usr/local/lib/pivilion/networkmanager.sh @@ -0,0 +1,53 @@ +#!/bin/bash + +pivilion_nm_log_diagnostics() { + local iface=$1 + { + echo "NetworkManager Wi-Fi diagnostics for $iface:" + echo "-- nmcli radio --" + LC_ALL=C nmcli radio || true + echo "-- nmcli device status --" + LC_ALL=C nmcli device status || true + echo "-- $iface state and reason --" + LC_ALL=C nmcli -f GENERAL.STATE,GENERAL.REASON device show "$iface" || true + echo "-- rfkill Wi-Fi state --" + rfkill list wifi || rfkill list || true + } >&2 +} + +pivilion_nm_prepare_interface() { + local iface=$1 timeout=${PIVILION_NM_READY_TIMEOUT:-30} state i + + if ! nmcli radio wifi on; then + echo "NetworkManager could not enable the Wi-Fi software radio" >&2 + pivilion_nm_log_diagnostics "$iface" + return 1 + fi + if ! nmcli device set "$iface" managed yes; then + echo "NetworkManager could not mark $iface as managed" >&2 + pivilion_nm_log_diagnostics "$iface" + return 1 + fi + + for ((i = 0; i < timeout; i++)); do + state=$(LC_ALL=C nmcli -g GENERAL.STATE device show "$iface" 2>/dev/null || true) + case "$state" in + *unmanaged*|*unavailable*|'10 '*|'20 '*|'') ;; + *) return 0 ;; + esac + sleep 1 + done + + echo "Timed out after ${timeout}s waiting for $iface to leave the unavailable/unmanaged state" >&2 + pivilion_nm_log_diagnostics "$iface" + return 1 +} + +pivilion_nm_connection_up() { + local connection=$1 iface=$2 + if ! nmcli connection up "$connection" ifname "$iface"; then + echo "NetworkManager could not activate $connection on $iface" >&2 + pivilion_nm_log_diagnostics "$iface" + return 1 + fi +}