#!/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 ls --type phone wgctl list --online wgctl list --blocked wgctl list --allowed wgctl list --restricted wgctl list --detailed wgctl list --name phone-nuno EOF } # ============================================ # Status Helpers # ============================================ function cmd::list::last_handshake_ts() { local public_key="$1" wg show "$(config::interface)" latest-handshakes 2>/dev/null \ | grep "^${public_key}" \ | awk '{print $2}' } function cmd::list::last_dropped_ts() { local client_ip="$1" journalctl -k --grep "wgctl-dropped: " 2>/dev/null \ | grep "SRC=${client_ip}" \ | tail -1 \ | awk '{print $1, $2, $3}' } function cmd::list::is_connected() { local public_key="$1" local ts ts=$(cmd::list::last_handshake_ts "$public_key") [[ -z "$ts" || "$ts" == "0" ]] && return 1 local now diff now=$(date +%s) diff=$(( now - ts )) (( diff < 180 )) } function cmd::list::is_attempting() { local name="$1" local ts ts=$(monitor::last_attempt "$name") [[ -z "$ts" ]] && return 1 local now attempt_ts diff now=$(date +%s) attempt_ts=$(python3 -c " from datetime import datetime, timezone dt = datetime.fromisoformat('${ts}') if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) print(int(dt.timestamp())) " 2>/dev/null || echo 0) diff=$(( now - attempt_ts )) (( diff < 180 )) } function cmd::list::is_blocked() { local name="$1" peers::is_blocked "$name" } function cmd::list::is_restricted() { local name="$1" [[ -f "$(ctx::block::path "${name}.block")" ]] } function cmd::list::format_last_seen() { local name="$1" local public_key="$2" local ip="$3" if cmd::list::is_blocked "$name"; then local ts ts=$(monitor::last_attempt "$name") if [[ -n "$ts" ]]; then # Format ISO timestamp local formatted formatted=$(python3 -c " from datetime import datetime, timezone dt = datetime.fromisoformat('${ts}') print(dt.strftime('%Y-%m-%d %H:%M')) " 2>/dev/null || echo "$ts") echo "${formatted} (dropped)" else echo "—" fi else local ts ts=$(cmd::list::last_handshake_ts "$public_key") if [[ -z "$ts" || "$ts" == "0" ]]; then echo "—" else local formatted formatted=$(date -d "@${ts}" "+%Y-%m-%d %H:%M" 2>/dev/null || echo "$ts") echo "${formatted} (handshake)" fi fi } function cmd::list::format_status() { local name="$1" local public_key="$2" local ip="$3" # new local connected=false local blocked=false local restricted=false cmd::list::is_blocked "$name" && blocked=true cmd::list::is_restricted "$name" && restricted=true if $blocked; then cmd::list::is_attempting "$name" && connected=true modifier=" (blocked)" elif $restricted; then cmd::list::is_connected "$public_key" && connected=true modifier=" (restricted)" else cmd::list::is_connected "$public_key" && connected=true modifier="" fi local conn_str $connected && conn_str="online" || conn_str="offline" local status="${conn_str}${modifier}" local color if $blocked; then color="\033[1;31m" elif $restricted; then color="\033[1;33m" elif $connected; then color="\033[1;32m" else color="\033[0;37m" fi echo -e "${color}${status}\033[0m" } # ============================================ # Display Type # ============================================ function cmd::list::display_type() { local name="$1" local type="$2" # log::debug "$(config::is_guest_type "$type")" if config::is_guest_type "$type"; then local subtype subtype=$(peers::get_meta "$name" "subtype") # log::debug "$subtype" if [[ -n "$subtype" ]]; then echo "guest/${subtype}" else echo "guest" fi else echo "$type" fi } # ============================================ # Detail Card # ============================================ function cmd::list::show_client() { local name="$1" local dir dir="$(ctx::clients)" local conf="${dir}/${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" | awk '{print $3}') local public_key public_key=$(keys::public "$name" 2>/dev/null || echo "unknown") # Get endpoint local endpoint="—" if cmd::list::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 # Determine type 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 local status status=$(cmd::list::format_status "$name" "$public_key" "$ip") local last_seen last_seen=$(cmd::list::format_last_seen "$name" "$public_key" "$ip") # Block rules 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 local sep sep="$(printf '─%.0s' {1..50})" echo "" echo " ${sep}" printf " \033[1;34m%-20s\033[0m %s\n" "Client:" "$name" echo " ${sep}" printf " %-20s %s\n" "IP:" "$ip" printf " %-20s %s\n" "Type:" "$type" printf " %-20s %b\n" "Status:" "$status" printf " %-20s %s\n" "Endpoint:" "$endpoint" printf " %-20s %s\n" "Last seen:" "$last_seen" printf " %-20s %s\n" "Allowed IPs:" "$allowed_ips" printf " %-20s %s\n" "Public key:" "$public_key" if [[ -z "$blocks" ]]; then printf " %-20s %s\n" "Blocks:" "none" elif [[ "$blocks" == *"all traffic blocked"* ]]; then printf " %-20s \033[1;31mAll\033[0m\n" "Blocks:" else printf " %-20s\n" "Blocks:" echo -e "$blocks" fi echo " ${sep}" echo "" } # ============================================ # Run # ============================================ function cmd::list::run() { local filter_type="" local online_only=false local offline_only=false local restricted_only=false local blocked_only=false local allowed_only=false local detailed=false local 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 client 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 # START - GROUP SECTION # Check if any groups exist local has_groups=false local groups_dir groups_dir="$(ctx::groups)" local group_files=("${groups_dir}"/*.group) [[ -f "${group_files[0]}" ]] && has_groups=true # Precompute peer->group map 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 # END - GROUP SECTION # Detailed mode — cards only, no table if $detailed; then log::section "WireGuard Clients" for conf in "${dir}"/*.conf; do [[ -f "$conf" ]] || continue local client_name client_name=$(basename "$conf" .conf) # Apply type filter if [[ -n "$filter_type" ]]; then local ip ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1) 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 [[ "$type" != "$filter_type" ]] && continue fi cmd::list::show_client "$client_name" done return fi # Normal table view log::section "WireGuard Clients" 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 for conf in "${dir}"/*.conf; do [[ -f "$conf" ]] || continue local client_name client_name=$(basename "$conf" .conf) local ip ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1) # Determine type 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 # Apply type filter if [[ -n "$filter_type" && "$type" != "$filter_type" ]]; then continue fi local public_key public_key=$(keys::public "$client_name" 2>/dev/null || echo "") # Apply filters if $online_only && ! cmd::list::is_connected "$public_key"; then continue fi if $offline_only && cmd::list::is_connected "$public_key"; then continue fi if $restricted_only && ! cmd::list::is_restricted "$client_name"; then continue fi if $blocked_only && ! cmd::list::is_blocked "$client_name"; then continue fi if $allowed_only && { cmd::list::is_blocked "$client_name" || cmd::list::is_restricted "$client_name"; }; then continue fi local status status=$(cmd::list::format_status "$client_name" "$public_key" "$ip") local last_seen last_seen=$(cmd::list::format_last_seen "$client_name" "$public_key" "$ip") local display_type display_type=$(cmd::list::display_type "$client_name" "$type") # log::debug "display_type called with name=$client_name type=$type" local rule rule=$(peers::effective_rule "$client_name") rule="${rule:-—}" local padded_status padded_status=$(cmd::list::pad_status "$status" 25) local group_display="—" if $has_groups; then group_display="${peer_group_map[$client_name]:-—}" fi local rule_col_width=12 [[ "$rule" == "—" ]] && rule_col_width=14 local group_col_width=12 [[ "$group_display" == "—" ]] && group_col_width=14 if $has_groups; then 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 printf " %-28s %-15s %-13s %-12s %s %s\n" \ "$client_name" "$ip" "$display_type" "$rule" \ "$padded_status" "$last_seen" fi done if $has_groups; then printf " %s\n" "$(printf '─%.0s' {1..135})" else printf " %s\n" "$(printf '─%.0s' {1..107})" fi local group_summary="" if $has_groups; then declare -A group_counts for peer in "${!peer_group_map[@]}"; do local g="${peer_group_map[$peer]}" group_counts["$g"]=$(( ${group_counts["$g"]:-0} + 1 )) || true done 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" printf "\n" } function cmd::list::_render_summary() { local group_summary="${1:-}" # Summary line local total online_count total=$(peers::all | wc -l) # Count by rule declare -A rule_summary while IFS= read -r peer_name; do local r r=$(peers::effective_rule "$peer_name") rule_summary["$r"]=$(( ${rule_summary["$r"]:-0} + 1 )) done < <(peers::all) local summary="" for r in "${!rule_summary[@]}"; do summary+="${rule_summary[$r]} ${r}, " done summary="${summary%, }" # remove trailing comma 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 } # Strip ANSI codes to measure visible length, then pad manually function cmd::list::pad_status() { local status="$1" local width="${2:-20}" local visible visible=$(echo -e "$status" | sed 's/\x1b\[[0-9;]*m//g') local pad=$(( width - ${#visible} )) printf "%b%${pad}s" "$status" "" }