mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-02 01:43:56 +08:00
🤞 Sync 2026-09-27 22:16:29
This commit is contained in:
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
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
Reference in new issue
Block a user