#!/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 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 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}}" 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}" # 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 ================================================================================================ 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")" 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 } # 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}' } 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}" 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 "=== 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} | 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) install ;; uninstall) uninstall ;; start) start ;; stop) stop ;; status) status ;; run) run ;; *) echo "Usage: $SCRIPT_PATH {install|uninstall|start|stop|status|run}" exit 1 ;; esac