refactor: rule::show new layout, assign fix, unblock helpers, test improvements

This commit is contained in:
Nuno Duque Nunes 2026-05-12 04:27:47 +00:00
parent 5702b118b0
commit 51e3443357
10 changed files with 312 additions and 147 deletions

View file

@ -41,23 +41,6 @@ Examples:
EOF
}
# ============================================
# Helpers
# ============================================
function cmd::block::get_client_ip() {
local name="$1"
local conf
conf="$(ctx::clients)/${name}.conf"
if [[ ! -f "$conf" ]]; then
log::error "Client not found: ${name}"
return 1
fi
grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1
}
# ============================================
# Block Run
# ============================================
@ -112,7 +95,7 @@ function cmd::block::run() {
endpoint=$(cmd::block::_get_endpoint "$name" "$public_key")
local client_ip
client_ip=$(cmd::block::get_client_ip "$name") || return 1
client_ip=$(peers::get_ip "$name") || return 1
# $quiet || log::section "Blocking client: ${name} (${client_ip})"

View file

@ -119,9 +119,9 @@ function cmd::list::_format_last_seen() {
local handshake_ts="${6:-0}"
if [[ "$is_blocked" == "true" ]]; then
if [[ -n "$last_ts" ]]; then
if [[ -n "$last_ts" && "$last_ts" != "0" && "$last_ts" != "null" ]]; then
local formatted
formatted=$(fmt::datetime "$last_ts")
formatted=$(fmt::datetime_iso "$last_ts")
echo "${formatted} (dropped)"
else
echo "—"

View file

@ -18,6 +18,7 @@ function cmd::rule::on_load() {
flag::register --peer
flag::register --peers
flag::register --dns-redirect
flag::register --color
}
# ============================================
@ -87,6 +88,7 @@ function cmd::rule::run() {
assign) cmd::rule::assign "$@" ;;
unassign) cmd::rule::unassign "$@" ;;
migrate) cmd::rule::migrate "$@" ;;
reapply) cmd::rule::reapply "$@" ;;
help) cmd::rule::help ;;
*)
log::error "Unknown subcommand: '${subcmd}'"
@ -131,87 +133,71 @@ function cmd::rule::list() {
# ============================================
function cmd::rule::show() {
local name="" show_peers=false
local name="" show_peers=false color=false
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--peers) show_peers=true; shift ;;
--color) color=true; shift ;;
--help) cmd::rule::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
if [[ -z "$name" ]]; then
log::error "Missing required flag: --name"
return 1
fi
[[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
rule::require_exists "$name" || return 1
local rule_file
rule_file="$(ctx::rule::path "${name}.rule")"
# Precompute peers before any operations
local peer_list=()
mapfile -t peer_list < <(peers::with_rule "$name")
local peer_count=${#peer_list[@]}
log::section "Rule: ${name}"
local desc dns_redirect
desc=$(json::get "$rule_file" "desc")
dns_redirect=$(json::get "$rule_file" "dns_redirect")
printf "\n %-20s %s\n" "Description:" "${desc:-}"
printf " %-20s %s\n" "DNS Redirect:" "${dns_redirect:-false}"
printf "\n"
ui::row "Description" "${desc:-}"
ui::row "DNS Redirect" "${dns_redirect:-false}"
# Allow ports
local allow_ports
# Load all entries
local allow_ports allow_ips block_ips block_ports
allow_ports=$(json::get "$rule_file" "allow_ports")
if [[ -n "$allow_ports" ]]; then
printf "\n Allow Ports:\n"
while IFS= read -r entry; do
printf " + %s\n" "$entry"
done <<< "$allow_ports"
fi
# Allow IPs
local allow_ips
allow_ips=$(json::get "$rule_file" "allow_ips")
if [[ -n "$allow_ips" ]]; then
printf "\n Allow IPs:\n"
while IFS= read -r ip; do
printf " + %s\n" "$ip"
done <<< "$allow_ips"
fi
# Block IPs
local block_ips
block_ips=$(json::get "$rule_file" "block_ips")
if [[ -n "$block_ips" ]]; then
printf "\n Block IPs:\n"
while IFS= read -r ip; do
printf " - %s\n" "$ip"
done <<< "$block_ips"
fi
# Block ports
local block_ports
block_ports=$(json::get "$rule_file" "block_ports")
if [[ -n "$block_ports" ]]; then
printf "\n Block Ports:\n"
while IFS= read -r entry; do
printf " - %s\n" "$entry"
done <<< "$block_ports"
# Allow section
if [[ -n "$allow_ports" || -n "$allow_ips" ]]; then
cmd::rule::_show_section "Allow" "green" "$color"
cmd::rule::_show_entries "Ports" "+" "$allow_ports" "$color" "green"
cmd::rule::_show_entries "IPs" "+" "$allow_ips" "$color" "green"
fi
# Precompute peers before any other operations
local peer_list=()
mapfile -t peer_list < <(peers::with_rule "$name")
local peer_count=${#peer_list[@]}
# Block section
if [[ -n "$block_ips" || -n "$block_ports" ]]; then
cmd::rule::_show_section "Block" "red" "$color"
cmd::rule::_show_entries "IPs" "-" "$block_ips" "$color" "red"
cmd::rule::_show_entries "Ports" "-" "$block_ports" "$color" "red"
fi
# Peer count — always shown
printf "\n %-20s %s\n" "Assigned Peers:" "$peer_count"
printf " %s\n" "$(printf '─%.0s' {1..40})"
if [[ -z "$allow_ports" && -z "$allow_ips" && -z "$block_ips" && -z "$block_ports" ]]; then
printf "\n"
ui::row "Access" "full (no restrictions)"
fi
# Peers section
cmd::rule::_show_section "Peers" "white" false
ui::row "Assigned" "$peer_count"
# Peer details — only with --peers flag
if $show_peers && [[ $peer_count -gt 0 ]]; then
printf "\n"
for peer_name in "${peer_list[@]}"; do
local ip
ip=$(peers::get_ip "$peer_name")
@ -222,6 +208,97 @@ function cmd::rule::show() {
printf "\n"
}
# function cmd::rule::show() {
# local name="" show_peers=false
# while [[ $# -gt 0 ]]; do
# case "$1" in
# --name) name="$2"; shift 2 ;;
# --peers) show_peers=true; shift ;;
# --help) cmd::rule::help; return ;;
# *) log::error "Unknown flag: $1"; return 1 ;;
# esac
# done
# if [[ -z "$name" ]]; then
# log::error "Missing required flag: --name"
# return 1
# fi
# rule::require_exists "$name" || return 1
# local rule_file
# rule_file="$(ctx::rule::path "${name}.rule")"
# log::section "Rule: ${name}"
# local desc dns_redirect
# desc=$(json::get "$rule_file" "desc")
# dns_redirect=$(json::get "$rule_file" "dns_redirect")
# printf "\n"
# ui::row "Description" "${desc:-—}"
# ui::row "DNS Redirect" "${dns_redirect:-false}"
# # Allow ports
# local allow_ports
# allow_ports=$(json::get "$rule_file" "allow_ports")
# if [[ -n "$allow_ports" ]]; then
# printf "\n Allow Ports:\n"
# ui::print_list "+" "$allow_ports"
# fi
# # Allow IPs
# local allow_ips
# allow_ips=$(json::get "$rule_file" "allow_ips")
# if [[ -n "$allow_ips" ]]; then
# printf "\n Allow IPs:\n"
# while IFS= read -r ip; do
# printf " + %s\n" "$ip"
# done <<< "$allow_ips"
# fi
# # Block IPs
# local block_ips
# block_ips=$(json::get "$rule_file" "block_ips")
# if [[ -n "$block_ips" ]]; then
# printf "\n Block IPs:\n"
# while IFS= read -r ip; do
# printf " - %s\n" "$ip"
# done <<< "$block_ips"
# fi
# # Block ports
# local block_ports
# block_ports=$(json::get "$rule_file" "block_ports")
# if [[ -n "$block_ports" ]]; then
# printf "\n Block Ports:\n"
# while IFS= read -r entry; do
# printf " - %s\n" "$entry"
# done <<< "$block_ports"
# fi
# # Precompute peers before any other operations
# local peer_list=()
# mapfile -t peer_list < <(peers::with_rule "$name")
# local peer_count=${#peer_list[@]}
# # Peer count — always shown
# printf "\n %-20s %s\n" "Assigned Peers:" "$peer_count"
# printf " %s\n" "$(printf '─%.0s' {1..40})"
# # Peer details — only with --peers flag
# if $show_peers && [[ $peer_count -gt 0 ]]; then
# for peer_name in "${peer_list[@]}"; do
# local ip
# ip=$(peers::get_ip "$peer_name")
# printf " %-28s %s\n" "$peer_name" "$ip"
# done
# fi
# printf "\n"
# }
# ============================================
# Add
# ============================================
@ -260,20 +337,15 @@ function cmd::rule::add() {
local rule_file
rule_file="$(ctx::rule::path "${name}.rule")"
# Build JSON using json_helper
python3 -c "
import json
rule = {
'name': '${name}',
'desc': '${desc}',
'dns_redirect': $(${dns_redirect} && echo 'true' || echo 'false'),
'allow_ips': [$(printf '"%s",' "${allow_ips[@]}" | sed 's/,$//')] ,
'block_ips': [$(printf '"%s",' "${block_ips[@]}" | sed 's/,$//')],
'block_ports': [$(printf '"%s",' "${block_ports[@]}" | sed 's/,$//')]
}
with open('${rule_file}', 'w') as f:
json.dump(rule, f, indent=2)
"
local allow_str block_str port_str
allow_str=$(IFS=','; echo "${allow_ips[*]}")
block_str=$(IFS=','; echo "${block_ips[*]}")
port_str=$(IFS=','; echo "${block_ports[*]}")
json::create_rule "$rule_file" "$name" "$desc" \
"$($dns_redirect && echo true || echo false)" \
"$allow_str" "$block_str" "$port_str" || return 1
log::wg_success "Rule created: ${name}"
}
@ -369,19 +441,21 @@ function cmd::rule::remove() {
rule::require_exists "$name" || return 1
# Check for assigned peers
local peer_count
peer_count=$(peers::with_rule "$name" | grep -c . || echo 0)
local peer_list=()
mapfile -t peer_list < <(peers::with_rule "$name")
local peer_count=${#peer_list[@]}
if [[ "$peer_count" -gt 0 ]]; then
log::error "Rule '${name}' is assigned to ${peer_count} peer(s) — unassign first or use --force"
if ! $force; then
return 1
fi
$force || return 1
# Force: unassign from all peers
while IFS= read -r peer; do
for peer in "${peer_list[@]}"; do
local ip
ip=$(peers::get_ip "$peer")
rule::unapply "$name" "$ip"
done < <(peers::with_rule "$name")
done
fi
rm -f "$(ctx::rule::path "${name}.rule")"
@ -394,7 +468,6 @@ function cmd::rule::remove() {
function cmd::rule::assign() {
local name="" peer="" type=""
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
@ -421,15 +494,19 @@ function cmd::rule::assign() {
# Unapply existing rule first if any
local existing_rule
existing_rule=$(peers::get_meta "$peer" "rule")
if [[ -n "$existing_rule" ]]; then
local ip
ip=$(peers::get_ip "$peer")
log::debug "rule::assign: peer=$peer ip=$ip"
[[ -z "$ip" ]] && log::error "Could not resolve IP for: $peer" && return 1
log::debug "assign: peer=$peer ip=$ip clients=$(ctx::clients)"
if [[ -n "$existing_rule" && "$existing_rule" != "$name" ]]; then
rule::unapply "$existing_rule" "$ip"
log::wg "Removed existing rule '${existing_rule}' from: ${peer}"
fi
local ip
ip=$(peers::get_ip "$peer")
rule::apply "$name" "$ip"
log::wg_success "Assigned rule '${name}' to: ${peer}"
@ -502,21 +579,76 @@ function cmd::rule::migrate() {
local count=0
local lines
mapfile -t lines < "$tmp"
echo "DEBUG: lines count=${#lines[@]}"
for line in "${lines[@]}"; do
echo "DEBUG: processing line=$line"
for line in "${lines[@]}"; do
IFS=" " read -r peer_name default_rule ip <<< "$line"
rule::apply "$default_rule" "$ip" "$peer_name" </dev/null
echo "DEBUG: after apply, count=$count"
(( count++ )) || true
echo "DEBUG: incremented count=$count"
done
echo "DEBUG: loop done, count=$count"
echo "DEBUG: final count=$count"
echo "DEBUG: tmp contents:"
cat "$tmp"
rm -f "$tmp"
log::wg_success "Migrated ${count} peers"
}
# ============================================
# Reapply Rule
# ============================================
function cmd::rule::reapply() {
local name=""
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
[[ -z "$name" ]] && log::error "Missing --name" && return 1
rule::require_exists "$name" || return 1
rule::reapply_all "$name"
log::wg_success "Rule '${name}' reapplied"
}
# ============================================
# Show helpers
# ============================================
function cmd::rule::_show_section() {
local title="${1:-}" color="${2:-white}" use_color="${3:-false}"
local color_code=""
if $use_color; then
case "$color" in
green) color_code="\033[0;32m" ;;
red) color_code="\033[0;31m" ;;
esac
fi
printf "\n ${color_code}── %s ──────────────────────────────────\033[0m\n" "$title"
}
function cmd::rule::_show_entries() {
local label="${1:-}" prefix="${2:-}" entries="${3:-}"
local use_color="${4:-false}" color="${5:-white}"
[[ -z "$entries" ]] && return 0
local color_code="" reset=""
if $use_color; then
case "$color" in
green) color_code="\033[0;32m" ;;
red) color_code="\033[0;31m" ;;
esac
reset="\033[0m"
fi
printf " %-8s" "${label}:"
local first=true
while IFS= read -r entry; do
[[ -z "$entry" ]] && continue
if $first; then
printf "%b%s %s%b\n" "$color_code" "$prefix" "$entry" "$reset"
first=false
else
printf " %b%s %s%b\n" "$color_code" "$prefix" "$entry" "$reset"
fi
done <<< "$entries"
}

View file

@ -121,6 +121,7 @@ function cmd::test::run_function() {
cmd::rule::assign) cmd::test::fn_rule_assign ;;
cmd::rename::run) cmd::test::fn_rename ;;
cmd::remove::run) cmd::test::fn_remove ;;
cmd::unblock::run) cmd::test::fn_unblock ;;
*)
log::error "No function test defined for: ${fn}"
return 1
@ -166,7 +167,7 @@ function cmd::test::section_rules() {
test::section "Rules"
cmd::test::run_cmd "rule list" "guest" rule list
cmd::test::run_cmd "rule show --name guest" "Description" rule show --name guest
cmd::test::run_cmd "rule show --name guest --peers" "Peers:" rule show --name guest --peers
cmd::test::run_cmd "rule show --name guest --peers" "Assigned:" rule show --name guest --peers
cmd::test::run_cmd "rule show --name user" "Description" rule show --name user
cmd::test::run_cmd "rule show --name admin" "Description" rule show --name admin
cmd::test::run_cmd_fails "rule show nonexistent" rule show --name nonexistent
@ -217,6 +218,9 @@ function cmd::test::section_destructive() {
cmd::test::run_cmd "add phone peer" "added successfully" \
add --name testunit --type phone
# Debug: show what was created
"$WGCTL_BINARY" list | grep testunit >&2
# Block/unblock
cmd::test::run_cmd "block peer" "blocked" \
block --name phone-testunit
@ -227,9 +231,12 @@ function cmd::test::section_destructive() {
# Rule assign/unassign
cmd::test::run_cmd "rule assign" "Assigned" \
rule assign --name admin --peer phone-testunit
rule assign --name user --peer phone-testunit
cmd::test::run_cmd "rule unassign" "Unassigned" \
rule unassign --peer phone-testunit
# Re-assign user rule (default) for cleanup
/usr/local/bin/wgctl rule assign --name user --peer phone-testunit \
> /dev/null 2>&1 || true
# Group operations
cmd::test::run_cmd "group add" "created" \
@ -318,10 +325,43 @@ function cmd::test::fn_rename() {
rename --name phone-testunit2
# Cleanup
/usr/local/bin/wgctl remove --name phone-testunit2 --force \
"$WGCTL_BINARY" remove --name phone-testunit2 --force \
> /dev/null 2>&1 || true
}
function cmd::test::fn_unblock() {
test::section "cmd::unblock::run"
# Setup — add and block a peer
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
"$WGCTL_BINARY" add --name testunit --type phone > /dev/null 2>&1
"$WGCTL_BINARY" block --name phone-testunit > /dev/null 2>&1
# Tests
cmd::test::run_cmd "unblock peer" "unblocked" unblock --name phone-testunit
cmd::test::run_cmd "unblock not blocked" "not blocked" unblock --name phone-testunit
cmd::test::run_cmd_fails "unblock nonexistent" unblock --name nonexistent-peer
cmd::test::run_cmd_fails "unblock missing --name" unblock
# Cleanup
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
}
function cmd::test::fn_rule_assign() {
test::section "cmd::rule::assign"
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
"$WGCTL_BINARY" add --name testunit --type phone > /dev/null 2>&1
# Verify peer exists
echo "DEBUG peer exists: $("$WGCTL_BINARY" list | grep phone-testunit)"
cmd::test::run_cmd "rule assign" "Assigned" \
rule assign --name admin --peer phone-testunit
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
}
# ============================================
# Run
# ============================================

View file

@ -40,23 +40,6 @@ Examples:
EOF
}
# ============================================
# Helpers
# ============================================
function cmd::unblock::get_client_ip() {
local name="$1"
local conf
conf="$(ctx::clients)/${name}.conf"
if [[ ! -f "$conf" ]]; then
log::error "Client not found: ${name}"
return 1
fi
grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1
}
# ============================================
# Unblock Run
# ============================================
@ -109,24 +92,12 @@ function cmd::unblock::run() {
fi
local client_ip
client_ip=$(cmd::unblock::get_client_ip "$name") || return 1
client_ip=$(peers::get_ip "$name") || return 1
# $quiet || log::section "Unblocking client: ${name} (${client_ip})"
if $all; then
fw::unblock_all "$client_ip"
fw::remove_block_file "$name"
monitor::unwatch_client "$name"
# Re-add peer to server if missing
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
$quiet || log::wg_success "${name} has been unblocked."
cmd::unblock::_unblock_all "$name" "$client_ip" "$quiet"
return 0
fi
@ -150,3 +121,24 @@ function cmd::unblock::run() {
$quiet || log::wg_success "Unblock rules applied for: ${name}"
}
function cmd::unblock::_unblock_all() {
local name="${1:-}"
local client_ip="${2:-}"
local quiet="${3:-false}"
log::debug "_unblock_all: name=$name ip=$client_ip"
fw::unblock_all "$client_ip"
fw::remove_block_file "$name"
monitor::unwatch_client "$name"
if ! peers::exists_in_server "$name"; then
local public_key
public_key=$(keys::public "$name") || return 1
log::debug "_unblock_all: adding to server pub=$public_key"
peers::add_to_server "$name" "$public_key" "$client_ip"
peers::reload
fi
$quiet || log::wg_success "${name} has been unblocked."
}

View file

@ -27,3 +27,4 @@ function json::iso_to_ts() { python3 "$JSON_HELPER" iso_to_ts "$@" </de
function json::rule_list_data() { python3 "$JSON_HELPER" rule_list_data "$@" </dev/null; }
function json::group_list_data() { python3 "$JSON_HELPER" group_list_data "$@" </dev/null; }
function json::fmt_datetime() { python3 "$JSON_HELPER" fmt_datetime "$@" </dev/null; }
function json::create_rule() { python3 "$JSON_HELPER" create_rule "$@" </dev/null; }

View file

@ -625,6 +625,19 @@ def fmt_datetime(iso_str, fmt):
except:
print(iso_str)
def create_rule(file, name, desc, dns_redirect, allow_ips, block_ips, block_ports):
rule = {
'name': name,
'desc': desc,
'dns_redirect': dns_redirect == 'true',
'allow_ips': [x for x in allow_ips.split(',') if x] if allow_ips else [],
'block_ips': [x for x in block_ips.split(',') if x] if block_ips else [],
'block_ports': [x for x in block_ports.split(',') if x] if block_ports else [],
'allow_ports': []
}
with open(file, 'w') as f:
json.dump(rule, f, indent=2)
commands = {
'get': lambda args: get(args[0], args[1]),
'set': lambda args: set_key(args[0], args[1], args[2]),
@ -651,6 +664,8 @@ commands = {
'rule_list_data': lambda args: rule_list_data(args[0], args[1]),
'group_list_data': lambda args: group_list_data(args[0], args[1]),
'fmt_datetime': lambda args: fmt_datetime(args[0], args[1]),
'create_rule': lambda args: create_rule(args[0], args[1], args[2], args[3], args[4], args[5], args[6]),
}
if __name__ == '__main__':

View file

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

View file

@ -76,7 +76,9 @@ function rule::apply() {
local client_ip="$2"
local peer_name="${3:-}" # optional, avoids find_by_ip call
log::debug "rule::apply ENTRY: rule=$rule_name ip=$client_ip peer=$peer_name"
rule::require_exists "$rule_name" || return 1
log::debug "rule::apply: exists check passed"
# Use provided peer_name or look it up
if [[ -z "$peer_name" ]]; then

2
wgctl
View file

@ -3,7 +3,7 @@ set -Eeuo pipefail
source "$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)/core.sh"
# LOG_LEVEL=DEBUG
LOG_LEVEL=DEBUG
# ============================================
# Modules