Compare commits
51 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4545a400b | ||
|
|
10ea174e44 | ||
|
|
9c11152682 | ||
|
|
b153f222a5 | ||
|
|
d26e67b940 | ||
|
|
b892298259 | ||
|
|
d314ba376e | ||
|
|
91593b2576 | ||
|
|
0b9f113453 | ||
|
|
79769667fb | ||
|
|
ddd705aa87 | ||
|
|
00d6be0766 | ||
|
|
8f3360c631 | ||
|
|
a7b05547f5 | ||
|
|
1a78dcf5da | ||
|
|
7a544f9019 | ||
|
|
dda8e408e8 | ||
|
|
74644e547c | ||
|
|
2a6648735e | ||
|
|
50013d8ede | ||
|
|
1fa40c1e25 | ||
|
|
0e3f281519 | ||
|
|
087f735790 | ||
|
|
fae088f61a | ||
|
|
14d2a78b78 | ||
|
|
a3fe7f5986 | ||
|
|
adab623f3f | ||
|
|
8b47e55b4a | ||
|
|
c3cf5bc572 | ||
|
|
c6883c6801 | ||
|
|
7120199004 | ||
|
|
fb33aa1b6d | ||
|
|
d5de344d99 | ||
|
|
3c3f870427 | ||
|
|
cf71e9f51a | ||
|
|
794e75bc9b | ||
|
|
d14db5e85c | ||
|
|
a003e3b753 | ||
|
|
86220850c1 | ||
|
|
3058750c3d | ||
|
|
5c2e16e358 | ||
|
|
8b1f4e48c1 | ||
|
|
3378ec3e5e | ||
|
|
1308f9e07a | ||
|
|
28ee56aeff | ||
|
|
a71f7a0dd9 | ||
|
|
92993e6423 | ||
|
|
689908c875 | ||
|
|
e54ce9c417 | ||
|
|
a9dcba73f4 | ||
|
|
b813810ff3 |
86 changed files with 9514 additions and 2550 deletions
|
|
@ -6,6 +6,8 @@
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
function cmd::activity::on_load() {
|
function cmd::activity::on_load() {
|
||||||
|
command::mixin json_output
|
||||||
|
|
||||||
load_module net
|
load_module net
|
||||||
|
|
||||||
flag::register --peer
|
flag::register --peer
|
||||||
|
|
@ -13,7 +15,14 @@ function cmd::activity::on_load() {
|
||||||
flag::register --ip
|
flag::register --ip
|
||||||
flag::register --hours
|
flag::register --hours
|
||||||
flag::register --type
|
flag::register --type
|
||||||
flag::register --dropped
|
flag::register --accept
|
||||||
|
flag::register --drop
|
||||||
|
flag::register --external
|
||||||
|
flag::register --ports
|
||||||
|
flag::register --exclude-service
|
||||||
|
flag::register --include-service
|
||||||
|
|
||||||
|
flag::exclusive --accept --drop
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
@ -33,11 +42,12 @@ Options:
|
||||||
--ip <ip> Filter by destination IP
|
--ip <ip> Filter by destination IP
|
||||||
--hours <n> Time window in hours (default: 24, 0 = all time)
|
--hours <n> Time window in hours (default: 24, 0 = all time)
|
||||||
--type <type> Filter by device type (combined with --peer)
|
--type <type> Filter by device type (combined with --peer)
|
||||||
--dropped Show only peers with at least one drop
|
--accept Show only accepted traffic (from conntrack)
|
||||||
|
--drop Show only firewall drops
|
||||||
|
--external Show only external traffic (full tunnel peers)
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
wgctl activity
|
wgctl activity
|
||||||
wgctl activity --dropped
|
|
||||||
wgctl activity --peer phone-nuno
|
wgctl activity --peer phone-nuno
|
||||||
wgctl activity --service truenas
|
wgctl activity --service truenas
|
||||||
wgctl activity --hours 0
|
wgctl activity --hours 0
|
||||||
|
|
@ -51,7 +61,9 @@ EOF
|
||||||
|
|
||||||
function cmd::activity::run() {
|
function cmd::activity::run() {
|
||||||
local filter_peer="" filter_service="" filter_ip="" filter_type=""
|
local filter_peer="" filter_service="" filter_ip="" filter_type=""
|
||||||
local hours=24 dropped_only=false
|
local hours=24
|
||||||
|
local accept_only=false drop_only=false external_only=false show_ports=false
|
||||||
|
local -a exclude_services=() include_services=()
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
|
|
@ -60,7 +72,12 @@ function cmd::activity::run() {
|
||||||
--ip) filter_ip="$2"; shift 2 ;;
|
--ip) filter_ip="$2"; shift 2 ;;
|
||||||
--type) filter_type="$2"; shift 2 ;;
|
--type) filter_type="$2"; shift 2 ;;
|
||||||
--hours) hours="$2"; shift 2 ;;
|
--hours) hours="$2"; shift 2 ;;
|
||||||
--dropped) dropped_only=true; shift ;;
|
--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 ;;
|
||||||
--help) cmd::activity::help; return ;;
|
--help) cmd::activity::help; return ;;
|
||||||
*)
|
*)
|
||||||
log::error "Unknown flag: $1"
|
log::error "Unknown flag: $1"
|
||||||
|
|
@ -70,42 +87,87 @@ function cmd::activity::run() {
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
# Resolve peer name if type provided
|
if command::json; then
|
||||||
|
cmd::activity::_output_json "$hours"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ -n "$filter_peer" && -n "$filter_type" ]]; then
|
if [[ -n "$filter_peer" && -n "$filter_type" ]]; then
|
||||||
filter_peer=$(peers::resolve_and_require "$filter_peer" "$filter_type") || return 1
|
filter_peer=$(peers::resolve_and_require "$filter_peer" "$filter_type") || return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Resolve --service to IP
|
|
||||||
local service_ip=""
|
local service_ip=""
|
||||||
if [[ -n "$filter_service" ]]; then
|
if [[ -n "$filter_service" ]]; then
|
||||||
service_ip=$(net::resolve "$filter_service" 2>/dev/null | head -1 | cut -d: -f1) || true
|
service_ip=$(net::resolve "$filter_service" 2>/dev/null | head -1 | cut -d: -f1) || true
|
||||||
if [[ -z "$service_ip" ]]; then
|
[[ -z "$service_ip" ]] && log::error "Service not found: ${filter_service}" && return 1
|
||||||
log::error "Service not found: ${filter_service}"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
[[ -n "$filter_ip" ]] && service_ip="$filter_ip"
|
[[ -n "$filter_ip" ]] && service_ip="$filter_ip"
|
||||||
|
|
||||||
# Fetch aggregated data
|
# Build final exclusion list — remove any --include-service entries
|
||||||
local data
|
local -a final_excludes=()
|
||||||
data=$(json::activity_aggregate \
|
for svc in "${exclude_services[@]:-}"; do
|
||||||
"$(ctx::fw_events_log)" \
|
local included=false
|
||||||
"$(ctx::events_log)" \
|
for inc in "${include_services[@]:-}"; do
|
||||||
"$(config::interface)" \
|
[[ "$svc" == "$inc" ]] && included=true && break
|
||||||
"$(ctx::net)" \
|
done
|
||||||
"$(ctx::clients)" \
|
$included || final_excludes+=("$svc")
|
||||||
"$(ctx::meta)" \
|
done
|
||||||
"$hours" \
|
|
||||||
"$filter_peer" \
|
|
||||||
"$service_ip" 2>/dev/null)
|
|
||||||
|
|
||||||
if [[ -z "$data" ]]; then
|
# Build exclude string for Python (space-separated)
|
||||||
log::wg_warning "No activity data found"
|
local exclude_str=""
|
||||||
return 0
|
[[ ${#final_excludes[@]} -gt 0 ]] && \
|
||||||
|
exclude_str=$(IFS=' '; echo "${final_excludes[*]}")
|
||||||
|
|
||||||
|
# ── Fetch data ──
|
||||||
|
local data=""
|
||||||
|
if ! $accept_only; then
|
||||||
|
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)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Measure w_peer and w_drops from data
|
local accept_data=""
|
||||||
local w_peer=16 w_drops=1
|
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
|
||||||
|
|
||||||
while IFS='|' read -r type rest; do
|
while IFS='|' read -r type rest; do
|
||||||
case "$type" in
|
case "$type" in
|
||||||
peer)
|
peer)
|
||||||
|
|
@ -113,22 +175,28 @@ function cmd::activity::run() {
|
||||||
name=$(echo "$rest" | cut -d'|' -f1)
|
name=$(echo "$rest" | cut -d'|' -f1)
|
||||||
drops=$(echo "$rest" | cut -d'|' -f4)
|
drops=$(echo "$rest" | cut -d'|' -f4)
|
||||||
(( ${#name} > w_peer )) && w_peer=${#name}
|
(( ${#name} > w_peer )) && w_peer=${#name}
|
||||||
(( ${#drops} > w_drops )) && w_drops=${#drops}
|
(( ${#drops} > w_count )) && w_count=${#drops}
|
||||||
;;
|
;;
|
||||||
service)
|
service)
|
||||||
local count
|
local svc_count
|
||||||
count=$(echo "$rest" | cut -d'|' -f3)
|
svc_count=$(echo "$rest" | cut -d'|' -f3)
|
||||||
(( ${#count} > w_drops )) && w_drops=${#count}
|
(( ${#svc_count} > w_count )) && w_count=${#svc_count}
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done <<< "$data"
|
done <<< "$data"
|
||||||
|
|
||||||
(( w_peer += 2 ))
|
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
|
||||||
|
|
||||||
# Compute exact column where drop count starts on peer row:
|
(( w_peer += 2 ))
|
||||||
# " " (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 drops_col=$(( w_peer + 30 ))
|
||||||
|
|
||||||
local hours_display="${hours}h"
|
local hours_display="${hours}h"
|
||||||
|
|
@ -137,27 +205,93 @@ function cmd::activity::run() {
|
||||||
log::section "Activity Monitor (last ${hours_display})"
|
log::section "Activity Monitor (last ${hours_display})"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
local first_peer=true skip_peer=false
|
if display::is_table "activity"; then
|
||||||
|
cmd::activity::_render_table "$data"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 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
|
while IFS='|' read -r record_type rest; do
|
||||||
case "$record_type" in
|
case "$record_type" in
|
||||||
peer)
|
peer)
|
||||||
local name rx tx drops
|
local name rx tx drops
|
||||||
IFS='|' read -r name rx tx drops <<< "$rest"
|
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
|
skip_peer=false
|
||||||
if $dropped_only && [[ "$drops" -eq 0 ]]; then
|
current_name="$name"
|
||||||
skip_peer=true
|
local has_accept="${_ACCEPT_PEER[$name]:-}"
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
$first_peer || echo ""
|
$first_peer || echo ""
|
||||||
first_peer=false
|
first_peer=false
|
||||||
|
rendered_peers+=("$name")
|
||||||
|
|
||||||
local rx_fmt tx_fmt
|
local rx_fmt tx_fmt
|
||||||
rx_fmt=$(cmd::activity::_fmt_bytes "$rx")
|
rx_fmt=$(fmt::bytes "$rx")
|
||||||
tx_fmt=$(cmd::activity::_fmt_bytes "$tx")
|
tx_fmt=$(fmt::bytes "$tx")
|
||||||
|
|
||||||
local name_pad rx_pad tx_pad
|
local name_pad rx_pad tx_pad
|
||||||
name_pad=$(printf "%-${w_peer}s" "$name")
|
name_pad=$(printf "%-${w_peer}s" "$name")
|
||||||
rx_pad=$(printf "%-10s" "$rx_fmt")
|
rx_pad=$(printf "%-10s" "$rx_fmt")
|
||||||
|
|
@ -165,51 +299,205 @@ function cmd::activity::run() {
|
||||||
|
|
||||||
local drop_word="drops"
|
local drop_word="drops"
|
||||||
[[ "$drops" -eq 1 ]] && drop_word="drop"
|
[[ "$drops" -eq 1 ]] && drop_word="drop"
|
||||||
printf " \033[1m%s\033[0m \033[2m↓\033[0m%s \033[2m↑\033[0m%s %${w_drops}s %s\n" \
|
|
||||||
"$name_pad" "$rx_pad" "$tx_pad" "$drops" "$drop_word"
|
# 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
|
||||||
;;
|
;;
|
||||||
|
|
||||||
service)
|
service)
|
||||||
$skip_peer && continue
|
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"
|
||||||
local peer dest_display drop_count
|
# Build dim suffix if --ports
|
||||||
IFS='|' read -r peer dest_display drop_count <<< "$rest"
|
local svc_display="$dest_display"
|
||||||
|
if [[ "$show_ports" == "true" && -n "$dst_ip" ]]; then
|
||||||
# Compute padding to align drop count with peer drop column
|
if [[ -n "$dst_port" ]]; then
|
||||||
# Service row visible prefix: " → " (6) + ${#dest_display}
|
svc_display=$(printf "%s \033[2m(%s:%s)\033[0m" \
|
||||||
local arrow_prefix=" → "
|
"$dest_display" "$dst_ip" "$dst_port")
|
||||||
local prefix_bytes=${#arrow_prefix} # = 8 due to → being 3 bytes
|
else
|
||||||
local prefix_len=$(( prefix_bytes + ${#dest_display} ))
|
svc_display=$(printf "%s \033[2m(%s)\033[0m" \
|
||||||
# local prefix_len=$(( 6 + ${#dest_display} ))
|
"$dest_display" "$dst_ip")
|
||||||
local pad_n=$(( drops_col - prefix_len ))
|
fi
|
||||||
[[ $pad_n -lt 1 ]] && pad_n=1
|
fi
|
||||||
|
|
||||||
local svc_drop_word="drops"
|
local svc_drop_word="drops"
|
||||||
[[ "$drop_count" -eq 1 ]] && svc_drop_word="drop"
|
[[ "$drop_count" -eq 1 ]] && svc_drop_word="drop"
|
||||||
printf " \033[2m→\033[0m %s%*s %${w_drops}s %s\n" \
|
$accept_only || ui::activity::service_row \
|
||||||
"$dest_display" "$pad_n" "" "$drop_count" "$svc_drop_word"
|
"$svc_display" "$drop_count" "$svc_drop_word" "$drops_col" "$w_count"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done <<< "$data"
|
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 ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================
|
function cmd::activity::_render_table() {
|
||||||
# Helpers
|
local data="${1:-}"
|
||||||
# ============================================
|
[[ -z "$data" ]] && return 0
|
||||||
|
|
||||||
function cmd::activity::_fmt_bytes() {
|
ui::activity::header_table
|
||||||
local bytes="${1:-0}"
|
local skip_peer=false
|
||||||
if (( bytes == 0 )); then
|
while IFS='|' read -r record_type rest; do
|
||||||
printf "—"
|
case "$record_type" in
|
||||||
elif (( bytes >= 1073741824 )); then
|
peer)
|
||||||
printf "%dGB" $(( bytes / 1073741824 ))
|
local name rx tx drops
|
||||||
elif (( bytes >= 1048576 )); then
|
IFS='|' read -r name rx tx drops <<< "$rest"
|
||||||
printf "%dMB" $(( bytes / 1048576 ))
|
skip_peer=false
|
||||||
elif (( bytes >= 1024 )); then
|
local rx_fmt tx_fmt
|
||||||
printf "%dKB" $(( bytes / 1024 ))
|
rx_fmt=$(fmt::bytes "$rx")
|
||||||
else
|
tx_fmt=$(fmt::bytes "$tx")
|
||||||
printf "%dB" "$bytes"
|
ui::activity::peer_row_table "$name" "$rx_fmt" "$tx_fmt" "$drops" ""
|
||||||
fi
|
;;
|
||||||
|
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=()
|
||||||
|
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,6 +16,7 @@ function cmd::block::on_load() {
|
||||||
flag::register --subnet
|
flag::register --subnet
|
||||||
flag::register --block-name
|
flag::register --block-name
|
||||||
flag::register --service
|
flag::register --service
|
||||||
|
flag::register --reason
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
@ -61,6 +62,7 @@ function cmd::block::run() {
|
||||||
local name="" identity="" type="" block_name=""
|
local name="" identity="" type="" block_name=""
|
||||||
local ips=() subnets=() ports=() services=()
|
local ips=() subnets=() ports=() services=()
|
||||||
local quiet=false force=false
|
local quiet=false force=false
|
||||||
|
local reason=""
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
|
|
@ -74,6 +76,7 @@ function cmd::block::run() {
|
||||||
--quiet) quiet=true; shift ;;
|
--quiet) quiet=true; shift ;;
|
||||||
--subnet) subnets+=("$2"); shift 2 ;;
|
--subnet) subnets+=("$2"); shift 2 ;;
|
||||||
--port) ports+=("$2"); shift 2 ;;
|
--port) ports+=("$2"); shift 2 ;;
|
||||||
|
--reason) reason="$2"; shift 2 ;;
|
||||||
--help) cmd::block::help; return ;;
|
--help) cmd::block::help; return ;;
|
||||||
*)
|
*)
|
||||||
log::error "Unknown flag: $1"
|
log::error "Unknown flag: $1"
|
||||||
|
|
@ -110,6 +113,7 @@ function cmd::block::run() {
|
||||||
fi
|
fi
|
||||||
monitor::update_endpoint_cache
|
monitor::update_endpoint_cache
|
||||||
cmd::block::_block_all "$name" "$client_ip" "$quiet"
|
cmd::block::_block_all "$name" "$client_ip" "$quiet"
|
||||||
|
cmd::block::_record_history "$name" "full" "manual" "$reason"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -209,6 +213,14 @@ function cmd::block::run() {
|
||||||
fi
|
fi
|
||||||
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
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -271,3 +283,24 @@ function cmd::block::_block_all() {
|
||||||
|
|
||||||
$quiet || log::wg_success "${name} has been blocked."
|
$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,6 +7,8 @@
|
||||||
function cmd::config::on_load() {
|
function cmd::config::on_load() {
|
||||||
flag::register --name
|
flag::register --name
|
||||||
flag::register --type
|
flag::register --type
|
||||||
|
flag::register --force
|
||||||
|
flag::register --dry-run
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
@ -33,27 +35,43 @@ EOF
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
function cmd::config::run() {
|
function cmd::config::run() {
|
||||||
local name=""
|
local subcmd="${1:-show}"
|
||||||
local type=""
|
|
||||||
|
|
||||||
|
# If first arg is a flag, treat as 'show' subcommand
|
||||||
|
if [[ "$subcmd" == --* ]]; then
|
||||||
|
subcmd="show"
|
||||||
|
else
|
||||||
|
shift || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$subcmd" in
|
||||||
|
show) cmd::config::_show "$@" ;;
|
||||||
|
migrate) cmd::config::migrate "$@" ;;
|
||||||
|
help) cmd::config::help ;;
|
||||||
|
*)
|
||||||
|
log::error "Unknown subcommand: '${subcmd}'"
|
||||||
|
cmd::config::help
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Show
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
function cmd::config::_show() {
|
||||||
|
local name="" type=""
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--name) name="$2"; shift 2 ;;
|
--name) name="$2"; shift 2 ;;
|
||||||
--type) type="$2"; shift 2 ;;
|
--type) type="$2"; shift 2 ;;
|
||||||
--help) cmd::config::help; return ;;
|
--help) cmd::config::help; return ;;
|
||||||
*)
|
*) log::error "Unknown flag: $1"; return 1 ;;
|
||||||
log::error "Unknown flag: $1"
|
|
||||||
cmd::config::help
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ -z "$name" ]]; then
|
[[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
|
||||||
log::error "Missing required flag: --name"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
name=$(peers::resolve_and_require "$name" "$type") || return 1
|
name=$(peers::resolve_and_require "$name" "$type") || return 1
|
||||||
|
|
||||||
local conf
|
local conf
|
||||||
|
|
@ -62,3 +80,202 @@ function cmd::config::run() {
|
||||||
log::section "Client Config: ${name}"
|
log::section "Client Config: ${name}"
|
||||||
cat "$conf"
|
cat "$conf"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Migrate
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
function cmd::config::migrate() {
|
||||||
|
local force=false dry_run=false
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--force) force=true; shift ;;
|
||||||
|
--dry-run) dry_run=true; shift ;;
|
||||||
|
--help) cmd::config::help; return ;;
|
||||||
|
*) log::error "Unknown flag: $1"; return 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
local wgctl_dir
|
||||||
|
wgctl_dir="$(ctx::wgctl)"
|
||||||
|
local config_dir="${wgctl_dir}/config"
|
||||||
|
local data_dir="${wgctl_dir}/data"
|
||||||
|
local legacy_conf="${wgctl_dir}/wgctl.conf"
|
||||||
|
local json_conf="${config_dir}/wgctl.json"
|
||||||
|
|
||||||
|
# Check if already migrated
|
||||||
|
if [[ -f "$json_conf" && ! -f "$legacy_conf" ]]; then
|
||||||
|
log::wg_warning "Already migrated to new config structure"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log::section "wgctl Config Migration"
|
||||||
|
printf "\n"
|
||||||
|
printf " This will:\n"
|
||||||
|
printf " 1. Create %s/config/ and %s/data/\n" "$wgctl_dir" "$wgctl_dir"
|
||||||
|
printf " 2. Convert wgctl.conf → wgctl.json\n"
|
||||||
|
printf " 3. Move data files to data/\n\n"
|
||||||
|
|
||||||
|
if ! $force && ! $dry_run; then
|
||||||
|
read -r -p " Proceed? [y/N] " confirm
|
||||||
|
case "$confirm" in [yY]*) ;; *) log::info "Aborted"; return 0 ;; esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
local do=""
|
||||||
|
$dry_run && do="echo [dry-run]"
|
||||||
|
|
||||||
|
# 1. Create directories
|
||||||
|
$dry_run || mkdir -p "$config_dir" "$data_dir"
|
||||||
|
$dry_run && printf " Would create: %s/config/\n" "$wgctl_dir"
|
||||||
|
$dry_run && printf " Would create: %s/data/\n" "$wgctl_dir"
|
||||||
|
|
||||||
|
# 2. Convert wgctl.conf → wgctl.json
|
||||||
|
if [[ -f "$legacy_conf" ]]; then
|
||||||
|
if ! $dry_run; then
|
||||||
|
config::_convert_to_json "$legacy_conf" "$json_conf"
|
||||||
|
fi
|
||||||
|
printf " %s wgctl.conf → config/wgctl.json\n" "$($dry_run && echo '[dry-run]' || echo '✓')"
|
||||||
|
else
|
||||||
|
log::wg_warning "No wgctl.conf found — creating default wgctl.json"
|
||||||
|
$dry_run || config::_write_default_json "$json_conf"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Move data files
|
||||||
|
local -a data_files=(
|
||||||
|
"hosts.json"
|
||||||
|
"services.json"
|
||||||
|
"subnets.json"
|
||||||
|
"policies.json"
|
||||||
|
)
|
||||||
|
local -a data_dirs=(
|
||||||
|
"rules"
|
||||||
|
"identities"
|
||||||
|
"groups"
|
||||||
|
"blocks"
|
||||||
|
"meta"
|
||||||
|
"peer-history"
|
||||||
|
)
|
||||||
|
|
||||||
|
for f in "${data_files[@]}"; do
|
||||||
|
if [[ -f "${wgctl_dir}/${f}" ]]; then
|
||||||
|
$dry_run || mv "${wgctl_dir}/${f}" "${data_dir}/${f}"
|
||||||
|
printf " %s %s → data/%s\n" \
|
||||||
|
"$($dry_run && echo '[dry-run]' || echo '✓')" "$f" "$f"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
for d in "${data_dirs[@]}"; do
|
||||||
|
if [[ -d "${wgctl_dir}/${d}" ]]; then
|
||||||
|
$dry_run || mv "${wgctl_dir}/${d}" "${data_dir}/${d}"
|
||||||
|
printf " %s %s/ → data/%s/\n" \
|
||||||
|
"$($dry_run && echo '[dry-run]' || echo '✓')" "$d" "$d"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 4. Remove legacy conf after successful migration
|
||||||
|
if ! $dry_run && [[ -f "$legacy_conf" ]]; then
|
||||||
|
mv "$legacy_conf" "${legacy_conf}.bak"
|
||||||
|
printf " ✓ wgctl.conf → wgctl.conf.bak (backup)\n"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf "\n"
|
||||||
|
$dry_run && log::wg_warning "Dry run — no changes made" \
|
||||||
|
|| log::wg_success "Migration complete"
|
||||||
|
}
|
||||||
|
|
||||||
|
function config::_convert_to_json() {
|
||||||
|
local legacy_file="$1" output_file="$2"
|
||||||
|
|
||||||
|
# Read legacy conf into variables
|
||||||
|
local wg_interface="wg0" wg_endpoint="" wg_dns="10.0.0.103"
|
||||||
|
local wg_dns_fallback="" wg_port="51820" wg_subnet="10.1.0.0/16"
|
||||||
|
local wg_lan="10.0.0.0/24" wg_hs_check="300" date_format="eu"
|
||||||
|
|
||||||
|
while IFS='=' read -r key value || [[ -n "$key" ]]; do
|
||||||
|
[[ "$key" =~ ^[[:space:]]*# ]] && continue
|
||||||
|
[[ -z "${key// }" ]] && continue
|
||||||
|
key="${key// /}"
|
||||||
|
value="${value// /}"
|
||||||
|
case "$key" in
|
||||||
|
WG_INTERFACE) wg_interface="$value" ;;
|
||||||
|
WG_ENDPOINT) wg_endpoint="$value" ;;
|
||||||
|
WG_DNS) wg_dns="$value" ;;
|
||||||
|
WG_DNS_FALLBACK) wg_dns_fallback="$value" ;;
|
||||||
|
WG_PORT) wg_port="$value" ;;
|
||||||
|
WG_SUBNET) wg_subnet="$value" ;;
|
||||||
|
WG_LAN) wg_lan="$value" ;;
|
||||||
|
WG_HANDSHAKE_CHECK_TIME_SEC) wg_hs_check="$value" ;;
|
||||||
|
DATE_FORMAT) date_format="$value" ;;
|
||||||
|
esac
|
||||||
|
done < "$legacy_file"
|
||||||
|
|
||||||
|
# Build fallback DNS array
|
||||||
|
local dns_fallback_json="[]"
|
||||||
|
if [[ -n "$wg_dns_fallback" ]]; then
|
||||||
|
local fallback_array
|
||||||
|
fallback_array=$(echo "$wg_dns_fallback" | tr ',' '\n' | \
|
||||||
|
while IFS= read -r s; do
|
||||||
|
s="${s// /}"
|
||||||
|
[[ -n "$s" ]] && printf '"%s",' "$s"
|
||||||
|
done | sed 's/,$//')
|
||||||
|
dns_fallback_json="[${fallback_array}]"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$output_file")"
|
||||||
|
cat > "$output_file" << JSON
|
||||||
|
{
|
||||||
|
"wireguard": {
|
||||||
|
"interface": "${wg_interface}",
|
||||||
|
"endpoint": "${wg_endpoint}",
|
||||||
|
"port": ${wg_port},
|
||||||
|
"subnet": "${wg_subnet}",
|
||||||
|
"lan": "${wg_lan}"
|
||||||
|
},
|
||||||
|
"dns": {
|
||||||
|
"primary": "${wg_dns}",
|
||||||
|
"fallback": ${dns_fallback_json}
|
||||||
|
},
|
||||||
|
"handshake": {
|
||||||
|
"check_interval_sec": ${wg_hs_check}
|
||||||
|
},
|
||||||
|
"activity": {
|
||||||
|
"total": {"low": 1000000, "medium": 10000000, "high": 100000000},
|
||||||
|
"current": {"low": 1000000, "medium": 10000000, "high": 100000000}
|
||||||
|
},
|
||||||
|
"display": {
|
||||||
|
"date_format": "${date_format}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
function config::_write_default_json() {
|
||||||
|
local output_file="$1"
|
||||||
|
mkdir -p "$(dirname "$output_file")"
|
||||||
|
cat > "$output_file" << 'JSON'
|
||||||
|
{
|
||||||
|
"wireguard": {
|
||||||
|
"interface": "wg0",
|
||||||
|
"endpoint": "",
|
||||||
|
"port": 51820,
|
||||||
|
"subnet": "10.1.0.0/16",
|
||||||
|
"lan": "10.0.0.0/24"
|
||||||
|
},
|
||||||
|
"dns": {
|
||||||
|
"primary": "10.0.0.103",
|
||||||
|
"fallback": []
|
||||||
|
},
|
||||||
|
"handshake": {
|
||||||
|
"check_interval_sec": 300
|
||||||
|
},
|
||||||
|
"activity": {
|
||||||
|
"total": {"low": 1000000, "medium": 10000000, "high": 100000000},
|
||||||
|
"current": {"low": 1000000, "medium": 10000000, "high": 100000000}
|
||||||
|
},
|
||||||
|
"display": {
|
||||||
|
"date_format": "eu"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
}
|
||||||
305
commands/export.command.sh
Normal file
305
commands/export.command.sh
Normal file
|
|
@ -0,0 +1,305 @@
|
||||||
|
#!/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,6 +13,9 @@ function cmd::group::on_load() {
|
||||||
flag::register --new-name
|
flag::register --new-name
|
||||||
flag::register --main
|
flag::register --main
|
||||||
flag::register --force
|
flag::register --force
|
||||||
|
flag::register --all
|
||||||
|
flag::register --dry-run
|
||||||
|
command::mixin json_output
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
@ -37,6 +40,7 @@ Subcommands:
|
||||||
peer add Add a peer to a group
|
peer add Add a peer to a group
|
||||||
peer remove, peer rm Remove a peer from a group
|
peer remove, peer rm Remove a peer from a group
|
||||||
rm-peers Remove all peers in group from WireGuard
|
rm-peers Remove all peers in group from WireGuard
|
||||||
|
purge-stale Remove peers that no longer exist from group(s)
|
||||||
block Block all peers in group
|
block Block all peers in group
|
||||||
unblock Unblock all peers in group
|
unblock Unblock all peers in group
|
||||||
rule assign Assign a rule to all peers in group
|
rule assign Assign a rule to all peers in group
|
||||||
|
|
@ -53,6 +57,7 @@ Options:
|
||||||
--new-name <name> New group name (for rename)
|
--new-name <name> New group name (for rename)
|
||||||
--limit <n> Max log entries per peer (for logs)
|
--limit <n> Max log entries per peer (for logs)
|
||||||
--force Skip confirmation prompts
|
--force Skip confirmation prompts
|
||||||
|
--all Apply to all groups (for purge-stale)
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
wgctl group list
|
wgctl group list
|
||||||
|
|
@ -62,6 +67,9 @@ Examples:
|
||||||
wgctl group block --name family
|
wgctl group block --name family
|
||||||
wgctl group unblock --name family
|
wgctl group unblock --name family
|
||||||
wgctl group rule assign --name family --rule user
|
wgctl group rule assign --name family --rule user
|
||||||
|
wgctl group purge-stale --name family
|
||||||
|
wgctl group purge-stale --all
|
||||||
|
wgctl group purge-stale --all --force
|
||||||
wgctl group audit --name family
|
wgctl group audit --name family
|
||||||
wgctl group logs --name family --limit 20
|
wgctl group logs --name family --limit 20
|
||||||
wgctl group watch --name family
|
wgctl group watch --name family
|
||||||
|
|
@ -76,6 +84,11 @@ function cmd::group::run() {
|
||||||
local subcmd="${1:-help}"
|
local subcmd="${1:-help}"
|
||||||
shift || true
|
shift || true
|
||||||
|
|
||||||
|
if command::json; then
|
||||||
|
cmd::group::_output_json
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
case "$subcmd" in
|
case "$subcmd" in
|
||||||
list|ls) cmd::group::list "$@" ;;
|
list|ls) cmd::group::list "$@" ;;
|
||||||
show) cmd::group::show "$@" ;;
|
show) cmd::group::show "$@" ;;
|
||||||
|
|
@ -88,6 +101,7 @@ function cmd::group::run() {
|
||||||
block) cmd::group::block "$@" ;;
|
block) cmd::group::block "$@" ;;
|
||||||
unblock) cmd::group::unblock "$@" ;;
|
unblock) cmd::group::unblock "$@" ;;
|
||||||
rule) cmd::group::rule "$@" ;;
|
rule) cmd::group::rule "$@" ;;
|
||||||
|
purge-stale) cmd::group::purge_stale "$@" ;;
|
||||||
audit) cmd::group::audit "$@" ;;
|
audit) cmd::group::audit "$@" ;;
|
||||||
logs) cmd::group::logs "$@" ;;
|
logs) cmd::group::logs "$@" ;;
|
||||||
watch) cmd::group::watch "$@" ;;
|
watch) cmd::group::watch "$@" ;;
|
||||||
|
|
@ -114,40 +128,36 @@ function cmd::group::list() {
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
local data
|
||||||
|
data=$(json::group_list_data "$groups_dir" "$(ctx::blocks)" "$(ctx::clients)")
|
||||||
|
[[ -z "$data" ]] && log::wg "No groups configured" && return 0
|
||||||
|
|
||||||
|
# Measure column widths
|
||||||
|
local w_name=12 w_desc=16
|
||||||
|
while IFS="|" read -r name desc total blocked; do
|
||||||
|
[[ -z "$name" ]] && continue
|
||||||
|
(( ${#name} > w_name )) && w_name=${#name}
|
||||||
|
local desc_len=${#desc}
|
||||||
|
[[ -z "$desc" ]] && desc_len=1
|
||||||
|
(( desc_len > w_desc )) && w_desc=$desc_len
|
||||||
|
done <<< "$data"
|
||||||
|
(( w_name += 2 ))
|
||||||
|
(( w_desc += 2 ))
|
||||||
|
|
||||||
log::section "Groups"
|
log::section "Groups"
|
||||||
printf "\n %-20s %-35s %-8s %s\n" "NAME" "DESCRIPTION" "PEERS" "STATUS"
|
echo ""
|
||||||
printf " %s\n" "$(printf '─%.0s' {1..75})"
|
|
||||||
|
if display::is_table "group_list"; then
|
||||||
|
cmd::group::_render_table "$data" "$w_name" "$w_desc"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
while IFS="|" read -r name desc total blocked; do
|
while IFS="|" read -r name desc total blocked; do
|
||||||
[[ -z "$name" ]] && continue
|
[[ -z "$name" ]] && continue
|
||||||
|
ui::group::list_row "$name" "$desc" "$total" "$blocked" "$w_name" "$w_desc"
|
||||||
|
done <<< "$data"
|
||||||
|
|
||||||
local status_color="" status_str="active"
|
echo ""
|
||||||
if [[ "$total" -gt 0 ]]; then
|
|
||||||
if [[ "$blocked" -eq "$total" ]]; then
|
|
||||||
status_color="\033[1;31m"
|
|
||||||
status_str="blocked"
|
|
||||||
elif [[ "$blocked" -gt 0 ]]; then
|
|
||||||
status_color="\033[1;33m"
|
|
||||||
status_str="blocked (${blocked}/${total})"
|
|
||||||
else
|
|
||||||
status_color="\033[1;32m"
|
|
||||||
status_str="active"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
local short_desc="${desc:0:33}"
|
|
||||||
[[ ${#desc} -gt 33 ]] && short_desc="${short_desc}..."
|
|
||||||
|
|
||||||
local desc_col_width=35
|
|
||||||
[[ "$desc" == "—" || -z "$desc" ]] && desc_col_width=37
|
|
||||||
|
|
||||||
printf " %-20s %-${desc_col_width}s %-8s %b\n" \
|
|
||||||
"$name" "${short_desc:-—}" "$total" \
|
|
||||||
"${status_color}${status_str}\033[0m"
|
|
||||||
|
|
||||||
done < <(json::group_list_data "$groups_dir" "$(ctx::blocks)")
|
|
||||||
|
|
||||||
printf "\n"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
@ -172,62 +182,61 @@ function cmd::group::show() {
|
||||||
group_file="$(group::path "$name")"
|
group_file="$(group::path "$name")"
|
||||||
|
|
||||||
log::section "Group: ${name}"
|
log::section "Group: ${name}"
|
||||||
|
printf "\n"
|
||||||
|
|
||||||
local desc
|
local desc
|
||||||
desc=$(json::get "$group_file" "desc")
|
desc=$(json::get "$group_file" "desc")
|
||||||
printf "\n %-20s %s\n" "Description:" "${desc:-—}"
|
ui::row "Description" "${desc:-—}"
|
||||||
|
|
||||||
# Load peers
|
# Load and filter peers
|
||||||
local peers_list=()
|
local peers_list=()
|
||||||
mapfile -t peers_list < <(json::get "$group_file" "peers")
|
mapfile -t peers_list < <(json::get "$group_file" "peers") || true
|
||||||
# Filter empty entries
|
|
||||||
local filtered=()
|
local filtered=()
|
||||||
for p in "${peers_list[@]:-}"; do
|
for p in "${peers_list[@]:-}"; do
|
||||||
[[ -n "$p" ]] && filtered+=("$p")
|
[[ -n "$p" ]] && filtered+=("$p")
|
||||||
done
|
done
|
||||||
peers_list=("${filtered[@]:-}")
|
peers_list=("${filtered[@]:-}")
|
||||||
local peer_count=${#peers_list[@]}
|
local peer_count=${#peers_list[@]}
|
||||||
|
[[ -z "${peers_list[0]:-}" ]] && peer_count=0
|
||||||
|
|
||||||
[[ -z "${peers_list[0]}" ]] && peer_count=0
|
# Count valid peers (data logic stays in command)
|
||||||
|
local valid_count=0
|
||||||
printf " %-20s %s\n" "Peers:" "$peer_count"
|
for p in "${peers_list[@]}"; do
|
||||||
printf " %s\n" "$(printf '─%.0s' {1..50})"
|
[[ -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"
|
||||||
|
|
||||||
if [[ "$peer_count" -gt 0 ]]; then
|
if [[ "$peer_count" -gt 0 ]]; then
|
||||||
printf "\n %-28s %-15s %-12s %s\n" "NAME" "IP" "RULE" "STATUS"
|
# Measure widths (data logic stays in command)
|
||||||
printf " %s\n" "$(printf '─%.0s' {1..65})"
|
local w_name=16 w_ip=13
|
||||||
for peer_name in "${peers_list[@]}"; do
|
for peer_name in "${peers_list[@]}"; do
|
||||||
[[ -z "$peer_name" ]] && continue
|
[[ -z "$peer_name" ]] && continue
|
||||||
|
(( ${#peer_name} > w_name )) && w_name=${#peer_name}
|
||||||
# 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
|
|
||||||
status_color="\033[1;32m"
|
|
||||||
status_str="active"
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf " %-28s %-15s %-12s %b\n" \
|
|
||||||
"$peer_name" "0" "$rule" \
|
|
||||||
"${status_str}\033[0m"
|
|
||||||
done
|
done
|
||||||
|
(( w_name += 2 ))
|
||||||
|
|
||||||
|
# Delegate rendering to ui::
|
||||||
|
ui::group::show_peers peers_list "$w_name" "$w_ip"
|
||||||
else
|
else
|
||||||
printf " —\n"
|
printf " \033[2m—\033[0m\n"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
printf "\n"
|
printf "\n"
|
||||||
return 0
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
@ -834,3 +843,110 @@ function cmd::group::watch() {
|
||||||
load_command watch
|
load_command watch
|
||||||
cmd::watch::run --peers "$peer_filter"
|
cmd::watch::run --peers "$peer_filter"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Purge Stale
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
function cmd::group::purge_stale() {
|
||||||
|
local name="" force=false all=false
|
||||||
|
local dry_run=false
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;;
|
||||||
|
--force) force=true; shift ;;
|
||||||
|
--all) all=true; shift ;;
|
||||||
|
--dry-run) dry_run=true; shift ;;
|
||||||
|
--help) cmd::group::help; return ;;
|
||||||
|
*) log::error "Unknown flag: $1"; return 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ -z "$name" && "$all" == "false" ]] && \
|
||||||
|
log::error "Specify --name <group> or --all" && return 1
|
||||||
|
|
||||||
|
# Build list of groups to process
|
||||||
|
local -a groups=()
|
||||||
|
if $all; then
|
||||||
|
while IFS= read -r group_file; do
|
||||||
|
groups+=("$(basename "$group_file" .group)")
|
||||||
|
done < <(find "$(ctx::groups)" -name "*.group" 2>/dev/null | sort)
|
||||||
|
else
|
||||||
|
group::require_exists "$name" || return 1
|
||||||
|
groups=("$name")
|
||||||
|
fi
|
||||||
|
|
||||||
|
local total_removed=0 total_groups=0
|
||||||
|
|
||||||
|
for group_name in "${groups[@]}"; do
|
||||||
|
[[ -z "$group_name" ]] && continue
|
||||||
|
|
||||||
|
# Find stale peers — in group but no .conf file
|
||||||
|
local -a stale=()
|
||||||
|
while IFS= read -r peer_name; do
|
||||||
|
[[ -z "$peer_name" ]] && continue
|
||||||
|
if [[ ! -f "$(ctx::clients)/${peer_name}.conf" ]]; then
|
||||||
|
stale+=("$peer_name")
|
||||||
|
fi
|
||||||
|
done < <(group::peers "$group_name" 2>/dev/null)
|
||||||
|
|
||||||
|
[[ ${#stale[@]} -eq 0 ]] && continue
|
||||||
|
|
||||||
|
(( total_groups++ )) || true
|
||||||
|
|
||||||
|
if ! $force; then
|
||||||
|
printf " Group '%s' has %d stale peer(s): %s\n" \
|
||||||
|
"$group_name" "${#stale[@]}" "${stale[*]}"
|
||||||
|
read -r -p " Remove them? [y/N] " confirm
|
||||||
|
case "$confirm" in
|
||||||
|
[yY]*) ;;
|
||||||
|
*) log::info "Skipped '${group_name}'"; continue ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
local group_file
|
||||||
|
group_file="$(group::path "$group_name")"
|
||||||
|
for peer_name in "${stale[@]}"; do
|
||||||
|
if $dry_run; then
|
||||||
|
printf " \033[2m[dry-run]\033[0m Would remove '%s' from group '%s'\n" \
|
||||||
|
"$peer_name" "$group_name"
|
||||||
|
else
|
||||||
|
json::remove "$group_file" "peers" "$peer_name" 2>/dev/null || true
|
||||||
|
log::debug "Removed stale peer '${peer_name}' from group '${group_name}'"
|
||||||
|
fi
|
||||||
|
(( total_removed++ )) || true
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
local action="Removed"
|
||||||
|
$dry_run && action="Would remove"
|
||||||
|
log::wg_success "${action} ${total_removed} stale peer(s)..."
|
||||||
|
|
||||||
|
if $all; then
|
||||||
|
if [[ "$total_removed" -eq 0 ]]; then
|
||||||
|
log::wg_warning "No stale peers found in any group"
|
||||||
|
else
|
||||||
|
log::wg_success "${action} ${total_removed} stale peer(s)..."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmd::group::_output_json() {
|
||||||
|
local groups_dir
|
||||||
|
groups_dir="$(ctx::groups)"
|
||||||
|
local data
|
||||||
|
data=$(json::group_list_data "$groups_dir" "$(ctx::blocks)" "$(ctx::clients)" 2>/dev/null)
|
||||||
|
|
||||||
|
local -a groups=()
|
||||||
|
while IFS='|' read -r name desc peer_count blocked_count; do
|
||||||
|
[[ -z "$name" ]] && continue
|
||||||
|
groups+=("$(printf '{"name":"%s","desc":"%s","peer_count":%s,"blocked_count":%s}' \
|
||||||
|
"$name" "$desc" "$peer_count" "$blocked_count")")
|
||||||
|
done <<< "$data"
|
||||||
|
|
||||||
|
local count=${#groups[@]}
|
||||||
|
local array
|
||||||
|
array=$(printf '%s\n' "${groups[@]:-}" | paste -sd ',' -)
|
||||||
|
printf '{"groups":[%s]}' "${array:-}" | json::envelope "group list" "$count"
|
||||||
|
}
|
||||||
326
commands/hosts.command.sh
Normal file
326
commands/hosts.command.sh
Normal file
|
|
@ -0,0 +1,326 @@
|
||||||
|
#!/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,9 +20,7 @@ function cmd::identity::on_load() {
|
||||||
flag::register --peer
|
flag::register --peer
|
||||||
flag::register --dry-run
|
flag::register --dry-run
|
||||||
flag::register --force
|
flag::register --force
|
||||||
# rule subcommand flags
|
|
||||||
flag::register --rule
|
flag::register --rule
|
||||||
# options subcommand flags
|
|
||||||
flag::register --policy
|
flag::register --policy
|
||||||
flag::register --set-strict-rule
|
flag::register --set-strict-rule
|
||||||
flag::register --unset-strict-rule
|
flag::register --unset-strict-rule
|
||||||
|
|
@ -30,6 +28,9 @@ function cmd::identity::on_load() {
|
||||||
flag::register --unset-auto-apply
|
flag::register --unset-auto-apply
|
||||||
flag::register --field
|
flag::register --field
|
||||||
flag::register --value
|
flag::register --value
|
||||||
|
flag::register --migrate
|
||||||
|
|
||||||
|
command::mixin json_output
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
@ -40,33 +41,32 @@ function cmd::identity::help() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
Usage: wgctl identity <subcommand> [options]
|
Usage: wgctl identity <subcommand> [options]
|
||||||
|
|
||||||
Manage peer identities.
|
Manage peer identities — group peers by person/device owner.
|
||||||
|
|
||||||
Subcommands:
|
Subcommands:
|
||||||
list List all identities
|
list List all identities
|
||||||
show --name <name> Show identity details and device status
|
show --name <n> Show identity details with peers and rule tree
|
||||||
add --name <name> Manually attach a peer to an identity
|
add --name <n> Create a new identity
|
||||||
--peer <peer>
|
remove --name <n> Remove an identity
|
||||||
remove --name <name> Remove identity and all associated peers
|
migrate Migrate peers to identities
|
||||||
migrate [--dry-run] Create identities from existing peer names
|
|
||||||
|
|
||||||
rule assign --name <name> Assign a rule to an identity
|
rule assign --name <n> --rule <r> Assign rule to identity
|
||||||
--rule <rule>
|
Blocked if peer already has rule directly
|
||||||
rule unassign --name <name> Remove rule from an identity
|
[--migrate] Remove conflicting direct peer rules first
|
||||||
rule show --name <name> Show current identity rule
|
rule unassign --name <n> --rule <r> Remove rule from identity
|
||||||
|
rule unassign --name <n> --all Remove all rules from identity
|
||||||
|
|
||||||
options --name <name> Set identity options
|
options --name <n> --strict-rule <bool> Set strict rule mode
|
||||||
[--policy <policy>]
|
options --name <n> --auto-apply <bool> Set auto apply
|
||||||
[--set-strict-rule | --unset-strict-rule]
|
|
||||||
[--set-auto-apply | --unset-auto-apply]
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
wgctl identity list
|
wgctl identity list
|
||||||
wgctl identity show --name nuno
|
wgctl identity show --name nuno
|
||||||
|
wgctl identity add --name alice
|
||||||
wgctl identity rule assign --name nuno --rule admin
|
wgctl identity rule assign --name nuno --rule admin
|
||||||
wgctl identity rule unassign --name nuno
|
wgctl identity rule assign --name nuno --rule user --migrate
|
||||||
wgctl identity options --name guests-identity --policy guest
|
wgctl identity rule unassign --name nuno --rule admin
|
||||||
wgctl identity options --name nuno --set-strict-rule
|
wgctl identity options --name nuno --strict-rule true
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,6 +78,11 @@ function cmd::identity::run() {
|
||||||
local subcmd="${1:-list}"
|
local subcmd="${1:-list}"
|
||||||
shift || true
|
shift || true
|
||||||
|
|
||||||
|
if command::json && [[ "$subcmd" == "list" ]]; then
|
||||||
|
cmd::identity::_output_json
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
case "$subcmd" in
|
case "$subcmd" in
|
||||||
list) cmd::identity::_list "$@" ;;
|
list) cmd::identity::_list "$@" ;;
|
||||||
show) cmd::identity::_show "$@" ;;
|
show) cmd::identity::_show "$@" ;;
|
||||||
|
|
@ -100,13 +105,18 @@ function cmd::identity::run() {
|
||||||
|
|
||||||
function cmd::identity::_list() {
|
function cmd::identity::_list() {
|
||||||
local data
|
local data
|
||||||
data=$(identity::list_data)
|
data=$(identity::list_data | ui::sort_rows 1)
|
||||||
|
|
||||||
if [[ -z "$data" ]]; then
|
if [[ -z "$data" ]]; then
|
||||||
log::info "No identities found. Run 'wgctl identity migrate' to create from existing peers."
|
log::info "No identities found. Run 'wgctl identity migrate' to create from existing peers."
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if display::is_table "identity_list"; then
|
||||||
|
cmd::identity::_render_table "$data"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
while IFS='|' read -r name peer_count types rules policy; do
|
while IFS='|' read -r name peer_count types rules policy; do
|
||||||
local rules_display
|
local rules_display
|
||||||
|
|
@ -140,7 +150,7 @@ function cmd::identity::_show() {
|
||||||
data=$(identity::show_data "$name")
|
data=$(identity::show_data "$name")
|
||||||
peer_count=$(echo "$data" | grep '^peer_count|' | cut -d'|' -f2)
|
peer_count=$(echo "$data" | grep '^peer_count|' | cut -d'|' -f2)
|
||||||
|
|
||||||
# Precompute handshakes once for all peers in this identity
|
# Precompute handshakes once for all peers
|
||||||
declare -A _id_handshakes=()
|
declare -A _id_handshakes=()
|
||||||
while IFS=$'\t' read -r pk ts; do
|
while IFS=$'\t' read -r pk ts; do
|
||||||
[[ -n "$pk" ]] && _id_handshakes["$pk"]="$ts"
|
[[ -n "$pk" ]] && _id_handshakes["$pk"]="$ts"
|
||||||
|
|
@ -168,9 +178,32 @@ function cmd::identity::_show() {
|
||||||
esac
|
esac
|
||||||
done <<< "$data"
|
done <<< "$data"
|
||||||
|
|
||||||
|
# Rules tree
|
||||||
|
local identity_rules
|
||||||
|
identity_rules=$(identity::rules "$name")
|
||||||
|
if [[ -n "$identity_rules" ]]; then
|
||||||
|
printf "\n \033[2m── Rules \033[0m%s\n\n" \
|
||||||
|
"$(printf '\033[2m─%.0s' {1..38})"
|
||||||
|
ui::rule::identity_block "$name" "$strict" --no-header
|
||||||
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cmd::identity::_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() {
|
function cmd::identity::_device_status() {
|
||||||
local peer_name="${1:-}"
|
local peer_name="${1:-}"
|
||||||
|
|
@ -350,11 +383,12 @@ function cmd::identity::_rule() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function cmd::identity::_rule_assign() {
|
function cmd::identity::_rule_assign() {
|
||||||
local name="" rule=""
|
local name="" rule="" migrate=false
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--name) name="$2"; shift 2 ;;
|
--name) name="$2"; shift 2 ;;
|
||||||
--rule) rule="$2"; shift 2 ;;
|
--rule) rule="$2"; shift 2 ;;
|
||||||
|
--migrate) migrate=true; shift ;;
|
||||||
*) log::error "Unknown flag: $1"; return 1 ;;
|
*) log::error "Unknown flag: $1"; return 1 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
@ -364,7 +398,30 @@ function cmd::identity::_rule_assign() {
|
||||||
identity::require_exists "$name" || return 1
|
identity::require_exists "$name" || return 1
|
||||||
rule::exists "$rule" || { log::error "Rule not found: ${rule}"; return 1; }
|
rule::exists "$rule" || { log::error "Rule not found: ${rule}"; return 1; }
|
||||||
|
|
||||||
local exit_code
|
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
|
||||||
identity::add_rule "$name" "$rule" || exit_code=$?
|
identity::add_rule "$name" "$rule" || exit_code=$?
|
||||||
|
|
||||||
if [[ $exit_code -eq 2 ]]; then
|
if [[ $exit_code -eq 2 ]]; then
|
||||||
|
|
@ -534,3 +591,40 @@ function cmd::identity::_options() {
|
||||||
cmd::identity::_rule_show --name "$name"
|
cmd::identity::_rule_show --name "$name"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cmd::identity::_output_json() {
|
||||||
|
local data
|
||||||
|
data=$(identity::list_data 2>/dev/null)
|
||||||
|
|
||||||
|
local -a identities=()
|
||||||
|
while IFS='|' read -r name peer_count types rules policy; do
|
||||||
|
[[ -z "$name" ]] && continue
|
||||||
|
|
||||||
|
# Build rules array
|
||||||
|
local rules_json="[]"
|
||||||
|
if [[ -n "$rules" ]]; then
|
||||||
|
local rules_array
|
||||||
|
rules_array=$(echo "$rules" | tr ',' '\n' | \
|
||||||
|
while IFS= read -r r; do [[ -n "$r" ]] && printf '"%s",' "$r"; done | sed 's/,$//')
|
||||||
|
rules_json="[${rules_array}]"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build types array (was comma-separated string)
|
||||||
|
local types_json="[]"
|
||||||
|
if [[ -n "$types" ]]; then
|
||||||
|
local types_array
|
||||||
|
types_array=$(echo "$types" | tr ',' '\n' | \
|
||||||
|
while IFS= read -r t; do [[ -n "$t" ]] && printf '"%s",' "$t"; done | sed 's/,$//')
|
||||||
|
types_json="[${types_array}]"
|
||||||
|
fi
|
||||||
|
|
||||||
|
identities+=("$(printf '{"name":"%s","peer_count":%s,"types":%s,"rules":%s,"policy":"%s"}' \
|
||||||
|
"$name" "$peer_count" "$types_json" "$rules_json" "$policy")")
|
||||||
|
done <<< "$data"
|
||||||
|
|
||||||
|
local count=${#identities[@]}
|
||||||
|
local array
|
||||||
|
array=$(printf '%s\n' "${identities[@]:-}" | paste -sd ',' -)
|
||||||
|
printf '{"identities":[%s]}' "${array:-}" | json::envelope "identity list" "$count"
|
||||||
|
}
|
||||||
|
|
||||||
288
commands/import.command.sh
Normal file
288
commands/import.command.sh
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
#!/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,6 +5,8 @@ function cmd::inspect::on_load() {
|
||||||
flag::register --type
|
flag::register --type
|
||||||
flag::register --config
|
flag::register --config
|
||||||
flag::register --qr
|
flag::register --qr
|
||||||
|
|
||||||
|
command::mixin json_output
|
||||||
}
|
}
|
||||||
|
|
||||||
function cmd::inspect::help() {
|
function cmd::inspect::help() {
|
||||||
|
|
@ -127,25 +129,6 @@ function cmd::inspect::_peer_info() {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# function cmd::inspect::_rule_info() {
|
|
||||||
# local name="${1:-}"
|
|
||||||
# local rule
|
|
||||||
# rule=$(peers::get_meta "$name" "rule")
|
|
||||||
# [[ -z "$rule" ]] && return 0
|
|
||||||
# rule::exists "$rule" || return 0
|
|
||||||
|
|
||||||
# cmd::inspect::_section "Rule: ${rule}"
|
|
||||||
|
|
||||||
# if ui::rule::tree "$rule"; then
|
|
||||||
# # printf "\n"
|
|
||||||
# : # no-op
|
|
||||||
# else
|
|
||||||
# # No inheritance — flat view
|
|
||||||
# rule::render_flat "$rule"
|
|
||||||
# fi
|
|
||||||
# return 0
|
|
||||||
# }
|
|
||||||
|
|
||||||
function cmd::inspect::_rule_separator() {
|
function cmd::inspect::_rule_separator() {
|
||||||
local line_width=20
|
local line_width=20
|
||||||
local total=$INSPECT_WIDTH
|
local total=$INSPECT_WIDTH
|
||||||
|
|
@ -348,6 +331,11 @@ function cmd::inspect::run() {
|
||||||
|
|
||||||
name=$(peers::resolve_and_require "$name" "$type") || return 1
|
name=$(peers::resolve_and_require "$name" "$type") || return 1
|
||||||
|
|
||||||
|
if command::json; then
|
||||||
|
cmd::inspect::_output_json "$name"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
load_command list
|
load_command list
|
||||||
|
|
||||||
log::section "Inspect: ${name}"
|
log::section "Inspect: ${name}"
|
||||||
|
|
@ -371,3 +359,70 @@ function cmd::inspect::run() {
|
||||||
|
|
||||||
printf "\n"
|
printf "\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# JSON (API consumption)
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
function cmd::inspect::_output_json() {
|
||||||
|
local name="${1:-}"
|
||||||
|
|
||||||
|
local ip type rule allowed_ips public_key is_blocked status
|
||||||
|
ip=$(peers::get_ip "$name")
|
||||||
|
type=$(peers::get_type "$name")
|
||||||
|
rule=$(peers::get_meta "$name" "rule")
|
||||||
|
allowed_ips=$(grep "^AllowedIPs" "$(ctx::clients)/${name}.conf" 2>/dev/null | \
|
||||||
|
awk '{print $3}' | tr -d ',')
|
||||||
|
public_key=$(keys::public "$name" 2>/dev/null || echo "")
|
||||||
|
peers::is_blocked "$name" && is_blocked="true" || is_blocked="false"
|
||||||
|
|
||||||
|
# Handshake status
|
||||||
|
local handshake_ts=0
|
||||||
|
handshake_ts=$(wg show "$(config::interface)" latest-handshakes 2>/dev/null | \
|
||||||
|
grep "$public_key" | awk '{print $2}') || handshake_ts=0
|
||||||
|
|
||||||
|
local last_ts
|
||||||
|
last_ts=$(peers::get_meta "$name" "last_ts" 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
local conn_state
|
||||||
|
conn_state=$(peers::connection_state "$is_blocked" "false" \
|
||||||
|
"${handshake_ts:-0}" "${last_ts:-}" | cut -d'|' -f1)
|
||||||
|
|
||||||
|
# Groups
|
||||||
|
local groups_json="[]"
|
||||||
|
local -a group_list=()
|
||||||
|
while IFS= read -r g; do
|
||||||
|
[[ -n "$g" ]] && group_list+=("\"$g\"")
|
||||||
|
done < <(json::peer_groups "$(ctx::groups)" "$name" 2>/dev/null)
|
||||||
|
[[ ${#group_list[@]} -gt 0 ]] && \
|
||||||
|
groups_json="[$(printf '%s,' "${group_list[@]}" | sed 's/,$//')]"
|
||||||
|
|
||||||
|
# Identity
|
||||||
|
local identity
|
||||||
|
identity=$(peers::get_identity "$name" 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
# Rule extends
|
||||||
|
local rule_extends="[]"
|
||||||
|
if [[ -n "$rule" ]]; then
|
||||||
|
local rule_file
|
||||||
|
rule_file=$(json::find_rule_file "$(ctx::rules)" "$rule" 2>/dev/null)
|
||||||
|
if [[ -n "$rule_file" ]]; then
|
||||||
|
local -a extends=()
|
||||||
|
while IFS= read -r ext; do
|
||||||
|
[[ -n "$ext" ]] && extends+=("\"$ext\"")
|
||||||
|
done < <(json::get "$rule_file" "extends" 2>/dev/null)
|
||||||
|
[[ ${#extends[@]} -gt 0 ]] && \
|
||||||
|
rule_extends="[$(printf '%s,' "${extends[@]}" | sed 's/,$//')]"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
local data
|
||||||
|
data=$(printf '{"name":"%s","ip":"%s","type":"%s","rule":"%s","rule_extends":%s,"allowed_ips":"%s","public_key":"%s","is_blocked":%s,"status":"%s","identity":"%s","groups":%s}' \
|
||||||
|
"$name" "$ip" "$type" \
|
||||||
|
"${rule:-}" "$rule_extends" \
|
||||||
|
"${allowed_ips:-}" "$public_key" \
|
||||||
|
"$is_blocked" "$conn_state" \
|
||||||
|
"${identity:-}" "$groups_json")
|
||||||
|
|
||||||
|
printf '%s' "$data" | json::envelope "inspect" "1"
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
function cmd::list::on_load() {
|
function cmd::list::on_load() {
|
||||||
|
command::mixin json_output
|
||||||
|
|
||||||
load_module identity
|
load_module identity
|
||||||
load_module ui
|
load_module ui
|
||||||
|
|
||||||
|
|
@ -19,6 +21,9 @@ function cmd::list::on_load() {
|
||||||
flag::register --allowed
|
flag::register --allowed
|
||||||
flag::register --detailed
|
flag::register --detailed
|
||||||
flag::register --name
|
flag::register --name
|
||||||
|
|
||||||
|
# Mutually exclusive filter groups
|
||||||
|
flag::exclusive --online --offline --blocked --restricted --allowed
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
@ -209,6 +214,11 @@ function cmd::list::run() {
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if command::json; then
|
||||||
|
cmd::list::_output_json "$collected_rows"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
if $detailed; then
|
if $detailed; then
|
||||||
cmd::list::_render_detailed "$collected_rows"
|
cmd::list::_render_detailed "$collected_rows"
|
||||||
cmd::list::_render_summary_from_rows "$collected_rows"
|
cmd::list::_render_summary_from_rows "$collected_rows"
|
||||||
|
|
@ -220,8 +230,11 @@ function cmd::list::run() {
|
||||||
|
|
||||||
case "$style" in
|
case "$style" in
|
||||||
table) cmd::list::_render_table ;;
|
table) cmd::list::_render_table ;;
|
||||||
compact) cmd::list::_render_compact "$collected_rows" ;;
|
compact) display::render "peer_list" "$collected_rows" \
|
||||||
*) cmd::list::_render_compact "$collected_rows" ;;
|
"cmd::list::_render_compact" "cmd::list::_render_table" ;;
|
||||||
|
|
||||||
|
*) display::render "peer_list" "$collected_rows" \
|
||||||
|
"cmd::list::_render_compact" "cmd::list::_render_table" ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -340,19 +353,63 @@ function cmd::list::_render_compact() {
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
function cmd::list::_render_table() {
|
function cmd::list::_render_table() {
|
||||||
declare -A rule_counts=() group_counts=()
|
local rows="${1:-}"
|
||||||
_list_header_printed=false
|
[[ -z "$rows" ]] && log::wg_warning "No results found" && return 0
|
||||||
|
|
||||||
cmd::list::_iter_confs_table
|
# 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 ))
|
||||||
|
|
||||||
if [[ "$_list_header_printed" == "true" ]]; then
|
# Header
|
||||||
cmd::list::_render_footer $has_groups
|
printf "\n %-${w_name}s %-${w_ip}s %-${w_type}s %-${w_rule}s %-${w_group}s %-${w_status}s %s\n" \
|
||||||
local group_summary=""
|
"NAME" "IP" "TYPE" "RULE" "GROUP" "STATUS" "LAST SEEN"
|
||||||
cmd::list::_build_group_summary
|
printf " %s\n" "$(printf '─%.0s' {1..115})"
|
||||||
printf "\n Showing peers\n\n"
|
|
||||||
|
# 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"
|
||||||
else
|
else
|
||||||
log::wg_warning "No results found"
|
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"
|
||||||
fi
|
fi
|
||||||
|
done <<< "$rows"
|
||||||
|
|
||||||
|
printf " %s\n" "$(printf '─%.0s' {1..115})"
|
||||||
|
cmd::list::_render_summary_from_rows "$rows"
|
||||||
}
|
}
|
||||||
|
|
||||||
function cmd::list::_iter_confs_table() {
|
function cmd::list::_iter_confs_table() {
|
||||||
|
|
@ -419,13 +476,10 @@ function cmd::list::_render_detailed() {
|
||||||
ui::peer::list_identity_header "$id_name"
|
ui::peer::list_identity_header "$id_name"
|
||||||
while IFS='|' read -r name ip type rule group status last_seen is_blocked is_restricted; do
|
while IFS='|' read -r name ip type rule group status last_seen is_blocked is_restricted; do
|
||||||
[[ -z "$name" ]] && continue
|
[[ -z "$name" ]] && continue
|
||||||
local subnet
|
|
||||||
subnet=$(peers::get_meta "$name" "subnet" 2>/dev/null)
|
|
||||||
if [[ -z "$subnet" ]]; then
|
|
||||||
local peer_type="${p_types[$name]:-}"
|
local peer_type="${p_types[$name]:-}"
|
||||||
[[ -n "$peer_type" ]] && subnet="$peer_type"
|
subnet=$(peers::get_display_subnet "$name" "$peer_type")
|
||||||
fi
|
|
||||||
[[ -z "$subnet" ]] && subnet="-"
|
|
||||||
ui::peer::list_row_detailed \
|
ui::peer::list_row_detailed \
|
||||||
"$w_name" "$w_ip" "$w_type" "$w_rule" "$w_group" "$w_subnet" \
|
"$w_name" "$w_ip" "$w_type" "$w_rule" "$w_group" "$w_subnet" \
|
||||||
"$name" "$ip" "$type" "$rule" "$group" "$subnet" \
|
"$name" "$ip" "$type" "$rule" "$group" "$subnet" \
|
||||||
|
|
@ -666,3 +720,31 @@ function cmd::list::_show_client_safe() {
|
||||||
local name="$1"
|
local name="$1"
|
||||||
cmd::list::show_client "$name" || true
|
cmd::list::show_client "$name" || true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# JSON (API consumption)
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
function cmd::list::_output_json() {
|
||||||
|
local rows="${1:-}"
|
||||||
|
local -a peers=()
|
||||||
|
|
||||||
|
while IFS='|' read -r name ip type rule group status last_seen is_blocked is_restricted; do
|
||||||
|
[[ -z "$name" ]] && continue
|
||||||
|
|
||||||
|
# Escape strings for JSON
|
||||||
|
local peer_json
|
||||||
|
peer_json=$(printf '{"name":"%s","ip":"%s","type":"%s","rule":"%s","group":"%s","status":"%s","last_seen":"%s","is_blocked":%s,"is_restricted":%s}' \
|
||||||
|
"$name" "$ip" "$type" \
|
||||||
|
"${rule}" "${group}" \
|
||||||
|
"$status" "$last_seen" \
|
||||||
|
"$is_blocked" "$is_restricted")
|
||||||
|
peers+=("$peer_json")
|
||||||
|
done <<< "$rows"
|
||||||
|
|
||||||
|
local count=${#peers[@]}
|
||||||
|
local array
|
||||||
|
# Join array with commas
|
||||||
|
array=$(printf '%s\n' "${peers[@]}" | paste -sd ',' -)
|
||||||
|
printf '{"peers":[%s]}' "$array" | json::envelope "list" "$count"
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,14 @@ function cmd::logs::on_load() {
|
||||||
flag::register --force
|
flag::register --force
|
||||||
flag::register --days
|
flag::register --days
|
||||||
flag::register --raw
|
flag::register --raw
|
||||||
|
flag::register --detailed
|
||||||
|
flag::register --service
|
||||||
|
flag::register --event
|
||||||
|
flag::register --ascending
|
||||||
|
flag::register --descending
|
||||||
|
flag::register --resolved
|
||||||
|
|
||||||
|
flag::exclusive --ascending --descending
|
||||||
}
|
}
|
||||||
|
|
||||||
function cmd::logs::help() {
|
function cmd::logs::help() {
|
||||||
|
|
@ -27,6 +35,7 @@ Show or manage WireGuard and firewall activity logs.
|
||||||
|
|
||||||
Subcommands:
|
Subcommands:
|
||||||
show (default) Show activity logs
|
show (default) Show activity logs
|
||||||
|
clean Remove keepalive handshakes (deduplicate)
|
||||||
remove, rm Remove log entries
|
remove, rm Remove log entries
|
||||||
rotate Remove entries older than N days
|
rotate Remove entries older than N days
|
||||||
|
|
||||||
|
|
@ -34,11 +43,23 @@ Options for show:
|
||||||
--name <name> Filter by client name
|
--name <name> Filter by client name
|
||||||
--type <type> Filter by device type
|
--type <type> Filter by device type
|
||||||
--limit <n> Max results per source (default: 50)
|
--limit <n> Max results per source (default: 50)
|
||||||
|
--since <time> Show events since: 2h, 7d, 23/05, 23/05/2026, 2026-05-23
|
||||||
|
--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
|
--fw Show only firewall drops
|
||||||
--wg Show only WireGuard events
|
--wg Show only WireGuard events
|
||||||
--merged Show all events chronologically interleaved
|
--merged Show all events chronologically interleaved
|
||||||
--follow, -f Follow logs in real time (alias: wgctl watch)
|
--detailed Show all deduplicated events (bypass hourly collapse)
|
||||||
|
--follow, -f Follow logs in real time
|
||||||
--raw Show raw IPs without service annotation
|
--raw Show raw IPs without service annotation
|
||||||
|
--resolved Show only resolved names, hide raw IPs
|
||||||
|
--ascending Sort oldest first
|
||||||
|
--descending Sort newest first (default)
|
||||||
|
|
||||||
|
Options for clean:
|
||||||
|
--wg Clean only WireGuard events (default: handshakes only)
|
||||||
|
--force Skip confirmation
|
||||||
|
|
||||||
Options for remove:
|
Options for remove:
|
||||||
--name <name> Remove entries for specific peer
|
--name <name> Remove entries for specific peer
|
||||||
|
|
@ -54,10 +75,20 @@ Options for rotate:
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
wgctl logs
|
wgctl logs
|
||||||
wgctl logs --name phone-nuno
|
wgctl logs --since 2h
|
||||||
wgctl logs --fw --limit 100
|
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 --merged
|
||||||
wgctl logs --follow
|
wgctl logs --follow
|
||||||
|
wgctl logs clean
|
||||||
|
wgctl logs clean --force
|
||||||
wgctl logs remove --name phone-nuno
|
wgctl logs remove --name phone-nuno
|
||||||
wgctl logs rotate --days 30
|
wgctl logs rotate --days 30
|
||||||
EOF
|
EOF
|
||||||
|
|
@ -75,6 +106,7 @@ function cmd::logs::run() {
|
||||||
show) cmd::logs::show "$@" ;;
|
show) cmd::logs::show "$@" ;;
|
||||||
remove|rm|del) cmd::logs::remove "$@" ;;
|
remove|rm|del) cmd::logs::remove "$@" ;;
|
||||||
rotate) cmd::logs::rotate "$@" ;;
|
rotate) cmd::logs::rotate "$@" ;;
|
||||||
|
clean) cmd::logs::clean "$@" ;;
|
||||||
help) cmd::logs::help ;;
|
help) cmd::logs::help ;;
|
||||||
*)
|
*)
|
||||||
log::error "Unknown subcommand: '${subcmd}'"
|
log::error "Unknown subcommand: '${subcmd}'"
|
||||||
|
|
@ -85,19 +117,31 @@ function cmd::logs::run() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function cmd::logs::show() {
|
function cmd::logs::show() {
|
||||||
local name="" type="" limit=50
|
local name="" type="" limit=50 since=""
|
||||||
local fw_only=false wg_only=false follow=false merged=false raw=false
|
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"
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--name) name="$2"; shift 2 ;;
|
--name) name="$2"; shift 2 ;;
|
||||||
--type) type="$2"; shift 2 ;;
|
--type) type="$2"; shift 2 ;;
|
||||||
--limit) limit="$2"; shift 2 ;;
|
--limit) limit="$2"; shift 2 ;;
|
||||||
|
--since) since="$2"; shift 2 ;;
|
||||||
|
--service) filter_service="$2"; shift 2 ;;
|
||||||
|
--event) filter_event="$2"; shift 2 ;;
|
||||||
--fw) fw_only=true; shift ;;
|
--fw) fw_only=true; shift ;;
|
||||||
--wg) wg_only=true; shift ;;
|
--wg) wg_only=true; shift ;;
|
||||||
--merged) merged=true; shift ;;
|
--merged) merged=true; shift ;;
|
||||||
--follow|-f) follow=true; shift ;;
|
--follow|-f) follow=true; shift ;;
|
||||||
--raw) raw=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 ;;
|
--help) cmd::logs::help; return ;;
|
||||||
*)
|
*)
|
||||||
log::error "Unknown flag: $1"
|
log::error "Unknown flag: $1"
|
||||||
|
|
@ -106,6 +150,9 @@ function cmd::logs::show() {
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
local collapse=1
|
||||||
|
$detailed && collapse=0
|
||||||
|
|
||||||
if [[ -n "$name" && -n "$type" ]]; then
|
if [[ -n "$name" && -n "$type" ]]; then
|
||||||
name=$(peers::resolve_and_require "$name" "$type") || return 1
|
name=$(peers::resolve_and_require "$name" "$type") || return 1
|
||||||
fi
|
fi
|
||||||
|
|
@ -116,6 +163,11 @@ function cmd::logs::show() {
|
||||||
[[ -z "$filter_ip" ]] && log::error "Could not find IP for: $name" && return 1
|
[[ -z "$filter_ip" ]] && log::error "Could not find IP for: $name" && return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if $fw_only && $wg_only; then
|
||||||
|
fw_only=false
|
||||||
|
wg_only=false
|
||||||
|
fi
|
||||||
|
|
||||||
if $follow; then
|
if $follow; then
|
||||||
cmd::logs::follow "$filter_ip" "$name" "$type" "$fw_only" "$wg_only"
|
cmd::logs::follow "$filter_ip" "$name" "$type" "$fw_only" "$wg_only"
|
||||||
return
|
return
|
||||||
|
|
@ -124,95 +176,257 @@ function cmd::logs::show() {
|
||||||
local net_file=""
|
local net_file=""
|
||||||
$raw || net_file="$(ctx::net)"
|
$raw || net_file="$(ctx::net)"
|
||||||
|
|
||||||
log::section "WireGuard Activity Log"
|
# Parse --service into dest_ip and dest_port
|
||||||
printf "\n"
|
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
|
if $merged; then
|
||||||
cmd::logs::show_merged "$filter_ip" "$name" "$type" "$limit" "$net_file"
|
log::section "WireGuard Activity Log"
|
||||||
|
printf "\n"
|
||||||
|
cmd::logs::show_merged "$filter_ip" "$name" "$type" "$limit" "$net_file" "$since"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
$wg_only || cmd::logs::show_fw_events "$filter_ip" "$name" "$type" "$limit" "$net_file"
|
# Collect output — only show header if there's data
|
||||||
$fw_only || cmd::logs::show_wg_events "$filter_ip" "$name" "$type" "$limit"
|
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() {
|
function cmd::logs::show_fw_events() {
|
||||||
local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" \
|
local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" \
|
||||||
limit="${4:-50}" net_file="${5:-}"
|
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
|
[[ ! -f "$FW_EVENTS_LOG" ]] && return 0
|
||||||
|
|
||||||
local data
|
local data
|
||||||
data=$(json::fw_events "$FW_EVENTS_LOG" "$filter_ip" "$filter_type" \
|
data=$(json::fw_events \
|
||||||
"$(ctx::clients)" "${net_file:-}" "$limit" 2>/dev/null)
|
"$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
|
[[ -z "$data" ]] && return 0
|
||||||
|
|
||||||
# Measure column widths
|
# ── Collect unique endpoints for batch resolution ──
|
||||||
local w_client=16 w_dest=20
|
local -a ep_list=()
|
||||||
while IFS='|' read -r ts client dest_ip dest_port proto svc count; do
|
while IFS='|' read -r ts client dest_ip dest_port proto svc count src_endpoint; do
|
||||||
[[ -z "$ts" ]] && continue
|
[[ -z "$ts" || -z "$src_endpoint" ]] && continue
|
||||||
(( ${#client} > w_client )) && w_client=${#client}
|
ep_list+=("$src_endpoint")
|
||||||
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}
|
|
||||||
done <<< "$data"
|
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_client += 2 ))
|
||||||
(( w_dest += 2 ))
|
(( w_dest += 2 ))
|
||||||
|
[[ "$w_endpoint" -gt 0 ]] && (( w_endpoint += 2 ))
|
||||||
|
|
||||||
|
# ── Pass 2: render ──
|
||||||
ui::logs::fw_section_header
|
ui::logs::fw_section_header
|
||||||
while IFS='|' read -r ts client dest_ip dest_port proto svc count; do
|
while IFS='|' read -r ts client dest_ip dest_port proto svc count src_endpoint src_resolved; do
|
||||||
[[ -z "$ts" ]] && continue
|
[[ -z "$ts" ]] && continue
|
||||||
ui::logs::fw_row "$ts" "$client" "$dest_ip" "$dest_port" \
|
ui::logs::fw_row "$ts" "$client" "$dest_ip" "$dest_port" \
|
||||||
"$proto" "$svc" "$count" "$w_client" "$w_dest"
|
"$proto" "$svc" "$count" "$w_client" "$w_dest" \
|
||||||
done <<< "$data"
|
"$src_endpoint" "$src_resolved" "$w_endpoint" "$resolved_only"
|
||||||
|
done <<< "$resolved_data"
|
||||||
printf "\n"
|
printf "\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
function cmd::logs::show_wg_events() {
|
function cmd::logs::show_wg_events() {
|
||||||
local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" limit="${4:-50}"
|
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
|
[[ ! -f "$WG_EVENTS_LOG" ]] && return 0
|
||||||
|
|
||||||
local data
|
local data
|
||||||
data=$(json::wg_events "$WG_EVENTS_LOG" "$filter_name" "$filter_type" "$limit" 2>/dev/null)
|
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
|
[[ -z "$data" ]] && return 0
|
||||||
|
|
||||||
# Measure column widths
|
# ── 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 w_client=16 w_endpoint=16
|
||||||
while IFS='|' read -r ts client endpoint event count; do
|
local resolved_data=""
|
||||||
|
|
||||||
|
while IFS='|' read -r ts client endpoint event count gap_seconds; do
|
||||||
[[ -z "$ts" ]] && continue
|
[[ -z "$ts" ]] && continue
|
||||||
(( ${#client} > w_client )) && w_client=${#client}
|
(( ${#client} > w_client )) && w_client=${#client}
|
||||||
(( ${#endpoint} > w_endpoint )) && w_endpoint=${#endpoint}
|
|
||||||
|
local resolved=""
|
||||||
|
if [[ -n "$endpoint" ]]; then
|
||||||
|
resolved="${resolve_cache[$endpoint]:-}"
|
||||||
|
[[ "$resolved" == "$endpoint" ]] && resolved=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
local ep_raw="${endpoint:--}"
|
||||||
|
local ep_measure_len
|
||||||
|
if $resolved_only; then
|
||||||
|
local ep_display="${resolved:-$endpoint}"
|
||||||
|
[[ -z "$ep_display" ]] && ep_display="-"
|
||||||
|
ep_measure_len=${#ep_display}
|
||||||
|
else
|
||||||
|
ep_measure_len=${#ep_raw}
|
||||||
|
[[ -n "$resolved" && -n "$endpoint" ]] && \
|
||||||
|
ep_measure_len=$(( ${#endpoint} + 4 + ${#resolved} ))
|
||||||
|
fi
|
||||||
|
(( ep_measure_len > w_endpoint )) && w_endpoint=$ep_measure_len
|
||||||
|
|
||||||
|
resolved_data+="${ts}|${client}|${endpoint}|${event}|${count}|${gap_seconds}|${resolved}"$'\n'
|
||||||
done <<< "$data"
|
done <<< "$data"
|
||||||
|
|
||||||
(( w_client += 2 ))
|
(( w_client += 2 ))
|
||||||
(( w_endpoint += 2 ))
|
(( w_endpoint += 2 ))
|
||||||
|
|
||||||
|
# ── Render ──
|
||||||
ui::logs::wg_section_header
|
ui::logs::wg_section_header
|
||||||
while IFS='|' read -r ts client endpoint event count; do
|
while IFS='|' read -r ts client endpoint event count gap_seconds resolved; do
|
||||||
[[ -z "$ts" ]] && continue
|
[[ -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" \
|
ui::logs::wg_row "$ts" "$client" "$endpoint" "$event" \
|
||||||
"$count" "$w_client" "$w_endpoint"
|
"$count" "$w_client" "$w_endpoint" "$gap_seconds" "$resolved"
|
||||||
done <<< "$data"
|
fi
|
||||||
|
done <<< "$resolved_data"
|
||||||
printf "\n"
|
printf "\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
function cmd::logs::show_merged() {
|
function cmd::logs::show_merged() {
|
||||||
local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" \
|
local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" \
|
||||||
limit="${4:-50}" net_file="${5:-}"
|
limit="${4:-50}" net_file="${5:-}" since="${6:-}"
|
||||||
|
|
||||||
local fw_data wg_data
|
local fw_data wg_data
|
||||||
fw_data=$(json::fw_events "$FW_EVENTS_LOG" "$filter_ip" "$filter_type" \
|
fw_data=$(json::fw_events \
|
||||||
"$(ctx::clients)" "${net_file:-}" "$limit" 2>/dev/null)
|
"$FW_EVENTS_LOG" "$filter_ip" "$filter_type" \
|
||||||
wg_data=$(json::wg_events "$WG_EVENTS_LOG" "$filter_name" "$filter_type" \
|
"$(ctx::clients)" "${net_file:-}" \
|
||||||
"$limit" 2>/dev/null)
|
"$limit" "1" "$since" "" "" \
|
||||||
|
2>/dev/null)
|
||||||
|
wg_data=$(json::wg_events \
|
||||||
|
"$WG_EVENTS_LOG" "$filter_name" "$filter_type" \
|
||||||
|
"$limit" "1" "$since" "" \
|
||||||
|
2>/dev/null)
|
||||||
|
|
||||||
# Measure widths across both sources
|
|
||||||
local w_client=16 w_dest=20
|
local w_client=16 w_dest=20
|
||||||
while IFS='|' read -r ts client rest; do
|
while IFS='|' read -r ts client rest; do
|
||||||
[[ -z "$ts" ]] && continue
|
[[ -z "$ts" ]] && continue
|
||||||
|
|
@ -220,7 +434,6 @@ function cmd::logs::show_merged() {
|
||||||
done < <(echo "$fw_data"; echo "$wg_data")
|
done < <(echo "$fw_data"; echo "$wg_data")
|
||||||
(( w_client += 2 ))
|
(( w_client += 2 ))
|
||||||
|
|
||||||
# Tag and merge: prefix fw lines with "fw|", wg lines with "wg|"
|
|
||||||
local merged_data
|
local merged_data
|
||||||
merged_data=$(
|
merged_data=$(
|
||||||
while IFS='|' read -r ts client dest_ip dest_port proto svc count; do
|
while IFS='|' read -r ts client dest_ip dest_port proto svc count; do
|
||||||
|
|
@ -233,7 +446,6 @@ function cmd::logs::show_merged() {
|
||||||
done <<< "$wg_data"
|
done <<< "$wg_data"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sort by timestamp field 2
|
|
||||||
while IFS='|' read -r source ts rest; do
|
while IFS='|' read -r source ts rest; do
|
||||||
[[ -z "$source" ]] && continue
|
[[ -z "$source" ]] && continue
|
||||||
case "$source" in
|
case "$source" in
|
||||||
|
|
@ -278,14 +490,12 @@ function cmd::logs::follow() {
|
||||||
log::section "WireGuard Live Log (Ctrl+C to stop)"
|
log::section "WireGuard Live Log (Ctrl+C to stop)"
|
||||||
printf "\n"
|
printf "\n"
|
||||||
|
|
||||||
# Delegate to watch command
|
local restricted_only=false blocked_only=false
|
||||||
local watch_args=()
|
$fw_only && restricted_only=true
|
||||||
[[ -n "$filter_name" ]] && watch_args+=(--name "$filter_name")
|
$wg_only && blocked_only=true
|
||||||
[[ -n "$filter_type" ]] && watch_args+=(--type "$filter_type")
|
|
||||||
$fw_only && watch_args+=(--restricted)
|
|
||||||
$wg_only && watch_args+=(--blocked)
|
|
||||||
|
|
||||||
cmd::watch::run "${watch_args[@]}"
|
monitor::live "$filter_name" "$filter_type" "" \
|
||||||
|
"$blocked_only" "$restricted_only" "false" "false"
|
||||||
}
|
}
|
||||||
|
|
||||||
function cmd::logs::remove() {
|
function cmd::logs::remove() {
|
||||||
|
|
@ -391,3 +601,30 @@ function cmd::logs::rotate() {
|
||||||
|
|
||||||
log::wg_success "Rotated ${total} entries older than ${days} days (wg: ${removed_wg}, fw: ${removed_fw})"
|
log::wg_success "Rotated ${total} entries older than ${days} days (wg: ${removed_wg}, fw: ${removed_fw})"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cmd::logs::clean() {
|
||||||
|
local force=false wg_only=false
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--force) force=true; shift ;;
|
||||||
|
--wg) wg_only=true; shift ;;
|
||||||
|
--help) cmd::logs::help; return ;;
|
||||||
|
*) log::error "Unknown flag: $1"; return 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if ! $force; then
|
||||||
|
read -r -p "Remove keepalive handshakes from events.log? [y/N] " confirm
|
||||||
|
case "$confirm" in [yY]*) ;; *) log::info "Aborted"; return 0 ;; esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
local removed
|
||||||
|
removed=$(json::clean_handshakes "$WG_EVENTS_LOG" "${WG_HANDSHAKE_CHECK_TIME_SEC:-300}")
|
||||||
|
|
||||||
|
if [[ "$removed" -eq 0 ]]; then
|
||||||
|
log::wg_warning "No keepalive handshakes found to remove"
|
||||||
|
else
|
||||||
|
log::wg_success "Removed ${removed} keepalive handshake entries"
|
||||||
|
fi
|
||||||
|
}
|
||||||
34
commands/mixins/MIXIN_TEMPLATE.mixin.sh.template
Normal file
34
commands/mixins/MIXIN_TEMPLATE.mixin.sh.template
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
#!/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,6 +8,8 @@ function cmd::net::on_load() {
|
||||||
flag::register --tag
|
flag::register --tag
|
||||||
flag::register --detailed
|
flag::register --detailed
|
||||||
flag::register --force
|
flag::register --force
|
||||||
|
|
||||||
|
command::mixin json_output
|
||||||
}
|
}
|
||||||
|
|
||||||
function cmd::net::help() {
|
function cmd::net::help() {
|
||||||
|
|
@ -58,6 +60,12 @@ EOF
|
||||||
function cmd::net::run() {
|
function cmd::net::run() {
|
||||||
local subcmd="${1:-list}"
|
local subcmd="${1:-list}"
|
||||||
shift || true
|
shift || true
|
||||||
|
|
||||||
|
if command::json; then
|
||||||
|
cmd::net::_output_json
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
case "$subcmd" in
|
case "$subcmd" in
|
||||||
list) cmd::net::list "$@" ;;
|
list) cmd::net::list "$@" ;;
|
||||||
show) cmd::net::show "$@" ;;
|
show) cmd::net::show "$@" ;;
|
||||||
|
|
@ -95,53 +103,79 @@ function cmd::net::list() {
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log::section "Network Services"
|
# Collect filtered data and build ports display per service
|
||||||
printf "\n %-20s %-16s %-6s %s\n" "NAME" "IP" "PORTS" "DESCRIPTION"
|
local filtered_data=""
|
||||||
local divider
|
while IFS="|" read -r name ip desc tags port_count; do
|
||||||
divider=$(printf '─%.0s' {1..72})
|
|
||||||
printf " %s\n" "$divider"
|
|
||||||
|
|
||||||
local found=false
|
|
||||||
while IFS="|" read -r name ip desc tags ports; do
|
|
||||||
[[ -z "$name" ]] && continue
|
[[ -z "$name" ]] && continue
|
||||||
|
[[ -n "$filter_tag" && "$tags" != *"$filter_tag"* ]] && continue
|
||||||
|
|
||||||
# Tag filter
|
# Build ports display from json::net_show
|
||||||
if [[ -n "$filter_tag" ]]; then
|
local ports_display=""
|
||||||
[[ "$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
|
while IFS="|" read -r ptype pname pport pproto pdesc; do
|
||||||
[[ "$ptype" != "port" ]] && continue
|
[[ "$ptype" != "port" ]] && continue
|
||||||
has_ports=true
|
local port_str=":${pport}"
|
||||||
local ann
|
[[ -n "$pproto" && "$pproto" != "tcp" ]] && port_str="${port_str}/${pproto}"
|
||||||
ann=$(net::annotation "$ip" "$pport" "$pproto")
|
ports_display+="${port_str}, "
|
||||||
printf " \033[0;37m%-18s %s:%s%s\033[0m\n" \
|
|
||||||
"${pname}" "$pport" "$pproto" \
|
|
||||||
"${pdesc:+ # $pdesc}"
|
|
||||||
done < <(json::net_show "$net_file" "$name")
|
done < <(json::net_show "$net_file" "$name")
|
||||||
$has_ports && printf "\n" # newline after each service with ports
|
ports_display="${ports_display%, }"
|
||||||
fi
|
[[ -z "$ports_display" ]] && ports_display="-"
|
||||||
|
|
||||||
|
filtered_data+="${name}|${ip}|${desc}|${tags}|${ports_display}"$'\n'
|
||||||
done < <(json::net_list "$net_file")
|
done < <(json::net_list "$net_file")
|
||||||
|
|
||||||
if ! $found; then
|
[[ -z "$filtered_data" ]] && {
|
||||||
[[ -n "$filter_tag" ]] && \
|
[[ -n "$filter_tag" ]] && \
|
||||||
log::wg_warning "No services with tag: ${filter_tag}" || \
|
log::wg_warning "No services with tag: ${filter_tag}" || \
|
||||||
log::wg_warning "No services configured"
|
log::wg_warning "No services configured"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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
|
fi
|
||||||
|
|
||||||
printf "\n"
|
while IFS="|" read -r name ip desc tags ports; do
|
||||||
return 0
|
[[ -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"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
@ -165,26 +199,24 @@ function cmd::net::show() {
|
||||||
log::section "Service: ${name}"
|
log::section "Service: ${name}"
|
||||||
printf "\n"
|
printf "\n"
|
||||||
|
|
||||||
|
local has_ports=false
|
||||||
while IFS="|" read -r key val1 val2 val3 val4; do
|
while IFS="|" read -r key val1 val2 val3 val4; do
|
||||||
case "$key" in
|
case "$key" in
|
||||||
name) ui::row "Name" "$val1" ;;
|
name) ui::row "Name" "$val1" ;;
|
||||||
ip) ui::row "IP" "$val1" ;;
|
|
||||||
desc) ui::row "Description" "${val1:-—}" ;;
|
desc) ui::row "Description" "${val1:-—}" ;;
|
||||||
tags) ui::row "Tags" "${val1:-—}" ;;
|
tags) ui::row "Tags" "${val1:-—}" ;;
|
||||||
|
ip) ui::row "IP" "$val1" ;;
|
||||||
port)
|
port)
|
||||||
# val1=port_name val2=port val3=proto val4=desc
|
if ! $has_ports; then
|
||||||
local ann
|
printf " %-20s\n" "Ports:"
|
||||||
ann=$(net::annotation "$(json::net_resolve "$(ctx::net)" "$name")" \
|
has_ports=true
|
||||||
"$val2" "$val3" 2>/dev/null || true)
|
fi
|
||||||
printf " %-20s \033[0;36m%s\033[0m %s:%s%s\n" \
|
ui::net::show_port_row "$val1" "$val2" "$val3" "$val4"
|
||||||
"${val1}:" "" "$val2" "$val3" \
|
|
||||||
"${val4:+ # $val4}"
|
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done < <(json::net_show "$(ctx::net)" "$name")
|
done < <(json::net_show "$(ctx::net)" "$name")
|
||||||
|
|
||||||
printf "\n"
|
printf "\n"
|
||||||
return 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
@ -297,3 +329,30 @@ function cmd::net::rm() {
|
||||||
log::wg_success "Removed: ${name}"
|
log::wg_success "Removed: ${name}"
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cmd::net::_output_json() {
|
||||||
|
local net_file
|
||||||
|
net_file="$(ctx::net)"
|
||||||
|
local data
|
||||||
|
data=$(json::net_list "$net_file" 2>/dev/null)
|
||||||
|
|
||||||
|
local -a services=()
|
||||||
|
while IFS='|' read -r name ip desc tags port_count; do
|
||||||
|
[[ -z "$name" ]] && continue
|
||||||
|
# Build tags array
|
||||||
|
local tags_json="[]"
|
||||||
|
if [[ -n "$tags" ]]; then
|
||||||
|
local tags_array
|
||||||
|
tags_array=$(echo "$tags" | tr ',' '\n' | \
|
||||||
|
while IFS= read -r t; do [[ -n "$t" ]] && printf '"%s",' "$t"; done | sed 's/,$//')
|
||||||
|
tags_json="[${tags_array}]"
|
||||||
|
fi
|
||||||
|
services+=("$(printf '{"name":"%s","ip":"%s","desc":"%s","tags":%s,"port_count":%s}' \
|
||||||
|
"$name" "$ip" "$desc" "$tags_json" "$port_count")")
|
||||||
|
done <<< "$data"
|
||||||
|
|
||||||
|
local count=${#services[@]}
|
||||||
|
local array
|
||||||
|
array=$(printf '%s\n' "${services[@]:-}" | paste -sd ',' -)
|
||||||
|
printf '{"services":[%s]}' "${array:-}" | json::envelope "net list" "$count"
|
||||||
|
}
|
||||||
197
commands/peer.command.sh
Normal file
197
commands/peer.command.sh
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
#!/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,6 +27,8 @@ function cmd::policy::on_load() {
|
||||||
flag::register --desc
|
flag::register --desc
|
||||||
flag::register --field
|
flag::register --field
|
||||||
flag::register --value
|
flag::register --value
|
||||||
|
|
||||||
|
command::mixin json_output
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
@ -79,6 +81,11 @@ function cmd::policy::run() {
|
||||||
local subcmd="${1:-list}"
|
local subcmd="${1:-list}"
|
||||||
shift || true
|
shift || true
|
||||||
|
|
||||||
|
if command::json; then
|
||||||
|
cmd::policy::_output_json
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
case "$subcmd" in
|
case "$subcmd" in
|
||||||
list) cmd::policy::_list "$@" ;;
|
list) cmd::policy::_list "$@" ;;
|
||||||
show) cmd::policy::_show "$@" ;;
|
show) cmd::policy::_show "$@" ;;
|
||||||
|
|
@ -99,13 +106,18 @@ function cmd::policy::run() {
|
||||||
|
|
||||||
function cmd::policy::_list() {
|
function cmd::policy::_list() {
|
||||||
local data
|
local data
|
||||||
data=$(policy::list_data)
|
data=$(policy::list_data | ui::sort_rows 1)
|
||||||
|
|
||||||
if [[ -z "$data" ]]; then
|
if [[ -z "$data" ]]; then
|
||||||
log::info "No policies defined."
|
log::info "No policies defined."
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if display::is_table "policy_list"; then
|
||||||
|
cmd::policy::_render_table "$data"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
while IFS='|' read -r name tunnel default_rule strict auto desc; do
|
while IFS='|' read -r name tunnel default_rule strict auto desc; do
|
||||||
ui::policy::list_row "$name" "$default_rule" "$strict" "$auto"
|
ui::policy::list_row "$name" "$default_rule" "$strict" "$auto"
|
||||||
|
|
@ -113,6 +125,19 @@ function cmd::policy::_list() {
|
||||||
echo ""
|
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() {
|
function cmd::policy::_show() {
|
||||||
local name=""
|
local name=""
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
|
|
@ -245,3 +270,27 @@ function cmd::policy::_set() {
|
||||||
json::policy_set_field "$(ctx::policies)" "$name" "$field" "$value"
|
json::policy_set_field "$(ctx::policies)" "$name" "$field" "$value"
|
||||||
log::ok "Policy '${name}': ${field} = ${value}"
|
log::ok "Policy '${name}': ${field} = ${value}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cmd::policy::_output_json() {
|
||||||
|
local data
|
||||||
|
data=$(policy::list_data 2>/dev/null)
|
||||||
|
|
||||||
|
local -a policies=()
|
||||||
|
while IFS='|' read -r name tunnel_mode default_rule strict_rule auto_apply desc; do
|
||||||
|
[[ -z "$name" ]] && continue
|
||||||
|
|
||||||
|
local strict_json="false"
|
||||||
|
[[ "$strict_rule" == "true" ]] && strict_json="true"
|
||||||
|
local auto_json="true"
|
||||||
|
[[ "$auto_apply" == "false" ]] && auto_json="false"
|
||||||
|
|
||||||
|
policies+=("$(printf '{"name":"%s","tunnel_mode":"%s","default_rule":"%s","strict_rule":%s,"auto_apply":%s,"desc":"%s"}' \
|
||||||
|
"$name" "$tunnel_mode" "${default_rule:-}" \
|
||||||
|
"$strict_json" "$auto_json" "$desc")")
|
||||||
|
done <<< "$data"
|
||||||
|
|
||||||
|
local count=${#policies[@]}
|
||||||
|
local array
|
||||||
|
array=$(printf '%s\n' "${policies[@]:-}" | paste -sd ',' -)
|
||||||
|
printf '{"policies":[%s]}' "${array:-}" | json::envelope "policy list" "$count"
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,8 @@ function cmd::rule::on_load() {
|
||||||
flag::register --force
|
flag::register --force
|
||||||
flag::register --type
|
flag::register --type
|
||||||
flag::register --all
|
flag::register --all
|
||||||
|
|
||||||
|
command::mixin json_output
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
@ -47,12 +49,13 @@ Service names from 'wgctl net' can be used instead of raw IPs/ports.
|
||||||
|
|
||||||
Subcommands:
|
Subcommands:
|
||||||
list, ls List all rules
|
list, ls List all rules
|
||||||
show, inspect Show rule details and inheritance
|
list --detailed Show inheritance tree
|
||||||
add, new, create Create a new rule
|
show, inspect --name <r> Show rule details and inheritance
|
||||||
update, edit Update a rule and re-apply to peers
|
add, new, create --name <r> Create a new rule
|
||||||
remove, rm, del Remove a rule
|
update, edit --name <r> Update a rule and re-apply to peers
|
||||||
assign Assign a rule to a peer
|
remove, rm, del --name <r> Remove a rule
|
||||||
unassign Remove rule from a peer
|
assign --name <r> Assign a rule to a peer
|
||||||
|
unassign --name <r> --peer <p> Remove rule from a peer
|
||||||
reapply Re-apply rule to all assigned peers
|
reapply Re-apply rule to all assigned peers
|
||||||
migrate Apply default rules to unassigned peers
|
migrate Apply default rules to unassigned peers
|
||||||
|
|
||||||
|
|
@ -115,6 +118,11 @@ function cmd::rule::run() {
|
||||||
local subcmd="${1:-help}"
|
local subcmd="${1:-help}"
|
||||||
shift || true
|
shift || true
|
||||||
|
|
||||||
|
if command::json; then
|
||||||
|
cmd::rule::_output_json
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
case "$subcmd" in
|
case "$subcmd" in
|
||||||
list|ls) cmd::rule::list "$@" ;;
|
list|ls) cmd::rule::list "$@" ;;
|
||||||
show|inspect) cmd::rule::show "$@" ;;
|
show|inspect) cmd::rule::show "$@" ;;
|
||||||
|
|
@ -173,6 +181,11 @@ function cmd::rule::list() {
|
||||||
log::section "Firewall Rules"
|
log::section "Firewall Rules"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
if display::is_table "rule_list"; then
|
||||||
|
cmd::rule::_render_table "$data"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
local current_group="" printing_base=false found_any=false
|
local current_group="" printing_base=false found_any=false
|
||||||
|
|
||||||
while IFS="|" read -r name desc n_allows n_blocks \
|
while IFS="|" read -r name desc n_allows n_blocks \
|
||||||
|
|
@ -232,6 +245,18 @@ function cmd::rule::list() {
|
||||||
echo ""
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Show
|
# Show
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
@ -312,10 +337,9 @@ function cmd::rule::show() {
|
||||||
local peer_count=${#peer_list[@]}
|
local peer_count=${#peer_list[@]}
|
||||||
ui::empty "$peer_count" && return 0
|
ui::empty "$peer_count" && return 0
|
||||||
|
|
||||||
local peer_word="peers"
|
[[ "$peer_count" -eq 1 ]]
|
||||||
[[ "$peer_count" -eq 1 ]] && peer_word="peer"
|
printf "\n \033[0;37m── Peers (%s) \033[0m%s\n\n" \
|
||||||
printf "\n \033[0;37m── Peers (%s %s) \033[0m%s\n\n" \
|
"$peer_count" "$(printf '\033[0;37m─%.0s' {1..30})"
|
||||||
"$peer_count" "$peer_word" "$(printf '\033[0;37m─%.0s' {1..30})"
|
|
||||||
|
|
||||||
for peer_name in "${peer_list[@]}"; do
|
for peer_name in "${peer_list[@]}"; do
|
||||||
local ip
|
local ip
|
||||||
|
|
@ -551,6 +575,19 @@ function cmd::rule::assign() {
|
||||||
|
|
||||||
peer=$(peers::resolve_and_require "$peer" "$type") || return 1
|
peer=$(peers::resolve_and_require "$peer" "$type") || return 1
|
||||||
|
|
||||||
|
# Identity rule check
|
||||||
|
local peer_identity
|
||||||
|
|
||||||
|
peer_identity=$(peers::get_identity "$peer")
|
||||||
|
if [[ -n "$peer_identity" ]]; then
|
||||||
|
local identity_rules
|
||||||
|
identity_rules=$(identity::rules "$peer_identity" 2>/dev/null)
|
||||||
|
if echo "$identity_rules" | grep -qx "$name"; then
|
||||||
|
log::error "Rule '${name}' is already applied to '${peer}' via identity '${peer_identity}' — cannot assign directly"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
local existing_rule ip
|
local existing_rule ip
|
||||||
existing_rule=$(peers::get_meta "$peer" "rule")
|
existing_rule=$(peers::get_meta "$peer" "rule")
|
||||||
ip=$(peers::get_ip "$peer")
|
ip=$(peers::get_ip "$peer")
|
||||||
|
|
@ -659,3 +696,37 @@ function cmd::rule::reapply() {
|
||||||
rule::reapply_all "$name"
|
rule::reapply_all "$name"
|
||||||
log::wg_success "Rule '${name}' reapplied"
|
log::wg_success "Rule '${name}' reapplied"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cmd::rule::_output_json() {
|
||||||
|
local rules_dir
|
||||||
|
rules_dir="$(ctx::rules)"
|
||||||
|
local data
|
||||||
|
data=$(json::rule_list_data "$rules_dir" "$(ctx::meta)" 2>/dev/null)
|
||||||
|
|
||||||
|
local -a rules=()
|
||||||
|
while IFS='|' read -r name desc n_allows n_blocks peer_count extends is_base group; do
|
||||||
|
[[ -z "$name" ]] && continue
|
||||||
|
|
||||||
|
# Build extends array
|
||||||
|
local extends_json="[]"
|
||||||
|
if [[ -n "$extends" ]]; then
|
||||||
|
local ext_array
|
||||||
|
ext_array=$(echo "$extends" | tr ',' '\n' | \
|
||||||
|
while IFS= read -r e; do [[ -n "$e" ]] && printf '"%s",' "$e"; done | sed 's/,$//')
|
||||||
|
extends_json="[${ext_array}]"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Convert Python bool to JSON bool
|
||||||
|
local is_base_json="false"
|
||||||
|
[[ "$is_base" == "True" ]] && is_base_json="true"
|
||||||
|
|
||||||
|
rules+=("$(printf '{"name":"%s","desc":"%s","allows":%s,"blocks":%s,"peer_count":%s,"extends":%s,"is_base":%s,"group":"%s"}' \
|
||||||
|
"$name" "$desc" "$n_allows" "$n_blocks" "$peer_count" \
|
||||||
|
"$extends_json" "$is_base_json" "$group")")
|
||||||
|
done <<< "$data"
|
||||||
|
|
||||||
|
local count=${#rules[@]}
|
||||||
|
local array
|
||||||
|
array=$(printf '%s\n' "${rules[@]:-}" | paste -sd ',' -)
|
||||||
|
printf '{"rules":[%s]}' "${array:-}" | json::envelope "rule list" "$count"
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ function cmd::shell::_is_wgctl_command() {
|
||||||
list add remove rm inspect block unblock
|
list add remove rm inspect block unblock
|
||||||
rule group audit logs watch fw config qr
|
rule group audit logs watch fw config qr
|
||||||
rename keys ip net service shell help test
|
rename keys ip net service shell help test
|
||||||
|
peer hosts identity subnet policy activity
|
||||||
)
|
)
|
||||||
local c
|
local c
|
||||||
for c in "${known[@]}"; do
|
for c in "${known[@]}"; do
|
||||||
|
|
@ -82,28 +83,44 @@ function cmd::shell::_banner() {
|
||||||
printf "\n"
|
printf "\n"
|
||||||
printf " Type wgctl commands directly (no 'wgctl' prefix).\n"
|
printf " Type wgctl commands directly (no 'wgctl' prefix).\n"
|
||||||
printf " Bash commands work too: ls, cat, systemctl, vim...\n\n"
|
printf " Bash commands work too: ls, cat, systemctl, vim...\n\n"
|
||||||
printf " \033[1;37mCommon commands:\033[0m\n"
|
printf " \033[1;37mPeer management:\033[0m\n"
|
||||||
printf " list List all peers\n"
|
printf " list List all peers\n"
|
||||||
printf " list --blocked Show blocked peers\n"
|
printf " list --blocked Show blocked peers\n"
|
||||||
printf " list --restricted Show restricted peers\n"
|
|
||||||
printf " list --rule user Filter by rule\n"
|
printf " list --rule user Filter by rule\n"
|
||||||
printf " inspect --name <peer> Full peer details\n"
|
printf " inspect --name <peer> Full peer details\n"
|
||||||
printf " block --name <peer> Block a peer entirely\n"
|
printf " add --identity <id> --type phone Add a peer\n"
|
||||||
printf " block --name <peer> --service proxmox Restrict service\n"
|
printf " block --name <peer> Block a peer\n"
|
||||||
printf " unblock --name <peer> Restore full access\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 " rule list Show firewall rules\n"
|
printf " rule list Show firewall rules\n"
|
||||||
printf " rule list --tree Show with inheritance\n"
|
printf " rule list --tree Show with inheritance\n"
|
||||||
printf " rule show --name <rule> Rule details\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 " net list Show network services\n"
|
printf " net list Show network services\n"
|
||||||
printf " net list --detailed Show services with ports\n"
|
printf " net list --detailed Show with ports\n"
|
||||||
|
printf " hosts list Show host annotations\n"
|
||||||
|
printf " subnet list Show subnets\n"
|
||||||
printf " group list Show groups\n"
|
printf " group list Show groups\n"
|
||||||
printf " group block --name <group> Block all peers in group\n"
|
printf " group block --name <group> Block all in group\n\n"
|
||||||
|
printf " \033[1;37mMonitoring:\033[0m\n"
|
||||||
|
printf " logs Activity logs\n"
|
||||||
|
printf " logs --since 2h Logs from last 2h\n"
|
||||||
|
printf " logs --fw --service pihole FW drops to service\n"
|
||||||
|
printf " logs --wg --event handshake WG handshake events\n"
|
||||||
|
printf " logs --resolved Show resolved names only\n"
|
||||||
printf " logs --follow Live activity log\n"
|
printf " logs --follow Live activity log\n"
|
||||||
|
printf " logs clean Remove keepalive entries\n"
|
||||||
printf " logs rotate Clean old log entries\n"
|
printf " logs rotate Clean old log entries\n"
|
||||||
printf " watch Live WG + firewall monitor\n"
|
printf " watch Live WG + firewall monitor\n"
|
||||||
|
printf " activity Transfer + drop summary\n"
|
||||||
printf " fw list Show iptables rules\n"
|
printf " fw list Show iptables rules\n"
|
||||||
printf " audit Verify firewall state\n"
|
printf " audit Verify firewall state\n"
|
||||||
printf " audit --fix Auto-repair firewall rules\n\n"
|
printf " audit --fix Auto-repair firewall\n\n"
|
||||||
printf " \033[1mexit\033[0m or \033[1mquit\033[0m to leave · \033[1mhelp\033[0m for full command list\n\n"
|
printf " \033[1mexit\033[0m or \033[1mquit\033[0m to leave · \033[1mhelp\033[0m for full command list\n\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,7 +160,9 @@ EOF
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
function cmd::shell::_setup_completion() {
|
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"
|
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"
|
||||||
|
|
||||||
function _wgctl_shell_complete() {
|
function _wgctl_shell_complete() {
|
||||||
local cur="${COMP_WORDS[COMP_CWORD]}"
|
local cur="${COMP_WORDS[COMP_CWORD]}"
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ function cmd::subnet::on_load() {
|
||||||
flag::register --desc
|
flag::register --desc
|
||||||
flag::register --group
|
flag::register --group
|
||||||
flag::register --new-name
|
flag::register --new-name
|
||||||
|
|
||||||
|
command::mixin json_output
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
@ -65,6 +67,11 @@ function cmd::subnet::run() {
|
||||||
local subcmd="${1:-list}"
|
local subcmd="${1:-list}"
|
||||||
shift || true
|
shift || true
|
||||||
|
|
||||||
|
if command::json; then
|
||||||
|
cmd::subnet::_output_json
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
case "$subcmd" in
|
case "$subcmd" in
|
||||||
list) cmd::subnet::_list "$@" ;;
|
list) cmd::subnet::_list "$@" ;;
|
||||||
show) cmd::subnet::_show "$@" ;;
|
show) cmd::subnet::_show "$@" ;;
|
||||||
|
|
@ -85,13 +92,18 @@ function cmd::subnet::run() {
|
||||||
|
|
||||||
function cmd::subnet::_list() {
|
function cmd::subnet::_list() {
|
||||||
local data
|
local data
|
||||||
data=$(subnet::list_data)
|
data=$(subnet::list_data | ui::sort_rows 1)
|
||||||
|
|
||||||
if [[ -z "$data" ]]; then
|
if [[ -z "$data" ]]; then
|
||||||
log::info "No subnets defined."
|
log::info "No subnets defined."
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if display::is_table "subnet_list"; then
|
||||||
|
cmd::subnet::_render_table "$data"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
local prev_group=""
|
local prev_group=""
|
||||||
while IFS='|' read -r display_name subnet type_key tunnel_mode desc is_group group_parent; do
|
while IFS='|' read -r display_name subnet type_key tunnel_mode desc is_group group_parent; do
|
||||||
|
|
@ -113,6 +125,18 @@ function cmd::subnet::_list() {
|
||||||
echo ""
|
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() {
|
function cmd::subnet::_maybe_group_separator() {
|
||||||
local is_group="${1:-}" group_parent="${2:-}" prev_group="${3:-}"
|
local is_group="${1:-}" group_parent="${2:-}" prev_group="${3:-}"
|
||||||
if [[ "$is_group" == "true" && "$group_parent" != "$prev_group" && -n "$prev_group" ]]; then
|
if [[ "$is_group" == "true" && "$group_parent" != "$prev_group" && -n "$prev_group" ]]; then
|
||||||
|
|
@ -292,3 +316,25 @@ function cmd::subnet::_validate_cidr() {
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cmd::subnet::_output_json() {
|
||||||
|
local data
|
||||||
|
data=$(subnet::list_data 2>/dev/null)
|
||||||
|
|
||||||
|
local -a subnets=()
|
||||||
|
while IFS='|' read -r type cidr display_name tunnel_mode desc is_group group_parent; do
|
||||||
|
[[ -z "$type" ]] && continue
|
||||||
|
|
||||||
|
local is_group_json="false"
|
||||||
|
[[ "$is_group" == "true" ]] && is_group_json="true"
|
||||||
|
|
||||||
|
subnets+=("$(printf '{"type":"%s","cidr":"%s","display_name":"%s","tunnel_mode":"%s","desc":"%s","is_group":%s,"group_parent":"%s"}' \
|
||||||
|
"$type" "$cidr" "$display_name" "$tunnel_mode" \
|
||||||
|
"$desc" "$is_group_json" "${group_parent:-}")")
|
||||||
|
done <<< "$data"
|
||||||
|
|
||||||
|
local count=${#subnets[@]}
|
||||||
|
local array
|
||||||
|
array=$(printf '%s\n' "${subnets[@]:-}" | paste -sd ',' -)
|
||||||
|
printf '{"subnets":[%s]}' "${array:-}" | json::envelope "subnet list" "$count"
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,8 @@ function cmd::test::section_destructive() {
|
||||||
cmd::test::_destructive_groups
|
cmd::test::_destructive_groups
|
||||||
cmd::test::_destructive_identity
|
cmd::test::_destructive_identity
|
||||||
cmd::test::_destructive_cleanup
|
cmd::test::_destructive_cleanup
|
||||||
|
cmd::test::_destructive_rule_duplicate
|
||||||
|
cmd::test::_destructive_peer_dns
|
||||||
}
|
}
|
||||||
|
|
||||||
function cmd::test::_destructive_peer() {
|
function cmd::test::_destructive_peer() {
|
||||||
|
|
@ -121,6 +123,67 @@ function cmd::test::_destructive_identity() {
|
||||||
identity show --name testunit2b
|
identity show --name testunit2b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cmd::test::_destructive_rule_duplicate() {
|
||||||
|
# Cleanup from any previous failed run
|
||||||
|
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
|
||||||
|
"$WGCTL_BINARY" identity rule unassign --name testunit --all > /dev/null 2>&1 || true
|
||||||
|
"$WGCTL_BINARY" add --name testunit --type phone > /dev/null 2>&1
|
||||||
|
|
||||||
|
# Assign admin to identity
|
||||||
|
"$WGCTL_BINARY" identity rule assign --name testunit --rule admin > /dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# Try to assign admin directly — should fail (identity has it)
|
||||||
|
cmd::test::run_cmd_fails "rule assign blocked by identity rule" \
|
||||||
|
rule assign --name admin --peer phone-testunit
|
||||||
|
|
||||||
|
# Remove admin from identity first so we can assign user directly to peer
|
||||||
|
"$WGCTL_BINARY" identity rule unassign --name testunit --rule admin > /dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# Assign user directly to peer
|
||||||
|
"$WGCTL_BINARY" rule assign --name user --peer phone-testunit > /dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# Now assign user to identity with --migrate — should remove from peer
|
||||||
|
cmd::test::run_cmd "identity rule assign --migrate" "Migrated" \
|
||||||
|
identity rule assign --name testunit --rule user --migrate
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
"$WGCTL_BINARY" identity rule unassign --name testunit --all > /dev/null 2>&1 || true
|
||||||
|
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmd::test::_destructive_peer_dns() {
|
||||||
|
test::section "Destructive: peer update-dns"
|
||||||
|
|
||||||
|
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
|
||||||
|
"$WGCTL_BINARY" add --name testunit --type phone > /dev/null 2>&1
|
||||||
|
|
||||||
|
# Update DNS and verify it's in the conf
|
||||||
|
cmd::test::run_cmd "peer update-dns single peer" "Updated DNS" \
|
||||||
|
peer update-dns --name phone-testunit --fallback-dns "9.9.9.9"
|
||||||
|
|
||||||
|
local conf_dns
|
||||||
|
conf_dns=$(grep "^DNS" /etc/wireguard/clients/phone-testunit.conf 2>/dev/null)
|
||||||
|
[[ "$conf_dns" == *"9.9.9.9"* ]] && \
|
||||||
|
test::pass "DNS line contains fallback" || \
|
||||||
|
test::fail "DNS line missing fallback (got: $conf_dns)"
|
||||||
|
|
||||||
|
# Update tunnel mode
|
||||||
|
cmd::test::run_cmd "peer update-tunnel to full" "Updated" \
|
||||||
|
peer update-tunnel --name phone-testunit --mode full
|
||||||
|
|
||||||
|
local conf_allowed
|
||||||
|
conf_allowed=$(grep "^AllowedIPs" /etc/wireguard/clients/phone-testunit.conf 2>/dev/null)
|
||||||
|
[[ "$conf_allowed" == *"0.0.0.0/0"* ]] && \
|
||||||
|
test::pass "AllowedIPs set to full tunnel" || \
|
||||||
|
test::fail "AllowedIPs not full tunnel (got: $conf_allowed)"
|
||||||
|
|
||||||
|
cmd::test::run_cmd "peer update-tunnel to split" "Updated" \
|
||||||
|
peer update-tunnel --name phone-testunit --mode split
|
||||||
|
|
||||||
|
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function cmd::test::_destructive_cleanup() {
|
function cmd::test::_destructive_cleanup() {
|
||||||
cmd::test::run_cmd "remove phone peer" "removed" \
|
cmd::test::run_cmd "remove phone peer" "removed" \
|
||||||
remove --name phone-testunit --force
|
remove --name phone-testunit --force
|
||||||
|
|
|
||||||
|
|
@ -126,12 +126,21 @@ function cmd::test::run_all_integration_sections() {
|
||||||
cmd::test::section_config
|
cmd::test::section_config
|
||||||
cmd::test::section_rules
|
cmd::test::section_rules
|
||||||
cmd::test::section_groups
|
cmd::test::section_groups
|
||||||
|
cmd::test::section_block_unblock
|
||||||
cmd::test::section_audit
|
cmd::test::section_audit
|
||||||
cmd::test::section_logs
|
cmd::test::section_logs
|
||||||
cmd::test::section_fw
|
cmd::test::section_fw
|
||||||
cmd::test::section_net
|
cmd::test::section_net
|
||||||
cmd::test::section_subnet
|
cmd::test::section_subnet
|
||||||
cmd::test::section_identity
|
cmd::test::section_identity
|
||||||
|
cmd::test::section_activity
|
||||||
|
cmd::test::section_policy
|
||||||
|
cmd::test::section_hosts
|
||||||
|
cmd::test::section_peer_cmd
|
||||||
|
cmd::test::section_group_purge
|
||||||
|
cmd::test::section_logs_clean
|
||||||
|
cmd::test::section_display
|
||||||
|
cmd::test::section_export
|
||||||
}
|
}
|
||||||
|
|
||||||
function cmd::test::section_list() {
|
function cmd::test::section_list() {
|
||||||
|
|
@ -143,6 +152,12 @@ function cmd::test::section_list() {
|
||||||
cmd::test::run_cmd "list --type phone" "phone" list --type phone
|
cmd::test::run_cmd "list --type phone" "phone" list --type phone
|
||||||
cmd::test::run_cmd "list --detailed" "rule:" list --detailed
|
cmd::test::run_cmd "list --detailed" "rule:" list --detailed
|
||||||
cmd::test::run_cmd "list --name phone-nuno" "phone-nuno" list --name phone-nuno
|
cmd::test::run_cmd "list --name phone-nuno" "phone-nuno" list --name phone-nuno
|
||||||
|
cmd::test::run_cmd "list --json" '"ok":true' list --json
|
||||||
|
cmd::test::run_cmd "list --json has peers" '"peers":' list --json
|
||||||
|
cmd::test::run_cmd "list --json has meta" '"meta":' list --json
|
||||||
|
cmd::test::run_cmd "list --json peer name" '"name":' list --json
|
||||||
|
cmd::test::run_cmd "list --json peer ip" '"ip":' list --json
|
||||||
|
cmd::test::run_cmd "list --json peer status" '"status":' list --json
|
||||||
}
|
}
|
||||||
|
|
||||||
function cmd::test::section_inspect() {
|
function cmd::test::section_inspect() {
|
||||||
|
|
@ -151,6 +166,10 @@ function cmd::test::section_inspect() {
|
||||||
cmd::test::run_cmd "inspect --name nuno --type phone" "IP:" inspect --name nuno --type phone
|
cmd::test::run_cmd "inspect --name nuno --type phone" "IP:" inspect --name nuno --type phone
|
||||||
cmd::test::run_cmd "inspect --name phone-nuno --config" "PrivateKey" inspect --name phone-nuno --config
|
cmd::test::run_cmd "inspect --name phone-nuno --config" "PrivateKey" inspect --name phone-nuno --config
|
||||||
cmd::test::run_cmd_fails "inspect nonexistent" inspect --name nonexistent-peer
|
cmd::test::run_cmd_fails "inspect nonexistent" inspect --name nonexistent-peer
|
||||||
|
cmd::test::run_cmd "inspect --json" '"ok":true' inspect --name phone-nuno --json
|
||||||
|
cmd::test::run_cmd "inspect --json rule" '"rule":' inspect --name phone-nuno --json
|
||||||
|
cmd::test::run_cmd "inspect --json identity" '"identity":' inspect --name phone-nuno --json
|
||||||
|
cmd::test::run_cmd "inspect --json groups" '"groups":' inspect --name phone-nuno --json
|
||||||
}
|
}
|
||||||
|
|
||||||
function cmd::test::section_config() {
|
function cmd::test::section_config() {
|
||||||
|
|
@ -158,6 +177,8 @@ function cmd::test::section_config() {
|
||||||
cmd::test::run_cmd "config --name phone-nuno" "PrivateKey" config --name phone-nuno
|
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 "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 "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() {
|
function cmd::test::section_rules() {
|
||||||
|
|
@ -166,6 +187,11 @@ function cmd::test::section_rules() {
|
||||||
cmd::test::run_cmd "rule show --name guest" "Description" rule show --name guest
|
cmd::test::run_cmd "rule show --name guest" "Description" rule show --name guest
|
||||||
cmd::test::run_cmd "rule show --name user" "Description" rule show --name user
|
cmd::test::run_cmd "rule show --name user" "Description" rule show --name user
|
||||||
cmd::test::run_cmd "rule show --name admin" "Description" rule show --name admin
|
cmd::test::run_cmd "rule show --name admin" "Description" rule show --name admin
|
||||||
|
cmd::test::run_cmd "rule list --json" '"rules":' rule list --json
|
||||||
|
cmd::test::run_cmd "rule list --json" '"rules":' rule list --json
|
||||||
|
cmd::test::run_cmd "rule --json is_base" '"is_base":' rule list --json
|
||||||
|
cmd::test::run_cmd "rule --json extends" '"extends":' rule list --json
|
||||||
|
cmd::test::run_cmd "rule --json allows" '"allows":' rule list --json
|
||||||
cmd::test::run_cmd_fails "rule show nonexistent" rule show --name nonexistent
|
cmd::test::run_cmd_fails "rule show nonexistent" rule show --name nonexistent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -173,9 +199,98 @@ function cmd::test::section_groups() {
|
||||||
test::section "Groups"
|
test::section "Groups"
|
||||||
cmd::test::run_cmd "group list" "Groups" group list
|
cmd::test::run_cmd "group list" "Groups" group list
|
||||||
cmd::test::run_cmd "group show --name family" "Peers:" group show --name family
|
cmd::test::run_cmd "group show --name family" "Peers:" group show --name family
|
||||||
|
cmd::test::run_cmd "group list --json" '"groups":' group list --json
|
||||||
|
cmd::test::run_cmd "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
|
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() {
|
function cmd::test::section_audit() {
|
||||||
test::section "Audit"
|
test::section "Audit"
|
||||||
cmd::test::run_cmd_any "audit" "passed" audit
|
cmd::test::run_cmd_any "audit" "passed" audit
|
||||||
|
|
@ -187,8 +302,17 @@ function cmd::test::section_logs() {
|
||||||
test::section "Logs"
|
test::section "Logs"
|
||||||
cmd::test::run_cmd "logs" "Activity" logs
|
cmd::test::run_cmd "logs" "Activity" logs
|
||||||
cmd::test::run_cmd "logs --name phone-nuno" "Activity" logs --name phone-nuno
|
cmd::test::run_cmd "logs --name phone-nuno" "Activity" logs --name phone-nuno
|
||||||
cmd::test::run_cmd "logs --fw" "Activity" logs --fw
|
cmd::test::run_cmd "logs --fw" "Firewall Drops" logs --fw
|
||||||
cmd::test::run_cmd "logs --wg" "Activity" logs --wg
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
function cmd::test::section_fw() {
|
function cmd::test::section_fw() {
|
||||||
|
|
@ -215,6 +339,9 @@ 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 add port again" "Added" net add --name test-svc:web --port 9999:tcp
|
||||||
cmd::test::run_cmd "net rm all ports" "Removed" net rm --name test-svc:ports --force
|
cmd::test::run_cmd "net rm all ports" "Removed" net rm --name test-svc:ports --force
|
||||||
cmd::test::run_cmd "net rm service" "Removed" net rm --name test-svc --force
|
cmd::test::run_cmd "net rm service" "Removed" net rm --name test-svc --force
|
||||||
|
cmd::test::run_cmd "net list --json" '"services":' net list --json
|
||||||
|
cmd::test::run_cmd "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 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
|
cmd::test::run_cmd_fails "net add port no service" net add --name nonexistent:web --port 80:tcp
|
||||||
}
|
}
|
||||||
|
|
@ -228,19 +355,15 @@ function cmd::test::section_subnet() {
|
||||||
cmd::test::run_cmd "subnet show desktop" "tunnel:" subnet show --name desktop
|
cmd::test::run_cmd "subnet show desktop" "tunnel:" subnet show --name desktop
|
||||||
cmd::test::run_cmd "subnet show guests group" "guests" subnet show --name guests
|
cmd::test::run_cmd "subnet show guests group" "guests" subnet show --name guests
|
||||||
cmd::test::run_cmd_fails "subnet show nonexistent" subnet show --name nonexistent
|
cmd::test::run_cmd_fails "subnet show nonexistent" subnet show --name nonexistent
|
||||||
|
cmd::test::run_cmd "subnet add" "added" subnet add --name test-subnet --subnet 10.1.250.0/24 --type iot --desc "Test"
|
||||||
cmd::test::run_cmd "subnet add" "added" \
|
cmd::test::run_cmd "subnet list shows new" "test-subnet" subnet list
|
||||||
subnet add --name test-subnet --subnet 10.1.250.0/24 --type iot --desc "Test"
|
cmd::test::run_cmd_fails "subnet rename in-use (desktop)" subnet rename --name desktop --new-name workstation
|
||||||
cmd::test::run_cmd "subnet list shows new" "test-subnet" \
|
cmd::test::run_cmd "subnet rename unused" "renamed" subnet rename --name test-subnet --new-name test-subnet-2
|
||||||
subnet list
|
cmd::test::run_cmd "subnet rm" "removed" subnet rm --name test-subnet-2
|
||||||
cmd::test::run_cmd_fails "subnet rename in-use (desktop)" \
|
cmd::test::run_cmd "subnet list --json" '"subnets":' subnet list --json
|
||||||
subnet rename --name desktop --new-name workstation
|
cmd::test::run_cmd "subnet --json has cidr" '"cidr":' subnet list --json
|
||||||
cmd::test::run_cmd "subnet rename unused" "renamed" \
|
cmd::test::run_cmd "subnet --json is_group" '"is_group":' subnet list --json
|
||||||
subnet rename --name test-subnet --new-name test-subnet-2
|
cmd::test::run_cmd_fails "subnet rm nonexistent" subnet rm --name nonexistent-subnet
|
||||||
cmd::test::run_cmd "subnet rm" "removed" \
|
|
||||||
subnet rm --name test-subnet-2
|
|
||||||
cmd::test::run_cmd_fails "subnet rm nonexistent" \
|
|
||||||
subnet rm --name nonexistent-subnet
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cmd::test::section_identity() {
|
function cmd::test::section_identity() {
|
||||||
|
|
@ -248,5 +371,176 @@ function cmd::test::section_identity() {
|
||||||
cmd::test::run_cmd "identity list" "" identity list
|
cmd::test::run_cmd "identity list" "" identity list
|
||||||
cmd::test::run_cmd "identity migrate --dry-run" "Dry run" identity migrate --dry-run
|
cmd::test::run_cmd "identity migrate --dry-run" "Dry run" identity migrate --dry-run
|
||||||
cmd::test::run_cmd "identity show nuno" "nuno" identity show --name nuno
|
cmd::test::run_cmd "identity show nuno" "nuno" identity show --name nuno
|
||||||
|
cmd::test::run_cmd "identity list --json" '"identities":' identity list --json
|
||||||
|
cmd::test::run_cmd "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
|
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,6 +44,13 @@ function cmd::test::run_all_unit_sections() {
|
||||||
cmd::test::unit_subnet
|
cmd::test::unit_subnet
|
||||||
cmd::test::unit_ip
|
cmd::test::unit_ip
|
||||||
cmd::test::unit_identity
|
cmd::test::unit_identity
|
||||||
|
cmd::test::unit_fmt
|
||||||
|
cmd::test::unit_config
|
||||||
|
cmd::test::unit_parse_since
|
||||||
|
cmd::test::unit_group_status
|
||||||
|
cmd::test::unit_json_output
|
||||||
|
cmd::test::unit_display
|
||||||
|
cmd::test::unit_version
|
||||||
}
|
}
|
||||||
|
|
||||||
function cmd::test::unit_subnet() {
|
function cmd::test::unit_subnet() {
|
||||||
|
|
@ -103,3 +110,184 @@ function cmd::test::unit_identity() {
|
||||||
cmd::test::assert "infer no convention" "$(identity::infer 'roboclean')" ""
|
cmd::test::assert "infer no convention" "$(identity::infer 'roboclean')" ""
|
||||||
cmd::test::assert "infer guest-zephyr" "$(identity::infer 'guest-zephyr')" ""
|
cmd::test::assert "infer guest-zephyr" "$(identity::infer 'guest-zephyr')" ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cmd::test::unit_fmt() {
|
||||||
|
test::section "Unit: fmt::bytes"
|
||||||
|
|
||||||
|
cmd::test::assert "fmt::bytes 0" "$(fmt::bytes 0)" "—"
|
||||||
|
cmd::test::assert "fmt::bytes bytes" "$(fmt::bytes 512)" "512B"
|
||||||
|
cmd::test::assert "fmt::bytes KB" "$(fmt::bytes 2048)" "2KB"
|
||||||
|
cmd::test::assert "fmt::bytes MB" "$(fmt::bytes 2097152)" "2MB"
|
||||||
|
cmd::test::assert "fmt::bytes GB" "$(fmt::bytes 2147483648)" "2GB"
|
||||||
|
cmd::test::assert "fmt::bytes 1023" "$(fmt::bytes 1023)" "1023B"
|
||||||
|
cmd::test::assert "fmt::bytes 1024" "$(fmt::bytes 1024)" "1KB"
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmd::test::unit_config() {
|
||||||
|
test::section "Unit: config dns"
|
||||||
|
|
||||||
|
# config::dns_string with no fallback
|
||||||
|
local orig_fallback="${_WG_DNS_FALLBACK:-}"
|
||||||
|
_WG_DNS_FALLBACK=""
|
||||||
|
cmd::test::assert "dns_string no fallback" \
|
||||||
|
"$(config::dns_string)" "$(config::dns)"
|
||||||
|
|
||||||
|
# config::dns_string with fallback
|
||||||
|
_WG_DNS_FALLBACK="9.9.9.9"
|
||||||
|
cmd::test::assert "dns_string with fallback" \
|
||||||
|
"$(config::dns_string)" "$(config::dns), 9.9.9.9"
|
||||||
|
|
||||||
|
# config::dns_string with multiple fallbacks
|
||||||
|
_WG_DNS_FALLBACK="9.9.9.9,1.1.1.1"
|
||||||
|
cmd::test::assert "dns_string multi fallback" \
|
||||||
|
"$(config::dns_string)" "$(config::dns), 9.9.9.9,1.1.1.1"
|
||||||
|
|
||||||
|
# Restore
|
||||||
|
_WG_DNS_FALLBACK="$orig_fallback"
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmd::test::unit_parse_since() {
|
||||||
|
test::section "Unit: parse_since (Python)"
|
||||||
|
|
||||||
|
# Test via Python directly
|
||||||
|
local py_result
|
||||||
|
|
||||||
|
# Relative formats
|
||||||
|
py_result=$(python3 -c "
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, '/etc/wireguard/wgctl/core/lib')
|
||||||
|
from util import parse_since
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
dt = parse_since('2h')
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
diff = abs((now - dt).total_seconds() - 7200)
|
||||||
|
print('ok' if diff < 5 else f'fail: {diff}')
|
||||||
|
")
|
||||||
|
cmd::test::assert "parse_since 2h" "$py_result" "ok"
|
||||||
|
|
||||||
|
py_result=$(python3 -c "
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, '/etc/wireguard/wgctl/core/lib')
|
||||||
|
from util import parse_since
|
||||||
|
dt = parse_since('7d')
|
||||||
|
print('ok' if dt is not None else 'fail')
|
||||||
|
")
|
||||||
|
cmd::test::assert "parse_since 7d" "$py_result" "ok"
|
||||||
|
|
||||||
|
# EU date format
|
||||||
|
py_result=$(python3 -c "
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, '/etc/wireguard/wgctl/core/lib')
|
||||||
|
from util import parse_since
|
||||||
|
from datetime import datetime
|
||||||
|
dt = parse_since('23/05')
|
||||||
|
print('ok' if dt is not None and dt.day == 23 and dt.month == 5 else f'fail: {dt}')
|
||||||
|
")
|
||||||
|
cmd::test::assert "parse_since 23/05" "$py_result" "ok"
|
||||||
|
|
||||||
|
# ISO format
|
||||||
|
py_result=$(python3 -c "
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, '/etc/wireguard/wgctl/core/lib')
|
||||||
|
from util import parse_since
|
||||||
|
dt = parse_since('2026-05-23')
|
||||||
|
print('ok' if dt is not None and dt.year == 2026 and dt.month == 5 and dt.day == 23 else f'fail: {dt}')
|
||||||
|
")
|
||||||
|
cmd::test::assert "parse_since ISO" "$py_result" "ok"
|
||||||
|
|
||||||
|
# Invalid
|
||||||
|
py_result=$(python3 -c "
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, '/etc/wireguard/wgctl/core/lib')
|
||||||
|
from util import parse_since
|
||||||
|
dt = parse_since('not-a-date')
|
||||||
|
print('ok' if dt is None else f'fail: {dt}')
|
||||||
|
")
|
||||||
|
cmd::test::assert "parse_since invalid" "$py_result" "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmd::test::unit_group_status() {
|
||||||
|
test::section "Unit: ui::group::status"
|
||||||
|
load_module ui
|
||||||
|
|
||||||
|
local color str
|
||||||
|
IFS='|' read -r color str <<< "$(ui::group::status 0 0)"
|
||||||
|
cmd::test::assert "group status inactive str" "$str" "inactive"
|
||||||
|
|
||||||
|
IFS='|' read -r color str <<< "$(ui::group::status 4 0)"
|
||||||
|
cmd::test::assert "group status active str" "$str" "active"
|
||||||
|
|
||||||
|
IFS='|' read -r color str <<< "$(ui::group::status 4 4)"
|
||||||
|
cmd::test::assert "group status blocked str" "$str" "blocked"
|
||||||
|
|
||||||
|
IFS='|' read -r color str <<< "$(ui::group::status 4 2)"
|
||||||
|
cmd::test::assert "group status partial str" "$str" "partial (2/4)"
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmd::test::unit_json_output() {
|
||||||
|
test::section "Unit: JSON output"
|
||||||
|
|
||||||
|
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,6 +16,7 @@ function cmd::unblock::on_load() {
|
||||||
flag::register --subnet
|
flag::register --subnet
|
||||||
flag::register --all
|
flag::register --all
|
||||||
flag::register --service
|
flag::register --service
|
||||||
|
flag::register --reason
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
@ -59,6 +60,7 @@ function cmd::unblock::run() {
|
||||||
local name="" identity="" type=""
|
local name="" identity="" type=""
|
||||||
local ips=() subnets=() ports=() services=()
|
local ips=() subnets=() ports=() services=()
|
||||||
local all=false quiet=false force=false
|
local all=false quiet=false force=false
|
||||||
|
local reason=""
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
|
|
@ -71,6 +73,7 @@ function cmd::unblock::run() {
|
||||||
--subnet) subnets+=("$2"); shift 2 ;;
|
--subnet) subnets+=("$2"); shift 2 ;;
|
||||||
--port) ports+=("$2"); shift 2 ;;
|
--port) ports+=("$2"); shift 2 ;;
|
||||||
--service) services+=("$2"); shift 2 ;;
|
--service) services+=("$2"); shift 2 ;;
|
||||||
|
--reason) reason="$2"; shift 2 ;;
|
||||||
--all) all=true; shift ;;
|
--all) all=true; shift ;;
|
||||||
--help) cmd::unblock::help; return ;;
|
--help) cmd::unblock::help; return ;;
|
||||||
*)
|
*)
|
||||||
|
|
@ -110,6 +113,7 @@ function cmd::unblock::run() {
|
||||||
|
|
||||||
if $all; then
|
if $all; then
|
||||||
cmd::unblock::_unblock_all "$name" "$client_ip" "$quiet"
|
cmd::unblock::_unblock_all "$name" "$client_ip" "$quiet"
|
||||||
|
cmd::unblock::_record_history "$name" "manual" "$reason"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -180,6 +184,9 @@ function cmd::unblock::run() {
|
||||||
done
|
done
|
||||||
|
|
||||||
block::cleanup "$name"
|
block::cleanup "$name"
|
||||||
|
|
||||||
|
# Record unblock for specific rules
|
||||||
|
cmd::unblock::_record_history "$name" "manual" "$reason"
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -249,3 +256,14 @@ function cmd::unblock::_unblock_all() {
|
||||||
$quiet || log::wg_success "${name} has been unblocked."
|
$quiet || log::wg_success "${name} has been unblocked."
|
||||||
return 0
|
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
|
||||||
|
}
|
||||||
|
|
@ -33,7 +33,7 @@ Options:
|
||||||
--blocked Show only blocked peer attempts
|
--blocked Show only blocked peer attempts
|
||||||
--allowed Show only handshakes
|
--allowed Show only handshakes
|
||||||
--restricted Show only firewall drop events
|
--restricted Show only firewall drop events
|
||||||
--raw Show raw IPs without service annotation
|
--raw Show raw IPs without host/service resolution
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
wgctl watch
|
wgctl watch
|
||||||
|
|
@ -62,7 +62,7 @@ function cmd::watch::run() {
|
||||||
--blocked) blocked_only=true; shift ;;
|
--blocked) blocked_only=true; shift ;;
|
||||||
--allowed) allowed_only=true; shift ;;
|
--allowed) allowed_only=true; shift ;;
|
||||||
--restricted) restricted_only=true; shift ;;
|
--restricted) restricted_only=true; shift ;;
|
||||||
--raw) raw=true; shift ;;
|
--raw) _WGCTL_RAW=true; shift ;;
|
||||||
--help) cmd::watch::help; return ;;
|
--help) cmd::watch::help; return ;;
|
||||||
*)
|
*)
|
||||||
log::error "Unknown flag: $1"
|
log::error "Unknown flag: $1"
|
||||||
|
|
@ -72,38 +72,11 @@ function cmd::watch::run() {
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
local net_file=""
|
|
||||||
$raw || net_file="$(ctx::net)"
|
|
||||||
|
|
||||||
log::section "wgctl — Live Monitor (Ctrl+C to stop)"
|
log::section "wgctl — Live Monitor (Ctrl+C to stop)"
|
||||||
printf "\n"
|
printf "\n"
|
||||||
|
|
||||||
# Fixed display widths for watch (dynamic measurement not possible in stream)
|
monitor::live "$filter_name" "$filter_type" "$filter_peers" \
|
||||||
local w_client=20 w_dest=18
|
"$blocked_only" "$restricted_only" "$allowed_only" "$raw"
|
||||||
|
|
||||||
# Handshake poller (background)
|
|
||||||
if ! $blocked_only && ! $restricted_only; then
|
|
||||||
(
|
|
||||||
while true; do
|
|
||||||
cmd::watch::_poll_handshakes \
|
|
||||||
"$filter_name" "$filter_type" "$filter_peers" "$w_client" "$w_dest"
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
) &
|
|
||||||
local poller_pid=$!
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Event tailer (background)
|
|
||||||
cmd::watch::_tail_events \
|
|
||||||
"$filter_name" "$filter_type" "$filter_peers" \
|
|
||||||
"$blocked_only" "$restricted_only" "$allowed_only" \
|
|
||||||
"$net_file" "$w_client" "$w_dest" &
|
|
||||||
local tailer_pid=$!
|
|
||||||
|
|
||||||
trap "kill $tailer_pid ${poller_pid:-} 2>/dev/null; \
|
|
||||||
rm -f /tmp/wgctl_hs_* /tmp/wgctl_attempt_*; printf '\n'; exit 0" INT TERM
|
|
||||||
|
|
||||||
wait
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
@ -112,10 +85,10 @@ function cmd::watch::run() {
|
||||||
|
|
||||||
function cmd::watch::_poll_handshakes() {
|
function cmd::watch::_poll_handshakes() {
|
||||||
local filter_name="${1:-}" filter_type="${2:-}" filter_peers="${3:-}"
|
local filter_name="${1:-}" filter_type="${2:-}" filter_peers="${3:-}"
|
||||||
local w_client="${4:-20}" w_dest="${5:-30}"
|
local w_client="${4:-20}" w_dest="${5:-18}"
|
||||||
|
|
||||||
local peer_set=()
|
# Collect rows with sort key before printing
|
||||||
[[ -n "$filter_peers" ]] && IFS=',' read -ra peer_set <<< "$filter_peers"
|
local -a rows=()
|
||||||
|
|
||||||
while IFS= read -r line; do
|
while IFS= read -r line; do
|
||||||
local public_key ts
|
local public_key ts
|
||||||
|
|
@ -123,7 +96,6 @@ function cmd::watch::_poll_handshakes() {
|
||||||
ts=$(echo "$line" | awk '{print $2}')
|
ts=$(echo "$line" | awk '{print $2}')
|
||||||
[[ -z "$ts" || "$ts" == "0" ]] && continue
|
[[ -z "$ts" || "$ts" == "0" ]] && continue
|
||||||
|
|
||||||
# Find client by public key
|
|
||||||
local client_name=""
|
local client_name=""
|
||||||
for conf in "$(ctx::clients)"/*.conf; do
|
for conf in "$(ctx::clients)"/*.conf; do
|
||||||
[[ -f "$conf" ]] || continue
|
[[ -f "$conf" ]] || continue
|
||||||
|
|
@ -139,26 +111,48 @@ function cmd::watch::_poll_handshakes() {
|
||||||
[[ -z "$client_name" ]] && continue
|
[[ -z "$client_name" ]] && continue
|
||||||
[[ -n "$filter_name" && "$client_name" != "$filter_name" ]] && continue
|
[[ -n "$filter_name" && "$client_name" != "$filter_name" ]] && continue
|
||||||
|
|
||||||
# Dedup — only emit if handshake is new
|
|
||||||
local safe_key
|
local safe_key
|
||||||
safe_key=$(echo "$public_key" | md5sum | cut -d' ' -f1)
|
safe_key=$(echo "$public_key" | md5sum | cut -d' ' -f1)
|
||||||
local prev_ts_file="/tmp/wgctl_hs_${safe_key}"
|
local prev_ts_file="/tmp/wgctl_hs_${safe_key}"
|
||||||
local prev_ts="0"
|
local prev_ts="0"
|
||||||
[[ -f "$prev_ts_file" ]] && prev_ts=$(cat "$prev_ts_file")
|
[[ -f "$prev_ts_file" ]] && prev_ts=$(cat "$prev_ts_file")
|
||||||
[[ "$ts" == "$prev_ts" ]] && continue
|
[[ "$ts" == "$prev_ts" ]] && continue
|
||||||
|
|
||||||
|
local gap=$(( ts - ${prev_ts:-0} ))
|
||||||
echo "$ts" > "$prev_ts_file"
|
echo "$ts" > "$prev_ts_file"
|
||||||
|
(( gap < ${WG_HANDSHAKE_CHECK_TIME_SEC:-300} )) && continue
|
||||||
|
|
||||||
local ts_fmt
|
local ts_fmt
|
||||||
ts_fmt=$(fmt::datetime_short "$ts")
|
ts_fmt=$(fmt::datetime_short "$ts")
|
||||||
|
|
||||||
|
# Resolve endpoint — try wg show first, fall back to endpoint cache
|
||||||
local endpoint
|
local endpoint
|
||||||
endpoint=$(monitor::endpoint_for_key "$public_key")
|
endpoint=$(monitor::endpoint_for_key "$public_key")
|
||||||
|
|
||||||
ui::watch::wg_row "$ts_fmt" "$client_name" "${endpoint:-—}" "handshake" \
|
if [[ -z "$endpoint" ]]; then
|
||||||
"$w_client" "$w_dest"
|
endpoint=$(monitor::get_cached_endpoint "$client_name")
|
||||||
|
fi
|
||||||
|
|
||||||
|
local ep_raw ep_resolved=""
|
||||||
|
ep_raw="${endpoint:-}"
|
||||||
|
if [[ -n "$ep_raw" ]]; then
|
||||||
|
local ep_parts
|
||||||
|
ep_parts=$(resolve::endpoint_parts "$ep_raw")
|
||||||
|
ep_resolved="${ep_parts#*|}"
|
||||||
|
fi
|
||||||
|
row=$(ui::watch::wg_row "$ts_fmt" "$client_name" "$ep_raw" "handshake" \
|
||||||
|
"$ep_resolved" "$w_client" "30")
|
||||||
|
rows+=("${ts}|${row}")
|
||||||
|
|
||||||
done < <(wg show "$(config::interface)" latest-handshakes 2>/dev/null)
|
done < <(wg show "$(config::interface)" latest-handshakes 2>/dev/null)
|
||||||
}
|
|
||||||
|
|
||||||
|
# Sort by ts descending (most recent first) and print
|
||||||
|
if [[ ${#rows[@]} -gt 0 ]]; then
|
||||||
|
printf '%s\n' "${rows[@]}" | sort -t'|' -k1,1rn | while IFS= read -r entry; do
|
||||||
|
printf "%s\n" "${entry#*|}"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
}
|
||||||
# ============================================
|
# ============================================
|
||||||
# Event Tailer
|
# Event Tailer
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
@ -166,10 +160,7 @@ function cmd::watch::_poll_handshakes() {
|
||||||
function cmd::watch::_tail_events() {
|
function cmd::watch::_tail_events() {
|
||||||
local filter_name="${1:-}" filter_type="${2:-}" filter_peers="${3:-}"
|
local filter_name="${1:-}" filter_type="${2:-}" filter_peers="${3:-}"
|
||||||
local blocked_only="${4:-false}" restricted_only="${5:-false}" allowed_only="${6:-false}"
|
local blocked_only="${4:-false}" restricted_only="${5:-false}" allowed_only="${6:-false}"
|
||||||
local net_file="${7:-}" w_client="${8:-20}" w_dest="${9:-30}"
|
local w_client="${7:-20}" w_dest="${8:-18}"
|
||||||
|
|
||||||
local peer_set=()
|
|
||||||
[[ -n "$filter_peers" ]] && IFS=',' read -ra peer_set <<< "$filter_peers"
|
|
||||||
|
|
||||||
# Build ip->name map
|
# Build ip->name map
|
||||||
declare -A ip_to_name=()
|
declare -A ip_to_name=()
|
||||||
|
|
@ -181,18 +172,6 @@ function cmd::watch::_tail_events() {
|
||||||
[[ -n "$ip" && -n "$cname" ]] && ip_to_name["$ip"]="$cname"
|
[[ -n "$ip" && -n "$cname" ]] && ip_to_name["$ip"]="$cname"
|
||||||
done < <(find "$(ctx::clients)" -name "*.conf" 2>/dev/null)
|
done < <(find "$(ctx::clients)" -name "*.conf" 2>/dev/null)
|
||||||
|
|
||||||
# Load net services if not --raw
|
|
||||||
declare -A _svc_cache=()
|
|
||||||
function _resolve_dest() {
|
|
||||||
local dest_ip="${1:-}" dest_port="${2:-}" proto="${3:-}"
|
|
||||||
[[ -z "$net_file" || ! -f "$net_file" ]] && echo "" && return
|
|
||||||
local key="${dest_ip}:${dest_port}:${proto}"
|
|
||||||
if [[ -z "${_svc_cache[$key]+x}" ]]; then
|
|
||||||
_svc_cache[$key]=$(net::reverse_lookup "$dest_ip" "$dest_port" "$proto" 2>/dev/null || true)
|
|
||||||
fi
|
|
||||||
echo "${_svc_cache[$key]:-}"
|
|
||||||
}
|
|
||||||
|
|
||||||
declare -A _WATCH_LAST_FW=()
|
declare -A _WATCH_LAST_FW=()
|
||||||
declare -A _WATCH_LAST_WG=()
|
declare -A _WATCH_LAST_WG=()
|
||||||
|
|
||||||
|
|
@ -227,7 +206,6 @@ function cmd::watch::_tail_events() {
|
||||||
local client="${ip_to_name[$src_ip]:-$src_ip}"
|
local client="${ip_to_name[$src_ip]:-$src_ip}"
|
||||||
[[ -n "$filter_name" && "$client" != "$filter_name" ]] && continue
|
[[ -n "$filter_name" && "$client" != "$filter_name" ]] && continue
|
||||||
|
|
||||||
# Dedup
|
|
||||||
local fw_key="${src_ip}:${dest_ip}:${dest_port}:${proto}"
|
local fw_key="${src_ip}:${dest_ip}:${dest_port}:${proto}"
|
||||||
local now; now=$(date +%s)
|
local now; now=$(date +%s)
|
||||||
local window=30
|
local window=30
|
||||||
|
|
@ -240,11 +218,18 @@ function cmd::watch::_tail_events() {
|
||||||
local ts_fmt
|
local ts_fmt
|
||||||
ts_fmt=$(fmt::datetime_short "$(json::iso_to_ts "$ts" 2>/dev/null || echo 0)")
|
ts_fmt=$(fmt::datetime_short "$(json::iso_to_ts "$ts" 2>/dev/null || echo 0)")
|
||||||
|
|
||||||
local svc_name dest_display
|
local fw_svc_name
|
||||||
svc_name=$(_resolve_dest "$dest_ip" "$dest_port" "$proto")
|
fw_svc_name=$(resolve::service_name "$dest_ip" "$dest_port" "$proto")
|
||||||
dest_display=$(ui::logs::build_dest "$dest_ip" "$dest_port" "$proto" "$svc_name")
|
local fw_src_ep fw_src_resolved=""
|
||||||
|
fw_src_ep=$(monitor::get_cached_endpoint "$client")
|
||||||
ui::watch::fw_row "$ts_fmt" "$client" "$dest_display" "$w_client" "$w_dest"
|
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
|
else
|
||||||
$restricted_only && continue
|
$restricted_only && continue
|
||||||
|
|
@ -259,18 +244,29 @@ function cmd::watch::_tail_events() {
|
||||||
$blocked_only && [[ "$event" != "attempt" ]] && continue
|
$blocked_only && [[ "$event" != "attempt" ]] && continue
|
||||||
$allowed_only && [[ "$event" != "handshake" ]] && continue
|
$allowed_only && [[ "$event" != "handshake" ]] && continue
|
||||||
|
|
||||||
# Dedup
|
|
||||||
local wg_key="${client}:${endpoint}:${event}"
|
local wg_key="${client}:${endpoint}:${event}"
|
||||||
local now; now=$(date +%s)
|
local now; now=$(date +%s)
|
||||||
local last="${_WATCH_LAST_WG[$wg_key]:-0}"
|
local last="${_WATCH_LAST_WG[$wg_key]:-0}"
|
||||||
(( now - last < 30 )) && continue
|
|
||||||
|
local window=30
|
||||||
|
[[ "$event" == "handshake" ]] && window="${WG_HANDSHAKE_CHECK_TIME_SEC:-300}"
|
||||||
|
|
||||||
|
(( now - last < window )) && continue
|
||||||
_WATCH_LAST_WG["$wg_key"]="$now"
|
_WATCH_LAST_WG["$wg_key"]="$now"
|
||||||
|
|
||||||
local ts_fmt
|
local ts_fmt
|
||||||
ts_fmt=$(fmt::datetime_short "$(json::iso_to_ts "$ts" 2>/dev/null || echo 0)")
|
ts_fmt=$(fmt::datetime_short "$(json::iso_to_ts "$ts" 2>/dev/null || echo 0)")
|
||||||
|
|
||||||
ui::watch::wg_row "$ts_fmt" "$client" "${endpoint:-—}" "$event" \
|
# Resolve endpoint — fall back to endpoint cache if empty
|
||||||
"$w_client" "$w_dest"
|
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
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|
|
||||||
3
core.sh
3
core.sh
|
|
@ -11,9 +11,12 @@ source "${WGCTL_DIR}/core/context.sh"
|
||||||
source "${WGCTL_DIR}/core/utils.sh"
|
source "${WGCTL_DIR}/core/utils.sh"
|
||||||
source "${WGCTL_DIR}/core/module.sh"
|
source "${WGCTL_DIR}/core/module.sh"
|
||||||
source "${WGCTL_DIR}/core/command.sh"
|
source "${WGCTL_DIR}/core/command.sh"
|
||||||
|
source "${WGCTL_DIR}/core/command_mixins.sh"
|
||||||
source "${WGCTL_DIR}/core/flag.sh"
|
source "${WGCTL_DIR}/core/flag.sh"
|
||||||
source "${WGCTL_DIR}/core/json.sh"
|
source "${WGCTL_DIR}/core/json.sh"
|
||||||
source "${WGCTL_DIR}/core/ui.sh"
|
source "${WGCTL_DIR}/core/ui.sh"
|
||||||
source "${WGCTL_DIR}/core/color.sh"
|
source "${WGCTL_DIR}/core/color.sh"
|
||||||
source "${WGCTL_DIR}/core/fmt.sh"
|
source "${WGCTL_DIR}/core/fmt.sh"
|
||||||
source "${WGCTL_DIR}/core/test/test.sh"
|
source "${WGCTL_DIR}/core/test/test.sh"
|
||||||
|
|
||||||
|
command::_load_mixins
|
||||||
|
|
|
||||||
BIN
core/__pycache__/json_helper.cpython-311.pyc
Normal file
BIN
core/__pycache__/json_helper.cpython-311.pyc
Normal file
Binary file not shown.
|
|
@ -8,6 +8,7 @@ declare -A _LOADED_COMMANDS=()
|
||||||
|
|
||||||
readonly _COMMAND_NAMESPACE="cmd"
|
readonly _COMMAND_NAMESPACE="cmd"
|
||||||
readonly _COMMAND_AUTO_LOAD_HOOK="on_load"
|
readonly _COMMAND_AUTO_LOAD_HOOK="on_load"
|
||||||
|
_CURRENT_LOADING_CMD=""
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Helpers
|
# Helpers
|
||||||
|
|
@ -36,14 +37,65 @@ function command::exists() { command::has_function "$1" run; }
|
||||||
# Runner
|
# 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() {
|
function command::run() {
|
||||||
local cmd="$1"
|
local cmd="$1"
|
||||||
shift
|
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
|
local fn
|
||||||
fn=$(command::fn "$cmd" run)
|
fn=$(command::fn "$cmd" run)
|
||||||
core::call_function "$fn" "$@"
|
core::call_function "$fn" ${args[@]+"${args[@]}"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function core::call_function() {
|
function core::call_function() {
|
||||||
local fn="$1"
|
local fn="$1"
|
||||||
shift
|
shift
|
||||||
|
|
@ -70,7 +122,9 @@ function load_command() {
|
||||||
source "$path"
|
source "$path"
|
||||||
_LOADED_COMMANDS["$name"]=1
|
_LOADED_COMMANDS["$name"]=1
|
||||||
|
|
||||||
|
_CURRENT_LOADING_CMD="$name"
|
||||||
core::call_if_exists "$(command::fn "$name" on_load)"
|
core::call_if_exists "$(command::fn "$name" on_load)"
|
||||||
|
_CURRENT_LOADING_CMD=""
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
|
||||||
195
core/command_mixins.sh
Normal file
195
core/command_mixins.sh
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
#!/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
|
||||||
|
}
|
||||||
108
core/context.sh
108
core/context.sh
|
|
@ -10,76 +10,92 @@ _CTX_CORE="${_CTX_ROOT}/core"
|
||||||
_CTX_MODULES="${_CTX_ROOT}/modules"
|
_CTX_MODULES="${_CTX_ROOT}/modules"
|
||||||
_CTX_COMMANDS="${_CTX_ROOT}/commands"
|
_CTX_COMMANDS="${_CTX_ROOT}/commands"
|
||||||
_CTX_CLIENTS="${_CTX_WG}/clients"
|
_CTX_CLIENTS="${_CTX_WG}/clients"
|
||||||
_CTX_DATA="${_CTX_WG}/.wgctl"
|
|
||||||
|
|
||||||
# ============================================
|
# ── Directory layout ──────────────────────────────────
|
||||||
# Artifacts
|
# .wgctl/
|
||||||
# ============================================
|
# config/ ← wgctl.json, display.json
|
||||||
|
# data/ ← all persistent data (rules, identities, etc.)
|
||||||
|
# daemon/ ← runtime files (logs, caches)
|
||||||
|
|
||||||
|
_CTX_WGCTL="${_CTX_WG}/.wgctl"
|
||||||
|
_CTX_CONFIG="${_CTX_WGCTL}/config"
|
||||||
|
_CTX_DATA="${_CTX_WGCTL}/data"
|
||||||
|
_CTX_DAEMON="${_CTX_WGCTL}/daemon"
|
||||||
|
|
||||||
|
# ── Data subdirs ──────────────────────────────────────
|
||||||
_CTX_RULES="${_CTX_DATA}/rules"
|
_CTX_RULES="${_CTX_DATA}/rules"
|
||||||
_CTX_RULES_BASE="${_CTX_RULES}/base"
|
_CTX_RULES_BASE="${_CTX_RULES}/base"
|
||||||
_CTX_GROUPS="${_CTX_DATA}/groups"
|
_CTX_GROUPS="${_CTX_DATA}/groups"
|
||||||
_CTX_BLOCKS="${_CTX_DATA}/blocks"
|
_CTX_BLOCKS="${_CTX_DATA}/blocks"
|
||||||
_CTX_META="${_CTX_DATA}/meta"
|
_CTX_META="${_CTX_DATA}/meta"
|
||||||
_CTX_IDENTITY="${_CTX_DATA}/identities"
|
_CTX_IDENTITY="${_CTX_DATA}/identities"
|
||||||
_CTX_DAEMON="${_CTX_DATA}/daemon"
|
_CTX_PEER_HISTORY="${_CTX_DATA}/peer-history"
|
||||||
_CTX_NET="${_CTX_DATA}/services.json"
|
|
||||||
|
|
||||||
|
# ── Data files ────────────────────────────────────────
|
||||||
|
_CTX_NET="${_CTX_DATA}/services.json"
|
||||||
|
_CTX_HOSTS="${_CTX_DATA}/hosts.json"
|
||||||
|
_CTX_SUBNETS="${_CTX_DATA}/subnets.json"
|
||||||
|
_CTX_POLICIES="${_CTX_DATA}/policies.json"
|
||||||
|
|
||||||
|
# ── Config files ──────────────────────────────────────
|
||||||
|
_CTX_CONFIG_FILE="${_CTX_CONFIG}/wgctl.json"
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Accessors
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
function ctx::root() { echo "$_CTX_ROOT"; }
|
function ctx::root() { echo "$_CTX_ROOT"; }
|
||||||
function ctx::core() { echo "$_CTX_CORE"; }
|
function ctx::core() { echo "$_CTX_CORE"; }
|
||||||
function ctx::modules() { echo "$_CTX_MODULES"; }
|
function ctx::modules() { echo "$_CTX_MODULES"; }
|
||||||
function ctx::commands() { echo "$_CTX_COMMANDS"; }
|
function ctx::commands() { echo "$_CTX_COMMANDS"; }
|
||||||
function ctx::blocks() { echo "$_CTX_BLOCKS"; }
|
function ctx::wg() { echo "$_CTX_WG"; }
|
||||||
function ctx::groups() { echo "$_CTX_GROUPS"; }
|
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::rules() { echo "$_CTX_RULES"; }
|
function ctx::rules() { echo "$_CTX_RULES"; }
|
||||||
function ctx::rules::base() { echo "$_CTX_RULES_BASE"; }
|
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::groups() { echo "$_CTX_GROUPS"; }
|
||||||
function ctx::blocks() { echo "$_CTX_BLOCKS"; }
|
function ctx::blocks() { echo "$_CTX_BLOCKS"; }
|
||||||
function ctx::meta() { echo "$_CTX_META"; }
|
function ctx::meta() { echo "$_CTX_META"; }
|
||||||
function ctx::daemon() { echo "$_CTX_DAEMON"; }
|
function ctx::identities() { echo "$_CTX_IDENTITY"; }
|
||||||
|
function ctx::peer_history() { echo "$_CTX_PEER_HISTORY"; }
|
||||||
|
|
||||||
|
# Data files
|
||||||
function ctx::net() { echo "$_CTX_NET"; }
|
function ctx::net() { echo "$_CTX_NET"; }
|
||||||
function ctx::identities() { echo "${_CTX_IDENTITY}"; }
|
function ctx::hosts() { echo "$_CTX_HOSTS"; }
|
||||||
function ctx::subnets() { echo "${_CTX_DATA}/subnets.json"; }
|
function ctx::subnets() { echo "$_CTX_SUBNETS"; }
|
||||||
function ctx::events_log() { echo "$(ctx::daemon)/events.log"; }
|
function ctx::policies() { echo "$_CTX_POLICIES"; }
|
||||||
function ctx::fw_events_log() { echo "$(ctx::daemon)/fw_events.log"; }
|
|
||||||
|
# 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::json_helper() { echo "${_CTX_CORE}/json_helper.py"; }
|
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
|
# Path Helpers
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
function ctx::client::path() {
|
function ctx::client::path() { local IFS="/"; echo "$_CTX_CLIENTS/$*"; }
|
||||||
local IFS="/"
|
function ctx::meta::path() { local IFS="/"; echo "$_CTX_META/$*"; }
|
||||||
echo "$_CTX_CLIENTS/$*"
|
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::meta::path() {
|
function ctx::rule::path() { local IFS="/"; echo "$_CTX_RULES/$*"; }
|
||||||
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/$*"
|
|
||||||
}
|
|
||||||
14
core/fmt.sh
14
core/fmt.sh
|
|
@ -79,3 +79,17 @@ function fmt::set_date_format() {
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fmt::bytes() {
|
||||||
|
local bytes="${1:-0}"
|
||||||
|
if (( bytes == 0 )); then
|
||||||
|
printf "—"
|
||||||
|
elif (( bytes >= 1073741824 )); then
|
||||||
|
printf "%dGB" $(( bytes / 1073741824 ))
|
||||||
|
elif (( bytes >= 1048576 )); then
|
||||||
|
printf "%dMB" $(( bytes / 1048576 ))
|
||||||
|
elif (( bytes >= 1024 )); then
|
||||||
|
printf "%dKB" $(( bytes / 1024 ))
|
||||||
|
else
|
||||||
|
printf "%dB" "$bytes"
|
||||||
|
fi
|
||||||
|
}
|
||||||
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::filter_values() { python3 "$JSON_HELPER" filter_values "$@" </dev/null; }
|
||||||
function json::last_event() { python3 "$JSON_HELPER" last_event "$@" </dev/null; }
|
function json::last_event() { python3 "$JSON_HELPER" last_event "$@" </dev/null; }
|
||||||
function json::events_for() { python3 "$JSON_HELPER" events_for "$@" </dev/null; }
|
function json::events_for() { python3 "$JSON_HELPER" events_for "$@" </dev/null; }
|
||||||
function json::fw_events() { WGCTL_DATETIME_FMT="$FMT_DATETIME_SHORT" python3 "$JSON_HELPER" fw_events "$@" </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 "$@" </dev/null; }
|
function json::wg_events() { WGCTL_DATETIME_FMT="$FMT_DATETIME_SHORT" python3 "$JSON_HELPER" wg_events "$@" "$(ctx::endpoint_cache)" </dev/null; }
|
||||||
function json::format_fw_event() { echo "$1" | python3 "$JSON_HELPER" format_fw_event "$2"; }
|
function json::format_fw_event() { echo "$1" | python3 "$JSON_HELPER" format_fw_event "$2"; }
|
||||||
function json::format_wg_event() { echo "$1" | python3 "$JSON_HELPER" format_wg_event; }
|
function json::format_wg_event() { echo "$1" | python3 "$JSON_HELPER" format_wg_event; }
|
||||||
function json::remove_events() { python3 "$JSON_HELPER" remove_events "$@" </dev/null; }
|
function json::remove_events() { python3 "$JSON_HELPER" remove_events "$@" </dev/null; }
|
||||||
|
|
@ -110,6 +110,55 @@ function json::subnet_policy() { python3 "$JSON_HELPER" subnet_policy
|
||||||
function json::activity_aggregate() { python3 "$JSON_HELPER" activity_aggregate "$@" </dev/null; }
|
function json::activity_aggregate() { python3 "$JSON_HELPER" activity_aggregate "$@" </dev/null; }
|
||||||
function json::iso_to_ts() { python3 "$JSON_HELPER" iso_to_ts "$@" </dev/null; }
|
function json::iso_to_ts() { python3 "$JSON_HELPER" iso_to_ts "$@" </dev/null; }
|
||||||
|
|
||||||
|
# Hosts Resolution
|
||||||
|
function json::hosts_list() { python3 "$JSON_HELPER" hosts_list "$@" </dev/null; }
|
||||||
|
function json::hosts_show() { python3 "$JSON_HELPER" hosts_show "$@" </dev/null; }
|
||||||
|
function json::hosts_add() { python3 "$JSON_HELPER" hosts_add "$@" </dev/null; }
|
||||||
|
function json::hosts_remove() { python3 "$JSON_HELPER" hosts_remove "$@" </dev/null; }
|
||||||
|
function json::hosts_exists() { python3 "$JSON_HELPER" hosts_exists "$@" </dev/null; }
|
||||||
|
function json::hosts_lookup() { python3 "$JSON_HELPER" hosts_lookup "$@" </dev/null; }
|
||||||
|
|
||||||
|
# Peer History
|
||||||
|
function json::peer_history_lookup() { python3 "$JSON_HELPER" peer_history_lookup "$(ctx::data)/peer-history" "$1" </dev/null; }
|
||||||
|
|
||||||
|
function json::clean_handshakes() { python3 "$JSON_HELPER" clean_handshakes "$@" </dev/null; }
|
||||||
|
function json::batch_resolve() { python3 "$JSON_HELPER" batch_resolve "$(ctx::hosts)" "$(ctx::net)" "$@" </dev/null; }
|
||||||
|
|
||||||
|
# JSON Envelopes
|
||||||
|
function json::envelope() {
|
||||||
|
local command="${1:-}" count="${2:-0}"
|
||||||
|
# Reads JSON array from stdin, wraps in envelope
|
||||||
|
local data
|
||||||
|
data=$(cat)
|
||||||
|
local ts
|
||||||
|
ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
printf '{"ok":true,"command":"%s","data":%s,"meta":{"count":%s,"generated_at":"%s"}}\n' \
|
||||||
|
"$command" "$data" "$count" "$ts"
|
||||||
|
}
|
||||||
|
|
||||||
|
function json::error_envelope() {
|
||||||
|
local command="${1:-}" error="${2:-}"
|
||||||
|
local ts
|
||||||
|
ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
printf '{"ok":false,"command":"%s","error":"%s","meta":{"generated_at":"%s"}}\n' \
|
||||||
|
"$command" "$error" "$ts"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Config
|
||||||
|
function json::config_load() { python3 "$JSON_HELPER" config_load "$1" </dev/null; }
|
||||||
|
|
||||||
|
function json::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() {
|
function json::peer_transfer() {
|
||||||
ACTIVITY_TOTAL_LOW="$(config::activity_total_low)" \
|
ACTIVITY_TOTAL_LOW="$(config::activity_total_low)" \
|
||||||
ACTIVITY_TOTAL_MED="$(config::activity_total_med)" \
|
ACTIVITY_TOTAL_MED="$(config::activity_total_med)" \
|
||||||
|
|
@ -123,3 +172,8 @@ function json::peer_transfer_delta() {
|
||||||
python3 "$JSON_HELPER" peer_transfer_delta "$@" </dev/null
|
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; }
|
||||||
2774
core/json_helper.py
2774
core/json_helper.py
File diff suppressed because it is too large
Load diff
0
core/lib/__init__.py
Normal file
0
core/lib/__init__.py
Normal file
BIN
core/lib/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
core/lib/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
core/lib/__pycache__/accept_events.cpython-311.pyc
Normal file
BIN
core/lib/__pycache__/accept_events.cpython-311.pyc
Normal file
Binary file not shown.
BIN
core/lib/__pycache__/activity.cpython-311.pyc
Normal file
BIN
core/lib/__pycache__/activity.cpython-311.pyc
Normal file
Binary file not shown.
BIN
core/lib/__pycache__/block_history.cpython-311.pyc
Normal file
BIN
core/lib/__pycache__/block_history.cpython-311.pyc
Normal file
Binary file not shown.
BIN
core/lib/__pycache__/events.cpython-311.pyc
Normal file
BIN
core/lib/__pycache__/events.cpython-311.pyc
Normal file
Binary file not shown.
BIN
core/lib/__pycache__/importer.cpython-311.pyc
Normal file
BIN
core/lib/__pycache__/importer.cpython-311.pyc
Normal file
Binary file not shown.
BIN
core/lib/__pycache__/peers.cpython-311.pyc
Normal file
BIN
core/lib/__pycache__/peers.cpython-311.pyc
Normal file
Binary file not shown.
BIN
core/lib/__pycache__/util.cpython-311.pyc
Normal file
BIN
core/lib/__pycache__/util.cpython-311.pyc
Normal file
Binary file not shown.
260
core/lib/accept_events.py
Normal file
260
core/lib/accept_events.py
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
"""
|
||||||
|
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
|
||||||
159
core/lib/activity.py
Normal file
159
core/lib/activity.py
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
"""
|
||||||
|
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}")
|
||||||
103
core/lib/block_history.py
Normal file
103
core/lib/block_history.py
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
# 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))
|
||||||
674
core/lib/events.py
Normal file
674
core/lib/events.py
Normal file
|
|
@ -0,0 +1,674 @@
|
||||||
|
"""
|
||||||
|
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)
|
||||||
195
core/lib/importer.py
Normal file
195
core/lib/importer.py
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
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 '')
|
||||||
180
core/lib/peers.py
Normal file
180
core/lib/peers.py
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
"""
|
||||||
|
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
Normal file
255
core/lib/util.py
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
"""
|
||||||
|
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
|
||||||
21
core/mixins/json_output.mixin.sh
Normal file
21
core/mixins/json_output.mixin.sh
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
#!/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" ]]; }
|
||||||
21
core/mixins/no_color.mixin.sh
Normal file
21
core/mixins/no_color.mixin.sh
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
#!/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" ]]; }
|
||||||
|
|
@ -3,6 +3,12 @@
|
||||||
UI_ROW_WIDTH=${UI_ROW_WIDTH:-20}
|
UI_ROW_WIDTH=${UI_ROW_WIDTH:-20}
|
||||||
UI_SECTION_WIDTH=${UI_SECTION_WIDTH:-44}
|
UI_SECTION_WIDTH=${UI_SECTION_WIDTH:-44}
|
||||||
|
|
||||||
|
# UTF-8 multi-byte character extras (bash ${#} counts bytes, not chars)
|
||||||
|
# extra = byte_length - visible_char_length
|
||||||
|
_UI_EMDASH_EXTRA=2 # — (em dash) 3 bytes, 1 visible
|
||||||
|
_UI_ARROW_EXTRA=2 # → (right arrow) 3 bytes, 1 visible
|
||||||
|
_UI_BULLET_EXTRA=1 # · (middle dot) 2 bytes, 1 visible
|
||||||
|
|
||||||
function ui::row() {
|
function ui::row() {
|
||||||
local label="$1" value="$2" width="${3:-$UI_ROW_WIDTH}"
|
local label="$1" value="$2" width="${3:-$UI_ROW_WIDTH}"
|
||||||
printf " %-${width}s %s\n" "${label}:" "$value"
|
printf " %-${width}s %s\n" "${label}:" "$value"
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,6 @@
|
||||||
"desktop-roboclean": "46.189.215.231",
|
"desktop-roboclean": "46.189.215.231",
|
||||||
"laptop-nuno": "94.63.0.129",
|
"laptop-nuno": "94.63.0.129",
|
||||||
"phone-luis": "176.223.61.15",
|
"phone-luis": "176.223.61.15",
|
||||||
"phone-helena-2": "148.69.202.127",
|
"phone-helena-2": "148.69.193.234",
|
||||||
"desktop-zephyr": "86.120.152.74"
|
"desktop-zephyr": "86.120.152.74"
|
||||||
}
|
}
|
||||||
33
daemon/wgctl-conntrack/cmd/root.go
Normal file
33
daemon/wgctl-conntrack/cmd/root.go
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
42
daemon/wgctl-conntrack/config/config.go
Normal file
42
daemon/wgctl-conntrack/config/config.go
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
29
daemon/wgctl-conntrack/conntrack/event.go
Normal file
29
daemon/wgctl-conntrack/conntrack/event.go
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
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"`
|
||||||
|
}
|
||||||
44
daemon/wgctl-conntrack/conntrack/filter.go
Normal file
44
daemon/wgctl-conntrack/conntrack/filter.go
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
117
daemon/wgctl-conntrack/conntrack/subscriber.go
Normal file
117
daemon/wgctl-conntrack/conntrack/subscriber.go
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
16
daemon/wgctl-conntrack/go.mod
Normal file
16
daemon/wgctl-conntrack/go.mod
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
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
|
||||||
|
)
|
||||||
20
daemon/wgctl-conntrack/go.sum
Normal file
20
daemon/wgctl-conntrack/go.sum
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
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=
|
||||||
BIN
daemon/wgctl-conntrack/go1.21.13.linux-amd64.tar.gz
Normal file
BIN
daemon/wgctl-conntrack/go1.21.13.linux-amd64.tar.gz
Normal file
Binary file not shown.
71
daemon/wgctl-conntrack/main.go
Normal file
71
daemon/wgctl-conntrack/main.go
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
93
daemon/wgctl-conntrack/resolver/peers.go
Normal file
93
daemon/wgctl-conntrack/resolver/peers.go
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
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 ""
|
||||||
|
}
|
||||||
93
daemon/wgctl-conntrack/resolver/services.go
Normal file
93
daemon/wgctl-conntrack/resolver/services.go
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
daemon/wgctl-conntrack/wgctl-conntrack
Executable file
BIN
daemon/wgctl-conntrack/wgctl-conntrack
Executable file
Binary file not shown.
21
daemon/wgctl-conntrack/wgctl-conntrack.service
Normal file
21
daemon/wgctl-conntrack/wgctl-conntrack.service
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
[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
|
||||||
47
daemon/wgctl-conntrack/writer/log.go
Normal file
47
daemon/wgctl-conntrack/writer/log.go
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
385
daemon/wgctl-monitor.py
Executable file
385
daemon/wgctl-monitor.py
Executable file
|
|
@ -0,0 +1,385 @@
|
||||||
|
#!/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]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
ExecStart=/usr/bin/python3 /etc/wireguard/.wgctl/daemon/wgctl-monitor.py
|
ExecStart=/usr/bin/python3 /etc/wireguard/wgctl/daemon/wgctl-monitor.py
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
Environment=WG_INTERFACE=eth0
|
Environment=WG_INTERFACE=eth0
|
||||||
|
|
|
||||||
|
|
@ -8,31 +8,33 @@ function config::on_load() {
|
||||||
config::_init_defaults
|
config::_init_defaults
|
||||||
config::load
|
config::load
|
||||||
config::validate
|
config::validate
|
||||||
fmt::set_date_format "${_FMT_DATE_FORMAT:-iso}"
|
fmt::set_date_format "${_FMT_DATE_FORMAT:-eu}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Defaults
|
# Defaults
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# Activity thresholds
|
|
||||||
declare -g _ACTIVITY_TOTAL_LOW_BYTES="${ACTIVITY_TOTAL_LOW_BYTES:-1000000}"
|
declare -g _ACTIVITY_TOTAL_LOW_BYTES="${ACTIVITY_TOTAL_LOW_BYTES:-1000000}"
|
||||||
declare -g _ACTIVITY_TOTAL_MED_BYTES="${ACTIVITY_TOTAL_MED_BYTES:-10000000}"
|
declare -g _ACTIVITY_TOTAL_MED_BYTES="${ACTIVITY_TOTAL_MED_BYTES:-10000000}"
|
||||||
declare -g _ACTIVITY_TOTAL_HIGH_BYTES="${ACTIVITY_TOTAL_HIGH_BYTES:-100000000}"
|
declare -g _ACTIVITY_TOTAL_HIGH_BYTES="${ACTIVITY_TOTAL_HIGH_BYTES:-100000000}"
|
||||||
|
|
||||||
declare -g _ACTIVITY_CURRENT_LOW_BYTES="${ACTIVITY_CURRENT_LOW_BYTES:-1000000}"
|
declare -g _ACTIVITY_CURRENT_LOW_BYTES="${ACTIVITY_CURRENT_LOW_BYTES:-1000000}"
|
||||||
declare -g _ACTIVITY_CURRENT_MED_BYTES="${ACTIVITY_CURRENT_MED_BYTES:-10000000}"
|
declare -g _ACTIVITY_CURRENT_MED_BYTES="${ACTIVITY_CURRENT_MED_BYTES:-10000000}"
|
||||||
declare -g _ACTIVITY_CURRENT_HIGH_BYTES="${ACTIVITY_CURRENT_HIGH_BYTES:-100000000}"
|
declare -g _ACTIVITY_CURRENT_HIGH_BYTES="${ACTIVITY_CURRENT_HIGH_BYTES:-100000000}"
|
||||||
|
|
||||||
|
declare -gA _COMMAND_DEFAULTS=()
|
||||||
|
declare -gA _COMMAND_ALIASES=()
|
||||||
|
|
||||||
function config::_init_defaults() {
|
function config::_init_defaults() {
|
||||||
_WG_INTERFACE="${WG_INTERFACE:-wg0}"
|
_WG_INTERFACE="wg0"
|
||||||
_WG_DNS="${WG_DNS:-10.0.0.103}"
|
_WG_DNS="10.0.0.103"
|
||||||
_WG_LAN="${WG_LAN:-10.0.0.0/24}"
|
_WG_DNS_FALLBACK=""
|
||||||
_WG_SUBNET="${WG_SUBNET:-10.1.0.0/16}"
|
_WG_LAN="10.0.0.0/24"
|
||||||
_WG_PORT="${WG_PORT:-51820}"
|
_WG_SUBNET="10.1.0.0/16"
|
||||||
_WG_ENDPOINT="${WG_ENDPOINT:-}"
|
_WG_PORT="51820"
|
||||||
_WG_HANDSHAKE_CHECK_TIME_SEC="${WG_HANDSHAKE_CHECK_TIME_SEC:-180}"
|
_WG_ENDPOINT=""
|
||||||
|
_WG_HANDSHAKE_CHECK_TIME_SEC="300"
|
||||||
|
_FMT_DATE_FORMAT="eu"
|
||||||
|
|
||||||
# Derived
|
# Derived
|
||||||
_WG_CONFIG="$(ctx::wg)/${_WG_INTERFACE}.conf"
|
_WG_CONFIG="$(ctx::wg)/${_WG_INTERFACE}.conf"
|
||||||
|
|
@ -43,13 +45,97 @@ function config::_init_defaults() {
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Validation
|
# 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)
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
function config::validate() {
|
function config::validate() {
|
||||||
local errors=()
|
local errors=()
|
||||||
|
|
||||||
# Server key and config files
|
|
||||||
if [[ ! -f "$_WG_SERVER_PUBLIC_KEY_FILE" ]]; then
|
if [[ ! -f "$_WG_SERVER_PUBLIC_KEY_FILE" ]]; then
|
||||||
errors+=("Server public key not found: ${_WG_SERVER_PUBLIC_KEY_FILE}")
|
errors+=("Server public key not found: ${_WG_SERVER_PUBLIC_KEY_FILE}")
|
||||||
fi
|
fi
|
||||||
|
|
@ -60,7 +146,6 @@ function config::validate() {
|
||||||
errors+=("WireGuard config not found: ${_WG_CONFIG}")
|
errors+=("WireGuard config not found: ${_WG_CONFIG}")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Required config values
|
|
||||||
local endpoint
|
local endpoint
|
||||||
endpoint=$(config::endpoint)
|
endpoint=$(config::endpoint)
|
||||||
if [[ -z "$endpoint" ]]; then
|
if [[ -z "$endpoint" ]]; then
|
||||||
|
|
@ -91,7 +176,6 @@ function config::validate() {
|
||||||
errors+=("WG_SUBNET is not set — required for IP allocation")
|
errors+=("WG_SUBNET is not set — required for IP allocation")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Warn-only
|
|
||||||
local lan
|
local lan
|
||||||
lan=$(config::lan)
|
lan=$(config::lan)
|
||||||
if [[ -z "$lan" ]]; then
|
if [[ -z "$lan" ]]; then
|
||||||
|
|
@ -103,7 +187,7 @@ function config::validate() {
|
||||||
for err in "${errors[@]}"; do
|
for err in "${errors[@]}"; do
|
||||||
printf " ✗ %s\n" "$err" >&2
|
printf " ✗ %s\n" "$err" >&2
|
||||||
done
|
done
|
||||||
printf "\n Edit /etc/wireguard/.wgctl/wgctl.conf to fix these issues.\n\n" >&2
|
printf "\n Edit %s to fix these issues.\n\n" "$(ctx::config_file)" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -111,49 +195,14 @@ function config::validate() {
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Load overrides from .wgctl/wgctl.conf
|
# Accessors (unchanged)
|
||||||
# ============================================
|
|
||||||
|
|
||||||
function config::load() {
|
|
||||||
local conf_file
|
|
||||||
conf_file="$(ctx::data)/wgctl.conf"
|
|
||||||
[[ ! -f "$conf_file" ]] && return 0
|
|
||||||
while IFS='=' read -r key value || [[ -n "$key" ]]; do
|
|
||||||
[[ "$key" =~ ^[[:space:]]*# ]] && continue
|
|
||||||
[[ -z "${key// }" ]] && continue
|
|
||||||
key="${key// /}"
|
|
||||||
value="${value// /}"
|
|
||||||
case "$key" in
|
|
||||||
WG_INTERFACE) _WG_INTERFACE="$value" ;;
|
|
||||||
WG_ENDPOINT) _WG_ENDPOINT="$value" ;;
|
|
||||||
WG_DNS) _WG_DNS="$value" ;;
|
|
||||||
WG_PORT) _WG_PORT="$value" ;;
|
|
||||||
WG_SUBNET) _WG_SUBNET="$value" ;;
|
|
||||||
WG_LAN) _WG_LAN="$value" ;;
|
|
||||||
WG_HANDSHAKE_CHECK_TIME_SEC) _WG_HANDSHAKE_CHECK_TIME_SEC="$value" ;;
|
|
||||||
ACTIVITY_LOW_BYTES) _ACTIVITY_LOW_BYTES="$value" ;;
|
|
||||||
ACTIVITY_MED_BYTES) _ACTIVITY_MED_BYTES="$value" ;;
|
|
||||||
ACTIVITY_HIGH_BYTES) _ACTIVITY_HIGH_BYTES="$value" ;;
|
|
||||||
DATE_FORMAT)
|
|
||||||
_FMT_DATE_FORMAT="$value"
|
|
||||||
fmt::set_date_format "$value"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done < "$conf_file"
|
|
||||||
|
|
||||||
# Recompute derived values after overrides
|
|
||||||
_WG_CONFIG="$(ctx::wg)/${_WG_INTERFACE}.conf"
|
|
||||||
_WG_TUNNEL_SPLIT="${_WG_SUBNET}, ${_WG_LAN}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# Accessors
|
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
function config::interface() { echo "$_WG_INTERFACE"; }
|
function config::interface() { echo "$_WG_INTERFACE"; }
|
||||||
function config::config_file() { echo "$_WG_CONFIG"; }
|
function config::config_file() { echo "$_WG_CONFIG"; }
|
||||||
function config::endpoint() { echo "$_WG_ENDPOINT"; }
|
function config::endpoint() { echo "$_WG_ENDPOINT"; }
|
||||||
function config::dns() { echo "$_WG_DNS"; }
|
function config::dns() { echo "$_WG_DNS"; }
|
||||||
|
function config::dns_fallback() { echo "${_WG_DNS_FALLBACK:-}"; }
|
||||||
function config::port() { echo "$_WG_PORT"; }
|
function config::port() { echo "$_WG_PORT"; }
|
||||||
function config::subnet() { echo "$_WG_SUBNET"; }
|
function config::subnet() { echo "$_WG_SUBNET"; }
|
||||||
function config::lan() { echo "$_WG_LAN"; }
|
function config::lan() { echo "$_WG_LAN"; }
|
||||||
|
|
@ -163,13 +212,13 @@ function config::handshake_time_sec() { echo "$_WG_HANDSHAKE_CHECK_TIME_SEC"
|
||||||
function config::activity_total_low() { echo "$_ACTIVITY_TOTAL_LOW_BYTES"; }
|
function config::activity_total_low() { echo "$_ACTIVITY_TOTAL_LOW_BYTES"; }
|
||||||
function config::activity_total_med() { echo "$_ACTIVITY_TOTAL_MED_BYTES"; }
|
function config::activity_total_med() { echo "$_ACTIVITY_TOTAL_MED_BYTES"; }
|
||||||
function config::activity_total_high() { echo "$_ACTIVITY_TOTAL_HIGH_BYTES"; }
|
function config::activity_total_high() { echo "$_ACTIVITY_TOTAL_HIGH_BYTES"; }
|
||||||
function config::activity_current_low() { echo "$_ACTIVITY_TOTAL_LOW_BYTES"; }
|
function config::activity_current_low() { echo "$_ACTIVITY_CURRENT_LOW_BYTES"; }
|
||||||
function config::activity_current_med() { echo "$_ACTIVITY_TOTAL_MED_BYTES"; }
|
function config::activity_current_med() { echo "$_ACTIVITY_CURRENT_MED_BYTES"; }
|
||||||
function config::activity_current_high() { echo "$_ACTIVITY_TOTAL_HIGH_BYTES"; }
|
function config::activity_current_high() { echo "$_ACTIVITY_CURRENT_HIGH_BYTES"; }
|
||||||
function config::server_public_key() { cat "$_WG_SERVER_PUBLIC_KEY_FILE"; }
|
function config::server_public_key() { cat "$_WG_SERVER_PUBLIC_KEY_FILE"; }
|
||||||
|
|
||||||
function config::allowed_ips_for() {
|
function config::allowed_ips_for() {
|
||||||
local tunnel="${2:-split}"
|
local tunnel="${1:-split}"
|
||||||
case "$tunnel" in
|
case "$tunnel" in
|
||||||
full) echo "$_WG_TUNNEL_FULL" ;;
|
full) echo "$_WG_TUNNEL_FULL" ;;
|
||||||
split) echo "$_WG_TUNNEL_SPLIT" ;;
|
split) echo "$_WG_TUNNEL_SPLIT" ;;
|
||||||
|
|
@ -179,3 +228,13 @@ function config::allowed_ips_for() {
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function config::dns_string() {
|
||||||
|
local fallback
|
||||||
|
fallback=$(config::dns_fallback)
|
||||||
|
if [[ -n "$fallback" ]]; then
|
||||||
|
echo "$(config::dns), ${fallback}"
|
||||||
|
else
|
||||||
|
echo "$(config::dns)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
72
modules/display.module.sh
Normal file
72
modules/display.module.sh
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
#!/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
|
||||||
|
}
|
||||||
22
modules/hosts.module.sh
Normal file
22
modules/hosts.module.sh
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
#!/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() {
|
function monitor::get_cached_endpoint() {
|
||||||
local client="$1"
|
local client="${1:-}"
|
||||||
json::get "$ENDPOINT_CACHE" "$client"
|
json::get "$ENDPOINT_CACHE" "$client"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -138,3 +138,43 @@ function monitor::restart() {
|
||||||
function monitor::is_running() {
|
function monitor::is_running() {
|
||||||
systemctl is-active --quiet "$MONITOR_SERVICE"
|
systemctl is-active --quiet "$MONITOR_SERVICE"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function monitor::live() {
|
||||||
|
local filter_name="${1:-}" filter_type="${2:-}" filter_peers="${3:-}"
|
||||||
|
local blocked_only="${4:-false}" restricted_only="${5:-false}" allowed_only="${6:-false}"
|
||||||
|
local raw="${7:-false}"
|
||||||
|
|
||||||
|
[[ "$raw" == "true" ]] && _WGCTL_RAW=true
|
||||||
|
|
||||||
|
rm -f /tmp/wgctl_hs_* /tmp/wgctl_attempt_* 2>/dev/null || true
|
||||||
|
|
||||||
|
local w_client=20 w_dest=18
|
||||||
|
while IFS= read -r conf; do
|
||||||
|
local name
|
||||||
|
name=$(basename "$conf" .conf)
|
||||||
|
(( ${#name} > w_client )) && w_client=${#name}
|
||||||
|
done < <(find "$(ctx::clients)" -name "*.conf" 2>/dev/null)
|
||||||
|
(( w_client += 2 ))
|
||||||
|
|
||||||
|
if ! $blocked_only && ! $restricted_only; then
|
||||||
|
(
|
||||||
|
while true; do
|
||||||
|
cmd::watch::_poll_handshakes \
|
||||||
|
"$filter_name" "$filter_type" "$filter_peers" "$w_client" "$w_dest"
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
) &
|
||||||
|
local poller_pid=$!
|
||||||
|
fi
|
||||||
|
|
||||||
|
cmd::watch::_tail_events \
|
||||||
|
"$filter_name" "$filter_type" "$filter_peers" \
|
||||||
|
"$blocked_only" "$restricted_only" "$allowed_only" \
|
||||||
|
"$w_client" "$w_dest" &
|
||||||
|
local tailer_pid=$!
|
||||||
|
|
||||||
|
trap "kill $tailer_pid ${poller_pid:-} 2>/dev/null; \
|
||||||
|
rm -f /tmp/wgctl_hs_* /tmp/wgctl_attempt_*; printf '\n'; exit 0" INT TERM
|
||||||
|
|
||||||
|
wait
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,21 +54,6 @@ function net::annotate() {
|
||||||
[[ -n "$ann" ]] && echo "${ann}" || echo ""
|
[[ -n "$ann" ]] && echo "${ann}" || echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
# function net::print_entry() {
|
|
||||||
# local sign="${1:-}" entry="${2:-}" indent="${3:-6}"
|
|
||||||
|
|
||||||
# local ann
|
|
||||||
# ann=$(net::annotate "$entry")
|
|
||||||
|
|
||||||
# local color
|
|
||||||
# [[ "$sign" == "+" ]] && color="\033[0;32m" || color="\033[0;31m"
|
|
||||||
|
|
||||||
# local spaces
|
|
||||||
# spaces=$(printf '%*s' "$indent" '')
|
|
||||||
# printf "%s%b%s\033[0m %s\033[0;37m%s\033[0m\n" \
|
|
||||||
# "$spaces" "$color" "$sign" "$entry" "${ann:+ → ${ann}}"
|
|
||||||
# }
|
|
||||||
|
|
||||||
function net::print_entry() {
|
function net::print_entry() {
|
||||||
local sign="${1:-}" entry="${2:-}" indent="${3:-6}"
|
local sign="${1:-}" entry="${2:-}" indent="${3:-6}"
|
||||||
local ann
|
local ann
|
||||||
|
|
@ -100,3 +85,26 @@ function net::print_dns_redirect_full() {
|
||||||
printf " \033[0;36m↺\033[0m Redirect all DNS → %s\033[0;37m%s\033[0m\n" \
|
printf " \033[0;36m↺\033[0m Redirect all DNS → %s\033[0;37m%s\033[0m\n" \
|
||||||
"$ip" "${ann:+ → ${ann}}"
|
"$ip" "${ann:+ → ${ann}}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function net::resolve_display() {
|
||||||
|
local ip="${1:-}" port="${2:-}" proto="${3:-}"
|
||||||
|
[[ -z "$ip" ]] && return 0
|
||||||
|
|
||||||
|
# --raw flag bypass
|
||||||
|
[[ "${_WGCTL_RAW:-false}" == "true" ]] && echo "$ip" && return 0
|
||||||
|
|
||||||
|
# 1. hosts.json exact IP match
|
||||||
|
local host_name=""
|
||||||
|
if [[ -f "$(ctx::hosts)" ]]; then
|
||||||
|
host_name=$(hosts::lookup_ip "$ip")
|
||||||
|
fi
|
||||||
|
[[ -n "$host_name" ]] && echo "$host_name" && return 0
|
||||||
|
|
||||||
|
# 2. services.json match
|
||||||
|
local svc_name=""
|
||||||
|
svc_name=$(net::reverse_lookup "$ip" "$port" "$proto" 2>/dev/null) || true
|
||||||
|
[[ -n "$svc_name" ]] && echo "$svc_name" && return 0
|
||||||
|
|
||||||
|
# 3. Raw IP fallback
|
||||||
|
echo "$ip"
|
||||||
|
}
|
||||||
|
|
@ -28,7 +28,7 @@ function peers::create_client_config() {
|
||||||
[Interface]
|
[Interface]
|
||||||
PrivateKey = ${private_key}
|
PrivateKey = ${private_key}
|
||||||
Address = ${ip}/32
|
Address = ${ip}/32
|
||||||
DNS = $(config::dns)
|
DNS = $(config::dns_string)
|
||||||
|
|
||||||
[Peer]
|
[Peer]
|
||||||
PublicKey = ${server_public_key}
|
PublicKey = ${server_public_key}
|
||||||
|
|
@ -271,6 +271,18 @@ function peers::set_main_group() {
|
||||||
peers::set_meta "$name" "main_group" "$group"
|
peers::set_meta "$name" "main_group" "$group"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function peers::get_display_subnet() {
|
||||||
|
local peer_name="${1:-}" peer_type="${2:-}"
|
||||||
|
local subnet
|
||||||
|
subnet=$(peers::get_meta "$peer_name" "subnet" 2>/dev/null)
|
||||||
|
if [[ -z "$subnet" ]]; then
|
||||||
|
subnet=$(subnet::type_from_ip "$(peers::get_ip "$peer_name")" 2>/dev/null)
|
||||||
|
[[ -n "$peer_type" ]] && subnet="$peer_type"
|
||||||
|
fi
|
||||||
|
[[ -z "$subnet" ]] && subnet="-"
|
||||||
|
echo "$subnet"
|
||||||
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Name + Type Parsing
|
# Name + Type Parsing
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
@ -526,6 +538,24 @@ function peers::format_activity_current() {
|
||||||
echo "${level} (↓${rx_hr}B/s ↑${tx_hr}B/s)"
|
echo "${level} (↓${rx_hr}B/s ↑${tx_hr}B/s)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# dentity
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
function peers::get_identity() {
|
||||||
|
local peer_name="${1:-}"
|
||||||
|
local id_dir
|
||||||
|
id_dir="$(ctx::identities)"
|
||||||
|
for id_file in "${id_dir}"/*.identity; do
|
||||||
|
[[ -f "$id_file" ]] || continue
|
||||||
|
if json::get "$id_file" "peers" 2>/dev/null | grep -qx "$peer_name"; then
|
||||||
|
basename "$id_file" .identity
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Helpers - Meta File
|
# Helpers - Meta File
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
|
||||||
|
|
@ -3,51 +3,15 @@
|
||||||
# Policies define behavioral flags for subnets, identities, and future contexts.
|
# Policies define behavioral flags for subnets, identities, and future contexts.
|
||||||
# Chain: Subnet → Policy → Identity → Peer
|
# Chain: Subnet → Policy → Identity → Peer
|
||||||
|
|
||||||
# ======================================================
|
|
||||||
# Hardcoded Fallbacks
|
|
||||||
# Mirror of policies.json built-in policies.
|
|
||||||
# Used when policies.json lookup fails.
|
|
||||||
# ======================================================
|
|
||||||
|
|
||||||
declare -gA _POLICY_TUNNEL_MODE=(
|
|
||||||
[default]="split"
|
|
||||||
[guest]="split"
|
|
||||||
[trusted]="split"
|
|
||||||
[server]="split"
|
|
||||||
[iot]="split"
|
|
||||||
)
|
|
||||||
|
|
||||||
declare -gA _POLICY_DEFAULT_RULE=(
|
|
||||||
[default]=""
|
|
||||||
[guest]="guest"
|
|
||||||
[trusted]=""
|
|
||||||
[server]=""
|
|
||||||
[iot]=""
|
|
||||||
)
|
|
||||||
|
|
||||||
declare -gA _POLICY_STRICT_RULE=(
|
|
||||||
[default]="false"
|
|
||||||
[guest]="true"
|
|
||||||
[trusted]="false"
|
|
||||||
[server]="false"
|
|
||||||
[iot]="false"
|
|
||||||
)
|
|
||||||
|
|
||||||
declare -gA _POLICY_AUTO_APPLY=(
|
|
||||||
[default]="true"
|
|
||||||
[guest]="true"
|
|
||||||
[trusted]="true"
|
|
||||||
[server]="true"
|
|
||||||
[iot]="true"
|
|
||||||
)
|
|
||||||
|
|
||||||
function policy::_hardcoded_field() {
|
function policy::_hardcoded_field() {
|
||||||
local name="${1:-}" field="${2:-}"
|
local name="${1:-}" field="${2:-}"
|
||||||
|
# Only fallback for 'default' policy if policies.json is unavailable
|
||||||
|
[[ "$name" != "default" ]] && echo "" && return 0
|
||||||
case "$field" in
|
case "$field" in
|
||||||
tunnel_mode) echo "${_POLICY_TUNNEL_MODE[$name]:-split}" ;;
|
tunnel_mode) echo "split" ;;
|
||||||
default_rule) echo "${_POLICY_DEFAULT_RULE[$name]:-}" ;;
|
default_rule) echo "" ;;
|
||||||
strict_rule) echo "${_POLICY_STRICT_RULE[$name]:-false}" ;;
|
strict_rule) echo "false" ;;
|
||||||
auto_apply) echo "${_POLICY_AUTO_APPLY[$name]:-true}" ;;
|
auto_apply) echo "true" ;;
|
||||||
*) echo "" ;;
|
*) echo "" ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
@ -56,8 +20,6 @@ function policy::_hardcoded_field() {
|
||||||
# Core Accessors
|
# Core Accessors
|
||||||
# ======================================================
|
# ======================================================
|
||||||
|
|
||||||
function ctx::policies() { echo "${_CTX_DATA}/policies.json"; }
|
|
||||||
|
|
||||||
function policy::exists() {
|
function policy::exists() {
|
||||||
local name="${1:-}"
|
local name="${1:-}"
|
||||||
json::policy_exists "$(ctx::policies)" "$name" 2>/dev/null
|
json::policy_exists "$(ctx::policies)" "$name" 2>/dev/null
|
||||||
|
|
|
||||||
125
modules/resolve.module.sh
Normal file
125
modules/resolve.module.sh
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
#!/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=()
|
||||||
|
}
|
||||||
99
modules/ui/activity.module.sh
Normal file
99
modules/ui/activity.module.sh
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
#!/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"
|
||||||
|
}
|
||||||
162
modules/ui/group.module.sh
Normal file
162
modules/ui/group.module.sh
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
#!/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
|
||||||
|
}
|
||||||
44
modules/ui/hosts.module.sh
Normal file
44
modules/ui/hosts.module.sh
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
#!/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,15 +22,6 @@ function ui::identity::detail_name() {
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
function ui::identity::device_row() {
|
|
||||||
local peer_name="${1:-}" dev_type="${2:-}" \
|
|
||||||
dev_index="${3:-1}" status="${4:-}"
|
|
||||||
local suffix=""
|
|
||||||
[[ "$dev_index" -gt 1 ]] && suffix=" (#${dev_index})"
|
|
||||||
printf " · %-24s %-10s%s%s\n" \
|
|
||||||
"$peer_name" "$dev_type" "$suffix" "$status"
|
|
||||||
}
|
|
||||||
|
|
||||||
function ui::identity::migrate_create() {
|
function ui::identity::migrate_create() {
|
||||||
local peer_name="${1:-}" identity_name="${2:-}" \
|
local peer_name="${1:-}" identity_name="${2:-}" \
|
||||||
peer_type="${3:-}" index="${4:-}"
|
peer_type="${3:-}" index="${4:-}"
|
||||||
|
|
@ -79,3 +70,27 @@ function ui::identity::list_row_table() {
|
||||||
[[ -z "$types_display" ]] && types_display="—"
|
[[ -z "$types_display" ]] && types_display="—"
|
||||||
printf " %-20s %-7s %s\n" "$name" "$peer_count" "$types_display"
|
printf " %-20s %-7s %s\n" "$name" "$peer_count" "$types_display"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ui::identity::device_row() {
|
||||||
|
local peer_name="${1:-}" dev_type="${2:-}" \
|
||||||
|
dev_index="${3:-1}" status="${4:-}"
|
||||||
|
|
||||||
|
# Extract connection state for dimming
|
||||||
|
local is_online=false
|
||||||
|
[[ "$status" == *"online"* ]] && is_online=true
|
||||||
|
|
||||||
|
# Only show index suffix if peer name doesn't already encode it
|
||||||
|
local suffix=""
|
||||||
|
[[ "$dev_index" -gt 1 && "$peer_name" != *"-${dev_index}" ]] && \
|
||||||
|
suffix=" (#${dev_index})"
|
||||||
|
|
||||||
|
if $is_online; then
|
||||||
|
printf " · %-24s %-10s%s%s\n" \
|
||||||
|
"$peer_name" "$dev_type" "$suffix" "$status"
|
||||||
|
else
|
||||||
|
local clean_status
|
||||||
|
clean_status=$(echo "$status" | sed 's/\x1b\[[0-9;]*m//g')
|
||||||
|
printf " · %-24s %-10s%s\033[2m%s\033[0m\n" \
|
||||||
|
"$peer_name" "$dev_type" "$suffix" "$clean_status"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
@ -11,13 +11,15 @@ function ui::logs::build_dest() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function ui::logs::fw_section_header() {
|
function ui::logs::fw_section_header() {
|
||||||
|
local cols=$(( $(tput cols 2>/dev/null || echo 80) - 4 ))
|
||||||
printf " Firewall Drops\n"
|
printf " Firewall Drops\n"
|
||||||
printf " %s\n" "$(printf '─%.0s' {1..42})"
|
printf " %s\n" "$(printf '─%.0s' $(seq 1 $cols))"
|
||||||
}
|
}
|
||||||
|
|
||||||
function ui::logs::wg_section_header() {
|
function ui::logs::wg_section_header() {
|
||||||
|
local cols=$(( $(tput cols 2>/dev/null || echo 80) - 4 ))
|
||||||
printf " WireGuard Events\n"
|
printf " WireGuard Events\n"
|
||||||
printf " %s\n" "$(printf '─%.0s' {1..42})"
|
printf " %s\n" "$(printf '─%.0s' $(seq 1 $cols))"
|
||||||
}
|
}
|
||||||
|
|
||||||
function ui::logs::fw_section_header_table() {
|
function ui::logs::fw_section_header_table() {
|
||||||
|
|
@ -35,17 +37,86 @@ function ui::logs::wg_section_header_table() {
|
||||||
function ui::logs::fw_row() {
|
function ui::logs::fw_row() {
|
||||||
local ts="${1:-}" client="${2:-}" dest_ip="${3:-}" dest_port="${4:-}" \
|
local ts="${1:-}" client="${2:-}" dest_ip="${3:-}" dest_port="${4:-}" \
|
||||||
proto="${5:-}" svc_name="${6:-}" count="${7:-1}" \
|
proto="${5:-}" svc_name="${6:-}" count="${7:-1}" \
|
||||||
w_client="${8:-20}" w_dest="${9:-30}"
|
w_client="${8:-20}" w_dest="${9:-30}" \
|
||||||
local dest_display
|
src_endpoint="${10:-}" src_resolved="${11:-}" \
|
||||||
dest_display=$(ui::logs::build_dest "$dest_ip" "$dest_port" "$proto" "$svc_name")
|
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=""
|
local count_suffix=""
|
||||||
[[ "$count" -gt 1 ]] && count_suffix=" \033[2m(x${count})\033[0m"
|
[[ "$count" -gt 1 ]] && count_suffix=" \033[2m(x${count})\033[0m"
|
||||||
local client_pad dest_pad_n
|
|
||||||
client_pad=$(printf "%-${w_client}s" "$client")
|
if [[ "$w_endpoint" -gt 0 ]]; then
|
||||||
dest_pad_n=$(( w_dest - ${#dest_display} ))
|
printf " %s %s %b \033[1;31m→\033[0m %s%b%*s%b\n" \
|
||||||
[[ $dest_pad_n -lt 0 ]] && dest_pad_n=0
|
"$ts_pad" "$client_pad" \
|
||||||
printf " %s %s \033[1;31m→\033[0m %s%*s%b\n" \
|
"$src_padded" \
|
||||||
"$ts" "$client_pad" "$dest_display" "$dest_pad_n" "" "$count_suffix"
|
"$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() {
|
function ui::logs::fw_row_table() {
|
||||||
|
|
@ -55,25 +126,61 @@ function ui::logs::fw_row_table() {
|
||||||
printf " %-20s %-18s %-25s %s%s\n" "$ts" "$client" "$dst" "$proto" "$count_str"
|
printf " %-20s %-18s %-25s %s%s\n" "$ts" "$client" "$dst" "$proto" "$count_str"
|
||||||
}
|
}
|
||||||
|
|
||||||
function ui::logs::wg_row() {
|
# function ui::logs::wg_row() {
|
||||||
local ts="${1:-}" client="${2:-}" endpoint="${3:-}" event="${4:-}" count="${5:-1}" \
|
# local ts="${1:-}" client="${2:-}" endpoint="${3:-}" event="${4:-}" \
|
||||||
w_client="${6:-20}" w_endpoint="${7:-20}"
|
# count="${5:-1}" w_client="${6:-20}" w_endpoint="${7:-20}" \
|
||||||
local event_color
|
# gap_seconds="${8:-}" resolved="${9:-}"
|
||||||
case "$event" in
|
|
||||||
handshake) event_color="\033[1;32m" ;;
|
# local event_color
|
||||||
attempt) event_color="\033[1;31m" ;;
|
# case "$event" in
|
||||||
*) event_color="\033[0;37m" ;;
|
# handshake) event_color="\033[1;32m" ;;
|
||||||
esac
|
# attempt) event_color="\033[1;31m" ;;
|
||||||
local count_suffix=""
|
# *) event_color="\033[0;37m" ;;
|
||||||
[[ "$count" -gt 1 ]] && count_suffix=" \033[2m(x${count})\033[0m"
|
# esac
|
||||||
local client_pad endpoint_pad_n
|
|
||||||
client_pad=$(printf "%-${w_client}s" "$client")
|
# local count_suffix=""
|
||||||
endpoint_pad_n=$(( w_endpoint - ${#endpoint} ))
|
# [[ "$count" -gt 1 ]] && count_suffix=" \033[2m(x${count})\033[0m"
|
||||||
[[ $endpoint_pad_n -lt 0 ]] && endpoint_pad_n=0
|
|
||||||
printf " %s %s %s%*s %b%s\033[0m%b\n" \
|
# # Gap suffix — offline label only when gap > threshold * 2
|
||||||
"$ts" "$client_pad" "$endpoint" "$endpoint_pad_n" "" \
|
# local gap_suffix=""
|
||||||
"$event_color" "$event" "$count_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() {
|
function ui::logs::wg_row_table() {
|
||||||
local ts="${1:-}" client="${2:-}" endpoint="${3:-}" event="${4:-}" count="${5:-1}"
|
local ts="${1:-}" client="${2:-}" endpoint="${3:-}" event="${4:-}" count="${5:-1}"
|
||||||
|
|
@ -91,56 +198,106 @@ function ui::logs::wg_row_table() {
|
||||||
_UI_WATCH_FW_COLOR="\033[1;31m"
|
_UI_WATCH_FW_COLOR="\033[1;31m"
|
||||||
_UI_WATCH_WG_COLOR="\033[1;32m"
|
_UI_WATCH_WG_COLOR="\033[1;32m"
|
||||||
|
|
||||||
function ui::watch::fw_row() {
|
# function ui::watch::fw_row() {
|
||||||
local ts="${1:-}" client="${2:-}" dest_display="${3:-}" \
|
# local ts="${1:-}" client="${2:-}" dest_display="${3:-}" \
|
||||||
w_client="${4:-20}" w_dest="${5:-18}"
|
# w_client="${4:-20}" w_dest="${5:-18}"
|
||||||
|
|
||||||
local ts_pad
|
# # "fw" is always 2 visible chars — no padding needed
|
||||||
ts_pad=$(printf "%-11s" "$ts")
|
# local src="${_UI_WATCH_FW_COLOR}fw\033[0m"
|
||||||
|
|
||||||
local src
|
# local ts_pad client_pad dest_pad_n
|
||||||
src=$(ui::pad_mb "${_UI_WATCH_FW_COLOR}fw\033[0m" 2)
|
# ts_pad=$(printf "%-11s" "$ts")
|
||||||
local client_pad dest_pad_n
|
# client_pad=$(printf "%-${w_client}s" "$client")
|
||||||
client_pad=$(printf "%-${w_client}s" "$client")
|
# dest_pad_n=$(( w_dest - ${#dest_display} ))
|
||||||
dest_pad_n=$(( w_dest - ${#dest_display} ))
|
# [[ $dest_pad_n -lt 0 ]] && dest_pad_n=0
|
||||||
[[ $dest_pad_n -lt 0 ]] && dest_pad_n=0
|
|
||||||
# echo "DEBUG fw: ts_bytes=${#ts} src_bytes=${#src} client='$client'(${#client}) client_pad_bytes=${#client_pad}" >&2
|
|
||||||
printf " %s %b %s \033[1;31m→\033[0m %s%*s \033[1;31mdrop\033[0m\n" \
|
|
||||||
"$ts_pad" "$src" "$client_pad" "$dest_display" "$dest_pad_n" ""
|
|
||||||
}
|
|
||||||
|
|
||||||
function ui::watch::wg_row() {
|
# printf " %s %b %s \033[1;31m→\033[0m %s%*s \033[1;31mdrop\033[0m\n" \
|
||||||
local ts="${1:-}" client="${2:-}" endpoint="${3:-}" event="${4:-}" \
|
# "$ts_pad" "$src" "$client_pad" "$dest_display" "$dest_pad_n" ""
|
||||||
w_client="${5:-20}" w_endpoint="${6:-18}"
|
# }
|
||||||
|
|
||||||
local ts_pad
|
|
||||||
ts_pad=$(printf "%-11s" "$ts")
|
|
||||||
|
|
||||||
local event_color
|
# function ui::watch::wg_row() {
|
||||||
case "$event" in
|
# local ts="${1:-}" client="${2:-}" endpoint="${3:-}" event="${4:-}" \
|
||||||
handshake) event_color="\033[1;32m" ;;
|
# w_client="${5:-20}" w_endpoint="${6:-18}"
|
||||||
attempt) event_color="\033[1;31m" ;;
|
|
||||||
*) event_color="\033[0;37m" ;;
|
|
||||||
esac
|
|
||||||
local src
|
|
||||||
src=$(ui::pad_mb "${_UI_WATCH_WG_COLOR}wg\033[0m" 2)
|
|
||||||
|
|
||||||
case "$event" in
|
# local event_color
|
||||||
handshake) src="\033[1;32m" ;; # green
|
# case "$event" in
|
||||||
attempt) src="\033[1;31m" ;; # red
|
# handshake) event_color="\033[1;32m" ;;
|
||||||
*) src="\033[0;37m" ;; # gray
|
# attempt) event_color="\033[1;31m" ;;
|
||||||
esac
|
# *) event_color="\033[0;37m" ;;
|
||||||
local src_colored="${src}wg\033[0m"
|
# esac
|
||||||
|
|
||||||
local client_pad endpoint_pad_n
|
# local endpoint_display="${endpoint:--}"
|
||||||
client_pad=$(printf "%-${w_client}s" "$client")
|
|
||||||
endpoint_pad_n=$(( w_endpoint - ${#endpoint} ))
|
# local ts_pad client_pad ep_pad_n
|
||||||
[[ $endpoint_pad_n -lt 0 ]] && endpoint_pad_n=0
|
# ts_pad=$(printf "%-11s" "$ts")
|
||||||
# echo "DEBUG wg: ts_bytes=${#ts} src_bytes=${#src} client='$client'(${#client}) client_pad_bytes=${#client_pad}" >&2
|
# client_pad=$(printf "%-${w_client}s" "$client")
|
||||||
printf " %s %b %s %s%*s %b%s\033[0m\n" \
|
# ep_pad_n=$(( w_endpoint - ${#endpoint_display} ))
|
||||||
"$ts_pad" "$src_colored" "$client_pad" "$endpoint" "$endpoint_pad_n" "" \
|
# [[ $ep_pad_n -lt 0 ]] && ep_pad_n=0
|
||||||
"$event_color" "$event"
|
|
||||||
}
|
# printf " %s %s %s%*s %b%s\033[0m\n" \
|
||||||
|
# "$ts_pad" "$client_pad" \
|
||||||
|
# "$endpoint_display" "$ep_pad_n" "" \
|
||||||
|
# "$event_color" "$event"
|
||||||
|
# }
|
||||||
|
|
||||||
|
|
||||||
|
# function ui::logs::wg_row() {
|
||||||
|
# local ts="${1:-}" client="${2:-}" endpoint="${3:-}" event="${4:-}" \
|
||||||
|
# count="${5:-1}" w_client="${6:-20}" w_endpoint="${7:-20}" \
|
||||||
|
# gap_seconds="${8:-}" resolved="${9:-}"
|
||||||
|
|
||||||
|
# local event_color
|
||||||
|
# case "$event" in
|
||||||
|
# handshake) event_color="\033[1;32m" ;;
|
||||||
|
# attempt) event_color="\033[1;31m" ;;
|
||||||
|
# *) event_color="\033[0;37m" ;;
|
||||||
|
# esac
|
||||||
|
|
||||||
|
# local count_suffix=""
|
||||||
|
# [[ "$count" -gt 1 ]] && count_suffix=" \033[2m(x${count})\033[0m"
|
||||||
|
|
||||||
|
# local gap_suffix=""
|
||||||
|
# if [[ "$event" == "handshake" && -n "$gap_seconds" && "$gap_seconds" -gt 0 ]]; then
|
||||||
|
# local gap_int="$gap_seconds"
|
||||||
|
# local threshold="${WG_HANDSHAKE_CHECK_TIME_SEC:-300}"
|
||||||
|
# local offline_label=""
|
||||||
|
# [[ "$gap_int" -gt $(( threshold * 2 )) ]] && offline_label=" offline"
|
||||||
|
# if (( gap_int >= 3600 )); then
|
||||||
|
# gap_suffix=" \033[2m↑ $(( gap_int / 3600 ))h${offline_label}\033[0m"
|
||||||
|
# elif (( gap_int >= 60 )); then
|
||||||
|
# gap_suffix=" \033[2m↑ $(( gap_int / 60 ))m${offline_label}\033[0m"
|
||||||
|
# fi
|
||||||
|
# fi
|
||||||
|
|
||||||
|
# # ── Endpoint padding ──
|
||||||
|
# # " → " = 5 bytes, 3 visible → visible = endpoint + 3 + resolved
|
||||||
|
# local endpoint_colored endpoint_visible_len
|
||||||
|
# if [[ -n "$resolved" && -n "$endpoint" ]]; then
|
||||||
|
# endpoint_colored="${endpoint} \033[2m→ ${resolved}\033[0m"
|
||||||
|
# endpoint_visible_len=$(( ${#endpoint} + 3 + ${#resolved} ))
|
||||||
|
# elif [[ -n "$endpoint" ]]; then
|
||||||
|
# endpoint_colored="${endpoint}"
|
||||||
|
# endpoint_visible_len=${#endpoint}
|
||||||
|
# else
|
||||||
|
# endpoint_colored="-"
|
||||||
|
# endpoint_visible_len=1
|
||||||
|
# fi
|
||||||
|
|
||||||
|
# local ep_pad_n=$(( w_endpoint - endpoint_visible_len ))
|
||||||
|
# [[ $ep_pad_n -lt 0 ]] && ep_pad_n=0
|
||||||
|
# local endpoint_padded
|
||||||
|
# endpoint_padded=$(printf "%b%*s" "$endpoint_colored" "$ep_pad_n" "")
|
||||||
|
|
||||||
|
# local ts_pad client_pad
|
||||||
|
# ts_pad=$(printf "%-11s" "$ts")
|
||||||
|
# client_pad=$(printf "%-${w_client}s" "$client")
|
||||||
|
|
||||||
|
# printf " %s %s %b %b%s\033[0m%b%b\n" \
|
||||||
|
# "$ts_pad" "$client_pad" \
|
||||||
|
# "$endpoint_padded" \
|
||||||
|
# "$event_color" "$event" "$count_suffix" "$gap_suffix"
|
||||||
|
# }
|
||||||
|
|
||||||
function ui::watch::header_table() {
|
function ui::watch::header_table() {
|
||||||
printf "\n %-20s %-8s %-22s %-28s %-14s %s\n" \
|
printf "\n %-20s %-8s %-22s %-28s %-14s %s\n" \
|
||||||
|
|
@ -165,3 +322,190 @@ function ui::watch::wg_row_table() {
|
||||||
printf " %-20s %-8s %-22s %-28s %b%-14s\033[0m %s\n" \
|
printf " %-20s %-8s %-22s %-28s %b%-14s\033[0m %s\n" \
|
||||||
"$ts" "wireguard" "$client" "$endpoint" "$event_color" "$event" "$status"
|
"$ts" "wireguard" "$client" "$endpoint" "$event_color" "$event" "$status"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ui::_render_endpoint_col
|
||||||
|
# Builds padded endpoint string: "raw_ip → resolved" or "raw_ip" or "-"
|
||||||
|
# Args: endpoint resolved w_endpoint
|
||||||
|
# Returns: padded colored string via stdout
|
||||||
|
function ui::_render_endpoint_col() {
|
||||||
|
local endpoint="${1:-}" resolved="${2:-}" w_endpoint="${3:-20}"
|
||||||
|
local colored visible_len pad_n
|
||||||
|
|
||||||
|
if [[ -n "$endpoint" ]]; then
|
||||||
|
if [[ -n "$resolved" ]]; then
|
||||||
|
colored="${endpoint} \033[2m→ ${resolved}\033[0m"
|
||||||
|
visible_len=$(( ${#endpoint} + 3 + ${#resolved} ))
|
||||||
|
else
|
||||||
|
colored="$endpoint"
|
||||||
|
visible_len=${#endpoint}
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
colored="\033[2m—\033[0m"
|
||||||
|
visible_len=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
pad_n=$(( w_endpoint - visible_len ))
|
||||||
|
[[ $pad_n -lt 0 ]] && pad_n=0
|
||||||
|
printf "%b%*s" "$colored" "$pad_n" ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# ui::_render_dest_col
|
||||||
|
# Builds padded destination string: "svc/proto (ip:port)" or raw
|
||||||
|
# Args: dest_ip dest_port proto svc_name w_dest resolved_only
|
||||||
|
# Returns: two vars via nameref — dest_colored dest_pad_n
|
||||||
|
function ui::_build_dest() {
|
||||||
|
local dest_ip="${1:-}" dest_port="${2:-}" proto="${3:-}" \
|
||||||
|
svc_name="${4:-}" w_dest="${5:-20}" resolved_only="${6:-false}"
|
||||||
|
local svc_display raw_suffix="" raw_plain=""
|
||||||
|
|
||||||
|
if [[ -n "$svc_name" ]]; then
|
||||||
|
[[ -n "$dest_port" ]] && svc_display="${svc_name}/${proto}" \
|
||||||
|
|| svc_display="${svc_name} (${proto})"
|
||||||
|
if ! $resolved_only; then
|
||||||
|
[[ -n "$dest_port" ]] && raw_suffix=" \033[2m(${dest_ip}:${dest_port})\033[0m" \
|
||||||
|
|| raw_suffix=" \033[2m(${dest_ip})\033[0m"
|
||||||
|
[[ -n "$dest_port" ]] && raw_plain=" (${dest_ip}:${dest_port})" \
|
||||||
|
|| raw_plain=" (${dest_ip})"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
[[ -n "$dest_port" ]] && svc_display="${dest_ip}:${dest_port}/${proto}" \
|
||||||
|
|| svc_display="${dest_ip} (${proto})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local full_len=$(( ${#svc_display} + ${#raw_plain} ))
|
||||||
|
local dest_pad_n=$(( w_dest - full_len ))
|
||||||
|
[[ $dest_pad_n -lt 0 ]] && dest_pad_n=0
|
||||||
|
|
||||||
|
# Output: svc_display|raw_suffix|dest_pad_n
|
||||||
|
printf "%s\t%s\t%s" "$svc_display" "$raw_suffix" "$dest_pad_n"
|
||||||
|
}
|
||||||
|
|
||||||
|
function ui::logs::fw_row() {
|
||||||
|
local ts="${1:-}" client="${2:-}" dest_ip="${3:-}" dest_port="${4:-}" \
|
||||||
|
proto="${5:-}" svc_name="${6:-}" count="${7:-1}" \
|
||||||
|
w_client="${8:-20}" w_dest="${9:-30}" \
|
||||||
|
src_endpoint="${10:-}" src_resolved="${11:-}" \
|
||||||
|
w_endpoint="${12:-0}" resolved_only="${13:-false}"
|
||||||
|
|
||||||
|
local ts_pad client_pad
|
||||||
|
ts_pad=$(printf "%-11s" "$ts")
|
||||||
|
client_pad=$(printf "%-${w_client}s" "$client")
|
||||||
|
|
||||||
|
local count_suffix=""
|
||||||
|
[[ "$count" -gt 1 ]] && count_suffix=" \033[2m(x${count})\033[0m"
|
||||||
|
|
||||||
|
# Dest
|
||||||
|
local dest_parts
|
||||||
|
dest_parts=$(ui::_build_dest "$dest_ip" "$dest_port" "$proto" "$svc_name" "$w_dest" "$resolved_only")
|
||||||
|
local svc_display raw_suffix dest_pad_n
|
||||||
|
IFS=$'\t' read -r svc_display raw_suffix dest_pad_n <<< "$dest_parts"
|
||||||
|
|
||||||
|
if [[ "$w_endpoint" -gt 0 ]]; then
|
||||||
|
local src_padded
|
||||||
|
src_padded=$(ui::_render_endpoint_col "$src_endpoint" "$src_resolved" "$w_endpoint")
|
||||||
|
printf " %s %s %b \033[1;31m→\033[0m %s%b%*s%b\n" \
|
||||||
|
"$ts_pad" "$client_pad" "$src_padded" \
|
||||||
|
"$svc_display" "$raw_suffix" "$dest_pad_n" "" "$count_suffix"
|
||||||
|
else
|
||||||
|
printf " %s %s \033[1;31m→\033[0m %s%b%*s%b\n" \
|
||||||
|
"$ts_pad" "$client_pad" \
|
||||||
|
"$svc_display" "$raw_suffix" "$dest_pad_n" "" "$count_suffix"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
function ui::logs::wg_row() {
|
||||||
|
local ts="${1:-}" client="${2:-}" endpoint="${3:-}" event="${4:-}" \
|
||||||
|
count="${5:-1}" w_client="${6:-20}" w_endpoint="${7:-20}" \
|
||||||
|
gap_seconds="${8:-}" resolved="${9:-}"
|
||||||
|
|
||||||
|
local event_color
|
||||||
|
case "$event" in
|
||||||
|
handshake) event_color="\033[1;32m" ;;
|
||||||
|
attempt) event_color="\033[1;31m" ;;
|
||||||
|
*) event_color="\033[0;37m" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
local count_suffix=""
|
||||||
|
[[ "$count" -gt 1 ]] && count_suffix=" \033[2m(x${count})\033[0m"
|
||||||
|
|
||||||
|
local gap_suffix=""
|
||||||
|
if [[ "$event" == "handshake" && -n "$gap_seconds" && "$gap_seconds" -gt 0 ]]; then
|
||||||
|
local gap_int="$gap_seconds"
|
||||||
|
local threshold="${WG_HANDSHAKE_CHECK_TIME_SEC:-300}"
|
||||||
|
local offline_label=""
|
||||||
|
[[ "$gap_int" -gt $(( threshold * 2 )) ]] && offline_label=" offline"
|
||||||
|
if (( gap_int >= 3600 )); then
|
||||||
|
gap_suffix=" \033[2m↑ $(( gap_int / 3600 ))h${offline_label}\033[0m"
|
||||||
|
elif (( gap_int >= 60 )); then
|
||||||
|
gap_suffix=" \033[2m↑ $(( gap_int / 60 ))m${offline_label}\033[0m"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
local ep_padded
|
||||||
|
ep_padded=$(ui::_render_endpoint_col "$endpoint" "$resolved" "$w_endpoint")
|
||||||
|
|
||||||
|
local ts_pad client_pad
|
||||||
|
ts_pad=$(printf "%-11s" "$ts")
|
||||||
|
client_pad=$(printf "%-${w_client}s" "$client")
|
||||||
|
|
||||||
|
printf " %s %s %b %b%s\033[0m%b%b\n" \
|
||||||
|
"$ts_pad" "$client_pad" "$ep_padded" \
|
||||||
|
"$event_color" "$event" "$count_suffix" "$gap_suffix"
|
||||||
|
}
|
||||||
|
|
||||||
|
function ui::watch::fw_row() {
|
||||||
|
local ts="${1:-}" client="${2:-}" dest_ip="${3:-}" dest_port="${4:-}" \
|
||||||
|
proto="${5:-}" svc_name="${6:-}" \
|
||||||
|
src_endpoint="${7:-}" src_resolved="${8:-}" \
|
||||||
|
w_client="${9:-20}" w_dest="${10:-18}" w_endpoint="${11:-0}"
|
||||||
|
|
||||||
|
local ts_pad client_pad
|
||||||
|
ts_pad=$(printf "%-11s" "$ts")
|
||||||
|
client_pad=$(printf "%-${w_client}s" "$client")
|
||||||
|
|
||||||
|
local dest_parts
|
||||||
|
dest_parts=$(ui::_build_dest "$dest_ip" "$dest_port" "$proto" "$svc_name" "$w_dest" "false")
|
||||||
|
local svc_display raw_suffix dest_pad_n
|
||||||
|
IFS=$'\t' read -r svc_display raw_suffix dest_pad_n <<< "$dest_parts"
|
||||||
|
|
||||||
|
if [[ "$w_endpoint" -gt 0 ]]; then
|
||||||
|
local src_padded
|
||||||
|
src_padded=$(ui::_render_endpoint_col "$src_endpoint" "$src_resolved" "$w_endpoint")
|
||||||
|
printf " %s %s %s \033[1;31m→\033[0m %s%b%*s \033[1;31mdrop\033[0m\n" \
|
||||||
|
"$ts_pad" "$client_pad" "$src_padded" \
|
||||||
|
"$svc_display" "$raw_suffix" "$dest_pad_n" ""
|
||||||
|
else
|
||||||
|
printf " %s %s \033[1;31m→\033[0m %s%b%*s \033[1;31mdrop\033[0m\n" \
|
||||||
|
"$ts_pad" "$client_pad" \
|
||||||
|
"$svc_display" "$raw_suffix" "$dest_pad_n" ""
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function ui::watch::wg_row() {
|
||||||
|
local ts="${1:-}" client="${2:-}" endpoint="${3:-}" event="${4:-}" \
|
||||||
|
src_resolved="${5:-}" \
|
||||||
|
w_client="${6:-20}" w_endpoint="${7:-18}"
|
||||||
|
|
||||||
|
local event_color
|
||||||
|
case "$event" in
|
||||||
|
handshake) event_color="\033[1;32m" ;;
|
||||||
|
attempt) event_color="\033[1;31m" ;;
|
||||||
|
*) event_color="\033[0;37m" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
local ep_padded
|
||||||
|
ep_padded=$(ui::_render_endpoint_col "$endpoint" "$src_resolved" "$w_endpoint")
|
||||||
|
|
||||||
|
local ts_pad client_pad
|
||||||
|
ts_pad=$(printf "%-11s" "$ts")
|
||||||
|
client_pad=$(printf "%-${w_client}s" "$client")
|
||||||
|
|
||||||
|
# echo "DEBUG: ts='$ts_pad' client='$client_pad'(${#client_pad}) ep='$ep_padded'(${#ep_padded}) event='$event'" >&2
|
||||||
|
|
||||||
|
printf " %s %s %s %b%s\033[0m\n" \
|
||||||
|
"$ts_pad" "$client_pad" "$ep_padded" \
|
||||||
|
"$event_color" "$event"
|
||||||
|
}
|
||||||
|
|
||||||
59
modules/ui/net.module.sh
Normal file
59
modules/ui/net.module.sh
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
#!/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
|
||||||
|
}
|
||||||
|
|
@ -31,7 +31,7 @@ function ui::peer::status_color() {
|
||||||
elif [[ "$status" == "online"* ]]; then
|
elif [[ "$status" == "online"* ]]; then
|
||||||
echo "\033[1;32m"
|
echo "\033[1;32m"
|
||||||
else
|
else
|
||||||
echo "\033[2;37m"
|
echo "\033[2m"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
45
modules/ui/policy.module.sh
Normal file
45
modules/ui/policy.module.sh
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
#!/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,20 +167,48 @@ function ui::rule::tree() {
|
||||||
# ui::rule::identity_block <identity_name> <strict_rule>
|
# ui::rule::identity_block <identity_name> <strict_rule>
|
||||||
# Renders the full identity rule block in inspect.
|
# Renders the full identity rule block in inspect.
|
||||||
function ui::rule::identity_block() {
|
function ui::rule::identity_block() {
|
||||||
local identity_name="${1:-}" strict="${2:-false}"
|
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 rules
|
local rules
|
||||||
rules=$(identity::rules "$identity_name")
|
rules=$(identity::rules "$identity_name")
|
||||||
[[ -z "$rules" ]] && return 0
|
[[ -z "$rules" ]] && return 0
|
||||||
|
|
||||||
|
if ! $no_header; then
|
||||||
printf "\n \033[0;37m· identity:%s\033[0m\n" "$identity_name"
|
printf "\n \033[0;37m· identity:%s\033[0m\n" "$identity_name"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Indentation levels:
|
||||||
|
# normal: entry=6 label=6 own=10 own_label=8
|
||||||
|
# no-header: entry=4 label=4 own=8 own_label=6
|
||||||
|
local entry_indent label_indent own_indent own_label_indent
|
||||||
|
if $no_header; then
|
||||||
|
entry_indent=4
|
||||||
|
label_indent=4
|
||||||
|
own_indent=8
|
||||||
|
own_label_indent=6
|
||||||
|
else
|
||||||
|
entry_indent=6
|
||||||
|
label_indent=6
|
||||||
|
own_indent=10
|
||||||
|
own_label_indent=8
|
||||||
|
fi
|
||||||
|
|
||||||
local first=true
|
local first=true
|
||||||
while IFS= read -r rule_name; do
|
while IFS= read -r rule_name; do
|
||||||
[[ -z "$rule_name" ]] && continue
|
[[ -z "$rule_name" ]] && continue
|
||||||
$first || printf "\n"
|
$first || printf "\n"
|
||||||
first=false
|
first=false
|
||||||
ui::rule::_identity_rule_entry "$rule_name"
|
ui::rule::_identity_rule_entry "$rule_name" \
|
||||||
|
"$entry_indent" "$label_indent" "$own_indent" "$own_label_indent"
|
||||||
done <<< "$rules"
|
done <<< "$rules"
|
||||||
|
|
||||||
if [[ "$strict" == "true" ]]; then
|
if [[ "$strict" == "true" ]]; then
|
||||||
|
|
@ -192,30 +220,41 @@ function ui::rule::identity_block() {
|
||||||
# Renders one rule within an identity block.
|
# Renders one rule within an identity block.
|
||||||
function ui::rule::_identity_rule_entry() {
|
function ui::rule::_identity_rule_entry() {
|
||||||
local rule_name="${1:-}"
|
local rule_name="${1:-}"
|
||||||
|
local entry_indent="${2:-6}"
|
||||||
|
local label_indent="${3:-6}"
|
||||||
|
local own_indent="${4:-10}"
|
||||||
|
local own_label_indent="${5:-8}"
|
||||||
|
|
||||||
local rule_file
|
local rule_file
|
||||||
rule_file="$(rule::path "$rule_name")" || return 0
|
rule_file="$(rule::path "$rule_name")" || return 0
|
||||||
|
|
||||||
printf " \033[0;37m↳ %s\033[0m\n" "$rule_name"
|
local label_pad
|
||||||
|
label_pad=$(printf '%*s' "$label_indent" '')
|
||||||
|
printf "%s\033[0;37m↳ %s\033[0m\n" "$label_pad" "$rule_name"
|
||||||
|
|
||||||
local extends_raw=()
|
local extends_raw=()
|
||||||
mapfile -t extends_raw < <(json::get "$rule_file" "extends" 2>/dev/null || true) || true
|
mapfile -t extends_raw < <(json::get "$rule_file" "extends" 2>/dev/null || true) || true
|
||||||
|
|
||||||
if [[ ${#extends_raw[@]} -gt 0 && -n "${extends_raw[0]:-}" ]]; then
|
if [[ ${#extends_raw[@]} -gt 0 && -n "${extends_raw[0]:-}" ]]; then
|
||||||
ui::rule::_render_bases extends_raw 10 8
|
ui::rule::_render_bases extends_raw "$own_indent" "$(( entry_indent + 2 ))"
|
||||||
|
|
||||||
local own_output
|
local own_output
|
||||||
own_output=$(ui::rule::own_entries "$rule_name" 10)
|
own_output=$(ui::rule::own_entries "$rule_name" "$own_indent")
|
||||||
if [[ -n "$own_output" ]]; then
|
if [[ -n "$own_output" ]]; then
|
||||||
printf "\n \033[0;37mOwn:\033[0m\n"
|
local own_pad
|
||||||
|
own_pad=$(printf '%*s' "$(( entry_indent + 2 ))" '')
|
||||||
|
printf "\n%s\033[0;37mOwn:\033[0m\n" "$own_pad"
|
||||||
printf "%s\n" "$own_output"
|
printf "%s\n" "$own_output"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
local own_output
|
local own_output
|
||||||
own_output=$(ui::rule::own_entries "$rule_name" 8)
|
own_output=$(ui::rule::own_entries "$rule_name" "$entry_indent")
|
||||||
if [[ -n "$own_output" ]]; then
|
if [[ -n "$own_output" ]]; then
|
||||||
printf "%s\n" "$own_output"
|
printf "%s\n" "$own_output"
|
||||||
else
|
else
|
||||||
printf " \033[2mfull access (no restrictions)\033[0m\n"
|
local full_pad
|
||||||
|
full_pad=$(printf '%*s' "$(( entry_indent + 2 ))" '')
|
||||||
|
printf "%s\033[2mfull access (no restrictions)\033[0m\n" "$full_pad"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
@ -292,20 +331,23 @@ function ui::rule::list_row() {
|
||||||
extends_indicator=" \033[2m↳ ${extends_display}\033[0m"
|
extends_indicator=" \033[2m↳ ${extends_display}\033[0m"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Allows column — green +N if >0, dim +0 if zero
|
# Allows — green +N, padded to 5 visible chars
|
||||||
|
# Append spaces after reset code so printf doesn't miscount
|
||||||
local allows_str
|
local allows_str
|
||||||
if [[ "$n_allows" -gt 0 ]]; then
|
if [[ "$n_allows" -gt 0 ]]; then
|
||||||
allows_str=$(ui::pad_mb "\033[1;32m+${n_allows}\033[0m" 5)
|
printf -v allows_str "\033[1;32m+%s\033[0m" "$n_allows"
|
||||||
|
allows_str="${allows_str}$(printf '%*s' "$(( 4 - ${#n_allows} ))" '')"
|
||||||
else
|
else
|
||||||
allows_str=$(ui::pad_mb "\033[2m+0\033[0m" 5)
|
printf -v allows_str "\033[2m+0\033[0m " # "+0" = 2 visible + 3 spaces = 5
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Blocks column — red -N if >0, dim -0 if zero
|
# Blocks — red -N, padded to 5 visible chars
|
||||||
local blocks_str
|
local blocks_str
|
||||||
if [[ "$n_blocks" -gt 0 ]]; then
|
if [[ "$n_blocks" -gt 0 ]]; then
|
||||||
blocks_str=$(ui::pad_mb "\033[1;31m-${n_blocks}\033[0m" 5)
|
printf -v blocks_str "\033[1;31m-%s\033[0m" "$n_blocks"
|
||||||
|
blocks_str="${blocks_str}$(printf '%*s' "$(( 4 - ${#n_blocks} ))" '')"
|
||||||
else
|
else
|
||||||
blocks_str=$(ui::pad_mb "\033[2m-0\033[0m" 5)
|
printf -v blocks_str "\033[2m-0\033[0m " # "-0" = 2 visible + 3 spaces = 5
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Peers — dim if zero
|
# Peers — dim if zero
|
||||||
|
|
@ -350,6 +392,24 @@ function ui::rule::list_extends_detailed() {
|
||||||
done
|
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
|
# Show helpers
|
||||||
# ======================================================
|
# ======================================================
|
||||||
|
|
|
||||||
|
|
@ -8,33 +8,6 @@ function ui::subnet::header() {
|
||||||
ui::divider 70
|
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() {
|
function ui::subnet::row() {
|
||||||
local display_name="${1:-}" subnet="${2:-}" type_key="${3:-}" \
|
local display_name="${1:-}" subnet="${2:-}" type_key="${3:-}" \
|
||||||
tunnel_mode="${4:-}" desc="${5:-}" is_group="${6:-false}"
|
tunnel_mode="${4:-}" desc="${5:-}" is_group="${6:-false}"
|
||||||
|
|
@ -211,3 +184,19 @@ function ui::subnet::show_peers_annotated() {
|
||||||
printf " · %b\n" "$peer"
|
printf " · %b\n" "$peer"
|
||||||
done
|
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,12 +5,17 @@ source "$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)/core.sh"
|
||||||
|
|
||||||
LOG_LEVEL=DEBUG
|
LOG_LEVEL=DEBUG
|
||||||
|
|
||||||
|
WGCTL_VERSION="0.7.1"
|
||||||
|
|
||||||
|
function wgctl::version() { echo "$WGCTL_VERSION"; }
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Modules
|
# Modules
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
load_module ip
|
load_module ip
|
||||||
load_module ui
|
load_module ui
|
||||||
|
load_module display
|
||||||
load_module config
|
load_module config
|
||||||
load_module keys
|
load_module keys
|
||||||
load_module peers
|
load_module peers
|
||||||
|
|
@ -22,6 +27,9 @@ load_module net
|
||||||
load_module group
|
load_module group
|
||||||
load_module subnet
|
load_module subnet
|
||||||
load_module identity
|
load_module identity
|
||||||
|
load_module policy
|
||||||
|
load_module hosts
|
||||||
|
load_module resolve
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Alias Map
|
# Alias Map
|
||||||
|
|
@ -35,7 +43,6 @@ declare -A CMD_ALIASES=(
|
||||||
[del]=remove
|
[del]=remove
|
||||||
[delete]=remove
|
[delete]=remove
|
||||||
[mv]=rename
|
[mv]=rename
|
||||||
[ls]=list
|
|
||||||
[show]=list
|
[show]=list
|
||||||
[monitor]=watch
|
[monitor]=watch
|
||||||
[ban]=block
|
[ban]=block
|
||||||
|
|
@ -45,7 +52,6 @@ declare -A CMD_ALIASES=(
|
||||||
[down]=service
|
[down]=service
|
||||||
[reload]=service
|
[reload]=service
|
||||||
[stat]=service
|
[stat]=service
|
||||||
[log]=service
|
|
||||||
[start]=service
|
[start]=service
|
||||||
[stop]=service
|
[stop]=service
|
||||||
[restart]=service
|
[restart]=service
|
||||||
|
|
@ -70,6 +76,11 @@ function wgctl::dispatch() {
|
||||||
local cmd
|
local cmd
|
||||||
cmd="$(wgctl::resolve_alias "$raw_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
|
case "$cmd" in
|
||||||
help) wgctl::help; return ;;
|
help) wgctl::help; return ;;
|
||||||
shell) : ;;
|
shell) : ;;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue