🔥 Sync 2026-11-04 02:13:23

This commit is contained in:
github-actions[bot] committed 2026-11-04 02:13:23 +08:00
1 parent e281fb4f73
commit bfc1ef0244
65 files changed
+1112 -468

No files matched your search

@@ -279,11 +279,29 @@ if command -v ip >/dev/null 2>&1; then
done
fi
CONNS_OUT=$(echo "$CONNTRACK_DATA" | awk -v ip="$IP" -v pf="$PROTO_FILTER" -v oifmap="$OIF_MAP" '
# The kernel device is not what the user named the interface: mwan3 egress is
# "awg3", the network is "Finland" (#10). The uci name comes from the role map
# trafficctl-ifaces.sh already memoizes in tmpfs — the overview polls it, so it
# is normally warm. Built here only when it is missing and there is something
# to label, never per connection.
IFROLE_CACHE="${TCTL_IFROLE_CACHE:-/tmp/trafficctl_ifroles}"
if [ -s "$OIF_MAP" ] && [ ! -s "$IFROLE_CACHE" ]; then
/usr/local/bin/trafficctl-ifaces.sh >/dev/null 2>&1
fi
CONNS_OUT=$(echo "$CONNTRACK_DATA" | awk -v ip="$IP" -v pf="$PROTO_FILTER" -v oifmap="$OIF_MAP" -v ifroles="$IFROLE_CACHE" '
BEGIN {
n=0
while ((getline line < oifmap) > 0) { split(line, p, " "); oifdev[p[1]] = p[2] }
close(oifmap)
# "dev role tunnel primary defroute enslaved label", label "-" when the
# device has no uci interface. Header lines start with "#".
while ((getline line < ifroles) > 0) {
if (substr(line, 1, 1) == "#") continue
if (split(line, f, " ") < 7 || f[7] == "-") continue
oiflbl[f[1]] = f[7]
}
close(ifroles)
}
{
proto=""
@@ -325,10 +343,11 @@ BEGIN {
else if (dport == "993") svc="imaps"
else if (dport == "8080") svc="http-alt"
oif = ""
if (dst in oifdev) oif = oifdev[dst]
odev = ""; oif = ""
if (dst in oifdev) { odev = oifdev[dst]; oif = odev }
if (odev in oiflbl) oif = oiflbl[odev]
if (n > 0) printf ","
printf "{\"proto\":\"%s\",\"dst\":\"%s\",\"host\":\"\",\"port\":%s,\"service\":\"%s\",\"bytes\":%.0f,\"state\":\"%s\",\"oif\":\"%s\"}", proto, dst, dport, svc, bytes, state, oif
printf "{\"proto\":\"%s\",\"dst\":\"%s\",\"host\":\"\",\"port\":%s,\"service\":\"%s\",\"bytes\":%.0f,\"state\":\"%s\",\"oif\":\"%s\",\"oif_dev\":\"%s\"}", proto, dst, dport, svc, bytes, state, oif, odev
n++
}
')
@@ -129,7 +129,7 @@ tctl_ratelimit_add() {
local rate_kbyte_up=$((rate_kbit_up / 8))
[ "$rate_kbyte_up" -lt 1 ] && rate_kbyte_up=1
local slug dl_expr ul_expr ul6_expr mac
local slug dl_expr ul_expr ul6_expr dl6_expr feed6_expr v6set mac
slug=$(tctl_target_slug "$ip")
# The two directions were already separate rules on separate hooks — they
# were symmetric only because both took the same number.
@@ -141,12 +141,9 @@ tctl_ratelimit_add() {
ul_expr="ip saddr $ip limit rate over ${rate_kbyte_up} kbytes/second"
fi
# Upload over IPv6, keyed on the MAC. This direction and only this one:
# at LAN ingress the frame is still the client's own, so "ether saddr"
# identifies it whatever address family it is using. Download over IPv6 is
# policed at LAN egress, where the destination MAC is the next hop's — it
# needs the device's current v6 address set and is deliberately not
# attempted here.
# Upload over IPv6, keyed on the MAC: at LAN ingress the frame is still
# the client's own, so "ether saddr" identifies it whatever address family
# it is using.
#
# Scoped to IPv6 on purpose: IPv4 keeps being policed by exactly the rule it
# always was, so nothing about existing behaviour depends on whether this
@@ -161,13 +158,41 @@ tctl_ratelimit_add() {
# against nftables 1.1.1 / kernel 6.6). nfproto is what tctl_block_add uses,
# because that rule lives in inet fw4. Same intent, different family, and
# the wrong one does not silently under-match — it fails to load.
ul6_expr=""
#
# Download over IPv6 cannot be keyed on the MAC: at LAN egress the L2
# header is not built yet. It is matched on the device's v6 ADDRESSES,
# which is only sound if the set of them can never go stale — SLAAC
# privacy extensions rotate them, and a set that misses the current one
# is a silent bypass. So the set is not written from a neighbour-table
# snapshot that something would have to keep refreshing; the data path
# fills it. Every v6 packet the client sends crosses LAN ingress with its
# own MAC, and the feed rule there adds its source address with a timeout
# ("update" re-arms it on every packet, so an address in use never
# expires). A new temporary address is in the set from its first packet,
# one that stopped being used drops out after the timeout. Download that
# no packet from the client ever preceded is the only thing that is not
# covered, and for TCP there is none. Link-local is left out: it never
# crosses the router.
#
# Verified on kernel 6.12 / nftables 1.0.9 with a client in its own netns
# behind a bridge: one ping from a privacy address put that address in the
# set, and a 5 Mbit/s limit held an iperf3 download to it at ~6 Mbit/s.
ul6_expr=""; dl6_expr=""; feed6_expr=""
v6set="tctl_v6_$slug"
if mac=$(tctl_target_mac "$ip"); then
ul6_expr="meta protocol ip6 ether saddr $mac limit rate over ${rate_kbyte_up} kbytes/second"
feed6_expr="meta protocol ip6 ether saddr $mac ip6 saddr != fe80::/10 update @$v6set { ip6 saddr }"
dl6_expr="meta protocol ip6 ip6 daddr @$v6set limit rate over ${rate_kbyte} kbytes/second"
fi
if [ "$TCTL_FW" = "nft" ]; then
nft add table netdev tm_ratelimit 2>/dev/null
# The set has to exist before any rule that names it. Without it the
# v6 download half is skipped and reported as such, never half-built.
if [ -n "$dl6_expr" ] && ! nft add set netdev tm_ratelimit "$v6set" \
"{ type ipv6_addr; flags dynamic,timeout; timeout ${TCTL_V6SET_TIMEOUT:-1h}; }" 2>/dev/null; then
feed6_expr=""; dl6_expr=""
fi
# Download is policed at LAN EGRESS, not WAN ingress.
#
@@ -183,7 +208,7 @@ tctl_ratelimit_add() {
# the IP stack, and the port-level egress hook never fires for it.
# Verified on kernel 6.12: br-lan egress counted every packet, the
# ports counted none.
local dev chain dl_ok=0 wan_dev
local dev chain dl_ok=0 dl6_ok=0 wan_dev
for dev in $(tctl_get_lan_devices); do
chain=$(tctl_egress_chain "$dev")
nft add chain netdev tm_ratelimit "$chain" \
@@ -191,15 +216,23 @@ tctl_ratelimit_add() {
nft add rule netdev tm_ratelimit "$chain" \
"$dl_expr counter drop comment \"$comment\"" 2>/dev/null \
&& dl_ok=1
[ -n "$dl6_expr" ] || continue
nft add rule netdev tm_ratelimit "$chain" \
"$dl6_expr counter drop comment \"${comment}_dl6\"" 2>/dev/null \
&& dl6_ok=1
done
# Kernels older than 5.16 have no netdev egress hook; fall back to WAN
# ingress, which is correct as long as the client isn't masqueraded.
# IPv6 is not masqueraded at all, so the v6 rule is correct there too.
if [ "$dl_ok" = "0" ] && wan_dev=$(tctl_get_wan_device); then
nft add chain netdev tm_ratelimit dl \
"{ type filter hook ingress device $wan_dev priority -200; policy accept; }" 2>/dev/null
nft add rule netdev tm_ratelimit dl \
"$dl_expr counter drop comment \"$comment\"" 2>/dev/null \
&& dl_ok=1
[ -n "$dl6_expr" ] && nft add rule netdev tm_ratelimit dl \
"$dl6_expr counter drop comment \"${comment}_dl6\"" 2>/dev/null \
&& dl6_ok=1
fi
# Set for the caller to inspect (see trafficctl-ratelimit.sh).
# shellcheck disable=SC2034
@@ -208,7 +241,7 @@ tctl_ratelimit_add() {
# One chain per ingress device: a device that refuses the hook then
# only loses its own chain instead of taking the whole set with it.
local dev chain
local ul_ok=0 ul6_ok=0
local ul_ok=0 ul6_ok=0 feed6_ok=0
for dev in $(tctl_ingress_devices); do
chain=$(tctl_ingress_chain "$dev")
nft add chain netdev tm_ratelimit "$chain" \
@@ -217,6 +250,11 @@ tctl_ratelimit_add() {
"$ul_expr counter drop comment \"${comment}_ul\"" 2>/dev/null \
&& ul_ok=1
[ -n "$ul6_expr" ] || continue
# Feed before police, so a packet the upload limit drops still
# keeps its address in the download set.
[ -n "$feed6_expr" ] && nft add rule netdev tm_ratelimit "$chain" \
"$feed6_expr comment \"${comment}_v6\"" 2>/dev/null \
&& feed6_ok=1
nft add rule netdev tm_ratelimit "$chain" \
"$ul6_expr counter drop comment \"${comment}_ul6\"" 2>/dev/null \
&& ul6_ok=1
@@ -226,11 +264,21 @@ tctl_ratelimit_add() {
# Set for the caller to report: "limited" means v4 only unless this is 1.
# shellcheck disable=SC2034
TCTL_RL_UPLOAD6_OK="$ul6_ok"
# A download rule over a set nothing feeds matches nothing, so it only
# counts when both halves loaded.
# shellcheck disable=SC2034
if [ "$dl6_ok" = "1" ] && [ "$feed6_ok" = "1" ]; then
TCTL_RL_DOWNLOAD6_OK=1
else
TCTL_RL_DOWNLOAD6_OK=0
fi
else
# fw3/iptables stays IPv4-only. A half-built ip6tables path would be
# worse than a stated gap; see docs/COMPATIBILITY.md.
# shellcheck disable=SC2034
TCTL_RL_UPLOAD6_OK=0
# shellcheck disable=SC2034
TCTL_RL_DOWNLOAD6_OK=0
iptables -t mangle -A FORWARD -d "$ip" -m hashlimit \
--hashlimit-above "${rate_kbit}kbit/sec" --hashlimit-burst "${rate_kbit}kbit" \
--hashlimit-mode dstip --hashlimit-name "rl_${comment}" \
@@ -247,9 +295,11 @@ tctl_ratelimit_remove() {
local chain h
if [ "$TCTL_FW" = "nft" ]; then
# Scan the whole table once: rules for this IP live in dl (daddr), in
# one ul_<dev> chain per ingress device (saddr), and — for a host with
# a known MAC — a second, IPv6-scoped rule in each of those (_ul6).
# Scan the whole table once: rules for this IP live in the dl chains
# (daddr, plus _dl6 over the v6 address set), in one ul_<dev> chain per
# ingress device (saddr), and — for a host with a known MAC — the
# IPv6-scoped _ul6 rule and the _v6 rule feeding the set in each of
# those.
#
# Each comment is matched in FULL, closing quote included. The suffixes
# are prefixes of one another ("_ul" of "_ul6"), and every comment ends
@@ -258,12 +308,15 @@ tctl_ratelimit_remove() {
nft -a list table netdev tm_ratelimit 2>/dev/null | awk -v cmt="$comment" '
/^[ \t]*chain [a-zA-Z0-9_]+ \{/ { chain = $2; next }
index($0, "\"" cmt "\"") || index($0, "\"" cmt "_ul\"") ||
index($0, "\"" cmt "_ul6\"") {
index($0, "\"" cmt "_ul6\"") || index($0, "\"" cmt "_dl6\"") ||
index($0, "\"" cmt "_v6\"") {
for (i = 1; i < NF; i++)
if ($i == "handle") { print chain, $(i+1); break }
}' | while read -r chain h; do
[ -n "$chain" ] && [ -n "$h" ] && nft delete rule netdev tm_ratelimit "$chain" handle "$h" 2>/dev/null
done
# Only once nothing references it: nft refuses to delete a set in use.
nft delete set netdev tm_ratelimit "tctl_v6_$(tctl_target_slug "$ip")" 2>/dev/null
else
while iptables -t mangle -D FORWARD -d "$ip" -m comment --comment "$comment" 2>/dev/null; do :; done
while iptables -t mangle -D FORWARD -s "$ip" -m comment --comment "${comment}_ul" 2>/dev/null; do :; done
@@ -931,6 +984,33 @@ TCTL_SEEN_MAX=1000
# TCTL_LEASES_FILE is set once at the top of this file (overridable for tests).
TCTL_SHAPES_FILE="/etc/trafficctl/shapes.json"
# "<ip> <classid>" for every shape in shapes.json — the only way from an
# address to its HTB class, because minors are ALLOCATED (see
# trafficctl-shape.sh), not derived from the address. Readers that derived
# them anyway showed one device's rate on another.
#
# Same record pattern as shapes_entries in trafficctl-shape.sh, optional
# rate_kbit_up included: a pattern that misses a record form makes that shape
# vanish from every reader. A record from before allocation carries no
# classid, and its class is still the one the old derivation gave it
# (legacy_classid there), so that is what it maps to.
tctl_shape_classmap() {
grep -oE '\{"ip":"[^"]+","rate_kbit":[0-9]+(,"classid":"1:[0-9a-f]+")?(,"rate_kbit_up":[0-9]+)?\}' \
"$TCTL_SHAPES_FILE" 2>/dev/null | awk '
{
if (!match($0, /"ip":"[^"]+"/)) next
ip = substr($0, RSTART + 6, RLENGTH - 7)
if (match($0, /"classid":"1:[0-9a-f]+"/)) {
print ip, substr($0, RSTART + 11, RLENGTH - 12)
next
}
if (split(ip, o, ".") != 4) next
dec = o[3] * 256 + o[4]
if (dec < 2 || dec > 65533) next
printf "%s 1:%x\n", ip, dec
}'
}
tctl_seen_normalize() {
printf '%s' "$1" | tr 'A-Z' 'a-z'
}
@@ -70,17 +70,24 @@ else
TCTL_RL_DOWNLOAD_FAILED=0
TCTL_RL_UPLOAD_FAILED=0
TCTL_RL_UPLOAD6_OK=0
TCTL_RL_DOWNLOAD6_OK=0
tctl_ratelimit_add "$IP" "$RATE" "$COMMENT" "$MODE" "$RATE_UP"
# IPv6 coverage is partial by construction and the operator has to know
# which part: upload is policed on the MAC, download is not policed at all
# over v6 (see tctl_ratelimit_add). Claiming "both directions" without
# this note is how issue #67 stayed invisible.
if [ "$TCTL_RL_UPLOAD6_OK" = "1" ]; then
# IPv6 coverage depends on whether the target has a MAC to key on, and
# each half can fail to load on its own. The operator has to know which
# part is live: claiming "both directions" without this note is how issue
# #67 stayed invisible.
if [ "$TCTL_RL_UPLOAD6_OK" = "1" ] && [ "$TCTL_RL_DOWNLOAD6_OK" = "1" ]; then
V6NOTE=" [IPv6: both directions]"
elif [ "$TCTL_RL_UPLOAD6_OK" = "1" ]; then
V6NOTE=" [IPv6: upload only]"
elif [ "$TCTL_RL_DOWNLOAD6_OK" = "1" ]; then
V6NOTE=" [IPv6: download only]"
else
V6NOTE=" [IPv4 only]"
fi
V6UP=$([ "$TCTL_RL_UPLOAD6_OK" = "1" ] && echo true || echo false)
V6DN=$([ "$TCTL_RL_DOWNLOAD6_OK" = "1" ] && echo true || echo false)
# A half-applied limit is a silent trap: report exactly which direction
# is live rather than claiming success for both.
@@ -108,10 +115,12 @@ else
NSS="$(tctl_nss_status)"
PERSIST=$(tctl_persist_enabled && echo true || echo false)
if [ "$TCTL_RL_DOWNLOAD_FAILED" = "1" ]; then
echo "{\"ok\":true,\"ipv6_upload\":$([ "$TCTL_RL_UPLOAD6_OK" = "1" ] && echo true || echo false),\"nss\":\"$NSS\",\"persist\":$PERSIST,\"msg\":\"rate limit ${RATE_UP} kbit/s applied to $IP UPLOAD ONLY — WAN device not resolvable$V6NOTE$NSSNOTE\"}"
echo "{\"ok\":true,\"ipv6_upload\":$V6UP,\"ipv6_download\":$V6DN,\"nss\":\"$NSS\",\"persist\":$PERSIST,\"msg\":\"rate limit ${RATE_UP} kbit/s applied to $IP UPLOAD ONLY — WAN device not resolvable$V6NOTE$NSSNOTE\"}"
elif [ "$TCTL_RL_UPLOAD_FAILED" = "1" ]; then
echo "{\"ok\":true,\"ipv6_upload\":false,\"nss\":\"$NSS\",\"persist\":$PERSIST,\"msg\":\"rate limit ${RATE} kbit/s applied to $IP DOWNLOAD ONLY — no LAN ingress device [IPv4 only]$NSSNOTE\"}"
# No ingress device means nothing feeds the v6 download set either, so
# tctl_ratelimit_add has already reported that half as not live.
echo "{\"ok\":true,\"ipv6_upload\":false,\"ipv6_download\":false,\"nss\":\"$NSS\",\"persist\":$PERSIST,\"msg\":\"rate limit ${RATE} kbit/s applied to $IP DOWNLOAD ONLY — no LAN ingress device [IPv4 only]$NSSNOTE\"}"
else
echo "{\"ok\":true,\"ipv6_upload\":$([ "$TCTL_RL_UPLOAD6_OK" = "1" ] && echo true || echo false),\"nss\":\"$NSS\",\"persist\":$PERSIST,\"msg\":\"rate limit $(rate_text) for $IP (both directions, $MODE)$V6NOTE$NSSNOTE\"}"
echo "{\"ok\":true,\"ipv6_upload\":$V6UP,\"ipv6_download\":$V6DN,\"nss\":\"$NSS\",\"persist\":$PERSIST,\"msg\":\"rate limit $(rate_text) for $IP (both directions, $MODE)$V6NOTE$NSSNOTE\"}"
fi
fi
@@ -21,9 +21,14 @@ fi
# Map classid → real IP from persisted shapes. Minors are allocated, not derived
# from the address, so this map is the only way back to an IP; classes missing
# from it belong to another tc user and are skipped rather than mislabelled.
CLASSMAP=$(grep -oE '\{"ip":"[^"]+","rate_kbit":[0-9]+,"classid":"1:[0-9a-f]+"\}' \
/etc/trafficctl/shapes.json 2>/dev/null | \
sed -n 's/{"ip":"\([^"]*\)","rate_kbit":[0-9]*,"classid":"\(1:[0-9a-f]*\)"}/\2 \1/p')
# The pattern used to require the record to end at the classid, so every shape
# with its own upload ceiling ("rate_kbit_up") was dropped from this output,
# and with it from tctl_has_limit.
#
# Flattened to ONE "classid ip classid ip …" line: a multi-line value passed
# through `awk -v` is rejected by some awks ("newline in string"), which left
# this output empty. Neither field can contain a space.
CLASSMAP=$(tctl_shape_classmap | awk '{ printf "%s %s ", $2, $1 }')
# Collect class stats into a temp file so we can merge with qdisc stats
CLASS_DATA=$(tc -s class show dev "$LAN_DEV" 2>/dev/null)
@@ -32,11 +37,9 @@ QDISC_DATA=$(tc -s qdisc show dev "$LAN_DEV" 2>/dev/null)
# Parse class stats: emit lines "classid ip rate bytes pkts backlog drops overlimits requeues lended borrowed"
CLASS_PARSED=$(echo "$CLASS_DATA" | awk -v classmap="$CLASSMAP" '
BEGIN {
n = split(classmap, cml, "\n")
for (ci = 1; ci <= n; ci++) {
split(cml[ci], cmf, " ")
if (cmf[1] != "" && cmf[2] != "") ipmap[cmf[1]] = cmf[2]
}
n = split(classmap, cmf, " ")
for (ci = 1; ci + 1 <= n; ci += 2)
if (cmf[ci] != "" && cmf[ci + 1] != "") ipmap[cmf[ci]] = cmf[ci + 1]
}
/class fq_codel/ { skip = 1; next }
@@ -242,6 +242,9 @@ if command -v tc >/dev/null 2>&1; then
}')
fi
# address → classid, from shapes.json (see tctl_shape_classmap).
SHAPE_MAP=$(tctl_shape_classmap)
# Wifi ACL state, split by policy, because "listed" means opposite things in the
# two modes: on a deny (blacklist) radio a listed MAC is blocked, on an allow
# (whitelist) radio a listed MAC is the only kind permitted. Collected once here
@@ -355,14 +358,14 @@ lookup_rate_limit() {
echo "${r:-0}"
}
# The classid is looked up, never derived from the address: minors are
# allocated, so deriving put the first shaped device's rate on 192.168.x.2 and
# showed 0 for the device actually shaped — and the Telegram bot built its
# "Shape — Remove" button from this field.
lookup_shape_kbit() {
local ip="$1" o3 o4 dec hex classid r
o3=$(echo "$ip" | cut -d. -f3)
o4=$(echo "$ip" | cut -d. -f4)
dec=$((o3 * 256 + o4))
hex=$(printf "%x" "$dec")
case "$hex" in 1|fffe) echo 0; return ;; esac
classid="1:$hex"
local ip="$1" classid r
classid=$(echo "$SHAPE_MAP" | awk -v ip="$ip" '$1==ip{print $2; exit}')
[ -n "$classid" ] || { echo 0; return; }
r=$(echo "$TC_MAP" | awk -v c="$classid" '$1==c{print $2; exit}')
echo "${r:-0}"
}