Compare commits

...

3 commits

Author SHA1 Message Date
Nuno Duque Nunes
1cfa5528c8 Merge feature/display-config: tableless layouts and activity monitor (v0.4.0) 2026-05-23 03:28:21 +00:00
Nuno Duque Nunes
4dcf98b128 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
2026-05-23 03:24:20 +00:00
Nuno Duque Nunes
57e08e88c4 feat: rule list tableless layout with inline extends and +all/-N indicators 2026-05-22 23:12:27 +00:00
12 changed files with 1090 additions and 824 deletions

View file

@ -11,10 +11,12 @@ function cmd::logs::on_load() {
flag::register --fw flag::register --fw
flag::register --wg flag::register --wg
flag::register --follow flag::register --follow
flag::register --merged
flag::register --all flag::register --all
flag::register --before flag::register --before
flag::register --force flag::register --force
flag::register --days flag::register --days
flag::register --raw
} }
function cmd::logs::help() { function cmd::logs::help() {
@ -34,7 +36,9 @@ Options for show:
--limit <n> Max results per source (default: 50) --limit <n> Max results per source (default: 50)
--fw Show only firewall drops --fw Show only firewall drops
--wg Show only WireGuard events --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: Options for remove:
--name <name> Remove entries for specific peer --name <name> Remove entries for specific peer
@ -52,12 +56,10 @@ Examples:
wgctl logs wgctl logs
wgctl logs --name phone-nuno wgctl logs --name phone-nuno
wgctl logs --fw --limit 100 wgctl logs --fw --limit 100
wgctl logs --merged
wgctl logs --follow wgctl logs --follow
wgctl logs remove --name phone-nuno wgctl logs remove --name phone-nuno
wgctl logs remove --all --force wgctl logs rotate --days 30
wgctl logs remove --fw --before 1
wgctl logs rotate
wgctl logs rotate --days 30 --force
EOF EOF
} }
@ -70,10 +72,10 @@ function cmd::logs::run() {
fi fi
case "$subcmd" in case "$subcmd" in
show) cmd::logs::show "$@" ;; show) cmd::logs::show "$@" ;;
remove|rm|del) cmd::logs::remove "$@" ;; remove|rm|del) cmd::logs::remove "$@" ;;
rotate) cmd::logs::rotate "$@" ;; rotate) cmd::logs::rotate "$@" ;;
help) cmd::logs::help ;; help) cmd::logs::help ;;
*) *)
log::error "Unknown subcommand: '${subcmd}'" log::error "Unknown subcommand: '${subcmd}'"
cmd::logs::help cmd::logs::help
@ -84,17 +86,19 @@ function cmd::logs::run() {
function cmd::logs::show() { function cmd::logs::show() {
local name="" type="" limit=50 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 while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--name) name="$2"; shift 2 ;; --name) name="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;; --type) type="$2"; shift 2 ;;
--limit) limit="$2"; shift 2 ;; --limit) limit="$2"; shift 2 ;;
--fw) fw_only=true; shift ;; --fw) fw_only=true; shift ;;
--wg) wg_only=true; shift ;; --wg) wg_only=true; shift ;;
--follow|-f) follow=true; shift ;; --merged) merged=true; shift ;;
--help) cmd::logs::help; return ;; --follow|-f) follow=true; shift ;;
--raw) raw=true; shift ;;
--help) cmd::logs::help; return ;;
*) *)
log::error "Unknown flag: $1" log::error "Unknown flag: $1"
return 1 return 1
@ -117,51 +121,171 @@ function cmd::logs::show() {
return return
fi fi
local net_file=""
$raw || net_file="$(ctx::net)"
log::section "WireGuard Activity Log" log::section "WireGuard Activity Log"
printf "\n" 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() { function cmd::logs::follow() {
local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}"
local fw_only="${4:-false}" wg_only="${5:-false}" 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)" log::section "WireGuard Live Log (Ctrl+C to stop)"
printf "\n %-20s %-8s %-20s %-25s %s\n" \ printf "\n"
"TIME" "SOURCE" "CLIENT" "DESTINATION/ENDPOINT" "EVENT"
printf " %s\n" "$(printf '─%.0s' {1..90})"
while IFS="|" read -r source ts client dst_or_endpoint event; do # Delegate to watch command
if [[ "$source" == "fw" ]]; then local watch_args=()
local colored_event [[ -n "$filter_name" ]] && watch_args+=(--name "$filter_name")
case "$event" in [[ -n "$filter_type" ]] && watch_args+=(--type "$filter_type")
tcp) colored_event="\033[1;33mtcp\033[0m" ;; $fw_only && watch_args+=(--restricted)
udp) colored_event="\033[0;36mudp\033[0m" ;; $wg_only && watch_args+=(--blocked)
icmp) colored_event="\033[0;37micmp\033[0m" ;;
*) colored_event="$event" ;; cmd::watch::run "${watch_args[@]}"
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")
} }
function cmd::logs::remove() { function cmd::logs::remove() {
@ -185,7 +309,6 @@ function cmd::logs::remove() {
esac esac
done done
# Validate — need at least one filter
if ! $all && [[ -z "$name" && -z "$before" ]]; then if ! $all && [[ -z "$name" && -z "$before" ]]; then
log::error "Specify --name, --before, or --all" log::error "Specify --name, --before, or --all"
cmd::logs::help cmd::logs::help
@ -198,7 +321,6 @@ function cmd::logs::remove() {
filter_ip=$(peers::get_ip "$name") filter_ip=$(peers::get_ip "$name")
fi fi
# Build description for confirmation
local desc="" local desc=""
$all && desc="all entries" $all && desc="all entries"
[[ -n "$name" ]] && desc="entries for '${name}'" [[ -n "$name" ]] && desc="entries for '${name}'"
@ -209,7 +331,7 @@ function cmd::logs::remove() {
if ! $force; then if ! $force; then
read -r -p "Remove ${desc}? [y/N] " confirm read -r -p "Remove ${desc}? [y/N] " confirm
case "$confirm" in case "$confirm" in
[yY][eE][sS]|[yY]) ;; [yY]*) ;;
*) log::info "Aborted"; return 0 ;; *) log::info "Aborted"; return 0 ;;
esac esac
fi fi
@ -233,69 +355,13 @@ function cmd::logs::remove() {
log::wg_success "Removed ${total} log entries (wg: ${removed_wg}, fw: ${removed_fw})" 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() { function cmd::logs::rotate() {
local days=7 force=false local days=7 force=false
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--days) days="$2"; shift 2 ;; --days) days="$2"; shift 2 ;;
--force) force=true; shift ;; --force) force=true; shift ;;
--help) cmd::logs::help; return ;; --help) cmd::logs::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;; *) log::error "Unknown flag: $1"; return 1 ;;
esac esac

View file

@ -23,10 +23,10 @@ function cmd::rule::on_load() {
flag::register --peer flag::register --peer
flag::register --peers flag::register --peers
flag::register --dns-redirect flag::register --dns-redirect
flag::register --color
flag::register --base flag::register --base
flag::register --no-base flag::register --no-base
flag::register --tree flag::register --tree
flag::register --detailed
flag::register --resolved flag::register --resolved
flag::register --force flag::register --force
flag::register --type flag::register --type
@ -60,7 +60,7 @@ Options for list:
--base Show only base rules --base Show only base rules
--no-base Hide base rules section --no-base Hide base rules section
--group <name> Filter by group (case insensitive) --group <name> Filter by group (case insensitive)
--tree Show full inheritance tree inline --detailed Show rule entries inline
Options for add: Options for add:
--name <name> Rule name --name <name> Rule name
@ -72,8 +72,8 @@ Options for add:
--allow-port <ip:port:proto> Allow specific port (repeatable) --allow-port <ip:port:proto> Allow specific port (repeatable)
--block-ip <ip/cidr> Block IP or subnet (repeatable) --block-ip <ip/cidr> Block IP or subnet (repeatable)
--block-port <ip:port:proto> Block specific port (repeatable) --block-port <ip:port:proto> Block specific port (repeatable)
--block-service <name> Block named service — resolved to IP/port at creation (repeatable) --block-service <name> Block named service (repeatable)
--allow-service <name> Allow named service — resolved to IP/port at creation (repeatable) --allow-service <name> Allow named service (repeatable)
--dns-redirect Force DNS through Pi-hole --dns-redirect Force DNS through Pi-hole
Options for update: Options for update:
@ -85,7 +85,7 @@ Options for update:
--remove-block-ip <ip> Remove block IP entry --remove-block-ip <ip> Remove block IP entry
--remove-block-port <entry> Remove block port entry --remove-block-port <entry> Remove block port entry
Options for show/inspect: Options for show:
--name <name> Rule name --name <name> Rule name
--resolved Show resolved/merged entries --resolved Show resolved/merged entries
--no-peers Hide assigned peers --no-peers Hide assigned peers
@ -96,14 +96,12 @@ Options for reapply:
Examples: Examples:
wgctl rule list wgctl rule list
wgctl rule list --tree wgctl rule list --detailed
wgctl rule list --group "VM Rules" wgctl rule list --group "VM Rules"
wgctl rule show --name guest wgctl rule show --name guest
wgctl rule show --name moonlight-02 --resolved wgctl rule show --name moonlight-02 --resolved
wgctl rule add --name no-proxmox --base --block-service proxmox 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 dev-01 --desc "Dev access" --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 assign --name dev-01 --peer laptop-nuno wgctl rule assign --name dev-01 --peer laptop-nuno
wgctl rule reapply --all wgctl rule reapply --all
EOF EOF
@ -118,17 +116,16 @@ function cmd::rule::run() {
shift || true shift || true
case "$subcmd" in case "$subcmd" in
list|ls) cmd::rule::list "$@" ;; list|ls) cmd::rule::list "$@" ;;
show|inspect) cmd::rule::show "$@" ;; show|inspect) cmd::rule::show "$@" ;;
inspect) cmd::rule::inspect "$@" ;; add|new|create) cmd::rule::add "$@" ;;
add|new|create) cmd::rule::add "$@" ;; update|edit) cmd::rule::update "$@" ;;
update|edit) cmd::rule::update "$@" ;; remove|rm|del|delete) cmd::rule::remove "$@" ;;
remove|rm|del|delete) cmd::rule::remove "$@" ;; assign) cmd::rule::assign "$@" ;;
assign) cmd::rule::assign "$@" ;;
unassign) cmd::rule::unassign "$@" ;; unassign) cmd::rule::unassign "$@" ;;
migrate) cmd::rule::migrate "$@" ;; migrate) cmd::rule::migrate "$@" ;;
reapply) cmd::rule::reapply "$@" ;; reapply) cmd::rule::reapply "$@" ;;
help) cmd::rule::help ;; help) cmd::rule::help ;;
*) *)
log::error "Unknown subcommand: '${subcmd}'" log::error "Unknown subcommand: '${subcmd}'"
cmd::rule::help cmd::rule::help
@ -141,172 +138,98 @@ function cmd::rule::run() {
# List # 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() { function cmd::rule::list() {
local rules_dir local rules_dir
rules_dir="$(ctx::rules)" rules_dir="$(ctx::rules)"
local show_base_only=false local show_base_only=false show_base=true
local show_base=true local filter_group="" detailed=false
local filter_group=""
local show_tree=false
local found_any=false
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--base) show_base_only=true; shift ;; --base) show_base_only=true; shift ;;
--no-base) show_base=false; shift ;; --no-base) show_base=false; shift ;;
--group) util::require_flag "--group" "${2:-}" || return 1 --group) filter_group="${2,,}"; shift 2 ;;
filter_group="${2,,}"; shift 2 ;; --detailed) detailed=true; shift ;;
--tree) show_tree=true; shift ;; --help) cmd::rule::help; return ;;
--help) cmd::rule::help; return ;;
*) *)
log::error "Unknown flag: $1" log::error "Unknown flag: $1"
return 1 ;; return 1 ;;
esac esac
done done
local rules=("${rules_dir}"/*.rule) local data
if [[ ! -f "${rules[0]}" ]]; then data=$(json::rule_list_data "$rules_dir" "$(ctx::meta)")
log::wg "No rules configured" [[ -z "$data" ]] && log::wg "No rules configured" && return 0
return 0
fi
local header_printed=false # Measure max name width
local printing_base=false local w_name=12
local current_group="" 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 \ while IFS="|" read -r name desc n_allows n_blocks \
peer_count extends is_base group; do peer_count extends is_base group; do
[[ -z "$name" ]] && continue [[ -z "$name" ]] && continue
# --base: show ONLY base rules $show_base_only && [[ "$is_base" == "False" ]] && continue
if $show_base_only && [[ "$is_base" == "False" ]]; then ! $show_base && [[ "$is_base" == "True" ]] && continue
continue [[ -n "$filter_group" && "${group,,}" != "$filter_group" ]] && continue
fi
# --no-base: hide base rules found_any=true
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
# Base rules section header # Base rules section header
if [[ "$is_base" == "True" && "$printing_base" == "false" ]]; then if [[ "$is_base" == "True" && "$printing_base" == "false" ]]; then
if ! $show_base_only; then if ! $show_base_only; then
local bdashes ui::rule::list_base_header
bdashes=$(printf '─%.0s' {1..74})
printf "\n \033[0;37m── Base Rules \033[0m%s\n" "$bdashes"
fi fi
printing_base=true printing_base=true
current_group="" current_group=""
fi fi
# Group header — only for non-base rules # Group header — non-base rules only
if [[ "$is_base" == "False" && "${group,,}" != "${current_group,,}" ]]; then if [[ "$is_base" == "False" && "${group,,}" != "${current_group,,}" ]]; then
if [[ -n "$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 elif [[ -n "$current_group" ]]; then
printf "\n" echo ""
fi fi
current_group="$group" current_group="$group"
fi fi
local short_desc="${desc:0:35}" # Rule row
[[ ${#desc} -gt 35 ]] && short_desc="${short_desc}..." # ui::rule::list_row "$name" "$n_allows" "$n_blocks" "$peer_count" "$w_name"
local desc_col_width=40 # Extends
[[ "${short_desc:-}" == "—" ]] && desc_col_width=42 # Rule row — pass extends_csv for compact inline display
local compact_extends=""
found_any=true if [[ -z "$detailed" ]] || ! $detailed; then
compact_extends="$extends"
printf " %-20s %-${desc_col_width}s %-8s %-8s %s\n" \ fi
"$name" "${short_desc:-}" \ ui::rule::list_row "$name" "$n_allows" "$n_blocks" "$peer_count" "$w_name" "$compact_extends"
"$(ui::center "$n_allows" 8)" \
"$(ui::center "$n_blocks" 8)" \ # Detailed mode — show expanded entries
"${peer_count} peers" if $detailed && [[ -n "$extends" ]]; then
ui::rule::list_extends_detailed "$extends" "$rules_dir"
# Print extends echo ""
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"
fi fi
done < <(json::rule_list_data "$rules_dir" "$(ctx::meta)") done <<< "$data"
if ! $found_any; then $found_any || {
if [[ -n "$filter_group" ]]; then [[ -n "$filter_group" ]] && \
log::wg_warning "No rules found in group: ${filter_group}" log::wg_warning "No rules found in group: ${filter_group}" || \
else
log::wg_warning "No rules found" log::wg_warning "No rules found"
fi }
fi
printf "\n" echo ""
} }
# ============================================ # ============================================
@ -318,11 +241,10 @@ function cmd::rule::show() {
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--name) util::require_flag "--name" "${2:-}" || return 1 --name) name="$2"; shift 2 ;;
name="$2"; shift 2 ;; --no-peers) show_peers=false; shift ;;
--no-peers) show_peers=false; shift ;; --resolved) show_resolved=true; shift ;;
--resolved) show_resolved=true; shift ;; --help) cmd::rule::help; return ;;
--help) cmd::rule::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;; *) log::error "Unknown flag: $1"; return 1 ;;
esac esac
done done
@ -333,7 +255,7 @@ function cmd::rule::show() {
local rule_file local rule_file
rule_file="$(rule::path "$name")" rule_file="$(rule::path "$name")"
# ── DNS display ─────────────────────────────── # DNS display
local dns_redirect resolved_dns dns_display local dns_redirect resolved_dns dns_display
dns_redirect=$(rule::get_own "$name" "dns_redirect") dns_redirect=$(rule::get_own "$name" "dns_redirect")
dns_redirect="${dns_redirect:-false}" dns_redirect="${dns_redirect:-false}"
@ -359,24 +281,21 @@ function cmd::rule::show() {
ui::row "DNS" "$dns_display" ui::row "DNS" "$dns_display"
printf "\n" printf "\n"
# ── Extends + own rules ──────────────────────── if ui::rule::tree "$name"; then
if rule::render_extends_tree "$name"; then
# Has inheritance — tree already rendered
: :
else else
# No inheritance — flat view ui::rule::flat "$name"
rule::render_flat "$name"
printf "\n" printf "\n"
fi fi
# ── Resolved ────────────────────────────────── # Resolved view
if $show_resolved; then if $show_resolved; then
cmd::rule::_show_section "Resolved (applied to peers)" ui::rule::section_header "Resolved (applied to peers)"
printf "\n" printf "\n"
local res_allow_ports res_allow_ips res_block_ips res_block_ports local res_allow_ports res_allow_ips res_block_ips res_block_ports
res_allow_ports=$(rule::get "$name" "allow_ports") res_allow_ports=$(rule::get "$name" "allow_ports")
res_allow_ips=$(rule::get "$name" "allow_ips") res_allow_ips=$(rule::get "$name" "allow_ips")
res_block_ips=$(rule::get "$name" "block_ips") res_block_ips=$(rule::get "$name" "block_ips")
res_block_ports=$(rule::get "$name" "block_ports") res_block_ports=$(rule::get "$name" "block_ports")
while IFS= read -r e; do [[ -n "$e" ]] && net::print_entry "+" "$e"; done \ while IFS= read -r e; do [[ -n "$e" ]] && net::print_entry "+" "$e"; done \
<<< "$res_allow_ports"$'\n'"$res_allow_ips" <<< "$res_allow_ports"$'\n'"$res_allow_ips"
@ -385,26 +304,24 @@ function cmd::rule::show() {
printf "\n" printf "\n"
fi fi
# ── Peers ───────────────────────────────────── # Peers
local peer_list=() $show_peers || return 0
mapfile -t peer_list < <(peers::with_rule "$name")
local peer_list=()
mapfile -t peer_list < <(peers::with_rule "$name") || true
local peer_count=${#peer_list[@]} local peer_count=${#peer_list[@]}
ui::empty "$peer_count" && return 0 ui::empty "$peer_count" && return 0
printf "\n" local peer_word="peers"
printf "\033[0;37m── Peers (%s) \033[0m%s\n\n" \ [[ "$peer_count" -eq 1 ]] && peer_word="peer"
"$(color::gray "${peer_count}")" \ printf "\n \033[0;37m── Peers (%s %s) \033[0m%s\n\n" \
"$(printf '\033[0;37m─%.0s' {1..35})" "$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
for peer_name in "${peer_list[@]}"; do local ip
local ip ip=$(peers::get_ip "$peer_name")
ip=$(peers::get_ip "$peer_name") printf " %-28s %s\n" "$peer_name" "$ip"
printf " %-28s %s\n" "$peer_name" "$ip" done
done
fi
printf "\n" printf "\n"
return 0 return 0
} }
@ -418,27 +335,26 @@ function cmd::rule::add() {
local extends=() local extends=()
local allow_ips=() block_ips=() block_ports=() allow_ports=() local allow_ips=() block_ips=() block_ports=() allow_ports=()
local block_services=() allow_services=() local block_services=() allow_services=()
local dns_redirect=false local dns_redirect=false is_base=false
local is_base=false
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--name) name="$2"; shift 2 ;; --name) name="$2"; shift 2 ;;
--desc) desc="$2"; shift 2 ;; --desc) desc="$2"; shift 2 ;;
--group) group="$2"; shift 2 ;; --group) group="$2"; shift 2 ;;
--extends) --extends)
IFS=',' read -ra ext <<< "$2" IFS=',' read -ra ext <<< "$2"
extends+=("${ext[@]}") extends+=("${ext[@]}")
shift 2 ;; shift 2 ;;
--base) is_base=true; shift ;; --base) is_base=true; shift ;;
--allow-ip) allow_ips+=("$2"); shift 2 ;; --allow-ip) allow_ips+=("$2"); shift 2 ;;
--allow-port) allow_ports+=("$2"); shift 2 ;; --allow-port) allow_ports+=("$2"); shift 2 ;;
--block-ip) block_ips+=("$2"); shift 2 ;; --block-ip) block_ips+=("$2"); shift 2 ;;
--block-port) block_ports+=("$2"); shift 2 ;; --block-port) block_ports+=("$2"); shift 2 ;;
--block-service) block_services+=("$2"); shift 2 ;; --block-service) block_services+=("$2"); shift 2 ;;
--allow-service) allow_services+=("$2"); shift 2 ;; --allow-service) allow_services+=("$2"); shift 2 ;;
--dns-redirect) dns_redirect=true; shift ;; --dns-redirect) dns_redirect=true; shift ;;
--help) cmd::rule::help; return ;; --help) cmd::rule::help; return ;;
*) *)
log::error "Unknown flag: $1" log::error "Unknown flag: $1"
return 1 ;; return 1 ;;
@ -452,12 +368,10 @@ function cmd::rule::add() {
return 1 return 1
fi fi
# Validate extends
for ext in "${extends[@]}"; do for ext in "${extends[@]}"; do
rule::require_exists "$ext" || return 1 rule::require_exists "$ext" || return 1
done done
# Determine target directory
local rule_dir local rule_dir
if $is_base; then if $is_base; then
rule_dir="$(ctx::rules)/base" rule_dir="$(ctx::rules)/base"
@ -487,7 +401,6 @@ function cmd::rule::add() {
done done
local rule_file="${rule_dir}/${name}.rule" local rule_file="${rule_dir}/${name}.rule"
local allow_str block_str port_str allow_port_str extends_str local allow_str block_str port_str allow_port_str extends_str
allow_str=$(IFS=','; echo "${allow_ips[*]}") allow_str=$(IFS=','; echo "${allow_ips[*]}")
block_str=$(IFS=','; echo "${block_ips[*]}") block_str=$(IFS=','; echo "${block_ips[*]}")
@ -502,7 +415,6 @@ function cmd::rule::add() {
local base_label="" local base_label=""
$is_base && base_label=" (base)" $is_base && base_label=" (base)"
log::wg_success "Rule created: ${name}${base_label}" log::wg_success "Rule created: ${name}${base_label}"
} }
@ -519,9 +431,9 @@ function cmd::rule::update() {
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--name) name="$2"; shift 2 ;; --name) name="$2"; shift 2 ;;
--desc) desc="$2"; shift 2 ;; --desc) desc="$2"; shift 2 ;;
--group) group="$2"; shift 2 ;; --group) group="$2"; shift 2 ;;
--add-extends) --add-extends)
IFS=',' read -ra ext <<< "$2" IFS=',' read -ra ext <<< "$2"
add_extends+=("${ext[@]}") add_extends+=("${ext[@]}")
@ -530,16 +442,16 @@ function cmd::rule::update() {
IFS=',' read -ra ext <<< "$2" IFS=',' read -ra ext <<< "$2"
rm_extends+=("${ext[@]}") rm_extends+=("${ext[@]}")
shift 2 ;; shift 2 ;;
--allow-ip) allow_ips+=("$2"); shift 2 ;; --allow-ip) allow_ips+=("$2"); shift 2 ;;
--allow-port) allow_ports+=("$2"); shift 2 ;; --allow-port) allow_ports+=("$2"); shift 2 ;;
--block-ip) block_ips+=("$2"); shift 2 ;; --block-ip) block_ips+=("$2"); shift 2 ;;
--block-port) block_ports+=("$2"); shift 2 ;; --block-port) block_ports+=("$2"); shift 2 ;;
--remove-allow-ip) rm_allow_ips+=("$2"); shift 2 ;; --remove-allow-ip) rm_allow_ips+=("$2"); shift 2 ;;
--remove-block-ip) rm_block_ips+=("$2"); shift 2 ;; --remove-block-ip) rm_block_ips+=("$2"); shift 2 ;;
--remove-block-port) rm_block_ports+=("$2"); shift 2 ;; --remove-block-port) rm_block_ports+=("$2"); shift 2 ;;
--remove-allow-port) rm_allow_ports+=("$2"); shift 2 ;; --remove-allow-port) rm_allow_ports+=("$2"); shift 2 ;;
--dns-redirect) dns_redirect=true; shift ;; --dns-redirect) dns_redirect=true; shift ;;
--help) cmd::rule::help; return ;; --help) cmd::rule::help; return ;;
*) *)
log::error "Unknown flag: $1" log::error "Unknown flag: $1"
return 1 ;; return 1 ;;
@ -552,18 +464,15 @@ function cmd::rule::update() {
local rule_file local rule_file
rule_file="$(rule::path "$name")" rule_file="$(rule::path "$name")"
# Update simple fields
[[ -n "$desc" ]] && json::set "$rule_file" "desc" "\"$desc\"" [[ -n "$desc" ]] && json::set "$rule_file" "desc" "\"$desc\""
[[ -n "$group" ]] && json::set "$rule_file" "group" "\"$group\"" [[ -n "$group" ]] && json::set "$rule_file" "group" "\"$group\""
[[ -n "$dns_redirect" ]] && json::set "$rule_file" "dns_redirect" "true" [[ -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 "${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 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 "${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 for p in "${allow_ports[@]}"; do json::append "$rule_file" "allow_ports" "$p"; done
# Add/remove extends
for ext in "${add_extends[@]}"; do for ext in "${add_extends[@]}"; do
rule::require_exists "$ext" || return 1 rule::require_exists "$ext" || return 1
json::append "$rule_file" "extends" "$ext" json::append "$rule_file" "extends" "$ext"
@ -572,7 +481,6 @@ function cmd::rule::update() {
json::remove "$rule_file" "extends" "$ext" json::remove "$rule_file" "extends" "$ext"
done done
# Remove entries
for ip in "${rm_allow_ips[@]}"; do json::remove "$rule_file" "allow_ips" "$ip"; done 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 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 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 # Remove
# (unchanged from original)
# ============================================ # ============================================
function cmd::rule::remove() { function cmd::rule::remove() {
@ -603,7 +510,7 @@ function cmd::rule::remove() {
rule::require_exists "$name" || return 1 rule::require_exists "$name" || return 1
local peer_list=() 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[@]} local peer_count=${#peer_list[@]}
if [[ "$peer_count" -gt 0 ]]; then if [[ "$peer_count" -gt 0 ]]; then
@ -620,6 +527,10 @@ function cmd::rule::remove() {
log::wg_success "Rule removed: ${name}" log::wg_success "Rule removed: ${name}"
} }
# ============================================
# Assign / Unassign
# ============================================
function cmd::rule::assign() { function cmd::rule::assign() {
local name="" peer="" type="" local name="" peer="" type=""
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
@ -635,15 +546,13 @@ function cmd::rule::assign() {
[[ -z "$name" || -z "$peer" ]] && \ [[ -z "$name" || -z "$peer" ]] && \
log::error "Missing required flags: --name and --peer" && return 1 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 rule::require_assignable "$name" || return 1
peer=$(peers::resolve_and_require "$peer" "$type") || 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") existing_rule=$(peers::get_meta "$peer" "rule")
local ip
ip=$(peers::get_ip "$peer") ip=$(peers::get_ip "$peer")
[[ -z "$ip" ]] && log::error "Could not resolve IP for: $peer" && return 1 [[ -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}" log::wg_success "Unassigned rule from: ${peer}"
} }
# ============================================
# Migrate
# ============================================
function cmd::rule::migrate() { function cmd::rule::migrate() {
log::section "Migrating peers to default rules" log::section "Migrating peers to default rules"
local tmp local count=0
tmp=$(mktemp)
while IFS= read -r peer_name; do while IFS= read -r peer_name; do
local existing local existing
existing=$(peers::get_meta "$peer_name" "rule") existing=$(peers::get_meta "$peer_name" "rule")
[[ -n "$existing" ]] && continue [[ -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 rule::exists "$default_rule" || continue
local ip local ip
ip=$(peers::get_ip "$peer_name") ip=$(peers::get_ip "$peer_name")
echo "${peer_name} ${default_rule} ${ip}" >> "$tmp" rule::apply "$default_rule" "$ip" "$peer_name"
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" </dev/null
(( count++ )) || true (( count++ )) || true
done done < <(peers::all)
log::wg_success "Migrated ${count} peers" log::wg_success "Migrated ${count} peers"
} }
# ============================================
# Reapply
# ============================================
function cmd::rule::reapply() { function cmd::rule::reapply() {
local name="" all=false local name="" all=false
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
@ -731,9 +644,8 @@ function cmd::rule::reapply() {
while IFS= read -r rule_file; do while IFS= read -r rule_file; do
local rname local rname
rname=$(basename "$rule_file" .rule) rname=$(basename "$rule_file" .rule)
# Skip if no peers assigned
local peer_list=() local peer_list=()
mapfile -t peer_list < <(peers::with_rule "$rname") mapfile -t peer_list < <(peers::with_rule "$rname") || true
[[ ${#peer_list[@]} -eq 0 ]] && continue [[ ${#peer_list[@]} -eq 0 ]] && continue
rule::reapply_all "$rname" rule::reapply_all "$rname"
(( count++ )) || true (( count++ )) || true
@ -746,20 +658,4 @@ function cmd::rule::reapply() {
rule::require_exists "$name" || return 1 rule::require_exists "$name" || return 1
rule::reapply_all "$name" rule::reapply_all "$name"
log::wg_success "Rule '${name}' reapplied" log::wg_success "Rule '${name}' reapplied"
}
# ============================================
# Show helpers
# ============================================
function cmd::rule::_show_section() {
local title="${1:-}" color="${2:-white}" use_color="${3:-false}"
local color_code=""
if $use_color; then
case "$color" in
green) color_code="\033[0;32m" ;;
red) color_code="\033[0;31m" ;;
esac
fi
printf "\n ${color_code}── %s ──────────────────────────────────\033[0m\n" "$title"
} }

View file

@ -11,6 +11,7 @@ function cmd::watch::on_load() {
flag::register --blocked flag::register --blocked
flag::register --restricted flag::register --restricted
flag::register --allowed flag::register --allowed
flag::register --raw
} }
# ============================================ # ============================================
@ -21,295 +22,27 @@ function cmd::watch::help() {
cat <<EOF cat <<EOF
Usage: wgctl watch [options] Usage: wgctl watch [options]
Live monitor of WireGuard activity. Shows: Live monitor of WireGuard activity.
- Handshakes from connected peers (green) - Handshakes from connected peers (green)
- Connection attempts from blocked peers (red, SOURCE: wg) - Connection attempts from blocked peers (red)
- Firewall drops from rule-restricted peers (red, SOURCE: fw) - Firewall drops from restricted peers (red)
Options: Options:
--name <name> Filter by client name --name <name> Filter by client name
--type <type> Filter by device type --type <type> Filter by device type
--peers <list> Comma-separated peer names (used internally by group watch)
--blocked Show only blocked peer attempts --blocked Show only blocked peer attempts
--allowed Show only handshakes (allowed peers) --allowed Show only handshakes
--restricted Show only firewall drop events --restricted Show only firewall drop events
--raw Show raw IPs without service annotation
Examples: Examples:
wgctl watch wgctl watch
wgctl watch --blocked
wgctl watch --allowed
wgctl watch --type phone
wgctl watch --name phone-nuno wgctl watch --name phone-nuno
wgctl watch --name phone-nuno --type phone wgctl watch --blocked
wgctl watch --type phone
EOF 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 # Run
# ============================================ # ============================================
@ -317,6 +50,7 @@ function cmd::watch::tail_events() {
function cmd::watch::run() { function cmd::watch::run() {
local filter_name="" filter_type="" filter_peers="" local filter_name="" filter_type="" filter_peers=""
local blocked_only=false allowed_only=false restricted_only=false 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 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 ;; --blocked) blocked_only=true; shift ;;
--allowed) allowed_only=true; shift ;; --allowed) allowed_only=true; shift ;;
--restricted) restricted_only=true; shift ;; --restricted) restricted_only=true; shift ;;
--raw) raw=true; shift ;;
--help) cmd::watch::help; return ;; --help) cmd::watch::help; return ;;
*) *)
log::error "Unknown flag: $1" log::error "Unknown flag: $1"
@ -337,27 +72,207 @@ function cmd::watch::run() {
esac esac
done 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 if ! $blocked_only && ! $restricted_only; then
( (
while true; do while true; do
cmd::watch::poll_handshakes \ cmd::watch::_poll_handshakes \
"$filter_name" "$filter_type" "$allowed_only" "$filter_peers" "$filter_name" "$filter_type" "$filter_peers" "$w_client" "$w_dest"
sleep 5 sleep 5
done done
) & ) &
local poller_pid=$! local poller_pid=$!
fi fi
cmd::watch::tail_events \ # Event tailer (background)
"$filter_name" "$filter_type" \ cmd::watch::_tail_events \
"$blocked_only" "$restricted_only" \ "$filter_name" "$filter_type" "$filter_peers" \
"$allowed_only" "$filter_peers" & "$blocked_only" "$restricted_only" "$allowed_only" \
"$net_file" "$w_client" "$w_dest" &
local tailer_pid=$! local tailer_pid=$!
trap "kill $tailer_pid ${poller_pid:-} 2>/dev/null; \ 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 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"
} }

View file

@ -13,6 +13,7 @@ FMT_DATETIME_EU="%d/%m/%Y %H:%M" # 10/05/2026 22:39
# Default — can be overridden in wgctl.conf # Default — can be overridden in wgctl.conf
FMT_DATE="${FMT_DATE_ISO}" FMT_DATE="${FMT_DATE_ISO}"
FMT_DATETIME="${FMT_DATETIME_ISO}" FMT_DATETIME="${FMT_DATETIME_ISO}"
FMT_DATETIME_SHORT="%m-%d %H:%M"
# Load from config or use default # Load from config or use default
_FMT_DATE_FORMAT="${DATE_FORMAT:-iso}" _FMT_DATE_FORMAT="${DATE_FORMAT:-iso}"
@ -62,14 +63,17 @@ function fmt::set_date_format() {
iso) iso)
FMT_DATE="%Y-%m-%d" FMT_DATE="%Y-%m-%d"
FMT_DATETIME="%Y-%m-%d %H:%M" FMT_DATETIME="%Y-%m-%d %H:%M"
FMT_DATETIME_SHORT="%m-%d %H:%M"
;; ;;
eu) eu)
FMT_DATE="%d/%m/%Y" FMT_DATE="%d/%m/%Y"
FMT_DATETIME="%d/%m/%Y %H:%M" FMT_DATETIME="%d/%m/%Y %H:%M"
FMT_DATETIME_SHORT="%d/%m %H:%M"
;; ;;
eu-dash) eu-dash)
FMT_DATE="%d-%m-%Y" FMT_DATE="%d-%m-%Y"
FMT_DATETIME="%d-%m-%Y %H:%M" FMT_DATETIME="%d-%m-%Y %H:%M"
FMT_DATETIME_SHORT="%d-%m %H:%M"
;; ;;
*) log::error "Unknown date format: $format" ;; *) log::error "Unknown date format: $format" ;;
esac esac

View file

@ -12,8 +12,8 @@ function json::has_key() { python3 "$JSON_HELPER" has_key
function json::filter_values() { python3 "$JSON_HELPER" filter_values "$@" </dev/null; } function json::filter_values() { python3 "$JSON_HELPER" filter_values "$@" </dev/null; }
function json::last_event() { python3 "$JSON_HELPER" last_event "$@" </dev/null; } function json::last_event() { python3 "$JSON_HELPER" last_event "$@" </dev/null; }
function json::events_for() { python3 "$JSON_HELPER" events_for "$@" </dev/null; } function json::events_for() { python3 "$JSON_HELPER" events_for "$@" </dev/null; }
function json::fw_events() { WGCTL_DATETIME_FMT="$FMT_DATETIME" python3 "$JSON_HELPER" fw_events "$@" </dev/null; } function json::fw_events() { WGCTL_DATETIME_FMT="$FMT_DATETIME_SHORT" python3 "$JSON_HELPER" fw_events "$@" </dev/null; }
function json::wg_events() { WGCTL_DATETIME_FMT="$FMT_DATETIME" python3 "$JSON_HELPER" wg_events "$@" </dev/null; } function json::wg_events() { WGCTL_DATETIME_FMT="$FMT_DATETIME_SHORT" python3 "$JSON_HELPER" wg_events "$@" </dev/null; }
function json::format_fw_event() { echo "$1" | python3 "$JSON_HELPER" format_fw_event "$2"; } function json::format_fw_event() { echo "$1" | python3 "$JSON_HELPER" format_fw_event "$2"; }
function json::format_wg_event() { echo "$1" | python3 "$JSON_HELPER" format_wg_event; } function json::format_wg_event() { echo "$1" | python3 "$JSON_HELPER" format_wg_event; }
function json::remove_events() { python3 "$JSON_HELPER" remove_events "$@" </dev/null; } function json::remove_events() { python3 "$JSON_HELPER" remove_events "$@" </dev/null; }

View file

@ -154,11 +154,16 @@ def events_for(file, ip, limit):
except: except:
pass pass
def fw_events(file, filter_ip, filter_type, clients_dir, limit): def fw_events(file, filter_ip, filter_type, clients_dir, net_file, limit):
"""Format firewall drop events with dedup and counts""" """
Format firewall drop events with dedup, counts, and service annotation.
Output per line: ts|client|dest_ip|dest_port|proto|service_name|count
"""
import glob import glob
from datetime import datetime
proto_map = {1: 'icmp', 6: 'tcp', 17: 'udp'} proto_map = {1: 'icmp', 6: 'tcp', 17: 'udp'}
# Build ip->name map
ip_to_name = {} ip_to_name = {}
for conf in glob.glob(f"{clients_dir}/*.conf"): for conf in glob.glob(f"{clients_dir}/*.conf"):
name = os.path.basename(conf).replace('.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'): if line.startswith('Address'):
ip = line.split('=')[1].strip().split('/')[0] ip = line.split('=')[1].strip().split('/')[0]
ip_to_name[ip] = name ip_to_name[ip] = name
except: except Exception:
pass 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 = [] events = []
last_seen = {} last_seen = {}
try: try:
with open(file) as f: with open(file) as f:
for line in f: for line in f:
@ -184,114 +217,91 @@ def fw_events(file, filter_ip, filter_type, clients_dir, limit):
continue continue
if filter_ip and src != filter_ip: if filter_ip and src != filter_ip:
continue continue
dst = e.get('dest_ip', '')
port = e.get('dest_port', '') proto_num = int(e.get('ip.protocol', 0))
proto = e.get('ip.protocol', 0) proto = proto_map.get(proto_num, str(proto_num))
key = (src, dst, port, proto) dst = e.get('dest_ip', '')
port = str(e.get('dest_port', ''))
key = (src, dst, port, proto_num)
ts_str = e.get('timestamp', '') ts_str = e.get('timestamp', '')
try: try:
from datetime import datetime
ts = datetime.fromisoformat(ts_str).timestamp() ts = datetime.fromisoformat(ts_str).timestamp()
except: except Exception:
ts = 0 ts = 0
windows = {1: 5, 6: 30, 17: 10} 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: if key in last_seen and (ts - last_seen[key]) < window:
continue continue
last_seen[key] = ts last_seen[key] = ts
events.append(e) events.append(e)
except: except Exception:
pass pass
except: except Exception:
pass pass
# Dedup consecutive same src+dst+port within 60s with count # Second-pass dedup consecutive same events with count
deduped = [] deduped = []
counts = [] counts = []
for e in events: for e in events:
ts_str = e.get('timestamp', '') ts_str = e.get('timestamp', '')
try: try:
from datetime import datetime
ts = datetime.fromisoformat(ts_str).timestamp() ts = datetime.fromisoformat(ts_str).timestamp()
except: except Exception:
ts = 0 ts = 0
src = e.get('src_ip', '')
dst = e.get('dest_ip', '') src = e.get('src_ip', '')
port = e.get('dest_port', '') dst = e.get('dest_ip', '')
proto = e.get('ip.protocol', 0) port = str(e.get('dest_port', ''))
key = (src, dst, port, proto) proto_num = int(e.get('ip.protocol', 0))
key = (src, dst, port, proto_num)
if deduped and counts:
if deduped:
prev = deduped[-1] prev = deduped[-1]
try: try:
prev_ts = datetime.fromisoformat(prev.get('timestamp','')).timestamp() prev_ts = datetime.fromisoformat(prev.get('timestamp', '')).timestamp()
except: except Exception:
prev_ts = 0 prev_ts = 0
prev_key = (prev.get('src_ip',''), prev.get('dest_ip',''), prev_key = (
prev.get('dest_port',''), prev.get('ip.protocol',0)) prev.get('src_ip', ''),
if key == prev_key and (ts - prev_ts) < 60: 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 counts[-1] += 1
continue continue
deduped.append(e) deduped.append(e)
counts.append(1) counts.append(1)
grouped = [] limit = int(limit) if limit else 50
group_counts = [] for e, count in list(zip(deduped, counts))[-limit:]:
for e in deduped: ts_str = e.get('timestamp', '')
ts_str = e.get('timestamp', '') src = e.get('src_ip', '')
try: dst = e.get('dest_ip', '')
from datetime import datetime port = str(e.get('dest_port', ''))
dt = datetime.fromisoformat(ts_str) proto_num = int(e.get('ip.protocol', 0))
# 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)
proto = proto_map.get(proto_num, str(proto_num)) 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) client = ip_to_name.get(src, src)
if filter_type and not client.startswith(filter_type + '-'): svc_name = reverse_lookup(dst, port, proto)
continue
count_str = f" (x{count})" if count > 1 else "" try:
print(f"{ts}|{client}|{dst_str}|{proto}{count_str}") 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): 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 = [] events = []
try: try:
with open(file) as f: 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 + '-'): if filter_type and not client.startswith(filter_type + '-'):
continue continue
events.append(e) events.append(e)
except: except Exception:
pass pass
except: except Exception:
pass pass
# Dedup consecutive same client+event+endpoint within 60s # Dedup consecutive same client+event+endpoint within 60s
deduped = [] deduped = []
counts = [] counts = []
for e in events: for e in events:
ts_str = e.get('timestamp', '') ts_str = e.get('timestamp', '')
try: try:
from datetime import datetime
ts = datetime.fromisoformat(ts_str).timestamp() ts = datetime.fromisoformat(ts_str).timestamp()
except: except Exception:
ts = 0 ts = 0
client = e.get('client', '') client = e.get('client', '')
event = e.get('event', '') event = e.get('event', '')
endpoint = e.get('endpoint', '') endpoint = e.get('endpoint', '')
key = (client, event, endpoint[:15]) key = (client, event, endpoint[:15])
if deduped and counts: if deduped:
prev = deduped[-1] prev = deduped[-1]
prev_ts_str = prev.get('timestamp', '') prev_ts_str = prev.get('timestamp', '')
try: try:
prev_ts = datetime.fromisoformat(prev_ts_str).timestamp() prev_ts = datetime.fromisoformat(prev_ts_str).timestamp()
except: except Exception:
prev_ts = 0 prev_ts = 0
prev_key = (prev.get('client',''), prev.get('event',''), prev.get('endpoint','')[:15]) prev_key = (
if key == prev_key and (ts - prev_ts) < 60: prev.get('client', ''),
prev.get('event', ''),
prev.get('endpoint', '')[:15]
)
if key == prev_key and (ts - prev_ts) < 300:
counts[-1] += 1 counts[-1] += 1
continue continue
deduped.append(e) deduped.append(e)
counts.append(1) counts.append(1)
for e, count in zip(deduped[-int(limit):], counts[-int(limit):]): limit = int(limit) if limit else 50
ts = e.get('timestamp', '') 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: try:
from datetime import datetime dt = datetime.fromisoformat(ts_str)
dt = datetime.fromisoformat(ts) ts_fmt = dt.strftime('%d/%m %H:%M')
ts = dt.strftime(DATETIME_FMT) except Exception:
except: ts_fmt = ts_str
pass
client = e.get('client', '') print(f"{ts_fmt}|{client}|{endpoint}|{event}|{count}")
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}")
def format_fw_event(line, clients_dir): def format_fw_event(line, clients_dir):
"""Format a single fw_event line""" """Format a single fw_event line"""
@ -2448,24 +2461,115 @@ def activity_aggregate(fw_file, wg_file, wg_interface, net_file,
except Exception: except Exception:
pass pass
def reverse_lookup(dest_ip, dest_port, proto): def reverse_lookup(dest_ip, dest_port, proto):
for svc_name, svc in net_data.items(): for svc_name, svc in net_data.items():
if not isinstance(svc, dict): if not isinstance(svc, dict):
continue continue
if svc.get('ip', '') != dest_ip: if svc.get('ip', '') != dest_ip:
continue continue
ports = svc.get('ports', {}) ports = svc.get('ports', {})
if dest_port: if dest_port:
for port_name, port_def in ports.items(): for port_name, port_def in ports.items():
if not isinstance(port_def, dict): 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 continue
if (str(port_def.get('port', '')) == dest_port and if filter_ip and src != filter_ip:
port_def.get('proto', 'tcp') == proto): continue
return f"{svc_name}:{port_name}"
return svc_name proto_num = int(e.get('ip.protocol', 0))
else: proto = proto_map.get(proto_num, str(proto_num))
return svc_name dst = e.get('dest_ip', '')
return '' 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): def make_dest_display(dest_ip, dest_port, proto, svc_name):
if svc_name: if svc_name:
@ -2563,8 +2667,8 @@ commands = {
'filter_values': lambda args: filter_values(args[0], args[1], args[2]), '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]), '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]), '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]), '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]), '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_fw_event': lambda args: format_fw_event(sys.stdin.read(), args[0]),
'format_wg_event': lambda args: format_wg_event(sys.stdin.read()), 'format_wg_event': lambda args: format_wg_event(sys.stdin.read()),
'remove_events': lambda args: remove_events(args[0], args[1]), 'remove_events': lambda args: remove_events(args[0], args[1]),

View file

@ -64,6 +64,29 @@ for s in sys.argv[1:]:
" "$@" " "$@"
} }
# ui::vis_len <string>
# 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 <string_bytes_printed> <target_visible_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() { function ui::pad_status() {
ui::pad "${1:-}" "${2:-25}" ui::pad "${1:-}" "${2:-25}"

View file

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

View file

@ -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_MED_BYTES="${ACTIVITY_CURRENT_MED_BYTES:-10000000}"
declare -g _ACTIVITY_CURRENT_HIGH_BYTES="${ACTIVITY_CURRENT_HIGH_BYTES:-100000000}" declare -g _ACTIVITY_CURRENT_HIGH_BYTES="${ACTIVITY_CURRENT_HIGH_BYTES:-100000000}"
function config::_init_defaults() { function config::_init_defaults() {
_WG_INTERFACE="${WG_INTERFACE:-wg0}" _WG_INTERFACE="${WG_INTERFACE:-wg0}"
_WG_DNS="${WG_DNS:-10.0.0.103}" _WG_DNS="${WG_DNS:-10.0.0.103}"

View file

@ -333,21 +333,14 @@ function rule::restore_all() {
# Rendering # Rendering
# ============================================ # ============================================
function rule::render_flat() { # ======================================================
ui::rule::flat "$1" # Aliases (for backward compat — remove in cleanup pass)
} # ======================================================
function rule::render_entries() { function rule::render_flat() { ui::rule::flat "$1"; }
ui::rule::entries "$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"; }
function rule::render_own_entries() {
ui::rule::own_entries "$1"
}
function rule::render_extends_tree() {
ui::rule::tree "$1"
}
# ============================================ # ============================================
# DNS Redirect # DNS Redirect

167
modules/ui/logs.module.sh Normal file
View file

@ -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"
}

View file

@ -11,24 +11,24 @@
# Renders the fully resolved entries for a rule (allow + block). # Renders the fully resolved entries for a rule (allow + block).
function ui::rule::entries() { function ui::rule::entries() {
local rule_name="${1:-}" indent="${2:-4}" local rule_name="${1:-}" indent="${2:-4}"
local allow_ports allow_ips block_ips block_ports dns local allow_ports allow_ips block_ips block_ports dns
allow_ports=$(rule::get "$rule_name" "allow_ports" 2>/dev/null || true) allow_ports=$(rule::get "$rule_name" "allow_ports" 2>/dev/null || true)
allow_ips=$(rule::get "$rule_name" "allow_ips" 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_ips=$(rule::get "$rule_name" "block_ips" 2>/dev/null || true)
block_ports=$(rule::get "$rule_name" "block_ports" 2>/dev/null || true) block_ports=$(rule::get "$rule_name" "block_ports" 2>/dev/null || true)
dns=$(rule::get_own "$rule_name" "dns_redirect") dns=$(rule::get_own "$rule_name" "dns_redirect")
while IFS= read -r e; do while IFS= read -r e; do
[[ -z "$e" ]] && continue [[ -z "$e" ]] && continue
net::print_entry "+" "$e" "$indent" net::print_entry "+" "$e" "$indent"
done <<< "$allow_ports"$'\n'"$allow_ips" done <<< "$allow_ports"$'\n'"$allow_ips"
while IFS= read -r e; do while IFS= read -r e; do
[[ -z "$e" ]] && continue [[ -z "$e" ]] && continue
net::print_entry "-" "$e" "$indent" net::print_entry "-" "$e" "$indent"
done <<< "$block_ips"$'\n'"$block_ports" done <<< "$block_ips"$'\n'"$block_ports"
[[ "${dns,,}" == "true" ]] && \ [[ "${dns,,}" == "true" ]] && \
net::print_dns_redirect "$(config::dns)" 6 "DNS" net::print_dns_redirect "$(config::dns)" 6 "DNS"
} }
@ -40,27 +40,27 @@ function ui::rule::own_entries() {
local rule_file local rule_file
rule_file="$(rule::path "$rule_name")" || return 0 rule_file="$(rule::path "$rule_name")" || return 0
[[ -z "$rule_file" ]] && return 0 [[ -z "$rule_file" ]] && return 0
local allow_ports allow_ips block_ips block_ports dns local allow_ports allow_ips block_ips block_ports dns
allow_ports=$(json::get "$rule_file" "allow_ports" 2>/dev/null || true) allow_ports=$(json::get "$rule_file" "allow_ports" 2>/dev/null || true)
allow_ips=$(json::get "$rule_file" "allow_ips" 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_ips=$(json::get "$rule_file" "block_ips" 2>/dev/null || true)
block_ports=$(json::get "$rule_file" "block_ports" 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) dns=$(json::get "$rule_file" "dns_redirect" 2>/dev/null || true)
local combined="${allow_ports}${allow_ips}${block_ips}${block_ports}" local combined="${allow_ports}${allow_ips}${block_ips}${block_ports}"
[[ -z "${combined//[$'\n']/}" ]] && return 0 [[ -z "${combined//[$'\n']/}" ]] && return 0
while IFS= read -r e; do while IFS= read -r e; do
[[ -z "$e" ]] && continue [[ -z "$e" ]] && continue
net::print_entry "+" "$e" "$indent" net::print_entry "+" "$e" "$indent"
done <<< "$allow_ports"$'\n'"$allow_ips" done <<< "$allow_ports"$'\n'"$allow_ips"
while IFS= read -r e; do while IFS= read -r e; do
[[ -z "$e" ]] && continue [[ -z "$e" ]] && continue
net::print_entry "-" "$e" "$indent" net::print_entry "-" "$e" "$indent"
done <<< "$block_ips"$'\n'"$block_ports" done <<< "$block_ips"$'\n'"$block_ports"
[[ "${dns,,}" == "true" ]] && \ [[ "${dns,,}" == "true" ]] && \
net::print_dns_redirect "$(config::dns)" 6 "DNS" 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. # Renders the full resolved entries as a flat list.
function ui::rule::flat() { function ui::rule::flat() {
local rule_name="${1:-}" local rule_name="${1:-}"
local allow_ports allow_ips block_ips block_ports dns local allow_ports allow_ips block_ips block_ports dns
allow_ports=$(rule::get "$rule_name" "allow_ports") allow_ports=$(rule::get "$rule_name" "allow_ports")
allow_ips=$(rule::get "$rule_name" "allow_ips") allow_ips=$(rule::get "$rule_name" "allow_ips")
block_ips=$(rule::get "$rule_name" "block_ips") block_ips=$(rule::get "$rule_name" "block_ips")
block_ports=$(rule::get "$rule_name" "block_ports") block_ports=$(rule::get "$rule_name" "block_ports")
dns=$(rule::get_own "$rule_name" "dns_redirect") dns=$(rule::get_own "$rule_name" "dns_redirect")
local has_content=false local has_content=false
[[ -n "${allow_ports}${allow_ips}${block_ips}${block_ports}" ]] && has_content=true [[ -n "${allow_ports}${allow_ips}${block_ips}${block_ports}" ]] && has_content=true
if ! $has_content; then if ! $has_content; then
printf "\n full access (no restrictions)\n" printf "\n full access (no restrictions)\n"
return 0 return 0
fi fi
if [[ -n "$allow_ports" || -n "$allow_ips" ]]; then if [[ -n "$allow_ports" || -n "$allow_ips" ]]; then
printf "\n" printf "\n"
while IFS= read -r e; do while IFS= read -r e; do
@ -92,7 +92,7 @@ function ui::rule::flat() {
net::print_entry "+" "$e" 2 net::print_entry "+" "$e" 2
done <<< "$allow_ports"$'\n'"$allow_ips" done <<< "$allow_ports"$'\n'"$allow_ips"
fi fi
if [[ -n "$block_ips" || -n "$block_ports" ]]; then if [[ -n "$block_ips" || -n "$block_ports" ]]; then
printf "\n" printf "\n"
while IFS= read -r e; do while IFS= read -r e; do
@ -100,7 +100,7 @@ function ui::rule::flat() {
net::print_entry "-" "$e" 2 net::print_entry "-" "$e" 2
done <<< "$block_ips"$'\n'"$block_ports" done <<< "$block_ips"$'\n'"$block_ports"
fi fi
[[ "${dns,,}" == "true" ]] && \ [[ "${dns,,}" == "true" ]] && \
net::print_dns_redirect "$(config::dns)" 6 "DNS" 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 entry_indent="${2:-6}" label_indent="${3:-4}"
local label_pad local label_pad
label_pad=$(printf '%*s' "$label_indent" '') label_pad=$(printf '%*s' "$label_indent" '')
local first=true local first=true
for base_name in "${_bases[@]}"; do for base_name in "${_bases[@]}"; do
[[ -z "$base_name" ]] && continue [[ -z "$base_name" ]] && continue
@ -140,23 +140,23 @@ function ui::rule::tree() {
local rule_file local rule_file
rule_file="$(rule::path "$rule_name")" || return 1 rule_file="$(rule::path "$rule_name")" || return 1
[[ -z "$rule_file" ]] && return 1 [[ -z "$rule_file" ]] && return 1
local extends_raw=() local extends_raw=()
mapfile -t extends_raw < <(json::get "$rule_file" "extends" 2>/dev/null || true) || true mapfile -t extends_raw < <(json::get "$rule_file" "extends" 2>/dev/null || true) || true
if [[ ${#extends_raw[@]} -eq 0 || -z "${extends_raw[0]:-}" ]]; then if [[ ${#extends_raw[@]} -eq 0 || -z "${extends_raw[0]:-}" ]]; then
return 1 return 1
fi fi
ui::rule::_render_bases extends_raw 6 4 ui::rule::_render_bases extends_raw 6 4
local own_output local own_output
own_output=$(ui::rule::own_entries "$rule_name" 6) own_output=$(ui::rule::own_entries "$rule_name" 6)
if [[ -n "$own_output" ]]; then if [[ -n "$own_output" ]]; then
printf "\n \033[0;37mOwn:\033[0m\n" printf "\n \033[0;37mOwn:\033[0m\n"
printf "%s\n" "$own_output" printf "%s\n" "$own_output"
fi fi
return 0 return 0
} }
@ -194,16 +194,15 @@ function ui::rule::_identity_rule_entry() {
local rule_name="${1:-}" local rule_name="${1:-}"
local rule_file local rule_file
rule_file="$(rule::path "$rule_name")" || return 0 rule_file="$(rule::path "$rule_name")" || return 0
printf " \033[0;37m↳ %s\033[0m\n" "$rule_name" printf " \033[0;37m↳ %s\033[0m\n" "$rule_name"
local extends_raw=() local extends_raw=()
mapfile -t extends_raw < <(json::get "$rule_file" "extends" 2>/dev/null || true) || true mapfile -t extends_raw < <(json::get "$rule_file" "extends" 2>/dev/null || true) || true
if [[ ${#extends_raw[@]} -gt 0 && -n "${extends_raw[0]:-}" ]]; then if [[ ${#extends_raw[@]} -gt 0 && -n "${extends_raw[0]:-}" ]]; then
# Rule has extends — render one level deep using shared helper
ui::rule::_render_bases extends_raw 10 8 ui::rule::_render_bases extends_raw 10 8
local own_output local own_output
own_output=$(ui::rule::own_entries "$rule_name" 10) own_output=$(ui::rule::own_entries "$rule_name" 10)
if [[ -n "$own_output" ]]; then if [[ -n "$own_output" ]]; then
@ -211,7 +210,6 @@ function ui::rule::_identity_rule_entry() {
printf "%s\n" "$own_output" printf "%s\n" "$own_output"
fi fi
else else
# Leaf rule — show own entries or note full access
local own_output local own_output
own_output=$(ui::rule::own_entries "$rule_name" 8) own_output=$(ui::rule::own_entries "$rule_name" 8)
if [[ -n "$own_output" ]]; then if [[ -n "$own_output" ]]; then
@ -254,4 +252,103 @@ function ui::rule::_peer_rule_entry() {
printf " \033[2mfull access (no restrictions)\033[0m\n" printf " \033[2mfull access (no restrictions)\033[0m\n"
fi fi
fi fi
}
# ======================================================
# List Rendering
# ======================================================
# ui::rule::list_group_header <group_name>
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 <name> <n_allows> <n_blocks> <peer_count> <w_name>
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 <extends_csv>
# 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 <extends_csv> <rules_dir>
# 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"
} }