From 0efa6c3a9e4fc31032009f25bf3d0242489a302f Mon Sep 17 00:00:00 2001 From: Nuno Duque Nunes Date: Mon, 11 May 2026 22:27:33 +0000 Subject: [PATCH] feat: date format config, batch optimizations, list refactor, fw:: rename, .wgctl data dir --- commands/add.command.sh | 65 +- commands/audit.command.sh | 182 ++ commands/block.command.sh | 29 +- commands/fw.command.sh | 154 + commands/group.command.sh | 761 +++++ commands/inspect.command.sh | 184 +- commands/list.command.sh | 563 ++-- commands/list.command.sh.bak | 560 ++++ commands/logs.command.sh | 252 ++ commands/remove.command.sh | 38 +- commands/rename.command.sh | 5 + commands/rule.command.sh | 522 ++++ commands/service.command.sh | 9 +- commands/shell.command.sh | 164 ++ commands/unblock.command.sh | 21 +- commands/watch.command.sh | 9 +- core.sh | 4 + core/context.sh | 44 +- core/fmt.sh | 55 + core/fmt_helper.py | 30 + core/json.sh | 29 + core/json_helper.py | 668 +++++ core/module.sh | 4 +- core/test/test.sh | 46 + core/ui.sh | 55 + core/utils.sh | 16 + daemon/endpoint_cache.json | 8 +- daemon/events.log | 5146 --------------------------------- daemon/watchlist.json | 1 - daemon/wgctl-monitor.py | 188 -- daemon/wgctl-monitor.service | 2 +- install/install.sh | 68 + install/ulogd.conf | 17 + install/wgctl-monitor.service | 15 + install/wgctl.conf.example | 9 + modules/config.module.sh | 119 +- modules/firewall.module.sh | 247 +- modules/group.module.sh | 53 + modules/log.module.sh | 115 +- modules/log.module.sh.bak | 326 +++ modules/monitor.module.sh | 152 +- modules/peers.module.sh | 107 +- modules/rule.module.sh | 292 ++ wgctl | 6 +- 44 files changed, 5338 insertions(+), 6002 deletions(-) create mode 100644 commands/audit.command.sh create mode 100644 commands/fw.command.sh create mode 100644 commands/group.command.sh create mode 100644 commands/list.command.sh.bak create mode 100644 commands/logs.command.sh create mode 100644 commands/rule.command.sh create mode 100644 commands/shell.command.sh create mode 100644 core/fmt.sh create mode 100644 core/fmt_helper.py create mode 100644 core/json.sh create mode 100644 core/json_helper.py create mode 100644 core/test/test.sh create mode 100644 core/ui.sh delete mode 100644 daemon/events.log delete mode 100644 daemon/watchlist.json delete mode 100755 daemon/wgctl-monitor.py create mode 100644 install/install.sh create mode 100644 install/ulogd.conf create mode 100644 install/wgctl-monitor.service create mode 100644 install/wgctl.conf.example create mode 100644 modules/group.module.sh create mode 100644 modules/log.module.sh.bak create mode 100644 modules/rule.module.sh diff --git a/commands/add.command.sh b/commands/add.command.sh index e2dea10..26f58b2 100644 --- a/commands/add.command.sh +++ b/commands/add.command.sh @@ -7,6 +7,8 @@ function cmd::add::on_load() { flag::register --name flag::register --type + flag::register --subtype + flag::register --rule flag::register --ip flag::register --preset flag::register --guest @@ -114,6 +116,8 @@ function cmd::add::is_mobile() { function cmd::add::run() { local name="" local type="" + local subtype="" + local rule="" local ip="" local tunnel="" local guest=false @@ -126,11 +130,13 @@ function cmd::add::run() { case "$1" in --name) name="$2"; shift 2 ;; --type) type="$2"; shift 2 ;; + --subtype) subtype="$2"; shift 2 ;; + --rule) rule="$2"; shift 2 ;; --ip) ip="$2"; shift 2 ;; --preset) presets+=("$2"); shift 2 ;; --guest) guest=true; shift ;; --tunnel) tunnel="$2"; shift 2 ;; - --show-config) show_config=true; shift ;; + --show-config) show_config=true; shift ;; --show-qr) show_qr=true; shift ;; --help) cmd::add::help; return ;; *) @@ -146,6 +152,18 @@ function cmd::add::run() { type="guest" fi + # Resolve guest subtype + local effective_type="$type" + if [[ "$type" == "guest" && -n "$subtype" ]]; then + # Validate subtype + local valid_subtypes="desktop laptop phone tablet" + if ! echo "$valid_subtypes" | grep -qw "$subtype"; then + log::error "Invalid subtype: ${subtype} (valid: desktop, laptop, phone, tablet)" + return 1 + fi + effective_type="guest-${subtype}" + fi + # Build full client name local full_name="${type}-${name}" @@ -157,14 +175,29 @@ function cmd::add::run() { tunnel=$(config::default_tunnel_for "$type") fi + # Determine rule — explicit flag > type default + if [[ -z "$rule" ]]; then + if config::is_guest_type "$type"; then + rule="guest" + else + rule="user" + fi + fi + + # Validate rule if specified + if ! rule::exists "$rule"; then + log::error "Rule not found: ${rule}" + return 1 + fi + local allowed_ips - allowed_ips=$(config::allowed_ips_for "$type" "$tunnel") || return 1 + allowed_ips=$(config::allowed_ips_for "$effective_type" "$tunnel") || return 1 log::section "Adding client: ${full_name}" # Auto-assign IP if not provided if [[ -z "$ip" ]]; then - ip=$(ip::next_for_type "$type") || return 1 + ip=$(ip::next_for_type "$effective_type") || return 1 fi log::wg_add "Name: ${full_name}" @@ -176,21 +209,28 @@ function cmd::add::run() { keys::generate_pair "$full_name" || return 1 # Create client config - peers::create_client_config "$full_name" "$type" "$ip" "$allowed_ips" || return 1 + peers::create_client_config "$full_name" "$effective_type" "$ip" "$allowed_ips" || return 1 + + # Store subtype in meta + if [[ -n "$subtype" ]]; then + peers::set_meta "$full_name" "subtype" "$subtype" + fi # Add peer to server config local public_key public_key=$(keys::public "$full_name") || return 1 peers::add_to_server "$full_name" "$public_key" "$ip" || return 1 + log::wg_add "Rule: ${rule:-none}" + # Apply presets for preset in "${presets[@]}"; do - firewall::apply_preset "$preset" "${ip}" || return 1 + fw::apply_preset "$preset" "${ip}" || return 1 done - # Apply guest rules if guest type - if [[ "$type" == "guest" ]]; then - firewall::apply_guest_rules + # Apply rule + if [[ -n "$rule" ]]; then + rule::apply "$rule" "$ip" || return 1 fi # Reload WireGuard @@ -199,13 +239,12 @@ function cmd::add::run() { log::wg_success "Client added successfully: ${full_name} (${ip}) [${tunnel} tunnel]" # Show QR for mobile by default, config for desktop/laptop - # --show-config overrides to always show config - if $show_qr; then + local display_type="${subtype:-$type}" + + if cmd::add::is_mobile "$display_type"; then keys::qr "$full_name" - elif $show_config || ! cmd::add::is_mobile "$type"; then + else log::section "Client Config" cat "$(ctx::clients)/${full_name}.conf" - else - keys::qr "$full_name" fi } \ No newline at end of file diff --git a/commands/audit.command.sh b/commands/audit.command.sh new file mode 100644 index 0000000..32b91ef --- /dev/null +++ b/commands/audit.command.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash + +function cmd::audit::on_load() { + flag::register --fix + flag::register --peer + flag::register --type +} + +function cmd::audit::help() { + cat < Audit specific peer only + --type Audit peers of specific type only + +Examples: + wgctl audit + wgctl audit --peer phone-nuno + wgctl audit --fix +EOF +} + +function cmd::audit::run() { + local fix=false peer="" type="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --fix) fix=true; shift ;; + --peer) + if [[ -z "${2:-}" ]]; then + log::error "Flag --peer requires a value" + cmd::audit::help + return 1 + fi + peer="$2" + shift 2 + ;; + --type) type="$2"; shift 2 ;; + --help) cmd::audit::help; return ;; + *) + log::error "Unknown flag: $1" + return 1 + ;; + esac + done + + test::reset + log::section "WireGuard Audit" + + # Precompute iptables counts via single Python call + declare -A peer_fw_counts + while IFS=":" read -r peer_name count; do + [[ -n "$peer_name" ]] && peer_fw_counts["$peer_name"]="$count" + done < <(json::audit_fw_counts "$(ctx::clients)") + + test::section "Peer Rules" + while IFS= read -r peer_name; do + [[ -n "$peer" && "$peer_name" != "$peer" ]] && continue + [[ -n "$type" && "$peer_name" != "${type}-"* ]] && continue + cmd::audit::check_peer "$peer_name" "$fix" "${peer_fw_counts[$peer_name]:-0}" + done < <(peers::all) + + test::section "WireGuard Server" + cmd::audit::check_wg "$fix" "$peer" "$type" + + test::section "Rules" + cmd::audit::check_rules + + test::summary +} + +function cmd::audit::check_peer() { + local peer_name="$1" + local fix="$2" + local actual="${3:-0}" + local ip rule + + ip=$(peers::get_ip "$peer_name") + rule=$(peers::get_meta "$peer_name" "rule") + + # Check rule assigned + if [[ -z "$rule" ]]; then + test::warn "$peer_name — no rule assigned (effective: $(peers::default_rule "$peer_name"))" + return + fi + + # Check rule exists + if ! rule::exists "$rule"; then + test::fail "$peer_name — assigned rule '$rule' does not exist" + return + fi + + # Count expected iptables rules from rule file + local rule_file + rule_file="$(ctx::rule::path "${rule}.rule")" + local block_ports block_ips allow_ports allow_ips expected + block_ports=$(json::count "$rule_file" "block_ports") + block_ips=$(json::count "$rule_file" "block_ips") + allow_ports=$(json::count "$rule_file" "allow_ports") + allow_ips=$(json::count "$rule_file" "allow_ips") + expected=$(( (block_ports + block_ips) * 2 + allow_ports + allow_ips )) + + # actual is passed in as $3 — no iptables call here + if [[ "$actual" -eq "$expected" ]]; then + test::pass "$(printf "%-28s rule=%-15s fw: %s/%s" "$peer_name" "$rule" "$actual" "$expected")" + elif [[ "$actual" -gt "$expected" ]]; then + test::warn "$(printf "%-28s rule=%-15s fw: %s/%s (extra rules)" "$peer_name" "$rule" "$actual" "$expected")" + if $fix; then + fw::flush_peer "$ip" + rule::apply "$rule" "$ip" "$peer_name" + test::pass " Fixed: $peer_name" + fi + else + test::fail "$(printf "%-28s rule=%-15s fw: %s/%s (missing rules)" "$peer_name" "$rule" "$actual" "$expected")" + if $fix; then + rule::apply "$rule" "$ip" "$peer_name" + test::pass " Fixed: $peer_name" + fi + fi +} + +function cmd::audit::check_wg() { + local fix="$1" filter_peer="$2" filter_type="$3" + local wg_peers + wg_peers=$(wg show wg0 peers 2>/dev/null) + + while IFS= read -r peer_name; do + [[ -n "$filter_peer" && "$peer_name" != "$filter_peer" ]] && continue + [[ -n "$filter_type" && "$peer_name" != "${filter_type}-"* ]] && continue + + local pub_key + pub_key=$(keys::public "$peer_name" 2>/dev/null) + [[ -z "$pub_key" ]] && test::fail "$peer_name — missing public key" && continue + + if peers::is_blocked "$peer_name"; then + if echo "$wg_peers" | grep -q "$pub_key"; then + test::fail "$peer_name — blocked but still in wg0" + $fix && peers::remove_from_server "$peer_name" && peers::reload + else + test::pass "$peer_name — correctly blocked (not in wg0)" + fi + else + if echo "$wg_peers" | grep -q "$pub_key"; then + test::pass "$peer_name — present in wg0" + else + test::fail "$peer_name — missing from wg0" + if $fix; then + local ip + ip=$(peers::get_ip "$peer_name") + peers::add_to_server "$peer_name" "$pub_key" "$ip" + peers::reload + fi + fi + fi + done < <(peers::all) +} + +function cmd::audit::check_rules() { + local rules_dir + rules_dir="$(ctx::rules)" + + # Precompute peer counts + declare -A rule_counts + while IFS= read -r peer_name; do + local r + r=$(peers::get_meta "$peer_name" "rule") + [[ -z "$r" ]] && continue + rule_counts["$r"]=$(( ${rule_counts["$r"]:-0} + 1 )) + done < <(peers::all) + + for rule_file in "${rules_dir}"/*.rule; do + [[ -f "$rule_file" ]] || continue + local name + name=$(json::get "$rule_file" "name") + local count=${rule_counts["$name"]:-0} + test::pass "Rule '${name}' — ${count} peers assigned" + done +} \ No newline at end of file diff --git a/commands/block.command.sh b/commands/block.command.sh index 74da3ca..bee8cae 100644 --- a/commands/block.command.sh +++ b/commands/block.command.sh @@ -7,6 +7,8 @@ function cmd::block::on_load() { flag::register --name flag::register --type + flag::register --force + flag::register --quiet flag::register --ip flag::register --port flag::register --proto @@ -66,12 +68,15 @@ function cmd::block::run() { local ips=() local subnets=() local ports=() + local quiet=false while [[ $# -gt 0 ]]; do case "$1" in --name) name="$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 ;; --help) cmd::block::help; return ;; @@ -114,17 +119,17 @@ function cmd::block::run() { local client_ip client_ip=$(cmd::block::get_client_ip "$name") || return 1 - log::section "Blocking client: ${name} (${client_ip})" + # $quiet || log::section "Blocking client: ${name} (${client_ip})" # No specific target — block everything if [[ ${#ips[@]} -eq 0 && ${#subnets[@]} -eq 0 && ${#ports[@]} -eq 0 ]]; then - # Get real endpoint IP before removing peer from server + # Get real endpoint IP before removing peer from server local public_key endpoint public_key=$(keys::public "$name") || return 1 endpoint=$(monitor::endpoint_for_key "$public_key") - firewall::block_all "$client_ip" "$name" - firewall::save_block "$name" "$client_ip" + fw::block_all "$client_ip" "$name" + fw::save_block "$name" "$client_ip" # Watch real endpoint IP if available if [[ -n "$endpoint" ]]; then @@ -136,22 +141,22 @@ function cmd::block::run() { peers::remove_from_server "$name" peers::reload - log::wg_success "Client blocked: ${name}" + $quiet || log::wg_success "${name} has been blocked." return 0 fi # Block specific IPs for ip in "${ips[@]}"; do ip::require_valid "$ip" - firewall::block_ip "$client_ip" "$ip" - firewall::save_block "$name" "$client_ip" "$ip" + fw::block_ip "$client_ip" "$ip" + fw::save_block "$name" "$client_ip" "$ip" done # Block specific subnets for subnet in "${subnets[@]}"; do ip::require_valid "$subnet" - firewall::block_subnet "$client_ip" "$subnet" - firewall::save_block "$name" "$client_ip" "$subnet" + fw::block_subnet "$client_ip" "$subnet" + fw::save_block "$name" "$client_ip" "$subnet" done # Block specific ports @@ -160,9 +165,9 @@ function cmd::block::run() { IFS=":" read -r target port proto <<< "$entry" proto="${proto:-tcp}" ip::require_valid "$target" - firewall::block_port "$client_ip" "$target" "$port" "$proto" - firewall::save_block "$name" "$client_ip" "$target" "$port" "$proto" + fw::block_port "$client_ip" "$target" "$port" "$proto" + fw::save_block "$name" "$client_ip" "$target" "$port" "$proto" done - log::wg_success "Block rules applied for: ${name}" + log::debug "Block rules applied for: ${name}" } diff --git a/commands/fw.command.sh b/commands/fw.command.sh new file mode 100644 index 0000000..7ef4d87 --- /dev/null +++ b/commands/fw.command.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash + +# ============================================ +# Lifecycle +# ============================================ + +function cmd::fw::on_load() { + flag::register --peer + flag::register --type + flag::register --no-nflog + flag::register --no-accept + flag::register --no-drop +} + +function cmd::fw::run() { + local subcmd="${1:-list}" + + # If first arg is a flag, default to list + if [[ "$subcmd" == --* ]]; then + subcmd="list" + else + shift || true + fi + + case "$subcmd" in + list) cmd::fw::list "$@" ;; + nat) cmd::fw::nat "$@" ;; + flush-nat) cmd::fw::flush_nat "$@" ;; + clean) cmd::fw::clean ;; + count) cmd::fw::count ;; + help) cmd::fw::help ;; + *) log::error "Unknown subcommand: $subcmd"; return 1 ;; + esac +} + +function cmd::fw::list() { + local peer="" type="" + local show_nflog=true show_accept=true show_drop=true + + while [[ $# -gt 0 ]]; do + case "$1" in + --peer) peer="$2"; shift 2 ;; + --type) type="$2"; shift 2 ;; + --no-nflog) show_nflog=false; shift ;; + --no-accept) show_accept=false; shift ;; + --no-drop) show_drop=false; shift ;; + *) shift ;; + esac + done + + log::section "Firewall Rules (FORWARD)" + printf "\n" + + if [[ -n "$peer" ]]; then + local ip + ip=$(peers::get_ip "$peer") + [[ -z "$ip" ]] && log::error "Peer not found: $peer" && return 1 + iptables -L FORWARD -n -v | grep -F "$ip" \ + | cmd::fw::_print_filtered "$show_nflog" "$show_accept" "$show_drop" + elif [[ -n "$type" ]]; then + local subnet + subnet=$(config::subnet_for "$type") + [[ -z "$subnet" ]] && log::error "Unknown type: $type" && return 1 + iptables -L FORWARD -n -v | grep -F "$subnet" \ + | cmd::fw::_print_filtered "$show_nflog" "$show_accept" "$show_drop" + else + iptables -L FORWARD -n -v \ + | grep -v "^Chain\|^target\|^$\|ACCEPT.*0\.0\.0\.0.*0\.0\.0\.0" \ + | cmd::fw::_print_filtered "$show_nflog" "$show_accept" "$show_drop" + fi + + printf "\n" +} + +function cmd::fw::nat() { + log::section "NAT Rules (PREROUTING)" + printf "\n" + iptables -t nat -L PREROUTING -n -v | while IFS= read -r rule; do + [[ -z "$rule" ]] && continue + ui::firewall_rule "$rule" + done + printf "\n" +} + +function cmd::fw::flush_nat() { + local type="" + while [[ $# -gt 0 ]]; do + case "$1" in + --type) type="$2"; shift 2 ;; + *) shift ;; + esac + done + + if [[ -z "$type" ]]; then + log::error "Missing required flag: --type" + return 1 + fi + + local subnet + subnet=$(config::subnet_for "$type") + if [[ -z "$subnet" ]]; then + log::error "Unknown type: $type" + return 1 + fi + + local nat_linenums=() + while IFS= read -r linenum; do + [[ -n "$linenum" ]] && nat_linenums+=("$linenum") + done < <(iptables -t nat -L PREROUTING -n --line-numbers | grep -F "${subnet}" | awk '{print $1}') + + local count=0 + for (( i=${#nat_linenums[@]}-1; i>=0; i-- )); do + iptables -t nat -D PREROUTING "${nat_linenums[$i]}" 2>/dev/null || true + (( count++ )) || true + done + + log::wg_success "Flushed ${count} NAT rules for type '${type}' (${subnet}.0/24)" +} + +function cmd::fw::stats() { + log::section "Firewall Stats" + iptables -L FORWARD -n -v | grep -v "^Chain\|^target\|^$" | \ + awk 'NR>1 {printf " %8s pkts %8s bytes %s\n", $1, $2, $0}' +} + +function cmd::fw::count() { + log::section "Firewall Rule Counts" + local drop accept nflog total + drop=$(iptables -L FORWARD -n | grep -c "^DROP" || echo 0) + accept=$(iptables -L FORWARD -n | grep -c "^ACCEPT" || echo 0) + nflog=$(iptables -L FORWARD -n | grep -c "^NFLOG" || echo 0) + total=$(( drop + accept + nflog )) + printf "\n %-10s %s\n" "DROP:" "$drop" + printf " %-10s %s\n" "ACCEPT:" "$accept" + printf " %-10s %s\n" "NFLOG:" "$nflog" + printf " %-10s %s\n" "TOTAL:" "$total" + printf "\n" +} + +function cmd::fw::clean() { + log::section "Duplicate Rule Report" + iptables-save | sort | uniq -d | grep "^-A FORWARD" +} + +function cmd::fw::_print_filtered() { + local show_nflog="$1" show_accept="$2" show_drop="$3" + while IFS= read -r rule; do + [[ -z "$rule" ]] && continue + ! $show_nflog && [[ "$rule" =~ NFLOG ]] && continue + ! $show_accept && [[ "$rule" =~ ACCEPT ]] && continue + ! $show_drop && [[ "$rule" =~ DROP ]] && continue + ui::firewall_rule "$rule" + done +} \ No newline at end of file diff --git a/commands/group.command.sh b/commands/group.command.sh new file mode 100644 index 0000000..395c75f --- /dev/null +++ b/commands/group.command.sh @@ -0,0 +1,761 @@ +#!/usr/bin/env bash + +# ============================================ +# Lifecycle +# ============================================ + +function cmd::group::on_load() { + flag::register --name + flag::register --desc + flag::register --peer + flag::register --type + flag::register --rule + flag::register --new-name + flag::register --force +} + +# ============================================ +# Help +# ============================================ + +function cmd::group::help() { + cat < [options] + +Manage peer groups. + +Subcommands: + list, ls List all groups + show Show group details and members + add, new, create Create a new group + remove, rm, del Remove a group definition + rename Rename a group + peer add Add a peer to a group + peer remove, peer rm Remove a peer from a group + rm-peers Remove all peers from WireGuard server + block Block all peers in group + unblock Unblock all peers in group + rule assign Assign a rule to all peers in group + audit Audit all peers in group + logs Show logs for all peers in group + watch Live monitor for all peers in group + +Options: + --name Group name + --desc Group description + --peer Peer name + --type Peer device type (for peer resolution) + --rule Rule name (for rule assign) + --new-name New name (for rename) + --force Skip confirmation prompts + +Examples: + wgctl group add --name family --desc "Family devices" + wgctl group peer add --name family --peer phone-nuno + wgctl group block --name family + wgctl group rule assign --name family --rule user + wgctl group audit --name family +EOF +} + +# ============================================ +# Run +# ============================================ + +function cmd::group::run() { + local subcmd="${1:-help}" + shift || true + + case "$subcmd" in + list|ls) cmd::group::list "$@" ;; + show) cmd::group::show "$@" ;; + add|new|create) cmd::group::add "$@" ;; + remove|rm|del|delete) cmd::group::remove "$@" ;; + rename) cmd::group::rename "$@" ;; + peer) cmd::group::peer "$@" ;; + rm-peers) cmd::group::rm_peers "$@" ;; + block) cmd::group::block "$@" ;; + unblock) cmd::group::unblock "$@" ;; + rule) cmd::group::rule "$@" ;; + audit) cmd::group::audit "$@" ;; + logs) cmd::group::logs "$@" ;; + watch) cmd::group::watch "$@" ;; + help) cmd::group::help ;; + *) + log::error "Unknown subcommand: '${subcmd}'" + cmd::group::help + return 1 + ;; + esac +} + +# ============================================ +# List +# ============================================ + +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 + + log::section "Groups" + printf "\n %-20s %-35s %-8s %s\n" "NAME" "DESCRIPTION" "PEERS" "STATUS" + printf " %s\n" "$(printf '─%.0s' {1..75})" + + 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}..." + + printf " %-20s %-35s %-8s %b\n" \ + "$name" "${short_desc:-—}" "$total" \ + "${status_color}${status_str}\033[0m" + done < <(json::group_list_data "$groups_dir" "$(ctx::blocks)") + + printf "\n" +} + +# 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 + +# log::section "Groups" + +# printf "\n %-20s %-35s %-8s %s\n" "NAME" "DESCRIPTION" "PEERS" "STATUS" +# printf " %s\n" "$(printf '─%.0s' {1..75})" + +# for group_file in "${groups_dir}"/*.group; do +# [[ -f "$group_file" ]] || continue + +# local name desc +# name=$(json::get "$group_file" "name") +# desc=$(json::get "$group_file" "desc") + +# # Count peers +# local peers_list=() +# mapfile -t peers_list < <(json::get "$group_file" "peers") +# # Filter empty entries +# local filtered=() +# for p in "${peers_list[@]:-}"; do +# [[ -n "$p" ]] && filtered+=("$p") +# done +# peers_list=("${filtered[@]:-}") +# local peer_count=${#peers_list[@]} + +# [[ -z "${peers_list[0]}" ]] && peer_count=0 + +# # Check block status +# local blocked=0 total=0 +# for peer_name in "${peers_list[@]}"; do +# [[ -z "$peer_name" ]] && continue +# (( total++ )) || true +# peers::is_blocked "$peer_name" && (( blocked++ )) || true +# done + +# local status_color="" +# local 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})" +# # status_color="\033[1;33m" +# # status_str="${blocked}/${total} blocked" +# else +# status_color="\033[1;32m" +# status_str="active" +# fi +# fi + +# local short_desc="${desc:0:33}" +# [[ ${#desc} -gt 33 ]] && short_desc="${short_desc}..." + +# printf " %-20s %-35s %-8s %b\n" \ +# "$name" \ +# "${short_desc:-—}" \ +# "$peer_count" \ +# "${status_color}${status_str}\033[0m" +# done + +# printf "\n" +# } + +# ============================================ +# Show +# ============================================ + +function cmd::group::show() { + local name="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;; + --help) cmd::group::help; return ;; + *) log::error "Unknown flag: $1"; return 1 ;; + esac + done + + [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1 + group::require_exists "$name" || return 1 + + local group_file + group_file="$(group::path "$name")" + + log::section "Group: ${name}" + + local desc + desc=$(json::get "$group_file" "desc") + printf "\n %-20s %s\n" "Description:" "${desc:-—}" + + # Load peers + local peers_list=() + mapfile -t peers_list < <(json::get "$group_file" "peers") + # Filter empty entries + local filtered=() + for p in "${peers_list[@]:-}"; do + [[ -n "$p" ]] && filtered+=("$p") + done + peers_list=("${filtered[@]:-}") + local peer_count=${#peers_list[@]} + + [[ -z "${peers_list[0]}" ]] && peer_count=0 + + printf " %-20s %s\n" "Peers:" "$peer_count" + printf " %s\n" "$(printf '─%.0s' {1..50})" + + if [[ "$peer_count" -gt 0 ]]; then + printf "\n %-28s %-15s %-12s %s\n" "NAME" "IP" "RULE" "STATUS" + printf " %s\n" "$(printf '─%.0s' {1..65})" + for peer_name in "${peers_list[@]}"; do + [[ -z "$peer_name" ]] && continue + 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"; 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" "${ip:-—}" "$rule" \ + "${status_color}${status_str}\033[0m" + done + else + printf " —\n" + fi + + printf "\n" +} + +# ============================================ +# Add +# ============================================ + +function cmd::group::add() { + local name="" desc="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;; + --desc) util::require_flag "--desc" "${2:-}" || return 1; desc="$2"; shift 2 ;; + --help) cmd::group::help; return ;; + *) log::error "Unknown flag: $1"; return 1 ;; + esac + done + + [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1 + + if group::exists "$name"; then + log::error "Group already exists: ${name}" + return 1 + fi + + local group_file + group_file="$(group::path "$name")" + + python3 -c " +import json +group = {'name': '${name}', 'desc': '${desc}', 'peers': []} +with open('${group_file}', 'w') as f: + json.dump(group, f, indent=2) +" Client name --type Device type (optional, combines with --name) + --config Show raw client config + --qr Show QR code Examples: wgctl inspect --name phone-nuno wgctl inspect --name nuno --type phone + wgctl inspect --name phone-nuno --config + wgctl inspect --name phone-nuno --qr EOF } -function cmd::inspect::run() { - local name="" - local type="" +# ============================================ +# Private helpers +# ============================================ + +function cmd::inspect::_section() { + local title="$1" + printf "\n \033[0;37m── %s ──────────────────────────────────\033[0m\n" "$title" +} + +function cmd::inspect::_row() { + local label="$1" value="$2" + printf " %-20s %s\n" "${label}:" "$value" +} + +function cmd::inspect::_peer_info() { + local name="$1" + local ip type rule status endpoint last_seen tunnel public_key + + ip=$(peers::get_ip "$name") + type=$(peers::get_type "$name") + rule=$(peers::get_meta "$name" "rule") + public_key=$(keys::public "$name" 2>/dev/null) + + # Status + endpoint + last seen — reuse list helpers + status=$(cmd::list::format_status "$name" "$public_key" "$ip") + last_seen=$(cmd::list::format_last_seen "$name" "$public_key" "$ip") + endpoint=$(monitor::get_cached_endpoint "$name") + + # Tunnel mode from AllowedIPs in conf + local allowed_ips + allowed_ips=$(grep "^AllowedIPs" "$(ctx::clients)/${name}.conf" 2>/dev/null | cut -d'=' -f2- | xargs) + + cmd::inspect::_section "Client" + cmd::inspect::_row "Name" "$name" + cmd::inspect::_row "IP" "$ip" + cmd::inspect::_row "Type" "$(cmd::list::display_type "$name" "$type")" + cmd::inspect::_row "Rule" "${rule:-—}" + cmd::inspect::_row "Status" "$(echo -e "$status")" + cmd::inspect::_row "Endpoint" "${endpoint:-—}" + cmd::inspect::_row "Last seen" "$last_seen" + cmd::inspect::_row "AllowedIPs" "$allowed_ips" + cmd::inspect::_row "Public key" "${public_key:-—}" +} + +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 + + local rule_file + rule_file="$(ctx::rule::path "${rule}.rule")" + + ui::section "Rule: ${rule}" + + local desc dns_redirect + desc=$(json::get "$rule_file" "desc") + dns_redirect=$(json::get "$rule_file" "dns_redirect") + ui::row "Description" "${desc:-—}" + ui::row "DNS Redirect" "${dns_redirect:-false}" + + local allow_ports allow_ips block_ips block_ports + allow_ports=$(json::get "$rule_file" "allow_ports") + allow_ips=$(json::get "$rule_file" "allow_ips") + block_ips=$(json::get "$rule_file" "block_ips") + block_ports=$(json::get "$rule_file" "block_ports") + + if [[ -n "$allow_ports" || -n "$allow_ips" ]]; then + printf " %-20s\n" "Allows:" + ui::print_list "+" "$allow_ports" + ui::print_list "+" "$allow_ips" + else + ui::row "Allows" "—" + fi + + if [[ -n "$block_ips" || -n "$block_ports" ]]; then + printf " %-20s\n" "Blocks:" + ui::print_list "-" "$block_ips" + ui::print_list "-" "$block_ports" + else + ui::row "Blocks" "— (full access)" + fi +} + +function cmd::inspect::_group_info() { + local name="$1" + + ui::section "Groups" + + local groups=() + mapfile -t groups < <(json::peer_groups "$(ctx::groups)" "$name") + + if [[ ${#groups[@]} -eq 0 ]] || [[ -z "${groups[0]:-}" ]]; then + printf " —\n" + return 0 + fi + + for g in "${groups[@]}"; do + [[ -z "$g" ]] && continue + local count + count=$(json::count "$(group::path "$g")" "peers") + printf " %-20s %s peers\n" "$g" "$count" + done +} + +function cmd::inspect::_firewall_info() { + local name="$1" show_nflog="${2:-false}" + local ip + ip=$(peers::get_ip "$name") + + ui::section "Firewall" + + local count=0 + while IFS=":" read -r pname pcount; do + [[ "$pname" == "$name" ]] && count="$pcount" && break + done < <(json::audit_fw_counts "$(ctx::clients)") + + ui::row "Active rules" "$count" + +if [[ "$count" -gt 0 ]]; then + printf "\n" + iptables -L FORWARD -n /dev/null \ - | grep "^${public_key}" \ - | awk '{print $2}' +function cmd::list::_precompute_wg() { + # Returns two associative arrays via nameref + local -n _handshakes="$1" + local -n _endpoints="$2" + + while IFS=$'\t' read -r pubkey ts; do + [[ -n "$pubkey" ]] && _handshakes["$pubkey"]="$ts" + done < <(wg show "$(config::interface)" latest-handshakes 2>/dev/null) + + while IFS=$'\t' read -r pubkey endpoint; do + [[ -n "$pubkey" ]] && _endpoints["$pubkey"]="$endpoint" + done < <(wg show "$(config::interface)" endpoints 2>/dev/null) } -function cmd::list::last_dropped_ts() { - local client_ip="$1" - journalctl -k --grep "wgctl-dropped: " 2>/dev/null \ - | grep "SRC=${client_ip}" \ - | tail -1 \ - | awk '{print $1, $2, $3}' +function cmd::list::_precompute_block_status() { + local -n _blocked="$1" + local -n _restricted="$2" + + # Blocked = not in wg server config + local wg_peers + wg_peers=$(wg show "$(config::interface)" peers 2>/dev/null) + + while IFS= read -r name; do + # Check block file + [[ -f "$(ctx::block::path "${name}.block")" ]] && _restricted["$name"]=true || _restricted["$name"]=false + + # Check if in server config + local pubkey + pubkey=$(keys::public "$name" 2>/dev/null || echo "") + if [[ -n "$pubkey" ]] && ! echo "$wg_peers" | grep -qF "$pubkey"; then + _blocked["$name"]=true + else + _blocked["$name"]=false + fi + done < <(peers::all) } -function cmd::list::is_connected() { - local public_key="$1" - local ts - ts=$(cmd::list::last_handshake_ts "$public_key") +# ============================================ +# Status / Display helpers +# ============================================ + +function cmd::list::_is_connected() { + local ts="$1" [[ -z "$ts" || "$ts" == "0" ]] && return 1 local now diff now=$(date +%s) @@ -77,138 +99,179 @@ function cmd::list::is_connected() { (( diff < 180 )) } -function cmd::list::is_attempting() { - local name="$1" - local ts - ts=$(monitor::last_attempt "$name") - [[ -z "$ts" ]] && return 1 - +function cmd::list::_is_attempting() { + local last_ts="$1" + [[ -z "$last_ts" ]] && return 1 local now attempt_ts diff now=$(date +%s) - attempt_ts=$(python3 -c " -from datetime import datetime, timezone -dt = datetime.fromisoformat('${ts}') -if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) -print(int(dt.timestamp())) -" 2>/dev/null || echo 0) - + attempt_ts=$(json::iso_to_ts "$last_ts") diff=$(( now - attempt_ts )) (( diff < 180 )) } -function cmd::list::is_blocked() { - local name="$1" - peers::is_blocked "$name" -} +function cmd::list::_format_last_seen() { + local name="$1" pubkey="$2" is_blocked="$3" + local last_ts="$4" last_evt="$5" handshake_ts="$6" -function cmd::list::is_restricted() { - local name="$1" - [[ -f "$(ctx::block::path "${name}.block")" ]] -} - -function cmd::list::format_last_seen() { - local name="$1" - local public_key="$2" - local ip="$3" - - if cmd::list::is_blocked "$name"; then - local ts - ts=$(monitor::last_attempt "$name") - if [[ -n "$ts" ]]; then - # Format ISO timestamp + if [[ "$is_blocked" == "true" ]]; then + if [[ -n "$last_ts" ]]; then local formatted - formatted=$(python3 -c " -from datetime import datetime, timezone -dt = datetime.fromisoformat('${ts}') -print(dt.strftime('%Y-%m-%d %H:%M')) -" 2>/dev/null || echo "$ts") + formatted=$(fmt::datetime "$last_ts") echo "${formatted} (dropped)" else echo "—" fi else - local ts - ts=$(cmd::list::last_handshake_ts "$public_key") - if [[ -z "$ts" || "$ts" == "0" ]]; then + if [[ -z "$handshake_ts" || "$handshake_ts" == "0" ]]; then echo "—" else local formatted - formatted=$(date -d "@${ts}" "+%Y-%m-%d %H:%M" 2>/dev/null || echo "$ts") + formatted=$(fmt::datetime "$handshake_ts") echo "${formatted} (handshake)" fi fi } -function cmd::list::format_status() { - local name="$1" - local public_key="$2" - local ip="$3" # new +function cmd::list::_format_status() { + local name="$1" pubkey="$2" + local is_blocked="$3" is_restricted="$4" + local handshake_ts="$5" last_ts="$6" - local connected=false - local blocked=false - local restricted=false + local connected=false modifier="" color - cmd::list::is_blocked "$name" && blocked=true - cmd::list::is_restricted "$name" && restricted=true - - if $blocked; then - cmd::list::is_attempting "$name" && connected=true + if [[ "$is_blocked" == "true" ]]; then + cmd::list::_is_attempting "$last_ts" && connected=true modifier=" (blocked)" - elif $restricted; then - cmd::list::is_connected "$public_key" && connected=true + color="\033[1;31m" + elif [[ "$is_restricted" == "true" ]]; then + cmd::list::_is_connected "$handshake_ts" && connected=true modifier=" (restricted)" + color="\033[1;33m" else - cmd::list::is_connected "$public_key" && connected=true + cmd::list::_is_connected "$handshake_ts" && connected=true modifier="" + if $connected; then + color="\033[1;32m" + else + color="\033[0;37m" + fi fi local conn_str $connected && conn_str="online" || conn_str="offline" + echo -e "${color}${conn_str}${modifier}\033[0m" +} - local status="${conn_str}${modifier}" +function cmd::list::_get_type() { + local ip="$1" + local type="unknown" + for t in $(config::device_types); do + local subnet + subnet=$(config::subnet_for "$t") + if string::starts_with "$ip" "${subnet}."; then + type="$t" + break + fi + done + echo "$type" +} - local color - if $blocked; then - color="\033[1;31m" - elif $restricted; then - color="\033[1;33m" - elif $connected; then - color="\033[1;32m" +function cmd::list::display_type() { + local name="$1" type="$2" subtype="$3" + if config::is_guest_type "$type" && [[ -n "$subtype" ]]; then + echo "guest/${subtype}" + elif config::is_guest_type "$type"; then + echo "guest" else - color="\033[0;37m" + echo "$type" fi +} - echo -e "${color}${status}\033[0m" +function cmd::list::pad_status() { + local status="$1" + local width="${2:-25}" + local visible + visible=$(echo -e "$status" | sed 's/\x1b\[[0-9;]*m//g') + local pad=$(( width - ${#visible} )) + printf "%b%${pad}s" "$status" "" } # ============================================ -# Detail Card +# Header / Footer +# ============================================ + +function cmd::list::_render_header() { + local has_groups="$1" + if $has_groups; then + printf "\n %-28s %-15s %-13s %-12s %-12s %-22s %s\n" \ + "NAME" "IP" "TYPE" "RULE" "GROUP" "STATUS" "LAST SEEN" + printf " %s\n" "$(printf '─%.0s' {1..135})" + else + printf "\n %-28s %-15s %-13s %-12s %-22s %s\n" \ + "NAME" "IP" "TYPE" "RULE" "STATUS" "LAST SEEN" + printf " %s\n" "$(printf '─%.0s' {1..107})" + fi +} + +function cmd::list::_render_footer() { + local has_groups="$1" + if $has_groups; then + printf " %s\n" "$(printf '─%.0s' {1..135})" + else + printf " %s\n" "$(printf '─%.0s' {1..107})" + fi +} + +function cmd::list::_render_summary() { + local group_summary="${1:-}" + local -n _rule_counts="$2" + local total="${3:-}" # filtered total + + # total=$(find "$(ctx::clients)" -name "*.conf" | wc -l | tr -d ' ') + + # Count total from rule_counts (only filtered peers) + local total=0 + for r in "${!_rule_counts[@]}"; do + (( total += _rule_counts[$r] )) || true + done + + local summary="" + for r in "${!_rule_counts[@]}"; do + summary+="${_rule_counts[$r]} ${r}, " + done + summary="${summary%, }" + + if [[ -n "$group_summary" ]]; then + printf "\n Showing %s peers [%s] — %s\n\n" "$total" "$summary" "$group_summary" + else + printf "\n Showing %s peers [%s]\n\n" "$total" "$summary" + fi +} + +# ============================================ +# Detail Card (show_client) # ============================================ function cmd::list::show_client() { local name="$1" - local dir - dir="$(ctx::clients)" - local conf="${dir}/${name}.conf" + local conf + conf="$(ctx::clients)/${name}.conf" if [[ ! -f "$conf" ]]; then log::error "Client not found: ${name}" return 1 fi - local ip + local ip allowed_ips public_key ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1) - - local allowed_ips allowed_ips=$(grep "^AllowedIPs" "$conf" | awk '{print $3}') - - local public_key public_key=$(keys::public "$name" 2>/dev/null || echo "unknown") - # Get endpoint + local type + type=$(cmd::list::_get_type "$ip") + local endpoint="—" - if cmd::list::is_blocked "$name"; then + if peers::is_blocked "$name"; then local ep ep=$(monitor::last_endpoint "$name") [[ -n "$ep" ]] && endpoint="$ep" @@ -218,29 +281,28 @@ function cmd::list::show_client() { [[ -n "$ep" ]] && endpoint="$ep" fi - # Determine type - local type="unknown" - for t in $(config::device_types); do - local subnet - subnet=$(config::subnet_for "$t") - if string::starts_with "$ip" "$subnet"; then - type="$t" - break - fi - done + # Get handshake and last attempt for status/last_seen + local handshake_ts + handshake_ts=$(wg show "$(config::interface)" latest-handshakes 2>/dev/null \ + | grep "^${public_key}" | awk '{print $2}') + handshake_ts="${handshake_ts:-0}" - local status - status=$(cmd::list::format_status "$name" "$public_key" "$ip") + local last_ts + last_ts=$(monitor::last_attempt "$name") - local last_seen - last_seen=$(cmd::list::format_last_seen "$name" "$public_key" "$ip") + local is_blocked="false" + peers::is_blocked "$name" && is_blocked="true" + + local status last_seen + status=$(cmd::list::_format_status "$name" "$public_key" \ + "$is_blocked" "false" "$handshake_ts" "$last_ts") + last_seen=$(cmd::list::_format_last_seen "$name" "$public_key" \ + "$is_blocked" "$last_ts" "" "$handshake_ts") - # Block rules local block_file block_file="$(ctx::block::path "${name}.block")" local blocks="" - -if [[ -f "$block_file" ]] && [[ -s "$block_file" ]]; then + if [[ -f "$block_file" ]] && [[ -s "$block_file" ]]; then while IFS=" " read -r client_ip target port proto; do if [[ -z "$target" ]]; then blocks+=" all traffic blocked\n" @@ -252,47 +314,43 @@ if [[ -f "$block_file" ]] && [[ -s "$block_file" ]]; then done < "$block_file" fi - local sep - sep="$(printf '─%.0s' {1..50})" - - echo "" - echo " ${sep}" - printf " \033[1;34m%-20s\033[0m %s\n" "Client:" "$name" - echo " ${sep}" - printf " %-20s %s\n" "IP:" "$ip" - printf " %-20s %s\n" "Type:" "$type" - printf " %-20s %b\n" "Status:" "$status" - printf " %-20s %s\n" "Endpoint:" "$endpoint" - printf " %-20s %s\n" "Last seen:" "$last_seen" - printf " %-20s %s\n" "Allowed IPs:" "$allowed_ips" - printf " %-20s %s\n" "Public key:" "$public_key" + ui::section "Client: ${name}" + ui::row "IP" "$ip" + ui::row "Type" "$type" + ui::row "Status" "$(echo -e "$status")" + ui::row "Endpoint" "$endpoint" + ui::row "Last seen" "$last_seen" + ui::row "AllowedIPs" "$allowed_ips" + ui::row "Public key" "$public_key" if [[ -z "$blocks" ]]; then - printf " %-20s %s\n" "Blocks:" "none" + ui::row "Blocks" "none" elif [[ "$blocks" == *"all traffic blocked"* ]]; then - printf " %-20s \033[1;31mAll\033[0m\n" "Blocks:" + ui::row "Blocks" "$(echo -e "\033[1;31mAll\033[0m")" else printf " %-20s\n" "Blocks:" echo -e "$blocks" fi - - echo " ${sep}" - echo "" + printf "\n" } +# Keep old format_status/format_last_seen for backward compat +function cmd::list::format_status() { cmd::list::_format_status "$@"; } +function cmd::list::format_last_seen() { cmd::list::_format_last_seen "$@"; } +function cmd::list::is_blocked() { peers::is_blocked "$1"; } +function cmd::list::is_restricted() { [[ -f "$(ctx::block::path "${1}.block")" ]]; } +function cmd::list::is_connected() { cmd::list::_is_connected "$@"; } +function cmd::list::is_attempting() { cmd::list::_is_attempting "$@"; } + # ============================================ # Run # ============================================ function cmd::list::run() { local filter_type="" - local online_only=false - local offline_only=false - local restricted_only=false - local blocked_only=false - local allowed_only=false - local detailed=false - local single_name="" + local online_only=false offline_only=false + local restricted_only=false blocked_only=false allowed_only=false + local detailed=false single_name="" while [[ $# -gt 0 ]]; do case "$1" in @@ -313,7 +371,7 @@ function cmd::list::run() { esac done - # Single client detail card + # Single detail card if [[ -n "$single_name" ]]; then cmd::list::show_client "$single_name" return @@ -322,106 +380,163 @@ function cmd::list::run() { local dir dir="$(ctx::clients)" local confs=("${dir}"/*.conf) - if [[ ! -f "${confs[0]}" ]]; then log::wg_list "No clients configured" return 0 fi - # Detailed mode — cards only, no table + # ── Precompute everything ────────────────── + + # Peer data (ip, rule, subtype, last_ts, last_evt) — single Python call + declare -A p_ips p_rules p_subtypes p_last_ts p_last_evt + while IFS="|" read -r name ip rule subtype last_ts last_evt; do + [[ -z "$name" ]] && continue + p_ips["$name"]="$ip" + p_rules["$name"]="${rule:-—}" + p_subtypes["$name"]="$subtype" + p_last_ts["$name"]="$last_ts" + p_last_evt["$name"]="$last_evt" + done < <(json::peer_data "$(ctx::clients)" "$(ctx::meta)" "$(ctx::events_log)") + + # WireGuard handshakes + endpoints — two wg show calls + declare -A wg_handshakes wg_endpoints + cmd::list::_precompute_wg wg_handshakes wg_endpoints + + # Block/restricted status + declare -A p_blocked p_restricted + cmd::list::_precompute_block_status p_blocked p_restricted + + # Public keys — read from key files + declare -A p_pubkeys + for kf in "${dir}"/*_public.key; do + [[ -f "$kf" ]] || continue + local kname + kname=$(basename "$kf" _public.key) + p_pubkeys["$kname"]=$(cat "$kf" 2>/dev/null || echo "") + done + + # Group map + local has_groups=false + local groups_dir + groups_dir="$(ctx::groups)" + local group_files=("${groups_dir}"/*.group) + [[ -f "${group_files[0]}" ]] && has_groups=true + + declare -A peer_group_map + if $has_groups; then + while IFS=":" read -r peer_name group_name; do + [[ -n "$peer_name" ]] && peer_group_map["$peer_name"]="$group_name" + done < <(json::peer_group_map "$groups_dir") + fi + + # ── Detailed mode ────────────────────────── + if $detailed; then log::section "WireGuard Clients" - for conf in "${dir}"/*.conf; do - [[ -f "$conf" ]] || continue - local client_name - client_name=$(basename "$conf" .conf) - - # Apply type filter - if [[ -n "$filter_type" ]]; then - local ip - ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1) - local type="unknown" - for t in $(config::device_types); do - local subnet - subnet=$(config::subnet_for "$t") - if string::starts_with "$ip" "$subnet"; then - type="$t" - break - fi - done - [[ "$type" != "$filter_type" ]] && continue - fi - - cmd::list::show_client "$client_name" - done + cmd::list::_iter_confs "$filter_type" cmd::list::show_client return fi - # Normal table view - log::section "WireGuard Clients" +# ── Table view ───────────────────────────── - printf "\n %-28s %-15s %-10s %-22s %s\n" \ - "NAME" "IP" "TYPE" "STATUS" "LAST SEEN" - printf " %s\n" "$(printf '─%.0s' {1..90})" + log::section "WireGuard Clients" + cmd::list::_render_header $has_groups + + declare -A rule_counts + declare -A group_counts + + cmd::list::_iter_confs "$filter_type" cmd::list::_render_row + cmd::list::_render_footer $has_groups + + # Build summaries + declare -A displayed_rules displayed_groups + + local group_summary="" + if $has_groups; then + for g in "${!group_counts[@]}"; do + group_summary+="${group_counts[$g]} in ${g}, " + done + group_summary="${group_summary%, }" + fi + + cmd::list::_render_summary "$group_summary" rule_counts +} + +function cmd::list::_iter_confs() { + # Usage: cmd::list::_iter_confs + local filter_type="$1" + local callback="$2" + local dir + dir="$(ctx::clients)" for conf in "${dir}"/*.conf; do [[ -f "$conf" ]] || continue - local client_name client_name=$(basename "$conf" .conf) - - local ip - ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1) - - # Determine type - local type="unknown" - for t in $(config::device_types); do - local subnet - subnet=$(config::subnet_for "$t") - if string::starts_with "$ip" "$subnet"; then - type="$t" - break - fi - done - - # Apply type filter - if [[ -n "$filter_type" && "$type" != "$filter_type" ]]; then - continue + local ip="${p_ips[$client_name]:-}" + if [[ -z "$ip" ]]; then + ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1) fi - - local public_key - public_key=$(keys::public "$client_name" 2>/dev/null || echo "") - - # Apply filters - if $online_only && ! cmd::list::is_connected "$public_key"; then - continue - fi - - if $offline_only && cmd::list::is_connected "$public_key"; then - continue - fi - - if $restricted_only && ! cmd::list::is_restricted "$client_name"; then - continue - fi - - if $blocked_only && ! cmd::list::is_blocked "$client_name"; then - continue - fi - - if $allowed_only && { cmd::list::is_blocked "$client_name" || cmd::list::is_restricted "$client_name"; }; then - continue - fi - - local status - status=$(cmd::list::format_status "$client_name" "$public_key" "$ip") - - local last_seen - last_seen=$(cmd::list::format_last_seen "$client_name" "$public_key" "$ip") - - printf " %-28s %-15s %-10s %-32b %s\n" \ - "$client_name" "$ip" "$type" "$status" "$last_seen" + local type + type=$(cmd::list::_get_type "$ip") + [[ -n "$filter_type" && "$type" != "$filter_type" ]] && continue + "$callback" "$client_name" "$ip" "$type" done +} - printf "\n" +function cmd::list::_render_row() { + local client_name="$1" ip="$2" type="$3" + + local pubkey="${p_pubkeys[$client_name]:-}" + local handshake_ts="${wg_handshakes[$pubkey]:-0}" + local is_blocked="${p_blocked[$client_name]:-false}" + local is_restricted="${p_restricted[$client_name]:-false}" + local last_ts="${p_last_ts[$client_name]:-}" + + # Apply status filters + if $online_only; then cmd::list::_is_connected "$handshake_ts" || return 0; fi + if $offline_only; then cmd::list::_is_connected "$handshake_ts" && return 0; fi + if $restricted_only && [[ "$is_restricted" != "true" ]]; then return 0; fi + if $blocked_only && [[ "$is_blocked" != "true" ]]; then return 0; fi + if $allowed_only && { [[ "$is_blocked" == "true" ]] || \ + [[ "$is_restricted" == "true" ]]; }; then return 0; fi + + # Format display values + local status last_seen display_type rule group_display + status=$(cmd::list::_format_status "$client_name" "$pubkey" \ + "$is_blocked" "$is_restricted" "$handshake_ts" "$last_ts") + last_seen=$(cmd::list::_format_last_seen "$client_name" "$pubkey" \ + "$is_blocked" "$last_ts" "" "$handshake_ts") + display_type=$(cmd::list::display_type "$client_name" "$type" \ + "${p_subtypes[$client_name]:-}") + rule="${p_rules[$client_name]:-—}" + + # Update rule counts for summary (outer scope array) + rule_counts["$rule"]=$(( ${rule_counts[$rule]:-0} + 1 )) || true + + # Pad status + local padded_status + padded_status=$(cmd::list::pad_status "$status" 25) + + # Render row + if $has_groups; then + group_display="${peer_group_map[$client_name]:-—}" + + if [[ -n "${peer_group_map[$client_name]:-}" ]]; then + group_counts["$group_display"]=$(( ${group_counts[$group_display]:-0} + 1 )) || true + fi + + local rule_col_width=12 group_col_width=12 + [[ "$rule" == "—" ]] && rule_col_width=14 + [[ "$group_display" == "—" ]] && group_col_width=14 + printf " %-28s %-15s %-13s %-${rule_col_width}s %-${group_col_width}s %s %s\n" \ + "$client_name" "$ip" "$display_type" "$rule" \ + "$group_display" "$padded_status" "$last_seen" + else + local rule_col_width=12 + [[ "$rule" == "—" ]] && rule_col_width=14 + printf " %-28s %-15s %-13s %-${rule_col_width}s %s %s\n" \ + "$client_name" "$ip" "$display_type" "$rule" \ + "$padded_status" "$last_seen" + fi } \ No newline at end of file diff --git a/commands/list.command.sh.bak b/commands/list.command.sh.bak new file mode 100644 index 0000000..0a44950 --- /dev/null +++ b/commands/list.command.sh.bak @@ -0,0 +1,560 @@ +#!/usr/bin/env bash + +# ============================================ +# Lifecycle +# ============================================ + +function cmd::list::on_load() { + flag::register --type + flag::register --online + flag::register --offline + flag::register --restricted + flag::register --blocked + flag::register --allowed + flag::register --detailed + flag::register --name +} + +# ============================================ +# Help +# ============================================ + +function cmd::list::help() { + cat < Filter by device type + --online Show only connected clients + --offline Show only disconnected clients + --allowed Show only fully allowed clients + --restricted Show only restricted clients + --blocked Show only blocked clients + --detailed Show full detail cards for all clients + --name Show detail card for a single client + +Examples: + wgctl list + wgctl ls --type phone + wgctl list --online + wgctl list --blocked + wgctl list --allowed + wgctl list --restricted + wgctl list --detailed + wgctl list --name phone-nuno +EOF +} + +# ============================================ +# Status Helpers +# ============================================ + +function cmd::list::last_handshake_ts() { + local public_key="$1" + wg show "$(config::interface)" latest-handshakes 2>/dev/null \ + | grep "^${public_key}" \ + | awk '{print $2}' +} + +function cmd::list::last_dropped_ts() { + local client_ip="$1" + journalctl -k --grep "wgctl-dropped: " 2>/dev/null \ + | grep "SRC=${client_ip}" \ + | tail -1 \ + | awk '{print $1, $2, $3}' +} + +function cmd::list::is_connected() { + local public_key="$1" + local ts + ts=$(cmd::list::last_handshake_ts "$public_key") + [[ -z "$ts" || "$ts" == "0" ]] && return 1 + local now diff + now=$(date +%s) + diff=$(( now - ts )) + (( diff < 180 )) +} + +function cmd::list::is_attempting() { + local name="$1" + local ts + ts=$(monitor::last_attempt "$name") + [[ -z "$ts" ]] && return 1 + + local now attempt_ts diff + now=$(date +%s) + attempt_ts=$(python3 -c " +from datetime import datetime, timezone +dt = datetime.fromisoformat('${ts}') +if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) +print(int(dt.timestamp())) +" 2>/dev/null || echo 0) + + diff=$(( now - attempt_ts )) + (( diff < 180 )) +} + +function cmd::list::is_blocked() { + local name="$1" + peers::is_blocked "$name" +} + +function cmd::list::is_restricted() { + local name="$1" + [[ -f "$(ctx::block::path "${name}.block")" ]] +} + +function cmd::list::format_last_seen() { + local name="$1" + local public_key="$2" + local ip="$3" + + if cmd::list::is_blocked "$name"; then + local ts + ts=$(monitor::last_attempt "$name") + if [[ -n "$ts" ]]; then + # Format ISO timestamp + local formatted + formatted=$(python3 -c " +from datetime import datetime, timezone +dt = datetime.fromisoformat('${ts}') +print(dt.strftime('%Y-%m-%d %H:%M')) +" 2>/dev/null || echo "$ts") + echo "${formatted} (dropped)" + else + echo "—" + fi + else + local ts + ts=$(cmd::list::last_handshake_ts "$public_key") + if [[ -z "$ts" || "$ts" == "0" ]]; then + echo "—" + else + local formatted + formatted=$(date -d "@${ts}" "+%Y-%m-%d %H:%M" 2>/dev/null || echo "$ts") + echo "${formatted} (handshake)" + fi + fi +} + +function cmd::list::format_status() { + local name="$1" + local public_key="$2" + local ip="$3" # new + + local connected=false + local blocked=false + local restricted=false + + cmd::list::is_blocked "$name" && blocked=true + cmd::list::is_restricted "$name" && restricted=true + + if $blocked; then + cmd::list::is_attempting "$name" && connected=true + modifier=" (blocked)" + elif $restricted; then + cmd::list::is_connected "$public_key" && connected=true + modifier=" (restricted)" + else + cmd::list::is_connected "$public_key" && connected=true + modifier="" + fi + + local conn_str + $connected && conn_str="online" || conn_str="offline" + + local status="${conn_str}${modifier}" + + local color + if $blocked; then + color="\033[1;31m" + elif $restricted; then + color="\033[1;33m" + elif $connected; then + color="\033[1;32m" + else + color="\033[0;37m" + fi + + echo -e "${color}${status}\033[0m" +} + +# ============================================ +# Display Type +# ============================================ + +function cmd::list::display_type() { + local name="$1" + local type="$2" + + # log::debug "$(config::is_guest_type "$type")" + if config::is_guest_type "$type"; then + local subtype + subtype=$(peers::get_meta "$name" "subtype") + # log::debug "$subtype" + + if [[ -n "$subtype" ]]; then + echo "guest/${subtype}" + else + echo "guest" + fi + else + echo "$type" + fi +} + +# ============================================ +# Detail Card +# ============================================ + +function cmd::list::show_client() { + local name="$1" + local dir + dir="$(ctx::clients)" + local conf="${dir}/${name}.conf" + + if [[ ! -f "$conf" ]]; then + log::error "Client not found: ${name}" + return 1 + fi + + local ip + ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1) + + local allowed_ips + allowed_ips=$(grep "^AllowedIPs" "$conf" | awk '{print $3}') + + local public_key + public_key=$(keys::public "$name" 2>/dev/null || echo "unknown") + + # Get endpoint + local endpoint="—" + if cmd::list::is_blocked "$name"; then + local ep + ep=$(monitor::last_endpoint "$name") + [[ -n "$ep" ]] && endpoint="$ep" + else + local ep + ep=$(monitor::endpoint_for_key "$public_key") + [[ -n "$ep" ]] && endpoint="$ep" + fi + + # Determine type + local type="unknown" + for t in $(config::device_types); do + local subnet + subnet=$(config::subnet_for "$t") + if string::starts_with "$ip" "$subnet."; then + type="$t" + break + fi + done + + local status + status=$(cmd::list::format_status "$name" "$public_key" "$ip") + + local last_seen + last_seen=$(cmd::list::format_last_seen "$name" "$public_key" "$ip") + + # Block rules + local block_file + block_file="$(ctx::block::path "${name}.block")" + local blocks="" + +if [[ -f "$block_file" ]] && [[ -s "$block_file" ]]; then + while IFS=" " read -r client_ip target port proto; do + if [[ -z "$target" ]]; then + blocks+=" all traffic blocked\n" + else + local rule=" ${target}" + [[ -n "$port" ]] && rule+=":${port}/${proto}" + blocks+="${rule}\n" + fi + done < "$block_file" + fi + + local sep + sep="$(printf '─%.0s' {1..50})" + + echo "" + echo " ${sep}" + printf " \033[1;34m%-20s\033[0m %s\n" "Client:" "$name" + echo " ${sep}" + printf " %-20s %s\n" "IP:" "$ip" + printf " %-20s %s\n" "Type:" "$type" + printf " %-20s %b\n" "Status:" "$status" + printf " %-20s %s\n" "Endpoint:" "$endpoint" + printf " %-20s %s\n" "Last seen:" "$last_seen" + printf " %-20s %s\n" "Allowed IPs:" "$allowed_ips" + printf " %-20s %s\n" "Public key:" "$public_key" + + if [[ -z "$blocks" ]]; then + printf " %-20s %s\n" "Blocks:" "none" + elif [[ "$blocks" == *"all traffic blocked"* ]]; then + printf " %-20s \033[1;31mAll\033[0m\n" "Blocks:" + else + printf " %-20s\n" "Blocks:" + echo -e "$blocks" + fi + + echo " ${sep}" + echo "" +} + +# ============================================ +# Run +# ============================================ + +function cmd::list::run() { + local filter_type="" + local online_only=false + local offline_only=false + local restricted_only=false + local blocked_only=false + local allowed_only=false + local detailed=false + local single_name="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --type) filter_type="$2"; shift 2 ;; + --online) online_only=true; shift ;; + --offline) offline_only=true; shift ;; + --restricted) restricted_only=true; shift ;; + --blocked) blocked_only=true; shift ;; + --allowed) allowed_only=true; shift ;; + --detailed) detailed=true; shift ;; + --name) single_name="$2"; shift 2 ;; + --help) cmd::list::help; return ;; + *) + log::error "Unknown flag: $1" + cmd::list::help + return 1 + ;; + esac + done + + # Single client detail card + if [[ -n "$single_name" ]]; then + cmd::list::show_client "$single_name" + return + fi + + local dir + dir="$(ctx::clients)" + local confs=("${dir}"/*.conf) + + if [[ ! -f "${confs[0]}" ]]; then + log::wg_list "No clients configured" + return 0 + fi + + # START - GROUP SECTION + # Check if any groups exist + local has_groups=false + local groups_dir + groups_dir="$(ctx::groups)" + local group_files=("${groups_dir}"/*.group) + [[ -f "${group_files[0]}" ]] && has_groups=true + + # Precompute peer->group map + declare -A peer_group_map + if $has_groups; then + while IFS=":" read -r peer_name group_name; do + [[ -n "$peer_name" ]] && peer_group_map["$peer_name"]="$group_name" + done < <(json::peer_group_map "$groups_dir") + fi + # END - GROUP SECTION + + # Detailed mode — cards only, no table + if $detailed; then + log::section "WireGuard Clients" + for conf in "${dir}"/*.conf; do + [[ -f "$conf" ]] || continue + local client_name + client_name=$(basename "$conf" .conf) + + # Apply type filter + if [[ -n "$filter_type" ]]; then + local ip + ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1) + local type="unknown" + for t in $(config::device_types); do + local subnet + subnet=$(config::subnet_for "$t") + if string::starts_with "$ip" "$subnet."; then + type="$t" + break + fi + done + [[ "$type" != "$filter_type" ]] && continue + fi + + cmd::list::show_client "$client_name" + done + return + fi + + # Normal table view + log::section "WireGuard Clients" + + if $has_groups; then + printf "\n %-28s %-15s %-13s %-12s %-12s %-22s %s\n" \ + "NAME" "IP" "TYPE" "RULE" "GROUP" "STATUS" "LAST SEEN" + printf " %s\n" "$(printf '─%.0s' {1..135})" + else + printf "\n %-28s %-15s %-13s %-12s %-22s %s\n" \ + "NAME" "IP" "TYPE" "RULE" "STATUS" "LAST SEEN" + printf " %s\n" "$(printf '─%.0s' {1..107})" + fi + + for conf in "${dir}"/*.conf; do + [[ -f "$conf" ]] || continue + + local client_name + client_name=$(basename "$conf" .conf) + + local ip + ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1) + + # Determine type + local type="unknown" + for t in $(config::device_types); do + local subnet + subnet=$(config::subnet_for "$t") + if string::starts_with "$ip" "$subnet."; then + type="$t" + break + fi + done + + # Apply type filter + if [[ -n "$filter_type" && "$type" != "$filter_type" ]]; then + continue + fi + + local public_key + public_key=$(keys::public "$client_name" 2>/dev/null || echo "") + + # Apply filters + if $online_only && ! cmd::list::is_connected "$public_key"; then + continue + fi + + if $offline_only && cmd::list::is_connected "$public_key"; then + continue + fi + + if $restricted_only && ! cmd::list::is_restricted "$client_name"; then + continue + fi + + if $blocked_only && ! cmd::list::is_blocked "$client_name"; then + continue + fi + + if $allowed_only && { cmd::list::is_blocked "$client_name" || cmd::list::is_restricted "$client_name"; }; then + continue + fi + + local status + status=$(cmd::list::format_status "$client_name" "$public_key" "$ip") + + local last_seen + last_seen=$(cmd::list::format_last_seen "$client_name" "$public_key" "$ip") + + local display_type + display_type=$(cmd::list::display_type "$client_name" "$type") + # log::debug "display_type called with name=$client_name type=$type" + + local rule + rule=$(peers::effective_rule "$client_name") + rule="${rule:-—}" + + local padded_status + padded_status=$(cmd::list::pad_status "$status" 25) + local group_display="—" + if $has_groups; then + group_display="${peer_group_map[$client_name]:-—}" + fi + + local rule_col_width=12 + [[ "$rule" == "—" ]] && rule_col_width=14 + + local group_col_width=12 + [[ "$group_display" == "—" ]] && group_col_width=14 + + if $has_groups; then + printf " %-28s %-15s %-13s %-${rule_col_width}s %-${group_col_width}s %s %s\n" \ + "$client_name" "$ip" "$display_type" "$rule" \ + "$group_display" "$padded_status" "$last_seen" + else + printf " %-28s %-15s %-13s %-12s %s %s\n" \ + "$client_name" "$ip" "$display_type" "$rule" \ + "$padded_status" "$last_seen" + fi + done + + if $has_groups; then + printf " %s\n" "$(printf '─%.0s' {1..135})" + else + printf " %s\n" "$(printf '─%.0s' {1..107})" + fi + +local group_summary="" +if $has_groups; then + declare -A group_counts + for peer in "${!peer_group_map[@]}"; do + local g="${peer_group_map[$peer]}" + group_counts["$g"]=$(( ${group_counts["$g"]:-0} + 1 )) || true + done + for g in "${!group_counts[@]}"; do + group_summary+="${group_counts[$g]} in ${g}, " + done + group_summary="${group_summary%, }" +fi + + cmd::list::_render_summary "$group_summary" + + printf "\n" +} + +function cmd::list::_render_summary() { + local group_summary="${1:-}" + # Summary line + local total online_count + total=$(peers::all | wc -l) + + # Count by rule + declare -A rule_summary + while IFS= read -r peer_name; do + local r + r=$(peers::effective_rule "$peer_name") + rule_summary["$r"]=$(( ${rule_summary["$r"]:-0} + 1 )) + done < <(peers::all) + + local summary="" + for r in "${!rule_summary[@]}"; do + summary+="${rule_summary[$r]} ${r}, " + done + summary="${summary%, }" # remove trailing comma + + if [[ -n "$group_summary" ]]; then + printf "\n Showing %s peers [%s] — %s\n\n" "$total" "$summary" "$group_summary" + else + printf "\n Showing %s peers [%s]\n\n" "$total" "$summary" + fi +} + +# Strip ANSI codes to measure visible length, then pad manually +function cmd::list::pad_status() { + local status="$1" + local width="${2:-20}" + local visible + visible=$(echo -e "$status" | sed 's/\x1b\[[0-9;]*m//g') + local pad=$(( width - ${#visible} )) + printf "%b%${pad}s" "$status" "" +} \ No newline at end of file diff --git a/commands/logs.command.sh b/commands/logs.command.sh new file mode 100644 index 0000000..d702eb8 --- /dev/null +++ b/commands/logs.command.sh @@ -0,0 +1,252 @@ +#!/usr/bin/env bash + +FW_EVENTS_LOG="$(ctx::fw_events_log)" +WG_EVENTS_LOG="$(ctx::events_log)" + +function cmd::logs::on_load() { + flag::register --name + flag::register --type + flag::register --since + flag::register --limit + flag::register --fw + flag::register --wg + flag::register --follow +} + +function cmd::logs::help() { + cat < Filter by client name + --type Filter by device type + --since