feat: command mixin system, --json output for list/inspect
- core/command_mixins.sh: mixin infrastructure with auto-loader - core/mixins/json_output.mixin.sh: --json flag mixin - core/mixins/no_color.mixin.sh: --no-color flag mixin - commands/mixins/MIXIN_TEMPLATE.mixin.sh: template for new mixins - command::run: reset mixin state, preprocess flags before dispatch - command::_preprocess_flags: nameref-based flag stripping, empty array fix - command::mixin: opt-in registration from on_load - list --json: structured JSON output with envelope - inspect --json: structured JSON peer detail output - json::envelope, json::error_envelope helpers
This commit is contained in:
parent
adab623f3f
commit
a3fe7f5986
9 changed files with 324 additions and 2 deletions
|
|
@ -5,6 +5,7 @@ function cmd::inspect::on_load() {
|
||||||
flag::register --type
|
flag::register --type
|
||||||
flag::register --config
|
flag::register --config
|
||||||
flag::register --qr
|
flag::register --qr
|
||||||
|
flag::register --json
|
||||||
}
|
}
|
||||||
|
|
||||||
function cmd::inspect::help() {
|
function cmd::inspect::help() {
|
||||||
|
|
@ -312,6 +313,7 @@ function cmd::inspect::run() {
|
||||||
--type) type="$2"; shift 2 ;;
|
--type) type="$2"; shift 2 ;;
|
||||||
--config) show_config=true; shift ;;
|
--config) show_config=true; shift ;;
|
||||||
--qr) show_qr=true; shift ;;
|
--qr) show_qr=true; shift ;;
|
||||||
|
--json) json_output=true; shift ;;
|
||||||
--help) cmd::inspect::help; return ;;
|
--help) cmd::inspect::help; return ;;
|
||||||
*)
|
*)
|
||||||
log::error "Unknown flag: $1"
|
log::error "Unknown flag: $1"
|
||||||
|
|
@ -329,6 +331,11 @@ function cmd::inspect::run() {
|
||||||
|
|
||||||
name=$(peers::resolve_and_require "$name" "$type") || return 1
|
name=$(peers::resolve_and_require "$name" "$type") || return 1
|
||||||
|
|
||||||
|
if $json_output; then
|
||||||
|
cmd::inspect::_output_json "$name"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
load_command list
|
load_command list
|
||||||
|
|
||||||
log::section "Inspect: ${name}"
|
log::section "Inspect: ${name}"
|
||||||
|
|
@ -351,4 +358,71 @@ function cmd::inspect::run() {
|
||||||
fi
|
fi
|
||||||
|
|
||||||
printf "\n"
|
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}')
|
||||||
|
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"
|
||||||
}
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ function cmd::list::on_load() {
|
||||||
flag::register --allowed
|
flag::register --allowed
|
||||||
flag::register --detailed
|
flag::register --detailed
|
||||||
flag::register --name
|
flag::register --name
|
||||||
|
command::mixin json_output
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
@ -209,6 +210,11 @@ function cmd::list::run() {
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if command::json; then
|
||||||
|
cmd::list::_output_json "$collected_rows"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
if $detailed; then
|
if $detailed; then
|
||||||
cmd::list::_render_detailed "$collected_rows"
|
cmd::list::_render_detailed "$collected_rows"
|
||||||
cmd::list::_render_summary_from_rows "$collected_rows"
|
cmd::list::_render_summary_from_rows "$collected_rows"
|
||||||
|
|
@ -662,4 +668,32 @@ function cmd::list::_build_group_summary() {
|
||||||
function cmd::list::_show_client_safe() {
|
function cmd::list::_show_client_safe() {
|
||||||
local name="$1"
|
local name="$1"
|
||||||
cmd::list::show_client "$name" || true
|
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"
|
||||||
}
|
}
|
||||||
34
commands/mixins/mixin.sh.template
Normal file
34
commands/mixins/mixin.sh.template
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# commands/mixins/<name>.mixin.sh
|
||||||
|
# Template for creating a new mixin
|
||||||
|
#
|
||||||
|
# 1. Copy this file to core/mixins/ (framework) or commands/mixins/ (wgctl-specific)
|
||||||
|
# 2. Replace <name> with your mixin name (e.g. peer_filter, time_filter)
|
||||||
|
# 3. Replace <FLAG> with your flag (e.g. --name, --since)
|
||||||
|
# 4. Add state variable and accessor function
|
||||||
|
# 5. In on_load: command::mixin <name>
|
||||||
|
|
||||||
|
# State variable
|
||||||
|
_COMMAND_<NAME>=false # or "" for string values
|
||||||
|
|
||||||
|
# Called when command::mixin <name> is used in on_load
|
||||||
|
function command::mixin::<name>::register() {
|
||||||
|
flag::register --<flag>
|
||||||
|
# Add more flag::register calls if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
# Called before each command invocation to reset state
|
||||||
|
function command::mixin::<name>::reset() {
|
||||||
|
_COMMAND_<NAME>=false
|
||||||
|
}
|
||||||
|
|
||||||
|
# Called for each arg — return 0 if consumed, 1 if not
|
||||||
|
function command::mixin::<name>::process() {
|
||||||
|
case "$1" in
|
||||||
|
--<flag>) _COMMAND_<NAME>=true; return 0 ;;
|
||||||
|
esac
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Public accessor — used by commands
|
||||||
|
function command::<name>() { [[ "${_COMMAND_<NAME>:-false}" == "true" ]]; }
|
||||||
5
core.sh
5
core.sh
|
|
@ -11,9 +11,12 @@ source "${WGCTL_DIR}/core/context.sh"
|
||||||
source "${WGCTL_DIR}/core/utils.sh"
|
source "${WGCTL_DIR}/core/utils.sh"
|
||||||
source "${WGCTL_DIR}/core/module.sh"
|
source "${WGCTL_DIR}/core/module.sh"
|
||||||
source "${WGCTL_DIR}/core/command.sh"
|
source "${WGCTL_DIR}/core/command.sh"
|
||||||
|
source "${WGCTL_DIR}/core/command_mixins.sh"
|
||||||
source "${WGCTL_DIR}/core/flag.sh"
|
source "${WGCTL_DIR}/core/flag.sh"
|
||||||
source "${WGCTL_DIR}/core/json.sh"
|
source "${WGCTL_DIR}/core/json.sh"
|
||||||
source "${WGCTL_DIR}/core/ui.sh"
|
source "${WGCTL_DIR}/core/ui.sh"
|
||||||
source "${WGCTL_DIR}/core/color.sh"
|
source "${WGCTL_DIR}/core/color.sh"
|
||||||
source "${WGCTL_DIR}/core/fmt.sh"
|
source "${WGCTL_DIR}/core/fmt.sh"
|
||||||
source "${WGCTL_DIR}/core/test/test.sh"
|
source "${WGCTL_DIR}/core/test/test.sh"
|
||||||
|
|
||||||
|
command::_load_mixins
|
||||||
|
|
|
||||||
|
|
@ -39,10 +39,17 @@ function command::exists() { command::has_function "$1" run; }
|
||||||
function command::run() {
|
function command::run() {
|
||||||
local cmd="$1"
|
local cmd="$1"
|
||||||
shift
|
shift
|
||||||
|
|
||||||
|
command::_reset_mixin_state # reset values only, keep _ACTIVE_MIXINS
|
||||||
|
|
||||||
|
local -a args=("$@")
|
||||||
|
command::_preprocess_flags args
|
||||||
|
|
||||||
local fn
|
local fn
|
||||||
fn=$(command::fn "$cmd" run)
|
fn=$(command::fn "$cmd" run)
|
||||||
core::call_function "$fn" "$@"
|
core::call_function "$fn" "${args[@]}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function core::call_function() {
|
function core::call_function() {
|
||||||
local fn="$1"
|
local fn="$1"
|
||||||
|
|
|
||||||
108
core/command_mixins.sh
Normal file
108
core/command_mixins.sh
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# core/command_mixins.sh
|
||||||
|
# Mixin infrastructure — loads mixin files and provides command::mixin
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Active mixin tracking (per-process)
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
declare -a _ACTIVE_MIXINS=()
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Auto-load all mixin files
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
function command::_load_mixins() {
|
||||||
|
local mixin_file
|
||||||
|
local -a mixin_paths=(
|
||||||
|
"${WGCTL_DIR}/core/mixins/"*.mixin.sh
|
||||||
|
"${WGCTL_DIR}/commands/mixins/"*.mixin.sh
|
||||||
|
)
|
||||||
|
for mixin_file in "${mixin_paths[@]:-}"; do
|
||||||
|
[[ -f "$mixin_file" ]] && source "$mixin_file"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# command::mixin <name>
|
||||||
|
# Called from cmd::<name>::on_load to opt into a mixin
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
function command::mixin() {
|
||||||
|
local name="${1:-}"
|
||||||
|
[[ -z "$name" ]] && log::error "command::mixin: missing name" && return 1
|
||||||
|
|
||||||
|
local register_fn="command::mixin::${name}::register"
|
||||||
|
if declare -f "$register_fn" >/dev/null 2>&1; then
|
||||||
|
# Track for reset/process — avoid duplicates
|
||||||
|
local m already=false
|
||||||
|
for m in "${_ACTIVE_MIXINS[@]:-}"; do
|
||||||
|
[[ "$m" == "$name" ]] && already=true && break
|
||||||
|
done
|
||||||
|
$already || _ACTIVE_MIXINS+=("$name")
|
||||||
|
"$register_fn"
|
||||||
|
else
|
||||||
|
log::error "Unknown mixin: ${name} (no command::mixin::${name}::register found)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# command::_reset_mixins
|
||||||
|
# Called by command::run before each invocation
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
function command::_reset_mixin_state() {
|
||||||
|
# Reset values but keep _ACTIVE_MIXINS populated
|
||||||
|
local m
|
||||||
|
for m in "${_ACTIVE_MIXINS[@]:-}"; do
|
||||||
|
local reset_fn="command::mixin::${m}::reset"
|
||||||
|
declare -f "$reset_fn" >/dev/null 2>&1 && "$reset_fn"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
function command::_reset_mixins() {
|
||||||
|
_ACTIVE_MIXINS=()
|
||||||
|
local reset_fn mixin_file
|
||||||
|
# Reset all known mixins regardless of active state
|
||||||
|
for mixin_file in \
|
||||||
|
"${WGCTL_DIR}/core/mixins/"*.mixin.sh \
|
||||||
|
"${WGCTL_DIR}/commands/mixins/"*.mixin.sh; do
|
||||||
|
[[ -f "$mixin_file" ]] || continue
|
||||||
|
local mixin_name
|
||||||
|
mixin_name=$(basename "$mixin_file" .mixin.sh)
|
||||||
|
reset_fn="command::mixin::${mixin_name}::reset"
|
||||||
|
declare -f "$reset_fn" >/dev/null 2>&1 && "$reset_fn"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# command::_preprocess_flags <args_nameref>
|
||||||
|
# Called by command::run — strips mixin flags from args
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
function command::_preprocess_flags() {
|
||||||
|
local -n _args_ref="$1"
|
||||||
|
local -a _filtered=()
|
||||||
|
|
||||||
|
for _arg in "${_args_ref[@]:-}"; do
|
||||||
|
local _consumed=false
|
||||||
|
local _mixin
|
||||||
|
for _mixin in "${_ACTIVE_MIXINS[@]:-}"; do
|
||||||
|
local _process_fn="command::mixin::${_mixin}::process"
|
||||||
|
if declare -f "$_process_fn" >/dev/null 2>&1; then
|
||||||
|
if "$_process_fn" "$_arg"; then
|
||||||
|
_consumed=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
$_consumed || _filtered+=("$_arg")
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ ${#_filtered[@]} -gt 0 ]]; then
|
||||||
|
_args_ref=("${_filtered[@]}")
|
||||||
|
else
|
||||||
|
_args_ref=()
|
||||||
|
fi
|
||||||
|
}
|
||||||
20
core/json.sh
20
core/json.sh
|
|
@ -124,6 +124,26 @@ function json::peer_history_lookup() { python3 "$JSON_HELPER" peer_history_lo
|
||||||
function json::clean_handshakes() { python3 "$JSON_HELPER" clean_handshakes "$@" </dev/null; }
|
function json::clean_handshakes() { python3 "$JSON_HELPER" clean_handshakes "$@" </dev/null; }
|
||||||
function json::batch_resolve() { python3 "$JSON_HELPER" batch_resolve "$(ctx::hosts)" "$(ctx::net)" "$@" </dev/null; }
|
function json::batch_resolve() { python3 "$JSON_HELPER" batch_resolve "$(ctx::hosts)" "$(ctx::net)" "$@" </dev/null; }
|
||||||
|
|
||||||
|
# JSON Envelopes
|
||||||
|
function json::envelope() {
|
||||||
|
local command="${1:-}" count="${2:-0}"
|
||||||
|
# Reads JSON array from stdin, wraps in envelope
|
||||||
|
local data
|
||||||
|
data=$(cat)
|
||||||
|
local ts
|
||||||
|
ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
printf '{"ok":true,"command":"%s","data":%s,"meta":{"count":%s,"generated_at":"%s"}}\n' \
|
||||||
|
"$command" "$data" "$count" "$ts"
|
||||||
|
}
|
||||||
|
|
||||||
|
function json::error_envelope() {
|
||||||
|
local command="${1:-}" error="${2:-}"
|
||||||
|
local ts
|
||||||
|
ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
printf '{"ok":false,"command":"%s","error":"%s","meta":{"generated_at":"%s"}}\n' \
|
||||||
|
"$command" "$error" "$ts"
|
||||||
|
}
|
||||||
|
|
||||||
function json::peer_transfer() {
|
function json::peer_transfer() {
|
||||||
ACTIVITY_TOTAL_LOW="$(config::activity_total_low)" \
|
ACTIVITY_TOTAL_LOW="$(config::activity_total_low)" \
|
||||||
ACTIVITY_TOTAL_MED="$(config::activity_total_med)" \
|
ACTIVITY_TOTAL_MED="$(config::activity_total_med)" \
|
||||||
|
|
|
||||||
21
core/mixins/json_output.mixin.sh
Normal file
21
core/mixins/json_output.mixin.sh
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# core/mixins/json_output.mixin.sh
|
||||||
|
# Adds --json flag support to any command
|
||||||
|
|
||||||
|
_COMMAND_JSON=false
|
||||||
|
|
||||||
|
function command::mixin::json_output::register() {
|
||||||
|
flag::register --json
|
||||||
|
}
|
||||||
|
|
||||||
|
function command::mixin::json_output::reset() {
|
||||||
|
_COMMAND_JSON=false
|
||||||
|
}
|
||||||
|
|
||||||
|
function command::mixin::json_output::process() {
|
||||||
|
[[ "$1" == "--json" ]] && _COMMAND_JSON=true && return 0
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Public accessor
|
||||||
|
function command::json() { [[ "${_COMMAND_JSON:-false}" == "true" ]]; }
|
||||||
21
core/mixins/no_color.mixin.sh
Normal file
21
core/mixins/no_color.mixin.sh
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# core/mixins/no_color.mixin.sh
|
||||||
|
# Adds --no-color flag support to any command
|
||||||
|
|
||||||
|
_COMMAND_NO_COLOR=false
|
||||||
|
|
||||||
|
function command::mixin::no_color::register() {
|
||||||
|
flag::register --no-color
|
||||||
|
}
|
||||||
|
|
||||||
|
function command::mixin::no_color::reset() {
|
||||||
|
_COMMAND_NO_COLOR=false
|
||||||
|
}
|
||||||
|
|
||||||
|
function command::mixin::no_color::process() {
|
||||||
|
[[ "$1" == "--no-color" ]] && _COMMAND_NO_COLOR=true && return 0
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Public accessor
|
||||||
|
function command::no_color() { [[ "${_COMMAND_NO_COLOR:-false}" == "true" ]]; }
|
||||||
Loading…
Add table
Reference in a new issue