From 14b08ed70be6493926de3120725e33879875dada Mon Sep 17 00:00:00 2001 From: Zakary Timson Date: Mon, 26 Oct 2026 15:31:26 -0400 Subject: [PATCH] Update fallback-ap.sh --- fallback-ap.sh | 309 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 263 insertions(+), 46 deletions(-) diff --git a/fallback-ap.sh b/fallback-ap.sh index 9769ba8..c8ab983 100644 --- a/fallback-ap.sh +++ b/fallback-ap.sh @@ -4,8 +4,9 @@ set -e # Configuration ======================================================================================================= # -# Connection settings (SSID, passphrase, subnet, DHCP range, interface) can -# be supplied three ways, highest priority first: +# Connection settings (SSID, passphrase, subnet, DHCP range, interface, +# monitor interfaces, check mode) can be supplied three ways, highest +# priority first: # 1. Environment variables (FALLBACK_AP_*) - used for headless installs. # 2. The persisted config file below, written by `install`. # 3. The hard-coded defaults in this block. @@ -16,8 +17,27 @@ set -e CONFIG_FILE="/etc/fallback-ap.conf" if [ -f "$CONFIG_FILE" ]; then source "$CONFIG_FILE"; fi +# The interface the AP is created on. The AP is NEVER created on any other +# interface, regardless of what is being monitored. WLAN0_IFACE="${FALLBACK_AP_IFACE:-${SAVED_IFACE:-wlan0}}" +# Which interfaces to watch for connectivity. If any of them is online, the +# AP stays off. Comma-separated list (e.g. "eth0,wlan1,wwan0"). +# Leave BLANK to monitor ALL interfaces (ethernet, wifi, gsm, cdma, bond, vlan). +# Note the missing colon in the expansion: an explicitly empty +# FALLBACK_AP_MONITOR_IFACE="" overrides a value saved in the config file. +MONITOR_IFACE="${FALLBACK_AP_MONITOR_IFACE-${SAVED_MONITOR_IFACE-}}" +MONITOR_IFACE="${MONITOR_IFACE// /}" + +# What "online" means: +# link - a monitored interface is connected (fast, never flaps) +# internet - a monitored interface is connected AND can ping a target +# out of that specific interface (detects dead uplinks) +CHECK_MODE="${FALLBACK_AP_CHECK_MODE:-${SAVED_CHECK_MODE:-link}}" + +# Hosts pinged (space-separated, any one answering counts) in internet mode. +CHECK_TARGETS="${FALLBACK_AP_CHECK_TARGETS:-${SAVED_CHECK_TARGETS:-1.1.1.1 8.8.8.8}}" + AP_NAME="${FALLBACK_AP_SSID:-${SAVED_SSID:-$HOSTNAME}}" AP_PASSWORD="${FALLBACK_AP_PASSWORD:-${SAVED_PASSWORD:-abcd1234}}" AP_ENCRYPTION="${FALLBACK_AP_ENCRYPTION:-${SAVED_ENCRYPTION:-wpa-psk}}" # none | wpa-psk | sae @@ -44,6 +64,13 @@ RECOVERY_WINDOW=20 # we query it again. STATE_SETTLE_DELAY=1 +# Internet mode only: how often to run the ping checks, how many consecutive +# failed checks are needed before declaring "offline" (prevents the AP from +# flapping on a single dropped ping), and the per-ping timeout in seconds. +ONLINE_CHECK_INTERVAL=5 +FAIL_THRESHOLD=3 +PING_TIMEOUT=2 + CONNECTION_NAME="Fallback AP" SERVICE_NAME="fallback-ap" SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service" @@ -56,6 +83,14 @@ STATE_FILE="$STATE_DIR/state" # End of Configuration ================================================================================================ +case "$CHECK_MODE" in + link|internet) ;; + *) + echo "Invalid FALLBACK_AP_CHECK_MODE '$CHECK_MODE' (use: link | internet)" + exit 1 + ;; +esac + # Resolve the actual location of this script. SCRIPT_PATH="$(readlink -f "$0")" @@ -86,17 +121,150 @@ is_ap_on() { } -is_wifi_connected() { - nmcli -t -f DEVICE,TYPE,STATE,CONNECTION device status 2>/dev/null | - awk -F: -v iface="$WLAN0_IFACE" -v ap="$CONNECTION_NAME" \ - '$1 == iface && $2 == "wifi" && $3 == "connected" && $4 != ap {found=1} END {exit !found}' +# --- Connectivity monitoring ----------------------------------------------------------------------------------------- + +# True if no monitor interface is configured (= monitor everything). +monitor_all() { + [ -z "$MONITOR_IFACE" ] } +# True if the AP interface is among the monitored interfaces. If it is not, +# a "recovery attempt" (taking the AP down to let wlan0 reconnect to Wi-Fi) +# could never change the result, so it is skipped. +monitors_wlan0() { + if monitor_all; then + return 0 + fi + + case ",${MONITOR_IFACE}," in + *",${WLAN0_IFACE},"*) return 0 ;; + esac + + return 1 +} + + +# Prints (one per line) the monitored devices that NetworkManager reports as +# connected, excluding the fallback AP connection itself. +# - Blank MONITOR_IFACE: every device of a real network type. +# - Otherwise: only the devices named in MONITOR_IFACE (any type). +# The state is matched with a prefix because nmcli reports values such as +# "connected (externally)" or "connected (site only)". +get_monitored_devices() { + local all=0 + + if monitor_all; then + all=1 + fi + + nmcli -t -f DEVICE,TYPE,STATE,CONNECTION device status 2>/dev/null | + awk -F: -v ap="$CONNECTION_NAME" -v list=",${MONITOR_IFACE}," -v all="$all" ' + $3 ~ /^connected/ && $4 != ap { + if (all) { + if ($2 ~ /^(ethernet|wifi|gsm|cdma|bond|vlan)$/) print $1 + } else if (index(list, "," $1 ",")) { + print $1 + } + }' +} + + +# Can this specific interface reach the internet? Pings are forced out of the +# given interface (-I) so another interface cannot answer on its behalf. +iface_has_internet() { + local dev="$1" + local target + + for target in $CHECK_TARGETS; do + if ping -I "$dev" -c 1 -W "$PING_TIMEOUT" "$target" >/dev/null 2>&1; then + return 0 + fi + done + + return 1 +} + + +# Is ANY monitored interface online right now (according to CHECK_MODE)? +check_online() { + local dev + + while read -r dev; do + if [ -z "$dev" ]; then + continue + fi + + if [ "$CHECK_MODE" = "link" ]; then + return 0 + fi + + if iface_has_internet "$dev"; then + return 0 + fi + done < <(get_monitored_devices) + + return 1 +} + + +# Debounced wrapper around check_online, used by the main loop. Updates the +# `online`, `fail_count` and `last_online_check` variables of run(). +# link mode: checked every tick, no debounce (cheap). +# internet mode: checked every ONLINE_CHECK_INTERVAL seconds, and only +# declared offline after FAIL_THRESHOLD consecutive failures. +# Coming back online is detected on the first success. +update_online() { + local now="$1" + local interval=0 + local threshold=1 + + if [ "$CHECK_MODE" = "internet" ]; then + interval="$ONLINE_CHECK_INTERVAL" + threshold="$FAIL_THRESHOLD" + fi + + if (( now - last_online_check < interval )); then + return 0 + fi + + last_online_check="$now" + + if check_online; then + fail_count=0 + online=1 + else + fail_count=$((fail_count + 1)) + + if (( fail_count >= threshold )); then + online=0 + fi + fi +} + + +wait_for_online() { + local timeout="$1" + local i + + for ((i=0; i/dev/null | awk -F: -v iface="$WLAN0_IFACE" \ - '$1 == iface && $2 == "wifi" && $3 == "connected" {print $4; exit}' + '$1 == iface && $2 == "wifi" && $3 ~ /^connected/ {print $4; exit}' } @@ -148,27 +316,14 @@ wait_for_ap_state() { } -wait_for_wifi() { - local timeout="$1" - - for ((i=0; i/dev/null 2>&1 || { log "Failed to start AP" @@ -207,23 +362,32 @@ stop_ap() { return 0 } + +# --- Main loop logic ------------------------------------------------------------------------------------------------- + +# Called once per second from run(). Relies on run()'s local variables +# (last_attempt, online, fail_count, last_online_check) via dynamic scoping. tick() { local now="$1" - if is_wifi_connected; then + update_online "$now" + + if (( online )); then remember_wifi if is_ap_on; then - stop_ap + log "Connectivity available, shutting down fallback AP" + stop_ap || true fi - set_state "wifi" + set_state "online" last_attempt="$now" return fi if ! is_ap_on; then - start_ap + log "No connectivity on monitored interface(s), starting fallback AP" + start_ap || true set_state "fallback" last_attempt="$now" return @@ -239,14 +403,20 @@ tick() { return fi + # Recovery only makes sense if wlan0 is one of the monitored interfaces: + # it works by briefly freeing wlan0 so it can reconnect to known Wi-Fi. + if ! monitors_wlan0; then + return + fi + if (( now - last_attempt >= RECOVERY_CHECK_INTERVAL )); then last_attempt="$now" - attempt_recovery + attempt_recovery || true fi } attempt_recovery() { - log "No AP clients, checking for known Wi-Fi (AP down for up to ${RECOVERY_WINDOW}s)" + log "No AP clients, checking for connectivity (AP down for up to ${RECOVERY_WINDOW}s)" set_state "checking" @@ -254,25 +424,27 @@ attempt_recovery() { if is_ap_on; then log "AP did not go down, aborting recovery attempt" - start_ap + start_ap || true set_state "fallback" return 1 fi - if wait_for_wifi "$RECOVERY_WINDOW"; then + if wait_for_online "$RECOVERY_WINDOW"; then sleep "$STATE_SETTLE_DELAY" - if is_wifi_connected; then - log "Wi-Fi reconnected" + if check_online; then + log "Connectivity restored" remember_wifi - set_state "wifi" + online=1 + fail_count=0 + set_state "online" return 0 fi fi - log "No Wi-Fi available, restoring AP" + log "No connectivity available, restoring AP" - start_ap + start_ap || true set_state "fallback" return 1 @@ -283,6 +455,9 @@ save_config() { mkdir -p "$(dirname "$CONFIG_FILE")" cat > "$CONFIG_FILE" <"$LOCK_FILE" flock 9 - if is_wifi_connected; then + if check_online; then + online=1 remember_wifi - set_state "wifi" - log "Wi-Fi already connected" + set_state "online" + log "Connectivity already available" else - log "No Wi-Fi connection, starting fallback AP" - start_ap + log "No connectivity on monitored interface(s), starting fallback AP" + start_ap || true set_state "fallback" fi + last_online_check="$(date +%s)" + flock -u 9 while true; do