wgctl/commands/activity.command.sh
Nuno Duque Nunes e4545a400b feat: --exclude-service/--include-service, --ports flags
- activity: --exclude-service (repeatable), --include-service override
- activity: --ports flag shows dim raw IP:port on accept and drop rows
- activity_aggregate: dst_ip/dst_port/proto in service row output
- activity_aggregate: exclude_services filtering for drop rows
- ui::activity::_visible_len: ANSI-aware padding for --ports alignment
- service_row/accept_dest_row: correct padding with ANSI suffixes
- accept_dest_row: fix ↓/↑ swap (bytes_reply=download, bytes_orig=upload)
- command defaults: activity defaults with pihole exclusions
2026-05-29 23:34:13 +00:00

503 lines
No EOL
16 KiB
Bash

#!/usr/bin/env bash
# activity.command.sh — WireGuard activity snapshot
# ============================================
# Lifecycle
# ============================================
function cmd::activity::on_load() {
command::mixin json_output
load_module net
flag::register --peer
flag::register --service
flag::register --ip
flag::register --hours
flag::register --type
flag::register --accept
flag::register --drop
flag::register --external
flag::register --ports
flag::register --exclude-service
flag::register --include-service
flag::exclusive --accept --drop
}
# ============================================
# Help
# ============================================
function cmd::activity::help() {
cat <<EOF
Usage: wgctl activity [options]
Show WireGuard activity — transfer totals and firewall drops per peer.
Data sources: wg show transfer, fw_events.log
Options:
--peer <name> Filter by peer name
--service <name> Filter by service (e.g. truenas, proxmox:web-ui)
--ip <ip> Filter by destination IP
--hours <n> Time window in hours (default: 24, 0 = all time)
--type <type> Filter by device type (combined with --peer)
--accept Show only accepted traffic (from conntrack)
--drop Show only firewall drops
--external Show only external traffic (full tunnel peers)
Examples:
wgctl activity
wgctl activity --peer phone-nuno
wgctl activity --service truenas
wgctl activity --hours 0
wgctl activity --ip 10.0.0.101
EOF
}
# ============================================
# Run
# ============================================
function cmd::activity::run() {
local filter_peer="" filter_service="" filter_ip="" filter_type=""
local hours=24
local accept_only=false drop_only=false external_only=false show_ports=false
local -a exclude_services=() include_services=()
while [[ $# -gt 0 ]]; do
case "$1" in
--peer) filter_peer="$2"; shift 2 ;;
--service) filter_service="$2"; shift 2 ;;
--ip) filter_ip="$2"; shift 2 ;;
--type) filter_type="$2"; shift 2 ;;
--hours) hours="$2"; shift 2 ;;
--accept) accept_only=true; shift ;;
--drop) drop_only=true; shift ;;
--external) external_only=true; shift ;;
--ports) show_ports=true; shift ;;
--exclude-service) exclude_services+=("$2"); shift 2 ;;
--include-service) include_services+=("$2"); shift 2 ;;
--help) cmd::activity::help; return ;;
*)
log::error "Unknown flag: $1"
cmd::activity::help
return 1
;;
esac
done
if command::json; then
cmd::activity::_output_json "$hours"
return 0
fi
if [[ -n "$filter_peer" && -n "$filter_type" ]]; then
filter_peer=$(peers::resolve_and_require "$filter_peer" "$filter_type") || return 1
fi
local service_ip=""
if [[ -n "$filter_service" ]]; then
service_ip=$(net::resolve "$filter_service" 2>/dev/null | head -1 | cut -d: -f1) || true
[[ -z "$service_ip" ]] && log::error "Service not found: ${filter_service}" && return 1
fi
[[ -n "$filter_ip" ]] && service_ip="$filter_ip"
# Build final exclusion list — remove any --include-service entries
local -a final_excludes=()
for svc in "${exclude_services[@]:-}"; do
local included=false
for inc in "${include_services[@]:-}"; do
[[ "$svc" == "$inc" ]] && included=true && break
done
$included || final_excludes+=("$svc")
done
# Build exclude string for Python (space-separated)
local exclude_str=""
[[ ${#final_excludes[@]} -gt 0 ]] && \
exclude_str=$(IFS=' '; echo "${final_excludes[*]}")
# ── Fetch data ──
local data=""
if ! $accept_only; then
data=$(json::activity_aggregate \
"$(ctx::fw_events_log)" "$(ctx::events_log)" \
"$(config::interface)" "$(ctx::net)" \
"$(ctx::clients)" "$(ctx::meta)" \
"$hours" "$filter_peer" "$service_ip" "$exclude_str" 2>/dev/null)
fi
local accept_data=""
if ! $drop_only; then
local since_arg="" ext_flag="0"
[[ "$hours" -gt 0 ]] && since_arg="${hours}h"
$external_only && ext_flag="1"
[[ -f "$(ctx::accept_events_log)" ]] && \
accept_data=$(json::accept_aggregate \
"$(ctx::accept_events_log)" "$(ctx::net)" "$(ctx::clients)" \
"$since_arg" "$filter_peer" "$ext_flag" "$exclude_str" 2>/dev/null)
fi
[[ -z "$data" && -z "$accept_data" ]] && \
log::wg_warning "No activity data found" && return 0
# ── Build accept maps ──
declare -gA _ACCEPT_PEER=()
declare -gA _ACCEPT_DEST_KEYS=()
declare -gA _ACCEPT_DEST=()
while IFS='|' read -r type rest; do
[[ -z "$type" ]] && continue
case "$type" in
peer)
local a_name a_bi a_bo a_pi a_po a_conns
IFS='|' read -r a_name a_bi a_bo a_pi a_po a_conns <<< "$rest"
_ACCEPT_PEER["$a_name"]="${a_bi}|${a_bo}|${a_pi}|${a_po}|${a_conns}"
;;
dest)
local d_peer d_ip d_port d_proto d_bytes_orig d_bytes_reply d_count
IFS='|' read -r d_peer d_ip d_port d_proto d_bytes_orig d_bytes_reply d_count <<< "$rest"
local d_key="${d_peer}:${d_ip}:${d_port}:${d_proto}"
_ACCEPT_DEST["$d_key"]="${d_bytes_orig}|${d_bytes_reply}|${d_count}"
_ACCEPT_DEST_KEYS["$d_peer"]+="${d_key} "
;;
esac
done <<< "$accept_data"
# ── Measure column widths ──
local w_peer=16 w_count=1
while IFS='|' read -r type rest; do
case "$type" in
peer)
local name drops
name=$(echo "$rest" | cut -d'|' -f1)
drops=$(echo "$rest" | cut -d'|' -f4)
(( ${#name} > w_peer )) && w_peer=${#name}
(( ${#drops} > w_count )) && w_count=${#drops}
;;
service)
local svc_count
svc_count=$(echo "$rest" | cut -d'|' -f3)
(( ${#svc_count} > w_count )) && w_count=${#svc_count}
;;
esac
done <<< "$data"
for a_name in "${!_ACCEPT_PEER[@]}"; do
(( ${#a_name} > w_peer )) && w_peer=${#a_name}
local a_conns_val="${_ACCEPT_PEER[$a_name]##*|}"
(( ${#a_conns_val} > w_count )) && w_count=${#a_conns_val}
done
for key in "${!_ACCEPT_DEST[@]}"; do
local d_val="${_ACCEPT_DEST[$key]}"
local d_count_val="${d_val##*|}"
(( ${#d_count_val} > w_count )) && w_count=${#d_count_val}
done
(( w_peer += 2 ))
local drops_col=$(( w_peer + 30 ))
local hours_display="${hours}h"
[[ "$hours" == "0" ]] && hours_display="all time"
log::section "Activity Monitor (last ${hours_display})"
echo ""
if display::is_table "activity"; then
cmd::activity::_render_table "$data"
return 0
fi
# ── Accept dest inline renderer ──
_render_peer_accept_dests() {
local peer_name="$1"
local keys="${_ACCEPT_DEST_KEYS[$peer_name]:-}"
[[ -z "$keys" ]] && return 0
for d_key in $keys; do
local dest_stats="${_ACCEPT_DEST[$d_key]:-}"
[[ -z "$dest_stats" ]] && continue
local d_bytes_orig d_bytes_reply d_count
IFS='|' read -r d_bytes_orig d_bytes_reply d_count <<< "$dest_stats"
local rest_key="${d_key#${peer_name}:}"
local d_ip="${rest_key%%:*}"
local pp="${rest_key#*:}"
local d_port="${pp%%:*}"
local d_proto="${pp##*:}"
local spec="${d_ip}:${d_port}:${d_proto}"
local dest_display
local raw_suffix=""
local resolved="${_DEST_RESOLVE_CACHE[$spec]:-${d_ip}:${d_port}/${d_proto}}"
local dest_display="$resolved"
if [[ "$show_ports" == "true" && "$resolved" != "${d_ip}:"* && "$resolved" != "${d_ip} "* ]]; then
if [[ -n "$d_port" && "$d_port" != "0" ]]; then
dest_display=$(printf "%s \033[2m(%s:%s)\033[0m" "$resolved" "$d_ip" "$d_port")
else
dest_display=$(printf "%s \033[2m(%s)\033[0m" "$resolved" "$d_ip")
fi
fi
ui::activity::accept_dest_row \
"$dest_display" "$d_bytes_orig" "$d_bytes_reply" \
"$d_count" "$drops_col" "$w_count"
done
}
declare -gA _DEST_RESOLVE_CACHE=()
local -a _dest_specs=()
for _dk in "${!_ACCEPT_DEST[@]}"; do
# key format: peer:ip:port:proto — strip peer prefix
local _rest="${_dk#*:}"
local _dip="${_rest%%:*}"
local _pp="${_rest#*:}"
local _dport="${_pp%%:*}"
local _dproto="${_pp##*:}"
local _spec="${_dip}:${_dport}:${_dproto}"
# Deduplicate
local _found=false
for _s in "${_dest_specs[@]:-}"; do
[[ "$_s" == "$_spec" ]] && _found=true && break
done
$_found || _dest_specs+=("$_spec")
done
if [[ ${#_dest_specs[@]} -gt 0 ]]; then
while IFS='|' read -r _spec _display; do
[[ -n "$_spec" ]] && _DEST_RESOLVE_CACHE["$_spec"]="$_display"
done < <(json::batch_resolve_dest "${_dest_specs[@]}" 2>/dev/null)
fi
local first_peer=true skip_peer=false current_name=""
local -a rendered_peers=()
# ── Main render loop (drop data) ──
while IFS='|' read -r record_type rest; do
case "$record_type" in
peer)
local name rx tx drops
IFS='|' read -r name rx tx drops <<< "$rest"
# Flush previous peer's accept dests
[[ -n "$current_name" ]] && ! $drop_only && \
_render_peer_accept_dests "$current_name"
skip_peer=false
current_name="$name"
local has_accept="${_ACCEPT_PEER[$name]:-}"
$first_peer || echo ""
first_peer=false
rendered_peers+=("$name")
local rx_fmt tx_fmt
rx_fmt=$(fmt::bytes "$rx")
tx_fmt=$(fmt::bytes "$tx")
local name_pad rx_pad tx_pad
name_pad=$(printf "%-${w_peer}s" "$name")
rx_pad=$(printf "%-10s" "$rx_fmt")
tx_pad=$(printf "%-10s" "$tx_fmt")
local drop_word="drops"
[[ "$drops" -eq 1 ]] && drop_word="drop"
# Always show peer name — either full row or name-only for accept_only
if $accept_only; then
printf " \033[1m%s\033[0m\n" "$name_pad"
else
ui::activity::peer_row \
"$name_pad" "$rx_pad" "$tx_pad" "$drops" "$drop_word" "$w_count"
fi
# Accept summary row
if [[ -n "$has_accept" ]] && ! $drop_only; then
local a_bi a_bo a_pi a_po a_conns
IFS='|' read -r a_bi a_bo a_pi a_po a_conns <<< "$has_accept"
ui::activity::accept_row \
"$name_pad" \
"$(printf '%-10s' "$(fmt::bytes "$a_bi")")" \
"$(printf '%-10s' "$(fmt::bytes "$a_bo")")" \
"$a_conns" "$w_count"
fi
;;
service)
local peer dest_display dst_ip dst_port proto drop_count
IFS='|' read -r peer dest_display dst_ip dst_port proto drop_count <<< "$rest"
# Build dim suffix if --ports
local svc_display="$dest_display"
if [[ "$show_ports" == "true" && -n "$dst_ip" ]]; then
if [[ -n "$dst_port" ]]; then
svc_display=$(printf "%s \033[2m(%s:%s)\033[0m" \
"$dest_display" "$dst_ip" "$dst_port")
else
svc_display=$(printf "%s \033[2m(%s)\033[0m" \
"$dest_display" "$dst_ip")
fi
fi
local svc_drop_word="drops"
[[ "$drop_count" -eq 1 ]] && svc_drop_word="drop"
$accept_only || ui::activity::service_row \
"$svc_display" "$drop_count" "$svc_drop_word" "$drops_col" "$w_count"
;;
esac
done <<< "$data"
# Flush last peer's accept dests
[[ -n "$current_name" ]] && ! $drop_only && \
_render_peer_accept_dests "$current_name"
# ── Accept-only peers (not in drop data) ──
if ! $drop_only; then
for a_name in $(echo "${!_ACCEPT_PEER[@]}" | tr ' ' '\n' | sort); do
# Skip already rendered
local already=false
for rp in "${rendered_peers[@]:-}"; do
[[ "$rp" == "$a_name" ]] && already=true && break
done
$already && continue
$first_peer || echo ""
first_peer=false
local a_stats="${_ACCEPT_PEER[$a_name]}"
local a_bi a_bo a_pi a_po a_conns
IFS='|' read -r a_bi a_bo a_pi a_po a_conns <<< "$a_stats"
local name_pad
name_pad=$(printf "%-${w_peer}s" "$a_name")
# Always show peer name
printf " \033[1m%s\033[0m\n" "$name_pad"
ui::activity::accept_row \
"$name_pad" \
"$(printf '%-10s' "$(fmt::bytes "$a_bi")")" \
"$(printf '%-10s' "$(fmt::bytes "$a_bo")")" \
"$a_conns" "$w_count"
_render_peer_accept_dests "$a_name"
done
fi
echo ""
}
function cmd::activity::_render_table() {
local data="${1:-}"
[[ -z "$data" ]] && return 0
ui::activity::header_table
local skip_peer=false
while IFS='|' read -r record_type rest; do
case "$record_type" in
peer)
local name rx tx drops
IFS='|' read -r name rx tx drops <<< "$rest"
skip_peer=false
local rx_fmt tx_fmt
rx_fmt=$(fmt::bytes "$rx")
tx_fmt=$(fmt::bytes "$tx")
ui::activity::peer_row_table "$name" "$rx_fmt" "$tx_fmt" "$drops" ""
;;
service)
$skip_peer && continue
local peer dest count
IFS='|' read -r peer dest count <<< "$rest"
ui::activity::service_row_table "$dest" "$count" "drops"
;;
esac
done <<< "$data"
}
function cmd::activity::_output_json() {
local hours="${1:-24}"
local data
data=$(json::activity_aggregate \
"$(ctx::fw_events_log)" "$(ctx::events_log)" \
"$(config::interface)" "$(ctx::net)" \
"$(ctx::clients)" "$(ctx::meta)" \
"$hours" "" "" 2>/dev/null)
local -a peers=()
local current_peer="" current_services=""
local -a current_svc_list=()
while IFS='|' read -r record_type rest; do
case "$record_type" in
peer)
# Flush previous peer
if [[ -n "$current_peer" ]]; then
local svc_array
svc_array=$(printf '%s\n' "${current_svc_list[@]:-}" | paste -sd ',' -)
peers+=("${current_peer},\"services\":[${svc_array:-}]}")
current_svc_list=()
fi
local name rx tx drops
IFS='|' read -r name rx tx drops <<< "$rest"
current_peer=$(printf '{"name":"%s","rx":%s,"tx":%s,"drops":%s' \
"$name" "$rx" "$tx" "$drops")
;;
service)
local peer dest count
IFS='|' read -r peer dest count <<< "$rest"
current_svc_list+=("$(printf '{"dest":"%s","drops":%s}' "$dest" "$count")")
;;
esac
done <<< "$data"
# Flush last peer
if [[ -n "$current_peer" ]]; then
local svc_array
svc_array=$(printf '%s\n' "${current_svc_list[@]:-}" | paste -sd ',' -)
peers+=("${current_peer},\"services\":[${svc_array:-}]}")
fi
local count=${#peers[@]}
local array
array=$(printf '%s\n' "${peers[@]:-}" | paste -sd ',' -)
printf '{"peers":[%s]}' "${array:-}" | json::envelope "activity" "$count"
}
function cmd::activity::_fetch_accept_data() {
local hours="${1:-24}" filter_peer="${2:-}" external_only="${3:-false}"
[[ ! -f "$(ctx::accept_events_log)" ]] && return 0
local since_arg=""
[[ "$hours" -gt 0 ]] && since_arg="${hours}h"
local ext_flag="0"
$external_only && ext_flag="1"
json::accept_aggregate \
"$(ctx::accept_events_log)" \
"$(ctx::net)" \
"$(ctx::clients)" \
"$since_arg" \
"$filter_peer" \
2>/dev/null
}
function cmd::activity::_build_accept_maps() {
local accept_data="${1:-}"
# Outputs to stdout as bash declare statements — use eval
# Sets: _ACCEPT_PEER[name]="bytes_in|bytes_out|packets_in|packets_out|conn_count"
# _ACCEPT_DEST[name:ip:port:proto]="bytes|count"
declare -gA _ACCEPT_PEER=()
declare -gA _ACCEPT_DEST=()
while IFS='|' read -r type rest; do
[[ -z "$type" ]] && continue
case "$type" in
peer)
local name bytes_in bytes_out packets_in packets_out conn_count
IFS='|' read -r name bytes_in bytes_out packets_in packets_out conn_count <<< "$rest"
_ACCEPT_PEER["$name"]="${bytes_in}|${bytes_out}|${packets_in}|${packets_out}|${conn_count}"
;;
dest)
local peer dst_ip dst_port proto bytes count
IFS='|' read -r peer dst_ip dst_port proto bytes count <<< "$rest"
_ACCEPT_DEST["${peer}:${dst_ip}:${dst_port}:${proto}"]="${bytes}|${count}"
;;
esac
done <<< "$accept_data"
}