Sync 2026-07-22 03:00:29

This commit is contained in:
github-actions[bot] 2026-07-22 03:00:29 +08:00
parent 5d9f64dd0e
commit 0eb3c6a042
28 changed files with 15055 additions and 1 deletions

View File

@ -7,7 +7,7 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=filebrowser
PKG_VERSION:=1.4.0-stable
PKG_VERSION:=1.5.0-stable
PKG_RELEASE=1
ifeq ($(ARCH),aarch64)

252
mwan3-nft/Makefile Normal file
View File

@ -0,0 +1,252 @@
#
# Copyright (C) 2006-2014 OpenWrt.org
#
# This is free software, licensed under the GNU General Public License v2.
# See /LICENSE for more information.
#
include $(TOPDIR)/rules.mk
PKG_NAME:=mwan3
PKG_VERSION:=3.6.9
PKG_RELEASE:=1
PKG_MAINTAINER:=Florian Eckert <fe@dev.tdt.de>
PKG_LICENSE:=GPL-2.0
PKG_CONFIG_DEPENDS:=CONFIG_IPV6
PKG_BUILD_DEPENDS:=libnetfilter_conntrack libmnl
include $(INCLUDE_DIR)/package.mk
define Package/mwan3
SECTION:=net
CATEGORY:=Network
SUBMENU:=Routing and Redirection
DEPENDS:= \
+ip-full \
+libnetfilter-conntrack \
+kmod-nft-core \
+nftables-json \
+rpcd-mod-ucode \
+jshn \
+ucode \
+ucode-mod-rtnl \
+ucode-mod-uloop \
+ucode-mod-uci \
+ucode-mod-ubus \
+ucode-mod-fs \
+ucode-mod-socket
TITLE:=Multiwan hotplug script with connection tracking support (ucode rtmon)
MAINTAINER:=Florian Eckert <fe@dev.tdt.de>
endef
define Package/mwan3/description
Hotplug script which makes configuration of multiple WAN interfaces simple
and manageable. With loadbalancing/failover support for up to 250 wan
interfaces, connection tracking and an easy to manage traffic ruleset.
mwan3rtmon now uses a ucode implementation leveraging ucode-mod-rtnl
for direct netlink access instead of forking ip commands.
endef
define Package/mwan3/conffiles
/etc/config/mwan3
/etc/mwan3.user
endef
define Package/mwan3/preinst
#!/bin/sh
if [ -z "$${IPKG_INSTROOT}" ]; then
# Stop mwan3evtd if present (older installs included this daemon). The
# pre-upgrade hook runs before APK removes files no longer in the
# package, so this must happen here to avoid leaving a running process
# with no init script or binary after the file swap.
/etc/init.d/mwan3evtd stop 2>/dev/null
/etc/init.d/mwan3evtd disable 2>/dev/null
# Record whether this is a fresh install so postinst can auto-enable
# without overriding an explicit disable by the user on upgrade.
[ -f /etc/init.d/mwan3 ] || touch /tmp/mwan3_first_install
# Stop mwan3 before APK replaces any files. This is critical for
# upgrades: if mwan3 is running when the init script is replaced,
# procd's inotify trigger restarts the service while the old ip rules
# are still installed, producing RTNETLINK "File exists" errors when
# start_service tries to re-add them. Stopping here puts the service
# into procd's "stopped" state before the init script changes, so
# procd does not auto-restart it.
/etc/init.d/mwan3 stop 2>/dev/null
fi
exit 0
endef
define Package/mwan3/postinst
#!/bin/sh
if [ -z "$${IPKG_INSTROOT}" ]; then
# Safety-net stop in case preinst did not run or procd restarted the
# service between preinst and postinst.
/etc/init.d/mwan3 stop 2>/dev/null
# mwan3_prerouting/mwan3_output run at priority mangle + 1, backed by
# non-destructive vmap-dispatch save/restore so the placement is
# order-independent w.r.t. pbr. Older installs used mangle - 1.
# nftables rejects a base chain redeclaration at a different priority,
# so flush+delete the old chains before start recreates them.
for chain in mwan3_prerouting mwan3_output; do
if nft list chain inet fw4 "$$chain" 2>/dev/null | grep -q "priority mangle - 1"; then
nft flush chain inet fw4 "$$chain" 2>/dev/null
nft delete chain inet fw4 "$$chain" 2>/dev/null
fi
done
# Drop legacy ip->mark sticky maps (replaced by per-(rule,family,member)
# ip-only sets). Leftover legacy maps are unreferenced after upgrade
# but waste a name and confuse status.
for mapname in $$(nft list maps inet 2>/dev/null | \
awk '$$1=="map" && $$2 ~ /^mwan3_sticky_v[46]_/ { print $$2 }'); do
nft delete map inet fw4 "$$mapname" 2>/dev/null
done
# mwan3 now operates in table inet mwan3 rather than table inet fw4.
# fw4 uses flush-table (not delete-table) on reload, so any mwan3
# chains/sets left in fw4 survive fw4 reloads and must be explicitly
# removed.
for chain in mwan3_prerouting mwan3_output mwan3_postrouting \
mwan3_ifaces_in mwan3_rules mwan3_connected mwan3_custom mwan3_dynamic; do
nft flush chain inet fw4 "$$chain" 2>/dev/null
nft delete chain inet fw4 "$$chain" 2>/dev/null
done
for setname in mwan3_connected_v4 mwan3_connected_v6 \
mwan3_custom_v4 mwan3_custom_v6 \
mwan3_dynamic_v4 mwan3_dynamic_v6; do
nft delete set inet fw4 "$$setname" 2>/dev/null
done
for mapname in $$(nft list maps inet fw4 2>/dev/null | \
awk '$$1=="map" && $$2 ~ /^mwan3_sticky_/ { print $$2 }'); do
nft flush map inet fw4 "$$mapname" 2>/dev/null
nft delete map inet fw4 "$$mapname" 2>/dev/null
done
# Remove stale firewall.mwan3_reload UCI section if present and reload
# fw4 only if it was. An unconditional fw4 -q reload would trigger
# queued ifup hotplug events that race with mwan3_create_iface_rules
# in the init hotplug, producing RTNETLINK "File exists" errors.
if uci -q delete firewall.mwan3_reload; then
uci commit firewall
fw4 -q reload
fi
# Migrate any fw4-side set references in mwan3 rules to config ipset
# declarations in /etc/config/mwan3 (one-shot, idempotent).
/lib/mwan3/mwan3-migrate-ipset-v4.sh
rm -f /lib/mwan3/mwan3-migrate-ipset-v4.sh
/etc/init.d/rpcd restart
# Second stop immediately before start: procd may have auto-started mwan3
# when the init script was installed (the first stop above runs before that
# happens and is therefore a no-op). By this point the auto-start has
# completed, so this stop calls stop_service and removes its ip rules.
# Without this, mwan3 start below calls procd_close_service (which tells
# procd to replace the service instances) but never calls stop_service,
# so the ip rules from the auto-start are still present when the init
# hotplug events try to add them again.
# Auto-enable on fresh install so mwan3 starts at boot without requiring
# a manual /etc/init.d/mwan3 enable. On upgrade the flag is absent, so
# an explicit disable by the user is preserved.
if [ -f /tmp/mwan3_first_install ]; then
rm -f /tmp/mwan3_first_install
/etc/init.d/mwan3 enable
fi
/etc/init.d/mwan3 stop 2>/dev/null
/etc/init.d/mwan3 start
fi
exit 0
endef
define Package/mwan3/postrm
#!/bin/sh
if [ -z "$${IPKG_INSTROOT}" ]; then
/etc/init.d/rpcd restart
for f in /tmp/dnsmasq.*.d/mwan3-nftsets.conf /tmp/dnsmasq.d/mwan3-nftsets.conf; do
[ -f "$$f" ] && rm -f "$$f"
done
/etc/init.d/dnsmasq restart 2>/dev/null
fi
exit 0
endef
define Build/Compile
$(TARGET_CC) $(CFLAGS) $(LDFLAGS) $(FPIC) \
-shared \
-o $(PKG_BUILD_DIR)/libwrap_mwan3_sockopt.so.1.0 \
$(if $(CONFIG_IPV6),-DCONFIG_IPV6) \
$(PKG_BUILD_DIR)/sockopt_wrap.c \
-ldl
$(TARGET_CC) $(TARGET_CFLAGS) $(TARGET_LDFLAGS) \
-o $(PKG_BUILD_DIR)/mwan3ct \
$(PKG_BUILD_DIR)/mwan3ct.c \
-lnetfilter_conntrack -lmnl -lnfnetlink
$(TARGET_CC) $(TARGET_CFLAGS) $(TARGET_LDFLAGS) \
-o $(PKG_BUILD_DIR)/mwan3ipcheck \
$(PKG_BUILD_DIR)/mwan3ipcheck.c
endef
define Package/mwan3/install
$(INSTALL_DIR) $(1)/etc/config
$(INSTALL_CONF) ./files/etc/config/mwan3 \
$(1)/etc/config/
$(INSTALL_DIR) $(1)/etc/hotplug.d/iface
$(INSTALL_DATA) ./files/etc/hotplug.d/iface/25-mwan3 \
$(1)/etc/hotplug.d/iface/
$(INSTALL_DATA) ./files/etc/hotplug.d/iface/26-mwan3-user \
$(1)/etc/hotplug.d/iface/
$(INSTALL_DIR) $(1)/etc/init.d
$(INSTALL_BIN) ./files/etc/init.d/mwan3 \
$(1)/etc/init.d/
$(INSTALL_DIR) $(1)/lib/mwan3
$(INSTALL_DATA) ./files/lib/mwan3/common.sh \
$(1)/lib/mwan3/
$(INSTALL_DATA) ./files/lib/mwan3/mwan3.sh \
$(1)/lib/mwan3/
$(INSTALL_DATA) ./files/lib/mwan3/mwan3-skeleton.nft \
$(1)/lib/mwan3/
$(INSTALL_BIN) ./files/lib/mwan3/mwan3-migrate-ipset-v4.sh \
$(1)/lib/mwan3/
$(INSTALL_BIN) ./files/lib/mwan3/mwan3-get-addr.uc \
$(1)/lib/mwan3/
$(INSTALL_BIN) ./files/lib/mwan3/mwan3-manage-rules.uc \
$(1)/lib/mwan3/
$(INSTALL_BIN) ./files/lib/mwan3/mwan3-list-routes.uc \
$(1)/lib/mwan3/
$(INSTALL_BIN) ./files/lib/mwan3/mwan3-create-iface-route.uc \
$(1)/lib/mwan3/
$(INSTALL_DIR) $(1)/usr/share/rpcd/ucode/
$(INSTALL_BIN) ./files/usr/share/rpcd/ucode/mwan3 \
$(1)/usr/share/rpcd/ucode/
$(INSTALL_DIR) $(1)/usr/sbin
$(INSTALL_BIN) ./files/usr/sbin/mwan3 \
$(1)/usr/sbin/
$(INSTALL_BIN) ./files/usr/sbin/mwan3rtmon \
$(1)/usr/sbin/
$(INSTALL_BIN) ./files/usr/sbin/mwan3track \
$(1)/usr/sbin/
$(INSTALL_BIN) ./files/usr/sbin/mwan3-lb-test \
$(1)/usr/sbin/
$(INSTALL_BIN) ./files/usr/sbin/mwan3-diag \
$(1)/usr/sbin/
$(INSTALL_DIR) $(1)/etc
$(INSTALL_BIN) ./files/etc/mwan3.user \
$(1)/etc/
$(CP) $(PKG_BUILD_DIR)/libwrap_mwan3_sockopt.so.1.0 $(1)/lib/mwan3/
$(INSTALL_BIN) $(PKG_BUILD_DIR)/mwan3ct $(1)/usr/sbin/
$(INSTALL_DIR) $(1)/usr/bin
$(INSTALL_BIN) $(PKG_BUILD_DIR)/mwan3ipcheck $(1)/usr/bin/
$(INSTALL_DIR) $(1)/etc/uci-defaults
$(INSTALL_DATA) ./files/etc/uci-defaults/mwan3-migrate-flush_conntrack \
$(1)/etc/uci-defaults/
$(INSTALL_DATA) ./files/etc/uci-defaults/mwan3-remove-firewall-include \
$(1)/etc/uci-defaults/
endef
$(eval $(call BuildPackage,mwan3))

4098
mwan3-nft/README.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,139 @@
# For full documentation of mwan3 configuration:
# https://openwrt.org/docs/guide-user/network/wan/multiwan/mwan3#mwan3_configuration
config globals 'globals'
option mmx_mask '0x3F00'
# option iif_rule_base '1000'
# option fwmark_rule_base '2000'
# option unreachable_rule_base '3000'
config interface 'wan'
option enabled '0'
list track_ip '1.0.0.1'
list track_ip '1.1.1.1'
list track_ip '208.67.222.222'
list track_ip '208.67.220.220'
option family 'ipv4'
option reliability '2'
config interface 'wan6'
option enabled '0'
list track_ip '2606:4700:4700::1001'
list track_ip '2606:4700:4700::1111'
list track_ip '2620:0:ccd::2'
list track_ip '2620:0:ccc::2'
option family 'ipv6'
option reliability '2'
config interface 'wanb'
option enabled '0'
list track_ip '1.0.0.1'
list track_ip '1.1.1.1'
list track_ip '208.67.222.222'
list track_ip '208.67.220.220'
option family 'ipv4'
option reliability '1'
config interface 'wanb6'
option enabled '0'
list track_ip '2606:4700:4700::1001'
list track_ip '2606:4700:4700::1111'
list track_ip '2620:0:ccd::2'
list track_ip '2620:0:ccc::2'
option family 'ipv6'
option reliability '1'
config member 'wan_m1_w3'
option interface 'wan'
option metric '1'
option weight '3'
config member 'wan_m2_w3'
option interface 'wan'
option metric '2'
option weight '3'
config member 'wanb_m1_w2'
option interface 'wanb'
option metric '1'
option weight '2'
config member 'wanb_m1_w3'
option interface 'wanb'
option metric '1'
option weight '3'
config member 'wanb_m2_w2'
option interface 'wanb'
option metric '2'
option weight '2'
config member 'wan6_m1_w3'
option interface 'wan6'
option metric '1'
option weight '3'
config member 'wan6_m2_w3'
option interface 'wan6'
option metric '2'
option weight '3'
config member 'wanb6_m1_w2'
option interface 'wanb6'
option metric '1'
option weight '2'
config member 'wanb6_m1_w3'
option interface 'wanb6'
option metric '1'
option weight '3'
config member 'wanb6_m2_w2'
option interface 'wanb6'
option metric '2'
option weight '2'
config policy 'wan_only'
list use_member 'wan_m1_w3'
list use_member 'wan6_m1_w3'
config policy 'wanb_only'
list use_member 'wanb_m1_w2'
list use_member 'wanb6_m1_w2'
config policy 'balanced'
list use_member 'wan_m1_w3'
list use_member 'wanb_m1_w3'
list use_member 'wan6_m1_w3'
list use_member 'wanb6_m1_w3'
config policy 'wan_wanb'
list use_member 'wan_m1_w3'
list use_member 'wanb_m2_w2'
list use_member 'wan6_m1_w3'
list use_member 'wanb6_m2_w2'
config policy 'wanb_wan'
list use_member 'wan_m2_w3'
list use_member 'wanb_m1_w2'
list use_member 'wan6_m2_w3'
list use_member 'wanb6_m1_w2'
config rule 'https'
option enabled '0'
option sticky '1'
option dest_port '443'
option proto 'tcp'
option use_policy 'balanced'
config rule 'default_rule_v4'
option enabled '0'
option dest_ip '0.0.0.0/0'
option use_policy 'balanced'
option family 'ipv4'
config rule 'default_rule_v6'
option enabled '0'
option dest_ip '::/0'
option use_policy 'balanced'
option family 'ipv6'

View File

@ -0,0 +1,92 @@
#!/bin/sh
. /lib/functions.sh
. /lib/functions/network.sh
. /lib/mwan3/mwan3.sh
initscript=/etc/init.d/mwan3
. /lib/functions/procd.sh
SCRIPTNAME="mwan3-hotplug"
[ "$ACTION" = "ifup" ] || [ "$ACTION" = "ifdown" ] || [ "$ACTION" = "connected" ] || [ "$ACTION" = "disconnected" ] || exit 1
[ -n "$INTERFACE" ] || exit 2
[ "$FIRSTCONNECT" = "1" ] || [ "$MWAN3_SHUTDOWN" = "1" ] && exit 0
if { [ "$ACTION" = "ifup" ] || [ "$ACTION" = "connected" ] ; } && [ -z "$DEVICE" ]; then
LOG notice "$ACTION called on $INTERFACE with no device set"
exit 3
fi
[ "$MWAN3_STARTUP" = "init" ] || procd_lock
# Exit silently if the nft table is not yet set up. This covers the boot
# race where an external ifup hotplug fires before start_service completes,
# and the case where mwan3 has been manually stopped. Checked before
# mwan3_init to avoid recreating /var/run/mwan3 as a side effect, which
# would corrupt the stopped-service indicator. start_service handles all
# interface setup itself via its own mwan3_ifup calls.
$NFT list chain inet mwan3 mwan3_prerouting &>/dev/null || exit 0
mwan3_init
if [ "$MWAN3_STARTUP" != "init" ] && [ "$ACTION" = "ifup" ]; then
mwan3_set_user_iface_rules $INTERFACE $DEVICE
fi
config_get_bool enabled $INTERFACE 'enabled' '0'
[ "${enabled}" -eq 1 ] || {
LOG notice "mwan3 hotplug on $INTERFACE not called because interface disabled"
exit 0
}
config_get initial_state $INTERFACE initial_state "online"
if [ "$initial_state" = "offline" ]; then
readfile status $MWAN3TRACK_STATUS_DIR/$INTERFACE/STATUS 2>/dev/null || status="unknown"
[ "$status" = "online" ] || status=offline
else
status=online
fi
LOG notice "Execute $ACTION event on interface $INTERFACE (${DEVICE:-unknown})"
case "$ACTION" in
connected)
mwan3_set_iface_hotplug_state $INTERFACE "online"
mwan3_create_iface_nft $INTERFACE $DEVICE
mwan3_set_policies_nft
mwan3_flush_unreplied_conntrack
;;
ifup)
mwan3_update_peer_track_ip $INTERFACE
mwan3_create_iface_nft $INTERFACE $DEVICE
mwan3_create_iface_rules $INTERFACE $DEVICE
mwan3_create_iface_route $INTERFACE $DEVICE
mwan3_set_iface_hotplug_state $INTERFACE "$status"
if [ "$MWAN3_STARTUP" != "init" ]; then
mwan3_set_general_rules
if [ "$status" = "online" ]; then
mwan3_set_policies_nft
mwan3_flush_unreplied_conntrack
fi
fi
[ "$ACTION" = ifup ] && procd_running mwan3 "track_$INTERFACE" && procd_send_signal mwan3 "track_$INTERFACE" USR2
;;
disconnected)
mwan3_set_iface_hotplug_state $INTERFACE "offline"
mwan3_set_policies_nft
;;
ifdown)
mwan3_set_iface_hotplug_state $INTERFACE "offline"
mwan3_delete_iface_map_entries $INTERFACE
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
mwan3_flush_conntrack "$INTERFACE" "$ACTION"
exit 0

View File

@ -0,0 +1,26 @@
#!/bin/sh
[ -f "/etc/mwan3.user" ] && {
. /lib/functions.sh
. /lib/mwan3/mwan3.sh
initscript=/etc/init.d/mwan3
. /lib/functions/procd.sh
[ "$MWAN3_SHUTDOWN" != 1 ] && procd_lock
[ "$MWAN3_SHUTDOWN" != 1 ] && ! /etc/init.d/mwan3 running && {
exit 0
}
config_load mwan3
config_get_bool enabled "$INTERFACE" enabled 0
[ "${enabled}" -eq 1 ] || {
exit 0
}
[ -x /etc/mwan3.user ] || chmod 755 /etc/mwan3.user
env -i ACTION="$ACTION" INTERFACE="$INTERFACE" DEVICE="$DEVICE" /etc/mwan3.user 1000>&-
}
exit 0

316
mwan3-nft/files/etc/init.d/mwan3 Executable file
View File

@ -0,0 +1,316 @@
#!/bin/sh /etc/rc.common
. "${IPKG_INSTROOT}/lib/functions/network.sh"
. "${IPKG_INSTROOT}/lib/mwan3/mwan3.sh"
START=20
USE_PROCD=1
SCRIPTNAME="mwan3-init"
service_running() {
[ -d "$MWAN3_STATUS_DIR" ]
}
start_tracker() {
local enabled interface track_gateway
interface=$1
config_get_bool enabled $interface 'enabled' '0'
[ $enabled -eq 0 ] && return
# Write gateway IP to state file for point-to-point interfaces
mwan3_update_peer_track_ip "$interface"
# Start tracker if there are tracking IPs or track_gateway may provide one
if [ -z "$(config_get $interface track_ip)" ]; then
config_get_bool track_gateway "$interface" track_gateway 0
[ "$track_gateway" -eq 1 ] || return
fi
procd_open_instance "track_${1}"
procd_set_param command /usr/sbin/mwan3track $interface
procd_set_param respawn
procd_close_instance
}
start_service() {
local enabled hotplug_pids
mwan3_init
local _any_enabled=0
_check_iface_enabled() {
local _e
config_get_bool _e "$1" enabled 0
[ "$_e" -eq 1 ] && _any_enabled=1
}
config_foreach _check_iface_enabled interface
[ "$_any_enabled" -eq 0 ] && return 0
# Load the standalone mwan3 nftables table skeleton. This creates
# (or atomically re-creates) `table inet mwan3` with its base chains
# and sets. Must succeed before any `nft add ... inet mwan3 ...`
# call, since those would otherwise silently paper over a missing
# table. Abort start if the skeleton cannot load.
if ! nft -f /lib/mwan3/mwan3-skeleton.nft; then
LOG error "failed to load /lib/mwan3/mwan3-skeleton.nft; aborting start"
return 1
fi
mwan3_ensure_nft_framework
mwan3_render_config_ipsets
mwan3_write_dnsmasq_fragments
config_foreach start_tracker interface
mwan3_update_iface_to_table
mwan3_set_dynamic_sets
mwan3_set_connected_sets
mwan3_set_custom_sets
mwan3_set_general_rules
config_foreach mwan3_ifup interface "init"
wait $hotplug_pids
mwan3_set_general_nft
mwan3_set_policies_nft
mwan3_set_user_rules
mwan3_flush_stale_conntrack
mwan3_dnsmasq_hup
# If software flow offloading is active, flush all conntrack entries so
# existing flows re-establish under the new policy rules immediately rather
# than continuing to use stale flowtable-cached routing decisions.
if [ "$(uci -q get firewall.@defaults[0].flow_offloading)" = "1" ] && \
[ -e "$CONNTRACK_FILE" ]; then
echo f > "$CONNTRACK_FILE"
LOG info "Connection tracking flushed on start (software flow offloading active)"
fi
procd_open_instance rtmon_ipv4
procd_set_param command /usr/sbin/mwan3rtmon ipv4
procd_set_param respawn
procd_close_instance
if [ $NO_IPV6 -eq 0 ]; then
procd_open_instance rtmon_ipv6
procd_set_param command /usr/sbin/mwan3rtmon ipv6
procd_set_param respawn
procd_close_instance
fi
}
stop_service() {
service_running || exit 0
# Stop mwan3track instances before tearing down routing
# infrastructure. procd sends TERM only after stop_service returns,
# so without this the trackers keep pinging while ip rules and nft
# chains are deleted underneath them, producing spurious failure logs.
# Uses procd_kill (not direct kill) so procd marks the instances as
# stopped and does not trigger respawn.
local _f _iface _killed=""
for _f in "$MWAN3TRACK_STATUS_DIR"/*/PID; do
[ -f "$_f" ] || continue
_iface="${_f%/PID}"
_iface="${_iface##*/}"
procd_kill mwan3 "track_$_iface" && _killed=1
done
[ -n "$_killed" ] && sleep 1
local chain_name IP family table tid
mwan3_init
config_foreach mwan3_interface_shutdown interface
for family in ipv4 ipv6; do
if [ "$family" = "ipv4" ]; then
IP="$IP4"
elif [ "$family" = "ipv6" ]; then
[ $NO_IPV6 -ne 0 ] && continue
IP="$IP6"
fi
for tid in $($IP route list table all | sed -ne 's/.*table \([0-9]\+\).*/\1/p' | sort -u); do
[ $tid -gt $MWAN3_INTERFACE_MAX ] && continue
$IP route flush table $tid &> /dev/null
done
# 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
# fwmark range extends to fwmark_base + MM_UNREACHABLE to cover
# the global blackhole and unreachable rules at fwmark_base +
# MM_BLACKHOLE / MM_UNREACHABLE.
# 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
# was the original bug: it deleted any rule in the band, which
# is also why an admin override or another package's rule placed
# in the same band could be wiped.
# Both gates are derived from values mwan3_init reads back from
# /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
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))
# 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
pref="${line%%:*}"
case "$pref" in
''|*[!0-9]*) continue ;;
esac
# 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
continue
fi
# Content gate (a): carries an fwmark masked by mwan3's MMX_MASK
case "$line" in
*" fwmark "*"/${mask} "*|*" fwmark "*"/${mask}")
$IP rule del pref "$pref" &> /dev/null
continue ;;
esac
# Content gate (b): looks up a mwan3-owned table id
case "$line" in
*" lookup "*)
tbl="${line#* lookup }"
tbl="${tbl%% *}"
case "$tbl" in
''|*[!0-9]*) continue ;;
esac
[ "$tbl" -ge 1 ] && [ "$tbl" -le "$MWAN3_INTERFACE_MAX" ] && \
$IP rule del pref "$pref" &> /dev/null
;;
esac
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
$NFT delete table inet mwan3 >/dev/null 2>&1
rm -rf $MWAN3_STATUS_DIR $MWAN3TRACK_STATUS_DIR
}
reload_service() {
mwan3_init
# 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
# from build functions nest inside it rather than committing immediately.
# The preamble tears down all dynamic chains and internal sets; user-defined
# sets and sticky sets are untouched throughout.
mwan3_nft_reload_start
mwan3_ensure_nft_framework
mwan3_render_config_ipsets
mwan3_cleanup_orphaned_ipsets
mwan3_set_dynamic_sets
mwan3_set_connected_sets
mwan3_set_custom_sets
config_foreach mwan3_rebuild_iface_nft interface
mwan3_set_general_nft
mwan3_set_policies_nft
mwan3_set_user_rules
mwan3_nft_reload_commit || return 1
# ip rules and routing tables are kernel state outside nftables —
# update them after the nft commit so the table is already consistent.
mwan3_update_iface_to_table
mwan3_set_general_rules
config_foreach mwan3_rebuild_iface_rules interface
# Signal mwan3rtmon to reload UCI config and re-sync the custom sets.
# This is needed when rt_table_lookup changes: the running mwan3rtmon
# holds a stale extra_table_set in memory until it reloads. Sent after
# the nft commit so the sets exist when rtmon re-dumps them.
procd_send_signal mwan3 rtmon_ipv4 HUP
[ $NO_IPV6 -eq 0 ] && procd_send_signal mwan3 rtmon_ipv6 HUP
# Signal trackers to reload UCI tracking parameters.
_signal_tracker() {
procd_running mwan3 "track_$1" && \
procd_send_signal mwan3 "track_$1" HUP
}
config_foreach _signal_tracker interface
# mwan3_write_dnsmasq_fragments restarts dnsmasq internally if fragment
# content changed. User-defined nft sets survive the atomic reload intact
# so no HUP is needed here when fragments are unchanged.
mwan3_write_dnsmasq_fragments
# If a set was deleted and recreated (flags changed) and had dnsmasq-populated
# domain entries, those entries were lost. HUP dnsmasq to repopulate them.
[ "$MWAN3_NEED_DNSMASQ_HUP" -eq 1 ] && mwan3_dnsmasq_hup
# Detect tracker count mismatch. reload_service cannot add or remove
# procd service instances -- only start_service can do that. If the
# number of running mwan3track processes does not match the number the
# current config requires, fall through to a full restart. The restart
# starts the correct tracker set; start_service calls mwan3_dnsmasq_hup
# so nftset-populated sets are repopulated on name resolution.
local _expected_trackers=0 _running_trackers=0
_count_tracker_needed() {
local enabled track_ip track_gateway
config_get_bool enabled "$1" enabled 0
[ "$enabled" -eq 1 ] || return
if [ -n "$(config_get "$1" track_ip)" ]; then
_expected_trackers=$((_expected_trackers + 1))
return
fi
config_get_bool track_gateway "$1" track_gateway 0
[ "$track_gateway" -eq 1 ] && _expected_trackers=$((_expected_trackers + 1))
}
config_foreach _count_tracker_needed interface
_count_running_tracker() {
procd_running mwan3 "track_$1" && _running_trackers=$((_running_trackers + 1))
}
config_foreach _count_running_tracker interface
if [ "$_running_trackers" -ne "$_expected_trackers" ]; then
LOG notice "reload: tracker mismatch (need $_expected_trackers, have $_running_trackers) -- restarting"
stop
start
fi
}
service_triggers() {
procd_add_reload_trigger 'mwan3'
}

View File

@ -0,0 +1,20 @@
#!/bin/sh
#
# This file is interpreted as shell script.
# Put your custom mwan3 action here, they will
# be executed with each netifd hotplug interface event
# on interfaces for which mwan3 is enabled.
#
# There are three main environment variables that are passed to this script.
#
# $ACTION
# <ifup> Is called by netifd and mwan3track.
# <ifdown> Is called by netifd and mwan3track.
# <connected> Is only called by mwan3track if tracking was successful.
# <disconnected> Is only called by mwan3track if tracking has failed.
# $INTERFACE Name of the interface an action relates to (e.g. "wan" or "wwan").
# $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

View File

@ -0,0 +1,26 @@
#!/bin/sh
. /lib/functions.sh
mwan3_migrate_flush_conntrack() {
local iface="$1"
config_get value "${iface}" flush_conntrack
case $value in
always)
uci_remove mwan3 "$iface" flush_conntrack
uci_add_list mwan3 "$iface" flush_conntrack ifup
uci_add_list mwan3 "$iface" flush_conntrack ifdown
;;
never)
uci_remove mwan3 "$iface" flush_conntrack
;;
esac
uci_commit mwan3
}
config_load mwan3
config_foreach mwan3_migrate_flush_conntrack interface
exit 0

View File

@ -0,0 +1,5 @@
#!/bin/sh
# One-shot upgrade cleanup: remove the stale firewall.mwan3_reload include
# section registered by older mwan3 installs that operated in table inet fw4.
uci -q delete firewall.mwan3_reload && uci commit firewall
exit 0

View File

@ -0,0 +1,568 @@
#!/bin/sh
IP4="ip -4"
IP6="ip -6"
UCODE="ucode"
MWAN3_LIB_PATH="/lib/mwan3"
MWAN3_CREATE_IFACE_ROUTE="${UCODE} ${MWAN3_LIB_PATH}/mwan3-create-iface-route.uc"
MWAN3_GET_ADDR="${UCODE} ${MWAN3_LIB_PATH}/mwan3-get-addr.uc"
MWAN3_LIST_ROUTES="${UCODE} ${MWAN3_LIB_PATH}/mwan3-list-routes.uc"
MWAN3_MANAGE_RULES="${UCODE} ${MWAN3_LIB_PATH}/mwan3-manage-rules.uc"
SCRIPTNAME="$(basename "$0")"
MWAN3_STATUS_DIR="/var/run/mwan3"
MWAN3TRACK_STATUS_DIR="/var/run/mwan3track"
MWAN3_INTERFACE_MAX=""
MMX_MASK=""
MMX_DEFAULT=""
MMX_BLACKHOLE=""
MM_BLACKHOLE=""
MMX_UNREACHABLE=""
MM_UNREACHABLE=""
MAX_SLEEP=$(((1<<31)-1))
# nftables inet family handles both IPv4 and IPv6
# Check if IPv6 is disabled in the kernel
[ -d /proc/sys/net/ipv6 ]
NO_IPV6=$?
NFT="nft"
MWAN3_NFT_BATCH="$MWAN3_STATUS_DIR/mwan3_nft_batch.$$"
MWAN3_BATCH_DEPTH=0
MWAN3_NEED_DNSMASQ_HUP=0
LOG()
{
local facility=$1; shift
[ "$facility" = "debug" ] && [ "${MWAN3_VERBOSE_LOGGING:-0}" = "0" ] && return
logger -t "${SCRIPTNAME}[$$]" -p $facility "$*"
}
# Execute an nft command. When inside a batch (MWAN3_BATCH_DEPTH > 0), push
# the command into the batch file instead of running nft immediately.
mwan3_nft_exec()
{
if [ "$MWAN3_BATCH_DEPTH" -gt 0 ]; then
mwan3_nft_push "$@"
return
fi
local error
error=$($NFT "$@" 2>&1) || {
LOG error "nft $*: $error"
return 1
}
}
# Start an nft batch. Truncates the batch file only when opening the outermost
# level (depth 0 -> 1). Nested calls increment depth and are otherwise no-ops,
# so callers can open their own mini-batches inside a larger reload batch
# without inadvertently truncating it.
mwan3_nft_batch_start()
{
if [ "$MWAN3_BATCH_DEPTH" -eq 0 ]; then
local old_umask
old_umask=$(umask)
umask 077
: > "$MWAN3_NFT_BATCH"
umask "$old_umask"
fi
MWAN3_BATCH_DEPTH=$((MWAN3_BATCH_DEPTH + 1))
}
# Append a line to the nft batch. Called directly with a pre-formatted nft
# statement, or indirectly via mwan3_nft_exec when MWAN3_BATCH_DEPTH > 0.
mwan3_nft_push()
{
echo "$*" >> "$MWAN3_NFT_BATCH"
}
# Commit the nft batch. Decrements depth; only commits to the kernel when
# reaching depth 0 (the outermost level). Inner commits from nested callers
# are no-ops: they just decrement the counter and return.
mwan3_nft_batch_commit()
{
MWAN3_BATCH_DEPTH=$((MWAN3_BATCH_DEPTH - 1))
[ "$MWAN3_BATCH_DEPTH" -gt 0 ] && return 0
local error
error=$($NFT -f "$MWAN3_NFT_BATCH" 2>&1) || {
LOG error "nft batch: $error"
rm -f "$MWAN3_NFT_BATCH"
return 1
}
rm -f "$MWAN3_NFT_BATCH"
}
# Begin an atomic reload batch. Opens the outermost batch level (depth 0->1)
# so all subsequent mwan3_nft_batch_start/commit calls from build functions
# accumulate into the same global batch rather than committing immediately.
# The preamble: flushes all skeleton chains, then flushes+deletes all dynamic
# chains (iface_in, policy, rule, or-setter, etc.).
# Internal mwan3_* sets are deleted so mwan3_ensure_nft_framework can recreate
# them with correct flags. User-defined sets are never touched.
mwan3_nft_reload_start()
{
local chain setname
mwan3_nft_batch_start
for chain in mwan3_prerouting mwan3_output mwan3_postrouting \
mwan3_ifaces_in mwan3_rules mwan3_connected mwan3_custom mwan3_dynamic; do
mwan3_nft_push "flush chain inet mwan3 $chain"
done
# Two-pass: flush all dynamic chains first to remove cross-references
# (e.g. mwan3_rule_* chains jump to mwan3_or_meta_* chains), then delete.
# A single-pass flush+delete in alphabetical order fails with "Device or
# resource busy" because mwan3_or_meta_* sorts before mwan3_rule_*, so
# the delete fires while the rule chain still holds a jump reference.
local _dyn_chains=""
for chain in $($NFT list chains inet 2>/dev/null | grep "chain mwan3_" | awk '{gsub(/ \{.*/, ""); print $2}'); do
case "$chain" in
mwan3_prerouting|mwan3_output|mwan3_postrouting|\
mwan3_ifaces_in|mwan3_rules|mwan3_connected|mwan3_custom|mwan3_dynamic)
;;
*)
mwan3_nft_push "flush chain inet mwan3 $chain"
_dyn_chains="$_dyn_chains $chain"
;;
esac
done
for chain in $_dyn_chains; do
mwan3_nft_push "delete chain inet mwan3 $chain"
done
for setname in mwan3_connected_v4 mwan3_connected_v6 \
mwan3_custom_v4 mwan3_custom_v6 \
mwan3_dynamic_v4 mwan3_dynamic_v6; do
mwan3_nft_push "delete set inet mwan3 $setname"
done
}
# Commit the reload batch atomically to the kernel (thin wrapper around
# mwan3_nft_batch_commit, which handles the depth decrement and actual commit).
mwan3_nft_reload_commit()
{
mwan3_nft_batch_commit
}
# Build an nft mark set expression
# iptables: -j MARK --set-xmark VALUE/MASK
# means: mark = (mark & ~MASK) | VALUE
# nftables: meta mark set (meta mark & ~MASK) | VALUE
# Uses & and | symbols (not 'and'/'or' keywords) to avoid parser ambiguity
mwan3_nft_mark_expr()
{
local value="$1" mask="$2"
local complement
complement=$(printf "0x%08x" $(( (~mask) & 0xFFFFFFFF )))
echo "meta mark set meta mark & $complement | $value"
}
# Canonicalise a mark value (e.g. 0x100, 0x00000100) to a stable chain-name suffix.
# Used to name the per-mark OR-immediate setter chains. Always emits lowercase
# 0x%x form so two callers computing the same mark land on the same chain name.
mwan3_or_chain_suffix()
{
printf "0x%x" $(($1))
}
# Build the per-mark OR-immediate setter chains used to synthesise non-destructive
# cross-register copies via vmap dispatch. For each mark M in the configured set
# of valid mwan3 mark values we create:
# chain mwan3_or_meta_<M>: meta mark set meta mark | M
# chain mwan3_or_ct_<M> : ct mark set ct mark | M
# These chains are jumped into from vmap statements that key on the masked value
# of the source register; the runtime register value is "lifted" into the
# immediate operand of each branch's set statement, sidestepping the kernel
# limitation that an nft set-statement expression tree may reference at most one
# runtime source register. With these helpers in place, both restore (ct -> meta)
# and save (meta -> ct) become non-destructive in the unmasked bits, which
# removes mwan3's previous priority dependency on pbr.
mwan3_build_or_chains_nft()
{
local id mark suffix want
# Compute the full set of mark values that may need a setter chain:
# every per-iface mark id 1..MWAN3_INTERFACE_MAX, plus the three special
# marks (default, blackhole, unreachable).
want=""
for id in $(seq 1 "$MWAN3_INTERFACE_MAX"); do
mark=$(mwan3_id2mask id MMX_MASK)
suffix=$(mwan3_or_chain_suffix "$mark")
want="$want $suffix"
done
want="$want $(mwan3_or_chain_suffix "$MMX_DEFAULT")"
want="$want $(mwan3_or_chain_suffix "$MMX_BLACKHOLE")"
want="$want $(mwan3_or_chain_suffix "$MMX_UNREACHABLE")"
# Always flush+repopulate. A previous idempotency check that only verified
# chain *existence* could leave empty chain bodies wedged after a partial
# batch failure, with no recovery path. ~126 trivial statements; cheap.
mwan3_nft_batch_start
for suffix in $want; do
mwan3_nft_push "add chain inet mwan3 mwan3_or_meta_${suffix}"
mwan3_nft_push "add chain inet mwan3 mwan3_or_ct_${suffix}"
mwan3_nft_push "flush chain inet mwan3 mwan3_or_meta_${suffix}"
mwan3_nft_push "flush chain inet mwan3 mwan3_or_ct_${suffix}"
mwan3_nft_push "add rule inet mwan3 mwan3_or_meta_${suffix} meta mark set meta mark | ${suffix}"
mwan3_nft_push "add rule inet mwan3 mwan3_or_ct_${suffix} ct mark set ct mark & $MMX_MASK_COMPLEMENT | ${suffix}"
done
mwan3_nft_batch_commit
}
# Build the verdict-map body of a vmap statement that, given a key set of
# mark values, jumps to the matching mwan3_or_<reg>_<mark> setter chain.
# Args: $1 = "meta" or "ct" (target register), $2... = mark values
# Result echoed as the body for use as: <key-expr> vmap { <body> }
mwan3_or_vmap_body()
{
local reg="$1"; shift
local mark suffix first=1 body=""
for mark in "$@"; do
suffix=$(mwan3_or_chain_suffix "$mark")
if [ $first -eq 1 ]; then
body="${suffix} : jump mwan3_or_${reg}_${suffix}"
first=0
else
body="${body}, ${suffix} : jump mwan3_or_${reg}_${suffix}"
fi
done
echo "$body"
}
# Enumerate every mark value that needs to appear in the restore/save vmaps:
# all per-iface marks plus the three specials. Echoes a space-separated list.
mwan3_all_marks()
{
local id
for id in $(seq 1 "$MWAN3_INTERFACE_MAX"); do
mwan3_id2mask id MMX_MASK
printf " "
done
printf "%s %s %s\n" "$MMX_DEFAULT" "$MMX_BLACKHOLE" "$MMX_UNREACHABLE"
}
# Ensure all mwan3 nftables framework objects exist with correct flags.
# Always deletes and recreates sets to guarantee auto-merge is present.
mwan3_ensure_nft_framework()
{
local setname
# Always delete existing sets — nft 'add set' is idempotent and won't
# update flags (like auto-merge) on existing sets, so we must recreate.
# stop_service() flushes chains first, so no rules reference the sets.
# Inside a reload batch the deletions are already in the preamble.
if [ "$MWAN3_BATCH_DEPTH" -eq 0 ]; then
for setname in mwan3_connected_v4 mwan3_connected_v6 \
mwan3_custom_v4 mwan3_custom_v6 \
mwan3_dynamic_v4 mwan3_dynamic_v6; do
$NFT delete set inet mwan3 "$setname" >/dev/null 2>&1
done
fi
mwan3_nft_batch_start
# Sets for network classification (interval + auto-merge for CIDR support)
mwan3_nft_push "add set inet mwan3 mwan3_connected_v4 { type ipv4_addr; flags interval; auto-merge; }"
mwan3_nft_push "add set inet mwan3 mwan3_connected_v6 { type ipv6_addr; flags interval; auto-merge; }"
mwan3_nft_push "add set inet mwan3 mwan3_custom_v4 { type ipv4_addr; flags interval; auto-merge; }"
mwan3_nft_push "add set inet mwan3 mwan3_custom_v6 { type ipv6_addr; flags interval; auto-merge; }"
mwan3_nft_push "add set inet mwan3 mwan3_dynamic_v4 { type ipv4_addr; flags interval; auto-merge; }"
mwan3_nft_push "add set inet mwan3 mwan3_dynamic_v6 { type ipv6_addr; flags interval; auto-merge; }"
# Hook chains (base chains with type/hook/priority)
mwan3_nft_push "add chain inet mwan3 mwan3_prerouting { type filter hook prerouting priority mangle + 1; policy accept; }"
mwan3_nft_push "add chain inet mwan3 mwan3_output { type route hook output priority mangle + 1; policy accept; }"
# IPv6 SNAT chain (opt-in per interface via 'snat6'). See 10-mwan3.nft
# for the rationale; per-iface rules are added by mwan3_create_iface_nft.
mwan3_nft_push "add chain inet mwan3 mwan3_postrouting { type nat hook postrouting priority srcnat - 1; policy accept; }"
# Internal chains (jumped to from hook chains)
mwan3_nft_push "add chain inet mwan3 mwan3_ifaces_in"
mwan3_nft_push "add chain inet mwan3 mwan3_rules"
mwan3_nft_push "add chain inet mwan3 mwan3_connected"
mwan3_nft_push "add chain inet mwan3 mwan3_custom"
mwan3_nft_push "add chain inet mwan3 mwan3_dynamic"
mwan3_nft_batch_commit
}
mwan3_get_true_iface()
{
local family V
_true_iface=$2
config_get family "$2" family ipv4
if [ "$family" = "ipv4" ]; then
V=4
elif [ "$family" = "ipv6" ]; then
V=6
fi
ubus call "network.interface.${2}_${V}" status &>/dev/null && _true_iface="${2}_${V}"
export "$1=$_true_iface"
}
mwan3_get_src_ip()
{
local family _src_ip interface true_iface device addr_cmd default_ip
interface=$2
mwan3_get_true_iface true_iface $interface
unset "$1"
config_get family "$interface" family ipv4
if [ "$family" = "ipv4" ]; then
addr_cmd='network_get_ipaddr'
default_ip="0.0.0.0"
elif [ "$family" = "ipv6" ]; then
addr_cmd='network_get_ipaddr6'
default_ip="::"
fi
$addr_cmd _src_ip "$true_iface"
if [ -z "$_src_ip" ]; then
if [ "$family" = "ipv6" ]; then
# on IPv6-PD interfaces (like PPPoE interfaces) we don't
# have a real address, just a prefix, that can be delegated
# to interfaces, because using :: (the fallback above) or
# the link-local address will not work (reliably, if at
# all) try to find an address which we can use instead
network_get_prefix6 _src_ip "$true_iface"
if [ -n "$_src_ip" ]; then
# got a prefix like 2001:xxxx:yyyy::/48, clean it up to
# only contain the prefix -> 2001:xxxx:yyyy
_src_ip=$(echo "$_src_ip" | sed -e 's;:*/.*$;;')
_src_ip=$(${MWAN3_GET_ADDR} 6 "" "$_src_ip")
fi
fi
if [ -z "$_src_ip" ]; then
network_get_device device $true_iface
local _fam_num=4
[ "$family" = "ipv6" ] && _fam_num=6
_src_ip=$(${MWAN3_GET_ADDR} "$_fam_num" "$device")
fi
if [ -n "$_src_ip" ]; then
LOG warn "no src $family address found from netifd for interface '$true_iface' dev '$device' guessing $_src_ip"
else
_src_ip="$default_ip"
LOG warn "no src $family address found for interface '$true_iface' dev '$device'"
fi
fi
export "$1=$_src_ip"
}
readfile() {
[ -f "$2" ] || return 1
# read returns 1 on EOF
read -d'\0' $1 <"$2" || :
}
mwan3_get_mwan3track_status()
{
local interface=$2
local track_ips track_gateway pid cmdline started
mwan3_list_track_ips()
{
track_ips="$1 $track_ips"
}
config_list_foreach "$interface" track_ip mwan3_list_track_ips
config_get_bool track_gateway "$interface" track_gateway 0
if [ -z "$track_ips" ] && [ "$track_gateway" -eq 0 ]; then
export -n "$1=disabled"
return
fi
readfile pid $MWAN3TRACK_STATUS_DIR/$interface/PID 2>/dev/null
if [ -z "$pid" ]; then
export -n "$1=down"
return
fi
readfile cmdline /proc/$pid/cmdline 2>/dev/null
if [ "$cmdline" != "/bin/sh/usr/sbin/mwan3track${interface}" ]; then
export -n "$1=down"
return
fi
readfile started $MWAN3TRACK_STATUS_DIR/$interface/STARTED
case "$started" in
0)
export -n "$1=paused"
;;
1)
export -n "$1=active"
;;
*)
export -n "$1=down"
;;
esac
}
mwan3_init()
{
local bitcnt mmdefault source_routing
config_load mwan3
config_get_bool MWAN3_VERBOSE_LOGGING globals verbose_logging 0
[ -d $MWAN3_STATUS_DIR ] || { mkdir -m 0700 $MWAN3_STATUS_DIR && mkdir -m 0700 $MWAN3_STATUS_DIR/iface_state; }
# mwan3's MARKing mask (at least 3 bits should be set)
if [ -e "${MWAN3_STATUS_DIR}/mmx_mask" ]; then
readfile MMX_MASK "${MWAN3_STATUS_DIR}/mmx_mask"
MWAN3_INTERFACE_MAX=$(uci_get_state mwan3 globals iface_max)
else
config_get MMX_MASK globals mmx_mask '0x3F00'
echo "$MMX_MASK"| tr 'A-F' 'a-f' > "${MWAN3_STATUS_DIR}/mmx_mask"
LOG debug "Using firewall mask ${MMX_MASK}"
bitcnt=$(mwan3_count_one_bits MMX_MASK)
mmdefault=$(((1<<bitcnt)-1))
MWAN3_INTERFACE_MAX=$((mmdefault-3))
uci_toggle_state mwan3 globals iface_max "$MWAN3_INTERFACE_MAX"
LOG debug "Max interface count is ${MWAN3_INTERFACE_MAX}"
fi
# mark mask constants
bitcnt=$(mwan3_count_one_bits MMX_MASK)
mmdefault=$(((1<<bitcnt)-1))
MM_BLACKHOLE=$((mmdefault-2))
MM_UNREACHABLE=$((mmdefault-1))
# MMX_DEFAULT should equal MMX_MASK
MMX_DEFAULT=$(mwan3_id2mask mmdefault MMX_MASK)
MMX_BLACKHOLE=$(mwan3_id2mask MM_BLACKHOLE MMX_MASK)
MMX_UNREACHABLE=$(mwan3_id2mask MM_UNREACHABLE MMX_MASK)
# Precompute mask complement for nft rules
MMX_MASK_COMPLEMENT=$(printf "0x%08x" $(( (~MMX_MASK) & 0xFFFFFFFF )))
# Configurable ip rule base priorities. Defaults preserve the historical
# layout exactly: per-interface iif lookup rules at id+1000, fwmark lookup
# rules at id+2000, fwmark unreachable rules at id+3000. Two ordering
# constraints must hold to avoid priority collisions:
# iif_rule_base + MWAN3_INTERFACE_MAX < fwmark_rule_base
# fwmark_rule_base + MWAN3_INTERFACE_MAX + 1 < unreachable_rule_base
# (the +1 reflects that the fwmark tier also contains the global blackhole
# and unreachable rules at fwmark_rule_base + MM_BLACKHOLE / MM_UNREACHABLE,
# the highest of which is fwmark_rule_base + MWAN3_INTERFACE_MAX + 2.)
# If either constraint is violated, all three values are reverted to defaults.
#
# Like MMX_MASK, the post-validation values are persisted to /var/state/mwan3
# at start time and read back from state on subsequent mwan3_init invocations
# (including from stop_service). This ensures the values seen by stop_service
# identify the rules the running instance actually created, even if
# /etc/config/mwan3 has been edited since start. The mmx_mask state file
# doubles as the "instance started" indicator.
if [ -e "${MWAN3_STATUS_DIR}/mmx_mask" ]; then
MWAN3_IIF_RULE_BASE=$(uci_get_state mwan3 globals iif_rule_base 1000)
MWAN3_FWMARK_RULE_BASE=$(uci_get_state mwan3 globals fwmark_rule_base 2000)
MWAN3_UNREACHABLE_RULE_BASE=$(uci_get_state mwan3 globals unreachable_rule_base 3000)
else
config_get MWAN3_IIF_RULE_BASE globals iif_rule_base 1000
config_get MWAN3_FWMARK_RULE_BASE globals fwmark_rule_base 2000
config_get MWAN3_UNREACHABLE_RULE_BASE globals unreachable_rule_base 3000
if [ "$((MWAN3_IIF_RULE_BASE + MWAN3_INTERFACE_MAX))" -ge "$MWAN3_FWMARK_RULE_BASE" ] || \
[ "$((MWAN3_FWMARK_RULE_BASE + MWAN3_INTERFACE_MAX + 1))" -ge "$MWAN3_UNREACHABLE_RULE_BASE" ]; then
LOG warn "Rule base ordering constraint violated (iif=$MWAN3_IIF_RULE_BASE, fwmark=$MWAN3_FWMARK_RULE_BASE, unreachable=$MWAN3_UNREACHABLE_RULE_BASE, max_interfaces=$MWAN3_INTERFACE_MAX); reverting all to defaults 1000/2000/3000"
MWAN3_IIF_RULE_BASE=1000
MWAN3_FWMARK_RULE_BASE=2000
MWAN3_UNREACHABLE_RULE_BASE=3000
fi
uci_toggle_state mwan3 globals iif_rule_base "$MWAN3_IIF_RULE_BASE"
uci_toggle_state mwan3 globals fwmark_rule_base "$MWAN3_FWMARK_RULE_BASE"
uci_toggle_state mwan3 globals unreachable_rule_base "$MWAN3_UNREACHABLE_RULE_BASE"
fi
}
# maps the 1st parameter so it only uses the bits allowed by the bitmask (2nd parameter)
# which means spreading the bits of the 1st parameter to only use the bits that are set to 1 in the 2nd parameter
# 0 0 0 0 0 1 0 1 (0x05) 1st parameter
# 1 0 1 0 1 0 1 0 (0xAA) 2nd parameter
# 1 0 1 result
mwan3_id2mask()
{
local bit_msk bit_val result
bit_val=0
result=0
for bit_msk in $(seq 0 31); do
if [ $((($2>>bit_msk)&1)) = "1" ]; then
if [ $((($1>>bit_val)&1)) = "1" ]; then
result=$((result|(1<<bit_msk)))
fi
bit_val=$((bit_val+1))
fi
done
printf "0x%x" $result
}
# counts how many bits are set to 1
# n&(n-1) clears the lowest bit set to 1
mwan3_count_one_bits()
{
local count n
count=0
n=$(($1))
while [ "$n" -gt "0" ]; do
n=$((n&(n-1)))
count=$((count+1))
done
echo $count
}
get_uptime() {
local _tmp
readfile _tmp /proc/uptime
if [ $# -eq 0 ]; then
echo "${_tmp%%.*}"
else
export -n "$1=${_tmp%%.*}"
fi
}
get_online_time() {
local time_n time_u iface
iface="$2"
readfile time_u "$MWAN3TRACK_STATUS_DIR/${iface}/ONLINE" 2>/dev/null
[ -z "${time_u}" ] || [ "${time_u}" = "0" ] || {
get_uptime time_n
export -n "$1=$((time_n-time_u))"
}
}
reload_service() {
restart
}

View File

@ -0,0 +1,121 @@
#!/usr/bin/env ucode
'use strict';
import * as rtnl from "rtnl";
import * as uci from "uci";
import * as ubus from "ubus";
const RTM_GETROUTE = rtnl.const.RTM_GETROUTE;
const RTM_NEWROUTE = rtnl.const.RTM_NEWROUTE;
const NLM_F_DUMP = rtnl.const.NLM_F_DUMP;
const NLM_F_CREATE = rtnl.const.NLM_F_CREATE;
const NLM_F_REPLACE = rtnl.const.NLM_F_REPLACE;
const RT_TABLE_MAIN = rtnl.const.RT_TABLE_MAIN;
const AF_INET = rtnl.const.AF_INET;
const AF_INET6 = rtnl.const.AF_INET6;
const ROUTE_FIELDS = ["dst", "gateway", "oif", "prefsrc", "priority",
"scope", "type", "tos", "metrics"];
function is_default_route(route) {
return (route.dst == null ||
route.dst == "0.0.0.0/0" ||
route.dst == "::/0");
}
function build_route_for_table(route, tid, src_routing) {
let r = { family: route.family, table: tid };
for (let f in ROUTE_FIELDS)
if (route[f] != null)
r[f] = route[f];
if (r.tos == 0) delete r.tos;
if (src_routing && route.src != null)
r.src = route.src;
return r;
}
let family_num = (ARGV[0] == "6") ? AF_INET6 : AF_INET;
let family_name = (ARGV[0] == "6") ? "ipv6" : "ipv4";
let table_id = +ARGV[1];
let source_routing = +ARGV[2];
let cur = uci.cursor();
cur.load("mwan3");
let extra_table_set = {};
let rt_tables = cur.get("mwan3", "globals", "rt_table_lookup");
if (type(rt_tables) == "array") {
for (let t in rt_tables)
extra_table_set[+t] = true;
} else if (rt_tables != null) {
extra_table_set[+rt_tables] = true;
}
let name_tid = {};
let tid = 0;
cur.foreach("mwan3", "interface", function(s) {
tid++;
let fam = s.family ?? "ipv4";
let enabled = +(s.enabled ?? "1");
if (enabled && fam == family_name)
name_tid[s[".name"]] = tid;
});
cur.unload("mwan3");
let dev_table_map = {};
let uconn = ubus.connect();
if (uconn) {
let dump = uconn.call("network.interface", "dump");
if (dump && dump.interface) {
for (let intf in dump.interface) {
let name = intf.interface;
let t = name_tid[name];
if (t == null) {
let m = match(name, /^(.+)_([46])$/);
if (m) {
let suffix_fam = (m[2] == "4") ? "ipv4" : "ipv6";
if (suffix_fam == family_name)
t = name_tid[m[1]];
}
}
if (t != null && intf.l3_device)
dev_table_map[intf.l3_device] = t;
}
}
uconn.disconnect();
}
let all_routes = rtnl.request(RTM_GETROUTE, NLM_F_DUMP, { family: family_num }) ?? [];
let source_routes = [];
for (let r in all_routes) {
if (r.table == RT_TABLE_MAIN || extra_table_set[r.table])
push(source_routes, r);
}
let existing_keys = {};
for (let r in all_routes) {
if (r.table != table_id) continue;
let key = (r.dst ?? "") + "|" + (r.oif ?? "") + "|" + (r.gateway ?? "") + "|" + (r.priority ?? "");
existing_keys[key] = true;
}
for (let route in source_routes) {
let dev = route.oif;
let target_tid = (dev != null) ? dev_table_map[dev] : null;
if (is_default_route(route) || route.dst == "fe80::/64") {
if (target_tid != table_id) continue;
} else if (target_tid != null && target_tid != table_id) {
continue;
}
let key = (route.dst ?? "") + "|" + (route.oif ?? "") + "|" + (route.gateway ?? "") + "|" + (route.priority ?? "");
if (existing_keys[key]) continue;
let r = build_route_for_table(route, table_id, source_routing);
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));
}

View File

@ -0,0 +1,51 @@
#!/usr/bin/env ucode
'use strict';
import * as rtnl from "rtnl";
const RTM_GETADDR = rtnl.const.RTM_GETADDR;
const NLM_F_DUMP = rtnl.const.NLM_F_DUMP;
const AF_INET = rtnl.const.AF_INET;
const AF_INET6 = rtnl.const.AF_INET6;
let family_num = (ARGV[0] == "6") ? AF_INET6 : AF_INET;
let device = ARGV[1];
let prefix = ARGV[2];
let addrs = rtnl.request(RTM_GETADDR, NLM_F_DUMP, { family: family_num }) ?? [];
let result = null;
if (device != null && device != "" && prefix == null) {
for (let a in addrs) {
if (a.dev != device) continue;
let addr = split(a.local ?? a.address ?? "", "/")[0];
if (!addr) continue;
if (family_num == AF_INET6) {
if (match(addr, /^fe80:/)) continue;
if (a.scope != 0) continue;
} else {
if (a.scope != 0 && a.scope != 253) continue;
}
result = addr;
break;
}
} else if ((device == null || device == "") && prefix != null) {
let pfx = prefix + ":";
for (let a in addrs) {
let addr = split(a.local ?? a.address ?? "", "/")[0];
if (!addr) continue;
if (a.scope != 0) continue;
if (substr(addr, 0, length(pfx)) == pfx) {
result = addr;
break;
}
}
}
if (result != null) {
printf("%s\n", result);
exit(0);
} else {
exit(1);
}

View File

@ -0,0 +1,52 @@
#!/usr/bin/env ucode
'use strict';
import * as rtnl from "rtnl";
const RTM_GETROUTE = rtnl.const.RTM_GETROUTE;
const NLM_F_DUMP = rtnl.const.NLM_F_DUMP;
const AF_INET = rtnl.const.AF_INET;
const AF_INET6 = rtnl.const.AF_INET6;
const RT_TABLE_MAIN = rtnl.const.RT_TABLE_MAIN;
function is_cidr_route(route, family_num) {
let dst = route.dst;
if (dst == null) return false;
let slash = index(dst, "/");
if (slash < 0) return false;
let prefix_len = +substr(dst, slash + 1);
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 ||
match(route.dst, /^169\.254\./) != null));
}
let family_num = (ARGV[0] == "6") ? AF_INET6 : AF_INET;
let table_arg = ARGV[1];
let table_num = (table_arg == "main") ? RT_TABLE_MAIN : +table_arg;
let routes = rtnl.request(RTM_GETROUTE, NLM_F_DUMP, { family: family_num }) ?? [];
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_linklocal_route(route)) continue;
if (family_num == AF_INET) {
let first_octet = +split(route.dst, ".")[0];
if (first_octet >= 224) continue;
}
if (seen[route.dst]) continue;
seen[route.dst] = true;
printf("%s\n", route.dst);
}

View File

@ -0,0 +1,162 @@
#!/usr/bin/env ucode
'use strict';
import * as rtnl from "rtnl";
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 NLM_F_DUMP = rtnl.const.NLM_F_DUMP;
const NLM_F_CREATE = rtnl.const.NLM_F_CREATE;
const AF_INET = rtnl.const.AF_INET;
const AF_INET6 = rtnl.const.AF_INET6;
const FR_ACT_TO_TBL = 1;
const FR_ACT_BLACKHOLE = 6;
const FR_ACT_UNREACHABLE = 7;
function is_default_route(route) {
return (route.dst == null ||
route.dst == "0.0.0.0/0" ||
route.dst == "::/0");
}
let mode = ARGV[0];
if (mode == "check") {
let family_num = (ARGV[1] == "6") ? AF_INET6 : AF_INET;
let priorities = [];
for (let i = 2; i < length(ARGV); i++)
push(priorities, +ARGV[i]);
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;
let result = 0;
for (let i = 0; i < length(priorities); i++)
if (!existing[priorities[i]])
result |= (1 << i);
printf("%d\n", result);
} else if (mode == "check-route") {
let family_num = (ARGV[1] == "6") ? AF_INET6 : AF_INET;
let table_id = +ARGV[2];
let device = ARGV[3];
let routes = rtnl.request(RTM_GETROUTE, NLM_F_DUMP, { family: family_num }) ?? [];
for (let r in routes) {
if (r.table == table_id && is_default_route(r) && r.oif == device)
exit(0);
}
exit(1);
} else if (mode == "delete-iface") {
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 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.dst != null) continue;
if (rule.fwmark == null && rule.priority == iif_prio) {
rtnl.request(RTM_DELRULE, 0, {
family: family,
priority: rule.priority,
table: rule.table,
action: rule.action
});
let err = rtnl.error();
if (err)
warn(sprintf("mwan3-manage-rules: delete iif rule failed: %s\n", 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,
fwmark: rule.fwmark,
fwmask: rule.fwmask,
table: rule.table,
action: rule.action
});
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));
}
}
}
} else if (mode == "add-general") {
let family_num = (ARGV[1] == "6") ? AF_INET6 : AF_INET;
let bh_prio = +ARGV[2], bh_mark = +ARGV[3];
let ur_prio = +ARGV[4], ur_mark = +ARGV[5];
let mask = +ARGV[6];
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[bh_prio]) {
rtnl.request(RTM_NEWRULE, NLM_F_CREATE, {
family: family_num,
priority: bh_prio,
fwmark: bh_mark,
fwmask: mask,
action: FR_ACT_BLACKHOLE
});
let err = rtnl.error();
if (err)
warn(sprintf("mwan3-manage-rules: add blackhole rule failed: %s\n", err));
}
if (!existing[ur_prio]) {
rtnl.request(RTM_NEWRULE, NLM_F_CREATE, {
family: family_num,
priority: ur_prio,
fwmark: ur_mark,
fwmask: mask,
action: FR_ACT_UNREACHABLE
});
let err = rtnl.error();
if (err)
warn(sprintf("mwan3-manage-rules: add unreachable rule failed: %s\n", err));
}
}

View File

@ -0,0 +1,81 @@
#!/bin/sh
# One-shot migration: scan /etc/config/mwan3 rules for ipset references and
# emit config ipset sections into /etc/config/mwan3, copying family, loadfile
# and inline entry values from /etc/config/firewall where available.
# Safe to run multiple times -- skips sets that already have a mwan3 declaration.
. /lib/functions.sh
LOG_TAG="mwan3-migrate-v4"
config_load mwan3
# Collect set names referenced by any mwan3 rule.
referenced_sets=""
_collect_refs()
{
local ipset_name ipset_src
config_get ipset_name "$1" ipset
config_get ipset_src "$1" ipset_src
[ -n "$ipset_name" ] && referenced_sets="$referenced_sets $ipset_name"
[ -n "$ipset_src" ] && referenced_sets="$referenced_sets $ipset_src"
}
config_foreach _collect_refs rule
[ -n "$referenced_sets" ] || exit 0
# Collect existing mwan3-side declarations for idempotency.
existing=""
_collect_existing()
{
local name
config_get name "$1" name
[ -n "$name" ] && existing="$existing $name"
}
config_foreach _collect_existing ipset
# For each referenced set not yet declared on the mwan3 side, pick up
# family, loadfile and inline entries from /etc/config/firewall and emit
# a section in /etc/config/mwan3.
for name in $referenced_sets; do
case " $existing " in *" $name "*) continue ;; esac
fw4_family=""
fw4_loadfile=""
fw4_section=""
found=0
_fw4_find()
{
local fname
config_get fname "$1" name
[ "$fname" = "$name" ] || return
config_get fw4_family "$1" family ipv4
config_get fw4_loadfile "$1" loadfile
fw4_section="$1"
found=1
}
reset_cb
config_load firewall
config_foreach _fw4_find ipset
if [ "$found" -eq 1 ]; then
sid=$(uci -q add mwan3 ipset)
uci -q set mwan3."$sid".name="$name"
uci -q set mwan3."$sid".family="$fw4_family"
[ -n "$fw4_loadfile" ] && uci -q set mwan3."$sid".loadfile="$fw4_loadfile"
_add_entry() { uci -q add_list mwan3."$sid".entry="$1"; }
config_list_foreach "$fw4_section" entry _add_entry
logger -t "$LOG_TAG" \
"added config ipset '$name' (family=$fw4_family) to /etc/config/mwan3"
existing="$existing $name"
else
logger -t "$LOG_TAG" \
"WARN: mwan3 rule references set '$name' not found in /etc/config/firewall; declare manually in /etc/config/mwan3 with name, family, and population source"
fi
reset_cb
config_load mwan3
done
uci -q commit mwan3
exit 0

View File

@ -0,0 +1,99 @@
# mwan3 nftables framework (v4)
# Standalone ruleset loaded by mwan3's init script via `nft -f` at
# start_service, before mwan3_ensure_nft_framework. Defines mwan3's
# own table (inet mwan3), its six address sets, its hook base chains
# at mangle + 1 priority, and its regular chains.
#
# All runtime content (per-interface mwan3_iface_in_* chains, policy
# chains, sticky-rule chains, setter chains, policy/rule content) is
# added dynamically by the mwan3 shell scripts.
#
# The leading "table inet mwan3" + "delete table inet mwan3" pair is
# the canonical idempotent atomic-replace idiom: the first statement
# ensures the table exists (no-op if already present) so the delete
# can succeed, the second wipes it, and the subsequent definition
# block recreates it fresh. All three statements run as one atomic
# nft transaction inside `nft -f`.
table inet mwan3
delete table inet mwan3
table inet mwan3 {
# Connected networks (populated by mwan3rtmon and mwan3_set_connected)
set mwan3_connected_v4 {
type ipv4_addr
flags interval
auto-merge
}
set mwan3_connected_v6 {
type ipv6_addr
flags interval
auto-merge
}
# Custom routing table networks
set mwan3_custom_v4 {
type ipv4_addr
flags interval
auto-merge
}
set mwan3_custom_v6 {
type ipv6_addr
flags interval
auto-merge
}
# Dynamic networks
set mwan3_dynamic_v4 {
type ipv4_addr
flags interval
auto-merge
}
set mwan3_dynamic_v6 {
type ipv6_addr
flags interval
auto-merge
}
# Entry point for prerouting
# priority mangle + 1: runs AFTER fw4's main mangle and after pbr.
# Order-independence with pbr is achieved by using non-destructive
# masked save/restore via vmap-dispatch into per-mark OR-immediate
# setter chains (mwan3_or_meta_*, mwan3_or_ct_*); see common.sh.
chain mwan3_prerouting {
type filter hook prerouting priority mangle + 1; policy accept;
}
# Entry point for locally-originated traffic
# type route so mark changes trigger re-routing
chain mwan3_output {
type route hook output priority mangle + 1; policy accept;
}
# Interface input marking (jumped to from mwan3_prerouting/mwan3_output)
chain mwan3_ifaces_in {
}
# User-defined classification rules
chain mwan3_rules {
}
# Connected/custom/dynamic destination matching
chain mwan3_connected {
}
chain mwan3_custom {
}
chain mwan3_dynamic {
}
# IPv6 SNAT for router-originated traffic rerouted by mwan3_output. fw4 does
# not masquerade IPv6 by default, so packets rerouted from WAN-A onto WAN-B
# would egress with WAN-A's source prefix and be dropped upstream. Opt-in
# per interface via the 'snat6' UCI option. Per-iface rules are added
# dynamically by mwan3.sh and only fire when (oif == this WAN) AND
# (mark == this WAN's mark) AND (saddr is local) AND (saddr != this WAN's
# IP). Runs at srcnat - 1 so it fires before fw4's srcnat hook.
chain mwan3_postrouting {
type nat hook postrouting priority srcnat - 1; policy accept;
}
}

File diff suppressed because it is too large Load Diff

248
mwan3-nft/files/usr/sbin/mwan3 Executable file
View File

@ -0,0 +1,248 @@
#!/bin/sh
. /lib/functions.sh
. /usr/share/libubox/jshn.sh
. /lib/functions/network.sh
. /lib/mwan3/mwan3.sh
command_help() {
local cmd="$1"
local help="$2"
echo "$(printf "%-25s%s" "${cmd}" "${help}")"
}
help()
{
cat <<EOF
Syntax: mwan3 [command]
Available commands:
EOF
command_help "start" "Load nftables rules, ip rules and ip routes"
command_help "stop" "Unload nftables rules, ip rules and ip routes"
command_help "restart" "Reload nftables rules, ip rules and ip routes"
command_help "ifup <iface>" "Load rules and routes for specific interface"
command_help "ifdown <iface>" "Unload rules and routes for specific interface"
command_help "interfaces" "Show interfaces status"
command_help "policies" "Show currently active policy"
command_help "connected" "Show directly connected networks"
command_help "rules" "Show active rules"
command_help "status" "Show all status"
command_help "internal <ipv4|ipv6>" "Show internal configuration <default: ipv4>"
command_help "use <iface> <cmd>" "Run a command bound to <iface> and avoid mwan3 rules"
}
ifdown() {
if [ -z "$1" ]; then
echo "Error: Expecting interface. Usage: mwan3 ifdown <interface>"
exit 0
fi
if [ -n "$2" ]; then
echo "Error: Too many arguments. Usage: mwan3 ifdown <interface>"
exit 0
fi
mwan3_interface_hotplug_shutdown "$1" 1
}
ifup() {
. /etc/init.d/mwan3
if [ -z "$1" ]; then
echo "Expecting interface. Usage: mwan3 ifup <interface>"
exit 0
fi
if [ -n "$2" ]; then
echo "Too many arguments. Usage: mwan3 ifup <interface>"
exit 0
fi
mwan3_ifup "$1" "cmd"
}
interfaces()
{
config_load mwan3
echo "Interface status:"
config_foreach mwan3_report_iface_status interface
echo
}
policies()
{
echo "Current policies:"
mwan3_report_policies_v4
echo
}
connected()
{
echo "Directly connected ipv4 networks:"
mwan3_report_connected_v4
echo
[ $NO_IPV6 -ne 0 ] && return
echo "Directly connected ipv6 networks:"
mwan3_report_connected_v6
echo
}
rules()
{
echo "Active user rules:"
mwan3_report_rules_v4
echo
}
status()
{
interfaces
policies
connected
rules
}
internal()
{
local family="$1"
local dash="-------------------------------------------------"
if [ -f "/etc/openwrt_release" ]; then
. /etc/openwrt_release
fi
local ip output
if [ "$family" = "ipv6" ]; then
ip="$IP6"
else
ip="$IP4"
fi
echo "Software-Version"
echo "$dash"
local mwan3_ver
mwan3_ver=$(apk list -I 2>/dev/null | grep '^mwan3-' | head -1 | sed 's/^mwan3-//; s/ .*//')
[ -z "$mwan3_ver" ] && mwan3_ver="unknown"
echo "mwan3 - $mwan3_ver"
echo ""
echo "Output of \"$ip a show\""
echo "$dash"
output="$($ip a show)"
if [ "$output" != "" ]; then
echo "$output"
else
echo "No data found"
fi
echo ""
echo "Output of \"$ip route show\""
echo "$dash"
output="$($ip route show)"
if [ "$output" != "" ]; then
echo "$output"
else
echo "No data found"
fi
echo ""
echo "Output of \"$ip rule show\""
echo "$dash"
output="$($ip rule show)"
if [ "$output" != "" ]; then
echo "$output"
else
echo "No data found"
fi
echo ""
echo "Output of \"$ip route list table 1-250\""
echo "$dash"
local dump=0
for i in $(seq 1 250); do
output=$($ip route list table $i 2>/dev/null)
if [ "$output" != "" ];then
dump=1
echo "Routing table $i:"
echo "$output"
echo ""
fi
done
if [ "$dump" = "0" ]; then
echo "No data found"
echo ""
fi
echo "Output of \"nft list table inet mwan3\" (mwan3 chains)"
echo "$dash"
output=$($NFT list table inet mwan3 2>/dev/null | grep -A 100 'chain mwan3_')
if [ "$output" != "" ]; then
echo "$output"
else
echo "No data found"
fi
}
start() {
/etc/init.d/mwan3 enable
/etc/init.d/mwan3 start
}
stop() {
/etc/init.d/mwan3 disable
/etc/init.d/mwan3 stop
}
restart() {
/etc/init.d/mwan3 enable
/etc/init.d/mwan3 stop
/etc/init.d/mwan3 start
}
use() {
# Run a command with the device, src_ip and fwmark set to avoid processing by mwan3
# firewall rules
local interface device src_ip family
interface=$1 ; shift
[ -z "$*" ] && echo "no command specified for mwan3 use" >&2 && return
network_get_device device $interface
[ -z "$device" ] && echo "could not find device for $interface" >&2 && return
mwan3_get_src_ip src_ip $interface
[ -z "$src_ip" ] && echo "could not find src_ip for $interface" >&2 && return
config_get family $interface family
[ -z "$family" ] && echo "could not find family for $interface. Using ipv4." >&2 && family='ipv4'
echo "Running '$*' with DEVICE=$device SRCIP=$src_ip FWMARK=$MMX_DEFAULT FAMILY=$family" >&2
# if a program (not a shell builtin) is run: use "exec" for allowing direct process control
if [ -x "$(command -v "$1")" ]; then
set -- exec "$@"
fi
FAMILY=$family DEVICE=$device SRCIP=$src_ip FWMARK=$MMX_DEFAULT LD_PRELOAD=/lib/mwan3/libwrap_mwan3_sockopt.so.1.0 "$@"
}
case "$1" in
ifup|ifdown|interfaces|policies|connected|rules|status|start|stop|restart|internal)
mwan3_init
"$@"
;;
use)
mwan3_init
"$@"
# Propagate mwan3 use command exit code
exit "$?"
;;
*)
help
;;
esac
exit 0

View File

@ -0,0 +1,426 @@
#!/usr/bin/ucode
// mwan3 comprehensive diagnostic script
// Run with: ucode /tmp/mwan3-diag.uc
//
// Collects network state relevant to mwan3 debugging.
// Public routable IPv4 and IPv6 addresses are replaced with PUB4_N / PUB6_N
// placeholders throughout the entire output so the report can be posted
// publicly. The same physical address always receives the same placeholder,
// so cross-references between routing tables, neighbor cache, nft rules,
// configuration and log entries remain internally consistent.
//
// Link-local (fe80::), ULA (fc00::/7), RFC1918, loopback and multicast
// addresses are left unchanged -- they are unroutable and diagnostically
// important.
import * as rtnl from 'rtnl';
import * as sock from 'socket';
import * as ubus from 'ubus';
import * as fs from 'fs';
// ---------------------------------------------------------------------------
// Address classification
// ---------------------------------------------------------------------------
function is_public_v6(addr) {
if (!addr || addr == '::' || addr == '::1') return false;
let a = lc(addr);
if (match(a, /^fe[89ab][0-9a-f]:/)) return false; // link-local fe80::/10
if (match(a, /^f[cd]/)) return false; // ULA fc00::/7
if (match(a, /^ff/)) return false; // multicast ff00::/8
return true;
}
function is_public_v4(addr) {
if (!addr) return false;
let p = split(addr, '.');
if (length(p) != 4) return false;
let a = +p[0], b = +p[1];
if (a == 0) return false; // "this network" (RFC 1122)
if (a == 127) return false; // loopback
if (a == 10) return false; // RFC1918
if (a == 172 && b >= 16 && b <= 31) return false; // RFC1918
if (a == 192 && b == 168) return false; // RFC1918
if (a == 169 && b == 254) return false; // link-local
if (a >= 224) return false; // multicast + reserved
return true;
}
// ---------------------------------------------------------------------------
// Address normalisation via sock.sockaddr()
// Returns canonical string form, or null on failure.
// Strips any /prefix-length before parsing.
// ---------------------------------------------------------------------------
function norm_addr(addr_str) {
if (!addr_str) return null;
let sa = sock.sockaddr(split(addr_str, '/')[0]);
if (!sa || (sa.family != 2 && sa.family != 10)) return null;
return sa.address;
}
// ---------------------------------------------------------------------------
// Phase 1: collect all structured data via rtnl
// ---------------------------------------------------------------------------
let nb_all = rtnl.request(rtnl.const.RTM_GETNEIGH, rtnl.const.NLM_F_DUMP, {});
let rt6_all = rtnl.request(rtnl.const.RTM_GETROUTE, rtnl.const.NLM_F_DUMP, { family: 10 });
let rt4_all = rtnl.request(rtnl.const.RTM_GETROUTE, rtnl.const.NLM_F_DUMP, { family: 2 });
let rul6_all = rtnl.request(rtnl.const.RTM_GETRULE, rtnl.const.NLM_F_DUMP, { family: 10 });
let rul4_all = rtnl.request(rtnl.const.RTM_GETRULE, rtnl.const.NLM_F_DUMP, { family: 2 });
let addr_all = rtnl.request(rtnl.const.RTM_GETADDR, rtnl.const.NLM_F_DUMP, {});
// mwan3 interface status via ubus
let ub = ubus.connect();
let mwan3_st = ub ? ub.call('mwan3', 'status', { section: 'interfaces' }) : null;
// mwan3 UCI config
let mwan3_cfg = null;
{
let f = fs.open('/etc/config/mwan3', 'r');
if (f) { mwan3_cfg = f.read('all'); f.close(); }
}
// ---------------------------------------------------------------------------
// Phase 2: extract every address that appears anywhere and classify it
// ---------------------------------------------------------------------------
let pub6_seen = {};
let pub4_seen = {};
function register(addr_str) {
if (!addr_str) return;
let norm = norm_addr(addr_str);
if (!norm) return;
let sa = sock.sockaddr(split(addr_str, '/')[0]);
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;
}
for (let n in nb_all) { register(n.dst); }
for (let r in rt6_all) { register(r.dst); register(r.gateway); register(r.src); register(r.prefsrc); }
for (let r in rt4_all) { register(r.dst); register(r.gateway); register(r.src); register(r.prefsrc); }
for (let r in rul6_all) { register(r.src); register(r.dst); }
for (let r in rul4_all) { register(r.src); register(r.dst); }
for (let a in addr_all) { register(a.address); register(a.local); }
if (mwan3_st?.interfaces)
for (let iface, idata in mwan3_st.interfaces)
for (let tip in (idata.track_ip || []))
register(tip.ip);
// 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;
}
// Strip elements from user-defined nft sets, replacing with { ... }.
// mwan3 infrastructure sets (names starting with mwan3_) are left intact:
// their IPs derive from the routing table and are already in addr_map.
// All other sets (user ipsets in the mwan3 table, fw4 sets) may contain
// arbitrary IPs not captured in phase 2 and have their elements redacted.
function strip_set_elements(text) {
let lines = split(text, '\n');
let out = [];
let skip = false;
let user_set = false;
for (let line in lines) {
if (skip) {
if (index(line, '}') != -1) skip = false;
continue;
}
// Detect set declaration: trimmed line is 'set <name> {'
let trimmed = trim(line);
if (substr(trimmed, 0, 4) == 'set ' && substr(trimmed, length(trimmed) - 2) == ' {') {
let setname = substr(trimmed, 4, length(trimmed) - 6);
user_set = (substr(setname, 0, 6) != 'mwan3_');
}
if (user_set) {
let ei = index(line, 'elements = {');
if (ei != -1) {
let after = substr(line, ei + length('elements = {'));
push(out, substr(line, 0, ei) + 'elements = { ... }');
if (index(after, '}') == -1) skip = true;
continue;
}
}
push(out, line);
}
return join('\n', out);
}
// Run a shell command and return its anonymised output.
function pcmd(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');
}
// ---------------------------------------------------------------------------
// Named constants for formatting
// ---------------------------------------------------------------------------
let NUD = { '1':'INCOMPLETE', '2':'REACHABLE', '4':'STALE', '8':'DELAY',
'16':'PROBE', '32':'FAILED', '64':'NOARP', '128':'PERMANENT' };
let PROTO = { '2':'kernel', '3':'boot', '4':'static', '11':'dhcp',
'186':'bgp', '187':'isis', '188':'ospf', '189':'rip' };
function nud_name(s) { return NUD[s] || sprintf('state=0x%x', s); }
function proto_name(p) { return PROTO[p] || sprintf('proto=%d', p); }
let TBL_NAMES = { '253': 'default', '254': 'main' };
function tbl_label(n) {
let name = TBL_NAMES[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
// ---------------------------------------------------------------------------
let mwan3_ver = 'unknown';
{
let f = fs.popen("apk list --installed 2>/dev/null | grep '^mwan3'");
let l = trim(f?.read('line') ?? '');
f?.close();
let parts = length(l) ? split(l, ' ') : [];
if (length(parts)) mwan3_ver = parts[0];
}
// ---------------------------------------------------------------------------
// Output
// ---------------------------------------------------------------------------
printf('=== mwan3 Diagnostic Report ===\n');
printf('Timestamp : %s', fs.popen('date')?.read('line') ?? '\n');
printf('Version : %s\n', mwan3_ver);
printf('Table : %s\n', mwan3_tbl);
printf('\n');
printf('NOTE: Public routable addresses are replaced with PUB4_N / PUB6_N\n');
printf(' placeholders. The same address always gets the same placeholder\n');
printf(' so cross-references within this report remain consistent.\n');
printf(' fe80:: link-local, ULA (fc00::/7), RFC1918 and multicast are shown as-is.\n');
printf('\n');
// --- IPv6 interface addresses ---
printf('=== IPv6 Interface Addresses ===\n');
for (let a in addr_all) {
if (a.family != 10 || a.dev == 'lo') continue;
let scope = (a.scope == 253) ? ' scope link' : (a.scope == 254) ? ' scope host' : '';
printf(' %-16s %s%s\n', a.dev, anon(a.address), scope);
}
printf('\n');
// --- IPv4 interface addresses ---
printf('=== IPv4 Interface Addresses ===\n');
for (let a in addr_all) {
if (a.family != 2 || a.dev == 'lo') continue;
printf(' %-16s %s\n', a.dev, anon(a.address));
}
printf('\n');
// --- IPv6 routing tables (all tables except local/unspec) ---
{
let tbls = {};
for (let r in rt6_all) if (r.table && r.table != 255) tbls[r.table] = 1;
let tlist = sort(keys(tbls), (a, b) => +a - +b);
printf('=== IPv6 Routing Tables ===\n');
for (let ts in tlist) {
let tn = +ts;
printf('-- %s --\n', tbl_label(tn));
for (let r in rt6_all) {
if (r.table != tn) continue;
let dst = anon(r.dst) ?? 'default';
let src = r.src ? sprintf(' from %s', anon(r.src)) : '';
let via = r.gateway ? sprintf(' via %s', anon(r.gateway)) : '';
let dev = r.oif ? sprintf(' dev %s', r.oif) : '';
let metric = r.priority ? sprintf(' metric %d', r.priority) : '';
printf(' %s%s%s%s proto %s%s\n', dst, src, via, dev, proto_name(r.protocol), metric);
}
printf('\n');
}
}
// --- IPv4 routing tables (all tables except local/unspec) ---
{
let tbls = {};
for (let r in rt4_all) if (r.table && r.table != 255) tbls[r.table] = 1;
let tlist = sort(keys(tbls), (a, b) => +a - +b);
printf('=== IPv4 Routing Tables ===\n');
for (let ts in tlist) {
let tn = +ts;
printf('-- %s --\n', tbl_label(tn));
for (let r in rt4_all) {
if (r.table != tn) continue;
let dst = anon(r.dst) ?? 'default';
let via = r.gateway ? sprintf(' via %s', anon(r.gateway)) : '';
let dev = r.oif ? sprintf(' dev %s', r.oif) : '';
let metric = r.priority ? sprintf(' metric %d', r.priority) : '';
printf(' %s%s%s proto %s%s\n', dst, via, dev, proto_name(r.protocol), metric);
}
printf('\n');
}
}
// rtnl does not decode FRA_IIFNAME; parse text output to get iif per priority.
function parse_iif(family_flag) {
let m = {};
let f = fs.popen(sprintf('ip -%s rule list 2>/dev/null', family_flag));
if (!f) return m;
for (let line = f.read('line'); length(line); line = f.read('line')) {
let parts = split(trim(line), /\s+/);
let prio = +replace(parts[0], ':', '');
for (let i = 1; i + 1 < length(parts); i++) {
if (parts[i] == 'iif') { m[prio] = parts[i + 1]; break; }
}
}
f.close();
return m;
}
function fmt_rule(r, iif_map) {
let src = r.src ? sprintf(' from %s', anon(r.src)) : ' from all';
let iif = iif_map[r.priority] ? sprintf(' iif %s', iif_map[r.priority]) : '';
let fwmark = r.fwmark ? sprintf(' fwmark 0x%x/0x%x', r.fwmark, r.fwmask) : '';
let action;
if (r.action == 6) action = 'blackhole';
else if (r.action == 7) action = 'unreachable';
else if (r.action == 8) action = 'prohibit';
else action = sprintf('lookup %s', r.table);
printf(' %-12s%s%s%s %s\n', (r.priority ?? 0) + ':', src, iif, fwmark, action);
}
let iif6_map = parse_iif('6');
let iif4_map = parse_iif('4');
// --- IPv6 policy rules ---
printf('=== IPv6 Policy Rules ===\n');
for (let r in rul6_all) fmt_rule(r, iif6_map);
printf('\n');
// --- IPv4 policy rules ---
printf('=== IPv4 Policy Rules ===\n');
for (let r in rul4_all) fmt_rule(r, iif4_map);
printf('\n');
// --- IPv6 neighbor cache ---
printf('=== IPv6 Neighbor Cache ===\n');
for (let n in nb_all) {
if (n.family != 10 || n.dev == 'lo') continue;
let mac = n.lladdr ? sprintf(' lladdr %s', n.lladdr) : '';
printf(' %-40s dev %-16s%s %s\n', anon(n.dst), n.dev, mac, nud_name(n.state));
}
printf('\n');
// --- IPv4 neighbor cache ---
printf('=== IPv4 Neighbor Cache ===\n');
for (let n in nb_all) {
if (n.family != 2 || n.dev == 'lo') continue;
let mac = n.lladdr ? sprintf(' lladdr %s', n.lladdr) : '';
printf(' %-18s dev %-16s%s %s\n', anon(n.dst), n.dev, mac, nud_name(n.state));
}
printf('\n');
// --- mwan3 status ---
printf('=== mwan3 Interface Status ===\n');
if (mwan3_st?.interfaces) {
for (let iface, d in mwan3_st.interfaces) {
printf(' %-14s status=%-12s score=%d tracking=%s\n',
iface, d.status, d.score, d.tracking);
for (let tip in (d.track_ip || []))
printf(' track %-30s status=%-8s latency=%dms loss=%d%%\n',
anon(tip.ip), tip.status, tip.latency, tip.packetloss);
}
} else {
printf(' (mwan3 not running or ubus call failed)\n');
}
printf('\n');
// --- mwan3 UCI config ---
printf('=== mwan3 Configuration (/etc/config/mwan3) ===\n');
if (mwan3_cfg)
printf('%s\n', anon_text(mwan3_cfg));
else
printf(' (not found)\n\n');
// --- 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))));
// --- 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('=== fw4 mangle_output chain ===\n');
printf('%s\n', pcmd('nft list chain inet fw4 mangle_output 2>/dev/null'));
printf('=== fw4 output chain ===\n');
printf('%s\n', pcmd('nft list chain inet fw4 output 2>/dev/null'));
// --- 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('=== End of Report ===\n');

View File

@ -0,0 +1,739 @@
#!/bin/sh
# mwan3-lb-test - Load balancing distribution verifier
#
# Verifies that a mwan3 load-balancing policy distributes traffic according
# to its configured member weights. Inserts temporary nft counter rules,
# generates a test command using mwan3 tracking IPs, waits for the user to
# run it, then reports per-member actual vs expected hit counts.
#
# Usage: mwan3-lb-test [-6] -c <client_ip> <policy_name> [ip1 ip2 ...]
# mwan3-lb-test cleanup
#
# -6 Test IPv6 traffic distribution instead of IPv4 (default).
#
# -c <client_ip>
# IP address of the LAN client that will run the test pings
# (required). Used to add isolation rules that block pings to
# the test destination IPs from all other LAN clients and from
# the router itself, preventing counter contamination.
#
# cleanup Remove any stale mwan3_lb_test_* sets and associated rules
# left behind by a run that was killed before it could clean up.
#
# Optional IP arguments override the default set of well-known public IPs
# used as ping destinations. Provide at least two distinct IPs. Use this
# when the well-known defaults are not reachable from your WAN links.
#
# Run on the router as root.
. /lib/functions.sh
TABLE="inet mwan3"
FORWARD_TABLE="inet fw4"
MWAN3TRACK_STATUS_DIR="/var/run/mwan3track"
MMX_MASK=$(uci -q get mwan3.globals.mmx_mask 2>/dev/null || echo "0x3f00")
HANDLES=""
TEST_RULE_HANDLE=""
FORWARD_BLOCK_HANDLE=""
OUTPUT_ISOLATION_HANDLE=""
TEST_SET="mwan3_lb_test_$$"
IPV6=0
CLIENT_IP=""
POLICY=""
MEMBERS=""
MEMBER_MARKS=""
MEMBER_WEIGHTS=""
MEMBER_IFACES=""
# ---------------------------------------------------------------------------
# Utilities
# ---------------------------------------------------------------------------
die() {
echo "ERROR: $*" >&2
cleanup
exit 1
}
warn() {
echo "WARNING: $*" >&2
}
cleanup() {
# Remove isolation rules
if [ -n "$OUTPUT_ISOLATION_HANDLE" ]; then
nft delete rule $TABLE mwan3_output handle "$OUTPUT_ISOLATION_HANDLE" 2>/dev/null
OUTPUT_ISOLATION_HANDLE=""
fi
if [ -n "$FORWARD_BLOCK_HANDLE" ]; then
nft delete rule $FORWARD_TABLE forward handle "$FORWARD_BLOCK_HANDLE" 2>/dev/null
FORWARD_BLOCK_HANDLE=""
fi
# Remove temporary test rule from mwan3_rules
if [ -n "$TEST_RULE_HANDLE" ]; then
nft delete rule $TABLE mwan3_rules handle "$TEST_RULE_HANDLE" 2>/dev/null
TEST_RULE_HANDLE=""
fi
# Remove temporary destination sets (one per table)
nft delete set $TABLE "$TEST_SET" 2>/dev/null
nft delete set $FORWARD_TABLE "$TEST_SET" 2>/dev/null
# Remove diagnostic counter rules from policy chain
[ -z "$HANDLES" ] && return
for h in $HANDLES; do
nft delete rule $TABLE "mwan3_policy_${POLICY}" handle "$h" 2>/dev/null
done
HANDLES=""
}
trap 'echo; echo "Interrupted - cleaning up..."; cleanup; exit 1' INT TERM PIPE
# Append handle to HANDLES list
add_handle() {
if [ -z "$HANDLES" ]; then
HANDLES="$1"
else
HANDLES="$HANDLES $1"
fi
}
# ---------------------------------------------------------------------------
# Argument check
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Cleanup-only invocation: mwan3-lb-test cleanup
#
# Removes any stale mwan3_lb_test_* sets and associated rules left behind by
# an aborted run (e.g. killed with SIGKILL before cleanup() could run).
# ---------------------------------------------------------------------------
if [ "$1" = "cleanup" ]; then
found=0
for stale_set in $(nft list sets inet 2>/dev/null \
| awk '/mwan3_lb_test_/{gsub(/.*mwan3_lb_test_/,"mwan3_lb_test_"); gsub(/ \{.*/,""); print}'); do
found=1
echo "Removing stale set: $stale_set"
stale_fwd=$(nft -a list chain $FORWARD_TABLE forward 2>/dev/null \
| awk "/@${stale_set}.*drop/{print \$NF; exit}")
[ -n "$stale_fwd" ] && {
echo " Removing forward isolation rule (handle $stale_fwd)"
nft delete rule $FORWARD_TABLE forward handle "$stale_fwd" 2>/dev/null
}
stale_out=$(nft -a list chain $TABLE mwan3_output 2>/dev/null \
| awk "/@${stale_set}.*return/{print \$NF; exit}")
[ -n "$stale_out" ] && {
echo " Removing output isolation rule (handle $stale_out)"
nft delete rule $TABLE mwan3_output handle "$stale_out" 2>/dev/null
}
stale_handle=$(nft -a list chain $TABLE mwan3_rules 2>/dev/null \
| awk "/@${stale_set}/{print \$NF; exit}")
[ -n "$stale_handle" ] && {
echo " Removing mwan3_rules test rule (handle $stale_handle)"
nft delete rule $TABLE mwan3_rules handle "$stale_handle" 2>/dev/null
}
nft delete set $TABLE "$stale_set" 2>/dev/null
nft delete set $FORWARD_TABLE "$stale_set" 2>/dev/null
done
[ "$found" -eq 0 ] && echo "Nothing to clean up."
exit 0
fi
while true; do
case "$1" in
-6) IPV6=1; shift ;;
-c)
[ -z "$2" ] && { echo "ERROR: -c requires an IP address argument" >&2; exit 1; }
CLIENT_IP="$2"; shift 2 ;;
*) break ;;
esac
done
if [ -z "$1" ]; then
echo "Usage: $0 [-6] -c <client_ip> <policy_name> [ip1 ip2 ...]"
echo " $0 cleanup"
echo
echo " -6 Test IPv6 traffic distribution (default: IPv4)"
echo " -c <client_ip> IP address of the LAN client running the test pings (required)"
echo " cleanup Remove stale rules/sets from an aborted run"
echo
echo " Optional IP arguments override the default well-known public IPs."
echo
echo "Available load-balancing policies:"
nft list chains inet 2>/dev/null \
| awk '/mwan3_policy_/{gsub(/.*mwan3_policy_/,""); gsub(/ \{.*/,""); print}' \
| sort -u \
| while read -r name; do
nft list chain inet mwan3 "mwan3_policy_${name}" 2>/dev/null \
| grep -q "numgen" && echo " $name"
done
exit 1
fi
[ -z "$CLIENT_IP" ] && die "client IP is required - use -c <ip>"
POLICY="$1"
shift
CHAIN="mwan3_policy_${POLICY}"
CUSTOM_IPS="$*"
# ---------------------------------------------------------------------------
# Pre-flight: chain exists
# ---------------------------------------------------------------------------
nft list chain $TABLE "$CHAIN" >/dev/null 2>&1 \
|| die "chain $CHAIN not found - is mwan3 running?"
# ---------------------------------------------------------------------------
# Pre-flight: chain contains a numgen rule (load-balancing policy)
# ---------------------------------------------------------------------------
NUMGEN_LINE=$(nft list chain $TABLE "$CHAIN" 2>/dev/null | grep "numgen")
[ -n "$NUMGEN_LINE" ] \
|| die "policy $POLICY has no numgen rule - it is not a load-balancing policy (single active member or all members offline)"
# ---------------------------------------------------------------------------
# Pre-flight: read members and weights from UCI
# ---------------------------------------------------------------------------
config_load mwan3
collect_member() {
local member="$1"
local iface weight
config_get iface "$member" interface ""
config_get weight "$member" weight 1
[ -z "$iface" ] && return
MEMBERS="$MEMBERS $member"
MEMBER_IFACES="$MEMBER_IFACES $iface"
MEMBER_WEIGHTS="$MEMBER_WEIGHTS $weight"
}
config_list_foreach "$POLICY" use_member collect_member
MEMBERS="${MEMBERS# }"
MEMBER_IFACES="${MEMBER_IFACES# }"
MEMBER_WEIGHTS="${MEMBER_WEIGHTS# }"
[ -z "$MEMBERS" ] && die "policy $POLICY has no members in UCI config"
MEMBER_COUNT=$(echo "$MEMBER_WEIGHTS" | wc -w)
[ "$MEMBER_COUNT" -ge 2 ] || die "policy $POLICY has fewer than two members in UCI config"
# ---------------------------------------------------------------------------
# Pre-flight: parse member marks from live numgen vmap
#
# The vmap entries are in member order, matching UCI use_member list order.
# Each entry jumps to mwan3_or_meta_0xMARK; extract MARK from chain name.
# ---------------------------------------------------------------------------
MEMBER_MARKS=$(echo "$NUMGEN_LINE" \
| grep -oE 'mwan3_or_meta_0x[0-9a-f]+' \
| sed 's/mwan3_or_meta_//')
MARK_COUNT=$(echo "$MEMBER_MARKS" | wc -w)
# For mixed IPv4/IPv6 policies there may be two numgen lines; marks may repeat.
# Deduplicate while preserving order.
MEMBER_MARKS=$(echo "$MEMBER_MARKS" | tr ' ' '\n' | awk '!seen[$0]++' | tr '\n' ' ')
MEMBER_MARKS="${MEMBER_MARKS% }"
MARK_COUNT=$(echo "$MEMBER_MARKS" | wc -w)
[ "$MARK_COUNT" -lt 2 ] \
&& die "could not parse at least two member marks from numgen vmap"
[ "$MARK_COUNT" -ne "$MEMBER_COUNT" ] \
&& warn "mark count ($MARK_COUNT) differs from UCI member count ($MEMBER_COUNT) - mixed IPv4/IPv6 policy; per-member results may be approximate"
# Normalise marks to consistent hex format (no leading zeros beyond 0x prefix)
# so our inserted rule syntax and the awk extraction both use the same values.
MEMBER_MARKS_NORM=""
for mark in $MEMBER_MARKS; do
norm=$(printf "0x%x" "$((mark))")
MEMBER_MARKS_NORM="$MEMBER_MARKS_NORM $norm"
done
MEMBER_MARKS="${MEMBER_MARKS_NORM# }"
# ---------------------------------------------------------------------------
# Startup cleanup: remove stale mwan3_lb_test_* artifacts from aborted runs
# ---------------------------------------------------------------------------
for stale_set in $(nft list sets inet 2>/dev/null \
| awk '/mwan3_lb_test_/{gsub(/.*mwan3_lb_test_/,"mwan3_lb_test_"); gsub(/ \{.*/,""); print}'); do
stale_fwd=$(nft -a list chain $FORWARD_TABLE forward 2>/dev/null \
| awk "/@${stale_set}.*drop/{print \$NF; exit}")
[ -n "$stale_fwd" ] && \
nft delete rule $FORWARD_TABLE forward handle "$stale_fwd" 2>/dev/null
stale_out=$(nft -a list chain $TABLE mwan3_output 2>/dev/null \
| awk "/@${stale_set}.*return/{print \$NF; exit}")
[ -n "$stale_out" ] && \
nft delete rule $TABLE mwan3_output handle "$stale_out" 2>/dev/null
stale_handle=$(nft -a list chain $TABLE mwan3_rules 2>/dev/null \
| awk "/@${stale_set}/{print \$NF; exit}")
[ -n "$stale_handle" ] && \
nft delete rule $TABLE mwan3_rules handle "$stale_handle" 2>/dev/null
nft delete set $TABLE "$stale_set" 2>/dev/null
nft delete set $FORWARD_TABLE "$stale_set" 2>/dev/null
done
# ---------------------------------------------------------------------------
# Pre-flight: warn if other rules use this policy
# ---------------------------------------------------------------------------
OTHER_RULES=$(nft list chain $TABLE mwan3_rules 2>/dev/null \
| grep "jump ${CHAIN}" \
| grep -v "^[[:space:]]*#")
RULE_COUNT=$(echo "$OTHER_RULES" | grep -c "jump ${CHAIN}" 2>/dev/null)
if [ "$RULE_COUNT" -gt 1 ]; then
warn "$RULE_COUNT rules send traffic to policy $POLICY."
warn "flows from rules other than your test rule will also increment the counter."
warn "consider temporarily disabling other rules using this policy before testing."
echo
fi
# ---------------------------------------------------------------------------
# Compute N
#
# NITER = (total_weight / GCD(all_weights)) * multiplier
# where multiplier = ceil(30 / base_N), giving NITER >= 30.
# This ensures NITER * weight_i / total_weight is an integer for every member.
# ---------------------------------------------------------------------------
TOTAL_WEIGHT=0
for w in $MEMBER_WEIGHTS; do
TOTAL_WEIGHT=$((TOTAL_WEIGHT + w))
done
GCD_WEIGHTS=$(echo "$MEMBER_WEIGHTS" | tr ' ' '\n' | \
awk 'BEGIN{g=0} {
a=$1; b=g;
while(b!=0){t=b; b=a%b; a=t}
g=a
} END{print g}')
BASE_N=$((TOTAL_WEIGHT / GCD_WEIGHTS))
MULTIPLIER=$(( (30 + BASE_N - 1) / BASE_N ))
NITER=$((BASE_N * MULTIPLIER))
# ---------------------------------------------------------------------------
# Collect test destination IPs
#
# Use well-known public IPs that should be reachable from any WAN link.
# Exclude any IPs that mwan3track is already pinging: those pings go through
# mwan3_output -> mwan3_rules and would match the test rule, contaminating
# the counter with router-originated traffic.
# ---------------------------------------------------------------------------
if [ -n "$CUSTOM_IPS" ]; then
TRACK_IPS="$CUSTOM_IPS"
CUSTOM_IP_COUNT=$(echo "$TRACK_IPS" | wc -w)
[ "$CUSTOM_IP_COUNT" -lt 2 ] && die "at least two destination IPs are required (got $CUSTOM_IP_COUNT)"
else
# Collect all track_ip values configured across all mwan3 interfaces.
MWAN3_TRACKED_IPS=""
collect_iface_track_ips() {
local iface_track_ips
config_get iface_track_ips "$1" track_ip ""
MWAN3_TRACKED_IPS="$MWAN3_TRACKED_IPS $iface_track_ips"
}
config_foreach collect_iface_track_ips interface
# Default pools - exclude IPs that mwan3track is already pinging
# (those go through mwan3_output -> mwan3_rules and contaminate the counter).
if [ "$IPV6" -eq 1 ]; then
DEFAULT_POOL="2001:4860:4860::8888 2001:4860:4860::8844 2606:4700:4700::1111 2606:4700:4700::1001 2620:fe::fe 2620:fe::9 2620:119:35::35 2620:119:53::53"
else
DEFAULT_POOL="8.8.8.8 8.8.4.4 1.1.1.1 1.0.0.1 9.9.9.9 149.112.112.112 208.67.222.222 208.67.220.220 4.2.2.1 4.2.2.2 185.228.168.168 185.228.169.168"
fi
TRACK_IPS=""
for ip in $DEFAULT_POOL; do
case " $MWAN3_TRACKED_IPS " in
*" $ip "*) ;;
*) TRACK_IPS="$TRACK_IPS $ip" ;;
esac
done
TRACK_IPS="${TRACK_IPS# }"
[ -z "$TRACK_IPS" ] && die "all default destination IPs are configured as mwan3 tracking IPs - use the IP override argument to specify test destinations not being tracked"
fi
TRACK_COUNT=$(echo "$TRACK_IPS" | wc -w)
# Compute Windows cmd.exe delay between pings.
# Windows ping uses a fixed ICMP identifier (id=1) so repeated pings to the
# same destination reuse the same conntrack entry. The ct mark from the first
# ping is restored on the second, bypassing the test rule (mark != 0 guard).
# The full cycle through TRACK_COUNT IPs (each taking up to 2s for the ping
# plus WIN_DELAY seconds) must exceed the 30s ICMP conntrack timeout so the
# entry expires before we revisit each IP.
# Formula: WIN_DELAY such that TRACK_COUNT * (2 + WIN_DELAY) > 30.
# Using WIN_DELAY = 30/TRACK_COUNT + 3 gives comfortable margin.
WIN_DELAY=$(( 30 / TRACK_COUNT + 3 ))
# Build the rotated destination array for the generated command.
# Each iteration pings TRACK_IPS[i % TRACK_COUNT].
# Expand to exactly N destinations, rotating through the pool.
DEST_LIST=""
i=0
for seq in $(seq 1 "$NITER"); do
idx=$((i % TRACK_COUNT + 1))
dst=$(echo "$TRACK_IPS" | tr ' ' '\n' | sed -n "${idx}p")
DEST_LIST="$DEST_LIST $dst"
i=$((i + 1))
done
DEST_LIST="${DEST_LIST# }"
# ---------------------------------------------------------------------------
# Compute expected per-member hits
# ---------------------------------------------------------------------------
EXPECTED_LIST=""
for w in $MEMBER_WEIGHTS; do
expected=$((NITER * w / TOTAL_WEIGHT))
EXPECTED_LIST="$EXPECTED_LIST $expected"
done
EXPECTED_LIST="${EXPECTED_LIST# }"
# ---------------------------------------------------------------------------
# Insert temporary test rule into mwan3_rules
#
# Creates a temporary nft set containing the test destination IPs and inserts
# a rule at the head of mwan3_rules that directs matching traffic to the
# policy under test. This ensures the test pings hit the policy chain without
# requiring the user to add a permanent rule. Both the rule and the set are
# removed by cleanup().
# ---------------------------------------------------------------------------
echo "Adding temporary test set, isolation rules, and test rule..."
echo
if [ "$IPV6" -eq 1 ]; then
SET_TYPE="ipv6_addr"
PROTO_MATCH="meta l4proto ipv6-icmp ip6 daddr"
PING_CMD="ping6"
FORWARD_BLOCK="ip6 saddr != $CLIENT_IP ip6 daddr @$TEST_SET meta l4proto ipv6-icmp icmpv6 type echo-request"
OUTPUT_ISOLATION="meta l4proto ipv6-icmp icmpv6 type echo-request ip6 daddr @$TEST_SET"
else
SET_TYPE="ipv4_addr"
PROTO_MATCH="meta l4proto icmp ip daddr"
PING_CMD="ping"
FORWARD_BLOCK="ip saddr != $CLIENT_IP ip daddr @$TEST_SET meta l4proto icmp icmp type echo-request"
OUTPUT_ISOLATION="meta l4proto icmp icmp type echo-request ip daddr @$TEST_SET"
fi
# The rule matches ICMP/ICMPv6 only - not DNS, TCP, or other traffic to these
# IPs. This prevents contamination from DNS forwarders and rogue clients that
# bypass the local resolver, without needing to avoid popular DNS server IPs.
#
# nft sets are table-scoped. The mwan3_output and mwan3_rules rules live in
# table inet mwan3; the forward isolation rule lives in table inet fw4.
# Create the set in both tables and populate both with the same IPs.
nft add set $TABLE "$TEST_SET" { type "$SET_TYPE" \; } \
|| die "failed to create temporary destination set $TEST_SET in $TABLE"
nft add set $FORWARD_TABLE "$TEST_SET" { type "$SET_TYPE" \; } \
|| die "failed to create temporary destination set $TEST_SET in $FORWARD_TABLE"
for ip in $TRACK_IPS; do
nft add element $TABLE "$TEST_SET" { "$ip" } 2>/dev/null
nft add element $FORWARD_TABLE "$TEST_SET" { "$ip" } 2>/dev/null
done
# Insert forward isolation rule: drop pings to test IPs from all LAN clients
# except the nominated test client. Scoped to the test set so other LAN traffic
# is unaffected.
nft insert rule $FORWARD_TABLE forward $FORWARD_BLOCK drop \
|| die "failed to insert forward isolation rule"
FORWARD_BLOCK_HANDLE=$(nft -a list chain $FORWARD_TABLE forward 2>/dev/null \
| awk "/@${TEST_SET}.*drop/{print \$NF; exit}")
[ -n "$FORWARD_BLOCK_HANDLE" ] || die "could not read forward isolation rule handle"
# Insert output isolation rule: return (skip mwan3 marking) for any router
# process pinging test IPs. mwan3track IPs are already excluded from the test
# set, so this only affects unrelated processes. 'return' in a base chain is
# equivalent to accept - the pings still work, they just don't hit the policy
# chain.
nft insert rule $TABLE mwan3_output $OUTPUT_ISOLATION return \
|| die "failed to insert output isolation rule"
OUTPUT_ISOLATION_HANDLE=$(nft -a list chain $TABLE mwan3_output 2>/dev/null \
| awk "/@${TEST_SET}.*return/{print \$NF; exit}")
[ -n "$OUTPUT_ISOLATION_HANDLE" ] || die "could not read output isolation rule handle"
nft insert rule $TABLE mwan3_rules \
$PROTO_MATCH @"$TEST_SET" meta mark \& "$MMX_MASK" == 0 \
jump "mwan3_policy_${POLICY}" \
|| die "failed to insert temporary test rule into mwan3_rules"
TEST_RULE_HANDLE=$(nft -a list chain $TABLE mwan3_rules 2>/dev/null \
| awk "/@${TEST_SET}/{print \$NF; exit}")
[ -n "$TEST_RULE_HANDLE" ] || die "could not read temporary test rule handle"
# ---------------------------------------------------------------------------
# Print summary
# ---------------------------------------------------------------------------
echo "=== mwan3 load balancing distribution test ==="
echo
echo "Policy : $POLICY"
echo "MMX mask : $MMX_MASK"
echo "IP family : $([ "$IPV6" -eq 1 ] && echo IPv6 || echo IPv4)"
echo "Test iterations : $NITER (base=$BASE_N x${MULTIPLIER}, total_weight=$TOTAL_WEIGHT, GCD=$GCD_WEIGHTS)"
if [ -n "$CUSTOM_IPS" ]; then
echo "Destinations : $TRACK_IPS (user override)"
elif [ -n "$MWAN3_TRACKED_IPS" ]; then
echo "Destinations : $TRACK_IPS (well-known IPs, excluding mwan3 tracking IPs)"
else
echo "Destinations : $TRACK_IPS (well-known IPs)"
fi
echo
printf "%-20s %-10s %-10s %-16s %s\n" "Member" "Interface" "Weight" "Mark" "Expected hits"
printf "%-20s %-10s %-10s %-16s %s\n" "------" "---------" "------" "----" "-------------"
i=1
for member in $MEMBERS; do
iface=$(echo "$MEMBER_IFACES" | tr ' ' '\n' | sed -n "${i}p")
weight=$(echo "$MEMBER_WEIGHTS" | tr ' ' '\n' | sed -n "${i}p")
mark=$(echo "$MEMBER_MARKS" | tr ' ' '\n' | sed -n "${i}p")
expected=$(echo "$EXPECTED_LIST" | tr ' ' '\n' | sed -n "${i}p")
printf "%-20s %-10s %-10s %-16s %s\n" "$member" "$iface" "$weight" "${mark:--}" "${expected:--}"
i=$((i + 1))
done
echo
# ---------------------------------------------------------------------------
# Insert diagnostic rules
#
# Insertion order (each insert prepends, so insert in reverse of desired order):
# desired: [unguarded] [guarded] [numgen] [member counters...] [last_resort]
# insert: guarded first (becomes head), then unguarded (pushes guarded to #2)
# member counters inserted before last_resort handle using 'add rule position'
# ---------------------------------------------------------------------------
echo "Adding diagnostic rules..."
# Guarded counter (mark==0) - inserted at head
nft insert rule $TABLE "$CHAIN" meta mark \& "$MMX_MASK" == 0 counter \
|| die "failed to add guarded counter"
GUARDED_HANDLE=$(nft -a list chain $TABLE "$CHAIN" 2>/dev/null \
| awk '/meta mark.*== 0x.*counter/{print $NF; exit}')
[ -n "$GUARDED_HANDLE" ] || die "could not read guarded counter handle"
add_handle "$GUARDED_HANDLE"
# Unguarded counter - inserted at head (pushes guarded to position 2)
nft insert rule $TABLE "$CHAIN" counter \
|| die "failed to add unguarded counter"
UNGUARDED_HANDLE=$(nft -a list chain $TABLE "$CHAIN" 2>/dev/null \
| awk '/^\t\tcounter packets/{print $NF; exit}')
[ -n "$UNGUARDED_HANDLE" ] || die "could not read unguarded counter handle"
add_handle "$UNGUARDED_HANDLE"
# Per-member counters - inserted before last_resort (last rule in chain)
# Use 'nft add rule position <handle>' which inserts AFTER the given handle.
# We insert after the last numgen rule handle so counters sit between numgen
# and last_resort regardless of how many numgen lines the chain has.
NUMGEN_HANDLE=$(nft -a list chain $TABLE "$CHAIN" 2>/dev/null \
| awk '/numgen/{last=$NF} END{print last}')
[ -n "$NUMGEN_HANDLE" ] || die "could not read numgen rule handle"
PREV_HANDLE="$NUMGEN_HANDLE"
for mark in $MEMBER_MARKS; do
nft add rule $TABLE "$CHAIN" position "$PREV_HANDLE" \
meta mark \& "$MMX_MASK" == "$mark" counter \
|| die "failed to add per-member counter for mark $mark"
# Read handle of the rule just inserted (it follows PREV_HANDLE)
NEW_HANDLE=$(nft -a list chain $TABLE "$CHAIN" 2>/dev/null \
| awk -v prev="$PREV_HANDLE" '
/handle/{
if (found) { print $NF; exit }
if ($NF == prev) found=1
}')
[ -n "$NEW_HANDLE" ] || die "could not read per-member counter handle for mark $mark"
add_handle "$NEW_HANDLE"
PREV_HANDLE="$NEW_HANDLE"
done
echo "Done. Current chain state:"
nft list chain $TABLE "$CHAIN"
echo
# ---------------------------------------------------------------------------
# Print test command
# ---------------------------------------------------------------------------
echo "----------------------------------------------------------------------"
echo
echo "Run the following on a LAN client (or on the router itself to test"
echo "the output path):"
echo
# Print DEST_LIST wrapped at 4 IPs per line with \ continuation
printf " for dst in"
col=0
for dst in $DEST_LIST; do
if [ "$col" -eq 0 ]; then
printf " \\\\\n %s" "$dst"
else
printf " %s" "$dst"
fi
col=$(( (col + 1) % 4 ))
done
echo "; do"
echo " echo -n \"\$dst: \""
echo " $PING_CMD -c1 -W2 \"\$dst\" >/dev/null 2>&1 && echo ok || echo timeout"
echo " sleep 2"
echo " done"
echo
echo "If testing from a Windows client cmd.exe (uses fixed ICMP id - requires"
echo "longer inter-ping delay so conntrack entries expire between revisits):"
echo
if [ "$IPV6" -eq 1 ]; then
WIN_PING="ping -6 -n 1 -w 2000"
else
WIN_PING="ping -n 1 -w 2000"
fi
# Print Windows FOR loop with ^ continuation at 4 IPs per line
printf " for %%i in (^\n"
col=0
total_dsts=$(echo "$DEST_LIST" | wc -w)
cur=0
for dst in $DEST_LIST; do
cur=$((cur + 1))
if [ "$col" -eq 0 ]; then
printf " %s" "$dst"
else
printf " %s" "$dst"
fi
col=$(( (col + 1) % 4 ))
if [ "$col" -eq 0 ] && [ "$cur" -lt "$total_dsts" ]; then
printf " ^\n"
fi
done
printf ") do (%s %%i & timeout /t %d /nobreak >nul)\n" "$WIN_PING" "$WIN_DELAY"
echo
echo "Press Enter here when the test is complete..."
echo
echo "----------------------------------------------------------------------"
read _
# ---------------------------------------------------------------------------
# Read results
# ---------------------------------------------------------------------------
echo
echo "=== Results ==="
echo
CHAIN_DUMP=$(nft list chain $TABLE "$CHAIN" 2>/dev/null)
TOTAL=$(echo "$CHAIN_DUMP" \
| awk '/^\t\tcounter packets/{for(i=1;i<=NF;i++) if($i=="packets"){print $(i+1); exit}}')
NEW=$(echo "$CHAIN_DUMP" \
| awk '/meta mark.*== 0x.*counter/{for(i=1;i<=NF;i++) if($i=="packets"){print $(i+1); exit}}')
# Read per-member counts. The member counter rules match 'meta mark & X == MARK counter'.
# They appear after the numgen rule. Extract in mark order.
MEMBER_ACTUALS=""
for mark in $MEMBER_MARKS; do
count=$(echo "$CHAIN_DUMP" \
| awk -v mark="$mark" '
/meta mark.*== .*counter/{
for(i=1;i<=NF;i++){
if($i=="==" && strtonum($(i+1))==strtonum(mark) && strtonum(mark)!=0){
for(j=i;j<=NF;j++){
if($j=="packets"){print $(j+1); exit}
}
}
}
}')
MEMBER_ACTUALS="$MEMBER_ACTUALS ${count:-0}"
done
MEMBER_ACTUALS="${MEMBER_ACTUALS# }"
# ---------------------------------------------------------------------------
# Report
# ---------------------------------------------------------------------------
echo "--- Summary ---"
echo
printf "%-40s %s\n" "Completed:" "$(date '+%Y-%m-%d %H:%M:%S')"
printf "%-40s %s\n" "Total packets entering chain:" "${TOTAL:-unknown}"
printf "%-40s %s\n" "New-connection packets (mark=0):" "${NEW:-unknown}"
printf "%-40s %s\n" "Test iterations:" "$NITER"
echo
FW4_RELOADED=0
if [ -z "$TOTAL" ]; then
FW4_RELOADED=1
warn "Counter rules are absent from $CHAIN."
warn "fw4 reload likely occurred during the test, wiping all dynamic nft rules."
warn "Re-run the test and return before fw4 triggers (e.g. within a few minutes)."
echo
fi
CONTAMINATED=0
if [ -n "$TOTAL" ] && [ -n "$NEW" ]; then
if [ "$TOTAL" -gt "$NEW" ] 2>/dev/null; then
STALE=$((TOTAL - NEW))
warn "$STALE packets had mark != 0 when entering the chain."
warn "Established-connection packets should not reach this chain."
warn "Per-member counts below may be inflated - check for prerouting early-exit bugs."
echo
CONTAMINATED=1
fi
fi
echo "--- Per-member distribution ---"
echo
printf "%-20s %-10s %-10s %-10s %-10s\n" "Member" "Interface" "Expected" "Actual" "Deviation"
printf "%-20s %-10s %-10s %-10s %-10s\n" "------" "---------" "--------" "------" "---------"
PASS=1
i=1
for member in $MEMBERS; do
iface=$(echo "$MEMBER_IFACES" | tr ' ' '\n' | sed -n "${i}p")
expected=$(echo "$EXPECTED_LIST" | tr ' ' '\n' | sed -n "${i}p")
actual=$(echo "$MEMBER_ACTUALS" | tr ' ' '\n' | sed -n "${i}p")
[ -z "$actual" ] && actual=0
deviation=$((actual - expected))
dev_str="$deviation"
[ "$deviation" -gt 0 ] && dev_str="+$deviation"
abs_dev=$deviation
[ "$abs_dev" -lt 0 ] && abs_dev=$((-abs_dev))
[ "$abs_dev" -gt 2 ] && PASS=0
printf "%-20s %-10s %-10s %-10s %-10s\n" "$member" "$iface" "$expected" "$actual" "$dev_str"
i=$((i + 1))
done
echo
if [ -n "$NEW" ] && [ "$NEW" -ne "$NITER" ] 2>/dev/null; then
EXTRA=$((NEW - NITER))
if [ "$EXTRA" -gt 0 ]; then
echo "FAIL: $EXTRA extra new-connection hits beyond $NITER test iterations."
echo " Something other than your test flows is reaching this policy chain."
echo " Check whether another rule also sends traffic to this policy, or"
echo " whether a process on the router is generating connections to the test IPs."
PASS=0
else
echo "NOTE: Fewer hits than expected ($NEW/$NITER)."
echo " Some test flows may have timed out or been routed by a higher-priority rule."
fi
echo
fi
if [ "$FW4_RELOADED" -eq 1 ]; then
echo "FAIL: test invalidated - fw4 reload wiped counter rules during the wait."
echo " Re-run the test and do not leave it unattended for more than a few minutes."
elif [ "$PASS" -eq 1 ] && [ "$CONTAMINATED" -eq 0 ]; then
echo "PASS: distribution matches expected weights within tolerance (+/-2 per member)."
echo " Load balancing is working according to configured policy."
else
echo "FAIL: distribution is outside tolerance."
echo " Actual counts deviate from expected by more than 2 for at least one member."
fi
echo
echo "Cleaning up diagnostic rules..."
cleanup
echo "Chain restored."

View File

@ -0,0 +1,684 @@
#!/usr/bin/env ucode
'use strict';
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";
const RTM_NEWROUTE = rtnl.const.RTM_NEWROUTE;
const RTM_DELROUTE = rtnl.const.RTM_DELROUTE;
const RTM_GETROUTE = rtnl.const.RTM_GETROUTE;
const NLM_F_DUMP = rtnl.const.NLM_F_DUMP;
const NLM_F_CREATE = rtnl.const.NLM_F_CREATE;
const NLM_F_REPLACE = rtnl.const.NLM_F_REPLACE;
const RT_TABLE_MAIN = rtnl.const.RT_TABLE_MAIN;
const AF_INET = rtnl.const.AF_INET;
const AF_INET6 = rtnl.const.AF_INET6;
const RTNLGRP_IPV4_ROUTE = rtnl.const.RTNLGRP_IPV4_ROUTE;
const RTNLGRP_IPV6_ROUTE = rtnl.const.RTNLGRP_IPV6_ROUTE;
const MWAN3TRACK_STATUS_DIR = "/var/run/mwan3track";
const ROUTE_FIELDS = ["dst", "gateway", "oif", "prefsrc", "priority", "scope", "type", "tos", "metrics"];
const DEBOUNCE_MS = 100;
let family_name, family_num, rtnl_group;
let mmx_mask;
let source_routing;
let extra_table_set = {}; // { table_id: true } for O(1) lookup
let dev_table_map = {}; // { device_name: table_id }
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 ubus_conn;
// ---------------------------------------------------------------------------
// Logging
// ---------------------------------------------------------------------------
function log_msg(level, msg) {
warn(`mwan3rtmon[${family_name}] ${level}: ${msg}\n`);
}
// ---------------------------------------------------------------------------
// Config loading
// ---------------------------------------------------------------------------
function load_config() {
let cur = uci.cursor();
cur.load("mwan3");
// MMX mask
mmx_mask = cur.get("mwan3", "globals", "mmx_mask") ?? "0x3F00";
mmx_mask = +mmx_mask;
// Source routing option
source_routing = +(cur.get("mwan3", "globals", "source_routing") ?? "0");
// Extra lookup tables
extra_table_set = {};
let rt_tables = cur.get("mwan3", "globals", "rt_table_lookup");
if (type(rt_tables) == "array") {
for (let t in rt_tables)
extra_table_set[+t] = true;
} else if (rt_tables != null) {
extra_table_set[+rt_tables] = true;
}
// Build interface list with table IDs
iface_list = [];
let tid = 0;
cur.foreach("mwan3", "interface", function(s) {
tid++;
let ifname = s[".name"];
let fam = s.family ?? "ipv4";
let enabled = +(s.enabled ?? "0");
let track_ip = s.track_ip;
let track_gateway = +(s.track_gateway ?? "0");
let has_tracking = track_gateway ||
(track_ip != null &&
!(type(track_ip) == "array" && length(track_ip) == 0));
push(iface_list, {
name: ifname,
family: fam,
tid: tid,
enabled: enabled,
has_tracking: has_tracking,
});
});
cur.unload("mwan3");
}
// ---------------------------------------------------------------------------
// Device -> table ID mapping (via ubus)
// ---------------------------------------------------------------------------
function build_dev_table_map() {
dev_table_map = {};
if (!ubus_conn)
return;
let dump = ubus_conn.call("network.interface", "dump");
if (!dump || !dump.interface)
return;
// Build name->tid lookup for enabled interfaces in this family
let name_tid = {};
for (let iface in iface_list) {
if (iface.enabled && iface.family == family_name)
name_tid[iface.name] = iface.tid;
}
for (let intf in dump.interface) {
let tid = name_tid[intf.interface];
if (tid != null && intf.l3_device)
dev_table_map[intf.l3_device] = tid;
}
}
// ---------------------------------------------------------------------------
// Cached nft chain existence check
// ---------------------------------------------------------------------------
function refresh_active_chains() {
active_chains = {};
let p = popen("nft -j list chains inet 2>/dev/null");
if (!p)
return;
let out = p.read("all");
p.close();
if (!out)
return;
let data;
try { data = json(out); } catch(e) { return; }
if (!data || !data.nftables)
return;
for (let item in data.nftables) {
if (item.chain && item.chain.table == "mwan3")
active_chains[item.chain.name] = true;
}
}
function is_iface_nft_active(ifname) {
return !!active_chains["mwan3_iface_in_" + ifname];
}
function get_active_tids() {
let tids = {};
for (let iface in iface_list) {
if (iface.family != family_name)
continue;
if (is_iface_nft_active(iface.name))
tids[iface.tid] = iface.name;
}
return tids;
}
// ---------------------------------------------------------------------------
// Check mwan3track status for an interface
// ---------------------------------------------------------------------------
function get_track_status(ifname) {
let iface = null;
for (let i in iface_list) {
if (i.name == ifname) { iface = i; break; }
}
if (!iface || !iface.has_tracking)
return "disabled";
let pid = trim(readfile(MWAN3TRACK_STATUS_DIR + "/" + ifname + "/PID") ?? "");
if (pid == "")
return "down";
let cmdline = readfile("/proc/" + pid + "/cmdline");
if (cmdline == null)
return "down";
let started = trim(readfile(MWAN3TRACK_STATUS_DIR + "/" + ifname + "/STARTED") ?? "");
if (started == "1")
return "active";
else if (started == "0")
return "paused";
return "down";
}
// ---------------------------------------------------------------------------
// nft helpers
// ---------------------------------------------------------------------------
function nft_exec(cmd) {
let p = popen("nft " + cmd + " 2>&1");
if (!p)
return false;
let out = p.read("all");
let rc = p.close();
if (rc != 0) {
log_msg("warn", "nft failed: nft " + cmd + " - " + (out ?? ""));
return false;
}
return true;
}
function nft_batch(cmds) {
let tmpfile = "/var/run/mwan3/mwan3rtmon_nft_batch";
let f = open(tmpfile, "w", 0600);
if (!f) {
log_msg("warn", "cannot open nft batch file");
return false;
}
for (let cmd in cmds)
f.write(cmd + "\n");
f.close();
let p = popen("nft -f " + tmpfile + " 2>&1");
if (!p)
return false;
let out = p.read("all");
let rc = p.close();
if (rc != 0) {
log_msg("warn", "nft batch failed: " + (out ?? ""));
return false;
}
return true;
}
// ---------------------------------------------------------------------------
// Route helpers
// ---------------------------------------------------------------------------
// Build a route object for adding to a per-interface table.
// Copies only the relevant fields, sets the target table.
// Strips src if source_routing is disabled.
function build_route_for_table(route, tid) {
let r = { family: route.family, table: tid };
for (let f in ROUTE_FIELDS)
if (route[f] != null)
r[f] = route[f];
// tos=0 is default, omit it
if (r.tos == 0)
delete r.tos;
// Strip src (source-based routing prefix) unless source_routing is enabled
if (source_routing && route.src != null)
r.src = route.src;
return r;
}
// Apply a route add or delete to a per-interface table
function apply_route_to_table(route, tid, is_new) {
let r = build_route_for_table(route, tid);
if (is_new) {
rtnl.request(RTM_NEWROUTE, NLM_F_CREATE | NLM_F_REPLACE, r);
let err = rtnl.error();
if (err)
log_msg("warn", "failed to add route to table " +
tid + ": " + err);
} else {
rtnl.request(RTM_DELROUTE, 0, r);
}
}
// Check if a route is a CIDR (has a prefix length < max for address family)
function is_cidr_route(route) {
let dst = route.dst;
if (dst == null)
return false;
let slash = index(dst, "/");
if (slash < 0)
return false;
let prefix_len = +substr(dst, slash + 1);
if (family_num == AF_INET)
return (prefix_len < 32);
else
return (prefix_len < 128);
}
// Check if a route is a default route
function is_default_route(route) {
return (route.dst == null ||
route.dst == "0.0.0.0/0" ||
route.dst == "::/0");
}
// Check if route is link-local (fe80::/10 IPv6 or 169.254.0.0/16 IPv4)
function is_linklocal_route(route) {
return (route.dst != null &&
(match(route.dst, /^fe80::\//) != null ||
match(route.dst, /^169\.254\./) != null));
}
// Get all routes from main table (and extra lookup tables) via netlink
function get_all_routes() {
let routes = rtnl.request(RTM_GETROUTE, NLM_F_DUMP,
{ family: family_num }) ?? [];
// Filter to main table + extra lookup tables (O(1) set lookup)
let result = [];
for (let r in routes) {
if (r.table == RT_TABLE_MAIN || extra_table_set[r.table])
push(result, r);
}
return result;
}
// Check if a route still exists in the main table (for delete event verification)
function route_still_exists(route) {
let routes = rtnl.request(RTM_GETROUTE, NLM_F_DUMP,
{ family: family_num }) ?? [];
for (let r in routes) {
if (r.table != RT_TABLE_MAIN)
continue;
if (r.dst == route.dst && r.oif == route.oif &&
r.gateway == route.gateway && r.priority == route.priority)
return true;
}
return false;
}
// ---------------------------------------------------------------------------
// Populate connected set (nft)
// ---------------------------------------------------------------------------
function populate_connected_set() {
let routes = get_all_routes();
let setname = (family_num == AF_INET)
? "mwan3_connected_v4" : "mwan3_connected_v6";
let seen = {};
let elements = [];
for (let route in routes) {
if (is_cidr_route(route) && !is_default_route(route) && !is_linklocal_route(route) && !seen[route.dst]) {
push(elements, route.dst);
seen[route.dst] = true;
}
}
if (family_num == AF_INET)
push(elements, "224.0.0.0/3");
sort(elements);
let fingerprint = join(",", elements);
if (fingerprint == last_connected_fingerprint)
return;
last_connected_fingerprint = fingerprint;
let cmds = [ "flush set inet mwan3 " + setname ];
if (length(elements) > 0)
push(cmds, "add element inet mwan3 " + setname +
" { " + join(", ", elements) + " }");
nft_batch(cmds);
}
// ---------------------------------------------------------------------------
// Populate per-interface routing tables
// ---------------------------------------------------------------------------
function populate_iface_routes() {
build_dev_table_map();
refresh_active_chains();
let routes = get_all_routes();
let active_tids = get_active_tids();
if (length(keys(active_tids)) == 0)
return;
for (let route in routes) {
let dev = route.oif;
let target_tid = (dev != null) ? dev_table_map[dev] : null;
if (target_tid != null) {
// Route has a device that maps to a specific interface table
if (active_tids["" + target_tid] != null) {
let r = build_route_for_table(route, target_tid);
rtnl.request(RTM_NEWROUTE, NLM_F_CREATE | NLM_F_REPLACE, r);
}
} else if (!is_default_route(route) && !is_linklocal_route(route)) {
// Broadcast to all active interface tables
for (let tid_str in keys(active_tids)) {
let r = build_route_for_table(route, +tid_str);
rtnl.request(RTM_NEWROUTE, NLM_F_CREATE | NLM_F_REPLACE, r);
}
}
}
}
// ---------------------------------------------------------------------------
// Repopulate mwan3_custom set from extra_table_set (used on SIGHUP reload)
// ---------------------------------------------------------------------------
function repopulate_custom_sets() {
let setname = (family_num == AF_INET) ? "mwan3_custom_v4" : "mwan3_custom_v6";
if (length(keys(extra_table_set)) == 0) {
nft_exec("flush set inet mwan3 " + setname);
return;
}
let routes = rtnl.request(RTM_GETROUTE, NLM_F_DUMP, { family: family_num }) ?? [];
let cmds = [ "flush set inet mwan3 " + setname ];
let seen = {};
for (let r in routes) {
if (!extra_table_set[r.table])
continue;
let dst = r.dst;
if (dst == null || is_default_route(r) || is_linklocal_route(r))
continue;
if (!seen[dst]) {
push(cmds, "add element inet mwan3 " + setname + " { " + dst + " }");
seen[dst] = true;
}
}
nft_batch(cmds);
}
// ---------------------------------------------------------------------------
// Handle rt_table_lookup route events -> mwan3_custom set
// ---------------------------------------------------------------------------
// Called when a route is added to or removed from a table listed in
// rt_table_lookup. Mirrors the change into mwan3_custom_v4/v6 so that
// destinations reachable via those tables receive MMX_DEFAULT and bypass
// mwan3's WAN policy selection. Default and link-local routes are skipped.
function handle_custom_set_event(route, is_new) {
let dst = route.dst;
if (dst == null || is_default_route(route) || is_linklocal_route(route))
return;
let setname = (family_num == AF_INET) ? "mwan3_custom_v4" : "mwan3_custom_v6";
if (is_new) {
log_msg("debug", "rt_table_lookup: adding " + dst + " to " + setname);
nft_exec("add element inet mwan3 " + setname + " { " + dst + " }");
} else {
log_msg("debug", "rt_table_lookup: removing " + dst + " from " + setname);
nft_exec("delete element inet mwan3 " + setname + " { " + dst + " }");
}
}
// ---------------------------------------------------------------------------
// Handle route events
// ---------------------------------------------------------------------------
function handle_route_event(event) {
let cmd = event.cmd;
let route = event.msg;
// Routes in rt_table_lookup tables are mirrored into mwan3_custom_v4/v6.
if (extra_table_set[route.table]) {
handle_custom_set_event(route, cmd == RTM_NEWROUTE);
return;
}
// Only handle main table routes
if (route.table != RT_TABLE_MAIN)
return;
let is_new = (cmd == RTM_NEWROUTE);
log_msg("debug", (is_new ? "new" : "del") + " route: dst=" +
(route.dst ?? "default") + " dev=" + (route.oif ?? "none") +
" gw=" + (route.gateway ?? "none"));
// -- Update connected set --
// Both adds and deletes are debounced through populate_connected_set so
// that bursts of RTM_NEWROUTE updates for already-present prefixes (common
// with IPv6 PD address renewal) do not spawn a rapid stream of nft calls.
// The fingerprint in populate_connected_set makes repeated identical calls
// cheap when nothing has actually changed.
if (is_cidr_route(route)) {
if (!connected_timer)
connected_timer = uloop.timer(DEBOUNCE_MS, () => {
populate_connected_set();
connected_timer = null;
});
else
connected_timer.set(DEBOUNCE_MS);
}
// -- Update per-interface routing tables --
build_dev_table_map();
refresh_active_chains();
// For delete events, verify the route is actually gone
if (!is_new && route_still_exists(route)) {
log_msg("debug", "deleted but route still exists: " +
(route.dst ?? "default"));
return;
}
let active_tids = get_active_tids();
let dev = route.oif;
let target_tid = (dev != null) ? dev_table_map[dev] : null;
if (target_tid != null) {
// Device-matched route: add to specific interface table
// (no track status check, matching shell behavior)
if (active_tids["" + target_tid] == null)
return;
apply_route_to_table(route, target_tid, is_new);
} else if (!is_default_route(route) &&
!is_linklocal_route(route)) {
// Broadcast to all active interface tables
for (let tid_str in keys(active_tids)) {
let tid = +tid_str;
let ifname = active_tids[tid_str];
let status = get_track_status(ifname);
if (status != "active" && status != "disabled") {
log_msg("debug", "interface " + ifname +
" is " + status + " - skipping");
continue;
}
apply_route_to_table(route, tid, is_new);
}
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main() {
// Parse args
family_name = ARGV[0] ?? "ipv4";
if (family_name == "ipv6") {
if (stat("/proc/sys/net/ipv6") == null) {
log_msg("warn",
"started for ipv6, but ipv6 not enabled on system");
exit(1);
}
family_num = AF_INET6;
rtnl_group = RTNLGRP_IPV6_ROUTE;
} else {
family_name = "ipv4";
family_num = AF_INET;
rtnl_group = RTNLGRP_IPV4_ROUTE;
}
load_config();
// Connect to ubus
ubus_conn = ubus.connect();
if (!ubus_conn) {
log_msg("warn", "failed to connect to ubus");
exit(1);
}
uloop.init();
// Bind the route listener first so its multicast socket is bound
// to the route group before any initial dumps run. Route changes
// emitted during the dump phase are then queued on the listener
// socket and processed when uloop.run() begins, rather than being
// lost to a subscribe-vs-dump race.
log_msg("debug", "creating route listener for " + family_name);
let route_listener = rtnl.listener(
handle_route_event,
[ RTM_NEWROUTE, RTM_DELROUTE ],
[ rtnl_group ]
);
if (!route_listener) {
log_msg("warn", "failed to create route listener: " +
rtnl.error());
exit(1);
}
// Initial population, dump-driven. With the listener already
// bound, any concurrent route changes will be delivered as deltas
// once uloop.run() begins.
log_msg("debug", "populating initial custom sets");
repopulate_custom_sets();
log_msg("debug", "populating initial connected set");
populate_connected_set();
log_msg("debug", "populating initial per-interface routes");
populate_iface_routes();
// Signal handlers for clean shutdown
uloop.signal("SIGTERM", () => {
log_msg("debug", "received SIGTERM, exiting");
uloop.end();
});
uloop.signal("SIGINT", () => {
log_msg("debug", "received SIGINT, exiting");
uloop.end();
});
uloop.signal("SIGHUP", () => {
log_msg("debug", "received SIGHUP, reloading config");
load_config();
repopulate_custom_sets();
});
log_msg("debug", "entering event loop");
uloop.run();
// Do not call route_listener.close() — ucode-mod-rtnl bug: explicit close
// zeroes the resource data pointer while the variable still holds a
// reference; the destructor is then called again with NULL on scope exit,
// causing a segfault. Let the GC collect the listener naturally instead.
log_msg("debug", "exiting cleanly");
}
main();

View File

@ -0,0 +1,592 @@
#!/bin/sh
. /lib/functions.sh
. /lib/functions/network.sh
. /lib/mwan3/common.sh
INTERFACE=""
DEVICE=""
IFDOWN_EVENT=0
IFUP_EVENT=0
RELOAD_EVENT=0
WRAP_LIB=/lib/mwan3/libwrap_mwan3_sockopt.so.1.0
stop_subprocs() {
local killpids="$(jobs -p)"
[ -n "$killpids" ] && kill $killpids
}
WRAP() {
# shellcheck disable=SC2048
FAMILY=$FAMILY DEVICE=$DEVICE SRCIP=$SRC_IP FWMARK=$MMX_DEFAULT LD_PRELOAD=$WRAP_LIB $*
}
clean_up() {
LOG notice "Stopping mwan3track for interface \"${INTERFACE}\". Status was \"${STATUS}\""
stop_subprocs
exit 0
}
if_down() {
LOG info "Detect ifdown event on interface ${INTERFACE} (${DEVICE})"
IFDOWN_EVENT=1
stop_subprocs
}
if_up() {
LOG info "Detect ifup event on interface ${INTERFACE} (${DEVICE})"
IFDOWN_EVENT=0
IFUP_EVENT=1
started
stop_subprocs
}
config_reload() {
LOG info "Received configuration reload signal for interface ${INTERFACE}"
RELOAD_EVENT=1
stop_subprocs
}
ping_test_host() {
if [ "$FAMILY" = "ipv6" ]; then
echo "::1"
else
echo "127.0.0.1"
fi
}
get_ping_command() {
if [ -x "/usr/bin/ping" ] && /usr/bin/ping -${FAMILY#ipv} -c1 -q $(ping_test_host) &>/dev/null; then
# -4 option added in iputils c3e68ac6 so need to check if we can use it
# or if we must use ping and ping6
echo "/usr/bin/ping -${FAMILY#ipv}"
elif [ "$FAMILY" = "ipv6" ] && [ -x "/usr/bin/ping6" ]; then
echo "/usr/bin/ping6"
elif [ "$FAMILY" = "ipv4" ] && [ -x "/usr/bin/ping" ]; then
echo "/usr/bin/ping"
elif [ -x "/bin/ping" ]; then
echo "/bin/ping -${FAMILY#ipv}"
else
return 1
fi
}
validate_track_method() {
case "$1" in
ping)
PING=$(get_ping_command)
if [ $? -ne 0 ]; then
LOG warn "Missing ping. Please enable BUSYBOX_DEFAULT_PING and recompile busybox or install iputils-ping package."
return 1
fi
;;
arping)
command -v arping 1>/dev/null 2>&1 || {
LOG warn "Missing arping. Please install iputils-arping package."
return 1
}
;;
httping)
command -v httping 1>/dev/null 2>&1 || {
LOG warn "Missing httping. Please install httping package."
return 1
}
;;
nping-*)
command -v nping 1>/dev/null 2>&1 || {
LOG warn "Missing nping. Please install nping package."
return 1
}
;;
nslookup)
command -v nslookup 1>/dev/null 2>&1 || {
LOG warn "Missing nslookup. Please install busybox package."
return 1
}
;;
*)
LOG warn "Unsupported tracking method: $track_method"
return 2
;;
esac
}
validate_wrap() {
[ -f "$WRAP_LIB" ] && return
LOG error "Missing libwrap_mwan3_sockopt. Please reinstall mwan3." && exit 1
}
disconnected() {
local status
readfile status ${MWAN3TRACK_STATUS_DIR}/${INTERFACE}/STATUS
STATUS='offline'
echo "offline" > $MWAN3TRACK_STATUS_DIR/$INTERFACE/STATUS
get_uptime > $MWAN3TRACK_STATUS_DIR/$INTERFACE/OFFLINE
echo "0" > $MWAN3TRACK_STATUS_DIR/$INTERFACE/ONLINE
score=0
[ "$1" = 1 ] && return
# Only execute disconnectd action if status was online or disconnecting
if [ "$status" = "online" ] || [ "$status" = "disconnecting" ]; then
LOG notice "Interface $INTERFACE ($DEVICE) is offline"
env -i ACTION="disconnected" INTERFACE="$INTERFACE" DEVICE="$DEVICE" /sbin/hotplug-call iface
else
LOG debug "Skip disconnected event for $INTERFACE ($DEVICE)"
fi
}
connected() {
STATUS='online'
echo "online" > $MWAN3TRACK_STATUS_DIR/$INTERFACE/STATUS
echo "0" > $MWAN3TRACK_STATUS_DIR/$INTERFACE/OFFLINE
get_uptime > $MWAN3TRACK_STATUS_DIR/$INTERFACE/ONLINE
score=$((down+up))
host_up_count=0
lost=0
turn=0
loss=0
LOG notice "Interface $INTERFACE ($DEVICE) is online"
env -i FIRSTCONNECT=$1 ACTION="connected" INTERFACE="$INTERFACE" DEVICE="$DEVICE" /sbin/hotplug-call iface
}
disconnecting() {
if [ "$STATUS" != "disconnecting" ] ; then
STATUS="disconnecting"
echo "disconnecting" > $MWAN3TRACK_STATUS_DIR/$INTERFACE/STATUS
LOG notice "Interface $INTERFACE ($DEVICE) is disconnecting"
env -i ACTION="disconnecting" INTERFACE="$INTERFACE" DEVICE="$DEVICE" /sbin/hotplug-call iface
fi
}
connecting() {
if [ "$STATUS" != "connecting" ] ; then
STATUS="connecting"
echo "connecting" > $MWAN3TRACK_STATUS_DIR/$INTERFACE/STATUS
LOG notice "Interface $INTERFACE ($DEVICE) is connecting"
env -i ACTION="connecting" INTERFACE="$INTERFACE" DEVICE="$DEVICE" /sbin/hotplug-call iface
fi
}
disabled() {
STATUS='disabled'
echo "disabled" > $MWAN3TRACK_STATUS_DIR/$INTERFACE/STATUS
stopped
}
firstconnect() {
local true_iface
network_flush_cache
mwan3_get_true_iface true_iface $INTERFACE
network_get_device DEVICE $true_iface
if [ "$STATUS" != "online" ]; then
config_get STATUS $INTERFACE initial_state "online"
fi
if ! network_is_up $true_iface || [ -z "$DEVICE" ]; then
disabled
return
fi
mwan3_get_src_ip SRC_IP $INTERFACE
LOG debug "firstconnect: called on $INTERFACE/$true_iface ($DEVICE). Status is $STATUS. SRC_IP is $SRC_IP"
started
if [ "$STATUS" = "offline" ]; then
disconnected 1
else
connected 1
fi
}
stopped() {
STARTED=0
echo 0 > $MWAN3TRACK_STATUS_DIR/$INTERFACE/STARTED
}
started() {
STARTED=1
echo 1 > $MWAN3TRACK_STATUS_DIR/$INTERFACE/STARTED
}
update_status() {
local track_ip=$1
echo "$2" > $MWAN3TRACK_STATUS_DIR/$INTERFACE/TRACK_${track_ip}
[ -z "$3" ] && return
echo "$3" > $MWAN3TRACK_STATUS_DIR/$INTERFACE/LATENCY_${track_ip}
echo "$4" > $MWAN3TRACK_STATUS_DIR/$INTERFACE/LOSS_${track_ip}
}
load_tracking_config() {
config_get_bool MWAN3_VERBOSE_LOGGING globals verbose_logging 0
config_get FAMILY $INTERFACE family ipv4
config_get track_method $INTERFACE track_method ping
config_get_bool httping_ssl $INTERFACE httping_ssl 0
validate_track_method $track_method || {
track_method=ping
if validate_track_method $track_method; then
LOG warn "Using ping to track interface $INTERFACE avaliability"
else
LOG err "No track method avaliable"
exit 1
fi
}
config_get reliability $INTERFACE reliability 1
config_get count $INTERFACE count 1
config_get timeout $INTERFACE timeout 4
config_get interval $INTERFACE interval 10
config_get down $INTERFACE down 5
config_get up $INTERFACE up 5
config_get size $INTERFACE size 56
config_get max_ttl $INTERFACE max_ttl 60
config_get failure_interval $INTERFACE failure_interval $interval
config_get_bool keep_failure_interval $INTERFACE keep_failure_interval 0
config_get recovery_interval $INTERFACE recovery_interval $interval
config_get_bool check_quality $INTERFACE check_quality 0
config_get failure_latency $INTERFACE failure_latency 1000
config_get recovery_latency $INTERFACE recovery_latency 500
config_get failure_loss $INTERFACE failure_loss 40
config_get recovery_loss $INTERFACE recovery_loss 10
# arping/nslookup/nping don't produce latency/loss samples;
# check_quality would trigger arithmetic errors on the empty values.
case "$track_method" in
ping|httping) ;;
*)
if [ "$check_quality" -eq 1 ]; then
LOG notice "check_quality is not supported for track_method '$track_method' on interface $INTERFACE; treating as disabled"
check_quality=0
fi
;;
esac
}
main() {
local reliability count timeout interval failure_interval
local recovery_interval down up size
local keep_failure_interval check_quality failure_latency
local recovery_latency failure_loss recovery_loss
local max_ttl httping_ssl track_ips do_log
INTERFACE=$1
STATUS=""
mkdir -p $MWAN3TRACK_STATUS_DIR/$INTERFACE
exec 9>"$MWAN3TRACK_STATUS_DIR/$INTERFACE/LOCK"
if ! flock -n 9; then
local old_pid
old_pid=$(cat $MWAN3TRACK_STATUS_DIR/$INTERFACE/PID 2>/dev/null)
if [ -n "$old_pid" ] && [ "$old_pid" != "$$" ] && kill -0 "$old_pid" 2>/dev/null; then
LOG notice "Terminating stale mwan3track instance (PID $old_pid) for interface $INTERFACE"
kill "$old_pid" 2>/dev/null
fi
flock 9
fi
echo $$ > $MWAN3TRACK_STATUS_DIR/$INTERFACE/PID
stopped
PING_OUTPUT=$MWAN3TRACK_STATUS_DIR/$INTERFACE/PING_OUTPUT
mwan3_init
validate_wrap
trap clean_up TERM
trap if_down USR1
trap if_up USR2
trap config_reload HUP
load_tracking_config
local sleep_time result ping_status loss latency
mwan3_list_track_ips()
{
track_ips="$track_ips $1"
}
mwan3_load_track_ips()
{
local track_gateway gw_ip f ip
track_ips=""
config_list_foreach "$INTERFACE" track_ip mwan3_list_track_ips
config_get_bool track_gateway "$INTERFACE" track_gateway 0
if [ "$track_gateway" -eq 1 ]; then
gw_ip=$(cat "$MWAN3TRACK_STATUS_DIR/${INTERFACE}/GATEWAY" 2>/dev/null)
if [ -n "$gw_ip" ]; then
case " $track_ips " in
*" $gw_ip "*) ;;
*) track_ips="$gw_ip $track_ips" ;;
esac
fi
fi
for f in "$MWAN3TRACK_STATUS_DIR/$INTERFACE/TRACK_"*; do
[ -f "$f" ] || continue
ip="${f##*/TRACK_}"
[ "$ip" = "OUTPUT" ] && continue
case " $track_ips " in
*" $ip "*) ;;
*) rm -f "$MWAN3TRACK_STATUS_DIR/$INTERFACE/TRACK_${ip}" \
"$MWAN3TRACK_STATUS_DIR/$INTERFACE/LATENCY_${ip}" \
"$MWAN3TRACK_STATUS_DIR/$INTERFACE/LOSS_${ip}" ;;
esac
done
local current_check_quality
config_get_bool current_check_quality "$INTERFACE" check_quality 0
# Mirror the clamp applied at startup so LATENCY_/LOSS_ files
# are cleaned up when the effective check_quality is disabled
# by virtue of an incompatible track_method.
case "$track_method" in
ping|httping) ;;
*) current_check_quality=0 ;;
esac
if [ "$current_check_quality" -eq 0 ]; then
rm -f $MWAN3TRACK_STATUS_DIR/$INTERFACE/LATENCY_* \
$MWAN3TRACK_STATUS_DIR/$INTERFACE/LOSS_* 2>/dev/null
fi
}
mwan3_load_track_ips
local score=$((down+up))
local warn_threshold=$((up + (down + 2) / 3))
local host_up_count=0
local lost=0
local turn=0
local failed_hosts=""
firstconnect
while true; do
[ $STARTED -eq 0 ] && {
sleep $MAX_SLEEP &
wait $!
}
# Process any pending interface events before starting a ping round.
# This avoids pinging with stale DEVICE/SRC_IP after wakeup from
# disabled state — a USR2 signal sets IFUP_EVENT but DEVICE is not
# refreshed until firstconnect() runs below.
if [ "${IFDOWN_EVENT}" -eq 1 ]; then
LOG debug "Register ifdown event on interface ${INTERFACE} (${DEVICE})"
disconnected
disabled
IFDOWN_EVENT=0
continue
fi
if [ "${IFUP_EVENT}" -eq 1 ]; then
LOG debug "Register ifup event on interface ${INTERFACE} (${DEVICE})"
config_load mwan3
mwan3_load_track_ips
firstconnect
IFUP_EVENT=0
continue
fi
if [ "${RELOAD_EVENT}" -eq 1 ]; then
LOG debug "Processing configuration reload for interface ${INTERFACE}"
config_load mwan3
load_tracking_config
mwan3_load_track_ips
if [ $score -gt $((down + up)) ]; then
score=$((down + up))
fi
warn_threshold=$((up + (down + 2) / 3))
RELOAD_EVENT=0
continue
fi
sleep_time=$interval
for track_ip in $track_ips; do
if [ $host_up_count -lt $reliability ]; then
case "$track_method" in
ping)
if [ $check_quality -eq 0 ]; then
WRAP $PING -n -c $count -W $timeout -s $size -t $max_ttl -q $track_ip &> /dev/null &
wait $!
result=$?
else
WRAP $PING -n -c $count -W $timeout -s $size -t $max_ttl -q $track_ip 2>/dev/null > $PING_OUTPUT &
wait $!
ping_status=$?
loss="$(sed $PING_OUTPUT -ne 's/.* \([0-9]\+\)% packet loss.*/\1/p')"
if [ "$ping_status" -ne 0 ] || [ "$loss" -eq 100 ]; then
latency=999999
loss=100
else
latency="$(sed $PING_OUTPUT -ne 's%\(rtt\|round-trip\).* = [^/]*/\([0-9]\+\).*%\2%p')"
fi
fi
;;
arping)
WRAP arping -I $DEVICE -c $count -w $timeout -q $track_ip &> /dev/null &
wait $!
result=$?
;;
httping)
if [ $check_quality -eq 0 ]; then
if [ "$httping_ssl" -eq 1 ]; then
WRAP httping -c $count -t $timeout -q "https://$track_ip" &> /dev/null &
else
WRAP httping -c $count -t $timeout -q "http://$track_ip" &> /dev/null &
fi
wait $!
result=$?
else
if [ "$httping_ssl" -eq 1 ]; then
WRAP httping -c $count -t $timeout "https://$track_ip" 2> /dev/null > $PING_OUTPUT &
else
WRAP httping -c $count -t $timeout "http://$track_ip" 2> /dev/null > $PING_OUTPUT &
fi
wait $!
ping_status=$?
loss="$(sed $PING_OUTPUT -ne 's/.* \([0-9]\+\).*% failed.*/\1/p')"
if [ "$ping_status" -ne 0 ] || [ "$loss" -eq 100 ]; then
latency=999999
loss=100
else
latency="$(sed $PING_OUTPUT -ne 's%\(rtt\|round-trip\).* = [^/]*/\([0-9]\+\).*%\2%p')"
fi
fi
;;
nping-*)
WRAP nping -${FAMILY#ipv} -c $count $track_ip --${track_method#nping-} > $PING_OUTPUT &
wait $!
result=$(grep Lost $PING_OUTPUT | awk '{print $12}')
result=${result:-1}
;;
nslookup)
WRAP nslookup www.google.com $track_ip > $PING_OUTPUT &
wait $!
result=$?
;;
esac
if [ $check_quality -eq 0 ]; then
if [ $result -eq 0 ]; then
let host_up_count++
update_status "$track_ip" "up"
[ $score -le $up ] && LOG debug "Check ($track_method) success for target \"$track_ip\" on interface $INTERFACE ($DEVICE). Current score: $score"
else
let lost++
update_status "$track_ip" "down"
failed_hosts="${failed_hosts:+$failed_hosts }$track_ip"
fi
else
if [ "$loss" -ge "$failure_loss" ] || [ "$latency" -ge "$failure_latency" ]; then
let lost++
update_status "$track_ip" "down" $latency $loss
failed_hosts="${failed_hosts:+$failed_hosts }$track_ip(${latency}ms/${loss}%)"
elif [ "$loss" -le "$recovery_loss" ] && [ "$latency" -le "$recovery_latency" ]; then
let host_up_count++
update_status "$track_ip" "up" $latency $loss
[ $score -le $up ] && LOG debug "Check (${track_method}: latency=${latency}ms loss=${loss}%) success for target \"$track_ip\" on interface $INTERFACE ($DEVICE). Current score: $score"
else
echo "skipped" > $MWAN3TRACK_STATUS_DIR/$INTERFACE/TRACK_${track_ip}
fi
fi
else
echo "skipped" > $MWAN3TRACK_STATUS_DIR/$INTERFACE/TRACK_${track_ip}
fi
if [ "${IFDOWN_EVENT}" -eq 1 ] || [ "${IFUP_EVENT}" -eq 1 ] || [ "${RELOAD_EVENT}" -eq 1 ]; then
break
fi
done
if [ "${IFDOWN_EVENT}" -eq 1 ] || [ "${IFUP_EVENT}" -eq 1 ] || [ "${RELOAD_EVENT}" -eq 1 ]; then
host_up_count=0
failed_hosts=""
continue
fi
# Log failures only when the round actually failed (reliability not met).
# Per-host failures are suppressed when a later host met the threshold,
# since logging them would falsely imply the interface is degraded.
# Also suppress when hotplug has already marked the interface offline
# (e.g. manual ifdown) — the USR1 signal may still be in transit.
if [ $host_up_count -lt $reliability ] && [ -n "$failed_hosts" ] && [ $score -gt $up ]; then
local hp_state
readfile hp_state "$MWAN3_STATUS_DIR/iface_state/$INTERFACE" 2>/dev/null
if [ "$hp_state" != "offline" ]; then
LOG info "Check ($track_method) failed for target(s) \"$failed_hosts\" on interface $INTERFACE ($DEVICE). Current score: $score"
fi
fi
if [ $host_up_count -lt $reliability ]; then
let score--
if [ $score -lt $up ]; then
score=0
[ ${keep_failure_interval} -eq 1 ] && sleep_time=$failure_interval
elif [ $score -eq $up ]; then
disconnecting
sleep_time=$failure_interval
disconnected
elif [ $score -gt $up ]; then
[ $score -le $warn_threshold ] && disconnecting
sleep_time=$failure_interval
fi
else
if [ $score -lt $((down+up)) ] && [ $lost -gt 0 ]; then
LOG debug "Lost $lost host(s) on interface $INTERFACE ($DEVICE). Current score: $score"
fi
let score++
lost=0
if [ $score -lt $up ]; then
connecting
sleep_time=$recovery_interval
elif [ $score -eq $up ]; then
connecting
sleep_time=$recovery_interval
connected
elif [ $score -gt $up ]; then
if [ "$STATUS" = "disconnecting" ]; then
STATUS="online"
LOG notice "Interface $INTERFACE ($DEVICE) is online"
fi
echo "online" > $MWAN3TRACK_STATUS_DIR/$INTERFACE/STATUS
score=$((down+up))
fi
fi
let turn++
mkdir -p "$MWAN3TRACK_STATUS_DIR/${1}"
echo "${lost}" > $MWAN3TRACK_STATUS_DIR/$INTERFACE/LOST
echo "${score}" > $MWAN3TRACK_STATUS_DIR/$INTERFACE/SCORE
echo "${turn}" > $MWAN3TRACK_STATUS_DIR/$INTERFACE/TURN
get_uptime > $MWAN3TRACK_STATUS_DIR/$INTERFACE/TIME
host_up_count=0
failed_hosts=""
if [ "${IFDOWN_EVENT}" -eq 0 ] && [ "${IFUP_EVENT}" -eq 0 ]; then
sleep "${sleep_time}" &
wait $!
fi
if [ "${IFDOWN_EVENT}" -eq 1 ]; then
LOG debug "Register ifdown event on interface ${INTERFACE} (${DEVICE})"
disconnected
disabled
IFDOWN_EVENT=0
fi
if [ "${IFUP_EVENT}" -eq 1 ]; then
LOG debug "Register ifup event on interface ${INTERFACE} (${DEVICE})"
config_load mwan3
mwan3_load_track_ips
firstconnect
IFUP_EVENT=0
fi
if [ "${RELOAD_EVENT}" -eq 1 ]; then
LOG debug "Processing configuration reload for interface ${INTERFACE}"
config_load mwan3
load_tracking_config
mwan3_load_track_ips
if [ $score -gt $((down + up)) ]; then
score=$((down + up))
fi
warn_threshold=$((up + (down + 2) / 3))
RELOAD_EVENT=0
fi
done
}
main "$@"

View File

@ -0,0 +1,756 @@
'use strict';
import { popen, readfile, glob } from 'fs';
import { cursor } from 'uci';
import * as rtnl from 'rtnl';
const ubus = require('ubus').connect();
function get_str_raw(iface, property) {
return readfile(sprintf('/var/run/mwan3track/%s/%s', iface, property));
}
function get_str(iface, property) {
return rtrim(get_str_raw(iface, property), '\n');
}
function get_int(iface, property) {
return int(get_str(iface, property));
}
function get_uptime() {
return int(split(readfile('/proc/uptime'), '.', 2)[0]);
}
function get_x_time(uptime, iface, property) {
let t = get_int(iface, property);
if (t > 0) {
t = uptime - t;
}
return t;
}
function ucibool(val) {
switch (val) {
case 'yes':
case 'on':
case 'true':
case 'enabled':
return true;
default:
return !!int(val);
}
}
function get_mwan3track_status(iface, uci_track_ips, track_gateway, procd) {
if (length(uci_track_ips) == 0 && !track_gateway) {
return 'disabled';
}
if (procd?.[sprintf('track_%s', iface)]?.running) {
const started = get_str(iface, 'STARTED');
switch (started) {
case '0':
return 'paused';
case '1':
return 'active';
default:
return 'down';
}
}
return 'down';
}
const nft_connected_set = {
'4': 'mwan3_connected_v4',
'6': 'mwan3_connected_v6',
};
function parse_elem_val(v) {
if (type(v) == 'string')
return v;
if (v?.prefix)
return sprintf('%s/%d', v.prefix.addr, v.prefix.len);
if (v?.range)
return sprintf('%s-%s', v.range[0], v.range[1]);
return null;
}
function count_nftset_elements(setname) {
if (!match(setname, /^[a-zA-Z0-9_-]+$/))
return 0;
const nft = popen(sprintf('nft -j list set inet mwan3 %s 2>/dev/null', setname), 'r');
if (!nft)
return 0;
const raw = nft.read('all');
nft.close();
if (!raw || length(raw) == 0)
return 0;
let data;
try {
data = json(raw);
} catch(e) {
return 0;
}
if (data?.nftables) {
for (let item in data.nftables) {
if (item?.set?.elem)
return length(item.set.elem);
}
}
return 0;
}
function get_nftset_members(setname) {
if (!match(setname, /^[a-zA-Z0-9_-]+$/))
return [];
const nft = popen(sprintf('nft -j list set inet mwan3 %s 2>/dev/null', setname), 'r');
if (!nft)
return [];
const raw = nft.read('all');
nft.close();
if (!raw || length(raw) == 0)
return [];
let data;
try {
data = json(raw);
} catch(e) {
return [];
}
const members = [];
if (data?.nftables) {
for (let item in data.nftables) {
if (item?.set?.elem) {
for (let elem in item.set.elem) {
let val;
if (type(elem) == 'object' && elem?.elem)
val = parse_elem_val(elem.elem?.val);
else
val = parse_elem_val(elem);
if (val != null)
push(members, val);
}
}
}
}
return members;
}
function get_nftset_elements(setname, max_count) {
if (!match(setname, /^[a-zA-Z0-9_-]+$/))
return { elements: [], truncated: false };
const nft = popen(sprintf('nft -j list set inet mwan3 %s 2>/dev/null', setname), 'r');
if (!nft)
return { elements: [], truncated: false };
const raw = nft.read('all');
nft.close();
if (!raw || length(raw) == 0)
return { elements: [], truncated: false };
let data;
try {
data = json(raw);
} catch(e) {
return { elements: [], truncated: false };
}
const elements = [];
let truncated = false;
let done = false;
if (data?.nftables) {
for (let item in data.nftables) {
if (done) break;
if (item?.set?.elem) {
for (let elem in item.set.elem) {
if (length(elements) >= max_count) {
truncated = true;
done = true;
break;
}
let val = null;
let packets = null;
let bytes = null;
if (type(elem) == 'object' && elem?.elem) {
const inner = elem.elem;
if (inner?.counter) {
packets = inner.counter.packets;
bytes = inner.counter.bytes;
}
val = parse_elem_val(inner?.val);
} else {
val = parse_elem_val(elem);
}
if (val != null) {
const entry = { value: val };
if (packets != null) {
entry.packets = packets;
entry.bytes = bytes;
}
push(elements, entry);
}
}
}
}
}
return { elements: elements, truncated: truncated };
}
function get_ip_rules() {
const rules = [];
for (let family in [2, 10]) {
const result = rtnl.request(rtnl.const.RTM_GETRULE, rtnl.const.NLM_F_DUMP,
{ family: family }) ?? [];
for (let r in result)
push(rules, r);
}
return rules;
}
function get_table_routes(table_id) {
const routes = [];
for (let family in [2, 10]) {
const result = rtnl.request(rtnl.const.RTM_GETROUTE, rtnl.const.NLM_F_DUMP,
{ family: family }) ?? [];
for (let r in result)
if (r.table == table_id)
push(routes, r);
}
return routes;
}
function nslookup_resolve(host, type) {
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');
p.close();
if (!raw || length(raw) == 0) return [];
const found = [];
for (let line in split(raw, '\n')) {
if (index(line, 'Address:') != 0) continue;
const rest = ltrim(substr(line, 8));
if (index(rest, '127.0.0.1') >= 0) continue;
if (length(rest) > 0) push(found, rest);
}
return found;
}
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;
const mmdefault = (1 << bitcnt) - 1;
const iface_max = mmdefault - 3;
let IIF_BASE = int(uci.get('mwan3', 'globals', 'iif_rule_base')) || 1000;
let FWMARK_BASE = int(uci.get('mwan3', 'globals', 'fwmark_rule_base')) || 2000;
let UNREACH_BASE = int(uci.get('mwan3', 'globals', 'unreachable_rule_base')) || 3000;
if ((IIF_BASE + iface_max) >= FWMARK_BASE ||
(FWMARK_BASE + iface_max + 1) >= UNREACH_BASE) {
IIF_BASE = 1000;
FWMARK_BASE = 2000;
UNREACH_BASE = 3000;
}
// Build ordered interface list with 1-based index (same ordering mwan3 uses)
const iface_order = [];
const iface_index = {};
uci.foreach('mwan3', 'interface', intf => {
push(iface_order, intf['.name']);
iface_index[intf['.name']] = length(iface_order);
});
// Fetch all ip rules and index by priority for O(1) lookup
const all_rules = get_ip_rules();
const rule_by_priority = {};
for (let rule in all_rules) {
if (rule?.priority != null)
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[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];
const iif_pri = IIF_BASE + id;
const fwmark_pri = FWMARK_BASE + id;
const unreach_pri = UNREACH_BASE + id;
const iif_rule = rule_by_priority[iif_pri];
const fwmark_rule = rule_by_priority[fwmark_pri];
const unreach_rule = rule_by_priority[unreach_pri];
const table_routes = get_table_routes(id);
const default_routes = filter(table_routes, r =>
r?.dst == null || r?.dst == '0.0.0.0/0' || r?.dst == '::/0'
);
const status = get_str(ifname, 'STATUS') || 'unknown';
result[ifname] = {
index: id,
status: status,
iif_rule: {
present: iif_rule != null,
priority: iif_pri,
},
fwmark_rule: {
present: fwmark_rule != null,
priority: fwmark_pri,
},
unreach_rule: {
present: unreach_rule != null,
priority: unreach_pri,
},
table: {
id: id,
has_default: length(default_routes) > 0,
default_routes: map(default_routes, r => ({
dst: r.dst ?? 'default',
gateway: r.gateway,
dev: r.oif,
family: (r.family == 10) ? 'ipv6' : 'ipv4',
})),
},
};
}
// 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;
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])
push(stale, { priority: p });
}
const mwan3_active = length(glob('/var/run/mwan3track/*/STATUS') || []) > 0;
return {
interfaces: result,
stale_rules: stale,
mwan3_active: mwan3_active,
rule_bases: { iif: IIF_BASE, fwmark: FWMARK_BASE, unreach: UNREACH_BASE },
};
}
function get_connected_ips(version) {
const setname = nft_connected_set[version];
const nft = popen(sprintf('nft -j list set inet mwan3 %s 2>/dev/null', setname), 'r');
if (!nft) {
return [];
}
const raw = nft.read('all');
nft.close();
if (!raw || length(raw) == 0) {
return [];
}
let data;
try {
data = json(raw);
} catch(e) {
return [];
}
const ips = [];
// nft JSON output: { nftables: [ { set: { elem: [ ... ] } } ] }
if (data?.nftables) {
for (let item in data.nftables) {
if (item?.set?.elem) {
for (let elem in item.set.elem) {
if (type(elem) == 'string') {
push(ips, elem);
} else if (type(elem) == 'object') {
// Handle prefix notation: { prefix: { addr: "x.x.x.x", len: N } }
if (elem?.prefix) {
push(ips, sprintf('%s/%d', elem.prefix.addr, elem.prefix.len));
} else if (elem?.range) {
push(ips, sprintf('%s-%s', elem.range[0], elem.range[1]));
}
}
}
}
}
}
return ips;
}
function get_policies(version) {
const uci = cursor();
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']] = {
interface: m['interface'],
metric: int(m['metric'] || 1),
weight: int(m['weight'] || 1),
};
});
const policies = {};
uci.foreach('mwan3', 'policy', p => {
const pname = p['.name'];
let use_members = p['use_member'];
if (!use_members) return;
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];
if (!m) continue;
if (iface_family[m.interface] != family_filter) continue;
if (!by_metric[m.metric]) by_metric[m.metric] = [];
push(by_metric[m.metric], {
interface: m.interface,
metric: m.metric,
weight: m.weight,
});
}
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) {
for (let m in by_metric[metric]) {
if (get_str(m.interface, 'STATUS') == 'online') {
active_metric = metric;
break;
}
}
if (active_metric != null) break;
}
// 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]) {
if (get_str(m.interface, 'STATUS') == 'online')
total_weight += m.weight;
}
}
policies[pname] = [];
for (let metric in sorted_metrics) {
for (let m in by_metric[metric]) {
const iface_status = get_str(m.interface, 'STATUS') || 'offline';
const is_active = (metric == active_metric) && (iface_status == 'online');
const percent = (is_active && total_weight > 0) ? int(m.weight * 100 / total_weight) : 0;
push(policies[pname], {
'interface': m.interface,
'metric': m.metric,
'weight': m.weight,
'percent': percent,
'status': iface_status,
});
}
}
});
return policies;
}
function interfaces_status(request) {
const uci = cursor();
const procd = ubus.call('service', 'list', { 'name': 'mwan3' })?.mwan3?.instances;
const interfaces = {};
uci.foreach('mwan3', 'interface', intf => {
const ifname = intf['.name'];
if (request.args.interface != null && request.args.interface != ifname) {
return;
}
const netstatus = ubus.call(sprintf('network.interface.%s', ifname), 'status', {});
const uptime = get_uptime();
const uci_track_ips = intf['track_ip'] || [];
const track_gateway = ucibool(intf['track_gateway'] || '0');
const track_status = get_mwan3track_status(ifname, uci_track_ips, track_gateway, procd);
const track_ips = [];
const track_files = glob(sprintf('/var/run/mwan3track/%s/TRACK_*', ifname)) || [];
for (let f in track_files) {
const ip = substr(f, length(sprintf('/var/run/mwan3track/%s/TRACK_', ifname)));
if (ip == 'OUTPUT') continue;
push(track_ips, {
'ip': ip,
'status': get_str(ifname, sprintf('TRACK_%s', ip)) || 'unknown',
'latency': get_int(ifname, sprintf('LATENCY_%s', ip)),
'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));
if (lat && length(rtrim(lat, '\n')) > 0) {
check_quality = true;
break;
}
}
interfaces[ifname] = {
'age': get_x_time(uptime, ifname, 'TIME'),
'online': get_x_time(uptime, ifname, 'ONLINE'),
'offline': get_x_time(uptime, ifname, 'OFFLINE'),
'uptime': netstatus?.uptime || 0,
'score': get_int(ifname, 'SCORE'),
'lost': get_int(ifname, 'LOST'),
'turn': get_int(ifname, 'TURN'),
'status': get_str(ifname, 'STATUS') || 'unknown',
'enabled': ucibool(intf['enabled']),
'running': track_status == 'active',
'tracking': track_status,
'up': netstatus?.up || false,
'check_quality': check_quality,
'track_ip': track_ips,
};
});
return interfaces;
}
const methods = {
nftset_info: {
args: {},
call: function(request) {
const nft = popen('nft -j list table inet mwan3 2>/dev/null', 'r');
if (!nft)
return { sets: {} };
const raw = nft.read('all');
nft.close();
if (!raw || length(raw) == 0)
return { sets: {} };
let data;
try {
data = json(raw);
} catch(e) {
return { sets: {} };
}
const sets = {};
if (data?.nftables) {
for (let item in data.nftables) {
if (item?.set) {
const s = item.set;
if (s.table == 'mwan3' && !match(s.name, /^mwan3_/)) {
let counters = false;
if (type(s.stmt) == 'array') {
for (let stmt in s.stmt) {
if (type(stmt) == 'object' && 'counter' in stmt) {
counters = true;
break;
}
}
}
sets[s.name] = { type: s.type, flags: s.flags || [], counters: counters, count: type(s.elem) == 'array' ? length(s.elem) : 0 };
}
}
}
}
return { sets: sets };
}
},
nftset_members: {
args: { set: '' },
call: function(request) {
const setname = request.args.set;
if (!setname)
return { error: 'set name required', members: [] };
return { members: get_nftset_members(setname) };
}
},
nftset_elements: {
args: { set: '', max: 0 },
call: function(request) {
const setname = request.args.set;
if (!setname)
return { error: 'set name required', elements: [], truncated: false };
const max = (request.args.max > 0) ? int(request.args.max) : 200;
const result = get_nftset_elements(setname, max > 5000 ? 5000 : max);
result.set = setname;
return result;
}
},
resolve_host: {
args: { host: '', family: '' },
call: function(request) {
const host = request.args.host;
if (!host || !match(host, /^[a-zA-Z0-9._-]+$/))
return { error: 'invalid hostname', v4: [], v6: [] };
const family = request.args.family || '';
const v4 = (family !== 'ipv6') ? nslookup_resolve(host, 'A') : [];
const v6 = (family !== 'ipv4') ? nslookup_resolve(host, 'AAAA') : [];
return { v4: v4, v6: v6 };
}
},
routing_health: {
args: {},
call: function(request) {
return routing_health();
}
},
status: {
args: {
section: 'section',
interface: 'interface'
},
call: function (request) {
switch (request.args.section) {
case 'connected':
return {
'connected': {
'ipv4': get_connected_ips('4'),
'ipv6': get_connected_ips('6'),
},
};
case 'policies':
return {
'policies': {
'ipv4': get_policies('4'),
'ipv6': get_policies('6'),
},
};
case 'interfaces':
return {
'interfaces': interfaces_status(request),
};
default:
return {
'interfaces': interfaces_status(request),
'connected': {
'ipv4': get_connected_ips('4'),
'ipv6': get_connected_ips('6'),
},
'policies': {
'ipv4': get_policies('4'),
'ipv6': get_policies('6'),
},
};
}
}
},
nftset_flush: {
args: { set: '' },
call: function(request) {
const setname = request.args.set;
if (!setname || !match(setname, /^[a-zA-Z0-9_-]+$/))
return { error: 'invalid set name' };
const proc = popen(sprintf('nft flush set inet mwan3 %s 2>&1', setname), 'r');
if (!proc)
return { error: 'failed to run nft' };
const out = proc.read('all');
const rc = proc.close();
if (rc != 0)
return { error: rtrim(out, '\n') || 'nft flush failed' };
return {};
}
},
nftset_reload: {
args: { set: '' },
call: function(request) {
const setname = request.args.set;
if (!setname || !match(setname, /^[a-zA-Z0-9_-]+$/))
return { error: 'invalid set name' };
let proc = popen(sprintf('nft flush set inet mwan3 %s 2>&1', setname), 'r');
if (!proc) return { error: 'failed to run nft' };
let out = proc.read('all');
let rc = proc.close();
if (rc != 0) return { error: rtrim(out, '\n') || 'nft flush failed' };
const uci = cursor();
let cfg = null;
uci.foreach('mwan3', 'ipset', function(s) {
if (s.name == setname) {
if (s.enabled !== '0')
cfg = s;
return false;
}
});
if (!cfg) return {};
const elements = [];
let entries = cfg.entry || [];
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 (cfg.loadfile) {
const content = readfile(cfg.loadfile);
if (content) {
for (let line in split(content, '\n')) {
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(elements) == 0) return {};
proc = popen(sprintf('nft add element inet mwan3 %s { %s } 2>&1',
setname, join(', ', elements)), 'r');
if (!proc) return { error: 'failed to run nft add element' };
out = proc.read('all');
rc = proc.close();
if (rc != 0) return { error: rtrim(out, '\n') || 'nft add element failed' };
return {};
}
},
nftset_resolve: {
args: { set: '' },
call: function(request) {
const setname = request.args.set;
if (!setname || !match(setname, /^[a-zA-Z0-9_-]+$/))
return { error: 'invalid set name' };
const uci = cursor();
let cfg = null;
uci.foreach('mwan3', 'ipset', function(s) {
if (s.name == setname) {
if (s.enabled !== '0')
cfg = s;
return false;
}
});
if (!cfg) return { error: 'set not found in config' };
let domains = cfg.domain || [];
if (type(domains) != 'array') domains = [domains];
if (length(domains) == 0) return {};
ubus.call('service', 'signal', { name: 'dnsmasq', signal: 1 });
const qtype = (cfg.family == 'ipv6') ? 'AAAA' : 'A';
let resolved = 0;
for (let domain in domains) {
const addrs = nslookup_resolve(domain, qtype);
if (length(addrs) > 0) resolved++;
}
return { resolved: resolved };
}
}
};
return { 'mwan3': methods };

File diff suppressed because it is too large Load Diff

310
mwan3-nft/src/mwan3ct.c Normal file
View File

@ -0,0 +1,310 @@
// SPDX-License-Identifier: GPL-2.0
/*
* mwan3ct - targeted conntrack flush helper for mwan3
*
* Uses libnetfilter_conntrack's NFCT_Q_FLUSH_FILTER to perform
* kernel-side filtered conntrack entry deletion by mark and/or
* status bits.
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <errno.h>
#include <libnetfilter_conntrack/libnetfilter_conntrack.h>
/*
* id2mask - expand a sequential bit index into a sparse mask
*
* Maps each set bit in 'id' (counting from bit 0) to the
* corresponding set bit position in 'mask', producing a value
* that occupies only the bit positions defined by 'mask'.
*/
static uint32_t id2mask(uint32_t id, uint32_t mask)
{
uint32_t result = 0;
uint32_t bit = 1;
for (uint32_t m = mask; m; m &= m - 1) {
if (id & bit)
result |= m & (-m);
bit <<= 1;
}
return result;
}
/*
* flush_filtered - flush conntrack entries matching mark and/or status
*
* Builds a kernel-side filter from the supplied mark and status
* value/mask pairs, then issues a single NFCT_Q_FLUSH_FILTER query.
* ENOENT (no matching entries) is treated as success.
*/
static int flush_filtered(struct nfct_handle *h,
uint32_t mark_val, uint32_t mark_mask,
uint32_t status_val, uint32_t status_mask,
int have_mark, int have_status)
{
struct nfct_filter_dump *filter = NULL;
int ret;
filter = nfct_filter_dump_create();
if (!filter) {
fprintf(stderr, "mwan3ct: nfct_filter_dump_create: %s\n",
strerror(errno));
return -1;
}
if (have_mark) {
struct nfct_filter_dump_mark mark = {
.val = mark_val,
.mask = mark_mask,
};
nfct_filter_dump_set_attr(filter, NFCT_FILTER_DUMP_MARK,
&mark);
}
if (have_status) {
struct nfct_filter_dump_mark status = {
.val = status_val,
.mask = status_mask,
};
nfct_filter_dump_set_attr(filter, NFCT_FILTER_DUMP_STATUS,
&status);
}
ret = nfct_query(h, NFCT_Q_FLUSH_FILTER, filter);
nfct_filter_dump_destroy(filter);
if (ret == -1 && errno != ENOENT)
return -1;
return 0;
}
/*
* flush_any_marked - flush conntrack entries for all mark values in a mask
*
* Iterates over every non-zero combination of bits within 'mask'
* and issues a filtered flush for each. Used when the caller wants
* to remove all mwan3-marked entries regardless of which interface
* they belong to.
*/
static int flush_any_marked(struct nfct_handle *h, uint32_t mask,
uint32_t status_val, uint32_t status_mask,
int have_status)
{
int bitcount = __builtin_popcount(mask);
uint32_t max_id;
if (bitcount > 16) {
errno = EINVAL;
return -1;
}
max_id = (1U << bitcount) - 1;
for (uint32_t id = 1; id <= max_id; id++) {
uint32_t mark_val = id2mask(id, mask);
int ret = flush_filtered(h, mark_val, mask, status_val,
status_mask, 1, have_status);
if (ret < 0)
return ret;
}
return 0;
}
/*
* parse_u32 - parse a string as a uint32_t with full validation
*
* Rejects negative values, overflow, and empty input. On success
* *endp points past the last consumed character, allowing the caller
* to check for trailing garbage. Returns 0 on success, -1 on failure.
*/
static int parse_u32(const char *s, char **endp, uint32_t *out)
{
unsigned long tmp;
if (*s == '-')
return -1;
errno = 0;
tmp = strtoul(s, endp, 0);
if (*endp == s || errno)
return -1;
if ((uint32_t)tmp != tmp)
return -1;
*out = (uint32_t)tmp;
return 0;
}
/*
* parse_val_mask - parse a "value/mask" string into two uint32 values
*
* Accepts "val/mask" or just "val", in which case the mask defaults
* to the value itself. Returns 0 on success, -1 on parse failure.
*/
static int parse_val_mask(const char *arg, uint32_t *val, uint32_t *mask)
{
char *end = NULL;
if (parse_u32(arg, &end, val))
return -1;
if (*end == '/') {
if (parse_u32(end + 1, &end, mask))
return -1;
if (*end != '\0')
return -1;
} else if (*end == '\0') {
*mask = *val;
} else {
return -1;
}
return 0;
}
/*
* usage - print command usage to stderr
*/
static void usage(void)
{
fprintf(stderr,
"Usage: mwan3ct flush [--mark <val>/<mask>] "
"[--mark-any <mask>] [--status <val>/<mask>]\n");
}
/*
* main - parse arguments and dispatch the requested flush operation
*
* Supports three mutually exclusive filter modes: --mark (exact
* value/mask match), --mark-any (iterate all non-zero mark values
* within a mask), and --status (conntrack status bit filter).
* --mark and --status, or --mark-any and --status, may be combined.
*/
int main(int argc, char *argv[])
{
uint32_t mark_val = 0, mark_mask = 0;
uint32_t status_val = 0, status_mask = 0;
int have_mark = 0, have_mark_any = 0, have_status = 0;
struct nfct_handle *h = NULL;
int ret;
if (argc >= 2 && (strcmp(argv[1], "--help") == 0 ||
strcmp(argv[1], "-h") == 0)) {
usage();
return EXIT_SUCCESS;
}
if (argc < 2 || strcmp(argv[1], "flush") != 0) {
usage();
return EXIT_FAILURE;
}
for (int i = 2; i < argc; i++) {
if (strcmp(argv[i], "--mark") == 0 && i + 1 < argc) {
if (have_mark) {
fprintf(stderr,
"mwan3ct: duplicate --mark\n");
return EXIT_FAILURE;
}
if (parse_val_mask(argv[++i], &mark_val, &mark_mask)) {
fprintf(stderr, "mwan3ct: bad --mark value\n");
return EXIT_FAILURE;
}
have_mark = 1;
} else if (strcmp(argv[i], "--mark-any") == 0 && i + 1 < argc) {
char *end;
if (have_mark_any) {
fprintf(stderr,
"mwan3ct: duplicate --mark-any\n");
return EXIT_FAILURE;
}
if (parse_u32(argv[++i], &end, &mark_mask) ||
*end != '\0' || !mark_mask) {
fprintf(stderr,
"mwan3ct: bad --mark-any mask\n");
return EXIT_FAILURE;
}
have_mark_any = 1;
} else if (strcmp(argv[i], "--status") == 0 && i + 1 < argc) {
if (have_status) {
fprintf(stderr,
"mwan3ct: duplicate --status\n");
return EXIT_FAILURE;
}
if (parse_val_mask(argv[++i],
&status_val, &status_mask)) {
fprintf(stderr,
"mwan3ct: bad --status value\n");
return EXIT_FAILURE;
}
have_status = 1;
} else {
usage();
return EXIT_FAILURE;
}
}
if (have_mark && have_mark_any) {
fprintf(stderr,
"mwan3ct: --mark and --mark-any "
"are mutually exclusive\n");
return EXIT_FAILURE;
}
if (!have_mark && !have_mark_any && !have_status) {
fprintf(stderr, "mwan3ct: at least one filter required\n");
usage();
return EXIT_FAILURE;
}
if (have_mark && !mark_mask) {
fprintf(stderr, "mwan3ct: --mark mask must be non-zero\n");
return EXIT_FAILURE;
}
if (have_mark && (mark_val & ~mark_mask)) {
fprintf(stderr,
"mwan3ct: --mark value has bits outside mask\n");
return EXIT_FAILURE;
}
if (have_status && !status_mask) {
fprintf(stderr,
"mwan3ct: --status mask must be non-zero\n");
return EXIT_FAILURE;
}
if (have_status && (status_val & ~status_mask)) {
fprintf(stderr,
"mwan3ct: --status value has bits outside mask\n");
return EXIT_FAILURE;
}
h = nfct_open(CONNTRACK, 0);
if (!h) {
fprintf(stderr, "mwan3ct: nfct_open: %s\n", strerror(errno));
return EXIT_FAILURE;
}
if (have_mark_any)
ret = flush_any_marked(h, mark_mask, status_val,
status_mask, have_status);
else
ret = flush_filtered(h, mark_val, mark_mask,
status_val, status_mask,
have_mark, have_status);
nfct_close(h);
if (ret < 0) {
fprintf(stderr, "mwan3ct: flush failed: %s\n", strerror(errno));
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}

View File

@ -0,0 +1,123 @@
// SPDX-License-Identifier: GPL-2.0
/*
* mwan3ipcheck - validate and classify IP addresses and CIDR notation
*
* Uses inet_pton() to determine whether a string is a valid IPv4 or
* IPv6 address, optionally with a CIDR prefix length.
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#include <arpa/inet.h>
static int classify(const char *arg)
{
char buf[64];
unsigned char addr[sizeof(struct in6_addr)];
const char *pfxstr = NULL;
int family;
size_t len;
len = strlen(arg);
if (len == 0 || len >= sizeof(buf))
return 0;
memcpy(buf, arg, len + 1);
char *slash = strchr(buf, '/');
if (slash) {
*slash = '\0';
pfxstr = slash + 1;
if (*pfxstr == '\0')
return 0;
}
if (inet_pton(AF_INET, buf, addr) == 1)
family = AF_INET;
else if (inet_pton(AF_INET6, buf, addr) == 1)
family = AF_INET6;
else
return 0;
if (pfxstr) {
char *end;
long pfx;
int max_pfx = (family == AF_INET) ? 32 : 128;
if (pfxstr[0] == '0' && pfxstr[1] != '\0')
return 0;
pfx = strtol(pfxstr, &end, 10);
if (*end != '\0' || end == pfxstr)
return 0;
if (pfx < 0 || pfx > max_pfx)
return 0;
}
return family;
}
static int classify_list(const char *arg)
{
char buf[512];
char *token = NULL, *saveptr = NULL;
int common_family = 0;
size_t len = strlen(arg);
if (len == 0 || len >= sizeof(buf))
return 0;
memcpy(buf, arg, len + 1);
for (token = strtok_r(buf, ",", &saveptr);
token;
token = strtok_r(NULL, ",", &saveptr)) {
while (*token && isspace((unsigned char)*token))
token++;
size_t tlen = strlen(token);
while (tlen > 0 && isspace((unsigned char)token[tlen - 1]))
token[--tlen] = '\0';
if (tlen == 0)
continue;
int family = classify(token);
if (family == 0)
return 0;
if (common_family == 0)
common_family = family;
else if (family != common_family)
return -1;
}
if (common_family == 0)
return 0;
return common_family;
}
int main(int argc, char *argv[])
{
int result;
if (argc != 2)
result = 0;
else
result = classify_list(argv[1]);
if (result == AF_INET)
puts("ipv4");
else if (result == AF_INET6)
puts("ipv6");
else if (result == -1) {
puts("mixed");
return EXIT_FAILURE;
} else {
puts("invalid");
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}

View File

@ -0,0 +1,260 @@
// SPDX-License-Identifier: GPL-2.0
/*
* Copyright (C) 2020 Aaron Goodman <aaronjg@alumni.stanford.edu>. All Rights Reserved.
*/
/*
* sockopt_wrap.c provides a shared library that intercepts syscalls to various
* networking functions to bind the sockets a source IP address and network device
* and to set the firewall mark on otugoing packets. Parameters are set using the
* DEVICE, SRCIP, FWMARK environment variables.
*
* Additionally the FAMILY environment variable can be set to either 'ipv4' or
* 'ipv6' to cause sockets opened with ipv6 or ipv4 to fail, respectively.
*
* Each environment variable is optional, and if not set, the library will not
* enforce the particular parameter.
*/
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <arpa/inet.h>
#include <net/ethernet.h>
#include <linux/if_packet.h>
#include <net/if.h>
static int (*next_socket)(int domain, int type, int protocol);
static int (*next_setsockopt)(int sockfd, int level, int optname,
const void *optval, socklen_t optlen);
static int (*next_bind)(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
static int (*next_close)(int fd);
static ssize_t (*next_send)(int sockfd, const void *buf, size_t len, int flags);
static ssize_t (*next_sendto)(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
static ssize_t (*next_sendmsg)(int sockfd, const struct msghdr *msg, int flags);
static int (*next_connect)(int sockfd, const struct sockaddr *addr,
socklen_t addrlen);
static int device=0;
static struct sockaddr_in source4 = {0};
#ifdef CONFIG_IPV6
static struct sockaddr_in6 source6 = {0};
#endif
static struct sockaddr * source = 0;
static int sockaddr_size = 0;
static int is_bound [1024] = {0};
#define next_func(x)\
void set_next_##x(){\
if (next_##x) return;\
next_##x = dlsym(RTLD_NEXT, #x);\
dlerror_handle();\
return;\
}
void dlerror_handle()
{
char *msg;
if ((msg = dlerror()) != NULL) {
fprintf(stderr, "socket: dlopen failed : %s\n", msg);
fflush(stderr);
exit(EXIT_FAILURE);
}
}
next_func(bind);
next_func(close);
next_func(setsockopt);
next_func(socket);
next_func(send);
next_func(sendto);
next_func(sendmsg);
next_func(connect);
void dobind(int sockfd)
{
if (source && sockfd < 1024 && !is_bound[sockfd]) {
set_next_bind();
if (next_bind(sockfd, source, sockaddr_size)) {
perror("mwan3 sockopt_wrap: failed to bind to source address");
next_close(sockfd);
/* Do not exit — let the caller's syscall fail with EBADF so
* the tracked process exits with a normal error code. This
* can happen when SRC_IP becomes stale (e.g. after a DHCP
* address change) and must not terminate the ping abruptly
* with no log from mwan3track. */
return;
}
is_bound[sockfd] = 1;
}
}
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen)
{
set_next_connect();
dobind(sockfd);
return next_connect(sockfd, addr, addrlen);
}
ssize_t send(int sockfd, const void *buf, size_t len, int flags)
{
set_next_send();
dobind(sockfd);
return next_send(sockfd, buf, len, flags);
}
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen)
{
set_next_sendto();
dobind(sockfd);
return next_sendto(sockfd, buf, len, flags, dest_addr, addrlen);
}
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags)
{
set_next_sendmsg();
dobind(sockfd);
return next_sendmsg(sockfd, msg, flags);
}
int bind (int sockfd, const struct sockaddr *addr, socklen_t addrlen)
{
set_next_bind();
if (device && addr->sa_family == AF_PACKET) {
((struct sockaddr_ll*)addr)->sll_ifindex=device;
}
else if (source && addr->sa_family == AF_INET) {
((struct sockaddr_in*)addr)->sin_addr = source4.sin_addr;
}
#ifdef CONFIG_IPV6
else if (source && addr->sa_family == AF_INET6) {
((struct sockaddr_in6*)addr)->sin6_addr = source6.sin6_addr;
}
#endif
if (sockfd < 1024)
is_bound[sockfd] = 1;
return next_bind(sockfd, addr, addrlen);
}
int close (int sockfd)
{
set_next_close();
if (sockfd < 1024)
is_bound[sockfd]=0;
return next_close(sockfd);
}
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen)
{
set_next_setsockopt();
if (level == SOL_SOCKET && (optname == SO_MARK || optname == SO_BINDTODEVICE))
return 0;
return next_setsockopt(sockfd, level, optname, optval, optlen);
}
int socket(int domain, int type, int protocol)
{
int handle;
const char *socket_str = getenv("DEVICE");
const char *srcip_str = getenv("SRCIP");
const char *fwmark_str = getenv("FWMARK");
const char *family_str = getenv("FAMILY");
const int iface_len = socket_str ? strnlen(socket_str, IFNAMSIZ) : 0;
int has_family = family_str && *family_str != 0;
int has_srcip = srcip_str && *srcip_str != 0;
const int fwmark = fwmark_str ? (int)strtol(fwmark_str, NULL, 0) : 0;
set_next_close();
set_next_socket();
set_next_send();
set_next_setsockopt();
set_next_sendmsg();
set_next_sendto();
set_next_connect();
if(has_family) {
#ifdef CONFIG_IPV6
if(domain == AF_INET && strncmp(family_str,"ipv6",4) == 0)
return -1;
#endif
if(domain == AF_INET6 && strncmp(family_str,"ipv4",4) == 0)
return -1;
}
if (domain != AF_INET
#ifdef CONFIG_IPV6
&& domain != AF_INET6
#endif
) {
return next_socket(domain, type, protocol);
}
if (iface_len > 0) {
if (iface_len == IFNAMSIZ) {
fprintf(stderr, "mwan3 sockopt_wrap: interface name too long\n");
fflush(stderr);
return -1;
}
}
if (has_srcip) {
int s;
void * addr_buf;
if (domain == AF_INET) {
addr_buf = &source4.sin_addr;
sockaddr_size=sizeof source4;
memset(&source4, 0, sockaddr_size);
source4.sin_family = domain;
source = (struct sockaddr*)&source4;
}
#ifdef CONFIG_IPV6
else {
addr_buf = &source6.sin6_addr;
sockaddr_size=sizeof source6;
memset(&source6, 0, sockaddr_size);
source6.sin6_family=domain;
source = (struct sockaddr*)&source6;
}
#endif
s = inet_pton(domain, srcip_str, addr_buf);
if (s == 0) {
fprintf(stderr, "socket: ip address invalid format for family %s\n",
domain == AF_INET ? "AF_INET" : domain == AF_INET6 ?
"AF_INET6" : "unknown");
return -1;
}
if (s < 0) {
perror("inet_pton");
exit(EXIT_FAILURE);
}
}
handle = next_socket(domain, type, protocol);
if (handle == -1 ) {
return handle;
}
if (iface_len > 0) {
device=if_nametoindex(socket_str);
if (next_setsockopt(handle, SOL_SOCKET, SO_BINDTODEVICE,
socket_str, iface_len + 1)) {
perror("mwan3 sockopt_wrap: failed to bind to interface");
next_close(handle);
return -1;
}
}
if (fwmark > 0) {
if (next_setsockopt(handle, SOL_SOCKET, SO_MARK,
&fwmark, sizeof fwmark)) {
perror("mwan3 sockopt_wrap: failed to set firewall mark");
next_close(handle);
return -1;
}
}
return handle;
}