🤞 Sync 2026-09-30 23:44:05

This commit is contained in:
github-actions[bot] committed 2026-09-30 23:44:05 +08:00
1 parent e3fde93105
commit 44bb740ab2
206 files changed
+5736 -2799

No files matched your search

@@ -51,8 +51,13 @@ if [ -f "$RULES_FILE" ] && tctl_persist_enabled; then
logger -t trafficctl "Failed to restore block for $ip"
;;
ratelimit)
# Records written before the mode was stored have none; they
# get the same default the CLI would have given the target, so
# a subnet does not change meaning across the upgrade.
mode=$(jsonfilter -i "$RULES_FILE" -e "@[$idx].mode" 2>/dev/null)
[ -z "$mode" ] && mode=$(tctl_ratelimit_default_mode "$ip")
[ -n "$param" ] && [ "$param" != "0" ] && \
tctl_ratelimit_add "$ip" "$param" "$(tctl_ratelimit_comment "$ip")" 2>/dev/null || \
tctl_ratelimit_add "$ip" "$param" "$(tctl_ratelimit_comment "$ip")" "$mode" 2>/dev/null || \
logger -t trafficctl "Failed to restore ratelimit for $ip (${param}kbit)"
;;
pfw_pause|pfw_limit)
@@ -45,6 +45,7 @@ list)
printf '"macfilter_remove":{"ip":"str"},'
printf '"ratelimit":{"ip":"str","rate_kbit":"int","label":"str","mode":"str"},'
printf '"ratelimit_stats":{},'
printf '"subnets":{},'
printf '"shape_add":{"ip":"str","rate_kbit":"int","label":"str"},'
printf '"shape_remove":{"ip":"str","label":"str"},'
printf '"shape_status":{"ip":"str"},'
@@ -169,6 +170,11 @@ call)
"$SCRIPTS/trafficctl-ratelimit-stats.sh"
printf '}'
;;
subnets)
printf '{"result":'
"$SCRIPTS/trafficctl-subnets.sh"
printf '}'
;;
shape_add)
read -r input
ip=$(echo "$input" | jsonfilter -e '@.ip' 2>/dev/null)
@@ -270,6 +270,21 @@ tctl_ratelimit_list() {
fi
}
# Which bucket layout a target gets when the caller names no mode.
#
# A block limited "shared" would let one device starve the rest, so per-device
# is the sane default whenever the target covers more than one address. Lives
# here rather than in trafficctl-ratelimit.sh because the reboot-restore hook
# has to reach the same answer for a record written before modes were stored —
# two copies of this rule would mean a limit that changes meaning on reboot.
tctl_ratelimit_default_mode() {
case "$1" in
*/32) echo "shared" ;; # a /32 is one host; both modes are identical
*/*) echo "each" ;; # any wider block: per-device buckets
*) echo "shared" ;; # bare host address
esac
}
# ── Internet Blocking ──────────────────────────────────────────────────────
# A block is two rules: the address-keyed one for IPv4, and a MAC-keyed one
@@ -822,12 +837,17 @@ tctl_persist_enabled() {
}
tctl_persist_save() {
local type="$1" ip="$2" param="$3"
local type="$1" ip="$2" param="$3" mode="$4"
[ -d "$(dirname "$TCTL_RULES_FILE")" ] || mkdir -p "$(dirname "$TCTL_RULES_FILE")"
[ -f "$TCTL_RULES_FILE" ] || echo '[]' > "$TCTL_RULES_FILE"
local tmp="${TCTL_RULES_FILE}.tmp"
# The mode field is written only when the caller has one, so records for
# rule types that have no bucket layout (blocks, port forwards) keep their
# exact previous shape.
local extra=""
[ -n "$mode" ] && extra=",\"mode\":\"$mode\""
# Remove existing entry for same ip+type, append new one
awk -v ip="$ip" -v t="$type" -v p="$param" '
awk -v ip="$ip" -v t="$type" -v p="$param" -v x="$extra" '
{
gsub(/^\[/,""); gsub(/\]$/,"")
n=split($0, items, "},{")
@@ -841,7 +861,7 @@ tctl_persist_save() {
first=0
}
if (!first) printf ","
printf "{\"type\":\"%s\",\"ip\":\"%s\",\"param\":\"%s\"}]", t, ip, p
printf "{\"type\":\"%s\",\"ip\":\"%s\",\"param\":\"%s\"%s}]", t, ip, p, x
}' "$TCTL_RULES_FILE" > "$tmp"
mv "$tmp" "$TCTL_RULES_FILE"
}
@@ -1,7 +1,15 @@
#!/bin/sh
# shellcheck shell=dash
# Show active rate-limit statistics.
# Output: JSON array [{"ip":"...","rate_kbit":N,"packets":N,"bytes":N,"pass_packets":0,"pass_bytes":0}]
# Output: JSON array
# [{"ip":"...","mode":"each|shared","rate_kbit":N,"packets":N,"bytes":N,
# "pass_packets":0,"pass_bytes":0}]
#
# One entry per target, counters summed across chains: the download rule is
# installed on every LAN device's egress chain, so a router with three bridges
# holds three copies of the same limit. Only the chain on the device the target
# actually lives behind ever matches, but which copy that is cannot be known
# here — taking any single one at random reported zero drops for most targets.
. /usr/local/bin/trafficctl-fw.sh
@@ -13,7 +21,13 @@ if [ "$TCTL_FW" = "nft" ]; then
packets = 0
bytes = 0
for (i = 1; i <= NF; i++) {
if ($i == "daddr" && i < NF) ip = $(i+1)
# FIRST daddr only. In "each" mode the rule reads
# ip daddr <target> meter m { ip daddr limit rate over ... }
# and the meter key is a bare "ip daddr" with no operand, so
# overwriting on every match reported the target as "limit" —
# every per-device subnet limit was invisible to this output, to
# the dashboard, and to tctl_has_limit.
if (ip == "" && $i == "daddr" && i < NF && $(i+1) ~ /^[0-9]/) ip = $(i+1)
if ($i == "rate" && $(i+1) == "over") {
val = $(i+2)
gsub(/[^0-9]/, "", val)
@@ -27,15 +41,29 @@ if [ "$TCTL_FW" = "nft" ]; then
}
}
if (ip != "") {
if (first) printf ","
printf "{\"ip\":\"%s\",\"rate_kbit\":%d,\"packets\":%.0f,\"bytes\":%.0f,\"pass_packets\":0,\"pass_bytes\":0}", ip, rate, packets, bytes
first = 1
if (!(ip in seen)) { seen[ip] = 1; order[++n] = ip }
# A meter keyed on the address is what makes the bucket per-device.
mode[ip] = (index($0, " meter ") > 0) ? "each" : "shared"
rate_of[ip] = rate
pkts[ip] += packets
byts[ip] += bytes
}
}
BEGIN { printf "["; first = 0 }
END { printf "]\n" }
END {
printf "["
for (i = 1; i <= n; i++) {
ip = order[i]
if (i > 1) printf ","
printf "{\"ip\":\"%s\",\"mode\":\"%s\",\"rate_kbit\":%d,\"packets\":%.0f,\"bytes\":%.0f,\"pass_packets\":0,\"pass_bytes\":0}", \
ip, mode[ip], rate_of[ip], pkts[ip], byts[ip]
}
printf "]\n"
}
'
else
# hashlimit is keyed on dstip whatever mode was asked for, so on this path
# every bucket is per-address — "each" is the honest answer, not the
# requested mode. Documented in docs/API.md: shared needs nftables.
iptables -t mangle -L FORWARD -nvx 2>/dev/null | grep "rl_ratelimit" | awk '
{
packets = $1
@@ -57,12 +85,21 @@ else
}
}
if (ip != "" && ip != "0.0.0.0/0") {
if (first) printf ","
printf "{\"ip\":\"%s\",\"rate_kbit\":%d,\"packets\":%.0f,\"bytes\":%.0f,\"pass_packets\":0,\"pass_bytes\":0}", ip, rate, packets, bytes
first = 1
if (!(ip in seen)) { seen[ip] = 1; order[++n] = ip }
rate_of[ip] = rate
pkts[ip] += packets
byts[ip] += bytes
}
}
BEGIN { printf "["; first = 0 }
END { printf "]\n" }
END {
printf "["
for (i = 1; i <= n; i++) {
ip = order[i]
if (i > 1) printf ","
printf "{\"ip\":\"%s\",\"mode\":\"each\",\"rate_kbit\":%d,\"packets\":%.0f,\"bytes\":%.0f,\"pass_packets\":0,\"pass_bytes\":0}", \
ip, rate_of[ip], pkts[ip], byts[ip]
}
printf "]\n"
}
'
fi
@@ -26,14 +26,8 @@ TARGET=$(tctl_validate_target "$IP") || {
}
IP="$TARGET"
# A block limited "shared" would let one device starve the rest, so per-device
# is the sane default whenever the target covers more than one address.
if [ -z "$MODE" ]; then
case "$IP" in
*/32) MODE="shared" ;; # a /32 is one host; both modes are identical
*/*) MODE="each" ;; # any wider block: per-device buckets
*) MODE="shared" ;; # bare host address
esac
MODE=$(tctl_ratelimit_default_mode "$IP")
fi
case "$MODE" in
each|shared) ;;
@@ -78,7 +72,10 @@ else
echo "{\"ok\":false,\"msg\":\"failed to set rate limit for $IP (no usable WAN or LAN ingress device)\"}"
exit 1
fi
tctl_persist_enabled && tctl_persist_save "ratelimit" "$IP" "$RATE"
# The mode is persisted with the rate. Without it the restore hook fell back
# to tctl_ratelimit_add's own default ("shared"), so a subnet limited
# "5 Mbit each" came back after a reboot as 5 Mbit for the entire subnet.
tctl_persist_enabled && tctl_persist_save "ratelimit" "$IP" "$RATE" "$MODE"
tctl_log "ratelimit_set" "$IP" "${RATE}kbit" "${TCTL_VIA:-cli}" "${TCTL_SRC:-local}"
if [ "$TCTL_RL_DOWNLOAD_FAILED" = "1" ]; then
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\"}"
@@ -244,7 +244,15 @@ do_add() {
# Download: queue on the LAN device, matching traffic destined to the client.
if ! shape_attach "$LAN_DEV" dst "$IP" "$classid" "$RATE" "$burst_bytes"; then
echo "{\"ok\":false,\"msg\":\"tc setup failed for $IP on $LAN_DEV\"}"
# Naming the cause matters here: declining to replace a foreign root
# qdisc is deliberate (it is how an SQM/cake setup survives this app),
# but "tc setup failed" reads as a bug and gives no way to act on it.
# The rate limiter is a policer, owns no qdisc, and works alongside SQM.
if ! root_qdisc_replaceable "$LAN_DEV"; then
echo "{\"ok\":false,\"msg\":\"shaping declined on $LAN_DEV: another QoS setup (SQM/cake or a custom HTB tree) owns the root qdisc and will not be torn down. Use the limiter instead — it is a policer and coexists with SQM.\"}"
else
echo "{\"ok\":false,\"msg\":\"tc setup failed for $IP on $LAN_DEV\"}"
fi
return 1
fi
@@ -0,0 +1,52 @@
#!/bin/sh
# shellcheck shell=dash
# List the subnets this router monitors, as CIDRs the dashboard can offer as
# rate-limit targets.
#
# Output: JSON array
# [{"cidr":"192.168.20.0/24","device":"br-guest","kind":"lan"},
# {"cidr":"10.0.5.0/24","device":"br-lan","kind":"routed"}]
#
# kind is "lan" for a directly connected subnet and "routed" for one reached
# via a next-hop on a LAN device (or listed in trafficctl.main.extra_subnets).
#
# This list is exactly the set a limit can be ENFORCED on: the netdev hooks
# that police traffic are attached to the devices these subnets resolve to, so
# a subnet missing from here (a masquerading non-lan zone, for instance) would
# take a rule that never matches a packet. The dashboard warns off such a
# target instead of pretending it applied.
. /usr/local/bin/trafficctl-fw.sh
tctl_monitored_subnets | awk '
# Octets are derived with arithmetic and printed individually: each is below
# 256, while the packed address is up to 2^32-1 and busybox awk formats "%d"
# through a 32-bit int.
function ip2s(v, o1, o2, o3, o4, rem) {
o1 = int(v / 16777216); rem = v - o1 * 16777216
o2 = int(rem / 65536); rem = rem - o2 * 65536
o3 = int(rem / 256)
o4 = rem - o3 * 256
return sprintf("%d.%d.%d.%d", o1, o2, o3, o4)
}
{
base = $2 + 0
block = $3 + 0
if (block < 1) next
# block is a power of two (1 << (32 - mask)); count it back down rather
# than calling log(), which awk only has in floating point.
mask = 32
b = block
while (b > 1) { b = b / 2; mask-- }
# router_int is 0 for a subnet that has no local address inside it, which
# is what tells a routed subnet from a connected one.
# Written long-hand: the bashism linter reads the whole file, and
# `name = (...)` at the start of a line looks like a shell array to it.
kind = "lan"
if ($4 + 0 == 0) { kind = "routed" }
if (n++) printf ","
else printf "["
printf "{\"cidr\":\"%s/%d\",\"device\":\"%s\",\"kind\":\"%s\"}", ip2s(base), mask, $1, kind
}
END { if (!n) printf "["; printf "]\n" }
'
@@ -6,7 +6,7 @@
"luci.trafficctl": [
"version",
"summary", "device", "bytes", "ifaces",
"ratelimit_stats", "shape_stats", "rdns",
"ratelimit_stats", "shape_stats", "rdns", "subnets",
"portfw_list", "names_list", "netify_status", "netify_list",
"shape_status",
"config_get", "telegram_config_get",