From a3fe7f5986e103dd8a08129d0ff31d1249aa0928 Mon Sep 17 00:00:00 2001 From: Nuno Duque Nunes Date: Tue, 26 May 2026 23:18:56 +0000 Subject: [PATCH] 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 --- commands/inspect.command.sh | 74 ++++++++++++++++++++ commands/list.command.sh | 34 ++++++++++ commands/mixins/mixin.sh.template | 34 ++++++++++ core.sh | 5 +- core/command.sh | 9 ++- core/command_mixins.sh | 108 ++++++++++++++++++++++++++++++ core/json.sh | 20 ++++++ core/mixins/json_output.mixin.sh | 21 ++++++ core/mixins/no_color.mixin.sh | 21 ++++++ 9 files changed, 324 insertions(+), 2 deletions(-) create mode 100644 commands/mixins/mixin.sh.template create mode 100644 core/command_mixins.sh create mode 100644 core/mixins/json_output.mixin.sh create mode 100644 core/mixins/no_color.mixin.sh diff --git a/commands/inspect.command.sh b/commands/inspect.command.sh index 1e0a9ec..22186eb 100644 --- a/commands/inspect.command.sh +++ b/commands/inspect.command.sh @@ -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}" @@ -351,4 +358,71 @@ function cmd::inspect::run() { 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}') + 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" } \ No newline at end of file diff --git a/commands/list.command.sh b/commands/list.command.sh index 42698a3..65fc2fe 100644 --- a/commands/list.command.sh +++ b/commands/list.command.sh @@ -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" @@ -662,4 +668,32 @@ function cmd::list::_build_group_summary() { 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" } \ No newline at end of file diff --git a/commands/mixins/mixin.sh.template b/commands/mixins/mixin.sh.template new file mode 100644 index 0000000..f59934f --- /dev/null +++ b/commands/mixins/mixin.sh.template @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# commands/mixins/.mixin.sh +# Template for creating a new mixin +# +# 1. Copy this file to core/mixins/ (framework) or commands/mixins/ (wgctl-specific) +# 2. Replace with your mixin name (e.g. peer_filter, time_filter) +# 3. Replace with your flag (e.g. --name, --since) +# 4. Add state variable and accessor function +# 5. In on_load: command::mixin + +# State variable +_COMMAND_=false # or "" for string values + +# Called when command::mixin is used in on_load +function command::mixin::::register() { + flag::register -- + # Add more flag::register calls if needed +} + +# Called before each command invocation to reset state +function command::mixin::::reset() { + _COMMAND_=false +} + +# Called for each arg — return 0 if consumed, 1 if not +function command::mixin::::process() { + case "$1" in + --) _COMMAND_=true; return 0 ;; + esac + return 1 +} + +# Public accessor — used by commands +function command::() { [[ "${_COMMAND_:-false}" == "true" ]]; } \ No newline at end of file diff --git a/core.sh b/core.sh index 0a1d78a..3def78e 100644 --- a/core.sh +++ b/core.sh @@ -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" \ No newline at end of file +source "${WGCTL_DIR}/core/test/test.sh" + +command::_load_mixins diff --git a/core/command.sh b/core/command.sh index ea0bc80..d359560 100644 --- a/core/command.sh +++ b/core/command.sh @@ -39,10 +39,17 @@ 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" diff --git a/core/command_mixins.sh b/core/command_mixins.sh new file mode 100644 index 0000000..ddda8b6 --- /dev/null +++ b/core/command_mixins.sh @@ -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 +# Called from cmd::::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 +# 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 +} \ No newline at end of file diff --git a/core/json.sh b/core/json.sh index f7895cd..8be5e2a 100644 --- a/core/json.sh +++ b/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 "$@"