#!/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 --force } # ============================================ # Help # ============================================ function cmd::group::help() { cat < [options] Manage peer groups. Subcommands: list, ls List all groups show Show group details and members 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 from WireGuard server block Block all peers in group unblock Unblock all peers in group rule assign Assign a rule to all peers in group audit Audit all peers in group logs Show logs for all peers in group watch Live monitor for all peers in group Options: --name Group name --desc Group description --peer Peer name --type Peer device type (for peer resolution) --rule Rule name (for rule assign) --new-name New name (for rename) --force Skip confirmation prompts Examples: wgctl group add --name family --desc "Family devices" wgctl group peer add --name family --peer phone-nuno wgctl group block --name family wgctl group rule assign --name family --rule user wgctl group audit --name family EOF } # ============================================ # Run # ============================================ function cmd::group::run() { local subcmd="${1:-help}" shift || true 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 "$@" ;; block) cmd::group::block "$@" ;; unblock) cmd::group::unblock "$@" ;; rule) cmd::group::rule "$@" ;; 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 log::section "Groups" printf "\n %-20s %-35s %-8s %s\n" "NAME" "DESCRIPTION" "PEERS" "STATUS" printf " %s\n" "$(printf '─%.0s' {1..75})" while IFS="|" read -r name desc total blocked; do [[ -z "$name" ]] && continue local status_color="" status_str="active" if [[ "$total" -gt 0 ]]; then if [[ "$blocked" -eq "$total" ]]; then status_color="\033[1;31m" status_str="blocked" elif [[ "$blocked" -gt 0 ]]; then status_color="\033[1;33m" status_str="blocked (${blocked}/${total})" else status_color="\033[1;32m" status_str="active" fi fi local short_desc="${desc:0:33}" [[ ${#desc} -gt 33 ]] && short_desc="${short_desc}..." local desc_col_width=35 [[ "$desc" == "—" || -z "$desc" ]] && desc_col_width=37 printf " %-20s %-${desc_col_width}s %-8s %b\n" \ "$name" "${short_desc:-—}" "$total" \ "${status_color}${status_str}\033[0m" done < <(json::group_list_data "$groups_dir" "$(ctx::blocks)") printf "\n" } # 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 # log::section "Groups" # printf "\n %-20s %-35s %-8s %s\n" "NAME" "DESCRIPTION" "PEERS" "STATUS" # printf " %s\n" "$(printf '─%.0s' {1..75})" # for group_file in "${groups_dir}"/*.group; do # [[ -f "$group_file" ]] || continue # local name desc # name=$(json::get "$group_file" "name") # desc=$(json::get "$group_file" "desc") # # Count peers # local peers_list=() # mapfile -t peers_list < <(json::get "$group_file" "peers") # # Filter empty entries # 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 # # Check block status # local blocked=0 total=0 # for peer_name in "${peers_list[@]}"; do # [[ -z "$peer_name" ]] && continue # (( total++ )) || true # peers::is_blocked "$peer_name" && (( blocked++ )) || true # done # local status_color="" # local status_str="active" # if [[ "$total" -gt 0 ]]; then # if [[ "$blocked" -eq "$total" ]]; then # status_color="\033[1;31m" # status_str="blocked" # elif [[ "$blocked" -gt 0 ]]; then # status_color="\033[1;33m" # status_str="blocked (${blocked}/${total})" # # status_color="\033[1;33m" # # status_str="${blocked}/${total} blocked" # else # status_color="\033[1;32m" # status_str="active" # fi # fi # local short_desc="${desc:0:33}" # [[ ${#desc} -gt 33 ]] && short_desc="${short_desc}..." # printf " %-20s %-35s %-8s %b\n" \ # "$name" \ # "${short_desc:-—}" \ # "$peer_count" \ # "${status_color}${status_str}\033[0m" # done # printf "\n" # } # ============================================ # 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}" local desc desc=$(json::get "$group_file" "desc") printf "\n %-20s %s\n" "Description:" "${desc:-—}" # Load peers local peers_list=() mapfile -t peers_list < <(json::get "$group_file" "peers") # Filter empty entries 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 printf " %-20s %s\n" "Peers:" "$peer_count" printf " %s\n" "$(printf '─%.0s' {1..50})" if [[ "$peer_count" -gt 0 ]]; then printf "\n %-28s %-15s %-12s %s\n" "NAME" "IP" "RULE" "STATUS" printf " %s\n" "$(printf '─%.0s' {1..65})" 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 local ip rule status_str status_color ip=$(peers::get_ip "$peer_name") rule=$(peers::get_meta "$peer_name" "rule") rule="${rule:-—}" if peers::is_blocked "$peer_name" 2>/dev/null; then status_color="\033[1;31m" status_str="blocked" else status_color="\033[1;32m" status_str="active" fi printf " %-28s %-15s %-12s %b\n" \ "$peer_name" "0" "$rule" \ "${status_str}\033[0m" done else printf " —\n" fi printf "\n" return 0 } # ============================================ # 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="" 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 # 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}'" } 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}'" } # ============================================ # 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 } # 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 # local count=0 # 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 # cmd::remove::run --name "$peer_name" --force # (( count++ )) || true # done # log::wg_success "Removed ${count} peers from WireGuard (group '${name}' definition kept)" # } # ============================================ # 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 # log::section "Blocking group: ${name}" local count=0 blocked_names=() 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 if peers::is_blocked "$peer_name"; then log::wg_warning "${peer_name} — already blocked" continue fi ( core::set_quiet; load_command block; cmd::block::run --name "$peer_name" --force ) blocked_names+=("$peer_name") (( count++ )) || true done [[ "$count" -eq 0 ]] && return 0 # if [[ "$count" -gt 0 ]]; then # printf "\n" # for n in "${blocked_names[@]}"; do # log::wg " Blocked: ${n}" # done # fi # log::wg_block "Blocked ${count} peers in group '${name}'" log::wg_block "All peers from ${name} have been blocked (${count} peers)." } 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") [[ -z "${peers_list[0]}" ]] && log::wg_warning "Group '${name}' has no peers" && return 0 # log::section "Unblocking group: ${name}" local count=0 unblocked_names=() 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 if ! peers::is_blocked "$peer_name"; then log::wg_warning "${peer_name} — not blocked" continue fi ( core::set_quiet; load_command unblock; cmd::unblock::run --name "$peer_name" --force ) unblocked_names+=("$peer_name") (( count++ )) || true done [[ "$count" -eq 0 ]] && return 0 # if [[ "$count" -gt 0 ]]; then # printf "\n" # for n in "${blocked_names[@]}"; do # log::wg " Unblocked: ${n}" # done # fi log::wg_unblock "All peers from ${name} have been unblocked (${count} peers)." } # ============================================ # 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 logs # Use follow mode — it shows all peers but user knows context # Future: add --peers flag to follow for multi-peer filter cmd::logs::follow "" "" "" false false "$peer_filter" }