feat: block system JSON migration, M:N group tracking, block module, block::restore_all, color module, fw refactor

This commit is contained in:
Nuno Duque Nunes 2026-05-15 04:44:53 +00:00
parent 7b32dcfebc
commit cf90ab22db
26 changed files with 1466 additions and 487 deletions

View file

@ -10,16 +10,23 @@ function cmd::audit::help() {
cat <<EOF
Usage: wgctl audit [options]
Verify WireGuard and firewall state matches expected configuration.
Verify that all peers have the correct iptables firewall rules applied.
Checks expected rule count (including inherited rules) vs actual iptables state.
Options:
--fix Attempt to fix detected issues
--peer <name> Audit specific peer only
--type <type> Audit peers of specific type only
--type <type> Audit peers of specific device type only
--fix Attempt to auto-repair missing or extra rules
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)
Examples:
wgctl audit
wgctl audit --peer phone-nuno
wgctl audit --type guest
wgctl audit --fix
EOF
}
@ -98,10 +105,10 @@ function cmd::audit::check_peer() {
local rule_file
rule_file="$(ctx::rule::path "${rule}.rule")"
local block_ports block_ips allow_ports allow_ips expected
block_ports=$(json::count "$rule_file" "block_ports")
block_ips=$(json::count "$rule_file" "block_ips")
allow_ports=$(json::count "$rule_file" "allow_ports")
allow_ips=$(json::count "$rule_file" "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 ))
# actual is passed in as $3 — no iptables call here

View file

@ -13,6 +13,7 @@ function cmd::block::on_load() {
flag::register --port
flag::register --proto
flag::register --subnet
flag::register --block-name
}
# ============================================
@ -52,6 +53,7 @@ EOF
function cmd::block::run() {
local name=""
local type=""
local block_name=""
local ips=()
local subnets=()
local ports=()
@ -62,6 +64,7 @@ function cmd::block::run() {
--name) name="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;;
--ip) ips+=("$2"); shift 2 ;;
--block-name) block_name="$2"; shift 2 ;;
--force) force=true; shift ;;
--quiet) quiet=true; shift ;;
--subnet) subnets+=("$2"); shift 2 ;;
@ -84,7 +87,7 @@ function cmd::block::run() {
name=$(peers::resolve_and_require "$name" "$type") || return 1
# Check if actually blocked
if peers::is_blocked "$name" || [[ -f "$(ctx::block::path "${name}.block")" ]]; then
if peers::is_blocked "$name" || block::has_file "$name"; then
log::wg_warning "Client is already blocked: ${name}"
return 0
fi
@ -101,33 +104,38 @@ function cmd::block::run() {
local client_ip
client_ip=$(peers::get_ip "$name") || return 1
# $quiet || log::section "Blocking client: ${name} (${client_ip})"
# No specific target — block everything
# Only full block if no specific targets provided
if [[ ${#ips[@]} -eq 0 && ${#ports[@]} -eq 0 && ${#subnets[@]} -eq 0 ]]; then
cmd::block::_block_all "$name" "$client_ip" "$quiet"
return 0
fi
# Specific rules — don't full block
block::set_direct "$name" "$client_ip" "false" # ensure not marked as full block
# Block specific IPs
for ip in "${ips[@]}"; do
ip::require_valid "$ip"
fw::block_ip "$client_ip" "$ip"
fw::save_block "$name" "$client_ip" "$ip"
block::add_rule "$name" "$client_ip" "ip" "" "$ip"
done
# Block specific subnets
for subnet in "${subnets[@]}"; do
ip::require_valid "$subnet"
fw::block_subnet "$client_ip" "$subnet"
fw::save_block "$name" "$client_ip" "$subnet"
block::add_rule "$name" "$client_ip" "subnet" "" "$target_ip"
done
# Block specific ports
for entry in "${ports[@]}"; do
local target port proto
IFS=":" read -r target port proto <<< "$entry"
proto="${proto:-tcp}"
ip::require_valid "$target"
fw::block_port "$client_ip" "$target" "$port" "$proto"
fw::save_block "$name" "$client_ip" "$target" "$port" "$proto"
fw::block_port "$client_ip" "$target" "$port" "${proto:-tcp}"
block::add_rule "$name" "$client_ip" "port" "" "$target" "$port" "${proto:-tcp}"
done
log::debug "Block rules applied for: ${name}"
@ -144,27 +152,15 @@ function cmd::block::_get_endpoint() {
}
function cmd::block::_block_all() {
local name="${1:-}"
local client_ip="${2:-}"
local name="${1:?name required}"
local client_ip="${2:?client_ip required}"
local quiet="${3:-false}"
[[ -z "$name" ]] && log::error "name required" && return 1
[[ -z "$client_ip" ]] && log::error "client_ip required" && return 1
# Apply fw rules and remove from server
block::apply_full "$name" "$client_ip"
local public_key endpoint
public_key=$(keys::public "$name") || return 1
endpoint=$(cmd::block::_get_endpoint "$name" "$public_key")
fw::block_all "$client_ip" "$name"
fw::save_block "$name" "$client_ip"
if [[ -n "$endpoint" ]]; then
monitor::unwatch "$client_ip"
monitor::watch "$endpoint" "$name"
fi
peers::remove_from_server "$name"
peers::reload
# Mark as directly blocked
block::set_direct "$name" "$client_ip" "true"
$quiet || log::wg_success "${name} has been blocked."
}

View file

@ -21,26 +21,27 @@ function cmd::fw::help() {
cat <<EOF
Usage: wgctl fw [subcommand] [options]
Inspect and manage firewall rules.
Inspect iptables firewall rules applied by wgctl.
Subcommands:
list Show FORWARD chain rules (default)
nat Show NAT/PREROUTING rules
list (default) Show FORWARD chain rules
nat Show NAT/PREROUTING rules (DNS redirects)
flush-nat Flush NAT rules for a subnet
count Show rule counts by type
count Show rule counts by type (ACCEPT/DROP/NFLOG)
Options for list:
--peer <name> Filter by peer name
--rule <rule> Filter by rule name (shows all peers with that rule)
--no-nflog Hide NFLOG rules
--peer <name> Filter rules for a specific peer
--rule <rule> Filter by rule — shows all peers with that rule
--no-nflog Hide NFLOG logging rules
--no-accept Hide ACCEPT rules
--no-drop Hide DROP rules
Examples:
wgctl fw list
wgctl fw
wgctl fw list --peer phone-nuno
wgctl fw list --rule guest
wgctl fw list --no-nflog
wgctl fw list --peer phone-nuno --no-nflog
wgctl fw nat
wgctl fw count
wgctl fw flush-nat --subnet 10.1.103.0/24
@ -93,16 +94,8 @@ function cmd::fw::list() {
log::section "Firewall Rules (FORWARD) — rule: ${rule}"
printf "\n"
local found=false
while IFS= read -r peer_name; do
local ip
ip=$(peers::get_ip "$peer_name")
[[ -z "$ip" ]] && continue
printf " \033[0;37m── %s (%s)\033[0m\n" "$peer_name" "$ip"
iptables -L FORWARD -n -v | grep -F "$ip" \
| cmd::fw::_print_filtered "$show_nflog" "$show_accept" "$show_drop" || true
found=true
done < <(peers::with_rule "$rule")
$found || log::wg_warning "No peers found with rule: ${rule}"
fw::list_peer_rules "$ip" "$show_nflog"
printf "\n"
return 0
fi

View file

@ -22,39 +22,47 @@ function cmd::group::help() {
cat <<EOF
Usage: wgctl group <subcommand> [options]
Manage peer groups.
Manage peer groups. Groups are organizational — a peer can belong
to multiple groups. Operations like block/unblock act on all peers in a group.
Subcommands:
list, ls List all groups
show Show group details and members
list, ls List all groups with status
show Show group members and their status
add, new, create Create a new group
remove, rm, del Remove a group definition
remove, rm, del Remove a group definition (not the peers)
rename Rename a group
peer add Add a peer to a group
peer remove, peer rm Remove a peer from a group
rm-peers Remove all peers from WireGuard server
rm-peers Remove all peers in group from WireGuard
block Block all peers in group
unblock Unblock all peers in group
rule assign Assign a rule to all peers in group
audit Audit all peers in group
logs Show logs for all peers in group
audit Audit firewall rules for all peers in group
logs Show activity logs for all peers in group
watch Live monitor for all peers in group
Options:
--name <name> Group name
--desc <description> Group description
--desc <description> Group description (for add)
--peer <peer> Peer name
--type <type> Peer device type (for peer resolution)
--type <type> Peer device type (optional)
--rule <rule> Rule name (for rule assign)
--new-name <name> New name (for rename)
--new-name <name> New group name (for rename)
--limit <n> Max log entries per peer (for logs)
--force Skip confirmation prompts
Examples:
wgctl group list
wgctl group add --name family --desc "Family devices"
wgctl group peer add --name family --peer phone-nuno
wgctl group peer remove --name family --peer phone-nuno
wgctl group show --name family
wgctl group block --name family
wgctl group unblock --name family
wgctl group rule assign --name family --rule user
wgctl group audit --name family
wgctl group logs --name family --limit 20
wgctl group watch --name family
EOF
}
@ -585,37 +593,28 @@ function cmd::group::block() {
return 0
fi
# log::section "Blocking group: ${name}"
local count=0 skipped=0 blocked_names=()
local filtered=()
for p in "${peers_list[@]:-}"; do
[[ -n "$p" ]] && filtered+=("$p")
done
[[ ${#filtered[@]} -eq 0 ]] && log::wg_warning "Group '${name}' has no peers" && return 0
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
fi
( core::set_quiet; load_command block; cmd::block::run --name "$peer_name" --force )
blocked_names+=("$peer_name")
for peer_name in "${filtered[@]}"; do
if cmd::group::_block_peer "$peer_name" "$name"; then
(( count++ )) || true
else
(( skipped++ )) || true
fi
done
[[ "$count" -eq 0 ]] && return 0
# if [[ "$count" -gt 0 ]]; then
# printf "\n"
# for n in "${blocked_names[@]}"; do
# log::wg " Blocked: ${n}"
# done
# fi
# log::wg_block "Blocked ${count} peers in group '${name}'"
if [[ "$count" -gt 0 ]]; then
log::wg_block "All peers from ${name} have been blocked (${count} peers)."
fi
if [[ "$skipped" -gt 0 ]]; then
log::wg_warning "${skipped} peers already blocked"
fi
}
function cmd::group::unblock() {
@ -635,38 +634,114 @@ function cmd::group::unblock() {
local peers_list=()
mapfile -t peers_list < <(group::peers "$name")
[[ -z "${peers_list[0]}" ]] && log::wg_warning "Group '${name}' has no peers" && return 0
# log::section "Unblocking group: ${name}"
local filtered=()
for p in "${peers_list[@]:-}"; do [[ -n "$p" ]] && filtered+=("$p"); done
[[ ${#filtered[@]} -eq 0 ]] && log::wg_warning "Group '${name}' has no peers" && return 0
local count=0 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
local count=0 skipped=0
if ! peers::is_blocked "$peer_name"; then
log::wg_warning "${peer_name} — not blocked"
continue
fi
( core::set_quiet; load_command unblock; cmd::unblock::run --name "$peer_name" --force )
unblocked_names+=("$peer_name")
for peer_name in "${filtered[@]}"; do
if cmd::group::_unblock_peer "$peer_name" "$name"; then
(( count++ )) || true
else
(( skipped++ )) || true
fi
done
[[ "$count" -eq 0 ]] && return 0
if [[ "$count" -gt 0 ]]; then
log::wg_unblock "All peers from ${name} have been unblocked."
fi
# if [[ "$count" -gt 0 ]]; then
# printf "\n"
# for n in "${blocked_names[@]}"; do
# log::wg " Unblocked: ${n}"
# done
# fi
if [[ "$skipped" -gt 0 ]]; then
log::wg_warning "${skipped} peer(s) remain blocked (blocked directly or by other groups)"
fi
}
log::wg_unblock "All peers from ${name} have been unblocked (${count} peers)."
function cmd::group::_block_peer() {
local peer_name="${1:-}" group_name="${2:-}"
if ! group::_peer_exists_check "$peer_name"; then
log::wg_warning "Peer '${peer_name}' no longer exists — skipping"
return 0
fi
local client_ip
client_ip=$(peers::get_ip "$peer_name")
# Check if already blocked by this group
local current_blocked_groups
current_blocked_groups=$(block::get_groups "$peer_name")
local IFS=','
for g in $current_blocked_groups; do
if [[ "$g" == "$group_name" ]]; then
log::wg_warning "${peer_name} — already blocked by group '${group_name}'"
return 1
fi
done
# Add group to block tracking
block::add_group "$peer_name" "$client_ip" "$group_name"
# Apply fw rules only if peer is still in WG server (not yet blocked)
if peers::exists_in_server "$peer_name"; then
block::apply_full "$peer_name" "$client_ip"
fi
}
function cmd::group::_unblock_peer() {
local peer_name="${1:-}" group_name="${2:-}"
if ! group::_peer_exists_check "$peer_name"; then
log::wg_warning "Peer '${peer_name}' no longer exists — skipping"
return 1
fi
# Check if blocked by this group at all
if ! block::has_file "$peer_name"; then
log::wg_warning "${peer_name} — not blocked"
return 1
fi
local current_groups
current_groups=$(block::get_groups "$peer_name")
if [[ "$current_groups" != *"$group_name"* ]]; then
log::wg_warning "${peer_name} — not blocked by group '${group_name}'"
return 1
fi
local client_ip
client_ip=$(peers::get_ip "$peer_name")
block::remove_group "$peer_name" "$client_ip" "$group_name"
if block::is_blocked "$peer_name"; then
local groups
groups=$(block::get_groups "$peer_name")
local direct
direct=$(block::is_blocked_direct "$peer_name")
if [[ "$direct" == "true" ]]; then
log::wg_warning "${peer_name} — still blocked directly, skipping"
else
log::wg_warning "${peer_name} — still blocked by group(s): ${groups}, skipping"
fi
return 1
fi
log::debug "_unblock_peer: restoring $peer_name"
block::restore_peer "$peer_name" "$client_ip"
block::remove_file "$peer_name"
local rule
rule=$(peers::get_meta "$peer_name" "rule")
[[ -n "$rule" ]] && rule::exists "$rule" && \
rule::apply "$rule" "$client_ip" "$peer_name"
log::debug "_unblock_peer: done"
}
# ============================================

View file

@ -9,22 +9,24 @@ function cmd::inspect::on_load() {
function cmd::inspect::help() {
cat <<EOF
Usage: wgctl inspect --name <name> [--type <type>]
Usage: wgctl inspect --name <name> [options]
wgctl inspect <full-name>
Show detailed information for a single client.
Show detailed information for a WireGuard client including
status, rule with inheritance, groups, firewall rules, and activity.
Options:
--name <name> Client name
--type <type> Device type (optional, combines with --name)
--config Show raw client config
--qr Show QR code
--name <name> Client name (e.g. phone-nuno)
--type <type> Device type — combines with --name (e.g. --name nuno --type phone)
--config Also show raw WireGuard client config
--qr Also show QR code
Examples:
wgctl inspect --name phone-nuno
wgctl inspect --name nuno --type phone
wgctl inspect --name phone-nuno --config
wgctl inspect --name phone-nuno --qr
wgctl inspect guest-zephyr
EOF
}
@ -70,61 +72,205 @@ function cmd::inspect::_peer_info() {
local subtype
subtype=$(peers::get_meta "$name" "subtype")
local rule_extends=""
if [[ -n "$rule" ]]; then
local rule_file
rule_file="$(rule::path "$rule" 2>/dev/null)" || true
if [[ -n "$rule_file" ]]; then
local ext=()
mapfile -t ext < <(json::get "$rule_file" "extends" 2>/dev/null || true)
if [[ ${#ext[@]} -gt 0 && -n "${ext[0]:-}" ]]; then
rule_extends=" (↳ ${ext[*]})"
fi
fi
fi
# Rule formatting
local rule_display="${rule:-}"
if [[ -n "$rule_file" && ${#ext[@]} -gt 0 && -n "${ext[0]:-}" ]]; then
local extends_str
extends_str=$(printf '%s, ' "${ext[@]}" | sed 's/, $//')
rule_display="${rule} ↳ (${extends_str})"
fi
cmd::inspect::_section "Client"
ui::row "Name" "$name"
ui::row "IP" "$ip"
ui::row "Type" "$(peers::display_type "$type" "$subtype")"
ui::row "Rule" "${rule:-}"
ui::row "Rule" "$rule_display"
ui::row "Status" "$(echo -e "$status")"
ui::row "Endpoint" "${endpoint:-}"
ui::row "Last seen" "$last_seen"
ui::row "AllowedIPs" "$allowed_ips"
ui::row "Public key" "${public_key:-}"
ui::row "Activity" "Total: $activity_total | Current: $activity_current"
ui::row "Activity (total)" "$activity_total"
ui::row "Activity (current)" "$activity_current"
return 0
}
function cmd::inspect::_rule_info() {
local name="$1"
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="$(ctx::rule::path "${rule}.rule")"
rule_file="$(rule::path "$rule")"
ui::section "Rule: ${rule}"
# Check for inheritance
local extends_raw=()
mapfile -t extends_raw < <(json::get "$rule_file" "extends" 2>/dev/null || true)
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}"
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
printf " \033[0;32m+\033[0m %s\n" "$e"
done <<< "$base_allows"
while IFS= read -r e; do
[[ -z "$e" ]] && continue
printf " \033[0;31m-\033[0m %s\n" "$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
printf " \033[0;32m+\033[0m %s\n" "$e"
done <<< "$own_allows"
while IFS= read -r e; do
[[ -z "$e" ]] && continue
printf " \033[0;31m-\033[0m %s\n" "$e"
done <<< "$own_blocks"
fi
else
# No inheritance — flat view
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")
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 " %-20s\n" "Allows:"
ui::print_list "+" "$allow_ports"
ui::print_list "+" "$allow_ips"
else
ui::row "Allows" "—"
printf "\n"
while IFS= read -r e; do
[[ -z "$e" ]] && continue
printf " \033[0;32m+\033[0m %s\n" "$e"
done <<< "$allow_ports"$'\n'"$allow_ips"
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" "—"
printf "\n"
while IFS= read -r e; do
[[ -z "$e" ]] && continue
printf " \033[0;31m-\033[0m %s\n" "$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::_blocks_info() {
local name="${1:-}"
block::has_file "$name" || return 0
cmd::inspect::_section "Peer Blocks"
local blocked_direct
blocked_direct=$(block::is_blocked_direct "$name")
[[ "$blocked_direct" == "true" ]] && \
printf " \033[1;31m🚫\033[0m blocked directly\n"
local blocked_groups
blocked_groups=$(block::get_groups "$name")
[[ -n "$blocked_groups" ]] && \
printf " \033[1;31m🚫\033[0m blocked by groups: %s\n" "$blocked_groups"
block::format_rules "$name"
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"
@ -144,30 +290,40 @@ function cmd::inspect::_group_info() {
count=$(json::count "$(group::path "$g")" "peers")
printf " %-20s %s peers\n" "$g" "$count"
done
return 0
}
function cmd::inspect::_firewall_info() {
local name="$1" show_nflog="${2:-false}"
local name="${1:-}"
local ip
ip=$(peers::get_ip "$name")
ui::section "Firewall"
local total=0 accepts=0 drops=0
local rules_output=()
while IFS= read -r line; do
[[ -z "$line" ]] && continue
(( total++ )) || true
[[ "$line" =~ ACCEPT ]] && (( accepts++ )) || true
[[ "$line" =~ DROP ]] && (( drops++ )) || true
rules_output+=("$line")
done < <(fw::forward_rules_for_ip "$ip" | grep -v NFLOG)
local count=0
while IFS=":" read -r pname pcount; do
[[ "$pname" == "$name" ]] && count="$pcount" && break
done < <(json::audit_fw_counts "$(ctx::clients)")
# printf "\n \033[0;37m── Firewall (\033[0;32m+%d\033[0m \033[0;31m-%d\033[0m) \033[0m%s\n" \
# "$accepts" "$drops" "$(printf '─%.0s' {1..28})"
ui::row "Active rules" "$count"
printf "\n \033[0;37m── Firewall (%s %s) \033[0m%s\n\n" \
"$(color::green "+${accepts}")" \
"$(color::red "-${drops}")" \
"$(printf '─%.0s' {1..28})"
if [[ "$count" -gt 0 ]]; then
printf "\n"
iptables -L FORWARD -n </dev/null | grep -F "$ip" | while IFS= read -r rule; do
[[ -z "$rule" ]] && continue
echo "$rule" | grep -q "NFLOG" && continue # skip NFLOG
ui::firewall_rule "$rule"
if [[ ${#rules_output[@]} -gt 0 ]]; then
for line in "${rules_output[@]}"; do
fw::format_rule "$line"
done
fi
return 0
}
function cmd::inspect::_config() {
@ -176,6 +332,8 @@ function cmd::inspect::_config() {
printf "\n"
cat "$(ctx::clients)/${name}.conf"
printf "\n"
return 0
}
# ============================================
@ -221,6 +379,7 @@ function cmd::inspect::run() {
cmd::inspect::_rule_info "$name"
cmd::inspect::_group_info "$name"
cmd::inspect::_firewall_info "$name"
cmd::inspect::_blocks_info "$name"
if $show_config; then
cmd::inspect::_config "$name"

View file

@ -157,19 +157,14 @@ function cmd::list::show_client() {
last_seen=$(peers::format_last_seen "$name" "$public_key" \
"$is_blocked" "$last_ts" "" "$handshake_ts")
local block_file
block_file="$(ctx::block::path "${name}.block")"
local blocks=""
if [[ -f "$block_file" ]] && [[ -s "$block_file" ]]; then
while IFS=" " read -r client_ip target port proto; do
if [[ -z "$target" ]]; then
blocks+=" all traffic blocked\n"
else
local rule=" ${target}"
[[ -n "$port" ]] && rule+=":${port}/${proto}"
blocks+="${rule}\n"
if block::has_file "$name" && block::is_blocked "$name"; then
if [[ "$(block::is_blocked_direct "$name")" == "true" ]]; then
blocks="all traffic blocked"
fi
done < "$block_file"
local rule_lines
rule_lines=$(block::format_rules "$name")
[[ -n "$rule_lines" ]] && blocks+="$rule_lines"
fi
ui::section "Client: ${name}"
@ -389,8 +384,11 @@ function cmd::list::_precompute_all() {
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
if block::is_blocked "$name" 2>/dev/null; then
p_restricted["$name"]=true
else
p_restricted["$name"]=false
fi
local pubkey
pubkey=$(keys::public "$name" 2>/dev/null || echo "")
if [[ -n "$pubkey" ]] && ! echo "$wg_peers" | grep -qF "$pubkey"; then
@ -434,6 +432,32 @@ function cmd::list::_precompute_all() {
done < <(json::peer_transfer "$(config::interface)")
}
function cmd::list::_precompute_block_status() {
local -n _blocked="$1"
local -n _restricted="$2"
local wg_peers
wg_peers=$(wg show "$(config::interface)" peers 2>/dev/null)
while IFS= read -r name; do
# Restricted = has block rules but still in server (partial block)
if block::is_blocked "$name" 2>/dev/null; then
_restricted["$name"]=true
else
_restricted["$name"]=false
fi
# Blocked = removed from WG server
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)
}
function cmd::list::_build_filter_desc() {
filter_desc=""
[[ -n "$filter_type" ]] && filter_desc+="type=${filter_type} "

View file

@ -14,6 +14,7 @@ function cmd::logs::on_load() {
flag::register --all
flag::register --before
flag::register --force
flag::register --days
}
function cmd::logs::help() {
@ -25,6 +26,7 @@ Show or manage WireGuard and firewall activity logs.
Subcommands:
show (default) Show activity logs
remove, rm Remove log entries
rotate Remove entries older than N days
Options for show:
--name <name> Filter by client name
@ -42,6 +44,10 @@ Options for remove:
--before <days> Remove entries older than N days
--force Skip confirmation
Options for rotate:
--days <n> Days to keep (default: 7)
--force Skip confirmation
Examples:
wgctl logs
wgctl logs --name phone-nuno
@ -49,8 +55,9 @@ Examples:
wgctl logs --follow
wgctl logs remove --name phone-nuno
wgctl logs remove --all --force
wgctl logs remove --before 7
wgctl logs remove --fw --before 1
wgctl logs rotate
wgctl logs rotate --days 30 --force
EOF
}
@ -65,6 +72,7 @@ function cmd::logs::run() {
case "$subcmd" in
show) cmd::logs::show "$@" ;;
remove|rm|del) cmd::logs::remove "$@" ;;
rotate) cmd::logs::rotate "$@" ;;
help) cmd::logs::help ;;
*)
log::error "Unknown subcommand: '${subcmd}'"
@ -278,3 +286,42 @@ function cmd::logs::show_fw_events() {
$found || printf " —\n"
printf "\n"
}
function cmd::logs::rotate() {
local days=7 force=false
while [[ $# -gt 0 ]]; do
case "$1" in
--days) days="$2"; shift 2 ;;
--force) force=true; shift ;;
--help) cmd::logs::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
$force || {
read -r -p "Remove log entries older than ${days} days? [y/N] " confirm
case "$confirm" in
[yY]*) ;;
*) log::info "Aborted"; return 0 ;;
esac
}
local result
result=$(json::remove_events_filtered \
"$WG_EVENTS_LOG" "$FW_EVENTS_LOG" \
"" "" "false" "false" "$days")
local removed_wg removed_fw
IFS="|" read -r removed_wg removed_fw <<< "$result"
local total=$(( removed_wg + removed_fw ))
if [[ "$total" -eq 0 ]]; then
log::wg_warning "No log entries older than ${days} days"
return 0
fi
log::wg_success "Rotated ${total} entries older than ${days} days (wg: ${removed_wg}, fw: ${removed_fw})"
}

View file

@ -96,7 +96,7 @@ function cmd::remove::_cleanup() {
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
block::remove_file "$name" 2>/dev/null || true
peers::remove_meta "$name" 2>/dev/null || true
peers::reload || return 1
}

View file

@ -106,9 +106,7 @@ function cmd::rename::_rename_files() {
sed -i "s/^# ${name}$/# ${new_name}/" "$(config::config_file)"
local block_file
block_file="$(ctx::block::path "${name}.block")"
[[ -f "$block_file" ]] && mv "$block_file" "$(ctx::block::path "${new_name}.block")"
block::rename "$name" "$new_name"
local old_meta new_meta
old_meta=$(peers::meta_path "$name")

View file

@ -28,6 +28,7 @@ function cmd::rule::on_load() {
flag::register --resolved
flag::register --force
flag::register --type
flag::register --all
}
# ============================================
@ -38,67 +39,71 @@ function cmd::rule::help() {
cat <<EOF
Usage: wgctl rule <subcommand> [options]
Manage firewall rules for peers.
Manage firewall rules with inheritance support.
Rules can extend base rules to compose reusable access policies.
Subcommands:
list, ls List all rules
show Show rule details
inspect Show full inheritance tree
show, inspect Show rule details and inheritance
add, new, create Create a new rule
update, edit Update a rule and re-apply to all peers
update, edit Update a rule and re-apply to peers
remove, rm, del Remove a rule
assign Assign a rule to a peer
unassign Remove rule from a peer
migrate Apply default rules to all unassigned peers
reapply Re-apply a rule to all assigned peers
reapply Re-apply rule to all assigned peers
migrate Apply default rules to unassigned peers
Options for list:
--base Show base rules section
--no-base Hide base rules section (default shows them)
--group <name> Filter by group name
--base Show only base rules
--no-base Hide base rules section
--group <name> Filter by group (case insensitive)
--tree Show full inheritance tree inline
Options for add/update:
Options for add:
--name <name> Rule name
--desc <description> Human readable description
--group <group> Display group (e.g. vm-rules, user-rules)
--extends <rule,...> Inherit from base rules (add only)
--add-extends <rule,...> Add base rules (update only)
--remove-extends <rule,...> Remove base rules (update only)
--desc <description> Description
--group <group> Display group (e.g. VM Rules, Users)
--extends <rule,...> Inherit from base rules (comma-separated)
--base Create as base rule (not directly assignable)
--allow-ip <ip/cidr> Allow IP or subnet (repeatable)
--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)
--dns-redirect Force DNS through Pi-hole
Options for update:
(same as add, plus:)
--add-extends <rule,...> Add inherited base rules
--remove-extends <rule,...> Remove inherited base rules
--remove-allow-ip <ip> Remove allow IP entry
--remove-allow-port <entry> Remove allow port entry
--remove-block-ip <ip> Remove block IP entry
--remove-block-port <entry> Remove block port entry
Options for inspect:
Options for show/inspect:
--name <name> Rule name
--peers Show assigned peers
--resolved Show resolved/merged rule entries
--resolved Show resolved/merged entries
--no-peers Hide assigned peers
Options for assign/unassign:
--name <rule> Rule name
--peer <peer> Peer name
--type <type> Peer device type (optional)
Options for reapply:
--name <name> Rule name
--all Reapply all rules
Examples:
wgctl rule list
wgctl rule list --base
wgctl rule list --group vm-rules
wgctl rule list --tree
wgctl rule list --group "VM Rules"
wgctl rule list --base
wgctl rule show --name guest
wgctl rule inspect --name moonlight-02
wgctl rule inspect --name moonlight-02 --resolved --peers
wgctl rule add --name dev-01 --desc "Dev VM" --group vm-rules --extends no-lan
wgctl rule update --name dev-01 --add-extends no-nginx
wgctl rule update --name dev-01 --remove-extends no-nginx
wgctl rule update --name dev-01 --group infra-rules
wgctl rule show --name moonlight-02 --resolved
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 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
}
@ -507,81 +512,6 @@ function cmd::rule::show() {
printf "\n"
}
# ============================================
# Inspect
# ============================================
function cmd::rule::inspect() {
local name="" show_peers=false show_resolved=false
while [[ $# -gt 0 ]]; do
case "$1" in
--name) util::require_flag "--name" "${2:-}" || return 1
name="$2"; shift 2 ;;
--peers) show_peers=true; shift ;;
--resolved) show_resolved=true; shift ;;
--help) cmd::rule::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
[[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
rule::require_exists "$name" || return 1
log::section "Rule Inspect: ${name}"
local prev_section=""
local show_resolved_flag="$show_resolved"
while IFS="|" read -r section key value; do
[[ -z "$section" ]] && continue
# Skip resolved section unless requested
if [[ "$section" == "resolved" ]] && ! $show_resolved_flag; then
continue
fi
if [[ "$section" != "$prev_section" ]]; then
case "$section" in
own) cmd::rule::_show_section "Own Rules" ;;
dns) cmd::rule::_show_section "DNS" ;;
resolved) cmd::rule::_show_section "Resolved (applied)" ;;
inherited:*)
local base_name="${section#inherited:}"
cmd::rule::_show_section "Inherited: ${base_name}" ;;
esac
prev_section="$section"
fi
case "$key" in
allow_ip|allow_port)
printf " \033[0;32m+\033[0m %s\n" "$value" ;;
block_ip|block_port)
printf " \033[0;31m-\033[0m %s\n" "$value" ;;
dns_redirect)
printf " Redirect all DNS → %s\n" "$(config::dns)" ;;
esac
done < <(json::rule_inspect "$(ctx::rules)" "$name")
if $show_peers; then
cmd::rule::_show_section "Peers" "white" false
local peer_list=()
mapfile -t peer_list < <(peers::with_rule "$name")
ui::row "Assigned" "${#peer_list[@]}"
if [[ ${#peer_list[@]} -gt 0 ]]; then
printf "\n"
for peer_name in "${peer_list[@]}"; do
local ip
ip=$(peers::get_ip "$peer_name")
printf " %-28s %s\n" "$peer_name" "$ip"
done
fi
fi
printf "\n"
}
# ============================================
# Add
# ============================================
@ -868,14 +798,33 @@ function cmd::rule::migrate() {
}
function cmd::rule::reapply() {
local name=""
local name="" all=false
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--all) all=true; shift ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
[[ -z "$name" ]] && log::error "Missing --name" && return 1
if $all; then
log::section "Reapplying all rules"
local count=0
while IFS= read -r rule_file; do
local rname
rname=$(basename "$rule_file" .rule)
# Skip if no peers assigned
local peer_list=()
mapfile -t peer_list < <(peers::with_rule "$rname")
[[ ${#peer_list[@]} -eq 0 ]] && continue
rule::reapply_all "$rname"
(( count++ )) || true
done < <(find "$(ctx::rules)" -maxdepth 1 -name "*.rule")
log::wg_success "Reapplied ${count} assignable rules"
return 0
fi
[[ -z "$name" ]] && log::error "Missing --name or --all" && return 1
rule::require_exists "$name" || return 1
rule::reapply_all "$name"
log::wg_success "Rule '${name}' reapplied"

View file

@ -59,6 +59,12 @@ function cmd::service::run() {
function cmd::service::start() {
log::wg_start "Starting WireGuard..."
systemctl start "wg-quick@$(config::interface)"
block::restore_all
rule::restore_all
cmd::service::_auto_rotate_logs
log::wg_success "WireGuard started"
}
@ -72,18 +78,26 @@ function cmd::service::restart() {
log::wg_start "Restarting WireGuard..."
# Flush firewall rules before restart so restore starts clean
iptables -F FORWARD
iptables -t nat -F PREROUTING
fw::flush_all
systemctl restart "wg-quick@$(config::interface)"
fw::restore_blocks
block::restore_all
rule::restore_all
cmd::service::_auto_rotate_logs
log::wg_success "WireGuard restarted"
}
function cmd::service::reload() {
log::wg_start "Reloading WireGuard config..."
peers::reload
fw::restore_blocks
block::restore_all
rule::restore_all
log::wg_success "WireGuard config reloaded"
}
function cmd::service::status() {
@ -102,6 +116,18 @@ function cmd::service::logs() {
journalctl -u "wg-quick@$(config::interface)" -f --no-pager
}
function cmd::service::_auto_rotate_logs() {
local max_size=10485760 # 10MB
local fw_size wg_size
fw_size=$(stat -c%s "$(ctx::fw_events_log)" 2>/dev/null || echo 0)
wg_size=$(stat -c%s "$(ctx::events_log)" 2>/dev/null || echo 0)
if (( fw_size > max_size || wg_size > max_size )); then
log::wg_warning "Log files exceed 10MB, auto-rotating (keeping 7 days)..."
cmd::logs::rotate --days 7 --force
fi
}
function cmd::service::enable() {
systemctl enable "wg-quick@$(config::interface)"
log::wg_success "WireGuard enabled on boot"

View file

@ -14,12 +14,11 @@ function cmd::shell::_prompt() {
}
function cmd::shell::_is_wgctl_command() {
local cmd="$1"
# Check against known wgctl commands
local cmd="${1:-}"
local known=(
list add remove rm inspect block unblock
rule group audit logs watch fw config qr
rename keys ip service shell help
rename keys ip service shell help test
)
local c
for c in "${known[@]}"; do
@ -29,7 +28,7 @@ function cmd::shell::_is_wgctl_command() {
}
function cmd::shell::_handle_builtin() {
local input="$1"
local input="${1:-}"
local first="${input%% *}"
case "$first" in
@ -39,36 +38,32 @@ function cmd::shell::_handle_builtin() {
cd "$dir" 2>/dev/null || log::error "cd: $dir: No such file or directory"
return 0
;;
cd*) eval "$input" ;; # eval preserves shell state for cd
export|unset|source|.)
eval "$input"
return 0
;;
esac
return 1 # not a builtin
return 1
}
function cmd::shell::_execute() {
local input="$1"
local input="${1:-}"
local first="${input%% *}"
local rest="${input#"$first"}"
rest="${rest# }"
# Handle shell builtins first
cmd::shell::_handle_builtin "$input" && return 0
# Try as wgctl command via dispatcher
if cmd::shell::_is_wgctl_command "$first"; then
if [[ -n "$rest" ]]; then
wgctl::dispatch "$first" $rest || true # never exit REPL on failure
wgctl::dispatch "$first" $rest || true
else
wgctl::dispatch "$first" || true
fi
return 0 # Always 0 to keep REPL running
return 0
fi
# Fall back to bash
bash -c "$input" || true # same for bash commands
bash -c "$input" || true
}
function cmd::shell::_setup_history() {
@ -85,14 +80,30 @@ function cmd::shell::_save_history() {
function cmd::shell::_banner() {
ui::section "wgctl shell"
printf "\n"
printf " Type wgctl commands directly, or any bash command.\n"
printf " Type \033[1mexit\033[0m or \033[1mquit\033[0m to leave.\n"
printf " Type \033[1mhelp\033[0m for wgctl commands.\n"
printf "\n"
printf " Type wgctl commands directly (no 'wgctl' prefix).\n"
printf " Bash commands work too: ls, cat, systemctl, vim...\n\n"
printf " \033[1;37mCommon commands:\033[0m\n"
printf " list List all peers\n"
printf " list --blocked Show blocked 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 " rule list Show firewall rules\n"
printf " rule list --tree Show with inheritance tree\n"
printf " rule show --name <rule> Rule details\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"
printf " logs rotate Clean old log entries\n"
printf " watch Live WG + firewall monitor\n"
printf " fw list Show iptables rules\n"
printf " audit Verify firewall state\n"
printf " audit --fix Auto-repair firewall rules\n\n"
printf " \033[1mexit\033[0m or \033[1mquit\033[0m to leave · \033[1mhelp\033[0m for full command list\n\n"
}
# ============================================
# Run
# Lifecycle
# ============================================
function cmd::shell::on_load() {
@ -103,62 +114,65 @@ function cmd::shell::help() {
cat <<EOF
Usage: wgctl shell
Start an interactive wgctl shell. Supports all wgctl commands
directly (no 'wgctl' prefix needed), plus any bash command.
Start an interactive wgctl shell.
All wgctl commands work directly (no 'wgctl' prefix needed).
Bash commands (ls, cat, systemctl, vim, etc.) also work.
Builtins handled: cd, export, unset, source
Everything else: passed to bash
Shell builtins handled natively: cd, export, unset, source
History saved to: ~/.wgctl_history
Examples:
wgctl shell
wgctl> list
wgctl> list --blocked
wgctl> inspect --name phone-nuno
wgctl> ls /etc/wireguard
wgctl> rule list --tree
wgctl> group block --name family
wgctl> logs --follow
wgctl> ls /etc/wireguard/.wgctl/rules/
wgctl> exit
EOF
}
function cmd::shell::run() {
cmd::shell::_banner
cmd::shell::_setup_history
while true; do
local input
# Read with readline support (-e) and custom prompt
IFS= read -r -e -p "$(cmd::shell::_prompt)" input || break
# Handle empty input
[[ -z "${input// }" ]] && continue
# Add to history
history -s "$input"
# Handle exit
case "${input%% *}" in
exit|quit) break ;;
esac
# Execute
cmd::shell::_execute "$input"
done
cmd::shell::_save_history
printf "\n Goodbye!\n\n"
}
# ============================================
# Tab completion (loaded when shell starts)
# Tab completion
# ============================================
function cmd::shell::_setup_completion() {
local commands="list add remove inspect block unblock rule group audit logs watch fw config qr rename shell help"
local commands="list add remove rm inspect block unblock rule group audit logs watch fw config qr rename service shell help test"
function _wgctl_shell_complete() {
local cur="${COMP_WORDS[COMP_CWORD]}"
COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
}
# Bind completion to the read prompt
bind 'set show-all-if-ambiguous on' 2>/dev/null || true
bind 'set completion-ignore-case on' 2>/dev/null || true
}
# ============================================
# Run
# ============================================
function cmd::shell::run() {
cmd::shell::_banner
cmd::shell::_setup_history
cmd::shell::_setup_completion
while true; do
local input
IFS= read -r -e -p "$(cmd::shell::_prompt)" input || break
[[ -z "${input// }" ]] && continue
history -s "$input"
case "${input%% *}" in
exit|quit) break ;;
esac
cmd::shell::_execute "$input"
done
cmd::shell::_save_history
printf "\n Goodbye!\n\n"
}

View file

@ -0,0 +1,164 @@
#!/usr/bin/env bash
# ============================================
# Private helpers
# ============================================
function cmd::shell::_prompt() {
local user host dir
user=$(whoami)
host=$(hostname -s)
dir=$(basename "$PWD")
printf "\033[1;32m%s@%s\033[0m:\033[0;36m%s\033[0m \033[1;34mwgctl\033[0m> " \
"$user" "$host" "$dir"
}
function cmd::shell::_is_wgctl_command() {
local cmd="$1"
# Check against known wgctl commands
local known=(
list add remove rm inspect block unblock
rule group audit logs watch fw config qr
rename keys ip service shell help
)
local c
for c in "${known[@]}"; do
[[ "$c" == "$cmd" ]] && return 0
done
return 1
}
function cmd::shell::_handle_builtin() {
local input="$1"
local first="${input%% *}"
case "$first" in
cd)
local dir="${input#cd }"
[[ "$dir" == "$input" ]] && dir="$HOME"
cd "$dir" 2>/dev/null || log::error "cd: $dir: No such file or directory"
return 0
;;
cd*) eval "$input" ;; # eval preserves shell state for cd
export|unset|source|.)
eval "$input"
return 0
;;
esac
return 1 # not a builtin
}
function cmd::shell::_execute() {
local input="$1"
local first="${input%% *}"
local rest="${input#"$first"}"
rest="${rest# }"
# Handle shell builtins first
cmd::shell::_handle_builtin "$input" && return 0
# Try as wgctl command via dispatcher
if cmd::shell::_is_wgctl_command "$first"; then
if [[ -n "$rest" ]]; then
wgctl::dispatch "$first" $rest || true # never exit REPL on failure
else
wgctl::dispatch "$first" || true
fi
return 0 # Always 0 to keep REPL running
fi
# Fall back to bash
bash -c "$input" || true # same for bash commands
}
function cmd::shell::_setup_history() {
HISTFILE="${HOME}/.wgctl_history"
HISTSIZE=1000
HISTFILESIZE=2000
history -r 2>/dev/null || true
}
function cmd::shell::_save_history() {
history -w 2>/dev/null || true
}
function cmd::shell::_banner() {
ui::section "wgctl shell"
printf "\n"
printf " Type wgctl commands directly, or any bash command.\n"
printf " Type \033[1mexit\033[0m or \033[1mquit\033[0m to leave.\n"
printf " Type \033[1mhelp\033[0m for wgctl commands.\n"
printf "\n"
}
# ============================================
# Run
# ============================================
function cmd::shell::on_load() {
: # no flags needed
}
function cmd::shell::help() {
cat <<EOF
Usage: wgctl shell
Start an interactive wgctl shell. Supports all wgctl commands
directly (no 'wgctl' prefix needed), plus any bash command.
Builtins handled: cd, export, unset, source
Everything else: passed to bash
Examples:
wgctl shell
wgctl> list
wgctl> inspect --name phone-nuno
wgctl> ls /etc/wireguard
wgctl> exit
EOF
}
function cmd::shell::run() {
cmd::shell::_banner
cmd::shell::_setup_history
while true; do
local input
# Read with readline support (-e) and custom prompt
IFS= read -r -e -p "$(cmd::shell::_prompt)" input || break
# Handle empty input
[[ -z "${input// }" ]] && continue
# Add to history
history -s "$input"
# Handle exit
case "${input%% *}" in
exit|quit) break ;;
esac
# Execute
cmd::shell::_execute "$input"
done
cmd::shell::_save_history
printf "\n Goodbye!\n\n"
}
# ============================================
# Tab completion (loaded when shell starts)
# ============================================
function cmd::shell::_setup_completion() {
local commands="list add remove inspect block unblock rule group audit logs watch fw config qr rename shell help"
function _wgctl_shell_complete() {
local cur="${COMP_WORDS[COMP_CWORD]}"
COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
}
# Bind completion to the read prompt
bind 'set show-all-if-ambiguous on' 2>/dev/null || true
bind 'set completion-ignore-case on' 2>/dev/null || true
}

View file

@ -166,7 +166,6 @@ function cmd::test::section_rules() {
test::section "Rules"
cmd::test::run_cmd "rule list" "guest" rule list
cmd::test::run_cmd "rule show --name guest" "Description" rule show --name guest
cmd::test::run_cmd "rule show --name guest --peers" "Assigned:" rule show --name guest --peers
cmd::test::run_cmd "rule show --name user" "Description" rule show --name user
cmd::test::run_cmd "rule show --name admin" "Description" rule show --name admin
cmd::test::run_cmd_fails "rule show nonexistent" rule show --name nonexistent
@ -209,14 +208,16 @@ function cmd::test::section_fw() {
function cmd::test::section_destructive() {
test::section "Destructive (modifying state)"
# Cleanup from any previous failed run
# ── Cleanup from any previous failed run ──
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
"$WGCTL_BINARY" group remove --name testgroup --force > /dev/null 2>&1 || true
"$WGCTL_BINARY" group remove --name testgroup2 --force > /dev/null 2>&1 || true
# Add test peer
# ── Add test peer ──────────────────────────
cmd::test::run_cmd "add phone peer" "added successfully" \
add --name testunit --type phone
# Block/unblock
# ── Direct block/unblock ───────────────────
cmd::test::run_cmd "block peer" "blocked" \
block --name phone-testunit
cmd::test::run_cmd "list shows blocked" "blocked" \
@ -224,28 +225,58 @@ function cmd::test::section_destructive() {
cmd::test::run_cmd "unblock peer" "unblocked" \
unblock --name phone-testunit
# Rule assign/unassign
# ── Rule assign/unassign ───────────────────
cmd::test::run_cmd "rule assign" "Assigned" \
rule assign --name user --peer phone-testunit
cmd::test::run_cmd "rule unassign" "Unassigned" \
rule unassign --peer phone-testunit
# Re-assign user rule (default) for cleanup
/usr/local/bin/wgctl rule assign --name user --peer phone-testunit \
"$WGCTL_BINARY" rule assign --name user --peer phone-testunit \
> /dev/null 2>&1 || true
# Group operations
# ── Group basic operations ─────────────────
cmd::test::run_cmd "group add" "created" \
group add --name testgroup --desc "Test group"
cmd::test::run_cmd "group peer add" "Added" \
group peer add --name testgroup --peer phone-testunit
cmd::test::run_cmd "group block" "have been blocked" \
cmd::test::run_cmd "group block" "blocked" \
group block --name testgroup
cmd::test::run_cmd "group unblock" "have been unblocked" \
cmd::test::run_cmd "group unblock" "unblocked" \
group unblock --name testgroup
# ── M:N group block tracking ───────────────
# Setup: add testunit to a second group
"$WGCTL_BINARY" group add --name testgroup2 \
--desc "Test group 2" > /dev/null 2>&1
"$WGCTL_BINARY" group peer add --name testgroup2 \
--peer phone-testunit > /dev/null 2>&1
# Block from both groups
cmd::test::run_cmd "group block first group" "blocked" \
group block --name testgroup
cmd::test::run_cmd "group block second group" "blocked" \
group block --name testgroup2
# Unblock from first — should stay blocked (second group still blocking)
"$WGCTL_BINARY" group unblock --name testgroup > /dev/null 2>&1
cmd::test::run_cmd "peer stays blocked after partial unblock" "blocked" \
list --blocked
# Unblock from second — should now be fully unblocked
"$WGCTL_BINARY" group unblock --name testgroup2 > /dev/null 2>&1
cmd::test::run_cmd "peer unblocked after all groups unblock" "phone-testunit" \
list --allowed
# ── Direct block overrides group block ─────
"$WGCTL_BINARY" group block --name testgroup > /dev/null 2>&1
cmd::test::run_cmd "direct unblock overrides group block" "unblocked" \
unblock --name phone-testunit
# ── Cleanup groups ─────────────────────────
cmd::test::run_cmd "group remove" "removed" \
group remove --name testgroup --force
"$WGCTL_BINARY" group remove --name testgroup2 --force > /dev/null 2>&1 || true
# Remove test peer
# ── Remove test peer ───────────────────────
cmd::test::run_cmd "remove phone peer" "removed" \
remove --name phone-testunit --force
}

View file

@ -85,7 +85,7 @@ function cmd::unblock::run() {
name=$(peers::resolve_and_require "$name" "$type") || return 1
# Check if actually blocked
if ! peers::is_blocked "$name" && [[ ! -f "$(ctx::block::path "${name}.block")" ]]; then
if ! peers::is_blocked "$name" && ! block::has_file "$name"; then
log::wg_warning "Client is not blocked: ${name}"
return 0
fi
@ -108,6 +108,7 @@ function cmd::unblock::run() {
# Unblock specific IPs
for ip in "${ips[@]}"; do
fw::unblock_ip "$client_ip" "$ip"
block::remove_rule "$name" "ip" "$ip"
done
# Unblock specific subnets
@ -124,23 +125,34 @@ function cmd::unblock::run() {
done
$quiet || log::wg_success "Unblock rules applied for: ${name}"
return 0
}
function cmd::unblock::_unblock_all() {
local name="${1:-}"
local client_ip="${2:-}"
local quiet="${3:-false}"
local name="${1:?}" client_ip="${2:?}" quiet="${3:-false}"
fw::unblock_all "$client_ip"
fw::remove_block_file "$name"
monitor::unwatch_client "$name"
# Direct unblock overrides everything — clear all block state
block::set_direct "$name" "$client_ip" "false"
if ! peers::exists_in_server "$name"; then
local public_key
public_key=$(keys::public "$name") || return 1
peers::add_to_server "$name" "$public_key" "$client_ip"
peers::reload
# Force full unblock regardless of group blocks
# (direct unblock = admin override)
block::restore_peer "$name" "$client_ip"
block::remove_file "$name"
local rule
rule=$(peers::get_meta "$name" "rule")
[[ -n "$rule" ]] && rule::exists "$rule" && \
rule::apply "$rule" "$client_ip" "$name"
local groups
groups=$(block::get_groups "$name")
if [[ -n "$groups" ]]; then
log::wg_warning "${name} was blocked by group(s): ${groups} — unblocking anyway"
fi
$quiet || log::wg_success "${name} has been unblocked."
return 0
}

View file

@ -21,16 +21,18 @@ function cmd::watch::help() {
cat <<EOF
Usage: wgctl watch [options]
Live monitor of WireGuard activity.
Shows handshakes from active clients and connection attempts from blocked clients.
Live monitor of WireGuard activity. Shows:
- Handshakes from connected peers (green)
- Connection attempts from blocked peers (red, SOURCE: wg)
- Firewall drops from rule-restricted peers (red, SOURCE: fw)
Options:
--type <type> Filter by device type
--name <name> Filter by client name
--peers <list> Filter by comma-separated peer names (used by group watch)
--allowed Show only allowed client handshakes
--restricted Show only restricted client events
--blocked Show only blocked client attempts
--type <type> Filter by device type
--peers <list> Comma-separated peer names (used internally by group watch)
--blocked Show only blocked peer attempts
--allowed Show only handshakes (allowed peers)
--restricted Show only firewall drop events
Examples:
wgctl watch
@ -38,6 +40,7 @@ Examples:
wgctl watch --allowed
wgctl watch --type phone
wgctl watch --name phone-nuno
wgctl watch --name phone-nuno --type phone
EOF
}
@ -164,6 +167,9 @@ function cmd::watch::tail_events() {
local blocked_only="${3:-false}" restricted_only="${4:-false}"
local allowed_only="${5:-false}" filter_peers="${6:-}"
declare -A _WATCH_LAST_ATTEMPT=()
declare -A _WATCH_LAST_FW=()
local peer_set=()
[[ -n "$filter_peers" ]] && IFS=',' read -ra peer_set <<< "$filter_peers"
@ -259,6 +265,19 @@ function cmd::watch::tail_events() {
[[ -z "$src_ip" ]] && continue
local fw_key="${src_ip}:${dst_ip}:${dst_port}:${proto}"
local now
now=$(date +%s)
local last_fw="${_WATCH_LAST_FW[$fw_key]:-0}"
local window=30
[[ "$proto" == "17" || "$proto" == "udp" ]] && window=10
[[ "$proto" == "1" || "$proto" == "icmp" ]] && window=5
local diff=$(( now - last_fw ))
(( diff < window )) && continue
_WATCH_LAST_FW["$fw_key"]="$now"
local client="${ip_to_name[$src_ip]:-$src_ip}"
[[ -n "$filter_name" && "$client" != "$filter_name" ]] && continue

View file

@ -13,5 +13,6 @@ source "${WGCTL_DIR}/core/command.sh"
source "${WGCTL_DIR}/core/flag.sh"
source "${WGCTL_DIR}/core/json.sh"
source "${WGCTL_DIR}/core/ui.sh"
source "${WGCTL_DIR}/core/color.sh"
source "${WGCTL_DIR}/core/fmt.sh"
source "${WGCTL_DIR}/core/test/test.sh"

15
core/color.sh Normal file
View file

@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Color module to use for display functions (layouts, etc...)
# For loops, avoid them, as they spawn subshells
# Precompute the variables in loops
# local GREEN="\033[0;32m" RED="\033[0;31m" RESET="\033[0m"
# printf "${GREEN}+${accepts}${RESET} ${RED}-${drops}${RESET}\n"
function color::green() { printf "\033[0;32m%s\033[0m" "${1:-}"; }
function color::red() { printf "\033[0;31m%s\033[0m" "${1:-}"; }
function color::gray() { printf "\033[0;37m%s\033[0m" "${1:-}"; }
function color::bold() { printf "\033[1;37m%s\033[0m" "${1:-}"; }
function color::cyan() { printf "\033[0;36m%s\033[0m" "${1:-}"; }
function color::yellow() { printf "\033[1;33m%s\033[0m" "${1:-}"; }

View file

@ -39,6 +39,17 @@ function json::rule_resolve_field() { python3 "$JSON_HELPER" rule_resolve_fi
function json::rule_inspect() { python3 "$JSON_HELPER" rule_inspect "$@" </dev/null; }
function json::find_rule_file() { python3 "$JSON_HELPER" find_rule_file "$@" </dev/null; }
function json::get_raw() { python3 "$JSON_HELPER" get_raw "$@" </dev/null; }
function json::count_resolved() { python3 "$JSON_HELPER" count_resolved "$(ctx::rules)" "$@" </dev/null; }
function json::block_get() { python3 "$JSON_HELPER" block_get "$@" </dev/null; }
function json::block_is_blocked() { python3 "$JSON_HELPER" block_is_blocked "$@" </dev/null; }
function json::block_set_direct() { python3 "$JSON_HELPER" block_set_direct "$@" </dev/null; }
function json::block_add_group() { python3 "$JSON_HELPER" block_add_group "$@" </dev/null; }
function json::block_remove_group() { python3 "$JSON_HELPER" block_remove_group "$@" </dev/null; }
function json::block_add_rule() { python3 "$JSON_HELPER" block_add_rule "$@" </dev/null; }
function json::block_remove_rule() { python3 "$JSON_HELPER" block_remove_rule "$@" </dev/null; }
function json::block_get_rules() { python3 "$JSON_HELPER" block_get_rules "$@" </dev/null; }
function json::block_get_groups() { python3 "$JSON_HELPER" block_get_groups "$@" </dev/null; }
function json::block_get_direct() { python3 "$JSON_HELPER" block_get_direct "$@" </dev/null; }
function json::peer_transfer() {
ACTIVITY_TOTAL_LOW="$(config::activity_total_low)" \

View file

@ -1132,6 +1132,186 @@ def get_raw(file, key):
except:
pass
def count_resolved(rules_dir, rule_name, key):
"""Count entries in resolved rule field"""
try:
resolved = _rule_resolve_internal(rules_dir, rule_name)
print(len(resolved.get(key, [])))
except:
print(0)
def _block_init(peer_ip):
"""Return empty block structure"""
return {
"peer_ip": peer_ip,
"blocked_direct": False,
"blocked_by_groups": [],
"rules": []
}
def _block_read(file):
try:
with open(file) as f:
content = f.read().strip()
if not content:
return None # empty file = no block data
try:
return json.loads(content)
except json.JSONDecodeError:
# Old format — migrate
lines = content.split('\n')
peer_ip = lines[0].split()[0] if lines else ''
new_data = {
"peer_ip": peer_ip,
"blocked_direct": True,
"blocked_by_groups": [],
"rules": [{"name": "full block", "type": "full"}]
}
with open(file, 'w') as f:
json.dump(new_data, f, indent=2)
return new_data
except FileNotFoundError:
return None
except Exception:
return None
def _block_write(file, data):
"""Write block file"""
with open(file, 'w') as f:
json.dump(data, f, indent=2)
def block_get(file):
"""Read and print block file as JSON"""
data = _block_read(file)
if data:
print(json.dumps(data))
def block_is_blocked(file):
"""Return true if peer is effectively blocked"""
data = _block_read(file)
if not data:
print("false")
return
blocked = data.get("blocked_direct", False) or \
bool(data.get("blocked_by_groups", []))
print("true" if blocked else "false")
def block_set_direct(file, peer_ip, value):
"""Set blocked_direct"""
try:
data = _block_read(file) or _block_init(peer_ip)
data["blocked_direct"] = value.lower() == "true"
data["peer_ip"] = peer_ip
_block_write(file, data)
remaining = data["blocked_direct"] or bool(data.get("blocked_by_groups", []))
pass
# print("true" if remaining else "false")
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def block_add_group(file, peer_ip, group):
"""Add group to blocked_by_groups"""
try:
data = _block_read(file) or _block_init(peer_ip)
data["peer_ip"] = peer_ip
groups = data.get("blocked_by_groups", [])
if group not in groups:
groups.append(group)
data["blocked_by_groups"] = groups
_block_write(file, data)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def block_remove_group(file, peer_ip, group):
"""Remove group from blocked_by_groups, return whether still blocked"""
try:
data = _block_read(file) or _block_init(peer_ip)
groups = data.get("blocked_by_groups", [])
if group in groups:
groups.remove(group)
data["blocked_by_groups"] = groups
_block_write(file, data)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
# print("true" if remaining else "false")
pass
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def block_add_rule(file, peer_ip, rule_type, name="", target="",
port="", proto=""):
"""Add a block rule entry"""
try:
data = _block_read(file) or _block_init(peer_ip)
data["peer_ip"] = peer_ip
rule = {"type": rule_type}
if name: rule["name"] = name
if target: rule["target"] = target
if port: rule["port"] = port
if proto: rule["proto"] = proto
rules = data.get("rules", [])
for existing in rules:
if existing.get("type") == rule_type and \
existing.get("target","") == target and \
existing.get("port","") == port and \
existing.get("proto","") == proto:
return # already exists, skip
rules.append(rule)
data["rules"] = rules
_block_write(file, data)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def block_remove_rule(file, rule_type, target="", port="", proto=""):
"""Remove matching block rule entry"""
try:
data = _block_read(file)
if not data:
return
rules = data.get("rules", [])
filtered = []
for r in rules:
if r.get("type") == rule_type and \
r.get("target", "") == target and \
r.get("port", "") == port and \
r.get("proto", "") == proto:
continue # remove this one
filtered.append(r)
data["rules"] = filtered
_block_write(file, data)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def block_get_rules(file):
"""Print rules as pipe-separated lines: name|type|target|port|proto"""
data = _block_read(file)
if not data:
return
for r in data.get("rules", []):
print(f"{r.get('name','')}|{r.get('type','')}|"
f"{r.get('target','')}|{r.get('port','')}|{r.get('proto','')}")
def block_get_groups(file):
data = _block_read(file)
if not data:
return
print(','.join(data.get('blocked_by_groups', [])))
def block_get_direct(file):
data = _block_read(file)
if not data:
print('false')
return
print('true' if data.get('blocked_direct', False) else 'false')
commands = {
'get': lambda args: get(args[0], args[1]),
'set': lambda args: set_key(args[0], args[1], args[2]),
@ -1179,6 +1359,28 @@ commands = {
'rule_inspect': lambda args: rule_inspect(args[0], args[1]),
'find_rule_file': lambda args: print(find_rule_file(args[0], args[1])),
'get_raw': lambda args: print(get_raw(args[0], args[1])),
'count_resolved': lambda args: count_resolved(args[0], args[1], args[2]),
'block_get': lambda args: block_get(args[0]),
'block_is_blocked': lambda args: block_is_blocked(args[0]),
'block_set_direct': lambda args: block_set_direct(args[0], args[1], args[2]),
'block_add_group': lambda args: block_add_group(args[0], args[1], args[2]),
'block_remove_group': lambda args: block_remove_group(args[0], args[1], args[2]),
'block_add_rule': lambda args: block_add_rule(
args[0], args[1], args[2],
args[3] if len(args) > 3 else '',
args[4] if len(args) > 4 else '',
args[5] if len(args) > 5 else '',
args[6] if len(args) > 6 else ''
),
'block_remove_rule': lambda args: block_remove_rule(
args[0], args[1],
args[2] if len(args) > 2 else '',
args[3] if len(args) > 3 else '',
args[4] if len(args) > 4 else ''
),
'block_get_rules': lambda args: block_get_rules(args[0]),
'block_get_groups': lambda args: block_get_groups(args[0]),
'block_get_direct': lambda args: block_get_direct(args[0]),
}
if __name__ == '__main__':

View file

@ -1,9 +1,10 @@
{
"phone-fred": "94.63.0.129",
"phone-helena": "148.69.46.73",
"phone-nuno": "94.63.0.129",
"phone-nuno": "148.69.51.201",
"tablet-nuno": "148.69.202.5",
"guest-zephyr": "5.13.82.5",
"guest-zephyr-test": "94.63.0.129",
"desktop-roboclean": "46.189.215.231"
"desktop-roboclean": "46.189.215.231",
"laptop-nuno": "94.63.0.129"
}

167
modules/block.module.sh Normal file
View file

@ -0,0 +1,167 @@
#!/usr/bin/env bash
# ============================================
# Block file management
# ============================================
# ── Core state queries ─────────────────────
function block::file() {
local name="${1:?name required}"
ctx::block::path "${name}.block"
}
function block::has_file() {
local name="${1:?}"
[[ -f "$(block::file "$name")" ]]
}
function block::is_blocked() {
local name="${1:?}"
block::has_file "$name" || return 1
local result
result=$(json::block_is_blocked "$(block::file "$name")")
[[ "$result" == "true" ]]
}
function block::is_blocked_direct() {
local name="${1:?}"
block::has_file "$name" || { echo "false"; return 0; }
json::block_get_direct "$(block::file "$name")"
}
function block::get_groups() {
local name="${1:?}"
block::has_file "$name" || return 0
json::block_get_groups "$(block::file "$name")"
}
function block::get_rules() {
local name="${1:?}"
block::has_file "$name" || return 0
json::block_get_rules "$(block::file "$name")"
}
# ── State mutations ────────────────────────
function block::set_direct() {
local name="${1:?}" client_ip="${2:?}" value="${3:-true}"
local file
file=$(block::file "$name")
json::block_set_direct "$file" "$client_ip" "$value"
}
function block::add_group() {
local name="${1:?}" client_ip="${2:?}" group="${3:?}"
local file
file=$(block::file "$name")
json::block_add_group "$file" "$client_ip" "$group"
}
function block::remove_group() {
local name="${1:?}" client_ip="${2:?}" group="${3:?}"
local file
file=$(block::file "$name")
json::block_remove_group "$file" "$client_ip" "$group"
}
function block::add_rule() {
local name="${1:?}" client_ip="${2:?}"
local rule_type="${3:?}" rule_name="${4:-}"
local target="${5:-}" port="${6:-}" proto="${7:-}"
local file
file=$(block::file "$name")
json::block_add_rule "$file" "$client_ip" "$rule_type" \
"$rule_name" "$target" "$port" "$proto"
}
function block::remove_rule() {
local name="${1:?}"
local rule_type="${2:?}" target="${3:-}" port="${4:-}" proto="${5:-}"
local file
file=$(block::file "$name")
json::block_remove_rule "$file" "$rule_type" "$target" "$port" "$proto"
}
function block::remove_file() {
local name="${1:?}"
rm -f "$(block::file "$name")"
}
function block::rename() {
local name="${1:?}" new_name="${2:?}"
local old_file new_file
old_file=$(block::file "$name")
new_file=$(block::file "$new_name")
[[ -f "$old_file" ]] && mv "$old_file" "$new_file"
}
# ── High level operations ──────────────────
function block::apply_full() {
local name="${1:?}" client_ip="${2:?}"
fw::flush_peer "$client_ip"
fw::block_all "$client_ip" "$name"
block::add_rule "$name" "$client_ip" "full" "full block"
local public_key endpoint
public_key=$(keys::public "$name") || return 1
endpoint=$(monitor::endpoint_for_key "$public_key")
[[ -n "$endpoint" ]] && monitor::watch "$endpoint" "$name"
peers::remove_from_server "$name"
peers::reload
}
function block::restore_peer() {
local name="${1:?}" client_ip="${2:?}"
fw::unblock_all "$client_ip"
fw::flush_peer "$client_ip"
monitor::unwatch_client "$name"
if ! peers::exists_in_server "$name"; then
local public_key
public_key=$(keys::public "$name") || return 1
peers::add_to_server "$name" "$public_key" "$client_ip"
peers::reload
fi
return 0
}
function block::restore_all() {
while IFS= read -r peer_name; do
block::has_file "$peer_name" || continue
local client_ip
client_ip=$(peers::get_ip "$peer_name")
[[ -z "$client_ip" ]] && continue
while IFS="|" read -r bname btype target port proto; do
[[ -z "$btype" ]] && continue
case "$btype" in
full) fw::block_all "$client_ip" "$peer_name" ;;
ip) fw::block_ip "$client_ip" "$target" ;;
port) fw::block_port "$client_ip" "$target" "$port" "${proto:-tcp}" ;;
subnet) fw::block_subnet "$client_ip" "$target" ;;
esac
done < <(block::get_rules "$peer_name")
done < <(peers::all)
}
# ── Display helpers ────────────────────────
function block::format_rules() {
local name="${1:?}"
block::has_file "$name" || return 0
while IFS="|" read -r bname btype target port proto; do
[[ -z "$btype" ]] && continue
local display
case "$btype" in
full) display="all traffic" ;;
ip) display="$target" ;;
port) display="${target}:${port}:${proto}" ;;
subnet) display="$target" ;;
esac
local label="${bname:-$btype}"
printf " \033[0;31m-\033[0m %-30s \033[0;37m%s\033[0m\n" \
"$display" "$label"
done < <(block::get_rules "$name")
}

View file

@ -96,6 +96,20 @@ function fw::unblock_subnet() {
log::wg_unblock "Unblocked ${client_ip} → subnet ${target_subnet}"
}
function fw::allow_subnet() {
local client_ip="${1:-}" target_subnet="${2:-}"
fw::_forward_exists -s "$client_ip" -d "$target_subnet" -j ACCEPT \
|| iptables -I FORWARD 1 -s "$client_ip" -d "$target_subnet" -j ACCEPT
}
function fw::unallow_subnet() {
local client_ip="${1:-}" target_subnet="${2:-}"
fw::_forward_exists -s "$client_ip" -d "$target_subnet" -j ACCEPT \
&& iptables -D FORWARD -s "$client_ip" -d "$target_subnet" -j ACCEPT 2>/dev/null || true
}
function fw::allow_ip() {
local client_ip="${1:-}" target_ip="${2:-}"
@ -192,57 +206,46 @@ function fw::remove_dns_redirect() {
}
# ============================================
# Persistence — block files
# Peer related
# ============================================
function fw::save_block() {
local name="$1"
local client_ip="$2"
local target="${3:-}"
local port="${4:-}"
local proto="${5:-}"
function fw::list_peer_rules() {
local ip="${1:-}" show_nflog="${2:-false}"
local block_file
block_file="$(ctx::block::path "${name}.block")"
echo "${client_ip} ${target} ${port} ${proto}" >> "$block_file"
log::debug "Persisted block rule for: ${name}"
}
function fw::remove_block_file() {
local name="$1"
local block_file
block_file="$(ctx::block::path "${name}.block")"
rm -f "$block_file"
log::debug "Removed block file for: ${name}"
}
function fw::restore_blocks() {
local blocks_dir
blocks_dir="$(ctx::blocks)"
# Restore rules from meta files (new system)
rule::restore_all
# Restore per-client full-blocks (wgctl block/unblock system)
for block_file in "${blocks_dir}"/*.block; do
[[ -f "$block_file" ]] || continue
local name
name=$(basename "$block_file" .block)
while IFS=" " read -r client_ip target port proto; do
if [[ -z "$target" ]]; then
fw::block_all "$client_ip" "$name"
elif [[ -n "$port" ]]; then
fw::block_port "$client_ip" "$target" "$port" "${proto:-tcp}"
else
fw::block_ip "$client_ip" "$target"
fi
done < "$block_file"
log::debug "Restored block rules for: ${name}"
fw::forward_rules_for_ip "$ip" | while IFS= read -r line; do
[[ -z "$line" ]] && continue
! $show_nflog && [[ "$line" =~ NFLOG ]] && continue
fw::format_rule "$line"
done
}
function fw::format_rule() {
local line="${1:-}"
[[ -z "$line" ]] && return 0
# Parse verbose iptables format:
# pkts bytes target prot opt in out src dst [extra]
local target prot src dst extra
target=$(awk '{print $3}' <<< "$line")
prot=$(awk '{print $4}' <<< "$line")
src=$(awk '{print $8}' <<< "$line")
dst=$(awk '{print $9}' <<< "$line")
extra=$(awk '{for(i=10;i<=NF;i++) printf $i" "}' <<< "$line" | xargs)
local prot_name
prot_name=$(fw::proto_name "$prot")
local dst_fmt="$dst"
if [[ "$extra" =~ dpt:([0-9]+) ]]; then
local port="${BASH_REMATCH[1]}"
dst_fmt="${dst}:${port}:${prot_name}"
fi
local formatted
formatted=$(printf " %-8s %-15s → %s" "$target" "$src" "$dst_fmt")
ui::firewall_rule "$formatted"
}
# ============================================
# Helpers
# ============================================
@ -251,6 +254,63 @@ function fw::_nat_exists() {
fw::_rule_exists nat PREROUTING "$@"
}
function fw::forward_rules_for_ip() {
local ip="${1:-}"
iptables -L FORWARD -n -v </dev/null | grep -F "$ip"
}
function fw::proto_name() {
local proto="${1:-0}"
case "$proto" in
6) echo "tcp" ;;
17) echo "udp" ;;
1) echo "icmp" ;;
0) echo "all" ;;
tcp|udp|icmp|all) echo "$proto" ;;
*) echo "$proto" ;;
esac
}
function fw::flush_forward() {
iptables -F FORWARD
log::debug "Flushed FORWARD chain"
}
function fw::flush_nat() {
iptables -t nat -F PREROUTING
log::debug "Flushed NAT PREROUTING chain"
}
function fw::flush_all() {
fw::flush_forward
fw::flush_nat
}
function fw::nat_add_dns_redirect() {
local subnet="${1:-}" dns="${2:-}" interface="${3:-wg0}"
iptables -t nat -A PREROUTING -i "$interface" -s "$subnet" \
-p udp --dport 53 ! -d "$dns" \
-j LOG --log-prefix "wgctl-dns-redirect: " --log-level 4
iptables -t nat -A PREROUTING -i "$interface" -s "$subnet" \
-p udp --dport 53 -j DNAT --to-destination "${dns}:53"
iptables -t nat -A PREROUTING -i "$interface" -s "$subnet" \
-p tcp --dport 53 -j DNAT --to-destination "${dns}:53"
}
function fw::nat_remove_dns_redirect() {
local subnet="${1:-}" dns="${2:-}" interface="${3:-wg0}"
iptables -t nat -D PREROUTING -i "$interface" -s "$subnet" \
-p udp --dport 53 ! -d "$dns" \
-j LOG --log-prefix "wgctl-dns-redirect: " \
--log-level 4 2>/dev/null || true
iptables -t nat -D PREROUTING -i "$interface" -s "$subnet" \
-p udp --dport 53 -j DNAT \
--to-destination "${dns}:53" 2>/dev/null || true
iptables -t nat -D PREROUTING -i "$interface" -s "$subnet" \
-p tcp --dport 53 -j DNAT \
--to-destination "${dns}:53" 2>/dev/null || true
}
# ============================================
# private
# ============================================

View file

@ -160,9 +160,14 @@ function peers::exists_in_server() {
grep -q "^# ${name}$" "$(config::config_file)"
}
# function peers::is_blocked() {
# local name="${1:-}"
# peers::exists_in_server "$name" && return 1 || return 0
# }
function peers::is_blocked() {
local name="${1:-}"
peers::exists_in_server "$name" && return 1 || return 0
block::is_blocked "$name"
}
# ============================================

View file

@ -192,7 +192,11 @@ function rule::unapply() {
# Remove allow_ips
while IFS= read -r allow_ip; do
[[ -z "$allow_ip" ]] && continue
if [[ "$allow_ip" == *"/"* ]]; then
fw::unallow_subnet "$client_ip" "$allow_ip"
else
fw::unallow_ip "$client_ip" "$allow_ip"
fi
done < <(rule::get "$rule_name" "allow_ips")
# Remove block_ports
@ -207,7 +211,11 @@ function rule::unapply() {
# Remove block_ips
while IFS= read -r block_ip; do
[[ -z "$block_ip" ]] && continue
if [[ "$block_ip" == *"/"* ]]; then
fw::unblock_subnet "$client_ip" "$block_ip"
else
fw::unblock_ip "$client_ip" "$block_ip"
fi
done < <(rule::get "$rule_name" "block_ips")
# Remove DNS redirect if applicable
@ -247,7 +255,8 @@ function rule::reapply_all() {
local client_ip
client_ip=$(peers::get_ip "$peer_name")
[[ -z "$client_ip" ]] && continue
rule::unapply "$rule_name" "$client_ip"
# FLUSH first to ensure clean ordering
fw::flush_peer "$client_ip"
rule::apply "$rule_name" "$client_ip" "$peer_name"
(( count++ )) || true
done
@ -257,8 +266,12 @@ function rule::reapply_all() {
function rule::restore_all() {
while IFS= read -r peer_name; do
# Skip blocked peers - no fw rules needed when blocked
block::is_blocked "$peer_name" && continue
local rule_name
rule_name=$(peers::get_meta "$peer_name" "rule")
[[ -z "$rule_name" ]] && continue
if ! rule::exists "$rule_name"; then
@ -281,27 +294,10 @@ function rule::restore_all() {
function rule::apply_dns_redirect() {
local client_subnet="${1:-}"
local dns
dns="$(config::dns)"
iptables -t nat -A PREROUTING -i wg0 -s "$client_subnet" -p udp --dport 53 \
! -d "$dns" -j LOG --log-prefix "wgctl-dns-redirect: " --log-level 4
iptables -t nat -A PREROUTING -i wg0 -s "$client_subnet" -p udp --dport 53 \
-j DNAT --to-destination "${dns}:53"
iptables -t nat -A PREROUTING -i wg0 -s "$client_subnet" -p tcp --dport 53 \
-j DNAT --to-destination "${dns}:53"
fw::nat_add_dns_redirect "$client_subnet" "$(config::dns)" "$(config::interface)"
}
function rule::remove_dns_redirect() {
local client_subnet="${1:-}"
local dns
dns="$(config::dns)"
iptables -t nat -D PREROUTING -i wg0 -s "$client_subnet" -p udp --dport 53 \
! -d "$dns" -j LOG --log-prefix "wgctl-dns-redirect: " \
--log-level 4 2>/dev/null || true
iptables -t nat -D PREROUTING -i wg0 -s "$client_subnet" -p udp --dport 53 \
-j DNAT --to-destination "${dns}:53" 2>/dev/null || true
iptables -t nat -D PREROUTING -i wg0 -s "$client_subnet" -p tcp --dport 53 \
-j DNAT --to-destination "${dns}:53" 2>/dev/null || true
fw::nat_remove_dns_redirect "$client_subnet" "$(config::dns)" "$(config::interface)"
}

39
wgctl
View file

@ -17,6 +17,7 @@ load_module peers
load_module firewall
load_module monitor
load_module rule
load_module block
load_module group
# ============================================
@ -102,47 +103,53 @@ $(log::section "wgctl — WireGuard Management" 2>/dev/null || printf "\n wgctl
Usage: wgctl <command> [options]
Client Commands:
add, new Add a new client
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
inspect Show detailed client info
list, ls List all clients (--type, --rule, --group, --blocked...)
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
block, ban Block a client entirely or restrict specific IPs/ports
unblock, unban Restore client access
rule Manage firewall rules (list, show, add, assign...)
rule Manage firewall rules with inheritance
list, show, inspect, add, update, assign, reapply...
Organization:
group Manage peer groups (list, show, block, watch...)
group Manage peer groups
list, show, add, block, unblock, watch, logs...
Monitoring:
watch Live monitor of WireGuard activity
logs Show activity and firewall logs
audit Verify firewall rules are correctly applied
fw Inspect firewall rules
watch Live monitor — handshakes, fw drops, blocked attempts
logs Show and manage activity logs
show, remove, rotate, --follow
audit Verify firewall rules match configuration
fw Inspect iptables firewall rules
Service:
service Manage WireGuard service (start/stop/restart/status)
restart Restart WireGuard
shell Start interactive wgctl shell
shell Interactive wgctl shell
Development:
test Run the wgctl test suite
Common examples:
wgctl add --name nuno --type phone
wgctl add --name visitor --type guest --subtype phone --group family
wgctl add --name nuno --type phone --rule admin --group family
wgctl list --blocked
wgctl list --group family
wgctl list --rule user --group family
wgctl block --name phone-nuno
wgctl inspect --name phone-nuno
wgctl rule assign --name admin --peer laptop-nuno
wgctl rule list --tree
wgctl rule show --name guest
wgctl rule add --name dev-01 --group vm-rules --extends no-lan
wgctl group block --name family
wgctl logs --follow
wgctl audit
wgctl logs rotate --days 7
wgctl audit --fix
wgctl fw list --rule guest
Run 'wgctl <command> --help' for command-specific help.
EOF