最終更新 1 month ago

修正履歴 6dcdb9f84d4133f6b5bae12778430701489ca665

gistfile1.txt Raw
1#!/usr/bin/env bash
2
3# ============================================
4# Lifecycle
5# ============================================
6
7function cmd::group::on_load() {
8 flag::register --name
9 flag::register --desc
10 flag::register --peer
11 flag::register --type
12 flag::register --rule
13 flag::register --new-name
14 flag::register --main
15 flag::register --force
16 flag::register --all
17 flag::register --dry-run
18 command::mixin json_output
19}
20
21# ============================================
22# Help
23# ============================================
24
25function cmd::group::help() {
26 cat <<EOF
27Usage: wgctl group <subcommand> [options]
28
29Manage peer groups. Operations like block/unblock act on all peers in a group.
30A peer can belong to multiple groups (M:N relationship).
31Group blocks track which groups blocked a peer — unblocking one group won't
32unblock a peer still blocked by another group.
33
34Subcommands:
35 list, ls List all groups
36 show Show group members and their status
37 add, new, create Create a new group
38 remove, rm, del Remove a group definition
39 rename Rename a group
40 peer add Add a peer to a group
41 peer remove, peer rm Remove a peer from a group
42 rm-peers Remove all peers in group from WireGuard
43 purge-stale Remove peers that no longer exist from group(s)
44 block Block all peers in group
45 unblock Unblock all peers in group
46 rule assign Assign a rule to all peers in group
47 audit Audit firewall rules for all peers in group
48 logs Show activity logs for all peers in group
49 watch Live monitor for all peers in group
50
51Options:
52 --name <name> Group name
53 --desc <description> Group description (for add)
54 --peer <peer> Peer name
55 --type <type> Peer device type (optional)
56 --rule <rule> Rule name (for rule assign)
57 --new-name <name> New group name (for rename)
58 --limit <n> Max log entries per peer (for logs)
59 --force Skip confirmation prompts
60 --all Apply to all groups (for purge-stale)
61
62Examples:
63 wgctl group list
64 wgctl group add --name family --desc "Family devices"
65 wgctl group peer add --name family --peer phone-nuno
66 wgctl group show --name family
67 wgctl group block --name family
68 wgctl group unblock --name family
69 wgctl group rule assign --name family --rule user
70 wgctl group purge-stale --name family
71 wgctl group purge-stale --all
72 wgctl group purge-stale --all --force
73 wgctl group audit --name family
74 wgctl group logs --name family --limit 20
75 wgctl group watch --name family
76EOF
77}
78
79# ============================================
80# Run
81# ============================================
82
83function cmd::group::run() {
84 local subcmd="${1:-help}"
85 shift || true
86
87 if command::json; then
88 cmd::group::_output_json
89 return 0
90 fi
91
92 case "$subcmd" in
93 list|ls) cmd::group::list "$@" ;;
94 show) cmd::group::show "$@" ;;
95 add|new|create) cmd::group::add "$@" ;;
96 remove|rm|del|delete) cmd::group::remove "$@" ;;
97 rename) cmd::group::rename "$@" ;;
98 peer) cmd::group::peer "$@" ;;
99 rm-peers) cmd::group::rm_peers "$@" ;;
100 set-main) cmd::group::set_main "$@" ;;
101 block) cmd::group::block "$@" ;;
102 unblock) cmd::group::unblock "$@" ;;
103 rule) cmd::group::rule "$@" ;;
104 purge-stale) cmd::group::purge_stale "$@" ;;
105 audit) cmd::group::audit "$@" ;;
106 logs) cmd::group::logs "$@" ;;
107 watch) cmd::group::watch "$@" ;;
108 help) cmd::group::help ;;
109 *)
110 log::error "Unknown subcommand: '${subcmd}'"
111 cmd::group::help
112 return 1
113 ;;
114 esac
115}
116
117# ============================================
118# List
119# ============================================
120
121function cmd::group::list() {
122 local groups_dir
123 groups_dir="$(ctx::groups)"
124
125 local groups=("${groups_dir}"/*.group)
126 if [[ ! -f "${groups[0]}" ]]; then
127 log::wg "No groups configured"
128 return 0
129 fi
130
131 local data
132 data=$(json::group_list_data "$groups_dir" "$(ctx::blocks)" "$(ctx::clients)")
133 [[ -z "$data" ]] && log::wg "No groups configured" && return 0
134
135 # Measure column widths
136 local w_name=12 w_desc=16
137 while IFS="|" read -r name desc total blocked; do
138 [[ -z "$name" ]] && continue
139 (( ${#name} > w_name )) && w_name=${#name}
140 local desc_len=${#desc}
141 [[ -z "$desc" ]] && desc_len=1
142 (( desc_len > w_desc )) && w_desc=$desc_len
143 done <<< "$data"
144 (( w_name += 2 ))
145 (( w_desc += 2 ))
146
147 log::section "Groups"
148 echo ""
149
150 if display::is_table "group_list"; then
151 cmd::group::_render_table "$data" "$w_name" "$w_desc"
152 return 0
153 fi
154
155 while IFS="|" read -r name desc total blocked; do
156 [[ -z "$name" ]] && continue
157 ui::group::list_row "$name" "$desc" "$total" "$blocked" "$w_name" "$w_desc"
158 done <<< "$data"
159
160 echo ""
161}
162
163# ============================================
164# Show
165# ============================================
166
167function cmd::group::show() {
168 local name=""
169
170 while [[ $# -gt 0 ]]; do
171 case "$1" in
172 --name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;;
173 --help) cmd::group::help; return ;;
174 *) log::error "Unknown flag: $1"; return 1 ;;
175 esac
176 done
177
178 [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
179 group::require_exists "$name" || return 1
180
181 local group_file
182 group_file="$(group::path "$name")"
183
184 log::section "Group: ${name}"
185 printf "\n"
186
187 local desc
188 desc=$(json::get "$group_file" "desc")
189 ui::row "Description" "${desc:-—}"
190
191 # Load and filter peers
192 local peers_list=()
193 mapfile -t peers_list < <(json::get "$group_file" "peers") || true
194 local filtered=()
195 for p in "${peers_list[@]:-}"; do
196 [[ -n "$p" ]] && filtered+=("$p")
197 done
198 peers_list=("${filtered[@]:-}")
199 local peer_count=${#peers_list[@]}
200 [[ -z "${peers_list[0]:-}" ]] && peer_count=0
201
202 # Count valid peers (data logic stays in command)
203 local valid_count=0
204 for p in "${peers_list[@]}"; do
205 [[ -z "$p" ]] && continue
206 peers::require_exists "$p" > /dev/null 2>&1 && (( valid_count++ )) || true
207 done
208 local peer_word="peers"
209 [[ "$valid_count" -eq 1 ]] && peer_word="peer"
210 ui::row "Peers" "${valid_count} ${peer_word}"
211 printf "\n"
212
213 if [[ "$peer_count" -gt 0 ]]; then
214 # Measure widths (data logic stays in command)
215 local w_name=16 w_ip=13
216 for peer_name in "${peers_list[@]}"; do
217 [[ -z "$peer_name" ]] && continue
218 (( ${#peer_name} > w_name )) && w_name=${#peer_name}
219 done
220 (( w_name += 2 ))
221
222 # Delegate rendering to ui::
223 ui::group::show_peers peers_list "$w_name" "$w_ip"
224 else
225 printf " \033[2m—\033[0m\n"
226 fi
227
228 printf "\n"
229}
230
231function cmd::group::_render_table() {
232 local data="${1:-}" w_name="${2:-20}" w_desc="${3:-20}"
233 [[ -z "$data" ]] && return 0
234
235 ui::group::list_header_table
236 while IFS='|' read -r name desc total blocked; do
237 [[ -z "$name" ]] && continue
238 ui::group::list_row_table "$name" "$desc" "$total" "$blocked"
239 done <<< "$data"
240}
241
242# ============================================
243# Add
244# ============================================
245
246function cmd::group::add() {
247 local name="" desc=""
248
249 while [[ $# -gt 0 ]]; do
250 case "$1" in
251 --name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;;
252 --desc) util::require_flag "--desc" "${2:-}" || return 1; desc="$2"; shift 2 ;;
253 --help) cmd::group::help; return ;;
254 *) log::error "Unknown flag: $1"; return 1 ;;
255 esac
256 done
257
258 [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
259
260 if group::exists "$name"; then
261 log::error "Group already exists: ${name}"
262 return 1
263 fi
264
265 json::create_group "$(group::path "$name")" "$name" "$desc"
266
267 log::wg_success "Group created: ${name}"
268}
269
270# ============================================
271# Remove
272# ============================================
273
274function cmd::group::remove() {
275 local name="" force=false
276
277 while [[ $# -gt 0 ]]; do
278 case "$1" in
279 --name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;;
280 --force) force=true; shift ;;
281 --help) cmd::group::help; return ;;
282 *) log::error "Unknown flag: $1"; return 1 ;;
283 esac
284 done
285
286 [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
287 group::require_exists "$name" || return 1
288
289 if ! $force; then
290 read -r -p "Remove group '${name}'? This only removes the group definition, not the peers. [y/N] " confirm
291 case "$confirm" in
292 [yY][eE][sS]|[yY]) ;;
293 *) log::info "Aborted"; return 0 ;;
294 esac
295 fi
296
297 rm -f "$(group::path "$name")"
298 log::wg_success "Group removed: ${name}"
299}
300
301# ============================================
302# Rename
303# ============================================
304
305function cmd::group::rename() {
306 local name="" new_name=""
307
308 while [[ $# -gt 0 ]]; do
309 case "$1" in
310 --name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;;
311 --new-name) util::require_flag "--new-name" "${2:-}" || return 1; new_name="$2"; shift 2 ;;
312 --help) cmd::group::help; return ;;
313 *) log::error "Unknown flag: $1"; return 1 ;;
314 esac
315 done
316
317 [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
318 [[ -z "$new_name" ]] && log::error "Missing required flag: --new-name" && return 1
319 group::require_exists "$name" || return 1
320
321 if group::exists "$new_name"; then
322 log::error "Group already exists: ${new_name}"
323 return 1
324 fi
325
326 local old_file new_file
327 old_file="$(group::path "$name")"
328 new_file="$(group::path "$new_name")"
329
330 # Update name field in file
331 json::set "$old_file" "name" "\"$new_name\""
332 mv "$old_file" "$new_file"
333
334 log::wg_success "Group renamed: ${name} → ${new_name}"
335}
336
337# ============================================
338# Peer subcommand
339# ============================================
340
341function cmd::group::peer() {
342 local subcmd="${1:-help}"
343 shift || true
344
345 case "$subcmd" in
346 add) cmd::group::peer_add "$@" ;;
347 remove|rm|del) cmd::group::peer_remove "$@" ;;
348 *)
349 log::error "Unknown peer subcommand: '${subcmd}'"
350 cmd::group::help
351 return 1
352 ;;
353 esac
354}
355
356function cmd::group::peer_add() {
357 local name="" peer="" type="" set_main=false
358
359 while [[ $# -gt 0 ]]; do
360 case "$1" in
361 --name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;;
362 --peer) util::require_flag "--peer" "${2:-}" || return 1; peer="$2"; shift 2 ;;
363 --type) util::require_flag "--type" "${2:-}" || return 1; type="$2"; shift 2 ;;
364 --main) util::require_flag "--main" "${2:-}" || return 1; set_main=true; shift ;;
365 --help) cmd::group::help; return ;;
366 *) log::error "Unknown flag: $1"; return 1 ;;
367 esac
368 done
369
370 [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
371 [[ -z "$peer" ]] && log::error "Missing required flag: --peer" && return 1
372 group::require_exists "$name" || return 1
373
374 peer=$(peers::resolve_and_require "$peer" "$type") || return 1
375
376 # Check if already in group
377 if group::peers "$name" | grep -qF "$peer"; then
378 log::wg_warning "'${peer}' is already in group '${name}'"
379 return 0
380 fi
381
382 group::add_peer "$name" "$peer"
383 log::wg_success "Added '${peer}' to group '${name}'"
384
385 if $set_main; then
386 peers::set_main_group "$peer_name" "$group_name"
387 log::wg_success "Set '${group_name}' as main group for ${peer_name}"
388 fi
389}
390
391function cmd::group::peer_remove() {
392 local name="" peer="" type=""
393
394 while [[ $# -gt 0 ]]; do
395 case "$1" in
396 --name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;;
397 --peer) util::require_flag "--peer" "${2:-}" || return 1; peer="$2"; shift 2 ;;
398 --type) util::require_flag "--type" "${2:-}" || return 1; type="$2"; shift 2 ;;
399 --help) cmd::group::help; return ;;
400 *) log::error "Unknown flag: $1"; return 1 ;;
401 esac
402 done
403
404 [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
405 [[ -z "$peer" ]] && log::error "Missing required flag: --peer" && return 1
406 group::require_exists "$name" || return 1
407
408 peer=$(peers::resolve_and_require "$peer" "$type") || return 1
409 group::remove_peer "$name" "$peer"
410 log::wg_success "Removed '${peer}' from group '${name}'"
411}
412
413function cmd::group::set_main() {
414 local group_name="" peer_name="" type=""
415 while [[ $# -gt 0 ]]; do
416 case "$1" in
417 --name) group_name="$2"; shift 2 ;;
418 --peer) peer_name="$2"; shift 2 ;;
419 --type) type="$2"; shift 2 ;;
420 *) log::error "Unknown flag: $1"; return 1 ;;
421 esac
422 done
423
424 [[ -z "$group_name" ]] && log::error "Missing --name" && return 1
425 [[ -z "$peer_name" ]] && log::error "Missing --peer" && return 1
426
427 # Resolve peer name
428 peer_name=$(peers::resolve_and_require "$peer_name" "$type") || return 1
429
430 # Verify peer is in the group
431 if ! group::has_peer "$group_name" "$peer_name"; then
432 log::error "Peer '${peer_name}' is not in group '${group_name}'"
433 log::info "Add them first: wgctl group peer add --name ${group_name} --peer ${peer_name}"
434 return 1
435 fi
436
437 peers::set_main_group "$peer_name" "$group_name"
438 log::wg_success "Main group for '${peer_name}' set to '${group_name}'"
439}
440
441# ============================================
442# Remove peers from WireGuard
443# ============================================
444
445function cmd::group::rm_peers() {
446 local name="" force=false
447
448 while [[ $# -gt 0 ]]; do
449 case "$1" in
450 --name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;;
451 --force) force=true; shift ;;
452 --help) cmd::group::help; return ;;
453 *) log::error "Unknown flag: $1"; return 1 ;;
454 esac
455 done
456
457 [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
458 group::require_exists "$name" || return 1
459
460 local peers_list=()
461 mapfile -t peers_list < <(group::peers "$name")
462 local peer_count=${#peers_list[@]}
463 [[ -z "${peers_list[0]:-}" ]] && peer_count=0
464
465 if [[ "$peer_count" -eq 0 ]]; then
466 log::wg_warning "Group '${name}' has no peers"
467 return 0
468 fi
469
470 if ! $force; then
471 read -r -p "Remove all ${peer_count} peers in group '${name}' from WireGuard? [y/N] " confirm
472 case "$confirm" in
473 [yY][eE][sS]|[yY]) ;;
474 *) log::info "Aborted"; return 0 ;;
475 esac
476 fi
477
478 load_command remove
479 group::each_peer "$name" cmd::group::_rm_peer_cb
480 log::wg_success "Removed peers from group '${name}' (definition kept)"
481}
482
483function cmd::group::_rm_peer_cb() {
484 local peer_name="${1:-}"
485 if ! group::_peer_exists_check "$peer_name"; then
486 log::wg_warning "Peer '${peer_name}' no longer exists — skipping"
487 return 0
488 fi
489 cmd::remove::run --name "$peer_name" --force
490}
491
492# ============================================
493# Block / Unblock
494# ============================================
495
496function cmd::group::block() {
497 local name="" force=false
498
499 while [[ $# -gt 0 ]]; do
500 case "$1" in
501 --name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;;
502 --force) force=true; shift ;;
503 --help) cmd::group::help; return ;;
504 *) log::error "Unknown flag: $1"; return 1 ;;
505 esac
506 done
507
508 [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
509 group::require_exists "$name" || return 1
510
511 local peers_list=()
512 mapfile -t peers_list < <(group::peers "$name")
513
514 if [[ ${#peers_list[@]} -eq 0 ]] || [[ -z "${peers_list[0]:-}" ]]; then
515 log::wg_warning "Group '${name}' has no peers"
516 return 0
517 fi
518
519 local count=0 skipped=0 blocked_names=()
520 local filtered=()
521 for p in "${peers_list[@]:-}"; do
522 [[ -n "$p" ]] && filtered+=("$p")
523 done
524 [[ ${#filtered[@]} -eq 0 ]] && log::wg_warning "Group '${name}' has no peers" && return 0
525
526 for peer_name in "${filtered[@]}"; do
527 if cmd::group::_block_peer "$peer_name" "$name"; then
528 (( count++ )) || true
529 else
530 (( skipped++ )) || true
531 fi
532 done
533
534 if [[ "$count" -gt 0 ]]; then
535 log::wg_block "All peers from ${name} have been blocked (${count} peers)."
536 fi
537
538 if [[ "$skipped" -gt 0 ]]; then
539 log::wg_warning "${skipped} peers already blocked"
540 fi
541}
542
543function cmd::group::unblock() {
544 local name="" force=false
545
546 while [[ $# -gt 0 ]]; do
547 case "$1" in
548 --name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;;
549 --force) force=true; shift ;;
550 --help) cmd::group::help; return ;;
551 *) log::error "Unknown flag: $1"; return 1 ;;
552 esac
553 done
554
555 [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
556 group::require_exists "$name" || return 1
557
558 local peers_list=()
559 mapfile -t peers_list < <(group::peers "$name")
560
561 local filtered=()
562 for p in "${peers_list[@]:-}"; do [[ -n "$p" ]] && filtered+=("$p"); done
563 [[ ${#filtered[@]} -eq 0 ]] && log::wg_warning "Group '${name}' has no peers" && return 0
564
565 local count=0 skipped=0
566
567 for peer_name in "${filtered[@]}"; do
568 if cmd::group::_unblock_peer "$peer_name" "$name"; then
569 (( count++ )) || true
570 else
571 (( skipped++ )) || true
572 fi
573 done
574
575 if [[ "$count" -gt 0 ]]; then
576 log::wg_unblock "All peers from ${name} have been unblocked."
577 fi
578
579 if [[ "$skipped" -gt 0 ]]; then
580 log::wg_warning "${skipped} peer(s) remain blocked (blocked directly or by other groups)"
581 fi
582}
583
584function cmd::group::_block_peer() {
585 local peer_name="${1:-}" group_name="${2:-}"
586 if ! group::_peer_exists_check "$peer_name"; then
587 log::wg_warning "Peer '${peer_name}' no longer exists — skipping"
588 return 0
589 fi
590
591 local client_ip
592 client_ip=$(peers::get_ip "$peer_name")
593
594 # Check if already blocked by this group
595 local current_blocked_groups
596 current_blocked_groups=$(block::get_groups "$peer_name")
597
598 local IFS=','
599 for g in $current_blocked_groups; do
600 if [[ "$g" == "$group_name" ]]; then
601 log::wg_warning "${peer_name} — already blocked by group '${group_name}'"
602 return 1
603 fi
604 done
605
606 # Add group to block tracking
607 block::add_group "$peer_name" "$client_ip" "$group_name"
608
609 # Apply fw rules only if peer is still in WG server (not yet blocked)
610 if peers::exists_in_server "$peer_name"; then
611 block::apply_full "$peer_name" "$client_ip"
612 fi
613}
614
615function cmd::group::_unblock_peer() {
616 local peer_name="${1:-}" group_name="${2:-}"
617
618 if ! group::_peer_exists_check "$peer_name"; then
619 log::wg_warning "Peer '${peer_name}' no longer exists — skipping"
620 return 1
621 fi
622
623 # Check if blocked by this group at all
624 if ! block::has_file "$peer_name"; then
625 log::wg_warning "${peer_name} — not blocked"
626 return 1
627 fi
628
629 local current_groups
630 current_groups=$(block::get_groups "$peer_name")
631 if [[ "$current_groups" != *"$group_name"* ]]; then
632 log::wg_warning "${peer_name} — not blocked by group '${group_name}'"
633 return 1
634 fi
635
636 local client_ip
637 client_ip=$(peers::get_ip "$peer_name")
638
639 block::remove_group "$peer_name" "$client_ip" "$group_name"
640
641 if block::is_blocked "$peer_name"; then
642 local groups
643 groups=$(block::get_groups "$peer_name")
644
645 local direct
646 direct=$(block::is_blocked_direct "$peer_name")
647
648 if [[ "$direct" == "true" ]]; then
649 log::wg_warning "${peer_name} — still blocked directly, skipping"
650 else
651 log::wg_warning "${peer_name} — still blocked by group(s): ${groups}, skipping"
652 fi
653
654 return 1
655 fi
656
657 block::restore_peer "$peer_name" "$client_ip"
658 block::remove_file "$peer_name"
659
660 local rule
661 rule=$(peers::get_meta "$peer_name" "rule")
662
663 [[ -n "$rule" ]] && rule::exists "$rule" && \
664 rule::apply "$rule" "$client_ip" "$peer_name"
665}
666
667# ============================================
668# Rule assign
669# ============================================
670
671function cmd::group::rule() {
672 local subcmd="${1:-help}"
673 shift || true
674 case "$subcmd" in
675 assign) cmd::group::rule_assign "$@" ;;
676 *)
677 log::error "Unknown rule subcommand: '${subcmd}'"
678 cmd::group::help
679 return 1
680 ;;
681 esac
682}
683
684function cmd::group::rule_assign() {
685 local name="" rule=""
686
687 while [[ $# -gt 0 ]]; do
688 case "$1" in
689 --name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;;
690 --rule) util::require_flag "--rule" "${2:-}" || return 1; rule="$2"; shift 2 ;;
691 --help) cmd::group::help; return ;;
692 *) log::error "Unknown flag: $1"; return 1 ;;
693 esac
694 done
695
696 [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
697 [[ -z "$rule" ]] && log::error "Missing required flag: --rule" && return 1
698 group::require_exists "$name" || return 1
699 rule::require_exists "$rule" || return 1
700
701 local peers_list=()
702 mapfile -t peers_list < <(group::peers "$name")
703 [[ -z "${peers_list[0]:-}" ]] && log::wg_warning "Group '${name}' has no peers" && return 0
704
705 group::each_peer "$name" cmd::group::_rule_assign_cb "$rule"
706 log::wg_success "Assigned rule '${rule}' to group '${name}'"
707}
708
709function cmd::group::_rule_assign_cb() {
710 local peer_name="${1:-}" rule="${2:-}"
711 if ! group::_peer_exists_check "$peer_name"; then
712 log::wg_warning "Peer '${peer_name}' no longer exists — skipping"
713 return 0
714 fi
715 load_command rule
716 cmd::rule::assign --name "$rule" --peer "$peer_name"
717}
718
719# ============================================
720# Audit
721# ============================================
722
723function cmd::group::audit() {
724 local name=""
725
726 while [[ $# -gt 0 ]]; do
727 case "$1" in
728 --name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;;
729 --help) cmd::group::help; return ;;
730 *) log::error "Unknown flag: $1"; return 1 ;;
731 esac
732 done
733
734 [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
735 group::require_exists "$name" || return 1
736
737 local peers_list=()
738 mapfile -t peers_list < <(group::peers "$name")
739 [[ -z "${peers_list[0]}" ]] && log::wg_warning "Group '${name}' has no peers" && return 0
740
741 # Run audit filtered to group peers
742 load_command audit
743 # Pass first peer as filter — audit handles the rest per-peer
744 # Actually run full audit and filter output
745 local peer_args=()
746 for peer_name in "${peers_list[@]}"; do
747 [[ -z "$peer_name" ]] && continue
748 peer_args+=("$peer_name")
749 done
750
751 test::reset
752 log::section "Audit: Group '${name}'"
753
754 # Precompute fw counts
755 declare -A peer_fw_counts
756 while IFS=":" read -r pname count; do
757 [[ -n "$pname" ]] && peer_fw_counts["$pname"]="$count"
758 done < <(json::audit_fw_counts "$(ctx::clients)")
759
760 test::section "Peer Rules"
761 for peer_name in "${peer_args[@]}"; do
762 # Skip if peer no longer exists
763 if ! peers::require_exists "$peer_name" > /dev/null 2>&1; then
764 log::wg_warning "Peer '${peer_name}' no longer exists — skipping"
765 continue
766 fi
767 cmd::audit::check_peer "$peer_name" false "${peer_fw_counts[$peer_name]:-0}"
768 done
769
770 test::summary
771}
772
773# ============================================
774# Logs
775# ============================================
776
777function cmd::group::logs() {
778 local name="" limit=50
779
780 while [[ $# -gt 0 ]]; do
781 case "$1" in
782 --name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;;
783 --limit) util::require_flag "--limit" "${2:-}" || return 1; limit="$2"; shift 2 ;;
784 --help) cmd::group::help; return ;;
785 *) log::error "Unknown flag: $1"; return 1 ;;
786 esac
787 done
788
789 [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
790 group::require_exists "$name" || return 1
791
792 local peers_list=()
793 mapfile -t peers_list < <(group::peers "$name")
794 [[ -z "${peers_list[0]}" ]] && log::wg_warning "Group '${name}' has no peers" && return 0
795
796 log::section "Logs: Group '${name}'"
797
798 for peer_name in "${peers_list[@]}"; do
799 [[ -z "$peer_name" ]] && continue
800 # Skip if peer no longer exists
801 if ! peers::require_exists "$peer_name" > /dev/null 2>&1; then
802 log::wg_warning "Peer '${peer_name}' no longer exists — skipping"
803 continue
804 fi
805 printf "\n \033[1;37m── %s ──\033[0m\n" "$peer_name"
806 load_command logs
807 cmd::logs::show --name "$peer_name" --limit "$limit"
808 done
809}
810
811# ============================================
812# Watch
813# ============================================
814
815function cmd::group::watch() {
816 local name=""
817
818 while [[ $# -gt 0 ]]; do
819 case "$1" in
820 --name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;;
821 --help) cmd::group::help; return ;;
822 *) log::error "Unknown flag: $1"; return 1 ;;
823 esac
824 done
825
826 [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
827 group::require_exists "$name" || return 1
828
829 local peers_list=()
830 mapfile -t peers_list < <(group::peers "$name")
831 [[ -z "${peers_list[0]}" ]] && log::wg_warning "Group '${name}' has no peers" && return 0
832
833 local peer_filter
834 peer_filter=$(IFS=','; echo "${peers_list[*]}")
835
836 # Build comma-separated peer list for watch filter
837 # Watch already supports --type filter but not multiple peers
838 # For now, use follow mode filtered to group peers one at a time
839 # or just run watch with no filter (shows all, user sees group context)
840 log::section "Live Monitor: Group '${name}'"
841 printf " Monitoring: %s\n\n" "${peers_list[*]}"
842
843 load_command watch
844 cmd::watch::run --peers "$peer_filter"
845}
846
847# ============================================
848# Purge Stale
849# ============================================
850
851function cmd::group::purge_stale() {
852 local name="" force=false all=false
853 local dry_run=false
854
855 while [[ $# -gt 0 ]]; do
856 case "$1" in
857 --name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;;
858 --force) force=true; shift ;;
859 --all) all=true; shift ;;
860 --dry-run) dry_run=true; shift ;;
861 --help) cmd::group::help; return ;;
862 *) log::error "Unknown flag: $1"; return 1 ;;
863 esac
864 done
865
866 [[ -z "$name" && "$all" == "false" ]] && \
867 log::error "Specify --name <group> or --all" && return 1
868
869 # Build list of groups to process
870 local -a groups=()
871 if $all; then
872 while IFS= read -r group_file; do
873 groups+=("$(basename "$group_file" .group)")
874 done < <(find "$(ctx::groups)" -name "*.group" 2>/dev/null | sort)
875 else
876 group::require_exists "$name" || return 1
877 groups=("$name")
878 fi
879
880 local total_removed=0 total_groups=0
881
882 for group_name in "${groups[@]}"; do
883 [[ -z "$group_name" ]] && continue
884
885 # Find stale peers — in group but no .conf file
886 local -a stale=()
887 while IFS= read -r peer_name; do
888 [[ -z "$peer_name" ]] && continue
889 if [[ ! -f "$(ctx::clients)/${peer_name}.conf" ]]; then
890 stale+=("$peer_name")
891 fi
892 done < <(group::peers "$group_name" 2>/dev/null)
893
894 [[ ${#stale[@]} -eq 0 ]] && continue
895
896 (( total_groups++ )) || true
897
898 if ! $force; then
899 printf " Group '%s' has %d stale peer(s): %s\n" \
900 "$group_name" "${#stale[@]}" "${stale[*]}"
901 read -r -p " Remove them? [y/N] " confirm
902 case "$confirm" in
903 [yY]*) ;;
904 *) log::info "Skipped '${group_name}'"; continue ;;
905 esac
906 fi
907
908 local group_file
909 group_file="$(group::path "$group_name")"
910 for peer_name in "${stale[@]}"; do
911 if $dry_run; then
912 printf " \033[2m[dry-run]\033[0m Would remove '%s' from group '%s'\n" \
913 "$peer_name" "$group_name"
914 else
915 json::remove "$group_file" "peers" "$peer_name" 2>/dev/null || true
916 log::debug "Removed stale peer '${peer_name}' from group '${group_name}'"
917 fi
918 (( total_removed++ )) || true
919 done
920 done
921
922 local action="Removed"
923 $dry_run && action="Would remove"
924 log::wg_success "${action} ${total_removed} stale peer(s)..."
925
926 if $all; then
927 if [[ "$total_removed" -eq 0 ]]; then
928 log::wg_warning "No stale peers found in any group"
929 else
930 log::wg_success "${action} ${total_removed} stale peer(s)..."
931 fi
932 fi
933}
934
935function cmd::group::_output_json() {
936 local groups_dir
937 groups_dir="$(ctx::groups)"
938 local data
939 data=$(json::group_list_data "$groups_dir" "$(ctx::blocks)" "$(ctx::clients)" 2>/dev/null)
940
941 local -a groups=()
942 while IFS='|' read -r name desc peer_count blocked_count; do
943 [[ -z "$name" ]] && continue
944 groups+=("$(printf '{"name":"%s","desc":"%s","peer_count":%s,"blocked_count":%s}' \
945 "$name" "$desc" "$peer_count" "$blocked_count")")
946 done <<< "$data"
947
948 local count=${#groups[@]}
949 local array
950 array=$(printf '%s\n' "${groups[@]:-}" | paste -sd ',' -)
951 printf '{"groups":[%s]}' "${array:-}" | json::envelope "group list" "$count"
952}