mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-01 06:33:56 +08:00
389 lines
15 KiB
Bash
Executable File
389 lines
15 KiB
Bash
Executable File
#!/bin/sh
|
|
# shellcheck shell=dash
|
|
# Traffic shaping (tc/HTB) for per-device bandwidth control.
|
|
# Usage: trafficctl-shape.sh <add|remove|status> <ip> [rate_kbit] [label]
|
|
|
|
. /usr/local/bin/trafficctl-fw.sh
|
|
|
|
SHAPES_FILE="/etc/trafficctl/shapes.json"
|
|
LAN_DEV=$(tctl_get_lan_device)
|
|
# Upload is shaped on the WAN device's egress — the only place LAN->WAN
|
|
# traffic can be queued. Shaping the LAN device alone (as this did originally)
|
|
# only ever touches the download direction.
|
|
# Upload is shaped on an IFB device fed from LAN-side ingress, NOT on the WAN
|
|
# device: by the time a packet reaches WAN egress, POSTROUTING has already
|
|
# masqueraded its source to the router's WAN address, so a per-client
|
|
# "match ip src" filter there matches nothing. Redirecting ingress into an IFB
|
|
# shapes it while the client's real source address is still intact — which is
|
|
# also what makes it work for routed/downstream clients.
|
|
IFB_DEV=$(uci -q get trafficctl.main.shape_ifb 2>/dev/null)
|
|
[ -z "$IFB_DEV" ] && IFB_DEV="tctl-ifb0"
|
|
|
|
ACTION="$1"
|
|
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
|
|
# address: 192.168.0.1 would land on the root class (deleting it tears down
|
|
# every other device's shape) and any x.x.255.254 on the default one. Addresses
|
|
# from different subnets that share their last two octets would also collide,
|
|
# and the app supports routed/downstream clients from other subnets. Minors are
|
|
# therefore allocated once per address and persisted alongside the rate.
|
|
CLASSID_MIN=2
|
|
CLASSID_MAX=65533
|
|
|
|
# Regex-quote a value used in a sed address or grep pattern: an unescaped dot
|
|
# matches any character, so 192.168.1.1 would also match 192.168.1x1.
|
|
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]+")?(,"rate_kbit_up":[0-9]+)?\}' \
|
|
"$SHAPES_FILE" 2>/dev/null
|
|
}
|
|
|
|
# Minor of the classid recorded for this address, empty when not shaped yet.
|
|
lookup_classid() {
|
|
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]*\)\{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
|
|
}
|
|
|
|
used_minors() {
|
|
shapes_entries \
|
|
| sed -n 's/.*"classid":"1:\([0-9a-f]*\)".*/\1/p' \
|
|
| while read -r h; do [ -n "$h" ] && printf '%d\n' "$((0x$h))"; done
|
|
for dev in "$LAN_DEV" "$IFB_DEV"; do
|
|
tc class show dev "$dev" 2>/dev/null \
|
|
| sed -n 's/^class htb 1:\([0-9a-f]*\) .*/\1/p' \
|
|
| while read -r h; do [ -n "$h" ] && printf '%d\n' "$((0x$h))"; done
|
|
done
|
|
}
|
|
|
|
alloc_classid() {
|
|
used_minors | sort -n -u | awk -v min="$CLASSID_MIN" -v max="$CLASSID_MAX" '
|
|
BEGIN { free = min }
|
|
{ if ($1 + 0 == free) free++ }
|
|
END {
|
|
if (free > max) exit 1
|
|
printf "1:%x\n", free
|
|
}'
|
|
}
|
|
|
|
# Shapes persisted before minors were allocated carry no classid, so their class
|
|
# is still the one derived from the address. Only used to clean those up on
|
|
# removal, and never for the two reserved minors the derivation could produce.
|
|
legacy_classid() {
|
|
local ip o3 o4 dec
|
|
ip="$1"
|
|
o3=$(echo "$ip" | cut -d. -f3)
|
|
o4=$(echo "$ip" | cut -d. -f4)
|
|
dec=$((o3 * 256 + o4))
|
|
[ "$dec" -lt "$CLASSID_MIN" ] && return 1
|
|
[ "$dec" -gt "$CLASSID_MAX" ] && return 1
|
|
printf "1:%x\n" "$dec"
|
|
}
|
|
|
|
# Existing shapes keep their minor; a new one gets the lowest free value.
|
|
resolve_classid() {
|
|
local ip cid
|
|
ip="$1"
|
|
cid=$(lookup_classid "$ip")
|
|
if [ -n "$cid" ]; then
|
|
echo "$cid"
|
|
return 0
|
|
fi
|
|
alloc_classid
|
|
}
|
|
|
|
# Root qdiscs that OpenWrt or the kernel put there by default carry no
|
|
# configuration worth preserving. Anything else (cake from SQM, an HFSC or
|
|
# foreign HTB hierarchy) is somebody's QoS setup and must not be torn down
|
|
# silently — the caller reports that shaping is unavailable instead.
|
|
root_qdisc_replaceable() {
|
|
local dev root kind
|
|
dev="$1"
|
|
root=$(tc qdisc show dev "$dev" 2>/dev/null | grep -m1 ' root ')
|
|
[ -n "$root" ] || return 0
|
|
kind=$(echo "$root" | awk '{print $2}')
|
|
case "$kind" in
|
|
pfifo_fast|fq_codel|fq|noqueue|mq|pfifo|bfifo|sfq|codel|htb) return 0 ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
ensure_root_qdisc() {
|
|
local dev="${1:-$LAN_DEV}"
|
|
# Set up root HTB hierarchy if not present
|
|
tc class show dev "$dev" 2>/dev/null | grep -q "class htb 1:1 " && return 0
|
|
root_qdisc_replaceable "$dev" || return 1
|
|
tc qdisc del dev "$dev" root 2>/dev/null
|
|
tc qdisc add dev "$dev" root handle 1: htb default fffe r2q 10
|
|
tc class add dev "$dev" parent 1: classid 1:1 htb rate 1000mbit ceil 1000mbit burst 125000b cburst 125000b
|
|
tc class add dev "$dev" parent 1:1 classid 1:fffe htb rate 1000mbit ceil 1000mbit burst 125000b cburst 125000b prio 0
|
|
tc qdisc add dev "$dev" parent 1:fffe fq_codel 2>/dev/null
|
|
}
|
|
|
|
# Bring up the IFB device and mirror LAN-side ingress into it. Needs kmod-ifb
|
|
# and act_mirred; returns non-zero when either is missing so callers can report
|
|
# download-only shaping instead of pretending upload is limited.
|
|
ensure_ifb() {
|
|
local dev
|
|
if ! ip link show "$IFB_DEV" >/dev/null 2>&1; then
|
|
ip link add name "$IFB_DEV" type ifb 2>/dev/null || return 1
|
|
fi
|
|
ip link set "$IFB_DEV" up 2>/dev/null || return 1
|
|
ensure_root_qdisc "$IFB_DEV" || return 1
|
|
|
|
for dev in $(tctl_ingress_devices); do
|
|
tc qdisc show dev "$dev" ingress 2>/dev/null | grep -q 'qdisc ingress' || \
|
|
tc qdisc add dev "$dev" handle ffff: ingress 2>/dev/null
|
|
tc filter show dev "$dev" parent ffff: 2>/dev/null | grep -q mirred || \
|
|
tc filter add dev "$dev" parent ffff: protocol ip prio 1 u32 \
|
|
match u32 0 0 action mirred egress redirect dev "$IFB_DEV" 2>/dev/null
|
|
done
|
|
# Useless unless at least the IFB root exists.
|
|
tc class show dev "$IFB_DEV" 2>/dev/null | grep -q "class htb 1:1 "
|
|
}
|
|
|
|
# Attach one shaping class on a device, matching the given IP in the given
|
|
# direction ("dst" on the LAN side for download, "src" on the WAN side for
|
|
# upload).
|
|
shape_attach() {
|
|
local dev="$1" match="$2" ip="$3" classid="$4" rate="$5" burst="$6"
|
|
|
|
tc filter del dev "$dev" parent 1:0 prio 10 protocol ip u32 match ip "$match" "$ip"/32 2>/dev/null
|
|
tc qdisc del dev "$dev" parent "$classid" 2>/dev/null
|
|
tc class del dev "$dev" classid "$classid" 2>/dev/null
|
|
|
|
ensure_root_qdisc "$dev" || return 1
|
|
tc class add dev "$dev" parent 1:1 classid "$classid" htb \
|
|
rate "${rate}kbit" ceil "${rate}kbit" burst "${burst}b" cburst "${burst}b" 2>&1 || return 1
|
|
tc qdisc add dev "$dev" parent "$classid" fq_codel 2>/dev/null
|
|
tc filter add dev "$dev" parent 1:0 prio 10 protocol ip u32 \
|
|
match ip "$match" "$ip"/32 flowid "$classid" 2>&1 || return 1
|
|
}
|
|
|
|
shape_detach() {
|
|
local dev="$1" match="$2" ip="$3" classid="$4"
|
|
tc filter del dev "$dev" parent 1:0 prio 10 protocol ip u32 match ip "$match" "$ip"/32 2>/dev/null
|
|
tc qdisc del dev "$dev" parent "$classid" 2>/dev/null
|
|
tc class del dev "$dev" classid "$classid" 2>/dev/null
|
|
}
|
|
|
|
save_shape() {
|
|
local ip="$1" rate="$2" classid="$3" rate_up="$4"
|
|
mkdir -p "$(dirname "$SHAPES_FILE")"
|
|
[ ! -f "$SHAPES_FILE" ] && echo "[]" > "$SHAPES_FILE"
|
|
|
|
local lockd="/tmp/trafficctl_shapes.lock.d"
|
|
local tmpf="/tmp/shapes_rebuild.$$"
|
|
|
|
# mkdir is atomic, so unlike a test-then-create lock file two writers cannot
|
|
# both believe they hold it and lose one another's entry.
|
|
local tries=0
|
|
while ! mkdir "$lockd" 2>/dev/null; do
|
|
tries=$((tries + 1))
|
|
if [ "$tries" -ge 50 ]; then
|
|
# A stale directory from a killed writer must not block shaping forever.
|
|
rmdir "$lockd" 2>/dev/null || true
|
|
mkdir "$lockd" 2>/dev/null || break
|
|
break
|
|
fi
|
|
sleep 0.1 2>/dev/null || sleep 1
|
|
done
|
|
# shellcheck disable=SC2064
|
|
trap "rm -f '$tmpf'; rmdir '$lockd' 2>/dev/null" EXIT
|
|
|
|
# Portable: extract entries with grep, filter, rebuild JSON
|
|
local old_entries esc
|
|
old_entries=$(shapes_entries || true)
|
|
esc=$(re_quote "$ip")
|
|
local filtered
|
|
filtered=$(echo "$old_entries" | grep -v "^{\"ip\":\"$esc\"," || true)
|
|
if [ "$rate" -gt 0 ] 2>/dev/null; then
|
|
# 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
|
|
filtered="$entry"
|
|
fi
|
|
fi
|
|
printf "[" > "$tmpf"
|
|
echo "$filtered" | awk 'NF{if(n++)printf ",";printf "%s",$0}' >> "$tmpf"
|
|
printf "]" >> "$tmpf"
|
|
|
|
mv "$tmpf" "$SHAPES_FILE"
|
|
rmdir "$lockd" 2>/dev/null || true
|
|
}
|
|
|
|
remove_shape() {
|
|
local ip="$1"
|
|
save_shape "$ip" "0" ""
|
|
}
|
|
|
|
do_add() {
|
|
local classid
|
|
classid=$(resolve_classid "$IP")
|
|
if [ -z "$classid" ]; then
|
|
echo '{"ok":false,"msg":"no free shaping class available"}'
|
|
return 1
|
|
fi
|
|
|
|
# Calculate burst: 10ms of data, minimum 1600 bytes
|
|
local burst_bytes
|
|
burst_bytes=$(( RATE * 125 / 100 ))
|
|
[ "$burst_bytes" -lt 1600 ] && burst_bytes=1600
|
|
|
|
# Download: queue on the LAN device, matching traffic destined to the client.
|
|
if ! shape_attach "$LAN_DEV" dst "$IP" "$classid" "$RATE" "$burst_bytes"; then
|
|
# 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
|
|
|
|
# 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_UP" "$ul_burst" || ul_ok=0
|
|
else
|
|
ul_ok=0
|
|
fi
|
|
|
|
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 $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
|
|
}
|
|
|
|
do_remove() {
|
|
local classid
|
|
classid=$(lookup_classid "$IP")
|
|
[ -n "$classid" ] || classid=$(legacy_classid "$IP")
|
|
|
|
if [ -n "$classid" ]; then
|
|
shape_detach "$LAN_DEV" dst "$IP" "$classid"
|
|
shape_detach "$IFB_DEV" src "$IP" "$classid"
|
|
fi
|
|
|
|
remove_shape "$IP"
|
|
echo "{\"ok\":true,\"msg\":\"shape removed for $IP\"}"
|
|
}
|
|
|
|
do_status() {
|
|
local classid
|
|
classid=$(lookup_classid "$IP")
|
|
if [ -z "$classid" ]; then
|
|
echo "{\"ok\":true,\"ip\":\"$IP\",\"classid\":\"\",\"info\":\"no shape active\"}"
|
|
return 0
|
|
fi
|
|
local info
|
|
info=$(tc -s class show dev "$LAN_DEV" classid "$classid" 2>/dev/null)
|
|
if [ -n "$info" ]; then
|
|
local rate_val
|
|
rate_val=$(echo "$info" | grep -oE 'rate [0-9]+[a-zA-Z]+' | head -1 | awk '{print $2}')
|
|
echo "{\"ok\":true,\"ip\":\"$IP\",\"classid\":\"$classid\",\"info\":\"$rate_val\"}"
|
|
else
|
|
echo "{\"ok\":true,\"ip\":\"$IP\",\"classid\":\"$classid\",\"info\":\"no shape active\"}"
|
|
fi
|
|
}
|
|
|
|
# Main
|
|
case "$ACTION" in
|
|
add)
|
|
if [ -z "$IP" ] || [ -z "$RATE" ]; then
|
|
echo '{"ok":false,"msg":"usage: trafficctl-shape.sh add <ip> <rate_kbit> [label]"}'
|
|
exit 1
|
|
fi
|
|
if ! tctl_validate_ip "$IP"; then
|
|
echo '{"ok":false,"msg":"invalid IP address"}'
|
|
exit 1
|
|
fi
|
|
do_add
|
|
;;
|
|
remove)
|
|
if [ -z "$IP" ]; then
|
|
echo '{"ok":false,"msg":"usage: trafficctl-shape.sh remove <ip>"}'
|
|
exit 1
|
|
fi
|
|
if ! tctl_validate_ip "$IP"; then
|
|
echo '{"ok":false,"msg":"invalid IP address"}'
|
|
exit 1
|
|
fi
|
|
do_remove
|
|
;;
|
|
status)
|
|
if [ -z "$IP" ]; then
|
|
echo '{"ok":false,"msg":"usage: trafficctl-shape.sh status <ip>"}'
|
|
exit 1
|
|
fi
|
|
if ! tctl_validate_ip "$IP"; then
|
|
echo '{"ok":false,"msg":"invalid IP address"}'
|
|
exit 1
|
|
fi
|
|
do_status
|
|
;;
|
|
*)
|
|
echo '{"ok":false,"msg":"usage: trafficctl-shape.sh <add|remove|status> <ip> [rate_kbit] [label]"}'
|
|
exit 1
|
|
;;
|
|
esac
|