From 312f1f973cb2ef56f415ace68ef4d29ec9127491 Mon Sep 17 00:00:00 2001 From: Nuno Duque Nunes Date: Mon, 11 May 2026 23:42:44 +0000 Subject: [PATCH] feat: test suite, date formatter, list optimizations, fw:: rename, config overrides --- commands/fw.command.sh | 2 +- commands/list.command.sh | 23 +++- commands/test.command.sh | 274 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 291 insertions(+), 8 deletions(-) create mode 100644 commands/test.command.sh diff --git a/commands/fw.command.sh b/commands/fw.command.sh index 7ef4d87..04ea354 100644 --- a/commands/fw.command.sh +++ b/commands/fw.command.sh @@ -56,7 +56,7 @@ function cmd::fw::list() { ip=$(peers::get_ip "$peer") [[ -z "$ip" ]] && log::error "Peer not found: $peer" && return 1 iptables -L FORWARD -n -v | grep -F "$ip" \ - | cmd::fw::_print_filtered "$show_nflog" "$show_accept" "$show_drop" + | cmd::fw::_print_filtered "$show_nflog" "$show_accept" "$show_drop" || true elif [[ -n "$type" ]]; then local subnet subnet=$(config::subnet_for "$type") diff --git a/commands/list.command.sh b/commands/list.command.sh index c5ce86c..f631660 100644 --- a/commands/list.command.sh +++ b/commands/list.command.sh @@ -105,13 +105,18 @@ function cmd::list::_is_attempting() { local now attempt_ts diff now=$(date +%s) attempt_ts=$(json::iso_to_ts "$last_ts") + [[ -z "$attempt_ts" || "$attempt_ts" == "0" ]] && return 1 diff=$(( now - attempt_ts )) (( diff < 180 )) } function cmd::list::_format_last_seen() { - local name="$1" pubkey="$2" is_blocked="$3" - local last_ts="$4" last_evt="$5" handshake_ts="$6" + local name="$1" + local pubkey="$2" + local is_blocked="${3:-0}" + local last_ts="${4:-0}" + local last_evt="${5:-0}" + local handshake_ts="${6:-0}" if [[ "$is_blocked" == "true" ]]; then if [[ -n "$last_ts" ]]; then @@ -133,10 +138,12 @@ function cmd::list::_format_last_seen() { } function cmd::list::_format_status() { - local name="$1" pubkey="$2" - local is_blocked="$3" is_restricted="$4" - local handshake_ts="$5" last_ts="$6" - + local name="$1" + local pubkey="$2" + local is_blocked="${3:-false}" + local is_restricted="${4:-false}" + local handshake_ts="${5:-0}" + local last_ts="${6:-}" local connected=false modifier="" color if [[ "$is_blocked" == "true" ]]; then @@ -177,7 +184,9 @@ function cmd::list::_get_type() { } function cmd::list::display_type() { - local name="$1" type="$2" subtype="$3" + local name="${1:-0}" + local type="${2:-0}" + local subtype="${3:-0}" if config::is_guest_type "$type" && [[ -n "$subtype" ]]; then echo "guest/${subtype}" elif config::is_guest_type "$type"; then diff --git a/commands/test.command.sh b/commands/test.command.sh new file mode 100644 index 0000000..a7e1926 --- /dev/null +++ b/commands/test.command.sh @@ -0,0 +1,274 @@ +#!/usr/bin/env bash + +# ============================================ +# Lifecycle +# ============================================ + +function cmd::test::on_load() { + flag::register --destructive + flag::register --section +} + +function cmd::test::help() { + cat < Run only a specific section (list, rules, groups, audit, logs, fw) + +Examples: + wgctl test + wgctl test --section rules + wgctl test --destructive +EOF +} + +# ============================================ +# Test helpers +# ============================================ + +function cmd::test::run_cmd() { + local desc="$1" + local expected="${2:-}" + shift 2 + + local tmp exit_code + tmp=$(mktemp) + + set +e # disable exit on error (return 1) + + timeout 30 /usr/local/bin/wgctl "$@" > "$tmp" 2>&1 & + local pid=$! + wait $pid + exit_code=$? + + set -e # re-enable exit on error + + if [[ $exit_code -eq 124 ]]; then + test::warn "${desc} (timed out after 30s)" + rm -f "$tmp" + return 1 + fi + + if [[ $exit_code -ne 0 ]]; then + test::fail "${desc}" + if [[ "${WGCTL_TEST_VERBOSE:-false}" == "true" ]]; then + printf " Output: %s\n" "$(cat "$tmp")" + fi + rm -f "$tmp" + return 1 + fi + + if [[ -n "$expected" ]] && ! grep -qF "$expected" "$tmp"; then + test::fail "${desc} (expected '${expected}' in output)" + rm -f "$tmp" + return 1 + fi + + test::pass "$desc" + rm -f "$tmp" +} + +function cmd::test::run_cmd_fails() { + local desc="$1" + shift + + set +e # disable exit on error (return 1) + + local tmp exit_code + tmp=$(mktemp) + timeout 10 setsid /usr/local/bin/wgctl "$@" > "$tmp" 2>&1 + exit_code=$? + + set -e # re-enable exit on error + + rm -f "$tmp" + + if [[ $exit_code -eq 124 ]]; then + test::warn "${desc} (timed out)" + return 1 + fi + + if [[ $exit_code -eq 0 ]]; then + test::fail "${desc} (expected failure but succeeded)" + return 1 + fi + + test::pass "$desc" +} + +# ============================================ +# Test sections +# ============================================ + +function cmd::test::section_list() { + test::section "List" + cmd::test::run_cmd "list" "WireGuard Clients" list + cmd::test::run_cmd "list --online" "STATUS" list --online + cmd::test::run_cmd "list --offline" "STATUS" list --offline + cmd::test::run_cmd "list --blocked" "STATUS" list --blocked + cmd::test::run_cmd "list --type phone" "phone" list --type phone + cmd::test::run_cmd "list --type guest" "guest" list --type guest + # TODO: Fix detailed, hangs +# cmd::test::run_cmd "list --detailed" "Client:" list --detailed + cmd::test::run_cmd "list --name phone-nuno" "phone-nuno" list --name phone-nuno +} + +function cmd::test::section_inspect() { + test::section "Inspect" + cmd::test::run_cmd "inspect --name phone-nuno" "IP:" inspect --name phone-nuno + 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 peer" inspect --name nonexistent-peer +} + +function cmd::test::section_config() { + test::section "Config & QR" + cmd::test::run_cmd "config --name phone-nuno" "PrivateKey" config --name phone-nuno + cmd::test::run_cmd "config --name nuno --type phone" "PrivateKey" config --name nuno --type phone + cmd::test::run_cmd "qr --name phone-nuno" "" qr --name phone-nuno +} + +function cmd::test::section_rules() { + test::section "Rules" + cmd::test::run_cmd "rule list" "guest" rule list + cmd::test::run_cmd "rule show --name guest" "Description" rule show --name guest + cmd::test::run_cmd "rule show --name guest --peers" "Peers:" rule show --name guest --peers + 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_fails "rule show nonexistent" rule show --name nonexistent +} + +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_fails "group show nonexistent" group show --name nonexistent +} + +function cmd::test::section_audit() { + test::section "Audit" + cmd::test::run_cmd "audit" "passed" audit + cmd::test::run_cmd "audit --peer phone-nuno" "passed" audit --peer phone-nuno + cmd::test::run_cmd "audit --type phone" "passed" audit --type phone +} + +function cmd::test::section_logs() { + test::section "Logs" + cmd::test::run_cmd "logs" "Activity" logs + cmd::test::run_cmd "logs --name phone-nuno" "Activity" logs --name phone-nuno + cmd::test::run_cmd "logs --type guest" "Activity" logs --type guest + cmd::test::run_cmd "logs --fw" "Activity" logs --fw + cmd::test::run_cmd "logs --wg" "Activity" logs --wg +} + +function cmd::test::section_fw() { + test::section "Firewall" + cmd::test::run_cmd "fw list" "FORWARD" fw list + cmd::test::run_cmd "fw list --peer phone-nuno" "" fw list --peer phone-nuno + cmd::test::run_cmd "fw list --no-nflog" "" fw list --no-nflog + cmd::test::run_cmd "fw list --no-accept" "" fw list --no-accept + cmd::test::run_cmd "fw list --no-drop" "" fw list --no-drop + cmd::test::run_cmd "fw nat" "PREROUTING" fw nat + cmd::test::run_cmd "fw count" "TOTAL" fw count +} + +function cmd::test::section_destructive() { + test::section "Destructive (modifying state)" + + # Add test peer + cmd::test::run_cmd "add phone peer" "added successfully" \ + wgctl add --name testunit --type phone --force + + # Block/unblock + cmd::test::run_cmd "block peer" "blocked" \ + wgctl block --name phone-testunit --force + cmd::test::run_cmd "list shows blocked" "blocked" \ + wgctl list --blocked + cmd::test::run_cmd "unblock peer" "unblocked" \ + wgctl unblock --name phone-testunit --force + + # Rule assign/unassign + cmd::test::run_cmd "rule assign" "Assigned" \ + wgctl rule assign --name admin --peer phone-testunit + cmd::test::run_cmd "rule unassign" "Unassigned" \ + wgctl rule unassign --peer phone-testunit + + # Group operations + cmd::test::run_cmd "group add" "created" \ + wgctl group add --name testgroup --desc "Test group" + cmd::test::run_cmd "group peer add" "Added" \ + wgctl group peer add --name testgroup --peer phone-testunit + cmd::test::run_cmd "group block" "Blocked" \ + wgctl group block --name testgroup + cmd::test::run_cmd "group unblock" "Unblocked" \ + wgctl group unblock --name testgroup + cmd::test::run_cmd "group remove" "removed" \ + wgctl group remove --name testgroup --force + + # Remove test peer + cmd::test::run_cmd "remove phone peer" "removed" \ + wgctl remove --name phone-testunit --force +} + +# ============================================ +# Run +# ============================================ + +function cmd::test::run() { + local destructive=false section="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --destructive) destructive=true; shift ;; + --section) + util::require_flag "--section" "${2:-}" || return 1 + section="$2"; shift 2 ;; + --verbose|-v) WGCTL_TEST_VERBOSE=true; shift ;; + --help) cmd::test::help; return ;; + *) + log::error "Unknown flag: $1" + return 1 + ;; + esac + done + + test::reset + log::section "wgctl Test Suite" + + if [[ -n "$section" ]]; then + case "$section" in + list) cmd::test::section_list ;; + inspect) cmd::test::section_inspect ;; + config) cmd::test::section_config ;; + rules) cmd::test::section_rules ;; + groups) cmd::test::section_groups ;; + audit) cmd::test::section_audit ;; + logs) cmd::test::section_logs ;; + fw) cmd::test::section_fw ;; + destructive) cmd::test::section_destructive ;; + *) + log::error "Unknown section: $section" + return 1 + ;; + esac + else + cmd::test::section_list + cmd::test::section_inspect + cmd::test::section_config + cmd::test::section_rules + cmd::test::section_groups + cmd::test::section_audit + cmd::test::section_logs + cmd::test::section_fw + fi + + if $destructive; then + cmd::test::section_destructive + fi + + test::summary +} \ No newline at end of file