Compare commits

..

No commits in common. "7a544f90192e499ebf658005fd00b45a3f838827" and "560e4cbe09fb02642e7299a1445cce9d6ae20ddd" have entirely different histories.

59 changed files with 2452 additions and 6378 deletions

View file

@ -14,8 +14,6 @@ function cmd::activity::on_load() {
flag::register --hours flag::register --hours
flag::register --type flag::register --type
flag::register --dropped flag::register --dropped
command::mixin json_output
} }
# ============================================ # ============================================
@ -72,11 +70,6 @@ function cmd::activity::run() {
esac esac
done done
if command::json; then
cmd::activity::_output_json "$hours"
return 0
fi
# Resolve peer name if type provided # Resolve peer name if type provided
if [[ -n "$filter_peer" && -n "$filter_type" ]]; then if [[ -n "$filter_peer" && -n "$filter_type" ]]; then
filter_peer=$(peers::resolve_and_require "$filter_peer" "$filter_type") || return 1 filter_peer=$(peers::resolve_and_require "$filter_peer" "$filter_type") || return 1
@ -111,7 +104,7 @@ function cmd::activity::run() {
return 0 return 0
fi fi
# Measure column widths # Measure w_peer and w_drops from data
local w_peer=16 w_drops=1 local w_peer=16 w_drops=1
while IFS='|' read -r type rest; do while IFS='|' read -r type rest; do
case "$type" in case "$type" in
@ -132,9 +125,9 @@ function cmd::activity::run() {
(( w_peer += 2 )) (( w_peer += 2 ))
# Compute column where drop count starts on peer row: # Compute exact column where drop count starts on peer row:
# " " (2) + name (w_peer) + " ↓" (3) + rx (10) + " ↑" (3) + tx (10) + " " (2) # " " (2) + name (w_peer) + " ↓" (3) + rx (10) + " ↑" (3) + tx (10) + " " (2)
# ↓ and ↑ are multi-byte (3 bytes, 1 visible) — 2 extra bytes each # Note: ↓ and ↑ are multi-byte (3 bytes) but display as 1 char — account for 2 extra bytes each
# Visible: 2 + w_peer + 2+1 + 10 + 2+1 + 10 + 2 = w_peer + 30 # Visible: 2 + w_peer + 2+1 + 10 + 2+1 + 10 + 2 = w_peer + 30
local drops_col=$(( w_peer + 30 )) local drops_col=$(( w_peer + 30 ))
@ -162,8 +155,8 @@ function cmd::activity::run() {
first_peer=false first_peer=false
local rx_fmt tx_fmt local rx_fmt tx_fmt
rx_fmt=$(fmt::bytes "$rx") rx_fmt=$(cmd::activity::_fmt_bytes "$rx")
tx_fmt=$(fmt::bytes "$tx") tx_fmt=$(cmd::activity::_fmt_bytes "$tx")
local name_pad rx_pad tx_pad local name_pad rx_pad tx_pad
name_pad=$(printf "%-${w_peer}s" "$name") name_pad=$(printf "%-${w_peer}s" "$name")
@ -172,9 +165,8 @@ function cmd::activity::run() {
local drop_word="drops" local drop_word="drops"
[[ "$drops" -eq 1 ]] && drop_word="drop" [[ "$drops" -eq 1 ]] && drop_word="drop"
printf " \033[1m%s\033[0m \033[2m↓\033[0m%s \033[2m↑\033[0m%s %${w_drops}s %s\n" \
ui::activity::peer_row \ "$name_pad" "$rx_pad" "$tx_pad" "$drops" "$drop_word"
"$name_pad" "$rx_pad" "$tx_pad" "$drops" "$drop_word" "$w_drops"
;; ;;
service) service)
@ -183,11 +175,19 @@ function cmd::activity::run() {
local peer dest_display drop_count local peer dest_display drop_count
IFS='|' read -r peer dest_display drop_count <<< "$rest" IFS='|' read -r peer dest_display drop_count <<< "$rest"
# Compute padding to align drop count with peer drop column
# Service row visible prefix: " → " (6) + ${#dest_display}
local arrow_prefix=" → "
local prefix_bytes=${#arrow_prefix} # = 8 due to → being 3 bytes
local prefix_len=$(( prefix_bytes + ${#dest_display} ))
# local prefix_len=$(( 6 + ${#dest_display} ))
local pad_n=$(( drops_col - prefix_len ))
[[ $pad_n -lt 1 ]] && pad_n=1
local svc_drop_word="drops" local svc_drop_word="drops"
[[ "$drop_count" -eq 1 ]] && svc_drop_word="drop" [[ "$drop_count" -eq 1 ]] && svc_drop_word="drop"
printf " \033[2m→\033[0m %s%*s %${w_drops}s %s\n" \
ui::activity::service_row \ "$dest_display" "$pad_n" "" "$drop_count" "$svc_drop_word"
"$dest_display" "$drop_count" "$svc_drop_word" "$drops_col" "$w_drops"
;; ;;
esac esac
done <<< "$data" done <<< "$data"
@ -195,51 +195,21 @@ function cmd::activity::run() {
echo "" echo ""
} }
function cmd::activity::_output_json() { # ============================================
local hours="${1:-24}" # Helpers
local data # ============================================
data=$(json::activity_aggregate \
"$(ctx::fw_events_log)" "$(ctx::events_log)" \
"$(config::interface)" "$(ctx::net)" \
"$(ctx::clients)" "$(ctx::meta)" \
"$hours" "" "" 2>/dev/null)
local -a peers=() function cmd::activity::_fmt_bytes() {
local current_peer="" current_services="" local bytes="${1:-0}"
local -a current_svc_list=() if (( bytes == 0 )); then
printf "—"
while IFS='|' read -r record_type rest; do elif (( bytes >= 1073741824 )); then
case "$record_type" in printf "%dGB" $(( bytes / 1073741824 ))
peer) elif (( bytes >= 1048576 )); then
# Flush previous peer printf "%dMB" $(( bytes / 1048576 ))
if [[ -n "$current_peer" ]]; then elif (( bytes >= 1024 )); then
local svc_array printf "%dKB" $(( bytes / 1024 ))
svc_array=$(printf '%s\n' "${current_svc_list[@]:-}" | paste -sd ',' -) else
peers+=("${current_peer},\"services\":[${svc_array:-}]}") printf "%dB" "$bytes"
current_svc_list=()
fi
local name rx tx drops
IFS='|' read -r name rx tx drops <<< "$rest"
current_peer=$(printf '{"name":"%s","rx":%s,"tx":%s,"drops":%s' \
"$name" "$rx" "$tx" "$drops")
;;
service)
local peer dest count
IFS='|' read -r peer dest count <<< "$rest"
current_svc_list+=("$(printf '{"dest":"%s","drops":%s}' "$dest" "$count")")
;;
esac
done <<< "$data"
# Flush last peer
if [[ -n "$current_peer" ]]; then
local svc_array
svc_array=$(printf '%s\n' "${current_svc_list[@]:-}" | paste -sd ',' -)
peers+=("${current_peer},\"services\":[${svc_array:-}]}")
fi fi
local count=${#peers[@]}
local array
array=$(printf '%s\n' "${peers[@]:-}" | paste -sd ',' -)
printf '{"peers":[%s]}' "${array:-}" | json::envelope "activity" "$count"
} }

View file

@ -7,8 +7,6 @@
function cmd::config::on_load() { function cmd::config::on_load() {
flag::register --name flag::register --name
flag::register --type flag::register --type
flag::register --force
flag::register --dry-run
} }
# ============================================ # ============================================
@ -35,43 +33,27 @@ EOF
# ============================================ # ============================================
function cmd::config::run() { function cmd::config::run() {
local subcmd="${1:-show}" local name=""
local type=""
# If first arg is a flag, treat as 'show' subcommand
if [[ "$subcmd" == --* ]]; then
subcmd="show"
else
shift || true
fi
case "$subcmd" in
show) cmd::config::_show "$@" ;;
migrate) cmd::config::migrate "$@" ;;
help) cmd::config::help ;;
*)
log::error "Unknown subcommand: '${subcmd}'"
cmd::config::help
return 1
;;
esac
}
# ============================================
# Show
# ============================================
function cmd::config::_show() {
local name="" type=""
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 ;;
--help) cmd::config::help; return ;; --help) cmd::config::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;; *)
log::error "Unknown flag: $1"
cmd::config::help
return 1
;;
esac esac
done done
[[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1 if [[ -z "$name" ]]; then
log::error "Missing required flag: --name"
return 1
fi
name=$(peers::resolve_and_require "$name" "$type") || return 1 name=$(peers::resolve_and_require "$name" "$type") || return 1
local conf local conf
@ -80,202 +62,3 @@ function cmd::config::_show() {
log::section "Client Config: ${name}" log::section "Client Config: ${name}"
cat "$conf" cat "$conf"
} }
# ============================================
# Migrate
# ============================================
function cmd::config::migrate() {
local force=false dry_run=false
while [[ $# -gt 0 ]]; do
case "$1" in
--force) force=true; shift ;;
--dry-run) dry_run=true; shift ;;
--help) cmd::config::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
local wgctl_dir
wgctl_dir="$(ctx::wgctl)"
local config_dir="${wgctl_dir}/config"
local data_dir="${wgctl_dir}/data"
local legacy_conf="${wgctl_dir}/wgctl.conf"
local json_conf="${config_dir}/wgctl.json"
# Check if already migrated
if [[ -f "$json_conf" && ! -f "$legacy_conf" ]]; then
log::wg_warning "Already migrated to new config structure"
return 0
fi
log::section "wgctl Config Migration"
printf "\n"
printf " This will:\n"
printf " 1. Create %s/config/ and %s/data/\n" "$wgctl_dir" "$wgctl_dir"
printf " 2. Convert wgctl.conf → wgctl.json\n"
printf " 3. Move data files to data/\n\n"
if ! $force && ! $dry_run; then
read -r -p " Proceed? [y/N] " confirm
case "$confirm" in [yY]*) ;; *) log::info "Aborted"; return 0 ;; esac
fi
local do=""
$dry_run && do="echo [dry-run]"
# 1. Create directories
$dry_run || mkdir -p "$config_dir" "$data_dir"
$dry_run && printf " Would create: %s/config/\n" "$wgctl_dir"
$dry_run && printf " Would create: %s/data/\n" "$wgctl_dir"
# 2. Convert wgctl.conf → wgctl.json
if [[ -f "$legacy_conf" ]]; then
if ! $dry_run; then
config::_convert_to_json "$legacy_conf" "$json_conf"
fi
printf " %s wgctl.conf → config/wgctl.json\n" "$($dry_run && echo '[dry-run]' || echo '✓')"
else
log::wg_warning "No wgctl.conf found — creating default wgctl.json"
$dry_run || config::_write_default_json "$json_conf"
fi
# 3. Move data files
local -a data_files=(
"hosts.json"
"services.json"
"subnets.json"
"policies.json"
)
local -a data_dirs=(
"rules"
"identities"
"groups"
"blocks"
"meta"
"peer-history"
)
for f in "${data_files[@]}"; do
if [[ -f "${wgctl_dir}/${f}" ]]; then
$dry_run || mv "${wgctl_dir}/${f}" "${data_dir}/${f}"
printf " %s %s → data/%s\n" \
"$($dry_run && echo '[dry-run]' || echo '✓')" "$f" "$f"
fi
done
for d in "${data_dirs[@]}"; do
if [[ -d "${wgctl_dir}/${d}" ]]; then
$dry_run || mv "${wgctl_dir}/${d}" "${data_dir}/${d}"
printf " %s %s/ → data/%s/\n" \
"$($dry_run && echo '[dry-run]' || echo '✓')" "$d" "$d"
fi
done
# 4. Remove legacy conf after successful migration
if ! $dry_run && [[ -f "$legacy_conf" ]]; then
mv "$legacy_conf" "${legacy_conf}.bak"
printf " ✓ wgctl.conf → wgctl.conf.bak (backup)\n"
fi
printf "\n"
$dry_run && log::wg_warning "Dry run — no changes made" \
|| log::wg_success "Migration complete"
}
function config::_convert_to_json() {
local legacy_file="$1" output_file="$2"
# Read legacy conf into variables
local wg_interface="wg0" wg_endpoint="" wg_dns="10.0.0.103"
local wg_dns_fallback="" wg_port="51820" wg_subnet="10.1.0.0/16"
local wg_lan="10.0.0.0/24" wg_hs_check="300" date_format="eu"
while IFS='=' read -r key value || [[ -n "$key" ]]; do
[[ "$key" =~ ^[[:space:]]*# ]] && continue
[[ -z "${key// }" ]] && continue
key="${key// /}"
value="${value// /}"
case "$key" in
WG_INTERFACE) wg_interface="$value" ;;
WG_ENDPOINT) wg_endpoint="$value" ;;
WG_DNS) wg_dns="$value" ;;
WG_DNS_FALLBACK) wg_dns_fallback="$value" ;;
WG_PORT) wg_port="$value" ;;
WG_SUBNET) wg_subnet="$value" ;;
WG_LAN) wg_lan="$value" ;;
WG_HANDSHAKE_CHECK_TIME_SEC) wg_hs_check="$value" ;;
DATE_FORMAT) date_format="$value" ;;
esac
done < "$legacy_file"
# Build fallback DNS array
local dns_fallback_json="[]"
if [[ -n "$wg_dns_fallback" ]]; then
local fallback_array
fallback_array=$(echo "$wg_dns_fallback" | tr ',' '\n' | \
while IFS= read -r s; do
s="${s// /}"
[[ -n "$s" ]] && printf '"%s",' "$s"
done | sed 's/,$//')
dns_fallback_json="[${fallback_array}]"
fi
mkdir -p "$(dirname "$output_file")"
cat > "$output_file" << JSON
{
"wireguard": {
"interface": "${wg_interface}",
"endpoint": "${wg_endpoint}",
"port": ${wg_port},
"subnet": "${wg_subnet}",
"lan": "${wg_lan}"
},
"dns": {
"primary": "${wg_dns}",
"fallback": ${dns_fallback_json}
},
"handshake": {
"check_interval_sec": ${wg_hs_check}
},
"activity": {
"total": {"low": 1000000, "medium": 10000000, "high": 100000000},
"current": {"low": 1000000, "medium": 10000000, "high": 100000000}
},
"display": {
"date_format": "${date_format}"
}
}
JSON
}
function config::_write_default_json() {
local output_file="$1"
mkdir -p "$(dirname "$output_file")"
cat > "$output_file" << 'JSON'
{
"wireguard": {
"interface": "wg0",
"endpoint": "",
"port": 51820,
"subnet": "10.1.0.0/16",
"lan": "10.0.0.0/24"
},
"dns": {
"primary": "10.0.0.103",
"fallback": []
},
"handshake": {
"check_interval_sec": 300
},
"activity": {
"total": {"low": 1000000, "medium": 10000000, "high": 100000000},
"current": {"low": 1000000, "medium": 10000000, "high": 100000000}
},
"display": {
"date_format": "eu"
}
}
JSON
}

View file

@ -13,9 +13,6 @@ function cmd::group::on_load() {
flag::register --new-name flag::register --new-name
flag::register --main flag::register --main
flag::register --force flag::register --force
flag::register --all
flag::register --dry-run
command::mixin json_output
} }
# ============================================ # ============================================
@ -40,7 +37,6 @@ Subcommands:
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 in group from WireGuard rm-peers Remove all peers in group from WireGuard
purge-stale Remove peers that no longer exist from group(s)
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
@ -57,7 +53,6 @@ Options:
--new-name <name> New group name (for rename) --new-name <name> New group name (for rename)
--limit <n> Max log entries per peer (for logs) --limit <n> Max log entries per peer (for logs)
--force Skip confirmation prompts --force Skip confirmation prompts
--all Apply to all groups (for purge-stale)
Examples: Examples:
wgctl group list wgctl group list
@ -67,9 +62,6 @@ Examples:
wgctl group block --name family wgctl group block --name family
wgctl group unblock --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 purge-stale --name family
wgctl group purge-stale --all
wgctl group purge-stale --all --force
wgctl group audit --name family wgctl group audit --name family
wgctl group logs --name family --limit 20 wgctl group logs --name family --limit 20
wgctl group watch --name family wgctl group watch --name family
@ -84,11 +76,6 @@ function cmd::group::run() {
local subcmd="${1:-help}" local subcmd="${1:-help}"
shift || true shift || true
if command::json; then
cmd::group::_output_json
return 0
fi
case "$subcmd" in case "$subcmd" in
list|ls) cmd::group::list "$@" ;; list|ls) cmd::group::list "$@" ;;
show) cmd::group::show "$@" ;; show) cmd::group::show "$@" ;;
@ -101,7 +88,6 @@ function cmd::group::run() {
block) cmd::group::block "$@" ;; block) cmd::group::block "$@" ;;
unblock) cmd::group::unblock "$@" ;; unblock) cmd::group::unblock "$@" ;;
rule) cmd::group::rule "$@" ;; rule) cmd::group::rule "$@" ;;
purge-stale) cmd::group::purge_stale "$@" ;;
audit) cmd::group::audit "$@" ;; audit) cmd::group::audit "$@" ;;
logs) cmd::group::logs "$@" ;; logs) cmd::group::logs "$@" ;;
watch) cmd::group::watch "$@" ;; watch) cmd::group::watch "$@" ;;
@ -128,31 +114,40 @@ function cmd::group::list() {
return 0 return 0
fi fi
local data
data=$(json::group_list_data "$groups_dir" "$(ctx::blocks)" "$(ctx::clients)")
[[ -z "$data" ]] && log::wg "No groups configured" && return 0
# Measure column widths
local w_name=12 w_desc=16
while IFS="|" read -r name desc total blocked; do
[[ -z "$name" ]] && continue
(( ${#name} > w_name )) && w_name=${#name}
local desc_len=${#desc}
[[ -z "$desc" ]] && desc_len=1
(( desc_len > w_desc )) && w_desc=$desc_len
done <<< "$data"
(( w_name += 2 ))
(( w_desc += 2 ))
log::section "Groups" log::section "Groups"
echo "" printf "\n %-20s %-35s %-8s %s\n" "NAME" "DESCRIPTION" "PEERS" "STATUS"
printf " %s\n" "$(printf '─%.0s' {1..75})"
while IFS="|" read -r name desc total blocked; do while IFS="|" read -r name desc total blocked; do
[[ -z "$name" ]] && continue [[ -z "$name" ]] && continue
ui::group::list_row "$name" "$desc" "$total" "$blocked" "$w_name" "$w_desc"
done <<< "$data"
echo "" local status_color="" status_str="active"
if [[ "$total" -gt 0 ]]; then
if [[ "$blocked" -eq "$total" ]]; then
status_color="\033[1;31m"
status_str="blocked"
elif [[ "$blocked" -gt 0 ]]; then
status_color="\033[1;33m"
status_str="blocked (${blocked}/${total})"
else
status_color="\033[1;32m"
status_str="active"
fi
fi
local short_desc="${desc:0:33}"
[[ ${#desc} -gt 33 ]] && short_desc="${short_desc}..."
local desc_col_width=35
[[ "$desc" == "—" || -z "$desc" ]] && desc_col_width=37
printf " %-20s %-${desc_col_width}s %-8s %b\n" \
"$name" "${short_desc:-}" "$total" \
"${status_color}${status_str}\033[0m"
done < <(json::group_list_data "$groups_dir" "$(ctx::blocks)")
printf "\n"
} }
# ============================================ # ============================================
@ -177,50 +172,62 @@ function cmd::group::show() {
group_file="$(group::path "$name")" group_file="$(group::path "$name")"
log::section "Group: ${name}" log::section "Group: ${name}"
printf "\n"
local desc local desc
desc=$(json::get "$group_file" "desc") desc=$(json::get "$group_file" "desc")
ui::row "Description" "${desc:-}" printf "\n %-20s %s\n" "Description:" "${desc:-}"
# Load and filter peers # Load peers
local peers_list=() local peers_list=()
mapfile -t peers_list < <(json::get "$group_file" "peers") || true mapfile -t peers_list < <(json::get "$group_file" "peers")
# Filter empty entries
local filtered=() local filtered=()
for p in "${peers_list[@]:-}"; do for p in "${peers_list[@]:-}"; do
[[ -n "$p" ]] && filtered+=("$p") [[ -n "$p" ]] && filtered+=("$p")
done done
peers_list=("${filtered[@]:-}") peers_list=("${filtered[@]:-}")
local peer_count=${#peers_list[@]} local peer_count=${#peers_list[@]}
[[ -z "${peers_list[0]:-}" ]] && peer_count=0
# Count valid peers (data logic stays in command) [[ -z "${peers_list[0]}" ]] && peer_count=0
local valid_count=0
for p in "${peers_list[@]}"; do printf " %-20s %s\n" "Peers:" "$peer_count"
[[ -z "$p" ]] && continue printf " %s\n" "$(printf '─%.0s' {1..50})"
peers::require_exists "$p" > /dev/null 2>&1 && (( valid_count++ )) || true
done
local peer_word="peers"
[[ "$valid_count" -eq 1 ]] && peer_word="peer"
ui::row "Peers" "${valid_count} ${peer_word}"
printf "\n"
if [[ "$peer_count" -gt 0 ]]; then if [[ "$peer_count" -gt 0 ]]; then
# Measure widths (data logic stays in command) printf "\n %-28s %-15s %-12s %s\n" "NAME" "IP" "RULE" "STATUS"
local w_name=16 w_ip=13 printf " %s\n" "$(printf '─%.0s' {1..65})"
for peer_name in "${peers_list[@]}"; do for peer_name in "${peers_list[@]}"; do
[[ -z "$peer_name" ]] && continue [[ -z "$peer_name" ]] && continue
(( ${#peer_name} > w_name )) && w_name=${#peer_name}
done
(( w_name += 2 ))
# Delegate rendering to ui:: # Skip if peer no longer exists
ui::group::show_peers peers_list "$w_name" "$w_ip" if ! peers::require_exists "$peer_name" > /dev/null 2>&1; then
log::wg_warning "Peer '${peer_name}' no longer exists — skipping"
continue
fi
local ip rule status_str status_color
ip=$(peers::get_ip "$peer_name")
rule=$(peers::get_meta "$peer_name" "rule")
rule="${rule:-}"
if peers::is_blocked "$peer_name" 2>/dev/null; then
status_color="\033[1;31m"
status_str="blocked"
else
status_color="\033[1;32m"
status_str="active"
fi
printf " %-28s %-15s %-12s %b\n" \
"$peer_name" "0" "$rule" \
"${status_str}\033[0m"
done
else else
printf " \033[2m—\033[0m\n" printf " \n"
fi fi
printf "\n" printf "\n"
return 0
} }
# ============================================ # ============================================
@ -827,110 +834,3 @@ function cmd::group::watch() {
load_command watch load_command watch
cmd::watch::run --peers "$peer_filter" cmd::watch::run --peers "$peer_filter"
} }
# ============================================
# Purge Stale
# ============================================
function cmd::group::purge_stale() {
local name="" force=false all=false
local dry_run=false
while [[ $# -gt 0 ]]; do
case "$1" in
--name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;;
--force) force=true; shift ;;
--all) all=true; shift ;;
--dry-run) dry_run=true; shift ;;
--help) cmd::group::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
[[ -z "$name" && "$all" == "false" ]] && \
log::error "Specify --name <group> or --all" && return 1
# Build list of groups to process
local -a groups=()
if $all; then
while IFS= read -r group_file; do
groups+=("$(basename "$group_file" .group)")
done < <(find "$(ctx::groups)" -name "*.group" 2>/dev/null | sort)
else
group::require_exists "$name" || return 1
groups=("$name")
fi
local total_removed=0 total_groups=0
for group_name in "${groups[@]}"; do
[[ -z "$group_name" ]] && continue
# Find stale peers — in group but no .conf file
local -a stale=()
while IFS= read -r peer_name; do
[[ -z "$peer_name" ]] && continue
if [[ ! -f "$(ctx::clients)/${peer_name}.conf" ]]; then
stale+=("$peer_name")
fi
done < <(group::peers "$group_name" 2>/dev/null)
[[ ${#stale[@]} -eq 0 ]] && continue
(( total_groups++ )) || true
if ! $force; then
printf " Group '%s' has %d stale peer(s): %s\n" \
"$group_name" "${#stale[@]}" "${stale[*]}"
read -r -p " Remove them? [y/N] " confirm
case "$confirm" in
[yY]*) ;;
*) log::info "Skipped '${group_name}'"; continue ;;
esac
fi
local group_file
group_file="$(group::path "$group_name")"
for peer_name in "${stale[@]}"; do
if $dry_run; then
printf " \033[2m[dry-run]\033[0m Would remove '%s' from group '%s'\n" \
"$peer_name" "$group_name"
else
json::remove "$group_file" "peers" "$peer_name" 2>/dev/null || true
log::debug "Removed stale peer '${peer_name}' from group '${group_name}'"
fi
(( total_removed++ )) || true
done
done
local action="Removed"
$dry_run && action="Would remove"
log::wg_success "${action} ${total_removed} stale peer(s)..."
if $all; then
if [[ "$total_removed" -eq 0 ]]; then
log::wg_warning "No stale peers found in any group"
else
log::wg_success "${action} ${total_removed} stale peer(s)..."
fi
fi
}
function cmd::group::_output_json() {
local groups_dir
groups_dir="$(ctx::groups)"
local data
data=$(json::group_list_data "$groups_dir" "$(ctx::blocks)" "$(ctx::clients)" 2>/dev/null)
local -a groups=()
while IFS='|' read -r name desc peer_count blocked_count; do
[[ -z "$name" ]] && continue
groups+=("$(printf '{"name":"%s","desc":"%s","peer_count":%s,"blocked_count":%s}' \
"$name" "$desc" "$peer_count" "$blocked_count")")
done <<< "$data"
local count=${#groups[@]}
local array
array=$(printf '%s\n' "${groups[@]:-}" | paste -sd ',' -)
printf '{"groups":[%s]}' "${array:-}" | json::envelope "group list" "$count"
}

View file

@ -1,325 +0,0 @@
#!/usr/bin/env bash
# hosts.command.sh — manage host/IP display name mappings
# ============================================
# Lifecycle
# ============================================
function cmd::hosts::on_load() {
flag::register --ip
flag::register --subnet
flag::register --port
flag::register --name
flag::register --desc
flag::register --tag
flag::register --tags
flag::register --force
command::mixin json_output
}
# ============================================
# Help
# ============================================
function cmd::hosts::help() {
cat <<EOF
Usage: wgctl hosts <subcommand> [options]
Manage host display names for IP resolution in logs, watch, and activity.
Maps IPs, subnets, and ports to human-readable names.
Subcommands:
list List all host entries
show --ip <ip> Show host entry details
show --subnet <cidr> Show subnet entry details
show --port <port> Show port entry details
add --ip <ip> --name <name> Add a host entry
add --subnet <cidr> --name <name>
Add a subnet entry
add --port <port> --name <name>
Add a port entry
rm --ip <ip> Remove a host entry
rm --subnet <cidr> Remove a subnet entry
rm --port <port> Remove a port entry
Options for add:
--ip <ip> IP address to map
--subnet <cidr> Subnet CIDR to map (e.g. 10.0.0.0/24)
--port <port> Port number to map (e.g. 443)
--name <name> Display name (e.g. vodafone-wan)
--desc <description> Optional description
--tag <tag> Tag (repeatable)
--tags <tag1,tag2> Tags (comma-separated)
Options for rm:
--force Skip confirmation
Examples:
wgctl hosts list
wgctl hosts add --ip 148.69.46.73 --name vodafone-wan --desc "Vodafone WAN"
wgctl hosts add --ip 94.63.0.129 --name nuno-home --tags home,isp
wgctl hosts add --subnet 10.0.0.0/24 --name lan --desc "Local LAN"
wgctl hosts add --port 443 --name https
wgctl hosts show --ip 148.69.46.73
wgctl hosts rm --ip 148.69.46.73
EOF
}
# ============================================
# Run
# ============================================
function cmd::hosts::run() {
local subcmd="${1:-list}"
shift || true
if command::json; then
cmd::hosts::_output_json
return 0
fi
case "$subcmd" in
list) cmd::hosts::list "$@" ;;
show) cmd::hosts::show "$@" ;;
add) cmd::hosts::add "$@" ;;
rm|remove|del) cmd::hosts::rm "$@" ;;
help) cmd::hosts::help ;;
*)
log::error "Unknown subcommand: '${subcmd}'"
cmd::hosts::help
return 1 ;;
esac
}
# ============================================
# List
# ============================================
function cmd::hosts::list() {
local filter_tag=""
while [[ $# -gt 0 ]]; do
case "$1" in
--tag) filter_tag="$2"; shift 2 ;;
--help) cmd::hosts::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
local hosts_file
hosts_file="$(ctx::hosts)"
if [[ ! -f "$hosts_file" ]]; then
log::wg_warning "No hosts configured. Use 'wgctl hosts add' to add one."
return 0
fi
local data
data=$(json::hosts_list "$hosts_file" 2>/dev/null)
[[ -z "$data" ]] && log::wg_warning "No hosts configured." && return 0
# Apply tag filter to data first
local filtered_data=""
while IFS='|' read -r type key name desc tags; do
[[ -z "$type" ]] && continue
[[ -n "$filter_tag" && "$tags" != *"$filter_tag"* ]] && continue
filtered_data+="${type}|${key}|${name}|${desc}|${tags}"$'\n'
done <<< "$data"
[[ -z "$filtered_data" ]] && log::wg_warning "No hosts found." && return 0
# Measure column widths from filtered data
local w_key=15 w_name=16 w_desc=10
while IFS='|' read -r type key name desc tags; do
[[ -z "$type" ]] && continue
(( ${#key} > w_key )) && w_key=${#key}
(( ${#name} > w_name )) && w_name=${#name}
local desc_len=${#desc}
[[ -z "$desc" ]] && desc_len=1 # "—" = 1 visible char
(( desc_len > w_desc )) && w_desc=$desc_len
done <<< "$filtered_data"
(( w_key += 2 ))
(( w_name += 2 ))
(( w_desc += 2 ))
log::section "Host Mappings"
echo ""
local last_type="" found=false
while IFS='|' read -r type key name desc tags; do
[[ -z "$type" ]] && continue
found=true
# Section header when type changes
if [[ "$type" != "$last_type" ]]; then
[[ -n "$last_type" ]] && echo ""
ui::hosts::section_header "$type"
last_type="$type"
fi
ui::hosts::list_row "$type" "$key" "$name" "$desc" "$tags" \
"$w_key" "$w_name" "$w_desc"
done <<< "$filtered_data"
$found || log::wg_warning "No hosts configured."
echo ""
}
# Table version (kept for future display config)
function cmd::hosts::_list_table() {
local hosts_file="${1:-}"
printf "\n %-6s %-18s %-16s %-30s %s\n" \
"TYPE" "KEY" "NAME" "DESCRIPTION" "TAGS"
printf " %s\n" "$(printf '─%.0s' {1..80})"
while IFS='|' read -r type key name desc tags; do
[[ -z "$type" ]] && continue
printf " %-6s %-18s %-16s %-30s %s\n" \
"$type" "$key" "$name" "${desc:-}" "${tags:-}"
done < <(json::hosts_list "$hosts_file" 2>/dev/null)
printf "\n"
}
# ============================================
# Show
# ============================================
function cmd::hosts::show() {
local ip="" subnet="" port=""
while [[ $# -gt 0 ]]; do
case "$1" in
--ip) ip="$2"; shift 2 ;;
--subnet) subnet="$2"; shift 2 ;;
--port) port="$2"; shift 2 ;;
--help) cmd::hosts::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
local key entry_type
if [[ -n "$ip" ]]; then key="$ip"; entry_type="host"; fi
if [[ -n "$subnet" ]]; then key="$subnet"; entry_type="subnet"; fi
if [[ -n "$port" ]]; then key="$port"; entry_type="port"; fi
[[ -z "$key" ]] && log::error "Specify --ip, --subnet, or --port" && return 1
hosts::require_exists "$entry_type" "$key" || return 1
log::section "${entry_type^}: ${key}"
printf "\n"
while IFS='|' read -r field val; do
case "$field" in
name) ui::row "Name" "${val:-}" ;;
desc) ui::row "Description" "${val:-}" ;;
tags) ui::row "Tags" "${val:-}" ;;
esac
done < <(json::hosts_show "$(ctx::hosts)" "$key" "$entry_type")
printf "\n"
}
# ============================================
# Add
# ============================================
function cmd::hosts::add() {
local ip="" subnet="" port="" name="" desc="" tags=()
while [[ $# -gt 0 ]]; do
case "$1" in
--ip) ip="$2"; shift 2 ;;
--subnet) subnet="$2"; shift 2 ;;
--port) port="$2"; shift 2 ;;
--name) name="$2"; shift 2 ;;
--desc) desc="$2"; shift 2 ;;
--tag) tags+=("$2"); shift 2 ;;
--tags) IFS=',' read -ra t <<< "$2"; tags+=("${t[@]}"); shift 2 ;;
--help) cmd::hosts::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
[[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
local key entry_type
if [[ -n "$ip" ]]; then key="$ip"; entry_type="host"; fi
if [[ -n "$subnet" ]]; then key="$subnet"; entry_type="subnet"; fi
if [[ -n "$port" ]]; then key="$port"; entry_type="port"; fi
[[ -z "$key" ]] && log::error "Specify --ip, --subnet, or --port" && return 1
local tags_str
tags_str=$(IFS=','; echo "${tags[*]}")
json::hosts_add "$(ctx::hosts)" "$entry_type" "$key" "$name" "$desc" "$tags_str"
log::wg_success "Added ${entry_type}: ${key}${name}"
}
# ============================================
# Remove
# ============================================
function cmd::hosts::rm() {
local ip="" subnet="" port="" force=false
while [[ $# -gt 0 ]]; do
case "$1" in
--ip) ip="$2"; shift 2 ;;
--subnet) subnet="$2"; shift 2 ;;
--port) port="$2"; shift 2 ;;
--force) force=true; shift ;;
--help) cmd::hosts::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
local key entry_type
if [[ -n "$ip" ]]; then key="$ip"; entry_type="host"; fi
if [[ -n "$subnet" ]]; then key="$subnet"; entry_type="subnet"; fi
if [[ -n "$port" ]]; then key="$port"; entry_type="port"; fi
[[ -z "$key" ]] && log::error "Specify --ip, --subnet, or --port" && return 1
hosts::require_exists "$entry_type" "$key" || return 1
if ! $force; then
read -r -p "Remove ${entry_type} '${key}'? [y/N] " confirm
case "$confirm" in
[yY]*) ;;
*) log::info "Aborted"; return 0 ;;
esac
fi
json::hosts_remove "$(ctx::hosts)" "$entry_type" "$key"
log::wg_success "Removed ${entry_type}: ${key}"
}
function cmd::hosts::_output_json() {
local data
data=$(json::hosts_list "$(ctx::hosts)" 2>/dev/null)
local -a hosts=()
while IFS='|' read -r type ip name desc tags; do
[[ -z "$type" ]] && continue
local tags_json="[]"
if [[ -n "$tags" ]]; then
local tags_array
tags_array=$(echo "$tags" | tr ',' '\n' | \
while IFS= read -r t; do [[ -n "$t" ]] && printf '"%s",' "$t"; done | sed 's/,$//')
tags_json="[${tags_array}]"
fi
hosts+=("$(printf '{"type":"%s","ip":"%s","name":"%s","desc":"%s","tags":%s}' \
"$type" "$ip" "$name" "$desc" "$tags_json")")
done <<< "$data"
local count=${#hosts[@]}
local array
array=$(printf '%s\n' "${hosts[@]:-}" | paste -sd ',' -)
printf '{"hosts":[%s]}' "${array:-}" | json::envelope "hosts list" "$count"
}

View file

@ -20,7 +20,9 @@ function cmd::identity::on_load() {
flag::register --peer flag::register --peer
flag::register --dry-run flag::register --dry-run
flag::register --force flag::register --force
# rule subcommand flags
flag::register --rule flag::register --rule
# options subcommand flags
flag::register --policy flag::register --policy
flag::register --set-strict-rule flag::register --set-strict-rule
flag::register --unset-strict-rule flag::register --unset-strict-rule
@ -28,9 +30,6 @@ function cmd::identity::on_load() {
flag::register --unset-auto-apply flag::register --unset-auto-apply
flag::register --field flag::register --field
flag::register --value flag::register --value
flag::register --migrate
command::mixin json_output
} }
# ============================================ # ============================================
@ -41,32 +40,33 @@ function cmd::identity::help() {
cat <<EOF cat <<EOF
Usage: wgctl identity <subcommand> [options] Usage: wgctl identity <subcommand> [options]
Manage peer identities — group peers by person/device owner. Manage peer identities.
Subcommands: Subcommands:
list List all identities list List all identities
show --name <n> Show identity details with peers and rule tree show --name <name> Show identity details and device status
add --name <n> Create a new identity add --name <name> Manually attach a peer to an identity
remove --name <n> Remove an identity --peer <peer>
migrate Migrate peers to identities remove --name <name> Remove identity and all associated peers
migrate [--dry-run] Create identities from existing peer names
rule assign --name <n> --rule <r> Assign rule to identity rule assign --name <name> Assign a rule to an identity
Blocked if peer already has rule directly --rule <rule>
[--migrate] Remove conflicting direct peer rules first rule unassign --name <name> Remove rule from an identity
rule unassign --name <n> --rule <r> Remove rule from identity rule show --name <name> Show current identity rule
rule unassign --name <n> --all Remove all rules from identity
options --name <n> --strict-rule <bool> Set strict rule mode options --name <name> Set identity options
options --name <n> --auto-apply <bool> Set auto apply [--policy <policy>]
[--set-strict-rule | --unset-strict-rule]
[--set-auto-apply | --unset-auto-apply]
Examples: Examples:
wgctl identity list wgctl identity list
wgctl identity show --name nuno wgctl identity show --name nuno
wgctl identity add --name alice
wgctl identity rule assign --name nuno --rule admin wgctl identity rule assign --name nuno --rule admin
wgctl identity rule assign --name nuno --rule user --migrate wgctl identity rule unassign --name nuno
wgctl identity rule unassign --name nuno --rule admin wgctl identity options --name guests-identity --policy guest
wgctl identity options --name nuno --strict-rule true wgctl identity options --name nuno --set-strict-rule
EOF EOF
} }
@ -78,11 +78,6 @@ function cmd::identity::run() {
local subcmd="${1:-list}" local subcmd="${1:-list}"
shift || true shift || true
if command::json && [[ "$subcmd" == "list" ]]; then
cmd::identity::_output_json
return 0
fi
case "$subcmd" in case "$subcmd" in
list) cmd::identity::_list "$@" ;; list) cmd::identity::_list "$@" ;;
show) cmd::identity::_show "$@" ;; show) cmd::identity::_show "$@" ;;
@ -105,7 +100,7 @@ function cmd::identity::run() {
function cmd::identity::_list() { function cmd::identity::_list() {
local data local data
data=$(identity::list_data | ui::sort_rows 1) data=$(identity::list_data)
if [[ -z "$data" ]]; then if [[ -z "$data" ]]; then
log::info "No identities found. Run 'wgctl identity migrate' to create from existing peers." log::info "No identities found. Run 'wgctl identity migrate' to create from existing peers."
@ -145,7 +140,7 @@ function cmd::identity::_show() {
data=$(identity::show_data "$name") data=$(identity::show_data "$name")
peer_count=$(echo "$data" | grep '^peer_count|' | cut -d'|' -f2) peer_count=$(echo "$data" | grep '^peer_count|' | cut -d'|' -f2)
# Precompute handshakes once for all peers # Precompute handshakes once for all peers in this identity
declare -A _id_handshakes=() declare -A _id_handshakes=()
while IFS=$'\t' read -r pk ts; do while IFS=$'\t' read -r pk ts; do
[[ -n "$pk" ]] && _id_handshakes["$pk"]="$ts" [[ -n "$pk" ]] && _id_handshakes["$pk"]="$ts"
@ -173,18 +168,10 @@ function cmd::identity::_show() {
esac esac
done <<< "$data" done <<< "$data"
# Rules tree
local identity_rules
identity_rules=$(identity::rules "$name")
if [[ -n "$identity_rules" ]]; then
printf "\n \033[2m── Rules \033[0m%s\n\n" \
"$(printf '\033[2m─%.0s' {1..38})"
ui::rule::identity_block "$name" "$strict" --no-header
fi
echo "" echo ""
} }
function cmd::identity::_device_status() { function cmd::identity::_device_status() {
local peer_name="${1:-}" local peer_name="${1:-}"
local -n _handshakes="${2:-__empty_map}" local -n _handshakes="${2:-__empty_map}"
@ -363,12 +350,11 @@ function cmd::identity::_rule() {
} }
function cmd::identity::_rule_assign() { function cmd::identity::_rule_assign() {
local name="" rule="" migrate=false local name="" rule=""
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--name) name="$2"; shift 2 ;; --name) name="$2"; shift 2 ;;
--rule) rule="$2"; shift 2 ;; --rule) rule="$2"; shift 2 ;;
--migrate) migrate=true; shift ;;
*) log::error "Unknown flag: $1"; return 1 ;; *) log::error "Unknown flag: $1"; return 1 ;;
esac esac
done done
@ -378,30 +364,7 @@ function cmd::identity::_rule_assign() {
identity::require_exists "$name" || return 1 identity::require_exists "$name" || return 1
rule::exists "$rule" || { log::error "Rule not found: ${rule}"; return 1; } rule::exists "$rule" || { log::error "Rule not found: ${rule}"; return 1; }
local conflicts=() local exit_code
while IFS= read -r peer_name; do
[[ -z "$peer_name" ]] && continue
local peer_rule
peer_rule=$(peers::get_meta "$peer_name" "rule" 2>/dev/null)
[[ "$peer_rule" == "$rule" ]] && conflicts+=("$peer_name")
done < <(identity::peers "$name")
if [[ ${#conflicts[@]} -gt 0 ]]; then
if ! $migrate; then
log::error "The following peers have '${rule}' as a direct rule: ${conflicts[*]}"
log::error "Use --migrate to remove direct rules and let the identity rule take over."
return 1
fi
# Migrate — remove direct rules from conflicting peers
for peer_name in "${conflicts[@]}"; do
local ip
ip=$(peers::get_ip "$peer_name")
rule::unapply "$rule" "$ip"
log::wg "Migrated '${rule}' from peer '${peer_name}' to identity '${name}'"
done
fi
local exit_code=0
identity::add_rule "$name" "$rule" || exit_code=$? identity::add_rule "$name" "$rule" || exit_code=$?
if [[ $exit_code -eq 2 ]]; then if [[ $exit_code -eq 2 ]]; then
@ -571,40 +534,3 @@ function cmd::identity::_options() {
cmd::identity::_rule_show --name "$name" cmd::identity::_rule_show --name "$name"
fi fi
} }
function cmd::identity::_output_json() {
local data
data=$(identity::list_data 2>/dev/null)
local -a identities=()
while IFS='|' read -r name peer_count types rules policy; do
[[ -z "$name" ]] && continue
# Build rules array
local rules_json="[]"
if [[ -n "$rules" ]]; then
local rules_array
rules_array=$(echo "$rules" | tr ',' '\n' | \
while IFS= read -r r; do [[ -n "$r" ]] && printf '"%s",' "$r"; done | sed 's/,$//')
rules_json="[${rules_array}]"
fi
# Build types array (was comma-separated string)
local types_json="[]"
if [[ -n "$types" ]]; then
local types_array
types_array=$(echo "$types" | tr ',' '\n' | \
while IFS= read -r t; do [[ -n "$t" ]] && printf '"%s",' "$t"; done | sed 's/,$//')
types_json="[${types_array}]"
fi
identities+=("$(printf '{"name":"%s","peer_count":%s,"types":%s,"rules":%s,"policy":"%s"}' \
"$name" "$peer_count" "$types_json" "$rules_json" "$policy")")
done <<< "$data"
local count=${#identities[@]}
local array
array=$(printf '%s\n' "${identities[@]:-}" | paste -sd ',' -)
printf '{"identities":[%s]}' "${array:-}" | json::envelope "identity list" "$count"
}

View file

@ -5,8 +5,6 @@ function cmd::inspect::on_load() {
flag::register --type flag::register --type
flag::register --config flag::register --config
flag::register --qr flag::register --qr
command::mixin json_output
} }
function cmd::inspect::help() { function cmd::inspect::help() {
@ -129,6 +127,25 @@ function cmd::inspect::_peer_info() {
return 0 return 0
} }
# function cmd::inspect::_rule_info() {
# local name="${1:-}"
# local rule
# rule=$(peers::get_meta "$name" "rule")
# [[ -z "$rule" ]] && return 0
# rule::exists "$rule" || return 0
# cmd::inspect::_section "Rule: ${rule}"
# if ui::rule::tree "$rule"; then
# # printf "\n"
# : # no-op
# else
# # No inheritance — flat view
# rule::render_flat "$rule"
# fi
# return 0
# }
function cmd::inspect::_rule_separator() { function cmd::inspect::_rule_separator() {
local line_width=20 local line_width=20
local total=$INSPECT_WIDTH local total=$INSPECT_WIDTH
@ -331,11 +348,6 @@ function cmd::inspect::run() {
name=$(peers::resolve_and_require "$name" "$type") || return 1 name=$(peers::resolve_and_require "$name" "$type") || return 1
if command::json; then
cmd::inspect::_output_json "$name"
return 0
fi
load_command list load_command list
log::section "Inspect: ${name}" log::section "Inspect: ${name}"
@ -359,70 +371,3 @@ function cmd::inspect::run() {
printf "\n" printf "\n"
} }
# ============================================
# JSON (API consumption)
# ============================================
function cmd::inspect::_output_json() {
local name="${1:-}"
local ip type rule allowed_ips public_key is_blocked status
ip=$(peers::get_ip "$name")
type=$(peers::get_type "$name")
rule=$(peers::get_meta "$name" "rule")
allowed_ips=$(grep "^AllowedIPs" "$(ctx::clients)/${name}.conf" 2>/dev/null | \
awk '{print $3}' | tr -d ',')
public_key=$(keys::public "$name" 2>/dev/null || echo "")
peers::is_blocked "$name" && is_blocked="true" || is_blocked="false"
# Handshake status
local handshake_ts=0
handshake_ts=$(wg show "$(config::interface)" latest-handshakes 2>/dev/null | \
grep "$public_key" | awk '{print $2}') || handshake_ts=0
local last_ts
last_ts=$(peers::get_meta "$name" "last_ts" 2>/dev/null || echo "")
local conn_state
conn_state=$(peers::connection_state "$is_blocked" "false" \
"${handshake_ts:-0}" "${last_ts:-}" | cut -d'|' -f1)
# Groups
local groups_json="[]"
local -a group_list=()
while IFS= read -r g; do
[[ -n "$g" ]] && group_list+=("\"$g\"")
done < <(json::peer_groups "$(ctx::groups)" "$name" 2>/dev/null)
[[ ${#group_list[@]} -gt 0 ]] && \
groups_json="[$(printf '%s,' "${group_list[@]}" | sed 's/,$//')]"
# Identity
local identity
identity=$(peers::get_identity "$name" 2>/dev/null || echo "")
# Rule extends
local rule_extends="[]"
if [[ -n "$rule" ]]; then
local rule_file
rule_file=$(json::find_rule_file "$(ctx::rules)" "$rule" 2>/dev/null)
if [[ -n "$rule_file" ]]; then
local -a extends=()
while IFS= read -r ext; do
[[ -n "$ext" ]] && extends+=("\"$ext\"")
done < <(json::get "$rule_file" "extends" 2>/dev/null)
[[ ${#extends[@]} -gt 0 ]] && \
rule_extends="[$(printf '%s,' "${extends[@]}" | sed 's/,$//')]"
fi
fi
local data
data=$(printf '{"name":"%s","ip":"%s","type":"%s","rule":"%s","rule_extends":%s,"allowed_ips":"%s","public_key":"%s","is_blocked":%s,"status":"%s","identity":"%s","groups":%s}' \
"$name" "$ip" "$type" \
"${rule:-}" "$rule_extends" \
"${allowed_ips:-}" "$public_key" \
"$is_blocked" "$conn_state" \
"${identity:-}" "$groups_json")
printf '%s' "$data" | json::envelope "inspect" "1"
}

View file

@ -19,7 +19,6 @@ function cmd::list::on_load() {
flag::register --allowed flag::register --allowed
flag::register --detailed flag::register --detailed
flag::register --name flag::register --name
command::mixin json_output
} }
# ============================================ # ============================================
@ -210,11 +209,6 @@ function cmd::list::run() {
return 0 return 0
fi fi
if command::json; then
cmd::list::_output_json "$collected_rows"
return 0
fi
if $detailed; then if $detailed; then
cmd::list::_render_detailed "$collected_rows" cmd::list::_render_detailed "$collected_rows"
cmd::list::_render_summary_from_rows "$collected_rows" cmd::list::_render_summary_from_rows "$collected_rows"
@ -226,11 +220,8 @@ function cmd::list::run() {
case "$style" in case "$style" in
table) cmd::list::_render_table ;; table) cmd::list::_render_table ;;
compact) display::render "peer_list" "$collected_rows" \ compact) cmd::list::_render_compact "$collected_rows" ;;
"cmd::list::_render_compact" "cmd::list::_render_table" ;; *) cmd::list::_render_compact "$collected_rows" ;;
*) display::render "peer_list" "$collected_rows" \
"cmd::list::_render_compact" "cmd::list::_render_table" ;;
esac esac
} }
@ -349,59 +340,19 @@ function cmd::list::_render_compact() {
# ============================================ # ============================================
function cmd::list::_render_table() { function cmd::list::_render_table() {
local rows="${1:-}" declare -A rule_counts=() group_counts=()
[[ -z "$rows" ]] && log::wg_warning "No results found" && return 0 _list_header_printed=false
# Measure column widths from data (same as compact) cmd::list::_iter_confs_table
local w_name=16 w_ip=13 w_type=8 w_rule=10 w_group=10 w_status=10 w_last=20
while IFS='|' read -r name ip type rule group status last_seen is_blocked is_restricted; do
[[ -z "$name" ]] && continue
(( ${#name} > w_name )) && w_name=${#name}
(( ${#ip} > w_ip )) && w_ip=${#ip}
(( ${#type} > w_type )) && w_type=${#type}
(( ${#rule} > w_rule )) && w_rule=${#rule}
(( ${#group} > w_group )) && w_group=${#group}
(( ${#last_seen} > w_last )) && w_last=${#last_seen}
local cs
cs=$(printf "%s" "$status" | sed 's/\x1b\[[0-9;]*m//g')
(( ${#cs} > w_status )) && w_status=${#cs}
echo "DEBUG cs='$cs' name='$name'" >&2
done <<< "$rows"
(( w_name += 2 )); (( w_ip += 2 ))
(( w_type += 2 )); (( w_rule += 2 ))
(( w_group += 2 )); (( w_last += 2 ))
# Header if [[ "$_list_header_printed" == "true" ]]; then
printf "\n %-${w_name}s %-${w_ip}s %-${w_type}s %-${w_rule}s %-${w_group}s %-${w_status}s %s\n" \ cmd::list::_render_footer $has_groups
"NAME" "IP" "TYPE" "RULE" "GROUP" "STATUS" "LAST SEEN" local group_summary=""
printf " %s\n" "$(printf '─%.0s' {1..115})" cmd::list::_build_group_summary
printf "\n Showing peers\n\n"
# Rows else
while IFS='|' read -r name ip type rule group status last_seen is_blocked is_restricted; do log::wg_warning "No results found"
[[ -z "$name" ]] && continue fi
local clean_status
clean_status=$(echo "$status" | sed 's/\x1b\[[0-9;]*m//g')
local status_pad_n=$(( w_status - ${#clean_status} ))
[[ $status_pad_n -lt 0 ]] && status_pad_n=0
local row_color status_color
row_color=$(ui::peer::_row_color "$is_blocked" "$is_restricted" "$clean_status")
status_color=$(ui::peer::status_color "$is_blocked" "$is_restricted" "$clean_status")
local status_colored="${status_color}${clean_status}\033[0m"
if [[ -n "$row_color" ]]; then
printf " %b%-${w_name}s %-${w_ip}s %-${w_type}s %-${w_rule}s %-${w_group}s %-${w_status}s %s\033[0m\n" \
"$row_color" "$name" "$ip" "$type" "$rule" "$group" "$clean_status" "$last_seen"
else
printf " %-${w_name}s %-${w_ip}s %-${w_type}s %-${w_rule}s %-${w_group}s %b%*s\033[0m %s\n" \
"$name" "$ip" "$type" "$rule" "$group" \
"$status_color${clean_status}" "$status_pad_n" "" "$last_seen"
fi
done <<< "$rows"
printf " %s\n" "$(printf '─%.0s' {1..115})"
cmd::list::_render_summary_from_rows "$rows"
} }
function cmd::list::_iter_confs_table() { function cmd::list::_iter_confs_table() {
@ -468,10 +419,13 @@ function cmd::list::_render_detailed() {
ui::peer::list_identity_header "$id_name" ui::peer::list_identity_header "$id_name"
while IFS='|' read -r name ip type rule group status last_seen is_blocked is_restricted; do while IFS='|' read -r name ip type rule group status last_seen is_blocked is_restricted; do
[[ -z "$name" ]] && continue [[ -z "$name" ]] && continue
local subnet
local peer_type="${p_types[$name]:-}" subnet=$(peers::get_meta "$name" "subnet" 2>/dev/null)
subnet=$(peers::get_display_subnet "$name" "$peer_type") if [[ -z "$subnet" ]]; then
local peer_type="${p_types[$name]:-}"
[[ -n "$peer_type" ]] && subnet="$peer_type"
fi
[[ -z "$subnet" ]] && subnet="-"
ui::peer::list_row_detailed \ ui::peer::list_row_detailed \
"$w_name" "$w_ip" "$w_type" "$w_rule" "$w_group" "$w_subnet" \ "$w_name" "$w_ip" "$w_type" "$w_rule" "$w_group" "$w_subnet" \
"$name" "$ip" "$type" "$rule" "$group" "$subnet" \ "$name" "$ip" "$type" "$rule" "$group" "$subnet" \
@ -712,31 +666,3 @@ function cmd::list::_show_client_safe() {
local name="$1" local name="$1"
cmd::list::show_client "$name" || true cmd::list::show_client "$name" || true
} }
# ============================================
# JSON (API consumption)
# ============================================
function cmd::list::_output_json() {
local rows="${1:-}"
local -a peers=()
while IFS='|' read -r name ip type rule group status last_seen is_blocked is_restricted; do
[[ -z "$name" ]] && continue
# Escape strings for JSON
local peer_json
peer_json=$(printf '{"name":"%s","ip":"%s","type":"%s","rule":"%s","group":"%s","status":"%s","last_seen":"%s","is_blocked":%s,"is_restricted":%s}' \
"$name" "$ip" "$type" \
"${rule}" "${group}" \
"$status" "$last_seen" \
"$is_blocked" "$is_restricted")
peers+=("$peer_json")
done <<< "$rows"
local count=${#peers[@]}
local array
# Join array with commas
array=$(printf '%s\n' "${peers[@]}" | paste -sd ',' -)
printf '{"peers":[%s]}' "$array" | json::envelope "list" "$count"
}

View file

@ -17,12 +17,6 @@ function cmd::logs::on_load() {
flag::register --force flag::register --force
flag::register --days flag::register --days
flag::register --raw flag::register --raw
flag::register --detailed
flag::register --service
flag::register --event
flag::register --ascending
flag::register --descending
flag::register --resolved
} }
function cmd::logs::help() { function cmd::logs::help() {
@ -33,31 +27,18 @@ Show or manage WireGuard and firewall activity logs.
Subcommands: Subcommands:
show (default) Show activity logs show (default) Show activity logs
clean Remove keepalive handshakes (deduplicate)
remove, rm Remove log entries remove, rm Remove log entries
rotate Remove entries older than N days 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
--type <type> Filter by device type --type <type> Filter by device type
--limit <n> Max results per source (default: 50) --limit <n> Max results per source (default: 50)
--since <time> Show events since: 2h, 7d, 23/05, 23/05/2026, 2026-05-23 --fw Show only firewall drops
--service <svc> Filter by service name, IP, or IP:port --wg Show only WireGuard events
e.g. pihole, proxmox:web-ui, 10.0.0.100, 10.0.0.100:8006 --merged Show all events chronologically interleaved
--event <type> Filter wg events: attempt | handshake --follow, -f Follow logs in real time (alias: wgctl watch)
--fw Show only firewall drops --raw Show raw IPs without service annotation
--wg Show only WireGuard events
--merged Show all events chronologically interleaved
--detailed Show all deduplicated events (bypass hourly collapse)
--follow, -f Follow logs in real time
--raw Show raw IPs without service annotation
--resolved Show only resolved names, hide raw IPs
--ascending Sort oldest first
--descending Sort newest first (default)
Options for clean:
--wg Clean only WireGuard events (default: handshakes only)
--force Skip confirmation
Options for remove: Options for remove:
--name <name> Remove entries for specific peer --name <name> Remove entries for specific peer
@ -73,20 +54,10 @@ Options for rotate:
Examples: Examples:
wgctl logs wgctl logs
wgctl logs --since 2h wgctl logs --name phone-nuno
wgctl logs --since 23/05 wgctl logs --fw --limit 100
wgctl logs --name phone-nuno --since 7d
wgctl logs --fw --service pihole
wgctl logs --fw --service proxmox:web-ui
wgctl logs --fw --service 10.0.0.100
wgctl logs --wg --event attempt
wgctl logs --wg --event handshake --since 24h
wgctl logs --detailed
wgctl logs --resolved
wgctl logs --merged wgctl logs --merged
wgctl logs --follow wgctl logs --follow
wgctl logs clean
wgctl logs clean --force
wgctl logs remove --name phone-nuno wgctl logs remove --name phone-nuno
wgctl logs rotate --days 30 wgctl logs rotate --days 30
EOF EOF
@ -104,7 +75,6 @@ function cmd::logs::run() {
show) cmd::logs::show "$@" ;; show) cmd::logs::show "$@" ;;
remove|rm|del) cmd::logs::remove "$@" ;; remove|rm|del) cmd::logs::remove "$@" ;;
rotate) cmd::logs::rotate "$@" ;; rotate) cmd::logs::rotate "$@" ;;
clean) cmd::logs::clean "$@" ;;
help) cmd::logs::help ;; help) cmd::logs::help ;;
*) *)
log::error "Unknown subcommand: '${subcmd}'" log::error "Unknown subcommand: '${subcmd}'"
@ -115,32 +85,20 @@ function cmd::logs::run() {
} }
function cmd::logs::show() { function cmd::logs::show() {
local name="" type="" limit=50 since="" local name="" type="" limit=50
local fw_only=false wg_only=false follow=false merged=false local fw_only=false wg_only=false follow=false merged=false raw=false
local raw=false detailed=false
local filter_service="" filter_event=""
local sort_order="desc"
local resolved=false
local sort_order="desc"
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 ;;
--limit) limit="$2"; shift 2 ;; --limit) limit="$2"; shift 2 ;;
--since) since="$2"; shift 2 ;; --fw) fw_only=true; shift ;;
--service) filter_service="$2"; shift 2 ;; --wg) wg_only=true; shift ;;
--event) filter_event="$2"; shift 2 ;; --merged) merged=true; shift ;;
--fw) fw_only=true; shift ;; --follow|-f) follow=true; shift ;;
--wg) wg_only=true; shift ;; --raw) raw=true; shift ;;
--merged) merged=true; shift ;; --help) cmd::logs::help; return ;;
--follow|-f) follow=true; shift ;;
--raw) raw=true; shift ;;
--resolved) resolved=true; shift ;;
--ascending) sort_order="asc"; shift ;;
--descending) sort_order="desc"; shift ;;
--detailed) detailed=true; shift ;;
--help) cmd::logs::help; return ;;
*) *)
log::error "Unknown flag: $1" log::error "Unknown flag: $1"
return 1 return 1
@ -148,9 +106,6 @@ function cmd::logs::show() {
esac esac
done done
local collapse=1
$detailed && collapse=0
if [[ -n "$name" && -n "$type" ]]; then if [[ -n "$name" && -n "$type" ]]; then
name=$(peers::resolve_and_require "$name" "$type") || return 1 name=$(peers::resolve_and_require "$name" "$type") || return 1
fi fi
@ -169,257 +124,95 @@ function cmd::logs::show() {
local net_file="" local net_file=""
$raw || net_file="$(ctx::net)" $raw || net_file="$(ctx::net)"
# Parse --service into dest_ip and dest_port
local filter_dest_ip="" filter_dest_port=""
if [[ -n "$filter_service" ]]; then
if [[ "$filter_service" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(:[0-9]+)?$ ]]; then
filter_dest_ip="${filter_service%%:*}"
local maybe_port="${filter_service##*:}"
[[ "$maybe_port" != "$filter_dest_ip" ]] && filter_dest_port="$maybe_port"
else
local svc_resolved
svc_resolved=$(net::resolve "$filter_service" 2>/dev/null | head -1)
if [[ -n "$svc_resolved" ]]; then
filter_dest_ip="${svc_resolved%%:*}"
local rest="${svc_resolved#*:}"
[[ "$rest" != "$filter_dest_ip" ]] && filter_dest_port="${rest%%:*}"
else
log::error "Service not found: ${filter_service}"
return 1
fi
fi
fi
if $merged; then
log::section "WireGuard Activity Log"
printf "\n"
cmd::logs::show_merged "$filter_ip" "$name" "$type" "$limit" "$net_file" "$since"
return
fi
# Collect output — only show header if there's data
local fw_output="" wg_output=""
$wg_only || fw_output=$(cmd::logs::show_fw_events \
"$filter_ip" "$name" "$type" "$limit" "$net_file" \
"$collapse" "$since" "$filter_dest_ip" "$filter_dest_port" "$sort_order" "$resolved")
$fw_only || wg_output=$(cmd::logs::show_wg_events \
"$filter_ip" "$name" "$type" "$limit" \
"$collapse" "$since" "$filter_event" "$sort_order" "$resolved")
if [[ -z "$(echo "$fw_output" | tr -d '[:space:]')" && \
-z "$(echo "$wg_output" | tr -d '[:space:]')" ]]; then
log::wg_warning "No logs found"
return 0
fi
log::section "WireGuard Activity Log" log::section "WireGuard Activity Log"
printf "\n" printf "\n"
if [[ -n "$fw_output" && -n "$wg_output" ]]; then if $merged; then
printf "%s\n\n" "$fw_output" cmd::logs::show_merged "$filter_ip" "$name" "$type" "$limit" "$net_file"
printf "%s\n" "$wg_output" return
elif [[ -n "$fw_output" ]]; then
printf "%s\n" "$fw_output"
else
printf "%s\n" "$wg_output"
fi fi
$wg_only || cmd::logs::show_fw_events "$filter_ip" "$name" "$type" "$limit" "$net_file"
$fw_only || cmd::logs::show_wg_events "$filter_ip" "$name" "$type" "$limit"
} }
function cmd::logs::show_fw_events() { function cmd::logs::show_fw_events() {
local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" \ local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" \
limit="${4:-50}" net_file="${5:-}" collapse="${6:-1}" \ limit="${4:-50}" net_file="${5:-}"
since="${7:-}" filter_dest_ip="${8:-}" filter_dest_port="${9:-}" \
sort_order="${10:-desc}" resolved_only="${11:-false}"
[[ ! -f "$FW_EVENTS_LOG" ]] && return 0 [[ ! -f "$FW_EVENTS_LOG" ]] && return 0
local data local data
data=$(json::fw_events \ data=$(json::fw_events "$FW_EVENTS_LOG" "$filter_ip" "$filter_type" \
"$FW_EVENTS_LOG" "$filter_ip" "$filter_type" \ "$(ctx::clients)" "${net_file:-}" "$limit" 2>/dev/null)
"$(ctx::clients)" "${net_file:-}" \
"$limit" "$collapse" "$since" \
"$filter_dest_ip" "$filter_dest_port" \
"$sort_order" \
2>/dev/null)
[[ -z "$data" ]] && return 0 [[ -z "$data" ]] && return 0
# ── Collect unique endpoints for batch resolution ── # Measure column widths
local -a ep_list=() local w_client=16 w_dest=20
while IFS='|' read -r ts client dest_ip dest_port proto svc count src_endpoint; do while IFS='|' read -r ts client dest_ip dest_port proto svc count; do
[[ -z "$ts" || -z "$src_endpoint" ]] && continue
ep_list+=("$src_endpoint")
done <<< "$data"
declare -A resolve_cache=()
if [[ ${#ep_list[@]} -gt 0 ]]; then
while IFS='|' read -r ip name; do
[[ -n "$ip" ]] && resolve_cache["$ip"]="$name"
done < <(json::batch_resolve "${ep_list[@]}" 2>/dev/null)
fi
# ── Pass 1: measure widths ──
local w_client=16 w_dest=20 w_endpoint=0
local resolved_data=""
while IFS='|' read -r ts client dest_ip dest_port proto svc count src_endpoint; do
[[ -z "$ts" ]] && continue [[ -z "$ts" ]] && continue
(( ${#client} > w_client )) && w_client=${#client} (( ${#client} > w_client )) && w_client=${#client}
local dest_display
local svc_display=""
if [[ -n "$svc" ]]; then if [[ -n "$svc" ]]; then
[[ -n "$dest_port" ]] && svc_display="${svc}/${proto}" \ [[ -n "$dest_port" ]] && dest_display="${svc}/${proto}" || dest_display="${svc} (${proto})"
|| svc_display="${svc} (${proto})"
else else
[[ -n "$dest_port" ]] && svc_display="${dest_ip}:${dest_port}/${proto}" \ [[ -n "$dest_port" ]] && dest_display="${dest_ip}:${dest_port}/${proto}" || dest_display="${dest_ip} (${proto})"
|| svc_display="${dest_ip} (${proto})"
fi fi
(( ${#dest_display} > w_dest )) && w_dest=${#dest_display}
local measure_len
if $resolved_only; then
measure_len=${#svc_display}
else
local raw_plain=""
[[ -n "$svc" && -n "$dest_port" ]] && raw_plain=" (${dest_ip}:${dest_port})"
[[ -n "$svc" && -z "$dest_port" ]] && raw_plain=" (${dest_ip})"
measure_len=$(( ${#svc_display} + ${#raw_plain} ))
fi
(( measure_len > w_dest )) && w_dest=$measure_len
local src_resolved=""
if [[ -n "$src_endpoint" ]]; then
src_resolved="${resolve_cache[$src_endpoint]:-}"
[[ "$src_resolved" == "$src_endpoint" ]] && src_resolved=""
local ep_measure_len
if $resolved_only; then
ep_measure_len=${#src_resolved}
[[ -z "$src_resolved" ]] && ep_measure_len=${#src_endpoint}
else
ep_measure_len=${#src_endpoint}
[[ -n "$src_resolved" ]] && \
ep_measure_len=$(( ${#src_endpoint} + 4 + ${#src_resolved} ))
fi
(( ep_measure_len > w_endpoint )) && w_endpoint=$ep_measure_len
fi
resolved_data+="${ts}|${client}|${dest_ip}|${dest_port}|${proto}|${svc}|${count}|${src_endpoint}|${src_resolved}"$'\n'
done <<< "$data" done <<< "$data"
(( w_client += 2 )) (( w_client += 2 ))
(( w_dest += 2 )) (( w_dest += 2 ))
[[ "$w_endpoint" -gt 0 ]] && (( w_endpoint += 2 ))
# ── Pass 2: render ──
ui::logs::fw_section_header ui::logs::fw_section_header
while IFS='|' read -r ts client dest_ip dest_port proto svc count src_endpoint src_resolved; do while IFS='|' read -r ts client dest_ip dest_port proto svc count; do
[[ -z "$ts" ]] && continue [[ -z "$ts" ]] && continue
ui::logs::fw_row "$ts" "$client" "$dest_ip" "$dest_port" \ ui::logs::fw_row "$ts" "$client" "$dest_ip" "$dest_port" \
"$proto" "$svc" "$count" "$w_client" "$w_dest" \ "$proto" "$svc" "$count" "$w_client" "$w_dest"
"$src_endpoint" "$src_resolved" "$w_endpoint" "$resolved_only" done <<< "$data"
done <<< "$resolved_data"
printf "\n" printf "\n"
} }
function cmd::logs::show_wg_events() { function cmd::logs::show_wg_events() {
local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" \ local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" limit="${4:-50}"
limit="${4:-50}" collapse="${5:-1}" \
since="${6:-}" filter_event="${7:-}" sort_order="${8:-desc}" \
resolved_only="${9:-false}"
[[ ! -f "$WG_EVENTS_LOG" ]] && return 0 [[ ! -f "$WG_EVENTS_LOG" ]] && return 0
local data local data
data=$(json::wg_events \ data=$(json::wg_events "$WG_EVENTS_LOG" "$filter_name" "$filter_type" "$limit" 2>/dev/null)
"$WG_EVENTS_LOG" "$filter_name" "$filter_type" \
"$limit" "$collapse" "$since" "$filter_event" \
"$(ctx::endpoint_cache)" "$sort_order" \
2>/dev/null)
[[ -z "$data" ]] && return 0 [[ -z "$data" ]] && return 0
# ── Collect unique endpoints for batch resolution ── # Measure column widths
local -a ep_list=()
while IFS='|' read -r ts client endpoint event count gap_seconds; do
[[ -z "$ts" || -z "$endpoint" ]] && continue
ep_list+=("$endpoint")
done <<< "$data"
declare -A resolve_cache=()
if [[ ${#ep_list[@]} -gt 0 ]]; then
while IFS='|' read -r ip name; do
[[ -n "$ip" ]] && resolve_cache["$ip"]="$name"
done < <(json::batch_resolve "${ep_list[@]}" 2>/dev/null)
fi
# ── Measure widths ──
local w_client=16 w_endpoint=16 local w_client=16 w_endpoint=16
local resolved_data="" while IFS='|' read -r ts client endpoint event count; do
while IFS='|' read -r ts client endpoint event count gap_seconds; do
[[ -z "$ts" ]] && continue [[ -z "$ts" ]] && continue
(( ${#client} > w_client )) && w_client=${#client} (( ${#client} > w_client )) && w_client=${#client}
(( ${#endpoint} > w_endpoint )) && w_endpoint=${#endpoint}
local resolved=""
if [[ -n "$endpoint" ]]; then
resolved="${resolve_cache[$endpoint]:-}"
[[ "$resolved" == "$endpoint" ]] && resolved=""
fi
local ep_raw="${endpoint:--}"
local ep_measure_len
if $resolved_only; then
local ep_display="${resolved:-$endpoint}"
[[ -z "$ep_display" ]] && ep_display="-"
ep_measure_len=${#ep_display}
else
ep_measure_len=${#ep_raw}
[[ -n "$resolved" && -n "$endpoint" ]] && \
ep_measure_len=$(( ${#endpoint} + 4 + ${#resolved} ))
fi
(( ep_measure_len > w_endpoint )) && w_endpoint=$ep_measure_len
resolved_data+="${ts}|${client}|${endpoint}|${event}|${count}|${gap_seconds}|${resolved}"$'\n'
done <<< "$data" done <<< "$data"
(( w_client += 2 )) (( w_client += 2 ))
(( w_endpoint += 2 )) (( w_endpoint += 2 ))
# ── Render ──
ui::logs::wg_section_header ui::logs::wg_section_header
while IFS='|' read -r ts client endpoint event count gap_seconds resolved; do while IFS='|' read -r ts client endpoint event count; do
[[ -z "$ts" ]] && continue [[ -z "$ts" ]] && continue
if $resolved_only; then ui::logs::wg_row "$ts" "$client" "$endpoint" "$event" \
local ep_display="${resolved:-$endpoint}" "$count" "$w_client" "$w_endpoint"
[[ -z "$ep_display" ]] && ep_display="-" done <<< "$data"
ui::logs::wg_row "$ts" "$client" "$ep_display" "$event" \
"$count" "$w_client" "$w_endpoint" "$gap_seconds" ""
else
ui::logs::wg_row "$ts" "$client" "$endpoint" "$event" \
"$count" "$w_client" "$w_endpoint" "$gap_seconds" "$resolved"
fi
done <<< "$resolved_data"
printf "\n" printf "\n"
} }
function cmd::logs::show_merged() { function cmd::logs::show_merged() {
local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" \ local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" \
limit="${4:-50}" net_file="${5:-}" since="${6:-}" limit="${4:-50}" net_file="${5:-}"
local fw_data wg_data local fw_data wg_data
fw_data=$(json::fw_events \ fw_data=$(json::fw_events "$FW_EVENTS_LOG" "$filter_ip" "$filter_type" \
"$FW_EVENTS_LOG" "$filter_ip" "$filter_type" \ "$(ctx::clients)" "${net_file:-}" "$limit" 2>/dev/null)
"$(ctx::clients)" "${net_file:-}" \ wg_data=$(json::wg_events "$WG_EVENTS_LOG" "$filter_name" "$filter_type" \
"$limit" "1" "$since" "" "" \ "$limit" 2>/dev/null)
2>/dev/null)
wg_data=$(json::wg_events \
"$WG_EVENTS_LOG" "$filter_name" "$filter_type" \
"$limit" "1" "$since" "" \
2>/dev/null)
# Measure widths across both sources
local w_client=16 w_dest=20 local w_client=16 w_dest=20
while IFS='|' read -r ts client rest; do while IFS='|' read -r ts client rest; do
[[ -z "$ts" ]] && continue [[ -z "$ts" ]] && continue
@ -427,6 +220,7 @@ function cmd::logs::show_merged() {
done < <(echo "$fw_data"; echo "$wg_data") done < <(echo "$fw_data"; echo "$wg_data")
(( w_client += 2 )) (( w_client += 2 ))
# Tag and merge: prefix fw lines with "fw|", wg lines with "wg|"
local merged_data local merged_data
merged_data=$( merged_data=$(
while IFS='|' read -r ts client dest_ip dest_port proto svc count; do while IFS='|' read -r ts client dest_ip dest_port proto svc count; do
@ -439,6 +233,7 @@ function cmd::logs::show_merged() {
done <<< "$wg_data" done <<< "$wg_data"
) )
# Sort by timestamp field 2
while IFS='|' read -r source ts rest; do while IFS='|' read -r source ts rest; do
[[ -z "$source" ]] && continue [[ -z "$source" ]] && continue
case "$source" in case "$source" in
@ -483,12 +278,14 @@ function cmd::logs::follow() {
log::section "WireGuard Live Log (Ctrl+C to stop)" log::section "WireGuard Live Log (Ctrl+C to stop)"
printf "\n" printf "\n"
local restricted_only=false blocked_only=false # Delegate to watch command
$fw_only && restricted_only=true local watch_args=()
$wg_only && blocked_only=true [[ -n "$filter_name" ]] && watch_args+=(--name "$filter_name")
[[ -n "$filter_type" ]] && watch_args+=(--type "$filter_type")
$fw_only && watch_args+=(--restricted)
$wg_only && watch_args+=(--blocked)
monitor::live "$filter_name" "$filter_type" "" \ cmd::watch::run "${watch_args[@]}"
"$blocked_only" "$restricted_only" "false" "false"
} }
function cmd::logs::remove() { function cmd::logs::remove() {
@ -563,9 +360,9 @@ function cmd::logs::rotate() {
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--days) days="$2"; shift 2 ;; --days) days="$2"; shift 2 ;;
--force) force=true; shift ;; --force) force=true; shift ;;
--help) cmd::logs::help; return ;; --help) cmd::logs::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;; *) log::error "Unknown flag: $1"; return 1 ;;
esac esac
done done
@ -594,30 +391,3 @@ function cmd::logs::rotate() {
log::wg_success "Rotated ${total} entries older than ${days} days (wg: ${removed_wg}, fw: ${removed_fw})" log::wg_success "Rotated ${total} entries older than ${days} days (wg: ${removed_wg}, fw: ${removed_fw})"
} }
function cmd::logs::clean() {
local force=false wg_only=false
while [[ $# -gt 0 ]]; do
case "$1" in
--force) force=true; shift ;;
--wg) wg_only=true; shift ;;
--help) cmd::logs::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
if ! $force; then
read -r -p "Remove keepalive handshakes from events.log? [y/N] " confirm
case "$confirm" in [yY]*) ;; *) log::info "Aborted"; return 0 ;; esac
fi
local removed
removed=$(json::clean_handshakes "$WG_EVENTS_LOG" "${WG_HANDSHAKE_CHECK_TIME_SEC:-300}")
if [[ "$removed" -eq 0 ]]; then
log::wg_warning "No keepalive handshakes found to remove"
else
log::wg_success "Removed ${removed} keepalive handshake entries"
fi
}

View file

@ -1,34 +0,0 @@
#!/usr/bin/env bash
# commands/mixins/<name>.mixin.sh
# Template for creating a new mixin
#
# 1. Copy this file to core/mixins/ (framework) or commands/mixins/ (wgctl-specific)
# 2. Replace <name> with your mixin name (e.g. peer_filter, time_filter)
# 3. Replace <FLAG> with your flag (e.g. --name, --since)
# 4. Add state variable and accessor function
# 5. In on_load: command::mixin <name>
# State variable
_COMMAND_<NAME>=false # or "" for string values
# Called when command::mixin <name> is used in on_load
function command::mixin::<name>::register() {
flag::register --<flag>
# Add more flag::register calls if needed
}
# Called before each command invocation to reset state
function command::mixin::<name>::reset() {
_COMMAND_<NAME>=false
}
# Called for each arg — return 0 if consumed, 1 if not
function command::mixin::<name>::process() {
case "$1" in
--<flag>) _COMMAND_<NAME>=true; return 0 ;;
esac
return 1
}
# Public accessor — used by commands
function command::<name>() { [[ "${_COMMAND_<NAME>:-false}" == "true" ]]; }

View file

@ -8,8 +8,6 @@ function cmd::net::on_load() {
flag::register --tag flag::register --tag
flag::register --detailed flag::register --detailed
flag::register --force flag::register --force
command::mixin json_output
} }
function cmd::net::help() { function cmd::net::help() {
@ -60,12 +58,6 @@ EOF
function cmd::net::run() { function cmd::net::run() {
local subcmd="${1:-list}" local subcmd="${1:-list}"
shift || true shift || true
if command::json; then
cmd::net::_output_json
return 0
fi
case "$subcmd" in case "$subcmd" in
list) cmd::net::list "$@" ;; list) cmd::net::list "$@" ;;
show) cmd::net::show "$@" ;; show) cmd::net::show "$@" ;;
@ -103,63 +95,53 @@ function cmd::net::list() {
return 0 return 0
fi fi
# Collect filtered data and build ports display per service log::section "Network Services"
local filtered_data="" printf "\n %-20s %-16s %-6s %s\n" "NAME" "IP" "PORTS" "DESCRIPTION"
while IFS="|" read -r name ip desc tags port_count; do local divider
divider=$(printf '─%.0s' {1..72})
printf " %s\n" "$divider"
local found=false
while IFS="|" read -r name ip desc tags ports; do
[[ -z "$name" ]] && continue [[ -z "$name" ]] && continue
[[ -n "$filter_tag" && "$tags" != *"$filter_tag"* ]] && continue
# Build ports display from json::net_show # Tag filter
local ports_display="" if [[ -n "$filter_tag" ]]; then
while IFS="|" read -r ptype pname pport pproto pdesc; do [[ "$tags" != *"$filter_tag"* ]] && continue
[[ "$ptype" != "port" ]] && continue fi
local port_str=":${pport}"
[[ -n "$pproto" && "$pproto" != "tcp" ]] && port_str="${port_str}/${pproto}" found=true
ports_display+="${port_str}, " local tag_display=""
done < <(json::net_show "$net_file" "$name") [[ -n "$tags" ]] && tag_display=" \033[0;37m[${tags}]\033[0m"
ports_display="${ports_display%, }"
[[ -z "$ports_display" ]] && ports_display="-" printf " %-20s %-16s %-6s %s%b\n" \
"$name" "$ip" "${ports}p" "${desc:-}" "$tag_display"
if $detailed; then
local has_ports=false
# Show ports inline
while IFS="|" read -r ptype pname pport pproto pdesc; do
[[ "$ptype" != "port" ]] && continue
has_ports=true
local ann
ann=$(net::annotation "$ip" "$pport" "$pproto")
printf " \033[0;37m%-18s %s:%s%s\033[0m\n" \
"${pname}" "$pport" "$pproto" \
"${pdesc:+ # $pdesc}"
done < <(json::net_show "$net_file" "$name")
$has_ports && printf "\n" # newline after each service with ports
fi
filtered_data+="${name}|${ip}|${desc}|${tags}|${ports_display}"$'\n'
done < <(json::net_list "$net_file") done < <(json::net_list "$net_file")
[[ -z "$filtered_data" ]] && { if ! $found; then
[[ -n "$filter_tag" ]] && \ [[ -n "$filter_tag" ]] && \
log::wg_warning "No services with tag: ${filter_tag}" || \ log::wg_warning "No services with tag: ${filter_tag}" || \
log::wg_warning "No services configured" log::wg_warning "No services configured"
return 0 fi
}
# Measure column widths printf "\n"
local w_name=12 w_ip=13 w_ports=16 return 0
while IFS="|" read -r name ip desc tags ports; do
[[ -z "$name" ]] && continue
(( ${#name} > w_name )) && w_name=${#name}
(( ${#ip} > w_ip )) && w_ip=${#ip}
(( ${#ports} > w_ports )) && w_ports=${#ports}
done <<< "$filtered_data"
(( w_name += 2 ))
(( w_ip += 2 ))
(( w_ports += 2 ))
log::section "Network Services"
echo ""
while IFS="|" read -r name ip desc tags ports; do
[[ -z "$name" ]] && continue
ui::net::list_row "$name" "$ip" "$desc" "$tags" "$ports" \
"$w_name" "$w_ip" "$w_ports"
if $detailed; then
while IFS="|" read -r ptype pname pport pproto pdesc; do
[[ "$ptype" != "port" ]] && continue
ui::net::show_port_row "$pname" "$pport" "$pproto" "$pdesc"
done < <(json::net_show "$net_file" "$name")
echo ""
fi
done <<< "$filtered_data"
echo ""
} }
# ============================================ # ============================================
@ -183,24 +165,26 @@ function cmd::net::show() {
log::section "Service: ${name}" log::section "Service: ${name}"
printf "\n" printf "\n"
local has_ports=false
while IFS="|" read -r key val1 val2 val3 val4; do while IFS="|" read -r key val1 val2 val3 val4; do
case "$key" in case "$key" in
name) ui::row "Name" "$val1" ;; name) ui::row "Name" "$val1" ;;
ip) ui::row "IP" "$val1" ;;
desc) ui::row "Description" "${val1:-}" ;; desc) ui::row "Description" "${val1:-}" ;;
tags) ui::row "Tags" "${val1:-}" ;; tags) ui::row "Tags" "${val1:-}" ;;
ip) ui::row "IP" "$val1" ;;
port) port)
if ! $has_ports; then # val1=port_name val2=port val3=proto val4=desc
printf " %-20s\n" "Ports:" local ann
has_ports=true ann=$(net::annotation "$(json::net_resolve "$(ctx::net)" "$name")" \
fi "$val2" "$val3" 2>/dev/null || true)
ui::net::show_port_row "$val1" "$val2" "$val3" "$val4" printf " %-20s \033[0;36m%s\033[0m %s:%s%s\n" \
"${val1}:" "" "$val2" "$val3" \
"${val4:+ # $val4}"
;; ;;
esac esac
done < <(json::net_show "$(ctx::net)" "$name") done < <(json::net_show "$(ctx::net)" "$name")
printf "\n" printf "\n"
return 0
} }
# ============================================ # ============================================
@ -313,30 +297,3 @@ function cmd::net::rm() {
log::wg_success "Removed: ${name}" log::wg_success "Removed: ${name}"
return 0 return 0
} }
function cmd::net::_output_json() {
local net_file
net_file="$(ctx::net)"
local data
data=$(json::net_list "$net_file" 2>/dev/null)
local -a services=()
while IFS='|' read -r name ip desc tags port_count; do
[[ -z "$name" ]] && continue
# Build tags array
local tags_json="[]"
if [[ -n "$tags" ]]; then
local tags_array
tags_array=$(echo "$tags" | tr ',' '\n' | \
while IFS= read -r t; do [[ -n "$t" ]] && printf '"%s",' "$t"; done | sed 's/,$//')
tags_json="[${tags_array}]"
fi
services+=("$(printf '{"name":"%s","ip":"%s","desc":"%s","tags":%s,"port_count":%s}' \
"$name" "$ip" "$desc" "$tags_json" "$port_count")")
done <<< "$data"
local count=${#services[@]}
local array
array=$(printf '%s\n' "${services[@]:-}" | paste -sd ',' -)
printf '{"services":[%s]}' "${array:-}" | json::envelope "net list" "$count"
}

View file

@ -1,197 +0,0 @@
#!/usr/bin/env bash
# peer.command.sh — peer management operations
# ============================================
# Lifecycle
# ============================================
function cmd::peer::on_load() {
flag::register --name
flag::register --type
flag::register --all
flag::register --mode
flag::register --dns
flag::register --fallback-dns
flag::register --force
}
# ============================================
# Help
# ============================================
function cmd::peer::help() {
cat <<EOF
Usage: wgctl peer <subcommand> [options]
Manage peer configuration and settings.
Subcommands:
update-dns Update DNS settings in client config(s)
update-tunnel Update tunnel mode (split/full) in client config(s)
Options for update-dns:
--name <name> Target peer
--all Apply to all peers
--type <type> Filter by device type
--dns <ip> Primary DNS (default: from config)
--fallback-dns <ips> Fallback DNS servers (comma-separated)
Default: from WG_DNS_FALLBACK in wgctl.conf
Options for update-tunnel:
--name <name> Target peer
--all Apply to all peers
--type <type> Filter by device type
--mode <mode> Tunnel mode: split | full
--force Skip confirmation for --all
Examples:
wgctl peer update-dns --all
wgctl peer update-dns --name phone-nuno
wgctl peer update-dns --name phone-nuno --fallback-dns 9.9.9.9,1.1.1.1
wgctl peer update-tunnel --all --mode split
wgctl peer update-tunnel --name phone-nuno --mode full
EOF
}
# ============================================
# Run
# ============================================
function cmd::peer::run() {
local subcmd="${1:-help}"
shift || true
case "$subcmd" in
update-dns) cmd::peer::update_dns "$@" ;;
update-tunnel) cmd::peer::update_tunnel "$@" ;;
help) cmd::peer::help ;;
*)
log::error "Unknown subcommand: '${subcmd}'"
cmd::peer::help
return 1
;;
esac
}
# ============================================
# Update DNS
# ============================================
function cmd::peer::update_dns() {
local name="" type="" all=false
local dns="" fallback_dns=""
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;;
--all) all=true; shift ;;
--dns) dns="$2"; shift 2 ;;
--fallback-dns) fallback_dns="$2"; shift 2 ;;
--help) cmd::peer::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
[[ -z "$name" && "$all" == "false" ]] && \
log::error "Specify --name or --all" && return 1
# Resolve DNS string
local primary="${dns:-$(config::dns)}"
local fallback="${fallback_dns:-$(config::dns_fallback)}"
local dns_string
if [[ -n "$fallback" ]]; then
dns_string="${primary}, ${fallback}"
else
dns_string="$primary"
fi
# Collect target peers
local peers=()
if $all; then
while IFS= read -r conf; do
peers+=("$(basename "$conf" .conf)")
done < <(find "$(ctx::clients)" -name "*.conf" 2>/dev/null)
else
name=$(peers::resolve_and_require "$name" "$type") || return 1
peers=("$name")
fi
local updated=0
for peer_name in "${peers[@]}"; do
local conf
conf="$(ctx::clients)/${peer_name}.conf"
[[ ! -f "$conf" ]] && continue
# Replace DNS line in-place
if grep -q "^DNS" "$conf"; then
sed -i "s|^DNS = .*|DNS = ${dns_string}|" "$conf"
else
# Add DNS line after Address line
sed -i "/^Address/a DNS = ${dns_string}" "$conf"
fi
(( updated++ )) || true
log::debug "Updated DNS for: ${peer_name}"
done
log::wg_success "Updated DNS to '${dns_string}' for ${updated} peer(s)"
}
# ============================================
# Update Tunnel
# ============================================
function cmd::peer::update_tunnel() {
local name="" type="" all=false mode="" force=false
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;;
--all) all=true; shift ;;
--mode) mode="$2"; shift 2 ;;
--force) force=true; shift ;;
--help) cmd::peer::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
[[ -z "$name" && "$all" == "false" ]] && \
log::error "Specify --name or --all" && return 1
[[ -z "$mode" ]] && \
log::error "Missing required flag: --mode (split|full)" && return 1
[[ "$mode" != "split" && "$mode" != "full" ]] && \
log::error "Invalid mode: ${mode} (must be split or full)" && return 1
local allowed_ips
allowed_ips=$(config::allowed_ips_for "$mode")
# Collect target peers
local peers=()
if $all; then
if ! $force; then
read -r -p "Update tunnel mode to '${mode}' for ALL peers? [y/N] " confirm
case "$confirm" in [yY]*) ;; *) log::info "Aborted"; return 0 ;; esac
fi
while IFS= read -r conf; do
peers+=("$(basename "$conf" .conf)")
done < <(find "$(ctx::clients)" -name "*.conf" 2>/dev/null)
else
name=$(peers::resolve_and_require "$name" "$type") || return 1
peers=("$name")
fi
local updated=0
for peer_name in "${peers[@]}"; do
local conf
conf="$(ctx::clients)/${peer_name}.conf"
[[ ! -f "$conf" ]] && continue
# Replace AllowedIPs line in-place
sed -i "s|^AllowedIPs = .*|AllowedIPs = ${allowed_ips}|" "$conf"
(( updated++ )) || true
log::debug "Updated tunnel for: ${peer_name}"
done
log::wg_success "Updated tunnel to '${mode}' (${allowed_ips}) for ${updated} peer(s)"
log::wg "Peers must reconnect to apply the new tunnel mode"
}

View file

@ -27,8 +27,6 @@ function cmd::policy::on_load() {
flag::register --desc flag::register --desc
flag::register --field flag::register --field
flag::register --value flag::register --value
command::mixin json_output
} }
# ============================================ # ============================================
@ -81,11 +79,6 @@ function cmd::policy::run() {
local subcmd="${1:-list}" local subcmd="${1:-list}"
shift || true shift || true
if command::json; then
cmd::policy::_output_json
return 0
fi
case "$subcmd" in case "$subcmd" in
list) cmd::policy::_list "$@" ;; list) cmd::policy::_list "$@" ;;
show) cmd::policy::_show "$@" ;; show) cmd::policy::_show "$@" ;;
@ -106,7 +99,7 @@ function cmd::policy::run() {
function cmd::policy::_list() { function cmd::policy::_list() {
local data local data
data=$(policy::list_data | ui::sort_rows 1) data=$(policy::list_data)
if [[ -z "$data" ]]; then if [[ -z "$data" ]]; then
log::info "No policies defined." log::info "No policies defined."
@ -252,27 +245,3 @@ function cmd::policy::_set() {
json::policy_set_field "$(ctx::policies)" "$name" "$field" "$value" json::policy_set_field "$(ctx::policies)" "$name" "$field" "$value"
log::ok "Policy '${name}': ${field} = ${value}" log::ok "Policy '${name}': ${field} = ${value}"
} }
function cmd::policy::_output_json() {
local data
data=$(policy::list_data 2>/dev/null)
local -a policies=()
while IFS='|' read -r name tunnel_mode default_rule strict_rule auto_apply desc; do
[[ -z "$name" ]] && continue
local strict_json="false"
[[ "$strict_rule" == "true" ]] && strict_json="true"
local auto_json="true"
[[ "$auto_apply" == "false" ]] && auto_json="false"
policies+=("$(printf '{"name":"%s","tunnel_mode":"%s","default_rule":"%s","strict_rule":%s,"auto_apply":%s,"desc":"%s"}' \
"$name" "$tunnel_mode" "${default_rule:-}" \
"$strict_json" "$auto_json" "$desc")")
done <<< "$data"
local count=${#policies[@]}
local array
array=$(printf '%s\n' "${policies[@]:-}" | paste -sd ',' -)
printf '{"policies":[%s]}' "${array:-}" | json::envelope "policy list" "$count"
}

View file

@ -31,8 +31,6 @@ function cmd::rule::on_load() {
flag::register --force flag::register --force
flag::register --type flag::register --type
flag::register --all flag::register --all
command::mixin json_output
} }
# ============================================ # ============================================
@ -48,16 +46,15 @@ Rules can extend base rules to compose reusable access policies.
Service names from 'wgctl net' can be used instead of raw IPs/ports. Service names from 'wgctl net' can be used instead of raw IPs/ports.
Subcommands: Subcommands:
list, ls List all rules list, ls List all rules
list --detailed Show inheritance tree show, inspect Show rule details and inheritance
show, inspect --name <r> Show rule details and inheritance add, new, create Create a new rule
add, new, create --name <r> Create a new rule update, edit Update a rule and re-apply to peers
update, edit --name <r> Update a rule and re-apply to peers remove, rm, del Remove a rule
remove, rm, del --name <r> Remove a rule assign Assign a rule to a peer
assign --name <r> Assign a rule to a peer unassign Remove rule from a peer
unassign --name <r> --peer <p> Remove rule from a peer reapply Re-apply rule to all assigned peers
reapply Re-apply rule to all assigned peers migrate Apply default rules to unassigned peers
migrate Apply default rules to unassigned peers
Options for list: Options for list:
--base Show only base rules --base Show only base rules
@ -118,11 +115,6 @@ function cmd::rule::run() {
local subcmd="${1:-help}" local subcmd="${1:-help}"
shift || true shift || true
if command::json; then
cmd::rule::_output_json
return 0
fi
case "$subcmd" in case "$subcmd" in
list|ls) cmd::rule::list "$@" ;; list|ls) cmd::rule::list "$@" ;;
show|inspect) cmd::rule::show "$@" ;; show|inspect) cmd::rule::show "$@" ;;
@ -320,9 +312,10 @@ function cmd::rule::show() {
local peer_count=${#peer_list[@]} local peer_count=${#peer_list[@]}
ui::empty "$peer_count" && return 0 ui::empty "$peer_count" && return 0
[[ "$peer_count" -eq 1 ]] local peer_word="peers"
printf "\n \033[0;37m── Peers (%s) \033[0m%s\n\n" \ [[ "$peer_count" -eq 1 ]] && peer_word="peer"
"$peer_count" "$(printf '\033[0;37m─%.0s' {1..30})" printf "\n \033[0;37m── Peers (%s %s) \033[0m%s\n\n" \
"$peer_count" "$peer_word" "$(printf '\033[0;37m─%.0s' {1..30})"
for peer_name in "${peer_list[@]}"; do for peer_name in "${peer_list[@]}"; do
local ip local ip
@ -558,19 +551,6 @@ function cmd::rule::assign() {
peer=$(peers::resolve_and_require "$peer" "$type") || return 1 peer=$(peers::resolve_and_require "$peer" "$type") || return 1
# Identity rule check
local peer_identity
peer_identity=$(peers::get_identity "$peer")
if [[ -n "$peer_identity" ]]; then
local identity_rules
identity_rules=$(identity::rules "$peer_identity" 2>/dev/null)
if echo "$identity_rules" | grep -qx "$name"; then
log::error "Rule '${name}' is already applied to '${peer}' via identity '${peer_identity}' — cannot assign directly"
return 1
fi
fi
local existing_rule ip local existing_rule ip
existing_rule=$(peers::get_meta "$peer" "rule") existing_rule=$(peers::get_meta "$peer" "rule")
ip=$(peers::get_ip "$peer") ip=$(peers::get_ip "$peer")
@ -679,37 +659,3 @@ function cmd::rule::reapply() {
rule::reapply_all "$name" rule::reapply_all "$name"
log::wg_success "Rule '${name}' reapplied" log::wg_success "Rule '${name}' reapplied"
} }
function cmd::rule::_output_json() {
local rules_dir
rules_dir="$(ctx::rules)"
local data
data=$(json::rule_list_data "$rules_dir" "$(ctx::meta)" 2>/dev/null)
local -a rules=()
while IFS='|' read -r name desc n_allows n_blocks peer_count extends is_base group; do
[[ -z "$name" ]] && continue
# Build extends array
local extends_json="[]"
if [[ -n "$extends" ]]; then
local ext_array
ext_array=$(echo "$extends" | tr ',' '\n' | \
while IFS= read -r e; do [[ -n "$e" ]] && printf '"%s",' "$e"; done | sed 's/,$//')
extends_json="[${ext_array}]"
fi
# Convert Python bool to JSON bool
local is_base_json="false"
[[ "$is_base" == "True" ]] && is_base_json="true"
rules+=("$(printf '{"name":"%s","desc":"%s","allows":%s,"blocks":%s,"peer_count":%s,"extends":%s,"is_base":%s,"group":"%s"}' \
"$name" "$desc" "$n_allows" "$n_blocks" "$peer_count" \
"$extends_json" "$is_base_json" "$group")")
done <<< "$data"
local count=${#rules[@]}
local array
array=$(printf '%s\n' "${rules[@]:-}" | paste -sd ',' -)
printf '{"rules":[%s]}' "${array:-}" | json::envelope "rule list" "$count"
}

View file

@ -19,7 +19,6 @@ function cmd::shell::_is_wgctl_command() {
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 net service shell help test rename keys ip net service shell help test
peer hosts identity subnet policy activity
) )
local c local c
for c in "${known[@]}"; do for c in "${known[@]}"; do
@ -83,44 +82,28 @@ function cmd::shell::_banner() {
printf "\n" printf "\n"
printf " Type wgctl commands directly (no 'wgctl' prefix).\n" printf " Type wgctl commands directly (no 'wgctl' prefix).\n"
printf " Bash commands work too: ls, cat, systemctl, vim...\n\n" printf " Bash commands work too: ls, cat, systemctl, vim...\n\n"
printf " \033[1;37mPeer management:\033[0m\n" printf " \033[1;37mCommon commands:\033[0m\n"
printf " list List all peers\n" printf " list List all peers\n"
printf " list --blocked Show blocked peers\n" printf " list --blocked Show blocked peers\n"
printf " list --rule user Filter by rule\n" printf " list --restricted Show restricted peers\n"
printf " inspect --name <peer> Full peer details\n" printf " list --rule user Filter by rule\n"
printf " add --identity <id> --type phone Add a peer\n" printf " inspect --name <peer> Full peer details\n"
printf " block --name <peer> Block a peer\n" printf " block --name <peer> Block a peer entirely\n"
printf " unblock --name <peer> Restore access\n" printf " block --name <peer> --service proxmox Restrict service\n"
printf " peer update-dns --all Update DNS on all peers\n" printf " unblock --name <peer> Restore full access\n"
printf " peer update-tunnel --name <p> --mode split\n\n" printf " rule list Show firewall rules\n"
printf " \033[1;37mRules & access:\033[0m\n" printf " rule list --tree Show with inheritance\n"
printf " rule list Show firewall rules\n" printf " rule show --name <rule> Rule details\n"
printf " rule list --tree Show with inheritance\n" printf " net list Show network services\n"
printf " rule assign --name <r> --peer <p> Assign rule to peer\n" printf " net list --detailed Show services with ports\n"
printf " identity list Show identities\n" printf " group list Show groups\n"
printf " identity show --name <id> Identity details + rule tree\n" printf " group block --name <group> Block all peers in group\n"
printf " identity rule assign --name <id> --rule <r>\n\n" printf " logs --follow Live activity log\n"
printf " \033[1;37mNetwork & services:\033[0m\n" printf " logs rotate Clean old log entries\n"
printf " net list Show network services\n" printf " watch Live WG + firewall monitor\n"
printf " net list --detailed Show with ports\n" printf " fw list Show iptables rules\n"
printf " hosts list Show host annotations\n" printf " audit Verify firewall state\n"
printf " subnet list Show subnets\n" printf " audit --fix Auto-repair firewall rules\n\n"
printf " group list Show groups\n"
printf " group block --name <group> Block all in group\n\n"
printf " \033[1;37mMonitoring:\033[0m\n"
printf " logs Activity logs\n"
printf " logs --since 2h Logs from last 2h\n"
printf " logs --fw --service pihole FW drops to service\n"
printf " logs --wg --event handshake WG handshake events\n"
printf " logs --resolved Show resolved names only\n"
printf " logs --follow Live activity log\n"
printf " logs clean Remove keepalive entries\n"
printf " logs rotate Clean old log entries\n"
printf " watch Live WG + firewall monitor\n"
printf " activity Transfer + drop summary\n"
printf " fw list Show iptables rules\n"
printf " audit Verify firewall state\n"
printf " audit --fix Auto-repair firewall\n\n"
printf " \033[1mexit\033[0m or \033[1mquit\033[0m to leave · \033[1mhelp\033[0m for full command list\n\n" printf " \033[1mexit\033[0m or \033[1mquit\033[0m to leave · \033[1mhelp\033[0m for full command list\n\n"
} }
@ -160,9 +143,7 @@ EOF
# ============================================ # ============================================
function cmd::shell::_setup_completion() { function cmd::shell::_setup_completion() {
local commands="list add remove rm inspect block unblock rule group audit \ local commands="list add remove rm inspect block unblock rule group audit logs watch fw config qr rename service shell help test"
logs watch fw config qr rename service shell help test \
peer hosts identity subnet policy activity"
function _wgctl_shell_complete() { function _wgctl_shell_complete() {
local cur="${COMP_WORDS[COMP_CWORD]}" local cur="${COMP_WORDS[COMP_CWORD]}"

View file

@ -22,8 +22,6 @@ function cmd::subnet::on_load() {
flag::register --desc flag::register --desc
flag::register --group flag::register --group
flag::register --new-name flag::register --new-name
command::mixin json_output
} }
# ============================================ # ============================================
@ -67,11 +65,6 @@ function cmd::subnet::run() {
local subcmd="${1:-list}" local subcmd="${1:-list}"
shift || true shift || true
if command::json; then
cmd::subnet::_output_json
return 0
fi
case "$subcmd" in case "$subcmd" in
list) cmd::subnet::_list "$@" ;; list) cmd::subnet::_list "$@" ;;
show) cmd::subnet::_show "$@" ;; show) cmd::subnet::_show "$@" ;;
@ -92,7 +85,7 @@ function cmd::subnet::run() {
function cmd::subnet::_list() { function cmd::subnet::_list() {
local data local data
data=$(subnet::list_data | ui::sort_rows 1) data=$(subnet::list_data)
if [[ -z "$data" ]]; then if [[ -z "$data" ]]; then
log::info "No subnets defined." log::info "No subnets defined."
@ -299,25 +292,3 @@ function cmd::subnet::_validate_cidr() {
return 1 return 1
fi fi
} }
function cmd::subnet::_output_json() {
local data
data=$(subnet::list_data 2>/dev/null)
local -a subnets=()
while IFS='|' read -r type cidr display_name tunnel_mode desc is_group group_parent; do
[[ -z "$type" ]] && continue
local is_group_json="false"
[[ "$is_group" == "true" ]] && is_group_json="true"
subnets+=("$(printf '{"type":"%s","cidr":"%s","display_name":"%s","tunnel_mode":"%s","desc":"%s","is_group":%s,"group_parent":"%s"}' \
"$type" "$cidr" "$display_name" "$tunnel_mode" \
"$desc" "$is_group_json" "${group_parent:-}")")
done <<< "$data"
local count=${#subnets[@]}
local array
array=$(printf '%s\n' "${subnets[@]:-}" | paste -sd ',' -)
printf '{"subnets":[%s]}' "${array:-}" | json::envelope "subnet list" "$count"
}

View file

@ -22,8 +22,6 @@ function cmd::test::section_destructive() {
cmd::test::_destructive_groups cmd::test::_destructive_groups
cmd::test::_destructive_identity cmd::test::_destructive_identity
cmd::test::_destructive_cleanup cmd::test::_destructive_cleanup
cmd::test::_destructive_rule_duplicate
cmd::test::_destructive_peer_dns
} }
function cmd::test::_destructive_peer() { function cmd::test::_destructive_peer() {
@ -123,67 +121,6 @@ function cmd::test::_destructive_identity() {
identity show --name testunit2b identity show --name testunit2b
} }
function cmd::test::_destructive_rule_duplicate() {
# Cleanup from any previous failed run
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
"$WGCTL_BINARY" identity rule unassign --name testunit --all > /dev/null 2>&1 || true
"$WGCTL_BINARY" add --name testunit --type phone > /dev/null 2>&1
# Assign admin to identity
"$WGCTL_BINARY" identity rule assign --name testunit --rule admin > /dev/null 2>&1 || true
# Try to assign admin directly — should fail (identity has it)
cmd::test::run_cmd_fails "rule assign blocked by identity rule" \
rule assign --name admin --peer phone-testunit
# Remove admin from identity first so we can assign user directly to peer
"$WGCTL_BINARY" identity rule unassign --name testunit --rule admin > /dev/null 2>&1 || true
# Assign user directly to peer
"$WGCTL_BINARY" rule assign --name user --peer phone-testunit > /dev/null 2>&1 || true
# Now assign user to identity with --migrate — should remove from peer
cmd::test::run_cmd "identity rule assign --migrate" "Migrated" \
identity rule assign --name testunit --rule user --migrate
# Cleanup
"$WGCTL_BINARY" identity rule unassign --name testunit --all > /dev/null 2>&1 || true
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
}
function cmd::test::_destructive_peer_dns() {
test::section "Destructive: peer update-dns"
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
"$WGCTL_BINARY" add --name testunit --type phone > /dev/null 2>&1
# Update DNS and verify it's in the conf
cmd::test::run_cmd "peer update-dns single peer" "Updated DNS" \
peer update-dns --name phone-testunit --fallback-dns "9.9.9.9"
local conf_dns
conf_dns=$(grep "^DNS" /etc/wireguard/clients/phone-testunit.conf 2>/dev/null)
[[ "$conf_dns" == *"9.9.9.9"* ]] && \
test::pass "DNS line contains fallback" || \
test::fail "DNS line missing fallback (got: $conf_dns)"
# Update tunnel mode
cmd::test::run_cmd "peer update-tunnel to full" "Updated" \
peer update-tunnel --name phone-testunit --mode full
local conf_allowed
conf_allowed=$(grep "^AllowedIPs" /etc/wireguard/clients/phone-testunit.conf 2>/dev/null)
[[ "$conf_allowed" == *"0.0.0.0/0"* ]] && \
test::pass "AllowedIPs set to full tunnel" || \
test::fail "AllowedIPs not full tunnel (got: $conf_allowed)"
cmd::test::run_cmd "peer update-tunnel to split" "Updated" \
peer update-tunnel --name phone-testunit --mode split
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
}
function cmd::test::_destructive_cleanup() { function cmd::test::_destructive_cleanup() {
cmd::test::run_cmd "remove phone peer" "removed" \ cmd::test::run_cmd "remove phone peer" "removed" \
remove --name phone-testunit --force remove --name phone-testunit --force

View file

@ -132,12 +132,6 @@ function cmd::test::run_all_integration_sections() {
cmd::test::section_net cmd::test::section_net
cmd::test::section_subnet cmd::test::section_subnet
cmd::test::section_identity cmd::test::section_identity
cmd::test::section_activity
cmd::test::section_policy
cmd::test::section_hosts
cmd::test::section_peer_cmd
cmd::test::section_group_purge
cmd::test::section_logs_clean
} }
function cmd::test::section_list() { function cmd::test::section_list() {
@ -149,12 +143,6 @@ function cmd::test::section_list() {
cmd::test::run_cmd "list --type phone" "phone" list --type phone cmd::test::run_cmd "list --type phone" "phone" list --type phone
cmd::test::run_cmd "list --detailed" "rule:" list --detailed cmd::test::run_cmd "list --detailed" "rule:" list --detailed
cmd::test::run_cmd "list --name phone-nuno" "phone-nuno" list --name phone-nuno cmd::test::run_cmd "list --name phone-nuno" "phone-nuno" list --name phone-nuno
cmd::test::run_cmd "list --json" '"ok":true' list --json
cmd::test::run_cmd "list --json has peers" '"peers":' list --json
cmd::test::run_cmd "list --json has meta" '"meta":' list --json
cmd::test::run_cmd "list --json peer name" '"name":' list --json
cmd::test::run_cmd "list --json peer ip" '"ip":' list --json
cmd::test::run_cmd "list --json peer status" '"status":' list --json
} }
function cmd::test::section_inspect() { function cmd::test::section_inspect() {
@ -163,10 +151,6 @@ function cmd::test::section_inspect() {
cmd::test::run_cmd "inspect --name nuno --type phone" "IP:" inspect --name nuno --type phone cmd::test::run_cmd "inspect --name nuno --type phone" "IP:" inspect --name nuno --type phone
cmd::test::run_cmd "inspect --name phone-nuno --config" "PrivateKey" inspect --name phone-nuno --config cmd::test::run_cmd "inspect --name phone-nuno --config" "PrivateKey" inspect --name phone-nuno --config
cmd::test::run_cmd_fails "inspect nonexistent" inspect --name nonexistent-peer cmd::test::run_cmd_fails "inspect nonexistent" inspect --name nonexistent-peer
cmd::test::run_cmd "inspect --json" '"ok":true' inspect --name phone-nuno --json
cmd::test::run_cmd "inspect --json rule" '"rule":' inspect --name phone-nuno --json
cmd::test::run_cmd "inspect --json identity" '"identity":' inspect --name phone-nuno --json
cmd::test::run_cmd "inspect --json groups" '"groups":' inspect --name phone-nuno --json
} }
function cmd::test::section_config() { function cmd::test::section_config() {
@ -182,22 +166,14 @@ function cmd::test::section_rules() {
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 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 "rule list --json" '"rules":' rule list --json
cmd::test::run_cmd "rule list --json" '"rules":' rule list --json
cmd::test::run_cmd "rule --json is_base" '"is_base":' rule list --json
cmd::test::run_cmd "rule --json extends" '"extends":' rule list --json
cmd::test::run_cmd "rule --json allows" '"allows":' rule list --json
cmd::test::run_cmd_fails "rule show nonexistent" rule show --name nonexistent cmd::test::run_cmd_fails "rule show nonexistent" rule show --name nonexistent
} }
function cmd::test::section_groups() { function cmd::test::section_groups() {
test::section "Groups" test::section "Groups"
cmd::test::run_cmd "group list" "Groups" group list cmd::test::run_cmd "group list" "Groups" group list
cmd::test::run_cmd "group show --name family" "Peers:" group show --name family cmd::test::run_cmd "group show --name family" "Peers:" group show --name family
cmd::test::run_cmd "group list --json" '"groups":' group list --json cmd::test::run_cmd_fails "group show nonexistent" group show --name nonexistent
cmd::test::run_cmd "group list --json" '"groups":' group list --json
cmd::test::run_cmd "group --json peer_count" '"peer_count":' group list --json
cmd::test::run_cmd_fails "group show nonexistent" group show --name nonexistent
} }
function cmd::test::section_audit() { function cmd::test::section_audit() {
@ -211,17 +187,8 @@ function cmd::test::section_logs() {
test::section "Logs" test::section "Logs"
cmd::test::run_cmd "logs" "Activity" logs cmd::test::run_cmd "logs" "Activity" logs
cmd::test::run_cmd "logs --name phone-nuno" "Activity" logs --name phone-nuno cmd::test::run_cmd "logs --name phone-nuno" "Activity" logs --name phone-nuno
cmd::test::run_cmd "logs --fw" "Firewall Drops" logs --fw cmd::test::run_cmd "logs --fw" "Activity" logs --fw
cmd::test::run_cmd "logs --wg" "WireGuard Events" logs --wg cmd::test::run_cmd "logs --wg" "Activity" logs --wg
cmd::test::run_cmd "logs --since 2099-01-01" "No logs" logs --since "2099-01-01"
cmd::test::run_cmd "logs --wg --since 2099-01-01" "No logs" logs --wg --since "2099-01-01"
cmd::test::run_cmd "logs --fw --since 2099-01-01" "No logs" logs --fw --since "2099-01-01"
cmd::test::run_cmd "logs --wg --event attempt" "" logs --wg --event attempt
cmd::test::run_cmd "logs --detailed" "" logs --detailed
cmd::test::run_cmd "logs --resolved" "" logs --resolved
cmd::test::run_cmd "logs --ascending" "" logs --ascending
cmd::test::run_cmd "logs --descending" "" logs --descending
cmd::test::run_cmd "logs --wg --ascending" "" logs --wg --ascending
} }
function cmd::test::section_fw() { function cmd::test::section_fw() {
@ -239,20 +206,17 @@ function cmd::test::section_net() {
test::section "Net" test::section "Net"
"$WGCTL_BINARY" net rm --name test-svc --force > /dev/null 2>&1 || true "$WGCTL_BINARY" net rm --name test-svc --force > /dev/null 2>&1 || true
cmd::test::run_cmd "net add service" "added" net add --name test-svc --ip 10.0.0.99 --desc "Test service" cmd::test::run_cmd "net add service" "added" net add --name test-svc --ip 10.0.0.99 --desc "Test service"
cmd::test::run_cmd "net add port" "Added" net add --name test-svc:web --port 9999:tcp cmd::test::run_cmd "net add port" "Added" net add --name test-svc:web --port 9999:tcp
cmd::test::run_cmd "net list" "test-svc" net list cmd::test::run_cmd "net list" "test-svc" net list
cmd::test::run_cmd "net list --detailed" "web" net list --detailed cmd::test::run_cmd "net list --detailed" "web" net list --detailed
cmd::test::run_cmd "net show" "9999" net show --name test-svc cmd::test::run_cmd "net show" "9999" net show --name test-svc
cmd::test::run_cmd "net rm port" "Removed" net rm --name test-svc:web --force cmd::test::run_cmd "net rm port" "Removed" net rm --name test-svc:web --force
cmd::test::run_cmd "net add port again" "Added" net add --name test-svc:web --port 9999:tcp cmd::test::run_cmd "net add port again" "Added" net add --name test-svc:web --port 9999:tcp
cmd::test::run_cmd "net rm all ports" "Removed" net rm --name test-svc:ports --force cmd::test::run_cmd "net rm all ports" "Removed" net rm --name test-svc:ports --force
cmd::test::run_cmd "net rm service" "Removed" net rm --name test-svc --force cmd::test::run_cmd "net rm service" "Removed" net rm --name test-svc --force
cmd::test::run_cmd "net list --json" '"services":' net list --json cmd::test::run_cmd_fails "net show nonexistent" net show --name nonexistent-svc
cmd::test::run_cmd "net --json has tags" '"tags":' net list --json cmd::test::run_cmd_fails "net add port no service" net add --name nonexistent:web --port 80:tcp
cmd::test::run_cmd "net --json port_count" '"port_count":' net list --json
cmd::test::run_cmd_fails "net show nonexistent" net show --name nonexistent-svc
cmd::test::run_cmd_fails "net add port no service" net add --name nonexistent:web --port 80:tcp
} }
function cmd::test::section_subnet() { function cmd::test::section_subnet() {
@ -260,110 +224,29 @@ function cmd::test::section_subnet() {
"$WGCTL_BINARY" subnet rm --name test-subnet-2 > /dev/null 2>&1 || true "$WGCTL_BINARY" subnet rm --name test-subnet-2 > /dev/null 2>&1 || true
"$WGCTL_BINARY" subnet rm --name test-subnet > /dev/null 2>&1 || true "$WGCTL_BINARY" subnet rm --name test-subnet > /dev/null 2>&1 || true
cmd::test::run_cmd "subnet list" "desktop" subnet list cmd::test::run_cmd "subnet list" "desktop" subnet list
cmd::test::run_cmd "subnet show desktop" "tunnel:" subnet show --name desktop cmd::test::run_cmd "subnet show desktop" "tunnel:" subnet show --name desktop
cmd::test::run_cmd "subnet show guests group" "guests" subnet show --name guests cmd::test::run_cmd "subnet show guests group" "guests" subnet show --name guests
cmd::test::run_cmd_fails "subnet show nonexistent" subnet show --name nonexistent cmd::test::run_cmd_fails "subnet show nonexistent" subnet show --name nonexistent
cmd::test::run_cmd "subnet add" "added" subnet add --name test-subnet --subnet 10.1.250.0/24 --type iot --desc "Test"
cmd::test::run_cmd "subnet list shows new" "test-subnet" subnet list cmd::test::run_cmd "subnet add" "added" \
cmd::test::run_cmd_fails "subnet rename in-use (desktop)" subnet rename --name desktop --new-name workstation subnet add --name test-subnet --subnet 10.1.250.0/24 --type iot --desc "Test"
cmd::test::run_cmd "subnet rename unused" "renamed" subnet rename --name test-subnet --new-name test-subnet-2 cmd::test::run_cmd "subnet list shows new" "test-subnet" \
cmd::test::run_cmd "subnet rm" "removed" subnet rm --name test-subnet-2 subnet list
cmd::test::run_cmd "subnet list --json" '"subnets":' subnet list --json cmd::test::run_cmd_fails "subnet rename in-use (desktop)" \
cmd::test::run_cmd "subnet --json has cidr" '"cidr":' subnet list --json subnet rename --name desktop --new-name workstation
cmd::test::run_cmd "subnet --json is_group" '"is_group":' subnet list --json cmd::test::run_cmd "subnet rename unused" "renamed" \
cmd::test::run_cmd_fails "subnet rm nonexistent" subnet rm --name nonexistent-subnet subnet rename --name test-subnet --new-name test-subnet-2
cmd::test::run_cmd "subnet rm" "removed" \
subnet rm --name test-subnet-2
cmd::test::run_cmd_fails "subnet rm nonexistent" \
subnet rm --name nonexistent-subnet
} }
function cmd::test::section_identity() { function cmd::test::section_identity() {
test::section "Identity" test::section "Identity"
cmd::test::run_cmd "identity list" "" identity list cmd::test::run_cmd "identity list" "" identity list
cmd::test::run_cmd "identity migrate --dry-run" "Dry run" identity migrate --dry-run cmd::test::run_cmd "identity migrate --dry-run" "Dry run" identity migrate --dry-run
cmd::test::run_cmd "identity show nuno" "nuno" identity show --name nuno cmd::test::run_cmd "identity show nuno" "nuno" identity show --name nuno
cmd::test::run_cmd "identity list --json" '"identities":' identity list --json cmd::test::run_cmd_fails "identity show nonexistent" identity show --name nonexistent
cmd::test::run_cmd "identity --json types array" '"types":[' identity list --json
cmd::test::run_cmd "identity --json rules array" '"rules":[' identity list --json
cmd::test::run_cmd_fails "identity show nonexistent" identity show --name nonexistent
}
function cmd::test::section_activity() {
test::section "Activity"
cmd::test::run_cmd "activity" "Activity" activity
cmd::test::run_cmd "activity --json" '"peers":' activity --json
cmd::test::run_cmd "activity --json services" '"services":' activity --json
cmd::test::run_cmd "activity --json rx" '"rx":' activity --json
}
function cmd::test::section_policy() {
test::section "Policy"
cmd::test::run_cmd "policy list --json" '"policies":' policy list --json
cmd::test::run_cmd "policy --json has tunnel_mode" '"tunnel_mode":' policy list --json
cmd::test::run_cmd "policy --json strict_rule bool" '"strict_rule":false' policy list --json
}
function cmd::test::section_hosts() {
test::section "Hosts"
# Cleanup
"$WGCTL_BINARY" hosts rm --ip 192.0.2.1 --force > /dev/null 2>&1 || true
"$WGCTL_BINARY" hosts rm --port 9999 --force > /dev/null 2>&1 || true
cmd::test::run_cmd "hosts list" "Hosts" hosts list
cmd::test::run_cmd "hosts add --ip" "Added" hosts add --ip 192.0.2.1 --name test-host --desc "Test" --tags test,unit
cmd::test::run_cmd "hosts list shows new" "test-host" hosts list
cmd::test::run_cmd "hosts show --ip" "Name" hosts show --ip 192.0.2.1
cmd::test::run_cmd "hosts add --port" "Added" hosts add --port 9999 --name test-port
cmd::test::run_cmd "hosts list shows port" "test-port" hosts list
cmd::test::run_cmd "hosts rm --ip" "Removed" hosts rm --ip 192.0.2.1 --force
cmd::test::run_cmd "hosts rm --port" "Removed" hosts rm --port 9999 --force
cmd::test::run_cmd "hosts list --json" '"hosts":' hosts list --json
cmd::test::run_cmd "hosts --json has type" '"type":' hosts list --json
cmd::test::run_cmd "hosts --json has tags" '"tags":' hosts list --json
cmd::test::run_cmd_fails "hosts show nonexistent" hosts show --ip 192.0.2.99
cmd::test::run_cmd_fails "hosts add missing --name" hosts add --ip 192.0.2.1
}
function cmd::test::section_peer_cmd() {
test::section "Peer command"
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
"$WGCTL_BINARY" add --name testunit --type phone > /dev/null 2>&1
# update-dns
cmd::test::run_cmd "peer update-dns --name" "Updated DNS" peer update-dns --name phone-testunit
cmd::test::run_cmd "peer update-dns applies" "10.0.0.103" config --name phone-testunit
cmd::test::run_cmd "peer update-dns --all" "peer(s)" peer update-dns --all
# update-tunnel
cmd::test::run_cmd "peer update-tunnel split" "split" peer update-tunnel --name phone-testunit --mode split
cmd::test::run_cmd "peer update-tunnel full" "Updated" peer update-tunnel --name phone-testunit --mode full
cmd::test::run_cmd_fails "peer update-tunnel bad mode" peer update-tunnel --name phone-testunit --mode invalid
cmd::test::run_cmd_fails "peer update-tunnel missing --mode" peer update-tunnel --name phone-testunit
cmd::test::run_cmd_fails "peer update-tunnel missing --name" peer update-tunnel --mode split
# Restore split tunnel
"$WGCTL_BINARY" peer update-tunnel --name phone-testunit --mode split > /dev/null 2>&1 || true
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
}
function cmd::test::section_group_purge() {
test::section "Group: purge-stale"
# dry-run should not modify anything
cmd::test::run_cmd "purge-stale --all --dry-run" \
"[dry-run]" \
group purge-stale --all --dry-run --force
# single group dry-run
cmd::test::run_cmd "purge-stale --name family --dry-run" \
"[dry-run]" \
group purge-stale --name family --dry-run --force
}
function cmd::test::section_logs_clean() {
test::section "Logs: clean"
cmd::test::run_cmd "logs clean --force" \
"keepalive" \
logs clean --force
} }

View file

@ -44,11 +44,6 @@ function cmd::test::run_all_unit_sections() {
cmd::test::unit_subnet cmd::test::unit_subnet
cmd::test::unit_ip cmd::test::unit_ip
cmd::test::unit_identity cmd::test::unit_identity
cmd::test::unit_fmt
cmd::test::unit_config
cmd::test::unit_parse_since
cmd::test::unit_group_status
cmd::test::unit_json_output
} }
function cmd::test::unit_subnet() { function cmd::test::unit_subnet() {
@ -108,147 +103,3 @@ function cmd::test::unit_identity() {
cmd::test::assert "infer no convention" "$(identity::infer 'roboclean')" "" cmd::test::assert "infer no convention" "$(identity::infer 'roboclean')" ""
cmd::test::assert "infer guest-zephyr" "$(identity::infer 'guest-zephyr')" "" cmd::test::assert "infer guest-zephyr" "$(identity::infer 'guest-zephyr')" ""
} }
function cmd::test::unit_fmt() {
test::section "Unit: fmt::bytes"
cmd::test::assert "fmt::bytes 0" "$(fmt::bytes 0)" "—"
cmd::test::assert "fmt::bytes bytes" "$(fmt::bytes 512)" "512B"
cmd::test::assert "fmt::bytes KB" "$(fmt::bytes 2048)" "2KB"
cmd::test::assert "fmt::bytes MB" "$(fmt::bytes 2097152)" "2MB"
cmd::test::assert "fmt::bytes GB" "$(fmt::bytes 2147483648)" "2GB"
cmd::test::assert "fmt::bytes 1023" "$(fmt::bytes 1023)" "1023B"
cmd::test::assert "fmt::bytes 1024" "$(fmt::bytes 1024)" "1KB"
}
function cmd::test::unit_config() {
test::section "Unit: config dns"
# config::dns_string with no fallback
local orig_fallback="${_WG_DNS_FALLBACK:-}"
_WG_DNS_FALLBACK=""
cmd::test::assert "dns_string no fallback" \
"$(config::dns_string)" "$(config::dns)"
# config::dns_string with fallback
_WG_DNS_FALLBACK="9.9.9.9"
cmd::test::assert "dns_string with fallback" \
"$(config::dns_string)" "$(config::dns), 9.9.9.9"
# config::dns_string with multiple fallbacks
_WG_DNS_FALLBACK="9.9.9.9,1.1.1.1"
cmd::test::assert "dns_string multi fallback" \
"$(config::dns_string)" "$(config::dns), 9.9.9.9,1.1.1.1"
# Restore
_WG_DNS_FALLBACK="$orig_fallback"
}
function cmd::test::unit_parse_since() {
test::section "Unit: parse_since (Python)"
# Test via Python directly
local py_result
# Relative formats
py_result=$(python3 -c "
import sys
sys.path.insert(0, '/etc/wireguard/wgctl/core/lib')
from util import parse_since
from datetime import datetime, timezone, timedelta
dt = parse_since('2h')
now = datetime.now(timezone.utc)
diff = abs((now - dt).total_seconds() - 7200)
print('ok' if diff < 5 else f'fail: {diff}')
")
cmd::test::assert "parse_since 2h" "$py_result" "ok"
py_result=$(python3 -c "
import sys
sys.path.insert(0, '/etc/wireguard/wgctl/core/lib')
from util import parse_since
dt = parse_since('7d')
print('ok' if dt is not None else 'fail')
")
cmd::test::assert "parse_since 7d" "$py_result" "ok"
# EU date format
py_result=$(python3 -c "
import sys
sys.path.insert(0, '/etc/wireguard/wgctl/core/lib')
from util import parse_since
from datetime import datetime
dt = parse_since('23/05')
print('ok' if dt is not None and dt.day == 23 and dt.month == 5 else f'fail: {dt}')
")
cmd::test::assert "parse_since 23/05" "$py_result" "ok"
# ISO format
py_result=$(python3 -c "
import sys
sys.path.insert(0, '/etc/wireguard/wgctl/core/lib')
from util import parse_since
dt = parse_since('2026-05-23')
print('ok' if dt is not None and dt.year == 2026 and dt.month == 5 and dt.day == 23 else f'fail: {dt}')
")
cmd::test::assert "parse_since ISO" "$py_result" "ok"
# Invalid
py_result=$(python3 -c "
import sys
sys.path.insert(0, '/etc/wireguard/wgctl/core/lib')
from util import parse_since
dt = parse_since('not-a-date')
print('ok' if dt is None else f'fail: {dt}')
")
cmd::test::assert "parse_since invalid" "$py_result" "ok"
}
function cmd::test::unit_group_status() {
test::section "Unit: ui::group::status"
load_module ui
local color str
IFS='|' read -r color str <<< "$(ui::group::status 0 0)"
cmd::test::assert "group status inactive str" "$str" "inactive"
IFS='|' read -r color str <<< "$(ui::group::status 4 0)"
cmd::test::assert "group status active str" "$str" "active"
IFS='|' read -r color str <<< "$(ui::group::status 4 4)"
cmd::test::assert "group status blocked str" "$str" "blocked"
IFS='|' read -r color str <<< "$(ui::group::status 4 2)"
cmd::test::assert "group status partial str" "$str" "partial (2/4)"
}
function cmd::test::unit_json_output() {
test::section "Unit: JSON output"
# json::envelope produces valid structure
local result
result=$(echo '{"peers":[]}' | json::envelope "list" "0")
cmd::test::assert "envelope ok field" "$(echo "$result" | grep -o '"ok":true')" '"ok":true'
cmd::test::assert "envelope command field" "$(echo "$result" | grep -o '"command":"list"')" '"command":"list"'
cmd::test::assert "envelope meta field" "$(echo "$result" | grep -o '"meta":')" '"meta":'
cmd::test::assert "envelope count field" "$(echo "$result" | grep -o '"count":0')" '"count":0'
# command::mixin registration
load_command list
cmd::test::assert_true "json_output mixin registered" "declare -f command::mixin::json_output::register >/dev/null 2>&1"
cmd::test::assert_true "command::json accessor exists" "declare -f command::json >/dev/null 2>&1"
# json::error_envelope
local err_result
err_result=$(json::error_envelope "inspect" "Peer not found")
cmd::test::assert "error envelope ok false" \
"$(echo "$err_result" | grep -o '"ok":false')" '"ok":false'
cmd::test::assert "error envelope error field" \
"$(echo "$err_result" | grep -o '"error":')" '"error":'
# command::json mixin accessor
_COMMAND_JSON=true
cmd::test::assert_true "command::json true" "command::json"
_COMMAND_JSON=false
cmd::test::assert_false "command::json false" "command::json"
}

View file

@ -33,7 +33,7 @@ Options:
--blocked Show only blocked peer attempts --blocked Show only blocked peer attempts
--allowed Show only handshakes --allowed Show only handshakes
--restricted Show only firewall drop events --restricted Show only firewall drop events
--raw Show raw IPs without host/service resolution --raw Show raw IPs without service annotation
Examples: Examples:
wgctl watch wgctl watch
@ -62,7 +62,7 @@ function cmd::watch::run() {
--blocked) blocked_only=true; shift ;; --blocked) blocked_only=true; shift ;;
--allowed) allowed_only=true; shift ;; --allowed) allowed_only=true; shift ;;
--restricted) restricted_only=true; shift ;; --restricted) restricted_only=true; shift ;;
--raw) _WGCTL_RAW=true; shift ;; --raw) raw=true; shift ;;
--help) cmd::watch::help; return ;; --help) cmd::watch::help; return ;;
*) *)
log::error "Unknown flag: $1" log::error "Unknown flag: $1"
@ -72,11 +72,38 @@ function cmd::watch::run() {
esac esac
done done
local net_file=""
$raw || net_file="$(ctx::net)"
log::section "wgctl — Live Monitor (Ctrl+C to stop)" log::section "wgctl — Live Monitor (Ctrl+C to stop)"
printf "\n" printf "\n"
monitor::live "$filter_name" "$filter_type" "$filter_peers" \ # Fixed display widths for watch (dynamic measurement not possible in stream)
"$blocked_only" "$restricted_only" "$allowed_only" "$raw" local w_client=20 w_dest=18
# Handshake poller (background)
if ! $blocked_only && ! $restricted_only; then
(
while true; do
cmd::watch::_poll_handshakes \
"$filter_name" "$filter_type" "$filter_peers" "$w_client" "$w_dest"
sleep 5
done
) &
local poller_pid=$!
fi
# Event tailer (background)
cmd::watch::_tail_events \
"$filter_name" "$filter_type" "$filter_peers" \
"$blocked_only" "$restricted_only" "$allowed_only" \
"$net_file" "$w_client" "$w_dest" &
local tailer_pid=$!
trap "kill $tailer_pid ${poller_pid:-} 2>/dev/null; \
rm -f /tmp/wgctl_hs_* /tmp/wgctl_attempt_*; printf '\n'; exit 0" INT TERM
wait
} }
# ============================================ # ============================================
@ -85,10 +112,10 @@ function cmd::watch::run() {
function cmd::watch::_poll_handshakes() { function cmd::watch::_poll_handshakes() {
local filter_name="${1:-}" filter_type="${2:-}" filter_peers="${3:-}" local filter_name="${1:-}" filter_type="${2:-}" filter_peers="${3:-}"
local w_client="${4:-20}" w_dest="${5:-18}" local w_client="${4:-20}" w_dest="${5:-30}"
# Collect rows with sort key before printing local peer_set=()
local -a rows=() [[ -n "$filter_peers" ]] && IFS=',' read -ra peer_set <<< "$filter_peers"
while IFS= read -r line; do while IFS= read -r line; do
local public_key ts local public_key ts
@ -96,6 +123,7 @@ function cmd::watch::_poll_handshakes() {
ts=$(echo "$line" | awk '{print $2}') ts=$(echo "$line" | awk '{print $2}')
[[ -z "$ts" || "$ts" == "0" ]] && continue [[ -z "$ts" || "$ts" == "0" ]] && continue
# Find client by public key
local client_name="" local client_name=""
for conf in "$(ctx::clients)"/*.conf; do for conf in "$(ctx::clients)"/*.conf; do
[[ -f "$conf" ]] || continue [[ -f "$conf" ]] || continue
@ -111,48 +139,26 @@ function cmd::watch::_poll_handshakes() {
[[ -z "$client_name" ]] && continue [[ -z "$client_name" ]] && continue
[[ -n "$filter_name" && "$client_name" != "$filter_name" ]] && continue [[ -n "$filter_name" && "$client_name" != "$filter_name" ]] && continue
# Dedup — only emit if handshake is new
local safe_key local safe_key
safe_key=$(echo "$public_key" | md5sum | cut -d' ' -f1) safe_key=$(echo "$public_key" | md5sum | cut -d' ' -f1)
local prev_ts_file="/tmp/wgctl_hs_${safe_key}" local prev_ts_file="/tmp/wgctl_hs_${safe_key}"
local prev_ts="0" local prev_ts="0"
[[ -f "$prev_ts_file" ]] && prev_ts=$(cat "$prev_ts_file") [[ -f "$prev_ts_file" ]] && prev_ts=$(cat "$prev_ts_file")
[[ "$ts" == "$prev_ts" ]] && continue [[ "$ts" == "$prev_ts" ]] && continue
local gap=$(( ts - ${prev_ts:-0} ))
echo "$ts" > "$prev_ts_file" echo "$ts" > "$prev_ts_file"
(( gap < ${WG_HANDSHAKE_CHECK_TIME_SEC:-300} )) && continue
local ts_fmt local ts_fmt
ts_fmt=$(fmt::datetime_short "$ts") ts_fmt=$(fmt::datetime_short "$ts")
# Resolve endpoint — try wg show first, fall back to endpoint cache
local endpoint local endpoint
endpoint=$(monitor::endpoint_for_key "$public_key") endpoint=$(monitor::endpoint_for_key "$public_key")
if [[ -z "$endpoint" ]]; then ui::watch::wg_row "$ts_fmt" "$client_name" "${endpoint:-}" "handshake" \
endpoint=$(monitor::get_cached_endpoint "$client_name") "$w_client" "$w_dest"
fi
local ep_raw ep_resolved=""
ep_raw="${endpoint:-}"
if [[ -n "$ep_raw" ]]; then
local ep_parts
ep_parts=$(resolve::endpoint_parts "$ep_raw")
ep_resolved="${ep_parts#*|}"
fi
row=$(ui::watch::wg_row "$ts_fmt" "$client_name" "$ep_raw" "handshake" \
"$ep_resolved" "$w_client" "30")
rows+=("${ts}|${row}")
done < <(wg show "$(config::interface)" latest-handshakes 2>/dev/null) done < <(wg show "$(config::interface)" latest-handshakes 2>/dev/null)
# Sort by ts descending (most recent first) and print
if [[ ${#rows[@]} -gt 0 ]]; then
printf '%s\n' "${rows[@]}" | sort -t'|' -k1,1rn | while IFS= read -r entry; do
printf "%s\n" "${entry#*|}"
done
fi
} }
# ============================================ # ============================================
# Event Tailer # Event Tailer
# ============================================ # ============================================
@ -160,7 +166,10 @@ function cmd::watch::_poll_handshakes() {
function cmd::watch::_tail_events() { function cmd::watch::_tail_events() {
local filter_name="${1:-}" filter_type="${2:-}" filter_peers="${3:-}" local filter_name="${1:-}" filter_type="${2:-}" filter_peers="${3:-}"
local blocked_only="${4:-false}" restricted_only="${5:-false}" allowed_only="${6:-false}" local blocked_only="${4:-false}" restricted_only="${5:-false}" allowed_only="${6:-false}"
local w_client="${7:-20}" w_dest="${8:-18}" local net_file="${7:-}" w_client="${8:-20}" w_dest="${9:-30}"
local peer_set=()
[[ -n "$filter_peers" ]] && IFS=',' read -ra peer_set <<< "$filter_peers"
# Build ip->name map # Build ip->name map
declare -A ip_to_name=() declare -A ip_to_name=()
@ -172,6 +181,18 @@ function cmd::watch::_tail_events() {
[[ -n "$ip" && -n "$cname" ]] && ip_to_name["$ip"]="$cname" [[ -n "$ip" && -n "$cname" ]] && ip_to_name["$ip"]="$cname"
done < <(find "$(ctx::clients)" -name "*.conf" 2>/dev/null) done < <(find "$(ctx::clients)" -name "*.conf" 2>/dev/null)
# Load net services if not --raw
declare -A _svc_cache=()
function _resolve_dest() {
local dest_ip="${1:-}" dest_port="${2:-}" proto="${3:-}"
[[ -z "$net_file" || ! -f "$net_file" ]] && echo "" && return
local key="${dest_ip}:${dest_port}:${proto}"
if [[ -z "${_svc_cache[$key]+x}" ]]; then
_svc_cache[$key]=$(net::reverse_lookup "$dest_ip" "$dest_port" "$proto" 2>/dev/null || true)
fi
echo "${_svc_cache[$key]:-}"
}
declare -A _WATCH_LAST_FW=() declare -A _WATCH_LAST_FW=()
declare -A _WATCH_LAST_WG=() declare -A _WATCH_LAST_WG=()
@ -206,6 +227,7 @@ function cmd::watch::_tail_events() {
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
# Dedup
local fw_key="${src_ip}:${dest_ip}:${dest_port}:${proto}" local fw_key="${src_ip}:${dest_ip}:${dest_port}:${proto}"
local now; now=$(date +%s) local now; now=$(date +%s)
local window=30 local window=30
@ -218,18 +240,11 @@ function cmd::watch::_tail_events() {
local ts_fmt local ts_fmt
ts_fmt=$(fmt::datetime_short "$(json::iso_to_ts "$ts" 2>/dev/null || echo 0)") ts_fmt=$(fmt::datetime_short "$(json::iso_to_ts "$ts" 2>/dev/null || echo 0)")
local fw_svc_name local svc_name dest_display
fw_svc_name=$(resolve::service_name "$dest_ip" "$dest_port" "$proto") svc_name=$(_resolve_dest "$dest_ip" "$dest_port" "$proto")
local fw_src_ep fw_src_resolved="" dest_display=$(ui::logs::build_dest "$dest_ip" "$dest_port" "$proto" "$svc_name")
fw_src_ep=$(monitor::get_cached_endpoint "$client")
if [[ -n "$fw_src_ep" ]]; then ui::watch::fw_row "$ts_fmt" "$client" "$dest_display" "$w_client" "$w_dest"
local fw_ep_parts
fw_ep_parts=$(resolve::endpoint_parts "$fw_src_ep")
fw_src_resolved="${fw_ep_parts#*|}"
fi
ui::watch::fw_row "$ts_fmt" "$client" "$dest_ip" "$dest_port" "$proto" \
"$fw_svc_name" "$fw_src_ep" "$fw_src_resolved" \
"$w_client" "$w_dest" "30"
else else
$restricted_only && continue $restricted_only && continue
@ -244,29 +259,18 @@ function cmd::watch::_tail_events() {
$blocked_only && [[ "$event" != "attempt" ]] && continue $blocked_only && [[ "$event" != "attempt" ]] && continue
$allowed_only && [[ "$event" != "handshake" ]] && continue $allowed_only && [[ "$event" != "handshake" ]] && continue
# Dedup
local wg_key="${client}:${endpoint}:${event}" local wg_key="${client}:${endpoint}:${event}"
local now; now=$(date +%s) local now; now=$(date +%s)
local last="${_WATCH_LAST_WG[$wg_key]:-0}" local last="${_WATCH_LAST_WG[$wg_key]:-0}"
(( now - last < 30 )) && continue
local window=30
[[ "$event" == "handshake" ]] && window="${WG_HANDSHAKE_CHECK_TIME_SEC:-300}"
(( now - last < window )) && continue
_WATCH_LAST_WG["$wg_key"]="$now" _WATCH_LAST_WG["$wg_key"]="$now"
local ts_fmt local ts_fmt
ts_fmt=$(fmt::datetime_short "$(json::iso_to_ts "$ts" 2>/dev/null || echo 0)") ts_fmt=$(fmt::datetime_short "$(json::iso_to_ts "$ts" 2>/dev/null || echo 0)")
# Resolve endpoint — fall back to endpoint cache if empty ui::watch::wg_row "$ts_fmt" "$client" "${endpoint:-}" "$event" \
local wg_ep_raw wg_ep_resolved="" "$w_client" "$w_dest"
wg_ep_raw="${endpoint:-}"
if [[ -n "$wg_ep_raw" ]]; then
local wg_ep_parts
wg_ep_parts=$(resolve::endpoint_parts "$wg_ep_raw")
wg_ep_resolved="${wg_ep_parts#*|}"
fi
ui::watch::wg_row "$ts_fmt" "$client" "$wg_ep_raw" "$event" \
"$wg_ep_resolved" "$w_client" "30"
fi fi
done done

View file

@ -11,12 +11,9 @@ source "${WGCTL_DIR}/core/context.sh"
source "${WGCTL_DIR}/core/utils.sh" source "${WGCTL_DIR}/core/utils.sh"
source "${WGCTL_DIR}/core/module.sh" source "${WGCTL_DIR}/core/module.sh"
source "${WGCTL_DIR}/core/command.sh" source "${WGCTL_DIR}/core/command.sh"
source "${WGCTL_DIR}/core/command_mixins.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/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"
command::_load_mixins

View file

@ -39,18 +39,11 @@ function command::exists() { command::has_function "$1" run; }
function command::run() { function command::run() {
local cmd="$1" local cmd="$1"
shift shift
command::_reset_mixin_state # reset values only, keep _ACTIVE_MIXINS
local -a args=("$@")
command::_preprocess_flags args
local fn local fn
fn=$(command::fn "$cmd" run) fn=$(command::fn "$cmd" run)
core::call_function "$fn" ${args[@]+"${args[@]}"} core::call_function "$fn" "$@"
} }
function core::call_function() { function core::call_function() {
local fn="$1" local fn="$1"
shift shift

View file

@ -1,111 +0,0 @@
#!/usr/bin/env bash
# core/command_mixins.sh
# Mixin infrastructure — loads mixin files and provides command::mixin
# ============================================
# Active mixin tracking (per-process)
# ============================================
declare -a _ACTIVE_MIXINS=()
# ============================================
# Auto-load all mixin files
# ============================================
function command::_load_mixins() {
local mixin_file
local -a mixin_paths=(
"${WGCTL_DIR}/core/mixins/"*.mixin.sh
"${WGCTL_DIR}/commands/mixins/"*.mixin.sh
)
for mixin_file in "${mixin_paths[@]:-}"; do
[[ -f "$mixin_file" ]] && source "$mixin_file"
done
}
# ============================================
# command::mixin <name>
# Called from cmd::<name>::on_load to opt into a mixin
# ============================================
function command::mixin() {
local name="${1:-}"
[[ -z "$name" ]] && log::error "command::mixin: missing name" && return 1
local register_fn="command::mixin::${name}::register"
if declare -f "$register_fn" >/dev/null 2>&1; then
# Track for reset/process — avoid duplicates
local m already=false
for m in "${_ACTIVE_MIXINS[@]:-}"; do
[[ "$m" == "$name" ]] && already=true && break
done
$already || _ACTIVE_MIXINS+=("$name")
"$register_fn"
else
log::error "Unknown mixin: ${name} (no command::mixin::${name}::register found)"
return 1
fi
}
# ============================================
# command::_reset_mixins
# Called by command::run before each invocation
# ============================================
function command::_reset_mixin_state() {
# Reset values but keep _ACTIVE_MIXINS populated
local m
for m in "${_ACTIVE_MIXINS[@]:-}"; do
local reset_fn="command::mixin::${m}::reset"
declare -f "$reset_fn" >/dev/null 2>&1 && "$reset_fn"
done
}
function command::_reset_mixins() {
_ACTIVE_MIXINS=()
local reset_fn mixin_file
# Reset all known mixins regardless of active state
for mixin_file in \
"${WGCTL_DIR}/core/mixins/"*.mixin.sh \
"${WGCTL_DIR}/commands/mixins/"*.mixin.sh; do
[[ -f "$mixin_file" ]] || continue
local mixin_name
mixin_name=$(basename "$mixin_file" .mixin.sh)
reset_fn="command::mixin::${mixin_name}::reset"
declare -f "$reset_fn" >/dev/null 2>&1 && "$reset_fn"
done
}
# ============================================
# command::_preprocess_flags <args_nameref>
# Called by command::run — strips mixin flags from args
# ============================================
function command::_preprocess_flags() {
local -n _args_ref="$1"
local -a _filtered=()
if [[ ${#_args_ref[@]} -eq 0 ]]; then
return 0 # nothing to process
fi
for _arg in "${_args_ref[@]}"; do
local _consumed=false
for _mixin in "${_ACTIVE_MIXINS[@]:-}"; do
local _process_fn="command::mixin::${_mixin}::process"
if declare -f "$_process_fn" >/dev/null 2>&1; then
if "$_process_fn" "$_arg"; then
_consumed=true
break
fi
fi
done
$_consumed || _filtered+=("$_arg")
done
if [[ ${#_filtered[@]} -gt 0 ]]; then
_args_ref=("${_filtered[@]}")
else
_args_ref=()
fi
}

View file

@ -10,89 +10,76 @@ _CTX_CORE="${_CTX_ROOT}/core"
_CTX_MODULES="${_CTX_ROOT}/modules" _CTX_MODULES="${_CTX_ROOT}/modules"
_CTX_COMMANDS="${_CTX_ROOT}/commands" _CTX_COMMANDS="${_CTX_ROOT}/commands"
_CTX_CLIENTS="${_CTX_WG}/clients" _CTX_CLIENTS="${_CTX_WG}/clients"
_CTX_DATA="${_CTX_WG}/.wgctl"
# ── Directory layout ────────────────────────────────── # ============================================
# .wgctl/ # Artifacts
# config/ ← wgctl.json, display.json # ============================================
# data/ ← all persistent data (rules, identities, etc.)
# daemon/ ← runtime files (logs, caches)
_CTX_WGCTL="${_CTX_WG}/.wgctl"
_CTX_CONFIG="${_CTX_WGCTL}/config"
_CTX_DATA="${_CTX_WGCTL}/data"
_CTX_DAEMON="${_CTX_WGCTL}/daemon"
# ── Data subdirs ──────────────────────────────────────
_CTX_RULES="${_CTX_DATA}/rules" _CTX_RULES="${_CTX_DATA}/rules"
_CTX_RULES_BASE="${_CTX_RULES}/base" _CTX_RULES_BASE="${_CTX_RULES}/base"
_CTX_GROUPS="${_CTX_DATA}/groups" _CTX_GROUPS="${_CTX_DATA}/groups"
_CTX_BLOCKS="${_CTX_DATA}/blocks" _CTX_BLOCKS="${_CTX_DATA}/blocks"
_CTX_META="${_CTX_DATA}/meta" _CTX_META="${_CTX_DATA}/meta"
_CTX_IDENTITY="${_CTX_DATA}/identities" _CTX_IDENTITY="${_CTX_DATA}/identities"
_CTX_PEER_HISTORY="${_CTX_DATA}/peer-history" _CTX_DAEMON="${_CTX_DATA}/daemon"
# ── Data files ────────────────────────────────────────
_CTX_NET="${_CTX_DATA}/services.json" _CTX_NET="${_CTX_DATA}/services.json"
_CTX_HOSTS="${_CTX_DATA}/hosts.json"
_CTX_SUBNETS="${_CTX_DATA}/subnets.json"
_CTX_POLICIES="${_CTX_DATA}/policies.json"
# ── Config files ──────────────────────────────────────
_CTX_CONFIG_FILE="${_CTX_CONFIG}/wgctl.json"
# ============================================
# Accessors
# ============================================ # ============================================
function ctx::root() { echo "$_CTX_ROOT"; } function ctx::root() { echo "$_CTX_ROOT"; }
function ctx::core() { echo "$_CTX_CORE"; } function ctx::core() { echo "$_CTX_CORE"; }
function ctx::modules() { echo "$_CTX_MODULES"; } function ctx::modules() { echo "$_CTX_MODULES"; }
function ctx::commands() { echo "$_CTX_COMMANDS"; } function ctx::commands() { echo "$_CTX_COMMANDS"; }
function ctx::wg() { echo "$_CTX_WG"; } function ctx::blocks() { echo "$_CTX_BLOCKS"; }
function ctx::clients() { echo "$_CTX_CLIENTS"; } function ctx::groups() { echo "$_CTX_GROUPS"; }
function ctx::rules() { echo "$_CTX_RULES"; }
# Top-level dirs function ctx::rules::base() { echo "$_CTX_RULES_BASE"; }
function ctx::wgctl() { echo "$_CTX_WGCTL"; } function ctx::clients() { echo "$_CTX_CLIENTS"; }
function ctx::config() { echo "$_CTX_CONFIG"; } function ctx::wg() { echo "$_CTX_WG"; }
function ctx::data() { echo "$_CTX_DATA"; } function ctx::data() { echo "$_CTX_DATA"; }
function ctx::daemon() { echo "$_CTX_DAEMON"; } function ctx::rules() { echo "$_CTX_RULES"; }
function ctx::groups() { echo "$_CTX_GROUPS"; }
# Data subdirs function ctx::blocks() { echo "$_CTX_BLOCKS"; }
function ctx::rules() { echo "$_CTX_RULES"; } function ctx::meta() { echo "$_CTX_META"; }
function ctx::rules::base() { echo "$_CTX_RULES_BASE"; } function ctx::daemon() { echo "$_CTX_DAEMON"; }
function ctx::groups() { echo "$_CTX_GROUPS"; } function ctx::net() { echo "$_CTX_NET"; }
function ctx::blocks() { echo "$_CTX_BLOCKS"; } function ctx::identities() { echo "${_CTX_IDENTITY}"; }
function ctx::meta() { echo "$_CTX_META"; } function ctx::subnets() { echo "${_CTX_DATA}/subnets.json"; }
function ctx::identities() { echo "$_CTX_IDENTITY"; } function ctx::events_log() { echo "$(ctx::daemon)/events.log"; }
function ctx::peer_history() { echo "$_CTX_PEER_HISTORY"; } function ctx::fw_events_log() { echo "$(ctx::daemon)/fw_events.log"; }
function ctx::json_helper() { echo "${_CTX_CORE}/json_helper.py"; }
# Data files
function ctx::net() { echo "$_CTX_NET"; }
function ctx::hosts() { echo "$_CTX_HOSTS"; }
function ctx::subnets() { echo "$_CTX_SUBNETS"; }
function ctx::policies() { echo "$_CTX_POLICIES"; }
# Config files
function ctx::config_file() { echo "$_CTX_CONFIG_FILE"; }
function ctx::display() { echo "${_CTX_CONFIG}/display.json"; }
# Daemon files
function ctx::events_log() { echo "${_CTX_DAEMON}/events.log"; }
function ctx::fw_events_log() { echo "${_CTX_DAEMON}/fw_events.log"; }
function ctx::endpoint_cache() { echo "${_CTX_DAEMON}/endpoint_cache.json"; }
# Tool paths
function ctx::json_helper() { echo "${_CTX_CORE}/json_helper.py"; }
function ctx::monitor_script() { echo "${_CTX_ROOT}/daemon/wgctl-monitor.py"; }
function ctx::lib() { echo "${_CTX_CORE}/lib"; }
# ============================================ # ============================================
# Path Helpers # Path Helpers
# ============================================ # ============================================
function ctx::client::path() { local IFS="/"; echo "$_CTX_CLIENTS/$*"; } function ctx::client::path() {
function ctx::meta::path() { local IFS="/"; echo "$_CTX_META/$*"; } local IFS="/"
function ctx::identity::path() { local IFS="/"; echo "$_CTX_IDENTITY/$*"; } echo "$_CTX_CLIENTS/$*"
function ctx::block::path() { local IFS="/"; echo "$_CTX_BLOCKS/$*"; } }
function ctx::group::path() { local IFS="/"; echo "$_CTX_GROUPS/$*"; }
function ctx::rule::path() { local IFS="/"; echo "$_CTX_RULES/$*"; } function ctx::meta::path() {
local IFS="/"
echo "$_CTX_META/$*"
}
function ctx::identity::path() {
local IFS="/"
echo "$_CTX_IDENTITY/$*"
}
function ctx::block::path() {
local IFS="/"
echo "$_CTX_BLOCKS/$*"
}
function ctx::group::path() {
local IFS="/"
echo "$_CTX_GROUPS/$*"
}
function ctx::rule::path() {
local IFS="/"
echo "$_CTX_RULES/$*"
}

View file

@ -79,17 +79,3 @@ function fmt::set_date_format() {
esac esac
} }
function fmt::bytes() {
local bytes="${1:-0}"
if (( bytes == 0 )); then
printf "—"
elif (( bytes >= 1073741824 )); then
printf "%dGB" $(( bytes / 1073741824 ))
elif (( bytes >= 1048576 )); then
printf "%dMB" $(( bytes / 1048576 ))
elif (( bytes >= 1024 )); then
printf "%dKB" $(( bytes / 1024 ))
else
printf "%dB" "$bytes"
fi
}

View file

@ -12,8 +12,8 @@ function json::has_key() { python3 "$JSON_HELPER" has_key
function json::filter_values() { python3 "$JSON_HELPER" filter_values "$@" </dev/null; } function json::filter_values() { python3 "$JSON_HELPER" filter_values "$@" </dev/null; }
function json::last_event() { python3 "$JSON_HELPER" last_event "$@" </dev/null; } function json::last_event() { python3 "$JSON_HELPER" last_event "$@" </dev/null; }
function json::events_for() { python3 "$JSON_HELPER" events_for "$@" </dev/null; } function json::events_for() { python3 "$JSON_HELPER" events_for "$@" </dev/null; }
function json::fw_events() { WGCTL_DATETIME_FMT="$FMT_DATETIME_SHORT" python3 "$JSON_HELPER" fw_events "$@" "$(ctx::endpoint_cache)" </dev/null; } function json::fw_events() { WGCTL_DATETIME_FMT="$FMT_DATETIME_SHORT" python3 "$JSON_HELPER" fw_events "$@" </dev/null; }
function json::wg_events() { WGCTL_DATETIME_FMT="$FMT_DATETIME_SHORT" python3 "$JSON_HELPER" wg_events "$@" "$(ctx::endpoint_cache)" </dev/null; } function json::wg_events() { WGCTL_DATETIME_FMT="$FMT_DATETIME_SHORT" python3 "$JSON_HELPER" wg_events "$@" </dev/null; }
function json::format_fw_event() { echo "$1" | python3 "$JSON_HELPER" format_fw_event "$2"; } function json::format_fw_event() { echo "$1" | python3 "$JSON_HELPER" format_fw_event "$2"; }
function json::format_wg_event() { echo "$1" | python3 "$JSON_HELPER" format_wg_event; } function json::format_wg_event() { echo "$1" | python3 "$JSON_HELPER" format_wg_event; }
function json::remove_events() { python3 "$JSON_HELPER" remove_events "$@" </dev/null; } function json::remove_events() { python3 "$JSON_HELPER" remove_events "$@" </dev/null; }
@ -107,46 +107,8 @@ function json::policy_set_field() { python3 "$JSON_HELPER" policy_set_fiel
function json::subnet_policy() { python3 "$JSON_HELPER" subnet_policy "$@" </dev/null; } function json::subnet_policy() { python3 "$JSON_HELPER" subnet_policy "$@" </dev/null; }
# Activity Monitor # Activity Monitor
function json::activity_aggregate() { python3 "$JSON_HELPER" activity_aggregate "$@" </dev/null; } function json::activity_aggregate() { python3 "$JSON_HELPER" activity_aggregate "$@" </dev/null; }
function json::iso_to_ts() { python3 "$JSON_HELPER" iso_to_ts "$@" </dev/null; } function json::iso_to_ts() { python3 "$JSON_HELPER" iso_to_ts "$@" </dev/null; }
# Hosts Resolution
function json::hosts_list() { python3 "$JSON_HELPER" hosts_list "$@" </dev/null; }
function json::hosts_show() { python3 "$JSON_HELPER" hosts_show "$@" </dev/null; }
function json::hosts_add() { python3 "$JSON_HELPER" hosts_add "$@" </dev/null; }
function json::hosts_remove() { python3 "$JSON_HELPER" hosts_remove "$@" </dev/null; }
function json::hosts_exists() { python3 "$JSON_HELPER" hosts_exists "$@" </dev/null; }
function json::hosts_lookup() { python3 "$JSON_HELPER" hosts_lookup "$@" </dev/null; }
# Peer History
function json::peer_history_lookup() { python3 "$JSON_HELPER" peer_history_lookup "$(ctx::data)/peer-history" "$1" </dev/null; }
function json::clean_handshakes() { python3 "$JSON_HELPER" clean_handshakes "$@" </dev/null; }
function json::batch_resolve() { python3 "$JSON_HELPER" batch_resolve "$(ctx::hosts)" "$(ctx::net)" "$@" </dev/null; }
# JSON Envelopes
function json::envelope() {
local command="${1:-}" count="${2:-0}"
# Reads JSON array from stdin, wraps in envelope
local data
data=$(cat)
local ts
ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
printf '{"ok":true,"command":"%s","data":%s,"meta":{"count":%s,"generated_at":"%s"}}\n' \
"$command" "$data" "$count" "$ts"
}
function json::error_envelope() {
local command="${1:-}" error="${2:-}"
local ts
ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
printf '{"ok":false,"command":"%s","error":"%s","meta":{"generated_at":"%s"}}\n' \
"$command" "$error" "$ts"
}
# Config
function json::config_load() { python3 "$JSON_HELPER" config_load "$1" </dev/null; }
function json::peer_transfer() { function json::peer_transfer() {
ACTIVITY_TOTAL_LOW="$(config::activity_total_low)" \ ACTIVITY_TOTAL_LOW="$(config::activity_total_low)" \

File diff suppressed because it is too large Load diff

View file

View file

@ -1,129 +0,0 @@
"""
activity.py activity aggregation for wgctl activity command.
"""
import os
import json
import glob
import subprocess
from collections import defaultdict
from datetime import datetime, timezone, timedelta
from lib.util import (
PROTO_MAP, build_ip_to_name, build_pubkey_to_name,
load_net_data, load_hosts_data,
reverse_lookup, resolve_display, make_dest_display,
ts_to_unix, parse_since,
)
def activity_aggregate(fw_file, wg_file, wg_interface, net_file,
clients_dir, meta_dir, hours, filter_peer,
filter_service_ip):
"""
Aggregate activity data for wgctl activity.
Output:
peer|name|rx_bytes|tx_bytes|drop_count
service|peer_name|dest_display|drop_count
"""
hours = int(hours) if hours else 24
cutoff = None
if hours > 0:
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
# Preload lookups once
ip_to_peer = build_ip_to_name(clients_dir)
pubkey_to_peer = build_pubkey_to_name(clients_dir)
net_data = load_net_data(net_file)
def _reverse(dest_ip, dest_port, proto):
return reverse_lookup(net_data, dest_ip, dest_port, proto)
# WireGuard transfer totals
peer_rx = defaultdict(int)
peer_tx = defaultdict(int)
try:
result = subprocess.run(
['wg', 'show', wg_interface, 'transfer'],
capture_output=True, text=True
)
for line in result.stdout.strip().splitlines():
parts = line.split()
if len(parts) >= 3:
pubkey, rx, tx = parts[0], int(parts[1]), int(parts[2])
peer = pubkey_to_peer.get(pubkey)
if peer:
peer_rx[peer] += rx
peer_tx[peer] += tx
except Exception:
pass
# Parse fw_events for drops
peer_drops = defaultdict(int)
service_drops = defaultdict(lambda: defaultdict(int))
if os.path.exists(fw_file):
try:
with open(fw_file) as f:
for line in f:
line = line.strip()
if not line:
continue
try:
ev = json.loads(line)
if cutoff:
ts_str = ev.get('timestamp', '')
try:
ts = datetime.fromisoformat(ts_str)
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
if ts < cutoff:
continue
except Exception:
pass
src_ip = ev.get('src_ip', '')
if not src_ip:
continue
dest_ip = ev.get('dest_ip', '')
dest_port = str(ev.get('dest_port', ''))
proto_num = ev.get('ip.protocol', 0)
proto = PROTO_MAP.get(int(proto_num), str(proto_num))
peer = ip_to_peer.get(src_ip)
if not peer:
continue
if filter_peer and peer != filter_peer:
continue
if filter_service_ip and dest_ip != filter_service_ip:
continue
svc_name = _reverse(dest_ip, dest_port, proto)
dest_display = make_dest_display(dest_ip, dest_port, proto, svc_name)
peer_drops[peer] += 1
service_drops[peer][dest_display] += 1
except Exception:
continue
except Exception:
pass
# Collect peers with any activity
all_peers = set()
all_peers.update(k for k in peer_rx if peer_rx[k] > 0)
all_peers.update(k for k in peer_tx if peer_tx[k] > 0)
all_peers.update(peer_drops.keys())
if filter_peer:
all_peers = {p for p in all_peers if p == filter_peer}
for peer in sorted(all_peers):
rx = peer_rx.get(peer, 0)
tx = peer_tx.get(peer, 0)
drops = peer_drops.get(peer, 0)
print(f"peer|{peer}|{rx}|{tx}|{drops}")
svc_map = service_drops.get(peer, {})
for dest_display, count in sorted(svc_map.items(), key=lambda x: -x[1]):
print(f"service|{peer}|{dest_display}|{count}")

View file

@ -1,674 +0,0 @@
"""
events.py WireGuard and firewall event processing.
"""
import os
import json
import sys
from collections import defaultdict
from datetime import datetime
from lib.util import (
DATETIME_FMT, PROTO_MAP,
build_ip_to_name, load_net_data, load_hosts_data,
reverse_lookup, hosts_lookup, resolve_display,
fmt_ts, fmt_ts_hour, ts_to_unix, parse_since,
make_dest_display,
)
# ──────────────────────────────────────────
# fw_events
# ──────────────────────────────────────────
def fw_events(file, filter_ip, filter_type, clients_dir, net_file,
limit, collapse='1', since='', filter_dest_ip='',
filter_dest_port='', sort_order='desc', endpoint_cache_file=''):
"""
Format firewall drop events with dedup, counts, and service annotation.
collapse='1' (default): hourly aggregation
collapse='0': show all deduplicated events (--detailed mode)
since: relative or absolute time string (e.g. '2h', '23/05', '2026-05-23')
filter_dest_ip: filter by destination IP (optional)
filter_dest_port: filter by destination port (optional)
Output per line: ts|client|dest_ip|dest_port|proto|service_name|count
"""
do_collapse = str(collapse) != '0'
limit = int(limit) if limit else 50
# Preload lookups once
ip_to_name = build_ip_to_name(clients_dir)
net_data = load_net_data(net_file)
hosts_data = load_hosts_data(None) # hosts lookup done in bash for now
endpoint_cache = {}
if endpoint_cache_file and os.path.exists(endpoint_cache_file):
try:
with open(endpoint_cache_file) as f:
endpoint_cache = json.load(f)
except Exception:
pass
since_dt = parse_since(since) if since else None
def _reverse(dest_ip, dest_port, proto):
return reverse_lookup(net_data, dest_ip, dest_port, proto)
# ── Parse and first-pass dedup (time-window per key) ──
events = []
last_seen = {}
try:
with open(file) as f:
for line in f:
try:
e = json.loads(line.strip())
src = e.get('src_ip', '')
if not src:
continue
if filter_ip and src != filter_ip:
continue
proto_num = int(e.get('ip.protocol', 0))
proto = PROTO_MAP.get(proto_num, str(proto_num))
dst = e.get('dest_ip', '')
port = str(e.get('dest_port', ''))
if filter_dest_ip and dst != filter_dest_ip:
continue
if filter_dest_port and port != filter_dest_port:
continue
ts_str = e.get('timestamp', '')
ts = ts_to_unix(ts_str)
if since_dt:
try:
ev_dt = datetime.fromisoformat(ts_str)
if ev_dt.tzinfo is None:
from datetime import timezone
ev_dt = ev_dt.replace(tzinfo=timezone.utc)
if ev_dt < since_dt:
continue
except Exception:
pass
key = (src, dst, port, proto_num)
windows = {1: 5, 6: 30, 17: 10}
window = windows.get(proto_num, 10)
if key in last_seen and (ts - last_seen[key]) < window:
continue
last_seen[key] = ts
events.append(e)
except Exception:
continue
except Exception:
pass
# ── Collapse or detailed output ──
if do_collapse:
hourly = defaultdict(int)
hourly_ts = {}
for e in events:
src = e.get('src_ip', '')
dst = e.get('dest_ip', '')
port = str(e.get('dest_port', ''))
proto_num = int(e.get('ip.protocol', 0))
proto = PROTO_MAP.get(proto_num, str(proto_num))
ts_str = e.get('timestamp', '')
client = ip_to_name.get(src, src)
svc_name = _reverse(dst, port, proto)
try:
dt = datetime.fromisoformat(ts_str)
hour_key = (client, dst, port, proto, svc_name,
dt.strftime('%Y-%m-%d %H'))
hourly[hour_key] += 1
if hour_key not in hourly_ts:
hourly_ts[hour_key] = dt
except Exception:
continue
sorted_buckets = sorted(hourly_ts.items(), key=lambda x: x[1])
output = sorted_buckets[-limit:]
if sort_order != 'asc':
output = list(reversed(output))
for hour_key, dt in output:
client, dst, port, proto, svc_name, _ = hour_key
count = hourly[hour_key]
ts_fmt = fmt_ts_hour(hourly_ts[hour_key].isoformat())
src_endpoint = endpoint_cache.get(client, '')
print(f"{ts_fmt}|{client}|{dst}|{port}|{proto}|{svc_name}|{count}|{src_endpoint}")
else:
# Detailed — consecutive dedup only
deduped = []
counts = []
for e in events:
src = e.get('src_ip', '')
dst = e.get('dest_ip', '')
port = str(e.get('dest_port', ''))
proto_num = int(e.get('ip.protocol', 0))
key = (src, dst, port, proto_num)
ts = ts_to_unix(e.get('timestamp', ''))
if deduped:
prev = deduped[-1]
prev_ts = ts_to_unix(prev.get('timestamp', ''))
prev_key = (
prev.get('src_ip', ''),
prev.get('dest_ip', ''),
str(prev.get('dest_port', '')),
int(prev.get('ip.protocol', 0))
)
if key == prev_key and (ts - prev_ts) < 300:
counts[-1] += 1
continue
deduped.append(e)
counts.append(1)
pairs = list(zip(deduped, counts))[-limit:]
if sort_order != 'asc':
pairs = list(reversed(pairs))
for e, count in pairs:
src = e.get('src_ip', '')
dst = e.get('dest_ip', '')
port = str(e.get('dest_port', ''))
proto_num = int(e.get('ip.protocol', 0))
proto = PROTO_MAP.get(proto_num, str(proto_num))
client = ip_to_name.get(src, src)
svc_name = reverse_lookup(net_data, dst, port, proto)
src_endpoint = endpoint_cache.get(client, '')
try:
dt = datetime.fromisoformat(e.get('timestamp', ''))
ts_fmt = dt.strftime(DATETIME_FMT)
except Exception:
ts_fmt = e.get('timestamp', '')
print(f"{ts_fmt}|{client}|{dst}|{port}|{proto}|{svc_name}|{count}|{src_endpoint}")
# ──────────────────────────────────────────
# wg_events
# ──────────────────────────────────────────
def wg_events(file, filter_client, filter_type, limit, collapse='1',
since='', filter_event='', endpoint_cache_file='', sort_order='desc'):
"""
Format WireGuard events with dedup, counts, gap and endpoint resolution.
sort_order: 'desc' (default, newest first) | 'asc' (oldest first)
Output per line: ts|client|endpoint|event|count|gap_seconds
"""
from datetime import datetime
from collections import defaultdict
do_collapse = str(collapse) != '0'
limit = int(limit) if limit else 50
since_dt = parse_since(since) if since else None
descending = sort_order != 'asc'
# Load endpoint cache once
endpoint_cache = {}
if endpoint_cache_file and os.path.exists(endpoint_cache_file):
try:
with open(endpoint_cache_file) as f:
endpoint_cache = json.load(f)
except Exception:
pass
events = []
try:
with open(file) as f:
for line in f:
try:
e = json.loads(line.strip())
client = e.get('client', '')
if not client:
continue
if filter_client and client != filter_client:
continue
if filter_type and not client.startswith(filter_type + '-'):
continue
if filter_event and e.get('event', '') != filter_event:
continue
if since_dt:
ts_str = e.get('timestamp', '')
try:
from datetime import timezone
ev_dt = datetime.fromisoformat(ts_str)
if ev_dt.tzinfo is None:
ev_dt = ev_dt.replace(tzinfo=timezone.utc)
if ev_dt < since_dt:
continue
except Exception:
pass
events.append(e)
except Exception:
pass
except Exception:
pass
def _endpoint(e):
ep = e.get('endpoint', '')
return ep or endpoint_cache.get(e.get('client', ''), '')
if do_collapse:
hourly_attempts = defaultdict(int)
hourly_ts = {}
handshakes = []
handshake_counts = []
for e in events:
ts_str = e.get('timestamp', '')
client = e.get('client', '')
endpoint = _endpoint(e)
event = e.get('event', '')
ts = ts_to_unix(ts_str)
try:
dt = datetime.fromisoformat(ts_str)
except Exception:
dt = None
if event == 'attempt':
if dt:
hour_key = (client, endpoint, event,
dt.strftime('%Y-%m-%d %H'))
hourly_attempts[hour_key] += 1
if hour_key not in hourly_ts:
hourly_ts[hour_key] = dt
else:
key = (client, event, endpoint[:15])
if handshakes:
prev = handshakes[-1]
prev_ts = ts_to_unix(prev.get('timestamp', ''))
prev_key = (prev.get('client', ''), prev.get('event', ''),
_endpoint(prev)[:15])
if key == prev_key and (ts - prev_ts) < 300:
handshake_counts[-1] += 1
continue
handshakes.append(e)
handshake_counts.append(1)
# Build output list — always ascending first for correct gap calculation
output = []
for hour_key, dt in hourly_ts.items():
client, endpoint, event, _ = hour_key
count = hourly_attempts[hour_key]
ts_fmt = fmt_ts_hour(dt.isoformat())
output.append((dt.timestamp(),
f"{ts_fmt}|{client}|{endpoint}|{event}|{count}|"))
# Compute gaps ascending
last_handshake_ts = {}
hs_output = []
for e, count in zip(handshakes, handshake_counts):
ts_str = e.get('timestamp', '')
client = e.get('client', '')
endpoint = _endpoint(e)
event = e.get('event', '')
ts = ts_to_unix(ts_str)
ts_fmt = fmt_ts(ts_str)
gap_seconds = ''
if event == 'handshake':
prev_ts = last_handshake_ts.get(client, 0)
if prev_ts > 0:
gap = int(ts - prev_ts)
if gap > 0:
gap_seconds = str(gap)
last_handshake_ts[client] = ts
hs_output.append((ts, f"{ts_fmt}|{client}|{endpoint}|{event}|{count}|{gap_seconds}"))
output.extend(hs_output)
# Sort ascending first to get correct limit slice, then reverse if needed
output.sort(key=lambda x: x[0])
output = output[-limit:]
if descending:
output.reverse()
for _, line in output:
print(line)
else:
deduped = []
counts = []
for e in events:
client = e.get('client', '')
event = e.get('event', '')
endpoint = _endpoint(e)
key = (client, event, endpoint[:15])
ts = ts_to_unix(e.get('timestamp', ''))
if deduped:
prev = deduped[-1]
prev_ts = ts_to_unix(prev.get('timestamp', ''))
prev_key = (prev.get('client', ''), prev.get('event', ''),
_endpoint(prev)[:15])
if key == prev_key and (ts - prev_ts) < 300:
counts[-1] += 1
continue
deduped.append(e)
counts.append(1)
# Compute gaps ascending, then slice, then reverse if needed
last_handshake_ts = {}
result = []
for e, count in zip(deduped, counts):
ts_str = e.get('timestamp', '')
client = e.get('client', '')
endpoint = _endpoint(e)
event = e.get('event', '')
ts = ts_to_unix(ts_str)
ts_fmt = fmt_ts(ts_str)
gap_seconds = ''
if event == 'handshake':
prev_ts = last_handshake_ts.get(client, 0)
if prev_ts > 0:
gap = int(ts - prev_ts)
if gap > 0:
gap_seconds = str(gap)
last_handshake_ts[client] = ts
result.append((ts, f"{ts_fmt}|{client}|{endpoint}|{event}|{count}|{gap_seconds}"))
result = result[-limit:]
if descending:
result.reverse()
for _, line in result:
print(line)
# ──────────────────────────────────────────
# Single event parsers (used by watch)
# ──────────────────────────────────────────
def parse_event(line):
"""Parse a single JSON wg event line."""
try:
e = json.loads(line)
print(f"{e.get('timestamp','')}|{e.get('client','')}|"
f"{e.get('endpoint','')}|{e.get('event','')}")
except Exception:
pass
def parse_fw_event(line):
"""Parse a single fw_events.log JSON line."""
try:
e = json.loads(line)
proto_num = e.get('ip.protocol', 0)
proto = PROTO_MAP.get(proto_num, str(proto_num))
print(f"{e.get('timestamp','')}|{e.get('src_ip','')}|"
f"{e.get('dest_ip','')}|{e.get('dest_port','')}|{proto}")
except Exception:
pass
def format_fw_event(line, clients_dir):
"""Format a single fw_event line for display."""
ip_to_name = build_ip_to_name(clients_dir)
try:
e = json.loads(line.strip())
src = e.get('src_ip', '')
if not src:
return None
dst = e.get('dest_ip', '')
port = e.get('dest_port', '')
proto_num = e.get('ip.protocol', 0)
proto = PROTO_MAP.get(proto_num, str(proto_num))
dst_str = f"{dst}:{port}" if port else dst
client = ip_to_name.get(src, src)
ts_fmt = fmt_ts(e.get('timestamp', ''))
return f"{ts_fmt}|{client}|{dst_str}|{proto}"
except Exception:
return None
def format_wg_event(line):
"""Format a single wg_event line for display."""
try:
e = json.loads(line.strip())
client = e.get('client', '')
if not client:
return None
ts_fmt = fmt_ts(e.get('timestamp', ''))
endpoint = e.get('endpoint', '')
event = e.get('event', '')
return f"{ts_fmt}|{client}|{endpoint}|{event}|wg"
except Exception:
return None
# ──────────────────────────────────────────
# Event removal
# ──────────────────────────────────────────
def remove_events(file, identifier):
"""Remove all events for a client/ip from a JSONL file."""
try:
lines = []
with open(file) as f:
for line in f:
try:
e = json.loads(line.strip())
if (e.get('client') == identifier or
e.get('src_ip') == identifier):
continue
lines.append(line)
except Exception:
lines.append(line)
with open(file, 'w') as f:
f.writelines(lines)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def remove_events_filtered(wg_file, fw_file, filter_name, filter_ip,
filter_fw, filter_wg, before_days):
"""Remove events with filters: by name/ip, source, or age."""
import time
cutoff_ts = None
if before_days:
cutoff_ts = time.time() - (float(before_days) * 86400)
def should_remove_wg(e):
if filter_name and e.get('client') != filter_name:
return False
if cutoff_ts:
try:
return ts_to_unix(e.get('timestamp', '')) < cutoff_ts
except Exception:
return False
return True
def should_remove_fw(e):
if filter_ip and e.get('src_ip') != filter_ip:
return False
if cutoff_ts:
try:
return ts_to_unix(e.get('timestamp', '')) < cutoff_ts
except Exception:
return False
return True
removed_wg = removed_fw = 0
if not filter_fw and os.path.exists(wg_file):
lines = []
with open(wg_file) as f:
for line in f:
try:
e = json.loads(line.strip())
if should_remove_wg(e):
removed_wg += 1
continue
except Exception:
pass
lines.append(line)
with open(wg_file, 'w') as f:
f.writelines(lines)
if not filter_wg and os.path.exists(fw_file):
lines = []
with open(fw_file) as f:
for line in f:
try:
e = json.loads(line.strip())
if should_remove_fw(e):
removed_fw += 1
continue
except Exception:
pass
lines.append(line)
with open(fw_file, 'w') as f:
f.writelines(lines)
print(f"{removed_wg}|{removed_fw}")
# ──────────────────────────────────────────
# Log follower (used by old follow_logs)
# ──────────────────────────────────────────
def follow_logs(fw_file, wg_file, filter_ip, filter_type,
clients_dir, filter_peers=''):
"""Follow both log files and output formatted events."""
import time
peer_filter = set(filter_peers.split(',')) if filter_peers else set()
ip_to_name = build_ip_to_name(clients_dir)
files = {}
for label, path in [('fw', fw_file), ('wg', wg_file)]:
if path and os.path.exists(path):
f = open(path)
f.seek(0, 2)
files[label] = f
dedup = {}
try:
while True:
for label, f in files.items():
line = f.readline()
if not line:
continue
try:
e = json.loads(line.strip())
except Exception:
continue
if label == 'fw':
src = e.get('src_ip', '')
if not src:
continue
if filter_ip and src != filter_ip:
continue
if peer_filter:
client_name = ip_to_name.get(src, '')
if client_name not in peer_filter:
continue
dst = e.get('dest_ip', '')
port = e.get('dest_port', '')
proto_num = e.get('ip.protocol', 0)
proto = PROTO_MAP.get(proto_num, str(proto_num))
key = (src, dst, port, proto_num)
windows = {1: 5, 6: 30, 17: 10}
window = windows.get(proto_num, 10)
now = time.time()
if key in dedup and (now - dedup[key]) < window:
continue
dedup[key] = now
client = ip_to_name.get(src, src)
if filter_type and not client.startswith(filter_type + '-'):
continue
dst_str = f"{dst}:{port}" if port else dst
ts = e.get('timestamp', '')[:16].replace('T', ' ')
print(f"fw|{ts}|{client}|{dst_str}|{proto}", flush=True)
elif label == 'wg':
client = e.get('client', '')
if not client:
continue
if filter_ip:
ip = ip_to_name.get(filter_ip, '')
if client != ip and client != filter_ip:
continue
if peer_filter and client not in peer_filter:
continue
if filter_type and not client.startswith(filter_type + '-'):
continue
ts = e.get('timestamp', '')[:16].replace('T', ' ')
endpoint = e.get('endpoint', '')
event = e.get('event', '')
print(f"wg|{ts}|{client}|{endpoint}|{event}", flush=True)
time.sleep(0.1)
except KeyboardInterrupt:
pass
# ──────────────────────────────────────────
# Misc
# ──────────────────────────────────────────
def last_event(file, key, field, client):
"""Get last event field for a client."""
try:
last = None
with open(file) as f:
for line in f:
try:
e = json.loads(line.strip())
if e.get(key) == client:
last = e
except Exception:
pass
if last:
print(last.get(field, ''))
except Exception:
pass
def events_for(file, ip, limit):
"""Format events for a given IP."""
try:
events = []
with open(file) as f:
for line in f:
try:
e = json.loads(line.strip())
if e.get('ip') == ip:
events.append(e)
except Exception:
pass
for e in events[-int(limit):]:
ts_fmt = fmt_ts(e.get('timestamp', ''))
endpoint = e.get('endpoint', '')
client = e.get('client', '')
event = e.get('event', '')
print(f' {ts_fmt} {client:<20} {endpoint:<20} {event}')
except Exception:
pass
def iso_to_ts(iso_str):
"""Convert ISO timestamp to unix timestamp."""
try:
from datetime import timezone
dt = datetime.fromisoformat(iso_str)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
print(int(dt.timestamp()))
except Exception:
print(0)

View file

@ -1,180 +0,0 @@
"""
peers.py peer data, transfer stats, group lookups.
"""
import os
import json
import sys
import glob
from lib.util import DATETIME_FMT, build_ip_to_name, build_pubkey_to_name, fmt_ts
def peer_data(clients_dir, meta_dir, events_log):
"""
Output: name|ip|rule|type|last_ts|last_evt|main_group
"""
meta = {}
for f in glob.glob(f"{meta_dir}/*.meta"):
name = os.path.basename(f).replace('.meta', '')
try:
with open(f) as mf:
meta[name] = json.load(mf)
except Exception:
meta[name] = {}
last_events = {}
try:
with open(events_log) as f:
for line in f:
try:
e = json.loads(line.strip())
client = e.get('client', '')
if client:
last_events[client] = e
except Exception:
pass
except Exception:
pass
for conf in sorted(glob.glob(f"{clients_dir}/*.conf")):
name = os.path.basename(conf).replace('.conf', '')
ip = ''
try:
with open(conf) as f:
for line in f:
if line.startswith('Address'):
ip = line.split('=')[1].strip().split('/')[0]
break
except Exception:
pass
m = meta.get(name, {})
rule = m.get('rule', '')
peer_type = m.get('type', '')
main_group = m.get('main_group', '')
last_event = last_events.get(name, {})
last_ts = last_event.get('timestamp', '')
last_evt = last_event.get('event', '')
print(f"{name}|{ip}|{rule}|{peer_type}|{last_ts}|{last_evt}|{main_group}")
def peer_transfer(wg_interface):
"""Get total transfer bytes per peer."""
import subprocess
low = int(os.environ.get('ACTIVITY_TOTAL_LOW_BYTES', '1000000'))
med = int(os.environ.get('ACTIVITY_TOTAL_MED_BYTES', '10000000'))
high = int(os.environ.get('ACTIVITY_TOTAL_HIGH_BYTES', '100000000'))
try:
result = subprocess.run(
['wg', 'show', wg_interface, 'transfer'],
capture_output=True, text=True
)
for line in result.stdout.strip().split('\n'):
if not line:
continue
parts = line.split('\t')
if len(parts) == 3:
pubkey, rx, tx = parts
total = int(rx) + int(tx)
if total == 0: level = 'none'
elif total < low: level = 'low'
elif total < med: level = 'medium'
elif total < high: level = 'high'
else: level = 'very high'
print(f"{pubkey}|{rx}|{tx}|{level}")
except Exception:
pass
def peer_transfer_delta(wg_interface, cache_file):
"""Calculate current transfer rate using delta from previous sample."""
import subprocess
import time
low = int(os.environ.get('ACTIVITY_CURRENT_LOW_BYTES', '10000'))
med = int(os.environ.get('ACTIVITY_CURRENT_MED_BYTES', '100000'))
high = int(os.environ.get('ACTIVITY_CURRENT_HIGH_BYTES', '1000000'))
current = {}
now = time.time()
try:
result = subprocess.run(
['wg', 'show', wg_interface, 'transfer'],
capture_output=True, text=True
)
for line in result.stdout.strip().split('\n'):
if not line:
continue
parts = line.split('\t')
if len(parts) == 3:
pubkey, rx, tx = parts
current[pubkey] = {'rx': int(rx), 'tx': int(tx), 'ts': now}
except Exception:
pass
prev = {}
if os.path.exists(cache_file):
try:
with open(cache_file) as f:
prev = json.load(f)
except Exception:
pass
try:
with open(cache_file, 'w') as f:
json.dump(current, f)
except Exception:
pass
for pubkey, data in current.items():
if pubkey in prev:
dt = data['ts'] - prev[pubkey].get('ts', data['ts'])
if dt > 0:
rx_rate = max(0, (data['rx'] - prev[pubkey]['rx']) / dt)
tx_rate = max(0, (data['tx'] - prev[pubkey]['tx']) / dt)
total = rx_rate + tx_rate
if total <= 0: level = 'idle'
elif total < low: level = 'low'
elif total < med: level = 'medium'
elif total < high: level = 'high'
else: level = 'very high'
print(f"{pubkey}|{int(rx_rate)}|{int(tx_rate)}|{level}")
else:
print(f"{pubkey}|0|0|idle")
else:
print(f"{pubkey}|0|0|unknown")
def peer_group_map(groups_dir):
"""Return peer:group pairs for all groups."""
try:
for group_file in glob.glob(f"{groups_dir}/*.group"):
try:
with open(group_file) as f:
g = json.load(f)
name = g.get('name', '')
for peer in g.get('peers', []):
if peer:
print(f"{peer}:{name}")
except Exception:
pass
except Exception:
pass
def peer_groups(groups_dir, peer_name):
"""Find all groups containing a peer."""
try:
for group_file in glob.glob(f"{groups_dir}/*.group"):
try:
with open(group_file) as f:
g = json.load(f)
if peer_name in g.get('peers', []):
print(g.get('name', ''))
except Exception:
pass
except Exception:
pass

View file

@ -1,255 +0,0 @@
"""
util.py shared utilities for wgctl json_helper modules.
Imported by all other lib modules.
"""
import os
import json
import sys
from datetime import datetime, timezone, timedelta
# ──────────────────────────────────────────
# Global config (read from environment)
# ──────────────────────────────────────────
DATETIME_FMT = os.environ.get('WGCTL_DATETIME_FMT', '%Y-%m-%d %H:%M')
DATE_FORMAT = os.environ.get('WGCTL_DATE_FORMAT', 'eu') # eu | iso
PROTO_MAP = {1: 'icmp', 6: 'tcp', 17: 'udp'}
# ──────────────────────────────────────────
# IP → Peer name map
# ──────────────────────────────────────────
def build_ip_to_name(clients_dir):
"""
Build a dict mapping peer IP -> peer name from .conf files.
Cached per process call once, reuse.
"""
import glob
ip_to_name = {}
for conf in glob.glob(f"{clients_dir}/*.conf"):
name = os.path.basename(conf).replace('.conf', '')
try:
with open(conf) as f:
for line in f:
if line.startswith('Address'):
ip = line.split('=')[1].strip().split('/')[0]
ip_to_name[ip] = name
break
except Exception:
pass
return ip_to_name
def build_pubkey_to_name(clients_dir):
"""
Build a dict mapping public key -> peer name from *_public.key files.
"""
import glob
pubkey_to_peer = {}
for kf in glob.glob(f"{clients_dir}/*_public.key"):
name = os.path.basename(kf).replace('_public.key', '')
try:
with open(kf) as f:
key = f.read().strip()
if key:
pubkey_to_peer[key] = name
except Exception:
pass
return pubkey_to_peer
# ──────────────────────────────────────────
# Service reverse lookup
# ──────────────────────────────────────────
def load_net_data(net_file):
"""Load services.json into a dict. Returns {} on failure."""
if not net_file or not os.path.exists(net_file):
return {}
try:
with open(net_file) as f:
return json.load(f)
except Exception:
return {}
def reverse_lookup(net_data, dest_ip, dest_port='', proto=''):
"""
Resolve dest_ip[:port] to a service name using services.json data.
Returns '' if no match found.
"""
for svc_name, svc in net_data.items():
if not isinstance(svc, dict):
continue
if svc.get('ip', '') != dest_ip:
continue
ports = svc.get('ports', {})
if dest_port:
for port_name, port_def in ports.items():
if not isinstance(port_def, dict):
continue
if (str(port_def.get('port', '')) == str(dest_port) and
port_def.get('proto', 'tcp') == proto):
return f"{svc_name}:{port_name}"
# IP matched but no port match — return service name
return svc_name
return svc_name
return ''
def load_hosts_data(hosts_file):
"""Load hosts.json into a dict. Returns empty structure on failure."""
if not hosts_file or not os.path.exists(hosts_file):
return {"hosts": {}, "subnets": {}, "ports": {}}
try:
with open(hosts_file) as f:
data = json.load(f)
data.setdefault("hosts", {})
data.setdefault("subnets", {})
data.setdefault("ports", {})
return data
except Exception:
return {"hosts": {}, "subnets": {}, "ports": {}}
def hosts_lookup(hosts_data, ip):
"""
Resolve IP to display name using hosts.json data.
Returns '' if no match.
"""
entry = hosts_data.get("hosts", {}).get(ip)
if not entry:
return ''
if isinstance(entry, dict):
return entry.get('name', '')
return str(entry)
def resolve_display(net_data, hosts_data, dest_ip, dest_port='', proto=''):
"""
Full resolution chain:
1. hosts.json exact IP match
2. services.json match
3. raw IP fallback (returns dest_ip)
"""
# 1. hosts.json
name = hosts_lookup(hosts_data, dest_ip)
if name:
return name
# 2. services.json
name = reverse_lookup(net_data, dest_ip, dest_port, proto)
if name:
return name
# 3. raw fallback
return dest_ip
# ──────────────────────────────────────────
# Timestamp utilities
# ──────────────────────────────────────────
def fmt_ts(ts_str, fmt=None):
"""
Format an ISO timestamp string using DATETIME_FMT (or override fmt).
Returns ts_str unchanged on failure.
"""
fmt = fmt or DATETIME_FMT
try:
dt = datetime.fromisoformat(ts_str)
return dt.strftime(fmt)
except Exception:
return ts_str
def fmt_ts_hour(ts_str, fmt=None):
"""
Format an ISO timestamp to hour precision (minutes replaced with 00).
"""
fmt = fmt or DATETIME_FMT
hour_fmt = fmt.replace('%M', '00')
try:
dt = datetime.fromisoformat(ts_str)
return dt.strftime(hour_fmt)
except Exception:
return ts_str
def ts_to_unix(ts_str):
"""Convert ISO timestamp to unix float. Returns 0.0 on failure."""
try:
dt = datetime.fromisoformat(ts_str)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.timestamp()
except Exception:
return 0.0
def parse_since(value, date_format=None):
"""
Parse a --since value to a datetime (UTC-aware).
Accepts:
Relative: 2h, 30m, 7d
EU date: 23/05, 23/05/2026, 23-05, 23-05-2026
ISO date: 2026-05-23, 2026-05-23 03:00
Returns None on failure.
"""
import re
date_format = date_format or DATE_FORMAT
value = value.strip()
# Relative: e.g. 2h, 30m, 7d
m = re.fullmatch(r'(\d+)([mhd])', value)
if m:
n, unit = int(m.group(1)), m.group(2)
delta = {'m': timedelta(minutes=n),
'h': timedelta(hours=n),
'd': timedelta(days=n)}[unit]
return datetime.now(timezone.utc) - delta
now_year = datetime.now().year
# EU formats: 23/05, 23/05/2026, 23-05, 23-05-2026
for pattern, fmt in [
(r'(\d{1,2})/(\d{1,2})$', f'%d/%m/{now_year}'),
(r'(\d{1,2})/(\d{1,2})/(\d{4})$', '%d/%m/%Y'),
(r'(\d{1,2})-(\d{1,2})$', f'%d-%m-{now_year}'),
(r'(\d{1,2})-(\d{1,2})-(\d{4})$', '%d-%m-%Y'),
]:
if re.fullmatch(pattern, value):
try:
if f'/{now_year}' in fmt or f'-{now_year}' in fmt:
dt = datetime.strptime(f"{value}/{now_year}" if '/' in value
else f"{value}-{now_year}", fmt)
else:
dt = datetime.strptime(value, fmt)
return dt.replace(tzinfo=timezone.utc)
except Exception:
pass
# ISO formats: 2026-05-23, 2026-05-23 03:00
for fmt in ('%Y-%m-%d', '%Y-%m-%d %H:%M'):
try:
dt = datetime.strptime(value, fmt)
return dt.replace(tzinfo=timezone.utc)
except Exception:
pass
return None
# ──────────────────────────────────────────
# Dest display formatting
# ──────────────────────────────────────────
def make_dest_display(dest_ip, dest_port, proto, svc_name):
"""Build a human-readable destination string."""
if svc_name and svc_name != dest_ip:
return svc_name
if dest_port:
return f"{dest_ip}:{dest_port}/{proto}"
if proto and proto not in ('tcp',):
return f"{dest_ip} ({proto})"
return dest_ip

View file

@ -1,21 +0,0 @@
#!/usr/bin/env bash
# core/mixins/json_output.mixin.sh
# Adds --json flag support to any command
_COMMAND_JSON=false
function command::mixin::json_output::register() {
flag::register --json
}
function command::mixin::json_output::reset() {
_COMMAND_JSON=false
}
function command::mixin::json_output::process() {
[[ "$1" == "--json" ]] && _COMMAND_JSON=true && return 0
return 1
}
# Public accessor
function command::json() { [[ "${_COMMAND_JSON:-false}" == "true" ]]; }

View file

@ -1,21 +0,0 @@
#!/usr/bin/env bash
# core/mixins/no_color.mixin.sh
# Adds --no-color flag support to any command
_COMMAND_NO_COLOR=false
function command::mixin::no_color::register() {
flag::register --no-color
}
function command::mixin::no_color::reset() {
_COMMAND_NO_COLOR=false
}
function command::mixin::no_color::process() {
[[ "$1" == "--no-color" ]] && _COMMAND_NO_COLOR=true && return 0
return 1
}
# Public accessor
function command::no_color() { [[ "${_COMMAND_NO_COLOR:-false}" == "true" ]]; }

View file

@ -3,12 +3,6 @@
UI_ROW_WIDTH=${UI_ROW_WIDTH:-20} UI_ROW_WIDTH=${UI_ROW_WIDTH:-20}
UI_SECTION_WIDTH=${UI_SECTION_WIDTH:-44} UI_SECTION_WIDTH=${UI_SECTION_WIDTH:-44}
# UTF-8 multi-byte character extras (bash ${#} counts bytes, not chars)
# extra = byte_length - visible_char_length
_UI_EMDASH_EXTRA=2 # — (em dash) 3 bytes, 1 visible
_UI_ARROW_EXTRA=2 # → (right arrow) 3 bytes, 1 visible
_UI_BULLET_EXTRA=1 # · (middle dot) 2 bytes, 1 visible
function ui::row() { function ui::row() {
local label="$1" value="$2" width="${3:-$UI_ROW_WIDTH}" local label="$1" value="$2" width="${3:-$UI_ROW_WIDTH}"
printf " %-${width}s %s\n" "${label}:" "$value" printf " %-${width}s %s\n" "${label}:" "$value"

View file

@ -1,13 +1,13 @@
{ {
"phone-fred": "176.223.61.130", "phone-fred": "176.223.61.130",
"phone-helena": "148.69.46.73", "phone-helena": "148.69.46.73",
"phone-nuno": "148.69.48.20", "phone-nuno": "94.63.0.129",
"tablet-nuno": "148.69.202.5", "tablet-nuno": "148.69.202.5",
"guest-zephyr": "86.120.152.74", "guest-zephyr": "86.120.152.74",
"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", "laptop-nuno": "94.63.0.129",
"phone-luis": "176.223.61.15", "phone-luis": "176.223.61.15",
"phone-helena-2": "148.69.202.234", "phone-helena-2": "148.69.202.127",
"desktop-zephyr": "86.120.152.74" "desktop-zephyr": "86.120.152.74"
} }

View file

@ -1,385 +0,0 @@
#!/usr/bin/env python3
import subprocess
import threading
import json
import logging
import os
import signal
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from scapy.all import IP, UDP, sniff
# ============================================
# Config
# ============================================
WATCHLIST_FILE = Path("/etc/wireguard/.wgctl/daemon/watchlist.json")
EVENTS_LOG = Path("/etc/wireguard/.wgctl/daemon/events.log")
WG_INTERFACE = os.environ.get("WG_INTERFACE", "eth0")
WG_PORT = int(os.environ.get("WG_PORT", "51820"))
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO")
WG_HANDSHAKE_CHECK_SEC = int(os.environ.get("WG_HANDSHAKE_CHECK_TIME_SEC", "300"))
WG_WG_INTERFACE = os.environ.get("WG_WG_INTERFACE", "wg0") # WireGuard interface, not capture interface
HS_CACHE_FILE = Path("/etc/wireguard/.wgctl/daemon/hs_cache.json")
ENDPOINT_CACHE_FILE = Path("/etc/wireguard/.wgctl/daemon/endpoint_cache.json")
PEER_HISTORY_DIR = Path("/etc/wireguard/.wgctl/peer-history")
ENDPOINT_INDEX_FILE = PEER_HISTORY_DIR / "endpoint_index.json"
# ============================================
# Logging
# ============================================
logging.basicConfig(
level=getattr(logging, LOG_LEVEL),
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[logging.StreamHandler(sys.stdout)]
)
log = logging.getLogger("wgctl-monitor")
# ============================================
# Watchlist
# ============================================
_watchlist: dict[str, str] = {}
_watchlist_mtime: float = 0.0
def load_watchlist() -> dict[str, str]:
global _watchlist, _watchlist_mtime
try:
mtime = WATCHLIST_FILE.stat().st_mtime
if mtime == _watchlist_mtime:
return _watchlist
with WATCHLIST_FILE.open() as f:
_watchlist = json.load(f)
_watchlist_mtime = mtime
log.debug(f"Watchlist reloaded: {len(_watchlist)} entries")
except Exception as e:
log.error(f"Failed to load watchlist: {e}")
return _watchlist
def is_watched(ip: str) -> str | None:
watchlist = load_watchlist()
return watchlist.get(ip)
# ============================================
# Endpoint Resolution
# ============================================
def get_endpoint(public_key: str) -> str | None:
try:
import subprocess
result = subprocess.run(
["wg", "show", WG_INTERFACE, "endpoints"],
capture_output=True, text=True
)
for line in result.stdout.splitlines():
parts = line.split()
if len(parts) == 2 and parts[0] == public_key:
# Return just the IP without port
return parts[1].rsplit(":", 1)[0]
except Exception as e:
log.debug(f"Failed to get endpoint: {e}")
return None
def get_client_public_key(client_name: str) -> str | None:
key_file = Path(f"/etc/wireguard/clients/{client_name}_public.key")
try:
return key_file.read_text().strip()
except Exception:
return None
# ============================================
# Event Logging
# ============================================
def log_event(ip: str, client: str, event: str, endpoint: str | None = None):
entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"ip": ip,
"client": client,
"event": event,
}
# Update endpoint cache when we see a packet
cache_file = ENDPOINT_CACHE_FILE
try:
with open(cache_file) as f:
cache = json.load(f)
except:
cache = {}
cache[client] = ip
with open(cache_file, 'w') as f:
json.dump(cache, f, indent=2)
if endpoint:
entry["endpoint"] = endpoint
try:
with EVENTS_LOG.open("a") as f:
f.write(json.dumps(entry) + "\n")
log.debug(f"Event logged: {entry}")
except Exception as e:
log.error(f"Failed to write event: {e}")
# ============================================
# Handshake Poller
# ============================================
# Tracks last logged handshake ts per pubkey
_hs_last_logged: dict[str, int] = {}
def load_hs_cache():
try:
with HS_CACHE_FILE.open() as f:
return {k: int(v) for k, v in json.load(f).items()}
except Exception:
return {}
def save_hs_cache(cache):
try:
with HS_CACHE_FILE.open('w') as f:
json.dump(cache, f)
except Exception:
pass
def build_pubkey_to_name() -> dict[str, str]:
"""Build pubkey -> client name map from public key files."""
mapping = {}
clients_dir = Path("/etc/wireguard/clients")
for kf in clients_dir.glob("*_public.key"):
name = kf.stem.replace("_public", "")
try:
mapping[kf.read_text().strip()] = name
except Exception:
pass
return mapping
def poll_handshakes():
"""
Poll wg show latest-handshakes periodically.
Log a handshake event only when gap > WG_HANDSHAKE_CHECK_SEC (new session).
"""
global _hs_last_logged, _endpoint_index
_hs_last_logged = load_hs_cache()
_endpoint_index = load_endpoint_index()
pubkey_to_name = build_pubkey_to_name()
log.info(f"Handshake poller started — {len(pubkey_to_name)} peers, "
f"session threshold {WG_HANDSHAKE_CHECK_SEC}s")
log.info(f"Endpoint index loaded — {len(_endpoint_index)} known endpoints")
while True:
try:
result = subprocess.run(
["wg", "show", WG_WG_INTERFACE, "latest-handshakes"],
capture_output=True, text=True
)
for line in result.stdout.strip().splitlines():
parts = line.split()
if len(parts) != 2:
continue
pubkey, ts_str = parts
try:
ts = int(ts_str)
except ValueError:
continue
if ts == 0:
continue
client = pubkey_to_name.get(pubkey)
if not client:
continue
last = _hs_last_logged.get(pubkey, 0)
gap = ts - last
# Always update last seen
_hs_last_logged[pubkey] = ts
# Get endpoint
endpoint = get_endpoint(pubkey) or ''
if not endpoint:
try:
cache = json.loads(ENDPOINT_CACHE_FILE.read_text())
endpoint = cache.get(client, '')
except Exception:
pass
# Always update peer history + index
if endpoint:
update_peer_history(client, endpoint, ts)
if gap < WG_HANDSHAKE_CHECK_SEC:
continue # keepalive, skip
# New session — log it
entry = {
"timestamp": datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(),
"ip": "",
"client": client,
"event": "handshake",
"endpoint": endpoint,
}
try:
with EVENTS_LOG.open("a") as f:
f.write(json.dumps(entry) + "\n")
log.info(f"New session: {client} from {endpoint}")
except Exception as e:
log.error(f"Failed to write handshake event: {e}")
log.debug(f"Gap for {client}: {gap}s (threshold: {WG_HANDSHAKE_CHECK_SEC}s)")
save_hs_cache(_hs_last_logged)
except Exception as e:
log.error(f"Handshake poll error: {e}")
time.sleep(WG_HANDSHAKE_CHECK_SEC // 2)
# ============================================
# Peer History
# ============================================
def load_endpoint_index() -> dict:
"""Load endpoint -> peer name index."""
try:
if ENDPOINT_INDEX_FILE.exists():
return json.loads(ENDPOINT_INDEX_FILE.read_text())
except Exception:
pass
return {}
def save_endpoint_index(index: dict):
"""Save endpoint -> peer name index."""
try:
PEER_HISTORY_DIR.mkdir(parents=True, exist_ok=True)
ENDPOINT_INDEX_FILE.write_text(json.dumps(index, indent=2))
except Exception as e:
log.error(f"Failed to save endpoint index: {e}")
# In-memory index — loaded once, updated on each new endpoint
_endpoint_index: dict = {}
def update_peer_history(client: str, endpoint: str, ts: int):
"""
Update peer endpoint history and endpoint index.
Called on every poll cycle to keep last_seen current.
"""
global _endpoint_index
if not endpoint:
return
try:
PEER_HISTORY_DIR.mkdir(parents=True, exist_ok=True)
history_file = PEER_HISTORY_DIR / f"{client}.json"
if history_file.exists():
try:
data = json.loads(history_file.read_text())
except Exception:
data = {"peer": client, "endpoints": {}}
else:
data = {"peer": client, "endpoints": {}}
ts_iso = datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
eps = data.setdefault("endpoints", {})
is_new = endpoint not in eps
if is_new:
eps[endpoint] = {
"first_seen": ts_iso,
"last_seen": ts_iso,
"count": 1
}
log.debug(f"New endpoint for {client}: {endpoint}")
# Update in-memory index and persist
_endpoint_index[endpoint] = client
save_endpoint_index(_endpoint_index)
else:
eps[endpoint]["last_seen"] = ts_iso
eps[endpoint]["count"] += 1
history_file.write_text(json.dumps(data, indent=2))
except Exception as e:
log.error(f"Failed to update peer history for {client}: {e}")
# ============================================
# Packet Handler
# ============================================
def handle_packet(pkt):
if not (IP in pkt and UDP in pkt):
return
# Only care about packets targeting WireGuard port
if pkt[UDP].dport != WG_PORT:
return
src_ip = pkt[IP].src
client = is_watched(src_ip)
if not client:
return
# Resolve real endpoint IP
public_key = get_client_public_key(client)
endpoint = None
if public_key:
endpoint = get_endpoint(public_key)
# If no endpoint from wg show, use packet source IP
if not endpoint:
endpoint = src_ip
log_event(src_ip, client, "attempt", endpoint)
log.info(f"Blocked attempt: {client} ({src_ip}) from endpoint {endpoint}")
# ============================================
# Signal Handling
# ============================================
def handle_signal(signum, frame):
log.info("Shutting down wgctl-monitor")
sys.exit(0)
signal.signal(signal.SIGTERM, handle_signal)
signal.signal(signal.SIGINT, handle_signal)
# ============================================
# Main
# ============================================
def main():
log.info(f"wgctl-monitor starting on interface {WG_INTERFACE} port {WG_PORT}")
if not WATCHLIST_FILE.exists():
log.error(f"Watchlist not found: {WATCHLIST_FILE}")
sys.exit(1)
load_watchlist()
log.info("Watchlist loaded, starting packet capture...")
# Start handshake poller in background thread
hs_thread = threading.Thread(target=poll_handshakes, daemon=True)
hs_thread.start()
sniff(
iface=WG_INTERFACE,
filter=f"udp port {WG_PORT}",
prn=handle_packet,
store=0
)
if __name__ == "__main__":
main()

View file

@ -4,7 +4,7 @@ After=network.target wg-quick@wg0.service
[Service] [Service]
Type=simple Type=simple
ExecStart=/usr/bin/python3 /etc/wireguard/wgctl/daemon/wgctl-monitor.py ExecStart=/usr/bin/python3 /etc/wireguard/.wgctl/daemon/wgctl-monitor.py
Restart=always Restart=always
RestartSec=5 RestartSec=5
Environment=WG_INTERFACE=eth0 Environment=WG_INTERFACE=eth0

View file

@ -8,30 +8,31 @@ function config::on_load() {
config::_init_defaults config::_init_defaults
config::load config::load
config::validate config::validate
fmt::set_date_format "${_FMT_DATE_FORMAT:-eu}" fmt::set_date_format "${_FMT_DATE_FORMAT:-iso}"
} }
# ============================================ # ============================================
# Defaults # Defaults
# ============================================ # ============================================
# Activity thresholds
declare -g _ACTIVITY_TOTAL_LOW_BYTES="${ACTIVITY_TOTAL_LOW_BYTES:-1000000}" declare -g _ACTIVITY_TOTAL_LOW_BYTES="${ACTIVITY_TOTAL_LOW_BYTES:-1000000}"
declare -g _ACTIVITY_TOTAL_MED_BYTES="${ACTIVITY_TOTAL_MED_BYTES:-10000000}" declare -g _ACTIVITY_TOTAL_MED_BYTES="${ACTIVITY_TOTAL_MED_BYTES:-10000000}"
declare -g _ACTIVITY_TOTAL_HIGH_BYTES="${ACTIVITY_TOTAL_HIGH_BYTES:-100000000}" declare -g _ACTIVITY_TOTAL_HIGH_BYTES="${ACTIVITY_TOTAL_HIGH_BYTES:-100000000}"
declare -g _ACTIVITY_CURRENT_LOW_BYTES="${ACTIVITY_CURRENT_LOW_BYTES:-1000000}" declare -g _ACTIVITY_CURRENT_LOW_BYTES="${ACTIVITY_CURRENT_LOW_BYTES:-1000000}"
declare -g _ACTIVITY_CURRENT_MED_BYTES="${ACTIVITY_CURRENT_MED_BYTES:-10000000}" declare -g _ACTIVITY_CURRENT_MED_BYTES="${ACTIVITY_CURRENT_MED_BYTES:-10000000}"
declare -g _ACTIVITY_CURRENT_HIGH_BYTES="${ACTIVITY_CURRENT_HIGH_BYTES:-100000000}" declare -g _ACTIVITY_CURRENT_HIGH_BYTES="${ACTIVITY_CURRENT_HIGH_BYTES:-100000000}"
function config::_init_defaults() { function config::_init_defaults() {
_WG_INTERFACE="wg0" _WG_INTERFACE="${WG_INTERFACE:-wg0}"
_WG_DNS="10.0.0.103" _WG_DNS="${WG_DNS:-10.0.0.103}"
_WG_DNS_FALLBACK="" _WG_LAN="${WG_LAN:-10.0.0.0/24}"
_WG_LAN="10.0.0.0/24" _WG_SUBNET="${WG_SUBNET:-10.1.0.0/16}"
_WG_SUBNET="10.1.0.0/16" _WG_PORT="${WG_PORT:-51820}"
_WG_PORT="51820" _WG_ENDPOINT="${WG_ENDPOINT:-}"
_WG_ENDPOINT="" _WG_HANDSHAKE_CHECK_TIME_SEC="${WG_HANDSHAKE_CHECK_TIME_SEC:-180}"
_WG_HANDSHAKE_CHECK_TIME_SEC="300"
_FMT_DATE_FORMAT="eu"
# Derived # Derived
_WG_CONFIG="$(ctx::wg)/${_WG_INTERFACE}.conf" _WG_CONFIG="$(ctx::wg)/${_WG_INTERFACE}.conf"
@ -42,89 +43,13 @@ function config::_init_defaults() {
} }
# ============================================ # ============================================
# Load from wgctl.json # Validation
# Falls back to wgctl.conf for migration period
# ============================================
function config::load() {
local json_conf
json_conf="$(ctx::config_file)"
if [[ -f "$json_conf" ]]; then
config::_load_json "$json_conf"
else
# Fallback: legacy wgctl.conf
local legacy_conf
legacy_conf="$(ctx::wgctl)/wgctl.conf"
[[ -f "$legacy_conf" ]] && config::_load_legacy "$legacy_conf"
fi
# Recompute derived values after overrides
_WG_CONFIG="$(ctx::wg)/${_WG_INTERFACE}.conf"
_WG_TUNNEL_SPLIT="${_WG_SUBNET}, ${_WG_LAN}"
}
function config::_load_json() {
local file="$1"
[[ ! -f "$file" ]] && return 0
while IFS='=' read -r key value; do
[[ -z "$key" ]] && continue
case "$key" in
WG_INTERFACE) _WG_INTERFACE="$value" ;;
WG_ENDPOINT) _WG_ENDPOINT="$value" ;;
WG_DNS) _WG_DNS="$value" ;;
WG_DNS_FALLBACK) _WG_DNS_FALLBACK="$value" ;;
WG_PORT) _WG_PORT="$value" ;;
WG_SUBNET) _WG_SUBNET="$value" ;;
WG_LAN) _WG_LAN="$value" ;;
WG_HANDSHAKE_CHECK_TIME_SEC) _WG_HANDSHAKE_CHECK_TIME_SEC="$value" ;;
DATE_FORMAT)
_FMT_DATE_FORMAT="$value"
fmt::set_date_format "$value"
;;
ACTIVITY_TOTAL_LOW_BYTES) _ACTIVITY_TOTAL_LOW_BYTES="$value" ;;
ACTIVITY_TOTAL_MED_BYTES) _ACTIVITY_TOTAL_MED_BYTES="$value" ;;
ACTIVITY_TOTAL_HIGH_BYTES) _ACTIVITY_TOTAL_HIGH_BYTES="$value" ;;
ACTIVITY_CURRENT_LOW_BYTES) _ACTIVITY_CURRENT_LOW_BYTES="$value" ;;
ACTIVITY_CURRENT_MED_BYTES) _ACTIVITY_CURRENT_MED_BYTES="$value" ;;
ACTIVITY_CURRENT_HIGH_BYTES) _ACTIVITY_CURRENT_HIGH_BYTES="$value" ;;
esac
done < <(json::config_load "$file" 2>/dev/null)
}
function config::_load_legacy() {
local conf_file="$1"
log::wg_warning "Using legacy wgctl.conf — run 'wgctl config migrate' to upgrade"
while IFS='=' read -r key value || [[ -n "$key" ]]; do
[[ "$key" =~ ^[[:space:]]*# ]] && continue
[[ -z "${key// }" ]] && continue
key="${key// /}"
value="${value// /}"
case "$key" in
WG_INTERFACE) _WG_INTERFACE="$value" ;;
WG_ENDPOINT) _WG_ENDPOINT="$value" ;;
WG_DNS) _WG_DNS="$value" ;;
WG_DNS_FALLBACK) _WG_DNS_FALLBACK="$value" ;;
WG_PORT) _WG_PORT="$value" ;;
WG_SUBNET) _WG_SUBNET="$value" ;;
WG_LAN) _WG_LAN="$value" ;;
WG_HANDSHAKE_CHECK_TIME_SEC) _WG_HANDSHAKE_CHECK_TIME_SEC="$value" ;;
DATE_FORMAT)
_FMT_DATE_FORMAT="$value"
fmt::set_date_format "$value"
;;
esac
done < "$conf_file"
}
# ============================================
# Validation (unchanged)
# ============================================ # ============================================
function config::validate() { function config::validate() {
local errors=() local errors=()
# Server key and config files
if [[ ! -f "$_WG_SERVER_PUBLIC_KEY_FILE" ]]; then if [[ ! -f "$_WG_SERVER_PUBLIC_KEY_FILE" ]]; then
errors+=("Server public key not found: ${_WG_SERVER_PUBLIC_KEY_FILE}") errors+=("Server public key not found: ${_WG_SERVER_PUBLIC_KEY_FILE}")
fi fi
@ -135,6 +60,7 @@ function config::validate() {
errors+=("WireGuard config not found: ${_WG_CONFIG}") errors+=("WireGuard config not found: ${_WG_CONFIG}")
fi fi
# Required config values
local endpoint local endpoint
endpoint=$(config::endpoint) endpoint=$(config::endpoint)
if [[ -z "$endpoint" ]]; then if [[ -z "$endpoint" ]]; then
@ -165,6 +91,7 @@ function config::validate() {
errors+=("WG_SUBNET is not set — required for IP allocation") errors+=("WG_SUBNET is not set — required for IP allocation")
fi fi
# Warn-only
local lan local lan
lan=$(config::lan) lan=$(config::lan)
if [[ -z "$lan" ]]; then if [[ -z "$lan" ]]; then
@ -176,7 +103,7 @@ function config::validate() {
for err in "${errors[@]}"; do for err in "${errors[@]}"; do
printf " ✗ %s\n" "$err" >&2 printf " ✗ %s\n" "$err" >&2
done done
printf "\n Edit %s to fix these issues.\n\n" "$(ctx::config_file)" >&2 printf "\n Edit /etc/wireguard/.wgctl/wgctl.conf to fix these issues.\n\n" >&2
return 1 return 1
fi fi
@ -184,14 +111,49 @@ function config::validate() {
} }
# ============================================ # ============================================
# Accessors (unchanged) # Load overrides from .wgctl/wgctl.conf
# ============================================
function config::load() {
local conf_file
conf_file="$(ctx::data)/wgctl.conf"
[[ ! -f "$conf_file" ]] && return 0
while IFS='=' read -r key value || [[ -n "$key" ]]; do
[[ "$key" =~ ^[[:space:]]*# ]] && continue
[[ -z "${key// }" ]] && continue
key="${key// /}"
value="${value// /}"
case "$key" in
WG_INTERFACE) _WG_INTERFACE="$value" ;;
WG_ENDPOINT) _WG_ENDPOINT="$value" ;;
WG_DNS) _WG_DNS="$value" ;;
WG_PORT) _WG_PORT="$value" ;;
WG_SUBNET) _WG_SUBNET="$value" ;;
WG_LAN) _WG_LAN="$value" ;;
WG_HANDSHAKE_CHECK_TIME_SEC) _WG_HANDSHAKE_CHECK_TIME_SEC="$value" ;;
ACTIVITY_LOW_BYTES) _ACTIVITY_LOW_BYTES="$value" ;;
ACTIVITY_MED_BYTES) _ACTIVITY_MED_BYTES="$value" ;;
ACTIVITY_HIGH_BYTES) _ACTIVITY_HIGH_BYTES="$value" ;;
DATE_FORMAT)
_FMT_DATE_FORMAT="$value"
fmt::set_date_format "$value"
;;
esac
done < "$conf_file"
# Recompute derived values after overrides
_WG_CONFIG="$(ctx::wg)/${_WG_INTERFACE}.conf"
_WG_TUNNEL_SPLIT="${_WG_SUBNET}, ${_WG_LAN}"
}
# ============================================
# Accessors
# ============================================ # ============================================
function config::interface() { echo "$_WG_INTERFACE"; } function config::interface() { echo "$_WG_INTERFACE"; }
function config::config_file() { echo "$_WG_CONFIG"; } function config::config_file() { echo "$_WG_CONFIG"; }
function config::endpoint() { echo "$_WG_ENDPOINT"; } function config::endpoint() { echo "$_WG_ENDPOINT"; }
function config::dns() { echo "$_WG_DNS"; } function config::dns() { echo "$_WG_DNS"; }
function config::dns_fallback() { echo "${_WG_DNS_FALLBACK:-}"; }
function config::port() { echo "$_WG_PORT"; } function config::port() { echo "$_WG_PORT"; }
function config::subnet() { echo "$_WG_SUBNET"; } function config::subnet() { echo "$_WG_SUBNET"; }
function config::lan() { echo "$_WG_LAN"; } function config::lan() { echo "$_WG_LAN"; }
@ -201,13 +163,13 @@ function config::handshake_time_sec() { echo "$_WG_HANDSHAKE_CHECK_TIME_SEC"
function config::activity_total_low() { echo "$_ACTIVITY_TOTAL_LOW_BYTES"; } function config::activity_total_low() { echo "$_ACTIVITY_TOTAL_LOW_BYTES"; }
function config::activity_total_med() { echo "$_ACTIVITY_TOTAL_MED_BYTES"; } function config::activity_total_med() { echo "$_ACTIVITY_TOTAL_MED_BYTES"; }
function config::activity_total_high() { echo "$_ACTIVITY_TOTAL_HIGH_BYTES"; } function config::activity_total_high() { echo "$_ACTIVITY_TOTAL_HIGH_BYTES"; }
function config::activity_current_low() { echo "$_ACTIVITY_CURRENT_LOW_BYTES"; } function config::activity_current_low() { echo "$_ACTIVITY_TOTAL_LOW_BYTES"; }
function config::activity_current_med() { echo "$_ACTIVITY_CURRENT_MED_BYTES"; } function config::activity_current_med() { echo "$_ACTIVITY_TOTAL_MED_BYTES"; }
function config::activity_current_high() { echo "$_ACTIVITY_CURRENT_HIGH_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::allowed_ips_for() { function config::allowed_ips_for() {
local tunnel="${1:-split}" local tunnel="${2:-split}"
case "$tunnel" in case "$tunnel" in
full) echo "$_WG_TUNNEL_FULL" ;; full) echo "$_WG_TUNNEL_FULL" ;;
split) echo "$_WG_TUNNEL_SPLIT" ;; split) echo "$_WG_TUNNEL_SPLIT" ;;
@ -217,13 +179,3 @@ function config::allowed_ips_for() {
;; ;;
esac esac
} }
function config::dns_string() {
local fallback
fallback=$(config::dns_fallback)
if [[ -n "$fallback" ]]; then
echo "$(config::dns), ${fallback}"
else
echo "$(config::dns)"
fi
}

View file

@ -1,72 +0,0 @@
#!/usr/bin/env bash
# modules/display.module.sh
# Display configuration — controls layout style per view
# ============================================
# State — loaded once on first access
# ============================================
_DISPLAY_LOADED=false
declare -gA _DISPLAY_STYLES=()
# ============================================
# Load display config
# ============================================
function display::_load() {
$_DISPLAY_LOADED && return 0
_DISPLAY_LOADED=true
local display_file
display_file="$(ctx::display)"
[[ ! -f "$display_file" ]] && return 0
# Load styles per view via json_helper
local view style
while IFS='=' read -r view style; do
[[ -n "$view" && -n "$style" ]] && _DISPLAY_STYLES["$view"]="$style"
done < <(python3 "$(ctx::json_helper)" display_load "$display_file" 2>/dev/null)
}
# ============================================
# Accessors
# ============================================
# display::style <view>
# Returns: compact | table | minimal (default: compact)
function display::style() {
local view="${1:-}"
display::_load
echo "${_DISPLAY_STYLES[$view]:-compact}"
}
# display::is_compact <view>
function display::is_compact() {
[[ "$(display::style "$1")" == "compact" ]]
}
# display::is_table <view>
function display::is_table() {
[[ "$(display::style "$1")" == "table" ]]
}
# ============================================
# display::render <view> <data> <compact_fn> <table_fn> [extra_args...]
# Generic dispatcher — calls compact or table render function
# ============================================
function display::render() {
local view="${1:-}" data="${2:-}" compact_fn="${3:-}" table_fn="${4:-}"
shift 4 || true
case "$(display::style "$view")" in
table)
declare -f "$table_fn" >/dev/null 2>&1 && \
"$table_fn" "$data" "$@" || \
"$compact_fn" "$data" "$@" # fallback to compact if no table fn
;;
*)
"$compact_fn" "$data" "$@"
;;
esac
}

View file

@ -1,22 +0,0 @@
#!/usr/bin/env bash
# hosts.module.sh — host resolution helpers
function hosts::exists() {
local entry_type="${1:-host}" key="${2:-}"
[[ "$(json::hosts_exists "$(ctx::hosts)" "$entry_type" "$key")" == "true" ]]
}
function hosts::require_exists() {
local entry_type="${1:-host}" key="${2:-}"
if ! hosts::exists "$entry_type" "$key"; then
log::error "${entry_type^} not found: ${key}"
return 1
fi
}
function hosts::resolve_ip() {
local ip="${1:-}"
[[ -z "$ip" ]] && return 0
[[ ! -f "$(ctx::hosts)" ]] && echo "" && return 0
json::hosts_lookup "$(ctx::hosts)" "$ip"
}

View file

@ -89,7 +89,7 @@ function monitor::cache_endpoint() {
} }
function monitor::get_cached_endpoint() { function monitor::get_cached_endpoint() {
local client="${1:-}" local client="$1"
json::get "$ENDPOINT_CACHE" "$client" json::get "$ENDPOINT_CACHE" "$client"
} }
@ -138,43 +138,3 @@ function monitor::restart() {
function monitor::is_running() { function monitor::is_running() {
systemctl is-active --quiet "$MONITOR_SERVICE" systemctl is-active --quiet "$MONITOR_SERVICE"
} }
function monitor::live() {
local filter_name="${1:-}" filter_type="${2:-}" filter_peers="${3:-}"
local blocked_only="${4:-false}" restricted_only="${5:-false}" allowed_only="${6:-false}"
local raw="${7:-false}"
[[ "$raw" == "true" ]] && _WGCTL_RAW=true
rm -f /tmp/wgctl_hs_* /tmp/wgctl_attempt_* 2>/dev/null || true
local w_client=20 w_dest=18
while IFS= read -r conf; do
local name
name=$(basename "$conf" .conf)
(( ${#name} > w_client )) && w_client=${#name}
done < <(find "$(ctx::clients)" -name "*.conf" 2>/dev/null)
(( w_client += 2 ))
if ! $blocked_only && ! $restricted_only; then
(
while true; do
cmd::watch::_poll_handshakes \
"$filter_name" "$filter_type" "$filter_peers" "$w_client" "$w_dest"
sleep 5
done
) &
local poller_pid=$!
fi
cmd::watch::_tail_events \
"$filter_name" "$filter_type" "$filter_peers" \
"$blocked_only" "$restricted_only" "$allowed_only" \
"$w_client" "$w_dest" &
local tailer_pid=$!
trap "kill $tailer_pid ${poller_pid:-} 2>/dev/null; \
rm -f /tmp/wgctl_hs_* /tmp/wgctl_attempt_*; printf '\n'; exit 0" INT TERM
wait
}

View file

@ -54,6 +54,21 @@ function net::annotate() {
[[ -n "$ann" ]] && echo "${ann}" || echo "" [[ -n "$ann" ]] && echo "${ann}" || echo ""
} }
# function net::print_entry() {
# local sign="${1:-}" entry="${2:-}" indent="${3:-6}"
# local ann
# ann=$(net::annotate "$entry")
# local color
# [[ "$sign" == "+" ]] && color="\033[0;32m" || color="\033[0;31m"
# local spaces
# spaces=$(printf '%*s' "$indent" '')
# printf "%s%b%s\033[0m %s\033[0;37m%s\033[0m\n" \
# "$spaces" "$color" "$sign" "$entry" "${ann:+ → ${ann}}"
# }
function net::print_entry() { function net::print_entry() {
local sign="${1:-}" entry="${2:-}" indent="${3:-6}" local sign="${1:-}" entry="${2:-}" indent="${3:-6}"
local ann local ann
@ -85,26 +100,3 @@ function net::print_dns_redirect_full() {
printf " \033[0;36m↺\033[0m Redirect all DNS → %s\033[0;37m%s\033[0m\n" \ printf " \033[0;36m↺\033[0m Redirect all DNS → %s\033[0;37m%s\033[0m\n" \
"$ip" "${ann:+ → ${ann}}" "$ip" "${ann:+ → ${ann}}"
} }
function net::resolve_display() {
local ip="${1:-}" port="${2:-}" proto="${3:-}"
[[ -z "$ip" ]] && return 0
# --raw flag bypass
[[ "${_WGCTL_RAW:-false}" == "true" ]] && echo "$ip" && return 0
# 1. hosts.json exact IP match
local host_name=""
if [[ -f "$(ctx::hosts)" ]]; then
host_name=$(hosts::lookup_ip "$ip")
fi
[[ -n "$host_name" ]] && echo "$host_name" && return 0
# 2. services.json match
local svc_name=""
svc_name=$(net::reverse_lookup "$ip" "$port" "$proto" 2>/dev/null) || true
[[ -n "$svc_name" ]] && echo "$svc_name" && return 0
# 3. Raw IP fallback
echo "$ip"
}

View file

@ -28,7 +28,7 @@ function peers::create_client_config() {
[Interface] [Interface]
PrivateKey = ${private_key} PrivateKey = ${private_key}
Address = ${ip}/32 Address = ${ip}/32
DNS = $(config::dns_string) DNS = $(config::dns)
[Peer] [Peer]
PublicKey = ${server_public_key} PublicKey = ${server_public_key}
@ -271,18 +271,6 @@ function peers::set_main_group() {
peers::set_meta "$name" "main_group" "$group" peers::set_meta "$name" "main_group" "$group"
} }
function peers::get_display_subnet() {
local peer_name="${1:-}" peer_type="${2:-}"
local subnet
subnet=$(peers::get_meta "$peer_name" "subnet" 2>/dev/null)
if [[ -z "$subnet" ]]; then
subnet=$(subnet::type_from_ip "$(peers::get_ip "$peer_name")" 2>/dev/null)
[[ -n "$peer_type" ]] && subnet="$peer_type"
fi
[[ -z "$subnet" ]] && subnet="-"
echo "$subnet"
}
# ============================================ # ============================================
# Name + Type Parsing # Name + Type Parsing
# ============================================ # ============================================
@ -538,24 +526,6 @@ function peers::format_activity_current() {
echo "${level} (↓${rx_hr}B/s ↑${tx_hr}B/s)" echo "${level} (↓${rx_hr}B/s ↑${tx_hr}B/s)"
} }
# ============================================
# dentity
# ============================================
function peers::get_identity() {
local peer_name="${1:-}"
local id_dir
id_dir="$(ctx::identities)"
for id_file in "${id_dir}"/*.identity; do
[[ -f "$id_file" ]] || continue
if json::get "$id_file" "peers" 2>/dev/null | grep -qx "$peer_name"; then
basename "$id_file" .identity
return 0
fi
done
return 1
}
# ============================================ # ============================================
# Helpers - Meta File # Helpers - Meta File
# ============================================ # ============================================

View file

@ -3,16 +3,52 @@
# Policies define behavioral flags for subnets, identities, and future contexts. # Policies define behavioral flags for subnets, identities, and future contexts.
# Chain: Subnet → Policy → Identity → Peer # Chain: Subnet → Policy → Identity → Peer
# ======================================================
# Hardcoded Fallbacks
# Mirror of policies.json built-in policies.
# Used when policies.json lookup fails.
# ======================================================
declare -gA _POLICY_TUNNEL_MODE=(
[default]="split"
[guest]="split"
[trusted]="split"
[server]="split"
[iot]="split"
)
declare -gA _POLICY_DEFAULT_RULE=(
[default]=""
[guest]="guest"
[trusted]=""
[server]=""
[iot]=""
)
declare -gA _POLICY_STRICT_RULE=(
[default]="false"
[guest]="true"
[trusted]="false"
[server]="false"
[iot]="false"
)
declare -gA _POLICY_AUTO_APPLY=(
[default]="true"
[guest]="true"
[trusted]="true"
[server]="true"
[iot]="true"
)
function policy::_hardcoded_field() { function policy::_hardcoded_field() {
local name="${1:-}" field="${2:-}" local name="${1:-}" field="${2:-}"
# Only fallback for 'default' policy if policies.json is unavailable
[[ "$name" != "default" ]] && echo "" && return 0
case "$field" in case "$field" in
tunnel_mode) echo "split" ;; tunnel_mode) echo "${_POLICY_TUNNEL_MODE[$name]:-split}" ;;
default_rule) echo "" ;; default_rule) echo "${_POLICY_DEFAULT_RULE[$name]:-}" ;;
strict_rule) echo "false" ;; strict_rule) echo "${_POLICY_STRICT_RULE[$name]:-false}" ;;
auto_apply) echo "true" ;; auto_apply) echo "${_POLICY_AUTO_APPLY[$name]:-true}" ;;
*) echo "" ;; *) echo "" ;;
esac esac
} }
@ -20,6 +56,8 @@ function policy::_hardcoded_field() {
# Core Accessors # Core Accessors
# ====================================================== # ======================================================
function ctx::policies() { echo "${_CTX_DATA}/policies.json"; }
function policy::exists() { function policy::exists() {
local name="${1:-}" local name="${1:-}"
json::policy_exists "$(ctx::policies)" "$name" 2>/dev/null json::policy_exists "$(ctx::policies)" "$name" 2>/dev/null

View file

@ -1,125 +0,0 @@
#!/usr/bin/env bash
# modules/resolve.module.sh — IP/host resolution chain
# Chains: hosts.json exact match → services.json match → raw IP
# Depends on: hosts.module.sh, net.module.sh
declare -gA _RESOLVE_CACHE=()
# resolve::ip <ip> [port] [proto]
# Resolves an IP to a display name using the full resolution chain.
# Returns raw IP if no match found.
# Respects _WGCTL_RAW=true to bypass resolution.
function resolve::ip() {
local ip="${1:-}" port="${2:-}" proto="${3:-}"
[[ -z "$ip" ]] && echo "" && return 0
[[ "${_WGCTL_RAW:-false}" == "true" ]] && echo "$ip" && return 0
local cache_key="${ip}:${port}:${proto}"
if [[ -z "${_RESOLVE_CACHE[$cache_key]+x}" ]]; then
local result=""
# 1. hosts.json exact IP match
if [[ -f "$(ctx::hosts)" ]]; then
result=$(hosts::resolve_ip "$ip")
fi
# 2. services.json match
if [[ -z "$result" ]]; then
result=$(net::reverse_lookup "$ip" "$port" "$proto" 2>/dev/null) || result=""
fi
# 3. Raw IP fallback
[[ -z "$result" ]] && result="$ip"
_RESOLVE_CACHE[$cache_key]="$result"
fi
echo "${_RESOLVE_CACHE[$cache_key]}"
}
# resolve::dest <ip> [port] [proto]
# Like resolve::ip but builds a formatted destination display string.
# e.g. "pihole:dns-udp" or "vodafone-wan" or "10.0.0.103:853/tcp"
function resolve::dest() {
local ip="${1:-}" port="${2:-}" proto="${3:-}"
[[ -z "$ip" ]] && echo "" && return 0
local name
name=$(resolve::ip "$ip" "$port" "$proto")
if [[ "$name" == "$ip" ]]; then
# No resolution — raw format
if [[ -n "$port" ]]; then
echo "${ip}:${port}/${proto}"
else
[[ -n "$proto" ]] && echo "${ip} (${proto})" || echo "$ip"
fi
else
# Resolved — just the name, no proto suffix
echo "$name"
fi
}
# resolve::service_name <ip> [port] [proto]
# Returns just the service/host name, empty string if no match (not raw IP).
# Use when you need to know IF something resolved, not what the raw fallback is.
function resolve::service_name() {
local ip="${1:-}" port="${2:-}" proto="${3:-}"
[[ -z "$ip" ]] && echo "" && return 0
[[ "${_WGCTL_RAW:-false}" == "true" ]] && echo "" && return 0
local result=""
# 1. hosts.json exact IP match
if [[ -f "$(ctx::hosts)" ]]; then
result=$(hosts::resolve_ip "$ip")
fi
# 2. services.json match
if [[ -z "$result" ]]; then
result=$(net::reverse_lookup "$ip" "$port" "$proto" 2>/dev/null) || result=""
fi
# Return empty if no match (caller handles raw fallback)
[[ "$result" == "$ip" ]] && result=""
echo "$result"
}
# resolve::endpoint_parts <endpoint_ip>
# Returns "raw_ip|resolved_name" — empty resolved if no match.
# Used by watch/logs rows to build "raw_ip → resolved" display.
function resolve::endpoint_parts() {
local ip="${1:-}"
[[ -z "$ip" ]] && echo "|" && return 0
[[ "${_WGCTL_RAW:-false}" == "true" ]] && echo "${ip}|" && return 0
local resolved=""
# 1. hosts.json exact IP match
[[ -f "$(ctx::hosts)" ]] && \
resolved=$(hosts::resolve_ip "$ip" 2>/dev/null || true)
# 2. services.json match
if [[ -z "$resolved" ]]; then
resolved=$(net::reverse_lookup "$ip" "" "" 2>/dev/null) || resolved=""
[[ "$resolved" == "$ip" ]] && resolved=""
fi
# 3. Peer history index — O(1) lookup
if [[ -z "$resolved" ]]; then
local history_dir
history_dir="$(ctx::data)/peer-history"
if [[ -d "$history_dir" ]]; then
resolved=$(json::peer_history_lookup "$ip" 2>/dev/null || true)
fi
fi
echo "${ip}|${resolved}"
}
# resolve::clear_cache
# Clears the resolution cache — call between commands if needed.
function resolve::clear_cache() {
_RESOLVE_CACHE=()
}

View file

@ -1,47 +0,0 @@
#!/usr/bin/env bash
# ui/activity.module.sh — rendering for wgctl activity
# ui::activity::peer_row <name_pad> <rx_pad> <tx_pad> <drops> <drop_word> <w_drops>
function ui::activity::peer_row() {
local name_pad="${1:-}" rx_pad="${2:-}" tx_pad="${3:-}" \
drops="${4:-0}" drop_word="${5:-drops}" w_drops="${6:-1}"
printf " \033[1m%s\033[0m \033[2m↓\033[0m%s \033[2m↑\033[0m%s %${w_drops}s %s\n" \
"$name_pad" "$rx_pad" "$tx_pad" "$drops" "$drop_word"
}
# ui::activity::service_row <dest_display> <drop_count> <drop_word> <drops_col> <w_drops>
function ui::activity::service_row() {
local dest_display="${1:-}" drop_count="${2:-0}" drop_word="${3:-drops}" \
drops_col="${4:-30}" w_drops="${5:-1}"
# Align drop count with peer drop column
# Service row visible prefix: " → " (6 visible) + ${#dest_display}
# But "→" is 3 bytes, 1 visible — arrow_prefix bytes = 8, visible = 6
local arrow_prefix=" → "
local prefix_bytes=${#arrow_prefix} # 8 bytes due to → being 3 bytes
local prefix_len=$(( prefix_bytes + ${#dest_display} ))
local pad_n=$(( drops_col - prefix_len ))
[[ $pad_n -lt 1 ]] && pad_n=1
printf " \033[2m→\033[0m %s%*s %${w_drops}s %s\n" \
"$dest_display" "$pad_n" "" "$drop_count" "$drop_word"
}
# Table versions (kept for future display config)
function ui::activity::header_table() {
printf "\n %-24s %-14s %-14s %s\n" "PEER" "↓ RX" "↑ TX" "DROPS"
printf " %s\n" "$(printf '─%.0s' {1..65})"
}
function ui::activity::peer_row_table() {
local name="${1:-}" rx_fmt="${2:-}" tx_fmt="${3:-}" \
drops="${4:-0}" drop_word="${5:-drops}"
printf " %-24s %-14s %-14s %s %s\n" \
"$name" "$rx_fmt" "$tx_fmt" "$drops" "$drop_word"
}
function ui::activity::service_row_table() {
local dest_display="${1:-}" drop_count="${2:-0}" drop_word="${3:-drops}"
printf " → %-30s %s %s\n" "$dest_display" "$drop_count" "$drop_word"
}

View file

@ -1,162 +0,0 @@
#!/usr/bin/env bash
# ui/group.module.sh — rendering for groups
# ======================================================
# List rendering
# ======================================================
# ui::group::list_row <name> <desc> <total> <blocked> <w_name> <w_desc>
function ui::group::list_row() {
local name="${1:-}" desc="${2:-}" total="${3:-0}" blocked="${4:-0}" \
w_name="${5:-16}" w_desc="${6:-30}"
local name_pad desc_val desc_pad_n
name_pad=$(printf "%-${w_name}s" "$name")
desc_val="${desc:--}"
desc_pad_n=$(( w_desc - ${#desc_val} ))
[[ $desc_pad_n -lt 0 ]] && desc_pad_n=0
# Peer count — dim if zero
local peers_word="peers"
[[ "$total" -eq 1 ]] && peers_word="peer"
local peers_display
if [[ "$total" -eq 0 ]]; then
peers_display="\033[2m0 ${peers_word}\033[0m"
else
peers_display="${total} ${peers_word}"
fi
local peers_pad
peers_pad=$(printf "%-10s" "${total} ${peers_word}")
# Status
local status_color status_str
IFS='|' read -r status_color status_str <<< "$(ui::group::status "$total" "$blocked")"
local peers_str="${total} ${peers_word}"
local peers_pad_n=$(( 10 - ${#peers_str} ))
[[ $peers_pad_n -lt 0 ]] && peers_pad_n=0
if [[ "$total" -eq 0 ]]; then
printf " \033[2m%s %s%*s %s%*s %s\033[0m\n" \
"$name_pad" "$desc_val" "$desc_pad_n" "" \
"$peers_str" "$peers_pad_n" "" "inactive"
else
printf " %s %s%*s %s%*s %b%s\033[0m\n" \
"$name_pad" "$desc_val" "$desc_pad_n" "" \
"$peers_str" "$peers_pad_n" "" \
"$status_color" "$status_str"
fi
}
# Table version (kept for future display config)
function ui::group::list_header_table() {
printf "\n %-20s %-35s %-8s %s\n" "NAME" "DESCRIPTION" "PEERS" "STATUS"
printf " %s\n" "$(printf '─%.0s' {1..75})"
}
function ui::group::list_row_table() {
local name="${1:-}" desc="${2:-}" total="${3:-0}" blocked="${4:-0}"
local status_color="" status_str="active"
if [[ "$total" -gt 0 ]]; then
if [[ "$blocked" -eq "$total" ]]; then
status_color="\033[1;31m"; status_str="blocked"
elif [[ "$blocked" -gt 0 ]]; then
status_color="\033[1;33m"; status_str="blocked (${blocked}/${total})"
else
status_color="\033[1;32m"; status_str="active"
fi
fi
printf " %-20s %-35s %-8s %b\n" \
"$name" "${desc:-}" "$total" \
"${status_color}${status_str}\033[0m"
}
# ======================================================
# Show rendering
# ======================================================
# ui::group::show_member_row <name> <ip> <rule> <is_blocked> <w_name> <w_ip>
function ui::group::show_member_row() {
local name="${1:-}" ip="${2:-}" rule="${3:--}" is_blocked="${4:-false}" \
w_name="${5:-22}" w_ip="${6:-14}"
local name_pad ip_pad rule_pad
name_pad=$(printf "%-${w_name}s" "$name")
ip_pad=$(printf "%-${w_ip}s" "$ip")
rule_pad=$(printf "%-12s" "${rule:--}")
local status_color status_str
if [[ "$is_blocked" == "true" ]]; then
status_color="\033[1;31m"; status_str="blocked"
else
status_color="\033[1;32m"; status_str="active"
fi
printf " %s %s \033[2mrule:\033[0m %s %b%s\033[0m\n" \
"$name_pad" "$ip_pad" "$rule_pad" \
"$status_color" "$status_str"
}
# Table version
function ui::group::show_header_table() {
printf "\n %-28s %-15s %-12s %s\n" "NAME" "IP" "RULE" "STATUS"
printf " %s\n" "$(printf '─%.0s' {1..65})"
}
function ui::group::show_member_row_table() {
local name="${1:-}" ip="${2:-}" rule="${3:--}" is_blocked="${4:-false}"
local status_color status_str
[[ "$is_blocked" == "true" ]] && { status_color="\033[1;31m"; status_str="blocked"; } \
|| { status_color="\033[1;32m"; status_str="active"; }
printf " %-28s %-15s %-12s %b\n" \
"$name" "$ip" "${rule:--}" "${status_color}${status_str}\033[0m"
}
# ui::group::show_peers <peers_list_nameref> <w_name> <w_ip>
function ui::group::show_peers() {
local -n _peers_list="$1"
local w_name="${2:-16}" w_ip="${3:-13}"
if [[ ${#_peers_list[@]} -eq 0 || -z "${_peers_list[0]:-}" ]]; then
printf " \033[2m—\033[0m\n"
return 0
fi
for peer_name in "${_peers_list[@]}"; do
[[ -z "$peer_name" ]] && continue
if ! peers::require_exists "$peer_name" > /dev/null 2>&1; then
printf " \033[2m%-${w_name}s (no longer exists)\033[0m\n" "$peer_name"
continue
fi
local ip rule is_blocked
ip=$(peers::get_ip "$peer_name")
rule=$(peers::get_meta "$peer_name" "rule")
peers::is_blocked "$peer_name" 2>/dev/null && is_blocked="true" || is_blocked="false"
ui::group::show_member_row "$peer_name" "$ip" "${rule:--}" \
"$is_blocked" "$w_name" "$w_ip"
done
}
# ======================================================
# Helpers
# ======================================================
# ui::group::status_color <total> <blocked>
# Returns color code and status string for a group
# Usage: IFS='|' read -r color str <<< "$(ui::group::status "$total" "$blocked")"
function ui::group::status() {
local total="${1:-0}" blocked="${2:-0}"
if [[ "$total" -eq 0 ]]; then
echo "\033[2;37m|inactive"
elif [[ "$blocked" -eq "$total" ]]; then
echo "\033[1;31m|blocked"
elif [[ "$blocked" -gt 0 ]]; then
echo "\033[1;33m|partial (${blocked}/${total})"
else
echo "\033[1;32m|active"
fi
}

View file

@ -1,44 +0,0 @@
#!/usr/bin/env bash
# ui/hosts.module.sh — rendering for hosts data
function ui::hosts::section_header() {
local type="${1:-}"
case "$type" in
host) printf " \033[0;37mHosts\033[0m\n" ;;
subnet) printf " \033[0;37mSubnets\033[0m\n" ;;
port) printf " \033[0;37mPorts\033[0m\n" ;;
esac
}
# ui::hosts::list_row <type> <key> <name> <desc> <tags> <w_key> <w_name> <w_desc>
function ui::hosts::list_row() {
local type="${1:-}" key="${2:-}" name="${3:-}" desc="${4:-}" tags="${5:-}" \
w_key="${6:-15}" w_name="${7:-16}" w_desc="${8:-10}"
local key_pad name_pad desc_val
key_pad=$(printf "%-${w_key}s" "$key")
name_pad=$(printf "%-${w_name}s" "$name")
desc_val="${desc:--}"
local desc_pad_n=$(( w_desc - ${#desc_val} ))
[[ $desc_pad_n -lt 0 ]] && desc_pad_n=0
local tags_display=""
[[ -n "$tags" ]] && tags_display="\033[2m[${tags//,/, }]\033[0m"
printf " %s %s %s%*s %b\n" \
"$key_pad" "$name_pad" "$desc_val" "$desc_pad_n" "" "$tags_display"
}
# Table version (kept for future display config)
function ui::hosts::list_row_table() {
local type="${1:-}" key="${2:-}" name="${3:-}" desc="${4:-}" tags="${5:-}"
printf " %-6s %-18s %-16s %-30s %s\n" \
"$type" "$key" "$name" "${desc:-}" "${tags:-}"
}
function ui::hosts::list_header_table() {
printf "\n %-6s %-18s %-16s %-30s %s\n" \
"TYPE" "KEY" "NAME" "DESCRIPTION" "TAGS"
printf " %s\n" "$(printf '─%.0s' {1..80})"
}

View file

@ -22,6 +22,15 @@ function ui::identity::detail_name() {
echo "" echo ""
} }
function ui::identity::device_row() {
local peer_name="${1:-}" dev_type="${2:-}" \
dev_index="${3:-1}" status="${4:-}"
local suffix=""
[[ "$dev_index" -gt 1 ]] && suffix=" (#${dev_index})"
printf " · %-24s %-10s%s%s\n" \
"$peer_name" "$dev_type" "$suffix" "$status"
}
function ui::identity::migrate_create() { function ui::identity::migrate_create() {
local peer_name="${1:-}" identity_name="${2:-}" \ local peer_name="${1:-}" identity_name="${2:-}" \
peer_type="${3:-}" index="${4:-}" peer_type="${3:-}" index="${4:-}"
@ -70,27 +79,3 @@ function ui::identity::list_row_table() {
[[ -z "$types_display" ]] && types_display="—" [[ -z "$types_display" ]] && types_display="—"
printf " %-20s %-7s %s\n" "$name" "$peer_count" "$types_display" printf " %-20s %-7s %s\n" "$name" "$peer_count" "$types_display"
} }
function ui::identity::device_row() {
local peer_name="${1:-}" dev_type="${2:-}" \
dev_index="${3:-1}" status="${4:-}"
# Extract connection state for dimming
local is_online=false
[[ "$status" == *"online"* ]] && is_online=true
# Only show index suffix if peer name doesn't already encode it
local suffix=""
[[ "$dev_index" -gt 1 && "$peer_name" != *"-${dev_index}" ]] && \
suffix=" (#${dev_index})"
if $is_online; then
printf " · %-24s %-10s%s%s\n" \
"$peer_name" "$dev_type" "$suffix" "$status"
else
local clean_status
clean_status=$(echo "$status" | sed 's/\x1b\[[0-9;]*m//g')
printf " · %-24s %-10s%s\033[2m%s\033[0m\n" \
"$peer_name" "$dev_type" "$suffix" "$clean_status"
fi
}

View file

@ -11,15 +11,13 @@ function ui::logs::build_dest() {
} }
function ui::logs::fw_section_header() { function ui::logs::fw_section_header() {
local cols=$(( $(tput cols 2>/dev/null || echo 80) - 4 ))
printf " Firewall Drops\n" printf " Firewall Drops\n"
printf " %s\n" "$(printf '─%.0s' $(seq 1 $cols))" printf " %s\n" "$(printf '─%.0s' {1..42})"
} }
function ui::logs::wg_section_header() { function ui::logs::wg_section_header() {
local cols=$(( $(tput cols 2>/dev/null || echo 80) - 4 ))
printf " WireGuard Events\n" printf " WireGuard Events\n"
printf " %s\n" "$(printf '─%.0s' $(seq 1 $cols))" printf " %s\n" "$(printf '─%.0s' {1..42})"
} }
function ui::logs::fw_section_header_table() { function ui::logs::fw_section_header_table() {
@ -37,86 +35,17 @@ function ui::logs::wg_section_header_table() {
function ui::logs::fw_row() { function ui::logs::fw_row() {
local ts="${1:-}" client="${2:-}" dest_ip="${3:-}" dest_port="${4:-}" \ local ts="${1:-}" client="${2:-}" dest_ip="${3:-}" dest_port="${4:-}" \
proto="${5:-}" svc_name="${6:-}" count="${7:-1}" \ proto="${5:-}" svc_name="${6:-}" count="${7:-1}" \
w_client="${8:-20}" w_dest="${9:-30}" \ w_client="${8:-20}" w_dest="${9:-30}"
src_endpoint="${10:-}" src_resolved="${11:-}" \ local dest_display
w_endpoint="${12:-0}" resolved_only="${13:-false}" dest_display=$(ui::logs::build_dest "$dest_ip" "$dest_port" "$proto" "$svc_name")
local ts_pad client_pad
ts_pad=$(printf "%-11s" "$ts")
client_pad=$(printf "%-${w_client}s" "$client")
# ── Source endpoint ──
# " → " = 5 bytes, 3 visible chars → overcount by _UI_ARROW_EXTRA=2
local src_padded=""
if [[ "$w_endpoint" -gt 0 ]]; then
local src_colored src_visible_len pad_n
if [[ -n "$src_endpoint" ]]; then
if $resolved_only; then
src_colored="${src_resolved:-$src_endpoint}"
src_visible_len=${#src_colored}
else
if [[ -n "$src_resolved" ]]; then
src_colored="${src_endpoint} \033[2m→ ${src_resolved}\033[0m"
# bytes: endpoint + " → " (5 bytes, 3 visible) + resolved
# visible: endpoint + 3 + resolved
src_visible_len=$(( ${#src_endpoint} + 3 + ${#src_resolved} ))
else
src_colored="${src_endpoint}"
src_visible_len=${#src_endpoint}
fi
fi
else
# — is 3 bytes, 1 visible char
src_colored="\033[2m—\033[0m"
src_visible_len=1
fi
pad_n=$(( w_endpoint - src_visible_len ))
[[ $pad_n -lt 0 ]] && pad_n=0
src_padded=$(printf "%b%*s" "$src_colored" "$pad_n" "")
fi
# ── Destination ──
local svc_display="" raw_suffix=""
if [[ -n "$svc_name" ]]; then
[[ -n "$dest_port" ]] && svc_display="${svc_name}/${proto}" \
|| svc_display="${svc_name} (${proto})"
if ! $resolved_only; then
[[ -n "$dest_port" ]] && raw_suffix=" \033[2m(${dest_ip}:${dest_port})\033[0m" \
|| raw_suffix=" \033[2m(${dest_ip})\033[0m"
fi
else
[[ -n "$dest_port" ]] && svc_display="${dest_ip}:${dest_port}/${proto}" \
|| svc_display="${dest_ip} (${proto})"
fi
local raw_plain=""
if ! $resolved_only; then
[[ -n "$svc_name" && -n "$dest_port" ]] && raw_plain=" (${dest_ip}:${dest_port})"
[[ -n "$svc_name" && -z "$dest_port" ]] && raw_plain=" (${dest_ip})"
fi
local full_dest_len=$(( ${#svc_display} + ${#raw_plain} ))
local dest_pad_n=$(( w_dest - full_dest_len ))
[[ $dest_pad_n -lt 0 ]] && dest_pad_n=0
local count_suffix="" local count_suffix=""
[[ "$count" -gt 1 ]] && count_suffix=" \033[2m(x${count})\033[0m" [[ "$count" -gt 1 ]] && count_suffix=" \033[2m(x${count})\033[0m"
local client_pad dest_pad_n
if [[ "$w_endpoint" -gt 0 ]]; then client_pad=$(printf "%-${w_client}s" "$client")
printf " %s %s %b \033[1;31m→\033[0m %s%b%*s%b\n" \ dest_pad_n=$(( w_dest - ${#dest_display} ))
"$ts_pad" "$client_pad" \ [[ $dest_pad_n -lt 0 ]] && dest_pad_n=0
"$src_padded" \ printf " %s %s \033[1;31m→\033[0m %s%*s%b\n" \
"$svc_display" "$raw_suffix" \ "$ts" "$client_pad" "$dest_display" "$dest_pad_n" "" "$count_suffix"
"$dest_pad_n" "" \
"$count_suffix"
else
printf " %s %s \033[1;31m→\033[0m %s%b%*s%b\n" \
"$ts_pad" "$client_pad" \
"$svc_display" "$raw_suffix" \
"$dest_pad_n" "" \
"$count_suffix"
fi
} }
function ui::logs::fw_row_table() { function ui::logs::fw_row_table() {
@ -126,61 +55,25 @@ function ui::logs::fw_row_table() {
printf " %-20s %-18s %-25s %s%s\n" "$ts" "$client" "$dst" "$proto" "$count_str" printf " %-20s %-18s %-25s %s%s\n" "$ts" "$client" "$dst" "$proto" "$count_str"
} }
# function ui::logs::wg_row() { function ui::logs::wg_row() {
# local ts="${1:-}" client="${2:-}" endpoint="${3:-}" event="${4:-}" \ local ts="${1:-}" client="${2:-}" endpoint="${3:-}" event="${4:-}" count="${5:-1}" \
# count="${5:-1}" w_client="${6:-20}" w_endpoint="${7:-20}" \ w_client="${6:-20}" w_endpoint="${7:-20}"
# gap_seconds="${8:-}" resolved="${9:-}" local event_color
case "$event" in
# local event_color handshake) event_color="\033[1;32m" ;;
# case "$event" in attempt) event_color="\033[1;31m" ;;
# handshake) event_color="\033[1;32m" ;; *) event_color="\033[0;37m" ;;
# attempt) event_color="\033[1;31m" ;; esac
# *) event_color="\033[0;37m" ;; local count_suffix=""
# esac [[ "$count" -gt 1 ]] && count_suffix=" \033[2m(x${count})\033[0m"
local client_pad endpoint_pad_n
# local count_suffix="" client_pad=$(printf "%-${w_client}s" "$client")
# [[ "$count" -gt 1 ]] && count_suffix=" \033[2m(x${count})\033[0m" endpoint_pad_n=$(( w_endpoint - ${#endpoint} ))
[[ $endpoint_pad_n -lt 0 ]] && endpoint_pad_n=0
# # Gap suffix — offline label only when gap > threshold * 2 printf " %s %s %s%*s %b%s\033[0m%b\n" \
# local gap_suffix="" "$ts" "$client_pad" "$endpoint" "$endpoint_pad_n" "" \
# if [[ "$event" == "handshake" && -n "$gap_seconds" && "$gap_seconds" -gt 0 ]]; then "$event_color" "$event" "$count_suffix"
# local gap_int="$gap_seconds" }
# local threshold="${WG_HANDSHAKE_CHECK_TIME_SEC:-300}"
# local offline_label=""
# [[ "$gap_int" -gt $(( threshold * 2 )) ]] && offline_label=" offline"
# if (( gap_int >= 3600 )); then
# gap_suffix=" \033[2m↑ $(( gap_int / 3600 ))h${offline_label}\033[0m"
# elif (( gap_int >= 60 )); then
# gap_suffix=" \033[2m↑ $(( gap_int / 60 ))m${offline_label}\033[0m"
# fi
# fi
# # ── Endpoint — native padding, no ui::pad_mb ──
# local endpoint_colored endpoint_visible_len
# local endpoint_raw="${endpoint:--}"
# if [[ -n "$resolved" && -n "$endpoint" ]]; then
# endpoint_colored="${endpoint} \033[2m→ ${resolved}\033[0m"
# endpoint_visible_len=$(( ${#endpoint} + 4 + ${#resolved} - _UI_ARROW_EXTRA ))
# else
# endpoint_colored="$endpoint_raw"
# # "-" is 1 char; endpoint may be empty
# [[ -n "$endpoint" ]] && endpoint_visible_len=${#endpoint} \
# || endpoint_visible_len=1
# fi
# local ep_pad_n=$(( w_endpoint - endpoint_visible_len ))
# [[ $ep_pad_n -lt 0 ]] && ep_pad_n=0
# local endpoint_padded=$(printf "%b%*s" "$endpoint_colored" "$ep_pad_n" "")
# local ts_pad client_pad
# ts_pad=$(printf "%-11s" "$ts")
# client_pad=$(printf "%-${w_client}s" "$client")
# printf " %s %s %b %b%s\033[0m%b%b\n" \
# "$ts_pad" "$client_pad" \
# "$endpoint_padded" \
# "$event_color" "$event" "$count_suffix" "$gap_suffix"
# }
function ui::logs::wg_row_table() { function ui::logs::wg_row_table() {
local ts="${1:-}" client="${2:-}" endpoint="${3:-}" event="${4:-}" count="${5:-1}" local ts="${1:-}" client="${2:-}" endpoint="${3:-}" event="${4:-}" count="${5:-1}"
@ -198,106 +91,56 @@ function ui::logs::wg_row_table() {
_UI_WATCH_FW_COLOR="\033[1;31m" _UI_WATCH_FW_COLOR="\033[1;31m"
_UI_WATCH_WG_COLOR="\033[1;32m" _UI_WATCH_WG_COLOR="\033[1;32m"
# function ui::watch::fw_row() { function ui::watch::fw_row() {
# local ts="${1:-}" client="${2:-}" dest_display="${3:-}" \ local ts="${1:-}" client="${2:-}" dest_display="${3:-}" \
# w_client="${4:-20}" w_dest="${5:-18}" w_client="${4:-20}" w_dest="${5:-18}"
# # "fw" is always 2 visible chars — no padding needed local ts_pad
# local src="${_UI_WATCH_FW_COLOR}fw\033[0m" ts_pad=$(printf "%-11s" "$ts")
# local ts_pad client_pad dest_pad_n local src
# ts_pad=$(printf "%-11s" "$ts") src=$(ui::pad_mb "${_UI_WATCH_FW_COLOR}fw\033[0m" 2)
# client_pad=$(printf "%-${w_client}s" "$client") local client_pad dest_pad_n
# dest_pad_n=$(( w_dest - ${#dest_display} )) client_pad=$(printf "%-${w_client}s" "$client")
# [[ $dest_pad_n -lt 0 ]] && dest_pad_n=0 dest_pad_n=$(( w_dest - ${#dest_display} ))
[[ $dest_pad_n -lt 0 ]] && dest_pad_n=0
# echo "DEBUG fw: ts_bytes=${#ts} src_bytes=${#src} client='$client'(${#client}) client_pad_bytes=${#client_pad}" >&2
printf " %s %b %s \033[1;31m→\033[0m %s%*s \033[1;31mdrop\033[0m\n" \
"$ts_pad" "$src" "$client_pad" "$dest_display" "$dest_pad_n" ""
}
# printf " %s %b %s \033[1;31m→\033[0m %s%*s \033[1;31mdrop\033[0m\n" \ function ui::watch::wg_row() {
# "$ts_pad" "$src" "$client_pad" "$dest_display" "$dest_pad_n" "" local ts="${1:-}" client="${2:-}" endpoint="${3:-}" event="${4:-}" \
# } w_client="${5:-20}" w_endpoint="${6:-18}"
local ts_pad
ts_pad=$(printf "%-11s" "$ts")
# function ui::watch::wg_row() { local event_color
# local ts="${1:-}" client="${2:-}" endpoint="${3:-}" event="${4:-}" \ case "$event" in
# w_client="${5:-20}" w_endpoint="${6:-18}" handshake) event_color="\033[1;32m" ;;
attempt) event_color="\033[1;31m" ;;
*) event_color="\033[0;37m" ;;
esac
local src
src=$(ui::pad_mb "${_UI_WATCH_WG_COLOR}wg\033[0m" 2)
# local event_color case "$event" in
# case "$event" in handshake) src="\033[1;32m" ;; # green
# handshake) event_color="\033[1;32m" ;; attempt) src="\033[1;31m" ;; # red
# attempt) event_color="\033[1;31m" ;; *) src="\033[0;37m" ;; # gray
# *) event_color="\033[0;37m" ;; esac
# esac local src_colored="${src}wg\033[0m"
# local endpoint_display="${endpoint:--}" local client_pad endpoint_pad_n
client_pad=$(printf "%-${w_client}s" "$client")
# local ts_pad client_pad ep_pad_n endpoint_pad_n=$(( w_endpoint - ${#endpoint} ))
# ts_pad=$(printf "%-11s" "$ts") [[ $endpoint_pad_n -lt 0 ]] && endpoint_pad_n=0
# client_pad=$(printf "%-${w_client}s" "$client") # echo "DEBUG wg: ts_bytes=${#ts} src_bytes=${#src} client='$client'(${#client}) client_pad_bytes=${#client_pad}" >&2
# ep_pad_n=$(( w_endpoint - ${#endpoint_display} )) printf " %s %b %s %s%*s %b%s\033[0m\n" \
# [[ $ep_pad_n -lt 0 ]] && ep_pad_n=0 "$ts_pad" "$src_colored" "$client_pad" "$endpoint" "$endpoint_pad_n" "" \
"$event_color" "$event"
# printf " %s %s %s%*s %b%s\033[0m\n" \ }
# "$ts_pad" "$client_pad" \
# "$endpoint_display" "$ep_pad_n" "" \
# "$event_color" "$event"
# }
# function ui::logs::wg_row() {
# local ts="${1:-}" client="${2:-}" endpoint="${3:-}" event="${4:-}" \
# count="${5:-1}" w_client="${6:-20}" w_endpoint="${7:-20}" \
# gap_seconds="${8:-}" resolved="${9:-}"
# local event_color
# case "$event" in
# handshake) event_color="\033[1;32m" ;;
# attempt) event_color="\033[1;31m" ;;
# *) event_color="\033[0;37m" ;;
# esac
# local count_suffix=""
# [[ "$count" -gt 1 ]] && count_suffix=" \033[2m(x${count})\033[0m"
# local gap_suffix=""
# if [[ "$event" == "handshake" && -n "$gap_seconds" && "$gap_seconds" -gt 0 ]]; then
# local gap_int="$gap_seconds"
# local threshold="${WG_HANDSHAKE_CHECK_TIME_SEC:-300}"
# local offline_label=""
# [[ "$gap_int" -gt $(( threshold * 2 )) ]] && offline_label=" offline"
# if (( gap_int >= 3600 )); then
# gap_suffix=" \033[2m↑ $(( gap_int / 3600 ))h${offline_label}\033[0m"
# elif (( gap_int >= 60 )); then
# gap_suffix=" \033[2m↑ $(( gap_int / 60 ))m${offline_label}\033[0m"
# fi
# fi
# # ── Endpoint padding ──
# # " → " = 5 bytes, 3 visible → visible = endpoint + 3 + resolved
# local endpoint_colored endpoint_visible_len
# if [[ -n "$resolved" && -n "$endpoint" ]]; then
# endpoint_colored="${endpoint} \033[2m→ ${resolved}\033[0m"
# endpoint_visible_len=$(( ${#endpoint} + 3 + ${#resolved} ))
# elif [[ -n "$endpoint" ]]; then
# endpoint_colored="${endpoint}"
# endpoint_visible_len=${#endpoint}
# else
# endpoint_colored="-"
# endpoint_visible_len=1
# fi
# local ep_pad_n=$(( w_endpoint - endpoint_visible_len ))
# [[ $ep_pad_n -lt 0 ]] && ep_pad_n=0
# local endpoint_padded
# endpoint_padded=$(printf "%b%*s" "$endpoint_colored" "$ep_pad_n" "")
# local ts_pad client_pad
# ts_pad=$(printf "%-11s" "$ts")
# client_pad=$(printf "%-${w_client}s" "$client")
# printf " %s %s %b %b%s\033[0m%b%b\n" \
# "$ts_pad" "$client_pad" \
# "$endpoint_padded" \
# "$event_color" "$event" "$count_suffix" "$gap_suffix"
# }
function ui::watch::header_table() { function ui::watch::header_table() {
printf "\n %-20s %-8s %-22s %-28s %-14s %s\n" \ printf "\n %-20s %-8s %-22s %-28s %-14s %s\n" \
@ -322,190 +165,3 @@ function ui::watch::wg_row_table() {
printf " %-20s %-8s %-22s %-28s %b%-14s\033[0m %s\n" \ printf " %-20s %-8s %-22s %-28s %b%-14s\033[0m %s\n" \
"$ts" "wireguard" "$client" "$endpoint" "$event_color" "$event" "$status" "$ts" "wireguard" "$client" "$endpoint" "$event_color" "$event" "$status"
} }
# ui::_render_endpoint_col
# Builds padded endpoint string: "raw_ip → resolved" or "raw_ip" or "-"
# Args: endpoint resolved w_endpoint
# Returns: padded colored string via stdout
function ui::_render_endpoint_col() {
local endpoint="${1:-}" resolved="${2:-}" w_endpoint="${3:-20}"
local colored visible_len pad_n
if [[ -n "$endpoint" ]]; then
if [[ -n "$resolved" ]]; then
colored="${endpoint} \033[2m→ ${resolved}\033[0m"
visible_len=$(( ${#endpoint} + 3 + ${#resolved} ))
else
colored="$endpoint"
visible_len=${#endpoint}
fi
else
colored="\033[2m—\033[0m"
visible_len=1
fi
pad_n=$(( w_endpoint - visible_len ))
[[ $pad_n -lt 0 ]] && pad_n=0
printf "%b%*s" "$colored" "$pad_n" ""
}
# ui::_render_dest_col
# Builds padded destination string: "svc/proto (ip:port)" or raw
# Args: dest_ip dest_port proto svc_name w_dest resolved_only
# Returns: two vars via nameref — dest_colored dest_pad_n
function ui::_build_dest() {
local dest_ip="${1:-}" dest_port="${2:-}" proto="${3:-}" \
svc_name="${4:-}" w_dest="${5:-20}" resolved_only="${6:-false}"
local svc_display raw_suffix="" raw_plain=""
if [[ -n "$svc_name" ]]; then
[[ -n "$dest_port" ]] && svc_display="${svc_name}/${proto}" \
|| svc_display="${svc_name} (${proto})"
if ! $resolved_only; then
[[ -n "$dest_port" ]] && raw_suffix=" \033[2m(${dest_ip}:${dest_port})\033[0m" \
|| raw_suffix=" \033[2m(${dest_ip})\033[0m"
[[ -n "$dest_port" ]] && raw_plain=" (${dest_ip}:${dest_port})" \
|| raw_plain=" (${dest_ip})"
fi
else
[[ -n "$dest_port" ]] && svc_display="${dest_ip}:${dest_port}/${proto}" \
|| svc_display="${dest_ip} (${proto})"
fi
local full_len=$(( ${#svc_display} + ${#raw_plain} ))
local dest_pad_n=$(( w_dest - full_len ))
[[ $dest_pad_n -lt 0 ]] && dest_pad_n=0
# Output: svc_display|raw_suffix|dest_pad_n
printf "%s\t%s\t%s" "$svc_display" "$raw_suffix" "$dest_pad_n"
}
function ui::logs::fw_row() {
local ts="${1:-}" client="${2:-}" dest_ip="${3:-}" dest_port="${4:-}" \
proto="${5:-}" svc_name="${6:-}" count="${7:-1}" \
w_client="${8:-20}" w_dest="${9:-30}" \
src_endpoint="${10:-}" src_resolved="${11:-}" \
w_endpoint="${12:-0}" resolved_only="${13:-false}"
local ts_pad client_pad
ts_pad=$(printf "%-11s" "$ts")
client_pad=$(printf "%-${w_client}s" "$client")
local count_suffix=""
[[ "$count" -gt 1 ]] && count_suffix=" \033[2m(x${count})\033[0m"
# Dest
local dest_parts
dest_parts=$(ui::_build_dest "$dest_ip" "$dest_port" "$proto" "$svc_name" "$w_dest" "$resolved_only")
local svc_display raw_suffix dest_pad_n
IFS=$'\t' read -r svc_display raw_suffix dest_pad_n <<< "$dest_parts"
if [[ "$w_endpoint" -gt 0 ]]; then
local src_padded
src_padded=$(ui::_render_endpoint_col "$src_endpoint" "$src_resolved" "$w_endpoint")
printf " %s %s %b \033[1;31m→\033[0m %s%b%*s%b\n" \
"$ts_pad" "$client_pad" "$src_padded" \
"$svc_display" "$raw_suffix" "$dest_pad_n" "" "$count_suffix"
else
printf " %s %s \033[1;31m→\033[0m %s%b%*s%b\n" \
"$ts_pad" "$client_pad" \
"$svc_display" "$raw_suffix" "$dest_pad_n" "" "$count_suffix"
fi
}
function ui::logs::wg_row() {
local ts="${1:-}" client="${2:-}" endpoint="${3:-}" event="${4:-}" \
count="${5:-1}" w_client="${6:-20}" w_endpoint="${7:-20}" \
gap_seconds="${8:-}" resolved="${9:-}"
local event_color
case "$event" in
handshake) event_color="\033[1;32m" ;;
attempt) event_color="\033[1;31m" ;;
*) event_color="\033[0;37m" ;;
esac
local count_suffix=""
[[ "$count" -gt 1 ]] && count_suffix=" \033[2m(x${count})\033[0m"
local gap_suffix=""
if [[ "$event" == "handshake" && -n "$gap_seconds" && "$gap_seconds" -gt 0 ]]; then
local gap_int="$gap_seconds"
local threshold="${WG_HANDSHAKE_CHECK_TIME_SEC:-300}"
local offline_label=""
[[ "$gap_int" -gt $(( threshold * 2 )) ]] && offline_label=" offline"
if (( gap_int >= 3600 )); then
gap_suffix=" \033[2m↑ $(( gap_int / 3600 ))h${offline_label}\033[0m"
elif (( gap_int >= 60 )); then
gap_suffix=" \033[2m↑ $(( gap_int / 60 ))m${offline_label}\033[0m"
fi
fi
local ep_padded
ep_padded=$(ui::_render_endpoint_col "$endpoint" "$resolved" "$w_endpoint")
local ts_pad client_pad
ts_pad=$(printf "%-11s" "$ts")
client_pad=$(printf "%-${w_client}s" "$client")
printf " %s %s %b %b%s\033[0m%b%b\n" \
"$ts_pad" "$client_pad" "$ep_padded" \
"$event_color" "$event" "$count_suffix" "$gap_suffix"
}
function ui::watch::fw_row() {
local ts="${1:-}" client="${2:-}" dest_ip="${3:-}" dest_port="${4:-}" \
proto="${5:-}" svc_name="${6:-}" \
src_endpoint="${7:-}" src_resolved="${8:-}" \
w_client="${9:-20}" w_dest="${10:-18}" w_endpoint="${11:-0}"
local ts_pad client_pad
ts_pad=$(printf "%-11s" "$ts")
client_pad=$(printf "%-${w_client}s" "$client")
local dest_parts
dest_parts=$(ui::_build_dest "$dest_ip" "$dest_port" "$proto" "$svc_name" "$w_dest" "false")
local svc_display raw_suffix dest_pad_n
IFS=$'\t' read -r svc_display raw_suffix dest_pad_n <<< "$dest_parts"
if [[ "$w_endpoint" -gt 0 ]]; then
local src_padded
src_padded=$(ui::_render_endpoint_col "$src_endpoint" "$src_resolved" "$w_endpoint")
printf " %s %s %s \033[1;31m→\033[0m %s%b%*s \033[1;31mdrop\033[0m\n" \
"$ts_pad" "$client_pad" "$src_padded" \
"$svc_display" "$raw_suffix" "$dest_pad_n" ""
else
printf " %s %s \033[1;31m→\033[0m %s%b%*s \033[1;31mdrop\033[0m\n" \
"$ts_pad" "$client_pad" \
"$svc_display" "$raw_suffix" "$dest_pad_n" ""
fi
}
function ui::watch::wg_row() {
local ts="${1:-}" client="${2:-}" endpoint="${3:-}" event="${4:-}" \
src_resolved="${5:-}" \
w_client="${6:-20}" w_endpoint="${7:-18}"
local event_color
case "$event" in
handshake) event_color="\033[1;32m" ;;
attempt) event_color="\033[1;31m" ;;
*) event_color="\033[0;37m" ;;
esac
local ep_padded
ep_padded=$(ui::_render_endpoint_col "$endpoint" "$src_resolved" "$w_endpoint")
local ts_pad client_pad
ts_pad=$(printf "%-11s" "$ts")
client_pad=$(printf "%-${w_client}s" "$client")
# echo "DEBUG: ts='$ts_pad' client='$client_pad'(${#client_pad}) ep='$ep_padded'(${#ep_padded}) event='$event'" >&2
printf " %s %s %s %b%s\033[0m\n" \
"$ts_pad" "$client_pad" "$ep_padded" \
"$event_color" "$event"
}

View file

@ -1,59 +0,0 @@
#!/usr/bin/env bash
# ui/net.module.sh — rendering for network services
# ======================================================
# List rendering
# ======================================================
# ui::net::list_row <name> <ip> <desc> <tags> <ports_display> <w_name> <w_ip> <w_ports>
function ui::net::list_row() {
local name="${1:-}" ip="${2:-}" desc="${3:-}" tags="${4:-}" ports="${5:-}" \
w_name="${6:-16}" w_ip="${7:-14}" w_ports="${8:-20}"
local name_pad ip_pad
name_pad=$(printf "%-${w_name}s" "$name")
ip_pad=$(printf "%-${w_ip}s" "$ip")
local ports_pad_n=$(( w_ports - ${#ports} ))
[[ $ports_pad_n -lt 0 ]] && ports_pad_n=0
local tags_display=""
[[ -n "$tags" ]] && tags_display=" \033[2m[${tags}]\033[0m"
printf " %s %s %s%*s %s%b\n" \
"$name_pad" "$ip_pad" "$ports" "$ports_pad_n" "" \
"${desc:--}" "$tags_display"
}
# Table version (kept for future display config)
function ui::net::list_header_table() {
printf "\n %-20s %-16s %-6s %s\n" "NAME" "IP" "PORTS" "DESCRIPTION"
printf " %s\n" "$(printf '─%.0s' {1..72})"
}
function ui::net::list_row_table() {
local name="${1:-}" ip="${2:-}" desc="${3:-}" tags="${4:-}" port_count="${5:-}"
local tag_display=""
[[ -n "$tags" ]] && tag_display=" \033[0;37m[${tags}]\033[0m"
printf " %-20s %-16s %-6s %s%b\n" \
"$name" "$ip" "${port_count}p" "${desc:-}" "$tag_display"
}
# ======================================================
# Show rendering
# ======================================================
# ui::net::show_port_row <port_name> <port> <proto> <desc>
function ui::net::show_port_row() {
local port_name="${1:-}" port="${2:-}" proto="${3:-}" desc="${4:-}"
local port_display=":${port}/${proto}"
local port_pad
port_pad=$(printf "%-14s" "$port_display")
if [[ -n "$desc" ]]; then
printf " %s \033[2m→\033[0m %-16s \033[2m# %s\033[0m\n" \
"$port_pad" "$port_name" "$desc"
else
printf " %s \033[2m→\033[0m %s\n" \
"$port_pad" "$port_name"
fi
}

View file

@ -167,48 +167,20 @@ function ui::rule::tree() {
# ui::rule::identity_block <identity_name> <strict_rule> # ui::rule::identity_block <identity_name> <strict_rule>
# Renders the full identity rule block in inspect. # Renders the full identity rule block in inspect.
function ui::rule::identity_block() { function ui::rule::identity_block() {
local identity_name="${1:-}" strict="${2:-false}" no_header=false local identity_name="${1:-}" strict="${2:-false}"
# Parse optional flags
shift 2 || true
while [[ $# -gt 0 ]]; do
case "$1" in
--no-header) no_header=true; shift ;;
*) shift ;;
esac
done
local rules local rules
rules=$(identity::rules "$identity_name") rules=$(identity::rules "$identity_name")
[[ -z "$rules" ]] && return 0 [[ -z "$rules" ]] && return 0
if ! $no_header; then printf "\n \033[0;37m· identity:%s\033[0m\n" "$identity_name"
printf "\n \033[0;37m· identity:%s\033[0m\n" "$identity_name"
fi
# Indentation levels:
# normal: entry=6 label=6 own=10 own_label=8
# no-header: entry=4 label=4 own=8 own_label=6
local entry_indent label_indent own_indent own_label_indent
if $no_header; then
entry_indent=4
label_indent=4
own_indent=8
own_label_indent=6
else
entry_indent=6
label_indent=6
own_indent=10
own_label_indent=8
fi
local first=true local first=true
while IFS= read -r rule_name; do while IFS= read -r rule_name; do
[[ -z "$rule_name" ]] && continue [[ -z "$rule_name" ]] && continue
$first || printf "\n" $first || printf "\n"
first=false first=false
ui::rule::_identity_rule_entry "$rule_name" \ ui::rule::_identity_rule_entry "$rule_name"
"$entry_indent" "$label_indent" "$own_indent" "$own_label_indent"
done <<< "$rules" done <<< "$rules"
if [[ "$strict" == "true" ]]; then if [[ "$strict" == "true" ]]; then
@ -220,41 +192,30 @@ function ui::rule::identity_block() {
# Renders one rule within an identity block. # Renders one rule within an identity block.
function ui::rule::_identity_rule_entry() { function ui::rule::_identity_rule_entry() {
local rule_name="${1:-}" local rule_name="${1:-}"
local entry_indent="${2:-6}"
local label_indent="${3:-6}"
local own_indent="${4:-10}"
local own_label_indent="${5:-8}"
local rule_file local rule_file
rule_file="$(rule::path "$rule_name")" || return 0 rule_file="$(rule::path "$rule_name")" || return 0
local label_pad printf " \033[0;37m↳ %s\033[0m\n" "$rule_name"
label_pad=$(printf '%*s' "$label_indent" '')
printf "%s\033[0;37m↳ %s\033[0m\n" "$label_pad" "$rule_name"
local extends_raw=() local extends_raw=()
mapfile -t extends_raw < <(json::get "$rule_file" "extends" 2>/dev/null || true) || true mapfile -t extends_raw < <(json::get "$rule_file" "extends" 2>/dev/null || true) || true
if [[ ${#extends_raw[@]} -gt 0 && -n "${extends_raw[0]:-}" ]]; then if [[ ${#extends_raw[@]} -gt 0 && -n "${extends_raw[0]:-}" ]]; then
ui::rule::_render_bases extends_raw "$own_indent" "$(( entry_indent + 2 ))" ui::rule::_render_bases extends_raw 10 8
local own_output local own_output
own_output=$(ui::rule::own_entries "$rule_name" "$own_indent") own_output=$(ui::rule::own_entries "$rule_name" 10)
if [[ -n "$own_output" ]]; then if [[ -n "$own_output" ]]; then
local own_pad printf "\n \033[0;37mOwn:\033[0m\n"
own_pad=$(printf '%*s' "$(( entry_indent + 2 ))" '')
printf "\n%s\033[0;37mOwn:\033[0m\n" "$own_pad"
printf "%s\n" "$own_output" printf "%s\n" "$own_output"
fi fi
else else
local own_output local own_output
own_output=$(ui::rule::own_entries "$rule_name" "$entry_indent") own_output=$(ui::rule::own_entries "$rule_name" 8)
if [[ -n "$own_output" ]]; then if [[ -n "$own_output" ]]; then
printf "%s\n" "$own_output" printf "%s\n" "$own_output"
else else
local full_pad printf " \033[2mfull access (no restrictions)\033[0m\n"
full_pad=$(printf '%*s' "$(( entry_indent + 2 ))" '')
printf "%s\033[2mfull access (no restrictions)\033[0m\n" "$full_pad"
fi fi
fi fi
} }
@ -331,23 +292,20 @@ function ui::rule::list_row() {
extends_indicator=" \033[2m↳ ${extends_display}\033[0m" extends_indicator=" \033[2m↳ ${extends_display}\033[0m"
fi fi
# Allows — green +N, padded to 5 visible chars # Allows column — green +N if >0, dim +0 if zero
# Append spaces after reset code so printf doesn't miscount
local allows_str local allows_str
if [[ "$n_allows" -gt 0 ]]; then if [[ "$n_allows" -gt 0 ]]; then
printf -v allows_str "\033[1;32m+%s\033[0m" "$n_allows" allows_str=$(ui::pad_mb "\033[1;32m+${n_allows}\033[0m" 5)
allows_str="${allows_str}$(printf '%*s' "$(( 4 - ${#n_allows} ))" '')"
else else
printf -v allows_str "\033[2m+0\033[0m " # "+0" = 2 visible + 3 spaces = 5 allows_str=$(ui::pad_mb "\033[2m+0\033[0m" 5)
fi fi
# Blocks — red -N, padded to 5 visible chars # Blocks column — red -N if >0, dim -0 if zero
local blocks_str local blocks_str
if [[ "$n_blocks" -gt 0 ]]; then if [[ "$n_blocks" -gt 0 ]]; then
printf -v blocks_str "\033[1;31m-%s\033[0m" "$n_blocks" blocks_str=$(ui::pad_mb "\033[1;31m-${n_blocks}\033[0m" 5)
blocks_str="${blocks_str}$(printf '%*s' "$(( 4 - ${#n_blocks} ))" '')"
else else
printf -v blocks_str "\033[2m-0\033[0m " # "-0" = 2 visible + 3 spaces = 5 blocks_str=$(ui::pad_mb "\033[2m-0\033[0m" 5)
fi fi
# Peers — dim if zero # Peers — dim if zero

4
wgctl
View file

@ -11,7 +11,6 @@ LOG_LEVEL=DEBUG
load_module ip load_module ip
load_module ui load_module ui
load_module display
load_module config load_module config
load_module keys load_module keys
load_module peers load_module peers
@ -23,9 +22,6 @@ load_module net
load_module group load_module group
load_module subnet load_module subnet
load_module identity load_module identity
load_module policy
load_module hosts
load_module resolve
# ============================================ # ============================================
# Alias Map # Alias Map