#!/usr/bin/env bash # activity.command.sh — WireGuard activity snapshot # ============================================ # Lifecycle # ============================================ function cmd::activity::on_load() { 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 command::mixin json_output } # ============================================ # Help # ============================================ function cmd::activity::help() { cat < Filter by peer name --service Filter by service (e.g. truenas, proxmox:web-ui) --ip Filter by destination IP --hours Time window in hours (default: 24, 0 = all time) --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 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 ;; --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" # ── 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" 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" 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 dest_display dest_display=$(resolve::dest "$d_ip" "$d_port" "$d_proto" 2>/dev/null \ || echo "${d_ip}:${d_port}/${d_proto}") ui::activity::accept_dest_row \ "$dest_display" "$d_bytes_orig" "$d_bytes_reply" \ "$d_count" "$drops_col" "$w_count" done } 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) $skip_peer && continue $accept_only && continue local peer dest_display drop_count IFS='|' read -r peer dest_display drop_count <<< "$rest" local svc_drop_word="drops" [[ "$drop_count" -eq 1 ]] && svc_drop_word="drop" ui::activity::service_row \ "$dest_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" }