#!/usr/bin/bash
# -*- mode: sh; sh-basic-offset: 2; indent-tabs-mode: nil; -*-
# vim:set ft=sh et sw=2 ts=2:
#
# 08-ipv6-prefix v1.99.7 - NetworkManager dispatch for ipv6 prefix delegation
# Author: Scott Shambarger <devel@shambarger.net>
#
# Copyright (C) 2014-2021 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/>.
#
# Instructions for use:
#
#   Put this script in /usr/lib/NetworkManager/dispatcher.d (or wherever
#   your distro has these files).
#
#   Finally, touch the file NMCONF/ipv6-prefix-<wan>.conf, and optionally
#   put the following setting in it:
#
#     WAN_LAN_INTFS=<lan>
#
#   <wan> is your WAN interface, <lan> is your LAN interface.
#
#   You will want ipv6 forwarding enabled globally for the prefix to be routed,
#   ie. /etc/sysctl.d/ipv6.conf should contain "net.ipv6.conf.all.forwarding=1"
#
#   For a WAN interface, this dispatcher will start dhclient (for ipv6).
#   To ensure NetworkManager isn't already starting a dhclient for ipv6:
#
#     /etc/sysconfig/network-scripts/ifcfg-<wan>.conf: IPV6INIT="no"
#   or
#     ipv6.method=link-local in NetworkManager config
#
#   If you need ipv6.method=auto on WAN, then you MUST supply a different
#   DHCP client port in WAN_DHCLIENT_OPTIONS ("-p <port>" != 546)
#
#   For a LAN interface, this dispatcher will attempt to replace a
#   missing LAN prefix if it comes up after the WAN, or is restarted.
#
# Requires:
#
#   NMUTILS/general-functions - shared functions
#   dhclient - ISC DHCP client
#
# Supported, but optional:
#
#   NMUTILS/ddns-functions - dynamic DNS functions (DDNS features
#     are unavailable if absent)
#
#   WAN_DDNS_CONFIG_PAT - WAN allocated address DDNS, default:
#     ${NMCONF}/ddns-@WAN@-from-@WAN@.conf
#     NOTE: this is different from ddns-@WAN@.conf as it's only
#     applied after dhclient has assigned the address (not on interface up)
#
#   LAN_DDNS_CONFIG_PAT - prefix LAN DDNS, default:
#     ${NMCONF}/ddns-@LAN@-from-@WAN@.conf
#
# WAN config location:
#
#   WAN_CONFIG_PAT - must contain single "@WAN@", default:
#     ${NMCONF}/ipv6-prefix-@WAN@.conf
#
# LAN config location, optional:
#
#   LAN_CONFIG_PAT - must contain single "@LAN@-from-@WAN@", default:
#     ${NMCONF}/ipv6-prefix-@LAN@-from-@WAN@.conf
#
# State files (created by this script):
#
#   RUNDIR/ipv6-prefix-<lan>-from-<iaid>-<wan>.state
#   RUNDIR/ipv6-<iaid>-<wan>.state
#   RUNDIR/ipv6-<wan>.state
#   RUNDIR/ddns-<wan>-<rrec>.state
#   RUNDIR/ddns-<lan>-from-<wan>-<rrec>.state
#
# WAN Settings (set in NMCONF/ipv6-prefix-<wan>.conf)
#
#   All settings are optional, but file must exist to trigger prefix query!
#
#   <ip6_prefix>/<ip6_prefix_len> below are the delegated prefix values.
#
#   WAN_DHCLIENT_OPTIONS - any additional dhclient options
#
#   WAN_LAN_INTFS - LAN interfaces (space separated) to assign
#       prefixes to.  See below for optional per-LAN config.  The order
#       determines which LAN interfaces get sub-prefixes if prefix
#       space is limited.
#
#   WAN_REQUIRE_IP4 - if set, requires valid, public ip4 address on
#       interface before starting dhclient (useful if modem assigns
#       private ip4 address when it's offline).  If set to "any", then
#       even private network addresses are accepted.
#
#   WAN_STATIC_IP6 - comma-list of ip6 addresses/plen to add to WAN,
#       allows mixing DHCP and static addresses.
#
#   WAN_STATIC_DNS6 (optional) - comma-list of ip6 dns servers added to WAN.
#
#   WAN_STATIC_DNS6_SEARCH (optional) - comma-list of static dns-search
#       added to WAN.
#
#   NOTE: A default (empty) config will assign a sub-prefix to the WAN.
#
# LAN Settings (set in NMCONF/ipv6-prefix-<lan>-from-<wan>.conf)
#
#   All LAN settings are optional (file does not need to exist).
#
#   LAN_SITE (hex, default: "auto") - (LAN_PREFIX_LEN - <ip6_prefix_len>)
#       bits added <ip6_prefix> to create <lan_prefix>. If "auto" or
#       unset then prefixes are created based on the order the LAN
#       appears WAN_LAN_INTFS.
#
#   LAN_PREFIX_LEN (default: 64) - prefix length to assign to LAN address.
#       Anything over <ip6_prefix_len> can be used for LAN_SITE.
#       128 sets just an address on the interface.
#
#   LAN_NODE (hex, default: "auto") - <lan_prefix>::LAN_NODE is address
#       assigned.  If auto or unset then the link-local address's host
#       part is used.
#
# NOTE: executable paths (see below) may be overriden if needed
#
# shellcheck disable=SC1090

# for logging
if [[ ${1-} && ${2-} ]]; then
  # run from NM
  NMG_TAG="${NMG_TAG-ipv6-prefix}"
  # or if not, log to stderr
  # shellcheck disable=SC2034
  [[ ${NM_DISPATCHER_ACTION} ]] || nmg_log_stderr=1
elif [[ ${interface-} ]]; then
  # run from dhclient
  NMG_TAG="${NMG_TAG-ipv6-prefix-dhc}"
fi

# set NMUTILS/NMCONF early, and allow environment to override
NMUTILS="${NMUTILS:-/etc/nmutils}"
NMCONF="${NMCONF:-${NMUTILS}/conf}"

########## Global defaults (customize in $NMCONF/general.conf)

########## Default paths

RUNDIR="${RUNDIR:-/run/nmutils}"
LEASEDIR="${LEASEDIR:-/var/lib/dhclient}"
ACCEPT_RA="${ACCEPT_RA:-/proc/sys/net/ipv6/conf/@WAN@/accept_ra}"
# sysctl forwarding state (must contain "@NODE@")
FORWARDING_PAT="${FORWARDING_PAT:-/proc/sys/net/ipv6/conf/@NODE@/forwarding}"

########## Support programs

NMDATE=${NMDATE:-date}
DHCLIENT=${DHCLIENT:-dhclient}

# these are optional (may be empty)
DHCLIENT_ORIG_SCRIPT=${DHCLIENT_ORIG_SCRIPT-dhclient-script}
NMCLI=${NMCLI-nmcli}

########## Config overrides

# set to 1 to spawn dhclient even if NetworkManager method=auto
NMDH6_IGNORE_METHOD_AUTO=${NMDH6_IGNORE_METHOD_AUTO-}
# by default, request addresses and prefixes... (cleared if method=auto)
NMDH6_DHCLIENT_ARGS=("-N")

########## SCRIPT START

NMG_REQUIRED="1.6.1"
NMDDNS_REQUIRED="1.5.0"

# load ddns- or general-functions
NMDDNS=${NMDDNS-${NMUTILS}/ddns-functions}
{ [[ -r ${NMDDNS} ]] && . "${NMDDNS}"; } || {
  # no DDNS, use general-functions
  NMDDNS='' NMG=${NMG:-${NMUTILS}/general-functions}
  { [[ -r ${NMG} ]] && . "${NMG}"; } || {
    echo 1>&2 "Unable to load ${NMG}"; exit 2; }
}

[[ ${NMG_VERSION} ]] || {
  nmg_err "${0##*/} requires NMG ${NMG_REQUIRED}"; exit 2; }
[[ -z ${NMDDNS} || ${NMDDNS_VERSION} ]] || {
  nmg_err "${0##*/} requires NMDDNS ${NMDDNS_REQUIRED}"; exit 2; }

########## Config/state locations

# main config
WAN_CONFIG_PAT=${WAN_CONFIG_PAT:-${NMCONF}/ipv6-prefix-@WAN@.conf}

##### dhclient config

# override LEASEDIR, not LEASES
LEASES="${LEASEDIR}/ipv6-prefix-@WAN@.leases"
# override RUNDIR, not DHCLIENT_PID
DHCLIENT_PID="${RUNDIR}/dhclient-ipv6-prefix-@WAN@.pid"

##### WAN address

# iaid state (must contain "@ID@-@WAN@")
WAN_STATE_PAT=${WAN_STATE_PAT:-${RUNDIR}/ipv6-addr-@ID@-@WAN@.state}

# connection state (must contain "@WAN@")
WAN_CONNSTATE_PAT=${WAN_CONNSTATE_PAT:-${RUNDIR}/ipv6-@WAN@.state}

# DDNS config (must contain "@WAN@-from-@WAN@")
WAN_DDNS_CONFIG_PAT=${WAN_DDNS_CONFIG_PAT:-${NMDDNS_CONFIG_PAT/@MATCH@/@WAN@-from-@WAN@}}
# DDNS state (must contain "@WAN@-from-@WAN@-@RREC@")
WAN_DDNS_STATE_PAT=${WAN_DDNS_STATE_PAT:-${NMDDNS_STATE_PAT/@MATCH@-@RREC@/@WAN@-from-@WAN@-@RREC@}}

#### prefix LAN (LAN_*_PAT values must have a single @LAN@ keyword)

# main LAN config (must contain "@LAN@-from-@WAN@")
LAN_CONFIG_PAT=${LAN_CONFIG_PAT:-${NMCONF}/ipv6-prefix-@LAN@-from-@WAN@.conf}

# node assignment state (must contain "@LAN@-from-@ID@-@WAN@")
LAN_STATE_PAT=${LAN_STATE_PAT:-${RUNDIR}/ipv6-prefix-@LAN@-from-@ID@-@WAN@.state}

# DDNS config (must contain "@LAN@-from-@WAN@")
LAN_DDNS_CONFIG_PAT=${LAN_DDNS_CONFIG_PAT:-${NMDDNS_CONFIG_PAT/@MATCH@/@LAN@-from-@WAN@}}
# LAN DDNS state (must contain "@LAN@-from-@WAN@-@RREC@")
LAN_DDNS_STATE_PAT=${LAN_DDNS_STATE_PAT:-${NMDDNS_STATE_PAT/@MATCH@-@RREC@/@LAN@-from-@WAN@-@RREC@}}

# internal state
RADVD_TRIGGER='' CUR_TIME='' EXIT_CODE=''
NM_MODIFIED='' CONN_ID='' NM_CONFIG=''

ipv6_wan_reset_config() {

  unset WAN_LAN_INTFS WAN_REQUIRE_IP4 WAN_DHCLIENT_OPTIONS
  unset WAN_STATIC_IP6 WAN_STATIC_DNS6 WAN_STATIC_DNS6_SEARCH
}

ipv6_wan_read_config() {
  # <wan>

  # clear config
  ipv6_wan_reset_config

  # load WAN config
  nmg_read_config "${WAN_CONFIG_PAT/@WAN@/$1}" || return
}

ipv6_life_calc() { # return 0 if <retval> set, 1 if offset unavailable
  # <retval> <life> <start>
  (( $2 >= 4294967295 )) && { printf -v "$1" "%s" "forever"; return 0; }
  [[ ${CUR_TIME} ]] || {
    nmg::saferun CUR_TIME "nolog" "${NMDATE}" "+%s" || CUR_TIME=0; }
  [[ ${CUR_TIME} == 0 ]] && return 1
  # sanity check time
  (( CUR_TIME < $3 )) && return 1
  local offset life=0
  (( offset = CUR_TIME - $3, ( $2 > offset ) && ( life = $2 - offset ) )) || :
  printf -v "$1" "%s" "${life}"
  return 0
}

# verify iaid (which is a number, possibly hex) for filename use
ipv6_verify_iaid() { # return 0 if valid, !0 otherwise
  # <retvar> <iaid>
  local _id
  [[ ${2-} ]] || return
  nmg::lowercase _id "$2"
  while [[ ${_id} =~ ([^0-9a-f]) ]]; do
    _id=${_id//"${BASH_REMATCH[1]}"}
  done
  [[ ${_id} ]] || {
    nmg_err "dhclient on ${interface} passed invalid IAID '$2'"
    return 1
  }
  printf -v "$1" %s "${_id}" || return
}

#
# DDNS Functions
#

ipv6_handle_ddns_cb() { # returns 0
  # <file> <iaid> <interface>
  local state addr6 pref start plife

  # load address
  nmg::read state "" "$1" || return 0
  nmg::prop_get_value addr6 "${state}" "inet6" || return 0

  nmg::is_ip6_prefix "${addr6}" && return 0

  # if pref-life 0, don't place in DNS (shouldn't be used for
  # new connections) - use state values to avoid races
  if nmg::prop_get_value pref "${state}" "pref-life" &&
      nmg::prop_get_value start "${state}" "life-start" &&
      ipv6_life_calc plife "${pref}" "${start}"; then
    [[ ${plife} == 0 ]] && return 0
  fi

  # ensure address is applied and ready
  nmg::wait_dad6 "$3" "${addr6}" && ddns_addrs+=("${addr6%%/*}")

  return 0
}

ipv6_handle_ddns() { # returns 0
  # <pat_with_@ID@> <interface> <ddns-config> [ <ddns-state-pat> ]
  local ddns_addrs=() addrs

  # DDNS available?
  [[ ${NMDDNS} ]] || return 0

  # any ddns config?
  [[ -e $3 ]] || return 0

  # gather addresses in ddns_addrs
  [[ $1 ]] && nmg::foreach_filematch "$1" "@ID@" ipv6_handle_ddns_cb "$2"

  nmg::array_join addrs "," "${ddns_addrs[@]-}"

  nmddns_spawn_update "$3" AAAA "${addrs}" "${4-}" || :
}

ipv6_lan_from_wan_ddns() { # returns 0
  # <lan> <wan> <lan-state-pat>
  local lan=$1 wan=$2 sfile=${3/@WAN@/$2}
  ipv6_handle_ddns \
    "${sfile/@LAN@/${lan}}" "${lan}" \
    "${LAN_DDNS_CONFIG_PAT/@LAN@-from-@WAN@/${lan}-from-${wan}}" \
    "${LAN_DDNS_STATE_PAT/@LAN@-from-@WAN@/${lan}-from-${wan}}"
}

#
# WAN Functions
#

ipv6_wan_check() {

  [[ ${WAN_REQUIRE_IP4-} ]] || return 0

  local priv_ok=''
  [[ ${WAN_REQUIRE_IP4} == any ]] && priv_ok=1

  # if no ip4, fail
  if [[ ${IP4_NUM_ADDRESSES:-0} == 0 ]]; then
    nmg_info "No ip4 addresses available"
    return 1
  fi

  # now see if there's a valid address
  local idx vname
  for (( idx=0; idx < IP4_NUM_ADDRESSES; idx++ )); do
    vname="IP4_ADDRESS_${idx}"
    # check addr (remove netmask and gateway)
    nmg_check_ip4_addr "${!vname%%/*}" "${priv_ok}" && return
  done

  local pub="valid"
  [[ ${priv_ok} ]] || pub="public"
  nmg_info "No ${pub} ip4 addresses available"

  return 1
}

#
# LAN Functions
#

# LAN_DDNS items: <wan>
ipv6_lan_flag_ddns() { # returns 0
  # <wan>
  local i wan=$1 IFS; unset IFS

  for (( i = 0; i < ${#LAN_DDNS[*]}; i++ )); do
    [[ ${LAN_DDNS[$i]} == "${wan}" ]] && return 0
  done
  LAN_DDNS+=("${wan}")
}

ipv6_lan_del_addr() { # returns 0
  # <lan> <addr/plen>

  # skip if just prefix
  nmg::is_ip6_prefix "$2" && return 0

  # flag radvd trigger (not WAN)
  nmg_del_ip6_addr "$1" "$2" && RADVD_TRIGGER=1

  return 0
}

ipv6_lan_change_addr() { # returns 0
  # <lan> <addr/plen> <ip-args>

  # skip if just prefix
  nmg::is_ip6_prefix "$2" && return 0

  # flag radvd trigger (not WAN)
  nmg_change_ip6_addr "$@" && RADVD_TRIGGER=1

  return 0
}

ipv6_lan_add_addr() {
  # <lan> <state>
  local lan=$1 state=$2 addr6='' vlife=0 plife=0 start=0

  nmg::prop_get_value addr6 "${state}" "inet6" || return 0
  # skip if just prefix
  nmg::is_ip6_prefix "${addr6}" && return 0

  if nmg::prop_get_value vlife "${state}" "valid-life" &&
      nmg::prop_get_value plife "${state}" "pref-life" &&
      nmg::prop_get_value start "${state}" "life-start" &&
      ipv6_life_calc vlife "${vlife}" "${start}" &&
      ipv6_life_calc plife "${plife}" "${start}"; then
    # don't add if address expired
    [[ ${vlife} == 0 ]] && return 0
    nmg_add_ip6_addr "${lan}" "${addr6}" valid_lft "${vlife}" \
                     preferred_lft "${plife}" || return 0
  else
    nmg_add_ip6_addr "${lan}" "${addr6}" || return 0
    plife=1
  fi

  # flag radvd trigger (for later)
  RADVD_TRIGGER=1

  # NM sets forwarding to 0 if managed and not method=shared, correct that
  [[ ${Forwarding} == 1 && -w ${FORWARDING_PAT//@NODE@/${lan}} ]] && {
    nmg_write "${FORWARDING_PAT//@NODE@/${lan}}" 1 || :; }

  return 0
}

ipv6_lan_handle_ddns() {
  # [ <lan-state-pat> ]
  local wan IFS; unset IFS

  # DDNS available?
  [[ ${NMDDNS} ]] || return 0

  for wan in ${LAN_DDNS[*]-}; do
    ipv6_lan_from_wan_ddns "${interface}" "${wan}" "${1-}"
  done
}

ipv6_lan_foreach_wan() {
  # <callback> [ <args> ]
  local IFS; unset IFS
  nmg::foreach_filematch "${LAN_STATE_PAT/@LAN@/${interface}}" \
                         "@ID@-@WAN@" "$@" || :
}

# calc address from prefix
ipv6_lan_calc_address() { # returns 0 if <retvar> set
  # <retvar> <lan> <prefix> <node>
  local host

  # still need to calc address (interface was down)
  nmg_debug "Calculating address for prefix $3"

  nmg::create_ip6_host host "$2" "$4" &&
    nmg::create_ip6_addr "$1" "$3" "${host}" &&
    return 0

  return 1
}

ipv6_lan_start_cb() { # returns 0
  # <state-file> <iaid>-<wan>
  local sfile=$1 state wan addr6

  # should only remove "<iaid>-" since <iaid> cannot include "-"
  wan=${2#*-}
  [[ ${wan} ]] || return 0

  ipv6_lan_flag_ddns "${wan}"

  # get WAN_LAN_INTFS
  ipv6_wan_read_config "${wan}" || :

  # this LAN still configured for this WAN?
  [[ ${WAN_LAN_INTFS-} =~ (^| )"${interface}"($| ) ]] || {
    nmg_remove "${sfile}"
    return 0
  }

  # read address from state
  nmg::read state "info" "${sfile}" || return 0

  if nmg::prop_get_value addr6 "${state}" "inet6" &&
      nmg::is_ip6_prefix "${addr6}"; then

    # try to calc address
    ipv6_lan_calc_address addr6 "${interface}" "${addr6}" "${LAN_NODE}" && {
      nmg::prop_set_value state "${state}" "inet6" "${addr6}"
      nmg_write "${sfile}" "${state}" || :
    }
  fi

  ipv6_lan_add_addr "${interface}" "${state}"

  return 0
}

# check DAD, and remove address if failed
ipv6_lan_check_dad() {
  # <lan> <state-file>
  local lan=$1 sfile=$2 state addr6

  # did we even add the address?
  if [[ -e "${sfile}" ]] && nmg::read state "nolog" "${sfile}" &&
       nmg::prop_get_value addr6 "${state}" "inet6" &&
       ! nmg::is_ip6_prefix "${addr6}"; then
    # check DAD
    nmg::wait_dad6 "${lan}" "${addr6}" || {
      # only handle DAD failure (not timeout)
      (( $? == 1 )) && {
        nmg_err "Address ${addr6} has conflict on ${lan}"
        ipv6_lan_node_remove "${sfile}" "${lan}"
      }
    }
  fi

  return 0
}

ipv6_lan_start_dad_cb() { # returns 0
  # <state-file> <iaid>-<wan>
  ipv6_lan_check_dad "${interface}" "$1"
}

ipv6_lan_start() {

  # used by ipv6_lan_add_addr
  local Forwarding=0 LAN_DDNS=() RADVD_TRIGGER=''

  nmg::read Forwarding "" "${FORWARDING_PAT//@NODE@/all}" || Forwarding=0

  ipv6_lan_read_config "${interface}"

  # set address from each WAN
  ipv6_lan_foreach_wan ipv6_lan_start_cb

  # set address from each WAN
  ipv6_lan_foreach_wan ipv6_lan_start_dad_cb

  ipv6_lan_handle_ddns "${LAN_STATE_PAT}"

  # trigger radvd if prefix(es) added
  [[ ${RADVD_TRIGGER} ]] && nmg_radvd_trigger
  return 0
}

ipv6_lan_stop_cb() { # returns 0
  # <state-file> <iaid>-<wan>
  local wan

  # should only remove "<iaid>-" since <iaid> cannot include "-"
  wan=${2#*-}
  [[ ${wan} ]] || return 0

  ipv6_lan_flag_ddns "${wan}"
}

ipv6_lan_stop() {
  # reset DDNS for each WAN
  local LAN_DDNS=()

  ipv6_lan_foreach_wan ipv6_lan_stop_cb

  ipv6_lan_handle_ddns

  # trigger radvd as prefix(es) removed
  nmg_radvd_trigger
}

ipv6_lan_node_remove() { # returns 0
  # <state-file> <lan>[-from-<iaid>]
  local sfile=$1 lan=$2 state addr6

  [[ ${sfile} && ${lan} ]] || return 0

  nmg::read state "ignore" "${sfile}"

  # remove state file
  [[ -e ${sfile} ]] && nmg_remove "${sfile}"

  nmg::prop_get_value addr6 "${state}" "inet6" || return 0

  # delete matching addr (if any, remove "-from-<iaid>" if present)
  ipv6_lan_del_addr "${lan%-from-*}" "${addr6}"

  return 0
}

ipv6_lan_reset_config() { # returns 0
  unset LAN_NODE LAN_PREFIX_LEN LAN_SITE
}

ipv6_lan_read_config() { # returns 0
  # <interface>

  # clear config
  ipv6_lan_reset_config

  # load LAN config (with defaults)
  LAN_PREFIX_LEN=64 LAN_SITE=auto LAN_NODE=auto

  nmg_read_config \
    "${LAN_CONFIG_PAT/@LAN@-from-@WAN@/${1}-from-${interface}}" || :

  # check values (node/site can't be empty)
  LAN_NODE=${LAN_NODE:-auto}
  LAN_SITE=${LAN_SITE:-auto}
  LAN_PREFIX_LEN=${LAN_PREFIX_LEN:-64}
}

# checks if <dec>/<plen> overlaps with <prefix>
ipv6_prefix_overlap() { # returns true if overlap
  # <site> <plen> <prefix>
  local site=$1 plen=$2 xsite=${3%/*} xlen=${3#*/} mlen
  (( mlen = ((plen < xlen) ? plen : xlen),
     site = site >> (plen - mlen),
     xsite = xsite >> (xlen - mlen),
     site == xsite )) || return
}

# sets <retvar> to first site/<plen> in <pspace> not overlapping any <prefix>
ipv6_find_freesite() { # returns true if found
  # <retvar> <pspace> <plen> [ <prefix>... ]
  local _retvar=$1 _pspace=$2 _plen=$3 _p='' _site _maxsite

  shift 3
  # pspace <= plen checked during load
  (( _maxsite = ( 1 << (_plen - _pspace) ) - 1 )) || :

  # find site/plen not in <prefix> list
  for (( _site=0; _site <= _maxsite; _site++ )); do
    for _p in "$@"; do
      [[ ${_p} ]] && ipv6_prefix_overlap "${_site}" "${_plen}" "${_p}" && break
      _p=''
    done
    # found non-overlapping site?
    [[ -z ${_p} ]] && { printf -v "${_retvar}" "%d" "${_site}"; return; }
  done
  return 1
}

# uses Assigned, Reserved
# sets <retvar> to prefix not Reserved (if possible), or not Assigned
ipv6_find_autosite() { # return 0 if found, else !0
  # <retvar> <pspace> <plen>

  # any prefix not Assigned+Reserved?
  ipv6_find_freesite "$@" "${Assigned[@]-}" "${Reserved[@]-}" && return

  # fallback: any prefix not Assigned?
  ipv6_find_freesite "$@" "${Assigned[@]-}" && return
}

ipv6_foreach_lan() {
  # <callback> [ <args> ]

  # call callback [(<intf>)] for each LAN in WAN_LAN_INTFS.
  local func=$1 lan IFS; unset IFS
  shift
  for lan in ${WAN_LAN_INTFS-}; do "${func}" "${lan}" "$@"; done

  return 0
}

# uses sets Lans, Reserved
ipv6_lan_node_load() { # returns 0
  # <lan> <pspace>
  local lan=$1 pspace=$2 plen site maxsite

  ipv6_lan_read_config "${lan}"

  [[ -z ${LAN_NODE} ]] && {
    Lans+=("${lan}")
    return
  }

  nmg::2dec plen "${LAN_PREFIX_LEN}" || plen=0

  (( plen <= 0 || plen > 128 )) && {
    nmg_err "${lan} config error, invalid LAN_PREFIX_LEN '${LAN_PREFIX_LEN}'"
    Lans+=("${lan}")
    return
  }

  (( plen > 64 )) && {
    # host address, use site=0
    Lans+=("${lan} 128 ${LAN_NODE} 0")
    return
  }

  (( plen < pspace )) && {
    nmg_info "${lan} not configured, LAN_PREFIX_LEN ${plen} < available ${pspace}"
    Lans+=("${lan}")
    return
  }

  [[ ${LAN_SITE} == auto ]] && {
    Lans+=("${lan} ${plen} ${LAN_NODE} ${LAN_SITE}")
    return
  }

  nmg::2dec site "0x${LAN_SITE}" || {
    nmg_err "${lan} config error, invalid LAN_SITE '${LAN_SITE}'"
    Lans+=("${lan}")
    return
  }

  # if site too large, mask to allowed size
  (( maxsite = ( 1 << (plen - pspace) ) - 1,
     ( site > maxsite ) && ( site = site & maxsite ) )) || :

  # reserve prefix
  Reserved+=("${site}/${plen}")
  Lans+=("${lan} ${plen} ${LAN_NODE} ${site}")
}

# sets <retvars> if addr/prefix found, adds to Assigned
ipv6_lan_node_calc() { # returns 0
  # <retaddr> <retsite> <lan> <prefix-len> <node> <site>
  local lan=$3 plen=$4 node=$5 site=$6 hsite p addr_prefix
  local pspace=${new_ip6_prefix#*/}

  if [[ ${site} == auto ]]; then

    ipv6_find_autosite site "${pspace}" "${plen}" || {
      nmg_info "${lan} cannot be configured, no /${plen} prefixes available"
      return
    }
  else

    for p in ${Assigned[*]+"${Assigned[@]}"}; do
      # check if requested site overlaps with assigned
      ipv6_prefix_overlap "${site}" "${plen}" "$p" || continue
      # ignore requested site, fallback to autosite
      ipv6_find_autosite site "${pspace}" "${plen}" || {
        nmg_info "${lan} cannot be configured, no /${plen} prefixes available"
        return
      }
      break
    done
  fi

  # convert to hex for prefix calc
  nmg::2hex hsite "${site}"

  # build address prefix and host parts
  nmg::create_ip6_prefix addr_prefix "${new_ip6_prefix}" \
                         "${hsite}" "${plen}" || return 0

  # consume prefix space
  Assigned+=("${site}/${plen}")
  printf -v "$2" '%s' "${site}/${plen}"

  # calc address, or just return prefix (lan down)
  ipv6_lan_calc_address "$1" "${lan}" "${addr_prefix}" "${node}" ||
    printf -v "$1" '%s' "${addr_prefix}"

  return 0
}

# sets Assigned
ipv6_lan_node_assign() { # returns 0
  # <lan> [ <plen> <node> <site> ]
  # <lan> [ <addr6> <site6> ]
  local lan=$1 new_addr='' new_site='' state old_addr

  if [[ ${4-} ]]; then
    ipv6_lan_node_calc new_addr new_site "$@"
  elif [[ ${3-} ]]; then
    new_addr=$2 new_site=$3
  fi

  local sfile=${LAN_STATE_PAT/@LAN@-from-@ID@-@WAN@/${lan}-from-${Iaid}-${interface}}

  if [[ ${new_addr} ]]; then

    if nmg::read state "nolog" "${sfile}" &&
        nmg::prop_get_value old_addr "${state}" "inet6" &&
        [[ ${new_addr} != "${old_addr}" ]]; then
      # remove old (different) address
      ipv6_lan_node_remove "${sfile}" "${lan}"
    fi

    nmg::prop_set_value state "" "inet6" "${new_addr}"
    nmg::prop_set_value state "${state}" "site6" "${new_site}"
    [[ ${new_max_life-} && ${new_preferred_life-} &&
         ${new_life_starts-} ]] && {
      nmg::prop_set_value state "${state}" "valid-life" "${new_max_life}"
      nmg::prop_set_value state "${state}" "pref-life" "${new_preferred_life}"
      nmg::prop_set_value state "${state}" "life-start" "${new_life_starts}"
    }
    # write state even if address isn't added (interface may be added later)
    nmg_write "${sfile}" "${state}" || :
    ipv6_lan_add_addr "${lan}" "${state}"
  else
    # not assignment failed, remove any old address
    ipv6_lan_node_remove "${sfile}" "${lan}"
  fi

  return 0
}

ipv6_lan_node_check_dad() {
  # <lan>
  ipv6_lan_check_dad \
    "$1" "${LAN_STATE_PAT/@LAN@-from-@ID@-@WAN@/${1}-from-${Iaid}-${interface}}"
}

ipv6_prefix_load_assigned() {
  # <file> <lan>
  local file=$1 lan=$2 i state addr6 site6

  # check if lan still in config
  for (( i = 0; i < ${#Lans[*]}; i++ )); do
    # shellcheck disable=SC2086
    set -- ${Lans[$i]}
    [[ $1 == "${lan}" ]] && break
    set --
  done

  [[ ${4-} ]] || {
    # no longer configured, remove node
    ipv6_lan_node_remove "${file}" "${lan}"
    return 0
  }

  if nmg::read state "" "${file}" &&
      nmg::prop_get_value addr6 "${state}" "inet6" &&
      nmg::prop_get_value site6 "${state}" "site6"; then
    # add to Assigned
    Assigned+=("${site6}")
    # update Lans with addr, site
    Lans[$i]="${lan} ${addr6} ${site6}"
  else
    # invalid state file
    nmg_remove "${file}"
  fi

  return 0
}

ipv6_lan_node_depref() { # returns 0
  # <state-file> <lan>
  local file=$1 lan=$2 state addr6 vlife=0

  [[ ${cur_max_life-} && ${cur_life_starts-} ]] && {
    ipv6_life_calc vlife "${cur_max_life}" "${cur_life_starts}" || :
  }

  if [[ ${vlife} == 0 ]] ||
       ! [[ ${WAN_LAN_INTFS-} =~ (^| )"${lan}"($| ) ]]; then
    # remove address from lan
    ipv6_lan_node_remove "${file}" "${lan}"
    return 0
  fi

  if nmg::read state "" "${file}" &&
      nmg::prop_get_value addr6 "${state}" "inet6"; then

    nmg::prop_set_value state "${state}" "valid-life" "${cur_max_life}"
    nmg::prop_set_value state "${state}" "pref-life" "0"
    nmg::prop_set_value state "${state}" "life-start" "${cur_life_starts}"

    if nmg::is_ip6_prefix "${addr6}"; then

      # load lan config
      ipv6_lan_read_config "${lan}"

      # calc address from prefix
      ipv6_lan_calc_address addr6 "${lan}" "${addr6}" "${LAN_NODE}" &&
        nmg::prop_set_value state "${state}" "inet6" "${addr6}"
    fi

    nmg_write "${file}" "${state}" || :

    ipv6_lan_change_addr "${lan}" "${addr6}" \
                         valid_lft "${vlife}" preferred_lft 0
  else
    # invalid file
    nmg_remove "${file}"
  fi

  return 0
}

ipv6_prefix_setup_nodes() {
  local -i pspace=${new_ip6_prefix#*/}
  local Forwarding=0 IFS; unset IFS

  # sanity check
  (( pspace <= 0 || pspace > 64 )) && return

  # used by ipv6_lan_add_addr
  nmg::read Forwarding "" "${FORWARDING_PAT//@NODE@/all}" || Forwarding=0

  # check all nodes for config issues
  local args Iaid Lans=() Assigned=() Reserved=()

  # calc iaid for state file
  ipv6_verify_iaid Iaid "${new_iaid}" || return 0

  # load Lans, Reserved
  ipv6_foreach_lan ipv6_lan_node_load "${pspace}"

  # load existing assignments (if any)
  nmg::foreach_filematch \
    "${LAN_STATE_PAT/@ID@-@WAN@/${Iaid}-${interface}}" "@LAN@" \
    ipv6_prefix_load_assigned

  # assign lan nodes in order
  for args in ${Lans[*]+"${Lans[@]}"}; do
    # args: lan [ plen node site ]
    # shellcheck disable=SC2086
    ipv6_lan_node_assign ${args}
  done

  # check lan nodes for DAD failures
  for args in ${Lans[*]+"${Lans[@]}"}; do
    ipv6_lan_node_check_dad "${args%% *}"
  done

  return 0
}

ipv6_prefix_flush_iaid() {
  # <iaid>
  local id
  # remove all LAN addresses based on <iaid>
  [[ $1 ]] || return 0
  ipv6_verify_iaid id "$1" || return 0
  nmg::foreach_filematch \
    "${LAN_STATE_PAT/@ID@-@WAN@/${id}-${interface}}" "@LAN@" \
    ipv6_lan_node_remove
}

ipv6_prefix_finish_cb() {
  # <file> "<lan>-from-<id>"
  Forwarding=1
}

ipv6_prefix_finish() {
  # update all LAN DDNS
  local Forwarding=0

  ipv6_foreach_lan ipv6_lan_from_wan_ddns "${interface}" "${LAN_STATE_PAT}"

  # any assigned prefix, and we can forward
  nmg::foreach_filematch \
    "${LAN_STATE_PAT/@WAN@/${interface}}" "@LAN@-from-@ID@" \
    ipv6_prefix_finish_cb

  [[ -w ${FORWARDING_PAT//@NODE@/${interface}} ]] && {
    nmg_write "${FORWARDING_PAT//@NODE@/${interface}}" "${Forwarding}" || :
    # if forwarding enabled, make sure global forwarding is on
    [[ ${Forwarding} == 1 && -w ${FORWARDING_PAT//@NODE@/all} ]] && {
      nmg_write "${FORWARDING_PAT//@NODE@/all}" 1 || :; }
  }

  # trigger radvd if we changed any LAN addresses
  [[ ${RADVD_TRIGGER} ]] && nmg_radvd_trigger

  return 0
}

ipv6_prefix_check_lan() {
  # <state-file> "<lan>-from-<id>"
  local file=$1 lan=${2%-from-*}

  # make sure lan is still configured for wan
  [[ ${WAN_LAN_INTFS-} =~ (^| )"${lan}"($| ) ]] && return 0

  # remove address from lan
  ipv6_lan_node_remove "${file}" "${lan}"
}

ipv6_prefix_bind() {

  local na="${new_ip6_prefix}" oa="${old_ip6_prefix-}"
  if [[ ${oa} && ${na} != "${oa}" ]]; then
    nmg_info "Prefix on ${interface}: ${na} (old: ${oa})"
  else
    nmg_info "Prefix on ${interface}: ${na}"
  fi

  if [[ ${new_max_life-} == 0 ]]; then
    # flush dead prefix
    ipv6_prefix_flush_iaid "${new_iaid}"
  else
    if [[ ${old_ip6_prefix-} &&
            ${old_ip6_prefix} != "${new_ip6_prefix}" ]]; then
      # flush old prefix
      ipv6_prefix_flush_iaid "${old_iaid}"
    fi

    # cleanup any LANs no longer configured
    nmg::foreach_filematch \
      "${LAN_STATE_PAT/@WAN@/${interface}}" "@LAN@-from-@ID@" \
      ipv6_prefix_check_lan

    ipv6_prefix_setup_nodes
  fi

  ipv6_prefix_finish
}

ipv6_prefix_release() {

  nmg_info "Prefix removed from ${interface} - ${old_ip6_prefix}"

  ipv6_prefix_flush_iaid "${old_iaid}"

  ipv6_prefix_finish
}

ipv6_prefix_depref() {

  local id

  nmg_info "Prefix devalued on ${interface}: ${cur_ip6_prefix}"

  ipv6_verify_iaid id "${cur_iaid-}" && {
    # only process currently assigned lans...
    nmg::foreach_filematch \
      "${LAN_STATE_PAT/@ID@-@WAN@/${id}-${interface}}" "@LAN@" \
      ipv6_lan_node_depref
  }

  ipv6_prefix_finish
}

ipv6_prefix_flush() {
  # flush all nodes from this interface
  nmg::foreach_filematch \
    "${LAN_STATE_PAT/@WAN@/${interface}}" "@LAN@-from-@ID@" \
    ipv6_lan_node_remove

  ipv6_prefix_finish
}

ipv6_addr_update_times() { # returns 0
  # <addr6> <valid> <pref> <start>
  local vlife=0 plife=0
  ipv6_life_calc vlife "$2" "$4" || return 0
  ipv6_life_calc plife "$3" "$4" || return 0
  # update times
  nmg_change_ip6_addr "${interface}" "$1" \
                      valid_lft "${vlife}" preferred_lft "${plife}" || :
}

# necessary after each device reapply as NM will set these values off
# (we actually want to permit default route)
ipv6_update_ra() {
  local sys rafile=${ACCEPT_RA/@WAN@/${interface}}

  [[ -w ${rafile} ]] && {
    nmg_write "${rafile}" 2 || :
    # update dependent sysctls in case they are off
    for sys in defrtr pinfo rtr_pref; do
      [[ -w ${rafile}_${sys} ]] && { nmg_write "${rafile}_${sys}" 1 || :; }
    done
  }

  return 0
}

# nmcli device reapply, update lifetimes
ipv6_nm_reapply() { # returns 0 if applied ok, !0 if DAD failure
  local new_addr mod=${NM_MODIFIED}

  NM_MODIFIED=''

  # on flush, don't mess with the device/interface
  [[ ${NM_SKIP_APPLY-} ]] && mod=''

  [[ ${mod} ]] && {
    nmg_cmd "${NMCLI}" device reapply "${interface}" || return 0
    # since NM will change accept_ra, we need to update it
    ipv6_update_ra
  }

  # are we recovering from DAD failure?
  [[ ${EXIT_CODE} ]] && return 0

  [[ ${new_ip6_address-} && ${new_ip6_prefixlen-} ]] || return 0

  new_addr="${new_ip6_address}/${new_ip6_prefixlen}"

  if [[ ${mod} ]]; then
    nmg::wait_dad6 "${interface}" "${new_addr}" || {
      # set EXIT_CODE so dhclient performs DECLINE
      nmg_err "DAD timeout for ${new_addr}"; EXIT_CODE=1; return 1; }
  fi

  return 0
}

# compares nm item <name> with ,-list handling duplicates and ordering diffs
ipv6_nm_config_diff() { # return 0 if differ, 1 if same
  # <name> <,-list-with-dups>
  nmg::prop_match_values "${NM_CONFIG}" "$1" "," "$2" && return 1
  return 0
}

ipv6_write_by_iaid() { # returns 0
  # <name-@ID@> <iaid> <data>
  local id
  ipv6_verify_iaid id "$2" || return 0
  nmg_write "${1/@ID@/${id}}" "$3" || :
}

# sets <retvar> to file contents
ipv6_read_state_iaid() { # returns 0 if read, !0 otherwise
  # <retvar> <name-@ID@> <iaid>
  local id
  ipv6_verify_iaid id "$3" || return
  nmg::read "$1" "" "${2/@ID@/$id}" || return
}

ipv6_remove_state_iaid() { # returns 0
  # <name-@ID@> <iaid>
  local id file
  ipv6_verify_iaid id "$2" || return 0
  file=${1/@ID@/${id}}
  [[ -e ${file} ]] && nmg_remove "${file}"
  return 0
}

ipv6_addr_state_times() { # returns 0
  # <state-file> <iaid (unused)>
  [[ ${NM_SKIP_APPLY-} ]] && return 0
  local state='' addr6='' valid=0 pref=0 start=0
  nmg::read state "" "$1" || return 0
  nmg::prop_get_value addr6 "${state}" "inet6" || return 0
  nmg::prop_get_value valid "${state}" "valid-life" || return 0
  nmg::prop_get_value pref "${state}" "pref-life" || return 0
  nmg::prop_get_value start "${state}" "life-start" || return 0
  ipv6_addr_update_times "${addr6}" "${valid}" "${pref}" "${start}"
}

# loads addr-state file, adds to addrs(), dns(), and dns_srch()
ipv6_merge_state() { # returns 0
  # <state-file> <iaid(unused)>
  local state val item IFS; unset IFS
  nmg::read state "" "$1" || return 0
  nmg::prop_get_value val "${state}" "inet6" && addrs+=("${val}")
  if nmg::prop_has_value "${NM_CONFIG}" "ipv6.ignore-auto-dns" "no"; then
    if nmg::prop_get_value val "${state}" "dns"; then
      for item in ${val//,/ }; do dns+=("${item}"); done
    fi
    if nmg::prop_get_value val "${state}" "dns-search"; then
      for item in ${val//,/ }; do dns_srch+=("${item}"); done
    fi
  fi
  return 0
}

ipv6_nm_update() { # returns 0
  # merge all iaid files, and apply any changes
  local val xaddrs xdns='' xdns_srch=''
  local addrs=() dns=() dns_srch=() args=()
  local state_pat=${WAN_STATE_PAT/@WAN@/${interface}}

  [[ ${WAN_STATIC_IP6-} ]] && {
    nmg::lowercase val "${WAN_STATIC_IP6}"
    nmg::array addrs "," "${val// }"
  }

  [[ ${WAN_STATIC_DNS6-} ]] && {
    nmg::lowercase val "${WAN_STATIC_DNS6}"
    nmg::array dns "," "${val// }"
  }

  [[ ${WAN_STATIC_DNS6_SEARCH-} ]] && {
    nmg::array dns_srch "," "${WAN_STATIC_DNS6_SEARCH// }"; }

  nmg::foreach_filematch "${state_pat}" "@ID@" ipv6_merge_state

  nmg::array_join xaddrs "," "${addrs[@]-}"
  nmg::array_join xdns "," "${dns[@]-}"
  nmg::array_join xdns_srch "," "${dns_srch[@]-}"

  if ipv6_nm_config_diff "ipv6.addresses" "${xaddrs}" ||
      ipv6_nm_config_diff "ipv6.dns" "${xdns}" ||
      ipv6_nm_config_diff "ipv6.dns-search" "${xdns_srch}"; then

    # we have changes... update NM
    if [[ ${xaddrs} ]]; then
      args+=("ipv6.method" "manual" "ipv6.addresses" "${xaddrs}")
      args+=("ipv6.dns" "${xdns}" "ipv6.dns-search" "${xdns_srch}")
    else
      # if no addresses, make link-local
      args+=("ipv6.method" "link-local" "ipv6.addresses" "")
      args+=("ipv6.dns" "" "ipv6.dns-search" "")
    fi

    nmg_cmd "${NMCLI}" conn modify --temporary \
            "${CONNECTION_UUID}" "${args[@]}" && NM_MODIFIED=1
  fi

  ipv6_nm_reapply || {
    # remove new state and re-apply (recursive nm_reapply will not fail
    # as EXIT_CODE is set)
    ipv6_remove_state_iaid "${state_pat}" "${new_iaid-}"
    ipv6_nm_update
    return 0
  }

  # update lifetimes (even if NM not modified, times may change)
  nmg::foreach_filematch "${state_pat}" "@ID@" ipv6_addr_state_times

  return 0
}

ipv6_addr_remove_state_iaid() { # <iaid>
  ipv6_remove_state_iaid "${WAN_STATE_PAT/@WAN@/${interface}}" "$1"
}

# addr-state files contain:
#   inet6:<ip6>/<plen> [ valid-life:<life> pref-life:<life> life-start:<ts> ]
#   [ dns:<ip6>[,<ip6>]... ] [ dns-search:<domain>[,<domain>]... ]
ipv6_addr_write_state() {
  local state_pat=${WAN_STATE_PAT/@WAN@/${interface}}

  [[ ${new_iaid-} && ${new_ip6_address-} && ${new_ip6_prefixlen-} &&
       ${new_max_life:-1} != 0 ]] || {
    ipv6_remove_state_iaid "${state_pat}" "${new_iaid-}"
    return 0
  }

  local state val dns=() dns_srch=()
  nmg::prop_set_value state "" "inet6" \
                      "${new_ip6_address}/${new_ip6_prefixlen}"

  [[ ${new_max_life-} && ${new_preferred_life-} &&
       ${new_life_starts-} ]] && {
    nmg::prop_set_value state "${state}" "valid-life" "${new_max_life}"

    nmg::prop_set_value state "${state}" "pref-life" "${new_preferred_life}"
    nmg::prop_set_value state "${state}" "life-start" "${new_life_starts}"
  }
  local IFS; unset IFS
  for val in ${new_dhcp6_name_servers-}; do dns+=("${val}"); done
  nmg::array_join val "," "${dns[@]-}"
  [[ ${val} ]] && nmg::prop_set_value state "${state}" "dns" "${val}"

  for val in ${new_dhcp6_domain_search-}; do dns_srch+=("${val}"); done
  nmg::array_join val "," "${dns_srch[@]-}"
  [[ ${val} ]] && nmg::prop_set_value state "${state}" "dns-search" "${val}"

  ipv6_write_by_iaid "${state_pat}" "${new_iaid}" "${state}"
}

ipv6_addr_ddns() { # returns 0
  ipv6_handle_ddns \
    "${WAN_STATE_PAT/@WAN@/${interface}}" "${interface}" \
    "${WAN_DDNS_CONFIG_PAT//@WAN@/${interface}}" \
    "${WAN_DDNS_STATE_PAT//@WAN@/${interface}}"
}

# sets NMCLI='' if nmcli not available
# returns 1 if ipv6.method=auto (w/o override)
# returns 2 if ipv6.method=disabled
ipv6_check_nm() {
  # clear NMCLI until we know it works
  local method nmcli=${NMCLI} IFS; unset IFS; NMCLI=''
  local state='' cfile=${WAN_CONNSTATE_PAT/@WAN@/${interface}}

  # check if we're a NetworkManager connection
  [[ ${nmcli} ]] || return 0

  # we need nmcli to talk to NM
  nmg_need_progs "${nmcli}" || return

  if [[ -z ${CONNECTION_UUID-} ]]; then
    # dhclient ran us, load connection state file
    nmg::read state "" "${cfile}" && {
      nmg::prop_get_value CONNECTION_UUID "${state}" "conn-uuid" || :; }
    [[ ${CONNECTION_UUID} ]] || return 0
  fi

  # load config
  nmg::saferun NM_CONFIG "" "${nmcli}" -t -f connection,ipv6 \
               conn show "${CONNECTION_UUID}" || return 0

  # remove spaces from all values
  NM_CONFIG=${NM_CONFIG// }

  # get CONN_ID for logging....
  nmg::prop_get_value CONN_ID "${NM_CONFIG}" "connection.id" || {
    nmg_err "Unable to find connection.id for ${CONNECTION_UUID}"; return 0; }

  # check ipv6.method
  nmg::prop_get_value method "${NM_CONFIG}" "ipv6.method" || {
    nmg_err "Unable to locate ipv6.method for ${CONN_ID}"; return 0; }

  # NMCLI appears to be working...
  NMCLI=${nmcli}

  [[ -z "${state}" ]] && {
    # didn't load UUID, save for dhclient mode
    nmg::prop_set_value state "" "conn-uuid" "${CONNECTION_UUID}"
    nmg_write "${cfile}" "${state}" || return 0;
  }

  [[ ${method} == disabled ]] && return 2

  # dhclient will conflict with NM's, unless configured
  # to override this (perhaps RA unmanaged) log an error and quit.
  if [[ ${method} == auto ]]; then
    local v="${NMDH6_DHCLIENT_ARGS[*]}"
    # don't request addresses
    read -r -a NMDH6_DHCLIENT_ARGS <<< "${v/-N}"
    # allow override
    [[ ${NMDH6_IGNORE_METHOD_AUTO} ]] && return
    # allow if we're using a alt port (testing?)
    for v in ${WAN_DHCLIENT_OPTIONS-}; do [[ ${v} == -p ]] && return 0; done
    return 1
  fi
  return 0
}

# remove any stale state files
ipv6_state_cleanup() {
  nmg::foreach_filematch \
    "${LAN_STATE_PAT/@WAN@/${interface}}" "@LAN@-from-@ID@" nmg_remove
  nmg::foreach_filematch \
    "${WAN_STATE_PAT/@WAN@/${interface}}" "@ID@" nmg_remove
}

# return 1 if NMCLI unavail (dhclient-script called) or method auto/disabled
ipv6_check_nm_or_fallback() {

  # check for invalid config (and working nmcli or sets NMCLI=)
  ipv6_check_nm || {
    # method auto/disabled, cleanup state files
    ipv6_state_cleanup
    return 1
  }

  if [[ -z ${NMCLI} ]]; then
    # fallback to default dhclient-script (will probably clobber resolv.conf!)
    if [[ ${DHCLIENT_ORIG_SCRIPT} ]]; then
      command >/dev/null -v "${DHCLIENT_ORIG_SCRIPT}" &&
        exec "${DHCLIENT_ORIG_SCRIPT}"

      nmg_err "nmcli unavailable and DHCLIENT_ORIG_SCRIPT not found"
    fi
    # just succeed to prevent DECLINE loops
    return 1
  fi

  return 0
}

ipv6_addr_bind() {

  local na="${new_ip6_address}/${new_ip6_prefixlen}"
  local oa="${old_ip6_address-}/${old_ip6_prefixlen-}"
  if [[ ${oa} != / && ${na} != "${oa}" ]]; then
    nmg_info "Address on ${interface}: ${na} (old: ${oa})"
  else
    nmg_info "Address on ${interface}: ${na}"
  fi

  ipv6_check_nm_or_fallback || return 0

  [[ ${old_iaid-} && ${old_iaid} != "${new_iaid}" ]] &&
    ipv6_addr_remove_state_iaid "${old_iaid}"

  ipv6_addr_write_state
  ipv6_nm_update
  ipv6_addr_ddns
}

ipv6_addr_release() {

  nmg_info "Address removed from ${interface}: ${old_ip6_address}/${old_ip6_prefixlen}"

  ipv6_check_nm_or_fallback || return 0

  ipv6_addr_remove_state_iaid "${old_iaid-}"
  ipv6_nm_update
  ipv6_addr_ddns
}

ipv6_addr_depref() {

  nmg_info "Address devalued on ${interface}: ${cur_ip6_address}/${cur_ip6_prefixlen}"

  ipv6_check_nm_or_fallback || return 0

  # map cur_ to new_ (skip dns vals)
  local new_iaid=${cur_iaid} new_ip6_address=${cur_ip6_address}
  local new_ip6_prefixlen=${cur_ip6_prefixlen}
  [[ ${cur_max_life-} && ${cur_life_starts-} ]] && {
    local new_max_life=${cur_max_life} new_life_starts=${cur_life_starts}
    local new_preferred_life=0
  }

  ipv6_addr_write_state
  ipv6_nm_update
  ipv6_addr_ddns
}

ipv6_dhc_bind() {
  # is this a prefix delegation or address?
  if [[ ${new_ip6_prefix-} ]]; then
    ipv6_prefix_bind
  elif [[ ${new_ip6_address-} && ${new_ip6_prefixlen-} ]]; then
    ipv6_addr_bind
  fi
  return 0
}

ipv6_dhc_release() {

  if [[ ${old_ip6_prefix-} ]]; then
    ipv6_prefix_release
  elif [[ ${old_ip6_address-} && ${old_ip6_prefixlen-} ]]; then
    ipv6_addr_release
  fi
  return 0
}

ipv6_dhc_depref() {
  [[ ${cur_iaid-} ]] || return 0
  if [[ ${cur_ip6_prefix-} ]]; then
    ipv6_prefix_depref
  elif [[ ${cur_ip6_address-} && ${cur_ip6_prefixlen-} ]]; then
    ipv6_addr_depref
  fi
  return 0
}

ipv6_addrs_flush() {
  local NM_SKIP_APPLY=1

  # cleanup state files
  nmg::foreach_filematch \
    "${WAN_STATE_PAT/@WAN@/${interface}}" "@ID@" nmg_remove

  # flush addrs on WAN
  ipv6_check_nm && ipv6_nm_update

  ipv6_addr_ddns
}

# check NM works, ensure link-local is available, start dhclient
ipv6_dhclient_start() {
  local rc=0 link_addr=() IFS; unset IFS

  nmg_need_progs "${DHCLIENT}" || exit

  # if NM config invalid, explain why we failed
  ipv6_check_nm || rc=$?
  if (( rc == 1 )); then
    nmg_err "${CONN_ID}: dhclient would conflict with NetworkManagers (set ipv6.method to link-local or manual)"
    exit 1
  elif (( rc == 2 )); then
    nmg_err "${CONN_ID}: ipv6.method = disabled (set ipv6.method to link-local or manual)"
    exit 1
  fi

  # allow autoconfig on WAN to set default route to ipv6 gateway
  # (this handles NM ipv6.method=disabled)
  ipv6_update_ra

  local pidfile=${DHCLIENT_PID/@WAN@/${interface}}
  # already running?
  if nmg_is_running "${DHCLIENT}" "${pidfile}"; then
    nmg_debug "${DHCLIENT} already running"
  elif nmg::query_ips link_addr "" 6a "${interface}" "" "scope" "link" &&
       [[ ${link_addr-} ]]; then
    # make sure address is active
    if nmg::wait_dad6 "${interface}" "${link_addr[0]}"; then
      # cleanup any stale state
      ipv6_state_cleanup
      # start dhclient to get addresses/prefixes
      # shellcheck disable=SC2086
      nmg_daemon "${DHCLIENT}" -P ${NMDH6_DHCLIENT_ARGS[*]-} -nw \
                 -sf "${BASH_SOURCE[0]}" -pf "${pidfile}" \
                 -lf "${LEASES/@WAN@/${interface}}" \
                 ${WAN_DHCLIENT_OPTIONS-} "${interface}"
    else
      nmg_err "Timed out waiting for link address ${link_addr[0]}"
    fi
  else
    nmg_err "Unable to find link-local address on ${interface}"
  fi
  return 0
}

ipv6_dhclient_stop() {

  local pidfile=${DHCLIENT_PID/@WAN@/${interface}}
  local cfile=${WAN_CONNSTATE_PAT/@WAN@/${interface}}

  nmg_need_progs "${DHCLIENT}" || exit

  # stop the dhclient if it's running
  if nmg_is_running "${DHCLIENT}" "${pidfile}" 1; then
    nmg_cmd "${DHCLIENT}" -x -sf "${BASH_SOURCE[0]}" -pf "${pidfile}" \
            "${interface}"
  fi

  # dhclient -x should call this script with STOP6, but doesn't...
  # work around it.
  ipv6_prefix_flush
  ipv6_addrs_flush

  # cleanup files
  [[ -e ${pidfile} ]] && nmg_remove "${pidfile}"
  [[ -e ${cfile} ]] && nmg_remove "${cfile}"
  return 0
}

ipv6_wan_start() {

  local rc=0
  ipv6_wan_read_config "${interface}" || rc=$?

  # no file ok
  [[ ${rc} == 1 ]] && return 0
  # other error?
  [[ ${rc} != 0 ]] && return ${rc}

  # start dhclient
  if ipv6_wan_check; then
    ipv6_dhclient_start
  else
    ipv6_dhclient_stop
  fi
}

ipv6_wan_stop() {

  # stop dhclient if we have config
  ipv6_wan_read_config "${interface}" && ipv6_dhclient_stop

  return 0
}

if [[ ${1-} && ${2-} ]]; then

  # used in functions
  interface=$1

  # run from NM
  nmg_debug "interface: ${interface} action: $2"

  # only create RUNDIR when run by NM (permissions)
  [[ -e ${RUNDIR} ]] || {
    [[ ${NM_DISPATCHER_ACTION} ]] || {
      nmg_err "RUNDIR ${RUNDIR} missing; run from NetworkManager\
 as dispatcher to create with correct permissions!"
      exit 1
    }
    nmg_cmd mkdir -p "${RUNDIR}" || exit
  }

  case "$2" in
    up)
      ipv6_lan_start
      ipv6_wan_start
      ;;
    dhcp4-change)
      ipv6_wan_start
      ;;
    down)
      ipv6_lan_stop
      ipv6_wan_stop
      ;;
  esac

elif [[ ${interface-} && ${reason-} ]]; then

  # run from dhclient
  nmg_debug "interface: ${interface} reason: ${reason}"

  # check if we're still configured
  ipv6_wan_read_config "${interface}" || exit 0

  # since we're a child of dhclient (not NM), don't set cgroup
  # shellcheck disable=SC2034
  NMG_DAEMON_CGROUP=''

  case "${reason-}" in
    BOUND6|RENEW6|REBIND6)
      # (old_) new_ values
      ipv6_dhc_bind
      ;;
    DEPREF6)
      # cur_ values
      ipv6_dhc_depref
      ;;
    EXPIRE6|RELEASE6|STOP6)
      # old_ values
      ipv6_dhc_release
      ;;
  esac

  # exit with correct code if declining address
  [[ ${EXIT_CODE} ]] && exit "${EXIT_CODE}"
fi

: # for loading in tests
