#!/usr/bin/env bash function cmd::inspect::on_load() { flag::register --name flag::register --type flag::register --config flag::register --qr command::mixin json_output } function cmd::inspect::help() { cat < [options] wgctl inspect Show detailed information for a WireGuard client. Sections shown: Client — IP, type, rule, status, activity Groups — group memberships Rule — firewall rule with inheritance tree and service annotations Peer Blocks — peer-specific restrictions (beyond the assigned rule) Firewall — active iptables rules with ACCEPT/DROP counts Options: --name Client name (e.g. phone-nuno) --type Device type — combines with --name --config Also show raw WireGuard client config --qr Also show QR code Examples: wgctl inspect --name phone-nuno wgctl inspect --name nuno --type phone wgctl inspect --name phone-nuno --config wgctl inspect --name phone-nuno --qr wgctl inspect guest-zephyr EOF } INSPECT_WIDTH=48 # total visible width of section lines INSPECT_LABEL_WIDTH=20 # ============================================ # Private helpers # ============================================ function cmd::inspect::_section() { local title="${1:-}" extra="${2:-0}" local width=$(( INSPECT_WIDTH + extra )) local title_len=${#title} # Account for "── " (3) + " " (1) before dashes local dash_count=$(( width - title_len - 4 )) [[ $dash_count -lt 2 ]] && dash_count=2 local dashes dashes=$(printf '─%.0s' $(seq 1 $dash_count)) printf "\n \033[0;37m── %s %s\033[0m\n" "$title" "$dashes" } function cmd::inspect::_peer_info() { local name="${1:-}" local ip type rule public_key allowed_ips ip=$(peers::get_ip "$name") type=$(peers::get_type "$name") rule=$(peers::get_meta "$name" "rule") public_key=$(keys::public "$name" 2>/dev/null || echo "") allowed_ips=$(grep "^AllowedIPs" "$(ctx::clients)/${name}.conf" \ 2>/dev/null | cut -d'=' -f2- | xargs) # Status local handshake_ts is_blocked last_ts handshake_ts=$(monitor::get_handshake_ts "$public_key") peers::is_blocked "$name" && is_blocked="true" || is_blocked="false" last_ts=$(monitor::last_attempt "$name") local is_restricted="false" block::has_specific_rules "$name" 2>/dev/null && is_restricted="true" local status last_seen endpoint status=$(peers::format_status_verbose "$name" "$public_key" \ "$is_blocked" "$is_restricted" "$handshake_ts" "$last_ts") last_seen=$(peers::format_last_seen "$name" "$public_key" \ "$is_blocked" "$last_ts" "" "$handshake_ts") endpoint=$(monitor::get_cached_endpoint "$name") local activity_total activity_total=$(peers::format_activity_total "$public_key") local activity_current activity_current=$(peers::format_activity_current "$public_key") local rule_file="" local rule_extends="" if [[ -n "$rule" ]]; then rule_file="$(rule::path "$rule" 2>/dev/null)" || true if [[ -n "$rule_file" ]]; then local ext=() mapfile -t ext < <(json::get "$rule_file" "extends" 2>/dev/null || true) if [[ ${#ext[@]} -gt 0 && -n "${ext[0]:-}" ]]; then rule_extends=" (↳ ${ext[*]})" fi fi fi # Rule formatting local rule_display="${rule:-—}" if [[ -n "$rule_file" && ${#ext[@]} -gt 0 && -n "${ext[0]:-}" ]]; then local extends_str extends_str=$(printf '%s, ' "${ext[@]}" | sed 's/, $//') rule_display="${rule} ↳ (${extends_str})" fi cmd::inspect::_section "Client" printf "\n" ui::row "Name" "$name" "${INSPECT_LABEL_WIDTH}" ui::row "IP" "$ip" "${INSPECT_LABEL_WIDTH}" ui::row "Type" "$(peers::display_type "$type")" "${INSPECT_LABEL_WIDTH}" ui::row "Rule" "$rule_display" "${INSPECT_LABEL_WIDTH}" ui::row "Status" "$(echo -e "$status")" "${INSPECT_LABEL_WIDTH}" ui::row "Endpoint" "${endpoint:-—}" "${INSPECT_LABEL_WIDTH}" ui::row "Last seen" "$last_seen" "${INSPECT_LABEL_WIDTH}" ui::row "AllowedIPs" "$allowed_ips" "${INSPECT_LABEL_WIDTH}" ui::row "Public key" "${public_key:-—}" "${INSPECT_LABEL_WIDTH}" ui::row "Activity (total)" "$activity_total" "${INSPECT_LABEL_WIDTH}" ui::row "Activity (current)" "$activity_current" "${INSPECT_LABEL_WIDTH}" return 0 } function cmd::inspect::_rule_separator() { local line_width=20 local total=$INSPECT_WIDTH local pad=$(( (total - line_width) / 2 )) printf "\n%*s\033[2m%s\033[0m\n\n" "$pad" "" "$(printf '─%.0s' $(seq 1 $line_width))" } function cmd::inspect::_rule_info() { local name="${1:-}" local rule rule=$(peers::get_meta "$name" "rule") local identity_name identity_rules strict identity_name=$(identity::get_name "$name") if [[ -n "$identity_name" ]]; then identity_rules=$(identity::rules "$identity_name") strict=$(identity::rule_flags "$identity_name" "strict_rule") fi # Skip section entirely if nothing to show [[ -z "$rule" && -z "$identity_rules" ]] && return 0 # Build section header local header="Rules" [[ -n "$rule" ]] && header="${header}: ${rule}" [[ -n "$identity_name" && -n "$identity_rules" ]] && \ header="${header} · identity:${identity_name}" cmd::inspect::_section "$header" # Identity block first if [[ -n "$identity_name" && -n "$identity_rules" ]]; then ui::rule::identity_block "$identity_name" "$strict" fi # Peer rule block — only if set and not suppressed if [[ -n "$rule" ]]; then rule::exists "$rule" || return 0 if [[ -n "$identity_rules" ]]; then # Both identity and peer rules exist — show peer block with same pattern printf "\n \033[0;37m· peer:%s\033[0m\n" "$name" ui::rule::_peer_rule_entry "$rule" else # Only peer rule — render directly without peer: label printf "\n" if rule::render_extends_tree "$rule"; then : else rule::render_flat "$rule" fi fi elif [[ "$strict" == "true" && -n "$rule" ]]; then printf "\n \033[2mpeer rule '%s' suppressed by strict policy\033[0m\n" "$rule" fi return 0 } function cmd::inspect::_blocks_info() { local name="${1:-}" block::has_file "$name" || return 0 local blocked_direct blocked_direct=$(block::is_blocked_direct "$name") local blocked_groups blocked_groups=$(block::get_groups "$name") local rules_output rules_output=$(block::get_rules "$name") # Skip if truly empty if [[ "$blocked_direct" != "true" ]] && \ ui::empty "$blocked_groups" && \ ui::empty "$rules_output"; then block::cleanup "$name" # clean up stale empty file return 0 fi # Count rules for header local rule_count=0 while IFS= read -r line; do [[ -n "$line" ]] && (( rule_count++ )) || true done <<< "$rules_output" # Build header like firewall: Blocks (+N) local header_counts="" [[ "$rule_count" -gt 0 ]] && header_counts=" (${rule_count})" [[ "$blocked_direct" == "true" || -n "$blocked_groups" ]] && \ header_counts="${header_counts} 🚫" cmd::inspect::_section "Blocks${header_counts}" printf "\n" [[ "$blocked_direct" == "true" ]] && \ printf " \033[1;31m🚫\033[0m blocked directly\n" [[ -n "$blocked_groups" ]] && \ printf " \033[1;31m🚫\033[0m blocked by groups: %s\n" "$blocked_groups" block::format_rules "$name" return 0 } function cmd::inspect::_group_info() { local name="$1" local groups=() mapfile -t groups < <(json::peer_groups "$(ctx::groups)" "$name") ui::empty "${groups[*]}" && return 0 local count=${#groups[@]} cmd::inspect::_section "Groups (${count})" printf "\n" for g in "${groups[@]}"; do [[ -z "$g" ]] && continue local peer_count local main_marker="" peer_count=$(json::count "$(group::path "$g")" "peers") [[ "$g" == "$(peers::get_main_group "$name")" ]] && \ main_marker=" \033[0;33m★\033[0m" printf " \033[0;37m·\033[0m %-20s \033[0;37m%s peers\033[0m%b\n" \ "$g" "$peer_count" "$main_marker" done return 0 } function cmd::inspect::_firewall_info() { local name="${1:-}" local ip ip=$(peers::get_ip "$name") local total=0 accepts=0 drops=0 local rules_output=() while IFS= read -r line; do [[ -z "$line" ]] && continue (( total++ )) || true [[ "$line" =~ ACCEPT ]] && (( accepts++ )) || true [[ "$line" =~ DROP ]] && (( drops++ )) || true rules_output+=("$line") done < <(fw::forward_rules_for_ip "$ip" | grep -v NFLOG) ui::empty "${rules_output[*]}" && return 0 printf "\n \033[0;37m── Firewall (%s %s) \033[0m%s\n\n" \ "$(color::green "+${accepts}")" \ "$(color::red "-${drops}")" \ "$(printf '\033[0;37m─%.0s' {1..28})" fw::list_peer_rules "$ip" false return 0 } function cmd::inspect::_config() { local name="$1" cmd::inspect::_section "Config" printf "\n" cat "$(ctx::clients)/${name}.conf" printf "\n" return 0 } # ============================================ # Run # ============================================ function cmd::inspect::run() { local name="" type="" show_config=false show_qr=false if [[ $# -gt 0 && "$1" != "--"* ]]; then name="$1" shift fi while [[ $# -gt 0 ]]; do case "$1" in --name) name="$2"; shift 2 ;; --type) type="$2"; shift 2 ;; --config) show_config=true; shift ;; --qr) show_qr=true; shift ;; --help) cmd::inspect::help; return ;; *) log::error "Unknown flag: $1" cmd::inspect::help return 1 ;; esac done if [[ -z "$name" ]]; then log::error "Missing required flag: --name" cmd::inspect::help return 1 fi name=$(peers::resolve_and_require "$name" "$type") || return 1 if command::json; then cmd::inspect::_output_json "$name" return 0 fi load_command list log::section "Inspect: ${name}" cmd::inspect::_peer_info "$name" cmd::inspect::_group_info "$name" cmd::inspect::_rule_info "$name" cmd::inspect::_blocks_info "$name" cmd::inspect::_firewall_info "$name" if $show_config; then cmd::inspect::_config "$name" fi if $show_qr; then cmd::inspect::_section "QR Code" printf "\n" load_command qr cmd::qr::run --name "$name" fi printf "\n" } # ============================================ # JSON (API consumption) # ============================================ function cmd::inspect::_output_json() { local name="${1:-}" local ip type rule allowed_ips public_key is_blocked status ip=$(peers::get_ip "$name") type=$(peers::get_type "$name") rule=$(peers::get_meta "$name" "rule") allowed_ips=$(grep "^AllowedIPs" "$(ctx::clients)/${name}.conf" 2>/dev/null | \ awk '{print $3}' | tr -d ',') public_key=$(keys::public "$name" 2>/dev/null || echo "") peers::is_blocked "$name" && is_blocked="true" || is_blocked="false" # Handshake status local handshake_ts=0 handshake_ts=$(wg show "$(config::interface)" latest-handshakes 2>/dev/null | \ grep "$public_key" | awk '{print $2}') || handshake_ts=0 local last_ts last_ts=$(peers::get_meta "$name" "last_ts" 2>/dev/null || echo "") local conn_state conn_state=$(peers::connection_state "$is_blocked" "false" \ "${handshake_ts:-0}" "${last_ts:-}" | cut -d'|' -f1) # Groups local groups_json="[]" local -a group_list=() while IFS= read -r g; do [[ -n "$g" ]] && group_list+=("\"$g\"") done < <(json::peer_groups "$(ctx::groups)" "$name" 2>/dev/null) [[ ${#group_list[@]} -gt 0 ]] && \ groups_json="[$(printf '%s,' "${group_list[@]}" | sed 's/,$//')]" # Identity local identity identity=$(peers::get_identity "$name" 2>/dev/null || echo "") # Rule extends local rule_extends="[]" if [[ -n "$rule" ]]; then local rule_file rule_file=$(json::find_rule_file "$(ctx::rules)" "$rule" 2>/dev/null) if [[ -n "$rule_file" ]]; then local -a extends=() while IFS= read -r ext; do [[ -n "$ext" ]] && extends+=("\"$ext\"") done < <(json::get "$rule_file" "extends" 2>/dev/null) [[ ${#extends[@]} -gt 0 ]] && \ rule_extends="[$(printf '%s,' "${extends[@]}" | sed 's/,$//')]" fi fi local data data=$(printf '{"name":"%s","ip":"%s","type":"%s","rule":"%s","rule_extends":%s,"allowed_ips":"%s","public_key":"%s","is_blocked":%s,"status":"%s","identity":"%s","groups":%s}' \ "$name" "$ip" "$type" \ "${rule:-}" "$rule_extends" \ "${allowed_ips:-}" "$public_key" \ "$is_blocked" "$conn_state" \ "${identity:-}" "$groups_json") printf '%s' "$data" | json::envelope "inspect" "1" }