feat: complete wgctl v2 — net services, block system M:N, rule inheritance, service annotations, restricted status, 64 tests passing

This commit is contained in:
Nuno Duque Nunes 2026-05-15 12:36:38 +00:00
parent 16b4351313
commit f32ca5c0a1
9 changed files with 122 additions and 282 deletions

View file

@ -11,7 +11,8 @@ function cmd::audit::help() {
Usage: wgctl audit [options]
Verify that all peers have the correct iptables firewall rules applied.
Checks expected rule count (including inherited rules) vs actual iptables state.
Checks expected rule count (including inherited rules and peer-specific blocks)
vs actual iptables state.
Options:
--peer <name> Audit specific peer only
@ -21,7 +22,12 @@ Options:
Output:
✅ pass — peer has correct rule count
❌ fail — peer has missing rules (run --fix to repair)
⚠️ warn — peer has extra rules (e.g. blocked peers with base rules)
⚠️ warn — peer has extra rules or configuration issues
Notes:
Fully blocked peers (removed from WireGuard) show 0 expected fw rules.
Restricted peers (peer-specific blocks) have their block rules included
in the expected count.
Examples:
wgctl audit
@ -81,53 +87,72 @@ function cmd::audit::run() {
}
function cmd::audit::check_peer() {
local peer_name="$1"
local fix="$2"
local actual="${3:-0}"
local peer_name="$1" fix="$2" actual="${3:-0}"
local ip rule
ip=$(peers::get_ip "$peer_name")
rule=$(peers::get_meta "$peer_name" "rule")
# Check rule assigned
if [[ -z "$rule" ]]; then
test::warn "$peer_name — no rule assigned (effective: $(peers::default_rule "$peer_name"))"
return
return 0
fi
# Check rule exists
if ! rule::exists "$rule"; then
test::fail "$peer_name — assigned rule '$rule' does not exist"
return
return 0
fi
# Count expected iptables rules from rule file
local rule_file
rule_file="$(ctx::rule::path "${rule}.rule")"
local block_ports block_ips allow_ports allow_ips expected
# Blocked peers have no fw rules (removed from wg0)
if block::is_blocked "$peer_name" 2>/dev/null; then
if [[ "$actual" -eq 0 ]]; then
test::pass "$(printf "%-28s rule=%-15s blocked (no fw rules)" \
"$peer_name" "$rule")"
else
test::warn "$(printf "%-28s rule=%-15s blocked but has %s fw rules" \
"$peer_name" "$rule" "$actual")"
fi
return 0
fi
# Count expected rules from assigned rule (includes inheritance)
local block_ports block_ips allow_ports allow_ips
block_ports=$(json::count_resolved "$rule" "block_ports")
block_ips=$(json::count_resolved "$rule" "block_ips")
allow_ports=$(json::count_resolved "$rule" "allow_ports")
allow_ips=$(json::count_resolved "$rule" "allow_ips")
expected=$(( (block_ports + block_ips) * 2 + allow_ports + allow_ips ))
local expected=$(( (block_ports + block_ips) * 2 + allow_ports + allow_ips ))
# Add peer-specific block rules (each ip/port/subnet = 2 rules: NFLOG+DROP)
if block::has_specific_rules "$peer_name" 2>/dev/null; then
while IFS="|" read -r bname btype target port proto; do
[[ -z "$btype" || "$btype" == "full" ]] && continue
(( expected += 2 )) || true
done < <(block::get_rules "$peer_name")
fi
# actual is passed in as $3 — no iptables call here
if [[ "$actual" -eq "$expected" ]]; then
test::pass "$(printf "%-28s rule=%-15s fw: %s/%s" "$peer_name" "$rule" "$actual" "$expected")"
test::pass "$(printf "%-28s rule=%-15s fw: %s/%s" \
"$peer_name" "$rule" "$actual" "$expected")"
elif [[ "$actual" -gt "$expected" ]]; then
test::warn "$(printf "%-28s rule=%-15s fw: %s/%s (extra rules)" "$peer_name" "$rule" "$actual" "$expected")"
test::warn "$(printf "%-28s rule=%-15s fw: %s/%s (extra rules)" \
"$peer_name" "$rule" "$actual" "$expected")"
if $fix; then
fw::flush_peer "$ip"
rule::apply "$rule" "$ip" "$peer_name"
block::restore_rules_for "$peer_name" "$ip"
test::pass " Fixed: $peer_name"
fi
else
test::fail "$(printf "%-28s rule=%-15s fw: %s/%s (missing rules)" "$peer_name" "$rule" "$actual" "$expected")"
test::fail "$(printf "%-28s rule=%-15s fw: %s/%s (missing rules)" \
"$peer_name" "$rule" "$actual" "$expected")"
if $fix; then
rule::apply "$rule" "$ip" "$peer_name"
block::restore_rules_for "$peer_name" "$ip"
test::pass " Fixed: $peer_name"
fi
fi
return 0
}
function cmd::audit::check_wg() {

View file

@ -27,17 +27,20 @@ function cmd::block::help() {
cat <<EOF
Usage: wgctl block --name <name> [options]
Block a client entirely or restrict access to specific IPs/ports/subnets.
Block rules are persisted and restored on WireGuard restart.
Block a client entirely or restrict access to specific IPs/ports/subnets/services.
All block rules are persisted and restored on WireGuard restart.
Clients blocked via --ip/--port/--subnet/--service remain in WireGuard
but have specific traffic restricted (shown as 'restricted' in list).
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)
--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)
--service <name> Block a named service (e.g. proxmox, truenas:web-ui) (repeatable)
--block-name <name> Optional name for this block rule
--quiet Suppress output (used by group block)
Examples:
wgctl block --name phone-nuno
@ -45,6 +48,8 @@ Examples:
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 block --name phone-nuno --service proxmox
wgctl block --name phone-nuno --service truenas:web-ui --block-name "no truenas ui"
wgctl ban --name phone-nuno
EOF
}

View file

@ -22,14 +22,16 @@ function cmd::group::help() {
cat <<EOF
Usage: wgctl group <subcommand> [options]
Manage peer groups. Groups are organizational — a peer can belong
to multiple groups. Operations like block/unblock act on all peers in a group.
Manage peer groups. Operations like block/unblock act on all peers in a group.
A peer can belong to multiple groups (M:N relationship).
Group blocks track which groups blocked a peer — unblocking one group won't
unblock a peer still blocked by another group.
Subcommands:
list, ls List all groups with status
list, ls List all groups
show Show group members and their status
add, new, create Create a new group
remove, rm, del Remove a group definition (not the peers)
remove, rm, del Remove a group definition
rename Rename a group
peer add Add a peer to a group
peer remove, peer rm Remove a peer from a group
@ -55,7 +57,6 @@ Examples:
wgctl group list
wgctl group add --name family --desc "Family devices"
wgctl group peer add --name family --peer phone-nuno
wgctl group peer remove --name family --peer phone-nuno
wgctl group show --name family
wgctl group block --name family
wgctl group unblock --name family
@ -147,79 +148,6 @@ function cmd::group::list() {
printf "\n"
}
# function cmd::group::list() {
# local groups_dir
# groups_dir="$(ctx::groups)"
# local groups=("${groups_dir}"/*.group)
# if [[ ! -f "${groups[0]}" ]]; then
# log::wg "No groups configured"
# return 0
# fi
# log::section "Groups"
# printf "\n %-20s %-35s %-8s %s\n" "NAME" "DESCRIPTION" "PEERS" "STATUS"
# printf " %s\n" "$(printf '─%.0s' {1..75})"
# for group_file in "${groups_dir}"/*.group; do
# [[ -f "$group_file" ]] || continue
# local name desc
# name=$(json::get "$group_file" "name")
# desc=$(json::get "$group_file" "desc")
# # Count peers
# local peers_list=()
# mapfile -t peers_list < <(json::get "$group_file" "peers")
# # Filter empty entries
# local filtered=()
# for p in "${peers_list[@]:-}"; do
# [[ -n "$p" ]] && filtered+=("$p")
# done
# peers_list=("${filtered[@]:-}")
# local peer_count=${#peers_list[@]}
# [[ -z "${peers_list[0]}" ]] && peer_count=0
# # Check block status
# local blocked=0 total=0
# for peer_name in "${peers_list[@]}"; do
# [[ -z "$peer_name" ]] && continue
# (( total++ )) || true
# peers::is_blocked "$peer_name" && (( blocked++ )) || true
# done
# local status_color=""
# local status_str="active"
# if [[ "$total" -gt 0 ]]; then
# if [[ "$blocked" -eq "$total" ]]; then
# status_color="\033[1;31m"
# status_str="blocked"
# elif [[ "$blocked" -gt 0 ]]; then
# status_color="\033[1;33m"
# status_str="blocked (${blocked}/${total})"
# # status_color="\033[1;33m"
# # status_str="${blocked}/${total} blocked"
# else
# status_color="\033[1;32m"
# status_str="active"
# fi
# fi
# local short_desc="${desc:0:33}"
# [[ ${#desc} -gt 33 ]] && short_desc="${short_desc}..."
# printf " %-20s %-35s %-8s %b\n" \
# "$name" \
# "${short_desc:-—}" \
# "$peer_count" \
# "${status_color}${status_str}\033[0m"
# done
# printf "\n"
# }
# ============================================
# Show
# ============================================
@ -731,7 +659,6 @@ function cmd::group::_unblock_peer() {
return 1
fi
log::debug "_unblock_peer: restoring $peer_name"
block::restore_peer "$peer_name" "$client_ip"
block::remove_file "$peer_name"
@ -740,8 +667,6 @@ function cmd::group::_unblock_peer() {
[[ -n "$rule" ]] && rule::exists "$rule" && \
rule::apply "$rule" "$client_ip" "$peer_name"
log::debug "_unblock_peer: done"
}
# ============================================

View file

@ -12,12 +12,18 @@ function cmd::inspect::help() {
Usage: wgctl inspect --name <name> [options]
wgctl inspect <full-name>
Show detailed information for a WireGuard client including
status, rule with inheritance, groups, firewall rules, and activity.
Show detailed information for a WireGuard client.
Sections shown:
Client — IP, type, rule, status, activity
Groups — group memberships
Rule — firewall rule with inheritance tree and service annotations
Peer Blocks — peer-specific restrictions (beyond the assigned rule)
Firewall — active iptables rules with ACCEPT/DROP counts
Options:
--name <name> Client name (e.g. phone-nuno)
--type <type> Device type — combines with --name (e.g. --name nuno --type phone)
--type <type> Device type — combines with --name
--config Also show raw WireGuard client config
--qr Also show QR code
@ -112,107 +118,6 @@ function cmd::inspect::_peer_info() {
return 0
}
# function cmd::inspect::_rule_info() {
# local name="${1:-}"
# local rule
# rule=$(peers::get_meta "$name" "rule")
# [[ -z "$rule" ]] && return 0
# rule::exists "$rule" || return 0
# cmd::inspect::_section "Rule: ${rule}"
# local rule_file
# rule_file="$(rule::path "$rule")"
# # Check for inheritance
# local extends_raw=()
# mapfile -t extends_raw < <(json::get "$rule_file" "extends" 2>/dev/null || true)
# if [[ ${#extends_raw[@]} -gt 0 && -n "${extends_raw[0]:-}" ]]; then
# # Show inheritance tree
# for base_name in "${extends_raw[@]}"; do
# [[ -z "$base_name" ]] && continue
# printf "\n \033[0;37m↳ %s\033[0m\n" "$base_name"
# local base_allows base_blocks
# base_allows=$(rule::get "$base_name" "allow_ports")$'\n'$(rule::get "$base_name" "allow_ips")
# base_blocks=$(rule::get "$base_name" "block_ips")$'\n'$(rule::get "$base_name" "block_ports")
# while IFS= read -r e; do
# [[ -z "$e" ]] && continue
# net::print_entry "+" "$e"
# done <<< "$base_allows"
# while IFS= read -r e; do
# [[ -z "$e" ]] && continue
# net::print_entry "-" "$e"
# done <<< "$base_blocks"
# local base_dns
# base_dns=$(rule::get_own "$base_name" "dns_redirect")
# [[ "${base_dns,,}" == "true" ]] && \
# printf " \033[0;36m↺\033[0m DNS → %s\n" "$(config::dns)"
# done
# # Own rules
# local own_allows own_blocks
# own_allows=$(json::get "$rule_file" "allow_ports" 2>/dev/null)$'\n'$(json::get "$rule_file" "allow_ips" 2>/dev/null)
# own_blocks=$(json::get "$rule_file" "block_ips" 2>/dev/null)$'\n'$(json::get "$rule_file" "block_ports" 2>/dev/null)
# local has_own=false
# while IFS= read -r e; do [[ -n "$e" ]] && has_own=true && break; done <<< "$own_allows$own_blocks"
# if $has_own; then
# printf "\n \033[0;37mOwn:\033[0m\n"
# while IFS= read -r e; do
# [[ -z "$e" ]] && continue
# net::print_entry "+" "$e"
# done <<< "$own_allows"
# while IFS= read -r e; do
# [[ -z "$e" ]] && continue
# net::print_entry "-" "$e"
# done <<< "$own_blocks"
# fi
# else
# # No inheritance — flat view
# local allow_ports allow_ips block_ips block_ports
# allow_ports=$(rule::get "$rule" "allow_ports")
# allow_ips=$(rule::get "$rule" "allow_ips")
# block_ips=$(rule::get "$rule" "block_ips")
# block_ports=$(rule::get "$rule" "block_ports")
# if [[ -n "$allow_ports" || -n "$allow_ips" ]]; then
# printf "\n"
# while IFS= read -r e; do
# [[ -z "$e" ]] && continue
# net::print_entry "+" "$e"
# done <<< "$allow_ports"$'\n'"$allow_ips"
# fi
# if [[ -n "$block_ips" || -n "$block_ports" ]]; then
# printf "\n"
# while IFS= read -r e; do
# [[ -z "$e" ]] && continue
# net::print_entry "-" "$e"
# done <<< "$block_ips"$'\n'"$block_ports"
# fi
# if [[ -z "$allow_ports" && -z "$allow_ips" && \
# -z "$block_ips" && -z "$block_ports" ]]; then
# printf "\n full access (no restrictions)\n"
# fi
# local dns_redirect
# dns_redirect=$(rule::get_own "$rule" "dns_redirect")
# [[ "${dns_redirect,,}" == "true" ]] && \
# printf "\n \033[0;36m↺\033[0m DNS → %s\n" "$(config::dns)"
# fi
# return 0
# }
function cmd::inspect::_rule_info() {
local name="${1:-}"
local rule
@ -252,48 +157,6 @@ function cmd::inspect::_blocks_info() {
return 0
}
# function cmd::inspect::_rule_info() {
# local name="$1"
# local rule
# rule=$(peers::get_meta "$name" "rule")
# [[ -z "$rule" ]] && return 0
# rule::exists "$rule" || return 0
# local rule_file
# rule_file="$(ctx::rule::path "${rule}.rule")"
# ui::section "Rule: ${rule}"
# local desc dns_redirect
# desc=$(json::get "$rule_file" "desc")
# dns_redirect=$(json::get "$rule_file" "dns_redirect")
# ui::row "Description" "${desc:-—}"
# ui::row "DNS Redirect" "${dns_redirect:-false}"
# local allow_ports allow_ips block_ips block_ports
# allow_ports=$(json::get "$rule_file" "allow_ports")
# allow_ips=$(json::get "$rule_file" "allow_ips")
# block_ips=$(json::get "$rule_file" "block_ips")
# block_ports=$(json::get "$rule_file" "block_ports")
# if [[ -n "$allow_ports" || -n "$allow_ips" ]]; then
# printf " %-20s\n" "Allows:"
# ui::print_list "+" "$allow_ports"
# ui::print_list "+" "$allow_ips"
# else
# ui::row "Allows" "—"
# fi
# if [[ -n "$block_ips" || -n "$block_ports" ]]; then
# printf " %-20s\n" "Blocks:"
# ui::print_list "-" "$block_ips"
# ui::print_list "-" "$block_ports"
# else
# ui::row "Blocks" "—"
# fi
# }
function cmd::inspect::_group_info() {
local name="$1"
@ -401,10 +264,10 @@ function cmd::inspect::run() {
log::section "Inspect: ${name}"
cmd::inspect::_peer_info "$name"
cmd::inspect::_rule_info "$name"
cmd::inspect::_group_info "$name"
cmd::inspect::_firewall_info "$name"
cmd::inspect::_rule_info "$name"
cmd::inspect::_blocks_info "$name"
cmd::inspect::_firewall_info "$name"
if $show_config; then
cmd::inspect::_config "$name"

View file

@ -33,12 +33,18 @@ Options:
--group <group> Filter by group membership
--online Show only connected clients
--offline Show only disconnected clients
--blocked Show only blocked clients
--restricted Show only restricted clients
--blocked Show only fully blocked clients (removed from WireGuard)
--restricted Show only restricted clients (specific IP/port blocks applied)
--allowed Show only unrestricted clients
--detailed Show full detail cards for all clients
--name <name> Show detail card for a single client
Status values:
online Connected (recent handshake)
offline Not connected
blocked Removed from WireGuard server (wgctl block --name)
restricted In WireGuard but with specific access rules (wgctl block --ip/--service)
Examples:
wgctl list
wgctl list --type guest
@ -46,6 +52,7 @@ Examples:
wgctl list --group family
wgctl list --online
wgctl list --blocked
wgctl list --restricted
wgctl list --detailed
wgctl list --name phone-nuno
EOF

View file

@ -43,6 +43,7 @@ Usage: wgctl rule <subcommand> [options]
Manage firewall rules with inheritance support.
Rules can extend base rules to compose reusable access policies.
Service names from 'wgctl net' can be used instead of raw IPs/ports.
Subcommands:
list, ls List all rules
@ -71,6 +72,8 @@ Options for add:
--allow-port <ip:port:proto> Allow specific port (repeatable)
--block-ip <ip/cidr> Block IP or subnet (repeatable)
--block-port <ip:port:proto> Block specific port (repeatable)
--block-service <name> Block named service — resolved to IP/port at creation (repeatable)
--allow-service <name> Allow named service — resolved to IP/port at creation (repeatable)
--dns-redirect Force DNS through Pi-hole
Options for update:
@ -95,16 +98,13 @@ Examples:
wgctl rule list
wgctl rule list --tree
wgctl rule list --group "VM Rules"
wgctl rule list --base
wgctl rule show --name guest
wgctl rule show --name moonlight-02 --resolved
wgctl rule add --name no-proxmox --base --block-service proxmox
wgctl rule add --name dev-01 --desc "Dev access" --group "Dev" --extends no-lan
wgctl rule add --name no-npm --base --block-ip 10.0.0.101/32
wgctl rule add --name restricted-dns --allow-service pihole:dns --block-service pihole
wgctl rule update --name user --add-extends no-nginx
wgctl rule update --name dev-01 --allow-ip 10.0.0.50/32
wgctl rule assign --name dev-01 --peer laptop-nuno
wgctl rule unassign --peer laptop-nuno
wgctl rule reapply --name user
wgctl rule reapply --all
EOF
}
@ -845,9 +845,7 @@ function cmd::rule::assign() {
local ip
ip=$(peers::get_ip "$peer")
log::debug "rule::assign: peer=$peer ip=$ip"
[[ -z "$ip" ]] && log::error "Could not resolve IP for: $peer" && return 1
log::debug "assign: peer=$peer ip=$ip clients=$(ctx::clients)"
if [[ -n "$existing_rule" && "$existing_rule" != "$name" ]]; then
rule::unapply "$existing_rule" "$ip"

View file

@ -18,7 +18,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 service shell help test
rename keys ip net service shell help test
)
local c
for c in "${known[@]}"; do
@ -85,12 +85,17 @@ function cmd::shell::_banner() {
printf " \033[1;37mCommon commands:\033[0m\n"
printf " list List all peers\n"
printf " list --blocked Show blocked peers\n"
printf " list --restricted Show restricted peers\n"
printf " list --rule user Filter by rule\n"
printf " inspect --name <peer> Full peer details\n"
printf " block/unblock --name <peer> Block or restore a peer\n"
printf " block --name <peer> Block a peer entirely\n"
printf " block --name <peer> --service proxmox Restrict service\n"
printf " unblock --name <peer> Restore full access\n"
printf " rule list Show firewall rules\n"
printf " rule list --tree Show with inheritance tree\n"
printf " rule list --tree Show with inheritance\n"
printf " rule show --name <rule> Rule details\n"
printf " net list Show network services\n"
printf " net list --detailed Show services with ports\n"
printf " group list Show groups\n"
printf " group block --name <group> Block all peers in group\n"
printf " logs --follow Live activity log\n"

View file

@ -27,22 +27,25 @@ function cmd::unblock::help() {
cat <<EOF
Usage: wgctl unblock --name <name> [options]
Remove block rules for a client.
Remove block rules for a client. Without specific flags, performs a full unblock.
Direct unblock overrides any group blocks.
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)
--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)
--service <name> Unblock a named service (repeatable)
--all Remove all block rules (same as no flags)
--quiet Suppress output (used by group unblock)
Examples:
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 --service proxmox
wgctl unblock --name phone-nuno --service truenas:web-ui
wgctl unban --name phone-nuno
EOF
}

17
wgctl
View file

@ -107,21 +107,26 @@ Usage: wgctl <command> [options]
add, new Add a new client (--type, --subtype, --rule, --group)
remove, rm Remove a client
rename, mv Rename a client
list, ls List all clients (--type, --rule, --group, --blocked...)
list, ls List all clients (--type, --rule, --group, --blocked, --restricted...)
inspect Show detailed client info (--config, --qr)
config Show client config
qr Show QR code for a client
Access Control:
block, ban Block a client entirely or restrict specific IPs/ports
unblock, unban Restore client access
block, ban Block a client entirely or restrict specific IPs/ports/services
unblock, unban Restore client access (overrides group blocks)
rule Manage firewall rules with inheritance
list, show, inspect, add, update, assign, reapply...
list, show, add, update, assign, reapply...
Organization:
group Manage peer groups
list, show, add, block, unblock, watch, logs...
Network Services:
net Manage named network services
list, show, add, rm
Used with block/unblock --service and rule --block-service
Monitoring:
watch Live monitor — handshakes, fw drops, blocked attempts
logs Show and manage activity logs
@ -140,12 +145,16 @@ Usage: wgctl <command> [options]
Common examples:
wgctl add --name nuno --type phone --rule admin --group family
wgctl list --blocked
wgctl list --restricted
wgctl list --rule user --group family
wgctl block --name phone-nuno
wgctl block --name phone-nuno --service proxmox
wgctl inspect --name phone-nuno
wgctl rule list --tree
wgctl rule show --name guest
wgctl rule add --name dev-01 --group vm-rules --extends no-lan
wgctl net add --name proxmox --ip 10.0.0.100
wgctl net add --name proxmox:web-ui --port 8006:tcp
wgctl group block --name family
wgctl logs --follow
wgctl logs rotate --days 7