#!/bin/bash
################################################################################
# kiosk-wifi-helper - the root side of the kiosk app's WiFi screen
# (Ctrl+Alt+Super+W), for joining a WiFi network from the kiosk
# itself when the console (Ctrl+Alt+F1..F6) is switched off.
#
# The kiosk user may run this one script via passwordless sudo
# (/etc/sudoers.d/kiosk-wifi, written by lib/provision.sh) - it does a
# fixed set of things and nothing else:
#
#   kiosk-wifi-helper status     {"iface","connected","ssid","ip","configured"}
#   kiosk-wifi-helper scan       {"iface","networks":[{"ssid","signal","secure"}]}
#   kiosk-wifi-helper connect    stdin {"ssid","password"} -> {"ok","ip"|"error"}
#   kiosk-wifi-helper region-status
#                                {"country","locale","keyboard","locales":[],"keyboards":[]}
#   kiosk-wifi-helper region-set stdin {"country"?,"locale"?,"keyboard"?} -> {"ok",...}
#                                WiFi country (regulatory domain), system
#                                locale and keyboard layout; every field optional
#
# Every reply is one JSON line on stdout, exit 0 on success. connect
# writes the same netplan file as Core Settings -> WiFi (menus/wifi.sh,
# 60-kiosk-wifi.yaml) and puts the previous one back if the interface
# gets no address - a typo in the password doesn't lose a WiFi network
# that was working. The password comes in on stdin, never argv (argv is
# visible to every user in ps).
################################################################################
set -uo pipefail

NETPLAN_DIR="${NETPLAN_DIR:-/etc/netplan}"
SYS_CLASS="${KIOSK_WIFI_SYS_CLASS:-/sys/class}"   # overridable for tests
WIFI_NETPLAN_FILE="$NETPLAN_DIR/60-kiosk-wifi.yaml"
REGDOM_CONF="${KIOSK_REGDOM_CONF:-/etc/modprobe.d/kiosk-regdom.conf}"
LOCALE_FILE="${KIOSK_LOCALE_FILE:-/etc/default/locale}"
LOCK_FILE="${KIOSK_WIFI_LOCK:-/run/kiosk-wifi-helper.lock}"
CONNECT_WAIT="${KIOSK_WIFI_CONNECT_WAIT:-30}"

reply_error() {
    jq -cn --arg e "$1" '{ok:false,error:$e}'
    exit 1
}

# The first wireless interface, by what it is (sysfs wireless/ or
# phy80211), falling back to the wl* naming convention. Same as
# menus/wifi.sh's wifi_detect_iface.
detect_iface() {
    local d
    for d in $SYS_CLASS/net/*; do
        if [[ -d "$d/wireless" || -e "$d/phy80211" ]]; then
            basename "$d"
            return 0
        fi
    done
    for d in $SYS_CLASS/net/wl*; do
        [[ -e "$d" ]] || continue
        basename "$d"
        return 0
    done
    return 1
}

# Clear an rfkill soft block (a hard block can't be cleared from software).
unblock() {
    local r
    for r in $SYS_CLASS/rfkill/rfkill*; do
        [[ "$(cat "$r/type" 2>/dev/null)" == "wlan" ]] || continue
        [[ "$(cat "$r/soft" 2>/dev/null)" == "1" ]] && echo 0 > "$r/soft" 2>/dev/null
    done
    return 0
}

hard_blocked() {
    local r
    for r in $SYS_CLASS/rfkill/rfkill*; do
        [[ "$(cat "$r/type" 2>/dev/null)" == "wlan" ]] || continue
        [[ "$(cat "$r/hard" 2>/dev/null)" == "1" ]] && return 0
    done
    return 1
}

iface_ip() {
    ip -4 -o addr show dev "$1" 2>/dev/null \
        | awk '{for (i = 1; i < NF; i++) if ($i == "inet") { sub("/.*", "", $(i+1)); print $(i+1); exit }}'
}

# YAML single-quoted scalar - the quote itself doubled is the only escape.
yaml_quote() {
    local v="$1"
    printf "'%s'" "${v//\'/\'\'}"
}

# iw prints a backslash, a leading/trailing space and anything
# unprintable in an SSID as \xNN (a backslash itself is always \x5c,
# so there are no other escapes) - %b turns those back into the bytes.
unescape_ssid() {
    printf '%b' "$1"
}

# The SSID the interface is associated with right now, if any.
current_ssid() {
    unescape_ssid "$(iw dev "$1" link 2>/dev/null | sed -n 's/^[[:space:]]*SSID: //p' | head -1)"
}

# The SSID in our netplan file, if any (the first access point's key).
configured_ssid() {
    [[ -f "$WIFI_NETPLAN_FILE" ]] || return 0
    python3 -I -c '
import sys, yaml
try:
    d = yaml.safe_load(open(sys.argv[1]))
    for w in (d.get("network", {}).get("wifis") or {}).values():
        for ssid in (w.get("access-points") or {}):
            print(ssid); sys.exit(0)
except Exception:
    pass' "$WIFI_NETPLAN_FILE" 2>/dev/null
}

# Why a connect attempt failed, from wpa_supplicant's own log lines in
# the journal (netplan runs it as netplan-wpa-<iface>.service). Prints a
# short human-readable reason, or nothing if the log says nothing useful.
diagnose_failure() {
    local log
    log=$(journalctl -b -u "netplan-wpa-$1" -n 150 --no-pager 2>/dev/null) || return 0
    if grep -q "reason=WRONG_KEY\|pre-shared key may be incorrect" <<< "$log"; then
        echo "wrong password (the network rejected the key)"
    elif grep -q "CTRL-EVENT-NETWORK-NOT-FOUND" <<< "$log"; then
        echo "network not found - wrong name, or out of range of this adapter"
    elif grep -q "CTRL-EVENT-ASSOC-REJECT\|CTRL-EVENT-AUTH-REJECT" <<< "$log"; then
        echo "the access point refused the connection (MAC filter, or WPA3-only/unsupported security)"
    fi
}

# Prefer the adapter our netplan file already configures (if it's still
# there) over "first one found", so a built-in card plus a USB dongle
# don't swap places between calls.
prefer_configured_iface() {
    [[ -f "$WIFI_NETPLAN_FILE" ]] || return 1
    python3 -I -c '
import sys, os, yaml
try:
    d = yaml.safe_load(open(sys.argv[1]))
    for n in (d.get("network", {}).get("wifis") or {}):
        if os.path.exists(sys.argv[2] + "/net/" + str(n)):
            print(n); sys.exit(0)
except Exception:
    pass
sys.exit(1)' "$WIFI_NETPLAN_FILE" "$SYS_CLASS" 2>/dev/null
}


# Two-letter country code for the WiFi regulatory domain: the one already
# in our netplan file, else from the system locale (en_US.UTF-8 -> US).
# Without a country set, Linux uses the restrictive "world" domain: many
# 5 GHz channels become listen-only, so a network shows up in scans but
# the adapter can't join it.
default_country() {
    local c=""
    if [[ -f "$WIFI_NETPLAN_FILE" ]]; then
        c=$(sed -n 's/^[[:space:]]*regulatory-domain:[[:space:]]*["'"'"']\{0,1\}\([A-Za-z][A-Za-z]\).*/\1/p' "$WIFI_NETPLAN_FILE" | head -1)
    fi
    if [[ -z "$c" && -f "$REGDOM_CONF" ]]; then
        c=$(sed -n 's/.*ieee80211_regdom=\([A-Za-z][A-Za-z]\).*/\1/p' "$REGDOM_CONF" | head -1)
    fi
    if [[ -z "$c" ]]; then
        local loc="${LANG:-}"
        [[ -r "$LOCALE_FILE" ]] && loc=$(sed -n 's/^LANG=["'"'"']\{0,1\}\([^"'"'"']*\).*/\1/p' "$LOCALE_FILE" | head -1)
        [[ "$loc" =~ _([A-Z][A-Z])[.@]?  ]] && c="${BASH_REMATCH[1]}"
    fi
    echo "${c^^}"
}

# Every wireless interface, by what it is (see detect_iface).
list_wifi_ifaces() {
    local d
    for d in $SYS_CLASS/net/*; do
        if [[ -d "$d/wireless" || -e "$d/phy80211" ]]; then
            basename "$d"
        fi
    done
}

# The wireless adapter that is connected right now (associated and has an
# address), if any. With a built-in card plus a USB dongle, the connected
# one is often not the first in name order - status/scan/connect must act
# on the adapter that is actually carrying the connection.
connected_iface() {
    local i
    for i in $(list_wifi_ifaces); do
        if [[ -n "$(current_ssid "$i")" && -n "$(iface_ip "$i")" ]]; then
            echo "$i"
            return 0
        fi
    done
    return 1
}

# Which adapter to use: the connected one, else the one our netplan file
# configures, else the first found.
pick_iface() {
    connected_iface || prefer_configured_iface || detect_iface
}

need_iface() {
    IFACE=$(pick_iface) || reply_error "No WiFi adapter found. If it's a USB adapter, check it's plugged in."
    command -v iw >/dev/null || reply_error "WiFi support (iw, wpasupplicant) isn't installed - run Core Settings -> WiFi once from the admin menu"
}

# ---- Region: WiFi country, system locale, keyboard layout -----------------

current_locale() {
    [[ -r "$LOCALE_FILE" ]] || return 0
    sed -n 's/^LANG=["'"'"']\{0,1\}\([^"'"'"']*\).*/\1/p' "$LOCALE_FILE" | head -1
}

current_keyboard() {
    localectl status 2>/dev/null | sed -n 's/^[[:space:]]*X11 Layout:[[:space:]]*//p' | head -1
}

# Locales that are generated on this system (setting any other one would
# leave the whole system on a locale that doesn't exist).
list_locales() {
    locale -a 2>/dev/null | grep -i 'utf-\?8' | sed 's/\.[Uu][Tt][Ff]-\?8$/.UTF-8/' | sort -u
}

list_keyboards() {
    localectl list-x11-keymap-layouts 2>/dev/null
}

do_region_status() {
    jq -cn --arg c "$(default_country)" --arg l "$(current_locale)" --arg k "$(current_keyboard)" \
        --argjson ls "$(list_locales | jq -R . | jq -cs .)" \
        --argjson ks "$(list_keyboards | jq -R . | jq -cs .)" \
        '{ok:true,country:$c,locale:$l,keyboard:$k,locales:$ls,keyboards:$ks}'
}

# Persist the WiFi country: kernel option for the regulatory domain (so it
# applies from boot, before any WiFi config exists), the netplan file if
# there is one (so wpa_supplicant gets it too), and now via iw.
set_country() {
    local c="$1"
    printf '# Written by kiosk-wifi-helper (Region setting)\noptions cfg80211 ieee80211_regdom=%s\n' "$c" > "$REGDOM_CONF"
    if [[ -f "$WIFI_NETPLAN_FILE" ]]; then
        sed -i '/^[[:space:]]*regulatory-domain:/d' "$WIFI_NETPLAN_FILE"
        sed -i "s/^\([[:space:]]*\)optional: true\$/&\n\1regulatory-domain: \"$c\"/" "$WIFI_NETPLAN_FILE"
    fi
    iw reg set "$c" 2>/dev/null || true
}

do_region_set() {
    local input country locale keyboard
    input=$(head -c 4096)
    country=$(jq -r '.country // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"
    locale=$(jq -r '.locale // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"
    keyboard=$(jq -r '.keyboard // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"
    country=${country^^}

    if [[ -n "$country" ]]; then
        [[ "$country" =~ ^[A-Z]{2}$ ]] || reply_error "A country code is 2 letters, e.g. US"
        # Reject codes that aren't real countries when the tz database is here.
        if [[ -r /usr/share/zoneinfo/iso3166.tab ]] && ! grep -q "^$country[[:space:]]" /usr/share/zoneinfo/iso3166.tab; then
            reply_error "$country isn't a known country code"
        fi
    fi
    if [[ -n "$locale" ]]; then
        list_locales | grep -qxF -- "$locale" || reply_error "Locale $locale isn't installed on this system"
    fi
    if [[ -n "$keyboard" ]]; then
        list_keyboards | grep -qxF -- "$keyboard" || reply_error "Unknown keyboard layout: $keyboard"
    fi

    local err
    [[ -n "$country" ]] && set_country "$country"
    if [[ -n "$locale" ]]; then
        err=$(localectl set-locale "LANG=$locale" 2>&1) || reply_error "Couldn't set the locale: ${err:0:200}"
    fi
    if [[ -n "$keyboard" ]]; then
        err=$(localectl set-x11-keymap "$keyboard" 2>&1) || reply_error "Couldn't set the keyboard: ${err:0:200}"
        # Console layout too, when there's a console map of the same name.
        localectl list-keymaps 2>/dev/null | grep -qxF -- "$keyboard" && localectl set-keymap "$keyboard" >/dev/null 2>&1
    fi
    jq -cn --arg c "$(default_country)" --arg l "$(current_locale)" --arg k "$(current_keyboard)" \
        --argjson restart "$([[ -n "$locale$keyboard" ]] && echo true || echo false)" \
        '{ok:true,country:$c,locale:$l,keyboard:$k,restart:$restart}'
}

do_status() {
    local iface ssid="" ip="" configured
    configured=$(configured_ssid)
    if ! iface=$(pick_iface); then
        jq -cn --arg c "$configured" '{ok:true,iface:"",connected:false,ssid:"",ip:"",configured:$c}'
        return 0
    fi
    ssid=$(current_ssid "$iface")
    ip=$(iface_ip "$iface")
    jq -cn --arg i "$iface" --arg s "$ssid" --arg a "$ip" --arg c "$configured" \
        '{ok:true,iface:$i,connected:($s != "" and $a != ""),ssid:$s,ip:$a,configured:$c}'
}

do_scan() {
    need_iface
    unblock
    hard_blocked && reply_error "WiFi is switched off by a hardware switch or in the BIOS/UEFI"
    ip link set "$IFACE" up 2>/dev/null || reply_error "Couldn't switch on $IFACE"

    # "Device or resource busy" while wpa_supplicant is mid-scan - retry.
    local out="" try
    for try in 1 2 3; do
        out=$(iw dev "$IFACE" scan 2>/dev/null) && break
        out=""
        sleep 2
    done
    # wpa_supplicant owns the interface and refuses iw: use its last scan.
    if [[ -z "$out" ]] && command -v wpa_cli >/dev/null; then
        wpa_cli -i "$IFACE" scan >/dev/null 2>&1
        sleep 4
        wpa_cli -i "$IFACE" scan_results 2>/dev/null | awk -F'\t' '
            NR > 1 && $5 != "" { secure = ($4 ~ /WPA|WEP|RSN|SAE/) ? 1 : 0; print $3 "\t" secure "\t" $5 }' \
            > "$TMP_SCAN"
    else
        # BSS blocks: signal, SSID, and RSN/WPA/Privacy for "secured".
        printf '%s\n' "$out" | awk '
            function flush() { if (ssid != "") print sig "\t" sec "\t" ssid; ssid = ""; sig = -100; sec = 0 }
            /^BSS / { flush() }
            /^\tsignal:/ { sig = int($2) }
            /^\tSSID: / { sub(/^\tSSID: /, ""); ssid = $0 }
            /^\t(RSN|WPA):/ { sec = 1 }
            /^\tcapability:.*Privacy/ { sec = 1 }
            END { flush() }' \
            | while IFS=$'\t' read -r sig sec ssid; do
                printf '%s\t%s\t%s\n' "$sig" "$sec" "$(unescape_ssid "$ssid")"
            done > "$TMP_SCAN"
    fi

    # Strongest first, one entry per SSID.
    jq -cR --arg i "$IFACE" -n '
        [inputs | split("\t") | select(length >= 3)
            | {signal: (.[0] | tonumber? // -100), secure: (.[1] == "1"), ssid: (.[2:] | join("\t"))}]
        | group_by(.ssid) | map(max_by(.signal)) | sort_by(-.signal)
        | {ok: true, iface: $i, networks: .}' < "$TMP_SCAN"
}

# netplan apply starts a netplan-wpa-<iface> unit for every wifis: entry
# in every file and waits for that device - 90s, then the whole apply
# fails - if one names an interface this machine doesn't have (the
# installer's own config can carry e.g. "wlan0" while the adapter here is
# "wlp1s0"). Take such entries out of the other netplan files first
# (originals kept as *.kiosk-bak, which netplan ignores).
drop_stale_entries() {
    local f
    for f in "$NETPLAN_DIR"/*.yaml; do
        [[ -f "$f" && "$f" != "$WIFI_NETPLAN_FILE" ]] || continue
        python3 -I - "$f" <<'PYEOF' 2>/dev/null || true
import os, shutil, sys, yaml
p = sys.argv[1]
try:
    d = yaml.safe_load(open(p)) or {}
    w = (d.get("network") or {}).get("wifis") or {}
    stale = [n for n in w if not os.path.exists("/sys/class/net/" + str(n))]
    if stale:
        shutil.copy2(p, p + ".kiosk-bak")
        for n in stale:
            del w[n]
        if not w:
            del d["network"]["wifis"]
    # Not "optional" = the boot waits (up to ~2 min) for it to get an address.
    opt = [n for n, v in w.items() if isinstance(v, dict) and not v.get("optional")]
    for n in opt:
        w[n]["optional"] = True
    if stale or opt:
        if not stale:
            shutil.copy2(p, p + ".kiosk-bak")
        mode = os.stat(p).st_mode & 0o777
        with open(p, "w") as fh:
            yaml.safe_dump(d, fh, default_flow_style=False)
        os.chmod(p, mode)
        print("Fixed WiFi entries in %s (removed: %s)" % (p, ", ".join(map(str, stale)) or "none"))
except Exception:
    pass
PYEOF
    done
}

do_connect() {
    need_iface
    local input ssid password
    input=$(head -c 4096)
    ssid=$(jq -er '.ssid | strings' <<< "$input" 2>/dev/null) || reply_error "No network name given"
    password=$(jq -r '.password // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"

    local ssid_bytes
    ssid_bytes=$(printf '%s' "$ssid" | wc -c)
    (( ssid_bytes >= 1 && ssid_bytes <= 32 )) || reply_error "A network name is 1-32 characters"
    # No control characters (newlines etc.) in either - they can't be in a
    # real SSID/passphrase and would only serve to break the YAML.
    [[ "$ssid$password" == *[[:cntrl:]]* ]] && reply_error "The name or password contains an invalid character"
    if [[ -n "$password" ]]; then
        local plen=${#password}
        if ! (( plen >= 8 && plen <= 63 )) && ! [[ "$password" =~ ^[0-9A-Fa-f]{64}$ ]]; then
            reply_error "A WiFi password is 8-63 characters"
        fi
    fi

    unblock
    hard_blocked && reply_error "WiFi is switched off by a hardware switch or in the BIOS/UEFI"

    local country backup="" new
    country=$(default_country)
    [[ -n "$country" ]] && iw reg set "$country" 2>/dev/null
    mkdir -p "$NETPLAN_DIR"
    if [[ -f "$WIFI_NETPLAN_FILE" ]]; then
        backup="${WIFI_NETPLAN_FILE}.backup-$(date +%Y%m%d-%H%M%S)"
        cp -p "$WIFI_NETPLAN_FILE" "$backup"
    fi

    new=$(mktemp "$NETPLAN_DIR/.kiosk-wifi.XXXXXX")
    {
        echo "network:"
        echo "  version: 2"
        echo "  renderer: networkd"
        echo "  wifis:"
        echo "    $IFACE:"
        echo "      dhcp4: true"
        echo "      dhcp6: false"
        echo "      optional: true"
        [[ -n "$country" ]] && echo "      regulatory-domain: \"$country\""
        echo "      access-points:"
        if [[ -n "$password" ]]; then
            echo "        $(yaml_quote "$ssid"):"
            echo "          password: $(yaml_quote "$password")"
        else
            echo "        $(yaml_quote "$ssid"): {}"
        fi
    } > "$new"
    chmod 0600 "$new"
    mv -f "$new" "$WIFI_NETPLAN_FILE"

    drop_stale_entries >/dev/null
    local err
    if ! err=$(netplan apply 2>&1); then
        restore "$backup"
        reply_error "netplan apply failed: ${err:0:300}"
    fi

    local i addr="" now
    for (( i = 0; i < CONNECT_WAIT; i++ )); do
        now=$(current_ssid "$IFACE")
        addr=$(iface_ip "$IFACE")
        [[ "$now" == "$ssid" && -n "$addr" ]] && break
        addr=""
        sleep 1
    done

    if [[ -z "$addr" ]]; then
        restore "$backup"
        local why
        why=$(diagnose_failure "$IFACE")
        if [[ -n "$why" ]]; then
            reply_error "Couldn't connect to \"$ssid\": $why."
        fi
        if [[ -n "$backup" ]]; then
            reply_error "Couldn't connect to \"$ssid\" - wrong password, or out of range. The previous WiFi setting was put back."
        fi
        reply_error "Couldn't connect to \"$ssid\" - wrong password, or out of range."
    fi
    jq -cn --arg s "$ssid" --arg a "$addr" --arg i "$IFACE" '{ok:true,ssid:$s,ip:$a,iface:$i}'
}

# Put the previous netplan WiFi file back (or remove ours if there was
# none) and re-apply, after a failed connect.
restore() {
    if [[ -n "$1" && -f "$1" ]]; then
        mv -f "$1" "$WIFI_NETPLAN_FILE"
    else
        rm -f "$WIFI_NETPLAN_FILE"
    fi
    netplan apply >/dev/null 2>&1
}

[[ $EUID -eq 0 ]] || reply_error "must run as root (sudo)"
command -v jq >/dev/null || { echo '{"ok":false,"error":"jq is not installed"}'; exit 1; }

TMP_SCAN=$(mktemp)
trap 'rm -f "$TMP_SCAN"' EXIT

# One at a time - two overlapping netplan applies/backups would race.
exec 9> "$LOCK_FILE"
flock -w 60 9 || reply_error "Another WiFi change is still running - try again in a moment"

case "${1:-}" in
    status)  do_status ;;
    scan)    do_scan ;;
    connect) do_connect ;;
    region-status) do_region_status ;;
    region-set)    do_region_set ;;
    *)       reply_error "unknown action: ${1:-}" ;;
esac
