feat: rule inheritance, rule groups, rule inspect, ui::center, fw dedup, activity metrics

This commit is contained in:
Nuno Duque Nunes 2026-05-13 22:44:07 +00:00
parent a09c59a7c4
commit 6ac1a7d3a2
18 changed files with 2147 additions and 379 deletions

View file

@ -205,9 +205,9 @@ function cmd::fw::_print_filtered() {
local show_nflog="$1" show_accept="$2" show_drop="$3"
while IFS= read -r rule; do
[[ -z "$rule" ]] && continue
! $show_nflog && [[ "$rule" =~ NFLOG ]] && continue
! $show_accept && [[ "$rule" =~ ACCEPT ]] && continue
! $show_drop && [[ "$rule" =~ DROP ]] && continue
if ! $show_nflog && [[ "$rule" =~ NFLOG ]]; then continue; fi
if ! $show_accept && [[ "$rule" =~ ACCEPT ]]; then continue; fi
if ! $show_drop && [[ "$rule" =~ DROP ]]; then continue; fi
ui::firewall_rule "$rule"
done
}

View file

@ -37,39 +37,50 @@ function cmd::inspect::_section() {
printf "\n \033[0;37m── %s ──────────────────────────────────\033[0m\n" "$title"
}
function cmd::inspect::_row() {
local label="$1" value="$2"
printf " %-20s %s\n" "${label}:" "$value"
}
function cmd::inspect::_peer_info() {
local name="$1"
local ip type rule status endpoint last_seen tunnel public_key
local name="${1:-}"
local ip type rule public_key allowed_ips
ip=$(peers::get_ip "$name")
type=$(peers::get_type "$name")
rule=$(peers::get_meta "$name" "rule")
public_key=$(keys::public "$name" 2>/dev/null)
public_key=$(keys::public "$name" 2>/dev/null || echo "")
allowed_ips=$(grep "^AllowedIPs" "$(ctx::clients)/${name}.conf" \
2>/dev/null | cut -d'=' -f2- | xargs)
# Status + endpoint + last seen — reuse list helpers
status=$(cmd::list::format_status "$name" "$public_key" "$ip")
last_seen=$(cmd::list::format_last_seen "$name" "$public_key" "$ip")
# Status
local handshake_ts is_blocked last_ts
handshake_ts=$(monitor::get_handshake_ts "$public_key")
peers::is_blocked "$name" && is_blocked="true" || is_blocked="false"
last_ts=$(monitor::last_attempt "$name")
local status last_seen endpoint
status=$(peers::format_status "$name" "$public_key" \
"$is_blocked" "false" "$handshake_ts" "$last_ts")
last_seen=$(peers::format_last_seen "$name" "$public_key" \
"$is_blocked" "$last_ts" "" "$handshake_ts")
endpoint=$(monitor::get_cached_endpoint "$name")
# Tunnel mode from AllowedIPs in conf
local allowed_ips
allowed_ips=$(grep "^AllowedIPs" "$(ctx::clients)/${name}.conf" 2>/dev/null | cut -d'=' -f2- | xargs)
local activity_total
activity_total=$(peers::format_activity_total "$public_key")
local activity_current
activity_current=$(peers::format_activity_current "$public_key")
local subtype
subtype=$(peers::get_meta "$name" "subtype")
cmd::inspect::_section "Client"
cmd::inspect::_row "Name" "$name"
cmd::inspect::_row "IP" "$ip"
cmd::inspect::_row "Type" "$(cmd::list::display_type "$name" "$type")"
cmd::inspect::_row "Rule" "${rule:-}"
cmd::inspect::_row "Status" "$(echo -e "$status")"
cmd::inspect::_row "Endpoint" "${endpoint:-}"
cmd::inspect::_row "Last seen" "$last_seen"
cmd::inspect::_row "AllowedIPs" "$allowed_ips"
cmd::inspect::_row "Public key" "${public_key:-}"
ui::row "Name" "$name"
ui::row "IP" "$ip"
ui::row "Type" "$(peers::display_type "$type" "$subtype")"
ui::row "Rule" "${rule:-}"
ui::row "Status" "$(echo -e "$status")"
ui::row "Endpoint" "${endpoint:-}"
ui::row "Last seen" "$last_seen"
ui::row "AllowedIPs" "$allowed_ips"
ui::row "Public key" "${public_key:-}"
ui::row "Activity" "Total: $activity_total | Current: $activity_current"
}
function cmd::inspect::_rule_info() {

View file

@ -51,125 +51,6 @@ Examples:
EOF
}
# ============================================
# Status / Display helpers
# ============================================
function cmd::list::_is_connected() {
local ts="$1"
[[ -z "$ts" || "$ts" == "0" ]] && return 1
local now diff
now=$(date +%s)
diff=$(( now - ts ))
(( diff < 180 ))
}
function cmd::list::_is_attempting() {
local last_ts="$1"
[[ -z "$last_ts" ]] && return 1
local now attempt_ts diff
now=$(date +%s)
attempt_ts=$(json::iso_to_ts "$last_ts")
[[ -z "$attempt_ts" || "$attempt_ts" == "0" ]] && return 1
diff=$(( now - attempt_ts ))
(( diff < 180 ))
}
function cmd::list::_format_last_seen() {
local name="$1"
local pubkey="$2"
local is_blocked="${3:-0}"
local last_ts="${4:-0}"
local last_evt="${5:-0}"
local handshake_ts="${6:-0}"
if [[ "$is_blocked" == "true" ]]; then
if [[ -n "$last_ts" && "$last_ts" != "0" && "$last_ts" != "null" ]]; then
local formatted
formatted=$(fmt::datetime_iso "$last_ts")
echo "${formatted} (dropped)"
else
echo "—"
fi
else
if [[ -z "$handshake_ts" || "$handshake_ts" == "0" ]]; then
echo "—"
else
local formatted
formatted=$(fmt::datetime "$handshake_ts")
echo "${formatted} (handshake)"
fi
fi
}
function cmd::list::_format_status() {
local name="$1"
local pubkey="$2"
local is_blocked="${3:-false}"
local is_restricted="${4:-false}"
local handshake_ts="${5:-0}"
local last_ts="${6:-}"
local connected=false modifier="" color
if [[ "$is_blocked" == "true" ]]; then
cmd::list::_is_attempting "$last_ts" && connected=true
modifier=" (blocked)"
color="\033[1;31m"
elif [[ "$is_restricted" == "true" ]]; then
cmd::list::_is_connected "$handshake_ts" && connected=true
modifier=" (restricted)"
color="\033[1;33m"
else
cmd::list::_is_connected "$handshake_ts" && connected=true
modifier=""
if $connected; then
color="\033[1;32m"
else
color="\033[0;37m"
fi
fi
local conn_str
$connected && conn_str="online" || conn_str="offline"
echo -e "${color}${conn_str}${modifier}\033[0m"
}
function peers::get_type() {
local ip="$1"
local type="unknown"
for t in $(config::device_types); do
local subnet
subnet=$(config::subnet_for "$t")
if string::starts_with "$ip" "${subnet}."; then
type="$t"
break
fi
done
echo "$type"
}
function cmd::list::display_type() {
local name="${1:-0}"
local type="${2:-0}"
local subtype="${3:-}"
if config::is_guest_type "$type" && [[ -n "$subtype" ]]; then
echo "guest/${subtype}"
elif config::is_guest_type "$type"; then
echo "guest"
else
echo "$type"
fi
}
function cmd::list::pad_status() {
local status="$1"
local width="${2:-25}"
local visible
visible=$(echo -e "$status" | sed 's/\x1b\[[0-9;]*m//g')
local pad=$(( width - ${#visible} ))
printf "%b%${pad}s" "$status" ""
}
# ============================================
# Header / Footer
# ============================================
@ -224,7 +105,7 @@ function cmd::list::_render_summary() {
# ============================================
function cmd::list::show_client() {
local name="$1"
local name="${1:-}"
local conf
conf="$(ctx::clients)/${name}.conf"
@ -233,16 +114,26 @@ function cmd::list::show_client() {
return 1
fi
local ip allowed_ips public_key
local ip
ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1)
local allowed_ips
allowed_ips=$(grep "^AllowedIPs" "$conf" | cut -d'=' -f2- | xargs)
local public_key
public_key=$(keys::public "$name" 2>/dev/null || echo "unknown")
local type
type=$(peers::get_type "$ip")
type=$(peers::get_type_from_ip "$ip")
local subtype
subtype=$(peers::get_meta "$name" "subtype")
local endpoint="—"
if peers::is_blocked "$name"; then
local is_blocked="false"
peers::is_blocked "$name" && is_blocked="true"
if [[ "$is_blocked" == "true" ]]; then
local ep
ep=$(monitor::last_endpoint "$name")
[[ -n "$ep" ]] && endpoint="$ep"
@ -252,22 +143,18 @@ function cmd::list::show_client() {
[[ -n "$ep" ]] && endpoint="$ep"
fi
# Get handshake and last attempt for status/last_seen
local handshake_ts
handshake_ts=$(wg show "$(config::interface)" latest-handshakes 2>/dev/null \
| grep "^${public_key}" | awk '{print $2}')
handshake_ts="${handshake_ts:-0}"
handshake_ts=$(monitor::get_handshake_ts "$public_key")
local last_ts
last_ts=$(monitor::last_attempt "$name")
local is_blocked="false"
peers::is_blocked "$name" && is_blocked="true"
local status last_seen
status=$(cmd::list::_format_status "$name" "$public_key" \
local status
status=$(peers::format_status "$name" "$public_key" \
"$is_blocked" "false" "$handshake_ts" "$last_ts")
last_seen=$(cmd::list::_format_last_seen "$name" "$public_key" \
local last_seen
last_seen=$(peers::format_last_seen "$name" "$public_key" \
"$is_blocked" "$last_ts" "" "$handshake_ts")
local block_file
@ -286,13 +173,13 @@ function cmd::list::show_client() {
fi
ui::section "Client: ${name}"
ui::row "IP" "$ip"
ui::row "Type" "$type"
ui::row "Status" "$(echo -e "$status")"
ui::row "Endpoint" "$endpoint"
ui::row "Last seen" "$last_seen"
ui::row "AllowedIPs" "$allowed_ips"
ui::row "Public key" "$public_key"
ui::row "IP" "$ip"
ui::row "Type" "$(peers::display_type "$type" "$subtype")"
ui::row "Status" "$(echo -e "$status")"
ui::row "Endpoint" "$endpoint"
ui::row "Last seen" "$last_seen"
ui::row "AllowedIPs" "$allowed_ips"
ui::row "Public key" "$public_key"
if [[ -z "$blocks" ]]; then
ui::row "Blocks" "none"
@ -305,14 +192,6 @@ function cmd::list::show_client() {
printf "\n"
}
# Keep old format_status/format_last_seen for backward compat
function cmd::list::format_status() { cmd::list::_format_status "$@"; }
function cmd::list::format_last_seen() { cmd::list::_format_last_seen "$@"; }
function cmd::list::is_blocked() { peers::is_blocked "$1"; }
function cmd::list::is_restricted() { [[ -f "$(ctx::block::path "${1}.block")" ]]; }
function cmd::list::is_connected() { cmd::list::_is_connected "$@"; }
function cmd::list::is_attempting() { cmd::list::_is_attempting "$@"; }
# ============================================
# Run
# ============================================
@ -404,7 +283,7 @@ function cmd::list::_iter_confs() {
ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1)
fi
local type
type=$(peers::get_type "$ip")
type=$(peers::get_type_from_ip "$ip")
[[ -n "$filter_type" && "$type" != "$filter_type" ]] && continue
"$callback" "$client_name" "$ip" "$type"
done
@ -420,8 +299,8 @@ function cmd::list::_render_row() {
local last_ts="${p_last_ts[$client_name]:-}"
# Apply status filters
if $online_only; then cmd::list::_is_connected "$handshake_ts" || return 0; fi
if $offline_only; then cmd::list::_is_connected "$handshake_ts" && return 0; fi
if $online_only; then peers::is_online "$client_name" "$handshake_ts" "$last_ts" || return 0; fi
if $offline_only; then peers::is_offline "$client_name" "$handshake_ts" "$last_ts" || return 0; fi
if $restricted_only && [[ "$is_restricted" != "true" ]]; then return 0; fi
if $blocked_only && [[ "$is_blocked" != "true" ]]; then return 0; fi
if $allowed_only && { [[ "$is_blocked" == "true" ]] || \
@ -434,12 +313,11 @@ function cmd::list::_render_row() {
# Format display values
local status last_seen display_type rule group_display
status=$(cmd::list::_format_status "$client_name" "$pubkey" \
status=$(peers::format_status "$client_name" "$pubkey" \
"$is_blocked" "$is_restricted" "$handshake_ts" "$last_ts")
last_seen=$(cmd::list::_format_last_seen "$client_name" "$pubkey" \
last_seen=$(peers::format_last_seen "$client_name" "$pubkey" \
"$is_blocked" "$last_ts" "" "$handshake_ts")
display_type=$(cmd::list::display_type "$client_name" "$type" \
"${p_subtypes[$client_name]:-}")
display_type=$(peers::display_type "$type" "${p_subtypes[$client_name]:-}")
rule="${p_rules[$client_name]:-}"
if [[ -n "$filter_rule" && "$rule" != "$filter_rule" ]]; then return 0; fi
@ -456,7 +334,7 @@ function cmd::list::_render_row() {
# Pad status
local padded_status
padded_status=$(cmd::list::pad_status "$status" 25)
padded_status=$(ui::pad_status "$status" 25)
# Render row
if $has_groups; then
@ -545,6 +423,15 @@ function cmd::list::_precompute_all() {
[[ -n "$peer_name" ]] && peer_group_map["$peer_name"]="$group_name"
done < <(json::peer_group_map "$groups_dir")
fi
# Transfer/activity data — keyed by pubkey
declare -gA p_rx=() p_tx=() p_activity=()
while IFS="|" read -r pubkey rx tx level; do
[[ -z "$pubkey" ]] && continue
p_rx["$pubkey"]="$rx"
p_tx["$pubkey"]="$tx"
p_activity["$pubkey"]="$level"
done < <(json::peer_transfer "$(config::interface)")
}
function cmd::list::_build_filter_desc() {

View file

@ -11,35 +11,51 @@ function cmd::logs::on_load() {
flag::register --fw
flag::register --wg
flag::register --follow
flag::register --all
flag::register --before
flag::register --force
}
function cmd::logs::help() {
cat <<EOF
Usage: wgctl logs [options]
Usage: wgctl logs [subcommand] [options]
Show WireGuard and firewall activity logs.
Show or manage WireGuard and firewall activity logs.
Options:
Subcommands:
show (default) Show activity logs
remove, rm Remove log entries
Options for show:
--name <name> Filter by client name
--type <type> Filter by device type
--since <time> Time filter (e.g. 1h, 24h, 7d)
--limit <n> Max results per source (default 50)
--limit <n> Max results per source (default: 50)
--fw Show only firewall drops
--wg Show only WireGuard events
--follow, -f Follow logs in real time
Options for remove:
--name <name> Remove entries for specific peer
--all Remove all log entries
--fw Remove only firewall events
--wg Remove only WireGuard events
--before <days> Remove entries older than N days
--force Skip confirmation
Examples:
wgctl logs
wgctl logs --name guest-test
wgctl logs --type guest
wgctl logs --since 1h
wgctl logs --name phone-nuno
wgctl logs --fw --limit 100
wgctl logs --follow
wgctl logs remove --name phone-nuno
wgctl logs remove --all --force
wgctl logs remove --before 7
wgctl logs remove --fw --before 1
EOF
}
function cmd::logs::run() {
local subcmd="${1:-show}"
# Check if first arg is a flag
if [[ "$subcmd" == --* ]]; then
subcmd="show"
else
@ -59,19 +75,18 @@ function cmd::logs::run() {
}
function cmd::logs::show() {
local name="" type="" since="" limit=50
local name="" type="" limit=50
local fw_only=false wg_only=false follow=false
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;;
--since) since="$2"; shift 2 ;;
--limit) limit="$2"; shift 2 ;;
--fw) fw_only=true; shift ;;
--wg) wg_only=true; shift ;;
--follow|-f) follow=true; shift ;;
--help) cmd::logs::help; return ;;
--name) name="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;;
--limit) limit="$2"; shift 2 ;;
--fw) fw_only=true; shift ;;
--wg) wg_only=true; shift ;;
--follow|-f) follow=true; shift ;;
--help) cmd::logs::help; return ;;
*)
log::error "Unknown flag: $1"
return 1
@ -104,10 +119,8 @@ function cmd::logs::follow() {
local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}"
local fw_only="${4:-false}" wg_only="${5:-false}"
local filter_peers="${6:-}"
local clients_dir
clients_dir="$(ctx::clients)"
local wg_log="$WG_EVENTS_LOG"
local fw_log="$FW_EVENTS_LOG"
$fw_only && wg_log=""
@ -139,18 +152,24 @@ function cmd::logs::follow() {
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")
done < <(json::follow_logs "$fw_log" "$wg_log" "$filter_ip" "$filter_type" \
"$clients_dir" "$filter_peers")
}
function cmd::logs::remove() {
local name="" type="" force=false
local name="" type="" before="" force=false
local fw_only=false wg_only=false all=false
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;;
--force) force=true; shift ;;
--help) cmd::logs::help; return ;;
--name) name="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;;
--before) before="$2"; shift 2 ;;
--fw) fw_only=true; shift ;;
--wg) wg_only=true; shift ;;
--all) all=true; shift ;;
--force) force=true; shift ;;
--help) cmd::logs::help; return ;;
*)
log::error "Unknown flag: $1"
return 1
@ -158,50 +177,56 @@ function cmd::logs::remove() {
esac
done
if [[ -z "$name" ]]; then
log::error "Missing required flag: --name"
# Validate — need at least one filter
if ! $all && [[ -z "$name" && -z "$before" ]]; then
log::error "Specify --name, --before, or --all"
cmd::logs::help
return 1
fi
name=$(peers::resolve_and_require "$name" "$type") || return 1
local client_ip
client_ip=$(peers::get_ip "$name")
local before_wg before_fw after_wg after_fw
before_wg=$(wc -l < "$WG_EVENTS_LOG" 2>/dev/null || echo 0)
before_fw=$(wc -l < "$FW_EVENTS_LOG" 2>/dev/null || echo 0)
json::remove_events "$WG_EVENTS_LOG" "$name"
json::remove_events "$FW_EVENTS_LOG" "$client_ip"
after_wg=$(wc -l < "$WG_EVENTS_LOG" 2>/dev/null || echo 0)
after_fw=$(wc -l < "$FW_EVENTS_LOG" 2>/dev/null || echo 0)
local removed=$(( (before_wg - after_wg) + (before_fw - after_fw) ))
if [[ "$removed" -eq 0 ]]; then
log::wg_warning "No log entries found for: ${name}"
return 0
local filter_ip=""
if [[ -n "$name" ]]; then
name=$(peers::resolve_and_require "$name" "$type") || return 1
filter_ip=$(peers::get_ip "$name")
fi
# Build description for confirmation
local desc=""
$all && desc="all entries"
[[ -n "$name" ]] && desc="entries for '${name}'"
[[ -n "$before" ]] && desc="${desc:+$desc, }entries older than ${before} days"
$fw_only && desc="${desc} (fw only)"
$wg_only && desc="${desc} (wg only)"
if ! $force; then
read -r -p "Remove all log entries for '${name}'? [y/N] " confirm
read -r -p "Remove ${desc}? [y/N] " confirm
case "$confirm" in
[yY][eE][sS]|[yY]) ;;
*) log::info "Aborted"; return 0 ;;
esac
fi
log::wg_success "Removed ${removed} log entries for: ${name}"
local result
result=$(json::remove_events_filtered \
"$WG_EVENTS_LOG" "$FW_EVENTS_LOG" \
"${name:-}" "${filter_ip:-}" \
"$fw_only" "$wg_only" \
"${before:-}")
json::remove_events "$WG_EVENTS_LOG" "$name"
json::remove_events "$FW_EVENTS_LOG" "$client_ip"
local removed_wg removed_fw
IFS="|" read -r removed_wg removed_fw <<< "$result"
local total=$(( removed_wg + removed_fw ))
log::wg_success "Removed log entries for: ${name}"
if [[ "$total" -eq 0 ]]; then
log::wg_warning "No log entries found matching the criteria"
return 0
fi
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"
local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" limit="${4:-50}"
[[ ! -f "$WG_EVENTS_LOG" ]] && return 0
@ -214,9 +239,9 @@ function cmd::logs::show_wg_events() {
[[ -z "$ts" ]] && continue
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" ;;
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
@ -227,7 +252,7 @@ function cmd::logs::show_wg_events() {
}
function cmd::logs::show_fw_events() {
local filter_ip="$1" filter_name="$2" filter_type="$3" limit="$4"
local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" limit="${4:-50}"
[[ ! -f "$FW_EVENTS_LOG" ]] && return 0
@ -240,14 +265,15 @@ function cmd::logs::show_fw_events() {
[[ -z "$ts" ]] && continue
local colored_proto
case "$proto" in
tcp) colored_proto="\033[1;33mtcp\033[0m" ;;
udp) colored_proto="\033[1;36mudp\033[0m" ;;
icmp) colored_proto="\033[0;37micmp\033[0m" ;;
*) colored_proto="$proto" ;;
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")
done < <(json::fw_events "$FW_EVENTS_LOG" "$filter_ip" "$filter_type" \
"$(ctx::clients)" "$limit")
$found || printf " —\n"
printf "\n"

View file

@ -0,0 +1,254 @@
#!/usr/bin/env bash
FW_EVENTS_LOG="$(ctx::fw_events_log)"
WG_EVENTS_LOG="$(ctx::events_log)"
function cmd::logs::on_load() {
flag::register --name
flag::register --type
flag::register --since
flag::register --limit
flag::register --fw
flag::register --wg
flag::register --follow
}
function cmd::logs::help() {
cat <<EOF
Usage: wgctl logs [options]
Show WireGuard and firewall activity logs.
Options:
--name <name> Filter by client name
--type <type> Filter by device type
--since <time> Time filter (e.g. 1h, 24h, 7d)
--limit <n> Max results per source (default 50)
--fw Show only firewall drops
--wg Show only WireGuard events
Examples:
wgctl logs
wgctl logs --name guest-test
wgctl logs --type guest
wgctl logs --since 1h
wgctl logs --fw --limit 100
EOF
}
function cmd::logs::run() {
local subcmd="${1:-show}"
# Check if first arg is a flag
if [[ "$subcmd" == --* ]]; then
subcmd="show"
else
shift || true
fi
case "$subcmd" in
show) cmd::logs::show "$@" ;;
remove|rm|del) cmd::logs::remove "$@" ;;
help) cmd::logs::help ;;
*)
log::error "Unknown subcommand: '${subcmd}'"
cmd::logs::help
return 1
;;
esac
}
function cmd::logs::show() {
local name="" type="" since="" limit=50
local fw_only=false wg_only=false follow=false
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;;
--since) since="$2"; shift 2 ;;
--limit) limit="$2"; shift 2 ;;
--fw) fw_only=true; shift ;;
--wg) wg_only=true; shift ;;
--follow|-f) follow=true; shift ;;
--help) cmd::logs::help; return ;;
*)
log::error "Unknown flag: $1"
return 1
;;
esac
done
if [[ -n "$name" && -n "$type" ]]; then
name=$(peers::resolve_and_require "$name" "$type") || return 1
fi
local filter_ip=""
if [[ -n "$name" ]]; then
filter_ip=$(peers::get_ip "$name")
[[ -z "$filter_ip" ]] && log::error "Could not find IP for: $name" && return 1
fi
if $follow; then
cmd::logs::follow "$filter_ip" "$name" "$type" "$fw_only" "$wg_only"
return
fi
log::section "WireGuard Activity Log"
printf "\n"
$wg_only || cmd::logs::show_fw_events "$filter_ip" "$name" "$type" "$limit"
$fw_only || cmd::logs::show_wg_events "$filter_ip" "$name" "$type" "$limit"
}
function cmd::logs::follow() {
local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}"
local fw_only="${4:-false}" wg_only="${5:-false}"
local filter_peers="${6:-}"
local clients_dir
clients_dir="$(ctx::clients)"
local wg_log="$WG_EVENTS_LOG"
local fw_log="$FW_EVENTS_LOG"
$fw_only && wg_log=""
$wg_only && fw_log=""
log::section "WireGuard Live Log (Ctrl+C to stop)"
printf "\n %-20s %-8s %-20s %-25s %s\n" \
"TIME" "SOURCE" "CLIENT" "DESTINATION/ENDPOINT" "EVENT"
printf " %s\n" "$(printf '─%.0s' {1..90})"
while IFS="|" read -r source ts client dst_or_endpoint event; do
if [[ "$source" == "fw" ]]; then
local colored_event
case "$event" in
tcp) colored_event="\033[1;33mtcp\033[0m" ;;
udp) colored_event="\033[0;36mudp\033[0m" ;;
icmp) colored_event="\033[0;37micmp\033[0m" ;;
*) colored_event="$event" ;;
esac
printf " %-20s %-8s %-20s %-25s %b\n" \
"$ts" "firewall" "$client" "$dst_or_endpoint" "$colored_event"
else
local colored_event
case "$event" in
attempt) colored_event="\033[1;31mattempt\033[0m" ;;
handshake) colored_event="\033[1;32mhandshake\033[0m" ;;
*) colored_event="$event" ;;
esac
printf " %-20s %-8s %-20s %-25s %b\n" \
"$ts" "wireguard" "$client" "$dst_or_endpoint" "$colored_event"
fi
done < <(json::follow_logs "$fw_log" "$wg_log" "$filter_ip" "$filter_type" "$clients_dir" "$filter_peers")
}
function cmd::logs::remove() {
local name="" type="" force=false
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;;
--force) force=true; shift ;;
--help) cmd::logs::help; return ;;
*)
log::error "Unknown flag: $1"
return 1
;;
esac
done
if [[ -z "$name" ]]; then
log::error "Missing required flag: --name"
return 1
fi
name=$(peers::resolve_and_require "$name" "$type") || return 1
local client_ip
client_ip=$(peers::get_ip "$name")
local before_wg before_fw after_wg after_fw
before_wg=$(wc -l < "$WG_EVENTS_LOG" 2>/dev/null || echo 0)
before_fw=$(wc -l < "$FW_EVENTS_LOG" 2>/dev/null || echo 0)
json::remove_events "$WG_EVENTS_LOG" "$name"
json::remove_events "$FW_EVENTS_LOG" "$client_ip"
after_wg=$(wc -l < "$WG_EVENTS_LOG" 2>/dev/null || echo 0)
after_fw=$(wc -l < "$FW_EVENTS_LOG" 2>/dev/null || echo 0)
local removed=$(( (before_wg - after_wg) + (before_fw - after_fw) ))
if [[ "$removed" -eq 0 ]]; then
log::wg_warning "No log entries found for: ${name}"
return 0
fi
if ! $force; then
read -r -p "Remove all log entries for '${name}'? [y/N] " confirm
case "$confirm" in
[yY][eE][sS]|[yY]) ;;
*) log::info "Aborted"; return 0 ;;
esac
fi
log::wg_success "Removed ${removed} log entries for: ${name}"
json::remove_events "$WG_EVENTS_LOG" "$name"
json::remove_events "$FW_EVENTS_LOG" "$client_ip"
log::wg_success "Removed log entries for: ${name}"
}
function cmd::logs::show_wg_events() {
local filter_ip="$1" filter_name="$2" filter_type="$3" limit="$4"
[[ ! -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;31mattempt\033[0m" ;;
handshake) colored_event="\033[1;32mhandshake\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"
[[ ! -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;33mtcp\033[0m" ;;
udp) colored_proto="\033[1;36mudp\033[0m" ;;
icmp) colored_proto="\033[0;37micmp\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"
}

View file

@ -89,6 +89,7 @@ function cmd::rule::run() {
unassign) cmd::rule::unassign "$@" ;;
migrate) cmd::rule::migrate "$@" ;;
reapply) cmd::rule::reapply "$@" ;;
inspect) cmd::rule::inspect "$@" ;;
help) cmd::rule::help ;;
*)
log::error "Unknown subcommand: '${subcmd}'"
@ -102,6 +103,16 @@ function cmd::rule::run() {
# List
# ============================================
function cmd::rule::_pad() {
local text="$1" width="$2"
local visible
visible=$(printf "%s" "$text" | sed 's/\x1b\[[0-9;]*m//g')
local visible_len=${#visible}
local byte_len=${#text}
local extra=$(( byte_len - visible_len ))
printf "%-$(( width + extra ))s" "$text"
}
function cmd::rule::list() {
local rules_dir
rules_dir="$(ctx::rules)"
@ -113,16 +124,63 @@ function cmd::rule::list() {
fi
log::section "Firewall Rules"
printf "\n %-20s %-45s %-8s %-8s %s\n" \
"NAME" "DESCRIPTION" "ALLOWS" "BLOCKS" "PEERS"
printf " %s\n" "$(printf '─%.0s' {1..95})"
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"
while IFS="|" read -r name desc n_allows n_blocks peer_count; do
local printing_base=false
local current_group=""
while IFS="|" read -r name desc n_allows n_blocks peer_count extends is_base group; do
[[ -z "$name" ]] && continue
local short_desc="${desc:0:43}"
[[ ${#desc} -gt 43 ]] && short_desc="${short_desc}..."
printf " %-20s %-45s %-8s %-8s %s\n" \
"$name" "${short_desc:-}" "$n_allows" "$n_blocks" "${peer_count} peers"
# Base rules section header
if [[ "$is_base" == "True" && "$printing_base" == "false" ]]; then
local bdashes
bdashes=$(printf '─%.0s' {1..74})
printf "\n \033[0;37m── Base Rules \033[0m%s\n" "$bdashes"
printing_base=true
current_group="" # reset group tracking for base section
fi
# Group header — only for non-base rules
if [[ "$is_base" == "False" && "$group" != "$current_group" ]]; then
if [[ -n "$group" ]]; then
printf "\n \033[0;36m▸ %s\033[0m\n" "$group"
elif [[ -n "$current_group" ]]; then
# Switching from grouped back to ungrouped
printf "\n"
fi
current_group="$group"
fi
local short_desc="${desc:0:35}"
[[ ${#desc} -gt 35 ]] && short_desc="${short_desc}..."
local desc_col_width=40
[[ "${short_desc:-}" == "—" ]] && desc_col_width=42
printf " %-20s %-${desc_col_width}s %-8s %-8s %s\n" \
"$name" "${short_desc:-}" \
"$(ui::center "$n_allows" 8)" \
"$(ui::center "$n_blocks" 8)" \
"${peer_count} peers"
# Print extends IMMEDIATELY after the rule row
if [[ -n "$extends" ]]; then
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
printf "\n" # blank line after rule with extends
fi
done < <(json::rule_list_data "$rules_dir" "$(ctx::meta)")
printf "\n"
@ -299,6 +357,60 @@ function cmd::rule::show() {
# printf "\n"
# }
# ============================================
# Inspect
# ============================================
function cmd::rule::inspect() {
local name=""
while [[ $# -gt 0 ]]; do
case "$1" in
--name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;;
--help) cmd::rule::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
[[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
rule::require_exists "$name" || return 1
log::section "Rule Inspect: ${name}"
local prev_section=""
while IFS="|" read -r section key value; do
[[ -z "$section" ]] && continue
# Print section header when section changes
if [[ "$section" != "$prev_section" ]]; then
case "$section" in
own)
cmd::rule::_show_section "Own Rules" ;;
dns)
cmd::rule::_show_section "DNS" ;;
resolved)
cmd::rule::_show_section "Resolved (applied to peers)" ;;
inherited:*)
local base_name="${section#inherited:}"
cmd::rule::_show_section "Inherited: ${base_name}" ;;
esac
prev_section="$section"
fi
case "$key" in
allow_ip|allow_port)
printf " \033[0;32m+\033[0m %s\n" "$value" ;;
block_ip|block_port)
printf " \033[0;31m-\033[0m %s\n" "$value" ;;
dns_redirect)
printf " Redirect all DNS → %s\n" "$(config::dns)" ;;
esac
done < <(json::rule_inspect "$(ctx::rules)" "$name")
printf "\n"
}
# ============================================
# Add
# ============================================
@ -486,7 +598,8 @@ function cmd::rule::assign() {
return 1
fi
rule::require_exists "$name" || return 1
rule::require_exists "$name" || return 1
rule::require_assignable "$name" || return 1
# Support --type for peer resolution
peer=$(peers::resolve_and_require "$peer" "$type") || return 1

View file

@ -216,10 +216,6 @@ function cmd::test::section_destructive() {
# Add test peer
cmd::test::run_cmd "add phone peer" "added successfully" \
add --name testunit --type phone
# Debug: show what was created
"$WGCTL_BINARY" list | grep testunit >&2
# Block/unblock
cmd::test::run_cmd "block peer" "blocked" \
block --name phone-testunit

View file

@ -17,6 +17,7 @@ _CTX_DATA="${_CTX_WG}/.wgctl"
# ============================================
_CTX_RULES="${_CTX_DATA}/rules"
_CTX_RULES_BASE="${_CTX_RULES}/base"
_CTX_GROUPS="${_CTX_DATA}/groups"
_CTX_BLOCKS="${_CTX_DATA}/blocks"
_CTX_META="${_CTX_DATA}/meta"
@ -24,22 +25,22 @@ _CTX_DAEMON="${_CTX_DATA}/daemon"
# ============================================
function ctx::root() { echo "$_CTX_ROOT"; }
function ctx::core() { echo "$_CTX_CORE"; }
function ctx::modules() { echo "$_CTX_MODULES"; }
function ctx::commands() { echo "$_CTX_COMMANDS"; }
function ctx::blocks() { echo "$_CTX_BLOCKS"; }
function ctx::groups() { echo "$_CTX_GROUPS"; }
function ctx::rules() { echo "$_CTX_RULES"; }
function ctx::clients() { echo "$_CTX_CLIENTS"; }
function ctx::wg() { echo "$_CTX_WG"; }
function ctx::data() { echo "$_CTX_DATA"; }
function ctx::rules() { echo "$_CTX_RULES"; }
function ctx::groups() { echo "$_CTX_GROUPS"; }
function ctx::blocks() { echo "$_CTX_BLOCKS"; }
function ctx::meta() { echo "$_CTX_META"; }
function ctx::daemon() { echo "$_CTX_DAEMON"; }
function ctx::root() { echo "$_CTX_ROOT"; }
function ctx::core() { echo "$_CTX_CORE"; }
function ctx::modules() { echo "$_CTX_MODULES"; }
function ctx::commands() { echo "$_CTX_COMMANDS"; }
function ctx::blocks() { echo "$_CTX_BLOCKS"; }
function ctx::groups() { echo "$_CTX_GROUPS"; }
function ctx::rules() { echo "$_CTX_RULES"; }
function ctx::rules::base() { echo "$_CTX_RULES_BASE"; }
function ctx::clients() { echo "$_CTX_CLIENTS"; }
function ctx::wg() { echo "$_CTX_WG"; }
function ctx::data() { echo "$_CTX_DATA"; }
function ctx::rules() { echo "$_CTX_RULES"; }
function ctx::groups() { echo "$_CTX_GROUPS"; }
function ctx::blocks() { echo "$_CTX_BLOCKS"; }
function ctx::meta() { echo "$_CTX_META"; }
function ctx::daemon() { echo "$_CTX_DAEMON"; }
function ctx::events_log() { echo "$(ctx::daemon)/events.log"; }
function ctx::fw_events_log() { echo "$(ctx::daemon)/fw_events.log"; }

View file

@ -2,34 +2,53 @@
JSON_HELPER="${_CTX_ROOT}/core/json_helper.py"
function json::get() { python3 "$JSON_HELPER" get "$@" </dev/null; }
function json::set() { python3 "$JSON_HELPER" set "$@" </dev/null; }
function json::delete() { python3 "$JSON_HELPER" delete "$@" </dev/null; }
function json::append() { python3 "$JSON_HELPER" append "$@" </dev/null; }
function json::remove() { python3 "$JSON_HELPER" remove "$@" </dev/null; }
function json::cat() { python3 "$JSON_HELPER" cat "$@" </dev/null; }
function json::has_key() { python3 "$JSON_HELPER" has_key "$@" </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::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::wg_events() { WGCTL_DATETIME_FMT="$FMT_DATETIME" python3 "$JSON_HELPER" wg_events "$@" </dev/null; }
function json::format_fw_event() { echo "$1" | python3 "$JSON_HELPER" format_fw_event "$2"; }
function json::format_wg_event() { echo "$1" | python3 "$JSON_HELPER" format_wg_event; }
function json::remove_events() { python3 "$JSON_HELPER" remove_events "$@" </dev/null; }
function json::follow_logs() { WGCTL_DATETIME_FMT="$FMT_DATETIME" python3 "$JSON_HELPER" follow_logs "$@"; }
function json::count() { python3 "$JSON_HELPER" count "$@" </dev/null; }
function json::audit_fw_counts() { python3 "$JSON_HELPER" audit_fw_counts "$@" </dev/null; }
function json::peer_group_map() { python3 "$JSON_HELPER" peer_group_map "$@" </dev/null; }
function json::peer_groups() { python3 "$JSON_HELPER" peer_groups "$@" </dev/null; }
function json::peer_data() { WGCTL_DATETIME_FMT="$FMT_DATETIME" python3 "$JSON_HELPER" peer_data "$@" </dev/null; }
function json::iso_to_ts() { python3 "$JSON_HELPER" iso_to_ts "$@" </dev/null; }
function json::rule_list_data() { python3 "$JSON_HELPER" rule_list_data "$@" </dev/null; }
function json::group_list_data() { python3 "$JSON_HELPER" group_list_data "$@" </dev/null; }
function json::fmt_datetime() { python3 "$JSON_HELPER" fmt_datetime "$@" </dev/null; }
function json::create_rule() { python3 "$JSON_HELPER" create_rule "$@" </dev/null; }
function json::cleanup_config() { python3 "$JSON_HELPER" cleanup_config "$@" </dev/null; }
function json::remove_peer_block() { python3 "$JSON_HELPER" remove_peer_block "$@" </dev/null; }
function json::create_group() { python3 "$JSON_HELPER" create_group "$@" </dev/null; }
function json::parse_event() { python3 "$JSON_HELPER" parse_event "$@" </dev/null; }
function json::parse_fw_event() { python3 "$JSON_HELPER" parse_fw_event "$@" </dev/null; }
function json::get() { python3 "$JSON_HELPER" get "$@" </dev/null; }
function json::set() { python3 "$JSON_HELPER" set "$@" </dev/null; }
function json::delete() { python3 "$JSON_HELPER" delete "$@" </dev/null; }
function json::append() { python3 "$JSON_HELPER" append "$@" </dev/null; }
function json::remove() { python3 "$JSON_HELPER" remove "$@" </dev/null; }
function json::cat() { python3 "$JSON_HELPER" cat "$@" </dev/null; }
function json::has_key() { python3 "$JSON_HELPER" has_key "$@" </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::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::wg_events() { WGCTL_DATETIME_FMT="$FMT_DATETIME" python3 "$JSON_HELPER" wg_events "$@" </dev/null; }
function json::format_fw_event() { echo "$1" | python3 "$JSON_HELPER" format_fw_event "$2"; }
function json::format_wg_event() { echo "$1" | python3 "$JSON_HELPER" format_wg_event; }
function json::remove_events() { python3 "$JSON_HELPER" remove_events "$@" </dev/null; }
function json::follow_logs() { WGCTL_DATETIME_FMT="$FMT_DATETIME" python3 "$JSON_HELPER" follow_logs "$@"; }
function json::count() { python3 "$JSON_HELPER" count "$@" </dev/null; }
function json::audit_fw_counts() { python3 "$JSON_HELPER" audit_fw_counts "$@" </dev/null; }
function json::peer_group_map() { python3 "$JSON_HELPER" peer_group_map "$@" </dev/null; }
function json::peer_groups() { python3 "$JSON_HELPER" peer_groups "$@" </dev/null; }
function json::peer_data() { WGCTL_DATETIME_FMT="$FMT_DATETIME" python3 "$JSON_HELPER" peer_data "$@" </dev/null; }
function json::iso_to_ts() { python3 "$JSON_HELPER" iso_to_ts "$@" </dev/null; }
function json::rule_list_data() { python3 "$JSON_HELPER" rule_list_data "$@" </dev/null; }
function json::group_list_data() { python3 "$JSON_HELPER" group_list_data "$@" </dev/null; }
function json::fmt_datetime() { python3 "$JSON_HELPER" fmt_datetime "$@" </dev/null; }
function json::create_rule() { python3 "$JSON_HELPER" create_rule "$@" </dev/null; }
function json::cleanup_config() { python3 "$JSON_HELPER" cleanup_config "$@" </dev/null; }
function json::remove_peer_block() { python3 "$JSON_HELPER" remove_peer_block "$@" </dev/null; }
function json::create_group() { python3 "$JSON_HELPER" create_group "$@" </dev/null; }
function json::parse_event() { python3 "$JSON_HELPER" parse_event "$@" </dev/null; }
function json::parse_fw_event() { python3 "$JSON_HELPER" parse_fw_event "$@" </dev/null; }
function json::remove_events_filtered() { python3 "$JSON_HELPER" remove_events_filtered "$@" </dev/null; }
function json::rule_resolve() { python3 "$JSON_HELPER" rule_resolve "$@" </dev/null; }
function json::rule_resolve_field() { python3 "$JSON_HELPER" rule_resolve_field "$@" </dev/null; }
function json::rule_inspect() { python3 "$JSON_HELPER" rule_inspect "$@" </dev/null; }
function json::find_rule_file() { python3 "$JSON_HELPER" find_rule_file "$@" </dev/null; }
function json::peer_transfer() {
ACTIVITY_TOTAL_LOW="$(config::activity_total_low)" \
ACTIVITY_TOTAL_MED="$(config::activity_total_med)" \
ACTIVITY_TOTAL_HIGH="$(config::activity_total_high)" \
python3 "$JSON_HELPER" peer_transfer "$@" </dev/null
}
function json::peer_transfer_delta() {
ACTIVITY_CURRENT_LOW="$(config::activity_current_low)" \
ACTIVITY_CURRENT_MED="$(config::activity_current_med)" \
ACTIVITY_CURRENT_HIGH="$(config::activity_current_high)" \
python3 "$JSON_HELPER" peer_transfer_delta "$@" </dev/null
}

View file

@ -155,12 +155,10 @@ def events_for(file, ip, limit):
pass
def fw_events(file, filter_ip, filter_type, clients_dir, limit):
"""Format firewall drop events"""
"""Format firewall drop events with dedup and counts"""
import glob
proto_map = {1: 'icmp', 6: 'tcp', 17: 'udp'}
# Build ip->name map
ip_to_name = {}
for conf in glob.glob(f"{clients_dir}/*.conf"):
name = os.path.basename(conf).replace('.conf', '')
@ -174,7 +172,7 @@ def fw_events(file, filter_ip, filter_type, clients_dir, limit):
pass
events = []
last_seen = {} # (src, dst, port, proto) -> last timestamp
last_seen = {}
try:
with open(file) as f:
@ -186,36 +184,93 @@ def fw_events(file, filter_ip, filter_type, clients_dir, limit):
continue
if filter_ip and src != filter_ip:
continue
# Dedup key
dst = e.get('dest_ip', '')
port = e.get('dest_port', '')
proto = e.get('ip.protocol', 0)
key = (src, dst, port, proto)
dst = e.get('dest_ip', '')
port = e.get('dest_port', '')
proto = e.get('ip.protocol', 0)
key = (src, dst, port, proto)
ts_str = e.get('timestamp', '')
try:
from datetime import datetime
ts = datetime.fromisoformat(ts_str).timestamp()
except:
ts = 0
# Protocol-aware dedup window
dedup_windows = {1: 5, 6: 30, 17: 10} # icmp=5s, tcp=30s, udp=10s
window = dedup_windows.get(proto, 10)
# Skip if same event within protocol window
windows = {1: 5, 6: 30, 17: 10}
window = windows.get(proto, 10)
if key in last_seen and (ts - last_seen[key]) < window:
continue
last_seen[key] = ts
events.append(e)
except:
pass
except:
pass
for e in events[-int(limit):]:
# Dedup consecutive same src+dst+port within 60s with count
deduped = []
counts = []
for e in events:
ts_str = e.get('timestamp', '')
try:
from datetime import datetime
ts = datetime.fromisoformat(ts_str).timestamp()
except:
ts = 0
src = e.get('src_ip', '')
dst = e.get('dest_ip', '')
port = e.get('dest_port', '')
proto = e.get('ip.protocol', 0)
key = (src, dst, port, proto)
if deduped and counts:
prev = deduped[-1]
try:
prev_ts = datetime.fromisoformat(prev.get('timestamp','')).timestamp()
except:
prev_ts = 0
prev_key = (prev.get('src_ip',''), prev.get('dest_ip',''),
prev.get('dest_port',''), prev.get('ip.protocol',0))
if key == prev_key and (ts - prev_ts) < 60:
counts[-1] += 1
continue
deduped.append(e)
counts.append(1)
grouped = []
group_counts = []
for e in deduped:
ts_str = e.get('timestamp', '')
try:
from datetime import datetime
dt = datetime.fromisoformat(ts_str)
# Truncate to minute for grouping
minute_key = dt.strftime('%Y-%m-%d %H:%M')
except:
minute_key = ts_str[:16]
src = e.get('src_ip', '')
dst = e.get('dest_ip', '')
port = e.get('dest_port', '')
proto = e.get('ip.protocol', 0)
key = (minute_key, src, dst, proto) # group within same minute
if grouped and group_counts:
prev = grouped[-1]
try:
prev_dt = datetime.fromisoformat(prev.get('timestamp',''))
prev_minute = prev_dt.strftime('%Y-%m-%d %H:%M')
except:
prev_minute = ''
prev_key = (prev_minute, prev.get('src_ip',''),
prev.get('dest_ip',''), prev.get('ip.protocol',0))
if key == prev_key:
group_counts[-1] += 1
continue
grouped.append(e)
group_counts.append(1)
for e, count in zip(deduped[-int(limit):], counts[-int(limit):]):
ts = e.get('timestamp', '')
try:
from datetime import datetime
@ -223,21 +278,20 @@ def fw_events(file, filter_ip, filter_type, clients_dir, limit):
ts = dt.strftime(DATETIME_FMT)
except:
pass
src = e.get('src_ip', '')
dst = e.get('dest_ip', '')
port = e.get('dest_port', '')
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))
dst_str = f"{dst}:{port}" if port else dst
client = ip_to_name.get(src, src)
proto = proto_map.get(proto_num, str(proto_num))
dst_str = f"{dst}:{port}" if port else dst
client = ip_to_name.get(src, src)
if filter_type and not client.startswith(filter_type + '-'):
continue
print(f"{ts}|{client}|{dst_str}|{proto}")
count_str = f" (x{count})" if count > 1 else ""
print(f"{ts}|{client}|{dst_str}|{proto}{count_str}")
def wg_events(file, filter_client, filter_type, limit):
"""Format WireGuard events from events.log"""
"""Format WireGuard events from events.log with dedup"""
events = []
try:
with open(file) as f:
@ -257,7 +311,37 @@ def wg_events(file, filter_client, filter_type, limit):
except:
pass
for e in events[-int(limit):]:
# Dedup consecutive same client+event+endpoint within 60s
deduped = []
counts = []
for e in events:
ts_str = e.get('timestamp', '')
try:
from datetime import datetime
ts = datetime.fromisoformat(ts_str).timestamp()
except:
ts = 0
client = e.get('client', '')
event = e.get('event', '')
endpoint = e.get('endpoint', '')
key = (client, event, endpoint[:15])
if deduped and counts:
prev = deduped[-1]
prev_ts_str = prev.get('timestamp', '')
try:
prev_ts = datetime.fromisoformat(prev_ts_str).timestamp()
except:
prev_ts = 0
prev_key = (prev.get('client',''), prev.get('event',''), prev.get('endpoint','')[:15])
if key == prev_key and (ts - prev_ts) < 60:
counts[-1] += 1
continue
deduped.append(e)
counts.append(1)
for e, count in zip(deduped[-int(limit):], counts[-int(limit):]):
ts = e.get('timestamp', '')
try:
from datetime import datetime
@ -265,10 +349,11 @@ def wg_events(file, filter_client, filter_type, limit):
ts = dt.strftime(DATETIME_FMT)
except:
pass
client = e.get('client', '')
client = e.get('client', '')
endpoint = e.get('endpoint', '')
event = e.get('event', '')
print(f"{ts}|{client}|{endpoint}|{event}")
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):
"""Format a single fw_event line"""
@ -355,8 +440,6 @@ def follow_logs(fw_file, wg_file, filter_ip, filter_type, clients_dir, filter_pe
proto_map = {1: 'icmp', 6: 'tcp', 17: 'udp'}
peer_filter = set(filter_peers.split(',')) if filter_peers else set()
import sys
print(f"DEBUG follow_logs: peer_filter={peer_filter}", file=sys.stderr, flush=True)
# Build ip->name map
ip_to_name = {}
@ -576,10 +659,9 @@ def iso_to_ts(iso_str):
print(0)
def rule_list_data(rules_dir, meta_dir):
"""Return all rule data for list display in one call"""
"""Return all rule data including base rules and extends"""
import glob
# Count peers per rule from meta files
rule_peer_counts = {}
for f in glob.glob(f"{meta_dir}/*.meta"):
try:
@ -591,20 +673,49 @@ def rule_list_data(rules_dir, meta_dir):
except:
pass
# Read each rule file
for rule_file in sorted(glob.glob(f"{rules_dir}/*.rule")):
rule_files = (
sorted(glob.glob(f"{rules_dir}/*.rule")) +
sorted(glob.glob(f"{rules_dir}/base/*.rule"))
)
# Collect all data first
rules_data = []
for rule_file in rule_files:
is_base = '/base/' in rule_file
try:
with open(rule_file) as f:
r = json.load(f)
name = r.get('name', '')
desc = r.get('desc', '')
n_allows = len(r.get('allow_ips', [])) + len(r.get('allow_ports', []))
n_blocks = len(r.get('block_ips', [])) + len(r.get('block_ports', []))
name = r.get('name', '')
desc = r.get('desc', '')
group = r.get('group', '')
extends = ','.join(r.get('extends', []))
resolved = _rule_resolve_internal(rules_dir, name)
n_allows = len(resolved.get('allow_ips', [])) + \
len(resolved.get('allow_ports', []))
n_blocks = len(resolved.get('block_ips', [])) + \
len(resolved.get('block_ports', []))
peer_count = rule_peer_counts.get(name, 0)
print(f"{name}|{desc}|{n_allows}|{n_blocks}|{peer_count}")
rules_data.append({
'name': name, 'desc': desc, 'n_allows': n_allows,
'n_blocks': n_blocks, 'peer_count': peer_count,
'extends': extends, 'is_base': is_base, 'group': group
})
except:
pass
# Sort: non-base first, then by group (empty group last within non-base),
# then by name within group
rules_data.sort(key=lambda x: (
x['is_base'],
x['group'] == '' and not x['is_base'],
x['group'],
x['name']
))
for r in rules_data:
print(f"{r['name']}|{r['desc']}|{r['n_allows']}|{r['n_blocks']}|"
f"{r['peer_count']}|{r['extends']}|{r['is_base']}|{r['group']}")
def group_list_data(groups_dir, blocks_dir):
"""Return group summary data in one call"""
import glob
@ -711,6 +822,288 @@ def parse_fw_event(line):
except:
pass
def peer_transfer(wg_interface):
"""Get total transfer bytes per peer"""
import subprocess
low = int(os.environ.get('ACTIVITY_TOTAL_LOW_BYTES', '1000000'))
med = int(os.environ.get('ACTIVITY_TOTAL_MED_BYTES', '10000000'))
high = int(os.environ.get('ACTIVITY_TOTAL_HIGH_BYTES', '100000000'))
try:
result = subprocess.run(
['wg', 'show', wg_interface, 'transfer'],
capture_output=True, text=True
)
for line in result.stdout.strip().split('\n'):
if not line:
continue
parts = line.split('\t')
if len(parts) == 3:
pubkey, rx, tx = parts
total = int(rx) + int(tx)
if total == 0: level = 'none'
elif total < low: level = 'low'
elif total < med: level = 'medium'
elif total < high: level = 'high'
else: level = 'very high'
print(f"{pubkey}|{rx}|{tx}|{level}")
except:
pass
def peer_transfer_delta(wg_interface, cache_file):
"""Calculate current transfer rate using delta from previous sample"""
import subprocess, time
low = int(os.environ.get('ACTIVITY_CURRENT_LOW_BYTES', '10000')) # 10KB/s
med = int(os.environ.get('ACTIVITY_CURRENT_MED_BYTES', '100000')) # 100KB/s
high = int(os.environ.get('ACTIVITY_CURRENT_HIGH_BYTES', '1000000')) # 1MB/s
current = {}
now = time.time()
try:
result = subprocess.run(
['wg', 'show', wg_interface, 'transfer'],
capture_output=True, text=True
)
for line in result.stdout.strip().split('\n'):
if not line:
continue
parts = line.split('\t')
if len(parts) == 3:
pubkey, rx, tx = parts
current[pubkey] = {'rx': int(rx), 'tx': int(tx), 'ts': now}
except:
pass
prev = {}
if os.path.exists(cache_file):
try:
with open(cache_file) as f:
prev = json.load(f)
except:
pass
try:
with open(cache_file, 'w') as f:
json.dump(current, f)
except:
pass
for pubkey, data in current.items():
if pubkey in prev:
dt = data['ts'] - prev[pubkey].get('ts', data['ts'])
if dt > 0:
rx_rate = max(0, (data['rx'] - prev[pubkey]['rx']) / dt)
tx_rate = max(0, (data['tx'] - prev[pubkey]['tx']) / dt)
total = rx_rate + tx_rate
if total <= 0: level = 'idle'
elif total < low: level = 'low'
elif total < med: level = 'medium'
elif total < high: level = 'high'
else: level = 'very high'
print(f"{pubkey}|{int(rx_rate)}|{int(tx_rate)}|{level}")
else:
print(f"{pubkey}|0|0|idle")
else:
print(f"{pubkey}|0|0|unknown")
def remove_events_filtered(wg_file, fw_file, filter_name, filter_ip,
filter_fw, filter_wg, before_days):
"""Remove events with filters: by name/ip, source, or age"""
import time
from datetime import datetime, timezone
cutoff_ts = None
if before_days:
cutoff_ts = time.time() - (float(before_days) * 86400)
def should_remove_wg(e):
if filter_name and e.get('client') != filter_name:
return False
if cutoff_ts:
try:
ts = datetime.fromisoformat(e.get('timestamp','')).timestamp()
return ts < cutoff_ts
except:
return False
return True
def should_remove_fw(e):
if filter_ip and e.get('src_ip') != filter_ip:
return False
if cutoff_ts:
try:
ts = datetime.fromisoformat(e.get('timestamp','')).timestamp()
return ts < cutoff_ts
except:
return False
return True
removed_wg = removed_fw = 0
if not filter_fw and os.path.exists(wg_file):
lines = []
with open(wg_file) as f:
for line in f:
try:
e = json.loads(line.strip())
if should_remove_wg(e):
removed_wg += 1
continue
except:
pass
lines.append(line)
with open(wg_file, 'w') as f:
f.writelines(lines)
if not filter_wg and os.path.exists(fw_file):
lines = []
with open(fw_file) as f:
for line in f:
try:
e = json.loads(line.strip())
if should_remove_fw(e):
removed_fw += 1
continue
except:
pass
lines.append(line)
with open(fw_file, 'w') as f:
f.writelines(lines)
print(f"{removed_wg}|{removed_fw}")
def _rule_resolve_internal(rules_dir, rule_name, visited=None):
"""Internal recursive resolver — returns dict, does not print"""
if visited is None:
visited = set()
if rule_name in visited:
raise ValueError(f"Circular dependency detected: {rule_name}")
visited.add(rule_name)
rule_file = find_rule_file(rules_dir, rule_name)
with open(rule_file) as f:
rule = json.load(f)
merged = {
'allow_ips': [], 'allow_ports': [],
'block_ips': [], 'block_ports': [],
'dns_redirect': False
}
for base_name in rule.get('extends', []):
base = _rule_resolve_internal(rules_dir, base_name, visited.copy())
merged['allow_ips'] += base.get('allow_ips', [])
merged['allow_ports'] += base.get('allow_ports', [])
merged['block_ips'] += base.get('block_ips', [])
merged['block_ports'] += base.get('block_ports', [])
if base.get('dns_redirect'):
merged['dns_redirect'] = True
merged['allow_ips'] = list(dict.fromkeys(merged['allow_ips'] + rule.get('allow_ips', [])))
merged['allow_ports'] = list(dict.fromkeys(merged['allow_ports'] + rule.get('allow_ports', [])))
merged['block_ips'] = list(dict.fromkeys(merged['block_ips'] + rule.get('block_ips', [])))
merged['block_ports'] = list(dict.fromkeys(merged['block_ports'] + rule.get('block_ports', [])))
if rule.get('dns_redirect'):
merged['dns_redirect'] = True
merged['name'] = rule['name']
merged['desc'] = rule.get('desc', '')
merged['extends'] = rule.get('extends', [])
return merged
def rule_resolve(rules_dir, rule_name):
"""Resolve a rule with inheritance — prints JSON"""
try:
resolved = _rule_resolve_internal(rules_dir, rule_name)
print(json.dumps(resolved))
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def rule_resolve_field(rules_dir, rule_name, field):
"""Get a single field from resolved rule — prints values one per line"""
try:
resolved = _rule_resolve_internal(rules_dir, rule_name)
val = resolved.get(field, [])
if isinstance(val, list):
for v in val:
print(v)
else:
print(val)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def rule_inspect(rules_dir, rule_name):
"""Show inheritance tree for a rule"""
try:
rule_file = find_rule_file(rules_dir, rule_name)
with open(rule_file) as f:
rule = json.load(f)
resolved = _rule_resolve_internal(rules_dir, rule_name)
has_extends = bool(rule.get('extends', []))
# Own rules
for ip in rule.get('allow_ips', []):
print(f"own|allow_ip|{ip}")
for p in rule.get('allow_ports', []):
print(f"own|allow_port|{p}")
for ip in rule.get('block_ips', []):
print(f"own|block_ip|{ip}")
for p in rule.get('block_ports', []):
print(f"own|block_port|{p}")
# DNS redirect — separate section
if rule.get('dns_redirect'):
print(f"dns|dns_redirect|true")
if has_extends:
# Inherited rules per base
for base_name in rule.get('extends', []):
base = _rule_resolve_internal(rules_dir, base_name)
for ip in base.get('allow_ips', []):
print(f"inherited:{base_name}|allow_ip|{ip}")
for p in base.get('allow_ports', []):
print(f"inherited:{base_name}|allow_port|{p}")
for ip in base.get('block_ips', []):
print(f"inherited:{base_name}|block_ip|{ip}")
for p in base.get('block_ports', []):
print(f"inherited:{base_name}|block_port|{p}")
if base.get('dns_redirect'):
print(f"inherited:{base_name}|dns_redirect|true")
# Resolved summary only when inheritance exists
has_resolved = (
resolved.get('allow_ips') or resolved.get('allow_ports') or
resolved.get('block_ips') or resolved.get('block_ports') or
resolved.get('dns_redirect')
)
if has_resolved:
for ip in resolved.get('allow_ips', []):
print(f"resolved|allow_ip|{ip}")
for p in resolved.get('allow_ports', []):
print(f"resolved|allow_port|{p}")
for ip in resolved.get('block_ips', []):
print(f"resolved|block_ip|{ip}")
for p in resolved.get('block_ports', []):
print(f"resolved|block_port|{p}")
if resolved.get('dns_redirect'):
print(f"resolved|dns_redirect|true")
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def find_rule_file(rules_dir, rule_name):
"""Find rule file in rules/ or rules/base/"""
for path in [
os.path.join(rules_dir, f"{rule_name}.rule"),
os.path.join(rules_dir, "base", f"{rule_name}.rule"),
]:
if os.path.exists(path):
return path
raise FileNotFoundError(f"Rule not found: {rule_name}")
commands = {
'get': lambda args: get(args[0], args[1]),
'set': lambda args: set_key(args[0], args[1], args[2]),
@ -743,6 +1136,15 @@ commands = {
'create_group': lambda args: create_group(args[0], args[1], args[2]),
'parse_event': lambda args: parse_event(args[0]),
'parse_fw_event': lambda args: parse_fw_event(args[0]),
'peer_transfer': lambda args: peer_transfer(args[0]),
'remove_events_filtered': lambda args: remove_events_filtered(
args[0], args[1], args[2], args[3], args[4]=='true', args[5]=='true', args[6] if len(args)>6 else ''),
'peer_transfer': lambda args: peer_transfer(args[0]),
'peer_transfer_delta': lambda args: peer_transfer_delta(args[0], args[1]),
'rule_resolve': lambda args: rule_resolve(args[0], args[1]),
'rule_resolve_field': lambda args: rule_resolve_field(args[0], args[1], args[2]),
'rule_inspect': lambda args: rule_inspect(args[0], args[1]),
'find_rule_file': lambda args: print(find_rule_file(args[0], args[1])),
}
if __name__ == '__main__':

790
core/json_helper.py.bak Normal file
View file

@ -0,0 +1,790 @@
#!/usr/bin/env python3
"""
wgctl JSON helper called by shell functions to read/write JSON files.
Usage: json_helper.py <command> <file> [key] [value]
"""
import sys
import json
import os
DATETIME_FMT = os.environ.get('WGCTL_DATETIME_FMT', '%Y-%m-%d %H:%M')
def get(file, key):
try:
with open(file) as f:
data = json.load(f)
val = data.get(key, [])
if isinstance(val, bool):
print(str(val).lower()) # true/false not True/False
elif isinstance(val, list):
if val:
print('\n'.join(str(v) for v in val))
else:
if val:
print(val)
except:
sys.exit(0)
def set_key(file, key, value):
try:
data = {}
if os.path.exists(file):
with open(file) as f:
data = json.load(f)
# Try to parse as JSON value first (for arrays/bools)
try:
data[key] = json.loads(value)
except:
data[key] = value
with open(file, 'w') as f:
json.dump(data, f, indent=2)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def delete_key(file, key):
try:
with open(file) as f:
data = json.load(f)
data.pop(key, None)
with open(file, 'w') as f:
json.dump(data, f, indent=2)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def append(file, key, value):
try:
data = {}
if os.path.exists(file):
with open(file) as f:
data = json.load(f)
if key not in data:
data[key] = []
if value not in data[key]:
data[key].append(value)
with open(file, 'w') as f:
json.dump(data, f, indent=2)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def remove_value(file, key, value):
try:
with open(file) as f:
data = json.load(f)
if key in data and value in data[key]:
data[key].remove(value)
with open(file, 'w') as f:
json.dump(data, f, indent=2)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def cat(file):
try:
with open(file) as f:
data = json.load(f)
print(json.dumps(data, indent=2))
except Exception as e:
sys.exit(1)
def has_key(file, key):
try:
with open(file) as f:
data = json.load(f)
sys.exit(0 if key in data else 1)
except:
sys.exit(1)
def filter_values(file, key, value):
"""Remove all entries where value matches"""
try:
with open(file) as f:
data = json.load(f)
data = {k: v for k, v in data.items() if v != value}
with open(file, 'w') as f:
json.dump(data, f, indent=2)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def last_event(file, key, field, client):
"""Get last event field for a client"""
try:
last = None
with open(file) as f:
for line in f:
try:
e = json.loads(line.strip())
if e.get(key) == client:
last = e
except:
pass
if last:
print(last.get(field, ''))
except:
pass
def events_for(file, ip, limit):
"""Format events for a given IP"""
try:
from datetime import datetime
events = []
with open(file) as f:
for line in f:
try:
e = json.loads(line.strip())
if e.get('ip') == ip:
events.append(e)
except:
pass
for e in events[-int(limit):]:
ts = e.get('timestamp', '')
try:
dt = datetime.fromisoformat(ts)
ts = dt.strftime(DATETIME_FMT)
except:
pass
endpoint = e.get('endpoint', '')
client = e.get('client', '')
event = e.get('event', '')
print(f' {ts} {client:<20} {endpoint:<20} {event}')
except:
pass
def fw_events(file, filter_ip, filter_type, clients_dir, limit):
"""Format firewall drop events"""
import glob
proto_map = {1: 'icmp', 6: 'tcp', 17: 'udp'}
# Build ip->name map
ip_to_name = {}
for conf in glob.glob(f"{clients_dir}/*.conf"):
name = os.path.basename(conf).replace('.conf', '')
try:
with open(conf) as f:
for line in f:
if line.startswith('Address'):
ip = line.split('=')[1].strip().split('/')[0]
ip_to_name[ip] = name
except:
pass
events = []
last_seen = {} # (src, dst, port, proto) -> last timestamp
try:
with open(file) as f:
for line in f:
try:
e = json.loads(line.strip())
src = e.get('src_ip', '')
if not src:
continue
if filter_ip and src != filter_ip:
continue
# Dedup key
dst = e.get('dest_ip', '')
port = e.get('dest_port', '')
proto = e.get('ip.protocol', 0)
key = (src, dst, port, proto)
ts_str = e.get('timestamp', '')
try:
from datetime import datetime
ts = datetime.fromisoformat(ts_str).timestamp()
except:
ts = 0
# Protocol-aware dedup window
dedup_windows = {1: 5, 6: 30, 17: 10} # icmp=5s, tcp=30s, udp=10s
window = dedup_windows.get(proto, 10)
# Skip if same event within protocol window
if key in last_seen and (ts - last_seen[key]) < window:
continue
last_seen[key] = ts
events.append(e)
except:
pass
except:
pass
for e in events[-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))
dst_str = f"{dst}:{port}" if port else dst
client = ip_to_name.get(src, src)
if filter_type and not client.startswith(filter_type + '-'):
continue
print(f"{ts}|{client}|{dst_str}|{proto}")
def wg_events(file, filter_client, filter_type, limit):
"""Format WireGuard events from events.log"""
events = []
try:
with open(file) as f:
for line in f:
try:
e = json.loads(line.strip())
client = e.get('client', '')
if not client:
continue
if filter_client and client != filter_client:
continue
if filter_type and not client.startswith(filter_type + '-'):
continue
events.append(e)
except:
pass
except:
pass
for e in events[-int(limit):]:
ts = e.get('timestamp', '')
try:
from datetime import datetime
dt = datetime.fromisoformat(ts)
ts = dt.strftime(DATETIME_FMT)
except:
pass
client = e.get('client', '')
endpoint = e.get('endpoint', '')
event = e.get('event', '')
print(f"{ts}|{client}|{endpoint}|{event}")
def format_fw_event(line, clients_dir):
"""Format a single fw_event line"""
import glob
proto_map = {1: 'icmp', 6: 'tcp', 17: 'udp'}
# Build ip->name map
ip_to_name = {}
for conf in glob.glob(f"{clients_dir}/*.conf"):
name = os.path.basename(conf).replace('.conf', '')
try:
with open(conf) as f:
for l in f:
if l.startswith('Address'):
ip = l.split('=')[1].strip().split('/')[0]
ip_to_name[ip] = name
except:
pass
try:
e = json.loads(line.strip())
src = e.get('src_ip', '')
if not src:
return None
ts = e.get('timestamp', '')
try:
from datetime import datetime
dt = datetime.fromisoformat(ts)
ts = dt.strftime(DATETIME_FMT)
except:
pass
dst = e.get('dest_ip', '')
port = e.get('dest_port', '')
proto_num = e.get('ip.protocol', 0)
proto = proto_map.get(proto_num, str(proto_num))
dst_str = f"{dst}:{port}" if port else dst
client = ip_to_name.get(src, src)
return f"{ts}|{client}|{dst_str}|{proto}"
except:
return None
def format_wg_event(line):
"""Format a single wg_event line"""
try:
e = json.loads(line.strip())
client = e.get('client', '')
if not client:
return None
ts = e.get('timestamp', '')
try:
from datetime import datetime
dt = datetime.fromisoformat(ts)
ts = dt.strftime(DATETIME_FMT)
except:
pass
endpoint = e.get('endpoint', '')
event = e.get('event', '')
return f"{ts}|{client}|{endpoint}|{event}|wg"
except:
return None
def remove_events(file, identifier):
"""Remove all events for a client/ip from a JSONL file"""
try:
lines = []
with open(file) as f:
for line in f:
try:
e = json.loads(line.strip())
if e.get('client') == identifier or e.get('src_ip') == identifier:
continue
lines.append(line)
except:
lines.append(line)
with open(file, 'w') as f:
f.writelines(lines)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def follow_logs(fw_file, wg_file, filter_ip, filter_type, clients_dir, filter_peers=""):
"""Follow both log files and output formatted events"""
import glob, time, select
proto_map = {1: 'icmp', 6: 'tcp', 17: 'udp'}
peer_filter = set(filter_peers.split(',')) if filter_peers else set()
import sys
print(f"DEBUG follow_logs: peer_filter={peer_filter}", file=sys.stderr, flush=True)
# Build ip->name map
ip_to_name = {}
for conf in glob.glob(f"{clients_dir}/*.conf"):
name = os.path.basename(conf).replace('.conf', '')
try:
with open(conf) as f:
for l in f:
if l.startswith('Address'):
ip = l.split('=')[1].strip().split('/')[0]
ip_to_name[ip] = name
except:
pass
# Open files and seek to end
files = {}
for label, path in [('fw', fw_file), ('wg', wg_file)]:
if path and os.path.exists(path):
f = open(path)
f.seek(0, 2) # seek to end
files[label] = f
dedup = {}
try:
while True:
for label, f in files.items():
line = f.readline()
if not line:
continue
try:
e = json.loads(line.strip())
except:
continue
if label == 'fw':
src = e.get('src_ip', '')
if not src:
continue
if filter_ip and src != filter_ip:
continue
# Filter by peer names if specified
if peer_filter:
client_name = ip_to_name.get(src, '')
if client_name not in peer_filter:
continue
dst = e.get('dest_ip', '')
port = e.get('dest_port', '')
proto_num = e.get('ip.protocol', 0)
proto = proto_map.get(proto_num, str(proto_num))
# Dedup
key = (src, dst, port, proto_num)
windows = {1: 5, 6: 30, 17: 10}
window = windows.get(proto_num, 10)
now = time.time()
if key in dedup and (now - dedup[key]) < window:
continue
dedup[key] = now
client = ip_to_name.get(src, src)
if filter_type and not client.startswith(filter_type + '-'):
continue
dst_str = f"{dst}:{port}" if port else dst
ts = e.get('timestamp', '')[:16].replace('T', ' ')
print(f"fw|{ts}|{client}|{dst_str}|{proto}", flush=True)
elif label == 'wg':
client = e.get('client', '')
if not client:
continue
if filter_ip:
ip = ip_to_name.get(filter_ip, '')
if client != ip and client != filter_ip:
continue
if peer_filter and client not in peer_filter:
continue
if filter_type and not client.startswith(filter_type + '-'):
continue
ts = e.get('timestamp', '')[:16].replace('T', ' ')
endpoint = e.get('endpoint', '')
event = e.get('event', '')
print(f"wg|{ts}|{client}|{endpoint}|{event}", flush=True)
time.sleep(0.1)
except KeyboardInterrupt:
pass
def count(file, key):
try:
with open(file) as f:
data = json.load(f)
val = data.get(key, [])
print(len(val) if isinstance(val, list) else 0)
except:
print(0)
def audit_fw_counts(clients_dir):
"""Return peer_name:fw_count pairs from iptables and client configs"""
import glob, subprocess
# Get iptables output once
try:
result = subprocess.run(
['iptables', '-L', 'FORWARD', '-n'],
capture_output=True, text=True
)
fw_output = result.stdout
except:
fw_output = ""
# Build ip->name and count rules
for conf in glob.glob(f"{clients_dir}/*.conf"):
name = os.path.basename(conf).replace('.conf', '')
try:
with open(conf) as f:
for line in f:
if line.startswith('Address'):
ip = line.split('=')[1].strip().split('/')[0]
count = fw_output.count(ip)
print(f"{name}:{count}")
break
except:
pass
def peer_group_map(groups_dir):
"""Return peer:group pairs for all groups"""
import glob
try:
for group_file in glob.glob(f"{groups_dir}/*.group"):
try:
with open(group_file) as f:
g = json.load(f)
name = g.get('name', '')
for peer in g.get('peers', []):
if peer:
print(f"{peer}:{name}")
except:
pass
except:
pass
def peer_groups(groups_dir, peer_name):
"""Find all groups containing a peer"""
import glob
try:
for group_file in glob.glob(f"{groups_dir}/*.group"):
try:
with open(group_file) as f:
g = json.load(f)
if peer_name in g.get('peers', []):
print(g.get('name', ''))
except:
pass
except:
pass
def peer_data(clients_dir, meta_dir, events_log):
import glob
meta = {}
for f in glob.glob(f"{meta_dir}/*.meta"):
name = os.path.basename(f).replace('.meta', '')
try:
with open(f) as mf:
meta[name] = json.load(mf)
except:
meta[name] = {}
last_events = {}
try:
with open(events_log) as f:
for line in f:
try:
e = json.loads(line.strip())
client = e.get('client', '')
if client:
last_events[client] = e
except:
pass
except:
pass
for conf in sorted(glob.glob(f"{clients_dir}/*.conf")):
name = os.path.basename(conf).replace('.conf', '')
ip = ''
try:
with open(conf) as f:
for line in f:
if line.startswith('Address'):
ip = line.split('=')[1].strip().split('/')[0]
break
except:
pass
m = meta.get(name, {})
rule = m.get('rule', '')
subtype = m.get('subtype', '')
last_event = last_events.get(name, {})
last_ts = last_event.get('timestamp', '') # raw ISO, no formatting
last_evt = last_event.get('event', '') # fixed: was last_event
print(f"{name}|{ip}|{rule}|{subtype}|{last_ts}|{last_evt}")
def iso_to_ts(iso_str):
"""Convert ISO timestamp to unix timestamp"""
try:
from datetime import datetime, timezone
dt = datetime.fromisoformat(iso_str)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
print(int(dt.timestamp()))
except:
print(0)
def rule_list_data(rules_dir, meta_dir):
"""Return all rule data for list display in one call"""
import glob
# Count peers per rule from meta files
rule_peer_counts = {}
for f in glob.glob(f"{meta_dir}/*.meta"):
try:
with open(f) as mf:
meta = json.load(mf)
rule = meta.get('rule', '')
if rule:
rule_peer_counts[rule] = rule_peer_counts.get(rule, 0) + 1
except:
pass
# Read each rule file
for rule_file in sorted(glob.glob(f"{rules_dir}/*.rule")):
try:
with open(rule_file) as f:
r = json.load(f)
name = r.get('name', '')
desc = r.get('desc', '')
n_allows = len(r.get('allow_ips', [])) + len(r.get('allow_ports', []))
n_blocks = len(r.get('block_ips', [])) + len(r.get('block_ports', []))
peer_count = rule_peer_counts.get(name, 0)
print(f"{name}|{desc}|{n_allows}|{n_blocks}|{peer_count}")
except:
pass
def group_list_data(groups_dir, blocks_dir):
"""Return group summary data in one call"""
import glob
# Get all block files
blocked_peers = set()
for f in glob.glob(f"{blocks_dir}/*.block"):
name = os.path.basename(f).replace('.block', '')
blocked_peers.add(name)
for group_file in sorted(glob.glob(f"{groups_dir}/*.group")):
try:
with open(group_file) as f:
g = json.load(f)
name = g.get('name', '')
desc = g.get('desc', '')
peers = [p for p in g.get('peers', []) if p]
total = len(peers)
blocked = sum(1 for p in peers if p in blocked_peers)
print(f"{name}|{desc}|{total}|{blocked}")
except:
pass
def fmt_datetime(iso_str, fmt):
"""Format ISO timestamp with given strftime format"""
try:
from datetime import datetime
dt = datetime.fromisoformat(iso_str)
print(dt.strftime(fmt))
except:
print(iso_str)
def create_rule(file, name, desc, dns_redirect, allow_ips, block_ips, block_ports):
rule = {
'name': name,
'desc': desc,
'dns_redirect': dns_redirect == 'true',
'allow_ips': [x for x in allow_ips.split(',') if x] if allow_ips else [],
'block_ips': [x for x in block_ips.split(',') if x] if block_ips else [],
'block_ports': [x for x in block_ports.split(',') if x] if block_ports else [],
'allow_ports': []
}
with open(file, 'w') as f:
json.dump(rule, f, indent=2)
def cleanup_config(config_file):
"""Normalize blank lines in WireGuard config"""
import re
try:
with open(config_file) as f:
config = f.read()
config = re.sub(r'\n{3,}', '\n\n', config)
config = config.rstrip('\n') + '\n'
with open(config_file, 'w') as f:
f.write(config)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def remove_peer_block(config_file, name):
"""Remove a peer block from WireGuard config by name"""
import re
try:
with open(config_file) as f:
config = f.read()
pattern = r'\n\[Peer\]\n# ' + re.escape(name) + r'\n[^\n]+\n[^\n]+\n'
result = re.sub(pattern, '\n', config)
with open(config_file, 'w') as f:
f.write(result)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def create_group(file, name, desc):
"""Create a new group JSON file"""
try:
group = {'name': name, 'desc': desc, 'peers': []}
with open(file, 'w') as f:
json.dump(group, f, indent=2)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def parse_event(line):
"""Parse a single JSON event line"""
try:
e = json.loads(line)
print(f"{e.get('timestamp','')}|{e.get('client','')}|{e.get('endpoint','')}|{e.get('event','')}")
except:
pass
def parse_fw_event(line):
"""Parse a single fw_events.log JSON line"""
try:
e = json.loads(line)
ts = e.get('timestamp', '')
src = e.get('src_ip', '')
dst = e.get('dest_ip', '')
port = e.get('dest_port', '')
proto_map = {1: 'icmp', 6: 'tcp', 17: 'udp'}
proto_num = e.get('ip.protocol', 0)
proto = proto_map.get(proto_num, str(proto_num))
print(f"{ts}|{src}|{dst}|{port}|{proto}")
except:
pass
def peer_transfer(wg_interface):
"""Get transfer bytes per peer from wg show"""
import subprocess
try:
result = subprocess.run(
['wg', 'show', wg_interface, 'transfer'],
capture_output=True, text=True
)
for line in result.stdout.strip().split('\n'):
if not line:
continue
parts = line.split('\t')
if len(parts) == 3:
pubkey, rx, tx = parts
total = int(rx) + int(tx)
if total == 0:
level = 'none'
elif total < 1_000_000:
level = 'low'
elif total < 10_000_000:
level = 'medium'
elif total < 100_000_000:
level = 'high'
else:
level = 'very high'
print(f"{pubkey}|{rx}|{tx}|{level}")
except:
pass
commands = {
'get': lambda args: get(args[0], args[1]),
'set': lambda args: set_key(args[0], args[1], args[2]),
'delete': lambda args: delete_key(args[0], args[1]),
'append': lambda args: append(args[0], args[1], args[2]),
'remove': lambda args: remove_value(args[0], args[1], args[2]),
'cat': lambda args: cat(args[0]),
'has_key': lambda args: has_key(args[0], args[1]),
'filter_values': lambda args: filter_values(args[0], args[1], args[2]),
'last_event': lambda args: last_event(args[0], args[1], args[2], args[3]),
'events_for': lambda args: events_for(args[0], args[1], args[2]),
'fw_events': lambda args: fw_events(args[0], args[1], args[2], args[3], args[4]),
'wg_events': lambda args: wg_events(args[0], args[1], args[2], args[3]),
'format_fw_event': lambda args: format_fw_event(sys.stdin.read(), args[0]),
'format_wg_event': lambda args: format_wg_event(sys.stdin.read()),
'remove_events': lambda args: remove_events(args[0], args[1]),
'follow_logs': lambda args: follow_logs(args[0], args[1], args[2], args[3], args[4], args[5]),
'count': lambda args: count(args[0], args[1]),
'audit_fw_counts': lambda args: audit_fw_counts(args[0]),
'peer_group_map': lambda args: peer_group_map(args[0]),
'peer_groups': lambda args: peer_groups(args[0], args[1]),
'peer_data': lambda args: peer_data(args[0], args[1], args[2]),
'iso_to_ts': lambda args: iso_to_ts(args[0]),
'rule_list_data': lambda args: rule_list_data(args[0], args[1]),
'group_list_data': lambda args: group_list_data(args[0], args[1]),
'fmt_datetime': lambda args: fmt_datetime(args[0], args[1]),
'create_rule': lambda args: create_rule(args[0], args[1], args[2], args[3], args[4], args[5], args[6]),
'cleanup_config': lambda args: cleanup_config(args[0]),
'remove_peer_block': lambda args: remove_peer_block(args[0], args[1]),
'create_group': lambda args: create_group(args[0], args[1], args[2]),
'parse_event': lambda args: parse_event(args[0]),
'parse_fw_event': lambda args: parse_fw_event(args[0]),
'peer_transfer': lambda args: peer_transfer(args[0]),
}
if __name__ == '__main__':
if len(sys.argv) < 2:
print("Usage: json_helper.py <command> <file> [key] [value]", file=sys.stderr)
sys.exit(1)
cmd = sys.argv[1]
args = sys.argv[2:]
if cmd not in commands:
print(f"Unknown command: {cmd}", file=sys.stderr)
sys.exit(1)
commands[cmd](args)

View file

@ -41,6 +41,18 @@ function ui::pad() {
printf "%b%${pad}s" "$text" ""
}
function ui::pad_status() {
ui::pad "${1:-}" "${2:-25}"
}
function ui::center() {
local text="$1" width="${2:-8}"
local len=${#text}
local pad=$(( (width - len) / 2 ))
local rpad=$(( width - len - pad ))
printf "%${pad}s%s%${rpad}s" "" "$text" ""
}
function ui::firewall_rule() {
local rule="$1"
if [[ "$rule" =~ ACCEPT|DNAT ]]; then

View file

@ -1,9 +1,9 @@
{
"phone-fred": "94.63.0.129",
"phone-helena": "148.69.46.241",
"phone-nuno": "148.69.51.160",
"phone-helena": "148.69.46.73",
"phone-nuno": "94.63.0.129",
"tablet-nuno": "148.69.202.5",
"guest-zephyr": "5.13.82.5",
"guest-zephyr-test": "148.69.193.47",
"guest-zephyr-test": "94.63.0.129",
"desktop-roboclean": "46.189.215.231"
}

View file

@ -15,6 +15,15 @@ function config::on_load() {
# Defaults
# ============================================
# Activity thresholds
declare -g _ACTIVITY_TOTAL_LOW_BYTES="${ACTIVITY_TOTAL_LOW_BYTES:-1000000}"
declare -g _ACTIVITY_TOTAL_MED_BYTES="${ACTIVITY_TOTAL_MED_BYTES:-10000000}"
declare -g _ACTIVITY_TOTAL_HIGH_BYTES="${ACTIVITY_TOTAL_HIGH_BYTES:-100000000}"
declare -g _ACTIVITY_CURRENT_LOW_BYTES="${ACTIVITY_CURRENT_LOW_BYTES:-1000000}"
declare -g _ACTIVITY_CURRENT_MED_BYTES="${ACTIVITY_CURRENT_MED_BYTES:-10000000}"
declare -g _ACTIVITY_CURRENT_HIGH_BYTES="${ACTIVITY_CURRENT_HIGH_BYTES:-100000000}"
function config::_init_defaults() {
_WG_INTERFACE="${WG_INTERFACE:-wg0}"
_WG_DNS="${WG_DNS:-10.0.0.103}"
@ -22,6 +31,7 @@ function config::_init_defaults() {
_WG_SUBNET="${WG_SUBNET:-10.1.0.0/16}"
_WG_PORT="${WG_PORT:-51820}"
_WG_ENDPOINT="${WG_ENDPOINT:-}"
_WG_HANDSHAKE_CHECK_TIME_SEC="${WG_HANDSHAKE_CHECK_TIME_SEC:-180}"
# Derived
_WG_CONFIG="$(ctx::wg)/${_WG_INTERFACE}.conf"
@ -45,13 +55,16 @@ function config::load() {
key="${key// /}"
value="${value// /}"
case "$key" in
WG_INTERFACE) _WG_INTERFACE="$value" ;;
WG_ENDPOINT) _WG_ENDPOINT="$value" ;;
WG_DNS) _WG_DNS="$value" ;;
WG_PORT) _WG_PORT="$value" ;;
WG_SUBNET) _WG_SUBNET="$value" ;;
WG_LAN) _WG_LAN="$value" ;;
# Add debug temporarily to config::load:
WG_INTERFACE) _WG_INTERFACE="$value" ;;
WG_ENDPOINT) _WG_ENDPOINT="$value" ;;
WG_DNS) _WG_DNS="$value" ;;
WG_PORT) _WG_PORT="$value" ;;
WG_SUBNET) _WG_SUBNET="$value" ;;
WG_LAN) _WG_LAN="$value" ;;
WG_HANDSHAKE_CHECK_TIME_SEC) _WG_HANDSHAKE_CHECK_TIME_SEC="$value" ;;
ACTIVITY_LOW_BYTES) _ACTIVITY_LOW_BYTES="$value" ;;
ACTIVITY_MED_BYTES) _ACTIVITY_MED_BYTES="$value" ;;
ACTIVITY_HIGH_BYTES) _ACTIVITY_HIGH_BYTES="$value" ;;
DATE_FORMAT)
_FMT_DATE_FORMAT="$value"
fmt::set_date_format "$value"
@ -100,15 +113,22 @@ declare -gA DEVICE_TUNNEL_MODE=(
# Accessors
# ============================================
function config::interface() { echo "$_WG_INTERFACE"; }
function config::config_file() { echo "$_WG_CONFIG"; }
function config::endpoint() { echo "$_WG_ENDPOINT"; }
function config::dns() { echo "$_WG_DNS"; }
function config::port() { echo "$_WG_PORT"; }
function config::subnet() { echo "$_WG_SUBNET"; }
function config::lan() { echo "$_WG_LAN"; }
function config::tunnel_split() { echo "$_WG_TUNNEL_SPLIT"; }
function config::tunnel_full() { echo "$_WG_TUNNEL_FULL"; }
function config::interface() { echo "$_WG_INTERFACE"; }
function config::config_file() { echo "$_WG_CONFIG"; }
function config::endpoint() { echo "$_WG_ENDPOINT"; }
function config::dns() { echo "$_WG_DNS"; }
function config::port() { echo "$_WG_PORT"; }
function config::subnet() { echo "$_WG_SUBNET"; }
function config::lan() { echo "$_WG_LAN"; }
function config::tunnel_split() { echo "$_WG_TUNNEL_SPLIT"; }
function config::tunnel_full() { echo "$_WG_TUNNEL_FULL"; }
function config::handshake_time_sec() { echo "$_WG_HANDSHAKE_CHECK_TIME_SEC"; }
function config::activity_total_low() { echo "$_ACTIVITY_TOTAL_LOW_BYTES"; }
function config::activity_total_med() { echo "$_ACTIVITY_TOTAL_MED_BYTES"; }
function config::activity_total_high() { echo "$_ACTIVITY_TOTAL_HIGH_BYTES"; }
function config::activity_current_low() { echo "$_ACTIVITY_TOTAL_LOW_BYTES"; }
function config::activity_current_med() { echo "$_ACTIVITY_TOTAL_MED_BYTES"; }
function config::activity_current_high() { echo "$_ACTIVITY_TOTAL_HIGH_BYTES"; }
function config::server_public_key() {
cat "$_WG_SERVER_PUBLIC_KEY_FILE"

View file

@ -70,6 +70,13 @@ function monitor::events_for() {
json::events_for "$EVENTS_LOG" "$ip" "$limit"
}
function monitor::get_handshake_ts() {
local public_key="${1:-}"
[[ -z "$public_key" ]] && echo "0" && return
wg show "$(config::interface)" latest-handshakes 2>/dev/null \
| grep "^${public_key}" | awk '{print $2}' || echo "0"
}
# ============================================
# Endpoint Cache (for blocked clients)
# ============================================

View file

@ -238,6 +238,52 @@ function peers::find_by_ip() {
done < <(peers::all)
}
function peers::is_connected() {
local handshake_ts="${1:-0}"
local now diff threshold
now=$(date +%s)
threshold=$(config::handshake_time_sec)
[[ "$handshake_ts" == "0" || -z "$handshake_ts" ]] && return 1
diff=$(( now - handshake_ts ))
(( diff < threshold ))
}
function peers::is_attempting() {
local last_ts="${1:-}"
[[ -z "$last_ts" ]] && return 1
local now attempt_ts diff threshold
now=$(date +%s)
threshold=$(config::handshake_time_sec)
attempt_ts=$(json::iso_to_ts "$last_ts")
[[ -z "$attempt_ts" || "$attempt_ts" == "0" ]] && return 1
diff=$(( now - attempt_ts ))
(( diff < threshold ))
}
function peers::is_online() {
local name="${1:-}" handshake_ts="${2:-0}" last_ts="${3:-}"
local is_blocked
peers::is_blocked "$name" && is_blocked="true" || is_blocked="false"
if [[ "$is_blocked" == "true" ]]; then
peers::is_attempting "$last_ts"
else
peers::is_connected "$handshake_ts"
fi
}
function peers::is_offline() {
local name="${1:-}" handshake_ts="${2:-0}" last_ts="${3:-}"
peers::is_online "$name" "$handshake_ts" "$last_ts" && return 1 || return 0
}
# function peers::is_offline() {
# local name="${1:-}" handshake_ts="${2:-0}" last_ts="${3:-}"
# if peers::is_online "$name" "$handshake_ts" "$last_ts"; then
# return 1
# fi
# return 0
# }
# ============================================
# Name + Type Parsing
# ============================================
@ -275,6 +321,167 @@ function peers::resolve_and_require() {
echo "$resolved"
}
# ============================================
# Display / Formatting
# ============================================
function peers::format_last_seen() {
local name="${1:-}" pubkey="${2:-}" is_blocked="${3:-false}"
local last_ts="${4:-}" last_evt="${5:-}" handshake_ts="${6:-0}"
local data
data=$(peers::last_seen_data "$is_blocked" "$last_ts" "$handshake_ts")
local ts type
IFS="|" read -r ts type <<< "$data"
case "$type" in
none) echo "—" ;;
dropped) echo "$(fmt::datetime_iso "$ts") (dropped)" ;;
handshake) echo "$(fmt::datetime "$ts") (handshake)" ;;
esac
}
function peers::format_status() {
local name="${1:-}" public_key="${2:-}" is_blocked="${3:-false}"
local is_restricted="${4:-false}" handshake_ts="${5:-0}" last_ts="${6:-}"
local state
state=$(peers::connection_state "$is_blocked" "$is_restricted" \
"$handshake_ts" "$last_ts")
local conn_str modifier color
IFS="|" read -r conn_str modifier color <<< "$state"
local display="$conn_str"
[[ -n "$modifier" ]] && display="${conn_str} (${modifier})"
echo -e "${color}${display}\033[0m"
}
function peers::display_type() {
local type="${1:-}" subtype="${2:-}"
if config::is_guest_type "$type" && [[ -n "$subtype" && "$subtype" != "0" ]]; then
echo "guest/${subtype}"
elif config::is_guest_type "$type"; then
echo "guest"
else
echo "$type"
fi
}
# ============================================
# Connection data
# ============================================
# Data functions — return raw values
function peers::connection_state() {
# Returns: connected|modifier|color_code
local is_blocked="${1:-false}" is_restricted="${2:-false}"
local handshake_ts="${3:-0}" last_ts="${4:-}"
local threshold
threshold=$(config::handshake_time_sec)
local now
now=$(date +%s)
local connected=false modifier="" color
if [[ "$is_blocked" == "true" ]]; then
local attempt_ts diff
attempt_ts=$(json::iso_to_ts "${last_ts:-0}")
diff=$(( now - attempt_ts ))
(( diff < threshold )) && connected=true
modifier="blocked"
color="\033[1;31m"
elif [[ "$is_restricted" == "true" ]]; then
local diff=$(( now - handshake_ts ))
(( diff < threshold )) && connected=true
modifier="restricted"
color="\033[1;33m"
else
local diff=$(( now - handshake_ts ))
(( diff < threshold )) && connected=true
$connected && color="\033[1;32m" || color="\033[0;37m"
fi
$connected && echo "online|${modifier}|${color}" || echo "offline|${modifier}|${color}"
}
function peers::last_seen_data() {
# Returns: timestamp|type (dropped|handshake|none)
local is_blocked="${1:-false}" last_ts="${2:-}" handshake_ts="${3:-0}"
if [[ "$is_blocked" == "true" ]]; then
if [[ -n "$last_ts" && "$last_ts" != "0" && "$last_ts" != "null" ]]; then
echo "${last_ts}|dropped"
else
echo "|none"
fi
else
if [[ -z "$handshake_ts" || "$handshake_ts" == "0" ]]; then
echo "|none"
else
echo "${handshake_ts}|handshake"
fi
fi
}
function peers::get_type_from_ip() {
local ip="${1:-}"
[[ -z "$ip" ]] && echo "unknown" && return 0
local type="unknown"
for t in $(config::device_types); do
local subnet
subnet=$(config::subnet_for "$t")
string::starts_with "$ip" "${subnet}." && type="$t" && break
done
echo "$type"
}
# ============================================
# Activity
# ============================================
# Returns: level|rx|tx
function peers::activity_total() {
local pubkey="${1:-}"
json::peer_transfer "$(config::interface)" | grep "^${pubkey}" | head -1 | cut -d'|' -f2-
}
# Returns: level|rx_rate|tx_rate
function peers::activity_current() {
local pubkey="${1:-}"
json::peer_transfer_delta "$(config::interface)" \
"$(ctx::daemon)/transfer_cache.json" \
| grep "^${pubkey}" | head -1 | cut -d'|' -f2-
}
function peers::format_activity_total() {
local pubkey="${1:-}"
local data
data=$(peers::activity_total "$pubkey")
[[ -z "$data" ]] && echo "—" && return 0
local level rx tx rx_hr tx_hr
IFS="|" read -r rx tx level <<< "$data"
rx_hr=$(numfmt --to=iec "${rx:-0}" 2>/dev/null || echo "0B")
tx_hr=$(numfmt --to=iec "${tx:-0}" 2>/dev/null || echo "0B")
echo "${level:-none} (↓${rx_hr}${tx_hr})"
}
function peers::format_activity_current() {
local pubkey="${1:-}"
local data
data=$(peers::activity_current "$pubkey")
[[ -z "$data" ]] && echo "—" && return 0
local level rx_rate tx_rate rx_hr tx_hr
IFS="|" read -r rx_rate tx_rate level <<< "$data"
[[ "$level" == "unknown" ]] && echo "sampling..." && return 0
local rx_hr tx_hr
rx_hr=$(numfmt --to=iec "${rx_rate:-0}" 2>/dev/null || echo "${rx_rate:-0}")
tx_hr=$(numfmt --to=iec "${tx_rate:-0}" 2>/dev/null || echo "${tx_rate:-0}")
echo "${level} (↓${rx_hr}B/s ↑${tx_hr}B/s)"
}
# ============================================
# Helpers - Meta File
# ============================================

View file

@ -4,9 +4,23 @@
# Rule File Parsing
# ============================================
function rule::is_base() {
local name="${1:-}"
[[ -f "$(ctx::rules::base)/${name}.rule" ]]
}
function rule::exists() {
local name="${1:-}"
[[ -f "$(ctx::rule::path "${name}.rule")" ]]
[[ -f "$(rule::path "${name}")" ]] || \
[[ -f "$(ctx::rules::base)/${name}.rule" ]]
}
function rule::require_assignable() {
local name="${1:-}"
if rule::is_base "$name"; then
log::error "Cannot assign base rule '${name}' — base rules cannot be assigned directly"
return 1
fi
}
function rule::require_exists() {
@ -19,12 +33,22 @@ function rule::require_exists() {
function rule::get() {
local name="${1:-}" key="${2:-}"
json::get "$(ctx::rule::path "${name}.rule")" "$key"
json::rule_resolve_field "$(ctx::rules)" "$name" "$key"
}
function rule::get_resolved() {
local name="${1:-}"
json::rule_resolve "$(ctx::rules)" "$name"
}
function rule::path() {
local name="${1:-}"
json::find_rule_file "$(ctx::rules)" "$name"
}
function rule::get_all() {
local name="${1:-}"
cat "$(ctx::rule::path "${name}.rule")"
rule::get_resolved "$name"
}
function rule::is_applied() {

1
wgctl
View file

@ -50,7 +50,6 @@ declare -A CMD_ALIASES=(
[disable]=service
)
# ============================================
# Dispatch
# ============================================