#!/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 < 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 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") [[ -z "$attempt_ts" || "$attempt_ts" == "0" ]] && return 1 diff=$(( now - attempt_ts )) (( diff < 180 )) } function cmd::list::_format_last_seen() { local name="$1" local pubkey="$2" local is_blocked="${3:-0}" local last_ts="${4:-0}" local last_evt="${5:-0}" local handshake_ts="${6:-0}" 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" local pubkey="$2" local is_blocked="${3:-false}" local is_restricted="${4:-false}" local handshake_ts="${5:-0}" local 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:-0}" local type="${2:-0}" local subtype="${3:-0}" 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 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 }