generated from ztimson/template
Merge pull request 'Add fallback-ap.sh' (#1) from ztimson-patch-1 into master
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
560
fallback-ap.sh
Normal file
560
fallback-ap.sh
Normal file
@@ -0,0 +1,560 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration =======================================================================================================
|
||||
|
||||
WLAN0_IFACE="wlan0"
|
||||
|
||||
AP_NAME="${HOSTNAME}"
|
||||
AP_PASSWORD='guesswhatitis'
|
||||
AP_ENCRYPTION="wpa-psk" # none | wpa-psk | sae
|
||||
|
||||
AP_CIDR="10.10.10.1/24"
|
||||
DHCP_START="10.10.10.100"
|
||||
DHCP_END="10.10.10.250"
|
||||
|
||||
# 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
|
||||
|
||||
CONNECTION_NAME="Fallback AP"
|
||||
SERVICE_NAME="pi-fallback-ap"
|
||||
SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service"
|
||||
|
||||
STATE_DIR="/var/lib/pi-fallback-ap"
|
||||
LAST_WIFI_FILE="$STATE_DIR/last-wifi"
|
||||
LOCK_FILE="$STATE_DIR/lock"
|
||||
STATE_FILE="$STATE_DIR/state"
|
||||
|
||||
# End of Configuration ================================================================================================
|
||||
|
||||
|
||||
# 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"
|
||||
}
|
||||
|
||||
|
||||
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}'
|
||||
}
|
||||
|
||||
|
||||
get_wifi_connection() {
|
||||
nmcli -t -f DEVICE,TYPE,STATE,CONNECTION device status 2>/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<timeout*10; i++)); do
|
||||
if [ "$wanted" = "on" ] && is_ap_on; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$wanted" = "off" ] && ! is_ap_on; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
|
||||
wait_for_wifi() {
|
||||
local timeout="$1"
|
||||
|
||||
for ((i=0; i<timeout*10; i++)); do
|
||||
if is_wifi_connected; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
|
||||
start_ap() {
|
||||
if is_ap_on; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "Enabling AP: $AP_NAME"
|
||||
|
||||
nmcli connection up "$CONNECTION_NAME" ifname "$WLAN0_IFACE" >/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
|
||||
}
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# One tick of the control loop. Called once per second, always while
|
||||
# holding the wlan0 lock, so everything here runs to completion before
|
||||
# the next tick starts - there is no interleaving to race against.
|
||||
#
|
||||
# `last_attempt` is intentionally not declared `local` here: it lives in
|
||||
# run()'s scope and this function updates it directly (bash resolves it
|
||||
# via the caller's scope as long as nothing else shadows it).
|
||||
# ==========================================================================
|
||||
tick() {
|
||||
local now="$1"
|
||||
|
||||
if is_wifi_connected; then
|
||||
remember_wifi
|
||||
|
||||
if is_ap_on; then
|
||||
stop_ap
|
||||
fi
|
||||
|
||||
set_state "wifi"
|
||||
last_attempt="$now"
|
||||
return
|
||||
fi
|
||||
|
||||
if ! is_ap_on; then
|
||||
start_ap
|
||||
set_state "fallback"
|
||||
last_attempt="$now"
|
||||
return
|
||||
fi
|
||||
|
||||
set_state "fallback"
|
||||
|
||||
local clients
|
||||
clients="$(get_client_count)"
|
||||
|
||||
if [ "$clients" -gt 0 ]; then
|
||||
# Someone is using the AP right now. Leave it alone and push the
|
||||
# next recovery attempt back so we don't kick them off.
|
||||
last_attempt="$now"
|
||||
return
|
||||
fi
|
||||
|
||||
if (( now - last_attempt >= RECOVERY_CHECK_INTERVAL )); then
|
||||
last_attempt="$now"
|
||||
attempt_recovery
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
# Stop the AP, give NetworkManager a window to autoconnect to any known
|
||||
# network it can see, and bring the AP back if that doesn't happen.
|
||||
attempt_recovery() {
|
||||
log "No AP clients, checking for known Wi-Fi (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
|
||||
set_state "fallback"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if wait_for_wifi "$RECOVERY_WINDOW"; then
|
||||
sleep "$STATE_SETTLE_DELAY"
|
||||
|
||||
if is_wifi_connected; then
|
||||
log "Wi-Fi reconnected"
|
||||
remember_wifi
|
||||
set_state "wifi"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
log "No Wi-Fi available, restoring AP"
|
||||
|
||||
start_ap
|
||||
set_state "fallback"
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
|
||||
install() {
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Run this script as root."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Installing NetworkManager fallback AP"
|
||||
|
||||
apt-get update
|
||||
apt-get install -y network-manager iw
|
||||
|
||||
systemctl enable NetworkManager
|
||||
systemctl start NetworkManager
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
if is_wifi_connected; then
|
||||
remember_wifi
|
||||
fi
|
||||
|
||||
create_connection
|
||||
|
||||
log "Creating recovery service"
|
||||
|
||||
cat > "$SERVICE_FILE" <<EOF
|
||||
[Unit]
|
||||
Description=NetworkManager Fallback AP
|
||||
After=NetworkManager.service
|
||||
Wants=NetworkManager.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=$SCRIPT_PATH run
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
chmod 644 "$SERVICE_FILE"
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable "$SERVICE_NAME"
|
||||
systemctl restart "$SERVICE_NAME"
|
||||
|
||||
log "Installation complete"
|
||||
log "Script: $SCRIPT_PATH"
|
||||
log "SSID: $AP_NAME"
|
||||
log "Interface: $WLAN0_IFACE"
|
||||
log "Network: $AP_CIDR"
|
||||
log "DHCP: $DHCP_START - $DHCP_END"
|
||||
log "Encryption: $AP_ENCRYPTION"
|
||||
log "Recovery check interval: ${RECOVERY_CHECK_INTERVAL}s"
|
||||
log "Recovery window: ${RECOVERY_WINDOW}s"
|
||||
log "Last Wi-Fi: $(get_last_wifi || echo none)"
|
||||
|
||||
echo
|
||||
nmcli device status
|
||||
}
|
||||
|
||||
|
||||
create_connection() {
|
||||
log "Creating NetworkManager AP connection..."
|
||||
|
||||
nmcli connection delete "$CONNECTION_NAME" >/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 -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 "=== 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() {
|
||||
local last_attempt=0
|
||||
local now
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
log "Fallback AP service started"
|
||||
log "Script: $SCRIPT_PATH"
|
||||
|
||||
# Open the lock fd once for the life of the process. Using `flock`
|
||||
# directly against this fd (instead of wrapping each tick in its own
|
||||
# `( ... ) 9>"$LOCK_FILE"` subshell) matters: any variable a tick
|
||||
# updates - like last_attempt below - has to survive into the next
|
||||
# loop iteration, and a subshell would silently throw those updates
|
||||
# away when it exits.
|
||||
exec 9>"$LOCK_FILE"
|
||||
|
||||
flock 9
|
||||
|
||||
if is_wifi_connected; then
|
||||
remember_wifi
|
||||
set_state "wifi"
|
||||
log "Wi-Fi already connected"
|
||||
else
|
||||
log "No Wi-Fi connection, starting fallback AP"
|
||||
start_ap
|
||||
set_state "fallback"
|
||||
fi
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user