]> git.99rst.org Git - openwrt-packages.git/commitdiff
tcfilter: add package for persistent tc ingress filters
authorMark Abe <redacted>
Sun, 6 Sep 2026 22:45:53 +0000 (00:45 +0200)
committerFlorian Eckert <redacted>
Wed, 9 Sep 2026 10:41:29 +0000 (12:41 +0200)
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 <redacted>
Assisted-by: Claude Code (Anthropic Claude Sonnet 5)
Signed-off-by: Mark Abe <redacted>
net/tcfilter/Makefile [new file with mode: 0644]
net/tcfilter/README.md [new file with mode: 0644]
net/tcfilter/files/tcfilter.config [new file with mode: 0644]
net/tcfilter/files/tcfilter.hotplug-iface [new file with mode: 0644]
net/tcfilter/files/tcfilter.hotplug-net [new file with mode: 0644]
net/tcfilter/files/tcfilter.init [new file with mode: 0644]

diff --git a/net/tcfilter/Makefile b/net/tcfilter/Makefile
new file mode 100644 (file)
index 0000000..0b174a8
--- /dev/null
@@ -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 <github@mab.wien>
+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 (file)
index 0000000..cc8c49c
--- /dev/null
@@ -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 <device> ingress pref <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 (file)
index 0000000..c92967d
--- /dev/null
@@ -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 <device> ingress pref <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 (file)
index 0000000..a22b3db
--- /dev/null
@@ -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 (file)
index 0000000..18c5dc8
--- /dev/null
@@ -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 (file)
index 0000000..b3b2acf
--- /dev/null
@@ -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" "<device>  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 <device>" >&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
+}
git clone https://git.99rst.org/PROJECT