#!/bin/sh
# 90-pdns-upstreams — NetworkManager dispatcher that keeps pdns forwarding to
# the DNS servers of whatever network (or VPN) is currently active.
#
# The .deb installs a copy to
# /etc/NetworkManager/dispatcher.d/90-pdns-upstreams (NetworkManager runs every
# executable there on interface events; the file must be root-owned and not
# group/other-writable). To install by hand:
#
#   sudo install -Dm0755 contrib/90-pdns-upstreams.sh \
#       /etc/NetworkManager/dispatcher.d/90-pdns-upstreams
#
# On a connectivity change it collects the DNS servers of the active devices,
# filters loopback/duplicates/empties, writes them to PDNS_UPSTREAMS_FILE
# (default /run/pdns/upstreams, one server per line) and asks pdns to re-read
# the file with `systemctl reload pdns` (SIGHUP). pdns keeps its listeners and
# response cache; only the upstream forwarder is swapped.
#
# It also writes PDNS_FORWARD_ZONES_FILE (default /run/pdns/forward-zones) with
# '<zone> <server>' lines derived from each device's search/domain and its DNS
# servers, so a VPN's split-horizon domains are resolved by the VPN while
# everything else keeps using the default upstreams.
#
# It also keeps /etc/resolv.conf pointed at pdns. Unless
# PDNS_MANAGE_RESOLV_CONF=0 it atomically rewrites it as:
#
#   # Managed by pdns (90-pdns-upstreams); do not edit
#   nameserver 127.0.0.1
#   search <domains...>          # omitted when no domain was discovered
#
# so the active connection's search domains are honoured and no other nameserver
# competes with pdns. The `configure` event is accepted as well so the
# maintainer scripts can seed the resolver on install.
#
# The upstreams file is never truncated to empty: if no usable server is found
# the previous file is left untouched, so a transient event cannot break DNS.
#
# Environment:
#   PDNS_UPSTREAMS_FILE     output path (default /run/pdns/upstreams); set the
#                           same value in /etc/pdns/pdns.env for the daemon.
#   PDNS_FORWARD_ZONES_FILE conditional-forwarding output path (default
#                           /run/pdns/forward-zones); set the same value in
#                           /etc/pdns/pdns.env for the daemon.
#   PDNS_MANAGE_RESOLV_CONF 1 (default) to manage /etc/resolv.conf, 0 to leave
#                           it alone.
#   PDNS_RESOLV_CONF        resolv.conf path (default /etc/resolv.conf);
#                           override for testing.
#   PDNS_DISPATCHER_LOG     log file for errors (default /dev/null: silent).
#
# The daemon only ever sees nameservers here; search domains are recorded as
# '#' comments for diagnostics and ignored by the pdns file parser.

set -u
set -f

UPSTREAMS_FILE="${PDNS_UPSTREAMS_FILE:-/run/pdns/upstreams}"
FORWARD_ZONES_FILE="${PDNS_FORWARD_ZONES_FILE:-/run/pdns/forward-zones}"
RESOLV_CONF="${PDNS_RESOLV_CONF:-/etc/resolv.conf}"
MANAGE_RESOLV_CONF="${PDNS_MANAGE_RESOLV_CONF:-1}"
LOG="${PDNS_DISPATCHER_LOG:-/dev/null}"
MAX_SEARCH=6

log() {
    [ "$LOG" = "/dev/null" ] || printf '%s: %s\n' "pdns-upstreams" "$*" >>"$LOG" 2>/dev/null
}

IFACE="${1:-}"
EVENT="${2:-}"

case "$EVENT" in
    configure|up|down|vpn-up|vpn-down|dhcp4-change|dhcp6-change|connectivity-change) ;;
    *) exit 0 ;;
esac

command -v nmcli >/dev/null 2>&1 || { log "nmcli not found"; exit 0; }
command -v systemctl >/dev/null 2>&1 || { log "systemctl not found"; exit 0; }

# Values of a multi-valued field are joined with '|' in nmcli's terse output.
servers=$(nmcli -g IP4.DNS,IP6.DNS device show 2>/dev/null | tr '|' '\n')
# SEARCHES holds the full search list (e.g. VPN split-DNS domains), while DOMAIN
# is only the single primary domain; collect both and let the dedupe below sort
# out the overlap.
domains=$(nmcli -g IP4.SEARCHES,IP6.SEARCHES,IP4.DOMAIN,IP6.DOMAIN device show 2>/dev/null |
    tr '|' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')

# Collect the search domains: trimmed (above), valid, unique, capped at
# MAX_SEARCH (glibc's default MAXDNSRCH).
search=""
seen_domains=""
nsearch=0
# shellcheck disable=SC2086 # deliberate word splitting on whitespace
for d in $domains; do
    [ -n "$d" ] || continue
    case "$d" in
        *[!A-Za-z0-9._-]*) continue ;;
    esac
    dup=0
    # shellcheck disable=SC2086 # deliberate word splitting on whitespace
    for p in $seen_domains; do
        if [ "$p" = "$d" ]; then
            dup=1
            break
        fi
    done
    [ "$dup" -eq 1 ] && continue
    seen_domains="$seen_domains $d"
    if [ -z "$search" ]; then
        search="$d"
    else
        search="$search $d"
    fi
    nsearch=$((nsearch + 1))
    [ "$nsearch" -ge "$MAX_SEARCH" ] && break
done

# Optionally take over /etc/resolv.conf, atomically. Done even when no usable
# upstream was found, so the resolver still points at pdns.
if [ "$MANAGE_RESOLV_CONF" != "0" ]; then
    rdir=$(dirname "$RESOLV_CONF")
    mkdir -p "$rdir" 2>/dev/null || true
    rtmp=$(mktemp "${RESOLV_CONF}.XXXXXX" 2>/dev/null)
    if [ -n "$rtmp" ]; then
        chmod 0644 "$rtmp" 2>/dev/null
        {
            printf '# Managed by pdns (90-pdns-upstreams); do not edit\n'
            printf 'nameserver 127.0.0.1\n'
            [ -z "$search" ] || printf 'search %s\n' "$search"
        } >"$rtmp"
        if mv -f "$rtmp" "$RESOLV_CONF"; then
            log "updated $RESOLV_CONF"
        else
            rm -f "$rtmp"
            log "installing $RESOLV_CONF failed"
        fi
    else
        log "mktemp failed for $RESOLV_CONF"
    fi
fi

dir=$(dirname "$UPSTREAMS_FILE")
mkdir -p "$dir" 2>/dev/null

tmp=$(mktemp "${UPSTREAMS_FILE}.XXXXXX" 2>/dev/null) || { log "mktemp failed in $dir"; exit 1; }
chmod 0644 "$tmp" 2>/dev/null

# shellcheck disable=SC2086 # deliberate word splitting on whitespace
for d in $search; do
    printf '# search %s\n' "$d" >>"$tmp"
done

count=0
seen=""
# shellcheck disable=SC2086 # deliberate word splitting on whitespace
for s in $servers; do
    [ -n "$s" ] || continue
    # IPv4 loopback (127/8) and IPv6 loopback, with or without a port.
    case "$s" in
        127.*|::1|\[::1\]|::1:53|\[::1\]:53) continue ;;
    esac
    dup=0
    # shellcheck disable=SC2086 # deliberate word splitting on whitespace
    for p in $seen; do
        if [ "$p" = "$s" ]; then
            dup=1
            break
        fi
    done
    [ "$dup" -eq 1 ] && continue
    seen="$seen $s"
    printf '%s\n' "$s" >>"$tmp"
    count=$((count + 1))
done

if [ "$count" -eq 0 ]; then
    rm -f "$tmp"
    log "no usable DNS servers (event '$EVENT' on '$IFACE'); keeping $UPSTREAMS_FILE"
    exit 0
fi

if ! mv -f "$tmp" "$UPSTREAMS_FILE"; then
    rm -f "$tmp"
    log "installing $UPSTREAMS_FILE failed"
    exit 1
fi

# Conditional (split) forwarding: a VPN may publish internal answers for its own
# domains while the public resolvers return the public ones, so a flat upstream
# list cannot express it. Route each device's search/domain to that device's own
# DNS servers and leave the default upstreams for everything else. Written as
# '<zone> <server>' lines; an empty set is valid and clears stale zones.
fz_dir=$(dirname "$FORWARD_ZONES_FILE")
mkdir -p "$fz_dir" 2>/dev/null
fz_tmp=$(mktemp "${FORWARD_ZONES_FILE}.XXXXXX" 2>/dev/null)
if [ -n "$fz_tmp" ]; then
    chmod 0644 "$fz_tmp" 2>/dev/null
    seen_pairs=""
    for dev in $(nmcli -g GENERAL.DEVICE device show 2>/dev/null); do
        [ -n "$dev" ] || continue
        # Gadget output is printed in field order, one line per field, with
        # multivalued fields joined by ' | ': DNS on lines 1 and 4, search
        # domains / DOMAIN on lines 2, 3, 5 and 6.
        vals=$(nmcli -g 'IP4.DNS,IP4.SEARCHES,IP4.DOMAIN,IP6.DNS,IP6.SEARCHES,IP6.DOMAIN' device show "$dev" 2>/dev/null)
        dev_dns=$(printf '%s\n' "$vals" | sed -n '1p;4p' | tr '|' '\n')
        dev_zones=$(printf '%s\n' "$vals" | sed -n '2p;3p;5p;6p' | tr '|' '\n')
        # shellcheck disable=SC2086 # deliberate word splitting on whitespace
        for zone in $dev_zones; do
            [ -n "$zone" ] || continue
            zone=$(printf '%s' "$zone" | tr 'A-Z' 'a-z')
            case "$zone" in
                *[!a-z0-9._-]*) continue ;;
                *.) ;;
                *) zone="$zone." ;;
            esac
            [ "$zone" = "." ] && continue
            # shellcheck disable=SC2086 # deliberate word splitting on whitespace
            for srv in $dev_dns; do
                [ -n "$srv" ] || continue
                case "$srv" in
                    127.*|::1|\[::1\]|::1:53|\[::1\]:53) continue ;;
                esac
                key="$zone|$srv"
                dup=0
                # shellcheck disable=SC2086 # deliberate word splitting on whitespace
                for p in $seen_pairs; do
                    if [ "$p" = "$key" ]; then dup=1; break; fi
                done
                [ "$dup" -eq 1 ] && continue
                seen_pairs="$seen_pairs $key"
                printf '%s %s\n' "$zone" "$srv" >>"$fz_tmp"
            done
        done
    done
    if mv -f "$fz_tmp" "$FORWARD_ZONES_FILE"; then
        log "updated $FORWARD_ZONES_FILE"
    else
        rm -f "$fz_tmp"
        log "installing $FORWARD_ZONES_FILE failed"
    fi
fi

systemctl reload pdns >/dev/null 2>&1 || log "systemctl reload pdns failed"
exit 0
