#!/bin/bash
#
# ap_manager

function print_usage() {
cat <<EOF
Usage:  `basename $0` [options]
Options:
 -R           Reset state - clears the state so that node numbering restarts at
              1 and all other variables (country, device) are cleared
 -S           Set state only according to provided options, do not program 
              device; useful for state configuration before programming actual
              devices
 -U           Update existing APs only, do not attempt discovery
 -D           Dry run - do everything except program the device; state will also
              not be altered after this unless done so explicitly
 -n NUM       Set node number, 1-20 [1 or next up from previous] - other values
              will be loaded from state
 -d DEVICE    Hardware device type; if the argument is empty lists all hardware
              devices available and returns; if not used the device variable
              will be loaded from the current state and if that is not available
              an error is shown
 -A           Use ACS (automatic channel assignment) for APs; in theory this
              avoids channel conflicts between APs but in practice it does not
              seem to work; when not used fixed channels are assigned
              round-robin
 -a ADDRESS   IP address of the target device [192.168.14.2]
 -N NETWORK   IP address of network in which nodes are assigned a static address
              [192.168.14.0]
 -G GATEWAY   IP address of gateway (server node) [192.168.14.1]
 -H HOSTNAME  Hostname root that will be assigned to node after configuration
              [signal_ap]
 -p PATH      Path to configuration state data [/var/lib/ap_manager/state]
 -P PATH      Path to device data [/usr/share/ap-manager]
 -h           Show this help

This script uses configuration file templates to configure openWRT APs as nodes
for Signalytic facilities. The hardware must previously have been configured
with the customized Signalytic version of openWRT. The openWRT version must be
23.05 or newer.
EOF
  exit 0
}

SUBNET_START=11   # node 1 will have IP address 192.168.14.11 (or according to network)
MAX_NUMBER=20     # with above limits node static IP range to 11-30

COUNTRY=$(bglocalconf | grep "lan[.]country"|cut -d= -f2|xargs)
if [ -z "$COUNTRY" ]; then
  bgsystemlog -s NETWORK -l ERROR -m "Node parameter lan.country not set"
  # default to UG because 00 breaks the radio
  COUNTRY="UG"
fi

# channel selection order to limit interference
# note: channel 13 should be ok (in most countries), but can be unreliable
CHANNELS=(1 6 11 9 3 8 4)
NUM_CHANNELS=${#CHANNELS[@]}

# Load SSID/PSK from node configuration - should also be locally configurable
SSID=$(bglocalconf | grep "lan[.]ssid"|cut -d= -f2|xargs)
[ -z "$SSID" ] && SSID="signalytic"
PSK=$(bglocalconf | grep "keys[.]wpa_psk"|cut -d= -f2|xargs)
[ -z "$PSK" ] && PSK="Blockgraph200"

RESET=0
STATE_ONLY=0
UPDATE_ONLY=0
DRY_RUN=0
LIST_DEVICES=0
STATE_PATH="/var/lib/ap_manager/state"
DATA_PATH="/usr/share/ap-manager"
NETCONF_PATH="/var/signalytic/etc/_network"

# default state
DEF_NEXT_NUMBER=1   # 1 means this is first node
DEF_DEVICE=""
DEF_ADDRESS="192.168.14.2"
DEF_NETWORK="192.168.14.0"
DEF_GATEWAY="192.168.14.1"
DEF_HOSTNAME="signal_ap"

while getopts ":RSDUn:d:X:k:a:N:G:H:p:P:h" options; do
  case "${options}" in
    R)
      RESET=1
      ;;
    S)
      STATE_ONLY=1
      ;;
    U)
      UPDATE_ONLY=1
      ;;
    D)
      DRY_RUN=1
      ;;
    n)
      SET_NEXT_NUMBER=${OPTARG}
      ;;
    d)
      SET_DEVICE=${OPTARG}
      ;;
    a)
      SET_ADDRESS=${OPTARG}
      ;;
    N)
      SET_NETWORK=${OPTARG}
      ;;
    G)
      SET_GATEWAY=${OPTARG}
      ;;
    H)
      SET_HOSTNAME=${OPTARG}
      ;;
    p)
      STATE_PATH=$(readlink -f ${OPTARG})
      ;;
    P)
      DATA_PATH=$(readlink -f ${OPTARG})
      ;;
    :)
      if [ "${OPTARG}" = "d" ]; then    # -d without argument
        LIST_DEVICES=1
      else
        echo "ERROR: option -${OPTARG} requires an argument"
        exit 1
      fi
      ;;
    *)
      print_usage
      ;;
  esac
done
shift $((OPTIND -1))

# Exit on error:
set -e
trap 'Error: Last command failed with exit code $?."' ERR

if [ ${LIST_DEVICES} -eq 1 ]; then
  echo "Available devices:"
  for dir in ${DATA_PATH}/devices/*/; do
    echo "  $(basename $dir)"; 
  done
  exit 0
fi

mkdir -p ${STATE_PATH}
STATE_FILE="${STATE_PATH}/current"
if [ ${RESET} -eq 1 ]; then
  echo "Reset state"
  rm -f ${STATE_FILE}
fi

SSH_KEY="${STATE_PATH}/.ssh/id_apmanager"
if [ ! -f ${SSH_KEY} ]; then
  mkdir -p "${STATE_PATH}/.ssh"
  ssh-keygen -t rsa -N "" -f ${SSH_KEY} -q
fi
SSHOPTS="-i ${SSH_KEY} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"

# Functions

function wait_for_ping() {
  local timeout=$1
  local interval=$2
  local address=$3
  local count=0
  echo -n "Trying to connect to ${address}: "
  while true; do
    count=$(( count + interval ))
    # Try to reach address
    if ping -W ${interval} -c 1 ${address} &> /dev/null; then
      echo "OK"
      return 0
    elif [ $count -ge $timeout ]; then
      echo ""
      return 1
    fi
    echo -n "."
  done
}

# initialize a new AP, locking down ssh and fetching device info
function init_ap() {
  local current_address=$1
  local ap_index=$2
  echo "Initializing AP$ap_index ($current_address)"

  # Write our cert to the remote
  ssh ${SSHOPTS} root@${current_address} "tee -a /etc/dropbear/authorized_keys" < ${SSH_KEY}.pub

  # Turn off password authentication on device
  ssh -q ${SSHOPTS} "root@${current_address}" << EOF
uci set dropbear.@dropbear[0].PasswordAuth="0"
uci set dropbear.@dropbear[0].RootPasswordAuth="0"
uci commit dropbear
passwd -l root
service dropbear restart
EOF

  # Remote device
  local ap_state="${STATE_PATH}/device/${ap_index}"
  rm -rf ${ap_state}
  mkdir -p ${ap_state}
  echo "Remote device:"
  ssh -q ${SSHOPTS} "root@${current_address}" cat /proc/cpuinfo > "${ap_state}/cpuinfo"
  local device_machine=$(grep "^machine\b" ${ap_state}/cpuinfo|cut -d: -f2|xargs)
  local device_type=$(grep "^system type\b" ${ap_state}/cpuinfo|cut -d: -f2|xargs)
  echo "  Hardware: ${device_machine} (${device_type})"

  ssh -q ${SSHOPTS} "root@${current_address}" cat /etc/os-release > "${ap_state}/os-release"
  local os_id=$(grep "^ID\b" ${ap_state}/os-release|cut -d= -f2|xargs)
  local os_version_id=$(grep "^VERSION_ID\b" ${ap_state}/os-release|cut -d= -f2|xargs)
  echo "  Firmware: ${os_id} version ${os_version_id}"
}

# get value by key from key=value network config
netcfg_get() {
  local key="$1" val
  val=$(awk -v k="$key" -F= '
    /^[[:space:]]*(#|$)/ { next }
    {
      lhs=$1
      gsub(/^[[:space:]]+|[[:space:]]+$/, "", lhs)
      if (lhs != k) next

      v = substr($0, index($0, "=") + 1)
      gsub(/^[[:space:]]+|[[:space:]]+$/, "", v)
      if (v ~ /^".*"$/) v = substr(v, 2, length(v) - 2)
      print v; exit
    }
  ' "$NETCONF_PATH")
  printf '%s' "$val"
}

# UCI config template writers
write_bridge_vlan() {
  local vid="$1" ap_if="$2" outfile="$3"
  cat <<EOF >>"$outfile"
config bridge-vlan
  option device 'br-default'
  option vlan '${vid}'
  list ports 'wan:t'
  list ports 'self'
  list ports '${ap_if}:u*'

EOF
}

write_wifi_iface() {
  local vid="$1" ssid="$2" psk="$3" outfile="$4"
  cat <<EOF >>"$outfile"
config wifi-iface 'signalap_v${vid}'
  option device 'radio0'
  option network 'default'
  option mode 'ap'
  option ssid '${ssid}'
  option encryption 'psk2+aes'
  option key '${psk}'
  option multicast_to_unicast '1'
  option disabled '0'
  option hostapd_bss_options 'ap_isolate=0'

EOF
}

# (re)generate config file templates for an AP using the current configuration
function update_templates() {
  local ap_index=$1
  local ap_state="${STATE_PATH}/device/${ap_index}"
  printf -v final_hostname "%s%03d" ${hostname} ${ap_index}
  local final_address="${network%.*}.$((ap_index+SUBNET_START-1))"
  local i_ch=$(( ((ap_index-1)) % NUM_CHANNELS ))
  local ap_channel=${CHANNELS[$i_ch]}

  # Generate configuration
  local generated_dir=${ap_state}/config/latest
  mkdir -p ${generated_dir}
  cp ${DATA_PATH}/devices/${device}/config-template/* ${generated_dir}/
  sed -i -e "s/_CONFIG_HOSTNAME_/${final_hostname}/g" ${generated_dir}/*
  sed -i -e "s/_CONFIG_ADDRESS_/${final_address}/g" ${generated_dir}/*
  sed -i -e "s/_CONFIG_GATEWAY_/${gateway}/g" ${generated_dir}/*
  sed -i -e "s/_CONFIG_COUNTRY_/${COUNTRY}/g" ${generated_dir}/*
  sed -i -e "s/_CONFIG_2G_CHANNEL_/${ap_channel}/g" ${generated_dir}/*
  sed -i -e "s/_CONFIG_SSID_/${SSID}/g" ${generated_dir}/*
  sed -i -e "s/_CONFIG_PSK_/${PSK}/g" ${generated_dir}/*

  # Extract VLAN IDs defined in lan.vlan{VID}.*
  mapfile -t vids < <(
    awk '
      /^[[:space:]]*(#|$)/ { next }
      {
        line = $0
        gsub(/^[[:space:]]+|[[:space:]]+$/, "", line)
        if (line ~ /^lan\.vlan[0-9]+\./) {
          vid = line
          sub(/^.*lan\.vlan/, "", vid)
          sub(/\..*$/, "", vid)
          print vid
        }
      }
    ' "$NETCONF_PATH" | sort -n -u
  )

  local vid ssid psk ap_if
  local ap_idx=1 # ap0 is reserved for default network

  for vid in "${vids[@]:-}"; do
    [[ -n "${vid:-}" && "$vid" =~ ^[0-9]+$ ]] || continue

    ssid="$(netcfg_get "lan.vlan${vid}.ssid" || true)"
    psk="$(netcfg_get "lan.vlan${vid}.psk" || true)"

    # only generate when both ssid and psk exist
    if [[ -n "${ssid:-}" && -n "${psk:-}" ]]; then
      ap_if="phy0-ap${ap_idx}"
      write_bridge_vlan "$vid" "$ap_if" "${generated_dir}/network"
      write_wifi_iface "$vid" "$ssid" "$psk" "${generated_dir}/wireless"
      ap_idx=$((ap_idx + 1))
    fi
  done
}

# returns 0 iff config files in 'current' and 'latest' match
function ap_up_to_date() {
  local ap_index=$1
  diff -q -r ${STATE_PATH}/device/${ap_index}/config/{current,latest} &>/dev/null
}

# send and apply updated config files to AP and update local device state
function configure_ap() {
  local current_address=$1
  local ap_index=$2
  local ap_state="${STATE_PATH}/device/${ap_index}"

  if [ ${DRY_RUN} -eq 1 ]; then
    echo "WARNING: dry-run - skipping device programming"
  else
    echo "Configuring AP${ap_index}"
    # Send configuration and apply
    scp -O -q ${SSHOPTS} ${ap_state}/config/latest/* "root@${current_address}:/etc/config/"
    ssh -q ${SSHOPTS} "root@${current_address}" << EOF
uci commit system
uci commit network
uci commit wireless
reboot
EOF
    # update 'current' state
    mkdir -p ${ap_state}/config/current/
    cp ${ap_state}/config/latest/* ${ap_state}/config/current/
  fi
}

# Load current state
if [ -f "${STATE_FILE}" ]; then
  source ${STATE_FILE}
else
  next_number=${DEF_NEXT_NUMBER}
  device=${DEF_DEVICE}
  address=${DEF_ADDRESS}
  network=${DEF_NETWORK}
  gateway=${DEF_GATEWAY}
  hostname=${DEF_HOSTNAME}
fi
# Apply overrides from options
[ "$SET_NEXT_NUMBER" ] && next_number=${SET_NEXT_NUMBER}
[ "$SET_DEVICE" ]      && device=${SET_DEVICE}
[ "$SET_ADDRESS" ]     && address=${SET_ADDRESS}
[ "$SET_NETWORK" ]     && network=${SET_NETWORK}
[ "$SET_GATEWAY" ]     && gateway=${SET_GATEWAY}
[ "$SET_HOSTNAME" ]    && hostname=${SET_HOSTNAME}

echo "Configuration:"
echo "  next_number:  ${next_number}"
echo "  device:       ${device}"
echo "  address:      ${address}"
echo "  network:      ${network}"
echo "  gateway:      ${gateway}"
echo "  hostname:     ${hostname}"
echo "Other settings:"
echo "  country:      ${COUNTRY}"
echo "  ssid:         ${SSID}"

# Sanity checks
if [ -z "$COUNTRY" ]; then
  echo "ERROR: country is not set."
  exit 1
elif [ ${#COUNTRY} -ne 2 ]; then
  echo "ERROR: country code must be 2 letters."
  exit 1
elif [ -z "$device" ]; then
  echo "ERROR: device is not set."
  exit 1
elif [ ! -d "${DATA_PATH}/devices/${device}" ]; then
  echo "ERROR: there is no configuration for device '${device}'"
  exit 1
elif [ ${next_number} -gt ${MAX_NUMBER} ]; then
  echo "ERROR: node number exceeds set maximum of ${MAX_NUMBER}"
  exit 1
fi

# Skip configuration generation and programming if we do state only
if [ ${STATE_ONLY} -ne 1 ]; then

  if [ ${UPDATE_ONLY} -ne 1 ]; then
    echo "Searching for new devices..."

    # Wait for connection on address:
    if wait_for_ping 3 1 ${address}; then
      # We have a ping
      echo "Connected to ${address}"

      # This increases next_number until there is no ping - in case state was wiped
      while
        # Generate new IP and test if it exists
        final_address="${network%.*}.$((next_number+SUBNET_START-1))"
        wait_for_ping 3 1 ${final_address}
      do ((next_number++)); done

      init_ap $address $next_number
      update_templates $next_number
      configure_ap $address $next_number
      (( next_number++ ))
    else
      echo "No new AP detected at ${address}"
    fi
  fi

  # check for and apply updates
  echo "Checking for updates..."
  for i in $(seq 1 $((next_number-1))); do
    update_templates $i
    if ap_up_to_date $i; then
      echo "AP$i up to date"
    else
      echo "AP$i needs update"
      current_address="${network%.*}.$((i+SUBNET_START-1))"
      if wait_for_ping 3 1 ${current_address}; then
        configure_ap $current_address $i
      else
        echo "WARNING: No response from AP$i ($current_address)"
      fi
    fi
  done
fi

# Save state
cat << EOF > ${STATE_FILE}
next_number=${next_number}
device="${device}"
address="${address}"
network="${network}"
gateway="${gateway}"
hostname="${hostname}"
EOF

echo "Done"

