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:
parent
de22dbeec7
commit
f61bc59446
14 changed files with 1495 additions and 282 deletions
9
commands/activity/activity.sh
Normal file
9
commands/activity/activity.sh
Normal 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
|
||||
323
commands/activity/helpers.sh
Normal file
323
commands/activity/helpers.sh
Normal 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
71
commands/activity/show.sh
Normal 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
9
commands/block/block.sh
Normal 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
220
commands/block/helpers.sh
Normal 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
54
commands/block/show.sh
Normal 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
180
commands/unblock/helpers.sh
Normal 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
52
commands/unblock/show.sh
Normal 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
|
||||
}
|
||||
9
commands/unblock/unblock.sh
Normal file
9
commands/unblock/unblock.sh
Normal 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
|
||||
|
|
@ -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...>
|
||||
|
|
|
|||
|
|
@ -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,42 +174,31 @@ 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")
|
||||
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
|
||||
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
|
||||
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
|
||||
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="${_FLAG_C_CHOICES[$key]:-}"
|
||||
if [[ -n "$choices" ]]; then
|
||||
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
|
||||
_FLAG_VALUES["$arg"]="$val"
|
||||
|
|
@ -234,8 +207,7 @@ function flag::parse() {
|
|||
;;
|
||||
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
|
||||
|
||||
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
|
||||
# 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=()
|
||||
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
|
||||
fi
|
||||
done < <(printf '%s' "$groups" | tr '|' '\n')
|
||||
fi
|
||||
|
||||
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"
|
||||
}
|
||||
346
core/framework/flag.sh.unoptimized
Normal file
346
core/framework/flag.sh.unoptimized
Normal 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
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ============================================
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue