pbr: update to 1.2.2-r6

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 <stangri@melmac.ca>

---

- **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 <stangri@melmac.ca>
This commit is contained in:
Stan Grishin
2026-02-25 02:31:00 +00:00
parent 5a82fcebe8
commit cf1d2770ed
31 changed files with 1745 additions and 227 deletions
+3 -3
View 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 \
+3 -3
View 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'
+1 -1
View 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
File diff suppressed because it is too large Load Diff
+2 -2
View 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
@@ -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
@@ -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
@@ -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
+71
View File
@@ -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
+61
View File
@@ -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
+72
View File
@@ -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
+58
View File
@@ -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
+28
View File
@@ -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
+31
View File
@@ -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
+64
View File
@@ -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
+30
View File
@@ -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
+55
View File
@@ -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
+48
View File
@@ -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
+161
View File
@@ -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"
}
+138
View File
@@ -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
}
+61
View File
@@ -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"
}
+86
View File
@@ -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"
+11
View File
@@ -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
+31
View File
@@ -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
+11
View File
@@ -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
+9
View File
@@ -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'
+20
View File
@@ -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'
+21
View File
@@ -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'
+52
View File
@@ -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'
+3
View File
@@ -0,0 +1,3 @@
config system
option hostname 'OpenWrt'
option timezone 'UTC'
+52
View File
@@ -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 ]