From 4b2f2a846adca9e2f09e006e6fffded6e4d905ca Mon Sep 17 00:00:00 2001 From: Nuno Duque Nunes Date: Fri, 22 May 2026 03:42:40 +0000 Subject: [PATCH] feat: identity, subnet, policy systems + tableless layouts --- commands/add.command.sh | 28 ++- commands/identity.command.sh | 175 +++++++++----- commands/inspect.command.sh | 85 +++++-- commands/list.command.sh | 420 ++++++++++++++++++++++------------ commands/policy.command.sh | 51 +++-- commands/rule.command.sh | 8 +- commands/subnet.command.sh | 90 +++++--- commands/test/integration.sh | 8 +- core/fmt.sh | 22 ++ core/json.sh | 7 + core/json_helper.py | 100 +++++++- core/ui.sh | 58 ++++- daemon/endpoint_cache.json | 2 +- modules/identity.module.sh | 112 ++++++--- modules/rule.module.sh | 242 ++++++-------------- modules/ui/identity.module.sh | 26 +++ modules/ui/peer.module.sh | 161 +++++++++++++ modules/ui/rule.module.sh | 257 +++++++++++++++++++++ modules/ui/subnet.module.sh | 162 +++++++++++++ wgctl | 2 +- 20 files changed, 1523 insertions(+), 493 deletions(-) create mode 100644 modules/ui/peer.module.sh create mode 100644 modules/ui/rule.module.sh diff --git a/commands/add.command.sh b/commands/add.command.sh index 139b1ab..abb8211 100644 --- a/commands/add.command.sh +++ b/commands/add.command.sh @@ -273,25 +273,31 @@ function cmd::add::_apply_identity_rule() { [[ -z "$identity_name" ]] && return 0 - local identity_rule - identity_rule=$(identity::rule "$identity_name") || true + local rules + rules=$(identity::rules "$identity_name") - [[ -z "$identity_rule" ]] && { - # No identity rule — warn if no peer rule either + if [[ -z "$rules" ]]; then + # No identity rules — warn if no peer rule either if [[ -z "$peer_rule" ]]; then policy::warn_no_rule "$full_name" fi return 0 - } + fi - # Apply identity rule - rule::exists "$identity_rule" && rule::apply "$identity_rule" "$ip" "$full_name" || true + # Apply all identity rules + rule::_apply_identity_rule "$full_name" "$ip" # Warn based on strict_rule - if policy::strict_rule "$effective_policy"; then - policy::warn_strict_rule "$identity_name" "$effective_policy" "$identity_rule" - elif [[ -n "$peer_rule" && "$peer_rule" != "$identity_rule" ]]; then - policy::warn_additive_rule "$identity_name" "$identity_rule" "$peer_rule" + local strict + strict=$(identity::rule_flags "$identity_name" "strict_rule") + if [[ "$strict" == "true" ]]; then + 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 } diff --git a/commands/identity.command.sh b/commands/identity.command.sh index 0b8a0b8..a56c0ca 100644 --- a/commands/identity.command.sh +++ b/commands/identity.command.sh @@ -101,16 +101,19 @@ function cmd::identity::run() { function cmd::identity::_list() { local data data=$(identity::list_data) - + if [[ -z "$data" ]]; then log::info "No identities found. Run 'wgctl identity migrate' to create from existing peers." return 0 fi - - ui::identity::header - while IFS='|' read -r name peer_count types; do - ui::identity::row "$name" "$peer_count" "$types" + + echo "" + while IFS='|' read -r name peer_count types rules policy; do + local rules_display + rules_display=$(echo "$rules" | sed 's/,/, /g') + ui::identity::list_row_compact "$name" "$peer_count" "$rules_display" "$policy" done <<< "$data" + echo "" } function cmd::identity::_show() { @@ -122,50 +125,73 @@ function cmd::identity::_show() { *) log::error "Unknown flag: $1"; return 1 ;; esac done - + [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } identity::require_exists "$name" || return 1 - - local data peer_count="0" + + # Gather identity-level metadata + 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 case "$key" in - name) - peer_count=$(echo "$data" | grep '^peer_count|' | cut -d'|' -f2) - ui::identity::detail_name "$val" "$peer_count" - ;; - peer_count) ;; # consumed above + name|peer_count) ;; device) 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" ;; esac done <<< "$data" - + echo "" } + function cmd::identity::_device_status() { local peer_name="${1:-}" + local -n _handshakes="${2:-__empty_map}" + local peer_ip peer_ip=$(peers::get_ip "$peer_name" 2>/dev/null) || return 0 [[ -z "$peer_ip" ]] && return 0 - + local is_blocked is_restricted pubkey handshake_ts - is_blocked=$(peers::is_blocked "$peer_name") - is_restricted=$(peers::is_restricted "$peer_name") - 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 - last_ts=$(peers::get_meta "$peer_name" "last_ts" 2>/dev/null) || last_ts="" - last_evt=$(peers::get_meta "$peer_name" "last_evt" 2>/dev/null) || last_evt="" - + peers::is_blocked "$peer_name" && is_blocked="true" || is_blocked="false" + peers::is_restricted "$peer_name" && is_restricted="true" || is_restricted="false" + + pubkey="$(keys::public "$peer_name")" + handshake_ts="${_handshakes[$pubkey]:-0}" + + local last_ts + last_ts=$(peers::get_meta "$peer_name" "last_ts" 2>/dev/null) || last_ts="" + local status - status=$(peers::format_status \ + status=$(peers::format_status_verbose \ "$peer_name" "$pubkey" "$is_blocked" "$is_restricted" "$handshake_ts" "$last_ts") echo " — ${status}" } @@ -336,12 +362,19 @@ function cmd::identity::_rule_assign() { [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } [[ -z "$rule" ]] && { log::error "Missing required flag: --rule"; 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}'" - # Reapply rules for all peers if auto_apply is set + # Reapply rules if auto_apply local auto auto=$(identity::rule_flags "$name" "auto_apply") if [[ "$auto" != "false" ]]; then @@ -350,17 +383,19 @@ function cmd::identity::_rule_assign() { log::ok "Rules reapplied" fi - # Warn about strict_rule impact + # Warn about strict_rule if policy::strict_rule "$(identity::policy "$name")"; then log::warn "Strict rule is enabled for identity '${name}' — peer rules will not be additive" fi } function cmd::identity::_rule_unassign() { - local name="" + local name="" rule="" all=false while [[ $# -gt 0 ]]; do case "$1" in --name) name="$2"; shift 2 ;; + --rule) rule="$2"; shift 2 ;; + --all) all=true; shift ;; *) log::error "Unknown flag: $1"; return 1 ;; esac done @@ -368,16 +403,46 @@ function cmd::identity::_rule_unassign() { [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } identity::require_exists "$name" || return 1 - local current_rule - current_rule=$(identity::rule "$name") - if [[ -z "$current_rule" ]]; then - log::warn "Identity '${name}' has no rule assigned" + if $all; then + local rules + rules=$(identity::rules "$name") + 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 fi - identity::clear_rule "$name" - log::ok "Rule removed from identity '${name}'" - log::info "Note: existing fw rules from '${current_rule}' are not automatically removed — run 'wgctl rule reapply' if needed" + [[ -z "$rule" ]] && { + log::error "Missing required flag: --rule (or use --all to remove all)" + 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() { @@ -392,20 +457,28 @@ function cmd::identity::_rule_show() { [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } identity::require_exists "$name" || return 1 - local rule policy - rule=$(identity::rule "$name") + local rules policy strict auto + rules=$(identity::rules "$name") policy=$(identity::policy "$name") - - local strict auto strict=$(identity::rule_flags "$name" "strict_rule") - auto=$(identity::rule_flags "$name" "auto_apply") - + auto=$(identity::rule_flags "$name" "auto_apply") + echo "" - ui::row "Identity" "$name" - ui::row "Rule" "${rule:-—}" - ui::row "Policy" "$policy" - ui::row "Strict rule" "$( [[ "$strict" == "true" ]] && echo "yes" || echo "no" )" - ui::row "Auto apply" "$( [[ "$auto" != "false" ]] && echo "yes" || echo "no" )" + ui::row "Identity" "$name" + ui::row "Policy" "$policy" + ui::row "Strict rule" "$(ui::bool "$strict")" + ui::row "Auto apply" "$(ui::bool "$auto")" + 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 "" } diff --git a/commands/inspect.command.sh b/commands/inspect.command.sh index e5d34b9..e148b01 100644 --- a/commands/inspect.command.sh +++ b/commands/inspect.command.sh @@ -89,9 +89,9 @@ function cmd::inspect::_peer_info() { local activity_current activity_current=$(peers::format_activity_current "$public_key") + local rule_file="" local rule_extends="" if [[ -n "$rule" ]]; then - local rule_file rule_file="$(rule::path "$rule" 2>/dev/null)" || true if [[ -n "$rule_file" ]]; then local ext=() @@ -115,7 +115,7 @@ function cmd::inspect::_peer_info() { ui::row "Name" "$name" "${INSPECT_LABEL_WIDTH}" ui::row "IP" "$ip" "${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 "Endpoint" "${endpoint:-—}" "${INSPECT_LABEL_WIDTH}" ui::row "Last seen" "$last_seen" "${INSPECT_LABEL_WIDTH}" @@ -127,22 +127,81 @@ function cmd::inspect::_peer_info() { 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() { 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 rule::render_extends_tree "$rule"; then - # printf "\n" - : # no-op - else - # No inheritance — flat view - rule::render_flat "$rule" + + local identity_name identity_rules strict + identity_name=$(identity::get_name "$name") + if [[ -n "$identity_name" ]]; then + identity_rules=$(identity::rules "$identity_name") + strict=$(identity::rule_flags "$identity_name" "strict_rule") 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 } diff --git a/commands/list.command.sh b/commands/list.command.sh index 6d9b4a6..a7c95a1 100644 --- a/commands/list.command.sh +++ b/commands/list.command.sh @@ -5,6 +5,9 @@ # ============================================ function cmd::list::on_load() { + load_module identity + load_module ui + flag::register --type flag::register --rule flag::register --group @@ -29,87 +32,29 @@ Usage: wgctl list [options] List all WireGuard clients. Options: - --type Filter by device type (desktop, laptop, phone, tablet) + --type Filter by device type --rule Filter by assigned rule --group Filter by group membership - --identity Filter by identity (show all peers for an identity) + --identity Filter by identity --online Show only connected clients --offline Show only disconnected clients - --blocked Show only fully blocked clients (removed from WireGuard) - --restricted Show only restricted clients (specific IP/port blocks applied) + --blocked Show only fully blocked clients + --restricted Show only restricted clients --allowed Show only unrestricted clients - --detailed Show full detail cards for all clients + --detailed Show detailed view grouped by identity --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: wgctl list wgctl list --type phone - wgctl list --rule user - wgctl list --group family wgctl list --identity nuno wgctl list --online wgctl list --blocked - wgctl list --restricted wgctl list --detailed wgctl list --name phone-nuno 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 # ============================================ @@ -133,7 +78,6 @@ function cmd::list::show_client() { local public_key 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 type=$(peers::get_meta "$name" "type" 2>/dev/null) [[ -z "$type" ]] && type=$(peers::get_type_from_ip "$ip") @@ -241,36 +185,53 @@ function cmd::list::run() { cmd::list::_precompute_all - if $detailed; then - log::section "WireGuard Clients" - cmd::list::_iter_confs "$filter_type" cmd::list::_show_client_safe + # Resolve 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 + + 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 fi - local filter_desc="" - cmd::list::_build_filter_desc - - declare -A rule_counts=() group_counts=() - _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}}" + if $detailed; then + cmd::list::_render_detailed "$collected_rows" + cmd::list::_render_summary_from_rows "$collected_rows" + return 0 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() { - local filter_type="$1" callback="$2" +function cmd::list::_collect_all_rows() { + # 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 dir="$(ctx::clients)" @@ -278,8 +239,124 @@ function cmd::list::_iter_confs() { [[ -f "$conf" ]] || continue local client_name 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 && \ -z "${p_identity_filter[$client_name]:-}" ]]; then continue @@ -288,16 +365,111 @@ function cmd::list::_iter_confs() { local ip="${p_ips[$client_name]:-}" [[ -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}" [[ -n "$filter_type" && "$type" != "$filter_type" ]] && continue - "$callback" "$client_name" "$ip" "$type" + cmd::list::_render_row "$client_name" "$ip" "$type" 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() { @@ -321,7 +493,7 @@ function cmd::list::_render_row() { [[ "$all_groups" != *"$filter_group"* ]] && return 0 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" \ "$is_blocked" "$is_restricted" "$handshake_ts" "$last_ts") 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 [[ "${_list_header_printed:-false}" == "false" ]]; then - log::section "WireGuard Clients" cmd::list::_render_header $has_groups _list_header_printed=true fi @@ -344,26 +515,12 @@ function cmd::list::_render_row() { if $has_groups; then local main_group="${p_main_groups[$client_name]:-}" - if [[ -n "$main_group" ]]; then - group_display="$main_group" - 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" \ + local group_display="${main_group:-${peer_group_map[$client_name]:-—}}" + printf " %-28s %-15s %-13s %-12s %-12s %s %s\n" \ "$client_name" "$ip" "$display_type" "$rule" \ "$group_display" "$padded_status" "$last_seen" else - local rule_col_width=12 - [[ "$rule" == "—" ]] && rule_col_width=14 - printf " %-28s %-15s %-13s %-${rule_col_width}s %s %s\n" \ + printf " %-28s %-15s %-13s %-12s %s %s\n" \ "$client_name" "$ip" "$display_type" "$rule" \ "$padded_status" "$last_seen" fi @@ -374,25 +531,22 @@ function cmd::list::_render_row() { # ============================================ 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=() while IFS="|" read -r name ip rule type last_ts last_evt main_group; do [[ -z "$name" ]] && continue p_ips["$name"]="$ip" - p_rules["$name"]="${rule:-—}" + p_rules["$name"]="${rule:-}" p_types["$name"]="${type:-}" p_last_ts["$name"]="$last_ts" p_last_evt["$name"]="$last_evt" p_main_groups["$name"]="${main_group:-}" 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 [[ -n "${p_types[$name]:-}" ]] && continue p_types["$name"]=$(peers::get_type_from_ip "${p_ips[$name]}") done - # WireGuard handshakes + endpoints declare -gA wg_handshakes=() wg_endpoints=() while IFS=$'\t' read -r pubkey ts; do [[ -n "$pubkey" ]] && wg_handshakes["$pubkey"]="$ts" @@ -401,11 +555,9 @@ function cmd::list::_precompute_all() { [[ -n "$pubkey" ]] && wg_endpoints["$pubkey"]="$endpoint" done < <(wg show "$(config::interface)" endpoints 2>/dev/null) - # Block/restricted status declare -gA p_blocked=() p_restricted=() cmd::list::_precompute_block_status p_blocked p_restricted - # Public keys declare -gA p_pubkeys=() local dir dir="$(ctx::clients)" @@ -416,7 +568,6 @@ function cmd::list::_precompute_all() { p_pubkeys["$kname"]=$(cat "$kf" 2>/dev/null || echo "") done - # Groups + main group has_groups=false declare -gA peer_group_map=() local groups_dir @@ -429,27 +580,8 @@ function cmd::list::_precompute_all() { done < <(json::peer_group_map "$groups_dir") fi - # Resolve identity filter into a peer set + # Identity precompute (for --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() { @@ -477,19 +609,15 @@ function cmd::list::_precompute_block_status() { } # ============================================ -# Filter helpers +# Header / Footer (table layout) # ============================================ -function cmd::list::_build_filter_desc() { - filter_desc="" - [[ -n "$filter_type" ]] && filter_desc+="type=${filter_type} " - [[ -n "$filter_rule" ]] && filter_desc+="rule=${filter_rule} " - [[ -n "$filter_group" ]] && filter_desc+="group=${filter_group} " - [[ -n "$filter_identity" ]] && filter_desc+="identity=${filter_identity} " - $online_only && filter_desc+="online " - $offline_only && filter_desc+="offline " - $blocked_only && filter_desc+="blocked " - filter_desc="${filter_desc% }" +function cmd::list::_render_header() { + ui::peer::list_header_table "$1" +} + +function cmd::list::_render_footer() { + ui::peer::list_footer_table "$1" } function cmd::list::_build_group_summary() { diff --git a/commands/policy.command.sh b/commands/policy.command.sh index 98a1d42..b9b0149 100644 --- a/commands/policy.command.sh +++ b/commands/policy.command.sh @@ -100,24 +100,17 @@ function cmd::policy::run() { function cmd::policy::_list() { local data data=$(policy::list_data) - + if [[ -z "$data" ]]; then log::info "No policies defined." return 0 fi - - printf " %-14s %-8s %-14s %-12s %-12s %s\n" \ - "NAME" "TUNNEL" "DEFAULT RULE" "STRICT RULE" "AUTO APPLY" "DESCRIPTION" - ui::divider 84 - + + echo "" while IFS='|' read -r name tunnel default_rule strict auto desc; do - local rule_display="${default_rule:-—}" - 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" + ui::policy::list_row "$name" "$default_rule" "$strict" "$auto" done <<< "$data" + echo "" } function cmd::policy::_show() { @@ -129,18 +122,34 @@ function cmd::policy::_show() { *) log::error "Unknown flag: $1"; return 1 ;; esac done - + [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } policy::require_exists "$name" || return 1 - - echo "" - ui::row "Name" "$name" - ui::row "Tunnel mode" "$(policy::tunnel_mode "$name")" - local dr + + local dr tunnel strict auto dr=$(policy::default_rule "$name") - ui::row "Default rule" "${dr:-—}" - ui::row "Strict rule" "$(policy::strict_rule "$name" && echo "yes" || echo "no")" - ui::row "Auto apply" "$(policy::auto_apply "$name" && echo "yes" || echo "no")" + tunnel=$(policy::tunnel_mode "$name") + strict=$(policy::strict_rule "$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 "" } diff --git a/commands/rule.command.sh b/commands/rule.command.sh index c66c674..10ff3cf 100644 --- a/commands/rule.command.sh +++ b/commands/rule.command.sh @@ -358,13 +358,15 @@ function cmd::rule::show() { ui::row "Group" "${group:-—}" ui::row "DNS" "$dns_display" + printf "\n" # ── Extends + own rules ──────────────────────── if rule::render_extends_tree "$name"; then # Has inheritance — tree already rendered - printf "\n" + : else # No inheritance — flat view rule::render_flat "$name" + printf "\n" fi # ── Resolved ────────────────────────────────── @@ -381,7 +383,6 @@ function cmd::rule::show() { while IFS= read -r e; do [[ -n "$e" ]] && net::print_entry "-" "$e"; done \ <<< "$res_block_ips"$'\n'"$res_block_ports" printf "\n" - fi # ── Peers ───────────────────────────────────── @@ -389,7 +390,10 @@ function cmd::rule::show() { mapfile -t peer_list < <(peers::with_rule "$name") local peer_count=${#peer_list[@]} + + ui::empty "$peer_count" && return 0 + printf "\n" printf "\033[0;37m── Peers (%s) \033[0m%s\n\n" \ "$(color::gray "${peer_count}")" \ "$(printf '\033[0;37m─%.0s' {1..35})" diff --git a/commands/subnet.command.sh b/commands/subnet.command.sh index e80bde7..04b8c6f 100644 --- a/commands/subnet.command.sh +++ b/commands/subnet.command.sh @@ -86,20 +86,31 @@ function cmd::subnet::run() { function cmd::subnet::_list() { local data data=$(subnet::list_data) - + if [[ -z "$data" ]]; then log::info "No subnets defined." return 0 fi - - ui::subnet::header - + + echo "" local prev_group="" 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" - prev_group=$(cmd::subnet::_update_prev_group "$is_group" "$group_parent" "$prev_group") - ui::subnet::row "$display_name" "$subnet" "$type_key" "$tunnel_mode" "$desc" "$is_group" + if [[ "$is_group" == "true" ]]; then + # Print group parent header when we encounter first child + 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" + echo "" } function cmd::subnet::_maybe_group_separator() { @@ -129,42 +140,53 @@ function cmd::subnet::_show() { *) log::error "Unknown flag: $1"; return 1 ;; esac done - + [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } subnet::require_exists "$name" || return 1 - + local data data=$(subnet::show_data "$name") - + local is_group="false" + local show_name="" show_subnet="" show_tunnel="" show_desc="" + while IFS='|' read -r key val rest; do case "$key" in - name) - ui::subnet::detail "$val" "$is_group" - ;; - is_group) - is_group="$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" - ;; + name) show_name="$val" ;; + is_group) is_group="$val" ;; + subnet) show_subnet="$val" ;; + tunnel_mode) show_tunnel="$val" ;; + desc) show_desc="$val" ;; esac done <<< "$data" - - local peers_using - peers_using=$(subnet::peers_using "$name") - ui::subnet::peers_in_use "$peers_using" + + if [[ "$is_group" == "true" ]]; then + # Group display + 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() { diff --git a/commands/test/integration.sh b/commands/test/integration.sh index c0ec5ce..b861127 100644 --- a/commands/test/integration.sh +++ b/commands/test/integration.sh @@ -136,12 +136,12 @@ function cmd::test::run_all_integration_sections() { function cmd::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 --offline" "" list --offline cmd::test::run_cmd "list --blocked" "" list --blocked 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 } @@ -225,8 +225,8 @@ function cmd::test::section_subnet() { "$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 show desktop" "Subnet" subnet show --name desktop - cmd::test::run_cmd "subnet show guests group" "group" subnet show --name guests + cmd::test::run_cmd "subnet show desktop" "tunnel:" subnet show --name desktop + 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 "subnet add" "added" \ diff --git a/core/fmt.sh b/core/fmt.sh index 64a37bc..56a2e4a 100644 --- a/core/fmt.sh +++ b/core/fmt.sh @@ -34,6 +34,28 @@ function fmt::datetime_iso() { python3 "$FMT_HELPER" fmt_datetime "$iso" "$FMT_DATETIME" +# 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() { local format="$1" case "$format" in diff --git a/core/json.sh b/core/json.sh index 96c2508..3dc474c 100644 --- a/core/json.sh +++ b/core/json.sh @@ -90,6 +90,13 @@ function json::identity_migrate() { python3 "$JSON_HELPER" identity_migrat function json::identity_infer() { python3 "$JSON_HELPER" identity_infer "$@" 2 else ''), 'get_nested': lambda args: json_get_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__': diff --git a/core/ui.sh b/core/ui.sh index 086671d..fe78485 100644 --- a/core/ui.sh +++ b/core/ui.sh @@ -77,6 +77,48 @@ function ui::center() { printf "%${pad}s%s%${rpad}s" "" "$text" "" } +# ui::measure_col [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 +# 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() { local rule="$1" if [[ "$rule" =~ ACCEPT|DNAT ]]; then @@ -109,9 +151,19 @@ function ui::skip_if_empty() { } function ui::empty() { - # ui::empty "$var" && return 0 - # ui::empty "${array[*]}" && return 0 - [[ -z "${1// }" ]] + local val="${1:-}" + # Empty string or whitespace only + [[ -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" } # ============================================ diff --git a/daemon/endpoint_cache.json b/daemon/endpoint_cache.json index 7398222..f7c1f06 100644 --- a/daemon/endpoint_cache.json +++ b/daemon/endpoint_cache.json @@ -8,6 +8,6 @@ "desktop-roboclean": "46.189.215.231", "laptop-nuno": "94.63.0.129", "phone-luis": "176.223.61.15", - "phone-helena-2": "148.69.192.157", + "phone-helena-2": "148.69.192.130", "desktop-zephyr": "86.120.152.74" } \ No newline at end of file diff --git a/modules/identity.module.sh b/modules/identity.module.sh index 8da7d3d..e688ba9 100644 --- a/modules/identity.module.sh +++ b/modules/identity.module.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash # identity.module.sh — identity file management and peer-name inference +declare -gA __empty_map=() + # =========================================================================== # Path helpers # =========================================================================== @@ -255,39 +257,7 @@ function identity::rename_peer() { fi } -# identity::rule -# 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 -# 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 -# 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 # Returns the policy name assigned to an identity, or "default". function identity::policy() { @@ -367,6 +337,82 @@ function identity::reapply_rules() { 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" +} + +# identity::rules +# 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 +# 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 +# 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 +# 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 +# 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 +# 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 diff --git a/modules/rule.module.sh b/modules/rule.module.sh index fa276fc..58ba2b3 100644 --- a/modules/rule.module.sh +++ b/modules/rule.module.sh @@ -99,32 +99,21 @@ function rule::is_applied() { # Rule Application # ============================================ -function rule::apply() { - local rule_name="${1:?rule_name required}" - local client_ip="${2:?client_ip required}" - local peer_name="${3:-}" +function rule::_apply_entries() { + local rule_name="${1:?}" client_ip="${2:?}" 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 - log::wg "Rule '${rule_name}' already applied to: ${client_ip}" - [[ -n "$peer_name" ]] && peers::set_meta "$peer_name" "rule" "$rule_name" + log::debug "Rule '${rule_name}' already applied to: ${client_ip}" return 0 fi - # Process block_ips while IFS= read -r block_ip; do [[ -z "$block_ip" ]] && continue fw::block_ip "$client_ip" "$block_ip" done < <(rule::get "$rule_name" "block_ips") - # Process block_ports while IFS= read -r entry; do [[ -z "$entry" ]] && continue local target port proto @@ -133,13 +122,11 @@ function rule::apply() { fw::block_port "$client_ip" "$target" "$port" "$proto" done < <(rule::get "$rule_name" "block_ports") - # Process allow_ips (inserted before blocks) while IFS= read -r allow_ip; do [[ -z "$allow_ip" ]] && continue fw::allow_ip "$client_ip" "$allow_ip" done < <(rule::get "$rule_name" "allow_ips") - # Process allow_ports (highest priority) while IFS= read -r entry; do [[ -z "$entry" ]] && continue local target port proto @@ -148,27 +135,46 @@ function rule::apply() { fw::allow_port "$client_ip" "$target" "$port" "$proto" done < <(rule::get "$rule_name" "allow_ports") - [[ -n "$peer_name" ]] && peers::set_meta "$peer_name" "rule" "$rule_name" - - # DNS redirect local dns_redirect dns_redirect=$(rule::get "$rule_name" "dns_redirect") 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) if ! fw::_nat_exists -i wg0 -s "${peer_subnet}.0/24" \ -p udp --dport 53 -j DNAT \ --to-destination "$(config::dns):53" 2>/dev/null; then 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 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:-} 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() { local rule_name="${1:-}" client_ip="${2:-}" @@ -236,21 +242,31 @@ function rule::unapply() { function rule::_apply_identity_rule() { local peer_name="${1:-}" client_ip="${2:-}" + local identity_name identity_name=$(identity::get_name "$peer_name") [[ -z "$identity_name" ]] && return 0 - - local identity_rule strict - identity_rule=$(identity::rule "$identity_name") - [[ -z "$identity_rule" ]] && return 0 - + + local rules + rules=$(identity::rules "$identity_name") + [[ -z "$rules" ]] && return 0 + + local strict strict=$(identity::rule_flags "$identity_name" "strict_rule") - + if [[ "$strict" == "true" ]]; then + # Strict: flush and apply only identity rules — peer rule ignored 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 - 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 } @@ -261,35 +277,33 @@ function rule::_apply_identity_rule() { function rule::full_restore_peer() { local peer_name="${1:-}" client_ip="${2:-}" [[ -z "$peer_name" || -z "$client_ip" ]] && return 1 - + fw::flush_peer "$client_ip" - + local peer_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" } -function rule::reapply_all() { - local rule_name="${1:-}" - rule::require_exists "$rule_name" || return 1 - - local peers=() - mapfile -t peers < <(peers::with_rule "$rule_name") - [[ ${#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::_get_identity_strict() { + local peer_name="${1:-}" + local identity_name + identity_name=$(identity::get_name "$peer_name") + [[ -z "$identity_name" ]] && echo "false" && return 0 + identity::rule_flags "$identity_name" "strict_rule" } function rule::restore_all() { @@ -320,125 +334,19 @@ function rule::restore_all() { # ============================================ function rule::render_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" - - return 0 + ui::rule::flat "$1" } - + function rule::render_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" - 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" + ui::rule::entries "$1" } - + function rule::render_own_entries() { - local rule_name="${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 + ui::rule::own_entries "$1" } - + function rule::render_extends_tree() { - local rule_name="${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 + ui::rule::tree "$1" } # ============================================ diff --git a/modules/ui/identity.module.sh b/modules/ui/identity.module.sh index ae77cde..a4bab5d 100644 --- a/modules/ui/identity.module.sh +++ b/modules/ui/identity.module.sh @@ -52,4 +52,30 @@ function ui::identity::migrate_summary() { else log::ok "Created identity entries for ${created} peers (${skipped} skipped)" 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" } \ No newline at end of file diff --git a/modules/ui/peer.module.sh b/modules/ui/peer.module.sh new file mode 100644 index 0000000..2772b4f --- /dev/null +++ b/modules/ui/peer.module.sh @@ -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" +} \ No newline at end of file diff --git a/modules/ui/rule.module.sh b/modules/ui/rule.module.sh new file mode 100644 index 0000000..c385775 --- /dev/null +++ b/modules/ui/rule.module.sh @@ -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 [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 [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 +# 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 [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 +# 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 +# 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 +# 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 +} \ No newline at end of file diff --git a/modules/ui/subnet.module.sh b/modules/ui/subnet.module.sh index 987c35c..9c7d9c5 100644 --- a/modules/ui/subnet.module.sh +++ b/modules/ui/subnet.module.sh @@ -8,6 +8,33 @@ function ui::subnet::header() { 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() { local display_name="${1:-}" subnet="${2:-}" type_key="${3:-}" \ 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" } +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() { echo "" } @@ -48,4 +96,118 @@ function ui::subnet::peers_in_use() { [[ -z "$peers_csv" ]] && return 0 echo "" 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 } \ No newline at end of file diff --git a/wgctl b/wgctl index 34efdc3..bb82c82 100755 --- a/wgctl +++ b/wgctl @@ -1,5 +1,5 @@ #!/usr/bin/env bash -set -Eeuo pipefail +set -uo pipefail source "$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)/core.sh"