wgctl/commands/list.command.sh

542 lines
No EOL
16 KiB
Bash

#!/usr/bin/env bash
# ============================================
# Lifecycle
# ============================================
function cmd::list::on_load() {
flag::register --type
flag::register --online
flag::register --offline
flag::register --restricted
flag::register --blocked
flag::register --allowed
flag::register --detailed
flag::register --name
}
# ============================================
# Help
# ============================================
function cmd::list::help() {
cat <<EOF
Usage: wgctl list [options]
List all WireGuard clients.
Options:
--type <type> Filter by device type
--online Show only connected clients
--offline Show only disconnected clients
--allowed Show only fully allowed clients
--restricted Show only restricted clients
--blocked Show only blocked clients
--detailed Show full detail cards for all clients
--name <name> Show detail card for a single client
Examples:
wgctl list
wgctl list --type phone
wgctl list --online
wgctl list --blocked
wgctl list --detailed
wgctl list --name phone-nuno
EOF
}
# ============================================
# Precompute helpers
# ============================================
function cmd::list::_precompute_wg() {
# Returns two associative arrays via nameref
local -n _handshakes="$1"
local -n _endpoints="$2"
while IFS=$'\t' read -r pubkey ts; do
[[ -n "$pubkey" ]] && _handshakes["$pubkey"]="$ts"
done < <(wg show "$(config::interface)" latest-handshakes 2>/dev/null)
while IFS=$'\t' read -r pubkey endpoint; do
[[ -n "$pubkey" ]] && _endpoints["$pubkey"]="$endpoint"
done < <(wg show "$(config::interface)" endpoints 2>/dev/null)
}
function cmd::list::_precompute_block_status() {
local -n _blocked="$1"
local -n _restricted="$2"
# Blocked = not in wg server config
local wg_peers
wg_peers=$(wg show "$(config::interface)" peers 2>/dev/null)
while IFS= read -r name; do
# Check block file
[[ -f "$(ctx::block::path "${name}.block")" ]] && _restricted["$name"]=true || _restricted["$name"]=false
# Check if in server config
local pubkey
pubkey=$(keys::public "$name" 2>/dev/null || echo "")
if [[ -n "$pubkey" ]] && ! echo "$wg_peers" | grep -qF "$pubkey"; then
_blocked["$name"]=true
else
_blocked["$name"]=false
fi
done < <(peers::all)
}
# ============================================
# Status / Display helpers
# ============================================
function cmd::list::_is_connected() {
local ts="$1"
[[ -z "$ts" || "$ts" == "0" ]] && return 1
local now diff
now=$(date +%s)
diff=$(( now - ts ))
(( diff < 180 ))
}
function cmd::list::_is_attempting() {
local last_ts="$1"
[[ -z "$last_ts" ]] && return 1
local now attempt_ts diff
now=$(date +%s)
attempt_ts=$(json::iso_to_ts "$last_ts")
diff=$(( now - attempt_ts ))
(( diff < 180 ))
}
function cmd::list::_format_last_seen() {
local name="$1" pubkey="$2" is_blocked="$3"
local last_ts="$4" last_evt="$5" handshake_ts="$6"
if [[ "$is_blocked" == "true" ]]; then
if [[ -n "$last_ts" ]]; then
local formatted
formatted=$(fmt::datetime "$last_ts")
echo "${formatted} (dropped)"
else
echo "—"
fi
else
if [[ -z "$handshake_ts" || "$handshake_ts" == "0" ]]; then
echo "—"
else
local formatted
formatted=$(fmt::datetime "$handshake_ts")
echo "${formatted} (handshake)"
fi
fi
}
function cmd::list::_format_status() {
local name="$1" pubkey="$2"
local is_blocked="$3" is_restricted="$4"
local handshake_ts="$5" last_ts="$6"
local connected=false modifier="" color
if [[ "$is_blocked" == "true" ]]; then
cmd::list::_is_attempting "$last_ts" && connected=true
modifier=" (blocked)"
color="\033[1;31m"
elif [[ "$is_restricted" == "true" ]]; then
cmd::list::_is_connected "$handshake_ts" && connected=true
modifier=" (restricted)"
color="\033[1;33m"
else
cmd::list::_is_connected "$handshake_ts" && connected=true
modifier=""
if $connected; then
color="\033[1;32m"
else
color="\033[0;37m"
fi
fi
local conn_str
$connected && conn_str="online" || conn_str="offline"
echo -e "${color}${conn_str}${modifier}\033[0m"
}
function cmd::list::_get_type() {
local ip="$1"
local type="unknown"
for t in $(config::device_types); do
local subnet
subnet=$(config::subnet_for "$t")
if string::starts_with "$ip" "${subnet}."; then
type="$t"
break
fi
done
echo "$type"
}
function cmd::list::display_type() {
local name="$1" type="$2" subtype="$3"
if config::is_guest_type "$type" && [[ -n "$subtype" ]]; then
echo "guest/${subtype}"
elif config::is_guest_type "$type"; then
echo "guest"
else
echo "$type"
fi
}
function cmd::list::pad_status() {
local status="$1"
local width="${2:-25}"
local visible
visible=$(echo -e "$status" | sed 's/\x1b\[[0-9;]*m//g')
local pad=$(( width - ${#visible} ))
printf "%b%${pad}s" "$status" ""
}
# ============================================
# Header / Footer
# ============================================
function cmd::list::_render_header() {
local has_groups="$1"
if $has_groups; then
printf "\n %-28s %-15s %-13s %-12s %-12s %-22s %s\n" \
"NAME" "IP" "TYPE" "RULE" "GROUP" "STATUS" "LAST SEEN"
printf " %s\n" "$(printf '─%.0s' {1..135})"
else
printf "\n %-28s %-15s %-13s %-12s %-22s %s\n" \
"NAME" "IP" "TYPE" "RULE" "STATUS" "LAST SEEN"
printf " %s\n" "$(printf '─%.0s' {1..107})"
fi
}
function cmd::list::_render_footer() {
local has_groups="$1"
if $has_groups; then
printf " %s\n" "$(printf '─%.0s' {1..135})"
else
printf " %s\n" "$(printf '─%.0s' {1..107})"
fi
}
function cmd::list::_render_summary() {
local group_summary="${1:-}"
local -n _rule_counts="$2"
local total="${3:-}" # filtered total
# total=$(find "$(ctx::clients)" -name "*.conf" | wc -l | tr -d ' ')
# Count total from rule_counts (only filtered peers)
local total=0
for r in "${!_rule_counts[@]}"; do
(( total += _rule_counts[$r] )) || true
done
local summary=""
for r in "${!_rule_counts[@]}"; do
summary+="${_rule_counts[$r]} ${r}, "
done
summary="${summary%, }"
if [[ -n "$group_summary" ]]; then
printf "\n Showing %s peers [%s] — %s\n\n" "$total" "$summary" "$group_summary"
else
printf "\n Showing %s peers [%s]\n\n" "$total" "$summary"
fi
}
# ============================================
# Detail Card (show_client)
# ============================================
function cmd::list::show_client() {
local name="$1"
local conf
conf="$(ctx::clients)/${name}.conf"
if [[ ! -f "$conf" ]]; then
log::error "Client not found: ${name}"
return 1
fi
local ip allowed_ips public_key
ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1)
allowed_ips=$(grep "^AllowedIPs" "$conf" | awk '{print $3}')
public_key=$(keys::public "$name" 2>/dev/null || echo "unknown")
local type
type=$(cmd::list::_get_type "$ip")
local endpoint="—"
if peers::is_blocked "$name"; then
local ep
ep=$(monitor::last_endpoint "$name")
[[ -n "$ep" ]] && endpoint="$ep"
else
local ep
ep=$(monitor::endpoint_for_key "$public_key")
[[ -n "$ep" ]] && endpoint="$ep"
fi
# Get handshake and last attempt for status/last_seen
local handshake_ts
handshake_ts=$(wg show "$(config::interface)" latest-handshakes 2>/dev/null \
| grep "^${public_key}" | awk '{print $2}')
handshake_ts="${handshake_ts:-0}"
local last_ts
last_ts=$(monitor::last_attempt "$name")
local is_blocked="false"
peers::is_blocked "$name" && is_blocked="true"
local status last_seen
status=$(cmd::list::_format_status "$name" "$public_key" \
"$is_blocked" "false" "$handshake_ts" "$last_ts")
last_seen=$(cmd::list::_format_last_seen "$name" "$public_key" \
"$is_blocked" "$last_ts" "" "$handshake_ts")
local block_file
block_file="$(ctx::block::path "${name}.block")"
local blocks=""
if [[ -f "$block_file" ]] && [[ -s "$block_file" ]]; then
while IFS=" " read -r client_ip target port proto; do
if [[ -z "$target" ]]; then
blocks+=" all traffic blocked\n"
else
local rule=" ${target}"
[[ -n "$port" ]] && rule+=":${port}/${proto}"
blocks+="${rule}\n"
fi
done < "$block_file"
fi
ui::section "Client: ${name}"
ui::row "IP" "$ip"
ui::row "Type" "$type"
ui::row "Status" "$(echo -e "$status")"
ui::row "Endpoint" "$endpoint"
ui::row "Last seen" "$last_seen"
ui::row "AllowedIPs" "$allowed_ips"
ui::row "Public key" "$public_key"
if [[ -z "$blocks" ]]; then
ui::row "Blocks" "none"
elif [[ "$blocks" == *"all traffic blocked"* ]]; then
ui::row "Blocks" "$(echo -e "\033[1;31mAll\033[0m")"
else
printf " %-20s\n" "Blocks:"
echo -e "$blocks"
fi
printf "\n"
}
# Keep old format_status/format_last_seen for backward compat
function cmd::list::format_status() { cmd::list::_format_status "$@"; }
function cmd::list::format_last_seen() { cmd::list::_format_last_seen "$@"; }
function cmd::list::is_blocked() { peers::is_blocked "$1"; }
function cmd::list::is_restricted() { [[ -f "$(ctx::block::path "${1}.block")" ]]; }
function cmd::list::is_connected() { cmd::list::_is_connected "$@"; }
function cmd::list::is_attempting() { cmd::list::_is_attempting "$@"; }
# ============================================
# Run
# ============================================
function cmd::list::run() {
local filter_type=""
local online_only=false offline_only=false
local restricted_only=false blocked_only=false allowed_only=false
local detailed=false single_name=""
while [[ $# -gt 0 ]]; do
case "$1" in
--type) filter_type="$2"; shift 2 ;;
--online) online_only=true; shift ;;
--offline) offline_only=true; shift ;;
--restricted) restricted_only=true; shift ;;
--blocked) blocked_only=true; shift ;;
--allowed) allowed_only=true; shift ;;
--detailed) detailed=true; shift ;;
--name) single_name="$2"; shift 2 ;;
--help) cmd::list::help; return ;;
*)
log::error "Unknown flag: $1"
cmd::list::help
return 1
;;
esac
done
# Single detail card
if [[ -n "$single_name" ]]; then
cmd::list::show_client "$single_name"
return
fi
local dir
dir="$(ctx::clients)"
local confs=("${dir}"/*.conf)
if [[ ! -f "${confs[0]}" ]]; then
log::wg_list "No clients configured"
return 0
fi
# ── Precompute everything ──────────────────
# Peer data (ip, rule, subtype, last_ts, last_evt) — single Python call
declare -A p_ips p_rules p_subtypes p_last_ts p_last_evt
while IFS="|" read -r name ip rule subtype last_ts last_evt; do
[[ -z "$name" ]] && continue
p_ips["$name"]="$ip"
p_rules["$name"]="${rule:-}"
p_subtypes["$name"]="$subtype"
p_last_ts["$name"]="$last_ts"
p_last_evt["$name"]="$last_evt"
done < <(json::peer_data "$(ctx::clients)" "$(ctx::meta)" "$(ctx::events_log)")
# WireGuard handshakes + endpoints — two wg show calls
declare -A wg_handshakes wg_endpoints
cmd::list::_precompute_wg wg_handshakes wg_endpoints
# Block/restricted status
declare -A p_blocked p_restricted
cmd::list::_precompute_block_status p_blocked p_restricted
# Public keys — read from key files
declare -A p_pubkeys
for kf in "${dir}"/*_public.key; do
[[ -f "$kf" ]] || continue
local kname
kname=$(basename "$kf" _public.key)
p_pubkeys["$kname"]=$(cat "$kf" 2>/dev/null || echo "")
done
# Group map
local has_groups=false
local groups_dir
groups_dir="$(ctx::groups)"
local group_files=("${groups_dir}"/*.group)
[[ -f "${group_files[0]}" ]] && has_groups=true
declare -A peer_group_map
if $has_groups; then
while IFS=":" read -r peer_name group_name; do
[[ -n "$peer_name" ]] && peer_group_map["$peer_name"]="$group_name"
done < <(json::peer_group_map "$groups_dir")
fi
# ── Detailed mode ──────────────────────────
if $detailed; then
log::section "WireGuard Clients"
cmd::list::_iter_confs "$filter_type" cmd::list::show_client
return
fi
# ── Table view ─────────────────────────────
log::section "WireGuard Clients"
cmd::list::_render_header $has_groups
declare -A rule_counts
declare -A group_counts
cmd::list::_iter_confs "$filter_type" cmd::list::_render_row
cmd::list::_render_footer $has_groups
# Build summaries
declare -A displayed_rules displayed_groups
local group_summary=""
if $has_groups; then
for g in "${!group_counts[@]}"; do
group_summary+="${group_counts[$g]} in ${g}, "
done
group_summary="${group_summary%, }"
fi
cmd::list::_render_summary "$group_summary" rule_counts
}
function cmd::list::_iter_confs() {
# Usage: cmd::list::_iter_confs <filter_type> <callback>
local filter_type="$1"
local callback="$2"
local dir
dir="$(ctx::clients)"
for conf in "${dir}"/*.conf; do
[[ -f "$conf" ]] || continue
local client_name
client_name=$(basename "$conf" .conf)
local ip="${p_ips[$client_name]:-}"
if [[ -z "$ip" ]]; then
ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1)
fi
local type
type=$(cmd::list::_get_type "$ip")
[[ -n "$filter_type" && "$type" != "$filter_type" ]] && continue
"$callback" "$client_name" "$ip" "$type"
done
}
function cmd::list::_render_row() {
local client_name="$1" ip="$2" type="$3"
local pubkey="${p_pubkeys[$client_name]:-}"
local handshake_ts="${wg_handshakes[$pubkey]:-0}"
local is_blocked="${p_blocked[$client_name]:-false}"
local is_restricted="${p_restricted[$client_name]:-false}"
local last_ts="${p_last_ts[$client_name]:-}"
# Apply status filters
if $online_only; then cmd::list::_is_connected "$handshake_ts" || return 0; fi
if $offline_only; then cmd::list::_is_connected "$handshake_ts" && return 0; fi
if $restricted_only && [[ "$is_restricted" != "true" ]]; then return 0; fi
if $blocked_only && [[ "$is_blocked" != "true" ]]; then return 0; fi
if $allowed_only && { [[ "$is_blocked" == "true" ]] || \
[[ "$is_restricted" == "true" ]]; }; then return 0; fi
# Format display values
local status last_seen display_type rule group_display
status=$(cmd::list::_format_status "$client_name" "$pubkey" \
"$is_blocked" "$is_restricted" "$handshake_ts" "$last_ts")
last_seen=$(cmd::list::_format_last_seen "$client_name" "$pubkey" \
"$is_blocked" "$last_ts" "" "$handshake_ts")
display_type=$(cmd::list::display_type "$client_name" "$type" \
"${p_subtypes[$client_name]:-}")
rule="${p_rules[$client_name]:-}"
# Update rule counts for summary (outer scope array)
rule_counts["$rule"]=$(( ${rule_counts[$rule]:-0} + 1 )) || true
# Pad status
local padded_status
padded_status=$(cmd::list::pad_status "$status" 25)
# Render row
if $has_groups; then
group_display="${peer_group_map[$client_name]:-}"
if [[ -n "${peer_group_map[$client_name]:-}" ]]; then
group_counts["$group_display"]=$(( ${group_counts[$group_display]:-0} + 1 )) || true
fi
local rule_col_width=12 group_col_width=12
[[ "$rule" == "—" ]] && rule_col_width=14
[[ "$group_display" == "—" ]] && group_col_width=14
printf " %-28s %-15s %-13s %-${rule_col_width}s %-${group_col_width}s %s %s\n" \
"$client_name" "$ip" "$display_type" "$rule" \
"$group_display" "$padded_status" "$last_seen"
else
local rule_col_width=12
[[ "$rule" == "—" ]] && rule_col_width=14
printf " %-28s %-15s %-13s %-${rule_col_width}s %s %s\n" \
"$client_name" "$ip" "$display_type" "$rule" \
"$padded_status" "$last_seen"
fi
}