From f61bc59446f1998b43af67bf9e336083800b49e0 Mon Sep 17 00:00:00 2001 From: Nuno Duque Nunes Date: Sat, 30 May 2026 12:31:41 +0000 Subject: [PATCH] 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 --- commands/activity/activity.sh | 9 + commands/activity/helpers.sh | 323 ++++++++++++++++++++++ commands/activity/show.sh | 71 +++++ commands/block/block.sh | 9 + commands/block/helpers.sh | 220 +++++++++++++++ commands/block/show.sh | 54 ++++ commands/unblock/helpers.sh | 180 +++++++++++++ commands/unblock/show.sh | 52 ++++ commands/unblock/unblock.sh | 9 + core/framework/command.sh | 9 + core/framework/flag.sh | 414 +++++++++++++---------------- core/framework/flag.sh.unoptimized | 346 ++++++++++++++++++++++++ core/framework/help.sh | 61 +---- core/framework/log.sh | 20 ++ 14 files changed, 1495 insertions(+), 282 deletions(-) create mode 100644 commands/activity/activity.sh create mode 100644 commands/activity/helpers.sh create mode 100644 commands/activity/show.sh create mode 100644 commands/block/block.sh create mode 100644 commands/block/helpers.sh create mode 100644 commands/block/show.sh create mode 100644 commands/unblock/helpers.sh create mode 100644 commands/unblock/show.sh create mode 100644 commands/unblock/unblock.sh create mode 100644 core/framework/flag.sh.unoptimized diff --git a/commands/activity/activity.sh b/commands/activity/activity.sh new file mode 100644 index 0000000..2905a60 --- /dev/null +++ b/commands/activity/activity.sh @@ -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 \ No newline at end of file diff --git a/commands/activity/helpers.sh b/commands/activity/helpers.sh new file mode 100644 index 0000000..a29eae0 --- /dev/null +++ b/commands/activity/helpers.sh @@ -0,0 +1,323 @@ +#!/usr/bin/env bash +# commands/activity/helpers.sh +# Implementation logic — render functions, data fetching, maps + +# cmd::activity::_impl +# +# +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[@]}" +} \ No newline at end of file diff --git a/commands/activity/show.sh b/commands/activity/show.sh new file mode 100644 index 0000000..77f7882 --- /dev/null +++ b/commands/activity/show.sh @@ -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" +} \ No newline at end of file diff --git a/commands/block/block.sh b/commands/block/block.sh new file mode 100644 index 0000000..851cc71 --- /dev/null +++ b/commands/block/block.sh @@ -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 \ No newline at end of file diff --git a/commands/block/helpers.sh b/commands/block/helpers.sh new file mode 100644 index 0000000..508f4c7 --- /dev/null +++ b/commands/block/helpers.sh @@ -0,0 +1,220 @@ +#!/usr/bin/env bash +# commands/block/helpers.sh + +# cmd::block::_impl +# +# +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 +} \ No newline at end of file diff --git a/commands/block/show.sh b/commands/block/show.sh new file mode 100644 index 0000000..7e7b778 --- /dev/null +++ b/commands/block/show.sh @@ -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 +} \ No newline at end of file diff --git a/commands/unblock/helpers.sh b/commands/unblock/helpers.sh new file mode 100644 index 0000000..51cd211 --- /dev/null +++ b/commands/unblock/helpers.sh @@ -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 +} \ No newline at end of file diff --git a/commands/unblock/show.sh b/commands/unblock/show.sh new file mode 100644 index 0000000..65e5a2b --- /dev/null +++ b/commands/unblock/show.sh @@ -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 +} \ No newline at end of file diff --git a/commands/unblock/unblock.sh b/commands/unblock/unblock.sh new file mode 100644 index 0000000..0052b89 --- /dev/null +++ b/commands/unblock/unblock.sh @@ -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 \ No newline at end of file diff --git a/core/framework/command.sh b/core/framework/command.sh index 35bb37e..ce1e4a2 100644 --- a/core/framework/command.sh +++ b/core/framework/command.sh @@ -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 diff --git a/core/framework/flag.sh b/core/framework/flag.sh index b026f21..245e6cc 100644 --- a/core/framework/flag.sh +++ b/core/framework/flag.sh @@ -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" } \ No newline at end of file diff --git a/core/framework/flag.sh.unoptimized b/core/framework/flag.sh.unoptimized new file mode 100644 index 0000000..b026f21 --- /dev/null +++ b/core/framework/flag.sh.unoptimized @@ -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 +} \ No newline at end of file diff --git a/core/framework/help.sh b/core/framework/help.sh index dc8cbce..d4b641d 100644 --- a/core/framework/help.sh +++ b/core/framework/help.sh @@ -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" diff --git a/core/framework/log.sh b/core/framework/log.sh index da86794..877f6ff 100644 --- a/core/framework/log.sh +++ b/core/framework/log.sh @@ -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 # ============================================