From 57e08e88c4ca3caca8657e43739f9df4bb178b85 Mon Sep 17 00:00:00 2001 From: Nuno Duque Nunes Date: Fri, 22 May 2026 23:12:27 +0000 Subject: [PATCH 1/2] feat: rule list tableless layout with inline extends and +all/-N indicators --- commands/rule.command.sh | 394 ++++++++++++++----------------------- core/ui.sh | 23 +++ daemon/endpoint_cache.json | 2 +- modules/rule.module.sh | 21 +- modules/ui/rule.module.sh | 151 +++++++++++--- 5 files changed, 300 insertions(+), 291 deletions(-) diff --git a/commands/rule.command.sh b/commands/rule.command.sh index 10ff3cf..cf1e5d4 100644 --- a/commands/rule.command.sh +++ b/commands/rule.command.sh @@ -23,10 +23,10 @@ function cmd::rule::on_load() { flag::register --peer flag::register --peers flag::register --dns-redirect - flag::register --color flag::register --base flag::register --no-base flag::register --tree + flag::register --detailed flag::register --resolved flag::register --force flag::register --type @@ -60,7 +60,7 @@ Options for list: --base Show only base rules --no-base Hide base rules section --group Filter by group (case insensitive) - --tree Show full inheritance tree inline + --detailed Show rule entries inline Options for add: --name Rule name @@ -72,8 +72,8 @@ Options for add: --allow-port Allow specific port (repeatable) --block-ip Block IP or subnet (repeatable) --block-port Block specific port (repeatable) - --block-service Block named service — resolved to IP/port at creation (repeatable) - --allow-service Allow named service — resolved to IP/port at creation (repeatable) + --block-service Block named service (repeatable) + --allow-service Allow named service (repeatable) --dns-redirect Force DNS through Pi-hole Options for update: @@ -85,7 +85,7 @@ Options for update: --remove-block-ip Remove block IP entry --remove-block-port Remove block port entry -Options for show/inspect: +Options for show: --name Rule name --resolved Show resolved/merged entries --no-peers Hide assigned peers @@ -96,14 +96,12 @@ Options for reapply: Examples: wgctl rule list - wgctl rule list --tree + wgctl rule list --detailed wgctl rule list --group "VM Rules" wgctl rule show --name guest wgctl rule show --name moonlight-02 --resolved wgctl rule add --name no-proxmox --base --block-service proxmox - wgctl rule add --name dev-01 --desc "Dev access" --group "Dev" --extends no-lan - wgctl rule add --name restricted-dns --allow-service pihole:dns --block-service pihole - wgctl rule update --name user --add-extends no-nginx + wgctl rule add --name dev-01 --desc "Dev access" --extends no-lan wgctl rule assign --name dev-01 --peer laptop-nuno wgctl rule reapply --all EOF @@ -118,17 +116,16 @@ function cmd::rule::run() { shift || true case "$subcmd" in - list|ls) cmd::rule::list "$@" ;; - show|inspect) cmd::rule::show "$@" ;; - inspect) cmd::rule::inspect "$@" ;; - add|new|create) cmd::rule::add "$@" ;; - update|edit) cmd::rule::update "$@" ;; - remove|rm|del|delete) cmd::rule::remove "$@" ;; - assign) cmd::rule::assign "$@" ;; + list|ls) cmd::rule::list "$@" ;; + show|inspect) cmd::rule::show "$@" ;; + add|new|create) cmd::rule::add "$@" ;; + update|edit) cmd::rule::update "$@" ;; + remove|rm|del|delete) cmd::rule::remove "$@" ;; + assign) cmd::rule::assign "$@" ;; unassign) cmd::rule::unassign "$@" ;; - migrate) cmd::rule::migrate "$@" ;; - reapply) cmd::rule::reapply "$@" ;; - help) cmd::rule::help ;; + migrate) cmd::rule::migrate "$@" ;; + reapply) cmd::rule::reapply "$@" ;; + help) cmd::rule::help ;; *) log::error "Unknown subcommand: '${subcmd}'" cmd::rule::help @@ -141,172 +138,98 @@ function cmd::rule::run() { # List # ============================================ -function cmd::rule::_pad() { - local text="$1" width="$2" - local visible - visible=$(printf "%s" "$text" | sed 's/\x1b\[[0-9;]*m//g') - local visible_len=${#visible} - local byte_len=${#text} - local extra=$(( byte_len - visible_len )) - printf "%-$(( width + extra ))s" "$text" -} - -function cmd::rule::_print_extends_tree() { - local extends="$1" indent="${2:-2}" rules_dir="$3" - [[ -z "$extends" ]] && return 0 - - local extend_list=() - IFS=',' read -ra extend_list <<< "$extends" - - for base in "${extend_list[@]}"; do - [[ -z "$base" ]] && continue - local spaces - spaces=$(printf '%*s' "$indent" '') - printf " \033[0;37m%s↳ %s\033[0m\n" "$spaces" "$base" - - if [[ "$indent" -lt 12 ]]; then - local sub_file="" - if sub_file=$(json::find_rule_file "$rules_dir" "$base" 2>/dev/null); then - local sub_extends="" - sub_extends=$(json::get "$sub_file" "extends" 2>/dev/null \ - | tr '\n' ',' | sed 's/,$//' || true) - if [[ -n "$sub_extends" ]]; then - cmd::rule::_print_extends_tree \ - "$sub_extends" $(( indent + 4 )) "$rules_dir" - fi - fi - fi - done -} - function cmd::rule::list() { local rules_dir rules_dir="$(ctx::rules)" - local show_base_only=false - local show_base=true - local filter_group="" - local show_tree=false - local found_any=false + local show_base_only=false show_base=true + local filter_group="" detailed=false while [[ $# -gt 0 ]]; do case "$1" in - --base) show_base_only=true; shift ;; - --no-base) show_base=false; shift ;; - --group) util::require_flag "--group" "${2:-}" || return 1 - filter_group="${2,,}"; shift 2 ;; - --tree) show_tree=true; shift ;; - --help) cmd::rule::help; return ;; + --base) show_base_only=true; shift ;; + --no-base) show_base=false; shift ;; + --group) filter_group="${2,,}"; shift 2 ;; + --detailed) detailed=true; shift ;; + --help) cmd::rule::help; return ;; *) log::error "Unknown flag: $1" return 1 ;; esac done - local rules=("${rules_dir}"/*.rule) - if [[ ! -f "${rules[0]}" ]]; then - log::wg "No rules configured" - return 0 - fi + local data + data=$(json::rule_list_data "$rules_dir" "$(ctx::meta)") + [[ -z "$data" ]] && log::wg "No rules configured" && return 0 - local header_printed=false - local printing_base=false - local current_group="" + # Measure max name width + local w_name=12 + while IFS='|' read -r name rest; do + [[ -z "$name" ]] && continue + (( ${#name} > w_name )) && w_name=${#name} + done <<< "$data" + (( w_name += 2 )) + + log::section "Firewall Rules" + echo "" + + local current_group="" printing_base=false found_any=false while IFS="|" read -r name desc n_allows n_blocks \ peer_count extends is_base group; do [[ -z "$name" ]] && continue - # --base: show ONLY base rules - if $show_base_only && [[ "$is_base" == "False" ]]; then - continue - fi + $show_base_only && [[ "$is_base" == "False" ]] && continue + ! $show_base && [[ "$is_base" == "True" ]] && continue + [[ -n "$filter_group" && "${group,,}" != "$filter_group" ]] && continue - # --no-base: hide base rules - if ! $show_base && [[ "$is_base" == "True" ]]; then - continue - fi - - # --group filter (case insensitive) - if [[ -n "$filter_group" && "${group,,}" != "$filter_group" ]]; then - continue - fi - - # Print header on first match - if ! $header_printed; then - log::section "Firewall Rules" - printf "\n %-20s %-40s %-8s %-8s %s\n" \ - "NAME" "DESCRIPTION" \ - "$(ui::center "ALLOWS" 8)" \ - "$(ui::center "BLOCKS" 8)" \ - "PEERS" - local divider - divider=$(printf '─%.0s' {1..88}) - printf " %s\n" "$divider" - header_printed=true - fi + found_any=true # Base rules section header if [[ "$is_base" == "True" && "$printing_base" == "false" ]]; then if ! $show_base_only; then - local bdashes - bdashes=$(printf '─%.0s' {1..74}) - printf "\n \033[0;37m── Base Rules \033[0m%s\n" "$bdashes" + ui::rule::list_base_header fi printing_base=true current_group="" fi - # Group header — only for non-base rules + # Group header — non-base rules only if [[ "$is_base" == "False" && "${group,,}" != "${current_group,,}" ]]; then if [[ -n "$group" ]]; then - printf "\n \033[0;36m▸ %s\033[0m\n" "$group" + ui::rule::list_group_header "$group" elif [[ -n "$current_group" ]]; then - printf "\n" + echo "" fi current_group="$group" fi - local short_desc="${desc:0:35}" - [[ ${#desc} -gt 35 ]] && short_desc="${short_desc}..." + # Rule row + # ui::rule::list_row "$name" "$n_allows" "$n_blocks" "$peer_count" "$w_name" - local desc_col_width=40 - [[ "${short_desc:-—}" == "—" ]] && desc_col_width=42 - - found_any=true - - printf " %-20s %-${desc_col_width}s %-8s %-8s %s\n" \ - "$name" "${short_desc:-—}" \ - "$(ui::center "$n_allows" 8)" \ - "$(ui::center "$n_blocks" 8)" \ - "${peer_count} peers" - - # Print extends - if [[ -n "$extends" ]]; then - if $show_tree; then - cmd::rule::_print_extends_tree "$extends" 2 "$rules_dir" - else - local extend_list=() - IFS=',' read -ra extend_list <<< "$extends" - for base in "${extend_list[@]}"; do - [[ -z "$base" ]] && continue - printf " \033[0;37m ↳ %s\033[0m\n" "$base" - done - fi - printf "\n" + # Extends + # Rule row — pass extends_csv for compact inline display + local compact_extends="" + if [[ -z "$detailed" ]] || ! $detailed; then + compact_extends="$extends" + fi + ui::rule::list_row "$name" "$n_allows" "$n_blocks" "$peer_count" "$w_name" "$compact_extends" + + # Detailed mode — show expanded entries + if $detailed && [[ -n "$extends" ]]; then + ui::rule::list_extends_detailed "$extends" "$rules_dir" + echo "" fi - done < <(json::rule_list_data "$rules_dir" "$(ctx::meta)") + done <<< "$data" - if ! $found_any; then - if [[ -n "$filter_group" ]]; then - log::wg_warning "No rules found in group: ${filter_group}" - else + $found_any || { + [[ -n "$filter_group" ]] && \ + log::wg_warning "No rules found in group: ${filter_group}" || \ log::wg_warning "No rules found" - fi - fi + } - printf "\n" + echo "" } # ============================================ @@ -318,11 +241,10 @@ function cmd::rule::show() { while [[ $# -gt 0 ]]; do case "$1" in - --name) util::require_flag "--name" "${2:-}" || return 1 - name="$2"; shift 2 ;; - --no-peers) show_peers=false; shift ;; - --resolved) show_resolved=true; shift ;; - --help) cmd::rule::help; return ;; + --name) name="$2"; shift 2 ;; + --no-peers) show_peers=false; shift ;; + --resolved) show_resolved=true; shift ;; + --help) cmd::rule::help; return ;; *) log::error "Unknown flag: $1"; return 1 ;; esac done @@ -333,7 +255,7 @@ function cmd::rule::show() { local rule_file rule_file="$(rule::path "$name")" - # ── DNS display ─────────────────────────────── + # DNS display local dns_redirect resolved_dns dns_display dns_redirect=$(rule::get_own "$name" "dns_redirect") dns_redirect="${dns_redirect:-false}" @@ -359,24 +281,21 @@ function cmd::rule::show() { ui::row "DNS" "$dns_display" printf "\n" - # ── Extends + own rules ──────────────────────── - if rule::render_extends_tree "$name"; then - # Has inheritance — tree already rendered + if ui::rule::tree "$name"; then : else - # No inheritance — flat view - rule::render_flat "$name" + ui::rule::flat "$name" printf "\n" fi - # ── Resolved ────────────────────────────────── + # Resolved view if $show_resolved; then - cmd::rule::_show_section "Resolved (applied to peers)" + ui::rule::section_header "Resolved (applied to peers)" printf "\n" local res_allow_ports res_allow_ips res_block_ips res_block_ports res_allow_ports=$(rule::get "$name" "allow_ports") - res_allow_ips=$(rule::get "$name" "allow_ips") - res_block_ips=$(rule::get "$name" "block_ips") + res_allow_ips=$(rule::get "$name" "allow_ips") + res_block_ips=$(rule::get "$name" "block_ips") res_block_ports=$(rule::get "$name" "block_ports") while IFS= read -r e; do [[ -n "$e" ]] && net::print_entry "+" "$e"; done \ <<< "$res_allow_ports"$'\n'"$res_allow_ips" @@ -385,26 +304,24 @@ function cmd::rule::show() { printf "\n" fi - # ── Peers ───────────────────────────────────── - local peer_list=() - mapfile -t peer_list < <(peers::with_rule "$name") + # Peers + $show_peers || return 0 + local peer_list=() + mapfile -t peer_list < <(peers::with_rule "$name") || true local peer_count=${#peer_list[@]} - ui::empty "$peer_count" && return 0 - printf "\n" - printf "\033[0;37m── Peers (%s) \033[0m%s\n\n" \ - "$(color::gray "${peer_count}")" \ - "$(printf '\033[0;37m─%.0s' {1..35})" + 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})" - if $show_peers && [[ $peer_count -gt 0 ]]; then - for peer_name in "${peer_list[@]}"; do - local ip - ip=$(peers::get_ip "$peer_name") - printf " %-28s %s\n" "$peer_name" "$ip" - done - fi + for peer_name in "${peer_list[@]}"; do + local ip + ip=$(peers::get_ip "$peer_name") + printf " %-28s %s\n" "$peer_name" "$ip" + done printf "\n" return 0 } @@ -418,27 +335,26 @@ function cmd::rule::add() { local extends=() local allow_ips=() block_ips=() block_ports=() allow_ports=() local block_services=() allow_services=() - local dns_redirect=false - local is_base=false + local dns_redirect=false is_base=false while [[ $# -gt 0 ]]; do case "$1" in - --name) name="$2"; shift 2 ;; - --desc) desc="$2"; shift 2 ;; - --group) group="$2"; shift 2 ;; + --name) name="$2"; shift 2 ;; + --desc) desc="$2"; shift 2 ;; + --group) group="$2"; shift 2 ;; --extends) IFS=',' read -ra ext <<< "$2" extends+=("${ext[@]}") shift 2 ;; - --base) is_base=true; shift ;; - --allow-ip) allow_ips+=("$2"); shift 2 ;; - --allow-port) allow_ports+=("$2"); shift 2 ;; - --block-ip) block_ips+=("$2"); shift 2 ;; - --block-port) block_ports+=("$2"); shift 2 ;; - --block-service) block_services+=("$2"); shift 2 ;; - --allow-service) allow_services+=("$2"); shift 2 ;; - --dns-redirect) dns_redirect=true; shift ;; - --help) cmd::rule::help; return ;; + --base) is_base=true; shift ;; + --allow-ip) allow_ips+=("$2"); shift 2 ;; + --allow-port) allow_ports+=("$2"); shift 2 ;; + --block-ip) block_ips+=("$2"); shift 2 ;; + --block-port) block_ports+=("$2"); shift 2 ;; + --block-service) block_services+=("$2"); shift 2 ;; + --allow-service) allow_services+=("$2"); shift 2 ;; + --dns-redirect) dns_redirect=true; shift ;; + --help) cmd::rule::help; return ;; *) log::error "Unknown flag: $1" return 1 ;; @@ -452,12 +368,10 @@ function cmd::rule::add() { return 1 fi - # Validate extends for ext in "${extends[@]}"; do rule::require_exists "$ext" || return 1 done - # Determine target directory local rule_dir if $is_base; then rule_dir="$(ctx::rules)/base" @@ -487,7 +401,6 @@ function cmd::rule::add() { done local rule_file="${rule_dir}/${name}.rule" - local allow_str block_str port_str allow_port_str extends_str allow_str=$(IFS=','; echo "${allow_ips[*]}") block_str=$(IFS=','; echo "${block_ips[*]}") @@ -502,7 +415,6 @@ function cmd::rule::add() { local base_label="" $is_base && base_label=" (base)" - log::wg_success "Rule created: ${name}${base_label}" } @@ -519,9 +431,9 @@ function cmd::rule::update() { while [[ $# -gt 0 ]]; do case "$1" in - --name) name="$2"; shift 2 ;; - --desc) desc="$2"; shift 2 ;; - --group) group="$2"; shift 2 ;; + --name) name="$2"; shift 2 ;; + --desc) desc="$2"; shift 2 ;; + --group) group="$2"; shift 2 ;; --add-extends) IFS=',' read -ra ext <<< "$2" add_extends+=("${ext[@]}") @@ -530,16 +442,16 @@ function cmd::rule::update() { IFS=',' read -ra ext <<< "$2" rm_extends+=("${ext[@]}") shift 2 ;; - --allow-ip) allow_ips+=("$2"); shift 2 ;; - --allow-port) allow_ports+=("$2"); shift 2 ;; - --block-ip) block_ips+=("$2"); shift 2 ;; - --block-port) block_ports+=("$2"); shift 2 ;; - --remove-allow-ip) rm_allow_ips+=("$2"); shift 2 ;; - --remove-block-ip) rm_block_ips+=("$2"); shift 2 ;; - --remove-block-port) rm_block_ports+=("$2"); shift 2 ;; - --remove-allow-port) rm_allow_ports+=("$2"); shift 2 ;; - --dns-redirect) dns_redirect=true; shift ;; - --help) cmd::rule::help; return ;; + --allow-ip) allow_ips+=("$2"); shift 2 ;; + --allow-port) allow_ports+=("$2"); shift 2 ;; + --block-ip) block_ips+=("$2"); shift 2 ;; + --block-port) block_ports+=("$2"); shift 2 ;; + --remove-allow-ip) rm_allow_ips+=("$2"); shift 2 ;; + --remove-block-ip) rm_block_ips+=("$2"); shift 2 ;; + --remove-block-port) rm_block_ports+=("$2"); shift 2 ;; + --remove-allow-port) rm_allow_ports+=("$2"); shift 2 ;; + --dns-redirect) dns_redirect=true; shift ;; + --help) cmd::rule::help; return ;; *) log::error "Unknown flag: $1" return 1 ;; @@ -552,18 +464,15 @@ function cmd::rule::update() { local rule_file rule_file="$(rule::path "$name")" - # Update simple fields [[ -n "$desc" ]] && json::set "$rule_file" "desc" "\"$desc\"" [[ -n "$group" ]] && json::set "$rule_file" "group" "\"$group\"" [[ -n "$dns_redirect" ]] && json::set "$rule_file" "dns_redirect" "true" - # Add entries for ip in "${allow_ips[@]}"; do json::append "$rule_file" "allow_ips" "$ip"; done for ip in "${block_ips[@]}"; do json::append "$rule_file" "block_ips" "$ip"; done for p in "${block_ports[@]}"; do json::append "$rule_file" "block_ports" "$p"; done for p in "${allow_ports[@]}"; do json::append "$rule_file" "allow_ports" "$p"; done - # Add/remove extends for ext in "${add_extends[@]}"; do rule::require_exists "$ext" || return 1 json::append "$rule_file" "extends" "$ext" @@ -572,7 +481,6 @@ function cmd::rule::update() { json::remove "$rule_file" "extends" "$ext" done - # Remove entries for ip in "${rm_allow_ips[@]}"; do json::remove "$rule_file" "allow_ips" "$ip"; done for ip in "${rm_block_ips[@]}"; do json::remove "$rule_file" "block_ips" "$ip"; done for p in "${rm_block_ports[@]}"; do json::remove "$rule_file" "block_ports" "$p"; done @@ -583,8 +491,7 @@ function cmd::rule::update() { } # ============================================ -# Remove / Assign / Unassign / Migrate / Reapply -# (unchanged from original) +# Remove # ============================================ function cmd::rule::remove() { @@ -603,7 +510,7 @@ function cmd::rule::remove() { rule::require_exists "$name" || return 1 local peer_list=() - mapfile -t peer_list < <(peers::with_rule "$name") + mapfile -t peer_list < <(peers::with_rule "$name") || true local peer_count=${#peer_list[@]} if [[ "$peer_count" -gt 0 ]]; then @@ -620,6 +527,10 @@ function cmd::rule::remove() { log::wg_success "Rule removed: ${name}" } +# ============================================ +# Assign / Unassign +# ============================================ + function cmd::rule::assign() { local name="" peer="" type="" while [[ $# -gt 0 ]]; do @@ -635,15 +546,13 @@ function cmd::rule::assign() { [[ -z "$name" || -z "$peer" ]] && \ log::error "Missing required flags: --name and --peer" && return 1 - rule::require_exists "$name" || return 1 + rule::require_exists "$name" || return 1 rule::require_assignable "$name" || return 1 peer=$(peers::resolve_and_require "$peer" "$type") || return 1 - local existing_rule + local existing_rule ip existing_rule=$(peers::get_meta "$peer" "rule") - - local ip ip=$(peers::get_ip "$peer") [[ -z "$ip" ]] && log::error "Could not resolve IP for: $peer" && return 1 @@ -684,37 +593,41 @@ function cmd::rule::unassign() { log::wg_success "Unassigned rule from: ${peer}" } +# ============================================ +# Migrate +# ============================================ + function cmd::rule::migrate() { log::section "Migrating peers to default rules" - local tmp - tmp=$(mktemp) + local count=0 while IFS= read -r peer_name; do local existing existing=$(peers::get_meta "$peer_name" "rule") [[ -n "$existing" ]] && continue - local default_rule - default_rule=$(peers::default_rule "$peer_name") + + # Try to get default rule from subnet policy + local peer_type subnet_name default_rule + peer_type=$(peers::get_meta "$peer_name" "type") + subnet_name=$(peers::get_meta "$peer_name" "subnet") + default_rule=$(subnet::default_rule "$subnet_name" "$peer_type") + [[ -z "$default_rule" ]] && continue + rule::exists "$default_rule" || continue + local ip ip=$(peers::get_ip "$peer_name") - echo "${peer_name} ${default_rule} ${ip}" >> "$tmp" - done < <(peers::all) - - local count=0 - local lines=() - mapfile -t lines < "$tmp" - rm -f "$tmp" - - for line in "${lines[@]}"; do - IFS=" " read -r peer_name default_rule ip <<< "$line" - rule::apply "$default_rule" "$ip" "$peer_name" +# Returns the visible (character) length of a string, +# stripping ANSI codes and accounting for multi-byte UTF-8. +function ui::vis_len() { + local str="${1:-}" + # Strip ANSI codes first + local clean + clean=$(echo "$str" | sed 's/\x1b\[[0-9;]*m//g') + # Use Python for accurate Unicode character count + python3 -c "import sys; print(len('${clean//\'/\'\\\'\'}'))" 2>/dev/null || echo "${#clean}" +} + +# ui::pad_to_col +# Returns padding needed accounting for UTF-8 byte/char difference. +# extra_bytes = bytes_printed - visible_chars_printed +function ui::utf8_extra_bytes() { + local str="${1:-}" + local byte_len=${#str} + local vis_len + vis_len=$(ui::vis_len "$str") + echo $(( byte_len - vis_len )) +} + function ui::pad_status() { ui::pad "${1:-}" "${2:-25}" diff --git a/daemon/endpoint_cache.json b/daemon/endpoint_cache.json index f7c1f06..04cae3f 100644 --- a/daemon/endpoint_cache.json +++ b/daemon/endpoint_cache.json @@ -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.192.130", + "phone-helena-2": "148.69.202.127", "desktop-zephyr": "86.120.152.74" } \ No newline at end of file diff --git a/modules/rule.module.sh b/modules/rule.module.sh index 58ba2b3..a3732bd 100644 --- a/modules/rule.module.sh +++ b/modules/rule.module.sh @@ -333,21 +333,14 @@ function rule::restore_all() { # Rendering # ============================================ -function rule::render_flat() { - ui::rule::flat "$1" -} +# ====================================================== +# Aliases (for backward compat — remove in cleanup pass) +# ====================================================== -function rule::render_entries() { - ui::rule::entries "$1" -} - -function rule::render_own_entries() { - ui::rule::own_entries "$1" -} - -function rule::render_extends_tree() { - ui::rule::tree "$1" -} +function rule::render_flat() { ui::rule::flat "$1"; } +function rule::render_entries() { ui::rule::entries "$1"; } +function rule::render_own_entries() { ui::rule::own_entries "$1"; } +function rule::render_extends_tree() { ui::rule::tree "$1"; } # ============================================ # DNS Redirect diff --git a/modules/ui/rule.module.sh b/modules/ui/rule.module.sh index c385775..83fde5c 100644 --- a/modules/ui/rule.module.sh +++ b/modules/ui/rule.module.sh @@ -11,24 +11,24 @@ # Renders the fully resolved entries for a rule (allow + block). function ui::rule::entries() { local rule_name="${1:-}" indent="${2:-4}" - + local allow_ports allow_ips block_ips block_ports dns allow_ports=$(rule::get "$rule_name" "allow_ports" 2>/dev/null || true) allow_ips=$(rule::get "$rule_name" "allow_ips" 2>/dev/null || true) block_ips=$(rule::get "$rule_name" "block_ips" 2>/dev/null || true) block_ports=$(rule::get "$rule_name" "block_ports" 2>/dev/null || true) dns=$(rule::get_own "$rule_name" "dns_redirect") - + while IFS= read -r e; do [[ -z "$e" ]] && continue net::print_entry "+" "$e" "$indent" done <<< "$allow_ports"$'\n'"$allow_ips" - + while IFS= read -r e; do [[ -z "$e" ]] && continue net::print_entry "-" "$e" "$indent" done <<< "$block_ips"$'\n'"$block_ports" - + [[ "${dns,,}" == "true" ]] && \ net::print_dns_redirect "$(config::dns)" 6 "DNS" } @@ -40,27 +40,27 @@ function ui::rule::own_entries() { local rule_file rule_file="$(rule::path "$rule_name")" || return 0 [[ -z "$rule_file" ]] && return 0 - + local allow_ports allow_ips block_ips block_ports dns allow_ports=$(json::get "$rule_file" "allow_ports" 2>/dev/null || true) allow_ips=$(json::get "$rule_file" "allow_ips" 2>/dev/null || true) block_ips=$(json::get "$rule_file" "block_ips" 2>/dev/null || true) block_ports=$(json::get "$rule_file" "block_ports" 2>/dev/null || true) dns=$(json::get "$rule_file" "dns_redirect" 2>/dev/null || true) - + local combined="${allow_ports}${allow_ips}${block_ips}${block_ports}" [[ -z "${combined//[$'\n']/}" ]] && return 0 - + while IFS= read -r e; do [[ -z "$e" ]] && continue net::print_entry "+" "$e" "$indent" done <<< "$allow_ports"$'\n'"$allow_ips" - + while IFS= read -r e; do [[ -z "$e" ]] && continue net::print_entry "-" "$e" "$indent" done <<< "$block_ips"$'\n'"$block_ports" - + [[ "${dns,,}" == "true" ]] && \ net::print_dns_redirect "$(config::dns)" 6 "DNS" } @@ -69,22 +69,22 @@ function ui::rule::own_entries() { # Renders the full resolved entries as a flat list. function ui::rule::flat() { local rule_name="${1:-}" - + local allow_ports allow_ips block_ips block_ports dns allow_ports=$(rule::get "$rule_name" "allow_ports") allow_ips=$(rule::get "$rule_name" "allow_ips") block_ips=$(rule::get "$rule_name" "block_ips") block_ports=$(rule::get "$rule_name" "block_ports") dns=$(rule::get_own "$rule_name" "dns_redirect") - + local has_content=false [[ -n "${allow_ports}${allow_ips}${block_ips}${block_ports}" ]] && has_content=true - + if ! $has_content; then printf "\n full access (no restrictions)\n" return 0 fi - + if [[ -n "$allow_ports" || -n "$allow_ips" ]]; then printf "\n" while IFS= read -r e; do @@ -92,7 +92,7 @@ function ui::rule::flat() { net::print_entry "+" "$e" 2 done <<< "$allow_ports"$'\n'"$allow_ips" fi - + if [[ -n "$block_ips" || -n "$block_ports" ]]; then printf "\n" while IFS= read -r e; do @@ -100,7 +100,7 @@ function ui::rule::flat() { net::print_entry "-" "$e" 2 done <<< "$block_ips"$'\n'"$block_ports" fi - + [[ "${dns,,}" == "true" ]] && \ net::print_dns_redirect "$(config::dns)" 6 "DNS" } @@ -117,7 +117,7 @@ function ui::rule::_render_bases() { local entry_indent="${2:-6}" label_indent="${3:-4}" local label_pad label_pad=$(printf '%*s' "$label_indent" '') - + local first=true for base_name in "${_bases[@]}"; do [[ -z "$base_name" ]] && continue @@ -140,23 +140,23 @@ function ui::rule::tree() { local rule_file rule_file="$(rule::path "$rule_name")" || return 1 [[ -z "$rule_file" ]] && return 1 - + local extends_raw=() mapfile -t extends_raw < <(json::get "$rule_file" "extends" 2>/dev/null || true) || true - + if [[ ${#extends_raw[@]} -eq 0 || -z "${extends_raw[0]:-}" ]]; then return 1 fi - + ui::rule::_render_bases extends_raw 6 4 - + local own_output own_output=$(ui::rule::own_entries "$rule_name" 6) if [[ -n "$own_output" ]]; then printf "\n \033[0;37mOwn:\033[0m\n" printf "%s\n" "$own_output" fi - + return 0 } @@ -194,16 +194,15 @@ function ui::rule::_identity_rule_entry() { local rule_name="${1:-}" local rule_file rule_file="$(rule::path "$rule_name")" || return 0 - + printf " \033[0;37m↳ %s\033[0m\n" "$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 - # Rule has extends — render one level deep using shared helper ui::rule::_render_bases extends_raw 10 8 - + local own_output own_output=$(ui::rule::own_entries "$rule_name" 10) if [[ -n "$own_output" ]]; then @@ -211,7 +210,6 @@ function ui::rule::_identity_rule_entry() { printf "%s\n" "$own_output" fi else - # Leaf rule — show own entries or note full access local own_output own_output=$(ui::rule::own_entries "$rule_name" 8) if [[ -n "$own_output" ]]; then @@ -254,4 +252,103 @@ function ui::rule::_peer_rule_entry() { printf " \033[2mfull access (no restrictions)\033[0m\n" fi fi +} + +# ====================================================== +# List Rendering +# ====================================================== + +# ui::rule::list_group_header +function ui::rule::list_group_header() { + local group="${1:-}" + printf "\n \033[0;36m▸ %s\033[0m\n" "$group" +} + +# ui::rule::list_base_header +function ui::rule::list_base_header() { + printf "\n \033[2m── Base Rules ──────────────────────\033[0m\n" +} + + +# ui::rule::list_row +function ui::rule::list_row() { + local name="${1:-}" n_allows="${2:-0}" n_blocks="${3:-0}" \ + peer_count="${4:-0}" w_name="${5:-16}" extends_csv="${6:-}" + + local name_pad + name_pad=$(printf "%-${w_name}s" "$name") + + local peer_word="peers" + [[ "$peer_count" -eq 1 ]] && peer_word="peer" + + local peers_display + peers_display=$(printf "%s %s" "$peer_count" "$peer_word") + local peers_pad_n=$(( 10 - ${#peers_display} )) + [[ $peers_pad_n -lt 0 ]] && peers_pad_n=0 + + local extends_indicator="" + if [[ -n "$extends_csv" ]]; then + local extends_display="${extends_csv//,/, }" + 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" "") + 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 + fi + + printf " %s %b%b %s%*s%b\n" \ + "$name_pad" "$allows_str" "$blocks_str" \ + "$peers_display" "$peers_pad_n" "" "$extends_indicator" +} + +# ui::rule::list_extends +# Renders the extends tree for a rule in list view (compact, one level) +function ui::rule::list_extends() { + local extends_csv="${1:-}" + [[ -z "$extends_csv" ]] && return 0 + + local extend_list=() + IFS=',' read -ra extend_list <<< "$extends_csv" + for base in "${extend_list[@]}"; do + [[ -z "$base" ]] && continue + printf " \033[0;37m ↳ %s\033[0m\n" "$base" + done +} + +# ui::rule::list_extends_detailed +# Renders the extends tree with entries expanded (--detailed mode) +function ui::rule::list_extends_detailed() { + local extends_csv="${1:-}" rules_dir="${2:-}" + [[ -z "$extends_csv" ]] && return 0 + + local extend_list=() + IFS=',' read -ra extend_list <<< "$extends_csv" + for base in "${extend_list[@]}"; do + [[ -z "$base" ]] && continue + printf " \033[0;37m ↳ %s\033[0m\n" "$base" + ui::rule::entries "$base" 6 + done +} + +# ====================================================== +# Show helpers +# ====================================================== + +function ui::rule::section_header() { + local title="${1:-}" + printf "\n \033[0;37m── %s ──────────────────────────────────\033[0m\n" "$title" } \ No newline at end of file From 4dcf98b128eb2e90765b4f2889c3b78b75bd8e86 Mon Sep 17 00:00:00 2001 From: Nuno Duque Nunes Date: Sat, 23 May 2026 03:24:20 +0000 Subject: [PATCH 2/2] feat: tableless logs/watch layout with service annotations - wgctl logs: tableless layout, fw/wg sections, --merged flag, --raw flag - wgctl watch: tableless layout, service annotations, colored fw/wg labels - wgctl rule list: tableless with +N/-N/+all indicators, inline extends - wgctl activity: transfer totals and firewall drops per peer - ui/logs.module.sh: fw_row, wg_row, watch rows, table versions kept - ui/rule.module.sh: list_row, list_group_header, list_base_header - fmt.sh: FMT_DATETIME_SHORT, updated fmt::set_date_format - json_helper.py: fw_events with service annotation, wg_events with count --- commands/logs.command.sh | 292 ++++++++++++++--------- commands/watch.command.sh | 481 ++++++++++++++++---------------------- core/fmt.sh | 4 + core/json.sh | 4 +- core/json_helper.py | 374 ++++++++++++++++++----------- modules/config.module.sh | 1 + modules/ui/logs.module.sh | 167 +++++++++++++ 7 files changed, 790 insertions(+), 533 deletions(-) create mode 100644 modules/ui/logs.module.sh diff --git a/commands/logs.command.sh b/commands/logs.command.sh index cd9c155..b5b24e9 100644 --- a/commands/logs.command.sh +++ b/commands/logs.command.sh @@ -11,10 +11,12 @@ function cmd::logs::on_load() { flag::register --fw flag::register --wg flag::register --follow + flag::register --merged flag::register --all flag::register --before flag::register --force flag::register --days + flag::register --raw } function cmd::logs::help() { @@ -34,7 +36,9 @@ Options for show: --limit Max results per source (default: 50) --fw Show only firewall drops --wg Show only WireGuard events - --follow, -f Follow logs in real time + --merged Show all events chronologically interleaved + --follow, -f Follow logs in real time (alias: wgctl watch) + --raw Show raw IPs without service annotation Options for remove: --name Remove entries for specific peer @@ -52,12 +56,10 @@ Examples: wgctl logs wgctl logs --name phone-nuno wgctl logs --fw --limit 100 + wgctl logs --merged wgctl logs --follow wgctl logs remove --name phone-nuno - wgctl logs remove --all --force - wgctl logs remove --fw --before 1 - wgctl logs rotate - wgctl logs rotate --days 30 --force + wgctl logs rotate --days 30 EOF } @@ -70,10 +72,10 @@ function cmd::logs::run() { fi case "$subcmd" in - show) cmd::logs::show "$@" ;; - remove|rm|del) cmd::logs::remove "$@" ;; - rotate) cmd::logs::rotate "$@" ;; - help) cmd::logs::help ;; + show) cmd::logs::show "$@" ;; + remove|rm|del) cmd::logs::remove "$@" ;; + rotate) cmd::logs::rotate "$@" ;; + help) cmd::logs::help ;; *) log::error "Unknown subcommand: '${subcmd}'" cmd::logs::help @@ -84,17 +86,19 @@ function cmd::logs::run() { function cmd::logs::show() { local name="" type="" limit=50 - local fw_only=false wg_only=false follow=false + local fw_only=false wg_only=false follow=false merged=false raw=false 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 ;; - --follow|-f) follow=true; shift ;; - --help) cmd::logs::help; return ;; + --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 ;; *) log::error "Unknown flag: $1" return 1 @@ -117,51 +121,171 @@ function cmd::logs::show() { return fi + local net_file="" + $raw || net_file="$(ctx::net)" + log::section "WireGuard Activity Log" printf "\n" - $wg_only || cmd::logs::show_fw_events "$filter_ip" "$name" "$type" "$limit" - $fw_only || cmd::logs::show_wg_events "$filter_ip" "$name" "$type" "$limit" + + if $merged; then + cmd::logs::show_merged "$filter_ip" "$name" "$type" "$limit" "$net_file" + 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" +} + +function cmd::logs::show_fw_events() { + local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" \ + limit="${4:-50}" net_file="${5:-}" + + [[ ! -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) + + [[ -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} + done <<< "$data" + (( w_client += 2 )) + (( w_dest += 2 )) + + ui::logs::fw_section_header + while IFS='|' read -r ts client dest_ip dest_port proto svc count; do + [[ -z "$ts" ]] && continue + ui::logs::fw_row "$ts" "$client" "$dest_ip" "$dest_port" \ + "$proto" "$svc" "$count" "$w_client" "$w_dest" + done <<< "$data" + printf "\n" +} + +function cmd::logs::show_wg_events() { + local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" limit="${4:-50}" + + [[ ! -f "$WG_EVENTS_LOG" ]] && return 0 + + local data + data=$(json::wg_events "$WG_EVENTS_LOG" "$filter_name" "$filter_type" "$limit" 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} + done <<< "$data" + (( w_client += 2 )) + (( w_endpoint += 2 )) + + ui::logs::wg_section_header + while IFS='|' read -r ts client endpoint event count; do + [[ -z "$ts" ]] && continue + ui::logs::wg_row "$ts" "$client" "$endpoint" "$event" \ + "$count" "$w_client" "$w_endpoint" + done <<< "$data" + printf "\n" +} + +function cmd::logs::show_merged() { + local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" \ + limit="${4:-50}" net_file="${5:-}" + + 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) + + # Measure widths across both sources + local w_client=16 w_dest=20 + while IFS='|' read -r ts client rest; do + [[ -z "$ts" ]] && continue + (( ${#client} > w_client )) && w_client=${#client} + 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 + [[ -z "$ts" ]] && continue + echo "fw|${ts}|${client}|${dest_ip}|${dest_port}|${proto}|${svc}|${count}" + done <<< "$fw_data" + while IFS='|' read -r ts client endpoint event count; do + [[ -z "$ts" ]] && continue + echo "wg|${ts}|${client}|${endpoint}|${event}|${count}" + done <<< "$wg_data" + ) + + # Sort by timestamp field 2 + while IFS='|' read -r source ts rest; do + [[ -z "$source" ]] && continue + case "$source" in + fw) + IFS='|' read -r client dest_ip dest_port proto svc count <<< "$rest" + 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} + ;; + esac + done <<< "$merged_data" + (( w_dest += 2 )) + + while IFS='|' read -r source ts rest; do + [[ -z "$source" ]] && continue + case "$source" in + fw) + IFS='|' read -r client dest_ip dest_port proto svc count <<< "$rest" + ui::watch::fw_row "$ts" "$client" \ + "$(ui::logs::build_dest "$dest_ip" "$dest_port" "$proto" "$svc")" \ + "$w_client" "$w_dest" + ;; + wg) + IFS='|' read -r client endpoint event count <<< "$rest" + ui::watch::wg_row "$ts" "$client" "$endpoint" "$event" \ + "$w_client" "$w_dest" + ;; + esac + done < <(echo "$merged_data" | sort -t'|' -k2,2) + + printf "\n" } function cmd::logs::follow() { local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" local fw_only="${4:-false}" wg_only="${5:-false}" - local filter_peers="${6:-}" - local clients_dir - clients_dir="$(ctx::clients)" - local wg_log="$WG_EVENTS_LOG" - local fw_log="$FW_EVENTS_LOG" - $fw_only && wg_log="" - $wg_only && fw_log="" log::section "WireGuard Live Log (Ctrl+C to stop)" - printf "\n %-20s %-8s %-20s %-25s %s\n" \ - "TIME" "SOURCE" "CLIENT" "DESTINATION/ENDPOINT" "EVENT" - printf " %s\n" "$(printf '─%.0s' {1..90})" + printf "\n" - while IFS="|" read -r source ts client dst_or_endpoint event; do - if [[ "$source" == "fw" ]]; then - local colored_event - case "$event" in - tcp) colored_event="\033[1;33mtcp\033[0m" ;; - udp) colored_event="\033[0;36mudp\033[0m" ;; - icmp) colored_event="\033[0;37micmp\033[0m" ;; - *) colored_event="$event" ;; - esac - printf " %-20s %-8s %-20s %-25s %b\n" \ - "$ts" "firewall" "$client" "$dst_or_endpoint" "$colored_event" - else - local colored_event - case "$event" in - attempt) colored_event="\033[1;31mattempt\033[0m" ;; - handshake) colored_event="\033[1;32mhandshake\033[0m" ;; - *) colored_event="$event" ;; - esac - printf " %-20s %-8s %-20s %-25s %b\n" \ - "$ts" "wireguard" "$client" "$dst_or_endpoint" "$colored_event" - fi - done < <(json::follow_logs "$fw_log" "$wg_log" "$filter_ip" "$filter_type" \ - "$clients_dir" "$filter_peers") + # 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) + + cmd::watch::run "${watch_args[@]}" } function cmd::logs::remove() { @@ -185,7 +309,6 @@ function cmd::logs::remove() { esac done - # Validate — need at least one filter if ! $all && [[ -z "$name" && -z "$before" ]]; then log::error "Specify --name, --before, or --all" cmd::logs::help @@ -198,7 +321,6 @@ function cmd::logs::remove() { filter_ip=$(peers::get_ip "$name") fi - # Build description for confirmation local desc="" $all && desc="all entries" [[ -n "$name" ]] && desc="entries for '${name}'" @@ -209,7 +331,7 @@ function cmd::logs::remove() { if ! $force; then read -r -p "Remove ${desc}? [y/N] " confirm case "$confirm" in - [yY][eE][sS]|[yY]) ;; + [yY]*) ;; *) log::info "Aborted"; return 0 ;; esac fi @@ -233,69 +355,13 @@ function cmd::logs::remove() { log::wg_success "Removed ${total} log entries (wg: ${removed_wg}, fw: ${removed_fw})" } -function cmd::logs::show_wg_events() { - local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" limit="${4:-50}" - - [[ ! -f "$WG_EVENTS_LOG" ]] && return 0 - - printf " WireGuard Events:\n" - printf " %-20s %-20s %-18s %s\n" "TIME" "CLIENT" "ENDPOINT" "EVENT" - printf " %s\n" "$(printf '─%.0s' {1..75})" - - local found=false - while IFS="|" read -r ts client endpoint event; do - [[ -z "$ts" ]] && continue - local colored_event - case "$event" in - attempt*) colored_event="\033[1;31m${event}\033[0m" ;; - handshake*) colored_event="\033[1;32m${event}\033[0m" ;; - *) colored_event="$event" ;; - esac - printf " %-20s %-20s %-18s %b\n" "$ts" "$client" "$endpoint" "$colored_event" - found=true - done < <(json::wg_events "$WG_EVENTS_LOG" "$filter_name" "$filter_type" "$limit") - - $found || printf " —\n" - printf "\n" -} - -function cmd::logs::show_fw_events() { - local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" limit="${4:-50}" - - [[ ! -f "$FW_EVENTS_LOG" ]] && return 0 - - printf " Firewall Drops:\n" - printf " %-20s %-18s %-25s %s\n" "TIME" "CLIENT" "DESTINATION" "PROTOCOL" - printf " %s\n" "$(printf '─%.0s' {1..75})" - - local found=false - while IFS="|" read -r ts client dst proto; do - [[ -z "$ts" ]] && continue - local colored_proto - case "$proto" in - tcp*) colored_proto="\033[1;33m${proto}\033[0m" ;; - udp*) colored_proto="\033[1;36m${proto}\033[0m" ;; - icmp*) colored_proto="\033[0;37m${proto}\033[0m" ;; - *) colored_proto="$proto" ;; - esac - printf " %-20s %-18s %-25s %b\n" "$ts" "$client" "$dst" "$colored_proto" - found=true - done < <(json::fw_events "$FW_EVENTS_LOG" "$filter_ip" "$filter_type" \ - "$(ctx::clients)" "$limit") - - $found || printf " —\n" - printf "\n" -} - - - function cmd::logs::rotate() { local days=7 force=false while [[ $# -gt 0 ]]; do case "$1" in - --days) days="$2"; shift 2 ;; - --force) force=true; shift ;; + --days) days="$2"; shift 2 ;; + --force) force=true; shift ;; --help) cmd::logs::help; return ;; *) log::error "Unknown flag: $1"; return 1 ;; esac diff --git a/commands/watch.command.sh b/commands/watch.command.sh index 0c54152..69a28fd 100644 --- a/commands/watch.command.sh +++ b/commands/watch.command.sh @@ -11,6 +11,7 @@ function cmd::watch::on_load() { flag::register --blocked flag::register --restricted flag::register --allowed + flag::register --raw } # ============================================ @@ -21,295 +22,27 @@ function cmd::watch::help() { cat < Filter by client name --type Filter by device type - --peers Comma-separated peer names (used internally by group watch) --blocked Show only blocked peer attempts - --allowed Show only handshakes (allowed peers) + --allowed Show only handshakes --restricted Show only firewall drop events + --raw Show raw IPs without service annotation Examples: wgctl watch - wgctl watch --blocked - wgctl watch --allowed - wgctl watch --type phone wgctl watch --name phone-nuno - wgctl watch --name phone-nuno --type phone + wgctl watch --blocked + wgctl watch --type phone EOF } -# ============================================ -# Helpers -# ============================================ - -function cmd::watch::format_event() { - local ts="${1:-}" source="${2:-}" client="${3:-}" - local dest="${4:-}" event="${5:-}" status="${6:-}" - - local event_color - case "$event" in - attempt|drop) event_color="\033[1;31m" ;; - handshake) event_color="\033[1;32m" ;; - *) event_color="\033[0;37m" ;; - esac - - local status_color="" - case "$status" in - blocked) status_color="\033[1;31m" ;; - allowed) status_color="\033[1;32m" ;; - esac - - printf " %-20s %-8s %-22s %-28s ${event_color}%-14s\033[0m ${status_color}%s\033[0m\n" \ - "$ts" "$source" "$client" "${dest:-—}" "$event" "$status" -} - -function cmd::watch::header() { - log::section "wgctl — Live Monitor (Ctrl+C to stop)" - printf "\n %-20s %-8s %-22s %-28s %-14s %s\n" \ - "TIME" "SOURCE" "CLIENT" "DESTINATION/ENDPOINT" "EVENT" "STATUS" - printf " %s\n\n" "$(printf '─%.0s' {1..105})" -} - -function cmd::watch::_peer_in_filter() { - local peer="$1" - shift - local peer_set=("$@") - [[ ${#peer_set[@]} -eq 0 ]] && return 0 # no filter = all pass - for p in "${peer_set[@]}"; do - [[ "$p" == "$peer" ]] && return 0 - done - return 1 -} - -# ============================================ -# Handshake Poller -# ============================================ - -function cmd::watch::poll_handshakes() { - local filter_name="${1:-}" filter_type="${2:-}" - local allowed_only="${3:-false}" - local filter_peers="${4:-}" - - local peer_set=() - [[ -n "$filter_peers" ]] && IFS=',' read -ra peer_set <<< "$filter_peers" - - 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 - local name - name=$(basename "$conf" .conf) - local key - key=$(keys::public "$name" 2>/dev/null || echo "") - if [[ "$key" == "$public_key" ]]; then - client_name="$name" - break - fi - done - - [[ -z "$client_name" ]] && continue - - # Apply filters - [[ -n "$filter_name" && "$client_name" != "$filter_name" ]] && continue - cmd::watch::_peer_in_filter "$client_name" "${peer_set[@]}" || continue - - if [[ -n "$filter_type" ]]; then - local ip - ip=$(grep "^Address" "$(ctx::clients)/${client_name}.conf" \ - | awk '{print $3}' | cut -d'/' -f1) - local subnet - subnet=$(config::subnet_for "$filter_type") - string::starts_with "$ip" "$subnet" || continue - fi - - # 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") - - if [[ "$ts" != "$prev_ts" ]]; then - echo "$ts" > "$prev_ts_file" - - local formatted_ts - formatted_ts=$(fmt::datetime "$ts") - - local endpoint - endpoint=$(monitor::endpoint_for_key "$public_key") - - cmd::watch::format_event \ - "$formatted_ts" "wg" "$client_name" "${endpoint:-—}" "handshake" "allowed" - fi - - done < <(wg show "$(config::interface)" latest-handshakes 2>/dev/null) -} - -# ============================================ -# Event Tailer -# ============================================ - -function cmd::watch::tail_events() { - local filter_name="${1:-}" filter_type="${2:-}" - local blocked_only="${3:-false}" restricted_only="${4:-false}" - local allowed_only="${5:-false}" filter_peers="${6:-}" - - declare -A _WATCH_LAST_ATTEMPT=() - declare -A _WATCH_LAST_FW=() - - local peer_set=() - [[ -n "$filter_peers" ]] && IFS=',' read -ra peer_set <<< "$filter_peers" - - declare -A _WATCH_LAST_ATTEMPT=() - - # Build ip->name map for fw events - declare -A ip_to_name=() - local conf_file - while IFS= read -r conf_file; do - local name - name=$(basename "$conf_file" .conf) - local ip - ip=$(grep "^Address" "$conf_file" 2>/dev/null | awk '{print $3}' | cut -d'/' -f1) - [[ -n "$ip" && -n "$name" ]] && ip_to_name["$ip"]="$name" - done < <(find "$(ctx::clients)" -name "*.conf" 2>/dev/null) - - # Source tracker via temp file (persists across subshell iterations) - local source_file - source_file=$(mktemp) - echo "wg" > "$source_file" - - # Cleanup temp file on exit - 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 - - # Handle tail -f file headers - if [[ "$line" == "==> "* ]]; then - if [[ "$line" == *"fw_events"* ]]; then - echo "fw" > "$source_file" - else - echo "wg" > "$source_file" - fi - continue - fi - - local source - source=$(cat "$source_file") - - if [[ "$source" == "wg" ]]; then - $allowed_only && continue # wg events are attempts/blocked - - local event_data - event_data=$(json::parse_event "$line") - [[ -z "$event_data" ]] && continue - - local ts client endpoint event - IFS="|" read -r ts client endpoint event <<< "$event_data" - - [[ -n "$filter_name" && "$client" != "$filter_name" ]] && continue - cmd::watch::_peer_in_filter "$client" "${peer_set[@]}" || continue - - if [[ -n "$filter_type" ]]; then - local conf - conf="$(ctx::clients)/${client}.conf" - [[ -f "$conf" ]] || continue - local ip - ip=$(grep "^Address" "$conf" 2>/dev/null | awk '{print $3}' | cut -d'/' -f1) - local subnet - subnet=$(config::subnet_for "$filter_type") - string::starts_with "$ip" "$subnet" || continue - fi - - $restricted_only && { cmd::list::is_restricted "$client" || continue; } - - # Dedup - local now - now=$(date +%s) - local safe_client="${client//[-.]/_}" - local last="${_WATCH_LAST_ATTEMPT[$safe_client]:-0}" - (( now - last < 30 )) && continue - _WATCH_LAST_ATTEMPT[$safe_client]="$now" - - local formatted_ts - formatted_ts=$(fmt::datetime_iso "$ts") - - cmd::watch::format_event \ - "$formatted_ts" "wg" "$client" "${endpoint:-—}" "$event" "blocked" - - else - # FW event - $allowed_only && continue - $blocked_only && continue # fw drops aren't "blocked peers" per se - - local fw_data - fw_data=$(json::parse_fw_event "$line") - [[ -z "$fw_data" ]] && continue - - local ts src_ip dst_ip dst_port proto - IFS="|" read -r ts src_ip dst_ip dst_port proto <<< "$fw_data" - - [[ -z "$src_ip" ]] && continue - - local fw_key="${src_ip}:${dst_ip}:${dst_port}:${proto}" - local now - now=$(date +%s) - local last_fw="${_WATCH_LAST_FW[$fw_key]:-0}" - - local window=30 - [[ "$proto" == "17" || "$proto" == "udp" ]] && window=10 - [[ "$proto" == "1" || "$proto" == "icmp" ]] && window=5 - - local diff=$(( now - last_fw )) - (( diff < window )) && continue - _WATCH_LAST_FW["$fw_key"]="$now" - - local client="${ip_to_name[$src_ip]:-$src_ip}" - - [[ -n "$filter_name" && "$client" != "$filter_name" ]] && continue - cmd::watch::_peer_in_filter "$client" "${peer_set[@]}" || continue - - if [[ -n "$filter_type" ]]; then - local peer_client="${ip_to_name[$src_ip]:-}" - [[ -z "$peer_client" ]] && continue - local conf - conf="$(ctx::clients)/${peer_client}.conf" - [[ -f "$conf" ]] || continue - local ip - ip=$(grep "^Address" "$conf" 2>/dev/null | awk '{print $3}' | cut -d'/' -f1) - local subnet - subnet=$(config::subnet_for "$filter_type") - string::starts_with "$ip" "$subnet" || continue - fi - - local dst_str="${dst_ip:-—}" - [[ -n "$dst_port" ]] && dst_str="${dst_ip}:${dst_port}/${proto}" - - local formatted_ts - formatted_ts=$(fmt::datetime_iso "$ts") - - cmd::watch::format_event \ - "$formatted_ts" "fw" "$client" "$dst_str" "drop" "blocked" - fi - done - - rm -f "$source_file" -} - # ============================================ # Run # ============================================ @@ -317,6 +50,7 @@ function cmd::watch::tail_events() { function cmd::watch::run() { local filter_name="" filter_type="" filter_peers="" local blocked_only=false allowed_only=false restricted_only=false + local raw=false rm -f /tmp/wgctl_hs_* /tmp/wgctl_attempt_* 2>/dev/null || true @@ -328,6 +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 ;; --help) cmd::watch::help; return ;; *) log::error "Unknown flag: $1" @@ -337,27 +72,207 @@ function cmd::watch::run() { esac done - cmd::watch::header + 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" "$allowed_only" "$filter_peers" + 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" \ - "$blocked_only" "$restricted_only" \ - "$allowed_only" "$filter_peers" & + # 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_*; echo ''; exit 0" INT TERM + rm -f /tmp/wgctl_hs_* /tmp/wgctl_attempt_*; printf '\n'; exit 0" INT TERM wait +} + +# ============================================ +# Handshake Poller +# ============================================ + +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" + + 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 + local cname + cname=$(basename "$conf" .conf) + local key + key=$(keys::public "$cname" 2>/dev/null || echo "") + if [[ "$key" == "$public_key" ]]; then + client_name="$cname" + break + fi + 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 + echo "$ts" > "$prev_ts_file" + + local ts_fmt + ts_fmt=$(fmt::datetime_short "$ts") + local endpoint + endpoint=$(monitor::endpoint_for_key "$public_key") + + ui::watch::wg_row "$ts_fmt" "$client_name" "${endpoint:-—}" "handshake" \ + "$w_client" "$w_dest" + + done < <(wg show "$(config::interface)" latest-handshakes 2>/dev/null) +} + +# ============================================ +# Event Tailer +# ============================================ + +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" + + # Build ip->name map + declare -A ip_to_name=() + while IFS= read -r conf; do + local cname + cname=$(basename "$conf" .conf) + local ip + 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 + [[ "$proto" == "17" || "$proto" == "udp" ]] && window=10 + [[ "$proto" == "1" || "$proto" == "icmp" ]] && window=5 + 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" + + 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 + _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" + fi + done + + rm -f "$source_file" } \ No newline at end of file diff --git a/core/fmt.sh b/core/fmt.sh index 56a2e4a..fb97281 100644 --- a/core/fmt.sh +++ b/core/fmt.sh @@ -13,6 +13,7 @@ FMT_DATETIME_EU="%d/%m/%Y %H:%M" # 10/05/2026 22:39 # Default — can be overridden in wgctl.conf FMT_DATE="${FMT_DATE_ISO}" FMT_DATETIME="${FMT_DATETIME_ISO}" +FMT_DATETIME_SHORT="%m-%d %H:%M" # Load from config or use default _FMT_DATE_FORMAT="${DATE_FORMAT:-iso}" @@ -62,14 +63,17 @@ function fmt::set_date_format() { iso) FMT_DATE="%Y-%m-%d" FMT_DATETIME="%Y-%m-%d %H:%M" + FMT_DATETIME_SHORT="%m-%d %H:%M" ;; eu) FMT_DATE="%d/%m/%Y" FMT_DATETIME="%d/%m/%Y %H:%M" + FMT_DATETIME_SHORT="%d/%m %H:%M" ;; eu-dash) FMT_DATE="%d-%m-%Y" FMT_DATETIME="%d-%m-%Y %H:%M" + FMT_DATETIME_SHORT="%d-%m %H:%M" ;; *) log::error "Unknown date format: $format" ;; esac diff --git a/core/json.sh b/core/json.sh index b887a6a..705d0f6 100644 --- a/core/json.sh +++ b/core/json.sh @@ -12,8 +12,8 @@ function json::has_key() { python3 "$JSON_HELPER" has_key function json::filter_values() { python3 "$JSON_HELPER" filter_values "$@" name map ip_to_name = {} for conf in glob.glob(f"{clients_dir}/*.conf"): name = os.path.basename(conf).replace('.conf', '') @@ -168,12 +173,40 @@ def fw_events(file, filter_ip, filter_type, clients_dir, limit): if line.startswith('Address'): ip = line.split('=')[1].strip().split('/')[0] ip_to_name[ip] = name - except: + except Exception: pass - + + # Load net services for reverse lookup — independent of rest of function + net_data = {} + if net_file and os.path.exists(net_file): + try: + with open(net_file) as f: + net_data = json.load(f) + except Exception: + pass + + def reverse_lookup(dest_ip, dest_port, proto): + 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}" + return svc_name + return svc_name + return '' + + # Parse and first-pass dedup (within time window per key) events = [] last_seen = {} - + try: with open(file) as f: for line in f: @@ -184,114 +217,91 @@ def fw_events(file, filter_ip, filter_type, clients_dir, limit): continue if filter_ip and src != filter_ip: continue - dst = e.get('dest_ip', '') - port = e.get('dest_port', '') - proto = e.get('ip.protocol', 0) - key = (src, dst, port, proto) + + 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', '')) + key = (src, dst, port, proto_num) + ts_str = e.get('timestamp', '') try: - from datetime import datetime ts = datetime.fromisoformat(ts_str).timestamp() - except: + except Exception: ts = 0 + windows = {1: 5, 6: 30, 17: 10} - window = windows.get(proto, 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: + except Exception: pass - except: + except Exception: pass - - # Dedup consecutive same src+dst+port within 60s with count + + # Second-pass dedup consecutive same events with count deduped = [] counts = [] for e in events: ts_str = e.get('timestamp', '') try: - from datetime import datetime ts = datetime.fromisoformat(ts_str).timestamp() - except: + except Exception: ts = 0 - src = e.get('src_ip', '') - dst = e.get('dest_ip', '') - port = e.get('dest_port', '') - proto = e.get('ip.protocol', 0) - key = (src, dst, port, proto) - - if deduped and counts: + + 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) + + if deduped: prev = deduped[-1] try: - prev_ts = datetime.fromisoformat(prev.get('timestamp','')).timestamp() - except: + prev_ts = datetime.fromisoformat(prev.get('timestamp', '')).timestamp() + except Exception: prev_ts = 0 - prev_key = (prev.get('src_ip',''), prev.get('dest_ip',''), - prev.get('dest_port',''), prev.get('ip.protocol',0)) - if key == prev_key and (ts - prev_ts) < 60: + 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) - - grouped = [] - group_counts = [] - for e in deduped: - ts_str = e.get('timestamp', '') - try: - from datetime import datetime - dt = datetime.fromisoformat(ts_str) - # Truncate to minute for grouping - minute_key = dt.strftime('%Y-%m-%d %H:%M') - except: - minute_key = ts_str[:16] - - src = e.get('src_ip', '') - dst = e.get('dest_ip', '') - port = e.get('dest_port', '') - proto = e.get('ip.protocol', 0) - key = (minute_key, src, dst, proto) # group within same minute - - if grouped and group_counts: - prev = grouped[-1] - try: - prev_dt = datetime.fromisoformat(prev.get('timestamp','')) - prev_minute = prev_dt.strftime('%Y-%m-%d %H:%M') - except: - prev_minute = '' - prev_key = (prev_minute, prev.get('src_ip',''), - prev.get('dest_ip',''), prev.get('ip.protocol',0)) - if key == prev_key: - group_counts[-1] += 1 - continue - - grouped.append(e) - group_counts.append(1) - - for e, count in zip(deduped[-int(limit):], counts[-int(limit):]): - ts = e.get('timestamp', '') - try: - from datetime import datetime - dt = datetime.fromisoformat(ts) - ts = dt.strftime(DATETIME_FMT) - except: - pass - src = e.get('src_ip', '—') - dst = e.get('dest_ip', '—') - port = e.get('dest_port', '') - proto_num = e.get('ip.protocol', 0) + + limit = int(limit) if limit else 50 + for e, count in list(zip(deduped, counts))[-limit:]: + ts_str = e.get('timestamp', '') + 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)) - dst_str = f"{dst}:{port}" if port else dst client = ip_to_name.get(src, src) - if filter_type and not client.startswith(filter_type + '-'): - continue - count_str = f" (x{count})" if count > 1 else "" - print(f"{ts}|{client}|{dst_str}|{proto}{count_str}") - + svc_name = reverse_lookup(dst, port, proto) + + try: + dt = datetime.fromisoformat(ts_str) + ts_fmt = dt.strftime(DATETIME_FMT) + except Exception: + ts_fmt = ts_str + + print(f"{ts_fmt}|{client}|{dst}|{port}|{proto}|{svc_name}|{count}") + def wg_events(file, filter_client, filter_type, limit): - """Format WireGuard events from events.log with dedup""" + """ + Format WireGuard events with dedup and counts. + Output per line: ts|client|endpoint|event|count + """ + from datetime import datetime + events = [] try: with open(file) as f: @@ -306,54 +316,57 @@ def wg_events(file, filter_client, filter_type, limit): if filter_type and not client.startswith(filter_type + '-'): continue events.append(e) - except: + except Exception: pass - except: + except Exception: pass - + # Dedup consecutive same client+event+endpoint within 60s deduped = [] - counts = [] + counts = [] for e in events: - ts_str = e.get('timestamp', '') + ts_str = e.get('timestamp', '') try: - from datetime import datetime ts = datetime.fromisoformat(ts_str).timestamp() - except: + except Exception: ts = 0 - client = e.get('client', '') - event = e.get('event', '') + client = e.get('client', '') + event = e.get('event', '') endpoint = e.get('endpoint', '') - key = (client, event, endpoint[:15]) - - if deduped and counts: - prev = deduped[-1] + key = (client, event, endpoint[:15]) + + if deduped: + prev = deduped[-1] prev_ts_str = prev.get('timestamp', '') try: prev_ts = datetime.fromisoformat(prev_ts_str).timestamp() - except: + except Exception: prev_ts = 0 - prev_key = (prev.get('client',''), prev.get('event',''), prev.get('endpoint','')[:15]) - if key == prev_key and (ts - prev_ts) < 60: + prev_key = ( + prev.get('client', ''), + prev.get('event', ''), + prev.get('endpoint', '')[:15] + ) + if key == prev_key and (ts - prev_ts) < 300: counts[-1] += 1 continue - + deduped.append(e) counts.append(1) - - for e, count in zip(deduped[-int(limit):], counts[-int(limit):]): - ts = e.get('timestamp', '') + + limit = int(limit) if limit else 50 + for e, count in list(zip(deduped, counts))[-limit:]: + ts_str = e.get('timestamp', '') + client = e.get('client', '') + endpoint = e.get('endpoint', '') + event = e.get('event', '') try: - from datetime import datetime - dt = datetime.fromisoformat(ts) - ts = dt.strftime(DATETIME_FMT) - except: - pass - client = e.get('client', '—') - endpoint = e.get('endpoint', '—') - event = e.get('event', '—') - count_str = f" (x{count})" if count > 1 else "" - print(f"{ts}|{client}|{endpoint}|{event}{count_str}") + dt = datetime.fromisoformat(ts_str) + ts_fmt = dt.strftime('%d/%m %H:%M') + except Exception: + ts_fmt = ts_str + + print(f"{ts_fmt}|{client}|{endpoint}|{event}|{count}") def format_fw_event(line, clients_dir): """Format a single fw_event line""" @@ -2448,24 +2461,115 @@ def activity_aggregate(fw_file, wg_file, wg_interface, net_file, except Exception: pass - def reverse_lookup(dest_ip, dest_port, proto): - 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): +def reverse_lookup(dest_ip, dest_port, proto): + 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}" + return svc_name + return svc_name + return '' + + # Parse and first-pass dedup (within 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 (str(port_def.get('port', '')) == dest_port and - port_def.get('proto', 'tcp') == proto): - return f"{svc_name}:{port_name}" - return svc_name - else: - return svc_name - return '' + 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', '')) + key = (src, dst, port, proto_num) + + ts_str = e.get('timestamp', '') + try: + ts = datetime.fromisoformat(ts_str).timestamp() + except Exception: + ts = 0 + + 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: + pass + except Exception: + pass + + # Second-pass dedup consecutive same events with count + deduped = [] + counts = [] + for e in events: + ts_str = e.get('timestamp', '') + try: + ts = datetime.fromisoformat(ts_str).timestamp() + except Exception: + ts = 0 + + 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) + + if deduped: + prev = deduped[-1] + try: + prev_ts = datetime.fromisoformat(prev.get('timestamp', '')).timestamp() + except Exception: + prev_ts = 0 + 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) + + limit = int(limit) if limit else 50 + for e, count in list(zip(deduped, counts))[-limit:]: + ts_str = e.get('timestamp', '') + 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(dst, port, proto) + + try: + dt = datetime.fromisoformat(ts_str) + ts_fmt = dt.strftime(DATETIME_FMT) + except Exception: + ts_fmt = ts_str + + print(f"{ts_fmt}|{client}|{dst}|{port}|{proto}|{svc_name}|{count}") def make_dest_display(dest_ip, dest_port, proto, svc_name): if svc_name: @@ -2563,8 +2667,8 @@ commands = { 'filter_values': lambda args: filter_values(args[0], args[1], args[2]), 'last_event': lambda args: last_event(args[0], args[1], args[2], args[3]), 'events_for': lambda args: events_for(args[0], args[1], args[2]), - 'fw_events': lambda args: fw_events(args[0], args[1], args[2], args[3], args[4]), - 'wg_events': lambda args: wg_events(args[0], args[1], args[2], args[3]), + 'fw_events': lambda args: fw_events(args[0], args[1], args[2], args[3], args[4], args[5] if len(args) > 5 else '50'), + 'wg_events': lambda args: wg_events(args[0], args[1], args[2], args[3] if len(args) > 3 else '50'), 'format_fw_event': lambda args: format_fw_event(sys.stdin.read(), args[0]), 'format_wg_event': lambda args: format_wg_event(sys.stdin.read()), 'remove_events': lambda args: remove_events(args[0], args[1]), diff --git a/modules/config.module.sh b/modules/config.module.sh index 1e54c7d..3e39a28 100644 --- a/modules/config.module.sh +++ b/modules/config.module.sh @@ -24,6 +24,7 @@ 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}" + function config::_init_defaults() { _WG_INTERFACE="${WG_INTERFACE:-wg0}" _WG_DNS="${WG_DNS:-10.0.0.103}" diff --git a/modules/ui/logs.module.sh b/modules/ui/logs.module.sh new file mode 100644 index 0000000..279a8f4 --- /dev/null +++ b/modules/ui/logs.module.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +# ui/logs.module.sh — rendering for logs and watch data + +function ui::logs::build_dest() { + local dest_ip="${1:-}" dest_port="${2:-}" proto="${3:-}" svc="${4:-}" + if [[ -n "$svc" ]]; then + [[ -n "$dest_port" ]] && echo "${svc}/${proto}" || echo "${svc} (${proto})" + else + [[ -n "$dest_port" ]] && echo "${dest_ip}:${dest_port}/${proto}" || echo "${dest_ip} (${proto})" + fi +} + +function ui::logs::fw_section_header() { + printf " Firewall Drops\n" + printf " %s\n" "$(printf '─%.0s' {1..42})" +} + +function ui::logs::wg_section_header() { + printf " WireGuard Events\n" + printf " %s\n" "$(printf '─%.0s' {1..42})" +} + +function ui::logs::fw_section_header_table() { + printf " Firewall Drops:\n" + printf " %-20s %-18s %-25s %s\n" "TIME" "CLIENT" "DESTINATION" "PROTOCOL" + printf " %s\n" "$(printf '─%.0s' {1..75})" +} + +function ui::logs::wg_section_header_table() { + printf " WireGuard Events:\n" + printf " %-20s %-20s %-18s %s\n" "TIME" "CLIENT" "ENDPOINT" "EVENT" + printf " %s\n" "$(printf '─%.0s' {1..75})" +} + +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") + 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" +} + +function ui::logs::fw_row_table() { + local ts="${1:-}" client="${2:-}" dst="${3:-}" proto="${4:-}" count="${5:-1}" + local count_str="" + [[ "$count" -gt 1 ]] && count_str=" (x${count})" + 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_table() { + local ts="${1:-}" client="${2:-}" endpoint="${3:-}" event="${4:-}" count="${5:-1}" + local count_str="" + [[ "$count" -gt 1 ]] && count_str=" (x${count})" + local event_colored + case "$event" in + attempt*) event_colored="\033[1;31m${event}\033[0m" ;; + handshake*) event_colored="\033[1;32m${event}\033[0m" ;; + *) event_colored="$event" ;; + esac + printf " %-20s %-20s %-18s %b%s\n" "$ts" "$client" "$endpoint" "$event_colored" "$count_str" +} + +_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}" + + 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}" + + local ts_pad + ts_pad=$(printf "%-11s" "$ts") + + 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) + + 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" + + 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::watch::header_table() { + printf "\n %-20s %-8s %-22s %-28s %-14s %s\n" \ + "TIME" "SOURCE" "CLIENT" "DESTINATION/ENDPOINT" "EVENT" "STATUS" + printf " %s\n\n" "$(printf '─%.0s' {1..105})" +} + +function ui::watch::fw_row_table() { + local ts="${1:-}" client="${2:-}" dest="${3:-}" event="${4:-}" status="${5:-}" + printf " %-20s %-8s %-22s %-28s \033[1;31m%-14s\033[0m %s\n" \ + "$ts" "firewall" "$client" "$dest" "$event" "$status" +} + +function ui::watch::wg_row_table() { + local ts="${1:-}" client="${2:-}" endpoint="${3:-}" event="${4:-}" status="${5:-}" + local event_color + case "$event" in + handshake) event_color="\033[1;32m" ;; + attempt) event_color="\033[1;31m" ;; + *) event_color="\033[0;37m" ;; + esac + printf " %-20s %-8s %-22s %-28s %b%-14s\033[0m %s\n" \ + "$ts" "wireguard" "$client" "$endpoint" "$event_color" "$event" "$status" +} \ No newline at end of file