feat: main group display, group::has_peer, config validation, full block cleanup on unblock, ui::empty helper, blocks header count

This commit is contained in:
Nuno Duque Nunes 2026-05-17 22:06:21 +00:00
parent 87f6c770ef
commit 7323bf20f1
14 changed files with 536 additions and 197 deletions

View file

@ -94,7 +94,7 @@ function cmd::block::run() {
# Full block if no specific targets
if [[ ${#ips[@]} -eq 0 && ${#ports[@]} -eq 0 && \
${#subnets[@]} -eq 0 && ${#services[@]} -eq 0 ]]; then
if peers::is_blocked "$name" || block::has_file "$name"; then
if peers::is_blocked "$name"; then
log::wg_warning "Client is already blocked: ${name}"
return 0
fi

View file

@ -11,6 +11,7 @@ function cmd::group::on_load() {
flag::register --type
flag::register --rule
flag::register --new-name
flag::register --main
flag::register --force
}
@ -83,6 +84,7 @@ function cmd::group::run() {
rename) cmd::group::rename "$@" ;;
peer) cmd::group::peer "$@" ;;
rm-peers) cmd::group::rm_peers "$@" ;;
set-main) cmd::group::set_main "$@" ;;
block) cmd::group::block "$@" ;;
unblock) cmd::group::unblock "$@" ;;
rule) cmd::group::rule "$@" ;;
@ -343,13 +345,14 @@ function cmd::group::peer() {
}
function cmd::group::peer_add() {
local name="" peer="" type=""
local name="" peer="" type="" set_main=false
while [[ $# -gt 0 ]]; do
case "$1" in
--name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;;
--peer) util::require_flag "--peer" "${2:-}" || return 1; peer="$2"; shift 2 ;;
--type) util::require_flag "--type" "${2:-}" || return 1; type="$2"; shift 2 ;;
--main) util::require_flag "--main" "${2:-}" || return 1; set_main=true; shift ;;
--help) cmd::group::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
@ -369,6 +372,11 @@ function cmd::group::peer_add() {
group::add_peer "$name" "$peer"
log::wg_success "Added '${peer}' to group '${name}'"
if $set_main; then
peers::set_main_group "$peer_name" "$group_name"
log::wg_success "Set '${group_name}' as main group for ${peer_name}"
fi
}
function cmd::group::peer_remove() {
@ -393,6 +401,34 @@ function cmd::group::peer_remove() {
log::wg_success "Removed '${peer}' from group '${name}'"
}
function cmd::group::set_main() {
local group_name="" peer_name="" type=""
while [[ $# -gt 0 ]]; do
case "$1" in
--name) group_name="$2"; shift 2 ;;
--peer) peer_name="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
[[ -z "$group_name" ]] && log::error "Missing --name" && return 1
[[ -z "$peer_name" ]] && log::error "Missing --peer" && return 1
# Resolve peer name
peer_name=$(peers::resolve_and_require "$peer_name" "$type") || return 1
# Verify peer is in the group
if ! group::has_peer "$group_name" "$peer_name"; then
log::error "Peer '${peer_name}' is not in group '${group_name}'"
log::info "Add them first: wgctl group peer add --name ${group_name} --peer ${peer_name}"
return 1
fi
peers::set_main_group "$peer_name" "$group_name"
log::wg_success "Main group for '${peer_name}' set to '${group_name}'"
}
# ============================================
# Remove peers from WireGuard
# ============================================
@ -444,56 +480,6 @@ function cmd::group::_rm_peer_cb() {
cmd::remove::run --name "$peer_name" --force
}
# function cmd::group::rm_peers() {
# local name="" force=false
# while [[ $# -gt 0 ]]; do
# case "$1" in
# --name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;;
# --force) force=true; shift ;;
# --help) cmd::group::help; return ;;
# *) log::error "Unknown flag: $1"; return 1 ;;
# esac
# done
# [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
# group::require_exists "$name" || return 1
# local peers_list=()
# mapfile -t peers_list < <(group::peers "$name")
# local peer_count=${#peers_list[@]}
# [[ -z "${peers_list[0]}" ]] && peer_count=0
# if [[ "$peer_count" -eq 0 ]]; then
# log::wg_warning "Group '${name}' has no peers"
# return 0
# fi
# if ! $force; then
# read -r -p "Remove all ${peer_count} peers in group '${name}' from WireGuard? [y/N] " confirm
# case "$confirm" in
# [yY][eE][sS]|[yY]) ;;
# *) log::info "Aborted"; return 0 ;;
# esac
# fi
# local count=0
# for peer_name in "${peers_list[@]}"; do
# [[ -z "$peer_name" ]] && continue
# # Skip if peer no longer exists
# if ! peers::require_exists "$peer_name" > /dev/null 2>&1; then
# log::wg_warning "Peer '${peer_name}' no longer exists — skipping"
# continue
# fi
# cmd::remove::run --name "$peer_name" --force
# (( count++ )) || true
# done
# log::wg_success "Removed ${count} peers from WireGuard (group '${name}' definition kept)"
# }
# ============================================
# Block / Unblock
# ============================================

View file

@ -36,13 +36,24 @@ Examples:
EOF
}
INSPECT_WIDTH=48 # total visible width of section lines
INSPECT_LABEL_WIDTH=20
# ============================================
# Private helpers
# ============================================
function cmd::inspect::_section() {
local title="$1"
printf "\n \033[0;37m── %s ──────────────────────────────────\033[0m\n" "$title"
local title="${1:-}" extra="${2:-0}"
local width=$(( INSPECT_WIDTH + extra ))
local title_len=${#title}
# Account for "── " (3) + " " (1) before dashes
local dash_count=$(( width - title_len - 4 ))
[[ $dash_count -lt 2 ]] && dash_count=2
local dashes
dashes=$(printf '─%.0s' $(seq 1 $dash_count))
printf "\n \033[0;37m── %s %s\033[0m\n" "$title" "$dashes"
}
function cmd::inspect::_peer_info() {
@ -66,7 +77,7 @@ function cmd::inspect::_peer_info() {
block::has_specific_rules "$name" 2>/dev/null && is_restricted="true"
local status last_seen endpoint
status=$(peers::format_status "$name" "$public_key" \
status=$(peers::format_status_verbose "$name" "$public_key" \
"$is_blocked" "$is_restricted" "$handshake_ts" "$last_ts")
last_seen=$(peers::format_last_seen "$name" "$public_key" \
"$is_blocked" "$last_ts" "" "$handshake_ts")
@ -103,17 +114,18 @@ function cmd::inspect::_peer_info() {
fi
cmd::inspect::_section "Client"
ui::row "Name" "$name"
ui::row "IP" "$ip"
ui::row "Type" "$(peers::display_type "$type" "$subtype")"
ui::row "Rule" "$rule_display"
ui::row "Status" "$(echo -e "$status")"
ui::row "Endpoint" "${endpoint:-}"
ui::row "Last seen" "$last_seen"
ui::row "AllowedIPs" "$allowed_ips"
ui::row "Public key" "${public_key:-}"
ui::row "Activity (total)" "$activity_total"
ui::row "Activity (current)" "$activity_current"
printf "\n"
ui::row "Name" "$name" "${INSPECT_LABEL_WIDTH}"
ui::row "IP" "$ip" "${INSPECT_LABEL_WIDTH}"
ui::row "Type" "$(peers::display_type "$type" "$subtype")" "${INSPECT_LABEL_WIDTH}"
ui::row "Rule" "$rule_display" "${INSPECT_LABEL_WIDTH}"
ui::row "Status" "$(echo -e "$status")" "${INSPECT_LABEL_WIDTH}"
ui::row "Endpoint" "${endpoint:-}" "${INSPECT_LABEL_WIDTH}"
ui::row "Last seen" "$last_seen" "${INSPECT_LABEL_WIDTH}"
ui::row "AllowedIPs" "$allowed_ips" "${INSPECT_LABEL_WIDTH}"
ui::row "Public key" "${public_key:-}" "${INSPECT_LABEL_WIDTH}"
ui::row "Activity (total)" "$activity_total" "${INSPECT_LABEL_WIDTH}"
ui::row "Activity (current)" "$activity_current" "${INSPECT_LABEL_WIDTH}"
return 0
}
@ -128,7 +140,8 @@ function cmd::inspect::_rule_info() {
cmd::inspect::_section "Rule: ${rule}"
if rule::render_extends_tree "$rule"; then
printf "\n"
# printf "\n"
: # no-op
else
# No inheritance — flat view
rule::render_flat "$rule"
@ -140,20 +153,44 @@ 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")
local rules_output
rules_output=$(block::get_rules "$name")
# Skip if truly empty
if [[ "$blocked_direct" != "true" ]] && \
ui::empty "$blocked_groups" && \
ui::empty "$rules_output"; then
block::cleanup "$name" # clean up stale empty file
return 0
fi
# Count rules for header
local rule_count=0
while IFS= read -r line; do
[[ -n "$line" ]] && (( rule_count++ )) || true
done <<< "$rules_output"
# Build header like firewall: Blocks (+N)
local header_counts=""
[[ "$rule_count" -gt 0 ]] && header_counts=" (${rule_count})"
[[ "$blocked_direct" == "true" || -n "$blocked_groups" ]] && \
header_counts="${header_counts} 🚫"
cmd::inspect::_section "Blocks${header_counts}"
printf "\n"
[[ "$blocked_direct" == "true" ]] && \
printf " \033[1;31m🚫\033[0m blocked directly\n"
[[ -n "$blocked_groups" ]] && \
printf " \033[1;31m🚫\033[0m blocked by groups: %s\n" "$blocked_groups"
block::format_rules "$name"
return 0
}
@ -162,20 +199,22 @@ function cmd::inspect::_group_info() {
local groups=()
mapfile -t groups < <(json::peer_groups "$(ctx::groups)" "$name")
[[ ${#groups[@]} -eq 0 || -z "${groups[0]:-}" ]] && return 0
ui::section "Groups"
ui::empty "${groups[*]}" && return 0
if [[ ${#groups[@]} -eq 0 ]] || [[ -z "${groups[0]:-}" ]]; then
printf " —\n"
return 0
fi
local count=${#groups[@]}
cmd::inspect::_section "Groups (${count})"
printf "\n"
for g in "${groups[@]}"; do
[[ -z "$g" ]] && continue
local count
count=$(json::count "$(group::path "$g")" "peers")
printf " %-20s %s peers\n" "$g" "$count"
local peer_count
local main_marker=""
peer_count=$(json::count "$(group::path "$g")" "peers")
[[ "$g" == "$(peers::get_main_group "$name")" ]] && \
main_marker=" \033[0;33m★\033[0m"
printf " \033[0;37m·\033[0m %-20s \033[0;37m%s peers\033[0m%b\n" \
"$g" "$peer_count" "$main_marker"
done
return 0
@ -196,24 +235,15 @@ function cmd::inspect::_firewall_info() {
rules_output+=("$line")
done < <(fw::forward_rules_for_ip "$ip" | grep -v NFLOG)
[[ ${#rules_output[@]} -eq 0 || -z "${rules_output[0]:-}" ]] && return 0
# printf "\n \033[0;37m── Firewall (\033[0;32m+%d\033[0m \033[0;31m-%d\033[0m) \033[0m%s\n" \
# "$accepts" "$drops" "$(printf '─%.0s' {1..28})"
ui::empty "${rules_output[*]}" && return 0
printf "\n \033[0;37m── Firewall (%s %s) \033[0m%s\n\n" \
"$(color::green "+${accepts}")" \
"$(color::red "-${drops}")" \
"$(printf '─%.0s' {1..28})"
"$(printf '\033[0;37m─%.0s' {1..28})"
fw::list_peer_rules "$ip" false
# if [[ ${#rules_output[@]} -gt 0 ]]; then
# for line in "${rules_output[@]}"; do
# fw::format_rule "$line"
# done
# fi
return 0
}

View file

@ -289,6 +289,76 @@ function cmd::list::_iter_confs() {
done
}
# function cmd::list::_render_row() {
# local client_name="$1" ip="$2" type="$3"
# local pubkey="${p_pubkeys[$client_name]:-}"
# local handshake_ts="${wg_handshakes[$pubkey]:-0}"
# local is_blocked="${p_blocked[$client_name]:-false}"
# local is_restricted="${p_restricted[$client_name]:-false}"
# local last_ts="${p_last_ts[$client_name]:-}"
# # Apply status filters
# if $online_only; then peers::is_online "$client_name" "$handshake_ts" "$last_ts" || return 0; fi
# if $offline_only; then peers::is_offline "$client_name" "$handshake_ts" "$last_ts" || return 0; fi
# if $restricted_only && [[ "$is_restricted" != "true" ]]; then return 0; fi
# if $blocked_only && [[ "$is_blocked" != "true" ]]; then return 0; fi
# if $allowed_only && { [[ "$is_blocked" == "true" ]] || \
# [[ "$is_restricted" == "true" ]]; }; then return 0; fi
# if [[ -n "$filter_group" ]]; then
# local peer_group="${peer_group_map[$client_name]:-}"
# [[ "$peer_group" != "$filter_group" ]] && return 0
# fi
# # Format display values
# local status last_seen display_type rule group_display
# status=$(peers::format_status "$client_name" "$pubkey" \
# "$is_blocked" "$is_restricted" "$handshake_ts" "$last_ts")
# last_seen=$(peers::format_last_seen "$client_name" "$pubkey" \
# "$is_blocked" "$last_ts" "" "$handshake_ts")
# display_type=$(peers::display_type "$type" "${p_subtypes[$client_name]:-}")
# rule="${p_rules[$client_name]:-—}"
# if [[ -n "$filter_rule" && "$rule" != "$filter_rule" ]]; then return 0; fi
# # Print header on first match
# if [[ "${_list_header_printed:-false}" == "false" ]]; then
# log::section "WireGuard Clients"
# cmd::list::_render_header $has_groups
# _list_header_printed=true
# fi
# # Update rule counts for summary (outer scope array)
# rule_counts["$rule"]=$(( ${rule_counts[$rule]:-0} + 1 )) || true
# # Pad status
# local padded_status
# padded_status=$(ui::pad_status "$status" 25)
# # Render row
# if $has_groups; then
# group_display="${peer_group_map[$client_name]:-—}"
# if [[ -n "${peer_group_map[$client_name]:-}" ]]; then
# group_counts["$group_display"]=$(( ${group_counts[$group_display]:-0} + 1 )) || true
# fi
# local rule_col_width=12 group_col_width=12
# [[ "$rule" == "—" ]] && rule_col_width=14
# [[ "$group_display" == "—" ]] && group_col_width=14
# printf " %-28s %-15s %-13s %-${rule_col_width}s %-${group_col_width}s %s %s\n" \
# "$client_name" "$ip" "$display_type" "$rule" \
# "$group_display" "$padded_status" "$last_seen"
# else
# local rule_col_width=12
# [[ "$rule" == "—" ]] && rule_col_width=14
# printf " %-28s %-15s %-13s %-${rule_col_width}s %s %s\n" \
# "$client_name" "$ip" "$display_type" "$rule" \
# "$padded_status" "$last_seen"
# fi
# }
function cmd::list::_render_row() {
local client_name="$1" ip="$2" type="$3"
@ -307,8 +377,8 @@ function cmd::list::_render_row() {
[[ "$is_restricted" == "true" ]]; }; then return 0; fi
if [[ -n "$filter_group" ]]; then
local peer_group="${peer_group_map[$client_name]:-}"
[[ "$peer_group" != "$filter_group" ]] && return 0
local all_groups="${peer_group_map[$client_name]:-}"
[[ "$all_groups" != *"$filter_group"* ]] && return 0
fi
# Format display values
@ -329,16 +399,19 @@ function cmd::list::_render_row() {
_list_header_printed=true
fi
# Update rule counts for summary (outer scope array)
rule_counts["$rule"]=$(( ${rule_counts[$rule]:-0} + 1 )) || true
# Pad status
local padded_status
padded_status=$(ui::pad_status "$status" 25)
# Render row
if $has_groups; then
# Use main group for display, fall back to first group, then —
local main_group="${p_main_groups[$client_name]:-}"
if [[ -n "$main_group" ]]; then
group_display="$main_group"
else
group_display="${peer_group_map[$client_name]:-}"
fi
if [[ -n "${peer_group_map[$client_name]:-}" ]]; then
group_counts["$group_display"]=$(( ${group_counts[$group_display]:-0} + 1 )) || true
@ -365,14 +438,15 @@ function cmd::list::_render_row() {
function cmd::list::_precompute_all() {
# Peer data
declare -gA p_ips=() p_rules=() p_subtypes=() p_last_ts=() p_last_evt=()
while IFS="|" read -r name ip rule subtype last_ts last_evt; do
declare -gA p_ips=() p_rules=() p_subtypes=() p_last_ts=() p_last_evt=() p_main_groups=()
while IFS="|" read -r name ip rule subtype last_ts last_evt main_group; do
[[ -z "$name" ]] && continue
p_ips["$name"]="$ip"
p_rules["$name"]="${rule:-}"
p_subtypes["$name"]="$subtype"
p_last_ts["$name"]="$last_ts"
p_last_evt["$name"]="$last_evt"
p_main_groups["$name"]="${main_group:-}"
done < <(json::peer_data "$(ctx::clients)" "$(ctx::meta)" "$(ctx::events_log)")
# WireGuard handshakes + endpoints
@ -399,7 +473,7 @@ function cmd::list::_precompute_all() {
p_pubkeys["$kname"]=$(cat "$kf" 2>/dev/null || echo "")
done
# Groups
# Groups + main group
has_groups=false
declare -gA peer_group_map=()
local groups_dir
@ -422,6 +496,65 @@ function cmd::list::_precompute_all() {
done < <(json::peer_transfer "$(config::interface)")
}
# function cmd::list::_precompute_all() {
# # Peer data
# declare -gA p_ips=() p_rules=() p_subtypes=() p_last_ts=() p_last_evt=()
# while IFS="|" read -r name ip rule subtype last_ts last_evt; do
# [[ -z "$name" ]] && continue
# p_ips["$name"]="$ip"
# p_rules["$name"]="${rule:-—}"
# p_subtypes["$name"]="$subtype"
# p_last_ts["$name"]="$last_ts"
# p_last_evt["$name"]="$last_evt"
# done < <(json::peer_data "$(ctx::clients)" "$(ctx::meta)" "$(ctx::events_log)")
# # WireGuard handshakes + endpoints
# declare -gA wg_handshakes=() wg_endpoints=()
# while IFS=$'\t' read -r pubkey ts; do
# [[ -n "$pubkey" ]] && wg_handshakes["$pubkey"]="$ts"
# done < <(wg show "$(config::interface)" latest-handshakes 2>/dev/null)
# while IFS=$'\t' read -r pubkey endpoint; do
# [[ -n "$pubkey" ]] && wg_endpoints["$pubkey"]="$endpoint"
# done < <(wg show "$(config::interface)" endpoints 2>/dev/null)
# # Block/restricted status
# declare -gA p_blocked=() p_restricted=()
# cmd::list::_precompute_block_status p_blocked p_restricted
# # Public keys
# declare -gA p_pubkeys=()
# local dir
# dir="$(ctx::clients)"
# for kf in "${dir}"/*_public.key; do
# [[ -f "$kf" ]] || continue
# local kname
# kname=$(basename "$kf" _public.key)
# p_pubkeys["$kname"]=$(cat "$kf" 2>/dev/null || echo "")
# done
# # Groups
# has_groups=false
# declare -gA peer_group_map=()
# local groups_dir
# groups_dir="$(ctx::groups)"
# local group_files=("${groups_dir}"/*.group)
# if [[ -f "${group_files[0]}" ]]; then
# has_groups=true
# while IFS=":" read -r peer_name group_name; do
# [[ -n "$peer_name" ]] && peer_group_map["$peer_name"]="$group_name"
# done < <(json::peer_group_map "$groups_dir")
# fi
# # Transfer/activity data — keyed by pubkey
# declare -gA p_rx=() p_tx=() p_activity=()
# while IFS="|" read -r pubkey rx tx level; do
# [[ -z "$pubkey" ]] && continue
# p_rx["$pubkey"]="$rx"
# p_tx["$pubkey"]="$tx"
# p_activity["$pubkey"]="$level"
# done < <(json::peer_transfer "$(config::interface)")
# }
function cmd::list::_precompute_block_status() {
local -n _blocked="$1"
local -n _restricted="$2"

View file

@ -191,11 +191,10 @@ function cmd::unblock::_unblock_all() {
# Direct unblock overrides everything — clear all block state
block::set_direct "$name" "$client_ip" "false"
block::clear_full_block "$name"
# Force full unblock regardless of group blocks
# (direct unblock = admin override)
block::restore_peer "$name" "$client_ip"
block::remove_file "$name"
block::cleanup "$name"
local rule
rule=$(peers::get_meta "$name" "rule")

View file

@ -59,6 +59,7 @@ function json::net_remove() { python3 "$JSON_HELPER" net_remove
function json::net_resolve() { python3 "$JSON_HELPER" net_resolve "$@" </dev/null; }
function json::net_reverse_lookup() { python3 "$JSON_HELPER" net_reverse_lookup "$@" </dev/null; }
function json::block_is_empty() { python3 "$JSON_HELPER" block_is_empty "$@" </dev/null; }
function json::group_has_peer() { python3 "$JSON_HELPER" group_has_peer "$@" </dev/null; }
function json::peer_transfer() {
ACTIVITY_TOTAL_LOW="$(config::activity_total_low)" \

View file

@ -640,12 +640,13 @@ def peer_data(clients_dir, meta_dir, events_log):
m = meta.get(name, {})
rule = m.get('rule', '')
subtype = m.get('subtype', '')
main_group = m.get('main_group', '')
last_event = last_events.get(name, {})
last_ts = last_event.get('timestamp', '') # raw ISO, no formatting
last_evt = last_event.get('event', '') # fixed: was last_event
print(f"{name}|{ip}|{rule}|{subtype}|{last_ts}|{last_evt}")
print(f"{name}|{ip}|{rule}|{subtype}|{last_ts}|{last_evt}|{main_group}")
def iso_to_ts(iso_str):
"""Convert ISO timestamp to unix timestamp"""
@ -1270,25 +1271,18 @@ def block_add_rule(file, peer_ip, rule_type, name="", target="",
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)
filtered = [r for r in rules if not (
r.get("type") == rule_type and
r.get("target", "") == target and
r.get("port", "") == port and
r.get("proto", "") == proto
)]
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"""
@ -1483,6 +1477,15 @@ def block_is_empty(file):
)
print("true" if empty else "false")
def group_has_peer(file, peer_name):
try:
with open(file) as f:
data = json.load(f)
peers = data.get('peers', [])
print('true' if peer_name in peers else 'false')
except Exception:
print('false')
commands = {
'get': lambda args: get(args[0], args[1]),
'set': lambda args: set_key(args[0], args[1], args[2]),
@ -1573,6 +1576,7 @@ commands = {
args[3] if len(args) > 3 else ''
),
'block_is_empty': lambda args: block_is_empty(args[0]),
'group_has_peer': lambda args: group_has_peer(args[0], args[1]),
}
if __name__ == '__main__':

View file

@ -41,6 +41,30 @@ function ui::pad() {
printf "%b%${pad}s" "$text" ""
}
function ui::pad_mb() {
local text="$1" width="${2:-20}"
local visible
visible=$(printf "%b" "$text" | sed 's/\x1b\[[0-9;]*m//g')
local vis_len
vis_len=$(python3 -c "import sys; print(len(sys.stdin.read().rstrip('\n')))" \
<<< "$visible")
local pad=$(( width - vis_len ))
[[ $pad -lt 0 ]] && pad=0
printf "%b%${pad}s" "$text" ""
}
function ui::vis_len_multi() {
# Get visible lengths of multiple strings in one Python call
# Returns newline-separated integers
python3 -c "
import sys, re
ansi = re.compile(r'\x1b\[[0-9;]*m')
for s in sys.argv[1:]:
print(len(ansi.sub('', s)))
" "$@"
}
function ui::pad_status() {
ui::pad "${1:-}" "${2:-25}"
}
@ -65,3 +89,27 @@ function ui::firewall_rule() {
printf "%s\n" "$rule"
fi
}
# ============================================
# Content Helpers
# ============================================
function ui::has_content() {
# Returns 0 (true) if content exists, 1 if empty
# Works with strings, arrays, or command output
local value="${1:-}"
[[ -n "$value" ]]
}
function ui::skip_if_empty() {
# Usage: ui::skip_if_empty "$var" || return 0
# Or: ui::skip_if_empty "${array[*]}" || return 0
local value="${1:-}"
[[ -z "${value// }" ]] && return 1 || return 0
}
function ui::empty() {
# ui::empty "$var" && return 0
# ui::empty "${array[*]}" && return 0
[[ -z "${1// }" ]]
}

View file

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

View file

@ -106,6 +106,14 @@ function block::rename() {
[[ -f "$old_file" ]] && mv "$old_file" "$new_file"
}
function block::clear_full_block() {
local name="${1:?}"
local file
file=$(block::file "$name")
[[ ! -f "$file" ]] && return 0
json::block_remove_rule "$file" "full"
}
# ── High level operations ──────────────────
function block::apply_full() {

View file

@ -41,6 +41,69 @@ function config::_init_defaults() {
_WG_TUNNEL_FULL="0.0.0.0/0, ::/0"
}
# ============================================
# Validation
# ============================================
function config::validate() {
local errors=()
# Required fields
local endpoint
endpoint=$(config::endpoint)
if [[ -z "$endpoint" ]]; then
errors+=("WG_ENDPOINT is not set — required for client config generation")
elif [[ "$endpoint" != *:* ]]; then
errors+=("WG_ENDPOINT must include port (e.g. wg.example.com:51820)")
fi
local port
port=$(config::port)
if [[ -z "$port" ]]; then
errors+=("WG_LISTEN_PORT is not set")
elif ! [[ "$port" =~ ^[0-9]+$ ]] || (( port < 1 || port > 65535 )); then
errors+=("WG_LISTEN_PORT must be a valid port number (1-65535)")
fi
local dns
dns=$(config::dns)
if [[ -z "$dns" ]]; then
errors+=("WG_DNS is not set — required for client configs")
elif ! ip::is_valid "$dns"; then
errors+=("WG_DNS must be a valid IP address")
fi
local subnet
subnet=$(config::subnet)
if [[ -z "$subnet" ]]; then
errors+=("WG_SUBNET is not set — required for IP allocation")
fi
local interface
interface=$(config::interface)
if [[ -z "$interface" ]]; then
errors+=("WG_INTERFACE is not set, defaulting to wg0")
fi
# Warn-only fields
local lan
lan=$(config::lan)
if [[ -z "$lan" ]]; then
log::wg_warning "WG_LAN is not set — some rule features may not work correctly"
fi
if [[ ${#errors[@]} -gt 0 ]]; then
log::error "wgctl configuration errors:"
for err in "${errors[@]}"; do
printf " ✗ %s\n" "$err" >&2
done
printf "\n Edit /etc/wireguard/.wgctl/wgctl.conf to fix these issues.\n\n" >&2
return 1
fi
return 0
}
# ============================================
# Load overrides from .wgctl/wgctl.conf
# ============================================
@ -129,10 +192,7 @@ function config::activity_total_high() { echo "$_ACTIVITY_TOTAL_HIGH_BYTES";
function config::activity_current_low() { echo "$_ACTIVITY_TOTAL_LOW_BYTES"; }
function config::activity_current_med() { echo "$_ACTIVITY_TOTAL_MED_BYTES"; }
function config::activity_current_high() { echo "$_ACTIVITY_TOTAL_HIGH_BYTES"; }
function config::server_public_key() {
cat "$_WG_SERVER_PUBLIC_KEY_FILE"
}
function config::server_public_key() { cat "$_WG_SERVER_PUBLIC_KEY_FILE"; }
function config::device_types() {
local types

View file

@ -84,3 +84,14 @@ function group::_peer_exists_check() {
local peer_name="${1:-}"
peers::require_exists "$peer_name" > /dev/null 2>&1
}
function group::has_peer() {
local group_name="${1:?}" peer_name="${2:?}"
local group_file
group_file="$(group::path "$group_name")"
[[ ! -f "$group_file" ]] && return 1
local result
result=$(json::group_has_peer "$group_file" "$peer_name")
[[ "$result" == "true" ]]
}

View file

@ -286,13 +286,15 @@ function peers::is_offline() {
peers::is_online "$name" "$handshake_ts" "$last_ts" && return 1 || return 0
}
# function peers::is_offline() {
# local name="${1:-}" handshake_ts="${2:-0}" last_ts="${3:-}"
# if peers::is_online "$name" "$handshake_ts" "$last_ts"; then
# return 1
# fi
# return 0
# }
function peers::get_main_group() {
local name="${1:?}"
peers::get_meta "$name" "main_group"
}
function peers::set_main_group() {
local name="${1:?}" group="${2:?}"
peers::set_meta "$name" "main_group" "$group"
}
# ============================================
# Name + Type Parsing
@ -352,6 +354,22 @@ function peers::format_last_seen() {
esac
}
# function peers::format_status() {
# local name="${1:-}" public_key="${2:-}" is_blocked="${3:-false}"
# local is_restricted="${4:-false}" handshake_ts="${5:-0}" last_ts="${6:-}"
# local state
# state=$(peers::connection_state "$is_blocked" "$is_restricted" \
# "$handshake_ts" "$last_ts")
# local conn_str modifier color
# IFS="|" read -r conn_str modifier color <<< "$state"
# local display="$conn_str"
# [[ -n "$modifier" ]] && display="${conn_str} (${modifier})"
# echo -e "${color}${display}\033[0m"
# }
function peers::format_status() {
local name="${1:-}" public_key="${2:-}" is_blocked="${3:-false}"
local is_restricted="${4:-false}" handshake_ts="${5:-0}" last_ts="${6:-}"
@ -363,9 +381,47 @@ function peers::format_status() {
local conn_str modifier color
IFS="|" read -r conn_str modifier color <<< "$state"
local display="$conn_str"
[[ -n "$modifier" ]] && display="${conn_str} (${modifier})"
echo -e "${color}${display}\033[0m"
# Color based on state — modifier overrides base connection color
if [[ "$is_blocked" == "true" ]]; then
color="\033[1;31m" # red — blocked
elif [[ "$is_restricted" == "true" ]]; then
color="\033[1;33m" # yellow — restricted
elif [[ "$conn_str" == "online" ]]; then
color="\033[1;32m" # green — online
else
color="\033[0;37m" # gray — offline
fi
local conn_str_padded
conn_str_padded=$(printf "%-7s" "$conn_str")
echo -e "${color}${conn_str_padded}\033[0m"
}
# Inspect — verbose, color + descriptive text
function peers::format_status_verbose() {
local name="${1:-}" public_key="${2:-}" is_blocked="${3:-false}"
local is_restricted="${4:-false}" handshake_ts="${5:-0}" last_ts="${6:-}"
local conn_str
local state
state=$(peers::connection_state "$is_blocked" "$is_restricted" \
"$handshake_ts" "$last_ts")
IFS="|" read -r conn_str _ _ <<< "$state"
local color suffix=""
if [[ "$is_blocked" == "true" ]]; then
color="\033[1;31m"
suffix=" (blocked)"
elif [[ "$is_restricted" == "true" ]]; then
color="\033[1;33m"
suffix=" (restricted)"
elif [[ "$conn_str" == "online" ]]; then
color="\033[1;32m"
else
color="\033[0;37m"
fi
echo -e "${color}${conn_str}${suffix}\033[0m"
}
function peers::display_type() {

2
wgctl
View file

@ -70,6 +70,8 @@ function wgctl::dispatch() {
case "$cmd" in
help) wgctl::help; return ;;
shell) : ;;
*) config::validate || exit 1 ;;
esac
# If alias resolved to service, pass original cmd as subcommand