--- /dev/null
+# 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))
--- /dev/null
+# 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.
--- /dev/null
+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'
--- /dev/null
+# 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
--- /dev/null
+# 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"
--- /dev/null
+#!/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
+}