🦄 Sync 2026-11-08 01:37:11

This commit is contained in:
github-actions[bot] committed 2026-11-08 01:37:11 +08:00
1 parent 3d26c9667a
commit 18b7d51c1a
6 files changed
+141 -67

No files matched your search

+2 -2
View File
@@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-trafficctl
PKG_VERSION:=1.26.3
PKG_RELEASE:=21
PKG_VERSION:=1.26.4
PKG_RELEASE:=22
PKG_LICENSE:=Apache-2.0
PKG_MAINTAINER:=Denis Iusupov <yusdyr@gmail.com>
@@ -316,6 +316,14 @@ function fmtRate(kbit) {
return kbit + ' ' + _('kbit/s');
}
// A ceiling as one figure, or both when upload has its own (#66). An upload
// figure of 0 means the backend found no upload ceiling to report; the
// download one is still the limit, so it is shown alone.
function fmtCeiling(kbit, kbitUp) {
if (!kbitUp || kbitUp <= 0 || kbitUp === kbit) return fmtRate(kbit);
return '↓ ' + fmtRate(kbit) + ' / ↑ ' + fmtRate(kbitUp);
}
// Render a "↓ down / ↑ up" speed cell. Both halves come from the same
// bytes_in/bytes_out sample, so they are always consistent with each other.
function renderSpeedCell(cell, sd) {
@@ -1240,6 +1248,7 @@ function buildSummaryTable(rows, sortCol, sortDir, onSort, onSelect, speedMap, d
r._backlog = sh ? sh.backlog : 0;
r._throttle_kbit = (r.shape_kbit || 0) > 0 ? r.shape_kbit : (r.rate_limit_kbit || 0);
r._throttle_mode = (r.shape_kbit || 0) > 0 ? 'shaper' : ((r.rate_limit_kbit || 0) > 0 ? 'limiter' : 'none');
r._throttle_kbit_up = r._throttle_mode === 'shaper' ? (r.shape_kbit_up || 0) : (r.rate_limit_kbit_up || 0);
});
var sorted = rows.slice().sort(function(a, b) {
@@ -1381,8 +1390,8 @@ function buildSummaryTable(rows, sortCol, sortDir, onSort, onSelect, speedMap, d
cellMap.app = E('div', { 'class': 'td tc-center' }, appBadge);
var throttleBadge;
if (r._throttle_mode === 'shaper') { throttleBadge = E('span', { 'class': 'tc-c-speed tc-fw-bold', 'title': _('Shaper (tc/HTB queue)') }, '≈ ' + fmtRate(r._throttle_kbit)); }
else if (r._throttle_mode === 'limiter') { throttleBadge = E('span', { 'class': 'tc-c-warn tc-fw-bold', 'title': _('Limiter (nft drop)') }, '⚡ ' + fmtRate(r._throttle_kbit)); }
if (r._throttle_mode === 'shaper') { throttleBadge = E('span', { 'class': 'tc-c-speed tc-fw-bold', 'title': _('Shaper (tc/HTB queue)') }, '≈ ' + fmtCeiling(r._throttle_kbit, r._throttle_kbit_up)); }
else if (r._throttle_mode === 'limiter') { throttleBadge = E('span', { 'class': 'tc-c-warn tc-fw-bold', 'title': _('Limiter (nft drop)') }, '⚡ ' + fmtCeiling(r._throttle_kbit, r._throttle_kbit_up)); }
else { throttleBadge = E('span', { 'class': 'tc-c-faint' }, '—'); }
cellMap._throttle_kbit = E('div', { 'class': 'td tc-center' }, throttleBadge);
@@ -1868,6 +1877,8 @@ return view.extend({
}
renderRecentChips();
updateModeUI();
// The split row describes the device it was opened for.
_upPinned = false;
runQuery();
}
@@ -2274,6 +2285,9 @@ return view.extend({
// it would close the field (and overwrite what is being typed) a few
// seconds after it was opened.
var _customPinned = false;
// The upload row's counterpart: set while the operator has it open
// for editing, so the poll does not refill or close it under them.
var _upPinned = false;
var _modeSelected = 'shaper';
var rateChipsRow = E('div', {'class':'tc-chips-row'});
@@ -2379,13 +2393,29 @@ return view.extend({
var upToggleBtn = E('span', {'class': 'tc-chip',
'data-tip': _('Give upload its own ceiling instead of matching download')
}, '⇅ ' + _('Split up/down'));
upToggleBtn.addEventListener('click', function() {
upRow.classList.toggle('tc-hidden');
var on = !upRow.classList.contains('tc-hidden');
function showUpRow(on) {
upRow.classList.toggle('tc-hidden', !on);
upToggleBtn.classList.toggle('tc-chip--active', on);
if (on) { upInput.focus(); } else { upInput.value = ''; }
if (!on) { upInput.value = ''; }
}
upToggleBtn.addEventListener('click', function() {
var on = upRow.classList.contains('tc-hidden');
showUpRow(on);
_upPinned = on;
if (on) { upInput.focus(); }
updateScopeExplain();
});
// The row has no button of its own: Enter applies the pair, as does
// picking a download rate.
upInput.addEventListener('keydown', function(ev) {
if (ev.key !== 'Enter') { return; }
ev.preventDefault();
if (getRateKbit() === '0') {
setStatus(statusDiv, 'error', _('Pick a download speed as well: the upload one is applied with it.'));
return;
}
applyRate();
});
rateChipsRow.appendChild(upToggleBtn);
// Mode: segmented toggle (Shaper default)
@@ -2634,6 +2664,7 @@ return view.extend({
scopeRow,
rateChipsRow,
customRow,
upRow,
subnetLimitsBox
]);
@@ -2680,6 +2711,7 @@ return view.extend({
var name = '';
var kbit = getRateKbit();
var kbitUp = getRateKbitUp();
_upPinned = false;
// A block target has no single tc classid, so the shaper can't
// express it — force the limiter rather than silently doing nothing.
var mode = all ? 'limiter' : _modeSelected;
@@ -3119,12 +3151,12 @@ return view.extend({
}
}
if ((data.shape_kbit || 0) > 0) {
parts.push(_('Shaped: <b style=\"color:var(--tc-speed)\">🌊 %s</b>').format(fmtRate(data.shape_kbit)));
parts.push(_('Shaped: <b style=\"color:var(--tc-speed)\">🌊 %s</b>').format(fmtCeiling(data.shape_kbit, data.shape_kbit_up)));
var sm = self._shapeMap[data.ip || searchSelect.getValue()] || {};
if ((sm.backlog||0) > 0) parts.push(_('Queued: <b style=\"color:var(--tc-speed)\">%s</b>').format(fmtBytes(sm.backlog)));
if ((sm.bytes||0) > 0) parts.push(_('Passed: <b>%s</b>').format(fmtBytes(sm.bytes)));
} else if ((data.rate_limit_kbit || 0) > 0) {
parts.push(_('Speed limit: <b style=\"color:var(--tc-warn)\">⚡ %s</b>').format(fmtRate(data.rate_limit_kbit)));
parts.push(_('Speed limit: <b style=\"color:var(--tc-warn)\">⚡ %s</b>').format(fmtCeiling(data.rate_limit_kbit, data.rate_limit_kbit_up)));
var dm = self._dropMap[data.ip || searchSelect.getValue()] || {};
if ((dm.packets||0) > 0) {
parts.push(_('Dropped: <b style="color:var(--tc-err)">🚫 %d pkts / %s</b>').format(Number(dm.packets) || 0, fmtBytes(dm.bytes||0)));
@@ -3175,6 +3207,19 @@ return view.extend({
customRow.classList.add('tc-hidden');
}
// Show the upload ceiling that is installed, so reopening a
// device with a split limit does not look symmetric.
var curUp = curShapeRate > 0 ? (data.shape_kbit_up || 0) : (data.rate_limit_kbit_up || 0);
if (!_upPinned && document.activeElement !== upInput) {
var split = curRate > 0 && curUp > 0 && curUp !== curRate;
showUpRow(split);
if (split) {
_upUnit = curUp % 1000 === 0 ? 'mbit' : 'kbit';
upInput.value = _upUnit === 'mbit' ? curUp / 1000 : curUp;
updateUpUnitBtns();
}
}
while (connsDiv.firstChild) connsDiv.removeChild(connsDiv.firstChild);
if (!data.connections || data.connections.length === 0) {
connsDiv.appendChild(E('p', {'style':'color:var(--tc-muted);padding:12px 0'}, _('No active connections.')));
@@ -751,6 +751,9 @@ msgstr ""
msgid "Persist rules"
msgstr ""
msgid "Pick a download speed as well: the upload one is applied with it."
msgstr ""
msgid "Pick a rate to apply it to this target, or Off to remove its limit."
msgstr ""
@@ -161,38 +161,35 @@ if [ -n "$MAC" ]; then
done
fi
# Get rate limit
RATE_LIM=0
# Get rate limit, each direction: upload has its own ceiling since #83.
RATE_LIM=0; RATE_LIM_UP=0
if [ "$TCTL_FW" = "nft" ]; then
RATE_LIM=$(nft list table netdev tm_ratelimit 2>/dev/null | tctl_rl_rate_kbit "$IP")
RL_DUMP=$(nft list table netdev tm_ratelimit 2>/dev/null)
RATE_LIM=$(echo "$RL_DUMP" | tctl_rl_rate_kbit "$IP")
RATE_LIM_UP=$(echo "$RL_DUMP" | tctl_rl_rate_kbit "$IP" saddr)
else
# The address as a whole field, not a substring (see tctl_rl_rate_kbit).
RATE_LIM=$(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 } }' \
| grep -oE '[0-9]+kbit' | head -1 | sed 's/kbit//')
# 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//')
fi
[ -z "$RATE_LIM" ] && RATE_LIM=0
[ -z "$RATE_LIM_UP" ] && RATE_LIM_UP=0
# Get shape rate
SHAPE_KBIT=0
o3=$(echo "$IP" | cut -d. -f3)
o4=$(echo "$IP" | cut -d. -f4)
dec=$((o3 * 256 + o4))
hex=$(printf "%x" "$dec")
classid="1:$hex"
# Skip reserved HTB classes (root 1:1 and default 1:fffe)
shape_info=""
case "$hex" in 1|fffe) ;; *) shape_info=$(tc class show dev "$LAN_DEV" classid "$classid" 2>/dev/null) ;; esac
if [ -n "$shape_info" ]; then
SHAPE_KBIT=$(echo "$shape_info" | grep -oE 'rate [0-9]+[A-Za-z]+' | head -1 | awk '{
rate=$2; num=rate+0
if (rate ~ /Gbit/) print num*1000000
else if (rate ~ /Mbit/) print num*1000
else if (rate ~ /[Kk]bit/) print num
else print num
}')
# Get shape rate. The classid is looked up in shapes.json, never derived from
# the address: minors are allocated (see lookup_shape_kbit in
# trafficctl-summary.sh). Download is the class on the LAN device, upload the
# one with the same classid on the IFB.
SHAPE_KBIT=0; SHAPE_KBIT_UP=0
classid=$(tctl_shape_classmap | awk -v ip="$IP" '$1==ip{print $2; exit}')
if [ -n "$classid" ]; then
SHAPE_KBIT=$(tctl_tc_class_rates "$LAN_DEV" | awk -v c="$classid" '$1==c{print $2; exit}')
SHAPE_KBIT_UP=$(tctl_tc_class_rates "$(tctl_shape_ifb_dev)" | awk -v c="$classid" '$1==c{print $2; exit}')
fi
[ -z "$SHAPE_KBIT" ] && SHAPE_KBIT=0
[ -z "$SHAPE_KBIT_UP" ] && SHAPE_KBIT_UP=0
# Parse conntrack for connections and totals
TIMESTAMP=$(date +%s)
@@ -404,7 +401,7 @@ tctl_wifi_block_pending "$([ "$WIFI_BLOCKED" = "true" ] && echo 1 || echo 0)" "$
&& WIFI_PENDING=true
# Output final JSON
printf '{"ip":"%s","name":"%s","mac":"%s","conn_type":"%s","conn_last":"%s","timestamp":%d,"blocked":%s,"block_packets":%d,"block_bytes":%.0f,"wifi_blocked":%s,"wifi_block_pending":%s,"total":%.0f,"protocols":{"tcp":%d,"udp":%d,"other":%d},"tcp_states":{"established":%d,"time_wait":%d,"syn_sent":%d,"close_wait":%d},"connections":[%s],"rate_limit_kbit":%d,"shape_kbit":%d}\n' \
printf '{"ip":"%s","name":"%s","mac":"%s","conn_type":"%s","conn_last":"%s","timestamp":%d,"blocked":%s,"block_packets":%d,"block_bytes":%.0f,"wifi_blocked":%s,"wifi_block_pending":%s,"total":%.0f,"protocols":{"tcp":%d,"udp":%d,"other":%d},"tcp_states":{"established":%d,"time_wait":%d,"syn_sent":%d,"close_wait":%d},"connections":[%s],"rate_limit_kbit":%d,"rate_limit_kbit_up":%d,"shape_kbit":%d,"shape_kbit_up":%d}\n' \
"$IP" "$NAME" "$MAC" "$CONN_TYPE" "$CONN_LAST" "$TIMESTAMP" "$BLOCKED" "$BLOCK_PACKETS" "$BLOCK_BYTES" \
"$WIFI_BLOCKED" "$WIFI_PENDING" "$TOTAL" "$N_TCP" "$N_UDP" "$N_OTHER" \
"$EST" "$TW" "$SS" "$CW" "$CONNS_OUT" "$RATE_LIM" "$SHAPE_KBIT"
"$EST" "$TW" "$SS" "$CW" "$CONNS_OUT" "$RATE_LIM" "$RATE_LIM_UP" "$SHAPE_KBIT" "$SHAPE_KBIT_UP"
@@ -284,7 +284,7 @@ tctl_ratelimit_add() {
--hashlimit-mode dstip --hashlimit-name "rl_${comment}" \
-j DROP -m comment --comment "$comment" 2>/dev/null
iptables -t mangle -A FORWARD -s "$ip" -m hashlimit \
--hashlimit-above "${rate_kbit}kbit/sec" --hashlimit-burst "${rate_kbit}kbit" \
--hashlimit-above "${rate_kbit_up}kbit/sec" --hashlimit-burst "${rate_kbit_up}kbit" \
--hashlimit-mode srcip --hashlimit-name "rl_${comment}_ul" \
-j DROP -m comment --comment "${comment}_ul" 2>/dev/null
fi
@@ -339,12 +339,16 @@ tctl_ratelimit_remove() {
# The readers that only knew "kbytes" turned the latter into 0 — or, in the
# stats, into 8 kbit/s. Every unit goes back to bytes by nft's factor, and
# bytes to kbit by the package's own, SI (see tctl_rate_bytes).
#
# The second argument picks the direction: "daddr" (the default) is the
# download rule, "saddr" the IPv4 upload one, which carries its own ceiling
# since the two directions could differ (#83).
tctl_rl_rate_kbit() {
awk -v ip="$1" "$TCTL_NFT_RATE_AWK"'
awk -v ip="$1" -v fld="${2:-daddr}" "$TCTL_NFT_RATE_AWK"'
/limit rate over/ {
hit = 0
for (i = 1; i < NF; i++)
if ($i == "daddr" && ($(i+1) == ip || $(i+1) == ip "/32")) { hit = 1; break }
if ($i == fld && ($(i+1) == ip || $(i+1) == ip "/32")) { hit = 1; break }
if (!hit) next
for (j = i; j < NF - 1; j++)
if ($j == "over") { printf "%.0f\n", tctl_rate_kbit($(j+1), $(j+2)); exit }
@@ -1068,6 +1072,38 @@ tctl_shape_classmap() {
}'
}
# The IFB device upload shaping runs on (see trafficctl-shape.sh).
tctl_shape_ifb_dev() {
local d
d=$(uci -q get trafficctl.main.shape_ifb 2>/dev/null)
echo "${d:-tctl-ifb0}"
}
# "classid rate_kbit" for every per-device HTB class on a device, from one tc
# call. On the LAN device that is the download ceiling, on the IFB the upload
# one: a shape uses the same classid on both.
tctl_tc_class_rates() {
command -v tc >/dev/null 2>&1 || return 0
tc class show dev "$1" 2>/dev/null | awk '
/^class htb 1:/ {
classid=$3
minor=classid; sub(/^1:/,"",minor)
if (minor=="1" || minor=="fffe") next
rate=0
for (i=1;i<=NF;i++) {
if ($i=="rate") {
v=$(i+1)
if (v ~ /Gbit/) { sub(/Gbit/,"",v); rate=(v+0)*1000000 }
else if (v ~ /Mbit/) { sub(/Mbit/,"",v); rate=(v+0)*1000 }
else if (v ~ /[Kk]bit/){ sub(/[Kk]bit/,"",v); rate=v+0 }
else { rate=(v+0)/1000 }
break
}
}
print classid, rate
}'
}
tctl_seen_normalize() {
printf '%s' "$1" | tr 'A-Z' 'a-z'
}
@@ -220,27 +220,10 @@ else
RL_DUMP=$(iptables -t mangle -L FORWARD -nv 2>/dev/null)
fi
# tc HTB classes → "classid rate_kbit" map (one tc call, parsed once)
TC_MAP=""
if command -v tc >/dev/null 2>&1; then
TC_MAP=$(tc class show dev "$PRIMARY_LAN_DEV" 2>/dev/null | awk '
/^class htb 1:/ {
classid=$3
minor=classid; sub(/^1:/,"",minor)
if (minor=="1" || minor=="fffe") next
rate=0
for (i=1;i<=NF;i++) {
if ($i=="rate") {
v=$(i+1)
if (v ~ /Gbit/) { sub(/Gbit/,"",v); rate=(v+0)*1000000 }
else if (v ~ /Mbit/) { sub(/Mbit/,"",v); rate=(v+0)*1000 }
else if (v ~ /[Kk]bit/){ sub(/[Kk]bit/,"",v); rate=v+0 }
break
}
}
print classid, rate
}')
fi
# tc HTB classes → "classid rate_kbit" maps (one tc call per device, parsed
# once): download on the LAN device, upload on the IFB.
TC_MAP=$(tctl_tc_class_rates "$PRIMARY_LAN_DEV")
TC_MAP_UP=$(tctl_tc_class_rates "$(tctl_shape_ifb_dev)")
# address → classid, from shapes.json (see tctl_shape_classmap).
SHAPE_MAP=$(tctl_shape_classmap)
@@ -348,13 +331,17 @@ lookup_block_bytes() {
echo "${b:-0}"
}
# $2 "up" reads the upload ceiling instead of the download one.
lookup_rate_limit() {
local ip="$1" r
local ip="$1" r fld=daddr pat='rl_ratelimit'
[ "$2" = "up" ] && { fld=saddr; pat='rl_ratelimit.*_ul'; }
if [ "$TCTL_FW" = "nft" ]; then
r=$(echo "$RL_DUMP" | tctl_rl_rate_kbit "$ip")
r=$(echo "$RL_DUMP" | tctl_rl_rate_kbit "$ip" "$fld")
else
# The address as a whole field, not a substring (see tctl_rl_rate_kbit).
r=$(echo "$RL_DUMP" | grep "rl_ratelimit" \
# The download rule is listed first, so it is the first match either
# 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//')
fi
@@ -365,11 +352,15 @@ lookup_rate_limit() {
# 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.
#
# $2 "up" reads the upload class on the IFB. 0 there with a download shape set
# means upload is not shaped at all (no kmod-ifb), not that it matches.
lookup_shape_kbit() {
local ip="$1" classid r
local ip="$1" classid r map="$TC_MAP"
[ "$2" = "up" ] && map="$TC_MAP_UP"
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}')
r=$(echo "$map" | awk -v c="$classid" '$1==c{print $2; exit}')
echo "${r:-0}"
}
@@ -402,7 +393,9 @@ for ip in $ACTIVE_IPS; do
BLOCK_BYTES=$(lookup_block_bytes "$ip")
WIFI_BLK=$(lookup_wifi_blocked "$MAC")
RATE_LIM=$(lookup_rate_limit "$ip")
RATE_LIM_UP=$(lookup_rate_limit "$ip" up)
SHAPE=$(lookup_shape_kbit "$ip")
SHAPE_UP=$(lookup_shape_kbit "$ip" up)
APP=$(lookup_app "$ip")
if [ -z "$NAME" ]; then
NAME="*"
@@ -462,13 +455,13 @@ for ip in $ACTIVE_IPS; do
else
printf ","
fi
printf '{"ip":"%s","name":"%s","mac":"%s","conn_type":"%s","conn_last":"%s","app":"%s","conns":%d,"total":%.0f,"tcp":%.0f,"udp":%.0f,"blocked":%s,"block_bytes":%.0f,"wifi_blocked":%s,"wifi_block_pending":%s,"rate_limit_kbit":%.0f,"shape_kbit":%.0f}' \
printf '{"ip":"%s","name":"%s","mac":"%s","conn_type":"%s","conn_last":"%s","app":"%s","conns":%d,"total":%.0f,"tcp":%.0f,"udp":%.0f,"blocked":%s,"block_bytes":%.0f,"wifi_blocked":%s,"wifi_block_pending":%s,"rate_limit_kbit":%.0f,"rate_limit_kbit_up":%.0f,"shape_kbit":%.0f,"shape_kbit_up":%.0f}' \
"$ip" "$NAME" "$MAC" "$CONN_TYPE" "$CONN_LAST" "$APP" "$CONNS" "$TOTAL" "$TCP" "$UDP" \
"$([ "$BLOCKED" = "1" ] && echo true || echo false)" \
"$BLOCK_BYTES" \
"$([ "$WIFI_BLK" = "1" ] && echo true || echo false)" \
"$([ "$WIFI_PENDING" = "1" ] && echo true || echo false)" \
"$RATE_LIM" "$SHAPE"
"$RATE_LIM" "$RATE_LIM_UP" "$SHAPE" "$SHAPE_UP"
done
printf "]\n"