🤞 Sync 2026-09-27 22:16:29

This commit is contained in:
github-actions[bot] committed 2026-09-27 22:16:29 +08:00
1 parent 8ede4a8320
commit 618f51e9dc
459 files changed
+44817 -2191

No files matched your search

@@ -0,0 +1,89 @@
config trafficctl 'main'
option enabled '1'
option default_mode 'limiter'
option persist_rules '0'
# Extra subnets to monitor (space-separated CIDRs), for downstream-router
# clients that have no kernel route on this router. Subnets with a
# "via" route over a LAN interface are picked up automatically.
option extra_subnets ''
# Resolve names for devices with no DHCP lease here (routed/downstream
# clients) via reverse DNS. rdns_server is a space-separated list of
# resolvers tried in order before the system resolver — point it at the
# downstream router (e.g. a MikroTik) so its lease names get used.
option resolve_names '1'
option rdns_server ''
option rdns_ttl '300'
# Optional netifyd (DPI) integration — adds application labels per device.
# Inactive unless the agent is installed and its socket exists.
option netify_enabled '1'
option netify_socket '/var/run/netifyd/netifyd.sock'
# Where flow telemetry is published. Agent v5's unix socket is a
# request/response API that never pushes flows — the socket SINK does,
# usually tcp://127.0.0.1:1780. Empty = auto-detect from
# /etc/netifyd/netify-sink-socket.json.
option netify_endpoint ''
option netify_interval '45'
# IFB device used to shape upload pre-NAT (needs kmod-ifb).
option shape_ifb 'tctl-ifb0'
# Dashboard defaults for a browser that has not chosen its own. The Poll
# and Window chips still override per-browser — these only decide what a
# fresh session starts with, which is the part an admin can usefully set
# once for a router rather than per person (#12).
#
# poll_interval: seconds between live samples; 0 disables polling.
# A smaller number costs a conntrack read per tick on the router, which
# is the overhead the request was about.
# avg_window: seconds of history the displayed speed is averaged over.
# Samples kept per device are avg_window/poll_interval, so a long window
# with a short poll is the expensive combination.
option poll_interval '2'
option avg_window '15'
config newdevice 'newdevice'
# Apply a limit automatically the first time a device is seen. Off by
# default: it is the only setting here that restricts a device nobody
# has looked at yet. limit_kbit 0 means "no limit" and makes enabled
# a no-op. An existing limit is never overwritten.
option enabled '0'
option limit_kbit '0'
option limit_mode 'limiter'
config logging 'logging'
option enabled '1'
option log_file '/tmp/trafficctl/activity.log'
option max_lines '500'
option syslog '1'
option log_blocks '1'
option log_ratelimits '1'
option log_shapes '1'
option log_telegram '1'
option log_config '1'
config telegram 'telegram'
option enabled '0'
option bot_token ''
option chat_id ''
option poll_interval '3'
option notify_new_device '1'
option notify_known_device '0'
option control_enabled '1'
option notify_template ''
option btn_block_inet '1'
option btn_block_wifi '1'
option btn_limiter '1'
option btn_shaper '1'
config metrics 'metrics'
# Prometheus/OpenMetrics exporter. Off by default: it exposes device
# names, addresses and traffic volumes without a LuCI session.
option enabled '0'
# Shared token for the CGI endpoint (empty = no token check).
option token ''
# Per-device gauges (connections, blocked, limits, name/mac). Costs a
# full summary run per scrape — set 0 for byte counters only.
option state '1'
# Port-forward metrics.
option portfw '1'
# DPI application metrics. Off by default: device x application is a
# cardinality trap on a busy network.
option apps '0'
@@ -0,0 +1,103 @@
#!/bin/sh
# shellcheck shell=dash
# Triggered by dnsmasq on DHCP events (add/old/del).
# Available env vars: ACTION, MACADDR, IPADDR, HOSTNAME
#
# Two consumers sit on "this MAC is new":
# * the Telegram bot, which wants a notification
# * the default-limit feature, which applies a rate limit or shape
#
# They are deliberately independent — either can be on without the other —
# and they use different notions of "known". The bot has always keyed off its
# own telegram_known.json, which the bot itself rewrites; the limiter uses the
# seen-MAC ledger in trafficctl-fw.sh, because applying a limit is not an
# action that may be repeated or misfired.
[ "$ACTION" = "add" ] || exit 0
[ -n "$MACADDR" ] || exit 0
# ── Telegram notification ──────────────────────────────────────────────────
KNOWN_FILE="/etc/trafficctl/telegram_known.json"
NOTIFY_FLAG="/tmp/trafficctl_tg_newdev"
if [ "$(uci -q get trafficctl.telegram.enabled)" = "1" ] &&
! grep -q "\"$MACADDR\"" "$KNOWN_FILE" 2>/dev/null; then
mkdir -p "$(dirname "$NOTIFY_FLAG")"
# HOSTNAME comes from the dnsmasq dhcp hotplug environment (see header),
# not a POSIX shell builtin — shellcheck's SC3028 is a false positive.
# shellcheck disable=SC3028
printf '%s\t%s\t%s\n' "$MACADDR" "${IPADDR:-?}" "${HOSTNAME:-unknown}" >> "$NOTIFY_FLAG"
fi
# ── Default limit for a device seen for the first time ─────────────────────
[ "$(uci -q get trafficctl.newdevice.enabled 2>/dev/null)" = "1" ] || exit 0
LIMIT_KBIT=$(uci -q get trafficctl.newdevice.limit_kbit 2>/dev/null)
[ -n "$LIMIT_KBIT" ] && [ "$LIMIT_KBIT" -gt 0 ] 2>/dev/null || exit 0
. /usr/local/bin/trafficctl-fw.sh
# The ledger decides what "new" means, so a run that has no ledger yet cannot
# answer the question — and the wrong answer here limits every device on the
# LAN as its lease renews.
#
# Seeding alone would not make that answer trustworthy: the seed reads the
# leases and the neighbour table, and both can be thin or empty early in boot,
# so a device missing from them would look new purely because the router had
# not noticed it yet. The first pass therefore records the baseline and
# applies nothing at all, whatever the seed happened to find.
if ! tctl_seen_ready; then
tctl_seen_seed
tctl_seen_mark "$MACADDR"
logger -t trafficctl "newdevice: seeded the seen-MAC ledger ($(tctl_seen_count) devices); no limit applied on this pass"
exit 0
fi
tctl_seen_known "$MACADDR" && exit 0
# Mark BEFORE acting. A failure while applying the limit must not leave the
# device unmarked, or every subsequent lease renewal retries — and each retry
# is another chance to race with an operator setting a limit by hand.
tctl_seen_mark "$MACADDR"
[ -n "$IPADDR" ] || {
logger -t trafficctl "newdevice: $MACADDR is new but has no address yet; no limit applied"
exit 0
}
# Never override a limit somebody set deliberately.
if tctl_has_limit "$IPADDR"; then
logger -t trafficctl "newdevice: $IPADDR ($MACADDR) already has a limit; left alone"
exit 0
fi
LIMIT_MODE=$(uci -q get trafficctl.newdevice.limit_mode 2>/dev/null)
[ -n "$LIMIT_MODE" ] || LIMIT_MODE="limiter"
case "$LIMIT_MODE" in
shaper)
TCTL_VIA=newdevice TCTL_SRC=hotplug \
/usr/local/bin/trafficctl-shape.sh add "$IPADDR" "$LIMIT_KBIT" \
"shape_$IPADDR" >/dev/null 2>&1
rc=$?
;;
*)
TCTL_VIA=newdevice TCTL_SRC=hotplug \
/usr/local/bin/trafficctl-ratelimit.sh "$IPADDR" "$LIMIT_KBIT" \
"$(tctl_ratelimit_comment "$IPADDR")" >/dev/null 2>&1
rc=$?
;;
esac
if [ "$rc" = "0" ]; then
# trafficctl-ratelimit.sh writes its own activity-log entry and picks up
# the via=newdevice/src=hotplug exported above, so the limiter path is
# already recorded. trafficctl-shape.sh logs nothing, hence this branch.
[ "$LIMIT_MODE" = "shaper" ] &&
tctl_log "shape_set" "$IPADDR" \
"${LIMIT_KBIT}kbit default for new device $MACADDR" "newdevice" "hotplug"
else
logger -t trafficctl "newdevice: failed to apply ${LIMIT_KBIT}kbit to $IPADDR ($MACADDR)"
fi
@@ -0,0 +1,80 @@
#!/bin/sh
# shellcheck shell=dash
# Restore persistent rules after LAN interface comes up
. /usr/local/bin/trafficctl-fw.sh
# Clean up nft monitor table on lan ifdown to avoid stale counters
if [ "$ACTION" = "ifdown" ] && [ "$INTERFACE" = "lan" ]; then
nft delete table inet trafficctl_mon 2>/dev/null
exit 0
fi
[ "$ACTION" = "ifup" ] && [ "$INTERFACE" = "lan" ] || exit 0
SHAPES_FILE="/etc/trafficctl/shapes.json"
RULES_FILE="/etc/trafficctl/rules.json"
# Wait for network stack to be ready (up to 10 seconds)
tries=0
while ! tc qdisc show dev "$(tctl_get_lan_device)" >/dev/null 2>&1; do
tries=$((tries + 1))
[ "$tries" -ge 20 ] && break
sleep 0.5 2>/dev/null || sleep 1
done
# Restore shaping rules
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)
if [ -n "$rate" ] && [ "$rate" != "0" ]; then
/usr/local/bin/trafficctl-shape.sh add "$ip" "$rate" || \
logger -t trafficctl "Failed to restore shape for $ip (${rate}kbit)"
fi
done
fi
# Restore blocks and ratelimits (if persist enabled)
if [ -f "$RULES_FILE" ] && tctl_persist_enabled; then
jsonfilter -i "$RULES_FILE" -e '@[*]' 2>/dev/null | while read -r _; do true; done
for idx in $(seq 0 99); do
type=$(jsonfilter -i "$RULES_FILE" -e "@[$idx].type" 2>/dev/null)
[ -z "$type" ] && break
ip=$(jsonfilter -i "$RULES_FILE" -e "@[$idx].ip" 2>/dev/null)
param=$(jsonfilter -i "$RULES_FILE" -e "@[$idx].param" 2>/dev/null)
[ -z "$ip" ] && continue
case "$type" in
block)
tctl_block_add "$ip" "$(tctl_block_comment "$ip")" 2>/dev/null || \
logger -t trafficctl "Failed to restore block for $ip"
;;
ratelimit)
[ -n "$param" ] && [ "$param" != "0" ] && \
tctl_ratelimit_add "$ip" "$param" "$(tctl_ratelimit_comment "$ip")" 2>/dev/null || \
logger -t trafficctl "Failed to restore ratelimit for $ip (${param}kbit)"
;;
pfw_pause|pfw_limit)
# ip field is a composite key: scope/proto/ip|local/port
_scope=${ip%%/*}; _rest=${ip#*/}
_proto=${_rest%%/*}; _rest=${_rest#*/}
_pip=${_rest%%/*}; _pport=${_rest#*/}
[ "$_pip" = "local" ] && _pip="-"
if [ "$type" = "pfw_pause" ]; then
/usr/local/bin/trafficctl-portfw.sh pause "$_scope" "$_proto" "$_pip" "$_pport" >/dev/null 2>&1 || \
logger -t trafficctl "Failed to restore port pause for $_pport"
else
[ -n "$param" ] && [ "$param" != "0" ] && \
/usr/local/bin/trafficctl-portfw.sh limit "$_scope" "$_proto" "$_pip" "$_pport" "$param" >/dev/null 2>&1 || \
logger -t trafficctl "Failed to restore port limit for $_pport"
fi
;;
esac
done
fi
# Pre-create nft monitor table if software offload is active
if [ "$(tctl_get_offload_mode)" = "software" ]; then
/usr/local/bin/trafficctl-bytes-nft.sh >/dev/null 2>&1 &
fi
+31
View File
@@ -0,0 +1,31 @@
#!/bin/sh /etc/rc.common
# shellcheck shell=dash
# START/STOP/USE_PROCD are consumed by the sourced /etc/rc.common framework.
# shellcheck disable=SC2034
START=99
STOP=10
USE_PROCD=1
start_service() {
local enabled
config_load trafficctl
config_get enabled telegram enabled 0
[ "$enabled" = "1" ] || return 0
procd_open_instance telegram
procd_set_param command /usr/local/bin/trafficctl-telegram.sh
procd_set_param stdout 1
procd_set_param stderr 1
procd_set_param respawn 3600 5 5
procd_set_param file /etc/config/trafficctl
procd_close_instance
}
service_triggers() {
procd_add_reload_trigger "trafficctl"
}
reload_service() {
stop
start
}
@@ -0,0 +1 @@
/etc/trafficctl/
+557
View File
@@ -0,0 +1,557 @@
#!/bin/sh
# shellcheck shell=dash
# rpcd/ubus backend for luci-app-trafficctl.
# Provides ubus object "luci.trafficctl" with methods for traffic control.
. /usr/local/bin/trafficctl-fw.sh
SCRIPTS="/usr/local/bin"
# Strip unsafe chars from label (allow alphanumeric, dash, underscore, dot)
sanitize_label() { echo "$1" | tr -cd 'a-zA-Z0-9_.-'; }
# The config holds the Telegram bot token and the metrics token. uci rewrites the
# file at the default mode on every commit, so the restriction is re-applied here
# rather than at the one call site that first set it.
tctl_commit() {
uci commit trafficctl
chmod 0600 /etc/config/trafficctl 2>/dev/null
}
# Detect LuCI session peer IP
get_peer_ip() {
local sid
sid=$(echo "$HTTP_SID" | tr -cd 'a-f0-9')
if [ -n "$sid" ]; then
ubus call session info "{\"ubus_rpc_session\":\"$sid\"}" 2>/dev/null | \
jsonfilter -e '@.data.peer' 2>/dev/null
fi
}
export TCTL_VIA="luci"
export TCTL_SRC="${REMOTE_ADDR:-$(get_peer_ip)}"
export TCTL_USER="${USERNAME:-root}"
case "$1" in
list)
printf '{'
printf '"summary":{},'
printf '"device":{"ip":"str","proto":"str"},'
printf '"bytes":{},'
printf '"ifaces":{},'
printf '"block":{"ip":"str","label":"str"},'
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_stats":{},'
printf '"shape_add":{"ip":"str","rate_kbit":"int","label":"str"},'
printf '"shape_remove":{"ip":"str","label":"str"},'
printf '"shape_status":{"ip":"str"},'
printf '"shape_stats":{},'
printf '"rdns":{"ip":"str"},'
printf '"netify_status":{},'
printf '"netify_list":{},'
printf '"netify_collect":{"secs":"int"},'
printf '"names_list":{},'
printf '"name_set":{"ip":"str","name":"str"},'
printf '"name_clear":{"ip":"str"},'
printf '"portfw_list":{},'
printf '"portfw_ctl":{"action":"str","scope":"str","proto":"str","ip":"str","port":"str","rate_kbit":"int"},'
printf '"config_get":{},'
printf '"config_set":{"enabled":"bool","default_mode":"str","sw":"bool","hw":"bool","poll_interval":"int","avg_window":"int"},'
printf '"telegram_config_get":{},'
printf '"telegram_config_set":{"enabled":"bool","bot_token":"str","chat_id":"str","poll_interval":"int","notify_new_device":"bool","notify_known_device":"bool","control_enabled":"bool","notify_template":"str","btn_block_inet":"bool","btn_block_wifi":"bool","btn_limiter":"bool","btn_shaper":"bool"},'
printf '"telegram_test":{"bot_token":"str","chat_id":"str","message":"str"},'
printf '"newdevice_config_get":{},'
printf '"newdevice_config_set":{"enabled":"bool","limit_kbit":"int","limit_mode":"str"},'
printf '"logging_config_get":{},'
printf '"logging_config_set":{"enabled":"bool","log_file":"str","max_lines":"int","syslog":"bool","log_blocks":"bool","log_ratelimits":"bool","log_shapes":"bool","log_telegram":"bool","log_config":"bool"},'
printf '"activity_log":{"lines":"int"},'
printf '"version":{}'
printf '}'
;;
call)
case "$2" in
summary)
printf '{"result":'
"$SCRIPTS/trafficctl-summary.sh"
printf '}'
;;
device)
read -r input
ip=$(echo "$input" | jsonfilter -e '@.ip' 2>/dev/null)
proto=$(echo "$input" | jsonfilter -e '@.proto' 2>/dev/null)
if [ -z "$ip" ]; then
echo '{"ok":false,"msg":"ip required"}'
else
case "$proto" in
tcp|udp) "$SCRIPTS/trafficctl-device.sh" "$ip" --proto "$proto" ;;
*) "$SCRIPTS/trafficctl-device.sh" "$ip" ;;
esac
fi
;;
bytes)
# trafficctl-totals.sh wraps trafficctl-bytes.sh: same array, same
# bytes_in/bytes_out the speed graph differentiates, plus the lifetime
# *_total fields the Bytes/TCP/UDP columns show. Folded into this one
# call rather than given a method of its own so the accumulator sees
# exactly one sample per poll — a second method would mean a second
# /proc/net/nf_conntrack read every two seconds.
#
# Note this makes a read-ACL method write the tmpfs accumulator as a
# side effect. That is what keeps the counter advancing while somebody
# is watching the page; a metrics scrape has always done the same.
printf '{"result":'
"$SCRIPTS/trafficctl-totals.sh"
printf '}'
;;
ifaces)
printf '{"result":'
"$SCRIPTS/trafficctl-ifaces.sh"
printf '}'
;;
block)
read -r input
ip=$(echo "$input" | jsonfilter -e '@.ip' 2>/dev/null)
label=$(sanitize_label "$(echo "$input" | jsonfilter -e '@.label' 2>/dev/null)")
if [ -z "$ip" ]; then
echo '{"ok":false,"msg":"ip required"}'
else
"$SCRIPTS/trafficctl-block.sh" "$ip" "$label"
fi
;;
unblock)
read -r input
ip=$(echo "$input" | jsonfilter -e '@.ip' 2>/dev/null)
label=$(sanitize_label "$(echo "$input" | jsonfilter -e '@.label' 2>/dev/null)")
if [ -z "$ip" ]; then
echo '{"ok":false,"msg":"ip required"}'
else
"$SCRIPTS/trafficctl-unblock.sh" "$ip" "$label"
fi
;;
macfilter_add)
read -r input
ip=$(echo "$input" | jsonfilter -e '@.ip' 2>/dev/null)
if [ -z "$ip" ]; then
echo '{"ok":false,"msg":"ip required"}'
else
"$SCRIPTS/trafficctl-macfilter-add.sh" "$ip"
fi
;;
macfilter_remove)
read -r input
ip=$(echo "$input" | jsonfilter -e '@.ip' 2>/dev/null)
if [ -z "$ip" ]; then
echo '{"ok":false,"msg":"ip required"}'
else
"$SCRIPTS/trafficctl-macfilter-remove.sh" "$ip"
fi
;;
ratelimit)
read -r input
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)")
mode=$(echo "$input" | jsonfilter -e '@.mode' 2>/dev/null)
case "$mode" in each|shared) ;; *) mode="" ;; esac
if [ -z "$ip" ]; then
echo '{"ok":false,"msg":"ip required"}'
else
"$SCRIPTS/trafficctl-ratelimit.sh" "$ip" "${rate:-0}" "$label" "$mode"
fi
;;
ratelimit_stats)
printf '{"result":'
"$SCRIPTS/trafficctl-ratelimit-stats.sh"
printf '}'
;;
shape_add)
read -r input
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)")
if [ -z "$ip" ] || [ -z "$rate" ]; then
echo '{"ok":false,"msg":"ip and rate_kbit required"}'
else
"$SCRIPTS/trafficctl-shape.sh" add "$ip" "$rate" "$label"
fi
;;
shape_remove)
read -r input
ip=$(echo "$input" | jsonfilter -e '@.ip' 2>/dev/null)
label=$(echo "$input" | jsonfilter -e '@.label' 2>/dev/null)
if [ -z "$ip" ]; then
echo '{"ok":false,"msg":"ip required"}'
else
"$SCRIPTS/trafficctl-shape.sh" remove "$ip"
fi
;;
shape_status)
read -r input
ip=$(echo "$input" | jsonfilter -e '@.ip' 2>/dev/null)
if [ -z "$ip" ]; then
echo '{"ok":false,"msg":"ip required"}'
else
"$SCRIPTS/trafficctl-shape.sh" status "$ip"
fi
;;
shape_stats)
printf '{"result":'
"$SCRIPTS/trafficctl-shape-stats.sh"
printf '}'
;;
rdns)
read -r input
ip=$(echo "$input" | jsonfilter -e '@.ip' 2>/dev/null)
if [ -z "$ip" ]; then
echo '{"ok":false,"msg":"ip required"}'
else
"$SCRIPTS/trafficctl-rdns.sh" "$ip"
fi
;;
netify_status)
"$SCRIPTS/trafficctl-netify.sh" status
;;
netify_list)
printf '{"result":'
"$SCRIPTS/trafficctl-netify.sh" list
printf '}'
;;
netify_collect)
read -r input
secs=$(echo "$input" | jsonfilter -e '@.secs' 2>/dev/null)
"$SCRIPTS/trafficctl-netify.sh" collect "${secs:-3}"
;;
names_list)
printf '{"result":'
"$SCRIPTS/trafficctl-names.sh" list
printf '}'
;;
name_set)
read -r input
ip=$(echo "$input" | jsonfilter -e '@.ip' 2>/dev/null)
name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null)
if [ -z "$ip" ]; then
echo '{"ok":false,"msg":"ip required"}'
elif [ -z "$name" ]; then
# Empty name means "forget the alias" so the UI can clear a name
# by submitting a blank field.
"$SCRIPTS/trafficctl-names.sh" remove "$ip"
else
"$SCRIPTS/trafficctl-names.sh" set "$ip" "$name"
fi
;;
name_clear)
read -r input
ip=$(echo "$input" | jsonfilter -e '@.ip' 2>/dev/null)
if [ -z "$ip" ]; then
echo '{"ok":false,"msg":"ip required"}'
else
"$SCRIPTS/trafficctl-names.sh" remove "$ip"
fi
;;
portfw_list)
printf '{"result":'
"$SCRIPTS/trafficctl-portfw.sh" list
printf '}'
;;
portfw_ctl)
read -r input
action=$(echo "$input" | jsonfilter -e '@.action' 2>/dev/null)
scope=$(echo "$input" | jsonfilter -e '@.scope' 2>/dev/null)
proto=$(echo "$input" | jsonfilter -e '@.proto' 2>/dev/null)
ip=$(echo "$input" | jsonfilter -e '@.ip' 2>/dev/null)
port=$(echo "$input" | jsonfilter -e '@.port' 2>/dev/null)
rate=$(echo "$input" | jsonfilter -e '@.rate_kbit' 2>/dev/null)
case "$action" in
pause|resume)
"$SCRIPTS/trafficctl-portfw.sh" "$action" "$scope" "$proto" "${ip:--}" "$port"
;;
limit)
"$SCRIPTS/trafficctl-portfw.sh" limit "$scope" "$proto" "${ip:--}" "$port" "${rate:-0}"
;;
*)
echo '{"ok":false,"msg":"action must be pause, resume or limit"}'
;;
esac
;;
config_get)
enabled=$(uci -q get trafficctl.main.enabled)
mode=$(uci -q get trafficctl.main.default_mode)
offload=$(tctl_get_offload_mode)
sw=$(uci -q get firewall.@defaults[0].flow_offloading)
hw=$(uci -q get firewall.@defaults[0].flow_offloading_hw)
poll=$(uci -q get trafficctl.main.poll_interval)
window=$(uci -q get trafficctl.main.avg_window)
printf '{"enabled":%s,"default_mode":"%s","offload_mode":"%s","sw":%s,"hw":%s,"poll_interval":%d,"avg_window":%d}' \
"$([ "$enabled" = "1" ] && echo true || echo false)" \
"${mode:-limiter}" \
"$offload" \
"$([ "$sw" = "1" ] && echo true || echo false)" \
"$([ "$hw" = "1" ] && echo true || echo false)" \
"${poll:-2}" \
"${window:-15}"
;;
config_set)
read -r input
enabled=$(echo "$input" | jsonfilter -e '@.enabled' 2>/dev/null)
mode=$(echo "$input" | jsonfilter -e '@.default_mode' 2>/dev/null)
sw=$(echo "$input" | jsonfilter -e '@.sw' 2>/dev/null)
hw=$(echo "$input" | jsonfilter -e '@.hw' 2>/dev/null)
poll=$(echo "$input" | jsonfilter -e '@.poll_interval' 2>/dev/null)
window=$(echo "$input" | jsonfilter -e '@.avg_window' 2>/dev/null)
[ -n "$enabled" ] && uci set trafficctl.main.enabled="$([ "$enabled" = "true" ] && echo 1 || echo 0)"
[ -n "$mode" ] && uci set trafficctl.main.default_mode="$mode"
# Bounded rather than free-form. 0 means "do not poll"; below a second
# the browser cannot keep up and the router pays a conntrack read per
# tick for frames nobody sees. The upper bound keeps a "live" dashboard
# recognisably live.
if [ -n "$poll" ]; then
case "$poll" in
''|*[!0-9]*) echo '{"ok":false,"msg":"poll_interval must be a number"}'; exit 0 ;;
esac
if [ "$poll" != "0" ] && { [ "$poll" -lt 1 ] || [ "$poll" -gt 300 ]; }; then
echo '{"ok":false,"msg":"poll_interval must be 0 (off) or between 1 and 300 seconds"}'
exit 0
fi
uci set trafficctl.main.poll_interval="$poll"
fi
# The window is what the speed is averaged over, and the per-device
# sample history is sized from it — so an unbounded value is an
# unbounded array in every open browser, one per device.
if [ -n "$window" ]; then
case "$window" in
''|*[!0-9]*) echo '{"ok":false,"msg":"avg_window must be a number"}'; exit 0 ;;
esac
if [ "$window" -lt 2 ] || [ "$window" -gt 3600 ]; then
echo '{"ok":false,"msg":"avg_window must be between 2 and 3600 seconds"}'
exit 0
fi
uci set trafficctl.main.avg_window="$window"
fi
tctl_commit
if [ -n "$sw" ] || [ -n "$hw" ]; then
[ -n "$sw" ] && uci set "firewall.@defaults[0].flow_offloading=$([ "$sw" = "true" ] && echo 1 || echo 0)"
[ -n "$hw" ] && uci set "firewall.@defaults[0].flow_offloading_hw=$([ "$hw" = "true" ] && echo 1 || echo 0)"
uci commit firewall
/etc/init.d/firewall reload >/dev/null 2>&1 &
fi
echo '{"ok":true}'
;;
telegram_config_get)
uci -q get trafficctl.telegram >/dev/null 2>&1 || {
echo '{"enabled":false,"bot_token":"","bot_token_set":false,"chat_id":"","poll_interval":3,"notify_new_device":true,"notify_known_device":false,"control_enabled":true,"notify_template":"","btn_block_inet":true,"btn_block_wifi":true,"btn_limiter":true,"btn_shaper":true,"bot_running":false}'
exit 0
}
tg_enabled=$(uci -q get trafficctl.telegram.enabled)
tg_token=$(uci -q get trafficctl.telegram.bot_token)
tg_chat_id=$(uci -q get trafficctl.telegram.chat_id)
tg_poll=$(uci -q get trafficctl.telegram.poll_interval)
tg_nn=$(uci -q get trafficctl.telegram.notify_new_device)
tg_nk=$(uci -q get trafficctl.telegram.notify_known_device)
tg_ctrl=$(uci -q get trafficctl.telegram.control_enabled)
tg_tmpl=$(uci -q get trafficctl.telegram.notify_template)
tg_bi=$(uci -q get trafficctl.telegram.btn_block_inet)
tg_bw=$(uci -q get trafficctl.telegram.btn_block_wifi)
tg_bl=$(uci -q get trafficctl.telegram.btn_limiter)
tg_bs=$(uci -q get trafficctl.telegram.btn_shaper)
tg_token_set=false
tg_token_display=""
if [ -n "$tg_token" ]; then
tg_token_set=true
tg_token_display="***"
fi
tg_running=false
pgrep -f "trafficctl-telegram.sh" >/dev/null 2>&1 && tg_running=true
tg_tmpl_esc=$(printf '%s' "${tg_tmpl:-}" | sed 's/\\/\\\\/g;s/"/\\"/g;s/ /\\t/g')
printf '{"enabled":%s,"bot_token":"%s","bot_token_set":%s,"chat_id":"%s","poll_interval":%d,"notify_new_device":%s,"notify_known_device":%s,"control_enabled":%s,"notify_template":"%s","btn_block_inet":%s,"btn_block_wifi":%s,"btn_limiter":%s,"btn_shaper":%s,"bot_running":%s}' \
"$([ "$tg_enabled" = "1" ] && echo true || echo false)" \
"$tg_token_display" "$tg_token_set" "${tg_chat_id:-}" \
"${tg_poll:-3}" \
"$([ "${tg_nn:-1}" != "0" ] && echo true || echo false)" \
"$([ "$tg_nk" = "1" ] && echo true || echo false)" \
"$([ "${tg_ctrl:-1}" != "0" ] && echo true || echo false)" \
"$tg_tmpl_esc" \
"$([ "${tg_bi:-1}" != "0" ] && echo true || echo false)" \
"$([ "${tg_bw:-1}" != "0" ] && echo true || echo false)" \
"$([ "${tg_bl:-1}" != "0" ] && echo true || echo false)" \
"$([ "${tg_bs:-1}" != "0" ] && echo true || echo false)" \
"$tg_running"
;;
telegram_config_set)
read -r input
uci -q get trafficctl.telegram >/dev/null 2>&1 || uci set trafficctl.telegram=telegram
val=$(echo "$input" | jsonfilter -e '@.enabled' 2>/dev/null)
[ -n "$val" ] && uci set trafficctl.telegram.enabled="$([ "$val" = "true" ] && echo 1 || echo 0)"
val=$(echo "$input" | jsonfilter -e '@.bot_token' 2>/dev/null)
if [ -n "$val" ] && [ "$val" != "***" ]; then
uci set trafficctl.telegram.bot_token="$val"
elif [ -z "$val" ]; then
uci -q delete trafficctl.telegram.bot_token
fi
val=$(echo "$input" | jsonfilter -e '@.chat_id' 2>/dev/null)
[ -n "$val" ] && uci set trafficctl.telegram.chat_id="$val"
val=$(echo "$input" | jsonfilter -e '@.poll_interval' 2>/dev/null)
[ -n "$val" ] && uci set trafficctl.telegram.poll_interval="$val"
val=$(echo "$input" | jsonfilter -e '@.notify_template' 2>/dev/null)
uci set trafficctl.telegram.notify_template="$val"
for opt in notify_new_device notify_known_device control_enabled btn_block_inet btn_block_wifi btn_limiter btn_shaper; do
val=$(echo "$input" | jsonfilter -e "@.$opt" 2>/dev/null)
[ -n "$val" ] && uci set "trafficctl.telegram.$opt=$([ "$val" = "true" ] && echo 1 || echo 0)"
done
tctl_commit
tctl_log "telegram_config_change" "" "" "luci" "${TCTL_SRC:-local}"
/etc/init.d/trafficctl-telegram reload 2>/dev/null &
echo '{"ok":true}'
;;
telegram_test)
read -r input
token=$(echo "$input" | jsonfilter -e '@.bot_token' 2>/dev/null)
chat_id=$(echo "$input" | jsonfilter -e '@.chat_id' 2>/dev/null)
msg=$(echo "$input" | jsonfilter -e '@.message' 2>/dev/null)
if [ "$token" = "***" ] || [ -z "$token" ]; then
token=$(uci -q get trafficctl.telegram.bot_token)
fi
if [ -z "$token" ] || [ -z "$chat_id" ]; then
echo '{"ok":false,"msg":"token and chat_id required"}'
else
TCTL_TG_TOKEN="$token" "$SCRIPTS/trafficctl-telegram-test.sh" - "$chat_id" "$msg"
fi
;;
logging_config_get)
log_enabled=$(uci -q get trafficctl.logging.enabled)
log_file=$(uci -q get trafficctl.logging.log_file)
max_lines=$(uci -q get trafficctl.logging.max_lines)
log_syslog=$(uci -q get trafficctl.logging.syslog)
log_blocks=$(uci -q get trafficctl.logging.log_blocks)
log_ratelimits=$(uci -q get trafficctl.logging.log_ratelimits)
log_shapes=$(uci -q get trafficctl.logging.log_shapes)
log_telegram=$(uci -q get trafficctl.logging.log_telegram)
log_config=$(uci -q get trafficctl.logging.log_config)
persist=$(uci -q get trafficctl.main.persist_rules)
printf '{"enabled":%s,"log_file":"%s","max_lines":%d,"syslog":%s,"log_blocks":%s,"log_ratelimits":%s,"log_shapes":%s,"log_telegram":%s,"log_config":%s,"persist_rules":%s}' \
"$([ "${log_enabled:-1}" = "1" ] && echo true || echo false)" \
"${log_file:-/tmp/trafficctl/activity.log}" \
"${max_lines:-500}" \
"$([ "${log_syslog:-1}" = "1" ] && echo true || echo false)" \
"$([ "${log_blocks:-1}" != "0" ] && echo true || echo false)" \
"$([ "${log_ratelimits:-1}" != "0" ] && echo true || echo false)" \
"$([ "${log_shapes:-1}" != "0" ] && echo true || echo false)" \
"$([ "${log_telegram:-1}" != "0" ] && echo true || echo false)" \
"$([ "${log_config:-1}" != "0" ] && echo true || echo false)" \
"$([ "$persist" = "1" ] && echo true || echo false)"
;;
logging_config_set)
read -r input
uci -q get trafficctl.logging >/dev/null 2>&1 || uci set trafficctl.logging=logging
for opt in enabled syslog log_blocks log_ratelimits log_shapes log_telegram log_config; do
val=$(echo "$input" | jsonfilter -e "@.$opt" 2>/dev/null)
[ -n "$val" ] && uci set "trafficctl.logging.$opt=$([ "$val" = "true" ] && echo 1 || echo 0)"
done
val=$(echo "$input" | jsonfilter -e '@.log_file' 2>/dev/null)
if [ -n "$val" ]; then
if ! tctl_validate_log_file "$val"; then
echo '{"ok":false,"msg":"log_file must be under /tmp/trafficctl/ or /var/log/"}'
exit 0
fi
uci set trafficctl.logging.log_file="$val"
fi
val=$(echo "$input" | jsonfilter -e '@.max_lines' 2>/dev/null)
if [ -n "$val" ]; then
case "$val" in
''|*[!0-9]*) echo '{"ok":false,"msg":"max_lines must be a number"}'; exit 0 ;;
esac
if [ "$val" -lt 20 ] || [ "$val" -gt 100000 ]; then
echo '{"ok":false,"msg":"max_lines must be between 20 and 100000"}'
exit 0
fi
uci set trafficctl.logging.max_lines="$val"
fi
val=$(echo "$input" | jsonfilter -e '@.persist_rules' 2>/dev/null)
[ -n "$val" ] && uci set "trafficctl.main.persist_rules=$([ "$val" = "true" ] && echo 1 || echo 0)"
tctl_commit
tctl_log "config_change" "logging" "" "luci" "${TCTL_SRC:-local}"
echo '{"ok":true}'
;;
newdevice_config_get)
nd_enabled=$(uci -q get trafficctl.newdevice.enabled)
nd_kbit=$(uci -q get trafficctl.newdevice.limit_kbit)
nd_mode=$(uci -q get trafficctl.newdevice.limit_mode)
# seen_count lets the UI say how large the baseline is, so an operator
# can tell "the ledger knows my 23 devices" from "the ledger is empty
# and everything is about to look new".
printf '{"enabled":%s,"limit_kbit":%d,"limit_mode":"%s","seen_count":%d,"seeded":%s}' \
"$([ "$nd_enabled" = "1" ] && echo true || echo false)" \
"${nd_kbit:-0}" \
"${nd_mode:-limiter}" \
"$(tctl_seen_count)" \
"$(tctl_seen_ready && echo true || echo false)"
;;
newdevice_config_set)
read -r input
uci -q get trafficctl.newdevice >/dev/null 2>&1 || uci set trafficctl.newdevice=newdevice
val=$(echo "$input" | jsonfilter -e '@.limit_kbit' 2>/dev/null)
if [ -n "$val" ]; then
case "$val" in
''|*[!0-9]*) echo '{"ok":false,"msg":"limit_kbit must be a number"}'; exit 0 ;;
esac
uci set trafficctl.newdevice.limit_kbit="$val"
fi
val=$(echo "$input" | jsonfilter -e '@.limit_mode' 2>/dev/null)
if [ -n "$val" ]; then
case "$val" in
limiter|shaper) uci set trafficctl.newdevice.limit_mode="$val" ;;
*) echo '{"ok":false,"msg":"limit_mode must be limiter or shaper"}'; exit 0 ;;
esac
fi
val=$(echo "$input" | jsonfilter -e '@.enabled' 2>/dev/null)
if [ -n "$val" ]; then
# Seed the ledger BEFORE the feature can fire. Done here rather
# than in the hotplug because this runs on an operator action,
# when the leases and the neighbour table are populated; seeding
# early in boot would record an empty LAN and then treat every
# device as new.
[ "$val" = "true" ] && tctl_seen_seed
uci set "trafficctl.newdevice.enabled=$([ "$val" = "true" ] && echo 1 || echo 0)"
fi
tctl_commit
tctl_log "config_change" "newdevice" "" "luci" "${TCTL_SRC:-local}"
echo '{"ok":true}'
;;
activity_log)
read -r input
lines=$(echo "$input" | jsonfilter -e '@.lines' 2>/dev/null)
lines="${lines:-50}"
case "$lines" in
''|*[!0-9]*) lines=50 ;;
esac
[ "$lines" -gt 1000 ] && lines=1000
log_file=$(tctl_log_file)
if [ -f "$log_file" ]; then
printf '{"ok":true,"lines":['
tail -n "$lines" "$log_file" | awk '
BEGIN { first=1 }
{
gsub(/\\/, "\\\\"); gsub(/"/, "\\\"")
if (!first) printf ","
printf "\"%s\"", $0
first=0
}'
printf ']}'
else
echo '{"ok":true,"lines":[]}'
fi
;;
version)
ver=$(opkg info luci-app-trafficctl 2>/dev/null | awk '/^Version:/{print $2}')
[ -z "$ver" ] && ver="dev"
printf '{"version":"%s"}' "$ver"
;;
*)
echo '{"error":"unknown method"}'
;;
esac
;;
esac
+51
View File
@@ -0,0 +1,51 @@
#!/bin/sh
# shellcheck shell=dash
# Block internet access for a device.
# Usage: trafficctl-block.sh <ip> [label]
. /usr/local/bin/trafficctl-fw.sh
IP="$1"
LABEL="${2:-block_$IP}"
if [ -z "$IP" ]; then
echo '{"ok":false,"msg":"usage: trafficctl-block.sh <ip> [label]"}'
exit 1
fi
if ! tctl_validate_ip "$IP"; then
echo '{"ok":false,"msg":"invalid IP address"}'
exit 1
fi
ROUTER_IP=$(ip -4 addr show dev "$(tctl_get_lan_device)" 2>/dev/null | grep -oE 'inet [0-9.]+' | awk '{print $2}' | head -1)
if [ "$IP" = "$ROUTER_IP" ]; then
echo '{"ok":false,"msg":"cannot block the router itself"}'
exit 1
fi
COMMENT=$(tctl_block_comment "$IP")
SELF_BLOCK=false
if [ -n "$TCTL_SRC" ] && [ "$TCTL_SRC" = "$IP" ]; then
SELF_BLOCK=true
fi
if tctl_is_blocked "$IP"; then
echo "{\"ok\":true,\"msg\":\"$IP is already blocked\"}"
exit 0
fi
if tctl_block_add "$IP" "$COMMENT"; then
conntrack -D -s "$IP" >/dev/null 2>&1
conntrack -D -d "$IP" >/dev/null 2>&1
tctl_persist_enabled && tctl_persist_save "block" "$IP" "$LABEL"
tctl_log "block" "$IP" "$LABEL" "${TCTL_VIA:-cli}" "${TCTL_SRC:-local}"
if [ "$SELF_BLOCK" = "true" ]; then
echo "{\"ok\":true,\"msg\":\"internet blocked for $IP (your device — LuCI access preserved)\"}"
else
echo "{\"ok\":true,\"msg\":\"internet blocked for $IP\"}"
fi
else
echo "{\"ok\":false,\"msg\":\"failed to block $IP\"}"
exit 1
fi
@@ -0,0 +1,101 @@
#!/bin/sh
# shellcheck shell=dash
# Per-device byte counters using nftables maps.
# Works with software flow offload (hook priority -200, before flowtable at -150).
# Output: JSON array
# [{"ip":"…","bytes_in":N,"bytes_out":N,"bytes_tcp":-1,"bytes_udp":-1,
# "src":"nft","degraded":false}]
#
# "degraded" is always false here, and that is the entire point of this script:
# these counters sit ahead of the flowtable, so they keep moving under offload
# where conntrack freezes. If this path is unavailable, trafficctl-bytes.sh
# reports degraded:true instead of quietly handing back frozen numbers.
#
# The counters here are cumulative since the table was created, unlike the
# conntrack sums trafficctl-bytes.sh returns, which cover live flows only.
# "src" lets trafficctl-totals.sh notice the swap and rebaseline instead of
# accumulating the whole map as a single enormous delta.
#
# The protocol split is -1, i.e. "unknown", not 0: a `type ipv4_addr : counter`
# map is keyed by address alone, so there is nothing here to split TCP from UDP
# with. Reporting 0 would render as a device that sent no TCP at all.
. /usr/local/bin/trafficctl-fw.sh
command -v nft >/dev/null 2>&1 || { echo '[]'; exit 0; }
# Create table/maps/chain/rules if not already present (idempotent).
#
# The gate matches the CURRENT rule orientation, not merely "some rule exists".
# Until 1.13.x the maps were keyed the other way round (bytes_in by saddr), so
# a router upgrading from that version already has the chain: a looser check
# would leave the old, inverted rules in place forever and the fix would only
# ever reach fresh installs.
#
# Direction convention, matching trafficctl-bytes.sh and docs/API.md:
# bytes_in = traffic toward the device (download) -> keyed by ip daddr
# bytes_out = traffic from the device (upload) -> keyed by ip saddr
if ! nft list chain inet trafficctl_mon mon_forward 2>/dev/null | grep -q "bytes_in.*daddr"; then
# Drop the old table wholesale rather than appending to it — leaving the
# previous pair of rules in place would double-count every packet. This
# resets the counters once, on upgrade; they are only used for speed
# deltas, so a single discarded sample is the whole cost.
nft delete table inet trafficctl_mon 2>/dev/null
nft add table inet trafficctl_mon 2>/dev/null
nft add map inet trafficctl_mon bytes_in \
'{ type ipv4_addr : counter; flags dynamic; }' 2>/dev/null
nft add map inet trafficctl_mon bytes_out \
'{ type ipv4_addr : counter; flags dynamic; }' 2>/dev/null
nft add chain inet trafficctl_mon mon_forward \
'{ type filter hook forward priority -200; policy accept; }' 2>/dev/null
nft add rule inet trafficctl_mon mon_forward \
'update @bytes_in { ip daddr counter }' 2>/dev/null
nft add rule inet trafficctl_mon mon_forward \
'update @bytes_out { ip saddr counter }' 2>/dev/null
fi
# Dynamic counter maps are unsupported on some kernels ("Not supported").
# Without this check the maps silently never exist, every rule fails, and the
# script returns [] forever — per-device speed just stops working.
if ! nft list map inet trafficctl_mon bytes_in >/dev/null 2>&1; then
TCTL_FORCE_CONNTRACK=1 exec /usr/local/bin/trafficctl-bytes.sh
fi
IN=$(nft list map inet trafficctl_mon bytes_in 2>/dev/null)
OUT=$(nft list map inet trafficctl_mon bytes_out 2>/dev/null)
# Parse: lines look like "192.168.0.100 : counter packets 584 bytes 892341[,]"
printf '%s\n__SEP__\n%s\n' "$IN" "$OUT" | awk '
/^__SEP__$/ { phase = 1; next }
/[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+ : counter/ {
ip = ""; val = 0
for (i = 1; i <= NF; i++) {
if ($i ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/) ip = $i
if ($i == "bytes") val = $(i+1) + 0
}
if (ip != "") {
if (phase == 0) in_b[ip] += val
else out_b[ip] += val
}
}
END {
printf "["
n = 0
for (ip in in_b) {
if (n > 0) printf ","
printf "{\"ip\":\"%s\",\"bytes_in\":%.0f,\"bytes_out\":%.0f,\"bytes_tcp\":-1,\"bytes_udp\":-1,\"src\":\"nft\",\"degraded\":false}", \
ip, in_b[ip], out_b[ip]+0
n++
}
for (ip in out_b) {
if (!(ip in in_b)) {
if (n > 0) printf ","
printf "{\"ip\":\"%s\",\"bytes_in\":0,\"bytes_out\":%.0f,\"bytes_tcp\":-1,\"bytes_udp\":-1,\"src\":\"nft\",\"degraded\":false}", \
ip, out_b[ip]
n++
}
}
printf "]\n"
}
'
+138
View File
@@ -0,0 +1,138 @@
#!/bin/sh
# shellcheck shell=dash
# Per-device byte counters from conntrack (for speed calculation).
# Output: JSON array
# [{"ip":"…","bytes_in":N,"bytes_out":N,"bytes_tcp":N,"bytes_udp":N,
# "src":"ct","degraded":false}]
#
# "src" names the counter source. trafficctl-totals.sh accumulates these into
# lifetime totals and the two sources have unrelated magnitudes (conntrack
# reports only live flows, the nft maps count since the table was built), so it
# has to be able to tell a source switch from a burst of traffic.
#
# "degraded" says whether the numbers can be believed AT ALL — see below. It is
# separate from "src" on purpose: src is about magnitude (and drives the
# rebaseline), degraded is about trust, and conflating them would make a change
# in trust look like a change of counter and throw away a delta for nothing.
#
# bytes_tcp / bytes_udp carry both directions summed, and exist only on the
# conntrack path — nft counter maps are keyed by address alone and cannot split
# by protocol, so trafficctl-bytes-nft.sh reports -1 there. -1 rather than 0:
# "unknown" and "no TCP traffic" must not render as the same number.
. /usr/local/bin/trafficctl-fw.sh
# Any offload mode (software, hardware, hardware-counter) bypasses conntrack counters
# for fast-path packets. Use nftables counters at forward priority -200 (before the
# flowtable at -150) which capture every packet regardless of offload state.
# Only pure "none" mode has accurate conntrack counters.
# Modes whose counters ARE synced back to conntrack ("*-counter") keep the
# conntrack path accurate; only the uncountered ones need the nft fallback.
# TCTL_FORCE_CONNTRACK is set by that fallback when the kernel lacks dynamic
# counter maps, so we don't bounce between the two.
_offload=$(tctl_get_offload_mode)
_uncountered=0
case "$_offload" in
none|*-counter) ;;
*)
_uncountered=1
[ "$TCTL_FW" = "nft" ] && [ -z "$TCTL_FORCE_CONNTRACK" ] && \
exec /usr/local/bin/trafficctl-bytes-nft.sh
;;
esac
# Reaching here with uncountered offload means the nft fallback was unavailable
# and we are about to read counters the forwarding path does not update.
#
# Two ways in, and the second is easy to miss:
# * TCTL_FORCE_CONNTRACK — bytes-nft.sh bounced back because the kernel has
# no dynamic counter map support. Observed on real hardware.
# * fw3/iptables — there is no nft fallback to reach in the first place.
#
# The numbers that follow are not merely imprecise, they are FROZEN for every
# offloaded flow: they stop moving while traffic continues. Left unflagged, a
# lifetime total accumulated from them looks authoritative and simply stalls,
# which reads as stale statistics rather than as a broken counter — the same
# shape as the 2 GiB %d freeze (#56), and harder to notice than the tiny live
# values that prompted #26, because the number is plausible.
_degraded=false
[ "$_uncountered" = "1" ] && _degraded=true
# All monitored subnets (connected LANs + routed downstream subnets +
# trafficctl.main.extra_subnets), as awk membership spec.
MATCH_SPEC=$(tctl_monitored_subnets | awk '{printf "%s%s:%s:%s",(NR>1?" ":""),$2,$3,$4}')
[ -z "$MATCH_SPEC" ] && { echo '[]'; exit 0; }
# Router-owned IPv4 addresses, excluded from the NAT fallback below.
LOCAL_IPS=$(ip -4 addr show 2>/dev/null | awk '/inet /{split($2,a,"/");print a[1]}' | tr '\n' ' ')
cat /proc/net/nf_conntrack 2>/dev/null | awk -v spec="$MATCH_SPEC" -v localips="$LOCAL_IPS" -v degraded="$_degraded" '
function ip2int(ip, a) {
split(ip, a, ".")
return a[1]*16777216 + a[2]*65536 + a[3]*256 + a[4]
}
function is_lan(ip, si, k) {
si = ip2int(ip)
for (k = 1; k <= ns; k++)
if (si - (si % blk[k]) == base[k]) return 1
return 0
}
BEGIN {
printf "["
ns = split(spec, parts, " ")
for (k = 1; k <= ns; k++) {
split(parts[k], kv, ":")
base[k] = kv[1] + 0; blk[k] = kv[2] + 0
}
nl = split(localips, lp, " ")
for (k = 1; k <= nl; k++) if (lp[k] != "") islocal[lp[k]] = 1
}
{
src=""; osrc=""; rdst=""; nsrc=0; bytes_orig=0; bytes_reply=0; bc=0; proto=""
for (i=1; i<=NF; i++) {
# The L4 protocol is a bare word in the header fields ("ipv4 2 tcp 6 …"),
# before any key=value pair, so it is matched by value like summary.sh.
if ($i == "tcp") proto="tcp"
else if ($i == "udp") proto="udp"
if ($i ~ /^src=/) {
v = substr($i, 5)
nsrc++
if (nsrc == 1) osrc = v
if (src == "" && is_lan(v)) src = v
}
if (nsrc == 2 && rdst == "" && $i ~ /^dst=/) rdst = substr($i, 5)
if ($i ~ /^bytes=/) {
v = substr($i, 7) + 0
bc++
if (bc == 1) bytes_orig = v
else if (bc == 2) bytes_reply = v
}
}
# NAT fallback: flow was SNAT/masqueraded here (reply dst != original
# src), so the original src is a forwarded client even though it is not
# in any monitored subnet (e.g. behind a downstream router).
if (src == "" && osrc != "" && rdst != "" && rdst != osrc && \
osrc ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ && !(osrc in islocal))
src = osrc
if (src != "") {
key = src
in_total[key] += bytes_reply
out_total[key] += bytes_orig
# Both directions, unlike the per-direction sums above: the protocol
# split answers how much of the traffic for this device was TCP, which
# is not a question about direction.
if (proto == "tcp") tcp_total[key] += bytes_orig + bytes_reply
else if (proto == "udp") udp_total[key] += bytes_orig + bytes_reply
}
}
END {
n = 0
for (ip in in_total) {
if (n > 0) printf ","
printf "{\"ip\":\"%s\",\"bytes_in\":%.0f,\"bytes_out\":%.0f,\"bytes_tcp\":%.0f,\"bytes_udp\":%.0f,\"src\":\"ct\",\"degraded\":%s}", \
ip, in_total[ip], out_total[ip], tcp_total[ip]+0, udp_total[ip]+0, degraded
n++
}
printf "]\n"
}
'
+381
View File
@@ -0,0 +1,381 @@
#!/bin/sh
# shellcheck shell=dash
# Detailed device info with connections.
# Usage: trafficctl-device.sh <ip> [--rdns] [--proto tcp|udp|all]
. /usr/local/bin/trafficctl-fw.sh
LAN_DEV=$(tctl_get_lan_device)
# Parse arguments
IP=""
DO_RDNS=0
PROTO_FILTER="all"
while [ $# -gt 0 ]; do
case "$1" in
--rdns) DO_RDNS=1 ;;
--proto) shift; PROTO_FILTER="$1" ;;
*) [ -z "$IP" ] && IP="$1" ;;
esac
shift
done
if [ -z "$IP" ]; then
echo '{"ok":false,"msg":"usage: trafficctl-device.sh <ip> [--rdns] [--proto tcp|udp|all]"}'
exit 1
fi
if ! tctl_validate_ip "$IP"; then
echo '{"ok":false,"msg":"invalid IP address"}'
exit 1
fi
# Get device name from DHCP leases
NAME=""
MAC=""
if [ -f /tmp/dhcp.leases ]; then
NAME=$(awk -v ip="$IP" '$3 == ip {print $4}' /tmp/dhcp.leases | head -1)
MAC=$(awk -v ip="$IP" '$3 == ip {print $2}' /tmp/dhcp.leases | head -1)
fi
if [ -z "$MAC" ]; then
MAC=$(ip neigh show "$IP" 2>/dev/null | grep -oE '[0-9a-fA-F:]{17}' | head -1)
fi
MAC=$(echo "$MAC" | tr 'A-F' 'a-f')
[ "$NAME" = "*" ] && NAME=""
# Same precedence as the summary: manual alias wins, then the lease name above,
# then cached reverse DNS (the only automatic source for routed devices).
ALIAS=$(awk -v ip="$IP" '$1 == ip {sub(/^[^ ]+ +/, ""); print; exit}' /etc/trafficctl/names 2>/dev/null)
[ -n "$ALIAS" ] && NAME="$ALIAS"
if [ -z "$NAME" ] && [ "$(uci -q get trafficctl.main.resolve_names 2>/dev/null)" != "0" ]; then
NAME=$(awk -v ip="$IP" '$1 == ip && $2 != "-" {print $2; exit}' /tmp/trafficctl_rdns_cache 2>/dev/null)
fi
[ -z "$NAME" ] && NAME="*"
# Detect connection type — specific interface or band
CONN_TYPE=""
CONN_LAST=""
CONN_CACHE="/tmp/trafficctl_conn_cache"
[ -f "$CONN_CACHE" ] || : > "$CONN_CACHE"
if ! tctl_ip_in_lan "$IP"; then
# Not on any connected LAN subnet — reached via a downstream router.
CONN_TYPE="routed"
elif [ -n "$MAC" ]; then
_wifi_stations=$(
for _wi in $(iw dev 2>/dev/null | awk '/Interface/{print $2}'); do
_ch=$(iw dev "$_wi" info 2>/dev/null | awk '/channel/{print $2}')
_band=""
if [ -n "$_ch" ]; then
if [ "$_ch" -le 14 ] 2>/dev/null; then _band="2.4G"
elif [ "$_ch" -le 177 ] 2>/dev/null; then _band="5G"
else _band="6G"; fi
fi
iw dev "$_wi" station dump 2>/dev/null | awk -v iface="$_wi" -v band="$_band" \
'/Station/{print tolower($2), iface, band}'
done
)
WIFI_LINE=$(echo "$_wifi_stations" | grep -i "$MAC")
if [ -n "$WIFI_LINE" ]; then
CONN_TYPE=$(echo "$WIFI_LINE" | awk '{print $3}')
[ -z "$CONN_TYPE" ] && CONN_TYPE="wifi"
else
BR_PORT=$(brctl showmacs "$LAN_DEV" 2>/dev/null | awk -v mac="$MAC" '
NR>1 && $3=="no" && tolower($2)==mac {print $1; exit}')
if [ -n "$BR_PORT" ]; then
PORT_IFACE=""
for pdir in /sys/class/net/"$LAN_DEV"/brif/*/; do
[ -d "$pdir" ] || continue
pno=$(cat "${pdir}port_no" 2>/dev/null)
[ -z "$pno" ] && continue
if [ $(( pno )) -eq "$BR_PORT" ] 2>/dev/null; then
PORT_IFACE=$(basename "$pdir")
break
fi
done
case "$PORT_IFACE" in
phy*|wlan*) CONN_TYPE="wifi" ;;
"") ;;
*) CONN_TYPE="$PORT_IFACE" ;;
esac
fi
fi
fi
if [ -n "$CONN_TYPE" ]; then
sed -i "/^$IP /d" "$CONN_CACHE" 2>/dev/null
echo "$IP $CONN_TYPE $(date +%s)" >> "$CONN_CACHE"
else
_arp_state=$(ip neigh show "$IP" 2>/dev/null | awk '{print $NF}')
case "$_arp_state" in
REACHABLE|STALE|DELAY|PROBE) CONN_TYPE="ethernet" ;;
*)
CONN_TYPE="?"
_cached=$(grep "^$IP " "$CONN_CACHE" 2>/dev/null | tail -1)
if [ -n "$_cached" ]; then
CONN_LAST=$(echo "$_cached" | awk '{print $2 "@" $3}')
fi
;;
esac
fi
# Check blocked status
BLOCKED=false
BLOCK_PACKETS=0
BLOCK_BYTES=0
if tctl_is_blocked "$IP"; then
BLOCKED=true
if [ "$TCTL_FW" = "nft" ]; then
block_line=$(nft list chain inet fw4 forward 2>/dev/null | grep "ip saddr $IP.*drop")
BLOCK_PACKETS=$(echo "$block_line" | grep -oE 'packets [0-9]+' | awk '{print $2}')
BLOCK_BYTES=$(echo "$block_line" | grep -oE 'bytes [0-9]+' | awk '{print $2}')
else
block_line=$(iptables -L FORWARD -nvx 2>/dev/null | grep "DROP" | grep "$IP")
BLOCK_PACKETS=$(echo "$block_line" | awk '{print $1}')
BLOCK_BYTES=$(echo "$block_line" | awk '{print $2}')
fi
fi
[ -z "$BLOCK_PACKETS" ] && BLOCK_PACKETS=0
[ -z "$BLOCK_BYTES" ] && BLOCK_BYTES=0
# Check wifi blocked
WIFI_BLOCKED=false
if [ -n "$MAC" ]; then
IFACES=$(tctl_get_wifi_interfaces)
for iface in $IFACES; do
maclist=$(uci -q get "wireless.${iface}.maclist")
listed=0
echo "$maclist" | grep -qi "$MAC" && listed=1
# Being listed means the opposite thing per policy: on a deny radio a
# listed MAC is blocked, on an allow (whitelist) radio an UNlisted one is.
if [ "$(tctl_get_wifi_filter_mode "$iface")" = "allow" ]; then
[ "$listed" = "0" ] && { WIFI_BLOCKED=true; break; }
else
[ "$listed" = "1" ] && { WIFI_BLOCKED=true; break; }
fi
done
fi
# Get rate limit
RATE_LIM=0
if [ "$TCTL_FW" = "nft" ]; then
RATE_LIM=$(nft list table netdev tm_ratelimit 2>/dev/null | grep "daddr $IP" | \
grep -oE '[0-9]+ kbytes' | awk '{print $1 * 8}')
else
RATE_LIM=$(iptables -t mangle -L FORWARD -nv 2>/dev/null | grep "rl_ratelimit" | grep "$IP" | \
grep -oE '[0-9]+kbit' | head -1 | sed 's/kbit//')
fi
[ -z "$RATE_LIM" ] && RATE_LIM=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
}')
fi
[ -z "$SHAPE_KBIT" ] && SHAPE_KBIT=0
# Parse conntrack for connections and totals
TIMESTAMP=$(date +%s)
CONNTRACK_DATA=$(cat /proc/net/nf_conntrack 2>/dev/null | grep "src=$IP ")
# Compute metadata (totals + states)
META_LINE=$(echo "$CONNTRACK_DATA" | awk -v ip="$IP" -v pf="$PROTO_FILTER" '
BEGIN { total=0; n_tcp=0; n_udp=0; n_other=0; est=0; tw=0; ss=0; cw=0 }
{
proto=""
for (i=1; i<=NF; i++) {
if ($i == "tcp") proto="tcp"
else if ($i == "udp") proto="udp"
else if ($i == "icmp") proto="icmp"
}
if (pf != "all" && proto != pf) next
dst=""; bytes=0; state=""
src_key = "src=" ip
seen_src=0; got_dst=0
for (i=1; i<=NF; i++) {
if ($i == src_key && !seen_src) { seen_src=1; continue }
if (seen_src && !got_dst && index($i, "dst=") == 1) { dst=substr($i, 5); got_dst=1 }
if (seen_src && !got_dst) continue
if (seen_src && index($i, "bytes=") == 1 && bytes == 0) { bytes=substr($i, 7)+0 }
if ($i == "ESTABLISHED") state="ESTABLISHED"
else if ($i == "TIME_WAIT") state="TIME_WAIT"
else if ($i == "SYN_SENT") state="SYN_SENT"
else if ($i == "CLOSE_WAIT") state="CLOSE_WAIT"
}
if (dst == "" || dst == ip) next
total += bytes
if (proto == "tcp") n_tcp++
else if (proto == "udp") n_udp++
else n_other++
if (state == "ESTABLISHED") est++
else if (state == "TIME_WAIT") tw++
else if (state == "SYN_SENT") ss++
else if (state == "CLOSE_WAIT") cw++
}
END { printf "%.0f %d %d %d %d %d %d %d", total, n_tcp, n_udp, n_other, est, tw, ss, cw }
')
TOTAL=$(echo "$META_LINE" | awk '{print $1}')
N_TCP=$(echo "$META_LINE" | awk '{print $2}')
N_UDP=$(echo "$META_LINE" | awk '{print $3}')
N_OTHER=$(echo "$META_LINE" | awk '{print $4}')
EST=$(echo "$META_LINE" | awk '{print $5}')
TW=$(echo "$META_LINE" | awk '{print $6}')
SS=$(echo "$META_LINE" | awk '{print $7}')
CW=$(echo "$META_LINE" | awk '{print $8}')
[ -z "$TOTAL" ] && TOTAL=0
[ -z "$N_TCP" ] && N_TCP=0
[ -z "$N_UDP" ] && N_UDP=0
[ -z "$N_OTHER" ] && N_OTHER=0
[ -z "$EST" ] && EST=0
[ -z "$TW" ] && TW=0
[ -z "$SS" ] && SS=0
[ -z "$CW" ] && CW=0
# Build connections JSON array
# Resolve egress interface per destination for policy-routed connections
# (mwan3 etc.). Only connections carrying a NON-zero conntrack mark are
# resolved: `ip route get <dst> mark <mark>` honours the fwmark ip-rules, so it
# reports the WAN that mwan3 actually selected. mark=0 connections (no policy
# routing, e.g. plain single-WAN or podkop) are left blank instead of showing a
# misleading main-table egress.
OIF_MAP="/tmp/trafficctl_oif_$$"
: > "$OIF_MAP"
if command -v ip >/dev/null 2>&1; then
echo "$CONNTRACK_DATA" | awk -v ip="$IP" '
{ dst=""; mark="0"; sk="src=" ip; seen=0; got=0
for (i=1; i<=NF; i++) {
if ($i==sk && !seen) { seen=1; continue }
if (seen && !got && index($i,"dst=")==1) { dst=substr($i,5); got=1 }
if (index($i,"mark=")==1) mark=substr($i,6)
}
if (dst!="" && dst!=ip && mark!="0" && mark!="") print dst, mark
}' | sort -u | while read -r dip dmark; do
[ -n "$dip" ] || continue
dev=$(ip route get "$dip" mark "$dmark" 2>/dev/null | grep -oE 'dev [^ ]+' | head -1 | cut -d' ' -f2)
case "$dev" in lo|"") continue ;; esac
echo "$dip $dev" >> "$OIF_MAP"
done
fi
CONNS_OUT=$(echo "$CONNTRACK_DATA" | awk -v ip="$IP" -v pf="$PROTO_FILTER" -v oifmap="$OIF_MAP" '
BEGIN {
n=0
while ((getline line < oifmap) > 0) { split(line, p, " "); oifdev[p[1]] = p[2] }
close(oifmap)
}
{
proto=""
for (i=1; i<=NF; i++) {
if ($i == "tcp") proto="tcp"
else if ($i == "udp") proto="udp"
else if ($i == "icmp") proto="icmp"
}
if (pf != "all" && proto != pf) next
dst=""; dport=""; bytes=0; state=""
src_key = "src=" ip
seen_src=0; got_dst=0
for (i=1; i<=NF; i++) {
if ($i == src_key && !seen_src) { seen_src=1; continue }
if (seen_src && !got_dst && index($i, "dst=") == 1) { dst=substr($i, 5); got_dst=1 }
if (seen_src && !got_dst) continue
if (seen_src && index($i, "dport=") == 1 && dport == "") dport=substr($i, 7)
if (seen_src && index($i, "bytes=") == 1 && bytes == 0) bytes=substr($i, 7)+0
if ($i == "ESTABLISHED") state="ESTABLISHED"
else if ($i == "TIME_WAIT") state="TIME_WAIT"
else if ($i == "SYN_SENT") state="SYN_SENT"
else if ($i == "CLOSE_WAIT") state="CLOSE_WAIT"
else if ($i == "FIN_WAIT") state="FIN_WAIT"
}
if (dst == "" || dst == ip) next
if (dport == "") dport = "0"
# Service name lookup
svc=""
if (dport == "80") svc="http"
else if (dport == "443") svc="https"
else if (dport == "53") svc="dns"
else if (dport == "22") svc="ssh"
else if (dport == "123") svc="ntp"
else if (dport == "5353") svc="mdns"
else if (dport == "21") svc="ftp"
else if (dport == "25") svc="smtp"
else if (dport == "993") svc="imaps"
else if (dport == "8080") svc="http-alt"
oif = ""
if (dst in oifdev) oif = oifdev[dst]
if (n > 0) printf ","
printf "{\"proto\":\"%s\",\"dst\":\"%s\",\"host\":\"\",\"port\":%s,\"service\":\"%s\",\"bytes\":%.0f,\"state\":\"%s\",\"oif\":\"%s\"}", proto, dst, dport, svc, bytes, state, oif
n++
}
')
rm -f "$OIF_MAP"
# Optionally resolve DNS for connection destinations
if [ "$DO_RDNS" = "1" ]; then
DST_IPS=$(echo "$CONNTRACK_DATA" | grep -oE 'dst=[0-9.]+' | sed 's/dst=//' | sort -u | grep -v "^$IP$")
if [ -n "$DST_IPS" ]; then
RDNS_MAP="/tmp/trafficctl_rdns_$$"
# shellcheck disable=SC2064
trap "rm -f '$RDNS_MAP'" EXIT INT TERM
: > "$RDNS_MAP"
RDNS_DONE=0
# Batch resolve via ubus network.rrdns (same as LuCI frontend; no extra packages needed)
if command -v ubus >/dev/null 2>&1; then
ADDRS_JSON=$(echo "$DST_IPS" | awk '{printf "%s\"%s\"", (NR>1?",":""), $1}')
RDNS_RESULT=$(ubus call network.rrdns lookup \
"{\"addrs\":[$ADDRS_JSON],\"timeout\":3000,\"limit\":64}" 2>/dev/null)
if [ -n "$RDNS_RESULT" ]; then
RDNS_DONE=1
for dip in $DST_IPS; do
host=$(printf '%s' "$RDNS_RESULT" | jsonfilter -e "@[\"$dip\"]" 2>/dev/null)
case "$host" in
*[!a-zA-Z0-9._-]*|"") continue ;;
esac
echo "$dip $host" >> "$RDNS_MAP"
done
fi
fi
# Fallback: per-IP nslookup (BusyBox, always available on OpenWrt)
if [ "$RDNS_DONE" = "0" ] && command -v nslookup >/dev/null 2>&1; then
for dip in $DST_IPS; do
host=$(nslookup "$dip" 2>/dev/null | sed -n 's/.*name = \(.*\)\.$/\1/p' | head -1)
case "$host" in
*[!a-zA-Z0-9._-]*|"") continue ;;
esac
echo "$dip $host" >> "$RDNS_MAP"
done
fi
if [ -s "$RDNS_MAP" ]; then
# Build sed expression from map
SED_EXPR=$(awk '{printf "s|\"dst\":\"%s\",\"host\":\"\"|\"dst\":\"%s\",\"host\":\"%s\"|g\n", $1, $1, $2}' "$RDNS_MAP")
CONNS_OUT=$(printf "%s" "$CONNS_OUT" | sed "$SED_EXPR")
fi
rm -f "$RDNS_MAP"
fi
fi
# 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,"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' \
"$IP" "$NAME" "$MAC" "$CONN_TYPE" "$CONN_LAST" "$TIMESTAMP" "$BLOCKED" "$BLOCK_PACKETS" "$BLOCK_BYTES" \
"$WIFI_BLOCKED" "$TOTAL" "$N_TCP" "$N_UDP" "$N_OTHER" \
"$EST" "$TW" "$SS" "$CW" "$CONNS_OUT" "$RATE_LIM" "$SHAPE_KBIT"
+772
View File
@@ -0,0 +1,772 @@
#!/bin/sh
# shellcheck shell=dash
# Firewall abstraction layer for trafficctl.
# Detects nft vs iptables and provides unified functions.
# Source this file: . /usr/local/bin/trafficctl-fw.sh
if command -v nft >/dev/null 2>&1 && nft list tables 2>/dev/null | grep -q .; then
TCTL_FW="nft"
else
TCTL_FW="iptables"
fi
# ── Rate Limiting (policer) ────────────────────────────────────────────────
# Rate limits are symmetric: the same ceiling is policed in both directions.
#
# Download is caught at WAN ingress (packets arriving for the client) and
# upload at LAN ingress (packets the client sends into the router). Policing
# upload on the way IN is what makes it work for downstream/routed clients
# too — their packets still enter over a LAN device with the client's source
# address, whatever router sits behind it.
# mode: "each" gives every address inside the target its own bucket (an nft
# 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 rate_kbyte=$((rate_kbit / 8))
[ "$rate_kbyte" -lt 1 ] && rate_kbyte=1
local slug dl_expr ul_expr
slug=$(tctl_target_slug "$ip")
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 }"
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"
fi
if [ "$TCTL_FW" = "nft" ]; then
nft add table netdev tm_ratelimit 2>/dev/null
# Download is policed at LAN EGRESS, not WAN ingress.
#
# With masquerading on, a reply arriving at WAN ingress is still
# addressed to the router's own WAN address: conntrack only restores
# the client's address in prerouting, which runs after the netdev
# ingress hook. So "ip daddr <client>" there matches nothing for any
# NATed client. At LAN egress the translation has happened and the
# destination is the real client address.
# Note the asymmetry with the upload hooks: ingress must bind the
# bridge PORTS (a bridge sees nothing on ingress), while egress must
# bind the BRIDGE itself — routed traffic is transmitted to br-lan by
# the IP stack, and the port-level egress hook never fires for it.
# Verified on kernel 6.12: br-lan egress counted every packet, the
# ports counted none.
local dev chain dl_ok=0 wan_dev
for dev in $(tctl_get_lan_devices); do
chain=$(tctl_egress_chain "$dev")
nft add chain netdev tm_ratelimit "$chain" \
"{ type filter hook egress device $dev priority 0; policy accept; }" 2>/dev/null
nft add rule netdev tm_ratelimit "$chain" \
"$dl_expr counter drop comment \"$comment\"" 2>/dev/null \
&& dl_ok=1
done
# Kernels older than 5.16 have no netdev egress hook; fall back to WAN
# ingress, which is correct as long as the client isn't masqueraded.
if [ "$dl_ok" = "0" ] && wan_dev=$(tctl_get_wan_device); then
nft add chain netdev tm_ratelimit dl \
"{ type filter hook ingress device $wan_dev priority -200; policy accept; }" 2>/dev/null
nft add rule netdev tm_ratelimit dl \
"$dl_expr counter drop comment \"$comment\"" 2>/dev/null \
&& dl_ok=1
fi
# Set for the caller to inspect (see trafficctl-ratelimit.sh).
# shellcheck disable=SC2034
[ "$dl_ok" = "1" ] || TCTL_RL_DOWNLOAD_FAILED=1
# One chain per ingress device: a device that refuses the hook then
# only loses its own chain instead of taking the whole set with it.
local dev chain
local ul_ok=0
for dev in $(tctl_ingress_devices); do
chain=$(tctl_ingress_chain "$dev")
nft add chain netdev tm_ratelimit "$chain" \
"{ type filter hook ingress device $dev priority -200; policy accept; }" 2>/dev/null
nft add rule netdev tm_ratelimit "$chain" \
"$ul_expr counter drop comment \"${comment}_ul\"" 2>/dev/null \
&& ul_ok=1
done
# shellcheck disable=SC2034
[ "$ul_ok" = "1" ] || TCTL_RL_UPLOAD_FAILED=1
else
iptables -t mangle -A FORWARD -d "$ip" -m hashlimit \
--hashlimit-above "${rate_kbit}kbit/sec" --hashlimit-burst "${rate_kbit}kbit" \
--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-mode srcip --hashlimit-name "rl_${comment}_ul" \
-j DROP -m comment --comment "${comment}_ul" 2>/dev/null
fi
}
tctl_ratelimit_remove() {
local ip="$1" comment="$2"
local chain h
if [ "$TCTL_FW" = "nft" ]; then
# Scan the whole table once: rules for this IP live in dl (daddr) and
# in one ul_<dev> chain per ingress device (saddr).
nft -a list table netdev tm_ratelimit 2>/dev/null | awk -v cmt="$comment" '
/^[ \t]*chain [a-zA-Z0-9_]+ \{/ { chain = $2; next }
index($0, "\"" cmt "\"") || index($0, "\"" cmt "_ul\"") {
for (i = 1; i < NF; i++)
if ($i == "handle") { print chain, $(i+1); break }
}' | while read -r chain h; do
[ -n "$chain" ] && [ -n "$h" ] && nft delete rule netdev tm_ratelimit "$chain" handle "$h" 2>/dev/null
done
else
while iptables -t mangle -D FORWARD -d "$ip" -m comment --comment "$comment" 2>/dev/null; do :; done
while iptables -t mangle -D FORWARD -s "$ip" -m comment --comment "${comment}_ul" 2>/dev/null; do :; done
fi
}
tctl_ratelimit_list() {
if [ "$TCTL_FW" = "nft" ]; then
nft list table netdev tm_ratelimit 2>/dev/null
else
iptables -t mangle -L FORWARD -nv --line-numbers 2>/dev/null | grep "rl_ratelimit"
fi
}
# ── Internet Blocking ──────────────────────────────────────────────────────
tctl_block_add() {
local ip="$1" comment="$2"
if [ "$TCTL_FW" = "nft" ]; then
nft insert rule inet fw4 forward "ip saddr $ip counter drop comment \"$comment\""
else
iptables -I FORWARD -s "$ip" -j DROP -m comment --comment "$comment"
fi
}
tctl_block_remove() {
local ip="$1" comment="$2"
if [ "$TCTL_FW" = "nft" ]; then
# Match the quoted comment in full: comments end in the address, so a
# substring match on 192.168.1.1 also deletes the rule for 192.168.1.10.
for h in $(nft -a list chain inet fw4 forward 2>/dev/null \
| awk -v cmt="$comment" 'index($0, "\"" cmt "\"") {
for (i = 1; i < NF; i++)
if ($i == "handle") { print $(i+1); break }
}'); do
nft delete rule inet fw4 forward handle "$h"
done
else
while iptables -D FORWARD -s "$ip" -m comment --comment "$comment" -j DROP 2>/dev/null; do :; done
fi
}
tctl_is_blocked() {
local ip="$1"
if [ "$TCTL_FW" = "nft" ]; then
nft list chain inet fw4 forward 2>/dev/null | grep -q "ip saddr $ip .*drop"
else
# The source renders as its own column, "ip" or "ip/32"; an unanchored
# match would report 192.168.1.10's rule as belonging to 192.168.1.1.
iptables -L FORWARD -n 2>/dev/null | awk -v ip="$ip" '
$1 == "DROP" {
src = $4
sub(/\/32$/, "", src)
if (src == ip) { found = 1; exit }
}
END { exit(found ? 0 : 1) }'
fi
}
# ── Helpers ────────────────────────────────────────────────────────────────
# Resolve the WAN device to a name that actually exists as a netdev.
#
# The old fallback returned the literal string "wan", which is an interface
# NAME, not a device — nft then rejected the chain ("device wan" doesn't
# exist), the failure was swallowed, and download limiting silently did
# nothing while still reporting success. Every candidate is now verified
# against sysfs, with the default route as a last resort.
tctl_get_wan_device() {
local sysfs="${TCTL_SYSFS_NET:-/sys/class/net}"
local dev candidates
candidates="$(ubus call network.interface.wan status 2>/dev/null | jsonfilter -e '@.l3_device' 2>/dev/null)
$(uci -q get network.wan.device 2>/dev/null)
$(uci -q get network.wan.device 2>/dev/null)
$(ip route show default 2>/dev/null | awk '/^default/{for (i = 1; i <= NF; i++) if ($i == "dev") { print $(i+1); exit }}')"
for dev in $candidates; do
[ -n "$dev" ] || continue
# A bridge/device name from uci can still be a config-only alias.
[ -e "$sysfs/$dev" ] || continue
echo "$dev"
return 0
done
return 1
}
tctl_get_lan_device() {
local dev
dev=$(uci -q get network.lan.device 2>/dev/null)
[ -z "$dev" ] && dev=$(uci -q get network.lan.device 2>/dev/null)
[ -z "$dev" ] && dev="br-lan"
echo "$dev"
}
# Enumerate all LAN-side IPv4 subnets, one per L3 interface.
#
# "LAN" = firewall zones that are NOT internet-facing. A zone is treated as LAN
# if it is named "lan", or if it is neither a wan zone nor masqueraded. This
# deliberately excludes VPN/tunnel zones (e.g. WireGuard/AmneziaWG awg*, which
# carry their own IPv4 and would otherwise be mistaken for LANs) because those
# are masqueraded out. Covers bridges (br-lan), bridge-VLANs and plain VLAN
# interfaces (eth0.20) uniformly via each interface's l3_device.
#
# Output: one line per subnet, "l3_device netbase_int block_size router_int"
# where membership can be tested without awk bit-ops:
# ip in subnet <=> ipint - (ipint % block) == netbase
tctl_lan_subnets() {
local i=0 zname zmasq nets net st l3 addr mask
local o1 o2 o3 o4 ipint block netbase
while zname=$(uci -q get "firewall.@zone[$i].name" 2>/dev/null); [ -n "$zname" ]; do
zmasq=$(uci -q get "firewall.@zone[$i].masq" 2>/dev/null)
nets=$(uci -q get "firewall.@zone[$i].network" 2>/dev/null)
i=$((i + 1))
case "$zname" in wan|wan6) continue ;; esac
[ "$zname" != "lan" ] && [ "$zmasq" = "1" ] && continue
for net in $nets; do
st=$(ubus call "network.interface.$net" status 2>/dev/null)
l3=$(echo "$st" | jsonfilter -e '@.l3_device' 2>/dev/null)
addr=$(echo "$st" | jsonfilter -e '@["ipv4-address"][0].address' 2>/dev/null)
mask=$(echo "$st" | jsonfilter -e '@["ipv4-address"][0].mask' 2>/dev/null)
[ -n "$l3" ] && [ -n "$addr" ] && [ -n "$mask" ] || continue
[ "$mask" -ge 1 ] && [ "$mask" -le 32 ] 2>/dev/null || continue
o1=${addr%%.*}; rest=${addr#*.}
o2=${rest%%.*}; rest=${rest#*.}
o3=${rest%%.*}; o4=${rest##*.}
ipint=$(( (o1 << 24) + (o2 << 16) + (o3 << 8) + o4 ))
block=$(( 1 << (32 - mask) ))
netbase=$(( ipint - (ipint % block) ))
echo "$l3 $netbase $block $ipint"
done
done
}
# Concrete devices to attach netdev ingress hooks to.
#
# A netdev ingress hook bound to a BRIDGE never sees bridged traffic: packets
# are received on the bridge's physical ports, so the hook must live there.
# Binding to br-lan silently matches nothing, which is exactly how upload
# limiting appeared to be applied while having no effect. Non-bridge L3
# devices (plain ports, VLAN interfaces) are hooked directly.
tctl_ingress_devices() {
local sysfs="${TCTL_SYSFS_NET:-/sys/class/net}"
local dev port
for dev in $(tctl_get_lan_devices); do
if [ -d "$sysfs/$dev/brif" ]; then
for port in "$sysfs/$dev/brif/"*; do
[ -e "$port" ] || continue
basename "$port"
done
else
echo "$dev"
fi
done | sort -u
}
# nft chain names for a device's hooks (chain names can't contain dots or
# dashes, which interface names routinely do).
tctl_ingress_chain() {
printf 'ul_%s' "$(printf '%s' "$1" | tr -c 'a-zA-Z0-9' '_')"
}
tctl_egress_chain() {
printf 'dl_%s' "$(printf '%s' "$1" | tr -c 'a-zA-Z0-9' '_')"
}
# LAN L3 device names only (deduplicated), e.g. "br-lan br-guest eth0.20".
tctl_get_lan_devices() {
tctl_lan_subnets | awk '{print $1}' | sort -u
}
# Convert "a.b.c.d/m" (or a bare host address) to "netbase_int block_size",
# normalized to the network base. Fails silently on malformed input.
tctl_cidr_spec() {
local cidr="$1" addr mask rest o1 o2 o3 o4 ipint block
addr=${cidr%%/*}
mask=${cidr#*/}
[ "$mask" = "$cidr" ] && mask=32
tctl_validate_ip "$addr" || return 1
case "$mask" in ''|*[!0-9]*) return 1 ;; esac
[ "$mask" -ge 1 ] && [ "$mask" -le 32 ] || return 1
o1=${addr%%.*}; rest=${addr#*.}
o2=${rest%%.*}; rest=${rest#*.}
o3=${rest%%.*}; o4=${rest##*.}
ipint=$(( (o1 << 24) + (o2 << 16) + (o3 << 8) + o4 ))
block=$(( 1 << (32 - mask) ))
echo "$(( ipint - (ipint % block) )) $block"
}
# Downstream subnets routed via a next-hop on a LAN interface — e.g. clients
# behind a second router on the LAN whose packets are forwarded (and NATed on
# WAN) through this router with their original source addresses. Also includes
# operator-defined extras from trafficctl.main.extra_subnets (space-separated
# CIDRs) for setups without an explicit kernel route.
# Output format matches tctl_lan_subnets; router_int is 0 because no local
# address lives inside a routed subnet (consumers use 0 to tell routed from
# directly connected).
tctl_routed_subnets() {
local lan_devs dev cidr spec extras
lan_devs=$(tctl_get_lan_devices)
if [ -n "$lan_devs" ]; then
ip route show 2>/dev/null | awk '
$1 != "default" && / via / {
dev = ""
for (i = 1; i <= NF; i++) if ($i == "dev") dev = $(i+1)
if (dev != "") print $1, dev
}' | while read -r cidr dev; do
echo "$lan_devs" | grep -qxF "$dev" || continue
spec=$(tctl_cidr_spec "$cidr") || continue
echo "$dev $spec 0"
done
fi
extras=$(uci -q get trafficctl.main.extra_subnets 2>/dev/null)
if [ -n "$extras" ]; then
dev=$(tctl_get_lan_device)
for cidr in $extras; do
spec=$(tctl_cidr_spec "$cidr") || continue
echo "$dev $spec 0"
done
fi
}
# Every subnet worth monitoring: directly connected LANs first, then routed
# and extra ones. Duplicate prefixes are dropped (first wins, keeping the
# connected entry with its real router address).
tctl_monitored_subnets() {
{ tctl_lan_subnets; tctl_routed_subnets; } | awk '!seen[$2":"$3]++'
}
# True (0) when the IP belongs to a directly connected LAN subnet; routed and
# extra subnets do not count. Used to tell on-link devices from downstream
# ones that only have an L3 presence here.
tctl_ip_in_lan() {
local ip="$1" o1 o2 o3 o4 rest ipint
tctl_validate_ip "$ip" || return 1
o1=${ip%%.*}; rest=${ip#*.}
o2=${rest%%.*}; rest=${rest#*.}
o3=${rest%%.*}; o4=${rest##*.}
ipint=$(( (o1 << 24) + (o2 << 16) + (o3 << 8) + o4 ))
tctl_lan_subnets | awk -v si="$ipint" '
si - (si % $3) == $2 { hit = 1; exit }
END { exit hit ? 0 : 1 }'
}
# Accept a single host address or a CIDR block ("all" means every address).
# Echoes the normalized target, or fails.
tctl_validate_target() {
local t="$1" addr mask
case "$t" in
all|any) echo "0.0.0.0/0"; return 0 ;;
esac
case "$t" in
*/*)
addr=${t%%/*}
mask=${t#*/}
tctl_validate_ip "$addr" || return 1
case "$mask" in ''|*[!0-9]*) return 1 ;; esac
[ "$mask" -ge 0 ] && [ "$mask" -le 32 ] || return 1
echo "$addr/$mask"
;;
*)
tctl_validate_ip "$t" || return 1
echo "$t"
;;
esac
}
# A target usable inside an nft set/meter name or a rule comment.
tctl_target_slug() {
printf '%s' "$1" | tr './' '__'
}
# Rule comments identify the target, not the caller. They used to be built from
# a caller-supplied label, so a block placed from LuCI (label "block_<ip>") and
# the same block seen from the Telegram bot (label "tg") produced different
# comments — removal grepped for the wrong one, found nothing, and still
# reported success.
tctl_block_comment() {
echo "tctl_block_$(tctl_target_slug "$1")"
}
tctl_ratelimit_comment() {
echo "rl_ratelimit_$(tctl_target_slug "$1")"
}
tctl_validate_ip() {
echo "$1" | grep -qE '^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$' || return 1
local IFS='.'
# shellcheck disable=SC2086
set -- $1
[ "$1" -le 255 ] && [ "$2" -le 255 ] && [ "$3" -le 255 ] && [ "$4" -le 255 ] 2>/dev/null
}
tctl_get_wifi_interfaces() {
uci show wireless 2>/dev/null | grep '=wifi-iface' | cut -d. -f2 | cut -d= -f1
}
# Get running WiFi interface names (e.g. wlan0, wlan1)
tctl_get_hostapd_ifaces() {
ubus list 2>/dev/null | grep '^hostapd\.' | cut -d. -f2
}
# Add MAC to hostapd deny ACL at runtime + deauth the client (no wifi reload)
# Which ACL policy a wifi-iface uses: "allow" (whitelist — only listed MACs may
# associate) or "deny" (blacklist — listed MACs are rejected). Anything else,
# including unset, means no filtering is configured yet, reported as "deny"
# because that is the mode we create on demand.
tctl_get_wifi_filter_mode() {
local iface="$1" mode
mode=$(uci -q get "wireless.${iface}.macfilter" 2>/dev/null)
[ "$mode" = "allow" ] && echo "allow" || echo "deny"
}
# Block a MAC at runtime. In deny mode that means adding it to the deny ACL; in
# allow (whitelist) mode it means dropping it from the accept ACL. Either way
# the client is deauthenticated so the change takes effect immediately.
tctl_hostapd_block_mac() {
local mac="$1" mode="$2"
local iface
for iface in $(tctl_get_hostapd_ifaces); do
if [ "$mode" = "allow" ]; then
hostapd_cli -i "$iface" accept_acl DEL_MAC "$mac" 2>/dev/null
else
hostapd_cli -i "$iface" deny_acl ADD_MAC "$mac" 2>/dev/null
fi
hostapd_cli -i "$iface" deauthenticate "$mac" 2>/dev/null
done
}
# Unblock a MAC at runtime — the inverse of tctl_hostapd_block_mac.
tctl_hostapd_unblock_mac() {
local mac="$1" mode="$2"
local iface
for iface in $(tctl_get_hostapd_ifaces); do
if [ "$mode" = "allow" ]; then
hostapd_cli -i "$iface" accept_acl ADD_MAC "$mac" 2>/dev/null
else
hostapd_cli -i "$iface" deny_acl DEL_MAC "$mac" 2>/dev/null
fi
done
}
tctl_hostapd_deny_mac() {
local mac="$1"
local iface
for iface in $(tctl_get_hostapd_ifaces); do
hostapd_cli -i "$iface" deny_acl ADD_MAC "$mac" 2>/dev/null
hostapd_cli -i "$iface" deauthenticate "$mac" 2>/dev/null
done
}
# Remove MAC from hostapd deny ACL at runtime (client can reassociate immediately)
tctl_hostapd_allow_mac() {
local mac="$1"
local iface
for iface in $(tctl_get_hostapd_ifaces); do
hostapd_cli -i "$iface" deny_acl DEL_MAC "$mac" 2>/dev/null
done
}
# ── Persistence ───────────────────────────────────────────────────────────
TCTL_RULES_FILE="/etc/trafficctl/rules.json"
tctl_persist_enabled() {
[ "$(uci -q get trafficctl.main.persist_rules 2>/dev/null)" = "1" ]
}
tctl_persist_save() {
local type="$1" ip="$2" param="$3"
[ -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"
# Remove existing entry for same ip+type, append new one
awk -v ip="$ip" -v t="$type" -v p="$param" '
{
gsub(/^\[/,""); gsub(/\]$/,"")
n=split($0, items, "},{")
printf "["
first=1
for (i=1; i<=n; i++) {
sub(/^\{/,"",items[i]); sub(/\}$/,"",items[i])
if (items[i] ~ "\"ip\":\"" ip "\"" && items[i] ~ "\"type\":\"" t "\"") continue
if (!first) printf ","
printf "{%s}", items[i]
first=0
}
if (!first) printf ","
printf "{\"type\":\"%s\",\"ip\":\"%s\",\"param\":\"%s\"}]", t, ip, p
}' "$TCTL_RULES_FILE" > "$tmp"
mv "$tmp" "$TCTL_RULES_FILE"
}
tctl_persist_remove() {
local type="$1" ip="$2"
[ -f "$TCTL_RULES_FILE" ] || return 0
local tmp="${TCTL_RULES_FILE}.tmp"
awk -v ip="$ip" -v t="$type" '
{
gsub(/^\[/,""); gsub(/\]$/,"")
n=split($0, items, "},{")
printf "["
first=1
for (i=1; i<=n; i++) {
sub(/^\{/,"",items[i]); sub(/\}$/,"",items[i])
if (items[i] ~ "\"ip\":\"" ip "\"" && items[i] ~ "\"type\":\"" t "\"") continue
if (!first) printf ","
printf "{%s}", items[i]
first=0
}
printf "]"
}' "$TCTL_RULES_FILE" > "$tmp"
mv "$tmp" "$TCTL_RULES_FILE"
}
# ── New-device ledger ─────────────────────────────────────────────────────
#
# Which MAC addresses this router has already seen. The default-limit feature
# keys off it, which makes the ledger safety-critical in one direction only:
# wrongly calling a device NEW applies an unasked-for limit, while wrongly
# calling it KNOWN merely does nothing. Every ambiguous case below therefore
# resolves to "known".
#
# It lives in /etc/trafficctl (kept across sysupgrade by keep.d) rather than
# in tmpfs. A ledger that reset on reboot would make every device on the LAN
# new again on the next lease renewal — the same mass-limiting accident, just
# reached more slowly. Flash cost is bounded by device count, not by DHCP
# traffic: an entry is appended only for a MAC that is not already listed.
TCTL_SEEN_FILE="/etc/trafficctl/seen_macs"
TCTL_SEEN_MAX=1000
TCTL_LEASES_FILE="/tmp/dhcp.leases"
TCTL_SHAPES_FILE="/etc/trafficctl/shapes.json"
tctl_seen_normalize() {
printf '%s' "$1" | tr 'A-Z' 'a-z'
}
# Take the baseline from the devices already on the network, so switching the
# feature on does not declare the existing LAN to be new. Callers should do
# this at a moment when the network is up (i.e. from the rpcd setter, on an
# operator's action) rather than early in boot, when both sources are empty.
tctl_seen_seed() {
[ -f "$TCTL_SEEN_FILE" ] && return 0
mkdir -p "$(dirname "$TCTL_SEEN_FILE")" 2>/dev/null
{
awk '{ print $2 }' "$TCTL_LEASES_FILE" 2>/dev/null
ip neigh show 2>/dev/null | awk '/lladdr/ { print $5 }'
} | tr 'A-Z' 'a-z' \
| grep -E '^([0-9a-f]{2}:){5}[0-9a-f]{2}$' \
| sort -u > "${TCTL_SEEN_FILE}.tmp" 2>/dev/null
mv "${TCTL_SEEN_FILE}.tmp" "$TCTL_SEEN_FILE" 2>/dev/null
}
tctl_seen_count() {
[ -f "$TCTL_SEEN_FILE" ] || { echo 0; return 0; }
wc -l < "$TCTL_SEEN_FILE" 2>/dev/null | tr -d ' ' || echo 0
}
# "Known" is the safe answer, so an unreadable MAC or a missing ledger says
# yes. Whether the ledger EXISTS is a separate question, asked by the caller
# through tctl_seen_ready before it acts on a negative.
tctl_seen_known() {
local mac
mac=$(tctl_seen_normalize "$1")
[ -n "$mac" ] || return 0
[ -f "$TCTL_SEEN_FILE" ] || return 0
grep -qxF "$mac" "$TCTL_SEEN_FILE" 2>/dev/null
}
tctl_seen_ready() {
[ -f "$TCTL_SEEN_FILE" ]
}
tctl_seen_mark() {
local mac n keep
mac=$(tctl_seen_normalize "$1")
[ -n "$mac" ] || return 0
# Not tctl_seen_known alone: it answers "known" for a missing ledger,
# which here would mean never writing the first entry.
if [ -f "$TCTL_SEEN_FILE" ] && tctl_seen_known "$mac"; then
return 0
fi
mkdir -p "$(dirname "$TCTL_SEEN_FILE")" 2>/dev/null
printf '%s\n' "$mac" >> "$TCTL_SEEN_FILE"
# Trim from the head when the ledger outgrows the cap. A device dropped
# this way can be seen as new again later; the cap sits far above any
# plausible LAN, so that is a theoretical cost against an unbounded file.
n=$(tctl_seen_count)
if [ "$n" -gt "$TCTL_SEEN_MAX" ]; then
keep=$((TCTL_SEEN_MAX * 4 / 5))
tail -n "$keep" "$TCTL_SEEN_FILE" > "${TCTL_SEEN_FILE}.tmp" 2>/dev/null &&
mv "${TCTL_SEEN_FILE}.tmp" "$TCTL_SEEN_FILE"
fi
}
# Does this address already carry a limit somebody set deliberately?
#
# Live nft/tc state alone is not a sufficient answer. After a reboot the
# restore hook runs on `ifup lan` and only when persist_rules is on, so a
# device with a manual limit reads as unlimited until then — and a default
# limit applied in that window would silently overwrite the operator's
# choice. The persisted files are consulted as well.
tctl_has_limit() {
local ip="$1"
[ -n "$ip" ] || return 1
/usr/local/bin/trafficctl-ratelimit-stats.sh 2>/dev/null \
| grep -qF "\"ip\":\"$ip\"" && return 0
/usr/local/bin/trafficctl-shape-stats.sh 2>/dev/null \
| grep -qF "\"ip\":\"$ip\"" && return 0
if [ -f "$TCTL_RULES_FILE" ]; then
grep -qF "\"ip\":\"$ip\"" "$TCTL_RULES_FILE" 2>/dev/null && return 0
fi
if [ -f "$TCTL_SHAPES_FILE" ]; then
grep -qF "\"ip\":\"$ip\"" "$TCTL_SHAPES_FILE" 2>/dev/null && return 0
fi
return 1
}
# ── Activity Logging ──────────────────────────────────────────────────────
TCTL_LOG_TAG="trafficctl"
tctl_log_enabled() {
[ "$(uci -q get trafficctl.logging.enabled 2>/dev/null)" = "1" ]
}
tctl_log_category_enabled() {
local cat="$1"
[ "$(uci -q get "trafficctl.logging.log_${cat}" 2>/dev/null)" != "0" ]
}
TCTL_LOG_DEFAULT="/tmp/trafficctl/activity.log"
# The log path is writable through the LuCI write ACL and is used both as an
# append target and as a tail/rewrite target, so an unconstrained value is an
# arbitrary root read and truncate. Only dedicated log locations are accepted.
tctl_validate_log_file() {
local f="$1"
case "$f" in
/tmp/trafficctl/*|/var/log/*) ;;
*) return 1 ;;
esac
case "$f" in
*/../*|*/..|*/) return 1 ;;
esac
# Path characters are restricted rather than blacklisted so no shell or glob
# metacharacter can reach the redirect, tail or mv below.
case "$f" in
*[!A-Za-z0-9._/-]*) return 1 ;;
esac
return 0
}
tctl_log_file() {
local f
f=$(uci -q get trafficctl.logging.log_file 2>/dev/null)
if [ -n "$f" ] && tctl_validate_log_file "$f"; then
echo "$f"
else
echo "$TCTL_LOG_DEFAULT"
fi
}
# Rotation keeps 3/5 of the cap, so a cap below 5 would round to zero lines and
# empty the file on the next write.
tctl_log_max_lines() {
local n
n=$(uci -q get trafficctl.logging.max_lines 2>/dev/null)
case "$n" in
''|*[!0-9]*) echo 500; return 0 ;;
esac
[ "$n" -ge 20 ] 2>/dev/null || { echo 20; return 0; }
[ "$n" -le 100000 ] 2>/dev/null || { echo 100000; return 0; }
echo "$n"
}
tctl_log() {
local action="$1" target="$2" detail="$3" via="${4:-cli}" src="${5:-local}"
tctl_log_enabled || return 0
local category
case "$action" in
block|unblock) category="blocks" ;;
ratelimit*) category="ratelimits" ;;
shape*) category="shapes" ;;
telegram*) category="telegram" ;;
config*) category="config" ;;
*) category="config" ;;
esac
tctl_log_category_enabled "$category" || return 0
local ts user log_file max_lines
ts=$(date '+%Y-%m-%d %H:%M:%S')
user="${TCTL_USER:-$(id -un 2>/dev/null || echo unknown)}"
log_file=$(tctl_log_file)
max_lines=$(tctl_log_max_lines)
local entry="[$TCTL_LOG_TAG] $ts src=$src user=$user via=$via action=$action target=$target${detail:+ detail=$detail}"
[ -d "$(dirname "$log_file")" ] || mkdir -p "$(dirname "$log_file")"
echo "$entry" >> "$log_file"
# Rotate if over max_lines
local lc
lc=$(wc -l < "$log_file" 2>/dev/null || echo 0)
if [ "$lc" -gt "$max_lines" ]; then
local keep=$(( max_lines * 3 / 5 ))
tail -n "$keep" "$log_file" > "${log_file}.tmp"
mv "${log_file}.tmp" "$log_file"
fi
# Duplicate to syslog if configured
if [ "$(uci -q get trafficctl.logging.syslog 2>/dev/null)" = "1" ]; then
logger -t "$TCTL_LOG_TAG" "$ts src=$src user=$user via=$via action=$action target=$target${detail:+ detail=$detail}"
fi
}
# ── Flow Offload Detection ─────────────────────────────────────────────────
tctl_get_offload_mode() {
local sw hw
sw=$(uci -q get firewall.@defaults[0].flow_offloading 2>/dev/null)
hw=$(uci -q get firewall.@defaults[0].flow_offloading_hw 2>/dev/null)
if [ "$hw" = "1" ]; then
# kernel 5.7+ supports counter sync on flowtables (docs.kernel.org/networking/nf_flowtable.html).
# OpenWrt 22.03+ fw4 sets the counter flag by default, syncing hardware
# byte counts back to conntrack — monitoring works.
if nft list flowtables 2>/dev/null | grep -q "counter"; then
echo "hardware-counter"
else
echo "hardware"
fi
elif [ "$sw" = "1" ]; then
# A counter-flagged flowtable syncs offloaded byte counts back to
# conntrack, so conntrack accounting stays valid — worth
# distinguishing, because the nft-map fallback needs dynamic counter
# maps that many kernels don't support.
if nft list flowtables 2>/dev/null | grep -q "counter"; then
echo "software-counter"
else
echo "software"
fi
else
echo "none"
fi
}
+299
View File
@@ -0,0 +1,299 @@
#!/bin/sh
# shellcheck shell=dash
# Per-interface byte counters for the global (bmon-style) overview.
#
# Output: JSON array
# [{"dev":"eth1","label":"wan","role":"wan","tunnel":false,"primary":true,
# "defroute":true,"enslaved":false,"up":true,"rx_bytes":N,"tx_bytes":N}]
#
# rx/tx are the kernel's own counters, i.e. relative to the INTERFACE, not to
# the client: rx on the WAN device is download, rx on br-lan is what the LAN
# sent upstream. Rates are not computed here — the frontend diffs two samples
# exactly as it already does for trafficctl-bytes.sh, so this script stays
# stateless and one sample cheap.
#
# Memory / fork discipline (128–512 MB routers; the dashboard polls this on
# its Poll interval, as often as every 2 s):
# * the hot path is ONE read of /proc/net/dev and ONE awk — nothing else
# * the role map is the expensive part (tctl_lan_subnets forks ubus and
# jsonfilter per configured network, and merely sourcing trafficctl-fw.sh
# runs `nft list tables`), so it is computed on a cold path and memoized
# in tmpfs with a TTL, the same idiom the rdns and netify caches use.
# trafficctl-fw.sh is therefore sourced ONLY inside build_cache()
# * even the cold path classifies every interface in a SINGLE awk rather
# than forking one per device out of a `while read` loop
# * the cache is a few dozen bytes per interface and lives in /tmp, so
# polling never touches flash
#
# Usage: trafficctl-ifaces.sh
CACHE="${TCTL_IFROLE_CACHE:-/tmp/trafficctl_ifroles}"
PROC_NET_DEV="${TCTL_PROC_NET_DEV:-/proc/net/dev}"
SYSFS="${TCTL_SYSFS_NET:-/sys/class/net}"
# 60 s: a WAN failover or a tunnel changing role shows up within a minute,
# which is 30 polls' worth of work saved at the default 2 s interval. A device
# that is entirely NEW does not wait for the TTL — see the #MISS retry below.
TTL="${TCTL_IFROLE_TTL:-60}"
build_cache() {
# COLD PATH ONLY. Never call this from the per-poll path.
. /usr/local/bin/trafficctl-fw.sh
# LAN = the L3 devices trafficctl already monitors (br-lan, br-guest,
# eth0.20 …). Deliberately the L3 devices and not their bridge ports:
# listing both would show the same bytes twice. Naming alone does not
# achieve that — lan2/lan3/phy0-ap0 are ports of br-lan and look like
# ordinary devices — so enslavement is detected from sysfs below.
_lan=$(tctl_get_lan_devices 2>/dev/null | tr '\n' ' ')
# Devices carrying a default route, v4 or v6. This is NOT the same as
# "the WAN" — see the role rules in the awk below.
_defroute=$( { ip -4 route show default; ip -6 route show default; } 2>/dev/null \
| awk '{ for (i = 1; i < NF; i++) if ($i == "dev") print $(i + 1) }' \
| sort -u | tr '\n' ' ')
# Friendly labels: the uci/ubus interface name for each L3 device, so the
# overview says "wan"/"lan"/"vpn_fi" instead of "eth1"/"br-lan"/"awg0".
# One ubus call, parsed line-wise — "interface" always precedes
# "l3_device" inside an entry, and the outer '"interface": [' line has no
# string value so it cannot seed a stale name.
#
# Flattened to ONE space-separated "dev name dev name …" list, not kept as
# lines: a multi-line value passed through `awk -v` is rejected outright by
# some awks ("newline in string"), so the pairs are read positionally
# below. Neither a device name nor a uci interface name can contain a
# space, so the pairing is unambiguous.
_labels=$(ubus call network.interface dump 2>/dev/null | awk '
function val(line, seg) {
if (!match(line, /:[ \t]*"/)) return ""
seg = substr(line, RSTART + RLENGTH)
if (!match(seg, /"/)) return ""
return substr(seg, 1, RSTART - 1)
}
/"interface"[ \t]*:/ { name = val($0); next }
/"l3_device"[ \t]*:/ {
dev = val($0)
if (dev != "" && name != "") { print dev " " name; name = "" }
}' | tr '\n' ' ')
# Every device present in /proc/net/dev gets a line, even the ones with no
# recognisable role. That is what makes the #MISS retry below terminate:
# after a rebuild, a lookup can only miss if an interface appeared in the
# meantime.
_tmp="$CACHE.$$"
{
echo "# $(date +%s)"
awk -v defroute="$_defroute" -v lan="$_lan" -v labels="$_labels" -v sysfs="$SYSFS" '
# Device-name patterns for tunnels. This is a LABEL as much as a role: it tells
# the UI how to group and badge the interface. ppp*/pppoe* is deliberately
# ABSENT — pppoe-wan is the standard OpenWrt DSL uplink, not a VPN, and badging
# it as one would be wrong on every PPPoE line.
function is_tun(d) {
return (d ~ /^(wg|awg|tun|tap|gre|sit|vti|ipsec|ip6tnl|xfrm|zt|nordlynx|tailscale|l2tp|pptp)/)
}
# Which of several uci interfaces sharing one l3_device gives the device its
# name. A dual-stack uplink is three interfaces on one device — "wan", "wan6"
# and "wan6_alias0" — and whichever the ubus dump happened to emit last used to
# win, so a router could title its uplink row "wan6_alias0". Lower score wins;
# ties break on length then lexically, so the result never depends on dump
# order.
function lbl_score(n_, s) {
s = 0
if (n_ ~ /_alias/) s += 4
if (n_ ~ /6$/ || n_ ~ /6_/) s += 2
return s
}
BEGIN {
n = split(defroute, a, " ")
for (i = 1; i <= n; i++) if (a[i] != "") isdef[a[i]] = 1
n = split(lan, a, " ")
for (i = 1; i <= n; i++) if (a[i] != "") islan[a[i]] = 1
# "dev name dev name …" — read two at a time, see the flattening above.
n = split(labels, a, " ")
for (i = 1; i + 1 <= n; i += 2) {
_d = a[i]; _n = a[i + 1]
if (_d == "" || _n == "") continue
# The uci network named wan/wan6 (or an alias on one) marks the uplink
# even when something else currently owns the default route — and it is
# taken from ANY of the device names, not just the one that wins below.
if (_n == "wan" || _n == "wan6" || _n ~ /^wan6?_/) isuciwan[_d] = 1
s = lbl_score(_n)
if (lblset[_d] != 1) { lbl[_d] = _n; lblscore[_d] = s; lblset[_d] = 1; continue }
if (s < lblscore[_d] || (s == lblscore[_d] && \
(length(_n) < length(lbl[_d]) || \
(length(_n) == length(lbl[_d]) && _n < lbl[_d])))) {
lbl[_d] = _n; lblscore[_d] = s
}
}
}
NR > 2 {
if (index($0, ":") == 0) next
d = substr($0, 1, index($0, ":") - 1)
gsub(/[ \t]/, "", d)
if (d == "" || d == "lo") next
devs[++nd] = d
tun[d] = is_tun(d) ? 1 : 0
# Enslaved = a port of a bridge (lan2, lan3, phy0-ap0 …), which reports the
# very same bytes its bridge already reports. A sysfs lookup, because the
# names give nothing away: "lan3" and "br-lan" look equally like top-level
# devices. Reading master/ifindex rather than testing the symlink keeps it
# to the one primitive awk has.
ens[d] = 0
if ((getline _x < (sysfs "/" d "/master/ifindex")) > 0) ens[d] = 1
close(sysfs "/" d "/master/ifindex")
# Role rules, in priority order.
#
# Every test below compares a VALUE, never `d in arr`. Referencing arr[d] in
# an awk condition CREATES the element, so a later membership test would be
# true for every device that merely reached the condition — which is exactly
# how "defroute" came out true for every non-LAN, non-tunnel interface.
#
# Tunnels are checked before default routes because a full-tunnel router
# (WireGuard/AmneziaWG, common on these boxes) has its default route via
# awg0 while the physical uplink still carries every byte ENCAPSULATED.
# Classifying awg0 as the WAN would both hide the real uplink under "other"
# and double-count the same traffic, so a tunnel stays a tunnel and the
# uplink stays the WAN; "defroute" records who actually holds the route.
if (ens[d] == 1) role[d] = "other"
else if (islan[d] == 1) role[d] = "lan"
else if (tun[d] == 1) role[d] = "vpn"
else if (isuciwan[d] == 1 || isdef[d] == 1) { role[d] = "wan"; nwan++ }
else role[d] = "other"
}
END {
# Fallback: a router whose ONLY default route is a tunnel and which has no
# uci "wan" would otherwise have no WAN row at all, leaving the overview
# headline blank. Promote the route holder in that case only.
if (!nwan) {
for (i = 1; i <= nd; i++) {
if (isdef[devs[i]] == 1 && ens[devs[i]] != 1) { role[devs[i]] = "wan"; nwan++ }
}
}
# Exactly one interface is flagged primary — the headline graph shows that
# one rather than a sum, because summing several WANs double-counts a
# failover pair and mixes two unrelated links (see #28).
for (i = 1; i <= nd && !prim; i++) {
d = devs[i]
if (role[d] == "wan" && isuciwan[d] == 1) prim = d
}
for (i = 1; i <= nd && !prim; i++) {
d = devs[i]
if (role[d] == "wan" && isdef[d] == 1) prim = d
}
for (i = 1; i <= nd && !prim; i++) {
if (role[devs[i]] == "wan") prim = devs[i]
}
for (i = 1; i <= nd; i++) {
d = devs[i]
dfl = 0; if (isdef[d] == 1) dfl = 1
pr = 0; if (d == prim) pr = 1
en = 0; if (ens[d] == 1) en = 1
lb = "-"; if (lblset[d] == 1) lb = lbl[d]
printf "%s %s %d %d %d %d %s\n", d, role[d], tun[d], pr, dfl, en, lb
}
}' "$PROC_NET_DEV" 2>/dev/null
} > "$_tmp" 2>/dev/null
# Rename into place so a concurrent poll never reads a half-written map.
[ -s "$_tmp" ] && mv "$_tmp" "$CACHE" 2>/dev/null
rm -f "$_tmp" 2>/dev/null
}
emit() {
awk -v cache="$CACHE" -v sysfs="$SYSFS" '
BEGIN {
while ((getline l < cache) > 0) {
if (substr(l, 1, 1) == "#") continue
if (split(l, f, " ") < 7) continue
# known[] is a separate explicit flag rather than `f[1] in role`,
# because the fields read below auto-vivify their own arrays and a
# membership test on any of them would then be true for every device.
known[f[1]] = 1
role[f[1]] = f[2]; tun[f[1]] = f[3] + 0; prim[f[1]] = f[4] + 0
dfr[f[1]] = f[5] + 0; ens[f[1]] = f[6] + 0; lbl[f[1]] = f[7]
}
close(cache)
printf "["
}
NR > 2 {
# Split on the COLON, not on whitespace. The kernel pads the name field to
# a fixed width, so a long interface name or a counter wide enough to fill
# it leaves no space before rx_bytes ("enp0s31f6:12345678") and every
# positional field after it shifts by one.
if (index($0, ":") == 0) next
dev = substr($0, 1, index($0, ":") - 1)
gsub(/[ \t]/, "", dev)
if (dev == "" || dev == "lo") next
rest = substr($0, index($0, ":") + 1)
sub(/^[ \t]+/, "", rest)
if (split(rest, c, /[ \t]+/) < 16) next
# Written as if/else rather than a ternary on purpose: an awk line starting
# "ident = (" trips the array-assignment bashism scanner in
# tests/test_openwrt_compat.sh, which cannot tell shell from an embedded
# awk program.
r = "other"
if (known[dev] == 1) { r = role[dev] } else { miss = 1 }
# operstate is a sysfs read, not a fork, so it is cheap enough for the
# poll path — and a tunnel that is down while still holding counters is
# exactly what the overview needs to show.
state = ""
if ((getline state < (sysfs "/" dev "/operstate")) <= 0) state = ""
close(sysfs "/" dev "/operstate")
up = "false"
if (state == "up" || state == "unknown") { up = "true" }
name = dev; gsub(/["\\]/, "", name)
label = name
if (lbl[dev] != "" && lbl[dev] != "-") { label = lbl[dev] }
gsub(/["\\]/, "", label)
if (n++ > 0) printf ","
# %.0f, never %d: these are 64-bit kernel counters and busybox awk formats
# %d through a 32-bit int (see tests/test_byte_overflow.sh). /proc/net/dev
# is the worst offender in the tree — an uptime of a few days puts any
# active interface past 2 GiB.
printf "{\"dev\":\"%s\",\"label\":\"%s\",\"role\":\"%s\",\"tunnel\":%s,\"primary\":%s,\"defroute\":%s,\"enslaved\":%s,\"up\":%s,\"rx_bytes\":%.0f,\"tx_bytes\":%.0f}", \
name, label, r, \
(tun[dev] ? "true" : "false"), (prim[dev] ? "true" : "false"), \
(dfr[dev] ? "true" : "false"), (ens[dev] ? "true" : "false"), \
up, c[1] + 0, c[9] + 0
}
END {
printf "]"
if (miss) printf "#MISS"
}' "$PROC_NET_DEV" 2>/dev/null
}
# Guard before anything else. awk still runs its BEGIN block when the input
# file is missing but never reaches END, so emit() would print a bare "[" —
# invalid JSON that the frontend surfaces as a dead panel rather than an empty
# one. Nothing below is worth doing without counters anyway.
[ -r "$PROC_NET_DEV" ] || { echo '[]'; exit 0; }
NOW=$(date +%s)
TS=0
if [ -f "$CACHE" ]; then
# Builtin read, no fork: the header line is "# <epoch>".
read -r _hash TS < "$CACHE" 2>/dev/null
case "$TS" in ''|*[!0-9]*) TS=0 ;; esac
fi
[ $((NOW - TS)) -ge "$TTL" ] && build_cache
OUT=$(emit)
case "$OUT" in
*'#MISS')
# An interface the cached map has never seen showed up — a tunnel came up,
# a USB modem was plugged in, a guest bridge was created. Rebuild once and
# re-emit so it is classified immediately instead of sitting in "other"
# until the TTL expires. build_cache() enumerates /proc/net/dev itself, so
# the retry always resolves and this cannot spin.
build_cache
OUT=$(emit)
;;
esac
[ -z "$OUT" ] && OUT="[]"
printf '%s\n' "${OUT%\#MISS}"
@@ -0,0 +1,86 @@
#!/bin/sh
# shellcheck shell=dash
# Block device WiFi access by adding its MAC to deny maclist on all interfaces.
# Uses hostapd to deauth only the target client — no wifi reload needed.
# Usage: trafficctl-macfilter-add.sh <ip>
. /usr/local/bin/trafficctl-fw.sh
IP="$1"
if [ -z "$IP" ]; then
echo '{"ok":false,"msg":"usage: trafficctl-macfilter-add.sh <ip>"}'
exit 1
fi
if ! tctl_validate_ip "$IP"; then
echo '{"ok":false,"msg":"invalid IP address"}'
exit 1
fi
# Look up MAC from DHCP leases
MAC=""
if [ -f /tmp/dhcp.leases ]; then
MAC=$(awk -v ip="$IP" '$3 == ip {print toupper($2)}' /tmp/dhcp.leases | head -1)
fi
if [ -z "$MAC" ]; then
MAC=$(ip neigh show "$IP" 2>/dev/null | grep -oE '[0-9a-fA-F:]{17}' | head -1 | tr 'a-f' 'A-F')
fi
if [ -z "$MAC" ]; then
echo "{\"ok\":false,\"msg\":\"cannot find MAC for $IP\"}"
exit 1
fi
# Normalize MAC to lowercase (OpenWrt stores lowercase)
MAC=$(echo "$MAC" | tr 'A-F' 'a-f')
# Add MAC to maclist on all wifi interfaces
IFACES=$(tctl_get_wifi_interfaces)
if [ -z "$IFACES" ]; then
echo '{"ok":false,"msg":"no wifi interfaces found"}'
exit 1
fi
CHANGED=0
MODE="deny"
for iface in $IFACES; do
# Respect the ACL policy the administrator configured. Forcing macfilter to
# "deny" here used to invert an existing whitelist: the curated allow-list
# would start being read as a block-list, letting in every device it was
# meant to keep out and banning every device it listed.
iface_mode=$(tctl_get_wifi_filter_mode "$iface")
[ "$iface_mode" = "allow" ] && MODE="allow"
existing=$(uci -q get "wireless.${iface}.maclist")
listed=0
echo "$existing" | grep -qi "$MAC" && listed=1
if [ "$iface_mode" = "allow" ]; then
# Whitelist: blocking means dropping the MAC from the allow-list.
if [ "$listed" = "1" ]; then
uci del_list "wireless.${iface}.maclist=$MAC"
CHANGED=1
fi
else
# Blacklist: blocking means adding the MAC, creating the list if needed.
if [ -z "$(uci -q get "wireless.${iface}.macfilter")" ]; then
uci set "wireless.${iface}.macfilter=deny"
CHANGED=1
fi
if [ "$listed" = "0" ]; then
uci add_list "wireless.${iface}.maclist=$MAC"
CHANGED=1
fi
fi
done
if [ "$CHANGED" = "1" ]; then
uci commit wireless
# Apply at runtime and deauth just this client — no wifi reload.
tctl_hostapd_block_mac "$MAC" "$MODE"
fi
tctl_log "wifi_block" "$IP" "MAC=$MAC" "${TCTL_VIA:-cli}" "${TCTL_SRC:-local}"
echo "{\"ok\":true,\"msg\":\"MAC $MAC blocked on wifi for $IP\"}"
@@ -0,0 +1,78 @@
#!/bin/sh
# shellcheck shell=dash
# Remove device WiFi MAC filter (unblock from wifi deny list).
# Uses hostapd beacon update — no wifi reload, other clients stay connected.
# Usage: trafficctl-macfilter-remove.sh <ip>
. /usr/local/bin/trafficctl-fw.sh
IP="$1"
if [ -z "$IP" ]; then
echo '{"ok":false,"msg":"usage: trafficctl-macfilter-remove.sh <ip>"}'
exit 1
fi
if ! tctl_validate_ip "$IP"; then
echo '{"ok":false,"msg":"invalid IP address"}'
exit 1
fi
# Look up MAC from DHCP leases
MAC=""
if [ -f /tmp/dhcp.leases ]; then
MAC=$(awk -v ip="$IP" '$3 == ip {print toupper($2)}' /tmp/dhcp.leases | head -1)
fi
if [ -z "$MAC" ]; then
MAC=$(ip neigh show "$IP" 2>/dev/null | grep -oE '[0-9a-fA-F:]{17}' | head -1 | tr 'a-f' 'A-F')
fi
if [ -z "$MAC" ]; then
echo "{\"ok\":false,\"msg\":\"cannot find MAC for $IP\"}"
exit 1
fi
# Normalize MAC to lowercase
MAC=$(echo "$MAC" | tr 'A-F' 'a-f')
# Remove MAC from maclist on all wifi interfaces
IFACES=$(tctl_get_wifi_interfaces)
if [ -z "$IFACES" ]; then
echo '{"ok":false,"msg":"no wifi interfaces found"}'
exit 1
fi
CHANGED=0
MODE="deny"
for iface in $IFACES; do
iface_mode=$(tctl_get_wifi_filter_mode "$iface")
[ "$iface_mode" = "allow" ] && MODE="allow"
existing=$(uci -q get "wireless.${iface}.maclist")
listed=0
echo "$existing" | grep -qi "$MAC" && listed=1
if [ "$iface_mode" = "allow" ]; then
# Whitelist: unblocking means putting the MAC back on the allow-list.
if [ "$listed" = "0" ]; then
uci add_list "wireless.${iface}.maclist=$MAC"
CHANGED=1
fi
else
# Blacklist: unblocking means removing the MAC from the block-list.
if [ "$listed" = "1" ]; then
uci del_list "wireless.${iface}.maclist=$MAC"
CHANGED=1
fi
fi
done
if [ "$CHANGED" = "1" ]; then
uci commit wireless
# Update the runtime ACL — the client can reassociate immediately.
tctl_hostapd_unblock_mac "$MAC" "$MODE"
fi
tctl_log "wifi_unblock" "$IP" "MAC=$MAC" "${TCTL_VIA:-cli}" "${TCTL_SRC:-local}"
echo "{\"ok\":true,\"msg\":\"MAC $MAC removed from wifi filter for $IP\"}"
@@ -0,0 +1,233 @@
#!/bin/sh
# shellcheck shell=dash
# Prometheus / OpenMetrics exporter for trafficctl.
#
# Writes to stdout so one implementation serves both delivery paths:
# scrape endpoint : /www/cgi-bin/trafficctl-metrics (uhttpd)
# textfile : cron > /var/lib/node_exporter/textfile_collector/trafficctl.prom
#
# Memory discipline (this runs on 128–512 MB routers):
# * no large shell variables — every source is streamed straight into awk
# * one awk process holds the only in-memory table, bounded by device count
#
# Counter semantics: the raw byte sources are NOT monotonic — a device's
# conntrack total drops when its flows expire. Exporting that directly would
# look like a counter reset on every expiry and produce nonsense rate(). The
# accumulation that fixes it used to live here; it now lives in
# trafficctl-totals.sh, which the LuCI Bytes column reads too, so the column
# and this counter cannot drift apart.
#
# Usage: trafficctl-metrics.sh
. /usr/local/bin/trafficctl-fw.sh
metrics_enabled() {
[ "$(uci -q get trafficctl.metrics.enabled 2>/dev/null)" = "1" ]
}
if ! metrics_enabled; then
echo "# trafficctl metrics are disabled (set trafficctl.metrics.enabled=1)"
exit 0
fi
opt_on() {
# default-on toggles: anything but an explicit 0 counts as enabled
[ "$(uci -q get "trafficctl.metrics.$1" 2>/dev/null)" != "0" ]
}
# ── device byte counters ────────────────────────────────────────────────────
# trafficctl-totals.sh owns both the accumulator and its state file. It also
# resolves the byte source (conntrack, or nft counters under uncountered flow
# offload) via trafficctl-bytes.sh, so none of that is duplicated here.
#
# --all, because a Prometheus series that vanished and came back every time a
# quiet device's last flow expired would be unusable: the exporter wants every
# device that still carries a total, not just the ones currently transmitting.
/usr/local/bin/trafficctl-totals.sh --all 2>/dev/null \
| sed 's/},{/}\n{/g' \
| awk '
function num(line, key, re, seg) {
re = "\"" key "\"[ \t]*:[ \t]*"
if (!match(line, re)) return -1
seg = substr(line, RSTART + RLENGTH)
if (!match(seg, /^[0-9]+/)) return -1
return substr(seg, RSTART, RLENGTH) + 0
}
function str(line, key, re, seg) {
re = "\"" key "\"[ \t]*:[ \t]*\""
if (!match(line, re)) return ""
seg = substr(line, RSTART + RLENGTH)
if (!match(seg, /"/)) return ""
return substr(seg, 1, RSTART - 1)
}
BEGIN {
print "# HELP trafficctl_device_bytes_total Bytes transferred per device since the counter started. The store is tmpfs, so this resets on reboot; it advances whenever anything samples (a scrape, or the LuCI page)."
print "# TYPE trafficctl_device_bytes_total counter"
print "# HELP trafficctl_device_bytes_degraded 1 when the counter above was built from byte counters the forwarding path does not update (uncountered flow offload with no usable nftables fallback). The counter is then a lower bound that stalls while traffic continues; alert on this rather than trusting a flat rate()."
print "# TYPE trafficctl_device_bytes_degraded gauge"
}
{
ip = str($0, "ip")
if (ip == "") next
rx = num($0, "bytes_in_total"); if (rx < 0) rx = 0
tx = num($0, "bytes_out_total"); if (tx < 0) tx = 0
printf "trafficctl_device_bytes_total{ip=\"%s\",direction=\"rx\"} %.0f\n", ip, rx
printf "trafficctl_device_bytes_total{ip=\"%s\",direction=\"tx\"} %.0f\n", ip, tx
# A counter that stops moving is indistinguishable from an idle device on a
# dashboard, which is exactly how this failure hides. Exported alongside so
# the condition is alertable instead of being inferred from a flat line.
printf "trafficctl_device_bytes_degraded{ip=\"%s\"} %d\n", ip, \
(index($0, "\"degraded\":true") ? 1 : 0)
}'
# ── per-device state gauges ─────────────────────────────────────────────────
# Off by default for the leanest possible scrape: this calls the full summary,
# which dumps nft/tc state and is markedly heavier than the byte pass.
if opt_on state; then
/usr/local/bin/trafficctl-summary.sh 2>/dev/null \
| sed 's/},{/}\n{/g' \
| awk -v rdnsfile=/tmp/trafficctl_rdns_cache '
function num(line, key, re, seg) {
re = "\"" key "\"[ \t]*:[ \t]*"
if (!match(line, re)) return 0
seg = substr(line, RSTART + RLENGTH)
if (!match(seg, /^[0-9]+/)) return 0
return substr(seg, RSTART, RLENGTH) + 0
}
function str(line, key, re, seg) {
re = "\"" key "\"[ \t]*:[ \t]*\""
if (!match(line, re)) return ""
seg = substr(line, RSTART + RLENGTH)
if (!match(seg, /"/)) return ""
return substr(seg, 1, RSTART - 1)
}
function esc(v) { gsub(/\\/, "\\\\", v); gsub(/"/, "\\\"", v); return v }
BEGIN {
# Reverse-DNS names resolved in the background; "-" is a cached miss.
while ((getline l < rdnsfile) > 0) {
n = split(l, f, " ")
if (n >= 2 && f[2] != "-") rdns[f[1]] = f[2]
}
close(rdnsfile)
print "# HELP trafficctl_device_connections Active tracked connections per device."
print "# TYPE trafficctl_device_connections gauge"
print "# HELP trafficctl_device_blocked Whether the device is blocked from the internet."
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_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)."
print "# TYPE trafficctl_device_conntrack_bytes gauge"
print "# HELP trafficctl_device_blocked_bytes Bytes dropped by the block rule."
print "# TYPE trafficctl_device_blocked_bytes gauge"
print "# HELP trafficctl_device_info Device metadata; the value is always 1."
print "# TYPE trafficctl_device_info gauge"
}
{
ip = str($0, "ip")
if (ip == "") next
printf "trafficctl_device_connections{ip=\"%s\"} %d\n", ip, num($0, "conns")
printf "trafficctl_device_blocked{ip=\"%s\"} %d\n", ip, (index($0, "\"blocked\":true") ? 1 : 0)
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")
# 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")
printf "trafficctl_device_conntrack_bytes{ip=\"%s\",proto=\"udp\"} %.0f\n", ip, num($0, "udp")
printf "trafficctl_device_blocked_bytes{ip=\"%s\"} %.0f\n", ip, num($0, "block_bytes")
# Names/MACs live on an info metric rather than on the counters: churn in a
# label would otherwise start a brand new time series for the same device.
# Hoisted rather than inlined as esc(ip in rdns ? ...): BusyBox awk parses
# an "in" test inside a call argument differently and silently drops the
# whole statement.
rd = ""
if (ip in rdns) rd = rdns[ip]
printf "trafficctl_device_info{ip=\"%s\",name=\"%s\",mac=\"%s\",link=\"%s\",app=\"%s\",rdns=\"%s\"} 1\n", \
ip, esc(str($0, "name")), esc(str($0, "mac")), esc(str($0, "conn_type")), esc(str($0, "app")), esc(rd)
}'
fi
# ── port forwards ───────────────────────────────────────────────────────────
if opt_on portfw; then
/usr/local/bin/trafficctl-portfw.sh list 2>/dev/null \
| sed 's/},{/}\n{/g' \
| awk '
function num(line, key, re, seg) {
re = "\"" key "\"[ \t]*:[ \t]*"
if (!match(line, re)) return 0
seg = substr(line, RSTART + RLENGTH)
if (!match(seg, /^[0-9]+/)) return 0
return substr(seg, RSTART, RLENGTH) + 0
}
function str(line, key, re, seg) {
re = "\"" key "\"[ \t]*:[ \t]*\""
if (!match(line, re)) return ""
seg = substr(line, RSTART + RLENGTH)
if (!match(seg, /"/)) return ""
return substr(seg, 1, RSTART - 1)
}
function esc(v) { gsub(/\\/, "\\\\", v); gsub(/"/, "\\\"", v); return v }
BEGIN {
print "# HELP trafficctl_portfw_connections Active inbound connections per port forward."
print "# TYPE trafficctl_portfw_connections gauge"
print "# HELP trafficctl_portfw_clients Distinct remote clients per port forward."
print "# TYPE trafficctl_portfw_clients gauge"
print "# HELP trafficctl_portfw_paused Whether inbound traffic is paused."
print "# TYPE trafficctl_portfw_paused gauge"
print "# HELP trafficctl_portfw_enabled Whether the firewall rule itself is enabled."
print "# TYPE trafficctl_portfw_enabled gauge"
print "# HELP trafficctl_portfw_limit_kbit Inbound rate limit in kbit/s (0 = none)."
print "# TYPE trafficctl_portfw_limit_kbit gauge"
print "# HELP trafficctl_portfw_bytes Bytes currently accounted per port forward."
print "# TYPE trafficctl_portfw_bytes gauge"
}
{
id = str($0, "id")
if (id == "") next
lbl = sprintf("name=\"%s\",proto=\"%s\",port=\"%s\"", \
esc(str($0, "name")), esc(str($0, "proto")), esc(str($0, "ext_port")))
printf "trafficctl_portfw_connections{%s} %d\n", lbl, num($0, "conns")
printf "trafficctl_portfw_clients{%s} %d\n", lbl, num($0, "clients")
printf "trafficctl_portfw_paused{%s} %d\n", lbl, (index($0, "\"paused\":true") ? 1 : 0)
printf "trafficctl_portfw_enabled{%s} %d\n", lbl, (index($0, "\"enabled\":true") ? 1 : 0)
printf "trafficctl_portfw_limit_kbit{%s} %d\n", lbl, num($0, "limit_kbit")
printf "trafficctl_portfw_bytes{%s,direction=\"rx\"} %.0f\n", lbl, num($0, "bytes_in")
printf "trafficctl_portfw_bytes{%s,direction=\"tx\"} %.0f\n", lbl, num($0, "bytes_out")
}'
fi
# ── DPI application labels ──────────────────────────────────────────────────
# Off by default: device × application is a cardinality trap on a busy network.
if [ "$(uci -q get trafficctl.metrics.apps 2>/dev/null)" = "1" ]; then
/usr/local/bin/trafficctl-netify.sh list 2>/dev/null \
| sed -e 's/\]},{/]}\n{/g' \
| awk '
BEGIN {
print "# HELP trafficctl_app_bytes Bytes per device per application in the last DPI sample."
print "# TYPE trafficctl_app_bytes gauge"
}
{
if (!match($0, /"ip":"[^"]*"/)) next
ip = substr($0, RSTART + 6, RLENGTH - 7)
rest = $0
# Match the whole app object, not just up to "bytes" — "flows" follows it.
while (match(rest, /\{"name":"[^"]*","bytes":[0-9]+[^}]*\}/)) {
rec = substr(rest, RSTART, RLENGTH)
rest = substr(rest, RSTART + RLENGTH)
if (!match(rec, /"name":"[^"]*"/)) continue
app = substr(rec, RSTART + 8, RLENGTH - 9)
if (!match(rec, /"bytes":[0-9]+/)) continue
b = substr(rec, RSTART + 8, RLENGTH - 8) + 0
printf "trafficctl_app_bytes{ip=\"%s\",app=\"%s\"} %.0f\n", ip, app, b
if (match(rec, /"flows":[0-9]+/))
printf "trafficctl_app_flows{ip=\"%s\",app=\"%s\"} %d\n", ip, app, \
substr(rec, RSTART + 8, RLENGTH - 8) + 0
}
}'
fi
echo "# HELP trafficctl_up Always 1; presence indicates the exporter ran."
echo "# TYPE trafficctl_up gauge"
echo "trafficctl_up 1"
+68
View File
@@ -0,0 +1,68 @@
#!/bin/sh
# shellcheck shell=dash
# Manual device names (aliases), keyed by IP.
#
# Aliases take precedence over DHCP lease names and reverse DNS, which is what
# makes routed devices nameable: they have no lease on this router, so without
# an alias (or a PTR record) they would only ever show as "*".
#
# Usage: trafficctl-names.sh list|set <ip> <name>|remove <ip>
# Storage: /etc/trafficctl/names — one "IP NAME" line per device.
. /usr/local/bin/trafficctl-fw.sh
NAMES_FILE="/etc/trafficctl/names"
# Strip anything that could break the line format or the JSON output, and cap
# the length so a pathological name can't bloat every poll response.
sanitize_name() {
printf '%s' "$1" | tr -d '\n\r\t' | tr -cd 'a-zA-Z0-9 _.()-' | cut -c1-32
}
do_list() {
printf '['
[ -f "$NAMES_FILE" ] || { printf ']\n'; return 0; }
awk 'NF >= 2 {
ip = $1
name = $0
sub(/^[^ ]+ +/, "", name)
if (n++) printf ","
printf "{\"ip\":\"%s\",\"name\":\"%s\"}", ip, name
}' "$NAMES_FILE"
printf ']\n'
}
do_set() {
local ip="$1" name="$2" tmp
tctl_validate_ip "$ip" || { echo '{"ok":false,"msg":"invalid IP address"}'; return 1; }
name=$(sanitize_name "$name")
[ -z "$name" ] && { echo '{"ok":false,"msg":"name is empty after sanitizing"}'; return 1; }
mkdir -p "$(dirname "$NAMES_FILE")"
[ -f "$NAMES_FILE" ] || : > "$NAMES_FILE"
tmp="${NAMES_FILE}.tmp"
awk -v ip="$ip" '$1 != ip' "$NAMES_FILE" > "$tmp"
echo "$ip $name" >> "$tmp"
mv "$tmp" "$NAMES_FILE"
tctl_log "config_change" "$ip" "name=$name" "${TCTL_VIA:-cli}" "${TCTL_SRC:-local}"
printf '{"ok":true,"msg":"named %s as %s"}\n' "$ip" "$name"
}
do_remove() {
local ip="$1" tmp
tctl_validate_ip "$ip" || { echo '{"ok":false,"msg":"invalid IP address"}'; return 1; }
[ -f "$NAMES_FILE" ] || { echo '{"ok":true,"msg":"no aliases stored"}'; return 0; }
tmp="${NAMES_FILE}.tmp"
awk -v ip="$ip" '$1 != ip' "$NAMES_FILE" > "$tmp"
mv "$tmp" "$NAMES_FILE"
tctl_log "config_change" "$ip" "name cleared" "${TCTL_VIA:-cli}" "${TCTL_SRC:-local}"
printf '{"ok":true,"msg":"name cleared for %s"}\n' "$ip"
}
case "$1" in
list) do_list ;;
set) do_set "$2" "$3" ;;
remove) do_remove "$2" ;;
*) echo '{"ok":false,"msg":"usage: trafficctl-names.sh list|set <ip> <name>|remove <ip>"}'; exit 1 ;;
esac
+247
View File
@@ -0,0 +1,247 @@
#!/bin/sh
# shellcheck shell=dash
# Optional Netify Agent (netifyd) integration — DPI application labels per device.
#
# Entirely optional: every entry point degrades to an empty result when netifyd
# is not installed, not running, or no socket reader is available, so the rest
# of trafficctl behaves exactly as before.
#
# Usage:
# trafficctl-netify.sh status — availability probe (JSON object)
# trafficctl-netify.sh collect [secs] — sample the socket, refresh the cache
# trafficctl-netify.sh list — cached per-device app breakdown
# trafficctl-netify.sh raw [secs] — raw socket lines (debugging)
#
# Telemetry comes from the agent's socket SINK, not from netifyd.sock: in
# Agent v5 that unix socket is a request/response API which never pushes flow
# data, so connecting to it returns nothing at all. The sink's bind address is
# read from netify-sink-socket.json (typically tcp://127.0.0.1:1780).
#
# Two payload shapes are handled: the aggregator's {"stats":[...]} records
# (application_id/local_ip/download/upload) and raw per-flow records
# (detected_application_name/local_ip/total_bytes). Records are parsed by field
# extraction rather than strict JSON, which tolerates either framing.
. /usr/local/bin/trafficctl-fw.sh
CACHE="/tmp/trafficctl_netify.json"
LOCK="/tmp/trafficctl_netify.lock"
DEFAULT_SOCKET="/var/run/netifyd/netifyd.sock"
netify_socket() {
local s
s=$(uci -q get trafficctl.main.netify_socket 2>/dev/null)
[ -n "$s" ] && { echo "$s"; return; }
echo "$DEFAULT_SOCKET"
}
# Where flow telemetry is actually published: an explicit override, else the
# first enabled socket-sink channel, else the legacy unix socket (Agent v4).
netify_endpoint() {
local ep
ep=$(uci -q get trafficctl.main.netify_endpoint 2>/dev/null)
[ -n "$ep" ] && { echo "$ep"; return; }
ep=$(sed -n 's/.*"bind_address\"[ \t]*:[ \t]*"\(tcp:\/\/[^"]*\)".*/\1/p' \
/etc/netifyd/netify-sink-socket.json 2>/dev/null | head -1)
[ -n "$ep" ] && { echo "$ep"; return; }
echo "unix://$(netify_socket)"
}
netify_enabled() {
[ "$(uci -q get trafficctl.main.netify_enabled 2>/dev/null)" != "0" ]
}
# Echo the command able to stream the socket, or nothing.
socket_reader() {
if command -v socat >/dev/null 2>&1; then
echo "socat"
elif command -v nc >/dev/null 2>&1; then
echo "nc"
fi
}
read_socket() {
local secs="$1" ep reader hostport path
ep=$(netify_endpoint)
reader=$(socket_reader)
[ -n "$reader" ] || return 1
case "$ep" in
tcp://*)
hostport=${ep#tcp://}
case "$reader" in
socat) timeout "$secs" socat -u "TCP-CONNECT:$hostport" - 2>/dev/null ;;
nc) timeout "$secs" nc "${hostport%%:*}" "${hostport##*:}" 2>/dev/null ;;
esac
;;
unix://*|/*)
path=${ep#unix://}
[ -S "$path" ] || return 1
case "$reader" in
socat) timeout "$secs" socat -u "UNIX-CONNECT:$path" - 2>/dev/null ;;
nc) timeout "$secs" nc -U "$path" 2>/dev/null ;;
esac
;;
*) return 1 ;;
esac
return 0
}
# Tests (and offline debugging) feed recorded agent output instead of a socket.
feed_lines() {
if [ -n "$TCTL_NETIFY_FEED" ] && [ -f "$TCTL_NETIFY_FEED" ]; then
cat "$TCTL_NETIFY_FEED"
else
read_socket "$1"
fi
}
do_status() {
local sock reader installed=false running=false readable=false age=-1 now mtime
sock=$(netify_socket)
reader=$(socket_reader)
command -v netifyd >/dev/null 2>&1 && installed=true
[ -S "$sock" ] && running=true
[ -n "$reader" ] && readable=true
if [ -f "$CACHE" ]; then
now=$(date +%s)
mtime=$(date -r "$CACHE" +%s 2>/dev/null || echo 0)
age=$(( now - mtime ))
fi
printf '{"enabled":%s,"installed":%s,"running":%s,"socket":"%s","endpoint":"%s","reader":"%s","readable":%s,"cache_age":%d}\n' \
"$(netify_enabled && echo true || echo false)" \
"$installed" "$running" "$sock" "$(netify_endpoint)" "${reader:-none}" "$readable" "$age"
}
# Aggregate flow records into "per local IP → per application" totals.
#
# The agent re-emits a flow as its byte counters grow (flow, flow_dpi_update,
# flow_dpi_complete), so totals are tracked per flow digest and only summed at
# the end — adding every sighting would multiply-count the same traffic.
parse_flows() {
# One JSON object per line so the field extractor sees one record at a
# time — the aggregator packs every device into a single "stats" array.
sed 's/},[ ]*{/}\n{/g' | awk '
function field(line, key, re, seg, v) {
re = "\"" key "\"[ \t]*:[ \t]*"
if (!match(line, re)) return ""
seg = substr(line, RSTART + RLENGTH)
if (substr(seg, 1, 1) == "\"") {
seg = substr(seg, 2)
if (!match(seg, /"/)) return ""
return substr(seg, 1, RSTART - 1)
}
if (!match(seg, /^-?[0-9]+/)) return ""
return substr(seg, RSTART, RLENGTH) + 0
}
{
# Only flow-bearing records carry local_ip; skip status/hello lines.
ip = field($0, "local_ip")
if (ip !~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/) next
# Aggregator records name the app as "<id>.netify.<name>".
app = field($0, "application_id")
sub(/^[0-9]+\./, "", app)
if (app == "") app = field($0, "detected_application_name")
# Agent prefixes catalogued apps with "netify."; the bare name reads
# better in a table.
sub(/^netify\./, "", app)
if (app == "" || app == "unknown") app = field($0, "detected_protocol_name")
if (app == "" || app == "Unknown") {
host = field($0, "host_server_name")
if (host != "") app = host; else app = "unclassified"
}
gsub(/[^a-zA-Z0-9._ -]/, "", app)
if (app == "") app = "unclassified"
digest = field($0, "digest")
# Aggregator: per-interval byte deltas, already summed per device+app.
bytes = field($0, "download") + field($0, "upload")
if (bytes == 0) bytes = field($0, "total_bytes")
if (bytes == "" || bytes == 0) bytes = field($0, "local_bytes") + field($0, "other_bytes")
if (digest == "") digest = ip "|" app "|" NR
key = ip SUBSEP app
# Last sighting of a digest carries its cumulative total.
if (!(digest in seen)) { seen[digest] = key; flows[key]++ }
dbytes[digest] = bytes + 0
}
END {
for (d in seen) total[seen[d]] += dbytes[d]
for (k in total) {
split(k, p, SUBSEP)
printf "%s %s %.0f %d\n", p[1], p[2], total[k], flows[k]
}
}'
}
do_collect() {
# The aggregator publishes on an interval (~15s), so a short sample window
# usually catches nothing at all — wait long enough to span one report.
local secs="${1:-18}" raw parsed tmp
case "$secs" in ''|*[!0-9]*) secs=18 ;; esac
[ "$secs" -lt 1 ] && secs=1
[ "$secs" -gt 60 ] && secs=60
netify_enabled || { echo '{"ok":false,"msg":"netify integration disabled"}'; return 0; }
# One collector at a time — overlapping samples would double the socket
# load and race on the cache file.
mkdir "$LOCK" 2>/dev/null || { echo '{"ok":false,"msg":"collection already running"}'; return 0; }
# shellcheck disable=SC2064
trap "rmdir '$LOCK' 2>/dev/null" EXIT INT TERM
raw=$(feed_lines "$secs")
if [ -z "$raw" ]; then
echo '{"ok":false,"msg":"no data from netifyd socket"}'
return 0
fi
parsed=$(printf '%s\n' "$raw" | parse_flows)
tmp="${CACHE}.tmp"
# Group by IP, apps sorted by bytes descending.
printf '%s\n' "$parsed" | sort -k1,1 -k3,3nr | awk '
BEGIN { printf "[" }
NF >= 4 {
if ($1 != cur) {
if (cur != "") printf "]},"
printf "{\"ip\":\"%s\",\"top\":\"%s\",\"apps\":[", $1, $2
cur = $1
first = 1
}
if (!first) printf ","
printf "{\"name\":\"%s\",\"bytes\":%.0f,\"flows\":%d}", $2, $3, $4
first = 0
}
END {
if (cur != "") printf "]}"
printf "]\n"
}' > "$tmp"
mv "$tmp" "$CACHE"
printf '{"ok":true,"msg":"collected %s","devices":%d}\n' \
"${secs}s" "$(printf '%s\n' "$parsed" | awk 'NF{print $1}' | sort -u | wc -l)"
}
do_list() {
if [ -f "$CACHE" ]; then
cat "$CACHE"
else
echo '[]'
fi
}
do_raw() {
local secs="${1:-18}"
case "$secs" in ''|*[!0-9]*) secs=18 ;; esac
[ "$secs" -gt 60 ] && secs=60
feed_lines "$secs"
}
case "$1" in
status) do_status ;;
collect) do_collect "$2" ;;
list) do_list ;;
raw) do_raw "$2" ;;
*) echo '{"ok":false,"msg":"usage: trafficctl-netify.sh status|collect [secs]|list|raw [secs]"}'; exit 1 ;;
esac
+485
View File
@@ -0,0 +1,485 @@
#!/bin/sh
# shellcheck shell=dash
# Inbound traffic control for port forwards and WAN-open ports.
#
# Usage:
# trafficctl-portfw.sh list
# trafficctl-portfw.sh pause <scope> <proto> <ip> <port>
# trafficctl-portfw.sh resume <scope> <proto> <ip> <port>
# trafficctl-portfw.sh limit <scope> <proto> <ip> <port> <rate_kbit> (0 removes)
#
# scope: "forward" for a DNAT port forward (ip = internal destination),
# "input" for a router-local open port (ip is ignored, pass "-").
# proto: "tcp", "udp" or "tcpudp" (applies to both).
#
# Rules live in our own table (inet tctl_pfw) hooked at forward/input
# priority -190 — after DNAT (prerouting) so the internal address is
# matchable, and before the flowtable offload rule so drops/limits keep
# working with software offload enabled.
. /usr/local/bin/trafficctl-fw.sh
PFW_PRIO="-190"
# ── validation helpers ──────────────────────────────────────────────────────
# iptables spells a port range lo:hi. Truncating to the low port (as this did)
# left every other port in the range open while reporting the whole range paused.
iptables_dport() {
case "$1" in
*-*) echo "${1%%-*}:${1##*-}" ;;
*) echo "$1" ;;
esac
}
# hashlimit names are capped at 15 characters, so a range cannot be spelled out
# in full: 8000-8100 as "pfw_8000_8100_tcp" would be silently truncated into a
# collision with a neighbouring range.
hashlimit_name() {
local proto="$1" port="$2" lo
lo="${port%%-*}"
case "$port" in
*-*) echo "pfwr${lo}_${proto}" ;;
*) echo "pfw${lo}_${proto}" ;;
esac
}
valid_port() {
case "$1" in
[0-9]*-[0-9]*)
local lo="${1%%-*}" hi="${1##*-}"
case "$lo$hi" in *[!0-9]*) return 1 ;; esac
[ "$lo" -ge 1 ] && [ "$lo" -le 65535 ] && \
[ "$hi" -ge 1 ] && [ "$hi" -le 65535 ] && [ "$lo" -le "$hi" ]
;;
*)
case "$1" in ''|*[!0-9]*) return 1 ;; esac
[ "$1" -ge 1 ] && [ "$1" -le 65535 ]
;;
esac
}
# Expand "tcpudp" / "tcp udp" to individual proto tokens
proto_tokens() {
case "$1" in
tcpudp|"tcp udp"|"udp tcp"|all|"") echo "tcp udp" ;;
tcp) echo "tcp" ;;
udp) echo "udp" ;;
*) return 1 ;;
esac
}
pfw_comment() {
# kind proto ip port -> stable rule comment
printf 'tctl_pfw_%s_%s_%s_%s' "$1" "$2" "$3" "$4"
}
# ── nft/iptables plumbing ───────────────────────────────────────────────────
ensure_pfw_table() {
[ "$TCTL_FW" = "nft" ] || return 0
nft add table inet tctl_pfw 2>/dev/null
nft add chain inet tctl_pfw pfw_forward \
"{ type filter hook forward priority $PFW_PRIO; policy accept; }" 2>/dev/null
nft add chain inet tctl_pfw pfw_input \
"{ type filter hook input priority $PFW_PRIO; policy accept; }" 2>/dev/null
}
pfw_chain_for_scope() {
[ "$1" = "input" ] && echo "pfw_input" || echo "pfw_forward"
}
# Delete every rule in our table/chains carrying the given comment
pfw_delete_by_comment() {
local cmt="$1" chain h
if [ "$TCTL_FW" = "nft" ]; then
for chain in pfw_forward pfw_input; do
for h in $(nft -a list chain inet tctl_pfw "$chain" 2>/dev/null | \
grep "\"$cmt\"" | grep -o 'handle [0-9]*' | awk '{print $2}'); do
nft delete rule inet tctl_pfw "$chain" handle "$h" 2>/dev/null
done
done
else
for chain in FORWARD INPUT; do
# Re-evaluate the first matching rule spec each pass until none
# with the comment is left (empty spec makes iptables -D fail).
# shellcheck disable=SC2046 # rule spec is intentionally re-split
while iptables -D "$chain" $(iptables -S "$chain" 2>/dev/null | \
grep -m1 -- "--comment $cmt " | sed 's/^-A [A-Z]* //') 2>/dev/null; do :; done
done
fi
}
pfw_add_pause() {
local scope="$1" proto="$2" ip="$3" port="$4"
local cmt chain
cmt=$(pfw_comment "pause" "$proto" "${ip:-local}" "$port")
if [ "$TCTL_FW" = "nft" ]; then
ensure_pfw_table
chain=$(pfw_chain_for_scope "$scope")
if [ "$scope" = "input" ]; then
nft insert rule inet tctl_pfw "$chain" \
"$proto" dport "$port" counter drop comment "\"$cmt\""
else
nft insert rule inet tctl_pfw "$chain" \
ip daddr "$ip" "$proto" dport "$port" counter drop comment "\"$cmt\""
fi
else
if [ "$scope" = "input" ]; then
iptables -I INPUT -p "$proto" --dport "$(iptables_dport "$port")" -j DROP \
-m comment --comment "$cmt" 2>/dev/null
else
iptables -I FORWARD -d "$ip" -p "$proto" --dport "$(iptables_dport "$port")" -j DROP \
-m comment --comment "$cmt" 2>/dev/null
fi
fi
}
pfw_add_limit() {
local scope="$1" proto="$2" ip="$3" port="$4" kbit="$5"
local cmt chain kbyte
kbyte=$((kbit / 8))
[ "$kbyte" -lt 1 ] && kbyte=1
cmt=$(pfw_comment "limit" "$proto" "${ip:-local}" "$port")
if [ "$TCTL_FW" = "nft" ]; then
ensure_pfw_table
chain=$(pfw_chain_for_scope "$scope")
if [ "$scope" = "input" ]; then
nft add rule inet tctl_pfw "$chain" \
"$proto" dport "$port" limit rate over "$kbyte" kbytes/second \
counter drop comment "\"$cmt\""
else
nft add rule inet tctl_pfw "$chain" \
ip daddr "$ip" "$proto" dport "$port" limit rate over "$kbyte" kbytes/second \
counter drop comment "\"$cmt\""
fi
else
if [ "$scope" = "input" ]; then
iptables -I INPUT -p "$proto" --dport "$(iptables_dport "$port")" -m hashlimit \
--hashlimit-above "${kbit}kbit/sec" --hashlimit-burst "$kbit" \
--hashlimit-mode dstport --hashlimit-name "$(hashlimit_name "$proto" "$port")" \
-j DROP -m comment --comment "$cmt" 2>/dev/null
else
iptables -I FORWARD -d "$ip" -p "$proto" --dport "$(iptables_dport "$port")" -m hashlimit \
--hashlimit-above "${kbit}kbit/sec" --hashlimit-burst "$kbit" \
--hashlimit-mode dstip --hashlimit-name "$(hashlimit_name "$proto" "$port")" \
-j DROP -m comment --comment "$cmt" 2>/dev/null
fi
fi
}
# Flush established conntrack entries so a pause takes effect immediately,
# even for flows already offloaded to the flowtable. Best effort.
pfw_flush_conntrack() {
local proto="$1" ip="$2" port="$3" lo hi p
command -v conntrack >/dev/null 2>&1 || return 0
if [ -n "$ip" ] && [ "$ip" != "-" ]; then
conntrack -D -p "$proto" --reply-src "$ip" 2>/dev/null
return 0
fi
# conntrack takes one port at a time, so a range is walked. Wide ranges are
# left to expire on their own rather than spending thousands of calls here.
lo="${port%%-*}"
hi="${port##*-}"
if [ "$lo" = "$hi" ] || [ "$((hi - lo))" -gt 64 ] 2>/dev/null; then
conntrack -D -p "$proto" --dport "$lo" 2>/dev/null
return 0
fi
p="$lo"
while [ "$p" -le "$hi" ]; do
conntrack -D -p "$proto" --dport "$p" 2>/dev/null
p=$((p + 1))
done
return 0
}
# ── list ────────────────────────────────────────────────────────────────────
do_list() {
# Current control rules (one dump, grepped below):
# comments look like tctl_pfw_<kind>_<proto>_<ip|local>_<port>
local CTL_DUMP=""
if [ "$TCTL_FW" = "nft" ]; then
CTL_DUMP=$(nft list table inet tctl_pfw 2>/dev/null)
else
CTL_DUMP=$(printf '%s\n%s' "$(iptables -S FORWARD 2>/dev/null)" "$(iptables -S INPUT 2>/dev/null)")
fi
# Monitored subnets — used to keep router-local (input) stats to
# external clients only (LAN hits on router ports are not "inbound").
local MATCH_SPEC
MATCH_SPEC=$(tctl_monitored_subnets | awk '{printf "%s%s:%s",(NR>1?" ":""),$2,$3}')
# Enumerate rules first: "id|kind|protos|ip|port|extport|name|enabled|zone"
local entries=""
local i=0 name src dport dest_ip dest_port proto enabled target
while uci -q get "firewall.@redirect[$i]" >/dev/null 2>&1; do
name=$(uci -q get "firewall.@redirect[$i].name")
src=$(uci -q get "firewall.@redirect[$i].src")
dport=$(uci -q get "firewall.@redirect[$i].src_dport")
dest_ip=$(uci -q get "firewall.@redirect[$i].dest_ip")
dest_port=$(uci -q get "firewall.@redirect[$i].dest_port")
proto=$(uci -q get "firewall.@redirect[$i].proto")
enabled=$(uci -q get "firewall.@redirect[$i].enabled")
target=$(uci -q get "firewall.@redirect[$i].target")
i=$((i + 1))
[ -n "$target" ] && [ "$target" != "DNAT" ] && continue
[ -n "$dest_ip" ] && [ -n "$dport" ] || continue
[ -z "$dest_port" ] && dest_port="$dport"
proto=$(proto_tokens "$proto") || proto="tcp udp"
name=$(printf '%s' "$name" | tr -cd 'a-zA-Z0-9 _.()-')
entries="${entries}r$((i-1))|forward|${proto}|${dest_ip}|${dest_port}|${dport}|${name:-forward $dport}|${enabled:-1}|${src:-wan}
"
done
i=0
local rsrc rdest
while uci -q get "firewall.@rule[$i]" >/dev/null 2>&1; do
name=$(uci -q get "firewall.@rule[$i].name")
rsrc=$(uci -q get "firewall.@rule[$i].src")
rdest=$(uci -q get "firewall.@rule[$i].dest")
dport=$(uci -q get "firewall.@rule[$i].dest_port")
proto=$(uci -q get "firewall.@rule[$i].proto")
enabled=$(uci -q get "firewall.@rule[$i].enabled")
target=$(uci -q get "firewall.@rule[$i].target")
i=$((i + 1))
# Router-local open ports: accept rules from a wan zone with no dest zone
case "$rsrc" in *wan*) ;; *) continue ;; esac
[ -n "$rdest" ] && continue
[ "$target" = "ACCEPT" ] || continue
[ -n "$dport" ] || continue
proto=$(proto_tokens "$proto") || continue
name=$(printf '%s' "$name" | tr -cd 'a-zA-Z0-9 _.()-')
entries="${entries}a$((i-1))|open|${proto}|-|${dport}|${dport}|${name:-open $dport}|${enabled:-1}|${rsrc}
"
done
if [ -z "$entries" ]; then
echo '[]'
return 0
fi
# Single conntrack pass: per-entry conns / distinct clients / bytes.
# forward: reply src == dest_ip && reply sport in dest_port && DNAT
# happened (orig dst != reply src)
# open: orig dport in port && no NAT (reply src == orig dst) && the
# client is not on a monitored subnet
local STATS
local ENTRY_FILE="/tmp/.trafficctl_pfw_entries.$$"
printf '%s' "$entries" > "$ENTRY_FILE"
STATS=$(awk -v ef="$ENTRY_FILE" -v spec="$MATCH_SPEC" '
function ip2int(ip, a) {
split(ip, a, ".")
return a[1]*16777216 + a[2]*65536 + a[3]*256 + a[4]
}
function is_internal(ip, si, k) {
if (ip !~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/) return 0
si = ip2int(ip)
for (k = 1; k <= ns; k++)
if (si - (si % blk[k]) == base[k]) return 1
return 0
}
function port_in(p, range, lo, hi, sep) {
sep = index(range, "-")
if (sep == 0) return (p + 0) == (range + 0)
lo = substr(range, 1, sep - 1) + 0
hi = substr(range, sep + 1) + 0
return (p + 0) >= lo && (p + 0) <= hi
}
BEGIN {
ns = split(spec, sp, " ")
for (k = 1; k <= ns; k++) {
split(sp[k], kv, ":")
base[k] = kv[1] + 0; blk[k] = kv[2] + 0
}
n = 0
while ((getline line < ef) > 0) {
if (line == "") continue
n++
split(line, f, "|")
eid[n] = f[1]; ekind[n] = f[2]; eproto[n] = f[3]
eip[n] = f[4]; eport[n] = f[5]
}
close(ef)
}
{
proto = ""
for (i = 1; i <= NF; i++) {
if ($i == "tcp") proto = "tcp"
else if ($i == "udp") proto = "udp"
}
if (proto == "") next
t = 0
osrc = ""; odst = ""; odport = ""; ob = 0
rsrc = ""; rsport = ""; rb = 0
for (i = 1; i <= NF; i++) {
if (index($i, "src=") == 1) {
t++
if (t == 1) osrc = substr($i, 5)
else if (t == 2) rsrc = substr($i, 5)
} else if (index($i, "dst=") == 1) {
if (t == 1 && odst == "") odst = substr($i, 5)
} else if (index($i, "dport=") == 1) {
if (t == 1 && odport == "") odport = substr($i, 7)
} else if (index($i, "sport=") == 1) {
if (t == 2 && rsport == "") rsport = substr($i, 7)
} else if (index($i, "bytes=") == 1) {
if (t == 1) ob = substr($i, 7) + 0
else if (t == 2) rb = substr($i, 7) + 0
}
}
if (osrc == "" || rsrc == "") next
for (k = 1; k <= n; k++) {
if (index(eproto[k], proto) == 0) continue
if (ekind[k] == "forward") {
if (rsrc != eip[k]) continue
if (!port_in(rsport, eport[k])) continue
if (odst == rsrc) continue # LAN-direct, not DNATed
} else {
if (rsrc != odst) continue # NATed => not router-local
if (!port_in(odport, eport[k])) continue
if (is_internal(osrc)) continue # LAN client, not inbound
}
conns[k]++
bin[k] += ob
bout[k] += rb
if (!((k, osrc) in seen)) { seen[k, osrc] = 1; clients[k]++ }
break
}
}
END {
for (k = 1; k <= n; k++)
printf "%s %d %d %d %d\n", eid[k], conns[k]+0, clients[k]+0, bin[k]+0, bout[k]+0
}' "${TCTL_CT_FILE:-/proc/net/nf_conntrack}" 2>/dev/null)
rm -f "$ENTRY_FILE"
# Emit JSON
printf '['
local first=1 id kind protos ip port extport ename en zone
local st conns clients b_in b_out paused limit_kbit cmt_ip p
printf '%s' "$entries" | while IFS='|' read -r id kind protos ip port extport ename en zone; do
[ -z "$id" ] && continue
# shellcheck disable=SC2046 # deliberate split of stats fields
set -- $(printf '%s\n' "$STATS" | awk -v id="$id" '$1==id{print $2,$3,$4,$5; exit}')
conns="${1:-0}"; clients="${2:-0}"; b_in="${3:-0}"; b_out="${4:-0}"
cmt_ip="$ip"
[ "$kind" = "open" ] && cmt_ip="local"
paused=false
limit_kbit=0
for p in $protos; do
printf '%s' "$CTL_DUMP" | grep -q "$(pfw_comment pause "$p" "$cmt_ip" "$port")" && paused=true
if [ "$limit_kbit" = "0" ]; then
st=$(printf '%s' "$CTL_DUMP" | grep "$(pfw_comment limit "$p" "$cmt_ip" "$port")" | \
grep -oE '[0-9]+ kbytes' | awk '{print $1 * 8; 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}')
[ -n "$st" ] && limit_kbit="$st"
fi
done
if [ "$first" = "1" ]; then first=0; else printf ','; fi
printf '{"id":"%s","kind":"%s","name":"%s","zone":"%s","proto":"%s","ext_port":"%s","ip":"%s","port":"%s","enabled":%s,"paused":%s,"limit_kbit":%d,"conns":%d,"clients":%d,"bytes_in":%.0f,"bytes_out":%.0f}' \
"$id" "$kind" "$ename" "$zone" "$protos" "$extport" "$ip" "$port" \
"$([ "$en" != "0" ] && echo true || echo false)" \
"$paused" "$limit_kbit" "$conns" "$clients" "$b_in" "$b_out"
done
printf ']\n'
}
# ── control actions ─────────────────────────────────────────────────────────
check_args() {
# scope proto ip port
case "$1" in forward|input) ;; *) return 1 ;; esac
proto_tokens "$2" >/dev/null || return 1
if [ "$1" = "forward" ]; then
tctl_validate_ip "$3" || return 1
fi
valid_port "$4" || return 1
return 0
}
do_pause() {
local scope="$1" proto="$2" ip="$3" port="$4" p cmt_ip
cmt_ip="$ip"
[ "$scope" = "input" ] && cmt_ip="local"
for p in $(proto_tokens "$proto"); do
pfw_delete_by_comment "$(pfw_comment pause "$p" "$cmt_ip" "$port")"
pfw_add_pause "$scope" "$p" "$ip" "$port" || {
echo "{\"ok\":false,\"msg\":\"failed to pause $p port $port\"}"
return 1
}
pfw_flush_conntrack "$p" "$([ "$scope" = "input" ] && echo "-" || echo "$ip")" "$port"
done
tctl_persist_enabled && tctl_persist_save "pfw_pause" "${scope}/${proto}/${cmt_ip}/${port}" "1"
tctl_log "block" "pfw:${cmt_ip}:${port}" "pause ${proto}" "${TCTL_VIA:-cli}" "${TCTL_SRC:-local}"
echo "{\"ok\":true,\"msg\":\"paused ${proto} port ${port}\"}"
}
do_resume() {
local scope="$1" proto="$2" ip="$3" port="$4" p cmt_ip
cmt_ip="$ip"
[ "$scope" = "input" ] && cmt_ip="local"
for p in $(proto_tokens "$proto"); do
pfw_delete_by_comment "$(pfw_comment pause "$p" "$cmt_ip" "$port")"
done
tctl_persist_enabled && tctl_persist_remove "pfw_pause" "${scope}/${proto}/${cmt_ip}/${port}"
tctl_log "unblock" "pfw:${cmt_ip}:${port}" "resume ${proto}" "${TCTL_VIA:-cli}" "${TCTL_SRC:-local}"
echo "{\"ok\":true,\"msg\":\"resumed ${proto} port ${port}\"}"
}
do_limit() {
local scope="$1" proto="$2" ip="$3" port="$4" kbit="$5" p cmt_ip
case "$kbit" in ''|*[!0-9]*)
echo '{"ok":false,"msg":"invalid rate"}'
return 1
esac
cmt_ip="$ip"
[ "$scope" = "input" ] && cmt_ip="local"
for p in $(proto_tokens "$proto"); do
pfw_delete_by_comment "$(pfw_comment limit "$p" "$cmt_ip" "$port")"
if [ "$kbit" != "0" ]; then
pfw_add_limit "$scope" "$p" "$ip" "$port" "$kbit" || {
echo "{\"ok\":false,\"msg\":\"failed to limit $p port $port\"}"
return 1
}
fi
done
if [ "$kbit" = "0" ]; then
tctl_persist_enabled && tctl_persist_remove "pfw_limit" "${scope}/${proto}/${cmt_ip}/${port}"
tctl_log "ratelimit_remove" "pfw:${cmt_ip}:${port}" "" "${TCTL_VIA:-cli}" "${TCTL_SRC:-local}"
echo "{\"ok\":true,\"msg\":\"limit removed for port ${port}\"}"
else
tctl_persist_enabled && tctl_persist_save "pfw_limit" "${scope}/${proto}/${cmt_ip}/${port}" "$kbit"
tctl_log "ratelimit_set" "pfw:${cmt_ip}:${port}" "${kbit}kbit" "${TCTL_VIA:-cli}" "${TCTL_SRC:-local}"
echo "{\"ok\":true,\"msg\":\"limit ${kbit} kbit/s set for port ${port}\"}"
fi
}
# ── main ────────────────────────────────────────────────────────────────────
ACTION="$1"
case "$ACTION" in
list)
do_list
;;
pause|resume)
if ! check_args "$2" "$3" "$4" "$5"; then
echo '{"ok":false,"msg":"usage: trafficctl-portfw.sh pause|resume <forward|input> <tcp|udp|tcpudp> <ip|-> <port>"}'
exit 1
fi
"do_$ACTION" "$2" "$3" "$4" "$5"
;;
limit)
if ! check_args "$2" "$3" "$4" "$5"; then
echo '{"ok":false,"msg":"usage: trafficctl-portfw.sh limit <forward|input> <tcp|udp|tcpudp> <ip|-> <port> <rate_kbit>"}'
exit 1
fi
do_limit "$2" "$3" "$4" "$5" "$6"
;;
*)
echo '{"ok":false,"msg":"usage: trafficctl-portfw.sh <list|pause|resume|limit> ..."}'
exit 1
;;
esac
@@ -0,0 +1,68 @@
#!/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}]
. /usr/local/bin/trafficctl-fw.sh
if [ "$TCTL_FW" = "nft" ]; then
nft list table netdev tm_ratelimit 2>/dev/null | awk '
/ip daddr/ && /limit rate/ && /counter/ {
ip = ""
rate = 0
packets = 0
bytes = 0
for (i = 1; i <= NF; i++) {
if ($i == "daddr" && i < NF) ip = $(i+1)
if ($i == "rate" && $(i+1) == "over") {
val = $(i+2)
gsub(/[^0-9]/, "", val)
# stored as kbytes/second, convert back to kbit
rate = val * 8
}
if ($i == "counter") {
# format: counter packets N bytes N
if ($(i+1) == "packets") packets = $(i+2)
if ($(i+3) == "bytes") bytes = $(i+4)
}
}
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
}
}
BEGIN { printf "["; first = 0 }
END { printf "]\n" }
'
else
iptables -t mangle -L FORWARD -nvx 2>/dev/null | grep "rl_ratelimit" | awk '
{
packets = $1
bytes = $2
ip = ""
rate = 0
for (i = 1; i <= NF; i++) {
if ($i ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ && ip == "") {
# skip source 0.0.0.0/0, take destination
}
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
}
}
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
}
}
BEGIN { printf "["; first = 0 }
END { printf "]\n" }
'
fi
@@ -0,0 +1,79 @@
#!/bin/sh
# shellcheck shell=dash
# Rate-limit bandwidth in both directions (policer).
#
# Usage: trafficctl-ratelimit.sh <target> <rate_kbit> [label] [mode]
# 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_kbit=0 removes the limit.
. /usr/local/bin/trafficctl-fw.sh
IP="$1"
RATE="$2"
MODE="$4"
if [ -z "$IP" ] || [ -z "$RATE" ]; then
echo '{"ok":false,"msg":"usage: trafficctl-ratelimit.sh <target> <rate_kbit> [label] [each|shared]"}'
exit 1
fi
TARGET=$(tctl_validate_target "$IP") || {
echo '{"ok":false,"msg":"invalid target — expected an IP, a CIDR, or \"all\""}'
exit 1
}
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
fi
case "$MODE" in
each|shared) ;;
*) echo '{"ok":false,"msg":"mode must be each or shared"}'; exit 1 ;;
esac
LABEL="${3:-rl_$(tctl_target_slug "$IP")}"
COMMENT=$(tctl_ratelimit_comment "$IP")
if [ "$RATE" = "0" ]; then
# Limits written before comments were derived from the target carry the
# caller's label, so one set from LuCI could not be removed from Telegram.
tctl_ratelimit_remove "$IP" "rl_ratelimit_${LABEL}" 2>/dev/null
if tctl_ratelimit_remove "$IP" "$COMMENT"; then
tctl_persist_enabled && tctl_persist_remove "ratelimit" "$IP"
tctl_log "ratelimit_remove" "$IP" "" "${TCTL_VIA:-cli}" "${TCTL_SRC:-local}"
echo "{\"ok\":true,\"msg\":\"rate limit removed for $IP\"}"
else
echo "{\"ok\":false,\"msg\":\"failed to remove rate limit for $IP\"}"
exit 1
fi
else
tctl_ratelimit_remove "$IP" "$COMMENT" 2>/dev/null
TCTL_RL_DOWNLOAD_FAILED=0
TCTL_RL_UPLOAD_FAILED=0
tctl_ratelimit_add "$IP" "$RATE" "$COMMENT" "$MODE"
# A half-applied limit is a silent trap: report exactly which direction
# is live rather than claiming success for both.
if [ "$TCTL_RL_DOWNLOAD_FAILED" = "1" ] && [ "$TCTL_RL_UPLOAD_FAILED" = "1" ]; then
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"
tctl_log "ratelimit_set" "$IP" "${RATE}kbit" "${TCTL_VIA:-cli}" "${TCTL_SRC:-local}"
if [ "$TCTL_RL_DOWNLOAD_FAILED" = "1" ]; then
echo "{\"ok\":true,\"msg\":\"rate limit ${RATE} kbit/s applied to $IP UPLOAD ONLY — WAN device not resolvable\"}"
elif [ "$TCTL_RL_UPLOAD_FAILED" = "1" ]; then
echo "{\"ok\":true,\"msg\":\"rate limit ${RATE} kbit/s applied to $IP DOWNLOAD ONLY — no LAN ingress device\"}"
else
echo "{\"ok\":true,\"msg\":\"rate limit ${RATE} kbit/s for $IP (both directions, $MODE)\"}"
fi
fi
@@ -0,0 +1,72 @@
#!/bin/sh
# shellcheck shell=dash
# Populate the reverse-DNS name cache for the given IPs.
#
# Runs detached from the summary poll: resolving is slow and must never block
# the device table. The summary reads whatever the cache already holds and
# spawns this to fill in the gaps for the next poll.
#
# Names for devices behind a downstream router (MikroTik etc.) are not in this
# router's DHCP leases, so PTR is the only automatic source. trafficctl.main.
# rdns_server takes a space-separated list of resolvers tried in order — point
# it at the downstream router so its lease names get used.
#
# Usage: trafficctl-rdns-refresh.sh <ip> [ip ...]
# Cache: /tmp/trafficctl_rdns_cache — "IP HOST TIMESTAMP", HOST "-" = no record.
. /usr/local/bin/trafficctl-fw.sh
CACHE="/tmp/trafficctl_rdns_cache"
LOCK="/tmp/trafficctl_rdns.lock"
# Only one refresher at a time: concurrent runs would fight over the cache file
# and hammer the resolvers with duplicate queries.
mkdir "$LOCK" 2>/dev/null || exit 0
# shellcheck disable=SC2064
trap "rmdir '$LOCK' 2>/dev/null" EXIT INT TERM
SERVERS=$(uci -q get trafficctl.main.rdns_server 2>/dev/null)
resolve_one() {
local ip="$1" host="" srv
for srv in $SERVERS; do
host=$(nslookup "$ip" "$srv" 2>/dev/null | \
sed -n -e 's/.*name = \([^ ]*\)\.$/\1/p' -e 's/^Name:[ ]*\(.*\)$/\1/p' | head -1)
[ -n "$host" ] && break
done
# System resolver (dnsmasq knows this router's own leases and any
# PTR forwarding configured for downstream subnets).
if [ -z "$host" ] && command -v ubus >/dev/null 2>&1; then
host=$(ubus call network.rrdns lookup \
"{\"addrs\":[\"$ip\"],\"timeout\":2000,\"limit\":1}" 2>/dev/null \
| jsonfilter -e "@[\"$ip\"]" 2>/dev/null)
fi
if [ -z "$host" ] && command -v nslookup >/dev/null 2>&1; then
host=$(nslookup "$ip" 2>/dev/null | \
sed -n -e 's/.*name = \([^ ]*\)\.$/\1/p' -e 's/^Name:[ ]*\(.*\)$/\1/p' | head -1)
fi
# Reject anything that isn't a plain hostname, and drop the reverse-zone
# echo some resolvers return instead of NXDOMAIN.
case "$host" in
*in-addr.arpa*|*[!a-zA-Z0-9._-]*|"") host="" ;;
esac
# Bare hostname only — "phone.lan" is more useful than the full FQDN in a table.
printf '%s' "${host%%.*}"
}
NOW=$(date +%s)
TMP="${CACHE}.tmp"
for ip in "$@"; do
tctl_validate_ip "$ip" || continue
HOST=$(resolve_one "$ip")
[ -z "$HOST" ] && HOST="-"
# Rewrite this IP's entry, keeping every other line.
[ -f "$CACHE" ] || : > "$CACHE"
awk -v ip="$ip" '$1 != ip' "$CACHE" > "$TMP"
echo "$ip $HOST $NOW" >> "$TMP"
mv "$TMP" "$CACHE"
done
+42
View File
@@ -0,0 +1,42 @@
#!/bin/sh
# shellcheck shell=dash
# Reverse DNS lookup for an IP address.
# Usage: trafficctl-rdns.sh <ip>
# Output: {"ip":"...","host":"..."}
. /usr/local/bin/trafficctl-fw.sh
IP="$1"
if [ -z "$IP" ]; then
echo '{"ip":"","host":""}'
exit 1
fi
if ! tctl_validate_ip "$IP"; then
echo "{\"ip\":\"$IP\",\"host\":\"\"}"
exit 1
fi
HOST=""
# Try ubus network.rrdns (same resolver the LuCI frontend uses; always available via rpcd)
if command -v ubus >/dev/null 2>&1; then
HOST=$(ubus call network.rrdns lookup \
"{\"addrs\":[\"$IP\"],\"timeout\":2000,\"limit\":1}" 2>/dev/null \
| jsonfilter -e "@[\"$IP\"]" 2>/dev/null)
fi
# Fallback: BusyBox nslookup (always available on OpenWrt)
if [ -z "$HOST" ] && command -v nslookup >/dev/null 2>&1; then
HOST=$(nslookup "$IP" 2>/dev/null | sed -n 's/.*name = \(.*\)\.$/\1/p' | head -1)
fi
# Validate hostname (only allow safe chars)
case "$HOST" in
*[!a-zA-Z0-9._-]*|"") HOST="" ;;
esac
if [ -z "$HOST" ]; then
echo "{\"ip\":\"$IP\",\"host\":\"\"}"
else
echo "{\"ip\":\"$IP\",\"host\":\"$HOST\"}"
fi
@@ -0,0 +1,219 @@
#!/bin/sh
# shellcheck shell=dash
# Show traffic shaping statistics for all shaped devices.
# Output: JSON array with extended stats per IP.
# Fields: ip, rate_kbit, bytes, packets, backlog, drops, overlimits,
# requeues, lended, borrowed, ecn_mark, new_flows, old_flows,
# target_us, memory_used
. /usr/local/bin/trafficctl-fw.sh
LAN_DEV=$(tctl_get_lan_device)
if ! command -v tc >/dev/null 2>&1; then
echo '[]'; exit 0
fi
if ! tc qdisc show dev "$LAN_DEV" 2>/dev/null | grep -q "htb 1:"; then
echo '[]'; exit 0
fi
# Map classid → real IP from persisted shapes. Minors are allocated, not derived
# from the address, so this map is the only way back to an IP; classes missing
# from it belong to another tc user and are skipped rather than mislabelled.
CLASSMAP=$(grep -oE '\{"ip":"[^"]+","rate_kbit":[0-9]+,"classid":"1:[0-9a-f]+"\}' \
/etc/trafficctl/shapes.json 2>/dev/null | \
sed -n 's/{"ip":"\([^"]*\)","rate_kbit":[0-9]*,"classid":"\(1:[0-9a-f]*\)"}/\2 \1/p')
# Collect class stats into a temp file so we can merge with qdisc stats
CLASS_DATA=$(tc -s class show dev "$LAN_DEV" 2>/dev/null)
QDISC_DATA=$(tc -s qdisc show dev "$LAN_DEV" 2>/dev/null)
# Parse class stats: emit lines "classid ip rate bytes pkts backlog drops overlimits requeues lended borrowed"
CLASS_PARSED=$(echo "$CLASS_DATA" | awk -v classmap="$CLASSMAP" '
BEGIN {
n = split(classmap, cml, "\n")
for (ci = 1; ci <= n; ci++) {
split(cml[ci], cmf, " ")
if (cmf[1] != "" && cmf[2] != "") ipmap[cmf[1]] = cmf[2]
}
}
/class fq_codel/ { skip = 1; next }
/^class htb 1:/ {
# emit previous record if valid
if (have_record && current_rate > 0 && current_rate < 1000000 && (classid in ipmap)) {
printf "%s %s %d %d %d %d %d %d %d %d %d\n", \
classid, ipmap[classid], current_rate, \
bytes, pkts, backlog, drops, overlimits, requeues, lended, borrowed
}
minor = $3
sub(/^1:/, "", minor)
if (minor == "1" || minor == "fffe") { skip = 1; next }
skip = 0
classid = "1:" minor
current_rate = 0
for (i = 1; i <= NF; i++) {
if ($i == "rate") {
v = $(i+1)
if (v ~ /Gbit/) { sub(/Gbit/, "", v); current_rate = (v+0) * 1000000 }
else if (v ~ /Mbit/) { sub(/Mbit/, "", v); current_rate = (v+0) * 1000 }
else if (v ~ /[Kk]bit/) { sub(/[Kk]bit/, "", v); current_rate = v+0 }
break
}
}
bytes = 0; pkts = 0; backlog = 0; drops = 0; overlimits = 0; requeues = 0
lended = 0; borrowed = 0
have_record = 1
}
/Sent [0-9]+ bytes/ && !skip {
for (i = 1; i <= NF; i++) {
if ($i == "Sent") bytes = $(i+1)
if ($i ~ /^[0-9]+$/ && $(i+1) == "pkt") pkts = $i
}
# parse "(dropped N, overlimits N requeues N)" from same line
for (i = 1; i <= NF; i++) {
if ($i == "(dropped") { v = $(i+1); sub(/,/, "", v); drops = v + 0 }
if ($i == "overlimits") { v = $(i+1); sub(/[^0-9]/, "", v); overlimits = v + 0 }
}
# requeues at end of Sent line
for (i = 1; i <= NF; i++) {
if ($i == "requeues") {
v = $(i+1); sub(/[^0-9]/, "", v)
if (v != "") requeues = v + 0
}
}
}
/backlog/ && !skip {
for (i = 1; i <= NF; i++) {
if ($i == "backlog") {
v = $(i+1); sub(/b$/, "", v)
backlog = v + 0
}
}
}
/lended:/ && !skip {
for (i = 1; i <= NF; i++) {
if ($i == "lended:") lended = $(i+1) + 0
if ($i == "borrowed:") borrowed = $(i+1) + 0
}
}
END {
if (have_record && current_rate > 0 && current_rate < 1000000 && (classid in ipmap)) {
printf "%s %s %d %d %d %d %d %d %d %d %d\n", \
classid, ipmap[classid], current_rate, \
bytes, pkts, backlog, drops, overlimits, requeues, lended, borrowed
}
}
')
# Parse fq_codel qdisc stats: emit lines "parent_classid ecn_mark new_flows old_flows target_us memory_used"
QDISC_PARSED=$(echo "$QDISC_DATA" | awk '
/^qdisc fq_codel/ {
# emit previous fq_codel record if any
if (have_qdisc && parent_id != "") {
printf "%s %d %d %d %d %d\n", parent_id, ecn_mark, new_flows, old_flows, target_us, memory_used
}
# extract parent classid
parent_id = ""
target_us = 0
for (i = 1; i <= NF; i++) {
if ($i == "parent") parent_id = $(i+1)
if ($i == "target") {
v = $(i+1)
if (v ~ /ms$/) { sub(/ms$/, "", v); target_us = (v + 0) * 1000 }
else if (v ~ /us$/) { sub(/us$/, "", v); target_us = v + 0 }
}
}
ecn_mark = 0; new_flows = 0; old_flows = 0; memory_used = 0
have_qdisc = 1
next
}
/^qdisc/ && !/fq_codel/ {
# different qdisc type, emit previous if valid
if (have_qdisc && parent_id != "") {
printf "%s %d %d %d %d %d\n", parent_id, ecn_mark, new_flows, old_flows, target_us, memory_used
}
have_qdisc = 0
parent_id = ""
next
}
have_qdisc && /ecn_mark/ {
for (i = 1; i <= NF; i++) {
if ($i == "ecn_mark") ecn_mark = $(i+1) + 0
}
}
have_qdisc && /new_flows/ && !/new_flow_count/ {
for (i = 1; i <= NF; i++) {
if ($i == "new_flows") new_flows = $(i+1) + 0
if ($i == "old_flows") old_flows = $(i+1) + 0
}
}
have_qdisc && /memory_used/ {
for (i = 1; i <= NF; i++) {
if ($i == "memory_used") memory_used = $(i+1) + 0
}
}
END {
if (have_qdisc && parent_id != "") {
printf "%s %d %d %d %d %d\n", parent_id, ecn_mark, new_flows, old_flows, target_us, memory_used
}
}
')
# If no classes found, output empty array
if [ -z "$CLASS_PARSED" ]; then
echo '[]'
exit 0
fi
# Write qdisc data to temp file for awk to read
QDISC_TMP=$(mktemp /tmp/tctl_qdisc.XXXXXX)
# shellcheck disable=SC2064
trap "rm -f '$QDISC_TMP'" EXIT INT TERM
echo "$QDISC_PARSED" > "$QDISC_TMP"
# Merge class and qdisc data, output JSON
# First file (QDISC_TMP) is loaded into arrays, second input (stdin) is class data
echo "$CLASS_PARSED" | awk '
NR == FNR {
# Reading qdisc temp file
if (NF >= 6) {
qd_ecn[$1] = $2 + 0
qd_new[$1] = $3 + 0
qd_old[$1] = $4 + 0
qd_target[$1] = $5 + 0
qd_mem[$1] = $6 + 0
}
next
}
{
classid = $1
ip = $2
rate = $3 + 0
bytes = $4 + 0
pkts = $5 + 0
backlog = $6 + 0
drops = $7 + 0
overlimits = $8 + 0
requeues = $9 + 0
lended = $10 + 0
borrowed = $11 + 0
ecn = 0; nf = 0; of = 0; tgt = 0; mem = 0
if (classid in qd_ecn) ecn = qd_ecn[classid]
if (classid in qd_new) nf = qd_new[classid]
if (classid in qd_old) of = qd_old[classid]
if (classid in qd_target) tgt = qd_target[classid]
if (classid in qd_mem) mem = qd_mem[classid]
if (first) printf ","
printf "{\"ip\":\"%s\",\"rate_kbit\":%d,\"bytes\":%.0f,\"packets\":%.0f,\"backlog\":%d,\"drops\":%.0f,\"overlimits\":%.0f,\"requeues\":%.0f,\"lended\":%.0f,\"borrowed\":%.0f,\"ecn_mark\":%.0f,\"new_flows\":%d,\"old_flows\":%d,\"target_us\":%d,\"memory_used\":%d}", \
ip, rate, bytes, pkts, backlog, drops, overlimits, requeues, lended, borrowed, ecn, nf, of, tgt, mem
first = 1
}
BEGIN { printf "[" }
END { printf "]\n" }
' "$QDISC_TMP" -
rm -f "$QDISC_TMP"
+339
View File
@@ -0,0 +1,339 @@
#!/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}"
# 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'
}
shapes_entries() {
grep -oE '\{"ip":"[^"]+","rate_kbit":[0-9]+(,"classid":"1:[0-9a-f]+")?\}' \
"$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]*\)\"}$/\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"
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
local entry="{\"ip\":\"$ip\",\"rate_kbit\":$rate,\"classid\":\"$classid\"}"
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
echo "{\"ok\":false,\"msg\":\"tc setup failed for $IP on $LAN_DEV\"}"
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.
local ul_ok=1
if ensure_ifb; then
shape_attach "$IFB_DEV" src "$IP" "$classid" "$RATE" "$burst_bytes" || ul_ok=0
else
ul_ok=0
fi
save_shape "$IP" "$RATE" "$classid"
if [ "$ul_ok" = "1" ]; then
echo "{\"ok\":true,\"msg\":\"shape ${RATE} kbit/s applied to $IP both directions (class $classid)\"}"
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
@@ -0,0 +1,460 @@
#!/bin/sh
# shellcheck shell=dash
# Summary of all active LAN devices with traffic control status.
# Output: JSON array with per-device info.
#
# Performance: all firewall/tc/conntrack state is fetched ONCE up front and
# reused per device, instead of re-dumping nft chains / re-reading conntrack
# inside the per-IP loop. On a router with many devices this turns O(N) heavy
# forks (one full conntrack read + two nft chain dumps + a tc call PER device)
# into a constant handful of calls.
. /usr/local/bin/trafficctl-fw.sh
CONN_CACHE="/tmp/trafficctl_conn_cache"
[ -f "$CONN_CACHE" ] || : > "$CONN_CACHE"
PORT_MAP_FILE="/tmp/.trafficctl_portmap.$$"
# shellcheck disable=SC2064
trap "rm -f '$PORT_MAP_FILE'" EXIT INT TERM
# Enumerate every LAN subnet (multi-bridge / multi-VLAN aware), once.
LAN_SUBNETS=$(tctl_lan_subnets)
[ -z "$LAN_SUBNETS" ] && { echo '[]'; exit 0; }
LAN_DEVS=$(echo "$LAN_SUBNETS" | awk '{print $1}' | sort -u)
# Monitored = connected LANs + subnets routed via LAN next-hops (downstream
# routers) + trafficctl.main.extra_subnets. Membership spec for awk:
# "netbase:block:routerint ..." — routerint 0 marks a routed (not directly
# connected) subnet, no awk bit-ops needed.
ALL_SUBNETS=$(tctl_monitored_subnets)
MATCH_SPEC=$(echo "$ALL_SUBNETS" | awk '{printf "%s%s:%s:%s",(NR>1?" ":""),$2,$3,$4}')
# Device that carries the tc/HTB shaper (shaping is single-bridge by design).
PRIMARY_LAN_DEV=$(tctl_get_lan_device)
# Get WiFi station→interface mapping: "mac iface_name band"
get_wifi_stations() {
iw dev 2>/dev/null | awk '/Interface/{print $2}' | while read -r iface; do
local band=""
local ch
ch=$(iw dev "$iface" info 2>/dev/null | awk '/channel/{print $2}')
if [ -n "$ch" ]; then
if [ "$ch" -le 14 ] 2>/dev/null; then
band="2.4G"
elif [ "$ch" -le 177 ] 2>/dev/null; then
band="5G"
else
band="6G"
fi
fi
iw dev "$iface" station dump 2>/dev/null | awk -v iface="$iface" -v band="$band" \
'/Station/{print tolower($2), iface, band}'
done
}
# Get bridge MAC→port interface mapping across all LAN bridges: "mac port_iface"
get_bridge_macs() {
local dev
for dev in $LAN_DEVS; do
[ -d "/sys/class/net/$dev/brif" ] || continue
for pdir in /sys/class/net/"$dev"/brif/*/; do
[ -d "$pdir" ] || continue
local iface pno
iface=$(basename "$pdir")
pno=$(cat "${pdir}port_no" 2>/dev/null)
[ -z "$pno" ] && continue
printf "%d %s\n" "$(( pno ))" "$iface"
done > "$PORT_MAP_FILE"
brctl showmacs "$dev" 2>/dev/null | awk -v pmf="$PORT_MAP_FILE" '
BEGIN { while ((getline line < pmf) > 0) { split(line, p, " "); portname[p[1]] = p[2] } }
NR > 1 && $3 == "no" {
mac = tolower($2)
port = $1 + 0
if (port in portname) print mac, portname[port]
}'
done
rm -f "$PORT_MAP_FILE"
}
# All IPv4 addresses owned by the router itself (any interface) — excluded
# from the NAT-based device detection below so the router never shows up.
LOCAL_IPS=$(ip -4 addr show 2>/dev/null | awk '/inet /{split($2,a,"/");print a[1]}' | tr '\n' ' ')
# ── Single conntrack pass ──────────────────────────────────────────────────
# Emit "ip total tcp udp conns kind" for every active source in one read.
# A source qualifies when it belongs to a monitored subnet (kind l=LAN,
# r=routed), or — regardless of subnet — when the flow was SNAT/masqueraded
# by this router (reply dst != original src), which catches every forwarded
# client behind downstream routers without any configuration (kind r).
CT_SUMMARY=$(awk -v spec="$MATCH_SPEC" -v localips="$LOCAL_IPS" '
function ip2int(ip, a) {
split(ip, a, ".")
return a[1]*16777216 + a[2]*65536 + a[3]*256 + a[4]
}
# Return the index of the LAN subnet that contains ip, or 0.
function lan_idx(ip, si, k) {
si = ip2int(ip)
for (k = 1; k <= ns; k++)
if (si - (si % blk[k]) == base[k]) return k
return 0
}
BEGIN {
ns = split(spec, parts, " ")
for (k = 1; k <= ns; k++) {
split(parts[k], kv, ":")
base[k] = kv[1] + 0; blk[k] = kv[2] + 0; rtr[k] = kv[3] + 0
}
nl = split(localips, lp, " ")
for (k = 1; k <= nl; k++) if (lp[k] != "") islocal[lp[k]] = 1
}
{
proto=""
for (i=1; i<=NF; i++) {
if ($i == "tcp") proto="tcp"
else if ($i == "udp") proto="udp"
}
# first src= field that belongs to one of our monitored subnets
srcidx=0; src=""; sk=0
for (i=1; i<=NF; i++) {
if (index($i, "src=") == 1) {
v=substr($i, 5)
k=lan_idx(v)
if (k > 0) { src=v; srcidx=i; sk=k; break }
}
}
if (src != "") {
si=ip2int(src)
# skip the router itself, the network address and the broadcast address
# (routed /31 and /32 entries have no network/broadcast hosts to skip)
if (rtr[sk] && si == rtr[sk]) next
if (blk[sk] > 2 && (si == base[sk] || si == base[sk] + blk[sk] - 1)) next
if (rtr[sk]) knd = "l"; else knd = "r"
} else {
# No subnet matched. NAT fallback: if the reply-direction dst differs
# from the original src, this router SNAT/masqueraded the flow, so the
# original src is an internal client (e.g. behind a downstream router
# on a subnet we have no route entry for).
osrc=""; oidx=0; nsrc=0; rdst=""
for (i=1; i<=NF; i++) {
if (index($i, "src=") == 1) {
nsrc++
if (nsrc == 1) { osrc=substr($i, 5); oidx=i }
} else if (nsrc == 2 && rdst == "" && index($i, "dst=") == 1) {
rdst=substr($i, 5)
}
}
if (osrc == "" || rdst == "" || rdst == osrc) next
if (osrc !~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/) next
if (osrc in islocal) next
src=osrc; srcidx=oidx; knd="r"
}
seen=0; got_dst=0; found=0; b=0
for (i=srcidx; i<=NF; i++) {
if (i == srcidx) { seen=1; continue }
if (seen && !got_dst && index($i, "dst=") == 1) {
dst=substr($i, 5)
if (dst != src) { got_dst=1 } else next
}
if (seen && got_dst && index($i, "bytes=") == 1) {
b=substr($i, 7) + 0; found=1; break
}
}
if (got_dst) {
conns[src]++
kind[src] = knd
if (found) {
total[src]+=b
if (proto == "tcp") tcp[src]+=b
else if (proto == "udp") udp[src]+=b
}
}
}
END {
for (ip in conns)
printf "%s %.0f %.0f %.0f %d %s\n", ip, total[ip]+0, tcp[ip]+0, udp[ip]+0, conns[ip], kind[ip]
}' /proc/net/nf_conntrack 2>/dev/null)
ACTIVE_IPS=$(echo "$CT_SUMMARY" | awk 'NF{print $1}' | sort -t. -k1,1n -k2,2n -k3,3n -k4,4n)
[ -z "$ACTIVE_IPS" ] && { echo '[]'; exit 0; }
# ── Prefetch all shared state once ─────────────────────────────────────────
NOW=$(date +%s)
LEASES=$(cat /tmp/dhcp.leases 2>/dev/null)
NEIGH=$(ip neigh show 2>/dev/null)
# Manual aliases (highest precedence) and the reverse-DNS name cache. Devices
# behind a downstream router have no lease here, so these two are the only way
# they get a name instead of "*".
ALIASES=$(cat /etc/trafficctl/names 2>/dev/null)
RDNS_CACHE_FILE="/tmp/trafficctl_rdns_cache"
RDNS_CACHE=$(cat "$RDNS_CACHE_FILE" 2>/dev/null)
RDNS_ENABLED=$(uci -q get trafficctl.main.resolve_names 2>/dev/null)
RDNS_TTL=$(uci -q get trafficctl.main.rdns_ttl 2>/dev/null)
[ "$RDNS_TTL" -gt 0 ] 2>/dev/null || RDNS_TTL=300
# IPs needing a (re)lookup, filled in during the emit loop and handed to the
# detached refresher at the end so this poll never blocks on DNS.
RDNS_PENDING=""
# Optional netifyd (DPI) application labels. Absent unless the agent is
# installed and running — the column simply stays empty otherwise.
NETIFY_CACHE_FILE="/tmp/trafficctl_netify.json"
NETIFY_APPS=""
if [ "$(uci -q get trafficctl.main.netify_enabled 2>/dev/null)" != "0" ] && [ -S "$(uci -q get trafficctl.main.netify_socket 2>/dev/null || echo /var/run/netifyd/netifyd.sock)" ]; then
# Flatten "ip top-app" pairs once so the per-device lookup stays a
# string match instead of a JSON parse per row.
NETIFY_APPS=$(sed 's/},{/}\n{/g' "$NETIFY_CACHE_FILE" 2>/dev/null | \
sed -n 's/.*"ip":"\([^"]*\)","top":"\([^"]*\)".*/\1 \2/p')
NETIFY_AGE=$(( NOW - $(date -r "$NETIFY_CACHE_FILE" +%s 2>/dev/null || echo 0) ))
NETIFY_INTERVAL=$(uci -q get trafficctl.main.netify_interval 2>/dev/null)
[ "$NETIFY_INTERVAL" -gt 0 ] 2>/dev/null || NETIFY_INTERVAL=45
if [ "$NETIFY_AGE" -ge "$NETIFY_INTERVAL" ]; then
/usr/local/bin/trafficctl-netify.sh collect >/dev/null 2>&1 &
fi
fi
# Firewall dumps (IP-independent — fetched once, grepped per device below)
if [ "$TCTL_FW" = "nft" ]; then
FWD_DUMP=$(nft list chain inet fw4 forward 2>/dev/null)
RL_DUMP=$(nft list table netdev tm_ratelimit 2>/dev/null)
else
FWD_DUMP=$(iptables -L FORWARD -nvx 2>/dev/null)
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
# Wifi ACL state, split by policy, because "listed" means opposite things in the
# two modes: on a deny (blacklist) radio a listed MAC is blocked, on an allow
# (whitelist) radio a listed MAC is the only kind permitted. Collected once here
# and matched fork-free per device below.
WIFI_DENY_MACS=""
WIFI_ALLOW_MACS=""
WIFI_HAS_ALLOW=0
for _wif in $(tctl_get_wifi_interfaces); do
_wlist=$(uci -q get "wireless.${_wif}.maclist" 2>/dev/null)
if [ "$(tctl_get_wifi_filter_mode "$_wif")" = "allow" ]; then
WIFI_HAS_ALLOW=1
WIFI_ALLOW_MACS="$WIFI_ALLOW_MACS $_wlist"
else
WIFI_DENY_MACS="$WIFI_DENY_MACS $_wlist"
fi
done
WIFI_DENY_MACS=" $(echo "$WIFI_DENY_MACS" | tr 'A-F' 'a-f') "
WIFI_ALLOW_MACS=" $(echo "$WIFI_ALLOW_MACS" | tr 'A-F' 'a-f') "
WIFI_STATIONS=$(get_wifi_stations)
BRIDGE_MACS=$(get_bridge_macs)
# ── Per-device helpers (operate on prefetched blobs, no new forks of nft/tc) ─
# Name precedence: manual alias > DHCP lease > cached reverse DNS.
lookup_name() {
local ip="$1" name
name=$(echo "$ALIASES" | awk -v ip="$ip" '$1 == ip {sub(/^[^ ]+ +/, ""); print; exit}')
[ -n "$name" ] && { echo "$name"; return; }
name=$(echo "$LEASES" | awk -v ip="$ip" '$3 == ip && $4 != "*" {print $4; exit}')
[ -n "$name" ] && { echo "$name"; return; }
[ "$RDNS_ENABLED" = "0" ] && return
echo "$RDNS_CACHE" | awk -v ip="$ip" '$1 == ip && $2 != "-" {print $2; exit}'
}
lookup_app() {
[ -z "$NETIFY_APPS" ] && return
echo "$NETIFY_APPS" | awk -v ip="$1" '$1 == ip {print $2; exit}'
}
# True when the cache holds no fresh entry for this IP (hit or negative), so
# the caller should queue a background lookup.
rdns_stale() {
local ip="$1" ts
[ "$RDNS_ENABLED" = "0" ] && return 1
ts=$(echo "$RDNS_CACHE" | awk -v ip="$ip" '$1 == ip {print $3; exit}')
[ -z "$ts" ] && return 0
[ $(( NOW - ts )) -ge "$RDNS_TTL" ]
}
lookup_mac() {
local ip="$1" mac
mac=$(echo "$LEASES" | awk -v ip="$ip" '$3 == ip {print $2; exit}')
[ -z "$mac" ] && mac=$(echo "$NEIGH" | awk -v ip="$ip" '$1 == ip {for(i=1;i<=NF;i++)if($i=="lladdr"){print $(i+1);exit}}')
echo "$mac" | tr 'A-F' 'a-f'
}
# Addresses are matched as whole fields throughout: an unanchored match reports
# 192.168.1.10's block as belonging to 192.168.1.1.
lookup_blocked() {
local ip="$1"
if [ "$TCTL_FW" = "nft" ]; then
echo "$FWD_DUMP" | grep -q "ip saddr $ip .*drop" && echo 1 || echo 0
else
echo "$FWD_DUMP" | awk -v ip="$ip" '
$3 == "DROP" {
for (i = 4; i <= NF; i++) {
src = $i
sub(/\/32$/, "", src)
if (src == ip) { found = 1; exit }
}
}
END { print (found ? 1 : 0) }'
fi
}
lookup_block_bytes() {
local ip="$1" b
if [ "$TCTL_FW" = "nft" ]; then
b=$(echo "$FWD_DUMP" | grep "ip saddr $ip " | grep -oE 'bytes [0-9]+' | awk '{print $2}' | head -1)
else
b=$(echo "$FWD_DUMP" | awk -v ip="$ip" '
$3 == "DROP" {
for (i = 4; i <= NF; i++) {
src = $i
sub(/\/32$/, "", src)
if (src == ip) { print $2; exit }
}
}' | head -1)
fi
echo "${b:-0}"
}
lookup_rate_limit() {
local ip="$1" r
if [ "$TCTL_FW" = "nft" ]; then
r=$(echo "$RL_DUMP" | grep "daddr $ip" | grep -oE '[0-9]+ kbytes' | awk '{print $1 * 8}')
else
r=$(echo "$RL_DUMP" | grep "rl_ratelimit" | grep "$ip" | grep -oE '[0-9]+kbit' | head -1 | sed 's/kbit//')
fi
echo "${r:-0}"
}
lookup_shape_kbit() {
local ip="$1" o3 o4 dec hex classid r
o3=$(echo "$ip" | cut -d. -f3)
o4=$(echo "$ip" | cut -d. -f4)
dec=$((o3 * 256 + o4))
hex=$(printf "%x" "$dec")
case "$hex" in 1|fffe) echo 0; return ;; esac
classid="1:$hex"
r=$(echo "$TC_MAP" | awk -v c="$classid" '$1==c{print $2; exit}')
echo "${r:-0}"
}
lookup_wifi_blocked() {
local mac="$1"
[ -z "$mac" ] && { echo 0; return; }
# Listed on a deny radio -> blocked there.
case "$WIFI_DENY_MACS" in *" $mac "*) echo 1; return ;; esac
# Absent from a whitelist radio -> cannot associate there, so also blocked.
if [ "$WIFI_HAS_ALLOW" = "1" ]; then
case "$WIFI_ALLOW_MACS" in
*" $mac "*) ;;
*) echo 1; return ;;
esac
fi
echo 0
}
# ── Emit JSON ──────────────────────────────────────────────────────────────
printf "["
FIRST=1
for ip in $ACTIVE_IPS; do
# shellcheck disable=SC2046 # deliberate split into 5 positional fields
set -- $(echo "$CT_SUMMARY" | awk -v ip="$ip" '$1==ip{print $2,$3,$4,$5,$6; exit}')
TOTAL="${1:-0}"; TCP="${2:-0}"; UDP="${3:-0}"; CONNS="${4:-0}"; KIND="${5:-l}"
NAME=$(lookup_name "$ip")
MAC=$(lookup_mac "$ip")
BLOCKED=$(lookup_blocked "$ip")
BLOCK_BYTES=$(lookup_block_bytes "$ip")
WIFI_BLK=$(lookup_wifi_blocked "$MAC")
RATE_LIM=$(lookup_rate_limit "$ip")
SHAPE=$(lookup_shape_kbit "$ip")
APP=$(lookup_app "$ip")
if [ -z "$NAME" ]; then
NAME="*"
rdns_stale "$ip" && RDNS_PENDING="$RDNS_PENDING $ip"
fi
# A DHCP hostname is client-controlled: a quote or backslash in it would
# break this JSON document for every consumer (the LuCI table, the bot,
# the exporter). Keep only characters that are safe unquoted.
NAME=$(printf '%s' "$NAME" | tr -d '"\\' | tr -d '\n\r\t')
CONN_TYPE=""
CONN_LAST=""
if [ "$KIND" = "r" ]; then
# Behind a downstream router — no local MAC / bridge port / ARP entry.
CONN_TYPE="routed"
else
if [ -n "$MAC" ]; then
_wl=$(echo "$WIFI_STATIONS" | grep -i "$MAC")
if [ -n "$_wl" ]; then
_band=$(echo "$_wl" | awk '{print $3}')
CONN_TYPE="${_band:-wifi}"
else
_bl=$(echo "$BRIDGE_MACS" | grep -i "$MAC")
if [ -n "$_bl" ]; then
_piface=$(echo "$_bl" | awk '{print $2}')
case "$_piface" in
phy*|wlan*) CONN_TYPE="wifi" ;;
*) CONN_TYPE="$_piface" ;;
esac
fi
fi
fi
if [ -n "$CONN_TYPE" ]; then
sed -i "/^$ip /d" "$CONN_CACHE" 2>/dev/null
echo "$ip $CONN_TYPE $NOW" >> "$CONN_CACHE"
else
_arp_state=$(echo "$NEIGH" | awk -v ip="$ip" '$1==ip{print $NF; exit}')
case "$_arp_state" in
REACHABLE|STALE|DELAY|PROBE) CONN_TYPE="ethernet" ;;
*)
CONN_TYPE="?"
_cached=$(grep "^$ip " "$CONN_CACHE" 2>/dev/null | tail -1)
if [ -n "$_cached" ]; then
CONN_LAST=$(echo "$_cached" | awk '{print $2 "@" $3}')
fi
;;
esac
fi
fi
if [ "$FIRST" = "1" ]; then
FIRST=0
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,"rate_limit_kbit":%.0f,"shape_kbit":%.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)" \
"$RATE_LIM" "$SHAPE"
done
printf "]\n"
# Kick off name resolution for whatever is still unnamed. Detached and capped:
# the results land in the cache and show up on the next poll.
if [ -n "$RDNS_PENDING" ]; then
# shellcheck disable=SC2086 # deliberate word-splitting into arguments
set -- $RDNS_PENDING
[ $# -gt 8 ] && shift $(( $# - 8 ))
/usr/local/bin/trafficctl-rdns-refresh.sh "$@" >/dev/null 2>&1 &
fi
@@ -0,0 +1,94 @@
#!/bin/sh
# shellcheck shell=dash
# Test Telegram bot connection by sending a test message.
# Usage: trafficctl-telegram-test.sh <token> <chat_id> [message]
# TCTL_TG_TOKEN=<token> trafficctl-telegram-test.sh - <chat_id> [message]
#
# Prefer the environment form: an argument is visible in ps to every local user.
CHAT_ID="$2"
CUSTOM_MSG="$3"
if [ -n "$TCTL_TG_TOKEN" ]; then
TOKEN="$TCTL_TG_TOKEN"
else
TOKEN="$1"
fi
if [ -z "$TOKEN" ] || [ -z "$CHAT_ID" ]; then
echo '{"ok":false,"msg":"token and chat_id required"}'
exit 0
fi
echo "$CHAT_ID" | grep -qE '^-?[0-9]+$' || {
echo '{"ok":false,"msg":"chat_id must be numeric"}'
exit 0
}
echo "$TOKEN" | grep -qE '^[0-9]+:[A-Za-z0-9_-]+$' || {
echo '{"ok":false,"msg":"invalid token format"}'
exit 0
}
_fill_template() {
ROUTER=$(uci -q get system.@system[0].hostname 2>/dev/null || echo "OpenWrt")
DATE_NOW=$(date '+%Y-%m-%d')
TIME_NOW=$(date '+%H:%M')
_SEC=$(cut -d. -f1 /proc/uptime 2>/dev/null || echo "0")
UPTIME_STR="$((_SEC / 86400))d $(((_SEC % 86400) / 3600))h"
LOAD=$(cut -d' ' -f1 /proc/loadavg 2>/dev/null || echo "0.00")
WAN_IP=$(ubus call network.interface.wan status 2>/dev/null | jsonfilter -e '@.ipv4-address[0].address' 2>/dev/null)
[ -z "$WAN_IP" ] && WAN_IP=$(ip route get 1.1.1.1 2>/dev/null | grep -o 'src [0-9.]*' | cut -d' ' -f2)
[ -z "$WAN_IP" ] && WAN_IP="unknown"
SSID=$(uci -q get wireless.@wifi-iface[0].ssid 2>/dev/null || echo "WiFi")
sed \
-e "s|{{ *name *}}|TestDevice|g" \
-e "s|{{ *ip *}}|192.168.0.100|g" \
-e "s|{{ *mac *}}|aa:bb:cc:dd:ee:ff|g" \
-e "s|{{ *link *}}|5G|g" \
-e "s|{{ *router *}}|$ROUTER|g" \
-e "s|{{ *date *}}|$DATE_NOW|g" \
-e "s|{{ *time *}}|$TIME_NOW|g" \
-e "s|{{ *datetime *}}|$DATE_NOW $TIME_NOW|g" \
-e "s|{{ *ssid *}}|$SSID|g" \
-e "s|{{ *signal *}}|-52|g" \
-e "s|{{ *freq *}}|5GHz|g" \
-e "s|{{ *iface *}}|wlan1|g" \
-e "s|{{ *clients *}}|12|g" \
-e "s|{{ *uptime *}}|$UPTIME_STR|g" \
-e "s|{{ *wan_ip *}}|$WAN_IP|g" \
-e "s|{{ *load *}}|$LOAD|g" \
-e "s|{{ *conns *}}|5|g"
}
_json_encode() {
# Escape backslashes, quotes, then fold real newlines back to \n for JSON
printf '%s' "$1" \
| sed 's/\\/\\\\/g; s/"/\\"/g' \
| awk 'NR==1{printf "%s",$0} NR>1{printf "\\n%s",$0} END{printf ""}'
}
if [ -n "$CUSTOM_MSG" ]; then
# Convert literal \n escape sequences, then fill template variables
MSG=$(printf '%s' "$CUSTOM_MSG" | sed 's/\\n/\n/g' | _fill_template)
else
ROUTER=$(uci -q get system.@system[0].hostname 2>/dev/null || echo "OpenWrt")
MSG=$(printf '✅ TrafficCtl bot connected from %s' "$ROUTER")
fi
MSG_JSON=$(_json_encode "$MSG")
# The URL carries the bot token, so it goes through a curl config file on stdin
# rather than argv, which is world-readable via ps and /proc.
RESULT=$(printf 'url = "https://api.telegram.org/bot%s/sendMessage"\n' "$TOKEN" | \
curl -s -m 10 -X POST \
-H "Content-Type: application/json" \
-d "{\"chat_id\":\"${CHAT_ID}\",\"text\":\"${MSG_JSON}\",\"parse_mode\":\"HTML\"}" \
--config - 2>/dev/null)
if echo "$RESULT" | jsonfilter -e '@.ok' 2>/dev/null | grep -q "true"; then
echo '{"ok":true,"msg":"test message sent"}'
else
ERR=$(echo "$RESULT" | jsonfilter -e '@.description' 2>/dev/null)
printf '{"ok":false,"msg":"API error: %s"}\n' "${ERR:-unknown}"
fi
@@ -0,0 +1,781 @@
#!/bin/sh
# shellcheck shell=dash
# Telegram bot daemon for trafficctl.
# Runs under procd. Uses curl + jsonfilter to talk to Telegram Bot API.
# Only responds to the authorized chat_id configured in UCI.
. /lib/functions.sh
SCRIPTS="/usr/local/bin"
KNOWN_FILE="/etc/trafficctl/telegram_known.json"
OFFSET_FILE="/tmp/trafficctl_tg_offset"
CACHE_FILE="/tmp/trafficctl_tg_devices.json"
CACHE_TTL=5
# How often to scan for new devices. discover_macs forks ip/iw/awk, so running
# it on every short poll loop wastes CPU continuously; 30s is plenty for a
# "new device joined" alert. Real-time joins still arrive via DHCP hotplug
# triggers (process_dhcp_triggers), which stay on every loop.
NEWDEV_INTERVAL=30
TG_ENABLED=0
TG_TOKEN=""
TG_CHAT_ID=""
TG_POLL=3
TG_NOTIFY_NEW=1
TG_NOTIFY_KNOWN=0
TG_CONTROL=1
TG_NOTIFY_TEMPLATE=""
TG_BTN_INET=1
TG_BTN_WIFI=1
TG_BTN_LIMIT=1
TG_BTN_SHAPE=1
# ── config ──────────────────────────────────────────────────────────────────
load_config() {
config_load trafficctl
config_get TG_ENABLED telegram enabled 0
config_get TG_TOKEN telegram bot_token ""
config_get TG_CHAT_ID telegram chat_id ""
config_get TG_POLL telegram poll_interval 3
config_get TG_NOTIFY_NEW telegram notify_new_device 1
config_get TG_NOTIFY_KNOWN telegram notify_known_device 0
config_get TG_CONTROL telegram control_enabled 1
config_get TG_NOTIFY_TEMPLATE telegram notify_template ""
config_get TG_BTN_INET telegram btn_block_inet 1
config_get TG_BTN_WIFI telegram btn_block_wifi 1
config_get TG_BTN_LIMIT telegram btn_limiter 1
config_get TG_BTN_SHAPE telegram btn_shaper 1
[ "$TG_POLL" -ge 2 ] 2>/dev/null || TG_POLL=3
}
validate_config() {
if [ "$TG_ENABLED" != "1" ]; then
logger -t trafficctl-tg "Telegram bot disabled"
exit 0
fi
if [ -z "$TG_TOKEN" ] || [ -z "$TG_CHAT_ID" ]; then
logger -t trafficctl-tg "Missing bot_token or chat_id"
exit 1
fi
}
# ── telegram API ────────────────────────────────────────────────────────────
# The URL carries the bot token, so it is fed through a curl config file on stdin
# instead of argv — a command line is world-readable via ps and /proc.
tg_api() {
local method="$1" body="$2"
printf 'url = "https://api.telegram.org/bot%s/%s"\n' "$TG_TOKEN" "$method" | \
curl -s -m 30 -X POST \
-H "Content-Type: application/json" \
-d "$body" \
--config - 2>/dev/null
}
# Escape a string for embedding in a JSON body: backslashes, quotes, tabs,
# and real newlines (raw newlines are invalid inside JSON strings and make
# Telegram reject the whole request with a 400).
tg_json_escape() {
printf '%s' "$1" | sed 's/\\/\\\\/g;s/"/\\"/g;s/ /\\t/g' | \
awk 'NR==1{printf "%s",$0} NR>1{printf "\\n%s",$0}'
}
tg_send() {
local text="$1" markup="$2"
local body
text=$(tg_json_escape "$text")
if [ -n "$markup" ]; then
body=$(printf '{"chat_id":"%s","text":"%s","parse_mode":"HTML","reply_markup":%s}' \
"$TG_CHAT_ID" "$text" "$markup")
else
body=$(printf '{"chat_id":"%s","text":"%s","parse_mode":"HTML"}' \
"$TG_CHAT_ID" "$text")
fi
tg_api "sendMessage" "$body" >/dev/null
}
tg_answer_cb() {
local cb_id="$1" text="$2"
text=$(tg_json_escape "$text")
tg_api "answerCallbackQuery" \
"$(printf '{"callback_query_id":"%s","text":"%s"}' "$cb_id" "$text")" >/dev/null
}
tg_edit_msg() {
local msg_id="$1" text="$2" markup="$3"
text=$(tg_json_escape "$text")
local body
if [ -n "$markup" ]; then
body=$(printf '{"chat_id":"%s","message_id":%s,"text":"%s","parse_mode":"HTML","reply_markup":%s}' \
"$TG_CHAT_ID" "$msg_id" "$text" "$markup")
else
body=$(printf '{"chat_id":"%s","message_id":%s,"text":"%s","parse_mode":"HTML"}' \
"$TG_CHAT_ID" "$msg_id" "$text")
fi
tg_api "editMessageText" "$body" >/dev/null
}
# ── device helpers ──────────────────────────────────────────────────────────
get_devices() {
local now mtime age
now=$(date +%s)
if [ -f "$CACHE_FILE" ]; then
mtime=$(date -r "$CACHE_FILE" +%s 2>/dev/null || echo 0)
age=$((now - mtime))
if [ "$age" -lt "$CACHE_TTL" ]; then
cat "$CACHE_FILE"
return
fi
fi
"$SCRIPTS/trafficctl-summary.sh" > "$CACHE_FILE" 2>/dev/null
cat "$CACHE_FILE"
}
invalidate_cache() { rm -f "$CACHE_FILE"; }
get_device_field() {
local json="$1" ip="$2" field="$3"
echo "$json" | jsonfilter -e "@[@.ip='$ip'].$field" 2>/dev/null
}
# ── known devices ───────────────────────────────────────────────────────────
seed_known() {
local mac info name ip
for mac in $(discover_macs); do
[ -z "$mac" ] && continue
info=$(resolve_mac_info "$mac")
name=$(printf '%s' "$info" | cut -f1)
ip=$(printf '%s' "$info" | cut -f2)
add_known_mac "$mac" "${name:-unknown}" "${ip:-?}"
done
logger -t trafficctl-tg "Seeded known devices from current state"
}
load_known() {
if [ ! -f "$KNOWN_FILE" ] || [ "$(cat "$KNOWN_FILE" 2>/dev/null)" = "[]" ]; then
mkdir -p "$(dirname "$KNOWN_FILE")"
[ -f "$KNOWN_FILE" ] || echo '[]' > "$KNOWN_FILE"
seed_known
fi
}
lock_known() { while ! mkdir /tmp/.trafficctl_known.lock 2>/dev/null; do sleep 0.1; done; }
unlock_known() { rmdir /tmp/.trafficctl_known.lock 2>/dev/null; }
is_known_mac() {
grep -q "\"$1\"" "$KNOWN_FILE" 2>/dev/null
}
add_known_mac() {
local mac="$1" name="$2" ip="$3"
local now
now=$(date +%s)
# Sanitize inputs: strip anything that could break JSON or sed
mac=$(printf '%s' "$mac" | tr -cd 'a-fA-F0-9:')
name=$(printf '%s' "$name" | tr -cd 'a-zA-Z0-9 _.-')
ip=$(printf '%s' "$ip" | tr -cd '0-9.')
local tmp="${KNOWN_FILE}.tmp"
local entry
entry=$(printf '{"mac":"%s","name":"%s","ip":"%s","first_seen":%d}' \
"$mac" "$name" "$ip" "$now")
lock_known
if [ "$(cat "$KNOWN_FILE" 2>/dev/null)" = "[]" ]; then
printf '[%s]' "$entry" > "$tmp"
else
awk -v e="$entry" '{sub(/\]$/,","e"]")}1' "$KNOWN_FILE" > "$tmp"
fi
mv "$tmp" "$KNOWN_FILE"
unlock_known
}
discover_macs() {
local seen_file="/tmp/trafficctl_tg_seen.tmp"
: > "$seen_file"
# Source 1: ARP table (covers any device that communicated at L2)
ip neigh show 2>/dev/null | awk '/lladdr/ {print tolower($5)}' >> "$seen_file"
# Source 2: DHCP leases (covers any device that requested an IP)
if [ -f /tmp/dhcp.leases ]; then
awk '{print tolower($2)}' /tmp/dhcp.leases >> "$seen_file"
fi
# Source 3: Wi-Fi associated stations (covers connected Wi-Fi clients even without traffic)
for iface in $(iw dev 2>/dev/null | awk '/Interface/{print $2}'); do
iw dev "$iface" station dump 2>/dev/null | awk '/Station/{print tolower($2)}'
done >> "$seen_file"
# Deduplicate, filter valid MACs
sort -u "$seen_file" | grep -E '^([0-9a-f]{2}:){5}[0-9a-f]{2}$'
rm -f "$seen_file"
}
resolve_mac_info() {
local mac="$1" name="" ip="" conn_type="?"
# Try DHCP leases first
if [ -f /tmp/dhcp.leases ]; then
ip=$(awk -v m="$mac" 'tolower($2)==m {print $3; exit}' /tmp/dhcp.leases)
name=$(awk -v m="$mac" 'tolower($2)==m {print $4; exit}' /tmp/dhcp.leases)
fi
# Fallback: ARP for IP
if [ -z "$ip" ]; then
ip=$(ip neigh show 2>/dev/null | awk -v m="$mac" 'tolower($5)==m {print $1; exit}')
fi
# Determine connection type
for iface in $(iw dev 2>/dev/null | awk '/Interface/{print $2}'); do
if iw dev "$iface" station get "$mac" >/dev/null 2>&1; then
conn_type="wifi"
break
fi
done
if [ "$conn_type" = "?" ] && [ -n "$ip" ]; then
conn_type="ethernet"
fi
[ -z "$name" ] || [ "$name" = "*" ] && name="unknown"
printf '%s\t%s\t%s' "${name}" "${ip:-?}" "${conn_type}"
}
ONLINE_STATE_FILE="/tmp/trafficctl_tg_online"
DHCP_TRIGGER_FILE="/tmp/trafficctl_tg_newdev"
get_wifi_info() {
local mac="$1" iw_out="" iface="" cand="" ssid="" signal="" freq=""
# Iterate all wireless interfaces via iw (covers phy0-ap0 style names,
# not just wlan*).
for cand in $(iw dev 2>/dev/null | awk '/Interface/{print $2}'); do
iw_out=$(iw dev "$cand" station get "$mac" 2>/dev/null)
if [ -n "$iw_out" ]; then
iface="$cand"
break
fi
done
if [ -n "$iw_out" ]; then
signal=$(echo "$iw_out" | awk '/signal:/{print $2; exit}')
# channel line: "channel 36 (5180 MHz), width: 80 MHz, ..." — find the
# first field that parses to a plausible frequency in MHz (the channel
# number itself is < 2400, so it never matches)
freq=$(iw dev "$iface" info 2>/dev/null | \
awk '/channel/{
for (i = 2; i <= NF; i++) {
v = $i; gsub(/[^0-9]/, "", v)
if (v + 0 >= 2400 && v + 0 <= 7200) { f = v + 0; break }
}
if (f >= 5925) print "6GHz"
else if (f >= 5000) print "5GHz"
else if (f > 0) print "2.4GHz"
exit
}')
ssid=$(iw dev "$iface" info 2>/dev/null | awk '/ssid/{print $2; exit}')
fi
printf '%s\t%s\t%s\t%s' "$iface" "$ssid" "$signal" "$freq"
}
get_device_conns() {
local ip="$1" n
# grep -c prints 0 itself on no match (with exit status 1), so an
# "|| echo 0" here would emit a second line and corrupt the value.
n=$(grep -c "src=$ip " /proc/net/nf_conntrack 2>/dev/null)
echo "${n:-0}"
}
get_router_vars() {
local uptime_s up_d up_h up_m __ip
uptime_s=$(awk '{print int($1)}' /proc/uptime)
up_d=$((uptime_s / 86400))
up_h=$(( (uptime_s % 86400) / 3600 ))
up_m=$(( (uptime_s % 3600) / 60 ))
if [ "$up_d" -gt 0 ]; then
TVAR_UPTIME="${up_d}d ${up_h}h"
elif [ "$up_h" -gt 0 ]; then
TVAR_UPTIME="${up_h}h ${up_m}m"
else
TVAR_UPTIME="${up_m}m"
fi
TVAR_DATE=$(date '+%Y-%m-%d')
TVAR_TIME=$(date '+%H:%M')
TVAR_DATETIME=$(date '+%Y-%m-%d %H:%M')
TVAR_ROUTER=$(uci -q get system.@system[0].hostname || cat /proc/sys/kernel/hostname)
. /lib/functions/network.sh
network_get_ipaddr __ip wan
TVAR_WAN_IP="$__ip"
TVAR_LOAD=$(awk '{print $1}' /proc/loadavg)
TVAR_CLIENTS=$(wc -l < /tmp/dhcp.leases 2>/dev/null || echo 0)
}
format_new_device_msg() {
local name="$1" ip="$2" mac="$3" link="$4"
if [ -n "$TG_NOTIFY_TEMPLATE" ]; then
get_router_vars
local wifi_info iface ssid signal freq conns
wifi_info=$(get_wifi_info "$mac")
iface=$(printf '%s' "$wifi_info" | cut -f1)
ssid=$(printf '%s' "$wifi_info" | cut -f2)
signal=$(printf '%s' "$wifi_info" | cut -f3)
freq=$(printf '%s' "$wifi_info" | cut -f4)
conns=$(get_device_conns "$ip")
printf '%s' "$TG_NOTIFY_TEMPLATE" | \
awk -v n="$name" -v i="$ip" -v m="$mac" -v l="$link" \
-v dt="$TVAR_DATE" -v tm="$TVAR_TIME" -v dtm="$TVAR_DATETIME" \
-v rtr="$TVAR_ROUTER" -v ssid="$ssid" -v sig="$signal" \
-v freq="$freq" -v iface="$iface" -v clients="$TVAR_CLIENTS" \
-v up="$TVAR_UPTIME" -v wan="$TVAR_WAN_IP" -v ld="$TVAR_LOAD" \
-v conns="$conns" '{
# [ \t]* instead of \s* — BusyBox awk has no \s class
gsub(/\{\{[ \t]*name[ \t]*\}\}/, n)
gsub(/\{\{[ \t]*ip[ \t]*\}\}/, i)
gsub(/\{\{[ \t]*mac[ \t]*\}\}/, m)
gsub(/\{\{[ \t]*link[ \t]*\}\}/, l)
gsub(/\{\{[ \t]*date[ \t]*\}\}/, dt)
gsub(/\{\{[ \t]*time[ \t]*\}\}/, tm)
gsub(/\{\{[ \t]*datetime[ \t]*\}\}/, dtm)
gsub(/\{\{[ \t]*router[ \t]*\}\}/, rtr)
gsub(/\{\{[ \t]*ssid[ \t]*\}\}/, ssid)
gsub(/\{\{[ \t]*signal[ \t]*\}\}/, sig)
gsub(/\{\{[ \t]*freq[ \t]*\}\}/, freq)
gsub(/\{\{[ \t]*iface[ \t]*\}\}/, iface)
gsub(/\{\{[ \t]*clients[ \t]*\}\}/, clients)
gsub(/\{\{[ \t]*uptime[ \t]*\}\}/, up)
gsub(/\{\{[ \t]*wan_ip[ \t]*\}\}/, wan)
gsub(/\{\{[ \t]*load[ \t]*\}\}/, ld)
gsub(/\{\{[ \t]*conns[ \t]*\}\}/, conns)
gsub(/\\n/, "\n")
print
}'
else
printf '🆕 <b>New device</b>\n%s (%s)\nMAC: <code>%s</code>\nLink: %s' \
"$name" "$ip" "$mac" "$link"
fi
}
process_dhcp_triggers() {
[ -f "$DHCP_TRIGGER_FILE" ] || return 0
local tmpf="${DHCP_TRIGGER_FILE}.proc"
mv "$DHCP_TRIGGER_FILE" "$tmpf" 2>/dev/null || return 0
local mac ip name
while IFS=' ' read -r mac ip name; do
[ -z "$mac" ] && continue
mac=$(printf '%s' "$mac" | tr 'A-F' 'a-f')
if ! is_known_mac "$mac"; then
add_known_mac "$mac" "${name:-unknown}" "${ip:-?}"
if [ "$TG_NOTIFY_NEW" = "1" ]; then
tg_send "$(format_new_device_msg "${name:-unknown}" "${ip:-?}" "$mac" "dhcp")"
fi
fi
done < "$tmpf"
rm -f "$tmpf"
}
check_new_devices() {
local mac info name ip conn_type
local current_macs="/tmp/trafficctl_tg_cur.tmp"
discover_macs > "$current_macs"
while read -r mac; do
[ -z "$mac" ] && continue
if ! is_known_mac "$mac"; then
info=$(resolve_mac_info "$mac")
name=$(printf '%s' "$info" | cut -f1)
ip=$(printf '%s' "$info" | cut -f2)
conn_type=$(printf '%s' "$info" | cut -f3)
add_known_mac "$mac" "${name:-unknown}" "${ip:-?}"
if [ "$TG_NOTIFY_NEW" = "1" ]; then
tg_send "$(format_new_device_msg "${name:-unknown}" "${ip:-?}" "$mac" "${conn_type:-?}")"
fi
elif [ "$TG_NOTIFY_KNOWN" = "1" ]; then
if [ -f "$ONLINE_STATE_FILE" ] && ! grep -q "$mac" "$ONLINE_STATE_FILE" 2>/dev/null; then
info=$(resolve_mac_info "$mac")
name=$(printf '%s' "$info" | cut -f1)
ip=$(printf '%s' "$info" | cut -f2)
conn_type=$(printf '%s' "$info" | cut -f3)
tg_send "$(printf '📱 <b>Device online</b>\n%s (%s)\nLink: %s' \
"${name:-unknown}" "${ip:-?}" "${conn_type:-?}")"
fi
fi
done < "$current_macs"
mv "$current_macs" "$ONLINE_STATE_FILE"
}
# ── keyboard builders ───────────────────────────────────────────────────────
build_device_keyboard() {
local devices="$1"
local tmpkb="/tmp/trafficctl_tg_kb.tmp"
local ip name btn col=0 first=1
printf '{"inline_keyboard":[' > "$tmpkb"
for ip in $(echo "$devices" | jsonfilter -e '@[*].ip' 2>/dev/null); do
name=$(echo "$devices" | jsonfilter -e "@[@.ip='$ip'].name" 2>/dev/null)
# Keep button labels JSON-safe: drop quotes/backslashes and anything
# non-ASCII, then truncate. A %.12s cut through a multi-byte character
# would produce invalid UTF-8 and Telegram would reject the keyboard.
name=$(printf '%s' "$name" | tr -cd 'a-zA-Z0-9 _.()-')
btn=$(printf '{"text":"%.12s %s","callback_data":"act:menu:%s"}' \
"${name:-$ip}" "$ip" "$ip")
if [ "$col" -eq 0 ]; then
if [ "$first" -eq 1 ]; then
first=0
else
printf '],' >> "$tmpkb"
fi
printf '[%s' "$btn" >> "$tmpkb"
col=1
else
printf ',%s' "$btn" >> "$tmpkb"
col=0
fi
done
printf ']]}' >> "$tmpkb"
cat "$tmpkb"
rm -f "$tmpkb"
}
build_action_keyboard() {
local ip="$1" devices="$2"
local blocked wifi_blocked rl_kbit shape_kbit
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")
conn_type=$(get_device_field "$devices" "$ip" "conn_type")
local kb='{"inline_keyboard":['
local rows=""
# internet block/unblock
if [ "$TG_BTN_INET" = "1" ]; then
if [ "$blocked" = "true" ] || [ "$blocked" = "1" ]; then
rows="${rows}[{\"text\":\"▶️ Unblock Internet\",\"callback_data\":\"act:unblock:${ip}\"}],"
else
rows="${rows}[{\"text\":\"⏸ Block Internet\",\"callback_data\":\"act:block:${ip}\"}],"
fi
fi
# wifi block/unblock (only for wifi devices)
if [ "$TG_BTN_WIFI" = "1" ]; then
case "$conn_type" in
*wifi*|*2.4G*|*5G*|*6G*|*WiFi*)
if [ "$wifi_blocked" = "true" ] || [ "$wifi_blocked" = "1" ]; then
rows="${rows}[{\"text\":\"📶 Unblock WiFi\",\"callback_data\":\"act:wunblock:${ip}\"}],"
else
rows="${rows}[{\"text\":\"📵 Block WiFi\",\"callback_data\":\"act:wblock:${ip}\"}],"
fi
;;
esac
fi
# 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}\"}],"
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\"}],"
fi
fi
# 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}\"}],"
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\"}],"
fi
fi
# back button
rows="${rows}[{\"text\":\"⬅️ Back\",\"callback_data\":\"act:back\"}]"
printf '%s%s]}' "$kb" "$rows"
}
# ── command handlers ────────────────────────────────────────────────────────
handle_help() {
if [ "$TG_CONTROL" = "1" ]; then
tg_send "$(printf '<b>TrafficCtl Bot</b>\n\n/devices — active devices with action buttons\n/status — blocked/limited summary\n/help — this message')"
else
tg_send "$(printf '<b>TrafficCtl Bot</b> (notifications only)\n\n/devices — active devices list\n/status — blocked/limited summary\n/help — this message\n\n<i>Control disabled — enable in LuCI settings</i>')"
fi
}
handle_devices() {
local devices
devices=$(get_devices)
if [ -z "$devices" ] || [ "$devices" = "[]" ]; then
tg_send "No active devices"
return
fi
local count
count=$(echo "$devices" | jsonfilter -e '@[*].ip' 2>/dev/null | wc -l)
if [ "$TG_CONTROL" = "1" ]; then
local kb
kb=$(build_device_keyboard "$devices")
tg_send "$(printf '<b>Active devices: %d</b>\nSelect a device:' "$count")" "$kb"
else
local list="" ip name
for ip in $(echo "$devices" | jsonfilter -e '@[*].ip' 2>/dev/null); do
name=$(get_device_field "$devices" "$ip" "name")
list="${list}• ${name:-?} (${ip})\n"
done
tg_send "$(printf '<b>Active devices: %d</b>\n%b' "$count" "$list")"
fi
}
handle_devices_edit() {
local msg_id="$1"
invalidate_cache
local devices
devices=$(get_devices)
local count
count=$(echo "$devices" | jsonfilter -e '@[*].ip' 2>/dev/null | wc -l)
local kb
kb=$(build_device_keyboard "$devices")
tg_edit_msg "$msg_id" "$(printf '<b>Active devices: %d</b>\nSelect a device:' "$count")" "$kb"
}
handle_status() {
local devices ip name rl_kbit shape_kbit blocked wifi_blocked
local result=""
devices=$(get_devices)
[ -z "$devices" ] || [ "$devices" = "[]" ] && { tg_send "No active devices"; return; }
for ip in $(echo "$devices" | jsonfilter -e '@[*].ip' 2>/dev/null); do
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")
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"
[ -n "$flags" ] && result="${result}${name:-?} (${ip}):${flags}\n"
done
if [ -z "$result" ]; then
tg_send "All devices are clean — no blocks or limits"
else
tg_send "$(printf '<b>Active restrictions:</b>\n%b' "$result")"
fi
}
# ── callback handler ────────────────────────────────────────────────────────
handle_callback() {
local cb_id="$1" data="$2" msg_id="$3"
local verb ip param result msg devices name
if [ "$TG_CONTROL" != "1" ]; then
tg_answer_cb "$cb_id" "Control disabled"
return
fi
verb=$(echo "$data" | cut -d: -f2)
ip=$(echo "$data" | cut -d: -f3)
param=$(echo "$data" | cut -d: -f4)
# Validate IP from callback data
if [ -n "$ip" ] && [ "$verb" != "back" ]; then
if ! echo "$ip" | grep -qE '^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$'; then
tg_answer_cb "$cb_id" "invalid IP"
return
fi
fi
# Validate rate param is numeric
if [ -n "$param" ]; then
case "$param" in *[!0-9]*) tg_answer_cb "$cb_id" "invalid param"; return ;; esac
fi
case "$verb" in
menu)
invalidate_cache
devices=$(get_devices)
name=$(get_device_field "$devices" "$ip" "name")
local blocked wifi_blocked rl_kbit shape_kbit
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")
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"
[ -z "$status_line" ] && status_line="✅ No restrictions\n"
local text
text=$(printf '<b>%s</b> (%s)\n%b' "${name:-?}" "$ip" "$status_line")
local kb
kb=$(build_action_keyboard "$ip" "$devices")
tg_edit_msg "$msg_id" "$text" "$kb"
tg_answer_cb "$cb_id" ""
;;
block)
result=$("$SCRIPTS/trafficctl-block.sh" "$ip" "tg")
msg=$(echo "$result" | jsonfilter -e '@.msg' 2>/dev/null)
tg_answer_cb "$cb_id" "${msg:-done}"
invalidate_cache
handle_callback "$cb_id" "act:menu:$ip" "$msg_id"
;;
unblock)
result=$("$SCRIPTS/trafficctl-unblock.sh" "$ip" "tg")
msg=$(echo "$result" | jsonfilter -e '@.msg' 2>/dev/null)
tg_answer_cb "$cb_id" "${msg:-done}"
invalidate_cache
handle_callback "$cb_id" "act:menu:$ip" "$msg_id"
;;
wblock)
result=$("$SCRIPTS/trafficctl-macfilter-add.sh" "$ip")
msg=$(echo "$result" | jsonfilter -e '@.msg' 2>/dev/null)
tg_answer_cb "$cb_id" "${msg:-done}"
invalidate_cache
handle_callback "$cb_id" "act:menu:$ip" "$msg_id"
;;
wunblock)
result=$("$SCRIPTS/trafficctl-macfilter-remove.sh" "$ip")
msg=$(echo "$result" | jsonfilter -e '@.msg' 2>/dev/null)
tg_answer_cb "$cb_id" "${msg:-done}"
invalidate_cache
handle_callback "$cb_id" "act:menu:$ip" "$msg_id"
;;
limit)
result=$("$SCRIPTS/trafficctl-ratelimit.sh" "$ip" "$param" "tg")
msg=$(echo "$result" | jsonfilter -e '@.msg' 2>/dev/null)
tg_answer_cb "$cb_id" "${msg:-done}"
invalidate_cache
handle_callback "$cb_id" "act:menu:$ip" "$msg_id"
;;
unlimit)
result=$("$SCRIPTS/trafficctl-ratelimit.sh" "$ip" "0" "tg")
msg=$(echo "$result" | jsonfilter -e '@.msg' 2>/dev/null)
tg_answer_cb "$cb_id" "${msg:-done}"
invalidate_cache
handle_callback "$cb_id" "act:menu:$ip" "$msg_id"
;;
shape)
result=$("$SCRIPTS/trafficctl-shape.sh" add "$ip" "$param" "tg")
msg=$(echo "$result" | jsonfilter -e '@.msg' 2>/dev/null)
tg_answer_cb "$cb_id" "${msg:-done}"
invalidate_cache
handle_callback "$cb_id" "act:menu:$ip" "$msg_id"
;;
unshape)
result=$("$SCRIPTS/trafficctl-shape.sh" remove "$ip")
msg=$(echo "$result" | jsonfilter -e '@.msg' 2>/dev/null)
tg_answer_cb "$cb_id" "${msg:-done}"
invalidate_cache
handle_callback "$cb_id" "act:menu:$ip" "$msg_id"
;;
back)
handle_devices_edit "$msg_id"
tg_answer_cb "$cb_id" ""
;;
*)
tg_answer_cb "$cb_id" "Unknown action"
;;
esac
}
# ── main loop ───────────────────────────────────────────────────────────────
main() {
load_config
validate_config
load_known
export TCTL_VIA="telegram"
export TCTL_SRC="telegram:${TG_CHAT_ID}"
export TCTL_USER="telegram-bot"
logger -t trafficctl-tg "Bot started, chat_id=$TG_CHAT_ID"
local offset response ok i
local update update_id msg_chat_id msg_text cb_id cb_data cb_msg_id
local config_reload_at newdev_check_at
config_reload_at=$(($(date +%s) + 60))
newdev_check_at=0
offset=$(cat "$OFFSET_FILE" 2>/dev/null || echo "0")
while true; do
# Periodic full device scan (rate-limited); hotplug path stays real-time.
if [ "$(date +%s)" -ge "$newdev_check_at" ]; then
check_new_devices
newdev_check_at=$(($(date +%s) + NEWDEV_INTERVAL))
fi
process_dhcp_triggers
response=$(tg_api "getUpdates" \
"$(printf '{"offset":%s,"timeout":%d,"allowed_updates":["message","callback_query"]}' \
"$offset" "$TG_POLL")")
ok=$(echo "$response" | jsonfilter -e '@.ok' 2>/dev/null)
[ "$ok" = "true" ] || { sleep 5; continue; }
# Iterate results by index until one comes back empty. Do NOT use
# `jsonfilter -l '@.result'` to get a count: on newer jsonfilter
# (OpenWrt 25.12 / APK and snapshot) it returns an empty string, which
# left update_count empty and turned `[ "$i" -lt "$update_count" ]` into
# a BusyBox "sh: out of range" loop that never processed commands (#22).
i=0
while update=$(echo "$response" | jsonfilter -e "@.result[$i]" 2>/dev/null); [ -n "$update" ]; do
update_id=$(echo "$update" | jsonfilter -e '@.update_id' 2>/dev/null)
[ -n "$update_id" ] && offset=$((update_id + 1))
echo "$offset" > "$OFFSET_FILE"
# try message first
msg_chat_id=$(echo "$update" | jsonfilter -e '@.message.chat.id' 2>/dev/null)
if [ -n "$msg_chat_id" ]; then
if [ "$msg_chat_id" = "$TG_CHAT_ID" ]; then
msg_text=$(echo "$update" | jsonfilter -e '@.message.text' 2>/dev/null)
case "$msg_text" in
/start*|/help*) handle_help ;;
/devices*) handle_devices ;;
/status*) handle_status ;;
esac
fi
i=$((i + 1))
continue
fi
# try callback_query
cb_id=$(echo "$update" | jsonfilter -e '@.callback_query.id' 2>/dev/null)
if [ -n "$cb_id" ]; then
msg_chat_id=$(echo "$update" | jsonfilter -e '@.callback_query.message.chat.id' 2>/dev/null)
if [ "$msg_chat_id" = "$TG_CHAT_ID" ]; then
cb_data=$(echo "$update" | jsonfilter -e '@.callback_query.data' 2>/dev/null)
cb_msg_id=$(echo "$update" | jsonfilter -e '@.callback_query.message.message_id' 2>/dev/null)
handle_callback "$cb_id" "$cb_data" "$cb_msg_id"
fi
fi
i=$((i + 1))
done
# reload config periodically
if [ "$(date +%s)" -ge "$config_reload_at" ]; then
load_config
config_reload_at=$(($(date +%s) + 60))
fi
done
}
main
+276
View File
@@ -0,0 +1,276 @@
#!/bin/sh
# shellcheck shell=dash
# Monotonic per-device byte totals — the single accumulator behind both the
# LuCI "Bytes / TCP / UDP" columns and the trafficctl_device_bytes_total metric.
#
# WHY AN ACCUMULATOR AT ALL (issue #26)
# Neither byte source is a lifetime total. conntrack reports what its
# CURRENTLY TRACKED flows have carried, so a device's number collapses the
# moment its flows age out — which is why the columns were reported showing
# values like "2 bytes". The nft counter maps are cumulative, but only since
# the table was created, and that table is rebuilt on upgrade and gone after
# a reboot. A lifetime total has to be accumulated from deltas and persisted.
#
# ONE STORE, ONE IMPLEMENTATION
# This logic used to live inline in trafficctl-metrics.sh. It was lifted out
# rather than copied: a second accumulator with its own state file would let
# the UI column and the Prometheus counter report different totals for the
# same device, and that is a bug report nobody can reproduce.
#
# SOURCE SELECTION is delegated entirely to trafficctl-bytes.sh, which already
# switches to nft counters when flow offload stops conntrack from counting —
# so the total stays right on exactly the routers where the complaint arises.
# That switch can happen at RUNTIME (offload toggled in the firewall config,
# or trafficctl-bytes-nft.sh bouncing back to conntrack on a kernel without
# dynamic counter maps), and the two sources have unrelated magnitudes. The
# source is therefore recorded per device, and a change rebaselines instead
# of accumulating the difference as if it were traffic.
#
# STORE: /tmp, i.e. tmpfs, so totals reset on reboot. Deliberate: this file is
# rewritten on every LuCI poll (2–10 s) and every scrape, and that write
# volume into flash would wear out the router. Surviving a reboot would mean
# a periodic flush to /etc/trafficctl (kept across sysupgrade by
# lib/upgrade/keep.d) at a much coarser interval — a separate decision.
#
# Usage: trafficctl-totals.sh [--all]
# default : one element per device in the current sample (what the UI wants)
# --all : also emit devices that are no longer in the sample but still
# carry a total, so a Prometheus series does not vanish and
# reappear every time a quiet device's last flow expires.
#
# Output: the trafficctl-bytes.sh array, each element extended with
# bytes_in_total / bytes_out_total / bytes_tcp_total / bytes_udp_total,
# total_since (unix time accumulation began for that device) and live.
# The proto totals are -1 when the active source cannot split by protocol.
#
# TRUST: "degraded" is carried through from the sampler and is STICKY per
# device. It means the counters the total was built from are frozen for
# offloaded flows (uncountered offload with no usable nft fallback), so the
# total is a lower bound that stops growing while traffic continues. One
# tainted sample understates the total for the rest of its life, so the flag
# cannot be cleared just because the next sample looked healthy — that would
# re-present the same understated number as trustworthy. Consumers must
# refuse to show it as a total, exactly as they refuse to show -1 as 0.
. /usr/local/bin/trafficctl-fw.sh
STATE="/tmp/trafficctl_totals.state"
LOCKD="/tmp/trafficctl_totals.lock.d"
ALL=0
[ "$1" = "--all" ] && ALL=1
SAMPLE="/tmp/.trafficctl_totals.sample.$$"
# Per-process, NOT a shared "$STATE.tmp". The lock below can be stolen after a
# timeout, so two writers coexisting is a reachable state rather than an
# impossible one; with a shared scratch name they would interleave lines into
# it and the surviving state file would be garbage. With one name each, the
# worst outcome is the benign one the lock is really for — a lost delta.
TMPF="/tmp/.trafficctl_totals.write.$$"
# shellcheck disable=SC2064 # expand the paths now, not at trap time
trap "rm -f '$SAMPLE' '$TMPF'" EXIT INT TERM
# Sample BEFORE taking the lock. Reading /proc/net/nf_conntrack on a busy
# router is by far the slowest thing here, and holding the lock across it would
# put every poll in a queue behind it: two browser tabs at a 2 s interval would
# then routinely hit the steal-on-timeout path below, which exists for a killed
# sampler, not for ordinary load. The file is in tmpfs and is streamed into
# awk, so this costs no more memory than the pipeline it replaces — and keeps
# the promise that no large value is ever held in a shell variable.
/usr/local/bin/trafficctl-bytes.sh 2>/dev/null | sed 's/},{/}\n{/g' > "$SAMPLE"
# mkdir is atomic, so unlike a test-then-create lock file two samplers cannot
# both believe they hold it. Without this a LuCI poll and a metrics scrape
# landing together would read the same state, and the later writer would
# silently discard the earlier one's delta.
_tries=0
while ! mkdir "$LOCKD" 2>/dev/null; do
_tries=$((_tries + 1))
if [ "$_tries" -ge 30 ]; then
# A stale directory from a killed sampler must not freeze the counters
# for good; steal it rather than give up on accumulating.
rmdir "$LOCKD" 2>/dev/null || true
mkdir "$LOCKD" 2>/dev/null || break
break
fi
sleep 0.1 2>/dev/null || sleep 1
done
# Re-armed only now that the lock is actually held: a signal arriving during
# the sample above must not make this process rmdir a lock it never took.
# shellcheck disable=SC2064 # expand the paths now, not at trap time
trap "rm -f '$SAMPLE' '$TMPF'; rmdir '$LOCKD' 2>/dev/null" EXIT INT TERM
NOW=$(date +%s)
# Everything from here to the end of awk is the critical section, and it is
# only a state-file read plus one pass over the sample — milliseconds.
awk -v state="$STATE" -v tmp="$TMPF" -v now="$NOW" -v all="$ALL" '
# Returns -1 for a missing key, an unparseable value, or an explicit -1 in the
# JSON — all three mean the same thing to every caller here: "not available".
function num(line, key, re, seg) {
re = "\"" key "\"[ \t]*:[ \t]*"
if (!match(line, re)) return -1
seg = substr(line, RSTART + RLENGTH)
if (!match(seg, /^[0-9]+/)) return -1
return substr(seg, RSTART, RLENGTH) + 0
}
function str(line, key, re, seg) {
re = "\"" key "\"[ \t]*:[ \t]*\""
if (!match(line, re)) return ""
seg = substr(line, RSTART + RLENGTH)
if (!match(seg, /"/)) return ""
return substr(seg, 1, RSTART - 1)
}
BEGIN {
# State line: ip rx_acc tx_acc rx_last tx_last seen tcp_acc udp_acc
# tcp_last udp_last src since
# The first six fields are byte-for-byte the layout the exporter used
# before this script existed, so an old state file still loads.
while ((getline l < state) > 0) {
n = split(l, f, " ")
if (n < 5) continue
ip = f[1]
rxa[ip] = f[2] + 0; txa[ip] = f[3] + 0
rxl[ip] = f[4] + 0; txl[ip] = f[5] + 0
known[ip] = 1
seenat[ip] = (n >= 6 ? f[6] + 0 : now)
if (n >= 10) {
tca[ip] = f[7] + 0; uda[ip] = f[8] + 0
tcl[ip] = f[9] + 0; udl[ip] = f[10] + 0
} else {
tcl[ip] = -1; udl[ip] = -1
}
srcof[ip] = (n >= 11 ? f[11] : "?")
since[ip] = (n >= 12 ? f[12] + 0 : now)
degof[ip] = (n >= 13 ? f[13] : "false")
}
close(state)
}
{
ip = str($0, "ip")
if (ip == "") next
rx = num($0, "bytes_in"); if (rx < 0) rx = 0
tx = num($0, "bytes_out"); if (tx < 0) tx = 0
tcv = num($0, "bytes_tcp") # -1 when the source cannot split by protocol
udv = num($0, "bytes_udp")
s = str($0, "src")
if (s == "") s = "?"
# Trust, not magnitude. Deliberately NOT folded into the source-switch test
# below: conntrack counters are continuous across a change in trust, so
# rebaselining on it would throw away a real delta for nothing.
dg = "false"
if (index($0, "\"degraded\":true") > 0) dg = "true"
# A source switch swaps "bytes carried by live flows" for "bytes since the
# nft table was built", or the reverse. Either way the jump is not traffic,
# so this tick only re-establishes the baseline.
switched = 0
if ((ip in known) && srcof[ip] != "?" && s != "?" && srcof[ip] != s) switched = 1
if ((ip in known) && !switched) {
# Only upward movement is new traffic. A drop means flows expired, and
# their bytes were already accumulated while they lived.
if (rx > rxl[ip]) rxa[ip] += rx - rxl[ip]
if (tx > txl[ip]) txa[ip] += tx - txl[ip]
} else if (!switched) {
# No baseline yet: the whole sample is the starting point, not a delta.
rxa[ip] += rx; txa[ip] += tx
since[ip] = now
}
rxl[ip] = rx; txl[ip] = tx
if (tcv >= 0) {
if ((ip in tcl) && tcl[ip] >= 0 && !switched) {
if (tcv > tcl[ip]) tca[ip] += tcv - tcl[ip]
} else if (!switched) {
tca[ip] += tcv
}
tcl[ip] = tcv
} else {
# Gone unavailable (offload turned on mid-life). Forget the baseline so
# that when it returns the stale value is not differenced against the
# new one — hours of frozen counter would land as one fake delta.
tcl[ip] = -1
}
if (udv >= 0) {
if ((ip in udl) && udl[ip] >= 0 && !switched) {
if (udv > udl[ip]) uda[ip] += udv - udl[ip]
} else if (!switched) {
uda[ip] += udv
}
udl[ip] = udv
} else {
udl[ip] = -1
}
known[ip] = 1
srcof[ip] = s
# Sticky: a total accumulated from frozen counters stays understated for
# the rest of its life, so one degraded sample taints it until the counter
# is reset. Clearing the flag as soon as the next sample looked healthy
# would quietly re-present that same understated number as trustworthy.
if (dg == "true") degof[ip] = "true"
else if (!(ip in degof)) degof[ip] = "false"
seenat[ip] = now
live[ip] = 1
srx[ip] = rx; stx[ip] = tx; stc[ip] = tcv; sud[ip] = udv
}
END {
for (ip in rxl) {
# Drop devices that have gone quiet AND carry no total, so the state
# file cannot grow without bound on a busy network.
if (!(ip in live) && rxa[ip] + txa[ip] + tca[ip] + uda[ip] == 0) continue
sv = srcof[ip]; if (sv == "") sv = "?"
dv = degof[ip]; if (dv == "") dv = "false"
printf "%s %.0f %.0f %.0f %.0f %d %.0f %.0f %.0f %.0f %s %d %s\n", \
ip, rxa[ip], txa[ip], rxl[ip], txl[ip], seenat[ip], \
tca[ip]+0, uda[ip]+0, tcl[ip], udl[ip], sv, since[ip], dv > tmp
}
close(tmp)
system("mv " tmp " " state " 2>/dev/null")
printf "["
n = 0
for (ip in rxl) {
islive = 0
if (ip in live) islive = 1
if (!islive && !all) continue
# A device absent from this sample has no CURRENT reading at all.
# Re-emitting its last one would hand a consumer a frozen number that
# looks live, with only the "live" flag to give it away — so the raw
# fields report -1, "not sampled", the same sentinel the protocol split
# uses. The accumulated totals below are unaffected: those are real
# history and stay exact.
crx = -1; ctx = -1; ctc = -1; cud = -1
if (islive) { crx = srx[ip]; ctx = stx[ip]; ctc = stc[ip]; cud = sud[ip] }
# -1, never 0, when the source cannot split by protocol: a UI that
# printed 0 would be claiming the device sent no TCP. Keyed off the
# stored baseline rather than this sample, so a quiet device under
# --all still reports the protocol history it does have.
#
# These MUST stay value tests. `(ip in tcl)` would be true for every
# device by the time control reaches here: the state-writing loop above
# reads tcl[ip] and tca[ip] by subscript, and merely referencing an awk
# array element creates it. A membership test would then report 0 bytes
# of TCP — a confident wrong answer — for devices that never had a
# protocol reading at all.
tt = -1; if (tcl[ip] >= 0) tt = tca[ip] + 0
ut = -1; if (udl[ip] >= 0) ut = uda[ip] + 0
# Hoisted out of the printf argument list rather than inlined as
# ternaries: BusyBox awk is the interpreter on the target and has been
# seen to mis-parse expressions in call arguments (see the note in
# trafficctl-metrics.sh), and a silently dropped statement here would
# corrupt the JSON for every consumer.
sv = srcof[ip]; if (sv == "") sv = "?"
lv = "false"; if (islive) lv = "true"
dv = degof[ip]; if (dv == "") dv = "false"
if (n > 0) printf ","
printf "{\"ip\":\"%s\",\"bytes_in\":%.0f,\"bytes_out\":%.0f,\"bytes_tcp\":%.0f,\"bytes_udp\":%.0f,\"src\":\"%s\",\"degraded\":%s,\"bytes_in_total\":%.0f,\"bytes_out_total\":%.0f,\"bytes_tcp_total\":%.0f,\"bytes_udp_total\":%.0f,\"total_since\":%d,\"live\":%s}", \
ip, crx, ctx, ctc, cud, sv, dv, \
rxa[ip], txa[ip], tt, ut, since[ip], lv
n++
}
printf "]\n"
}' "$SAMPLE"
@@ -0,0 +1,34 @@
#!/bin/sh
# shellcheck shell=dash
# Unblock internet access for a device.
# Usage: trafficctl-unblock.sh <ip> [label]
. /usr/local/bin/trafficctl-fw.sh
IP="$1"
LABEL="${2:-block_$IP}"
if [ -z "$IP" ]; then
echo '{"ok":false,"msg":"usage: trafficctl-unblock.sh <ip> [label]"}'
exit 1
fi
if ! tctl_validate_ip "$IP"; then
echo '{"ok":false,"msg":"invalid IP address"}'
exit 1
fi
COMMENT=$(tctl_block_comment "$IP")
# Rules written before comments were derived from the address carry the caller's
# label, so a block placed from LuCI could not be removed from Telegram.
tctl_block_remove "$IP" "tctl_block_${LABEL}" 2>/dev/null
if tctl_block_remove "$IP" "$COMMENT"; then
tctl_persist_enabled && tctl_persist_remove "block" "$IP"
tctl_log "unblock" "$IP" "$LABEL" "${TCTL_VIA:-cli}" "${TCTL_SRC:-local}"
echo "{\"ok\":true,\"msg\":\"internet unblocked for $IP\"}"
else
echo "{\"ok\":false,\"msg\":\"failed to unblock $IP\"}"
exit 1
fi
@@ -0,0 +1,37 @@
{
"admin/status/trafficctl": {
"title": "Traffic Control",
"order": 80,
"action": {
"type": "alias",
"path": "admin/status/trafficctl/status"
},
"depends": {
"acl": [ "luci-app-trafficctl" ]
}
},
"admin/status/trafficctl/status": {
"title": "Devices",
"order": 1,
"action": {
"type": "view",
"path": "trafficctl/status"
},
"depends": {
"acl": [ "luci-app-trafficctl" ]
}
},
"admin/status/trafficctl/portfw": {
"title": "Port Forwards",
"order": 2,
"action": {
"type": "view",
"path": "trafficctl/portfw"
},
"depends": {
"acl": [ "luci-app-trafficctl" ]
}
}
}
@@ -0,0 +1,38 @@
{
"luci-app-trafficctl": {
"description": "Grant access to LuCI Traffic Control",
"read": {
"ubus": {
"luci.trafficctl": [
"version",
"summary", "device", "bytes", "ifaces",
"ratelimit_stats", "shape_stats", "rdns",
"portfw_list", "names_list", "netify_status", "netify_list",
"shape_status",
"config_get", "telegram_config_get",
"logging_config_get", "newdevice_config_get"
],
"file": [ "read" ]
},
"uci": [ "trafficctl", "wireless", "network" ],
"file": {
"/tmp/dhcp.leases": [ "read" ]
}
},
"write": {
"ubus": {
"luci.trafficctl": [
"block", "unblock",
"macfilter_add", "macfilter_remove",
"ratelimit", "shape_add", "shape_remove",
"portfw_ctl", "name_set", "name_clear", "netify_collect",
"config_set",
"telegram_config_set", "telegram_test",
"logging_config_set", "activity_log",
"newdevice_config_set"
]
},
"uci": [ "trafficctl" ]
}
}
}
+32
View File
@@ -0,0 +1,32 @@
#!/bin/sh
# Prometheus scrape endpoint. Inert unless trafficctl.metrics.enabled=1.
#
# This serves device names, addresses and traffic volumes with no LuCI session,
# so it stays disabled by default and supports an optional shared token:
# uci set trafficctl.metrics.token=<secret>
# scrape ... /cgi-bin/trafficctl-metrics?token=<secret>
# Prefer also restricting the port to the LAN in your firewall.
TOKEN=$(uci -q get trafficctl.metrics.token 2>/dev/null)
deny() {
printf 'Status: 403 Forbidden\r\n'
printf 'Content-Type: text/plain\r\n\r\n'
printf '%s\n' "$1"
exit 0
}
[ "$(uci -q get trafficctl.metrics.enabled 2>/dev/null)" = "1" ] \
|| deny "trafficctl metrics disabled"
if [ -n "$TOKEN" ]; then
# QUERY_STRING is the only channel a stock Prometheus scrape config can use
# without custom headers.
case "&${QUERY_STRING}&" in
*"&token=${TOKEN}&"*) ;;
*) deny "invalid or missing token" ;;
esac
fi
printf 'Content-Type: text/plain; version=0.0.4; charset=utf-8\r\n\r\n'
exec /usr/local/bin/trafficctl-metrics.sh