# -*- mode: sh; sh-basic-offset: 2; indent-tabs-mode: nil; -*-
# vim:set ft=sh et sw=2 ts=2:
#
# NMDDNS - Dynamic DNS functions scripts can include and use
# 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:
#
#   Setup a few constants in your NetworkManager dispatcher script, and
#   include this file, here's an example:
#
#       # optional, for logging
#       NMG_TAG="ddns"
#
#       # set NMUTILS/NMCONF early, and allow environment to override
#       NMUTILS=${NMUTILS:-/etc/nmutils}
#       NMCONF=${NMCONF:-${NMUTILS}/conf}
#
#       # optional min-version required
#       NMDDNS_REQUIRED="1.3.7"
#
#       NMDDNS=${NMDDNS:-${NMUTILS}/ddns-functions}
#       { [[ -r ${NMDDNS} ]] && . "${NMDDNS}"; } || NMDDNS=''
#
#   Use of NM* variables above is optional (NMDDNS here indicates
#   nmddns_* functions were loaded), but the above allows easy
#   overrides from the environment (and easy testing).  You may also
#   want to customize some settings in NMCONF/general.conf (see
#   "Global overrides" below)
#
# Requires:
#
#   NMUTILS/general-functions - shared functions
#   nsupdate - for DNS updates
#   dig - for DNS queries
#
# Supported, but optional:
#
#   09-ddns - asynchronous DDNS helper- place "HELPER" in
#             /usr/lib/NetworkManager/dispatcher.d (or set NMDDNS_DHELPER
#             in NMG's general.conf)
#   flock - used for locking
#
# Dynamic DNS Functions:
#
#   nmddns_read_config <config-file>
#
#     Reset config setting (including for all resource records) and
#     read <config-file>. Return 1 if not found or required elements missing.
#
#   nmddns_required_config <config-file>
#
#     Same as nmddns_read_config but exit 0 if not found/required element
#     missing, or exit with error if other error.
#
#   nmddns_reset_config
#
#     Unset all config environment
#
#   nmddns_cleanup
#
#     Unset all loaded environment
#
#   nmddns::get_A_addrs <retarr> <interface> [ <private-ok> ]
#
#     Sets <retarr> to all global ipv4 addresses on <interface> that
#     pass <private-ok> (see nmg_check_ip4_addr).  Fails if
#     nmg::query_ips does.
#
#   nmddns::get_AAAA_addrs <retvar> <interface> [ <private-ok> ]
#
#     As get_A_addrs above, but for ipv6 addresses that also pass DAD.
#
#   nmddns_update <rrec> [ <value> [ <state-pat> ] ]
#
#     Set DNS resource record <rrec> to <value>, or if <value> empty,
#     set to the fallback value if not empty (see below), or remove
#     the record (DDNS_* values should be set).  If supplied,
#     <value> is expected to be in RDATA format appropriate for <rrec>.
#     If provided, <state-pat> must contain the string "@RREC@" and
#     is used to save <value> and override value assigned later by 09-ddns.
#     Returns any errors from nsupdate.
#
#     SPECIAL CASE: for A/AAAA records, values containing "," are handled
#     as multiple (comma-separated) address records (see _LISTSEP below for
#     multiple records of other types).
#
#     SPECIAL CASE: for A/AAAA records, values of "!<interface>" will
#     use nmddns::get_<A|AAAA>_addrs to query current ips on <interface>.
#
#   nmddns_spawn_update <config-file> <rrec> [ <value> [ <state-pat> ] ]
#
#     Checks if <rrec> defined in <config-file>, and if so spawns HELPER
#     to call nmddns_update asynchronously.  If HELPER not found, calls
#     nmddns_update directly with reduced timeouts.
#
#   nmddns_update_all <"up" | "down"> [ <ip4-addr> <ip6-addr> [ <state-pat> ] ]
#
#     Updates for all configured DDNS names (DDNS_* values should be set),
#     using <ip4-addr> for A and <ip6-addr> for AAAA (if respective
#     DDNS_RREC_*_VALUEs don't override).  Returns any errors from nsupdate.
#     See nmddns_update for special A/AAAA values and use of <state-pat>.
#
#   nmddns_spawn_update_all <"up" | "down"> <config-file>
#                          [ <ip4-addr> <ip6-addr> [ <state-pat> ] ]
#
#     Spawn HELPER to call nmddns_update_all asynchronously, or read
#     <config-file> and call it directly if HELPER not found.
#
# Configuration Settings (set before including this file)
#
#   Any general-functions configuration, eg. NMUTILS/NMCONF
#
#   NMDDNS_REQUIRED (optional) - minimum required NMDDNS_VERSION
#
# Global Overrides (put in NMCONF/general.conf)
#
#  "@MATCH@" can be <interface>, or <interface>-from-<something>
#
#   NMDDNS_CONFIG_PAT (default: "NMCONF/ddns-@MATCH@.conf")
#       Value must contain a single "@MATCH@"
#       See "Config Settings" below for file contents.
#
#   DDNS_STATE_DIR (default: "$RUNDIR", or "/run/nmutils" if unset)
#
#   NMDDNS_STATE_PAT (default: "DDNS_STATE_DIR/ddns-@MATCH@-@RREC@.state")
#       Value must contain a single "@MATCH@-@RREC@" and is used by
#       09-ddns for per-@RREC@ overrides for @MATCH@ config.
#       (use with nmddns_update)
#
#   DDNS_GLOBAL_LOCKFILE (optional) - flock lockfile used to prevent
#       races between query and set of records.  Overrides use of config
#       file with this file for all locking (useful to serialize all DNS
#       updates).
#
#   DDNS_GLOBAL_FLOCK_TIMEOUT (default: 15) - flock timeout in seconds
#
#   DDNS_GLOBAL_DIG_TIMEOUT (default: 3) - DNS query (dig) timeout
#
#   DDNS_GLOBAL_DIG_RETRIES (default: 2) - DNS query (dig) retries
#
#   DDNS_GLOBAL_DIG_OPTIONS (optional) - options for dig, to have
#       it use TCP ("+tcp") or a keyfile
#       (eg "-k /etc/Kexample.net.+157+12345.private")
#
#   DDNS_GLOBAL_NSUPDATE_TIMEOUT (default: 10) - nsupdate timeout in seconds
#
#   DDNS_GLOBAL_NSUPDATE_OPTIONS (optional) - options for nsupdate, to have
#       it use TCP ("-v") or a keyfile
#       (eg "-k /etc/Kexample.net.+157+12345.private")
#
# Config Settings (put in <config-file>, see nmddns_read_config() above):
#
#   DDNS_ZONE (required) - zone to update
#
#   DDNS_RREC_<rrec>_NAME (one per <rrec>) - name to update for <rrec> record
#       (<rrec> is A, AAAA, CNAME, TXT etc).  This is generally a
#       domain or host name, but can be any valid DNS name.
#
#   DDNS_RREC_<rrec>_VALUE (optional) -  use this value when interface is up.
#       If empty, then DDNS_RREC_<rrec>_FALLBACK is used.  If set to "*"
#       (an asterisk), then FALLBACK is ignored, and the <rrec> is removed
#       when the interface is up.
#
#   DDNS_RREC_<rrec>_FALLBACK (optional) - value to use if a record would
#       otherwise be removed (empty value); useful to set a value when
#       interface is down, or an global address is not yet available
#       on an interface.
#
#   DDNS_RREC_<rrec>_PRIVATE (optional) - for A/AAAA only, allow private
#       interface addresses to be used (otherwise would use FALLBACK)
#
#   DDNS_RREC_<rrec>_LISTSEP (optional) - for non-A/AAAA records,
#       handles <value> and *_VALUEs as multiple records separated by
#       this variable's value.
#
#   DDNS_DIG_TIMEOUT (optional) - override global DNS query (dig) timeout
#
#   DDNS_DIG_RETRIES (optional) - override global DNS query (dig) retries
#
#   DDNS_DIG_OPTIONS (optional) - override global dig options
#
#   DDNS_NSUPDATE_TIMEOUT (optional) - override global nsupdate timeout
#
#   DDNS_NSUPDATE_OPTIONS (optional) - override global nsupdate options
#
#   DDNS_SERVER (default: 127.0.0.1) - dns server to update
#
#   DDNS_TTL (default: 600) - ttl of entry
#
#   DDNS_FLOCK_TIMEOUT (optional) - override global flock timeout
#
#   DDNS_LOCKFILE (optional) - flock lockfile used to prevent races between
#       query and update.  Defaults to config file itself.  Set to empty
#       in a config file to disable locking for just that file.
#
# NOTE: executable paths (see below) may be overriden if needed.
#
# Globals
#
#   NMDDNS_VERSION - current file version
#
# shellcheck shell=bash disable=SC1090

[[ ${NMDDNS_VERSION-} ]] || declare -r NMDDNS_VERSION="1.5.0"

# set default paths if missing
NMUTILS=${NMUTILS:-/etc/nmutils}
NMCONF=${NMCONF:-${NMUTILS}/conf}

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

DDNS_STATE_DIR=${RUNDIR:-/run/nmutils}
DDNS_GLOBAL_LOCKFILE=${DDNS_GLOBAL_LOCKFILE-}
DDNS_GLOBAL_FLOCK_TIMEOUT=${DDNS_GLOBAL_FLOCK_TIMEOUT:-15}
DDNS_GLOBAL_DIG_TIMEOUT=${DDNS_GLOBAL_DIG_TIMEOUT:-3}
DDNS_GLOBAL_DIG_RETRIES=${DDNS_GLOBAL_DIG_RETRIES:-2}
DDNS_GLOBAL_DIG_OPTIONS=${DDNS_GLOBAL_DIG_OPTIONS-}
DDNS_GLOBAL_NSUPDATE_TIMEOUT=${DDNS_GLOBAL_NSUPDATE_TIMEOUT:-10}
DDNS_GLOBAL_NSUPDATE_OPTIONS=${DDNS_GLOBAL_NSUPDATE_OPTIONS-}

########## Support Programs

NMDDNS_DHELPER=${NMDDNS_DHELPER:-/usr/lib/NetworkManager/dispatcher.d/09-ddns}
NMDDNS_DIG=${NMDDNS_DIG:-dig}
NMDDNS_NSUPDATE=${NMDDNS_NSUPDATE:-nsupdate}

# set NMDDNS_FLOCK to empty to disable all locking
NMDDNS_FLOCK=${NMDDNS_FLOCK:-flock}

########## Config Locations (defaults for HELPER)

NMDDNS_CONFIG_PAT=${NMDDNS_CONFIG_PAT:-${NMCONF}/ddns-@MATCH@.conf}
NMDDNS_STATE_PAT=${NMDDNS_STATE_PAT:-${DDNS_STATE_DIR}/ddns-@MATCH@-@RREC@.state}

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

# load general-functions
NMG=${NMG:-${NMUTILS}/general-functions}
# save any existing NMG_REQUIRED (checked later)
NMDDNS_NMG_REQ=${NMG_REQUIRED-}
NMG_REQUIRED="1.6.0"
{ [[ -r ${NMG} ]] && . "${NMG}"; } || {
  echo 1>&2 "Unable to load ${NMG}"; NMG=''; }

# private
nmddns::_loaded() {

  # test if general-functions loaded..
  [[ ${NMG} ]] || {
    # exit, as any fallback will fail to load NMG anyway
    exit 2
  }

  if [[ ${NMDDNS_NMG_REQ} ]]; then
    NMG_REQUIRED=${NMDDNS_NMG_REQ}
    if ! nmg::require_version "${NMG_VERSION}" "${NMG_REQUIRED}"; then
      nmg_err "${BASH_SOURCE[0]}: NMG_VERSION=${NMG_VERSION} < NMG_REQUIRED=${NMG_REQUIRED}"
      return 1
    fi
  fi
  if [[ ${NMDDNS_REQUIRED-} ]] &&
       ! nmg::require_version "${NMDDNS_VERSION}" "${NMDDNS_REQUIRED}"; then
    nmg_err "${BASH_SOURCE[0]}: NMDDNS_VERSION=${NMDDNS_VERSION} < NMDDNS_REQUIRED=${NMDDNS_REQUIRED}"
    return 1
  fi

  # test required programs
  nmg_need_progs "${NMDDNS_DIG}" "${NMDDNS_NSUPDATE}" || return
}

# internal
nmddns::_short_timeouts() {
  # fast timeouts with no background HELPER is available
  DDNS_DIG_TIMEOUT=1
  DDNS_DIG_RETRIES=0
  DDNS_NSUPDATE_TIMEOUT=2
  DDNS_FLOCK_TIMEOUT=1
}

# internal
nmddns::_update() {
  # <name> <rrec> <value>
  local ddns_name=$1 ddns_rrec=$2 ddns_value=$3 cur_value name sep

  DDNS_SERVER=${DDNS_SERVER:-127.0.0.1}

  # check if server already has correct entry
  nmg_debug "Looking up current ${ddns_rrec} on server ${DDNS_SERVER}"

  local qtime=${DDNS_DIG_TIMEOUT:-${DDNS_GLOBAL_DIG_TIMEOUT:-3}}
  local qretry=${DDNS_DIG_RETRIES:-${DDNS_GLOBAL_DIG_RETRIES:-2}}
  local qopts=${DDNS_DIG_OPTIONS:-${DDNS_GLOBAL_DIG_OPTIONS-}}
  # shellcheck disable=SC2086
  if ! nmg::run cur_value "" "${NMDDNS_DIG}" "@${DDNS_SERVER}" \
       +short +retry="${qretry}" +time="${qtime}" ${qopts} \
       "${ddns_rrec}" "${ddns_name}"; then
    if [[ ${ddns_value} ]]; then
      nmg_err "Update ${ddns_name} ${ddns_rrec} to ${ddns_value} failed"
    else
      nmg_err "Removal of ${ddns_name} ${ddns_rrec} failed"
    fi
    return 25
  fi

  name="DDNS_RREC_${ddns_rrec}_LISTSEP"; sep=${!name:-}

  [[ ${ddns_rrec} =~ ^A|AAAA$ ]] && sep=","

  if [[ ${sep} ]]; then
    # shellcheck disable=SC2034
    local acur=() anew=()
    nmg::array acur $'\n' "${cur_value}"
    nmg::array anew "${sep}" "${ddns_value}"
    if nmg::array_match_values acur anew; then
      nmg_debug "${ddns_name} ${ddns_rrec} entry current: ${ddns_value}"
      return
    fi
  elif [[ ${cur_value} == "${ddns_value}" ]]; then
    nmg_debug "${ddns_name} ${ddns_rrec} entry current: ${ddns_value}"
    return
  fi

  nmg_debug "Old ${ddns_name} ${ddns_rrec} value: ${cur_value}"
  local ddns_cmd='' items=()
  if [[ ${ddns_value} ]]; then
    nmg_info "Setting ${ddns_name} ${ddns_rrec} to ${ddns_value}"
    if [[ ${sep} ]]; then
      nmg::array items "${sep}" "${ddns_value}"
      for ddns_value in "${items[@]}"; do
        ddns_cmd+="update add ${ddns_name} ${DDNS_TTL:-600} ${ddns_rrec} ${ddns_value}"$'\n'
      done
    else
      ddns_cmd="update add ${ddns_name} ${DDNS_TTL:-600} ${ddns_rrec} ${ddns_value}"$'\n'
    fi
  else
    nmg_info "Removing ${ddns_name} ${ddns_rrec}"
  fi

  # update the entry (15 sec timeout)
  local timeout=${DDNS_NSUPDATE_TIMEOUT:-${DDNS_GLOBAL_NSUPDATE_TIMEOUT:-10}}
  local options=${DDNS_NSUPDATE_OPTIONS:-${DDNS_GLOBAL_NSUPDATE_OPTIONS-}}
  # shellcheck disable=SC2086
  nmg_cmd "${NMDDNS_NSUPDATE}" -t "${timeout}" ${options} <<- EOF
	server ${DDNS_SERVER}
	zone ${DDNS_ZONE}
	update delete ${ddns_name} ${ddns_rrec}
	${ddns_cmd}send
	EOF
  local rc=$?
  [[ ${rc} != 0 ]] && nmg_err "DNS update to server ${DDNS_SERVER} failed for ${ddns_name} ${ddns_rrec}"

  return ${rc}
}

nmddns_reset_config() {
  local name
  for name in "${!DDNS_RREC_@}"; do unset "${name}"; done
  unset DDNS_ZONE DDNS_SERVER DDNS_TTL DDNS_FLOCK_TIMEOUT
  unset DDNS_DIG_TIMEOUT DDNS_DIG_RETRIES DDNS_DIG_OPTIONS
  unset DDNS_NSUPDATE_TIMEOUT DDNS_NSUPDATE_OPTIONS DDNS_LOCKFILE
}

nmddns_read_config() {
  # <config-file>
  local config=${1-}

  # clear config
  nmddns_reset_config

  # lockfile defaults to config-file (as it will exist if used)
  DDNS_LOCKFILE=${DDNS_GLOBAL_LOCKFILE-${config}}

  # read config if any
  nmg_read_config "${config}" || return

  # check required elements
  [[ ${DDNS_ZONE} ]] || return
}

nmddns_cleanup() {
  nmddns_reset_config
  unset DDNS_GLOBAL_LOCKFILE DDNS_GLOBAL_FLOCK_TIMEOUT
  unset DDNS_GLOBAL_DIG_TIMEOUT DDNS_GLOBAL_DIG_RETRIES
  unset DDNS_GLOBAL_DIG_OPTIONS DDNS_GLOBAL_NSUPDATE_TIMEOUT
  unset DDNS_GLOBAL_NSUPDATE_OPTIONS
}

nmddns_required_config() {
  # <config-file>
  local rc=0
  nmddns_read_config "${1-}" || rc=$?

  # 1 means no file, just exit 0
  (( rc == 1 )) && exit 0
  # any other error, exit with it
  (( rc != 0 )) && exit ${rc}
  # no errors, continue...
  return 0
}

# Sets _rlist arrays to all active <ip-ver> addresses on <intf>
# If <priv> not empty, allow private addresses
nmddns::_query_addrs() { # fails if <intf> empty or query_ips fails
  # <ip-ver> <intf> <priv>
  local ver=$1 intf=$2 priv=$3 addr addrp alist=()

  [[ ${intf} ]] || return

  # query addresses with properties
  nmg::query_ips alist "" "${ver}p" "${intf}" || return

  for addrp in ${alist[*]+"${alist[@]}"}; do
    # strip /<subnet>
    addr=${addrp%%/*}
    if [[ ${ver} == 4 ]]; then
      nmg_check_ip4_addr "${addr}" "${priv}" || continue
    else
      nmg_check_ip6_addr "${addr}" "${priv}" || continue
      [[ ${addrp} =~ (^| )dadfailed($| ) ]] && continue
      [[ ${addrp} =~ (^| )tentative($| ) ]] && {
        nmg::wait_dad6 "${intf}" "${addrp%% *}" || continue
      }
    fi
    _rlist+=("${addr}")
  done

  return 0
}

# sets <retarr> with all valid dns ip4 address on <intf>
nmddns::get_A_addrs() { # returns err if query_ips fails
  # <retarr> <intf> [ <priv> ]
  local _rlist=() _rc=0
  nmddns::_query_addrs 4 "${2-}" "${3-}" || _rc=$?
  [[ ${1-} ]] && nmg::array_copy "$1" "_rlist"
  return ${_rc}
}

# sets <retarr> with all valid dns ip4 address on <intf>
nmddns::get_AAAA_addrs() { # returns err if query_ips fails
  # <retarr> <intf> [ <priv> ]
  local _rlist=() _rc=0
  nmddns::_query_addrs 6 "${2-}" "${3-}" || _rc=$?
  [[ ${1-} ]] && nmg::array_copy "$1" "_rlist"
  return ${_rc}
}

nmddns_update() {
  # <rrec> [<value> [<state-pat>]]
  local rrec=${1-} value=${2-} state_pat=${3-} name ddns_name state
  local new_value priv_ok timeout avalue=() anew=() addr

  [[ ${rrec} ]] || {
    nmg_err "nmddns_update: missing <rrec>"; return 1; }

  # are we configured to update this <rrec>?
  name="DDNS_RREC_${rrec}_NAME"; ddns_name=${!name-}
  [[ ${ddns_name} ]] || return 0

  # check config
  [[ ${DDNS_ZONE-} ]] || {
    nmg_err "Missing required DDNS_ZONE config"; return 5; }

  [[ ${state_pat} && ! ${state_pat} =~ @RREC@ ]] && {
    nmg_err "nmddns_update: <state-pat> must contain '@RREC@'"
    state_pat=''
  }

  state=${state_pat/@RREC@/${rrec}}

  if [[ ${value} ]]; then

    # set state if requested
    [[ ${state} ]] && { nmg_write "${state}" "${value}"$'\n' || :; }

    if [[ ${rrec} =~ ^A|AAAA$ ]]; then

      # check for !<interface> value
      if [[ ${value} =~ ^[!].+$ ]]; then
        # if interface down, remove addrs
        if [[ ${rrec} == A ]]; then
          nmddns::get_A_addrs anew "${value#!}" \
                              "${DDNS_RREC_A_PRIVATE-}" || :
        else
          nmddns::get_AAAA_addrs anew "${value#!}" \
                                 "${DDNS_RREC_AAAA_PRIVATE-}"|| :
        fi
      else
        nmg::lowercase value "${value}"
        nmg::array anew "," "${value}"
      fi
      # strip any "/<prefix>"
      for addr in ${anew[*]+"${anew[@]}"}; do avalue+=("${addr%%/*}"); done
      nmg::array_join value "," "${avalue[@]-}"
    fi

    # use DDNS_RREC_*_VALUE, or $value if not set/empty
    name="DDNS_RREC_${rrec}_VALUE"; new_value=${!name:-${value}}

    if [[ ${rrec} =~ ^A|AAAA$ ]]; then
      # SPECIAL CASE: if A or AAAA (without _VALUE override), make
      # sure new value is valid
      nmg::lowercase new_value "${new_value}"
      nmg::array anew "," "${new_value}"
      # if list new_value == value (no override), check values are valid
      if nmg::array_match_values avalue anew; then
        name="DDNS_RREC_${rrec}_PRIVATE"; priv_ok=${!name-}
        anew=()
        for addr in ${avalue[*]+"${avalue[@]}"}; do
          if [[ ${rrec} == A ]]; then
	    nmg_check_ip4_addr "${addr}" "${priv_ok}" && anew+=("${addr}")
          else
	    nmg_check_ip6_addr "${addr}" "${priv_ok}" && anew+=("${addr}")
          fi
        done
        # update new_value with only checked values
        nmg::array_join new_value "," "${anew[@]-}"
      fi
    fi

    # use fallback (if any) if <new_value> empty
    [[ ${new_value} ]] || {
      name="DDNS_RREC_${rrec}_FALLBACK"; new_value=${!name-}; }

    # new_value of * means remove entry on set (ignoring fallback)
    [[ ${new_value} == "*" ]] && value='' || value=${new_value}
  else

    # remove state if requested
    [[ ${state} ]] && nmg_remove "${state}"

    # clearing value, use fallback (if any)
    name="DDNS_RREC_${rrec}_FALLBACK"; value=${!name-}
  fi

  if [[ ${DDNS_LOCKFILE-} ]] && command &>/dev/null -v "${NMDDNS_FLOCK}"; then
    timeout=${DDNS_FLOCK_TIMEOUT:-${DDNS_GLOBAL_FLOCK_TIMEOUT:-15}}
    ("${NMDDNS_FLOCK}" -w "${timeout}" 9 || {
       nmg_err "Timeout getting DDNS lock for ${rrec}"; exit 1; }
     nmddns::_update "${ddns_name}" "${rrec}" "${value}"
    ) 9<"${DDNS_LOCKFILE}" || return
  else
    # no locking
    [[ ${DDNS_LOCKFILE-} ]] && nmg_info "Locking not available for DDNS"
    nmddns::_update "${ddns_name}" "${rrec}" "${value}" || return
  fi
  return 0
}

nmddns::_daemon_helper() { # return 1 if helper unavail or daemonize fails
  # <action> <config> [ <state> ]
  [[ ${NMDDNS_DHELPER-} ]] || return 1

  # export helper action
  local -x NMDDNSH_ACTION=$1 NMDDNSH_CONFIG=$2 NMDDNSH_STATE=${3-}
  # keep log tag
  export NMG_TAG

  nmg_daemon "${NMDDNS_DHELPER}" || return
}

nmddns_spawn_update() {
  # <ddns-file> <rrec> [<value> [<state-pat>]]
  local config=${1-} rrec=${2-} name ddns_name

  [[ ${config} ]] || {
    nmg_err "nmddns_spawn_update: <config> requires a value"
    return 1
  }

  [[ ${rrec} ]] || {
    nmg_err "nmddns_spawn_update: <rrec> must be a RREC name"
    return 1
  }

  # check if config exists
  nmddns_read_config "${config}" || return 0

  # check if name configured
  name="DDNS_RREC_${rrec}_NAME"; ddns_name=${!name-}
  [[ ${ddns_name} ]] || return 0

  # we have a valid config, spawn the HELPER
  local -x NMDDNSH_RREC=${rrec} NMDDNSH_VALUE=${3-}

  nmddns::_daemon_helper update "${config}" "${4-}" && return 0

  # make timeouts much faster, or NetworkManager will kill us
  nmddns::_short_timeouts

  # HELPER not found, or can't be started, update directly.
  shift 1
  nmddns_update "$@"
}

nmddns_update_all() {
  # <"up"|"down"> [ <ip4-addr> <ip6-addr> [ <state-pat> ] ]
  local action=$1 addr4=${2-} addr6=${3-} state_pat=${4-}
  local name rrec value rc=0

  for name in "${!DDNS_RREC_@}"; do

    # DDNS_RREC_<rrec>_NAME is required
    [[ ${name} =~ _NAME$ ]] || continue

    # parse rrec
    name=${name#DDNS_RREC_}
    rrec=${name%_NAME}
    [[ ${rrec} ]] || continue

    case ${action} in
      up)
	# special case values for A and AAAA
	case ${rrec} in
	  A) value=${addr4} ;;
	  AAAA) value=${addr6} ;;
	  *) name="DDNS_RREC_${rrec}_VALUE"; value=${!name-} ;;
	esac
	;;
      down) value='' ;;
      *)
	nmg_err "nmddns_update_all: <action> must be 'up' or 'down'"
	return 1
	;;
    esac

    # perform update
    nmddns_update "${rrec}" "${value}" "${state_pat}" || rc=$?
  done

  return ${rc}
}

nmddns_spawn_update_all() {
  # <"up"|"down"> <config-file> [ <ip4-addr> <ip6-addr> [ <state-pat> ] ]
  local action=${1-} config=${2-}

  [[ ${action} =~ ^(up|down)$ ]] || {
    nmg_err "nmddns_spawn_update_all: <action> must be 'up' of 'down'"
    return 1
  }

  [[ ${config} ]] || {
    nmg_err "nmddns_spawn_update_all: <config> requires a value"
    return 1
  }

  # check if config exists
  nmddns_read_config "${config}" || return 0

  # check if any names configured
  local name name_found=''
  for name in "${!DDNS_RREC_@}"; do
    # DDNS_RREC_<rrec>_NAME is required
    [[ ${name} =~ _NAME$ ]] && name_found=1 && break
  done
  [[ ${name_found} ]] || return 0

  # we have a valid config, spawn the HELPER
  local -x NMDDNSH_ADDR4=${3-} NMDDNSH_ADDR6=${4-}

  nmddns::_daemon_helper "${action}" "${config}" "${5-}" && return 0

  # make timeouts much faster, or NetworkManager will kill us
  nmddns::_short_timeouts

  # HELPER not found, or can't be started, update directly.
  shift 2
  nmddns_update_all "${action}" "$@"
}

# last, to fail load if any missing components
nmddns::_loaded
