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