feat: logs descending sort, gap/offline indicator, endpoint resolution

- wg_events: sort_order param (desc default), --ascending/--descending flags
- wg_events: endpoint cache fallback via _endpoint() helper
- wg_events: gap computed ascending always, then sliced/reversed correctly
- fw_events: sort_order param, descending default
- ui::logs::wg_row: gap suffix with 'offline' label when gap > threshold
- logs.command.sh: --ascending/--descending flags, pass sort_order to both functions
- daemon: endpoint cache fallback in poll_handshakes
- json.sh: json::wg_events passes ctx::endpoint_cache as arg
This commit is contained in:
Nuno Duque Nunes 2026-05-26 01:34:48 +00:00
parent cf71e9f51a
commit 3c3f870427
8 changed files with 159 additions and 72 deletions

View file

@ -20,6 +20,8 @@ function cmd::logs::on_load() {
flag::register --detailed
flag::register --service
flag::register --event
flag::register --ascending
flag::register --descending
}
function cmd::logs::help() {
@ -104,6 +106,7 @@ function cmd::logs::show() {
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"
while [[ $# -gt 0 ]]; do
case "$1" in
@ -181,11 +184,11 @@ function cmd::logs::show() {
$wg_only || fw_output=$(cmd::logs::show_fw_events \
"$filter_ip" "$name" "$type" "$limit" "$net_file" \
"$collapse" "$since" "$filter_dest_ip" "$filter_dest_port")
"$collapse" "$since" "$filter_dest_ip" "$filter_dest_port" "$sort_order")
$fw_only || wg_output=$(cmd::logs::show_wg_events \
"$filter_ip" "$name" "$type" "$limit" \
"$collapse" "$since" "$filter_event")
"$collapse" "$since" "$filter_event" "$sort_order")
if [[ -z "$(echo "$fw_output" | tr -d '[:space:]')" && \
-z "$(echo "$wg_output" | tr -d '[:space:]')" ]]; then
@ -209,16 +212,18 @@ function cmd::logs::show() {
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:-}"
since="${7:-}" filter_dest_ip="${8:-}" filter_dest_port="${9:-}" \
sort_order="${10:-desc}"
[[ ! -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
@ -254,14 +259,15 @@ function cmd::logs::show_fw_events() {
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:-}"
since="${6:-}" filter_event="${7:-}" sort_order="${8:-desc}"
[[ ! -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
@ -269,12 +275,12 @@ function cmd::logs::show_wg_events() {
# Resolve endpoints and measure column widths
local w_client=16 w_endpoint=16
local resolved_data=""
while IFS='|' read -r ts client endpoint event count; do
while IFS='|' read -r ts client endpoint event count gap_seconds; do
[[ -z "$ts" ]] && continue
local endpoint_display
endpoint_display=$(resolve::ip "$endpoint")
[[ -z "$endpoint_display" ]] && endpoint_display="$endpoint"
resolved_data+="${ts}|${client}|${endpoint_display}|${event}|${count}"$'\n'
resolved_data+="${ts}|${client}|${endpoint_display}|${event}|${count}|${gap_seconds}"$'\n'
(( ${#client} > w_client )) && w_client=${#client}
(( ${#endpoint_display} > w_endpoint )) && w_endpoint=${#endpoint_display}
done <<< "$data"
@ -282,10 +288,10 @@ function cmd::logs::show_wg_events() {
(( w_endpoint += 2 ))
ui::logs::wg_section_header
while IFS='|' read -r ts client endpoint event count; do
while IFS='|' read -r ts client endpoint event count gap_seconds; do
[[ -z "$ts" ]] && continue
ui::logs::wg_row "$ts" "$client" "$endpoint" "$event" \
"$count" "$w_client" "$w_endpoint"
"$count" "$w_client" "$w_endpoint" "$gap_seconds"
done <<< "$resolved_data"
printf "\n"
}

View file

@ -52,6 +52,7 @@ function ctx::events_log() { echo "$(ctx::daemon)/events.log"; }
function ctx::fw_events_log() { echo "$(ctx::daemon)/fw_events.log"; }
function ctx::json_helper() { echo "${_CTX_CORE}/json_helper.py"; }
function ctx::monitor_script() { echo "${_CTX_ROOT}/daemon/wgctl-monitor.py"; }
function ctx::endpoint_cache() { echo "${_CTX_DAEMON}/endpoint_cache.json"; }
# ============================================
# Path Helpers

View file

@ -13,7 +13,7 @@ function json::filter_values() { python3 "$JSON_HELPER" filter_values
function json::last_event() { python3 "$JSON_HELPER" last_event "$@" </dev/null; }
function json::events_for() { python3 "$JSON_HELPER" events_for "$@" </dev/null; }
function json::fw_events() { WGCTL_DATETIME_FMT="$FMT_DATETIME_SHORT" python3 "$JSON_HELPER" fw_events "$@" </dev/null; }
function json::wg_events() { WGCTL_DATETIME_FMT="$FMT_DATETIME_SHORT" python3 "$JSON_HELPER" wg_events "$@" </dev/null; }
function json::wg_events() { WGCTL_DATETIME_FMT="$FMT_DATETIME_SHORT" python3 "$JSON_HELPER" wg_events "$@" "$(ctx::endpoint_cache)" </dev/null; }
function json::format_fw_event() { echo "$1" | python3 "$JSON_HELPER" format_fw_event "$2"; }
function json::format_wg_event() { echo "$1" | python3 "$JSON_HELPER" format_wg_event; }
function json::remove_events() { python3 "$JSON_HELPER" remove_events "$@" </dev/null; }

View file

@ -1689,13 +1689,16 @@ commands = {
args[6] if len(args) > 6 else '1',
args[7] if len(args) > 7 else '',
args[8] if len(args) > 8 else '',
args[9] if len(args) > 9 else ''),
args[9] if len(args) > 9 else '',
args[10] if len(args) > 10 else 'desc'),
'wg_events': lambda args: __import__('lib.events', fromlist=['wg_events']).wg_events(
args[0], args[1], args[2],
args[3] if len(args) > 3 else '50',
args[4] if len(args) > 4 else '1',
args[5] if len(args) > 5 else '',
args[6] if len(args) > 6 else ''),
args[6] if len(args) > 6 else '',
args[7] if len(args) > 7 else '',
args[8] if len(args) > 8 else 'desc'),
'parse_event': lambda args: __import__('lib.events', fromlist=['parse_event']).parse_event(args[0]),
'parse_fw_event': lambda args: __import__('lib.events', fromlist=['parse_fw_event']).parse_fw_event(args[0]),
'format_fw_event': lambda args: __import__('lib.events', fromlist=['format_fw_event']).format_fw_event(args[0], args[1]),

View file

@ -22,7 +22,7 @@ from lib.util import (
# ──────────────────────────────────────────
def fw_events(file, filter_ip, filter_type, clients_dir, net_file,
limit, collapse='1', since='', filter_dest_ip='', filter_dest_port=''):
limit, collapse='1', since='', filter_dest_ip='', filter_dest_port='', sort_order='desc'):
"""
Format firewall drop events with dedup, counts, and service annotation.
@ -124,7 +124,10 @@ def fw_events(file, filter_ip, filter_type, clients_dir, net_file,
continue
sorted_buckets = sorted(hourly_ts.items(), key=lambda x: x[1])
for hour_key, dt in sorted_buckets[-limit:]:
output = sorted_buckets[-limit:]
if sort_order != 'asc':
output = list(reversed(output))
for hour_key, dt in output:
client, dst, port, proto, svc_name, _ = hour_key
count = hourly[hour_key]
ts_fmt = fmt_ts_hour(dt.isoformat())
@ -159,7 +162,10 @@ def fw_events(file, filter_ip, filter_type, clients_dir, net_file,
deduped.append(e)
counts.append(1)
for e, count in list(zip(deduped, counts))[-limit:]:
pairs = list(zip(deduped, counts))[-limit:]
if sort_order != 'asc':
pairs = list(reversed(pairs))
for e, count in pairs:
src = e.get('src_ip', '')
dst = e.get('dest_ip', '')
port = str(e.get('dest_port', ''))
@ -176,21 +182,28 @@ def fw_events(file, filter_ip, filter_type, clients_dir, net_file,
# ──────────────────────────────────────────
def wg_events(file, filter_client, filter_type, limit, collapse='1',
since='', filter_event=''):
since='', filter_event='', endpoint_cache_file='', sort_order='desc'):
"""
Format WireGuard events with dedup and counts.
collapse='1' (default): hourly aggregation for attempt events
collapse='0': show all deduplicated events (--detailed mode)
since: relative or absolute time string
filter_event: 'attempt' | 'handshake' | '' (all)
Output per line: ts|client|endpoint|event|count
Format WireGuard events with dedup, counts, gap and endpoint resolution.
sort_order: 'desc' (default, newest first) | 'asc' (oldest first)
Output per line: ts|client|endpoint|event|count|gap_seconds
"""
do_collapse = str(collapse) != '0'
limit = int(limit) if limit else 50
since_dt = parse_since(since) if since else None
from datetime import datetime
from collections import defaultdict
do_collapse = str(collapse) != '0'
limit = int(limit) if limit else 50
since_dt = parse_since(since) if since else None
descending = sort_order != 'asc'
# Load endpoint cache once
endpoint_cache = {}
if endpoint_cache_file and os.path.exists(endpoint_cache_file):
try:
with open(endpoint_cache_file) as f:
endpoint_cache = json.load(f)
except Exception:
pass
events = []
try:
with open(file) as f:
@ -206,43 +219,45 @@ def wg_events(file, filter_client, filter_type, limit, collapse='1',
continue
if filter_event and e.get('event', '') != filter_event:
continue
if since_dt:
ts_str = e.get('timestamp', '')
try:
from datetime import timezone
ev_dt = datetime.fromisoformat(ts_str)
if ev_dt.tzinfo is None:
from datetime import timezone
ev_dt = ev_dt.replace(tzinfo=timezone.utc)
if ev_dt < since_dt:
continue
except Exception:
pass
events.append(e)
except Exception:
pass
except Exception:
pass
def _endpoint(e):
ep = e.get('endpoint', '')
return ep or endpoint_cache.get(e.get('client', ''), '')
if do_collapse:
hourly_attempts = defaultdict(int)
hourly_ts = {}
handshakes = []
handshake_counts = []
for e in events:
ts_str = e.get('timestamp', '')
client = e.get('client', '')
endpoint = e.get('endpoint', '')
endpoint = _endpoint(e)
event = e.get('event', '')
ts = ts_to_unix(ts_str)
try:
dt = datetime.fromisoformat(ts_str)
except Exception:
dt = None
if event == 'attempt':
if dt:
hour_key = (client, endpoint, event,
@ -255,70 +270,105 @@ def wg_events(file, filter_client, filter_type, limit, collapse='1',
if handshakes:
prev = handshakes[-1]
prev_ts = ts_to_unix(prev.get('timestamp', ''))
prev_key = (
prev.get('client', ''),
prev.get('event', ''),
prev.get('endpoint', '')[:15]
)
prev_key = (prev.get('client', ''), prev.get('event', ''),
_endpoint(prev)[:15])
if key == prev_key and (ts - prev_ts) < 300:
handshake_counts[-1] += 1
continue
handshakes.append(e)
handshake_counts.append(1)
# Build output list — always ascending first for correct gap calculation
output = []
for hour_key, dt in hourly_ts.items():
client, endpoint, event, _ = hour_key
count = hourly_attempts[hour_key]
ts_fmt = fmt_ts_hour(dt.isoformat())
output.append((dt.timestamp(), f"{ts_fmt}|{client}|{endpoint}|{event}|{count}"))
output.append((dt.timestamp(),
f"{ts_fmt}|{client}|{endpoint}|{event}|{count}|"))
# Compute gaps ascending
last_handshake_ts = {}
hs_output = []
for e, count in zip(handshakes, handshake_counts):
ts_str = e.get('timestamp', '')
client = e.get('client', '')
endpoint = e.get('endpoint', '')
endpoint = _endpoint(e)
event = e.get('event', '')
ts = ts_to_unix(ts_str)
ts_fmt = fmt_ts(ts_str)
output.append((ts, f"{ts_fmt}|{client}|{endpoint}|{event}|{count}"))
gap_seconds = ''
if event == 'handshake':
prev_ts = last_handshake_ts.get(client, 0)
if prev_ts > 0:
gap = int(ts - prev_ts)
if gap > 0:
gap_seconds = str(gap)
last_handshake_ts[client] = ts
hs_output.append((ts, f"{ts_fmt}|{client}|{endpoint}|{event}|{count}|{gap_seconds}"))
output.extend(hs_output)
# Sort ascending first to get correct limit slice, then reverse if needed
output.sort(key=lambda x: x[0])
for _, line in output[-limit:]:
output = output[-limit:]
if descending:
output.reverse()
for _, line in output:
print(line)
else:
deduped = []
counts = []
for e in events:
client = e.get('client', '')
event = e.get('event', '')
endpoint = e.get('endpoint', '')
endpoint = _endpoint(e)
key = (client, event, endpoint[:15])
ts = ts_to_unix(e.get('timestamp', ''))
if deduped:
prev = deduped[-1]
prev_ts = ts_to_unix(prev.get('timestamp', ''))
prev_key = (
prev.get('client', ''),
prev.get('event', ''),
prev.get('endpoint', '')[:15]
)
prev_key = (prev.get('client', ''), prev.get('event', ''),
_endpoint(prev)[:15])
if key == prev_key and (ts - prev_ts) < 300:
counts[-1] += 1
continue
deduped.append(e)
counts.append(1)
for e, count in list(zip(deduped, counts))[-limit:]:
ts_fmt = fmt_ts(e.get('timestamp', ''))
# Compute gaps ascending, then slice, then reverse if needed
last_handshake_ts = {}
result = []
for e, count in zip(deduped, counts):
ts_str = e.get('timestamp', '')
client = e.get('client', '')
endpoint = e.get('endpoint', '')
endpoint = _endpoint(e)
event = e.get('event', '')
print(f"{ts_fmt}|{client}|{endpoint}|{event}|{count}")
ts = ts_to_unix(ts_str)
ts_fmt = fmt_ts(ts_str)
gap_seconds = ''
if event == 'handshake':
prev_ts = last_handshake_ts.get(client, 0)
if prev_ts > 0:
gap = int(ts - prev_ts)
if gap > 0:
gap_seconds = str(gap)
last_handshake_ts[client] = ts
result.append((ts, f"{ts_fmt}|{client}|{endpoint}|{event}|{count}|{gap_seconds}"))
result = result[-limit:]
if descending:
result.reverse()
for _, line in result:
print(line)
# ──────────────────────────────────────────
# Single event parsers (used by watch)
# ──────────────────────────────────────────

View file

@ -208,7 +208,15 @@ def poll_handshakes():
# Get endpoint
endpoint = get_endpoint(pubkey) or ''
if not endpoint:
try:
cache = json.loads(ENDPOINT_CACHE_FILE.read_text())
endpoint = cache.get(client, '')
except Exception:
pass
# New session, log it
entry = {
"timestamp": datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(),

View file

@ -56,23 +56,42 @@ function ui::logs::fw_row_table() {
}
function ui::logs::wg_row() {
local ts="${1:-}" client="${2:-}" endpoint="${3:-}" event="${4:-}" count="${5:-1}" \
w_client="${6:-20}" w_endpoint="${7:-20}"
local ts="${1:-}" client="${2:-}" endpoint="${3:-}" event="${4:-}" \
count="${5:-1}" w_client="${6:-20}" w_endpoint="${7:-20}" \
gap_seconds="${8:-}"
local event_color
case "$event" in
handshake) event_color="\033[1;32m" ;;
attempt) event_color="\033[1;31m" ;;
*) event_color="\033[0;37m" ;;
esac
local count_suffix=""
[[ "$count" -gt 1 ]] && count_suffix=" \033[2m(x${count})\033[0m"
# Gap suffix — only for handshakes with a meaningful gap
local gap_suffix=""
if [[ "$event" == "handshake" && -n "$gap_seconds" && "$gap_seconds" -gt 0 ]]; then
local gap_int="$gap_seconds"
local threshold="${WG_HANDSHAKE_CHECK_TIME_SEC:-300}"
local offline_label=""
[[ "$gap_int" -gt "$threshold" ]] && offline_label=" offline"
if (( gap_int >= 3600 )); then
gap_suffix=" \033[2m↑ $(( gap_int / 3600 ))h${offline_label}\033[0m"
elif (( gap_int >= 60 )); then
gap_suffix=" \033[2m↑ $(( gap_int / 60 ))m${offline_label}\033[0m"
fi
fi
local client_pad endpoint_pad_n
client_pad=$(printf "%-${w_client}s" "$client")
endpoint_pad_n=$(( w_endpoint - ${#endpoint} ))
[[ $endpoint_pad_n -lt 0 ]] && endpoint_pad_n=0
printf " %s %s %s%*s %b%s\033[0m%b\n" \
printf " %s %s %s%*s %b%s\033[0m%b%b\n" \
"$ts" "$client_pad" "$endpoint" "$endpoint_pad_n" "" \
"$event_color" "$event" "$count_suffix"
"$event_color" "$event" "$count_suffix" "$gap_suffix"
}
function ui::logs::wg_row_table() {