Compare commits

..

No commits in common. "master" and "feature/accept-logging" have entirely different histories.

14 changed files with 108 additions and 481 deletions

View file

@ -6,8 +6,6 @@
# ============================================ # ============================================
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
@ -18,11 +16,8 @@ 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 command::mixin json_output
flag::register --include-service
flag::exclusive --accept --drop
} }
# ============================================ # ============================================
@ -62,8 +57,7 @@ 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 show_ports=false local accept_only=false drop_only=false external_only=false
local -a exclude_services=() include_services=()
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
@ -75,9 +69,6 @@ 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"
@ -103,21 +94,6 @@ 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
@ -125,7 +101,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" "$exclude_str" 2>/dev/null) "$hours" "$filter_peer" "$service_ip" 2>/dev/null)
fi fi
local accept_data="" local accept_data=""
@ -136,7 +112,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" "$exclude_str" 2>/dev/null) "$since_arg" "$filter_peer" "$ext_flag" 2>/dev/null)
fi fi
[[ -z "$data" && -z "$accept_data" ]] && \ [[ -z "$data" && -z "$accept_data" ]] && \
@ -212,60 +188,27 @@ function cmd::activity::run() {
# ── Accept dest inline renderer ── # ── Accept dest inline renderer ──
_render_peer_accept_dests() { _render_peer_accept_dests() {
local peer_name="$1" local peer_name="$1"
local keys="${_ACCEPT_DEST_KEYS[$peer_name]:-}" local keys="${_ACCEPT_DEST_KEYS[$peer_name]:-}"
[[ -z "$keys" ]] && return 0 [[ -z "$keys" ]] && return 0
for d_key in $keys; do for d_key in $keys; do
local dest_stats="${_ACCEPT_DEST[$d_key]:-}" local dest_stats="${_ACCEPT_DEST[$d_key]:-}"
[[ -z "$dest_stats" ]] && continue [[ -z "$dest_stats" ]] && continue
local d_bytes_orig d_bytes_reply d_count local d_bytes_orig d_bytes_reply d_count
IFS='|' read -r d_bytes_orig d_bytes_reply d_count <<< "$dest_stats" IFS='|' read -r d_bytes_orig d_bytes_reply d_count <<< "$dest_stats"
local rest_key="${d_key#${peer_name}:}" local rest_key="${d_key#${peer_name}:}"
local d_ip="${rest_key%%:*}" local d_ip="${rest_key%%:*}"
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}}" ui::activity::accept_dest_row \
local dest_display="$resolved" "$dest_display" "$d_bytes_orig" "$d_bytes_reply" \
if [[ "$show_ports" == "true" && "$resolved" != "${d_ip}:"* && "$resolved" != "${d_ip} "* ]]; then "$d_count" "$drops_col" "$w_count"
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 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=()
@ -321,23 +264,14 @@ function cmd::activity::run() {
;; ;;
service) service)
local peer dest_display dst_ip dst_port proto drop_count $skip_peer && continue
IFS='|' read -r peer dest_display dst_ip dst_port proto drop_count <<< "$rest" $accept_only && continue
# Build dim suffix if --ports local peer dest_display drop_count
local svc_display="$dest_display" IFS='|' read -r peer dest_display drop_count <<< "$rest"
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"
$accept_only || ui::activity::service_row \ ui::activity::service_row \
"$svc_display" "$drop_count" "$svc_drop_word" "$drops_col" "$w_count" "$dest_display" "$drop_count" "$svc_drop_word" "$drops_col" "$w_count"
;; ;;
esac esac
done <<< "$data" done <<< "$data"

View file

@ -5,8 +5,6 @@
# ============================================ # ============================================
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
@ -21,9 +19,7 @@ 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,8 +23,6 @@ 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() {
@ -163,11 +161,6 @@ 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,7 +8,6 @@ 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
@ -37,57 +36,13 @@ 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
# 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=() command::_reset_mixin_state # reset values only, keep _ACTIVE_MIXINS
for _d in "${default_args[@]:-}"; do
[[ -n "$_d" ]] && cleaned_defaults+=("$_d")
done
default_args=("${cleaned_defaults[@]:-}")
local -a args=() 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
@ -122,9 +77,7 @@ 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 / flag::exclusive # Mixin infrastructure — loads mixin files and provides command::mixin
# ============================================ # ============================================
# Active mixin tracking (per-process) # Active mixin tracking (per-process)
@ -108,88 +108,4 @@ function command::_preprocess_flags() {
else else
_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,7 +157,6 @@ 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,23 +1607,6 @@ 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)
@ -1838,60 +1821,6 @@ 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):
@ -2148,8 +2077,7 @@ 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]),
@ -2303,15 +2231,13 @@ commands = {
args[5] if len(args) > 5 else '1', args[5] if len(args) > 5 else '1',
args[6] if len(args) > 6 else '', args[6] if len(args) > 6 else '',
args[7] if len(args) > 7 else '0', 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', 'accept_aggregate': lambda args: __import__('lib.accept_events',
fromlist=['accept_aggregate']).accept_aggregate( fromlist=['accept_aggregate']).accept_aggregate(
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', exclude_services=''): filter_peer='', external_only='0'):
""" """
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,7 +145,6 @@ 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'
@ -158,14 +157,6 @@ 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:
@ -209,10 +200,7 @@ def accept_aggregate(file, net_file, clients_dir, since='',
ps['packets_out'] += p_orig ps['packets_out'] += p_orig
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
@ -237,24 +225,4 @@ def accept_aggregate(file, net_file, clients_dir, since='',
top = list(group)[:20] top = list(group)[:20]
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,49 +19,26 @@ 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, exclude_services=''): filter_service_ip):
""" """
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|dst_ip|dst_port|proto|drop_count service|peer_name|dest_display|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)
net_data = load_net_data(net_file) net_data = load_net_data(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)
@ -80,12 +57,11 @@ def activity_aggregate(fw_file, wg_file, wg_interface, net_file,
peer_tx[peer] += tx peer_tx[peer] += tx
except Exception: except Exception:
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))
if os.path.exists(fw_file): if os.path.exists(fw_file):
try: try:
with open(fw_file) as f: with open(fw_file) as f:
@ -105,16 +81,16 @@ def activity_aggregate(fw_file, wg_file, wg_interface, net_file,
continue continue
except Exception: except Exception:
pass pass
src_ip = ev.get('src_ip', '') src_ip = ev.get('src_ip', '')
if not src_ip: if not src_ip:
continue continue
dest_ip = ev.get('dest_ip', '') dest_ip = ev.get('dest_ip', '')
dest_port = str(ev.get('dest_port', '')) dest_port = str(ev.get('dest_port', ''))
proto_num = ev.get('ip.protocol', 0) proto_num = ev.get('ip.protocol', 0)
proto = PROTO_MAP.get(int(proto_num), str(proto_num)) proto = PROTO_MAP.get(int(proto_num), str(proto_num))
peer = ip_to_peer.get(src_ip) peer = ip_to_peer.get(src_ip)
if not peer: if not peer:
continue continue
@ -122,23 +98,18 @@ def activity_aggregate(fw_file, wg_file, wg_interface, net_file,
continue continue
if filter_service_ip and dest_ip != filter_service_ip: if filter_service_ip and dest_ip != filter_service_ip:
continue continue
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
# Key includes raw ip:port:proto for --ports support service_drops[peer][dest_display] += 1
svc_key = (dest_display, dest_ip, dest_port, proto)
service_drops[peer][svc_key] += 1
except Exception: except Exception:
continue continue
except Exception: except Exception:
pass pass
# Collect peers with any activity # Collect peers with any activity
all_peers = set() all_peers = set()
all_peers.update(k for k in peer_rx if peer_rx[k] > 0) all_peers.update(k for k in peer_rx if peer_rx[k] > 0)
@ -146,14 +117,13 @@ def activity_aggregate(fw_file, wg_file, wg_interface, net_file,
all_peers.update(peer_drops.keys()) all_peers.update(peer_drops.keys())
if filter_peer: if filter_peer:
all_peers = {p for p in all_peers if p == filter_peer} all_peers = {p for p in all_peers if p == filter_peer}
for peer in sorted(all_peers): for peer in sorted(all_peers):
rx = peer_rx.get(peer, 0) rx = peer_rx.get(peer, 0)
tx = peer_tx.get(peer, 0) tx = peer_tx.get(peer, 0)
drops = peer_drops.get(peer, 0) drops = peer_drops.get(peer, 0)
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, dst_ip, dst_port, proto), count in \ for dest_display, count in sorted(svc_map.items(), key=lambda x: -x[1]):
sorted(svc_map.items(), key=lambda x: -x[1]): print(f"service|{peer}|{dest_display}|{count}")
print(f"service|{peer}|{dest_display}|{dst_ip}|{dst_port}|{proto}|{count}")

View file

@ -22,9 +22,6 @@ 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"
@ -92,14 +89,6 @@ 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,6 +1,7 @@
#!/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}"
@ -9,78 +10,22 @@ function ui::activity::peer_row() {
"$name_pad" "$rx_pad" "$tx_pad" "$drops" "$drop_word" "$name_pad" "$rx_pad" "$tx_pad" "$drops" "$drop_word"
} }
# ── _strip_ansi <string> → visible string # ui::activity::service_row <dest_display> <drop_count> <drop_word> <drops_col> <w_drops>
# 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}
# Measure visible length of dest (strip ANSI for correct padding) local prefix_len=$(( prefix_bytes + ${#dest_display} ))
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→\033[0m \033[0;31m%b\033[0m%*s \033[0;31m%${w_count}s %s\033[0m\n" \ printf " \033[0;31m→ %s%*s %${w_count}s %s\033[0m\n" \
"$dest_display" "$pad_n" "" "$drop_count" "$drop_word" "$dest_display" "$pad_n" "" "$drop_count" "$drop_word"
} }
function ui::activity::accept_row() { # Table versions (kept for future display config)
local name_pad="${1:-}" bytes_in="${2:-}" bytes_out="${3:-}" \
conns="${4:-0}" w_count="${5:-4}"
local conn_word="conns"
[[ "$conns" -eq 1 ]] && conn_word="conn"
local spaces
spaces=$(printf '%*s' "${#name_pad}" '')
printf " \033[0;32m%s ↓%-10s ↑%-10s %${w_count}s %s\033[0m\n" \
"$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}"
local conn_word="conns"
[[ "$count" -eq 1 ]] && conn_word="conn"
local arrow_prefix=" → "
local prefix_bytes=${#arrow_prefix}
# 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
# Build bytes display
local bytes_display=""
if [[ "$bytes_orig" -gt 0 || "$bytes_reply" -gt 0 ]]; then
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
# 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() { function ui::activity::header_table() {
printf "\n %-24s %-14s %-14s %s\n" "PEER" "↓ RX" "↑ TX" "DROPS" printf "\n %-24s %-14s %-14s %s\n" "PEER" "↓ RX" "↑ TX" "DROPS"
printf " %s\n" "$(printf '─%.0s' {1..65})" printf " %s\n" "$(printf '─%.0s' {1..65})"
@ -96,4 +41,45 @@ function ui::activity::peer_row_table() {
function ui::activity::service_row_table() { function ui::activity::service_row_table() {
local dest_display="${1:-}" drop_count="${2:-0}" drop_word="${3:-drops}" local dest_display="${1:-}" drop_count="${2:-0}" drop_word="${3:-drops}"
printf " → %-30s %s %s\n" "$dest_display" "$drop_count" "$drop_word" 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}"
local conn_word="conns"
[[ "$conns" -eq 1 ]] && conn_word="conn"
local spaces
spaces=$(printf '%*s' "${#name_pad}" '')
printf " \033[0;32m%s ↓%-10s ↑%-10s %${w_count}s %s\033[0m\n" \
"$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}"
local conn_word="conns"
[[ "$count" -eq 1 ]] && conn_word="conn"
local arrow_prefix=" → "
local prefix_bytes=${#arrow_prefix}
local prefix_len=$(( prefix_bytes + ${#dest} ))
local pad_n=$(( drops_col - prefix_len ))
[[ $pad_n -lt 1 ]] && pad_n=1
# Only show bytes if non-zero
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
fi
printf " \033[0;32m→\033[0m \033[0;32m%s%*s %${w_count}s %-5s%s\033[0m\n" \
"$dest" "$pad_n" "" "$count" "$conn_word" "$bytes_display"
} }

7
wgctl
View file

@ -43,6 +43,7 @@ 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
@ -52,6 +53,7 @@ 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
@ -76,11 +78,6 @@ 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) : ;;