#!/bin/bash
#
# License: Copyright 2015 SpinetiX. This file is licensed
#          under the terms of the GNU General Public License version 2.
#          This program is licensed "as is" without any warranty of any
#          kind, whether express or implied.
#
# Copyright 1999-2003 MontaVista Software, Inc.
# Copyright 2002, 2003, 2004 Sony Corporation
# Copyright 2002, 2003, 2004 Matsushita Electric Industrial Co., Ltd.
#
### BEGIN INIT INFO
# Required-Start:
# Required-Stop:
# Should-Start:
# Should-Stop:
# Default-Start: 2 3 5
# Default-Stop:
# Short-Description: Starting/stopping SpinetiX's player Wi-Fi configurator
# Description: Starting/stopping SpinetiX's player Wi-Fi configurator
### END INIT INFO

# Init script information
NAME=spxmanage-wifi-cfg
DESC="SPX Wi-Fi configurator"

# This script needs pipelines to fail if any command in it fails
set -o pipefail

# Create intrerface
# Set proper MAC address if same as wlan0
# Start hostapd with minimal config
# Start dnsmasq

# Source the init script functions
. /etc/init.d/functions

# Source main resources
[ -f /usr/share/resources/default/init/main ] && . /usr/share/resources/default/init/main

# Do nothing on models not supporting Wi-Fi
[ "$rc_has_wlan" = "yes" ] || exit 0

PHY=phy0
BASE_IFACE=wlan0
CFG_IFACE=wlan-cfg
CFG_IFACE_HWADDR_CACHE=/var/cache/spxmanage/wifi-cfg-hw-address
WIFI_PASSPHRASE_CACHE=/var/cache/spxmanage/wifi-cfg-passphrase

# If this value is not "yes" this service is not run on NFS-root systems,
# as they are used for development and most often left unconfigured, we
# want to avoid creating plenty of APs that are left enabled in that case.
RUN_ON_NFSROOT=no

# Possible private IPv4 address with low probability of being used
# by other networks which may be connected to via other interfaces
# in order of preference. All addresses must end in .1
IPV4_CANDIDATES="192.168.106.1 192.168.167.1 172.24.72.1 172.21.221.1"

# The IPv4 and IPv6 network addresses are generated dynamically when setting up the interface
IPV4_ADDR=
IPV6_ADDR=
IPV4_PLEN=24
IPV6_PLEN=64
IPV4_DHCP_START=
IPV4_DHCP_END=

DOMAIN=dsos.link

# The domain names we need to take over and do DNS redirection to our IP addresses,
# typically to ensure that captive portal detection works on connecting devices.
# DNS lookups for these domains and any name under them will get the IPv4 or IPv6
# address of the Wi-Fi configuration interface.
TAKEOVER_DOMAINS="\
        captive.apple.com \
        msftncsi.com \
        msftconnecttest.com \
        clients1.google.com \
        clients3.google.com \
        connectivitycheck.android.com \
        connectivitycheck.gstatic.com \
        connectivitycheck.platform.hicloud.com \
        nmcheck.gnome.org \
        network-test.debian.org \
        connectivity-check.ubuntu.com \
        "

HTTPD_CONFIG=/run/spxmanage/httpd-wifi-cfg.inc
RPCREQUEST=/run/spxmanage/wifi-cfg-rpc.json
RPCRESPONSE=/run/spxmanage/wifi-cfg-rpc-response.json

WIFI_CONFIG_PAGE_PATH="/wifi"

# The Wi-Fi SSID and passphrase, set up during initialization
SSID=
PASSPHRASE=

# The Wi-Fi authentication type as per the WIFI: URL syntax from
# https://github.com/zxing/zxing/wiki/Barcode-Contents#wi-fi-network-config-android-ios-11
# and the Wi-Fi Alliance's "WPA3 Specification Addendum for WPA3" spec.
# WPA: if password is usable with WPA2-Personal and WPA3-Personal
# WPA3: if password is usable with WPA3-Personal only
# nopass: for no password (i.e. open)
# WPA2-EAP: for WPA2-Enterprise
WIFI_AUTH_TYPE=WPA

DNSMASQ=/usr/bin/dnsmasq
DNSMASQ_PID=/run/spxmanage/dnsmasq/dnsmasq.pid

# A comma separated list of CNAMEs pointing to the hostname
HOSTALIASES="dsos"

HOSTAPD=/usr/sbin/hostapd
HOSTAPD_CONF=/run/spxmanage/hostapd.conf
HOSTAPD_CONF_TEMPLATE=/usr/share/spxmanage/hostapd.conf
HOSTAPD_PID=/run/hostapd.pid

# Selects a private IPv4 address which cannot conflict with the
# network of another IPv4 local address currently present
select_ipv4_address() {
    local candidate
    local ifidx ifname inet addr plen other
    local anet cnet conflict

    conflict=1
    for candidate in $IPV4_CANDIDATES; do
        conflict=
        while read ifidx ifname inet addr plen other; do
            [ "$inet" = "inet" ] || continue
            [ "$ifname" != "lo" ] || continue
            # for overlap check use the minimum of prefix lengths (widest net)
            [ "$plen" -gt "$IPV4_PLEN" ] && plen="$IPV4_PLEN"
            anet="$(ipcalc -n "$addr"/"$plen" | cut -d= -f2)"
            cnet="$(ipcalc -n "$candidate"/"$plen" | cut -d= -f2)"
            if [ "$anet" = "$cnet" ]; then
                conflict=1
                break
            fi
        done < <(ip -4 -o addr show | sed -e 's|/| |')
        [ -z "$conflict" ] && break
    done
    if [ -n "$conflict" ]; then
        echo "could not find non-conflicting private IPv4 address"
        return 1
    fi
    if [ "${candidate##*.}" != "1" ]; then
        echo "IPv4 address does not end in .1"
        return 1
    fi
    IPV4_ADDR="$candidate"
    if [ "$IPV4_PLEN" -gt 25 ]; then
        echo "IPv4 network too small"
        return 1
    fi
    IPV4_DHCP_START="${IPV4_ADDR%.*}.10"
    IPV4_DHCP_END="${IPV4_ADDR%.*}.110"
}

# Selects a private IPv6 address which cannot conflict with the
# network of another IPv6 local address currently present.
# For IPv6 it suffices to choose a random prefix within the ULA
# (Unique Local Address) block.
select_ipv6_address() {
    local prefix

    # Create an IPv6 Unique Local Address network prefix, the block is fd00::/8
    # with 40 bit random hexstring to create a globally unique /48 prefix
    # plus 16 bit random hexstring to create a network within it and
    # reach the complete 64 bit network prefix address.
    # The generated value is something like "fdce:7be6:927b:d161" (no traling :: and no host part)
    prefix="$(echo -n fd$(od /dev/urandom -N1 -t x1 -An | cut -c 2-) && od /dev/urandom -N6 -t x2 -An | sed 's/ /:/g')"
    IPV6_ADDR="$prefix"::1
}

setup_wlan_iface() {
    local macmask basemac cfgmac ipv6net

    if [ -e /sys/class/net/$CFG_IFACE ]; then
        echo "conflicting WLAN interface exists"
        return 1
    fi

    select_ipv4_address && select_ipv6_address || return

    iw phy $PHY interface add $CFG_IFACE type managed || return

    # Set the TX power to a minimum, as we want it to behave as much as
    # possible as a Personal Area Network, minimize interference on other
    # Wi-Fi networks and avoid advertising ourselves all over the place.
    if ! iw dev $CFG_IFACE set txpower limit 100; then
        echo "failed to limit TX power"
        return 1
    fi

    # Some WLAN adapters have more than one MAC address that they can use
    # and thus automatically get a new different MAC address on the new
    # interface, others have a single MAC address and we need to generate
    # a new random one (locally administered, bit 1 of MSB set, and unicast,
    # bit 0 of MSB clear). Yet some others need to have all MAC addresses
    # fit within a mask, we do not support this latter case.
    macmask="$(< /sys/class/ieee80211/$PHY/address_mask)"
    if [ "$macmask" != "00:00:00:00:00:00" ]; then
        echo "WLAN adapters which do not support arbitrary MAC addresses are not supported"
        return 1
    fi
    cfgmac="$(< /sys/class/net/$CFG_IFACE/address)"
    basemac="$(< /sys/class/net/$BASE_IFACE/address)"
    if [ "$cfgmac" = "$basemac" ]; then
        [ -f "$CFG_IFACE_HWADDR_CACHE" ] && cfgmac="$(< "$CFG_IFACE_HWADDR_CACHE")" || cfgmac=
        if [ -z "$cfgmac" ] || echo "$cfgmac" | egrep -qv '^[[:xdigit:]]{2}(:[[:xdigit:]]{2}){5}$'; then
            cfgmac="$(printf '%02x' $((0x$(od /dev/urandom -N1 -t x1 -An | cut -c 2-) & 0xFE | 0x02)) && od /dev/urandom -N5 -t x1 -An | sed 's/ /:/g')"
            echo "$cfgmac" > "$CFG_IFACE_HWADDR_CACHE"
            fsync "$CFG_IFACE_HWADDR_CACHE" 2>/dev/null || true
        fi
        if ! ip link set $CFG_IFACE address "$cfgmac"; then
            rm -f "$CFG_IFACE_HWADDR_CACHE"
            echo "failed setting MAC address"
            return 1
        fi
    fi

    if ! ip -4 addr add "$IPV4_ADDR"/"$IPV4_PLEN" dev "$CFG_IFACE"; then
        echo "failed setting IPv4 address"
        return 1
    fi
    if ! ip -6 addr add "$IPV6_ADDR"/"$IPV6_PLEN" dev "$CFG_IFACE"; then
        echo "failed setting IPv6 address"
        return 1
    fi
}

setup_httpd_config() {
    local td

     cat > "$HTTPD_CONFIG" <<EOF
#
# Redirect configuration for captive portal pop-ups on connected
# devices to the Wi-Fi configuration page.
#

# We make use of RewriteRule, make sure it is enabled.
RewriteEngine on

# For each possible captive portal detection name or domain
# always return a redirect to the local interface Wi-Fi configuration
# page, using the IP address that received the request as the target
# host (the latter ensures the client can connect).

# Skip all the redirection rules below if the host header does
# not match the top-domain used as shortcut or any of the redirected
# host names or domains.
RewriteCond expr "! %{HTTP_HOST} -strcmatch '$DOMAIN'" [NV,NC]
EOF

    for td in $TAKEOVER_DOMAINS; do
        echo 'RewriteCond expr "! %{HTTP_HOST} -strcmatch '\'"$td"\''" [NV,NC]' >> "$HTTPD_CONFIG"
        echo 'RewriteCond expr "! %{HTTP_HOST} -strcmatch '\'"*.$td"\''" [NV,NC]' >> "$HTTPD_CONFIG"
    done

     cat >> "$HTTPD_CONFIG" <<EOF
RewriteRule ^ - [S=3,NS]

# If request was received by the local IPv4 address (i.e. has a dot) then
# redirect to a litteral IPv4 address, otherwise redirect to a litteral
# IPv6 address with the enclosing braces.
RewriteCond %{SERVER_ADDR} \\.
RewriteRule ^ - [S=1,NS]

# Redirect as IPv6 litteral
RewriteRule ^ http://[%{SERVER_ADDR}]$WIFI_CONFIG_PAGE_PATH [R,L,NS]

# Redirect as IPv4 litteral
RewriteRule ^ http://%{SERVER_ADDR}$WIFI_CONFIG_PAGE_PATH [R,L,NS]

EOF

    # Signal httpd to reload its configuration files
    httpd -k graceful
}

# The rationale for the DHCP and DNS configuration is as follows.
# - smartphones may refuse to use the browser if no router is set
#   (your are not connected to the Internet), so always provide
#   a router, even though we do not forward any packets
# - return "no such domain" for all domains except the ones locally defined
# - force a domain to avoid clients registering via DHCP from adding
#   bogus or malicious names
# - do not pick up external DNS servers from resolv.conf
# - do not use /etc/hosts to populate DNS data it has irrelevant and
#   potentially conflicting data
# - make $DOMAIN resolve to this host, but not the PTR target
# - do not save any lease data, this is a throw away server
# - DNS redirect known captive portal detection sites to the local addresses
# - do not DNS redirect just any name to the local addresses, this causes
#   a lot of trouble on clients with all kinds of certificate violation
#   warnings, failed websites, etc.
# - as most of the data is runtime dependent we provide all the
#   configuration on the command line and disable reading the default
start_dnsmasq() {
    local hostname="$(< /proc/sys/kernel/hostname)"
    local hosts_args
    local td

    hosts_args="--address=/#/"
    for td in $TAKEOVER_DOMAINS; do
        hosts_args="$hosts_args --address=/$td/$IPV4_ADDR --address=/$td/$IPV6_ADDR"
    done

    start-stop-daemon --start --quiet --pidfile "$DNSMASQ_PID" --exec "$DNSMASQ" -- \
         --pid-file="$DNSMASQ_PID" --conf-file=/dev/null \
         --user=dnsmasq --group=dnsmasq \
         --no-resolv --bind-interfaces --localise-queries --no-hosts --leasefile-ro \
         --interface="$CFG_IFACE" \
         --domain="$DOMAIN" --local=/"$DOMAIN"/ \
         --interface-name="$hostname.$DOMAIN","$CFG_IFACE" \
         --interface-name="$hostname","$CFG_IFACE" \
         --interface-name="$DOMAIN","$CFG_IFACE" \
         --cname="$HOSTALIASES","$hostname.$DOMAIN" \
         --dhcp-authoritative --no-ping \
         --dhcp-range="$IPV4_DHCP_START","$IPV4_DHCP_END" \
         --dhcp-range=::100,::164,constructor:"$CFG_IFACE",ra-stateless \
         --dhcp-option=option6:domain-search,"$DOMAIN" \
         $hosts_args
}

stop_dnsmasq() {
    start-stop-daemon --stop --quiet --oknodo --pidfile "$DNSMASQ_PID" --exec "$DNSMASQ"
}

start_hostapd() {
    local sn

    [ -n "$SPX_USER_SN" ] && sn="$SPX_USER_SN" || sn="$SPX_SN"
    if [ -z "$SPX_SN" ]; then
        echo "no serial number for SSID"
        return 1
    fi
    SSID="${rc_cfgap_ssid_prefix}${sn}"

    # Use a random alphanumeric password which is easy to type.
    # base32 (RFC 4648) uses alphabet [A-Z2-7], we convert it to lowercase since it is
    # easier to type lowercase on smartphones and replace letters l and o by 8 and 9
    # to avoid ambiguities. To ease manual copying we add dash (-) separators.
    # All these characters are also regular expression safe.
    # Minimum Wi-Fi passphrase length is 8, max is 63.
    [ -f "$WIFI_PASSPHRASE_CACHE" ] && PASSPHRASE="$(tr -dc -- '-a-kmnp-z2-9' < "$WIFI_PASSPHRASE_CACHE")" || PASSPHRASE=
    [ ${#PASSPHRASE} -ge 8 -a ${#PASSPHRASE} -le 63 ] || PASSPHRASE=
    if [ -z "$PASSPHRASE" ]; then
        # 9 random characters formatted in groups of 3 (e.g., d6m-w2h-728)
        PASSPHRASE="$(dd status=none bs=6 count=1 if=/dev/urandom | base32 | tr 'A-KLMNOP-Z' 'a-k8mn9p-z' | sed -e 's/^\(...\)\(...\)\(...\).*/\1-\2-\3/')"
        if ! [ ${#PASSPHRASE} -ge 8 ]; then
            echo "failed generating passphrase"
            return 1
        fi
        echo "$PASSPHRASE" > "$WIFI_PASSPHRASE_CACHE"
        fsync "$WIFI_PASSPHRASE_CACHE" 2>/dev/null || true
    fi
    sed -e "s/@@SSID@@/$SSID/;s/@@DOMAIN@@/$DOMAIN/;s/@@PASSPHRASE@@/$PASSPHRASE/" "$HOSTAPD_CONF_TEMPLATE" > "$HOSTAPD_CONF"
    if [ $? -ne 0 ]; then
        echo "failed to generate AP configuration"
        return 1
    fi
    start-stop-daemon --start --quiet --pidfile "$HOSTAPD_PID" --exec "$HOSTAPD" -- \
        -B -P "$HOSTAPD_PID" "$HOSTAPD_CONF" > /dev/null
}

stop_hostapd() {
    start-stop-daemon --stop --quiet --oknodo --pidfile "$HOSTAPD_PID" --exec "$HOSTAPD"
}

# Post the wifi-cfg data as a shared variable so that
# raperca can display it.
post_wificfg_data() {
    local vfilter cfilter ret retries

    # jq filter to create the shared variable value
    vfilter="$(cat <<'EOF'
{
            "iface": ($iface),
            "domain": ($domain),
            "ipv4addr": ($ipv4),
            "ipv6addr": ($ipv6),
            "ssid": ($ssid),
            "type": ($type),
            "pass": ($pass),
            "path": ($path)
}
EOF
)"

    # jq filter to create the RPC call
    cfilter="$(cat <<'EOF'
{
    "method": "webstorage_set",
    "params": [[{
        "name": "spx:setup:wifi-cfg",
        "value": .
        }]],
    "id": "wifi-cfg"
}
EOF
)"

    jq -n --arg iface "$CFG_IFACE" --arg domain "$DOMAIN" \
        --arg ipv4 "$IPV4_ADDR" --arg ipv6 "$IPV6_ADDR" \
        --arg ssid "$SSID" --arg type "$WIFI_AUTH_TYPE" --arg pass "$PASSPHRASE" \
        --arg path "$WIFI_CONFIG_PAGE_PATH" \
        "$vfilter" | \
        jq -sR "$cfilter" > "$RPCREQUEST"
    if [ $? -ne 0 ]; then
        echo "failed generating RPC request for wifi-cfg shared variable"
        return 1
    fi
    retries=2
    while : ; do
        rm -f "$RPCRESPONSE"
        curl --disable --fail --max-time 15 -o "$RPCRESPONSE" --silent \
            --data-binary @"$RPCREQUEST" -H "Content-Type: application/json" \
            http://localhost/rpc
        ret=$?
        # a shared variable may not be set before raperca opens the doc, so retry if the variable was not set
        if [ $ret -eq 0 ] && [ $retries -gt 0 ] && jq -e '.result[0].success == false' > /dev/null 2>&1 < "$RPCRESPONSE"; then
            retries=$((retries - 1))
            sleep 1
            continue
        fi
        rm -f "$RPCREQUEST"
        if [ $ret -ne 0 ] || ! jq -e 'has("error") and .error == null and .result[0].success == true' > /dev/null 2>&1 < "$RPCRESPONSE"; then
            echo "failed posting RPC request for wifi-cfg shared variable"
            if [ -f "$RPCRESPONSE" ]; then
                echo "RPC response:"
                # pretty print JSON if possible
                if jq . < "$RPCRESPONSE" >/dev/null 2>&1; then
                    jq . < "$RPCRESPONSE"
                else
                    cat "$RPCRESPONSE"
                fi
                rm -f "$RPCRESPONSE"
            fi
            return 1
        fi
        rm -f "$RPCRESPONSE"
        break
    done
}

start() {

    echo -n "Starting $DESC: "

    if ! [ -e /sys/class/ieee80211/$PHY ]; then
        echo -n "no WLAN adapter "; failure; echo
        return 1
    fi
    if ! [ -d /sys/class/net/$BASE_IFACE ]; then
        echo -n "WLAN interface $BASE_IFACE missing "; failure; echo
        return 1
    fi

    if [ -f /etc/spxmanage/configured ]; then
        echo -n "skipping, system already configured "; success; echo
        return 0
    fi
    if grep -q '^\s*auto\s\+'"$BASE_IFACE"'\b' /etc/network/interfaces; then
        echo -n "skipping, $BASE_IFACE already configured "; success; echo
        return 0
    fi
    if [ "$RUN_ON_NFSROOT" != "yes" ] && grep -q '\bnfsroot=' /proc/cmdline; then
        echo -n "skipping, running on nfsroot "; success; echo
        return 0
    fi

    [ -f /etc/spinetix/identifiers ] && . /etc/spinetix/identifiers

    echo -n "httpd "
    if ! setup_httpd_config; then
        failure; echo
        return 1
    fi

    echo -n "iface-setup "
    if ! setup_wlan_iface; then
        failure; echo
        return 1
    fi

    echo -n "dnsmasq "
    if ! start_dnsmasq; then
        failure; echo
        return 1
    fi

    echo -n "hostapd "
    if ! start_hostapd; then
        failure; echo
        return 1
    fi

    echo -n "sv-post "
    if ! post_wificfg_data; then
        failure; echo;
        return 1
    fi
    success; echo
}

stop() {

    echo -n "Stopping $DESC: "

    if ! stop_dnsmasq; then
        echo -n "failed to stop DHCP/DNS daemon"; failure echo
        return 1
    fi

    if ! stop_hostapd; then
        echo -n "failed to stop AP daemon"; failure echo
        return 1
    fi

    if [ -e /sys/class/net/$CFG_IFACE ]; then
        iw dev $CFG_IFACE del
    fi

    rm -f "$HTTPD_CONFIG"

    success; echo
}

parse() {
    case "$1" in
        start)
            start
            return $?
            ;;
        stop)
            stop
            return $?
            ;;
        *)
            echo "Usage: $NAME " \
                "{start|stop}" >&2
            ;;
    esac

    return 1
}

parse $@
