mirror of
https://github.com/alexhopeoconnor/WiFiManager.git
synced 2026-10-04 02:48:13 +10:00
455 lines
18 KiB
Bash
Executable File
455 lines
18 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# shellcheck source=tools/lib/harness-locks.sh
|
|
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/harness-locks.sh"
|
|
# Shared host-side helpers for the WiFiManager portal hardware test harness.
|
|
# They never modify a network interface other than the explicit client adapter.
|
|
|
|
wm_portal_state_root() {
|
|
printf '%s/wifimanager-portal-hardware' "${XDG_STATE_HOME:-$HOME/.local/state}"
|
|
}
|
|
|
|
wm_require() {
|
|
command -v "$1" >/dev/null 2>&1 || {
|
|
echo "Required command not found: $1" >&2
|
|
return 1
|
|
}
|
|
}
|
|
|
|
wm_nmcli_permission() {
|
|
local permission="$1"
|
|
awk -F: -v permission="$permission" '$1 == permission { print $2; exit }' \
|
|
<<<"${WM_NMCLI_PERMISSIONS:-}"
|
|
}
|
|
|
|
wm_prepare_networkmanager_authorization() {
|
|
# A GUI Polkit agent can authorize direct nmcli actions. An SSH/headless
|
|
# shell has no such agent on many Linux hosts, even for a sudo-capable
|
|
# developer. Resolve that host-side boundary before an erase/upload, then
|
|
# use one fixed command path rather than suppressing an unauthorized scan
|
|
# and later misreporting an SSID timeout.
|
|
local permission value direct=yes
|
|
case "${WM_NMCLI_AUTH:-auto}" in
|
|
auto|direct|sudo) ;;
|
|
*)
|
|
echo 'WM_NMCLI_AUTH must be auto, direct, or sudo.' >&2
|
|
return 2
|
|
;;
|
|
esac
|
|
[[ "${WM_NMCLI_AUTH_READY:-no}" == yes ]] && return 0
|
|
|
|
WM_NMCLI_PERMISSIONS="$(nmcli -t -f PERMISSION,VALUE general permissions 2>/dev/null || true)"
|
|
for permission in \
|
|
org.freedesktop.NetworkManager.wifi.scan \
|
|
org.freedesktop.NetworkManager.network-control \
|
|
org.freedesktop.NetworkManager.settings.modify.system; do
|
|
value="$(wm_nmcli_permission "$permission")"
|
|
[[ "$value" == yes ]] || direct=no
|
|
done
|
|
|
|
case "${WM_NMCLI_AUTH:-auto}" in
|
|
direct)
|
|
WM_NMCLI_MODE=direct
|
|
;;
|
|
sudo)
|
|
WM_NMCLI_MODE=sudo
|
|
;;
|
|
auto)
|
|
if [[ "$direct" == yes ]]; then
|
|
WM_NMCLI_MODE=direct
|
|
# A session D-Bus socket is common over SSH but does not itself
|
|
# provide a graphical Polkit agent, so require an actual display.
|
|
elif [[ -z "${DISPLAY:-}" && -z "${WAYLAND_DISPLAY:-}" ]]; then
|
|
WM_NMCLI_MODE=sudo
|
|
else
|
|
# Give a graphical Polkit agent the chance to authorize the
|
|
# action. A failure is reported verbatim by wm_nmcli.
|
|
WM_NMCLI_MODE=direct
|
|
fi
|
|
;;
|
|
esac
|
|
|
|
if [[ "$WM_NMCLI_MODE" == sudo ]]; then
|
|
command -v sudo >/dev/null 2>&1 || {
|
|
echo 'NetworkManager requires authorization, but sudo is unavailable. Use a graphical Polkit session or install/configure sudo.' >&2
|
|
return 1
|
|
}
|
|
echo 'NetworkManager requires scoped authorization for the named portal adapter; validating sudo before the board is flashed.' >&2
|
|
sudo -v || {
|
|
echo 'Could not validate sudo for the scoped NetworkManager portal actions.' >&2
|
|
return 1
|
|
}
|
|
fi
|
|
WM_NMCLI_AUTH_READY=yes
|
|
export WM_NMCLI_MODE WM_NMCLI_AUTH_READY WM_NMCLI_PERMISSIONS
|
|
}
|
|
|
|
wm_report_networkmanager_authorization() {
|
|
local permission value direct=yes
|
|
WM_NMCLI_PERMISSIONS="$(nmcli -t -f PERMISSION,VALUE general permissions 2>/dev/null || true)"
|
|
for permission in \
|
|
org.freedesktop.NetworkManager.wifi.scan \
|
|
org.freedesktop.NetworkManager.network-control \
|
|
org.freedesktop.NetworkManager.settings.modify.system; do
|
|
value="$(wm_nmcli_permission "$permission")"
|
|
[[ "$value" == yes ]] || direct=no
|
|
done
|
|
if [[ "$direct" == yes ]]; then
|
|
echo 'NetworkManager portal authorization: direct.'
|
|
elif [[ -z "${DISPLAY:-}" && -z "${WAYLAND_DISPLAY:-}" ]]; then
|
|
echo 'NetworkManager portal authorization: scoped sudo will be requested before a portal command flashes the board.'
|
|
else
|
|
echo 'NetworkManager portal authorization: graphical Polkit may authorize actions; set WM_NMCLI_AUTH=sudo to use scoped sudo instead.'
|
|
fi
|
|
}
|
|
|
|
wm_nmcli() {
|
|
# Only this small set of nmcli calls is elevated when the preflight selects
|
|
# sudo. The runner, artifacts, and session record remain owned by the
|
|
# invoking developer; every mutating call still names the guarded adapter
|
|
# or a generated temporary connection.
|
|
if [[ "${WM_NMCLI_MODE:-direct}" == sudo ]]; then
|
|
sudo -n true || {
|
|
echo 'The sudo authorization for scoped NetworkManager actions expired; run sudo -v and retry the portal command.' >&2
|
|
return 1
|
|
}
|
|
sudo -n -- nmcli "$@"
|
|
else
|
|
nmcli "$@"
|
|
fi
|
|
}
|
|
|
|
wm_default_route_interface() {
|
|
ip route show default 2>/dev/null | awk '/^default/{print $5; exit}'
|
|
}
|
|
|
|
wm_acquire_hardware_lock() {
|
|
# All ordinary ESP portals use this gateway/subnet. Keep portal commands
|
|
# mutually exclusive even when they name different boards or adapters.
|
|
wm_harness_lock_portal_network
|
|
}
|
|
|
|
wm_require_client_adapter() {
|
|
local interface="$1" allow_takeover="$2" default_interface device_type active_connection
|
|
ip link show "$interface" >/dev/null 2>&1 || {
|
|
echo "Wi-Fi interface not found: $interface" >&2
|
|
return 1
|
|
}
|
|
default_interface="$(wm_default_route_interface)"
|
|
[[ "$interface" != "$default_interface" ]] || {
|
|
echo "Refusing to use the host default-route interface: $interface" >&2
|
|
return 1
|
|
}
|
|
# A non-default Ethernet, tunnel, or virtual interface can otherwise look
|
|
# harmless here and reach board flashing before the first Wi-Fi scan
|
|
# fails. Ask NetworkManager through the already-selected authorization
|
|
# path, so a denied inspection cannot be mistaken for a usable adapter.
|
|
if ! device_type="$(wm_nmcli -g GENERAL.TYPE device show "$interface" 2>/dev/null)"; then
|
|
echo "NetworkManager could not determine the selected portal adapter type: $interface" >&2
|
|
return 1
|
|
fi
|
|
[[ "$device_type" == "wifi" ]] || {
|
|
echo "Client adapter is not Wi-Fi: $interface ($device_type)." >&2
|
|
return 1
|
|
}
|
|
wm_harness_lock_wifi_adapter "$interface"
|
|
if ! active_connection="$(wm_nmcli -g GENERAL.CONNECTION device show "$interface" 2>/dev/null)"; then
|
|
echo "NetworkManager could not inspect the selected portal adapter: $interface" >&2
|
|
return 1
|
|
fi
|
|
if [[ -n "$active_connection" && "$active_connection" != "--" && "$allow_takeover" != "yes" ]]; then
|
|
echo "Client adapter $interface already has connection '$active_connection'." >&2
|
|
echo "Pass --take-over-client-adapter to replace only that adapter's connection." >&2
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
wm_portal_ssid() {
|
|
case "$1" in
|
|
esp8266) printf '%s\n' 'WM Test Harness ESP8266' ;;
|
|
esp32) printf '%s\n' 'WM Test Harness ESP32' ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
wm_wait_for_portal_ssid() {
|
|
local interface="$1" ssid="$2" attempt advertised
|
|
if ! wm_nmcli device wifi rescan ifname "$interface"; then
|
|
echo "NetworkManager could not scan the selected portal adapter: $interface" >&2
|
|
return 1
|
|
fi
|
|
for attempt in $(seq 1 45); do
|
|
if ! advertised="$(wm_nmcli -t -f SSID device wifi list ifname "$interface")"; then
|
|
echo "NetworkManager could not read Wi-Fi scan results from $interface." >&2
|
|
return 1
|
|
fi
|
|
if grep -Fxq "$ssid" <<<"$advertised"; then
|
|
return 0
|
|
fi
|
|
sleep 1
|
|
if ! wm_nmcli device wifi rescan ifname "$interface"; then
|
|
echo "NetworkManager could not refresh Wi-Fi scan results from $interface." >&2
|
|
return 1
|
|
fi
|
|
done
|
|
echo "Portal SSID not detected on $interface: $ssid" >&2
|
|
return 1
|
|
}
|
|
|
|
wm_remove_connection_by_name() {
|
|
local name="$1"
|
|
[[ -n "$name" ]] || return 0
|
|
wm_nmcli connection down "$name" >/dev/null 2>&1 || true
|
|
if ! wm_nmcli connection delete "$name"; then
|
|
# A pending recovery record can survive an uncatchable exit before
|
|
# `connection add` ran. Only a successful complete listing which does
|
|
# not contain this generated name proves that there is nothing left to
|
|
# remove; an authorization or NetworkManager query failure must retain
|
|
# the record for an explicit later `down` command.
|
|
if wm_connection_name_is_absent "$name"; then
|
|
return 0
|
|
fi
|
|
wm_report_portal_connection_cleanup_failure
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
wm_connection_name_is_absent() {
|
|
local name="$1" names
|
|
if ! names="$(wm_nmcli -t -f NAME connection show 2>/dev/null)"; then
|
|
return 1
|
|
fi
|
|
! grep -Fxq -- "$name" <<<"$names"
|
|
}
|
|
|
|
wm_connection_uuid_is_absent() {
|
|
local uuid="$1" uuids
|
|
if ! uuids="$(wm_nmcli -t -f UUID connection show 2>/dev/null)"; then
|
|
return 1
|
|
fi
|
|
! grep -Fxq -- "$uuid" <<<"$uuids"
|
|
}
|
|
|
|
wm_report_portal_connection_cleanup_failure() {
|
|
echo 'Could not remove the temporary WiFiManager portal connection. Its recovery state was retained; restore NetworkManager authorization and run ./tools/portal-hardware down.' >&2
|
|
}
|
|
|
|
wm_create_portal_connection() {
|
|
local interface="$1" ssid="$2" password="$3" platform="${4:-}" reconnect_after_drop="${5:-no}" name uuid
|
|
case "$reconnect_after_drop" in
|
|
yes|no) ;;
|
|
*)
|
|
echo "Portal connection reconnect policy must be yes or no." >&2
|
|
return 2
|
|
;;
|
|
esac
|
|
name="wifimanager-portal-${RANDOM}-$(date +%s)"
|
|
# Publish the owned name before the first NetworkManager mutation. The
|
|
# caller's signal trap can then remove it throughout the pending-to-active
|
|
# state transition.
|
|
WM_PORTAL_CONNECTION_NAME="$name"
|
|
WM_PORTAL_CONNECTION_UUID=""
|
|
WM_PORTAL_CONNECTION_OWNED=yes
|
|
export WM_PORTAL_CONNECTION_UUID WM_PORTAL_CONNECTION_NAME WM_PORTAL_CONNECTION_OWNED
|
|
# A pending record is atomically committed before the first NetworkManager
|
|
# mutation. If the host dies after that point, `down` can remove this
|
|
# exact name even before NetworkManager's UUID has been obtained.
|
|
if [[ -n "$platform" ]] && ! wm_write_state "$interface" "$platform" "" "$name"; then
|
|
WM_PORTAL_CONNECTION_OWNED=no
|
|
unset WM_PORTAL_CONNECTION_UUID WM_PORTAL_CONNECTION_NAME
|
|
return 1
|
|
fi
|
|
wm_nmcli device disconnect "$interface" >/dev/null 2>&1 || true
|
|
if ! wm_wait_for_portal_ssid "$interface" "$ssid"; then
|
|
# No `connection add` has run on this path, so this process knows that
|
|
# its pending record has no NetworkManager profile to remove. Clear it
|
|
# directly rather than requiring a privileged delete of a profile that
|
|
# cannot exist.
|
|
if ! wm_clear_state; then
|
|
wm_report_portal_connection_cleanup_failure
|
|
return 1
|
|
fi
|
|
WM_PORTAL_CONNECTION_OWNED=no
|
|
unset WM_PORTAL_CONNECTION_UUID WM_PORTAL_CONNECTION_NAME
|
|
return 1
|
|
fi
|
|
if ! wm_nmcli connection add type wifi ifname "$interface" con-name "$name" ssid "$ssid" \
|
|
ipv4.method auto ipv4.never-default yes ipv6.method ignore connection.autoconnect no >/dev/null; then
|
|
wm_cleanup_created_connection
|
|
return 1
|
|
fi
|
|
# NetworkManager creates the UUID at `connection add`, before any later
|
|
# configuration or association step. Capture it immediately so cleanup
|
|
# has an exact identifier throughout the remaining critical section.
|
|
uuid="$(wm_nmcli -g connection.uuid connection show "$name")"
|
|
if [[ -z "$uuid" || "$uuid" == "--" ]]; then
|
|
wm_cleanup_created_connection
|
|
echo "NetworkManager did not return a UUID for the portal connection." >&2
|
|
return 1
|
|
fi
|
|
WM_PORTAL_CONNECTION_UUID="$uuid"
|
|
export WM_PORTAL_CONNECTION_UUID
|
|
# Atomically replace the pending record with the exact UUID before
|
|
# association. A normal signal trap removes it immediately; the durable
|
|
# record also makes `down` useful after an uncatchable host termination.
|
|
if [[ -n "$platform" ]] && ! wm_write_state "$interface" "$platform" "$uuid" "$name"; then
|
|
wm_cleanup_created_connection
|
|
return 1
|
|
fi
|
|
if ! wm_nmcli connection modify "$name" wifi-sec.key-mgmt wpa-psk wifi-sec.psk "$password"; then
|
|
wm_cleanup_created_connection
|
|
return 1
|
|
fi
|
|
# The ordinary portal runner must leave its disposable connection inert so
|
|
# it cannot surprise a developer later. The OTA runner is different: the
|
|
# board intentionally disappears after POST /u, then returns as the same
|
|
# AP, so its one owned connection needs to reassociate autonomously for the
|
|
# browser and host-side B checks. Cleanup still deletes this exact profile.
|
|
if [[ "$reconnect_after_drop" == yes ]] && ! wm_nmcli connection modify "$name" connection.autoconnect yes; then
|
|
wm_cleanup_created_connection
|
|
return 1
|
|
fi
|
|
if ! wm_nmcli connection up "$name" ifname "$interface"; then
|
|
wm_cleanup_created_connection
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
wm_verify_portal_route() {
|
|
local interface="$1" route
|
|
route="$(ip route get 192.168.4.1 2>/dev/null || true)"
|
|
[[ "$route" == *" dev $interface "* ]] || {
|
|
echo "Portal route does not use the selected adapter: $route" >&2
|
|
return 1
|
|
}
|
|
}
|
|
|
|
wm_remove_connection() {
|
|
local uuid="${1:-}" name="${2:-}"
|
|
[[ -n "$uuid" || -n "$name" ]] || return 0
|
|
if [[ -n "$uuid" ]]; then
|
|
wm_nmcli connection down uuid "$uuid" >/dev/null 2>&1 || true
|
|
if ! wm_nmcli connection delete uuid "$uuid"; then
|
|
# An active UUID means this runner did create a profile. Retain
|
|
# the exact recovery state unless the same authorized
|
|
# NetworkManager view positively proves another actor already
|
|
# removed it. A failed listing (including an expired scoped sudo
|
|
# ticket) is never treated as absence.
|
|
if ! wm_connection_uuid_is_absent "$uuid"; then
|
|
wm_report_portal_connection_cleanup_failure
|
|
return 1
|
|
fi
|
|
fi
|
|
elif [[ -n "$name" ]]; then
|
|
wm_remove_connection_by_name "$name" || return 1
|
|
fi
|
|
}
|
|
|
|
wm_cleanup_created_connection() {
|
|
# Only remove the connection this process named. This is intentionally
|
|
# separate from `finish_portal_session`, which reads a retained state file
|
|
# for an explicit later `down` command.
|
|
[[ "${WM_PORTAL_CONNECTION_OWNED:-no}" == "yes" ]] || return 0
|
|
wm_remove_connection "${WM_PORTAL_CONNECTION_UUID:-}" "${WM_PORTAL_CONNECTION_NAME:-}" || return 1
|
|
if ! wm_clear_state; then
|
|
echo 'The temporary WiFiManager portal connection was removed, but its recovery state could not be cleared.' >&2
|
|
return 1
|
|
fi
|
|
WM_PORTAL_CONNECTION_OWNED=no
|
|
unset WM_PORTAL_CONNECTION_UUID WM_PORTAL_CONNECTION_NAME
|
|
}
|
|
|
|
wm_state_file() {
|
|
printf '%s/session.env\n' "$(wm_portal_state_root)"
|
|
}
|
|
|
|
wm_require_no_active_session() {
|
|
local file
|
|
file="$(wm_state_file)"
|
|
[[ ! -e "$file" ]] || {
|
|
echo "An existing WiFiManager portal session is recorded; run ./tools/portal-hardware down first." >&2
|
|
return 1
|
|
}
|
|
}
|
|
|
|
wm_write_state() {
|
|
local interface="$1" platform="$2" uuid="$3" name="$4" root file temporary_file state
|
|
root="$(wm_portal_state_root)"
|
|
file="$(wm_state_file)"
|
|
install -d -m 700 "$root"
|
|
if [[ -n "$uuid" ]]; then
|
|
state="active"
|
|
else
|
|
state="pending"
|
|
fi
|
|
temporary_file="$(mktemp "$root/.session.env.XXXXXX")" || return 1
|
|
if ! {
|
|
printf 'WM_PORTAL_STATE=%s\nWM_PORTAL_INTERFACE=%s\nWM_PORTAL_PLATFORM=%s\nWM_PORTAL_CONNECTION_UUID=%s\nWM_PORTAL_CONNECTION_NAME=%s\n' \
|
|
"$state" "$interface" "$platform" "$uuid" "$name"
|
|
} >"$temporary_file"; then
|
|
rm -f "$temporary_file"
|
|
return 1
|
|
fi
|
|
chmod 600 "$temporary_file"
|
|
mv -f "$temporary_file" "$file"
|
|
}
|
|
|
|
wm_load_state() {
|
|
local file key value
|
|
file="$(wm_state_file)"
|
|
[[ -f "$file" ]] || {
|
|
echo "No active WiFiManager portal session was found." >&2
|
|
return 1
|
|
}
|
|
WM_PORTAL_INTERFACE=""
|
|
WM_PORTAL_PLATFORM=""
|
|
WM_PORTAL_CONNECTION_UUID=""
|
|
WM_PORTAL_CONNECTION_NAME=""
|
|
WM_PORTAL_STATE=""
|
|
while IFS='=' read -r key value; do
|
|
case "$key" in
|
|
WM_PORTAL_STATE|WM_PORTAL_INTERFACE|WM_PORTAL_PLATFORM|WM_PORTAL_CONNECTION_UUID|WM_PORTAL_CONNECTION_NAME)
|
|
printf -v "$key" '%s' "$value"
|
|
;;
|
|
'') ;;
|
|
*)
|
|
echo "Invalid WiFiManager portal session state." >&2
|
|
return 1
|
|
;;
|
|
esac
|
|
done <"$file"
|
|
[[ -n "$WM_PORTAL_INTERFACE" && -n "$WM_PORTAL_PLATFORM" && -n "$WM_PORTAL_CONNECTION_NAME" ]] || {
|
|
echo "Incomplete WiFiManager portal session state." >&2
|
|
return 1
|
|
}
|
|
# State files written before this recovery format carried an exact UUID
|
|
# but no phase. Continue to accept those retained sessions as active.
|
|
if [[ -z "$WM_PORTAL_STATE" && -n "$WM_PORTAL_CONNECTION_UUID" ]]; then
|
|
WM_PORTAL_STATE="active"
|
|
fi
|
|
case "$WM_PORTAL_STATE" in
|
|
pending)
|
|
[[ -z "$WM_PORTAL_CONNECTION_UUID" ]] || {
|
|
echo "Invalid WiFiManager pending portal session state." >&2
|
|
return 1
|
|
}
|
|
;;
|
|
active)
|
|
[[ -n "$WM_PORTAL_CONNECTION_UUID" ]] || {
|
|
echo "Incomplete WiFiManager active portal session state." >&2
|
|
return 1
|
|
}
|
|
;;
|
|
*)
|
|
echo "Invalid WiFiManager portal session state." >&2
|
|
return 1
|
|
;;
|
|
esac
|
|
export WM_PORTAL_STATE WM_PORTAL_INTERFACE WM_PORTAL_PLATFORM WM_PORTAL_CONNECTION_UUID WM_PORTAL_CONNECTION_NAME
|
|
}
|
|
|
|
wm_clear_state() {
|
|
local file
|
|
file="$(wm_state_file)"
|
|
rm -f -- "$file"
|
|
}
|