feat: new flag::define syntax, flag::set_constraint

- 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
This commit is contained in:
Nuno Duque Nunes 2026-05-31 00:16:55 +00:00
parent f61bc59446
commit a559b73e8e
18 changed files with 664 additions and 205 deletions

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}
# 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
# }

10
commands/config/config.sh Normal file
View file

@ -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

169
commands/config/helpers.sh Normal file
View file

@ -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
}

View file

@ -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"
}

24
commands/config/show.sh Normal file
View file

@ -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"
}

View file

@ -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
}

View file

@ -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"

View file

@ -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"
}

View file

@ -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

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

@ -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

View file

@ -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)"
}

View file

@ -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"
}

View file

@ -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() {

View file

@ -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
}

View file

@ -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/<cmd>/<subcmd>.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,14 +334,14 @@ function command::run() {
local subcmd="$_ROUTED_SUBCMD"
local -a routed_args=("${_ROUTED_ARGS[@]:-}")
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
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=""
command::load_subcmd "$cmd" "$subcmd"
for arg in "$@"; do
[[ "$arg" == "--help" || "$arg" == "-h" ]] && {
@ -340,7 +349,6 @@ function command::run() {
return 0
}
done
fi
command::run_routed "$cmd" "$subcmd" "${routed_args[@]:-}"
return $?

View file

@ -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 <flag> <key> [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,39 +108,68 @@ function flag::_key() { printf '%s:%s' "${_CURRENT_COMMAND:-__global__}" "$1
# ── Registration ──────────────────────────────────────────────────────────
# flag::define <flag> [bool|value] "description" [constraints...]
# flag::define <flag[]> "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,13 +253,16 @@ function flag::parse() {
log::error "Flag ${arg} maximum is ${max}, got: ${val}" && return 1
fi
local choices="${_FLAG_C_CHOICES[$key]:-}"
# log::debug "choices check: flag=$arg val=$val choices='${_FLAG_C_CHOICES[$key]:-EMPTY}'"
if [[ -n "$choices" ]]; then
local valid=false choice
local IFS='|'
for choice in $choices; do
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
unset IFS
if ! $valid; then
log::error "Flag ${arg} must be one of: ${choices//|/, }, got: ${val}"
return 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 <key> <constraint_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" ]]; }