💐 Sync 2026-09-29 21:24:56

This commit is contained in:
github-actions[bot] committed 2026-09-29 21:24:56 +08:00
1 parent ac66545478
commit ed79a5268f
87 files changed
+351 -75

No files matched your search

@@ -374,8 +374,13 @@ if [ "$DO_RDNS" = "1" ]; then
fi
fi
# Listed as blocked yet associated right now = the radio never got the block.
WIFI_PENDING=false
tctl_wifi_block_pending "$([ "$WIFI_BLOCKED" = "true" ] && echo 1 || echo 0)" "$CONN_TYPE" \
&& WIFI_PENDING=true
# Output final JSON
printf '{"ip":"%s","name":"%s","mac":"%s","conn_type":"%s","conn_last":"%s","timestamp":%d,"blocked":%s,"block_packets":%d,"block_bytes":%.0f,"wifi_blocked":%s,"total":%.0f,"protocols":{"tcp":%d,"udp":%d,"other":%d},"tcp_states":{"established":%d,"time_wait":%d,"syn_sent":%d,"close_wait":%d},"connections":[%s],"rate_limit_kbit":%d,"shape_kbit":%d}\n' \
printf '{"ip":"%s","name":"%s","mac":"%s","conn_type":"%s","conn_last":"%s","timestamp":%d,"blocked":%s,"block_packets":%d,"block_bytes":%.0f,"wifi_blocked":%s,"wifi_block_pending":%s,"total":%.0f,"protocols":{"tcp":%d,"udp":%d,"other":%d},"tcp_states":{"established":%d,"time_wait":%d,"syn_sent":%d,"close_wait":%d},"connections":[%s],"rate_limit_kbit":%d,"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" \
"$WIFI_BLOCKED" "$WIFI_PENDING" "$TOTAL" "$N_TCP" "$N_UDP" "$N_OTHER" \
"$EST" "$TW" "$SS" "$CW" "$CONNS_OUT" "$RATE_LIM" "$SHAPE_KBIT"
@@ -418,12 +418,24 @@ 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)
# Running AP interface names, one per line (e.g. phy0-ap0), taken from the
# hostapd ubus objects.
#
# Returns non-zero when ubus itself could not be consulted. That is NOT the
# same as "no AP is running", and callers must not read an empty list as
# "nothing to enforce" unless the query actually succeeded — doing so would
# turn a broken router into a silent "blocked".
tctl_get_hostapd_ifaces() {
ubus list 2>/dev/null | grep '^hostapd\.' | cut -d. -f2
local out
command -v ubus >/dev/null 2>&1 || return 1
out=$(ubus list 2>/dev/null) || return 1
# An empty result is a valid answer -- "no AP is running" -- so the grep
# matching nothing must not be reported as a failed query. Only ubus itself
# failing, above, is that.
printf '%s\n' "$out" | grep '^hostapd\.' | cut -d. -f2-
return 0
}
# 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"
@@ -434,51 +446,187 @@ tctl_get_wifi_filter_mode() {
[ "$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.
# ── Runtime WiFi enforcement ──────────────────────────────────────────────
#
# The uci maclist is the durable half of a WiFi block; hostapd's running ACL is
# the half that decides whether the device is on the air *now*. The two drift
# apart whenever the runtime call cannot be made, and for a long time that
# drift was invisible: the calls below were fire-and-forget, their exit status
# discarded, so a router without hostapd-utils reported every block as done
# while the device kept browsing.
#
# So every entry point here reports how far enforcement actually got, as one
# of these words, and proves it by reading the state back rather than trusting
# a return code:
#
# acl - the running ACL was changed and the change was read back
# ban - no usable hostapd_cli; hostapd's ubus deauthed and banned the
# client, which expires on its own (see TCTL_WIFI_BAN_MS)
# none - nothing could be applied or verified on the running radio
# no-radio - ubus answered and no AP is running, so there is nothing to
# enforce; the uci maclist applies when wifi next starts
#
# Only "acl" and "no-radio" mean the operator's intent is in force, and the
# functions return 0 for exactly those two.
# How long a ubus ban lasts, in milliseconds. Used only where hostapd_cli is
# unavailable: hostapd's ubus object has no ACL method (del_client, list_bans,
# get_clients, reload), so a timed ban is the strongest immediate measure it
# can offer. One hour is long enough to be worth doing and short enough that
# callers must keep calling it temporary rather than done.
TCTL_WIFI_BAN_MS=3600000
# hostapd_cli exists AND this AP's control socket answers. Both halves matter:
# the package can be absent (the router this bug was found on), or present
# while hostapd is not listening, and only a PONG shows the ACL commands will
# reach anything.
tctl_hostapd_cli_alive() {
command -v hostapd_cli >/dev/null 2>&1 || return 1
hostapd_cli -i "$1" ping 2>/dev/null | grep -q PONG
}
# Does the running ACL currently keep this MAC off the air? Read back from
# hostapd instead of inferred from our own call: hostapd_cli exits 0 for
# "command delivered", and an ACL that stays empty afterwards is exactly the
# failure being guarded against. hostapd also reloads its maclist file at times
# of its own choosing, so the entry present may not be the one we added — which
# is fine, and another reason to ask rather than assume.
tctl_hostapd_acl_blocks() {
local iface="$1" mac="$2" mode="$3" acl
if [ "$mode" = "allow" ]; then
acl=$(hostapd_cli -i "$iface" accept_acl SHOW 2>/dev/null)
! printf '%s\n' "$acl" | grep -qi "$mac"
else
acl=$(hostapd_cli -i "$iface" deny_acl SHOW 2>/dev/null)
printf '%s\n' "$acl" | grep -qi "$mac"
fi
}
tctl_hostapd_ubus_banned() {
local iface="$1" mac="$2"
command -v ubus >/dev/null 2>&1 || return 1
ubus call "hostapd.$iface" list_bans 2>/dev/null | grep -qi "$mac"
}
# Fallback when hostapd_cli is unusable. del_client deauthenticates and refuses
# the client for ban_time, which is not an ACL entry — the durable half stays
# the uci maclist. The call's own exit status is deliberately ignored: it can
# fail for a client that is not currently associated while the ban still lands,
# and list_bans is the only answer worth having.
tctl_hostapd_ubus_ban() {
local iface="$1" mac="$2"
command -v ubus >/dev/null 2>&1 || return 1
ubus call "hostapd.$iface" del_client \
"{\"addr\":\"$mac\",\"reason\":1,\"deauth\":true,\"ban_time\":$TCTL_WIFI_BAN_MS}" \
>/dev/null 2>&1
tctl_hostapd_ubus_banned "$iface" "$mac"
}
# acl > ban > none, so a loop over several APs can keep the worst result.
tctl_enforce_rank() {
case "$1" in
acl) echo 3 ;;
ban) echo 2 ;;
*) echo 1 ;;
esac
}
tctl_hostapd_block_iface() {
local iface="$1" mac="$2" mode="$3"
if tctl_hostapd_cli_alive "$iface"; then
if [ "$mode" = "allow" ]; then
hostapd_cli -i "$iface" accept_acl DEL_MAC "$mac" >/dev/null 2>&1
else
hostapd_cli -i "$iface" deny_acl ADD_MAC "$mac" >/dev/null 2>&1
fi
hostapd_cli -i "$iface" deauthenticate "$mac" >/dev/null 2>&1
if tctl_hostapd_acl_blocks "$iface" "$mac" "$mode"; then
echo acl
return
fi
fi
if tctl_hostapd_ubus_ban "$iface" "$mac"; then
echo ban
return
fi
echo none
}
tctl_hostapd_unblock_iface() {
local iface="$1" mac="$2" mode="$3"
if tctl_hostapd_cli_alive "$iface"; then
if [ "$mode" = "allow" ]; then
hostapd_cli -i "$iface" accept_acl ADD_MAC "$mac" >/dev/null 2>&1
else
hostapd_cli -i "$iface" deny_acl DEL_MAC "$mac" >/dev/null 2>&1
fi
if ! tctl_hostapd_acl_blocks "$iface" "$mac" "$mode"; then
# The ACL permits the MAC again, but a ban left over from an
# earlier hostapd_cli-less block would still keep it off the air,
# and there is no ubus method to lift one.
if tctl_hostapd_ubus_banned "$iface" "$mac"; then
echo ban
else
echo acl
fi
return
fi
fi
if tctl_hostapd_ubus_banned "$iface" "$mac"; then
echo ban
return
fi
echo none
}
# Applies $2 ("allow"/"deny" ACL policy) to every running AP and echoes the
# worst per-AP outcome. Always safe to re-run: it enforces from the runtime
# state, so calling it on a MAC that uci already lists is how a block that was
# only ever written to config gets applied for real.
tctl_hostapd_apply_mac() {
local op="$1" mac="$2" mode="$3"
local ifaces iface state rank worst=3
ifaces=$(tctl_get_hostapd_ifaces) || { echo none; return 1; }
if [ -z "$ifaces" ]; then
echo no-radio
return 0
fi
for iface in $ifaces; do
case "$op" in
block) state=$(tctl_hostapd_block_iface "$iface" "$mac" "$mode") ;;
*) state=$(tctl_hostapd_unblock_iface "$iface" "$mac" "$mode") ;;
esac
rank=$(tctl_enforce_rank "$state")
[ "$rank" -lt "$worst" ] && worst="$rank"
done
case "$worst" in
3) echo acl; return 0 ;;
2) echo ban ;;
*) echo none ;;
esac
return 1
}
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
tctl_hostapd_apply_mac block "$1" "$2"
}
# 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_apply_mac unblock "$1" "$2"
}
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
# A device listed as WiFi-blocked while it is associated on a radio right now
# is proof the running ACL does not carry the block — the state a silent
# enforcement failure leaves a router in. Keyed off conn_type rather than the
# station dump so it still holds where iw is missing and the connection type
# came from the bridge port instead.
tctl_wifi_block_pending() {
local blocked="$1" conn_type="$2"
[ "$blocked" = "1" ] || return 1
case "$conn_type" in
wifi|2.4G|5G|6G) return 0 ;;
esac
return 1
}
# ── Persistence ───────────────────────────────────────────────────────────
@@ -1,7 +1,9 @@
#!/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.
# The uci maclist is the durable half; hostapd is then programmed at runtime so
# only the target client is kicked, with no wifi reload. Reports how far that
# runtime half actually got — see the enforcement words in trafficctl-fw.sh.
# Usage: trafficctl-macfilter-add.sh <ip>
. /usr/local/bin/trafficctl-fw.sh
@@ -76,11 +78,41 @@ for iface in $IFACES; do
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
[ "$CHANGED" = "1" ] && uci commit wireless
tctl_log "wifi_block" "$IP" "MAC=$MAC" "${TCTL_VIA:-cli}" "${TCTL_SRC:-local}"
echo "{\"ok\":true,\"msg\":\"MAC $MAC blocked on wifi for $IP\"}"
# Applied unconditionally, not just when uci needed editing. Config and runtime
# are two pieces of state: a router whose maclist already names the MAC but
# whose radio never got the ACL entry used to make this a no-op that still
# reported success, so pressing Block again — the obvious reaction to "it did
# not work" — could never recover it.
ENFORCE=$(tctl_hostapd_block_mac "$MAC" "$MODE")
BAN_MIN=$(( TCTL_WIFI_BAN_MS / 60000 ))
# The uci entry is kept even when the radio could not be programmed. It is the
# only durable record of the operator's intent and it does take effect at the
# next wifi restart, so discarding it would throw away the half that worked and
# leave the device unblocked forever. What made keeping it dangerous was the
# claim of success, not the entry — so the claim is what goes. A wifi reload
# would apply it now, but it disconnects every client on the radio, which is
# not something a per-device click should do behind the operator's back.
case "$ENFORCE" in
acl)
OK=true
MSG="MAC $MAC blocked on wifi for $IP"
;;
no-radio)
OK=true
MSG="MAC $MAC added to the wifi deny list for $IP; no radio is running, it applies when wifi starts"
;;
ban)
OK=false
MSG="MAC $MAC is NOT permanently blocked: hostapd_cli is missing, so the block is temporary ($BAN_MIN min). Install hostapd-utils, or restart wifi (drops all clients)"
;;
*)
OK=false
MSG="MAC $MAC saved to the wifi deny list but NOT applied to the radio, so the device stays online. Install hostapd-utils, or restart wifi (drops all clients)"
;;
esac
tctl_log "wifi_block" "$IP" "MAC=$MAC enforce=$ENFORCE" "${TCTL_VIA:-cli}" "${TCTL_SRC:-local}"
printf '{"ok":%s,"enforcement":"%s","msg":"%s"}\n' "$OK" "$ENFORCE" "$MSG"
@@ -1,7 +1,9 @@
#!/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.
# Updates the uci maclist and the running hostapd ACL, so the client can
# reassociate without a wifi reload and other clients stay connected. Reports
# how far the runtime half got — see the enforcement words in trafficctl-fw.sh.
# Usage: trafficctl-macfilter-remove.sh <ip>
. /usr/local/bin/trafficctl-fw.sh
@@ -68,11 +70,32 @@ for iface in $IFACES; do
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
[ "$CHANGED" = "1" ] && uci commit wireless
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\"}"
# Unconditional for the same reason as the add path, and the stakes are higher
# here: a MAC that uci no longer lists but the radio still denies leaves a real
# person off the network while the UI says they are not blocked.
ENFORCE=$(tctl_hostapd_unblock_mac "$MAC" "$MODE")
BAN_MIN=$(( TCTL_WIFI_BAN_MS / 60000 ))
case "$ENFORCE" in
acl)
OK=true
MSG="MAC $MAC removed from wifi filter for $IP"
;;
no-radio)
OK=true
MSG="MAC $MAC removed from the wifi filter for $IP; no radio is running"
;;
ban)
OK=false
MSG="MAC $MAC removed from the wifi filter, but hostapd still bans it for up to $BAN_MIN min. Restart wifi (drops all clients) to clear it now"
;;
*)
OK=false
MSG="MAC $MAC removed from the wifi filter, but the radio ACL could not be read back: it may still be blocked. Install hostapd-utils, or restart wifi"
;;
esac
tctl_log "wifi_unblock" "$IP" "MAC=$MAC enforce=$ENFORCE" "${TCTL_VIA:-cli}" "${TCTL_SRC:-local}"
printf '{"ok":%s,"enforcement":"%s","msg":"%s"}\n' "$OK" "$ENFORCE" "$MSG"
@@ -436,16 +436,21 @@ for ip in $ACTIVE_IPS; do
fi
fi
# Needs CONN_TYPE, so it is decided here rather than next to WIFI_BLK.
WIFI_PENDING=0
tctl_wifi_block_pending "$WIFI_BLK" "$CONN_TYPE" && WIFI_PENDING=1
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}' \
printf '{"ip":"%s","name":"%s","mac":"%s","conn_type":"%s","conn_last":"%s","app":"%s","conns":%d,"total":%.0f,"tcp":%.0f,"udp":%.0f,"blocked":%s,"block_bytes":%.0f,"wifi_blocked":%s,"wifi_block_pending":%s,"rate_limit_kbit":%.0f,"shape_kbit":%.0f}' \
"$ip" "$NAME" "$MAC" "$CONN_TYPE" "$CONN_LAST" "$APP" "$CONNS" "$TOTAL" "$TCP" "$UDP" \
"$([ "$BLOCKED" = "1" ] && echo true || echo false)" \
"$BLOCK_BYTES" \
"$([ "$WIFI_BLK" = "1" ] && echo true || echo false)" \
"$([ "$WIFI_PENDING" = "1" ] && echo true || echo false)" \
"$RATE_LIM" "$SHAPE"
done
printf "]\n"
@@ -97,6 +97,14 @@ tg_send() {
tg_answer_cb() {
local cb_id="$1" text="$2"
# The Bot API caps a callback toast at 200 characters and rejects the whole
# call when it is longer — so an over-long message does not get trimmed, it
# vanishes. Backend results carrying remediation advice can reach that
# length, and a silently missing answer is exactly the failure mode this
# plugin is being fixed for.
if [ "${#text}" -gt 200 ]; then
text="$(printf '%.196s' "$text")..."
fi
text=$(tg_json_escape "$text")
tg_api "answerCallbackQuery" \
"$(printf '{"callback_query_id":"%s","text":"%s"}' "$cb_id" "$text")" >/dev/null