Compare commits

...

6 commits

Author SHA1 Message Date
Nuno Duque Nunes
1fa40c1e25 Merge feature/json-output: --json output, command mixin system (v0.6.0) 2026-05-27 00:52:47 +00:00
Nuno Duque Nunes
0e3f281519 feat: --json for hosts/subnet/policy list commands
- cmd::hosts::_output_json: hosts with type/tags array
- cmd::subnet::_output_json: subnets with is_group bool
- cmd::policy::_output_json: policies with proper booleans
2026-05-27 00:52:45 +00:00
Nuno Duque Nunes
087f735790 feat: --json output for group/rule/identity/net/activity
- cmd::group::_output_json: groups array with peer/blocked counts
- cmd::rule::_output_json: rules with extends array, is_base bool fix
- cmd::identity::_output_json: identities with types/rules as arrays
- cmd::net::_output_json: services with tags array and port count
- cmd::activity::_output_json: peers with nested services array
- all commands: command::mixin json_output registered in on_load
2026-05-27 00:36:30 +00:00
Nuno Duque Nunes
fae088f61a commands/mixin/MIXIN_TEMPLATE.mixin.sh 2026-05-27 00:01:53 +00:00
Nuno Duque Nunes
14d2a78b78 feat: command mixin system, --json for list/inspect, tests
- 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
2026-05-27 00:01:06 +00:00
Nuno Duque Nunes
a3fe7f5986 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
2026-05-26 23:18:56 +00:00
19 changed files with 698 additions and 8 deletions

View file

@ -14,6 +14,8 @@ function cmd::activity::on_load() {
flag::register --hours flag::register --hours
flag::register --type flag::register --type
flag::register --dropped flag::register --dropped
command::mixin json_output
} }
# ============================================ # ============================================
@ -70,6 +72,11 @@ function cmd::activity::run() {
esac esac
done done
if command::json; then
cmd::activity::_output_json "$hours"
return 0
fi
# Resolve peer name if type provided # Resolve peer name if type provided
if [[ -n "$filter_peer" && -n "$filter_type" ]]; then if [[ -n "$filter_peer" && -n "$filter_type" ]]; then
filter_peer=$(peers::resolve_and_require "$filter_peer" "$filter_type") || return 1 filter_peer=$(peers::resolve_and_require "$filter_peer" "$filter_type") || return 1
@ -188,3 +195,51 @@ function cmd::activity::run() {
echo "" echo ""
} }
function cmd::activity::_output_json() {
local hours="${1:-24}"
local data
data=$(json::activity_aggregate \
"$(ctx::fw_events_log)" "$(ctx::events_log)" \
"$(config::interface)" "$(ctx::net)" \
"$(ctx::clients)" "$(ctx::meta)" \
"$hours" "" "" 2>/dev/null)
local -a peers=()
local current_peer="" current_services=""
local -a current_svc_list=()
while IFS='|' read -r record_type rest; do
case "$record_type" in
peer)
# Flush previous peer
if [[ -n "$current_peer" ]]; then
local svc_array
svc_array=$(printf '%s\n' "${current_svc_list[@]:-}" | paste -sd ',' -)
peers+=("${current_peer},\"services\":[${svc_array:-}]}")
current_svc_list=()
fi
local name rx tx drops
IFS='|' read -r name rx tx drops <<< "$rest"
current_peer=$(printf '{"name":"%s","rx":%s,"tx":%s,"drops":%s' \
"$name" "$rx" "$tx" "$drops")
;;
service)
local peer dest count
IFS='|' read -r peer dest count <<< "$rest"
current_svc_list+=("$(printf '{"dest":"%s","drops":%s}' "$dest" "$count")")
;;
esac
done <<< "$data"
# Flush last peer
if [[ -n "$current_peer" ]]; then
local svc_array
svc_array=$(printf '%s\n' "${current_svc_list[@]:-}" | paste -sd ',' -)
peers+=("${current_peer},\"services\":[${svc_array:-}]}")
fi
local count=${#peers[@]}
local array
array=$(printf '%s\n' "${peers[@]:-}" | paste -sd ',' -)
printf '{"peers":[%s]}' "${array:-}" | json::envelope "activity" "$count"
}

View file

@ -15,6 +15,7 @@ function cmd::group::on_load() {
flag::register --force flag::register --force
flag::register --all flag::register --all
flag::register --dry-run flag::register --dry-run
command::mixin json_output
} }
# ============================================ # ============================================
@ -83,6 +84,11 @@ function cmd::group::run() {
local subcmd="${1:-help}" local subcmd="${1:-help}"
shift || true shift || true
if command::json; then
cmd::group::_output_json
return 0
fi
case "$subcmd" in case "$subcmd" in
list|ls) cmd::group::list "$@" ;; list|ls) cmd::group::list "$@" ;;
show) cmd::group::show "$@" ;; show) cmd::group::show "$@" ;;
@ -909,3 +915,22 @@ function cmd::group::purge_stale() {
fi fi
fi fi
} }
function cmd::group::_output_json() {
local groups_dir
groups_dir="$(ctx::groups)"
local data
data=$(json::group_list_data "$groups_dir" "$(ctx::blocks)" "$(ctx::clients)" 2>/dev/null)
local -a groups=()
while IFS='|' read -r name desc peer_count blocked_count; do
[[ -z "$name" ]] && continue
groups+=("$(printf '{"name":"%s","desc":"%s","peer_count":%s,"blocked_count":%s}' \
"$name" "$desc" "$peer_count" "$blocked_count")")
done <<< "$data"
local count=${#groups[@]}
local array
array=$(printf '%s\n' "${groups[@]:-}" | paste -sd ',' -)
printf '{"groups":[%s]}' "${array:-}" | json::envelope "group list" "$count"
}

View file

@ -14,6 +14,8 @@ function cmd::hosts::on_load() {
flag::register --tag flag::register --tag
flag::register --tags flag::register --tags
flag::register --force flag::register --force
command::mixin json_output
} }
# ============================================ # ============================================
@ -71,6 +73,12 @@ EOF
function cmd::hosts::run() { function cmd::hosts::run() {
local subcmd="${1:-list}" local subcmd="${1:-list}"
shift || true shift || true
if command::json; then
cmd::hosts::_output_json
return 0
fi
case "$subcmd" in case "$subcmd" in
list) cmd::hosts::list "$@" ;; list) cmd::hosts::list "$@" ;;
show) cmd::hosts::show "$@" ;; show) cmd::hosts::show "$@" ;;
@ -289,3 +297,29 @@ function cmd::hosts::rm() {
json::hosts_remove "$(ctx::hosts)" "$entry_type" "$key" json::hosts_remove "$(ctx::hosts)" "$entry_type" "$key"
log::wg_success "Removed ${entry_type}: ${key}" log::wg_success "Removed ${entry_type}: ${key}"
} }
function cmd::hosts::_output_json() {
local data
data=$(json::hosts_list "$(ctx::hosts)" 2>/dev/null)
local -a hosts=()
while IFS='|' read -r type ip name desc tags; do
[[ -z "$type" ]] && continue
local tags_json="[]"
if [[ -n "$tags" ]]; then
local tags_array
tags_array=$(echo "$tags" | tr ',' '\n' | \
while IFS= read -r t; do [[ -n "$t" ]] && printf '"%s",' "$t"; done | sed 's/,$//')
tags_json="[${tags_array}]"
fi
hosts+=("$(printf '{"type":"%s","ip":"%s","name":"%s","desc":"%s","tags":%s}' \
"$type" "$ip" "$name" "$desc" "$tags_json")")
done <<< "$data"
local count=${#hosts[@]}
local array
array=$(printf '%s\n' "${hosts[@]:-}" | paste -sd ',' -)
printf '{"hosts":[%s]}' "${array:-}" | json::envelope "hosts list" "$count"
}

View file

@ -20,9 +20,7 @@ function cmd::identity::on_load() {
flag::register --peer flag::register --peer
flag::register --dry-run flag::register --dry-run
flag::register --force flag::register --force
# rule subcommand flags
flag::register --rule flag::register --rule
# options subcommand flags
flag::register --policy flag::register --policy
flag::register --set-strict-rule flag::register --set-strict-rule
flag::register --unset-strict-rule flag::register --unset-strict-rule
@ -31,6 +29,8 @@ function cmd::identity::on_load() {
flag::register --field flag::register --field
flag::register --value flag::register --value
flag::register --migrate flag::register --migrate
command::mixin json_output
} }
# ============================================ # ============================================
@ -78,6 +78,11 @@ function cmd::identity::run() {
local subcmd="${1:-list}" local subcmd="${1:-list}"
shift || true shift || true
if command::json && [[ "$subcmd" == "list" ]]; then
cmd::identity::_output_json
return 0
fi
case "$subcmd" in case "$subcmd" in
list) cmd::identity::_list "$@" ;; list) cmd::identity::_list "$@" ;;
show) cmd::identity::_show "$@" ;; show) cmd::identity::_show "$@" ;;
@ -566,3 +571,40 @@ function cmd::identity::_options() {
cmd::identity::_rule_show --name "$name" cmd::identity::_rule_show --name "$name"
fi fi
} }
function cmd::identity::_output_json() {
local data
data=$(identity::list_data 2>/dev/null)
local -a identities=()
while IFS='|' read -r name peer_count types rules policy; do
[[ -z "$name" ]] && continue
# Build rules array
local rules_json="[]"
if [[ -n "$rules" ]]; then
local rules_array
rules_array=$(echo "$rules" | tr ',' '\n' | \
while IFS= read -r r; do [[ -n "$r" ]] && printf '"%s",' "$r"; done | sed 's/,$//')
rules_json="[${rules_array}]"
fi
# Build types array (was comma-separated string)
local types_json="[]"
if [[ -n "$types" ]]; then
local types_array
types_array=$(echo "$types" | tr ',' '\n' | \
while IFS= read -r t; do [[ -n "$t" ]] && printf '"%s",' "$t"; done | sed 's/,$//')
types_json="[${types_array}]"
fi
identities+=("$(printf '{"name":"%s","peer_count":%s,"types":%s,"rules":%s,"policy":"%s"}' \
"$name" "$peer_count" "$types_json" "$rules_json" "$policy")")
done <<< "$data"
local count=${#identities[@]}
local array
array=$(printf '%s\n' "${identities[@]:-}" | paste -sd ',' -)
printf '{"identities":[%s]}' "${array:-}" | json::envelope "identity list" "$count"
}

View file

@ -5,6 +5,8 @@ function cmd::inspect::on_load() {
flag::register --type flag::register --type
flag::register --config flag::register --config
flag::register --qr flag::register --qr
command::mixin json_output
} }
function cmd::inspect::help() { function cmd::inspect::help() {
@ -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 command::json; then
cmd::inspect::_output_json "$name"
return 0
fi
load_command list load_command list
log::section "Inspect: ${name}" log::section "Inspect: ${name}"
@ -352,3 +359,70 @@ function cmd::inspect::run() {
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}' | 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"
}

View file

@ -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"
@ -663,3 +669,31 @@ 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"
}

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

@ -8,6 +8,8 @@ function cmd::net::on_load() {
flag::register --tag flag::register --tag
flag::register --detailed flag::register --detailed
flag::register --force flag::register --force
command::mixin json_output
} }
function cmd::net::help() { function cmd::net::help() {
@ -58,6 +60,12 @@ EOF
function cmd::net::run() { function cmd::net::run() {
local subcmd="${1:-list}" local subcmd="${1:-list}"
shift || true shift || true
if command::json; then
cmd::net::_output_json
return 0
fi
case "$subcmd" in case "$subcmd" in
list) cmd::net::list "$@" ;; list) cmd::net::list "$@" ;;
show) cmd::net::show "$@" ;; show) cmd::net::show "$@" ;;
@ -305,3 +313,30 @@ function cmd::net::rm() {
log::wg_success "Removed: ${name}" log::wg_success "Removed: ${name}"
return 0 return 0
} }
function cmd::net::_output_json() {
local net_file
net_file="$(ctx::net)"
local data
data=$(json::net_list "$net_file" 2>/dev/null)
local -a services=()
while IFS='|' read -r name ip desc tags port_count; do
[[ -z "$name" ]] && continue
# Build tags array
local tags_json="[]"
if [[ -n "$tags" ]]; then
local tags_array
tags_array=$(echo "$tags" | tr ',' '\n' | \
while IFS= read -r t; do [[ -n "$t" ]] && printf '"%s",' "$t"; done | sed 's/,$//')
tags_json="[${tags_array}]"
fi
services+=("$(printf '{"name":"%s","ip":"%s","desc":"%s","tags":%s,"port_count":%s}' \
"$name" "$ip" "$desc" "$tags_json" "$port_count")")
done <<< "$data"
local count=${#services[@]}
local array
array=$(printf '%s\n' "${services[@]:-}" | paste -sd ',' -)
printf '{"services":[%s]}' "${array:-}" | json::envelope "net list" "$count"
}

View file

@ -27,6 +27,8 @@ function cmd::policy::on_load() {
flag::register --desc flag::register --desc
flag::register --field flag::register --field
flag::register --value flag::register --value
command::mixin json_output
} }
# ============================================ # ============================================
@ -79,6 +81,11 @@ function cmd::policy::run() {
local subcmd="${1:-list}" local subcmd="${1:-list}"
shift || true shift || true
if command::json; then
cmd::policy::_output_json
return 0
fi
case "$subcmd" in case "$subcmd" in
list) cmd::policy::_list "$@" ;; list) cmd::policy::_list "$@" ;;
show) cmd::policy::_show "$@" ;; show) cmd::policy::_show "$@" ;;
@ -245,3 +252,27 @@ function cmd::policy::_set() {
json::policy_set_field "$(ctx::policies)" "$name" "$field" "$value" json::policy_set_field "$(ctx::policies)" "$name" "$field" "$value"
log::ok "Policy '${name}': ${field} = ${value}" log::ok "Policy '${name}': ${field} = ${value}"
} }
function cmd::policy::_output_json() {
local data
data=$(policy::list_data 2>/dev/null)
local -a policies=()
while IFS='|' read -r name tunnel_mode default_rule strict_rule auto_apply desc; do
[[ -z "$name" ]] && continue
local strict_json="false"
[[ "$strict_rule" == "true" ]] && strict_json="true"
local auto_json="true"
[[ "$auto_apply" == "false" ]] && auto_json="false"
policies+=("$(printf '{"name":"%s","tunnel_mode":"%s","default_rule":"%s","strict_rule":%s,"auto_apply":%s,"desc":"%s"}' \
"$name" "$tunnel_mode" "${default_rule:-}" \
"$strict_json" "$auto_json" "$desc")")
done <<< "$data"
local count=${#policies[@]}
local array
array=$(printf '%s\n' "${policies[@]:-}" | paste -sd ',' -)
printf '{"policies":[%s]}' "${array:-}" | json::envelope "policy list" "$count"
}

View file

@ -31,6 +31,8 @@ function cmd::rule::on_load() {
flag::register --force flag::register --force
flag::register --type flag::register --type
flag::register --all flag::register --all
command::mixin json_output
} }
# ============================================ # ============================================
@ -116,6 +118,11 @@ function cmd::rule::run() {
local subcmd="${1:-help}" local subcmd="${1:-help}"
shift || true shift || true
if command::json; then
cmd::rule::_output_json
return 0
fi
case "$subcmd" in case "$subcmd" in
list|ls) cmd::rule::list "$@" ;; list|ls) cmd::rule::list "$@" ;;
show|inspect) cmd::rule::show "$@" ;; show|inspect) cmd::rule::show "$@" ;;
@ -672,3 +679,37 @@ function cmd::rule::reapply() {
rule::reapply_all "$name" rule::reapply_all "$name"
log::wg_success "Rule '${name}' reapplied" log::wg_success "Rule '${name}' reapplied"
} }
function cmd::rule::_output_json() {
local rules_dir
rules_dir="$(ctx::rules)"
local data
data=$(json::rule_list_data "$rules_dir" "$(ctx::meta)" 2>/dev/null)
local -a rules=()
while IFS='|' read -r name desc n_allows n_blocks peer_count extends is_base group; do
[[ -z "$name" ]] && continue
# Build extends array
local extends_json="[]"
if [[ -n "$extends" ]]; then
local ext_array
ext_array=$(echo "$extends" | tr ',' '\n' | \
while IFS= read -r e; do [[ -n "$e" ]] && printf '"%s",' "$e"; done | sed 's/,$//')
extends_json="[${ext_array}]"
fi
# Convert Python bool to JSON bool
local is_base_json="false"
[[ "$is_base" == "True" ]] && is_base_json="true"
rules+=("$(printf '{"name":"%s","desc":"%s","allows":%s,"blocks":%s,"peer_count":%s,"extends":%s,"is_base":%s,"group":"%s"}' \
"$name" "$desc" "$n_allows" "$n_blocks" "$peer_count" \
"$extends_json" "$is_base_json" "$group")")
done <<< "$data"
local count=${#rules[@]}
local array
array=$(printf '%s\n' "${rules[@]:-}" | paste -sd ',' -)
printf '{"rules":[%s]}' "${array:-}" | json::envelope "rule list" "$count"
}

View file

@ -22,6 +22,8 @@ function cmd::subnet::on_load() {
flag::register --desc flag::register --desc
flag::register --group flag::register --group
flag::register --new-name flag::register --new-name
command::mixin json_output
} }
# ============================================ # ============================================
@ -65,6 +67,11 @@ function cmd::subnet::run() {
local subcmd="${1:-list}" local subcmd="${1:-list}"
shift || true shift || true
if command::json; then
cmd::subnet::_output_json
return 0
fi
case "$subcmd" in case "$subcmd" in
list) cmd::subnet::_list "$@" ;; list) cmd::subnet::_list "$@" ;;
show) cmd::subnet::_show "$@" ;; show) cmd::subnet::_show "$@" ;;
@ -292,3 +299,25 @@ function cmd::subnet::_validate_cidr() {
return 1 return 1
fi fi
} }
function cmd::subnet::_output_json() {
local data
data=$(subnet::list_data 2>/dev/null)
local -a subnets=()
while IFS='|' read -r type cidr display_name tunnel_mode desc is_group group_parent; do
[[ -z "$type" ]] && continue
local is_group_json="false"
[[ "$is_group" == "true" ]] && is_group_json="true"
subnets+=("$(printf '{"type":"%s","cidr":"%s","display_name":"%s","tunnel_mode":"%s","desc":"%s","is_group":%s,"group_parent":"%s"}' \
"$type" "$cidr" "$display_name" "$tunnel_mode" \
"$desc" "$is_group_json" "${group_parent:-}")")
done <<< "$data"
local count=${#subnets[@]}
local array
array=$(printf '%s\n' "${subnets[@]:-}" | paste -sd ',' -)
printf '{"subnets":[%s]}' "${array:-}" | json::envelope "subnet list" "$count"
}

View file

@ -132,8 +132,11 @@ function cmd::test::run_all_integration_sections() {
cmd::test::section_net cmd::test::section_net
cmd::test::section_subnet cmd::test::section_subnet
cmd::test::section_identity cmd::test::section_identity
cmd::test::section_activity
cmd::test::section_hosts cmd::test::section_hosts
cmd::test::section_peer_cmd cmd::test::section_peer_cmd
cmd::test::section_group_purge
cmd::test::section_logs_clean
} }
function cmd::test::section_list() { function cmd::test::section_list() {
@ -145,6 +148,12 @@ function cmd::test::section_list() {
cmd::test::run_cmd "list --type phone" "phone" list --type phone cmd::test::run_cmd "list --type phone" "phone" list --type phone
cmd::test::run_cmd "list --detailed" "rule:" list --detailed cmd::test::run_cmd "list --detailed" "rule:" list --detailed
cmd::test::run_cmd "list --name phone-nuno" "phone-nuno" list --name phone-nuno cmd::test::run_cmd "list --name phone-nuno" "phone-nuno" list --name phone-nuno
cmd::test::run_cmd "list --json" '"ok":true' list --json
cmd::test::run_cmd "list --json has peers" '"peers":' list --json
cmd::test::run_cmd "list --json has meta" '"meta":' list --json
cmd::test::run_cmd "list --json peer name" '"name":' list --json
cmd::test::run_cmd "list --json peer ip" '"ip":' list --json
cmd::test::run_cmd "list --json peer status" '"status":' list --json
} }
function cmd::test::section_inspect() { function cmd::test::section_inspect() {
@ -153,6 +162,10 @@ function cmd::test::section_inspect() {
cmd::test::run_cmd "inspect --name nuno --type phone" "IP:" inspect --name nuno --type phone cmd::test::run_cmd "inspect --name nuno --type phone" "IP:" inspect --name nuno --type phone
cmd::test::run_cmd "inspect --name phone-nuno --config" "PrivateKey" inspect --name phone-nuno --config cmd::test::run_cmd "inspect --name phone-nuno --config" "PrivateKey" inspect --name phone-nuno --config
cmd::test::run_cmd_fails "inspect nonexistent" inspect --name nonexistent-peer cmd::test::run_cmd_fails "inspect nonexistent" inspect --name nonexistent-peer
cmd::test::run_cmd "inspect --json" '"ok":true' inspect --name phone-nuno --json
cmd::test::run_cmd "inspect --json rule" '"rule":' inspect --name phone-nuno --json
cmd::test::run_cmd "inspect --json identity" '"identity":' inspect --name phone-nuno --json
cmd::test::run_cmd "inspect --json groups" '"groups":' inspect --name phone-nuno --json
} }
function cmd::test::section_config() { function cmd::test::section_config() {
@ -168,6 +181,7 @@ function cmd::test::section_rules() {
cmd::test::run_cmd "rule show --name guest" "Description" rule show --name guest cmd::test::run_cmd "rule show --name guest" "Description" rule show --name guest
cmd::test::run_cmd "rule show --name user" "Description" rule show --name user cmd::test::run_cmd "rule show --name user" "Description" rule show --name user
cmd::test::run_cmd "rule show --name admin" "Description" rule show --name admin cmd::test::run_cmd "rule show --name admin" "Description" rule show --name admin
cmd::test::run_cmd "rule list --json" '"rules":' rule list --json
cmd::test::run_cmd_fails "rule show nonexistent" rule show --name nonexistent cmd::test::run_cmd_fails "rule show nonexistent" rule show --name nonexistent
} }
@ -175,6 +189,7 @@ function cmd::test::section_groups() {
test::section "Groups" test::section "Groups"
cmd::test::run_cmd "group list" "Groups" group list cmd::test::run_cmd "group list" "Groups" group list
cmd::test::run_cmd "group show --name family" "Peers:" group show --name family cmd::test::run_cmd "group show --name family" "Peers:" group show --name family
cmd::test::run_cmd "group list --json" '"groups":' group list --json
cmd::test::run_cmd_fails "group show nonexistent" group show --name nonexistent cmd::test::run_cmd_fails "group show nonexistent" group show --name nonexistent
} }
@ -196,6 +211,10 @@ function cmd::test::section_logs() {
cmd::test::run_cmd "logs --fw --since 2099-01-01" "No logs" logs --fw --since "2099-01-01" cmd::test::run_cmd "logs --fw --since 2099-01-01" "No logs" logs --fw --since "2099-01-01"
cmd::test::run_cmd "logs --wg --event attempt" "" logs --wg --event attempt cmd::test::run_cmd "logs --wg --event attempt" "" logs --wg --event attempt
cmd::test::run_cmd "logs --detailed" "" logs --detailed cmd::test::run_cmd "logs --detailed" "" logs --detailed
cmd::test::run_cmd "logs --resolved" "" logs --resolved
cmd::test::run_cmd "logs --ascending" "" logs --ascending
cmd::test::run_cmd "logs --descending" "" logs --descending
cmd::test::run_cmd "logs --wg --ascending" "" logs --wg --ascending
} }
function cmd::test::section_fw() { function cmd::test::section_fw() {
@ -222,6 +241,7 @@ function cmd::test::section_net() {
cmd::test::run_cmd "net add port again" "Added" net add --name test-svc:web --port 9999:tcp cmd::test::run_cmd "net add port again" "Added" net add --name test-svc:web --port 9999:tcp
cmd::test::run_cmd "net rm all ports" "Removed" net rm --name test-svc:ports --force cmd::test::run_cmd "net rm all ports" "Removed" net rm --name test-svc:ports --force
cmd::test::run_cmd "net rm service" "Removed" net rm --name test-svc --force cmd::test::run_cmd "net rm service" "Removed" net rm --name test-svc --force
cmd::test::run_cmd "net list --json" '"services":' net list --json
cmd::test::run_cmd_fails "net show nonexistent" net show --name nonexistent-svc cmd::test::run_cmd_fails "net show nonexistent" net show --name nonexistent-svc
cmd::test::run_cmd_fails "net add port no service" net add --name nonexistent:web --port 80:tcp cmd::test::run_cmd_fails "net add port no service" net add --name nonexistent:web --port 80:tcp
} }
@ -255,9 +275,14 @@ function cmd::test::section_identity() {
cmd::test::run_cmd "identity list" "" identity list cmd::test::run_cmd "identity list" "" identity list
cmd::test::run_cmd "identity migrate --dry-run" "Dry run" identity migrate --dry-run cmd::test::run_cmd "identity migrate --dry-run" "Dry run" identity migrate --dry-run
cmd::test::run_cmd "identity show nuno" "nuno" identity show --name nuno cmd::test::run_cmd "identity show nuno" "nuno" identity show --name nuno
cmd::test::run_cmd "identity list --json" '"identities":' identity list --json
cmd::test::run_cmd_fails "identity show nonexistent" identity show --name nonexistent cmd::test::run_cmd_fails "identity show nonexistent" identity show --name nonexistent
} }
function cmd::test::section_activity() {
cmd::test::run_cmd "activity --json" '"peers":' activity --json
}
function cmd::test::section_hosts() { function cmd::test::section_hosts() {
test::section "Hosts" test::section "Hosts"
@ -300,3 +325,24 @@ function cmd::test::section_peer_cmd() {
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true "$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
} }
function cmd::test::section_group_purge() {
test::section "Group: purge-stale"
# dry-run should not modify anything
cmd::test::run_cmd "purge-stale --all --dry-run" \
"[dry-run]" \
group purge-stale --all --dry-run --force
# single group dry-run
cmd::test::run_cmd "purge-stale --name family --dry-run" \
"[dry-run]" \
group purge-stale --name family --dry-run --force
}
function cmd::test::section_logs_clean() {
test::section "Logs: clean"
cmd::test::run_cmd "logs clean --force" \
"keepalive" \
logs clean --force
}

View file

@ -48,6 +48,7 @@ function cmd::test::run_all_unit_sections() {
cmd::test::unit_config cmd::test::unit_config
cmd::test::unit_parse_since cmd::test::unit_parse_since
cmd::test::unit_group_status cmd::test::unit_group_status
cmd::test::unit_json_output
} }
function cmd::test::unit_subnet() { function cmd::test::unit_subnet() {
@ -220,3 +221,29 @@ function cmd::test::unit_group_status() {
IFS='|' read -r color str <<< "$(ui::group::status 4 2)" IFS='|' read -r color str <<< "$(ui::group::status 4 2)"
cmd::test::assert "group status partial str" "$str" "partial (2/4)" cmd::test::assert "group status partial str" "$str" "partial (2/4)"
} }
function cmd::test::unit_json_output() {
test::section "Unit: JSON output"
# json::envelope produces valid structure
local result
result=$(echo '{"peers":[]}' | json::envelope "list" "0")
cmd::test::assert "envelope ok field" "$(echo "$result" | grep -o '"ok":true')" '"ok":true'
cmd::test::assert "envelope command field" "$(echo "$result" | grep -o '"command":"list"')" '"command":"list"'
cmd::test::assert "envelope meta field" "$(echo "$result" | grep -o '"meta":')" '"meta":'
cmd::test::assert "envelope count field" "$(echo "$result" | grep -o '"count":0')" '"count":0'
# json::error_envelope
local err_result
err_result=$(json::error_envelope "inspect" "Peer not found")
cmd::test::assert "error envelope ok false" \
"$(echo "$err_result" | grep -o '"ok":false')" '"ok":false'
cmd::test::assert "error envelope error field" \
"$(echo "$err_result" | grep -o '"error":')" '"error":'
# command::json mixin accessor
_COMMAND_JSON=true
cmd::test::assert_true "command::json true" "command::json"
_COMMAND_JSON=false
cmd::test::assert_false "command::json false" "command::json"
}

View file

@ -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

View file

@ -39,11 +39,18 @@ 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[@]+"${args[@]}"}
} }
function core::call_function() { function core::call_function() {
local fn="$1" local fn="$1"
shift shift

111
core/command_mixins.sh Normal file
View file

@ -0,0 +1,111 @@
#!/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=()
if [[ ${#_args_ref[@]} -eq 0 ]]; then
return 0 # nothing to process
fi
for _arg in "${_args_ref[@]}"; do
local _consumed=false
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::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)" \

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