feat: identity, subnet, policy systems + tableless layouts

This commit is contained in:
Nuno Duque Nunes 2026-05-22 03:42:40 +00:00
parent 92d829e184
commit 4b2f2a846a
20 changed files with 1523 additions and 493 deletions

View file

@ -273,25 +273,31 @@ function cmd::add::_apply_identity_rule() {
[[ -z "$identity_name" ]] && return 0 [[ -z "$identity_name" ]] && return 0
local identity_rule local rules
identity_rule=$(identity::rule "$identity_name") || true rules=$(identity::rules "$identity_name")
[[ -z "$identity_rule" ]] && { if [[ -z "$rules" ]]; then
# No identity rule — warn if no peer rule either # No identity rules — warn if no peer rule either
if [[ -z "$peer_rule" ]]; then if [[ -z "$peer_rule" ]]; then
policy::warn_no_rule "$full_name" policy::warn_no_rule "$full_name"
fi fi
return 0 return 0
} fi
# Apply identity rule # Apply all identity rules
rule::exists "$identity_rule" && rule::apply "$identity_rule" "$ip" "$full_name" || true rule::_apply_identity_rule "$full_name" "$ip"
# Warn based on strict_rule # Warn based on strict_rule
if policy::strict_rule "$effective_policy"; then local strict
policy::warn_strict_rule "$identity_name" "$effective_policy" "$identity_rule" strict=$(identity::rule_flags "$identity_name" "strict_rule")
elif [[ -n "$peer_rule" && "$peer_rule" != "$identity_rule" ]]; then if [[ "$strict" == "true" ]]; then
policy::warn_additive_rule "$identity_name" "$identity_rule" "$peer_rule" local rule_list
rule_list=$(echo "$rules" | tr '\n' ',' | sed 's/,$//')
policy::warn_strict_rule "$identity_name" "$effective_policy" "$rule_list"
elif [[ -n "$peer_rule" ]]; then
local rule_list
rule_list=$(echo "$rules" | tr '\n' ',' | sed 's/,$//')
policy::warn_additive_rule "$identity_name" "$rule_list" "$peer_rule"
fi fi
} }

View file

@ -107,10 +107,13 @@ function cmd::identity::_list() {
return 0 return 0
fi fi
ui::identity::header echo ""
while IFS='|' read -r name peer_count types; do while IFS='|' read -r name peer_count types rules policy; do
ui::identity::row "$name" "$peer_count" "$types" local rules_display
rules_display=$(echo "$rules" | sed 's/,/, /g')
ui::identity::list_row_compact "$name" "$peer_count" "$rules_display" "$policy"
done <<< "$data" done <<< "$data"
echo ""
} }
function cmd::identity::_show() { function cmd::identity::_show() {
@ -126,19 +129,40 @@ function cmd::identity::_show() {
[[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; }
identity::require_exists "$name" || return 1 identity::require_exists "$name" || return 1
local data peer_count="0" # Gather identity-level metadata
data=$(identity::show_data "$name") local policy strict auto rules_list peer_count
policy=$(identity::policy "$name")
strict=$(identity::rule_flags "$name" "strict_rule")
auto=$(identity::rule_flags "$name" "auto_apply")
rules_list=$(identity::rules "$name" | tr '\n' ',' | sed 's/,$//' | sed 's/,/, /g')
local data
data=$(identity::show_data "$name")
peer_count=$(echo "$data" | grep '^peer_count|' | cut -d'|' -f2)
# Precompute handshakes once for all peers in this identity
declare -A _id_handshakes=()
while IFS=$'\t' read -r pk ts; do
[[ -n "$pk" ]] && _id_handshakes["$pk"]="$ts"
done < <(wg show "$(config::interface)" latest-handshakes 2>/dev/null)
# Header
echo ""
ui::row "Identity" "$name"
ui::row "Policy" "$policy"
ui::row "Rules" "${rules_list:-}"
ui::row "Strict rule" "$(ui::bool "$strict")"
ui::row "Auto apply" "$(ui::bool "$auto")"
ui::row "Peers" "$peer_count"
echo ""
# Device list
while IFS='|' read -r key val type_val index_val; do while IFS='|' read -r key val type_val index_val; do
case "$key" in case "$key" in
name) name|peer_count) ;;
peer_count=$(echo "$data" | grep '^peer_count|' | cut -d'|' -f2)
ui::identity::detail_name "$val" "$peer_count"
;;
peer_count) ;; # consumed above
device) device)
local status="" local status=""
status=$(cmd::identity::_device_status "$val") status=$(cmd::identity::_device_status "$val" _id_handshakes)
ui::identity::device_row "$val" "$type_val" "$index_val" "$status" ui::identity::device_row "$val" "$type_val" "$index_val" "$status"
;; ;;
esac esac
@ -147,25 +171,27 @@ function cmd::identity::_show() {
echo "" echo ""
} }
function cmd::identity::_device_status() { function cmd::identity::_device_status() {
local peer_name="${1:-}" local peer_name="${1:-}"
local -n _handshakes="${2:-__empty_map}"
local peer_ip local peer_ip
peer_ip=$(peers::get_ip "$peer_name" 2>/dev/null) || return 0 peer_ip=$(peers::get_ip "$peer_name" 2>/dev/null) || return 0
[[ -z "$peer_ip" ]] && return 0 [[ -z "$peer_ip" ]] && return 0
local is_blocked is_restricted pubkey handshake_ts local is_blocked is_restricted pubkey handshake_ts
is_blocked=$(peers::is_blocked "$peer_name") peers::is_blocked "$peer_name" && is_blocked="true" || is_blocked="false"
is_restricted=$(peers::is_restricted "$peer_name") peers::is_restricted "$peer_name" && is_restricted="true" || is_restricted="false"
pubkey=$(cat "$(ctx::clients)/${peer_name}.pub" 2>/dev/null) || pubkey=""
handshake_ts=$(wg show wg0 latest-handshakes 2>/dev/null \
| awk -v pk="$pubkey" '$1==pk{print $2}') || handshake_ts=0
local last_ts last_evt pubkey="$(keys::public "$peer_name")"
last_ts=$(peers::get_meta "$peer_name" "last_ts" 2>/dev/null) || last_ts="" handshake_ts="${_handshakes[$pubkey]:-0}"
last_evt=$(peers::get_meta "$peer_name" "last_evt" 2>/dev/null) || last_evt=""
local last_ts
last_ts=$(peers::get_meta "$peer_name" "last_ts" 2>/dev/null) || last_ts=""
local status local status
status=$(peers::format_status \ status=$(peers::format_status_verbose \
"$peer_name" "$pubkey" "$is_blocked" "$is_restricted" "$handshake_ts" "$last_ts") "$peer_name" "$pubkey" "$is_blocked" "$is_restricted" "$handshake_ts" "$last_ts")
echo "${status}" echo "${status}"
} }
@ -336,12 +362,19 @@ function cmd::identity::_rule_assign() {
[[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; }
[[ -z "$rule" ]] && { log::error "Missing required flag: --rule"; return 1; } [[ -z "$rule" ]] && { log::error "Missing required flag: --rule"; return 1; }
identity::require_exists "$name" || return 1 identity::require_exists "$name" || return 1
rule::exists "$rule" || { log::error "Rule not found: ${rule}"; return 1; } rule::exists "$rule" || { log::error "Rule not found: ${rule}"; return 1; }
local exit_code
identity::add_rule "$name" "$rule" || exit_code=$?
if [[ $exit_code -eq 2 ]]; then
log::warn "Rule '${rule}' is already assigned to identity '${name}'"
return 0
fi
identity::set_rule "$name" "$rule"
log::ok "Rule '${rule}' assigned to identity '${name}'" log::ok "Rule '${rule}' assigned to identity '${name}'"
# Reapply rules for all peers if auto_apply is set # Reapply rules if auto_apply
local auto local auto
auto=$(identity::rule_flags "$name" "auto_apply") auto=$(identity::rule_flags "$name" "auto_apply")
if [[ "$auto" != "false" ]]; then if [[ "$auto" != "false" ]]; then
@ -350,17 +383,19 @@ function cmd::identity::_rule_assign() {
log::ok "Rules reapplied" log::ok "Rules reapplied"
fi fi
# Warn about strict_rule impact # Warn about strict_rule
if policy::strict_rule "$(identity::policy "$name")"; then if policy::strict_rule "$(identity::policy "$name")"; then
log::warn "Strict rule is enabled for identity '${name}' — peer rules will not be additive" log::warn "Strict rule is enabled for identity '${name}' — peer rules will not be additive"
fi fi
} }
function cmd::identity::_rule_unassign() { function cmd::identity::_rule_unassign() {
local name="" local name="" rule="" all=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 ;;
--rule) rule="$2"; shift 2 ;;
--all) all=true; shift ;;
*) log::error "Unknown flag: $1"; return 1 ;; *) log::error "Unknown flag: $1"; return 1 ;;
esac esac
done done
@ -368,16 +403,46 @@ function cmd::identity::_rule_unassign() {
[[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; }
identity::require_exists "$name" || return 1 identity::require_exists "$name" || return 1
local current_rule if $all; then
current_rule=$(identity::rule "$name") local rules
if [[ -z "$current_rule" ]]; then rules=$(identity::rules "$name")
log::warn "Identity '${name}' has no rule assigned" if [[ -z "$rules" ]]; then
log::warn "Identity '${name}' has no rules assigned"
return 0
fi
identity::clear_rules "$name"
log::ok "All rules removed from identity '${name}'"
cmd::identity::_reapply_after_unassign "$name"
return 0 return 0
fi fi
identity::clear_rule "$name" [[ -z "$rule" ]] && {
log::ok "Rule removed from identity '${name}'" log::error "Missing required flag: --rule (or use --all to remove all)"
log::info "Note: existing fw rules from '${current_rule}' are not automatically removed — run 'wgctl rule reapply' if needed" return 1
}
identity::remove_rule "$name" "$rule"
local exit_code=$?
if [[ $exit_code -ne 0 ]]; then
log::error "Rule '${rule}' is not assigned to identity '${name}'"
return 1
fi
log::ok "Rule '${rule}' removed from identity '${name}'"
cmd::identity::_reapply_after_unassign "$name"
}
function cmd::identity::_reapply_after_unassign() {
local name="${1:-}"
local auto
auto=$(identity::rule_flags "$name" "auto_apply")
if [[ "$auto" != "false" ]]; then
log::info "Reapplying rules for all peers in identity '${name}'..."
identity::reapply_rules "$name"
log::ok "Rules reapplied"
else
log::info "Note: auto_apply is disabled — run 'wgctl audit --fix' to update fw rules"
fi
} }
function cmd::identity::_rule_show() { function cmd::identity::_rule_show() {
@ -392,20 +457,28 @@ function cmd::identity::_rule_show() {
[[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; }
identity::require_exists "$name" || return 1 identity::require_exists "$name" || return 1
local rule policy local rules policy strict auto
rule=$(identity::rule "$name") rules=$(identity::rules "$name")
policy=$(identity::policy "$name") policy=$(identity::policy "$name")
local strict auto
strict=$(identity::rule_flags "$name" "strict_rule") strict=$(identity::rule_flags "$name" "strict_rule")
auto=$(identity::rule_flags "$name" "auto_apply") auto=$(identity::rule_flags "$name" "auto_apply")
echo "" echo ""
ui::row "Identity" "$name" ui::row "Identity" "$name"
ui::row "Rule" "${rule:-}" ui::row "Policy" "$policy"
ui::row "Policy" "$policy" ui::row "Strict rule" "$(ui::bool "$strict")"
ui::row "Strict rule" "$( [[ "$strict" == "true" ]] && echo "yes" || echo "no" )" ui::row "Auto apply" "$(ui::bool "$auto")"
ui::row "Auto apply" "$( [[ "$auto" != "false" ]] && echo "yes" || echo "no" )" echo ""
if [[ -z "$rules" ]]; then
ui::row "Rules" "— none assigned"
else
printf " %-20s\n" "Rules:"
while IFS= read -r rule_name; do
[[ -z "$rule_name" ]] && continue
printf " · %s\n" "$rule_name"
done <<< "$rules"
fi
echo "" echo ""
} }

View file

@ -89,9 +89,9 @@ function cmd::inspect::_peer_info() {
local activity_current local activity_current
activity_current=$(peers::format_activity_current "$public_key") activity_current=$(peers::format_activity_current "$public_key")
local rule_file=""
local rule_extends="" local rule_extends=""
if [[ -n "$rule" ]]; then if [[ -n "$rule" ]]; then
local rule_file
rule_file="$(rule::path "$rule" 2>/dev/null)" || true rule_file="$(rule::path "$rule" 2>/dev/null)" || true
if [[ -n "$rule_file" ]]; then if [[ -n "$rule_file" ]]; then
local ext=() local ext=()
@ -115,7 +115,7 @@ function cmd::inspect::_peer_info() {
ui::row "Name" "$name" "${INSPECT_LABEL_WIDTH}" ui::row "Name" "$name" "${INSPECT_LABEL_WIDTH}"
ui::row "IP" "$ip" "${INSPECT_LABEL_WIDTH}" ui::row "IP" "$ip" "${INSPECT_LABEL_WIDTH}"
ui::row "Type" "$(peers::display_type "$type")" "${INSPECT_LABEL_WIDTH}" ui::row "Type" "$(peers::display_type "$type")" "${INSPECT_LABEL_WIDTH}"
ui::row "Rule" "$rule_display" "${INSPECT_LABEL_WIDTH}" ui::row "Rule" "$rule_display" "${INSPECT_LABEL_WIDTH}"
ui::row "Status" "$(echo -e "$status")" "${INSPECT_LABEL_WIDTH}" ui::row "Status" "$(echo -e "$status")" "${INSPECT_LABEL_WIDTH}"
ui::row "Endpoint" "${endpoint:-}" "${INSPECT_LABEL_WIDTH}" ui::row "Endpoint" "${endpoint:-}" "${INSPECT_LABEL_WIDTH}"
ui::row "Last seen" "$last_seen" "${INSPECT_LABEL_WIDTH}" ui::row "Last seen" "$last_seen" "${INSPECT_LABEL_WIDTH}"
@ -127,22 +127,81 @@ function cmd::inspect::_peer_info() {
return 0 return 0
} }
# function cmd::inspect::_rule_info() {
# local name="${1:-}"
# local rule
# rule=$(peers::get_meta "$name" "rule")
# [[ -z "$rule" ]] && return 0
# rule::exists "$rule" || return 0
# cmd::inspect::_section "Rule: ${rule}"
# if ui::rule::tree "$rule"; then
# # printf "\n"
# : # no-op
# else
# # No inheritance — flat view
# rule::render_flat "$rule"
# fi
# return 0
# }
function cmd::inspect::_rule_separator() {
local line_width=20
local total=$INSPECT_WIDTH
local pad=$(( (total - line_width) / 2 ))
printf "\n%*s\033[2m%s\033[0m\n\n" "$pad" "" "$(printf '─%.0s' $(seq 1 $line_width))"
}
function cmd::inspect::_rule_info() { function cmd::inspect::_rule_info() {
local name="${1:-}" local name="${1:-}"
local rule local rule
rule=$(peers::get_meta "$name" "rule") rule=$(peers::get_meta "$name" "rule")
[[ -z "$rule" ]] && return 0
rule::exists "$rule" || return 0
cmd::inspect::_section "Rule: ${rule}" local identity_name identity_rules strict
identity_name=$(identity::get_name "$name")
if rule::render_extends_tree "$rule"; then if [[ -n "$identity_name" ]]; then
# printf "\n" identity_rules=$(identity::rules "$identity_name")
: # no-op strict=$(identity::rule_flags "$identity_name" "strict_rule")
else
# No inheritance — flat view
rule::render_flat "$rule"
fi fi
# Skip section entirely if nothing to show
[[ -z "$rule" && -z "$identity_rules" ]] && return 0
# Build section header
local header="Rules"
[[ -n "$rule" ]] && header="${header}: ${rule}"
[[ -n "$identity_name" && -n "$identity_rules" ]] && \
header="${header} · identity:${identity_name}"
cmd::inspect::_section "$header"
# Identity block first
if [[ -n "$identity_name" && -n "$identity_rules" ]]; then
ui::rule::identity_block "$identity_name" "$strict"
fi
# Peer rule block — only if set and not suppressed
if [[ -n "$rule" ]]; then
rule::exists "$rule" || return 0
if [[ -n "$identity_rules" ]]; then
# Both identity and peer rules exist — show peer block with same pattern
printf "\n \033[0;37m· peer:%s\033[0m\n" "$name"
ui::rule::_peer_rule_entry "$rule"
else
# Only peer rule — render directly without peer: label
printf "\n"
if rule::render_extends_tree "$rule"; then
:
else
rule::render_flat "$rule"
fi
fi
elif [[ "$strict" == "true" && -n "$rule" ]]; then
printf "\n \033[2mpeer rule '%s' suppressed by strict policy\033[0m\n" "$rule"
fi
return 0 return 0
} }

View file

@ -5,6 +5,9 @@
# ============================================ # ============================================
function cmd::list::on_load() { function cmd::list::on_load() {
load_module identity
load_module ui
flag::register --type flag::register --type
flag::register --rule flag::register --rule
flag::register --group flag::register --group
@ -29,87 +32,29 @@ Usage: wgctl list [options]
List all WireGuard clients. List all WireGuard clients.
Options: Options:
--type <type> Filter by device type (desktop, laptop, phone, tablet) --type <type> Filter by device type
--rule <rule> Filter by assigned rule --rule <rule> Filter by assigned rule
--group <group> Filter by group membership --group <group> Filter by group membership
--identity <name> Filter by identity (show all peers for an identity) --identity <name> Filter by identity
--online Show only connected clients --online Show only connected clients
--offline Show only disconnected clients --offline Show only disconnected clients
--blocked Show only fully blocked clients (removed from WireGuard) --blocked Show only fully blocked clients
--restricted Show only restricted clients (specific IP/port blocks applied) --restricted Show only restricted clients
--allowed Show only unrestricted clients --allowed Show only unrestricted clients
--detailed Show full detail cards for all clients --detailed Show detailed view grouped by identity
--name <name> Show detail card for a single client --name <name> Show detail card for a single client
Status values:
online Connected (recent handshake)
offline Not connected
blocked Removed from WireGuard server (wgctl block --name)
restricted In WireGuard but with specific access rules (wgctl block --ip/--service)
Examples: Examples:
wgctl list wgctl list
wgctl list --type phone wgctl list --type phone
wgctl list --rule user
wgctl list --group family
wgctl list --identity nuno wgctl list --identity nuno
wgctl list --online wgctl list --online
wgctl list --blocked wgctl list --blocked
wgctl list --restricted
wgctl list --detailed wgctl list --detailed
wgctl list --name phone-nuno wgctl list --name phone-nuno
EOF EOF
} }
# ============================================
# Header / Footer
# ============================================
function cmd::list::_render_header() {
local has_groups="$1"
if $has_groups; then
printf "\n %-28s %-15s %-13s %-12s %-12s %-22s %s\n" \
"NAME" "IP" "TYPE" "RULE" "GROUP" "STATUS" "LAST SEEN"
printf " %s\n" "$(printf '─%.0s' {1..135})"
else
printf "\n %-28s %-15s %-13s %-12s %-22s %s\n" \
"NAME" "IP" "TYPE" "RULE" "STATUS" "LAST SEEN"
printf " %s\n" "$(printf '─%.0s' {1..107})"
fi
}
function cmd::list::_render_footer() {
local has_groups="$1"
if $has_groups; then
printf " %s\n" "$(printf '─%.0s' {1..135})"
else
printf " %s\n" "$(printf '─%.0s' {1..107})"
fi
}
function cmd::list::_render_summary() {
local group_summary="${1:-}"
local -n _rule_counts="$2"
local filter_desc="${3:-}"
local total=0
for r in "${!_rule_counts[@]}"; do
(( total += _rule_counts[$r] )) || true
done
local summary=""
for r in "${!_rule_counts[@]}"; do
summary+="${_rule_counts[$r]} ${r}, "
done
summary="${summary%, }"
if [[ -n "$group_summary" ]]; then
printf "\n Showing %s peers [%s] — %s\n\n" "$total" "$summary" "$group_summary"
else
printf "\n Showing %s peers [%s]\n\n" "$total" "$summary"
fi
}
# ============================================ # ============================================
# Detail Card # Detail Card
# ============================================ # ============================================
@ -133,7 +78,6 @@ function cmd::list::show_client() {
local public_key local public_key
public_key=$(keys::public "$name" 2>/dev/null || echo "unknown") public_key=$(keys::public "$name" 2>/dev/null || echo "unknown")
# Meta type is authoritative; IP reverse lookup is fallback for pre-migration peers
local type local type
type=$(peers::get_meta "$name" "type" 2>/dev/null) type=$(peers::get_meta "$name" "type" 2>/dev/null)
[[ -z "$type" ]] && type=$(peers::get_type_from_ip "$ip") [[ -z "$type" ]] && type=$(peers::get_type_from_ip "$ip")
@ -241,36 +185,53 @@ function cmd::list::run() {
cmd::list::_precompute_all cmd::list::_precompute_all
if $detailed; then # Resolve identity filter
log::section "WireGuard Clients" declare -gA p_identity_filter=()
cmd::list::_iter_confs "$filter_type" cmd::list::_show_client_safe if [[ -n "$filter_identity" ]]; then
identity::require_exists "$filter_identity" || return 1
while IFS= read -r peer_name; do
[[ -n "$peer_name" ]] && p_identity_filter["$peer_name"]=1
done < <(identity::peers "$filter_identity")
if [[ ${#p_identity_filter[@]} -eq 0 ]]; then
log::wg_warning "Identity '${filter_identity}' has no peers"
return 0
fi
fi
log::section "WireGuard Clients"
# Collect all filtered rows first (needed for dynamic column widths)
local collected_rows=""
collected_rows=$(cmd::list::_collect_all_rows | ui::sort_rows)
if [[ -z "$collected_rows" ]]; then
log::wg_warning "No results found"
return 0 return 0
fi fi
local filter_desc="" if $detailed; then
cmd::list::_build_filter_desc cmd::list::_render_detailed "$collected_rows"
cmd::list::_render_summary_from_rows "$collected_rows"
declare -A rule_counts=() group_counts=() return 0
_list_header_printed=false
cmd::list::_iter_confs "$filter_type" cmd::list::_render_row
if [[ "$_list_header_printed" == "true" ]]; then
cmd::list::_render_footer $has_groups
local group_summary=""
cmd::list::_build_group_summary
cmd::list::_render_summary "$group_summary" rule_counts "$filter_desc"
else
log::wg_warning "No results found${filter_desc:+ for: ${filter_desc}}"
fi fi
local style
style=$(ui::peer::list_style)
case "$style" in
table) cmd::list::_render_table ;;
compact) cmd::list::_render_compact "$collected_rows" ;;
*) cmd::list::_render_compact "$collected_rows" ;;
esac
} }
# ============================================ # ============================================
# Iteration # Row collection (single pass, all filters)
# ============================================ # ============================================
function cmd::list::_iter_confs() { function cmd::list::_collect_all_rows() {
local filter_type="$1" callback="$2" # Outputs pipe-delimited rows for peers that pass all filters
# Fields: name|ip|type|rule|group|status|last_seen|is_blocked|is_restricted
local dir local dir
dir="$(ctx::clients)" dir="$(ctx::clients)"
@ -278,8 +239,124 @@ function cmd::list::_iter_confs() {
[[ -f "$conf" ]] || continue [[ -f "$conf" ]] || continue
local client_name local client_name
client_name=$(basename "$conf" .conf) client_name=$(basename "$conf" .conf)
[[ -z "$client_name" ]] && continue
# Identity filter
if [[ ${#p_identity_filter[@]} -gt 0 && \
-z "${p_identity_filter[$client_name]:-}" ]]; then
continue
fi
local ip="${p_ips[$client_name]:-}"
[[ -z "$ip" ]] && ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1)
[[ -z "$ip" ]] && continue
local type="${p_types[$client_name]:-unknown}"
[[ -n "$filter_type" && "$type" != "$filter_type" ]] && continue
local pubkey="${p_pubkeys[$client_name]:-}"
local handshake_ts="${wg_handshakes[$pubkey]:-0}"
local is_blocked="${p_blocked[$client_name]:-false}"
local is_restricted="${p_restricted[$client_name]:-false}"
local last_ts="${p_last_ts[$client_name]:-}"
local rule="${p_rules[$client_name]:-}"
local group="${p_main_groups[$client_name]:-}"
# Apply status filters
if $online_only; then peers::is_online "$client_name" "$handshake_ts" "$last_ts" || continue; fi
if $offline_only; then peers::is_offline "$client_name" "$handshake_ts" "$last_ts" || continue; fi
if $restricted_only && [[ "$is_restricted" != "true" ]]; then continue; fi
if $blocked_only && [[ "$is_blocked" != "true" ]]; then continue; fi
if $allowed_only && { [[ "$is_blocked" == "true" ]] || \
[[ "$is_restricted" == "true" ]]; }; then continue; fi
# Apply rule/group filters
if [[ -n "$filter_rule" && "$rule" != "$filter_rule" ]]; then continue; fi
if [[ -n "$filter_group" ]]; then
local all_groups="${peer_group_map[$client_name]:-}"
[[ "$all_groups" != *"$filter_group"* ]] && continue
fi
# Resolve status
local state
state=$(peers::connection_state "$is_blocked" "$is_restricted" "$handshake_ts" "$last_ts")
local status="${state%%|*}"
# Resolve last seen
local last_seen="—"
if [[ "$is_blocked" == "true" && -n "$last_ts" && "$last_ts" != "0" ]]; then
local attempt_ts
attempt_ts=$(json::iso_to_ts "$last_ts")
last_seen=$(fmt::datetime_short "$attempt_ts")
elif [[ -n "$handshake_ts" && "$handshake_ts" != "0" ]]; then
last_seen=$(fmt::datetime_short "$handshake_ts")
fi
printf "%s|%s|%s|%s|%s|%s|%s|%s|%s\n" \
"$client_name" "$ip" "$type" \
"${rule:--}" "${group:--}" \
"$status" "$last_seen" \
"$is_blocked" "$is_restricted"
done
}
# ============================================
# Compact render
# ============================================
function cmd::list::_render_compact() {
local rows="${1:-}"
# Measure column widths from pure values (fields 1-5, no labels)
local w_name w_ip w_type w_rule w_group
w_name=$(ui::measure_col "$rows" 1 14)
w_ip=$(ui::measure_col "$rows" 2 13)
w_type=$(ui::measure_col "$rows" 3 7)
w_rule=$(ui::measure_col "$rows" 4 4)
w_group=$(ui::measure_col "$rows" 5 4)
echo ""
while IFS='|' read -r name ip type rule group status last_seen is_blocked is_restricted; do
[[ -z "$name" ]] && continue
ui::peer::list_row_compact \
"$w_name" "$w_ip" "$w_type" "$w_rule" "$w_group" \
"$name" "$ip" "$type" "$rule" "$group" \
"$status" "$last_seen" "$is_blocked" "$is_restricted"
done <<< "$rows"
echo ""
cmd::list::_render_summary_from_rows "$rows"
}
# ============================================
# Table render (kept for config switching)
# ============================================
function cmd::list::_render_table() {
declare -A rule_counts=() group_counts=()
_list_header_printed=false
cmd::list::_iter_confs_table
if [[ "$_list_header_printed" == "true" ]]; then
cmd::list::_render_footer $has_groups
local group_summary=""
cmd::list::_build_group_summary
printf "\n Showing peers\n\n"
else
log::wg_warning "No results found"
fi
}
function cmd::list::_iter_confs_table() {
local dir
dir="$(ctx::clients)"
for conf in "${dir}"/*.conf; do
[[ -f "$conf" ]] || continue
local client_name
client_name=$(basename "$conf" .conf)
[[ -z "$client_name" ]] && continue
# Identity filter — skip peers not in the identity set
if [[ ${#p_identity_filter[@]} -gt 0 && \ if [[ ${#p_identity_filter[@]} -gt 0 && \
-z "${p_identity_filter[$client_name]:-}" ]]; then -z "${p_identity_filter[$client_name]:-}" ]]; then
continue continue
@ -288,16 +365,111 @@ function cmd::list::_iter_confs() {
local ip="${p_ips[$client_name]:-}" local ip="${p_ips[$client_name]:-}"
[[ -z "$ip" ]] && ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1) [[ -z "$ip" ]] && ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1)
# p_types is authoritative — set during precompute from meta + IP fallback
local type="${p_types[$client_name]:-unknown}" local type="${p_types[$client_name]:-unknown}"
[[ -n "$filter_type" && "$type" != "$filter_type" ]] && continue [[ -n "$filter_type" && "$type" != "$filter_type" ]] && continue
"$callback" "$client_name" "$ip" "$type" cmd::list::_render_row "$client_name" "$ip" "$type"
done done
} }
# ============================================ # ============================================
# Row rendering # Detailed render (grouped by identity)
# ============================================
function cmd::list::_render_detailed() {
local rows="${1:-}"
# Measure widths
local w_name w_ip w_type w_rule w_group w_subnet
w_name=$(ui::measure_col "$rows" 1 14)
w_ip=$(ui::measure_col "$rows" 2 13)
w_type=$(ui::measure_col "$rows" 3 7)
w_rule=$(ui::measure_col "$rows" 4 4)
w_group=$(ui::measure_col "$rows" 5 4)
# subnet not in rows — use fixed width
w_subnet=10
# Group by identity
declare -A identity_rows=()
local no_identity_rows=""
while IFS='|' read -r name ip type rule group status last_seen is_blocked is_restricted; do
[[ -z "$name" ]] && continue
local id_name
id_name=$(identity::get_name "$name")
local row="${name}|${ip}|${type}|${rule}|${group}|${status}|${last_seen}|${is_blocked}|${is_restricted}"
if [[ -n "$id_name" ]]; then
identity_rows["$id_name"]+="${row}"$'\n'
else
no_identity_rows+="${row}"$'\n'
fi
done <<< "$rows"
echo ""
# Render identity groups (sorted)
for id_name in $(echo "${!identity_rows[@]}" | tr ' ' '\n' | sort); do
ui::peer::list_identity_header "$id_name"
while IFS='|' read -r name ip type rule group status last_seen is_blocked is_restricted; do
[[ -z "$name" ]] && continue
local subnet
subnet=$(peers::get_meta "$name" "subnet" 2>/dev/null) || subnet="-"
[[ -z "$subnet" ]] && subnet="-"
ui::peer::list_row_detailed \
"$w_name" "$w_ip" "$w_type" "$w_rule" "$w_group" "$w_subnet" \
"$name" "$ip" "$type" "$rule" "$group" "$subnet" \
"$status" "$last_seen" "$is_blocked" "$is_restricted"
done < <(echo "${identity_rows[$id_name]}" | ui::sort_rows)
done
# Render peers without identity (no "other" header if empty)
if [[ -n "$no_identity_rows" ]]; then
local trimmed
trimmed=$(echo "$no_identity_rows" | grep -v '^$')
if [[ -n "$trimmed" ]]; then
ui::peer::list_identity_header "other"
while IFS='|' read -r name ip type rule group status last_seen is_blocked is_restricted; do
[[ -z "$name" ]] && continue
local subnet
subnet=$(peers::get_meta "$name" "subnet" 2>/dev/null) || subnet="—"
[[ -z "$subnet" ]] && subnet="—"
ui::peer::list_row_detailed \
"$w_name" "$w_ip" "$w_type" "$w_rule" "$w_group" "$w_subnet" \
"$name" "$ip" "$type" "$rule" "$group" "$subnet" \
"$status" "$last_seen" "$is_blocked" "$is_restricted"
done <<< "$trimmed"
fi
fi
echo ""
}
# ============================================
# Summary
# ============================================
function cmd::list::_render_summary_from_rows() {
local rows="${1:-}"
declare -A rule_counts=()
local total=0
while IFS='|' read -r name ip type rule rest; do
[[ -z "$name" ]] && continue
(( total++ )) || true
rule_counts["${rule:-}"]=$(( ${rule_counts[${rule:-}]:-0} + 1 )) || true
done <<< "$rows"
local summary=""
for r in "${!rule_counts[@]}"; do
summary+="${rule_counts[$r]} ${r}, "
done
summary="${summary%, }"
printf " Showing %s peers [%s]\n\n" "$total" "$summary"
}
# ============================================
# Table row rendering
# ============================================ # ============================================
function cmd::list::_render_row() { function cmd::list::_render_row() {
@ -321,7 +493,7 @@ function cmd::list::_render_row() {
[[ "$all_groups" != *"$filter_group"* ]] && return 0 [[ "$all_groups" != *"$filter_group"* ]] && return 0
fi fi
local status last_seen display_type rule group_display local status last_seen display_type rule
status=$(peers::format_status_verbose "$client_name" "$pubkey" \ status=$(peers::format_status_verbose "$client_name" "$pubkey" \
"$is_blocked" "$is_restricted" "$handshake_ts" "$last_ts") "$is_blocked" "$is_restricted" "$handshake_ts" "$last_ts")
last_seen=$(peers::format_last_seen "$client_name" "$pubkey" \ last_seen=$(peers::format_last_seen "$client_name" "$pubkey" \
@ -332,7 +504,6 @@ function cmd::list::_render_row() {
if [[ -n "$filter_rule" && "$rule" != "$filter_rule" ]]; then return 0; fi if [[ -n "$filter_rule" && "$rule" != "$filter_rule" ]]; then return 0; fi
if [[ "${_list_header_printed:-false}" == "false" ]]; then if [[ "${_list_header_printed:-false}" == "false" ]]; then
log::section "WireGuard Clients"
cmd::list::_render_header $has_groups cmd::list::_render_header $has_groups
_list_header_printed=true _list_header_printed=true
fi fi
@ -344,26 +515,12 @@ function cmd::list::_render_row() {
if $has_groups; then if $has_groups; then
local main_group="${p_main_groups[$client_name]:-}" local main_group="${p_main_groups[$client_name]:-}"
if [[ -n "$main_group" ]]; then local group_display="${main_group:-${peer_group_map[$client_name]:-}}"
group_display="$main_group" printf " %-28s %-15s %-13s %-12s %-12s %s %s\n" \
else
group_display="${peer_group_map[$client_name]:-}"
fi
if [[ -n "${peer_group_map[$client_name]:-}" ]]; then
group_counts["$group_display"]=$(( ${group_counts[$group_display]:-0} + 1 )) || true
fi
local rule_col_width=12 group_col_width=12
[[ "$rule" == "—" ]] && rule_col_width=14
[[ "$group_display" == "—" ]] && group_col_width=14
printf " %-28s %-15s %-13s %-${rule_col_width}s %-${group_col_width}s %s %s\n" \
"$client_name" "$ip" "$display_type" "$rule" \ "$client_name" "$ip" "$display_type" "$rule" \
"$group_display" "$padded_status" "$last_seen" "$group_display" "$padded_status" "$last_seen"
else else
local rule_col_width=12 printf " %-28s %-15s %-13s %-12s %s %s\n" \
[[ "$rule" == "—" ]] && rule_col_width=14
printf " %-28s %-15s %-13s %-${rule_col_width}s %s %s\n" \
"$client_name" "$ip" "$display_type" "$rule" \ "$client_name" "$ip" "$display_type" "$rule" \
"$padded_status" "$last_seen" "$padded_status" "$last_seen"
fi fi
@ -374,25 +531,22 @@ function cmd::list::_render_row() {
# ============================================ # ============================================
function cmd::list::_precompute_all() { function cmd::list::_precompute_all() {
# Peer data — field 4 is 'type' from peer_data_v2
declare -gA p_ips=() p_rules=() p_types=() p_last_ts=() p_last_evt=() p_main_groups=() declare -gA p_ips=() p_rules=() p_types=() p_last_ts=() p_last_evt=() p_main_groups=()
while IFS="|" read -r name ip rule type last_ts last_evt main_group; do while IFS="|" read -r name ip rule type last_ts last_evt main_group; do
[[ -z "$name" ]] && continue [[ -z "$name" ]] && continue
p_ips["$name"]="$ip" p_ips["$name"]="$ip"
p_rules["$name"]="${rule:-}" p_rules["$name"]="${rule:-}"
p_types["$name"]="${type:-}" p_types["$name"]="${type:-}"
p_last_ts["$name"]="$last_ts" p_last_ts["$name"]="$last_ts"
p_last_evt["$name"]="$last_evt" p_last_evt["$name"]="$last_evt"
p_main_groups["$name"]="${main_group:-}" p_main_groups["$name"]="${main_group:-}"
done < <(json::peer_data "$(ctx::clients)" "$(ctx::meta)" "$(ctx::events_log)") done < <(json::peer_data "$(ctx::clients)" "$(ctx::meta)" "$(ctx::events_log)")
# Fill type from IP for peers missing meta type (pre-migration peers)
for name in "${!p_ips[@]}"; do for name in "${!p_ips[@]}"; do
[[ -n "${p_types[$name]:-}" ]] && continue [[ -n "${p_types[$name]:-}" ]] && continue
p_types["$name"]=$(peers::get_type_from_ip "${p_ips[$name]}") p_types["$name"]=$(peers::get_type_from_ip "${p_ips[$name]}")
done done
# WireGuard handshakes + endpoints
declare -gA wg_handshakes=() wg_endpoints=() declare -gA wg_handshakes=() wg_endpoints=()
while IFS=$'\t' read -r pubkey ts; do while IFS=$'\t' read -r pubkey ts; do
[[ -n "$pubkey" ]] && wg_handshakes["$pubkey"]="$ts" [[ -n "$pubkey" ]] && wg_handshakes["$pubkey"]="$ts"
@ -401,11 +555,9 @@ function cmd::list::_precompute_all() {
[[ -n "$pubkey" ]] && wg_endpoints["$pubkey"]="$endpoint" [[ -n "$pubkey" ]] && wg_endpoints["$pubkey"]="$endpoint"
done < <(wg show "$(config::interface)" endpoints 2>/dev/null) done < <(wg show "$(config::interface)" endpoints 2>/dev/null)
# Block/restricted status
declare -gA p_blocked=() p_restricted=() declare -gA p_blocked=() p_restricted=()
cmd::list::_precompute_block_status p_blocked p_restricted cmd::list::_precompute_block_status p_blocked p_restricted
# Public keys
declare -gA p_pubkeys=() declare -gA p_pubkeys=()
local dir local dir
dir="$(ctx::clients)" dir="$(ctx::clients)"
@ -416,7 +568,6 @@ function cmd::list::_precompute_all() {
p_pubkeys["$kname"]=$(cat "$kf" 2>/dev/null || echo "") p_pubkeys["$kname"]=$(cat "$kf" 2>/dev/null || echo "")
done done
# Groups + main group
has_groups=false has_groups=false
declare -gA peer_group_map=() declare -gA peer_group_map=()
local groups_dir local groups_dir
@ -429,27 +580,8 @@ function cmd::list::_precompute_all() {
done < <(json::peer_group_map "$groups_dir") done < <(json::peer_group_map "$groups_dir")
fi fi
# Resolve identity filter into a peer set # Identity precompute (for --identity filter)
declare -gA p_identity_filter=() declare -gA p_identity_filter=()
if [[ -n "$filter_identity" ]]; then
identity::require_exists "$filter_identity" || return 1
while IFS= read -r peer_name; do
[[ -n "$peer_name" ]] && p_identity_filter["$peer_name"]=1
done < <(identity::peers "$filter_identity")
if [[ ${#p_identity_filter[@]} -eq 0 ]]; then
log::wg_warning "Identity '${filter_identity}' has no peers"
return 0
fi
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::_precompute_block_status() { function cmd::list::_precompute_block_status() {
@ -477,19 +609,15 @@ function cmd::list::_precompute_block_status() {
} }
# ============================================ # ============================================
# Filter helpers # Header / Footer (table layout)
# ============================================ # ============================================
function cmd::list::_build_filter_desc() { function cmd::list::_render_header() {
filter_desc="" ui::peer::list_header_table "$1"
[[ -n "$filter_type" ]] && filter_desc+="type=${filter_type} " }
[[ -n "$filter_rule" ]] && filter_desc+="rule=${filter_rule} "
[[ -n "$filter_group" ]] && filter_desc+="group=${filter_group} " function cmd::list::_render_footer() {
[[ -n "$filter_identity" ]] && filter_desc+="identity=${filter_identity} " ui::peer::list_footer_table "$1"
$online_only && filter_desc+="online "
$offline_only && filter_desc+="offline "
$blocked_only && filter_desc+="blocked "
filter_desc="${filter_desc% }"
} }
function cmd::list::_build_group_summary() { function cmd::list::_build_group_summary() {

View file

@ -106,18 +106,11 @@ function cmd::policy::_list() {
return 0 return 0
fi fi
printf " %-14s %-8s %-14s %-12s %-12s %s\n" \ echo ""
"NAME" "TUNNEL" "DEFAULT RULE" "STRICT RULE" "AUTO APPLY" "DESCRIPTION"
ui::divider 84
while IFS='|' read -r name tunnel default_rule strict auto desc; do while IFS='|' read -r name tunnel default_rule strict auto desc; do
local rule_display="${default_rule:-}" ui::policy::list_row "$name" "$default_rule" "$strict" "$auto"
local strict_display auto_display
[[ "$strict" == "true" ]] && strict_display="$(color::red yes)" || strict_display="no"
[[ "$auto" == "true" ]] && auto_display="yes" || auto_display="no"
printf " %-14s %-8s %-14s %-12s %-12s %s\n" \
"$name" "$tunnel" "$rule_display" "$strict_display" "$auto_display" "$desc"
done <<< "$data" done <<< "$data"
echo ""
} }
function cmd::policy::_show() { function cmd::policy::_show() {
@ -133,14 +126,30 @@ function cmd::policy::_show() {
[[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; }
policy::require_exists "$name" || return 1 policy::require_exists "$name" || return 1
echo "" local dr tunnel strict auto
ui::row "Name" "$name"
ui::row "Tunnel mode" "$(policy::tunnel_mode "$name")"
local dr
dr=$(policy::default_rule "$name") dr=$(policy::default_rule "$name")
ui::row "Default rule" "${dr:-}" tunnel=$(policy::tunnel_mode "$name")
ui::row "Strict rule" "$(policy::strict_rule "$name" && echo "yes" || echo "no")" strict=$(policy::strict_rule "$name" && echo "yes" || echo "no")
ui::row "Auto apply" "$(policy::auto_apply "$name" && echo "yes" || echo "no")" auto=$(policy::auto_apply "$name" && echo "yes" || echo "no")
local rule_val="-"
[[ -n "$dr" ]] && rule_val="$dr"
local strict_padded
strict_padded=$(printf "%-4s" "$strict")
# First line — mirrors list format
echo ""
printf " \033[1m%-14s\033[0m \033[2mrule:\033[0m %-16s \033[2mstrict:\033[0m %s\n" \
"$name" "$rule_val" "$strict_padded"
echo ""
# Detail section
local desc
desc=$(policy::get "$name" "desc")
[[ -n "$desc" ]] && printf " \033[2mDescription:\033[0m %s\n" "$desc"
printf " \033[2mTunnel:\033[0m %s\n" "$tunnel"
printf " \033[2mAuto apply:\033[0m %s\n" "$auto"
echo "" echo ""
} }

View file

@ -358,13 +358,15 @@ function cmd::rule::show() {
ui::row "Group" "${group:-}" ui::row "Group" "${group:-}"
ui::row "DNS" "$dns_display" ui::row "DNS" "$dns_display"
printf "\n"
# ── Extends + own rules ──────────────────────── # ── Extends + own rules ────────────────────────
if rule::render_extends_tree "$name"; then if rule::render_extends_tree "$name"; then
# Has inheritance — tree already rendered # Has inheritance — tree already rendered
printf "\n" :
else else
# No inheritance — flat view # No inheritance — flat view
rule::render_flat "$name" rule::render_flat "$name"
printf "\n"
fi fi
# ── Resolved ────────────────────────────────── # ── Resolved ──────────────────────────────────
@ -381,7 +383,6 @@ function cmd::rule::show() {
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_block_ips"$'\n'"$res_block_ports" <<< "$res_block_ips"$'\n'"$res_block_ports"
printf "\n" printf "\n"
fi fi
# ── Peers ───────────────────────────────────── # ── Peers ─────────────────────────────────────
@ -390,6 +391,9 @@ function cmd::rule::show() {
local peer_count=${#peer_list[@]} local peer_count=${#peer_list[@]}
ui::empty "$peer_count" && return 0
printf "\n"
printf "\033[0;37m── Peers (%s) \033[0m%s\n\n" \ printf "\033[0;37m── Peers (%s) \033[0m%s\n\n" \
"$(color::gray "${peer_count}")" \ "$(color::gray "${peer_count}")" \
"$(printf '\033[0;37m─%.0s' {1..35})" "$(printf '\033[0;37m─%.0s' {1..35})"

View file

@ -92,14 +92,25 @@ function cmd::subnet::_list() {
return 0 return 0
fi fi
ui::subnet::header echo ""
local prev_group="" local prev_group=""
while IFS='|' read -r display_name subnet type_key tunnel_mode desc is_group group_parent; do while IFS='|' read -r display_name subnet type_key tunnel_mode desc is_group group_parent; do
cmd::subnet::_maybe_group_separator "$is_group" "$group_parent" "$prev_group" if [[ "$is_group" == "true" ]]; then
prev_group=$(cmd::subnet::_update_prev_group "$is_group" "$group_parent" "$prev_group") # Print group parent header when we encounter first child
ui::subnet::row "$display_name" "$subnet" "$type_key" "$tunnel_mode" "$desc" "$is_group" if [[ "$group_parent" != "$prev_group" ]]; then
[[ -n "$prev_group" ]] && ui::subnet::group_separator
ui::subnet::row_group_parent "$group_parent"
prev_group="$group_parent"
fi
ui::subnet::row_group_child "$type_key" "$subnet" "$tunnel_mode"
else
# Scalar entry
[[ -n "$prev_group" ]] && ui::subnet::group_separator
prev_group=""
ui::subnet::row_scalar "$display_name" "$subnet" "$tunnel_mode"
fi
done <<< "$data" done <<< "$data"
echo ""
} }
function cmd::subnet::_maybe_group_separator() { function cmd::subnet::_maybe_group_separator() {
@ -137,34 +148,45 @@ function cmd::subnet::_show() {
data=$(subnet::show_data "$name") data=$(subnet::show_data "$name")
local is_group="false" local is_group="false"
local show_name="" show_subnet="" show_tunnel="" show_desc=""
while IFS='|' read -r key val rest; do while IFS='|' read -r key val rest; do
case "$key" in case "$key" in
name) name) show_name="$val" ;;
ui::subnet::detail "$val" "$is_group" is_group) is_group="$val" ;;
;; subnet) show_subnet="$val" ;;
is_group) tunnel_mode) show_tunnel="$val" ;;
is_group="$val" desc) show_desc="$val" ;;
ui::subnet::detail_field "Type" "$( [[ $val == true ]] && echo "group" || echo "scalar" )"
[[ "$val" == "true" ]] && ui::subnet::child_header
;;
subnet) ui::subnet::detail_field "Subnet" "$val" ;;
type) ui::subnet::detail_field "Device type" "$val" ;;
tunnel_mode) ui::subnet::detail_field "Tunnel" "$val" ;;
desc) ui::subnet::detail_field "Description" "$val" ;;
child)
local c_type="$val"
local c_subnet c_tunnel c_desc
c_subnet=$(echo "$rest" | cut -d'|' -f1)
c_tunnel=$(echo "$rest" | cut -d'|' -f2)
c_desc=$(echo "$rest" | cut -d'|' -f3)
ui::subnet::child_row "$c_type" "$c_subnet" "$c_tunnel" "$c_desc"
;;
esac esac
done <<< "$data" done <<< "$data"
local peers_using if [[ "$is_group" == "true" ]]; then
peers_using=$(subnet::peers_using "$name") # Group display
ui::subnet::peers_in_use "$peers_using" ui::subnet::show_group "$show_name"
while IFS='|' read -r key val rest; do
[[ "$key" != "child" ]] && continue
local c_type="$val"
local c_subnet c_tunnel c_desc
c_subnet=$(echo "$rest" | cut -d'|' -f1)
c_tunnel=$(echo "$rest" | cut -d'|' -f2)
c_desc=$(echo "$rest" | cut -d'|' -f3)
ui::subnet::show_child_row "$c_type" "$c_subnet" "$c_tunnel" "$c_desc"
done <<< "$data"
local peers_using
peers_using=$(subnet::peers_using "$name")
ui::subnet::show_peers_annotated "$peers_using" "$(ctx::subnets)"
else
# Scalar display
ui::subnet::show_scalar "$show_name" "$show_subnet" "$show_tunnel" "$show_desc"
local peers_using
peers_using=$(subnet::peers_using "$name")
ui::subnet::show_peers "$peers_using"
fi
echo ""
} }
function cmd::subnet::_add() { function cmd::subnet::_add() {

View file

@ -136,12 +136,12 @@ function cmd::test::run_all_integration_sections() {
function cmd::test::section_list() { function cmd::test::section_list() {
test::section "List" test::section "List"
cmd::test::run_cmd "list" "NAME" list cmd::test::run_cmd "list" "rule:" list
cmd::test::run_cmd "list --online" "" list --online cmd::test::run_cmd "list --online" "" list --online
cmd::test::run_cmd "list --offline" "" list --offline cmd::test::run_cmd "list --offline" "" list --offline
cmd::test::run_cmd "list --blocked" "" list --blocked cmd::test::run_cmd "list --blocked" "" list --blocked
cmd::test::run_cmd "list --type phone" "phone" list --type phone cmd::test::run_cmd "list --type phone" "phone" list --type phone
cmd::test::run_cmd "list --detailed" "IP:" list --detailed cmd::test::run_cmd "list --detailed" "rule:" list --detailed
cmd::test::run_cmd "list --name phone-nuno" "phone-nuno" list --name phone-nuno cmd::test::run_cmd "list --name phone-nuno" "phone-nuno" list --name phone-nuno
} }
@ -225,8 +225,8 @@ function cmd::test::section_subnet() {
"$WGCTL_BINARY" subnet rm --name test-subnet > /dev/null 2>&1 || true "$WGCTL_BINARY" subnet rm --name test-subnet > /dev/null 2>&1 || true
cmd::test::run_cmd "subnet list" "desktop" subnet list cmd::test::run_cmd "subnet list" "desktop" subnet list
cmd::test::run_cmd "subnet show desktop" "Subnet" subnet show --name desktop cmd::test::run_cmd "subnet show desktop" "tunnel:" subnet show --name desktop
cmd::test::run_cmd "subnet show guests group" "group" subnet show --name guests cmd::test::run_cmd "subnet show guests group" "guests" subnet show --name guests
cmd::test::run_cmd_fails "subnet show nonexistent" subnet show --name nonexistent cmd::test::run_cmd_fails "subnet show nonexistent" subnet show --name nonexistent
cmd::test::run_cmd "subnet add" "added" \ cmd::test::run_cmd "subnet add" "added" \

View file

@ -34,6 +34,28 @@ function fmt::datetime_iso() {
python3 "$FMT_HELPER" fmt_datetime "$iso" "$FMT_DATETIME" </dev/null python3 "$FMT_HELPER" fmt_datetime "$iso" "$FMT_DATETIME" </dev/null
} }
# fmt::datetime_short <unix_timestamp>
# Returns a compact datetime — just time if today, short date+time if older.
# Respects configured date format. Returns "—" for empty/zero timestamps.
function fmt::datetime_short() {
local ts="${1:-}"
[[ -z "$ts" || "$ts" == "0" ]] && echo "—" && return 0
local today ts_day
today=$(date +%Y-%m-%d)
ts_day=$(date -d "@${ts}" +%Y-%m-%d 2>/dev/null) || { echo "—"; return 0; }
if [[ "$ts_day" == "$today" ]]; then
date -d "@${ts}" +"%H:%M" 2>/dev/null || echo "—"
else
case "$_FMT_DATE_FORMAT" in
iso) date -d "@${ts}" +"%m-%d %H:%M" 2>/dev/null || echo "—" ;;
eu*) date -d "@${ts}" +"%d/%m %H:%M" 2>/dev/null || echo "—" ;;
*) date -d "@${ts}" +"%m-%d %H:%M" 2>/dev/null || echo "—" ;;
esac
fi
}
function fmt::set_date_format() { function fmt::set_date_format() {
local format="$1" local format="$1"
case "$format" in case "$format" in

View file

@ -90,6 +90,13 @@ function json::identity_migrate() { python3 "$JSON_HELPER" identity_migrat
function json::identity_infer() { python3 "$JSON_HELPER" identity_infer "$@" </dev/null; } function json::identity_infer() { python3 "$JSON_HELPER" identity_infer "$@" </dev/null; }
function json::identity_exists() { python3 "$JSON_HELPER" identity_exists "$@" </dev/null; } function json::identity_exists() { python3 "$JSON_HELPER" identity_exists "$@" </dev/null; }
# Identity rule wrappers (1:N)
function json::identity_rules() { python3 "$JSON_HELPER" identity_rules "$@" </dev/null; }
function json::identity_add_rule() { python3 "$JSON_HELPER" identity_add_rule "$@" </dev/null; }
function json::identity_remove_rule() { python3 "$JSON_HELPER" identity_remove_rule "$@" </dev/null; }
function json::identity_clear_rules() { python3 "$JSON_HELPER" identity_clear_rules "$@" </dev/null; }
function json::identity_has_rule() { python3 "$JSON_HELPER" identity_has_rule "$@" </dev/null; }
# Policy wrappers — append to json.sh # Policy wrappers — append to json.sh
function json::policy_get() { python3 "$JSON_HELPER" policy_get "$@" </dev/null; } function json::policy_get() { python3 "$JSON_HELPER" policy_get "$@" </dev/null; }
function json::policy_list() { python3 "$JSON_HELPER" policy_list "$@" </dev/null; } function json::policy_list() { python3 "$JSON_HELPER" policy_list "$@" </dev/null; }

View file

@ -1764,6 +1764,79 @@ def subnet_exists(file, name):
# Identity System # Identity System
# ============================================ # ============================================
def identity_rules(file):
"""
Return all rules assigned to an identity, one per line.
Reads from 'rules' array (1:N). Falls back to 'rule' scalar for migration.
"""
data = _identity_read(file)
if not data:
return
# Support legacy scalar 'rule' field
rules = data.get('rules', [])
if not rules and data.get('rule'):
rules = [data['rule']]
for r in rules:
if r:
print(r)
def identity_add_rule(file, identity_name, rule_name):
"""
Add a rule to an identity's rules array.
Warns if already present (prints warning to stderr, exits 2).
Creates identity file if it doesn't exist.
"""
data = _identity_read(file) or _identity_init(identity_name)
rules = data.get('rules', [])
# Migrate legacy scalar field
if 'rule' in data and data['rule']:
if data['rule'] not in rules:
rules.append(data['rule'])
del data['rule']
if rule_name in rules:
print(f"Warning: Rule '{rule_name}' is already assigned to identity '{identity_name}'",
file=sys.stderr)
sys.exit(2)
rules.append(rule_name)
data['rules'] = rules
_identity_write(file, data)
def identity_remove_rule(file, rule_name):
"""
Remove a specific rule from an identity's rules array.
Exits 1 if rule not found.
"""
data = _identity_read(file)
if not data:
print(f"Error: Identity not found", file=sys.stderr)
sys.exit(1)
rules = data.get('rules', [])
if rule_name not in rules:
print(f"Error: Rule '{rule_name}' not assigned to this identity", file=sys.stderr)
sys.exit(1)
rules.remove(rule_name)
data['rules'] = rules
_identity_write(file, data)
def identity_clear_rules(file):
"""Remove all rules from an identity."""
data = _identity_read(file)
if not data:
return
data['rules'] = []
data.pop('rule', None) # remove legacy scalar too
_identity_write(file, data)
def identity_has_rule(file, rule_name):
"""Exit 0 if identity has this rule, 1 otherwise."""
data = _identity_read(file)
if not data:
sys.exit(1)
rules = data.get('rules', [])
if not rules and data.get('rule'):
rules = [data['rule']]
sys.exit(0 if rule_name in rules else 1)
def _identity_read(file): def _identity_read(file):
"""Read an identity file, return dict or None""" """Read an identity file, return dict or None"""
try: try:
@ -1822,17 +1895,27 @@ def _parse_peer_name(peer_name):
return (peer_type, identity, index) return (peer_type, identity, index)
def identity_list(identities_dir): def identity_list(identities_dir):
"""List all identities with peer count""" """
List all identities with peer count, rules and policy.
Output per line: name|peer_count|types|rules|policy
"""
import glob import glob
for id_file in sorted(glob.glob(f"{identities_dir}/*.identity")): for id_file in sorted(glob.glob(f"{identities_dir}/*.identity")):
try: try:
with open(id_file) as f: with open(id_file) as f:
data = json.load(f) data = json.load(f)
name = data.get('name', '') name = data.get('name', '')
peers = data.get('peers', []) peers = data.get('peers', [])
devices = data.get('devices', {}) devices = data.get('devices', {})
types = sorted(set(d.get('type', '') for d in devices.values() if d.get('type'))) rules = data.get('rules', [])
print(f"{name}|{len(peers)}|{','.join(types)}") # Migrate legacy scalar rule field
if not rules and data.get('rule'):
rules = [data['rule']]
policy = data.get('policy', 'default')
types = sorted(set(
d.get('type', '') for d in devices.values() if d.get('type')
))
print(f"{name}|{len(peers)}|{','.join(types)}|{','.join(rules)}|{policy}")
except Exception: except Exception:
continue continue
@ -2426,6 +2509,11 @@ commands = {
'subnet_policy': lambda args: subnet_policy(args[0], args[1], args[2] if len(args) > 2 else ''), 'subnet_policy': lambda args: subnet_policy(args[0], args[1], args[2] if len(args) > 2 else ''),
'get_nested': lambda args: json_get_nested(args[0], *args[1:]), 'get_nested': lambda args: json_get_nested(args[0], *args[1:]),
'set_nested': lambda args: json_set_nested(args[0], *args[1:]), 'set_nested': lambda args: json_set_nested(args[0], *args[1:]),
'identity_rules': lambda args: identity_rules(args[0]),
'identity_add_rule': lambda args: identity_add_rule(args[0], args[1], args[2]),
'identity_remove_rule': lambda args: identity_remove_rule(args[0], args[1]),
'identity_clear_rules': lambda args: identity_clear_rules(args[0]),
'identity_has_rule': lambda args: identity_has_rule(args[0], args[1]),
} }
if __name__ == '__main__': if __name__ == '__main__':

View file

@ -77,6 +77,48 @@ function ui::center() {
printf "%${pad}s%s%${rpad}s" "" "$text" "" printf "%${pad}s%s%${rpad}s" "" "$text" ""
} }
# ui::measure_col <data> <field_index> [min_width]
# Scans pipe-delimited data and returns the max visible width
# of the field at field_index (1-based), with optional minimum.
# Strips ANSI codes before measuring.
# Usage:
# name_width=$(ui::measure_col "$data" 1 10)
# ip_width=$(ui::measure_col "$data" 2 14)
function ui::measure_col() {
local data="${1:-}" field_index="${2:-1}" min_width="${3:-0}"
local max=$min_width
while IFS='|' read -r line; do
local val
val=$(echo "$line" | cut -d'|' -f"$field_index")
# Strip ANSI codes for accurate measurement
local clean
clean=$(echo "$val" | sed 's/\x1b\[[0-9;]*m//g')
local len=${#clean}
(( len > max )) && max=$len
done <<< "$data"
echo $max
}
# ui::measure_cols <data> <field_indices...>
# Measure multiple columns at once, returns space-separated widths.
# Usage: read -r w1 w2 w3 <<< $(ui::measure_cols "$data" 1 2 3)
function ui::measure_cols() {
local data="${1:-}"
shift
local widths=()
for idx in "$@"; do
widths+=("$(ui::measure_col "$data" "$idx")")
done
echo "${widths[*]}"
}
function ui::sort_rows() {
local field="${1:-1}"
sort -t'|' -k"${field},${field}V"
}
function ui::firewall_rule() { function ui::firewall_rule() {
local rule="$1" local rule="$1"
if [[ "$rule" =~ ACCEPT|DNAT ]]; then if [[ "$rule" =~ ACCEPT|DNAT ]]; then
@ -109,9 +151,19 @@ function ui::skip_if_empty() {
} }
function ui::empty() { function ui::empty() {
# ui::empty "$var" && return 0 local val="${1:-}"
# ui::empty "${array[*]}" && return 0 # Empty string or whitespace only
[[ -z "${1// }" ]] [[ -z "${val// }" ]] && return 0
# Numeric zero
[[ "$val" =~ ^[0-9]+$ ]] && [[ "$val" -eq 0 ]] && return 0
return 1
}
# Usage: ui::bool "$value" [yes_label] [no_label]
# Default labels: yes / no
function ui::bool() {
local val="${1:-}" yes="${2:-yes}" no="${3:-no}"
[[ "$val" == "true" ]] && echo "$yes" || echo "$no"
} }
# ============================================ # ============================================

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.157", "phone-helena-2": "148.69.192.130",
"desktop-zephyr": "86.120.152.74" "desktop-zephyr": "86.120.152.74"
} }

View file

@ -1,6 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# identity.module.sh — identity file management and peer-name inference # identity.module.sh — identity file management and peer-name inference
declare -gA __empty_map=()
# =========================================================================== # ===========================================================================
# Path helpers # Path helpers
# =========================================================================== # ===========================================================================
@ -255,38 +257,6 @@ function identity::rename_peer() {
fi fi
} }
# identity::rule <identity_name>
# Returns the rule assigned to an identity, or empty string if none.
function identity::rule() {
local identity_name="${1:-}"
local id_file
id_file=$(ctx::identity::path "${identity_name}.identity")
[[ ! -f "$id_file" ]] && return 0
json::get "$id_file" "rule" 2>/dev/null || true
}
# identity::set_rule <identity_name> <rule_name>
# Sets the rule on an identity file.
function identity::set_rule() {
local identity_name="${1:-}" rule_name="${2:-}"
local id_file
id_file=$(ctx::identity::path "${identity_name}.identity")
if [[ ! -f "$id_file" ]]; then
log::error "Identity '${identity_name}' not found"
return 1
fi
json::set "$id_file" "rule" "$rule_name"
}
# identity::clear_rule <identity_name>
# Removes the rule from an identity file.
function identity::clear_rule() {
local identity_name="${1:-}"
local id_file
id_file=$(ctx::identity::path "${identity_name}.identity")
[[ ! -f "$id_file" ]] && return 0
json::delete "$id_file" "rule" 2>/dev/null || true
}
# identity::policy <identity_name> # identity::policy <identity_name>
# Returns the policy name assigned to an identity, or "default". # Returns the policy name assigned to an identity, or "default".
@ -374,3 +344,79 @@ function identity::reapply_rules() {
rule::full_restore_peer "$peer_name" "$client_ip" rule::full_restore_peer "$peer_name" "$client_ip"
done <<< "$peers" done <<< "$peers"
} }
# identity::rules <identity_name>
# Returns all rules assigned to an identity, one per line.
# Empty output if no rules assigned.
function identity::rules() {
local identity_name="${1:-}"
local id_file
id_file=$(ctx::identity::path "${identity_name}.identity")
[[ ! -f "$id_file" ]] && return 0
json::identity_rules "$id_file" 2>/dev/null || true
}
# identity::has_rule <identity_name> <rule_name>
# Returns 0 if identity has this rule, 1 otherwise.
function identity::has_rule() {
local identity_name="${1:-}" rule_name="${2:-}"
local id_file
id_file=$(ctx::identity::path "${identity_name}.identity")
json::identity_has_rule "$id_file" "$rule_name" 2>/dev/null
}
# identity::add_rule <identity_name> <rule_name>
# Adds a rule to an identity. Warns if already present (exit 2).
function identity::add_rule() {
local identity_name="${1:-}" rule_name="${2:-}"
local id_file
id_file=$(ctx::identity::path "${identity_name}.identity")
local exit_code=0
json::identity_add_rule "$id_file" "$identity_name" "$rule_name" 2>/dev/null || exit_code=$?
return $exit_code
}
# identity::remove_rule <identity_name> <rule_name>
# Removes a specific rule from an identity.
function identity::remove_rule() {
local identity_name="${1:-}" rule_name="${2:-}"
local id_file
id_file=$(ctx::identity::path "${identity_name}.identity")
json::identity_remove_rule "$id_file" "$rule_name" 2>/dev/null
}
# identity::clear_rules <identity_name>
# Removes all rules from an identity.
function identity::clear_rules() {
local identity_name="${1:-}"
local id_file
id_file=$(ctx::identity::path "${identity_name}.identity")
json::identity_clear_rules "$id_file" 2>/dev/null
}
# identity::reapply_rules <identity_name>
# Reapply all identity rules to all peers in this identity.
# Respects auto_apply flag.
function identity::reapply_rules() {
local identity_name="${1:-}"
local auto
auto=$(identity::rule_flags "$identity_name" "auto_apply")
[[ "$auto" == "false" ]] && return 0
local rules
rules=$(identity::rules "$identity_name")
[[ -z "$rules" ]] && return 0
local peers
peers=$(identity::peers "$identity_name")
[[ -z "$peers" ]] && return 0
while IFS= read -r peer_name; do
[[ -z "$peer_name" ]] && continue
local client_ip
client_ip=$(peers::get_ip "$peer_name") || continue
rule::full_restore_peer "$peer_name" "$client_ip"
done <<< "$peers"
}

View file

@ -99,32 +99,21 @@ function rule::is_applied() {
# Rule Application # Rule Application
# ============================================ # ============================================
function rule::apply() { function rule::_apply_entries() {
local rule_name="${1:?rule_name required}" local rule_name="${1:?}" client_ip="${2:?}"
local client_ip="${2:?client_ip required}"
local peer_name="${3:-}"
rule::require_exists "$rule_name" || return 1 rule::require_exists "$rule_name" || return 1
if [[ -z "$peer_name" ]]; then
peer_name=$(peers::find_by_ip "$client_ip")
fi
log::debug "rule::apply: peer_name=$peer_name ip=$client_ip"
if rule::is_applied "$rule_name" "$client_ip"; then if rule::is_applied "$rule_name" "$client_ip"; then
log::wg "Rule '${rule_name}' already applied to: ${client_ip}" log::debug "Rule '${rule_name}' already applied to: ${client_ip}"
[[ -n "$peer_name" ]] && peers::set_meta "$peer_name" "rule" "$rule_name"
return 0 return 0
fi fi
# Process block_ips
while IFS= read -r block_ip; do while IFS= read -r block_ip; do
[[ -z "$block_ip" ]] && continue [[ -z "$block_ip" ]] && continue
fw::block_ip "$client_ip" "$block_ip" fw::block_ip "$client_ip" "$block_ip"
done < <(rule::get "$rule_name" "block_ips") done < <(rule::get "$rule_name" "block_ips")
# Process block_ports
while IFS= read -r entry; do while IFS= read -r entry; do
[[ -z "$entry" ]] && continue [[ -z "$entry" ]] && continue
local target port proto local target port proto
@ -133,13 +122,11 @@ function rule::apply() {
fw::block_port "$client_ip" "$target" "$port" "$proto" fw::block_port "$client_ip" "$target" "$port" "$proto"
done < <(rule::get "$rule_name" "block_ports") done < <(rule::get "$rule_name" "block_ports")
# Process allow_ips (inserted before blocks)
while IFS= read -r allow_ip; do while IFS= read -r allow_ip; do
[[ -z "$allow_ip" ]] && continue [[ -z "$allow_ip" ]] && continue
fw::allow_ip "$client_ip" "$allow_ip" fw::allow_ip "$client_ip" "$allow_ip"
done < <(rule::get "$rule_name" "allow_ips") done < <(rule::get "$rule_name" "allow_ips")
# Process allow_ports (highest priority)
while IFS= read -r entry; do while IFS= read -r entry; do
[[ -z "$entry" ]] && continue [[ -z "$entry" ]] && continue
local target port proto local target port proto
@ -148,27 +135,46 @@ function rule::apply() {
fw::allow_port "$client_ip" "$target" "$port" "$proto" fw::allow_port "$client_ip" "$target" "$port" "$proto"
done < <(rule::get "$rule_name" "allow_ports") done < <(rule::get "$rule_name" "allow_ports")
[[ -n "$peer_name" ]] && peers::set_meta "$peer_name" "rule" "$rule_name"
# DNS redirect
local dns_redirect local dns_redirect
dns_redirect=$(rule::get "$rule_name" "dns_redirect") dns_redirect=$(rule::get "$rule_name" "dns_redirect")
if [[ "$dns_redirect" == "true" ]]; then if [[ "$dns_redirect" == "true" ]]; then
local peer_subnet local peer_name peer_subnet
peer_name=$(peers::find_by_ip "$client_ip")
peer_subnet=$(peers::get_ip "$peer_name" | cut -d'.' -f1-3) peer_subnet=$(peers::get_ip "$peer_name" | cut -d'.' -f1-3)
if ! fw::_nat_exists -i wg0 -s "${peer_subnet}.0/24" \ if ! fw::_nat_exists -i wg0 -s "${peer_subnet}.0/24" \
-p udp --dport 53 -j DNAT \ -p udp --dport 53 -j DNAT \
--to-destination "$(config::dns):53" 2>/dev/null; then --to-destination "$(config::dns):53" 2>/dev/null; then
rule::apply_dns_redirect "${peer_subnet}.0/24" rule::apply_dns_redirect "${peer_subnet}.0/24"
log::debug "dns_redirect: applied for ${peer_subnet}.0/24"
else
log::debug "dns_redirect: already applied for ${peer_subnet}.0/24"
fi fi
fi fi
log::debug "Applied rule '${rule_name}' to: ${client_ip}" log::debug "Applied rule '${rule_name}' to: ${client_ip}"
} }
function rule::apply_transient() {
# Apply rule entries without touching peer meta
# Used for identity rules and other transient applications
local rule_name="${1:?}" client_ip="${2:?}"
log::debug "rule::apply_transient: $rule_name -> $client_ip"
rule::_apply_entries "$rule_name" "$client_ip"
}
function rule::apply() {
local rule_name="${1:?}" client_ip="${2:?}" peer_name="${3:-}"
rule::require_exists "$rule_name" || return 1
log::debug "rule::apply: peer_name=${peer_name:-<lookup>} ip=$client_ip"
rule::_apply_entries "$rule_name" "$client_ip" || return 1
# Write to peer meta — only for explicit peer rule assignment
if [[ -z "$peer_name" ]]; then
peer_name=$(peers::find_by_ip "$client_ip")
fi
[[ -n "$peer_name" ]] && peers::set_meta "$peer_name" "rule" "$rule_name"
}
function rule::unapply() { function rule::unapply() {
local rule_name="${1:-}" client_ip="${2:-}" local rule_name="${1:-}" client_ip="${2:-}"
@ -236,21 +242,31 @@ function rule::unapply() {
function rule::_apply_identity_rule() { function rule::_apply_identity_rule() {
local peer_name="${1:-}" client_ip="${2:-}" local peer_name="${1:-}" client_ip="${2:-}"
local identity_name local identity_name
identity_name=$(identity::get_name "$peer_name") identity_name=$(identity::get_name "$peer_name")
[[ -z "$identity_name" ]] && return 0 [[ -z "$identity_name" ]] && return 0
local identity_rule strict local rules
identity_rule=$(identity::rule "$identity_name") rules=$(identity::rules "$identity_name")
[[ -z "$identity_rule" ]] && return 0 [[ -z "$rules" ]] && return 0
local strict
strict=$(identity::rule_flags "$identity_name" "strict_rule") strict=$(identity::rule_flags "$identity_name" "strict_rule")
if [[ "$strict" == "true" ]]; then if [[ "$strict" == "true" ]]; then
# Strict: flush and apply only identity rules — peer rule ignored
fw::flush_peer "$client_ip" fw::flush_peer "$client_ip"
rule::apply "$identity_rule" "$client_ip" "$peer_name" while IFS= read -r rule_name; do
[[ -z "$rule_name" ]] && continue
rule::exists "$rule_name" && rule::apply_transient "$rule_name" "$client_ip" || true
done <<< "$rules"
else else
rule::apply "$identity_rule" "$client_ip" "$peer_name" # Additive: apply identity rules on top of peer rule
while IFS= read -r rule_name; do
[[ -z "$rule_name" ]] && continue
rule::exists "$rule_name" && rule::apply_transient "$rule_name" "$client_ip" || true
done <<< "$rules"
fi fi
} }
@ -266,30 +282,28 @@ function rule::full_restore_peer() {
local peer_rule local peer_rule
peer_rule=$(peers::get_meta "$peer_name" "rule") peer_rule=$(peers::get_meta "$peer_name" "rule")
[[ -n "$peer_rule" ]] && rule::apply "$peer_rule" "$client_ip" "$peer_name"
rule::_apply_identity_rule "$peer_name" "$client_ip" local strict
strict=$(rule::_get_identity_strict "$peer_name")
if [[ "$strict" == "true" ]]; then
# Strict mode: only identity rules apply
rule::_apply_identity_rule "$peer_name" "$client_ip"
else
# Normal mode: peer rule + identity rules (additive)
[[ -n "$peer_rule" ]] && rule::apply "$peer_rule" "$client_ip" "$peer_name"
rule::_apply_identity_rule "$peer_name" "$client_ip"
fi
block::restore_rules_for "$peer_name" "$client_ip" block::restore_rules_for "$peer_name" "$client_ip"
} }
function rule::reapply_all() { function rule::_get_identity_strict() {
local rule_name="${1:-}" local peer_name="${1:-}"
rule::require_exists "$rule_name" || return 1 local identity_name
identity_name=$(identity::get_name "$peer_name")
local peers=() [[ -z "$identity_name" ]] && echo "false" && return 0
mapfile -t peers < <(peers::with_rule "$rule_name") identity::rule_flags "$identity_name" "strict_rule"
[[ ${#peers[@]} -eq 0 ]] && return 0
local count=0
for peer_name in "${peers[@]}"; do
local client_ip
client_ip=$(peers::get_ip "$peer_name")
[[ -z "$client_ip" ]] && continue
rule::full_restore_peer "$peer_name" "$client_ip"
(( count++ )) || true
done
log::wg_success "Rule '${rule_name}' re-applied to ${count} peers"
} }
function rule::restore_all() { function rule::restore_all() {
@ -320,125 +334,19 @@ function rule::restore_all() {
# ============================================ # ============================================
function rule::render_flat() { function rule::render_flat() {
local rule_name="${1:-}" ui::rule::flat "$1"
local allow_ports allow_ips block_ips block_ports dns
allow_ports=$(rule::get "$rule_name" "allow_ports")
allow_ips=$(rule::get "$rule_name" "allow_ips")
block_ips=$(rule::get "$rule_name" "block_ips")
block_ports=$(rule::get "$rule_name" "block_ports")
dns=$(rule::get_own "$rule_name" "dns_redirect")
local has_content=false
[[ -n "${allow_ports}${allow_ips}${block_ips}${block_ports}" ]] && \
has_content=true
if ! $has_content; then
printf "\n full access (no restrictions)\n"
return 0
fi
if [[ -n "$allow_ports" || -n "$allow_ips" ]]; then
printf "\n"
while IFS= read -r e; do
[[ -z "$e" ]] && continue
net::print_entry "+" "$e" 2
done <<< "$allow_ports"$'\n'"$allow_ips"
fi
if [[ -n "$block_ips" || -n "$block_ports" ]]; then
printf "\n"
while IFS= read -r e; do
[[ -z "$e" ]] && continue
net::print_entry "-" "$e" 2
done <<< "$block_ips"$'\n'"$block_ports"
fi
[[ "${dns,,}" == "true" ]] && \
net::print_dns_redirect "$(config::dns)" 6 "DNS"
return 0
} }
function rule::render_entries() { function rule::render_entries() {
local rule_name="${1:-}" indent="${2:-4}" ui::rule::entries "$1"
local allow_ports allow_ips block_ips block_ports dns
allow_ports=$(rule::get "$rule_name" "allow_ports" 2>/dev/null || true)
allow_ips=$(rule::get "$rule_name" "allow_ips" 2>/dev/null || true)
block_ips=$(rule::get "$rule_name" "block_ips" 2>/dev/null || true)
block_ports=$(rule::get "$rule_name" "block_ports" 2>/dev/null || true)
dns=$(rule::get_own "$rule_name" "dns_redirect")
while IFS= read -r e; do
[[ -z "$e" ]] && continue
net::print_entry "+" "$e"
done <<< "$allow_ports"$'\n'"$allow_ips"
while IFS= read -r e; do
[[ -z "$e" ]] && continue
net::print_entry "-" "$e"
done <<< "$block_ips"$'\n'"$block_ports"
[[ "${dns,,}" == "true" ]] && \
net::print_dns_redirect "$(config::dns)" 6 "DNS"
} }
function rule::render_own_entries() { function rule::render_own_entries() {
local rule_name="${1:-}" ui::rule::own_entries "$1"
local rule_file
rule_file="$(rule::path "$rule_name")"
local allow_ports allow_ips block_ips block_ports dns
allow_ports=$(json::get "$rule_file" "allow_ports" 2>/dev/null || true)
allow_ips=$(json::get "$rule_file" "allow_ips" 2>/dev/null || true)
block_ips=$(json::get "$rule_file" "block_ips" 2>/dev/null || true)
block_ports=$(json::get "$rule_file" "block_ports" 2>/dev/null || true)
dns=$(json::get "$rule_file" "dns_redirect" 2>/dev/null || true)
local combined="${allow_ports}${allow_ips}${block_ips}${block_ports}"
[[ -z "${combined//[$'\n']/}" ]] && return 0
while IFS= read -r e; do
[[ -z "$e" ]] && continue
net::print_entry "+" "$e"
done <<< "$allow_ports"$'\n'"$allow_ips"
while IFS= read -r e; do
[[ -z "$e" ]] && continue
net::print_entry "-" "$e"
done <<< "$block_ips"$'\n'"$block_ports"
[[ "${dns,,}" == "true" ]] && \
net::print_dns_redirect "$(config::dns)" 6 "DNS"
return 0
} }
function rule::render_extends_tree() { function rule::render_extends_tree() {
local rule_name="${1:-}" ui::rule::tree "$1"
local rule_file
rule_file="$(rule::path "$rule_name")"
local extends_raw=()
mapfile -t extends_raw < <(json::get "$rule_file" "extends" 2>/dev/null || true)
[[ ${#extends_raw[@]} -eq 0 || -z "${extends_raw[0]:-}" ]] && return 1
for base_name in "${extends_raw[@]}"; do
[[ -z "$base_name" ]] && continue
printf "\n \033[0;37m↳ %s\033[0m\n" "$base_name"
rule::render_entries "$base_name"
done
local own_output
own_output=$(rule::render_own_entries "$rule_name")
if [[ -n "$own_output" ]]; then
printf "\n \033[0;37mOwn:\033[0m\n"
printf "%s\n" "$own_output"
fi
return 0
} }
# ============================================ # ============================================

View file

@ -53,3 +53,29 @@ function ui::identity::migrate_summary() {
log::ok "Created identity entries for ${created} peers (${skipped} skipped)" log::ok "Created identity entries for ${created} peers (${skipped} skipped)"
fi fi
} }
function ui::identity::list_row_compact() {
local name="${1:-}" peer_count="${2:-}" rules_list="${3:-}" policy="${4:-}"
local peer_word="peers"
[[ "$peer_count" -eq 1 ]] && peer_word="peer"
local peers_col="${peer_count} ${peer_word}"
local rules_val="-"
[[ -n "$rules_list" ]] && rules_val="$rules_list"
# Pad rules_val to fixed width before adding any ANSI
local pad=16
local rules_padded
rules_padded=$(printf "%-${pad}s" "$rules_val")
printf " \033[1m%-20s\033[0m %-10s \033[2mrules:\033[0m %s \033[2mpolicy:\033[0m %s\n" \
"$name" "$peers_col" "$rules_padded" "$policy"
}
function ui::identity::list_row_table() {
local name="${1:-}" peer_count="${2:-}" types="${3:-}"
local types_display="${types//,/, }"
[[ -z "$types_display" ]] && types_display="—"
printf " %-20s %-7s %s\n" "$name" "$peer_count" "$types_display"
}

161
modules/ui/peer.module.sh Normal file
View file

@ -0,0 +1,161 @@
#!/usr/bin/env bash
# ui/peer.module.sh — rendering for peer list data
# Both compact (tableless) and table layouts kept for future config switching.
_LIST_STYLE="${LIST_STYLE:-compact}"
function ui::peer::list_style() {
echo "$_LIST_STYLE"
}
# ======================================================
# Compact layout (tableless)
# ======================================================
function ui::peer::list_row_compact() {
local w_name="${1:-22}" w_ip="${2:-14}" w_type="${3:-9}" \
w_rule="${4:-6}" w_group="${5:-6}"
shift 5
local name="${1:-}" ip="${2:-}" type="${3:-}" rule="${4:-}" \
group="${5:-}" status="${6:-}" last_seen="${7:-}" \
is_blocked="${8:-false}" is_restricted="${9:-false}"
local status_color="\033[0;37m"
if [[ "$is_blocked" == "true" ]]; then
status_color="\033[1;31m"
elif [[ "$is_restricted" == "true" ]]; then
status_color="\033[1;33m"
elif [[ "$status" == "online" ]]; then
status_color="\033[1;32m"
fi
local ls_color="\033[0;37m"
[[ "$status" == "online" ]] && ls_color="\033[1;32m"
local rule_val="${rule:--}"
local group_val="${group:--}"
# Pad name, ip, type — pure ASCII, safe for printf
local name_pad ip_pad type_pad status_pad
name_pad=$(printf "%-${w_name}s" "$name")
ip_pad=$(printf "%-${w_ip}s" "$ip")
type_pad=$(printf "%-${w_type}s" "$type")
status_pad=$(printf "%-8s" "$status")
# Padding for label+value fields — compute trailing spaces manually
# so ANSI codes in labels don't confuse printf width calculation
local rule_pad_n group_pad_n
rule_pad_n=$(( w_rule - ${#rule_val} ))
group_pad_n=$(( w_group - ${#group_val} ))
[[ $rule_pad_n -lt 0 ]] && rule_pad_n=0
[[ $group_pad_n -lt 0 ]] && group_pad_n=0
printf " %s %s %s \033[2mrule:\033[0m %s%*s \033[2mgroup:\033[0m %s%*s %b%s\033[0m %b%s\033[0m\n" \
"$name_pad" "$ip_pad" "$type_pad" \
"$rule_val" "$rule_pad_n" "" \
"$group_val" "$group_pad_n" "" \
"$status_color" "$status_pad" \
"$ls_color" "$last_seen"
}
# ======================================================
# Table layout (kept for config switching)
# ======================================================
function ui::peer::list_header_table() {
local has_groups="${1:-false}"
if $has_groups; then
printf "\n %-28s %-15s %-13s %-12s %-12s %-22s %s\n" \
"NAME" "IP" "TYPE" "RULE" "GROUP" "STATUS" "LAST SEEN"
printf " %s\n" "$(printf '─%.0s' {1..135})"
else
printf "\n %-28s %-15s %-13s %-12s %-22s %s\n" \
"NAME" "IP" "TYPE" "RULE" "STATUS" "LAST SEEN"
printf " %s\n" "$(printf '─%.0s' {1..107})"
fi
}
function ui::peer::list_footer_table() {
local has_groups="${1:-false}"
if $has_groups; then
printf " %s\n" "$(printf '─%.0s' {1..135})"
else
printf " %s\n" "$(printf '─%.0s' {1..107})"
fi
}
function ui::peer::list_row_table() {
local has_groups="${1:-false}"
shift
local name="${1:-}" ip="${2:-}" type="${3:-}" rule="${4:-}" \
group="${5:-}" status="${6:-}" last_seen="${7:-}"
local padded_status
padded_status=$(ui::pad_status "$status" 25)
if $has_groups; then
printf " %-28s %-15s %-13s %-12s %-12s %s %s\n" \
"$name" "$ip" "$type" "${rule:-}" "${group:-}" \
"$padded_status" "$last_seen"
else
printf " %-28s %-15s %-13s %-12s %s %s\n" \
"$name" "$ip" "$type" "${rule:-}" \
"$padded_status" "$last_seen"
fi
}
# ======================================================
# Detailed layout (grouped by identity)
# ======================================================
function ui::peer::list_identity_header() {
local identity_name="${1:-}"
printf "\n \033[1m%s\033[0m\n" "$identity_name"
}
function ui::peer::list_row_detailed() {
local w_name="${1:-22}" w_ip="${2:-14}" w_type="${3:-9}" \
w_rule="${4:-6}" w_group="${5:-6}" w_subnet="${6:-8}"
shift 6
local name="${1:-}" ip="${2:-}" type="${3:-}" rule="${4:-}" \
group="${5:-}" subnet="${6:-}" status="${7:-}" last_seen="${8:-}" \
is_blocked="${9:-false}" is_restricted="${10:-false}"
local status_color="\033[0;37m"
if [[ "$is_blocked" == "true" ]]; then
status_color="\033[1;31m"
elif [[ "$is_restricted" == "true" ]]; then
status_color="\033[1;33m"
elif [[ "$status" == "online" ]]; then
status_color="\033[1;32m"
fi
local ls_color="\033[0;37m"
[[ "$status" == "online" ]] && ls_color="\033[1;32m"
local rule_val="${rule:-}"
local group_val="${group:-}"
local subnet_val="${subnet:-}"
local name_pad ip_pad type_pad status_pad
name_pad=$(printf "%-${w_name}s" "$name")
ip_pad=$(printf "%-${w_ip}s" "$ip")
type_pad=$(printf "%-${w_type}s" "$type")
status_pad=$(printf "%-8s" "$status")
local rule_pad_n group_pad_n subnet_pad_n
rule_pad_n=$(( w_rule - ${#rule_val} ))
group_pad_n=$(( w_group - ${#group_val} ))
subnet_pad_n=$(( w_subnet - ${#subnet_val} ))
[[ $rule_pad_n -lt 0 ]] && rule_pad_n=0
[[ $group_pad_n -lt 0 ]] && group_pad_n=0
[[ $subnet_pad_n -lt 0 ]] && subnet_pad_n=0
printf " · %s %s %s \033[2mrule:\033[0m %s%*s \033[2mgroup:\033[0m %s%*s \033[2msubnet:\033[0m %s%*s %b%s\033[0m %b%s\033[0m\n" \
"$name_pad" "$ip_pad" "$type_pad" \
"$rule_val" "$rule_pad_n" "" \
"$group_val" "$group_pad_n" "" \
"$subnet_val" "$subnet_pad_n" "" \
"$status_color" "$status_pad" \
"$ls_color" "$last_seen"
}

257
modules/ui/rule.module.sh Normal file
View file

@ -0,0 +1,257 @@
#!/usr/bin/env bash
# ui/rule.module.sh — rendering for rule data
# Replaces rule::render_* functions from rule.module.sh.
# All functions pure rendering — no writes, no state changes.
# ======================================================
# Entry Rendering (shared primitives)
# ======================================================
# ui::rule::entries <rule_name> [indent]
# Renders the fully resolved entries for a rule (allow + block).
function ui::rule::entries() {
local rule_name="${1:-}" indent="${2:-4}"
local allow_ports allow_ips block_ips block_ports dns
allow_ports=$(rule::get "$rule_name" "allow_ports" 2>/dev/null || true)
allow_ips=$(rule::get "$rule_name" "allow_ips" 2>/dev/null || true)
block_ips=$(rule::get "$rule_name" "block_ips" 2>/dev/null || true)
block_ports=$(rule::get "$rule_name" "block_ports" 2>/dev/null || true)
dns=$(rule::get_own "$rule_name" "dns_redirect")
while IFS= read -r e; do
[[ -z "$e" ]] && continue
net::print_entry "+" "$e" "$indent"
done <<< "$allow_ports"$'\n'"$allow_ips"
while IFS= read -r e; do
[[ -z "$e" ]] && continue
net::print_entry "-" "$e" "$indent"
done <<< "$block_ips"$'\n'"$block_ports"
[[ "${dns,,}" == "true" ]] && \
net::print_dns_redirect "$(config::dns)" 6 "DNS"
}
# ui::rule::own_entries <rule_name> [indent]
# Renders only the rule's own (non-inherited) entries.
function ui::rule::own_entries() {
local rule_name="${1:-}" indent="${2:-4}"
local rule_file
rule_file="$(rule::path "$rule_name")" || return 0
[[ -z "$rule_file" ]] && return 0
local allow_ports allow_ips block_ips block_ports dns
allow_ports=$(json::get "$rule_file" "allow_ports" 2>/dev/null || true)
allow_ips=$(json::get "$rule_file" "allow_ips" 2>/dev/null || true)
block_ips=$(json::get "$rule_file" "block_ips" 2>/dev/null || true)
block_ports=$(json::get "$rule_file" "block_ports" 2>/dev/null || true)
dns=$(json::get "$rule_file" "dns_redirect" 2>/dev/null || true)
local combined="${allow_ports}${allow_ips}${block_ips}${block_ports}"
[[ -z "${combined//[$'\n']/}" ]] && return 0
while IFS= read -r e; do
[[ -z "$e" ]] && continue
net::print_entry "+" "$e" "$indent"
done <<< "$allow_ports"$'\n'"$allow_ips"
while IFS= read -r e; do
[[ -z "$e" ]] && continue
net::print_entry "-" "$e" "$indent"
done <<< "$block_ips"$'\n'"$block_ports"
[[ "${dns,,}" == "true" ]] && \
net::print_dns_redirect "$(config::dns)" 6 "DNS"
}
# ui::rule::flat <rule_name>
# Renders the full resolved entries as a flat list.
function ui::rule::flat() {
local rule_name="${1:-}"
local allow_ports allow_ips block_ips block_ports dns
allow_ports=$(rule::get "$rule_name" "allow_ports")
allow_ips=$(rule::get "$rule_name" "allow_ips")
block_ips=$(rule::get "$rule_name" "block_ips")
block_ports=$(rule::get "$rule_name" "block_ports")
dns=$(rule::get_own "$rule_name" "dns_redirect")
local has_content=false
[[ -n "${allow_ports}${allow_ips}${block_ips}${block_ports}" ]] && has_content=true
if ! $has_content; then
printf "\n full access (no restrictions)\n"
return 0
fi
if [[ -n "$allow_ports" || -n "$allow_ips" ]]; then
printf "\n"
while IFS= read -r e; do
[[ -z "$e" ]] && continue
net::print_entry "+" "$e" 2
done <<< "$allow_ports"$'\n'"$allow_ips"
fi
if [[ -n "$block_ips" || -n "$block_ports" ]]; then
printf "\n"
while IFS= read -r e; do
[[ -z "$e" ]] && continue
net::print_entry "-" "$e" 2
done <<< "$block_ips"$'\n'"$block_ports"
fi
[[ "${dns,,}" == "true" ]] && \
net::print_dns_redirect "$(config::dns)" 6 "DNS"
}
# ======================================================
# Shared Base Renderer
# ======================================================
# ui::rule::_render_bases <array_nameref> [entry_indent] [label_indent]
# Renders a list of base rule names with newlines between them.
# Used by both ui::rule::tree and ui::rule::_identity_rule_entry.
function ui::rule::_render_bases() {
local -n _bases="$1"
local entry_indent="${2:-6}" label_indent="${3:-4}"
local label_pad
label_pad=$(printf '%*s' "$label_indent" '')
local first=true
for base_name in "${_bases[@]}"; do
[[ -z "$base_name" ]] && continue
$first || printf "\n"
first=false
printf "%s\033[0;37m↳ %s\033[0m\n" "$label_pad" "$base_name"
ui::rule::entries "$base_name" "$entry_indent"
done
}
# ======================================================
# Tree Rendering
# ======================================================
# ui::rule::tree <rule_name>
# Renders a rule's extends tree — one level deep with own entries.
# Returns 1 if rule has no extends (caller can fall back to flat).
function ui::rule::tree() {
local rule_name="${1:-}"
local rule_file
rule_file="$(rule::path "$rule_name")" || return 1
[[ -z "$rule_file" ]] && return 1
local extends_raw=()
mapfile -t extends_raw < <(json::get "$rule_file" "extends" 2>/dev/null || true) || true
if [[ ${#extends_raw[@]} -eq 0 || -z "${extends_raw[0]:-}" ]]; then
return 1
fi
ui::rule::_render_bases extends_raw 6 4
local own_output
own_output=$(ui::rule::own_entries "$rule_name" 6)
if [[ -n "$own_output" ]]; then
printf "\n \033[0;37mOwn:\033[0m\n"
printf "%s\n" "$own_output"
fi
return 0
}
# ======================================================
# Identity Rule Block
# ======================================================
# ui::rule::identity_block <identity_name> <strict_rule>
# Renders the full identity rule block in inspect.
function ui::rule::identity_block() {
local identity_name="${1:-}" strict="${2:-false}"
local rules
rules=$(identity::rules "$identity_name")
[[ -z "$rules" ]] && return 0
printf "\n \033[0;37m· identity:%s\033[0m\n" "$identity_name"
local first=true
while IFS= read -r rule_name; do
[[ -z "$rule_name" ]] && continue
$first || printf "\n"
first=false
ui::rule::_identity_rule_entry "$rule_name"
done <<< "$rules"
if [[ "$strict" == "true" ]]; then
printf "\n \033[2m(strict — peer rule suppressed)\033[0m\n"
fi
}
# ui::rule::_identity_rule_entry <rule_name>
# Renders one rule within an identity block.
function ui::rule::_identity_rule_entry() {
local rule_name="${1:-}"
local rule_file
rule_file="$(rule::path "$rule_name")" || return 0
printf " \033[0;37m↳ %s\033[0m\n" "$rule_name"
local extends_raw=()
mapfile -t extends_raw < <(json::get "$rule_file" "extends" 2>/dev/null || true) || true
if [[ ${#extends_raw[@]} -gt 0 && -n "${extends_raw[0]:-}" ]]; then
# Rule has extends — render one level deep using shared helper
ui::rule::_render_bases extends_raw 10 8
local own_output
own_output=$(ui::rule::own_entries "$rule_name" 10)
if [[ -n "$own_output" ]]; then
printf "\n \033[0;37mOwn:\033[0m\n"
printf "%s\n" "$own_output"
fi
else
# Leaf rule — show own entries or note full access
local own_output
own_output=$(ui::rule::own_entries "$rule_name" 8)
if [[ -n "$own_output" ]]; then
printf "%s\n" "$own_output"
else
printf " \033[2mfull access (no restrictions)\033[0m\n"
fi
fi
}
# ======================================================
# Peer Entry
# ======================================================
function ui::rule::_peer_rule_entry() {
local rule_name="${1:-}"
local rule_file
rule_file="$(rule::path "$rule_name")" || return 0
printf " \033[0;37m↳ %s\033[0m\n" "$rule_name"
local extends_raw=()
mapfile -t extends_raw < <(json::get "$rule_file" "extends" 2>/dev/null || true) || true
if [[ ${#extends_raw[@]} -gt 0 && -n "${extends_raw[0]:-}" ]]; then
ui::rule::_render_bases extends_raw 10 8
local own_output
own_output=$(ui::rule::own_entries "$rule_name" 10)
if [[ -n "$own_output" ]]; then
printf "\n \033[0;37mOwn:\033[0m\n"
printf "%s\n" "$own_output"
fi
else
local own_output
own_output=$(ui::rule::own_entries "$rule_name" 8)
if [[ -n "$own_output" ]]; then
printf "%s\n" "$own_output"
else
printf " \033[2mfull access (no restrictions)\033[0m\n"
fi
fi
}

View file

@ -8,6 +8,33 @@ function ui::subnet::header() {
ui::divider 70 ui::divider 70
} }
function ui::policy::list_row() {
local name="${1:-}" default_rule="${2:-}" strict="${3:-}" auto="${4:-}"
local rule_val="-"
[[ -n "$default_rule" ]] && rule_val="$default_rule"
local rule_padded
rule_padded=$(printf "%-16s" "$rule_val")
local strict_display
[[ "$strict" == "true" ]] && strict_display="yes" || strict_display="no"
local strict_padded
strict_padded=$(printf "%-4s" "$strict_display")
local auto_display=""
[[ "$auto" == "false" ]] && auto_display=" \033[2mauto:\033[0m no"
printf " %-14s \033[2mrule:\033[0m %s \033[2mstrict:\033[0m %s%s\n" \
"$name" "$rule_padded" "$strict_padded" "$auto_display"
}
function ui::policy::detail_field() {
local key="${1:-}" value="${2:-}"
ui::row "$key" "$value"
}
function ui::subnet::row() { function ui::subnet::row() {
local display_name="${1:-}" subnet="${2:-}" type_key="${3:-}" \ local display_name="${1:-}" subnet="${2:-}" type_key="${3:-}" \
tunnel_mode="${4:-}" desc="${5:-}" is_group="${6:-false}" tunnel_mode="${4:-}" desc="${5:-}" is_group="${6:-false}"
@ -17,6 +44,27 @@ function ui::subnet::row() {
"$name_col" "$subnet" "$type_key" "$tunnel_mode" "$desc" "$name_col" "$subnet" "$type_key" "$tunnel_mode" "$desc"
} }
function ui::subnet::row_scalar() {
local name="${1:-}" subnet="${2:-}" tunnel="${3:-split}"
local tunnel_val
tunnel_val=$(printf "%-8s" "$tunnel")
printf " %-14s %-18s \033[2mtunnel:\033[0m %s\n" \
"$name" "$subnet" "$tunnel_val"
}
function ui::subnet::row_group_parent() {
local name="${1:-}"
printf " \033[1m%s\033[0m\n" "$name"
}
function ui::subnet::row_group_child() {
local type_key="${1:-}" subnet="${2:-}" tunnel="${3:-split}"
local tunnel_val
tunnel_val=$(printf "%-8s" "$tunnel")
printf " · %-10s %-18s \033[2mtunnel:\033[0m %s\n" \
"$type_key" "$subnet" "$tunnel_val"
}
function ui::subnet::group_separator() { function ui::subnet::group_separator() {
echo "" echo ""
} }
@ -49,3 +97,117 @@ function ui::subnet::peers_in_use() {
echo "" echo ""
ui::row "Peers using" "${peers_csv//,/, }" ui::row "Peers using" "${peers_csv//,/, }"
} }
function ui::subnet::show_scalar() {
local name="${1:-}" subnet="${2:-}" tunnel="${3:-}" desc="${4:-}"
echo ""
printf " \033[1m%-16s\033[0m %-18s \033[2mtunnel:\033[0m %s\n" \
"$name" "$subnet" "$tunnel"
echo ""
[[ -n "$desc" ]] && printf " \033[2mDescription:\033[0m %s\n" "$desc"
}
function ui::subnet::show_group() {
local name="${1:-}"
echo ""
printf " \033[1m%s\033[0m\n" "$name"
echo ""
}
function ui::subnet::show_child_row() {
local type_key="${1:-}" subnet="${2:-}" tunnel="${3:-}" desc="${4:-}"
local desc_part=""
[[ -n "$desc" ]] && desc_part=" $desc"
printf " · %-10s %-18s \033[2mtunnel:\033[0m %-8s%s\n" \
"$type_key" "$subnet" "$tunnel" "$desc_part"
}
function ui::subnet::show_peers() {
local peers_csv="${1:-}"
[[ -z "$peers_csv" ]] && return 0
echo ""
local peers_arr=()
IFS=',' read -ra peers_arr <<< "$peers_csv"
local count="${#peers_arr[@]}"
printf " Peers (%s):\n" "$count"
# Group peers by identity, then by subnet child (for groups)
# Build: identity -> list of peer names
declare -A identity_peers_map=()
local no_identity=()
for peer in "${peers_arr[@]}"; do
peer="${peer// /}"
[[ -z "$peer" ]] && continue
local id_name
id_name=$(identity::get_name "$peer")
if [[ -n "$id_name" ]]; then
identity_peers_map["$id_name"]+="${peer},"
else
no_identity+=("$peer")
fi
done
# Print identity groups on same line
for id_name in "${!identity_peers_map[@]}"; do
local peer_list="${identity_peers_map[$id_name]%,}"
printf " · %s\n" "${peer_list//,/, }"
done
# Print peers without identity one per line
for peer in "${no_identity[@]}"; do
printf " · %s\n" "$peer"
done
}
function ui::subnet::show_peers_annotated() {
# For group subnets — peers annotated with their subnet child
local peers_csv="${1:-}" subnets_file="${2:-}"
[[ -z "$peers_csv" ]] && return 0
local peers_arr=()
IFS=',' read -ra peers_arr <<< "$peers_csv"
local count="${#peers_arr[@]}"
echo ""
printf " Peers (%s):\n" "$count"
declare -A identity_peers_map=() # identity -> "peer→child,peer→child"
local no_identity=()
for peer in "${peers_arr[@]}"; do
peer="${peer// /}"
[[ -z "$peer" ]] && continue
# Find which child subnet this peer's IP belongs to
local peer_ip child_name=""
peer_ip=$(peers::get_ip "$peer" 2>/dev/null) || peer_ip=""
if [[ -n "$peer_ip" ]]; then
local result
result=$(json::subnet_for_ip "$subnets_file" "$peer_ip" 2>/dev/null) || true
[[ -n "$result" ]] && child_name="${result##*|}"
fi
local annotated="${peer}"
[[ -n "$child_name" ]] && annotated="${peer} \033[2m→ ${child_name}\033[0m"
local id_name
id_name=$(identity::get_name "$peer")
if [[ -n "$id_name" ]]; then
identity_peers_map["$id_name"]+="${annotated},"
else
no_identity+=("$annotated")
fi
done
for id_name in "${!identity_peers_map[@]}"; do
local peer_list="${identity_peers_map[$id_name]%,}"
printf " · %b\n" "${peer_list//,/, }"
done
for peer in "${no_identity[@]}"; do
printf " · %b\n" "$peer"
done
}

2
wgctl
View file

@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -Eeuo pipefail set -uo pipefail
source "$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)/core.sh" source "$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)/core.sh"