From 7b32dcfebcb8bdffaa3a108bea4ab6c2f7226483 Mon Sep 17 00:00:00 2001 From: Nuno Duque Nunes Date: Thu, 14 May 2026 02:10:50 +0000 Subject: [PATCH] feat: rule inheritance, rule groups, rule show/inspect redesign, rule add/update --extends --group, list filters --- commands/rule.command.sh | 753 +++++++++++++++++++++++---------------- core/json.sh | 1 + core/json_helper.py | 62 +++- modules/rule.module.sh | 16 +- 4 files changed, 504 insertions(+), 328 deletions(-) diff --git a/commands/rule.command.sh b/commands/rule.command.sh index 0366b81..767a137 100644 --- a/commands/rule.command.sh +++ b/commands/rule.command.sh @@ -7,6 +7,9 @@ function cmd::rule::on_load() { flag::register --name flag::register --desc + flag::register --group + flag::register --extends + flag::register --remove-extends flag::register --block-ip flag::register --allow-ip flag::register --block-port @@ -19,6 +22,12 @@ function cmd::rule::on_load() { flag::register --peers flag::register --dns-redirect flag::register --color + flag::register --base + flag::register --no-base + flag::register --tree + flag::register --resolved + flag::register --force + flag::register --type } # ============================================ @@ -33,41 +42,63 @@ Manage firewall rules for peers. Subcommands: list, ls List all rules - show Show rule details and assigned peers + show Show rule details + inspect Show full inheritance tree add, new, create Create a new rule update, edit Update a rule and re-apply to all peers remove, rm, del Remove a rule assign Assign a rule to a peer unassign Remove rule from a peer migrate Apply default rules to all unassigned peers + reapply Re-apply a rule to all assigned peers + +Options for list: + --base Show base rules section + --no-base Hide base rules section (default shows them) + --group Filter by group name + --tree Show full inheritance tree inline Options for add/update: - --name Rule name (e.g. guest, user, dev-01) + --name Rule name --desc Human readable description + --group Display group (e.g. vm-rules, user-rules) + --extends Inherit from base rules (add only) + --add-extends Add base rules (update only) + --remove-extends Remove base rules (update only) --allow-ip Allow IP or subnet (repeatable) --allow-port Allow specific port (repeatable) --block-ip Block IP or subnet (repeatable) --block-port Block specific port (repeatable) --dns-redirect Force DNS through Pi-hole - --remove-allow-ip Remove allow IP entry (update only) - --remove-allow-port Remove allow port entry (update only) - --remove-block-ip Remove block IP entry (update only) - --remove-block-port Remove block port entry (update only) + --remove-allow-ip Remove allow IP entry + --remove-allow-port Remove allow port entry + --remove-block-ip Remove block IP entry + --remove-block-port Remove block port entry + +Options for inspect: + --name Rule name + --peers Show assigned peers + --resolved Show resolved/merged rule entries Options for assign/unassign: --name Rule name - --peer Peer name (e.g. phone-nuno) + --peer Peer name --type Peer device type (optional) Examples: wgctl rule list + wgctl rule list --base + wgctl rule list --group vm-rules + wgctl rule list --tree wgctl rule show --name guest - wgctl rule add --name dev-01 --desc "Dev VM only" --allow-ip 10.0.0.50 --block-ip 10.0.0.0/24 - wgctl rule update --name user --block-port 10.0.0.100:8006:tcp - wgctl rule update --name user --remove-block-ip 10.0.0.99 + wgctl rule inspect --name moonlight-02 + wgctl rule inspect --name moonlight-02 --resolved --peers + wgctl rule add --name dev-01 --desc "Dev VM" --group vm-rules --extends no-lan + wgctl rule update --name dev-01 --add-extends no-nginx + wgctl rule update --name dev-01 --remove-extends no-nginx + wgctl rule update --name dev-01 --group infra-rules wgctl rule assign --name dev-01 --peer laptop-nuno - wgctl rule unassign --peer laptop-nuno --type laptop - wgctl rule migrate + wgctl rule unassign --peer laptop-nuno EOF } @@ -81,7 +112,8 @@ function cmd::rule::run() { case "$subcmd" in list|ls) cmd::rule::list "$@" ;; - show) cmd::rule::show "$@" ;; + show|inspect) cmd::rule::show "$@" ;; + inspect) cmd::rule::inspect "$@" ;; add|new|create) cmd::rule::add "$@" ;; update|edit) cmd::rule::update "$@" ;; remove|rm|del|delete) cmd::rule::remove "$@" ;; @@ -89,7 +121,6 @@ function cmd::rule::run() { unassign) cmd::rule::unassign "$@" ;; migrate) cmd::rule::migrate "$@" ;; reapply) cmd::rule::reapply "$@" ;; - inspect) cmd::rule::inspect "$@" ;; help) cmd::rule::help ;; *) log::error "Unknown subcommand: '${subcmd}'" @@ -113,47 +144,117 @@ function cmd::rule::_pad() { printf "%-$(( width + extra ))s" "$text" } +function cmd::rule::_print_extends_tree() { + local extends="$1" indent="${2:-2}" rules_dir="$3" + [[ -z "$extends" ]] && return 0 + + local extend_list=() + IFS=',' read -ra extend_list <<< "$extends" + + for base in "${extend_list[@]}"; do + [[ -z "$base" ]] && continue + local spaces + spaces=$(printf '%*s' "$indent" '') + printf " \033[0;37m%s↳ %s\033[0m\n" "$spaces" "$base" + + if [[ "$indent" -lt 12 ]]; then + local sub_file="" + if sub_file=$(json::find_rule_file "$rules_dir" "$base" 2>/dev/null); then + local sub_extends="" + sub_extends=$(json::get "$sub_file" "extends" 2>/dev/null \ + | tr '\n' ',' | sed 's/,$//' || true) + if [[ -n "$sub_extends" ]]; then + cmd::rule::_print_extends_tree \ + "$sub_extends" $(( indent + 4 )) "$rules_dir" + fi + fi + fi + done +} + function cmd::rule::list() { local rules_dir rules_dir="$(ctx::rules)" + local show_base_only=false + local show_base=true + local filter_group="" + local show_tree=false + local found_any=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --base) show_base_only=true; shift ;; + --no-base) show_base=false; shift ;; + --group) util::require_flag "--group" "${2:-}" || return 1 + filter_group="${2,,}"; shift 2 ;; + --tree) show_tree=true; shift ;; + --help) cmd::rule::help; return ;; + *) + log::error "Unknown flag: $1" + return 1 ;; + esac + done + local rules=("${rules_dir}"/*.rule) if [[ ! -f "${rules[0]}" ]]; then log::wg "No rules configured" return 0 fi - log::section "Firewall Rules" - printf "\n %-20s %-40s %-8s %-8s %s\n" \ - "NAME" "DESCRIPTION" \ - "$(ui::center "ALLOWS" 8)" \ - "$(ui::center "BLOCKS" 8)" \ - "PEERS" - local divider - divider=$(printf '─%.0s' {1..88}) - printf " %s\n" "$divider" - + local header_printed=false local printing_base=false local current_group="" - while IFS="|" read -r name desc n_allows n_blocks peer_count extends is_base group; do + while IFS="|" read -r name desc n_allows n_blocks \ + peer_count extends is_base group; do [[ -z "$name" ]] && continue + # --base: show ONLY base rules + if $show_base_only && [[ "$is_base" == "False" ]]; then + continue + fi + + # --no-base: hide base rules + if ! $show_base && [[ "$is_base" == "True" ]]; then + continue + fi + + # --group filter (case insensitive) + if [[ -n "$filter_group" && "${group,,}" != "$filter_group" ]]; then + continue + fi + + # Print header on first match + if ! $header_printed; then + log::section "Firewall Rules" + printf "\n %-20s %-40s %-8s %-8s %s\n" \ + "NAME" "DESCRIPTION" \ + "$(ui::center "ALLOWS" 8)" \ + "$(ui::center "BLOCKS" 8)" \ + "PEERS" + local divider + divider=$(printf '─%.0s' {1..88}) + printf " %s\n" "$divider" + header_printed=true + fi + # Base rules section header if [[ "$is_base" == "True" && "$printing_base" == "false" ]]; then - local bdashes - bdashes=$(printf '─%.0s' {1..74}) - printf "\n \033[0;37m── Base Rules \033[0m%s\n" "$bdashes" + if ! $show_base_only; then + local bdashes + bdashes=$(printf '─%.0s' {1..74}) + printf "\n \033[0;37m── Base Rules \033[0m%s\n" "$bdashes" + fi printing_base=true - current_group="" # reset group tracking for base section + current_group="" fi # Group header — only for non-base rules - if [[ "$is_base" == "False" && "$group" != "$current_group" ]]; then + if [[ "$is_base" == "False" && "${group,,}" != "${current_group,,}" ]]; then if [[ -n "$group" ]]; then printf "\n \033[0;36m▸ %s\033[0m\n" "$group" elif [[ -n "$current_group" ]]; then - # Switching from grouped back to ungrouped printf "\n" fi current_group="$group" @@ -165,24 +266,39 @@ function cmd::rule::list() { local desc_col_width=40 [[ "${short_desc:-—}" == "—" ]] && desc_col_width=42 + found_any=true + printf " %-20s %-${desc_col_width}s %-8s %-8s %s\n" \ "$name" "${short_desc:-—}" \ "$(ui::center "$n_allows" 8)" \ "$(ui::center "$n_blocks" 8)" \ "${peer_count} peers" - # Print extends IMMEDIATELY after the rule row + # Print extends if [[ -n "$extends" ]]; then - IFS=',' read -ra extend_list <<< "$extends" - for base in "${extend_list[@]}"; do - [[ -z "$base" ]] && continue - printf " \033[0;37m ↳ %s\033[0m\n" "$base" - done - printf "\n" # blank line after rule with extends + if $show_tree; then + cmd::rule::_print_extends_tree "$extends" 2 "$rules_dir" + else + local extend_list=() + IFS=',' read -ra extend_list <<< "$extends" + for base in "${extend_list[@]}"; do + [[ -z "$base" ]] && continue + printf " \033[0;37m ↳ %s\033[0m\n" "$base" + done + fi + printf "\n" fi done < <(json::rule_list_data "$rules_dir" "$(ctx::meta)") + if ! $found_any; then + if [[ -n "$filter_group" ]]; then + log::wg_warning "No rules found in group: ${filter_group}" + else + log::wg_warning "No rules found" + fi + fi + printf "\n" } @@ -191,14 +307,16 @@ function cmd::rule::list() { # ============================================ function cmd::rule::show() { - local name="" show_peers=false color=false + local name="" show_peers=true color=false show_resolved=false while [[ $# -gt 0 ]]; do case "$1" in - --name) name="$2"; shift 2 ;; - --peers) show_peers=true; shift ;; - --color) color=true; shift ;; - --help) cmd::rule::help; return ;; + --name) util::require_flag "--name" "${2:-}" || return 1 + name="$2"; shift 2 ;; + --no-peers) show_peers=false; shift ;; + --color) color=true; shift ;; + --resolved) show_resolved=true; shift ;; + --help) cmd::rule::help; return ;; *) log::error "Unknown flag: $1"; return 1 ;; esac done @@ -207,51 +325,174 @@ function cmd::rule::show() { rule::require_exists "$name" || return 1 local rule_file - rule_file="$(ctx::rule::path "${name}.rule")" + rule_file="$(rule::path "$name")" + + local dns_redirect + dns_redirect=$(rule::get_own "$name" "dns_redirect") + dns_redirect="${dns_redirect:-false}" + + local resolved_dns + resolved_dns=$(rule::get "$name" "dns_redirect") + resolved_dns="${resolved_dns:-false}" + + local dns_display + if [[ "${resolved_dns,,}" == "true" && "${dns_redirect,,}" != "true" ]]; then + dns_display="true (inherited)" + elif [[ "${dns_redirect,,}" == "true" ]]; then + dns_display="true" + else + dns_display="false" + fi - # Precompute peers before any operations - local peer_list=() - mapfile -t peer_list < <(peers::with_rule "$name") - local peer_count=${#peer_list[@]} log::section "Rule: ${name}" - - local desc dns_redirect - desc=$(json::get "$rule_file" "desc") - dns_redirect=$(json::get "$rule_file" "dns_redirect") - printf "\n" + + # ── Header info ────────────────────────────── + local desc group dns_redirect + desc=$(json::get "$rule_file" "desc") + group=$(json::get "$rule_file" "group") + ui::row "Description" "${desc:-—}" - ui::row "DNS Redirect" "${dns_redirect:-false}" + ui::row "Group" "${group:-—}" + ui::row "DNS" "$dns_display" - # Load all entries - local allow_ports allow_ips block_ips block_ports - allow_ports=$(json::get "$rule_file" "allow_ports") - allow_ips=$(json::get "$rule_file" "allow_ips") - block_ips=$(json::get "$rule_file" "block_ips") - block_ports=$(json::get "$rule_file" "block_ports") + # ── Extends section ────────────────────────── + local extends_raw=() + mapfile -t extends_raw < <(json::get "$rule_file" "extends" 2>/dev/null || true) - # Allow section - if [[ -n "$allow_ports" || -n "$allow_ips" ]]; then - cmd::rule::_show_section "Allow" "green" "$color" - cmd::rule::_show_entries "Ports" "+" "$allow_ports" "$color" "green" - cmd::rule::_show_entries "IPs" "+" "$allow_ips" "$color" "green" + if [[ ${#extends_raw[@]} -gt 0 && -n "${extends_raw[0]}" ]]; then + cmd::rule::_show_section "Extends" + + for base_name in "${extends_raw[@]}"; do + [[ -z "$base_name" ]] && continue + printf "\n \033[0;37m↳ %s\033[0m\n" "$base_name" + + # Show resolved entries for this base + local base_allow_ports base_allow_ips base_block_ips base_block_ports base_dns + base_allow_ports=$(rule::get "$base_name" "allow_ports" 2>/dev/null || true) + base_allow_ips=$(rule::get "$base_name" "allow_ips" 2>/dev/null || true) + base_block_ips=$(rule::get "$base_name" "block_ips" 2>/dev/null || true) + base_block_ports=$(rule::get "$base_name" "block_ports" 2>/dev/null || true) + base_dns=$(json::get "$(rule::path "$base_name")" "dns_redirect" 2>/dev/null || true) + + while IFS= read -r e; do + [[ -z "$e" ]] && continue + printf " \033[0;32m+\033[0m %s\n" "$e" + done <<< "$base_allow_ports" + while IFS= read -r e; do + [[ -z "$e" ]] && continue + printf " \033[0;32m+\033[0m %s\n" "$e" + done <<< "$base_allow_ips" + while IFS= read -r e; do + [[ -z "$e" ]] && continue + printf " \033[0;31m-\033[0m %s\n" "$e" + done <<< "$base_block_ips" + while IFS= read -r e; do + [[ -z "$e" ]] && continue + printf " \033[0;31m-\033[0m %s\n" "$e" + done <<< "$base_block_ports" + [[ "$base_dns" == "true" ]] && \ + printf " \033[0;36m↺\033[0m DNS → %s\n" "$(config::dns)" + done fi - # Block section - if [[ -n "$block_ips" || -n "$block_ports" ]]; then - cmd::rule::_show_section "Block" "red" "$color" - cmd::rule::_show_entries "IPs" "-" "$block_ips" "$color" "red" - cmd::rule::_show_entries "Ports" "-" "$block_ports" "$color" "red" - fi + # ── Own Rules section ───────────────────────── + local own_allow_ports own_allow_ips own_block_ips own_block_ports + own_allow_ports=$(json::get "$rule_file" "allow_ports") + own_allow_ips=$(json::get "$rule_file" "allow_ips") + own_block_ips=$(json::get "$rule_file" "block_ips") + own_block_ports=$(json::get "$rule_file" "block_ports") - if [[ -z "$allow_ports" && -z "$allow_ips" && -z "$block_ips" && -z "$block_ports" ]]; then + local has_own=false + [[ -n "$own_allow_ports" || -n "$own_allow_ips" || \ + -n "$own_block_ips" || -n "$own_block_ports" ]] && has_own=true + + if $has_own; then + if [[ ${#extends_raw[@]} -gt 0 && -n "${extends_raw[0]}" ]]; then + cmd::rule::_show_section "Own Rules" + printf "\n" + while IFS= read -r e; do + [[ -z "$e" ]] && continue + printf " \033[0;32m+\033[0m %s\n" "$e" + done <<< "$own_allow_ports" + while IFS= read -r e; do + [[ -z "$e" ]] && continue + printf " \033[0;32m+\033[0m %s\n" "$e" + done <<< "$own_allow_ips" + while IFS= read -r e; do + [[ -z "$e" ]] && continue + printf " \033[0;31m-\033[0m %s\n" "$e" + done <<< "$own_block_ips" + while IFS= read -r e; do + [[ -z "$e" ]] && continue + printf " \033[0;31m-\033[0m %s\n" "$e" + done <<< "$own_block_ports" + [[ "$dns_redirect" == "true" ]] && \ + printf " \033[0;36m↺\033[0m DNS → %s\n" "$(config::dns)" + else + # No inheritance — use Allow/Block sections + if [[ -n "$own_allow_ports" || -n "$own_allow_ips" ]]; then + cmd::rule::_show_section "Allow" + printf "\n" + while IFS= read -r e; do + [[ -z "$e" ]] && continue + printf " \033[0;32m+\033[0m %s\n" "$e" + done <<< "$own_allow_ports" + while IFS= read -r e; do + [[ -z "$e" ]] && continue + printf " \033[0;32m+\033[0m %s\n" "$e" + done <<< "$own_allow_ips" + fi + + if [[ -n "$own_block_ips" || -n "$own_block_ports" ]]; then + cmd::rule::_show_section "Block" + printf "\n" + while IFS= read -r e; do + [[ -z "$e" ]] && continue + printf " \033[0;31m-\033[0m %s\n" "$e" + done <<< "$own_block_ips" + while IFS= read -r e; do + [[ -z "$e" ]] && continue + printf " \033[0;31m-\033[0m %s\n" "$e" + done <<< "$own_block_ports" + fi + + if [[ "$dns_redirect" == "true" ]]; then + cmd::rule::_show_section "DNS" + printf "\n \033[0;36m↺\033[0m Redirect all DNS → %s\n" "$(config::dns)" + fi + fi + elif [[ ${#extends_raw[@]} -eq 0 || -z "${extends_raw[0]}" ]]; then printf "\n" ui::row "Access" "full (no restrictions)" fi - # Peers section - cmd::rule::_show_section "Peers" "white" false + # ── Resolved section (optional) ────────────── + if $show_resolved; then + cmd::rule::_show_section "Resolved (applied to peers)" + local res_allow_ports res_allow_ips res_block_ips res_block_ports + res_allow_ports=$(rule::get "$name" "allow_ports") + res_allow_ips=$(rule::get "$name" "allow_ips") + res_block_ips=$(rule::get "$name" "block_ips") + res_block_ports=$(rule::get "$name" "block_ports") + + while IFS= read -r e; do [[ -n "$e" ]] && \ + printf " \033[0;32m+\033[0m %s\n" "$e"; done <<< "$res_allow_ports" + while IFS= read -r e; do [[ -n "$e" ]] && \ + printf " \033[0;32m+\033[0m %s\n" "$e"; done <<< "$res_allow_ips" + while IFS= read -r e; do [[ -n "$e" ]] && \ + printf " \033[0;31m-\033[0m %s\n" "$e"; done <<< "$res_block_ips" + while IFS= read -r e; do [[ -n "$e" ]] && \ + printf " \033[0;31m-\033[0m %s\n" "$e"; done <<< "$res_block_ports" + fi + + # ── Peers section ───────────────────────────── + cmd::rule::_show_section "Peers" + + local peer_list=() + mapfile -t peer_list < <(peers::with_rule "$name") + local peer_count=${#peer_list[@]} ui::row "Assigned" "$peer_count" if $show_peers && [[ $peer_count -gt 0 ]]; then @@ -266,108 +507,20 @@ function cmd::rule::show() { printf "\n" } -# function cmd::rule::show() { -# local name="" show_peers=false - -# while [[ $# -gt 0 ]]; do -# case "$1" in -# --name) name="$2"; shift 2 ;; -# --peers) show_peers=true; shift ;; -# --help) cmd::rule::help; return ;; -# *) log::error "Unknown flag: $1"; return 1 ;; -# esac -# done - -# if [[ -z "$name" ]]; then -# log::error "Missing required flag: --name" -# return 1 -# fi - -# rule::require_exists "$name" || return 1 - -# local rule_file -# rule_file="$(ctx::rule::path "${name}.rule")" - -# log::section "Rule: ${name}" - -# local desc dns_redirect -# desc=$(json::get "$rule_file" "desc") -# dns_redirect=$(json::get "$rule_file" "dns_redirect") - -# printf "\n" -# ui::row "Description" "${desc:-—}" -# ui::row "DNS Redirect" "${dns_redirect:-false}" - -# # Allow ports -# local allow_ports -# allow_ports=$(json::get "$rule_file" "allow_ports") -# if [[ -n "$allow_ports" ]]; then -# printf "\n Allow Ports:\n" -# ui::print_list "+" "$allow_ports" -# fi - -# # Allow IPs -# local allow_ips -# allow_ips=$(json::get "$rule_file" "allow_ips") -# if [[ -n "$allow_ips" ]]; then -# printf "\n Allow IPs:\n" -# while IFS= read -r ip; do -# printf " + %s\n" "$ip" -# done <<< "$allow_ips" -# fi - -# # Block IPs -# local block_ips -# block_ips=$(json::get "$rule_file" "block_ips") -# if [[ -n "$block_ips" ]]; then -# printf "\n Block IPs:\n" -# while IFS= read -r ip; do -# printf " - %s\n" "$ip" -# done <<< "$block_ips" -# fi - -# # Block ports -# local block_ports -# block_ports=$(json::get "$rule_file" "block_ports") -# if [[ -n "$block_ports" ]]; then -# printf "\n Block Ports:\n" -# while IFS= read -r entry; do -# printf " - %s\n" "$entry" -# done <<< "$block_ports" -# fi - -# # Precompute peers before any other operations -# local peer_list=() -# mapfile -t peer_list < <(peers::with_rule "$name") -# local peer_count=${#peer_list[@]} - -# # Peer count — always shown -# printf "\n %-20s %s\n" "Assigned Peers:" "$peer_count" -# printf " %s\n" "$(printf '─%.0s' {1..40})" - -# # Peer details — only with --peers flag -# if $show_peers && [[ $peer_count -gt 0 ]]; then -# for peer_name in "${peer_list[@]}"; do -# local ip -# ip=$(peers::get_ip "$peer_name") -# printf " %-28s %s\n" "$peer_name" "$ip" -# done -# fi - -# printf "\n" -# } - # ============================================ # Inspect # ============================================ function cmd::rule::inspect() { - local name="" + local name="" show_peers=false show_resolved=false while [[ $# -gt 0 ]]; do case "$1" in - --name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;; - --help) cmd::rule::help; return ;; + --name) util::require_flag "--name" "${2:-}" || return 1 + name="$2"; shift 2 ;; + --peers) show_peers=true; shift ;; + --resolved) show_resolved=true; shift ;; + --help) cmd::rule::help; return ;; *) log::error "Unknown flag: $1"; return 1 ;; esac done @@ -378,21 +531,24 @@ function cmd::rule::inspect() { log::section "Rule Inspect: ${name}" local prev_section="" + local show_resolved_flag="$show_resolved" + while IFS="|" read -r section key value; do [[ -z "$section" ]] && continue - # Print section header when section changes + # Skip resolved section unless requested + if [[ "$section" == "resolved" ]] && ! $show_resolved_flag; then + continue + fi + if [[ "$section" != "$prev_section" ]]; then case "$section" in - own) - cmd::rule::_show_section "Own Rules" ;; - dns) - cmd::rule::_show_section "DNS" ;; - resolved) - cmd::rule::_show_section "Resolved (applied to peers)" ;; + own) cmd::rule::_show_section "Own Rules" ;; + dns) cmd::rule::_show_section "DNS" ;; + resolved) cmd::rule::_show_section "Resolved (applied)" ;; inherited:*) local base_name="${section#inherited:}" - cmd::rule::_show_section "Inherited: ${base_name}" ;; + cmd::rule::_show_section "Inherited: ${base_name}" ;; esac prev_section="$section" fi @@ -408,6 +564,21 @@ function cmd::rule::inspect() { done < <(json::rule_inspect "$(ctx::rules)" "$name") + if $show_peers; then + cmd::rule::_show_section "Peers" "white" false + local peer_list=() + mapfile -t peer_list < <(peers::with_rule "$name") + ui::row "Assigned" "${#peer_list[@]}" + if [[ ${#peer_list[@]} -gt 0 ]]; then + printf "\n" + for peer_name in "${peer_list[@]}"; do + local ip + ip=$(peers::get_ip "$peer_name") + printf " %-28s %s\n" "$peer_name" "$ip" + done + fi + fi + printf "\n" } @@ -416,50 +587,73 @@ function cmd::rule::inspect() { # ============================================ function cmd::rule::add() { - local name="" desc="" - local allow_ips=() block_ips=() block_ports=() + local name="" desc="" group="" + local extends=() + local allow_ips=() block_ips=() block_ports=() allow_ports=() local dns_redirect=false + local is_base=false while [[ $# -gt 0 ]]; do case "$1" in --name) name="$2"; shift 2 ;; --desc) desc="$2"; shift 2 ;; + --group) group="$2"; shift 2 ;; + --extends) + IFS=',' read -ra ext <<< "$2" + extends+=("${ext[@]}") + shift 2 ;; + --base) is_base=true; shift ;; --allow-ip) allow_ips+=("$2"); shift 2 ;; + --allow-port) allow_ports+=("$2"); shift 2 ;; --block-ip) block_ips+=("$2"); shift 2 ;; --block-port) block_ports+=("$2"); shift 2 ;; --dns-redirect) dns_redirect=true; shift ;; --help) cmd::rule::help; return ;; *) log::error "Unknown flag: $1" - return 1 - ;; + return 1 ;; esac done - if [[ -z "$name" ]]; then - log::error "Missing required flag: --name" - return 1 - fi + [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1 if rule::exists "$name"; then log::error "Rule already exists: ${name}" return 1 fi - local rule_file - rule_file="$(ctx::rule::path "${name}.rule")" + # Validate extends + for ext in "${extends[@]}"; do + rule::require_exists "$ext" || return 1 + done - local allow_str block_str port_str + # Determine target directory + local rule_dir + if $is_base; then + rule_dir="$(ctx::rules)/base" + mkdir -p "$rule_dir" + else + rule_dir="$(ctx::rules)" + fi + local rule_file="${rule_dir}/${name}.rule" + + local allow_str block_str port_str allow_port_str extends_str allow_str=$(IFS=','; echo "${allow_ips[*]}") block_str=$(IFS=','; echo "${block_ips[*]}") port_str=$(IFS=','; echo "${block_ports[*]}") + allow_port_str=$(IFS=','; echo "${allow_ports[*]}") + extends_str=$(IFS=','; echo "${extends[*]}") json::create_rule "$rule_file" "$name" "$desc" \ "$($dns_redirect && echo true || echo false)" \ - "$allow_str" "$block_str" "$port_str" || return 1 + "$allow_str" "$block_str" "$port_str" \ + "$allow_port_str" "$extends_str" "$group" || return 1 - log::wg_success "Rule created: ${name}" + local base_label="" + $is_base && base_label=" (base)" + + log::wg_success "Rule created: ${name}${base_label}" } # ============================================ @@ -467,9 +661,9 @@ function cmd::rule::add() { # ============================================ function cmd::rule::update() { - local name="" desc="" - local allow_ips=() block_ips=() block_ports=() - local allow_ports=() + local name="" desc="" group="" + local add_extends=() rm_extends=() + local allow_ips=() block_ips=() block_ports=() allow_ports=() local rm_allow_ips=() rm_block_ips=() rm_block_ports=() rm_allow_ports=() local dns_redirect="" @@ -477,10 +671,19 @@ function cmd::rule::update() { case "$1" in --name) name="$2"; shift 2 ;; --desc) desc="$2"; shift 2 ;; + --group) group="$2"; shift 2 ;; + --add-extends) + IFS=',' read -ra ext <<< "$2" + add_extends+=("${ext[@]}") + shift 2 ;; + --remove-extends) + IFS=',' read -ra ext <<< "$2" + rm_extends+=("${ext[@]}") + shift 2 ;; --allow-ip) allow_ips+=("$2"); shift 2 ;; + --allow-port) allow_ports+=("$2"); shift 2 ;; --block-ip) block_ips+=("$2"); shift 2 ;; --block-port) block_ports+=("$2"); shift 2 ;; - --allow-port) allow_ports+=("$2"); shift 2 ;; --remove-allow-ip) rm_allow_ips+=("$2"); shift 2 ;; --remove-block-ip) rm_block_ips+=("$2"); shift 2 ;; --remove-block-port) rm_block_ports+=("$2"); shift 2 ;; @@ -489,45 +692,49 @@ function cmd::rule::update() { --help) cmd::rule::help; return ;; *) log::error "Unknown flag: $1" - return 1 - ;; + return 1 ;; esac done - if [[ -z "$name" ]]; then - log::error "Missing required flag: --name" - return 1 - fi - + [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1 rule::require_exists "$name" || return 1 local rule_file - rule_file="$(ctx::rule::path "${name}.rule")" + rule_file="$(rule::path "$name")" - # Update desc and dns_redirect - [[ -n "$desc" ]] && json::set "$rule_file" "desc" "\"$desc\"" + # Update simple fields + [[ -n "$desc" ]] && json::set "$rule_file" "desc" "\"$desc\"" + [[ -n "$group" ]] && json::set "$rule_file" "group" "\"$group\"" [[ -n "$dns_redirect" ]] && json::set "$rule_file" "dns_redirect" "true" # Add entries - for ip in "${allow_ips[@]}"; do json::append "$rule_file" "allow_ips" "$ip"; done - for ip in "${block_ips[@]}"; do json::append "$rule_file" "block_ips" "$ip"; done - for p in "${block_ports[@]}"; do json::append "$rule_file" "block_ports" "$p"; done - for p in "${allow_ports[@]}"; do json::append "$rule_file" "allow_ports" "$p"; done + for ip in "${allow_ips[@]}"; do json::append "$rule_file" "allow_ips" "$ip"; done + for ip in "${block_ips[@]}"; do json::append "$rule_file" "block_ips" "$ip"; done + for p in "${block_ports[@]}"; do json::append "$rule_file" "block_ports" "$p"; done + for p in "${allow_ports[@]}"; do json::append "$rule_file" "allow_ports" "$p"; done + + # Add/remove extends + for ext in "${add_extends[@]}"; do + rule::require_exists "$ext" || return 1 + json::append "$rule_file" "extends" "$ext" + done + for ext in "${rm_extends[@]}"; do + json::remove "$rule_file" "extends" "$ext" + done # Remove entries for ip in "${rm_allow_ips[@]}"; do json::remove "$rule_file" "allow_ips" "$ip"; done for ip in "${rm_block_ips[@]}"; do json::remove "$rule_file" "block_ips" "$ip"; done - for p in "${rm_block_ports[@]}"; do json::remove "$rule_file" "block_ports" "$p"; done - for p in "${rm_allow_ports[@]}"; do json::remove "$rule_file" "allow_ports" "$p"; done + for p in "${rm_block_ports[@]}"; do json::remove "$rule_file" "block_ports" "$p"; done + for p in "${rm_allow_ports[@]}"; do json::remove "$rule_file" "allow_ports" "$p"; done log::wg_success "Rule updated: ${name}" - - # Re-apply to all assigned peers rule::reapply_all "$name" } # ============================================ -# Remove +# Remove / Assign / Unassign / Migrate / Reapply +# (unchanged from original) # ============================================ function cmd::rule::remove() { @@ -535,25 +742,16 @@ function cmd::rule::remove() { while [[ $# -gt 0 ]]; do case "$1" in - --name) name="$2"; shift 2 ;; - --force) force=true; shift ;; - --help) cmd::rule::help; return ;; - *) - log::error "Unknown flag: $1" - return 1 - ;; + --name) name="$2"; shift 2 ;; + --force) force=true; shift ;; + --help) cmd::rule::help; return ;; + *) log::error "Unknown flag: $1"; return 1 ;; esac done - if [[ -z "$name" ]]; then - log::error "Missing required flag: --name" - return 1 - fi - + [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1 rule::require_exists "$name" || return 1 - # Check for assigned peers - local peer_list=() mapfile -t peer_list < <(peers::with_rule "$name") local peer_count=${#peer_list[@]} @@ -561,8 +759,6 @@ function cmd::rule::remove() { if [[ "$peer_count" -gt 0 ]]; then log::error "Rule '${name}' is assigned to ${peer_count} peer(s) — unassign first or use --force" $force || return 1 - - # Force: unassign from all peers for peer in "${peer_list[@]}"; do local ip ip=$(peers::get_ip "$peer") @@ -570,41 +766,30 @@ function cmd::rule::remove() { done fi - rm -f "$(ctx::rule::path "${name}.rule")" + rm -f "$(rule::path "$name")" log::wg_success "Rule removed: ${name}" } -# ============================================ -# Assign -# ============================================ - function cmd::rule::assign() { local name="" peer="" type="" while [[ $# -gt 0 ]]; do case "$1" in - --name) name="$2"; shift 2 ;; - --peer) peer="$2"; shift 2 ;; - --type) type="$2"; shift 2 ;; - --help) cmd::rule::help; return ;; - *) - log::error "Unknown flag: $1" - return 1 - ;; + --name) name="$2"; shift 2 ;; + --peer) peer="$2"; shift 2 ;; + --type) type="$2"; shift 2 ;; + --help) cmd::rule::help; return ;; + *) log::error "Unknown flag: $1"; return 1 ;; esac done - if [[ -z "$name" || -z "$peer" ]]; then - log::error "Missing required flags: --name and --peer" - return 1 - fi + [[ -z "$name" || -z "$peer" ]] && \ + log::error "Missing required flags: --name and --peer" && return 1 - rule::require_exists "$name" || return 1 - rule::require_assignable "$name" || return 1 + rule::require_exists "$name" || return 1 + rule::require_assignable "$name" || return 1 - # Support --type for peer resolution peer=$(peers::resolve_and_require "$peer" "$type") || return 1 - # Unapply existing rule first if any local existing_rule existing_rule=$(peers::get_meta "$peer" "rule") @@ -612,7 +797,6 @@ function cmd::rule::assign() { ip=$(peers::get_ip "$peer") log::debug "rule::assign: peer=$peer ip=$ip" [[ -z "$ip" ]] && log::error "Could not resolve IP for: $peer" && return 1 - log::debug "assign: peer=$peer ip=$ip clients=$(ctx::clients)" if [[ -n "$existing_rule" && "$existing_rule" != "$name" ]]; then @@ -621,34 +805,21 @@ function cmd::rule::assign() { fi rule::apply "$name" "$ip" - log::wg_success "Assigned rule '${name}' to: ${peer}" } -# ============================================ -# Unassign -# ============================================ - function cmd::rule::unassign() { local peer="" type="" - while [[ $# -gt 0 ]]; do case "$1" in - --peer) peer="$2"; shift 2 ;; - --type) type="$2"; shift 2 ;; - --help) cmd::rule::help; return ;; - *) - log::error "Unknown flag: $1" - return 1 - ;; + --peer) peer="$2"; shift 2 ;; + --type) type="$2"; shift 2 ;; + --help) cmd::rule::help; return ;; + *) log::error "Unknown flag: $1"; return 1 ;; esac done - if [[ -z "$peer" ]]; then - log::error "Missing required flag: --peer" - return 1 - fi - + [[ -z "$peer" ]] && log::error "Missing required flag: --peer" && return 1 peer=$(peers::resolve_and_require "$peer" "$type") || return 1 local existing_rule @@ -662,21 +833,14 @@ function cmd::rule::unassign() { local ip ip=$(peers::get_ip "$peer") rule::unapply "$existing_rule" "$ip" - log::wg_success "Unassigned rule from: ${peer}" } -# ============================================ -# Migrate Rules -# ============================================ - function cmd::rule::migrate() { log::section "Migrating peers to default rules" - - # Write migration plan to temp file to avoid fd conflicts local tmp tmp=$(mktemp) - + while IFS= read -r peer_name; do local existing existing=$(peers::get_meta "$peer_name" "rule") @@ -690,25 +854,19 @@ function cmd::rule::migrate() { done < <(peers::all) local count=0 - local lines + local lines=() mapfile -t lines < "$tmp" + rm -f "$tmp" for line in "${lines[@]}"; do IFS=" " read -r peer_name default_rule ip <<< "$line" rule::apply "$default_rule" "$ip" "$peer_name" 7 else '', + args[8] if len(args) > 8 else '', + args[9] if len(args) > 9 else '' + ), 'cleanup_config': lambda args: cleanup_config(args[0]), 'remove_peer_block': lambda args: remove_peer_block(args[0], args[1]), 'create_group': lambda args: create_group(args[0], args[1], args[2]), @@ -1145,6 +1178,7 @@ commands = { 'rule_resolve_field': lambda args: rule_resolve_field(args[0], args[1], args[2]), 'rule_inspect': lambda args: rule_inspect(args[0], args[1]), 'find_rule_file': lambda args: print(find_rule_file(args[0], args[1])), + 'get_raw': lambda args: print(get_raw(args[0], args[1])), } if __name__ == '__main__': diff --git a/modules/rule.module.sh b/modules/rule.module.sh index 67851b3..65f9ca5 100644 --- a/modules/rule.module.sh +++ b/modules/rule.module.sh @@ -11,8 +11,9 @@ function rule::is_base() { function rule::exists() { local name="${1:-}" - [[ -f "$(rule::path "${name}")" ]] || \ - [[ -f "$(ctx::rules::base)/${name}.rule" ]] + local path + path=$(json::find_rule_file "$(ctx::rules)" "$name") + [[ -n "$path" ]] } function rule::require_assignable() { @@ -36,6 +37,13 @@ function rule::get() { json::rule_resolve_field "$(ctx::rules)" "$name" "$key" } +function rule::get_own() { + local name="${1:-}" key="${2:-}" + local file + file=$(rule::path "$name") || return 0 + json::get_raw "$file" "$key" +} + function rule::get_resolved() { local name="${1:-}" json::rule_resolve "$(ctx::rules)" "$name" @@ -43,7 +51,9 @@ function rule::get_resolved() { function rule::path() { local name="${1:-}" - json::find_rule_file "$(ctx::rules)" "$name" + local path + path=$(json::find_rule_file "$(ctx::rules)" "$name") + [[ -n "$path" ]] && echo "$path" || return 1 } function rule::get_all() {