Compare commits

..

5 commits

Author SHA1 Message Date
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
Nuno Duque Nunes
10ea174e44 Merge feature/command-defaults: defaults, aliases, exclusive flags 2026-05-29 15:52:41 +00:00
Nuno Duque Nunes
9c11152682 feat: command defaults, aliases, exclusive flag groups
- wgctl.json: commands section with defaults and aliases
- command_mixins.sh: flag::exclusive, command::_resolve_conflicts
- command::run: two-pass defaults+user with conflict resolution
- load_command: _CURRENT_LOADING_CMD for flag::exclusive context
- list: flag::exclusive --online --offline --blocked --restricted --allowed
- logs: flag::exclusive --ascending --descending
- logs: fix --fw --wg together treated as neither (show both)
- dispatch: config alias resolution before load_command
- wgctl ls/peers/act: aliases via wgctl.json
2026-05-29 04:31:07 +00:00
Nuno Duque Nunes
b153f222a5 perf: batch resolve accept dest IPs in activity (4x speedup)
- json_helper: batch_resolve_dest() resolves all dest IPs in one Python call
- json.sh: json::batch_resolve_dest wrapper
- activity: _DEST_RESOLVE_CACHE pre-populated before render loop
- _render_peer_accept_dests: cache lookup instead of resolve::dest per row
- activity: 1.8s -> 0.48s
2026-05-29 00:27:43 +00:00
Nuno Duque Nunes
d26e67b940 Merge feature/accept-logging: conntrack daemon, activity integration 2026-05-28 23:34:12 +00:00
14 changed files with 481 additions and 108 deletions

View file

@ -6,6 +6,8 @@
# ============================================ # ============================================
function cmd::activity::on_load() { function cmd::activity::on_load() {
command::mixin json_output
load_module net load_module net
flag::register --peer flag::register --peer
@ -16,8 +18,11 @@ function cmd::activity::on_load() {
flag::register --accept flag::register --accept
flag::register --drop flag::register --drop
flag::register --external flag::register --external
flag::register --ports
flag::register --exclude-service
flag::register --include-service
command::mixin json_output flag::exclusive --accept --drop
} }
# ============================================ # ============================================
@ -57,7 +62,8 @@ EOF
function cmd::activity::run() { function cmd::activity::run() {
local filter_peer="" filter_service="" filter_ip="" filter_type="" local filter_peer="" filter_service="" filter_ip="" filter_type=""
local hours=24 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 while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
@ -69,6 +75,9 @@ function cmd::activity::run() {
--accept) accept_only=true; shift ;; --accept) accept_only=true; shift ;;
--drop) drop_only=true; shift ;; --drop) drop_only=true; shift ;;
--external) external_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 ;; --help) cmd::activity::help; return ;;
*) *)
log::error "Unknown flag: $1" log::error "Unknown flag: $1"
@ -94,6 +103,21 @@ function cmd::activity::run() {
fi fi
[[ -n "$filter_ip" ]] && service_ip="$filter_ip" [[ -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 ── # ── Fetch data ──
local data="" local data=""
if ! $accept_only; then if ! $accept_only; then
@ -101,7 +125,7 @@ function cmd::activity::run() {
"$(ctx::fw_events_log)" "$(ctx::events_log)" \ "$(ctx::fw_events_log)" "$(ctx::events_log)" \
"$(config::interface)" "$(ctx::net)" \ "$(config::interface)" "$(ctx::net)" \
"$(ctx::clients)" "$(ctx::meta)" \ "$(ctx::clients)" "$(ctx::meta)" \
"$hours" "$filter_peer" "$service_ip" 2>/dev/null) "$hours" "$filter_peer" "$service_ip" "$exclude_str" 2>/dev/null)
fi fi
local accept_data="" local accept_data=""
@ -112,7 +136,7 @@ function cmd::activity::run() {
[[ -f "$(ctx::accept_events_log)" ]] && \ [[ -f "$(ctx::accept_events_log)" ]] && \
accept_data=$(json::accept_aggregate \ accept_data=$(json::accept_aggregate \
"$(ctx::accept_events_log)" "$(ctx::net)" "$(ctx::clients)" \ "$(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 fi
[[ -z "$data" && -z "$accept_data" ]] && \ [[ -z "$data" && -z "$accept_data" ]] && \
@ -201,15 +225,48 @@ function cmd::activity::run() {
local pp="${rest_key#*:}" local pp="${rest_key#*:}"
local d_port="${pp%%:*}" local d_port="${pp%%:*}"
local d_proto="${pp##*:}" local d_proto="${pp##*:}"
local spec="${d_ip}:${d_port}:${d_proto}"
local dest_display local dest_display
dest_display=$(resolve::dest "$d_ip" "$d_port" "$d_proto" 2>/dev/null \ local raw_suffix=""
|| echo "${d_ip}:${d_port}/${d_proto}") 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 \ ui::activity::accept_dest_row \
"$dest_display" "$d_bytes_orig" "$d_bytes_reply" \ "$dest_display" "$d_bytes_orig" "$d_bytes_reply" \
"$d_count" "$drops_col" "$w_count" "$d_count" "$drops_col" "$w_count"
done 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 first_peer=true skip_peer=false current_name=""
local -a rendered_peers=() local -a rendered_peers=()
@ -264,14 +321,23 @@ function cmd::activity::run() {
;; ;;
service) service)
$skip_peer && continue local peer dest_display dst_ip dst_port proto drop_count
$accept_only && continue IFS='|' read -r peer dest_display dst_ip dst_port proto drop_count <<< "$rest"
local peer dest_display drop_count # Build dim suffix if --ports
IFS='|' read -r peer dest_display drop_count <<< "$rest" 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" local svc_drop_word="drops"
[[ "$drop_count" -eq 1 ]] && svc_drop_word="drop" [[ "$drop_count" -eq 1 ]] && svc_drop_word="drop"
ui::activity::service_row \ $accept_only || ui::activity::service_row \
"$dest_display" "$drop_count" "$svc_drop_word" "$drops_col" "$w_count" "$svc_display" "$drop_count" "$svc_drop_word" "$drops_col" "$w_count"
;; ;;
esac esac
done <<< "$data" done <<< "$data"

View file

@ -5,6 +5,8 @@
# ============================================ # ============================================
function cmd::list::on_load() { function cmd::list::on_load() {
command::mixin json_output
load_module identity load_module identity
load_module ui load_module ui
@ -19,7 +21,9 @@ function cmd::list::on_load() {
flag::register --allowed flag::register --allowed
flag::register --detailed flag::register --detailed
flag::register --name flag::register --name
command::mixin json_output
# Mutually exclusive filter groups
flag::exclusive --online --offline --blocked --restricted --allowed
} }
# ============================================ # ============================================

View file

@ -23,6 +23,8 @@ function cmd::logs::on_load() {
flag::register --ascending flag::register --ascending
flag::register --descending flag::register --descending
flag::register --resolved flag::register --resolved
flag::exclusive --ascending --descending
} }
function cmd::logs::help() { function cmd::logs::help() {
@ -161,6 +163,11 @@ function cmd::logs::show() {
[[ -z "$filter_ip" ]] && log::error "Could not find IP for: $name" && return 1 [[ -z "$filter_ip" ]] && log::error "Could not find IP for: $name" && return 1
fi fi
if $fw_only && $wg_only; then
fw_only=false
wg_only=false
fi
if $follow; then if $follow; then
cmd::logs::follow "$filter_ip" "$name" "$type" "$fw_only" "$wg_only" cmd::logs::follow "$filter_ip" "$name" "$type" "$fw_only" "$wg_only"
return return

View file

@ -8,6 +8,7 @@ declare -A _LOADED_COMMANDS=()
readonly _COMMAND_NAMESPACE="cmd" readonly _COMMAND_NAMESPACE="cmd"
readonly _COMMAND_AUTO_LOAD_HOOK="on_load" readonly _COMMAND_AUTO_LOAD_HOOK="on_load"
_CURRENT_LOADING_CMD=""
# ============================================ # ============================================
# Helpers # Helpers
@ -36,13 +37,57 @@ function command::exists() { command::has_function "$1" run; }
# Runner # Runner
# ============================================ # ============================================
# function command::run() {
# local cmd="$1"
# shift
# command::_reset_mixin_state # reset values only, keep _ACTIVE_MIXINS
# local -a args=("$@")
# command::_preprocess_flags args
# local fn
# fn=$(command::fn "$cmd" run)
# core::call_function "$fn" ${args[@]+"${args[@]}"}
# }
function command::run() { function command::run() {
local cmd="$1" local cmd="$1"
shift shift
command::_reset_mixin_state # reset values only, keep _ACTIVE_MIXINS command::_reset_mixin_state
local -a args=("$@") # Build default args from config
local -a default_args=()
local defaults="${_COMMAND_DEFAULTS[$cmd]:-}"
if [[ -n "$defaults" ]]; then
read -ra default_args <<< "$defaults"
fi
local -a user_args=("$@")
[[ $# -gt 0 ]] && user_args=("$@")
# Resolve exclusive group conflicts — user args override defaults
local groups="${_FLAG_EXCLUSIVE_GROUPS[$cmd]:-}"
if [[ -n "$groups" && ${#default_args[@]} -gt 0 && ${#user_args[@]} -gt 0 ]]; then
command::_resolve_conflicts default_args user_args "$groups"
fi
local -a cleaned_defaults=()
for _d in "${default_args[@]:-}"; do
[[ -n "$_d" ]] && cleaned_defaults+=("$_d")
done
default_args=("${cleaned_defaults[@]:-}")
local -a args=()
for _d in "${default_args[@]:-}"; do
[[ -n "$_d" ]] && args+=("$_d")
done
for _u in "${user_args[@]:-}"; do
[[ -n "$_u" ]] && args+=("$_u")
done
# Preprocess mixin flags (--json, --no-color etc)
command::_preprocess_flags args command::_preprocess_flags args
local fn local fn
@ -77,7 +122,9 @@ function load_command() {
source "$path" source "$path"
_LOADED_COMMANDS["$name"]=1 _LOADED_COMMANDS["$name"]=1
_CURRENT_LOADING_CMD="$name"
core::call_if_exists "$(command::fn "$name" on_load)" core::call_if_exists "$(command::fn "$name" on_load)"
_CURRENT_LOADING_CMD=""
return 0 return 0
} }

View file

@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# core/command_mixins.sh # core/command_mixins.sh
# Mixin infrastructure — loads mixin files and provides command::mixin # Mixin infrastructure — loads mixin files and provides command::mixin / flag::exclusive
# ============================================ # ============================================
# Active mixin tracking (per-process) # Active mixin tracking (per-process)
@ -109,3 +109,87 @@ function command::_preprocess_flags() {
_args_ref=() _args_ref=()
fi fi
} }
# command::_resolve_conflicts <defaults_nameref> <user_nameref> <groups_string>
# Removes conflicting defaults when user provides a member of an exclusive group
function command::_resolve_conflicts() {
local -n _def_ref="$1"
local -n _usr_ref="$2"
local groups="$3"
[[ -z "$groups" ]] && return 0
[[ ${#_def_ref[@]} -eq 0 ]] && return 0
# Work on a copy — progressively filter across all groups
local -a working=("${_def_ref[@]}")
local group
while IFS= read -r group; do
[[ -z "$group" ]] && continue
local -a members=()
IFS=',' read -ra members <<< "$group"
# Find which member (if any) the user passed from this group
local user_member=""
local member user_arg
for member in "${members[@]}"; do
for user_arg in "${_usr_ref[@]:-}"; do
if [[ "$user_arg" == "$member" ]]; then
user_member="$member"
break 2
fi
done
done
# No user member in this group — don't touch defaults
[[ -z "$user_member" ]] && continue
# User passed a member — remove all OTHER members from defaults
# (keep the same flag if it was already in defaults)
local -a new_working=()
local def_arg
for def_arg in "${working[@]:-}"; do
local is_other_member=false
for member in "${members[@]}"; do
# It's another member if it's in the group AND not the same as user's choice
if [[ "$def_arg" == "$member" && "$def_arg" != "$user_member" ]]; then
is_other_member=true
break
fi
done
$is_other_member || new_working+=("$def_arg")
done
working=("${new_working[@]:-}")
done < <(echo "$groups" | tr '|' '\n')
# Write back
if [[ ${#working[@]} -gt 0 ]]; then
_def_ref=("${working[@]}")
else
_def_ref=()
fi
}
# ============================================
# Flag Exclusive
# ============================================
declare -gA _FLAG_EXCLUSIVE_GROUPS=()
# flag::exclusive <flag1> <flag2> ...
# Called from on_load — registers mutually exclusive flags for current command
function flag::exclusive() {
local cmd="${_CURRENT_LOADING_CMD:-}"
[[ -z "$cmd" ]] && return 0
# Join flags with comma as one group
local group
group=$(IFS=','; echo "$*")
if [[ -n "${_FLAG_EXCLUSIVE_GROUPS[$cmd]:-}" ]]; then
_FLAG_EXCLUSIVE_GROUPS["$cmd"]+="${group}|"
else
_FLAG_EXCLUSIVE_GROUPS["$cmd"]="${group}|"
fi
}

View file

@ -157,6 +157,7 @@ function json::endpoint_cache_get() { python3 "$JSON_HELPER" endpoint_cach
# Accept Events # Accept Events
function json::accept_events() { python3 "$JSON_HELPER" accept_events "$@" </dev/null; } function json::accept_events() { python3 "$JSON_HELPER" accept_events "$@" </dev/null; }
function json::accept_aggregate() { python3 "$JSON_HELPER" accept_aggregate "$@" </dev/null; } function json::accept_aggregate() { python3 "$JSON_HELPER" accept_aggregate "$@" </dev/null; }
function json::batch_resolve_dest() { python3 "$JSON_HELPER" batch_resolve_dest "$(ctx::net)" "$(ctx::hosts)" "$@" </dev/null; }
function json::peer_transfer() { function json::peer_transfer() {
ACTIVITY_TOTAL_LOW="$(config::activity_total_low)" \ ACTIVITY_TOTAL_LOW="$(config::activity_total_low)" \

View file

@ -1607,6 +1607,23 @@ def config_load(file):
emit('ACTIVITY_CURRENT_LOW_BYTES', acur.get('low')) emit('ACTIVITY_CURRENT_LOW_BYTES', acur.get('low'))
emit('ACTIVITY_CURRENT_MED_BYTES', acur.get('medium')) emit('ACTIVITY_CURRENT_MED_BYTES', acur.get('medium'))
emit('ACTIVITY_CURRENT_HIGH_BYTES', acur.get('high')) emit('ACTIVITY_CURRENT_HIGH_BYTES', acur.get('high'))
# Command defaults and aliases
# Output format:
# CMD_DEFAULT:activity=--exclude-service pihole:dns-udp --limit 50
# CMD_ALIAS:act=activity
# CMD_ALIAS:a=activity
cmds = d.get('commands', {})
for cmd_name, cmd_cfg in cmds.items():
if not isinstance(cmd_cfg, dict):
continue
defaults = cmd_cfg.get('defaults', [])
if defaults:
print(f"CMD_DEFAULT:{cmd_name}={' '.join(str(x) for x in defaults)}")
aliases = cmd_cfg.get('aliases', [])
for alias in aliases:
print(f"CMD_ALIAS:{alias}={cmd_name}")
except Exception as e: except Exception as e:
print(f"Error: {e}", file=sys.stderr) print(f"Error: {e}", file=sys.stderr)
sys.exit(1) sys.exit(1)
@ -1821,6 +1838,60 @@ def endpoint_cache_get(cache_file, peer):
except Exception: except Exception:
print('') print('')
def batch_resolve_dest(net_file, hosts_file, *dest_specs):
"""
Resolve multiple ip:port:proto specs at once.
Input: "ip:port:proto" strings
Output: "ip:port:proto|display_name" per line
Uses same logic as resolve::dest bash function.
"""
from lib.util import load_net_data, load_hosts_data, reverse_lookup, hosts_lookup
net_data = load_net_data(net_file)
hosts_data = load_hosts_data(hosts_file)
seen = set()
for spec in dest_specs:
if not spec or spec in seen:
continue
seen.add(spec)
parts = spec.split(':')
if len(parts) < 3:
print(f"{spec}|{spec}")
continue
ip = parts[0]
port = parts[1]
proto = parts[2]
# Try service name first
svc = reverse_lookup(net_data, ip, port, proto)
if svc and svc != ip:
if port:
display = f"{svc}:{proto}-{port}" if False else f"{svc}"
# Use same format as resolve::dest: "svcname/proto" or "svcname:port"
display = svc
else:
display = svc
print(f"{spec}|{display}")
continue
# Try host name
host = hosts_lookup(hosts_data, ip)
if host and host != ip:
if port:
print(f"{spec}|{host}:{port}/{proto}")
else:
print(f"{spec}|{host}")
continue
# Raw fallback
if port:
print(f"{spec}|{ip}:{port}/{proto}")
else:
print(f"{spec}|{ip}")
# ====================================================== # ======================================================
def _net_read(file): def _net_read(file):
@ -2077,7 +2148,8 @@ commands = {
args[0], args[1], args[2], args[3], args[4], args[0], args[1], args[2], args[3], args[4],
args[5], args[6] if len(args) > 6 else '24', args[5], args[6] if len(args) > 6 else '24',
args[7] if len(args) > 7 else '', 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 # Rules
'rule_resolve': lambda args: rule_resolve(args[0], args[1]), 'rule_resolve': lambda args: rule_resolve(args[0], args[1]),
'rule_resolve_field':lambda args: rule_resolve_field(args[0], args[1], args[2]), 'rule_resolve_field':lambda args: rule_resolve_field(args[0], args[1], args[2]),
@ -2237,7 +2309,9 @@ commands = {
args[0], args[1], args[2], args[0], args[1], args[2],
args[3] if len(args) > 3 else '', args[3] if len(args) > 3 else '',
args[4] if len(args) > 4 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:]),
} }
# ── Main ───────────────────────────────────────────────────────────────────── # ── Main ─────────────────────────────────────────────────────────────────────

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='', 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. Aggregate accept events per peer total bytes, packets, top destinations.
Used by wgctl activity to show accepted traffic alongside drops. 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 collections import defaultdict
from itertools import groupby from itertools import groupby
from lib.util import load_net_data, hosts_lookup, reverse_lookup
since_dt = parse_since(since) if since else None since_dt = parse_since(since) if since else None
show_external = str(external_only) == '1' 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': 0, 'count': 0})
dest_stats = defaultdict(lambda: {'bytes_orig': 0, 'bytes_reply': 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: try:
with open(file) as f: with open(file) as f:
for line in f: for line in f:
@ -201,6 +210,9 @@ def accept_aggregate(file, net_file, clients_dir, since='',
ps['packets_in'] += p_reply ps['packets_in'] += p_reply
ps['conn_count'] += 1 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_key = (peer, dst_ip, dst_port, proto)
dest_stats[dest_key]['bytes_orig'] += b_orig dest_stats[dest_key]['bytes_orig'] += b_orig
dest_stats[dest_key]['bytes_reply'] += b_reply 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: for (p, dst_ip, dst_port, proto), stats in top:
print(f"dest|{p}|{dst_ip}|{dst_port}|{proto}|" print(f"dest|{p}|{dst_ip}|{dst_port}|{proto}|"
f"{stats['bytes_orig']}|{stats['bytes_reply']}|{stats['count']}") 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, def activity_aggregate(fw_file, wg_file, wg_interface, net_file,
clients_dir, meta_dir, hours, filter_peer, clients_dir, meta_dir, hours, filter_peer,
filter_service_ip): filter_service_ip, exclude_services=''):
""" """
Aggregate activity data for wgctl activity. Aggregate activity data for wgctl activity.
Output: Output:
peer|name|rx_bytes|tx_bytes|drop_count 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 hours = int(hours) if hours else 24
cutoff = None cutoff = None
if hours > 0: if hours > 0:
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours) 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 # Preload lookups once
ip_to_peer = build_ip_to_name(clients_dir) ip_to_peer = build_ip_to_name(clients_dir)
pubkey_to_peer = build_pubkey_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): def _reverse(dest_ip, dest_port, proto):
return reverse_lookup(net_data, 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 # WireGuard transfer totals
peer_rx = defaultdict(int) peer_rx = defaultdict(int)
peer_tx = defaultdict(int) peer_tx = defaultdict(int)
@ -59,6 +82,7 @@ def activity_aggregate(fw_file, wg_file, wg_interface, net_file,
pass pass
# Parse fw_events for drops # Parse fw_events for drops
# service_drops[peer][(dest_display, dst_ip, dst_port, proto)] = count
peer_drops = defaultdict(int) peer_drops = defaultdict(int)
service_drops = defaultdict(lambda: 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) svc_name = _reverse(dest_ip, dest_port, proto)
dest_display = make_dest_display(dest_ip, dest_port, proto, svc_name) 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 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: except Exception:
continue continue
@ -125,5 +154,6 @@ def activity_aggregate(fw_file, wg_file, wg_interface, net_file,
print(f"peer|{peer}|{rx}|{tx}|{drops}") print(f"peer|{peer}|{rx}|{tx}|{drops}")
svc_map = service_drops.get(peer, {}) svc_map = service_drops.get(peer, {})
for dest_display, count in sorted(svc_map.items(), key=lambda x: -x[1]): for (dest_display, dst_ip, dst_port, proto), count in \
print(f"service|{peer}|{dest_display}|{count}") sorted(svc_map.items(), key=lambda x: -x[1]):
print(f"service|{peer}|{dest_display}|{dst_ip}|{dst_port}|{proto}|{count}")

View file

@ -22,6 +22,9 @@ declare -g _ACTIVITY_CURRENT_LOW_BYTES="${ACTIVITY_CURRENT_LOW_BYTES:-1000000}"
declare -g _ACTIVITY_CURRENT_MED_BYTES="${ACTIVITY_CURRENT_MED_BYTES:-10000000}" declare -g _ACTIVITY_CURRENT_MED_BYTES="${ACTIVITY_CURRENT_MED_BYTES:-10000000}"
declare -g _ACTIVITY_CURRENT_HIGH_BYTES="${ACTIVITY_CURRENT_HIGH_BYTES:-100000000}" declare -g _ACTIVITY_CURRENT_HIGH_BYTES="${ACTIVITY_CURRENT_HIGH_BYTES:-100000000}"
declare -gA _COMMAND_DEFAULTS=()
declare -gA _COMMAND_ALIASES=()
function config::_init_defaults() { function config::_init_defaults() {
_WG_INTERFACE="wg0" _WG_INTERFACE="wg0"
_WG_DNS="10.0.0.103" _WG_DNS="10.0.0.103"
@ -89,6 +92,14 @@ function config::_load_json() {
ACTIVITY_CURRENT_LOW_BYTES) _ACTIVITY_CURRENT_LOW_BYTES="$value" ;; ACTIVITY_CURRENT_LOW_BYTES) _ACTIVITY_CURRENT_LOW_BYTES="$value" ;;
ACTIVITY_CURRENT_MED_BYTES) _ACTIVITY_CURRENT_MED_BYTES="$value" ;; ACTIVITY_CURRENT_MED_BYTES) _ACTIVITY_CURRENT_MED_BYTES="$value" ;;
ACTIVITY_CURRENT_HIGH_BYTES) _ACTIVITY_CURRENT_HIGH_BYTES="$value" ;; ACTIVITY_CURRENT_HIGH_BYTES) _ACTIVITY_CURRENT_HIGH_BYTES="$value" ;;
CMD_DEFAULT:*)
local cmd_name="${key#CMD_DEFAULT:}"
_COMMAND_DEFAULTS["$cmd_name"]="$value"
;;
CMD_ALIAS:*)
local alias_name="${key#CMD_ALIAS:}"
_COMMAND_ALIASES["$alias_name"]="$value"
;;
esac esac
done < <(json::config_load "$file" 2>/dev/null) done < <(json::config_load "$file" 2>/dev/null)
} }

View file

@ -1,7 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ui/activity.module.sh — rendering for wgctl activity # 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() { function ui::activity::peer_row() {
local name_pad="${1:-}" rx_pad="${2:-}" tx_pad="${3:-}" \ local name_pad="${1:-}" rx_pad="${2:-}" tx_pad="${3:-}" \
drops="${4:-0}" drop_word="${5:-drops}" w_drops="${6:-1}" 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" "$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() { function ui::activity::service_row() {
local dest_display="${1:-}" drop_count="${2:-0}" drop_word="${3:-drops}" \ local dest_display="${1:-}" drop_count="${2:-0}" drop_word="${3:-drops}" \
drops_col="${4:-30}" w_count="${5:-1}" drops_col="${4:-30}" w_count="${5:-1}"
local arrow_prefix=" → " local arrow_prefix=" → "
local prefix_bytes=${#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 )) local pad_n=$(( drops_col - prefix_len ))
[[ $pad_n -lt 1 ]] && pad_n=1 [[ $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" "$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() { function ui::activity::accept_row() {
local name_pad="${1:-}" bytes_in="${2:-}" bytes_out="${3:-}" \ local name_pad="${1:-}" bytes_in="${2:-}" bytes_out="${3:-}" \
conns="${4:-0}" w_count="${5:-4}" conns="${4:-0}" w_count="${5:-4}"
@ -57,7 +49,6 @@ function ui::activity::accept_row() {
"$spaces" "$bytes_in" "$bytes_out" "$conns" "$conn_word" "$spaces" "$bytes_in" "$bytes_out" "$conns" "$conn_word"
} }
function ui::activity::accept_dest_row() { function ui::activity::accept_dest_row() {
local dest="${1:-}" bytes_orig="${2:-0}" bytes_reply="${3:-0}" \ local dest="${1:-}" bytes_orig="${2:-0}" bytes_reply="${3:-0}" \
count="${4:-0}" drops_col="${5:-40}" w_count="${6:-4}" 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 arrow_prefix=" → "
local prefix_bytes=${#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 )) local pad_n=$(( drops_col - prefix_len ))
[[ $pad_n -lt 1 ]] && pad_n=1 [[ $pad_n -lt 1 ]] && pad_n=1
# Only show bytes if non-zero # Build bytes display
local bytes_display="" local bytes_display=""
if [[ "$bytes_orig" -gt 0 || "$bytes_reply" -gt 0 ]]; then if [[ "$bytes_orig" -gt 0 || "$bytes_reply" -gt 0 ]]; then
local bytes_display=" " bytes_display=" "
[[ "$bytes_orig" -gt 0 ]] && bytes_display+="$(fmt::bytes "$bytes_orig") " [[ "$bytes_reply" -gt 0 ]] && bytes_display+="$(fmt::bytes "$bytes_reply") "
[[ "$bytes_reply" -gt 0 ]] && bytes_display+="$(fmt::bytes "$bytes_reply")" [[ "$bytes_orig" -gt 0 ]] && bytes_display+="$(fmt::bytes "$bytes_orig")"
bytes_display="${bytes_display% }" # trim trailing space bytes_display="${bytes_display% }"
fi 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" "$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"
}

7
wgctl
View file

@ -43,7 +43,6 @@ declare -A CMD_ALIASES=(
[del]=remove [del]=remove
[delete]=remove [delete]=remove
[mv]=rename [mv]=rename
[ls]=list
[show]=list [show]=list
[monitor]=watch [monitor]=watch
[ban]=block [ban]=block
@ -53,7 +52,6 @@ declare -A CMD_ALIASES=(
[down]=service [down]=service
[reload]=service [reload]=service
[stat]=service [stat]=service
[log]=service
[start]=service [start]=service
[stop]=service [stop]=service
[restart]=service [restart]=service
@ -78,6 +76,11 @@ function wgctl::dispatch() {
local cmd local cmd
cmd="$(wgctl::resolve_alias "$raw_cmd")" cmd="$(wgctl::resolve_alias "$raw_cmd")"
# Resolve config-defined aliases (from wgctl.json commands section)
if [[ -n "${_COMMAND_ALIASES[$cmd]:-}" ]]; then
cmd="${_COMMAND_ALIASES[$cmd]}"
fi
case "$cmd" in case "$cmd" in
help) wgctl::help; return ;; help) wgctl::help; return ;;
shell) : ;; shell) : ;;