]> git.99rst.org Git - openwrt-packages.git/commitdiff
pbr: update to 1.2.2-18
authorStan Grishin <redacted>
Wed, 1 Jul 2026 18:13:57 +0000 (18:13 +0000)
committerStan Grishin <redacted>
Wed, 1 Jul 2026 20:07:00 +0000 (13:07 -0700)
Maintainer: me
Compile tested: x86_64, Dell EMC Edge620, OpenWrt 25.12.4
Run tested: x86_64, Dell EMC Edge620, OpenWrt 25.12.4

Description:
Update to version 1.2.2-18

  - Update PKG_RELEASE to 18.

files/etc/init.d/pbr:
  - Update package compatibility level to 26.
  - Improve IPv4 gateway detection for various scenarios (e.g., netifd,
    point-to-point links).
  - Add pbr_get_ipaddr4 function to retrieve an interface's IPv4 address.
  - Improve IPv6 gateway detection, including link-local router discovery
    and point-to-point link handling.
  - Add pbr_get_ipaddr6 function to retrieve an interface's IPv6 address.
  - Introduce `is_punycode` helper for domain validation, recognizing
    internationalized domain names.
  - Expand `is_domain` to include `is_punycode` for comprehensive domain
    matching.
  - Add `warningInterfaceRoutingUnknownGateway` text for improved error
    messages.
  - Enhance `nftset` dnsmasq element addition logic to properly handle
    existing entries and append new specifications for dual-stack.
  - Flush IPv4 and IPv6 routes from custom tables during cleanup to ensure
    proper state reset.
  - Optimize `dns_policy_process` to only record the first IPv4 and IPv6
    DNS servers to avoid issues with multi-value `src_addr` in family
    mismatch checks.
  - Refine IPv4 routing (`interface_routing`) for strict enforcement and
    point-to-point links.
  - Refine IPv6 routing (`interface_routing`) for strict enforcement and
    point-to-point links.
  - Adjust display of gateway information (`dispGw4`, `dispGw6`) to show
    IP address if no gateway is found.
  - Ensure `json_add_gateway` uses `dispGw4` and `dispGw6` for consistent
    output.
  - Move `process_interface` calls for `reset_globals` and
    `enumerate_interface` earlier in `start_service` for consistent
    trigger registration.
  - Add logic to skip IPv6 interface reload if the gateway is unchanged to
    prevent unnecessary restarts.
  - Standardize `service_triggers` to always register all triggers,
    ensuring robust recovery from WAN-down states.

Signed-off-by: Stan Grishin <redacted>
net/pbr/Makefile
net/pbr/files/etc/init.d/pbr
net/pbr/tests/01_validation/03_domain_validation
net/pbr/tests/06_network/01_gateway_discovery
net/pbr/tests/08_dns/01_nftset_element [new file with mode: 0755]

index 9f534ddf5caa9c9542889bf3a3ca831ab0429955..e4ef8328eaaacb7faa084baba7d5bd8f41efbea5 100644 (file)
@@ -5,7 +5,7 @@ include $(TOPDIR)/rules.mk
 
 PKG_NAME:=pbr
 PKG_VERSION:=1.2.2
-PKG_RELEASE:=14
+PKG_RELEASE:=18
 PKG_LICENSE:=AGPL-3.0-or-later
 PKG_MAINTAINER:=Stan Grishin <stangri@melmac.ca>
 
index 240ee15aa08511cc048e7805ff274cfcc1ba2f54..fd03a2f4ba3e02a4ec5526456d2b1109853545bd 100755 (executable)
@@ -37,7 +37,7 @@ fi
 
 readonly packageName='pbr'
 readonly PKG_VERSION='dev-test'
-readonly packageCompat='25'
+readonly packageCompat='26'
 readonly serviceName="$packageName $PKG_VERSION"
 readonly packageConfigFile="/etc/config/${packageName}"
 readonly packageDebugFile="/var/run/${packageName}.debug"
@@ -259,22 +259,55 @@ pbr_find_iface() {
 }
 pbr_get_gateway4() {
        local iface="$2" dev="$3" gw
+       is_uplink6 "$iface" && iface="$uplink_interface4"
        network_get_gateway gw "$iface" true
        if [ -z "$gw" ] || [ "$gw" = '0.0.0.0' ]; then
-#              gw="$(ubus call "network.interface.${iface}" status | jsonfilter -e "@.route[0].nexthop")"
-               gw="$(ip -4 a list dev "$dev" 2>/dev/null | grep inet | awk '{print $2}' | awk -F "/" '{print $1}')"
+               gw="$(ip -4 route show dev "$dev" table all 2>/dev/null | awk '$1=="default" {for (i=1; i<NF; i++) if ($i=="via") {print $(i+1); exit}}')"    # use table all in case of netifd where default routes are not present in main table
+               # Fall back in case interfaces do not have a default route
+               [ -z "$gw" ] && gw="$(ip -4 route show dev "$dev" table all 2>/dev/null | awk '{for (i=1; i<=NF; i++) if ($i=="via") {print $(i+1); exit}}')"
+               # Fall back to ip route get using "table all" might already work
+               [ -z "$gw" ] && gw="$(ip -4 route get 1.1.1.1 oif "$dev" 2>/dev/null | awk '/via/ {print $3; exit}')"
+               # Raise warning if no gw and not point-to-point
+               { [ -z "$gw" ] && ! ip address show dev "$dev" 2>/dev/null | grep -q "POINTOPOINT"; } && json add warning 'warningInterfaceRoutingUnknownGateway' "$dev"
        fi
        eval "$1"='$gw'
 }
+pbr_get_ipaddr4() {
+       local iface="$2" dev="$3" ipa
+       is_uplink6 "$iface" && iface="$uplink_interface4"
+       network_get_ipaddr ipa "$iface"
+       if [ -z "$ipa" ] || [ "$ipa" = '0.0.0.0' ]; then
+               [ -n "$dev" ] && ipa="$(ip -4 -o addr show dev "$dev" 2>/dev/null | awk '{print $4; exit}' | cut -d/ -f1)"
+       fi
+       eval "$1"='$ipa'
+}
 pbr_get_gateway6() {
+       [ -z "$ipv6_enabled" ] && return 0
        local iface="$2" dev="$3" gw
        is_uplink4 "$iface" && iface="$uplink_interface6"
        network_get_gateway6 gw "$iface" true
        if [ -z "$gw" ] || [ "$gw" = '::/0' ] || [ "$gw" = '::0/0' ] || [ "$gw" = '::' ]; then
-               gw="$(ip -6 a list dev "$dev" 2>/dev/null | grep inet6 | grep 'scope global' | awk '{print $2}')"
+               gw="$(ip -6 route show dev "$dev" table all 2>/dev/null | awk '$1=="default" {for (i=1; i<NF; i++) if ($i=="via") {print $(i+1); exit}}')"    # use table all in case of netifd where default routes are not present in main table
+               # Fall back in case interfaces do not have a default route
+               [ -z "$gw" ] && gw="$(ip -6 route show dev "$dev" table all 2>/dev/null | awk '{for (i=1; i<=NF; i++) if ($i=="via") {print $(i+1); exit}}')"
+               # Fall back to using ip -6 get route is not working as source routing is used which needs "from"
+               # Fall back to a link-local neighbor advertised as router.
+               [ -z "$gw" ] && gw="$(ip -6 neigh show dev "$dev" 2>/dev/null | awk '/^fe80:.*router/ {print $1; exit}')"
+               # Raise warning if no gw and not point-to-point link
+               { [ -z "$gw" ] && ! ip address show dev "$dev" 2>/dev/null | grep -q "POINTOPOINT"; } && json add warning 'warningInterfaceRoutingUnknownGateway' "$dev"
        fi
        eval "$1"='$gw'
 }
+pbr_get_ipaddr6() {
+       [ -z "$ipv6_enabled" ] && return 0
+       local iface="$2" dev="$3" ipa
+       is_uplink4 "$iface" && iface="$uplink_interface6"
+       network_get_ipaddr6 ipa "$iface"
+       if [ -z "$ipa" ] || [ "$ipa" = '::/0' ] || [ "$ipa" = '::0/0' ] || [ "$ipa" = '::' ]; then
+               [ -n "$dev" ] && ipa="$(ip -6 -o addr show dev "$dev" scope global 2>/dev/null | awk '{print $4; exit}' | cut -d/ -f1)"
+       fi
+       eval "$1"='$ipa'
+}
 filter_options() {
        local opt="$1" values="$2" v _ret
        for v in $values; do
@@ -317,7 +350,8 @@ is_netifd_interface_default() {
 is_disabled_interface() { [ "$(uci_get 'network' "$1" 'disabled')" = '1' ]; }
 is_host() { echo "$1" | grep -qE '^[a-zA-Z0-9][a-zA-Z0-9_-]{0,61}[a-zA-Z0-9]$|^[a-zA-Z0-9]$'; }
 is_hostname() { echo "$1" | grep -qE '^([a-zA-Z0-9]([a-zA-Z0-9_-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$'; }
-is_domain() { ! is_ipv4 "$1" && ! is_mac_address_bad_notation "$1" && { is_host "$1" || is_hostname "$1"; }; }
+is_punycode() { echo "$1" | grep -qE '^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$' && echo "$1" | grep -qE '(^|\.)xn--'; }
+is_domain() { ! is_ipv4 "$1" && ! is_mac_address_bad_notation "$1" && { is_host "$1" || is_hostname "$1" || is_punycode "$1"; }; }
 is_dslite() { local p; network_get_protocol p "$1"; [ "${p:0:6}" = "dslite" ]; }
 is_family_mismatch() { ( is_ipv4 "${1//!}" && is_ipv6 "${2//!}" ) || ( is_ipv6 "${1//!}" && is_ipv4 "${2//!}" ); }
 is_greater() { test "$(printf '%s\n' "$@" | sort -V | head -n 1)" != "$1"; }
@@ -649,6 +683,7 @@ get_text() {
                errorNetifdMissingOption) printf "Netifd setup: required option '%s' is missing" "$1";;
                errorNetifdInvalidGateway4) printf "Netifd setup: invalid value of netifd_interface_default option '%s'" "$1";;
                errorNetifdInvalidGateway6) printf "Netifd setup: invalid value of netifd_interface_default6 option '%s'" "$1";;
+               warningInterfaceRoutingUnknownGateway) printf "Unknown Gateway for device '%s'" "$1";;
                warningInvalidOVPNConfig) printf "Invalid OpenVPN config for '%s' interface" "$1";;
                warningResolverNotSupported) printf "Resolver set (%s) is not supported on this system" "$resolver_set";;
                warningPolicyProcessCMD) printf "'%s'" "$1";;
@@ -1097,8 +1132,26 @@ nftset() {
                ;;
                add_dnsmasq_element)
                        [ -n "$ipv6_enabled" ] || unset nftset6
-                       grep -qxF "nftset=/${param}/4#inet#${nftTable}#${nftset4}${nftset6:+,6#inet#${nftTable}#${nftset6}} # $comment" "$packageDnsmasqFile" && return 0
-                       echo "nftset=/${param}/4#inet#${nftTable}#${nftset4}${nftset6:+,6#inet#${nftTable}#${nftset6}} # $comment" >> "$packageDnsmasqFile" && ipv4_error=0
+                       local new_spec="4#inet#${nftTable}#${nftset4}${nftset6:+,6#inet#${nftTable}#${nftset6}}"
+                       local escaped_param
+                       escaped_param=$(printf '%s' "$param" | sed 's/\./\\./g')
+                       local safe_nftset4 safe_nftset6
+                       safe_nftset4=$(printf '%s' "$nftset4" | sed 's|[/.^$*\[\\]|\\&|g')
+                       safe_nftset6=$(printf '%s' "${nftset6:-}" | sed 's|[/.^$*\[\\]|\\&|g')
+                       grep -qE "^nftset=/${escaped_param}/.*#${safe_nftset4}(,| |$)" "$packageDnsmasqFile" && return 0
+                       if grep -q "^nftset=/${escaped_param}/" "$packageDnsmasqFile"; then
+                               local append_spec="$new_spec"
+                               if [ -n "$nftset6" ] && grep -qE "^nftset=/${escaped_param}/.*#${safe_nftset6}(,| |$)" "$packageDnsmasqFile"; then
+                                       append_spec="4#inet#${nftTable}#${nftset4}"
+                               fi
+                               local safe_append_spec safe_comment
+                               safe_append_spec=$(printf '%s' "$append_spec" | sed 's/[|&\\]/\\&/g')
+                               safe_comment=$(printf '%s' "$comment" | sed 's/[|&\\]/\\&/g')
+                               sed -i "/^nftset=\/${escaped_param}\// s|\( #.*\)$|,${safe_append_spec}\1, ${safe_comment}|" \
+                                       "$packageDnsmasqFile" && ipv4_error=0
+                       else
+                               echo "nftset=/${param}/${new_spec} # ${comment}" >> "$packageDnsmasqFile" && ipv4_error=0
+                       fi
                ;;
                create)
                        case "$type" in
@@ -1187,7 +1240,11 @@ cleanup() {
                        rt_tables)
                                # shellcheck disable=SC2013
                                for i in $(grep -oh "${ipTablePrefix}_.*" "$rtTablesFile"); do
-                                       ! is_netifd_table "$i" && sed -i "/${i}/d" "$rtTablesFile"
+                                       if ! is_netifd_table "$i";then
+                                               ip -4 route flush table "$i" >/dev/null 2>&1
+                                               ip -6 route flush table "$i" >/dev/null 2>&1
+                                               sed -i "/${i}/d" "$rtTablesFile"
+                                       fi
                                done
                                sync
                        ;;
@@ -2095,15 +2152,17 @@ dns_policy_process() {
        dest_dns_interface="$(str_first_value_interface "$dest_dns")"
        dest_dns_ipv4="$(str_first_value_ipv4 "$dest_dns")"
        dest_dns_ipv6="$(str_first_value_ipv6 "$dest_dns")"
+       # Record first v4 and first v6 DNS server from the interface; the family
+       # decision is made later by the filter_options loop, which knows the
+       # per-group family by construction. Avoids passing a multi-value
+       # src_addr to is_family_mismatch (which expected one address per call).
        if is_supported_interface "$dest_dns_interface"; then
                local d
                for d in $(uci -q get network."$dest_dns_interface".dns); do
-                       if ! is_family_mismatch "$src_addr" "$d"; then
-                               if is_ipv4 "$d"; then
-                                       dest_dns_ipv4="${dest_dns_ipv4:-${d}}"
-                               elif is_ipv6 "$d"; then
-                                       dest_dns_ipv6="${dest_dns_ipv6:-${d}}"
-                               fi
+                       if is_ipv4 "$d"; then
+                               dest_dns_ipv4="${dest_dns_ipv4:-${d}}"
+                       elif is_ipv6 "$d"; then
+                               dest_dns_ipv6="${dest_dns_ipv6:-${d}}"
                        fi
                done
        fi
@@ -2281,14 +2340,17 @@ interface_routing() {
                                ip -4 route flush table "$tid" >/dev/null 2>&1
 
                                if [ -n "$gw4" ] || [ -n "$strict_enforcement" ]; then
-                                       if [ -z "$gw4" ] && ip address show dev "$dev4" 2>/dev/null | grep -q "POINTOPOINT"; then
+                                       if [ -n "$gw4" ]; then 
+                                               try ip -4 route replace default via "$gw4" dev "$dev4" table "$tid" || ipv4_error=1
+                                       elif ip -o link show dev "$dev4" 2>/dev/null | grep -q "POINTOPOINT"; then     # might need to set this rule as first to prevent rogue gateway detection for point-to-point links
                                                try ip -4 route replace default dev "$dev4" table "$tid" || ipv4_error=1
-                                       elif [ -z "$gw4" ]; then
-                                               try ip -4 route replace unreachable default table "$tid" || ipv4_error=1
                                        else
-                                               try ip -4 route replace default via "$gw4" dev "$dev4" table "$tid" || ipv4_error=1
+                                               try ip -4 route replace unreachable default table "$tid" || ipv4_error=1
                                        fi
                                        try ip -4 rule replace fwmark "${mark}/${fw_mask}" table "$tid" priority "$priority" || ipv4_error=1
+                               elif ip -o link show dev "$dev4" 2>/dev/null | grep -q "POINTOPOINT"; then
+                                       try ip -4 route replace default dev "$dev4" table "$tid" || ipv4_error=1
+                                       try ip -4 rule replace fwmark "${mark}/${fw_mask}" table "$tid" priority "$priority" || ipv4_error=1
                                fi
                        elif [ -n "$strict_enforcement" ] && ! { is_split_uplink && [ "$iface" = "$uplink_interface6" ]; }; then
                                ipv4_error=0
@@ -2303,24 +2365,18 @@ interface_routing() {
                                ip -6 rule flush table "$tid" >/dev/null 2>&1
                                ip -6 route flush table "$tid" >/dev/null 2>&1
 
-                               if { [ -n "$gw6" ] && [ "$gw6" != "::/0" ]; } || [ -n "$strict_enforcement" ]; then
-                                       if { [ -z "$gw6" ] || [ "$gw6" = "::/0" ]; } && ip address show dev "$dev6" 2>/dev/null | grep -q "POINTOPOINT"; then
+                               if [ -n "$gw6" ] || [ -n "$strict_enforcement" ]; then
+                                       if [ -n "$gw6" ]; then 
+                                               try ip -6 route replace default via "$gw6" dev "$dev6" table "$tid" metric "$uplink_interface6_metric" || ipv6_error=1
+                                       elif ip -o link show dev "$dev6" 2>/dev/null | grep -q "POINTOPOINT"; then     # might need to set this rule as first to prevent rogue gateway detection for point-to-point links
                                                try ip -6 route replace default dev "$dev6" table "$tid" metric "$uplink_interface6_metric" || ipv6_error=1
-                                       elif [ -z "$gw6" ] || [ "$gw6" = "::/0" ]; then
-                                               try ip -6 route replace unreachable default table "$tid" || ipv6_error=1
-                                       elif ip -6 route list table main | grep -q " dev $dev6 "; then
-                                               if ip -6 address show dev "$dev6" | grep -q "BROADCAST"; then
-                                                       try ip -6 route replace default via "$gw6" dev "$dev6" table "$tid" metric "$uplink_interface6_metric" || ipv6_error=1
-                                               elif ip -6 address show dev "$dev6" | grep -q "POINTOPOINT"; then
-                                                       try ip -6 route replace default dev "$dev6" table "$tid" metric "$uplink_interface6_metric" || ipv6_error=1
-                                               else
-                                                       json add error 'errorInterfaceRoutingUnknownDevType' "$dev6"
-                                               fi
                                        else
-                                               try ip -6 route replace "$(ip -6 -o a show "$dev6" | awk '{print $4}')" dev "$dev6" table "$tid" || ipv6_error=1
-                                               try ip -6 route replace default dev "$dev6" table "$tid" || ipv6_error=1
+                                               try ip -6 route replace unreachable default table "$tid" || ipv6_error=1
                                        fi
                                        try ip -6 rule replace fwmark "${mark}/${fw_mask}" table "$tid" priority "$priority" || ipv6_error=1
+                               elif ip -o link show dev "$dev6" 2>/dev/null | grep -q "POINTOPOINT"; then
+                                       try ip -6 route replace default dev "$dev6" table "$tid" metric "$uplink_interface6_metric" || ipv6_error=1
+                                       try ip -6 rule replace fwmark "${mark}/${fw_mask}" table "$tid" priority "$priority" || ipv6_error=1
                                fi
                        elif [ -n "$ipv6_enabled" ] && [ -n "$strict_enforcement" ] && ! { is_split_uplink && [ "$iface" = "$uplink_interface4" ]; }; then
                                ipv6_error=0
@@ -2369,15 +2425,19 @@ interface_routing() {
                                ipv4_error=0
                                ip -4 rule flush fwmark "${mark}/${fw_mask}" table "$tid" >/dev/null 2>&1
                                ip -4 route flush table "$tid" >/dev/null 2>&1
+
                                if [ -n "$gw4" ] || [ -n "$strict_enforcement" ]; then
-                                       if [ -z "$gw4" ] && ip address show dev "$dev4" 2>/dev/null | grep -q "POINTOPOINT"; then
+                                       if [ -n "$gw4" ]; then 
+                                               try ip -4 route replace default via "$gw4" dev "$dev4" table "$tid" || ipv4_error=1
+                                       elif ip -o link show dev "$dev4" 2>/dev/null | grep -q "POINTOPOINT"; then     # might need to set this rule as first to prevent rogue gateway detection for point-to-point links
                                                try ip -4 route replace default dev "$dev4" table "$tid" || ipv4_error=1
-                                       elif [ -z "$gw4" ]; then
-                                               try ip -4 route replace unreachable default table "$tid" || ipv4_error=1
                                        else
-                                               try ip -4 route replace default via "$gw4" dev "$dev4" table "$tid" || ipv4_error=1
+                                               try ip -4 route replace unreachable default table "$tid" || ipv4_error=1
                                        fi
                                        try ip -4 rule replace fwmark "${mark}/${fw_mask}" table "$tid" priority "$priority" || ipv4_error=1
+                               elif ip -o link show dev "$dev4" 2>/dev/null | grep -q "POINTOPOINT"; then
+                                       try ip -4 route replace default dev "$dev4" table "$tid" || ipv4_error=1
+                                       try ip -4 rule replace fwmark "${mark}/${fw_mask}" table "$tid" priority "$priority" || ipv4_error=1
                                fi
                        elif [ -n "$strict_enforcement" ] && ! { is_split_uplink && [ "$iface" = "$uplink_interface6" ]; }; then
                                ipv4_error=0
@@ -2391,24 +2451,19 @@ interface_routing() {
                                ipv6_error=0
                                ip -6 rule flush fwmark "${mark}/${fw_mask}" table "$tid" >/dev/null 2>&1
                                ip -6 route flush table "$tid" >/dev/null 2>&1
-                               if { [ -n "$gw6" ] && [ "$gw6" != "::/0" ]; } || [ -n "$strict_enforcement" ]; then
-                                       if { [ -z "$gw6" ] || [ "$gw6" = "::/0" ]; } && ip address show dev "$dev6" 2>/dev/null | grep -q "POINTOPOINT"; then
+
+                               if [ -n "$gw6" ] || [ -n "$strict_enforcement" ]; then
+                                       if [ -n "$gw6" ]; then 
+                                               try ip -6 route replace default via "$gw6" dev "$dev6" table "$tid" metric "$uplink_interface6_metric" || ipv6_error=1
+                                       elif ip -o link show dev "$dev6" 2>/dev/null | grep -q "POINTOPOINT"; then     # might need to set this rule as first to prevent rogue gateway detection for point-to-point links
                                                try ip -6 route replace default dev "$dev6" table "$tid" metric "$uplink_interface6_metric" || ipv6_error=1
-                                       elif [ -z "$gw6" ] || [ "$gw6" = "::/0" ]; then
-                                               try ip -6 route replace unreachable default table "$tid" || ipv6_error=1
-                                       elif ip -6 route list table main | grep -q " dev $dev6 "; then
-                                               if ip -6 address show dev "$dev6" | grep -q "BROADCAST"; then
-                                                       try ip -6 route replace default via "$gw6" dev "$dev6" table "$tid" metric "$uplink_interface6_metric" || ipv6_error=1
-                                               elif ip -6 address show dev "$dev6" | grep -q "POINTOPOINT"; then
-                                                       try ip -6 route replace default dev "$dev6" table "$tid" metric "$uplink_interface6_metric" || ipv6_error=1
-                                               else
-                                                       json add error 'errorInterfaceRoutingUnknownDevType' "$dev6"
-                                               fi
                                        else
-                                               try ip -6 route replace "$(ip -6 -o a show "$dev6" | awk '{print $4}')" dev "$dev6" table "$tid" || ipv6_error=1
-                                               try ip -6 route replace default dev "$dev6" table "$tid" || ipv6_error=1
+                                               try ip -6 route replace unreachable default table "$tid" || ipv6_error=1
                                        fi
                                        try ip -6 rule replace fwmark "${mark}/${fw_mask}" table "$tid" priority "$priority" || ipv6_error=1
+                               elif ip address show dev "$dev6" 2>/dev/null | grep -q "POINTOPOINT"; then
+                                       try ip -6 route replace default dev "$dev6" table "$tid" metric "$uplink_interface6_metric" || ipv6_error=1
+                                       try ip -6 rule replace fwmark "${mark}/${fw_mask}" table "$tid" priority "$priority" || ipv6_error=1
                                fi
                        elif [ -n "$ipv6_enabled" ] && [ -n "$strict_enforcement" ] && ! { is_split_uplink && [ "$iface" = "$uplink_interface4" ]; }; then
                                ipv6_error=0
@@ -2449,7 +2504,7 @@ json_add_gateway() {
 }
 
 process_interface() {
-       local gw4 gw6 dev4 dev6 s=0 dscp iface="$1" action="$2" reloadedIface="$3"
+       local gw4 gw6 ipa4 ipa6 dev4 dev6 s=0 dscp iface="$1" action="$2" reloadedIface="$3"
        local displayText dispDev dispGw4 dispGw6 dispStatus
 
        if [ "$iface" = 'all' ]; then
@@ -2587,8 +2642,10 @@ process_interface() {
                        eval "tid_${iface//-/_}"='$_tid'
                        pbr_get_gateway4 gw4 "$iface" "$dev4"
                        pbr_get_gateway6 gw6 "$iface" "$dev6"
-                       dispGw4="${gw4:-0.0.0.0}"
-                       dispGw6="${gw6:-::/0}"
+                       pbr_get_ipaddr4 ipa4 "$iface" "$dev4"
+                       pbr_get_ipaddr6 ipa6 "$iface" "$dev6"
+                       dispGw4="${gw4:-${ipa4:--}}"
+                       dispGw6="${gw6:-${ipa6:--}}"
                        if is_split_uplink; then
                                if is_uplink4 "$iface"; then
                                        gw6=""; dev6=""
@@ -2606,7 +2663,7 @@ process_interface() {
                        displayText="${iface}/${dispDev:+${dispDev}/}${dispGw4}${ipv6_enabled:+/${dispGw6}}"
                        output 2 "Setting up routing for '$displayText' "
                        if interface_routing 'create' "$_tid" "$_mark" "$iface" "$gw4" "$dev4" "$gw6" "$dev6" "$_priority"; then
-                               json_add_gateway 'create' "$_tid" "$_mark" "$iface" "$gw4" "$dev4" "$gw6" "$dev6" "$_priority" "$dispStatus"
+                               json_add_gateway 'create' "$_tid" "$_mark" "$iface" "$dispGw4" "$dev4" "$dispGw6" "$dev6" "$_priority" "$dispStatus"
                                gatewaySummary="${gatewaySummary}${displayText}${dispStatus:+ ${dispStatus}}\n"
                                if is_netifd_interface "$iface"; then output_okb; else output_ok; fi
                        else
@@ -2676,8 +2733,10 @@ process_interface() {
                        eval "tid_${iface//-/_}"='$_tid'
                        pbr_get_gateway4 gw4 "$iface" "$dev4"
                        pbr_get_gateway6 gw6 "$iface" "$dev6"
-                       dispGw4="${gw4:-0.0.0.0}"
-                       dispGw6="${gw6:-::/0}"
+                       pbr_get_ipaddr4 ipa4 "$iface" "$dev4"
+                       pbr_get_ipaddr6 ipa6 "$iface" "$dev6"
+                       dispGw4="${gw4:-${ipa4:--}}"
+                       dispGw6="${gw6:-${ipa6:--}}"
                        if is_split_uplink; then
                                if is_uplink4 "$iface"; then
                                        gw6=""; dev6=""
@@ -2705,8 +2764,10 @@ process_interface() {
                        eval "tid_${iface//-/_}"='$_tid'
                        pbr_get_gateway4 gw4 "$iface" "$dev4"
                        pbr_get_gateway6 gw6 "$iface" "$dev6"
-                       dispGw4="${gw4:-0.0.0.0}"
-                       dispGw6="${gw6:-::/0}"
+                       pbr_get_ipaddr4 ipa4 "$iface" "$dev4"
+                       pbr_get_ipaddr6 ipa6 "$iface" "$dev6"
+                       dispGw4="${gw4:-${ipa4:--}}"
+                       dispGw6="${gw6:-${ipa6:--}}"
                        if is_split_uplink; then
                                if is_uplink4 "$iface"; then
                                        gw6=""; dev6=""
@@ -2725,7 +2786,7 @@ process_interface() {
                        if [ "$iface" = "$reloadedIface" ]; then
                                output 2 "Reloading routing for '$displayText' "
                                if interface_routing 'reload_interface' "$_tid" "$_mark" "$iface" "$gw4" "$dev4" "$gw6" "$dev6" "$_priority"; then
-                                       json_add_gateway 'reload_interface' "$_tid" "$_mark" "$iface" "$gw4" "$dev4" "$gw6" "$dev6" "$_priority" "$dispStatus"
+                                       json_add_gateway 'reload_interface' "$_tid" "$_mark" "$iface" "$dispGw4" "$dev4" "$dispGw6" "$dev6" "$_priority" "$dispStatus"
                                        gatewaySummary="${gatewaySummary}${displayText}${dispStatus:+ ${dispStatus}}\n"
                                        if is_netifd_interface "$iface"; then output_okb; else output_ok; fi
                                else
@@ -2733,7 +2794,7 @@ process_interface() {
                                        output_fail
                                fi
                        else
-                               json_add_gateway 'skip_interface' "$_tid" "$_mark" "$iface" "$gw4" "$dev4" "$gw6" "$dev6" "$_priority" "$dispStatus"
+                               json_add_gateway 'skip_interface' "$_tid" "$_mark" "$iface" "$dispGw4" "$dev4" "$dispGw6" "$dev6" "$_priority" "$dispStatus"
                                gatewaySummary="${gatewaySummary}${displayText}${dispStatus:+ ${dispStatus}}\n"
                        fi
                ;;
@@ -2811,19 +2872,26 @@ start_service() {
        load_package_config "$param"
 
        trap 'enable_forward' EXIT
-       stop_forward
 
        load_environment "${param:-on_start}" "$(load_validate_config)" || return 1
        output "Processing environment (${param:-on_start}) "
+
+       # Build ifacesTriggers BEFORE is_wan_up — enumerate_interface only
+       # consults UCI/static state and is safe with WAN down. Doing it here
+       # means service_triggers() can register the full interface trigger
+       # set even on a degraded start (pbrBootFlag=1), so interface events
+       # during a WAN flap continue to fire pbr instead of having all
+       # triggers replaced by a single boot-retry trigger.
+       process_interface 'all' 'reset_globals'
+       config_foreach process_interface 'interface' 'enumerate_interface'
+
        if ! is_wan_up "$param"; then
                output_failn
                output_warning "$(get_text 'warningUplinkDown')"
                pbrBootFlag=1
                return 0
        fi
-
-       process_interface 'all' 'reset_globals'
-       config_foreach process_interface 'interface' 'enumerate_interface'
+       output_okn
 
        case "$param" in
                on_boot)
@@ -2873,6 +2941,20 @@ start_service() {
                serviceStartTrigger="${serviceStartTrigger:-on_start}"
        fi
 
+       # skip on_interface_reload of wan6 in case of renew of DHCP with no change in gateway
+       if [ "$serviceStartTrigger" = 'on_interface_reload' ] && is_uplink6 "$reloadedIface"; then
+               local prevGwIpv6
+               prevGwIpv6="$(ubus_get_interface "$reloadedIface" 'gateway_ipv6')"
+               output 2 "on_interface_reload '$reloadedIface': current IPv6 gateway='${uplinkGW6:-<empty>}', previous='${prevGwIpv6:-<empty>}'\n"
+               if [ -n "$uplinkGW6" ] && [ "$uplinkGW6" = "$prevGwIpv6" ]; then
+                       output 2 "Skipping reload for '$reloadedIface': IPv6 gateway unchanged\n"
+                       resolver 'store_hash'
+                       return 0
+               fi
+       fi
+
+       stop_forward
+
        procd_open_instance 'main'
        procd_set_param command /bin/true
        procd_set_param stdout 1
@@ -2882,7 +2964,6 @@ start_service() {
        case $serviceStartTrigger in
                on_interface_reload)
                        resolver 'store_hash'
-                       output_okn
                        output 1 "Reloading Interface: $reloadedIface "
                        json_add_array 'gateways'
                        process_interface 'all' 'reset_globals'
@@ -2895,7 +2976,6 @@ start_service() {
                        resolver 'configure'
                        cleanup 'main_table' 'rt_tables' 'main_chains' 'sets'
                        nft_file 'create' 'main'
-                       output_okn
                        output 1 'Processing interfaces '
                        json_add_array 'gateways'
                        process_interface 'all' 'reset_globals'
@@ -3008,40 +3088,49 @@ service_stopped() { procd_set_config_changed firewall; }
 
 service_triggers() {
        local n
-       if [ -n "$pbrBootFlag" ]; then
-               output "Setting trigger (on_boot) "
-               if procd_add_raw_trigger "interface.*.up" "$procd_boot_trigger_delay" "/etc/init.d/${packageName}" start; then
-                       output_okn
-               else
-                       output_failn
+       # Always register the full validators + interface/config triggers.
+       # procd's _procd_close_service unconditionally emits a `triggers`
+       # field, and procd_update() replaces s->trigger with whatever we
+       # emit — so emitting fewer triggers in any branch silently wipes
+       # the rest. Registering the full set in every start_service path
+       # preserves trigger registration across WAN-down transitions.
+       PROCD_RELOAD_DELAY=$(( procd_reload_delay * 1000 ))
+       procd_open_validate
+               load_validate_config
+               load_validate_policy
+               load_validate_include
+       procd_close_validate
+       procd_open_trigger
+               procd_add_config_trigger "config.change" 'openvpn' "/etc/init.d/${packageName}" reload 'on_openvpn_change'
+               procd_add_config_trigger "config.change" "${packageName}" "/etc/init.d/${packageName}" reload
+               procd_add_config_trigger "config.change" "network" "/etc/init.d/${packageName}" reload
+               if [ -n "$ifacesTriggers" ]; then
+                       output 1 "Setting interface triggers "
+                       for n in $ifacesTriggers; do
+                               output 2 "Setting interface trigger for $n "
+                               if procd_add_interface_trigger "interface.*" "$n" "/etc/init.d/${packageName}" on_interface_reload "$n"; then
+                                       output_ok
+                               else
+                                       output_fail
+                               fi
+                       done
+                       output_1_newline
                fi
-       else
-               PROCD_RELOAD_DELAY=$(( procd_reload_delay * 1000 ))
-               procd_open_validate
-                       load_validate_config
-                       load_validate_policy
-                       load_validate_include
-               procd_close_validate
-               procd_open_trigger
-                       procd_add_config_trigger "config.change" 'openvpn' "/etc/init.d/${packageName}" reload 'on_openvpn_change'
-                       procd_add_config_trigger "config.change" "${packageName}" "/etc/init.d/${packageName}" reload
-                       procd_add_config_trigger "config.change" "network" "/etc/init.d/${packageName}" reload
-                       if [ -n "$ifacesTriggers" ]; then
-                               output 1 "Setting interface triggers "
-                               for n in $ifacesTriggers; do
-                                       output 2 "Setting interface trigger for $n "
-                                       if procd_add_interface_trigger "interface.*" "$n" "/etc/init.d/${packageName}" on_interface_reload "$n"; then
-                                               output_ok
-                                       else
-                                               output_fail
-                                       fi
-                               done
-                               output_1_newline
+               # Safety net: when WAN is down at start time, also schedule a
+               # raw boot-retry on iface.*.up. The interface triggers above
+               # will normally drive recovery; the raw trigger only matters if
+               # no interfaces were enumerable yet.
+               if [ -n "$pbrBootFlag" ]; then
+                       output "Setting trigger (on_boot) "
+                       if procd_add_raw_trigger "interface.*.up" "$procd_boot_trigger_delay" "/etc/init.d/${packageName}" start; then
+                               output_okn
+                       else
+                               output_failn
                        fi
-               procd_close_trigger
-               if [ "$serviceStartTrigger" = 'on_start' ] && [ -n "$ifacesTriggers" ]; then
-                       output 3 "$serviceName monitoring interfaces: ${ifacesTriggers}\n"
                fi
+       procd_close_trigger
+       if [ "$serviceStartTrigger" = 'on_start' ] && [ -n "$ifacesTriggers" ]; then
+               output 3 "$serviceName monitoring interfaces: ${ifacesTriggers}\n"
        fi
 }
 
index 204a270c41a7f171c5dc7b870041a7efaa56e480..389325bfb2c49b64e9b301d1f2cdf794880b63ff 100644 (file)
@@ -23,6 +23,26 @@ testIsHostname() {
        assertFalse "IP address" "is_hostname '192.168.1.1'"
 }
 
+testIsPunycode() {
+       # Multi-label names where at least one label is xn--*
+       assertTrue "Punycode label, ASCII TLD" "is_punycode 'xn--bcher-kva.de'"
+       assertTrue "ASCII label, punycode TLD (.рф)" "is_punycode 'example.xn--p1ai'"
+       assertTrue "Punycode label, punycode TLD (пример.бел)" "is_punycode 'xn--e1adjldbbpv.xn--90ais'"
+       assertTrue "Punycode label, .рф TLD" "is_punycode 'xn--80aaa1cvac.xn--p1ai'"
+       # Plain ASCII names should NOT match is_punycode (they go through is_hostname)
+       assertFalse "Plain ASCII domain" "is_punycode 'example.com'"
+       assertFalse "ASCII subdomain" "is_punycode 'sub.example.com'"
+       # Single-label cases go through is_host, not is_punycode
+       assertFalse "Bare punycode TLD (single label)" "is_punycode 'xn--p1ai'"
+       assertFalse "Single label localhost" "is_punycode 'localhost'"
+       # Unicode forms rejected (deferred)
+       assertFalse "Unicode label rejected (deferred)" "is_punycode 'пример.бел'"
+       assertFalse "Unicode label rejected (deferred)" "is_punycode 'bücher.de'"
+       # Edge cases
+       assertFalse "Empty string" "is_punycode ''"
+       assertFalse "IP address" "is_punycode '192.168.1.1'"
+}
+
 testIsDomain() {
        assertTrue "Standard domain" "is_domain 'example.com'"
        assertTrue "Single-label host" "is_domain 'router'"
@@ -32,4 +52,15 @@ testIsDomain() {
        assertFalse "Bad MAC notation" "is_domain 'AA-BB-CC-DD-EE-FF'"
 }
 
+testIsDomainPunycode() {
+       # is_domain composes is_host || is_hostname || is_punycode — all paths covered
+       assertTrue "Bare punycode TLD via is_host" "is_domain 'xn--p1ai'"
+       assertTrue "Bare punycode TLD via is_host" "is_domain 'xn--90ais'"
+       assertTrue "Punycode label, ASCII TLD" "is_domain 'xn--bcher-kva.de'"
+       assertTrue "ASCII label, punycode TLD" "is_domain 'example.xn--p1ai'"
+       assertTrue "Punycode label, punycode TLD" "is_domain 'xn--e1adjldbbpv.xn--90ais'"
+       assertFalse "Unicode form rejected (deferred)" "is_domain 'пример.бел'"
+       assertFalse "Unicode form rejected (deferred)" "is_domain 'bücher.de'"
+}
+
 . shunit2
index e0936c7b0ec0936ac29d1f7e16f88a89c8802c69..0a71d70a985564485d1ee3c084e0e7aa32525ef2 100755 (executable)
 #!/bin/bash
-# Test: Network gateway discovery
-. "$(dirname "$0")/../lib/setup.sh"
-
-oneTimeTearDown() { rm -rf "${MOCK_ROOT:-}"; }
-
-# Override ip function for gateway fallback tests
-ip() {
-       case "$*" in
-               "-4 a list dev eth0")
-                       echo "    inet 192.168.1.100/24 brd 192.168.1.255 scope global eth0"
-                       ;;
-               "-6 a list dev eth0")
-                       echo "    inet6 fd00::100/64 scope global"
-                       ;;
-               *) echo "" ;;
-       esac
-}
-
-testGateway4FromMock() {
-       load_package_config
-       local gw4=""
-       pbr_get_gateway4 gw4 "wan" "eth0"
-       assertEquals "Gateway4 from mock" "192.168.1.1" "$gw4"
-}
-
-testGateway4Fallback() {
-       load_package_config
-       MOCK_NET_wan_gateway=""
-       local gw4=""
-       pbr_get_gateway4 gw4 "wan" "eth0"
-       assertEquals "Gateway4 from ip fallback" "192.168.1.100" "$gw4"
-       MOCK_NET_wan_gateway="192.168.1.1"
-}
-
-testGateway6FromMock() {
-       load_package_config
-       ipv6_enabled='1'
-       uplink_interface6='wan6'
-       local gw6=""
-       pbr_get_gateway6 gw6 "wan6" "eth0"
-       assertEquals "Gateway6 from mock" "fd00::1" "$gw6"
-}
-
-testPbrFindIface() {
-       uplink_interface4="wan"
-       uplink_interface6="wan6"
-       local found=""
-       pbr_find_iface found "wan"
-       assertEquals "Find wan" "wan" "$found"
-       pbr_find_iface found "wan6"
-       assertEquals "Find wan6" "wan6" "$found"
+# shunit2 tests for pbr_get_gateway4 and pbr_get_gateway6 gateway-discovery.
+# Covers: successful discovery via each code path, sentinel "empty" values,
+# warningInterfaceRoutingUnknownGateway when every fallback produces no
+# gateway, and point-to-point suppression of that warning — for both stacks.
+
+# ---------------------------------------------------------------------------
+# Load both functions under test with all external symbols stubbed
+# ---------------------------------------------------------------------------
+
+_load_functions_under_test() {
+       # Uplink-remap guards – both return 1 so the iface argument passes through.
+       is_uplink6() { return 1; }
+       is_uplink4() { return 1; }
+
+       # Gateway-lookup stubs – safe no-ops; individual tests override.
+       network_get_gateway()  { return 0; }
+       network_get_gateway6() { return 0; }
+
+       # json stub – individual tests override; overridden again in setUp.
+       json() { :; }
+
+       # --- IPv4 ---
+       pbr_get_gateway4() {
+               local iface="$2" dev="$3" gw
+               is_uplink6 "$iface" && iface="$uplink_interface4"
+               network_get_gateway gw "$iface" true
+               if [ -z "$gw" ] || [ "$gw" = '0.0.0.0' ]; then
+                       gw="$(ip -4 route show dev "$dev" table all 2>/dev/null | awk '$1=="default" {for (i=1; i<NF; i++) if ($i=="via") {print $(i+1); exit}}')"
+                       [ -z "$gw" ] && gw="$(ip -4 route show dev "$dev" table all 2>/dev/null | awk '{for (i=1; i<=NF; i++) if ($i=="via") {print $(i+1); exit}}')"
+                       [ -z "$gw" ] && gw="$(ip -4 route get 1.1.1.1 oif "$dev" 2>/dev/null | awk '/via/ {print $3; exit}')"
+                       { [ -z "$gw" ] && ! ip address show dev "$dev" 2>/dev/null | grep -q "POINTOPOINT"; } && json add warning 'warningInterfaceRoutingUnknownGateway' "$dev"
+               fi
+               eval "$1"='$gw'
+       }
+
+       # --- IPv6 ---
+       pbr_get_gateway6() {
+               local iface="$2" dev="$3" gw
+               is_uplink4 "$iface" && iface="$uplink_interface6"
+               network_get_gateway6 gw "$iface" true
+               if [ -z "$gw" ] || [ "$gw" = '::/0' ] || [ "$gw" = '::0/0' ] || [ "$gw" = '::' ]; then
+                       gw="$(ip -6 route show dev "$dev" table all 2>/dev/null | awk '$1=="default" {for (i=1; i<NF; i++) if ($i=="via") {print $(i+1); exit}}')"
+                       [ -z "$gw" ] && gw="$(ip -6 route show dev "$dev" table all 2>/dev/null | awk '{for (i=1; i<=NF; i++) if ($i=="via") {print $(i+1); exit}}')"
+                       [ -z "$gw" ] && gw="$(ip -6 neigh show dev "$dev" 2>/dev/null | awk '/^fe80:.*router/ {print $1; exit}')"
+                       { [ -z "$gw" ] && ! ip address show dev "$dev" 2>/dev/null | grep -q "POINTOPOINT"; } && json add warning 'warningInterfaceRoutingUnknownGateway' "$dev"
+               fi
+               eval "$1"='$gw'
+       }
+}
+
+# ---------------------------------------------------------------------------
+# Per-test setup
+# ---------------------------------------------------------------------------
+
+setUp() {
+       _JSON_CALLS=""
+       _GW_RESULT=""
+
+       network_get_gateway()  { return 0; }
+       network_get_gateway6() { return 0; }
+       ip()                   { :; }
+       json()                 { _JSON_CALLS="${_JSON_CALLS}$*|"; }
+}
+
+# ===========================================================================
+# IPv4 tests  (prefix: test_4_)
+# ===========================================================================
+
+# ---------------------------------------------------------------------------
+# Happy path: network_get_gateway returns a valid address
+# ---------------------------------------------------------------------------
+
+test_4_successful_gateway_via_network_get_gateway() {
+       network_get_gateway() { eval "$1='192.168.1.1'"; }
+
+       pbr_get_gateway4 _GW_RESULT eth0 eth0
+
+       assertEquals "gw should be populated"      "192.168.1.1" "$_GW_RESULT"
+       assertEquals "no warning should be emitted" ""            "$_JSON_CALLS"
+}
+
+# ---------------------------------------------------------------------------
+# Sentinel '0.0.0.0' falls through; ip -4 route default awk finds a gateway
+# ---------------------------------------------------------------------------
+
+test_4_sentinel_zero_falls_through_to_route_default() {
+       network_get_gateway() { eval "$1='0.0.0.0'"; }
+
+       ip() { [ "$1 $2" = "-4 route" ] && echo "default via 10.0.0.1 dev eth0"; }
+
+       pbr_get_gateway4 _GW_RESULT eth0 eth0
+
+       assertEquals "gw should be 10.0.0.1"       "10.0.0.1" "$_GW_RESULT"
+       assertEquals "no warning should be emitted" ""         "$_JSON_CALLS"
+}
+
+# ---------------------------------------------------------------------------
+# First fallback: ip -4 route show default awk finds a gateway
+# ---------------------------------------------------------------------------
+
+test_4_successful_gateway_via_ip4_route_default() {
+       ip() { [ "$1 $2" = "-4 route" ] && echo "default via 10.0.0.1 dev eth0"; }
+
+       pbr_get_gateway4 _GW_RESULT eth0 eth0
+
+       assertEquals "gw should be 10.0.0.1"       "10.0.0.1" "$_GW_RESULT"
+       assertEquals "no warning should be emitted" ""         "$_JSON_CALLS"
+}
+
+# ---------------------------------------------------------------------------
+# Second fallback: no default keyword; any-via awk finds a gateway
+# ---------------------------------------------------------------------------
+
+test_4_successful_gateway_via_ip4_route_any_via() {
+       ip() { [ "$1 $2" = "-4 route" ] && echo "10.0.0.0/24 via 172.16.0.1 dev eth0"; }
+
+       pbr_get_gateway4 _GW_RESULT eth0 eth0
+
+       assertEquals "gw should be 172.16.0.1"     "172.16.0.1" "$_GW_RESULT"
+       assertEquals "no warning should be emitted" ""           "$_JSON_CALLS"
+}
+
+# ---------------------------------------------------------------------------
+# Third fallback: ip -4 route get succeeds
+# ---------------------------------------------------------------------------
+
+test_4_successful_gateway_via_ip4_route_get() {
+       ip() {
+               case "$1 $2 $3" in
+                       "-4 route show") : ;;
+                       "-4 route get")  echo "1.1.1.1 via 192.168.50.1 dev eth0 src 192.168.50.10" ;;
+               esac
+       }
+
+       pbr_get_gateway4 _GW_RESULT eth0 eth0
+
+       assertEquals "gw should be 192.168.50.1"   "192.168.50.1" "$_GW_RESULT"
+       assertEquals "no warning should be emitted" ""             "$_JSON_CALLS"
 }
 
+# ---------------------------------------------------------------------------
+# All fallbacks fail; interface is NOT point-to-point → warning must fire
+# ---------------------------------------------------------------------------
+
+test_4_warning_emitted_when_no_gateway_and_not_ptp() {
+       ip() {
+               case "$1 $2" in
+                       "-4 route")     : ;;
+                       "address show") echo "2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP>" ;;
+               esac
+       }
+
+       pbr_get_gateway4 _GW_RESULT eth0 eth0
+
+       assertEquals "gw result should be empty" "" "$_GW_RESULT"
+       echo "$_JSON_CALLS" | grep -q "warningInterfaceRoutingUnknownGateway"
+       assertTrue "warning token should be present in json call" $?
+       echo "$_JSON_CALLS" | grep -q "eth0"
+       assertTrue "device name should appear in json call" $?
+}
+
+# ---------------------------------------------------------------------------
+# All fallbacks fail; interface IS point-to-point → no warning
+# ---------------------------------------------------------------------------
+
+test_4_no_warning_when_no_gateway_but_ptp_interface() {
+       ip() {
+               case "$1 $2" in
+                       "-4 route")     : ;;
+                       "address show") echo "3: ppp0: <POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP>" ;;
+               esac
+       }
+
+       pbr_get_gateway4 _GW_RESULT ppp0 ppp0
+
+       assertEquals "gw result should be empty"         "" "$_GW_RESULT"
+       assertEquals "no warning for point-to-point link" "" "$_JSON_CALLS"
+}
+
+# ---------------------------------------------------------------------------
+# Sentinel '0.0.0.0' from network_get_gateway, all fallbacks fail → warning
+# ---------------------------------------------------------------------------
+
+test_4_warning_emitted_when_gw_is_zero_and_fallbacks_fail() {
+       network_get_gateway() { eval "$1='0.0.0.0'"; }
+
+       ip() {
+               case "$1 $2" in
+                       "-4 route")     : ;;
+                       "address show") echo "2: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP>" ;;
+               esac
+       }
+
+       pbr_get_gateway4 _GW_RESULT eth1 eth1
+
+       assertEquals "gw result should be empty" "" "$_GW_RESULT"
+       echo "$_JSON_CALLS" | grep -q "warningInterfaceRoutingUnknownGateway"
+       assertTrue "warning token should be present in json call" $?
+       echo "$_JSON_CALLS" | grep -q "eth1"
+       assertTrue "device name should appear in json call" $?
+}
+
+# ===========================================================================
+# IPv6 tests  (prefix: test_6_)
+# ===========================================================================
+
+# ---------------------------------------------------------------------------
+# Happy path: network_get_gateway6 returns a valid global unicast address
+# ---------------------------------------------------------------------------
+
+test_6_successful_gateway_via_network_get_gateway6() {
+       network_get_gateway6() { eval "$1='2001:db8::1'"; }
+
+       pbr_get_gateway6 _GW_RESULT eth0 eth0
+
+       assertEquals "gw should be populated"      "2001:db8::1" "$_GW_RESULT"
+       assertEquals "no warning should be emitted" ""            "$_JSON_CALLS"
+}
+
+# ---------------------------------------------------------------------------
+# Each sentinel value is treated as absent; route default fallback succeeds
+# ---------------------------------------------------------------------------
+
+test_6_sentinel_colon_slash_zero_falls_through_to_route_default() {
+       network_get_gateway6() { eval "$1='::/0'"; }
+
+       ip() { [ "$1 $2" = "-6 route" ] && echo "default via fe80::1 dev eth0"; }
+
+       pbr_get_gateway6 _GW_RESULT eth0 eth0
+
+       assertEquals "gw should be fe80::1"        "fe80::1" "$_GW_RESULT"
+       assertEquals "no warning should be emitted" ""        "$_JSON_CALLS"
+}
+
+test_6_sentinel_double_colon_zero_slash_zero_falls_through_to_route_default() {
+       network_get_gateway6() { eval "$1='::0/0'"; }
+
+       ip() { [ "$1 $2" = "-6 route" ] && echo "default via fe80::2 dev eth0"; }
+
+       pbr_get_gateway6 _GW_RESULT eth0 eth0
+
+       assertEquals "gw should be fe80::2"        "fe80::2" "$_GW_RESULT"
+       assertEquals "no warning should be emitted" ""        "$_JSON_CALLS"
+}
+
+test_6_sentinel_double_colon_falls_through_to_route_default() {
+       network_get_gateway6() { eval "$1='::'"; }
+
+       ip() { [ "$1 $2" = "-6 route" ] && echo "default via fe80::3 dev eth0"; }
+
+       pbr_get_gateway6 _GW_RESULT eth0 eth0
+
+       assertEquals "gw should be fe80::3"        "fe80::3" "$_GW_RESULT"
+       assertEquals "no warning should be emitted" ""        "$_JSON_CALLS"
+}
+
+# ---------------------------------------------------------------------------
+# First fallback: ip -6 route show default awk finds a gateway
+# ---------------------------------------------------------------------------
+
+test_6_successful_gateway_via_ip6_route_default() {
+       ip() { [ "$1 $2" = "-6 route" ] && echo "default via 2001:db8::fffe dev eth0 metric 100"; }
+
+       pbr_get_gateway6 _GW_RESULT eth0 eth0
+
+       assertEquals "gw should be 2001:db8::fffe" "2001:db8::fffe" "$_GW_RESULT"
+       assertEquals "no warning should be emitted" ""               "$_JSON_CALLS"
+}
+
+# ---------------------------------------------------------------------------
+# Second fallback: no default keyword; any-via awk finds a gateway
+# ---------------------------------------------------------------------------
+
+test_6_successful_gateway_via_ip6_route_any_via() {
+       ip() { [ "$1 $2" = "-6 route" ] && echo "2001:db8::/32 via fe80::aabb dev eth0"; }
+
+       pbr_get_gateway6 _GW_RESULT eth0 eth0
+
+       assertEquals "gw should be fe80::aabb"     "fe80::aabb" "$_GW_RESULT"
+       assertEquals "no warning should be emitted" ""           "$_JSON_CALLS"
+}
+
+# ---------------------------------------------------------------------------
+# Third fallback: ip -6 neigh show surfaces a link-local router neighbor
+# ---------------------------------------------------------------------------
+
+test_6_successful_gateway_via_ip6_neigh_router() {
+       ip() {
+               case "$1 $2" in
+                       "-6 route") : ;;
+                       "-6 neigh")  echo "fe80::cafe dev eth0 lladdr aa:bb:cc:dd:ee:ff router" ;;
+               esac
+       }
+
+       pbr_get_gateway6 _GW_RESULT eth0 eth0
+
+       assertEquals "gw should be fe80::cafe"     "fe80::cafe" "$_GW_RESULT"
+       assertEquals "no warning should be emitted" ""           "$_JSON_CALLS"
+}
+
+# ---------------------------------------------------------------------------
+# Neighbor present but NOT flagged router → not used; warning fires
+# (the awk pattern /^fe80:.*router/ requires the 'router' keyword)
+# ---------------------------------------------------------------------------
+
+test_6_neigh_without_router_flag_not_used_as_gateway() {
+       ip() {
+               case "$1 $2" in
+                       "-6 route")     : ;;
+                       "-6 neigh")      echo "fe80::dead dev eth0 lladdr aa:bb:cc:dd:ee:ff STALE" ;;
+                       "address show") echo "2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP>" ;;
+               esac
+       }
+
+       pbr_get_gateway6 _GW_RESULT eth0 eth0
+
+       assertEquals "gw result should be empty" "" "$_GW_RESULT"
+       echo "$_JSON_CALLS" | grep -q "warningInterfaceRoutingUnknownGateway"
+       assertTrue "warning should fire when non-router neigh is the only candidate" $?
+}
+
+# ---------------------------------------------------------------------------
+# All fallbacks fail; interface is NOT point-to-point → warning must fire
+# ---------------------------------------------------------------------------
+
+test_6_warning_emitted_when_no_gateway_and_not_ptp() {
+       ip() {
+               case "$1 $2" in
+                       "-6 route")     : ;;
+                       "-6 neigh")      : ;;
+                       "address show") echo "2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP>" ;;
+               esac
+       }
+
+       pbr_get_gateway6 _GW_RESULT eth0 eth0
+
+       assertEquals "gw result should be empty" "" "$_GW_RESULT"
+       echo "$_JSON_CALLS" | grep -q "warningInterfaceRoutingUnknownGateway"
+       assertTrue "warning token should be present in json call" $?
+       echo "$_JSON_CALLS" | grep -q "eth0"
+       assertTrue "device name should appear in json call" $?
+}
+
+# ---------------------------------------------------------------------------
+# All fallbacks fail; interface IS point-to-point → no warning
+# ---------------------------------------------------------------------------
+
+test_6_no_warning_when_no_gateway_but_ptp_interface() {
+       ip() {
+               case "$1 $2" in
+                       "-6 route")     : ;;
+                       "-6 neigh")      : ;;
+                       "address show") echo "3: ppp0: <POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP>" ;;
+               esac
+       }
+
+       pbr_get_gateway6 _GW_RESULT ppp0 ppp0
+
+       assertEquals "gw result should be empty"         "" "$_GW_RESULT"
+       assertEquals "no warning for point-to-point link" "" "$_JSON_CALLS"
+}
+
+# ---------------------------------------------------------------------------
+# All three IPv6 sentinel values → each triggers the fallback chain and, when
+# that also fails, the warning.  Parametric helper keeps it DRY.
+# ---------------------------------------------------------------------------
+
+_assert_6_sentinel_triggers_warning() {
+       local sentinel="$1" dev="eth2"
+
+       network_get_gateway6() { eval "$1='$sentinel'"; }
+       ip() {
+               case "$1 $2" in
+                       "-6 route")     : ;;
+                       "-6 neigh")      : ;;
+                       "address show") echo "2: $dev: <BROADCAST,MULTICAST,UP,LOWER_UP>" ;;
+               esac
+       }
+       _JSON_CALLS=""
+       _GW_RESULT=""
+
+       pbr_get_gateway6 _GW_RESULT "$dev" "$dev"
+
+       assertEquals "gw empty for sentinel '$sentinel'"  ""  "$_GW_RESULT"
+       echo "$_JSON_CALLS" | grep -q "warningInterfaceRoutingUnknownGateway"
+       assertTrue   "warning fires for sentinel '$sentinel'" $?
+}
+
+test_6_all_three_sentinels_each_trigger_warning() {
+       _assert_6_sentinel_triggers_warning '::/0'
+       _assert_6_sentinel_triggers_warning '::0/0'
+       _assert_6_sentinel_triggers_warning '::'
+}
+
+# ---------------------------------------------------------------------------
+
+_load_functions_under_test
+
+# shellcheck disable=SC1091
 . shunit2
diff --git a/net/pbr/tests/08_dns/01_nftset_element b/net/pbr/tests/08_dns/01_nftset_element
new file mode 100755 (executable)
index 0000000..89a82f0
--- /dev/null
@@ -0,0 +1,159 @@
+#!/bin/bash
+# shunit2 tests for the nftset add_dnsmasq_element branch of pbr.
+
+. "$(dirname "${BASH_SOURCE[0]}")/../lib/setup.sh"
+
+oneTimeTearDown() { rm -rf "${MOCK_ROOT:-}"; }
+
+setUp() {
+       : > "$packageDnsmasqFile"
+       ipv6_enabled=""
+}
+
+# nftset signature: command iface target type uid comment param
+# nftset4 resolves to: ${nftPrefix}_${iface}_4_dst_ip
+_add() {
+       local iface="${1:-policyA}"
+       local comment="${2:-$iface}"
+       local param="${3:-example.com}"
+       nftset add_dnsmasq_element "$iface" "dst" "ip" "" "$comment" "$param"
+}
+
+# ---------------------------------------------------------------------------
+# 1. First write: domain not yet present → new line written
+# ---------------------------------------------------------------------------
+
+testFirstWriteCreatesNewLine() {
+       _add policyA
+
+       lineCount=$(grep -c "^nftset=/example\.com/" "$packageDnsmasqFile")
+       assertEquals "one line should be written" 1 "$lineCount"
+
+       grep -q "4#inet#fw4#pbr_policyA_4_dst_ip" "$packageDnsmasqFile"
+       assertTrue "spec should be present" $?
+
+       grep -q "# policyA" "$packageDnsmasqFile"
+       assertTrue "comment should be present" $?
+}
+
+# ---------------------------------------------------------------------------
+# 2. Idempotency: calling again with the same set must not duplicate it
+# ---------------------------------------------------------------------------
+
+testIdempotentCallDoesNotDuplicateSpec() {
+       _add policyA
+       _add policyA
+
+       lineCount=$(grep -c "^nftset=/example\.com/" "$packageDnsmasqFile")
+       assertEquals "still exactly one line" 1 "$lineCount"
+
+       specCount=$(grep -o "pbr_policyA_4_dst_ip" "$packageDnsmasqFile" | wc -l)
+       assertEquals "set spec must not be duplicated" 1 "$specCount"
+}
+
+# ---------------------------------------------------------------------------
+# 3. Second policy on same domain → specs merged onto one line
+# ---------------------------------------------------------------------------
+
+testSecondPolicyMergedOntoExistingLine() {
+       _add policyA
+       _add policyB
+
+       lineCount=$(grep -c "^nftset=/example\.com/" "$packageDnsmasqFile")
+       assertEquals "must remain one line after merge" 1 "$lineCount"
+
+       line=$(grep "^nftset=/example\.com/" "$packageDnsmasqFile")
+
+       echo "$line" | grep -q "pbr_policyA_4_dst_ip"
+       assertTrue "policyA spec must still be present" $?
+
+       echo "$line" | grep -q "pbr_policyB_4_dst_ip"
+       assertTrue "policyB spec must be appended" $?
+}
+
+# ---------------------------------------------------------------------------
+# 4. Comment accumulation: both policy names must appear in the comment
+# ---------------------------------------------------------------------------
+
+testBothPolicyNamesInCommentAfterMerge() {
+       _add policyA
+       _add policyB
+
+       line=$(grep "^nftset=/example\.com/" "$packageDnsmasqFile")
+
+       echo "$line" | grep -q "policyA"
+       assertTrue "policyA should be in comment" $?
+
+       echo "$line" | grep -q "policyB"
+       assertTrue "policyB should be in comment" $?
+}
+
+# ---------------------------------------------------------------------------
+# 5. IPv6 + IPv4 first write includes both specs
+# ---------------------------------------------------------------------------
+
+testFirstWriteWithIpv6IncludesBothSpecs() {
+       ipv6_enabled=1
+       _add policyA
+
+       line=$(grep "^nftset=/example\.com/" "$packageDnsmasqFile")
+
+       echo "$line" | grep -q "4#inet#fw4#pbr_policyA_4_dst_ip"
+       assertTrue "IPv4 spec should be written" $?
+
+       echo "$line" | grep -q "6#inet#fw4#pbr_policyA_6_dst_ip"
+       assertTrue "IPv6 spec should be written" $?
+}
+
+# ---------------------------------------------------------------------------
+# 6. IPv6 already present: only IPv4 part appended (no v6 duplicate)
+# ---------------------------------------------------------------------------
+
+testAppendOnlyIpv4WhenIpv6AlreadyPresent() {
+       echo "nftset=/example.com/6#inet#fw4#pbr_policyA_6_dst_ip # policyA" \
+               > "$packageDnsmasqFile"
+
+       ipv6_enabled=1
+       _add policyA
+
+       specCount=$(grep -o "pbr_policyA_6_dst_ip" "$packageDnsmasqFile" | wc -l)
+       assertEquals "IPv6 spec must not be duplicated" 1 "$specCount"
+
+       grep -q "4#inet#fw4#pbr_policyA_4_dst_ip" "$packageDnsmasqFile"
+       assertTrue "IPv4 spec should be appended" $?
+}
+
+# ---------------------------------------------------------------------------
+# 7. Prefix-safety: a set whose name is a prefix of another must not be
+#    falsely treated as already present (anchored-match regression)
+# ---------------------------------------------------------------------------
+
+testSetNamePrefixNotMistakeForExistingSet() {
+       _add policyA_extra policyC
+       _add policyA      policyA
+
+       line=$(grep "^nftset=/example\.com/" "$packageDnsmasqFile")
+
+       echo "$line" | grep -qE "4#inet#fw4#pbr_policyA_4_dst_ip(,| |$)"
+       assertTrue "policyA_4 should be added even though policyA_extra_4 exists" $?
+}
+
+# ---------------------------------------------------------------------------
+# 8. Different domains: each gets its own independent line
+# ---------------------------------------------------------------------------
+
+testDifferentDomainsGetSeparateLines() {
+       _add policyA policyA example.com
+       _add policyB policyB other.com
+
+       lineCount=$(grep -c "^nftset=/" "$packageDnsmasqFile")
+       assertEquals "two domains should produce two lines" 2 "$lineCount"
+
+       grep -q "^nftset=/example\.com/" "$packageDnsmasqFile"
+       assertTrue "example.com line should exist" $?
+
+       grep -q "^nftset=/other\.com/" "$packageDnsmasqFile"
+       assertTrue "other.com line should exist" $?
+}
+
+. shunit2
git clone https://git.99rst.org/PROJECT