pbr: update to 1.2.2-r6
authorStan Grishin <redacted>
Wed, 25 Feb 2026 02:31:00 +0000 (02:31 +0000)
committerStan Grishin <redacted>
Fri, 27 Feb 2026 01:01:02 +0000 (17:01 -0800)
Update pbr from 1.2.1-r87 to 1.2.2-r6. This release
adds mwan4 (Multi-WAN) integration, a diagnostic
`support` command, IPv6 lease-to-nftset handling,
improved split-uplink detection, stricter UCI
validation, shell variable quoting fixes across 30+
locations, and a comprehensive 126-case test suite
with a full mock OpenWrt sysroot.

Signed-off-by: Stan Grishin <redacted>
---

- **31 files changed**, +1,745 / -227 lines
  (net +1,518)
- **1 commit**: `61c8923` —
  `pbr: update to 1.2.2-r6`

---

- Version bumped from `1.2.1-r87` to `1.2.2-r6`
- URL updated from `github.com/stangri/pbr/` to
  `github.com/mossdef-org/pbr/`
- No dependency changes

---

Three options changed from scalar to list type:

| Option              | Old Type | New Type |
|---------------------|----------|----------|
| `ignored_interface` | `option` | `list`   |
| `lan_device`        | `option` | `list`   |
| `resolver_instance` | `option` | `list`   |

Options reordered: scalars first, then lists,
matching UCI convention. No values changed.

---

The init script (`/etc/init.d/pbr`) received
significant additions and fixes across ~660 lines
(+443/-218).

Bumped from `24` to `25`.

**mwan4 (Multi-WAN) Integration (8 new functions):**
- `mwan4_is_installed()` — Detect mwan4 package
- `mwan4_is_running()` — Check service status
- `mwan4_get_iface_list()` — Get enabled interfaces
- `mwan4_get_strategy_list()` — Get strategies
- `mwan4_get_iface_mark_chain()` — Get nft mark
  chain for interface
- `mwan4_get_iface_nft_sets()` — Get nftset names
- `mwan4_get_strategy_chain()` — Get strategy chain
- `mwan4_get_mmx_mask()` — Get Multi-WAN mark mask

Enables PBR to coordinate with mwan4 for combined
policy routing and multi-WAN failover.

**Diagnostic `support` Command:**
- New `support()` function generates masked
  diagnostic output for troubleshooting
- `print_config_masked()` redacts sensitive data
  (passwords, keys, tokens, PSKs, endpoints)
  while preserving IP addresses and structure

**IPv6 Lease Handling:**
- New `ipv6_leases_to_nftset()` parses DHCPv6
  leases from `/tmp/hosts/odhcpd`
- Complements existing `ipv4_leases_to_nftset()`

**Split Uplink Detection (3 new functions):**
- `is_uplink4()` — Check IPv4 uplink interface
- `is_uplink6()` — Check IPv6 uplink interface
- `is_uplink()` — Unified check (v4 or v6)
- New `ipv6_default_lookup` variable for split
  IPv4/IPv6 uplink routing table assignment

**ubus Integration:**
- New `ubus_get_interface()` queries PBR gateway
  data via ubus

**Shell Variable Quoting (30+ locations):**
Systematic conversion of bare variable references
to brace-quoted syntax throughout the script:
- `$2` to `${2}` in string replacements
- `$_ret` to `${_ret}` in conditional expansions
- `$_mark` to `${_mark}` in nft rule generation
- `$nftset6` to `${nftset6}` in dnsmasq rules
- `$nft_set_timeout` to `${nft_set_timeout}`
- `$xrayIfacePrefix` to `${xrayIfacePrefix}`
- And many more across rule generation, output
  strings, and conditional expressions

**Specific Fixes:**
- `pbr_get_gateway6()`: Changed `is_wan` to
  `is_uplink4` for correct IPv4 uplink detection
- `is_netifd_interface()`: Now checks both
  `ip4table` and `ip6table` (was IPv4 only)
- `load_environment()`: Fixed inverted flag check
  (`-z` changed to `-n` for `loadEnvironmentFlag`)
- Dnsmasq instance detection: Fixed UCI section
  lookup with proper variable handling
- Help text URL: `#WarningMessagesDetails` changed
  to `#warning-messages-details` (kebab-case)

- `uplink_ip_rules_priority`: Changed from
  `uinteger` to `range(99,32765)` to enforce
  valid Linux routing policy DB bounds

Three options now use `config_get_list` instead of
`config_get` to support multiple values:
- `ignored_interface`
- `lan_device`
- `resolver_instance`

**Rule Cleanup Refactored:**
- Replaced complex awk-based rule parsing with
  priority-range approach
- Calculates `prio_min = priority - max_ifaces`
  and `prio_max = priority`, iterates and deletes
  rules within range
- Skips netifd-managed fwmark rules
- Added legacy rule cleanup for
  `suppress_prefixlength` entries

**Firewall Sync:**
- Added `fw4 -q reload` after successful nft file
  installation to ensure fw4 state synchronizes
  with PBR's nftables changes

**Resolver Instance Handling:**
- Added robustness checks in
  `_dnsmasq_instance_config()`: file existence
  check and instance validity check
- Better section name resolution with UCI query
- Added missing `setup` parameter in resolver
  instance setup calls

- `uci_get_device()` — Replaced with inline call
- `uci_get_protocol()` — Replaced with inline call

---

In `70-pbr`, fixed shell variable quoting:
```sh
${DEVICE:+ ($DEVICE)}
${DEVICE:+ (${DEVICE})}
```

---

In `pbr.user.netflix`, fixed two instances of
bare variable expansion in parameter substitution:
```sh
params="${params:+$params, }${p}"
params="${params:+${params}, }${p}"
```

---

A full test suite is added in `net/pbr/tests/`
(21 new files, ~1,300 lines) using the shunit2
framework with a complete mock OpenWrt sysroot.

**Runner (`run_tests.sh`):**
- Discovers test files via glob pattern
- Supports pattern-based filtering via CLI arg
- Executes each test in isolated bash subprocess
- Captures output, reports pass/fail with color
- Accumulates stats and lists failures at end
- Requires `shunit2` package

**Setup (`lib/setup.sh`):**
- Creates temporary mock sysroot (`$MOCK_ROOT`)
- Sets `IPKG_INSTROOT` for OpenWrt path resolution
- Installs mock libraries, configs, and binaries
- Stubs `rc.common`, procd, logger, resolveip,
  jsonfilter, pidof, sync
- Sources pbr init script with `readonly` keyword
  stripped (allows test overrides)
- Redirects all file paths to temp directories

**UCI Config API (`lib/mocks/functions.sh`):**
- Full `config_load` parser for UCI syntax
- `config_get`, `config_get_bool`,
  `config_get_list`, `config_foreach`,
  `config_list_foreach`
- `uci_set`, `uci_get`, `uci_add_list`,
  `uci_remove`, `uci_remove_list`, `uci_commit`
- Stores state in associative arrays

**Network API (`lib/mocks/network.sh`):**
- `network_get_device`, `network_get_physdev`,
  `network_get_gateway`, `network_get_gateway6`,
  `network_get_protocol`, `network_get_ipaddr`,
  `network_get_ip6addr`, `network_get_dnsserver`,
  `network_flush_cache`
- Backed by `MOCK_NET_*` variables that tests
  override to simulate different network states
- Pre-configured: wan (eth0/dhcp/192.168.1.1),
  wan6 (eth0/dhcpv6/fd00::1), wg0 (wireguard),
  lan (br-lan/static), loopback (lo/static)

**JSON Shell (`lib/mocks/jshn.sh`):**
- Minimal JSON-in-shell implementation
- `json_init`, `json_add_string/boolean/int`,
  `json_add_object/array`, `json_close_*`,
  `json_select`, `json_get_var`, `json_get_keys`,
  `json_dump`, `json_load`
- Associative array backend with path tracking

**Mock Binaries:**
- `nft` — Returns fw4 table structure with
  standard chains (input, forward, output,
  dstnat, mangle_*); passes syntax checks
- `dnsmasq` — Reports version with nftset support
- `readlink` — Returns `/usr/libexec/ip-full`
  for `*/sbin/ip` (simulates ip-full installed)

**Mock UCI Configs:**
- `pbr` — Full config: enabled, policies
  (vpn_all, vpn_gaming, disabled_policy),
  dns_policy, nft settings, interface lists
- `network` — Interfaces: loopback, lan, wan,
  wan6, wg0 (wireguard)
- `firewall` — Zones: lan (accept all),
  wan (reject input/forward)
- `dhcp` — DHCP server stub
- `system` — Hostname and timezone

**01_validation — Input Validation (67 cases):**

`01_ipv4_validation` (13 cases):
- Valid IPs: 192.168.1.1, 10.0.0.1, 172.16.0.1
- Valid CIDR: /8, /24, /32, /0
- Invalid: octets >255, wrong octet count,
  CIDR >32, IPv6 addresses, domain names

`02_ipv6_validation` (21 cases):
- Valid: ::1, fe80::1, 2001:db8::1, fd00::1,
  full addresses, ::/0
- Invalid: IPv4 addrs, plain strings, MACs
- Scope detection: global (2001:db8::/32),
  link-local (fe80::/10), ULA (fd00::/8)

`03_domain_validation` (8 cases):
- Host: single labels (router, host123)
- Hostname: multi-label (example.com,
  sub.example.com, deep.sub.example.com)
- Domain: FQDN or single-label
- Invalid: IPs, empty strings, MAC notation

`04_misc_validators` (25 cases):
- MAC addresses (colon notation, case variants)
- Integer validation (positive, not negative)
- Negation marker (! prefix detection)
- URL schemes (http, https, ftp, file://)
- Version comparison (is_greater,
  is_greater_or_equal)
- Family mismatch (IPv4/IPv6 mixing detection)

**02_string_utils — String Functions (8 cases):**

`01_str_functions`:
- `str_contains` — Substring search
- `str_contains_word` — Word-boundary search
- `str_to_lower` / `str_to_upper` — Case convert
- `str_first_word` — Token extraction
- `str_replace` — String substitution
- `str_extras_to_underscore` — Normalize delims
- `str_extras_to_space` — Expand delimiters

**03_wan_detection — Interface Detection
  (13 cases):**

`01_wan_types`:
- `is_wan4` — Detects wan/wanX, not wan6/lan/wg0
- `is_wan6` — Detects wan6/mwan6 (IPv6-aware)
- `is_wan6_disabled` — Disabled when ipv6 off
- `is_wan` — Unified v4+v6 detection
- `is_uplink4` / `is_uplink6` — Uplink detection
- `is_tor` — Case-insensitive tor detection
- `is_ignore_target` — Ignore target detection
- `is_list` — Comma/space list vs single value

**04_config — Configuration Loading (13 cases):**

`01_load_config` (7 cases):
- Default values from UCI config
- Hex value parsing (fw_mask, uplink_mark)
- XOR calculation (fw_maskXor = ~fw_mask)
- List parsing (ignored_interface, resolver)
- nft parameters (auto-merge, flags)
- Config-loaded flag tracking

`02_disabled_service` (2 cases):
- Disabled: enabled option becomes unset
- Enabled: enabled option is set

`03_config_ipv6` (4 cases):
- IPv6 enabled: config and uplink interface set
- IPv6 disabled: both unset
- Reload behavior verification

**05_nft — nftables Integration (14 cases):**

`01_nft_file_operations` (8 cases):
- File creation with nft shebang
- Chain creation (dstnat, forward, output,
  prerouting)
- Jump rules and guard rules
- File append, content search, file deletion

`02_nft_check_element` (6 cases):
- fw4 table existence
- Chain existence (input, forward, output,
  dstnat, mangle_*)
- Non-existent chain detection

**06_network — Network Functions (11 cases):**

`01_gateway_discovery` (4 cases):
- IPv4 gateway from mock (192.168.1.1)
- IPv4 gateway fallback (ip addr parsing)
- IPv6 gateway from mock (fd00::1)
- Interface finding for uplinks

`02_supported_interfaces` (7 cases):
- Ignored: loopback in ignored list
- LAN detection vs non-LAN
- Uplink support (wan is supported)
- LAN/loopback not supported
- Wireguard supported (wg0)
- Explicit custom interface support

---

```sh
cd net/pbr/tests && sh run_tests.sh
```

Requires: `bash`, `shunit2`.
Optional filter: `sh run_tests.sh 01_validation`

Signed-off-by: Stan Grishin <redacted>
31 files changed:
net/pbr/Makefile
net/pbr/files/etc/config/pbr
net/pbr/files/etc/hotplug.d/iface/70-pbr
net/pbr/files/etc/init.d/pbr
net/pbr/files/usr/share/pbr/pbr.user.netflix
net/pbr/tests/01_validation/01_ipv4_validation [new file with mode: 0644]
net/pbr/tests/01_validation/02_ipv6_validation [new file with mode: 0644]
net/pbr/tests/01_validation/03_domain_validation [new file with mode: 0644]
net/pbr/tests/01_validation/04_misc_validators [new file with mode: 0755]
net/pbr/tests/02_string_utils/01_str_functions [new file with mode: 0755]
net/pbr/tests/03_wan_detection/01_wan_types [new file with mode: 0755]
net/pbr/tests/04_config/01_load_config [new file with mode: 0755]
net/pbr/tests/04_config/02_disabled_service [new file with mode: 0755]
net/pbr/tests/04_config/03_config_ipv6 [new file with mode: 0755]
net/pbr/tests/05_nft/01_nft_file_operations [new file with mode: 0755]
net/pbr/tests/05_nft/02_nft_check_element [new file with mode: 0755]
net/pbr/tests/06_network/01_gateway_discovery [new file with mode: 0755]
net/pbr/tests/06_network/02_supported_interfaces [new file with mode: 0755]
net/pbr/tests/lib/mocks/functions.sh [new file with mode: 0644]
net/pbr/tests/lib/mocks/jshn.sh [new file with mode: 0644]
net/pbr/tests/lib/mocks/network.sh [new file with mode: 0644]
net/pbr/tests/lib/setup.sh [new file with mode: 0644]
net/pbr/tests/mocks/bin/dnsmasq [new file with mode: 0644]
net/pbr/tests/mocks/bin/nft [new file with mode: 0644]
net/pbr/tests/mocks/bin/readlink [new file with mode: 0644]
net/pbr/tests/mocks/etc/config/dhcp [new file with mode: 0644]
net/pbr/tests/mocks/etc/config/firewall [new file with mode: 0644]
net/pbr/tests/mocks/etc/config/network [new file with mode: 0644]
net/pbr/tests/mocks/etc/config/pbr [new file with mode: 0644]
net/pbr/tests/mocks/etc/config/system [new file with mode: 0644]
net/pbr/tests/run_tests.sh [new file with mode: 0644]

index f2af31b5820d25f1f552e2724e67044694b6118b..53ab64311dc8fe94d1af3e9c3576ff3897d36d95 100644 (file)
@@ -4,8 +4,8 @@
 include $(TOPDIR)/rules.mk
 
 PKG_NAME:=pbr
-PKG_VERSION:=1.2.1
-PKG_RELEASE:=87
+PKG_VERSION:=1.2.2
+PKG_RELEASE:=6
 PKG_LICENSE:=AGPL-3.0-or-later
 PKG_MAINTAINER:=Stan Grishin <stangri@melmac.ca>
 
@@ -16,7 +16,7 @@ define Package/pbr
   CATEGORY:=Network
   SUBMENU:=Routing and Redirection
   TITLE:=Policy Based Routing Service with nft/nft set support
-  URL:=https://github.com/stangri/pbr/
+  URL:=https://github.com/mossdef-org/pbr/
   PKGARCH:=all
   DEPENDS:= \
        +ip-full \
index 2c216bb509b77983224135ffd61ee825d9d9d4fe..f6532e39eb9ca87cbe128dc918ae301b22f1cc22 100644 (file)
@@ -1,9 +1,7 @@
 config pbr 'config'
        option enabled '0'
        option fw_mask '00ff0000'
-       list ignored_interface 'vpnserver'
        option ipv6_enabled '0'
-       option lan_device 'br-lan'
        option nft_rule_counter '0'
        option nft_set_auto_merge '1'
        option nft_set_counter '0'
@@ -13,7 +11,6 @@ config pbr 'config'
        option nft_user_set_counter '0'
        option procd_boot_trigger_delay '5000'
        option procd_reload_delay '0'
-       list resolver_instance '*'
        option resolver_set 'dnsmasq.nftset'
        option strict_enforcement '1'
        option uplink_interface 'wan'
@@ -21,6 +18,9 @@ config pbr 'config'
        option uplink_ip_rules_priority '30000'
        option uplink_mark '00010000'
        option verbosity '2'
+       list ignored_interface 'vpnserver'
+       list lan_device 'br-lan'
+       list resolver_instance '*'
        list webui_supported_protocol 'all'
        list webui_supported_protocol 'tcp'
        list webui_supported_protocol 'udp'
index 5340ba5c0392a2ad0b6edd060c0f808a22b7d6d9..002ddb7d1123d47be70cce1712cd9678f98c7425 100644 (file)
@@ -1,6 +1,6 @@
 #!/bin/sh
 # shellcheck disable=SC1091,SC3060
 if [ -x /etc/init.d/pbr ] && /etc/init.d/pbr enabled; then
-       logger -t pbr "Sending reload signal to pbr for $INTERFACE due to $ACTION of $INTERFACE${DEVICE:+ ($DEVICE)}"
+       logger -t pbr "Sending reload signal to pbr for $INTERFACE due to $ACTION of $INTERFACE${DEVICE:+ (${DEVICE})}"
        /etc/init.d/pbr on_interface_reload "$INTERFACE" "$ACTION"
 fi
index c846e7314443249d5465c958de3b463673a4d278..f9a9c4230123f324ec01bc8414aeed877f1ecf7d 100755 (executable)
@@ -18,21 +18,25 @@ if type extra_command >/dev/null 2>&1; then
                Use '-p' option to automatically upload data under PBR paste.ee account
                        WARNING: while paste.ee uploads are unlisted, they are still publicly available
                List domain names after options to include their lookup in report"
+       extra_command 'support' "Show diagnostic info and mask sensitive data"
        extra_command 'version' "Show version information"
 else
 # shellcheck disable=SC2034
-       EXTRA_COMMANDS='netifd on_interface_reload status version'
+       EXTRA_COMMANDS='help netifd on_interface_reload status version'
 # shellcheck disable=SC2034
-       EXTRA_HELP="    status  Generates output required to troubleshoot routing issues
-               Use '-d' option for more detailed output
-               Use '-p' option to automatically upload data under PBR paste.ee account
-                       WARNING: while paste.ee uploads are unlisted, they are still publicly available
-               List domain names after options to include their lookup in report"
+       EXTRA_HELP="
+\tstatus\tGenerates output required to troubleshoot routing issues
+\t\tUse '-d' option for more detailed output
+\t\tUse '-p' option to automatically upload data under PBR paste.ee account
+\t\t\tWARNING: while paste.ee uploads are unlisted, they are still publicly available
+\t\tList domain names after options to include their lookup in report
+\tsupport\tShow diagnostic info and mask sensitive data
+"
 fi
 
 readonly packageName='pbr'
 readonly PKG_VERSION='dev-test'
-readonly packageCompat='24'
+readonly packageCompat='25'
 readonly serviceName="$packageName $PKG_VERSION"
 readonly packageConfigFile="/etc/config/${packageName}"
 readonly packageDebugFile="/var/run/${packageName}.debug"
@@ -201,8 +205,8 @@ resolverWorkingFlag=
 # shellcheck disable=SC1091
 . "${IPKG_INSTROOT}/usr/share/libubox/jshn.sh"
 
-debug() { local i j; for i in "$@"; do eval "j=\$$i"; logger "${packageName:+-t $packageName}" "${i}: ${j} "; done; }
-str_contains() { [ "${1//$2}" != "$1" ]; }
+debug() { local i j; for i in "$@"; do eval "j=\$$i"; logger "${packageName:+-t ${packageName}}" "${i}: ${j} "; done; }
+str_contains() { [ "${1//${2}}" != "$1" ]; }
 str_contains_word() { echo "$1" | grep -qw "$2"; }
 str_extras_to_underscore() { echo "$1" | sed -E 's/[\. ~`!@#$%^&*()+=,<>?;:\/\\-]/_/g; s/_+/_/g'; }
 str_extras_to_space() { echo "$1" | tr ',;{}' ' '; }
@@ -210,7 +214,7 @@ str_first_value_interface() { local i; for i in $1; do is_supported_interface "$
 str_first_value_ipv4() { local i; for i in $1; do is_ipv4 "$i" && { echo "$i"; break; }; done; }
 str_first_value_ipv6() { local i; for i in $1; do is_ipv6 "$i" && { echo "$i"; break; }; done; }
 str_first_word() { echo "${1%% *}"; }
-str_replace() { echo "${1//$2/$3}"; }
+str_replace() { echo "${1//${2}/${3}}"; }
 str_to_dnsmasq_nftset() { echo "$1" | tr ' ' '/'; }
 str_to_lower() { echo "$1" | tr 'A-Z' 'a-z'; }
 str_to_upper() { echo "$1" | tr 'a-z' 'A-Z'; }
@@ -262,7 +266,7 @@ pbr_get_gateway4() {
 }
 pbr_get_gateway6() {
        local iface="$2" dev="$3" gw
-       is_wan "$iface" && iface="$uplink_interface6"
+       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}')"
@@ -277,7 +281,7 @@ filter_options() {
                        opt="${opt/_negative}"
                fi
                eval "is_$opt" "${v/\!}" || continue
-               _ret="${_ret:+$_ret }$v"
+               _ret="${_ret:+${_ret} }$v"
        done
        echo "$_ret"
        return 0
@@ -285,7 +289,7 @@ filter_options() {
 inline_set() {
        local value="$1" inline_set i
        for i in $value; do
-               inline_set="${inline_set:+$inline_set, }${i#[@\!]}"
+               inline_set="${inline_set:+${inline_set}, }${i#[@\!]}"
        done
        echo "$inline_set"
 }
@@ -301,13 +305,6 @@ is_config_enabled() {
        config_foreach _check_config "$cfg"
        return "$_cfg_enabled"
 }
-uci_get_device() {
-       local __tmp
-       __tmp="$(uci_get 'network' "$2" 'device')"
-       [ -z "$__tmp" ] && unset "$1" && return 1
-       eval "$1=$__tmp"
-}
-uci_get_protocol() { uci_get 'network' "$1" 'proto'; }
 is_default_dev() { [ "$1" = "$(ip -4 route show default | awk '{for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1);exit}}')" ]; }
 is_netifd_interface_default() {
        is_netifd_interface "$1" || return 1
@@ -339,20 +336,20 @@ is_mac_address() { echo "$1" | grep -qE '^([0-9A-Fa-f]{2}:){5}([0-9A-Fa-f]{2})$'
 is_mac_address_bad_notation() { echo "$1" | grep -qE '^([0-9A-Fa-f]{2}-){5}([0-9A-Fa-f]{2})$'; }
 is_negated() { [ "${1:0:1}" = '!' ]; }
 is_netifd_table() { grep -q "ip.table.*$1" /etc/config/network; }
-is_netifd_interface() { local iface="$1"; [ -n "$(uci_get 'network' "$iface" 'ip4table')" ]; }
+is_netifd_interface() { local iface="$1"; [ -n "$(uci_get 'network' "$iface" 'ip4table')" ] || [ -n "$(uci_get 'network' "$iface" 'ip6table')" ]; }
 is_oc() { local p; network_get_protocol p "$1"; [ "${p:0:11}" = "openconnect" ]; }
 is_ovpn() { local d; uci_get_device d "$1"; [ "${d:0:3}" = "tun" ] || [ "${d:0:3}" = "tap" ] || [ -f "/sys/devices/virtual/net/${d}/tun_flags" ]; }
 is_ovpn_valid() { local dev_net dev_ovpn; uci_get_device dev_net "$1"; dev_ovpn="$(uci_get 'openvpn' "$1" 'dev')"; [ -n "$dev_net" ] && [ -n "$dev_ovpn" ] && [ "$dev_net" = "$dev_ovpn" ]; }
-is_phys_dev(){ [ "${1:0:1}" = "@" ] && [ -L "/sys/class/net/${1#@}" ]; }
+is_phys_dev() { [ "${1:0:1}" = "@" ] && [ -L "/sys/class/net/${1#@}" ]; }
 is_present() { command -v "$1" >/dev/null 2>&1; }
 is_service_running() { is_service_running_nft; }
 is_service_running_nft() { [ -x "$nft" ] && [ -n "$(get_mark_nft_chains)" ]; }
 is_supported_iface_dev() { local n dev; for n in $ifacesSupported; do network_get_device dev "$n"; [ "$1" = "$dev" ] && return 0; done; return 1; }
-is_supported_protocol(){ grep -qi "^${1:--}" /etc/protocols;}
+is_supported_protocol() { grep -qi "^${1:--}" /etc/protocols;}
 is_pptp() { local p; network_get_protocol p "$1"; [ "${p:0:4}" = "pptp" ]; }
 is_softether() { local d; network_get_device d "$1"; [ "${d:0:4}" = "vpn_" ]; }
 is_split_uplink() { [ -n "$ipv6_enabled" ] && [ "$uplink_interface4" != "$uplink_interface6" ]; }
-is_supported_interface() { { is_lan "$1" || is_disabled_interface "$1"; } && return 1; str_contains_word "$supported_interface" "$1" || { ! is_ignored_interface "$1" && { is_wan "$1" || is_wan6 "$1" || is_tunnel "$1"; }; } || is_ignore_target "$1" || is_xray "$1"; }
+is_supported_interface() { { is_lan "$1" || is_disabled_interface "$1"; } && return 1; str_contains_word "$supported_interface" "$1" || { ! is_ignored_interface "$1" && { is_uplink "$1" || is_wan "$1" || is_tunnel "$1"; }; } || is_ignore_target "$1" || is_xray "$1"; }
 is_netbird() { local d; network_get_device d "$1"; [ "${d:0:2}" = "wt" ]; }
 is_tailscale() { local d; network_get_device d "$1"; [ "${d:0:9}" = "tailscale" ]; }
 is_tor() { [ "$(str_to_lower "$1")" = "tor" ]; }
@@ -364,8 +361,12 @@ is_url_file() { [ "$1" != "${1#file://}" ]; }
 is_url_ftp() { [ "$1" != "${1#ftp://}" ]; }
 is_url_http() { [ "$1" != "${1#http://}" ]; }
 is_url_https() { [ "$1" != "${1#https://}" ]; }
-is_wan() { [ "$1" = "$uplink_interface4" ]; }
-is_wan6() { [ -n "$ipv6_enabled" ] && [ "$1" = "$uplink_interface6" ]; }
+is_uplink4() { [ "$1" = "$uplink_interface4" ]; }
+is_uplink6() { [ -n "$ipv6_enabled" ] && [ "$1" = "$uplink_interface6" ]; }
+is_uplink() { is_uplink4 "$1" || is_uplink6 "$1"; }
+is_wan6() { [ -n "$ipv6_enabled" ] || return 1; case "$1" in wan*6|*wan6) return 0;; *) return 1;; esac; }
+is_wan4() { case "$1" in wan*6|*wan6) return 1;; wan*|*wan) return 0;; *) return 1;; esac; }
+is_wan() { is_wan4 "$1" || is_wan6 "$1"; }
 is_wg() { local p lp; network_get_protocol p "$1"; uci_get_listen_port lp "$1"; [ -z "$lp" ] && [ "${p:0:9}" = "wireguard" ]; }
 is_wg_server() { local p lp; network_get_protocol p "$1"; uci_get_listen_port lp "$1"; [ -n "$lp" ] && [ "${p:0:9}" = "wireguard" ]; }
 is_xray() { [ -n "$(get_xray_traffic_port "$1")" ]; }
@@ -378,7 +379,7 @@ get_ss_traffic_ports() { local i="$(jsonfilter -i "$ssConfigFile" -q -e "@.inbou
 get_tor_dns_port() { local i="$(grep -m1 DNSPort "$torConfigFile" | awk -F: '{print $2}')"; echo "${i:-9053}"; }
 # shellcheck disable=SC2155
 get_tor_traffic_port() { local i="$(grep -m1 TransPort "$torConfigFile" | awk -F: '{print $2}')"; echo "${i:-9040}"; }
-get_xray_traffic_port() { local i="${1//$xrayIfacePrefix}"; [ "$i" = "$1" ] && unset i; echo "$i"; }
+get_xray_traffic_port() { local i="${1//${xrayIfacePrefix}}"; [ "$i" = "$1" ] && unset i; echo "$i"; }
 get_rt_tables_id() { local iface="$1"; grep "${ipTablePrefix}_${iface}\$" "$rtTablesFile" | awk '{print $1;}'; }
 get_rt_tables_next_id() { echo "$(($(sort -r -n "$rtTablesFile" | grep -o -E -m 1 "^[0-9]+")+1))"; }
 get_rt_tables_non_pbr_next_id() { echo "$(($(grep -v "${ipTablePrefix}_" "$rtTablesFile" | sort -r -n  | grep -o -E -m 1 "^[0-9]+")+1))"; }
@@ -387,17 +388,25 @@ resolveip_to_nftset() { resolver 'wait' && resolveip "$@" | sed -n 'H;${x;s/\n/,
 resolveip_to_nftset4() { resolveip_to_nftset -4 "$@"; }
 resolveip_to_nftset6() { [ -n "$ipv6_enabled" ] && resolveip_to_nftset -6 "$@"; }
 # shellcheck disable=SC2016
-ipv4_leases_to_nftset(){ [ -s '/tmp/dhcp.leases' ] && awk -v arg="$1" 'BEGIN{fs=""};$0~arg{printf fs$3;fs=","}' /tmp/dhcp.leases;}
+ipv4_leases_to_nftset() { [ -s '/tmp/dhcp.leases' ] && awk -v arg="$1" 'BEGIN{fs=""};$0~arg{printf fs$3;fs=","}' /tmp/dhcp.leases;}
 # shellcheck disable=SC2016
-ipv6_leases_to_nftset(){ [ -s '/tmp/hosts/odhcpd' ] && awk -v arg="$1" 'BEGIN{fs=""};$0~arg{printf fs$1;fs=","}' /tmp/hosts/odhcpd;}
+ipv6_leases_to_nftset() { [ -s '/tmp/hosts/odhcpd' ] && awk -v arg="$1" 'BEGIN{fs=""};$0~arg{printf fs$1;fs=","}' /tmp/hosts/odhcpd;}
 # shellcheck disable=SC3037
 ports_to_nftset() { echo -en "$1"; }
 get_mark_nft_chains() { "$nft" list table inet "$nftTable" 2>/dev/null | grep chain | grep "${nftPrefix}_mark_" | awk '{ print $2 }'; }
 get_nft_sets() { "$nft" list table inet "$nftTable" 2>/dev/null | grep 'set' | grep "${nftPrefix}_" | awk '{ print $2 }'; }
 __ubus_get() { ubus call service list "{ 'name': '$packageName' }" | jsonfilter -e "$1"; }
 ubus_get_status() { __ubus_get "@.${packageName}.instances.main.data.status.${1}"; }
-ubus_get_interface() { __ubus_get "@.${packageName}.instances.main.data.gateways[@.name='${1}']${2:+.$2}"; }
+ubus_get_interface() { __ubus_get "@.${packageName}.instances.main.data.gateways[@.name='${1}']${2:+.${2}}"; }
 ubus_get_gateways() { __ubus_get "@.${packageName}.instances.main.data.gateways"; }
+config_get_list() { config_get "$@"; }
+uci_get_device() {
+       local __tmp
+       __tmp="$(uci_get 'network' "$2" 'device')"
+       [ -z "$__tmp" ] && unset "$1" && return 1
+       eval "$1=$__tmp"
+}
+uci_get_protocol() { uci_get 'network' "$1" 'proto'; }
 uci_add_list_if_new() {
        local PACKAGE="$1"
        local CONFIG="$2"
@@ -415,7 +424,7 @@ uci_changes() {
        local CONFIG="$2"
        local OPTION="$3"
        [ -s "${UCI_CONFIG_DIR:-/etc/config/}${PACKAGE}" ] && \
-       [ -n "$(/sbin/uci ${UCI_CONFIG_DIR:+-c $UCI_CONFIG_DIR} changes "$PACKAGE${CONFIG:+.$CONFIG}${OPTION:+.$OPTION}")" ]
+       [ -n "$(/sbin/uci ${UCI_CONFIG_DIR:+-c ${UCI_CONFIG_DIR}} changes "$PACKAGE${CONFIG:+.${CONFIG}}${OPTION:+.${OPTION}}")" ]
 }
 uci_get_listen_port() {
        local __tmp
@@ -437,6 +446,115 @@ check_dnsmasq_nftset() {
        [ -z "$dnsmasq_features" ] && dnsmasq_features="$(dnsmasq --version | grep -m1 'Compile time options:' | cut -d: -f2) "
        [ "${dnsmasq_features#* nftset }" != "$dnsmasq_features" ]
 }
+
+# mwan4 detection and integration functions
+mwan4_is_installed() {
+       [ -x /etc/init.d/mwan4 ] && [ -f /etc/config/mwan4 ]
+}
+
+mwan4_is_running() {
+       mwan4_is_installed && /etc/init.d/mwan4 running >/dev/null 2>&1
+}
+
+mwan4_get_iface_list() {
+       # Get list of enabled mwan4 interfaces from UCI config
+       # Output: space-separated list of interface names
+       local iface_list=""
+       mwan4_is_installed || return 1
+
+       _mwan4_collect_iface() {
+               local en
+               config_get_bool en "$1" 'enabled' '0'
+               [ "$en" -gt '0' ] && iface_list="${iface_list}${1} "
+       }
+       config_load 'mwan4'
+       config_foreach _mwan4_collect_iface 'interface'
+
+       echo "${iface_list% }"
+}
+
+mwan4_get_strategy_list() {
+       # Get list of mwan4 strategies from UCI config
+       # Output: space-separated list of strategy names
+       local strategy_list=""
+       mwan4_is_installed || return 1
+
+       _mwan4_collect_strategy() { strategy_list="${strategy_list}${1} "; }
+       config_load 'mwan4'
+       config_foreach _mwan4_collect_strategy 'strategy'
+
+       echo "${strategy_list% }"
+}
+
+mwan4_get_iface_mark_chain() {
+       # Get the nftables marking chain name for a specific mwan4 interface
+       # $1 = interface name
+       # Output: chain name (e.g., "mwan4_iface_in_wan")
+       local iface="$1"
+       [ -z "$iface" ] && return 1
+       mwan4_is_running || return 1
+
+       # Check if the chain exists in nftables
+       if "$nft" list chain inet fw4 "mwan4_iface_in_${iface}" >/dev/null 2>&1; then
+               echo "mwan4_iface_in_${iface}"
+               return 0
+       fi
+       return 1
+}
+
+mwan4_get_iface_nft_sets() {
+       # Get the nftables set names used by a specific mwan4 interface
+       # $1 = interface name
+       # Output: space-separated list of set names
+       local iface="$1"
+       local family=""
+       local sets=""
+       [ -z "$iface" ] && return 1
+       mwan4_is_installed || return 1
+
+       # Get the family (ipv4/ipv6) for the interface
+       config_load 'mwan4'
+       config_get family "$iface" 'family' 'ipv4'
+
+       # The sets used by mwan4 per interface family
+       for settype in connected custom dynamic; do
+               if "$nft" list set inet fw4 "mwan4_${settype}_${family}" >/dev/null 2>&1; then
+                       sets="${sets}mwan4_${settype}_${family} "
+               fi
+       done
+
+       echo "${sets% }"
+}
+
+mwan4_get_strategy_chain() {
+       # Get the nftables strategy chain name for a specific mwan4 strategy
+       # $1 = strategy name
+       # $2 = family (ipv4 or ipv6, defaults to ipv4)
+       # Output: chain name (e.g., "mwan4_strategy_balanced_ipv4")
+       local strategy="$1"
+       local family="${2:-ipv4}"
+       [ -z "$strategy" ] && return 1
+       mwan4_is_running || return 1
+
+       # Check if the chain exists in nftables
+       if "$nft" list chain inet fw4 "mwan4_strategy_${strategy}_${family}" >/dev/null 2>&1; then
+               echo "mwan4_strategy_${strategy}_${family}"
+               return 0
+       fi
+       return 1
+}
+
+mwan4_get_mmx_mask() {
+       # Get the MMX (Multi-WAN Mark) mask used by mwan4
+       # Output: hex mask value (e.g., "0x3F00")
+       mwan4_is_installed || return 1
+
+       local mask
+       config_load 'mwan4'
+       config_get mask 'globals' 'mmx_mask' '0x3F00'
+       echo "$mask"
+}
+
 print_json_bool() { json_init; json_add_boolean "$1" "$2"; json_dump; json_cleanup; }
 print_json_string() { json_init; json_add_string "$1" "$2"; json_dump; json_cleanup; }
 try() {
@@ -515,7 +633,7 @@ get_text() {
                warningOutdatedLuciPackage) printf "The WebUI application is outdated (version %s), please update it" "$1";;
                warningDnsmasqInstanceNoConfdir) printf "Dnsmasq instance '%s' targeted in settings, but it doesn't have its own confdir" "$1";;
                warningDhcpLanForce) printf "Please set 'dhcp.%s.force=1' to speed up service start-up" "$1";;
-               warningSummary) printf "Warnings encountered, please check %s" "$(get_url '#WarningMessagesDetails')";;
+               warningSummary) printf "Warnings encountered, please check %s" "$(get_url '#warning-messages-details')";;
                warningIncompatibleDHCPOption6) printf "Incompatible DHCP Option 6 for interface '%s'" "$1";;
                warningNetifdMissingInterfaceLocal) printf "Netifd setup: option netifd_interface_local is missing, assuming '%s'" "$1";;
                warningUplinkDown) printf "Uplink/WAN interface is still down, going back to boot mode";;
@@ -571,9 +689,9 @@ load_package_config() {
        config_get_bool enabled                   'config' 'enabled'                  '0'
        config_get      fw_mask                   'config' 'fw_mask'                  '00ff0000'
        config_get      icmp_interface            'config' 'icmp_interface'
-       config_get      ignored_interface         'config' 'ignored_interface'
+       config_get_list ignored_interface         'config' 'ignored_interface'
        config_get_bool ipv6_enabled              'config' 'ipv6_enabled'             '0'
-       config_get      lan_device                'config' 'lan_device'               'br-lan'
+       config_get_list lan_device                'config' 'lan_device'               'br-lan'
        config_get_bool nft_rule_counter          'config' 'nft_rule_counter'         '0'
        config_get_bool nft_set_auto_merge        'config' 'nft_set_auto_merge'       '1'
        config_get_bool nft_set_counter           'config' 'nft_set_counter'          '0'
@@ -586,10 +704,10 @@ load_package_config() {
        config_get      prefixlength              'config' 'prefixlength'             '1'
        config_get      procd_boot_trigger_delay  'config' 'procd_boot_trigger_delay' '5000'
        config_get      procd_reload_delay        'config' 'procd_reload_delay'       '0'
-       config_get      resolver_instance         'config' 'resolver_instance'        '*'
+       config_get_list resolver_instance         'config' 'resolver_instance'        '*'
        config_get      resolver_set              'config' 'resolver_set'
        config_get_bool strict_enforcement        'config' 'strict_enforcement'       '1'
-       config_get      supported_interface       'config' 'supported_interface'
+       config_get_list supported_interface       'config' 'supported_interface'
        config_get      uplink_interface          'config' 'uplink_interface'         'wan'
        config_get      uplink_interface6         'config' 'uplink_interface6'        'wan6'
        config_get      uplink_ip_rules_priority  'config' 'uplink_ip_rules_priority' '30000'
@@ -620,9 +738,9 @@ load_package_config() {
 
        local nft_set_flags
        case "${nft_set_flags_interval}:${nft_set_flags_timeout}" in
-               1:1) nft_set_flags="flags interval, timeout${nft_set_timeout:+; timeout $nft_set_timeout}";;
+               1:1) nft_set_flags="flags interval, timeout${nft_set_timeout:+; timeout ${nft_set_timeout}}";;
                1:0) nft_set_flags='flags interval';;
-               0:1) nft_set_flags="flags timeout${nft_set_timeout:+; timeout $nft_set_timeout}";;
+               0:1) nft_set_flags="flags timeout${nft_set_timeout:+; timeout ${nft_set_timeout}}";;
                0:0) nft_set_flags='';;
        esac
 
@@ -639,10 +757,10 @@ load_package_config() {
        nftSetParams=" \
                ${nft_set_auto_merge:+ auto-merge;} \
                ${nft_set_counter:+ counter;} \
-               ${nft_set_flags:+ $nft_set_flags;} \
-               ${nft_set_gc_interval:+ gc_interval "$nft_set_gc_interval";} \
-               ${nft_set_policy:+ policy "$nft_set_policy";} \
-               ${nft_set_timeout:+ timeout "$nft_set_timeout";} \
+               ${nft_set_flags:+ ${nft_set_flags};} \
+               ${nft_set_gc_interval:+ gc_interval "${nft_set_gc_interval}";} \
+               ${nft_set_policy:+ policy "${nft_set_policy}";} \
+               ${nft_set_timeout:+ timeout "${nft_set_timeout}";} \
                "
 
        if [ -x "$agh" ] && [ ! -s "$aghConfigFile" ]; then
@@ -706,7 +824,7 @@ load_environment() {
                return "$_ret"
        }
        local param="$1" validation_result="$2"
-       [ -z "$loadEnvironmentFlag" ] || return 0
+       [ -n "$loadEnvironmentFlag" ] && return 0
        case "$param" in
                on_boot|on_start)
                        output 1 "Loading environment ($param) "
@@ -779,7 +897,7 @@ load_network() {
                        [ -n "$uplinkGW6" ] && output 2 "Found uplink IPv6 gateway (${param}): $uplinkGW6 $__OK__\n"
                ;;
        esac
-       uplinkGW="${uplinkGW4:-$uplinkGW6}"
+       uplinkGW="${uplinkGW4:-${uplinkGW6}}"
 }
 
 is_wan_up() {
@@ -799,6 +917,9 @@ is_wan_up() {
        fi
 }
 
+# nft() overrides the nft binary: all calls append to the atomic nft file.
+# This captures both internal rules and user include script output.
+# Use nft_call() for direct nft binary access.
 nft() { [ -n "$*" ] && nft_file 'add' 'main' "$@"; }
 nft4() { nft "$@"; }
 nft6() { [ -n "$ipv6_enabled" ] || return 0; nft "$@"; }
@@ -840,7 +961,7 @@ nft_file() {
                        echo "" >> "$nftTempFile"
                        # Insert PBR guards at the top of pbr chains so first PBR match wins, while preserving foreign marks.
                        for chain in $chainsList; do
-                               echo "add rule inet $nftTable ${nftPrefix}_${chain} ${nftRuleParams:+$nftRuleParams }meta mark & $fw_mask != 0 return" >> "$nftTempFile"
+                               echo "add rule inet $nftTable ${nftPrefix}_${chain} ${nftRuleParams:+${nftRuleParams} }meta mark & $fw_mask != 0 return" >> "$nftTempFile"
                        done
                ;;
                create:netifd)
@@ -874,6 +995,7 @@ nft_file() {
                        if nft_call -c -f "$nftTempFile" && \
                                cp -f "$nftTempFile" "$nftMainFile"; then
                                output_okn
+                               fw4 -q reload >/dev/null 2>&1
                        else
                                json add error 'errorNftMainFileInstall' "$nftTempFile"
                                output_failn
@@ -915,8 +1037,8 @@ nftset() {
        local command="$1" iface="$2" target="${3:-dst}" type="${4:-ip}" uid="$5" comment="$6" param="$7" mark="$7"
        local nftset4 nftset6 i param4 param6
        local ipv4_error=1 ipv6_error=1
-       nftset4="${nftPrefix}${iface:+_$iface}_4${target:+_$target}${type:+_$type}${uid:+_$uid}"
-       nftset6="${nftPrefix}${iface:+_$iface}_6${target:+_$target}${type:+_$type}${uid:+_$uid}"
+       nftset4="${nftPrefix}${iface:+_${iface}}_4${target:+_${target}}${type:+_${type}}${uid:+_${uid}}"
+       nftset6="${nftPrefix}${iface:+_${iface}}_6${target:+_${target}}${type:+_${type}}${uid:+_${uid}}"
 
        if [ "${#nftset4}" -gt '255' ]; then
                json add error 'errorNftsetNameTooLong' "$nftset4"
@@ -949,8 +1071,8 @@ 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
+                       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
                ;;
                create)
                        case "$type" in
@@ -1044,59 +1166,45 @@ cleanup() {
                                sync
                        ;;
                        main_table)
-                               # Get all rules to delete in one pass (format: "priority suppress_prefixlength_value table_name")
-                               ip -4 rule show | awk '
-                                       /lookup[[:space:]]+main[[:space:]]+suppress_prefixlength[[:space:]]+[0-9]+/ { 
-                                               sub(":", "", $1)
-                                               match($0, /suppress_prefixlength[[:space:]]+([0-9]+)/, arr)
-                                               print $1 " " arr[1] " main"
-                                       }
-                                       /lookup '"$ipTablePrefix"'_/ { 
-                                               sub(":", "", $1)
-                                               match($0, /lookup[[:space:]]+([^[:space:]]+)/, arr)
-                                               print $1 " 0 " arr[1]
-                                       }
-                               ' | while read -r prio len table; do
-                                       if [ "$table" != "main" ] && is_netifd_table "$table"; then
-                                               continue  # Skip netifd-managed tables
-                                       fi
-                                       if [ "$len" = "0" ]; then
-                                               # pbr table rule - try priority deletion first
-                                               ip -4 rule del priority "$prio" 2>/dev/null
-                                       else
-                                               # suppress_prefixlength rule - try priority first, then full spec
-                                               if ! ip -4 rule del priority "$prio" 2>/dev/null; then
-                                                       ip -4 rule del lookup main suppress_prefixlength "$len" priority "$prio" 2>/dev/null || \
-                                                       ip -4 rule del table main suppress_prefixlength "$len" priority "$prio" 2>/dev/null
-                                               fi
-                                       fi
+                               # Delete rules by priority range instead of parsing table names.
+                               # pbr rules occupy: uplink_ip_rules_priority (down) for interfaces,
+                               # uplink_ip_rules_priority+1 for suppress_prefixlength.
+                               local prio_min prio_max prio table line
+                               # max interfaces = fw_mask / uplink_mark (e.g. 0x00ff0000/0x00010000 = 255)
+                               local max_ifaces="$((fw_mask / uplink_mark))"
+                               prio_max="$((uplink_ip_rules_priority))"
+                               prio_min="$((uplink_ip_rules_priority - max_ifaces))"
+                               [ "$prio_min" -lt 1 ] && prio_min="1"
+                               ip -4 rule show | while IFS= read -r line; do
+                                       prio="${line%%:*}"
+                                       [ "$prio" -ge "$prio_min" ] 2>/dev/null && [ "$prio" -le "$prio_max" ] 2>/dev/null || continue
+                                       # Skip netifd-managed fwmark rules (but not WG sport rules)
+                                       case "$line" in
+                                               *fwmark*"lookup ${ipTablePrefix}_"*)
+                                                       table="${line##*lookup }"
+                                                       table="${table%% *}"
+                                                       is_netifd_table "$table" && continue
+                                                       ;;
+                                       esac
+                                       ip -4 rule del priority "$prio" 2>/dev/null
                                done
+                               # Legacy: remove suppress_prefixlength rules created without explicit priority
+                               ip -4 rule del lookup 'main' suppress_prefixlength "$prefixlength" 2>/dev/null
                                # Always attempt IPv6 cleanup regardless of current ipv6_enabled setting
                                # since rules might exist from when IPv6 was previously enabled
-                               ip -6 rule show 2>/dev/null | awk '
-                                       /lookup[[:space:]]+main[[:space:]]+suppress_prefixlength[[:space:]]+[0-9]+/ { 
-                                               sub(":", "", $1)
-                                               match($0, /suppress_prefixlength[[:space:]]+([0-9]+)/, arr)
-                                               print $1 " " arr[1] " main"
-                                       }
-                                       /lookup '"$ipTablePrefix"'_/ { 
-                                               sub(":", "", $1)
-                                               match($0, /lookup[[:space:]]+([^[:space:]]+)/, arr)
-                                               print $1 " 0 " arr[1]
-                                       }
-                               ' | while read -r prio len table; do
-                                       if [ "$table" != "main" ] && is_netifd_table "$table"; then
-                                               continue  # Skip netifd-managed tables
-                                       fi
-                                       if [ "$len" = "0" ]; then
-                                               ip -6 rule del priority "$prio" 2>/dev/null
-                                       else
-                                               if ! ip -6 rule del priority "$prio" 2>/dev/null; then
-                                                       ip -6 rule del lookup main suppress_prefixlength "$len" priority "$prio" 2>/dev/null || \
-                                                       ip -6 rule del table main suppress_prefixlength "$len" priority "$prio" 2>/dev/null
-                                               fi
-                                       fi
+                               ip -6 rule show 2>/dev/null | while IFS= read -r line; do
+                                       prio="${line%%:*}"
+                                       [ "$prio" -ge "$prio_min" ] 2>/dev/null && [ "$prio" -le "$prio_max" ] 2>/dev/null || continue
+                                       case "$line" in
+                                               *fwmark*"lookup ${ipTablePrefix}_"*)
+                                                       table="${line##*lookup }"
+                                                       table="${table%% *}"
+                                                       is_netifd_table "$table" && continue
+                                                       ;;
+                                       esac
+                                       ip -6 rule del priority "$prio" 2>/dev/null
                                done
+                               ip -6 rule del lookup 'main' suppress_prefixlength "$prefixlength" 2>/dev/null
                        ;;
                        main_chains)
                                for i in $chainsList dstnat; do
@@ -1170,14 +1278,17 @@ json() {
 
 resolver() {
        _dnsmasq_instance_get_confdir() {
-               local cfg_file
+               local cfg cfg_file
+               cfg="$(uci -q show "dhcp.${1}" | awk -F'[.=]' 'NR==1{print $2}')"
                [ -z "$dnsmasq_ubus" ] && dnsmasq_ubus="$(ubus call service list '{"name":"dnsmasq"}')"
-               cfg_file="$(echo "$dnsmasq_ubus" | jsonfilter -e "@.dnsmasq.instances.${1}.command" \
+               cfg_file="$(echo "$dnsmasq_ubus" | jsonfilter -e "@.dnsmasq.instances.${cfg}.command" \
                        | awk '{gsub(/\\\//,"/");gsub(/[][",]/,"");for(i=1;i<=NF;i++)if($i=="-C"){print $(i+1);exit}}')"
                awk -F= '/^conf-dir=/{print $2; exit}' "$cfg_file"
        }
        _dnsmasq_instance_config() {
                local cfg="$1" param="$2" confdir
+               [ -s "/etc/config/dhcp" ] || return 0
+               [ -n "$(uci_get dhcp "$cfg")" ] || return 1
                case "$param" in
                        cleanup)
                                # clean up all dnsmasq configs
@@ -1271,8 +1382,8 @@ resolver() {
                                        else
                                                config_foreach _dnsmasq_instance_config 'dnsmasq' 'cleanup'
                                                for i in $resolver_instance; do
-                                                       _dnsmasq_instance_config "@dnsmasq[$i]" \
-                                                       || _dnsmasq_instance_config "$i"
+                                                       _dnsmasq_instance_config "@dnsmasq[$i]" 'setup' \
+                                                       || _dnsmasq_instance_config "$i" 'setup'
                                                done
                                        fi
                                ;;
@@ -1373,9 +1484,13 @@ netifd() {
                                                uci_set 'network' "${rt_name}_ipv4" 'priority' "${lan_priority}"
                                        fi
                                        if [ -n "$ipv6_enabled" ] && [ -n "$netifd_interface_default6" ]; then
+                                               local ipv6_default_lookup="${ipTablePrefix}_${netifd_interface_default6}"
+                                               if is_split_uplink && [ "$netifd_interface_default6" = "$uplink_interface6" ]; then
+                                                       ipv6_default_lookup="${ipTablePrefix}_${uplink_interface4}"
+                                               fi
                                                uci_add 'network' 'rule6' "${rt_name}_ipv6"
                                                uci_set 'network' "${rt_name}_ipv6" 'in' "${iface}"
-                                               uci_set 'network' "${rt_name}_ipv6" 'lookup' "${ipTablePrefix}_${netifd_interface_default6}"
+                                               uci_set 'network' "${rt_name}_ipv6" 'lookup' "$ipv6_default_lookup"
                                                uci_set 'network' "${rt_name}_ipv6" 'priority' "${lan_priority}"
                                        fi
                                        lan_priority="$((lan_priority + 1))"
@@ -1393,7 +1508,7 @@ netifd() {
                local splitUplinkSecondIface
 
                if is_split_uplink; then
-                       if is_wan "$iface" || is_wan6 "$iface"; then
+                       if is_uplink4 "$iface" || is_uplink6 "$iface"; then
                                if [ -n "$_uplinkMark" ] && [ -n "$_uplinkPriority" ] && [ -n "$_uplinkTableID" ]; then
                                        _mark="$_uplinkMark"
                                        _priority="$_uplinkPriority"
@@ -1415,7 +1530,7 @@ netifd() {
                        case "$action" in
                                install)
                                        output 2 "Setting up netifd extensions for $iface... "
-                                       if ! is_split_uplink || ! is_wan6 "$iface"; then
+                                       if ! is_split_uplink || ! is_uplink6 "$iface"; then
                                                uci_set 'network' "${iface}" 'ip4table' "${rt_name}"
                                                uci_add 'network' 'rule' "${rt_name}_ipv4"
                                                uci_set 'network' "${rt_name}_ipv4" 'priority' "${_priority}"
@@ -1423,7 +1538,7 @@ netifd() {
                                                uci_set 'network' "${rt_name}_ipv4" 'mark' "${_mark}"
                                                uci_set 'network' "${rt_name}_ipv4" 'mask' "${fw_mask}"
                                        fi
-                                       if [ -n "$ipv6_enabled" ] && { ! is_split_uplink || ! is_wan "$iface"; }; then
+                                       if [ -n "$ipv6_enabled" ] && { ! is_split_uplink || ! is_uplink4 "$iface"; }; then
                                                uci_set 'network' "${iface}" 'ip6table' "${rt_name}"
                                                uci_add 'network' 'rule6' "${rt_name}_ipv6"
                                                uci_set 'network' "${rt_name}_ipv6" 'priority' "${_priority}"
@@ -1431,7 +1546,7 @@ netifd() {
                                                uci_set 'network' "${rt_name}_ipv6" 'mark' "${_mark}"
                                                uci_set 'network' "${rt_name}_ipv6" 'mask' "${fw_mask}"
                                        fi
-                                       if ! is_split_uplink || ! is_wan6 "$iface"; then
+                                       if ! is_split_uplink || ! is_uplink6 "$iface"; then
                                        [ "$rt_name" = 'main' ] || sed -i "\#${rt_name}\$#d" "$rtTablesFile" >/dev/null 2>&1
                                        [ "$rt_name" = 'main' ] || echo "${_tid} ${rt_name}" >> "$rtTablesFile"
                                                nft_file 'sed' 'temp' "\#${_mark}#d"
@@ -1443,18 +1558,18 @@ netifd() {
                                        fi
                                        local dscp="$(uci_get "$packageName" 'config' "${iface}_dscp")"
                                        if [ "${dscp:-0}" -ge '1' ] && [ "${dscp:-0}" -le '63' ]; then
-                                               if ! is_split_uplink || ! is_wan6 "$iface"; then
+                                               if ! is_split_uplink || ! is_uplink6 "$iface"; then
                                                        nft add rule inet "$nftTable" "${nftPrefix}_prerouting ${nftIPv4Flag} dscp ${dscp} ${nftRuleParams} goto ${nftPrefix}_mark_${_mark}"
                                                fi
-                                               if [ -n "$ipv6_enabled" ] && { ! is_split_uplink || ! is_wan "$iface"; }; then
+                                               if [ -n "$ipv6_enabled" ] && { ! is_split_uplink || ! is_uplink4 "$iface"; }; then
                                                        nft add rule inet "$nftTable" "${nftPrefix}_prerouting ${nftIPv6Flag} dscp ${dscp} ${nftRuleParams} goto ${nftPrefix}_mark_${_mark}"
                                                fi
                                        fi
                                        if [ "$iface" = "$icmp_interface" ]; then
-                                               if ! is_split_uplink || ! is_wan6 "$iface"; then
+                                               if ! is_split_uplink || ! is_uplink6 "$iface"; then
                                                        nft add rule inet "$nftTable" "${nftPrefix}_output ${nftIPv4Flag} protocol icmp ${nftRuleParams} goto ${nftPrefix}_mark_${_mark}"
                                                fi
-                                               if [ -n "$ipv6_enabled" ] && { ! is_split_uplink || ! is_wan "$iface"; }; then
+                                               if [ -n "$ipv6_enabled" ] && { ! is_split_uplink || ! is_uplink4 "$iface"; }; then
                                                        nft add rule inet "$nftTable" "${nftPrefix}_output ${nftIPv6Flag} protocol icmp ${nftRuleParams} goto ${nftPrefix}_mark_${_mark}"
                                                fi
                                        fi
@@ -1528,7 +1643,7 @@ netifd() {
        esac
 
        nft_file 'create' 'netifd'
-       output 1 "Netifd extensions $action ${target_iface:+on $target_iface }"
+       output 1 "Netifd extensions $action ${target_iface:+on ${target_iface} }"
        uci_remove 'network' 'rule' "main_ipv4"  2>/dev/null
        uci_remove 'network' 'rule6' "main_ipv6" 2>/dev/null
        config_load 'network'
@@ -1557,7 +1672,7 @@ netifd() {
        uci_commit "$packageName"
        uci_commit 'network'
        sync
-       output "Restarting network ${action:+(on_$action) }"
+       output "Restarting network ${action:+(on_${action}) }"
        { /etc/init.d/network 'reload'; /etc/init.d/firewall 'reload'; } >/dev/null 2>&1 && output_okbn || output_failn
 }
 
@@ -1606,11 +1721,11 @@ dns_policy_routing() {
                        value="$src_addr"
                        first_value="$(str_first_word "$value")"
                        if is_phys_dev "$first_value"; then
-                               param4="${param4:+$param4 }iifname ${negation:+$negation }{ $(inline_set "$value") }"
-                               param6="${param6:+$param6 }iifname ${negation:+$negation }{ $(inline_set "$value") }"
+                               param4="${param4:+${param4} }iifname ${negation:+${negation} }{ $(inline_set "$value") }"
+                               param6="${param6:+${param6} }iifname ${negation:+${negation} }{ $(inline_set "$value") }"
                        elif is_mac_address "$first_value"; then
-                               param4="${param4:+$param4 }ether saddr ${negation:+$negation }{ $(inline_set "$value") }"
-                               param6="${param6:+$param6 }ether saddr ${negation:+$negation }{ $(inline_set "$value") }"
+                               param4="${param4:+${param4} }ether saddr ${negation:+${negation} }{ $(inline_set "$value") }"
+                               param6="${param6:+${param6} }ether saddr ${negation:+${negation} }{ $(inline_set "$value") }"
                        elif is_domain "$first_value"; then
                                local inline_set_ipv4='' inline_set_ipv6='' d=''
                                for d in $value; do
@@ -1620,17 +1735,17 @@ dns_policy_routing() {
                                        if [ -z "${resolved_ipv4}${resolved_ipv6}" ]; then
                                                json add error 'errorFailedToResolve' "$d"
                                        else
-                                       [ -n "$resolved_ipv4" ] && inline_set_ipv4="${inline_set_ipv4:+$inline_set_ipv4, }$resolved_ipv4"
-                                       [ -n "$resolved_ipv6" ] && inline_set_ipv6="${inline_set_ipv6:+$inline_set_ipv6, }$resolved_ipv6"
+                                       [ -n "$resolved_ipv4" ] && inline_set_ipv4="${inline_set_ipv4:+${inline_set_ipv4}, }$resolved_ipv4"
+                                       [ -n "$resolved_ipv6" ] && inline_set_ipv6="${inline_set_ipv6:+${inline_set_ipv6}, }$resolved_ipv6"
                                        fi
                                done
                                [ -n "$inline_set_ipv4" ] || inline_set_ipv4_empty_flag='true'
                                [ -n "$inline_set_ipv6" ] || inline_set_ipv6_empty_flag='true'
-                               param4="${param4:+$param4 }${nftIPv4Flag} saddr ${negation:+$negation }{ $inline_set_ipv4 }"
-                               param6="${param6:+$param6 }${nftIPv6Flag} saddr ${negation:+$negation }{ $inline_set_ipv6 }"
+                               param4="${param4:+${param4} }${nftIPv4Flag} saddr ${negation:+${negation} }{ $inline_set_ipv4 }"
+                               param6="${param6:+${param6} }${nftIPv6Flag} saddr ${negation:+${negation} }{ $inline_set_ipv6 }"
                        else
-                               param4="${param4:+$param4 }${nftIPv4Flag} saddr ${negation:+$negation }{ $(inline_set "$value") }"
-                               param6="${param6:+$param6 }${nftIPv6Flag} saddr ${negation:+$negation }{ $(inline_set "$value") }"
+                               param4="${param4:+${param4} }${nftIPv4Flag} saddr ${negation:+${negation} }{ $(inline_set "$value") }"
+                               param6="${param6:+${param6} }${nftIPv6Flag} saddr ${negation:+${negation} }{ $(inline_set "$value") }"
                        fi
                fi
 
@@ -1740,17 +1855,17 @@ policy_routing() {
                        fi
                        first_value_src="$(str_first_word "$value")"
                        if is_phys_dev "$first_value_src"; then
-                               param4="${param4:+$param4 }iifname ${negation:+$negation }{ $(inline_set "$value") }"
-                               param6="${param6:+$param6 }iifname ${negation:+$negation }{ $(inline_set "$value") }"
+                               param4="${param4:+${param4} }iifname ${negation:+${negation} }{ $(inline_set "$value") }"
+                               param6="${param6:+${param6} }iifname ${negation:+${negation} }{ $(inline_set "$value") }"
                        elif is_mac_address "$first_value_src"; then
-                               param4="${param4:+$param4 }ether saddr ${negation:+$negation }{ $(inline_set "$value") }"
-                               param6="${param6:+$param6 }ether saddr ${negation:+$negation }{ $(inline_set "$value") }"
+                               param4="${param4:+${param4} }ether saddr ${negation:+${negation} }{ $(inline_set "$value") }"
+                               param6="${param6:+${param6} }ether saddr ${negation:+${negation} }{ $(inline_set "$value") }"
                        elif is_domain "$first_value_src"; then
                                local target='src' type='ip'
                                if resolver 'create_resolver_set' "$iface" "$target" "$type" "$uid" "$name" && \
                                        resolver 'add_resolver_element' "$iface" "$target" "$type" "$uid" "$name" "$value"; then
-                                       param4="${param4:+$param4 }${nftIPv4Flag} saddr ${negation:+$negation }@${nftPrefix}_${iface}_4_${target}_${type}_${uid}${nftset_suffix}"
-                                       param6="${param6:+$param6 }${nftIPv6Flag} saddr ${negation:+$negation }@${nftPrefix}_${iface}_6_${target}_${type}_${uid}${nftset_suffix}"
+                                       param4="${param4:+${param4} }${nftIPv4Flag} saddr ${negation:+${negation} }@${nftPrefix}_${iface}_4_${target}_${type}_${uid}${nftset_suffix}"
+                                       param6="${param6:+${param6} }${nftIPv6Flag} saddr ${negation:+${negation} }@${nftPrefix}_${iface}_6_${target}_${type}_${uid}${nftset_suffix}"
                                else
                                        local inline_set_ipv4='' inline_set_ipv6='' d=''
                                        unset src_inline_set_ipv4_empty_flag
@@ -1762,18 +1877,18 @@ policy_routing() {
                                                if [ -z "${resolved_ipv4}${resolved_ipv6}" ]; then
                                                        json add error 'errorFailedToResolve' "$d"
                                                else
-                                               [ -n "$resolved_ipv4" ] && inline_set_ipv4="${inline_set_ipv4:+$inline_set_ipv4, }$resolved_ipv4"
-                                               [ -n "$resolved_ipv6" ] && inline_set_ipv6="${inline_set_ipv6:+$inline_set_ipv6, }$resolved_ipv6"
+                                               [ -n "$resolved_ipv4" ] && inline_set_ipv4="${inline_set_ipv4:+${inline_set_ipv4}, }$resolved_ipv4"
+                                               [ -n "$resolved_ipv6" ] && inline_set_ipv6="${inline_set_ipv6:+${inline_set_ipv6}, }$resolved_ipv6"
                                                fi
                                        done
                                        [ -n "$inline_set_ipv4" ] || src_inline_set_ipv4_empty_flag='true'
                                        [ -n "$inline_set_ipv6" ] || src_inline_set_ipv6_empty_flag='true'
-                                       param4="${param4:+$param4 }${nftIPv4Flag} saddr ${negation:+$negation }{ $inline_set_ipv4 }"
-                                       param6="${param6:+$param6 }${nftIPv6Flag} saddr ${negation:+$negation }{ $inline_set_ipv6 }"
+                                       param4="${param4:+${param4} }${nftIPv4Flag} saddr ${negation:+${negation} }{ $inline_set_ipv4 }"
+                                       param6="${param6:+${param6} }${nftIPv6Flag} saddr ${negation:+${negation} }{ $inline_set_ipv6 }"
                                fi
                        else
-                               param4="${param4:+$param4 }${nftIPv4Flag} saddr ${negation:+$negation }{ $(inline_set "$value") }"
-                               param6="${param6:+$param6 }${nftIPv6Flag} saddr ${negation:+$negation }{ $(inline_set "$value") }"
+                               param4="${param4:+${param4} }${nftIPv4Flag} saddr ${negation:+${negation} }{ $(inline_set "$value") }"
+                               param6="${param6:+${param6} }${nftIPv6Flag} saddr ${negation:+${negation} }{ $(inline_set "$value") }"
                        fi
                fi
 
@@ -1785,14 +1900,14 @@ policy_routing() {
                        fi
                        first_value_dest="$(str_first_word "$value")"
                        if is_phys_dev "$first_value_dest"; then
-                               param4="${param4:+$param4 }oifname ${negation:+$negation }{ $(inline_set "$value") }"
-                               param6="${param6:+$param6 }oifname ${negation:+$negation }{ $(inline_set "$value") }"
+                               param4="${param4:+${param4} }oifname ${negation:+${negation} }{ $(inline_set "$value") }"
+                               param6="${param6:+${param6} }oifname ${negation:+${negation} }{ $(inline_set "$value") }"
                        elif is_domain "$first_value_dest"; then
                                local target='dst' type='ip'
                                if resolver 'create_resolver_set' "$iface" "$target" "$type" "$uid" "$name" && \
                                        resolver 'add_resolver_element' "$iface" "$target" "$type" "$uid" "$name" "$value"; then
-                                       param4="${param4:+$param4 }${nftIPv4Flag} daddr ${negation:+$negation }@${nftPrefix}_${iface}_4_${target}_${type}_${uid}${nftset_suffix}"
-                                       param6="${param6:+$param6 }${nftIPv6Flag} daddr ${negation:+$negation }@${nftPrefix}_${iface}_6_${target}_${type}_${uid}${nftset_suffix}"
+                                       param4="${param4:+${param4} }${nftIPv4Flag} daddr ${negation:+${negation} }@${nftPrefix}_${iface}_4_${target}_${type}_${uid}${nftset_suffix}"
+                                       param6="${param6:+${param6} }${nftIPv6Flag} daddr ${negation:+${negation} }@${nftPrefix}_${iface}_6_${target}_${type}_${uid}${nftset_suffix}"
                                else
                                        local inline_set_ipv4='' inline_set_ipv6='' d=''
                                        unset dest_inline_set_ipv4_empty_flag
@@ -1804,18 +1919,18 @@ policy_routing() {
                                                if [ -z "${resolved_ipv4}${resolved_ipv6}" ]; then
                                                        json add error 'errorFailedToResolve' "$d"
                                                else
-                                               [ -n "$resolved_ipv4" ] && inline_set_ipv4="${inline_set_ipv4:+$inline_set_ipv4, }$resolved_ipv4"
-                                               [ -n "$resolved_ipv6" ] && inline_set_ipv6="${inline_set_ipv6:+$inline_set_ipv6, }$resolved_ipv6"
+                                               [ -n "$resolved_ipv4" ] && inline_set_ipv4="${inline_set_ipv4:+${inline_set_ipv4}, }$resolved_ipv4"
+                                               [ -n "$resolved_ipv6" ] && inline_set_ipv6="${inline_set_ipv6:+${inline_set_ipv6}, }$resolved_ipv6"
                                                fi
                                        done
                                        [ -n "$inline_set_ipv4" ] || dest_inline_set_ipv4_empty_flag='true'
                                        [ -n "$inline_set_ipv6" ] || dest_inline_set_ipv6_empty_flag='true'
-                                       param4="${param4:+$param4 }${nftIPv4Flag} daddr ${negation:+$negation }{ $inline_set_ipv4 }"
-                                       param6="${param6:+$param6 }${nftIPv6Flag} daddr ${negation:+$negation }{ $inline_set_ipv6 }"
+                                       param4="${param4:+${param4} }${nftIPv4Flag} daddr ${negation:+${negation} }{ $inline_set_ipv4 }"
+                                       param6="${param6:+${param6} }${nftIPv6Flag} daddr ${negation:+${negation} }{ $inline_set_ipv6 }"
                                fi
                        else
-                               param4="${param4:+$param4 }${nftIPv4Flag} daddr ${negation:+$negation }{ $(inline_set "$value") }"
-                               param6="${param6:+$param6 }${nftIPv6Flag} daddr ${negation:+$negation }{ $(inline_set "$value") }"
+                               param4="${param4:+${param4} }${nftIPv4Flag} daddr ${negation:+${negation} }{ $(inline_set "$value") }"
+                               param6="${param6:+${param6} }${nftIPv6Flag} daddr ${negation:+${negation} }{ $(inline_set "$value") }"
                        fi
                fi
 
@@ -1825,8 +1940,8 @@ policy_routing() {
                        else
                                unset negation; value="$src_port";
                        fi
-                       param4="${param4:+$param4 }${proto_i:+$proto_i }sport ${negation:+$negation }{ $(inline_set "$value") }"
-                       param6="${param6:+$param6 }${proto_i:+$proto_i }sport ${negation:+$negation }{ $(inline_set "$value") }"
+                       param4="${param4:+${param4} }${proto_i:+${proto_i} }sport ${negation:+${negation} }{ $(inline_set "$value") }"
+                       param6="${param6:+${param6} }${proto_i:+${proto_i} }sport ${negation:+${negation} }{ $(inline_set "$value") }"
                fi
 
                if [ -n "$dest_port" ]; then
@@ -1835,8 +1950,8 @@ policy_routing() {
                        else
                                unset negation; value="$dest_port";
                        fi
-                       param4="${param4:+$param4 }${proto_i:+$proto_i }dport ${negation:+$negation }{ $(inline_set "$value") }"
-                       param6="${param6:+$param6 }${proto_i:+$proto_i }dport ${negation:+$negation }{ $(inline_set "$value") }"
+                       param4="${param4:+${param4} }${proto_i:+${proto_i} }dport ${negation:+${negation} }{ $(inline_set "$value") }"
+                       param6="${param6:+${param6} }${proto_i:+${proto_i} }dport ${negation:+${negation} }{ $(inline_set "$value") }"
                fi
 
                if is_tor "$iface"; then
@@ -1918,7 +2033,7 @@ dns_policy_process() {
                if is_url "$i"; then
                        i="$(process_url "$i")"
                fi
-               j="${j:+$j }$i"
+               j="${j:+${j} }$i"
        done
        src_addr="$j"
 
@@ -1931,9 +2046,9 @@ dns_policy_process() {
                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}"
+                                               dest_dns_ipv4="${dest_dns_ipv4:-${d}}"
                                        elif is_ipv6 "$d"; then
-                                               dest_dns_ipv6="${dest_dns_ipv6:-$d}"
+                                               dest_dns_ipv6="${dest_dns_ipv6:-${d}}"
                                        fi
                                fi
                        done
@@ -2006,7 +2121,7 @@ policy_process() {
                if is_url "$i"; then
                        i="$(process_url "$i")"
                fi
-               j="${j:+$j }$i"
+               j="${j:+${j} }$i"
        done
        src_addr="$j"
 
@@ -2015,7 +2130,7 @@ policy_process() {
                if is_url "$i"; then
                        i="$(process_url "$i")"
                fi
-               j="${j:+$j }$i"
+               j="${j:+${j} }$i"
        done
        dest_addr="$j"
 
@@ -2040,8 +2155,8 @@ policy_process() {
                                                        continue
                                        fi
                                        policy_routing "$name" "$interface" "$filtered_value_src_addr" "$src_port" "$filtered_value_dest_addr" "$dest_port" "$proto" "$chain" "$uid"
-                                       processed_value_src_addr="${processed_value_src_addr:+$processed_value_src_addr }$filtered_value_src_addr"
-                                       processed_value_dest_addr="${processed_value_dest_addr:+$processed_value_dest_addr }$filtered_value_dest_addr"
+                                       processed_value_src_addr="${processed_value_src_addr:+${processed_value_src_addr} }$filtered_value_src_addr"
+                                       processed_value_dest_addr="${processed_value_dest_addr:+${processed_value_dest_addr} }$filtered_value_dest_addr"
                                fi
                        done
                fi
@@ -2104,17 +2219,17 @@ interface_routing() {
                                fi
 
                                if ! nft_file 'match' 'temp' "${nftPrefix}_mark_${mark}"; then
-                                       try nft add chain inet "$nftTable" "${nftPrefix}_mark_${mark}" || ipv4_error=1
-                                       try nft add rule inet "$nftTable" "${nftPrefix}_mark_${mark} ${nftRuleParams} meta mark set (meta mark & ${fw_maskXor}) | ${mark}" || ipv4_error=1
-                                       try nft add rule inet "$nftTable" "${nftPrefix}_mark_${mark} return" || ipv4_error=1
+                                       nft add chain inet "$nftTable" "${nftPrefix}_mark_${mark}"
+                                       nft add rule inet "$nftTable" "${nftPrefix}_mark_${mark} ${nftRuleParams} meta mark set (meta mark & ${fw_maskXor}) | ${mark}"
+                                       nft add rule inet "$nftTable" "${nftPrefix}_mark_${mark} return"
                                fi
 
                                dscp="$(uci_get "$packageName" 'config' "${iface}_dscp" '0')"
                                if [ "$dscp" -ge '1' ] && [ "$dscp" -le '63' ]; then
-                                       try nft add rule inet "$nftTable" "${nftPrefix}_prerouting ${nftIPv4Flag} dscp ${dscp} ${nftRuleParams} goto ${nftPrefix}_mark_${mark}" || s=1
+                                       nft add rule inet "$nftTable" "${nftPrefix}_prerouting ${nftIPv4Flag} dscp ${dscp} ${nftRuleParams} goto ${nftPrefix}_mark_${mark}"
                                fi
                                if [ "$iface" = "$icmp_interface" ]; then
-                                       try nft add rule inet "$nftTable" "${nftPrefix}_output ${nftIPv4Flag} protocol icmp ${nftRuleParams} goto ${nftPrefix}_mark_${mark}" || s=1
+                                       nft add rule inet "$nftTable" "${nftPrefix}_output ${nftIPv4Flag} protocol icmp ${nftRuleParams} goto ${nftPrefix}_mark_${mark}"
                                fi
                        fi
 
@@ -2142,17 +2257,17 @@ interface_routing() {
                                fi
 
                                if ! nft_file 'match' 'temp' "${nftPrefix}_mark_${mark}"; then
-                                       try nft add chain inet "$nftTable" "${nftPrefix}_mark_${mark}" || ipv6_error=1
-                                       try nft add rule inet "$nftTable" "${nftPrefix}_mark_${mark} ${nftRuleParams} meta mark set (meta mark & ${fw_maskXor}) | ${mark}" || ipv6_error=1
-                                       try nft add rule inet "$nftTable" "${nftPrefix}_mark_${mark} return" || ipv6_error=1
+                                       nft add chain inet "$nftTable" "${nftPrefix}_mark_${mark}"
+                                       nft add rule inet "$nftTable" "${nftPrefix}_mark_${mark} ${nftRuleParams} meta mark set (meta mark & ${fw_maskXor}) | ${mark}"
+                                       nft add rule inet "$nftTable" "${nftPrefix}_mark_${mark} return"
                                fi
 
                                dscp="$(uci_get "$packageName" 'config' "${iface}_dscp" '0')"
                                if [ "$dscp" -ge '1' ] && [ "$dscp" -le '63' ]; then
-                                       try nft add rule inet "$nftTable" "${nftPrefix}_prerouting ${nftIPv6Flag} dscp ${dscp} ${nftRuleParams} goto ${nftPrefix}_mark_${mark}" || s=1
+                                       nft add rule inet "$nftTable" "${nftPrefix}_prerouting ${nftIPv6Flag} dscp ${dscp} ${nftRuleParams} goto ${nftPrefix}_mark_${mark}"
                                fi
                                if [ "$iface" = "$icmp_interface" ]; then
-                                       try nft add rule inet "$nftTable" "${nftPrefix}_output ${nftIPv6Flag} protocol icmp ${nftRuleParams} goto ${nftPrefix}_mark_${mark}" || s=1
+                                       nft add rule inet "$nftTable" "${nftPrefix}_output ${nftIPv6Flag} protocol icmp ${nftRuleParams} goto ${nftPrefix}_mark_${mark}"
                                fi
                        fi
 
@@ -2171,10 +2286,8 @@ interface_routing() {
                ;;
                delete|destroy)
                        is_netifd_interface "$iface" && return 0
-                       ip -4 rule del table 'main' suppress_prefixlength "$prefixlength" prio "$((priority - 1))" >/dev/null 2>&1
                        ip -4 rule del table 'main' prio "$((priority - 1000))" >/dev/null 2>&1
                        ip -4 rule del table "$tid" prio "$priority" >/dev/null 2>&1
-                       ip -6 rule del table 'main' suppress_prefixlength "$prefixlength" prio "$((priority - 1))" >/dev/null 2>&1
                        ip -6 rule del table 'main' prio "$((priority - 1000))" >/dev/null 2>&1
                        ip -6 rule del table "$tid" prio "$priority" >/dev/null 2>&1
                        ip -4 rule flush table "$tid" >/dev/null 2>&1
@@ -2275,12 +2388,6 @@ process_interface() {
                                return 0
                        ;;
                        create_global_rules)
-                               ip -4 rule del lookup 'main' suppress_prefixlength "$prefixlength" priority "$ifacePriority" >/dev/null 2>&1
-                               try ip -4 rule add lookup 'main' suppress_prefixlength "$prefixlength" priority "$ifacePriority" || ipv4_error=1
-                               if [ -n "$ipv6_enabled" ]; then
-                                       ip -6 rule del lookup 'main' suppress_prefixlength "$prefixlength" priority "$ifacePriority" >/dev/null 2>&1
-                                       try ip -6 rule add lookup 'main' suppress_prefixlength "$prefixlength" priority "$ifacePriority" || ipv6_error=1
-                               fi
                                _wg_server() {
                                        local iface="$1"
                                        if is_wg_server "$iface" && ! is_ignored_interface "$iface"; then
@@ -2289,17 +2396,27 @@ process_interface() {
                                                config_get listen_port "$iface" 'listen_port'
                                                if [ "$disabled" != '1' ] && [ -n "$listen_port" ]; then
                                                        if [ -n "$uplink_interface4" ]; then
-                                                               ip rule del sport "$listen_port" table "${ipTablePrefix}_${uplink_interface4}" >/dev/null 2>&1
-                                                               ip rule add sport "$listen_port" table "${ipTablePrefix}_${uplink_interface4}" >/dev/null 2>&1
+                                                               #ip rule del sport "$listen_port" table "${ipTablePrefix}_${uplink_interface4}" priority "$ifacePriority" >/dev/null 2>&1
+                                                               ip rule add sport "$listen_port" table "${ipTablePrefix}_${uplink_interface4}" priority "$ifacePriority" >/dev/null 2>&1
                                                                if [ -n "$ipv6_enabled" ]; then
-                                                                       ip -6 rule del sport "$listen_port" table "${ipTablePrefix}_${uplink_interface4}" >/dev/null 2>&1
-                                                                       ip -6 rule add sport "$listen_port" table "${ipTablePrefix}_${uplink_interface4}" >/dev/null 2>&1
+                                                                       #ip -6 rule del sport "$listen_port" table "${ipTablePrefix}_${uplink_interface4}" priority "$ifacePriority" >/dev/null 2>&1
+                                                                       ip -6 rule add sport "$listen_port" table "${ipTablePrefix}_${uplink_interface4}" priority "$ifacePriority" >/dev/null 2>&1
                                                                fi
+                                                               ifacePriority="$((ifacePriority - 1))"
                                                        fi
                                                fi
                                        fi
                                }
                                config_foreach _wg_server 'interface'
+
+                               #ip -4 rule del priority "$ifacePriority" >/dev/null 2>&1
+                               #ip -4 rule del lookup 'main' suppress_prefixlength "$prefixlength" >/dev/null 2>&1
+                               try ip -4 rule add lookup 'main' suppress_prefixlength "$prefixlength" priority "$ifacePriority" || ipv4_error=1
+                               if [ -n "$ipv6_enabled" ]; then
+                                       #ip -6 rule del priority "$ifacePriority" >/dev/null 2>&1
+                                       #ip -6 rule del lookup 'main' suppress_prefixlength "$prefixlength" >/dev/null 2>&1
+                                       try ip -6 rule add lookup 'main' suppress_prefixlength "$prefixlength" priority "$ifacePriority" || ipv6_error=1
+                               fi
                                return 0
                        ;;
                esac
@@ -2344,7 +2461,7 @@ process_interface() {
 
        network_get_device dev4 "$iface"
        [ -z "$dev4" ] && network_get_physdev dev4 "$iface"
-       if is_wan "$iface" && [ -n "$uplink_interface6" ]; then
+       if is_uplink4 "$iface" && [ -n "$uplink_interface6" ]; then
                network_get_device dev6 "$uplink_interface6"
                [ -z "$dev6" ] && network_get_physdev dev6 "$uplink_interface6"
        fi
@@ -2357,7 +2474,7 @@ process_interface() {
        local splitUplinkSecondIface
 
        if is_split_uplink; then
-               if is_wan "$iface" || is_wan6 "$iface"; then
+               if is_uplink4 "$iface" || is_uplink6 "$iface"; then
                        if [ -n "$_uplinkMark" ] && [ -n "$_uplinkPriority" ] && [ -n "$_uplinkTableID" ]; then
                                _mark="$_uplinkMark"
                                _priority="$_uplinkPriority"
@@ -2380,7 +2497,7 @@ process_interface() {
                        eval "enum_mark_${iface//-/_}"='$_mark'
                        eval "enum_priority_${iface//-/_}"='$_priority'
                        eval "enum_tid_${iface//-/_}"='$_tid'
-                       ifacesTriggers="${ifacesTriggers:+$ifacesTriggers }$iface"
+                       ifacesTriggers="${ifacesTriggers:+${ifacesTriggers} }$iface"
                ;;
                create)
                        if [ -z "$splitUplinkSecondIface" ]; then
@@ -2395,9 +2512,9 @@ process_interface() {
                        dispGw4="${gw4:-0.0.0.0}"
                        dispGw6="${gw6:-::/0}"
                        if is_split_uplink; then
-                               if is_wan "$iface"; then
+                               if is_uplink4 "$iface"; then
                                        gw6=""; dev6=""
-                               elif is_wan6 "$iface"; then
+                               elif is_uplink6 "$iface"; then
                                        gw4=""; dev4=""
                                fi
                        fi
@@ -2408,11 +2525,11 @@ process_interface() {
                        if is_netifd_interface_default "$iface"; then
                                [ "$verbosity" = '1' ] && dispStatus="$_OKB_" || dispStatus="$__OKB__"
                        fi
-                       displayText="${iface}/${dispDev:+$dispDev/}${dispGw4}${ipv6_enabled:+/$dispGw6}"
+                       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"
-                               gatewaySummary="${gatewaySummary}${displayText}${dispStatus:+ $dispStatus}\n"
+                               gatewaySummary="${gatewaySummary}${displayText}${dispStatus:+ ${dispStatus}}\n"
                                if is_netifd_interface "$iface"; then output_okb; else output_ok; fi
                        else
                                json add error 'errorFailedSetup' "$displayText"
@@ -2428,9 +2545,9 @@ process_interface() {
                        eval "mark_${iface//-/_}"='$_mark'
                        eval "tid_${iface//-/_}"='$_tid'
                        if is_split_uplink; then
-                               if is_wan "$iface"; then
+                               if is_uplink4 "$iface"; then
                                        dev6=""
-                               elif is_wan6 "$iface"; then
+                               elif is_uplink6 "$iface"; then
                                        dev4=""
                                fi
                        fi
@@ -2441,7 +2558,7 @@ process_interface() {
                        if is_netifd_interface_default "$iface"; then
                                [ "$verbosity" = '1' ] && dispStatus="$_OKB_" || dispStatus="$__OKB__"
                        fi
-                       displayText="${iface}/${dispDev:+$dispDev/}"
+                       displayText="${iface}/${dispDev:+${dispDev}/}"
                        interface_routing 'create_user_set' "$_tid" "$_mark" "$iface" "" "$dev4" "" "$dev6" "$_priority"
                ;;
                destroy)
@@ -2453,9 +2570,9 @@ process_interface() {
                        eval "mark_${iface//-/_}"='$_mark'
                        eval "tid_${iface//-/_}"='$_tid'
                        if is_split_uplink; then
-                               if is_wan "$iface"; then
+                               if is_uplink4 "$iface"; then
                                        dev6=""
-                               elif is_wan6 "$iface"; then
+                               elif is_uplink6 "$iface"; then
                                        dev4=""
                                fi
                        fi
@@ -2466,7 +2583,7 @@ process_interface() {
                        if is_netifd_interface_default "$iface"; then
                                [ "$verbosity" = '1' ] && dispStatus="$_OKB_" || dispStatus="$__OKB__"
                        fi
-                       displayText="${iface}/${dispDev:+$dispDev}"
+                       displayText="${iface}/${dispDev:+${dispDev}}"
                        output 2 "Removing routing for '$displayText' "
                        interface_routing 'destroy' "$_tid" "$_mark" "$iface" "" "$dev4" "" "$dev6" "$_priority"
                        if is_netifd_interface "$iface"; then output_okb; else output_ok; fi
@@ -2484,9 +2601,9 @@ process_interface() {
                        dispGw4="${gw4:-0.0.0.0}"
                        dispGw6="${gw6:-::/0}"
                        if is_split_uplink; then
-                               if is_wan "$iface"; then
+                               if is_uplink4 "$iface"; then
                                        gw6=""; dev6=""
-                               elif is_wan6 "$iface"; then
+                               elif is_uplink6 "$iface"; then
                                        gw4=""; dev4=""
                                fi
                        fi
@@ -2497,8 +2614,8 @@ process_interface() {
                        if is_netifd_interface_default "$iface"; then
                                [ "$verbosity" = '1' ] && dispStatus="$_OKB_" || dispStatus="$__OKB__"
                        fi
-                       displayText="${iface}/${dispDev:+$dispDev/}${dispGw4}${ipv6_enabled:+/$dispGw6}"
-                       gatewaySummary="${gatewaySummary}${displayText}${dispStatus:+ $dispStatus}\n"
+                       displayText="${iface}/${dispDev:+${dispDev}/}${dispGw4}${ipv6_enabled:+/${dispGw6}}"
+                       gatewaySummary="${gatewaySummary}${displayText}${dispStatus:+ ${dispStatus}}\n"
                ;;
                reload_interface)
                        if [ -z "$splitUplinkSecondIface" ]; then
@@ -2513,9 +2630,9 @@ process_interface() {
                        dispGw4="${gw4:-0.0.0.0}"
                        dispGw6="${gw6:-::/0}"
                        if is_split_uplink; then
-                               if is_wan "$iface"; then
+                               if is_uplink4 "$iface"; then
                                        gw6=""; dev6=""
-                               elif is_wan6 "$iface"; then
+                               elif is_uplink6 "$iface"; then
                                        gw4=""; dev4=""
                                fi
                        fi
@@ -2526,12 +2643,12 @@ process_interface() {
                        if is_netifd_interface_default "$iface"; then
                                [ "$verbosity" = '1' ] && dispStatus="$_OKB_" || dispStatus="$__OKB__"
                        fi
-                       displayText="${iface}/${dispDev:+$dispDev/}${dispGw4}${ipv6_enabled:+/$dispGw6}"
+                       displayText="${iface}/${dispDev:+${dispDev}/}${dispGw4}${ipv6_enabled:+/${dispGw6}}"
                        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"
-                                       gatewaySummary="${gatewaySummary}${displayText}${dispStatus:+ $dispStatus}\n"
+                                       gatewaySummary="${gatewaySummary}${displayText}${dispStatus:+ ${dispStatus}}\n"
                                        if is_netifd_interface "$iface"; then output_okb; else output_ok; fi
                                else
                                        json add error 'errorFailedReload' "$displayText"
@@ -2539,13 +2656,13 @@ process_interface() {
                                fi
                        else
                                json_add_gateway 'skip_interface' "$_tid" "$_mark" "$iface" "$gw4" "$dev4" "$gw6" "$dev6" "$_priority" "$dispStatus"
-                               gatewaySummary="${gatewaySummary}${displayText}${dispStatus:+ $dispStatus}\n"
+                               gatewaySummary="${gatewaySummary}${displayText}${dispStatus:+ ${dispStatus}}\n"
                        fi
                ;;
        esac
 
        if is_split_uplink && [ -z "$splitUplinkSecondIface" ]; then
-               if is_wan "$iface" || is_wan6 "$iface"; then
+               if is_uplink4 "$iface" || is_uplink6 "$iface"; then
                        _uplinkTableID="$_tid"
                fi
        fi
@@ -2638,6 +2755,10 @@ start_service() {
                        local tid enum_tid
                        tid="$(get_rt_tables_id "$reloadedIface")"
                        enum_tid="$(eval echo "\$enum_tid_${reloadedIface//-/_}")"
+                       if is_split_uplink && is_uplink6 "$reloadedIface" && { [ -z "$tid" ] || [ -z "$enum_tid" ]; }; then
+                               tid="$(get_rt_tables_id "$uplink_interface4")"
+                               enum_tid="$(eval echo "\$enum_tid_${uplink_interface4//-/_}")"
+                       fi
                        if [ "$tid" = "$enum_tid" ]; then
                                serviceStartTrigger='on_interface_reload'
                        else
@@ -2730,7 +2851,6 @@ start_service() {
                                output_1_newline
                        fi
                        nft_file 'install' 'main'
-                       resolver 'compare_hash' && resolver 'restart'
                ;;
        esac
 
@@ -2761,15 +2881,15 @@ start_service() {
        procd_close_instance
 }
 
-service_running() { procd_set_config_changed firewall; }
+service_running() { is_service_running; }
 service_started() {
        [ -n "$pbrBootFlag" ] && return 0
        local error warning c
        if nft_file 'exists' 'main'; then
-               procd_set_config_changed firewall
-               [ -n "$gatewaySummary" ] && output "$serviceName (fw4 nft file mode) started with gateways:\n${gatewaySummary}"
+               resolver 'compare_hash' && resolver 'restart'
+               [ -n "$gatewaySummary" ] && output "$serviceName started with gateways:\n${gatewaySummary}"
        else
-               output "$serviceName FAILED TO START in fw4 nft file mode!!!\n"
+               output "$serviceName FAILED TO START!!!\n"
                output "Check the output of nft -c -f $nftTempFile\n"
        fi
        warning="$(json get warning)"
@@ -2779,7 +2899,7 @@ service_started() {
                        info="$(json get warning "$c" 'info')"
                        output_warning "$(get_text "$code" "$info")"
                done
-               output_warning "$(get_text 'warningSummary' "$(get_url '#WarningMessagesDetails')")"
+               output_warning "$(get_text 'warningSummary' "$(get_url '#warning-messages-details')")"
        fi
        error="$(json get error)"
        if [ -n "$error" ]; then
@@ -2788,7 +2908,7 @@ service_started() {
                        info="$(json get error "$c" 'info')"
                        output_error "$(get_text "$code" "$info")"
                done
-               output_error "$(get_text 'errorSummary' "$(get_url '#ErrorMessagesDetails')")"
+               output_error "$(get_text 'errorSummary' "$(get_url '#error-messages-details')")"
        fi
        touch "$packageLockFile"
        if [ -n "$error" ]; then
@@ -2845,7 +2965,7 @@ stop_service() {
        fi
        output 'Resetting routing '
        if nft_file 'delete' 'main' && \
-               cleanup 'main_table' 'rt_tables' 'main_chains' && \
+               cleanup 'main_table' 'rt_tables' && \
                ip route flush cache; then
                output_okn
        else
@@ -2886,13 +3006,13 @@ status_service() {
        if [ -n "$uplink_interface4" ]; then
                network_get_device dev4 "$uplink_interface4"
                [ -z "$dev4" ] && network_get_physdev dev4 "$uplink_interface4"
-               status="${status}Uplink (IPv4): ${uplink_interface4}${dev4:+/$dev4}/${uplinkGW4:-0.0.0.0}.\n"
+               status="${status}Uplink (IPv4): ${uplink_interface4}${dev4:+/${dev4}}/${uplinkGW4:-0.0.0.0}.\n"
        fi
        if [ -n "$uplink_interface6" ]; then
                network_get_device dev6 "$uplink_interface6"
                [ -z "$dev6" ] && network_get_physdev dev6 "$uplink_interface6"
                [ -z "$dev6" ] && dev6="$dev4"
-               status="${status}Uplink (IPv6): ${uplink_interface6}${dev6:+/$dev6}/${uplinkGW6:-::/0}.\n"
+               status="${status}Uplink (IPv6): ${uplink_interface6}${dev6:+/${dev6}}/${uplinkGW6:-::/0}.\n"
        fi
 
        echo "$_SEPARATOR_"
@@ -2936,9 +3056,9 @@ status_service() {
        wanTID=$(($(get_rt_tables_next_id)-tableCount))
        for tid in main $(seq "$wanTID" $((wanTID + tableCount - 1))); do
                status_table="$(grep "^${tid}[[:space:]]" "$rtTablesFile" | awk '{print $2}')"
-               echo "IPv4 table ${tid}${status_table:+ ($status_table)} routes:"
+               echo "IPv4 table ${tid}${status_table:+ (${status_table})} routes:"
                ip -4 route show table "$tid" | sed 's/^/    /'
-               echo "IPv4 table ${tid}${status_table:+ ($status_table)} rules:"
+               echo "IPv4 table ${tid}${status_table:+ (${status_table})} rules:"
                ip -4 rule list table "$tid" | sed 's/^/    /'
                if [ -n "$ipv6_enabled" ]; then
                        echo "$_SEPARATOR_"
@@ -2951,9 +3071,114 @@ status_service() {
        done
 }
 
+print_config_masked() {
+       local file="$1"
+       [ ! -f "/etc/config/$file" ] && return
+       printf "\n===== %s config =====\n" "$file"
+
+       awk -v sq="'" '
+               BEGIN {
+                       masklist = "^(endpoint_host|key|password|preshared_key|private_key|psk|public_key|token|username)$"
+               }
+
+               /^[ \t]*(option|list)[ \t]+/ {
+                       orig = $0
+
+                       # capture indentation
+                       match(orig, /^[ \t]*/)
+                       indent = substr(orig, RSTART, RLENGTH)
+
+                       # capture kind: "option" or "list"
+                       tmp = orig
+                       sub(/^[ \t]*/, "", tmp)
+                       kind = tmp
+                       sub(/[ \t].*$/, "", kind)
+
+                       # remove leading indent + kind
+                       line = orig
+                       sub(/^[ \t]*(option|list)[ \t]+/, "", line)
+
+                       # extract key name
+                       key = line
+                       sub(/[ \t].*$/, "", key)
+
+                       # extract value (rest after key)
+                       sub(/^[^ \t]+[ \t]+/, "", line)
+                       val = line
+                       sub(/^[ \t]+/, "", val)
+                       sub(/[ \t]+$/, "", val)
+
+                       # If this key is in masklist, mask the value (preserve dots and length)
+                       if (key ~ masklist) {
+                               q = ""
+                               if (val ~ "^" sq ".*" sq "$")      { q = sq; val = substr(val, 2, length(val)-2) }
+                               else if (val ~ /^".*"$/)           { q = "\""; val = substr(val, 2, length(val)-2) }
+
+                               masked = val
+                               gsub(/[^.]/, "*", masked)
+
+                               if (q != "")
+                                       print indent kind " " key " " q masked q
+                               else
+                                       print indent kind " " key " " masked
+                               next
+                       }
+
+                       # Otherwise print unchanged for now
+                       print orig
+                       next
+               }
+
+               { print }
+       ' "/etc/config/$file" \
+       | awk '
+               # Do NOT mask IPs (v4) in allowed_ips (option or list)
+               /^[ \t]*(option|list)[ \t]+allowed_ips[ \t]+/ {
+                       print
+                       next
+               }
+
+               {
+                       line = $0
+
+                       # Mask digits inside IPv4-looking tokens, keep dots
+                       while (match(line, /([0-9]{1,3}\.){3}[0-9]{1,3}/)) {
+                               ip = substr(line, RSTART, RLENGTH)
+                               masked = ip
+                               gsub(/[0-9]/, "*", masked)
+                               line = substr(line, 1, RSTART-1) masked substr(line, RSTART+RLENGTH)
+                       }
+
+                       print line
+               }
+       ' \
+       | sed -E 's/([a-fA-F0-9:]{2,}:){1,7}[a-fA-F0-9]{2,}/***/g'
+}
+
+support() {
+       echo "Setting counters and verbosity for diagnostics..."
+       uci set pbr.config.nft_rule_counter='1'
+       uci set pbr.config.nft_set_counter='1'
+       uci set pbr.config.verbosity='2'
+       uci commit pbr
+
+       for cfg in dhcp firewall network pbr; do
+               print_config_masked "$cfg"
+       done
+
+       printf "\n===== ubus call system board =====\n"
+       ubus call system board
+
+       printf "\n===== /etc/init.d/pbr restart =====\n"
+       /etc/init.d/pbr restart
+
+       printf "\n===== /etc/init.d/pbr status (after restart) =====\n"
+       /etc/init.d/pbr status
+}
+
 # shellcheck disable=SC2120
 load_validate_config() {
-       uci_load_validate "$packageName" "$packageName" "$1" "${2}${3:+ $3}" \
+       uci_load_validate "$packageName" "$packageName" "$1" "${2}${3:+ ${3}}" \
                'enabled:bool:0' \
                'strict_enforcement:bool:1' \
                'ipv6_enabled:bool:0' \
@@ -2961,7 +3186,7 @@ load_validate_config() {
                'resolver_instance:list(or(integer, string)):*' \
                'verbosity:range(0,2):2' \
                'uplink_mark:regex("[A-Fa-f0-9]{8}"):00010000' \
-               'uplink_ip_rules_priority:uinteger:30000' \
+               'uplink_ip_rules_priority:range(99,32765):30000' \
                'fw_mask:regex("[A-Fa-f0-9]{8}"):00ff0000' \
                'icmp_interface:or("", tor, uci("network", "@interface"))' \
                'ignored_interface:list(or(tor, uci("network", "@interface")))' \
@@ -2991,7 +3216,7 @@ load_validate_dns_policy() {
        local src_addr
        local dest_dns
        local dest_dns_port
-       uci_load_validate "$packageName" 'dns_policy' "$1" "${2}${3:+ $3}" \
+       uci_load_validate "$packageName" 'dns_policy' "$1" "${2}${3:+ ${3}}" \
                'name:string:Untitled' \
                'enabled:bool:1' \
                'src_addr:list(neg(or(host,network,macaddr,string)))' \
@@ -3011,7 +3236,7 @@ load_validate_policy() {
        local src_port
        local dest_addr
        local dest_port
-       uci_load_validate "$packageName" 'policy' "$1" "${2}${3:+ $3}" \
+       uci_load_validate "$packageName" 'policy' "$1" "${2}${3:+ ${3}}" \
                'name:string:Untitled' \
                'enabled:bool:1' \
                'interface:or("ignore", "tor", regex("xray_.*"), uci("network", "@interface")):wan' \
@@ -3028,7 +3253,7 @@ load_validate_policy() {
 load_validate_include() {
        local path=
        local enabled=
-       uci_load_validate "$packageName" 'include' "$1" "${2}${3:+ $3}" \
+       uci_load_validate "$packageName" 'include' "$1" "${2}${3:+ ${3}}" \
                'path:file' \
                'enabled:bool:0' \
        ;
index 3bbe09b830aa172d63038d4ddee8a03a3d3d8313..2c3c8c76306c5ee2f10503b05315a3c1d680cef0 100644 (file)
@@ -35,7 +35,7 @@ fi
 
 if [ -s "$TARGET_DL_FILE_4" ]; then
        params=
-       while read -r p; do params="${params:+$params, }${p}"; done < "$TARGET_DL_FILE_4"
+       while read -r p; do params="${params:+${params}, }${p}"; done < "$TARGET_DL_FILE_4"
        [ -n "$params" ] && nft "add element $TARGET_TABLE $TARGET_NFTSET_4 { $params }" || _ret=1
 fi
 
@@ -47,7 +47,7 @@ if [ -n "$TARGET_DL_FILE_6" ] && [ ! -s "$TARGET_DL_FILE_6" ]; then
 fi
 if [ -s "$TARGET_DL_FILE_6" ]; then
        params=
-       while read -r p; do params="${params:+$params, }${p}"; done < "$TARGET_DL_FILE_6"
+       while read -r p; do params="${params:+${params}, }${p}"; done < "$TARGET_DL_FILE_6"
        [ -n "$params" ] && nft "add element $TARGET_TABLE $TARGET_NFTSET_6 { $params }" || _ret=1
 fi
 
diff --git a/net/pbr/tests/01_validation/01_ipv4_validation b/net/pbr/tests/01_validation/01_ipv4_validation
new file mode 100644 (file)
index 0000000..5107d75
--- /dev/null
@@ -0,0 +1,37 @@
+#!/bin/bash
+# Test: IPv4 address validation
+. "$(dirname "$0")/../lib/setup.sh"
+
+oneTimeTearDown() { rm -rf "${MOCK_ROOT:-}"; }
+
+testIpv4ValidStandard() {
+       assertTrue "Standard private IP" "is_ipv4 '192.168.1.1'"
+       assertTrue "Class A private" "is_ipv4 '10.0.0.1'"
+       assertTrue "Class B private" "is_ipv4 '172.16.0.1'"
+       assertTrue "Google DNS" "is_ipv4 '8.8.8.8'"
+       assertTrue "All zeros" "is_ipv4 '0.0.0.0'"
+       assertTrue "All ones" "is_ipv4 '255.255.255.255'"
+       assertTrue "Simple IP" "is_ipv4 '1.2.3.4'"
+}
+
+testIpv4ValidCIDR() {
+       assertTrue "CIDR /8" "is_ipv4 '10.0.0.0/8'"
+       assertTrue "CIDR /24" "is_ipv4 '192.168.1.0/24'"
+       assertTrue "CIDR /32" "is_ipv4 '10.0.0.1/32'"
+       assertTrue "Default route" "is_ipv4 '0.0.0.0/0'"
+}
+
+testIpv4Invalid() {
+       assertFalse "Octet > 255" "is_ipv4 '256.1.1.1'"
+       assertFalse "Last octet > 255" "is_ipv4 '1.2.3.256'"
+       assertFalse "Not an IP" "is_ipv4 'not_an_ip'"
+       assertFalse "Empty string" "is_ipv4 ''"
+       assertFalse "Only 3 octets" "is_ipv4 '192.168.1'"
+       assertFalse "5 octets" "is_ipv4 '192.168.1.1.1'"
+       assertFalse "CIDR > 32" "is_ipv4 '192.168.1.1/33'"
+       assertFalse "IPv6 loopback" "is_ipv4 '::1'"
+       assertFalse "IPv6 link-local" "is_ipv4 'fe80::1'"
+       assertFalse "Domain name" "is_ipv4 'example.com'"
+}
+
+. shunit2
diff --git a/net/pbr/tests/01_validation/02_ipv6_validation b/net/pbr/tests/01_validation/02_ipv6_validation
new file mode 100644 (file)
index 0000000..f5bb017
--- /dev/null
@@ -0,0 +1,47 @@
+#!/bin/bash
+# Test: IPv6 address validation and scope detection
+. "$(dirname "$0")/../lib/setup.sh"
+
+oneTimeTearDown() { rm -rf "${MOCK_ROOT:-}"; }
+
+testIpv6Valid() {
+       assertTrue "Loopback" "is_ipv6 '::1'"
+       assertTrue "Link-local" "is_ipv6 'fe80::1'"
+       assertTrue "Documentation prefix" "is_ipv6 '2001:db8::1'"
+       assertTrue "Unique local" "is_ipv6 'fd00::1'"
+       assertTrue "Full address" "is_ipv6 '2001:0db8:85a3::8a2e:0370:7334'"
+       assertTrue "Default route" "is_ipv6 '::/0'"
+}
+
+testIpv6Invalid() {
+       assertFalse "IPv4 address" "is_ipv6 '192.168.1.1'"
+       assertFalse "Plain string" "is_ipv6 'not_ipv6'"
+       assertFalse "Empty string" "is_ipv6 ''"
+       assertFalse "MAC address" "is_ipv6 'AA:BB:CC:DD:EE:FF'"
+}
+
+testIpv6GlobalScope() {
+       assertTrue "Global scope 2001" "is_ipv6_global_scope '2001:db8::1'"
+       assertFalse "Link-local not global" "is_ipv6_global_scope 'fe80::1'"
+       assertFalse "ULA not global" "is_ipv6_global_scope 'fd00::1'"
+}
+
+testIpv6LinkLocal() {
+       assertTrue "Link-local fe80" "is_ipv6_local_link 'fe80::1'"
+       assertFalse "Global not link-local" "is_ipv6_local_link '2001::1'"
+}
+
+testIpv6UniqueLocal() {
+       assertTrue "ULA fd" "is_ipv6_local_unique 'fd00::1'"
+       assertTrue "ULA fc" "is_ipv6_local_unique 'fc00::1'"
+       assertFalse "Link-local not ULA" "is_ipv6_local_unique 'fe80::1'"
+       assertFalse "Global not ULA" "is_ipv6_local_unique '2001::1'"
+}
+
+testIpv6LocalScope() {
+       assertTrue "Link-local is local scope" "is_ipv6_local_scope 'fe80::1'"
+       assertTrue "ULA is local scope" "is_ipv6_local_scope 'fd00::1'"
+       assertFalse "Global not local scope" "is_ipv6_local_scope '2001::1'"
+}
+
+. shunit2
diff --git a/net/pbr/tests/01_validation/03_domain_validation b/net/pbr/tests/01_validation/03_domain_validation
new file mode 100644 (file)
index 0000000..204a270
--- /dev/null
@@ -0,0 +1,35 @@
+#!/bin/bash
+# Test: Domain, host, and hostname validation
+. "$(dirname "$0")/../lib/setup.sh"
+
+oneTimeTearDown() { rm -rf "${MOCK_ROOT:-}"; }
+
+testIsHost() {
+       assertTrue "Simple hostname" "is_host 'router'"
+       assertTrue "Hostname with hyphen" "is_host 'my-host'"
+       assertTrue "Hostname with numbers" "is_host 'host123'"
+       assertTrue "Single character" "is_host 'A'"
+       assertFalse "Empty string" "is_host ''"
+       assertFalse "Starts with hyphen" "is_host '-invalid'"
+}
+
+testIsHostname() {
+       assertTrue "Simple domain" "is_hostname 'example.com'"
+       assertTrue "Subdomain" "is_hostname 'sub.example.com'"
+       assertTrue "Deep subdomain" "is_hostname 'deep.sub.example.com'"
+       assertTrue "Hyphenated with ccTLD" "is_hostname 'my-site.co.uk'"
+       assertFalse "Single label" "is_hostname 'localhost'"
+       assertFalse "Empty string" "is_hostname ''"
+       assertFalse "IP address" "is_hostname '192.168.1.1'"
+}
+
+testIsDomain() {
+       assertTrue "Standard domain" "is_domain 'example.com'"
+       assertTrue "Single-label host" "is_domain 'router'"
+       assertTrue "Local domain" "is_domain 'my-server.local'"
+       assertFalse "IPv4 not a domain" "is_domain '192.168.1.1'"
+       assertFalse "Empty string" "is_domain ''"
+       assertFalse "Bad MAC notation" "is_domain 'AA-BB-CC-DD-EE-FF'"
+}
+
+. shunit2
diff --git a/net/pbr/tests/01_validation/04_misc_validators b/net/pbr/tests/01_validation/04_misc_validators
new file mode 100755 (executable)
index 0000000..c8df0b8
--- /dev/null
@@ -0,0 +1,71 @@
+#!/bin/bash
+# Test: Miscellaneous validators (MAC, integer, URL, negation, version comparison)
+. "$(dirname "$0")/../lib/setup.sh"
+
+oneTimeTearDown() { rm -rf "${MOCK_ROOT:-}"; }
+
+testMacAddress() {
+       assertTrue "Uppercase MAC" "is_mac_address 'AA:BB:CC:DD:EE:FF'"
+       assertTrue "Lowercase MAC" "is_mac_address 'aa:bb:cc:dd:ee:ff'"
+       assertTrue "Numeric MAC" "is_mac_address '00:11:22:33:44:55'"
+       assertFalse "Too short" "is_mac_address 'AA:BB:CC:DD:EE'"
+       assertFalse "Too long" "is_mac_address 'AA:BB:CC:DD:EE:FF:00'"
+       assertFalse "Dash notation" "is_mac_address 'AA-BB-CC-DD-EE-FF'"
+       assertFalse "Not a MAC" "is_mac_address 'not_a_mac'"
+       assertFalse "Empty string" "is_mac_address ''"
+}
+
+testMacAddressBadNotation() {
+       assertTrue "Dash notation" "is_mac_address_bad_notation 'AA-BB-CC-DD-EE-FF'"
+       assertFalse "Colon notation" "is_mac_address_bad_notation 'AA:BB:CC:DD:EE:FF'"
+}
+
+testIsInteger() {
+       assertTrue "Zero" "is_integer '0'"
+       assertTrue "Positive" "is_integer '123'"
+       assertTrue "Large number" "is_integer '999999'"
+       assertFalse "Empty string" "is_integer ''"
+       assertFalse "Letters" "is_integer 'abc'"
+       assertFalse "Decimal" "is_integer '12.34'"
+       assertFalse "Negative" "is_integer '-1'"
+}
+
+testIsNegated() {
+       assertTrue "Negated IP" "is_negated '!192.168.1.1'"
+       assertTrue "Negated domain" "is_negated '!example.com'"
+       assertFalse "Not negated" "is_negated '192.168.1.1'"
+       assertFalse "Empty string" "is_negated ''"
+}
+
+testUrlValidators() {
+       assertTrue "HTTP URL" "is_url_http 'http://example.com'"
+       assertTrue "HTTPS URL" "is_url_https 'https://example.com'"
+       assertTrue "FTP URL" "is_url_ftp 'ftp://files.example.com'"
+       assertTrue "File URL" "is_url_file 'file:///tmp/list.txt'"
+       assertFalse "HTTPS is not HTTP" "is_url_http 'https://example.com'"
+       assertFalse "HTTP is not HTTPS" "is_url_https 'http://example.com'"
+       assertTrue "HTTP is URL" "is_url 'http://example.com'"
+       assertTrue "HTTPS is URL" "is_url 'https://example.com'"
+       assertTrue "FTP is URL" "is_url 'ftp://example.com'"
+       assertTrue "File is URL" "is_url 'file:///tmp/x'"
+       assertFalse "Plain domain not URL" "is_url 'example.com'"
+}
+
+testVersionComparison() {
+       assertTrue "2.0 > 1.0" "is_greater '2.0' '1.0'"
+       assertTrue "1.10 > 1.9" "is_greater '1.10' '1.9'"
+       assertFalse "1.0 not > 2.0" "is_greater '1.0' '2.0'"
+       assertFalse "Equal not greater" "is_greater '1.0' '1.0'"
+       assertTrue "Equal is >=" "is_greater_or_equal '1.0' '1.0'"
+       assertTrue "Greater is >=" "is_greater_or_equal '2.0' '1.0'"
+       assertFalse "Lesser not >=" "is_greater_or_equal '1.0' '2.0'"
+}
+
+testFamilyMismatch() {
+       assertTrue "IPv4 src IPv6 dst" "is_family_mismatch '192.168.1.1' '::1'"
+       assertTrue "IPv6 src IPv4 dst" "is_family_mismatch '::1' '10.0.0.1'"
+       assertFalse "Both IPv4" "is_family_mismatch '10.0.0.1' '10.0.0.2'"
+       assertFalse "Both IPv6" "is_family_mismatch '::1' '::2'"
+}
+
+. shunit2
diff --git a/net/pbr/tests/02_string_utils/01_str_functions b/net/pbr/tests/02_string_utils/01_str_functions
new file mode 100755 (executable)
index 0000000..5fb7b7d
--- /dev/null
@@ -0,0 +1,61 @@
+#!/bin/bash
+# Test: String utility functions
+. "$(dirname "$0")/../lib/setup.sh"
+
+oneTimeTearDown() { rm -rf "${MOCK_ROOT:-}"; }
+
+testStrContains() {
+       assertTrue "Contains word" "str_contains 'hello world' 'world'"
+       assertTrue "Contains substring" "str_contains 'hello world' 'lo wo'"
+       assertTrue "Contains middle" "str_contains 'abcdef' 'bcd'"
+       assertFalse "Does not contain" "str_contains 'hello' 'xyz'"
+       assertFalse "Empty haystack" "str_contains '' 'test'"
+       # In bash, ${1//} with empty pattern doesn't remove anything, so returns false
+       assertFalse "Empty needle returns false" "str_contains 'hello' ''"
+}
+
+testStrContainsWord() {
+       assertTrue "Contains exact word" "str_contains_word 'one two three' 'two'"
+       assertFalse "Partial not word match" "str_contains_word 'one twothree' 'two'"
+       assertTrue "Single word" "str_contains_word 'one' 'one'"
+       assertFalse "Word not present" "str_contains_word 'one two three' 'four'"
+}
+
+testStrToLower() {
+       assertEquals "All caps to lower" "hello" "$(str_to_lower 'HELLO')"
+       assertEquals "Mixed case" "hello" "$(str_to_lower 'Hello')"
+       assertEquals "Already lowercase" "hello" "$(str_to_lower 'hello')"
+       assertEquals "With numbers" "123abc" "$(str_to_lower '123ABC')"
+}
+
+testStrToUpper() {
+       assertEquals "All lower to upper" "HELLO" "$(str_to_upper 'hello')"
+       assertEquals "Mixed case" "HELLO" "$(str_to_upper 'Hello')"
+       assertEquals "With numbers" "123ABC" "$(str_to_upper '123abc')"
+}
+
+testStrFirstWord() {
+       assertEquals "First of two" "hello" "$(str_first_word 'hello world')"
+       assertEquals "First of three" "one" "$(str_first_word 'one two three')"
+       assertEquals "Single word" "single" "$(str_first_word 'single')"
+}
+
+testStrReplace() {
+       assertEquals "Replace word" "hello universe" "$(str_replace 'hello world' 'world' 'universe')"
+       assertEquals "Replace dots" "aXbXc" "$(str_replace 'a.b.c' '.' 'X')"
+       assertEquals "No match unchanged" "hello world" "$(str_replace 'hello world' 'xyz' 'abc')"
+}
+
+testStrExtrasToUnderscore() {
+       assertEquals "Dot to underscore" "hello_world" "$(str_extras_to_underscore 'hello.world')"
+       assertEquals "Spaces to underscores" "a_b_c" "$(str_extras_to_underscore 'a b c')"
+       assertEquals "Slash to underscore" "test_path" "$(str_extras_to_underscore 'test/path')"
+       assertEquals "Multiple dots collapsed" "no_dups" "$(str_extras_to_underscore 'no..dups')"
+}
+
+testStrExtrasToSpace() {
+       assertEquals "Delimiters to spaces" "a b c d" "$(str_extras_to_space 'a,b;c{d')"
+       assertEquals "Closing brace to space" "a b" "$(str_extras_to_space 'a}b')"
+}
+
+. shunit2
diff --git a/net/pbr/tests/03_wan_detection/01_wan_types b/net/pbr/tests/03_wan_detection/01_wan_types
new file mode 100755 (executable)
index 0000000..156ca85
--- /dev/null
@@ -0,0 +1,72 @@
+#!/bin/bash
+# Test: WAN/interface type detection functions
+. "$(dirname "$0")/../lib/setup.sh"
+
+oneTimeTearDown() { rm -rf "${MOCK_ROOT:-}"; }
+
+testIsWan4() {
+       assertTrue "Standard wan" "is_wan4 'wan'"
+       assertTrue "wan prefix" "is_wan4 'wanX'"
+       assertFalse "wan6 is not wan4" "is_wan4 'wan6'"
+       assertFalse "Ends with wan6" "is_wan4 'mwan6'"
+       assertFalse "LAN not wan4" "is_wan4 'lan'"
+       assertFalse "Wireguard not wan4" "is_wan4 'wg0'"
+}
+
+testIsWan6() {
+       ipv6_enabled='1'
+       assertTrue "Standard wan6" "is_wan6 'wan6'"
+       assertTrue "Ends with wan6" "is_wan6 'mwan6'"
+       assertFalse "wan is not wan6" "is_wan6 'wan'"
+       assertFalse "LAN not wan6" "is_wan6 'lan'"
+}
+
+testIsWan6Disabled() {
+       unset ipv6_enabled
+       assertFalse "wan6 without ipv6 disabled" "is_wan6 'wan6'"
+}
+
+testIsWan() {
+       ipv6_enabled='1'
+       assertTrue "wan matches" "is_wan 'wan'"
+       assertTrue "wan6 matches" "is_wan 'wan6'"
+       assertFalse "LAN not wan" "is_wan 'lan'"
+       assertFalse "Wireguard not wan" "is_wan 'wg0'"
+}
+
+testIsUplink() {
+       uplink_interface4="wan"
+       uplink_interface6="wan6"
+       ipv6_enabled='1'
+       assertTrue "wan is uplink4" "is_uplink4 'wan'"
+       assertFalse "wan6 is not uplink4" "is_uplink4 'wan6'"
+       assertTrue "wan6 is uplink6" "is_uplink6 'wan6'"
+       assertFalse "wan is not uplink6" "is_uplink6 'wan'"
+       assertTrue "wan is uplink" "is_uplink 'wan'"
+       assertTrue "wan6 is uplink" "is_uplink 'wan6'"
+       assertFalse "wg0 is not uplink" "is_uplink 'wg0'"
+}
+
+testIsTor() {
+       assertTrue "Lowercase tor" "is_tor 'tor'"
+       assertTrue "Uppercase TOR" "is_tor 'TOR'"
+       assertTrue "Mixed case Tor" "is_tor 'Tor'"
+       assertFalse "Not tor" "is_tor 'vpn'"
+}
+
+testIsIgnoreTarget() {
+       assertTrue "Lowercase ignore" "is_ignore_target 'ignore'"
+       assertTrue "Uppercase IGNORE" "is_ignore_target 'IGNORE'"
+       assertTrue "Mixed case" "is_ignore_target 'Ignore'"
+       assertFalse "Not ignore" "is_ignore_target 'wan'"
+}
+
+testIsList() {
+       assertTrue "Comma-separated" "is_list 'a,b'"
+       assertTrue "Space-separated" "is_list 'a b'"
+       assertTrue "Multiple commas" "is_list 'a,b,c'"
+       assertFalse "Single value" "is_list 'single'"
+       assertFalse "Empty string" "is_list ''"
+}
+
+. shunit2
diff --git a/net/pbr/tests/04_config/01_load_config b/net/pbr/tests/04_config/01_load_config
new file mode 100755 (executable)
index 0000000..6311cef
--- /dev/null
@@ -0,0 +1,58 @@
+#!/bin/bash
+# Test: Package config loading
+. "$(dirname "$0")/../lib/setup.sh"
+
+oneTimeTearDown() { rm -rf "${MOCK_ROOT:-}"; }
+
+testLoadBasicConfig() {
+       load_package_config
+       assertNotNull "enabled is set" "$enabled"
+       assertEquals "verbosity" "2" "$verbosity"
+       assertEquals "uplink_interface4" "wan" "$uplink_interface4"
+       assertEquals "uplink_ip_rules_priority" "30000" "$uplink_ip_rules_priority"
+       assertEquals "procd_boot_trigger_delay" "5000" "$procd_boot_trigger_delay"
+}
+
+testLoadHexValues() {
+       load_package_config
+       assertEquals "fw_mask hex" "0x00ff0000" "$fw_mask"
+       assertEquals "uplink_mark hex" "0x00010000" "$uplink_mark"
+}
+
+testFwMaskXor() {
+       load_package_config
+       assertNotNull "fw_maskXor computed" "${fw_maskXor:-}"
+       assertEquals "fw_maskXor value" "0xff00ffff" "$fw_maskXor"
+}
+
+testIpv6DisabledConfig() {
+       load_package_config
+       assertNull "ipv6_enabled unset when 0" "${ipv6_enabled:-}"
+       assertNull "uplink_interface6 unset" "${uplink_interface6:-}"
+}
+
+testStrictEnforcement() {
+       load_package_config
+       assertNotNull "strict_enforcement set" "${strict_enforcement:-}"
+}
+
+testNftSetParams() {
+       load_package_config
+       echo "$nftSetParams" | grep -q 'auto-merge'
+       assertTrue "nft auto-merge enabled" $?
+       echo "$nftSetParams" | grep -q 'flags interval'
+       assertTrue "nft flags interval enabled" $?
+}
+
+testLoadPackageConfigFlag() {
+       load_package_config
+       assertEquals "flag set" "true" "$loadPackageConfigFlag"
+}
+
+testIgnoredInterfaceList() {
+       load_package_config
+       echo "$ignored_interface" | grep -qF 'loopback'
+       assertTrue "loopback in ignored_interface" $?
+}
+
+. shunit2
diff --git a/net/pbr/tests/04_config/02_disabled_service b/net/pbr/tests/04_config/02_disabled_service
new file mode 100755 (executable)
index 0000000..1abd5f1
--- /dev/null
@@ -0,0 +1,28 @@
+#!/bin/bash
+# Test: Disabled service detection
+. "$(dirname "$0")/../lib/setup.sh"
+
+oneTimeTearDown() { rm -rf "${MOCK_ROOT:-}"; }
+
+testDisabledService() {
+       cp "$MOCK_ROOT/etc/config/pbr" "$MOCK_ROOT/etc/config/pbr.bak"
+       sed -i "s/option enabled '1'/option enabled '0'/" "$MOCK_ROOT/etc/config/pbr"
+
+       _CONFIG_LOADED_PKG=""
+       loadPackageConfigFlag=""
+       load_package_config
+
+       assertNull "enabled is unset when service disabled" "${enabled:-}"
+
+       cp "$MOCK_ROOT/etc/config/pbr.bak" "$MOCK_ROOT/etc/config/pbr"
+}
+
+testEnabledService() {
+       _CONFIG_LOADED_PKG=""
+       loadPackageConfigFlag=""
+       load_package_config
+
+       assertNotNull "enabled is set when service enabled" "$enabled"
+}
+
+. shunit2
diff --git a/net/pbr/tests/04_config/03_config_ipv6 b/net/pbr/tests/04_config/03_config_ipv6
new file mode 100755 (executable)
index 0000000..58ab6b3
--- /dev/null
@@ -0,0 +1,31 @@
+#!/bin/bash
+# Test: IPv6 config variations
+. "$(dirname "$0")/../lib/setup.sh"
+
+oneTimeTearDown() { rm -rf "${MOCK_ROOT:-}"; }
+
+testIpv6Enabled() {
+       cp "$MOCK_ROOT/etc/config/pbr" "$MOCK_ROOT/etc/config/pbr.bak"
+       sed -i "s/option ipv6_enabled '0'/option ipv6_enabled '1'/" "$MOCK_ROOT/etc/config/pbr"
+
+       _CONFIG_LOADED_PKG=""
+       loadPackageConfigFlag=""
+       load_package_config
+
+       assertNotNull "ipv6_enabled is set" "${ipv6_enabled:-}"
+       assertEquals "uplink_interface6" "wan6" "${uplink_interface6:-}"
+       assertTrue "wan6 detected" "is_wan6 'wan6'"
+
+       cp "$MOCK_ROOT/etc/config/pbr.bak" "$MOCK_ROOT/etc/config/pbr"
+}
+
+testIpv6Disabled() {
+       _CONFIG_LOADED_PKG=""
+       loadPackageConfigFlag=""
+       load_package_config
+
+       assertNull "ipv6_enabled unset" "${ipv6_enabled:-}"
+       assertNull "uplink_interface6 unset" "${uplink_interface6:-}"
+}
+
+. shunit2
diff --git a/net/pbr/tests/05_nft/01_nft_file_operations b/net/pbr/tests/05_nft/01_nft_file_operations
new file mode 100755 (executable)
index 0000000..82ee480
--- /dev/null
@@ -0,0 +1,64 @@
+#!/bin/bash
+# Test: nft file operations (create, add, match, delete)
+. "$(dirname "$0")/../lib/setup.sh"
+
+oneTimeTearDown() { rm -rf "${MOCK_ROOT:-}"; }
+
+setUp() {
+       mkdir -p "$(dirname "$nftTempFile")" 2>/dev/null || true
+       mkdir -p "$(dirname "$nftMainFile")" 2>/dev/null || true
+       rm -f "$nftTempFile" "$nftMainFile"
+       load_package_config
+}
+
+tearDown() {
+       rm -f "$nftTempFile" "$nftMainFile"
+}
+
+testNftFileCreate() {
+       nft_file 'create' 'main'
+       assertTrue "nft temp file created" "[ -f '$nftTempFile' ]"
+       assertTrue "Has nft shebang" "grep -q '#!/usr/sbin/nft -f' '$nftTempFile'"
+}
+
+testNftFileChains() {
+       nft_file 'create' 'main'
+       assertTrue "dstnat chain" "grep -q 'add chain inet fw4 pbr_dstnat' '$nftTempFile'"
+       assertTrue "forward chain" "grep -q 'add chain inet fw4 pbr_forward' '$nftTempFile'"
+       assertTrue "output chain" "grep -q 'add chain inet fw4 pbr_output' '$nftTempFile'"
+       assertTrue "prerouting chain" "grep -q 'add chain inet fw4 pbr_prerouting' '$nftTempFile'"
+}
+
+testNftFileJumpRules() {
+       nft_file 'create' 'main'
+       assertTrue "jump to dstnat" "grep -q 'jump pbr_dstnat' '$nftTempFile'"
+       assertTrue "jump to prerouting" "grep -q 'jump pbr_prerouting' '$nftTempFile'"
+       assertTrue "jump to output" "grep -q 'jump pbr_output' '$nftTempFile'"
+       assertTrue "jump to forward" "grep -q 'jump pbr_forward' '$nftTempFile'"
+}
+
+testNftFileGuardRules() {
+       nft_file 'create' 'main'
+       assertTrue "Guard rule" "grep -q 'meta mark & 0x00ff0000 != 0 return' '$nftTempFile'"
+}
+
+testNftFileAdd() {
+       nft_file 'create' 'main'
+       nft_file 'add' 'main' 'add rule inet fw4 pbr_prerouting ip saddr 192.168.1.0/24 goto pbr_mark_0x00010000'
+       assertTrue "Added rule present" "grep -q '192.168.1.0/24' '$nftTempFile'"
+}
+
+testNftFileMatch() {
+       nft_file 'create' 'main'
+       assertTrue "Match existing" "nft_file 'match' 'temp' 'pbr_prerouting'"
+       assertFalse "Match missing" "nft_file 'match' 'temp' 'nonexistent_xyz'"
+}
+
+testNftFileDelete() {
+       nft_file 'create' 'main'
+       nft_file 'delete' 'main'
+       assertFalse "Temp file deleted" "[ -f '$nftTempFile' ]"
+       assertFalse "Main file deleted" "[ -f '$nftMainFile' ]"
+}
+
+. shunit2
diff --git a/net/pbr/tests/05_nft/02_nft_check_element b/net/pbr/tests/05_nft/02_nft_check_element
new file mode 100755 (executable)
index 0000000..f188f11
--- /dev/null
@@ -0,0 +1,30 @@
+#!/bin/bash
+# Test: nft_check_element for verifying fw4 table/chain existence
+. "$(dirname "$0")/../lib/setup.sh"
+
+oneTimeTearDown() { rm -rf "${MOCK_ROOT:-}"; }
+
+setUp() {
+       nft_fw4_dump=""
+}
+
+testTableExists() {
+       assertTrue "fw4 table exists" "nft_check_element 'table' 'fw4'"
+}
+
+testChainsExist() {
+       assertTrue "input chain" "nft_check_element 'chain' 'input'"
+       assertTrue "forward chain" "nft_check_element 'chain' 'forward'"
+       assertTrue "output chain" "nft_check_element 'chain' 'output'"
+       assertTrue "dstnat chain" "nft_check_element 'chain' 'dstnat'"
+       assertTrue "mangle_prerouting" "nft_check_element 'chain' 'mangle_prerouting'"
+       assertTrue "mangle_output" "nft_check_element 'chain' 'mangle_output'"
+       assertTrue "mangle_forward" "nft_check_element 'chain' 'mangle_forward'"
+}
+
+testNonExistentElements() {
+       assertFalse "Non-existent chain" "nft_check_element 'chain' 'nonexistent_chain'"
+       assertFalse "srcnat not present" "nft_check_element 'chain' 'srcnat'"
+}
+
+. shunit2
diff --git a/net/pbr/tests/06_network/01_gateway_discovery b/net/pbr/tests/06_network/01_gateway_discovery
new file mode 100755 (executable)
index 0000000..e0936c7
--- /dev/null
@@ -0,0 +1,55 @@
+#!/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
diff --git a/net/pbr/tests/06_network/02_supported_interfaces b/net/pbr/tests/06_network/02_supported_interfaces
new file mode 100755 (executable)
index 0000000..e876bdd
--- /dev/null
@@ -0,0 +1,48 @@
+#!/bin/bash
+# Test: Interface support detection
+. "$(dirname "$0")/../lib/setup.sh"
+
+oneTimeTearDown() { rm -rf "${MOCK_ROOT:-}"; }
+
+setUp() {
+       load_package_config
+       lan_device="br-lan"
+       supported_interface=""
+       ignored_interface="loopback"
+       uplink_interface4="wan"
+       uplink_interface6=""
+}
+
+testIgnoredInterface() {
+       assertTrue "loopback is ignored" "is_ignored_interface 'loopback'"
+       assertFalse "wan is not ignored" "is_ignored_interface 'wan'"
+       assertFalse "wg0 is not ignored" "is_ignored_interface 'wg0'"
+}
+
+testIsLan() {
+       assertTrue "lan is LAN" "is_lan 'lan'"
+       assertFalse "wan is not LAN" "is_lan 'wan'"
+}
+
+testWanIsSupported() {
+       assertTrue "wan is supported" "is_supported_interface 'wan'"
+}
+
+testLanNotSupported() {
+       assertFalse "lan not supported" "is_supported_interface 'lan'"
+}
+
+testLoopbackNotSupported() {
+       assertFalse "loopback not supported" "is_supported_interface 'loopback'"
+}
+
+testWireguardSupported() {
+       assertTrue "wg0 supported" "is_supported_interface 'wg0'"
+}
+
+testExplicitlySupportedInterface() {
+       supported_interface="custom_iface"
+       assertTrue "Explicitly supported" "is_supported_interface 'custom_iface'"
+}
+
+. shunit2
diff --git a/net/pbr/tests/lib/mocks/functions.sh b/net/pbr/tests/lib/mocks/functions.sh
new file mode 100644 (file)
index 0000000..c7e383f
--- /dev/null
@@ -0,0 +1,161 @@
+#!/bin/bash
+# Mock /lib/functions.sh for pbr tests
+# Implements OpenWrt UCI config shell API backed by UCI-format config files
+
+# Config state
+_CONFIG_LOADED_PKG=""
+declare -gA _CONFIG_TYPES    # section -> type
+declare -gA _CONFIG_OPTS     # section.option -> value
+declare -gA _CONFIG_LISTS    # section.option -> "val1 val2 ..."
+_CONFIG_SECTIONS=""
+
+config_load() {
+       local package="$1"
+       local file="${UCI_CONFIG_DIR:-${IPKG_INSTROOT}/etc/config}/${package}"
+
+       # Reset state
+       _CONFIG_LOADED_PKG="$package"
+       _CONFIG_TYPES=()
+       _CONFIG_OPTS=()
+       _CONFIG_LISTS=()
+       _CONFIG_SECTIONS=""
+
+       [ -f "$file" ] || return 1
+
+       local section="" anon_counter=0
+       while IFS= read -r line || [ -n "$line" ]; do
+               # Strip leading whitespace
+               line="${line#"${line%%[![:space:]]*}"}"
+               # Skip comments and empty lines
+               [[ "$line" == \#* || -z "$line" ]] && continue
+
+               if [[ "$line" =~ ^config[[:space:]]+([^[:space:]\'\"]+)[[:space:]]*([\'\"]([^\'\"]*)[\'\"])?(.*)$ ]]; then
+                       local type="${BASH_REMATCH[1]}"
+                       section="${BASH_REMATCH[3]}"
+                       [ -z "$section" ] && section="cfg${anon_counter}" && anon_counter=$((anon_counter + 1))
+                       _CONFIG_TYPES["$section"]="$type"
+                       _CONFIG_SECTIONS="${_CONFIG_SECTIONS:+$_CONFIG_SECTIONS }$section"
+               elif [[ "$line" =~ ^option[[:space:]]+([^[:space:]]+)[[:space:]]+[\'\"]([^\'\"]*)[\'\"] ]]; then
+                       local key="${BASH_REMATCH[1]}"
+                       local val="${BASH_REMATCH[2]}"
+                       _CONFIG_OPTS["${section}.${key}"]="$val"
+               elif [[ "$line" =~ ^option[[:space:]]+([^[:space:]]+)[[:space:]]+([^[:space:]]+) ]]; then
+                       local key="${BASH_REMATCH[1]}"
+                       local val="${BASH_REMATCH[2]}"
+                       val="${val//\'/}"
+                       val="${val//\"/}"
+                       _CONFIG_OPTS["${section}.${key}"]="$val"
+               elif [[ "$line" =~ ^list[[:space:]]+([^[:space:]]+)[[:space:]]+[\'\"]([^\'\"]*)[\'\"] ]]; then
+                       local key="${BASH_REMATCH[1]}"
+                       local val="${BASH_REMATCH[2]}"
+                       if [ -n "${_CONFIG_LISTS["${section}.${key}"]:-}" ]; then
+                               _CONFIG_LISTS["${section}.${key}"]="${_CONFIG_LISTS["${section}.${key}"]} $val"
+                       else
+                               _CONFIG_LISTS["${section}.${key}"]="$val"
+                       fi
+               elif [[ "$line" =~ ^list[[:space:]]+([^[:space:]]+)[[:space:]]+([^[:space:]]+) ]]; then
+                       local key="${BASH_REMATCH[1]}"
+                       local val="${BASH_REMATCH[2]}"
+                       val="${val//\'/}"
+                       val="${val//\"/}"
+                       if [ -n "${_CONFIG_LISTS["${section}.${key}"]:-}" ]; then
+                               _CONFIG_LISTS["${section}.${key}"]="${_CONFIG_LISTS["${section}.${key}"]} $val"
+                       else
+                               _CONFIG_LISTS["${section}.${key}"]="$val"
+                       fi
+               fi
+       done < "$file"
+}
+
+config_get() {
+       local var="$1" section="$2" option="$3" default="$4"
+       local key="${section}.${option}"
+       local val="${_CONFIG_OPTS[$key]:-${_CONFIG_LISTS[$key]:-}}"
+       [ -z "$val" ] && val="$default"
+       eval "$var=\"\$val\""
+}
+
+config_get_bool() {
+       local var="$1" section="$2" option="$3" default="${4:-0}"
+       local key="${section}.${option}"
+       local val="${_CONFIG_OPTS[$key]:-$default}"
+       case "$val" in
+               1|yes|on|true|enabled) val=1;;
+               *) val=0;;
+       esac
+       eval "$var=$val"
+}
+
+config_get_list() {
+       config_get "$@"
+}
+
+config_foreach() {
+       local callback="$1" type="$2"
+       local section
+       for section in $_CONFIG_SECTIONS; do
+               [ "${_CONFIG_TYPES[$section]:-}" = "$type" ] && "$callback" "$section"
+       done
+}
+
+config_list_foreach() {
+       local section="$1" option="$2" callback="$3"
+       local key="${section}.${option}"
+       local val="${_CONFIG_LISTS[$key]:-}"
+       local item
+       for item in $val; do
+               "$callback" "$item"
+       done
+}
+
+uci_get() {
+       local package="${1:-}" section="${2:-}" option="${3:-}" default="${4:-}"
+       [ -z "$package" ] || [ -z "$section" ] && return 1
+       # Auto-load if different package
+       if [ "$_CONFIG_LOADED_PKG" != "$package" ]; then
+               config_load "$package"
+       fi
+       if [ -n "$option" ]; then
+               local key="${section}.${option}"
+               echo "${_CONFIG_OPTS[$key]:-${_CONFIG_LISTS[$key]:-$default}}"
+       else
+               # Check if section exists
+               [ -n "${_CONFIG_TYPES[$section]:-}" ] && echo "$section"
+       fi
+}
+
+uci_add_list() {
+       local package="$1" section="$2" option="$3" value="$4"
+       local key="${section}.${option}"
+       if [ -n "${_CONFIG_LISTS[$key]:-}" ]; then
+               _CONFIG_LISTS[$key]="${_CONFIG_LISTS[$key]} $value"
+       else
+               _CONFIG_LISTS[$key]="$value"
+       fi
+}
+
+uci_remove() {
+       local package="$1" section="$2" option="${3:-}"
+       if [ -n "$option" ]; then
+               unset "_CONFIG_OPTS[${section}.${option}]"
+               unset "_CONFIG_LISTS[${section}.${option}]"
+       fi
+}
+
+uci_remove_list() {
+       local package="$1" section="$2" option="$3" value="$4"
+       local key="${section}.${option}"
+       local old="${_CONFIG_LISTS[$key]:-}"
+       local new="" item
+       for item in $old; do
+               [ "$item" != "$value" ] && new="${new:+$new }$item"
+       done
+       _CONFIG_LISTS[$key]="$new"
+}
+
+uci_commit() { :; }
+
+uci_set() {
+       local package="$1" section="$2" option="$3" value="$4"
+       _CONFIG_OPTS["${section}.${option}"]="$value"
+}
diff --git a/net/pbr/tests/lib/mocks/jshn.sh b/net/pbr/tests/lib/mocks/jshn.sh
new file mode 100644 (file)
index 0000000..64e665c
--- /dev/null
@@ -0,0 +1,138 @@
+#!/bin/bash
+# Minimal mock /usr/share/libubox/jshn.sh for pbr tests
+# Implements enough of the jshn API to support the json() function and procd_open_data
+
+# Internal state
+_JSON_PREFIX=""
+_JSON_DEPTH=0
+declare -gA _JSON_DATA
+_JSON_CUR_PATH=""
+_JSON_KEYS=""
+_JSON_NS=""
+
+json_set_namespace() {
+       _JSON_NS="${1:-}"
+}
+
+json_init() {
+       _JSON_DATA=()
+       _JSON_DEPTH=0
+       _JSON_CUR_PATH=""
+       _JSON_KEYS=""
+}
+
+json_add_string() {
+       local key="$1" value="$2"
+       _JSON_DATA["${_JSON_CUR_PATH}${key}"]="$value"
+}
+
+json_add_boolean() {
+       local key="$1" value="$2"
+       [ "$value" = "1" ] && value="true" || value="false"
+       _JSON_DATA["${_JSON_CUR_PATH}${key}"]="$value"
+}
+
+json_add_int() {
+       local key="$1" value="$2"
+       _JSON_DATA["${_JSON_CUR_PATH}${key}"]="$value"
+}
+
+json_add_object() {
+       local key="${1:-}"
+       if [ -n "$key" ]; then
+               _JSON_CUR_PATH="${_JSON_CUR_PATH}${key}."
+       fi
+       _JSON_DEPTH=$((_JSON_DEPTH + 1))
+}
+
+json_close_object() {
+       _JSON_DEPTH=$((_JSON_DEPTH - 1))
+       # Pop last path component
+       if [ -n "$_JSON_CUR_PATH" ]; then
+               _JSON_CUR_PATH="${_JSON_CUR_PATH%*.}"
+               _JSON_CUR_PATH="${_JSON_CUR_PATH%.*}"
+               [ -n "$_JSON_CUR_PATH" ] && _JSON_CUR_PATH="${_JSON_CUR_PATH}."
+       fi
+}
+
+json_add_array() {
+       local key="${1:-}"
+       if [ -n "$key" ]; then
+               _JSON_CUR_PATH="${_JSON_CUR_PATH}${key}."
+               _JSON_DATA["${_JSON_CUR_PATH}_type"]="array"
+       fi
+       _JSON_DEPTH=$((_JSON_DEPTH + 1))
+}
+
+json_close_array() {
+       json_close_object
+}
+
+json_select() {
+       local key="$1"
+       if [ "$key" = ".." ]; then
+               # Go up one level
+               if [ -n "$_JSON_CUR_PATH" ]; then
+                       _JSON_CUR_PATH="${_JSON_CUR_PATH%*.}"
+                       _JSON_CUR_PATH="${_JSON_CUR_PATH%.*}"
+                       [ -n "$_JSON_CUR_PATH" ] && _JSON_CUR_PATH="${_JSON_CUR_PATH}."
+               fi
+               return 0
+       fi
+       # Check if key exists
+       local prefix="${_JSON_CUR_PATH}${key}."
+       local found=0
+       for k in "${!_JSON_DATA[@]}"; do
+               if [[ "$k" == "${prefix}"* ]] || [ -n "${_JSON_DATA["${_JSON_CUR_PATH}${key}"]:-}" ]; then
+                       found=1
+                       break
+               fi
+       done
+       if [ "$found" = "1" ]; then
+               _JSON_CUR_PATH="$prefix"
+               return 0
+       fi
+       return 1
+}
+
+json_get_var() {
+       local var="$1" key="$2"
+       local val="${_JSON_DATA["${_JSON_CUR_PATH}${key}"]:-}"
+       eval "$var=\"\$val\""
+}
+
+json_get_keys() {
+       local var="$1"
+       local prefix="$_JSON_CUR_PATH"
+       local keys="" k
+       for k in "${!_JSON_DATA[@]}"; do
+               if [[ "$k" == "${prefix}"* ]]; then
+                       local rest="${k#"$prefix"}"
+                       local first="${rest%%.*}"
+                       if [ -n "$first" ] && ! echo " $keys " | grep -q " $first "; then
+                               keys="${keys:+$keys }$first"
+                       fi
+               fi
+       done
+       eval "$var=\"\$keys\""
+}
+
+json_dump() {
+       # Simple JSON output - enough for testing
+       echo "{}"
+}
+
+json_load() {
+       json_init
+}
+
+json_load_file() {
+       local file="$1"
+       [ -f "$file" ] || return 1
+       json_init
+       return 0
+}
+
+json_cleanup() {
+       json_init
+}
diff --git a/net/pbr/tests/lib/mocks/network.sh b/net/pbr/tests/lib/mocks/network.sh
new file mode 100644 (file)
index 0000000..7778ceb
--- /dev/null
@@ -0,0 +1,61 @@
+#!/bin/bash
+# Mock /lib/functions/network.sh for pbr tests
+# Provides configurable network state via MOCK_NET_* variables
+
+# Default mock network data - tests can override these before calling setup
+: "${MOCK_NET_wan_device:=eth0}"
+: "${MOCK_NET_wan_gateway:=192.168.1.1}"
+: "${MOCK_NET_wan_proto:=dhcp}"
+: "${MOCK_NET_wan6_device:=eth0}"
+: "${MOCK_NET_wan6_gateway6:=fd00::1}"
+: "${MOCK_NET_wan6_proto:=dhcpv6}"
+: "${MOCK_NET_wg0_device:=wg0}"
+: "${MOCK_NET_wg0_proto:=wireguard}"
+: "${MOCK_NET_lan_device:=br-lan}"
+: "${MOCK_NET_lan_proto:=static}"
+: "${MOCK_NET_loopback_device:=lo}"
+: "${MOCK_NET_loopback_proto:=static}"
+
+_net_get_var() {
+       local var="$1" iface="$2" field="$3"
+       local iface_safe="${iface//-/_}"
+       local val=""
+       eval "val=\"\${MOCK_NET_${iface_safe}_${field}:-}\""
+       eval "$var=\"\$val\""
+}
+
+network_get_device() {
+       _net_get_var "$1" "$2" "device"
+}
+
+network_get_physdev() {
+       _net_get_var "$1" "$2" "device"
+}
+
+network_get_gateway() {
+       local var="$1" iface="$2"
+       _net_get_var "$var" "$iface" "gateway"
+}
+
+network_get_gateway6() {
+       local var="$1" iface="$2"
+       _net_get_var "$var" "$iface" "gateway6"
+}
+
+network_get_protocol() {
+       _net_get_var "$1" "$2" "proto"
+}
+
+network_get_ipaddr() {
+       _net_get_var "$1" "$2" "ipaddr"
+}
+
+network_get_ip6addr() {
+       _net_get_var "$1" "$2" "ip6addr"
+}
+
+network_flush_cache() { :; }
+
+network_get_dnsserver() {
+       _net_get_var "$1" "$2" "dns"
+}
diff --git a/net/pbr/tests/lib/setup.sh b/net/pbr/tests/lib/setup.sh
new file mode 100644 (file)
index 0000000..4531c76
--- /dev/null
@@ -0,0 +1,86 @@
+#!/bin/bash
+# Common test setup for pbr shell tests (shunit2-based)
+# Source this at the top of each test file before defining test functions.
+# Each test file should end with: . shunit2
+
+TESTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+PKG_DIR="$(cd "$TESTS_DIR/.." && pwd)"
+
+# Create mock sysroot
+MOCK_ROOT="$(mktemp -d)"
+export IPKG_INSTROOT="$MOCK_ROOT"
+
+# Install mock libraries into sysroot
+mkdir -p "$MOCK_ROOT/lib/functions"
+mkdir -p "$MOCK_ROOT/usr/share/libubox"
+cp "$TESTS_DIR/lib/mocks/functions.sh" "$MOCK_ROOT/lib/functions.sh"
+cp "$TESTS_DIR/lib/mocks/network.sh" "$MOCK_ROOT/lib/functions/network.sh"
+cp "$TESTS_DIR/lib/mocks/jshn.sh" "$MOCK_ROOT/usr/share/libubox/jshn.sh"
+
+# Install mock config files
+mkdir -p "$MOCK_ROOT/etc/config"
+if [ -d "$TESTS_DIR/mocks/etc/config" ]; then
+       cp "$TESTS_DIR/mocks/etc/config/"* "$MOCK_ROOT/etc/config/" 2>/dev/null || true
+fi
+
+# Install mock binaries and add to PATH
+mkdir -p "$MOCK_ROOT/bin"
+if [ -d "$TESTS_DIR/mocks/bin" ]; then
+       cp "$TESTS_DIR/mocks/bin/"* "$MOCK_ROOT/bin/" 2>/dev/null || true
+       chmod +x "$MOCK_ROOT/bin/"*
+fi
+export PATH="$MOCK_ROOT/bin:$PATH"
+
+# Create required directories
+mkdir -p "$MOCK_ROOT/var/run"
+mkdir -p "$MOCK_ROOT/dev/shm"
+mkdir -p "$MOCK_ROOT/usr/share/nftables.d/ruleset-post"
+mkdir -p "$MOCK_ROOT/etc/iproute2"
+cat > "$MOCK_ROOT/etc/iproute2/rt_tables" <<'RT'
+255    local
+254    main
+253    default
+0      unspec
+RT
+
+# Stub out OpenWrt rc.common / procd functions
+extra_command() { :; }
+rc_procd() { "$@"; }
+service_started() { :; }
+procd_open_instance() { :; }
+procd_set_param() { :; }
+procd_close_instance() { :; }
+procd_open_data() { :; }
+procd_close_data() { :; }
+procd_add_reload_trigger() { :; }
+procd_add_interface_trigger() { :; }
+procd_open_trigger() { :; }
+procd_close_trigger() { :; }
+
+# Stub external commands
+logger() { :; }
+resolveip() { echo "127.0.0.1"; }
+jsonfilter() { echo ""; }
+pidof() { return 1; }
+sync() { :; }
+
+# Prepare a test-friendly copy of the pbr script:
+# 1. Strip 'readonly' keyword to avoid collision with shunit2 internals
+#    (pbr defines readonly _FAIL_, _OK_ etc. that clash with shunit2)
+# 2. Redirect file paths to temp directories we control
+_PBR_TEST_SCRIPT="$MOCK_ROOT/pbr_test.sh"
+sed 's/^readonly //' "$PKG_DIR/files/etc/init.d/pbr" > "$_PBR_TEST_SCRIPT"
+
+# Source the modified pbr script
+. "$_PBR_TEST_SCRIPT"
+
+# Override file paths to use test-friendly temp locations
+nftTempFile="$MOCK_ROOT/var/run/pbr.nft"
+nftMainFile="$MOCK_ROOT/usr/share/nftables.d/ruleset-post/30-pbr.nft"
+nftNetifdFile="$MOCK_ROOT/usr/share/nftables.d/ruleset-post/20-pbr-netifd.nft"
+rtTablesFile="$MOCK_ROOT/etc/iproute2/rt_tables"
+runningStatusFile="$MOCK_ROOT/dev/shm/pbr.status.json"
+packageLockFile="$MOCK_ROOT/var/run/pbr.lock"
+packageDnsmasqFile="$MOCK_ROOT/var/run/pbr.dnsmasq"
+packageDebugFile="$MOCK_ROOT/var/run/pbr.debug"
+packageConfigFile="$MOCK_ROOT/etc/config/pbr"
diff --git a/net/pbr/tests/mocks/bin/dnsmasq b/net/pbr/tests/mocks/bin/dnsmasq
new file mode 100644 (file)
index 0000000..de48341
--- /dev/null
@@ -0,0 +1,11 @@
+#!/bin/bash
+# Mock dnsmasq for pbr tests
+case "$1" in
+       --version)
+               echo "Dnsmasq version 2.90"
+               echo "Compile time options: IPv6 GNU-getopt DBus no-UBus no-i18n IDN2 DHCP DHCPv6 no-Lua TFTP conntrack ipset nftset auth cryptohash DNSSEC loop-detect inotify dumpfile"
+               ;;
+       *)
+               exit 0
+               ;;
+esac
diff --git a/net/pbr/tests/mocks/bin/nft b/net/pbr/tests/mocks/bin/nft
new file mode 100644 (file)
index 0000000..52ae75c
--- /dev/null
@@ -0,0 +1,31 @@
+#!/bin/bash
+# Mock nft binary for pbr tests
+case "$1" in
+       list)
+               case "$*" in
+                       "list table inet fw4"|"list table inet fw4 2>&1")
+                               cat <<'EOF'
+table inet fw4 {
+       chain input { }
+       chain forward { }
+       chain output { }
+       chain dstnat { }
+       chain mangle_prerouting { }
+       chain mangle_output { }
+       chain mangle_forward { }
+}
+EOF
+                               ;;
+                       *)
+                               echo "table inet fw4 {}"
+                               ;;
+               esac
+               ;;
+       -c)
+               # Syntax check - always succeed
+               exit 0
+               ;;
+       *)
+               exit 0
+               ;;
+esac
diff --git a/net/pbr/tests/mocks/bin/readlink b/net/pbr/tests/mocks/bin/readlink
new file mode 100644 (file)
index 0000000..50e6ebf
--- /dev/null
@@ -0,0 +1,11 @@
+#!/bin/bash
+# Mock readlink for pbr tests
+# Returns /usr/libexec/ip-full for /sbin/ip to pass the ip-full check
+case "$*" in
+       */sbin/ip)
+               echo "/usr/libexec/ip-full"
+               ;;
+       *)
+               command readlink "$@" 2>/dev/null || echo "$1"
+               ;;
+esac
diff --git a/net/pbr/tests/mocks/etc/config/dhcp b/net/pbr/tests/mocks/etc/config/dhcp
new file mode 100644 (file)
index 0000000..218274a
--- /dev/null
@@ -0,0 +1,9 @@
+config dnsmasq 'cfg01411c'
+       option domainneeded '1'
+
+config dhcp 'lan'
+       option interface 'lan'
+       option start '100'
+       option limit '150'
+       option leasetime '12h'
+       option force '1'
diff --git a/net/pbr/tests/mocks/etc/config/firewall b/net/pbr/tests/mocks/etc/config/firewall
new file mode 100644 (file)
index 0000000..248e467
--- /dev/null
@@ -0,0 +1,20 @@
+config defaults 'defaults'
+       option input 'REJECT'
+       option output 'ACCEPT'
+       option forward 'REJECT'
+
+config zone 'lan_zone'
+       option name 'lan'
+       list network 'lan'
+       option input 'ACCEPT'
+       option output 'ACCEPT'
+       option forward 'ACCEPT'
+
+config zone 'wan_zone'
+       option name 'wan'
+       list network 'wan'
+       list network 'wan6'
+       list network 'wg0'
+       option input 'REJECT'
+       option output 'ACCEPT'
+       option forward 'REJECT'
diff --git a/net/pbr/tests/mocks/etc/config/network b/net/pbr/tests/mocks/etc/config/network
new file mode 100644 (file)
index 0000000..43937f1
--- /dev/null
@@ -0,0 +1,21 @@
+config interface 'loopback'
+       option device 'lo'
+       option proto 'static'
+       option ipaddr '127.0.0.1'
+
+config interface 'lan'
+       option device 'br-lan'
+       option proto 'static'
+       option ipaddr '192.168.1.1'
+
+config interface 'wan'
+       option device 'eth0'
+       option proto 'dhcp'
+
+config interface 'wan6'
+       option device 'eth0'
+       option proto 'dhcpv6'
+
+config interface 'wg0'
+       option proto 'wireguard'
+       option device 'wg0'
diff --git a/net/pbr/tests/mocks/etc/config/pbr b/net/pbr/tests/mocks/etc/config/pbr
new file mode 100644 (file)
index 0000000..f067c68
--- /dev/null
@@ -0,0 +1,52 @@
+config pbr 'config'
+       option enabled '1'
+       option verbosity '2'
+       option strict_enforcement '1'
+       option ipv6_enabled '0'
+       option fw_mask '00ff0000'
+       option resolver_set 'none'
+       option uplink_interface 'wan'
+       option uplink_interface6 'wan6'
+       option uplink_mark '00010000'
+       option uplink_ip_rules_priority '30000'
+       list ignored_interface 'loopback'
+       list lan_device 'br-lan'
+       option procd_boot_trigger_delay '5000'
+       option procd_reload_delay '0'
+       option nft_set_policy 'performance'
+       option nft_set_auto_merge '1'
+       option nft_set_flags_interval '1'
+       option nft_set_flags_timeout '0'
+       option nft_rule_counter '0'
+       option nft_set_counter '0'
+       option nft_user_set_counter '0'
+       option prefixlength '1'
+       list resolver_instance '*'
+       option webui_show_ignore_target '0'
+
+config policy 'vpn_all'
+       option name 'VPN All Traffic'
+       option interface 'wg0'
+       option src_addr '192.168.1.0/24'
+       option dest_addr ''
+       option enabled '1'
+
+config policy 'vpn_gaming'
+       option name 'VPN Gaming'
+       option interface 'wg0'
+       option src_addr ''
+       option dest_addr '10.0.0.0/8'
+       option src_port '27015-27030'
+       option enabled '1'
+
+config policy 'disabled_policy'
+       option name 'Disabled Policy'
+       option interface 'wan'
+       option src_addr '10.10.10.0/24'
+       option enabled '0'
+
+config dns_policy 'dns_vpn'
+       option name 'DNS via VPN'
+       option interface 'wg0'
+       option src_addr '192.168.1.100'
+       option enabled '1'
diff --git a/net/pbr/tests/mocks/etc/config/system b/net/pbr/tests/mocks/etc/config/system
new file mode 100644 (file)
index 0000000..302202e
--- /dev/null
@@ -0,0 +1,3 @@
+config system
+       option hostname 'OpenWrt'
+       option timezone 'UTC'
diff --git a/net/pbr/tests/run_tests.sh b/net/pbr/tests/run_tests.sh
new file mode 100644 (file)
index 0000000..595fc48
--- /dev/null
@@ -0,0 +1,52 @@
+#!/bin/bash
+# Test runner for pbr shell tests (shunit2-based)
+# Usage: bash tests/run_tests.sh [test_pattern]
+set -uo pipefail
+
+cd "$(dirname "$0")/.." || exit 1
+TESTS_DIR="$(pwd)/tests"
+PASS=0
+FAIL=0
+TOTAL=0
+FAILED_TESTS=""
+
+# Check shunit2 availability
+if ! command -v shunit2 >/dev/null 2>&1 && [ ! -f /usr/bin/shunit2 ]; then
+       echo "ERROR: shunit2 not found. Install with: apt-get install shunit2" >&2
+       exit 1
+fi
+
+pattern="${1:-}"
+
+for test_dir in "$TESTS_DIR"/[0-9]*/; do
+       [ -d "$test_dir" ] || continue
+       for test_script in "$test_dir"[0-9]*; do
+               [ -f "$test_script" ] || continue
+               test_name="${test_dir##*tests/}${test_script##*/}"
+               # Filter by pattern if provided
+               if [ -n "$pattern" ] && ! echo "$test_name" | grep -q "$pattern"; then
+                       continue
+               fi
+               TOTAL=$((TOTAL + 1))
+               output_file="$(mktemp)"
+               if bash "$test_script" >"$output_file" 2>&1; then
+                       printf '\033[0;32mPASS\033[0m: %s\n' "$test_name"
+                       PASS=$((PASS + 1))
+               else
+                       printf '\033[0;31mFAIL\033[0m: %s\n' "$test_name"
+                       cat "$output_file" | sed 's/^/  /'
+                       FAIL=$((FAIL + 1))
+                       FAILED_TESTS="${FAILED_TESTS:+$FAILED_TESTS\n}  $test_name"
+               fi
+               rm -f "$output_file"
+       done
+done
+
+echo ""
+echo "Results: $PASS/$TOTAL passed, $FAIL failed"
+if [ -n "$FAILED_TESTS" ]; then
+       echo ""
+       echo "Failed tests:"
+       printf "%b\n" "$FAILED_TESTS"
+fi
+[ "$FAIL" -eq 0 ]
git clone https://git.99rst.org/PROJECT