wgctl/commands/group.command.sh

761 lines
No EOL
22 KiB
Bash

#!/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 <<EOF
Usage: wgctl group <subcommand> [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 <name> Group name
--desc <description> Group description
--peer <peer> Peer name
--type <type> Peer device type (for peer resolution)
--rule <rule> Rule name (for rule assign)
--new-name <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}..."
printf " %-20s %-35s %-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
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"; 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" "${ip:-}" "$rule" \
"${status_color}${status_str}\033[0m"
done
else
printf " —\n"
fi
printf "\n"
}
# ============================================
# 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
local group_file
group_file="$(group::path "$name")"
python3 -c "
import json
group = {'name': '${name}', 'desc': '${desc}', 'peers': []}
with open('${group_file}', 'w') as f:
json.dump(group, f, indent=2)
" </dev/null
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
local count=0
for peer_name in "${peers_list[@]}"; do
[[ -z "$peer_name" ]] && continue
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")
[[ -z "${peers_list[0]}" ]] && log::wg_warning "Group '${name}' has no peers" && return 0
# log::section "Blocking group: ${name}"
local count=0 blocked_names=()
for peer_name in "${peers_list[@]}"; do
[[ -z "$peer_name" ]] && continue
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
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
local count=0
for peer_name in "${peers_list[@]}"; do
[[ -z "$peer_name" ]] && continue
cmd::rule::assign --name "$rule" --peer "$peer_name"
(( count++ )) || true
done
log::wg_success "Assigned rule '${rule}' to ${count} peers in group '${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
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
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
# 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 peers: %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
}