#!/usr/bin/env bash # ============================================ # Lifecycle # ============================================ function cmd::list::on_load() { load_module identity load_module ui 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 command::mixin json_output } # ============================================ # Help # ============================================ function cmd::list::help() { cat < Filter by device type --rule Filter by assigned rule --group Filter by group membership --identity Filter by identity --online Show only connected clients --offline Show only disconnected clients --blocked Show only fully blocked clients --restricted Show only restricted clients --allowed Show only unrestricted clients --detailed Show detailed view grouped by identity --name Show detail card for a single client Examples: wgctl list wgctl list --type phone wgctl list --identity nuno wgctl list --online wgctl list --blocked wgctl list --detailed wgctl list --name phone-nuno EOF } # ============================================ # 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") 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 # Resolve identity filter 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 log::section "WireGuard Clients" # Collect all filtered rows first (needed for dynamic column widths) local collected_rows="" collected_rows=$(cmd::list::_collect_all_rows | ui::sort_rows) if [[ -z "$collected_rows" ]]; then log::wg_warning "No results found" return 0 fi if command::json; then cmd::list::_output_json "$collected_rows" return 0 fi if $detailed; then cmd::list::_render_detailed "$collected_rows" cmd::list::_render_summary_from_rows "$collected_rows" return 0 fi local style style=$(ui::peer::list_style) case "$style" in table) cmd::list::_render_table ;; compact) display::render "peer_list" "$collected_rows" \ "cmd::list::_render_compact" "cmd::list::_render_table" ;; *) display::render "peer_list" "$collected_rows" \ "cmd::list::_render_compact" "cmd::list::_render_table" ;; esac } # ============================================ # Row collection (single pass, all filters) # ============================================ function cmd::list::_collect_all_rows() { local dir dir="$(ctx::clients)" local _verbose_status="${LIST_VERBOSE_STATUS:-true}" for conf in "${dir}"/*.conf; do [[ -f "$conf" ]] || continue local client_name client_name=$(basename "$conf" .conf) [[ -z "$client_name" ]] && continue 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) [[ -z "$ip" ]] && continue local type="${p_types[$client_name]:-unknown}" [[ -n "$filter_type" && "$type" != "$filter_type" ]] && continue 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]:-}" local rule="${p_rules[$client_name]:-}" local group="${p_main_groups[$client_name]:-}" if $online_only; then peers::is_online "$client_name" "$handshake_ts" "$last_ts" || continue; fi if $offline_only; then peers::is_offline "$client_name" "$handshake_ts" "$last_ts" || continue; fi if $restricted_only && [[ "$is_restricted" != "true" ]]; then continue; fi if $blocked_only && [[ "$is_blocked" != "true" ]]; then continue; fi if $allowed_only && { [[ "$is_blocked" == "true" ]] || \ [[ "$is_restricted" == "true" ]]; }; then continue; fi if [[ -n "$filter_rule" && "$rule" != "$filter_rule" ]]; then continue; fi if [[ -n "$filter_group" ]]; then local all_groups="${peer_group_map[$client_name]:-}" [[ "$all_groups" != *"$filter_group"* ]] && continue fi # Resolve status — verbose or simple local status if [[ "$_verbose_status" == "true" ]]; then status=$(peers::format_status_verbose "$client_name" "$pubkey" \ "$is_blocked" "$is_restricted" "$handshake_ts" "$last_ts" | \ sed 's/\x1b\[[0-9;]*m//g') else local state state=$(peers::connection_state "$is_blocked" "$is_restricted" "$handshake_ts" "$last_ts") status="${state%%|*}" fi # Resolve last seen local last_seen="-" if [[ "$is_blocked" == "true" && -n "$last_ts" && "$last_ts" != "0" ]]; then local attempt_ts attempt_ts=$(json::iso_to_ts "$last_ts") last_seen="$(fmt::datetime_short "$attempt_ts") (dropped)" elif [[ -n "$handshake_ts" && "$handshake_ts" != "0" ]]; then local ts_display ts_display=$(fmt::datetime_short "$handshake_ts") if [[ "$status" == "online"* ]]; then last_seen="${ts_display} (handshake)" else last_seen="$ts_display" fi fi printf "%s|%s|%s|%s|%s|%s|%s|%s|%s\n" \ "$client_name" "$ip" "$type" \ "${rule:--}" "${group:--}" \ "$status" "$last_seen" \ "$is_blocked" "$is_restricted" done } # ============================================ # Compact render # ============================================ function cmd::list::_render_compact() { local rows="${1:-}" # Measure column widths from pure values (fields 1-5, no labels) local w_name w_ip w_type w_rule w_group w_name=$(ui::measure_col "$rows" 1 14) w_ip=$(ui::measure_col "$rows" 2 13) w_type=$(ui::measure_col "$rows" 3 7) w_rule=$(ui::measure_col "$rows" 4 4) w_group=$(ui::measure_col "$rows" 5 4) echo "" while IFS='|' read -r name ip type rule group status last_seen is_blocked is_restricted; do [[ -z "$name" ]] && continue ui::peer::list_row_compact \ "$w_name" "$w_ip" "$w_type" "$w_rule" "$w_group" \ "$name" "$ip" "$type" "$rule" "$group" \ "$status" "$last_seen" "$is_blocked" "$is_restricted" done <<< "$rows" echo "" cmd::list::_render_summary_from_rows "$rows" } # ============================================ # Table render (kept for config switching) # ============================================ function cmd::list::_render_table() { local rows="${1:-}" [[ -z "$rows" ]] && log::wg_warning "No results found" && return 0 # Measure column widths from data (same as compact) local w_name=16 w_ip=13 w_type=8 w_rule=10 w_group=10 w_status=10 w_last=20 while IFS='|' read -r name ip type rule group status last_seen is_blocked is_restricted; do [[ -z "$name" ]] && continue (( ${#name} > w_name )) && w_name=${#name} (( ${#ip} > w_ip )) && w_ip=${#ip} (( ${#type} > w_type )) && w_type=${#type} (( ${#rule} > w_rule )) && w_rule=${#rule} (( ${#group} > w_group )) && w_group=${#group} (( ${#last_seen} > w_last )) && w_last=${#last_seen} local cs cs=$(printf "%s" "$status" | sed 's/\x1b\[[0-9;]*m//g') (( ${#cs} > w_status )) && w_status=${#cs} done <<< "$rows" (( w_name += 2 )); (( w_ip += 2 )) (( w_type += 2 )); (( w_rule += 2 )) (( w_group += 2 )); (( w_last += 2 )) # Header printf "\n %-${w_name}s %-${w_ip}s %-${w_type}s %-${w_rule}s %-${w_group}s %-${w_status}s %s\n" \ "NAME" "IP" "TYPE" "RULE" "GROUP" "STATUS" "LAST SEEN" printf " %s\n" "$(printf '─%.0s' {1..115})" # Rows while IFS='|' read -r name ip type rule group status last_seen is_blocked is_restricted; do [[ -z "$name" ]] && continue local clean_status clean_status=$(echo "$status" | sed 's/\x1b\[[0-9;]*m//g') local status_pad_n=$(( w_status - ${#clean_status} )) [[ $status_pad_n -lt 0 ]] && status_pad_n=0 local row_color status_color row_color=$(ui::peer::_row_color "$is_blocked" "$is_restricted" "$clean_status") status_color=$(ui::peer::status_color "$is_blocked" "$is_restricted" "$clean_status") local status_colored="${status_color}${clean_status}\033[0m" local last_seen_colored="$last_seen" [[ -n "$row_color" ]] && last_seen_colored="${row_color}${last_seen}\033[0m" \ || last_seen_colored="${status_color}${last_seen}\033[0m" if [[ -n "$row_color" ]]; then printf " %b%-${w_name}s %-${w_ip}s %-${w_type}s %-${w_rule}s %-${w_group}s %-${w_status}s %s\033[0m\n" \ "$row_color" "$name" "$ip" "$type" "$rule" "$group" "$clean_status" "$last_seen" else printf " %-${w_name}s %-${w_ip}s %-${w_type}s %-${w_rule}s %-${w_group}s %b%*s\033[0m %b\n" \ "$name" "$ip" "$type" "$rule" "$group" \ "$status_color${clean_status}" "$status_pad_n" "" \ "$last_seen_colored" fi done <<< "$rows" printf " %s\n" "$(printf '─%.0s' {1..115})" cmd::list::_render_summary_from_rows "$rows" } function cmd::list::_iter_confs_table() { local dir dir="$(ctx::clients)" for conf in "${dir}"/*.conf; do [[ -f "$conf" ]] || continue local client_name client_name=$(basename "$conf" .conf) [[ -z "$client_name" ]] && continue 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) local type="${p_types[$client_name]:-unknown}" [[ -n "$filter_type" && "$type" != "$filter_type" ]] && continue cmd::list::_render_row "$client_name" "$ip" "$type" done } # ============================================ # Detailed render (grouped by identity) # ============================================ function cmd::list::_render_detailed() { local rows="${1:-}" # Measure widths local w_name w_ip w_type w_rule w_group w_subnet w_name=$(ui::measure_col "$rows" 1 14) w_ip=$(ui::measure_col "$rows" 2 13) w_type=$(ui::measure_col "$rows" 3 7) w_rule=$(ui::measure_col "$rows" 4 4) w_group=$(ui::measure_col "$rows" 5 4) # subnet not in rows — use fixed width w_subnet=10 # Group by identity declare -A identity_rows=() local no_identity_rows="" while IFS='|' read -r name ip type rule group status last_seen is_blocked is_restricted; do [[ -z "$name" ]] && continue local id_name id_name=$(identity::get_name "$name") local row="${name}|${ip}|${type}|${rule}|${group}|${status}|${last_seen}|${is_blocked}|${is_restricted}" if [[ -n "$id_name" ]]; then identity_rows["$id_name"]+="${row}"$'\n' else no_identity_rows+="${row}"$'\n' fi done <<< "$rows" echo "" # Render identity groups (sorted) for id_name in $(echo "${!identity_rows[@]}" | tr ' ' '\n' | sort); do ui::peer::list_identity_header "$id_name" while IFS='|' read -r name ip type rule group status last_seen is_blocked is_restricted; do [[ -z "$name" ]] && continue local peer_type="${p_types[$name]:-}" subnet=$(peers::get_display_subnet "$name" "$peer_type") ui::peer::list_row_detailed \ "$w_name" "$w_ip" "$w_type" "$w_rule" "$w_group" "$w_subnet" \ "$name" "$ip" "$type" "$rule" "$group" "$subnet" \ "$status" "$last_seen" "$is_blocked" "$is_restricted" done < <(echo "${identity_rows[$id_name]}" | ui::sort_rows) done # Render peers without identity (no "other" header if empty) if [[ -n "$no_identity_rows" ]]; then local trimmed trimmed=$(echo "$no_identity_rows" | grep -v '^$') if [[ -n "$trimmed" ]]; then ui::peer::list_identity_header "other" while IFS='|' read -r name ip type rule group status last_seen is_blocked is_restricted; do [[ -z "$name" ]] && continue local subnet subnet=$(peers::get_meta "$name" "subnet" 2>/dev/null) if [[ -z "$subnet" ]]; then local peer_type="${p_types[$name]:-}" [[ -n "$peer_type" ]] && subnet="$peer_type" fi [[ -z "$subnet" ]] && subnet="-" ui::peer::list_row_detailed \ "$w_name" "$w_ip" "$w_type" "$w_rule" "$w_group" "$w_subnet" \ "$name" "$ip" "$type" "$rule" "$group" "$subnet" \ "$status" "$last_seen" "$is_blocked" "$is_restricted" done <<< "$trimmed" fi fi echo "" } # ============================================ # Summary # ============================================ function cmd::list::_render_summary_from_rows() { local rows="${1:-}" declare -A rule_counts=() group_counts=() local total=0 while IFS='|' read -r name ip type rule group rest; do [[ -z "$name" ]] && continue (( total++ )) || true rule_counts["${rule:--}"]=$(( ${rule_counts[${rule:--}]:-0} + 1 )) || true [[ "$group" != "-" && -n "$group" ]] && \ group_counts["$group"]=$(( ${group_counts[$group]:-0} + 1 )) || true done <<< "$rows" local rule_summary="" for r in $(echo "${!rule_counts[@]}" | tr ' ' '\n' | sort); do rule_summary+="${rule_counts[$r]} ${r}, " done rule_summary="${rule_summary%, }" local group_summary="" for g in $(echo "${!group_counts[@]}" | tr ' ' '\n' | sort); do group_summary+="${group_counts[$g]} in ${g}, " done group_summary="${group_summary%, }" if [[ -n "$group_summary" ]]; then printf " Showing %s peers [%s] — %s\n\n" "$total" "$rule_summary" "$group_summary" else printf " Showing %s peers [%s]\n\n" "$total" "$rule_summary" fi } # ============================================ # Table 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 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 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]:-}" local group_display="${main_group:-${peer_group_map[$client_name]:-—}}" printf " %-28s %-15s %-13s %-12s %-12s %s %s\n" \ "$client_name" "$ip" "$display_type" "$rule" \ "$group_display" "$padded_status" "$last_seen" else printf " %-28s %-15s %-13s %-12s %s %s\n" \ "$client_name" "$ip" "$display_type" "$rule" \ "$padded_status" "$last_seen" fi } # ============================================ # Precompute # ============================================ function cmd::list::_precompute_all() { 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)") for name in "${!p_ips[@]}"; do [[ -n "${p_types[$name]:-}" ]] && continue p_types["$name"]=$(peers::get_type_from_ip "${p_ips[$name]}") done 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) declare -gA p_blocked=() p_restricted=() cmd::list::_precompute_block_status p_blocked p_restricted 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 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 # Identity precompute (for --identity filter) declare -gA p_identity_filter=() } 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) } # ============================================ # Header / Footer (table layout) # ============================================ function cmd::list::_render_header() { ui::peer::list_header_table "$1" } function cmd::list::_render_footer() { ui::peer::list_footer_table "$1" } 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 } # ============================================ # JSON (API consumption) # ============================================ function cmd::list::_output_json() { local rows="${1:-}" local -a peers=() while IFS='|' read -r name ip type rule group status last_seen is_blocked is_restricted; do [[ -z "$name" ]] && continue # Escape strings for JSON local peer_json peer_json=$(printf '{"name":"%s","ip":"%s","type":"%s","rule":"%s","group":"%s","status":"%s","last_seen":"%s","is_blocked":%s,"is_restricted":%s}' \ "$name" "$ip" "$type" \ "${rule}" "${group}" \ "$status" "$last_seen" \ "$is_blocked" "$is_restricted") peers+=("$peer_json") done <<< "$rows" local count=${#peers[@]} local array # Join array with commas array=$(printf '%s\n' "${peers[@]}" | paste -sd ',' -) printf '{"peers":[%s]}' "$array" | json::envelope "list" "$count" }