#!/usr/bin/bash
# -*- mode: sh; sh-basic-offset: 2; indent-tabs-mode: nil; -*-
# vim:set ft=sh et sw=2 ts=2:
#
# radvd-gen v1.5.2 - Generate radvd.conf from template based on ip state
# Author: Scott Shambarger <devel@shambarger.net>
#
# Copyright (C) 2018-21 Scott Shambarger
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
# Usage: [ -d ] [ -v ] [ <interface> [ <action> ] ]
#
# Generates radvd.conf file from source template, substituting dynamic
# prerix entries with ones discovered from the OS, and including
# valid and prefered lifetimes.
#
# TO INSTALL:
#   copy to /usr/lib/NetworkManager/dispatcher.d/95-radvd-gen
#   create template in /usr/lib/NetworkManager/radvd.conf.templ
#   optionally copy /etc/nmutils/ipv6_utils.sh (allows prefix grouping)
#   optionally override settings with /usr/lib/NetworkManager/radvd-gen.conf
#
# Settings (set in /usr/lib/NetworkManager/radvd-gen.conf)
#
#   PERDIFF (default: 10) - minimum percentage difference between
#       valid or preferred lifetimes on interface address and
#       radvd.conf values to trigger rewriting radvd.conf
#
#   RADVD_GROUP (default: radvd) - group allowed to read generated
#       radvd.conf (set empty to not change)
#
#   RESTORECON_EXE (default: restorecon in path, optional)
#       used to correct SELinux context on radvd.conf (set empty
#       to not use even if available)
#
# Script is triggered on any interface up/down, or dhcp6 address
# change, but can also be triggered by other scripts, for example:
#
#    /etc/nmutils/conf/ipv6-prefix-lan0-from-wan0.conf
#      NMG_RADVD_TRIGGER="/usr/lib/NetworkManager/dispatcher.d/95-radvd-gen
#
# Existing radvd.conf is parsed to discover current settings, and if
# new settings are not significantly different (similar timeouts within
# $PERDIFF percentage), radvd is only signaled to reset the decrementing
# lifetimes (but then only if some prefix has enabled that)
#
# Example template is:
#
# interface lan1 {
#	AdvSendAdvert on;
#	MinRtrAdvInterval 30;
#       @PREFIX@ {
#		AdvAutonomous on;
#		DecrementLifetimes on;
#       };
#       prefix ffdd:1234:1234::/64 {
#		AdvValidLifetime infinity;
#	};
# };
#
# Multiple interface sections are supported.
#
# Any missing AdvValidLifetime/AdvPreferredLifetime values will be
# added with current values found from the interface (this appies to
# both dynamically created prefixes for @PREFIX@, or statically
# defined prefixes like ffdd:1234:1234::/64 above).
#
# @PREFIX@ specific options are optional, just "@PREFIX@" (w/o { }) is ok.
#
# TODO:
#
# Unclear what should happen if no interfaces are added to
# radvd.conf - radvd tends to crash in this case...perhaps remove
# the file and kill radvd?
#
# shellcheck disable=SC1090

[[ $TRACE ]] && set -x

#
# DEFAULT CONFIG (override in radvd-gen.conf or environment)
#

# Default config/template locations accessable by dispatcher scripts
RADVDGEN_CONF=${RADVDGEN_CONF:-/usr/lib/NetworkManager/radvd-gen.conf}

# ipv6_utils (optional)
ipv6_prefix() { echo -n "${1-}"; } # stub
IPV6_UTILS=${IPV6_UTILS:-/etc/nmutils/ipv6_utils.sh}

# template and config locations
SRC=/usr/lib/NetworkManager/radvd.conf.templ
DST=/etc/radvd.conf

# percentage difference to trigger new config
PERDIFF=10
# group that can read radvd.conf (set empty for default)
RADVD_GROUP=radvd

# external binaries (IP_EXE/MKTEMP_EXE are required)
IP_EXE=$(command -v ip)
MKTEMP_EXE=$(command -v mktemp)
# restorecon used if availble to correct SELinux context (set empty to skip)
RESTORECON_EXE=$(command -v restorecon)

SC_EXE=$(command -v systemctl) # set empty to disable
# use KILL_EXE/PID if SC_EXE not avail/disabled
RADVD_PID=/run/radvd/radvd.pid
KILL_EXE=$(command -v kill) # set empty to disable

# load config, if any
[ -r "$RADVDGEN_CONF" ] && . "$RADVDGEN_CONF"
# load ipv6_utils if available
[ -r "$IPV6_UTILS" ] && . "$IPV6_UTILS"

verbose=
declare -i debug=0

[[ $VERBOSE ]] && verbose=1
[[ $DEBUG ]] && debug=$DEBUG

usage() {
  echo "Generate '$DST' from template '$SRC'"
  echo "Usage: [ -d ] [ -v ] [ <interface> [ <action> ] ]"
  echo " -d - enable debug output (repeat for more)"
  echo " -v - verbose output"
  echo " <interface> - interface (ignored)"
  echo " <action> - up | dhcp6-change | down (default: up, unlisted ignored)"
  exit 0
}

while :; do
  case "$1" in
    -v|--verbose) verbose=1;;
    -d|--debug) ((debug++));;
    -h) usage;;
    -*) echo >&2 "Unknown option '$1' (-h for help)"; exit 1;;
    *) break;;
  esac
  shift
done

#interface=$1
action=$2

# if no action, default to up
action=${action:-up}

#
# LOGGING FUNCTIONS
#

err() {
  local IFS=' '; echo >&2 "$*"; return 0
}

# backtrace to stderr, skipping <level> callers
backtrace() { # <level>
  local -i x=$1; echo >&2 "Backtrace: <line#> <func> <file>"
  while :; do ((x++)); caller >&2 "$x" || return 0; done
}

# print <msg> to stderr, and dump backtrace of callers
fatal() { # <msg>
  local IFS=' '; echo >&2 "FATAL: $*"
  (( debug > 0 )) && backtrace 0
  exit 1
}

# fd for debug/verbose output
exec 3>&1

xdebug() { # <msg>
  local IFS=' '; printf >&3 "%16s: %s\\n" "${FUNCNAME[2]}" "${*//$'\a'/\\}"
  return 0
}

if (( debug > 0 )); then debug() { xdebug "$@"; }; else debug() { :; }; fi
if (( debug > 1 )); then debug2() { xdebug "$@"; }; else debug2() { :; }; fi
if (( debug > 2 )); then debug3() { xdebug "$@"; }; else debug3() { :; }; fi

verbose() { # <msg>
  [[ $verbose ]] || return 0
  local IFS=' '; echo >&3 "$*"; return 0
}

#
# DATASTORE INTERNAL FUNCTIONS
#

# INTERNAL: declare global DS if it's not an assoc array in scope
_ds_init() {
  (( BASH_VERSINFO[0] >= 4 )) || fatal "Bash v4+ required"
  local v; v=$(declare 2>/dev/null -p -A DS)
  if [[ -z $v || ${v#declare -A DS} == "$v" ]]; then
    # we can declare global DS in bash 4.2+"
    (( BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 2 )) && \
      fatal "'declare -A DS' must be declared before using datastore!"
    debug3 "Initializing DS"
    unset -v DS 2>/dev/null; declare -gA DS
  fi
  unset -f _ds_init
  _ds_init() { :; }
}

# INTERNAL: sets _n=\a<i>\a<i2>...\a<in>
_ds_name() { # <n> <i1>...<in> ...(ignored)...
  local IFS=$'\a'; local -i n=$1; _n=$'\a'${*:2:$n}
}

# INTERNAL: safely assign <var>=<value>
_ds_ret() { # <var> <value>
  [[ $1 ]] && printf 2>/dev/null -v "$1" %s "$2"
  [[ $2 ]]
}

if (( debug > 1 )); then
  _ds_ret() { # <var> <value>
    [[ $1 ]] && printf 2>/dev/null -v "$1" %s "$2" && debug2 " $1 => ${!1}"
    [[ $2 ]] && { [[ $1 ]] || debug2 " => <true>"; return 0; }
    [[ $1 ]] || debug2 " => <false>"; return 1
  }
fi


#
# DS USER FUNCTIONS
#
# optionally "local -A DS" before using...
#

# dump contents of DS to debug
ds_dump() {
  declare -p DS | \
    sed -e 's/[[]\$'\''/\n  [/g' -e 's/'\''[]]/]/g' -e 's/\\a/\\/g' | \
    grep '[[]' | sort >&3 -b -t \\ -k 2 -k 1
}

# DS[_ds_name($@)]=<value>...
ds_nset() { # <n> [ <key1>...<keyn> ] [ <value>... ]
  _ds_init
  local _n iname value IFS=' '
  _ds_name "$@"; shift "$1"; value=${*:2}
  debug3 "$_n=$value"
  [[ $value ]] || return
  [[ ${DS[_$_n]} ]] && DS[_$_n]=$value && return
  iname=${_n%$'\a'*}
  local -i i=${DS[i$iname]}
  DS[k$iname$'\a'$i]=${_n##*$'\a'}; ((i++))
  DS[i$iname]=$i
  DS[_$_n]=$value
}

# short for ds_nset(1 <key> <value>...)
ds_set() { # <key> <value>...
  if [[ $1 ]]; then ds_nset 1 "$@"; else ds_nset 0 "${@:2}"; fi
}

# <ret>=value identified by <key1>...<keyn> (true if value)
ds_nget() { # <ret> <n> <key1>...<keyn>
  local _n
  _ds_name "${@:2}"
  debug3 "$_n => ${DS[_$_n]}"
  _ds_ret "$1" "${DS[_$_n]}"
}

# short for ds_nget(<ret> 1 <key>) (true if value)
ds_get() { # <ret> <key>
  if [[ $2 ]]; then ds_nget "$1" 1 "$2"; else ds_nget "$1"; fi
}

# <ret>=<i>th key below <key1>...<keyn> (true if value)
ds_ngeti() { # <ret> <n> <key1>...<keyn> <i>
  local _n _key
  local -i _i=$2
  ((_i+=3)); _i=${*:$_i:1}
  (( _i < 0 )) && { _ds_ret "$1"; return; }
  _ds_name "${@:2}"
  _key=${DS[k$_n$'\a'$_i]}
  debug3 "$_n#$_i => $_key"
  _ds_ret "$1" "$_key"
}

#
# A FEW UTITLITY FUNCTIONS
#

strip() { # <var> <text>
  debug "$@"
  [[ $2 =~ ^[[:space:]]*(.*[^[:space:]])?[[:space:]]*$ ]] || :
  printf -v "$1" "%s" "${BASH_REMATCH[1]-}"
}

convert() { # <ret> <value>
  debug2 "$@"
  local ts=$2
  if [[ $ts == forever ]]; then
    ts=infinity
  elif [[ $ts == [0-9]*sec ]]; then
    ts=${ts%sec}
  fi
  printf -v "$1" %s "$ts"
}

# var=2nd word of <text>, trailing ';' stripped
parse_2nd() { # <var> <text>
  local _w IFS=' '; read -r -a _w <<< "$2"
  printf -v "$1" %s "${_w[1]%%;*}"
}

#
# INTERFACE/PREFIX DATASTORE
#

set_iface() { # <iface>
  debug "$@"
  ds_nset 2 IFACES "$1" -
}

set_iface_key() { # <iface> <key> <value>
  [[ ! $3 ]] && return
  debug "$@"
  ds_nset 3 IFACES "$@"
}

is_iface_key() { # <iface> <key> (true if value)
  debug "$@"
  ds_nget "" 3 IFACES "$@"
}

# ret=interface #n (true if value)
get_iface() { # <ret> <n>
  debug "$@"
  ds_ngeti "$1" 1 IFACES "$2"
}

set_prefix() { # <iface> <prefix>
  debug "$@"
  ds_nset 4 IFACES "$1" prefix "$2" -
}

set_prefix_key() { # <iface> <prefix> <key> <value>
  [[ ! $4 ]] && return
  debug "$@"
  ds_nset 5 IFACES "$1" prefix "${@:2}"
}

# ret=value for <iface> <prefix> <key> (true if value, <ret> may be empty)
get_prefix_key() { # <ret> <iface> <prefix> <key>
  debug "$@"
  ds_nget "$1" 5 IFACES "$2" prefix "${@:3}"
}

# short for get_prefix_key "" ... (true if value)
is_prefix_key() { # <iface> <prefix> <key>
  get_prefix_key "" "$@"
}

# ret=prefix #n on iface (true if value)
get_prefix() { # <ret> <iface> <n>
  debug "$@"
  ds_ngeti "$1" 3 IFACES "$2" prefix "$3"
}

# ret=key #n for iface prefix (true if value)
get_prefix_keyi() { # <ret> <iface> <prefix> <n>
  debug "$@"
  ds_ngeti "$1" 4 IFACES "$2" prefix "${@:3}"
}

#
# IPV6 PREFIX CLEANUP
#

# Echoes compacted prefix (or original if ipv6_utils not loaded)
compact_prefix() { # <ip6-addr>/<prefix>
  local n="${1#*/}" oifs=$IFS
  [[ $n == "$1" ]] && n=
  [[ $n ]] || n=64
  unset IFS
  echo -n "$(ipv6_prefix "${1%/*}" "$n")/$n"
  IFS=$oifs
}

#
# RADVD.CONF READ/PARSE/WRITE
#

# Sets following values to indicate decrementing counters
#   <iface> decr 1
#   <iface> <prefix> decr 1
# If static prefix, also flags RESET
set_decr_iface() { # <iface> <prefix>
  local iface=$1 prefix=$2
  if [[ $prefix != dynamic ]]; then
    verbose "      Resetting radvd, $prefix decrementing"
    ds_set RESET 1
  fi
  set_iface_key "$iface" decr 1
  set_prefix_key "$iface" "$prefix" decr 1
}

set_prefix_vals() { # <mode> <iface> <prefix> <valid> <pref> <decr> <has_decr>
  debug "$@"
  local mode=$1 iface=$2 prefix=$3 valid=$4 pref=$5 decr=$6 has_decr=$7

  verbose "      Adding $prefix valid=$valid pref=$pref decr=$decr has_decr=$has_decr"
  set_prefix "$iface" "$prefix"
  set_prefix_key "$iface" "$prefix" "${mode}_valid" "$valid"
  set_prefix_key "$iface" "$prefix" "${mode}_pref" "$pref"
  set_prefix_key "$iface" "$prefix" "${mode}_decr" "$decr"
  set_prefix_key "$iface" "$prefix" "${mode}_has_decr" "$has_decr"
  if [[ $mode == src ]]; then
    [[ $prefix == dynamic ]] && set_iface_key "$iface" dynamic 1
    if [[ ! ( $valid = infinity && $pref = infinity ) ]]; then
      [[ $decr ]] && set_decr_iface "$iface" "$prefix"
    fi
  fi
}

# parse { }; and ; in $line (used in read_file and gen_file)
parse_braces() {
  save_prefix=
  if [[ $line =~ \{ ]]; then
    case $state in
      interface|prefix) state=${state}-opts;;
      child-opts) err "Parse depth error in file \"$src\":$lno"; return 1;;
      *) parent=$state state=child-opts;;
    esac
  elif [[ $line =~ \}\; ]]; then
    case $state in
      prefix-opts) state=interface-opts save_prefix=$prefix prefix=;;
      interface-opts) state=global iface=;;
      child-opts) state=$parent parent=;;
      *) err "Parse nesting error in file \"$src\":$lno"; return 1;;
    esac
  elif [[ $line =~ \; ]]; then
    case $state in
      interface) state=global iface=;;
      prefix) state=interface-opts save_prefix=$prefix prefix=;;
    esac
  fi

  return 0
}

# parse global radvd.conf section
parse_global() {
  [[ ! $line =~ ^interface ]] && return 0

  state=interface
  parse_2nd iface "$line"
  [[ ! $iface ]] &&
    err "Unable to parse interface in \"$src\":$lno" && return 1
  verbose "  Found interface $iface"

  return 0
}

# parse interface radvd.conf section
parse_interface() {
  if [[ $line =~ ^prefix ]]; then
    state=prefix
    parse_2nd prefix "$line"
    [[ ! $prefix ]] &&
      err "Unable to parse prefix in \"$src\":$lno" && return 1
    prefix=$(compact_prefix "$prefix")
    verbose "    Found static prefix $prefix"
  elif [[ $line =~ ^@prefix@ ]]; then
    state=prefix prefix=dynamic
    verbose "    Found dynamic prefix"
    [[ $line =~ \{ || $line =~ \; ]] || line+=";"
  fi
}

# Parses <file> to determine interface and prefix settings
# Sets the following values based <mode> ("cur" for existing, or "src"):
#   <iface> <mode> 1 - if interface exists in that file
#   <iface> dynamic 1 - if interface is @PREFIX@
#   <iface> decr 1 - if interface has prefixes with decr times
#   <iface> <prefix> decr 1 - static prefix has decrementing times
#   <iface> <prefix> <mode> 1 - prefix declared in <mode>
#   <iface> <prefix> <mode>_valid # - valid lifetime in <mode> (# or infinity)
#   <iface> <prefix> <mode>_pref # - pref lifetime in <mode> (# or infinity)
#   <iface> <prefix> <mode>_decr 1 - decrement on in <mode>
#   <iface> <prefix> <mode>_has_decr 1 - decrement declared in <mode>
read_file() { # cur|src <file>
  debug "$@"
  local mode=$1 src=$2
  [[ -r $src ]] || return

  verbose "Parsing $mode file \"$src\""

  local iface prefix val valid pref decr has_decr save_prefix

  local -i lno=0
  local -l line # lowercase
  local IFS=$'\n' state=global parent=
  while read -r line || [[ $line ]]; do
    debug2 "state=$state parent=$parent iface=$iface prefix=$prefix"
    strip line "$line"
    ((lno++))

    [[ $line =~ ^# ]] && continue

    case $state in
      global)
        parse_global || return 1
        if [[ $state == interface ]]; then
          set_iface "$iface"
          set_iface_key "$iface" "$mode" 1
        fi
        ;;
      interface-opts)
        parse_interface || return 1
        if [[ $prefix == dynamic && $mode != src ]]; then
          err "@PREFIX@ found in \"$src\":$lno!" && return 1
        fi
        ;;
      prefix-opts)
        parse_2nd val "$line"
        if [[ $line =~ ^advvalidlifetime ]]; then
          verbose "      Found valid-life $val"
          valid=$val
        elif [[ $line =~ ^advpreferredlifetime ]]; then
          verbose "      Found pref-life $val"
          pref=$val
        elif [[ $line =~ ^decrementlifetimes ]]; then
          verbose "      Found decrement $val"
          [[ $val =~ ^on ]] && decr=1 || decr=
          has_decr=1
        fi
        ;;
    esac

    parse_braces || return 1

    if [[ $save_prefix ]]; then
      if [[ $mode == src || $save_prefix != dynamic ]]; then
        if [[ $save_prefix != dynamic ]]; then
          set_prefix "$iface" "$save_prefix"
          set_prefix_key "$iface" "$save_prefix" "$mode" 1
        fi
        set_prefix_vals "$mode" "$iface" "$save_prefix" "$valid" "$pref" "$decr" "$has_decr"
      fi
      valid='' pref='' decr='' has_decr=''
    fi
  done < "$src"

  [[ $state != global ]] &&
    err "Parse error in $mode file \"$src\":$lno" && return 1

  return 0
}

# ret=max of <a> and <b> lifetimes
get_max_lifetime() { # <ret> <a> <b>
  local a=$2 b=$3
  if [[ $a == infinity || $b == infinity ]]; then
    a=infinity
  else
    (( a < b )) && a=$b
  fi
  printf -v "$1" %s "$a"
}

# ret=min of <a> and <b> lifetimes
get_min_lifetime() { # <ret> <a> <b>
  local a=$2 b=$3
  if [[ $a == infinity ]]; then
    a=$b
  elif [[ $b != infinity ]]; then
    (( a > b )) && a=$b
  fi
  printf -v "$1" %s "$a"
}

# Examines <iface> for prefix addresses
# Sets the following values
#   <iface> <prefix> wired 1 - <prefix> found
#   <iface> <prefix> wired_valid # - valid lifetime (# or infinity)
#   <iface> <prefix> wired_pref # - pref lifetime (# or infinity)
get_addrs() { # <iface>
  local e state=text pfx valid pref iface=$1 valid2 pref2

  verbose "  Looking for prefixes on interface $iface"

  for e in $("$IP_EXE" -6 addr show dev "$iface" scope global); do
    case "$state" in
      text)
        case "$e" in
          inet6) state=inet6;;
          valid_lft) state=valid;;
          preferred_lft) state=pref;;
        esac
        ;;
      inet6)
        pfx='' valid='' pref=''
        [[ $e =~ [0-9a-f:]+/[0-9]+ ]] && pfx=$(compact_prefix "$e")
        state=text
        ;;
      valid)
        [[ $e =~ [0-9]*(sec|forever)+ ]] && convert valid "$e"
        state=text
        ;;
      pref)
        [[ $e =~ [0-9]*(sec|forever)+ ]] && convert pref "$e"
        state=text
        ;;
    esac
    if [[ $pfx && $valid && $pref ]]; then
      verbose "    Found prefix=$pfx valid=$valid pref=$pref"
      set_prefix "$iface" "$pfx"
      if is_prefix_key "$iface" "$pfx" wired; then
        verbose "    Merging prefix=$pfx valid=$valid pref=$pref"
        # compare valid lifetimes
        get_prefix_key valid2 "$iface" "$pfx" wired_valid
        get_max_lifetime valid "$valid" "$valid2"
        set_prefix_key "$iface" "$pfx" wired_valid "$valid"
        # compare preferred lifetimes
        get_prefix_key pref2 "$iface" "$pfx" wired_pref
        get_max_lifetime pref "$pref" "$pref2"
        set_prefix_key "$iface" "$pfx" wired_pref "$pref"
      else
        set_prefix_key "$iface" "$pfx" wired 1
        set_prefix_key "$iface" "$pfx" wired_valid "$valid"
        set_prefix_key "$iface" "$pfx" wired_pref "$pref"
      fi
      if ! is_prefix_key "$iface" "$pfx" src; then
        # not in source, mark as decrementing if dynamic prefix decrements
        is_prefix_key "$iface" dynamic decr && set_decr_iface "$iface" "$pfx"
      fi
      pfx='' valid='' pref=''
    fi
  done
}

# Examines all interfaces which have <iface> dynamic set
get_iface_addrs() {
  verbose "Looking for addresses on interfaces"
  local -i i; local if

  i=0
  while get_iface if $i; do
    ((i++))
    # skip if not in src doesn't have dynamic settings on interface
    is_iface_key "$if" dynamic || continue
    get_addrs "$if"
  done
}

# ret=item from static or dynamic entry in template
get_src_value() { # <ret> <iface> <prefix> <item>
  local ret=$1 iface=$2 prefix=$3 item=$4

  # if static prefix, use that value, or dynamic by default
  if is_prefix_key "$iface" "$prefix" src; then
    get_prefix_key "$ret" "$iface" "$prefix" "src_$item"
  else
    get_prefix_key "$ret" "$iface" dynamic "src_$item"
  fi
}

item_differs() { # <new> <cur> <desc>
  local -i rc=1; local msg
  [[ $1 != "$2" ]] && rc=0
  (( rc == 0 )) && msg=" - CHANGE"
  verbose "    Checking $3 ($1 : $2)$msg"
  return $rc
}

pfx_item_differs() { # <iface> <prefix> <item> <desc>
  debug "$@"
  local iface=$1 prefix=$2 item=$3 desc=$4 new cur

  # get template value, and compare with current file
  get_src_value new "$iface" "$prefix" "$item"
  get_prefix_key cur "$iface" "$prefix" "cur_$item"

  item_differs "$new" "$cur" "$desc"
}

# ret=<type> lifetime, get template value, or wired if unset
get_new_lifetime() { # <ret> <iface> <prefix> <type>
  get_src_value "$1" "$2" "$3" "$4" ||
    get_prefix_key "$1" "$2" "$3" "wired_$4"
}

pfx_lifetime_differs() { # <iface> <prefix> <type>
  debug "$@"
  local iface=$1 prefix=$2 type=$3 new cur

  # get template value (or wired if unset), and compare with current file
  get_new_lifetime new "$iface" "$prefix" "$type"
  get_prefix_key cur "$iface" "$prefix" "cur_$type"

  # if either infinity, just check for a change
  if [[ $new == infinity || $cur == infinity ]]; then
    item_differs "$new" "$cur" "$type lifetimes"
    return
  fi

  # if either is unset, just check for change (handles unwired prefixes)
  if [[ ! ( $new && $cur ) ]]; then
    item_differs "$new" "$cur" "$type lifetimes"
    return
  fi

  verbose "    Checking $type lifetime values ($new : $cur)"

  local -i a=$new b=$cur d
  ((d=((a*200)-(b*200))/(a+b)))
  (( d < 0 )) && ((d=-d))
  if (( d < PERDIFF )); then
    verbose "      Lifetimes within $PERDIFF%"
    return 1
  fi
  verbose "      Lifetimes differ more than $PERDIFF% ($d%) - CHANGE"
  return 0
}

# Look for changes on new and current <iface> (true if changes)
iface_differs() { # <iface>
  debug "$@"
  local if=$1 pfx decr un=un
  local -i p same=1

  # check if interface is in <src>
  is_iface_key "$if" src || return 0

  verbose "Looking for significant changes on interface $if"

  p=0
  while get_prefix pfx "$if" $p; do
    ((p++))
    # we only examine changes on real prefixes
    [[ $pfx == dynamic ]] && continue
    verbose "  Considering prefix $pfx"

    # check if src missing in current (checking dyn changes, not all)
    if is_prefix_key "$if" "$pfx" cur; then
      # confirm prefix still wired
      verbose "    Checking if still on interface"
      is_prefix_key "$if" "$pfx" wired || {
        same=0; verbose "      Not found! - CHANGE"
      }
      # check template vs current
      pfx_lifetime_differs "$if" "$pfx" valid && same=0
      pfx_lifetime_differs "$if" "$pfx" pref && same=0
      pfx_item_differs "$if" "$pfx" has_decr "if decrement declared" && same=0
      pfx_item_differs "$if" "$pfx" decr "decrement setting" && same=0
    else
      same=0; verbose "    Missing in current, change"
    fi
  done

  (( same )) || un=
  verbose "  Interface $if ${un}changed"
  return $same
}

# Check all interfaces for changes (true if changes)
ifaces_differs() {
  local -i i same=1; local iface

  i=0
  while get_iface iface $i; do
    ((i++))
    iface_differs "$iface" && same=0
  done

  return $same
}

gen_line() {
  # <fmt> <arg>
  local out
  # shellcheck disable=SC2059
  printf -v out "$1" "$2"
  debug2 "$out"
  echo "$out"
}

# Echos missing lifetime entries for prefix, if not defined in <source>
gen_missing_lifetimes() { # <iface> <source> <prefix>
  debug "$@"
  local iface=$1 source=$2 prefix=$3 svalid spref valid pref

  get_prefix_key svalid "$iface" "$source" src_valid
  get_prefix_key spref "$iface" "$source" src_pref

  if [[ $spref ]]; then
    [[ $svalid ]] && return # both set
    get_new_lifetime valid "$iface" "$prefix" valid
    get_max_lifetime valid "$valid" "$spref"
    gen_line "\\t\\tAdvValidLifetime %s;" "$valid"
  elif [[ $svalid ]]; then
    get_new_lifetime pref "$iface" "$prefix" pref
    get_min_lifetime pref "$pref" "$svalid"
    gen_line "\\t\\tAdvPreferredLifetime %s;" "$pref"
  else
    get_new_lifetime valid "$iface" "$prefix" valid
    gen_line "\\t\\tAdvValidLifetime %s;" "$valid"
    get_new_lifetime pref "$iface" "$prefix" pref
    gen_line "\\t\\tAdvPreferredLifetime %s;" "$pref"
  fi
}

# Echos dynamic section for <iface>
gen_dynamic() { # <iface>
  debug "$@"
  local -i p i; local iface=$1 pfx key val

  p=0
  while get_prefix pfx "$iface" $p; do
    ((p++))
    if ! is_prefix_key "$iface" "$pfx" wired; then
      debug "  skipping $pfx as not available" && continue
    elif is_prefix_key "$iface" "$pfx" src; then
      debug "  skipping $pfx as declared static in template" && continue
    fi
    gen_line "\\tprefix %s {" "$pfx"
    # always include missing lifetimes
    gen_missing_lifetimes "$iface" dynamic "$pfx"
    # include any saved values from @PREFIX@
    i=0
    while get_prefix_keyi key "$iface" saved "$i"; do
      ((i++))
      get_prefix_key val "$iface" saved "$key"
      gen_line "%s" "$val"
    done
    gen_line "\\t%s" "};"
  done
}

TMPFILE=
gen_cleanup() {
  trap - EXIT INT TERM
  [[ -f $TMPFILE ]] && verbose "Cleaning up \"$TMPFILE\"" && rm -f "$TMPFILE"
}

# cleanup tmpfiles on exit
trap gen_cleanup EXIT INT TERM

gen_file() { # <template> <config>
  local src=$1 dst=$2

  verbose "Generating \"$dst\" from \"$src\""

  TMPFILE=$("$MKTEMP_EXE")
  [[ -w $TMPFILE ]] || fatal "Unable to create temp file '$TMPFILE'"

  local orig iface prefix save_prefix add_close

  local -i lno=0 si=0
  local -l line # lowercase
  local IFS=$'\n' state=global parent=
  while read -r orig || [[ $orig ]]; do
    debug2 "state=$state parent=$parent iface=$iface prefix=$prefix"
    strip line "$orig"
    ((lno++))

    if [[ $line =~ ^# ]]; then
      if [[ $prefix == dynamic ]]; then
        set_prefix_key "$iface" saved "$si" "$orig"
        ((si++))
      else
        gen_line "%s" "$orig"
      fi
      continue
    fi

    case $state in
      global) parse_global || exit 1;;
      interface-opts) parse_interface || exit 1;;
    esac

    parse_braces || exit 1
    [[ $state == global ]] && si=0

    if [[ $save_prefix ]]; then
      if [[ $save_prefix == dynamic ]]; then
        gen_dynamic "$iface"
        [[ $line =~ \}\; ]] && continue # gen_dynamic includes '};'
        [[ $line =~ ^@prefix@ ]] && continue; # don't write @PREFIX@
      elif is_prefix_key "$iface" "$save_prefix" wired; then
        # add brackets if none in orig
        if [[ ! $line =~ \}\; ]]; then
          gen_line "%s" "${orig/;/ \{}"
          add_close=1
        fi
        gen_missing_lifetimes "$iface" "$save_prefix" "$save_prefix"
        [[ $add_close ]] && { orig="	};" add_close=; }
      fi
    fi

    if [[ $prefix == dynamic ]]; then
      # don't save braces
      [[ $state == prefix-opts && $line =~ \{ ]] && continue;
      [[ $state == interface-opts && $line =~ \}\; ]] && continue
      set_prefix_key "$iface" saved "$si" "$orig"
      ((si++))
    else
      gen_line "%s" "$orig"
    fi
  done < "$src" >> "$TMPFILE"

  [[ $state != global ]] &&
    err "Parse error in file \"$src\":$lno" && return 1

  cp "$TMPFILE" "$dst" || fatal "Unable to copy \"$TMPFILE\" to \"$dst\""

  chmod u+rw "$dst"
  [[ $RADVD_GROUP ]] && { chgrp "$RADVD_GROUP" "$dst" && chmod g+r "$dst"; }
  [[ $RESTORECON_EXE ]] && "$RESTORECON_EXE" -F "$dst"

  return 0
}

signal_radvd() { # reload|reset
  local mode=$1 action sig
  case $mode in
    reload) action="reload config"; sig=SIGHUP; mode=reload-or-restart;;
    reset) action="reset timers"; sig=SIGUSR1;;
    *) return
  esac
  if [[ -x $SC_EXE ]] && "$SC_EXE" 2>/dev/null -q is-enabled radvd; then
    verbose "Signaling radvd.service to $action"
    "$SC_EXE" -q is-active radvd || mode=restart
    if [[ $mode == reset ]]; then
      "$SC_EXE" kill -s "$sig" radvd
    else
      "$SC_EXE" "$mode" radvd
    fi
  elif [[ -x $KILL_EXE && -r $RADVD_PID ]]; then
    verbose "Signaling radvd to $action"
    "$KILL_EXE" -s "$sig" -- "$(< "$RADVD_PID")"
  else
    verbose "No radvd found to tell to $action"
  fi
}

setup_radvd() {

  # if no template, we're not configured; bail.
  [[ -f $SRC ]] || { verbose "No template file '$SRC'"; return; }

  (( BASH_VERSINFO[0] >= 4 )) || fatal "Bash v4+ required"

  # keep data out of environment
  local -A DS

  [[ -x $IP_EXE ]] || fatal "Unable to find ip command ('$IP_EXE')"
  [[ -x $MKTEMP_EXE ]] || \
    fatal "Unable to find mktemp command ('$MKTEMP_EXE')"

  [[ -r $SRC ]] || fatal "Unable to read $SRC"
  [[ -f $DST && ! -w $DST ]] && fatal "Unable to write $DST"

  read_file "src" "$SRC" || exit 1
  read_file "cur" "$DST"

  get_iface_addrs

  if [[ -f $DST && $DST -nt $SRC ]] && ! ifaces_differs; then
    # reset radvd if a prefix is decrementing
    if ds_get "" RESET; then
      signal_radvd reset
    else
      verbose "No action required"
    fi
  else
    # generate new radvd.conf
    gen_file "$SRC" "$DST" || return

    # reload radvd
    signal_radvd reload
  fi
  (( debug > 2 )) && ds_dump
  return 0
}

case $action in
  up|dhcp6-change|down)
    setup_radvd
    ;;
esac
