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 cat <<EOF
Usage: wgctl audit [options] 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: Options:
--fix Attempt to fix detected issues --peer <name> Audit specific peer only
--peer <name> Audit specific peer only --type <type> Audit peers of specific device type only
--type <type> Audit peers of specific 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: Examples:
wgctl audit wgctl audit
wgctl audit --peer phone-nuno wgctl audit --peer phone-nuno
wgctl audit --type guest
wgctl audit --fix wgctl audit --fix
EOF EOF
} }
@ -98,10 +105,10 @@ function cmd::audit::check_peer() {
local rule_file local rule_file
rule_file="$(ctx::rule::path "${rule}.rule")" rule_file="$(ctx::rule::path "${rule}.rule")"
local block_ports block_ips allow_ports allow_ips expected local block_ports block_ips allow_ports allow_ips expected
block_ports=$(json::count "$rule_file" "block_ports") block_ports=$(json::count_resolved "$rule" "block_ports")
block_ips=$(json::count "$rule_file" "block_ips") block_ips=$(json::count_resolved "$rule" "block_ips")
allow_ports=$(json::count "$rule_file" "allow_ports") allow_ports=$(json::count_resolved "$rule" "allow_ports")
allow_ips=$(json::count "$rule_file" "allow_ips") allow_ips=$(json::count_resolved "$rule" "allow_ips")
expected=$(( (block_ports + block_ips) * 2 + allow_ports + allow_ips )) expected=$(( (block_ports + block_ips) * 2 + allow_ports + allow_ips ))
# actual is passed in as $3 — no iptables call here # 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 --port
flag::register --proto flag::register --proto
flag::register --subnet flag::register --subnet
flag::register --block-name
} }
# ============================================ # ============================================
@ -52,6 +53,7 @@ EOF
function cmd::block::run() { function cmd::block::run() {
local name="" local name=""
local type="" local type=""
local block_name=""
local ips=() local ips=()
local subnets=() local subnets=()
local ports=() local ports=()
@ -59,13 +61,14 @@ function cmd::block::run() {
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--name) name="$2"; shift 2 ;; --name) name="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;; --type) type="$2"; shift 2 ;;
--ip) ips+=("$2"); shift 2 ;; --ip) ips+=("$2"); shift 2 ;;
--force) force=true; shift ;; --block-name) block_name="$2"; shift 2 ;;
--quiet) quiet=true; shift ;; --force) force=true; shift ;;
--subnet) subnets+=("$2"); shift 2 ;; --quiet) quiet=true; shift ;;
--port) ports+=("$2"); shift 2 ;; --subnet) subnets+=("$2"); shift 2 ;;
--port) ports+=("$2"); shift 2 ;;
--help) cmd::block::help; return ;; --help) cmd::block::help; return ;;
*) *)
log::error "Unknown flag: $1" log::error "Unknown flag: $1"
@ -84,7 +87,7 @@ function cmd::block::run() {
name=$(peers::resolve_and_require "$name" "$type") || return 1 name=$(peers::resolve_and_require "$name" "$type") || return 1
# Check if actually blocked # 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}" log::wg_warning "Client is already blocked: ${name}"
return 0 return 0
fi fi
@ -101,33 +104,38 @@ function cmd::block::run() {
local client_ip local client_ip
client_ip=$(peers::get_ip "$name") || return 1 client_ip=$(peers::get_ip "$name") || return 1
# $quiet || log::section "Blocking client: ${name} (${client_ip})"
# No specific target — block everything # No specific target — block everything
cmd::block::_block_all "$name" "$client_ip" "$quiet" # 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 # Block specific IPs
for ip in "${ips[@]}"; do for ip in "${ips[@]}"; do
ip::require_valid "$ip" ip::require_valid "$ip"
fw::block_ip "$client_ip" "$ip" fw::block_ip "$client_ip" "$ip"
fw::save_block "$name" "$client_ip" "$ip" block::add_rule "$name" "$client_ip" "ip" "" "$ip"
done done
# Block specific subnets # Block specific subnets
for subnet in "${subnets[@]}"; do for subnet in "${subnets[@]}"; do
ip::require_valid "$subnet" ip::require_valid "$subnet"
fw::block_subnet "$client_ip" "$subnet" fw::block_subnet "$client_ip" "$subnet"
fw::save_block "$name" "$client_ip" "$subnet" block::add_rule "$name" "$client_ip" "subnet" "" "$target_ip"
done done
# Block specific ports # Block specific ports
for entry in "${ports[@]}"; do for entry in "${ports[@]}"; do
local target port proto local target port proto
IFS=":" read -r target port proto <<< "$entry" IFS=":" read -r target port proto <<< "$entry"
proto="${proto:-tcp}"
ip::require_valid "$target" 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 done
log::debug "Block rules applied for: ${name}" log::debug "Block rules applied for: ${name}"
@ -144,27 +152,15 @@ function cmd::block::_get_endpoint() {
} }
function cmd::block::_block_all() { function cmd::block::_block_all() {
local name="${1:-}" local name="${1:?name required}"
local client_ip="${2:-}" local client_ip="${2:?client_ip required}"
local quiet="${3:-false}" local quiet="${3:-false}"
[[ -z "$name" ]] && log::error "name required" && return 1 # Apply fw rules and remove from server
[[ -z "$client_ip" ]] && log::error "client_ip required" && return 1 block::apply_full "$name" "$client_ip"
local public_key endpoint # Mark as directly blocked
public_key=$(keys::public "$name") || return 1 block::set_direct "$name" "$client_ip" "true"
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
$quiet || log::wg_success "${name} has been blocked." $quiet || log::wg_success "${name} has been blocked."
} }

View file

@ -21,26 +21,27 @@ function cmd::fw::help() {
cat <<EOF cat <<EOF
Usage: wgctl fw [subcommand] [options] Usage: wgctl fw [subcommand] [options]
Inspect and manage firewall rules. Inspect iptables firewall rules applied by wgctl.
Subcommands: Subcommands:
list Show FORWARD chain rules (default) list (default) Show FORWARD chain rules
nat Show NAT/PREROUTING rules nat Show NAT/PREROUTING rules (DNS redirects)
flush-nat Flush NAT rules for a subnet 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: Options for list:
--peer <name> Filter by peer name --peer <name> Filter rules for a specific peer
--rule <rule> Filter by rule name (shows all peers with that rule) --rule <rule> Filter by rule — shows all peers with that rule
--no-nflog Hide NFLOG rules --no-nflog Hide NFLOG logging rules
--no-accept Hide ACCEPT rules --no-accept Hide ACCEPT rules
--no-drop Hide DROP rules --no-drop Hide DROP rules
Examples: Examples:
wgctl fw list wgctl fw
wgctl fw list --peer phone-nuno wgctl fw list --peer phone-nuno
wgctl fw list --rule guest wgctl fw list --rule guest
wgctl fw list --no-nflog wgctl fw list --no-nflog
wgctl fw list --peer phone-nuno --no-nflog
wgctl fw nat wgctl fw nat
wgctl fw count wgctl fw count
wgctl fw flush-nat --subnet 10.1.103.0/24 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}" log::section "Firewall Rules (FORWARD) — rule: ${rule}"
printf "\n" printf "\n"
local found=false 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}" $found || log::wg_warning "No peers found with rule: ${rule}"
fw::list_peer_rules "$ip" "$show_nflog"
printf "\n" printf "\n"
return 0 return 0
fi fi

View file

@ -22,39 +22,47 @@ function cmd::group::help() {
cat <<EOF cat <<EOF
Usage: wgctl group <subcommand> [options] 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: Subcommands:
list, ls List all groups list, ls List all groups with status
show Show group details and members show Show group members and their status
add, new, create Create a new group 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 rename Rename a group
peer add Add a peer to a group peer add Add a peer to a group
peer remove, peer rm Remove a peer from 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 block Block all peers in group
unblock Unblock all peers in group unblock Unblock all peers in group
rule assign Assign a rule to all peers in group rule assign Assign a rule to all peers in group
audit Audit all peers in group audit Audit firewall rules for all peers in group
logs Show logs for all peers in group logs Show activity logs for all peers in group
watch Live monitor for all peers in group watch Live monitor for all peers in group
Options: Options:
--name <name> Group name --name <name> Group name
--desc <description> Group description --desc <description> Group description (for add)
--peer <peer> Peer name --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) --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 --force Skip confirmation prompts
Examples: Examples:
wgctl group list
wgctl group add --name family --desc "Family devices" wgctl group add --name family --desc "Family devices"
wgctl group peer add --name family --peer phone-nuno 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 block --name family
wgctl group unblock --name family
wgctl group rule assign --name family --rule user wgctl group rule assign --name family --rule user
wgctl group audit --name family wgctl group audit --name family
wgctl group logs --name family --limit 20
wgctl group watch --name family
EOF EOF
} }
@ -585,37 +593,28 @@ function cmd::group::block() {
return 0 return 0
fi 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 "${filtered[@]}"; do
for peer_name in "${peers_list[@]}"; do if cmd::group::_block_peer "$peer_name" "$name"; then
[[ -z "$peer_name" ]] && continue (( count++ )) || true
# Skip if peer no longer exists else
if ! peers::require_exists "$peer_name" > /dev/null 2>&1; then (( skipped++ )) || true
log::wg_warning "Peer '${peer_name}' no longer exists — skipping"
continue
fi 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")
(( count++ )) || true
done done
[[ "$count" -eq 0 ]] && return 0 if [[ "$count" -gt 0 ]]; then
log::wg_block "All peers from ${name} have been blocked (${count} peers)."
fi
# if [[ "$count" -gt 0 ]]; then if [[ "$skipped" -gt 0 ]]; then
# printf "\n" log::wg_warning "${skipped} peers already blocked"
# for n in "${blocked_names[@]}"; do fi
# log::wg " Blocked: ${n}"
# done
# fi
# log::wg_block "Blocked ${count} peers in group '${name}'"
log::wg_block "All peers from ${name} have been blocked (${count} peers)."
} }
function cmd::group::unblock() { function cmd::group::unblock() {
@ -635,38 +634,114 @@ function cmd::group::unblock() {
local peers_list=() local peers_list=()
mapfile -t peers_list < <(group::peers "$name") mapfile -t peers_list < <(group::peers "$name")
[[ -z "${peers_list[0]}" ]] && log::wg_warning "Group '${name}' has no peers" && return 0
# 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=() local count=0 skipped=0
for peer_name in "${peers_list[@]}"; do
[[ -z "$peer_name" ]] && continue for peer_name in "${filtered[@]}"; do
# Skip if peer no longer exists if cmd::group::_unblock_peer "$peer_name" "$name"; then
if ! peers::require_exists "$peer_name" > /dev/null 2>&1; then (( count++ )) || true
log::wg_warning "Peer '${peer_name}' no longer exists — skipping" else
continue (( skipped++ )) || true
fi fi
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")
(( count++ )) || true
done 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 if [[ "$skipped" -gt 0 ]]; then
# printf "\n" log::wg_warning "${skipped} peer(s) remain blocked (blocked directly or by other groups)"
# for n in "${blocked_names[@]}"; do fi
# log::wg " Unblocked: ${n}" }
# done
# 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() { function cmd::inspect::help() {
cat <<EOF cat <<EOF
Usage: wgctl inspect --name <name> [--type <type>] Usage: wgctl inspect --name <name> [options]
wgctl inspect <full-name> 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: Options:
--name <name> Client name --name <name> Client name (e.g. phone-nuno)
--type <type> Device type (optional, combines with --name) --type <type> Device type — combines with --name (e.g. --name nuno --type phone)
--config Show raw client config --config Also show raw WireGuard client config
--qr Show QR code --qr Also show QR code
Examples: Examples:
wgctl inspect --name phone-nuno wgctl inspect --name phone-nuno
wgctl inspect --name nuno --type phone wgctl inspect --name nuno --type phone
wgctl inspect --name phone-nuno --config wgctl inspect --name phone-nuno --config
wgctl inspect --name phone-nuno --qr wgctl inspect --name phone-nuno --qr
wgctl inspect guest-zephyr
EOF EOF
} }
@ -70,61 +72,205 @@ function cmd::inspect::_peer_info() {
local subtype local subtype
subtype=$(peers::get_meta "$name" "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" cmd::inspect::_section "Client"
ui::row "Name" "$name" ui::row "Name" "$name"
ui::row "IP" "$ip" ui::row "IP" "$ip"
ui::row "Type" "$(peers::display_type "$type" "$subtype")" 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 "Status" "$(echo -e "$status")"
ui::row "Endpoint" "${endpoint:-}" ui::row "Endpoint" "${endpoint:-}"
ui::row "Last seen" "$last_seen" ui::row "Last seen" "$last_seen"
ui::row "AllowedIPs" "$allowed_ips" ui::row "AllowedIPs" "$allowed_ips"
ui::row "Public key" "${public_key:-}" 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() { function cmd::inspect::_rule_info() {
local name="$1" local name="${1:-}"
local rule local rule
rule=$(peers::get_meta "$name" "rule") rule=$(peers::get_meta "$name" "rule")
[[ -z "$rule" ]] && return 0 [[ -z "$rule" ]] && return 0
rule::exists "$rule" || return 0 rule::exists "$rule" || return 0
cmd::inspect::_section "Rule: ${rule}"
local rule_file 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 if [[ ${#extends_raw[@]} -gt 0 && -n "${extends_raw[0]:-}" ]]; then
desc=$(json::get "$rule_file" "desc") # Show inheritance tree
dns_redirect=$(json::get "$rule_file" "dns_redirect") for base_name in "${extends_raw[@]}"; do
ui::row "Description" "${desc:-}" [[ -z "$base_name" ]] && continue
ui::row "DNS Redirect" "${dns_redirect:-false}" printf "\n \033[0;37m↳ %s\033[0m\n" "$base_name"
local allow_ports allow_ips block_ips block_ports local base_allows base_blocks
allow_ports=$(json::get "$rule_file" "allow_ports") base_allows=$(rule::get "$base_name" "allow_ports")$'\n'$(rule::get "$base_name" "allow_ips")
allow_ips=$(json::get "$rule_file" "allow_ips") base_blocks=$(rule::get "$base_name" "block_ips")$'\n'$(rule::get "$base_name" "block_ports")
block_ips=$(json::get "$rule_file" "block_ips")
block_ports=$(json::get "$rule_file" "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
if [[ -n "$allow_ports" || -n "$allow_ips" ]]; then
printf " %-20s\n" "Allows:"
ui::print_list "+" "$allow_ports"
ui::print_list "+" "$allow_ips"
else else
ui::row "Allows" "—" # 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
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 "\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 fi
if [[ -n "$block_ips" || -n "$block_ports" ]]; then return 0
printf " %-20s\n" "Blocks:"
ui::print_list "-" "$block_ips"
ui::print_list "-" "$block_ports"
else
ui::row "Blocks" "—"
fi
} }
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() { function cmd::inspect::_group_info() {
local name="$1" local name="$1"
@ -144,30 +290,40 @@ function cmd::inspect::_group_info() {
count=$(json::count "$(group::path "$g")" "peers") count=$(json::count "$(group::path "$g")" "peers")
printf " %-20s %s peers\n" "$g" "$count" printf " %-20s %s peers\n" "$g" "$count"
done done
return 0
} }
function cmd::inspect::_firewall_info() { function cmd::inspect::_firewall_info() {
local name="$1" show_nflog="${2:-false}" local name="${1:-}"
local ip local ip
ip=$(peers::get_ip "$name") 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 # printf "\n \033[0;37m── Firewall (\033[0;32m+%d\033[0m \033[0;31m-%d\033[0m) \033[0m%s\n" \
while IFS=":" read -r pname pcount; do # "$accepts" "$drops" "$(printf '─%.0s' {1..28})"
[[ "$pname" == "$name" ]] && count="$pcount" && break
done < <(json::audit_fw_counts "$(ctx::clients)")
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 if [[ ${#rules_output[@]} -gt 0 ]]; then
printf "\n" for line in "${rules_output[@]}"; do
iptables -L FORWARD -n </dev/null | grep -F "$ip" | while IFS= read -r rule; do fw::format_rule "$line"
[[ -z "$rule" ]] && continue
echo "$rule" | grep -q "NFLOG" && continue # skip NFLOG
ui::firewall_rule "$rule"
done done
fi fi
return 0
} }
function cmd::inspect::_config() { function cmd::inspect::_config() {
@ -176,6 +332,8 @@ function cmd::inspect::_config() {
printf "\n" printf "\n"
cat "$(ctx::clients)/${name}.conf" cat "$(ctx::clients)/${name}.conf"
printf "\n" printf "\n"
return 0
} }
# ============================================ # ============================================
@ -221,6 +379,7 @@ function cmd::inspect::run() {
cmd::inspect::_rule_info "$name" cmd::inspect::_rule_info "$name"
cmd::inspect::_group_info "$name" cmd::inspect::_group_info "$name"
cmd::inspect::_firewall_info "$name" cmd::inspect::_firewall_info "$name"
cmd::inspect::_blocks_info "$name"
if $show_config; then if $show_config; then
cmd::inspect::_config "$name" cmd::inspect::_config "$name"

View file

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

View file

@ -14,6 +14,7 @@ function cmd::logs::on_load() {
flag::register --all flag::register --all
flag::register --before flag::register --before
flag::register --force flag::register --force
flag::register --days
} }
function cmd::logs::help() { function cmd::logs::help() {
@ -25,6 +26,7 @@ Show or manage WireGuard and firewall activity logs.
Subcommands: Subcommands:
show (default) Show activity logs show (default) Show activity logs
remove, rm Remove log entries remove, rm Remove log entries
rotate Remove entries older than N days
Options for show: Options for show:
--name <name> Filter by client name --name <name> Filter by client name
@ -42,6 +44,10 @@ Options for remove:
--before <days> Remove entries older than N days --before <days> Remove entries older than N days
--force Skip confirmation --force Skip confirmation
Options for rotate:
--days <n> Days to keep (default: 7)
--force Skip confirmation
Examples: Examples:
wgctl logs wgctl logs
wgctl logs --name phone-nuno wgctl logs --name phone-nuno
@ -49,8 +55,9 @@ Examples:
wgctl logs --follow wgctl logs --follow
wgctl logs remove --name phone-nuno wgctl logs remove --name phone-nuno
wgctl logs remove --all --force wgctl logs remove --all --force
wgctl logs remove --before 7
wgctl logs remove --fw --before 1 wgctl logs remove --fw --before 1
wgctl logs rotate
wgctl logs rotate --days 30 --force
EOF EOF
} }
@ -65,6 +72,7 @@ function cmd::logs::run() {
case "$subcmd" in case "$subcmd" in
show) cmd::logs::show "$@" ;; show) cmd::logs::show "$@" ;;
remove|rm|del) cmd::logs::remove "$@" ;; remove|rm|del) cmd::logs::remove "$@" ;;
rotate) cmd::logs::rotate "$@" ;;
help) cmd::logs::help ;; help) cmd::logs::help ;;
*) *)
log::error "Unknown subcommand: '${subcmd}'" log::error "Unknown subcommand: '${subcmd}'"
@ -278,3 +286,42 @@ function cmd::logs::show_fw_events() {
$found || printf " —\n" $found || printf " —\n"
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 group::remove_peer_from_all "$name" || return 1
[[ -n "$client_ip" ]] && $was_blocked && fw::unblock_all "$client_ip" [[ -n "$client_ip" ]] && $was_blocked && fw::unblock_all "$client_ip"
fw::remove_block_file "$name" 2>/dev/null || true block::remove_file "$name" 2>/dev/null || true
peers::remove_meta "$name" 2>/dev/null || true peers::remove_meta "$name" 2>/dev/null || true
peers::reload || return 1 peers::reload || return 1
} }

View file

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

View file

@ -28,6 +28,7 @@ function cmd::rule::on_load() {
flag::register --resolved flag::register --resolved
flag::register --force flag::register --force
flag::register --type flag::register --type
flag::register --all
} }
# ============================================ # ============================================
@ -38,67 +39,71 @@ function cmd::rule::help() {
cat <<EOF cat <<EOF
Usage: wgctl rule <subcommand> [options] 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: Subcommands:
list, ls List all rules list, ls List all rules
show Show rule details show, inspect Show rule details and inheritance
inspect Show full inheritance tree
add, new, create Create a new rule 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 remove, rm, del Remove a rule
assign Assign a rule to a peer assign Assign a rule to a peer
unassign Remove rule from a peer unassign Remove rule from a peer
migrate Apply default rules to all unassigned peers reapply Re-apply rule to all assigned peers
reapply Re-apply a rule to all assigned peers migrate Apply default rules to unassigned peers
Options for list: Options for list:
--base Show base rules section --base Show only base rules
--no-base Hide base rules section (default shows them) --no-base Hide base rules section
--group <name> Filter by group name --group <name> Filter by group (case insensitive)
--tree Show full inheritance tree inline --tree Show full inheritance tree inline
Options for add/update: Options for add:
--name <name> Rule name --name <name> Rule name
--desc <description> Human readable description --desc <description> Description
--group <group> Display group (e.g. vm-rules, user-rules) --group <group> Display group (e.g. VM Rules, Users)
--extends <rule,...> Inherit from base rules (add only) --extends <rule,...> Inherit from base rules (comma-separated)
--add-extends <rule,...> Add base rules (update only) --base Create as base rule (not directly assignable)
--remove-extends <rule,...> Remove base rules (update only)
--allow-ip <ip/cidr> Allow IP or subnet (repeatable) --allow-ip <ip/cidr> Allow IP or subnet (repeatable)
--allow-port <ip:port:proto> Allow specific port (repeatable) --allow-port <ip:port:proto> Allow specific port (repeatable)
--block-ip <ip/cidr> Block IP or subnet (repeatable) --block-ip <ip/cidr> Block IP or subnet (repeatable)
--block-port <ip:port:proto> Block specific port (repeatable) --block-port <ip:port:proto> Block specific port (repeatable)
--dns-redirect Force DNS through Pi-hole --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-ip <ip> Remove allow IP entry
--remove-allow-port <entry> Remove allow port entry --remove-allow-port <entry> Remove allow port entry
--remove-block-ip <ip> Remove block IP entry --remove-block-ip <ip> Remove block IP entry
--remove-block-port <entry> Remove block port entry --remove-block-port <entry> Remove block port entry
Options for inspect: Options for show/inspect:
--name <name> Rule name --name <name> Rule name
--peers Show assigned peers --resolved Show resolved/merged entries
--resolved Show resolved/merged rule entries --no-peers Hide assigned peers
Options for assign/unassign: Options for reapply:
--name <rule> Rule name --name <name> Rule name
--peer <peer> Peer name --all Reapply all rules
--type <type> Peer device type (optional)
Examples: Examples:
wgctl rule list wgctl rule list
wgctl rule list --base
wgctl rule list --group vm-rules
wgctl rule list --tree wgctl rule list --tree
wgctl rule list --group "VM Rules"
wgctl rule list --base
wgctl rule show --name guest wgctl rule show --name guest
wgctl rule inspect --name moonlight-02 wgctl rule show --name moonlight-02 --resolved
wgctl rule inspect --name moonlight-02 --resolved --peers wgctl rule add --name dev-01 --desc "Dev access" --group "Dev" --extends no-lan
wgctl rule add --name dev-01 --desc "Dev VM" --group vm-rules --extends no-lan wgctl rule add --name no-npm --base --block-ip 10.0.0.101/32
wgctl rule update --name dev-01 --add-extends no-nginx wgctl rule update --name user --add-extends no-nginx
wgctl rule update --name dev-01 --remove-extends no-nginx wgctl rule update --name dev-01 --allow-ip 10.0.0.50/32
wgctl rule update --name dev-01 --group infra-rules
wgctl rule assign --name dev-01 --peer laptop-nuno wgctl rule assign --name dev-01 --peer laptop-nuno
wgctl rule unassign --peer laptop-nuno wgctl rule unassign --peer laptop-nuno
wgctl rule reapply --name user
wgctl rule reapply --all
EOF EOF
} }
@ -507,81 +512,6 @@ function cmd::rule::show() {
printf "\n" 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 # Add
# ============================================ # ============================================
@ -868,14 +798,33 @@ function cmd::rule::migrate() {
} }
function cmd::rule::reapply() { function cmd::rule::reapply() {
local name="" local name="" all=false
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--name) name="$2"; shift 2 ;; --name) name="$2"; shift 2 ;;
--all) all=true; shift ;;
*) log::error "Unknown flag: $1"; return 1 ;; *) log::error "Unknown flag: $1"; return 1 ;;
esac esac
done 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::require_exists "$name" || return 1
rule::reapply_all "$name" rule::reapply_all "$name"
log::wg_success "Rule '${name}' reapplied" log::wg_success "Rule '${name}' reapplied"

View file

@ -59,6 +59,12 @@ function cmd::service::run() {
function cmd::service::start() { function cmd::service::start() {
log::wg_start "Starting WireGuard..." log::wg_start "Starting WireGuard..."
systemctl start "wg-quick@$(config::interface)" systemctl start "wg-quick@$(config::interface)"
block::restore_all
rule::restore_all
cmd::service::_auto_rotate_logs
log::wg_success "WireGuard started" log::wg_success "WireGuard started"
} }
@ -72,18 +78,26 @@ function cmd::service::restart() {
log::wg_start "Restarting WireGuard..." log::wg_start "Restarting WireGuard..."
# Flush firewall rules before restart so restore starts clean # Flush firewall rules before restart so restore starts clean
iptables -F FORWARD fw::flush_all
iptables -t nat -F PREROUTING
systemctl restart "wg-quick@$(config::interface)" 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" log::wg_success "WireGuard restarted"
} }
function cmd::service::reload() { function cmd::service::reload() {
log::wg_start "Reloading WireGuard config..." log::wg_start "Reloading WireGuard config..."
peers::reload peers::reload
fw::restore_blocks block::restore_all
rule::restore_all
log::wg_success "WireGuard config reloaded"
} }
function cmd::service::status() { function cmd::service::status() {
@ -102,6 +116,18 @@ function cmd::service::logs() {
journalctl -u "wg-quick@$(config::interface)" -f --no-pager 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() { function cmd::service::enable() {
systemctl enable "wg-quick@$(config::interface)" systemctl enable "wg-quick@$(config::interface)"
log::wg_success "WireGuard enabled on boot" log::wg_success "WireGuard enabled on boot"

View file

@ -14,12 +14,11 @@ function cmd::shell::_prompt() {
} }
function cmd::shell::_is_wgctl_command() { function cmd::shell::_is_wgctl_command() {
local cmd="$1" local cmd="${1:-}"
# Check against known wgctl commands
local known=( local known=(
list add remove rm inspect block unblock list add remove rm inspect block unblock
rule group audit logs watch fw config qr rule group audit logs watch fw config qr
rename keys ip service shell help rename keys ip service shell help test
) )
local c local c
for c in "${known[@]}"; do for c in "${known[@]}"; do
@ -29,7 +28,7 @@ function cmd::shell::_is_wgctl_command() {
} }
function cmd::shell::_handle_builtin() { function cmd::shell::_handle_builtin() {
local input="$1" local input="${1:-}"
local first="${input%% *}" local first="${input%% *}"
case "$first" in 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" cd "$dir" 2>/dev/null || log::error "cd: $dir: No such file or directory"
return 0 return 0
;; ;;
cd*) eval "$input" ;; # eval preserves shell state for cd
export|unset|source|.) export|unset|source|.)
eval "$input" eval "$input"
return 0 return 0
;; ;;
esac esac
return 1 # not a builtin return 1
} }
function cmd::shell::_execute() { function cmd::shell::_execute() {
local input="$1" local input="${1:-}"
local first="${input%% *}" local first="${input%% *}"
local rest="${input#"$first"}" local rest="${input#"$first"}"
rest="${rest# }" rest="${rest# }"
# Handle shell builtins first
cmd::shell::_handle_builtin "$input" && return 0 cmd::shell::_handle_builtin "$input" && return 0
# Try as wgctl command via dispatcher
if cmd::shell::_is_wgctl_command "$first"; then if cmd::shell::_is_wgctl_command "$first"; then
if [[ -n "$rest" ]]; then if [[ -n "$rest" ]]; then
wgctl::dispatch "$first" $rest || true # never exit REPL on failure wgctl::dispatch "$first" $rest || true
else else
wgctl::dispatch "$first" || true wgctl::dispatch "$first" || true
fi fi
return 0 # Always 0 to keep REPL running return 0
fi fi
# Fall back to bash bash -c "$input" || true
bash -c "$input" || true # same for bash commands
} }
function cmd::shell::_setup_history() { function cmd::shell::_setup_history() {
@ -85,14 +80,30 @@ function cmd::shell::_save_history() {
function cmd::shell::_banner() { function cmd::shell::_banner() {
ui::section "wgctl shell" ui::section "wgctl shell"
printf "\n" printf "\n"
printf " Type wgctl commands directly, or any bash command.\n" printf " Type wgctl commands directly (no 'wgctl' prefix).\n"
printf " Type \033[1mexit\033[0m or \033[1mquit\033[0m to leave.\n" printf " Bash commands work too: ls, cat, systemctl, vim...\n\n"
printf " Type \033[1mhelp\033[0m for wgctl commands.\n" printf " \033[1;37mCommon commands:\033[0m\n"
printf "\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() { function cmd::shell::on_load() {
@ -103,62 +114,65 @@ function cmd::shell::help() {
cat <<EOF cat <<EOF
Usage: wgctl shell Usage: wgctl shell
Start an interactive wgctl shell. Supports all wgctl commands Start an interactive wgctl shell.
directly (no 'wgctl' prefix needed), plus any bash command. All wgctl commands work directly (no 'wgctl' prefix needed).
Bash commands (ls, cat, systemctl, vim, etc.) also work.
Builtins handled: cd, export, unset, source Shell builtins handled natively: cd, export, unset, source
Everything else: passed to bash History saved to: ~/.wgctl_history
Examples: Examples:
wgctl shell wgctl shell
wgctl> list wgctl> list --blocked
wgctl> inspect --name phone-nuno 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 wgctl> exit
EOF 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() { 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() { function _wgctl_shell_complete() {
local cur="${COMP_WORDS[COMP_CWORD]}" local cur="${COMP_WORDS[COMP_CWORD]}"
COMPREPLY=( $(compgen -W "$commands" -- "$cur") ) COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
} }
# Bind completion to the read prompt
bind 'set show-all-if-ambiguous on' 2>/dev/null || true bind 'set show-all-if-ambiguous on' 2>/dev/null || true
bind 'set completion-ignore-case 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" test::section "Rules"
cmd::test::run_cmd "rule list" "guest" rule list 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" "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 user" "Description" rule show --name user
cmd::test::run_cmd "rule show --name admin" "Description" rule show --name admin 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 cmd::test::run_cmd_fails "rule show nonexistent" rule show --name nonexistent
@ -209,45 +208,77 @@ function cmd::test::section_fw() {
function cmd::test::section_destructive() { function cmd::test::section_destructive() {
test::section "Destructive (modifying state)" 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" 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 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" \ cmd::test::run_cmd "add phone peer" "added successfully" \
add --name testunit --type phone add --name testunit --type phone
# Block/unblock
cmd::test::run_cmd "block peer" "blocked" \
block --name phone-testunit
cmd::test::run_cmd "list shows blocked" "blocked" \
list --blocked
cmd::test::run_cmd "unblock peer" "unblocked" \
unblock --name phone-testunit
# Rule assign/unassign # ── Direct block/unblock ───────────────────
cmd::test::run_cmd "block peer" "blocked" \
block --name phone-testunit
cmd::test::run_cmd "list shows blocked" "blocked" \
list --blocked
cmd::test::run_cmd "unblock peer" "unblocked" \
unblock --name phone-testunit
# ── Rule assign/unassign ───────────────────
cmd::test::run_cmd "rule assign" "Assigned" \ cmd::test::run_cmd "rule assign" "Assigned" \
rule assign --name user --peer phone-testunit rule assign --name user --peer phone-testunit
cmd::test::run_cmd "rule unassign" "Unassigned" \ cmd::test::run_cmd "rule unassign" "Unassigned" \
rule unassign --peer phone-testunit rule unassign --peer phone-testunit
# Re-assign user rule (default) for cleanup "$WGCTL_BINARY" rule assign --name user --peer phone-testunit \
/usr/local/bin/wgctl rule assign --name user --peer phone-testunit \
> /dev/null 2>&1 || true > /dev/null 2>&1 || true
# Group operations # ── Group basic operations ─────────────────
cmd::test::run_cmd "group add" "created" \ cmd::test::run_cmd "group add" "created" \
group add --name testgroup --desc "Test group" group add --name testgroup --desc "Test group"
cmd::test::run_cmd "group peer add" "Added" \ cmd::test::run_cmd "group peer add" "Added" \
group peer add --name testgroup --peer phone-testunit 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 group block --name testgroup
cmd::test::run_cmd "group unblock" "have been unblocked" \ cmd::test::run_cmd "group unblock" "unblocked" \
group unblock --name testgroup group unblock --name testgroup
cmd::test::run_cmd "group remove" "removed" \
group remove --name testgroup --force
# Remove test peer # ── M:N group block tracking ───────────────
cmd::test::run_cmd "remove phone peer" "removed" \ # Setup: add testunit to a second group
remove --name phone-testunit --force "$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 ───────────────────────
cmd::test::run_cmd "remove phone peer" "removed" \
remove --name phone-testunit --force
} }
# ============================================ # ============================================

View file

@ -84,8 +84,8 @@ function cmd::unblock::run() {
name=$(peers::resolve_and_require "$name" "$type") || return 1 name=$(peers::resolve_and_require "$name" "$type") || return 1
# Check if actually blocked # 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}" log::wg_warning "Client is not blocked: ${name}"
return 0 return 0
fi fi
@ -108,6 +108,7 @@ function cmd::unblock::run() {
# Unblock specific IPs # Unblock specific IPs
for ip in "${ips[@]}"; do for ip in "${ips[@]}"; do
fw::unblock_ip "$client_ip" "$ip" fw::unblock_ip "$client_ip" "$ip"
block::remove_rule "$name" "ip" "$ip"
done done
# Unblock specific subnets # Unblock specific subnets
@ -124,23 +125,34 @@ function cmd::unblock::run() {
done done
$quiet || log::wg_success "Unblock rules applied for: ${name}" $quiet || log::wg_success "Unblock rules applied for: ${name}"
return 0
} }
function cmd::unblock::_unblock_all() { function cmd::unblock::_unblock_all() {
local name="${1:-}" local name="${1:?}" client_ip="${2:?}" quiet="${3:-false}"
local client_ip="${2:-}"
local quiet="${3:-false}"
fw::unblock_all "$client_ip" # Direct unblock overrides everything — clear all block state
fw::remove_block_file "$name" block::set_direct "$name" "$client_ip" "false"
monitor::unwatch_client "$name"
if ! peers::exists_in_server "$name"; then # Force full unblock regardless of group blocks
local public_key # (direct unblock = admin override)
public_key=$(keys::public "$name") || return 1 block::restore_peer "$name" "$client_ip"
peers::add_to_server "$name" "$public_key" "$client_ip" block::remove_file "$name"
peers::reload
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 fi
$quiet || log::wg_success "${name} has been unblocked." $quiet || log::wg_success "${name} has been unblocked."
return 0
} }

View file

@ -21,16 +21,18 @@ function cmd::watch::help() {
cat <<EOF cat <<EOF
Usage: wgctl watch [options] Usage: wgctl watch [options]
Live monitor of WireGuard activity. Live monitor of WireGuard activity. Shows:
Shows handshakes from active clients and connection attempts from blocked clients. - Handshakes from connected peers (green)
- Connection attempts from blocked peers (red, SOURCE: wg)
- Firewall drops from rule-restricted peers (red, SOURCE: fw)
Options: Options:
--type <type> Filter by device type
--name <name> Filter by client name --name <name> Filter by client name
--peers <list> Filter by comma-separated peer names (used by group watch) --type <type> Filter by device type
--allowed Show only allowed client handshakes --peers <list> Comma-separated peer names (used internally by group watch)
--restricted Show only restricted client events --blocked Show only blocked peer attempts
--blocked Show only blocked client attempts --allowed Show only handshakes (allowed peers)
--restricted Show only firewall drop events
Examples: Examples:
wgctl watch wgctl watch
@ -38,6 +40,7 @@ Examples:
wgctl watch --allowed wgctl watch --allowed
wgctl watch --type phone wgctl watch --type phone
wgctl watch --name phone-nuno wgctl watch --name phone-nuno
wgctl watch --name phone-nuno --type phone
EOF EOF
} }
@ -164,6 +167,9 @@ function cmd::watch::tail_events() {
local blocked_only="${3:-false}" restricted_only="${4:-false}" local blocked_only="${3:-false}" restricted_only="${4:-false}"
local allowed_only="${5:-false}" filter_peers="${6:-}" local allowed_only="${5:-false}" filter_peers="${6:-}"
declare -A _WATCH_LAST_ATTEMPT=()
declare -A _WATCH_LAST_FW=()
local peer_set=() local peer_set=()
[[ -n "$filter_peers" ]] && IFS=',' read -ra peer_set <<< "$filter_peers" [[ -n "$filter_peers" ]] && IFS=',' read -ra peer_set <<< "$filter_peers"
@ -259,6 +265,19 @@ function cmd::watch::tail_events() {
[[ -z "$src_ip" ]] && continue [[ -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}" local client="${ip_to_name[$src_ip]:-$src_ip}"
[[ -n "$filter_name" && "$client" != "$filter_name" ]] && continue [[ -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/flag.sh"
source "${WGCTL_DIR}/core/json.sh" source "${WGCTL_DIR}/core/json.sh"
source "${WGCTL_DIR}/core/ui.sh" source "${WGCTL_DIR}/core/ui.sh"
source "${WGCTL_DIR}/core/color.sh"
source "${WGCTL_DIR}/core/fmt.sh" source "${WGCTL_DIR}/core/fmt.sh"
source "${WGCTL_DIR}/core/test/test.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::rule_inspect() { python3 "$JSON_HELPER" rule_inspect "$@" </dev/null; }
function json::find_rule_file() { python3 "$JSON_HELPER" find_rule_file "$@" </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::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() { function json::peer_transfer() {
ACTIVITY_TOTAL_LOW="$(config::activity_total_low)" \ ACTIVITY_TOTAL_LOW="$(config::activity_total_low)" \

View file

@ -1132,6 +1132,186 @@ def get_raw(file, key):
except: except:
pass 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 = { commands = {
'get': lambda args: get(args[0], args[1]), 'get': lambda args: get(args[0], args[1]),
'set': lambda args: set_key(args[0], args[1], args[2]), 'set': lambda args: set_key(args[0], args[1], args[2]),
@ -1179,6 +1359,28 @@ commands = {
'rule_inspect': lambda args: rule_inspect(args[0], args[1]), 'rule_inspect': lambda args: rule_inspect(args[0], args[1]),
'find_rule_file': lambda args: print(find_rule_file(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])), '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__': if __name__ == '__main__':

View file

@ -1,9 +1,10 @@
{ {
"phone-fred": "94.63.0.129", "phone-fred": "94.63.0.129",
"phone-helena": "148.69.46.73", "phone-helena": "148.69.46.73",
"phone-nuno": "94.63.0.129", "phone-nuno": "148.69.51.201",
"tablet-nuno": "148.69.202.5", "tablet-nuno": "148.69.202.5",
"guest-zephyr": "5.13.82.5", "guest-zephyr": "5.13.82.5",
"guest-zephyr-test": "94.63.0.129", "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}" 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() { function fw::allow_ip() {
local client_ip="${1:-}" target_ip="${2:-}" 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() { function fw::list_peer_rules() {
local name="$1" local ip="${1:-}" show_nflog="${2:-false}"
local client_ip="$2"
local target="${3:-}"
local port="${4:-}"
local proto="${5:-}"
local block_file fw::forward_rules_for_ip "$ip" | while IFS= read -r line; do
block_file="$(ctx::block::path "${name}.block")" [[ -z "$line" ]] && continue
! $show_nflog && [[ "$line" =~ NFLOG ]] && continue
echo "${client_ip} ${target} ${port} ${proto}" >> "$block_file" fw::format_rule "$line"
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}"
done 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 # Helpers
# ============================================ # ============================================
@ -251,6 +254,63 @@ function fw::_nat_exists() {
fw::_rule_exists nat PREROUTING "$@" 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 # private
# ============================================ # ============================================

View file

@ -160,9 +160,14 @@ function peers::exists_in_server() {
grep -q "^# ${name}$" "$(config::config_file)" 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() { function peers::is_blocked() {
local name="${1:-}" 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 # Remove allow_ips
while IFS= read -r allow_ip; do while IFS= read -r allow_ip; do
[[ -z "$allow_ip" ]] && continue [[ -z "$allow_ip" ]] && continue
fw::unallow_ip "$client_ip" "$allow_ip" 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") done < <(rule::get "$rule_name" "allow_ips")
# Remove block_ports # Remove block_ports
@ -207,7 +211,11 @@ function rule::unapply() {
# Remove block_ips # Remove block_ips
while IFS= read -r block_ip; do while IFS= read -r block_ip; do
[[ -z "$block_ip" ]] && continue [[ -z "$block_ip" ]] && continue
fw::unblock_ip "$client_ip" "$block_ip" 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") done < <(rule::get "$rule_name" "block_ips")
# Remove DNS redirect if applicable # Remove DNS redirect if applicable
@ -247,7 +255,8 @@ function rule::reapply_all() {
local client_ip local client_ip
client_ip=$(peers::get_ip "$peer_name") client_ip=$(peers::get_ip "$peer_name")
[[ -z "$client_ip" ]] && continue [[ -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" rule::apply "$rule_name" "$client_ip" "$peer_name"
(( count++ )) || true (( count++ )) || true
done done
@ -257,8 +266,12 @@ function rule::reapply_all() {
function rule::restore_all() { function rule::restore_all() {
while IFS= read -r peer_name; do 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 local rule_name
rule_name=$(peers::get_meta "$peer_name" "rule") rule_name=$(peers::get_meta "$peer_name" "rule")
[[ -z "$rule_name" ]] && continue [[ -z "$rule_name" ]] && continue
if ! rule::exists "$rule_name"; then if ! rule::exists "$rule_name"; then
@ -281,27 +294,10 @@ function rule::restore_all() {
function rule::apply_dns_redirect() { function rule::apply_dns_redirect() {
local client_subnet="${1:-}" local client_subnet="${1:-}"
local dns fw::nat_add_dns_redirect "$client_subnet" "$(config::dns)" "$(config::interface)"
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"
} }
function rule::remove_dns_redirect() { function rule::remove_dns_redirect() {
local client_subnet="${1:-}" local client_subnet="${1:-}"
local dns fw::nat_remove_dns_redirect "$client_subnet" "$(config::dns)" "$(config::interface)"
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
} }

39
wgctl
View file

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