🌈 Sync 2026-10-01 13:09:37

This commit is contained in:
github-actions[bot] committed 2026-10-01 13:09:37 +08:00
1 parent dceb6de0e4
commit 889951b6d0
116 files changed
+6376 -1093

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.19.1
PKG_RELEASE:=6
PKG_VERSION:=1.21.0
PKG_RELEASE:=7
PKG_LICENSE:=Apache-2.0
PKG_MAINTAINER:=Denis Iusupov <yusdyr@gmail.com>
@@ -252,6 +252,21 @@
overflow: hidden;
border: 1px solid var(--tc-border);
}
/* Unit picker inside .tc-custom-unit-btns. The download row's pair still
carries these as inline styles; new controls use the class so the active
state is not another inline override to hunt down. */
.tc-unit-btn {
padding: 4px 8px;
font-size: 11px;
cursor: pointer;
background: var(--tc-bg);
color: currentColor;
user-select: none;
}
.tc-unit-btn--on {
background: var(--tc-speed);
color: var(--tc-on-accent);
}
/* ── Subnet target: the each/shared explanation and its warning ──── */
.tc-scope-row {
@@ -114,7 +114,10 @@ var callMacfilterRemove = rpc.declare({
var callRatelimit = rpc.declare({
object: 'luci.trafficctl',
method: 'ratelimit',
params: ['ip', 'rate_kbit', 'label', 'mode']
// rate_kbit_up is omitted for a symmetric limit rather than sent equal to
// rate_kbit: the backend reads absence as "same as download", and sending
// it always would make every record asymmetric-shaped on disk.
params: ['ip', 'rate_kbit', 'label', 'mode', 'rate_kbit_up']
});
var callRatelimitStats = rpc.declare({
@@ -132,7 +135,7 @@ var callSubnets = rpc.declare({
var callShapeAdd = rpc.declare({
object: 'luci.trafficctl',
method: 'shape_add',
params: ['ip', 'rate_kbit', 'label']
params: ['ip', 'rate_kbit', 'label', 'rate_kbit_up']
});
var callShapeRemove = rpc.declare({
@@ -2334,6 +2337,47 @@ return view.extend({
customInput, customUnitBtns, customApplyBtn
]);
// ── Separate upload ceiling ────────────────────────────────────────
//
// Disclosed rather than always shown. Most limits are symmetric, and
// the backend treats an absent upload rate as "same as download", so
// the default state of this control matches the default meaning: one
// number, one click. Opening it is the opt-in into two.
//
// The rate chips above stay the DOWNLOAD ceiling in either state, so
// the common path does not change shape when this is revealed.
var upInput = E('input', { 'type':'number', 'min':'1', 'step':'1',
'placeholder': _('value'), 'class': 'tc-custom-input' });
var _upUnit = 'mbit';
var upMbitBtn = E('span', {'class':'tc-unit-btn'}, _('Mbit/s'));
var upKbitBtn = E('span', {'class':'tc-unit-btn'}, _('kbit/s'));
function updateUpUnitBtns() {
upMbitBtn.classList.toggle('tc-unit-btn--on', _upUnit === 'mbit');
upKbitBtn.classList.toggle('tc-unit-btn--on', _upUnit === 'kbit');
}
upMbitBtn.addEventListener('click', function() { _upUnit = 'mbit'; updateUpUnitBtns(); updateScopeExplain(); });
upKbitBtn.addEventListener('click', function() { _upUnit = 'kbit'; updateUpUnitBtns(); updateScopeExplain(); });
updateUpUnitBtns();
upInput.addEventListener('input', function() { updateScopeExplain(); });
var upRow = E('div', {'class':'tc-custom-row tc-hidden'}, [
E('span', {'class':'tc-c-muted','style':'font-size:11px'}, _('↑ Upload')),
upInput,
E('span', {'class':'tc-custom-unit-btns'}, [upMbitBtn, upKbitBtn])
]);
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');
upToggleBtn.classList.toggle('tc-chip--active', on);
if (on) { upInput.focus(); } else { upInput.value = ''; }
updateScopeExplain();
});
rateChipsRow.appendChild(upToggleBtn);
// Mode: segmented toggle (Shaper default)
var modeToggle = E('div', {'class':'tc-mode-toggle'});
var shaperBtn = E('span', {
@@ -2446,15 +2490,29 @@ return view.extend({
var t = (scopeInput.value || 'all').trim();
var shown = (t === 'all' || t === 'any') ? _('every device') : t;
var kbit = parseInt(getRateKbit(), 10);
// The sentence has to quote the rate that will actually be applied.
// Naming only the download figure while a split ceiling is set would
// describe a different rule than the one being installed — and these
// sentences exist precisely so the each/shared distinction cannot be
// misread.
var kbitUp = parseInt(getRateKbitUp(), 10);
var rateWords = (kbitUp && kbitUp !== kbit)
? _('%s down and %s up').format(fmtRate(kbit), fmtRate(kbitUp))
: fmtRate(kbit);
// A split ceiling is two buckets, one per direction, so the aggregate
// wording changes with it.
var sharedTail = (kbitUp && kbitUp !== kbit)
? _('between them — one bucket per direction for the whole subnet.')
: _('between them — one bucket for the whole subnet.');
if (!kbit || kbit <= 0) {
scopeExplain.textContent =
_('Pick a rate to apply it to this target, or Off to remove its limit.');
} else if (_scopeSelected === 'shared') {
scopeExplain.textContent = _('All of') + ' ' + shown + ' ' + _('share') + ' ' +
fmtRate(kbit) + ' ' + _('between them — one bucket for the whole subnet.');
rateWords + ' ' + sharedTail;
} else {
scopeExplain.textContent = _('Every device in') + ' ' + shown + ' ' +
_('may use') + ' ' + fmtRate(kbit) + ' ' + _('of its own.');
_('may use') + ' ' + rateWords + ' ' + _('of its own.');
}
var bad = !targetIsMonitored(t);
@@ -2588,6 +2646,22 @@ return view.extend({
return String(Math.round(n));
}
// Empty string means symmetric — never 0, which the backend would have
// to read as a ceiling and which is a total upload block.
function getRateKbitUp() {
if (upRow.classList.contains('tc-hidden')) return '';
var n = parseFloat(upInput.value);
if (!n || n <= 0) return '';
return String(_upUnit === 'mbit' ? Math.round(n * 1000) : Math.round(n));
}
// How the pending action is described. Naming one figure when the
// operator has entered two would hide the thing they just asked for.
function rateLabel(kbit, up) {
if (!up || up === kbit) return fmtRate(parseInt(kbit));
return _('%s down / %s up').format(fmtRate(parseInt(kbit)), fmtRate(parseInt(up)));
}
function applyRate() {
var all = isAllMode();
// In all-devices mode the target is the typed scope ("all" or a
@@ -2595,6 +2669,7 @@ return view.extend({
var ip = all ? (scopeInput.value || 'all').trim() : searchSelect.getValue();
var name = '';
var kbit = getRateKbit();
var kbitUp = getRateKbitUp();
// 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;
@@ -2602,8 +2677,8 @@ return view.extend({
if (all && kbit !== '0') {
setStatus(statusDiv, 'loading',
_('Limiting') + ' ' + ip + ' → ' + fmtRate(parseInt(kbit)) + ' (' + scope + ')…');
callRatelimit(ip, parseInt(kbit), name, scope).then(function(res) {
_('Limiting') + ' ' + ip + ' → ' + rateLabel(kbit, kbitUp) + ' (' + scope + ')…');
callRatelimit(ip, parseInt(kbit), name, scope, kbitUp ? parseInt(kbitUp) : null).then(function(res) {
setStatus(statusDiv, (res && res.ok) ? 'action' : 'error', (res && res.msg) || '?');
runQuery();
}).catch(function(e) { setStatus(statusDiv, 'error', '✗ '+e.message); });
@@ -2629,18 +2704,18 @@ return view.extend({
runQuery();
}).catch(function(e) { setStatus(statusDiv, 'error', '✗ '+e.message); });
} else if (mode === 'shaper') {
setStatus(statusDiv, 'loading', _('Shaping') + ' → ' + fmtRate(parseInt(kbit)) + '…');
setStatus(statusDiv, 'loading', _('Shaping') + ' → ' + rateLabel(kbit, kbitUp) + '…');
callRatelimit(ip, 0, name)
.then(function() { return callShapeAdd(ip, parseInt(kbit), name); })
.then(function() { return callShapeAdd(ip, parseInt(kbit), name, kbitUp ? parseInt(kbitUp) : null); })
.then(function(res) {
setStatus(statusDiv, (res && res.ok) ? 'action' : 'error', (res && res.msg) || '?');
runQuery();
})
.catch(function(e) { setStatus(statusDiv, 'error', '✗ '+e.message); });
} else {
setStatus(statusDiv, 'loading', _('Limiting') + ' → ' + fmtRate(parseInt(kbit)) + '…');
setStatus(statusDiv, 'loading', _('Limiting') + ' → ' + rateLabel(kbit, kbitUp) + '…');
callShapeRemove(ip, name)
.then(function() { return callRatelimit(ip, parseInt(kbit), name); })
.then(function() { return callRatelimit(ip, parseInt(kbit), name, '', kbitUp ? parseInt(kbitUp) : null); })
.then(function(res) {
setStatus(statusDiv, (res && res.ok) ? 'action' : 'error', (res && res.msg) || '?');
runQuery();
@@ -27,8 +27,13 @@ done
if [ -f "$SHAPES_FILE" ]; then
jsonfilter -i "$SHAPES_FILE" -e '@[*].ip' 2>/dev/null | while read -r ip; do
rate=$(jsonfilter -i "$SHAPES_FILE" -e "@[@.ip='$ip'].rate_kbit" 2>/dev/null)
# Absent on symmetric records, which is all of them before asymmetric
# shaping existed; trafficctl-shape.sh reads empty as "same as
# download". The label has to be passed explicitly because the upload
# rate is the fifth positional.
rate_up=$(jsonfilter -i "$SHAPES_FILE" -e "@[@.ip='$ip'].rate_kbit_up" 2>/dev/null)
if [ -n "$rate" ] && [ "$rate" != "0" ]; then
/usr/local/bin/trafficctl-shape.sh add "$ip" "$rate" || \
/usr/local/bin/trafficctl-shape.sh add "$ip" "$rate" "shape_$ip" "$rate_up" || \
logger -t trafficctl "Failed to restore shape for $ip (${rate}kbit)"
fi
done
@@ -56,8 +61,13 @@ if [ -f "$RULES_FILE" ] && tctl_persist_enabled; then
# 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")
# Absent on every record written before the directions could
# differ, and on every symmetric one since. tctl_ratelimit_add
# reads that as "same as download", which is what those records
# have always meant.
param_up=$(jsonfilter -i "$RULES_FILE" -e "@[$idx].param_up" 2>/dev/null)
[ -n "$param" ] && [ "$param" != "0" ] && \
tctl_ratelimit_add "$ip" "$param" "$(tctl_ratelimit_comment "$ip")" "$mode" 2>/dev/null || \
tctl_ratelimit_add "$ip" "$param" "$(tctl_ratelimit_comment "$ip")" "$mode" "$param_up" 2>/dev/null || \
logger -t trafficctl "Failed to restore ratelimit for $ip (${param}kbit)"
;;
pfw_pause|pfw_limit)
@@ -43,10 +43,10 @@ list)
printf '"unblock":{"ip":"str","label":"str"},'
printf '"macfilter_add":{"ip":"str"},'
printf '"macfilter_remove":{"ip":"str"},'
printf '"ratelimit":{"ip":"str","rate_kbit":"int","label":"str","mode":"str"},'
printf '"ratelimit":{"ip":"str","rate_kbit":"int","label":"str","mode":"str","rate_kbit_up":"int"},'
printf '"ratelimit_stats":{},'
printf '"subnets":{},'
printf '"shape_add":{"ip":"str","rate_kbit":"int","label":"str"},'
printf '"shape_add":{"ip":"str","rate_kbit":"int","label":"str","rate_kbit_up":"int"},'
printf '"shape_remove":{"ip":"str","label":"str"},'
printf '"shape_status":{"ip":"str"},'
printf '"shape_stats":{},'
@@ -159,10 +159,16 @@ call)
label=$(sanitize_label "$(echo "$input" | jsonfilter -e '@.label' 2>/dev/null)")
mode=$(echo "$input" | jsonfilter -e '@.mode' 2>/dev/null)
case "$mode" in each|shared) ;; *) mode="" ;; esac
rate_up=$(echo "$input" | jsonfilter -e '@.rate_kbit_up' 2>/dev/null)
# A caller that does not send the field — any UI older than this
# method signature — means "symmetric", NOT "no upload allowed". Zero
# is normalised away for the same reason: a zero ceiling polices every
# packet, which is a total upload block reporting itself as a limit.
[ "$rate_up" = "0" ] && rate_up=""
if [ -z "$ip" ]; then
echo '{"ok":false,"msg":"ip required"}'
else
"$SCRIPTS/trafficctl-ratelimit.sh" "$ip" "${rate:-0}" "$label" "$mode"
"$SCRIPTS/trafficctl-ratelimit.sh" "$ip" "${rate:-0}" "$label" "$mode" "$rate_up"
fi
;;
ratelimit_stats)
@@ -180,10 +186,13 @@ call)
ip=$(echo "$input" | jsonfilter -e '@.ip' 2>/dev/null)
rate=$(echo "$input" | jsonfilter -e '@.rate_kbit' 2>/dev/null)
label=$(sanitize_label "$(echo "$input" | jsonfilter -e '@.label' 2>/dev/null)")
rate_up=$(echo "$input" | jsonfilter -e '@.rate_kbit_up' 2>/dev/null)
# Same reading as the limiter: absent or zero means "same as download".
[ "$rate_up" = "0" ] && rate_up=""
if [ -z "$ip" ] || [ -z "$rate" ]; then
echo '{"ok":false,"msg":"ip and rate_kbit required"}'
else
"$SCRIPTS/trafficctl-shape.sh" add "$ip" "$rate" "$label"
"$SCRIPTS/trafficctl-shape.sh" add "$ip" "$rate" "$label" "$rate_up"
fi
;;
shape_remove)
@@ -119,18 +119,26 @@ tctl_conntrack_flush_v6() {
# meter keyed by address); "shared" makes the whole target share one bucket.
# For a single host the two are identical.
tctl_ratelimit_add() {
local ip="$1" rate_kbit="$2" comment="$3" mode="${4:-shared}"
local ip="$1" rate_kbit="$2" comment="$3" mode="${4:-shared}" rate_kbit_up="$5"
# Empty means "same as download" — the only meaning this had before the two
# directions could differ. Not zero: a zero ceiling policed every packet and
# would be a silent total block reported as a successful limit.
[ -z "$rate_kbit_up" ] && rate_kbit_up="$rate_kbit"
local rate_kbyte=$((rate_kbit / 8))
[ "$rate_kbyte" -lt 1 ] && rate_kbyte=1
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
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.
if [ "$mode" = "each" ]; then
dl_expr="ip daddr $ip meter tctl_d_$slug { ip daddr limit rate over ${rate_kbyte} kbytes/second }"
ul_expr="ip saddr $ip meter tctl_u_$slug { ip saddr limit rate over ${rate_kbyte} kbytes/second }"
ul_expr="ip saddr $ip meter tctl_u_$slug { ip saddr limit rate over ${rate_kbyte_up} kbytes/second }"
else
dl_expr="ip daddr $ip limit rate over ${rate_kbyte} kbytes/second"
ul_expr="ip saddr $ip limit rate over ${rate_kbyte} kbytes/second"
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:
@@ -155,7 +163,7 @@ tctl_ratelimit_add() {
# 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"
ul6_expr="meta protocol ip6 ether saddr $mac limit rate over ${rate_kbyte_up} kbytes/second"
fi
if [ "$TCTL_FW" = "nft" ]; then
@@ -837,15 +845,18 @@ tctl_persist_enabled() {
}
tctl_persist_save() {
local type="$1" ip="$2" param="$3" mode="$4"
local type="$1" ip="$2" param="$3" mode="$4" param_up="$5"
[ -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.
# exact previous shape. param_up follows the same rule: the caller passes it
# only for an asymmetric limit, so a symmetric one still writes the record
# it always wrote and an older package can still read it back.
local extra=""
[ -n "$mode" ] && extra=",\"mode\":\"$mode\""
[ -n "$param_up" ] && extra="$extra,\"param_up\":\"$param_up\""
# Remove existing entry for same ip+type, append new one
awk -v ip="$ip" -v t="$type" -v p="$param" -v x="$extra" '
{
@@ -2,11 +2,13 @@
# shellcheck shell=dash
# Rate-limit bandwidth in both directions (policer).
#
# Usage: trafficctl-ratelimit.sh <target> <rate_kbit> [label] [mode]
# Usage: trafficctl-ratelimit.sh <target> <rate_kbit> [label] [mode] [rate_up]
# target a host (10.0.20.122), a CIDR (10.0.20.0/24), or "all"
# mode each — every address in the target gets its own bucket (default
# for CIDR/all, so "5 Mbit each" means per device)
# shared — the whole target shares one bucket (aggregate cap)
# rate_up upload ceiling in kbit/s; omitted means the same as rate_kbit,
# which is what this did before the directions could differ
# rate_kbit=0 removes the limit.
. /usr/local/bin/trafficctl-fw.sh
@@ -14,6 +16,11 @@
IP="$1"
RATE="$2"
MODE="$4"
# Fifth positional so every existing caller keeps working unchanged. Empty is
# "same as download", never zero — a zero ceiling policed every packet, which
# is a total block dressed up as a limit.
RATE_UP="$5"
[ -z "$RATE_UP" ] && RATE_UP="$RATE"
if [ -z "$IP" ] || [ -z "$RATE" ]; then
echo '{"ok":false,"msg":"usage: trafficctl-ratelimit.sh <target> <rate_kbit> [label] [each|shared]"}'
@@ -50,11 +57,20 @@ if [ "$RATE" = "0" ]; then
exit 1
fi
else
# How the applied ceiling is spelled out. Naming one number when the
# operator gave two would hide exactly the thing they asked for.
rate_text() {
if [ "$RATE_UP" != "$RATE" ]; then
echo "${RATE} kbit/s down, ${RATE_UP} kbit/s up"
else
echo "${RATE} kbit/s"
fi
}
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"
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
@@ -75,13 +91,16 @@ else
# 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}"
# The upload rate is persisted alongside, and only when it differs — a
# symmetric limit keeps producing exactly the record it always did.
tctl_persist_enabled && tctl_persist_save "ratelimit" "$IP" "$RATE" "$MODE" \
"$([ "$RATE_UP" != "$RATE" ] && echo "$RATE_UP")"
tctl_log "ratelimit_set" "$IP" "$(rate_text)" "${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\"}"
echo "{\"ok\":true,\"ipv6_upload\":$([ "$TCTL_RL_UPLOAD6_OK" = "1" ] && echo true || echo false),\"msg\":\"rate limit ${RATE_UP} kbit/s applied to $IP UPLOAD ONLY — WAN device not resolvable$V6NOTE\"}"
elif [ "$TCTL_RL_UPLOAD_FAILED" = "1" ]; then
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,\"ipv6_upload\":$([ "$TCTL_RL_UPLOAD6_OK" = "1" ] && echo true || echo false),\"msg\":\"rate limit ${RATE} kbit/s for $IP (both directions, $MODE)$V6NOTE\"}"
echo "{\"ok\":true,\"ipv6_upload\":$([ "$TCTL_RL_UPLOAD6_OK" = "1" ] && echo true || echo false),\"msg\":\"rate limit $(rate_text) for $IP (both directions, $MODE)$V6NOTE\"}"
fi
fi
@@ -24,6 +24,13 @@ IP="$2"
RATE="$3"
# shellcheck disable=SC2034
LABEL="${4:-shape_$IP}"
# Upload ceiling. Appended as a fifth positional so every existing caller —
# the rpcd backend, the Telegram bot, the DHCP new-device hook and the boot
# restore — keeps working unchanged. Empty means "same as download", which is
# what shaping did before this existed; it is deliberately NOT treated as zero,
# since a zero ceiling is a total upload block that would report success.
RATE_UP="$5"
[ -z "$RATE_UP" ] && RATE_UP="$RATE"
# A classid minor is 16 bit and two values are already taken — 1:1 is the root
# class and 1:fffe the default one — so a minor cannot be derived from the
@@ -41,8 +48,14 @@ re_quote() {
echo "$1" | sed 's/[].[^$*\/]/\\&/g'
}
# Records are extracted by pattern, not parsed, so this expression decides what
# counts as a record at all. A record it does not match is not merely misread —
# it disappears from used_minors(), alloc_classid hands its minor to another
# device, and the two then share an HTB class: removing one tears down the
# other. The optional rate_kbit_up group is therefore at the TAIL, and every
# reader below tolerates both the old three-field form and the new one.
shapes_entries() {
grep -oE '\{"ip":"[^"]+","rate_kbit":[0-9]+(,"classid":"1:[0-9a-f]+")?\}' \
grep -oE '\{"ip":"[^"]+","rate_kbit":[0-9]+(,"classid":"1:[0-9a-f]+")?(,"rate_kbit_up":[0-9]+)?\}' \
"$SHAPES_FILE" 2>/dev/null
}
@@ -52,7 +65,19 @@ lookup_classid() {
ip="$1"
esc=$(re_quote "$ip")
shapes_entries \
| sed -n "s/^{\"ip\":\"$esc\",\"rate_kbit\":[0-9]*,\"classid\":\"\(1:[0-9a-f]*\)\"}$/\1/p" \
| sed -n "s/^{\"ip\":\"$esc\",\"rate_kbit\":[0-9]*,\"classid\":\"\(1:[0-9a-f]*\)\"\(,\"rate_kbit_up\":[0-9]*\)\{0,1\}}$/\1/p" \
| head -1
}
# Upload ceiling recorded for this address. Empty means the record predates
# asymmetric shaping, or was written symmetric — both mean "same as download",
# which is what every shape did before this existed.
lookup_rate_up() {
local ip esc
ip="$1"
esc=$(re_quote "$ip")
shapes_entries \
| sed -n "s/^{\"ip\":\"$esc\",\"rate_kbit\":[0-9]*,\"classid\":\"1:[0-9a-f]*\",\"rate_kbit_up\":\([0-9]*\)}$/\1/p" \
| head -1
}
@@ -179,7 +204,7 @@ shape_detach() {
}
save_shape() {
local ip="$1" rate="$2" classid="$3"
local ip="$1" rate="$2" classid="$3" rate_up="$4"
mkdir -p "$(dirname "$SHAPES_FILE")"
[ ! -f "$SHAPES_FILE" ] && echo "[]" > "$SHAPES_FILE"
@@ -209,7 +234,12 @@ save_shape() {
local filtered
filtered=$(echo "$old_entries" | grep -v "^{\"ip\":\"$esc\"," || true)
if [ "$rate" -gt 0 ] 2>/dev/null; then
local entry="{\"ip\":\"$ip\",\"rate_kbit\":$rate,\"classid\":\"$classid\"}"
# rate_kbit_up is written only when the two directions differ, so a
# symmetric shape still produces exactly the record it always did.
# Downgrading the package then keeps working for the common case.
local up_field=""
[ -n "$rate_up" ] && [ "$rate_up" != "$rate" ] && up_field=",\"rate_kbit_up\":$rate_up"
local entry="{\"ip\":\"$ip\",\"rate_kbit\":$rate,\"classid\":\"$classid\"$up_field}"
if [ -n "$filtered" ]; then
filtered=$(printf "%s\n%s" "$filtered" "$entry")
else
@@ -258,16 +288,27 @@ do_add() {
# Upload: shaped pre-NAT on the IFB device fed from LAN ingress.
# Reported separately so a missing kmod-ifb doesn't look like total failure.
# The two directions are already separate HTB classes on separate devices,
# so giving them separate rates costs nothing beyond its own burst.
local ul_burst
ul_burst=$(( RATE_UP * 125 / 100 ))
[ "$ul_burst" -lt 1600 ] && ul_burst=1600
local ul_ok=1
if ensure_ifb; then
shape_attach "$IFB_DEV" src "$IP" "$classid" "$RATE" "$burst_bytes" || ul_ok=0
shape_attach "$IFB_DEV" src "$IP" "$classid" "$RATE_UP" "$ul_burst" || ul_ok=0
else
ul_ok=0
fi
save_shape "$IP" "$RATE" "$classid"
save_shape "$IP" "$RATE" "$classid" "$RATE_UP"
local rates="${RATE} kbit/s"
[ "$RATE_UP" != "$RATE" ] && rates="${RATE} kbit/s down, ${RATE_UP} kbit/s up"
if [ "$ul_ok" = "1" ]; then
echo "{\"ok\":true,\"msg\":\"shape ${RATE} kbit/s applied to $IP both directions (class $classid)\"}"
echo "{\"ok\":true,\"msg\":\"shape $rates applied to $IP both directions (class $classid)\"}"
elif [ "$RATE_UP" != "$RATE" ]; then
# Saying "applied" here would be a lie about the half the operator
# explicitly asked to differ.
echo "{\"ok\":true,\"msg\":\"shape ${RATE} kbit/s applied to $IP for download only — the ${RATE_UP} kbit/s upload ceiling was NOT applied, it needs kmod-ifb: opkg/apk install kmod-ifb kmod-sched\"}"
else
echo "{\"ok\":true,\"msg\":\"shape ${RATE} kbit/s applied to $IP (download only — upload needs kmod-ifb: opkg/apk install kmod-ifb kmod-sched)\"}"
fi