refactor: group::each_peer helper, peer existence checks, group remove cleanup, watch multi-peer filter

This commit is contained in:
Nuno Duque Nunes 2026-05-13 00:06:34 +00:00
parent 51e3443357
commit a7fd62ce32
26 changed files with 505 additions and 593 deletions

View file

@ -9,8 +9,8 @@ function cmd::add::on_load() {
flag::register --type
flag::register --subtype
flag::register --rule
flag::register --group
flag::register --ip
flag::register --preset
flag::register --guest
flag::register --tunnel
flag::register --show-config
@ -30,10 +30,12 @@ Add a new WireGuard client.
Options:
--name <name> Client name (e.g. nuno)
--type <type> Device type: desktop, laptop, phone, tablet, guest
--subtype <subtype> Guest subtype: desktop, laptop, phone, tablet (mostly used for guest)
--ip <ip> Override auto-assigned IP (optional)
--preset <name> Apply a firewall preset (repeatable)
--guest Shorthand for --type guest
--tunnel <mode> Tunnel mode: split (default) or full
--tunnel <mode> Tunnel mode: split|full (default: split)
--rule <rule> Assign rule on creation (default: user, guest types: guest)
--group <group> Add to group on creation (group must exist)
--show-config Shows the WireGuard peer config
--show-qr Shows the WireGuard config in a QR Code
@ -44,6 +46,10 @@ Device Types and Subnets:
tablet 10.1.4.x
guest 10.1.100.x
Rules:
Automatically assigned based on type (guest → guest rule, others → user rule).
Override with --rule. Manage rules with: wgctl rule help
Tunnel Modes:
split Route only VPN subnet + LAN through WireGuard (default)
full Route all traffic through WireGuard
@ -53,7 +59,9 @@ Examples:
wgctl add --name nuno --type laptop --ip 10.1.2.5
wgctl add --name nuno --type phone --tunnel full
wgctl add --name guest1 --type phone --guest
wgctl add --name restricted --type desktop --preset no-docker --preset no-proxmox
wgctl add --name restricted --type desktop
wgctl add --name dev --type laptop --rule dev-01
wgctl add --name visitor --type guest --show-qr
EOF
}
@ -118,10 +126,10 @@ function cmd::add::run() {
local type=""
local subtype=""
local rule=""
local group=""
local ip=""
local tunnel=""
local guest=false
local presets=()
local show_config=false
local show_qr=false
@ -132,8 +140,8 @@ function cmd::add::run() {
--type) type="$2"; shift 2 ;;
--subtype) subtype="$2"; shift 2 ;;
--rule) rule="$2"; shift 2 ;;
--group) group="$2"; shift 2 ;;
--ip) ip="$2"; shift 2 ;;
--preset) presets+=("$2"); shift 2 ;;
--guest) guest=true; shift ;;
--tunnel) tunnel="$2"; shift 2 ;;
--show-config) show_config=true; shift ;;
@ -176,14 +184,19 @@ function cmd::add::run() {
peers::create_client_config "$full_name" "$effective_type" "$ip" "$allowed_ips" || return 1
[[ -n "$subtype" ]] && peers::set_meta "$full_name" "subtype" "$subtype"
if [[ -n "$group" ]]; then
if ! group::exists "$group"; then
log::wg_warning "Group '${group}' not found — skipping group assignment"
else
group::add_peer "$group" "$full_name"
log::wg "Added to group: ${group}"
fi
fi
local public_key
public_key=$(keys::public "$full_name") || return 1
peers::add_to_server "$full_name" "$public_key" "$ip" || return 1
for preset in "${presets[@]}"; do
fw::apply_preset "$preset" "$ip" || return 1
done
[[ -n "$rule" ]] && rule::apply "$rule" "$ip" || return 1
peers::reload || return 1

View file

@ -28,16 +28,20 @@ Block rules are persisted and restored on WireGuard restart.
Options:
--name <name> Client name (e.g. phone-nuno)
--type <type> Device type (optional, combines with --name)
--ip <ip> Block access to specific IP (repeatable)
--subnet <cidr> Block access to subnet (repeatable)
--port <ip:port:proto> Block specific port, e.g. 10.0.0.210:9000:tcp (repeatable)
--force Skip confirmation prompt
--quiet Suppress output (used by group block)
Examples:
wgctl block --name phone-nuno
wgctl block --name nuno --type phone
wgctl block --name phone-nuno --ip 10.0.0.210
wgctl block --name phone-nuno --subnet 10.0.0.0/24
wgctl block --name phone-nuno --port 10.0.0.210:9000:tcp
wgctl ban --name phone-nuno --ip 10.0.0.100 --ip 10.0.0.200
wgctl ban --name phone-nuno
EOF
}

View file

@ -260,12 +260,19 @@ function cmd::group::show() {
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"; then
if peers::is_blocked "$peer_name" 2>/dev/null; then
status_color="\033[1;31m"
status_str="blocked"
else
@ -274,14 +281,15 @@ function cmd::group::show() {
fi
printf " %-28s %-15s %-12s %b\n" \
"$peer_name" "${ip:-}" "$rule" \
"${status_color}${status_str}\033[0m"
"$peer_name" "0" "$rule" \
"${status_str}\033[0m"
done
else
printf " —\n"
fi
printf "\n"
return 0
}
# ============================================
@ -307,15 +315,7 @@ function cmd::group::add() {
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
json::create_group "$(group::path "$name")" "$name" "$desc"
log::wg_success "Group created: ${name}"
}
@ -479,7 +479,7 @@ function cmd::group::rm_peers() {
local peers_list=()
mapfile -t peers_list < <(group::peers "$name")
local peer_count=${#peers_list[@]}
[[ -z "${peers_list[0]}" ]] && peer_count=0
[[ -z "${peers_list[0]:-}" ]] && peer_count=0
if [[ "$peer_count" -eq 0 ]]; then
log::wg_warning "Group '${name}' has no peers"
@ -494,16 +494,70 @@ function cmd::group::rm_peers() {
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)"
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
# ============================================
@ -536,6 +590,12 @@ function cmd::group::block() {
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
@ -582,6 +642,12 @@ function cmd::group::unblock() {
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
@ -639,16 +705,20 @@ function cmd::group::rule_assign() {
local peers_list=()
mapfile -t peers_list < <(group::peers "$name")
[[ -z "${peers_list[0]}" ]] && log::wg_warning "Group '${name}' has no peers" && return 0
[[ -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
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"
(( count++ )) || true
done
log::wg_success "Assigned rule '${rule}' to ${count} peers in group '${name}'"
}
# ============================================
@ -694,6 +764,11 @@ function cmd::group::audit() {
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
@ -727,6 +802,11 @@ function cmd::group::logs() {
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"
@ -755,15 +835,18 @@ function cmd::group::watch() {
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 peers: %s\n\n" "${peers_list[*]}"
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
cmd::logs::follow "" "" "" false false "$peer_filter"
}

View file

@ -110,7 +110,7 @@ function cmd::inspect::_rule_info() {
ui::print_list "-" "$block_ips"
ui::print_list "-" "$block_ports"
else
ui::row "Blocks" "— (full access)"
ui::row "Blocks" "—"
fi
}

View file

@ -6,6 +6,8 @@
function cmd::list::on_load() {
flag::register --type
flag::register --rule
flag::register --group
flag::register --online
flag::register --offline
flag::register --restricted
@ -26,18 +28,22 @@ Usage: wgctl list [options]
List all WireGuard clients.
Options:
--type <type> Filter by device type
--type <type> Filter by device type (desktop, laptop, phone, tablet, guest)
--rule <rule> Filter by assigned rule
--group <group> Filter by group membership
--online Show only connected clients
--offline Show only disconnected clients
--allowed Show only fully allowed clients
--restricted Show only restricted clients
--blocked Show only blocked clients
--restricted Show only restricted clients
--allowed Show only unrestricted clients
--detailed Show full detail cards for all clients
--name <name> Show detail card for a single client
Examples:
wgctl list
wgctl list --type phone
wgctl list --type guest
wgctl list --rule user
wgctl list --group family
wgctl list --online
wgctl list --blocked
wgctl list --detailed
@ -45,47 +51,6 @@ Examples:
EOF
}
# ============================================
# Precompute helpers
# ============================================
function cmd::list::_precompute_wg() {
# Returns two associative arrays via nameref
local -n _handshakes="$1"
local -n _endpoints="$2"
while IFS=$'\t' read -r pubkey ts; do
[[ -n "$pubkey" ]] && _handshakes["$pubkey"]="$ts"
done < <(wg show "$(config::interface)" latest-handshakes 2>/dev/null)
while IFS=$'\t' read -r pubkey endpoint; do
[[ -n "$pubkey" ]] && _endpoints["$pubkey"]="$endpoint"
done < <(wg show "$(config::interface)" endpoints 2>/dev/null)
}
function cmd::list::_precompute_block_status() {
local -n _blocked="$1"
local -n _restricted="$2"
# Blocked = not in wg server config
local wg_peers
wg_peers=$(wg show "$(config::interface)" peers 2>/dev/null)
while IFS= read -r name; do
# Check block file
[[ -f "$(ctx::block::path "${name}.block")" ]] && _restricted["$name"]=true || _restricted["$name"]=false
# Check if in server config
local pubkey
pubkey=$(keys::public "$name" 2>/dev/null || echo "")
if [[ -n "$pubkey" ]] && ! echo "$wg_peers" | grep -qF "$pubkey"; then
_blocked["$name"]=true
else
_blocked["$name"]=false
fi
done < <(peers::all)
}
# ============================================
# Status / Display helpers
# ============================================
@ -186,7 +151,7 @@ function peers::get_type() {
function cmd::list::display_type() {
local name="${1:-0}"
local type="${2:-0}"
local subtype="${3:-0}"
local subtype="${3:-}"
if config::is_guest_type "$type" && [[ -n "$subtype" ]]; then
echo "guest/${subtype}"
elif config::is_guest_type "$type"; then
@ -234,9 +199,6 @@ function cmd::list::_render_footer() {
function cmd::list::_render_summary() {
local group_summary="${1:-}"
local -n _rule_counts="$2"
local total="${3:-}" # filtered total
# total=$(find "$(ctx::clients)" -name "*.conf" | wc -l | tr -d ' ')
# Count total from rule_counts (only filtered peers)
local total=0
@ -273,7 +235,7 @@ function cmd::list::show_client() {
local ip allowed_ips public_key
ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1)
allowed_ips=$(grep "^AllowedIPs" "$conf" | awk '{print $3}')
allowed_ips=$(grep "^AllowedIPs" "$conf" | cut -d'=' -f2- | xargs)
public_key=$(keys::public "$name" 2>/dev/null || echo "unknown")
local type
@ -356,7 +318,7 @@ function cmd::list::is_attempting() { cmd::list::_is_attempting "$@"; }
# ============================================
function cmd::list::run() {
local filter_type=""
local filter_type="" filter_rule="" filter_group=""
local online_only=false offline_only=false
local restricted_only=false blocked_only=false allowed_only=false
local detailed=false single_name=""
@ -364,6 +326,8 @@ function cmd::list::run() {
while [[ $# -gt 0 ]]; do
case "$1" in
--type) filter_type="$2"; shift 2 ;;
--rule) filter_rule="$2"; shift 2 ;;
--group) filter_group="$2"; shift 2 ;;
--online) online_only=true; shift ;;
--offline) offline_only=true; shift ;;
--restricted) restricted_only=true; shift ;;
@ -383,92 +347,45 @@ function cmd::list::run() {
# Single detail card
if [[ -n "$single_name" ]]; then
cmd::list::show_client "$single_name"
return
return 0
fi
local dir
dir="$(ctx::clients)"
local confs=("${dir}"/*.conf)
if [[ ! -f "${confs[0]}" ]]; then
log::wg_list "No clients configured"
log::wg_warning "No clients configured"
return 0
fi
# ── Precompute everything ──────────────────
# Peer data (ip, rule, subtype, last_ts, last_evt) — single Python call
declare -A p_ips p_rules p_subtypes p_last_ts p_last_evt
while IFS="|" read -r name ip rule subtype last_ts last_evt; do
[[ -z "$name" ]] && continue
p_ips["$name"]="$ip"
p_rules["$name"]="${rule:-}"
p_subtypes["$name"]="$subtype"
p_last_ts["$name"]="$last_ts"
p_last_evt["$name"]="$last_evt"
done < <(json::peer_data "$(ctx::clients)" "$(ctx::meta)" "$(ctx::events_log)")
# WireGuard handshakes + endpoints — two wg show calls
declare -A wg_handshakes wg_endpoints
cmd::list::_precompute_wg wg_handshakes wg_endpoints
# Block/restricted status
declare -A p_blocked p_restricted
cmd::list::_precompute_block_status p_blocked p_restricted
# Public keys — read from key files
declare -A p_pubkeys
for kf in "${dir}"/*_public.key; do
[[ -f "$kf" ]] || continue
local kname
kname=$(basename "$kf" _public.key)
p_pubkeys["$kname"]=$(cat "$kf" 2>/dev/null || echo "")
done
# Group map
local has_groups=false
local groups_dir
groups_dir="$(ctx::groups)"
local group_files=("${groups_dir}"/*.group)
[[ -f "${group_files[0]}" ]] && has_groups=true
declare -A peer_group_map
if $has_groups; then
while IFS=":" read -r peer_name group_name; do
[[ -n "$peer_name" ]] && peer_group_map["$peer_name"]="$group_name"
done < <(json::peer_group_map "$groups_dir")
fi
cmd::list::_precompute_all
# ── Detailed mode ──────────────────────────
if $detailed; then
log::section "WireGuard Clients"
cmd::list::_iter_confs "$filter_type" cmd::list::show_client
return
cmd::list::_iter_confs "$filter_type" cmd::list::_show_client_safe
return 0
fi
# ── Table view ─────────────────────────────
# ── Build filter description ───────────────
local filter_desc=""
cmd::list::_build_filter_desc
log::section "WireGuard Clients"
cmd::list::_render_header $has_groups
declare -A rule_counts
declare -A group_counts
# ── Table view ─────────────────────────────
declare -A rule_counts=() group_counts=()
_list_header_printed=false
cmd::list::_iter_confs "$filter_type" cmd::list::_render_row
if [[ "$_list_header_printed" == "true" ]]; then
cmd::list::_render_footer $has_groups
# Build summaries
declare -A displayed_rules displayed_groups
local group_summary=""
if $has_groups; then
for g in "${!group_counts[@]}"; do
group_summary+="${group_counts[$g]} in ${g}, "
done
group_summary="${group_summary%, }"
cmd::list::_build_group_summary
cmd::list::_render_summary "$group_summary" rule_counts "$filter_desc"
else
log::wg_warning "No results found${filter_desc:+ for: ${filter_desc}}"
fi
cmd::list::_render_summary "$group_summary" rule_counts
}
function cmd::list::_iter_confs() {
@ -510,6 +427,11 @@ function cmd::list::_render_row() {
if $allowed_only && { [[ "$is_blocked" == "true" ]] || \
[[ "$is_restricted" == "true" ]]; }; then return 0; fi
if [[ -n "$filter_group" ]]; then
local peer_group="${peer_group_map[$client_name]:-}"
[[ "$peer_group" != "$filter_group" ]] && return 0
fi
# Format display values
local status last_seen display_type rule group_display
status=$(cmd::list::_format_status "$client_name" "$pubkey" \
@ -520,6 +442,15 @@ function cmd::list::_render_row() {
"${p_subtypes[$client_name]:-}")
rule="${p_rules[$client_name]:-}"
if [[ -n "$filter_rule" && "$rule" != "$filter_rule" ]]; then return 0; fi
# Print header on first match
if [[ "${_list_header_printed:-false}" == "false" ]]; then
log::section "WireGuard Clients"
cmd::list::_render_header $has_groups
_list_header_printed=true
fi
# Update rule counts for summary (outer scope array)
rule_counts["$rule"]=$(( ${rule_counts[$rule]:-0} + 1 )) || true
@ -549,3 +480,100 @@ function cmd::list::_render_row() {
"$padded_status" "$last_seen"
fi
}
# ============================================
# Private helpers
# ============================================
function cmd::list::_precompute_all() {
# Peer data
declare -gA p_ips=() p_rules=() p_subtypes=() p_last_ts=() p_last_evt=()
while IFS="|" read -r name ip rule subtype last_ts last_evt; do
[[ -z "$name" ]] && continue
p_ips["$name"]="$ip"
p_rules["$name"]="${rule:-}"
p_subtypes["$name"]="$subtype"
p_last_ts["$name"]="$last_ts"
p_last_evt["$name"]="$last_evt"
done < <(json::peer_data "$(ctx::clients)" "$(ctx::meta)" "$(ctx::events_log)")
# WireGuard handshakes + endpoints
declare -gA wg_handshakes=() wg_endpoints=()
while IFS=$'\t' read -r pubkey ts; do
[[ -n "$pubkey" ]] && wg_handshakes["$pubkey"]="$ts"
done < <(wg show "$(config::interface)" latest-handshakes 2>/dev/null)
while IFS=$'\t' read -r pubkey endpoint; do
[[ -n "$pubkey" ]] && wg_endpoints["$pubkey"]="$endpoint"
done < <(wg show "$(config::interface)" endpoints 2>/dev/null)
# Block/restricted status
declare -gA p_blocked=() p_restricted=()
local wg_peers
wg_peers=$(wg show "$(config::interface)" peers 2>/dev/null)
while IFS= read -r name; do
[[ -f "$(ctx::block::path "${name}.block")" ]] \
&& p_restricted["$name"]=true || p_restricted["$name"]=false
local pubkey
pubkey=$(keys::public "$name" 2>/dev/null || echo "")
if [[ -n "$pubkey" ]] && ! echo "$wg_peers" | grep -qF "$pubkey"; then
p_blocked["$name"]=true
else
p_blocked["$name"]=false
fi
done < <(peers::all)
# Public keys
declare -gA p_pubkeys=()
local dir
dir="$(ctx::clients)"
for kf in "${dir}"/*_public.key; do
[[ -f "$kf" ]] || continue
local kname
kname=$(basename "$kf" _public.key)
p_pubkeys["$kname"]=$(cat "$kf" 2>/dev/null || echo "")
done
# Groups
has_groups=false
declare -gA peer_group_map=()
local groups_dir
groups_dir="$(ctx::groups)"
local group_files=("${groups_dir}"/*.group)
if [[ -f "${group_files[0]}" ]]; then
has_groups=true
while IFS=":" read -r peer_name group_name; do
[[ -n "$peer_name" ]] && peer_group_map["$peer_name"]="$group_name"
done < <(json::peer_group_map "$groups_dir")
fi
}
function cmd::list::_build_filter_desc() {
filter_desc=""
[[ -n "$filter_type" ]] && filter_desc+="type=${filter_type} "
[[ -n "$filter_rule" ]] && filter_desc+="rule=${filter_rule} "
[[ -n "$filter_group" ]] && filter_desc+="group=${filter_group} "
$online_only && filter_desc+="online "
$offline_only && filter_desc+="offline "
$blocked_only && filter_desc+="blocked "
filter_desc="${filter_desc% }"
}
function cmd::list::_build_group_summary() {
group_summary=""
if $has_groups; then
declare -A _gs=()
for peer in "${!peer_group_map[@]}"; do
local g="${peer_group_map[$peer]}"
_gs["$g"]=$(( ${_gs["$g"]:-0} + 1 )) || true
done
for g in "${!_gs[@]}"; do
group_summary+="${_gs[$g]} in ${g}, "
done
group_summary="${group_summary%, }"
fi
}
function cmd::list::_show_client_safe() {
local name="$1"
cmd::list::show_client "$name" || true
}

View file

@ -101,8 +101,10 @@ function cmd::logs::show() {
}
function cmd::logs::follow() {
local filter_ip="$1" filter_name="$2" filter_type="$3"
local fw_only="$4" wg_only="$5"
local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}"
local fw_only="${4:-false}" wg_only="${5:-false}"
local filter_peers="${6:-}"
local clients_dir
clients_dir="$(ctx::clients)"
@ -137,7 +139,7 @@ function cmd::logs::follow() {
printf " %-20s %-8s %-20s %-25s %b\n" \
"$ts" "wireguard" "$client" "$dst_or_endpoint" "$colored_event"
fi
done < <(json::follow_logs "$fw_log" "$wg_log" "$filter_ip" "$filter_type" "$clients_dir")
done < <(json::follow_logs "$fw_log" "$wg_log" "$filter_ip" "$filter_type" "$clients_dir" "$filter_peers")
}
function cmd::logs::remove() {

View file

@ -1,199 +0,0 @@
#!/usr/bin/env bash
# ============================================
# Help
# ============================================
function cmd::preset::help() {
cat <<EOF
Usage: wgctl preset <subcommand> [options]
Manage firewall presets.
Subcommands:
list, ls List available presets
add, new, create Add a new preset
remove, rm, del Remove a preset
Options for add:
--name <name> Preset name (e.g. no-jellyfin)
--desc <description> Human readable description
--block-ip <ip> Block specific IP (repeatable)
--block-subnet <cidr> Block subnet (repeatable)
--block-port <ip:port:proto> Block specific port (repeatable)
Examples:
wgctl preset list
wgctl preset add --name no-jellyfin --desc "Block Jellyfin" --block-ip 10.0.0.210 --block-port 10.0.0.210:8096:tcp
wgctl preset remove --name no-jellyfin
EOF
}
# ============================================
# Run
# ============================================
function cmd::preset::run() {
local subcmd="${1:-help}"
shift || true
case "$subcmd" in
list|ls) cmd::preset::list "$@" ;;
add|new|create) cmd::preset::add "$@" ;;
remove|rm|del|delete) cmd::preset::remove "$@" ;;
help) cmd::preset::help ;;
*)
log::error "Unknown subcommand: '${subcmd}'"
cmd::preset::help
return 1
;;
esac
}
# ============================================
# List
# ============================================
function cmd::preset::list() {
local dir
dir="$(ctx::presets)"
local presets=("${dir}"/*.preset)
if [[ ! -f "${presets[0]}" ]]; then
log::wg_preset "No presets configured"
return 0
fi
log::section "Available Presets"
printf "\n %-25s %-40s %s\n" "NAME" "DESCRIPTION" "RULES"
printf " %s\n" "$(printf '─%.0s' {1..75})"
for preset_file in "${dir}"/*.preset; do
[[ -f "$preset_file" ]] || continue
# Reset vars before sourcing
local PRESET_NAME="" PRESET_DESC=""
local BLOCK_IPS="" BLOCK_SUBNETS="" BLOCK_PORTS=""
source "$preset_file"
local rules=""
[[ -n "$BLOCK_IPS" ]] && rules+="IPs:$(echo "$BLOCK_IPS" | wc -w) "
[[ -n "$BLOCK_SUBNETS" ]] && rules+="Subnets:$(echo "$BLOCK_SUBNETS" | wc -w) "
[[ -n "$BLOCK_PORTS" ]] && rules+="Ports:$(echo "$BLOCK_PORTS" | wc -w)"
printf " %-25s %-40s %s\n" \
"$PRESET_NAME" \
"${PRESET_DESC:-}" \
"${rules:-}"
done
printf "\n"
}
# ============================================
# Add
# ============================================
function cmd::preset::add() {
local name=""
local desc=""
local block_ips=()
local block_subnets=()
local block_ports=()
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--desc) desc="$2"; shift 2 ;;
--block-ip) block_ips+=("$2"); shift 2 ;;
--block-subnet) block_subnets+=("$2"); shift 2 ;;
--block-port) block_ports+=("$2"); shift 2 ;;
--help) cmd::preset::help; return ;;
*)
log::error "Unknown flag: $1"
cmd::preset::help
return 1
;;
esac
done
if [[ -z "$name" ]]; then
log::error "Missing required flag: --name"
return 1
fi
if [[ ${#block_ips[@]} -eq 0 && ${#block_subnets[@]} -eq 0 && ${#block_ports[@]} -eq 0 ]]; then
log::error "At least one of --block-ip, --block-subnet, or --block-port is required"
return 1
fi
local preset_file
preset_file="$(ctx::preset::path "${name}.preset")"
if [[ -f "$preset_file" ]]; then
log::error "Preset already exists: ${name}"
return 1
fi
cat > "$preset_file" <<EOF
# wgctl preset — ${name}
PRESET_NAME="${name}"
PRESET_DESC="${desc}"
BLOCK_IPS="${block_ips[*]:-}"
BLOCK_SUBNETS="${block_subnets[*]:-}"
BLOCK_PORTS="${block_ports[*]:-}"
EOF
log::wg_success "Preset created: ${name}"
}
# ============================================
# Remove
# ============================================
function cmd::preset::remove() {
local name=""
local force=false
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--force) force=true; shift ;;
--help) cmd::preset::help; return ;;
*)
log::error "Unknown flag: $1"
cmd::preset::help
return 1
;;
esac
done
if [[ -z "$name" ]]; then
log::error "Missing required flag: --name"
return 1
fi
local preset_file
preset_file="$(ctx::preset::path "${name}.preset")"
if [[ ! -f "$preset_file" ]]; then
log::error "Preset not found: ${name}"
return 1
fi
if ! $force; then
read -r -p "Are you sure you want to remove preset '${name}'? [y/N] " confirm
case "$confirm" in
[yY][eE][sS]|[yY]) ;;
*)
log::info "Aborted"
return 0
;;
esac
fi
rm -f "$preset_file"
log::wg_success "Preset removed: ${name}"
}

View file

@ -93,6 +93,7 @@ function cmd::remove::_cleanup() {
peers::remove_from_server "$name" || return 1
peers::remove_client_config "$name" || return 1
keys::remove "$name" || return 1
group::remove_peer_from_all "$name" || return 1
[[ -n "$client_ip" ]] && $was_blocked && fw::unblock_all "$client_ip"
fw::remove_block_file "$name" 2>/dev/null || true

View file

@ -19,7 +19,7 @@ function cmd::shell::_is_wgctl_command() {
local known=(
list add remove rm inspect block unblock
rule group audit logs watch fw config qr
rename keys ip preset service shell help
rename keys ip service shell help
)
local c
for c in "${known[@]}"; do

View file

@ -138,13 +138,12 @@ function cmd::test::run_function() {
function cmd::test::section_list() {
test::section "List"
cmd::test::run_cmd "list" "WireGuard Clients" list
cmd::test::run_cmd "list --online" "STATUS" list --online
cmd::test::run_cmd "list --offline" "STATUS" list --offline
cmd::test::run_cmd "list --blocked" "STATUS" list --blocked
cmd::test::run_cmd "list --type phone" "phone" list --type phone
cmd::test::run_cmd "list --type guest" "guest" list --type guest
# TODO: Fix detailed, hangs
# cmd::test::run_cmd "list --detailed" "Client:" list --detailed
cmd::test::run_cmd "list --online" "" list --online
cmd::test::run_cmd "list --offline" "" list --offline
cmd::test::run_cmd "list --blocked" "" list --blocked
cmd::test::run_cmd "list --type phone" "" list --type phone
cmd::test::run_cmd "list --type guest" "" list --type guest
cmd::test::run_cmd "list --detailed" "Client:" list --detailed
cmd::test::run_cmd "list --name phone-nuno" "phone-nuno" list --name phone-nuno
}

View file

@ -28,15 +28,19 @@ Remove block rules for a client.
Options:
--name <name> Client name (e.g. phone-nuno)
--type <type> Device type (optional, combines with --name)
--ip <ip> Unblock specific IP (repeatable)
--subnet <cidr> Unblock specific subnet (repeatable)
--port <ip:port:proto> Unblock specific port (repeatable)
--all Remove all block rules for this client
--force Skip confirmation prompt
--quiet Suppress output (used by group unblock)
Examples:
wgctl unblock --name phone-nuno --all
wgctl unblock --name phone-nuno
wgctl unblock --name nuno --type phone
wgctl unblock --name phone-nuno --ip 10.0.0.210
wgctl unban --name phone-nuno --all
wgctl unban --name phone-nuno
EOF
}
@ -127,7 +131,6 @@ function cmd::unblock::_unblock_all() {
local client_ip="${2:-}"
local quiet="${3:-false}"
log::debug "_unblock_all: name=$name ip=$client_ip"
fw::unblock_all "$client_ip"
fw::remove_block_file "$name"
monitor::unwatch_client "$name"
@ -135,7 +138,6 @@ function cmd::unblock::_unblock_all() {
if ! peers::exists_in_server "$name"; then
local public_key
public_key=$(keys::public "$name") || return 1
log::debug "_unblock_all: adding to server pub=$public_key"
peers::add_to_server "$name" "$public_key" "$client_ip"
peers::reload
fi

View file

@ -161,17 +161,13 @@ function cmd::watch::tail_events() {
[[ -z "$line" ]] && continue
local event_data
event_data=$(python3 -c "
import json, sys
try:
e = json.loads('${line//\'/\'\\\'\'}')
print(e.get('timestamp',''), e.get('client',''), e.get('endpoint',''), e.get('event',''))
except:
pass
" 2>/dev/null)
event_data=$(json::parse_event "$line")
[[ -z "$event_data" ]] && continue
local ts client endpoint event
IFS="|" read -r ts client endpoint event <<< "$event_data"
if $restricted_only; then
local conf
conf="$(ctx::clients)/${client}.conf"
@ -179,9 +175,6 @@ except:
cmd::list::is_restricted "$client" || continue
fi
local ts client endpoint event
read -r ts client endpoint event <<< "$event_data"
# Apply filters
[[ -n "$filter_name" && "$client" != "$filter_name" ]] && continue

View file

@ -9,7 +9,6 @@ _CTX_WG="/etc/wireguard"
_CTX_CORE="${_CTX_ROOT}/core"
_CTX_MODULES="${_CTX_ROOT}/modules"
_CTX_COMMANDS="${_CTX_ROOT}/commands"
_CTX_PRESETS="${_CTX_ROOT}/presets"
_CTX_CLIENTS="${_CTX_WG}/clients"
_CTX_DATA="${_CTX_WG}/.wgctl"
@ -29,7 +28,6 @@ function ctx::root() { echo "$_CTX_ROOT"; }
function ctx::core() { echo "$_CTX_CORE"; }
function ctx::modules() { echo "$_CTX_MODULES"; }
function ctx::commands() { echo "$_CTX_COMMANDS"; }
function ctx::presets() { echo "$_CTX_PRESETS"; }
function ctx::blocks() { echo "$_CTX_BLOCKS"; }
function ctx::groups() { echo "$_CTX_GROUPS"; }
function ctx::rules() { echo "$_CTX_RULES"; }
@ -54,11 +52,6 @@ function ctx::client::path() {
echo "$_CTX_CLIENTS/$*"
}
function ctx::preset::path() {
local IFS="/"
echo "$_CTX_PRESETS/$*"
}
function ctx::meta::path() {
local IFS="/"
echo "$_CTX_META/$*"

View file

@ -28,3 +28,7 @@ function json::rule_list_data() { python3 "$JSON_HELPER" rule_list_data "$@"
function json::group_list_data() { python3 "$JSON_HELPER" group_list_data "$@" </dev/null; }
function json::fmt_datetime() { python3 "$JSON_HELPER" fmt_datetime "$@" </dev/null; }
function json::create_rule() { python3 "$JSON_HELPER" create_rule "$@" </dev/null; }
function json::cleanup_config() { python3 "$JSON_HELPER" cleanup_config "$@" </dev/null; }
function json::remove_peer_block() { python3 "$JSON_HELPER" remove_peer_block "$@" </dev/null; }
function json::create_group() { python3 "$JSON_HELPER" create_group "$@" </dev/null; }
function json::parse_event() { python3 "$JSON_HELPER" parse_event "$@" </dev/null; }

View file

@ -349,11 +349,12 @@ def remove_events(file, identifier):
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def follow_logs(fw_file, wg_file, filter_ip, filter_type, clients_dir):
def follow_logs(fw_file, wg_file, filter_ip, filter_type, clients_dir, filter_peers=""):
"""Follow both log files and output formatted events"""
import glob, time, select
proto_map = {1: 'icmp', 6: 'tcp', 17: 'udp'}
peer_filter = set(filter_peers.split(',')) if filter_peers else set()
# Build ip->name map
ip_to_name = {}
@ -424,6 +425,9 @@ def follow_logs(fw_file, wg_file, filter_ip, filter_type, clients_dir):
ip = ip_to_name.get(filter_ip, '')
if client != ip and client != filter_ip:
continue
if peer_filter and client not in peer_filter:
continue
if filter_type and not client.startswith(filter_type + '-'):
continue
ts = e.get('timestamp', '')[:16].replace('T', ' ')
@ -638,6 +642,52 @@ def create_rule(file, name, desc, dns_redirect, allow_ips, block_ips, block_port
with open(file, 'w') as f:
json.dump(rule, f, indent=2)
def cleanup_config(config_file):
"""Normalize blank lines in WireGuard config"""
import re
try:
with open(config_file) as f:
config = f.read()
config = re.sub(r'\n{3,}', '\n\n', config)
config = config.rstrip('\n') + '\n'
with open(config_file, 'w') as f:
f.write(config)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def remove_peer_block(config_file, name):
"""Remove a peer block from WireGuard config by name"""
import re
try:
with open(config_file) as f:
config = f.read()
pattern = r'\n\[Peer\]\n# ' + re.escape(name) + r'\n[^\n]+\n[^\n]+\n'
result = re.sub(pattern, '\n', config)
with open(config_file, 'w') as f:
f.write(result)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def create_group(file, name, desc):
"""Create a new group JSON file"""
try:
group = {'name': name, 'desc': desc, 'peers': []}
with open(file, 'w') as f:
json.dump(group, f, indent=2)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def parse_event(line):
"""Parse a single JSON event line"""
try:
e = json.loads(line)
print(f"{e.get('timestamp','')}|{e.get('client','')}|{e.get('endpoint','')}|{e.get('event','')}")
except:
pass
commands = {
'get': lambda args: get(args[0], args[1]),
'set': lambda args: set_key(args[0], args[1], args[2]),
@ -654,7 +704,7 @@ commands = {
'format_fw_event': lambda args: format_fw_event(sys.stdin.read(), args[0]),
'format_wg_event': lambda args: format_wg_event(sys.stdin.read()),
'remove_events': lambda args: remove_events(args[0], args[1]),
'follow_logs': lambda args: follow_logs(args[0], args[1], args[2], args[3], args[4]),
'follow_logs': lambda args: follow_logs(args[0], args[1], args[2], args[3], args[4], args[5]),
'count': lambda args: count(args[0], args[1]),
'audit_fw_counts': lambda args: audit_fw_counts(args[0]),
'peer_group_map': lambda args: peer_group_map(args[0]),
@ -665,7 +715,10 @@ commands = {
'group_list_data': lambda args: group_list_data(args[0], args[1]),
'fmt_datetime': lambda args: fmt_datetime(args[0], args[1]),
'create_rule': lambda args: create_rule(args[0], args[1], args[2], args[3], args[4], args[5], args[6]),
'cleanup_config': lambda args: cleanup_config(args[0]),
'remove_peer_block': lambda args: remove_peer_block(args[0], args[1]),
'create_group': lambda args: create_group(args[0], args[1], args[2]),
'parse_event': lambda args: parse_event(args[0]),
}
if __name__ == '__main__':

View file

@ -1,6 +1,6 @@
{
"phone-fred": "94.63.0.129",
"phone-helena": "148.69.37.26",
"phone-helena": "148.69.44.173",
"phone-nuno": "94.63.0.129",
"tablet-nuno": "148.69.202.5",
"guest-zephyr": "5.13.82.5",

View file

@ -53,7 +53,6 @@ function config::load() {
WG_LAN) _WG_LAN="$value" ;;
# Add debug temporarily to config::load:
DATE_FORMAT)
log::debug "config: setting date format to $value"
_FMT_DATE_FORMAT="$value"
fmt::set_date_format "$value"
;;

View file

@ -88,7 +88,7 @@ function fw::unallow_port() {
}
function fw::flush_peer() {
local client_ip="$1"
local client_ip="${1:?client_ip required}"
log::debug "flush_peer: starting for $client_ip"
# Collect line numbers into array
@ -205,47 +205,3 @@ function fw::restore_blocks() {
log::debug "Restored block rules for: ${name}"
done
}
# ============================================
# Preset Application
# ============================================
function fw::apply_preset() {
local name="$1"
local client_ip="$2"
local preset_file
preset_file="$(ctx::preset::path "${name}.preset")"
if [[ ! -f "$preset_file" ]]; then
log::error "Preset not found: ${name}"
return 1
fi
source "$preset_file"
if [[ -n "${BLOCK_IPS:-}" ]]; then
for ip in $BLOCK_IPS; do
fw::block_ip "$client_ip" "$ip"
fw::save_block "$client_ip" "$client_ip" "$ip"
done
fi
if [[ -n "${BLOCK_SUBNETS:-}" ]]; then
for subnet in $BLOCK_SUBNETS; do
fw::block_subnet "$client_ip" "$subnet"
fw::save_block "$client_ip" "$client_ip" "$subnet"
done
fi
if [[ -n "${BLOCK_PORTS:-}" ]]; then
for entry in $BLOCK_PORTS; do
local target port proto
IFS=":" read -r target port proto <<< "$entry"
proto="${proto:-tcp}"
fw::block_port "$client_ip" "$target" "$port" "$proto"
fw::save_block "$name" "$client_ip" "$target" "$port" "$proto"
done
fi
log::debug "Applied preset '${name}' to: ${client_ip}"
}

View file

@ -51,3 +51,36 @@ function group::peer_groups() {
fi
done < <(group::all)
}
function group::remove_peer_from_all() {
local peer_name="${1:?peer_name required}"
while IFS= read -r group_name; do
group::remove_peer "$group_name" "$peer_name"
done < <(json::peer_groups "$(ctx::groups)" "$peer_name")
}
function group::each_peer() {
local name="${1:?name required}"
local callback="${2:?callback required}"
shift 2
# $@ = extra args passed to callback
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 ]] && return 0
for peer_name in "${filtered[@]}"; do
"$callback" "$peer_name" "$@"
done
}
function group::_peer_exists_check() {
local peer_name="${1:-}"
peers::require_exists "$peer_name" > /dev/null 2>&1
}

View file

@ -60,27 +60,14 @@ function peers::remove_client_config() {
# ============================================
function peers::cleanup_config() {
local config
config=$(config::config_file)
python3 -c "
import re
config = open('${config}').read()
# Normalize multiple blank lines to single blank line
config = re.sub(r'\n{3,}', '\n\n', config)
# Ensure file ends with single newline
config = config.rstrip('\n') + '\n'
open('${config}', 'w').write(config)
"
json::cleanup_config "$(config::config_file)"
}
function peers::add_to_server() {
local name="$1"
local public_key="$2"
local ip="$3"
local name="${1:?name required}"
local public_key="${2:?public_key required}"
local ip="${3:?ip required}"
local config
config=$(config::config_file)
@ -97,21 +84,12 @@ EOF
}
function peers::remove_block() {
local name="$1"
local config
config=$(config::config_file)
python3 -c "
import re
config = open('${config}').read()
pattern = r'\n\[Peer\]\n# ${name}\n[^\n]+\n[^\n]+\n'
result = re.sub(pattern, '\n', config)
open('${config}', 'w').write(result)
"
local name="${1:?name required}"
json::remove_peer_block "$(config::config_file)" "$name"
}
function peers::remove_from_server() {
local name="$1"
local name="${1:?name required}"
peers::remove_block "$name"
peers::cleanup_config
log::debug "Removed peer from server config: ${name}"
@ -183,8 +161,8 @@ function peers::exists_in_server() {
}
function peers::is_blocked() {
local name="$1"
! peers::exists_in_server "$name"
local name="${1:-}"
peers::exists_in_server "$name" && return 1 || return 0
}
# ============================================
@ -248,7 +226,7 @@ function peers::with_rule() {
function peers::get_ip() {
local name="$1"
grep "^Address" "$(ctx::clients)/${name}.conf" 2>/dev/null \
| awk '{print $3}' | cut -d'/' -f1
| awk '{print $3}' | cut -d'/' -f1 || true
}
function peers::find_by_ip() {

View file

@ -72,13 +72,11 @@ function rule::is_applied() {
# ============================================
function rule::apply() {
local rule_name="$1"
local client_ip="$2"
local rule_name="${1:?rule_name required}"
local client_ip="${2:?client_ip required}"
local peer_name="${3:-}" # optional, avoids find_by_ip call
log::debug "rule::apply ENTRY: rule=$rule_name ip=$client_ip peer=$peer_name"
rule::require_exists "$rule_name" || return 1
log::debug "rule::apply: exists check passed"
# Use provided peer_name or look it up
if [[ -z "$peer_name" ]]; then
@ -100,7 +98,6 @@ function rule::apply() {
# Check if already applied
local peer_name
peer_name=$(peers::find_by_ip "$client_ip")
log::debug "rule::apply: find_by_ip($client_ip) = '$peer_name'"
if [[ -n "$peer_name" ]]; then
# Check if already applied via iptables
if rule::is_applied "$rule_name" "$client_ip"; then
@ -140,10 +137,8 @@ function rule::apply() {
done < <(rule::get "$rule_name" "allow_ports")
# Persist rule assignment in meta
log::debug "rule::apply: peer_name=$peer_name ip=$client_ip"
if [[ -n "$peer_name" ]]; then
peers::set_meta "$peer_name" "rule" "$rule_name"
log::debug "rule::apply: set meta rule=$rule_name for $peer_name"
fi
local dns_redirect

View file

@ -1,5 +0,0 @@
PRESET_NAME="guest"
PRESET_DESC="Internet only, no LAN access"
BLOCK_IPS=""
BLOCK_SUBNETS="10.0.0.0/24"
BLOCK_PORTS=""

View file

@ -1,5 +0,0 @@
PRESET_NAME="no-docker"
PRESET_DESC="Block access to Docker host"
BLOCK_IPS="10.0.0.210"
BLOCK_SUBNETS=""
BLOCK_PORTS=""

View file

@ -1,5 +0,0 @@
PRESET_NAME="no-internet"
PRESET_DESC="LAN access only, no internet"
BLOCK_IPS=""
BLOCK_SUBNETS="0.0.0.0/0"
BLOCK_PORTS=""

View file

@ -1,5 +0,0 @@
PRESET_NAME="no-proxmox"
PRESET_DESC="Block access to Proxmox"
BLOCK_IPS="10.0.0.100"
BLOCK_SUBNETS=""
BLOCK_PORTS=""

5
wgctl
View file

@ -120,11 +120,6 @@ Service Commands:
enable Enable WireGuard on boot
disable Disable WireGuard on boot
Preset Commands:
preset list List available presets
preset add Add a new preset
preset remove Remove a preset
Run 'wgctl <command> --help' for command-specific help.
EOF
}