diff --git a/commands/logs.command.sh b/commands/logs.command.sh index 99b1203..de5762d 100644 --- a/commands/logs.command.sh +++ b/commands/logs.command.sh @@ -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" } diff --git a/core/context.sh b/core/context.sh index bbeada5..bd56cb1 100644 --- a/core/context.sh +++ b/core/context.sh @@ -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 diff --git a/core/json.sh b/core/json.sh index 80401cb..afd0ef4 100644 --- a/core/json.sh +++ b/core/json.sh @@ -13,7 +13,7 @@ function json::filter_values() { python3 "$JSON_HELPER" filter_values function json::last_event() { python3 "$JSON_HELPER" last_event "$@" 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]), diff --git a/core/lib/__pycache__/events.cpython-311.pyc b/core/lib/__pycache__/events.cpython-311.pyc index be3ab56..0152321 100644 Binary files a/core/lib/__pycache__/events.cpython-311.pyc and b/core/lib/__pycache__/events.cpython-311.pyc differ diff --git a/core/lib/events.py b/core/lib/events.py index b4c9b00..1ce065f 100644 --- a/core/lib/events.py +++ b/core/lib/events.py @@ -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) # ────────────────────────────────────────── diff --git a/daemon/wgctl-monitor.py b/daemon/wgctl-monitor.py index 9fbb342..a4e3236 100755 --- a/daemon/wgctl-monitor.py +++ b/daemon/wgctl-monitor.py @@ -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(), diff --git a/modules/ui/logs.module.sh b/modules/ui/logs.module.sh index c028422..f8dddd4 100644 --- a/modules/ui/logs.module.sh +++ b/modules/ui/logs.module.sh @@ -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() {