wgctl/commands/group.command.sh

829 lines
25 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 --main
flag::register --force
}
# ============================================
# Help
# ============================================
function cmd::group::help() {
cat <<EOF
Usage: wgctl group <subcommand> [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
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 <name> Group name
--desc <description> Group description (for add)
--peer <peer> Peer name
--type <type> Peer device type (optional)
--rule <rule> Rule name (for rule assign)
--new-name <name> New group name (for rename)
--limit <n> Max log entries per peer (for logs)
--force Skip confirmation prompts
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 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
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 "$@" ;;
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)")
[[ -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 ""
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:-}"
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
local peer_word="peers"
[[ "$peer_count" -eq 1 ]] && peer_word="peer"
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 name and IP widths
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 ))
for peer_name in "${peers_list[@]}"; do
[[ -z "$peer_name" ]] && continue
if ! peers::require_exists "$peer_name" > /dev/null 2>&1; then
printf " \033[2m%-${w_name}s (no longer exists)\033[0m\n" "$peer_name"
continue
fi
local ip rule is_blocked
ip=$(peers::get_ip "$peer_name")
rule=$(peers::get_meta "$peer_name" "rule")
peers::is_blocked "$peer_name" 2>/dev/null && is_blocked="true" || is_blocked="false"
ui::group::show_member_row "$peer_name" "$ip" "${rule:--}" \
"$is_blocked" "$w_name" "$w_ip"
done
else
printf " \033[2m—\033[0m\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
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"
}