feat: block/unblock migration, help.sh optimization

- commands/block/: block.sh, show.sh, helpers.sh
- commands/unblock/: unblock.sh, show.sh, helpers.sh
- flag::define array type: --ip[], --subnet[], --port[], --service[]
- help.sh: use pre-cached _FLAG_C_* arrays instead of flag::_parse_constraints
- remove flag::_parse_constraints/flag::_constraint_get calls from help.sh
- adopt local var; var=value pattern for safe assignment
This commit is contained in:
Nuno Duque Nunes 2026-05-30 12:31:41 +00:00
parent de22dbeec7
commit f61bc59446
14 changed files with 1495 additions and 282 deletions

View file

@ -0,0 +1,9 @@
#!/usr/bin/env bash
# commands/activity/activity.sh — router only
function cmd::activity::on_load() {
command::helpers "helpers.sh"
command::define show "Activity monitor — accepted and dropped traffic" [*]
}
hook::on "command:help:activity" command::help::auto

View file

@ -0,0 +1,323 @@
#!/usr/bin/env bash
# commands/activity/helpers.sh
# Implementation logic — render functions, data fetching, maps
# cmd::activity::_impl <filter_peer> <filter_service> <filter_ip> <filter_type>
# <hours> <accept_only> <drop_only> <external_only>
# <show_ports> <exclude_str>
function cmd::activity::_impl() {
local filter_peer="$1" filter_service="$2" filter_ip="$3" filter_type="$4"
local hours="$5"
local accept_only="$6" drop_only="$7" external_only="$8"
local show_ports="$9" exclude_str="${10}"
if [[ -n "$filter_peer" && -n "$filter_type" ]]; then
filter_peer=$(peers::resolve_and_require "$filter_peer" "$filter_type") || return 1
fi
local service_ip=""
if [[ -n "$filter_service" ]]; then
service_ip=$(net::resolve "$filter_service" 2>/dev/null | head -1 | cut -d: -f1) || true
[[ -z "$service_ip" ]] && log::error "Service not found: ${filter_service}" && return 1
fi
[[ -n "$filter_ip" ]] && service_ip="$filter_ip"
# ── Fetch data ──
local data=""
if [[ "$accept_only" != "true" ]]; then
data=$(json::activity_aggregate \
"$(ctx::fw_events_log)" "$(ctx::events_log)" \
"$(config::interface)" "$(ctx::net)" \
"$(ctx::clients)" "$(ctx::meta)" \
"$hours" "$filter_peer" "$service_ip" "$exclude_str" 2>/dev/null)
fi
local accept_data=""
if [[ "$drop_only" != "true" ]]; then
local since_arg="" ext_flag="0"
[[ "$hours" -gt 0 ]] && since_arg="${hours}h"
[[ "$external_only" == "true" ]] && ext_flag="1"
[[ -f "$(ctx::accept_events_log)" ]] && \
accept_data=$(json::accept_aggregate \
"$(ctx::accept_events_log)" "$(ctx::net)" "$(ctx::clients)" \
"$since_arg" "$filter_peer" "$ext_flag" "$exclude_str" 2>/dev/null)
fi
[[ -z "$data" && -z "$accept_data" ]] && \
log::wg_warning "No activity data found" && return 0
# ── Build accept maps ──
declare -gA _ACCEPT_PEER=()
declare -gA _ACCEPT_DEST_KEYS=()
declare -gA _ACCEPT_DEST=()
while IFS='|' read -r type rest; do
[[ -z "$type" ]] && continue
case "$type" in
peer)
local a_name a_bi a_bo a_pi a_po a_conns
IFS='|' read -r a_name a_bi a_bo a_pi a_po a_conns <<< "$rest"
_ACCEPT_PEER["$a_name"]="${a_bi}|${a_bo}|${a_pi}|${a_po}|${a_conns}"
;;
dest)
local d_peer d_ip d_port d_proto d_bytes_orig d_bytes_reply d_count
IFS='|' read -r d_peer d_ip d_port d_proto d_bytes_orig d_bytes_reply d_count <<< "$rest"
local d_key="${d_peer}:${d_ip}:${d_port}:${d_proto}"
_ACCEPT_DEST["$d_key"]="${d_bytes_orig}|${d_bytes_reply}|${d_count}"
_ACCEPT_DEST_KEYS["$d_peer"]+="${d_key} "
;;
esac
done <<< "$accept_data"
# ── Measure column widths ──
local w_peer=16 w_count=1
while IFS='|' read -r type rest; do
case "$type" in
peer)
local name drops
name=$(echo "$rest" | cut -d'|' -f1)
drops=$(echo "$rest" | cut -d'|' -f4)
(( ${#name} > w_peer )) && w_peer=${#name}
(( ${#drops} > w_count )) && w_count=${#drops}
;;
service)
local svc_count
svc_count=$(echo "$rest" | cut -d'|' -f3)
(( ${#svc_count} > w_count )) && w_count=${#svc_count}
;;
esac
done <<< "$data"
for a_name in "${!_ACCEPT_PEER[@]}"; do
(( ${#a_name} > w_peer )) && w_peer=${#a_name}
local a_conns_val="${_ACCEPT_PEER[$a_name]##*|}"
(( ${#a_conns_val} > w_count )) && w_count=${#a_conns_val}
done
for key in "${!_ACCEPT_DEST[@]}"; do
local d_val="${_ACCEPT_DEST[$key]}"
local d_count_val="${d_val##*|}"
(( ${#d_count_val} > w_count )) && w_count=${#d_count_val}
done
(( w_peer += 2 ))
local drops_col=$(( w_peer + 30 ))
local hours_display="${hours}h"
[[ "$hours" == "0" ]] && hours_display="all time"
log::section "Activity Monitor (last ${hours_display})"
echo ""
if display::is_table "activity"; then
cmd::activity::_render_table "$data"
return 0
fi
# ── Batch resolve accept dest IPs ──
declare -gA _DEST_RESOLVE_CACHE=()
local -a _dest_specs=()
for _dk in "${!_ACCEPT_DEST[@]}"; do
local _rest="${_dk#*:}"
local _dip="${_rest%%:*}"
local _pp="${_rest#*:}"
local _dport="${_pp%%:*}"
local _dproto="${_pp##*:}"
local _spec="${_dip}:${_dport}:${_dproto}"
local _found=false
for _s in "${_dest_specs[@]:-}"; do
[[ "$_s" == "$_spec" ]] && _found=true && break
done
$_found || _dest_specs+=("$_spec")
done
if [[ ${#_dest_specs[@]} -gt 0 ]]; then
while IFS='|' read -r _spec _display; do
[[ -n "$_spec" ]] && _DEST_RESOLVE_CACHE["$_spec"]="$_display"
done < <(json::batch_resolve_dest "${_dest_specs[@]}" 2>/dev/null)
fi
# ── Accept dest inline renderer ──
_render_peer_accept_dests() {
local peer_name="$1"
local keys="${_ACCEPT_DEST_KEYS[$peer_name]:-}"
[[ -z "$keys" ]] && return 0
for d_key in $keys; do
local dest_stats="${_ACCEPT_DEST[$d_key]:-}"
[[ -z "$dest_stats" ]] && continue
local d_bytes_orig d_bytes_reply d_count
IFS='|' read -r d_bytes_orig d_bytes_reply d_count <<< "$dest_stats"
local rest_key="${d_key#${peer_name}:}"
local d_ip="${rest_key%%:*}"
local pp="${rest_key#*:}"
local d_port="${pp%%:*}"
local d_proto="${pp##*:}"
local spec="${d_ip}:${d_port}:${d_proto}"
local resolved="${_DEST_RESOLVE_CACHE[$spec]:-${d_ip}:${d_port}/${d_proto}}"
local dest_display="$resolved"
if [[ "$show_ports" == "true" && "$resolved" != "${d_ip}:"* && "$resolved" != "${d_ip} "* ]]; then
if [[ -n "$d_port" && "$d_port" != "0" ]]; then
dest_display=$(printf "%s \033[2m(%s:%s)\033[0m" "$resolved" "$d_ip" "$d_port")
else
dest_display=$(printf "%s \033[2m(%s)\033[0m" "$resolved" "$d_ip")
fi
fi
ui::activity::accept_dest_row \
"$dest_display" "$d_bytes_orig" "$d_bytes_reply" \
"$d_count" "$drops_col" "$w_count"
done
}
local first_peer=true skip_peer=false current_name=""
local -a rendered_peers=()
# ── Main render loop ──
while IFS='|' read -r record_type rest; do
case "$record_type" in
peer)
local name rx tx drops
IFS='|' read -r name rx tx drops <<< "$rest"
[[ -n "$current_name" ]] && [[ "$drop_only" != "true" ]] && \
_render_peer_accept_dests "$current_name"
skip_peer=false
current_name="$name"
local has_accept="${_ACCEPT_PEER[$name]:-}"
$first_peer || echo ""
first_peer=false
rendered_peers+=("$name")
local rx_fmt tx_fmt
rx_fmt=$(fmt::bytes "$rx")
tx_fmt=$(fmt::bytes "$tx")
local name_pad rx_pad tx_pad
name_pad=$(printf "%-${w_peer}s" "$name")
rx_pad=$(printf "%-10s" "$rx_fmt")
tx_pad=$(printf "%-10s" "$tx_fmt")
local drop_word="drops"
[[ "$drops" -eq 1 ]] && drop_word="drop"
if [[ "$accept_only" == "true" ]]; then
printf " \033[1m%s\033[0m\n" "$name_pad"
else
ui::activity::peer_row \
"$name_pad" "$rx_pad" "$tx_pad" "$drops" "$drop_word" "$w_count"
fi
if [[ -n "$has_accept" && "$drop_only" != "true" ]]; then
local a_bi a_bo a_pi a_po a_conns
IFS='|' read -r a_bi a_bo a_pi a_po a_conns <<< "$has_accept"
ui::activity::accept_row \
"$name_pad" \
"$(printf '%-10s' "$(fmt::bytes "$a_bi")")" \
"$(printf '%-10s' "$(fmt::bytes "$a_bo")")" \
"$a_conns" "$w_count"
fi
;;
service)
$skip_peer && continue
[[ "$accept_only" == "true" ]] && continue
local peer dest_display dst_ip dst_port proto drop_count
IFS='|' read -r peer dest_display dst_ip dst_port proto drop_count <<< "$rest"
local svc_display="$dest_display"
if [[ "$show_ports" == "true" && -n "$dst_ip" ]]; then
if [[ -n "$dst_port" ]]; then
svc_display=$(printf "%s \033[2m(%s:%s)\033[0m" \
"$dest_display" "$dst_ip" "$dst_port")
else
svc_display=$(printf "%s \033[2m(%s)\033[0m" \
"$dest_display" "$dst_ip")
fi
fi
local svc_drop_word="drops"
[[ "$drop_count" -eq 1 ]] && svc_drop_word="drop"
ui::activity::service_row \
"$svc_display" "$drop_count" "$svc_drop_word" "$drops_col" "$w_count"
;;
esac
done <<< "$data"
[[ -n "$current_name" ]] && [[ "$drop_only" != "true" ]] && \
_render_peer_accept_dests "$current_name"
# ── Accept-only peers ──
if [[ "$drop_only" != "true" ]]; then
for a_name in $(echo "${!_ACCEPT_PEER[@]}" | tr ' ' '\n' | sort); do
local already=false
for rp in "${rendered_peers[@]:-}"; do
[[ "$rp" == "$a_name" ]] && already=true && break
done
$already && continue
$first_peer || echo ""
first_peer=false
local a_stats="${_ACCEPT_PEER[$a_name]}"
local a_bi a_bo a_pi a_po a_conns
IFS='|' read -r a_bi a_bo a_pi a_po a_conns <<< "$a_stats"
local name_pad
name_pad=$(printf "%-${w_peer}s" "$a_name")
printf " \033[1m%s\033[0m\n" "$name_pad"
ui::activity::accept_row \
"$name_pad" \
"$(printf '%-10s' "$(fmt::bytes "$a_bi")")" \
"$(printf '%-10s' "$(fmt::bytes "$a_bo")")" \
"$a_conns" "$w_count"
_render_peer_accept_dests "$a_name"
done
fi
echo ""
}
function cmd::activity::_render_table() {
local data="${1:-}"
[[ -z "$data" ]] && return 0
ui::activity::header_table
local skip_peer=false
while IFS='|' read -r record_type rest; do
case "$record_type" in
peer)
local name rx tx drops
IFS='|' read -r name rx tx drops <<< "$rest"
skip_peer=false
local rx_fmt tx_fmt
rx_fmt=$(fmt::bytes "$rx")
tx_fmt=$(fmt::bytes "$tx")
local drop_word="drops"
[[ "$drops" -eq 1 ]] && drop_word="drop"
ui::activity::peer_row_table "$name" "$rx_fmt" "$tx_fmt" "$drops" "$drop_word"
;;
service)
$skip_peer && continue
local peer dest_display drop_count
IFS='|' read -r peer dest_display drop_count <<< "$rest"
ui::activity::service_row_table "$dest_display" "$drop_count" "drops"
;;
esac
done <<< "$data"
}
function cmd::activity::_output_json() {
local hours="${1:-24}"
local data
data=$(json::activity_aggregate \
"$(ctx::fw_events_log)" "$(ctx::events_log)" \
"$(config::interface)" "$(ctx::net)" \
"$(ctx::clients)" "$(ctx::meta)" \
"$hours" "" "" "" 2>/dev/null)
local -a peers=()
while IFS='|' read -r record_type rest; do
[[ "$record_type" != "peer" ]] && continue
local name rx tx drops
IFS='|' read -r name rx tx drops <<< "$rest"
peers+=("{\"name\":\"${name}\",\"rx\":${rx},\"tx\":${tx},\"drops\":${drops}}")
done <<< "$data"
local array
array=$(IFS=','; echo "${peers[*]:-}")
printf '[%s]' "$array" | json::envelope "activity" "${#peers[@]}"
}

71
commands/activity/show.sh Normal file
View file

@ -0,0 +1,71 @@
#!/usr/bin/env bash
# commands/activity/show.sh
function cmd::activity::show::on_load() {
command::mixin json_output [section="Output"]
help::section "Filters"
flag::define --peer value "Filter by peer name" [label="name", section="Filters"]
flag::define --type value "Filter by device type" [label="type", section="Filters"]
flag::define --service value "Filter by service" [label="service", section="Filters"]
flag::define --ip value "Filter by destination IP" [label="ip", section="Filters"]
flag::define --hours value "Hours to look back" [default=24, type=int, min=0, section="Filters"]
flag::define --exclude-service[] "Exclude service from output" [label="service", section="Filters"]
flag::define --include-service[] "Override excluded service" [label="service", section="Filters"]
help::section "Display"
flag::define --accept bool "Show only accepted traffic" [section="Display"]
flag::define --drop bool "Show only firewall drops" [section="Display"]
flag::define --external bool "Show only external traffic" [section="Display"]
flag::define --ports bool "Show raw IP:port annotations" [section="Display"]
flag::exclusive --accept --drop
}
function cmd::activity::show::run() {
flag::parse "$@" || return 1
local filter_peer; filter_peer=$(flag::value --peer)
local filter_service; filter_service=$(flag::value --service)
local filter_ip; filter_ip=$(flag::value --ip)
local filter_type; filter_type=$(flag::value --type)
local hours; hours=$(flag::value --hours)
local accept_only=false drop_only=false external_only=false show_ports=false
flag::bool --accept && accept_only=true
flag::bool --drop && drop_only=true
flag::bool --external && external_only=true
flag::bool --ports && show_ports=true
# Build exclusion list — remove any --include-service entries
local -a exclude_services=() include_services=()
while IFS= read -r svc; do
[[ -n "$svc" ]] && exclude_services+=("$svc")
done < <(flag::array --exclude-service)
while IFS= read -r svc; do
[[ -n "$svc" ]] && include_services+=("$svc")
done < <(flag::array --include-service)
local -a final_excludes=()
for svc in "${exclude_services[@]:-}"; do
local included=false
for inc in "${include_services[@]:-}"; do
[[ "$svc" == "$inc" ]] && included=true && break
done
$included || final_excludes+=("$svc")
done
local exclude_str=""
[[ ${#final_excludes[@]} -gt 0 ]] && \
exclude_str=$(IFS=' '; echo "${final_excludes[*]}")
if command::json; then
cmd::activity::_output_json "$hours"
return 0
fi
cmd::activity::_impl \
"$filter_peer" "$filter_service" "$filter_ip" "$filter_type" \
"$hours" "$accept_only" "$drop_only" "$external_only" \
"$show_ports" "$exclude_str"
}

9
commands/block/block.sh Normal file
View file

@ -0,0 +1,9 @@
#!/usr/bin/env bash
# commands/block/block.sh — router only
function cmd::block::on_load() {
command::helpers "helpers.sh"
command::define show "Block a peer or service" [*]
}
hook::on "command:help:block" command::help::auto

220
commands/block/helpers.sh Normal file
View file

@ -0,0 +1,220 @@
#!/usr/bin/env bash
# commands/block/helpers.sh
# cmd::block::_impl <name> <identity> <type> <block_name> <reason>
# <quiet> <force>
# <ips_nameref> <subnets_nameref> <ports_nameref> <services_nameref>
function cmd::block::_impl() {
local name="$1" identity="$2" type="$3" block_name="$4" reason="$5"
local quiet="$6" force="$7"
local -n _ips_ref="$8"
local -n _subnets_ref="$9"
local -n _ports_ref="${10}"
local -n _services_ref="${11}"
# --identity: block all peers for this identity
if [[ -n "$identity" ]]; then
cmd::block::_block_identity "$identity" "$quiet" \
"${_ips_ref[@]+"${_ips_ref[@]}"}" || return 1
return 0
fi
[[ -z "$name" ]] && {
log::error "Missing required flag: --name or --identity"
return 1
}
name=$(peers::resolve_and_require "$name" "$type") || return 1
local client_ip
client_ip=$(peers::get_ip "$name") || return 1
# Full block if no specific targets
if [[ ${#_ips_ref[@]} -eq 0 && ${#_ports_ref[@]} -eq 0 && \
${#_subnets_ref[@]} -eq 0 && ${#_services_ref[@]} -eq 0 ]]; then
if peers::is_blocked "$name"; then
log::wg_warning "Client is already blocked: ${name}"
return 0
fi
monitor::update_endpoint_cache
cmd::block::_block_all "$name" "$client_ip" "$quiet"
cmd::block::_record_history "$name" "full" "manual" "$reason"
return 0
fi
# Specific rules
if block::has_file "$name"; then
local direct
direct=$(block::is_blocked_direct "$name")
if [[ "$direct" == "true" ]]; then
log::wg_warning "${name} is fully blocked — unblock first to add specific rules"
return 1
fi
fi
local changed=false
for ip in "${_ips_ref[@]:-}"; do
ip::require_valid "$ip"
fw::block_ip "$client_ip" "$ip"
block::add_rule "$name" "$client_ip" "ip" "${block_name:-}" "$ip"
$quiet || log::wg_success "${ip} has been blocked for ${name}"
done
for subnet in "${_subnets_ref[@]:-}"; do
ip::require_valid "$subnet"
fw::block_subnet "$client_ip" "$subnet"
block::add_rule "$name" "$client_ip" "subnet" "${block_name:-}" "$subnet"
$quiet || log::wg_success "${subnet} has been blocked for ${name}"
done
for entry in "${_ports_ref[@]:-}"; do
local b_target b_port b_proto
IFS=":" read -r b_target b_port b_proto <<< "$entry"
ip::require_valid "$b_target"
fw::block_port "$client_ip" "$b_target" "$b_port" "${b_proto:-tcp}"
block::add_rule "$name" "$client_ip" "port" "${block_name:-}" \
"$b_target" "$b_port" "${b_proto:-tcp}"
$quiet || log::wg_success "${b_target}:${b_port}:${b_proto:-tcp} has been blocked for ${name}"
done
for svc in "${_services_ref[@]:-}"; do
local resolved_lines=()
mapfile -t resolved_lines < <(net::resolve "$svc" 2>/dev/null)
if [[ ${#resolved_lines[@]} -eq 0 ]]; then
log::error "Service not found or has no ports: ${svc}"
return 1
fi
local already_blocked=true
for resolved in "${resolved_lines[@]}"; do
if [[ "$resolved" == *:*:* ]]; then
local b_ip b_port b_proto
IFS=":" read -r b_ip b_port b_proto <<< "$resolved"
fw::has_block_rule "$client_ip" "$b_ip" "$b_port" "$b_proto" 2>/dev/null || \
{ already_blocked=false; break; }
else
fw::has_block_rule "$client_ip" "$resolved" 2>/dev/null || \
{ already_blocked=false; break; }
fi
done
if $already_blocked; then
$quiet || log::wg_warning "${svc} is already blocked for ${name}"
continue
fi
for resolved in "${resolved_lines[@]}"; do
if [[ "$resolved" == *:*:* ]]; then
local b_ip b_port b_proto
IFS=":" read -r b_ip b_port b_proto <<< "$resolved"
fw::block_port "$client_ip" "$b_ip" "$b_port" "$b_proto"
block::add_rule "$name" "$client_ip" "port" "$svc" \
"$b_ip" "$b_port" "$b_proto"
else
fw::block_ip "$client_ip" "$resolved"
block::add_rule "$name" "$client_ip" "ip" "$svc" "$resolved"
fi
done
changed=true
$quiet || log::wg_success "${svc} has been blocked for ${name}"
done
[[ ${#_ips_ref[@]} -gt 0 || ${#_ports_ref[@]} -gt 0 || \
${#_subnets_ref[@]} -gt 0 ]] && changed=true
if $changed; then
local peer_rule
peer_rule=$(peers::get_meta "$name" "rule")
if [[ -n "$peer_rule" ]] && rule::exists "$peer_rule"; then
fw::flush_peer "$client_ip"
rule::apply "$peer_rule" "$client_ip" "$name"
block::restore_rules_for "$name" "$client_ip"
fi
fi
local btype="specific"
[[ ${#_services_ref[@]} -gt 0 ]] && btype="${_services_ref[0]}"
[[ ${#_ips_ref[@]} -gt 0 ]] && btype="ip"
[[ ${#_subnets_ref[@]} -gt 0 ]] && btype="subnet"
[[ ${#_ports_ref[@]} -gt 0 ]] && btype="port"
cmd::block::_record_history "$name" "$btype" "manual" "$reason"
return 0
}
# ── Helpers ───────────────────────────────────────────────────────────────────
function cmd::block::_block_identity() {
local identity_name="${1:-}" quiet="${2:-false}"
shift 2 || true
identity::require_exists "$identity_name" || return 1
identity::require_has_peers "$identity_name" || return 1
local peers blocked=0 failed=0
peers=$(identity::peers "$identity_name")
while IFS= read -r peer_name; do
[[ -z "$peer_name" ]] && continue
if peers::is_blocked "$peer_name"; then
$quiet || log::wg_warning "${peer_name} is already blocked"
continue
fi
local client_ip
client_ip=$(peers::get_ip "$peer_name") || continue
monitor::update_endpoint_cache
if cmd::block::_block_all "$peer_name" "$client_ip" true; then
blocked=$(( blocked + 1 ))
else
failed=$(( failed + 1 ))
fi
done <<< "$peers"
log::ok "Blocked ${blocked} peer(s) for identity '${identity_name}'"
[[ $failed -gt 0 ]] && log::warn "${failed} peer(s) failed to block"
return 0
}
function cmd::block::_get_endpoint() {
local name="$1" public_key="$2"
local endpoint
endpoint=$(monitor::endpoint_for_key "$public_key")
if [[ -z "$endpoint" || "$endpoint" == "(none)" ]]; then
endpoint=$(monitor::get_cached_endpoint "$name")
fi
echo "$endpoint"
}
function cmd::block::_block_all() {
local name="${1:?name required}"
local client_ip="${2:?client_ip required}"
local quiet="${3:-false}"
block::apply_full "$name" "$client_ip"
block::set_direct "$name" "$client_ip" "true"
$quiet || log::wg_success "${name} has been blocked."
}
function cmd::block::_record_history() {
local name="${1:-}" block_type="${2:-full}" \
triggered_by="${3:-manual}" reason="${4:-}"
local endpoint
endpoint=$(json::peer_history_lookup "$name" 2>/dev/null || true)
# endpoint_cache lookup
local ep_cache
ep_cache=$(json::endpoint_cache_get "$(ctx::endpoint_cache)" "$name" 2>/dev/null || true)
json::block_history_record \
"$(ctx::block_history)" \
"$name" \
"$block_type" \
"$triggered_by" \
"$reason" \
"${ep_cache:-}" \
2>/dev/null > /dev/null || true
}

54
commands/block/show.sh Normal file
View file

@ -0,0 +1,54 @@
#!/usr/bin/env bash
# commands/block/show.sh
function cmd::block::show::on_load() {
help::section "Target"
flag::define --name value "Peer name to block" [label="name", section="Target"]
flag::define --identity value "Block all peers in identity" [label="identity", section="Target"]
flag::define --type value "Filter by device type" [label="type", section="Target"]
help::section "Rules"
flag::define --ip[] "Block specific IP" [label="ip", section="Rules"]
flag::define --subnet[] "Block specific subnet" [label="subnet", section="Rules"]
flag::define --port[] "Block specific port (ip:port:proto)" [label="port",section="Rules"]
flag::define --service[] "Block by service name" [label="service", section="Rules"]
flag::define --block-name value "Label for this block rule" [label="name", section="Rules"]
help::section "Options"
flag::define --reason value "Reason for block (recorded in history)" [label="reason", section="Options"]
flag::define --force bool "Skip confirmation" [section="Options"]
flag::define --quiet bool "Suppress output" [section="Options"]
flag::exclusive --name --identity
}
function cmd::block::show::run() {
flag::parse "$@" || return 1
local name; name=$(flag::value --name)
local identity; identity=$(flag::value --identity)
local type; type=$(flag::value --type)
local block_name; block_name=$(flag::value --block-name)
local reason; reason=$(flag::value --reason)
local quiet=false force=false
flag::bool --quiet && quiet=true
flag::bool --force && force=true
# Array flags
local -a ips=() subnets=() ports=() services=()
while IFS= read -r v; do [[ -n "$v" ]] && ips+=("$v"); done < <(flag::array --ip)
while IFS= read -r v; do [[ -n "$v" ]] && subnets+=("$v"); done < <(flag::array --subnet)
while IFS= read -r v; do [[ -n "$v" ]] && ports+=("$v"); done < <(flag::array --port)
while IFS= read -r v; do [[ -n "$v" ]] && services+=("$v"); done < <(flag::array --service)
# Require --name or --identity
if [[ -z "$name" && -z "$identity" ]]; then
log::error "Missing required flag: --name or --identity"
return 1
fi
cmd::block::_impl \
"$name" "$identity" "$type" "$block_name" "$reason" \
"$quiet" "$force" \
ips subnets ports services
}

180
commands/unblock/helpers.sh Normal file
View file

@ -0,0 +1,180 @@
#!/usr/bin/env bash
# commands/unblock/helpers.sh
function cmd::unblock::_impl() {
local name="$1" identity="$2" type="$3" reason="$4"
local all="$5" quiet="$6" force="$7"
local -n _ips_ref="$8"
local -n _subnets_ref="$9"
local -n _ports_ref="${10}"
local -n _services_ref="${11}"
if [[ -n "$identity" ]]; then
cmd::unblock::_unblock_identity "$identity" "$quiet" || return 1
return 0
fi
[[ -z "$name" ]] && {
log::error "Missing required flag: --name or --identity"
return 1
}
name=$(peers::resolve_and_require "$name" "$type") || return 1
if ! peers::is_blocked "$name" && ! block::has_file "$name"; then
log::wg_warning "Client is not blocked: ${name}"
return 0
fi
if [[ ${#_ips_ref[@]} -eq 0 && ${#_subnets_ref[@]} -eq 0 && \
${#_ports_ref[@]} -eq 0 && ${#_services_ref[@]} -eq 0 ]]; then
all=true
fi
local client_ip
client_ip=$(peers::get_ip "$name") || return 1
if [[ "$all" == "true" ]]; then
cmd::unblock::_unblock_all "$name" "$client_ip" "$quiet"
cmd::unblock::_record_history "$name" "manual" "$reason"
return 0
fi
for ip in "${_ips_ref[@]:-}"; do
fw::unblock_ip "$client_ip" "$ip"
block::remove_rule "$name" "ip" "$ip"
$quiet || log::wg_success "${ip} has been unblocked for ${name}"
done
for subnet in "${_subnets_ref[@]:-}"; do
fw::unblock_subnet "$client_ip" "$subnet"
block::remove_rule "$name" "subnet" "$subnet"
$quiet || log::wg_success "${subnet} has been unblocked for ${name}"
done
for entry in "${_ports_ref[@]:-}"; do
local target port proto
IFS=":" read -r target port proto <<< "$entry"
proto="${proto:-tcp}"
fw::unblock_port "$client_ip" "$target" "$port" "$proto"
block::remove_rule "$name" "port" "$target" "$port" "$proto"
$quiet || log::wg_success "${target}:${port}:${proto} has been unblocked for ${name}"
done
for svc in "${_services_ref[@]:-}"; do
local resolved_lines=()
mapfile -t resolved_lines < <(net::resolve "$svc" 2>/dev/null)
if [[ ${#resolved_lines[@]} -eq 0 ]]; then
log::error "Service not found: ${svc}"
return 1
fi
local is_blocked=false
for resolved in "${resolved_lines[@]}"; do
if [[ "$resolved" == *:*:* ]]; then
local b_ip b_port b_proto
IFS=":" read -r b_ip b_port b_proto <<< "$resolved"
fw::has_block_rule "$client_ip" "$b_ip" "$b_port" "$b_proto" 2>/dev/null && \
{ is_blocked=true; break; }
else
fw::has_block_rule "$client_ip" "$resolved" 2>/dev/null && \
{ is_blocked=true; break; }
fi
done
if ! $is_blocked; then
$quiet || log::wg_warning "${svc} is not blocked for ${name}"
continue
fi
for resolved in "${resolved_lines[@]}"; do
if [[ "$resolved" == *:*:* ]]; then
local b_ip b_port b_proto
IFS=":" read -r b_ip b_port b_proto <<< "$resolved"
fw::unblock_port "$client_ip" "$b_ip" "$b_port" "$b_proto"
block::remove_rule "$name" "port" "$b_ip" "$b_port" "$b_proto"
else
fw::unblock_ip "$client_ip" "$resolved"
block::remove_rule "$name" "ip" "$resolved"
fi
done
$quiet || log::wg_success "${svc} has been unblocked for ${name}"
done
block::cleanup "$name"
cmd::unblock::_record_history "$name" "manual" "$reason"
return 0
}
# ── Helpers ───────────────────────────────────────────────────────────────────
function cmd::unblock::_unblock_identity() {
local identity_name="${1:-}" quiet="${2:-false}"
identity::require_exists "$identity_name" || return 1
identity::require_has_peers "$identity_name" || return 1
local peers unblocked=0 skipped=0
peers=$(identity::peers "$identity_name")
while IFS= read -r peer_name; do
[[ -z "$peer_name" ]] && continue
if ! peers::is_blocked "$peer_name" && ! block::has_file "$peer_name"; then
skipped=$(( skipped + 1 ))
continue
fi
local client_ip
client_ip=$(peers::get_ip "$peer_name") || continue
if cmd::unblock::_unblock_all "$peer_name" "$client_ip" true; then
unblocked=$(( unblocked + 1 ))
else
skipped=$(( skipped + 1 ))
fi
done <<< "$peers"
if [[ $unblocked -eq 0 ]]; then
log::wg_warning "No peers were blocked for identity '${identity_name}'"
elif [[ $skipped -gt 0 ]]; then
log::ok "Unblocked ${unblocked} peer(s) for identity '${identity_name}' (${skipped} were not blocked)"
else
log::ok "Unblocked ${unblocked} peer(s) for identity '${identity_name}'"
fi
return 0
}
function cmd::unblock::_unblock_all() {
local name="${1:?}" client_ip="${2:?}" quiet="${3:-false}"
block::set_direct "$name" "$client_ip" "false"
block::clear_full_block "$name"
block::restore_peer "$name" "$client_ip"
block::cleanup "$name"
local rule
rule=$(peers::get_meta "$name" "rule")
if [[ -n "$rule" ]] && rule::exists "$rule"; then
rule::apply "$rule" "$client_ip" "$name"
fi
local groups
groups=$(block::get_groups "$name")
if [[ -n "$groups" ]]; then
log::wg_warning "${name} was blocked by group(s): ${groups} — unblocking anyway"
fi
$quiet || log::wg_success "${name} has been unblocked."
return 0
}
function cmd::unblock::_record_history() {
local name="${1:-}" unblocked_by="${2:-manual}" reason="${3:-}"
json::block_history_unblock \
"$(ctx::block_history)" \
"$name" \
"$unblocked_by" \
"$reason" \
2>/dev/null > /dev/null || true
}

52
commands/unblock/show.sh Normal file
View file

@ -0,0 +1,52 @@
#!/usr/bin/env bash
# commands/unblock/show.sh
function cmd::unblock::show::on_load() {
help::section "Target"
flag::define --name value "Peer name to unblock" [label="name", section="Target"]
flag::define --identity value "Unblock all peers in identity" [label="identity", section="Target"]
flag::define --type value "Filter by device type" [label="type", section="Target"]
help::section "Rules"
flag::define --ip[] "Unblock specific IP" [label="ip", section="Rules"]
flag::define --subnet[] "Unblock specific subnet" [label="subnet", section="Rules"]
flag::define --port[] "Unblock specific port (ip:port:proto)" [label="port", section="Rules"]
flag::define --service[] "Unblock by service name" [label="service", section="Rules"]
flag::define --all bool "Unblock all rules" [section="Rules"]
help::section "Options"
flag::define --reason value "Reason for unblock (recorded in history)" [label="reason", section="Options"]
flag::define --force bool "Skip confirmation" [section="Options"]
flag::define --quiet bool "Suppress output" [section="Options"]
flag::exclusive --name --identity
}
function cmd::unblock::show::run() {
flag::parse "$@" || return 1
local name; name=$(flag::value --name)
local identity; identity=$(flag::value --identity)
local type; type=$(flag::value --type)
local reason; reason=$(flag::value --reason)
local all=false quiet=false force=false
flag::bool --all && all=true
flag::bool --quiet && quiet=true
flag::bool --force && force=true
local -a ips=() subnets=() ports=() services=()
while IFS= read -r v; do [[ -n "$v" ]] && ips+=("$v"); done < <(flag::array --ip)
while IFS= read -r v; do [[ -n "$v" ]] && subnets+=("$v"); done < <(flag::array --subnet)
while IFS= read -r v; do [[ -n "$v" ]] && ports+=("$v"); done < <(flag::array --port)
while IFS= read -r v; do [[ -n "$v" ]] && services+=("$v"); done < <(flag::array --service)
if [[ -z "$name" && -z "$identity" ]]; then
log::error "Missing required flag: --name or --identity"
return 1
fi
cmd::unblock::_impl \
"$name" "$identity" "$type" "$reason" \
"$all" "$quiet" "$force" \
ips subnets ports services
}

View file

@ -0,0 +1,9 @@
#!/usr/bin/env bash
# commands/unblock/unblock.sh — router only
function cmd::unblock::on_load() {
command::helpers "helpers.sh"
command::define show "Unblock a peer or service" [*]
}
hook::on "command:help:unblock" command::help::auto

View file

@ -183,6 +183,15 @@ function command::load_subcmd() {
_CURRENT_LOADING_CMD=""
}
function command::helpers() {
local file="${1:-}"
local cmd="${_CURRENT_LOADING_CMD:-}"
[[ -z "$file" || -z "$cmd" ]] && return 1
local cmd_name="${cmd%%::*}"
local path="$(ctx::commands)/${cmd_name}/${file}"
[[ -f "$path" ]] && source "$path"
}
# ── Run ───────────────────────────────────────────────────────────────────────
# command::run_routed <cmd> <subcmd> <args...>

View file

@ -1,151 +1,145 @@
#!/usr/bin/env bash
# core/framework/flag.sh
#
# Declarative flag registration and parsing framework.
#
# Registration (in on_load):
# flag::define --name value "Filter by peer name"
# flag::define --fw bool "Firewall events only"
# flag::define --limit value "Max results" [default=50, type=int, min=1]
# flag::define --exclude[] "Exclude service" [label="service"]
# flag::exclusive --fw --wg
#
# Parsing (in run):
# flag::parse "$@" || return 1
#
# Accessors:
# flag::bool --fw → exits 0/1
# flag::value --name → prints value or default
# flag::array --exclude → prints newline-separated values
# flag::set --name → exits 0 if explicitly passed
# flag::args → prints remaining positional args
# Optimized: pre-parsed constraints + per-command flag index
# ── Storage ────────────────────────────────────────────────────────────────
# Per-command flag registry — populated by flag::define during on_load
# Key: "cmd::subcmd:--flag"
# Value: "type|description|constraints"
declare -gA _FLAG_REGISTRY=()
declare -gA _FLAG_REGISTRY=() # "ctx:--flag" → "type|description"
declare -gA _FLAG_INDEX=() # "ctx" → " --flag1 --flag2 ..." (space-separated)
# Runtime values — populated by flag::parse during run
declare -gA _FLAG_VALUES=() # --flag → value (bool: "true"/"false")
declare -gA _FLAG_ARRAYS=() # --flag → newline-separated values
declare -gA _FLAG_SET=() # --flag → "1" if explicitly passed
declare -ga _FLAG_ARGS=() # remaining positional args
# Pre-parsed constraints
declare -gA _FLAG_C_DEFAULT=()
declare -gA _FLAG_C_TYPE=()
declare -gA _FLAG_C_MIN=()
declare -gA _FLAG_C_MAX=()
declare -gA _FLAG_C_CHOICES=()
declare -gA _FLAG_C_REQUIRED=()
declare -gA _FLAG_C_LABEL=()
declare -gA _FLAG_C_SECTION=()
# Defaults — populated at registration time
declare -gA _FLAG_DEFAULTS=() # "cmd::subcmd:--flag" → default value
# Runtime values
declare -gA _FLAG_VALUES=()
declare -gA _FLAG_ARRAYS=()
declare -gA _FLAG_SET=()
declare -ga _FLAG_ARGS=()
# ── Helpers ─────────────────────────────────────────────────────────────────
# Completion registry
declare -gA _FLAG_COMPLETION=()
function flag::_context() {
echo "${_CURRENT_COMMAND:-__global__}"
}
# ── Pure bash constraint extractor ────────────────────────────────────────
function flag::_key() {
echo "$(flag::_context):${1}"
}
function flag::_parse_constraints() {
# Parse "[key=val, key=val]" constraint string
# Output: "key=val\nkey=val"
local constraints="${1:-}"
constraints="${constraints#[}"
constraints="${constraints%]}"
echo "$constraints" | tr ',' '\n' | sed 's/^ *//' | sed 's/ *$//'
}
function flag::_constraint_get() {
local constraints="$1" key="$2"
echo "$constraints" | while IFS='=' read -r k v; do
k="${k// /}"
if [[ "$k" == "$key" ]]; then
echo "${v//\"/}"
function flag::_extract_constraint() {
local constraints="$1" lookup="$2"
constraints="${constraints#[}"; constraints="${constraints%]}"
local rest="$constraints"
while [[ -n "$rest" ]]; do
local pair="${rest%%,*}"
pair="${pair# }"; pair="${pair% }"
local k="${pair%%=*}"; local v="${pair#*=}"
k="${k% }"; v="${v# }"; v="${v//\"/}"
if [[ "$k" == "$lookup" ]]; then
printf '%s' "$v"
return 0
fi
[[ "$rest" == *,* ]] && rest="${rest#*,}" || break
done
}
# ── Registration ─────────────────────────────────────────────────────────────
function flag::_parse_and_cache() {
local key="$1" constraints="$2"
[[ -z "$constraints" ]] && return 0
local v
v=$(flag::_extract_constraint "$constraints" "default")
[[ -n "$v" ]] && _FLAG_C_DEFAULT["$key"]="$v"
v=$(flag::_extract_constraint "$constraints" "type")
[[ -n "$v" ]] && _FLAG_C_TYPE["$key"]="$v"
v=$(flag::_extract_constraint "$constraints" "min")
[[ -n "$v" ]] && _FLAG_C_MIN["$key"]="$v"
v=$(flag::_extract_constraint "$constraints" "max")
[[ -n "$v" ]] && _FLAG_C_MAX["$key"]="$v"
v=$(flag::_extract_constraint "$constraints" "choices")
[[ -n "$v" ]] && _FLAG_C_CHOICES["$key"]="$v"
v=$(flag::_extract_constraint "$constraints" "required")
[[ "$v" == "true" ]] && _FLAG_C_REQUIRED["$key"]="true"
v=$(flag::_extract_constraint "$constraints" "label")
[[ -n "$v" ]] && _FLAG_C_LABEL["$key"]="$v"
v=$(flag::_extract_constraint "$constraints" "section")
[[ -n "$v" ]] && _FLAG_C_SECTION["$key"]="$v"
}
# ── Context ───────────────────────────────────────────────────────────────
function flag::_context() { printf '%s' "${_CURRENT_COMMAND:-__global__}"; }
function flag::_key() { printf '%s:%s' "${_CURRENT_COMMAND:-__global__}" "$1"; }
# ── Registration ──────────────────────────────────────────────────────────
# flag::define --flag [bool|value] "description" [constraints]
# flag::define --flag[] "description" [constraints] ← array flag
function flag::define() {
local raw_flag="$1"
local is_array=false
local raw_flag="$1" is_array=false
# Detect array flag syntax: --flag[]
if [[ "$raw_flag" == *"[]" ]]; then
is_array=true
raw_flag="${raw_flag%[]}"
is_array=true; raw_flag="${raw_flag%[]}"
fi
local type description constraints=""
if $is_array; then
type="array"
description="${2:-}"
constraints="${3:-}"
type="array"; description="${2:-}"; constraints="${3:-}"
else
type="${2:-bool}"
description="${3:-}"
constraints="${4:-}"
type="${2:-bool}"; description="${3:-}"; constraints="${4:-}"
fi
local key
key=$(flag::_key "$raw_flag")
local ctx="${_CURRENT_COMMAND:-__global__}"
local key="${ctx}:${raw_flag}"
_FLAG_REGISTRY["$key"]="${type}|${description}|${constraints}"
_FLAG_REGISTRY["$key"]="${type}|${description}"
# Extract and store default
if [[ -n "$constraints" ]]; then
local parsed_constraints
parsed_constraints=$(flag::_parse_constraints "$constraints")
local default_val
default_val=$(flag::_constraint_get "$parsed_constraints" "default")
if [[ -n "$default_val" ]]; then
_FLAG_DEFAULTS["$key"]="$default_val"
fi
# Per-command index — fast lookup in flag::parse
_FLAG_INDEX["$ctx"]+=" ${raw_flag}"
# Pre-parse constraints
flag::_parse_and_cache "$key" "$constraints"
# Bool default
if [[ "$type" == "bool" && -z "${_FLAG_C_DEFAULT[$key]:-}" ]]; then
_FLAG_C_DEFAULT["$key"]="false"
fi
# Set bool default to false if not specified
if [[ "$type" == "bool" && -z "${_FLAG_DEFAULTS[$key]:-}" ]]; then
_FLAG_DEFAULTS["$key"]="false"
# Help section assignment (no subshell — uses cached value)
if declare -f help::_assign_flag_from_cache &>/dev/null; then
help::_assign_flag_from_cache "$raw_flag" "${_FLAG_C_SECTION[$key]:-}"
fi
# Register for shell completion (backward compat)
flag::register "$raw_flag" 2>/dev/null || true
help::_assign_flag_section "$raw_flag" "$constraints"
_FLAG_COMPLETION["$raw_flag"]=1
}
# flag::register remains for backward compat and completion-only registration
# Already defined in command.sh — this is a no-op if already defined
function flag::register() {
: # handled by command.sh completion registry
}
function flag::register() { _FLAG_COMPLETION["${1:-}"]=1; }
# ── Parsing ──────────────────────────────────────────────────────────────────
# ── Parsing ───────────────────────────────────────────────────────────────
function flag::parse() {
local ctx
ctx=$(flag::_context)
local ctx="${_CURRENT_COMMAND:-__global__}"
# Reset runtime state
_FLAG_VALUES=()
_FLAG_ARRAYS=()
_FLAG_SET=()
_FLAG_ARGS=()
# Reset runtime
_FLAG_VALUES=(); _FLAG_ARRAYS=(); _FLAG_SET=(); _FLAG_ARGS=()
# Initialize bools to false, values to defaults
local key
for key in "${!_FLAG_REGISTRY[@]}"; do
[[ "$key" != "${ctx}:"* ]] && continue
local flag="${key#${ctx}:}"
local reg="${_FLAG_REGISTRY[$key]}"
local type="${reg%%|*}"
local default_val="${_FLAG_DEFAULTS[$key]:-}"
# Check for --help/-h first (fast path)
local arg
for arg in "$@"; do
if [[ "$arg" == "--help" || "$arg" == "-h" ]]; then
local cmd="${_CURRENT_COMMAND%%::*}"
declare -f hook::fire &>/dev/null && \
hook::fire "command:help:${cmd}" "$cmd" "${_CURRENT_COMMAND##*::}"
return 1
fi
done
# Initialize from per-command index (fast — no full registry scan)
local flag key type default_val
for flag in ${_FLAG_INDEX[$ctx]:-}; do
key="${ctx}:${flag}"
[[ -z "${_FLAG_REGISTRY[$key]+x}" ]] && continue
type="${_FLAG_REGISTRY[$key]%%|*}"
default_val="${_FLAG_C_DEFAULT[$key]:-}"
case "$type" in
bool) _FLAG_VALUES["$flag"]="${default_val:-false}" ;;
value) [[ -n "$default_val" ]] && _FLAG_VALUES["$flag"]="$default_val" ;;
@ -155,32 +149,22 @@ function flag::parse() {
# Parse args
while [[ $# -gt 0 ]]; do
local arg="$1"
arg="$1"
if [[ "$arg" == "--" ]]; then
shift
_FLAG_ARGS+=("$@")
break
shift; _FLAG_ARGS+=("$@"); break
fi
if [[ "$arg" != --* ]]; then
_FLAG_ARGS+=("$arg")
shift
continue
_FLAG_ARGS+=("$arg"); shift; continue
fi
local key
key=$(flag::_key "$arg")
key="${ctx}:${arg}"
if [[ -z "${_FLAG_REGISTRY[$key]+x}" ]]; then
log::error "Unknown flag: ${arg}"
return 1
log::error "Unknown flag: ${arg}"; return 1
fi
local reg="${_FLAG_REGISTRY[$key]}"
local type="${reg%%|*}"
local constraints
constraints=$(echo "$reg" | cut -d'|' -f3)
type="${_FLAG_REGISTRY[$key]%%|*}"
case "$type" in
bool)
@ -190,52 +174,40 @@ function flag::parse() {
;;
value)
if [[ $# -lt 2 || "$2" == --* ]]; then
log::error "Flag ${arg} requires a value"
return 1
log::error "Flag ${arg} requires a value"; return 1
fi
local val="$2"
# Type validation
if [[ -n "$constraints" ]]; then
local parsed
parsed=$(flag::_parse_constraints "$constraints")
local vtype
vtype=$(flag::_constraint_get "$parsed" "type")
local vtype="${_FLAG_C_TYPE[$key]:-}"
if [[ "$vtype" == "int" ]]; then
if ! [[ "$val" =~ ^[0-9]+$ ]]; then
log::error "Flag ${arg} requires an integer, got: ${val}"
return 1
log::error "Flag ${arg} requires an integer, got: ${val}"; return 1
fi
local min max
min=$(flag::_constraint_get "$parsed" "min")
max=$(flag::_constraint_get "$parsed" "max")
local min="${_FLAG_C_MIN[$key]:-}" max="${_FLAG_C_MAX[$key]:-}"
[[ -n "$min" && "$val" -lt "$min" ]] && \
log::error "Flag ${arg} minimum is ${min}, got: ${val}" && return 1
[[ -n "$max" && "$val" -gt "$max" ]] && \
log::error "Flag ${arg} maximum is ${max}, got: ${val}" && return 1
fi
local choices
choices=$(flag::_constraint_get "$parsed" "choices")
local choices="${_FLAG_C_CHOICES[$key]:-}"
if [[ -n "$choices" ]]; then
local valid=false
local choice
IFS='|' read -ra choice_list <<< "$choices"
for choice in "${choice_list[@]}"; do
local valid=false choice
local IFS='|'
for choice in $choices; do
[[ "$val" == "$choice" ]] && valid=true && break
done
unset IFS
if ! $valid; then
log::error "Flag ${arg} must be one of: ${choices//|/, }, got: ${val}"
return 1
fi
fi
fi
_FLAG_VALUES["$arg"]="$val"
_FLAG_SET["$arg"]="1"
shift 2
;;
array)
if [[ $# -lt 2 || "$2" == --* ]]; then
log::error "Flag ${arg} requires a value"
return 1
log::error "Flag ${arg} requires a value"; return 1
fi
if [[ -n "${_FLAG_ARRAYS[$arg]:-}" ]]; then
_FLAG_ARRAYS["$arg"]+=$'\n'"$2"
@ -248,99 +220,77 @@ function flag::parse() {
esac
done
# Validate required
for flag in ${_FLAG_INDEX[$ctx]:-}; do
key="${ctx}:${flag}"
[[ -z "${_FLAG_C_REQUIRED[$key]:-}" ]] && continue
if [[ -z "${_FLAG_SET[$flag]+x}" && -z "${_FLAG_VALUES[$flag]:-}" ]]; then
log::error "Flag ${flag} is required"; return 1
fi
done
# Validate exclusive groups
local groups="${_FLAG_EXCLUSIVE_GROUPS[${_CURRENT_COMMAND%%::*}]:-}"
if [[ -n "$groups" ]]; then
local group
while IFS= read -r group; do
[[ -z "$group" ]] && continue
local -a members=()
IFS=',' read -ra members <<< "$group"
local found_count=0 found_flags=""
local OLD_IFS="$IFS"; IFS=','; read -ra members <<< "$group"; IFS="$OLD_IFS"
local found_count=0 found_flags="" member
for member in "${members[@]}"; do
flag::set "$member" && (( found_count++ )) && found_flags+=" $member"
done
if [[ $found_count -gt 1 ]]; then
log::error "Flags${found_flags} are mutually exclusive"
return 1
log::error "Flags${found_flags} are mutually exclusive"; return 1
fi
done < <(echo "$groups" | tr '|' '\n')
done < <(printf '%s' "$groups" | tr '|' '\n')
fi
# Validate required flags
for key in "${!_FLAG_REGISTRY[@]}"; do
[[ "$key" != "${ctx}:"* ]] && continue
local flag="${key#${ctx}:}"
local reg="${_FLAG_REGISTRY[$key]}"
local constraints
constraints=$(echo "$reg" | cut -d'|' -f3)
if [[ -n "$constraints" ]]; then
local parsed
parsed=$(flag::_parse_constraints "$constraints")
local required
required=$(flag::_constraint_get "$parsed" "required")
if [[ "$required" == "true" && -z "${_FLAG_SET[$flag]+x}" ]]; then
# Check if provided by defaults
if [[ -z "${_FLAG_VALUES[$flag]:-}" ]]; then
log::error "Flag ${flag} is required"
return 1
fi
fi
fi
done
return 0
}
# ── Accessors ─────────────────────────────────────────────────────────────────
# ── Accessors ─────────────────────────────────────────────────────────────
# flag::bool --flag → exits 0 if true, 1 if false
function flag::bool() {
[[ "${_FLAG_VALUES[$1]:-false}" == "true" ]]
}
function flag::bool() { [[ "${_FLAG_VALUES[$1]:-false}" == "true" ]]; }
function flag::value() { printf '%s' "${_FLAG_VALUES[$1]:-}"; }
function flag::array() { printf '%s' "${_FLAG_ARRAYS[$1]:-}"; }
function flag::set() { [[ -n "${_FLAG_SET[$1]+x}" ]]; }
function flag::args() { printf '%s ' "${_FLAG_ARGS[@]:-}"; }
# flag::value --flag → prints value or empty string
function flag::value() {
echo "${_FLAG_VALUES[$1]:-}"
}
# flag::array --flag → prints newline-separated values
function flag::array() {
echo "${_FLAG_ARRAYS[$1]:-}"
}
# flag::set --flag → exits 0 if explicitly passed by user
function flag::set() {
[[ -n "${_FLAG_SET[$1]+x}" ]]
}
# flag::args → prints positional args array
function flag::args() {
echo "${_FLAG_ARGS[@]:-}"
}
# flag::args_array → populates a nameref array with positional args
function flag::args_array() {
local -n _arr_ref="$1"
_arr_ref=("${_FLAG_ARGS[@]:-}")
}
# ── Reset ─────────────────────────────────────────────────────────────────────
# ── Reset ─────────────────────────────────────────────────────────────────
function flag::_reset_runtime() {
_FLAG_VALUES=()
_FLAG_ARRAYS=()
_FLAG_SET=()
_FLAG_ARGS=()
_FLAG_VALUES=(); _FLAG_ARRAYS=(); _FLAG_SET=(); _FLAG_ARGS=()
}
function flag::_reset_registry() {
# Clear registry entries for a specific command context
local ctx="${1:-$(flag::_context)}"
local key
for key in "${!_FLAG_REGISTRY[@]}"; do
[[ "$key" == "${ctx}:"* ]] && unset "_FLAG_REGISTRY[$key]"
done
for key in "${!_FLAG_DEFAULTS[@]}"; do
[[ "$key" == "${ctx}:"* ]] && unset "_FLAG_DEFAULTS[$key]"
[[ "$key" == "${ctx}:"* ]] || continue
unset "_FLAG_REGISTRY[$key]" "_FLAG_C_DEFAULT[$key]" "_FLAG_C_TYPE[$key]"
unset "_FLAG_C_MIN[$key]" "_FLAG_C_MAX[$key]" "_FLAG_C_CHOICES[$key]"
unset "_FLAG_C_REQUIRED[$key]" "_FLAG_C_LABEL[$key]" "_FLAG_C_SECTION[$key]"
done
unset "_FLAG_INDEX[$ctx]"
}
# ── Help bridge ───────────────────────────────────────────────────────────
function help::_assign_flag_from_cache() {
local flag="$1" section="$2"
local ctx="${_CURRENT_COMMAND:-__global__}"
[[ -z "$section" ]] && section="${_CURRENT_HELP_SECTION:-}"
[[ -z "$section" ]] && return 0
local skey="${ctx}:${section}"
if [[ -z "${_HELP_SECTIONS[$skey]+x}" ]]; then
_HELP_SECTIONS["$skey"]=$(( _HELP_SECTION_COUNT++ ))
fi
_HELP_FLAG_SECTION["${ctx}:${flag}"]="$section"
}

View file

@ -0,0 +1,346 @@
#!/usr/bin/env bash
# core/framework/flag.sh
#
# Declarative flag registration and parsing framework.
#
# Registration (in on_load):
# flag::define --name value "Filter by peer name"
# flag::define --fw bool "Firewall events only"
# flag::define --limit value "Max results" [default=50, type=int, min=1]
# flag::define --exclude[] "Exclude service" [label="service"]
# flag::exclusive --fw --wg
#
# Parsing (in run):
# flag::parse "$@" || return 1
#
# Accessors:
# flag::bool --fw → exits 0/1
# flag::value --name → prints value or default
# flag::array --exclude → prints newline-separated values
# flag::set --name → exits 0 if explicitly passed
# flag::args → prints remaining positional args
# ── Storage ────────────────────────────────────────────────────────────────
# Per-command flag registry — populated by flag::define during on_load
# Key: "cmd::subcmd:--flag"
# Value: "type|description|constraints"
declare -gA _FLAG_REGISTRY=()
# Runtime values — populated by flag::parse during run
declare -gA _FLAG_VALUES=() # --flag → value (bool: "true"/"false")
declare -gA _FLAG_ARRAYS=() # --flag → newline-separated values
declare -gA _FLAG_SET=() # --flag → "1" if explicitly passed
declare -ga _FLAG_ARGS=() # remaining positional args
# Defaults — populated at registration time
declare -gA _FLAG_DEFAULTS=() # "cmd::subcmd:--flag" → default value
# ── Helpers ─────────────────────────────────────────────────────────────────
function flag::_context() {
echo "${_CURRENT_COMMAND:-__global__}"
}
function flag::_key() {
echo "$(flag::_context):${1}"
}
function flag::_parse_constraints() {
# Parse "[key=val, key=val]" constraint string
# Output: "key=val\nkey=val"
local constraints="${1:-}"
constraints="${constraints#[}"
constraints="${constraints%]}"
echo "$constraints" | tr ',' '\n' | sed 's/^ *//' | sed 's/ *$//'
}
function flag::_constraint_get() {
local constraints="$1" key="$2"
echo "$constraints" | while IFS='=' read -r k v; do
k="${k// /}"
if [[ "$k" == "$key" ]]; then
echo "${v//\"/}"
return 0
fi
done
}
# ── Registration ─────────────────────────────────────────────────────────────
# flag::define --flag [bool|value] "description" [constraints]
# flag::define --flag[] "description" [constraints] ← array flag
function flag::define() {
local raw_flag="$1"
local is_array=false
# Detect array flag syntax: --flag[]
if [[ "$raw_flag" == *"[]" ]]; then
is_array=true
raw_flag="${raw_flag%[]}"
fi
local type description constraints=""
if $is_array; then
type="array"
description="${2:-}"
constraints="${3:-}"
else
type="${2:-bool}"
description="${3:-}"
constraints="${4:-}"
fi
local key
key=$(flag::_key "$raw_flag")
_FLAG_REGISTRY["$key"]="${type}|${description}|${constraints}"
# Extract and store default
if [[ -n "$constraints" ]]; then
local parsed_constraints
parsed_constraints=$(flag::_parse_constraints "$constraints")
local default_val
default_val=$(flag::_constraint_get "$parsed_constraints" "default")
if [[ -n "$default_val" ]]; then
_FLAG_DEFAULTS["$key"]="$default_val"
fi
fi
# Set bool default to false if not specified
if [[ "$type" == "bool" && -z "${_FLAG_DEFAULTS[$key]:-}" ]]; then
_FLAG_DEFAULTS["$key"]="false"
fi
# Register for shell completion (backward compat)
flag::register "$raw_flag" 2>/dev/null || true
help::_assign_flag_section "$raw_flag" "$constraints"
}
# flag::register remains for backward compat and completion-only registration
# Already defined in command.sh — this is a no-op if already defined
function flag::register() {
: # handled by command.sh completion registry
}
# ── Parsing ──────────────────────────────────────────────────────────────────
function flag::parse() {
local ctx
ctx=$(flag::_context)
# Reset runtime state
_FLAG_VALUES=()
_FLAG_ARRAYS=()
_FLAG_SET=()
_FLAG_ARGS=()
# Initialize bools to false, values to defaults
local key
for key in "${!_FLAG_REGISTRY[@]}"; do
[[ "$key" != "${ctx}:"* ]] && continue
local flag="${key#${ctx}:}"
local reg="${_FLAG_REGISTRY[$key]}"
local type="${reg%%|*}"
local default_val="${_FLAG_DEFAULTS[$key]:-}"
case "$type" in
bool) _FLAG_VALUES["$flag"]="${default_val:-false}" ;;
value) [[ -n "$default_val" ]] && _FLAG_VALUES["$flag"]="$default_val" ;;
array) _FLAG_ARRAYS["$flag"]="" ;;
esac
done
# Parse args
while [[ $# -gt 0 ]]; do
local arg="$1"
if [[ "$arg" == "--" ]]; then
shift
_FLAG_ARGS+=("$@")
break
fi
if [[ "$arg" != --* ]]; then
_FLAG_ARGS+=("$arg")
shift
continue
fi
local key
key=$(flag::_key "$arg")
if [[ -z "${_FLAG_REGISTRY[$key]+x}" ]]; then
log::error "Unknown flag: ${arg}"
return 1
fi
local reg="${_FLAG_REGISTRY[$key]}"
local type="${reg%%|*}"
local constraints
constraints=$(echo "$reg" | cut -d'|' -f3)
case "$type" in
bool)
_FLAG_VALUES["$arg"]="true"
_FLAG_SET["$arg"]="1"
shift
;;
value)
if [[ $# -lt 2 || "$2" == --* ]]; then
log::error "Flag ${arg} requires a value"
return 1
fi
local val="$2"
# Type validation
if [[ -n "$constraints" ]]; then
local parsed
parsed=$(flag::_parse_constraints "$constraints")
local vtype
vtype=$(flag::_constraint_get "$parsed" "type")
if [[ "$vtype" == "int" ]]; then
if ! [[ "$val" =~ ^[0-9]+$ ]]; then
log::error "Flag ${arg} requires an integer, got: ${val}"
return 1
fi
local min max
min=$(flag::_constraint_get "$parsed" "min")
max=$(flag::_constraint_get "$parsed" "max")
[[ -n "$min" && "$val" -lt "$min" ]] && \
log::error "Flag ${arg} minimum is ${min}, got: ${val}" && return 1
[[ -n "$max" && "$val" -gt "$max" ]] && \
log::error "Flag ${arg} maximum is ${max}, got: ${val}" && return 1
fi
local choices
choices=$(flag::_constraint_get "$parsed" "choices")
if [[ -n "$choices" ]]; then
local valid=false
local choice
IFS='|' read -ra choice_list <<< "$choices"
for choice in "${choice_list[@]}"; do
[[ "$val" == "$choice" ]] && valid=true && break
done
if ! $valid; then
log::error "Flag ${arg} must be one of: ${choices//|/, }, got: ${val}"
return 1
fi
fi
fi
_FLAG_VALUES["$arg"]="$val"
_FLAG_SET["$arg"]="1"
shift 2
;;
array)
if [[ $# -lt 2 || "$2" == --* ]]; then
log::error "Flag ${arg} requires a value"
return 1
fi
if [[ -n "${_FLAG_ARRAYS[$arg]:-}" ]]; then
_FLAG_ARRAYS["$arg"]+=$'\n'"$2"
else
_FLAG_ARRAYS["$arg"]="$2"
fi
_FLAG_SET["$arg"]="1"
shift 2
;;
esac
done
local groups="${_FLAG_EXCLUSIVE_GROUPS[${_CURRENT_COMMAND%%::*}]:-}"
if [[ -n "$groups" ]]; then
local group
while IFS= read -r group; do
[[ -z "$group" ]] && continue
local -a members=()
IFS=',' read -ra members <<< "$group"
local found_count=0 found_flags=""
for member in "${members[@]}"; do
flag::set "$member" && (( found_count++ )) && found_flags+=" $member"
done
if [[ $found_count -gt 1 ]]; then
log::error "Flags${found_flags} are mutually exclusive"
return 1
fi
done < <(echo "$groups" | tr '|' '\n')
fi
# Validate required flags
for key in "${!_FLAG_REGISTRY[@]}"; do
[[ "$key" != "${ctx}:"* ]] && continue
local flag="${key#${ctx}:}"
local reg="${_FLAG_REGISTRY[$key]}"
local constraints
constraints=$(echo "$reg" | cut -d'|' -f3)
if [[ -n "$constraints" ]]; then
local parsed
parsed=$(flag::_parse_constraints "$constraints")
local required
required=$(flag::_constraint_get "$parsed" "required")
if [[ "$required" == "true" && -z "${_FLAG_SET[$flag]+x}" ]]; then
# Check if provided by defaults
if [[ -z "${_FLAG_VALUES[$flag]:-}" ]]; then
log::error "Flag ${flag} is required"
return 1
fi
fi
fi
done
return 0
}
# ── Accessors ─────────────────────────────────────────────────────────────────
# flag::bool --flag → exits 0 if true, 1 if false
function flag::bool() {
[[ "${_FLAG_VALUES[$1]:-false}" == "true" ]]
}
# flag::value --flag → prints value or empty string
function flag::value() {
echo "${_FLAG_VALUES[$1]:-}"
}
# flag::array --flag → prints newline-separated values
function flag::array() {
echo "${_FLAG_ARRAYS[$1]:-}"
}
# flag::set --flag → exits 0 if explicitly passed by user
function flag::set() {
[[ -n "${_FLAG_SET[$1]+x}" ]]
}
# flag::args → prints positional args array
function flag::args() {
echo "${_FLAG_ARGS[@]:-}"
}
# flag::args_array → populates a nameref array with positional args
function flag::args_array() {
local -n _arr_ref="$1"
_arr_ref=("${_FLAG_ARGS[@]:-}")
}
# ── Reset ─────────────────────────────────────────────────────────────────────
function flag::_reset_runtime() {
_FLAG_VALUES=()
_FLAG_ARRAYS=()
_FLAG_SET=()
_FLAG_ARGS=()
}
function flag::_reset_registry() {
# Clear registry entries for a specific command context
local ctx="${1:-$(flag::_context)}"
local key
for key in "${!_FLAG_REGISTRY[@]}"; do
[[ "$key" == "${ctx}:"* ]] && unset "_FLAG_REGISTRY[$key]"
done
for key in "${!_FLAG_DEFAULTS[@]}"; do
[[ "$key" == "${ctx}:"* ]] && unset "_FLAG_DEFAULTS[$key]"
done
}

View file

@ -55,33 +55,6 @@ function help::section() {
_CURRENT_HELP_SECTION="$name"
}
# Called by flag::define to record which section a flag belongs to
# Checks constraint [section="..."] first, falls back to _CURRENT_HELP_SECTION
function help::_assign_flag_section() {
local flag="$1" constraints="${2:-}"
local ctx="${_CURRENT_COMMAND:-__global__}"
# Check constraint
local section=""
if [[ -n "$constraints" ]]; then
local parsed
parsed=$(flag::_parse_constraints "$constraints")
section=$(flag::_constraint_get "$parsed" "section")
fi
# Fall back to active section
[[ -z "$section" ]] && section="${_CURRENT_HELP_SECTION:-}"
# Register section if new
if [[ -n "$section" ]]; then
local skey="${ctx}:${section}"
if [[ -z "${_HELP_SECTIONS[$skey]+x}" ]]; then
_HELP_SECTIONS["$skey"]=$(( _HELP_SECTION_COUNT++ ))
fi
_HELP_FLAG_SECTION["${ctx}:${flag}"]="$section"
fi
}
# ── Auto help generation ──────────────────────────────────────────────────────
# command::help::auto
@ -105,12 +78,9 @@ function command::help::auto() {
constraints=$(echo "$reg" | cut -d'|' -f3)
if [[ -n "$constraints" ]]; then
local parsed
parsed=$(flag::_parse_constraints "$constraints")
local required
required=$(flag::_constraint_get "$parsed" "required")
local label
label=$(flag::_constraint_get "$parsed" "label")
local required; required="${_FLAG_C_REQUIRED[$key]:-}"
local label; label="${_FLAG_C_LABEL[$key]:-value}"
[[ -z "$label" ]] && label="value"
if [[ "$required" == "true" ]]; then
@ -231,22 +201,13 @@ function help::_print_flag() {
[[ -z "$reg" ]] && return 0
local type="${reg%%|*}"
local rest="${reg#*|}"
local desc="${rest%%|*}"
local constraints="${rest#*|}"
local desc="${reg#*|}"
local label="" required=false default_val="" choices=""
if [[ -n "$constraints" ]]; then
local parsed
parsed=$(flag::_parse_constraints "$constraints")
label=$(flag::_constraint_get "$parsed" "label")
local req
req=$(flag::_constraint_get "$parsed" "required")
[[ "$req" == "true" ]] && required=true
default_val=$(flag::_constraint_get "$parsed" "default")
choices=$(flag::_constraint_get "$parsed" "choices")
fi
[[ -z "$label" ]] && label="value"
# Use pre-cached constraints instead of parsing
local label="${_FLAG_C_LABEL[$key]:-value}"
local required="${_FLAG_C_REQUIRED[$key]:-}"
local default_val="${_FLAG_C_DEFAULT[$key]:-}"
local choices="${_FLAG_C_CHOICES[$key]:-}"
local flag_display="$flag"
case "$type" in
@ -255,8 +216,8 @@ function help::_print_flag() {
esac
local suffix=""
$required && suffix=" *"
[[ -n "$default_val" ]] && suffix+=" [default: ${default_val}]"
[[ "$required" == "true" ]] && suffix=" *"
[[ -n "$default_val" && "$type" != "bool" ]] && suffix+=" [default: ${default_val}]"
[[ -n "$choices" ]] && suffix+=" (${choices//|/|})"
printf " %-28s %s%s\n" "$flag_display" "$desc" "$suffix"

View file

@ -151,6 +151,26 @@ function internal::get_context_icon() {
esac
}
# ============================================
# Profiler
# ============================================
declare -gi _PROFILE_T0=0
function log::profile_start() {
_PROFILE_T0=$(date +%s%3N)
}
function log::profile() {
[[ "${LOG_LEVEL:-2}" -gt 0 ]] && return 0
local label="${1:-checkpoint}"
local now
now=$(date +%s%3N)
printf " \033[2m[profile] %s: %dms\033[0m\n" \
"$label" "$(( now - _PROFILE_T0 ))" >&2
_PROFILE_T0=$now # reset for next checkpoint
}
# ============================================
# Loggers
# ============================================