From a559b73e8e5d785cfec5dc6c78a388dc0dedf65c Mon Sep 17 00:00:00 2001 From: Nuno Duque Nunes Date: Sun, 31 May 2026 00:16:55 +0000 Subject: [PATCH] feat: new flag::define syntax, flag::set_constraint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - flag::define: variadic constraint args (key:value) instead of bracket string - flag::_parse_constraints_from_args: replaces flag::_parse_and_cache - flag::set_constraint: Option B syntax for post-definition constraints - choices separator: comma (choices:split,full) — no quoting needed - guard against empty _CURRENT_COMMAND in exclusive groups lookup - migrate all commands to new constraint syntax - add helpful error for unknown constraint args --- commands/activity/show.sh | 22 ++-- commands/block/show.sh | 22 ++-- commands/config.command.sh | 180 ++++++++++++++++----------------- commands/config/config.sh | 10 ++ commands/config/helpers.sh | 169 +++++++++++++++++++++++++++++++ commands/config/migrate.sh | 17 ++++ commands/config/show.sh | 24 +++++ commands/list/show.sh | 22 ++-- commands/logs/remove.sh | 4 +- commands/logs/rotate.sh | 2 +- commands/logs/show.sh | 30 +++--- commands/peer/peer.sh | 9 ++ commands/peer/update-dns.sh | 62 ++++++++++++ commands/peer/update-tunnel.sh | 57 +++++++++++ commands/test/integration.sh | 6 +- commands/unblock/show.sh | 22 ++-- core/framework/command.sh | 42 ++++---- core/framework/flag.sh | 169 +++++++++++++++++++++++++------ 18 files changed, 664 insertions(+), 205 deletions(-) create mode 100644 commands/config/config.sh create mode 100644 commands/config/helpers.sh create mode 100644 commands/config/migrate.sh create mode 100644 commands/config/show.sh create mode 100644 commands/peer/peer.sh create mode 100644 commands/peer/update-dns.sh create mode 100644 commands/peer/update-tunnel.sh diff --git a/commands/activity/show.sh b/commands/activity/show.sh index 77f7882..0b00f48 100644 --- a/commands/activity/show.sh +++ b/commands/activity/show.sh @@ -5,19 +5,19 @@ 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"] + 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::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 } diff --git a/commands/block/show.sh b/commands/block/show.sh index 7e7b778..6acaa0d 100644 --- a/commands/block/show.sh +++ b/commands/block/show.sh @@ -3,21 +3,21 @@ 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"] + 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"] + 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::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 } diff --git a/commands/config.command.sh b/commands/config.command.sh index 27a5396..d8af5fb 100644 --- a/commands/config.command.sh +++ b/commands/config.command.sh @@ -184,98 +184,98 @@ function cmd::config::migrate() { || log::wg_success "Migration complete" } -function config::_convert_to_json() { - local legacy_file="$1" output_file="$2" +# function config::_convert_to_json() { +# local legacy_file="$1" output_file="$2" - # Read legacy conf into variables - local wg_interface="wg0" wg_endpoint="" wg_dns="10.0.0.103" - local wg_dns_fallback="" wg_port="51820" wg_subnet="10.1.0.0/16" - local wg_lan="10.0.0.0/24" wg_hs_check="300" date_format="eu" +# # Read legacy conf into variables +# local wg_interface="wg0" wg_endpoint="" wg_dns="10.0.0.103" +# local wg_dns_fallback="" wg_port="51820" wg_subnet="10.1.0.0/16" +# local wg_lan="10.0.0.0/24" wg_hs_check="300" date_format="eu" - while IFS='=' read -r key value || [[ -n "$key" ]]; do - [[ "$key" =~ ^[[:space:]]*# ]] && continue - [[ -z "${key// }" ]] && continue - key="${key// /}" - value="${value// /}" - case "$key" in - WG_INTERFACE) wg_interface="$value" ;; - WG_ENDPOINT) wg_endpoint="$value" ;; - WG_DNS) wg_dns="$value" ;; - WG_DNS_FALLBACK) wg_dns_fallback="$value" ;; - WG_PORT) wg_port="$value" ;; - WG_SUBNET) wg_subnet="$value" ;; - WG_LAN) wg_lan="$value" ;; - WG_HANDSHAKE_CHECK_TIME_SEC) wg_hs_check="$value" ;; - DATE_FORMAT) date_format="$value" ;; - esac - done < "$legacy_file" +# while IFS='=' read -r key value || [[ -n "$key" ]]; do +# [[ "$key" =~ ^[[:space:]]*# ]] && continue +# [[ -z "${key// }" ]] && continue +# key="${key// /}" +# value="${value// /}" +# case "$key" in +# WG_INTERFACE) wg_interface="$value" ;; +# WG_ENDPOINT) wg_endpoint="$value" ;; +# WG_DNS) wg_dns="$value" ;; +# WG_DNS_FALLBACK) wg_dns_fallback="$value" ;; +# WG_PORT) wg_port="$value" ;; +# WG_SUBNET) wg_subnet="$value" ;; +# WG_LAN) wg_lan="$value" ;; +# WG_HANDSHAKE_CHECK_TIME_SEC) wg_hs_check="$value" ;; +# DATE_FORMAT) date_format="$value" ;; +# esac +# done < "$legacy_file" - # Build fallback DNS array - local dns_fallback_json="[]" - if [[ -n "$wg_dns_fallback" ]]; then - local fallback_array - fallback_array=$(echo "$wg_dns_fallback" | tr ',' '\n' | \ - while IFS= read -r s; do - s="${s// /}" - [[ -n "$s" ]] && printf '"%s",' "$s" - done | sed 's/,$//') - dns_fallback_json="[${fallback_array}]" - fi +# # Build fallback DNS array +# local dns_fallback_json="[]" +# if [[ -n "$wg_dns_fallback" ]]; then +# local fallback_array +# fallback_array=$(echo "$wg_dns_fallback" | tr ',' '\n' | \ +# while IFS= read -r s; do +# s="${s// /}" +# [[ -n "$s" ]] && printf '"%s",' "$s" +# done | sed 's/,$//') +# dns_fallback_json="[${fallback_array}]" +# fi - mkdir -p "$(dirname "$output_file")" - cat > "$output_file" << JSON -{ - "wireguard": { - "interface": "${wg_interface}", - "endpoint": "${wg_endpoint}", - "port": ${wg_port}, - "subnet": "${wg_subnet}", - "lan": "${wg_lan}" - }, - "dns": { - "primary": "${wg_dns}", - "fallback": ${dns_fallback_json} - }, - "handshake": { - "check_interval_sec": ${wg_hs_check} - }, - "activity": { - "total": {"low": 1000000, "medium": 10000000, "high": 100000000}, - "current": {"low": 1000000, "medium": 10000000, "high": 100000000} - }, - "display": { - "date_format": "${date_format}" - } -} -JSON -} +# mkdir -p "$(dirname "$output_file")" +# cat > "$output_file" << JSON +# { +# "wireguard": { +# "interface": "${wg_interface}", +# "endpoint": "${wg_endpoint}", +# "port": ${wg_port}, +# "subnet": "${wg_subnet}", +# "lan": "${wg_lan}" +# }, +# "dns": { +# "primary": "${wg_dns}", +# "fallback": ${dns_fallback_json} +# }, +# "handshake": { +# "check_interval_sec": ${wg_hs_check} +# }, +# "activity": { +# "total": {"low": 1000000, "medium": 10000000, "high": 100000000}, +# "current": {"low": 1000000, "medium": 10000000, "high": 100000000} +# }, +# "display": { +# "date_format": "${date_format}" +# } +# } +# JSON +# } -function config::_write_default_json() { - local output_file="$1" - mkdir -p "$(dirname "$output_file")" - cat > "$output_file" << 'JSON' -{ - "wireguard": { - "interface": "wg0", - "endpoint": "", - "port": 51820, - "subnet": "10.1.0.0/16", - "lan": "10.0.0.0/24" - }, - "dns": { - "primary": "10.0.0.103", - "fallback": [] - }, - "handshake": { - "check_interval_sec": 300 - }, - "activity": { - "total": {"low": 1000000, "medium": 10000000, "high": 100000000}, - "current": {"low": 1000000, "medium": 10000000, "high": 100000000} - }, - "display": { - "date_format": "eu" - } -} -JSON -} \ No newline at end of file +# function config::_write_default_json() { +# local output_file="$1" +# mkdir -p "$(dirname "$output_file")" +# cat > "$output_file" << 'JSON' +# { +# "wireguard": { +# "interface": "wg0", +# "endpoint": "", +# "port": 51820, +# "subnet": "10.1.0.0/16", +# "lan": "10.0.0.0/24" +# }, +# "dns": { +# "primary": "10.0.0.103", +# "fallback": [] +# }, +# "handshake": { +# "check_interval_sec": 300 +# }, +# "activity": { +# "total": {"low": 1000000, "medium": 10000000, "high": 100000000}, +# "current": {"low": 1000000, "medium": 10000000, "high": 100000000} +# }, +# "display": { +# "date_format": "eu" +# } +# } +# JSON +# } \ No newline at end of file diff --git a/commands/config/config.sh b/commands/config/config.sh new file mode 100644 index 0000000..21ee726 --- /dev/null +++ b/commands/config/config.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# commands/config/config.sh — router only + +function cmd::config::on_load() { + command::helpers "helpers.sh" + command::define show "Show client config" [*] + command::define migrate "Migrate config to JSON" [m] +} + +hook::on "command:help:config" command::help::auto \ No newline at end of file diff --git a/commands/config/helpers.sh b/commands/config/helpers.sh new file mode 100644 index 0000000..70ba70d --- /dev/null +++ b/commands/config/helpers.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# commands/config/helpers.sh + +function cmd::config::_migrate_impl() { + local force="$1" dry_run="$2" + + local wgctl_dir; wgctl_dir="$(ctx::wgctl)" + local config_dir="${wgctl_dir}/config" + local data_dir="${wgctl_dir}/data" + local legacy_conf="${wgctl_dir}/wgctl.conf" + local json_conf="${config_dir}/wgctl.json" + + if [[ -f "$json_conf" && ! -f "$legacy_conf" ]]; then + log::wg_warning "Already migrated to new config structure" + return 0 + fi + + log::section "wgctl Config Migration" + printf "\n" + printf " This will:\n" + printf " 1. Create %s/config/ and %s/data/\n" "$wgctl_dir" "$wgctl_dir" + printf " 2. Convert wgctl.conf → wgctl.json\n" + printf " 3. Move data files to data/\n\n" + + if [[ "$force" != "true" && "$dry_run" != "true" ]]; then + read -r -p " Proceed? [y/N] " confirm + case "$confirm" in [yY]*) ;; *) log::info "Aborted"; return 0 ;; esac + fi + + $dry_run || mkdir -p "$config_dir" "$data_dir" + $dry_run && printf " Would create: %s/config/\n" "$wgctl_dir" + $dry_run && printf " Would create: %s/data/\n" "$wgctl_dir" + + if [[ -f "$legacy_conf" ]]; then + $dry_run || config::_convert_to_json "$legacy_conf" "$json_conf" + printf " %s wgctl.conf → config/wgctl.json\n" \ + "$($dry_run && echo '[dry-run]' || echo '✓')" + else + log::wg_warning "No wgctl.conf found — creating default wgctl.json" + $dry_run || config::_write_default_json "$json_conf" + fi + + local -a data_files=(hosts.json services.json subnets.json policies.json) + local -a data_dirs=(rules identities groups blocks meta peer-history) + + for f in "${data_files[@]}"; do + if [[ -f "${wgctl_dir}/${f}" ]]; then + $dry_run || mv "${wgctl_dir}/${f}" "${data_dir}/${f}" + printf " %s %s → data/%s\n" \ + "$($dry_run && echo '[dry-run]' || echo '✓')" "$f" "$f" + fi + done + + for d in "${data_dirs[@]}"; do + if [[ -d "${wgctl_dir}/${d}" ]]; then + $dry_run || mv "${wgctl_dir}/${d}" "${data_dir}/${d}" + printf " %s %s/ → data/%s/\n" \ + "$($dry_run && echo '[dry-run]' || echo '✓')" "$d" "$d" + fi + done + + if [[ "$dry_run" != "true" && -f "$legacy_conf" ]]; then + mv "$legacy_conf" "${legacy_conf}.bak" + printf " ✓ wgctl.conf → wgctl.conf.bak (backup)\n" + fi + + printf "\n" + [[ "$dry_run" == "true" ]] \ + && log::wg_warning "Dry run — no changes made" \ + || log::wg_success "Migration complete" +} + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +function config::_convert_to_json() { + local legacy_file="$1" output_file="$2" + + # Read legacy conf into variables + local wg_interface="wg0" wg_endpoint="" wg_dns="10.0.0.103" + local wg_dns_fallback="" wg_port="51820" wg_subnet="10.1.0.0/16" + local wg_lan="10.0.0.0/24" wg_hs_check="300" date_format="eu" + + while IFS='=' read -r key value || [[ -n "$key" ]]; do + [[ "$key" =~ ^[[:space:]]*# ]] && continue + [[ -z "${key// }" ]] && continue + key="${key// /}" + value="${value// /}" + case "$key" in + WG_INTERFACE) wg_interface="$value" ;; + WG_ENDPOINT) wg_endpoint="$value" ;; + WG_DNS) wg_dns="$value" ;; + WG_DNS_FALLBACK) wg_dns_fallback="$value" ;; + WG_PORT) wg_port="$value" ;; + WG_SUBNET) wg_subnet="$value" ;; + WG_LAN) wg_lan="$value" ;; + WG_HANDSHAKE_CHECK_TIME_SEC) wg_hs_check="$value" ;; + DATE_FORMAT) date_format="$value" ;; + esac + done < "$legacy_file" + + # Build fallback DNS array + local dns_fallback_json="[]" + if [[ -n "$wg_dns_fallback" ]]; then + local fallback_array + fallback_array=$(echo "$wg_dns_fallback" | tr ',' '\n' | \ + while IFS= read -r s; do + s="${s// /}" + [[ -n "$s" ]] && printf '"%s",' "$s" + done | sed 's/,$//') + dns_fallback_json="[${fallback_array}]" + fi + + mkdir -p "$(dirname "$output_file")" + cat > "$output_file" << JSON +{ + "wireguard": { + "interface": "${wg_interface}", + "endpoint": "${wg_endpoint}", + "port": ${wg_port}, + "subnet": "${wg_subnet}", + "lan": "${wg_lan}" + }, + "dns": { + "primary": "${wg_dns}", + "fallback": ${dns_fallback_json} + }, + "handshake": { + "check_interval_sec": ${wg_hs_check} + }, + "activity": { + "total": {"low": 1000000, "medium": 10000000, "high": 100000000}, + "current": {"low": 1000000, "medium": 10000000, "high": 100000000} + }, + "display": { + "date_format": "${date_format}" + } +} +JSON +} + +function config::_write_default_json() { + local output_file="$1" + mkdir -p "$(dirname "$output_file")" + cat > "$output_file" << 'JSON' +{ + "wireguard": { + "interface": "wg0", + "endpoint": "", + "port": 51820, + "subnet": "10.1.0.0/16", + "lan": "10.0.0.0/24" + }, + "dns": { + "primary": "10.0.0.103", + "fallback": [] + }, + "handshake": { + "check_interval_sec": 300 + }, + "activity": { + "total": {"low": 1000000, "medium": 10000000, "high": 100000000}, + "current": {"low": 1000000, "medium": 10000000, "high": 100000000} + }, + "display": { + "date_format": "eu" + } +} +JSON +} \ No newline at end of file diff --git a/commands/config/migrate.sh b/commands/config/migrate.sh new file mode 100644 index 0000000..c44f027 --- /dev/null +++ b/commands/config/migrate.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# commands/config/migrate.sh + +function cmd::config::migrate::on_load() { + flag::define --force bool "Skip confirmation" section:Options + flag::define --dry-run bool "Show what would be done" section:Options +} + +function cmd::config::migrate::run() { + flag::parse "$@" || return 1 + + local force=false dry_run=false + flag::bool --force && force=true + flag::bool --dry-run && dry_run=true + + cmd::config::_migrate_impl "$force" "$dry_run" +} \ No newline at end of file diff --git a/commands/config/show.sh b/commands/config/show.sh new file mode 100644 index 0000000..166c5c4 --- /dev/null +++ b/commands/config/show.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# commands/config/show.sh + +function cmd::config::show::on_load() { + help::section "Filters" + flag::define --name value "Peer name" label:name required:true section:Filters + flag::define --type value "Filter by type" label:type section:Filters +} + +function cmd::config::show::run() { + flag::parse "$@" || return 1 + + local name; name=$(flag::value --name) + local type; type=$(flag::value --type) + + [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1 + name=$(peers::resolve_and_require "$name" "$type") || return 1 + + local conf + conf="$(ctx::clients)/${name}.conf" + + log::section "Client Config: ${name}" + cat "$conf" +} \ No newline at end of file diff --git a/commands/list/show.sh b/commands/list/show.sh index 32c213a..15d6a11 100644 --- a/commands/list/show.sh +++ b/commands/list/show.sh @@ -5,19 +5,19 @@ function cmd::list::show::on_load() { command::mixin json_output [section="Output"] help::section "Filters" - flag::define --name value "Show single peer" [label="name", section="Filters"] - flag::define --type value "Filter by device type" [label="type", section="Filters"] - flag::define --rule value "Filter by rule" [label="rule", section="Filters"] - flag::define --group value "Filter by group" [label="group", section="Filters"] - flag::define --identity value "Filter by identity" [label="identity", section="Filters"] - flag::define --online bool "Show online peers only" [section="Filters"] - flag::define --offline bool "Show offline peers only" [section="Filters"] - flag::define --restricted bool "Show restricted peers" [section="Filters"] - flag::define --blocked bool "Show blocked peers" [section="Filters"] - flag::define --allowed bool "Show allowed peers" [section="Filters"] + flag::define --name value "Show single peer" label:name section:Filters + flag::define --type value "Filter by device type" label:type section:Filters + flag::define --rule value "Filter by rule" label:rule section:Filters + flag::define --group value "Filter by group" label:group section:Filters + flag::define --identity value "Filter by identity" label:identity section:Filters + flag::define --online bool "Show online peers only" section:Filters + flag::define --offline bool "Show offline peers only" section:Filters + flag::define --restricted bool "Show restricted peers" section:Filters + flag::define --blocked bool "Show blocked peers" section:Filters + flag::define --allowed bool "Show allowed peers" section:Filters help::section "Output" - flag::define --detailed bool "Show detailed view" [section="Output"] + flag::define --detailed bool "Show detailed view" section:Output flag::exclusive --online --offline --blocked --restricted --allowed } diff --git a/commands/logs/remove.sh b/commands/logs/remove.sh index 014213b..7008d7a 100644 --- a/commands/logs/remove.sh +++ b/commands/logs/remove.sh @@ -2,8 +2,8 @@ # commands/logs/remove.sh function cmd::logs::remove::on_load() { - flag::define --name value "Filter by peer name" [label="name"] - flag::define --since value "Remove entries since" [label="time"] + flag::define --name value "Filter by peer name" label:name + flag::define --since value "Remove entries since" label:time flag::define --fw bool "Remove firewall logs only" flag::define --wg bool "Remove WireGuard logs only" flag::define --force bool "Skip confirmation" diff --git a/commands/logs/rotate.sh b/commands/logs/rotate.sh index b0f5796..3491fe6 100644 --- a/commands/logs/rotate.sh +++ b/commands/logs/rotate.sh @@ -2,7 +2,7 @@ # commands/logs/rotate.sh function cmd::logs::rotate::on_load() { - flag::define --days value "Remove entries older than N days" [default=30, type=int, min=1, label="days"] + flag::define --days value "Remove entries older than N days" default:30 type:int min:1 label:days flag::define --force bool "Skip confirmation" flag::define --dry-run bool "Show what would be removed" } diff --git a/commands/logs/show.sh b/commands/logs/show.sh index 6f221d3..1b924b8 100644 --- a/commands/logs/show.sh +++ b/commands/logs/show.sh @@ -8,23 +8,23 @@ function cmd::logs::show::on_load() { command::mixin json_output [section="Output"] help::section "Filters" - flag::define --name value "Filter by peer name" [label="name", section="Filters"] - flag::define --type value "Filter by device type" [label="type", section="Filters"] - flag::define --since value "Show events since (2h, 7d)" [label="time", section="Filters"] - flag::define --service value "Filter by service/IP" [label="service", section="Filters"] - flag::define --event value "Filter wg events" [label="type", section="Filters"] - flag::define --fw bool "Show firewall drops only" [section="Filters"] - flag::define --wg bool "Show WireGuard events only" [section="Filters"] - flag::define --merged bool "Show all events interleaved" [section="Filters"] + flag::define --name value "Filter by peer name" label:name section:Filters + flag::define --type value "Filter by device type" label:type section:Filters + flag::define --since value "Show events since (2h, 7d)" label:time section:Filters + flag::define --service value "Filter by service/IP" label:service section:Filters + flag::define --event value "Filter wg events" label:type section:Filters + flag::define --fw bool "Show firewall drops only" section:Filters + flag::define --wg bool "Show WireGuard events only" section:Filters + flag::define --merged bool "Show all events interleaved" section:Filters help::section "Output" - flag::define --limit value "Max results per source" [default=50, type=int, min=1, section="Output"] - flag::define --ascending bool "Sort ascending" [section="Output"] - flag::define --descending bool "Sort descending" [section="Output"] - flag::define --resolved bool "Resolve endpoints" [section="Output"] - flag::define --detailed bool "Show per-event detail" [section="Output"] - flag::define --raw bool "Skip service resolution" [section="Output"] - flag::define --follow bool "Follow live" [section="Output"] + flag::define --limit value "Max results per source" default:50 type:int min:1 section:Output + flag::define --ascending bool "Sort ascending" section:Output + flag::define --descending bool "Sort descending" section:Output + flag::define --resolved bool "Resolve endpoints" section:Output + flag::define --detailed bool "Show per-event detail" section:Output + flag::define --raw bool "Skip service resolution" section:Output + flag::define --follow bool "Follow live" section:Output flag::exclusive --fw --wg flag::exclusive --ascending --descending diff --git a/commands/peer/peer.sh b/commands/peer/peer.sh new file mode 100644 index 0000000..ac2bd46 --- /dev/null +++ b/commands/peer/peer.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# commands/peer/peer.sh — router only + +function cmd::peer::on_load() { + command::define update-dns "Update DNS servers in peer configs" [dns] + command::define update-tunnel "Update tunnel mode for peer configs" [tunnel] +} + +hook::on "command:help:peer" command::help::auto \ No newline at end of file diff --git a/commands/peer/update-dns.sh b/commands/peer/update-dns.sh new file mode 100644 index 0000000..14e94fc --- /dev/null +++ b/commands/peer/update-dns.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# commands/peer/update-dns.sh + +function cmd::peer::update_dns::on_load() { + help::section "Target" + flag::define --name value "Peer name" label:name section:Target + flag::define --type value "Filter by type" label:type section:Target + flag::define --all bool "Update all peers" section:Target + + help::section "DNS" + flag::define --dns value "Primary DNS server" label:ip section:DNS + flag::define --fallback-dns value "Fallback DNS servers" label:ips section:DNS +} + +function cmd::peer::update_dns::run() { + flag::parse "$@" || return 1 + + local name; name=$(flag::value --name) + local type; type=$(flag::value --type) + local dns; dns=$(flag::value --dns) + local fallback_dns; fallback_dns=$(flag::value --fallback-dns) + local all=false + flag::bool --all && all=true + + [[ -z "$name" && "$all" == "false" ]] && \ + log::error "Specify --name or --all" && return 1 + + local primary="${dns:-$(config::dns)}" + local fallback="${fallback_dns:-$(config::dns_fallback)}" + local dns_string + if [[ -n "$fallback" ]]; then + dns_string="${primary}, ${fallback}" + else + dns_string="$primary" + fi + + local -a peers=() + if $all; then + while IFS= read -r conf; do + peers+=("$(basename "$conf" .conf)") + done < <(find "$(ctx::clients)" -name "*.conf" 2>/dev/null) + else + name=$(peers::resolve_and_require "$name" "$type") || return 1 + peers=("$name") + fi + + local updated=0 + for peer_name in "${peers[@]}"; do + local conf + conf="$(ctx::clients)/${peer_name}.conf" + [[ ! -f "$conf" ]] && continue + if grep -q "^DNS" "$conf"; then + sed -i "s|^DNS = .*|DNS = ${dns_string}|" "$conf" + else + sed -i "/^Address/a DNS = ${dns_string}" "$conf" + fi + (( updated++ )) || true + log::debug "Updated DNS for: ${peer_name}" + done + + log::wg_success "Updated DNS to '${dns_string}' for ${updated} peer(s)" +} \ No newline at end of file diff --git a/commands/peer/update-tunnel.sh b/commands/peer/update-tunnel.sh new file mode 100644 index 0000000..9ff6a61 --- /dev/null +++ b/commands/peer/update-tunnel.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# commands/peer/update-tunnel.sh + +function cmd::peer::update_tunnel::on_load() { + help::section "Target" + flag::define --name value "desc" label:name section:Filters + flag::define --type value "Filter by type" label:type section:Target + flag::define --all bool "Update all peers" section:Target + + help::section "Options" + flag::define --mode value "Tunnel mode" label:mode required choices:split,full section:Options + flag::define --force bool "Skip confirmation for --all" section:Options +} + +function cmd::peer::update_tunnel::run() { + flag::parse "$@" || return 1 + + local name; name=$(flag::value --name) + local type; type=$(flag::value --type) + local mode; mode=$(flag::value --mode) + local all=false force=false + flag::bool --all && all=true + flag::bool --force && force=true + + [[ -z "$name" && "$all" == "false" ]] && \ + log::error "Specify --name or --all" && return 1 + + local allowed_ips + allowed_ips=$(config::allowed_ips_for "$mode") + + local -a peers=() + if $all; then + if ! $force; then + read -r -p "Update tunnel mode to '${mode}' for ALL peers? [y/N] " confirm + case "$confirm" in [yY]*) ;; *) log::info "Aborted"; return 0 ;; esac + fi + while IFS= read -r conf; do + peers+=("$(basename "$conf" .conf)") + done < <(find "$(ctx::clients)" -name "*.conf" 2>/dev/null) + else + name=$(peers::resolve_and_require "$name" "$type") || return 1 + peers=("$name") + fi + + local updated=0 + for peer_name in "${peers[@]}"; do + local conf + conf="$(ctx::clients)/${peer_name}.conf" + [[ ! -f "$conf" ]] && continue + sed -i "s|^AllowedIPs = .*|AllowedIPs = ${allowed_ips}|" "$conf" + (( updated++ )) || true + log::debug "Updated tunnel for: ${peer_name}" + done + + log::wg_success "Updated tunnel to '${mode}' (${allowed_ips}) for ${updated} peer(s)" + log::wg "Peers must reconnect to apply the new tunnel mode" +} \ No newline at end of file diff --git a/commands/test/integration.sh b/commands/test/integration.sh index 9253e98..81db294 100644 --- a/commands/test/integration.sh +++ b/commands/test/integration.sh @@ -380,9 +380,9 @@ function cmd::test::section_identity() { function cmd::test::section_activity() { test::section "Activity" cmd::test::run_cmd "activity" "Activity" activity - cmd::test::run_cmd "activity --json" '"peers":' activity --json - cmd::test::run_cmd "activity --json services" '"services":' activity --json - cmd::test::run_cmd "activity --json rx" '"rx":' activity --json + cmd::test::run_cmd "activity --json" '"command":"activity"' activity --json + cmd::test::run_cmd "activity --json has data" '"data":' activity --json + cmd::test::run_cmd "activity --json has rx" '"rx":' activity --json } function cmd::test::section_policy() { diff --git a/commands/unblock/show.sh b/commands/unblock/show.sh index 65e5a2b..934535f 100644 --- a/commands/unblock/show.sh +++ b/commands/unblock/show.sh @@ -3,21 +3,21 @@ 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"] + 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"] + 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::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 } diff --git a/core/framework/command.sh b/core/framework/command.sh index ce1e4a2..456bf24 100644 --- a/core/framework/command.sh +++ b/core/framework/command.sh @@ -162,6 +162,7 @@ function command::route() { # Lazy-loads a subcommand's file and calls its on_load function command::load_subcmd() { local cmd="$1" subcmd="$2" + # log::debug "load_subcmd: cmd=$cmd subcmd=$subcmd" [[ -z "$cmd" || -z "$subcmd" ]] && return 1 # Determine file path — commands//.sh @@ -169,13 +170,20 @@ function command::load_subcmd() { cmd_dir="${WGCTL_DIR}/commands/${cmd}" local subcmd_file="${cmd_dir}/${subcmd}.sh" + # log::debug "looking for: $subcmd_file exists=$([ -f "$subcmd_file" ] && echo yes || echo no)" [[ ! -f "$subcmd_file" ]] && return 1 + local fn_subcmd="${subcmd//-/_}" + local on_load_fn="cmd::${cmd}::${fn_subcmd}::on_load" + _CURRENT_LOADING_CMD="${cmd}::${subcmd}" _CURRENT_COMMAND="${cmd}::${subcmd}" source "$subcmd_file" - local on_load_fn="cmd::${cmd}::${subcmd}::on_load" + # log::debug "sourced: $subcmd_file exit=$?" + # log::debug "on_load_fn=$on_load_fn exists=$(declare -f "$on_load_fn" &>/dev/null && echo yes || echo no)" + + # log::debug "load_subcmd: calling on_load, _CURRENT_COMMAND=$_CURRENT_COMMAND _CURRENT_LOADING_CMD=$_CURRENT_LOADING_CMD" if declare -f "$on_load_fn" &>/dev/null; then "$on_load_fn" fi @@ -235,7 +243,8 @@ function command::run_routed() { command::_preprocess_flags final_args # Run - local run_fn="cmd::${cmd}::${subcmd}::run" + local fn_subcmd="${subcmd//-/_}" + local run_fn="cmd::${cmd}::${fn_subcmd}::run" if declare -f "$run_fn" &>/dev/null; then if [[ ${#final_args[@]} -gt 0 ]]; then "$run_fn" "${final_args[@]}" @@ -325,23 +334,22 @@ function command::run() { local subcmd="$_ROUTED_SUBCMD" local -a routed_args=("${_ROUTED_ARGS[@]:-}") - # Lazy load subcommand file - local subcmd_file="$(ctx::commands)/${cmd}/${subcmd}.sh" - if [[ -f "$subcmd_file" ]]; then - _CURRENT_LOADING_CMD="${cmd}::${subcmd}" - _CURRENT_COMMAND="${cmd}::${subcmd}" - source "$subcmd_file" - core::call_if_exists "cmd::${cmd}::${subcmd}::on_load" - _CURRENT_LOADING_CMD="" - - for arg in "$@"; do - [[ "$arg" == "--help" || "$arg" == "-h" ]] && { - hook::fire "command:help:${cmd}" "$cmd" "$_ROUTED_SUBCMD" - return 0 - } - done + if [[ -z "$subcmd" ]]; then + hook::fire "command:help:${cmd}" "$cmd" "" + return 0 fi + # log::debug "about to load_subcmd: cmd=$cmd subcmd=$subcmd" + # Lazy load subcommand file + command::load_subcmd "$cmd" "$subcmd" + + for arg in "$@"; do + [[ "$arg" == "--help" || "$arg" == "-h" ]] && { + hook::fire "command:help:${cmd}" "$cmd" "$_ROUTED_SUBCMD" + return 0 + } + done + command::run_routed "$cmd" "$subcmd" "${routed_args[@]:-}" return $? fi diff --git a/core/framework/flag.sh b/core/framework/flag.sh index 245e6cc..5c9b8df 100644 --- a/core/framework/flag.sh +++ b/core/framework/flag.sh @@ -48,6 +48,7 @@ function flag::_extract_constraint() { function flag::_parse_and_cache() { local key="$1" constraints="$2" [[ -z "$constraints" ]] && return 0 + # log::debug "parse_and_cache: key=$key" local v v=$(flag::_extract_constraint "$constraints" "default") [[ -n "$v" ]] && _FLAG_C_DEFAULT["$key"]="$v" @@ -58,6 +59,7 @@ function flag::_parse_and_cache() { v=$(flag::_extract_constraint "$constraints" "max") [[ -n "$v" ]] && _FLAG_C_MAX["$key"]="$v" v=$(flag::_extract_constraint "$constraints" "choices") + # log::debug "parse_and_cache: choices='$v'" [[ -n "$v" ]] && _FLAG_C_CHOICES["$key"]="$v" v=$(flag::_extract_constraint "$constraints" "required") [[ "$v" == "true" ]] && _FLAG_C_REQUIRED["$key"]="true" @@ -67,6 +69,38 @@ function flag::_parse_and_cache() { [[ -n "$v" ]] && _FLAG_C_SECTION["$key"]="$v" } +# ── Constraints ──────────────────────────────────────── + +# flag::set_constraint [value] +# Set a constraint on an already-defined flag (Option B syntax) +# Examples: +# flag::set_constraint --mode choices "split|full" +# flag::set_constraint --mode required +# flag::set_constraint --mode section Options +function flag::set_constraint() { + local flag="$1" k="$2" v="${3:-}" + local ctx="${_CURRENT_COMMAND:-__global__}" + local key="${ctx}:${flag}" + + [[ -z "${_FLAG_REGISTRY[$key]+x}" ]] && \ + log::error "flag::set_constraint: flag not defined: ${flag}" && return 1 + + case "$k" in + label) _FLAG_C_LABEL["$key"]="$v" ;; + default) _FLAG_C_DEFAULT["$key"]="$v" ;; + type) _FLAG_C_TYPE["$key"]="$v" ;; + min) _FLAG_C_MIN["$key"]="$v" ;; + max) _FLAG_C_MAX["$key"]="$v" ;; + choices) _FLAG_C_CHOICES["$key"]="$v" ;; + section) + _FLAG_C_SECTION["$key"]="$v" + declare -f help::_assign_flag_from_cache &>/dev/null && \ + help::_assign_flag_from_cache "$flag" "$v" + ;; + required) _FLAG_C_REQUIRED["$key"]="true" ;; + esac +} + # ── Context ─────────────────────────────────────────────────────────────── function flag::_context() { printf '%s' "${_CURRENT_COMMAND:-__global__}"; } @@ -74,41 +108,70 @@ function flag::_key() { printf '%s:%s' "${_CURRENT_COMMAND:-__global__}" "$1 # ── Registration ────────────────────────────────────────────────────────── +# flag::define [bool|value] "description" [constraints...] +# flag::define "description" [constraints...] +# +# Constraints (positional, after description): +# label:name Display label in help +# default:50 Default value +# type:int Value type (int, string) +# min:1 Minimum value (int) +# max:100 Maximum value (int) +# choices:a|b|c Allowed values (pipe-separated) +# section:Filters Help section +# required Mark as required (no value needed) +# +# Examples: +# flag::define --fw bool "Firewall only" section:Filters +# flag::define --limit value "Max results" default:50 type:int min:1 section:Output +# flag::define --mode value "Tunnel mode" required choices:split|full section:Options +# flag::define --svc[] "Exclude service" label:service section:Filters function flag::define() { - local raw_flag="$1" is_array=false - + local raw_flag="$1" + local is_array=false + if [[ "$raw_flag" == *"[]" ]]; then - is_array=true; raw_flag="${raw_flag%[]}" + is_array=true + raw_flag="${raw_flag%[]}" fi - - local type description constraints="" + + local type description + local -a constraints=() + if $is_array; then - type="array"; description="${2:-}"; constraints="${3:-}" + type="array" + description="${2:-}" + shift 2 + constraints=("$@") else - type="${2:-bool}"; description="${3:-}"; constraints="${4:-}" + type="${2:-bool}" + description="${3:-}" + shift 3 + constraints=("$@") fi - + local ctx="${_CURRENT_COMMAND:-__global__}" local key="${ctx}:${raw_flag}" - + _FLAG_REGISTRY["$key"]="${type}|${description}" - - # Per-command index — fast lookup in flag::parse _FLAG_INDEX["$ctx"]+=" ${raw_flag}" - - # Pre-parse constraints - flag::_parse_and_cache "$key" "$constraints" - + + # Parse constraints + flag::_parse_constraints_from_args "$key" "${constraints[@]:-}" + # Bool default if [[ "$type" == "bool" && -z "${_FLAG_C_DEFAULT[$key]:-}" ]]; then _FLAG_C_DEFAULT["$key"]="false" fi - - # 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]:-}" + + # Help section — constraint takes precedence, then active section + local section="${_FLAG_C_SECTION[$key]:-${_CURRENT_HELP_SECTION:-}}" + if [[ -n "$section" ]]; then + _FLAG_C_SECTION["$key"]="$section" + declare -f help::_assign_flag_from_cache &>/dev/null && \ + help::_assign_flag_from_cache "$raw_flag" "$section" fi - + _FLAG_COMPLETION["$raw_flag"]=1 } @@ -118,6 +181,7 @@ function flag::register() { _FLAG_COMPLETION["${1:-}"]=1; } function flag::parse() { local ctx="${_CURRENT_COMMAND:-__global__}" + # log::debug "ctx=$ctx index='${_FLAG_INDEX[$ctx]:-EMPTY}'" >&2 # Reset runtime _FLAG_VALUES=(); _FLAG_ARRAYS=(); _FLAG_SET=(); _FLAG_ARGS=() @@ -189,17 +253,20 @@ function flag::parse() { 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 + # log::debug "choices check: flag=$arg val=$val choices='${_FLAG_C_CHOICES[$key]:-EMPTY}'" + if [[ -n "$choices" ]]; then + local valid=false + local choice + local saved_ifs="$IFS" + IFS=',' read -ra choice_list <<< "$choices" + IFS="$saved_ifs" + 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 _FLAG_VALUES["$arg"]="$val" _FLAG_SET["$arg"]="1" @@ -230,7 +297,10 @@ function flag::parse() { done # Validate exclusive groups - local groups="${_FLAG_EXCLUSIVE_GROUPS[${_CURRENT_COMMAND%%::*}]:-}" + local cmd="${_CURRENT_COMMAND%%::*}" + local groups="" + [[ -n "$cmd" ]] && groups="${_FLAG_EXCLUSIVE_GROUPS[$cmd]:-}" + if [[ -n "$groups" ]]; then local group while IFS= read -r group; do @@ -246,10 +316,43 @@ function flag::parse() { fi done < <(printf '%s' "$groups" | tr '|' '\n') fi - return 0 } +# flag::_parse_constraints_from_args +# Parse variadic constraint args: label:x required choices:a|b section:x +function flag::_parse_constraints_from_args() { + local key="$1" + shift + local arg k v + + for arg in "$@"; do + # Handle bare keywords (no colon) — e.g. "required" + if [[ "$arg" != *:* ]]; then + case "$arg" in + required) _FLAG_C_REQUIRED["$key"]="true" ;; + optional) ;; # default, no-op + esac + continue + fi + + k="${arg%%:*}" + v="${arg#*:}" + + case "$k" in + label) _FLAG_C_LABEL["$key"]="$v" ;; + default) _FLAG_C_DEFAULT["$key"]="$v" ;; + type) _FLAG_C_TYPE["$key"]="$v" ;; + min) _FLAG_C_MIN["$key"]="$v" ;; + max) _FLAG_C_MAX["$key"]="$v" ;; + choices) _FLAG_C_CHOICES["$key"]="$v" ;; + section) _FLAG_C_SECTION["$key"]="$v" ;; + required) _FLAG_C_REQUIRED["$key"]="$v" ;; + esac + done +} + + # ── Accessors ───────────────────────────────────────────────────────────── function flag::bool() { [[ "${_FLAG_VALUES[$1]:-false}" == "true" ]]; }