#!/usr/bin/env bash # # OpenVPN helper to add DHCP information into systemd-resolved via DBus. # Copyright (C) 2016, Jonathan Wright # # 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 . # 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 []." } 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 - "$#" ' [ ...]' || 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 - "$#" ' [ ...]' || 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 "$#" ' ' || 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 - "$#" ' [ ...]' || 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 - "$#" ' [ ...]' || 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 "$#" '
' || 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}
' || 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 "$#" ' ' || 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 "$#" '
' || 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 - "$#" ' [ ]' || 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 "$#" '
[ ]' || 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 "$#" '
[ ]' || 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 " # 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