🐶 Sync 2026-11-08 23:51:11

This commit is contained in:
github-actions[bot] committed 2026-11-08 23:51:11 +08:00
1 parent 5a5196e2d6
commit 534c8d6ab2
73 files changed
+12872 -2580

No files matched your search

@@ -172,8 +172,9 @@ else
# The download rule is listed first; the upload one is the "_ul" name.
RL_DUMP=$(iptables -t mangle -L FORWARD -nv 2>/dev/null | grep "rl_ratelimit" \
| awk -v ip="$IP" '{ for (i = 1; i <= NF; i++) if ($i == ip || $i == ip "/32") { print; next } }')
RATE_LIM=$(echo "$RL_DUMP" | grep -oE '[0-9]+kbit' | head -1 | sed 's/kbit//')
RATE_LIM_UP=$(echo "$RL_DUMP" | grep -E 'rl_ratelimit.*_ul' | grep -oE '[0-9]+kbit' | head -1 | sed 's/kbit//')
RATE_LIM=$(echo "$RL_DUMP" | awk "$TCTL_NFT_RATE_AWK"'{ r = tctl_ipt_first_rate(); if (r >= 0) { print r; exit } }')
RATE_LIM_UP=$(echo "$RL_DUMP" | grep -E 'rl_ratelimit.*_ul' \
| awk "$TCTL_NFT_RATE_AWK"'{ r = tctl_ipt_first_rate(); if (r >= 0) { print r; exit } }')
fi
[ -z "$RATE_LIM" ] && RATE_LIM=0
[ -z "$RATE_LIM_UP" ] && RATE_LIM_UP=0
@@ -279,12 +279,14 @@ tctl_ratelimit_add() {
TCTL_RL_UPLOAD6_OK=0
# shellcheck disable=SC2034
TCTL_RL_DOWNLOAD6_OK=0
# shellcheck disable=SC2046 # deliberate split into the option words
iptables -t mangle -A FORWARD -d "$ip" -m hashlimit \
--hashlimit-above "${rate_kbit}kbit/sec" --hashlimit-burst "${rate_kbit}kbit" \
$(tctl_ipt_limit_args "$rate_kbit") \
--hashlimit-mode dstip --hashlimit-name "rl_${comment}" \
-j DROP -m comment --comment "$comment" 2>/dev/null
# shellcheck disable=SC2046 # deliberate split into the option words
iptables -t mangle -A FORWARD -s "$ip" -m hashlimit \
--hashlimit-above "${rate_kbit_up}kbit/sec" --hashlimit-burst "${rate_kbit_up}kbit" \
$(tctl_ipt_limit_args "$rate_kbit_up") \
--hashlimit-mode srcip --hashlimit-name "rl_${comment}_ul" \
-j DROP -m comment --comment "${comment}_ul" 2>/dev/null
fi
@@ -382,8 +384,50 @@ function tctl_rate_kbit(n, u, b) {
# Rounded here, so a reader printing %d agrees with one printing %.0f.
return int(b * 8 / 1000 + 0.5)
}
# An iptables hashlimit rate as listed, e.g. "624992b/s" or "1213mb/s".
# iptables prints bytes in 1024 steps too. A packet rate ("5000/sec") is not a
# ceiling in kbit/s and reads 0.
function tctl_ipt_rate_kbit(t, n, u) {
if (!match(t, /^[0-9]+/)) return 0
n = substr(t, 1, RLENGTH); u = substr(t, RLENGTH + 1)
if (u ~ /^b\//) return tctl_rate_kbit(n, "bytes")
if (u ~ /^kb\//) return tctl_rate_kbit(n, "kbytes")
if (u ~ /^mb\//) return tctl_rate_kbit(n, "mbytes")
if (u ~ /^gb\//) return tctl_rate_kbit(n, "gbytes")
return 0
}
# The ceiling of the hashlimit rule on the current line: "above X" in an
# "iptables -L" listing, "--hashlimit-above X" in "iptables -S". -1 if none.
function tctl_ipt_first_rate( i) {
for (i = 1; i < NF; i++)
if ($i == "above" || $i == "--hashlimit-above") return tctl_ipt_rate_kbit($(i+1))
return -1
}
'
# hashlimit arguments for a kbit/s ceiling, in BYTES.
#
# "${kbit}kbit/sec" is not a byte rate to hashlimit: it reads the number as
# PACKETS per second (iptables -S lists it back as "5000/sec"), and the
# "${kbit}kbit" burst written next to it is rejected outright ("bad value for
# option --hashlimit-burst", iptables 1.8.7 and 1.8.10). The 2>/dev/null after
# the rule hid that, so on fw3 no limit was ever installed.
#
# Bytes per second, SI like the nft side (tctl_rate_bytes). The burst is two
# seconds of the rate: hashlimit refuses a byte burst below about one second
# of it ("burst cannot be smaller than 125172064b" at 1 Gbit/s), and it is a
# 32-bit field, hence the cap, which puts the highest ceiling fw3 can
# express near 30 Gbit/s (10 Gbit/s is accepted, 40 is not). Verified on iptables-legacy 1.8.7 with iperf3
# through a forwarding netns: 5, 50 and 1000 Mbit/s held at 4.9-4.97, 49-50.6
# and 990-1003 Mbit/s.
tctl_ipt_limit_args() {
local b burst
b=$(tctl_rate_bytes "$1")
burst=$(( b * 2 ))
[ "$burst" -gt 4000000000 ] && burst=4000000000
echo "--hashlimit-above ${b}b/s --hashlimit-burst ${burst}b"
}
tctl_ratelimit_list() {
if [ "$TCTL_FW" = "nft" ]; then
nft list table netdev tm_ratelimit 2>/dev/null
@@ -115,6 +115,8 @@ BEGIN {
print "# TYPE trafficctl_device_blocked gauge"
print "# HELP trafficctl_device_limit_kbit Applied throttle in kbit/s (0 = none)."
print "# TYPE trafficctl_device_limit_kbit gauge"
print "# HELP trafficctl_device_limit_up_kbit Applied upload throttle in kbit/s (0 = none); equals the download one unless split."
print "# TYPE trafficctl_device_limit_up_kbit gauge"
print "# HELP trafficctl_device_wifi_blocked Whether the device is denied on WiFi."
print "# TYPE trafficctl_device_wifi_blocked gauge"
print "# HELP trafficctl_device_conntrack_bytes Bytes currently accounted in conntrack (falls as flows expire)."
@@ -132,6 +134,10 @@ BEGIN {
printf "trafficctl_device_wifi_blocked{ip=\"%s\"} %d\n", ip, (index($0, "\"wifi_blocked\":true") ? 1 : 0)
printf "trafficctl_device_limit_kbit{ip=\"%s\",mode=\"limiter\"} %d\n", ip, num($0, "rate_limit_kbit")
printf "trafficctl_device_limit_kbit{ip=\"%s\",mode=\"shaper\"} %d\n", ip, num($0, "shape_kbit")
# Its own metric rather than a direction label on the one above: a new
# label would start new series for every existing dashboard and alert.
printf "trafficctl_device_limit_up_kbit{ip=\"%s\",mode=\"limiter\"} %d\n", ip, num($0, "rate_limit_kbit_up")
printf "trafficctl_device_limit_up_kbit{ip=\"%s\",mode=\"shaper\"} %d\n", ip, num($0, "shape_kbit_up")
# Conntrack view: current in-flight totals and the protocol split.
printf "trafficctl_device_conntrack_bytes{ip=\"%s\",proto=\"all\"} %.0f\n", ip, num($0, "total")
printf "trafficctl_device_conntrack_bytes{ip=\"%s\",proto=\"tcp\"} %.0f\n", ip, num($0, "tcp")
@@ -154,13 +154,15 @@ pfw_add_limit() {
fi
else
if [ "$scope" = "input" ]; then
# shellcheck disable=SC2046 # deliberate split into the option words
iptables -I INPUT -p "$proto" --dport "$(iptables_dport "$port")" -m hashlimit \
--hashlimit-above "${kbit}kbit/sec" --hashlimit-burst "$kbit" \
$(tctl_ipt_limit_args "$kbit") \
--hashlimit-mode dstport --hashlimit-name "$(hashlimit_name "$proto" "$port")" \
-j DROP -m comment --comment "$cmt" 2>/dev/null
else
# shellcheck disable=SC2046 # deliberate split into the option words
iptables -I FORWARD -d "$ip" -p "$proto" --dport "$(iptables_dport "$port")" -m hashlimit \
--hashlimit-above "${kbit}kbit/sec" --hashlimit-burst "$kbit" \
$(tctl_ipt_limit_args "$kbit") \
--hashlimit-mode dstip --hashlimit-name "$(hashlimit_name "$proto" "$port")" \
-j DROP -m comment --comment "$cmt" 2>/dev/null
fi
@@ -372,7 +374,7 @@ do_list() {
st=$(printf '%s' "$CTL_DUMP" | grep "$(pfw_comment limit "$p" "$cmt_ip" "$port")" | \
awk "$TCTL_NFT_RATE_AWK"'{ for (i = 1; i < NF - 1; i++) if ($i == "over") { printf "%.0f\n", tctl_rate_kbit($(i+1), $(i+2)); exit } }')
[ -z "$st" ] && st=$(printf '%s' "$CTL_DUMP" | grep "$(pfw_comment limit "$p" "$cmt_ip" "$port")" | \
grep -oE 'hashlimit-above [0-9]+' | awk '{print $2; exit}')
awk "$TCTL_NFT_RATE_AWK"'{ r = tctl_ipt_first_rate(); if (r >= 0) { print r; exit } }')
[ -n "$st" ] && limit_kbit="$st"
fi
done
@@ -65,7 +65,7 @@ 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 '
iptables -t mangle -L FORWARD -nvx 2>/dev/null | grep "rl_ratelimit" | awk "$TCTL_NFT_RATE_AWK"'
{
packets = $1
bytes = $2
@@ -77,14 +77,9 @@ else
}
if (i == 9) ip = $i
}
# extract rate from hashlimit-above
for (i = 1; i <= NF; i++) {
if ($i ~ /^[0-9]+kbit/) {
gsub(/kbit.*/, "", $i)
rate = $i
break
}
}
# the "above X" ceiling, in iptables units (see tctl_ipt_rate_kbit)
rate = tctl_ipt_first_rate()
if (rate < 0) rate = 0
if (ip != "" && ip != "0.0.0.0/0") {
if (!(ip in seen)) { seen[ip] = 1; order[++n] = ip }
rate_of[ip] = rate
@@ -343,7 +343,7 @@ lookup_rate_limit() {
# way; the upload one is the only "_ul" name.
r=$(echo "$RL_DUMP" | grep -E "$pat" \
| awk -v ip="$ip" '{ for (i = 1; i <= NF; i++) if ($i == ip || $i == ip "/32") { print; next } }' \
| grep -oE '[0-9]+kbit' | head -1 | sed 's/kbit//')
| awk "$TCTL_NFT_RATE_AWK"'{ r = tctl_ipt_first_rate(); if (r >= 0) { print r; exit } }')
fi
echo "${r:-0}"
}
@@ -450,13 +450,34 @@ build_device_keyboard() {
rm -f "$tmpkb"
}
# A ceiling as "8000 kbit/s", or "8000↓ / 2000↑ kbit/s" when upload has its
# own (#66). An upload figure of 0 or equal to download is not a split.
tg_ceiling() {
if [ "${2:-0}" -gt 0 ] 2>/dev/null && [ "$2" != "$1" ]; then
printf '%s↓ / %s↑ kbit/s' "$1" "$2"
else
printf '%s kbit/s' "$1"
fi
}
# The same, compact, for the /status flags: "8000k" or "8000/2000k".
tg_ceiling_short() {
if [ "${2:-0}" -gt 0 ] 2>/dev/null && [ "$2" != "$1" ]; then
printf '%s/%sk' "$1" "$2"
else
printf '%sk' "$1"
fi
}
build_action_keyboard() {
local ip="$1" devices="$2"
local blocked wifi_blocked rl_kbit shape_kbit
local blocked wifi_blocked rl_kbit shape_kbit rl_up shape_up
blocked=$(get_device_field "$devices" "$ip" "blocked")
wifi_blocked=$(get_device_field "$devices" "$ip" "wifi_blocked")
rl_kbit=$(get_device_field "$devices" "$ip" "rate_limit_kbit")
shape_kbit=$(get_device_field "$devices" "$ip" "shape_kbit")
rl_up=$(get_device_field "$devices" "$ip" "rate_limit_kbit_up")
shape_up=$(get_device_field "$devices" "$ip" "shape_kbit_up")
conn_type=$(get_device_field "$devices" "$ip" "conn_type")
local kb='{"inline_keyboard":['
@@ -487,7 +508,7 @@ build_action_keyboard() {
# limiter
if [ "$TG_BTN_LIMIT" = "1" ]; then
if [ "${rl_kbit:-0}" -gt 0 ] 2>/dev/null; then
rows="${rows}[{\"text\":\"⚡ Limit: ${rl_kbit} kbit/s — Remove\",\"callback_data\":\"act:unlimit:${ip}\"}],"
rows="${rows}[{\"text\":\"⚡ Limit: $(tg_ceiling "$rl_kbit" "$rl_up") — Remove\",\"callback_data\":\"act:unlimit:${ip}\"}],"
else
rows="${rows}[{\"text\":\"⚡ 1M\",\"callback_data\":\"act:limit:${ip}:1000\"},{\"text\":\"⚡ 2M\",\"callback_data\":\"act:limit:${ip}:2000\"},{\"text\":\"⚡ 5M\",\"callback_data\":\"act:limit:${ip}:5000\"}],"
rows="${rows}[{\"text\":\"⚡ 10M\",\"callback_data\":\"act:limit:${ip}:10000\"},{\"text\":\"⚡ 25M\",\"callback_data\":\"act:limit:${ip}:25000\"},{\"text\":\"⚡ 50M\",\"callback_data\":\"act:limit:${ip}:50000\"},{\"text\":\"⚡ 100M\",\"callback_data\":\"act:limit:${ip}:100000\"}],"
@@ -497,7 +518,7 @@ build_action_keyboard() {
# shaper
if [ "$TG_BTN_SHAPE" = "1" ]; then
if [ "${shape_kbit:-0}" -gt 0 ] 2>/dev/null; then
rows="${rows}[{\"text\":\"🔧 Shape: ${shape_kbit} kbit/s — Remove\",\"callback_data\":\"act:unshape:${ip}\"}],"
rows="${rows}[{\"text\":\"🔧 Shape: $(tg_ceiling "$shape_kbit" "$shape_up") — Remove\",\"callback_data\":\"act:unshape:${ip}\"}],"
else
rows="${rows}[{\"text\":\"🔧 1M\",\"callback_data\":\"act:shape:${ip}:1000\"},{\"text\":\"🔧 2M\",\"callback_data\":\"act:shape:${ip}:2000\"},{\"text\":\"🔧 5M\",\"callback_data\":\"act:shape:${ip}:5000\"}],"
rows="${rows}[{\"text\":\"🔧 10M\",\"callback_data\":\"act:shape:${ip}:10000\"},{\"text\":\"🔧 25M\",\"callback_data\":\"act:shape:${ip}:25000\"},{\"text\":\"🔧 50M\",\"callback_data\":\"act:shape:${ip}:50000\"},{\"text\":\"🔧 100M\",\"callback_data\":\"act:shape:${ip}:100000\"}],"
@@ -556,7 +577,7 @@ handle_devices_edit() {
}
handle_status() {
local devices ip name rl_kbit shape_kbit blocked wifi_blocked
local devices ip name rl_kbit shape_kbit rl_up shape_up blocked wifi_blocked
local result=""
devices=$(get_devices)
[ -z "$devices" ] || [ "$devices" = "[]" ] && { tg_send "No active devices"; return; }
@@ -566,13 +587,15 @@ handle_status() {
wifi_blocked=$(get_device_field "$devices" "$ip" "wifi_blocked")
rl_kbit=$(get_device_field "$devices" "$ip" "rate_limit_kbit")
shape_kbit=$(get_device_field "$devices" "$ip" "shape_kbit")
rl_up=$(get_device_field "$devices" "$ip" "rate_limit_kbit_up")
shape_up=$(get_device_field "$devices" "$ip" "shape_kbit_up")
name=$(get_device_field "$devices" "$ip" "name")
local flags=""
[ "$blocked" = "true" ] || [ "$blocked" = "1" ] && flags="${flags} 🚫inet"
[ "$wifi_blocked" = "true" ] || [ "$wifi_blocked" = "1" ] && flags="${flags} 📵wifi"
[ "${rl_kbit:-0}" -gt 0 ] 2>/dev/null && flags="${flags} ⚡${rl_kbit}k"
[ "${shape_kbit:-0}" -gt 0 ] 2>/dev/null && flags="${flags} 🔧${shape_kbit}k"
[ "${rl_kbit:-0}" -gt 0 ] 2>/dev/null && flags="${flags} ⚡$(tg_ceiling_short "$rl_kbit" "$rl_up")"
[ "${shape_kbit:-0}" -gt 0 ] 2>/dev/null && flags="${flags} 🔧$(tg_ceiling_short "$shape_kbit" "$shape_up")"
[ -n "$flags" ] && result="${result}${name:-?} (${ip}):${flags}\n"
done
@@ -616,17 +639,19 @@ handle_callback() {
invalidate_cache
devices=$(get_devices)
name=$(get_device_field "$devices" "$ip" "name")
local blocked wifi_blocked rl_kbit shape_kbit
local blocked wifi_blocked rl_kbit shape_kbit rl_up shape_up
blocked=$(get_device_field "$devices" "$ip" "blocked")
wifi_blocked=$(get_device_field "$devices" "$ip" "wifi_blocked")
rl_kbit=$(get_device_field "$devices" "$ip" "rate_limit_kbit")
shape_kbit=$(get_device_field "$devices" "$ip" "shape_kbit")
rl_up=$(get_device_field "$devices" "$ip" "rate_limit_kbit_up")
shape_up=$(get_device_field "$devices" "$ip" "shape_kbit_up")
local status_line=""
[ "$blocked" = "true" ] || [ "$blocked" = "1" ] && status_line="${status_line}🚫 Internet blocked\n"
[ "$wifi_blocked" = "true" ] || [ "$wifi_blocked" = "1" ] && status_line="${status_line}📵 WiFi blocked\n"
[ "${rl_kbit:-0}" -gt 0 ] 2>/dev/null && status_line="${status_line}⚡ Limiter: ${rl_kbit} kbit/s\n"
[ "${shape_kbit:-0}" -gt 0 ] 2>/dev/null && status_line="${status_line}🔧 Shaper: ${shape_kbit} kbit/s\n"
[ "${rl_kbit:-0}" -gt 0 ] 2>/dev/null && status_line="${status_line}⚡ Limiter: $(tg_ceiling "$rl_kbit" "$rl_up")\n"
[ "${shape_kbit:-0}" -gt 0 ] 2>/dev/null && status_line="${status_line}🔧 Shaper: $(tg_ceiling "$shape_kbit" "$shape_up")\n"
[ -z "$status_line" ] && status_line="✅ No restrictions\n"
local text