#!/usr/bin/env bash # ============================================ # Lifecycle # ============================================ function cmd::list::on_load() { flag::register --type flag::register --rule flag::register --group flag::register --identity 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 (desktop, laptop, phone, tablet) --rule Filter by assigned rule --group Filter by group membership --identity Filter by identity (show all peers for an identity) --online Show only connected clients --offline Show only disconnected clients --blocked Show only fully blocked clients (removed from WireGuard) --restricted Show only restricted clients (specific IP/port blocks applied) --allowed Show only unrestricted clients --detailed Show full detail cards for all clients --name Show detail card for a single client Status values: online Connected (recent handshake) offline Not connected blocked Removed from WireGuard server (wgctl block --name) restricted In WireGuard but with specific access rules (wgctl block --ip/--service) Examples: wgctl list wgctl list --type phone wgctl list --rule user wgctl list --group family wgctl list --identity nuno wgctl list --online wgctl list --blocked wgctl list --restricted wgctl list --detailed wgctl list --name phone-nuno EOF } # ============================================ # 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 filter_desc="${3:-}" 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 # ============================================ 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 ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1) local allowed_ips allowed_ips=$(grep "^AllowedIPs" "$conf" | cut -d'=' -f2- | xargs) local public_key public_key=$(keys::public "$name" 2>/dev/null || echo "unknown") # Meta type is authoritative; IP reverse lookup is fallback for pre-migration peers local type type=$(peers::get_meta "$name" "type" 2>/dev/null) [[ -z "$type" ]] && type=$(peers::get_type_from_ip "$ip") local endpoint="—" local ep ep=$(monitor::endpoint_for_key "$public_key") [[ -z "$ep" ]] && ep=$(monitor::last_endpoint "$name") [[ -n "$ep" ]] && endpoint="$ep" local is_blocked="false" peers::is_blocked "$name" && is_blocked="true" local is_restricted="false" peers::is_restricted "$name" && is_restricted="true" local handshake_ts handshake_ts=$(monitor::get_handshake_ts "$public_key") local last_ts last_ts=$(monitor::last_attempt "$name") local status status=$(peers::format_status_verbose "$name" "$public_key" \ "$is_blocked" "$is_restricted" "$handshake_ts" "$last_ts") local last_seen last_seen=$(peers::format_last_seen "$name" "$public_key" \ "$is_blocked" "$last_ts" "" "$handshake_ts") local blocks="" if block::has_file "$name" && block::is_blocked "$name"; then if [[ "$(block::is_blocked_direct "$name")" == "true" ]]; then blocks="all traffic blocked" fi local rule_lines rule_lines=$(block::format_rules "$name") [[ -n "$rule_lines" ]] && blocks+="$rule_lines" fi ui::section "Client: ${name}" ui::row "IP" "$ip" ui::row "Type" "$(peers::display_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" } # ============================================ # Run # ============================================ function cmd::list::run() { local filter_type="" filter_rule="" filter_group="" filter_identity="" 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 ;; --rule) filter_rule="$2"; shift 2 ;; --group) filter_group="$2"; shift 2 ;; --identity) filter_identity="$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 if [[ -n "$single_name" ]]; then cmd::list::show_client "$single_name" return 0 fi local dir dir="$(ctx::clients)" local confs=("${dir}"/*.conf) if [[ ! -f "${confs[0]}" ]]; then log::wg_warning "No clients configured" return 0 fi cmd::list::_precompute_all if $detailed; then log::section "WireGuard Clients" cmd::list::_iter_confs "$filter_type" cmd::list::_show_client_safe return 0 fi local filter_desc="" cmd::list::_build_filter_desc declare -A rule_counts=() group_counts=() _list_header_printed=false cmd::list::_iter_confs "$filter_type" cmd::list::_render_row if [[ "$_list_header_printed" == "true" ]]; then cmd::list::_render_footer $has_groups local group_summary="" cmd::list::_build_group_summary cmd::list::_render_summary "$group_summary" rule_counts "$filter_desc" else log::wg_warning "No results found${filter_desc:+ for: ${filter_desc}}" fi } # ============================================ # Iteration # ============================================ function cmd::list::_iter_confs() { local filter_type="$1" callback="$2" local dir dir="$(ctx::clients)" for conf in "${dir}"/*.conf; do [[ -f "$conf" ]] || continue local client_name client_name=$(basename "$conf" .conf) # Identity filter — skip peers not in the identity set if [[ ${#p_identity_filter[@]} -gt 0 && \ -z "${p_identity_filter[$client_name]:-}" ]]; then continue fi local ip="${p_ips[$client_name]:-}" [[ -z "$ip" ]] && ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1) # p_types is authoritative — set during precompute from meta + IP fallback local type="${p_types[$client_name]:-unknown}" [[ -n "$filter_type" && "$type" != "$filter_type" ]] && continue "$callback" "$client_name" "$ip" "$type" done } # ============================================ # Row rendering # ============================================ 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]:-}" if $online_only; then peers::is_online "$client_name" "$handshake_ts" "$last_ts" || return 0; fi if $offline_only; then peers::is_offline "$client_name" "$handshake_ts" "$last_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 if [[ -n "$filter_group" ]]; then local all_groups="${peer_group_map[$client_name]:-}" [[ "$all_groups" != *"$filter_group"* ]] && return 0 fi local status last_seen display_type rule group_display status=$(peers::format_status_verbose "$client_name" "$pubkey" \ "$is_blocked" "$is_restricted" "$handshake_ts" "$last_ts") last_seen=$(peers::format_last_seen "$client_name" "$pubkey" \ "$is_blocked" "$last_ts" "" "$handshake_ts") display_type=$(peers::display_type "$type") rule="${p_rules[$client_name]:-—}" if [[ -n "$filter_rule" && "$rule" != "$filter_rule" ]]; then return 0; fi if [[ "${_list_header_printed:-false}" == "false" ]]; then log::section "WireGuard Clients" cmd::list::_render_header $has_groups _list_header_printed=true fi rule_counts["$rule"]=$(( ${rule_counts[$rule]:-0} + 1 )) || true local padded_status padded_status=$(ui::pad_status "$status" 25) if $has_groups; then local main_group="${p_main_groups[$client_name]:-}" if [[ -n "$main_group" ]]; then group_display="$main_group" else group_display="${peer_group_map[$client_name]:-—}" fi 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 } # ============================================ # Precompute # ============================================ function cmd::list::_precompute_all() { # Peer data — field 4 is 'type' from peer_data_v2 declare -gA p_ips=() p_rules=() p_types=() p_last_ts=() p_last_evt=() p_main_groups=() while IFS="|" read -r name ip rule type last_ts last_evt main_group; do [[ -z "$name" ]] && continue p_ips["$name"]="$ip" p_rules["$name"]="${rule:-—}" p_types["$name"]="${type:-}" p_last_ts["$name"]="$last_ts" p_last_evt["$name"]="$last_evt" p_main_groups["$name"]="${main_group:-}" done < <(json::peer_data "$(ctx::clients)" "$(ctx::meta)" "$(ctx::events_log)") # Fill type from IP for peers missing meta type (pre-migration peers) for name in "${!p_ips[@]}"; do [[ -n "${p_types[$name]:-}" ]] && continue p_types["$name"]=$(peers::get_type_from_ip "${p_ips[$name]}") done # WireGuard handshakes + endpoints declare -gA wg_handshakes=() wg_endpoints=() while IFS=$'\t' read -r pubkey ts; do [[ -n "$pubkey" ]] && wg_handshakes["$pubkey"]="$ts" done < <(wg show "$(config::interface)" latest-handshakes 2>/dev/null) while IFS=$'\t' read -r pubkey endpoint; do [[ -n "$pubkey" ]] && wg_endpoints["$pubkey"]="$endpoint" done < <(wg show "$(config::interface)" endpoints 2>/dev/null) # Block/restricted status declare -gA p_blocked=() p_restricted=() cmd::list::_precompute_block_status p_blocked p_restricted # Public keys declare -gA p_pubkeys=() local dir dir="$(ctx::clients)" 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 # Groups + main group has_groups=false declare -gA peer_group_map=() local groups_dir groups_dir="$(ctx::groups)" local group_files=("${groups_dir}"/*.group) if [[ -f "${group_files[0]}" ]]; then has_groups=true 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 # Resolve identity filter into a peer set declare -gA p_identity_filter=() if [[ -n "$filter_identity" ]]; then identity::require_exists "$filter_identity" || return 1 while IFS= read -r peer_name; do [[ -n "$peer_name" ]] && p_identity_filter["$peer_name"]=1 done < <(identity::peers "$filter_identity") if [[ ${#p_identity_filter[@]} -eq 0 ]]; then log::wg_warning "Identity '${filter_identity}' has no peers" return 0 fi fi # Transfer/activity data — keyed by pubkey declare -gA p_rx=() p_tx=() p_activity=() while IFS="|" read -r pubkey rx tx level; do [[ -z "$pubkey" ]] && continue p_rx["$pubkey"]="$rx" p_tx["$pubkey"]="$tx" p_activity["$pubkey"]="$level" done < <(json::peer_transfer "$(config::interface)") } function cmd::list::_precompute_block_status() { local -n _blocked="$1" local -n _restricted="$2" local wg_peers wg_peers=$(wg show "$(config::interface)" peers 2>/dev/null) while IFS= read -r name; do if block::has_specific_rules "$name" 2>/dev/null; then _restricted["$name"]=true else _restricted["$name"]=false fi 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) } # ============================================ # Filter helpers # ============================================ function cmd::list::_build_filter_desc() { filter_desc="" [[ -n "$filter_type" ]] && filter_desc+="type=${filter_type} " [[ -n "$filter_rule" ]] && filter_desc+="rule=${filter_rule} " [[ -n "$filter_group" ]] && filter_desc+="group=${filter_group} " [[ -n "$filter_identity" ]] && filter_desc+="identity=${filter_identity} " $online_only && filter_desc+="online " $offline_only && filter_desc+="offline " $blocked_only && filter_desc+="blocked " filter_desc="${filter_desc% }" } function cmd::list::_build_group_summary() { group_summary="" if $has_groups; then declare -A _gs=() for peer in "${!peer_group_map[@]}"; do local g="${peer_group_map[$peer]}" _gs["$g"]=$(( ${_gs["$g"]:-0} + 1 )) || true done for g in "${!_gs[@]}"; do group_summary+="${_gs[$g]} in ${g}, " done group_summary="${group_summary%, }" fi } function cmd::list::_show_client_safe() { local name="$1" cmd::list::show_client "$name" || true }