#!/usr/bin/env bash # ============================================ # Lifecycle # ============================================ function cmd::watch::on_load() { flag::register --type flag::register --name flag::register --peers flag::register --blocked flag::register --restricted flag::register --allowed } # ============================================ # Help # ============================================ function cmd::watch::help() { cat < Filter by client name --type Filter by device type --peers Comma-separated peer names (used internally by group watch) --blocked Show only blocked peer attempts --allowed Show only handshakes (allowed peers) --restricted Show only firewall drop events Examples: wgctl watch wgctl watch --blocked wgctl watch --allowed wgctl watch --type phone wgctl watch --name phone-nuno wgctl watch --name phone-nuno --type phone EOF } # ============================================ # Helpers # ============================================ function cmd::watch::format_event() { local ts="${1:-}" source="${2:-}" client="${3:-}" local dest="${4:-}" event="${5:-}" status="${6:-}" local event_color case "$event" in attempt|drop) event_color="\033[1;31m" ;; handshake) event_color="\033[1;32m" ;; *) event_color="\033[0;37m" ;; esac local status_color="" case "$status" in blocked) status_color="\033[1;31m" ;; allowed) status_color="\033[1;32m" ;; esac printf " %-20s %-8s %-22s %-28s ${event_color}%-14s\033[0m ${status_color}%s\033[0m\n" \ "$ts" "$source" "$client" "${dest:-—}" "$event" "$status" } function cmd::watch::header() { log::section "wgctl — Live Monitor (Ctrl+C to stop)" printf "\n %-20s %-8s %-22s %-28s %-14s %s\n" \ "TIME" "SOURCE" "CLIENT" "DESTINATION/ENDPOINT" "EVENT" "STATUS" printf " %s\n\n" "$(printf '─%.0s' {1..105})" } function cmd::watch::_peer_in_filter() { local peer="$1" shift local peer_set=("$@") [[ ${#peer_set[@]} -eq 0 ]] && return 0 # no filter = all pass for p in "${peer_set[@]}"; do [[ "$p" == "$peer" ]] && return 0 done return 1 } # ============================================ # Handshake Poller # ============================================ function cmd::watch::poll_handshakes() { local filter_name="${1:-}" filter_type="${2:-}" local allowed_only="${3:-false}" local filter_peers="${4:-}" local peer_set=() [[ -n "$filter_peers" ]] && IFS=',' read -ra peer_set <<< "$filter_peers" 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 cmd::watch::_peer_in_filter "$client_name" "${peer_set[@]}" || 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=$(fmt::datetime "$ts") local endpoint endpoint=$(monitor::endpoint_for_key "$public_key") cmd::watch::format_event \ "$formatted_ts" "wg" "$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:-}" filter_type="${2:-}" local blocked_only="${3:-false}" restricted_only="${4:-false}" local allowed_only="${5:-false}" filter_peers="${6:-}" declare -A _WATCH_LAST_ATTEMPT=() declare -A _WATCH_LAST_FW=() local peer_set=() [[ -n "$filter_peers" ]] && IFS=',' read -ra peer_set <<< "$filter_peers" declare -A _WATCH_LAST_ATTEMPT=() # Build ip->name map for fw events declare -A ip_to_name=() local conf_file while IFS= read -r conf_file; do local name name=$(basename "$conf_file" .conf) local ip ip=$(grep "^Address" "$conf_file" 2>/dev/null | awk '{print $3}' | cut -d'/' -f1) [[ -n "$ip" && -n "$name" ]] && ip_to_name["$ip"]="$name" done < <(find "$(ctx::clients)" -name "*.conf" 2>/dev/null) # Source tracker via temp file (persists across subshell iterations) local source_file source_file=$(mktemp) echo "wg" > "$source_file" # Cleanup temp file on exit trap "rm -f '$source_file'" EXIT tail -f "$(ctx::events_log)" "$(ctx::fw_events_log)" 2>/dev/null \ | while IFS= read -r line; do [[ -z "$line" ]] && continue # Handle tail -f file headers if [[ "$line" == "==> "* ]]; then if [[ "$line" == *"fw_events"* ]]; then echo "fw" > "$source_file" else echo "wg" > "$source_file" fi continue fi local source source=$(cat "$source_file") if [[ "$source" == "wg" ]]; then $allowed_only && continue # wg events are attempts/blocked local event_data event_data=$(json::parse_event "$line") [[ -z "$event_data" ]] && continue local ts client endpoint event IFS="|" read -r ts client endpoint event <<< "$event_data" [[ -n "$filter_name" && "$client" != "$filter_name" ]] && continue cmd::watch::_peer_in_filter "$client" "${peer_set[@]}" || continue if [[ -n "$filter_type" ]]; then local conf conf="$(ctx::clients)/${client}.conf" [[ -f "$conf" ]] || continue local ip ip=$(grep "^Address" "$conf" 2>/dev/null | awk '{print $3}' | cut -d'/' -f1) local subnet subnet=$(config::subnet_for "$filter_type") string::starts_with "$ip" "$subnet" || continue fi $restricted_only && { cmd::list::is_restricted "$client" || continue; } # Dedup local now now=$(date +%s) local safe_client="${client//[-.]/_}" local last="${_WATCH_LAST_ATTEMPT[$safe_client]:-0}" (( now - last < 30 )) && continue _WATCH_LAST_ATTEMPT[$safe_client]="$now" local formatted_ts formatted_ts=$(fmt::datetime_iso "$ts") cmd::watch::format_event \ "$formatted_ts" "wg" "$client" "${endpoint:-—}" "$event" "blocked" else # FW event $allowed_only && continue $blocked_only && continue # fw drops aren't "blocked peers" per se local fw_data fw_data=$(json::parse_fw_event "$line") [[ -z "$fw_data" ]] && continue local ts src_ip dst_ip dst_port proto IFS="|" read -r ts src_ip dst_ip dst_port proto <<< "$fw_data" [[ -z "$src_ip" ]] && continue local fw_key="${src_ip}:${dst_ip}:${dst_port}:${proto}" local now now=$(date +%s) local last_fw="${_WATCH_LAST_FW[$fw_key]:-0}" local window=30 [[ "$proto" == "17" || "$proto" == "udp" ]] && window=10 [[ "$proto" == "1" || "$proto" == "icmp" ]] && window=5 local diff=$(( now - last_fw )) (( diff < window )) && continue _WATCH_LAST_FW["$fw_key"]="$now" local client="${ip_to_name[$src_ip]:-$src_ip}" [[ -n "$filter_name" && "$client" != "$filter_name" ]] && continue cmd::watch::_peer_in_filter "$client" "${peer_set[@]}" || continue if [[ -n "$filter_type" ]]; then local peer_client="${ip_to_name[$src_ip]:-}" [[ -z "$peer_client" ]] && continue local conf conf="$(ctx::clients)/${peer_client}.conf" [[ -f "$conf" ]] || continue local ip ip=$(grep "^Address" "$conf" 2>/dev/null | awk '{print $3}' | cut -d'/' -f1) local subnet subnet=$(config::subnet_for "$filter_type") string::starts_with "$ip" "$subnet" || continue fi local dst_str="${dst_ip:-—}" [[ -n "$dst_port" ]] && dst_str="${dst_ip}:${dst_port}/${proto}" local formatted_ts formatted_ts=$(fmt::datetime_iso "$ts") cmd::watch::format_event \ "$formatted_ts" "fw" "$client" "$dst_str" "drop" "blocked" fi done rm -f "$source_file" } # ============================================ # Run # ============================================ function cmd::watch::run() { local filter_name="" filter_type="" filter_peers="" local blocked_only=false allowed_only=false restricted_only=false 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 ;; --peers) filter_peers="$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 if ! $blocked_only && ! $restricted_only; then ( while true; do cmd::watch::poll_handshakes \ "$filter_name" "$filter_type" "$allowed_only" "$filter_peers" sleep 5 done ) & local poller_pid=$! fi cmd::watch::tail_events \ "$filter_name" "$filter_type" \ "$blocked_only" "$restricted_only" \ "$allowed_only" "$filter_peers" & local tailer_pid=$! trap "kill $tailer_pid ${poller_pid:-} 2>/dev/null; \ rm -f /tmp/wgctl_hs_* /tmp/wgctl_attempt_*; echo ''; exit 0" INT TERM wait }