From: Mark Abe Date: Sun, 6 Sep 2026 22:45:53 +0000 (+0200) Subject: tcfilter: add package for persistent tc ingress filters X-Git-Url: http://git.99rst.org/?a=commitdiff_plain;h=acdc070b06137a55bb74e20d1ebd0be34b7a6679;p=openwrt-packages.git tcfilter: add package for persistent tc ingress filters UCI front-end that installs raw "tc filter ... ingress" expressions on network devices at boot and re-applies them on ifup, removing them on stop. The match/action part is entered verbatim as tc syntax; the package manages the device, the preference number, enable/disable and persistence. A procd reload trigger re-applies on config changes. Intended for driving hardware tc-flower offload (e.g. the Realtek DSA PIE offload) where no higher-level configuration layer exists. Co-authored-by: OpenWrt AI review account Assisted-by: Claude Code (Anthropic Claude Sonnet 5) Signed-off-by: Mark Abe --- diff --git a/net/tcfilter/Makefile b/net/tcfilter/Makefile new file mode 100644 index 000000000..0b174a84f --- /dev/null +++ b/net/tcfilter/Makefile @@ -0,0 +1,52 @@ +# SPDX-License-Identifier: GPL-2.0-only + +include $(TOPDIR)/rules.mk + +PKG_NAME:=tcfilter +PKG_VERSION:=1.0 +PKG_RELEASE:=1 + +PKG_MAINTAINER:=Mark Abe +PKG_LICENSE:=GPL-2.0-only +PKG_LICENSE_FILES:= + +include $(INCLUDE_DIR)/package.mk + +define Package/tcfilter + SECTION:=net + CATEGORY:=Network + SUBMENU:=Routing and Redirection + TITLE:=Persistent tc ingress filters (UCI-managed) + DEPENDS:=+tc-full +kmod-sched-flower + PKGARCH:=all +endef + +define Package/tcfilter/description + Thin UCI front-end that installs raw "tc filter ... ingress" expressions on + network devices at boot and on interface up, and removes them on stop. The + match/action part is entered verbatim as tc syntax; the package only manages + the device, the preference number, enable/disable and persistence. + + Intended for driving hardware tc-flower offload (e.g. the Realtek DSA PIE + offload) where no higher-level configuration layer exists. +endef + +define Package/tcfilter/conffiles +/etc/config/tcfilter +endef + +define Build/Compile +endef + +define Package/tcfilter/install + $(INSTALL_DIR) $(1)/etc/config + $(INSTALL_CONF) ./files/tcfilter.config $(1)/etc/config/tcfilter + $(INSTALL_DIR) $(1)/etc/init.d + $(INSTALL_BIN) ./files/tcfilter.init $(1)/etc/init.d/tcfilter + $(INSTALL_DIR) $(1)/etc/hotplug.d/iface + $(INSTALL_DATA) ./files/tcfilter.hotplug-iface $(1)/etc/hotplug.d/iface/30-tcfilter + $(INSTALL_DIR) $(1)/etc/hotplug.d/net + $(INSTALL_DATA) ./files/tcfilter.hotplug-net $(1)/etc/hotplug.d/net/30-tcfilter +endef + +$(eval $(call BuildPackage,tcfilter)) diff --git a/net/tcfilter/README.md b/net/tcfilter/README.md new file mode 100644 index 000000000..cc8c49cac --- /dev/null +++ b/net/tcfilter/README.md @@ -0,0 +1,84 @@ +# tcfilter + +A thin UCI front-end for persistent `tc filter ... ingress` rules. + +It does **not** model the flower match/action fields. You write that part as +raw `tc` syntax in `option spec`; the package only manages the device, the +`pref` number, enable/disable, persistence across boot, and re-applying the +rules when the network is reconfigured. + +Meant for driving hardware tc-flower offload (e.g. the Realtek DSA PIE +offload) where no higher-level config layer exists. + +## Config + +`/etc/config/tcfilter`: + +``` +config tcfilter 'global' + option enabled '1' + +config rule + option label 'Drop-HomePlug-AV (FRITZ!Box)' + option device 'lan1' + option enabled '1' + option pref '49152' + option spec 'protocol 0x88e1 flower skip_sw action drop' +``` + +The shipped default config carries a few such rules with `enabled '0'` +as ready-to-use examples (FRITZ!Box powerline discovery, mDNS) — set +`device` and flip `enabled` to `1`. + +`label` is optional and cosmetic — it only tags the log messages and the +LuCI rows. + +`spec` is everything that would follow + +``` +tc filter add dev ingress pref +``` + +* `pref` is **required** — it is how the rule is deleted again. +* Use `skip_sw` so a match the hardware cannot offload fails loudly instead + of silently installing in software. +* One `rule` per `pref` per device. + +## Commands + +``` +/etc/init.d/tcfilter start # apply all enabled rules +/etc/init.d/tcfilter stop # remove them +/etc/init.d/tcfilter reload # stop + start +/etc/init.d/tcfilter show # tc -s filter show for every configured device +/etc/init.d/tcfilter reapply_dev lan1 +``` + +## Notes / limitations + +* ingress / `clsact` only. +* The `clsact` qdisc is added if missing but never removed on stop (other + users may share it); only the individual filters are deleted. +* Installed `(device, pref)` pairs are tracked in `/var/run/tcfilter.state` + so a rule removed from the config is still torn down on the next reload. +* Re-apply hooks: `hotplug.d/iface` runs `start` on `ifup`, `hotplug.d/net` + runs `reapply_dev` on netdev `add`. `start` is idempotent. +* A `procd_add_reload_trigger` reloads the service when the `tcfilter` + config changes, so LuCI Save & Apply and + `uci commit tcfilter && reload_config` take effect on their own; a bare + `uci commit` still needs an explicit `/etc/init.d/tcfilter reload`. +* No dry-run validation — an invalid `spec` is reported via logread only. +* Free-form `spec` is passed to `tc` by word-split (no shell). Anyone who can + edit the config can install redirect/mirror rules, i.e. tap traffic. + +### Hardware packet counters (Realtek rtl930x PIE offload) + +Older rtl930x kernels mis-read the per-rule LOG packet counter: with more +than one offloaded flower rule, only the rule whose PIE rule id was +even-aligned reported a working `tc -s` hardware packet count and the +others stayed at 0 (`rtl930x_packet_cntr_read()` assumed the L3-route +counter layout). Dropping / trapping / redirecting was never affected. + +Fixed in the kernel driver upstream (openwrt/openwrt#24994); every +offloaded rule now reports its own count. Firmware built before that +patch still shows the old behaviour. diff --git a/net/tcfilter/files/tcfilter.config b/net/tcfilter/files/tcfilter.config new file mode 100644 index 000000000..c92967d5e --- /dev/null +++ b/net/tcfilter/files/tcfilter.config @@ -0,0 +1,46 @@ +config tcfilter 'global' + option enabled '1' + +# One 'rule' section per tc filter. 'spec' is everything that would follow +# tc filter add dev ingress pref +# i.e. the (optional) protocol, the filter kind and its match, and the action. +# +# 'pref' is required: it is how the rule is removed again on stop/reload. +# 'label' is optional and only used for log messages and the LuCI view. +# Use 'skip_sw' so a match the hardware cannot offload fails loudly instead +# of silently installing in software. +# +# The rules below are disabled examples. Adjust 'device', then set +# 'enabled' to 1. +# +# Drop AVM FRITZ!Box powerline (HomePlug AV / MediaXtream) discovery: + +config rule + option label 'Drop-HomePlug-AV (FRITZ!Box)' + option device 'lan1' + option pref '49152' + option spec 'protocol 0x88e1 flower skip_sw action drop' + option enabled '0' + +config rule + option label 'Drop-MediaXtream (FRITZ!Box)' + option device 'lan1' + option pref '49153' + option spec 'protocol 0x8912 flower skip_sw action drop' + option enabled '0' + +# Drop multicast DNS / service discovery (UDP port 5353), IPv4 and IPv6: + +config rule + option label 'Drop-mDNS (IPv4)' + option device 'lan1' + option pref '49154' + option spec 'protocol ip flower ip_proto udp dst_port 5353 skip_sw action drop' + option enabled '0' + +config rule + option label 'Drop-mDNS (IPv6)' + option device 'lan1' + option pref '49155' + option spec 'protocol ipv6 flower ip_proto udp dst_port 5353 skip_sw action drop' + option enabled '0' diff --git a/net/tcfilter/files/tcfilter.hotplug-iface b/net/tcfilter/files/tcfilter.hotplug-iface new file mode 100644 index 000000000..a22b3db51 --- /dev/null +++ b/net/tcfilter/files/tcfilter.hotplug-iface @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: GPL-2.0-only +# clsact filters are lost when the network is reconfigured; re-apply on ifup. +# 'start' is idempotent (rules already present are skipped), so repeated ifup +# events at boot are cheap. + +[ "$ACTION" = "ifup" ] || exit 0 +[ -x /etc/init.d/tcfilter ] || exit 0 +[ -f /var/run/tcfilter.state ] || exit 0 + +/etc/init.d/tcfilter start diff --git a/net/tcfilter/files/tcfilter.hotplug-net b/net/tcfilter/files/tcfilter.hotplug-net new file mode 100644 index 000000000..18c5dc822 --- /dev/null +++ b/net/tcfilter/files/tcfilter.hotplug-net @@ -0,0 +1,11 @@ +# SPDX-License-Identifier: GPL-2.0-only +# A managed netdev (re)appeared - re-apply just the rules bound to it. + +dev="${INTERFACE:-$DEVICE}" + +[ "$ACTION" = "add" ] || exit 0 +[ -n "$dev" ] || exit 0 +[ -x /etc/init.d/tcfilter ] || exit 0 +[ -f /var/run/tcfilter.state ] || exit 0 + +/etc/init.d/tcfilter reapply_dev "$dev" diff --git a/net/tcfilter/files/tcfilter.init b/net/tcfilter/files/tcfilter.init new file mode 100644 index 000000000..b3b2acfb9 --- /dev/null +++ b/net/tcfilter/files/tcfilter.init @@ -0,0 +1,272 @@ +#!/bin/sh /etc/rc.common +# SPDX-License-Identifier: GPL-2.0-only + +USE_PROCD=1 +START=99 +STOP=10 + +extra_command "show" "Dump tc filters on every configured device" +extra_command "reapply_dev" " Re-apply the rules bound to one device" + +. /lib/functions.sh + +TC=tc +TAG=tcfilter +STATE=/var/run/tcfilter.state +LOCK=/var/run/tcfilter.lock + +_log() { + logger -t "$TAG" "$@" +} + +# Serialise everything that mutates $STATE. The init.d actions +# (start/stop/reload) and the hotplug-driven "start" / "reapply_dev" can +# otherwise interleave and corrupt the file. fd 9 stays open for the life +# of this script process, so the lock is dropped when the action returns. +_lock() { + exec 9>"$LOCK" + flock 9 +} + +# Record a (device, pref) pair we installed so a later stop/reload can tear +# the filter down even after its config section is gone. $1 = device, +# $2 = pref; both must be non-empty. Deduplicated against the state file. +_state_record() { + local device="$1" + local pref="$2" + + [ -n "$device" ] && [ -n "$pref" ] || return + + # -q: no output, -x: match the whole line, -F: plain string not a regexp + grep -qxF "$device $pref" "$STATE" 2>/dev/null && return + + echo "$device $pref" >> "$STATE" +} + +# Remove the ingress filter at pref $2 from device $1, if the device exists. +_del_filter() { + local device="$1" + local pref="$2" + + [ -e "/sys/class/net/$device" ] || return + + $TC filter del dev "$device" ingress pref "$pref" 2>/dev/null && _log "removed $device pref $pref" +} + +# config_foreach helper for _all_pairs: emit "device pref" for one rule +# section if both are set. $1 = config section name. +_cfg_pair() { + local cfg="$1" + + local device pref + + config_get device "$cfg" device + config_get pref "$cfg" pref + + [ -n "$device" ] && [ -n "$pref" ] && echo "$device $pref" +} + +# "device pref" lines: the union of what we installed (state file) and what +# the config currently lists, deduplicated. +_all_pairs() { + { + [ -f "$STATE" ] && cat "$STATE" + config_foreach _cfg_pair rule + } 2>/dev/null | sort -u +} + +# Install one rule section on its device. $1 = config section name. +_rule_apply() { + local cfg="$1" + + local device pref spec enabled label desc err + + config_get device "$cfg" device + config_get pref "$cfg" pref + config_get spec "$cfg" spec + config_get label "$cfg" label + config_get_bool enabled "$cfg" enabled 1 + + desc="${label:-$cfg}" + + [ "$enabled" = 1 ] || return + + if [ -z "$device" ] || [ -z "$spec" ]; then + _log "rule $desc: 'device' and 'spec' are required" + return + fi + + if [ -z "$pref" ]; then + _log "rule $desc: 'pref' is required (needed to remove the rule again)" + return + fi + + if [ ! -e "/sys/class/net/$device" ]; then + _log "rule $desc: device '$device' not present, skipped" + return + fi + + # already installed (e.g. re-run from a hotplug event) - just track it + if $TC filter show dev "$device" ingress pref "$pref" 2>/dev/null | grep -q .; then + _state_record "$device" "$pref" + return + fi + + # add the clsact qdisc if it is not already there; never 'replace', + # that would flush filters installed by anything else + $TC qdisc add dev "$device" clsact 2>/dev/null + + # turn the spec string into argv for tc: split it on whitespace into + # positional parameters. "set -f" disables globbing first so a shell + # metacharacter in the spec (e.g. "*") is passed through verbatim; + # "set +f" restores it afterwards. + set -f + # shellcheck disable=SC2086 # deliberate word-split of the tc spec + set -- $spec + set +f + + if err=$($TC filter add dev "$device" ingress pref "$pref" "$@" 2>&1); then + _state_record "$device" "$pref" + _log "rule $desc: added on $device (pref $pref)" + else + # tc dumps its whole usage on a parse error - keep the first line only + err=$(printf '%s\n' "$err" | sed -n '1p' | cut -c1-200) + _log "rule $desc: tc rejected the spec: ${err:-unknown error}" + fi +} + +# config_foreach helper for reapply_dev: apply section $1 only if its device +# equals $2 (target_dev). +_reapply_one() { + local cfg="$1" + local target_dev="$2" + + local device + + config_get device "$cfg" device + + [ "$device" = "$target_dev" ] || return + + _rule_apply "$cfg" +} + +# config_foreach helper for show: collect each rule's device into TCF_DEVS +# without duplicates. $1 = config section name. +_collect_dev() { + local cfg="$1" + + local device + + config_get device "$cfg" device + + [ -n "$device" ] || return + + case " $TCF_DEVS " in + *" $device "*) ;; + *) TCF_DEVS="$TCF_DEVS $device" ;; + esac +} + +start_service() { + local enabled + + _lock + + config_load tcfilter + config_get_bool enabled global enabled 1 + + if [ "$enabled" != 1 ]; then + _log "disabled via tcfilter.global.enabled=0" + return + fi + + # create if missing, but never truncate: records of rules that have + # meanwhile been removed from the config are needed by a later + # stop/reload to tear the filters down. The file also doubles as the + # "service is up" marker for the ifup hotplug. + [ -f "$STATE" ] || touch "$STATE" + + config_foreach _rule_apply rule +} + +stop_service() { + local device pref + + _lock + + config_load tcfilter + + _all_pairs | while read -r device pref; do + [ -n "$device" ] && [ -n "$pref" ] && _del_filter "$device" "$pref" + done + + rm -f "$STATE" +} + +reload_service() { + stop_service + start_service +} + +service_triggers() { + procd_add_reload_trigger tcfilter +} + +reapply_dev() { + local target_dev="$1" + + local device pref enabled tmp + + if [ -z "$target_dev" ]; then + echo "usage: $initscript reapply_dev " >&2 + return 1 + fi + + _lock + + config_load tcfilter + config_get_bool enabled global enabled 1 + # an extra_command's return value is the script's exit status, so a + # clean "globally disabled, nothing to do" must be 0 + [ "$enabled" = 1 ] || return 0 + + _all_pairs | while read -r device pref; do + [ "$device" = "$target_dev" ] && _del_filter "$device" "$pref" + done + + # rewrite the state file without this device's lines, keeping the rest. + # truncate tmp to empty (a stale $STATE.$$ from an interrupted run with + # the same pid must not be appended to) so the mv still works when every + # line belonged to target_dev. + if [ -f "$STATE" ]; then + tmp="$STATE.$$" + printf '' > "$tmp" + while read -r device pref; do + [ "$device" = "$target_dev" ] || echo "$device $pref" >> "$tmp" + done < "$STATE" + mv "$tmp" "$STATE" + fi + + config_foreach _reapply_one rule "$target_dev" +} + +show() { + local device + + TCF_DEVS="" + + config_load tcfilter + config_foreach _collect_dev rule + + for device in $TCF_DEVS; do + if [ ! -e "/sys/class/net/$device" ]; then + echo "== $device (absent) ==" + echo + continue + fi + + echo "== $device ingress ==" + $TC -s filter show dev "$device" ingress + echo + done +}