#!/usr/bin/env bash # ============================================ # Lifecycle # ============================================ function cmd::group::on_load() { flag::register --name flag::register --desc flag::register --peer flag::register --type flag::register --rule flag::register --new-name flag::register --main flag::register --force flag::register --all flag::register --dry-run command::mixin json_output } # ============================================ # Help # ============================================ function cmd::group::help() { cat < [options] Manage peer groups. Operations like block/unblock act on all peers in a group. A peer can belong to multiple groups (M:N relationship). Group blocks track which groups blocked a peer — unblocking one group won't unblock a peer still blocked by another group. Subcommands: list, ls List all groups show Show group members and their status add, new, create Create a new group remove, rm, del Remove a group definition rename Rename a group peer add Add a peer to a group peer remove, peer rm Remove a peer from a group rm-peers Remove all peers in group from WireGuard purge-stale Remove peers that no longer exist from group(s) block Block all peers in group unblock Unblock all peers in group rule assign Assign a rule to all peers in group audit Audit firewall rules for all peers in group logs Show activity logs for all peers in group watch Live monitor for all peers in group Options: --name Group name --desc Group description (for add) --peer Peer name --type Peer device type (optional) --rule Rule name (for rule assign) --new-name New group name (for rename) --limit Max log entries per peer (for logs) --force Skip confirmation prompts --all Apply to all groups (for purge-stale) Examples: wgctl group list wgctl group add --name family --desc "Family devices" wgctl group peer add --name family --peer phone-nuno wgctl group show --name family wgctl group block --name family wgctl group unblock --name family wgctl group rule assign --name family --rule user wgctl group purge-stale --name family wgctl group purge-stale --all wgctl group purge-stale --all --force wgctl group audit --name family wgctl group logs --name family --limit 20 wgctl group watch --name family EOF } # ============================================ # Run # ============================================ 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 "$@" ;; add|new|create) cmd::group::add "$@" ;; remove|rm|del|delete) cmd::group::remove "$@" ;; rename) cmd::group::rename "$@" ;; peer) cmd::group::peer "$@" ;; rm-peers) cmd::group::rm_peers "$@" ;; set-main) cmd::group::set_main "$@" ;; block) cmd::group::block "$@" ;; unblock) cmd::group::unblock "$@" ;; rule) cmd::group::rule "$@" ;; purge-stale) cmd::group::purge_stale "$@" ;; audit) cmd::group::audit "$@" ;; logs) cmd::group::logs "$@" ;; watch) cmd::group::watch "$@" ;; help) cmd::group::help ;; *) log::error "Unknown subcommand: '${subcmd}'" cmd::group::help return 1 ;; esac } # ============================================ # List # ============================================ function cmd::group::list() { local groups_dir groups_dir="$(ctx::groups)" local groups=("${groups_dir}"/*.group) if [[ ! -f "${groups[0]}" ]]; then log::wg "No groups configured" return 0 fi local data data=$(json::group_list_data "$groups_dir" "$(ctx::blocks)" "$(ctx::clients)") [[ -z "$data" ]] && log::wg "No groups configured" && return 0 # Measure column widths local w_name=12 w_desc=16 while IFS="|" read -r name desc total blocked; do [[ -z "$name" ]] && continue (( ${#name} > w_name )) && w_name=${#name} local desc_len=${#desc} [[ -z "$desc" ]] && desc_len=1 (( desc_len > w_desc )) && w_desc=$desc_len done <<< "$data" (( w_name += 2 )) (( w_desc += 2 )) log::section "Groups" echo "" if display::is_table "group_list"; then cmd::group::_render_table "$data" "$w_name" "$w_desc" return 0 fi while IFS="|" read -r name desc total blocked; do [[ -z "$name" ]] && continue ui::group::list_row "$name" "$desc" "$total" "$blocked" "$w_name" "$w_desc" done <<< "$data" echo "" } # ============================================ # Show # ============================================ function cmd::group::show() { local name="" while [[ $# -gt 0 ]]; do case "$1" in --name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;; --help) cmd::group::help; return ;; *) log::error "Unknown flag: $1"; return 1 ;; esac done [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1 group::require_exists "$name" || return 1 local group_file group_file="$(group::path "$name")" log::section "Group: ${name}" printf "\n" local desc desc=$(json::get "$group_file" "desc") ui::row "Description" "${desc:-—}" # Load and filter peers local peers_list=() mapfile -t peers_list < <(json::get "$group_file" "peers") || true local filtered=() for p in "${peers_list[@]:-}"; do [[ -n "$p" ]] && filtered+=("$p") done peers_list=("${filtered[@]:-}") local peer_count=${#peers_list[@]} [[ -z "${peers_list[0]:-}" ]] && peer_count=0 # Count valid peers (data logic stays in command) local valid_count=0 for p in "${peers_list[@]}"; do [[ -z "$p" ]] && continue peers::require_exists "$p" > /dev/null 2>&1 && (( valid_count++ )) || true done local peer_word="peers" [[ "$valid_count" -eq 1 ]] && peer_word="peer" ui::row "Peers" "${valid_count} ${peer_word}" printf "\n" if [[ "$peer_count" -gt 0 ]]; then # Measure widths (data logic stays in command) local w_name=16 w_ip=13 for peer_name in "${peers_list[@]}"; do [[ -z "$peer_name" ]] && continue (( ${#peer_name} > w_name )) && w_name=${#peer_name} done (( w_name += 2 )) # Delegate rendering to ui:: ui::group::show_peers peers_list "$w_name" "$w_ip" else printf " \033[2m—\033[0m\n" fi printf "\n" } function cmd::group::_render_table() { local data="${1:-}" w_name="${2:-20}" w_desc="${3:-20}" [[ -z "$data" ]] && return 0 ui::group::list_header_table while IFS='|' read -r name desc total blocked; do [[ -z "$name" ]] && continue ui::group::list_row_table "$name" "$desc" "$total" "$blocked" done <<< "$data" } # ============================================ # Add # ============================================ function cmd::group::add() { local name="" desc="" while [[ $# -gt 0 ]]; do case "$1" in --name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;; --desc) util::require_flag "--desc" "${2:-}" || return 1; desc="$2"; shift 2 ;; --help) cmd::group::help; return ;; *) log::error "Unknown flag: $1"; return 1 ;; esac done [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1 if group::exists "$name"; then log::error "Group already exists: ${name}" return 1 fi json::create_group "$(group::path "$name")" "$name" "$desc" log::wg_success "Group created: ${name}" } # ============================================ # Remove # ============================================ function cmd::group::remove() { local name="" force=false while [[ $# -gt 0 ]]; do case "$1" in --name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;; --force) force=true; shift ;; --help) cmd::group::help; return ;; *) log::error "Unknown flag: $1"; return 1 ;; esac done [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1 group::require_exists "$name" || return 1 if ! $force; then read -r -p "Remove group '${name}'? This only removes the group definition, not the peers. [y/N] " confirm case "$confirm" in [yY][eE][sS]|[yY]) ;; *) log::info "Aborted"; return 0 ;; esac fi rm -f "$(group::path "$name")" log::wg_success "Group removed: ${name}" } # ============================================ # Rename # ============================================ function cmd::group::rename() { local name="" new_name="" while [[ $# -gt 0 ]]; do case "$1" in --name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;; --new-name) util::require_flag "--new-name" "${2:-}" || return 1; new_name="$2"; shift 2 ;; --help) cmd::group::help; return ;; *) log::error "Unknown flag: $1"; return 1 ;; esac done [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1 [[ -z "$new_name" ]] && log::error "Missing required flag: --new-name" && return 1 group::require_exists "$name" || return 1 if group::exists "$new_name"; then log::error "Group already exists: ${new_name}" return 1 fi local old_file new_file old_file="$(group::path "$name")" new_file="$(group::path "$new_name")" # Update name field in file json::set "$old_file" "name" "\"$new_name\"" mv "$old_file" "$new_file" log::wg_success "Group renamed: ${name} → ${new_name}" } # ============================================ # Peer subcommand # ============================================ function cmd::group::peer() { local subcmd="${1:-help}" shift || true case "$subcmd" in add) cmd::group::peer_add "$@" ;; remove|rm|del) cmd::group::peer_remove "$@" ;; *) log::error "Unknown peer subcommand: '${subcmd}'" cmd::group::help return 1 ;; esac } function cmd::group::peer_add() { local name="" peer="" type="" set_main=false while [[ $# -gt 0 ]]; do case "$1" in --name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;; --peer) util::require_flag "--peer" "${2:-}" || return 1; peer="$2"; shift 2 ;; --type) util::require_flag "--type" "${2:-}" || return 1; type="$2"; shift 2 ;; --main) util::require_flag "--main" "${2:-}" || return 1; set_main=true; shift ;; --help) cmd::group::help; return ;; *) log::error "Unknown flag: $1"; return 1 ;; esac done [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1 [[ -z "$peer" ]] && log::error "Missing required flag: --peer" && return 1 group::require_exists "$name" || return 1 peer=$(peers::resolve_and_require "$peer" "$type") || return 1 # Check if already in group if group::peers "$name" | grep -qF "$peer"; then log::wg_warning "'${peer}' is already in group '${name}'" return 0 fi group::add_peer "$name" "$peer" log::wg_success "Added '${peer}' to group '${name}'" if $set_main; then peers::set_main_group "$peer_name" "$group_name" log::wg_success "Set '${group_name}' as main group for ${peer_name}" fi } function cmd::group::peer_remove() { local name="" peer="" type="" while [[ $# -gt 0 ]]; do case "$1" in --name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;; --peer) util::require_flag "--peer" "${2:-}" || return 1; peer="$2"; shift 2 ;; --type) util::require_flag "--type" "${2:-}" || return 1; type="$2"; shift 2 ;; --help) cmd::group::help; return ;; *) log::error "Unknown flag: $1"; return 1 ;; esac done [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1 [[ -z "$peer" ]] && log::error "Missing required flag: --peer" && return 1 group::require_exists "$name" || return 1 peer=$(peers::resolve_and_require "$peer" "$type") || return 1 group::remove_peer "$name" "$peer" log::wg_success "Removed '${peer}' from group '${name}'" } function cmd::group::set_main() { local group_name="" peer_name="" type="" while [[ $# -gt 0 ]]; do case "$1" in --name) group_name="$2"; shift 2 ;; --peer) peer_name="$2"; shift 2 ;; --type) type="$2"; shift 2 ;; *) log::error "Unknown flag: $1"; return 1 ;; esac done [[ -z "$group_name" ]] && log::error "Missing --name" && return 1 [[ -z "$peer_name" ]] && log::error "Missing --peer" && return 1 # Resolve peer name peer_name=$(peers::resolve_and_require "$peer_name" "$type") || return 1 # Verify peer is in the group if ! group::has_peer "$group_name" "$peer_name"; then log::error "Peer '${peer_name}' is not in group '${group_name}'" log::info "Add them first: wgctl group peer add --name ${group_name} --peer ${peer_name}" return 1 fi peers::set_main_group "$peer_name" "$group_name" log::wg_success "Main group for '${peer_name}' set to '${group_name}'" } # ============================================ # Remove peers from WireGuard # ============================================ function cmd::group::rm_peers() { local name="" force=false while [[ $# -gt 0 ]]; do case "$1" in --name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;; --force) force=true; shift ;; --help) cmd::group::help; return ;; *) log::error "Unknown flag: $1"; return 1 ;; esac done [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1 group::require_exists "$name" || return 1 local peers_list=() mapfile -t peers_list < <(group::peers "$name") local peer_count=${#peers_list[@]} [[ -z "${peers_list[0]:-}" ]] && peer_count=0 if [[ "$peer_count" -eq 0 ]]; then log::wg_warning "Group '${name}' has no peers" return 0 fi if ! $force; then read -r -p "Remove all ${peer_count} peers in group '${name}' from WireGuard? [y/N] " confirm case "$confirm" in [yY][eE][sS]|[yY]) ;; *) log::info "Aborted"; return 0 ;; esac fi load_command remove group::each_peer "$name" cmd::group::_rm_peer_cb log::wg_success "Removed peers from group '${name}' (definition kept)" } function cmd::group::_rm_peer_cb() { local peer_name="${1:-}" if ! group::_peer_exists_check "$peer_name"; then log::wg_warning "Peer '${peer_name}' no longer exists — skipping" return 0 fi cmd::remove::run --name "$peer_name" --force } # ============================================ # Block / Unblock # ============================================ function cmd::group::block() { local name="" force=false while [[ $# -gt 0 ]]; do case "$1" in --name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;; --force) force=true; shift ;; --help) cmd::group::help; return ;; *) log::error "Unknown flag: $1"; return 1 ;; esac done [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1 group::require_exists "$name" || return 1 local peers_list=() mapfile -t peers_list < <(group::peers "$name") if [[ ${#peers_list[@]} -eq 0 ]] || [[ -z "${peers_list[0]:-}" ]]; then log::wg_warning "Group '${name}' has no peers" return 0 fi local count=0 skipped=0 blocked_names=() local filtered=() for p in "${peers_list[@]:-}"; do [[ -n "$p" ]] && filtered+=("$p") done [[ ${#filtered[@]} -eq 0 ]] && log::wg_warning "Group '${name}' has no peers" && return 0 for peer_name in "${filtered[@]}"; do if cmd::group::_block_peer "$peer_name" "$name"; then (( count++ )) || true else (( skipped++ )) || true fi done if [[ "$count" -gt 0 ]]; then log::wg_block "All peers from ${name} have been blocked (${count} peers)." fi if [[ "$skipped" -gt 0 ]]; then log::wg_warning "${skipped} peers already blocked" fi } function cmd::group::unblock() { local name="" force=false while [[ $# -gt 0 ]]; do case "$1" in --name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;; --force) force=true; shift ;; --help) cmd::group::help; return ;; *) log::error "Unknown flag: $1"; return 1 ;; esac done [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1 group::require_exists "$name" || return 1 local peers_list=() mapfile -t peers_list < <(group::peers "$name") local filtered=() for p in "${peers_list[@]:-}"; do [[ -n "$p" ]] && filtered+=("$p"); done [[ ${#filtered[@]} -eq 0 ]] && log::wg_warning "Group '${name}' has no peers" && return 0 local count=0 skipped=0 for peer_name in "${filtered[@]}"; do if cmd::group::_unblock_peer "$peer_name" "$name"; then (( count++ )) || true else (( skipped++ )) || true fi done if [[ "$count" -gt 0 ]]; then log::wg_unblock "All peers from ${name} have been unblocked." fi if [[ "$skipped" -gt 0 ]]; then log::wg_warning "${skipped} peer(s) remain blocked (blocked directly or by other groups)" fi } function cmd::group::_block_peer() { local peer_name="${1:-}" group_name="${2:-}" if ! group::_peer_exists_check "$peer_name"; then log::wg_warning "Peer '${peer_name}' no longer exists — skipping" return 0 fi local client_ip client_ip=$(peers::get_ip "$peer_name") # Check if already blocked by this group local current_blocked_groups current_blocked_groups=$(block::get_groups "$peer_name") local IFS=',' for g in $current_blocked_groups; do if [[ "$g" == "$group_name" ]]; then log::wg_warning "${peer_name} — already blocked by group '${group_name}'" return 1 fi done # Add group to block tracking block::add_group "$peer_name" "$client_ip" "$group_name" # Apply fw rules only if peer is still in WG server (not yet blocked) if peers::exists_in_server "$peer_name"; then block::apply_full "$peer_name" "$client_ip" fi } function cmd::group::_unblock_peer() { local peer_name="${1:-}" group_name="${2:-}" if ! group::_peer_exists_check "$peer_name"; then log::wg_warning "Peer '${peer_name}' no longer exists — skipping" return 1 fi # Check if blocked by this group at all if ! block::has_file "$peer_name"; then log::wg_warning "${peer_name} — not blocked" return 1 fi local current_groups current_groups=$(block::get_groups "$peer_name") if [[ "$current_groups" != *"$group_name"* ]]; then log::wg_warning "${peer_name} — not blocked by group '${group_name}'" return 1 fi local client_ip client_ip=$(peers::get_ip "$peer_name") block::remove_group "$peer_name" "$client_ip" "$group_name" if block::is_blocked "$peer_name"; then local groups groups=$(block::get_groups "$peer_name") local direct direct=$(block::is_blocked_direct "$peer_name") if [[ "$direct" == "true" ]]; then log::wg_warning "${peer_name} — still blocked directly, skipping" else log::wg_warning "${peer_name} — still blocked by group(s): ${groups}, skipping" fi return 1 fi block::restore_peer "$peer_name" "$client_ip" block::remove_file "$peer_name" local rule rule=$(peers::get_meta "$peer_name" "rule") [[ -n "$rule" ]] && rule::exists "$rule" && \ rule::apply "$rule" "$client_ip" "$peer_name" } # ============================================ # Rule assign # ============================================ function cmd::group::rule() { local subcmd="${1:-help}" shift || true case "$subcmd" in assign) cmd::group::rule_assign "$@" ;; *) log::error "Unknown rule subcommand: '${subcmd}'" cmd::group::help return 1 ;; esac } function cmd::group::rule_assign() { local name="" rule="" while [[ $# -gt 0 ]]; do case "$1" in --name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;; --rule) util::require_flag "--rule" "${2:-}" || return 1; rule="$2"; shift 2 ;; --help) cmd::group::help; return ;; *) log::error "Unknown flag: $1"; return 1 ;; esac done [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1 [[ -z "$rule" ]] && log::error "Missing required flag: --rule" && return 1 group::require_exists "$name" || return 1 rule::require_exists "$rule" || return 1 local peers_list=() mapfile -t peers_list < <(group::peers "$name") [[ -z "${peers_list[0]:-}" ]] && log::wg_warning "Group '${name}' has no peers" && return 0 group::each_peer "$name" cmd::group::_rule_assign_cb "$rule" log::wg_success "Assigned rule '${rule}' to group '${name}'" } function cmd::group::_rule_assign_cb() { local peer_name="${1:-}" rule="${2:-}" if ! group::_peer_exists_check "$peer_name"; then log::wg_warning "Peer '${peer_name}' no longer exists — skipping" return 0 fi load_command rule cmd::rule::assign --name "$rule" --peer "$peer_name" } # ============================================ # Audit # ============================================ function cmd::group::audit() { local name="" while [[ $# -gt 0 ]]; do case "$1" in --name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;; --help) cmd::group::help; return ;; *) log::error "Unknown flag: $1"; return 1 ;; esac done [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1 group::require_exists "$name" || return 1 local peers_list=() mapfile -t peers_list < <(group::peers "$name") [[ -z "${peers_list[0]}" ]] && log::wg_warning "Group '${name}' has no peers" && return 0 # Run audit filtered to group peers load_command audit # Pass first peer as filter — audit handles the rest per-peer # Actually run full audit and filter output local peer_args=() for peer_name in "${peers_list[@]}"; do [[ -z "$peer_name" ]] && continue peer_args+=("$peer_name") done test::reset log::section "Audit: Group '${name}'" # Precompute fw counts declare -A peer_fw_counts while IFS=":" read -r pname count; do [[ -n "$pname" ]] && peer_fw_counts["$pname"]="$count" done < <(json::audit_fw_counts "$(ctx::clients)") test::section "Peer Rules" for peer_name in "${peer_args[@]}"; do # Skip if peer no longer exists if ! peers::require_exists "$peer_name" > /dev/null 2>&1; then log::wg_warning "Peer '${peer_name}' no longer exists — skipping" continue fi cmd::audit::check_peer "$peer_name" false "${peer_fw_counts[$peer_name]:-0}" done test::summary } # ============================================ # Logs # ============================================ function cmd::group::logs() { local name="" limit=50 while [[ $# -gt 0 ]]; do case "$1" in --name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;; --limit) util::require_flag "--limit" "${2:-}" || return 1; limit="$2"; shift 2 ;; --help) cmd::group::help; return ;; *) log::error "Unknown flag: $1"; return 1 ;; esac done [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1 group::require_exists "$name" || return 1 local peers_list=() mapfile -t peers_list < <(group::peers "$name") [[ -z "${peers_list[0]}" ]] && log::wg_warning "Group '${name}' has no peers" && return 0 log::section "Logs: Group '${name}'" for peer_name in "${peers_list[@]}"; do [[ -z "$peer_name" ]] && continue # Skip if peer no longer exists if ! peers::require_exists "$peer_name" > /dev/null 2>&1; then log::wg_warning "Peer '${peer_name}' no longer exists — skipping" continue fi printf "\n \033[1;37m── %s ──\033[0m\n" "$peer_name" load_command logs cmd::logs::show --name "$peer_name" --limit "$limit" done } # ============================================ # Watch # ============================================ function cmd::group::watch() { local name="" while [[ $# -gt 0 ]]; do case "$1" in --name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;; --help) cmd::group::help; return ;; *) log::error "Unknown flag: $1"; return 1 ;; esac done [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1 group::require_exists "$name" || return 1 local peers_list=() mapfile -t peers_list < <(group::peers "$name") [[ -z "${peers_list[0]}" ]] && log::wg_warning "Group '${name}' has no peers" && return 0 local peer_filter peer_filter=$(IFS=','; echo "${peers_list[*]}") # Build comma-separated peer list for watch filter # Watch already supports --type filter but not multiple peers # For now, use follow mode filtered to group peers one at a time # or just run watch with no filter (shows all, user sees group context) log::section "Live Monitor: Group '${name}'" printf " Monitoring: %s\n\n" "${peers_list[*]}" load_command watch cmd::watch::run --peers "$peer_filter" } # ============================================ # Purge Stale # ============================================ function cmd::group::purge_stale() { local name="" force=false all=false local dry_run=false while [[ $# -gt 0 ]]; do case "$1" in --name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;; --force) force=true; shift ;; --all) all=true; shift ;; --dry-run) dry_run=true; shift ;; --help) cmd::group::help; return ;; *) log::error "Unknown flag: $1"; return 1 ;; esac done [[ -z "$name" && "$all" == "false" ]] && \ log::error "Specify --name or --all" && return 1 # Build list of groups to process local -a groups=() if $all; then while IFS= read -r group_file; do groups+=("$(basename "$group_file" .group)") done < <(find "$(ctx::groups)" -name "*.group" 2>/dev/null | sort) else group::require_exists "$name" || return 1 groups=("$name") fi local total_removed=0 total_groups=0 for group_name in "${groups[@]}"; do [[ -z "$group_name" ]] && continue # Find stale peers — in group but no .conf file local -a stale=() while IFS= read -r peer_name; do [[ -z "$peer_name" ]] && continue if [[ ! -f "$(ctx::clients)/${peer_name}.conf" ]]; then stale+=("$peer_name") fi done < <(group::peers "$group_name" 2>/dev/null) [[ ${#stale[@]} -eq 0 ]] && continue (( total_groups++ )) || true if ! $force; then printf " Group '%s' has %d stale peer(s): %s\n" \ "$group_name" "${#stale[@]}" "${stale[*]}" read -r -p " Remove them? [y/N] " confirm case "$confirm" in [yY]*) ;; *) log::info "Skipped '${group_name}'"; continue ;; esac fi local group_file group_file="$(group::path "$group_name")" for peer_name in "${stale[@]}"; do if $dry_run; then printf " \033[2m[dry-run]\033[0m Would remove '%s' from group '%s'\n" \ "$peer_name" "$group_name" else json::remove "$group_file" "peers" "$peer_name" 2>/dev/null || true log::debug "Removed stale peer '${peer_name}' from group '${group_name}'" fi (( total_removed++ )) || true done done local action="Removed" $dry_run && action="Would remove" log::wg_success "${action} ${total_removed} stale peer(s)..." if $all; then if [[ "$total_removed" -eq 0 ]]; then log::wg_warning "No stale peers found in any group" else 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" }