🤞 Sync 2026-09-30 09:28:31

This commit is contained in:
github-actions[bot] committed 2026-09-30 09:28:31 +08:00
1 parent 0108cfa10a
commit 3d9edb1c37
78 files changed
+4594 -1815

No files matched your search

@@ -35,15 +35,33 @@ if tctl_is_blocked "$IP"; then
exit 0
fi
TCTL_BLOCK_IPV6=0
if tctl_block_add "$IP" "$COMMENT"; then
conntrack -D -s "$IP" >/dev/null 2>&1
conntrack -D -d "$IP" >/dev/null 2>&1
# The v4 flush above leaves every established IPv6 flow running, and with
# flow offload those are never re-evaluated against the new rule — the
# device would stay online over v6 indefinitely while the UI reads blocked.
MAC=$(tctl_target_mac "$IP" 2>/dev/null) && tctl_conntrack_flush_v6 "$MAC"
tctl_persist_enabled && tctl_persist_save "block" "$IP" "$LABEL"
tctl_log "block" "$IP" "$LABEL" "${TCTL_VIA:-cli}" "${TCTL_SRC:-local}"
if [ "$SELF_BLOCK" = "true" ]; then
echo "{\"ok\":true,\"msg\":\"internet blocked for $IP (your device — LuCI access preserved)\"}"
# Say which families are actually covered. Reporting plain success while
# half the traffic walks past the rule is the bug this fixes, so the
# no-MAC case is stated rather than left to be discovered (same reasoning
# as the half-applied-limit messages in trafficctl-ratelimit.sh).
if [ "$TCTL_BLOCK_IPV6" = "1" ]; then
NOTE=""
elif tctl_ip_is_nexthop "$IP"; then
NOTE=" — IPv4 only: $IP is a routed next hop, and a MAC-keyed IPv6 rule would also block everything behind it"
else
echo "{\"ok\":true,\"msg\":\"internet blocked for $IP\"}"
NOTE=" — IPv4 only: no MAC known for $IP (no DHCP lease, no neighbour entry), so IPv6 is not covered"
fi
if [ "$SELF_BLOCK" = "true" ]; then
echo "{\"ok\":true,\"ipv6\":$([ "$TCTL_BLOCK_IPV6" = "1" ] && echo true || echo false),\"msg\":\"internet blocked for $IP (your device — LuCI access preserved)$NOTE\"}"
else
echo "{\"ok\":true,\"ipv6\":$([ "$TCTL_BLOCK_IPV6" = "1" ] && echo true || echo false),\"msg\":\"internet blocked for $IP$NOTE\"}"
fi
else
echo "{\"ok\":false,\"msg\":\"failed to block $IP\"}"
@@ -127,9 +127,13 @@ BLOCK_BYTES=0
if tctl_is_blocked "$IP"; then
BLOCKED=true
if [ "$TCTL_FW" = "nft" ]; then
block_line=$(nft list chain inet fw4 forward 2>/dev/null | grep "ip saddr $IP.*drop")
BLOCK_PACKETS=$(echo "$block_line" | grep -oE 'packets [0-9]+' | awk '{print $2}')
BLOCK_BYTES=$(echo "$block_line" | grep -oE 'bytes [0-9]+' | awk '{print $2}')
# A block is two rules — the v4 one keyed on the address and the v6 one
# keyed on the MAC (see tctl_block_add) — so the counters are summed
# across both. The v6 rule carries no address, only its "_mac" comment.
block_line=$(nft list chain inet fw4 forward 2>/dev/null \
| grep -e "ip saddr $IP.*drop" -e "comment \"$(tctl_block_comment "$IP")_mac\"")
BLOCK_PACKETS=$(echo "$block_line" | grep -oE 'packets [0-9]+' | awk '{ t += $2 } END { printf "%.0f", t }')
BLOCK_BYTES=$(echo "$block_line" | grep -oE 'bytes [0-9]+' | awk '{ t += $2 } END { printf "%.0f", t }')
else
block_line=$(iptables -L FORWARD -nvx 2>/dev/null | grep "DROP" | grep "$IP")
BLOCK_PACKETS=$(echo "$block_line" | awk '{print $1}')
@@ -10,6 +10,102 @@ else
TCTL_FW="iptables"
fi
# ── MAC-keyed matching (address-family independent) ────────────────────────
#
# Everything in this package is keyed on the IPv4 address a DHCP lease gives a
# device, and every enforcement rule matches "ip saddr"/"ip daddr" — so IPv6
# walks past all of it (issue #67: 9.96 Mbit/s over v4 against a 10 Mbit/s cap,
# 151 Mbit/s over v6 to the same endpoint).
#
# The fix is NOT "add ip6 saddr next to ip saddr": with SLAAC and privacy
# extensions a client holds several v6 addresses at once and rotates them on a
# timer, so a rule written against one stops matching without warning — the
# same silent bypass, reached more slowly. The MAC does not rotate, and on the
# hooks used here the client's own frame is still intact, so it is what the v6
# rules are keyed on.
#
# Only the directions where that holds are covered; see tctl_block_add and
# tctl_ratelimit_add for which, and README/COMPATIBILITY for what is not.
# Named by absolute path, as it must be on the router; the tests sed this line
# into their scratch directory rather than have the shipped script carry a
# test-only override (same approach as the ledger and shapes paths below).
TCTL_LEASES_FILE="/tmp/dhcp.leases"
# The MAC behind an IPv4 address: DHCP lease first, neighbour table second.
# Fails (non-zero, no output) when neither knows it — a routed/downstream
# client has no lease and no neighbour entry here, and there is nothing to key
# a rule on. Callers must report that rather than assume coverage.
#
# The result is validated strictly, not merely trimmed: it is interpolated
# into an nft rule string, so anything but six hex pairs is refused.
tctl_lookup_mac() {
local addr="$1" mac
tctl_validate_ip "$addr" || return 1
mac=$(awk -v ip="$addr" '$3 == ip { print $2; exit }' "$TCTL_LEASES_FILE" 2>/dev/null)
[ -z "$mac" ] && mac=$(ip neigh show 2>/dev/null | awk -v ip="$addr" '
$1 == ip { for (i = 1; i < NF; i++) if ($i == "lladdr") { print $(i+1); exit } }')
mac=$(printf '%s' "$mac" | tr 'A-F' 'a-f')
case "$mac" in
[0-9a-f][0-9a-f]:[0-9a-f][0-9a-f]:[0-9a-f][0-9a-f]:[0-9a-f][0-9a-f]:[0-9a-f][0-9a-f]:[0-9a-f][0-9a-f]) ;;
*) return 1 ;;
esac
printf '%s\n' "$mac"
}
# True when this address is the next hop of a route, i.e. a router rather than
# an end device. Its MAC is the source of every packet it FORWARDS, so a
# MAC-keyed rule aimed at it would also hit every client behind it — blocking
# one device would black-hole a whole subnet. Those addresses are excluded and
# the caller says so out loud.
tctl_ip_is_nexthop() {
local addr="$1"
[ -n "$addr" ] || return 1
ip route show 2>/dev/null | awk -v n="$addr" '
{ for (i = 1; i < NF; i++) if ($i == "via" && $(i+1) == n) { hit = 1; exit } }
END { exit hit ? 0 : 1 }'
}
# The MAC to key a target's IPv6 rules on, or failure. A CIDR target has no
# single MAC; a next hop's MAC is not its own traffic.
tctl_target_mac() {
local t="$1"
case "$t" in
*/32) t=${t%/32} ;;
*/*) return 1 ;;
esac
tctl_ip_is_nexthop "$t" && return 1
tctl_lookup_mac "$t"
}
# Flush a device's live IPv6 flows.
#
# "conntrack -D -s <v4addr>" only touches the IPv4 table, so without this an
# established v6 flow survives a block that was just installed — and with flow
# offload it is never re-evaluated by the forward chain again, so it survives
# indefinitely. That is the same "a block that does not block" failure in a new
# place, on the offload-enabled hardware most likely to hit it.
#
# Addresses come from a one-shot neighbour snapshot. Reading them is sound even
# though a RULE written against them would not be: the snapshot cannot go stale
# between here and the next statement, whereas a rule has to stay correct for
# as long as the block lasts.
tctl_conntrack_flush_v6() {
local mac="$1" addr
[ -n "$mac" ] || return 0
command -v conntrack >/dev/null 2>&1 || return 0
ip -6 neigh show 2>/dev/null | awk -v m="$mac" '
{ for (i = 1; i < NF; i++) if ($i == "lladdr" && tolower($(i+1)) == m) { print $1; break } }
' | while read -r addr; do
case "$addr" in
''|*[!0-9a-fA-F:]*) continue ;;
esac
conntrack -D -f ipv6 -s "$addr" >/dev/null 2>&1
conntrack -D -f ipv6 -d "$addr" >/dev/null 2>&1
done
return 0
}
# ── Rate Limiting (policer) ────────────────────────────────────────────────
# Rate limits are symmetric: the same ceiling is policed in both directions.
@@ -27,7 +123,7 @@ tctl_ratelimit_add() {
local rate_kbyte=$((rate_kbit / 8))
[ "$rate_kbyte" -lt 1 ] && rate_kbyte=1
local slug dl_expr ul_expr
local slug dl_expr ul_expr ul6_expr mac
slug=$(tctl_target_slug "$ip")
if [ "$mode" = "each" ]; then
dl_expr="ip daddr $ip meter tctl_d_$slug { ip daddr limit rate over ${rate_kbyte} kbytes/second }"
@@ -37,6 +133,31 @@ tctl_ratelimit_add() {
ul_expr="ip saddr $ip limit rate over ${rate_kbyte} 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.
#
# 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
# lookup succeeds — and an unscoped ether rule would police a dual-stack
# client's v4 traffic TWICE, halving the ceiling it was given. Its own
# bucket, too: sharing one would make the v4 and v6 halves cannibalise each
# other's allowance, which is not what "10 Mbit/s" has ever meant here.
#
# The scope is "meta protocol ip6", NOT "meta nfproto ipv6" — these chains
# are in the NETDEV family, where nft rejects nfproto outright ("meta
# nfproto is only useful in the inet family", verified with nft --check
# 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=""
if mac=$(tctl_target_mac "$ip"); then
ul6_expr="meta protocol ip6 ether saddr $mac limit rate over ${rate_kbyte} kbytes/second"
fi
if [ "$TCTL_FW" = "nft" ]; then
nft add table netdev tm_ratelimit 2>/dev/null
@@ -79,7 +200,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
local ul_ok=0 ul6_ok=0
for dev in $(tctl_ingress_devices); do
chain=$(tctl_ingress_chain "$dev")
nft add chain netdev tm_ratelimit "$chain" \
@@ -87,10 +208,21 @@ tctl_ratelimit_add() {
nft add rule netdev tm_ratelimit "$chain" \
"$ul_expr counter drop comment \"${comment}_ul\"" 2>/dev/null \
&& ul_ok=1
[ -n "$ul6_expr" ] || continue
nft add rule netdev tm_ratelimit "$chain" \
"$ul6_expr counter drop comment \"${comment}_ul6\"" 2>/dev/null \
&& ul6_ok=1
done
# shellcheck disable=SC2034
[ "$ul_ok" = "1" ] || TCTL_RL_UPLOAD_FAILED=1
# Set for the caller to report: "limited" means v4 only unless this is 1.
# shellcheck disable=SC2034
TCTL_RL_UPLOAD6_OK="$ul6_ok"
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
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}" \
@@ -107,11 +239,18 @@ tctl_ratelimit_remove() {
local chain h
if [ "$TCTL_FW" = "nft" ]; then
# Scan the whole table once: rules for this IP live in dl (daddr) and
# in one ul_<dev> chain per ingress device (saddr).
# 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).
#
# Each comment is matched in FULL, closing quote included. The suffixes
# are prefixes of one another ("_ul" of "_ul6"), and every comment ends
# in an address slug, so a substring match would have a limit on
# 192.168.1.1 delete 192.168.1.10's rules as well.
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 "\"") || index($0, "\"" cmt "_ul\"") ||
index($0, "\"" cmt "_ul6\"") {
for (i = 1; i < NF; i++)
if ($i == "handle") { print chain, $(i+1); break }
}' | while read -r chain h; do
@@ -133,12 +272,45 @@ tctl_ratelimit_list() {
# ── Internet Blocking ──────────────────────────────────────────────────────
# A block is two rules: the address-keyed one for IPv4, and a MAC-keyed one
# scoped to IPv6.
#
# This is the row of issue #67 that matters most. A rate limit that under-
# delivers shows up in a speed test; a block that reports success while the
# device keeps full, unmetered IPv6 access is invisible until it matters — and
# "block internet" is what people reach for as a parental control.
#
# The MAC is resolved HERE rather than in trafficctl-block.sh because three
# callers reach this function: the block script, the Telegram bot, and the
# ifup-lan restore hook. Resolving it in the script would bring every persisted
# block back IPv4-only after each reboot.
#
# "meta nfproto ipv6" keeps the two rules disjoint: IPv4 is still dropped by
# precisely the rule it always was, so a failed MAC lookup costs the v6 half
# and nothing else. (nfproto is correct HERE, in the inet family; the limiter's
# netdev chains need "meta protocol ip6" instead — see tctl_ratelimit_add.)
# `insert`, not `add`, for the same reason the v4 rule uses it — fw4's forward
# chain accepts established/offloaded flows near the top, and a rule appended
# below that is dead.
tctl_block_add() {
local ip="$1" comment="$2"
local ip="$1" comment="$2" mac rc
if [ "$TCTL_FW" = "nft" ]; then
nft insert rule inet fw4 forward "ip saddr $ip counter drop comment \"$comment\""
rc=$?
# Whether the v6 half is live. Read by callers; see trafficctl-block.sh.
# shellcheck disable=SC2034
TCTL_BLOCK_IPV6=0
if mac=$(tctl_target_mac "$ip"); then
nft insert rule inet fw4 forward \
"meta nfproto ipv6 ether saddr $mac counter drop comment \"${comment}_mac\"" \
2>/dev/null && TCTL_BLOCK_IPV6=1
fi
return "$rc"
else
# fw3/iptables stays IPv4-only; see docs/COMPATIBILITY.md.
# shellcheck disable=SC2034
TCTL_BLOCK_IPV6=0
iptables -I FORWARD -s "$ip" -j DROP -m comment --comment "$comment"
fi
}
@@ -149,8 +321,12 @@ tctl_block_remove() {
if [ "$TCTL_FW" = "nft" ]; then
# Match the quoted comment in full: comments end in the address, so a
# substring match on 192.168.1.1 also deletes the rule for 192.168.1.10.
# The IPv6 half carries no address to match on at all, only its own
# "_mac" comment — which is why it is listed explicitly here rather
# than being caught by a looser pattern.
for h in $(nft -a list chain inet fw4 forward 2>/dev/null \
| awk -v cmt="$comment" 'index($0, "\"" cmt "\"") {
| awk -v cmt="$comment" 'index($0, "\"" cmt "\"") ||
index($0, "\"" cmt "_mac\"") {
for (i = 1; i < NF; i++)
if ($i == "handle") { print $(i+1); break }
}'); do
@@ -162,9 +338,17 @@ tctl_block_remove() {
}
tctl_is_blocked() {
local ip="$1"
local ip="$1" dump comment
if [ "$TCTL_FW" = "nft" ]; then
nft list chain inet fw4 forward 2>/dev/null | grep -q "ip saddr $ip .*drop"
dump=$(nft list chain inet fw4 forward 2>/dev/null)
# The v4 rule is still found by address, so blocks written by versions
# that derived the comment from the caller's label keep reading as
# blocked.
echo "$dump" | grep -q "ip saddr $ip .*drop" && return 0
# The v6 rule names a MAC, not an address — its comment is the only
# handle on it, and it is matched in full for the reason above.
comment=$(tctl_block_comment "$ip")
echo "$dump" | grep -qF "comment \"${comment}_mac\""
else
# The source renders as its own column, "ip" or "ip/32"; an unanchored
# match would report 192.168.1.10's rule as belonging to 192.168.1.1.
@@ -699,7 +883,7 @@ tctl_persist_remove() {
# traffic: an entry is appended only for a MAC that is not already listed.
TCTL_SEEN_FILE="/etc/trafficctl/seen_macs"
TCTL_SEEN_MAX=1000
TCTL_LEASES_FILE="/tmp/dhcp.leases"
# TCTL_LEASES_FILE is set once at the top of this file (overridable for tests).
TCTL_SHAPES_FILE="/etc/trafficctl/shapes.json"
tctl_seen_normalize() {
@@ -59,8 +59,19 @@ else
tctl_ratelimit_remove "$IP" "$COMMENT" 2>/dev/null
TCTL_RL_DOWNLOAD_FAILED=0
TCTL_RL_UPLOAD_FAILED=0
TCTL_RL_UPLOAD6_OK=0
tctl_ratelimit_add "$IP" "$RATE" "$COMMENT" "$MODE"
# 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
V6NOTE=" [IPv6: upload only]"
else
V6NOTE=" [IPv4 only]"
fi
# A half-applied limit is a silent trap: report exactly which direction
# is live rather than claiming success for both.
if [ "$TCTL_RL_DOWNLOAD_FAILED" = "1" ] && [ "$TCTL_RL_UPLOAD_FAILED" = "1" ]; then
@@ -70,10 +81,10 @@ else
tctl_persist_enabled && tctl_persist_save "ratelimit" "$IP" "$RATE"
tctl_log "ratelimit_set" "$IP" "${RATE}kbit" "${TCTL_VIA:-cli}" "${TCTL_SRC:-local}"
if [ "$TCTL_RL_DOWNLOAD_FAILED" = "1" ]; then
echo "{\"ok\":true,\"msg\":\"rate limit ${RATE} kbit/s applied to $IP UPLOAD ONLY — WAN device not resolvable\"}"
echo "{\"ok\":true,\"ipv6_upload\":$([ "$TCTL_RL_UPLOAD6_OK" = "1" ] && echo true || echo false),\"msg\":\"rate limit ${RATE} kbit/s applied to $IP UPLOAD ONLY — WAN device not resolvable$V6NOTE\"}"
elif [ "$TCTL_RL_UPLOAD_FAILED" = "1" ]; then
echo "{\"ok\":true,\"msg\":\"rate limit ${RATE} kbit/s applied to $IP DOWNLOAD ONLY — no LAN ingress device\"}"
echo "{\"ok\":true,\"ipv6_upload\":false,\"msg\":\"rate limit ${RATE} kbit/s applied to $IP DOWNLOAD ONLY — no LAN ingress device [IPv4 only]\"}"
else
echo "{\"ok\":true,\"msg\":\"rate limit ${RATE} kbit/s for $IP (both directions, $MODE)\"}"
echo "{\"ok\":true,\"ipv6_upload\":$([ "$TCTL_RL_UPLOAD6_OK" = "1" ] && echo true || echo false),\"msg\":\"rate limit ${RATE} kbit/s for $IP (both directions, $MODE)$V6NOTE\"}"
fi
fi
@@ -303,7 +303,12 @@ lookup_mac() {
lookup_blocked() {
local ip="$1"
if [ "$TCTL_FW" = "nft" ]; then
echo "$FWD_DUMP" | grep -q "ip saddr $ip .*drop" && echo 1 || echo 0
echo "$FWD_DUMP" | grep -q "ip saddr $ip .*drop" && { echo 1; return; }
# The IPv6 half of a block matches a MAC and carries no address, so it
# is found by its own full comment. Without this the table reports "not
# blocked" for a device whose v6 rule is the only one left standing.
echo "$FWD_DUMP" | grep -qF "comment \"$(tctl_block_comment "$ip")_mac\"" \
&& echo 1 || echo 0
else
echo "$FWD_DUMP" | awk -v ip="$ip" '
$3 == "DROP" {
@@ -320,7 +325,13 @@ lookup_blocked() {
lookup_block_bytes() {
local ip="$1" b
if [ "$TCTL_FW" = "nft" ]; then
b=$(echo "$FWD_DUMP" | grep "ip saddr $ip " | grep -oE 'bytes [0-9]+' | awk '{print $2}' | head -1)
# Both halves of the block, summed: the v4 rule keyed on the address
# and the v6 rule keyed on the MAC. Counting only the first would make
# a dual-stack device's dropped bytes read low for the same reason the
# traffic used to escape entirely.
b=$(echo "$FWD_DUMP" | grep -e "ip saddr $ip " \
-e "comment \"$(tctl_block_comment "$ip")_mac\"" \
| grep -oE 'bytes [0-9]+' | awk '{ t += $2 } END { printf "%.0f", t }')
else
b=$(echo "$FWD_DUMP" | awk -v ip="$ip" '
$3 == "DROP" {