🤞 Sync 2026-10-06 13:52:46

This commit is contained in:
github-actions[bot] committed 2026-10-06 13:52:46 +08:00
1 parent 4d2d69bed9
commit 947eaea2ee
42 files changed
+2681 -1582

No files matched your search

+8 -3
View File
@@ -8,8 +8,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=mwan3-nft
PKG_VERSION:=3.6.9
PKG_RELEASE:=3
PKG_VERSION:=3.6.12
PKG_RELEASE:=4
PKG_MAINTAINER:=Florian Eckert <fe@dev.tdt.de>
PKG_LICENSE:=GPL-2.0
@@ -35,7 +35,8 @@ define Package/$(PKG_NAME)
+ucode-mod-uci \
+ucode-mod-ubus \
+ucode-mod-fs \
+ucode-mod-socket
+ucode-mod-socket \
+ucode-mod-log
CONFLICTS:=mwan3
TITLE:=Multiwan hotplug script with connection tracking support (ucode rtmon)
MAINTAINER:=Florian Eckert <fe@dev.tdt.de>
@@ -220,6 +221,10 @@ define Package/$(PKG_NAME)/install
$(INSTALL_BIN) ./files/usr/share/rpcd/ucode/mwan3 \
$(1)/usr/share/rpcd/ucode/
$(INSTALL_DIR) $(1)/usr/share/ucode/mwan3
$(INSTALL_DATA) ./files/usr/share/ucode/mwan3/common.uc \
$(1)/usr/share/ucode/mwan3/
$(INSTALL_DIR) $(1)/usr/sbin
$(INSTALL_BIN) ./files/usr/sbin/mwan3 \
$(1)/usr/sbin/
+721 -179
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,5 +1,5 @@
# For full documentation of mwan3 configuration:
# https://openwrt.org/docs/guide-user/network/wan/multiwan/mwan3#mwan3_configuration
# https://openwrt.org/docs/guide-user/network/wan/multiwan/mwan3-nft#configuration
config globals 'globals'
option mmx_mask '0x3F00'
+44 -1
View File
@@ -52,9 +52,24 @@ LOG notice "Execute $ACTION event on interface $INTERFACE (${DEVICE:-unknown})"
case "$ACTION" in
connected)
# A tracker recovery must restore the member's routing before the policy
# re-selects it, so the member is routable the instant it is marked, then
# snap new flows off the worse-metric backups back toward it. The policy
# rebuild commits as one nft transaction so no packet classifies against a
# half-rebuilt policy chain. The snap-back stays outside that batch on
# purpose: a recovery's worse-metric pins point at backups that are still
# up, so a source re-pinned in the window before the snap reaches a live
# member, not a dead one, unlike a teardown whose pins must be flushed in
# the same transaction because they point at the departing member.
mwan3_set_iface_hotplug_state $INTERFACE "online"
mwan3_create_iface_nft $INTERFACE $DEVICE
mwan3_create_iface_rules $INTERFACE $DEVICE
mwan3_create_iface_route $INTERFACE $DEVICE
mwan3_nft_batch_start
mwan3_set_policies_nft
mwan3_nft_batch_commit
mwan3_flush_worse_metric_sticky $INTERFACE
mwan3_flush_unreplied_conntrack
;;
ifup)
@@ -66,24 +81,52 @@ case "$ACTION" in
if [ "$MWAN3_STARTUP" != "init" ]; then
mwan3_set_general_rules
if [ "$status" = "online" ]; then
# Same atomic policy rebuild and out-of-batch snap-back as the
# connected arm above; see that comment for why the snap-back is
# not folded into the batch. The batch sits inside the online
# guard so the init path never opens one.
mwan3_nft_batch_start
mwan3_set_policies_nft
mwan3_nft_batch_commit
mwan3_flush_worse_metric_sticky $INTERFACE
mwan3_flush_unreplied_conntrack
fi
fi
[ "$ACTION" = ifup ] && procd_running mwan3 "track_$INTERFACE" && procd_send_signal mwan3 "track_$INTERFACE" USR2
;;
disconnected)
# A soft tracker failure drops the member from the policy; flush its
# sticky pins after the rebuild so a pinned source re-evaluates to the
# survivor, the same treatment a hard ifdown gets. The policy rebuild
# and the sticky flush commit as one nft transaction so no packet
# classifies against a half-rebuilt policy. The member's marked flows
# are cleared by the conntrack flush at the handler tail, which is
# kernel state outside nftables and stays a separate operation.
mwan3_set_iface_hotplug_state $INTERFACE "offline"
mwan3_nft_batch_start
mwan3_set_policies_nft
mwan3_delete_iface_map_entries $INTERFACE
mwan3_nft_batch_commit
;;
ifdown)
# Drop the member from the policy before tearing down its routing, so the
# policy never marks for a member whose rules are already gone; flush its
# sticky sets after the rebuild so the rebuild cannot re-seed them. The
# policy rebuild and the sticky flush commit as one nft transaction; the
# routing teardown and the conntrack flush follow as separate steps.
mwan3_set_iface_hotplug_state $INTERFACE "offline"
mwan3_nft_batch_start
mwan3_set_policies_nft
mwan3_delete_iface_map_entries $INTERFACE
mwan3_nft_batch_commit
mwan3_delete_iface_rules $INTERFACE
mwan3_delete_iface_route $INTERFACE
mwan3_delete_iface_nft $INTERFACE
procd_running mwan3 "track_$INTERFACE" && procd_send_signal mwan3 "track_$INTERFACE" USR1
mwan3_set_policies_nft
;;
esac
+32 -39
View File
@@ -66,6 +66,7 @@ start_service() {
mwan3_set_connected_sets
mwan3_set_custom_sets
mwan3_set_general_rules
mwan3_set_member_backstops
config_foreach mwan3_ifup interface "init"
wait $hotplug_pids
mwan3_set_general_nft
@@ -134,11 +135,13 @@ stop_service() {
# Delete ip rules created by this mwan3 instance. A rule must pass
# both gates before it is deleted:
# priority gate -- the priority lies in one of mwan3's three
# configured rule-base ranges (iif, fwmark, unreachable). The
# priority gate -- the priority lies in one of mwan3's four
# rule-base ranges (iif, fwmark, unreachable, source). The
# fwmark range extends to fwmark_base + MM_UNREACHABLE to cover
# the global blackhole and unreachable rules at fwmark_base +
# MM_BLACKHOLE / MM_UNREACHABLE.
# MM_BLACKHOLE / MM_UNREACHABLE. The source band is not
# configurable; it is derived from the unreachable base and
# sits immediately above the unreachable band.
# content gate -- the line either references a mwan3-owned routing
# table (lookup <N> with N in 1..MWAN3_INTERFACE_MAX) or carries
# an fwmark masked by mwan3's MMX_MASK. The priority gate alone
@@ -149,13 +152,15 @@ stop_service() {
# /var/run/mwan3/mmx_mask and /var/state/mwan3 (set at start_service
# time), so they reflect what the running instance created, not
# whatever is currently in /etc/config/mwan3.
local line pref tbl iif_min iif_max fw_min fw_max ur_min ur_max mask
local line pref tbl iif_min iif_max fw_min fw_max ur_min ur_max src_min src_max mask
iif_min=$((MWAN3_IIF_RULE_BASE + 1))
iif_max=$((MWAN3_IIF_RULE_BASE + MWAN3_INTERFACE_MAX))
fw_min=$((MWAN3_FWMARK_RULE_BASE + 1))
fw_max=$((MWAN3_FWMARK_RULE_BASE + MM_UNREACHABLE))
ur_min=$((MWAN3_UNREACHABLE_RULE_BASE + 1))
ur_max=$((MWAN3_UNREACHABLE_RULE_BASE + MWAN3_INTERFACE_MAX))
src_min=$((MWAN3_UNREACHABLE_RULE_BASE + MWAN3_INTERFACE_MAX + 2))
src_max=$((MWAN3_UNREACHABLE_RULE_BASE + 2 * MWAN3_INTERFACE_MAX + 1))
# ip rule list prints fwmark masks lowercase; normalise for matching
mask=$(echo "$MMX_MASK" | tr 'A-Z' 'a-z')
$IP rule list | while read -r line; do
@@ -166,7 +171,8 @@ stop_service() {
# Priority gate
if ! { { [ "$pref" -ge "$iif_min" ] && [ "$pref" -le "$iif_max" ]; } || \
{ [ "$pref" -ge "$fw_min" ] && [ "$pref" -le "$fw_max" ]; } || \
{ [ "$pref" -ge "$ur_min" ] && [ "$pref" -le "$ur_max" ]; }; }; then
{ [ "$pref" -ge "$ur_min" ] && [ "$pref" -le "$ur_max" ]; } || \
{ [ "$pref" -ge "$src_min" ] && [ "$pref" -le "$src_max" ]; }; }; then
continue
fi
# Content gate (a): carries an fwmark masked by mwan3's MMX_MASK
@@ -190,40 +196,9 @@ stop_service() {
done
done
# Flush all mwan3 chains (rules removed, chains stay as skeletons)
for chain_name in $($NFT list chains inet 2>/dev/null | grep "chain mwan3_" | awk '{print $2}'); do
$NFT flush chain inet mwan3 "$chain_name" >/dev/null 2>&1
done
# Delete dynamic per-interface and per-policy chains (not the skeleton ones from 10-mwan3.nft)
for chain_name in $($NFT list chains inet 2>/dev/null | grep "chain mwan3_" | awk '{print $2}'); do
case "$chain_name" in
mwan3_prerouting|mwan3_output|mwan3_postrouting|mwan3_ifaces_in|mwan3_rules|mwan3_connected|mwan3_custom|mwan3_dynamic)
# These are skeleton chains from the static .nft file; keep them
;;
*)
$NFT delete chain inet mwan3 "$chain_name" >/dev/null 2>&1
;;
esac
done
# Final safety flush of skeleton chains (handles any transient failures above)
for chain_name in mwan3_prerouting mwan3_output mwan3_postrouting mwan3_ifaces_in mwan3_rules mwan3_connected mwan3_custom mwan3_dynamic; do
$NFT flush chain inet mwan3 "$chain_name" >/dev/null 2>&1
done
# Flush all mwan3 sets
for setname in $($NFT list sets inet mwan3 2>/dev/null | grep "set mwan3_" | awk '{print $2}'); do
$NFT flush set inet mwan3 "$setname" >/dev/null 2>&1
done
# Delete dynamic maps (sticky maps)
for mapname in $($NFT list maps inet mwan3 2>/dev/null | grep "map mwan3_" | awk '{print $2}'); do
$NFT flush map inet mwan3 "$mapname" >/dev/null 2>&1
$NFT delete map inet mwan3 "$mapname" >/dev/null 2>&1
done
# Remove the table entirely so a stopped mwan3 leaves no nft residue
# Remove the table entirely. The single atomic delete drops every
# chain, set and map mwan3 created in one kernel transaction, so a
# stopped mwan3 leaves no nft residue.
$NFT delete table inet mwan3 >/dev/null 2>&1
rm -rf $MWAN3_STATUS_DIR $MWAN3TRACK_STATUS_DIR
@@ -233,6 +208,23 @@ stop_service() {
reload_service() {
mwan3_init
# The nft table is created only by the skeleton load in start_service,
# which is skipped when the service starts with every interface
# disabled. In that state the table is absent while the status
# directory (created by mwan3_init) exists, so the service reports
# running and a config change drives a reload, not a start. Rebuilding
# the ruleset here would batch every add against a missing table, the
# atomic commit would fail, and this function would return at the commit
# check before the tracker-mismatch restart below. Recover with a full
# stop and start, which reloads the skeleton and recreates the table.
if ! $NFT list table inet mwan3 >/dev/null 2>&1; then
LOG notice "reload escalated to restart as table inet mwan3 not initialised"
stop
start
return
fi
# Atomically rebuild the entire mwan3 nft ruleset in a single kernel
# transaction. mwan3_nft_reload_start opens the outermost batch level
# (MWAN3_BATCH_DEPTH 0->1) so all subsequent batch_start/commit calls
@@ -256,6 +248,7 @@ reload_service() {
# update them after the nft commit so the table is already consistent.
mwan3_update_iface_to_table
mwan3_set_general_rules
mwan3_set_member_backstops
config_foreach mwan3_rebuild_iface_rules interface
# Signal mwan3rtmon to reload UCI config and re-sync the custom sets.
+2 -2
View File
@@ -16,5 +16,5 @@
# $DEVICE Physical device name of the interface the action relates to (e.g. "eth0" or "wwan0").
# Note: On an ifdown event, $DEVICE is not available, use $INTERFACE instead.
#
# Further documentation can be found here:
# https://openwrt.org/docs/guide-user/network/wan/multiwan/mwan3#alertsnotifications
# Further documentation can be found at:
# https://openwrt.org/docs/guide-user/network/wan/multiwan/mwan3-nft#hotplug_events_mwan3user
@@ -4,6 +4,7 @@
import * as rtnl from "rtnl";
import * as uci from "uci";
import * as ubus from "ubus";
import { log_open, log_msg } from 'mwan3.common';
const RTM_GETROUTE = rtnl.const.RTM_GETROUTE;
const RTM_NEWROUTE = rtnl.const.RTM_NEWROUTE;
@@ -56,12 +57,14 @@ let tid = 0;
cur.foreach("mwan3", "interface", function(s) {
tid++;
let fam = s.family ?? "ipv4";
let enabled = +(s.enabled ?? "1");
let enabled = +(s.enabled ?? "0");
if (enabled && fam == family_name)
name_tid[s[".name"]] = tid;
});
cur.unload("mwan3");
log_open("mwan3-create-iface-route");
let dev_table_map = {};
let uconn = ubus.connect();
if (uconn) {
@@ -117,5 +120,5 @@ for (let route in source_routes) {
rtnl.request(RTM_NEWROUTE, NLM_F_CREATE | NLM_F_REPLACE, r);
let err = rtnl.error();
if (err)
warn(sprintf("mwan3-create-iface-route: table %d: %s\n", table_id, err));
log_msg("err", sprintf("table %d: %s", table_id, err));
}
@@ -2,6 +2,7 @@
'use strict';
import * as rtnl from "rtnl";
import { is_default_equivalent } from 'mwan3.common';
const RTM_GETROUTE = rtnl.const.RTM_GETROUTE;
const NLM_F_DUMP = rtnl.const.NLM_F_DUMP;
@@ -18,12 +19,6 @@ function is_cidr_route(route, family_num) {
return (family_num == AF_INET) ? (prefix_len < 32) : (prefix_len < 128);
}
function is_default_route(route) {
return (route.dst == null ||
route.dst == "0.0.0.0/0" ||
route.dst == "::/0");
}
function is_linklocal_route(route) {
return (route.dst != null &&
(match(route.dst, /^fe80::\//) != null ||
@@ -40,7 +35,7 @@ let seen = {};
for (let route in routes) {
if (route.table != table_num) continue;
if (!is_cidr_route(route, family_num)) continue;
if (is_default_route(route)) continue;
if (is_default_equivalent(route)) continue;
if (is_linklocal_route(route)) continue;
if (family_num == AF_INET) {
let first_octet = +split(route.dst, ".")[0];
+74 -29
View File
@@ -2,11 +2,13 @@
'use strict';
import * as rtnl from "rtnl";
import { log_open, log_msg } from 'mwan3.common';
const RTM_GETRULE = rtnl.const.RTM_GETRULE;
const RTM_DELRULE = rtnl.const.RTM_DELRULE;
const RTM_NEWRULE = rtnl.const.RTM_NEWRULE;
const RTM_GETROUTE = rtnl.const.RTM_GETROUTE;
const RTM_GETADDR = rtnl.const.RTM_GETADDR;
const NLM_F_DUMP = rtnl.const.NLM_F_DUMP;
const NLM_F_CREATE = rtnl.const.NLM_F_CREATE;
const AF_INET = rtnl.const.AF_INET;
@@ -22,6 +24,8 @@ function is_default_route(route) {
route.dst == "::/0");
}
log_open("mwan3-manage-rules");
let mode = ARGV[0];
if (mode == "check") {
@@ -59,20 +63,33 @@ if (mode == "check") {
let id = +ARGV[1];
let iif_base = +ARGV[2];
let fwmark_base = +ARGV[3];
let unreachable_base = +ARGV[4];
let mmx_mask = +ARGV[5];
let mmx_mask = +ARGV[4];
let src_prio = +ARGV[5];
let iif_prio = iif_base + id;
let fwmark_prio = fwmark_base + id;
let unreachable_prio = unreachable_base + id;
for (let family in [AF_INET, AF_INET6]) {
let rules = rtnl.request(RTM_GETRULE, NLM_F_DUMP, { family: family }) ?? [];
let fwmark_val = null;
for (let rule in rules) {
if (rule.table != id) continue;
if (rule.action != FR_ACT_TO_TBL) continue;
if (rule.src != null && rule.priority == src_prio) {
rtnl.request(RTM_DELRULE, 0, {
family: family,
priority: rule.priority,
src: rule.src,
table: rule.table,
action: rule.action
});
let err = rtnl.error();
if (err)
log_msg("err", sprintf("delete src rule failed: %s", err));
continue;
}
if (rule.src != null || rule.dst != null) continue;
if (rule.fwmark == null && rule.priority == iif_prio) {
@@ -84,9 +101,8 @@ if (mode == "check") {
});
let err = rtnl.error();
if (err)
warn(sprintf("mwan3-manage-rules: delete iif rule failed: %s\n", err));
log_msg("err", sprintf("delete iif rule failed: %s", err));
} else if (rule.fwmark != null && rule.priority == fwmark_prio && rule.fwmask == mmx_mask) {
fwmark_val = rule.fwmark;
rtnl.request(RTM_DELRULE, 0, {
family: family,
priority: rule.priority,
@@ -97,27 +113,7 @@ if (mode == "check") {
});
let err = rtnl.error();
if (err)
warn(sprintf("mwan3-manage-rules: delete fwmark rule failed: %s\n", err));
}
}
if (fwmark_val != null) {
for (let rule in rules) {
if (rule.fwmark != fwmark_val) continue;
if (rule.action != FR_ACT_UNREACHABLE) continue;
if (rule.priority != unreachable_prio) continue;
if (rule.fwmask != mmx_mask) continue;
if (rule.src != null || rule.dst != null) continue;
rtnl.request(RTM_DELRULE, 0, {
family: family,
priority: rule.priority,
fwmark: rule.fwmark,
fwmask: rule.fwmask,
action: rule.action
});
let err = rtnl.error();
if (err)
warn(sprintf("mwan3-manage-rules: delete unreachable rule failed: %s\n", err));
log_msg("err", sprintf("delete fwmark rule failed: %s", err));
}
}
}
@@ -144,7 +140,7 @@ if (mode == "check") {
});
let err = rtnl.error();
if (err)
warn(sprintf("mwan3-manage-rules: add blackhole rule failed: %s\n", err));
log_msg("err", sprintf("add blackhole rule failed: %s", err));
}
if (!existing[ur_prio]) {
@@ -157,6 +153,55 @@ if (mode == "check") {
});
let err = rtnl.error();
if (err)
warn(sprintf("mwan3-manage-rules: add unreachable rule failed: %s\n", err));
log_msg("err", sprintf("add unreachable rule failed: %s", err));
}
} else if (mode == "add-backstop") {
let family_num = (ARGV[1] == "6") ? AF_INET6 : AF_INET;
let prio = +ARGV[2], mark = +ARGV[3];
let mask = +ARGV[4];
let rules = rtnl.request(RTM_GETRULE, NLM_F_DUMP, { family: family_num }) ?? [];
let existing = {};
for (let r in rules)
if (r.priority != null)
existing[r.priority] = true;
if (!existing[prio]) {
rtnl.request(RTM_NEWRULE, NLM_F_CREATE, {
family: family_num,
priority: prio,
fwmark: mark,
fwmask: mask,
action: FR_ACT_UNREACHABLE
});
let err = rtnl.error();
if (err)
log_msg("err", sprintf("add backstop rule failed: %s", err));
}
} else if (mode == "add-src") {
// IPv4 only
let device = ARGV[1];
let table_id = +ARGV[2];
let prio = +ARGV[3];
let addrs = rtnl.request(RTM_GETADDR, NLM_F_DUMP, { family: AF_INET }) ?? [];
for (let a in addrs) {
if (a.dev != device) continue;
if (a.scope != 0) continue;
let addr = split(a.local ?? a.address ?? "", "/")[0];
if (!addr) continue;
rtnl.request(RTM_NEWRULE, NLM_F_CREATE, {
family: AF_INET,
priority: prio,
src: addr,
table: table_id,
action: FR_ACT_TO_TBL
});
let err = rtnl.error();
if (err)
log_msg("err", sprintf("add src rule failed: %s", err));
}
}
+336 -100
View File
@@ -266,7 +266,8 @@ _mwan3_render_one_ipset()
set_decl="type ${addr_type}; flags interval"
[ "$timeout" -gt 0 ] && set_decl="$set_decl, timeout"
set_decl="$set_decl; auto-merge;"
set_decl="$set_decl;"
[ "$timeout" -eq 0 ] && set_decl="$set_decl auto-merge;"
[ "$counters" -eq 1 ] && set_decl="$set_decl counter;"
[ "$timeout" -gt 0 ] && set_decl="$set_decl timeout ${timeout}s;"
[ "$maxelem" -gt 0 ] && set_decl="$set_decl size ${maxelem};"
@@ -291,14 +292,35 @@ _mwan3_render_one_ipset()
fi
# Collect all elements (inline list + loadfile) before entering batch.
# Every element is validated against the nft set element grammar (IPv4 and
# IPv6 addresses, CIDR prefixes and hyphen ranges) so a crafted value
# cannot close the element braces early and inject nft statements into the
# batch that is later committed with nft -f. Anything carrying a character
# outside that class is dropped.
local elements="" line
_add_entry() { elements="${elements:+$elements, }$1"; }
_add_entry() {
case "$1" in
""|*[!0-9A-Fa-f:./-]*)
LOG warn "config ipset '$name': dropping invalid entry"
return 0
;;
esac
elements="${elements:+$elements, }$1"
}
config_list_foreach "$section" entry _add_entry
if [ -n "$loadfile" ] && [ -f "$loadfile" ]; then
while IFS= read -r line; do
line="${line%%#*}"
line=$(echo "$line" | xargs 2>/dev/null)
[ -n "$line" ] && elements="${elements:+$elements, }$line"
[ -n "$line" ] || continue
case "$line" in
*[!0-9A-Fa-f:./-]*)
LOG warn "config ipset '$name': dropping invalid loadfile element"
continue
;;
esac
elements="${elements:+$elements, }$line"
done < "$loadfile"
fi
@@ -466,6 +488,41 @@ mwan3_set_general_rules()
"$MMX_MASK"
}
# Install a static unreachable ip rule for every configured interface mark, in
# the per-member unreachable priority band. These are created at service start
# and reload and removed only at stop, so a packet carrying a member mark whose
# fwmark rule is currently absent (the member is down, disabled, or briefly
# between a delete and re-add) is rejected instead of leaking to the main
# routing table. One rule per member is required because an fwmark rule matches
# an exact value under the mask, not "any member mark"; the reserved last_resort
# marks are never enumerated here, so last_resort behaviour is unaffected.
mwan3_set_member_backstops()
{
mwan3_add_backstop()
{
local id family fam
config_get family "$1" family ipv4
mwan3_get_iface_id id "$1"
[ -n "$id" ] || return 0
if [ "$family" = "ipv4" ]; then
fam=4
elif [ "$family" = "ipv6" ] && [ $NO_IPV6 -eq 0 ]; then
fam=6
else
return 0
fi
${MWAN3_MANAGE_RULES} add-backstop "$fam" \
"$((id+MWAN3_UNREACHABLE_RULE_BASE))" \
"$(mwan3_id2mask id MMX_MASK)" "$MMX_MASK"
}
config_foreach mwan3_add_backstop interface
}
mwan3_set_general_nft()
{
local chain_exists restore_vmap save_vmap all_marks
@@ -843,16 +900,112 @@ mwan3_delete_iface_map_entries()
# Sticky scheme: one set per (rule, family, iface_id) holding
# only saddrs (no value side). Removing an interface invalidates every
# such set whose name ends in "_<id>"; we flush rather than delete since
# rule chains may still reference the set name.
# rule chains may still reference the set name. The flush lines are pushed
# into the caller's open nft batch so they commit in the same transaction
# as the policy rebuild; the set enumeration reads committed kernel state
# and so stays outside the batch. Call only within an open nft batch.
for setname in $($NFT list sets inet 2>/dev/null | \
awk '$1=="set" && $2 ~ /^mwan3_sticky_v[46]_/ { print $2 }'); do
case "$setname" in
*_"$id") $NFT flush set inet mwan3 "$setname" 2>/dev/null ;;
*_"$id") mwan3_nft_push "flush set inet mwan3 $setname" ;;
esac
done
}
mwan3_flush_worse_metric_sticky()
{
# On recovery of a better-metric member, snap new flows off the worse-metric
# backups it is now preferred over. For each policy the recovered interface
# participates in, flush the per-rule sticky sets of that policy's same-family
# members whose metric is strictly worse, scoped to the rules that use the
# policy, so pinned sources re-evaluate to the recovered member on their next
# new flow while established flows ride their conntrack marks to completion
# undisturbed. The scope is per policy and per rule by construction: each set
# name is built from the rule name and the member id rather than flushing
# every set ending in an id, so a member that is also a deliberate-stickiness
# member of a separate load-balance policy keeps those pins.
local recovered="$1" recovered_id rec_family rec_fam_short
local _snap_pairs=""
mwan3_get_iface_id recovered_id "$recovered"
[ -n "$recovered_id" ] || return 0
# mwan3 runs an independent metric contest per address family, so only
# same-family members compete with the recovered one; resolve its family once
# and snap only that family's backups.
config_get rec_family "$recovered" family ipv4
if [ "$rec_family" = "ipv6" ]; then
rec_fam_short="v6"
else
rec_fam_short="v4"
fi
# Record one "policy=worse-id" pair per strictly-worse-metric same-family
# member of each policy the recovered interface is in. The recovered
# interface's own id is excluded so its own pins are never cleared.
_mwan3_snap_collect() {
local policy="$1" rec_metric=""
# The recovered interface's metric in this policy sets the threshold;
# take the lowest if it appears more than once.
_mwan3_snap_rec_metric() {
local m_iface m_metric
config_get m_iface "$1" interface
[ "$m_iface" = "$recovered" ] || return
config_get m_metric "$1" metric 1
{ [ -z "$rec_metric" ] || [ "$m_metric" -lt "$rec_metric" ]; } && rec_metric="$m_metric"
}
config_list_foreach "$policy" use_member _mwan3_snap_rec_metric
# Policy does not include the recovered interface: nothing to snap.
[ -n "$rec_metric" ] || return
_mwan3_snap_worse() {
local m_iface m_metric m_id m_family
config_get m_iface "$1" interface
[ -n "$m_iface" ] || return
config_get m_family "$m_iface" family ipv4
[ "$m_family" = "$rec_family" ] || return
config_get m_metric "$1" metric 1
[ "$m_metric" -gt "$rec_metric" ] || return
mwan3_get_iface_id m_id "$m_iface"
[ -n "$m_id" ] || return
[ "$m_id" = "$recovered_id" ] && return
case " $_snap_pairs " in
*" ${policy}=${m_id} "*) ;;
*) _snap_pairs="$_snap_pairs ${policy}=${m_id}" ;;
esac
}
config_list_foreach "$policy" use_member _mwan3_snap_worse
}
config_foreach _mwan3_snap_collect policy
[ -n "$_snap_pairs" ] || return 0
# For every rule, flush the worse-metric members' per-rule sticky sets of the
# policy that rule uses, in the recovered member's family. A set name that
# does not exist is a harmless no-op.
_mwan3_snap_flush() {
local r_policy pair p_name p_id
config_get r_policy "$1" use_policy
[ -n "$r_policy" ] || return
for pair in $_snap_pairs; do
p_name="${pair%=*}"
p_id="${pair##*=}"
[ "$p_name" = "$r_policy" ] || continue
$NFT flush set inet mwan3 "mwan3_sticky_${rec_fam_short}_${1}_${p_id}" 2>/dev/null
done
}
config_foreach _mwan3_snap_flush rule
}
mwan3_create_iface_route()
{
local family id source_routing _fam_num
@@ -904,7 +1057,8 @@ mwan3_create_iface_rules()
$IP rule add pref $((id+MWAN3_IIF_RULE_BASE)) iif "$2" lookup "$id" 2>/dev/null
$IP rule add pref $((id+MWAN3_FWMARK_RULE_BASE)) fwmark "$(mwan3_id2mask id MMX_MASK)/$MMX_MASK" lookup "$id" 2>/dev/null
$IP rule add pref $((id+MWAN3_UNREACHABLE_RULE_BASE)) fwmark "$(mwan3_id2mask id MMX_MASK)/$MMX_MASK" unreachable 2>/dev/null
[ "$family" = "ipv4" ] && ${MWAN3_MANAGE_RULES} add-src "$2" "$id" \
"$((id + MWAN3_UNREACHABLE_RULE_BASE + MWAN3_INTERFACE_MAX + 1))"
}
mwan3_delete_iface_rules()
@@ -915,13 +1069,13 @@ mwan3_delete_iface_rules()
[ -n "$id" ] || return 0
${MWAN3_MANAGE_RULES} delete-iface "$id" \
"$MWAN3_IIF_RULE_BASE" "$MWAN3_FWMARK_RULE_BASE" \
"$MWAN3_UNREACHABLE_RULE_BASE" "$MMX_MASK"
"$MWAN3_IIF_RULE_BASE" "$MWAN3_FWMARK_RULE_BASE" "$MMX_MASK" \
"$((id + MWAN3_UNREACHABLE_RULE_BASE + MWAN3_INTERFACE_MAX + 1))"
}
mwan3_set_policy()
{
local id iface family metric weight device is_lowest is_offline
local id iface family metric weight is_lowest is_offline
is_lowest=0
config_get iface "$1" interface
@@ -929,7 +1083,6 @@ mwan3_set_policy()
config_get weight "$1" weight 1
[ -n "$iface" ] || return 0
network_get_device device "$iface"
[ "$metric" -gt $DEFAULT_LOWEST_METRIC ] && LOG warn "Member interface $iface has >$DEFAULT_LOWEST_METRIC metric. Not appending to policy" && return 0
mwan3_get_iface_id id "$iface"
@@ -976,30 +1129,28 @@ mwan3_set_policy()
if [ $is_offline -eq 0 ]; then
# Accumulate members per family: "iface_name:id:weight" tuples
# Accumulate members per family: "iface_name:id:weight" tuples.
# The branches mirror the metric contest above, so a member only
# accumulates through its interface family's contest: an IPv6
# member is excluded on a kernel without IPv6, and an
# unrecognised family value is never accumulated.
if [ "$family" = "ipv4" ]; then
policy_members_v4="$policy_members_v4 $iface:$id:$weight"
else
elif [ "$family" = "ipv6" ] && [ $NO_IPV6 -eq 0 ]; then
policy_members_v6="$policy_members_v6 $iface:$id:$weight"
fi
elif [ -n "$device" ]; then
# Offline interface with device: record for fallback out-device rule
policy_offline_devices="$policy_offline_devices $iface:$device"
fi
}
mwan3_create_policies_nft()
{
local last_resort lowest_metric_v4 lowest_metric_v6 total_weight_v4 total_weight_v6
local policy policy_members_v4 policy_members_v6 policy_offline_devices
local policy policy_members_v4 policy_members_v6
policy="$1"
policy_members_v4=""
policy_members_v6=""
policy_offline_devices=""
config_get last_resort "$1" last_resort unreachable
@@ -1097,22 +1248,6 @@ mwan3_create_policies_nft()
done
fi
# Add offline device fallback rules
local dev_entry offline_iface offline_device
# Only add if no online members
if [ "$total_weight" -eq 0 ]; then
for dev_entry in $policy_offline_devices; do
offline_iface="${dev_entry%%:*}"
offline_device="${dev_entry#*:}"
mwan3_nft_exec add rule inet mwan3 "mwan3_policy_$policy" \
oifname "$offline_device" meta mark \& "$MMX_MASK" == 0 \
"$(mwan3_nft_mark_expr $MMX_DEFAULT $MMX_MASK)"
done
fi
# Add last resort rule
case "$last_resort" in
@@ -1214,7 +1349,7 @@ mwan3_set_user_nft_rule()
local ipset_name ipset_src family proto policy src_ip src_port src_iface src_dev
local sticky dest_ip dest_port use_policy timeout policy
local global_logging rule_logging loglevel rule_policy rule ipv
local enabled fwmark fwmask _check_set _set_info
local enabled fwmark fwmask _check_set _set_info _fam_anchored
config_get_bool enabled "$1" enabled 1
[ "$enabled" -eq 1 ] || return
@@ -1262,18 +1397,30 @@ mwan3_set_user_nft_rule()
[ "$family" = "ipv4" ] && [ "$ipv" = "ipv6" ] && return
[ "$family" = "ipv6" ] && [ "$ipv" = "ipv4" ] && return
# family=any rules whose nft expression has no IP-version-specific element
# (no src_ip/dest_ip/ipset/ipset_src) generate identical output on both the
# ipv4 and ipv6 passes. Skip the ipv6 pass to avoid pushing a duplicate
# rule. The ipv4 pass output already matches IPv6 traffic at runtime
# because the match operates on family-agnostic fields (meta mark, l4proto,
# port, iifname). Exception: proto=icmp requires both passes because ICMP
# (protocol 1) and ICMPv6 (protocol 58) are distinct L4 protocols.
# A family-anchoring element (src_ip/dest_ip/ipset/ipset_src) ties the
# rendered expression to one address family per pass. This list must
# cover every matcher whose nft form differs by address family; the
# de-duplication guard and the sticky block both key on the flag.
_fam_anchored=0
if [ -n "$src_ip" ] || [ -n "$dest_ip" ] || \
[ -n "$ipset_name" ] || [ -n "$ipset_src" ]; then
_fam_anchored=1
fi
# family=any rules with no family-anchoring element render once, on
# the ipv4 pass: the emitted line matches on family-agnostic fields
# (meta mark, l4proto, port, iifname, ether saddr) and carries no
# nfproto guard, so it covers both families at its config position,
# and the sticky block builds the per-rule chain complete for both
# families on that pass. Skip the ipv6 pass to avoid a duplicate
# line. Exception: proto=icmp requires both passes because ICMP
# (protocol 1) and ICMPv6 (protocol 58) are distinct L4 protocols;
# the ipv6 pass then emits its own line but must not rebuild the
# sticky chain.
if [ "$family" = "any" ] && [ "$ipv" = "ipv6" ] && \
[ -z "$src_ip" ] && [ -z "$dest_ip" ] && \
[ -z "$ipset_name" ] && [ -z "$ipset_src" ] && \
[ "$proto" != "icmp" ]; then
[ $_fam_anchored -eq 0 ] && [ "$proto" != "icmp" ]; then
return
fi
@@ -1300,7 +1447,13 @@ mwan3_set_user_nft_rule()
# the src_ip/dest_ip address validation above: a set of type ipv4_addr
# cannot appear in an "ip6 daddr @set" expression, and vice versa.
local _ipset_name_uci_type="" _ipset_src_uci_type=""
local _ipset_name_uci_type="" _ipset_src_uci_type="" _pass_addrtype
if [ "$ipv" = "ipv4" ]; then
_pass_addrtype="ipv4_addr"
else
_pass_addrtype="ipv6_addr"
fi
for _check_set in "$ipset_name" "$ipset_src"; do
[ -z "$_check_set" ] && continue
local _set_info
@@ -1339,6 +1492,25 @@ mwan3_set_user_nft_rule()
[ "$ipv" = "ipv6" ] && [ "$_uci_addrtype" = "ipv4_addr" ] && return
continue
fi
# An earlier rule in this render may already have pre-created
# the set into the open batch, which the kernel query above
# cannot see. Consult the render's own record instead: the
# first pre-creation establishes the set's type for the whole
# render, and every later reference is treated exactly as if
# the kernel held a set of that type.
case " $MWAN3_RENDER_SETS " in
*" ${_check_set}=${_pass_addrtype} "*)
continue
;;
*" ${_check_set}="*)
[ "$family" = "any" ] && return
LOG warn "Rule $rule: set '$_check_set' pre-created as the other address family by an earlier rule, incompatible with family $family"
return
;;
esac
# Not UCI-managed: apply existing guard for external sets.
# For family=any, skip the ipv6 pass; the ipv4 pass will pre-create
# the set as ipv4_addr until an external creator establishes its type.
@@ -1439,15 +1611,20 @@ mwan3_set_user_nft_rule()
# set is missing, which would kill ALL user rules.
# UCI-managed sets are already added to the batch by mwan3_render_config_ipsets;
# skip the kernel check and pre-creation for those.
# A set this render has already pre-created is in the record and is
# never added twice: two adds of one name in a batch are rejected
# when the types differ, and redundant when they match.
if [ -z "$_ipset_name_uci_type" ] && \
! $NFT list set inet mwan3 "$ipset_name" &>/dev/null; then
LOG notice "Creating missing nft set '$ipset_name' for rule $rule"
if [ "$ipv" = "ipv4" ]; then
mwan3_nft_push "add set inet mwan3 $ipset_name { type ipv4_addr; flags interval; auto-merge; }"
else
mwan3_nft_push "add set inet mwan3 $ipset_name { type ipv6_addr; flags interval; auto-merge; }"
fi
case " $MWAN3_RENDER_SETS " in
*" ${ipset_name}=${_pass_addrtype} "*) ;;
*)
LOG notice "Creating missing nft set '$ipset_name' for rule $rule"
mwan3_nft_push "add set inet mwan3 $ipset_name { type ${_pass_addrtype}; flags interval; auto-merge; }"
MWAN3_RENDER_SETS="$MWAN3_RENDER_SETS ${ipset_name}=${_pass_addrtype}"
;;
esac
fi
if [ "$ipv" = "ipv4" ]; then
nft_match="$nft_match ip daddr @$ipset_name"
@@ -1461,12 +1638,14 @@ mwan3_set_user_nft_rule()
if [ -n "$ipset_src" ]; then
if [ -z "$_ipset_src_uci_type" ] && \
! $NFT list set inet mwan3 "$ipset_src" &>/dev/null; then
LOG notice "Creating missing nft set '$ipset_src' for rule $rule"
if [ "$ipv" = "ipv4" ]; then
mwan3_nft_push "add set inet mwan3 $ipset_src { type ipv4_addr; flags interval; auto-merge; }"
else
mwan3_nft_push "add set inet mwan3 $ipset_src { type ipv6_addr; flags interval; auto-merge; }"
fi
case " $MWAN3_RENDER_SETS " in
*" ${ipset_src}=${_pass_addrtype} "*) ;;
*)
LOG notice "Creating missing nft set '$ipset_src' for rule $rule"
mwan3_nft_push "add set inet mwan3 $ipset_src { type ${_pass_addrtype}; flags interval; auto-merge; }"
MWAN3_RENDER_SETS="$MWAN3_RENDER_SETS ${ipset_src}=${_pass_addrtype}"
;;
esac
fi
if [ "$ipv" = "ipv4" ]; then
nft_match="$nft_match ip saddr @$ipset_src"
@@ -1509,7 +1688,7 @@ mwan3_set_user_nft_rule()
# like default_rule (family ipv4, no saddr/daddr) generates a bare
# "meta mark ... jump policy" that matches IPv6 traffic too.
if [ -z "$src_ip" ] && [ -z "$dest_ip" ] && [ -z "$ipset_name" ] && [ -z "$ipset_src" ]; then
if [ $_fam_anchored -eq 0 ]; then
if [ "$family" = "ipv4" ]; then
nft_match="${nft_match:+$nft_match }meta nfproto ipv4"
elif [ "$family" = "ipv6" ]; then
@@ -1549,49 +1728,86 @@ mwan3_set_user_nft_rule()
# bit. The save side mirrors this with per-member "update @set" rules
# guarded on (meta mark & MMX) == <member_mark>.
# The per-rule chain is shared by every mwan3_rules line that
# jumps to it, so it is built exactly once, complete for every
# family whose traffic can reach it. A family=any rule with no
# family-anchoring element serves both families from the single
# line emitted on the ipv4 pass, so that pass builds both
# families' arms; for proto=icmp the ipv6 pass also reaches this
# block and only points its own line at the finished chain. The
# layout is load-bearing: every lookup precedes the single
# fall-through, so a pinned source ORs its member mark into the
# packet before the fall-through can hand it to the policy, and
# the update rules follow.
local _policy_member_marks _entry _m_id _m_mark _setname
local _fam_short _saddr_kw _addr_type
if [ "$ipv" = "ipv4" ]; then
_fam_short="v4"; _saddr_kw="ip saddr"; _addr_type="ipv4_addr"
else
_fam_short="v6"; _saddr_kw="ip6 saddr"; _addr_type="ipv6_addr"
local _fam_short _saddr_kw _addr_type _sticky_families _sfam
_sticky_families="$ipv"
if [ "$family" = "any" ] && [ $_fam_anchored -eq 0 ]; then
if [ "$ipv" = "ipv6" ]; then
_sticky_families=""
elif [ $NO_IPV6 -eq 0 ]; then
_sticky_families="ipv4 ipv6"
fi
fi
mwan3_get_policy_members_for_family "$use_policy" "$ipv"
_sticky_fam_vars() {
if [ "$1" = "ipv4" ]; then
_fam_short="v4"; _saddr_kw="ip saddr"; _addr_type="ipv4_addr"
else
_fam_short="v6"; _saddr_kw="ip6 saddr"; _addr_type="ipv6_addr"
fi
}
# Create sticky rule chain if it doesn't exist yet. The chain was
# already flushed in the preamble of mwan3_set_user_rules, so both
# ipv4 and ipv6 passes can add their rules without interference.
if [ -n "$_sticky_families" ]; then
mwan3_nft_push "add chain inet mwan3 mwan3_rule_$1"
# Create sticky rule chain if it doesn't exist yet. The chain
# was already flushed in the preamble of mwan3_set_user_rules.
# Per-member sticky sets and lookup rules.
mwan3_nft_push "add chain inet mwan3 mwan3_rule_$1"
for _entry in $_policy_member_marks; do
_m_id="${_entry%%:*}"
_m_mark="${_entry##*:}"
_setname="mwan3_sticky_${_fam_short}_${rule}_${_m_id}"
# Per-member sticky sets and lookup rules, every served
# family's lookups ahead of the fall-through.
$NFT list set inet mwan3 "$_setname" &>/dev/null || \
mwan3_nft_push "add set inet mwan3 $_setname { type ${_addr_type}; flags timeout; timeout ${timeout}s; }"
for _sfam in $_sticky_families; do
_sticky_fam_vars "$_sfam"
mwan3_get_policy_members_for_family "$use_policy" "$_sfam"
mwan3_nft_push "add rule inet mwan3 mwan3_rule_$1 ${_saddr_kw} @${_setname} jump mwan3_or_meta_$(mwan3_or_chain_suffix "$_m_mark")"
done
for _entry in $_policy_member_marks; do
_m_id="${_entry%%:*}"
_m_mark="${_entry##*:}"
_setname="mwan3_sticky_${_fam_short}_${rule}_${_m_id}"
# Fall through to policy for new flows (no sticky entry hit -> mark still 0).
$NFT list set inet mwan3 "$_setname" &>/dev/null || \
mwan3_nft_push "add set inet mwan3 $_setname { type ${_addr_type}; flags timeout; timeout ${timeout}s; }"
mwan3_nft_push "add rule inet mwan3 mwan3_rule_$1 meta mark & $MMX_MASK == 0 jump mwan3_policy_$use_policy"
mwan3_nft_push "add rule inet mwan3 mwan3_rule_$1 ${_saddr_kw} @${_setname} jump mwan3_or_meta_$(mwan3_or_chain_suffix "$_m_mark")"
done
done
# After the policy assigns a mark, populate the matching per-member
# sticky set so subsequent packets from this saddr stay on the same WAN.
# Fall through to policy for new flows (no sticky entry hit ->
# mark still 0).
for _entry in $_policy_member_marks; do
_m_id="${_entry%%:*}"
_m_mark="${_entry##*:}"
_setname="mwan3_sticky_${_fam_short}_${rule}_${_m_id}"
mwan3_nft_push "add rule inet mwan3 mwan3_rule_$1 meta mark & $MMX_MASK == 0 jump mwan3_policy_$use_policy"
mwan3_nft_push "add rule inet mwan3 mwan3_rule_$1 meta mark & $MMX_MASK == $_m_mark update @${_setname} { ${_saddr_kw} timeout ${timeout}s }"
done
# After the policy assigns a mark, populate the matching
# per-member sticky set so subsequent packets from this saddr
# stay on the same WAN.
for _sfam in $_sticky_families; do
_sticky_fam_vars "$_sfam"
mwan3_get_policy_members_for_family "$use_policy" "$_sfam"
for _entry in $_policy_member_marks; do
_m_id="${_entry%%:*}"
_m_mark="${_entry##*:}"
_setname="mwan3_sticky_${_fam_short}_${rule}_${_m_id}"
mwan3_nft_push "add rule inet mwan3 mwan3_rule_$1 meta mark & $MMX_MASK == $_m_mark update @${_setname} { ${_saddr_kw} timeout ${timeout}s }"
done
done
fi
policy_action="jump mwan3_rule_$1"
fi
@@ -1638,14 +1854,21 @@ mwan3_set_user_iface_rules()
mwan3_set_user_rules()
{
local ipv
# Record of the external sets this render has pre-created into the
# batch, as space-delimited "name=addrtype" tokens, read and appended
# to by mwan3_set_user_nft_rule through dynamic scoping. Kernel
# queries inside an open batch see pre-batch state, so the renderer
# carries its own shadow of what the batch already holds. Reset per
# render, so repeated renders emit identical batches.
local MWAN3_RENDER_SETS=""
mwan3_nft_batch_start
mwan3_nft_push "flush chain inet mwan3 mwan3_rules"
# Pre-create and flush per-rule chains for all enabled UCI rules, before
# the per-family loop. Sourcing names from UCI (not the kernel) prevents
# the render pass. Sourcing names from UCI (not the kernel) prevents
# chains for deleted rules from being recreated after reload. "add chain"
# is idempotent: it recreates a chain deleted by mwan3_nft_reload_start or
# is a no-op if the chain already exists (hotplug path).
@@ -1659,10 +1882,22 @@ mwan3_set_user_rules()
}
config_foreach _init_rule_chain rule
for ipv in ipv4 ipv6; do
[ "$ipv" = "ipv6" ] && [ $NO_IPV6 -ne 0 ] && continue
config_foreach mwan3_set_user_nft_rule rule "$ipv"
done
# Render the rules in one pass over the config, in config order,
# emitting each rule's IPv4 and IPv6 variants at that rule's position.
# The chain is evaluated first-match, so a rule's position in the chain
# must equal its position in the config; rendering per family instead
# would place every IPv6-specific rule after every IPv4-pass rule,
# letting a family-agnostic rule shadow IPv6 rules the config places
# before it.
_render_rule() {
local _rr_ipv
for _rr_ipv in ipv4 ipv6; do
[ "$_rr_ipv" = "ipv6" ] && [ $NO_IPV6 -ne 0 ] && continue
mwan3_set_user_nft_rule "$1" "$_rr_ipv"
done
}
config_foreach _render_rule rule
mwan3_nft_batch_commit
}
@@ -2018,12 +2253,13 @@ mwan3_flush_conntrack()
config_list_foreach "$interface" flush_conntrack handle_flush "$action"
fi
# On ifdown, selectively flush conntrack entries for this interface's mark.
# This forces flows that were using the failed WAN to immediately re-establish
# via the new policy rather than waiting for a TCP retransmit timeout.
# More targeted than the UCI flush_conntrack mechanism which flushes everything.
# On a teardown transition (a hard ifdown or a soft tracker disconnect),
# selectively flush conntrack entries carrying this interface's mark. This
# forces flows that were using the failed WAN to immediately re-establish via
# the new policy rather than waiting for a TCP retransmit timeout. More
# targeted than the UCI flush_conntrack mechanism which flushes everything.
if [ "$action" = "ifdown" ] && [ -e "$CONNTRACK_FILE" ]; then
if { [ "$action" = "ifdown" ] || [ "$action" = "disconnected" ]; } && [ -e "$CONNTRACK_FILE" ]; then
local iface_id iface_mark
mwan3_get_iface_id iface_id "$interface"
if [ -n "$iface_id" ]; then
+128 -71
View File
@@ -87,14 +87,43 @@ let mwan3_cfg = null;
let pub6_seen = {};
let pub4_seen = {};
let lit_alias = {};
// Register one public address. The placeholder map is keyed and numbered on
// normalised forms only; when the literal spelling differs from the
// normalised form it is recorded as an alias so free-form text carrying
// that spelling is still replaced.
function register(addr_str) {
if (!addr_str) return;
let lit = split(addr_str, '/')[0];
let norm = norm_addr(addr_str);
if (!norm) return;
let sa = sock.sockaddr(split(addr_str, '/')[0]);
let sa = sock.sockaddr(lit);
if (sa.family == 10 && is_public_v6(norm)) pub6_seen[norm] = 1;
else if (sa.family == 2 && is_public_v4(norm)) pub4_seen[norm] = 1;
else return;
if (lit != norm) lit_alias[lit] = norm;
}
// Sweep a block of free-form text for address literals. Splitting on any
// run of characters that cannot occur inside an address or prefix keeps
// every address-bearing token intact; a token that is not a valid public
// address fails validation inside register() and drops out. A colon-less
// token must be a full dotted quad: the address parser also accepts
// shorthand forms like "1.0", which free-form text produces as ordinary
// decimal fragments, and registering one would alias that fragment to a
// placeholder everywhere in the report.
function scan_text(text) {
if (!text) return;
for (let tok in split(text, /[^0-9a-fA-F:.\/]+/)) {
if (!length(tok)) continue;
if (index(tok, ':') == -1 &&
!match(tok, /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(\/[0-9]+)?$/))
continue;
register(tok);
}
}
for (let n in nb_all) { register(n.dst); }
@@ -110,57 +139,8 @@ if (mwan3_st?.interfaces)
// Register any addresses appearing in the mwan3 UCI config (e.g. track_ip
// values not yet reflected in routes/status).
if (mwan3_cfg)
for (let line in split(mwan3_cfg, '\n'))
for (let tok in split(replace(line, "'", ' '), ' '))
register(trim(tok));
// ---------------------------------------------------------------------------
// Phase 3: build deterministic placeholder map
// Sort addresses so the same address always gets the same placeholder across
// multiple runs (e.g. v3.4 run vs v3.5.1 run).
// ---------------------------------------------------------------------------
let pub6_list = sort(keys(pub6_seen));
let pub4_list = sort(keys(pub4_seen));
let addr_map = {};
for (let i = 0; i < length(pub6_list); i++)
addr_map[pub6_list[i]] = sprintf('PUB6_%d', i + 1);
for (let i = 0; i < length(pub4_list); i++)
addr_map[pub4_list[i]] = sprintf('PUB4_%d', i + 1);
// ---------------------------------------------------------------------------
// Anonymisation helpers
// ---------------------------------------------------------------------------
// Anonymise a single address string (preserves any /prefix-length suffix).
function anon(addr_str) {
if (!addr_str) return addr_str;
let parts = split(addr_str, '/');
let norm = norm_addr(parts[0]);
let ph = norm ? (addr_map[norm] ?? null) : null;
if (ph) return ph + (length(parts) > 1 ? '/' + parts[1] : '');
return addr_str;
}
// Anonymise a block of free-form text (nft output, log lines, etc.).
// Sorted longest-first so longer addresses are replaced before any shorter
// prefix they might share (prevents partial substitutions).
let _sorted_addrs = sort(keys(addr_map), (a, b) => length(b) - length(a));
function anon_text(text) {
if (!text) return '(no output)';
for (let addr in _sorted_addrs) {
let ph = addr_map[addr];
let pos = 0;
while (true) {
let found = index(text, addr, pos);
if (found == -1) break;
text = substr(text, 0, found) + ph + substr(text, found + length(addr));
pos = found + length(ph);
}
}
return text;
}
scan_text(mwan3_cfg);
// Strip elements from user-defined nft sets, replacing with { ... }.
// mwan3 infrastructure sets (names starting with mwan3_) are left intact:
@@ -197,13 +177,102 @@ function strip_set_elements(text) {
return join('\n', out);
}
// Run a shell command and return its anonymised output.
function pcmd(cmd) {
// Run a shell command and return its raw output.
function rcmd(cmd) {
let f = fs.popen(cmd);
if (!f) return '(failed to run: ' + cmd + ')\n';
let out = f.read('all');
f.close();
return anon_text(out || '(no output)\n');
return out || '(no output)\n';
}
// ---------------------------------------------------------------------------
// Detect which table mwan3 is using (v3.4 = inet fw4, v3.5+ = inet mwan3)
// ---------------------------------------------------------------------------
let mwan3_tbl = 'inet mwan3';
{
let f = fs.popen('nft list chain inet mwan3 mwan3_prerouting 2>/dev/null | head -1');
let l = f?.read('line');
f?.close();
if (!l || !length(trim(l))) mwan3_tbl = 'inet fw4';
}
// ---------------------------------------------------------------------------
// Capture the free-form text sections the report prints and sweep them for
// address literals. Some public addresses exist only in this text, e.g. NAT
// map elements derived from no route or interface address, or logged
// addresses no longer present in live state, and are registered here so
// they receive placeholders. Each section is captured exactly once and the
// captured text itself is printed later, so no literal can appear in the
// printed copy without having been swept. The mwan3 table is stripped of
// user-set elements before the sweep so redacted addresses are not
// assigned placeholders.
// ---------------------------------------------------------------------------
let nft_mwan3_txt = strip_set_elements(rcmd(sprintf('nft list table %s 2>/dev/null', mwan3_tbl)));
let fw4_mangle_pre_txt = rcmd('nft list chain inet fw4 mangle_prerouting 2>/dev/null');
let fw4_mangle_out_txt = rcmd('nft list chain inet fw4 mangle_output 2>/dev/null');
let fw4_output_txt = rcmd('nft list chain inet fw4 output 2>/dev/null');
let log_txt = rcmd('logread 2>/dev/null | grep -E "mwan3track\\[|mwan3-hotplug\\[|mwan3-init\\[|mwan3\\[|mwan3rtmon\\[|mwan3-manage-rules\\[|mwan3-create-iface-route\\[" | tail -200');
scan_text(nft_mwan3_txt);
scan_text(fw4_mangle_pre_txt);
scan_text(fw4_mangle_out_txt);
scan_text(fw4_output_txt);
scan_text(log_txt);
// ---------------------------------------------------------------------------
// Phase 3: build deterministic placeholder map
// Sort addresses so the same address always gets the same placeholder across
// multiple runs (e.g. v3.4 run vs v3.5.1 run).
// ---------------------------------------------------------------------------
let pub6_list = sort(keys(pub6_seen));
let pub4_list = sort(keys(pub4_seen));
let addr_map = {};
for (let i = 0; i < length(pub6_list); i++)
addr_map[pub6_list[i]] = sprintf('PUB6_%d', i + 1);
for (let i = 0; i < length(pub4_list); i++)
addr_map[pub4_list[i]] = sprintf('PUB4_%d', i + 1);
// A literal spelling that normalises to a registered address receives the
// registered form's placeholder, so both spellings are replaced in text.
for (let lit, norm in lit_alias)
if (addr_map[norm]) addr_map[lit] = addr_map[norm];
// ---------------------------------------------------------------------------
// Anonymisation helpers
// ---------------------------------------------------------------------------
// Anonymise a single address string (preserves any /prefix-length suffix).
function anon(addr_str) {
if (!addr_str) return addr_str;
let parts = split(addr_str, '/');
let norm = norm_addr(parts[0]);
let ph = norm ? (addr_map[norm] ?? null) : null;
if (ph) return ph + (length(parts) > 1 ? '/' + parts[1] : '');
return addr_str;
}
// Anonymise a block of free-form text (nft output, log lines, etc.).
// Sorted longest-first so longer addresses are replaced before any shorter
// prefix they might share (prevents partial substitutions).
let _sorted_addrs = sort(keys(addr_map), (a, b) => length(b) - length(a));
function anon_text(text) {
if (!text) return '(no output)';
for (let addr in _sorted_addrs) {
let ph = addr_map[addr];
let pos = 0;
while (true) {
let found = index(text, addr, pos);
if (found == -1) break;
text = substr(text, 0, found) + ph + substr(text, found + length(addr));
pos = found + length(ph);
}
}
return text;
}
// ---------------------------------------------------------------------------
@@ -224,18 +293,6 @@ function tbl_label(n) {
return name ? sprintf('%s (%d)', name, n) : sprintf('%d', n);
}
// ---------------------------------------------------------------------------
// Detect which table mwan3 is using (v3.4 = inet fw4, v3.5+ = inet mwan3)
// ---------------------------------------------------------------------------
let mwan3_tbl = 'inet mwan3';
{
let f = fs.popen('nft list chain inet mwan3 mwan3_prerouting 2>/dev/null | head -1');
let l = f?.read('line');
f?.close();
if (!l || !length(trim(l))) mwan3_tbl = 'inet fw4';
}
// ---------------------------------------------------------------------------
// mwan3 version
// ---------------------------------------------------------------------------
@@ -407,20 +464,20 @@ else
// --- full mwan3 nft table ---
printf('=== mwan3 nft table (%s) ===\n', mwan3_tbl);
printf('%s\n', strip_set_elements(pcmd(sprintf('nft list table %s 2>/dev/null', mwan3_tbl))));
printf('%s\n', anon_text(nft_mwan3_txt));
// --- fw4 mangle chains (relevant to mwan3 mark interactions) ---
printf('=== fw4 mangle_prerouting chain ===\n');
printf('%s\n', pcmd('nft list chain inet fw4 mangle_prerouting 2>/dev/null'));
printf('%s\n', anon_text(fw4_mangle_pre_txt));
printf('=== fw4 mangle_output chain ===\n');
printf('%s\n', pcmd('nft list chain inet fw4 mangle_output 2>/dev/null'));
printf('%s\n', anon_text(fw4_mangle_out_txt));
printf('=== fw4 output chain ===\n');
printf('%s\n', pcmd('nft list chain inet fw4 output 2>/dev/null'));
printf('%s\n', anon_text(fw4_output_txt));
// --- recent mwan3 log ---
printf('=== Recent mwan3 Log (last 200 lines) ===\n');
printf('%s\n', pcmd('logread 2>/dev/null | grep -E "mwan3track\\[|mwan3-hotplug\\[|mwan3-init\\[|mwan3\\[" | tail -200'));
printf('%s\n', anon_text(log_txt));
printf('=== End of Report ===\n');
+178 -7
View File
@@ -5,7 +5,8 @@ import * as rtnl from "rtnl";
import * as uloop from "uloop";
import * as uci from "uci";
import * as ubus from "ubus";
import { open, readfile, popen, stat } from "fs";
import { open, readfile, popen, stat, mkdir, rename, unlink } from "fs";
import { log_open, log_verbose, log_msg as mlog, ucibool, is_default_equivalent } from 'mwan3.common';
const RTM_NEWROUTE = rtnl.const.RTM_NEWROUTE;
const RTM_DELROUTE = rtnl.const.RTM_DELROUTE;
@@ -32,6 +33,7 @@ let iface_list = []; // [ { name, family, tid, enabled, has_tracking } ]
let active_chains = {}; // { chain_name: true } cached from nft
let connected_timer = null; // debounce timer for connected set rebuilds
let last_connected_fingerprint = ""; // sorted element list from last populate call
let route_state_timer = null; // debounce timer for ROUTE_STATUS refresh
let ubus_conn;
// ---------------------------------------------------------------------------
@@ -40,7 +42,7 @@ let ubus_conn;
function log_msg(level, msg) {
warn(`mwan3rtmon[${family_name}] ${level}: ${msg}\n`);
mlog(level, `[${family_name}] ${msg}`);
}
// ---------------------------------------------------------------------------
@@ -52,6 +54,10 @@ function load_config() {
let cur = uci.cursor();
cur.load("mwan3");
// Verbose logging gate
log_verbose(ucibool(cur.get("mwan3", "globals", "verbose_logging") ?? "0"));
// MMX mask
mmx_mask = cur.get("mwan3", "globals", "mmx_mask") ?? "0x3F00";
@@ -219,9 +225,17 @@ function nft_exec(cmd) {
return true;
}
// Run a list of nft commands as a single nft -f transaction. The batch
// file is per-family: one mwan3rtmon instance runs per address family
// and each instance is single-threaded, so the family suffix alone
// keeps the two instances' batch files apart without locking. The file
// is removed on every exit path after creation. nft runs through a
// read-mode popen with stderr folded into stdout so the failure log
// carries nft's own error text.
function nft_batch(cmds) {
let tmpfile = "/var/run/mwan3/mwan3rtmon_nft_batch";
let tmpfile = "/var/run/mwan3/mwan3rtmon_nft_batch." + family_name;
let f = open(tmpfile, "w", 0600);
if (!f) {
log_msg("warn", "cannot open nft batch file");
@@ -232,10 +246,13 @@ function nft_batch(cmds) {
f.close();
let p = popen("nft -f " + tmpfile + " 2>&1");
if (!p)
if (!p) {
unlink(tmpfile);
return false;
}
let out = p.read("all");
let rc = p.close();
unlink(tmpfile);
if (rc != 0) {
log_msg("warn", "nft batch failed: " + (out ?? ""));
return false;
@@ -323,6 +340,126 @@ function is_linklocal_route(route) {
match(route.dst, /^169\.254\./) != null));
}
// Does this interface's own routing table hold a usable default route?
//
// This is the datapath truth: the interface's fwmark ip rule looks up
// exactly this table, so an interface whose table N holds no default route
// black-holes the traffic mwan3 marks to it, even when the main table still
// has a default via the same device. This mirrors the predicate the rpcd
// backend uses for has_default and the one mwan3-manage-rules.uc check-route
// uses. 'routes' is a single full route dump for this family (all tables).
function table_has_default(routes, tid, dev) {
for (let r in routes)
if (r.table == tid && is_default_route(r) && r.oif == dev)
return true;
return false;
}
// The interfaces this rtmon instance is responsible for: enabled, tracked,
// of this family, and currently bound to an l3 device. iface_list is built
// by load_config() and already carries name, family, tid, enabled, and
// has_tracking. The device comes from the same ubus network.interface dump
// build_dev_table_map() uses. An interface with no l3_device yet is skipped;
// it gets evaluated later when its device appears and routes change.
function tracked_interfaces() {
let ifaces = [];
if (!ubus_conn)
return ifaces;
let dump = ubus_conn.call("network.interface", "dump");
if (!dump || !dump.interface)
return ifaces;
let dev_by_name = {};
for (let intf in dump.interface)
if (intf.l3_device)
dev_by_name[intf.interface] = intf.l3_device;
for (let iface in iface_list) {
if (iface.family != family_name || !iface.enabled || !iface.has_tracking)
continue;
let dev = dev_by_name[iface.name];
if (dev == null)
continue;
push(ifaces, { name: iface.name, tid: iface.tid, dev: dev });
}
return ifaces;
}
// Write the interface's ROUTE_STATUS file, only when the value changes, and
// log the transition. The write is atomic (temp file + rename within the
// same tmpfs directory) so the tracker never reads a torn value. The
// tracker creates these directories, but rtmon may evaluate an interface
// before its tracker has, so mkdir best-effort first; it fails harmlessly
// if the directory already exists.
function write_route_state(ifname, state) {
let path = MWAN3TRACK_STATUS_DIR + "/" + ifname + "/ROUTE_STATUS";
let cur = trim(readfile(path) ?? "");
if (cur == state)
return;
mkdir(MWAN3TRACK_STATUS_DIR, 0700);
mkdir(MWAN3TRACK_STATUS_DIR + "/" + ifname, 0755);
let tmp = path + ".tmp";
let f = open(tmp, "w", 0644);
if (!f) {
log_msg("warn", "cannot open ROUTE_STATUS for " + ifname);
return;
}
f.write(state + "\n");
f.close();
if (!rename(tmp, path)) {
unlink(tmp);
log_msg("warn", "cannot commit ROUTE_STATUS for " + ifname);
return;
}
// A missing default route is abnormal, not routine failover, so log the
// loss at warn and the return to normal at notice.
log_msg(state == "down" ? "warn" : "notice", ifname + " default route " + state);
}
// Recompute and persist every responsible interface's route state from one
// route dump. The dump spans all tables for this family; table_has_default
// filters to each interface's own table. Called at startup, on SIGHUP, and
// debounced from the route-event handler.
function refresh_route_states() {
let ifaces = tracked_interfaces();
if (length(ifaces) == 0)
return;
let routes = rtnl.request(RTM_GETROUTE, NLM_F_DUMP, { family: family_num }) ?? [];
for (let iface in ifaces)
write_route_state(iface.name,
table_has_default(routes, iface.tid, iface.dev) ? "up" : "down");
}
// Arm the debounced refresh. Bursts of route events (PD renewal, reconnects,
// the boot-time route flood) collapse into a single sweep, exactly as the
// connected-set rebuild is debounced.
function arm_route_state_refresh() {
if (!route_state_timer)
route_state_timer = uloop.timer(DEBOUNCE_MS, () => {
refresh_route_states();
route_state_timer = null;
});
else
route_state_timer.set(DEBOUNCE_MS);
}
// Get all routes from main table (and extra lookup tables) via netlink
function get_all_routes() {
@@ -370,8 +507,18 @@ function populate_connected_set() {
let seen = {};
let elements = [];
// Main-table routes only. get_all_routes() also returns the extra lookup
// tables for the per-interface mirroring, but their routes belong to
// mwan3_custom_v4/v6, which grants the identical bypass and is maintained
// on those tables' own route events. Sweeping them in here as well would
// strand them: extra-table events never arm this rebuild, so a route
// deleted from its table would stay in the connected set indefinitely.
for (let route in routes) {
if (is_cidr_route(route) && !is_default_route(route) && !is_linklocal_route(route) && !seen[route.dst]) {
if (route.table != RT_TABLE_MAIN)
continue;
if (is_cidr_route(route) && !is_default_equivalent(route) && !is_linklocal_route(route) && !seen[route.dst]) {
push(elements, route.dst);
seen[route.dst] = true;
}
@@ -456,7 +603,7 @@ function repopulate_custom_sets() {
if (!extra_table_set[r.table])
continue;
let dst = r.dst;
if (dst == null || is_default_route(r) || is_linklocal_route(r))
if (dst == null || is_default_equivalent(r) || is_linklocal_route(r))
continue;
if (!seen[dst]) {
push(cmds, "add element inet mwan3 " + setname + " { " + dst + " }");
@@ -478,7 +625,7 @@ function repopulate_custom_sets() {
function handle_custom_set_event(route, is_new) {
let dst = route.dst;
if (dst == null || is_default_route(route) || is_linklocal_route(route))
if (dst == null || is_default_equivalent(route) || is_linklocal_route(route))
return;
let setname = (family_num == AF_INET) ? "mwan3_custom_v4" : "mwan3_custom_v6";
@@ -536,6 +683,10 @@ function handle_route_event(event) {
connected_timer.set(DEBOUNCE_MS);
}
// Re-evaluate every interface's default-route state on any routing change.
arm_route_state_refresh();
// -- Update per-interface routing tables --
build_dev_table_map();
@@ -594,6 +745,7 @@ function main() {
// Parse args
family_name = ARGV[0] ?? "ipv4";
log_open("mwan3rtmon");
if (family_name == "ipv6") {
if (stat("/proc/sys/net/ipv6") == null) {
@@ -610,6 +762,7 @@ function main() {
}
load_config();
log_msg("notice", "started");
// Connect to ubus
@@ -653,6 +806,9 @@ function main() {
log_msg("debug", "populating initial per-interface routes");
populate_iface_routes();
log_msg("debug", "initial route-state sweep");
refresh_route_states();
// Signal handlers for clean shutdown
uloop.signal("SIGTERM", () => {
@@ -669,8 +825,23 @@ function main() {
log_msg("debug", "received SIGHUP, reloading config");
load_config();
repopulate_custom_sets();
refresh_route_states();
});
// Slow periodic re-sweep as defense in depth: bounds recovery if a
// route-state change ever arrives without an accompanying main-table
// route event (a coalesced or missed netlink multicast). It is cheap
// and silent when nothing moves, since write_route_state only writes
// on an actual change.
function schedule_periodic_route_sweep() {
uloop.timer(20000, () => {
refresh_route_states();
schedule_periodic_route_sweep();
});
}
schedule_periodic_route_sweep();
log_msg("debug", "entering event loop");
uloop.run();
+22 -2
View File
@@ -299,7 +299,7 @@ main() {
trap config_reload HUP
load_tracking_config
local sleep_time result ping_status loss latency
local sleep_time result ping_status loss latency route_state route_state_prev probe_ips
mwan3_list_track_ips()
{
track_ips="$track_ips $1"
@@ -352,6 +352,7 @@ main() {
local lost=0
local turn=0
local failed_hosts=""
route_state_prev=""
firstconnect
while true; do
@@ -391,7 +392,26 @@ main() {
continue
fi
sleep_time=$interval
for track_ip in $track_ips; do
# An interface can be reachable to its tracking target yet hold no
# usable default route in its own table, in which case mwan3 marks
# traffic to it and the per-interface backstop drops it. mwan3rtmon
# writes the interface's default-route state to ROUTE_STATUS; treat
# "down" as a failed round and skip probing so the existing score
# machinery carries the interface offline, and back online when the
# route returns. An absent file means no route monitor has evaluated
# this interface, so probe exactly as before.
route_state=$(cat "$MWAN3TRACK_STATUS_DIR/$INTERFACE/ROUTE_STATUS" 2>/dev/null)
probe_ips=$track_ips
if [ "$route_state" = "down" ]; then
probe_ips=""
[ "$route_state_prev" != "down" ] && LOG warn "Interface $INTERFACE ($DEVICE) has no usable default route in its table; treating tracking round as failed"
elif [ "$route_state_prev" = "down" ]; then
LOG notice "Interface $INTERFACE ($DEVICE) default route restored"
fi
route_state_prev=$route_state
for track_ip in $probe_ips; do
if [ $host_up_count -lt $reliability ]; then
case "$track_method" in
ping)
+65 -7
View File
@@ -75,6 +75,16 @@ function parse_elem_val(v) {
return null;
}
// Allowlist for nft set elements written back to the kernel: IPv4 and IPv6
// addresses, CIDR prefixes and hyphen-written ranges only, which is exactly
// what the apply path emits. These values are interpolated into an nft add
// element command line built with popen, so anything carrying a character
// outside this class is dropped before it can break out of the element list.
function valid_nft_element(v) {
return match(v, /^[0-9A-Fa-f:.\/-]+$/) != null;
}
function count_nftset_elements(setname) {
if (!match(setname, /^[a-zA-Z0-9_-]+$/))
return 0;
@@ -216,6 +226,14 @@ function get_table_routes(table_id) {
}
function nslookup_resolve(host, type) {
// Reject any host outside the hostname character class before it is
// interpolated into the /bin/sh -c command line below. This is the single
// chokepoint for both resolve_host and the nftset_resolve domain path,
// whose UCI-supplied domain values are otherwise unvalidated.
if (!match(host, /^[a-zA-Z0-9._-]+$/))
return [];
const p = popen(sprintf('/bin/busybox nslookup -type=%s %s 127.0.0.1 2>/dev/null', type, host), 'r');
if (!p) return [];
const raw = p.read('all');
@@ -233,7 +251,6 @@ function nslookup_resolve(host, type) {
function routing_health() {
const uci = cursor();
const mmx_mask = int(uci.get('mwan3', 'globals', 'mmx_mask')) || 0x3F00;
let bitcnt = 0;
for (let m = mmx_mask; m; m >>= 1) bitcnt += m & 1;
@@ -250,34 +267,47 @@ function routing_health() {
UNREACH_BASE = 3000;
}
const SRC_BASE = UNREACH_BASE + iface_max + 1;
// Build ordered interface list with 1-based index (same ordering mwan3 uses)
const iface_order = [];
const iface_index = {};
const iface_family = {};
uci.foreach('mwan3', 'interface', intf => {
push(iface_order, intf['.name']);
iface_index[intf['.name']] = length(iface_order);
iface_family[intf['.name']] = intf['family'] || 'ipv4';
});
// Fetch all ip rules and index by priority for O(1) lookup
const all_rules = get_ip_rules();
const rule_by_priority = {};
const v4_rule_by_priority = {};
for (let rule in all_rules) {
if (rule?.priority != null)
if (rule?.priority != null) {
rule_by_priority[rule.priority] = rule;
if (rule.family == 2)
v4_rule_by_priority[rule.priority] = rule;
}
}
// Pre-build the set of priorities that correspond to current UCI interfaces
const valid_priorities = {};
for (let ifname in iface_order) {
const id = iface_index[ifname];
valid_priorities[IIF_BASE + id] = true;
valid_priorities[FWMARK_BASE + id] = true;
valid_priorities[UNREACH_BASE + id] = true;
valid_priorities[UNREACH_BASE + iface_max + 1 + id] = true;
}
valid_priorities[FWMARK_BASE + mmdefault - 2] = true;
valid_priorities[FWMARK_BASE + mmdefault - 1] = true;
// Per-interface health report
const result = {};
for (let ifname in iface_order) {
const id = iface_index[ifname];
@@ -319,17 +349,38 @@ function routing_health() {
})),
},
};
// The source band is IPv4-only, so test presence against the IPv4
// rule index alone. A device holds one source rule per global IPv4
// address, all sharing this priority, so a populated slot means the
// band exists for this interface, not that every address is covered.
if (iface_family[ifname] == 'ipv4') {
result[ifname].src_rule = {
present: v4_rule_by_priority[SRC_BASE + id] != null,
priority: SRC_BASE + id,
};
}
}
// Stale rules: priorities in mwan3 ranges but not matching any current UCI interface
const stale = [];
for (let rule in all_rules) {
const p = rule?.priority;
if (p == null) continue;
// The iif and fwmark windows are mmdefault wide (the fwmark band
// carries the global blackhole and unreachable rules above
// iface_max). The unreachable band holds only ids 1..iface_max;
// the slot above it is unused and the source band follows at
// iface_max + 1 + id.
const in_iif_range = (p > IIF_BASE && p <= IIF_BASE + mmdefault);
const in_fwmark_range = (p > FWMARK_BASE && p <= FWMARK_BASE + mmdefault);
const in_unreach_range = (p > UNREACH_BASE && p <= UNREACH_BASE + mmdefault);
if ((in_iif_range || in_fwmark_range || in_unreach_range) && !valid_priorities[p])
const in_unreach_range = (p > UNREACH_BASE && p <= UNREACH_BASE + iface_max);
const in_src_range = (p > UNREACH_BASE + iface_max + 1 && p <= UNREACH_BASE + 2 * iface_max + 1);
if ((in_iif_range || in_fwmark_range || in_unreach_range || in_src_range) && !valid_priorities[p])
push(stale, { priority: p });
}
@@ -339,7 +390,7 @@ function routing_health() {
interfaces: result,
stale_rules: stale,
mwan3_active: mwan3_active,
rule_bases: { iif: IIF_BASE, fwmark: FWMARK_BASE, unreach: UNREACH_BASE },
rule_bases: { iif: IIF_BASE, fwmark: FWMARK_BASE, unreach: UNREACH_BASE, src: SRC_BASE },
};
}
@@ -391,12 +442,14 @@ function get_policies(version) {
const family_filter = (version == '6') ? 'ipv6' : 'ipv4';
// Build interface family map: iface_name -> 'ipv4'/'ipv6'
const iface_family = {};
uci.foreach('mwan3', 'interface', intf => {
iface_family[intf['.name']] = intf['family'] || 'ipv4';
});
// Build member info map: member_name -> { interface, metric, weight }
const member_info = {};
uci.foreach('mwan3', 'member', m => {
member_info[m['.name']] = {
@@ -415,6 +468,7 @@ function get_policies(version) {
if (type(use_members) != 'array') use_members = [use_members];
// Collect members matching the requested address family
const by_metric = {};
for (let mname in use_members) {
const m = member_info[mname];
@@ -431,6 +485,7 @@ function get_policies(version) {
if (!length(keys(by_metric))) return;
// Find the lowest metric with at least one online member
const sorted_metrics = sort(keys(by_metric), (a, b) => int(a) - int(b));
let active_metric = null;
for (let metric in sorted_metrics) {
@@ -444,6 +499,7 @@ function get_policies(version) {
}
// Sum weights of online members at the active metric for percent calculation
let total_weight = 0;
if (active_metric != null) {
for (let m in by_metric[active_metric]) {
@@ -498,10 +554,12 @@ function interfaces_status(request) {
'packetloss': get_int(ifname, sprintf('LOSS_%s', ip)),
});
}
// Derive check_quality from LATENCY file content rather than UCI.
// UCI may be stale if check_quality was changed without restarting mwan3track.
// mwan3track only writes LATENCY_* files when check_quality=1, so file
// presence with content is the authoritative indicator of actual state.
let check_quality = false;
for (let tip in track_ips) {
const lat = get_str_raw(ifname, sprintf('LATENCY_%s', tip.ip));
@@ -694,7 +752,7 @@ const methods = {
if (type(entries) != 'array') entries = [entries];
for (let e in entries) {
const ev = ltrim(rtrim(e, ' \t\r\n'), ' \t\r\n');
if (length(ev) > 0) push(elements, ev);
if (length(ev) > 0 && valid_nft_element(ev)) push(elements, ev);
}
if (cfg.loadfile) {
const content = readfile(cfg.loadfile);
@@ -703,7 +761,7 @@ const methods = {
const ci = index(line, '#');
if (ci >= 0) line = substr(line, 0, ci);
line = ltrim(rtrim(line, ' \t\r\n'), ' \t\r\n');
if (length(line) > 0) push(elements, line);
if (length(line) > 0 && valid_nft_element(line)) push(elements, line);
}
}
}
@@ -0,0 +1,107 @@
'use strict';
// common.uc: the shared mwan3 ucode library, the ucode analogue of common.sh.
// Each shared ucode routine is defined here so it lives, and is tested, in one
// place.
import { openlog, syslog, LOG_PID, LOG_DAEMON } from 'log';
// ---- Logging ---------------------------------------------------------------
//
// All mwan3 ucode logs through these functions so the sink, tag format and
// verbose gate live in one place. Output goes to syslog, the same destination
// logger(1) reaches, so it appears in logread regardless of how procd handles
// the process stderr.
let verbose = false;
// Map the level names mwan3 uses (the shell's warn/error vocabulary and the
// canonical syslog names) onto the exact tokens parse_priority() accepts. It
// rejects "warn" and "error", so an unmapped name would be dropped; an unknown
// level falls back to notice so a mislabelled message still logs.
const PRIO = {
emerg: "emerg", alert: "alert", crit: "crit",
err: "err", error: "err",
warn: "warning", warning: "warning",
notice: "notice", info: "info", debug: "debug",
};
// Open the syslog connection with the given identity. LOG_PID appends the pid
// so the tag reads ident[pid], matching the shell's "${SCRIPTNAME}[$$]"
// convention. Facility daemon suits these long-running and service-invoked
// components.
function log_open(ident) {
openlog(ident, LOG_PID, LOG_DAEMON);
}
// Set the verbose gate. The caller reads mwan3.globals.verbose_logging and
// pushes the parsed bool here; debug is suppressed unless verbose, exactly as
// common.sh:LOG() gates the debug facility behind MWAN3_VERBOSE_LOGGING.
function log_verbose(enable) {
verbose = enable;
}
// Emit one message. debug is dropped unless verbose; every other level always
// logs. Message content is passed as a %s argument so it is never treated as a
// format string.
function log_msg(level, msg) {
if (level == "debug" && !verbose)
return;
syslog(PRIO[level] ?? "notice", "%s", msg);
}
// ---- Config helpers --------------------------------------------------------
// Parse a UCI bool the way the shell's config_get_bool does: 1/on/true/yes/
// enabled are true, everything else is false. Kept here so every ucode
// consumer interprets a UCI bool identically to the shell. It reads no config
// itself, so it does not couple the module to uci.
function ucibool(val) {
switch (val) {
case 'yes':
case 'on':
case 'true':
case 'enabled':
return true;
default:
return !!int(val);
}
}
// ---- Route classification --------------------------------------------------
// The width classifier for the bypass sweeps: a route is default-equivalent
// when its destination is absent (a literal default) or its prefix is wide
// enough that it can only exist as a component of an in-substance default
// route - /2 or wider for IPv4, /3 or wider for IPv6, which covers the
// split-half pairs VPN clients install and the 2000::/3 global-unicast
// aggregate. Each route is judged alone, so a half-installed pair is still
// excluded. The family comes from iptoarr, the inet_pton wrapper, so no
// address text is hand-parsed; a destination it cannot parse is not
// default-equivalent. The strict literal-default predicate the mirroring and
// route-state paths use is deliberately not widened and lives on beside this.
function is_default_equivalent(route) {
let dst = route.dst;
if (dst == null)
return true;
let slash = index(dst, "/");
if (slash < 0)
return false;
let a = iptoarr(substr(dst, 0, slash));
if (a == null)
return false;
return +substr(dst, slash + 1) <= (length(a) == 16 ? 3 : 2);
}
// ---- Exports ---------------------------------------------------------------
// The module's public interface. Declared as an export list rather than inline
// on each function, which this ucode build does not accept.
export { log_open, log_verbose, log_msg, ucibool, is_default_equivalent };
File diff suppressed because it is too large. Load diff