#!/bin/bash
#
# air-wifi
#
# Sudo-allowlisted wrapper around `nmcli` that lets the `air` service
# user manage WiFi connections from the cast-server REST API without
# granting blanket nmcli access. Subcommand and argv are validated
# strictly here; the sudoers entry in /etc/sudoers.d/air-vpn is
# `Cmnd_Alias AIR_WIFI = /usr/local/sbin/air-wifi` (no trailing argv
# allowance — anything past the script name is rejected by sudo
# itself thanks to the implicit `""` terminator behaviour discussed
# in that file's header).
#
# # Why a wrapper, not raw `sudo nmcli`
#
# Sudoers can't easily express "allow `nmcli device wifi connect`
# but not `nmcli connection delete <existing>`" because the
# subcommand is multi-token argv. A wrapper that whitelists the
# subcommands we care about (scan / connect / disconnect / status)
# and rejects everything else is auditable in one file and
# unit-testable. Same idiom as air-tailscale / air-zerotier.
#
# # Subcommands
#
#   air-wifi scan
#     Trigger a scan and print the result as one network per line in
#     a stable colon-separated format:
#       <ssid>:<signal_pct>:<security>:<in_use>
#     Empty SSIDs (hidden networks) are skipped. Caller parses.
#     Exits 0 on success even when no networks are found.
#
#   air-wifi connect <ssid> <password>
#     Add or update an nmcli connection profile and bring it up.
#     SSID is taken verbatim (already validated upstream); password
#     must be 0 (open) or 8..63 chars (WPA2/WPA3 PSK length per
#     802.11i-2004). Exits 0 on success, non-zero with stderr on
#     failure (wrong password, AP gone, no DHCP, etc.). The active
#     connection persists across reboots — that's nmcli's default
#     and the operator-friendly behaviour.
#
#   air-wifi disconnect
#     Disconnect any active wlan0 connection and delete its profile
#     so a future re-flash doesn't auto-reconnect to a stale WiFi.
#     Idempotent — a no-op when wlan0 isn't connected.
#
#   air-wifi status
#     Print one line of JSON describing the current wlan0 state.
#     Format: {"connected": <bool>, "ssid": <str|null>,
#              "signal": <int|null>, "ip": <str|null>}.
#     Always exits 0; never raises.
#
# # Safety invariants
#
# 1. The script does NOT accept arbitrary `nmcli` subcommands.
#    Anything not matched by the case statement below exits 2 with
#    a refusal message.
# 2. SSID + password are passed to nmcli via argv (not shell). nmcli
#    itself does the right escaping internally — passing through bash
#    would otherwise be a command-injection surface.
# 3. The wrapper does not hide or invent connections. The operator's
#    saved profiles are visible to nmcli at all times; we only
#    add/up/down the ones we created.

set -euo pipefail

NM_DEV="${AIR_WIFI_DEV:-wlan0}"

case "${1:-}" in
    scan)
        # Defensively unblock rfkill before the rescan. Same rationale
        # as the connect path: Pi firmware sometimes ships WiFi soft-
        # blocked until the regulatory domain is known. Without this
        # the very first scan from the web UI returns an empty list
        # (kernel won't tune to disallowed channels), which looks
        # identical to "no APs in range" to the operator. Both
        # commands are idempotent and silent on already-unblocked
        # radios.
        rfkill unblock wifi 2>/dev/null || true
        nmcli radio wifi on 2>/dev/null || true
        # Trigger a fresh scan (nmcli does not auto-rescan often
        # enough on a Pi that's been idle on Ethernet for a while).
        # `--rescan yes` blocks up to ~10 s while the radio sweeps.
        # Failures here are non-fatal — we still emit the cached
        # network list, which is better than nothing.
        nmcli device wifi rescan --rescan yes 2>/dev/null || true
        # nmcli --terse separates fields with `:` and escapes literal
        # colons inside a field as `\:`. A naive `awk -F:` would
        # split on the escaped colons too, dropping SSIDs that
        # contain `:` (small minority but real). Decode in awk via a
        # 2-state walk: when we see `\:` keep both characters as a
        # literal `:` in the current field; when we see a bare `:`
        # advance to the next field. Output one JSON object per
        # line so the Rust parser can json::from_str each line —
        # no second-pass escape handling needed downstream.
        nmcli --terse --fields IN-USE,SSID,SIGNAL,SECURITY \
              device wifi list ifname "$NM_DEV" 2>/dev/null \
            | awk '
                function json_escape(s,    out, i, c) {
                    out = ""
                    for (i = 1; i <= length(s); i++) {
                        c = substr(s, i, 1)
                        if (c == "\\") out = out "\\\\"
                        else if (c == "\"") out = out "\\\""
                        else if (c == "\n") out = out "\\n"
                        else if (c == "\r") out = out "\\r"
                        else if (c == "\t") out = out "\\t"
                        else out = out c
                    }
                    return out
                }
                {
                    # Decode terse-with-escapes into f[1..4].
                    delete f
                    fi = 1
                    f[1] = ""
                    n = length($0)
                    i = 1
                    while (i <= n) {
                        c = substr($0, i, 1)
                        if (c == "\\" && i < n) {
                            nc = substr($0, i + 1, 1)
                            if (nc == ":" || nc == "\\") {
                                f[fi] = f[fi] nc
                                i += 2
                                continue
                            }
                        }
                        if (c == ":") {
                            fi++
                            f[fi] = ""
                        } else {
                            f[fi] = f[fi] c
                        }
                        i++
                    }
                    if (fi != 4 || f[2] == "") next
                    in_use = (f[1] == "*") ? "true" : "false"
                    # Prefix with raw signal+TAB so `sort -k1 -n -r`
                    # gives strongest-first; then `cut -f2-` strips
                    # the prefix. Sort happens outside awk so we do
                    # not need to buffer everything in memory.
                    # (No apostrophe in this comment — the awk script
                    # is wrapped in single quotes from bash, so any
                    # apostrophe here would prematurely close the
                    # quote and bash would parse the rest as code.
                    # That bricked /api/network/wifi/* on the 2026-04-26
                    # image — silent log spam every 2 s.)
                    printf "%s\t{\"ssid\":\"%s\",\"signal\":%s,\"security\":\"%s\",\"in_use\":%s}\n",
                        f[3], json_escape(f[2]), f[3], json_escape(f[4]), in_use
                }' \
            | sort -k1 -n -r \
            | cut -f2- \
            | head -50
        ;;
    connect)
        if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then
            echo "Usage: air-wifi connect <ssid> [password]" >&2
            exit 2
        fi
        SSID="$2"
        PASSWORD="${3:-}"
        # PSK length sanity. WPA2/WPA3 PSK is 8..63 ASCII or 64 hex
        # (raw PMK). Empty = open network. Anything else is wrong.
        if [ -n "$PASSWORD" ]; then
            len="${#PASSWORD}"
            if [ "$len" -lt 8 ] || [ "$len" -gt 64 ]; then
                echo "password must be 8-63 chars (WPA PSK) or 64 hex (PMK), got $len" >&2
                exit 3
            fi
        fi
        # Defensively unblock the WiFi radio before attempting connect.
        # Some Pi firmware ships with WiFi soft-blocked via rfkill until
        # the regulatory domain has been set — `nmcli connect` would
        # otherwise fail with `Operation could not be performed because
        # device is strictly unmanaged` or `device is not connected`.
        # Both `rfkill` and `nmcli radio wifi on` are idempotent: no-ops
        # if already unblocked. Failures are non-fatal — we still try
        # the connect and surface the real nmcli error if rfkill is
        # the actual problem.
        rfkill unblock wifi 2>/dev/null || true
        nmcli radio wifi on 2>/dev/null || true
        # Build args. `--ask` would prompt interactively; we pass via
        # `password` arg instead. `nmcli device wifi connect` either
        # creates a new profile or updates the existing one with the
        # same SSID. Profile name defaults to SSID.
        if [ -n "$PASSWORD" ]; then
            nmcli device wifi connect "$SSID" password "$PASSWORD" ifname "$NM_DEV"
        else
            nmcli device wifi connect "$SSID" ifname "$NM_DEV"
        fi
        ;;
    disconnect)
        # Drop the active connection AND delete the profile so that
        # the Pi doesn't auto-reconnect on next boot. Operator who
        # wants a persistent connection runs `connect` again — this
        # one-line undo behaviour is what they expect from a UI
        # button labeled "Forget".
        ACTIVE=$(nmcli --terse --fields NAME,DEVICE connection show --active \
                 | awk -F: -v dev="$NM_DEV" '$2==dev {print $1; exit}')
        if [ -n "$ACTIVE" ]; then
            nmcli connection down "$ACTIVE" >/dev/null 2>&1 || true
            nmcli connection delete "$ACTIVE" >/dev/null 2>&1 || true
            echo "disconnected from $ACTIVE"
        else
            echo "no active connection on $NM_DEV"
        fi
        ;;
    status)
        # Detect wlan0 presence first. CM4 Lite, custom carrier
        # boards, or Pi 5 with `dtoverlay=disable-wifi` have no
        # wlan0 — without this flag the UI would render the WiFi
        # card on hardware that can't possibly use it.
        WLAN_PRESENT=false
        if nmcli --terse --fields DEVICE device 2>/dev/null \
                | awk -F: -v dev="$NM_DEV" '$1 == dev {found=1; exit} END {exit !found}'; then
            WLAN_PRESENT=true
        fi
        # Read STATE + active CONNECTION from `device show wlan0`.
        # Sourcing SSID from the connection profile name (rather than
        # `device wifi list ... ACTIVE=yes`) is both more reliable
        # (active profile is set even if the AP just flickered out
        # of range, briefly stripping it from the visible AP list)
        # and avoids the `\:` escape parsing problem that `wifi list`
        # has — connection names rarely contain colons because nmcli
        # sanitises them at create time.
        SHOW_OUTPUT=$(nmcli --terse --fields GENERAL.STATE,GENERAL.CONNECTION,IP4.ADDRESS \
                       device show "$NM_DEV" 2>/dev/null || true)
        STATE=$(printf '%s\n' "$SHOW_OUTPUT" | awk -F: '/^GENERAL.STATE:/ {sub(/^[^:]+:/,""); print; exit}')
        # Active profile name. The empty string sentinel "--" means
        # no active connection on this device.
        SSID=$(printf '%s\n' "$SHOW_OUTPUT" | awk -F: '/^GENERAL.CONNECTION:/ {sub(/^[^:]+:/,""); print; exit}')
        if [ "$SSID" = "--" ]; then SSID=""; fi
        # Pull signal of the active SSID from the AP list. If the
        # active SSID is missing from the visible list (out-of-range
        # flicker) we just emit null signal — better than wrong.
        SIGNAL=""
        if [ -n "$SSID" ]; then
            SIGNAL=$(nmcli --terse --fields ACTIVE,SIGNAL device wifi list ifname "$NM_DEV" 2>/dev/null \
                     | awk -F: '$1=="yes" {print $2; exit}' || true)
        fi
        # IP4 address — strip the `/24` CIDR suffix. `[1]` always
        # refers to the first assigned address in NM's terse output.
        IP=$(printf '%s\n' "$SHOW_OUTPUT" \
             | awk -F: '/^IP4.ADDRESS\[1\]:/ {sub(/^[^:]+:/,""); sub(/\/.*/,""); print; exit}')
        json_str() {
            if [ -z "$1" ]; then printf 'null'
            else printf '"%s"' "$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g')"
            fi
        }
        json_int() {
            if [ -z "$1" ]; then printf 'null'
            else printf '%s' "$1"
            fi
        }
        # `connected` is true only when STATE indicates a connected
        # state. NetworkManager emits the literal `100 (connected)`
        # for connected and `30 (disconnected)` / `20 (unavailable)`
        # for not-connected. A naive substring match on `connected`
        # would match `disconnected` too — which would silently
        # paint the UI green on a Pi that's actually disconnected.
        # Match on the literal `(connected)` token instead, which
        # is unambiguous and stable across NM versions.
        CONNECTED=false
        case "$STATE" in
            *"(connected)"*) CONNECTED=true;;
        esac
        printf '{"wlan_present":%s,"connected":%s,"ssid":%s,"signal":%s,"ip":%s}\n' \
            "$WLAN_PRESENT" "$CONNECTED" "$(json_str "$SSID")" "$(json_int "$SIGNAL")" "$(json_str "$IP")"
        ;;
    radio-off)
        # Hard-disable the WiFi radio. Equivalent to flicking the
        # software radio kill switch — the kernel deauths the
        # interface, NetworkManager stops scanning, and no profile
        # (including STARLINK) will auto-connect until the operator
        # flips it back on. Useful for radio-quiet operations
        # (recon flights, RF-hostile environments, EMI-sensitive
        # payloads) and for power saving on bench-test rigs.
        # Persists across reboot via /var/lib/cast/wifi-radio-state
        # (read by air-wifi-regulatory on next boot).
        nmcli radio wifi off >/dev/null 2>&1 || true
        # Persist intent. The regulatory boot service consults this
        # file to decide whether to unblock rfkill — without the
        # marker, a reboot would silently re-enable the radio.
        if [ -d /var/lib/cast ]; then
            printf 'off\n' > /var/lib/cast/wifi-radio-state 2>/dev/null || true
        fi
        echo "wifi radio: off"
        ;;
    radio-on)
        # Re-enable the WiFi radio. Cleared persistence flag means
        # next boot's regulatory service unblocks rfkill normally.
        # Pre-existing nmcli profiles autoconnect per their own
        # priority; this is a no-op for profiles that were already
        # disconnected.
        nmcli radio wifi on >/dev/null 2>&1 || true
        if [ -d /var/lib/cast ]; then
            printf 'on\n' > /var/lib/cast/wifi-radio-state 2>/dev/null || true
        fi
        echo "wifi radio: on"
        ;;
    radio-status)
        # Report the current radio state as a one-word token
        # (`enabled` / `disabled` / `unknown`). The full /api/network/
        # status endpoint reads this to drive the toggle's visual
        # state in the WiFi card.
        STATE=$(nmcli radio wifi 2>/dev/null | tr -d '[:space:]')
        case "$STATE" in
            enabled) echo "enabled" ;;
            disabled) echo "disabled" ;;
            *) echo "unknown" ;;
        esac
        ;;
    *)
        echo "Usage: air-wifi {scan|connect <ssid> [password]|disconnect|status|radio-on|radio-off|radio-status}" >&2
        exit 2
        ;;
esac
