#!/usr/bin/env bash
#
# The kiosk's own WiFi network (architecture.md).
#
# The room does not always have a network worth joining - a rented hall, a
# garden, a living room whose WiFi password nobody remembers - and the
# companion application is only as reachable as the machine is. So the machine
# carries its own: hostapd raises an access point on the first wireless
# interface it finds, systemd-networkd gives it an address and answers DHCP
# (actions/network/80-archstar-hotspot.network), and every phone that joins
# can reach the page, the API - and nothing else. The hotspot routes nowhere:
# no forwarding, no NAT, no Internet. Joining the WiFi of the room is worth
# exactly what walking into the room is worth, which is the page's own rule.
#
# Run by archstar-hotspot.service, never by hand and never by the page
# directly - the `hotspot` machine action enables or disables the unit, and
# this script is what the unit runs.
#
#   archstar-hotspot run             find the interface, raise the network
#   archstar-hotspot note-stopped    record that the network is down
#
# The `hotspot` setting has two values, and this script reads it because they
# do not mean the same thing:
#
#   on     always broadcast
#   auto   broadcast only where there is no cable - the nomad case: a rented
#          hall, a garden. With a cable there is a network already, and one the
#          room's phones can reach the page on, so raising a second one would
#          be a second network to explain to guests
#
# There used to be an `off`, retired with issue #17: the television now carries
# the join-this-network code, so a machine that can broadcast, broadcasts. A
# settings file left saying `off` by an older release is not a third value here
# - it falls through to `on` with everything else this build does not know.
#
# **`auto` decides once, when the unit starts.** Not a watcher following the
# cable in and out: a party is not the moment to find out that a daemon of ours
# reacts badly to a switch being rebooted, and the machine that carries this
# has one job for four hours. Unplug the cable and want the hotspot: the
# setting is two taps away on the page, and it is `on`.
#
# What it publishes: /run/archstar/hotspot.json, for the page - whether a
# hotspot is possible on this machine, whether one is up, and what to join.
# The passphrase is in it on purpose: the page's whole job is to hand it to
# the room, as words and as a QR code.
#
# Environment - the suite sets these; the machine uses the defaults:
#   ARCHSTAR_HOTSPOT_CONF    the network's name and passphrase
#                            (default: /etc/archstar/hotspot.conf)
#   ARCHSTAR_SYS_NET         where interfaces are looked for
#                            (default: /sys/class/net)
#   ARCHSTAR_HOSTAPD         the hostapd to run (default: hostapd)
#   ARCHSTAR_HOTSPOT_STATUS  (default: /run/archstar/hotspot.json)
#   ARCHSTAR_HOTSPOT_RUN     where the generated hostapd configuration goes
#                            (default: /run/archstar/hotspot)
#   ARCHSTAR_SETTINGS_DIR    where the `hotspot` setting is remembered
#                            (default: /etc/archstar/settings)

set -euo pipefail

CONF="${ARCHSTAR_HOTSPOT_CONF:-/etc/archstar/hotspot.conf}"
SYS_NET="${ARCHSTAR_SYS_NET:-/sys/class/net}"
HOSTAPD="${ARCHSTAR_HOSTAPD:-hostapd}"
STATUS_FILE="${ARCHSTAR_HOTSPOT_STATUS:-/run/archstar/hotspot.json}"
RUN_DIR="${ARCHSTAR_HOTSPOT_RUN:-/run/archstar/hotspot}"
SETTINGS_DIR="${ARCHSTAR_SETTINGS_DIR:-/etc/archstar/settings}"

# The machine's address on its own network. The same constant lives in
# actions/network/80-archstar-hotspot.network, where systemd-networkd reads
# it - that file is configuration and cannot read this one, so the two are
# spelled twice and tests/test-hotspot.sh refuses a build where they differ.
ADDRESS='10.42.42.1'

# What the network is called when nobody chose: install.conf decides on a real
# installation and configure.sh writes the file; these exist for a machine
# updated into this feature before its installer ever heard of it.
DEFAULT_SSID='ArchStar'
DEFAULT_PASSPHRASE='archstar-party'

SSID=''
PASSPHRASE=''

die() {
    printf 'error: %s\n' "$*" >&2
    exit 1
}

# One `key=value` out of the configuration file. Read, never sourced: this
# runs as root, and a configuration file is data, not a script.
conf_value() {
    local key="$1"
    [[ -r "${CONF}" ]] || return 0
    sed -n "s/^${key}=//p" "${CONF}" | head -n1
}

# The bounds are 802.11's own - an SSID is 1..32 bytes, a WPA2 passphrase
# 8..63 - narrowed to printable ASCII without quotes or backslashes, because
# both values travel on into a JSON file written by this same script and into
# a QR code, and one character set that survives every carrier beats three
# escaping schemes. In the C locale so "printable" means ASCII on every
# machine, whatever language the kiosk is installed in.
name_is_sound() {
    local value="$1" min="$2" max="$3"
    local LC_ALL=C
    (( ${#value} >= min && ${#value} <= max )) || return 1
    [[ "${value}" =~ ^[[:print:]]+$ ]] || return 1
    [[ "${value}" != *[\"\\]* ]]
}

# What hostapd is told, and what the page repeats: both come through here, so
# a name that would break either is refused before anything is raised.
take_the_names() {
    SSID="$(conf_value ssid)"
    PASSPHRASE="$(conf_value passphrase)"
    [[ -n "${SSID}" ]] || SSID="${DEFAULT_SSID}"
    [[ -n "${PASSPHRASE}" ]] || PASSPHRASE="${DEFAULT_PASSPHRASE}"

    name_is_sound "${SSID}" 1 32 || \
        die "the ssid in ${CONF} must be 1..32 printable ASCII characters without quotes or backslashes"
    name_is_sound "${PASSPHRASE}" 8 63 || \
        die "the passphrase in ${CONF} must be 8..63 printable ASCII characters without quotes or backslashes"
}

# The first wireless interface this machine has, or nothing. /sys is the
# authority: an interface is wireless exactly when the kernel gives it a
# `wireless` directory, whatever it is called and whoever made it.
find_wireless_interface() {
    local entry
    for entry in "${SYS_NET}"/*/wireless; do
        [[ -e "${entry}" ]] || continue
        basename "$(dirname "${entry}")"
        return 0
    done
    return 1
}

# Which of the two the machine is set to. `on` when nothing says otherwise:
# a machine whose unit is running and whose setting file is missing was told to
# broadcast by somebody, and the safe reading of that is the one that keeps the
# network up.
hotspot_setting() {
    local value=''
    [[ -r "${SETTINGS_DIR}/hotspot" ]] && value="$(<"${SETTINGS_DIR}/hotspot")"
    value="${value//[[:space:]]/}"
    case "${value}" in
        auto|on) printf '%s' "${value}" ;;
        *) printf 'on' ;;
    esac
}

# The name of a wired interface with a cable in it, or nothing.
#
# Wired means: the kernel gave it a `device` (so not a tunnel, not a bridge of
# ours, not `lo`) and no `wireless` directory. `carrier` is the cable itself -
# 1 when a link is up - and it is unreadable on an interface that is down,
# which is not an error here: no answer is no cable.
find_wired_carrier() {
    local entry name
    for entry in "${SYS_NET}"/*; do
        [[ -d "${entry}" ]] || continue
        name="$(basename "${entry}")"
        [[ "${name}" == 'lo' ]] && continue
        [[ -e "${entry}/device" ]] || continue
        [[ -e "${entry}/wireless" ]] && continue
        [[ "$(cat "${entry}/carrier" 2>/dev/null || true)" == '1' ]] || continue
        printf '%s' "${name}"
        return 0
    done
    return 1
}

# What the page reads. Written whole and renamed into place, like every file
# something else polls. The values that reach this file went through
# take_the_names, so plain printf is JSON enough.
publish_status() {
    local available="$1" running="$2" interface="$3" reason="$4"
    install -d -m 0755 "$(dirname "${STATUS_FILE}")"
    {
        printf '{"available": %s, "running": %s' "${available}" "${running}"
        printf ', "ssid": "%s", "passphrase": "%s", "address": "%s"' \
            "${SSID}" "${PASSPHRASE}" "${ADDRESS}"
        if [[ -n "${interface}" ]]; then
            printf ', "interface": "%s"' "${interface}"
        fi
        if [[ -n "${reason}" ]]; then
            printf ', "reason": "%s"' "${reason}"
        fi
        printf '}\n'
    } >"${STATUS_FILE}.new"
    chmod 0644 "${STATUS_FILE}.new"
    mv -f "${STATUS_FILE}.new" "${STATUS_FILE}"
}

# hostapd's configuration, generated fresh on every start into /run: the file
# holds the passphrase, /run holds nothing across a reboot, and a stale copy
# of an old name cannot outlive the configuration that replaced it.
write_hostapd_conf() {
    local interface="$1"
    install -d -m 0700 "${RUN_DIR}"
    cat >"${RUN_DIR}/hostapd.conf" <<CONF_END
# Generated by archstar-hotspot; edit /etc/archstar/hotspot.conf instead.
interface=${interface}
driver=nl80211
ssid=${SSID}
hw_mode=g
channel=6
ieee80211n=1
wmm_enabled=1
auth_algs=1
wpa=2
wpa_key_mgmt=WPA-PSK
wpa_passphrase=${PASSPHRASE}
rsn_pairwise=CCMP
ignore_broadcast_ssid=0
CONF_END
    chmod 0600 "${RUN_DIR}/hostapd.conf"
}

run() {
    take_the_names

    local interface
    if ! interface="$(find_wireless_interface)"; then
        # Not a failure: most of what this project runs on is a desktop with
        # no radio at all. The page reads why and says so; the unit ends
        # cleanly and nothing retries a card into existence.
        publish_status false false '' 'no wireless interface on this machine'
        printf 'no wireless interface here; the hotspot stays down\n'
        exit 0
    fi

    # The nomad rule, checked after the radio so that a machine with no card
    # still says *that* - it is the more useful thing to be told, and it does
    # not change when a cable moves.
    if [[ "$(hotspot_setting)" == 'auto' ]]; then
        local cable=''
        if cable="$(find_wired_carrier)"; then
            publish_status true false "${interface}" \
                "a cable is plugged into ${cable}, so the hotspot is not needed"
            printf 'a cable is in %s; on "auto" the hotspot stays down\n' "${cable}"
            exit 0
        fi
    fi

    command -v "${HOSTAPD}" >/dev/null 2>&1 || \
        die "${HOSTAPD} is not available; the archstar-web package depends on it, so this machine's packages are broken"

    # A radio that was soft-blocked - a function key, a previous life as a
    # laptop - stays blocked forever on a machine with no keyboard. Best
    # effort: where there is no rfkill there is usually no block either.
    command -v rfkill >/dev/null 2>&1 && rfkill unblock wlan 2>/dev/null || true

    write_hostapd_conf "${interface}"
    publish_status true true "${interface}" ''
    printf 'raising %s on %s at %s\n' "${SSID}" "${interface}" "${ADDRESS}"

    # Foreground, so the unit's life is the network's life: hostapd dying is
    # the unit failing is note-stopped running is the page saying so.
    exec "${HOSTAPD}" "${RUN_DIR}/hostapd.conf"
}

note_stopped() {
    take_the_names
    local interface=''
    interface="$(find_wireless_interface)" || true
    if [[ -n "${interface}" ]]; then
        publish_status true false "${interface}" ''
    else
        publish_status false false '' 'no wireless interface on this machine'
    fi
    printf 'the hotspot is down\n'
}

case "${1:-run}" in
    run) run ;;
    note-stopped) note_stopped ;;
    *) die "unknown verb: $1 (run, note-stopped)" ;;
esac
