wgctl/commands/logs.command.sh
Nuno Duque Nunes 7120199004 feat: logs --resolved flag, logs clean, performance improvements
- logs --resolved: show only resolved names, hide raw IPs
- logs clean: remove keepalive handshakes via json::clean_handshakes
- batch_resolve: single Python call for all endpoint resolutions
- fw_row/wg_row: native bash padding replaces ui::pad_mb (5x speedup)
- fw_row/wg_row: correct arrow byte counting (→ = 3 bytes, 1 visible)
- help: updated with new subcommands and flags
- on_load: --resolved, --ascending, --descending registered
2026-05-26 04:34:39 +00:00

620 lines
No EOL
19 KiB
Bash

#!/usr/bin/env bash
FW_EVENTS_LOG="$(ctx::fw_events_log)"
WG_EVENTS_LOG="$(ctx::events_log)"
function cmd::logs::on_load() {
flag::register --name
flag::register --type
flag::register --since
flag::register --limit
flag::register --fw
flag::register --wg
flag::register --follow
flag::register --merged
flag::register --all
flag::register --before
flag::register --force
flag::register --days
flag::register --raw
flag::register --detailed
flag::register --service
flag::register --event
flag::register --ascending
flag::register --descending
flag::register --resolved
}
function cmd::logs::help() {
cat <<EOF
Usage: wgctl logs [subcommand] [options]
Show or manage WireGuard and firewall activity logs.
Subcommands:
show (default) Show activity logs
clean Remove keepalive handshakes (deduplicate)
remove, rm Remove log entries
rotate Remove entries older than N days
Options for show:
--name <name> Filter by client name
--type <type> Filter by device type
--limit <n> Max results per source (default: 50)
--since <time> Show events since: 2h, 7d, 23/05, 23/05/2026, 2026-05-23
--service <svc> Filter by service name, IP, or IP:port
e.g. pihole, proxmox:web-ui, 10.0.0.100, 10.0.0.100:8006
--event <type> Filter wg events: attempt | handshake
--fw Show only firewall drops
--wg Show only WireGuard events
--merged Show all events chronologically interleaved
--detailed Show all deduplicated events (bypass hourly collapse)
--follow, -f Follow logs in real time
--raw Show raw IPs without service annotation
--resolved Show only resolved names, hide raw IPs
--ascending Sort oldest first
--descending Sort newest first (default)
Options for clean:
--wg Clean only WireGuard events (default: handshakes only)
--force Skip confirmation
Options for remove:
--name <name> Remove entries for specific peer
--all Remove all log entries
--fw Remove only firewall events
--wg Remove only WireGuard events
--before <days> Remove entries older than N days
--force Skip confirmation
Options for rotate:
--days <n> Days to keep (default: 7)
--force Skip confirmation
Examples:
wgctl logs
wgctl logs --since 2h
wgctl logs --since 23/05
wgctl logs --name phone-nuno --since 7d
wgctl logs --fw --service pihole
wgctl logs --fw --service proxmox:web-ui
wgctl logs --fw --service 10.0.0.100
wgctl logs --wg --event attempt
wgctl logs --wg --event handshake --since 24h
wgctl logs --detailed
wgctl logs --resolved
wgctl logs --merged
wgctl logs --follow
wgctl logs clean
wgctl logs clean --force
wgctl logs remove --name phone-nuno
wgctl logs rotate --days 30
EOF
}
function cmd::logs::run() {
local subcmd="${1:-show}"
if [[ "$subcmd" == --* ]]; then
subcmd="show"
else
shift || true
fi
case "$subcmd" in
show) cmd::logs::show "$@" ;;
remove|rm|del) cmd::logs::remove "$@" ;;
rotate) cmd::logs::rotate "$@" ;;
clean) cmd::logs::clean "$@" ;;
help) cmd::logs::help ;;
*)
log::error "Unknown subcommand: '${subcmd}'"
cmd::logs::help
return 1
;;
esac
}
function cmd::logs::show() {
local name="" type="" limit=50 since=""
local fw_only=false wg_only=false follow=false merged=false
local raw=false detailed=false
local filter_service="" filter_event=""
local sort_order="desc"
local resolved=false
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;;
--limit) limit="$2"; shift 2 ;;
--since) since="$2"; shift 2 ;;
--service) filter_service="$2"; shift 2 ;;
--event) filter_event="$2"; shift 2 ;;
--fw) fw_only=true; shift ;;
--wg) wg_only=true; shift ;;
--merged) merged=true; shift ;;
--follow|-f) follow=true; shift ;;
--raw) raw=true; shift ;;
--resolved) resolved=true; shift ;;
--detailed) detailed=true; shift ;;
--help) cmd::logs::help; return ;;
*)
log::error "Unknown flag: $1"
return 1
;;
esac
done
local collapse=1
$detailed && collapse=0
if [[ -n "$name" && -n "$type" ]]; then
name=$(peers::resolve_and_require "$name" "$type") || return 1
fi
local filter_ip=""
if [[ -n "$name" ]]; then
filter_ip=$(peers::get_ip "$name")
[[ -z "$filter_ip" ]] && log::error "Could not find IP for: $name" && return 1
fi
if $follow; then
cmd::logs::follow "$filter_ip" "$name" "$type" "$fw_only" "$wg_only"
return
fi
local net_file=""
$raw || net_file="$(ctx::net)"
# Parse --service into dest_ip and dest_port
local filter_dest_ip="" filter_dest_port=""
if [[ -n "$filter_service" ]]; then
if [[ "$filter_service" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(:[0-9]+)?$ ]]; then
filter_dest_ip="${filter_service%%:*}"
local maybe_port="${filter_service##*:}"
[[ "$maybe_port" != "$filter_dest_ip" ]] && filter_dest_port="$maybe_port"
else
local svc_resolved
svc_resolved=$(net::resolve "$filter_service" 2>/dev/null | head -1)
if [[ -n "$svc_resolved" ]]; then
filter_dest_ip="${svc_resolved%%:*}"
local rest="${svc_resolved#*:}"
[[ "$rest" != "$filter_dest_ip" ]] && filter_dest_port="${rest%%:*}"
else
log::error "Service not found: ${filter_service}"
return 1
fi
fi
fi
if $merged; then
log::section "WireGuard Activity Log"
printf "\n"
cmd::logs::show_merged "$filter_ip" "$name" "$type" "$limit" "$net_file" "$since"
return
fi
# Collect output — only show header if there's data
local fw_output="" wg_output=""
$wg_only || fw_output=$(cmd::logs::show_fw_events \
"$filter_ip" "$name" "$type" "$limit" "$net_file" \
"$collapse" "$since" "$filter_dest_ip" "$filter_dest_port" "$sort_order" "$resolved")
$fw_only || wg_output=$(cmd::logs::show_wg_events \
"$filter_ip" "$name" "$type" "$limit" \
"$collapse" "$since" "$filter_event" "$sort_order" "$resolved")
if [[ -z "$(echo "$fw_output" | tr -d '[:space:]')" && \
-z "$(echo "$wg_output" | tr -d '[:space:]')" ]]; then
log::wg_warning "No logs found"
return 0
fi
log::section "WireGuard Activity Log"
printf "\n"
if [[ -n "$fw_output" && -n "$wg_output" ]]; then
printf "%s\n\n" "$fw_output"
printf "%s\n" "$wg_output"
elif [[ -n "$fw_output" ]]; then
printf "%s\n" "$fw_output"
else
printf "%s\n" "$wg_output"
fi
}
function cmd::logs::show_fw_events() {
local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" \
limit="${4:-50}" net_file="${5:-}" collapse="${6:-1}" \
since="${7:-}" filter_dest_ip="${8:-}" filter_dest_port="${9:-}" \
sort_order="${10:-desc}" resolved_only="${11:-false}"
[[ ! -f "$FW_EVENTS_LOG" ]] && return 0
local data
data=$(json::fw_events \
"$FW_EVENTS_LOG" "$filter_ip" "$filter_type" \
"$(ctx::clients)" "${net_file:-}" \
"$limit" "$collapse" "$since" \
"$filter_dest_ip" "$filter_dest_port" \
"$sort_order" \
2>/dev/null)
[[ -z "$data" ]] && return 0
# ── Collect unique endpoints for batch resolution ──
local -a ep_list=()
while IFS='|' read -r ts client dest_ip dest_port proto svc count src_endpoint; do
[[ -z "$ts" || -z "$src_endpoint" ]] && continue
ep_list+=("$src_endpoint")
done <<< "$data"
declare -A resolve_cache=()
if [[ ${#ep_list[@]} -gt 0 ]]; then
while IFS='|' read -r ip name; do
[[ -n "$ip" ]] && resolve_cache["$ip"]="$name"
done < <(json::batch_resolve "${ep_list[@]}" 2>/dev/null)
fi
# ── Pass 1: measure widths ──
local w_client=16 w_dest=20 w_endpoint=0
local resolved_data=""
while IFS='|' read -r ts client dest_ip dest_port proto svc count src_endpoint; do
[[ -z "$ts" ]] && continue
(( ${#client} > w_client )) && w_client=${#client}
local svc_display=""
if [[ -n "$svc" ]]; then
[[ -n "$dest_port" ]] && svc_display="${svc}/${proto}" \
|| svc_display="${svc} (${proto})"
else
[[ -n "$dest_port" ]] && svc_display="${dest_ip}:${dest_port}/${proto}" \
|| svc_display="${dest_ip} (${proto})"
fi
local measure_len
if $resolved_only; then
measure_len=${#svc_display}
else
local raw_plain=""
[[ -n "$svc" && -n "$dest_port" ]] && raw_plain=" (${dest_ip}:${dest_port})"
[[ -n "$svc" && -z "$dest_port" ]] && raw_plain=" (${dest_ip})"
measure_len=$(( ${#svc_display} + ${#raw_plain} ))
fi
(( measure_len > w_dest )) && w_dest=$measure_len
local src_resolved=""
if [[ -n "$src_endpoint" ]]; then
src_resolved="${resolve_cache[$src_endpoint]:-}"
[[ "$src_resolved" == "$src_endpoint" ]] && src_resolved=""
local ep_measure_len
if $resolved_only; then
ep_measure_len=${#src_resolved}
[[ -z "$src_resolved" ]] && ep_measure_len=${#src_endpoint}
else
ep_measure_len=${#src_endpoint}
[[ -n "$src_resolved" ]] && \
ep_measure_len=$(( ${#src_endpoint} + 4 + ${#src_resolved} ))
fi
(( ep_measure_len > w_endpoint )) && w_endpoint=$ep_measure_len
fi
resolved_data+="${ts}|${client}|${dest_ip}|${dest_port}|${proto}|${svc}|${count}|${src_endpoint}|${src_resolved}"$'\n'
done <<< "$data"
(( w_client += 2 ))
(( w_dest += 2 ))
[[ "$w_endpoint" -gt 0 ]] && (( w_endpoint += 2 ))
# ── Pass 2: render ──
ui::logs::fw_section_header
while IFS='|' read -r ts client dest_ip dest_port proto svc count src_endpoint src_resolved; do
[[ -z "$ts" ]] && continue
ui::logs::fw_row "$ts" "$client" "$dest_ip" "$dest_port" \
"$proto" "$svc" "$count" "$w_client" "$w_dest" \
"$src_endpoint" "$src_resolved" "$w_endpoint" "$resolved_only"
done <<< "$resolved_data"
printf "\n"
}
function cmd::logs::show_wg_events() {
local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" \
limit="${4:-50}" collapse="${5:-1}" \
since="${6:-}" filter_event="${7:-}" sort_order="${8:-desc}" \
resolved_only="${9:-false}"
[[ ! -f "$WG_EVENTS_LOG" ]] && return 0
local data
data=$(json::wg_events \
"$WG_EVENTS_LOG" "$filter_name" "$filter_type" \
"$limit" "$collapse" "$since" "$filter_event" \
"$(ctx::endpoint_cache)" "$sort_order" \
2>/dev/null)
[[ -z "$data" ]] && return 0
# ── Collect unique endpoints for batch resolution ──
local -a ep_list=()
while IFS='|' read -r ts client endpoint event count gap_seconds; do
[[ -z "$ts" || -z "$endpoint" ]] && continue
ep_list+=("$endpoint")
done <<< "$data"
declare -A resolve_cache=()
if [[ ${#ep_list[@]} -gt 0 ]]; then
while IFS='|' read -r ip name; do
[[ -n "$ip" ]] && resolve_cache["$ip"]="$name"
done < <(json::batch_resolve "${ep_list[@]}" 2>/dev/null)
fi
# ── Measure widths ──
local w_client=16 w_endpoint=16
local resolved_data=""
while IFS='|' read -r ts client endpoint event count gap_seconds; do
[[ -z "$ts" ]] && continue
(( ${#client} > w_client )) && w_client=${#client}
local resolved=""
if [[ -n "$endpoint" ]]; then
resolved="${resolve_cache[$endpoint]:-}"
[[ "$resolved" == "$endpoint" ]] && resolved=""
fi
local ep_raw="${endpoint:--}"
local ep_measure_len
if $resolved_only; then
local ep_display="${resolved:-$endpoint}"
[[ -z "$ep_display" ]] && ep_display="-"
ep_measure_len=${#ep_display}
else
ep_measure_len=${#ep_raw}
[[ -n "$resolved" && -n "$endpoint" ]] && \
ep_measure_len=$(( ${#endpoint} + 4 + ${#resolved} ))
fi
(( ep_measure_len > w_endpoint )) && w_endpoint=$ep_measure_len
resolved_data+="${ts}|${client}|${endpoint}|${event}|${count}|${gap_seconds}|${resolved}"$'\n'
done <<< "$data"
(( w_client += 2 ))
(( w_endpoint += 2 ))
# ── Render ──
ui::logs::wg_section_header
while IFS='|' read -r ts client endpoint event count gap_seconds resolved; do
[[ -z "$ts" ]] && continue
if $resolved_only; then
local ep_display="${resolved:-$endpoint}"
[[ -z "$ep_display" ]] && ep_display="-"
ui::logs::wg_row "$ts" "$client" "$ep_display" "$event" \
"$count" "$w_client" "$w_endpoint" "$gap_seconds" ""
else
ui::logs::wg_row "$ts" "$client" "$endpoint" "$event" \
"$count" "$w_client" "$w_endpoint" "$gap_seconds" "$resolved"
fi
done <<< "$resolved_data"
printf "\n"
}
function cmd::logs::show_merged() {
local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" \
limit="${4:-50}" net_file="${5:-}" since="${6:-}"
local fw_data wg_data
fw_data=$(json::fw_events \
"$FW_EVENTS_LOG" "$filter_ip" "$filter_type" \
"$(ctx::clients)" "${net_file:-}" \
"$limit" "1" "$since" "" "" \
2>/dev/null)
wg_data=$(json::wg_events \
"$WG_EVENTS_LOG" "$filter_name" "$filter_type" \
"$limit" "1" "$since" "" \
2>/dev/null)
local w_client=16 w_dest=20
while IFS='|' read -r ts client rest; do
[[ -z "$ts" ]] && continue
(( ${#client} > w_client )) && w_client=${#client}
done < <(echo "$fw_data"; echo "$wg_data")
(( w_client += 2 ))
local merged_data
merged_data=$(
while IFS='|' read -r ts client dest_ip dest_port proto svc count; do
[[ -z "$ts" ]] && continue
echo "fw|${ts}|${client}|${dest_ip}|${dest_port}|${proto}|${svc}|${count}"
done <<< "$fw_data"
while IFS='|' read -r ts client endpoint event count; do
[[ -z "$ts" ]] && continue
echo "wg|${ts}|${client}|${endpoint}|${event}|${count}"
done <<< "$wg_data"
)
while IFS='|' read -r source ts rest; do
[[ -z "$source" ]] && continue
case "$source" in
fw)
IFS='|' read -r client dest_ip dest_port proto svc count <<< "$rest"
local dest_display
if [[ -n "$svc" ]]; then
[[ -n "$dest_port" ]] && dest_display="${svc}/${proto}" || dest_display="${svc} (${proto})"
else
[[ -n "$dest_port" ]] && dest_display="${dest_ip}:${dest_port}/${proto}" || dest_display="${dest_ip} (${proto})"
fi
(( ${#dest_display} > w_dest )) && w_dest=${#dest_display}
;;
esac
done <<< "$merged_data"
(( w_dest += 2 ))
while IFS='|' read -r source ts rest; do
[[ -z "$source" ]] && continue
case "$source" in
fw)
IFS='|' read -r client dest_ip dest_port proto svc count <<< "$rest"
ui::watch::fw_row "$ts" "$client" \
"$(ui::logs::build_dest "$dest_ip" "$dest_port" "$proto" "$svc")" \
"$w_client" "$w_dest"
;;
wg)
IFS='|' read -r client endpoint event count <<< "$rest"
ui::watch::wg_row "$ts" "$client" "$endpoint" "$event" \
"$w_client" "$w_dest"
;;
esac
done < <(echo "$merged_data" | sort -t'|' -k2,2)
printf "\n"
}
function cmd::logs::follow() {
local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}"
local fw_only="${4:-false}" wg_only="${5:-false}"
log::section "WireGuard Live Log (Ctrl+C to stop)"
printf "\n"
local restricted_only=false blocked_only=false
$fw_only && restricted_only=true
$wg_only && blocked_only=true
monitor::live "$filter_name" "$filter_type" "" \
"$blocked_only" "$restricted_only" "false" "false"
}
function cmd::logs::remove() {
local name="" type="" before="" force=false
local fw_only=false wg_only=false all=false
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;;
--before) before="$2"; shift 2 ;;
--fw) fw_only=true; shift ;;
--wg) wg_only=true; shift ;;
--all) all=true; shift ;;
--force) force=true; shift ;;
--help) cmd::logs::help; return ;;
*)
log::error "Unknown flag: $1"
return 1
;;
esac
done
if ! $all && [[ -z "$name" && -z "$before" ]]; then
log::error "Specify --name, --before, or --all"
cmd::logs::help
return 1
fi
local filter_ip=""
if [[ -n "$name" ]]; then
name=$(peers::resolve_and_require "$name" "$type") || return 1
filter_ip=$(peers::get_ip "$name")
fi
local desc=""
$all && desc="all entries"
[[ -n "$name" ]] && desc="entries for '${name}'"
[[ -n "$before" ]] && desc="${desc:+$desc, }entries older than ${before} days"
$fw_only && desc="${desc} (fw only)"
$wg_only && desc="${desc} (wg only)"
if ! $force; then
read -r -p "Remove ${desc}? [y/N] " confirm
case "$confirm" in
[yY]*) ;;
*) log::info "Aborted"; return 0 ;;
esac
fi
local result
result=$(json::remove_events_filtered \
"$WG_EVENTS_LOG" "$FW_EVENTS_LOG" \
"${name:-}" "${filter_ip:-}" \
"$fw_only" "$wg_only" \
"${before:-}")
local removed_wg removed_fw
IFS="|" read -r removed_wg removed_fw <<< "$result"
local total=$(( removed_wg + removed_fw ))
if [[ "$total" -eq 0 ]]; then
log::wg_warning "No log entries found matching the criteria"
return 0
fi
log::wg_success "Removed ${total} log entries (wg: ${removed_wg}, fw: ${removed_fw})"
}
function cmd::logs::rotate() {
local days=7 force=false
while [[ $# -gt 0 ]]; do
case "$1" in
--days) days="$2"; shift 2 ;;
--force) force=true; shift ;;
--help) cmd::logs::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
$force || {
read -r -p "Remove log entries older than ${days} days? [y/N] " confirm
case "$confirm" in
[yY]*) ;;
*) log::info "Aborted"; return 0 ;;
esac
}
local result
result=$(json::remove_events_filtered \
"$WG_EVENTS_LOG" "$FW_EVENTS_LOG" \
"" "" "false" "false" "$days")
local removed_wg removed_fw
IFS="|" read -r removed_wg removed_fw <<< "$result"
local total=$(( removed_wg + removed_fw ))
if [[ "$total" -eq 0 ]]; then
log::wg_warning "No log entries older than ${days} days"
return 0
fi
log::wg_success "Rotated ${total} entries older than ${days} days (wg: ${removed_wg}, fw: ${removed_fw})"
}
function cmd::logs::clean() {
local force=false wg_only=false
while [[ $# -gt 0 ]]; do
case "$1" in
--force) force=true; shift ;;
--wg) wg_only=true; shift ;;
--help) cmd::logs::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
if ! $force; then
read -r -p "Remove keepalive handshakes from events.log? [y/N] " confirm
case "$confirm" in [yY]*) ;; *) log::info "Aborted"; return 0 ;; esac
fi
local removed
removed=$(json::clean_handshakes "$WG_EVENTS_LOG" "${WG_HANDSHAKE_CHECK_TIME_SEC:-300}")
if [[ "$removed" -eq 0 ]]; then
log::wg_warning "No keepalive handshakes found to remove"
else
log::wg_success "Removed ${removed} keepalive handshake entries"
fi
}