#!/usr/bin/env bash # ============================================ # Lifecycle # ============================================ function cmd::watch::on_load() { flag::register --type flag::register --name flag::register --blocked flag::register --restricted flag::register --allowed } # ============================================ # Help # ============================================ function cmd::watch::help() { cat < Filter by device type --name Filter by client name --allowed Show only allowed client handshakes --restricted Show only restricted client events --blocked Show only blocked client attempts Examples: wgctl watch wgctl watch --blocked wgctl watch --allowed wgctl watch --type phone wgctl watch --name phone-nuno EOF } # ============================================ # Helpers # ============================================ function cmd::watch::format_event() { local ts="$1" local client="$2" local endpoint="$3" local event="$4" local status="$5" local event_color case "$event" in attempt) event_color="\033[1;31m" ;; # red handshake) event_color="\033[1;32m" ;; # green *) event_color="\033[0;37m" ;; # grey esac local status_str="" if [[ -n "$status" ]]; then case "$status" in blocked) status_str=" \033[1;31mblocked\033[0m" ;; allowed) status_str=" \033[1;32mallowed\033[0m" ;; *) status_str="" ;; esac fi printf " %-20s %-25s %-20s ${event_color}%-12s\033[0m%b\n" \ "$ts" "$client" "${endpoint:-—}" "$event" "$status_str" } function cmd::watch::header() { log::section "wgctl — Live Monitor (Ctrl+C to stop)" printf "\n %-20s %-25s %-20s %-12s %s\n" \ "TIME" "CLIENT" "ENDPOINT" "EVENT" "STATUS" printf " %s\n\n" "$(printf '─%.0s' {1..85})" } # ============================================ # Handshake Poller # ============================================ declare -A _WATCH_LAST_HANDSHAKES=() function cmd::watch::poll_handshakes() { local filter_name="$1" local filter_type="$2" local allowed_only="$3" while IFS= read -r line; do local public_key ts public_key=$(echo "$line" | awk '{print $1}') ts=$(echo "$line" | awk '{print $2}') [[ -z "$ts" || "$ts" == "0" ]] && continue # Find client by public key local client_name="" for conf in "$(ctx::clients)"/*.conf; do [[ -f "$conf" ]] || continue local name name=$(basename "$conf" .conf) local key key=$(keys::public "$name" 2>/dev/null || echo "") if [[ "$key" == "$public_key" ]]; then client_name="$name" break fi done [[ -z "$client_name" ]] && continue # Apply filters [[ -n "$filter_name" && "$client_name" != "$filter_name" ]] && continue if [[ -n "$filter_type" ]]; then local ip ip=$(grep "^Address" "$(ctx::clients)/${client_name}.conf" | awk '{print $3}' | cut -d'/' -f1) local subnet subnet=$(config::subnet_for "$filter_type") string::starts_with "$ip" "$subnet" || continue fi # Only emit if handshake is new local safe_key safe_key=$(echo "$public_key" | md5sum | cut -d' ' -f1) local prev_ts_file="/tmp/wgctl_hs_${safe_key}" local prev_ts="0" [[ -f "$prev_ts_file" ]] && prev_ts=$(cat "$prev_ts_file") if [[ "$ts" != "$prev_ts" ]]; then echo "$ts" > "$prev_ts_file" local formatted_ts formatted_ts=$(date -d "@${ts}" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "$ts") local endpoint endpoint=$(monitor::endpoint_for_key "$public_key") cmd::watch::format_event \ "$formatted_ts" "$client_name" "${endpoint:-—}" "handshake" "allowed" fi done < <(wg show "$(config::interface)" latest-handshakes 2>/dev/null) } # ============================================ # Event Tailer # ============================================ function cmd::watch::tail_events() { local filter_name="$1" local filter_type="$2" local blocked_only="$3" local restricted_only="$4" local allowed_only="$5" declare -A _WATCH_LAST_ATTEMPT=() tail -f "$(ctx::root)/daemon/events.log" 2>/dev/null | while IFS= read -r line; do [[ -z "$line" ]] && continue local event_data event_data=$(python3 -c " import json, sys try: e = json.loads('${line//\'/\'\\\'\'}') print(e.get('timestamp',''), e.get('client',''), e.get('endpoint',''), e.get('event','')) except: pass " 2>/dev/null) [[ -z "$event_data" ]] && continue if $restricted_only; then local conf conf="$(ctx::clients)/${client}.conf" [[ -f "$conf" ]] || continue cmd::list::is_restricted "$client" || continue fi local ts client endpoint event read -r ts client endpoint event <<< "$event_data" # Apply filters [[ -n "$filter_name" && "$client" != "$filter_name" ]] && continue if [[ -n "$filter_type" ]]; then local conf conf="$(ctx::clients)/${client}.conf" [[ -f "$conf" ]] || continue local ip ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1) local subnet subnet=$(config::subnet_for "$filter_type") string::starts_with "$ip" "$subnet" || continue fi # Filter by status if $allowed_only && [[ "$event" != "handshake" ]]; then continue fi if $restricted_only; then local conf conf="$(ctx::clients)/${client}.conf" [[ -f "$conf" ]] || continue cmd::list::is_restricted "$client" || continue fi local formatted_ts formatted_ts=$(python3 -c " from datetime import datetime dt = datetime.fromisoformat('${ts}') dt = dt.astimezone() print(dt.strftime('%Y-%m-%d %H:%M:%S')) " 2>/dev/null || echo "$ts") # Before printing the event local now now=$(date +%s) local safe_client="${client//[-.]/_}" local last="${_WATCH_LAST_ATTEMPT[$safe_client]:-0}" local diff=$(( now - last )) if (( diff < 30 )); then continue fi _WATCH_LAST_ATTEMPT[$safe_client]="$now" cmd::watch::format_event \ "$formatted_ts" "$client" "${endpoint:-—}" "$event" "blocked" done } # ============================================ # Run # ============================================ function cmd::watch::run() { local filter_name="" local filter_type="" local blocked_only=false local allowed_only=false local restricted_only=false # Clean up any stale temp files from previous runs rm -f /tmp/wgctl_hs_* /tmp/wgctl_attempt_* 2>/dev/null || true while [[ $# -gt 0 ]]; do case "$1" in --name) filter_name="$2"; shift 2 ;; --type) filter_type="$2"; shift 2 ;; --blocked) blocked_only=true; shift ;; --allowed) allowed_only=true; shift ;; --restricted) restricted_only=true; shift ;; --help) cmd::watch::help; return ;; *) log::error "Unknown flag: $1" cmd::watch::help return 1 ;; esac done cmd::watch::header # Start event tailer in background unless --blocked not set if ! $blocked_only && ! $restricted_only; then # Poll handshakes every 5 seconds in background ( while true; do cmd::watch::poll_handshakes "$filter_name" "$filter_type" "$allowed_only" sleep 5 done ) & local poller_pid=$! fi # Tail events log for blocked attempts cmd::watch::tail_events "$filter_name" "$filter_type" "$blocked_only" "$restricted_only" "$allowed_only" & local tailer_pid=$! # Trap Ctrl+C to clean up background processes trap "kill $tailer_pid ${poller_pid:-} 2>/dev/null; rm -f /tmp/wgctl_hs_* /tmp/wgctl_attempt_*; echo ''; exit 0" INT TERM # Keep main process alive wait }