Compare commits
17 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4545a400b | ||
|
|
10ea174e44 | ||
|
|
9c11152682 | ||
|
|
b153f222a5 | ||
|
|
d26e67b940 | ||
|
|
b892298259 | ||
|
|
d314ba376e | ||
|
|
91593b2576 | ||
|
|
0b9f113453 | ||
|
|
79769667fb | ||
|
|
ddd705aa87 | ||
|
|
00d6be0766 | ||
|
|
8f3360c631 | ||
|
|
a7b05547f5 | ||
|
|
1a78dcf5da | ||
|
|
7a544f9019 | ||
|
|
dda8e408e8 |
56 changed files with 3398 additions and 226 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,9 +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
|
||||||
command::mixin json_output
|
flag::register --external
|
||||||
|
flag::register --ports
|
||||||
|
flag::register --exclude-service
|
||||||
|
flag::register --include-service
|
||||||
|
|
||||||
|
flag::exclusive --accept --drop
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
@ -35,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
|
||||||
|
|
@ -53,17 +61,24 @@ 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
|
||||||
--peer) filter_peer="$2"; shift 2 ;;
|
--peer) filter_peer="$2"; shift 2 ;;
|
||||||
--service) filter_service="$2"; shift 2 ;;
|
--service) filter_service="$2"; shift 2 ;;
|
||||||
--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 ;;
|
||||||
--help) cmd::activity::help; return ;;
|
--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 ;;
|
||||||
*)
|
*)
|
||||||
log::error "Unknown flag: $1"
|
log::error "Unknown flag: $1"
|
||||||
cmd::activity::help
|
cmd::activity::help
|
||||||
|
|
@ -77,42 +92,82 @@ function cmd::activity::run() {
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Resolve peer name if type provided
|
|
||||||
if [[ -n "$filter_peer" && -n "$filter_type" ]]; then
|
if [[ -n "$filter_peer" && -n "$filter_type" ]]; then
|
||||||
filter_peer=$(peers::resolve_and_require "$filter_peer" "$filter_type") || return 1
|
filter_peer=$(peers::resolve_and_require "$filter_peer" "$filter_type") || return 1
|
||||||
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" \
|
# Build exclude string for Python (space-separated)
|
||||||
"$service_ip" 2>/dev/null)
|
local exclude_str=""
|
||||||
|
[[ ${#final_excludes[@]} -gt 0 ]] && \
|
||||||
|
exclude_str=$(IFS=' '; echo "${final_excludes[*]}")
|
||||||
|
|
||||||
if [[ -z "$data" ]]; then
|
# ── Fetch data ──
|
||||||
log::wg_warning "No activity data found"
|
local data=""
|
||||||
return 0
|
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 column widths
|
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)
|
||||||
|
|
@ -120,81 +175,239 @@ 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"
|
||||||
|
|
||||||
|
for a_name in "${!_ACCEPT_PEER[@]}"; do
|
||||||
|
(( ${#a_name} > w_peer )) && w_peer=${#a_name}
|
||||||
|
local a_conns_val="${_ACCEPT_PEER[$a_name]##*|}"
|
||||||
|
(( ${#a_conns_val} > w_count )) && w_count=${#a_conns_val}
|
||||||
|
done
|
||||||
|
for key in "${!_ACCEPT_DEST[@]}"; do
|
||||||
|
local d_val="${_ACCEPT_DEST[$key]}"
|
||||||
|
local d_count_val="${d_val##*|}"
|
||||||
|
(( ${#d_count_val} > w_count )) && w_count=${#d_count_val}
|
||||||
|
done
|
||||||
|
|
||||||
(( w_peer += 2 ))
|
(( w_peer += 2 ))
|
||||||
|
|
||||||
# Compute column where drop count starts on peer row:
|
|
||||||
# " " (2) + name (w_peer) + " ↓" (3) + rx (10) + " ↑" (3) + tx (10) + " " (2)
|
|
||||||
# ↓ and ↑ are multi-byte (3 bytes, 1 visible) — 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"
|
||||||
[[ "$hours" == "0" ]] && hours_display="all time"
|
[[ "$hours" == "0" ]] && hours_display="all time"
|
||||||
|
|
||||||
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=$(fmt::bytes "$rx")
|
rx_fmt=$(fmt::bytes "$rx")
|
||||||
tx_fmt=$(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")
|
||||||
tx_pad=$(printf "%-10s" "$tx_fmt")
|
tx_pad=$(printf "%-10s" "$tx_fmt")
|
||||||
|
|
||||||
local drop_word="drops"
|
local drop_word="drops"
|
||||||
[[ "$drops" -eq 1 ]] && drop_word="drop"
|
[[ "$drops" -eq 1 ]] && drop_word="drop"
|
||||||
|
|
||||||
ui::activity::peer_row \
|
# Always show peer name — either full row or name-only for accept_only
|
||||||
"$name_pad" "$rx_pad" "$tx_pad" "$drops" "$drop_word" "$w_drops"
|
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
|
||||||
|
if [[ -n "$dst_port" ]]; then
|
||||||
|
svc_display=$(printf "%s \033[2m(%s:%s)\033[0m" \
|
||||||
|
"$dest_display" "$dst_ip" "$dst_port")
|
||||||
|
else
|
||||||
|
svc_display=$(printf "%s \033[2m(%s)\033[0m" \
|
||||||
|
"$dest_display" "$dst_ip")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
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"
|
||||||
|
$accept_only || ui::activity::service_row \
|
||||||
ui::activity::service_row \
|
"$svc_display" "$drop_count" "$svc_drop_word" "$drops_col" "$w_count"
|
||||||
"$dest_display" "$drop_count" "$svc_drop_word" "$drops_col" "$w_drops"
|
|
||||||
;;
|
;;
|
||||||
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() {
|
||||||
|
local data="${1:-}"
|
||||||
|
[[ -z "$data" ]] && return 0
|
||||||
|
|
||||||
|
ui::activity::header_table
|
||||||
|
local skip_peer=false
|
||||||
|
while IFS='|' read -r record_type rest; do
|
||||||
|
case "$record_type" in
|
||||||
|
peer)
|
||||||
|
local name rx tx drops
|
||||||
|
IFS='|' read -r name rx tx drops <<< "$rest"
|
||||||
|
skip_peer=false
|
||||||
|
local rx_fmt tx_fmt
|
||||||
|
rx_fmt=$(fmt::bytes "$rx")
|
||||||
|
tx_fmt=$(fmt::bytes "$tx")
|
||||||
|
ui::activity::peer_row_table "$name" "$rx_fmt" "$tx_fmt" "$drops" ""
|
||||||
|
;;
|
||||||
|
service)
|
||||||
|
$skip_peer && continue
|
||||||
|
local peer dest count
|
||||||
|
IFS='|' read -r peer dest count <<< "$rest"
|
||||||
|
ui::activity::service_row_table "$dest" "$count" "drops"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done <<< "$data"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function cmd::activity::_output_json() {
|
function cmd::activity::_output_json() {
|
||||||
local hours="${1:-24}"
|
local hours="${1:-24}"
|
||||||
local data
|
local data
|
||||||
|
|
@ -243,3 +456,48 @@ function cmd::activity::_output_json() {
|
||||||
array=$(printf '%s\n' "${peers[@]:-}" | paste -sd ',' -)
|
array=$(printf '%s\n' "${peers[@]:-}" | paste -sd ',' -)
|
||||||
printf '{"peers":[%s]}' "${array:-}" | json::envelope "activity" "$count"
|
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,7 +62,8 @@ 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
|
||||||
--name) name="$2"; shift 2 ;;
|
--name) name="$2"; shift 2 ;;
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -82,25 +85,25 @@ function cmd::block::run() {
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
# --identity: block all peers for this identity
|
# --identity: block all peers for this identity
|
||||||
if [[ -n "$identity" ]]; then
|
if [[ -n "$identity" ]]; then
|
||||||
cmd::block::_block_identity "$identity" "$quiet" \
|
cmd::block::_block_identity "$identity" "$quiet" \
|
||||||
"${ips[@]+"${ips[@]}"}" || return 1
|
"${ips[@]+"${ips[@]}"}" || return 1
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
[[ -z "$name" ]] && {
|
[[ -z "$name" ]] && {
|
||||||
log::error "Missing required flag: --name or --identity"
|
log::error "Missing required flag: --name or --identity"
|
||||||
cmd::block::help
|
cmd::block::help
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
name=$(peers::resolve_and_require "$name" "$type") || return 1
|
name=$(peers::resolve_and_require "$name" "$type") || return 1
|
||||||
|
|
||||||
local client_ip
|
local client_ip
|
||||||
client_ip=$(peers::get_ip "$name") || return 1
|
client_ip=$(peers::get_ip "$name") || return 1
|
||||||
|
|
||||||
# Full block if no specific targets
|
# Full block if no specific targets
|
||||||
if [[ ${#ips[@]} -eq 0 && ${#ports[@]} -eq 0 && \
|
if [[ ${#ips[@]} -eq 0 && ${#ports[@]} -eq 0 && \
|
||||||
${#subnets[@]} -eq 0 && ${#services[@]} -eq 0 ]]; then
|
${#subnets[@]} -eq 0 && ${#services[@]} -eq 0 ]]; then
|
||||||
|
|
@ -110,9 +113,10 @@ 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
|
||||||
|
|
||||||
# Specific rules — check if already fully blocked
|
# Specific rules — check if already fully blocked
|
||||||
if block::has_file "$name"; then
|
if block::has_file "$name"; then
|
||||||
local direct
|
local direct
|
||||||
|
|
@ -122,9 +126,9 @@ function cmd::block::run() {
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local changed=false
|
local changed=false
|
||||||
|
|
||||||
# Block specific IPs
|
# Block specific IPs
|
||||||
for ip in "${ips[@]}"; do
|
for ip in "${ips[@]}"; do
|
||||||
ip::require_valid "$ip"
|
ip::require_valid "$ip"
|
||||||
|
|
@ -132,7 +136,7 @@ function cmd::block::run() {
|
||||||
block::add_rule "$name" "$client_ip" "ip" "${block_name:-}" "$ip"
|
block::add_rule "$name" "$client_ip" "ip" "${block_name:-}" "$ip"
|
||||||
$quiet || log::wg_success "${ip} has been blocked for ${name}"
|
$quiet || log::wg_success "${ip} has been blocked for ${name}"
|
||||||
done
|
done
|
||||||
|
|
||||||
# Block specific subnets
|
# Block specific subnets
|
||||||
for subnet in "${subnets[@]}"; do
|
for subnet in "${subnets[@]}"; do
|
||||||
ip::require_valid "$subnet"
|
ip::require_valid "$subnet"
|
||||||
|
|
@ -140,7 +144,7 @@ function cmd::block::run() {
|
||||||
block::add_rule "$name" "$client_ip" "subnet" "${block_name:-}" "$subnet"
|
block::add_rule "$name" "$client_ip" "subnet" "${block_name:-}" "$subnet"
|
||||||
$quiet || log::wg_success "${subnet} has been blocked for ${name}"
|
$quiet || log::wg_success "${subnet} has been blocked for ${name}"
|
||||||
done
|
done
|
||||||
|
|
||||||
# Block specific ports
|
# Block specific ports
|
||||||
for entry in "${ports[@]}"; do
|
for entry in "${ports[@]}"; do
|
||||||
local b_target b_port b_proto
|
local b_target b_port b_proto
|
||||||
|
|
@ -151,7 +155,7 @@ function cmd::block::run() {
|
||||||
"$b_target" "$b_port" "${b_proto:-tcp}"
|
"$b_target" "$b_port" "${b_proto:-tcp}"
|
||||||
$quiet || log::wg_success "${client_ip}:${b_port}:${b_proto:-tcp} has been blocked for ${name}"
|
$quiet || log::wg_success "${client_ip}:${b_port}:${b_proto:-tcp} has been blocked for ${name}"
|
||||||
done
|
done
|
||||||
|
|
||||||
# Block services
|
# Block services
|
||||||
for svc in "${services[@]}"; do
|
for svc in "${services[@]}"; do
|
||||||
local resolved_lines=()
|
local resolved_lines=()
|
||||||
|
|
@ -160,7 +164,7 @@ function cmd::block::run() {
|
||||||
log::error "Service not found or has no ports: ${svc}"
|
log::error "Service not found or has no ports: ${svc}"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local already_blocked=true
|
local already_blocked=true
|
||||||
for resolved in "${resolved_lines[@]}"; do
|
for resolved in "${resolved_lines[@]}"; do
|
||||||
if [[ "$resolved" == *:*:* ]]; then
|
if [[ "$resolved" == *:*:* ]]; then
|
||||||
|
|
@ -173,12 +177,12 @@ function cmd::block::run() {
|
||||||
{ already_blocked=false; break; }
|
{ already_blocked=false; break; }
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
if $already_blocked; then
|
if $already_blocked; then
|
||||||
$quiet || log::wg_warning "${svc} is already blocked for ${name}"
|
$quiet || log::wg_warning "${svc} is already blocked for ${name}"
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
for resolved in "${resolved_lines[@]}"; do
|
for resolved in "${resolved_lines[@]}"; do
|
||||||
if [[ "$resolved" == *:*:* ]]; then
|
if [[ "$resolved" == *:*:* ]]; then
|
||||||
local b_ip b_port b_proto
|
local b_ip b_port b_proto
|
||||||
|
|
@ -191,14 +195,14 @@ function cmd::block::run() {
|
||||||
block::add_rule "$name" "$client_ip" "ip" "$svc" "$resolved"
|
block::add_rule "$name" "$client_ip" "ip" "$svc" "$resolved"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
changed=true
|
changed=true
|
||||||
$quiet || log::wg_success "${svc} has been blocked for ${name}"
|
$quiet || log::wg_success "${svc} has been blocked for ${name}"
|
||||||
done
|
done
|
||||||
|
|
||||||
[[ ${#ips[@]} -gt 0 || ${#ports[@]} -gt 0 || \
|
[[ ${#ips[@]} -gt 0 || ${#ports[@]} -gt 0 || \
|
||||||
${#subnets[@]} -gt 0 ]] && changed=true
|
${#subnets[@]} -gt 0 ]] && changed=true
|
||||||
|
|
||||||
if $changed; then
|
if $changed; then
|
||||||
local peer_rule
|
local peer_rule
|
||||||
peer_rule=$(peers::get_meta "$name" "rule")
|
peer_rule=$(peers::get_meta "$name" "rule")
|
||||||
|
|
@ -208,7 +212,15 @@ function cmd::block::run() {
|
||||||
block::restore_rules_for "$name" "$client_ip"
|
block::restore_rules_for "$name" "$client_ip"
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -270,4 +282,25 @@ function cmd::block::_block_all() {
|
||||||
block::set_direct "$name" "$client_ip" "true"
|
block::set_direct "$name" "$client_ip" "true"
|
||||||
|
|
||||||
$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
|
||||||
}
|
}
|
||||||
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
|
||||||
|
}
|
||||||
|
|
@ -147,6 +147,11 @@ function cmd::group::list() {
|
||||||
log::section "Groups"
|
log::section "Groups"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
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"
|
ui::group::list_row "$name" "$desc" "$total" "$blocked" "$w_name" "$w_desc"
|
||||||
|
|
@ -223,6 +228,17 @@ function cmd::group::show() {
|
||||||
printf "\n"
|
printf "\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cmd::group::_render_table() {
|
||||||
|
local data="${1:-}" w_name="${2:-20}" w_desc="${3:-20}"
|
||||||
|
[[ -z "$data" ]] && return 0
|
||||||
|
|
||||||
|
ui::group::list_header_table
|
||||||
|
while IFS='|' read -r name desc total blocked; do
|
||||||
|
[[ -z "$name" ]] && continue
|
||||||
|
ui::group::list_row_table "$name" "$desc" "$total" "$blocked"
|
||||||
|
done <<< "$data"
|
||||||
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Add
|
# Add
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,11 @@ function cmd::hosts::list() {
|
||||||
log::section "Host Mappings"
|
log::section "Host Mappings"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
if display::is_table "hosts_list"; then
|
||||||
|
cmd::hosts::_render_table "$data"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
local last_type="" found=false
|
local last_type="" found=false
|
||||||
while IFS='|' read -r type key name desc tags; do
|
while IFS='|' read -r type key name desc tags; do
|
||||||
[[ -z "$type" ]] && continue
|
[[ -z "$type" ]] && continue
|
||||||
|
|
@ -167,19 +172,15 @@ function cmd::hosts::list() {
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
# Table version (kept for future display config)
|
function cmd::hosts::_render_table() {
|
||||||
function cmd::hosts::_list_table() {
|
local data="${1:-}"
|
||||||
local hosts_file="${1:-}"
|
[[ -z "$data" ]] && return 0
|
||||||
printf "\n %-6s %-18s %-16s %-30s %s\n" \
|
|
||||||
"TYPE" "KEY" "NAME" "DESCRIPTION" "TAGS"
|
ui::hosts::list_header_table
|
||||||
printf " %s\n" "$(printf '─%.0s' {1..80})"
|
|
||||||
|
|
||||||
while IFS='|' read -r type key name desc tags; do
|
while IFS='|' read -r type key name desc tags; do
|
||||||
[[ -z "$type" ]] && continue
|
[[ -z "$type" ]] && continue
|
||||||
printf " %-6s %-18s %-16s %-30s %s\n" \
|
ui::hosts::list_row_table "$type" "$key" "$name" "$desc" "$tags"
|
||||||
"$type" "$key" "$name" "${desc:-—}" "${tags:-—}"
|
done <<< "$data"
|
||||||
done < <(json::hosts_list "$hosts_file" 2>/dev/null)
|
|
||||||
printf "\n"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,12 @@ function cmd::identity::_list() {
|
||||||
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
|
||||||
|
|
@ -185,6 +190,21 @@ function cmd::identity::_show() {
|
||||||
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:-}"
|
||||||
local -n _handshakes="${2:-__empty_map}"
|
local -n _handshakes="${2:-__empty_map}"
|
||||||
|
|
|
||||||
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::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,7 +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
|
||||||
command::mixin json_output
|
|
||||||
|
# Mutually exclusive filter groups
|
||||||
|
flag::exclusive --online --offline --blocked --restricted --allowed
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
@ -226,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -346,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"
|
|
||||||
else
|
# Rows
|
||||||
log::wg_warning "No results found"
|
while IFS='|' read -r name ip type rule group status last_seen is_blocked is_restricted; do
|
||||||
fi
|
[[ -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
|
||||||
|
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
|
||||||
|
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() {
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ function cmd::logs::on_load() {
|
||||||
flag::register --ascending
|
flag::register --ascending
|
||||||
flag::register --descending
|
flag::register --descending
|
||||||
flag::register --resolved
|
flag::register --resolved
|
||||||
|
|
||||||
|
flag::exclusive --ascending --descending
|
||||||
}
|
}
|
||||||
|
|
||||||
function cmd::logs::help() {
|
function cmd::logs::help() {
|
||||||
|
|
@ -161,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
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ function cmd::net::list() {
|
||||||
log::wg_warning "No services configured"
|
log::wg_warning "No services configured"
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# Measure column widths
|
# Measure column widths
|
||||||
local w_name=12 w_ip=13 w_ports=16
|
local w_name=12 w_ip=13 w_ports=16
|
||||||
while IFS="|" read -r name ip desc tags ports; do
|
while IFS="|" read -r name ip desc tags ports; do
|
||||||
|
|
@ -144,6 +144,11 @@ function cmd::net::list() {
|
||||||
|
|
||||||
log::section "Network Services"
|
log::section "Network Services"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
if display::is_table "net_list"; then
|
||||||
|
cmd::net::_render_table "$filtered_data"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
while IFS="|" read -r name ip desc tags ports; do
|
while IFS="|" read -r name ip desc tags ports; do
|
||||||
[[ -z "$name" ]] && continue
|
[[ -z "$name" ]] && continue
|
||||||
|
|
@ -162,6 +167,17 @@ function cmd::net::list() {
|
||||||
echo ""
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Show
|
# Show
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,11 @@ function cmd::policy::_list() {
|
||||||
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"
|
||||||
|
|
@ -120,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
|
||||||
|
|
|
||||||
|
|
@ -181,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 \
|
||||||
|
|
@ -240,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
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,11 @@ function cmd::subnet::_list() {
|
||||||
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
|
||||||
|
|
@ -120,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
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,7 @@ 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
|
||||||
|
|
@ -138,6 +139,8 @@ function cmd::test::run_all_integration_sections() {
|
||||||
cmd::test::section_peer_cmd
|
cmd::test::section_peer_cmd
|
||||||
cmd::test::section_group_purge
|
cmd::test::section_group_purge
|
||||||
cmd::test::section_logs_clean
|
cmd::test::section_logs_clean
|
||||||
|
cmd::test::section_display
|
||||||
|
cmd::test::section_export
|
||||||
}
|
}
|
||||||
|
|
||||||
function cmd::test::section_list() {
|
function cmd::test::section_list() {
|
||||||
|
|
@ -174,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() {
|
||||||
|
|
@ -200,6 +205,92 @@ function cmd::test::section_groups() {
|
||||||
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
|
||||||
|
|
@ -366,4 +457,90 @@ function cmd::test::section_logs_clean() {
|
||||||
cmd::test::run_cmd "logs clean --force" \
|
cmd::test::run_cmd "logs clean --force" \
|
||||||
"keepalive" \
|
"keepalive" \
|
||||||
logs clean --force
|
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
|
||||||
}
|
}
|
||||||
|
|
@ -49,6 +49,8 @@ function cmd::test::run_all_unit_sections() {
|
||||||
cmd::test::unit_parse_since
|
cmd::test::unit_parse_since
|
||||||
cmd::test::unit_group_status
|
cmd::test::unit_group_status
|
||||||
cmd::test::unit_json_output
|
cmd::test::unit_json_output
|
||||||
|
cmd::test::unit_display
|
||||||
|
cmd::test::unit_version
|
||||||
}
|
}
|
||||||
|
|
||||||
function cmd::test::unit_subnet() {
|
function cmd::test::unit_subnet() {
|
||||||
|
|
@ -225,6 +227,8 @@ function cmd::test::unit_group_status() {
|
||||||
function cmd::test::unit_json_output() {
|
function cmd::test::unit_json_output() {
|
||||||
test::section "Unit: JSON output"
|
test::section "Unit: JSON output"
|
||||||
|
|
||||||
|
command::_load_mixins 2>/dev/null || true
|
||||||
|
|
||||||
# json::envelope produces valid structure
|
# json::envelope produces valid structure
|
||||||
local result
|
local result
|
||||||
result=$(echo '{"peers":[]}' | json::envelope "list" "0")
|
result=$(echo '{"peers":[]}' | json::envelope "list" "0")
|
||||||
|
|
@ -235,8 +239,11 @@ function cmd::test::unit_json_output() {
|
||||||
|
|
||||||
# command::mixin registration
|
# command::mixin registration
|
||||||
load_command list
|
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 "json_output mixin registered" \
|
||||||
cmd::test::assert_true "command::json accessor exists" "declare -f command::json >/dev/null 2>&1"
|
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
|
# json::error_envelope
|
||||||
local err_result
|
local err_result
|
||||||
|
|
@ -251,4 +258,36 @@ function cmd::test::unit_json_output() {
|
||||||
cmd::test::assert_true "command::json true" "command::json"
|
cmd::test::assert_true "command::json true" "command::json"
|
||||||
_COMMAND_JSON=false
|
_COMMAND_JSON=false
|
||||||
cmd::test::assert_false "command::json false" "command::json"
|
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,20 +60,22 @@ 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
|
||||||
--name) name="$2"; shift 2 ;;
|
--name) name="$2"; shift 2 ;;
|
||||||
--identity) identity="$2"; shift 2 ;;
|
--identity) identity="$2"; shift 2 ;;
|
||||||
--type) type="$2"; shift 2 ;;
|
--type) type="$2"; shift 2 ;;
|
||||||
--ip) ips+=("$2"); shift 2 ;;
|
--ip) ips+=("$2"); shift 2 ;;
|
||||||
--force) force=true; shift ;;
|
--force) force=true; shift ;;
|
||||||
--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 ;;
|
||||||
--service) services+=("$2"); shift 2 ;;
|
--service) services+=("$2"); shift 2 ;;
|
||||||
--all) all=true; shift ;;
|
--reason) reason="$2"; shift 2 ;;
|
||||||
--help) cmd::unblock::help; return ;;
|
--all) all=true; shift ;;
|
||||||
|
--help) cmd::unblock::help; return ;;
|
||||||
*)
|
*)
|
||||||
log::error "Unknown flag: $1"
|
log::error "Unknown flag: $1"
|
||||||
cmd::unblock::help
|
cmd::unblock::help
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -248,4 +255,15 @@ 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
|
||||||
}
|
}
|
||||||
|
|
@ -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,13 +37,57 @@ 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
|
||||||
|
|
||||||
command::_reset_mixin_state # reset values only, keep _ACTIVE_MIXINS
|
local -a cleaned_defaults=()
|
||||||
|
for _d in "${default_args[@]:-}"; do
|
||||||
|
[[ -n "$_d" ]] && cleaned_defaults+=("$_d")
|
||||||
|
done
|
||||||
|
default_args=("${cleaned_defaults[@]:-}")
|
||||||
|
|
||||||
local -a args=("$@")
|
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
|
command::_preprocess_flags args
|
||||||
|
|
||||||
local fn
|
local fn
|
||||||
|
|
@ -77,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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# core/command_mixins.sh
|
# core/command_mixins.sh
|
||||||
# Mixin infrastructure — loads mixin files and provides command::mixin
|
# Mixin infrastructure — loads mixin files and provides command::mixin / flag::exclusive
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Active mixin tracking (per-process)
|
# Active mixin tracking (per-process)
|
||||||
|
|
@ -108,4 +108,88 @@ function command::_preprocess_flags() {
|
||||||
else
|
else
|
||||||
_args_ref=()
|
_args_ref=()
|
||||||
fi
|
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
|
||||||
}
|
}
|
||||||
|
|
@ -44,46 +44,50 @@ _CTX_CONFIG_FILE="${_CTX_CONFIG}/wgctl.json"
|
||||||
# Accessors
|
# Accessors
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
function ctx::root() { echo "$_CTX_ROOT"; }
|
function ctx::root() { echo "$_CTX_ROOT"; }
|
||||||
function ctx::core() { echo "$_CTX_CORE"; }
|
function ctx::core() { echo "$_CTX_CORE"; }
|
||||||
function ctx::modules() { echo "$_CTX_MODULES"; }
|
function ctx::modules() { echo "$_CTX_MODULES"; }
|
||||||
function ctx::commands() { echo "$_CTX_COMMANDS"; }
|
function ctx::commands() { echo "$_CTX_COMMANDS"; }
|
||||||
function ctx::wg() { echo "$_CTX_WG"; }
|
function ctx::wg() { echo "$_CTX_WG"; }
|
||||||
function ctx::clients() { echo "$_CTX_CLIENTS"; }
|
function ctx::clients() { echo "$_CTX_CLIENTS"; }
|
||||||
|
|
||||||
# Top-level dirs
|
# Top-level dirs
|
||||||
function ctx::wgctl() { echo "$_CTX_WGCTL"; }
|
function ctx::wgctl() { echo "$_CTX_WGCTL"; }
|
||||||
function ctx::config() { echo "$_CTX_CONFIG"; }
|
function ctx::config() { echo "$_CTX_CONFIG"; }
|
||||||
function ctx::data() { echo "$_CTX_DATA"; }
|
function ctx::data() { echo "$_CTX_DATA"; }
|
||||||
function ctx::daemon() { echo "$_CTX_DAEMON"; }
|
function ctx::daemon() { echo "$_CTX_DAEMON"; }
|
||||||
|
|
||||||
# Data subdirs
|
# 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::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::identities() { echo "$_CTX_IDENTITY"; }
|
function ctx::identities() { echo "$_CTX_IDENTITY"; }
|
||||||
function ctx::peer_history() { echo "$_CTX_PEER_HISTORY"; }
|
function ctx::peer_history() { echo "$_CTX_PEER_HISTORY"; }
|
||||||
|
|
||||||
# Data files
|
# Data files
|
||||||
function ctx::net() { echo "$_CTX_NET"; }
|
function ctx::net() { echo "$_CTX_NET"; }
|
||||||
function ctx::hosts() { echo "$_CTX_HOSTS"; }
|
function ctx::hosts() { echo "$_CTX_HOSTS"; }
|
||||||
function ctx::subnets() { echo "$_CTX_SUBNETS"; }
|
function ctx::subnets() { echo "$_CTX_SUBNETS"; }
|
||||||
function ctx::policies() { echo "$_CTX_POLICIES"; }
|
function ctx::policies() { echo "$_CTX_POLICIES"; }
|
||||||
|
|
||||||
# Config files
|
# Config files
|
||||||
function ctx::config_file() { echo "$_CTX_CONFIG_FILE"; }
|
function ctx::config_file() { echo "$_CTX_CONFIG_FILE"; }
|
||||||
|
function ctx::display() { echo "${_CTX_CONFIG}/display.json"; }
|
||||||
|
|
||||||
# Daemon files
|
# Daemon files
|
||||||
function ctx::events_log() { echo "${_CTX_DAEMON}/events.log"; }
|
function ctx::events_log() { echo "${_CTX_DAEMON}/events.log"; }
|
||||||
function ctx::fw_events_log() { echo "${_CTX_DAEMON}/fw_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::endpoint_cache() { echo "${_CTX_DAEMON}/endpoint_cache.json"; }
|
||||||
|
function ctx::accept_events_log() { echo "${_CTX_DAEMON}/accept_events.log"; }
|
||||||
|
|
||||||
# Tool paths
|
# 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::monitor_script() { echo "${_CTX_ROOT}/daemon/wgctl-monitor.py"; }
|
||||||
function ctx::lib() { echo "${_CTX_CORE}/lib"; }
|
function ctx::lib() { echo "${_CTX_CORE}/lib"; }
|
||||||
|
|
||||||
|
function ctx::block_history() { echo "${_CTX_DATA}/block-history"; }
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Path Helpers
|
# Path Helpers
|
||||||
|
|
|
||||||
18
core/json.sh
18
core/json.sh
|
|
@ -145,9 +145,20 @@ function json::error_envelope() {
|
||||||
}
|
}
|
||||||
|
|
||||||
# Config
|
# Config
|
||||||
|
|
||||||
function json::config_load() { python3 "$JSON_HELPER" config_load "$1" </dev/null; }
|
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)" \
|
||||||
|
|
@ -161,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; }
|
||||||
|
|
@ -1607,10 +1607,291 @@ def config_load(file):
|
||||||
emit('ACTIVITY_CURRENT_LOW_BYTES', acur.get('low'))
|
emit('ACTIVITY_CURRENT_LOW_BYTES', acur.get('low'))
|
||||||
emit('ACTIVITY_CURRENT_MED_BYTES', acur.get('medium'))
|
emit('ACTIVITY_CURRENT_MED_BYTES', acur.get('medium'))
|
||||||
emit('ACTIVITY_CURRENT_HIGH_BYTES', acur.get('high'))
|
emit('ACTIVITY_CURRENT_HIGH_BYTES', acur.get('high'))
|
||||||
|
|
||||||
|
# Command defaults and aliases
|
||||||
|
# Output format:
|
||||||
|
# CMD_DEFAULT:activity=--exclude-service pihole:dns-udp --limit 50
|
||||||
|
# CMD_ALIAS:act=activity
|
||||||
|
# CMD_ALIAS:a=activity
|
||||||
|
cmds = d.get('commands', {})
|
||||||
|
for cmd_name, cmd_cfg in cmds.items():
|
||||||
|
if not isinstance(cmd_cfg, dict):
|
||||||
|
continue
|
||||||
|
defaults = cmd_cfg.get('defaults', [])
|
||||||
|
if defaults:
|
||||||
|
print(f"CMD_DEFAULT:{cmd_name}={' '.join(str(x) for x in defaults)}")
|
||||||
|
aliases = cmd_cfg.get('aliases', [])
|
||||||
|
for alias in aliases:
|
||||||
|
print(f"CMD_ALIAS:{alias}={cmd_name}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error: {e}", file=sys.stderr)
|
print(f"Error: {e}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
def display_load(file):
|
||||||
|
"""
|
||||||
|
Load display.json and output view=style pairs.
|
||||||
|
Output: view_name=style per line
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(file) as f:
|
||||||
|
d = json.load(f)
|
||||||
|
views = d.get('views', {})
|
||||||
|
for view_name, view_config in views.items():
|
||||||
|
style = view_config.get('style', 'compact')
|
||||||
|
print(f"{view_name}={style}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def _export_peer_data(name, clients_dir, meta_dir, identities_dir, groups_dir, blocks_dir):
|
||||||
|
"""Build peer data dict for export."""
|
||||||
|
import glob, base64, os
|
||||||
|
|
||||||
|
conf_path = os.path.join(clients_dir, f"{name}.conf")
|
||||||
|
if not os.path.exists(conf_path):
|
||||||
|
return None
|
||||||
|
|
||||||
|
with open(conf_path, 'rb') as f:
|
||||||
|
conf_b64 = base64.b64encode(f.read()).decode()
|
||||||
|
|
||||||
|
# Public key
|
||||||
|
key_path = os.path.join(clients_dir, f"{name}_public.key")
|
||||||
|
public_key = open(key_path).read().strip() if os.path.exists(key_path) else ''
|
||||||
|
|
||||||
|
# IP from conf
|
||||||
|
ip = ''
|
||||||
|
with open(conf_path) as f:
|
||||||
|
for line in f:
|
||||||
|
if line.startswith('Address'):
|
||||||
|
ip = line.split('=')[1].strip().split('/')[0]
|
||||||
|
break
|
||||||
|
|
||||||
|
# Meta
|
||||||
|
meta = {}
|
||||||
|
meta_path = os.path.join(meta_dir, f"{name}.meta")
|
||||||
|
if os.path.exists(meta_path):
|
||||||
|
try:
|
||||||
|
with open(meta_path) as f:
|
||||||
|
meta = json.load(f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
direct_rule = meta.get('rule', '')
|
||||||
|
|
||||||
|
# Identity + type — scan identity files once for both
|
||||||
|
identity = ''
|
||||||
|
peer_type = meta.get('type', '')
|
||||||
|
for id_file in sorted(glob.glob(os.path.join(identities_dir, '*.identity'))):
|
||||||
|
try:
|
||||||
|
with open(id_file) as f:
|
||||||
|
id_data = json.load(f)
|
||||||
|
if name in id_data.get('peers', []):
|
||||||
|
identity = id_data.get('name', '')
|
||||||
|
if not peer_type:
|
||||||
|
peer_type = id_data.get('devices', {}).get(name, {}).get('type', '')
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Groups
|
||||||
|
peer_groups = []
|
||||||
|
for grp_file in sorted(glob.glob(os.path.join(groups_dir, '*.group'))):
|
||||||
|
try:
|
||||||
|
with open(grp_file) as f:
|
||||||
|
g = json.load(f)
|
||||||
|
if name in g.get('peers', []):
|
||||||
|
peer_groups.append(g.get('name', ''))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Blocks
|
||||||
|
blocks = {'is_blocked': False, 'block_file': None}
|
||||||
|
block_path = os.path.join(blocks_dir, f"{name}.block")
|
||||||
|
if os.path.exists(block_path):
|
||||||
|
with open(block_path, 'rb') as f:
|
||||||
|
blocks = {
|
||||||
|
'is_blocked': True,
|
||||||
|
'block_file': base64.b64encode(f.read()).decode()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'name': name,
|
||||||
|
'ip': ip,
|
||||||
|
'type': peer_type,
|
||||||
|
'public_key': public_key,
|
||||||
|
'conf': conf_b64,
|
||||||
|
'meta': meta,
|
||||||
|
'identity': identity,
|
||||||
|
'groups': peer_groups,
|
||||||
|
'direct_rule': direct_rule,
|
||||||
|
'blocks': blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def export_full(clients_dir, meta_dir, rules_dir, identities_dir,
|
||||||
|
groups_dir, blocks_dir, block_history_dir, config_file,
|
||||||
|
policies_file, subnets_file, net_file, hosts_file,
|
||||||
|
no_config, no_peers, version):
|
||||||
|
"""Build full wgctl export as JSON."""
|
||||||
|
import glob, os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
# Config
|
||||||
|
if no_config != 'true' and os.path.exists(config_file):
|
||||||
|
try:
|
||||||
|
with open(config_file) as f:
|
||||||
|
data['config'] = json.load(f)
|
||||||
|
except Exception:
|
||||||
|
data['config'] = {}
|
||||||
|
|
||||||
|
# Peers
|
||||||
|
if no_peers != 'true':
|
||||||
|
peers = []
|
||||||
|
for conf_path in sorted(glob.glob(f"{clients_dir}/*.conf")):
|
||||||
|
name = os.path.basename(conf_path).replace('.conf', '')
|
||||||
|
try:
|
||||||
|
peer = _export_peer_data(
|
||||||
|
name, clients_dir, meta_dir, identities_dir,
|
||||||
|
groups_dir, blocks_dir)
|
||||||
|
if peer:
|
||||||
|
peers.append(peer)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
data['peers'] = peers
|
||||||
|
|
||||||
|
# Rules
|
||||||
|
rules = []
|
||||||
|
for rule_file in sorted(
|
||||||
|
glob.glob(f"{rules_dir}/*.rule") +
|
||||||
|
glob.glob(f"{rules_dir}/base/*.rule")
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
with open(rule_file) as f:
|
||||||
|
rules.append(json.load(f))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
data['rules'] = rules
|
||||||
|
|
||||||
|
# Identities
|
||||||
|
identities = []
|
||||||
|
for id_file in sorted(glob.glob(f"{identities_dir}/*.identity")):
|
||||||
|
try:
|
||||||
|
with open(id_file) as f:
|
||||||
|
identities.append(json.load(f))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
data['identities'] = identities
|
||||||
|
|
||||||
|
# Groups
|
||||||
|
groups = []
|
||||||
|
for grp_file in sorted(glob.glob(f"{groups_dir}/*.group")):
|
||||||
|
try:
|
||||||
|
with open(grp_file) as f:
|
||||||
|
groups.append(json.load(f))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
data['groups'] = groups
|
||||||
|
|
||||||
|
|
||||||
|
# Block history
|
||||||
|
block_histories = []
|
||||||
|
for path in sorted(glob.glob(f"{block_history_dir}/*.json")):
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
block_histories.append(json.load(f))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
data['block_history'] = block_histories
|
||||||
|
|
||||||
|
# Flat JSON files
|
||||||
|
for key, path in [
|
||||||
|
('policies', policies_file),
|
||||||
|
('subnets', subnets_file),
|
||||||
|
('services', net_file),
|
||||||
|
('hosts', hosts_file),
|
||||||
|
]:
|
||||||
|
if os.path.exists(path):
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
data[key] = json.load(f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
ts = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||||
|
result = {
|
||||||
|
'wgctl_version': version,
|
||||||
|
'export_type': 'full',
|
||||||
|
'exported_at': ts,
|
||||||
|
'data': data,
|
||||||
|
}
|
||||||
|
print(json.dumps(result))
|
||||||
|
|
||||||
|
def endpoint_cache_get(cache_file, peer):
|
||||||
|
"""Get cached endpoint IP for a peer."""
|
||||||
|
try:
|
||||||
|
with open(cache_file) as f:
|
||||||
|
cache = json.load(f)
|
||||||
|
print(cache.get(peer, ''))
|
||||||
|
except Exception:
|
||||||
|
print('')
|
||||||
|
|
||||||
|
def batch_resolve_dest(net_file, hosts_file, *dest_specs):
|
||||||
|
"""
|
||||||
|
Resolve multiple ip:port:proto specs at once.
|
||||||
|
Input: "ip:port:proto" strings
|
||||||
|
Output: "ip:port:proto|display_name" per line
|
||||||
|
Uses same logic as resolve::dest bash function.
|
||||||
|
"""
|
||||||
|
from lib.util import load_net_data, load_hosts_data, reverse_lookup, hosts_lookup
|
||||||
|
net_data = load_net_data(net_file)
|
||||||
|
hosts_data = load_hosts_data(hosts_file)
|
||||||
|
seen = set()
|
||||||
|
|
||||||
|
for spec in dest_specs:
|
||||||
|
if not spec or spec in seen:
|
||||||
|
continue
|
||||||
|
seen.add(spec)
|
||||||
|
|
||||||
|
parts = spec.split(':')
|
||||||
|
if len(parts) < 3:
|
||||||
|
print(f"{spec}|{spec}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
ip = parts[0]
|
||||||
|
port = parts[1]
|
||||||
|
proto = parts[2]
|
||||||
|
|
||||||
|
# Try service name first
|
||||||
|
svc = reverse_lookup(net_data, ip, port, proto)
|
||||||
|
if svc and svc != ip:
|
||||||
|
if port:
|
||||||
|
display = f"{svc}:{proto}-{port}" if False else f"{svc}"
|
||||||
|
# Use same format as resolve::dest: "svcname/proto" or "svcname:port"
|
||||||
|
display = svc
|
||||||
|
else:
|
||||||
|
display = svc
|
||||||
|
print(f"{spec}|{display}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Try host name
|
||||||
|
host = hosts_lookup(hosts_data, ip)
|
||||||
|
if host and host != ip:
|
||||||
|
if port:
|
||||||
|
print(f"{spec}|{host}:{port}/{proto}")
|
||||||
|
else:
|
||||||
|
print(f"{spec}|{host}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Raw fallback
|
||||||
|
if port:
|
||||||
|
print(f"{spec}|{ip}:{port}/{proto}")
|
||||||
|
else:
|
||||||
|
print(f"{spec}|{ip}")
|
||||||
|
|
||||||
|
|
||||||
# ======================================================
|
# ======================================================
|
||||||
|
|
||||||
def _net_read(file):
|
def _net_read(file):
|
||||||
|
|
@ -1867,7 +2148,8 @@ commands = {
|
||||||
args[0], args[1], args[2], args[3], args[4],
|
args[0], args[1], args[2], args[3], args[4],
|
||||||
args[5], args[6] if len(args) > 6 else '24',
|
args[5], args[6] if len(args) > 6 else '24',
|
||||||
args[7] if len(args) > 7 else '',
|
args[7] if len(args) > 7 else '',
|
||||||
args[8] if len(args) > 8 else ''),
|
args[8] if len(args) > 8 else '',
|
||||||
|
args[9] if len(args) > 9 else ''),
|
||||||
# Rules
|
# Rules
|
||||||
'rule_resolve': lambda args: rule_resolve(args[0], args[1]),
|
'rule_resolve': lambda args: rule_resolve(args[0], args[1]),
|
||||||
'rule_resolve_field':lambda args: rule_resolve_field(args[0], args[1], args[2]),
|
'rule_resolve_field':lambda args: rule_resolve_field(args[0], args[1], args[2]),
|
||||||
|
|
@ -1981,6 +2263,55 @@ commands = {
|
||||||
'batch_resolve': lambda args: batch_resolve(args[0], args[1], *args[2:]),
|
'batch_resolve': lambda args: batch_resolve(args[0], args[1], *args[2:]),
|
||||||
'peer_history_lookup': lambda args: peer_history_lookup(args[0], args[1]),
|
'peer_history_lookup': lambda args: peer_history_lookup(args[0], args[1]),
|
||||||
'config_load': lambda args: config_load(args[0]),
|
'config_load': lambda args: config_load(args[0]),
|
||||||
|
'display_load': lambda args: display_load(args[0]),
|
||||||
|
'export_full': lambda args: export_full(
|
||||||
|
args[0], args[1], args[2], args[3], args[4], args[5],
|
||||||
|
args[6], args[7], args[8], args[9], args[10], args[11],
|
||||||
|
args[12] if len(args) > 12 else 'false',
|
||||||
|
args[13] if len(args) > 13 else 'false',
|
||||||
|
args[14] if len(args) > 14 else 'unknown'),
|
||||||
|
|
||||||
|
# Import
|
||||||
|
'import_peer': lambda args: __import__('lib.importer', fromlist=['import_peer']).import_peer(
|
||||||
|
args[0], args[1], args[2], args[3], args[4],
|
||||||
|
args[5], args[6], args[7] if len(args) > 7 else 'false'),
|
||||||
|
'import_identity': lambda args: __import__('lib.importer', fromlist=['import_identity']).import_identity(
|
||||||
|
args[0], args[1], args[2], args[3],
|
||||||
|
args[4] if len(args) > 4 else 'false'),
|
||||||
|
'import_full': lambda args: __import__('lib.importer', fromlist=['import_full']).import_full(
|
||||||
|
args[0], args[1], args[2], args[3], args[4], args[5],
|
||||||
|
args[6], args[7], args[8], args[9], args[10],
|
||||||
|
args[11] if len(args) > 11 else 'false'),
|
||||||
|
'import_get_field': lambda args: __import__('lib.importer', fromlist=['import_get_field']).import_get_field(args[0], *args[1:]),
|
||||||
|
'block_history_record': lambda args: __import__('lib.block_history',
|
||||||
|
fromlist=['block_history_record']).block_history_record(
|
||||||
|
args[0], args[1], args[2], args[3],
|
||||||
|
args[4] if len(args) > 4 else '',
|
||||||
|
args[5] if len(args) > 5 else ''),
|
||||||
|
'block_history_unblock': lambda args: __import__('lib.block_history',
|
||||||
|
fromlist=['block_history_unblock']).block_history_unblock(
|
||||||
|
args[0], args[1], args[2],
|
||||||
|
args[3] if len(args) > 3 else ''),
|
||||||
|
'block_history_list': lambda args: __import__('lib.block_history',
|
||||||
|
fromlist=['block_history_list']).block_history_list(args[0], args[1]),
|
||||||
|
'block_history_list_all': lambda args: __import__('lib.block_history',
|
||||||
|
fromlist=['block_history_list_all']).block_history_list_all(args[0]),
|
||||||
|
'endpoint_cache_get': lambda args: endpoint_cache_get(args[0], args[1]),
|
||||||
|
'accept_events': lambda args: __import__('lib.accept_events', fromlist=['accept_events']).accept_events(
|
||||||
|
args[0], args[1], args[2], args[3],
|
||||||
|
args[4] if len(args) > 4 else '100',
|
||||||
|
args[5] if len(args) > 5 else '1',
|
||||||
|
args[6] if len(args) > 6 else '',
|
||||||
|
args[7] if len(args) > 7 else '0',
|
||||||
|
args[8] if len(args) > 8 else 'desc'),
|
||||||
|
'accept_aggregate': lambda args: __import__('lib.accept_events',
|
||||||
|
fromlist=['accept_aggregate']).accept_aggregate(
|
||||||
|
args[0], args[1], args[2],
|
||||||
|
args[3] if len(args) > 3 else '',
|
||||||
|
args[4] if len(args) > 4 else '',
|
||||||
|
args[5] if len(args) > 5 else '0',
|
||||||
|
args[6] if len(args) > 6 else ''),
|
||||||
|
'batch_resolve_dest': lambda args: batch_resolve_dest(args[0], args[1], *args[2:]),
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
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.
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.
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.
Binary file not shown.
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
|
||||||
|
|
@ -19,26 +19,49 @@ from lib.util import (
|
||||||
|
|
||||||
def activity_aggregate(fw_file, wg_file, wg_interface, net_file,
|
def activity_aggregate(fw_file, wg_file, wg_interface, net_file,
|
||||||
clients_dir, meta_dir, hours, filter_peer,
|
clients_dir, meta_dir, hours, filter_peer,
|
||||||
filter_service_ip):
|
filter_service_ip, exclude_services=''):
|
||||||
"""
|
"""
|
||||||
Aggregate activity data for wgctl activity.
|
Aggregate activity data for wgctl activity.
|
||||||
Output:
|
Output:
|
||||||
peer|name|rx_bytes|tx_bytes|drop_count
|
peer|name|rx_bytes|tx_bytes|drop_count
|
||||||
service|peer_name|dest_display|drop_count
|
service|peer_name|dest_display|dst_ip|dst_port|proto|drop_count
|
||||||
"""
|
"""
|
||||||
hours = int(hours) if hours else 24
|
hours = int(hours) if hours else 24
|
||||||
cutoff = None
|
cutoff = None
|
||||||
if hours > 0:
|
if hours > 0:
|
||||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
|
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
|
# Preload lookups once
|
||||||
ip_to_peer = build_ip_to_name(clients_dir)
|
ip_to_peer = build_ip_to_name(clients_dir)
|
||||||
pubkey_to_peer = build_pubkey_to_name(clients_dir)
|
pubkey_to_peer = build_pubkey_to_name(clients_dir)
|
||||||
net_data = load_net_data(net_file)
|
net_data = load_net_data(net_file)
|
||||||
|
|
||||||
def _reverse(dest_ip, dest_port, proto):
|
def _reverse(dest_ip, dest_port, proto):
|
||||||
return reverse_lookup(net_data, 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
|
# WireGuard transfer totals
|
||||||
peer_rx = defaultdict(int)
|
peer_rx = defaultdict(int)
|
||||||
peer_tx = defaultdict(int)
|
peer_tx = defaultdict(int)
|
||||||
|
|
@ -57,11 +80,12 @@ def activity_aggregate(fw_file, wg_file, wg_interface, net_file,
|
||||||
peer_tx[peer] += tx
|
peer_tx[peer] += tx
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Parse fw_events for drops
|
# Parse fw_events for drops
|
||||||
|
# service_drops[peer][(dest_display, dst_ip, dst_port, proto)] = count
|
||||||
peer_drops = defaultdict(int)
|
peer_drops = defaultdict(int)
|
||||||
service_drops = defaultdict(lambda: defaultdict(int))
|
service_drops = defaultdict(lambda: defaultdict(int))
|
||||||
|
|
||||||
if os.path.exists(fw_file):
|
if os.path.exists(fw_file):
|
||||||
try:
|
try:
|
||||||
with open(fw_file) as f:
|
with open(fw_file) as f:
|
||||||
|
|
@ -81,16 +105,16 @@ def activity_aggregate(fw_file, wg_file, wg_interface, net_file,
|
||||||
continue
|
continue
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
src_ip = ev.get('src_ip', '')
|
src_ip = ev.get('src_ip', '')
|
||||||
if not src_ip:
|
if not src_ip:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
dest_ip = ev.get('dest_ip', '')
|
dest_ip = ev.get('dest_ip', '')
|
||||||
dest_port = str(ev.get('dest_port', ''))
|
dest_port = str(ev.get('dest_port', ''))
|
||||||
proto_num = ev.get('ip.protocol', 0)
|
proto_num = ev.get('ip.protocol', 0)
|
||||||
proto = PROTO_MAP.get(int(proto_num), str(proto_num))
|
proto = PROTO_MAP.get(int(proto_num), str(proto_num))
|
||||||
|
|
||||||
peer = ip_to_peer.get(src_ip)
|
peer = ip_to_peer.get(src_ip)
|
||||||
if not peer:
|
if not peer:
|
||||||
continue
|
continue
|
||||||
|
|
@ -98,18 +122,23 @@ def activity_aggregate(fw_file, wg_file, wg_interface, net_file,
|
||||||
continue
|
continue
|
||||||
if filter_service_ip and dest_ip != filter_service_ip:
|
if filter_service_ip and dest_ip != filter_service_ip:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
svc_name = _reverse(dest_ip, dest_port, proto)
|
svc_name = _reverse(dest_ip, dest_port, proto)
|
||||||
dest_display = make_dest_display(dest_ip, dest_port, proto, svc_name)
|
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
|
peer_drops[peer] += 1
|
||||||
service_drops[peer][dest_display] += 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:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Collect peers with any activity
|
# Collect peers with any activity
|
||||||
all_peers = set()
|
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_rx if peer_rx[k] > 0)
|
||||||
|
|
@ -117,13 +146,14 @@ def activity_aggregate(fw_file, wg_file, wg_interface, net_file,
|
||||||
all_peers.update(peer_drops.keys())
|
all_peers.update(peer_drops.keys())
|
||||||
if filter_peer:
|
if filter_peer:
|
||||||
all_peers = {p for p in all_peers if p == filter_peer}
|
all_peers = {p for p in all_peers if p == filter_peer}
|
||||||
|
|
||||||
for peer in sorted(all_peers):
|
for peer in sorted(all_peers):
|
||||||
rx = peer_rx.get(peer, 0)
|
rx = peer_rx.get(peer, 0)
|
||||||
tx = peer_tx.get(peer, 0)
|
tx = peer_tx.get(peer, 0)
|
||||||
drops = peer_drops.get(peer, 0)
|
drops = peer_drops.get(peer, 0)
|
||||||
print(f"peer|{peer}|{rx}|{tx}|{drops}")
|
print(f"peer|{peer}|{rx}|{tx}|{drops}")
|
||||||
|
|
||||||
svc_map = service_drops.get(peer, {})
|
svc_map = service_drops.get(peer, {})
|
||||||
for dest_display, count in sorted(svc_map.items(), key=lambda x: -x[1]):
|
for (dest_display, dst_ip, dst_port, proto), count in \
|
||||||
print(f"service|{peer}|{dest_display}|{count}")
|
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))
|
||||||
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 '')
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
{
|
{
|
||||||
"phone-fred": "176.223.61.130",
|
"phone-fred": "176.223.61.130",
|
||||||
"phone-helena": "148.69.46.73",
|
"phone-helena": "148.69.46.73",
|
||||||
"phone-nuno": "148.69.50.62",
|
"phone-nuno": "94.63.0.129",
|
||||||
"tablet-nuno": "148.69.202.5",
|
"tablet-nuno": "148.69.202.5",
|
||||||
"guest-zephyr": "86.120.152.74",
|
"guest-zephyr": "86.120.152.74",
|
||||||
"guest-zephyr-test": "94.63.0.129",
|
"guest-zephyr-test": "94.63.0.129",
|
||||||
"desktop-roboclean": "46.189.215.231",
|
"desktop-roboclean": "46.189.215.231",
|
||||||
"laptop-nuno": "94.63.0.129",
|
"laptop-nuno": "94.63.0.129",
|
||||||
"phone-luis": "176.223.61.15",
|
"phone-luis": "176.223.61.15",
|
||||||
"phone-helena-2": "148.69.203.225",
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,9 @@ 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="wg0"
|
_WG_INTERFACE="wg0"
|
||||||
_WG_DNS="10.0.0.103"
|
_WG_DNS="10.0.0.103"
|
||||||
|
|
@ -89,6 +92,14 @@ function config::_load_json() {
|
||||||
ACTIVITY_CURRENT_LOW_BYTES) _ACTIVITY_CURRENT_LOW_BYTES="$value" ;;
|
ACTIVITY_CURRENT_LOW_BYTES) _ACTIVITY_CURRENT_LOW_BYTES="$value" ;;
|
||||||
ACTIVITY_CURRENT_MED_BYTES) _ACTIVITY_CURRENT_MED_BYTES="$value" ;;
|
ACTIVITY_CURRENT_MED_BYTES) _ACTIVITY_CURRENT_MED_BYTES="$value" ;;
|
||||||
ACTIVITY_CURRENT_HIGH_BYTES) _ACTIVITY_CURRENT_HIGH_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
|
esac
|
||||||
done < <(json::config_load "$file" 2>/dev/null)
|
done < <(json::config_load "$file" 2>/dev/null)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# ui/activity.module.sh — rendering for wgctl activity
|
# ui/activity.module.sh — rendering for wgctl activity
|
||||||
|
|
||||||
# ui::activity::peer_row <name_pad> <rx_pad> <tx_pad> <drops> <drop_word> <w_drops>
|
|
||||||
function ui::activity::peer_row() {
|
function ui::activity::peer_row() {
|
||||||
local name_pad="${1:-}" rx_pad="${2:-}" tx_pad="${3:-}" \
|
local name_pad="${1:-}" rx_pad="${2:-}" tx_pad="${3:-}" \
|
||||||
drops="${4:-0}" drop_word="${5:-drops}" w_drops="${6:-1}"
|
drops="${4:-0}" drop_word="${5:-drops}" w_drops="${6:-1}"
|
||||||
|
|
@ -10,25 +9,78 @@ function ui::activity::peer_row() {
|
||||||
"$name_pad" "$rx_pad" "$tx_pad" "$drops" "$drop_word"
|
"$name_pad" "$rx_pad" "$tx_pad" "$drops" "$drop_word"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ui::activity::service_row <dest_display> <drop_count> <drop_word> <drops_col> <w_drops>
|
# ── _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() {
|
function ui::activity::service_row() {
|
||||||
local dest_display="${1:-}" drop_count="${2:-0}" drop_word="${3:-drops}" \
|
local dest_display="${1:-}" drop_count="${2:-0}" drop_word="${3:-drops}" \
|
||||||
drops_col="${4:-30}" w_drops="${5:-1}"
|
drops_col="${4:-30}" w_count="${5:-1}"
|
||||||
|
|
||||||
# Align drop count with peer drop column
|
|
||||||
# Service row visible prefix: " → " (6 visible) + ${#dest_display}
|
|
||||||
# But "→" is 3 bytes, 1 visible — arrow_prefix bytes = 8, visible = 6
|
|
||||||
local arrow_prefix=" → "
|
local arrow_prefix=" → "
|
||||||
local prefix_bytes=${#arrow_prefix} # 8 bytes due to → being 3 bytes
|
local prefix_bytes=${#arrow_prefix}
|
||||||
local prefix_len=$(( prefix_bytes + ${#dest_display} ))
|
# 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 ))
|
local pad_n=$(( drops_col - prefix_len ))
|
||||||
[[ $pad_n -lt 1 ]] && pad_n=1
|
[[ $pad_n -lt 1 ]] && pad_n=1
|
||||||
|
|
||||||
printf " \033[2m→\033[0m %s%*s %${w_drops}s %s\n" \
|
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"
|
"$dest_display" "$pad_n" "" "$drop_count" "$drop_word"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Table versions (kept for future display config)
|
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() {
|
function ui::activity::header_table() {
|
||||||
printf "\n %-24s %-14s %-14s %s\n" "PEER" "↓ RX" "↑ TX" "DROPS"
|
printf "\n %-24s %-14s %-14s %s\n" "PEER" "↓ RX" "↑ TX" "DROPS"
|
||||||
printf " %s\n" "$(printf '─%.0s' {1..65})"
|
printf " %s\n" "$(printf '─%.0s' {1..65})"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -392,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}"
|
||||||
|
|
@ -210,4 +183,20 @@ function ui::subnet::show_peers_annotated() {
|
||||||
for peer in "${no_identity[@]}"; do
|
for peer in "${no_identity[@]}"; do
|
||||||
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:--}"
|
||||||
}
|
}
|
||||||
12
wgctl
12
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
|
||||||
|
|
@ -38,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
|
||||||
|
|
@ -48,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
|
||||||
|
|
@ -73,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