Compare commits

..

40 commits

Author SHA1 Message Date
Nuno Duque Nunes
7a544f9019 feat: display config system, table/compact toggle
- modules/display.module.sh: display config loader
- .wgctl/config/display.json: per-view style configuration
- ctx::display: points to .wgctl/config/display.json
- json_helper: display_load() reads view styles
- list: display::render dispatcher, _render_table with dynamic widths/colors
- ui::peer::_row_color/status_color: shared by both table and compact
- table layout: aligned columns, colored status/rows, dynamic separator
2026-05-27 02:48:51 +00:00
Nuno Duque Nunes
dda8e408e8 merge master 2026-05-27 02:05:32 +00:00
Nuno Duque Nunes
74644e547c Merge refactor/config-restructure: JSON config, data/ layout 2026-05-27 01:37:39 +00:00
Nuno Duque Nunes
2a6648735e refactor: config restructure, wgctl.json, data/ directory layout
- context.sh: .wgctl/{config,data,daemon} directory structure
- ctx::config_file: points to .wgctl/config/wgctl.json
- ctx::data: points to .wgctl/data/ (rules, identities, groups, etc.)
- ctx::peer_history: .wgctl/data/peer-history/
- config.module.sh: loads from wgctl.json via json::config_load
- config::_load_legacy: fallback for old wgctl.conf with migration warning
- json_helper.py: config_load() outputs KEY=value pairs from wgctl.json
- cmd::config::migrate: converts wgctl.conf → wgctl.json, moves data files
- cmd::config::_show: renamed from run body
- daemon/wgctl-monitor.py: updated PEER_HISTORY_DIR path
2026-05-27 01:37:28 +00:00
Nuno Duque Nunes
50013d8ede add remaining tests related to json api 2026-05-27 01:04:30 +00:00
Nuno Duque Nunes
1fa40c1e25 Merge feature/json-output: --json output, command mixin system (v0.6.0) 2026-05-27 00:52:47 +00:00
Nuno Duque Nunes
0e3f281519 feat: --json for hosts/subnet/policy list commands
- cmd::hosts::_output_json: hosts with type/tags array
- cmd::subnet::_output_json: subnets with is_group bool
- cmd::policy::_output_json: policies with proper booleans
2026-05-27 00:52:45 +00:00
Nuno Duque Nunes
087f735790 feat: --json output for group/rule/identity/net/activity
- cmd::group::_output_json: groups array with peer/blocked counts
- cmd::rule::_output_json: rules with extends array, is_base bool fix
- cmd::identity::_output_json: identities with types/rules as arrays
- cmd::net::_output_json: services with tags array and port count
- cmd::activity::_output_json: peers with nested services array
- all commands: command::mixin json_output registered in on_load
2026-05-27 00:36:30 +00:00
Nuno Duque Nunes
fae088f61a commands/mixin/MIXIN_TEMPLATE.mixin.sh 2026-05-27 00:01:53 +00:00
Nuno Duque Nunes
14d2a78b78 feat: command mixin system, --json for list/inspect, tests
- core/command_mixins.sh: mixin infrastructure with auto-loader
- core/mixins/json_output.mixin.sh, no_color.mixin.sh
- commands/mixins/MIXIN_TEMPLATE.mixin.sh
- command::run: mixin preprocess with nameref, empty array guard
- list --json, inspect --json: structured JSON with envelope
- json::envelope, json::error_envelope
- tests: json output unit tests, group purge-stale, logs clean
2026-05-27 00:01:06 +00:00
Nuno Duque Nunes
a3fe7f5986 feat: command mixin system, --json output for list/inspect
- core/command_mixins.sh: mixin infrastructure with auto-loader
- core/mixins/json_output.mixin.sh: --json flag mixin
- core/mixins/no_color.mixin.sh: --no-color flag mixin
- commands/mixins/MIXIN_TEMPLATE.mixin.sh: template for new mixins
- command::run: reset mixin state, preprocess flags before dispatch
- command::_preprocess_flags: nameref-based flag stripping, empty array fix
- command::mixin: opt-in registration from on_load
- list --json: structured JSON output with envelope
- inspect --json: structured JSON peer detail output
- json::envelope, json::error_envelope helpers
2026-05-26 23:18:56 +00:00
Nuno Duque Nunes
adab623f3f feat: group purge-stale, peer endpoint history, resolve improvements
- group purge-stale: remove stale peers from group(s), --all, --dry-run
- daemon: update_peer_history() tracks all endpoints per peer
- daemon: endpoint_index.json for O(1) IP -> peer name lookup
- json_helper: peer_history_lookup() with index + scan fallback
- resolve::endpoint_parts: peer history as step 3 in resolution chain
- resolve::service_name: returns service name only, no raw fallback
- resolve::endpoint_parts: removed stale cache, always fresh
- watch: ui::wg_row/fw_row use shared primitives
- ui: ui::_render_endpoint_col, ui::_build_dest shared primitives
- shell: peer/hosts/identity/subnet/policy/activity in known commands
2026-05-26 20:51:40 +00:00
Nuno Duque Nunes
8b47e55b4a feat: peer endpoint history tracking and resolution
- daemon: update_peer_history() tracks all endpoints per peer
- daemon: endpoint_index.json for O(1) IP -> peer name lookup
- daemon: poll_handshakes updates history on every cycle
- json_helper: peer_history_lookup() uses index, falls back to scan
- resolve::endpoint_parts: step 3 checks peer history index
- json.sh: json::peer_history_lookup wrapper
- resolve: mobile peer IPs now resolve to peer name via history
2026-05-26 15:51:53 +00:00
Nuno Duque Nunes
c3cf5bc572 feat: watch/logs endpoint annotation, shared row primitives
- ui::_render_endpoint_col: shared endpoint padding primitive
- ui::_build_dest: shared destination display primitive
- ui::wg_row/fw_row: endpoint annotation (raw_ip → resolved)
- resolve::endpoint_parts: fresh resolution, no stale cache
- resolve::service_name: returns service name or empty (no raw fallback)
- monitor::live: pre-measure w_client from peer names
- watch: fixed w_endpoint=30 for consistent live alignment
- shell: add peer/hosts/identity/subnet/policy/activity to known commands
- shell: updated banner with new commands
- identity/rule help: updated with new features
2026-05-26 15:16:33 +00:00
Nuno Duque Nunes
c6883c6801 fix: --ascending/--descending flag parsing in logs show
- cmd::logs::show: parse --ascending and --descending flags
- sort_order defaults to desc
2026-05-26 12:40:35 +00:00
Nuno Duque Nunes
7120199004 feat: logs --resolved flag, logs clean, performance improvements
- logs --resolved: show only resolved names, hide raw IPs
- logs clean: remove keepalive handshakes via json::clean_handshakes
- batch_resolve: single Python call for all endpoint resolutions
- fw_row/wg_row: native bash padding replaces ui::pad_mb (5x speedup)
- fw_row/wg_row: correct arrow byte counting (→ = 3 bytes, 1 visible)
- help: updated with new subcommands and flags
- on_load: --resolved, --ascending, --descending registered
2026-05-26 04:34:39 +00:00
Nuno Duque Nunes
fb33aa1b6d feat: logs endpoint annotation, alignment, descending sort
- fw/wg events: raw_ip → resolved_name annotation (dim)
- fw events: endpoint column with pre-resolved names (two-pass render)
- fw events: raw IP:port dim suffix after service name
- wg events: endpoint annotation in logs (same as watch)
- fw/wg: descending sort default, --ascending/--descending flags
- wg events: gap/offline indicator, threshold * 2 for offline label
- fw_row: no-endpoint rows show dim — placeholder for alignment
- section headers: dynamic width via tput cols
2026-05-26 03:07:57 +00:00
Nuno Duque Nunes
d5de344d99 add ctx::endpoint_cache as arg to wrapper 2026-05-26 01:47:13 +00:00
Nuno Duque Nunes
3c3f870427 feat: logs descending sort, gap/offline indicator, endpoint resolution
- wg_events: sort_order param (desc default), --ascending/--descending flags
- wg_events: endpoint cache fallback via _endpoint() helper
- wg_events: gap computed ascending always, then sliced/reversed correctly
- fw_events: sort_order param, descending default
- ui::logs::wg_row: gap suffix with 'offline' label when gap > threshold
- logs.command.sh: --ascending/--descending flags, pass sort_order to both functions
- daemon: endpoint cache fallback in poll_handshakes
- json.sh: json::wg_events passes ctx::endpoint_cache as arg
2026-05-26 01:34:48 +00:00
Nuno Duque Nunes
cf71e9f51a test: add tests for all new features, fix bugs found by tests
- integration: logs query flags, hosts command, peer command sections
- unit: fmt::bytes, config::dns_string, parse_since, ui::group::status
- destructive: duplicate rule validation, peer update-dns/tunnel
- fix: config::allowed_ips_for used $2 instead of $1
- fix: identity rule assign exit_code unbound variable
- fix: ctx::identity → ctx::identities in peers::get_identity
- fix: peers::get_identity restored (needed for rule assign duplicate check)
- rule assign: blocks if rule already in peer's identity via peers::get_identity
- identity rule assign: --migrate removes conflicting direct peer rules
2026-05-26 00:09:30 +00:00
Nuno Duque Nunes
794e75bc9b feat: duplicate rule validation, peer command, fallback DNS
- rule assign: block if rule already in peer's identity
- identity rule assign: --migrate flag to remove conflicting direct peer rules
- commands/peer.command.sh: update-dns and update-tunnel subcommands
- config.sh: config::dns_fallback, config::dns_string
- peers.module.sh: peers::get_display_subnet extraction
- wgctl peer update-dns --all: retrofits existing peer configs with fallback DNS
- wgctl.conf: WG_DNS_FALLBACK support
2026-05-25 21:39:17 +00:00
Nuno Duque Nunes
d14db5e85c remove hardcoded policies 2026-05-25 18:47:48 +00:00
Nuno Duque Nunes
a003e3b753 fix: policy_read accidental defaults merge from module split
- _policy_read: remove erroneous _POLICY_DEFAULTS merge (introduced during split)
- fmt.sh: fmt::bytes extracted from cmd::activity::_fmt_bytes
- identity/subnet/policy list: ui::sort_rows applied
- ctx::policies moved from policy.module.sh to context.sh
2026-05-25 18:45:23 +00:00
Nuno Duque Nunes
86220850c1 fix: handshake session detection, endpoint cache, watch ordering
- wgctl-monitor: update _hs_last_logged on ALL handshakes not just new sessions
- wgctl-monitor: fix endpoint_cache.json absolute path
- wgctl-monitor: move script to wgctl/daemon/ (correct location)
- watch: _poll_handshakes sorts by ts descending, endpoint cache fallback
- watch: empty endpoint uses - not em dash (alignment fix)
- logs: newline between fw and wg sections
- monitor::live extracted, cmd::logs::follow no longer calls cmd::run
- ui.sh: UTF-8 extra byte constants
2026-05-25 16:19:13 +00:00
Nuno Duque Nunes
3058750c3d cleanup: ui::pad_mb removal, watch alignment fixes, endpoint cache fallback
- ui::rule::list_row: inline padding math replaces ui::pad_mb (major perf gain)
- ui::fw_row/wg_row: drop ui::pad_mb for fw/wg labels (always 2 chars)
- watch: endpoint fallback via monitor::get_cached_endpoint
- watch: _poll_handshakes sorts by ts descending (most recent first)
- watch: empty endpoint uses - not — (avoids multi-byte padding issues)
- ui.sh: UTF-8 extra byte constants (_UI_EMDASH_EXTRA, _UI_ARROW_EXTRA, _UI_BULLET_EXTRA)
2026-05-25 15:09:13 +00:00
Nuno Duque Nunes
5c2e16e358 Merge feature/logs-query: logs query flags, json_helper module split, handshake logging (v0.5.1) 2026-05-25 14:07:52 +00:00
Nuno Duque Nunes
8b1f4e48c1 fix block_is_empty missing in dict 2026-05-25 14:07:26 +00:00
Nuno Duque Nunes
3378ec3e5e feat: logs query flags, json_helper module split, handshake logging
- wgctl logs --since: relative (2h/7d) and EU/ISO date formats
- wgctl logs --service: filter by service name, IP, or IP:port
- wgctl logs --event: filter wg events by type
- wgctl logs: no header when no logs found
- core/lib/util.py: shared utilities, parse_since, reverse_lookup
- core/lib/events.py: fw_events, wg_events with query params
- core/lib/peers.py: peer_data, peer_transfer
- core/lib/activity.py: activity_aggregate
- wgctl-monitor.py: handshake session poller thread with cache
2026-05-25 00:21:16 +00:00
Nuno Duque Nunes
1308f9e07a refactor: split json_helper.py into lib/ modules
- core/lib/util.py: shared utilities, ip_to_name, reverse_lookup, parse_since
- core/lib/events.py: fw_events, wg_events, follow_logs, event parsers
- core/lib/peers.py: peer_data, peer_transfer, peer_transfer_delta
- core/lib/activity.py: activity_aggregate
- json_helper.py: thin dispatcher importing from lib/
- events.py: --since, --filter-event, --filter-dest-ip/port query flags
- util.py: parse_since supporting relative (2h/7d) and EU/ISO date formats
2026-05-24 22:02:50 +00:00
Nuno Duque Nunes
28ee56aeff feat: identity show with rule tree, peer dimming, net/group tableless layouts
- identity show: peers, rules tree, dim offline peers
- ui::rule::identity_block --no-header flag with reduced indentation
- ui::identity::device_row: index suffix fix, offline dimming
- net list/show: tableless with port display and descriptions
- group list/show: tableless with status coloring, stale peer handling
- group list_data: filter stale peers via clients_dir
- logs: hourly collapse for attempts, --detailed for raw events
- hosts resolution in wg_events static view
- wg-quick PostDown iptables error fix (2>/dev/null)
2026-05-24 20:46:02 +00:00
Nuno Duque Nunes
a71f7a0dd9 fix fw logs not showing, add hourly structuring to logs 2026-05-24 02:13:06 +00:00
Nuno Duque Nunes
92993e6423 merge tableless-list refactors 2026-05-24 00:09:11 +00:00
Nuno Duque Nunes
689908c875 refactor: tableless design for net, group list | net, group show 2026-05-24 00:08:15 +00:00
Nuno Duque Nunes
e54ce9c417 load module policy 2026-05-23 23:30:39 +00:00
Nuno Duque Nunes
a9dcba73f4 Merge feature/hosts-resolution: IP resolution system (v0.5.0) 2026-05-23 22:13:49 +00:00
Nuno Duque Nunes
b813810ff3 feat: hosts.json IP resolution system
- wgctl hosts command (list, show, add, rm) with tags support
- modules/resolve.module.sh — chain: hosts.json → services.json → raw IP
- modules/hosts.module.sh — hosts::resolve_ip, hosts::lookup_ip
- resolve::ip and resolve::dest used in watch, logs, activity
- _WGCTL_RAW=true via --raw flag bypasses all resolution
- json_helper.py: hosts_list, hosts_show, hosts_add, hosts_remove, hosts_lookup
2026-05-23 22:01:45 +00:00
Nuno Duque Nunes
6323f758ae Merge feature/display-config: tableless layouts, peer coloring, rule list improvements (v0.4.1) 2026-05-23 05:11:26 +00:00
Nuno Duque Nunes
1cfa5528c8 Merge feature/display-config: tableless layouts and activity monitor (v0.4.0) 2026-05-23 03:28:21 +00:00
Nuno Duque Nunes
7aff1d146d Merge feature/activity-monitor: wgctl activity command (v0.3.1) 2026-05-22 20:41:09 +00:00
Nuno Duque Nunes
d046596766 Merge feature/activity-monitor: list display fixes 2026-05-22 16:47:48 +00:00
59 changed files with 6388 additions and 2462 deletions

View file

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

View file

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

View file

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

325
commands/hosts.command.sh Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

197
commands/peer.command.sh Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

View file

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

111
core/command_mixins.sh Normal file
View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

0
core/lib/__init__.py Normal file
View file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

129
core/lib/activity.py Normal file
View file

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

674
core/lib/events.py Normal file
View file

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

180
core/lib/peers.py Normal file
View file

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

255
core/lib/util.py Normal file
View file

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

View file

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

View file

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

View file

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

View file

@ -1,13 +1,13 @@
{ {
"phone-fred": "176.223.61.130", "phone-fred": "176.223.61.130",
"phone-helena": "148.69.46.73", "phone-helena": "148.69.46.73",
"phone-nuno": "94.63.0.129", "phone-nuno": "148.69.48.20",
"tablet-nuno": "148.69.202.5", "tablet-nuno": "148.69.202.5",
"guest-zephyr": "86.120.152.74", "guest-zephyr": "86.120.152.74",
"guest-zephyr-test": "94.63.0.129", "guest-zephyr-test": "94.63.0.129",
"desktop-roboclean": "46.189.215.231", "desktop-roboclean": "46.189.215.231",
"laptop-nuno": "94.63.0.129", "laptop-nuno": "94.63.0.129",
"phone-luis": "176.223.61.15", "phone-luis": "176.223.61.15",
"phone-helena-2": "148.69.202.127", "phone-helena-2": "148.69.202.234",
"desktop-zephyr": "86.120.152.74" "desktop-zephyr": "86.120.152.74"
} }

385
daemon/wgctl-monitor.py Executable file
View file

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

View file

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

View file

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

72
modules/display.module.sh Normal file
View file

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

22
modules/hosts.module.sh Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

125
modules/resolve.module.sh Normal file
View file

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

View file

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

162
modules/ui/group.module.sh Normal file
View file

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

View file

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

View file

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

View file

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

59
modules/ui/net.module.sh Normal file
View file

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

View file

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

4
wgctl
View file

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