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 --type
flag::register --subtype flag::register --subtype
flag::register --rule flag::register --rule
flag::register --group
flag::register --ip flag::register --ip
flag::register --preset
flag::register --guest flag::register --guest
flag::register --tunnel flag::register --tunnel
flag::register --show-config flag::register --show-config
@ -30,10 +30,12 @@ Add a new WireGuard client.
Options: Options:
--name <name> Client name (e.g. nuno) --name <name> Client name (e.g. nuno)
--type <type> Device type: desktop, laptop, phone, tablet, guest --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) --ip <ip> Override auto-assigned IP (optional)
--preset <name> Apply a firewall preset (repeatable)
--guest Shorthand for --type guest --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-config Shows the WireGuard peer config
--show-qr Shows the WireGuard config in a QR Code --show-qr Shows the WireGuard config in a QR Code
@ -44,6 +46,10 @@ Device Types and Subnets:
tablet 10.1.4.x tablet 10.1.4.x
guest 10.1.100.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: Tunnel Modes:
split Route only VPN subnet + LAN through WireGuard (default) split Route only VPN subnet + LAN through WireGuard (default)
full Route all traffic through WireGuard 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 laptop --ip 10.1.2.5
wgctl add --name nuno --type phone --tunnel full wgctl add --name nuno --type phone --tunnel full
wgctl add --name guest1 --type phone --guest 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 EOF
} }
@ -118,10 +126,10 @@ function cmd::add::run() {
local type="" local type=""
local subtype="" local subtype=""
local rule="" local rule=""
local group=""
local ip="" local ip=""
local tunnel="" local tunnel=""
local guest=false local guest=false
local presets=()
local show_config=false local show_config=false
local show_qr=false local show_qr=false
@ -132,8 +140,8 @@ function cmd::add::run() {
--type) type="$2"; shift 2 ;; --type) type="$2"; shift 2 ;;
--subtype) subtype="$2"; shift 2 ;; --subtype) subtype="$2"; shift 2 ;;
--rule) rule="$2"; shift 2 ;; --rule) rule="$2"; shift 2 ;;
--group) group="$2"; shift 2 ;;
--ip) ip="$2"; shift 2 ;; --ip) ip="$2"; shift 2 ;;
--preset) presets+=("$2"); shift 2 ;;
--guest) guest=true; shift ;; --guest) guest=true; shift ;;
--tunnel) tunnel="$2"; shift 2 ;; --tunnel) tunnel="$2"; shift 2 ;;
--show-config) show_config=true; shift ;; --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 peers::create_client_config "$full_name" "$effective_type" "$ip" "$allowed_ips" || return 1
[[ -n "$subtype" ]] && peers::set_meta "$full_name" "subtype" "$subtype" [[ -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 local public_key
public_key=$(keys::public "$full_name") || return 1 public_key=$(keys::public "$full_name") || return 1
peers::add_to_server "$full_name" "$public_key" "$ip" || 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 [[ -n "$rule" ]] && rule::apply "$rule" "$ip" || return 1
peers::reload || return 1 peers::reload || return 1

View file

@ -28,16 +28,20 @@ Block rules are persisted and restored on WireGuard restart.
Options: Options:
--name <name> Client name (e.g. phone-nuno) --name <name> Client name (e.g. phone-nuno)
--type <type> Device type (optional, combines with --name)
--ip <ip> Block access to specific IP (repeatable) --ip <ip> Block access to specific IP (repeatable)
--subnet <cidr> Block access to subnet (repeatable) --subnet <cidr> Block access to subnet (repeatable)
--port <ip:port:proto> Block specific port, e.g. 10.0.0.210:9000:tcp (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: Examples:
wgctl block --name phone-nuno 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 --ip 10.0.0.210
wgctl block --name phone-nuno --subnet 10.0.0.0/24 wgctl block --name phone-nuno --subnet 10.0.0.0/24
wgctl block --name phone-nuno --port 10.0.0.210:9000:tcp 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 EOF
} }

View file

@ -260,12 +260,19 @@ function cmd::group::show() {
printf " %s\n" "$(printf '─%.0s' {1..65})" printf " %s\n" "$(printf '─%.0s' {1..65})"
for peer_name in "${peers_list[@]}"; do for peer_name in "${peers_list[@]}"; do
[[ -z "$peer_name" ]] && continue [[ -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 local ip rule status_str status_color
ip=$(peers::get_ip "$peer_name") ip=$(peers::get_ip "$peer_name")
rule=$(peers::get_meta "$peer_name" "rule") rule=$(peers::get_meta "$peer_name" "rule")
rule="${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_color="\033[1;31m"
status_str="blocked" status_str="blocked"
else else
@ -274,14 +281,15 @@ function cmd::group::show() {
fi fi
printf " %-28s %-15s %-12s %b\n" \ printf " %-28s %-15s %-12s %b\n" \
"$peer_name" "${ip:-}" "$rule" \ "$peer_name" "0" "$rule" \
"${status_color}${status_str}\033[0m" "${status_str}\033[0m"
done done
else else
printf " —\n" printf " —\n"
fi fi
printf "\n" printf "\n"
return 0
} }
# ============================================ # ============================================
@ -307,15 +315,7 @@ function cmd::group::add() {
return 1 return 1
fi fi
local group_file json::create_group "$(group::path "$name")" "$name" "$desc"
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}" log::wg_success "Group created: ${name}"
} }
@ -479,7 +479,7 @@ function cmd::group::rm_peers() {
local peers_list=() local peers_list=()
mapfile -t peers_list < <(group::peers "$name") mapfile -t peers_list < <(group::peers "$name")
local peer_count=${#peers_list[@]} 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 if [[ "$peer_count" -eq 0 ]]; then
log::wg_warning "Group '${name}' has no peers" log::wg_warning "Group '${name}' has no peers"
@ -494,16 +494,70 @@ function cmd::group::rm_peers() {
esac esac
fi fi
local count=0 load_command remove
for peer_name in "${peers_list[@]}"; do group::each_peer "$name" cmd::group::_rm_peer_cb
[[ -z "$peer_name" ]] && continue log::wg_success "Removed peers from group '${name}' (definition kept)"
cmd::remove::run --name "$peer_name" --force
(( count++ )) || true
done
log::wg_success "Removed ${count} peers from WireGuard (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 # Block / Unblock
# ============================================ # ============================================
@ -536,6 +590,12 @@ function cmd::group::block() {
local count=0 blocked_names=() local count=0 blocked_names=()
for peer_name in "${peers_list[@]}"; do for peer_name in "${peers_list[@]}"; do
[[ -z "$peer_name" ]] && continue [[ -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 if peers::is_blocked "$peer_name"; then
log::wg_warning "${peer_name} — already blocked" log::wg_warning "${peer_name} — already blocked"
continue continue
@ -582,6 +642,12 @@ function cmd::group::unblock() {
local count=0 unblocked_names=() local count=0 unblocked_names=()
for peer_name in "${peers_list[@]}"; do for peer_name in "${peers_list[@]}"; do
[[ -z "$peer_name" ]] && continue [[ -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 if ! peers::is_blocked "$peer_name"; then
log::wg_warning "${peer_name} — not blocked" log::wg_warning "${peer_name} — not blocked"
continue continue
@ -639,16 +705,20 @@ function cmd::group::rule_assign() {
local peers_list=() local peers_list=()
mapfile -t peers_list < <(group::peers "$name") 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 group::each_peer "$name" cmd::group::_rule_assign_cb "$rule"
for peer_name in "${peers_list[@]}"; do log::wg_success "Assigned rule '${rule}' to group '${name}'"
[[ -z "$peer_name" ]] && continue }
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" 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" test::section "Peer Rules"
for peer_name in "${peer_args[@]}"; do 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}" cmd::audit::check_peer "$peer_name" false "${peer_fw_counts[$peer_name]:-0}"
done done
@ -727,6 +802,11 @@ function cmd::group::logs() {
for peer_name in "${peers_list[@]}"; do for peer_name in "${peers_list[@]}"; do
[[ -z "$peer_name" ]] && continue [[ -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" printf "\n \033[1;37m── %s ──\033[0m\n" "$peer_name"
load_command logs load_command logs
cmd::logs::show --name "$peer_name" --limit "$limit" cmd::logs::show --name "$peer_name" --limit "$limit"
@ -755,15 +835,18 @@ function cmd::group::watch() {
mapfile -t peers_list < <(group::peers "$name") 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 peer_filter
peer_filter=$(IFS=','; echo "${peers_list[*]}")
# Build comma-separated peer list for watch filter # Build comma-separated peer list for watch filter
# Watch already supports --type filter but not multiple peers # Watch already supports --type filter but not multiple peers
# For now, use follow mode filtered to group peers one at a time # 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) # or just run watch with no filter (shows all, user sees group context)
log::section "Live Monitor: Group '${name}'" log::section "Live Monitor: Group '${name}'"
printf " Monitoring peers: %s\n\n" "${peers_list[*]}" printf " Monitoring: %s\n\n" "${peers_list[*]}"
load_command logs load_command logs
# Use follow mode — it shows all peers but user knows context # Use follow mode — it shows all peers but user knows context
# Future: add --peers flag to follow for multi-peer filter # 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_ips"
ui::print_list "-" "$block_ports" ui::print_list "-" "$block_ports"
else else
ui::row "Blocks" "— (full access)" ui::row "Blocks" "—"
fi fi
} }

View file

@ -6,6 +6,8 @@
function cmd::list::on_load() { function cmd::list::on_load() {
flag::register --type flag::register --type
flag::register --rule
flag::register --group
flag::register --online flag::register --online
flag::register --offline flag::register --offline
flag::register --restricted flag::register --restricted
@ -26,18 +28,22 @@ Usage: wgctl list [options]
List all WireGuard clients. List all WireGuard clients.
Options: 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 --online Show only connected clients
--offline Show only disconnected clients --offline Show only disconnected clients
--allowed Show only fully allowed clients
--restricted Show only restricted clients
--blocked Show only blocked clients --blocked Show only blocked clients
--restricted Show only restricted clients
--allowed Show only unrestricted clients
--detailed Show full detail cards for all clients --detailed Show full detail cards for all clients
--name <name> Show detail card for a single client --name <name> Show detail card for a single client
Examples: Examples:
wgctl list wgctl list
wgctl list --type phone wgctl list --type guest
wgctl list --rule user
wgctl list --group family
wgctl list --online wgctl list --online
wgctl list --blocked wgctl list --blocked
wgctl list --detailed wgctl list --detailed
@ -45,47 +51,6 @@ Examples:
EOF 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 # Status / Display helpers
# ============================================ # ============================================
@ -186,7 +151,7 @@ function peers::get_type() {
function cmd::list::display_type() { function cmd::list::display_type() {
local name="${1:-0}" local name="${1:-0}"
local type="${2:-0}" local type="${2:-0}"
local subtype="${3:-0}" local subtype="${3:-}"
if config::is_guest_type "$type" && [[ -n "$subtype" ]]; then if config::is_guest_type "$type" && [[ -n "$subtype" ]]; then
echo "guest/${subtype}" echo "guest/${subtype}"
elif config::is_guest_type "$type"; then elif config::is_guest_type "$type"; then
@ -234,9 +199,6 @@ function cmd::list::_render_footer() {
function cmd::list::_render_summary() { function cmd::list::_render_summary() {
local group_summary="${1:-}" local group_summary="${1:-}"
local -n _rule_counts="$2" 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) # Count total from rule_counts (only filtered peers)
local total=0 local total=0
@ -273,7 +235,7 @@ function cmd::list::show_client() {
local ip allowed_ips public_key local ip allowed_ips public_key
ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1) 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") public_key=$(keys::public "$name" 2>/dev/null || echo "unknown")
local type local type
@ -356,7 +318,7 @@ function cmd::list::is_attempting() { cmd::list::_is_attempting "$@"; }
# ============================================ # ============================================
function cmd::list::run() { function cmd::list::run() {
local filter_type="" local filter_type="" filter_rule="" filter_group=""
local online_only=false offline_only=false local online_only=false offline_only=false
local restricted_only=false blocked_only=false allowed_only=false local restricted_only=false blocked_only=false allowed_only=false
local detailed=false single_name="" local detailed=false single_name=""
@ -364,6 +326,8 @@ function cmd::list::run() {
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--type) filter_type="$2"; shift 2 ;; --type) filter_type="$2"; shift 2 ;;
--rule) filter_rule="$2"; shift 2 ;;
--group) filter_group="$2"; shift 2 ;;
--online) online_only=true; shift ;; --online) online_only=true; shift ;;
--offline) offline_only=true; shift ;; --offline) offline_only=true; shift ;;
--restricted) restricted_only=true; shift ;; --restricted) restricted_only=true; shift ;;
@ -383,92 +347,45 @@ function cmd::list::run() {
# Single detail card # Single detail card
if [[ -n "$single_name" ]]; then if [[ -n "$single_name" ]]; then
cmd::list::show_client "$single_name" cmd::list::show_client "$single_name"
return return 0
fi fi
local dir local dir
dir="$(ctx::clients)" dir="$(ctx::clients)"
local confs=("${dir}"/*.conf) local confs=("${dir}"/*.conf)
if [[ ! -f "${confs[0]}" ]]; then if [[ ! -f "${confs[0]}" ]]; then
log::wg_list "No clients configured" log::wg_warning "No clients configured"
return 0 return 0
fi fi
# ── Precompute everything ────────────────── # ── Precompute everything ──────────────────
cmd::list::_precompute_all
# 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
# ── Detailed mode ────────────────────────── # ── Detailed mode ──────────────────────────
if $detailed; then if $detailed; then
log::section "WireGuard Clients" log::section "WireGuard Clients"
cmd::list::_iter_confs "$filter_type" cmd::list::show_client cmd::list::_iter_confs "$filter_type" cmd::list::_show_client_safe
return return 0
fi fi
# ── Build filter description ───────────────
local filter_desc=""
cmd::list::_build_filter_desc
# ── Table view ───────────────────────────── # ── Table view ─────────────────────────────
declare -A rule_counts=() group_counts=()
log::section "WireGuard Clients" _list_header_printed=false
cmd::list::_render_header $has_groups
declare -A rule_counts
declare -A group_counts
cmd::list::_iter_confs "$filter_type" cmd::list::_render_row cmd::list::_iter_confs "$filter_type" cmd::list::_render_row
if [[ "$_list_header_printed" == "true" ]]; then
cmd::list::_render_footer $has_groups cmd::list::_render_footer $has_groups
# Build summaries
declare -A displayed_rules displayed_groups
local group_summary="" local group_summary=""
if $has_groups; then cmd::list::_build_group_summary
for g in "${!group_counts[@]}"; do cmd::list::_render_summary "$group_summary" rule_counts "$filter_desc"
group_summary+="${group_counts[$g]} in ${g}, " else
done log::wg_warning "No results found${filter_desc:+ for: ${filter_desc}}"
group_summary="${group_summary%, }"
fi fi
cmd::list::_render_summary "$group_summary" rule_counts
} }
function cmd::list::_iter_confs() { function cmd::list::_iter_confs() {
@ -510,6 +427,11 @@ function cmd::list::_render_row() {
if $allowed_only && { [[ "$is_blocked" == "true" ]] || \ if $allowed_only && { [[ "$is_blocked" == "true" ]] || \
[[ "$is_restricted" == "true" ]]; }; then return 0; fi [[ "$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 # Format display values
local status last_seen display_type rule group_display local status last_seen display_type rule group_display
status=$(cmd::list::_format_status "$client_name" "$pubkey" \ status=$(cmd::list::_format_status "$client_name" "$pubkey" \
@ -520,6 +442,15 @@ function cmd::list::_render_row() {
"${p_subtypes[$client_name]:-}") "${p_subtypes[$client_name]:-}")
rule="${p_rules[$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) # Update rule counts for summary (outer scope array)
rule_counts["$rule"]=$(( ${rule_counts[$rule]:-0} + 1 )) || true rule_counts["$rule"]=$(( ${rule_counts[$rule]:-0} + 1 )) || true
@ -549,3 +480,100 @@ function cmd::list::_render_row() {
"$padded_status" "$last_seen" "$padded_status" "$last_seen"
fi 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() { function cmd::logs::follow() {
local filter_ip="$1" filter_name="$2" filter_type="$3" local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}"
local fw_only="$4" wg_only="$5" local fw_only="${4:-false}" wg_only="${5:-false}"
local filter_peers="${6:-}"
local clients_dir local clients_dir
clients_dir="$(ctx::clients)" clients_dir="$(ctx::clients)"
@ -137,7 +139,7 @@ function cmd::logs::follow() {
printf " %-20s %-8s %-20s %-25s %b\n" \ printf " %-20s %-8s %-20s %-25s %b\n" \
"$ts" "wireguard" "$client" "$dst_or_endpoint" "$colored_event" "$ts" "wireguard" "$client" "$dst_or_endpoint" "$colored_event"
fi 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() { 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_from_server "$name" || return 1
peers::remove_client_config "$name" || return 1 peers::remove_client_config "$name" || return 1
keys::remove "$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" [[ -n "$client_ip" ]] && $was_blocked && fw::unblock_all "$client_ip"
fw::remove_block_file "$name" 2>/dev/null || true fw::remove_block_file "$name" 2>/dev/null || true

View file

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

View file

@ -138,13 +138,12 @@ function cmd::test::run_function() {
function cmd::test::section_list() { function cmd::test::section_list() {
test::section "List" test::section "List"
cmd::test::run_cmd "list" "WireGuard Clients" list cmd::test::run_cmd "list" "WireGuard Clients" list
cmd::test::run_cmd "list --online" "STATUS" list --online cmd::test::run_cmd "list --online" "" list --online
cmd::test::run_cmd "list --offline" "STATUS" list --offline cmd::test::run_cmd "list --offline" "" list --offline
cmd::test::run_cmd "list --blocked" "STATUS" list --blocked cmd::test::run_cmd "list --blocked" "" list --blocked
cmd::test::run_cmd "list --type phone" "phone" list --type phone cmd::test::run_cmd "list --type phone" "" list --type phone
cmd::test::run_cmd "list --type guest" "guest" list --type guest cmd::test::run_cmd "list --type guest" "" list --type guest
# TODO: Fix detailed, hangs cmd::test::run_cmd "list --detailed" "Client:" list --detailed
# cmd::test::run_cmd "list --detailed" "Client:" list --detailed
cmd::test::run_cmd "list --name phone-nuno" "phone-nuno" list --name phone-nuno 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: Options:
--name <name> Client name (e.g. phone-nuno) --name <name> Client name (e.g. phone-nuno)
--type <type> Device type (optional, combines with --name)
--ip <ip> Unblock specific IP (repeatable) --ip <ip> Unblock specific IP (repeatable)
--subnet <cidr> Unblock specific subnet (repeatable) --subnet <cidr> Unblock specific subnet (repeatable)
--port <ip:port:proto> Unblock specific port (repeatable) --port <ip:port:proto> Unblock specific port (repeatable)
--all Remove all block rules for this client --all Remove all block rules for this client
--force Skip confirmation prompt
--quiet Suppress output (used by group unblock)
Examples: 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 unblock --name phone-nuno --ip 10.0.0.210
wgctl unban --name phone-nuno --all wgctl unban --name phone-nuno
EOF EOF
} }
@ -127,7 +131,6 @@ function cmd::unblock::_unblock_all() {
local client_ip="${2:-}" local client_ip="${2:-}"
local quiet="${3:-false}" local quiet="${3:-false}"
log::debug "_unblock_all: name=$name ip=$client_ip"
fw::unblock_all "$client_ip" fw::unblock_all "$client_ip"
fw::remove_block_file "$name" fw::remove_block_file "$name"
monitor::unwatch_client "$name" monitor::unwatch_client "$name"
@ -135,7 +138,6 @@ function cmd::unblock::_unblock_all() {
if ! peers::exists_in_server "$name"; then if ! peers::exists_in_server "$name"; then
local public_key local public_key
public_key=$(keys::public "$name") || return 1 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::add_to_server "$name" "$public_key" "$client_ip"
peers::reload peers::reload
fi fi

View file

@ -161,17 +161,13 @@ function cmd::watch::tail_events() {
[[ -z "$line" ]] && continue [[ -z "$line" ]] && continue
local event_data local event_data
event_data=$(python3 -c " event_data=$(json::parse_event "$line")
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)
[[ -z "$event_data" ]] && continue [[ -z "$event_data" ]] && continue
local ts client endpoint event
IFS="|" read -r ts client endpoint event <<< "$event_data"
if $restricted_only; then if $restricted_only; then
local conf local conf
conf="$(ctx::clients)/${client}.conf" conf="$(ctx::clients)/${client}.conf"
@ -179,9 +175,6 @@ except:
cmd::list::is_restricted "$client" || continue cmd::list::is_restricted "$client" || continue
fi fi
local ts client endpoint event
read -r ts client endpoint event <<< "$event_data"
# Apply filters # Apply filters
[[ -n "$filter_name" && "$client" != "$filter_name" ]] && continue [[ -n "$filter_name" && "$client" != "$filter_name" ]] && continue

View file

@ -9,7 +9,6 @@ _CTX_WG="/etc/wireguard"
_CTX_CORE="${_CTX_ROOT}/core" _CTX_CORE="${_CTX_ROOT}/core"
_CTX_MODULES="${_CTX_ROOT}/modules" _CTX_MODULES="${_CTX_ROOT}/modules"
_CTX_COMMANDS="${_CTX_ROOT}/commands" _CTX_COMMANDS="${_CTX_ROOT}/commands"
_CTX_PRESETS="${_CTX_ROOT}/presets"
_CTX_CLIENTS="${_CTX_WG}/clients" _CTX_CLIENTS="${_CTX_WG}/clients"
_CTX_DATA="${_CTX_WG}/.wgctl" _CTX_DATA="${_CTX_WG}/.wgctl"
@ -29,7 +28,6 @@ function ctx::root() { echo "$_CTX_ROOT"; }
function ctx::core() { echo "$_CTX_CORE"; } function ctx::core() { echo "$_CTX_CORE"; }
function ctx::modules() { echo "$_CTX_MODULES"; } function ctx::modules() { echo "$_CTX_MODULES"; }
function ctx::commands() { echo "$_CTX_COMMANDS"; } function ctx::commands() { echo "$_CTX_COMMANDS"; }
function ctx::presets() { echo "$_CTX_PRESETS"; }
function ctx::blocks() { echo "$_CTX_BLOCKS"; } function ctx::blocks() { echo "$_CTX_BLOCKS"; }
function ctx::groups() { echo "$_CTX_GROUPS"; } function ctx::groups() { echo "$_CTX_GROUPS"; }
function ctx::rules() { echo "$_CTX_RULES"; } function ctx::rules() { echo "$_CTX_RULES"; }
@ -54,11 +52,6 @@ function ctx::client::path() {
echo "$_CTX_CLIENTS/$*" echo "$_CTX_CLIENTS/$*"
} }
function ctx::preset::path() {
local IFS="/"
echo "$_CTX_PRESETS/$*"
}
function ctx::meta::path() { function ctx::meta::path() {
local IFS="/" local IFS="/"
echo "$_CTX_META/$*" 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::group_list_data() { python3 "$JSON_HELPER" group_list_data "$@" </dev/null; }
function json::fmt_datetime() { python3 "$JSON_HELPER" fmt_datetime "$@" </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::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) print(f"Error: {e}", file=sys.stderr)
sys.exit(1) 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""" """Follow both log files and output formatted events"""
import glob, time, select import glob, time, select
proto_map = {1: 'icmp', 6: 'tcp', 17: 'udp'} proto_map = {1: 'icmp', 6: 'tcp', 17: 'udp'}
peer_filter = set(filter_peers.split(',')) if filter_peers else set()
# Build ip->name map # Build ip->name map
ip_to_name = {} 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, '') ip = ip_to_name.get(filter_ip, '')
if client != ip and client != filter_ip: if client != ip and client != filter_ip:
continue continue
if peer_filter and client not in peer_filter:
continue
if filter_type and not client.startswith(filter_type + '-'): if filter_type and not client.startswith(filter_type + '-'):
continue continue
ts = e.get('timestamp', '')[:16].replace('T', ' ') 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: with open(file, 'w') as f:
json.dump(rule, f, indent=2) 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 = { commands = {
'get': lambda args: get(args[0], args[1]), 'get': lambda args: get(args[0], args[1]),
'set': lambda args: set_key(args[0], args[1], args[2]), '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_fw_event': lambda args: format_fw_event(sys.stdin.read(), args[0]),
'format_wg_event': lambda args: format_wg_event(sys.stdin.read()), 'format_wg_event': lambda args: format_wg_event(sys.stdin.read()),
'remove_events': lambda args: remove_events(args[0], args[1]), '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]), 'count': lambda args: count(args[0], args[1]),
'audit_fw_counts': lambda args: audit_fw_counts(args[0]), 'audit_fw_counts': lambda args: audit_fw_counts(args[0]),
'peer_group_map': lambda args: peer_group_map(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]), 'group_list_data': lambda args: group_list_data(args[0], args[1]),
'fmt_datetime': lambda args: fmt_datetime(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]), '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__': if __name__ == '__main__':

View file

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

View file

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

View file

@ -88,7 +88,7 @@ function fw::unallow_port() {
} }
function fw::flush_peer() { function fw::flush_peer() {
local client_ip="$1" local client_ip="${1:?client_ip required}"
log::debug "flush_peer: starting for $client_ip" log::debug "flush_peer: starting for $client_ip"
# Collect line numbers into array # Collect line numbers into array
@ -205,47 +205,3 @@ function fw::restore_blocks() {
log::debug "Restored block rules for: ${name}" log::debug "Restored block rules for: ${name}"
done 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 fi
done < <(group::all) 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() { function peers::cleanup_config() {
local config json::cleanup_config "$(config::config_file)"
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)
"
} }
function peers::add_to_server() { function peers::add_to_server() {
local name="$1" local name="${1:?name required}"
local public_key="$2" local public_key="${2:?public_key required}"
local ip="$3" local ip="${3:?ip required}"
local config local config
config=$(config::config_file) config=$(config::config_file)
@ -97,21 +84,12 @@ EOF
} }
function peers::remove_block() { function peers::remove_block() {
local name="$1" local name="${1:?name required}"
local config json::remove_peer_block "$(config::config_file)" "$name"
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)
"
} }
function peers::remove_from_server() { function peers::remove_from_server() {
local name="$1" local name="${1:?name required}"
peers::remove_block "$name" peers::remove_block "$name"
peers::cleanup_config peers::cleanup_config
log::debug "Removed peer from server config: ${name}" log::debug "Removed peer from server config: ${name}"
@ -183,8 +161,8 @@ function peers::exists_in_server() {
} }
function peers::is_blocked() { function peers::is_blocked() {
local name="$1" local name="${1:-}"
! peers::exists_in_server "$name" peers::exists_in_server "$name" && return 1 || return 0
} }
# ============================================ # ============================================
@ -248,7 +226,7 @@ function peers::with_rule() {
function peers::get_ip() { function peers::get_ip() {
local name="$1" local name="$1"
grep "^Address" "$(ctx::clients)/${name}.conf" 2>/dev/null \ 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() { function peers::find_by_ip() {

View file

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