#!/bin/zsh # Detect a captive portal on macOS and surface its sign-in page. # # This script deliberately does not click or submit the portal's acceptance # form. Portal forms vary, and automatically bypassing an operator's session # policy may violate its terms. set -u set -o pipefail readonly SCRIPT_NAME="${0:t}" CHECK_INTERVAL="${CHECK_INTERVAL:-20}" FAIL_THRESHOLD="${FAIL_THRESHOLD:-2}" RECOVERY_THRESHOLD="${RECOVERY_THRESHOLD:-2}" PORTAL_COOLDOWN="${PORTAL_COOLDOWN:-300}" CURL_TIMEOUT="${CURL_TIMEOUT:-7}" LOG_FILE="${LOG_FILE:-$HOME/Library/Logs/captive-portal-watch.log}" OPEN_PORTAL=1 RUN_ONCE=0 VERBOSE=0 usage() { cat < && "$1" -gt 0 ]] } while (( $# > 0 )); do case "$1" in --once) RUN_ONCE=1 ;; --no-open) OPEN_PORTAL=0 ;; --verbose) VERBOSE=1 ;; --interval) shift if (( $# == 0 )) || ! is_positive_integer "$1"; then print -u2 "Error: --interval requires a positive integer." exit 2 fi CHECK_INTERVAL="$1" ;; --help|-h) usage exit 0 ;; *) print -u2 "Error: unknown option: $1" usage >&2 exit 2 ;; esac shift done for setting_name in CHECK_INTERVAL FAIL_THRESHOLD RECOVERY_THRESHOLD PORTAL_COOLDOWN CURL_TIMEOUT; do setting_value="${(P)setting_name}" if ! is_positive_integer "$setting_value"; then print -u2 "Error: $setting_name must be a positive integer." exit 2 fi done log_dir="${LOG_FILE:h}" if ! mkdir -p "$log_dir" 2>/dev/null; then LOG_FILE="/tmp/captive-portal-watch-${UID}.log" print -u2 "Warning: using fallback log file $LOG_FILE" fi log() { local level="$1" shift local line line="$(date '+%Y-%m-%d %H:%M:%S') [$level] $*" # Logs go to stderr so command substitutions can safely return state values # on stdout without verbose diagnostics corrupting them. print -u2 -r -- "$line" print -r -- "$line" >> "$LOG_FILE" 2>/dev/null || true } notify() { local title="$1" local message="$2" /usr/bin/osascript \ -e 'on run argv' \ -e 'display notification (item 2 of argv) with title (item 1 of argv)' \ -e 'end run' \ "$title" "$message" >/dev/null 2>&1 || true } find_wifi_device() { /usr/sbin/networksetup -listallhardwareports 2>/dev/null | /usr/bin/awk ' /^Hardware Port: (Wi-Fi|AirPort)$/ { getline if ($1 == "Device:") { print $2 exit } } ' } wifi_link_state() { local device="$1" if [[ -z "$device" ]]; then print "unknown" return fi if ! /sbin/ifconfig "$device" 2>/dev/null | /usr/bin/grep -q "status: active"; then print "disconnected" return fi if ! /usr/sbin/ipconfig getifaddr "$device" >/dev/null 2>&1; then print "disconnected" return fi print "connected" } # Each probe is URL|expected status|optional exact response body. # Requiring two successful, independent probes reduces false alarms when one # connectivity-check service is temporarily blocked or unavailable. readonly -a PROBES=( "http://connectivitycheck.gstatic.com/generate_204|204|" "http://www.msftconnecttest.com/connecttest.txt|200|Microsoft Connect Test" "http://captive.apple.com/hotspot-detect.html|200|SuccessSuccess" ) probe_endpoint() { local spec="$1" local url expected_status expected_body actual_status body_file url="${spec%%|*}" spec="${spec#*|}" expected_status="${spec%%|*}" expected_body="${spec#*|}" body_file="$(mktemp -t captive-portal-watch.XXXXXX)" || return 1 actual_status=$( /usr/bin/curl \ --silent \ --output "$body_file" \ --write-out '%{http_code}' \ --connect-timeout 3 \ --max-time "$CURL_TIMEOUT" \ --max-redirs 0 \ --user-agent "CaptivePortalWatch/1.0 macOS" \ "$url" 2>/dev/null ) local curl_exit=$? if (( VERBOSE )); then log "DEBUG" "Probe ${url}: curl_exit=${curl_exit}, HTTP=${actual_status:-000}" fi if (( curl_exit != 0 )) || [[ "$actual_status" != "$expected_status" ]]; then rm -f "$body_file" return 1 fi if [[ -n "$expected_body" ]]; then local actual_body actual_body="$(<"$body_file")" rm -f "$body_file" [[ "$actual_body" == "$expected_body" ]] return fi rm -f "$body_file" return 0 } connectivity_state() { local successes=0 local probe for probe in "${PROBES[@]}"; do if probe_endpoint "$probe"; then (( successes += 1 )) fi done if (( successes >= 2 )); then print "online" elif (( successes == 1 )); then print "uncertain" else print "intercepted" fi } open_portal() { # A plain HTTP URL is intentional: captive portals commonly intercept it and # redirect the browser to their local sign-in page. /usr/bin/open "http://captive.apple.com/hotspot-detect.html" >/dev/null 2>&1 || /usr/bin/open "http://neverssl.com/" >/dev/null 2>&1 || log "WARN" "Could not open a portal-discovery page." } wifi_device="$(find_wifi_device)" if [[ -n "$wifi_device" ]]; then log "INFO" "Watching Wi-Fi device $wifi_device every ${CHECK_INTERVAL}s." else log "WARN" "Could not identify a Wi-Fi device; connectivity checks will continue." fi failure_count=0 recovery_count=0 portal_active=0 last_portal_open=0 last_reported_state="" cleanup() { log "INFO" "Watcher stopped." exit 0 } trap cleanup INT TERM HUP while true; do link_state="$(wifi_link_state "$wifi_device")" if [[ "$link_state" == "disconnected" ]]; then current_state="wifi-disconnected" else current_state="$(connectivity_state)" fi if (( RUN_ONCE )); then log "RESULT" "$current_state" case "$current_state" in online) exit 0 ;; uncertain) exit 3 ;; intercepted) exit 4 ;; wifi-disconnected) exit 5 ;; *) exit 6 ;; esac fi case "$current_state" in online) failure_count=0 (( recovery_count += 1 )) if (( portal_active && recovery_count >= RECOVERY_THRESHOLD )); then portal_active=0 log "INFO" "Internet access restored." notify "Wi-Fi restored" "Internet access is working again." elif [[ "$last_reported_state" != "online" && $portal_active -eq 0 ]]; then log "INFO" "Internet access is online." fi ;; uncertain) # A single successful probe is inconclusive. Do not increase either # counter, preventing a flaky endpoint from rapidly changing state. recovery_count=0 if [[ "$last_reported_state" != "uncertain" ]]; then log "WARN" "Connectivity is uncertain; waiting for another check." fi ;; intercepted) recovery_count=0 (( failure_count += 1 )) if (( failure_count >= FAIL_THRESHOLD )); then now="$(date +%s)" cooldown_elapsed=$(( now - last_portal_open )) if (( ! portal_active )); then portal_active=1 log "WARN" "Captive portal or internet interception detected." notify "Captive portal detected" "The Wi-Fi sign-in page needs attention." fi if (( OPEN_PORTAL && cooldown_elapsed >= PORTAL_COOLDOWN )); then log "INFO" "Opening the captive-portal sign-in page." open_portal last_portal_open="$now" fi fi ;; wifi-disconnected) failure_count=0 recovery_count=0 portal_active=0 if [[ "$last_reported_state" != "wifi-disconnected" ]]; then log "WARN" "Wi-Fi is disconnected; waiting for it to reconnect." notify "Wi-Fi disconnected" "Reconnect to Wi-Fi to resume portal monitoring." fi ;; esac last_reported_state="$current_state" sleep "$CHECK_INTERVAL" done