- core/command_mixins.sh: mixin infrastructure with auto-loader - core/mixins/json_output.mixin.sh, no_color.mixin.sh - commands/mixins/MIXIN_TEMPLATE.mixin.sh - command::run: mixin preprocess with nameref, empty array guard - list --json, inspect --json: structured JSON with envelope - json::envelope, json::error_envelope - tests: json output unit tests, group purge-stale, logs clean
428 lines
No EOL
12 KiB
Bash
428 lines
No EOL
12 KiB
Bash
#!/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 <<EOF
|
|
Usage: wgctl inspect --name <name> [options]
|
|
wgctl inspect <full-name>
|
|
|
|
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 <name> Client name (e.g. phone-nuno)
|
|
--type <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"
|
|
} |