diff --git a/commands/activity.command.sh b/commands/activity.command.sh index a2eb16a..207286e 100644 --- a/commands/activity.command.sh +++ b/commands/activity.command.sh @@ -14,6 +14,8 @@ function cmd::activity::on_load() { flag::register --hours flag::register --type flag::register --dropped + + command::mixin json_output } # ============================================ @@ -70,6 +72,11 @@ function cmd::activity::run() { esac done + if command::json; then + cmd::activity::_output_json "$hours" + return 0 + fi + # Resolve peer name if type provided if [[ -n "$filter_peer" && -n "$filter_type" ]]; then filter_peer=$(peers::resolve_and_require "$filter_peer" "$filter_type") || return 1 @@ -188,3 +195,51 @@ function cmd::activity::run() { 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" +} diff --git a/commands/group.command.sh b/commands/group.command.sh index 0fe8fea..7cd4751 100644 --- a/commands/group.command.sh +++ b/commands/group.command.sh @@ -15,6 +15,7 @@ function cmd::group::on_load() { flag::register --force flag::register --all flag::register --dry-run + command::mixin json_output } # ============================================ @@ -83,6 +84,11 @@ function cmd::group::run() { local subcmd="${1:-help}" shift || true + if command::json; then + cmd::group::_output_json + return 0 + fi + case "$subcmd" in list|ls) cmd::group::list "$@" ;; show) cmd::group::show "$@" ;; @@ -908,4 +914,23 @@ function cmd::group::purge_stale() { log::wg_success "${action} ${total_removed} stale peer(s)..." 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" } \ No newline at end of file diff --git a/commands/hosts.command.sh b/commands/hosts.command.sh index 55e5e7b..cd9e10c 100644 --- a/commands/hosts.command.sh +++ b/commands/hosts.command.sh @@ -14,6 +14,8 @@ function cmd::hosts::on_load() { flag::register --tag flag::register --tags flag::register --force + + command::mixin json_output } # ============================================ @@ -71,6 +73,12 @@ EOF function cmd::hosts::run() { local subcmd="${1:-list}" shift || true + + if command::json; then + cmd::hosts::_output_json + return 0 + fi + case "$subcmd" in list) cmd::hosts::list "$@" ;; show) cmd::hosts::show "$@" ;; @@ -288,4 +296,30 @@ function cmd::hosts::rm() { json::hosts_remove "$(ctx::hosts)" "$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" } \ No newline at end of file diff --git a/commands/identity.command.sh b/commands/identity.command.sh index f498eb0..3f4517f 100644 --- a/commands/identity.command.sh +++ b/commands/identity.command.sh @@ -20,9 +20,7 @@ function cmd::identity::on_load() { flag::register --peer flag::register --dry-run flag::register --force - # rule subcommand flags flag::register --rule - # options subcommand flags flag::register --policy flag::register --set-strict-rule flag::register --unset-strict-rule @@ -31,6 +29,8 @@ function cmd::identity::on_load() { flag::register --field flag::register --value flag::register --migrate + + command::mixin json_output } # ============================================ @@ -78,6 +78,11 @@ function cmd::identity::run() { local subcmd="${1:-list}" shift || true + if command::json && [[ "$subcmd" == "list" ]]; then + cmd::identity::_output_json + return 0 + fi + case "$subcmd" in list) cmd::identity::_list "$@" ;; show) cmd::identity::_show "$@" ;; @@ -565,4 +570,41 @@ function cmd::identity::_options() { if ! $changed; then cmd::identity::_rule_show --name "$name" fi -} \ No newline at end of file +} + +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" +} + \ No newline at end of file diff --git a/commands/inspect.command.sh b/commands/inspect.command.sh index 1e0a9ec..7f8cacf 100644 --- a/commands/inspect.command.sh +++ b/commands/inspect.command.sh @@ -5,6 +5,8 @@ function cmd::inspect::on_load() { flag::register --type flag::register --config flag::register --qr + + command::mixin json_output } function cmd::inspect::help() { @@ -329,6 +331,11 @@ function cmd::inspect::run() { 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}" @@ -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}' | 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" } \ No newline at end of file diff --git a/commands/list.command.sh b/commands/list.command.sh index 42698a3..e4779f4 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_TEMPLATE.mixin.sh.template b/commands/mixins/MIXIN_TEMPLATE.mixin.sh.template new file mode 100644 index 0000000..f59934f --- /dev/null +++ b/commands/mixins/MIXIN_TEMPLATE.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/commands/net.command.sh b/commands/net.command.sh index 2045a8e..b36ed01 100644 --- a/commands/net.command.sh +++ b/commands/net.command.sh @@ -8,6 +8,8 @@ function cmd::net::on_load() { flag::register --tag flag::register --detailed flag::register --force + + command::mixin json_output } function cmd::net::help() { @@ -58,6 +60,12 @@ EOF function cmd::net::run() { local subcmd="${1:-list}" shift || true + + if command::json; then + cmd::net::_output_json + return 0 + fi + case "$subcmd" in list) cmd::net::list "$@" ;; show) cmd::net::show "$@" ;; @@ -304,4 +312,31 @@ function cmd::net::rm() { json::net_remove "$(ctx::net)" "$name" log::wg_success "Removed: ${name}" 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" } \ No newline at end of file diff --git a/commands/policy.command.sh b/commands/policy.command.sh index 649a540..db2927a 100644 --- a/commands/policy.command.sh +++ b/commands/policy.command.sh @@ -27,6 +27,8 @@ function cmd::policy::on_load() { flag::register --desc flag::register --field flag::register --value + + command::mixin json_output } # ============================================ @@ -79,6 +81,11 @@ function cmd::policy::run() { local subcmd="${1:-list}" shift || true + if command::json; then + cmd::policy::_output_json + return 0 + fi + case "$subcmd" in list) cmd::policy::_list "$@" ;; show) cmd::policy::_show "$@" ;; @@ -244,4 +251,28 @@ function cmd::policy::_set() { json::policy_set_field "$(ctx::policies)" "$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" } \ No newline at end of file diff --git a/commands/rule.command.sh b/commands/rule.command.sh index f592d83..e5b2611 100644 --- a/commands/rule.command.sh +++ b/commands/rule.command.sh @@ -31,6 +31,8 @@ function cmd::rule::on_load() { flag::register --force flag::register --type flag::register --all + + command::mixin json_output } # ============================================ @@ -116,6 +118,11 @@ function cmd::rule::run() { local subcmd="${1:-help}" shift || true + if command::json; then + cmd::rule::_output_json + return 0 + fi + case "$subcmd" in list|ls) cmd::rule::list "$@" ;; show|inspect) cmd::rule::show "$@" ;; @@ -671,4 +678,38 @@ function cmd::rule::reapply() { rule::require_exists "$name" || return 1 rule::reapply_all "$name" 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" } \ No newline at end of file diff --git a/commands/subnet.command.sh b/commands/subnet.command.sh index 0a41956..af7e494 100644 --- a/commands/subnet.command.sh +++ b/commands/subnet.command.sh @@ -22,6 +22,8 @@ function cmd::subnet::on_load() { flag::register --desc flag::register --group flag::register --new-name + + command::mixin json_output } # ============================================ @@ -65,6 +67,11 @@ function cmd::subnet::run() { local subcmd="${1:-list}" shift || true + if command::json; then + cmd::subnet::_output_json + return 0 + fi + case "$subcmd" in list) cmd::subnet::_list "$@" ;; show) cmd::subnet::_show "$@" ;; @@ -291,4 +298,26 @@ function cmd::subnet::_validate_cidr() { log::error "Invalid CIDR format: '${cidr}'" return 1 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" } \ No newline at end of file diff --git a/commands/test/integration.sh b/commands/test/integration.sh index de3a636..d440c66 100644 --- a/commands/test/integration.sh +++ b/commands/test/integration.sh @@ -132,8 +132,11 @@ function cmd::test::run_all_integration_sections() { cmd::test::section_net cmd::test::section_subnet cmd::test::section_identity + cmd::test::section_activity cmd::test::section_hosts cmd::test::section_peer_cmd + cmd::test::section_group_purge + cmd::test::section_logs_clean } 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 --detailed" "rule:" list --detailed 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() { @@ -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 phone-nuno --config" "PrivateKey" inspect --name phone-nuno --config 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() { @@ -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 user" "Description" rule show --name user 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 } @@ -175,6 +189,7 @@ function cmd::test::section_groups() { test::section "Groups" 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 list --json" '"groups":' group list --json 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 --wg --event attempt" "" logs --wg --event attempt 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() { @@ -222,8 +241,9 @@ 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 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_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 "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 add port no service" net add --name nonexistent:web --port 80:tcp } function cmd::test::section_subnet() { @@ -255,9 +275,14 @@ function cmd::test::section_identity() { 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 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 } +function cmd::test::section_activity() { + cmd::test::run_cmd "activity --json" '"peers":' activity --json +} + function cmd::test::section_hosts() { test::section "Hosts" @@ -299,4 +324,25 @@ function cmd::test::section_peer_cmd() { "$WGCTL_BINARY" peer update-tunnel --name phone-testunit --mode split > /dev/null 2>&1 || true "$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true } - \ No newline at end of file + + 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 +} \ No newline at end of file diff --git a/commands/test/unit.sh b/commands/test/unit.sh index e242b43..e53463c 100644 --- a/commands/test/unit.sh +++ b/commands/test/unit.sh @@ -48,6 +48,7 @@ function cmd::test::run_all_unit_sections() { cmd::test::unit_config cmd::test::unit_parse_since cmd::test::unit_group_status + cmd::test::unit_json_output } function cmd::test::unit_subnet() { @@ -219,4 +220,30 @@ function cmd::test::unit_group_status() { IFS='|' read -r color str <<< "$(ui::group::status 4 2)" 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" } \ 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..6957d53 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[@]+"${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..fdb5582 --- /dev/null +++ b/core/command_mixins.sh @@ -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 +# 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=() + + 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 +} \ 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 "$@"