diff --git a/commands/list/list.sh b/commands/list/list.sh new file mode 100644 index 0000000..cad49c2 --- /dev/null +++ b/commands/list/list.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# commands/list/list.sh — router only + +function cmd::list::on_load() { + command::define show "List WireGuard clients" [*] +} + +hook::on "command:help:list" command::help::auto \ No newline at end of file diff --git a/commands/list/show.sh b/commands/list/show.sh new file mode 100644 index 0000000..32c213a --- /dev/null +++ b/commands/list/show.sh @@ -0,0 +1,682 @@ +#!/usr/bin/env bash +# commands/list/show.sh + +function cmd::list::show::on_load() { + command::mixin json_output [section="Output"] + + help::section "Filters" + flag::define --name value "Show single peer" [label="name", section="Filters"] + flag::define --type value "Filter by device type" [label="type", section="Filters"] + flag::define --rule value "Filter by rule" [label="rule", section="Filters"] + flag::define --group value "Filter by group" [label="group", section="Filters"] + flag::define --identity value "Filter by identity" [label="identity", section="Filters"] + flag::define --online bool "Show online peers only" [section="Filters"] + flag::define --offline bool "Show offline peers only" [section="Filters"] + flag::define --restricted bool "Show restricted peers" [section="Filters"] + flag::define --blocked bool "Show blocked peers" [section="Filters"] + flag::define --allowed bool "Show allowed peers" [section="Filters"] + + help::section "Output" + flag::define --detailed bool "Show detailed view" [section="Output"] + + flag::exclusive --online --offline --blocked --restricted --allowed +} + +function cmd::list::show::run() { + flag::parse "$@" || return 1 + + local filter_type; filter_type=$(flag::value --type) + local filter_rule; filter_rule=$(flag::value --rule) + local filter_group; filter_group=$(flag::value --group) + local filter_identity; filter_identity=$(flag::value --identity) + local single_name; single_name=$(flag::value --name) + local online_only=false offline_only=false + local restricted_only=false blocked_only=false allowed_only=false + local detailed=false + + flag::bool --online && online_only=true + flag::bool --offline && offline_only=true + flag::bool --restricted && restricted_only=true + flag::bool --blocked && blocked_only=true + flag::bool --allowed && allowed_only=true + flag::bool --detailed && detailed=true + + 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 + + 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" + + 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 + + display::render "peer_list" "$collected_rows" \ + "cmd::list::_render_compact" "cmd::list::_render_table" +} + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +# ============================================ +# 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" +} +# ============================================ +# 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" +} \ No newline at end of file diff --git a/core/framework/command.sh b/core/framework/command.sh index ff18e2f..35bb37e 100644 --- a/core/framework/command.sh +++ b/core/framework/command.sh @@ -324,6 +324,13 @@ function command::run() { source "$subcmd_file" core::call_if_exists "cmd::${cmd}::${subcmd}::on_load" _CURRENT_LOADING_CMD="" + + for arg in "$@"; do + [[ "$arg" == "--help" || "$arg" == "-h" ]] && { + hook::fire "command:help:${cmd}" "$cmd" "$_ROUTED_SUBCMD" + return 0 + } + done fi command::run_routed "$cmd" "$subcmd" "${routed_args[@]:-}" diff --git a/core/framework/command_mixins.sh b/core/framework/command_mixins.sh index 3acfe23..f65311c 100644 --- a/core/framework/command_mixins.sh +++ b/core/framework/command_mixins.sh @@ -181,6 +181,7 @@ declare -gA _FLAG_EXCLUSIVE_GROUPS=() function flag::exclusive() { local cmd="${_CURRENT_LOADING_CMD:-}" [[ -z "$cmd" ]] && return 0 + cmd="${cmd%%::*}" # Join flags with comma as one group local group diff --git a/core/framework/flag.sh b/core/framework/flag.sh index 1b2bc38..b026f21 100644 --- a/core/framework/flag.sh +++ b/core/framework/flag.sh @@ -248,6 +248,24 @@ function flag::parse() { esac done + local groups="${_FLAG_EXCLUSIVE_GROUPS[${_CURRENT_COMMAND%%::*}]:-}" + if [[ -n "$groups" ]]; then + local group + while IFS= read -r group; do + [[ -z "$group" ]] && continue + local -a members=() + IFS=',' read -ra members <<< "$group" + local found_count=0 found_flags="" + for member in "${members[@]}"; do + flag::set "$member" && (( found_count++ )) && found_flags+=" $member" + done + if [[ $found_count -gt 1 ]]; then + log::error "Flags${found_flags} are mutually exclusive" + return 1 + fi + done < <(echo "$groups" | tr '|' '\n') + fi + # Validate required flags for key in "${!_FLAG_REGISTRY[@]}"; do [[ "$key" != "${ctx}:"* ]] && continue diff --git a/core/framework/help.sh b/core/framework/help.sh index 58f0d3f..dc8cbce 100644 --- a/core/framework/help.sh +++ b/core/framework/help.sh @@ -125,8 +125,9 @@ function command::help::auto() { # Print usage local cmd_path="${cmd}" - [[ -n "$subcmd" ]] && cmd_path="${cmd} ${subcmd}" - + local default_subcmd="${_COMMAND_DEFAULT[$cmd]:-}" + # Only show subcmd in usage if it's not the default + [[ -n "$subcmd" && "$subcmd" != "$default_subcmd" ]] && cmd_path="${cmd} ${subcmd}" printf "\nUsage: wgctl %s" "$cmd_path" for part in "${usage_parts[@]:-}"; do printf " %s" "$part" @@ -148,14 +149,14 @@ function command::help::auto() { for sc in "${subcmds[@]}"; do local sc_key="${cmd}:${sc}" local sc_desc="${_COMMAND_DEFS[$sc_key]:-}" - local sc_type - sc_type=$(echo "$sc_desc" | cut -d'|' -f1) - local sc_text - sc_text=$(echo "$sc_desc" | cut -d'|' -f2) - local sc_aliases - sc_aliases=$(echo "$sc_desc" | cut -d'|' -f3) - local sc_default - sc_default=$(echo "$sc_desc" | cut -d'|' -f4) + + local sc_text sc_aliases sc_default + sc_text=$(echo "$sc_desc" | cut -d'|' -f1) + sc_aliases=$(echo "$sc_desc" | cut -d'|' -f2) + sc_default=$(echo "$sc_desc" | cut -d'|' -f3) + + # Clean up empty aliases + [[ "$sc_aliases" == "false" || "$sc_aliases" == "true" ]] && sc_aliases="" local suffix="" [[ "$sc_default" == "true" ]] && suffix=" (default)"