Compare commits
No commits in common. "master" and "feature/activity-monitor" have entirely different histories.
master
...
feature/ac
87 changed files with 2973 additions and 10287 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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" ]]; }
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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]}"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
3
core.sh
3
core.sh
|
|
@ -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
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
106
core/context.sh
106
core/context.sh
|
|
@ -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/$*"
|
||||
}
|
||||
18
core/fmt.sh
18
core/fmt.sh
|
|
@ -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
|
||||
}
|
||||
58
core/json.sh
58
core/json.sh
|
|
@ -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; }
|
||||
2672
core/json_helper.py
2672
core/json_helper.py
File diff suppressed because it is too large
Load diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||
|
|
@ -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}")
|
||||
|
|
@ -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))
|
||||
|
|
@ -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)
|
||||
|
|
@ -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 '')
|
||||
|
|
@ -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
|
||||
255
core/lib/util.py
255
core/lib/util.py
|
|
@ -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
|
||||
|
|
@ -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" ]]; }
|
||||
|
|
@ -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" ]]; }
|
||||
29
core/ui.sh
29
core/ui.sh
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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=
|
||||
Binary file not shown.
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
# ============================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=()
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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})"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
15
wgctl
|
|
@ -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) : ;;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue