#!/bin/bash set -e # Configuration ======================================================================================================= # # 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. # # Everything else (timers, paths) is not meant to be reconfigured per-install # and stays as plain constants. 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 REAL interfaces: physical ethernet, wifi and # cellular modems. Virtual interfaces (docker0, br-*, veth*, virbr*, tun/tap, # wireguard, vlan, bridges, bonds, ...) are detected automatically and never # count, so Docker/LXC/VM networking cannot keep the AP off. # 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// /}" # Extra interfaces to ignore when monitoring all (comma-separated, globs # allowed, e.g. "enx*,usb0"). Only needed for physical NICs you do not want # to count; virtual interfaces are already excluded. Has no effect when # MONITOR_IFACE names interfaces explicitly. IGNORE_IFACE="${FALLBACK_AP_IGNORE_IFACE-${SAVED_IGNORE_IFACE-}}" IGNORE_IFACE="${IGNORE_IFACE// /}" # What "online" means: # link - (default) a monitored interface is connected. An outage here is # local and fixable; an upstream outage is not our problem, so # the AP stays off. Fast and never flaps. # internet - a monitored interface is connected AND can ping a target # out of that specific interface (also 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 FALLBACK_AP_CIDR="${FALLBACK_AP_CIDR:-${SAVED_CIDR:-10.10.10.1/24}}" FALLBACK_AP_DHCP_START_OCTET="${FALLBACK_AP_DHCP_START_OCTET:-${SAVED_DHCP_START_OCTET:-100}}" FALLBACK_AP_DHCP_END_OCTET="${FALLBACK_AP_DHCP_END_OCTET:-${SAVED_DHCP_END_OCTET:-250}}" # Values derived from the CIDR / DHCP settings. A function, because the # `install` command-line flags can change those settings and need to # recompute these. compute_derived() { AP_CIDR="$FALLBACK_AP_CIDR" AP_NETWORK_BASE="${AP_CIDR%%/*}" # e.g. "10.10.10.1" out of "10.10.10.1/24" AP_NETWORK_BASE="${AP_NETWORK_BASE%.*}" # e.g. "10.10.10" DHCP_START="${AP_NETWORK_BASE}.${FALLBACK_AP_DHCP_START_OCTET}" DHCP_END="${AP_NETWORK_BASE}.${FALLBACK_AP_DHCP_END_OCTET}" } compute_derived # How often, while the AP has no clients, to stop the AP and give # NetworkManager a chance to reconnect to a known Wi-Fi network. RECOVERY_CHECK_INTERVAL=60 # How long to leave the AP down during a recovery attempt before giving # up and bringing the AP back. RECOVERY_WINDOW=20 # Small delay after changing wlan0 state, to let things settle before # 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" STATE_DIR="/var/lib/fallback-ap" LAST_WIFI_FILE="$STATE_DIR/last-wifi" LOCK_FILE="$STATE_DIR/lock" STATE_FILE="$STATE_DIR/state" # End of Configuration ================================================================================================ validate_check_mode() { case "$CHECK_MODE" in link|internet) ;; *) echo "Invalid check mode '$CHECK_MODE' (use: link | internet)" exit 1 ;; esac } validate_check_mode # Resolve the actual location of this script. SCRIPT_PATH="$(readlink -f "$0")" if [ -z "$SCRIPT_PATH" ] || [ ! -f "$SCRIPT_PATH" ]; then echo "Unable to determine script path." exit 1 fi log() { echo "[$(date '+%H:%M:%S')] $*" } set_state() { printf '%s\n' "$1" > "$STATE_FILE" } get_state() { cat "$STATE_FILE" 2>/dev/null || echo "unknown" } is_ap_on() { nmcli -t -f NAME connection show --active 2>/dev/null | grep -Fxq "$CONNECTION_NAME" } # --- 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 } # A "real" interface is backed by hardware. Linux exposes this as a `device` # link under /sys/class/net//, which physical NICs (PCI, USB, SDIO, # virtio, ...) have and virtual ones (docker0, veth*, br-*, tun*, wg*, vlan, # bridges, bonds, dummy) do not. Cellular modems are the exception: NetworkManager # names them after their control port (e.g. cdc-wdm0), which is not in # /sys/class/net, so gsm/cdma types are accepted as-is. is_real_interface() { local dev="$1" local type="$2" case "$type" in gsm|cdma) return 0 ;; esac [ -e "/sys/class/net/$dev/device" ] } # True if the device matches an entry of IGNORE_IFACE (globs allowed). is_ignored_interface() { local dev="$1" local pattern local -a patterns=() IFS=',' read -r -a patterns <<< "$IGNORE_IFACE" || true for pattern in "${patterns[@]}"; do if [ -n "$pattern" ] && [[ "$dev" == $pattern ]]; then return 0 fi done return 1 } # Prints (one per line) the monitored devices that NetworkManager reports as # connected, excluding the fallback AP connection itself. # - Blank MONITOR_IFACE: every real (hardware-backed) ethernet, wifi or # cellular device that is not in IGNORE_IFACE. # - 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 dev type local all=0 if monitor_all; then all=1 fi while IFS=: read -r dev type; do if [ -z "$dev" ]; then continue fi if [ "$all" -eq 1 ]; then case "$type" in ethernet|wifi|gsm|cdma) ;; *) continue ;; esac if ! is_real_interface "$dev" "$type"; then continue fi if is_ignored_interface "$dev"; then continue fi else case ",${MONITOR_IFACE}," in *",${dev},"*) ;; *) continue ;; esac fi echo "$dev" done < <( nmcli -t -f DEVICE,TYPE,STATE,CONNECTION device status 2>/dev/null | awk -F: -v ap="$CONNECTION_NAME" \ '$3 ~ /^connected/ && $4 != ap {print $1 ":" $2}' ) } # 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}' } remember_wifi() { local connection connection="$(get_wifi_connection)" [ -z "$connection" ] && return [ "$connection" = "$CONNECTION_NAME" ] && return mkdir -p "$STATE_DIR" if [ "$(cat "$LAST_WIFI_FILE" 2>/dev/null || true)" != "$connection" ]; then log "Remembering Wi-Fi connection: $connection" printf '%s\n' "$connection" > "$LAST_WIFI_FILE" fi } get_last_wifi() { [ -f "$LAST_WIFI_FILE" ] && cat "$LAST_WIFI_FILE" } get_client_count() { iw dev "$WLAN0_IFACE" station dump 2>/dev/null | grep -c '^Station ' || true } wait_for_ap_state() { local wanted="$1" local timeout="${2:-5}" for ((i=0; i/dev/null 2>&1 || { log "Failed to start AP" return 1 } if ! wait_for_ap_state on 5; then log "AP failed to become active" return 1 fi sleep "$STATE_SETTLE_DELAY" log "AP enabled" return 0 } stop_ap() { if ! is_ap_on; then return 0 fi log "Disabling AP" nmcli connection down "$CONNECTION_NAME" >/dev/null 2>&1 || true if ! wait_for_ap_state off 5; then log "AP failed to stop cleanly" return 1 fi sleep "$STATE_SETTLE_DELAY" 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" update_online "$now" if (( online )); then remember_wifi if is_ap_on; then log "Connectivity available, shutting down fallback AP" stop_ap || true fi set_state "online" last_attempt="$now" return fi if ! is_ap_on; then log "No connectivity on monitored interface(s), starting fallback AP" start_ap || true set_state "fallback" last_attempt="$now" return fi set_state "fallback" local clients clients="$(get_client_count)" if [ "$clients" -gt 0 ]; then last_attempt="$now" 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 || true fi } attempt_recovery() { log "No AP clients, checking for connectivity (AP down for up to ${RECOVERY_WINDOW}s)" set_state "checking" stop_ap || true if is_ap_on; then log "AP did not go down, aborting recovery attempt" start_ap || true set_state "fallback" return 1 fi if wait_for_online "$RECOVERY_WINDOW"; then sleep "$STATE_SETTLE_DELAY" if check_online; then log "Connectivity restored" remember_wifi online=1 fail_count=0 set_state "online" return 0 fi fi log "No connectivity available, restoring AP" start_ap || true set_state "fallback" return 1 } save_config() { mkdir -p "$(dirname "$CONFIG_FILE")" cat > "$CONFIG_FILE" < "$SERVICE_FILE" </dev/null 2>&1 || true nmcli connection add \ type wifi \ ifname "$WLAN0_IFACE" \ con-name "$CONNECTION_NAME" \ autoconnect no \ ssid "$AP_NAME" nmcli connection modify "$CONNECTION_NAME" \ connection.autoconnect no \ connection.autoconnect-priority -999 \ connection.autoconnect-retries 0 \ 802-11-wireless.mode ap \ 802-11-wireless.powersave 2 \ ipv4.method shared \ ipv4.addresses "$AP_CIDR" \ ipv4.shared-dhcp-range "$DHCP_START,$DHCP_END" \ ipv6.method disabled case "$AP_ENCRYPTION" in none) nmcli connection modify "$CONNECTION_NAME" \ wifi-sec.key-mgmt none ;; wpa-psk) if [ "${#AP_PASSWORD}" -lt 8 ] || [ "${#AP_PASSWORD}" -gt 63 ]; then echo "WPA-PSK password must be between 8 and 63 characters." exit 1 fi nmcli connection modify "$CONNECTION_NAME" \ wifi-sec.key-mgmt wpa-psk \ wifi-sec.psk "$AP_PASSWORD" ;; sae) if [ "${#AP_PASSWORD}" -lt 8 ]; then echo "WPA3-SAE password must be at least 8 characters." exit 1 fi nmcli connection modify "$CONNECTION_NAME" \ wifi-sec.key-mgmt sae \ wifi-sec.psk "$AP_PASSWORD" ;; *) echo "Unsupported encryption: $AP_ENCRYPTION" exit 1 ;; esac } uninstall() { if [ "$EUID" -ne 0 ]; then echo "Run this script as root." exit 1 fi log "Uninstalling fallback AP" systemctl disable --now "$SERVICE_NAME" >/dev/null 2>&1 || true nmcli connection down "$CONNECTION_NAME" >/dev/null 2>&1 || true nmcli connection delete "$CONNECTION_NAME" >/dev/null 2>&1 || true rm -f "$SERVICE_FILE" rm -f "$CONFIG_FILE" rm -rf "$STATE_DIR" systemctl daemon-reload log "Fallback AP removed" } start() { mkdir -p "$STATE_DIR" ( flock -n 9 || exit 1 start_ap ) 9>"$LOCK_FILE" } stop() { mkdir -p "$STATE_DIR" ( flock -n 9 || exit 1 stop_ap ) 9>"$LOCK_FILE" } status() { echo echo "=== Service ===" systemctl status "$SERVICE_NAME" --no-pager || true echo echo "=== Devices ===" nmcli device status echo echo "=== Active Connections ===" nmcli connection show --active echo echo "=== Saved Wi-Fi Profiles ===" nmcli -f NAME,TYPE,AUTOCONNECT,AUTOCONNECT-PRIORITY connection show echo echo "=== Fallback AP ===" nmcli connection show "$CONNECTION_NAME" 2>/dev/null || echo "Not installed" echo echo "=== Config ($CONFIG_FILE) ===" echo "SSID: $AP_NAME" echo "AP interface: $WLAN0_IFACE" echo "Monitored interfaces: ${MONITOR_IFACE:-all real interfaces}" if monitor_all && [ -n "$IGNORE_IFACE" ]; then echo "Ignored interfaces: $IGNORE_IFACE" fi echo "Check mode: $CHECK_MODE" if [ "$CHECK_MODE" = "internet" ]; then echo "Ping targets: $CHECK_TARGETS" fi echo "Network: $AP_CIDR" echo "DHCP: $DHCP_START - $DHCP_END" echo echo "=== Connectivity ($CHECK_MODE) ===" local dev found=0 while read -r dev; do if [ -z "$dev" ]; then continue fi found=1 if [ "$CHECK_MODE" = "internet" ]; then if iface_has_internet "$dev"; then echo "$dev: internet OK" else echo "$dev: connected, NO internet" fi else echo "$dev: connected" fi done < <(get_monitored_devices) if [ "$found" -eq 0 ]; then echo "No monitored interface is connected" fi echo echo "=== Connected but NOT counted (virtual / ignored / unmonitored) ===" local monitored monitored="$(get_monitored_devices)" nmcli -t -f DEVICE,STATE,CONNECTION device status 2>/dev/null | awk -F: -v ap="$CONNECTION_NAME" \ '$2 ~ /^connected/ && $3 != ap {print $1}' | while read -r dev; do if ! grep -Fxq "$dev" <<< "$monitored"; then echo "$dev" fi done echo echo "=== Last Known Wi-Fi ===" get_last_wifi || echo "None" echo echo "=== State ===" get_state echo echo "=== AP Clients ===" get_client_count echo echo "=== Script ===" echo "$SCRIPT_PATH" } run() { # These are shared with tick/update_online/attempt_recovery through # dynamic scoping. local last_attempt=0 local last_online_check=0 local fail_count=0 local online=0 local now mkdir -p "$STATE_DIR" log "Fallback AP service started" log "Script: $SCRIPT_PATH" log "AP interface: $WLAN0_IFACE | monitoring: ${MONITOR_IFACE:-all real interfaces} | mode: $CHECK_MODE" exec 9>"$LOCK_FILE" flock 9 if check_online; then online=1 remember_wifi set_state "online" log "Connectivity already available" else 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 now="$(date +%s)" if flock -n 9; then tick "$now" flock -u 9 fi sleep 1 done } if [ "$EUID" -ne 0 ]; then echo "Run this script as root." exit 1 fi case "${1:-}" in install) shift install "$@" ;; uninstall) uninstall ;; start) start ;; stop) stop ;; status) status ;; run) run ;; *) echo "Usage: $SCRIPT_PATH {install [options]|uninstall|start|stop|status|run}" echo " $SCRIPT_PATH install --help for install options" exit 1 ;; esac