Compare commits

..

No commits in common. "master" and "feature/activity-monitor" have entirely different histories.

87 changed files with 2973 additions and 10287 deletions

View file

@ -6,8 +6,6 @@
# ============================================
function cmd::activity::on_load() {
command::mixin json_output
load_module net
flag::register --peer
@ -15,14 +13,7 @@ function cmd::activity::on_load() {
flag::register --ip
flag::register --hours
flag::register --type
flag::register --accept
flag::register --drop
flag::register --external
flag::register --ports
flag::register --exclude-service
flag::register --include-service
flag::exclusive --accept --drop
flag::register --dropped
}
# ============================================
@ -42,12 +33,11 @@ Options:
--ip <ip> Filter by destination IP
--hours <n> Time window in hours (default: 24, 0 = all time)
--type <type> Filter by device type (combined with --peer)
--accept Show only accepted traffic (from conntrack)
--drop Show only firewall drops
--external Show only external traffic (full tunnel peers)
--dropped Show only peers with at least one drop
Examples:
wgctl activity
wgctl activity --dropped
wgctl activity --peer phone-nuno
wgctl activity --service truenas
wgctl activity --hours 0
@ -61,9 +51,7 @@ EOF
function cmd::activity::run() {
local filter_peer="" filter_service="" filter_ip="" filter_type=""
local hours=24
local accept_only=false drop_only=false external_only=false show_ports=false
local -a exclude_services=() include_services=()
local hours=24 dropped_only=false
while [[ $# -gt 0 ]]; do
case "$1" in
@ -72,12 +60,7 @@ function cmd::activity::run() {
--ip) filter_ip="$2"; shift 2 ;;
--type) filter_type="$2"; shift 2 ;;
--hours) hours="$2"; shift 2 ;;
--accept) accept_only=true; shift ;;
--drop) drop_only=true; shift ;;
--external) external_only=true; shift ;;
--ports) show_ports=true; shift ;;
--exclude-service) exclude_services+=("$2"); shift 2 ;;
--include-service) include_services+=("$2"); shift 2 ;;
--dropped) dropped_only=true; shift ;;
--help) cmd::activity::help; return ;;
*)
log::error "Unknown flag: $1"
@ -87,87 +70,42 @@ function cmd::activity::run() {
esac
done
if command::json; then
cmd::activity::_output_json "$hours"
return 0
fi
# Resolve peer name if type provided
if [[ -n "$filter_peer" && -n "$filter_type" ]]; then
filter_peer=$(peers::resolve_and_require "$filter_peer" "$filter_type") || return 1
fi
# Resolve --service to IP
local service_ip=""
if [[ -n "$filter_service" ]]; then
service_ip=$(net::resolve "$filter_service" 2>/dev/null | head -1 | cut -d: -f1) || true
[[ -z "$service_ip" ]] && log::error "Service not found: ${filter_service}" && return 1
if [[ -z "$service_ip" ]]; then
log::error "Service not found: ${filter_service}"
return 1
fi
fi
[[ -n "$filter_ip" ]] && service_ip="$filter_ip"
# Build final exclusion list — remove any --include-service entries
local -a final_excludes=()
for svc in "${exclude_services[@]:-}"; do
local included=false
for inc in "${include_services[@]:-}"; do
[[ "$svc" == "$inc" ]] && included=true && break
done
$included || final_excludes+=("$svc")
done
# Build exclude string for Python (space-separated)
local exclude_str=""
[[ ${#final_excludes[@]} -gt 0 ]] && \
exclude_str=$(IFS=' '; echo "${final_excludes[*]}")
# ── Fetch data ──
local data=""
if ! $accept_only; then
# Fetch aggregated data
local data
data=$(json::activity_aggregate \
"$(ctx::fw_events_log)" "$(ctx::events_log)" \
"$(config::interface)" "$(ctx::net)" \
"$(ctx::clients)" "$(ctx::meta)" \
"$hours" "$filter_peer" "$service_ip" "$exclude_str" 2>/dev/null)
"$(ctx::fw_events_log)" \
"$(ctx::events_log)" \
"$(config::interface)" \
"$(ctx::net)" \
"$(ctx::clients)" \
"$(ctx::meta)" \
"$hours" \
"$filter_peer" \
"$service_ip" 2>/dev/null)
if [[ -z "$data" ]]; then
log::wg_warning "No activity data found"
return 0
fi
local accept_data=""
if ! $drop_only; then
local since_arg="" ext_flag="0"
[[ "$hours" -gt 0 ]] && since_arg="${hours}h"
$external_only && ext_flag="1"
[[ -f "$(ctx::accept_events_log)" ]] && \
accept_data=$(json::accept_aggregate \
"$(ctx::accept_events_log)" "$(ctx::net)" "$(ctx::clients)" \
"$since_arg" "$filter_peer" "$ext_flag" "$exclude_str" 2>/dev/null)
fi
[[ -z "$data" && -z "$accept_data" ]] && \
log::wg_warning "No activity data found" && return 0
# ── Build accept maps ──
declare -gA _ACCEPT_PEER=()
declare -gA _ACCEPT_DEST_KEYS=()
declare -gA _ACCEPT_DEST=()
while IFS='|' read -r type rest; do
[[ -z "$type" ]] && continue
case "$type" in
peer)
local a_name a_bi a_bo a_pi a_po a_conns
IFS='|' read -r a_name a_bi a_bo a_pi a_po a_conns <<< "$rest"
_ACCEPT_PEER["$a_name"]="${a_bi}|${a_bo}|${a_pi}|${a_po}|${a_conns}"
;;
dest)
local d_peer d_ip d_port d_proto d_bytes_orig d_bytes_reply d_count
IFS='|' read -r d_peer d_ip d_port d_proto d_bytes_orig d_bytes_reply d_count <<< "$rest"
local d_key="${d_peer}:${d_ip}:${d_port}:${d_proto}"
_ACCEPT_DEST["$d_key"]="${d_bytes_orig}|${d_bytes_reply}|${d_count}"
_ACCEPT_DEST_KEYS["$d_peer"]+="${d_key} "
;;
esac
done <<< "$accept_data"
# ── Measure column widths ──
local w_peer=16 w_count=1
# Measure w_peer and w_drops from data
local w_peer=16 w_drops=1
while IFS='|' read -r type rest; do
case "$type" in
peer)
@ -175,28 +113,22 @@ function cmd::activity::run() {
name=$(echo "$rest" | cut -d'|' -f1)
drops=$(echo "$rest" | cut -d'|' -f4)
(( ${#name} > w_peer )) && w_peer=${#name}
(( ${#drops} > w_count )) && w_count=${#drops}
(( ${#drops} > w_drops )) && w_drops=${#drops}
;;
service)
local svc_count
svc_count=$(echo "$rest" | cut -d'|' -f3)
(( ${#svc_count} > w_count )) && w_count=${#svc_count}
local count
count=$(echo "$rest" | cut -d'|' -f3)
(( ${#count} > w_drops )) && w_drops=${#count}
;;
esac
done <<< "$data"
for a_name in "${!_ACCEPT_PEER[@]}"; do
(( ${#a_name} > w_peer )) && w_peer=${#a_name}
local a_conns_val="${_ACCEPT_PEER[$a_name]##*|}"
(( ${#a_conns_val} > w_count )) && w_count=${#a_conns_val}
done
for key in "${!_ACCEPT_DEST[@]}"; do
local d_val="${_ACCEPT_DEST[$key]}"
local d_count_val="${d_val##*|}"
(( ${#d_count_val} > w_count )) && w_count=${#d_count_val}
done
(( w_peer += 2 ))
# Compute exact column where drop count starts on peer row:
# " " (2) + name (w_peer) + " ↓" (3) + rx (10) + " ↑" (3) + tx (10) + " " (2)
# 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
local drops_col=$(( w_peer + 30 ))
local hours_display="${hours}h"
@ -205,93 +137,27 @@ function cmd::activity::run() {
log::section "Activity Monitor (last ${hours_display})"
echo ""
if display::is_table "activity"; then
cmd::activity::_render_table "$data"
return 0
fi
local first_peer=true skip_peer=false
# ── Accept dest inline renderer ──
_render_peer_accept_dests() {
local peer_name="$1"
local keys="${_ACCEPT_DEST_KEYS[$peer_name]:-}"
[[ -z "$keys" ]] && return 0
for d_key in $keys; do
local dest_stats="${_ACCEPT_DEST[$d_key]:-}"
[[ -z "$dest_stats" ]] && continue
local d_bytes_orig d_bytes_reply d_count
IFS='|' read -r d_bytes_orig d_bytes_reply d_count <<< "$dest_stats"
local rest_key="${d_key#${peer_name}:}"
local d_ip="${rest_key%%:*}"
local pp="${rest_key#*:}"
local d_port="${pp%%:*}"
local d_proto="${pp##*:}"
local spec="${d_ip}:${d_port}:${d_proto}"
local dest_display
local raw_suffix=""
local resolved="${_DEST_RESOLVE_CACHE[$spec]:-${d_ip}:${d_port}/${d_proto}}"
local dest_display="$resolved"
if [[ "$show_ports" == "true" && "$resolved" != "${d_ip}:"* && "$resolved" != "${d_ip} "* ]]; then
if [[ -n "$d_port" && "$d_port" != "0" ]]; then
dest_display=$(printf "%s \033[2m(%s:%s)\033[0m" "$resolved" "$d_ip" "$d_port")
else
dest_display=$(printf "%s \033[2m(%s)\033[0m" "$resolved" "$d_ip")
fi
fi
ui::activity::accept_dest_row \
"$dest_display" "$d_bytes_orig" "$d_bytes_reply" \
"$d_count" "$drops_col" "$w_count"
done
}
declare -gA _DEST_RESOLVE_CACHE=()
local -a _dest_specs=()
for _dk in "${!_ACCEPT_DEST[@]}"; do
# key format: peer:ip:port:proto — strip peer prefix
local _rest="${_dk#*:}"
local _dip="${_rest%%:*}"
local _pp="${_rest#*:}"
local _dport="${_pp%%:*}"
local _dproto="${_pp##*:}"
local _spec="${_dip}:${_dport}:${_dproto}"
# Deduplicate
local _found=false
for _s in "${_dest_specs[@]:-}"; do
[[ "$_s" == "$_spec" ]] && _found=true && break
done
$_found || _dest_specs+=("$_spec")
done
if [[ ${#_dest_specs[@]} -gt 0 ]]; then
while IFS='|' read -r _spec _display; do
[[ -n "$_spec" ]] && _DEST_RESOLVE_CACHE["$_spec"]="$_display"
done < <(json::batch_resolve_dest "${_dest_specs[@]}" 2>/dev/null)
fi
local first_peer=true skip_peer=false current_name=""
local -a rendered_peers=()
# ── Main render loop (drop data) ──
while IFS='|' read -r record_type rest; do
case "$record_type" in
peer)
local name rx tx drops
IFS='|' read -r name rx tx drops <<< "$rest"
# Flush previous peer's accept dests
[[ -n "$current_name" ]] && ! $drop_only && \
_render_peer_accept_dests "$current_name"
skip_peer=false
current_name="$name"
local has_accept="${_ACCEPT_PEER[$name]:-}"
if $dropped_only && [[ "$drops" -eq 0 ]]; then
skip_peer=true
continue
fi
$first_peer || echo ""
first_peer=false
rendered_peers+=("$name")
local rx_fmt tx_fmt
rx_fmt=$(fmt::bytes "$rx")
tx_fmt=$(fmt::bytes "$tx")
rx_fmt=$(cmd::activity::_fmt_bytes "$rx")
tx_fmt=$(cmd::activity::_fmt_bytes "$tx")
local name_pad rx_pad tx_pad
name_pad=$(printf "%-${w_peer}s" "$name")
rx_pad=$(printf "%-10s" "$rx_fmt")
@ -299,205 +165,51 @@ function cmd::activity::run() {
local drop_word="drops"
[[ "$drops" -eq 1 ]] && drop_word="drop"
# Always show peer name — either full row or name-only for accept_only
if $accept_only; then
printf " \033[1m%s\033[0m\n" "$name_pad"
else
ui::activity::peer_row \
"$name_pad" "$rx_pad" "$tx_pad" "$drops" "$drop_word" "$w_count"
fi
# Accept summary row
if [[ -n "$has_accept" ]] && ! $drop_only; then
local a_bi a_bo a_pi a_po a_conns
IFS='|' read -r a_bi a_bo a_pi a_po a_conns <<< "$has_accept"
ui::activity::accept_row \
"$name_pad" \
"$(printf '%-10s' "$(fmt::bytes "$a_bi")")" \
"$(printf '%-10s' "$(fmt::bytes "$a_bo")")" \
"$a_conns" "$w_count"
fi
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"
;;
service)
local peer dest_display dst_ip dst_port proto drop_count
IFS='|' read -r peer dest_display dst_ip dst_port proto drop_count <<< "$rest"
# Build dim suffix if --ports
local svc_display="$dest_display"
if [[ "$show_ports" == "true" && -n "$dst_ip" ]]; then
if [[ -n "$dst_port" ]]; then
svc_display=$(printf "%s \033[2m(%s:%s)\033[0m" \
"$dest_display" "$dst_ip" "$dst_port")
else
svc_display=$(printf "%s \033[2m(%s)\033[0m" \
"$dest_display" "$dst_ip")
fi
fi
$skip_peer && continue
local peer dest_display drop_count
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"
[[ "$drop_count" -eq 1 ]] && svc_drop_word="drop"
$accept_only || ui::activity::service_row \
"$svc_display" "$drop_count" "$svc_drop_word" "$drops_col" "$w_count"
printf " \033[2m→\033[0m %s%*s %${w_drops}s %s\n" \
"$dest_display" "$pad_n" "" "$drop_count" "$svc_drop_word"
;;
esac
done <<< "$data"
# Flush last peer's accept dests
[[ -n "$current_name" ]] && ! $drop_only && \
_render_peer_accept_dests "$current_name"
# ── Accept-only peers (not in drop data) ──
if ! $drop_only; then
for a_name in $(echo "${!_ACCEPT_PEER[@]}" | tr ' ' '\n' | sort); do
# Skip already rendered
local already=false
for rp in "${rendered_peers[@]:-}"; do
[[ "$rp" == "$a_name" ]] && already=true && break
done
$already && continue
$first_peer || echo ""
first_peer=false
local a_stats="${_ACCEPT_PEER[$a_name]}"
local a_bi a_bo a_pi a_po a_conns
IFS='|' read -r a_bi a_bo a_pi a_po a_conns <<< "$a_stats"
local name_pad
name_pad=$(printf "%-${w_peer}s" "$a_name")
# Always show peer name
printf " \033[1m%s\033[0m\n" "$name_pad"
ui::activity::accept_row \
"$name_pad" \
"$(printf '%-10s' "$(fmt::bytes "$a_bi")")" \
"$(printf '%-10s' "$(fmt::bytes "$a_bo")")" \
"$a_conns" "$w_count"
_render_peer_accept_dests "$a_name"
done
fi
echo ""
}
function cmd::activity::_render_table() {
local data="${1:-}"
[[ -z "$data" ]] && return 0
# ============================================
# Helpers
# ============================================
ui::activity::header_table
local skip_peer=false
while IFS='|' read -r record_type rest; do
case "$record_type" in
peer)
local name rx tx drops
IFS='|' read -r name rx tx drops <<< "$rest"
skip_peer=false
local rx_fmt tx_fmt
rx_fmt=$(fmt::bytes "$rx")
tx_fmt=$(fmt::bytes "$tx")
ui::activity::peer_row_table "$name" "$rx_fmt" "$tx_fmt" "$drops" ""
;;
service)
$skip_peer && continue
local peer dest count
IFS='|' read -r peer dest count <<< "$rest"
ui::activity::service_row_table "$dest" "$count" "drops"
;;
esac
done <<< "$data"
}
function cmd::activity::_output_json() {
local hours="${1:-24}"
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=()
local current_peer="" current_services=""
local -a current_svc_list=()
while IFS='|' read -r record_type rest; do
case "$record_type" in
peer)
# Flush previous 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:-}]}")
current_svc_list=()
function cmd::activity::_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
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
local count=${#peers[@]}
local array
array=$(printf '%s\n' "${peers[@]:-}" | paste -sd ',' -)
printf '{"peers":[%s]}' "${array:-}" | json::envelope "activity" "$count"
}
function cmd::activity::_fetch_accept_data() {
local hours="${1:-24}" filter_peer="${2:-}" external_only="${3:-false}"
[[ ! -f "$(ctx::accept_events_log)" ]] && return 0
local since_arg=""
[[ "$hours" -gt 0 ]] && since_arg="${hours}h"
local ext_flag="0"
$external_only && ext_flag="1"
json::accept_aggregate \
"$(ctx::accept_events_log)" \
"$(ctx::net)" \
"$(ctx::clients)" \
"$since_arg" \
"$filter_peer" \
2>/dev/null
}
function cmd::activity::_build_accept_maps() {
local accept_data="${1:-}"
# Outputs to stdout as bash declare statements — use eval
# Sets: _ACCEPT_PEER[name]="bytes_in|bytes_out|packets_in|packets_out|conn_count"
# _ACCEPT_DEST[name:ip:port:proto]="bytes|count"
declare -gA _ACCEPT_PEER=()
declare -gA _ACCEPT_DEST=()
while IFS='|' read -r type rest; do
[[ -z "$type" ]] && continue
case "$type" in
peer)
local name bytes_in bytes_out packets_in packets_out conn_count
IFS='|' read -r name bytes_in bytes_out packets_in packets_out conn_count <<< "$rest"
_ACCEPT_PEER["$name"]="${bytes_in}|${bytes_out}|${packets_in}|${packets_out}|${conn_count}"
;;
dest)
local peer dst_ip dst_port proto bytes count
IFS='|' read -r peer dst_ip dst_port proto bytes count <<< "$rest"
_ACCEPT_DEST["${peer}:${dst_ip}:${dst_port}:${proto}"]="${bytes}|${count}"
;;
esac
done <<< "$accept_data"
}

View file

@ -16,7 +16,6 @@ function cmd::block::on_load() {
flag::register --subnet
flag::register --block-name
flag::register --service
flag::register --reason
}
# ============================================
@ -62,7 +61,6 @@ function cmd::block::run() {
local name="" identity="" type="" block_name=""
local ips=() subnets=() ports=() services=()
local quiet=false force=false
local reason=""
while [[ $# -gt 0 ]]; do
case "$1" in
@ -76,7 +74,6 @@ function cmd::block::run() {
--quiet) quiet=true; shift ;;
--subnet) subnets+=("$2"); shift 2 ;;
--port) ports+=("$2"); shift 2 ;;
--reason) reason="$2"; shift 2 ;;
--help) cmd::block::help; return ;;
*)
log::error "Unknown flag: $1"
@ -113,7 +110,6 @@ function cmd::block::run() {
fi
monitor::update_endpoint_cache
cmd::block::_block_all "$name" "$client_ip" "$quiet"
cmd::block::_record_history "$name" "full" "manual" "$reason"
return 0
fi
@ -213,14 +209,6 @@ function cmd::block::run() {
fi
fi
# Record history — derive block type from what was blocked
local btype="specific"
[[ ${#services[@]} -gt 0 ]] && btype="${services[0]}"
[[ ${#ips[@]} -gt 0 ]] && btype="ip"
[[ ${#subnets[@]} -gt 0 ]] && btype="subnet"
[[ ${#ports[@]} -gt 0 ]] && btype="port"
cmd::block::_record_history "$name" "$btype" "manual" "$reason"
return 0
}
@ -283,24 +271,3 @@ function cmd::block::_block_all() {
$quiet || log::wg_success "${name} has been blocked."
}
function cmd::block::_record_history() {
local name="${1:-}" block_type="${2:-full}" \
triggered_by="${3:-manual}" reason="${4:-}"
local endpoint
endpoint=$(json::peer_history_lookup "$name" 2>/dev/null || true)
# endpoint_cache lookup
local ep_cache
ep_cache=$(json::endpoint_cache_get "$(ctx::endpoint_cache)" "$name" 2>/dev/null || true)
json::block_history_record \
"$(ctx::block_history)" \
"$name" \
"$block_type" \
"$triggered_by" \
"$reason" \
"${ep_cache:-}" \
2>/dev/null > /dev/null || true
}

View file

@ -7,8 +7,6 @@
function cmd::config::on_load() {
flag::register --name
flag::register --type
flag::register --force
flag::register --dry-run
}
# ============================================
@ -35,43 +33,27 @@ EOF
# ============================================
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
case "$1" in
--name) name="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;;
--help) cmd::config::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;;
*)
log::error "Unknown flag: $1"
cmd::config::help
return 1
;;
esac
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
local conf
@ -80,202 +62,3 @@ function cmd::config::_show() {
log::section "Client Config: ${name}"
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

@ -1,305 +0,0 @@
#!/usr/bin/env bash
# commands/export.command.sh
function cmd::export::on_load() {
flag::register --peer
flag::register --identity
flag::register --all
flag::register --out
flag::register --conf-only
flag::register --meta-only
flag::register --no-config
flag::register --no-peers
flag::register --force
}
function cmd::export::help() {
cat <<EOF
Usage: wgctl export [options]
Export wgctl data as a portable JSON bundle.
Options:
--peer <name> Export a single peer (conf, meta, groups, identity, blocks)
--identity <name> Export an identity
--all Full backup (all peers, rules, identities, groups, etc.)
--out <file> Write to file instead of stdout
--conf-only Export peer conf only (with --peer)
--meta-only Export peer meta only (with --peer)
--no-config Skip wgctl.json (with --all)
--no-peers Skip peer confs (with --all)
--force Overwrite existing output file
Examples:
wgctl export --peer phone-nuno
wgctl export --peer phone-nuno --out phone-nuno.json
wgctl export --identity nuno --out nuno.json
wgctl export --all --out backup.json
wgctl export --all --no-config --out data-only.json
EOF
}
function cmd::export::run() {
local peer="" identity="" all=false out=""
local conf_only=false meta_only=false
local no_config=false no_peers=false force=false
while [[ $# -gt 0 ]]; do
case "$1" in
--peer) peer="$2"; shift 2 ;;
--identity) identity="$2"; shift 2 ;;
--all) all=true; shift ;;
--out) out="$2"; shift 2 ;;
--conf-only) conf_only=true; shift ;;
--meta-only) meta_only=true; shift ;;
--no-config) no_config=true; shift ;;
--no-peers) no_peers=true; shift ;;
--force) force=true; shift ;;
--help) cmd::export::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
# Validate
local mode_count=0
[[ -n "$peer" ]] && (( mode_count++ )) || true
[[ -n "$identity" ]] && (( mode_count++ )) || true
$all && (( mode_count++ )) || true
if [[ "$mode_count" -eq 0 ]]; then
log::error "Specify --peer, --identity, or --all"
cmd::export::help
return 1
fi
if [[ "$mode_count" -gt 1 ]]; then
log::error "Only one of --peer, --identity, --all can be used at a time"
return 1
fi
# Check output file
if [[ -n "$out" && -f "$out" && ! $force ]]; then
log::error "Output file already exists: ${out} (use --force to overwrite)"
return 1
fi
local json=""
if [[ -n "$peer" ]]; then
json=$(cmd::export::_peer "$peer" "$conf_only" "$meta_only") || return 1
elif [[ -n "$identity" ]]; then
json=$(cmd::export::_identity "$identity") || return 1
elif $all; then
json=$(cmd::export::_full "$no_config" "$no_peers") || return 1
fi
if [[ -n "$out" ]]; then
echo "$json" > "$out"
log::wg_success "Exported to ${out}"
else
echo "$json"
fi
}
# ======================================================
# Peer export
# ======================================================
function cmd::export::_peer() {
local name="${1:-}" conf_only="${2:-false}" meta_only="${3:-false}"
peers::require_exists "$name" || return 1
local conf_file
conf_file="$(ctx::clients)/${name}.conf"
[[ ! -f "$conf_file" ]] && log::error "Client conf not found: ${conf_file}" && return 1
local conf_b64
conf_b64=$(base64 -w 0 < "$conf_file" 2>/dev/null || base64 < "$conf_file")
if $conf_only; then
cmd::export::_envelope "peer_conf" \
"$(printf '{"name":"%s","conf":"%s"}' "$name" "$conf_b64")"
return 0
fi
# Meta
local meta_file meta_json="{}"
meta_file="$(ctx::meta)/${name}.meta"
[[ -f "$meta_file" ]] && meta_json=$(cat "$meta_file")
if $meta_only; then
cmd::export::_envelope "peer_meta" \
"$(printf '{"name":"%s","meta":%s}' "$name" "$meta_json")"
return 0
fi
# Public key
local public_key=""
local key_file
key_file="$(ctx::clients)/${name}_public.key"
[[ -f "$key_file" ]] && public_key=$(cat "$key_file")
# IP
local ip
ip=$(peers::get_ip "$name")
# Type
local peer_type
peer_type=$(peers::get_type "$name" 2>/dev/null || echo "")
# Direct rule
local direct_rule
direct_rule=$(peers::get_meta "$name" "rule" 2>/dev/null || echo "")
# Identity
local identity
identity=$(peers::get_identity "$name" 2>/dev/null || echo "")
# Groups
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)
local groups_json="[]"
[[ ${#group_list[@]} -gt 0 ]] && \
groups_json="[$(printf '%s,' "${group_list[@]}" | sed 's/,$//')]"
# Blocks
local block_file is_blocked="false" block_json="null"
block_file="$(ctx::blocks)/${name}.block"
if [[ -f "$block_file" ]]; then
is_blocked="true"
block_json=$(base64 -w 0 < "$block_file" 2>/dev/null || base64 < "$block_file")
block_json="\"${block_json}\""
fi
local peer_data
peer_data=$(printf \
'{"name":"%s","ip":"%s","type":"%s","public_key":"%s","conf":"%s","meta":%s,"identity":"%s","groups":%s,"direct_rule":"%s","blocks":{"is_blocked":%s,"block_file":%s}}' \
"$name" "$ip" "$peer_type" "$public_key" "$conf_b64" \
"$meta_json" "$identity" "$groups_json" "$direct_rule" \
"$is_blocked" "$block_json")
cmd::export::_envelope "peer" "$peer_data"
}
# ======================================================
# Identity export
# ======================================================
function cmd::export::_identity() {
local name="${1:-}"
identity::require_exists "$name" || return 1
local id_file
id_file="$(ctx::identities)/${name}.identity"
local id_json
id_json=$(cat "$id_file")
cmd::export::_envelope "identity" \
"$(printf '{"name":"%s","identity":%s}' "$name" "$id_json")"
}
# ======================================================
# Full backup
# ======================================================
function cmd::export::_full() {
local no_config="${1:-false}" no_peers="${2:-false}"
local version
version=$(wgctl::version 2>/dev/null || echo "unknown")
python3 "$(ctx::json_helper)" export_full \
"$(ctx::clients)" \
"$(ctx::meta)" \
"$(ctx::rules)" \
"$(ctx::identities)" \
"$(ctx::groups)" \
"$(ctx::blocks)" \
"$(ctx::block_history)" \
"$(ctx::config_file)" \
"$(ctx::policies)" \
"$(ctx::subnets)" \
"$(ctx::net)" \
"$(ctx::hosts)" \
"$no_config" \
"$no_peers" \
"$version" \
2>/dev/null
}
# Helper — peer data without envelope (used by full backup)
function cmd::export::_peer_data() {
local name="${1:-}"
local conf_file
conf_file="$(ctx::clients)/${name}.conf"
[[ ! -f "$conf_file" ]] && return 0
local conf_b64
conf_b64=$(base64 -w 0 < "$conf_file" 2>/dev/null || base64 < "$conf_file")
local meta_file meta_json="{}"
meta_file="$(ctx::meta)/${name}.meta"
[[ -f "$meta_file" ]] && meta_json=$(cat "$meta_file")
local public_key=""
local key_file
key_file="$(ctx::clients)/${name}_public.key"
[[ -f "$key_file" ]] && public_key=$(cat "$key_file")
local ip
ip=$(peers::get_ip "$name")
local peer_type
peer_type=$(peers::get_type "$name" 2>/dev/null || echo "")
local direct_rule
direct_rule=$(peers::get_meta "$name" "rule" 2>/dev/null || echo "")
local identity
identity=$(peers::get_identity "$name" 2>/dev/null || echo "")
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)
local groups_json="[]"
[[ ${#group_list[@]} -gt 0 ]] && \
groups_json="[$(printf '%s,' "${group_list[@]}" | sed 's/,$//')]"
local block_file is_blocked="false" block_json="null"
block_file="$(ctx::blocks)/${name}.block"
if [[ -f "$block_file" ]]; then
is_blocked="true"
block_json="\"$(base64 -w 0 < "$block_file" 2>/dev/null || base64 < "$block_file")\""
fi
printf \
'{"name":"%s","ip":"%s","type":"%s","public_key":"%s","conf":"%s","meta":%s,"identity":"%s","groups":%s,"direct_rule":"%s","blocks":{"is_blocked":%s,"block_file":%s}}' \
"$name" "$ip" "$peer_type" "$public_key" "$conf_b64" \
"$meta_json" "$identity" "$groups_json" "$direct_rule" \
"$is_blocked" "$block_json"
}
# ======================================================
# Envelope helper
# ======================================================
function cmd::export::_envelope() {
local export_type="${1:-}" data="${2:-}"
local version ts
version=$(wgctl::version 2>/dev/null || echo "unknown")
ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
printf '{"wgctl_version":"%s","export_type":"%s","exported_at":"%s","data":%s}\n' \
"$version" "$export_type" "$ts" "$data"
}
function cmd::export::_compact_json() {
local file="$1"
python3 -c "
import json, sys
try:
print(json.dumps(json.load(open('${file}'))))
except Exception as e:
print('{}', file=sys.stderr)
" 2>/dev/null
}

View file

@ -13,9 +13,6 @@ function cmd::group::on_load() {
flag::register --new-name
flag::register --main
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 remove, peer rm Remove a peer from a group
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
unblock Unblock 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)
--limit <n> Max log entries per peer (for logs)
--force Skip confirmation prompts
--all Apply to all groups (for purge-stale)
Examples:
wgctl group list
@ -67,9 +62,6 @@ Examples:
wgctl group block --name family
wgctl group unblock --name family
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 logs --name family --limit 20
wgctl group watch --name family
@ -84,11 +76,6 @@ function cmd::group::run() {
local subcmd="${1:-help}"
shift || true
if command::json; then
cmd::group::_output_json
return 0
fi
case "$subcmd" in
list|ls) cmd::group::list "$@" ;;
show) cmd::group::show "$@" ;;
@ -101,7 +88,6 @@ function cmd::group::run() {
block) cmd::group::block "$@" ;;
unblock) cmd::group::unblock "$@" ;;
rule) cmd::group::rule "$@" ;;
purge-stale) cmd::group::purge_stale "$@" ;;
audit) cmd::group::audit "$@" ;;
logs) cmd::group::logs "$@" ;;
watch) cmd::group::watch "$@" ;;
@ -128,36 +114,40 @@ function cmd::group::list() {
return 0
fi
local data
data=$(json::group_list_data "$groups_dir" "$(ctx::blocks)" "$(ctx::clients)")
[[ -z "$data" ]] && log::wg "No groups configured" && return 0
log::section "Groups"
printf "\n %-20s %-35s %-8s %s\n" "NAME" "DESCRIPTION" "PEERS" "STATUS"
printf " %s\n" "$(printf '─%.0s' {1..75})"
# 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"
echo ""
if display::is_table "group_list"; then
cmd::group::_render_table "$data" "$w_name" "$w_desc"
return 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
while IFS="|" read -r name desc total blocked; do
[[ -z "$name" ]] && continue
ui::group::list_row "$name" "$desc" "$total" "$blocked" "$w_name" "$w_desc"
done <<< "$data"
local short_desc="${desc:0:33}"
[[ ${#desc} -gt 33 ]] && short_desc="${short_desc}..."
echo ""
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"
}
# ============================================
@ -182,61 +172,62 @@ function cmd::group::show() {
group_file="$(group::path "$name")"
log::section "Group: ${name}"
printf "\n"
local 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=()
mapfile -t peers_list < <(json::get "$group_file" "peers") || true
mapfile -t peers_list < <(json::get "$group_file" "peers")
# Filter empty entries
local filtered=()
for p in "${peers_list[@]:-}"; do
[[ -n "$p" ]] && filtered+=("$p")
done
peers_list=("${filtered[@]:-}")
local peer_count=${#peers_list[@]}
[[ -z "${peers_list[0]:-}" ]] && peer_count=0
# Count valid peers (data logic stays in command)
local valid_count=0
for p in "${peers_list[@]}"; do
[[ -z "$p" ]] && continue
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"
[[ -z "${peers_list[0]}" ]] && peer_count=0
printf " %-20s %s\n" "Peers:" "$peer_count"
printf " %s\n" "$(printf '─%.0s' {1..50})"
if [[ "$peer_count" -gt 0 ]]; then
# Measure widths (data logic stays in command)
local w_name=16 w_ip=13
printf "\n %-28s %-15s %-12s %s\n" "NAME" "IP" "RULE" "STATUS"
printf " %s\n" "$(printf '─%.0s' {1..65})"
for peer_name in "${peers_list[@]}"; do
[[ -z "$peer_name" ]] && continue
(( ${#peer_name} > w_name )) && w_name=${#peer_name}
done
(( w_name += 2 ))
# Delegate rendering to ui::
ui::group::show_peers peers_list "$w_name" "$w_ip"
# Skip if peer no longer exists
if ! peers::require_exists "$peer_name" > /dev/null 2>&1; then
log::wg_warning "Peer '${peer_name}' no longer exists — skipping"
continue
fi
local 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
printf " \033[2m—\033[0m\n"
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
printf " —\n"
fi
printf "\n"
}
function cmd::group::_render_table() {
local data="${1:-}" w_name="${2:-20}" w_desc="${3:-20}"
[[ -z "$data" ]] && return 0
ui::group::list_header_table
while IFS='|' read -r name desc total blocked; do
[[ -z "$name" ]] && continue
ui::group::list_row_table "$name" "$desc" "$total" "$blocked"
done <<< "$data"
return 0
}
# ============================================
@ -843,110 +834,3 @@ function cmd::group::watch() {
load_command watch
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,326 +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 ""
if display::is_table "hosts_list"; then
cmd::hosts::_render_table "$data"
return 0
fi
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 ""
}
function cmd::hosts::_render_table() {
local data="${1:-}"
[[ -z "$data" ]] && return 0
ui::hosts::list_header_table
while IFS='|' read -r type key name desc tags; do
[[ -z "$type" ]] && continue
ui::hosts::list_row_table "$type" "$key" "$name" "$desc" "$tags"
done <<< "$data"
}
# ============================================
# 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 --dry-run
flag::register --force
# rule subcommand flags
flag::register --rule
# options subcommand flags
flag::register --policy
flag::register --set-strict-rule
flag::register --unset-strict-rule
@ -28,9 +30,6 @@ function cmd::identity::on_load() {
flag::register --unset-auto-apply
flag::register --field
flag::register --value
flag::register --migrate
command::mixin json_output
}
# ============================================
@ -41,32 +40,33 @@ function cmd::identity::help() {
cat <<EOF
Usage: wgctl identity <subcommand> [options]
Manage peer identities — group peers by person/device owner.
Manage peer identities.
Subcommands:
list List all identities
show --name <n> Show identity details with peers and rule tree
add --name <n> Create a new identity
remove --name <n> Remove an identity
migrate Migrate peers to identities
show --name <name> Show identity details and device status
add --name <name> Manually attach a peer to an identity
--peer <peer>
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
Blocked if peer already has rule directly
[--migrate] Remove conflicting direct peer rules first
rule unassign --name <n> --rule <r> Remove rule from identity
rule unassign --name <n> --all Remove all rules from identity
rule assign --name <name> Assign a rule to an identity
--rule <rule>
rule unassign --name <name> Remove rule from an identity
rule show --name <name> Show current identity rule
options --name <n> --strict-rule <bool> Set strict rule mode
options --name <n> --auto-apply <bool> Set auto apply
options --name <name> Set identity options
[--policy <policy>]
[--set-strict-rule | --unset-strict-rule]
[--set-auto-apply | --unset-auto-apply]
Examples:
wgctl identity list
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 user --migrate
wgctl identity rule unassign --name nuno --rule admin
wgctl identity options --name nuno --strict-rule true
wgctl identity rule unassign --name nuno
wgctl identity options --name guests-identity --policy guest
wgctl identity options --name nuno --set-strict-rule
EOF
}
@ -78,11 +78,6 @@ function cmd::identity::run() {
local subcmd="${1:-list}"
shift || true
if command::json && [[ "$subcmd" == "list" ]]; then
cmd::identity::_output_json
return 0
fi
case "$subcmd" in
list) cmd::identity::_list "$@" ;;
show) cmd::identity::_show "$@" ;;
@ -105,18 +100,13 @@ function cmd::identity::run() {
function cmd::identity::_list() {
local data
data=$(identity::list_data | ui::sort_rows 1)
data=$(identity::list_data)
if [[ -z "$data" ]]; then
log::info "No identities found. Run 'wgctl identity migrate' to create from existing peers."
return 0
fi
if display::is_table "identity_list"; then
cmd::identity::_render_table "$data"
return 0
fi
echo ""
while IFS='|' read -r name peer_count types rules policy; do
local rules_display
@ -150,7 +140,7 @@ function cmd::identity::_show() {
data=$(identity::show_data "$name")
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=()
while IFS=$'\t' read -r pk ts; do
[[ -n "$pk" ]] && _id_handshakes["$pk"]="$ts"
@ -178,32 +168,9 @@ function cmd::identity::_show() {
esac
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 ""
}
function cmd::identity::_render_table() {
local data="${1:-}"
[[ -z "$data" ]] && return 0
printf "\n %-20s %-8s %-20s %s\n" "NAME" "PEERS" "RULES" "POLICY"
printf " %s\n" "$(printf '─%.0s' {1..65})"
while IFS='|' read -r name peer_count types rules policy; do
[[ -z "$name" ]] && continue
local rules_display
rules_display=$(echo "$rules" | sed 's/,/, /g')
ui::identity::list_row_table "$name" "$peer_count" "$rules_display" "$policy"
done <<< "$data"
printf " %s\n\n" "$(printf '─%.0s' {1..65})"
}
function cmd::identity::_device_status() {
local peer_name="${1:-}"
@ -383,12 +350,11 @@ function cmd::identity::_rule() {
}
function cmd::identity::_rule_assign() {
local name="" rule="" migrate=false
local name="" rule=""
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--rule) rule="$2"; shift 2 ;;
--migrate) migrate=true; shift ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
@ -398,30 +364,7 @@ function cmd::identity::_rule_assign() {
identity::require_exists "$name" || return 1
rule::exists "$rule" || { log::error "Rule not found: ${rule}"; return 1; }
local conflicts=()
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
local exit_code
identity::add_rule "$name" "$rule" || exit_code=$?
if [[ $exit_code -eq 2 ]]; then
@ -591,40 +534,3 @@ function cmd::identity::_options() {
cmd::identity::_rule_show --name "$name"
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

@ -1,288 +0,0 @@
#!/usr/bin/env bash
# commands/import.command.sh
function cmd::import::on_load() {
flag::register --file
flag::register --peer
flag::register --dry-run
flag::register --force
}
function cmd::import::help() {
cat <<EOF
Usage: wgctl import --file <file> [options]
Import a wgctl JSON export bundle.
Options:
--file <path> Path to export bundle (required)
--peer <name> Import only this peer from a full backup
--dry-run Show what would be imported without making changes
--force Overwrite existing data
Export types handled:
peer Single peer bundle
peer_conf Peer conf only
peer_meta Peer meta only
identity Identity bundle
full Full backup
Examples:
wgctl import --file backup.json
wgctl import --file backup.json --peer phone-nuno
wgctl import --file phone-nuno.json --dry-run
wgctl import --file backup.json --force
EOF
}
function cmd::import::run() {
local file="" peer="" dry_run=false force=false
while [[ $# -gt 0 ]]; do
case "$1" in
--file) file="$2"; shift 2 ;;
--peer) peer="$2"; shift 2 ;;
--dry-run) dry_run=true; shift ;;
--force) force=true; shift ;;
--help) cmd::import::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
[[ -z "$file" ]] && log::error "Missing required flag: --file" && return 1
[[ ! -f "$file" ]] && log::error "File not found: ${file}" && return 1
# Read export metadata via Python
local export_type export_version
export_type=$(json::import_get_field \
"$file" "export_type" 2>/dev/null)
export_version=$(json::import_get_field \
"$file" "wgctl_version" 2>/dev/null)
[[ -z "$export_type" ]] && \
log::error "Invalid export file: ${file}" && return 1
# Version check
local current_version
current_version=$(wgctl::version 2>/dev/null || echo "unknown")
if [[ "$export_version" != "$current_version" ]]; then
log::wg_warning "Export version (${export_version}) differs from current (${current_version})"
fi
log::section "wgctl Import"
printf " File: %s\n" "$file"
printf " Type: %s\n" "$export_type"
$dry_run && printf " Mode: \033[2mdry run\033[0m\n"
printf "\n"
case "$export_type" in
peer) cmd::import::_peer "$file" "$dry_run" "$force" ;;
peer_conf) cmd::import::_peer_conf "$file" "$dry_run" "$force" ;;
peer_meta) cmd::import::_peer_meta "$file" "$dry_run" "$force" ;;
identity) cmd::import::_identity "$file" "$dry_run" "$force" ;;
full)
if [[ -n "$peer" ]]; then
cmd::import::_peer_from_full "$file" "$peer" "$dry_run" "$force"
else
cmd::import::_full "$file" "$dry_run" "$force"
fi
;;
*)
log::error "Unknown export type: ${export_type}"
return 1
;;
esac
}
# ======================================================
# Helpers
# ======================================================
function cmd::import::_print_results() {
while IFS= read -r line; do
[[ -z "$line" ]] && continue
case "$line" in
error:*) log::error "${line#error:}" ;;
skip:*) printf " \033[2mskip: %s (already exists)\033[0m\n" "${line#skip:}" ;;
peer:*) printf " ✓ peer: %s\n" "${line#peer:}" ;;
group:*) printf " ✓ group: %s\n" "${line#group:}" ;;
*) printf " ✓ %s\n" "$line" ;;
esac
done
}
# ======================================================
# Peer import
# ======================================================
function cmd::import::_peer() {
local file="${1:-}" dry_run="${2:-false}" force="${3:-false}"
local name
name=$(json::import_get_field "$file" "data" "name" 2>/dev/null)
[[ -z "$name" ]] && log::error "Could not read peer name from export" && return 1
$dry_run && \
printf " \033[2m[dry-run]\033[0m Would import peer '%s'\n" "$name" && return 0
local err
err=$(json::import_peer \
"$file" "data" "$name" \
"$(ctx::clients)" "$(ctx::meta)" \
"$(ctx::groups)" "$(ctx::blocks)" \
"$($force && echo true || echo false)" \
2>&1 >/dev/null) || { log::error "$err"; return 1; }
json::import_peer \
"$file" "data" "$name" \
"$(ctx::clients)" "$(ctx::meta)" \
"$(ctx::groups)" "$(ctx::blocks)" \
"$($force && echo true || echo false)" \
2>/dev/null | cmd::import::_print_results
log::wg_success "Imported peer '${name}'"
}
# ======================================================
# Peer conf only
# ======================================================
function cmd::import::_peer_conf() {
local file="${1:-}" dry_run="${2:-false}" force="${3:-false}"
local name
name=$(json::import_get_field "$file" "data" "name" 2>/dev/null)
local conf_file="$(ctx::clients)/${name}.conf"
if [[ -f "$conf_file" ]] && ! $force; then
log::error "Peer conf '${name}' already exists. Use --force to overwrite."
return 1
fi
$dry_run && \
printf " \033[2m[dry-run]\033[0m Would import conf for '%s'\n" "$name" && return 0
python3 -c "
import json, base64
d = json.load(open('${file}'))
conf = base64.b64decode(d['data']['conf']).decode()
open('${conf_file}', 'w').write(conf)
" 2>/dev/null || { log::error "Failed to write conf"; return 1; }
printf " ✓ conf\n"
log::wg_success "Imported conf for '${name}'"
}
# ======================================================
# Peer meta only
# ======================================================
function cmd::import::_peer_meta() {
local file="${1:-}" dry_run="${2:-false}" force="${3:-false}"
local name
name=$(json::import_get_field "$file" "data" "name" 2>/dev/null)
[[ ! -f "$(ctx::clients)/${name}.conf" ]] && \
log::error "Peer '${name}' does not exist — import the peer conf first" && return 1
$dry_run && \
printf " \033[2m[dry-run]\033[0m Would import meta for '%s'\n" "$name" && return 0
python3 -c "
import json
d = json.load(open('${file}'))
meta = d['data']['meta']
open('$(ctx::meta)/${name}.meta', 'w').write(json.dumps(meta, indent=2))
" 2>/dev/null || { log::error "Failed to write meta"; return 1; }
printf " ✓ meta\n"
log::wg_success "Imported meta for '${name}'"
}
# ======================================================
# Identity import
# ======================================================
function cmd::import::_identity() {
local file="${1:-}" dry_run="${2:-false}" force="${3:-false}"
local name
name=$(json::import_get_field "$file" "data" "name" 2>/dev/null)
$dry_run && \
printf " \033[2m[dry-run]\033[0m Would import identity '%s'\n" "$name" && return 0
local err
err=$(json::import_identity \
"$file" "$name" \
"$(ctx::identities)" "$(ctx::clients)" \
"$($force && echo true || echo false)" \
2>&1 >/dev/null) || { log::error "$err"; return 1; }
json::import_identity \
"$file" "$name" \
"$(ctx::identities)" "$(ctx::clients)" \
"$($force && echo true || echo false)" \
2>/dev/null | cmd::import::_print_results
log::wg_success "Imported identity '${name}'"
}
# ======================================================
# Single peer from full backup
# ======================================================
function cmd::import::_peer_from_full() {
local file="${1:-}" name="${2:-}" dry_run="${3:-false}" force="${4:-false}"
$dry_run && \
printf " \033[2m[dry-run]\033[0m Would import peer '%s' from backup\n" "$name" && return 0
local err
err=$(json::import_peer \
"$file" "peers" "$name" \
"$(ctx::clients)" "$(ctx::meta)" \
"$(ctx::groups)" "$(ctx::blocks)" \
"$($force && echo true || echo false)" \
2>&1 >/dev/null) || { log::error "$err"; return 1; }
json::import_peer \
"$file" "peers" "$name" \
"$(ctx::clients)" "$(ctx::meta)" \
"$(ctx::groups)" "$(ctx::blocks)" \
"$($force && echo true || echo false)" \
2>/dev/null | cmd::import::_print_results
log::wg_success "Imported peer '${name}' from backup"
}
# ======================================================
# Full backup import
# ======================================================
function cmd::import::_full() {
local file="${1:-}" dry_run="${2:-false}" force="${3:-false}"
local peer_count
peer_count=$(json::import_get_field \
"$file" "data" "peers" 2>/dev/null | python3 -c \
"import json,sys; print(len(json.load(sys.stdin)))" 2>/dev/null || echo "?")
printf " Importing full backup (%s peers)...\n\n" "$peer_count"
$dry_run && \
log::wg_warning "Dry run — no changes would be made" && return 0
json::import_full \
"$file" \
"$(ctx::clients)" "$(ctx::meta)" \
"$(ctx::rules)" "$(ctx::identities)" \
"$(ctx::groups)" "$(ctx::blocks)" \
"$(ctx::policies)" "$(ctx::subnets)" \
"$(ctx::net)" "$(ctx::hosts)" \
"$($force && echo true || echo false)" \
2>/dev/null | cmd::import::_print_results
log::wg_success "Import complete"
}

View file

@ -5,8 +5,6 @@ function cmd::inspect::on_load() {
flag::register --type
flag::register --config
flag::register --qr
command::mixin json_output
}
function cmd::inspect::help() {
@ -129,6 +127,25 @@ function cmd::inspect::_peer_info() {
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() {
local line_width=20
local total=$INSPECT_WIDTH
@ -331,11 +348,6 @@ function cmd::inspect::run() {
name=$(peers::resolve_and_require "$name" "$type") || return 1
if command::json; then
cmd::inspect::_output_json "$name"
return 0
fi
load_command list
log::section "Inspect: ${name}"
@ -359,70 +371,3 @@ function cmd::inspect::run() {
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

@ -5,8 +5,6 @@
# ============================================
function cmd::list::on_load() {
command::mixin json_output
load_module identity
load_module ui
@ -21,9 +19,6 @@ function cmd::list::on_load() {
flag::register --allowed
flag::register --detailed
flag::register --name
# Mutually exclusive filter groups
flag::exclusive --online --offline --blocked --restricted --allowed
}
# ============================================
@ -214,11 +209,6 @@ function cmd::list::run() {
return 0
fi
if command::json; then
cmd::list::_output_json "$collected_rows"
return 0
fi
if $detailed; then
cmd::list::_render_detailed "$collected_rows"
cmd::list::_render_summary_from_rows "$collected_rows"
@ -230,11 +220,8 @@ function cmd::list::run() {
case "$style" in
table) cmd::list::_render_table ;;
compact) display::render "peer_list" "$collected_rows" \
"cmd::list::_render_compact" "cmd::list::_render_table" ;;
*) display::render "peer_list" "$collected_rows" \
"cmd::list::_render_compact" "cmd::list::_render_table" ;;
compact) cmd::list::_render_compact "$collected_rows" ;;
*) cmd::list::_render_compact "$collected_rows" ;;
esac
}
@ -243,9 +230,10 @@ function cmd::list::run() {
# ============================================
function cmd::list::_collect_all_rows() {
# Outputs pipe-delimited rows for peers that pass all filters
# Fields: name|ip|type|rule|group|status|last_seen|is_blocked|is_restricted
local dir
dir="$(ctx::clients)"
local _verbose_status="${LIST_VERBOSE_STATUS:-true}"
for conf in "${dir}"/*.conf; do
[[ -f "$conf" ]] || continue
@ -253,6 +241,7 @@ function cmd::list::_collect_all_rows() {
client_name=$(basename "$conf" .conf)
[[ -z "$client_name" ]] && continue
# Identity filter
if [[ ${#p_identity_filter[@]} -gt 0 && \
-z "${p_identity_filter[$client_name]:-}" ]]; then
continue
@ -273,29 +262,25 @@ function cmd::list::_collect_all_rows() {
local rule="${p_rules[$client_name]:-}"
local group="${p_main_groups[$client_name]:-}"
# Apply status filters
if $online_only; then peers::is_online "$client_name" "$handshake_ts" "$last_ts" || continue; fi
if $offline_only; then peers::is_offline "$client_name" "$handshake_ts" "$last_ts" || continue; fi
if $restricted_only && [[ "$is_restricted" != "true" ]]; then continue; fi
if $blocked_only && [[ "$is_blocked" != "true" ]]; then continue; fi
if $allowed_only && { [[ "$is_blocked" == "true" ]] || \
[[ "$is_restricted" == "true" ]]; }; then continue; fi
# Apply rule/group filters
if [[ -n "$filter_rule" && "$rule" != "$filter_rule" ]]; then continue; fi
if [[ -n "$filter_group" ]]; then
local all_groups="${peer_group_map[$client_name]:-}"
[[ "$all_groups" != *"$filter_group"* ]] && continue
fi
# Resolve status — verbose or simple
local status
if [[ "$_verbose_status" == "true" ]]; then
status=$(peers::format_status_verbose "$client_name" "$pubkey" \
"$is_blocked" "$is_restricted" "$handshake_ts" "$last_ts" | \
sed 's/\x1b\[[0-9;]*m//g')
else
# Resolve status
local state
state=$(peers::connection_state "$is_blocked" "$is_restricted" "$handshake_ts" "$last_ts")
status="${state%%|*}"
fi
local status="${state%%|*}"
# Resolve last seen
local last_seen="-"
@ -306,7 +291,7 @@ function cmd::list::_collect_all_rows() {
elif [[ -n "$handshake_ts" && "$handshake_ts" != "0" ]]; then
local ts_display
ts_display=$(fmt::datetime_short "$handshake_ts")
if [[ "$status" == "online"* ]]; then
if [[ "$status" == "online" ]]; then
last_seen="${ts_display} (handshake)"
else
last_seen="$ts_display"
@ -320,6 +305,7 @@ function cmd::list::_collect_all_rows() {
"$is_blocked" "$is_restricted"
done
}
# ============================================
# Compact render
# ============================================
@ -353,63 +339,19 @@ function cmd::list::_render_compact() {
# ============================================
function cmd::list::_render_table() {
local rows="${1:-}"
[[ -z "$rows" ]] && log::wg_warning "No results found" && return 0
declare -A rule_counts=() group_counts=()
_list_header_printed=false
# Measure column widths from data (same as compact)
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}
done <<< "$rows"
(( w_name += 2 )); (( w_ip += 2 ))
(( w_type += 2 )); (( w_rule += 2 ))
(( w_group += 2 )); (( w_last += 2 ))
cmd::list::_iter_confs_table
# Header
printf "\n %-${w_name}s %-${w_ip}s %-${w_type}s %-${w_rule}s %-${w_group}s %-${w_status}s %s\n" \
"NAME" "IP" "TYPE" "RULE" "GROUP" "STATUS" "LAST SEEN"
printf " %s\n" "$(printf '─%.0s' {1..115})"
# Rows
while IFS='|' read -r name ip type rule group status last_seen is_blocked is_restricted; do
[[ -z "$name" ]] && continue
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"
local last_seen_colored="$last_seen"
[[ -n "$row_color" ]] && last_seen_colored="${row_color}${last_seen}\033[0m" \
|| last_seen_colored="${status_color}${last_seen}\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"
if [[ "$_list_header_printed" == "true" ]]; then
cmd::list::_render_footer $has_groups
local group_summary=""
cmd::list::_build_group_summary
printf "\n Showing peers\n\n"
else
printf " %-${w_name}s %-${w_ip}s %-${w_type}s %-${w_rule}s %-${w_group}s %b%*s\033[0m %b\n" \
"$name" "$ip" "$type" "$rule" "$group" \
"$status_color${clean_status}" "$status_pad_n" "" \
"$last_seen_colored"
log::wg_warning "No results found"
fi
done <<< "$rows"
printf " %s\n" "$(printf '─%.0s' {1..115})"
cmd::list::_render_summary_from_rows "$rows"
}
function cmd::list::_iter_confs_table() {
@ -476,10 +418,13 @@ function cmd::list::_render_detailed() {
ui::peer::list_identity_header "$id_name"
while IFS='|' read -r name ip type rule group status last_seen is_blocked is_restricted; do
[[ -z "$name" ]] && continue
local subnet
subnet=$(peers::get_meta "$name" "subnet" 2>/dev/null)
if [[ -z "$subnet" ]]; then
local peer_type="${p_types[$name]:-}"
subnet=$(peers::get_display_subnet "$name" "$peer_type")
[[ -n "$peer_type" ]] && subnet="$peer_type"
fi
[[ -z "$subnet" ]] && subnet="-"
ui::peer::list_row_detailed \
"$w_name" "$w_ip" "$w_type" "$w_rule" "$w_group" "$w_subnet" \
"$name" "$ip" "$type" "$rule" "$group" "$subnet" \
@ -519,34 +464,22 @@ function cmd::list::_render_detailed() {
function cmd::list::_render_summary_from_rows() {
local rows="${1:-}"
declare -A rule_counts=() group_counts=()
declare -A rule_counts=()
local total=0
while IFS='|' read -r name ip type rule group rest; do
while IFS='|' read -r name ip type rule rest; do
[[ -z "$name" ]] && continue
(( total++ )) || true
rule_counts["${rule:--}"]=$(( ${rule_counts[${rule:--}]:-0} + 1 )) || true
[[ "$group" != "-" && -n "$group" ]] && \
group_counts["$group"]=$(( ${group_counts[$group]:-0} + 1 )) || true
rule_counts["${rule:-}"]=$(( ${rule_counts[${rule:-}]:-0} + 1 )) || true
done <<< "$rows"
local rule_summary=""
for r in $(echo "${!rule_counts[@]}" | tr ' ' '\n' | sort); do
rule_summary+="${rule_counts[$r]} ${r}, "
local summary=""
for r in "${!rule_counts[@]}"; do
summary+="${rule_counts[$r]} ${r}, "
done
rule_summary="${rule_summary%, }"
summary="${summary%, }"
local group_summary=""
for g in $(echo "${!group_counts[@]}" | tr ' ' '\n' | sort); do
group_summary+="${group_counts[$g]} in ${g}, "
done
group_summary="${group_summary%, }"
if [[ -n "$group_summary" ]]; then
printf " Showing %s peers [%s] — %s\n\n" "$total" "$rule_summary" "$group_summary"
else
printf " Showing %s peers [%s]\n\n" "$total" "$rule_summary"
fi
printf " Showing %s peers [%s]\n\n" "$total" "$summary"
}
# ============================================
@ -720,31 +653,3 @@ function cmd::list::_show_client_safe() {
local name="$1"
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

@ -11,20 +11,10 @@ function cmd::logs::on_load() {
flag::register --fw
flag::register --wg
flag::register --follow
flag::register --merged
flag::register --all
flag::register --before
flag::register --force
flag::register --days
flag::register --raw
flag::register --detailed
flag::register --service
flag::register --event
flag::register --ascending
flag::register --descending
flag::register --resolved
flag::exclusive --ascending --descending
}
function cmd::logs::help() {
@ -35,7 +25,6 @@ Show or manage WireGuard and firewall activity logs.
Subcommands:
show (default) Show activity logs
clean Remove keepalive handshakes (deduplicate)
remove, rm Remove log entries
rotate Remove entries older than N days
@ -43,23 +32,9 @@ Options for show:
--name <name> Filter by client name
--type <type> Filter by device type
--limit <n> Max results per source (default: 50)
--since <time> Show events since: 2h, 7d, 23/05, 23/05/2026, 2026-05-23
--service <svc> Filter by service name, IP, or IP:port
e.g. pihole, proxmox:web-ui, 10.0.0.100, 10.0.0.100:8006
--event <type> Filter wg events: attempt | handshake
--fw Show only firewall drops
--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:
--name <name> Remove entries for specific peer
@ -75,22 +50,14 @@ Options for rotate:
Examples:
wgctl logs
wgctl logs --since 2h
wgctl logs --since 23/05
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 --name phone-nuno
wgctl logs --fw --limit 100
wgctl logs --follow
wgctl logs clean
wgctl logs clean --force
wgctl logs remove --name phone-nuno
wgctl logs rotate --days 30
wgctl logs remove --all --force
wgctl logs remove --fw --before 1
wgctl logs rotate
wgctl logs rotate --days 30 --force
EOF
}
@ -106,7 +73,6 @@ function cmd::logs::run() {
show) cmd::logs::show "$@" ;;
remove|rm|del) cmd::logs::remove "$@" ;;
rotate) cmd::logs::rotate "$@" ;;
clean) cmd::logs::clean "$@" ;;
help) cmd::logs::help ;;
*)
log::error "Unknown subcommand: '${subcmd}'"
@ -117,31 +83,17 @@ function cmd::logs::run() {
}
function cmd::logs::show() {
local name="" type="" limit=50 since=""
local fw_only=false wg_only=false follow=false merged=false
local raw=false detailed=false
local filter_service="" filter_event=""
local sort_order="desc"
local resolved=false
local sort_order="desc"
local name="" type="" limit=50
local fw_only=false wg_only=false follow=false
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;;
--limit) limit="$2"; shift 2 ;;
--since) since="$2"; shift 2 ;;
--service) filter_service="$2"; shift 2 ;;
--event) filter_event="$2"; shift 2 ;;
--fw) fw_only=true; shift ;;
--wg) wg_only=true; shift ;;
--merged) merged=true; shift ;;
--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"
@ -150,9 +102,6 @@ function cmd::logs::show() {
esac
done
local collapse=1
$detailed && collapse=0
if [[ -n "$name" && -n "$type" ]]; then
name=$(peers::resolve_and_require "$name" "$type") || return 1
fi
@ -163,339 +112,56 @@ function cmd::logs::show() {
[[ -z "$filter_ip" ]] && log::error "Could not find IP for: $name" && return 1
fi
if $fw_only && $wg_only; then
fw_only=false
wg_only=false
fi
if $follow; then
cmd::logs::follow "$filter_ip" "$name" "$type" "$fw_only" "$wg_only"
return
fi
local net_file=""
$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"
printf "\n"
if [[ -n "$fw_output" && -n "$wg_output" ]]; then
printf "%s\n\n" "$fw_output"
printf "%s\n" "$wg_output"
elif [[ -n "$fw_output" ]]; then
printf "%s\n" "$fw_output"
else
printf "%s\n" "$wg_output"
fi
}
function cmd::logs::show_fw_events() {
local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" \
limit="${4:-50}" net_file="${5:-}" collapse="${6:-1}" \
since="${7:-}" filter_dest_ip="${8:-}" filter_dest_port="${9:-}" \
sort_order="${10:-desc}" resolved_only="${11:-false}"
[[ ! -f "$FW_EVENTS_LOG" ]] && return 0
local data
data=$(json::fw_events \
"$FW_EVENTS_LOG" "$filter_ip" "$filter_type" \
"$(ctx::clients)" "${net_file:-}" \
"$limit" "$collapse" "$since" \
"$filter_dest_ip" "$filter_dest_port" \
"$sort_order" \
2>/dev/null)
[[ -z "$data" ]] && return 0
# ── Collect unique endpoints for batch resolution ──
local -a ep_list=()
while IFS='|' read -r ts client dest_ip dest_port proto svc count src_endpoint; 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
(( ${#client} > w_client )) && w_client=${#client}
local svc_display=""
if [[ -n "$svc" ]]; then
[[ -n "$dest_port" ]] && svc_display="${svc}/${proto}" \
|| svc_display="${svc} (${proto})"
else
[[ -n "$dest_port" ]] && svc_display="${dest_ip}:${dest_port}/${proto}" \
|| svc_display="${dest_ip} (${proto})"
fi
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"
(( w_client += 2 ))
(( w_dest += 2 ))
[[ "$w_endpoint" -gt 0 ]] && (( w_endpoint += 2 ))
# ── Pass 2: render ──
ui::logs::fw_section_header
while IFS='|' read -r ts client dest_ip dest_port proto svc count src_endpoint src_resolved; do
[[ -z "$ts" ]] && continue
ui::logs::fw_row "$ts" "$client" "$dest_ip" "$dest_port" \
"$proto" "$svc" "$count" "$w_client" "$w_dest" \
"$src_endpoint" "$src_resolved" "$w_endpoint" "$resolved_only"
done <<< "$resolved_data"
printf "\n"
}
function cmd::logs::show_wg_events() {
local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" \
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
local data
data=$(json::wg_events \
"$WG_EVENTS_LOG" "$filter_name" "$filter_type" \
"$limit" "$collapse" "$since" "$filter_event" \
"$(ctx::endpoint_cache)" "$sort_order" \
2>/dev/null)
[[ -z "$data" ]] && return 0
# ── Collect unique endpoints for batch resolution ──
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 resolved_data=""
while IFS='|' read -r ts client endpoint event count gap_seconds; do
[[ -z "$ts" ]] && continue
(( ${#client} > w_client )) && w_client=${#client}
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"
(( w_client += 2 ))
(( w_endpoint += 2 ))
# ── Render ──
ui::logs::wg_section_header
while IFS='|' read -r ts client endpoint event count gap_seconds resolved; do
[[ -z "$ts" ]] && continue
if $resolved_only; then
local ep_display="${resolved:-$endpoint}"
[[ -z "$ep_display" ]] && ep_display="-"
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"
}
function cmd::logs::show_merged() {
local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" \
limit="${4:-50}" net_file="${5:-}" since="${6:-}"
local fw_data wg_data
fw_data=$(json::fw_events \
"$FW_EVENTS_LOG" "$filter_ip" "$filter_type" \
"$(ctx::clients)" "${net_file:-}" \
"$limit" "1" "$since" "" "" \
2>/dev/null)
wg_data=$(json::wg_events \
"$WG_EVENTS_LOG" "$filter_name" "$filter_type" \
"$limit" "1" "$since" "" \
2>/dev/null)
local w_client=16 w_dest=20
while IFS='|' read -r ts client rest; do
[[ -z "$ts" ]] && continue
(( ${#client} > w_client )) && w_client=${#client}
done < <(echo "$fw_data"; echo "$wg_data")
(( w_client += 2 ))
local merged_data
merged_data=$(
while IFS='|' read -r ts client dest_ip dest_port proto svc count; do
[[ -z "$ts" ]] && continue
echo "fw|${ts}|${client}|${dest_ip}|${dest_port}|${proto}|${svc}|${count}"
done <<< "$fw_data"
while IFS='|' read -r ts client endpoint event count; do
[[ -z "$ts" ]] && continue
echo "wg|${ts}|${client}|${endpoint}|${event}|${count}"
done <<< "$wg_data"
)
while IFS='|' read -r source ts rest; do
[[ -z "$source" ]] && continue
case "$source" in
fw)
IFS='|' read -r client dest_ip dest_port proto svc count <<< "$rest"
local dest_display
if [[ -n "$svc" ]]; then
[[ -n "$dest_port" ]] && dest_display="${svc}/${proto}" || dest_display="${svc} (${proto})"
else
[[ -n "$dest_port" ]] && dest_display="${dest_ip}:${dest_port}/${proto}" || dest_display="${dest_ip} (${proto})"
fi
(( ${#dest_display} > w_dest )) && w_dest=${#dest_display}
;;
esac
done <<< "$merged_data"
(( w_dest += 2 ))
while IFS='|' read -r source ts rest; do
[[ -z "$source" ]] && continue
case "$source" in
fw)
IFS='|' read -r client dest_ip dest_port proto svc count <<< "$rest"
ui::watch::fw_row "$ts" "$client" \
"$(ui::logs::build_dest "$dest_ip" "$dest_port" "$proto" "$svc")" \
"$w_client" "$w_dest"
;;
wg)
IFS='|' read -r client endpoint event count <<< "$rest"
ui::watch::wg_row "$ts" "$client" "$endpoint" "$event" \
"$w_client" "$w_dest"
;;
esac
done < <(echo "$merged_data" | sort -t'|' -k2,2)
printf "\n"
$wg_only || cmd::logs::show_fw_events "$filter_ip" "$name" "$type" "$limit"
$fw_only || cmd::logs::show_wg_events "$filter_ip" "$name" "$type" "$limit"
}
function cmd::logs::follow() {
local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}"
local fw_only="${4:-false}" wg_only="${5:-false}"
local filter_peers="${6:-}"
local clients_dir
clients_dir="$(ctx::clients)"
local wg_log="$WG_EVENTS_LOG"
local fw_log="$FW_EVENTS_LOG"
$fw_only && wg_log=""
$wg_only && fw_log=""
log::section "WireGuard Live Log (Ctrl+C to stop)"
printf "\n"
printf "\n %-20s %-8s %-20s %-25s %s\n" \
"TIME" "SOURCE" "CLIENT" "DESTINATION/ENDPOINT" "EVENT"
printf " %s\n" "$(printf '─%.0s' {1..90})"
local restricted_only=false blocked_only=false
$fw_only && restricted_only=true
$wg_only && blocked_only=true
monitor::live "$filter_name" "$filter_type" "" \
"$blocked_only" "$restricted_only" "false" "false"
while IFS="|" read -r source ts client dst_or_endpoint event; do
if [[ "$source" == "fw" ]]; then
local colored_event
case "$event" in
tcp) colored_event="\033[1;33mtcp\033[0m" ;;
udp) colored_event="\033[0;36mudp\033[0m" ;;
icmp) colored_event="\033[0;37micmp\033[0m" ;;
*) colored_event="$event" ;;
esac
printf " %-20s %-8s %-20s %-25s %b\n" \
"$ts" "firewall" "$client" "$dst_or_endpoint" "$colored_event"
else
local colored_event
case "$event" in
attempt) colored_event="\033[1;31mattempt\033[0m" ;;
handshake) colored_event="\033[1;32mhandshake\033[0m" ;;
*) colored_event="$event" ;;
esac
printf " %-20s %-8s %-20s %-25s %b\n" \
"$ts" "wireguard" "$client" "$dst_or_endpoint" "$colored_event"
fi
done < <(json::follow_logs "$fw_log" "$wg_log" "$filter_ip" "$filter_type" \
"$clients_dir" "$filter_peers")
}
function cmd::logs::remove() {
@ -519,6 +185,7 @@ function cmd::logs::remove() {
esac
done
# Validate — need at least one filter
if ! $all && [[ -z "$name" && -z "$before" ]]; then
log::error "Specify --name, --before, or --all"
cmd::logs::help
@ -531,6 +198,7 @@ function cmd::logs::remove() {
filter_ip=$(peers::get_ip "$name")
fi
# Build description for confirmation
local desc=""
$all && desc="all entries"
[[ -n "$name" ]] && desc="entries for '${name}'"
@ -541,7 +209,7 @@ function cmd::logs::remove() {
if ! $force; then
read -r -p "Remove ${desc}? [y/N] " confirm
case "$confirm" in
[yY]*) ;;
[yY][eE][sS]|[yY]) ;;
*) log::info "Aborted"; return 0 ;;
esac
fi
@ -565,6 +233,62 @@ function cmd::logs::remove() {
log::wg_success "Removed ${total} log entries (wg: ${removed_wg}, fw: ${removed_fw})"
}
function cmd::logs::show_wg_events() {
local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" limit="${4:-50}"
[[ ! -f "$WG_EVENTS_LOG" ]] && return 0
printf " WireGuard Events:\n"
printf " %-20s %-20s %-18s %s\n" "TIME" "CLIENT" "ENDPOINT" "EVENT"
printf " %s\n" "$(printf '─%.0s' {1..75})"
local found=false
while IFS="|" read -r ts client endpoint event; do
[[ -z "$ts" ]] && continue
local colored_event
case "$event" in
attempt*) colored_event="\033[1;31m${event}\033[0m" ;;
handshake*) colored_event="\033[1;32m${event}\033[0m" ;;
*) colored_event="$event" ;;
esac
printf " %-20s %-20s %-18s %b\n" "$ts" "$client" "$endpoint" "$colored_event"
found=true
done < <(json::wg_events "$WG_EVENTS_LOG" "$filter_name" "$filter_type" "$limit")
$found || printf " —\n"
printf "\n"
}
function cmd::logs::show_fw_events() {
local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" limit="${4:-50}"
[[ ! -f "$FW_EVENTS_LOG" ]] && return 0
printf " Firewall Drops:\n"
printf " %-20s %-18s %-25s %s\n" "TIME" "CLIENT" "DESTINATION" "PROTOCOL"
printf " %s\n" "$(printf '─%.0s' {1..75})"
local found=false
while IFS="|" read -r ts client dst proto; do
[[ -z "$ts" ]] && continue
local colored_proto
case "$proto" in
tcp*) colored_proto="\033[1;33m${proto}\033[0m" ;;
udp*) colored_proto="\033[1;36m${proto}\033[0m" ;;
icmp*) colored_proto="\033[0;37m${proto}\033[0m" ;;
*) colored_proto="$proto" ;;
esac
printf " %-20s %-18s %-25s %b\n" "$ts" "$client" "$dst" "$colored_proto"
found=true
done < <(json::fw_events "$FW_EVENTS_LOG" "$filter_ip" "$filter_type" \
"$(ctx::clients)" "$limit")
$found || printf " —\n"
printf "\n"
}
function cmd::logs::rotate() {
local days=7 force=false
@ -601,30 +325,3 @@ function cmd::logs::rotate() {
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 --detailed
flag::register --force
command::mixin json_output
}
function cmd::net::help() {
@ -60,12 +58,6 @@ EOF
function cmd::net::run() {
local subcmd="${1:-list}"
shift || true
if command::json; then
cmd::net::_output_json
return 0
fi
case "$subcmd" in
list) cmd::net::list "$@" ;;
show) cmd::net::show "$@" ;;
@ -103,79 +95,53 @@ function cmd::net::list() {
return 0
fi
# Collect filtered data and build ports display per service
local filtered_data=""
while IFS="|" read -r name ip desc tags port_count; do
[[ -z "$name" ]] && continue
[[ -n "$filter_tag" && "$tags" != *"$filter_tag"* ]] && continue
log::section "Network Services"
printf "\n %-20s %-16s %-6s %s\n" "NAME" "IP" "PORTS" "DESCRIPTION"
local divider
divider=$(printf '─%.0s' {1..72})
printf " %s\n" "$divider"
# Build ports display from json::net_show
local ports_display=""
local found=false
while IFS="|" read -r name ip desc tags ports; do
[[ -z "$name" ]] && continue
# Tag filter
if [[ -n "$filter_tag" ]]; then
[[ "$tags" != *"$filter_tag"* ]] && continue
fi
found=true
local tag_display=""
[[ -n "$tags" ]] && tag_display=" \033[0;37m[${tags}]\033[0m"
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
local port_str=":${pport}"
[[ -n "$pproto" && "$pproto" != "tcp" ]] && port_str="${port_str}/${pproto}"
ports_display+="${port_str}, "
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")
ports_display="${ports_display%, }"
[[ -z "$ports_display" ]] && ports_display="-"
$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")
[[ -z "$filtered_data" ]] && {
if ! $found; then
[[ -n "$filter_tag" ]] && \
log::wg_warning "No services with tag: ${filter_tag}" || \
log::wg_warning "No services configured"
return 0
}
# Measure column widths
local w_name=12 w_ip=13 w_ports=16
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 ""
if display::is_table "net_list"; then
cmd::net::_render_table "$filtered_data"
return 0
fi
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 ""
}
function cmd::net::_render_table() {
local data="${1:-}"
[[ -z "$data" ]] && return 0
ui::net::list_header_table
while IFS='|' read -r name ip desc tags port_count; do
[[ -z "$name" ]] && continue
ui::net::list_row_table "$name" "$ip" "$desc" "$tags" "$port_count"
done <<< "$data"
printf "\n"
return 0
}
# ============================================
@ -199,24 +165,26 @@ function cmd::net::show() {
log::section "Service: ${name}"
printf "\n"
local has_ports=false
while IFS="|" read -r key val1 val2 val3 val4; do
case "$key" in
name) ui::row "Name" "$val1" ;;
ip) ui::row "IP" "$val1" ;;
desc) ui::row "Description" "${val1:-}" ;;
tags) ui::row "Tags" "${val1:-}" ;;
ip) ui::row "IP" "$val1" ;;
port)
if ! $has_ports; then
printf " %-20s\n" "Ports:"
has_ports=true
fi
ui::net::show_port_row "$val1" "$val2" "$val3" "$val4"
# val1=port_name val2=port val3=proto val4=desc
local ann
ann=$(net::annotation "$(json::net_resolve "$(ctx::net)" "$name")" \
"$val2" "$val3" 2>/dev/null || true)
printf " %-20s \033[0;36m%s\033[0m %s:%s%s\n" \
"${val1}:" "" "$val2" "$val3" \
"${val4:+ # $val4}"
;;
esac
done < <(json::net_show "$(ctx::net)" "$name")
printf "\n"
return 0
}
# ============================================
@ -329,30 +297,3 @@ function cmd::net::rm() {
log::wg_success "Removed: ${name}"
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 --field
flag::register --value
command::mixin json_output
}
# ============================================
@ -81,11 +79,6 @@ function cmd::policy::run() {
local subcmd="${1:-list}"
shift || true
if command::json; then
cmd::policy::_output_json
return 0
fi
case "$subcmd" in
list) cmd::policy::_list "$@" ;;
show) cmd::policy::_show "$@" ;;
@ -106,18 +99,13 @@ function cmd::policy::run() {
function cmd::policy::_list() {
local data
data=$(policy::list_data | ui::sort_rows 1)
data=$(policy::list_data)
if [[ -z "$data" ]]; then
log::info "No policies defined."
return 0
fi
if display::is_table "policy_list"; then
cmd::policy::_render_table "$data"
return 0
fi
echo ""
while IFS='|' read -r name tunnel default_rule strict auto desc; do
ui::policy::list_row "$name" "$default_rule" "$strict" "$auto"
@ -125,19 +113,6 @@ function cmd::policy::_list() {
echo ""
}
function cmd::policy::_render_table() {
local data="${1:-}"
[[ -z "$data" ]] && return 0
ui::policy::list_header_table
while IFS='|' read -r name tunnel default_rule strict auto desc; do
[[ -z "$name" ]] && continue
ui::policy::list_row_table "$name" "$tunnel" "$default_rule" "$strict" "$auto"
done <<< "$data"
printf "\n"
}
function cmd::policy::_show() {
local name=""
while [[ $# -gt 0 ]]; do
@ -270,27 +245,3 @@ function cmd::policy::_set() {
json::policy_set_field "$(ctx::policies)" "$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

@ -23,16 +23,14 @@ function cmd::rule::on_load() {
flag::register --peer
flag::register --peers
flag::register --dns-redirect
flag::register --color
flag::register --base
flag::register --no-base
flag::register --tree
flag::register --detailed
flag::register --resolved
flag::register --force
flag::register --type
flag::register --all
command::mixin json_output
}
# ============================================
@ -49,13 +47,12 @@ Service names from 'wgctl net' can be used instead of raw IPs/ports.
Subcommands:
list, ls List all rules
list --detailed Show inheritance tree
show, inspect --name <r> Show rule details and inheritance
add, new, create --name <r> Create a new rule
update, edit --name <r> Update a rule and re-apply to peers
remove, rm, del --name <r> Remove a rule
assign --name <r> Assign a rule to a peer
unassign --name <r> --peer <p> Remove rule from a peer
show, inspect Show rule details and inheritance
add, new, create Create a new rule
update, edit Update a rule and re-apply to peers
remove, rm, del Remove a rule
assign Assign a rule to a peer
unassign Remove rule from a peer
reapply Re-apply rule to all assigned peers
migrate Apply default rules to unassigned peers
@ -63,7 +60,7 @@ Options for list:
--base Show only base rules
--no-base Hide base rules section
--group <name> Filter by group (case insensitive)
--detailed Show rule entries inline
--tree Show full inheritance tree inline
Options for add:
--name <name> Rule name
@ -75,8 +72,8 @@ Options for add:
--allow-port <ip:port:proto> Allow specific port (repeatable)
--block-ip <ip/cidr> Block IP or subnet (repeatable)
--block-port <ip:port:proto> Block specific port (repeatable)
--block-service <name> Block named service (repeatable)
--allow-service <name> Allow named service (repeatable)
--block-service <name> Block named service — resolved to IP/port at creation (repeatable)
--allow-service <name> Allow named service — resolved to IP/port at creation (repeatable)
--dns-redirect Force DNS through Pi-hole
Options for update:
@ -88,7 +85,7 @@ Options for update:
--remove-block-ip <ip> Remove block IP entry
--remove-block-port <entry> Remove block port entry
Options for show:
Options for show/inspect:
--name <name> Rule name
--resolved Show resolved/merged entries
--no-peers Hide assigned peers
@ -99,12 +96,14 @@ Options for reapply:
Examples:
wgctl rule list
wgctl rule list --detailed
wgctl rule list --tree
wgctl rule list --group "VM Rules"
wgctl rule show --name guest
wgctl rule show --name moonlight-02 --resolved
wgctl rule add --name no-proxmox --base --block-service proxmox
wgctl rule add --name dev-01 --desc "Dev access" --extends no-lan
wgctl rule add --name dev-01 --desc "Dev access" --group "Dev" --extends no-lan
wgctl rule add --name restricted-dns --allow-service pihole:dns --block-service pihole
wgctl rule update --name user --add-extends no-nginx
wgctl rule assign --name dev-01 --peer laptop-nuno
wgctl rule reapply --all
EOF
@ -118,14 +117,10 @@ function cmd::rule::run() {
local subcmd="${1:-help}"
shift || true
if command::json; then
cmd::rule::_output_json
return 0
fi
case "$subcmd" in
list|ls) cmd::rule::list "$@" ;;
show|inspect) cmd::rule::show "$@" ;;
inspect) cmd::rule::inspect "$@" ;;
add|new|create) cmd::rule::add "$@" ;;
update|edit) cmd::rule::update "$@" ;;
remove|rm|del|delete) cmd::rule::remove "$@" ;;
@ -146,19 +141,61 @@ function cmd::rule::run() {
# List
# ============================================
function cmd::rule::_pad() {
local text="$1" width="$2"
local visible
visible=$(printf "%s" "$text" | sed 's/\x1b\[[0-9;]*m//g')
local visible_len=${#visible}
local byte_len=${#text}
local extra=$(( byte_len - visible_len ))
printf "%-$(( width + extra ))s" "$text"
}
function cmd::rule::_print_extends_tree() {
local extends="$1" indent="${2:-2}" rules_dir="$3"
[[ -z "$extends" ]] && return 0
local extend_list=()
IFS=',' read -ra extend_list <<< "$extends"
for base in "${extend_list[@]}"; do
[[ -z "$base" ]] && continue
local spaces
spaces=$(printf '%*s' "$indent" '')
printf " \033[0;37m%s↳ %s\033[0m\n" "$spaces" "$base"
if [[ "$indent" -lt 12 ]]; then
local sub_file=""
if sub_file=$(json::find_rule_file "$rules_dir" "$base" 2>/dev/null); then
local sub_extends=""
sub_extends=$(json::get "$sub_file" "extends" 2>/dev/null \
| tr '\n' ',' | sed 's/,$//' || true)
if [[ -n "$sub_extends" ]]; then
cmd::rule::_print_extends_tree \
"$sub_extends" $(( indent + 4 )) "$rules_dir"
fi
fi
fi
done
}
function cmd::rule::list() {
local rules_dir
rules_dir="$(ctx::rules)"
local show_base_only=false show_base=true
local filter_group="" detailed=false
local show_base_only=false
local show_base=true
local filter_group=""
local show_tree=false
local found_any=false
while [[ $# -gt 0 ]]; do
case "$1" in
--base) show_base_only=true; shift ;;
--no-base) show_base=false; shift ;;
--group) filter_group="${2,,}"; shift 2 ;;
--detailed) detailed=true; shift ;;
--group) util::require_flag "--group" "${2:-}" || return 1
filter_group="${2,,}"; shift 2 ;;
--tree) show_tree=true; shift ;;
--help) cmd::rule::help; return ;;
*)
log::error "Unknown flag: $1"
@ -166,94 +203,109 @@ function cmd::rule::list() {
esac
done
local data
data=$(json::rule_list_data "$rules_dir" "$(ctx::meta)")
[[ -z "$data" ]] && log::wg "No rules configured" && return 0
# Measure max name width
local w_name=12
while IFS='|' read -r name rest; do
[[ -z "$name" ]] && continue
(( ${#name} > w_name )) && w_name=${#name}
done <<< "$data"
(( w_name += 2 ))
log::section "Firewall Rules"
echo ""
if display::is_table "rule_list"; then
cmd::rule::_render_table "$data"
local rules=("${rules_dir}"/*.rule)
if [[ ! -f "${rules[0]}" ]]; then
log::wg "No rules configured"
return 0
fi
local current_group="" printing_base=false found_any=false
local header_printed=false
local printing_base=false
local current_group=""
while IFS="|" read -r name desc n_allows n_blocks \
peer_count extends is_base group; do
[[ -z "$name" ]] && continue
$show_base_only && [[ "$is_base" == "False" ]] && continue
! $show_base && [[ "$is_base" == "True" ]] && continue
[[ -n "$filter_group" && "${group,,}" != "$filter_group" ]] && continue
# --base: show ONLY base rules
if $show_base_only && [[ "$is_base" == "False" ]]; then
continue
fi
found_any=true
# --no-base: hide base rules
if ! $show_base && [[ "$is_base" == "True" ]]; then
continue
fi
# --group filter (case insensitive)
if [[ -n "$filter_group" && "${group,,}" != "$filter_group" ]]; then
continue
fi
# Print header on first match
if ! $header_printed; then
log::section "Firewall Rules"
printf "\n %-20s %-40s %-8s %-8s %s\n" \
"NAME" "DESCRIPTION" \
"$(ui::center "ALLOWS" 8)" \
"$(ui::center "BLOCKS" 8)" \
"PEERS"
local divider
divider=$(printf '─%.0s' {1..88})
printf " %s\n" "$divider"
header_printed=true
fi
# Base rules section header
if [[ "$is_base" == "True" && "$printing_base" == "false" ]]; then
if ! $show_base_only; then
ui::rule::list_base_header
local bdashes
bdashes=$(printf '─%.0s' {1..74})
printf "\n \033[0;37m── Base Rules \033[0m%s\n" "$bdashes"
fi
printing_base=true
current_group=""
fi
# Group header — non-base rules only
# Group header — only for non-base rules
if [[ "$is_base" == "False" && "${group,,}" != "${current_group,,}" ]]; then
if [[ -n "$group" ]]; then
ui::rule::list_group_header "$group"
printf "\n \033[0;36m▸ %s\033[0m\n" "$group"
elif [[ -n "$current_group" ]]; then
echo ""
printf "\n"
fi
current_group="$group"
fi
# Rule row
# ui::rule::list_row "$name" "$n_allows" "$n_blocks" "$peer_count" "$w_name"
local short_desc="${desc:0:35}"
[[ ${#desc} -gt 35 ]] && short_desc="${short_desc}..."
# Extends
# Rule row — pass extends_csv for compact inline display
local compact_extends=""
if [[ -z "$detailed" ]] || ! $detailed; then
compact_extends="$extends"
local desc_col_width=40
[[ "${short_desc:-}" == "—" ]] && desc_col_width=42
found_any=true
printf " %-20s %-${desc_col_width}s %-8s %-8s %s\n" \
"$name" "${short_desc:-}" \
"$(ui::center "$n_allows" 8)" \
"$(ui::center "$n_blocks" 8)" \
"${peer_count} peers"
# Print extends
if [[ -n "$extends" ]]; then
if $show_tree; then
cmd::rule::_print_extends_tree "$extends" 2 "$rules_dir"
else
local extend_list=()
IFS=',' read -ra extend_list <<< "$extends"
for base in "${extend_list[@]}"; do
[[ -z "$base" ]] && continue
printf " \033[0;37m ↳ %s\033[0m\n" "$base"
done
fi
ui::rule::list_row "$name" "$n_allows" "$n_blocks" "$peer_count" "$w_name" "$compact_extends"
# Detailed mode — show expanded entries
if $detailed && [[ -n "$extends" ]]; then
ui::rule::list_extends_detailed "$extends" "$rules_dir"
echo ""
printf "\n"
fi
done <<< "$data"
done < <(json::rule_list_data "$rules_dir" "$(ctx::meta)")
$found_any || {
[[ -n "$filter_group" ]] && \
log::wg_warning "No rules found in group: ${filter_group}" || \
if ! $found_any; then
if [[ -n "$filter_group" ]]; then
log::wg_warning "No rules found in group: ${filter_group}"
else
log::wg_warning "No rules found"
}
fi
fi
echo ""
}
function cmd::rule::_render_table() {
local data="${1:-}"
[[ -z "$data" ]] && return 0
ui::rule::list_header_table
while IFS='|' read -r name desc n_allows n_blocks peer_count extends is_base group; do
[[ -z "$name" ]] && continue
ui::rule::list_row_table "$name" "$n_allows" "$n_blocks" "$peer_count" "$extends" "$group"
done <<< "$data"
printf "\n"
}
@ -266,7 +318,8 @@ function cmd::rule::show() {
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--name) util::require_flag "--name" "${2:-}" || return 1
name="$2"; shift 2 ;;
--no-peers) show_peers=false; shift ;;
--resolved) show_resolved=true; shift ;;
--help) cmd::rule::help; return ;;
@ -280,7 +333,7 @@ function cmd::rule::show() {
local rule_file
rule_file="$(rule::path "$name")"
# DNS display
# ── DNS display ───────────────────────────────
local dns_redirect resolved_dns dns_display
dns_redirect=$(rule::get_own "$name" "dns_redirect")
dns_redirect="${dns_redirect:-false}"
@ -306,16 +359,19 @@ function cmd::rule::show() {
ui::row "DNS" "$dns_display"
printf "\n"
if ui::rule::tree "$name"; then
# ── Extends + own rules ────────────────────────
if rule::render_extends_tree "$name"; then
# Has inheritance — tree already rendered
:
else
ui::rule::flat "$name"
# No inheritance — flat view
rule::render_flat "$name"
printf "\n"
fi
# Resolved view
# ── Resolved ──────────────────────────────────
if $show_resolved; then
ui::rule::section_header "Resolved (applied to peers)"
cmd::rule::_show_section "Resolved (applied to peers)"
printf "\n"
local res_allow_ports res_allow_ips res_block_ips res_block_ports
res_allow_ports=$(rule::get "$name" "allow_ports")
@ -329,23 +385,26 @@ function cmd::rule::show() {
printf "\n"
fi
# Peers
$show_peers || return 0
# ── Peers ─────────────────────────────────────
local peer_list=()
mapfile -t peer_list < <(peers::with_rule "$name") || true
mapfile -t peer_list < <(peers::with_rule "$name")
local peer_count=${#peer_list[@]}
ui::empty "$peer_count" && return 0
[[ "$peer_count" -eq 1 ]]
printf "\n \033[0;37m── Peers (%s) \033[0m%s\n\n" \
"$peer_count" "$(printf '\033[0;37m─%.0s' {1..30})"
printf "\n"
printf "\033[0;37m── Peers (%s) \033[0m%s\n\n" \
"$(color::gray "${peer_count}")" \
"$(printf '\033[0;37m─%.0s' {1..35})"
if $show_peers && [[ $peer_count -gt 0 ]]; then
for peer_name in "${peer_list[@]}"; do
local ip
ip=$(peers::get_ip "$peer_name")
printf " %-28s %s\n" "$peer_name" "$ip"
done
fi
printf "\n"
return 0
}
@ -359,7 +418,8 @@ function cmd::rule::add() {
local extends=()
local allow_ips=() block_ips=() block_ports=() allow_ports=()
local block_services=() allow_services=()
local dns_redirect=false is_base=false
local dns_redirect=false
local is_base=false
while [[ $# -gt 0 ]]; do
case "$1" in
@ -392,10 +452,12 @@ function cmd::rule::add() {
return 1
fi
# Validate extends
for ext in "${extends[@]}"; do
rule::require_exists "$ext" || return 1
done
# Determine target directory
local rule_dir
if $is_base; then
rule_dir="$(ctx::rules)/base"
@ -425,6 +487,7 @@ function cmd::rule::add() {
done
local rule_file="${rule_dir}/${name}.rule"
local allow_str block_str port_str allow_port_str extends_str
allow_str=$(IFS=','; echo "${allow_ips[*]}")
block_str=$(IFS=','; echo "${block_ips[*]}")
@ -439,6 +502,7 @@ function cmd::rule::add() {
local base_label=""
$is_base && base_label=" (base)"
log::wg_success "Rule created: ${name}${base_label}"
}
@ -488,15 +552,18 @@ function cmd::rule::update() {
local rule_file
rule_file="$(rule::path "$name")"
# Update simple fields
[[ -n "$desc" ]] && json::set "$rule_file" "desc" "\"$desc\""
[[ -n "$group" ]] && json::set "$rule_file" "group" "\"$group\""
[[ -n "$dns_redirect" ]] && json::set "$rule_file" "dns_redirect" "true"
# Add entries
for ip in "${allow_ips[@]}"; do json::append "$rule_file" "allow_ips" "$ip"; done
for ip in "${block_ips[@]}"; do json::append "$rule_file" "block_ips" "$ip"; done
for p in "${block_ports[@]}"; do json::append "$rule_file" "block_ports" "$p"; done
for p in "${allow_ports[@]}"; do json::append "$rule_file" "allow_ports" "$p"; done
# Add/remove extends
for ext in "${add_extends[@]}"; do
rule::require_exists "$ext" || return 1
json::append "$rule_file" "extends" "$ext"
@ -505,6 +572,7 @@ function cmd::rule::update() {
json::remove "$rule_file" "extends" "$ext"
done
# Remove entries
for ip in "${rm_allow_ips[@]}"; do json::remove "$rule_file" "allow_ips" "$ip"; done
for ip in "${rm_block_ips[@]}"; do json::remove "$rule_file" "block_ips" "$ip"; done
for p in "${rm_block_ports[@]}"; do json::remove "$rule_file" "block_ports" "$p"; done
@ -515,7 +583,8 @@ function cmd::rule::update() {
}
# ============================================
# Remove
# Remove / Assign / Unassign / Migrate / Reapply
# (unchanged from original)
# ============================================
function cmd::rule::remove() {
@ -534,7 +603,7 @@ function cmd::rule::remove() {
rule::require_exists "$name" || return 1
local peer_list=()
mapfile -t peer_list < <(peers::with_rule "$name") || true
mapfile -t peer_list < <(peers::with_rule "$name")
local peer_count=${#peer_list[@]}
if [[ "$peer_count" -gt 0 ]]; then
@ -551,10 +620,6 @@ function cmd::rule::remove() {
log::wg_success "Rule removed: ${name}"
}
# ============================================
# Assign / Unassign
# ============================================
function cmd::rule::assign() {
local name="" peer="" type=""
while [[ $# -gt 0 ]]; do
@ -575,21 +640,10 @@ function cmd::rule::assign() {
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
existing_rule=$(peers::get_meta "$peer" "rule")
local ip
ip=$(peers::get_ip "$peer")
[[ -z "$ip" ]] && log::error "Could not resolve IP for: $peer" && return 1
@ -630,41 +684,37 @@ function cmd::rule::unassign() {
log::wg_success "Unassigned rule from: ${peer}"
}
# ============================================
# Migrate
# ============================================
function cmd::rule::migrate() {
log::section "Migrating peers to default rules"
local count=0
local tmp
tmp=$(mktemp)
while IFS= read -r peer_name; do
local existing
existing=$(peers::get_meta "$peer_name" "rule")
[[ -n "$existing" ]] && continue
# Try to get default rule from subnet policy
local peer_type subnet_name default_rule
peer_type=$(peers::get_meta "$peer_name" "type")
subnet_name=$(peers::get_meta "$peer_name" "subnet")
default_rule=$(subnet::default_rule "$subnet_name" "$peer_type")
[[ -z "$default_rule" ]] && continue
local default_rule
default_rule=$(peers::default_rule "$peer_name")
rule::exists "$default_rule" || continue
local ip
ip=$(peers::get_ip "$peer_name")
rule::apply "$default_rule" "$ip" "$peer_name"
(( count++ )) || true
echo "${peer_name} ${default_rule} ${ip}" >> "$tmp"
done < <(peers::all)
local count=0
local lines=()
mapfile -t lines < "$tmp"
rm -f "$tmp"
for line in "${lines[@]}"; do
IFS=" " read -r peer_name default_rule ip <<< "$line"
rule::apply "$default_rule" "$ip" "$peer_name" </dev/null
(( count++ )) || true
done
log::wg_success "Migrated ${count} peers"
}
# ============================================
# Reapply
# ============================================
function cmd::rule::reapply() {
local name="" all=false
while [[ $# -gt 0 ]]; do
@ -681,8 +731,9 @@ function cmd::rule::reapply() {
while IFS= read -r rule_file; do
local rname
rname=$(basename "$rule_file" .rule)
# Skip if no peers assigned
local peer_list=()
mapfile -t peer_list < <(peers::with_rule "$rname") || true
mapfile -t peer_list < <(peers::with_rule "$rname")
[[ ${#peer_list[@]} -eq 0 ]] && continue
rule::reapply_all "$rname"
(( count++ )) || true
@ -697,36 +748,18 @@ function cmd::rule::reapply() {
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)
# ============================================
# Show helpers
# ============================================
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}]"
function cmd::rule::_show_section() {
local title="${1:-}" color="${2:-white}" use_color="${3:-false}"
local color_code=""
if $use_color; then
case "$color" in
green) color_code="\033[0;32m" ;;
red) color_code="\033[0;31m" ;;
esac
fi
# 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"
printf "\n ${color_code}── %s ──────────────────────────────────\033[0m\n" "$title"
}

View file

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

View file

@ -22,8 +22,6 @@ function cmd::subnet::on_load() {
flag::register --desc
flag::register --group
flag::register --new-name
command::mixin json_output
}
# ============================================
@ -67,11 +65,6 @@ function cmd::subnet::run() {
local subcmd="${1:-list}"
shift || true
if command::json; then
cmd::subnet::_output_json
return 0
fi
case "$subcmd" in
list) cmd::subnet::_list "$@" ;;
show) cmd::subnet::_show "$@" ;;
@ -92,18 +85,13 @@ function cmd::subnet::run() {
function cmd::subnet::_list() {
local data
data=$(subnet::list_data | ui::sort_rows 1)
data=$(subnet::list_data)
if [[ -z "$data" ]]; then
log::info "No subnets defined."
return 0
fi
if display::is_table "subnet_list"; then
cmd::subnet::_render_table "$data"
return 0
fi
echo ""
local prev_group=""
while IFS='|' read -r display_name subnet type_key tunnel_mode desc is_group group_parent; do
@ -125,18 +113,6 @@ function cmd::subnet::_list() {
echo ""
}
function cmd::subnet::_render_table() {
local data="${1:-}"
[[ -z "$data" ]] && return 0
ui::subnet::list_header_table
while IFS='|' read -r type cidr display_name tunnel desc is_group group_parent; do
[[ -z "$type" ]] && continue
ui::subnet::list_row_table "$type" "$cidr" "$tunnel" "$desc"
done <<< "$data"
printf "\n"
}
function cmd::subnet::_maybe_group_separator() {
local is_group="${1:-}" group_parent="${2:-}" prev_group="${3:-}"
if [[ "$is_group" == "true" && "$group_parent" != "$prev_group" && -n "$prev_group" ]]; then
@ -316,25 +292,3 @@ function cmd::subnet::_validate_cidr() {
return 1
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_identity
cmd::test::_destructive_cleanup
cmd::test::_destructive_rule_duplicate
cmd::test::_destructive_peer_dns
}
function cmd::test::_destructive_peer() {
@ -123,67 +121,6 @@ function cmd::test::_destructive_identity() {
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() {
cmd::test::run_cmd "remove phone peer" "removed" \
remove --name phone-testunit --force

View file

@ -126,21 +126,12 @@ function cmd::test::run_all_integration_sections() {
cmd::test::section_config
cmd::test::section_rules
cmd::test::section_groups
cmd::test::section_block_unblock
cmd::test::section_audit
cmd::test::section_logs
cmd::test::section_fw
cmd::test::section_net
cmd::test::section_subnet
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
cmd::test::section_display
cmd::test::section_export
}
function cmd::test::section_list() {
@ -152,12 +143,6 @@ function cmd::test::section_list() {
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 --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() {
@ -166,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 phone-nuno --config" "PrivateKey" inspect --name phone-nuno --config
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() {
@ -177,8 +158,6 @@ function cmd::test::section_config() {
cmd::test::run_cmd "config --name phone-nuno" "PrivateKey" config --name phone-nuno
cmd::test::run_cmd "config --name nuno --type phone" "PrivateKey" config --name nuno --type phone
cmd::test::run_cmd "qr --name phone-nuno" "" qr --name phone-nuno
cmd::test::run_cmd "config migrate --dry-run" "" config migrate --dry-run
cmd::test::run_cmd_fails "config missing --name" config
}
function cmd::test::section_rules() {
@ -187,11 +166,6 @@ 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 user" "Description" rule show --name user
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
}
@ -199,98 +173,9 @@ function cmd::test::section_groups() {
test::section "Groups"
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 list --json" '"groups":' group list --json
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_blocks() {
# test::section "Blocks"
# cmd::test::run_cmd "block --reason records history" "block-history" block --name guest-test --reason "test block" --force
# cmd::test::run_cmd_succeeds "unblock clears history" unblock --name guest-test --force
# }
function cmd::test::section_block_unblock() {
test::section "Block / Unblock"
# ── Setup fixture ──
local fixture="phone-testblock"
wgctl unblock --name "$fixture" --force >/dev/null 2>&1 || true
wgctl remove --name "$fixture" --force >/dev/null 2>&1 || true
wgctl add --name testblock --type phone >/dev/null 2>&1 || true
local history_file
history_file="$(ctx::block_history)/${fixture}.json"
# ── Block ──
echo "DEBUG about to run: $WGCTL_BINARY block --name $fixture --force" >&2
cmd::test::run_cmd "block peer" "blocked" block --name "$fixture" --force
cmd::test::run_cmd "block already blocked" "already" block --name "$fixture" --force
wgctl unblock --name "$fixture" --force >/dev/null 2>&1 || true
cmd::test::run_cmd "block with reason" "blocked" block --name "$fixture" --force \
--reason "test reason"
# ── Block history file created ──
[[ -f "$history_file" ]] && test::pass "block history file created" \
|| test::fail "block history file not created"
# ── Block history fields ──
if [[ -f "$history_file" ]]; then
local has_id has_blocked_at has_endpoint has_reason
has_id=$(python3 -c "
import json
d=json.load(open('$history_file'))
print('yes' if d['history'] and 'id' in d['history'][-1] else 'no')
" 2>/dev/null)
has_blocked_at=$(python3 -c "
import json
d=json.load(open('$history_file'))
print('yes' if d['history'] and d['history'][-1].get('blocked_at') else 'no')
" 2>/dev/null)
has_endpoint=$(python3 -c "
import json
d=json.load(open('$history_file'))
print('yes' if 'endpoint_at_block' in d['history'][-1] else 'no')
" 2>/dev/null)
has_reason=$(python3 -c "
import json
d=json.load(open('$history_file'))
print('yes' if d['history'][-1].get('reason') == 'test reason' else 'no')
" 2>/dev/null)
cmd::test::assert "history has id" "$has_id" "yes"
cmd::test::assert "history has blocked_at" "$has_blocked_at" "yes"
cmd::test::assert "history has endpoint" "$has_endpoint" "yes"
cmd::test::assert "history has reason" "$has_reason" "yes"
fi
# ── Unblock ──
cmd::test::run_cmd "unblock peer" "unblocked" unblock --name "$fixture" --force \
--reason "test cleanup"
cmd::test::run_cmd "unblock not blocked" "not blocked" unblock --name "$fixture" --force
# ── Unblock history updated ──
if [[ -f "$history_file" ]]; then
local has_unblocked has_unblock_reason
has_unblocked=$(python3 -c "
import json
d=json.load(open('$history_file'))
print('yes' if d['history'] and d['history'][-1].get('unblocked_at') else 'no')
" 2>/dev/null)
has_unblock_reason=$(python3 -c "
import json
d=json.load(open('$history_file'))
print('yes' if d['history'][-1].get('unblock_reason') == 'test cleanup' else 'no')
" 2>/dev/null)
cmd::test::assert "history has unblocked_at" "$has_unblocked" "yes"
cmd::test::assert "history has unblock_reason" "$has_unblock_reason" "yes"
fi
# ── Teardown fixture ──
wgctl remove --name "$fixture" --force >/dev/null 2>&1 || true
rm -f "$history_file"
}
function cmd::test::section_audit() {
test::section "Audit"
cmd::test::run_cmd_any "audit" "passed" audit
@ -302,17 +187,8 @@ function cmd::test::section_logs() {
test::section "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 --fw" "Firewall Drops" logs --fw
cmd::test::run_cmd "logs --wg" "WireGuard Events" 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
cmd::test::run_cmd "logs --fw" "Activity" logs --fw
cmd::test::run_cmd "logs --wg" "Activity" logs --wg
}
function cmd::test::section_fw() {
@ -339,9 +215,6 @@ function cmd::test::section_net() {
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 service" "Removed" net rm --name test-svc --force
cmd::test::run_cmd "net list --json" '"services":' net list --json
cmd::test::run_cmd "net --json has tags" '"tags":' net list --json
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
}
@ -355,15 +228,19 @@ function cmd::test::section_subnet() {
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_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_fails "subnet rename in-use (desktop)" subnet rename --name desktop --new-name workstation
cmd::test::run_cmd "subnet rename unused" "renamed" 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 "subnet list --json" '"subnets":' subnet list --json
cmd::test::run_cmd "subnet --json has cidr" '"cidr":' subnet list --json
cmd::test::run_cmd "subnet --json is_group" '"is_group":' subnet list --json
cmd::test::run_cmd_fails "subnet rm nonexistent" subnet rm --name nonexistent-subnet
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_fails "subnet rename in-use (desktop)" \
subnet rename --name desktop --new-name workstation
cmd::test::run_cmd "subnet rename unused" "renamed" \
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() {
@ -371,176 +248,5 @@ function cmd::test::section_identity() {
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 show nuno" "nuno" identity show --name nuno
cmd::test::run_cmd "identity list --json" '"identities":' identity list --json
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
}
function cmd::test::section_export() {
test::section "Export"
# Single peer export
cmd::test::run_cmd "export --peer" '"export_type":"peer"' export --peer phone-nuno
cmd::test::run_cmd "export --peer has name" '"name":"phone-nuno"' export --peer phone-nuno
cmd::test::run_cmd "export --peer has conf" '"conf":' export --peer phone-nuno
cmd::test::run_cmd "export --peer has identity" '"identity":"nuno"' export --peer phone-nuno
cmd::test::run_cmd "export --peer has groups" '"groups":' export --peer phone-nuno
cmd::test::run_cmd "export --peer has blocks" '"blocks":' export --peer phone-nuno
cmd::test::run_cmd "export --peer conf-only" '"export_type":"peer_conf"' export --peer phone-nuno --conf-only
cmd::test::run_cmd "export --peer meta-only" '"export_type":"peer_meta"' export --peer phone-nuno --meta-only
cmd::test::run_cmd_fails "export missing flag" export
# Identity export
cmd::test::run_cmd "export --identity" '"export_type":"identity"' export --identity nuno
# Full backup
cmd::test::run_cmd "export --all" '"export_type"' export --all
cmd::test::run_cmd "export --all is full" '"full"' export --all
cmd::test::run_cmd "export --all has peers" '"peers"' export --all
cmd::test::run_cmd "export --all has rules" '"rules"' export --all
cmd::test::run_cmd "export --all has identities" '"identities"' export --all
cmd::test::run_cmd "export --all has config" '"config"' export --all
cmd::test::run_cmd "export --all no-config" '"peers"' export --all --no-config
# --no-config should NOT have config section
local no_config_out
no_config_out=$(wgctl export --all --no-config 2>/dev/null)
if echo "$no_config_out" | grep -qF '"config":'; then
test::fail "export --all --no-config should not have config section"
else
test::pass "export --all --no-config has no config section"
fi
# Export to file
local tmp_file="/tmp/wgctl_test_export_$$.json"
cmd::test::run_cmd "export --peer --out file" "Exported to" export --peer phone-nuno --out "$tmp_file"
[[ -f "$tmp_file" ]] && test::pass "export --out file exists" \
|| test::fail "export --out file not created"
rm -f "$tmp_file"
# Version in export
cmd::test::run_cmd "export has version" '"wgctl_version":' export --peer phone-nuno
}
function cmd::test::section_display() {
test::section "Display config"
cmd::test::run_cmd "display config exists" "" \
config show --name phone-nuno # just check wgctl works, not display directly
# Test style via unit tests (see unit section)
}
# ============================================
# Helpers
# ============================================
function cmd::test::assert() {
local desc="${1:-}" result="${2:-}" expected="${3:-}"
if [[ "$result" == "$expected" ]]; then
test::pass "$desc"
else
test::fail "${desc} (expected '${expected}', got '${result}')"
fi
}
function cmd::test::assert_true() {
local desc="${1:-}"
shift
if "$@" 2>/dev/null; then
test::pass "$desc"
else
test::fail "$desc (expected true, got false)"
fi
}
function cmd::test::assert_false() {
local desc="${1:-}"
shift
if ! "$@" 2>/dev/null; then
test::pass "$desc"
else
test::fail "$desc (expected false, got true)"
fi
}

View file

@ -44,13 +44,6 @@ function cmd::test::run_all_unit_sections() {
cmd::test::unit_subnet
cmd::test::unit_ip
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
cmd::test::unit_display
cmd::test::unit_version
}
function cmd::test::unit_subnet() {
@ -110,184 +103,3 @@ function cmd::test::unit_identity() {
cmd::test::assert "infer no convention" "$(identity::infer 'roboclean')" ""
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"
command::_load_mixins 2>/dev/null || true
# 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"
}
function cmd::test::unit_display() {
test::section "Unit: display config"
load_module display
# Default style is compact
cmd::test::assert "display::style peer_list default" \
"$(display::style "peer_list")" "compact"
# is_compact returns true for compact
display::is_compact "peer_list" && \
test::pass "display::is_compact peer_list" || \
test::fail "display::is_compact peer_list"
# is_table returns false for compact
display::is_table "peer_list" && \
test::fail "display::is_table should be false for compact" || \
test::pass "display::is_table returns false for compact"
# Unknown view defaults to compact
cmd::test::assert "display::style unknown view" \
"$(display::style "nonexistent_view")" "compact"
}
function cmd::test::unit_version() {
test::section "Unit: wgctl version"
local ver
ver=$(wgctl::version 2>/dev/null)
[[ -n "$ver" ]] && test::pass "wgctl::version returns value: $ver" \
|| test::fail "wgctl::version returns empty"
}

View file

@ -16,7 +16,6 @@ function cmd::unblock::on_load() {
flag::register --subnet
flag::register --all
flag::register --service
flag::register --reason
}
# ============================================
@ -60,7 +59,6 @@ function cmd::unblock::run() {
local name="" identity="" type=""
local ips=() subnets=() ports=() services=()
local all=false quiet=false force=false
local reason=""
while [[ $# -gt 0 ]]; do
case "$1" in
@ -73,7 +71,6 @@ function cmd::unblock::run() {
--subnet) subnets+=("$2"); shift 2 ;;
--port) ports+=("$2"); shift 2 ;;
--service) services+=("$2"); shift 2 ;;
--reason) reason="$2"; shift 2 ;;
--all) all=true; shift ;;
--help) cmd::unblock::help; return ;;
*)
@ -113,7 +110,6 @@ function cmd::unblock::run() {
if $all; then
cmd::unblock::_unblock_all "$name" "$client_ip" "$quiet"
cmd::unblock::_record_history "$name" "manual" "$reason"
return 0
fi
@ -184,9 +180,6 @@ function cmd::unblock::run() {
done
block::cleanup "$name"
# Record unblock for specific rules
cmd::unblock::_record_history "$name" "manual" "$reason"
return 0
}
@ -256,14 +249,3 @@ function cmd::unblock::_unblock_all() {
$quiet || log::wg_success "${name} has been unblocked."
return 0
}
function cmd::unblock::_record_history() {
local name="${1:-}" unblocked_by="${2:-manual}" reason="${3:-}"
json::block_history_unblock \
"$(ctx::block_history)" \
"$name" \
"$unblocked_by" \
"$reason" \
2>/dev/null > /dev/null || true
}

View file

@ -11,7 +11,6 @@ function cmd::watch::on_load() {
flag::register --blocked
flag::register --restricted
flag::register --allowed
flag::register --raw
}
# ============================================
@ -22,27 +21,295 @@ function cmd::watch::help() {
cat <<EOF
Usage: wgctl watch [options]
Live monitor of WireGuard activity.
Live monitor of WireGuard activity. Shows:
- Handshakes from connected peers (green)
- Connection attempts from blocked peers (red)
- Firewall drops from restricted peers (red)
- Connection attempts from blocked peers (red, SOURCE: wg)
- Firewall drops from rule-restricted peers (red, SOURCE: fw)
Options:
--name <name> Filter by client name
--type <type> Filter by device type
--peers <list> Comma-separated peer names (used internally by group watch)
--blocked Show only blocked peer attempts
--allowed Show only handshakes
--allowed Show only handshakes (allowed peers)
--restricted Show only firewall drop events
--raw Show raw IPs without host/service resolution
Examples:
wgctl watch
wgctl watch --name phone-nuno
wgctl watch --blocked
wgctl watch --allowed
wgctl watch --type phone
wgctl watch --name phone-nuno
wgctl watch --name phone-nuno --type phone
EOF
}
# ============================================
# Helpers
# ============================================
function cmd::watch::format_event() {
local ts="${1:-}" source="${2:-}" client="${3:-}"
local dest="${4:-}" event="${5:-}" status="${6:-}"
local event_color
case "$event" in
attempt|drop) event_color="\033[1;31m" ;;
handshake) event_color="\033[1;32m" ;;
*) event_color="\033[0;37m" ;;
esac
local status_color=""
case "$status" in
blocked) status_color="\033[1;31m" ;;
allowed) status_color="\033[1;32m" ;;
esac
printf " %-20s %-8s %-22s %-28s ${event_color}%-14s\033[0m ${status_color}%s\033[0m\n" \
"$ts" "$source" "$client" "${dest:-}" "$event" "$status"
}
function cmd::watch::header() {
log::section "wgctl — Live Monitor (Ctrl+C to stop)"
printf "\n %-20s %-8s %-22s %-28s %-14s %s\n" \
"TIME" "SOURCE" "CLIENT" "DESTINATION/ENDPOINT" "EVENT" "STATUS"
printf " %s\n\n" "$(printf '─%.0s' {1..105})"
}
function cmd::watch::_peer_in_filter() {
local peer="$1"
shift
local peer_set=("$@")
[[ ${#peer_set[@]} -eq 0 ]] && return 0 # no filter = all pass
for p in "${peer_set[@]}"; do
[[ "$p" == "$peer" ]] && return 0
done
return 1
}
# ============================================
# Handshake Poller
# ============================================
function cmd::watch::poll_handshakes() {
local filter_name="${1:-}" filter_type="${2:-}"
local allowed_only="${3:-false}"
local filter_peers="${4:-}"
local peer_set=()
[[ -n "$filter_peers" ]] && IFS=',' read -ra peer_set <<< "$filter_peers"
while IFS= read -r line; do
local public_key ts
public_key=$(echo "$line" | awk '{print $1}')
ts=$(echo "$line" | awk '{print $2}')
[[ -z "$ts" || "$ts" == "0" ]] && continue
# Find client by public key
local client_name=""
for conf in "$(ctx::clients)"/*.conf; do
[[ -f "$conf" ]] || continue
local name
name=$(basename "$conf" .conf)
local key
key=$(keys::public "$name" 2>/dev/null || echo "")
if [[ "$key" == "$public_key" ]]; then
client_name="$name"
break
fi
done
[[ -z "$client_name" ]] && continue
# Apply filters
[[ -n "$filter_name" && "$client_name" != "$filter_name" ]] && continue
cmd::watch::_peer_in_filter "$client_name" "${peer_set[@]}" || continue
if [[ -n "$filter_type" ]]; then
local ip
ip=$(grep "^Address" "$(ctx::clients)/${client_name}.conf" \
| awk '{print $3}' | cut -d'/' -f1)
local subnet
subnet=$(config::subnet_for "$filter_type")
string::starts_with "$ip" "$subnet" || continue
fi
# Only emit if handshake is new
local safe_key
safe_key=$(echo "$public_key" | md5sum | cut -d' ' -f1)
local prev_ts_file="/tmp/wgctl_hs_${safe_key}"
local prev_ts="0"
[[ -f "$prev_ts_file" ]] && prev_ts=$(cat "$prev_ts_file")
if [[ "$ts" != "$prev_ts" ]]; then
echo "$ts" > "$prev_ts_file"
local formatted_ts
formatted_ts=$(fmt::datetime "$ts")
local endpoint
endpoint=$(monitor::endpoint_for_key "$public_key")
cmd::watch::format_event \
"$formatted_ts" "wg" "$client_name" "${endpoint:-}" "handshake" "allowed"
fi
done < <(wg show "$(config::interface)" latest-handshakes 2>/dev/null)
}
# ============================================
# Event Tailer
# ============================================
function cmd::watch::tail_events() {
local filter_name="${1:-}" filter_type="${2:-}"
local blocked_only="${3:-false}" restricted_only="${4:-false}"
local allowed_only="${5:-false}" filter_peers="${6:-}"
declare -A _WATCH_LAST_ATTEMPT=()
declare -A _WATCH_LAST_FW=()
local peer_set=()
[[ -n "$filter_peers" ]] && IFS=',' read -ra peer_set <<< "$filter_peers"
declare -A _WATCH_LAST_ATTEMPT=()
# Build ip->name map for fw events
declare -A ip_to_name=()
local conf_file
while IFS= read -r conf_file; do
local name
name=$(basename "$conf_file" .conf)
local ip
ip=$(grep "^Address" "$conf_file" 2>/dev/null | awk '{print $3}' | cut -d'/' -f1)
[[ -n "$ip" && -n "$name" ]] && ip_to_name["$ip"]="$name"
done < <(find "$(ctx::clients)" -name "*.conf" 2>/dev/null)
# Source tracker via temp file (persists across subshell iterations)
local source_file
source_file=$(mktemp)
echo "wg" > "$source_file"
# Cleanup temp file on exit
trap "rm -f '$source_file'" EXIT
tail -f "$(ctx::events_log)" "$(ctx::fw_events_log)" 2>/dev/null \
| while IFS= read -r line; do
[[ -z "$line" ]] && continue
# Handle tail -f file headers
if [[ "$line" == "==> "* ]]; then
if [[ "$line" == *"fw_events"* ]]; then
echo "fw" > "$source_file"
else
echo "wg" > "$source_file"
fi
continue
fi
local source
source=$(cat "$source_file")
if [[ "$source" == "wg" ]]; then
$allowed_only && continue # wg events are attempts/blocked
local event_data
event_data=$(json::parse_event "$line")
[[ -z "$event_data" ]] && continue
local ts client endpoint event
IFS="|" read -r ts client endpoint event <<< "$event_data"
[[ -n "$filter_name" && "$client" != "$filter_name" ]] && continue
cmd::watch::_peer_in_filter "$client" "${peer_set[@]}" || continue
if [[ -n "$filter_type" ]]; then
local conf
conf="$(ctx::clients)/${client}.conf"
[[ -f "$conf" ]] || continue
local ip
ip=$(grep "^Address" "$conf" 2>/dev/null | awk '{print $3}' | cut -d'/' -f1)
local subnet
subnet=$(config::subnet_for "$filter_type")
string::starts_with "$ip" "$subnet" || continue
fi
$restricted_only && { cmd::list::is_restricted "$client" || continue; }
# Dedup
local now
now=$(date +%s)
local safe_client="${client//[-.]/_}"
local last="${_WATCH_LAST_ATTEMPT[$safe_client]:-0}"
(( now - last < 30 )) && continue
_WATCH_LAST_ATTEMPT[$safe_client]="$now"
local formatted_ts
formatted_ts=$(fmt::datetime_iso "$ts")
cmd::watch::format_event \
"$formatted_ts" "wg" "$client" "${endpoint:-}" "$event" "blocked"
else
# FW event
$allowed_only && continue
$blocked_only && continue # fw drops aren't "blocked peers" per se
local fw_data
fw_data=$(json::parse_fw_event "$line")
[[ -z "$fw_data" ]] && continue
local ts src_ip dst_ip dst_port proto
IFS="|" read -r ts src_ip dst_ip dst_port proto <<< "$fw_data"
[[ -z "$src_ip" ]] && continue
local fw_key="${src_ip}:${dst_ip}:${dst_port}:${proto}"
local now
now=$(date +%s)
local last_fw="${_WATCH_LAST_FW[$fw_key]:-0}"
local window=30
[[ "$proto" == "17" || "$proto" == "udp" ]] && window=10
[[ "$proto" == "1" || "$proto" == "icmp" ]] && window=5
local diff=$(( now - last_fw ))
(( diff < window )) && continue
_WATCH_LAST_FW["$fw_key"]="$now"
local client="${ip_to_name[$src_ip]:-$src_ip}"
[[ -n "$filter_name" && "$client" != "$filter_name" ]] && continue
cmd::watch::_peer_in_filter "$client" "${peer_set[@]}" || continue
if [[ -n "$filter_type" ]]; then
local peer_client="${ip_to_name[$src_ip]:-}"
[[ -z "$peer_client" ]] && continue
local conf
conf="$(ctx::clients)/${peer_client}.conf"
[[ -f "$conf" ]] || continue
local ip
ip=$(grep "^Address" "$conf" 2>/dev/null | awk '{print $3}' | cut -d'/' -f1)
local subnet
subnet=$(config::subnet_for "$filter_type")
string::starts_with "$ip" "$subnet" || continue
fi
local dst_str="${dst_ip:-}"
[[ -n "$dst_port" ]] && dst_str="${dst_ip}:${dst_port}/${proto}"
local formatted_ts
formatted_ts=$(fmt::datetime_iso "$ts")
cmd::watch::format_event \
"$formatted_ts" "fw" "$client" "$dst_str" "drop" "blocked"
fi
done
rm -f "$source_file"
}
# ============================================
# Run
# ============================================
@ -50,7 +317,6 @@ EOF
function cmd::watch::run() {
local filter_name="" filter_type="" filter_peers=""
local blocked_only=false allowed_only=false restricted_only=false
local raw=false
rm -f /tmp/wgctl_hs_* /tmp/wgctl_attempt_* 2>/dev/null || true
@ -62,7 +328,6 @@ function cmd::watch::run() {
--blocked) blocked_only=true; shift ;;
--allowed) allowed_only=true; shift ;;
--restricted) restricted_only=true; shift ;;
--raw) _WGCTL_RAW=true; shift ;;
--help) cmd::watch::help; return ;;
*)
log::error "Unknown flag: $1"
@ -72,203 +337,27 @@ function cmd::watch::run() {
esac
done
log::section "wgctl — Live Monitor (Ctrl+C to stop)"
printf "\n"
cmd::watch::header
monitor::live "$filter_name" "$filter_type" "$filter_peers" \
"$blocked_only" "$restricted_only" "$allowed_only" "$raw"
}
# ============================================
# Handshake Poller
# ============================================
function cmd::watch::_poll_handshakes() {
local filter_name="${1:-}" filter_type="${2:-}" filter_peers="${3:-}"
local w_client="${4:-20}" w_dest="${5:-18}"
# Collect rows with sort key before printing
local -a rows=()
while IFS= read -r line; do
local public_key ts
public_key=$(echo "$line" | awk '{print $1}')
ts=$(echo "$line" | awk '{print $2}')
[[ -z "$ts" || "$ts" == "0" ]] && continue
local client_name=""
for conf in "$(ctx::clients)"/*.conf; do
[[ -f "$conf" ]] || continue
local cname
cname=$(basename "$conf" .conf)
local key
key=$(keys::public "$cname" 2>/dev/null || echo "")
if [[ "$key" == "$public_key" ]]; then
client_name="$cname"
break
fi
if ! $blocked_only && ! $restricted_only; then
(
while true; do
cmd::watch::poll_handshakes \
"$filter_name" "$filter_type" "$allowed_only" "$filter_peers"
sleep 5
done
[[ -z "$client_name" ]] && continue
[[ -n "$filter_name" && "$client_name" != "$filter_name" ]] && continue
local safe_key
safe_key=$(echo "$public_key" | md5sum | cut -d' ' -f1)
local prev_ts_file="/tmp/wgctl_hs_${safe_key}"
local prev_ts="0"
[[ -f "$prev_ts_file" ]] && prev_ts=$(cat "$prev_ts_file")
[[ "$ts" == "$prev_ts" ]] && continue
local gap=$(( ts - ${prev_ts:-0} ))
echo "$ts" > "$prev_ts_file"
(( gap < ${WG_HANDSHAKE_CHECK_TIME_SEC:-300} )) && continue
local ts_fmt
ts_fmt=$(fmt::datetime_short "$ts")
# Resolve endpoint — try wg show first, fall back to endpoint cache
local endpoint
endpoint=$(monitor::endpoint_for_key "$public_key")
if [[ -z "$endpoint" ]]; then
endpoint=$(monitor::get_cached_endpoint "$client_name")
) &
local poller_pid=$!
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}")
cmd::watch::tail_events \
"$filter_name" "$filter_type" \
"$blocked_only" "$restricted_only" \
"$allowed_only" "$filter_peers" &
local tailer_pid=$!
done < <(wg show "$(config::interface)" latest-handshakes 2>/dev/null)
trap "kill $tailer_pid ${poller_pid:-} 2>/dev/null; \
rm -f /tmp/wgctl_hs_* /tmp/wgctl_attempt_*; echo ''; exit 0" INT TERM
# 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
# ============================================
function cmd::watch::_tail_events() {
local filter_name="${1:-}" filter_type="${2:-}" filter_peers="${3:-}"
local blocked_only="${4:-false}" restricted_only="${5:-false}" allowed_only="${6:-false}"
local w_client="${7:-20}" w_dest="${8:-18}"
# Build ip->name map
declare -A ip_to_name=()
while IFS= read -r conf; do
local cname
cname=$(basename "$conf" .conf)
local ip
ip=$(grep "^Address" "$conf" 2>/dev/null | awk '{print $3}' | cut -d'/' -f1)
[[ -n "$ip" && -n "$cname" ]] && ip_to_name["$ip"]="$cname"
done < <(find "$(ctx::clients)" -name "*.conf" 2>/dev/null)
declare -A _WATCH_LAST_FW=()
declare -A _WATCH_LAST_WG=()
local source_file
source_file=$(mktemp)
echo "wg" > "$source_file"
trap "rm -f '$source_file'" EXIT
tail -f "$(ctx::events_log)" "$(ctx::fw_events_log)" 2>/dev/null \
| while IFS= read -r line; do
[[ -z "$line" ]] && continue
if [[ "$line" == "==> "* ]]; then
[[ "$line" == *"fw_events"* ]] && echo "fw" > "$source_file" || echo "wg" > "$source_file"
continue
fi
local source
source=$(cat "$source_file")
if [[ "$source" == "fw" ]]; then
$allowed_only && continue
local fw_data
fw_data=$(python3 "$(ctx::json_helper)" parse_fw_event "$line" 2>/dev/null) || continue
[[ -z "$fw_data" ]] && continue
local ts src_ip dest_ip dest_port proto
IFS='|' read -r ts src_ip dest_ip dest_port proto <<< "$fw_data"
[[ -z "$src_ip" ]] && continue
local client="${ip_to_name[$src_ip]:-$src_ip}"
[[ -n "$filter_name" && "$client" != "$filter_name" ]] && continue
local fw_key="${src_ip}:${dest_ip}:${dest_port}:${proto}"
local now; now=$(date +%s)
local window=30
[[ "$proto" == "17" || "$proto" == "udp" ]] && window=10
[[ "$proto" == "1" || "$proto" == "icmp" ]] && window=5
local last="${_WATCH_LAST_FW[$fw_key]:-0}"
(( now - last < window )) && continue
_WATCH_LAST_FW["$fw_key"]="$now"
local ts_fmt
ts_fmt=$(fmt::datetime_short "$(json::iso_to_ts "$ts" 2>/dev/null || echo 0)")
local fw_svc_name
fw_svc_name=$(resolve::service_name "$dest_ip" "$dest_port" "$proto")
local fw_src_ep fw_src_resolved=""
fw_src_ep=$(monitor::get_cached_endpoint "$client")
if [[ -n "$fw_src_ep" ]]; then
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
$restricted_only && continue
local ev_data
ev_data=$(python3 "$(ctx::json_helper)" parse_event "$line" 2>/dev/null) || continue
[[ -z "$ev_data" ]] && continue
local ts client endpoint event
IFS='|' read -r ts client endpoint event <<< "$ev_data"
[[ -n "$filter_name" && "$client" != "$filter_name" ]] && continue
$blocked_only && [[ "$event" != "attempt" ]] && continue
$allowed_only && [[ "$event" != "handshake" ]] && continue
local wg_key="${client}:${endpoint}:${event}"
local now; now=$(date +%s)
local last="${_WATCH_LAST_WG[$wg_key]:-0}"
local window=30
[[ "$event" == "handshake" ]] && window="${WG_HANDSHAKE_CHECK_TIME_SEC:-300}"
(( now - last < window )) && continue
_WATCH_LAST_WG["$wg_key"]="$now"
local ts_fmt
ts_fmt=$(fmt::datetime_short "$(json::iso_to_ts "$ts" 2>/dev/null || echo 0)")
# Resolve endpoint — fall back to endpoint cache if empty
local wg_ep_raw wg_ep_resolved=""
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
done
rm -f "$source_file"
wait
}

View file

@ -11,12 +11,9 @@ source "${WGCTL_DIR}/core/context.sh"
source "${WGCTL_DIR}/core/utils.sh"
source "${WGCTL_DIR}/core/module.sh"
source "${WGCTL_DIR}/core/command.sh"
source "${WGCTL_DIR}/core/command_mixins.sh"
source "${WGCTL_DIR}/core/flag.sh"
source "${WGCTL_DIR}/core/json.sh"
source "${WGCTL_DIR}/core/ui.sh"
source "${WGCTL_DIR}/core/color.sh"
source "${WGCTL_DIR}/core/fmt.sh"
source "${WGCTL_DIR}/core/test/test.sh"
command::_load_mixins

View file

@ -8,7 +8,6 @@ declare -A _LOADED_COMMANDS=()
readonly _COMMAND_NAMESPACE="cmd"
readonly _COMMAND_AUTO_LOAD_HOOK="on_load"
_CURRENT_LOADING_CMD=""
# ============================================
# Helpers
@ -37,65 +36,14 @@ function command::exists() { command::has_function "$1" run; }
# Runner
# ============================================
# function command::run() {
# local cmd="$1"
# shift
# command::_reset_mixin_state # reset values only, keep _ACTIVE_MIXINS
# local -a args=("$@")
# command::_preprocess_flags args
# local fn
# fn=$(command::fn "$cmd" run)
# core::call_function "$fn" ${args[@]+"${args[@]}"}
# }
function command::run() {
local cmd="$1"
shift
command::_reset_mixin_state
# Build default args from config
local -a default_args=()
local defaults="${_COMMAND_DEFAULTS[$cmd]:-}"
if [[ -n "$defaults" ]]; then
read -ra default_args <<< "$defaults"
fi
local -a user_args=("$@")
[[ $# -gt 0 ]] && user_args=("$@")
# Resolve exclusive group conflicts — user args override defaults
local groups="${_FLAG_EXCLUSIVE_GROUPS[$cmd]:-}"
if [[ -n "$groups" && ${#default_args[@]} -gt 0 && ${#user_args[@]} -gt 0 ]]; then
command::_resolve_conflicts default_args user_args "$groups"
fi
local -a cleaned_defaults=()
for _d in "${default_args[@]:-}"; do
[[ -n "$_d" ]] && cleaned_defaults+=("$_d")
done
default_args=("${cleaned_defaults[@]:-}")
local -a args=()
for _d in "${default_args[@]:-}"; do
[[ -n "$_d" ]] && args+=("$_d")
done
for _u in "${user_args[@]:-}"; do
[[ -n "$_u" ]] && args+=("$_u")
done
# Preprocess mixin flags (--json, --no-color etc)
command::_preprocess_flags args
local fn
fn=$(command::fn "$cmd" run)
core::call_function "$fn" ${args[@]+"${args[@]}"}
core::call_function "$fn" "$@"
}
function core::call_function() {
local fn="$1"
shift
@ -122,9 +70,7 @@ function load_command() {
source "$path"
_LOADED_COMMANDS["$name"]=1
_CURRENT_LOADING_CMD="$name"
core::call_if_exists "$(command::fn "$name" on_load)"
_CURRENT_LOADING_CMD=""
return 0
}

View file

@ -1,195 +0,0 @@
#!/usr/bin/env bash
# core/command_mixins.sh
# Mixin infrastructure — loads mixin files and provides command::mixin / flag::exclusive
# ============================================
# 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
}
# command::_resolve_conflicts <defaults_nameref> <user_nameref> <groups_string>
# Removes conflicting defaults when user provides a member of an exclusive group
function command::_resolve_conflicts() {
local -n _def_ref="$1"
local -n _usr_ref="$2"
local groups="$3"
[[ -z "$groups" ]] && return 0
[[ ${#_def_ref[@]} -eq 0 ]] && return 0
# Work on a copy — progressively filter across all groups
local -a working=("${_def_ref[@]}")
local group
while IFS= read -r group; do
[[ -z "$group" ]] && continue
local -a members=()
IFS=',' read -ra members <<< "$group"
# Find which member (if any) the user passed from this group
local user_member=""
local member user_arg
for member in "${members[@]}"; do
for user_arg in "${_usr_ref[@]:-}"; do
if [[ "$user_arg" == "$member" ]]; then
user_member="$member"
break 2
fi
done
done
# No user member in this group — don't touch defaults
[[ -z "$user_member" ]] && continue
# User passed a member — remove all OTHER members from defaults
# (keep the same flag if it was already in defaults)
local -a new_working=()
local def_arg
for def_arg in "${working[@]:-}"; do
local is_other_member=false
for member in "${members[@]}"; do
# It's another member if it's in the group AND not the same as user's choice
if [[ "$def_arg" == "$member" && "$def_arg" != "$user_member" ]]; then
is_other_member=true
break
fi
done
$is_other_member || new_working+=("$def_arg")
done
working=("${new_working[@]:-}")
done < <(echo "$groups" | tr '|' '\n')
# Write back
if [[ ${#working[@]} -gt 0 ]]; then
_def_ref=("${working[@]}")
else
_def_ref=()
fi
}
# ============================================
# Flag Exclusive
# ============================================
declare -gA _FLAG_EXCLUSIVE_GROUPS=()
# flag::exclusive <flag1> <flag2> ...
# Called from on_load — registers mutually exclusive flags for current command
function flag::exclusive() {
local cmd="${_CURRENT_LOADING_CMD:-}"
[[ -z "$cmd" ]] && return 0
# Join flags with comma as one group
local group
group=$(IFS=','; echo "$*")
if [[ -n "${_FLAG_EXCLUSIVE_GROUPS[$cmd]:-}" ]]; then
_FLAG_EXCLUSIVE_GROUPS["$cmd"]+="${group}|"
else
_FLAG_EXCLUSIVE_GROUPS["$cmd"]="${group}|"
fi
}

View file

@ -10,92 +10,76 @@ _CTX_CORE="${_CTX_ROOT}/core"
_CTX_MODULES="${_CTX_ROOT}/modules"
_CTX_COMMANDS="${_CTX_ROOT}/commands"
_CTX_CLIENTS="${_CTX_WG}/clients"
_CTX_DATA="${_CTX_WG}/.wgctl"
# ── Directory layout ──────────────────────────────────
# .wgctl/
# config/ ← wgctl.json, display.json
# data/ ← all persistent data (rules, identities, etc.)
# daemon/ ← runtime files (logs, caches)
# ============================================
# Artifacts
# ============================================
_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_BASE="${_CTX_RULES}/base"
_CTX_GROUPS="${_CTX_DATA}/groups"
_CTX_BLOCKS="${_CTX_DATA}/blocks"
_CTX_META="${_CTX_DATA}/meta"
_CTX_IDENTITY="${_CTX_DATA}/identities"
_CTX_PEER_HISTORY="${_CTX_DATA}/peer-history"
# ── Data files ────────────────────────────────────────
_CTX_DAEMON="${_CTX_DATA}/daemon"
_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::core() { echo "$_CTX_CORE"; }
function ctx::modules() { echo "$_CTX_MODULES"; }
function ctx::commands() { echo "$_CTX_COMMANDS"; }
function ctx::wg() { echo "$_CTX_WG"; }
function ctx::clients() { echo "$_CTX_CLIENTS"; }
# Top-level dirs
function ctx::wgctl() { echo "$_CTX_WGCTL"; }
function ctx::config() { echo "$_CTX_CONFIG"; }
function ctx::data() { echo "$_CTX_DATA"; }
function ctx::daemon() { echo "$_CTX_DAEMON"; }
# Data subdirs
function ctx::blocks() { echo "$_CTX_BLOCKS"; }
function ctx::groups() { echo "$_CTX_GROUPS"; }
function ctx::rules() { echo "$_CTX_RULES"; }
function ctx::rules::base() { echo "$_CTX_RULES_BASE"; }
function ctx::clients() { echo "$_CTX_CLIENTS"; }
function ctx::wg() { echo "$_CTX_WG"; }
function ctx::data() { echo "$_CTX_DATA"; }
function ctx::rules() { echo "$_CTX_RULES"; }
function ctx::groups() { echo "$_CTX_GROUPS"; }
function ctx::blocks() { echo "$_CTX_BLOCKS"; }
function ctx::meta() { echo "$_CTX_META"; }
function ctx::identities() { echo "$_CTX_IDENTITY"; }
function ctx::peer_history() { echo "$_CTX_PEER_HISTORY"; }
# Data files
function ctx::daemon() { echo "$_CTX_DAEMON"; }
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"; }
function ctx::accept_events_log() { echo "${_CTX_DAEMON}/accept_events.log"; }
# Tool paths
function ctx::identities() { echo "${_CTX_IDENTITY}"; }
function ctx::subnets() { echo "${_CTX_DATA}/subnets.json"; }
function ctx::events_log() { echo "$(ctx::daemon)/events.log"; }
function ctx::fw_events_log() { echo "$(ctx::daemon)/fw_events.log"; }
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"; }
function ctx::block_history() { echo "${_CTX_DATA}/block-history"; }
# ============================================
# Path Helpers
# ============================================
function ctx::client::path() { local IFS="/"; echo "$_CTX_CLIENTS/$*"; }
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/$*"; }
function ctx::client::path() {
local IFS="/"
echo "$_CTX_CLIENTS/$*"
}
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

@ -13,7 +13,6 @@ FMT_DATETIME_EU="%d/%m/%Y %H:%M" # 10/05/2026 22:39
# Default — can be overridden in wgctl.conf
FMT_DATE="${FMT_DATE_ISO}"
FMT_DATETIME="${FMT_DATETIME_ISO}"
FMT_DATETIME_SHORT="%m-%d %H:%M"
# Load from config or use default
_FMT_DATE_FORMAT="${DATE_FORMAT:-iso}"
@ -63,33 +62,16 @@ function fmt::set_date_format() {
iso)
FMT_DATE="%Y-%m-%d"
FMT_DATETIME="%Y-%m-%d %H:%M"
FMT_DATETIME_SHORT="%m-%d %H:%M"
;;
eu)
FMT_DATE="%d/%m/%Y"
FMT_DATETIME="%d/%m/%Y %H:%M"
FMT_DATETIME_SHORT="%d/%m %H:%M"
;;
eu-dash)
FMT_DATE="%d-%m-%Y"
FMT_DATETIME="%d-%m-%Y %H:%M"
FMT_DATETIME_SHORT="%d-%m %H:%M"
;;
*) log::error "Unknown date format: $format" ;;
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::last_event() { python3 "$JSON_HELPER" last_event "$@" </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::wg_events() { WGCTL_DATETIME_FMT="$FMT_DATETIME_SHORT" python3 "$JSON_HELPER" wg_events "$@" "$(ctx::endpoint_cache)" </dev/null; }
function json::fw_events() { WGCTL_DATETIME_FMT="$FMT_DATETIME" python3 "$JSON_HELPER" fw_events "$@" </dev/null; }
function json::wg_events() { WGCTL_DATETIME_FMT="$FMT_DATETIME" python3 "$JSON_HELPER" wg_events "$@" </dev/null; }
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::remove_events() { python3 "$JSON_HELPER" remove_events "$@" </dev/null; }
@ -110,55 +110,6 @@ function json::subnet_policy() { python3 "$JSON_HELPER" subnet_policy
function json::activity_aggregate() { python3 "$JSON_HELPER" activity_aggregate "$@" </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::block_history_record() { python3 "$JSON_HELPER" block_history_record "$@" </dev/null; }
function json::block_history_unblock() { python3 "$JSON_HELPER" block_history_unblock "$@" </dev/null; }
function json::block_history_list() { python3 "$JSON_HELPER" block_history_list "$@" </dev/null; }
function json::block_history_list_all() { python3 "$JSON_HELPER" block_history_list_all "$@" </dev/null; }
function json::endpoint_cache_get() { python3 "$JSON_HELPER" endpoint_cache_get "$@" </dev/null; }
# Accept Events
function json::accept_events() { python3 "$JSON_HELPER" accept_events "$@" </dev/null; }
function json::accept_aggregate() { python3 "$JSON_HELPER" accept_aggregate "$@" </dev/null; }
function json::batch_resolve_dest() { python3 "$JSON_HELPER" batch_resolve_dest "$(ctx::net)" "$(ctx::hosts)" "$@" </dev/null; }
function json::peer_transfer() {
ACTIVITY_TOTAL_LOW="$(config::activity_total_low)" \
ACTIVITY_TOTAL_MED="$(config::activity_total_med)" \
@ -172,8 +123,3 @@ function json::peer_transfer_delta() {
python3 "$JSON_HELPER" peer_transfer_delta "$@" </dev/null
}
# Importer
function json::import_peer() { python3 "$JSON_HELPER" import_peer "$@" </dev/null; }
function json::import_identity() { python3 "$JSON_HELPER" import_identity "$@" </dev/null; }
function json::import_full() { python3 "$JSON_HELPER" import_full "$@" </dev/null; }
function json::import_get_field() { python3 "$JSON_HELPER" import_get_field "$@" </dev/null; }

File diff suppressed because it is too large Load diff

View file

View file

@ -1,260 +0,0 @@
"""
accept_events.py conntrack accept event processing.
Reads accept_events.log written by wgctl-conntrack daemon.
Each line is a JSON object with fields:
ts, peer, src_ip, dst_ip, dst_port, proto,
bytes_orig, bytes_reply, packets_orig, packets_reply,
duration_sec, service, event, external
"""
import os
import json
from collections import defaultdict
from datetime import datetime
from lib.util import (
DATETIME_FMT,
load_net_data, load_hosts_data,
reverse_lookup, hosts_lookup,
fmt_ts, fmt_ts_hour, ts_to_unix, parse_since,
)
def accept_events(file, filter_peer, filter_type, net_file,
limit, collapse='1', since='', filter_external='0',
sort_order='desc'):
"""
Format accept events with optional aggregation.
Output per line (collapse=1):
ts|peer|dst_ip|dst_port|proto|bytes_total|packets_total|count|duration_avg
Output per line (collapse=0):
ts|peer|dst_ip|dst_port|proto|bytes_orig|bytes_reply|packets_orig|packets_reply|duration_sec
"""
do_collapse = str(collapse) != '0'
external_only = str(filter_external) == '1'
limit = int(limit) if limit else 100
since_dt = parse_since(since) if since else None
descending = sort_order != 'asc'
events = []
try:
with open(file) as f:
for line in f:
try:
e = json.loads(line.strip())
if not e.get('peer'):
continue
if filter_peer and e.get('peer') != filter_peer:
continue
if filter_type and not e.get('peer', '').startswith(filter_type + '-'):
continue
if external_only and not e.get('external', False):
continue
if not external_only and e.get('external', False):
continue
if since_dt:
ts_str = e.get('ts', '')
try:
from datetime import timezone
ev_dt = datetime.fromisoformat(ts_str.replace('Z', '+00:00'))
if ev_dt < since_dt:
continue
except Exception:
pass
events.append(e)
except Exception:
pass
except Exception:
pass
if do_collapse:
# Aggregate by peer + dst_ip + dst_port + proto + hour
buckets = defaultdict(lambda: {'count': 0, 'bytes': 0, 'packets': 0, 'duration': 0.0})
bucket_ts = {}
for e in events:
ts_str = e.get('ts', '')
peer = e.get('peer', '')
dst_ip = e.get('dst_ip', '')
dst_port = str(e.get('dst_port', ''))
proto = e.get('proto', '')
try:
dt = datetime.fromisoformat(ts_str.replace('Z', '+00:00'))
hour_key = (peer, dst_ip, dst_port, proto, dt.strftime('%Y-%m-%d %H'))
except Exception:
continue
b = buckets[hour_key]
b['count'] += 1
b['bytes'] += e.get('bytes_orig', 0) + e.get('bytes_reply', 0)
b['packets'] += e.get('packets_orig', 0) + e.get('packets_reply', 0)
b['duration'] += e.get('duration_sec', 0.0)
if hour_key not in bucket_ts:
bucket_ts[hour_key] = dt
# Sort and limit
sorted_buckets = sorted(bucket_ts.items(), key=lambda x: x[1])
output = sorted_buckets[-limit:]
if descending:
output = list(reversed(output))
for hour_key, dt in output:
peer, dst_ip, dst_port, proto, _ = hour_key
b = buckets[hour_key]
ts_fmt = fmt_ts_hour(dt.isoformat())
dur_avg = b['duration'] / b['count'] if b['count'] > 0 else 0.0
print(f"{ts_fmt}|{peer}|{dst_ip}|{dst_port}|{proto}|{b['bytes']}|{b['packets']}|{b['count']}|{dur_avg:.1f}")
else:
# Detailed — one row per event
result = [(ts_to_unix(e.get('ts', '')), e) for e in events]
result = result[-limit:]
if descending:
result.reverse()
for _, e in result:
ts_fmt = fmt_ts(e.get('ts', ''))
peer = e.get('peer', '')
dst_ip = e.get('dst_ip', '')
dst_port = str(e.get('dst_port', ''))
proto = e.get('proto', '')
b_orig = e.get('bytes_orig', 0)
b_reply = e.get('bytes_reply', 0)
p_orig = e.get('packets_orig', 0)
p_reply = e.get('packets_reply', 0)
dur = e.get('duration_sec', 0.0)
print(f"{ts_fmt}|{peer}|{dst_ip}|{dst_port}|{proto}|{b_orig}|{b_reply}|{p_orig}|{p_reply}|{dur:.1f}")
def accept_aggregate(file, net_file, clients_dir, since='',
filter_peer='', external_only='0', exclude_services=''):
"""
Aggregate accept events per peer total bytes, packets, top destinations.
Used by wgctl activity to show accepted traffic alongside drops.
external_only='1': only show traffic to external IPs (non-private)
external_only='0': only show traffic to internal IPs (default)
Output:
peer|peer_name|bytes_in|bytes_out|packets_in|packets_out|conn_count
dest|peer_name|dst_ip|dst_port|proto|bytes_total|conn_count
"""
from collections import defaultdict
from itertools import groupby
from lib.util import load_net_data, hosts_lookup, reverse_lookup
since_dt = parse_since(since) if since else None
show_external = str(external_only) == '1'
peer_stats = defaultdict(lambda: {
'bytes_in': 0, 'bytes_out': 0,
'packets_in': 0, 'packets_out': 0,
'conn_count': 0
})
# dest_stats = defaultdict(lambda: {'bytes': 0, 'count': 0})
dest_stats = defaultdict(lambda: {'bytes_orig': 0, 'bytes_reply': 0, 'count': 0})
# Build exclusion set — supports service names and ip:port:proto
exclude_set = set()
if exclude_services:
for svc in exclude_services.split():
exclude_set.add(svc.strip())
net_data = load_net_data(net_file) if (net_file and exclude_set) else {}
try:
with open(file) as f:
for line in f:
try:
e = json.loads(line.strip())
peer = e.get('peer', '')
if not peer:
continue
if filter_peer and peer != filter_peer:
continue
# Filter by external/internal
is_external = e.get('external', False)
if show_external and not is_external:
continue
if not show_external and is_external:
continue
if since_dt:
ts_str = e.get('ts', '')
try:
from datetime import timezone
ev_dt = datetime.fromisoformat(
ts_str.replace('Z', '+00:00'))
if ev_dt < since_dt:
continue
except Exception:
pass
dst_ip = e.get('dst_ip', '')
dst_port = str(e.get('dst_port', ''))
proto = e.get('proto', '')
b_orig = e.get('bytes_orig', 0)
b_reply = e.get('bytes_reply', 0)
p_orig = e.get('packets_orig', 0)
p_reply = e.get('packets_reply', 0)
ps = peer_stats[peer]
ps['bytes_out'] += b_orig
ps['bytes_in'] += b_reply
ps['packets_out'] += p_orig
ps['packets_in'] += p_reply
ps['conn_count'] += 1
if _is_excluded(dst_ip, dst_port, proto, exclude_set, net_data):
continue
dest_key = (peer, dst_ip, dst_port, proto)
dest_stats[dest_key]['bytes_orig'] += b_orig
dest_stats[dest_key]['bytes_reply'] += b_reply
dest_stats[dest_key]['count'] += 1
except Exception:
pass
except Exception:
pass
# Output peer summaries
for peer, ps in sorted(peer_stats.items()):
print(f"peer|{peer}|{ps['bytes_in']}|{ps['bytes_out']}|"
f"{ps['packets_in']}|{ps['packets_out']}|{ps['conn_count']}")
# Output top 5 destinations per peer sorted by byte count
dest_items = sorted(
dest_stats.items(),
key=lambda x: (x[0][0], -(x[1]['bytes_orig'] + x[1]['bytes_reply']))
)
for peer, group in groupby(dest_items, key=lambda x: x[0][0]):
top = list(group)[:20]
for (p, dst_ip, dst_port, proto), stats in top:
print(f"dest|{p}|{dst_ip}|{dst_port}|{proto}|"
f"{stats['bytes_orig']}|{stats['bytes_reply']}|{stats['count']}")
def _is_excluded(ip, port, proto, exclude_set, net_data):
if not exclude_set:
return False
# Check raw ip:port:proto
if f"{ip}:{port}:{proto}" in exclude_set:
return True
# Check service name
svc = reverse_lookup(net_data, ip, str(port), proto) if net_data else ''
if svc and svc in exclude_set:
return True
# Check service:proto format (e.g. "pihole:dns-udp" -> "pihole" + "dns-udp")
if svc:
for excl in exclude_set:
if ':' in excl:
excl_svc, excl_port = excl.rsplit(':', 1)
if excl_svc == svc and excl_port in (f"{proto}-{port}", f"dns-{proto}"):
return True
return False

View file

@ -1,159 +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, exclude_services=''):
"""
Aggregate activity data for wgctl activity.
Output:
peer|name|rx_bytes|tx_bytes|drop_count
service|peer_name|dest_display|dst_ip|dst_port|proto|drop_count
"""
hours = int(hours) if hours else 24
cutoff = None
if hours > 0:
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
# Build exclusion set
exclude_set = set()
if exclude_services:
for svc in exclude_services.split():
exclude_set.add(svc.strip())
# 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)
def _is_excluded(ip, port, proto, svc_name):
if not exclude_set:
return False
if f"{ip}:{port}:{proto}" in exclude_set:
return True
if svc_name and svc_name in exclude_set:
return True
if svc_name:
for excl in exclude_set:
if ':' in excl:
excl_svc, excl_port = excl.rsplit(':', 1)
if excl_svc == svc_name and excl_port in (
f"{proto}-{port}", f"dns-{proto}", f"dns-udp", f"dns-tcp"
):
return True
return False
# 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
# service_drops[peer][(dest_display, dst_ip, dst_port, proto)] = count
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)
if _is_excluded(dest_ip, dest_port, proto, svc_name):
continue
peer_drops[peer] += 1
# Key includes raw ip:port:proto for --ports support
svc_key = (dest_display, dest_ip, dest_port, proto)
service_drops[peer][svc_key] += 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, dst_ip, dst_port, proto), count in \
sorted(svc_map.items(), key=lambda x: -x[1]):
print(f"service|{peer}|{dest_display}|{dst_ip}|{dst_port}|{proto}|{count}")

View file

@ -1,103 +0,0 @@
# core/lib/block_history.py
import json
import os
from datetime import datetime, timezone
BLOCK_HISTORY_VERSION = 1
def _history_file(history_dir, peer):
return os.path.join(history_dir, f"{peer}.json")
def _load(history_dir, peer):
path = _history_file(history_dir, peer)
if os.path.exists(path):
try:
return json.load(open(path))
except Exception:
pass
return {"peer": peer, "version": BLOCK_HISTORY_VERSION, "history": []}
def _save(history_dir, peer, data):
os.makedirs(history_dir, exist_ok=True)
path = _history_file(history_dir, peer)
open(path, 'w').write(json.dumps(data, indent=2))
def _next_id(history):
if not history:
return 1
return max(int(e.get("id", 0)) for e in history) + 1
def _now():
return datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
def _get_endpoint(clients_dir, peer):
"""Try to get current endpoint from endpoint cache."""
cache_file = os.path.join(
os.path.dirname(os.path.dirname(clients_dir)),
'.wgctl', 'daemon', 'endpoint_cache.json')
try:
cache = json.load(open(cache_file))
return cache.get(peer, '')
except Exception:
return ''
def block_history_record(history_dir, peer, block_type,
triggered_by, reason, endpoint_at_block):
"""Record a new block event."""
data = _load(history_dir, peer)
entry = {
"id": _next_id(data["history"]),
"blocked_at": _now(),
"unblocked_at": None,
"block_type": block_type,
"triggered_by": triggered_by,
"reason": reason or '',
"endpoint_at_block": endpoint_at_block or '',
"unblocked_by": None,
"unblock_reason": None,
}
data["history"].append(entry)
_save(history_dir, peer, data)
print(entry["id"])
def block_history_unblock(history_dir, peer, unblocked_by, unblock_reason):
"""Update the most recent open block event with unblock timestamp."""
data = _load(history_dir, peer)
# Find most recent entry without unblocked_at
for entry in reversed(data["history"]):
if entry.get("unblocked_at") is None:
entry["unblocked_at"] = _now()
entry["unblocked_by"] = unblocked_by
entry["unblock_reason"] = unblock_reason or ''
_save(history_dir, peer, data)
print(entry["id"])
return
# No open block found — not an error, peer may have been unblocked externally
def block_history_list(history_dir, peer):
"""Output block history for a peer as JSON."""
data = _load(history_dir, peer)
print(json.dumps(data))
def block_history_list_all(history_dir):
"""Output block history for all peers as JSON array."""
import glob
results = []
for path in sorted(glob.glob(os.path.join(history_dir, '*.json'))):
try:
results.append(json.load(open(path)))
except Exception:
pass
print(json.dumps(results))

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,195 +0,0 @@
import os
import json
import sys
import glob
def import_peer(file, data_key, name, clients_dir, meta_dir,
groups_dir, blocks_dir, force):
"""
Import a single peer from an export bundle.
data_key: 'data' for peer export, 'peers' for full backup
Returns: list of imported items as strings
"""
import base64, os
d = json.load(open(file))
if data_key == 'data':
peer = d['data']
else:
# Find peer in full backup peers array
peers = d['data'].get('peers', [])
peer = next((p for p in peers if p['name'] == name), None)
if not peer:
print(f"error: peer '{name}' not found in backup", file=sys.stderr)
sys.exit(1)
imported = []
# conf
conf_path = os.path.join(clients_dir, f"{name}.conf")
if os.path.exists(conf_path) and force != 'true':
print(f"error: peer '{name}' already exists, use --force to overwrite",
file=sys.stderr)
sys.exit(1)
os.makedirs(clients_dir, exist_ok=True)
conf = base64.b64decode(peer['conf']).decode()
open(conf_path, 'w').write(conf)
imported.append('conf')
# meta
meta = peer.get('meta', {})
if meta:
os.makedirs(meta_dir, exist_ok=True)
open(os.path.join(meta_dir, f"{name}.meta"), 'w').write(
json.dumps(meta, indent=2))
imported.append('meta')
# groups
for grp in peer.get('groups', []):
grp_file = os.path.join(groups_dir, f"{grp}.group")
if os.path.exists(grp_file):
try:
g = json.load(open(grp_file))
if name not in g.get('peers', []):
g.setdefault('peers', []).append(name)
open(grp_file, 'w').write(json.dumps(g, indent=2))
imported.append(f"group:{grp}")
except Exception:
pass
# blocks
blocks = peer.get('blocks', {})
if blocks.get('is_blocked') and blocks.get('block_file'):
os.makedirs(blocks_dir, exist_ok=True)
block_data = base64.b64decode(blocks['block_file'])
open(os.path.join(blocks_dir, f"{name}.block"), 'wb').write(block_data)
imported.append('block')
print('\n'.join(imported))
def import_identity(file, name, identities_dir, clients_dir, force):
"""Import an identity from an export bundle."""
import os
d = json.load(open(file))
id_data = d['data'].get('identity', d['data'])
# Check all referenced peers exist
peers = id_data.get('peers', [])
missing = [p for p in peers
if not os.path.exists(os.path.join(clients_dir, f"{p}.conf"))]
if missing:
print(f"error: missing peers: {' '.join(missing)}", file=sys.stderr)
sys.exit(1)
id_file = os.path.join(identities_dir, f"{name}.identity")
if os.path.exists(id_file) and force != 'true':
print(f"error: identity '{name}' already exists, use --force to overwrite",
file=sys.stderr)
sys.exit(1)
os.makedirs(identities_dir, exist_ok=True)
open(id_file, 'w').write(json.dumps(id_data, indent=2))
print('identity')
def import_full(file, clients_dir, meta_dir, rules_dir, identities_dir,
groups_dir, blocks_dir, policies_file, subnets_file,
net_file, hosts_file, force):
"""Import a full backup bundle."""
import base64, os, glob
d = json.load(open(file))
data = d['data']
results = []
# Peers
for peer in data.get('peers', []):
name = peer.get('name', '')
if not name:
continue
try:
conf_path = os.path.join(clients_dir, f"{name}.conf")
if os.path.exists(conf_path) and force != 'true':
results.append(f"skip:{name}")
continue
os.makedirs(clients_dir, exist_ok=True)
conf = base64.b64decode(peer['conf']).decode()
open(conf_path, 'w').write(conf)
meta = peer.get('meta', {})
if meta:
os.makedirs(meta_dir, exist_ok=True)
open(os.path.join(meta_dir, f"{name}.meta"), 'w').write(
json.dumps(meta, indent=2))
blocks = peer.get('blocks', {})
if blocks.get('is_blocked') and blocks.get('block_file'):
os.makedirs(blocks_dir, exist_ok=True)
block_data = base64.b64decode(blocks['block_file'])
open(os.path.join(blocks_dir, f"{name}.block"), 'wb').write(block_data)
results.append(f"peer:{name}")
except Exception as e:
results.append(f"error:{name}:{e}")
# Rules
os.makedirs(rules_dir, exist_ok=True)
for rule in data.get('rules', []):
name = rule.get('name', '')
if name:
open(os.path.join(rules_dir, f"{name}.rule"), 'w').write(
json.dumps(rule, indent=2))
results.append('rules')
# Identities
os.makedirs(identities_dir, exist_ok=True)
for identity in data.get('identities', []):
name = identity.get('name', '')
if name:
open(os.path.join(identities_dir, f"{name}.identity"), 'w').write(
json.dumps(identity, indent=2))
results.append('identities')
# Groups
os.makedirs(groups_dir, exist_ok=True)
for grp in data.get('groups', []):
name = grp.get('name', '')
if name:
open(os.path.join(groups_dir, f"{name}.group"), 'w').write(
json.dumps(grp, indent=2))
results.append('groups')
# Block history
bh_dir = os.path.join(os.path.dirname(groups_dir), 'block-history')
os.makedirs(bh_dir, exist_ok=True)
for bh in data.get('block_history', []):
peer_name = bh.get('peer', '')
if peer_name:
open(os.path.join(bh_dir, f"{peer_name}.json"), 'w').write(
json.dumps(bh, indent=2))
if data.get('block_history'):
results.append('block_history')
# Flat JSON files
for key, path in [('policies', policies_file), ('subnets', subnets_file),
('services', net_file), ('hosts', hosts_file)]:
section = data.get(key)
if section is not None:
open(path, 'w').write(json.dumps(section, indent=2))
results.append(key)
print('\n'.join(results))
def import_get_field(file, *keys):
"""Get a field from export JSON. Keys are dot-separated path."""
d = json.load(open(file))
val = d
for k in keys:
val = val.get(k, '')
if not val:
break
print(val if val else '')

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_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() {
local label="$1" value="$2" width="${3:-$UI_ROW_WIDTH}"
printf " %-${width}s %s\n" "${label}:" "$value"
@ -70,29 +64,6 @@ for s in sys.argv[1:]:
" "$@"
}
# ui::vis_len <string>
# Returns the visible (character) length of a string,
# stripping ANSI codes and accounting for multi-byte UTF-8.
function ui::vis_len() {
local str="${1:-}"
# Strip ANSI codes first
local clean
clean=$(echo "$str" | sed 's/\x1b\[[0-9;]*m//g')
# Use Python for accurate Unicode character count
python3 -c "import sys; print(len('${clean//\'/\'\\\'\'}'))" 2>/dev/null || echo "${#clean}"
}
# ui::pad_to_col <string_bytes_printed> <target_visible_col>
# Returns padding needed accounting for UTF-8 byte/char difference.
# extra_bytes = bytes_printed - visible_chars_printed
function ui::utf8_extra_bytes() {
local str="${1:-}"
local byte_len=${#str}
local vis_len
vis_len=$(ui::vis_len "$str")
echo $(( byte_len - vis_len ))
}
function ui::pad_status() {
ui::pad "${1:-}" "${2:-25}"

View file

@ -8,6 +8,6 @@
"desktop-roboclean": "46.189.215.231",
"laptop-nuno": "94.63.0.129",
"phone-luis": "176.223.61.15",
"phone-helena-2": "148.69.193.234",
"phone-helena-2": "148.69.192.130",
"desktop-zephyr": "86.120.152.74"
}

View file

@ -1,33 +0,0 @@
package cmd
import (
"flag"
"fmt"
"os"
)
// Flags holds CLI flags
type Flags struct {
WGDir string
Subnet string
LogFile string
Version bool
}
const Version = "0.1.0"
func Parse() *Flags {
f := &Flags{}
flag.StringVar(&f.WGDir, "wg-dir", "/etc/wireguard", "WireGuard base directory")
flag.StringVar(&f.Subnet, "subnet", "", "WireGuard subnet override")
flag.StringVar(&f.LogFile, "log-file", "", "Accept events log file override")
flag.BoolVar(&f.Version, "version", false, "Print version and exit")
flag.Parse()
if f.Version {
fmt.Println(Version)
os.Exit(0)
}
return f
}

View file

@ -1,42 +0,0 @@
package config
import (
"encoding/json"
"os"
)
// Config holds wgctl-conntrack runtime configuration
type Config struct {
WGSubnet string
DataDir string
ClientsDir string
AcceptLogFile string
ServicesFile string
}
type wgctlJSON struct {
WireGuard struct {
Subnet string `json:"subnet"`
} `json:"wireguard"`
}
// Load reads config from wgctl.json and applies defaults
func Load(wgDir string) (*Config, error) {
cfg := &Config{
WGSubnet: "10.1.0.0/16",
DataDir: wgDir + "/.wgctl/data",
ClientsDir: wgDir + "/clients",
AcceptLogFile: wgDir + "/.wgctl/daemon/accept_events.log",
ServicesFile: wgDir + "/.wgctl/data/services.json",
}
jsonFile := wgDir + "/.wgctl/config/wgctl.json"
if data, err := os.ReadFile(jsonFile); err == nil {
var wj wgctlJSON
if json.Unmarshal(data, &wj) == nil && wj.WireGuard.Subnet != "" {
cfg.WGSubnet = wj.WireGuard.Subnet
}
}
return cfg, nil
}

View file

@ -1,29 +0,0 @@
package conntrack
import "time"
// EventType represents the type of traffic event
type EventType string
const (
EventAccept EventType = "accept"
EventExternal EventType = "external"
)
// TrafficEvent is the normalized event written to the log
type TrafficEvent struct {
Timestamp time.Time `json:"ts"`
Peer string `json:"peer"`
SrcIP string `json:"src_ip"`
DstIP string `json:"dst_ip"`
DstPort uint16 `json:"dst_port"`
Proto string `json:"proto"`
BytesOrig uint64 `json:"bytes_orig"`
BytesReply uint64 `json:"bytes_reply"`
PacketsOrig uint64 `json:"packets_orig"`
PacketsReply uint64 `json:"packets_reply"`
DurationSec float64 `json:"duration_sec"`
Service string `json:"service,omitempty"`
Event EventType `json:"event"`
External bool `json:"external"`
}

View file

@ -1,44 +0,0 @@
package conntrack
import "net"
var privateRanges = []string{
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
}
var privateCIDRs []*net.IPNet
func init() {
for _, cidr := range privateRanges {
_, ipnet, _ := net.ParseCIDR(cidr)
privateCIDRs = append(privateCIDRs, ipnet)
}
}
func IsWGPeer(ip net.IP, wgSubnet *net.IPNet) bool {
return wgSubnet.Contains(ip)
}
func IsExternal(ip net.IP) bool {
for _, cidr := range privateCIDRs {
if cidr.Contains(ip) {
return false
}
}
return true
}
func ProtoName(proto uint8) string {
switch proto {
case 6:
return "tcp"
case 17:
return "udp"
case 1:
return "icmp"
default:
return "unknown"
}
}

View file

@ -1,117 +0,0 @@
package conntrack
import (
"log"
"net"
"time"
ct "github.com/ti-mo/conntrack"
"github.com/ti-mo/netfilter"
)
// Resolver maps IPs and ports to peer/service names
type Resolver interface {
PeerForIP(ip net.IP) string
ServiceForDst(ip net.IP, port uint16, proto string) string
}
// Subscriber listens for conntrack DESTROY events
type Subscriber struct {
wgSubnet *net.IPNet
events chan<- TrafficEvent
resolver Resolver
}
func NewSubscriber(wgSubnet *net.IPNet, events chan<- TrafficEvent, resolver Resolver) *Subscriber {
return &Subscriber{wgSubnet: wgSubnet, events: events, resolver: resolver}
}
func (s *Subscriber) Run() error {
conn, err := ct.Dial(nil)
if err != nil {
return err
}
defer conn.Close()
evCh := make(chan ct.Event, 256)
errCh, err := conn.Listen(evCh, 1, []netfilter.NetlinkGroup{
netfilter.GroupCTDestroy,
})
if err != nil {
return err
}
log.Println("conntrack subscriber started")
for {
select {
case ev := <-evCh:
s.processEvent(ev)
case err := <-errCh:
return err
}
}
}
func (s *Subscriber) processEvent(ev ct.Event) {
flow := ev.Flow
if flow == nil {
return
}
tuple := flow.TupleOrig
// Skip IPv6
if !tuple.IP.SourceAddress.Is4() || !tuple.IP.DestinationAddress.Is4() {
return
}
srcBytes := tuple.IP.SourceAddress.As4()
dstBytes := tuple.IP.DestinationAddress.As4()
srcIP := net.IP(srcBytes[:])
dstIP := net.IP(dstBytes[:])
// Only process WireGuard peer traffic
if !IsWGPeer(srcIP, s.wgSubnet) {
return
}
proto := ProtoName(tuple.Proto.Protocol)
dstPort := tuple.Proto.DestinationPort
external := IsExternal(dstIP)
peer := s.resolver.PeerForIP(srcIP)
if peer == "" {
return
}
service := s.resolver.ServiceForDst(dstIP, dstPort, proto)
var durationSec float64
if flow.Timestamp.Stop.After(flow.Timestamp.Start) {
durationSec = flow.Timestamp.Stop.Sub(flow.Timestamp.Start).Seconds()
}
eventType := EventAccept
if external {
eventType = EventExternal
}
s.events <- TrafficEvent{
Timestamp: time.Now().UTC(),
Peer: peer,
SrcIP: srcIP.String(),
DstIP: dstIP.String(),
DstPort: dstPort,
Proto: proto,
BytesOrig: flow.CountersOrig.Bytes,
BytesReply: flow.CountersReply.Bytes,
PacketsOrig: flow.CountersOrig.Packets,
PacketsReply: flow.CountersReply.Packets,
DurationSec: durationSec,
Service: service,
Event: eventType,
External: external,
}
}

View file

@ -1,16 +0,0 @@
module git.krilio.net/nuno/wgctl-conntrack
go 1.23.0
require (
github.com/google/go-cmp v0.7.0 // indirect
github.com/josharian/native v1.1.0 // indirect
github.com/mdlayher/netlink v1.7.2 // indirect
github.com/mdlayher/socket v0.5.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/ti-mo/conntrack v0.6.0 // indirect
github.com/ti-mo/netfilter v0.5.3 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
)

View file

@ -1,20 +0,0 @@
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g=
github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/ti-mo/conntrack v0.6.0 h1:laiW2+dzKyS2u0aVr6FeRQs+v7cj4t7q+twolL/ZkjQ=
github.com/ti-mo/conntrack v0.6.0/go.mod h1:4HZrFQQLOSuBzgQNid3H/wYyyp1kfGXUYxueXjIGibo=
github.com/ti-mo/netfilter v0.5.3 h1:ikzduvnaUMwre5bhbNwWOd6bjqLMVb33vv0XXbK0xGQ=
github.com/ti-mo/netfilter v0.5.3/go.mod h1:08SyBCg6hu1qyQk4s3DjjJKNrm3RTb32nm6AzyT972E=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=

View file

@ -1,71 +0,0 @@
package main
import (
"log"
"net"
"os"
"os/signal"
"syscall"
"git.krilio.net/nuno/wgctl-conntrack/cmd"
"git.krilio.net/nuno/wgctl-conntrack/config"
ctconn "git.krilio.net/nuno/wgctl-conntrack/conntrack"
"git.krilio.net/nuno/wgctl-conntrack/resolver"
"git.krilio.net/nuno/wgctl-conntrack/writer"
)
func main() {
flags := cmd.Parse()
cfg, err := config.Load(flags.WGDir)
if err != nil {
log.Fatalf("failed to load config: %v", err)
}
if flags.Subnet != "" {
cfg.WGSubnet = flags.Subnet
}
if flags.LogFile != "" {
cfg.AcceptLogFile = flags.LogFile
}
_, wgSubnet, err := net.ParseCIDR(cfg.WGSubnet)
if err != nil {
log.Fatalf("invalid WG subnet %q: %v", cfg.WGSubnet, err)
}
log.Printf("wgctl-conntrack v%s starting (subnet: %s, log: %s)",
cmd.Version, cfg.WGSubnet, cfg.AcceptLogFile)
peerResolver := resolver.NewPeerResolver(flags.WGDir)
svcResolver := resolver.NewServiceResolver(cfg.ServicesFile)
res := &combinedResolver{peers: peerResolver, services: svcResolver}
events := make(chan ctconn.TrafficEvent, 512)
go writer.NewLogWriter(cfg.AcceptLogFile).Run(events)
sub := ctconn.NewSubscriber(wgSubnet, events, res)
go func() {
if err := sub.Run(); err != nil {
log.Fatalf("conntrack subscriber error: %v", err)
}
}()
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
log.Println("wgctl-conntrack shutting down")
}
type combinedResolver struct {
peers *resolver.PeerResolver
services *resolver.ServiceResolver
}
func (r *combinedResolver) PeerForIP(ip net.IP) string {
return r.peers.PeerForIP(ip)
}
func (r *combinedResolver) ServiceForDst(ip net.IP, port uint16, proto string) string {
return r.services.ServiceForDst(ip, port, proto)
}

View file

@ -1,93 +0,0 @@
package resolver
import (
"encoding/json"
"net"
"os"
"strings"
"sync"
"time"
)
// PeerResolver maps WireGuard peer IPs to peer names
type PeerResolver struct {
mu sync.RWMutex
ipToName map[string]string
wgDir string
}
func NewPeerResolver(wgDir string) *PeerResolver {
r := &PeerResolver{wgDir: wgDir, ipToName: make(map[string]string)}
r.reload()
go r.watchReload()
return r
}
func (r *PeerResolver) PeerForIP(ip net.IP) string {
r.mu.RLock()
defer r.mu.RUnlock()
return r.ipToName[ip.String()]
}
func (r *PeerResolver) reload() {
newMap := make(map[string]string)
// WireGuard IPs from conf files (10.1.x.x → peer name)
clientsDir := r.wgDir + "/clients"
entries, err := os.ReadDir(clientsDir)
if err == nil {
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".conf") {
continue
}
name := strings.TrimSuffix(entry.Name(), ".conf")
if ip := parseAddressFromConf(clientsDir + "/" + entry.Name()); ip != "" {
newMap[ip] = name
}
}
}
// External IPs from endpoint index (external IP → peer name)
indexFile := r.wgDir + "/.wgctl/data/peer-history/endpoint_index.json"
if data, err := os.ReadFile(indexFile); err == nil {
var index map[string]string
if json.Unmarshal(data, &index) == nil {
for ip, peer := range index {
newMap[ip] = peer
}
}
}
r.mu.Lock()
r.ipToName = newMap
r.mu.Unlock()
}
func (r *PeerResolver) watchReload() {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
for range ticker.C {
r.reload()
}
}
func parseAddressFromConf(path string) string {
data, err := os.ReadFile(path)
if err != nil {
return ""
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Address") {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
ip := strings.TrimSpace(parts[1])
if idx := strings.Index(ip, "/"); idx != -1 {
ip = ip[:idx]
}
return ip
}
}
}
return ""
}

View file

@ -1,93 +0,0 @@
package resolver
import (
"encoding/json"
"fmt"
"net"
"os"
"sync"
"time"
)
// ServiceResolver maps IP:port:proto to service names
type ServiceResolver struct {
mu sync.RWMutex
portToSvc map[string]string
servicesFile string
}
func NewServiceResolver(servicesFile string) *ServiceResolver {
r := &ServiceResolver{servicesFile: servicesFile, portToSvc: make(map[string]string)}
r.reload()
go r.watchReload()
return r
}
func (r *ServiceResolver) ServiceForDst(ip net.IP, port uint16, proto string) string {
r.mu.RLock()
defer r.mu.RUnlock()
// Try IP:port:proto first
if svc, ok := r.portToSvc[fmt.Sprintf("%s:%d:%s", ip.String(), port, proto)]; ok {
return svc
}
// Fall back to IP only
if svc, ok := r.portToSvc[ip.String()]; ok {
return svc
}
return ""
}
func (r *ServiceResolver) reload() {
data, err := os.ReadFile(r.servicesFile)
if err != nil {
return
}
var services map[string]interface{}
if json.Unmarshal(data, &services) != nil {
return
}
newMap := make(map[string]string)
for name, svcRaw := range services {
svc, ok := svcRaw.(map[string]interface{})
if !ok {
continue
}
hosts := map[string]bool{}
if hostsRaw, ok := svc["hosts"].(map[string]interface{}); ok {
for ip := range hostsRaw {
hosts[ip] = true
newMap[ip] = name
}
}
if portsRaw, ok := svc["ports"].([]interface{}); ok {
for _, portRaw := range portsRaw {
port, ok := portRaw.(map[string]interface{})
if !ok {
continue
}
portNum := fmt.Sprintf("%.0f", port["port"])
proto, _ := port["proto"].(string)
for ip := range hosts {
newMap[fmt.Sprintf("%s:%s:%s", ip, portNum, proto)] = name
}
}
}
}
r.mu.Lock()
r.portToSvc = newMap
r.mu.Unlock()
}
func (r *ServiceResolver) watchReload() {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
for range ticker.C {
r.reload()
}
}

View file

@ -1,21 +0,0 @@
[Unit]
Description=wgctl conntrack accept logging daemon
After=network.target wg-quick@wg0.service
Requires=wg-quick@wg0.service
[Service]
Type=simple
ExecStart=/etc/wireguard/wgctl/daemon/wgctl-conntrack/wgctl-conntrack \
--wg-dir /etc/wireguard
Restart=on-failure
RestartSec=5s
StandardOutput=journal
StandardError=journal
SyslogIdentifier=wgctl-conntrack
# Needs CAP_NET_ADMIN for netlink conntrack
AmbientCapabilities=CAP_NET_ADMIN
CapabilityBoundingSet=CAP_NET_ADMIN
[Install]
WantedBy=multi-user.target

View file

@ -1,47 +0,0 @@
package writer
import (
"encoding/json"
"log"
"os"
"sync"
"git.krilio.net/nuno/wgctl-conntrack/conntrack"
)
// LogWriter writes TrafficEvents as JSON lines to a file
type LogWriter struct {
path string
mu sync.Mutex
}
func NewLogWriter(path string) *LogWriter {
return &LogWriter{path: path}
}
func (w *LogWriter) Write(ev conntrack.TrafficEvent) error {
w.mu.Lock()
defer w.mu.Unlock()
f, err := os.OpenFile(w.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer f.Close()
data, err := json.Marshal(ev)
if err != nil {
return err
}
_, err = f.Write(append(data, '\n'))
return err
}
func (w *LogWriter) Run(events <-chan conntrack.TrafficEvent) {
for ev := range events {
if err := w.Write(ev); err != nil {
log.Printf("error writing event: %v", err)
}
}
}

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]
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
RestartSec=5
Environment=WG_INTERFACE=eth0

View file

@ -8,33 +8,30 @@ function config::on_load() {
config::_init_defaults
config::load
config::validate
fmt::set_date_format "${_FMT_DATE_FORMAT:-eu}"
fmt::set_date_format "${_FMT_DATE_FORMAT:-iso}"
}
# ============================================
# Defaults
# ============================================
# Activity thresholds
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_HIGH_BYTES="${ACTIVITY_TOTAL_HIGH_BYTES:-100000000}"
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_HIGH_BYTES="${ACTIVITY_CURRENT_HIGH_BYTES:-100000000}"
declare -gA _COMMAND_DEFAULTS=()
declare -gA _COMMAND_ALIASES=()
function config::_init_defaults() {
_WG_INTERFACE="wg0"
_WG_DNS="10.0.0.103"
_WG_DNS_FALLBACK=""
_WG_LAN="10.0.0.0/24"
_WG_SUBNET="10.1.0.0/16"
_WG_PORT="51820"
_WG_ENDPOINT=""
_WG_HANDSHAKE_CHECK_TIME_SEC="300"
_FMT_DATE_FORMAT="eu"
_WG_INTERFACE="${WG_INTERFACE:-wg0}"
_WG_DNS="${WG_DNS:-10.0.0.103}"
_WG_LAN="${WG_LAN:-10.0.0.0/24}"
_WG_SUBNET="${WG_SUBNET:-10.1.0.0/16}"
_WG_PORT="${WG_PORT:-51820}"
_WG_ENDPOINT="${WG_ENDPOINT:-}"
_WG_HANDSHAKE_CHECK_TIME_SEC="${WG_HANDSHAKE_CHECK_TIME_SEC:-180}"
# Derived
_WG_CONFIG="$(ctx::wg)/${_WG_INTERFACE}.conf"
@ -45,97 +42,13 @@ function config::_init_defaults() {
}
# ============================================
# Load from wgctl.json
# 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" ;;
CMD_DEFAULT:*)
local cmd_name="${key#CMD_DEFAULT:}"
_COMMAND_DEFAULTS["$cmd_name"]="$value"
;;
CMD_ALIAS:*)
local alias_name="${key#CMD_ALIAS:}"
_COMMAND_ALIASES["$alias_name"]="$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)
# Validation
# ============================================
function config::validate() {
local errors=()
# Server key and config files
if [[ ! -f "$_WG_SERVER_PUBLIC_KEY_FILE" ]]; then
errors+=("Server public key not found: ${_WG_SERVER_PUBLIC_KEY_FILE}")
fi
@ -146,6 +59,7 @@ function config::validate() {
errors+=("WireGuard config not found: ${_WG_CONFIG}")
fi
# Required config values
local endpoint
endpoint=$(config::endpoint)
if [[ -z "$endpoint" ]]; then
@ -176,6 +90,7 @@ function config::validate() {
errors+=("WG_SUBNET is not set — required for IP allocation")
fi
# Warn-only
local lan
lan=$(config::lan)
if [[ -z "$lan" ]]; then
@ -187,7 +102,7 @@ function config::validate() {
for err in "${errors[@]}"; do
printf " ✗ %s\n" "$err" >&2
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
fi
@ -195,14 +110,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::config_file() { echo "$_WG_CONFIG"; }
function config::endpoint() { echo "$_WG_ENDPOINT"; }
function config::dns() { echo "$_WG_DNS"; }
function config::dns_fallback() { echo "${_WG_DNS_FALLBACK:-}"; }
function config::port() { echo "$_WG_PORT"; }
function config::subnet() { echo "$_WG_SUBNET"; }
function config::lan() { echo "$_WG_LAN"; }
@ -212,13 +162,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_med() { echo "$_ACTIVITY_TOTAL_MED_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_med() { echo "$_ACTIVITY_CURRENT_MED_BYTES"; }
function config::activity_current_high() { echo "$_ACTIVITY_CURRENT_HIGH_BYTES"; }
function config::activity_current_low() { echo "$_ACTIVITY_TOTAL_LOW_BYTES"; }
function config::activity_current_med() { echo "$_ACTIVITY_TOTAL_MED_BYTES"; }
function config::activity_current_high() { echo "$_ACTIVITY_TOTAL_HIGH_BYTES"; }
function config::server_public_key() { cat "$_WG_SERVER_PUBLIC_KEY_FILE"; }
function config::allowed_ips_for() {
local tunnel="${1:-split}"
local tunnel="${2:-split}"
case "$tunnel" in
full) echo "$_WG_TUNNEL_FULL" ;;
split) echo "$_WG_TUNNEL_SPLIT" ;;
@ -228,13 +178,3 @@ function config::allowed_ips_for() {
;;
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() {
local client="${1:-}"
local client="$1"
json::get "$ENDPOINT_CACHE" "$client"
}
@ -138,43 +138,3 @@ function monitor::restart() {
function monitor::is_running() {
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 ""
}
# 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() {
local sign="${1:-}" entry="${2:-}" indent="${3:-6}"
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" \
"$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]
PrivateKey = ${private_key}
Address = ${ip}/32
DNS = $(config::dns_string)
DNS = $(config::dns)
[Peer]
PublicKey = ${server_public_key}
@ -271,18 +271,6 @@ function peers::set_main_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
# ============================================
@ -538,24 +526,6 @@ function peers::format_activity_current() {
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
# ============================================

View file

@ -3,15 +3,51 @@
# Policies define behavioral flags for subnets, identities, and future contexts.
# 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() {
local name="${1:-}" field="${2:-}"
# Only fallback for 'default' policy if policies.json is unavailable
[[ "$name" != "default" ]] && echo "" && return 0
case "$field" in
tunnel_mode) echo "split" ;;
default_rule) echo "" ;;
strict_rule) echo "false" ;;
auto_apply) echo "true" ;;
tunnel_mode) echo "${_POLICY_TUNNEL_MODE[$name]:-split}" ;;
default_rule) echo "${_POLICY_DEFAULT_RULE[$name]:-}" ;;
strict_rule) echo "${_POLICY_STRICT_RULE[$name]:-false}" ;;
auto_apply) echo "${_POLICY_AUTO_APPLY[$name]:-true}" ;;
*) echo "" ;;
esac
}
@ -20,6 +56,8 @@ function policy::_hardcoded_field() {
# Core Accessors
# ======================================================
function ctx::policies() { echo "${_CTX_DATA}/policies.json"; }
function policy::exists() {
local name="${1:-}"
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

@ -333,14 +333,21 @@ function rule::restore_all() {
# Rendering
# ============================================
# ======================================================
# Aliases (for backward compat — remove in cleanup pass)
# ======================================================
function rule::render_flat() {
ui::rule::flat "$1"
}
function rule::render_flat() { ui::rule::flat "$1"; }
function rule::render_entries() { ui::rule::entries "$1"; }
function rule::render_own_entries() { ui::rule::own_entries "$1"; }
function rule::render_extends_tree() { ui::rule::tree "$1"; }
function rule::render_entries() {
ui::rule::entries "$1"
}
function rule::render_own_entries() {
ui::rule::own_entries "$1"
}
function rule::render_extends_tree() {
ui::rule::tree "$1"
}
# ============================================
# DNS Redirect

View file

@ -1,99 +0,0 @@
#!/usr/bin/env bash
# ui/activity.module.sh — rendering for wgctl activity
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"
}
# ── _strip_ansi <string> → visible string
# Used for measuring visible length of strings that may contain ANSI codes
function ui::activity::_visible_len() {
local s="$1"
printf "%b" "$s" | sed 's/\x1b\[[0-9;]*m//g' | wc -m | tr -d ' '
}
# ui::activity::service_row
# dest_display may contain ANSI (when --ports passes dim suffix)
function ui::activity::service_row() {
local dest_display="${1:-}" drop_count="${2:-0}" drop_word="${3:-drops}" \
drops_col="${4:-30}" w_count="${5:-1}"
local arrow_prefix=" → "
local prefix_bytes=${#arrow_prefix}
# Measure visible length of dest (strip ANSI for correct padding)
local dest_visible_len
dest_visible_len=$(ui::activity::_visible_len "$dest_display")
local prefix_len=$(( prefix_bytes + dest_visible_len ))
local pad_n=$(( drops_col - prefix_len ))
[[ $pad_n -lt 1 ]] && pad_n=1
printf " \033[0;31m→\033[0m \033[0;31m%b\033[0m%*s \033[0;31m%${w_count}s %s\033[0m\n" \
"$dest_display" "$pad_n" "" "$drop_count" "$drop_word"
}
function ui::activity::accept_row() {
local name_pad="${1:-}" bytes_in="${2:-}" bytes_out="${3:-}" \
conns="${4:-0}" w_count="${5:-4}"
local conn_word="conns"
[[ "$conns" -eq 1 ]] && conn_word="conn"
local spaces
spaces=$(printf '%*s' "${#name_pad}" '')
printf " \033[0;32m%s ↓%-10s ↑%-10s %${w_count}s %s\033[0m\n" \
"$spaces" "$bytes_in" "$bytes_out" "$conns" "$conn_word"
}
function ui::activity::accept_dest_row() {
local dest="${1:-}" bytes_orig="${2:-0}" bytes_reply="${3:-0}" \
count="${4:-0}" drops_col="${5:-40}" w_count="${6:-4}"
local conn_word="conns"
[[ "$count" -eq 1 ]] && conn_word="conn"
local arrow_prefix=" → "
local prefix_bytes=${#arrow_prefix}
# Measure visible length of dest (strip ANSI for correct padding)
local dest_visible_len
dest_visible_len=$(ui::activity::_visible_len "$dest")
local prefix_len=$(( prefix_bytes + dest_visible_len ))
local pad_n=$(( drops_col - prefix_len ))
[[ $pad_n -lt 1 ]] && pad_n=1
# Build bytes display
local bytes_display=""
if [[ "$bytes_orig" -gt 0 || "$bytes_reply" -gt 0 ]]; then
bytes_display=" "
[[ "$bytes_reply" -gt 0 ]] && bytes_display+="$(fmt::bytes "$bytes_reply") "
[[ "$bytes_orig" -gt 0 ]] && bytes_display+="$(fmt::bytes "$bytes_orig")"
bytes_display="${bytes_display% }"
fi
# Use %b for dest to interpret ANSI, keep rest as %s/%d
printf " \033[0;32m→\033[0m \033[0;32m%b\033[0m%*s \033[0;32m%${w_count}s %-5s\033[0m%s\n" \
"$dest" "$pad_n" "" "$count" "$conn_word" "$bytes_display"
}
# ── Table versions ──────────────────────────────────────
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 ""
}
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() {
local peer_name="${1:-}" identity_name="${2:-}" \
peer_type="${3:-}" index="${4:-}"
@ -70,27 +79,3 @@ function ui::identity::list_row_table() {
[[ -z "$types_display" ]] && 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

@ -1,511 +0,0 @@
#!/usr/bin/env bash
# ui/logs.module.sh — rendering for logs and watch data
function ui::logs::build_dest() {
local dest_ip="${1:-}" dest_port="${2:-}" proto="${3:-}" svc="${4:-}"
if [[ -n "$svc" ]]; then
[[ -n "$dest_port" ]] && echo "${svc}/${proto}" || echo "${svc} (${proto})"
else
[[ -n "$dest_port" ]] && echo "${dest_ip}:${dest_port}/${proto}" || echo "${dest_ip} (${proto})"
fi
}
function ui::logs::fw_section_header() {
local cols=$(( $(tput cols 2>/dev/null || echo 80) - 4 ))
printf " Firewall Drops\n"
printf " %s\n" "$(printf '─%.0s' $(seq 1 $cols))"
}
function ui::logs::wg_section_header() {
local cols=$(( $(tput cols 2>/dev/null || echo 80) - 4 ))
printf " WireGuard Events\n"
printf " %s\n" "$(printf '─%.0s' $(seq 1 $cols))"
}
function ui::logs::fw_section_header_table() {
printf " Firewall Drops:\n"
printf " %-20s %-18s %-25s %s\n" "TIME" "CLIENT" "DESTINATION" "PROTOCOL"
printf " %s\n" "$(printf '─%.0s' {1..75})"
}
function ui::logs::wg_section_header_table() {
printf " WireGuard Events:\n"
printf " %-20s %-20s %-18s %s\n" "TIME" "CLIENT" "ENDPOINT" "EVENT"
printf " %s\n" "$(printf '─%.0s' {1..75})"
}
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")
# ── 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=""
[[ "$count" -gt 1 ]] && count_suffix=" \033[2m(x${count})\033[0m"
if [[ "$w_endpoint" -gt 0 ]]; then
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::fw_row_table() {
local ts="${1:-}" client="${2:-}" dst="${3:-}" proto="${4:-}" count="${5:-1}"
local count_str=""
[[ "$count" -gt 1 ]] && count_str=" (x${count})"
printf " %-20s %-18s %-25s %s%s\n" "$ts" "$client" "$dst" "$proto" "$count_str"
}
# 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"
# # Gap suffix — offline label only when gap > threshold * 2
# 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 — 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() {
local ts="${1:-}" client="${2:-}" endpoint="${3:-}" event="${4:-}" count="${5:-1}"
local count_str=""
[[ "$count" -gt 1 ]] && count_str=" (x${count})"
local event_colored
case "$event" in
attempt*) event_colored="\033[1;31m${event}\033[0m" ;;
handshake*) event_colored="\033[1;32m${event}\033[0m" ;;
*) event_colored="$event" ;;
esac
printf " %-20s %-20s %-18s %b%s\n" "$ts" "$client" "$endpoint" "$event_colored" "$count_str"
}
_UI_WATCH_FW_COLOR="\033[1;31m"
_UI_WATCH_WG_COLOR="\033[1;32m"
# function ui::watch::fw_row() {
# local ts="${1:-}" client="${2:-}" dest_display="${3:-}" \
# w_client="${4:-20}" w_dest="${5:-18}"
# # "fw" is always 2 visible chars — no padding needed
# local src="${_UI_WATCH_FW_COLOR}fw\033[0m"
# local ts_pad client_pad dest_pad_n
# ts_pad=$(printf "%-11s" "$ts")
# client_pad=$(printf "%-${w_client}s" "$client")
# dest_pad_n=$(( w_dest - ${#dest_display} ))
# [[ $dest_pad_n -lt 0 ]] && dest_pad_n=0
# 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" ""
# }
# function ui::watch::wg_row() {
# local ts="${1:-}" client="${2:-}" endpoint="${3:-}" event="${4:-}" \
# w_client="${5:-20}" w_endpoint="${6:-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 endpoint_display="${endpoint:--}"
# local ts_pad client_pad ep_pad_n
# ts_pad=$(printf "%-11s" "$ts")
# client_pad=$(printf "%-${w_client}s" "$client")
# ep_pad_n=$(( w_endpoint - ${#endpoint_display} ))
# [[ $ep_pad_n -lt 0 ]] && ep_pad_n=0
# 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() {
printf "\n %-20s %-8s %-22s %-28s %-14s %s\n" \
"TIME" "SOURCE" "CLIENT" "DESTINATION/ENDPOINT" "EVENT" "STATUS"
printf " %s\n\n" "$(printf '─%.0s' {1..105})"
}
function ui::watch::fw_row_table() {
local ts="${1:-}" client="${2:-}" dest="${3:-}" event="${4:-}" status="${5:-}"
printf " %-20s %-8s %-22s %-28s \033[1;31m%-14s\033[0m %s\n" \
"$ts" "firewall" "$client" "$dest" "$event" "$status"
}
function ui::watch::wg_row_table() {
local ts="${1:-}" client="${2:-}" endpoint="${3:-}" event="${4:-}" status="${5:-}"
local event_color
case "$event" in
handshake) event_color="\033[1;32m" ;;
attempt) event_color="\033[1;31m" ;;
*) event_color="\033[0;37m" ;;
esac
printf " %-20s %-8s %-22s %-28s %b%-14s\033[0m %s\n" \
"$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

@ -8,58 +8,6 @@ function ui::peer::list_style() {
echo "$_LIST_STYLE"
}
# ======================================================
# Row state helpers
# ======================================================
function ui::peer::_is_inactive() {
local status="${1:-}"
[[ "$status" == "online"* ]] && return 1
return 0
}
function ui::peer::status_color() {
local is_blocked="${1:-false}" is_restricted="${2:-false}" status="${3:-}"
if [[ "$is_blocked" == "true" && "$status" == "online"* ]]; then
echo "\033[1;31m"
elif [[ "$is_blocked" == "true" ]]; then
echo "\033[2;31m"
elif [[ "$is_restricted" == "true" && "$status" == "online"* ]]; then
echo "\033[1;33m"
elif [[ "$is_restricted" == "true" ]]; then
echo "\033[2;33m"
elif [[ "$status" == "online"* ]]; then
echo "\033[1;32m"
else
echo "\033[2m"
fi
}
# Row color — wraps entire row
function ui::peer::_row_color() {
local is_blocked="${1:-false}" is_restricted="${2:-false}" status="${3:-}"
local inactive=false
ui::peer::_is_inactive "$status" && inactive=true
if $inactive; then
if [[ "$is_blocked" == "true" ]]; then
echo "\033[2;31m" # dim red — blocked offline
elif [[ "$is_restricted" == "true" ]]; then
echo "\033[2;33m" # dim yellow — restricted offline
else
echo "\033[2m" # dim gray — plain offline
fi
else
if [[ "$is_blocked" == "true" ]]; then
echo "\033[1;31m" # bold red — blocked active
elif [[ "$is_restricted" == "true" ]]; then
echo "\033[1;33m" # bold yellow — restricted active
else
echo "" # no row color — normal online
fi
fi
}
# ======================================================
# Compact layout (tableless)
# ======================================================
@ -72,21 +20,26 @@ function ui::peer::list_row_compact() {
group="${5:-}" status="${6:-}" last_seen="${7:-}" \
is_blocked="${8:-false}" is_restricted="${9:-false}"
local row_color
row_color=$(ui::peer::_row_color "$is_blocked" "$is_restricted" "$status")
local status_color="\033[0;37m"
if [[ "$is_blocked" == "true" ]]; then
status_color="\033[1;31m"
elif [[ "$is_restricted" == "true" ]]; then
status_color="\033[1;33m"
elif [[ "$status" == "online" ]]; then
status_color="\033[1;32m"
fi
local status_color
status_color=$(ui::peer::status_color "$is_blocked" "$is_restricted" "$status")
# Last seen mirrors status color
local ls_color="$status_color"
local rule_val="${rule:--}"
local group_val="${group:--}"
local name_pad ip_pad type_pad ts_pad status_pad
local name_pad ip_pad type_pad status_pad
name_pad=$(printf "%-${w_name}s" "$name")
ip_pad=$(printf "%-${w_ip}s" "$ip")
type_pad=$(printf "%-${w_type}s" "$type")
ts_pad=$(printf "%-11s" "$last_seen")
status_pad=$(printf "%-18s" "$status")
status_pad=$(printf "%-8s" "$status")
local rule_pad_n group_pad_n
rule_pad_n=$(( w_rule - ${#rule_val} ))
@ -94,24 +47,12 @@ function ui::peer::list_row_compact() {
[[ $rule_pad_n -lt 0 ]] && rule_pad_n=0
[[ $group_pad_n -lt 0 ]] && group_pad_n=0
if [[ -n "$row_color" ]]; then
# Colored row — entire row in row_color, status uses status_color
printf " %b%s %s %s \033[2mrule:\033[0m%b %s%*s \033[2mgroup:\033[0m%b %s%*s\033[0m %b%s\033[0m %b%s\033[0m\n" \
"$row_color" \
"$name_pad" "$ip_pad" "$type_pad" \
"$row_color" "$rule_val" "$rule_pad_n" "" \
"$row_color" "$group_val" "$group_pad_n" "" \
"$status_color" "$status_pad" \
"$status_color" "$ts_pad"
else
# Normal online row — white fields, colored status/last_seen
printf " %s %s %s \033[2mrule:\033[0m %s%*s \033[2mgroup:\033[0m %s%*s %b%s\033[0m %b%s\033[0m\n" \
"$name_pad" "$ip_pad" "$type_pad" \
"$rule_val" "$rule_pad_n" "" \
"$group_val" "$group_pad_n" "" \
"$status_color" "$status_pad" \
"$status_color" "$ts_pad"
fi
"$ls_color" "$last_seen"
}
# ======================================================
@ -177,22 +118,27 @@ function ui::peer::list_row_detailed() {
group="${5:-}" subnet="${6:-}" status="${7:-}" last_seen="${8:-}" \
is_blocked="${9:-false}" is_restricted="${10:-false}"
local row_color
row_color=$(ui::peer::_row_color "$is_blocked" "$is_restricted" "$status")
local status_color="\033[0;37m"
if [[ "$is_blocked" == "true" ]]; then
status_color="\033[1;31m"
elif [[ "$is_restricted" == "true" ]]; then
status_color="\033[1;33m"
elif [[ "$status" == "online" ]]; then
status_color="\033[1;32m"
fi
local status_color
status_color=$(ui::peer::status_color "$is_blocked" "$is_restricted" "$status")
# Last seen mirrors status color
local ls_color="$status_color"
local rule_val="${rule:--}"
local group_val="${group:--}"
local subnet_val="${subnet:--}"
local name_pad ip_pad type_pad ts_pad status_pad
local name_pad ip_pad type_pad status_pad
name_pad=$(printf "%-${w_name}s" "$name")
ip_pad=$(printf "%-${w_ip}s" "$ip")
type_pad=$(printf "%-${w_type}s" "$type")
ts_pad=$(printf "%-11s" "$last_seen")
status_pad=$(printf "%-18s" "$status")
status_pad=$(printf "%-8s" "$status")
local rule_pad_n group_pad_n subnet_pad_n
rule_pad_n=$(( w_rule - ${#rule_val} ))
@ -202,22 +148,11 @@ function ui::peer::list_row_detailed() {
[[ $group_pad_n -lt 0 ]] && group_pad_n=0
[[ $subnet_pad_n -lt 0 ]] && subnet_pad_n=0
if [[ -n "$row_color" ]]; then
printf " · %b%s %s %s \033[2mrule:\033[0m%b %s%*s \033[2mgroup:\033[0m%b %s%*s \033[2msubnet:\033[0m%b %s%*s\033[0m %b%s\033[0m %b%s\033[0m\n" \
"$row_color" \
"$name_pad" "$ip_pad" "$type_pad" \
"$row_color" "$rule_val" "$rule_pad_n" "" \
"$row_color" "$group_val" "$group_pad_n" "" \
"$row_color" "$subnet_val" "$subnet_pad_n" "" \
"$status_color" "$status_pad" \
"$status_color" "$ts_pad"
else
printf " · %s %s %s \033[2mrule:\033[0m %s%*s \033[2mgroup:\033[0m %s%*s \033[2msubnet:\033[0m %s%*s %b%s\033[0m %b%s\033[0m\n" \
"$name_pad" "$ip_pad" "$type_pad" \
"$rule_val" "$rule_pad_n" "" \
"$group_val" "$group_pad_n" "" \
"$subnet_val" "$subnet_pad_n" "" \
"$status_color" "$status_pad" \
"$status_color" "$ts_pad"
fi
"$ls_color" "$last_seen"
}

View file

@ -1,45 +0,0 @@
#!/usr/bin/env bash
function ui::policy::list_row() {
local name="${1:-}" default_rule="${2:-}" strict="${3:-}" auto="${4:-}"
local rule_val="-"
[[ -n "$default_rule" ]] && rule_val="$default_rule"
local rule_padded
rule_padded=$(printf "%-16s" "$rule_val")
local strict_display
[[ "$strict" == "true" ]] && strict_display="yes" || strict_display="no"
local strict_padded
strict_padded=$(printf "%-4s" "$strict_display")
local auto_display=""
[[ "$auto" == "false" ]] && auto_display=" \033[2mauto:\033[0m no"
printf " %-14s \033[2mrule:\033[0m %s \033[2mstrict:\033[0m %s%s\n" \
"$name" "$rule_padded" "$strict_padded" "$auto_display"
}
function ui::policy::detail_field() {
local key="${1:-}" value="${2:-}"
ui::row "$key" "$value"
}
# ======================================================
# Table view
# ======================================================
function ui::policy::list_header_table() {
printf "\n %-16s %-8s %-14s %-8s %s\n" \
"NAME" "TUNNEL" "DEFAULT RULE" "STRICT" "AUTO"
printf " %s\n" "$(printf '─%.0s' {1..60})"
}
function ui::policy::list_row_table() {
local name="${1:-}" tunnel="${2:-}" default_rule="${3:-}" \
strict="${4:-}" auto="${5:-}"
printf " %-16s %-8s %-14s %-8s %s\n" \
"$name" "$tunnel" "${default_rule:--}" "$strict" "$auto"
}

View file

@ -167,48 +167,20 @@ function ui::rule::tree() {
# ui::rule::identity_block <identity_name> <strict_rule>
# Renders the full identity rule block in inspect.
function ui::rule::identity_block() {
local identity_name="${1:-}" strict="${2:-false}" no_header=false
# Parse optional flags
shift 2 || true
while [[ $# -gt 0 ]]; do
case "$1" in
--no-header) no_header=true; shift ;;
*) shift ;;
esac
done
local identity_name="${1:-}" strict="${2:-false}"
local rules
rules=$(identity::rules "$identity_name")
[[ -z "$rules" ]] && return 0
if ! $no_header; then
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
while IFS= read -r rule_name; do
[[ -z "$rule_name" ]] && continue
$first || printf "\n"
first=false
ui::rule::_identity_rule_entry "$rule_name" \
"$entry_indent" "$label_indent" "$own_indent" "$own_label_indent"
ui::rule::_identity_rule_entry "$rule_name"
done <<< "$rules"
if [[ "$strict" == "true" ]]; then
@ -220,41 +192,32 @@ function ui::rule::identity_block() {
# Renders one rule within an identity block.
function ui::rule::_identity_rule_entry() {
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
rule_file="$(rule::path "$rule_name")" || return 0
local label_pad
label_pad=$(printf '%*s' "$label_indent" '')
printf "%s\033[0;37m↳ %s\033[0m\n" "$label_pad" "$rule_name"
printf " \033[0;37m↳ %s\033[0m\n" "$rule_name"
local extends_raw=()
mapfile -t extends_raw < <(json::get "$rule_file" "extends" 2>/dev/null || true) || true
if [[ ${#extends_raw[@]} -gt 0 && -n "${extends_raw[0]:-}" ]]; then
ui::rule::_render_bases extends_raw "$own_indent" "$(( entry_indent + 2 ))"
# Rule has extends — render one level deep using shared helper
ui::rule::_render_bases extends_raw 10 8
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
local own_pad
own_pad=$(printf '%*s' "$(( entry_indent + 2 ))" '')
printf "\n%s\033[0;37mOwn:\033[0m\n" "$own_pad"
printf "\n \033[0;37mOwn:\033[0m\n"
printf "%s\n" "$own_output"
fi
else
# Leaf rule — show own entries or note full access
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
printf "%s\n" "$own_output"
else
local full_pad
full_pad=$(printf '%*s' "$(( entry_indent + 2 ))" '')
printf "%s\033[2mfull access (no restrictions)\033[0m\n" "$full_pad"
printf " \033[2mfull access (no restrictions)\033[0m\n"
fi
fi
}
@ -292,129 +255,3 @@ function ui::rule::_peer_rule_entry() {
fi
fi
}
# ======================================================
# List Rendering
# ======================================================
# ui::rule::list_group_header <group_name>
function ui::rule::list_group_header() {
local group="${1:-}"
printf "\n \033[0;36m▸ %s\033[0m\n" "$group"
}
# ui::rule::list_base_header
function ui::rule::list_base_header() {
printf "\n \033[2m── Base Rules ──────────────────────\033[0m\n"
}
# ui::rule::list_row <name> <n_allows> <n_blocks> <peer_count> <w_name>
function ui::rule::list_row() {
local name="${1:-}" n_allows="${2:-0}" n_blocks="${3:-0}" \
peer_count="${4:-0}" w_name="${5:-16}" extends_csv="${6:-}"
local name_pad
name_pad=$(printf "%-${w_name}s" "$name")
local peer_word="peers"
[[ "$peer_count" -eq 1 ]] && peer_word="peer"
local peers_display
peers_display=$(printf "%s %s" "$peer_count" "$peer_word")
local peers_pad_n=$(( 10 - ${#peers_display} ))
[[ $peers_pad_n -lt 0 ]] && peers_pad_n=0
local extends_indicator=""
if [[ -n "$extends_csv" ]]; then
local extends_display="${extends_csv//,/, }"
extends_indicator=" \033[2m↳ ${extends_display}\033[0m"
fi
# Allows — green +N, padded to 5 visible chars
# Append spaces after reset code so printf doesn't miscount
local allows_str
if [[ "$n_allows" -gt 0 ]]; then
printf -v allows_str "\033[1;32m+%s\033[0m" "$n_allows"
allows_str="${allows_str}$(printf '%*s' "$(( 4 - ${#n_allows} ))" '')"
else
printf -v allows_str "\033[2m+0\033[0m " # "+0" = 2 visible + 3 spaces = 5
fi
# Blocks — red -N, padded to 5 visible chars
local blocks_str
if [[ "$n_blocks" -gt 0 ]]; then
printf -v blocks_str "\033[1;31m-%s\033[0m" "$n_blocks"
blocks_str="${blocks_str}$(printf '%*s' "$(( 4 - ${#n_blocks} ))" '')"
else
printf -v blocks_str "\033[2m-0\033[0m " # "-0" = 2 visible + 3 spaces = 5
fi
# Peers — dim if zero
local peers_colored
if [[ "$peer_count" -eq 0 ]]; then
peers_colored="\033[2m${peers_display}\033[0m"
else
peers_colored="$peers_display"
fi
printf " %s %b%b %b%*s%b\n" \
"$name_pad" "$allows_str" "$blocks_str" \
"$peers_colored" "$peers_pad_n" "" "$extends_indicator"
}
# ui::rule::list_extends <extends_csv>
# Renders the extends tree for a rule in list view (compact, one level)
function ui::rule::list_extends() {
local extends_csv="${1:-}"
[[ -z "$extends_csv" ]] && return 0
local extend_list=()
IFS=',' read -ra extend_list <<< "$extends_csv"
for base in "${extend_list[@]}"; do
[[ -z "$base" ]] && continue
printf " \033[0;37m ↳ %s\033[0m\n" "$base"
done
}
# ui::rule::list_extends_detailed <extends_csv> <rules_dir>
# Renders the extends tree with entries expanded (--detailed mode)
function ui::rule::list_extends_detailed() {
local extends_csv="${1:-}" rules_dir="${2:-}"
[[ -z "$extends_csv" ]] && return 0
local extend_list=()
IFS=',' read -ra extend_list <<< "$extends_csv"
for base in "${extend_list[@]}"; do
[[ -z "$base" ]] && continue
printf " \033[0;37m ↳ %s\033[0m\n" "$base"
ui::rule::entries "$base" 6
done
}
# ======================================================
# Table view
# ======================================================
function ui::rule::list_header_table() {
printf "\n %-20s %-6s %-6s %-8s %-20s %s\n" \
"NAME" "ALLOW" "BLOCK" "PEERS" "EXTENDS" "GROUP"
printf " %s\n" "$(printf '─%.0s' {1..75})"
}
function ui::rule::list_row_table() {
local name="${1:-}" n_allows="${2:-0}" n_blocks="${3:-0}" \
peer_count="${4:-0}" extends="${5:-}" group="${6:-}"
printf " %-20s %-6s %-6s %-8s %-20s %s\n" \
"$name" "+${n_allows}" "-${n_blocks}" "$peer_count" \
"${extends:--}" "${group:--}"
}
# ======================================================
# Show helpers
# ======================================================
function ui::rule::section_header() {
local title="${1:-}"
printf "\n \033[0;37m── %s ──────────────────────────────────\033[0m\n" "$title"
}

View file

@ -8,6 +8,33 @@ function ui::subnet::header() {
ui::divider 70
}
function ui::policy::list_row() {
local name="${1:-}" default_rule="${2:-}" strict="${3:-}" auto="${4:-}"
local rule_val="-"
[[ -n "$default_rule" ]] && rule_val="$default_rule"
local rule_padded
rule_padded=$(printf "%-16s" "$rule_val")
local strict_display
[[ "$strict" == "true" ]] && strict_display="yes" || strict_display="no"
local strict_padded
strict_padded=$(printf "%-4s" "$strict_display")
local auto_display=""
[[ "$auto" == "false" ]] && auto_display=" \033[2mauto:\033[0m no"
printf " %-14s \033[2mrule:\033[0m %s \033[2mstrict:\033[0m %s%s\n" \
"$name" "$rule_padded" "$strict_padded" "$auto_display"
}
function ui::policy::detail_field() {
local key="${1:-}" value="${2:-}"
ui::row "$key" "$value"
}
function ui::subnet::row() {
local display_name="${1:-}" subnet="${2:-}" type_key="${3:-}" \
tunnel_mode="${4:-}" desc="${5:-}" is_group="${6:-false}"
@ -184,19 +211,3 @@ function ui::subnet::show_peers_annotated() {
printf " · %b\n" "$peer"
done
}
# ======================================================
# Table view
# ======================================================
function ui::subnet::list_header_table() {
printf "\n %-14s %-20s %-8s %s\n" \
"TYPE" "CIDR" "TUNNEL" "DESCRIPTION"
printf " %s\n" "$(printf '─%.0s' {1..65})"
}
function ui::subnet::list_row_table() {
local type="${1:-}" cidr="${2:-}" tunnel="${3:-}" desc="${4:-}"
printf " %-14s %-20s %-8s %s\n" \
"$type" "$cidr" "$tunnel" "${desc:--}"
}

15
wgctl
View file

@ -5,17 +5,12 @@ source "$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)/core.sh"
LOG_LEVEL=DEBUG
WGCTL_VERSION="0.7.1"
function wgctl::version() { echo "$WGCTL_VERSION"; }
# ============================================
# Modules
# ============================================
load_module ip
load_module ui
load_module display
load_module config
load_module keys
load_module peers
@ -27,9 +22,6 @@ load_module net
load_module group
load_module subnet
load_module identity
load_module policy
load_module hosts
load_module resolve
# ============================================
# Alias Map
@ -43,6 +35,7 @@ declare -A CMD_ALIASES=(
[del]=remove
[delete]=remove
[mv]=rename
[ls]=list
[show]=list
[monitor]=watch
[ban]=block
@ -52,6 +45,7 @@ declare -A CMD_ALIASES=(
[down]=service
[reload]=service
[stat]=service
[log]=service
[start]=service
[stop]=service
[restart]=service
@ -76,11 +70,6 @@ function wgctl::dispatch() {
local cmd
cmd="$(wgctl::resolve_alias "$raw_cmd")"
# Resolve config-defined aliases (from wgctl.json commands section)
if [[ -n "${_COMMAND_ALIASES[$cmd]:-}" ]]; then
cmd="${_COMMAND_ALIASES[$cmd]}"
fi
case "$cmd" in
help) wgctl::help; return ;;
shell) : ;;