Compare commits
No commits in common. "master" and "feature/block-history" have entirely different histories.
master
...
feature/bl
30 changed files with 139 additions and 1581 deletions
|
|
@ -6,8 +6,6 @@
|
|||
# ============================================
|
||||
|
||||
function cmd::activity::on_load() {
|
||||
command::mixin json_output
|
||||
|
||||
load_module net
|
||||
|
||||
flag::register --peer
|
||||
|
|
@ -15,14 +13,9 @@ function cmd::activity::on_load() {
|
|||
flag::register --ip
|
||||
flag::register --hours
|
||||
flag::register --type
|
||||
flag::register --accept
|
||||
flag::register --drop
|
||||
flag::register --external
|
||||
flag::register --ports
|
||||
flag::register --exclude-service
|
||||
flag::register --include-service
|
||||
|
||||
flag::exclusive --accept --drop
|
||||
flag::register --dropped
|
||||
|
||||
command::mixin json_output
|
||||
}
|
||||
|
||||
# ============================================
|
||||
|
|
@ -42,12 +35,11 @@ Options:
|
|||
--ip <ip> Filter by destination IP
|
||||
--hours <n> Time window in hours (default: 24, 0 = all time)
|
||||
--type <type> Filter by device type (combined with --peer)
|
||||
--accept Show only accepted traffic (from conntrack)
|
||||
--drop Show only firewall drops
|
||||
--external Show only external traffic (full tunnel peers)
|
||||
--dropped Show only peers with at least one drop
|
||||
|
||||
Examples:
|
||||
wgctl activity
|
||||
wgctl activity --dropped
|
||||
wgctl activity --peer phone-nuno
|
||||
wgctl activity --service truenas
|
||||
wgctl activity --hours 0
|
||||
|
|
@ -61,24 +53,17 @@ 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 show_ports=false
|
||||
local -a exclude_services=() include_services=()
|
||||
local hours=24 dropped_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 ;;
|
||||
--ports) show_ports=true; shift ;;
|
||||
--exclude-service) exclude_services+=("$2"); shift 2 ;;
|
||||
--include-service) include_services+=("$2"); shift 2 ;;
|
||||
--help) cmd::activity::help; return ;;
|
||||
--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 ;;
|
||||
--dropped) dropped_only=true; shift ;;
|
||||
--help) cmd::activity::help; return ;;
|
||||
*)
|
||||
log::error "Unknown flag: $1"
|
||||
cmd::activity::help
|
||||
|
|
@ -92,82 +77,42 @@ function cmd::activity::run() {
|
|||
return 0
|
||||
fi
|
||||
|
||||
# Resolve peer name if type provided
|
||||
if [[ -n "$filter_peer" && -n "$filter_type" ]]; then
|
||||
filter_peer=$(peers::resolve_and_require "$filter_peer" "$filter_type") || return 1
|
||||
fi
|
||||
|
||||
# Resolve --service to IP
|
||||
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
|
||||
if [[ -z "$service_ip" ]]; then
|
||||
log::error "Service not found: ${filter_service}"
|
||||
return 1
|
||||
fi
|
||||
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 aggregated data
|
||||
local data
|
||||
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)
|
||||
|
||||
# ── Fetch data ──
|
||||
local data=""
|
||||
if ! $accept_only; then
|
||||
data=$(json::activity_aggregate \
|
||||
"$(ctx::fw_events_log)" "$(ctx::events_log)" \
|
||||
"$(config::interface)" "$(ctx::net)" \
|
||||
"$(ctx::clients)" "$(ctx::meta)" \
|
||||
"$hours" "$filter_peer" "$service_ip" "$exclude_str" 2>/dev/null)
|
||||
if [[ -z "$data" ]]; then
|
||||
log::wg_warning "No activity data found"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local accept_data=""
|
||||
if ! $drop_only; then
|
||||
local since_arg="" ext_flag="0"
|
||||
[[ "$hours" -gt 0 ]] && since_arg="${hours}h"
|
||||
$external_only && ext_flag="1"
|
||||
[[ -f "$(ctx::accept_events_log)" ]] && \
|
||||
accept_data=$(json::accept_aggregate \
|
||||
"$(ctx::accept_events_log)" "$(ctx::net)" "$(ctx::clients)" \
|
||||
"$since_arg" "$filter_peer" "$ext_flag" "$exclude_str" 2>/dev/null)
|
||||
fi
|
||||
|
||||
[[ -z "$data" && -z "$accept_data" ]] && \
|
||||
log::wg_warning "No activity data found" && return 0
|
||||
|
||||
# ── Build accept maps ──
|
||||
declare -gA _ACCEPT_PEER=()
|
||||
declare -gA _ACCEPT_DEST_KEYS=()
|
||||
declare -gA _ACCEPT_DEST=()
|
||||
|
||||
while IFS='|' read -r type rest; do
|
||||
[[ -z "$type" ]] && continue
|
||||
case "$type" in
|
||||
peer)
|
||||
local a_name a_bi a_bo a_pi a_po a_conns
|
||||
IFS='|' read -r a_name a_bi a_bo a_pi a_po a_conns <<< "$rest"
|
||||
_ACCEPT_PEER["$a_name"]="${a_bi}|${a_bo}|${a_pi}|${a_po}|${a_conns}"
|
||||
;;
|
||||
dest)
|
||||
local d_peer d_ip d_port d_proto d_bytes_orig d_bytes_reply d_count
|
||||
IFS='|' read -r d_peer d_ip d_port d_proto d_bytes_orig d_bytes_reply d_count <<< "$rest"
|
||||
local d_key="${d_peer}:${d_ip}:${d_port}:${d_proto}"
|
||||
_ACCEPT_DEST["$d_key"]="${d_bytes_orig}|${d_bytes_reply}|${d_count}"
|
||||
_ACCEPT_DEST_KEYS["$d_peer"]+="${d_key} "
|
||||
;;
|
||||
esac
|
||||
done <<< "$accept_data"
|
||||
|
||||
# ── Measure column widths ──
|
||||
local w_peer=16 w_count=1
|
||||
|
||||
# Measure column widths
|
||||
local w_peer=16 w_drops=1
|
||||
while IFS='|' read -r type rest; do
|
||||
case "$type" in
|
||||
peer)
|
||||
|
|
@ -175,208 +120,83 @@ function cmd::activity::run() {
|
|||
name=$(echo "$rest" | cut -d'|' -f1)
|
||||
drops=$(echo "$rest" | cut -d'|' -f4)
|
||||
(( ${#name} > w_peer )) && w_peer=${#name}
|
||||
(( ${#drops} > w_count )) && w_count=${#drops}
|
||||
(( ${#drops} > w_drops )) && w_drops=${#drops}
|
||||
;;
|
||||
service)
|
||||
local svc_count
|
||||
svc_count=$(echo "$rest" | cut -d'|' -f3)
|
||||
(( ${#svc_count} > w_count )) && w_count=${#svc_count}
|
||||
local count
|
||||
count=$(echo "$rest" | cut -d'|' -f3)
|
||||
(( ${#count} > w_drops )) && w_drops=${#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 ))
|
||||
|
||||
# Compute column where drop count starts on peer row:
|
||||
# " " (2) + name (w_peer) + " ↓" (3) + rx (10) + " ↑" (3) + tx (10) + " " (2)
|
||||
# ↓ and ↑ are multi-byte (3 bytes, 1 visible) — 2 extra bytes each
|
||||
# Visible: 2 + w_peer + 2+1 + 10 + 2+1 + 10 + 2 = w_peer + 30
|
||||
local drops_col=$(( w_peer + 30 ))
|
||||
|
||||
|
||||
local hours_display="${hours}h"
|
||||
[[ "$hours" == "0" ]] && hours_display="all time"
|
||||
|
||||
|
||||
log::section "Activity Monitor (last ${hours_display})"
|
||||
echo ""
|
||||
|
||||
|
||||
if display::is_table "activity"; then
|
||||
cmd::activity::_render_table "$data"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ── Accept dest inline renderer ──
|
||||
_render_peer_accept_dests() {
|
||||
local peer_name="$1"
|
||||
local keys="${_ACCEPT_DEST_KEYS[$peer_name]:-}"
|
||||
[[ -z "$keys" ]] && return 0
|
||||
for d_key in $keys; do
|
||||
local dest_stats="${_ACCEPT_DEST[$d_key]:-}"
|
||||
[[ -z "$dest_stats" ]] && continue
|
||||
local d_bytes_orig d_bytes_reply d_count
|
||||
IFS='|' read -r d_bytes_orig d_bytes_reply d_count <<< "$dest_stats"
|
||||
local rest_key="${d_key#${peer_name}:}"
|
||||
local d_ip="${rest_key%%:*}"
|
||||
local pp="${rest_key#*:}"
|
||||
local d_port="${pp%%:*}"
|
||||
local d_proto="${pp##*:}"
|
||||
local spec="${d_ip}:${d_port}:${d_proto}"
|
||||
local dest_display
|
||||
local raw_suffix=""
|
||||
local resolved="${_DEST_RESOLVE_CACHE[$spec]:-${d_ip}:${d_port}/${d_proto}}"
|
||||
local dest_display="$resolved"
|
||||
if [[ "$show_ports" == "true" && "$resolved" != "${d_ip}:"* && "$resolved" != "${d_ip} "* ]]; then
|
||||
if [[ -n "$d_port" && "$d_port" != "0" ]]; then
|
||||
dest_display=$(printf "%s \033[2m(%s:%s)\033[0m" "$resolved" "$d_ip" "$d_port")
|
||||
else
|
||||
dest_display=$(printf "%s \033[2m(%s)\033[0m" "$resolved" "$d_ip")
|
||||
fi
|
||||
fi
|
||||
ui::activity::accept_dest_row \
|
||||
"$dest_display" "$d_bytes_orig" "$d_bytes_reply" \
|
||||
"$d_count" "$drops_col" "$w_count"
|
||||
done
|
||||
}
|
||||
|
||||
declare -gA _DEST_RESOLVE_CACHE=()
|
||||
local -a _dest_specs=()
|
||||
for _dk in "${!_ACCEPT_DEST[@]}"; do
|
||||
# key format: peer:ip:port:proto — strip peer prefix
|
||||
local _rest="${_dk#*:}"
|
||||
local _dip="${_rest%%:*}"
|
||||
local _pp="${_rest#*:}"
|
||||
local _dport="${_pp%%:*}"
|
||||
local _dproto="${_pp##*:}"
|
||||
local _spec="${_dip}:${_dport}:${_dproto}"
|
||||
# Deduplicate
|
||||
local _found=false
|
||||
for _s in "${_dest_specs[@]:-}"; do
|
||||
[[ "$_s" == "$_spec" ]] && _found=true && break
|
||||
done
|
||||
$_found || _dest_specs+=("$_spec")
|
||||
done
|
||||
|
||||
if [[ ${#_dest_specs[@]} -gt 0 ]]; then
|
||||
while IFS='|' read -r _spec _display; do
|
||||
[[ -n "$_spec" ]] && _DEST_RESOLVE_CACHE["$_spec"]="$_display"
|
||||
done < <(json::batch_resolve_dest "${_dest_specs[@]}" 2>/dev/null)
|
||||
fi
|
||||
|
||||
local first_peer=true skip_peer=false current_name=""
|
||||
local -a rendered_peers=()
|
||||
|
||||
# ── Main render loop (drop data) ──
|
||||
local first_peer=true 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"
|
||||
|
||||
# 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]:-}"
|
||||
|
||||
if $dropped_only && [[ "$drops" -eq 0 ]]; then
|
||||
skip_peer=true
|
||||
continue
|
||||
fi
|
||||
|
||||
$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
|
||||
|
||||
ui::activity::peer_row \
|
||||
"$name_pad" "$rx_pad" "$tx_pad" "$drops" "$drop_word" "$w_drops"
|
||||
;;
|
||||
|
||||
|
||||
service)
|
||||
local peer dest_display dst_ip dst_port proto drop_count
|
||||
IFS='|' read -r peer dest_display dst_ip dst_port proto drop_count <<< "$rest"
|
||||
# Build dim suffix if --ports
|
||||
local svc_display="$dest_display"
|
||||
if [[ "$show_ports" == "true" && -n "$dst_ip" ]]; then
|
||||
if [[ -n "$dst_port" ]]; then
|
||||
svc_display=$(printf "%s \033[2m(%s:%s)\033[0m" \
|
||||
"$dest_display" "$dst_ip" "$dst_port")
|
||||
else
|
||||
svc_display=$(printf "%s \033[2m(%s)\033[0m" \
|
||||
"$dest_display" "$dst_ip")
|
||||
fi
|
||||
fi
|
||||
$skip_peer && 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"
|
||||
$accept_only || ui::activity::service_row \
|
||||
"$svc_display" "$drop_count" "$svc_drop_word" "$drops_col" "$w_count"
|
||||
|
||||
ui::activity::service_row \
|
||||
"$dest_display" "$drop_count" "$svc_drop_word" "$drops_col" "$w_drops"
|
||||
;;
|
||||
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 ""
|
||||
}
|
||||
|
||||
|
|
@ -456,48 +276,3 @@ function cmd::activity::_output_json() {
|
|||
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"
|
||||
}
|
||||
|
|
@ -5,8 +5,6 @@
|
|||
# ============================================
|
||||
|
||||
function cmd::list::on_load() {
|
||||
command::mixin json_output
|
||||
|
||||
load_module identity
|
||||
load_module ui
|
||||
|
||||
|
|
@ -21,9 +19,7 @@ function cmd::list::on_load() {
|
|||
flag::register --allowed
|
||||
flag::register --detailed
|
||||
flag::register --name
|
||||
|
||||
# Mutually exclusive filter groups
|
||||
flag::exclusive --online --offline --blocked --restricted --allowed
|
||||
command::mixin json_output
|
||||
}
|
||||
|
||||
# ============================================
|
||||
|
|
|
|||
|
|
@ -23,8 +23,6 @@ function cmd::logs::on_load() {
|
|||
flag::register --ascending
|
||||
flag::register --descending
|
||||
flag::register --resolved
|
||||
|
||||
flag::exclusive --ascending --descending
|
||||
}
|
||||
|
||||
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
|
||||
fi
|
||||
|
||||
if $fw_only && $wg_only; then
|
||||
fw_only=false
|
||||
wg_only=false
|
||||
fi
|
||||
|
||||
if $follow; then
|
||||
cmd::logs::follow "$filter_ip" "$name" "$type" "$fw_only" "$wg_only"
|
||||
return
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ declare -A _LOADED_COMMANDS=()
|
|||
|
||||
readonly _COMMAND_NAMESPACE="cmd"
|
||||
readonly _COMMAND_AUTO_LOAD_HOOK="on_load"
|
||||
_CURRENT_LOADING_CMD=""
|
||||
|
||||
# ============================================
|
||||
# Helpers
|
||||
|
|
@ -37,57 +36,13 @@ function command::exists() { command::has_function "$1" run; }
|
|||
# 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() {
|
||||
local cmd="$1"
|
||||
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=()
|
||||
for _d in "${default_args[@]:-}"; do
|
||||
[[ -n "$_d" ]] && cleaned_defaults+=("$_d")
|
||||
done
|
||||
default_args=("${cleaned_defaults[@]:-}")
|
||||
command::_reset_mixin_state # reset values only, keep _ACTIVE_MIXINS
|
||||
|
||||
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)
|
||||
local -a args=("$@")
|
||||
command::_preprocess_flags args
|
||||
|
||||
local fn
|
||||
|
|
@ -122,9 +77,7 @@ function load_command() {
|
|||
source "$path"
|
||||
_LOADED_COMMANDS["$name"]=1
|
||||
|
||||
_CURRENT_LOADING_CMD="$name"
|
||||
core::call_if_exists "$(command::fn "$name" on_load)"
|
||||
_CURRENT_LOADING_CMD=""
|
||||
|
||||
return 0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#!/usr/bin/env bash
|
||||
# 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)
|
||||
|
|
@ -108,88 +108,4 @@ function command::_preprocess_flags() {
|
|||
else
|
||||
_args_ref=()
|
||||
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
|
||||
}
|
||||
|
|
@ -44,50 +44,49 @@ _CTX_CONFIG_FILE="${_CTX_CONFIG}/wgctl.json"
|
|||
# Accessors
|
||||
# ============================================
|
||||
|
||||
function ctx::root() { echo "$_CTX_ROOT"; }
|
||||
function ctx::core() { echo "$_CTX_CORE"; }
|
||||
function ctx::modules() { echo "$_CTX_MODULES"; }
|
||||
function ctx::commands() { echo "$_CTX_COMMANDS"; }
|
||||
function ctx::wg() { echo "$_CTX_WG"; }
|
||||
function ctx::clients() { echo "$_CTX_CLIENTS"; }
|
||||
function ctx::root() { echo "$_CTX_ROOT"; }
|
||||
function ctx::core() { echo "$_CTX_CORE"; }
|
||||
function ctx::modules() { echo "$_CTX_MODULES"; }
|
||||
function ctx::commands() { echo "$_CTX_COMMANDS"; }
|
||||
function ctx::wg() { echo "$_CTX_WG"; }
|
||||
function ctx::clients() { echo "$_CTX_CLIENTS"; }
|
||||
|
||||
# Top-level dirs
|
||||
function ctx::wgctl() { echo "$_CTX_WGCTL"; }
|
||||
function ctx::config() { echo "$_CTX_CONFIG"; }
|
||||
function ctx::data() { echo "$_CTX_DATA"; }
|
||||
function ctx::daemon() { echo "$_CTX_DAEMON"; }
|
||||
function ctx::wgctl() { echo "$_CTX_WGCTL"; }
|
||||
function ctx::config() { echo "$_CTX_CONFIG"; }
|
||||
function ctx::data() { echo "$_CTX_DATA"; }
|
||||
function ctx::daemon() { echo "$_CTX_DAEMON"; }
|
||||
|
||||
# Data subdirs
|
||||
function ctx::rules() { echo "$_CTX_RULES"; }
|
||||
function ctx::rules::base() { echo "$_CTX_RULES_BASE"; }
|
||||
function ctx::groups() { echo "$_CTX_GROUPS"; }
|
||||
function ctx::blocks() { echo "$_CTX_BLOCKS"; }
|
||||
function ctx::meta() { echo "$_CTX_META"; }
|
||||
function ctx::identities() { echo "$_CTX_IDENTITY"; }
|
||||
function ctx::peer_history() { echo "$_CTX_PEER_HISTORY"; }
|
||||
function ctx::rules() { echo "$_CTX_RULES"; }
|
||||
function ctx::rules::base() { echo "$_CTX_RULES_BASE"; }
|
||||
function ctx::groups() { echo "$_CTX_GROUPS"; }
|
||||
function ctx::blocks() { echo "$_CTX_BLOCKS"; }
|
||||
function ctx::meta() { echo "$_CTX_META"; }
|
||||
function ctx::identities() { echo "$_CTX_IDENTITY"; }
|
||||
function ctx::peer_history() { echo "$_CTX_PEER_HISTORY"; }
|
||||
|
||||
# Data files
|
||||
function ctx::net() { echo "$_CTX_NET"; }
|
||||
function ctx::hosts() { echo "$_CTX_HOSTS"; }
|
||||
function ctx::subnets() { echo "$_CTX_SUBNETS"; }
|
||||
function ctx::policies() { echo "$_CTX_POLICIES"; }
|
||||
function ctx::net() { echo "$_CTX_NET"; }
|
||||
function ctx::hosts() { echo "$_CTX_HOSTS"; }
|
||||
function ctx::subnets() { echo "$_CTX_SUBNETS"; }
|
||||
function ctx::policies() { echo "$_CTX_POLICIES"; }
|
||||
|
||||
# Config files
|
||||
function ctx::config_file() { echo "$_CTX_CONFIG_FILE"; }
|
||||
function ctx::display() { echo "${_CTX_CONFIG}/display.json"; }
|
||||
function ctx::config_file() { echo "$_CTX_CONFIG_FILE"; }
|
||||
function ctx::display() { echo "${_CTX_CONFIG}/display.json"; }
|
||||
|
||||
# Daemon files
|
||||
function ctx::events_log() { echo "${_CTX_DAEMON}/events.log"; }
|
||||
function ctx::fw_events_log() { echo "${_CTX_DAEMON}/fw_events.log"; }
|
||||
function ctx::endpoint_cache() { echo "${_CTX_DAEMON}/endpoint_cache.json"; }
|
||||
function ctx::accept_events_log() { echo "${_CTX_DAEMON}/accept_events.log"; }
|
||||
function ctx::events_log() { echo "${_CTX_DAEMON}/events.log"; }
|
||||
function ctx::fw_events_log() { echo "${_CTX_DAEMON}/fw_events.log"; }
|
||||
function ctx::endpoint_cache() { echo "${_CTX_DAEMON}/endpoint_cache.json"; }
|
||||
|
||||
# Tool paths
|
||||
function ctx::json_helper() { echo "${_CTX_CORE}/json_helper.py"; }
|
||||
function ctx::monitor_script() { echo "${_CTX_ROOT}/daemon/wgctl-monitor.py"; }
|
||||
function ctx::lib() { echo "${_CTX_CORE}/lib"; }
|
||||
function ctx::json_helper() { echo "${_CTX_CORE}/json_helper.py"; }
|
||||
function ctx::monitor_script() { echo "${_CTX_ROOT}/daemon/wgctl-monitor.py"; }
|
||||
function ctx::lib() { echo "${_CTX_CORE}/lib"; }
|
||||
|
||||
function ctx::block_history() { echo "${_CTX_DATA}/block-history"; }
|
||||
function ctx::block_history() { echo "${_CTX_DATA}/block-history"; }
|
||||
|
||||
# ============================================
|
||||
# Path Helpers
|
||||
|
|
|
|||
|
|
@ -154,11 +154,6 @@ function json::block_history_list_all() { python3 "$JSON_HELPER" block_history
|
|||
|
||||
function json::endpoint_cache_get() { python3 "$JSON_HELPER" endpoint_cache_get "$@" </dev/null; }
|
||||
|
||||
# Accept Events
|
||||
function json::accept_events() { python3 "$JSON_HELPER" accept_events "$@" </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() {
|
||||
ACTIVITY_TOTAL_LOW="$(config::activity_total_low)" \
|
||||
ACTIVITY_TOTAL_MED="$(config::activity_total_med)" \
|
||||
|
|
|
|||
|
|
@ -1607,23 +1607,6 @@ def config_load(file):
|
|||
emit('ACTIVITY_CURRENT_LOW_BYTES', acur.get('low'))
|
||||
emit('ACTIVITY_CURRENT_MED_BYTES', acur.get('medium'))
|
||||
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:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
|
@ -1838,60 +1821,6 @@ def endpoint_cache_get(cache_file, peer):
|
|||
except Exception:
|
||||
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):
|
||||
|
|
@ -2148,8 +2077,7 @@ 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[9] if len(args) > 9 else ''),
|
||||
args[8] if len(args) > 8 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]),
|
||||
|
|
@ -2297,21 +2225,6 @@ commands = {
|
|||
'block_history_list_all': lambda args: __import__('lib.block_history',
|
||||
fromlist=['block_history_list_all']).block_history_list_all(args[0]),
|
||||
'endpoint_cache_get': lambda args: endpoint_cache_get(args[0], args[1]),
|
||||
'accept_events': lambda args: __import__('lib.accept_events', fromlist=['accept_events']).accept_events(
|
||||
args[0], args[1], args[2], args[3],
|
||||
args[4] if len(args) > 4 else '100',
|
||||
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'),
|
||||
'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[6] if len(args) > 6 else ''),
|
||||
'batch_resolve_dest': lambda args: batch_resolve_dest(args[0], args[1], *args[2:]),
|
||||
}
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,260 +0,0 @@
|
|||
"""
|
||||
accept_events.py — conntrack accept event processing.
|
||||
|
||||
Reads accept_events.log written by wgctl-conntrack daemon.
|
||||
Each line is a JSON object with fields:
|
||||
ts, peer, src_ip, dst_ip, dst_port, proto,
|
||||
bytes_orig, bytes_reply, packets_orig, packets_reply,
|
||||
duration_sec, service, event, external
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
|
||||
from lib.util import (
|
||||
DATETIME_FMT,
|
||||
load_net_data, load_hosts_data,
|
||||
reverse_lookup, hosts_lookup,
|
||||
fmt_ts, fmt_ts_hour, ts_to_unix, parse_since,
|
||||
)
|
||||
|
||||
|
||||
def accept_events(file, filter_peer, filter_type, net_file,
|
||||
limit, collapse='1', since='', filter_external='0',
|
||||
sort_order='desc'):
|
||||
"""
|
||||
Format accept events with optional aggregation.
|
||||
|
||||
Output per line (collapse=1):
|
||||
ts|peer|dst_ip|dst_port|proto|bytes_total|packets_total|count|duration_avg
|
||||
|
||||
Output per line (collapse=0):
|
||||
ts|peer|dst_ip|dst_port|proto|bytes_orig|bytes_reply|packets_orig|packets_reply|duration_sec
|
||||
"""
|
||||
do_collapse = str(collapse) != '0'
|
||||
external_only = str(filter_external) == '1'
|
||||
limit = int(limit) if limit else 100
|
||||
since_dt = parse_since(since) if since else None
|
||||
descending = sort_order != 'asc'
|
||||
|
||||
events = []
|
||||
try:
|
||||
with open(file) as f:
|
||||
for line in f:
|
||||
try:
|
||||
e = json.loads(line.strip())
|
||||
if not e.get('peer'):
|
||||
continue
|
||||
if filter_peer and e.get('peer') != filter_peer:
|
||||
continue
|
||||
if filter_type and not e.get('peer', '').startswith(filter_type + '-'):
|
||||
continue
|
||||
if external_only and not e.get('external', False):
|
||||
continue
|
||||
if not external_only and e.get('external', False):
|
||||
continue
|
||||
if since_dt:
|
||||
ts_str = e.get('ts', '')
|
||||
try:
|
||||
from datetime import timezone
|
||||
ev_dt = datetime.fromisoformat(ts_str.replace('Z', '+00:00'))
|
||||
if ev_dt < since_dt:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
events.append(e)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if do_collapse:
|
||||
# Aggregate by peer + dst_ip + dst_port + proto + hour
|
||||
buckets = defaultdict(lambda: {'count': 0, 'bytes': 0, 'packets': 0, 'duration': 0.0})
|
||||
bucket_ts = {}
|
||||
|
||||
for e in events:
|
||||
ts_str = e.get('ts', '')
|
||||
peer = e.get('peer', '')
|
||||
dst_ip = e.get('dst_ip', '')
|
||||
dst_port = str(e.get('dst_port', ''))
|
||||
proto = e.get('proto', '')
|
||||
try:
|
||||
dt = datetime.fromisoformat(ts_str.replace('Z', '+00:00'))
|
||||
hour_key = (peer, dst_ip, dst_port, proto, dt.strftime('%Y-%m-%d %H'))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
b = buckets[hour_key]
|
||||
b['count'] += 1
|
||||
b['bytes'] += e.get('bytes_orig', 0) + e.get('bytes_reply', 0)
|
||||
b['packets'] += e.get('packets_orig', 0) + e.get('packets_reply', 0)
|
||||
b['duration'] += e.get('duration_sec', 0.0)
|
||||
|
||||
if hour_key not in bucket_ts:
|
||||
bucket_ts[hour_key] = dt
|
||||
|
||||
# Sort and limit
|
||||
sorted_buckets = sorted(bucket_ts.items(), key=lambda x: x[1])
|
||||
output = sorted_buckets[-limit:]
|
||||
if descending:
|
||||
output = list(reversed(output))
|
||||
|
||||
for hour_key, dt in output:
|
||||
peer, dst_ip, dst_port, proto, _ = hour_key
|
||||
b = buckets[hour_key]
|
||||
ts_fmt = fmt_ts_hour(dt.isoformat())
|
||||
dur_avg = b['duration'] / b['count'] if b['count'] > 0 else 0.0
|
||||
print(f"{ts_fmt}|{peer}|{dst_ip}|{dst_port}|{proto}|{b['bytes']}|{b['packets']}|{b['count']}|{dur_avg:.1f}")
|
||||
|
||||
else:
|
||||
# Detailed — one row per event
|
||||
result = [(ts_to_unix(e.get('ts', '')), e) for e in events]
|
||||
result = result[-limit:]
|
||||
if descending:
|
||||
result.reverse()
|
||||
|
||||
for _, e in result:
|
||||
ts_fmt = fmt_ts(e.get('ts', ''))
|
||||
peer = e.get('peer', '')
|
||||
dst_ip = e.get('dst_ip', '')
|
||||
dst_port = str(e.get('dst_port', ''))
|
||||
proto = e.get('proto', '')
|
||||
b_orig = e.get('bytes_orig', 0)
|
||||
b_reply = e.get('bytes_reply', 0)
|
||||
p_orig = e.get('packets_orig', 0)
|
||||
p_reply = e.get('packets_reply', 0)
|
||||
dur = e.get('duration_sec', 0.0)
|
||||
print(f"{ts_fmt}|{peer}|{dst_ip}|{dst_port}|{proto}|{b_orig}|{b_reply}|{p_orig}|{p_reply}|{dur:.1f}")
|
||||
|
||||
|
||||
def accept_aggregate(file, net_file, clients_dir, since='',
|
||||
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.
|
||||
|
||||
external_only='1': only show traffic to external IPs (non-private)
|
||||
external_only='0': only show traffic to internal IPs (default)
|
||||
|
||||
Output:
|
||||
peer|peer_name|bytes_in|bytes_out|packets_in|packets_out|conn_count
|
||||
dest|peer_name|dst_ip|dst_port|proto|bytes_total|conn_count
|
||||
"""
|
||||
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'
|
||||
|
||||
peer_stats = defaultdict(lambda: {
|
||||
'bytes_in': 0, 'bytes_out': 0,
|
||||
'packets_in': 0, 'packets_out': 0,
|
||||
'conn_count': 0
|
||||
})
|
||||
# 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:
|
||||
try:
|
||||
e = json.loads(line.strip())
|
||||
peer = e.get('peer', '')
|
||||
if not peer:
|
||||
continue
|
||||
if filter_peer and peer != filter_peer:
|
||||
continue
|
||||
|
||||
# Filter by external/internal
|
||||
is_external = e.get('external', False)
|
||||
if show_external and not is_external:
|
||||
continue
|
||||
if not show_external and is_external:
|
||||
continue
|
||||
|
||||
if since_dt:
|
||||
ts_str = e.get('ts', '')
|
||||
try:
|
||||
from datetime import timezone
|
||||
ev_dt = datetime.fromisoformat(
|
||||
ts_str.replace('Z', '+00:00'))
|
||||
if ev_dt < since_dt:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
dst_ip = e.get('dst_ip', '')
|
||||
dst_port = str(e.get('dst_port', ''))
|
||||
proto = e.get('proto', '')
|
||||
b_orig = e.get('bytes_orig', 0)
|
||||
b_reply = e.get('bytes_reply', 0)
|
||||
p_orig = e.get('packets_orig', 0)
|
||||
p_reply = e.get('packets_reply', 0)
|
||||
|
||||
ps = peer_stats[peer]
|
||||
ps['bytes_out'] += b_orig
|
||||
ps['bytes_in'] += b_reply
|
||||
ps['packets_out'] += p_orig
|
||||
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
|
||||
dest_stats[dest_key]['count'] += 1
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Output peer summaries
|
||||
for peer, ps in sorted(peer_stats.items()):
|
||||
print(f"peer|{peer}|{ps['bytes_in']}|{ps['bytes_out']}|"
|
||||
f"{ps['packets_in']}|{ps['packets_out']}|{ps['conn_count']}")
|
||||
|
||||
# Output top 5 destinations per peer sorted by byte count
|
||||
dest_items = sorted(
|
||||
dest_stats.items(),
|
||||
key=lambda x: (x[0][0], -(x[1]['bytes_orig'] + x[1]['bytes_reply']))
|
||||
)
|
||||
for peer, group in groupby(dest_items, key=lambda x: x[0][0]):
|
||||
top = list(group)[:20]
|
||||
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
|
||||
|
|
@ -19,49 +19,26 @@ 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, exclude_services=''):
|
||||
filter_service_ip):
|
||||
"""
|
||||
Aggregate activity data for wgctl activity.
|
||||
Output:
|
||||
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
|
||||
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)
|
||||
net_data = load_net_data(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)
|
||||
|
|
@ -80,12 +57,11 @@ def activity_aggregate(fw_file, wg_file, wg_interface, net_file,
|
|||
peer_tx[peer] += tx
|
||||
except Exception:
|
||||
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))
|
||||
|
||||
|
||||
if os.path.exists(fw_file):
|
||||
try:
|
||||
with open(fw_file) as f:
|
||||
|
|
@ -105,16 +81,16 @@ def activity_aggregate(fw_file, wg_file, wg_interface, net_file,
|
|||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
src_ip = ev.get('src_ip', '')
|
||||
if not src_ip:
|
||||
continue
|
||||
|
||||
|
||||
dest_ip = ev.get('dest_ip', '')
|
||||
dest_port = str(ev.get('dest_port', ''))
|
||||
proto_num = ev.get('ip.protocol', 0)
|
||||
proto = PROTO_MAP.get(int(proto_num), str(proto_num))
|
||||
|
||||
|
||||
peer = ip_to_peer.get(src_ip)
|
||||
if not peer:
|
||||
continue
|
||||
|
|
@ -122,23 +98,18 @@ def activity_aggregate(fw_file, wg_file, wg_interface, net_file,
|
|||
continue
|
||||
if filter_service_ip and dest_ip != filter_service_ip:
|
||||
continue
|
||||
|
||||
|
||||
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
|
||||
# Key includes raw ip:port:proto for --ports support
|
||||
svc_key = (dest_display, dest_ip, dest_port, proto)
|
||||
service_drops[peer][svc_key] += 1
|
||||
|
||||
service_drops[peer][dest_display] += 1
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Collect peers with any activity
|
||||
all_peers = set()
|
||||
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())
|
||||
if filter_peer:
|
||||
all_peers = {p for p in all_peers if p == filter_peer}
|
||||
|
||||
|
||||
for peer in sorted(all_peers):
|
||||
rx = peer_rx.get(peer, 0)
|
||||
tx = peer_tx.get(peer, 0)
|
||||
drops = peer_drops.get(peer, 0)
|
||||
print(f"peer|{peer}|{rx}|{tx}|{drops}")
|
||||
|
||||
|
||||
svc_map = service_drops.get(peer, {})
|
||||
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}")
|
||||
for dest_display, count in sorted(svc_map.items(), key=lambda x: -x[1]):
|
||||
print(f"service|{peer}|{dest_display}|{count}")
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Flags holds CLI flags
|
||||
type Flags struct {
|
||||
WGDir string
|
||||
Subnet string
|
||||
LogFile string
|
||||
Version bool
|
||||
}
|
||||
|
||||
const Version = "0.1.0"
|
||||
|
||||
func Parse() *Flags {
|
||||
f := &Flags{}
|
||||
flag.StringVar(&f.WGDir, "wg-dir", "/etc/wireguard", "WireGuard base directory")
|
||||
flag.StringVar(&f.Subnet, "subnet", "", "WireGuard subnet override")
|
||||
flag.StringVar(&f.LogFile, "log-file", "", "Accept events log file override")
|
||||
flag.BoolVar(&f.Version, "version", false, "Print version and exit")
|
||||
flag.Parse()
|
||||
|
||||
if f.Version {
|
||||
fmt.Println(Version)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
return f
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Config holds wgctl-conntrack runtime configuration
|
||||
type Config struct {
|
||||
WGSubnet string
|
||||
DataDir string
|
||||
ClientsDir string
|
||||
AcceptLogFile string
|
||||
ServicesFile string
|
||||
}
|
||||
|
||||
type wgctlJSON struct {
|
||||
WireGuard struct {
|
||||
Subnet string `json:"subnet"`
|
||||
} `json:"wireguard"`
|
||||
}
|
||||
|
||||
// Load reads config from wgctl.json and applies defaults
|
||||
func Load(wgDir string) (*Config, error) {
|
||||
cfg := &Config{
|
||||
WGSubnet: "10.1.0.0/16",
|
||||
DataDir: wgDir + "/.wgctl/data",
|
||||
ClientsDir: wgDir + "/clients",
|
||||
AcceptLogFile: wgDir + "/.wgctl/daemon/accept_events.log",
|
||||
ServicesFile: wgDir + "/.wgctl/data/services.json",
|
||||
}
|
||||
|
||||
jsonFile := wgDir + "/.wgctl/config/wgctl.json"
|
||||
if data, err := os.ReadFile(jsonFile); err == nil {
|
||||
var wj wgctlJSON
|
||||
if json.Unmarshal(data, &wj) == nil && wj.WireGuard.Subnet != "" {
|
||||
cfg.WGSubnet = wj.WireGuard.Subnet
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
package conntrack
|
||||
|
||||
import "time"
|
||||
|
||||
// EventType represents the type of traffic event
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventAccept EventType = "accept"
|
||||
EventExternal EventType = "external"
|
||||
)
|
||||
|
||||
// TrafficEvent is the normalized event written to the log
|
||||
type TrafficEvent struct {
|
||||
Timestamp time.Time `json:"ts"`
|
||||
Peer string `json:"peer"`
|
||||
SrcIP string `json:"src_ip"`
|
||||
DstIP string `json:"dst_ip"`
|
||||
DstPort uint16 `json:"dst_port"`
|
||||
Proto string `json:"proto"`
|
||||
BytesOrig uint64 `json:"bytes_orig"`
|
||||
BytesReply uint64 `json:"bytes_reply"`
|
||||
PacketsOrig uint64 `json:"packets_orig"`
|
||||
PacketsReply uint64 `json:"packets_reply"`
|
||||
DurationSec float64 `json:"duration_sec"`
|
||||
Service string `json:"service,omitempty"`
|
||||
Event EventType `json:"event"`
|
||||
External bool `json:"external"`
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
package conntrack
|
||||
|
||||
import "net"
|
||||
|
||||
var privateRanges = []string{
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
}
|
||||
|
||||
var privateCIDRs []*net.IPNet
|
||||
|
||||
func init() {
|
||||
for _, cidr := range privateRanges {
|
||||
_, ipnet, _ := net.ParseCIDR(cidr)
|
||||
privateCIDRs = append(privateCIDRs, ipnet)
|
||||
}
|
||||
}
|
||||
|
||||
func IsWGPeer(ip net.IP, wgSubnet *net.IPNet) bool {
|
||||
return wgSubnet.Contains(ip)
|
||||
}
|
||||
|
||||
func IsExternal(ip net.IP) bool {
|
||||
for _, cidr := range privateCIDRs {
|
||||
if cidr.Contains(ip) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func ProtoName(proto uint8) string {
|
||||
switch proto {
|
||||
case 6:
|
||||
return "tcp"
|
||||
case 17:
|
||||
return "udp"
|
||||
case 1:
|
||||
return "icmp"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
package conntrack
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
ct "github.com/ti-mo/conntrack"
|
||||
"github.com/ti-mo/netfilter"
|
||||
)
|
||||
|
||||
// Resolver maps IPs and ports to peer/service names
|
||||
type Resolver interface {
|
||||
PeerForIP(ip net.IP) string
|
||||
ServiceForDst(ip net.IP, port uint16, proto string) string
|
||||
}
|
||||
|
||||
// Subscriber listens for conntrack DESTROY events
|
||||
type Subscriber struct {
|
||||
wgSubnet *net.IPNet
|
||||
events chan<- TrafficEvent
|
||||
resolver Resolver
|
||||
}
|
||||
|
||||
func NewSubscriber(wgSubnet *net.IPNet, events chan<- TrafficEvent, resolver Resolver) *Subscriber {
|
||||
return &Subscriber{wgSubnet: wgSubnet, events: events, resolver: resolver}
|
||||
}
|
||||
|
||||
func (s *Subscriber) Run() error {
|
||||
conn, err := ct.Dial(nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
evCh := make(chan ct.Event, 256)
|
||||
|
||||
errCh, err := conn.Listen(evCh, 1, []netfilter.NetlinkGroup{
|
||||
netfilter.GroupCTDestroy,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Println("conntrack subscriber started")
|
||||
|
||||
for {
|
||||
select {
|
||||
case ev := <-evCh:
|
||||
s.processEvent(ev)
|
||||
case err := <-errCh:
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Subscriber) processEvent(ev ct.Event) {
|
||||
flow := ev.Flow
|
||||
if flow == nil {
|
||||
return
|
||||
}
|
||||
|
||||
tuple := flow.TupleOrig
|
||||
|
||||
// Skip IPv6
|
||||
if !tuple.IP.SourceAddress.Is4() || !tuple.IP.DestinationAddress.Is4() {
|
||||
return
|
||||
}
|
||||
|
||||
srcBytes := tuple.IP.SourceAddress.As4()
|
||||
dstBytes := tuple.IP.DestinationAddress.As4()
|
||||
srcIP := net.IP(srcBytes[:])
|
||||
dstIP := net.IP(dstBytes[:])
|
||||
|
||||
// Only process WireGuard peer traffic
|
||||
if !IsWGPeer(srcIP, s.wgSubnet) {
|
||||
return
|
||||
}
|
||||
|
||||
proto := ProtoName(tuple.Proto.Protocol)
|
||||
dstPort := tuple.Proto.DestinationPort
|
||||
external := IsExternal(dstIP)
|
||||
|
||||
peer := s.resolver.PeerForIP(srcIP)
|
||||
if peer == "" {
|
||||
return
|
||||
}
|
||||
|
||||
service := s.resolver.ServiceForDst(dstIP, dstPort, proto)
|
||||
|
||||
var durationSec float64
|
||||
if flow.Timestamp.Stop.After(flow.Timestamp.Start) {
|
||||
durationSec = flow.Timestamp.Stop.Sub(flow.Timestamp.Start).Seconds()
|
||||
}
|
||||
|
||||
eventType := EventAccept
|
||||
if external {
|
||||
eventType = EventExternal
|
||||
}
|
||||
|
||||
s.events <- TrafficEvent{
|
||||
Timestamp: time.Now().UTC(),
|
||||
Peer: peer,
|
||||
SrcIP: srcIP.String(),
|
||||
DstIP: dstIP.String(),
|
||||
DstPort: dstPort,
|
||||
Proto: proto,
|
||||
BytesOrig: flow.CountersOrig.Bytes,
|
||||
BytesReply: flow.CountersReply.Bytes,
|
||||
PacketsOrig: flow.CountersOrig.Packets,
|
||||
PacketsReply: flow.CountersReply.Packets,
|
||||
DurationSec: durationSec,
|
||||
Service: service,
|
||||
Event: eventType,
|
||||
External: external,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
module git.krilio.net/nuno/wgctl-conntrack
|
||||
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/josharian/native v1.1.0 // indirect
|
||||
github.com/mdlayher/netlink v1.7.2 // indirect
|
||||
github.com/mdlayher/socket v0.5.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/ti-mo/conntrack v0.6.0 // indirect
|
||||
github.com/ti-mo/netfilter v0.5.3 // indirect
|
||||
golang.org/x/net v0.39.0 // indirect
|
||||
golang.org/x/sync v0.14.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
)
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
|
||||
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
|
||||
github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g=
|
||||
github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
|
||||
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
|
||||
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/ti-mo/conntrack v0.6.0 h1:laiW2+dzKyS2u0aVr6FeRQs+v7cj4t7q+twolL/ZkjQ=
|
||||
github.com/ti-mo/conntrack v0.6.0/go.mod h1:4HZrFQQLOSuBzgQNid3H/wYyyp1kfGXUYxueXjIGibo=
|
||||
github.com/ti-mo/netfilter v0.5.3 h1:ikzduvnaUMwre5bhbNwWOd6bjqLMVb33vv0XXbK0xGQ=
|
||||
github.com/ti-mo/netfilter v0.5.3/go.mod h1:08SyBCg6hu1qyQk4s3DjjJKNrm3RTb32nm6AzyT972E=
|
||||
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
Binary file not shown.
|
|
@ -1,71 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"git.krilio.net/nuno/wgctl-conntrack/cmd"
|
||||
"git.krilio.net/nuno/wgctl-conntrack/config"
|
||||
ctconn "git.krilio.net/nuno/wgctl-conntrack/conntrack"
|
||||
"git.krilio.net/nuno/wgctl-conntrack/resolver"
|
||||
"git.krilio.net/nuno/wgctl-conntrack/writer"
|
||||
)
|
||||
|
||||
func main() {
|
||||
flags := cmd.Parse()
|
||||
|
||||
cfg, err := config.Load(flags.WGDir)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
if flags.Subnet != "" {
|
||||
cfg.WGSubnet = flags.Subnet
|
||||
}
|
||||
if flags.LogFile != "" {
|
||||
cfg.AcceptLogFile = flags.LogFile
|
||||
}
|
||||
|
||||
_, wgSubnet, err := net.ParseCIDR(cfg.WGSubnet)
|
||||
if err != nil {
|
||||
log.Fatalf("invalid WG subnet %q: %v", cfg.WGSubnet, err)
|
||||
}
|
||||
|
||||
log.Printf("wgctl-conntrack v%s starting (subnet: %s, log: %s)",
|
||||
cmd.Version, cfg.WGSubnet, cfg.AcceptLogFile)
|
||||
|
||||
peerResolver := resolver.NewPeerResolver(flags.WGDir)
|
||||
svcResolver := resolver.NewServiceResolver(cfg.ServicesFile)
|
||||
|
||||
res := &combinedResolver{peers: peerResolver, services: svcResolver}
|
||||
events := make(chan ctconn.TrafficEvent, 512)
|
||||
|
||||
go writer.NewLogWriter(cfg.AcceptLogFile).Run(events)
|
||||
|
||||
sub := ctconn.NewSubscriber(wgSubnet, events, res)
|
||||
go func() {
|
||||
if err := sub.Run(); err != nil {
|
||||
log.Fatalf("conntrack subscriber error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sig
|
||||
log.Println("wgctl-conntrack shutting down")
|
||||
}
|
||||
|
||||
type combinedResolver struct {
|
||||
peers *resolver.PeerResolver
|
||||
services *resolver.ServiceResolver
|
||||
}
|
||||
|
||||
func (r *combinedResolver) PeerForIP(ip net.IP) string {
|
||||
return r.peers.PeerForIP(ip)
|
||||
}
|
||||
|
||||
func (r *combinedResolver) ServiceForDst(ip net.IP, port uint16, proto string) string {
|
||||
return r.services.ServiceForDst(ip, port, proto)
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
package resolver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PeerResolver maps WireGuard peer IPs to peer names
|
||||
type PeerResolver struct {
|
||||
mu sync.RWMutex
|
||||
ipToName map[string]string
|
||||
wgDir string
|
||||
}
|
||||
|
||||
func NewPeerResolver(wgDir string) *PeerResolver {
|
||||
r := &PeerResolver{wgDir: wgDir, ipToName: make(map[string]string)}
|
||||
r.reload()
|
||||
go r.watchReload()
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *PeerResolver) PeerForIP(ip net.IP) string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.ipToName[ip.String()]
|
||||
}
|
||||
|
||||
func (r *PeerResolver) reload() {
|
||||
newMap := make(map[string]string)
|
||||
|
||||
// WireGuard IPs from conf files (10.1.x.x → peer name)
|
||||
clientsDir := r.wgDir + "/clients"
|
||||
entries, err := os.ReadDir(clientsDir)
|
||||
if err == nil {
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".conf") {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSuffix(entry.Name(), ".conf")
|
||||
if ip := parseAddressFromConf(clientsDir + "/" + entry.Name()); ip != "" {
|
||||
newMap[ip] = name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// External IPs from endpoint index (external IP → peer name)
|
||||
indexFile := r.wgDir + "/.wgctl/data/peer-history/endpoint_index.json"
|
||||
if data, err := os.ReadFile(indexFile); err == nil {
|
||||
var index map[string]string
|
||||
if json.Unmarshal(data, &index) == nil {
|
||||
for ip, peer := range index {
|
||||
newMap[ip] = peer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
r.ipToName = newMap
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *PeerResolver) watchReload() {
|
||||
ticker := time.NewTicker(60 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
r.reload()
|
||||
}
|
||||
}
|
||||
|
||||
func parseAddressFromConf(path string) string {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "Address") {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
ip := strings.TrimSpace(parts[1])
|
||||
if idx := strings.Index(ip, "/"); idx != -1 {
|
||||
ip = ip[:idx]
|
||||
}
|
||||
return ip
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
package resolver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ServiceResolver maps IP:port:proto to service names
|
||||
type ServiceResolver struct {
|
||||
mu sync.RWMutex
|
||||
portToSvc map[string]string
|
||||
servicesFile string
|
||||
}
|
||||
|
||||
func NewServiceResolver(servicesFile string) *ServiceResolver {
|
||||
r := &ServiceResolver{servicesFile: servicesFile, portToSvc: make(map[string]string)}
|
||||
r.reload()
|
||||
go r.watchReload()
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *ServiceResolver) ServiceForDst(ip net.IP, port uint16, proto string) string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
// Try IP:port:proto first
|
||||
if svc, ok := r.portToSvc[fmt.Sprintf("%s:%d:%s", ip.String(), port, proto)]; ok {
|
||||
return svc
|
||||
}
|
||||
// Fall back to IP only
|
||||
if svc, ok := r.portToSvc[ip.String()]; ok {
|
||||
return svc
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (r *ServiceResolver) reload() {
|
||||
data, err := os.ReadFile(r.servicesFile)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var services map[string]interface{}
|
||||
if json.Unmarshal(data, &services) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
newMap := make(map[string]string)
|
||||
for name, svcRaw := range services {
|
||||
svc, ok := svcRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
hosts := map[string]bool{}
|
||||
if hostsRaw, ok := svc["hosts"].(map[string]interface{}); ok {
|
||||
for ip := range hostsRaw {
|
||||
hosts[ip] = true
|
||||
newMap[ip] = name
|
||||
}
|
||||
}
|
||||
|
||||
if portsRaw, ok := svc["ports"].([]interface{}); ok {
|
||||
for _, portRaw := range portsRaw {
|
||||
port, ok := portRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
portNum := fmt.Sprintf("%.0f", port["port"])
|
||||
proto, _ := port["proto"].(string)
|
||||
for ip := range hosts {
|
||||
newMap[fmt.Sprintf("%s:%s:%s", ip, portNum, proto)] = name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
r.portToSvc = newMap
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *ServiceResolver) watchReload() {
|
||||
ticker := time.NewTicker(60 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
r.reload()
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
|
@ -1,21 +0,0 @@
|
|||
[Unit]
|
||||
Description=wgctl conntrack accept logging daemon
|
||||
After=network.target wg-quick@wg0.service
|
||||
Requires=wg-quick@wg0.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/etc/wireguard/wgctl/daemon/wgctl-conntrack/wgctl-conntrack \
|
||||
--wg-dir /etc/wireguard
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=wgctl-conntrack
|
||||
|
||||
# Needs CAP_NET_ADMIN for netlink conntrack
|
||||
AmbientCapabilities=CAP_NET_ADMIN
|
||||
CapabilityBoundingSet=CAP_NET_ADMIN
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
package writer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"git.krilio.net/nuno/wgctl-conntrack/conntrack"
|
||||
)
|
||||
|
||||
// LogWriter writes TrafficEvents as JSON lines to a file
|
||||
type LogWriter struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewLogWriter(path string) *LogWriter {
|
||||
return &LogWriter{path: path}
|
||||
}
|
||||
|
||||
func (w *LogWriter) Write(ev conntrack.TrafficEvent) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
f, err := os.OpenFile(w.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
data, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = f.Write(append(data, '\n'))
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *LogWriter) Run(events <-chan conntrack.TrafficEvent) {
|
||||
for ev := range events {
|
||||
if err := w.Write(ev); err != nil {
|
||||
log.Printf("error writing event: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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_HIGH_BYTES="${ACTIVITY_CURRENT_HIGH_BYTES:-100000000}"
|
||||
|
||||
declare -gA _COMMAND_DEFAULTS=()
|
||||
declare -gA _COMMAND_ALIASES=()
|
||||
|
||||
function config::_init_defaults() {
|
||||
_WG_INTERFACE="wg0"
|
||||
_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_MED_BYTES) _ACTIVITY_CURRENT_MED_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
|
||||
done < <(json::config_load "$file" 2>/dev/null)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
#!/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}"
|
||||
|
|
@ -9,78 +10,25 @@ function ui::activity::peer_row() {
|
|||
"$name_pad" "$rx_pad" "$tx_pad" "$drops" "$drop_word"
|
||||
}
|
||||
|
||||
# ── _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)
|
||||
# ui::activity::service_row <dest_display> <drop_count> <drop_word> <drops_col> <w_drops>
|
||||
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}"
|
||||
drops_col="${4:-30}" w_drops="${5:-1}"
|
||||
|
||||
# Align drop count with peer drop column
|
||||
# Service row visible prefix: " → " (6 visible) + ${#dest_display}
|
||||
# But "→" is 3 bytes, 1 visible — arrow_prefix bytes = 8, visible = 6
|
||||
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_display")
|
||||
local prefix_len=$(( prefix_bytes + dest_visible_len ))
|
||||
local prefix_bytes=${#arrow_prefix} # 8 bytes due to → being 3 bytes
|
||||
local prefix_len=$(( prefix_bytes + ${#dest_display} ))
|
||||
local pad_n=$(( drops_col - prefix_len ))
|
||||
[[ $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[2m→\033[0m %s%*s %${w_drops}s %s\n" \
|
||||
"$dest_display" "$pad_n" "" "$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}
|
||||
# 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 ──────────────────────────────────────
|
||||
|
||||
# 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})"
|
||||
|
|
|
|||
7
wgctl
7
wgctl
|
|
@ -43,6 +43,7 @@ declare -A CMD_ALIASES=(
|
|||
[del]=remove
|
||||
[delete]=remove
|
||||
[mv]=rename
|
||||
[ls]=list
|
||||
[show]=list
|
||||
[monitor]=watch
|
||||
[ban]=block
|
||||
|
|
@ -52,6 +53,7 @@ declare -A CMD_ALIASES=(
|
|||
[down]=service
|
||||
[reload]=service
|
||||
[stat]=service
|
||||
[log]=service
|
||||
[start]=service
|
||||
[stop]=service
|
||||
[restart]=service
|
||||
|
|
@ -76,11 +78,6 @@ function wgctl::dispatch() {
|
|||
local 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
|
||||
help) wgctl::help; return ;;
|
||||
shell) : ;;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue