363 lines
No EOL
10 KiB
Bash
363 lines
No EOL
10 KiB
Bash
#!/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 <<EOF
|
|
Usage: wgctl watch [options]
|
|
|
|
Live monitor of WireGuard activity. Shows:
|
|
- Handshakes from connected peers (green)
|
|
- Connection attempts from blocked peers (red, SOURCE: wg)
|
|
- Firewall drops from rule-restricted peers (red, SOURCE: fw)
|
|
|
|
Options:
|
|
--name <name> Filter by client name
|
|
--type <type> Filter by device type
|
|
--peers <list> 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
|
|
} |