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:
Nuno Duque Nunes 2026-05-26 23:18:56 +00:00
parent adab623f3f
commit a3fe7f5986
9 changed files with 324 additions and 2 deletions

View file

@ -5,6 +5,7 @@ function cmd::inspect::on_load() {
flag::register --type
flag::register --config
flag::register --qr
flag::register --json
}
function cmd::inspect::help() {
@ -312,6 +313,7 @@ function cmd::inspect::run() {
--type) type="$2"; shift 2 ;;
--config) show_config=true; shift ;;
--qr) show_qr=true; shift ;;
--json) json_output=true; shift ;;
--help) cmd::inspect::help; return ;;
*)
log::error "Unknown flag: $1"
@ -329,6 +331,11 @@ function cmd::inspect::run() {
name=$(peers::resolve_and_require "$name" "$type") || return 1
if $json_output; then
cmd::inspect::_output_json "$name"
return 0
fi
load_command list
log::section "Inspect: ${name}"
@ -352,3 +359,70 @@ function cmd::inspect::run() {
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"
}

View file

@ -19,6 +19,7 @@ function cmd::list::on_load() {
flag::register --allowed
flag::register --detailed
flag::register --name
command::mixin json_output
}
# ============================================
@ -209,6 +210,11 @@ function cmd::list::run() {
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"
@ -663,3 +669,31 @@ 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"
}

View 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" ]]; }

View file

@ -11,9 +11,12 @@ source "${WGCTL_DIR}/core/context.sh"
source "${WGCTL_DIR}/core/utils.sh"
source "${WGCTL_DIR}/core/module.sh"
source "${WGCTL_DIR}/core/command.sh"
source "${WGCTL_DIR}/core/command_mixins.sh"
source "${WGCTL_DIR}/core/flag.sh"
source "${WGCTL_DIR}/core/json.sh"
source "${WGCTL_DIR}/core/ui.sh"
source "${WGCTL_DIR}/core/color.sh"
source "${WGCTL_DIR}/core/fmt.sh"
source "${WGCTL_DIR}/core/test/test.sh"
command::_load_mixins

View file

@ -39,11 +39,18 @@ function command::exists() { command::has_function "$1" run; }
function command::run() {
local cmd="$1"
shift
command::_reset_mixin_state # reset values only, keep _ACTIVE_MIXINS
local -a args=("$@")
command::_preprocess_flags args
local fn
fn=$(command::fn "$cmd" run)
core::call_function "$fn" "$@"
core::call_function "$fn" "${args[@]}"
}
function core::call_function() {
local fn="$1"
shift

108
core/command_mixins.sh Normal file
View 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
}

View file

@ -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::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() {
ACTIVITY_TOTAL_LOW="$(config::activity_total_low)" \
ACTIVITY_TOTAL_MED="$(config::activity_total_med)" \

View 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" ]]; }

View 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" ]]; }