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
This commit is contained in:
Nuno Duque Nunes 2026-05-29 23:34:13 +00:00
parent 10ea174e44
commit 13f9941ebd
8 changed files with 201 additions and 82 deletions

View file

@ -18,6 +18,9 @@ function cmd::activity::on_load() {
flag::register --accept
flag::register --drop
flag::register --external
flag::register --ports
flag::register --exclude-service
flag::register --include-service
flag::exclusive --accept --drop
}
@ -59,7 +62,8 @@ EOF
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
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
@ -71,6 +75,9 @@ function cmd::activity::run() {
--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"
@ -96,6 +103,21 @@ function cmd::activity::run() {
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
@ -103,7 +125,7 @@ function cmd::activity::run() {
"$(ctx::fw_events_log)" "$(ctx::events_log)" \
"$(config::interface)" "$(ctx::net)" \
"$(ctx::clients)" "$(ctx::meta)" \
"$hours" "$filter_peer" "$service_ip" 2>/dev/null)
"$hours" "$filter_peer" "$service_ip" "$exclude_str" 2>/dev/null)
fi
local accept_data=""
@ -114,7 +136,7 @@ function cmd::activity::run() {
[[ -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)
"$since_arg" "$filter_peer" "$ext_flag" "$exclude_str" 2>/dev/null)
fi
[[ -z "$data" && -z "$accept_data" ]] && \
@ -204,7 +226,17 @@ function cmd::activity::run() {
local d_port="${pp%%:*}"
local d_proto="${pp##*:}"
local spec="${d_ip}:${d_port}:${d_proto}"
local dest_display="${_DEST_RESOLVE_CACHE[$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"
@ -289,14 +321,23 @@ function cmd::activity::run() {
;;
service)
$skip_peer && continue
$accept_only && continue
local peer dest_display drop_count
IFS='|' read -r peer dest_display drop_count <<< "$rest"
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"
ui::activity::service_row \
"$dest_display" "$drop_count" "$svc_drop_word" "$drops_col" "$w_count"
$accept_only || ui::activity::service_row \
"$svc_display" "$drop_count" "$svc_drop_word" "$drops_col" "$w_count"
;;
esac
done <<< "$data"

View file

@ -89,7 +89,7 @@ function command::run() {
# Preprocess mixin flags (--json, --no-color etc)
command::_preprocess_flags args
echo "DEBUG cmd=$cmd groups='$groups' defs='${default_args[*]}' user='${user_args[*]:-}'" >&2
local fn
fn=$(command::fn "$cmd" run)
core::call_function "$fn" ${args[@]+"${args[@]}"}

View file

@ -2148,7 +2148,8 @@ commands = {
args[0], args[1], args[2], args[3], args[4],
args[5], args[6] if len(args) > 6 else '24',
args[7] if len(args) > 7 else '',
args[8] if len(args) > 8 else ''),
args[8] if len(args) > 8 else '',
args[9] if len(args) > 9 else ''),
# Rules
'rule_resolve': lambda args: rule_resolve(args[0], args[1]),
'rule_resolve_field':lambda args: rule_resolve_field(args[0], args[1], args[2]),
@ -2302,13 +2303,14 @@ commands = {
args[5] if len(args) > 5 else '1',
args[6] if len(args) > 6 else '',
args[7] if len(args) > 7 else '0',
args[8] if len(args) > 8 else 'desc'),
args[8] if len(args) > 8 else 'desc'),
'accept_aggregate': lambda args: __import__('lib.accept_events',
fromlist=['accept_aggregate']).accept_aggregate(
args[0], args[1], args[2],
args[3] if len(args) > 3 else '',
args[4] if len(args) > 4 else '',
args[5] if len(args) > 5 else '0'),
args[5] if len(args) > 5 else '0',
args[6] if len(args) > 6 else ''),
'batch_resolve_dest': lambda args: batch_resolve_dest(args[0], args[1], *args[2:]),
}

View file

@ -131,7 +131,7 @@ def accept_events(file, filter_peer, filter_type, net_file,
def accept_aggregate(file, net_file, clients_dir, since='',
filter_peer='', external_only='0'):
filter_peer='', external_only='0', exclude_services=''):
"""
Aggregate accept events per peer total bytes, packets, top destinations.
Used by wgctl activity to show accepted traffic alongside drops.
@ -145,6 +145,7 @@ def accept_aggregate(file, net_file, clients_dir, since='',
"""
from collections import defaultdict
from itertools import groupby
from lib.util import load_net_data, hosts_lookup, reverse_lookup
since_dt = parse_since(since) if since else None
show_external = str(external_only) == '1'
@ -157,6 +158,14 @@ def accept_aggregate(file, net_file, clients_dir, since='',
# dest_stats = defaultdict(lambda: {'bytes': 0, 'count': 0})
dest_stats = defaultdict(lambda: {'bytes_orig': 0, 'bytes_reply': 0, 'count': 0})
# Build exclusion set — supports service names and ip:port:proto
exclude_set = set()
if exclude_services:
for svc in exclude_services.split():
exclude_set.add(svc.strip())
net_data = load_net_data(net_file) if (net_file and exclude_set) else {}
try:
with open(file) as f:
for line in f:
@ -201,6 +210,9 @@ def accept_aggregate(file, net_file, clients_dir, since='',
ps['packets_in'] += p_reply
ps['conn_count'] += 1
if _is_excluded(dst_ip, dst_port, proto, exclude_set, net_data):
continue
dest_key = (peer, dst_ip, dst_port, proto)
dest_stats[dest_key]['bytes_orig'] += b_orig
dest_stats[dest_key]['bytes_reply'] += b_reply
@ -226,3 +238,23 @@ def accept_aggregate(file, net_file, clients_dir, since='',
for (p, dst_ip, dst_port, proto), stats in top:
print(f"dest|{p}|{dst_ip}|{dst_port}|{proto}|"
f"{stats['bytes_orig']}|{stats['bytes_reply']}|{stats['count']}")
def _is_excluded(ip, port, proto, exclude_set, net_data):
if not exclude_set:
return False
# Check raw ip:port:proto
if f"{ip}:{port}:{proto}" in exclude_set:
return True
# Check service name
svc = reverse_lookup(net_data, ip, str(port), proto) if net_data else ''
if svc and svc in exclude_set:
return True
# Check service:proto format (e.g. "pihole:dns-udp" -> "pihole" + "dns-udp")
if svc:
for excl in exclude_set:
if ':' in excl:
excl_svc, excl_port = excl.rsplit(':', 1)
if excl_svc == svc and excl_port in (f"{proto}-{port}", f"dns-{proto}"):
return True
return False

View file

@ -19,18 +19,24 @@ from lib.util import (
def activity_aggregate(fw_file, wg_file, wg_interface, net_file,
clients_dir, meta_dir, hours, filter_peer,
filter_service_ip):
filter_service_ip, exclude_services=''):
"""
Aggregate activity data for wgctl activity.
Output:
peer|name|rx_bytes|tx_bytes|drop_count
service|peer_name|dest_display|drop_count
service|peer_name|dest_display|dst_ip|dst_port|proto|drop_count
"""
hours = int(hours) if hours else 24
cutoff = None
if hours > 0:
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
# Build exclusion set
exclude_set = set()
if exclude_services:
for svc in exclude_services.split():
exclude_set.add(svc.strip())
# Preload lookups once
ip_to_peer = build_ip_to_name(clients_dir)
pubkey_to_peer = build_pubkey_to_name(clients_dir)
@ -39,6 +45,23 @@ def activity_aggregate(fw_file, wg_file, wg_interface, net_file,
def _reverse(dest_ip, dest_port, proto):
return reverse_lookup(net_data, dest_ip, dest_port, proto)
def _is_excluded(ip, port, proto, svc_name):
if not exclude_set:
return False
if f"{ip}:{port}:{proto}" in exclude_set:
return True
if svc_name and svc_name in exclude_set:
return True
if svc_name:
for excl in exclude_set:
if ':' in excl:
excl_svc, excl_port = excl.rsplit(':', 1)
if excl_svc == svc_name and excl_port in (
f"{proto}-{port}", f"dns-{proto}", f"dns-udp", f"dns-tcp"
):
return True
return False
# WireGuard transfer totals
peer_rx = defaultdict(int)
peer_tx = defaultdict(int)
@ -59,6 +82,7 @@ def activity_aggregate(fw_file, wg_file, wg_interface, net_file,
pass
# Parse fw_events for drops
# service_drops[peer][(dest_display, dst_ip, dst_port, proto)] = count
peer_drops = defaultdict(int)
service_drops = defaultdict(lambda: defaultdict(int))
@ -102,8 +126,13 @@ def activity_aggregate(fw_file, wg_file, wg_interface, net_file,
svc_name = _reverse(dest_ip, dest_port, proto)
dest_display = make_dest_display(dest_ip, dest_port, proto, svc_name)
if _is_excluded(dest_ip, dest_port, proto, svc_name):
continue
peer_drops[peer] += 1
service_drops[peer][dest_display] += 1
# Key includes raw ip:port:proto for --ports support
svc_key = (dest_display, dest_ip, dest_port, proto)
service_drops[peer][svc_key] += 1
except Exception:
continue
@ -125,5 +154,6 @@ def activity_aggregate(fw_file, wg_file, wg_interface, net_file,
print(f"peer|{peer}|{rx}|{tx}|{drops}")
svc_map = service_drops.get(peer, {})
for dest_display, count in sorted(svc_map.items(), key=lambda x: -x[1]):
print(f"service|{peer}|{dest_display}|{count}")
for (dest_display, dst_ip, dst_port, proto), count in \
sorted(svc_map.items(), key=lambda x: -x[1]):
print(f"service|{peer}|{dest_display}|{dst_ip}|{dst_port}|{proto}|{count}")

View file

@ -1,7 +1,6 @@
#!/usr/bin/env bash
# ui/activity.module.sh — rendering for wgctl activity
# ui::activity::peer_row <name_pad> <rx_pad> <tx_pad> <drops> <drop_word> <w_drops>
function ui::activity::peer_row() {
local name_pad="${1:-}" rx_pad="${2:-}" tx_pad="${3:-}" \
drops="${4:-0}" drop_word="${5:-drops}" w_drops="${6:-1}"
@ -10,39 +9,32 @@ function ui::activity::peer_row() {
"$name_pad" "$rx_pad" "$tx_pad" "$drops" "$drop_word"
}
# ui::activity::service_row <dest_display> <drop_count> <drop_word> <drops_col> <w_drops>
# ── _strip_ansi <string> → visible string
# Used for measuring visible length of strings that may contain ANSI codes
function ui::activity::_visible_len() {
local s="$1"
printf "%b" "$s" | sed 's/\x1b\[[0-9;]*m//g' | wc -m | tr -d ' '
}
# ui::activity::service_row
# dest_display may contain ANSI (when --ports passes dim suffix)
function ui::activity::service_row() {
local dest_display="${1:-}" drop_count="${2:-0}" drop_word="${3:-drops}" \
drops_col="${4:-30}" w_count="${5:-1}"
local arrow_prefix=" → "
local prefix_bytes=${#arrow_prefix}
local prefix_len=$(( prefix_bytes + ${#dest_display} ))
# Measure visible length of dest (strip ANSI for correct padding)
local dest_visible_len
dest_visible_len=$(ui::activity::_visible_len "$dest_display")
local prefix_len=$(( prefix_bytes + dest_visible_len ))
local pad_n=$(( drops_col - prefix_len ))
[[ $pad_n -lt 1 ]] && pad_n=1
printf " \033[0;31m→ %s%*s %${w_count}s %s\033[0m\n" \
printf " \033[0;31m→\033[0m \033[0;31m%b\033[0m%*s \033[0;31m%${w_count}s %s\033[0m\n" \
"$dest_display" "$pad_n" "" "$drop_count" "$drop_word"
}
# Table versions (kept for future display config)
function ui::activity::header_table() {
printf "\n %-24s %-14s %-14s %s\n" "PEER" "↓ RX" "↑ TX" "DROPS"
printf " %s\n" "$(printf '─%.0s' {1..65})"
}
function ui::activity::peer_row_table() {
local name="${1:-}" rx_fmt="${2:-}" tx_fmt="${3:-}" \
drops="${4:-0}" drop_word="${5:-drops}"
printf " %-24s %-14s %-14s %s %s\n" \
"$name" "$rx_fmt" "$tx_fmt" "$drops" "$drop_word"
}
function ui::activity::service_row_table() {
local dest_display="${1:-}" drop_count="${2:-0}" drop_word="${3:-drops}"
printf " → %-30s %s %s\n" "$dest_display" "$drop_count" "$drop_word"
}
function ui::activity::accept_row() {
local name_pad="${1:-}" bytes_in="${2:-}" bytes_out="${3:-}" \
conns="${4:-0}" w_count="${5:-4}"
@ -57,7 +49,6 @@ function ui::activity::accept_row() {
"$spaces" "$bytes_in" "$bytes_out" "$conns" "$conn_word"
}
function ui::activity::accept_dest_row() {
local dest="${1:-}" bytes_orig="${2:-0}" bytes_reply="${3:-0}" \
count="${4:-0}" drops_col="${5:-40}" w_count="${6:-4}"
@ -67,19 +58,42 @@ function ui::activity::accept_dest_row() {
local arrow_prefix=" → "
local prefix_bytes=${#arrow_prefix}
local prefix_len=$(( prefix_bytes + ${#dest} ))
# Measure visible length of dest (strip ANSI for correct padding)
local dest_visible_len
dest_visible_len=$(ui::activity::_visible_len "$dest")
local prefix_len=$(( prefix_bytes + dest_visible_len ))
local pad_n=$(( drops_col - prefix_len ))
[[ $pad_n -lt 1 ]] && pad_n=1
# Only show bytes if non-zero
# Build bytes display
local bytes_display=""
if [[ "$bytes_orig" -gt 0 || "$bytes_reply" -gt 0 ]]; then
local bytes_display=" "
[[ "$bytes_orig" -gt 0 ]] && bytes_display+="$(fmt::bytes "$bytes_orig") "
[[ "$bytes_reply" -gt 0 ]] && bytes_display+="$(fmt::bytes "$bytes_reply")"
bytes_display="${bytes_display% }" # trim trailing space
bytes_display=" "
[[ "$bytes_reply" -gt 0 ]] && bytes_display+="$(fmt::bytes "$bytes_reply") "
[[ "$bytes_orig" -gt 0 ]] && bytes_display+="$(fmt::bytes "$bytes_orig")"
bytes_display="${bytes_display% }"
fi
printf " \033[0;32m→\033[0m \033[0;32m%s%*s %${w_count}s %-5s%s\033[0m\n" \
# Use %b for dest to interpret ANSI, keep rest as %s/%d
printf " \033[0;32m→\033[0m \033[0;32m%b\033[0m%*s \033[0;32m%${w_count}s %-5s\033[0m%s\n" \
"$dest" "$pad_n" "" "$count" "$conn_word" "$bytes_display"
}
# ── Table versions ──────────────────────────────────────
function ui::activity::header_table() {
printf "\n %-24s %-14s %-14s %s\n" "PEER" "↓ RX" "↑ TX" "DROPS"
printf " %s\n" "$(printf '─%.0s' {1..65})"
}
function ui::activity::peer_row_table() {
local name="${1:-}" rx_fmt="${2:-}" tx_fmt="${3:-}" \
drops="${4:-0}" drop_word="${5:-drops}"
printf " %-24s %-14s %-14s %s %s\n" \
"$name" "$rx_fmt" "$tx_fmt" "$drops" "$drop_word"
}
function ui::activity::service_row_table() {
local dest_display="${1:-}" drop_count="${2:-0}" drop_word="${3:-drops}"
printf " → %-30s %s %s\n" "$dest_display" "$drop_count" "$drop_word"
}