Compare commits

...

53 commits

Author SHA1 Message Date
Nuno Duque Nunes
e4545a400b feat: --exclude-service/--include-service, --ports flags
- activity: --exclude-service (repeatable), --include-service override
- activity: --ports flag shows dim raw IP:port on accept and drop rows
- activity_aggregate: dst_ip/dst_port/proto in service row output
- activity_aggregate: exclude_services filtering for drop rows
- ui::activity::_visible_len: ANSI-aware padding for --ports alignment
- service_row/accept_dest_row: correct padding with ANSI suffixes
- accept_dest_row: fix ↓/↑ swap (bytes_reply=download, bytes_orig=upload)
- command defaults: activity defaults with pihole exclusions
2026-05-29 23:34:13 +00:00
Nuno Duque Nunes
10ea174e44 Merge feature/command-defaults: defaults, aliases, exclusive flags 2026-05-29 15:52:41 +00:00
Nuno Duque Nunes
9c11152682 feat: command defaults, aliases, exclusive flag groups
- wgctl.json: commands section with defaults and aliases
- command_mixins.sh: flag::exclusive, command::_resolve_conflicts
- command::run: two-pass defaults+user with conflict resolution
- load_command: _CURRENT_LOADING_CMD for flag::exclusive context
- list: flag::exclusive --online --offline --blocked --restricted --allowed
- logs: flag::exclusive --ascending --descending
- logs: fix --fw --wg together treated as neither (show both)
- dispatch: config alias resolution before load_command
- wgctl ls/peers/act: aliases via wgctl.json
2026-05-29 04:31:07 +00:00
Nuno Duque Nunes
b153f222a5 perf: batch resolve accept dest IPs in activity (4x speedup)
- json_helper: batch_resolve_dest() resolves all dest IPs in one Python call
- json.sh: json::batch_resolve_dest wrapper
- activity: _DEST_RESOLVE_CACHE pre-populated before render loop
- _render_peer_accept_dests: cache lookup instead of resolve::dest per row
- activity: 1.8s -> 0.48s
2026-05-29 00:27:43 +00:00
Nuno Duque Nunes
d26e67b940 Merge feature/accept-logging: conntrack daemon, activity integration 2026-05-28 23:34:12 +00:00
Nuno Duque Nunes
b892298259 feat: accept logging, conntrack daemon, activity integration
- daemon/wgctl-conntrack: Go daemon for conntrack DESTROY events
- wgctl-conntrack.service: systemd service
- core/lib/accept_events.py: accept_events(), accept_aggregate()
- ctx::accept_events_log: .wgctl/daemon/accept_events.log
- activity: ACCEPT row with bytes in/out and conn count
- activity: accept dest rows with ↓/↑ bytes at end
- activity: --accept, --drop, --external flags
- activity: unified w_count for drop/accept alignment
- activity: drop service rows in red
- activity: accept dest rows in green
- sysctl: nf_conntrack_acct=1 for byte counting
- note: --exclude-service/--include-service deferred
2026-05-28 23:31:10 +00:00
Nuno Duque Nunes
d314ba376e feat: wgctl-conntrack Go daemon
- conntrack/event.go: TrafficEvent type
- conntrack/filter.go: WG subnet filter, IsExternal, ProtoName
- conntrack/subscriber.go: netlink conntrack DESTROY subscriber
- writer/log.go: JSON line writer with mutex
- resolver/peers.go: WG IP → peer name from conf files + endpoint index
- resolver/services.go: IP:port → service name from services.json
- config/config.go: reads wgctl.json, sensible defaults
- cmd/root.go: CLI flags
- main.go: wires everything together
- DESTROY events only: full byte/packet counts per connection
- filters to WireGuard subnet, marks external traffic
2026-05-28 02:51:27 +00:00
Nuno Duque Nunes
91593b2576 Merge feature/block-history into master 2026-05-28 01:54:02 +00:00
Nuno Duque Nunes
0b9f113453 feat: block history tracking
- core/lib/block_history.py: record/unblock/list functions
- ctx::block_history: .wgctl/data/block-history/ path
- block --reason: record block event with reason, endpoint, triggered_by
- unblock --reason: update block event with unblock timestamp
- json::block_history_record/unblock/list/list_all wrappers
- json::endpoint_cache_get: get cached endpoint for peer
- export --all: include block-history in full backup
- import --all: restore block-history files
- tests: section_block_unblock with fixture peer, history field validation
2026-05-28 01:51:37 +00:00
Nuno Duque Nunes
79769667fb add json core functions 2026-05-27 23:08:21 +00:00
Nuno Duque Nunes
ddd705aa87 Merge feature/export-import: wgctl export/import commands (v0.8.0) 2026-05-27 16:47:41 +00:00
Nuno Duque Nunes
00d6be0766 add export,import features/add tests 2026-05-27 16:46:09 +00:00
Nuno Duque Nunes
8f3360c631 feat: wgctl export command
- export --peer: single peer export with conf/meta/identity/groups/blocks
- export --identity: identity export
- export --all: full backup with all sections
- export --conf-only, --meta-only: selective peer export
- export --no-config, --no-peers: selective full export
- export --out: write to file
- json_helper: export_full(), _export_peer_data() Python functions
- base64 encoding for conf and block files
- valid JSON output via Python for full backup
2026-05-27 04:11:59 +00:00
Nuno Duque Nunes
a7b05547f5 Merge feature/display-config: display config system, table/compact toggle 2026-05-27 03:32:44 +00:00
Nuno Duque Nunes
1a78dcf5da feat: display config, table layouts for all commands
- display.module.sh: style toggle per view (compact/table)
- display.json: default config with all views set to compact
- ctx::display: points to .wgctl/config/display.json
- list: _render_table with dynamic widths, colors, shared row_color/status_color
- group/identity/net/hosts/activity: _render_table added
- rule/subnet/policy: table UI functions + _render_table
- ui::peer::status_color: \033[2m for offline (dimmer, more readable)
- note: individual table layout refinements pending cleanup pass
- note: configurable colors per field deferred to display config v2
2026-05-27 03:32:31 +00:00
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
560e4cbe09 feat: peer list row coloring, verbose status, dim offline rows
- Dim gray for offline peers — lights off visual
- Dim/bold red for blocked peers (offline/online)
- Dim/bold yellow for restricted peers (offline/online)
- online (blocked) / offline (blocked) verbose status
- LIST_VERBOSE_STATUS=false to revert to simple status
- Rule list: +0/-0 dimmed, 0 peers dimmed
- Summary includes group breakdown
2026-05-23 04:51:33 +00:00
86 changed files with 9699 additions and 2651 deletions

View file

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

View file

@ -16,6 +16,7 @@ function cmd::block::on_load() {
flag::register --subnet
flag::register --block-name
flag::register --service
flag::register --reason
}
# ============================================
@ -61,7 +62,8 @@ function cmd::block::run() {
local name="" identity="" type="" block_name=""
local ips=() subnets=() ports=() services=()
local quiet=false force=false
local reason=""
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
@ -74,6 +76,7 @@ function cmd::block::run() {
--quiet) quiet=true; shift ;;
--subnet) subnets+=("$2"); shift 2 ;;
--port) ports+=("$2"); shift 2 ;;
--reason) reason="$2"; shift 2 ;;
--help) cmd::block::help; return ;;
*)
log::error "Unknown flag: $1"
@ -82,25 +85,25 @@ function cmd::block::run() {
;;
esac
done
# --identity: block all peers for this identity
if [[ -n "$identity" ]]; then
cmd::block::_block_identity "$identity" "$quiet" \
"${ips[@]+"${ips[@]}"}" || return 1
return 0
fi
[[ -z "$name" ]] && {
log::error "Missing required flag: --name or --identity"
cmd::block::help
return 1
}
name=$(peers::resolve_and_require "$name" "$type") || return 1
local client_ip
client_ip=$(peers::get_ip "$name") || return 1
# Full block if no specific targets
if [[ ${#ips[@]} -eq 0 && ${#ports[@]} -eq 0 && \
${#subnets[@]} -eq 0 && ${#services[@]} -eq 0 ]]; then
@ -110,9 +113,10 @@ function cmd::block::run() {
fi
monitor::update_endpoint_cache
cmd::block::_block_all "$name" "$client_ip" "$quiet"
cmd::block::_record_history "$name" "full" "manual" "$reason"
return 0
fi
# Specific rules — check if already fully blocked
if block::has_file "$name"; then
local direct
@ -122,9 +126,9 @@ function cmd::block::run() {
return 1
fi
fi
local changed=false
# Block specific IPs
for ip in "${ips[@]}"; do
ip::require_valid "$ip"
@ -132,7 +136,7 @@ function cmd::block::run() {
block::add_rule "$name" "$client_ip" "ip" "${block_name:-}" "$ip"
$quiet || log::wg_success "${ip} has been blocked for ${name}"
done
# Block specific subnets
for subnet in "${subnets[@]}"; do
ip::require_valid "$subnet"
@ -140,7 +144,7 @@ function cmd::block::run() {
block::add_rule "$name" "$client_ip" "subnet" "${block_name:-}" "$subnet"
$quiet || log::wg_success "${subnet} has been blocked for ${name}"
done
# Block specific ports
for entry in "${ports[@]}"; do
local b_target b_port b_proto
@ -151,7 +155,7 @@ function cmd::block::run() {
"$b_target" "$b_port" "${b_proto:-tcp}"
$quiet || log::wg_success "${client_ip}:${b_port}:${b_proto:-tcp} has been blocked for ${name}"
done
# Block services
for svc in "${services[@]}"; do
local resolved_lines=()
@ -160,7 +164,7 @@ function cmd::block::run() {
log::error "Service not found or has no ports: ${svc}"
return 1
fi
local already_blocked=true
for resolved in "${resolved_lines[@]}"; do
if [[ "$resolved" == *:*:* ]]; then
@ -173,12 +177,12 @@ function cmd::block::run() {
{ already_blocked=false; break; }
fi
done
if $already_blocked; then
$quiet || log::wg_warning "${svc} is already blocked for ${name}"
continue
fi
for resolved in "${resolved_lines[@]}"; do
if [[ "$resolved" == *:*:* ]]; then
local b_ip b_port b_proto
@ -191,14 +195,14 @@ function cmd::block::run() {
block::add_rule "$name" "$client_ip" "ip" "$svc" "$resolved"
fi
done
changed=true
$quiet || log::wg_success "${svc} has been blocked for ${name}"
done
[[ ${#ips[@]} -gt 0 || ${#ports[@]} -gt 0 || \
${#subnets[@]} -gt 0 ]] && changed=true
if $changed; then
local peer_rule
peer_rule=$(peers::get_meta "$name" "rule")
@ -208,7 +212,15 @@ function cmd::block::run() {
block::restore_rules_for "$name" "$client_ip"
fi
fi
# Record history — derive block type from what was blocked
local btype="specific"
[[ ${#services[@]} -gt 0 ]] && btype="${services[0]}"
[[ ${#ips[@]} -gt 0 ]] && btype="ip"
[[ ${#subnets[@]} -gt 0 ]] && btype="subnet"
[[ ${#ports[@]} -gt 0 ]] && btype="port"
cmd::block::_record_history "$name" "$btype" "manual" "$reason"
return 0
}
@ -270,4 +282,25 @@ function cmd::block::_block_all() {
block::set_direct "$name" "$client_ip" "true"
$quiet || log::wg_success "${name} has been blocked."
}
function cmd::block::_record_history() {
local name="${1:-}" block_type="${2:-full}" \
triggered_by="${3:-manual}" reason="${4:-}"
local endpoint
endpoint=$(json::peer_history_lookup "$name" 2>/dev/null || true)
# endpoint_cache lookup
local ep_cache
ep_cache=$(json::endpoint_cache_get "$(ctx::endpoint_cache)" "$name" 2>/dev/null || true)
json::block_history_record \
"$(ctx::block_history)" \
"$name" \
"$block_type" \
"$triggered_by" \
"$reason" \
"${ep_cache:-}" \
2>/dev/null > /dev/null || true
}

View file

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

305
commands/export.command.sh Normal file
View file

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

View file

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

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

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

View file

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

288
commands/import.command.sh Normal file
View file

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

View file

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

View file

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

View file

@ -17,29 +17,50 @@ function cmd::logs::on_load() {
flag::register --force
flag::register --days
flag::register --raw
flag::register --detailed
flag::register --service
flag::register --event
flag::register --ascending
flag::register --descending
flag::register --resolved
flag::exclusive --ascending --descending
}
function cmd::logs::help() {
cat <<EOF
Usage: wgctl logs [subcommand] [options]
Show or manage WireGuard and firewall activity logs.
Subcommands:
show (default) Show activity logs
clean Remove keepalive handshakes (deduplicate)
remove, rm Remove log entries
rotate Remove entries older than N days
Options for show:
--name <name> Filter by client name
--type <type> Filter by device type
--limit <n> Max results per source (default: 50)
--fw Show only firewall drops
--wg Show only WireGuard events
--merged Show all events chronologically interleaved
--follow, -f Follow logs in real time (alias: wgctl watch)
--raw Show raw IPs without service annotation
--name <name> Filter by client name
--type <type> Filter by device type
--limit <n> Max results per source (default: 50)
--since <time> Show events since: 2h, 7d, 23/05, 23/05/2026, 2026-05-23
--service <svc> Filter by service name, IP, or IP:port
e.g. pihole, proxmox:web-ui, 10.0.0.100, 10.0.0.100:8006
--event <type> Filter wg events: attempt | handshake
--fw Show only firewall drops
--wg Show only WireGuard events
--merged Show all events chronologically interleaved
--detailed Show all deduplicated events (bypass hourly collapse)
--follow, -f Follow logs in real time
--raw Show raw IPs without service annotation
--resolved Show only resolved names, hide raw IPs
--ascending Sort oldest first
--descending Sort newest first (default)
Options for clean:
--wg Clean only WireGuard events (default: handshakes only)
--force Skip confirmation
Options for remove:
--name <name> Remove entries for specific peer
--all Remove all log entries
@ -47,17 +68,27 @@ Options for remove:
--wg Remove only WireGuard events
--before <days> Remove entries older than N days
--force Skip confirmation
Options for rotate:
--days <n> Days to keep (default: 7)
--force Skip confirmation
Examples:
wgctl logs
wgctl logs --name phone-nuno
wgctl logs --fw --limit 100
wgctl logs --since 2h
wgctl logs --since 23/05
wgctl logs --name phone-nuno --since 7d
wgctl logs --fw --service pihole
wgctl logs --fw --service proxmox:web-ui
wgctl logs --fw --service 10.0.0.100
wgctl logs --wg --event attempt
wgctl logs --wg --event handshake --since 24h
wgctl logs --detailed
wgctl logs --resolved
wgctl logs --merged
wgctl logs --follow
wgctl logs clean
wgctl logs clean --force
wgctl logs remove --name phone-nuno
wgctl logs rotate --days 30
EOF
@ -75,6 +106,7 @@ function cmd::logs::run() {
show) cmd::logs::show "$@" ;;
remove|rm|del) cmd::logs::remove "$@" ;;
rotate) cmd::logs::rotate "$@" ;;
clean) cmd::logs::clean "$@" ;;
help) cmd::logs::help ;;
*)
log::error "Unknown subcommand: '${subcmd}'"
@ -85,20 +117,32 @@ function cmd::logs::run() {
}
function cmd::logs::show() {
local name="" type="" limit=50
local fw_only=false wg_only=false follow=false merged=false raw=false
local name="" type="" limit=50 since=""
local fw_only=false wg_only=false follow=false merged=false
local raw=false detailed=false
local filter_service="" filter_event=""
local sort_order="desc"
local resolved=false
local sort_order="desc"
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;;
--limit) limit="$2"; shift 2 ;;
--fw) fw_only=true; shift ;;
--wg) wg_only=true; shift ;;
--merged) merged=true; shift ;;
--follow|-f) follow=true; shift ;;
--raw) raw=true; shift ;;
--help) cmd::logs::help; return ;;
--name) name="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;;
--limit) limit="$2"; shift 2 ;;
--since) since="$2"; shift 2 ;;
--service) filter_service="$2"; shift 2 ;;
--event) filter_event="$2"; shift 2 ;;
--fw) fw_only=true; shift ;;
--wg) wg_only=true; shift ;;
--merged) merged=true; shift ;;
--follow|-f) follow=true; shift ;;
--raw) raw=true; shift ;;
--resolved) resolved=true; shift ;;
--ascending) sort_order="asc"; shift ;;
--descending) sort_order="desc"; shift ;;
--detailed) detailed=true; shift ;;
--help) cmd::logs::help; return ;;
*)
log::error "Unknown flag: $1"
return 1
@ -106,6 +150,9 @@ function cmd::logs::show() {
esac
done
local collapse=1
$detailed && collapse=0
if [[ -n "$name" && -n "$type" ]]; then
name=$(peers::resolve_and_require "$name" "$type") || return 1
fi
@ -116,6 +163,11 @@ function cmd::logs::show() {
[[ -z "$filter_ip" ]] && log::error "Could not find IP for: $name" && return 1
fi
if $fw_only && $wg_only; then
fw_only=false
wg_only=false
fi
if $follow; then
cmd::logs::follow "$filter_ip" "$name" "$type" "$fw_only" "$wg_only"
return
@ -124,95 +176,257 @@ function cmd::logs::show() {
local net_file=""
$raw || net_file="$(ctx::net)"
log::section "WireGuard Activity Log"
printf "\n"
# Parse --service into dest_ip and dest_port
local filter_dest_ip="" filter_dest_port=""
if [[ -n "$filter_service" ]]; then
if [[ "$filter_service" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(:[0-9]+)?$ ]]; then
filter_dest_ip="${filter_service%%:*}"
local maybe_port="${filter_service##*:}"
[[ "$maybe_port" != "$filter_dest_ip" ]] && filter_dest_port="$maybe_port"
else
local svc_resolved
svc_resolved=$(net::resolve "$filter_service" 2>/dev/null | head -1)
if [[ -n "$svc_resolved" ]]; then
filter_dest_ip="${svc_resolved%%:*}"
local rest="${svc_resolved#*:}"
[[ "$rest" != "$filter_dest_ip" ]] && filter_dest_port="${rest%%:*}"
else
log::error "Service not found: ${filter_service}"
return 1
fi
fi
fi
if $merged; then
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
fi
$wg_only || cmd::logs::show_fw_events "$filter_ip" "$name" "$type" "$limit" "$net_file"
$fw_only || cmd::logs::show_wg_events "$filter_ip" "$name" "$type" "$limit"
# Collect output — only show header if there's data
local fw_output="" wg_output=""
$wg_only || fw_output=$(cmd::logs::show_fw_events \
"$filter_ip" "$name" "$type" "$limit" "$net_file" \
"$collapse" "$since" "$filter_dest_ip" "$filter_dest_port" "$sort_order" "$resolved")
$fw_only || wg_output=$(cmd::logs::show_wg_events \
"$filter_ip" "$name" "$type" "$limit" \
"$collapse" "$since" "$filter_event" "$sort_order" "$resolved")
if [[ -z "$(echo "$fw_output" | tr -d '[:space:]')" && \
-z "$(echo "$wg_output" | tr -d '[:space:]')" ]]; then
log::wg_warning "No logs found"
return 0
fi
log::section "WireGuard Activity Log"
printf "\n"
if [[ -n "$fw_output" && -n "$wg_output" ]]; then
printf "%s\n\n" "$fw_output"
printf "%s\n" "$wg_output"
elif [[ -n "$fw_output" ]]; then
printf "%s\n" "$fw_output"
else
printf "%s\n" "$wg_output"
fi
}
function cmd::logs::show_fw_events() {
local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" \
limit="${4:-50}" net_file="${5:-}"
limit="${4:-50}" net_file="${5:-}" collapse="${6:-1}" \
since="${7:-}" filter_dest_ip="${8:-}" filter_dest_port="${9:-}" \
sort_order="${10:-desc}" resolved_only="${11:-false}"
[[ ! -f "$FW_EVENTS_LOG" ]] && return 0
local data
data=$(json::fw_events "$FW_EVENTS_LOG" "$filter_ip" "$filter_type" \
"$(ctx::clients)" "${net_file:-}" "$limit" 2>/dev/null)
data=$(json::fw_events \
"$FW_EVENTS_LOG" "$filter_ip" "$filter_type" \
"$(ctx::clients)" "${net_file:-}" \
"$limit" "$collapse" "$since" \
"$filter_dest_ip" "$filter_dest_port" \
"$sort_order" \
2>/dev/null)
[[ -z "$data" ]] && return 0
# Measure column widths
local w_client=16 w_dest=20
while IFS='|' read -r ts client dest_ip dest_port proto svc count; do
[[ -z "$ts" ]] && continue
(( ${#client} > w_client )) && w_client=${#client}
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}
# ── Collect unique endpoints for batch resolution ──
local -a ep_list=()
while IFS='|' read -r ts client dest_ip dest_port proto svc count src_endpoint; do
[[ -z "$ts" || -z "$src_endpoint" ]] && continue
ep_list+=("$src_endpoint")
done <<< "$data"
declare -A resolve_cache=()
if [[ ${#ep_list[@]} -gt 0 ]]; then
while IFS='|' read -r ip name; do
[[ -n "$ip" ]] && resolve_cache["$ip"]="$name"
done < <(json::batch_resolve "${ep_list[@]}" 2>/dev/null)
fi
# ── Pass 1: measure widths ──
local w_client=16 w_dest=20 w_endpoint=0
local resolved_data=""
while IFS='|' read -r ts client dest_ip dest_port proto svc count src_endpoint; do
[[ -z "$ts" ]] && continue
(( ${#client} > w_client )) && w_client=${#client}
local svc_display=""
if [[ -n "$svc" ]]; then
[[ -n "$dest_port" ]] && svc_display="${svc}/${proto}" \
|| svc_display="${svc} (${proto})"
else
[[ -n "$dest_port" ]] && svc_display="${dest_ip}:${dest_port}/${proto}" \
|| svc_display="${dest_ip} (${proto})"
fi
local measure_len
if $resolved_only; then
measure_len=${#svc_display}
else
local raw_plain=""
[[ -n "$svc" && -n "$dest_port" ]] && raw_plain=" (${dest_ip}:${dest_port})"
[[ -n "$svc" && -z "$dest_port" ]] && raw_plain=" (${dest_ip})"
measure_len=$(( ${#svc_display} + ${#raw_plain} ))
fi
(( measure_len > w_dest )) && w_dest=$measure_len
local src_resolved=""
if [[ -n "$src_endpoint" ]]; then
src_resolved="${resolve_cache[$src_endpoint]:-}"
[[ "$src_resolved" == "$src_endpoint" ]] && src_resolved=""
local ep_measure_len
if $resolved_only; then
ep_measure_len=${#src_resolved}
[[ -z "$src_resolved" ]] && ep_measure_len=${#src_endpoint}
else
ep_measure_len=${#src_endpoint}
[[ -n "$src_resolved" ]] && \
ep_measure_len=$(( ${#src_endpoint} + 4 + ${#src_resolved} ))
fi
(( ep_measure_len > w_endpoint )) && w_endpoint=$ep_measure_len
fi
resolved_data+="${ts}|${client}|${dest_ip}|${dest_port}|${proto}|${svc}|${count}|${src_endpoint}|${src_resolved}"$'\n'
done <<< "$data"
(( w_client += 2 ))
(( w_dest += 2 ))
[[ "$w_endpoint" -gt 0 ]] && (( w_endpoint += 2 ))
# ── Pass 2: render ──
ui::logs::fw_section_header
while IFS='|' read -r ts client dest_ip dest_port proto svc count; do
while IFS='|' read -r ts client dest_ip dest_port proto svc count src_endpoint src_resolved; do
[[ -z "$ts" ]] && continue
ui::logs::fw_row "$ts" "$client" "$dest_ip" "$dest_port" \
"$proto" "$svc" "$count" "$w_client" "$w_dest"
done <<< "$data"
"$proto" "$svc" "$count" "$w_client" "$w_dest" \
"$src_endpoint" "$src_resolved" "$w_endpoint" "$resolved_only"
done <<< "$resolved_data"
printf "\n"
}
function cmd::logs::show_wg_events() {
local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" limit="${4:-50}"
local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" \
limit="${4:-50}" collapse="${5:-1}" \
since="${6:-}" filter_event="${7:-}" sort_order="${8:-desc}" \
resolved_only="${9:-false}"
[[ ! -f "$WG_EVENTS_LOG" ]] && return 0
local data
data=$(json::wg_events "$WG_EVENTS_LOG" "$filter_name" "$filter_type" "$limit" 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
# Measure column widths
local w_client=16 w_endpoint=16
while IFS='|' read -r ts client endpoint event count; do
[[ -z "$ts" ]] && continue
(( ${#client} > w_client )) && w_client=${#client}
(( ${#endpoint} > w_endpoint )) && w_endpoint=${#endpoint}
# ── Collect unique endpoints for batch resolution ──
local -a ep_list=()
while IFS='|' read -r ts client endpoint event count gap_seconds; do
[[ -z "$ts" || -z "$endpoint" ]] && continue
ep_list+=("$endpoint")
done <<< "$data"
declare -A resolve_cache=()
if [[ ${#ep_list[@]} -gt 0 ]]; then
while IFS='|' read -r ip name; do
[[ -n "$ip" ]] && resolve_cache["$ip"]="$name"
done < <(json::batch_resolve "${ep_list[@]}" 2>/dev/null)
fi
# ── Measure widths ──
local w_client=16 w_endpoint=16
local resolved_data=""
while IFS='|' read -r ts client endpoint event count gap_seconds; do
[[ -z "$ts" ]] && continue
(( ${#client} > w_client )) && w_client=${#client}
local resolved=""
if [[ -n "$endpoint" ]]; then
resolved="${resolve_cache[$endpoint]:-}"
[[ "$resolved" == "$endpoint" ]] && resolved=""
fi
local ep_raw="${endpoint:--}"
local ep_measure_len
if $resolved_only; then
local ep_display="${resolved:-$endpoint}"
[[ -z "$ep_display" ]] && ep_display="-"
ep_measure_len=${#ep_display}
else
ep_measure_len=${#ep_raw}
[[ -n "$resolved" && -n "$endpoint" ]] && \
ep_measure_len=$(( ${#endpoint} + 4 + ${#resolved} ))
fi
(( ep_measure_len > w_endpoint )) && w_endpoint=$ep_measure_len
resolved_data+="${ts}|${client}|${endpoint}|${event}|${count}|${gap_seconds}|${resolved}"$'\n'
done <<< "$data"
(( w_client += 2 ))
(( w_endpoint += 2 ))
# ── Render ──
ui::logs::wg_section_header
while IFS='|' read -r ts client endpoint event count; do
while IFS='|' read -r ts client endpoint event count gap_seconds resolved; do
[[ -z "$ts" ]] && continue
ui::logs::wg_row "$ts" "$client" "$endpoint" "$event" \
"$count" "$w_client" "$w_endpoint"
done <<< "$data"
if $resolved_only; then
local ep_display="${resolved:-$endpoint}"
[[ -z "$ep_display" ]] && ep_display="-"
ui::logs::wg_row "$ts" "$client" "$ep_display" "$event" \
"$count" "$w_client" "$w_endpoint" "$gap_seconds" ""
else
ui::logs::wg_row "$ts" "$client" "$endpoint" "$event" \
"$count" "$w_client" "$w_endpoint" "$gap_seconds" "$resolved"
fi
done <<< "$resolved_data"
printf "\n"
}
function cmd::logs::show_merged() {
local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" \
limit="${4:-50}" net_file="${5:-}"
limit="${4:-50}" net_file="${5:-}" since="${6:-}"
local fw_data wg_data
fw_data=$(json::fw_events "$FW_EVENTS_LOG" "$filter_ip" "$filter_type" \
"$(ctx::clients)" "${net_file:-}" "$limit" 2>/dev/null)
wg_data=$(json::wg_events "$WG_EVENTS_LOG" "$filter_name" "$filter_type" \
"$limit" 2>/dev/null)
fw_data=$(json::fw_events \
"$FW_EVENTS_LOG" "$filter_ip" "$filter_type" \
"$(ctx::clients)" "${net_file:-}" \
"$limit" "1" "$since" "" "" \
2>/dev/null)
wg_data=$(json::wg_events \
"$WG_EVENTS_LOG" "$filter_name" "$filter_type" \
"$limit" "1" "$since" "" \
2>/dev/null)
# Measure widths across both sources
local w_client=16 w_dest=20
while IFS='|' read -r ts client rest; do
[[ -z "$ts" ]] && continue
@ -220,7 +434,6 @@ function cmd::logs::show_merged() {
done < <(echo "$fw_data"; echo "$wg_data")
(( w_client += 2 ))
# Tag and merge: prefix fw lines with "fw|", wg lines with "wg|"
local merged_data
merged_data=$(
while IFS='|' read -r ts client dest_ip dest_port proto svc count; do
@ -233,7 +446,6 @@ function cmd::logs::show_merged() {
done <<< "$wg_data"
)
# Sort by timestamp field 2
while IFS='|' read -r source ts rest; do
[[ -z "$source" ]] && continue
case "$source" in
@ -278,14 +490,12 @@ function cmd::logs::follow() {
log::section "WireGuard Live Log (Ctrl+C to stop)"
printf "\n"
# Delegate to watch command
local watch_args=()
[[ -n "$filter_name" ]] && watch_args+=(--name "$filter_name")
[[ -n "$filter_type" ]] && watch_args+=(--type "$filter_type")
$fw_only && watch_args+=(--restricted)
$wg_only && watch_args+=(--blocked)
local restricted_only=false blocked_only=false
$fw_only && restricted_only=true
$wg_only && blocked_only=true
cmd::watch::run "${watch_args[@]}"
monitor::live "$filter_name" "$filter_type" "" \
"$blocked_only" "$restricted_only" "false" "false"
}
function cmd::logs::remove() {
@ -360,9 +570,9 @@ function cmd::logs::rotate() {
while [[ $# -gt 0 ]]; do
case "$1" in
--days) days="$2"; shift 2 ;;
--force) force=true; shift ;;
--help) cmd::logs::help; return ;;
--days) days="$2"; shift 2 ;;
--force) force=true; shift ;;
--help) cmd::logs::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
@ -390,4 +600,31 @@ function cmd::logs::rotate() {
fi
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 --detailed
flag::register --force
command::mixin json_output
}
function cmd::net::help() {
@ -58,6 +60,12 @@ EOF
function cmd::net::run() {
local subcmd="${1:-list}"
shift || true
if command::json; then
cmd::net::_output_json
return 0
fi
case "$subcmd" in
list) cmd::net::list "$@" ;;
show) cmd::net::show "$@" ;;
@ -77,7 +85,7 @@ function cmd::net::run() {
function cmd::net::list() {
local detailed=false filter_tag=""
while [[ $# -gt 0 ]]; do
case "$1" in
--detailed) detailed=true; shift ;;
@ -86,62 +94,88 @@ function cmd::net::list() {
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
local net_file
net_file="$(ctx::net)"
if [[ ! -f "$net_file" ]]; then
log::wg_warning "No services configured. Use 'wgctl net add' to add one."
return 0
fi
log::section "Network Services"
printf "\n %-20s %-16s %-6s %s\n" "NAME" "IP" "PORTS" "DESCRIPTION"
local divider
divider=$(printf '─%.0s' {1..72})
printf " %s\n" "$divider"
local found=false
while IFS="|" read -r name ip desc tags ports; do
# Collect filtered data and build ports display per service
local filtered_data=""
while IFS="|" read -r name ip desc tags port_count; do
[[ -z "$name" ]] && continue
# Tag filter
if [[ -n "$filter_tag" ]]; then
[[ "$tags" != *"$filter_tag"* ]] && continue
fi
found=true
local tag_display=""
[[ -n "$tags" ]] && tag_display=" \033[0;37m[${tags}]\033[0m"
printf " %-20s %-16s %-6s %s%b\n" \
"$name" "$ip" "${ports}p" "${desc:-}" "$tag_display"
if $detailed; then
local has_ports=false
# Show ports inline
while IFS="|" read -r ptype pname pport pproto pdesc; do
[[ "$ptype" != "port" ]] && continue
has_ports=true
local ann
ann=$(net::annotation "$ip" "$pport" "$pproto")
printf " \033[0;37m%-18s %s:%s%s\033[0m\n" \
"${pname}" "$pport" "$pproto" \
"${pdesc:+ # $pdesc}"
done < <(json::net_show "$net_file" "$name")
$has_ports && printf "\n" # newline after each service with ports
fi
[[ -n "$filter_tag" && "$tags" != *"$filter_tag"* ]] && continue
# Build ports display from json::net_show
local ports_display=""
while IFS="|" read -r ptype pname pport pproto pdesc; do
[[ "$ptype" != "port" ]] && continue
local port_str=":${pport}"
[[ -n "$pproto" && "$pproto" != "tcp" ]] && port_str="${port_str}/${pproto}"
ports_display+="${port_str}, "
done < <(json::net_show "$net_file" "$name")
ports_display="${ports_display%, }"
[[ -z "$ports_display" ]] && ports_display="-"
filtered_data+="${name}|${ip}|${desc}|${tags}|${ports_display}"$'\n'
done < <(json::net_list "$net_file")
if ! $found; then
[[ -z "$filtered_data" ]] && {
[[ -n "$filter_tag" ]] && \
log::wg_warning "No services with tag: ${filter_tag}" || \
log::wg_warning "No services configured"
fi
return 0
}
printf "\n"
return 0
# Measure column widths
local w_name=12 w_ip=13 w_ports=16
while IFS="|" read -r name ip desc tags ports; do
[[ -z "$name" ]] && continue
(( ${#name} > w_name )) && w_name=${#name}
(( ${#ip} > w_ip )) && w_ip=${#ip}
(( ${#ports} > w_ports )) && w_ports=${#ports}
done <<< "$filtered_data"
(( w_name += 2 ))
(( w_ip += 2 ))
(( w_ports += 2 ))
log::section "Network Services"
echo ""
if display::is_table "net_list"; then
cmd::net::_render_table "$filtered_data"
return 0
fi
while IFS="|" read -r name ip desc tags ports; do
[[ -z "$name" ]] && continue
ui::net::list_row "$name" "$ip" "$desc" "$tags" "$ports" \
"$w_name" "$w_ip" "$w_ports"
if $detailed; then
while IFS="|" read -r ptype pname pport pproto pdesc; do
[[ "$ptype" != "port" ]] && continue
ui::net::show_port_row "$pname" "$pport" "$pproto" "$pdesc"
done < <(json::net_show "$net_file" "$name")
echo ""
fi
done <<< "$filtered_data"
echo ""
}
function cmd::net::_render_table() {
local data="${1:-}"
[[ -z "$data" ]] && return 0
ui::net::list_header_table
while IFS='|' read -r name ip desc tags port_count; do
[[ -z "$name" ]] && continue
ui::net::list_row_table "$name" "$ip" "$desc" "$tags" "$port_count"
done <<< "$data"
}
# ============================================
@ -158,33 +192,31 @@ function cmd::net::show() {
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
[[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
net::require_exists "$name" || return 1
log::section "Service: ${name}"
printf "\n"
local has_ports=false
while IFS="|" read -r key val1 val2 val3 val4; do
case "$key" in
name) ui::row "Name" "$val1" ;;
ip) ui::row "IP" "$val1" ;;
desc) ui::row "Description" "${val1:-}" ;;
tags) ui::row "Tags" "${val1:-}" ;;
ip) ui::row "IP" "$val1" ;;
port)
# val1=port_name val2=port val3=proto val4=desc
local ann
ann=$(net::annotation "$(json::net_resolve "$(ctx::net)" "$name")" \
"$val2" "$val3" 2>/dev/null || true)
printf " %-20s \033[0;36m%s\033[0m %s:%s%s\n" \
"${val1}:" "" "$val2" "$val3" \
"${val4:+ # $val4}"
if ! $has_ports; then
printf " %-20s\n" "Ports:"
has_ports=true
fi
ui::net::show_port_row "$val1" "$val2" "$val3" "$val4"
;;
esac
done < <(json::net_show "$(ctx::net)" "$name")
printf "\n"
return 0
}
# ============================================
@ -296,4 +328,31 @@ function cmd::net::rm() {
json::net_remove "$(ctx::net)" "$name"
log::wg_success "Removed: ${name}"
return 0
}
function cmd::net::_output_json() {
local net_file
net_file="$(ctx::net)"
local data
data=$(json::net_list "$net_file" 2>/dev/null)
local -a services=()
while IFS='|' read -r name ip desc tags port_count; do
[[ -z "$name" ]] && continue
# Build tags array
local tags_json="[]"
if [[ -n "$tags" ]]; then
local tags_array
tags_array=$(echo "$tags" | tr ',' '\n' | \
while IFS= read -r t; do [[ -n "$t" ]] && printf '"%s",' "$t"; done | sed 's/,$//')
tags_json="[${tags_array}]"
fi
services+=("$(printf '{"name":"%s","ip":"%s","desc":"%s","tags":%s,"port_count":%s}' \
"$name" "$ip" "$desc" "$tags_json" "$port_count")")
done <<< "$data"
local count=${#services[@]}
local array
array=$(printf '%s\n' "${services[@]:-}" | paste -sd ',' -)
printf '{"services":[%s]}' "${array:-}" | json::envelope "net list" "$count"
}

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 --field
flag::register --value
command::mixin json_output
}
# ============================================
@ -79,6 +81,11 @@ function cmd::policy::run() {
local subcmd="${1:-list}"
shift || true
if command::json; then
cmd::policy::_output_json
return 0
fi
case "$subcmd" in
list) cmd::policy::_list "$@" ;;
show) cmd::policy::_show "$@" ;;
@ -99,13 +106,18 @@ function cmd::policy::run() {
function cmd::policy::_list() {
local data
data=$(policy::list_data)
data=$(policy::list_data | ui::sort_rows 1)
if [[ -z "$data" ]]; then
log::info "No policies defined."
return 0
fi
if display::is_table "policy_list"; then
cmd::policy::_render_table "$data"
return 0
fi
echo ""
while IFS='|' read -r name tunnel default_rule strict auto desc; do
ui::policy::list_row "$name" "$default_rule" "$strict" "$auto"
@ -113,6 +125,19 @@ function cmd::policy::_list() {
echo ""
}
function cmd::policy::_render_table() {
local data="${1:-}"
[[ -z "$data" ]] && return 0
ui::policy::list_header_table
while IFS='|' read -r name tunnel default_rule strict auto desc; do
[[ -z "$name" ]] && continue
ui::policy::list_row_table "$name" "$tunnel" "$default_rule" "$strict" "$auto"
done <<< "$data"
printf "\n"
}
function cmd::policy::_show() {
local name=""
while [[ $# -gt 0 ]]; do
@ -244,4 +269,28 @@ function cmd::policy::_set() {
json::policy_set_field "$(ctx::policies)" "$name" "$field" "$value"
log::ok "Policy '${name}': ${field} = ${value}"
}
function cmd::policy::_output_json() {
local data
data=$(policy::list_data 2>/dev/null)
local -a policies=()
while IFS='|' read -r name tunnel_mode default_rule strict_rule auto_apply desc; do
[[ -z "$name" ]] && continue
local strict_json="false"
[[ "$strict_rule" == "true" ]] && strict_json="true"
local auto_json="true"
[[ "$auto_apply" == "false" ]] && auto_json="false"
policies+=("$(printf '{"name":"%s","tunnel_mode":"%s","default_rule":"%s","strict_rule":%s,"auto_apply":%s,"desc":"%s"}' \
"$name" "$tunnel_mode" "${default_rule:-}" \
"$strict_json" "$auto_json" "$desc")")
done <<< "$data"
local count=${#policies[@]}
local array
array=$(printf '%s\n' "${policies[@]:-}" | paste -sd ',' -)
printf '{"policies":[%s]}' "${array:-}" | json::envelope "policy list" "$count"
}

View file

@ -31,6 +31,8 @@ function cmd::rule::on_load() {
flag::register --force
flag::register --type
flag::register --all
command::mixin json_output
}
# ============================================
@ -46,15 +48,16 @@ Rules can extend base rules to compose reusable access policies.
Service names from 'wgctl net' can be used instead of raw IPs/ports.
Subcommands:
list, ls List all rules
show, inspect Show rule details and inheritance
add, new, create Create a new rule
update, edit Update a rule and re-apply to peers
remove, rm, del Remove a rule
assign Assign a rule to a peer
unassign Remove rule from a peer
reapply Re-apply rule to all assigned peers
migrate Apply default rules to unassigned peers
list, ls List all rules
list --detailed Show inheritance tree
show, inspect --name <r> Show rule details and inheritance
add, new, create --name <r> Create a new rule
update, edit --name <r> Update a rule and re-apply to peers
remove, rm, del --name <r> Remove a rule
assign --name <r> Assign a rule to a peer
unassign --name <r> --peer <p> Remove rule from a peer
reapply Re-apply rule to all assigned peers
migrate Apply default rules to unassigned peers
Options for list:
--base Show only base rules
@ -115,6 +118,11 @@ function cmd::rule::run() {
local subcmd="${1:-help}"
shift || true
if command::json; then
cmd::rule::_output_json
return 0
fi
case "$subcmd" in
list|ls) cmd::rule::list "$@" ;;
show|inspect) cmd::rule::show "$@" ;;
@ -173,6 +181,11 @@ function cmd::rule::list() {
log::section "Firewall Rules"
echo ""
if display::is_table "rule_list"; then
cmd::rule::_render_table "$data"
return 0
fi
local current_group="" printing_base=false found_any=false
while IFS="|" read -r name desc n_allows n_blocks \
@ -232,6 +245,18 @@ function cmd::rule::list() {
echo ""
}
function cmd::rule::_render_table() {
local data="${1:-}"
[[ -z "$data" ]] && return 0
ui::rule::list_header_table
while IFS='|' read -r name desc n_allows n_blocks peer_count extends is_base group; do
[[ -z "$name" ]] && continue
ui::rule::list_row_table "$name" "$n_allows" "$n_blocks" "$peer_count" "$extends" "$group"
done <<< "$data"
printf "\n"
}
# ============================================
# Show
# ============================================
@ -312,10 +337,9 @@ function cmd::rule::show() {
local peer_count=${#peer_list[@]}
ui::empty "$peer_count" && return 0
local peer_word="peers"
[[ "$peer_count" -eq 1 ]] && peer_word="peer"
printf "\n \033[0;37m── Peers (%s %s) \033[0m%s\n\n" \
"$peer_count" "$peer_word" "$(printf '\033[0;37m─%.0s' {1..30})"
[[ "$peer_count" -eq 1 ]]
printf "\n \033[0;37m── Peers (%s) \033[0m%s\n\n" \
"$peer_count" "$(printf '\033[0;37m─%.0s' {1..30})"
for peer_name in "${peer_list[@]}"; do
local ip
@ -551,6 +575,19 @@ function cmd::rule::assign() {
peer=$(peers::resolve_and_require "$peer" "$type") || return 1
# Identity rule check
local peer_identity
peer_identity=$(peers::get_identity "$peer")
if [[ -n "$peer_identity" ]]; then
local identity_rules
identity_rules=$(identity::rules "$peer_identity" 2>/dev/null)
if echo "$identity_rules" | grep -qx "$name"; then
log::error "Rule '${name}' is already applied to '${peer}' via identity '${peer_identity}' — cannot assign directly"
return 1
fi
fi
local existing_rule ip
existing_rule=$(peers::get_meta "$peer" "rule")
ip=$(peers::get_ip "$peer")
@ -658,4 +695,38 @@ function cmd::rule::reapply() {
rule::require_exists "$name" || return 1
rule::reapply_all "$name"
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
rule group audit logs watch fw config qr
rename keys ip net service shell help test
peer hosts identity subnet policy activity
)
local c
for c in "${known[@]}"; do
@ -82,28 +83,44 @@ function cmd::shell::_banner() {
printf "\n"
printf " Type wgctl commands directly (no 'wgctl' prefix).\n"
printf " Bash commands work too: ls, cat, systemctl, vim...\n\n"
printf " \033[1;37mCommon commands:\033[0m\n"
printf " list List all peers\n"
printf " list --blocked Show blocked peers\n"
printf " list --restricted Show restricted peers\n"
printf " list --rule user Filter by rule\n"
printf " inspect --name <peer> Full peer details\n"
printf " block --name <peer> Block a peer entirely\n"
printf " block --name <peer> --service proxmox Restrict service\n"
printf " unblock --name <peer> Restore full access\n"
printf " rule list Show firewall rules\n"
printf " rule list --tree Show with inheritance\n"
printf " rule show --name <rule> Rule details\n"
printf " net list Show network services\n"
printf " net list --detailed Show services with ports\n"
printf " group list Show groups\n"
printf " group block --name <group> Block all peers in group\n"
printf " logs --follow Live activity log\n"
printf " logs rotate Clean old log entries\n"
printf " watch Live WG + firewall monitor\n"
printf " fw list Show iptables rules\n"
printf " audit Verify firewall state\n"
printf " audit --fix Auto-repair firewall rules\n\n"
printf " \033[1;37mPeer management:\033[0m\n"
printf " list List all peers\n"
printf " list --blocked Show blocked peers\n"
printf " list --rule user Filter by rule\n"
printf " inspect --name <peer> Full peer details\n"
printf " add --identity <id> --type phone Add a peer\n"
printf " block --name <peer> Block a peer\n"
printf " unblock --name <peer> Restore access\n"
printf " peer update-dns --all Update DNS on all peers\n"
printf " peer update-tunnel --name <p> --mode split\n\n"
printf " \033[1;37mRules & access:\033[0m\n"
printf " rule list Show firewall rules\n"
printf " rule list --tree Show with inheritance\n"
printf " rule assign --name <r> --peer <p> Assign rule to peer\n"
printf " identity list Show identities\n"
printf " identity show --name <id> Identity details + rule tree\n"
printf " identity rule assign --name <id> --rule <r>\n\n"
printf " \033[1;37mNetwork & services:\033[0m\n"
printf " net list Show network services\n"
printf " net list --detailed Show with ports\n"
printf " hosts list Show host annotations\n"
printf " subnet list Show subnets\n"
printf " group list Show groups\n"
printf " group block --name <group> Block all in group\n\n"
printf " \033[1;37mMonitoring:\033[0m\n"
printf " logs Activity logs\n"
printf " logs --since 2h Logs from last 2h\n"
printf " logs --fw --service pihole FW drops to service\n"
printf " logs --wg --event handshake WG handshake events\n"
printf " logs --resolved Show resolved names only\n"
printf " logs --follow Live activity log\n"
printf " logs clean Remove keepalive entries\n"
printf " logs rotate Clean old log entries\n"
printf " watch Live WG + firewall monitor\n"
printf " activity Transfer + drop summary\n"
printf " fw list Show iptables rules\n"
printf " audit Verify firewall state\n"
printf " audit --fix Auto-repair firewall\n\n"
printf " \033[1mexit\033[0m or \033[1mquit\033[0m to leave · \033[1mhelp\033[0m for full command list\n\n"
}
@ -143,13 +160,15 @@ EOF
# ============================================
function cmd::shell::_setup_completion() {
local commands="list add remove rm inspect block unblock rule group audit logs watch fw config qr rename service shell help test"
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() {
local cur="${COMP_WORDS[COMP_CWORD]}"
COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
}
bind 'set show-all-if-ambiguous on' 2>/dev/null || true
bind 'set completion-ignore-case on' 2>/dev/null || true
}

View file

@ -22,6 +22,8 @@ function cmd::subnet::on_load() {
flag::register --desc
flag::register --group
flag::register --new-name
command::mixin json_output
}
# ============================================
@ -65,6 +67,11 @@ function cmd::subnet::run() {
local subcmd="${1:-list}"
shift || true
if command::json; then
cmd::subnet::_output_json
return 0
fi
case "$subcmd" in
list) cmd::subnet::_list "$@" ;;
show) cmd::subnet::_show "$@" ;;
@ -85,13 +92,18 @@ function cmd::subnet::run() {
function cmd::subnet::_list() {
local data
data=$(subnet::list_data)
data=$(subnet::list_data | ui::sort_rows 1)
if [[ -z "$data" ]]; then
log::info "No subnets defined."
return 0
fi
if display::is_table "subnet_list"; then
cmd::subnet::_render_table "$data"
return 0
fi
echo ""
local prev_group=""
while IFS='|' read -r display_name subnet type_key tunnel_mode desc is_group group_parent; do
@ -113,6 +125,18 @@ function cmd::subnet::_list() {
echo ""
}
function cmd::subnet::_render_table() {
local data="${1:-}"
[[ -z "$data" ]] && return 0
ui::subnet::list_header_table
while IFS='|' read -r type cidr display_name tunnel desc is_group group_parent; do
[[ -z "$type" ]] && continue
ui::subnet::list_row_table "$type" "$cidr" "$tunnel" "$desc"
done <<< "$data"
printf "\n"
}
function cmd::subnet::_maybe_group_separator() {
local is_group="${1:-}" group_parent="${2:-}" prev_group="${3:-}"
if [[ "$is_group" == "true" && "$group_parent" != "$prev_group" && -n "$prev_group" ]]; then
@ -291,4 +315,26 @@ function cmd::subnet::_validate_cidr() {
log::error "Invalid CIDR format: '${cidr}'"
return 1
fi
}
function cmd::subnet::_output_json() {
local data
data=$(subnet::list_data 2>/dev/null)
local -a subnets=()
while IFS='|' read -r type cidr display_name tunnel_mode desc is_group group_parent; do
[[ -z "$type" ]] && continue
local is_group_json="false"
[[ "$is_group" == "true" ]] && is_group_json="true"
subnets+=("$(printf '{"type":"%s","cidr":"%s","display_name":"%s","tunnel_mode":"%s","desc":"%s","is_group":%s,"group_parent":"%s"}' \
"$type" "$cidr" "$display_name" "$tunnel_mode" \
"$desc" "$is_group_json" "${group_parent:-}")")
done <<< "$data"
local count=${#subnets[@]}
local array
array=$(printf '%s\n' "${subnets[@]:-}" | paste -sd ',' -)
printf '{"subnets":[%s]}' "${array:-}" | json::envelope "subnet list" "$count"
}

View file

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

View file

@ -126,12 +126,21 @@ function cmd::test::run_all_integration_sections() {
cmd::test::section_config
cmd::test::section_rules
cmd::test::section_groups
cmd::test::section_block_unblock
cmd::test::section_audit
cmd::test::section_logs
cmd::test::section_fw
cmd::test::section_net
cmd::test::section_subnet
cmd::test::section_identity
cmd::test::section_activity
cmd::test::section_policy
cmd::test::section_hosts
cmd::test::section_peer_cmd
cmd::test::section_group_purge
cmd::test::section_logs_clean
cmd::test::section_display
cmd::test::section_export
}
function cmd::test::section_list() {
@ -143,6 +152,12 @@ function cmd::test::section_list() {
cmd::test::run_cmd "list --type phone" "phone" list --type phone
cmd::test::run_cmd "list --detailed" "rule:" list --detailed
cmd::test::run_cmd "list --name phone-nuno" "phone-nuno" list --name phone-nuno
cmd::test::run_cmd "list --json" '"ok":true' list --json
cmd::test::run_cmd "list --json has peers" '"peers":' list --json
cmd::test::run_cmd "list --json has meta" '"meta":' list --json
cmd::test::run_cmd "list --json peer name" '"name":' list --json
cmd::test::run_cmd "list --json peer ip" '"ip":' list --json
cmd::test::run_cmd "list --json peer status" '"status":' list --json
}
function cmd::test::section_inspect() {
@ -151,6 +166,10 @@ function cmd::test::section_inspect() {
cmd::test::run_cmd "inspect --name nuno --type phone" "IP:" inspect --name nuno --type phone
cmd::test::run_cmd "inspect --name phone-nuno --config" "PrivateKey" inspect --name phone-nuno --config
cmd::test::run_cmd_fails "inspect nonexistent" inspect --name nonexistent-peer
cmd::test::run_cmd "inspect --json" '"ok":true' inspect --name phone-nuno --json
cmd::test::run_cmd "inspect --json rule" '"rule":' inspect --name phone-nuno --json
cmd::test::run_cmd "inspect --json identity" '"identity":' inspect --name phone-nuno --json
cmd::test::run_cmd "inspect --json groups" '"groups":' inspect --name phone-nuno --json
}
function cmd::test::section_config() {
@ -158,6 +177,8 @@ function cmd::test::section_config() {
cmd::test::run_cmd "config --name phone-nuno" "PrivateKey" config --name phone-nuno
cmd::test::run_cmd "config --name nuno --type phone" "PrivateKey" config --name nuno --type phone
cmd::test::run_cmd "qr --name phone-nuno" "" qr --name phone-nuno
cmd::test::run_cmd "config migrate --dry-run" "" config migrate --dry-run
cmd::test::run_cmd_fails "config missing --name" config
}
function cmd::test::section_rules() {
@ -166,16 +187,110 @@ function cmd::test::section_rules() {
cmd::test::run_cmd "rule show --name guest" "Description" rule show --name guest
cmd::test::run_cmd "rule show --name user" "Description" rule show --name user
cmd::test::run_cmd "rule show --name admin" "Description" rule show --name admin
cmd::test::run_cmd "rule list --json" '"rules":' rule list --json
cmd::test::run_cmd "rule list --json" '"rules":' rule list --json
cmd::test::run_cmd "rule --json is_base" '"is_base":' rule list --json
cmd::test::run_cmd "rule --json extends" '"extends":' rule list --json
cmd::test::run_cmd "rule --json allows" '"allows":' rule list --json
cmd::test::run_cmd_fails "rule show nonexistent" rule show --name nonexistent
}
function cmd::test::section_groups() {
test::section "Groups"
cmd::test::run_cmd "group list" "Groups" group list
cmd::test::run_cmd "group show --name family" "Peers:" group show --name family
cmd::test::run_cmd_fails "group show nonexistent" group show --name nonexistent
cmd::test::run_cmd "group list" "Groups" group list
cmd::test::run_cmd "group show --name family" "Peers:" group show --name family
cmd::test::run_cmd "group list --json" '"groups":' group list --json
cmd::test::run_cmd "group list --json" '"groups":' group list --json
cmd::test::run_cmd "group --json peer_count" '"peer_count":' group list --json
cmd::test::run_cmd_fails "group show nonexistent" group show --name nonexistent
}
# function cmd::test::section_blocks() {
# test::section "Blocks"
# cmd::test::run_cmd "block --reason records history" "block-history" block --name guest-test --reason "test block" --force
# cmd::test::run_cmd_succeeds "unblock clears history" unblock --name guest-test --force
# }
function cmd::test::section_block_unblock() {
test::section "Block / Unblock"
# ── Setup fixture ──
local fixture="phone-testblock"
wgctl unblock --name "$fixture" --force >/dev/null 2>&1 || true
wgctl remove --name "$fixture" --force >/dev/null 2>&1 || true
wgctl add --name testblock --type phone >/dev/null 2>&1 || true
local history_file
history_file="$(ctx::block_history)/${fixture}.json"
# ── Block ──
echo "DEBUG about to run: $WGCTL_BINARY block --name $fixture --force" >&2
cmd::test::run_cmd "block peer" "blocked" block --name "$fixture" --force
cmd::test::run_cmd "block already blocked" "already" block --name "$fixture" --force
wgctl unblock --name "$fixture" --force >/dev/null 2>&1 || true
cmd::test::run_cmd "block with reason" "blocked" block --name "$fixture" --force \
--reason "test reason"
# ── Block history file created ──
[[ -f "$history_file" ]] && test::pass "block history file created" \
|| test::fail "block history file not created"
# ── Block history fields ──
if [[ -f "$history_file" ]]; then
local has_id has_blocked_at has_endpoint has_reason
has_id=$(python3 -c "
import json
d=json.load(open('$history_file'))
print('yes' if d['history'] and 'id' in d['history'][-1] else 'no')
" 2>/dev/null)
has_blocked_at=$(python3 -c "
import json
d=json.load(open('$history_file'))
print('yes' if d['history'] and d['history'][-1].get('blocked_at') else 'no')
" 2>/dev/null)
has_endpoint=$(python3 -c "
import json
d=json.load(open('$history_file'))
print('yes' if 'endpoint_at_block' in d['history'][-1] else 'no')
" 2>/dev/null)
has_reason=$(python3 -c "
import json
d=json.load(open('$history_file'))
print('yes' if d['history'][-1].get('reason') == 'test reason' else 'no')
" 2>/dev/null)
cmd::test::assert "history has id" "$has_id" "yes"
cmd::test::assert "history has blocked_at" "$has_blocked_at" "yes"
cmd::test::assert "history has endpoint" "$has_endpoint" "yes"
cmd::test::assert "history has reason" "$has_reason" "yes"
fi
# ── Unblock ──
cmd::test::run_cmd "unblock peer" "unblocked" unblock --name "$fixture" --force \
--reason "test cleanup"
cmd::test::run_cmd "unblock not blocked" "not blocked" unblock --name "$fixture" --force
# ── Unblock history updated ──
if [[ -f "$history_file" ]]; then
local has_unblocked has_unblock_reason
has_unblocked=$(python3 -c "
import json
d=json.load(open('$history_file'))
print('yes' if d['history'] and d['history'][-1].get('unblocked_at') else 'no')
" 2>/dev/null)
has_unblock_reason=$(python3 -c "
import json
d=json.load(open('$history_file'))
print('yes' if d['history'][-1].get('unblock_reason') == 'test cleanup' else 'no')
" 2>/dev/null)
cmd::test::assert "history has unblocked_at" "$has_unblocked" "yes"
cmd::test::assert "history has unblock_reason" "$has_unblock_reason" "yes"
fi
# ── Teardown fixture ──
wgctl remove --name "$fixture" --force >/dev/null 2>&1 || true
rm -f "$history_file"
}
function cmd::test::section_audit() {
test::section "Audit"
cmd::test::run_cmd_any "audit" "passed" audit
@ -187,8 +302,17 @@ function cmd::test::section_logs() {
test::section "Logs"
cmd::test::run_cmd "logs" "Activity" logs
cmd::test::run_cmd "logs --name phone-nuno" "Activity" logs --name phone-nuno
cmd::test::run_cmd "logs --fw" "Activity" logs --fw
cmd::test::run_cmd "logs --wg" "Activity" logs --wg
cmd::test::run_cmd "logs --fw" "Firewall Drops" logs --fw
cmd::test::run_cmd "logs --wg" "WireGuard Events" logs --wg
cmd::test::run_cmd "logs --since 2099-01-01" "No logs" logs --since "2099-01-01"
cmd::test::run_cmd "logs --wg --since 2099-01-01" "No logs" logs --wg --since "2099-01-01"
cmd::test::run_cmd "logs --fw --since 2099-01-01" "No logs" logs --fw --since "2099-01-01"
cmd::test::run_cmd "logs --wg --event attempt" "" logs --wg --event attempt
cmd::test::run_cmd "logs --detailed" "" logs --detailed
cmd::test::run_cmd "logs --resolved" "" logs --resolved
cmd::test::run_cmd "logs --ascending" "" logs --ascending
cmd::test::run_cmd "logs --descending" "" logs --descending
cmd::test::run_cmd "logs --wg --ascending" "" logs --wg --ascending
}
function cmd::test::section_fw() {
@ -206,17 +330,20 @@ function cmd::test::section_net() {
test::section "Net"
"$WGCTL_BINARY" net rm --name test-svc --force > /dev/null 2>&1 || true
cmd::test::run_cmd "net add service" "added" net add --name test-svc --ip 10.0.0.99 --desc "Test service"
cmd::test::run_cmd "net add port" "Added" net add --name test-svc:web --port 9999:tcp
cmd::test::run_cmd "net list" "test-svc" net list
cmd::test::run_cmd "net list --detailed" "web" net list --detailed
cmd::test::run_cmd "net show" "9999" net show --name test-svc
cmd::test::run_cmd "net rm port" "Removed" net rm --name test-svc:web --force
cmd::test::run_cmd "net add port again" "Added" net add --name test-svc:web --port 9999:tcp
cmd::test::run_cmd "net rm all ports" "Removed" net rm --name test-svc:ports --force
cmd::test::run_cmd "net rm service" "Removed" net rm --name test-svc --force
cmd::test::run_cmd_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 "net add service" "added" net add --name test-svc --ip 10.0.0.99 --desc "Test service"
cmd::test::run_cmd "net add port" "Added" net add --name test-svc:web --port 9999:tcp
cmd::test::run_cmd "net list" "test-svc" net list
cmd::test::run_cmd "net list --detailed" "web" net list --detailed
cmd::test::run_cmd "net show" "9999" net show --name test-svc
cmd::test::run_cmd "net rm port" "Removed" net rm --name test-svc:web --force
cmd::test::run_cmd "net add port again" "Added" net add --name test-svc:web --port 9999:tcp
cmd::test::run_cmd "net rm all ports" "Removed" net rm --name test-svc:ports --force
cmd::test::run_cmd "net rm service" "Removed" net rm --name test-svc --force
cmd::test::run_cmd "net list --json" '"services":' net list --json
cmd::test::run_cmd "net --json has tags" '"tags":' net list --json
cmd::test::run_cmd "net --json port_count" '"port_count":' net list --json
cmd::test::run_cmd_fails "net show nonexistent" net show --name nonexistent-svc
cmd::test::run_cmd_fails "net add port no service" net add --name nonexistent:web --port 80:tcp
}
function cmd::test::section_subnet() {
@ -224,29 +351,196 @@ function cmd::test::section_subnet() {
"$WGCTL_BINARY" subnet rm --name test-subnet-2 > /dev/null 2>&1 || true
"$WGCTL_BINARY" subnet rm --name test-subnet > /dev/null 2>&1 || true
cmd::test::run_cmd "subnet list" "desktop" subnet list
cmd::test::run_cmd "subnet show desktop" "tunnel:" subnet show --name desktop
cmd::test::run_cmd "subnet show guests group" "guests" subnet show --name guests
cmd::test::run_cmd_fails "subnet show nonexistent" subnet show --name nonexistent
cmd::test::run_cmd "subnet add" "added" \
subnet add --name test-subnet --subnet 10.1.250.0/24 --type iot --desc "Test"
cmd::test::run_cmd "subnet list shows new" "test-subnet" \
subnet list
cmd::test::run_cmd_fails "subnet rename in-use (desktop)" \
subnet rename --name desktop --new-name workstation
cmd::test::run_cmd "subnet rename unused" "renamed" \
subnet rename --name test-subnet --new-name test-subnet-2
cmd::test::run_cmd "subnet rm" "removed" \
subnet rm --name test-subnet-2
cmd::test::run_cmd_fails "subnet rm nonexistent" \
subnet rm --name nonexistent-subnet
cmd::test::run_cmd "subnet list" "desktop" subnet list
cmd::test::run_cmd "subnet show desktop" "tunnel:" subnet show --name desktop
cmd::test::run_cmd "subnet show guests group" "guests" subnet show --name guests
cmd::test::run_cmd_fails "subnet show nonexistent" subnet show --name nonexistent
cmd::test::run_cmd "subnet add" "added" subnet add --name test-subnet --subnet 10.1.250.0/24 --type iot --desc "Test"
cmd::test::run_cmd "subnet list shows new" "test-subnet" subnet list
cmd::test::run_cmd_fails "subnet rename in-use (desktop)" subnet rename --name desktop --new-name workstation
cmd::test::run_cmd "subnet rename unused" "renamed" subnet rename --name test-subnet --new-name test-subnet-2
cmd::test::run_cmd "subnet rm" "removed" subnet rm --name test-subnet-2
cmd::test::run_cmd "subnet list --json" '"subnets":' subnet list --json
cmd::test::run_cmd "subnet --json has cidr" '"cidr":' subnet list --json
cmd::test::run_cmd "subnet --json is_group" '"is_group":' subnet list --json
cmd::test::run_cmd_fails "subnet rm nonexistent" subnet rm --name nonexistent-subnet
}
function cmd::test::section_identity() {
test::section "Identity"
cmd::test::run_cmd "identity list" "" identity list
cmd::test::run_cmd "identity migrate --dry-run" "Dry run" identity migrate --dry-run
cmd::test::run_cmd "identity show nuno" "nuno" identity show --name nuno
cmd::test::run_cmd_fails "identity show nonexistent" identity show --name nonexistent
cmd::test::run_cmd "identity list" "" identity list
cmd::test::run_cmd "identity migrate --dry-run" "Dry run" identity migrate --dry-run
cmd::test::run_cmd "identity show nuno" "nuno" identity show --name nuno
cmd::test::run_cmd "identity list --json" '"identities":' identity list --json
cmd::test::run_cmd "identity --json types array" '"types":[' identity list --json
cmd::test::run_cmd "identity --json rules array" '"rules":[' identity list --json
cmd::test::run_cmd_fails "identity show nonexistent" identity show --name nonexistent
}
function cmd::test::section_activity() {
test::section "Activity"
cmd::test::run_cmd "activity" "Activity" activity
cmd::test::run_cmd "activity --json" '"peers":' activity --json
cmd::test::run_cmd "activity --json services" '"services":' activity --json
cmd::test::run_cmd "activity --json rx" '"rx":' activity --json
}
function cmd::test::section_policy() {
test::section "Policy"
cmd::test::run_cmd "policy list --json" '"policies":' policy list --json
cmd::test::run_cmd "policy --json has tunnel_mode" '"tunnel_mode":' policy list --json
cmd::test::run_cmd "policy --json strict_rule bool" '"strict_rule":false' policy list --json
}
function cmd::test::section_hosts() {
test::section "Hosts"
# Cleanup
"$WGCTL_BINARY" hosts rm --ip 192.0.2.1 --force > /dev/null 2>&1 || true
"$WGCTL_BINARY" hosts rm --port 9999 --force > /dev/null 2>&1 || true
cmd::test::run_cmd "hosts list" "Hosts" hosts list
cmd::test::run_cmd "hosts add --ip" "Added" hosts add --ip 192.0.2.1 --name test-host --desc "Test" --tags test,unit
cmd::test::run_cmd "hosts list shows new" "test-host" hosts list
cmd::test::run_cmd "hosts show --ip" "Name" hosts show --ip 192.0.2.1
cmd::test::run_cmd "hosts add --port" "Added" hosts add --port 9999 --name test-port
cmd::test::run_cmd "hosts list shows port" "test-port" hosts list
cmd::test::run_cmd "hosts rm --ip" "Removed" hosts rm --ip 192.0.2.1 --force
cmd::test::run_cmd "hosts rm --port" "Removed" hosts rm --port 9999 --force
cmd::test::run_cmd "hosts list --json" '"hosts":' hosts list --json
cmd::test::run_cmd "hosts --json has type" '"type":' hosts list --json
cmd::test::run_cmd "hosts --json has tags" '"tags":' hosts list --json
cmd::test::run_cmd_fails "hosts show nonexistent" hosts show --ip 192.0.2.99
cmd::test::run_cmd_fails "hosts add missing --name" hosts add --ip 192.0.2.1
}
function cmd::test::section_peer_cmd() {
test::section "Peer command"
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
"$WGCTL_BINARY" add --name testunit --type phone > /dev/null 2>&1
# update-dns
cmd::test::run_cmd "peer update-dns --name" "Updated DNS" peer update-dns --name phone-testunit
cmd::test::run_cmd "peer update-dns applies" "10.0.0.103" config --name phone-testunit
cmd::test::run_cmd "peer update-dns --all" "peer(s)" peer update-dns --all
# update-tunnel
cmd::test::run_cmd "peer update-tunnel split" "split" peer update-tunnel --name phone-testunit --mode split
cmd::test::run_cmd "peer update-tunnel full" "Updated" peer update-tunnel --name phone-testunit --mode full
cmd::test::run_cmd_fails "peer update-tunnel bad mode" peer update-tunnel --name phone-testunit --mode invalid
cmd::test::run_cmd_fails "peer update-tunnel missing --mode" peer update-tunnel --name phone-testunit
cmd::test::run_cmd_fails "peer update-tunnel missing --name" peer update-tunnel --mode split
# Restore split tunnel
"$WGCTL_BINARY" peer update-tunnel --name phone-testunit --mode split > /dev/null 2>&1 || true
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
}
function cmd::test::section_group_purge() {
test::section "Group: purge-stale"
# dry-run should not modify anything
cmd::test::run_cmd "purge-stale --all --dry-run" \
"[dry-run]" \
group purge-stale --all --dry-run --force
# single group dry-run
cmd::test::run_cmd "purge-stale --name family --dry-run" \
"[dry-run]" \
group purge-stale --name family --dry-run --force
}
function cmd::test::section_logs_clean() {
test::section "Logs: clean"
cmd::test::run_cmd "logs clean --force" \
"keepalive" \
logs clean --force
}
function cmd::test::section_export() {
test::section "Export"
# Single peer export
cmd::test::run_cmd "export --peer" '"export_type":"peer"' export --peer phone-nuno
cmd::test::run_cmd "export --peer has name" '"name":"phone-nuno"' export --peer phone-nuno
cmd::test::run_cmd "export --peer has conf" '"conf":' export --peer phone-nuno
cmd::test::run_cmd "export --peer has identity" '"identity":"nuno"' export --peer phone-nuno
cmd::test::run_cmd "export --peer has groups" '"groups":' export --peer phone-nuno
cmd::test::run_cmd "export --peer has blocks" '"blocks":' export --peer phone-nuno
cmd::test::run_cmd "export --peer conf-only" '"export_type":"peer_conf"' export --peer phone-nuno --conf-only
cmd::test::run_cmd "export --peer meta-only" '"export_type":"peer_meta"' export --peer phone-nuno --meta-only
cmd::test::run_cmd_fails "export missing flag" export
# Identity export
cmd::test::run_cmd "export --identity" '"export_type":"identity"' export --identity nuno
# Full backup
cmd::test::run_cmd "export --all" '"export_type"' export --all
cmd::test::run_cmd "export --all is full" '"full"' export --all
cmd::test::run_cmd "export --all has peers" '"peers"' export --all
cmd::test::run_cmd "export --all has rules" '"rules"' export --all
cmd::test::run_cmd "export --all has identities" '"identities"' export --all
cmd::test::run_cmd "export --all has config" '"config"' export --all
cmd::test::run_cmd "export --all no-config" '"peers"' export --all --no-config
# --no-config should NOT have config section
local no_config_out
no_config_out=$(wgctl export --all --no-config 2>/dev/null)
if echo "$no_config_out" | grep -qF '"config":'; then
test::fail "export --all --no-config should not have config section"
else
test::pass "export --all --no-config has no config section"
fi
# Export to file
local tmp_file="/tmp/wgctl_test_export_$$.json"
cmd::test::run_cmd "export --peer --out file" "Exported to" export --peer phone-nuno --out "$tmp_file"
[[ -f "$tmp_file" ]] && test::pass "export --out file exists" \
|| test::fail "export --out file not created"
rm -f "$tmp_file"
# Version in export
cmd::test::run_cmd "export has version" '"wgctl_version":' export --peer phone-nuno
}
function cmd::test::section_display() {
test::section "Display config"
cmd::test::run_cmd "display config exists" "" \
config show --name phone-nuno # just check wgctl works, not display directly
# Test style via unit tests (see unit section)
}
# ============================================
# Helpers
# ============================================
function cmd::test::assert() {
local desc="${1:-}" result="${2:-}" expected="${3:-}"
if [[ "$result" == "$expected" ]]; then
test::pass "$desc"
else
test::fail "${desc} (expected '${expected}', got '${result}')"
fi
}
function cmd::test::assert_true() {
local desc="${1:-}"
shift
if "$@" 2>/dev/null; then
test::pass "$desc"
else
test::fail "$desc (expected true, got false)"
fi
}
function cmd::test::assert_false() {
local desc="${1:-}"
shift
if ! "$@" 2>/dev/null; then
test::pass "$desc"
else
test::fail "$desc (expected false, got true)"
fi
}

View file

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

View file

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

View file

@ -33,7 +33,7 @@ Options:
--blocked Show only blocked peer attempts
--allowed Show only handshakes
--restricted Show only firewall drop events
--raw Show raw IPs without service annotation
--raw Show raw IPs without host/service resolution
Examples:
wgctl watch
@ -62,7 +62,7 @@ function cmd::watch::run() {
--blocked) blocked_only=true; shift ;;
--allowed) allowed_only=true; shift ;;
--restricted) restricted_only=true; shift ;;
--raw) raw=true; shift ;;
--raw) _WGCTL_RAW=true; shift ;;
--help) cmd::watch::help; return ;;
*)
log::error "Unknown flag: $1"
@ -72,38 +72,11 @@ function cmd::watch::run() {
esac
done
local net_file=""
$raw || net_file="$(ctx::net)"
log::section "wgctl — Live Monitor (Ctrl+C to stop)"
printf "\n"
# Fixed display widths for watch (dynamic measurement not possible in stream)
local w_client=20 w_dest=18
# 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
monitor::live "$filter_name" "$filter_type" "$filter_peers" \
"$blocked_only" "$restricted_only" "$allowed_only" "$raw"
}
# ============================================
@ -112,18 +85,17 @@ function cmd::watch::run() {
function cmd::watch::_poll_handshakes() {
local filter_name="${1:-}" filter_type="${2:-}" filter_peers="${3:-}"
local w_client="${4:-20}" w_dest="${5:-30}"
local peer_set=()
[[ -n "$filter_peers" ]] && IFS=',' read -ra peer_set <<< "$filter_peers"
local w_client="${4:-20}" w_dest="${5:-18}"
# Collect rows with sort key before printing
local -a rows=()
while IFS= read -r line; do
local public_key ts
public_key=$(echo "$line" | awk '{print $1}')
ts=$(echo "$line" | awk '{print $2}')
[[ -z "$ts" || "$ts" == "0" ]] && continue
# Find client by public key
local client_name=""
for conf in "$(ctx::clients)"/*.conf; do
[[ -f "$conf" ]] || continue
@ -138,27 +110,49 @@ function cmd::watch::_poll_handshakes() {
done
[[ -z "$client_name" ]] && continue
[[ -n "$filter_name" && "$client_name" != "$filter_name" ]] && continue
# Dedup — only emit if handshake is new
local safe_key
safe_key=$(echo "$public_key" | md5sum | cut -d' ' -f1)
local prev_ts_file="/tmp/wgctl_hs_${safe_key}"
local prev_ts="0"
[[ -f "$prev_ts_file" ]] && prev_ts=$(cat "$prev_ts_file")
[[ "$ts" == "$prev_ts" ]] && continue
local gap=$(( ts - ${prev_ts:-0} ))
echo "$ts" > "$prev_ts_file"
(( gap < ${WG_HANDSHAKE_CHECK_TIME_SEC:-300} )) && continue
local ts_fmt
ts_fmt=$(fmt::datetime_short "$ts")
# Resolve endpoint — try wg show first, fall back to endpoint cache
local endpoint
endpoint=$(monitor::endpoint_for_key "$public_key")
ui::watch::wg_row "$ts_fmt" "$client_name" "${endpoint:-}" "handshake" \
"$w_client" "$w_dest"
if [[ -z "$endpoint" ]]; then
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)
# 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
# ============================================
@ -166,11 +160,8 @@ function cmd::watch::_poll_handshakes() {
function cmd::watch::_tail_events() {
local filter_name="${1:-}" filter_type="${2:-}" filter_peers="${3:-}"
local blocked_only="${4:-false}" restricted_only="${5:-false}" allowed_only="${6:-false}"
local net_file="${7:-}" w_client="${8:-20}" w_dest="${9:-30}"
local peer_set=()
[[ -n "$filter_peers" ]] && IFS=',' read -ra peer_set <<< "$filter_peers"
local w_client="${7:-20}" w_dest="${8:-18}"
# Build ip->name map
declare -A ip_to_name=()
while IFS= read -r conf; do
@ -180,54 +171,41 @@ function cmd::watch::_tail_events() {
ip=$(grep "^Address" "$conf" 2>/dev/null | awk '{print $3}' | cut -d'/' -f1)
[[ -n "$ip" && -n "$cname" ]] && ip_to_name["$ip"]="$cname"
done < <(find "$(ctx::clients)" -name "*.conf" 2>/dev/null)
# 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_WG=()
local source_file
source_file=$(mktemp)
echo "wg" > "$source_file"
trap "rm -f '$source_file'" EXIT
tail -f "$(ctx::events_log)" "$(ctx::fw_events_log)" 2>/dev/null \
| while IFS= read -r line; do
[[ -z "$line" ]] && continue
if [[ "$line" == "==> "* ]]; then
[[ "$line" == *"fw_events"* ]] && echo "fw" > "$source_file" || echo "wg" > "$source_file"
continue
fi
local source
source=$(cat "$source_file")
if [[ "$source" == "fw" ]]; then
$allowed_only && continue
local fw_data
fw_data=$(python3 "$(ctx::json_helper)" parse_fw_event "$line" 2>/dev/null) || continue
[[ -z "$fw_data" ]] && continue
local ts src_ip dest_ip dest_port proto
IFS='|' read -r ts src_ip dest_ip dest_port proto <<< "$fw_data"
[[ -z "$src_ip" ]] && continue
local client="${ip_to_name[$src_ip]:-$src_ip}"
[[ -n "$filter_name" && "$client" != "$filter_name" ]] && continue
# Dedup
local fw_key="${src_ip}:${dest_ip}:${dest_port}:${proto}"
local now; now=$(date +%s)
local window=30
@ -236,43 +214,61 @@ function cmd::watch::_tail_events() {
local last="${_WATCH_LAST_FW[$fw_key]:-0}"
(( now - last < window )) && continue
_WATCH_LAST_FW["$fw_key"]="$now"
local ts_fmt
ts_fmt=$(fmt::datetime_short "$(json::iso_to_ts "$ts" 2>/dev/null || echo 0)")
local svc_name dest_display
svc_name=$(_resolve_dest "$dest_ip" "$dest_port" "$proto")
dest_display=$(ui::logs::build_dest "$dest_ip" "$dest_port" "$proto" "$svc_name")
ui::watch::fw_row "$ts_fmt" "$client" "$dest_display" "$w_client" "$w_dest"
local fw_svc_name
fw_svc_name=$(resolve::service_name "$dest_ip" "$dest_port" "$proto")
local fw_src_ep fw_src_resolved=""
fw_src_ep=$(monitor::get_cached_endpoint "$client")
if [[ -n "$fw_src_ep" ]]; then
local fw_ep_parts
fw_ep_parts=$(resolve::endpoint_parts "$fw_src_ep")
fw_src_resolved="${fw_ep_parts#*|}"
fi
ui::watch::fw_row "$ts_fmt" "$client" "$dest_ip" "$dest_port" "$proto" \
"$fw_svc_name" "$fw_src_ep" "$fw_src_resolved" \
"$w_client" "$w_dest" "30"
else
$restricted_only && continue
local ev_data
ev_data=$(python3 "$(ctx::json_helper)" parse_event "$line" 2>/dev/null) || continue
[[ -z "$ev_data" ]] && continue
local ts client endpoint event
IFS='|' read -r ts client endpoint event <<< "$ev_data"
[[ -n "$filter_name" && "$client" != "$filter_name" ]] && continue
$blocked_only && [[ "$event" != "attempt" ]] && continue
$allowed_only && [[ "$event" != "handshake" ]] && continue
# Dedup
local wg_key="${client}:${endpoint}:${event}"
local now; now=$(date +%s)
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"
local ts_fmt
ts_fmt=$(fmt::datetime_short "$(json::iso_to_ts "$ts" 2>/dev/null || echo 0)")
ui::watch::wg_row "$ts_fmt" "$client" "${endpoint:-}" "$event" \
"$w_client" "$w_dest"
# Resolve endpoint — fall back to endpoint cache if empty
local wg_ep_raw wg_ep_resolved=""
wg_ep_raw="${endpoint:-}"
if [[ -n "$wg_ep_raw" ]]; then
local wg_ep_parts
wg_ep_parts=$(resolve::endpoint_parts "$wg_ep_raw")
wg_ep_resolved="${wg_ep_parts#*|}"
fi
ui::watch::wg_row "$ts_fmt" "$client" "$wg_ep_raw" "$event" \
"$wg_ep_resolved" "$w_client" "30"
fi
done
rm -f "$source_file"
}

View file

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

Binary file not shown.

View file

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

195
core/command_mixins.sh Normal file
View file

@ -0,0 +1,195 @@
#!/usr/bin/env bash
# core/command_mixins.sh
# Mixin infrastructure — loads mixin files and provides command::mixin / flag::exclusive
# ============================================
# Active mixin tracking (per-process)
# ============================================
declare -a _ACTIVE_MIXINS=()
# ============================================
# Auto-load all mixin files
# ============================================
function command::_load_mixins() {
local mixin_file
local -a mixin_paths=(
"${WGCTL_DIR}/core/mixins/"*.mixin.sh
"${WGCTL_DIR}/commands/mixins/"*.mixin.sh
)
for mixin_file in "${mixin_paths[@]:-}"; do
[[ -f "$mixin_file" ]] && source "$mixin_file"
done
}
# ============================================
# command::mixin <name>
# Called from cmd::<name>::on_load to opt into a mixin
# ============================================
function command::mixin() {
local name="${1:-}"
[[ -z "$name" ]] && log::error "command::mixin: missing name" && return 1
local register_fn="command::mixin::${name}::register"
if declare -f "$register_fn" >/dev/null 2>&1; then
# Track for reset/process — avoid duplicates
local m already=false
for m in "${_ACTIVE_MIXINS[@]:-}"; do
[[ "$m" == "$name" ]] && already=true && break
done
$already || _ACTIVE_MIXINS+=("$name")
"$register_fn"
else
log::error "Unknown mixin: ${name} (no command::mixin::${name}::register found)"
return 1
fi
}
# ============================================
# command::_reset_mixins
# Called by command::run before each invocation
# ============================================
function command::_reset_mixin_state() {
# Reset values but keep _ACTIVE_MIXINS populated
local m
for m in "${_ACTIVE_MIXINS[@]:-}"; do
local reset_fn="command::mixin::${m}::reset"
declare -f "$reset_fn" >/dev/null 2>&1 && "$reset_fn"
done
}
function command::_reset_mixins() {
_ACTIVE_MIXINS=()
local reset_fn mixin_file
# Reset all known mixins regardless of active state
for mixin_file in \
"${WGCTL_DIR}/core/mixins/"*.mixin.sh \
"${WGCTL_DIR}/commands/mixins/"*.mixin.sh; do
[[ -f "$mixin_file" ]] || continue
local mixin_name
mixin_name=$(basename "$mixin_file" .mixin.sh)
reset_fn="command::mixin::${mixin_name}::reset"
declare -f "$reset_fn" >/dev/null 2>&1 && "$reset_fn"
done
}
# ============================================
# command::_preprocess_flags <args_nameref>
# Called by command::run — strips mixin flags from args
# ============================================
function command::_preprocess_flags() {
local -n _args_ref="$1"
local -a _filtered=()
if [[ ${#_args_ref[@]} -eq 0 ]]; then
return 0 # nothing to process
fi
for _arg in "${_args_ref[@]}"; do
local _consumed=false
for _mixin in "${_ACTIVE_MIXINS[@]:-}"; do
local _process_fn="command::mixin::${_mixin}::process"
if declare -f "$_process_fn" >/dev/null 2>&1; then
if "$_process_fn" "$_arg"; then
_consumed=true
break
fi
fi
done
$_consumed || _filtered+=("$_arg")
done
if [[ ${#_filtered[@]} -gt 0 ]]; then
_args_ref=("${_filtered[@]}")
else
_args_ref=()
fi
}
# command::_resolve_conflicts <defaults_nameref> <user_nameref> <groups_string>
# Removes conflicting defaults when user provides a member of an exclusive group
function command::_resolve_conflicts() {
local -n _def_ref="$1"
local -n _usr_ref="$2"
local groups="$3"
[[ -z "$groups" ]] && return 0
[[ ${#_def_ref[@]} -eq 0 ]] && return 0
# Work on a copy — progressively filter across all groups
local -a working=("${_def_ref[@]}")
local group
while IFS= read -r group; do
[[ -z "$group" ]] && continue
local -a members=()
IFS=',' read -ra members <<< "$group"
# Find which member (if any) the user passed from this group
local user_member=""
local member user_arg
for member in "${members[@]}"; do
for user_arg in "${_usr_ref[@]:-}"; do
if [[ "$user_arg" == "$member" ]]; then
user_member="$member"
break 2
fi
done
done
# No user member in this group — don't touch defaults
[[ -z "$user_member" ]] && continue
# User passed a member — remove all OTHER members from defaults
# (keep the same flag if it was already in defaults)
local -a new_working=()
local def_arg
for def_arg in "${working[@]:-}"; do
local is_other_member=false
for member in "${members[@]}"; do
# It's another member if it's in the group AND not the same as user's choice
if [[ "$def_arg" == "$member" && "$def_arg" != "$user_member" ]]; then
is_other_member=true
break
fi
done
$is_other_member || new_working+=("$def_arg")
done
working=("${new_working[@]:-}")
done < <(echo "$groups" | tr '|' '\n')
# Write back
if [[ ${#working[@]} -gt 0 ]]; then
_def_ref=("${working[@]}")
else
_def_ref=()
fi
}
# ============================================
# Flag Exclusive
# ============================================
declare -gA _FLAG_EXCLUSIVE_GROUPS=()
# flag::exclusive <flag1> <flag2> ...
# Called from on_load — registers mutually exclusive flags for current command
function flag::exclusive() {
local cmd="${_CURRENT_LOADING_CMD:-}"
[[ -z "$cmd" ]] && return 0
# Join flags with comma as one group
local group
group=$(IFS=','; echo "$*")
if [[ -n "${_FLAG_EXCLUSIVE_GROUPS[$cmd]:-}" ]]; then
_FLAG_EXCLUSIVE_GROUPS["$cmd"]+="${group}|"
else
_FLAG_EXCLUSIVE_GROUPS["$cmd"]="${group}|"
fi
}

View file

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

View file

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

View file

@ -12,8 +12,8 @@ function json::has_key() { python3 "$JSON_HELPER" has_key
function json::filter_values() { python3 "$JSON_HELPER" filter_values "$@" </dev/null; }
function json::last_event() { python3 "$JSON_HELPER" last_event "$@" </dev/null; }
function json::events_for() { python3 "$JSON_HELPER" events_for "$@" </dev/null; }
function json::fw_events() { WGCTL_DATETIME_FMT="$FMT_DATETIME_SHORT" python3 "$JSON_HELPER" fw_events "$@" </dev/null; }
function json::wg_events() { WGCTL_DATETIME_FMT="$FMT_DATETIME_SHORT" python3 "$JSON_HELPER" wg_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 "$@" "$(ctx::endpoint_cache)" </dev/null; }
function json::format_fw_event() { echo "$1" | python3 "$JSON_HELPER" format_fw_event "$2"; }
function json::format_wg_event() { echo "$1" | python3 "$JSON_HELPER" format_wg_event; }
function json::remove_events() { python3 "$JSON_HELPER" remove_events "$@" </dev/null; }
@ -107,9 +107,58 @@ function json::policy_set_field() { python3 "$JSON_HELPER" policy_set_fiel
function json::subnet_policy() { python3 "$JSON_HELPER" subnet_policy "$@" </dev/null; }
# Activity Monitor
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::activity_aggregate() { python3 "$JSON_HELPER" activity_aggregate "$@" </dev/null; }
function json::iso_to_ts() { python3 "$JSON_HELPER" iso_to_ts "$@" </dev/null; }
# Hosts Resolution
function json::hosts_list() { python3 "$JSON_HELPER" hosts_list "$@" </dev/null; }
function json::hosts_show() { python3 "$JSON_HELPER" hosts_show "$@" </dev/null; }
function json::hosts_add() { python3 "$JSON_HELPER" hosts_add "$@" </dev/null; }
function json::hosts_remove() { python3 "$JSON_HELPER" hosts_remove "$@" </dev/null; }
function json::hosts_exists() { python3 "$JSON_HELPER" hosts_exists "$@" </dev/null; }
function json::hosts_lookup() { python3 "$JSON_HELPER" hosts_lookup "$@" </dev/null; }
# Peer History
function json::peer_history_lookup() { python3 "$JSON_HELPER" peer_history_lookup "$(ctx::data)/peer-history" "$1" </dev/null; }
function json::clean_handshakes() { python3 "$JSON_HELPER" clean_handshakes "$@" </dev/null; }
function json::batch_resolve() { python3 "$JSON_HELPER" batch_resolve "$(ctx::hosts)" "$(ctx::net)" "$@" </dev/null; }
# JSON Envelopes
function json::envelope() {
local command="${1:-}" count="${2:-0}"
# Reads JSON array from stdin, wraps in envelope
local data
data=$(cat)
local ts
ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
printf '{"ok":true,"command":"%s","data":%s,"meta":{"count":%s,"generated_at":"%s"}}\n' \
"$command" "$data" "$count" "$ts"
}
function json::error_envelope() {
local command="${1:-}" error="${2:-}"
local ts
ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
printf '{"ok":false,"command":"%s","error":"%s","meta":{"generated_at":"%s"}}\n' \
"$command" "$error" "$ts"
}
# Config
function json::config_load() { python3 "$JSON_HELPER" config_load "$1" </dev/null; }
function json::block_history_record() { python3 "$JSON_HELPER" block_history_record "$@" </dev/null; }
function json::block_history_unblock() { python3 "$JSON_HELPER" block_history_unblock "$@" </dev/null; }
function json::block_history_list() { python3 "$JSON_HELPER" block_history_list "$@" </dev/null; }
function json::block_history_list_all() { python3 "$JSON_HELPER" block_history_list_all "$@" </dev/null; }
function json::endpoint_cache_get() { python3 "$JSON_HELPER" endpoint_cache_get "$@" </dev/null; }
# Accept Events
function json::accept_events() { python3 "$JSON_HELPER" accept_events "$@" </dev/null; }
function json::accept_aggregate() { python3 "$JSON_HELPER" accept_aggregate "$@" </dev/null; }
function json::batch_resolve_dest() { python3 "$JSON_HELPER" batch_resolve_dest "$(ctx::net)" "$(ctx::hosts)" "$@" </dev/null; }
function json::peer_transfer() {
ACTIVITY_TOTAL_LOW="$(config::activity_total_low)" \
ACTIVITY_TOTAL_MED="$(config::activity_total_med)" \
@ -123,3 +172,8 @@ function json::peer_transfer_delta() {
python3 "$JSON_HELPER" peer_transfer_delta "$@" </dev/null
}
# Importer
function json::import_peer() { python3 "$JSON_HELPER" import_peer "$@" </dev/null; }
function json::import_identity() { python3 "$JSON_HELPER" import_identity "$@" </dev/null; }
function json::import_full() { python3 "$JSON_HELPER" import_full "$@" </dev/null; }
function json::import_get_field() { python3 "$JSON_HELPER" import_get_field "$@" </dev/null; }

File diff suppressed because it is too large Load diff

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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

260
core/lib/accept_events.py Normal file
View file

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

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

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

103
core/lib/block_history.py Normal file
View file

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

674
core/lib/events.py Normal file
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)

195
core/lib/importer.py Normal file
View file

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

180
core/lib/peers.py Normal file
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_SECTION_WIDTH=${UI_SECTION_WIDTH:-44}
# UTF-8 multi-byte character extras (bash ${#} counts bytes, not chars)
# extra = byte_length - visible_char_length
_UI_EMDASH_EXTRA=2 # — (em dash) 3 bytes, 1 visible
_UI_ARROW_EXTRA=2 # → (right arrow) 3 bytes, 1 visible
_UI_BULLET_EXTRA=1 # · (middle dot) 2 bytes, 1 visible
function ui::row() {
local label="$1" value="$2" width="${3:-$UI_ROW_WIDTH}"
printf " %-${width}s %s\n" "${label}:" "$value"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

View file

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

View file

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

View file

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

Binary file not shown.

View file

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

View file

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

385
daemon/wgctl-monitor.py Executable file
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]
Type=simple
ExecStart=/usr/bin/python3 /etc/wireguard/.wgctl/daemon/wgctl-monitor.py
ExecStart=/usr/bin/python3 /etc/wireguard/wgctl/daemon/wgctl-monitor.py
Restart=always
RestartSec=5
Environment=WG_INTERFACE=eth0

View file

@ -8,31 +8,33 @@ function config::on_load() {
config::_init_defaults
config::load
config::validate
fmt::set_date_format "${_FMT_DATE_FORMAT:-iso}"
fmt::set_date_format "${_FMT_DATE_FORMAT:-eu}"
}
# ============================================
# Defaults
# ============================================
# Activity thresholds
declare -g _ACTIVITY_TOTAL_LOW_BYTES="${ACTIVITY_TOTAL_LOW_BYTES:-1000000}"
declare -g _ACTIVITY_TOTAL_MED_BYTES="${ACTIVITY_TOTAL_MED_BYTES:-10000000}"
declare -g _ACTIVITY_TOTAL_HIGH_BYTES="${ACTIVITY_TOTAL_HIGH_BYTES:-100000000}"
declare -g _ACTIVITY_CURRENT_LOW_BYTES="${ACTIVITY_CURRENT_LOW_BYTES:-1000000}"
declare -g _ACTIVITY_CURRENT_MED_BYTES="${ACTIVITY_CURRENT_MED_BYTES:-10000000}"
declare -g _ACTIVITY_CURRENT_HIGH_BYTES="${ACTIVITY_CURRENT_HIGH_BYTES:-100000000}"
declare -gA _COMMAND_DEFAULTS=()
declare -gA _COMMAND_ALIASES=()
function config::_init_defaults() {
_WG_INTERFACE="${WG_INTERFACE:-wg0}"
_WG_DNS="${WG_DNS:-10.0.0.103}"
_WG_LAN="${WG_LAN:-10.0.0.0/24}"
_WG_SUBNET="${WG_SUBNET:-10.1.0.0/16}"
_WG_PORT="${WG_PORT:-51820}"
_WG_ENDPOINT="${WG_ENDPOINT:-}"
_WG_HANDSHAKE_CHECK_TIME_SEC="${WG_HANDSHAKE_CHECK_TIME_SEC:-180}"
_WG_INTERFACE="wg0"
_WG_DNS="10.0.0.103"
_WG_DNS_FALLBACK=""
_WG_LAN="10.0.0.0/24"
_WG_SUBNET="10.1.0.0/16"
_WG_PORT="51820"
_WG_ENDPOINT=""
_WG_HANDSHAKE_CHECK_TIME_SEC="300"
_FMT_DATE_FORMAT="eu"
# Derived
_WG_CONFIG="$(ctx::wg)/${_WG_INTERFACE}.conf"
@ -43,13 +45,97 @@ function config::_init_defaults() {
}
# ============================================
# Validation
# Load from wgctl.json
# Falls back to wgctl.conf for migration period
# ============================================
function config::load() {
local json_conf
json_conf="$(ctx::config_file)"
if [[ -f "$json_conf" ]]; then
config::_load_json "$json_conf"
else
# Fallback: legacy wgctl.conf
local legacy_conf
legacy_conf="$(ctx::wgctl)/wgctl.conf"
[[ -f "$legacy_conf" ]] && config::_load_legacy "$legacy_conf"
fi
# Recompute derived values after overrides
_WG_CONFIG="$(ctx::wg)/${_WG_INTERFACE}.conf"
_WG_TUNNEL_SPLIT="${_WG_SUBNET}, ${_WG_LAN}"
}
function config::_load_json() {
local file="$1"
[[ ! -f "$file" ]] && return 0
while IFS='=' read -r key value; do
[[ -z "$key" ]] && continue
case "$key" in
WG_INTERFACE) _WG_INTERFACE="$value" ;;
WG_ENDPOINT) _WG_ENDPOINT="$value" ;;
WG_DNS) _WG_DNS="$value" ;;
WG_DNS_FALLBACK) _WG_DNS_FALLBACK="$value" ;;
WG_PORT) _WG_PORT="$value" ;;
WG_SUBNET) _WG_SUBNET="$value" ;;
WG_LAN) _WG_LAN="$value" ;;
WG_HANDSHAKE_CHECK_TIME_SEC) _WG_HANDSHAKE_CHECK_TIME_SEC="$value" ;;
DATE_FORMAT)
_FMT_DATE_FORMAT="$value"
fmt::set_date_format "$value"
;;
ACTIVITY_TOTAL_LOW_BYTES) _ACTIVITY_TOTAL_LOW_BYTES="$value" ;;
ACTIVITY_TOTAL_MED_BYTES) _ACTIVITY_TOTAL_MED_BYTES="$value" ;;
ACTIVITY_TOTAL_HIGH_BYTES) _ACTIVITY_TOTAL_HIGH_BYTES="$value" ;;
ACTIVITY_CURRENT_LOW_BYTES) _ACTIVITY_CURRENT_LOW_BYTES="$value" ;;
ACTIVITY_CURRENT_MED_BYTES) _ACTIVITY_CURRENT_MED_BYTES="$value" ;;
ACTIVITY_CURRENT_HIGH_BYTES) _ACTIVITY_CURRENT_HIGH_BYTES="$value" ;;
CMD_DEFAULT:*)
local cmd_name="${key#CMD_DEFAULT:}"
_COMMAND_DEFAULTS["$cmd_name"]="$value"
;;
CMD_ALIAS:*)
local alias_name="${key#CMD_ALIAS:}"
_COMMAND_ALIASES["$alias_name"]="$value"
;;
esac
done < <(json::config_load "$file" 2>/dev/null)
}
function config::_load_legacy() {
local conf_file="$1"
log::wg_warning "Using legacy wgctl.conf — run 'wgctl config migrate' to upgrade"
while IFS='=' read -r key value || [[ -n "$key" ]]; do
[[ "$key" =~ ^[[:space:]]*# ]] && continue
[[ -z "${key// }" ]] && continue
key="${key// /}"
value="${value// /}"
case "$key" in
WG_INTERFACE) _WG_INTERFACE="$value" ;;
WG_ENDPOINT) _WG_ENDPOINT="$value" ;;
WG_DNS) _WG_DNS="$value" ;;
WG_DNS_FALLBACK) _WG_DNS_FALLBACK="$value" ;;
WG_PORT) _WG_PORT="$value" ;;
WG_SUBNET) _WG_SUBNET="$value" ;;
WG_LAN) _WG_LAN="$value" ;;
WG_HANDSHAKE_CHECK_TIME_SEC) _WG_HANDSHAKE_CHECK_TIME_SEC="$value" ;;
DATE_FORMAT)
_FMT_DATE_FORMAT="$value"
fmt::set_date_format "$value"
;;
esac
done < "$conf_file"
}
# ============================================
# Validation (unchanged)
# ============================================
function config::validate() {
local errors=()
# Server key and config files
if [[ ! -f "$_WG_SERVER_PUBLIC_KEY_FILE" ]]; then
errors+=("Server public key not found: ${_WG_SERVER_PUBLIC_KEY_FILE}")
fi
@ -60,7 +146,6 @@ function config::validate() {
errors+=("WireGuard config not found: ${_WG_CONFIG}")
fi
# Required config values
local endpoint
endpoint=$(config::endpoint)
if [[ -z "$endpoint" ]]; then
@ -91,7 +176,6 @@ function config::validate() {
errors+=("WG_SUBNET is not set — required for IP allocation")
fi
# Warn-only
local lan
lan=$(config::lan)
if [[ -z "$lan" ]]; then
@ -103,7 +187,7 @@ function config::validate() {
for err in "${errors[@]}"; do
printf " ✗ %s\n" "$err" >&2
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
fi
@ -111,49 +195,14 @@ function config::validate() {
}
# ============================================
# Load overrides from .wgctl/wgctl.conf
# ============================================
function config::load() {
local conf_file
conf_file="$(ctx::data)/wgctl.conf"
[[ ! -f "$conf_file" ]] && return 0
while IFS='=' read -r key value || [[ -n "$key" ]]; do
[[ "$key" =~ ^[[:space:]]*# ]] && continue
[[ -z "${key// }" ]] && continue
key="${key// /}"
value="${value// /}"
case "$key" in
WG_INTERFACE) _WG_INTERFACE="$value" ;;
WG_ENDPOINT) _WG_ENDPOINT="$value" ;;
WG_DNS) _WG_DNS="$value" ;;
WG_PORT) _WG_PORT="$value" ;;
WG_SUBNET) _WG_SUBNET="$value" ;;
WG_LAN) _WG_LAN="$value" ;;
WG_HANDSHAKE_CHECK_TIME_SEC) _WG_HANDSHAKE_CHECK_TIME_SEC="$value" ;;
ACTIVITY_LOW_BYTES) _ACTIVITY_LOW_BYTES="$value" ;;
ACTIVITY_MED_BYTES) _ACTIVITY_MED_BYTES="$value" ;;
ACTIVITY_HIGH_BYTES) _ACTIVITY_HIGH_BYTES="$value" ;;
DATE_FORMAT)
_FMT_DATE_FORMAT="$value"
fmt::set_date_format "$value"
;;
esac
done < "$conf_file"
# Recompute derived values after overrides
_WG_CONFIG="$(ctx::wg)/${_WG_INTERFACE}.conf"
_WG_TUNNEL_SPLIT="${_WG_SUBNET}, ${_WG_LAN}"
}
# ============================================
# Accessors
# Accessors (unchanged)
# ============================================
function config::interface() { echo "$_WG_INTERFACE"; }
function config::config_file() { echo "$_WG_CONFIG"; }
function config::endpoint() { echo "$_WG_ENDPOINT"; }
function config::dns() { echo "$_WG_DNS"; }
function config::dns_fallback() { echo "${_WG_DNS_FALLBACK:-}"; }
function config::port() { echo "$_WG_PORT"; }
function config::subnet() { echo "$_WG_SUBNET"; }
function config::lan() { echo "$_WG_LAN"; }
@ -163,13 +212,13 @@ function config::handshake_time_sec() { echo "$_WG_HANDSHAKE_CHECK_TIME_SEC"
function config::activity_total_low() { echo "$_ACTIVITY_TOTAL_LOW_BYTES"; }
function config::activity_total_med() { echo "$_ACTIVITY_TOTAL_MED_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_med() { echo "$_ACTIVITY_TOTAL_MED_BYTES"; }
function config::activity_current_high() { echo "$_ACTIVITY_TOTAL_HIGH_BYTES"; }
function config::activity_current_low() { echo "$_ACTIVITY_CURRENT_LOW_BYTES"; }
function config::activity_current_med() { echo "$_ACTIVITY_CURRENT_MED_BYTES"; }
function config::activity_current_high() { echo "$_ACTIVITY_CURRENT_HIGH_BYTES"; }
function config::server_public_key() { cat "$_WG_SERVER_PUBLIC_KEY_FILE"; }
function config::allowed_ips_for() {
local tunnel="${2:-split}"
local tunnel="${1:-split}"
case "$tunnel" in
full) echo "$_WG_TUNNEL_FULL" ;;
split) echo "$_WG_TUNNEL_SPLIT" ;;
@ -178,4 +227,14 @@ function config::allowed_ips_for() {
return 1
;;
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() {
local client="$1"
local client="${1:-}"
json::get "$ENDPOINT_CACHE" "$client"
}
@ -138,3 +138,43 @@ function monitor::restart() {
function monitor::is_running() {
systemctl is-active --quiet "$MONITOR_SERVICE"
}
function monitor::live() {
local filter_name="${1:-}" filter_type="${2:-}" filter_peers="${3:-}"
local blocked_only="${4:-false}" restricted_only="${5:-false}" allowed_only="${6:-false}"
local raw="${7:-false}"
[[ "$raw" == "true" ]] && _WGCTL_RAW=true
rm -f /tmp/wgctl_hs_* /tmp/wgctl_attempt_* 2>/dev/null || true
local w_client=20 w_dest=18
while IFS= read -r conf; do
local name
name=$(basename "$conf" .conf)
(( ${#name} > w_client )) && w_client=${#name}
done < <(find "$(ctx::clients)" -name "*.conf" 2>/dev/null)
(( w_client += 2 ))
if ! $blocked_only && ! $restricted_only; then
(
while true; do
cmd::watch::_poll_handshakes \
"$filter_name" "$filter_type" "$filter_peers" "$w_client" "$w_dest"
sleep 5
done
) &
local poller_pid=$!
fi
cmd::watch::_tail_events \
"$filter_name" "$filter_type" "$filter_peers" \
"$blocked_only" "$restricted_only" "$allowed_only" \
"$w_client" "$w_dest" &
local tailer_pid=$!
trap "kill $tailer_pid ${poller_pid:-} 2>/dev/null; \
rm -f /tmp/wgctl_hs_* /tmp/wgctl_attempt_*; printf '\n'; exit 0" INT TERM
wait
}

View file

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

View file

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

View file

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

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

162
modules/ui/group.module.sh Normal file
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 ""
}
function ui::identity::device_row() {
local peer_name="${1:-}" dev_type="${2:-}" \
dev_index="${3:-1}" status="${4:-}"
local suffix=""
[[ "$dev_index" -gt 1 ]] && suffix=" (#${dev_index})"
printf " · %-24s %-10s%s%s\n" \
"$peer_name" "$dev_type" "$suffix" "$status"
}
function ui::identity::migrate_create() {
local peer_name="${1:-}" identity_name="${2:-}" \
peer_type="${3:-}" index="${4:-}"
@ -78,4 +69,28 @@ function ui::identity::list_row_table() {
local types_display="${types//,/, }"
[[ -z "$types_display" ]] && types_display="—"
printf " %-20s %-7s %s\n" "$name" "$peer_count" "$types_display"
}
function ui::identity::device_row() {
local peer_name="${1:-}" dev_type="${2:-}" \
dev_index="${3:-1}" status="${4:-}"
# Extract connection state for dimming
local is_online=false
[[ "$status" == *"online"* ]] && is_online=true
# Only show index suffix if peer name doesn't already encode it
local suffix=""
[[ "$dev_index" -gt 1 && "$peer_name" != *"-${dev_index}" ]] && \
suffix=" (#${dev_index})"
if $is_online; then
printf " · %-24s %-10s%s%s\n" \
"$peer_name" "$dev_type" "$suffix" "$status"
else
local clean_status
clean_status=$(echo "$status" | sed 's/\x1b\[[0-9;]*m//g')
printf " · %-24s %-10s%s\033[2m%s\033[0m\n" \
"$peer_name" "$dev_type" "$suffix" "$clean_status"
fi
}

View file

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

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

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

View file

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

View file

@ -167,22 +167,50 @@ function ui::rule::tree() {
# ui::rule::identity_block <identity_name> <strict_rule>
# Renders the full identity rule block in inspect.
function ui::rule::identity_block() {
local identity_name="${1:-}" strict="${2:-false}"
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
rules=$(identity::rules "$identity_name")
[[ -z "$rules" ]] && return 0
printf "\n \033[0;37m· identity:%s\033[0m\n" "$identity_name"
if ! $no_header; then
printf "\n \033[0;37m· identity:%s\033[0m\n" "$identity_name"
fi
# Indentation levels:
# normal: entry=6 label=6 own=10 own_label=8
# no-header: entry=4 label=4 own=8 own_label=6
local entry_indent label_indent own_indent own_label_indent
if $no_header; then
entry_indent=4
label_indent=4
own_indent=8
own_label_indent=6
else
entry_indent=6
label_indent=6
own_indent=10
own_label_indent=8
fi
local first=true
while IFS= read -r rule_name; do
[[ -z "$rule_name" ]] && continue
$first || printf "\n"
first=false
ui::rule::_identity_rule_entry "$rule_name"
ui::rule::_identity_rule_entry "$rule_name" \
"$entry_indent" "$label_indent" "$own_indent" "$own_label_indent"
done <<< "$rules"
if [[ "$strict" == "true" ]]; then
printf "\n \033[2m(strict — peer rule suppressed)\033[0m\n"
fi
@ -192,30 +220,41 @@ function ui::rule::identity_block() {
# Renders one rule within an identity block.
function ui::rule::_identity_rule_entry() {
local rule_name="${1:-}"
local entry_indent="${2:-6}"
local label_indent="${3:-6}"
local own_indent="${4:-10}"
local own_label_indent="${5:-8}"
local rule_file
rule_file="$(rule::path "$rule_name")" || return 0
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=()
mapfile -t extends_raw < <(json::get "$rule_file" "extends" 2>/dev/null || true) || true
if [[ ${#extends_raw[@]} -gt 0 && -n "${extends_raw[0]:-}" ]]; then
ui::rule::_render_bases extends_raw 10 8
ui::rule::_render_bases extends_raw "$own_indent" "$(( entry_indent + 2 ))"
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
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"
fi
else
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
printf "%s\n" "$own_output"
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
}
@ -292,29 +331,38 @@ function ui::rule::list_row() {
extends_indicator=" \033[2m↳ ${extends_display}\033[0m"
fi
# Build allows and blocks — pad to fixed visible width of 5
local allows_str blocks_str
if [[ "$n_allows" -eq 0 && "$n_blocks" -eq 0 ]]; then
allows_str=$(ui::pad_mb "\033[1;32m+all\033[0m" 5)
blocks_str=$(printf "%5s" "")
# Allows — green +N, padded to 5 visible chars
# Append spaces after reset code so printf doesn't miscount
local allows_str
if [[ "$n_allows" -gt 0 ]]; then
printf -v allows_str "\033[1;32m+%s\033[0m" "$n_allows"
allows_str="${allows_str}$(printf '%*s' "$(( 4 - ${#n_allows} ))" '')"
else
if [[ "$n_allows" -gt 0 ]]; then
allows_str=$(ui::pad_mb "\033[1;32m+${n_allows}\033[0m" 5)
else
allows_str=$(printf "%5s" "")
fi
if [[ "$n_blocks" -gt 0 ]]; then
blocks_str=$(ui::pad_mb "\033[1;31m-${n_blocks}\033[0m" 5)
else
blocks_str=$(printf "%5s" "")
fi
printf -v allows_str "\033[2m+0\033[0m " # "+0" = 2 visible + 3 spaces = 5
fi
printf " %s %b%b %s%*s%b\n" \
"$name_pad" "$allows_str" "$blocks_str" \
"$peers_display" "$peers_pad_n" "" "$extends_indicator"
}
# Blocks — red -N, padded to 5 visible chars
local blocks_str
if [[ "$n_blocks" -gt 0 ]]; then
printf -v blocks_str "\033[1;31m-%s\033[0m" "$n_blocks"
blocks_str="${blocks_str}$(printf '%*s' "$(( 4 - ${#n_blocks} ))" '')"
else
printf -v blocks_str "\033[2m-0\033[0m " # "-0" = 2 visible + 3 spaces = 5
fi
# Peers — dim if zero
local peers_colored
if [[ "$peer_count" -eq 0 ]]; then
peers_colored="\033[2m${peers_display}\033[0m"
else
peers_colored="$peers_display"
fi
printf " %s %b%b %b%*s%b\n" \
"$name_pad" "$allows_str" "$blocks_str" \
"$peers_colored" "$peers_pad_n" "" "$extends_indicator"
}
# ui::rule::list_extends <extends_csv>
# Renders the extends tree for a rule in list view (compact, one level)
function ui::rule::list_extends() {
@ -344,6 +392,24 @@ function ui::rule::list_extends_detailed() {
done
}
# ======================================================
# Table view
# ======================================================
function ui::rule::list_header_table() {
printf "\n %-20s %-6s %-6s %-8s %-20s %s\n" \
"NAME" "ALLOW" "BLOCK" "PEERS" "EXTENDS" "GROUP"
printf " %s\n" "$(printf '─%.0s' {1..75})"
}
function ui::rule::list_row_table() {
local name="${1:-}" n_allows="${2:-0}" n_blocks="${3:-0}" \
peer_count="${4:-0}" extends="${5:-}" group="${6:-}"
printf " %-20s %-6s %-6s %-8s %-20s %s\n" \
"$name" "+${n_allows}" "-${n_blocks}" "$peer_count" \
"${extends:--}" "${group:--}"
}
# ======================================================
# Show helpers
# ======================================================

View file

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

15
wgctl
View file

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