OpenVPN Alternative up down script

October 21, 2023

Backup from https://raw..com/jonathanio/update-systemd-resolved/master/update-systemd-resolved in case it ever gets removed. Used in my blog post OpenVPN client force DNS servers in Linux.

#!/usr/bin/env bash
#
# OpenVPN helper to add DHCP information into systemd-resolved via DBus.
# Copyright (C) 2016, Jonathan Wright <jon@than.io>
#
# 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/>.

# This script will parse DHCP options set via OpenVPN (dhcp-option) to update
# systemd-resolved directly via DBus, instead of updating /etc/resolv.conf. To
# install, set as the 'up' and 'down' script in your OpenVPN configuration file
# or via the command-line arguments, alongside setting the 'down-pre' option to
# run the 'down' script before the device is closed. For example:
#
#   script-security 2
#   up /usr/local/libexec/openvpn/update-systemd-resolved
#   up-restart
#   down /usr/local/libexec/openvpn/update-systemd-resolved
#   down-pre

# Define what needs to be called via DBus
DBUS_DEST="org.freedesktop.resolve1"
DBUS_NODE="/org/freedesktop/resolve1"

SCRIPT_NAME="${BASH_SOURCE[0]##*/}"

if [[ -S /dev/log ]] && command -v logger &> /dev/null; then
  if [[ -t 2 ]]; then
    log() {
      logger -s -t "$SCRIPT_NAME" "$@"
    }
  else
    # Suppress output on stderr when not attached to a (p|t)ty.
    # https://github.com/jonathanio/update-systemd-resolved/issues/81
    log() {
      logger -t "$SCRIPT_NAME" "$@"
    }
  fi

  for level in err warning info debug; do
    printf -v functext -- '%s() { log -p user.%s -- "$@" ; }' "$level" "$level"
    eval "$functext"
  done
else
  log() {
    printf 1>&2 -- '%s: %s\n' "$SCRIPT_NAME" "$*"
  }

  for level in err warning info debug; do
    printf -v functext -- '%s() { log "%s:" "$@" ; }' "$level" "${level^^}"
    eval "$functext"
  done
fi

usage() {
  err "${1:?${1}. }. Usage: ${SCRIPT_NAME} up|down|print-polkit-rules [<options>]."
}

busctl_status() {
  busctl status "$DBUS_DEST"
}

busctl_call() {
  # Preserve busctl's exit status
  busctl call "$DBUS_DEST" "$DBUS_NODE" "${DBUS_DEST}.Manager" "$@" || {
    local -i status=$?
    err "'busctl' exited with status $status"
    print_polkit_rules_command_for_current_user | err
    return $status
  }
}

get_link_info() {
  dev="$1"
  shift

  link=''
  link="$(ip link show dev "$dev")" || return $?

  echo "$dev" "${link%%:*}"
}

each_dhcp_setting() {
  local foreign_option foreign_option_value setting_type setting_value

  for foreign_option in "${!foreign_option_@}"; do
    foreign_option_value="${!foreign_option}"

    # Matches:
    #
    #   dhcp-option SOME-SETTING a-value
    #   dhcp-option ANOTHER-SETTING
    #
    # In the second case, the setting value is the empty string.
    if [[ $foreign_option_value =~ ^[[:space:]]*dhcp-option[[:space:]]+([^[:space:]]+)([[:space:]]+(.*))?$ ]]; then
      "$@" "${BASH_REMATCH[1]}" "${BASH_REMATCH[3]-}" || return
    fi
  done
}

# Check that a function was supplied the expected number of arguments.  If not,
# issue a diagnostic message and return nonzero status.
usage_for() {
  if [[ ${FUNCNAME[1]} != "${FUNCNAME[0]}" ]]; then
    usage_for 4 - "$#" '<funcname> <argc-min> <argc-max> <argc-actual> <usage-string> [<usage-string> ...]' || return
  fi

  local caller="${FUNCNAME[1]}"

  local -i argc_min

  case "$1" in
    -)
      argc_min=0
      ;;
    *)
      argc_min="$1"
      ;;
  esac

  shift

  local have_argc_max
  local -i argc_max

  case "$1" in
    -) ;;
    *)
      have_argc_max=yes
      argc_max="$1"
      ;;
  esac

  shift

  local -i argc="$1"
  shift

  if ((argc < argc_min)) || { [[ -n ${have_argc_max-} ]] && ((argc > argc_max)); }; then
    local expectation
    if [[ -n ${have_argc_max-} ]]; then
      if ((argc_min == argc_max)); then
        expectation="exactly ${argc_min}"
      else
        expectation="from ${argc_min} to ${argc_max}"
      fi
    else
      expectation="at least ${argc_min}"
    fi

    err "${caller}: got ${argc} argument(s); expected ${expectation}"
    err "usage: ${caller} $*"

    return 64 # EX_USAGE
  fi
}

# mapfile wrapper that (unlike "mapfile -t somevar < <(some command)") bubbles
# up the exit status of the command used to generate the output read into the
# mapfile'd variable.
mapfile_from_command() {
  usage_for 2 - "$#" '<varname> <command> [<arg> ...]' || return

  local -a passthru

  while (("$#" > 0)); do
    case "$1" in
      -d | -n | -O | -s | -u | -C | -c)
        passthru+=("$1" "$2")
        shift
        ;;
      -t)
        passthru+=("$1")
        ;;
      --)
        shift
        break
        ;;
      *)
        break
        ;;
    esac

    shift
  done

  var="$1"
  shift

  local out
  out="$("$@")" || return

  # "printf" rather than herestring ("<<<"); avoids introducing a newline
  mapfile "${passthru[@]}" "$var" < <(printf -- '%s' "$out")
}

# Work around this:
#
#   $ IFS=$'.' read -r -a octets <<<192.168.1.1. # note trailing "."
#   $ echo "${#octets[@]}"
#   4
#   $ echo "${octets[-1]}"
#   1
#   $ mapfile -d $'.' -t octets < <(printf -- '192.168.2.1.')
#   $ echo "${#octets[@]}"
#   4
#   $ echo "${octets[-1]}"
#   1
#
# This function is like "read -r -a" or "mapfile -t", except that it adds an
# empty string in the final spot in the generated array if the source string
# ends with the separator sequence.
#
# NOTE: uses "declare -n", so requires Bash >= 4.3
split_on_separator_into() {
  usage_for 3 3 "$#" '<separator> <varname> <string>' || return

  local sep="$1"
  shift

  local -n rvar="$1"
  shift

  # "printf" rather than herestring ("<<<"); avoids introducing a newline.
  # Cannot count on "mapfile -d", which was released in the relatively-recent
  # Bash 5.0, so use a workaround that handles only a single line of input.
  IFS="$sep" read -r -a rvar < <(printf -- '%s' "$1") || :

  if [[ $1 == *"$sep" ]]; then
    rvar+=('')
  fi
}

# Print the supplied arguments as a string joined with the specified separator
print_with_separator() {
  usage_for 1 - "$#" '<separator> [<arg> <arg> ...]' || return

  local sep="$1"
  shift

  printf -- '%s' "$1"
  shift

  if (("$#" < 1)); then
    return
  fi

  printf -- "${sep}%s" "$@"
}

# Like "print_with_separator", but adds a final newline
puts_with_separator() {
  usage_for 1 - "$#" '<separator> [<arg> <arg> ...]' || return
  print_with_separator "$@" || return
  printf -- '\n'
}

with_openvpn_script_handling() {
  if (("$#" == 0)); then
    usage 'No script type specified'
    return 1
  fi

  local func="$1"
  shift || :

  local dev="${1:-${dev-}}"
  shift || :

  if [[ -z ${dev-} ]]; then
    usage 'No device name specified'
    return 1
  fi

  if ! read -r link if_index _ < <(get_link_info "$dev"); then
    usage "Invalid device name: '$dev'"
    return 1
  fi

  busctl_status &> /dev/null || {
    local -i status="$?"
    err << ERR
systemd-resolved DBus interface (${DBUS_DEST}) is not available.
$SCRIPT_NAME requires systemd version 229 or above.
ERR
    return "$status"
  }

  if ! "$func" "$link" "$if_index" "$@"; then
    err 'Unable to configure systemd-resolved.'
    return 1
  fi
}

_up() {
  local link="$1"
  shift
  local if_index="$1"
  shift

  info "Link '$link' coming up"

  # Preset values for processing -- will be altered in the various process_*
  # functions.
  local -a dns_servers=() dns_ex_servers=() dns_domain=() dns_search=() dns_routed=() dnssec_negative_trust_anchors=()
  local -i dns_server_count=0 dns_ex_server_count=0
  local flush_caches=yes
  local dns_sec reset_statistics reset_server_features default_route
  local llmnr multicast_dns dns_over_tls

  # This function is called indirectly below (via `each_dhcp_setting`); disable
  # check for unreachable commands.
  # shellcheck disable=SC2317
  _dispatch_dhcp_setting() {
    local setting_type="${1?}"
    local setting_value="${2?}"

    process_setting_function="${setting_type,,}"
    process_setting_function="process_${process_setting_function//-/_}"

    if declare -f "$process_setting_function" &> /dev/null; then
      "$process_setting_function" "$setting_value" || return $?
    else
      warning "Not a recognized DHCP setting: '${setting_type}'"
    fi
  }

  each_dhcp_setting _dispatch_dhcp_setting || return

  if [[ ${reset_statistics-} == yes ]]; then
    info "ResetStatistics()"
    busctl_call ResetStatistics || return $?
  fi

  if [[ ${reset_server_features-} == yes ]]; then
    info 'ResetServerFeatures()'
    busctl_call ResetServerFeatures || return $?
  fi

  if [[ -n ${dns_sec+x} ]]; then
    info "SetLinkDNSSEC(${if_index} '${dns_sec}')"
    busctl_call SetLinkDNSSEC 'is' "$if_index" "${dns_sec}" || return
  fi

  if [[ ${#dns_servers[*]} -gt 0 ]]; then
    busctl_params=("$if_index" "$dns_server_count" "${dns_servers[@]}")
    info "SetLinkDNS(${busctl_params[*]})"
    busctl_call SetLinkDNS 'ia(iay)' "${busctl_params[@]}" || return $?
  fi

  if [[ ${#dns_ex_servers[*]} -gt 0 ]]; then
    busctl_params=("$if_index" "$dns_ex_server_count" "${dns_ex_servers[@]}")
    info "SetLinkDNSEx(${busctl_params[*]})"
    busctl_call SetLinkDNSEx 'ia(iayqs)' "${busctl_params[@]}" || return $?
  fi

  # Divide by two to account for the boolean second argument
  dns_count="$(((${#dns_domain[*]} + ${#dns_search[*]} + ${#dns_routed[*]}) / 2))"
  if ((dns_count > 0)); then
    busctl_params=(
      "$if_index"
      "$dns_count"

      # Hack to work around pre-4.4 Bash `empty array == unset` bug
      ${dns_domain:+"${dns_domain[@]}"}
      ${dns_search:+"${dns_search[@]}"}
      ${dns_routed:+"${dns_routed[@]}"}
    )
    info "SetLinkDomains(${busctl_params[*]})"
    busctl_call SetLinkDomains 'ia(sb)' "${busctl_params[@]}" || return $?
  fi

  if [[ -n ${default_route-} ]]; then
    info "SetLinkDefaultRoute(${if_index} ${default_route})"
    busctl_call SetLinkDefaultRoute 'ib' "$if_index" "$default_route" || return $?
  fi

  if [[ -n ${llmnr+x} ]]; then
    info "SetLinkLLMNR(${if_index} '${llmnr}')"
    busctl_call SetLinkLLMNR 'is' "$if_index" "$llmnr"
  fi

  if [[ -n ${multicast_dns+x} ]]; then
    info "SetLinkMulticastDNS(${if_index} '${multicast_dns}')"
    busctl_call SetLinkMulticastDNS 'is' "$if_index" "$multicast_dns"
  fi

  if [[ -n ${dns_over_tls+x} ]]; then
    info "SetLinkDNSOverTLS(${if_index} '${dns_over_tls}')"
    busctl_call SetLinkDNSOverTLS 'is' "$if_index" "$dns_over_tls"
  fi

  if (("${#dnssec_negative_trust_anchors[*]}" > 0)); then
    busctl_params=(
      "$if_index"
      "${#dnssec_negative_trust_anchors[*]}"
      "${dnssec_negative_trust_anchors[@]}"
    )

    info "SetLinkDNSSECNegativeTrustAnchors(${busctl_params[*]})"
    busctl_call SetLinkDNSSECNegativeTrustAnchors ias "${busctl_params[@]}"
  fi

  if [[ -n ${flush_caches-} ]]; then
    info 'FlushCaches()'
    busctl_call FlushCaches || return
  fi
}

up() {
  with_openvpn_script_handling _up "$@"
}

down() {
  with_openvpn_script_handling _down "$@"
}

_down() {
  local link="$1"
  shift
  local if_index="$1"
  shift

  info "Link '$link' going down"

  if ! busctl_call RevertLink i "$if_index"; then
    info 'Calling RevertLink failed; this can happen if privileges were dropped in the OpenVPN client.'
    print_polkit_rules_command_for_current_user | info
  fi
}

# Run sipcalc and extract a single line matching the provided prefix
match_sipcalc_output() {
  usage_for 2 2 "$#" '<sipcalc-output-prefix-match> <address>' || return

  local prefix="$1"
  shift

  local out
  out="$(sipcalc "$@" 2> >(err))" || return

  while read -r line; do
    if [[ $line == "$prefix"* ]]; then
      printf -- '%s\n' "${line##*- }"
      return
    fi
  done <<< "$out"

  return 1
}

# Expand an IPv4 or IPv6 address using Python's "ipaddress" module
expand_ip_python() {
  usage_for 2 2 "$#" '{IPv4,IPv6} <address>' || return

  local type="$1"
  shift

  case "$type" in
    IPv4 | IPv6) ;;
    *)
      err "${FUNCNAME[0]}: not a valid IP version type: ${type}"
      return 64
      ;;
  esac

  python -c "
import ipaddress
import sys

# Abort if we're on an older Python; the backported 'ipaddress' module requires
# IPs to be unicode, and properly decoding sys.argv is problematic on Python 2
# (see https://bugs.python.org/issue2128).
if sys.version_info < (3, 0):
  majmin = '.'.join([str(v) for v in sys.version_info[0:2]])
  sys.stderr.write('${type} address expansion is not supported for Python {0}\\n'.format(majmin))
  sys.exit(1)

try:
  print(ipaddress.${type}Address(sys.argv[1]).exploded)
except Exception as e:
  sys.stderr.write(\"'{0}' is not a valid ${type} address: {1}\\n\".format(sys.argv[1], e))
  sys.exit(1)
" "${1?}" 2> >(err)
}

# Very light check to see if a string looks vaguely in the vicinity of an IPv4
# address; more robust validation occurs in (the course of executing)
# "parse_ipv4".
#
# NOTE that we include the "! looks_like_ipv6" condition in order return a
# nonzero status when provided an IPv4-in-IPv6 address (e.g. "::ffff:1.2.3.4").
# This check comes after the check for dotted-quad so that we can do
#
#  if looks_like_ipv6 "$address"; then
#    process_dns_ipv6 "$address" || return $?
#  elif looks_like_ipv4 "$address"; then
#    process_dns_ipv4 "$address" || return $?
#  else
#
# without repeating work.
looks_like_ipv4() {
  [[ ${1-} =~ ^([^.]+\.){3}[^.]+$ ]] && ! looks_like_ipv6 "${1-}"
}

# Read the components of a dotted-quad IPv4 into the specified array variable
read_ipv4_segments_into() {
  usage_for 2 2 "$#" '<varname> <string>' || return

  split_on_separator_into $'.' "$@"
}

each_ipv4_segment() {
  looks_like_ipv4 "$@" || return

  local -a segments
  read_ipv4_segments_into segments "$@" || return

  ((${#segments[@]} == 4)) || return

  local segment
  for segment in "${segments[@]}"; do
    printf -- '%s\n' "$segment"
  done
}

expand_ipv4_native() {
  local address="$1"

  local -a segments
  mapfile_from_command -t segments each_ipv4_segment "$address" || return

  log_invalid_ipv4() {
    local message="'$address' is not a valid IPv4 address"
    err "${message}: $*"
    unset -f "${FUNCNAME[0]:-log_invalid_ipv4}"
    return 1
  }

  local segment
  local -i decimal_segment

  for segment in "${segments[@]}"; do
    printf -v decimal_segment -- '%d' "$segment" 2> /dev/null || {
      local -i status="$?"
      log_invalid_ipv4 "cannot interpret '${segment}' as a decimal number"
      return "$status"
    }

    if ((decimal_segment < 0)) || ((decimal_segment > 255)); then
      log_invalid_ipv4 "'${segment}' is not a decimal number from 0 to 255, inclusive"
      return 1
    fi
  done

  puts_with_separator $'.' "${segments[@]}"
}

expand_ipv4_sipcalc() {
  match_sipcalc_output 'Host address' "$@"
}

expand_ipv4_python() {
  expand_ip_python IPv4 "$@"
}

parse_ipv4() {
  local expanded
  expanded="$(expand_ipv4 "$@")" || return
  each_ipv4_segment "$expanded"
}

# Very light check to see if a string looks vaguely in the vicinity of an IPv6
# address; more robust validation occurs in (the course of executing)
# "parse_ipv6".
looks_like_ipv6() {
  [[ ${1-} == *:*:* ]]
}

read_ipv6_segments_into() {
  usage_for 2 2 "$#" '<varname> <address>' || return

  split_on_separator_into $':' "$@"
}

each_ipv6_segment() {
  looks_like_ipv6 "$@" || return

  local -a segments
  read_ipv6_segments_into segments "$@" || return

  ((${#segments[@]} == 8)) || return

  local segment
  for segment in "${segments[@]}"; do
    printf -- '%s\n' "$segment"
  done
}

expand_ipv6_native() {
  local orig_address="${1-}"

  log_invalid_ipv6() {
    local message="'$orig_address' is not a valid IPv6 address"
    err "${message}: $*"
    unset -f "${FUNCNAME[0]:-log_invalid_ipv6}"
    return 1
  }

  local -a orig_segments
  read_ipv6_segments_into orig_segments "$orig_address" || {
    local -i status="$?"
    log_invalid_ipv6 'failed to read address segments'
    return "$status"
  }

  if (("${#orig_segments[@]}" < 3)); then
    log_invalid_ipv6 "expected at least 3 address segments; got ${#orig_segments[@]}"
    return 1
  fi

  if looks_like_ipv4 "${orig_segments[-1]-}"; then
    local -a ipv4_segments

    mapfile_from_command -t ipv4_segments parse_ipv4 "${orig_segments[-1]}" || {
      local -i status="$?"
      log_invalid_ipv6 "failed to parse embedded IPv4 address '${orig_segments[-1]}'"
      return "$status"
    }

    printf -v 'orig_segments[-1]' -- '%0.2x%0.2x' "${ipv4_segments[@]:0:2}"
    printf -v "orig_segments[${#orig_segments[@]}]" -- '%0.2x%0.2x' "${ipv4_segments[@]:2:4}"
  fi

  local -i expected_len=8
  local -i orig_len="${#orig_segments[@]}"

  # "expected_len + 1" to account for addresses like "::1:1:1:1:1:1:1"
  if ((orig_len > (expected_len + 1))); then
    log_invalid_ipv6 "at most ${expected_len} colons permitted; got $((orig_len - 1))"
    return 1
  fi

  local -a final_segments
  local final_segment
  local -i orig_idx
  local saw_compressed_group
  local -i zero_segments_needed_count

  for ((orig_idx = 0; orig_idx < orig_len; orig_idx++)); do
    orig_segment="${orig_segments[orig_idx]}"

    if [[ -z $orig_segment ]]; then
      if [[ -n ${saw_compressed_group-} ]]; then
        log_invalid_ipv6 "at most one '::' permitted"
        return 1
      fi

      saw_compressed_group=yes

      if ((orig_idx == 0)); then
        # ::1:2:3:4
        if [[ -z ${orig_segments[$((orig_idx + 1))]-} ]]; then
          zero_segments_needed_count="$(((expected_len - orig_len) + 2))"
          ((orig_idx++))
        # :1:2:3:4
        else
          log_invalid_ipv6 "leading ':' without '::'"
          return 1
        fi
      # 1:2:3:4::
      elif {
        ((orig_idx == (orig_len - 2))) &&
          [[ -z ${orig_segments[$((orig_idx + 1))]-} ]]
      }; then
        zero_segments_needed_count="$(((expected_len - orig_len) + 2))"
        ((orig_idx++))
      # 1:2:3:4:
      elif ((orig_idx == (orig_len - 1))); then
        log_invalid_ipv6 "trailing ':' without '::'"
        return 1
      # 1:2::3:4
      else
        zero_segments_needed_count="$(((expected_len - orig_len) + 1))"
      fi

      if ((zero_segments_needed_count < 1)); then
        log_invalid_ipv6 "cannot expand '::'; address already has 8 or more segments"
        return 1
      fi

      local -i zero_segment_counter
      for ((\
      zero_segment_counter = 0;  \
      zero_segment_counter < zero_segments_needed_count;  \
      zero_segment_counter++)); do
        final_segments+=(0000)
      done
    elif (("${#orig_segment}" > 4)); then
      log_invalid_ipv6 "'$orig_segment' is longer than 4 characters"
      return 1
    else
      printf -v final_segment -- '%0.4x' "0x${orig_segment}" 2> /dev/null || {
        local -i status="$?"
        log_invalid_ipv6 "cannot interpret '${orig_segment}' as a hexadecimal number"
        return "$status"
      }

      final_segments+=("$final_segment")
    fi
  done

  if (("${#final_segments[@]}" != expected_len)); then
    log_invalid_ipv6 "expected ${expected_len} segments; got ${#final_segments[@]}"
    return 1
  fi

  puts_with_separator $':' "${final_segments[@]}"
}

expand_ipv6_sipcalc() {
  match_sipcalc_output 'Expanded Address' "$@"
}

expand_ipv6_python() {
  expand_ip_python IPv6 "$@"
}

test_ipv4_expansion_func() {
  local expanded
  expanded="$("${1?}" 127.0.0.1)" && [[ $expanded == '127.0.0.1' ]]
}

test_ipv6_expansion_func() {
  local expanded
  expanded="$("${1?}" ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff)" &&
    [[ $expanded == ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff ]]
}

each_ip_expansion_func() {
  local cb="$1"
  shift

  local type name

  case "${1,,}" in
    ipv4)
      type="${1,,}"
      name=IPv4
      ;;
    ipv6)
      type="${1,,}"
      name=IPv6
      ;;
    *)
      err "unrecognized IP version type '${1}'"
      return 64
      ;;
  esac

  local expansion_func="expand_${type}"
  local expansion_func_impl
  local impl

  for impl in python sipcalc native; do
    expansion_func_impl="${expansion_func}_${impl}"

    # Run in subshell with `logger` defined as a NOP to avoid issuing useless
    # messages about (say) not being able to find the `python` or `sipcalc`
    # programs.
    # `log` is called indirectly; disable warning about unreachable command.
    # shellcheck disable=SC2317
    if (
      log() { :; }
      "test_${type}_expansion_func" "$expansion_func_impl"
    ); then
      if "$cb" "$expansion_func_impl" 1; then
        return
      fi
    elif "$cb" "$expansion_func_impl" 0; then
      return
    fi
  done
}

set_up_ip_expansion_func() {
  local type name

  case "${1,,}" in
    ipv4)
      type="${1,,}"
      name=IPv4
      ;;
    ipv6)
      type="${1,,}"
      name=IPv6
      ;;
    *)
      err "unrecognized IP version type '${1}'"
      return 64
      ;;
  esac

  local preference_var="UPDATE_SYSTEMD_RESOLVED_PREFERRED_${type^^}_EXPANSION_IMPLEMENTATION"

  local preference
  if [[ -v $preference_var ]]; then
    preference="${!preference_var}"
  fi

  preference="${preference:-${UPDATE_SYSTEMD_RESOLVED_PREFERRED_IP_EXPANSION_IMPLEMENTATION-}}"

  local expansion_func="expand_${type}"

  local expansion_func_impl

  if [[ -n $preference ]]; then
    expansion_func_impl="${expansion_func}_${preference}"

    if declare -f "$expansion_func_impl" &> /dev/null; then
      eval "${expansion_func}() { $expansion_func_impl \"\$@\"; }"
    else
      err "${preference} is not a valid ${name} address expansion implementation"
      exit 1
    fi
  fi

  if ! declare -f "$expansion_func" &> /dev/null; then
    # This function is called indirectly below (via `each_ip_expansion_func`);
    # disable check for unreachable commands.
    # shellcheck disable=SC2317
    choose_expansion_func_impl() {
      expansion_func_impl="$1"

      if (("$2" == 1)); then
        eval "${expansion_func}() { $expansion_func_impl \"\$@\"; }"
      else
        return 1
      fi
    }

    each_ip_expansion_func choose_expansion_func_impl "$type"

    unset -f choose_expansion_func_impl
  fi

  if ! declare -f "$expansion_func" &> /dev/null; then
    err "no usable ${name} expansion implementations"
    return 1
  fi
}

# "builtin exit" because the test suite overrides "exit".  If we cannot handle
# IP addresses, no sense in continuing.
set_up_ip_expansion_func ipv4 || builtin exit
set_up_ip_expansion_func ipv6 || builtin exit

parse_ipv6() {
  local expanded
  expanded="$(expand_ipv6 "$@")" || return
  each_ipv6_segment "$expanded"
}

parse_dns_spec() {
  usage_for 1 - "$#" '<dns-server-spec> [<address-var> <port-var> <server-name-var>]' || return

  local spec="$1"
  shift

  local -n address_ref="${1:-address}"
  shift

  local -n port_ref="${1:-port}"
  shift

  local -n server_name_ref="${1:-server_name}"
  shift

  local cursor="$spec"
  while [[ -n ${cursor-} ]]; do
    case "$cursor" in
      *'#'?*)
        server_name_ref="${cursor#*'#'}"
        cursor="${cursor%%'#'*}"
        ;;
      *:?*)
        if looks_like_ipv6 "$cursor" &> /dev/null; then
          address_ref="$cursor"
          break
        else
          case "$cursor" in
            '['*']'*)
              case "$cursor" in
                '['*']:'?*)
                  address_ref="${cursor#[}"
                  address_ref="${address_ref#]}"
                  port_ref="${cursor#*:}"
                  break
                  ;;
                *)
                  err "invalid DNS server specification '${spec}'"
                  return 1
                  ;;
              esac
              ;;
            *)
              address_ref="${cursor%%:*}"
              port_ref="${cursor#*:}"
              break
              ;;
          esac
        fi
        ;;
      *)
        address_ref="$cursor"
        break
        ;;
    esac
  done

  # Ensure that port variable is defined if server name variable is. The
  # default value is `0`, meaning "default port 53" when passed to
  # `SetLinkDNSEx`.
  #
  # NOTE that we do not do any further input validation here; instead we let
  # `SetLinkDNSEx` complain if the port is anything other than an unsigned
  # integer < 2 ** 16.
  if [[ -n ${server_name_ref-} ]]; then
    port_ref="${port_ref:-0}"
  fi

  # Ensure that server name variable is defined if port variable is.  The
  # default value is the empty string, meaning "no server name" when passed to
  # `SetLinkDNSEx`.
  if [[ -n ${port_ref-} ]]; then
    server_name_ref="${server_name_ref-}"
  fi
}

process_dns() {
  local spec="$1"
  shift

  local address port server_name
  parse_dns_spec "$spec" address port server_name || return

  local -a args=()
  if [[ -n ${port-} ]] || [[ -n ${server_name-} ]]; then
    args=("${port:-0}" "${server_name-}")
  fi

  if looks_like_ipv6 "$address"; then
    process_dns_ipv6 "$address" "${args[@]}" || return
  elif looks_like_ipv4 "$address"; then
    process_dns_ipv4 "$address" "${args[@]}" || return
  else
    err "Not a valid IPv6 or IPv4 address: '${address}' (full specification: '${spec}')"
    return 1
  fi
}

process_dns6() {
  process_dns "$@"
}

process_dns_ipv4() {
  usage_for 1 3 "$#" '<address> [<port> <server-name>]' || return

  local address="$1"
  shift

  info "Adding IPv4 DNS Server ${address}"

  local -a segments
  mapfile_from_command -t segments parse_ipv4 "$address" || return

  if (("$#" > 0)); then
    dns_ex_servers+=(2 4 "${segments[@]}" "${1:-0}" "${2-}")
    ((dns_ex_server_count += 1))
  else
    dns_servers+=(2 4 "${segments[@]}")
    ((dns_server_count += 1))
  fi
}

process_dns_ipv6() {
  usage_for 1 3 "$#" '<address> [<port> <server-name>]' || return

  local address="$1"
  shift

  info "Adding IPv6 DNS Server ${address}"

  local -a segments
  mapfile_from_command -t segments parse_ipv6 "$address" || return

  if (("$#" > 0)); then
    # Add AF_INET6 and byte count
    dns_ex_servers+=(10 16)
    for segment in "${segments[@]}"; do
      dns_ex_servers+=("$((16#${segment:0:2}))" "$((16#${segment:2:2}))")
    done

    dns_ex_servers+=("${1:-0}" "${2-}")

    ((dns_ex_server_count += 1))
  else
    # Add AF_INET6 and byte count
    dns_servers+=(10 16)
    for segment in "${segments[@]}"; do
      dns_servers+=("$((16#${segment:0:2}))" "$((16#${segment:2:2}))")
    done

    ((dns_server_count += 1))
  fi
}

process_domain() {
  local domain="$1"
  shift

  info "Adding DNS Domain ${domain}"

  # Make sure the first domain specified with "dhcp-option DOMAIN <domain>"
  # appears at the head of the list we pass to SetLinkDNS.
  if (("${#dns_domain[*]}" == 0)); then
    dns_domain+=("${domain}" false)
  else
    dns_search+=("${domain}" false)
  fi
}

process_adapter_domain_suffix() {
  # This enables support for ADAPTER_DOMAIN_SUFFIX which is a Microsoft standard
  # which works in the same way as DOMAIN to set the primary search domain on
  # this specific link.
  process_domain "$@"
}

process_domain_search() {
  local domain="$1"
  shift

  info "Adding DNS Search Domain ${domain}"
  dns_search+=("${domain}" false)
}

process_domain_route() {
  local domain="$1"
  shift

  info "Adding DNS Routed Domain ${domain}"
  dns_routed+=("${domain}" true)
}

process_dnssec() {
  case "${1,,}" in
    yes | true)
      dns_sec=yes
      ;;
    no | false)
      dns_sec=no
      ;;
    allow-downgrade)
      dns_sec=allow-downgrade
      ;;
    default)
      dns_sec=""
      ;;
    *)
      err "'$1' is not a valid DNSSEC option"
      return 1
      ;;
  esac

  info "Setting DNSSEC to ${dns_sec:-default}"
}

process_reset_statistics() {
  case "${1,,}" in
    yes | true)
      reset_statistics=yes
      ;;
    no | false)
      reset_statistics=""
      ;;
    *)
      err "'$1' is not a valid value for RESET-STATISTICS"
      return 1
      ;;
  esac
}

process_flush_caches() {
  case "${1,,}" in
    yes | true)
      flush_caches=yes
      ;;
    no | false)
      flush_caches=""
      ;;
    *)
      err "'$1' is not a valid value for FLUSH-CACHES"
      return 1
      ;;
  esac
}

process_reset_server_features() {
  case "${1,,}" in
    yes | true)
      reset_server_features=yes
      ;;
    no | false)
      reset_server_features=""
      ;;
    *)
      err "'$1' is not a valid value for RESET-SERVER-FEATURES"
      return 1
      ;;
  esac
}

process_default_route() {
  case "${1,,}" in
    yes | true)
      default_route=true
      ;;
    no | false)
      default_route=false
      ;;
    *)
      err "'$1' is not a valid value for DEFAULT-ROUTE"
      return 1
      ;;
  esac

  info "Setting DEFAULT-ROUTE to ${default_route}"
}

process_llmnr() {
  case "${1,,}" in
    yes | true)
      llmnr=yes
      ;;
    no | false)
      llmnr=no
      ;;
    resolve)
      llmnr=resolve
      ;;
    default)
      llmnr=""
      ;;
    *)
      err "'$1' is not a valid value for LLMNR"
      return 1
      ;;
  esac

  info "Setting LLMNR to ${llmnr:-default}"
}

process_multicast_dns() {
  case "${1,,}" in
    yes | true)
      multicast_dns=yes
      ;;
    no | false)
      multicast_dns=no
      ;;
    resolve)
      multicast_dns=resolve
      ;;
    default)
      multicast_dns=""
      ;;
    *)
      err "'$1' is not a valid value for MULTICAST-DNS"
      return 1
      ;;
  esac

  info "Setting MULTICAST-DNS to ${multicast_dns:-default}"
}

process_dns_over_tls() {
  case "${1,,}" in
    yes | true)
      dns_over_tls=yes
      ;;
    no | false)
      dns_over_tls=no
      ;;
    opportunistic)
      dns_over_tls=opportunistic
      ;;
    default)
      dns_over_tls=""
      ;;
    *)
      err "'$1' is not a valid value for DNS-OVER-TLS"
      return 1
      ;;
  esac

  info "Setting DNS-OVER-TLS to ${dns_over_tls:-default}"
}

process_dnssec_negative_trust_anchors() {
  local domain="$1"
  shift

  info "Adding DNSSEC negative trust anchor ${domain}"
  dnssec_negative_trust_anchors+=("$domain")
}

to_json_array_jq() {
  jq --compact-output --null-input '$ARGS.positional' --args -- "$@"
}

to_json_array_perl() {
  perl -MModule::Load -wle '
foreach my $mod ( qw(Cpanel::JSON::XS JSON::MaybeXS JSON::XS JSON::PP JSON) ) {
  if ( eval { load $mod; $mod->import(qw(encode_json)); 1 } ) {
    print encode_json(\@ARGV);
    last;
  }
}
  ' -- "$@"
}

to_json_array_python() {
  python -c "
import sys

try:
  import json
except ImportError:
  import simplejson as json

print(json.dumps(sys.argv[1:]))
" "$@"
}

to_json_array_native() {
  printf -- '['

  while (("$#" > 0)); do
    printf -- '"%s"' "${1//\"/\\\"}"
    shift
    if (("$#" > 0)); then
      printf -- ','
    fi
  done

  printf -- ']'
}

test_to_json_array_func() {
  local expanded
  expanded="$("${1?}" foo bar baz)" && [[ $expanded =~ ^\['"foo",'[[:space:]]*'"bar",'[[:space:]]*'"baz"'\]$ ]]
}

set_up_to_json_array_func() {
  local expansion_func_impl
  local impl

  for impl in jq perl python native; do
    expansion_func_impl="to_json_array_${impl}"
    if test_to_json_array_func "$expansion_func_impl" 2> /dev/null; then
      eval "to_json_array() { $expansion_func_impl \"\$@\"; }"
      return
    fi
  done

  return 1
}

if ! set_up_to_json_array_func; then
  to_json_array_func() {
    printf -- 'Unable to serialize arguments to a JSON array'
    return 127
  }
fi

require_optarg() {
  local opt="$1"
  shift

  local argc="$1"
  shift

  if ((argc < 2)); then
    err "missing required argument for option \"$opt\""
    return 1
  fi
}

# shellcheck disable=SC2120
print_polkit_rules() {
  local -A allowed_users_map=() allowed_groups_map=() systemd_openvpn_units_map=()

  while (("$#" > 0)); do
    case "$1" in
      --polkit-allowed-user)
        require_optarg "$1" "$#" || return
        allowed_users_map["${2?}"]=1
        shift
        ;;
      --polkit-allowed-user=?*)
        allowed_users_map["${1#*=}"]=1
        ;;
      --polkit-allowed-group)
        require_optarg "$1" "$#" || return
        allowed_groups_map["${2?}"]=1
        shift
        ;;
      --polkit-allowed-group=?*)
        allowed_groups_map["${1#*=}"]=1
        ;;
      --polkit-systemd-openvpn-unit)
        require_optarg "$1" "$#" || return
        systemd_openvpn_units_map["${2?}"]=1
        shift
        ;;
      --polkit-systemd-openvpn-unit=?*)
        systemd_openvpn_units_map["${1#*=}"]=1
        ;;
      *)
        err "unrecognized option: $1"
        return 1
        ;;
    esac

    shift
  done

  if {
    (("${#systemd_openvpn_units_map[@]}" < 1)) &&
      (("${#allowed_users_map[@]}" < 1)) &&
      (("${#allowed_groups_map[@]}" < 1))
  }; then
    # NOTE that we cannot use the template unit "openvpn-client@.service"
    # itself:
    #
    #   $ systemctl show -p User openvpn-client@.service
    #   Failed to get properties: Unit name openvpn-client@.service is neither a valid invocation ID nor unit name.
    #   $ systemctl show -p User openvpn-client@utterly-bogus.service
    #   User=openvpn
    #
    systemd_openvpn_units_map["openvpn-client@totally-made-up-to-avoid-collisions-${RANDOM:-12345}.service"]=1
  fi

  local allowed_user
  while read -r allowed_user; do
    if [[ -n ${allowed_user-} ]]; then
      allowed_users_map["$allowed_user"]=1
    fi
  done < <(systemctl show -P User "${!systemd_openvpn_units_map[@]}" 2> /dev/null)

  if ((${#allowed_users_map[@]} < 1)); then
    warning 'unable to determine the value(s) of "User=..." for OpenVPN client systemd units; assuming "root".'
    allowed_users_map[root]=1
  fi

  local allowed_group
  while read -r allowed_group; do
    if [[ -n ${allowed_group-} ]]; then
      allowed_groups_map["$allowed_group"]=1
    fi
  done < <(systemctl show -P Group "${!systemd_openvpn_units_map[@]}" 2> /dev/null)

  if ((${#allowed_groups_map[@]} < 1)); then
    err 'unable to determine the value(s) of "Group=..." for OpenVPN client systemd units; assuming "root".'
    allowed_groups_map[root]=1
  fi

  local allowed_users allowed_groups
  allowed_users="$(to_json_array "${!allowed_users_map[@]}")" || return
  allowed_groups="$(to_json_array "${!allowed_groups_map[@]}")" || return

  printf -- \
    '/*
 * Allow OpenVPN client services to update systemd-resolved settings.
 * Added by %s.
 */

function listToBoolMap(list) {
  var result = {};

  for (var i = 0; i < list.length; i++) {
    var item = list[i];
    result[item] = true;
  }

  return result;
}

const updateSystemdResolved = {
  allowedUsers: listToBoolMap(%s),

  allowedGroups: %s,

  allowedSubactions: listToBoolMap([
    "set-dns-servers",
    "set-domains",
    "set-default-route",
    "set-llmnr",
    "set-mdns",
    "set-dns-over-tls",
    "set-dnssec",
    "set-dnssec-negative-trust-anchors",
    "revert"
  ]),

  actionIsAllowed: function(action) {
    if ( !action.id.startsWith("org.freedesktop.resolve1.") ) {
      return false;
    }

    var ns = action.id.split(".");
    var subaction = ns[ns.length - 1];

    return this.allowedSubactions[subaction];
  },

  subjectIsAllowed: function(subject) {
    if ( this.allowedUsers[subject.user] ) {
      return true;
    }

    return this.allowedGroups.some(function(group) {
      subject.isInGroup(group);
    });
  },

  isAllowed: function(action, subject) {
    return this.actionIsAllowed(action) && this.subjectIsAllowed(subject);
  }
};

polkit.addRule(function(action, subject) {
  if ( updateSystemdResolved.isAllowed(action, subject) ) {
    return polkit.Result.YES;
  } else {
    return polkit.Result.NOT_HANDLED;
  }
});
' "$SCRIPT_NAME" "$allowed_users" "$allowed_groups"
}

print_polkit_rules_command_for_current_user() {
  local current_user current_group

  local format='You may wish to add the output of the following command'
  format+=' to your polkit rules in order to authorize your user to access'
  format+=' the systemd-resolved DBus interface:'
  format+='\n%q print-polkit-rules'

  local -a args=("$SCRIPT_NAME")

  if current_user="$(id -u -n 2> /dev/null)" && [[ -n ${current_user-} ]]; then
    format+=' --polkit-allowed-user %q'
    args+=("$current_user")
  fi

  if current_group="$(id -g -n 2> /dev/null)" && [[ -n ${current_group-} ]]; then
    format+=' --polkit-allowed-group %q'
    args+=("$current_group")
  fi

  format+='\nPlease see %s for additional details on configuring polkit.\n'
  args+=('https://github.com/tomeon/update-systemd-resolved/tree/polkit-rules-definition#policykit-rules')

  # shellcheck disable=SC2059
  printf -- "$format" "${args[@]}"
}

main() {
  local action
  while (("$#" > 0)); do
    case "$1" in
      up | down | print-polkit-rules)
        action="$1"
        ;;
      --)
        shift
        break
        ;;
      *)
        break
        ;;
    esac

    shift
  done

  action="${action:-${script_type:-down}}"
  action="${action//-/_}"

  if ! declare -f "${action}" &> /dev/null; then
    usage "Invalid script type: '${action}'"
    return 1
  fi

  "$action" "$@"
}

if [[ ${BASH_SOURCE[0]} == "$0" ]] || [[ ${AUTOMATED_TESTING-} == 1 ]]; then
  set -o nounset

  main "$@"
fi

Read also

Differences Between X86 and ARM Computer Processors
How to Become Fit in One Month
How to Deal with the Anxious-Avoidant Trap
Notable Historic Figures in South America
Things to Look Out for When Buying a Second Hand Car
High Hopes Unfulfilled: A Review of the Libratone Zipp 2
Comments
Tags