finish base implementation

This commit is contained in:
Nuno Duque Nunes 2026-05-20 21:49:44 +00:00
parent 8bb1de4976
commit de1a44a7e4
23 changed files with 958 additions and 787 deletions

View file

@ -1,20 +1,26 @@
#!/usr/bin/env bash
# ============================================
# Lifecycle
# ============================================
function cmd::add::on_load() {
load_module subnet
load_module identity
flag::register --name
flag::register --identity
flag::register --type
flag::register --subtype
flag::register --subnet
flag::register --rule
flag::register --group
flag::register --ip
flag::register --guest
flag::register --tunnel
flag::register --show-config
flag::register --show-qr
# Dynamically register --<subnet_name> as shorthand flags
local subnet_name
while IFS= read -r subnet_name; do
[[ -n "$subnet_name" ]] && flag::register "--${subnet_name}"
done < <(subnet::list_names)
}
# ============================================
@ -24,44 +30,31 @@ function cmd::add::on_load() {
function cmd::add::help() {
cat <<EOF
Usage: wgctl add --name <name> --type <type> [options]
or: wgctl add --identity <identity> --type <type> [options]
Add a new WireGuard client.
Options:
--name <name> Client name (e.g. nuno)
--type <type> Device type: desktop, laptop, phone, tablet, guest
--subtype <subtype> Guest subtype: desktop, laptop, phone, tablet (mostly used for guest)
--ip <ip> Override auto-assigned IP (optional)
--guest Shorthand for --type guest
--tunnel <mode> Tunnel mode: split|full (default: split)
--rule <rule> Assign rule on creation (default: user, guest types: guest)
--group <group> Add to group on creation (group must exist)
--show-config Shows the WireGuard peer config
--show-qr Shows the WireGuard config in a QR Code
--name <name> Client name (e.g. nuno) — combined with type: phone-nuno
--identity <name> Identity name — auto-names peer with next available index
--type <type> Device type: desktop, laptop, phone, tablet, server, iot
--subnet <subnet> Subnet to allocate from (default: type-native)
--ip <ip> Override auto-assigned IP (optional)
--tunnel <mode> Tunnel mode: split|full (default: from subnet)
--rule <rule> Assign rule on creation (default: from subnet or user)
--group <group> Add to group on creation (group must exist)
--show-qr Show the WireGuard config as a QR code after creation
Device Types and Subnets:
desktop 10.1.1.x
laptop 10.1.2.x
phone 10.1.3.x
tablet 10.1.4.x
guest 10.1.100.x
Rules:
Automatically assigned based on type (guest → guest rule, others → user rule).
Override with --rule. Manage rules with: wgctl rule help
Tunnel Modes:
split Route only VPN subnet + LAN through WireGuard (default)
full Route all traffic through WireGuard
Subnet shorthands (equivalent to --subnet <name>):
--guests, --servers, --iot, ... (see: wgctl subnet list)
Examples:
wgctl add --name nuno --type phone
wgctl add --name nuno --type laptop --ip 10.1.2.5
wgctl add --name nuno --type phone --tunnel full
wgctl add --name guest1 --type phone --guest
wgctl add --name restricted --type desktop
wgctl add --name dev --type laptop --rule dev-01
wgctl add --name visitor --type guest --show-qr
wgctl add --identity nuno --type phone # auto-names phone-nuno (or phone-nuno-2)
wgctl add --identity nuno --type laptop
wgctl add --name zephyr --type desktop --guests
wgctl add --identity zephyr --type desktop --guests
wgctl add --name visitor --type phone --guests --show-qr
EOF
}
@ -69,14 +62,11 @@ EOF
# Validation
# ============================================
function cmd::add::validate() {
local name="$1"
local type="$2"
local ip="$3"
local tunnel="$4"
function cmd::add::_validate() {
local name="$1" identity="$2" type="$3" ip="$4" tunnel="$5"
if [[ -z "$name" ]]; then
log::error "Missing required flag: --name"
if [[ -z "$name" && -z "$identity" ]]; then
log::error "Missing required flag: --name or --identity"
return 1
fi
@ -85,23 +75,24 @@ function cmd::add::validate() {
return 1
fi
if ! config::is_valid_type "$type"; then
log::error "Invalid device type: ${type}"
log::info "Valid types: $(config::device_types | tr ' ' ', ')"
if ! json::subnet_exists "$(ctx::subnets)" "$type" 2>/dev/null; then
log::error "Unknown device type: '${type}'"
log::info "Use 'wgctl subnet list' to see valid types and subnets"
return 1
fi
if [[ -n "$tunnel" && "$tunnel" != "split" && "$tunnel" != "full" ]]; then
log::error "Invalid tunnel mode: ${tunnel} (use 'split' or 'full')"
log::error "Invalid tunnel mode: '${tunnel}' (use 'split' or 'full')"
return 1
fi
if [[ -n "$ip" ]]; then
ip::require_valid "$ip"
fi
}
local full_name="${type}-${name}"
function cmd::add::_validate_not_exists() {
local full_name="$1"
if [[ -f "$(ctx::clients)/${full_name}.conf" ]]; then
log::error "Client already exists: ${full_name}"
return 1
@ -122,31 +113,32 @@ function cmd::add::is_mobile() {
# ============================================
function cmd::add::run() {
local name=""
local type=""
local subtype=""
local rule=""
local group=""
local ip=""
local tunnel=""
local guest=false
local show_config=false
local show_qr=false
local name="" identity="" type="" subnet_name="" rule="" \
group="" ip="" tunnel="" show_qr=false
# Parse flags
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;;
--subtype) subtype="$2"; shift 2 ;;
--rule) rule="$2"; shift 2 ;;
--group) group="$2"; shift 2 ;;
--ip) ip="$2"; shift 2 ;;
--guest) guest=true; shift ;;
--tunnel) tunnel="$2"; shift 2 ;;
--show-config) show_config=true; shift ;;
--show-qr) show_qr=true; shift ;;
--help) cmd::add::help; return ;;
--name) name="$2"; shift 2 ;;
--identity) identity="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;;
--subnet) subnet_name="$2"; shift 2 ;;
--rule) rule="$2"; shift 2 ;;
--group) group="$2"; shift 2 ;;
--ip) ip="$2"; shift 2 ;;
--tunnel) tunnel="$2"; shift 2 ;;
--show-qr) show_qr=true; shift ;;
--help) cmd::add::help; return ;;
--*)
local flag_name="${1#--}"
if subnet::exists "$flag_name" 2>/dev/null; then
subnet_name="$flag_name"
shift
else
log::error "Unknown flag: $1"
cmd::add::help
return 1
fi
;;
*)
log::error "Unknown flag: $1"
cmd::add::help
@ -155,78 +147,108 @@ function cmd::add::run() {
esac
done
$guest && type="guest"
cmd::add::_validate "$name" "$identity" "$type" "$ip" "$tunnel" || return 1
local effective_type
effective_type=$(cmd::add::_resolve_type "$type" "$subtype") || return 1
# Resolve full peer name
local full_name
if [[ -n "$identity" ]]; then
full_name=$(identity::next_peer_name "$identity" "$type") || return 1
log::info "Auto-named: ${full_name}"
else
full_name="${type}-${name}"
fi
local full_name="${type}-${name}"
cmd::add::validate "$name" "$type" "$ip" "$tunnel" || return 1
cmd::add::_validate_not_exists "$full_name" || return 1
[[ -z "$tunnel" ]] && tunnel=$(config::default_tunnel_for "$type")
[[ -z "$rule" ]] && rule=$(cmd::add::_default_rule "$type")
# Resolve subnet CIDR and canonical type
local resolved_cidr resolved_type
resolved_cidr=$(subnet::resolve_for_add "$type" "$subnet_name") || return 1
resolved_type=$(subnet::type_for_add "$type" "$subnet_name") || return 1
# Resolve tunnel mode
[[ -z "$tunnel" ]] && tunnel=$(subnet::tunnel_mode "${subnet_name:-$type}" "$type")
# Resolve rule — subnet default_rule, then global default
if [[ -z "$rule" ]]; then
rule=$(subnet::default_rule "$subnet_name" "$resolved_type")
[[ -z "$rule" ]] && rule="user"
fi
rule::exists "$rule" || { log::error "Rule not found: ${rule}"; return 1; }
local allowed_ips
allowed_ips=$(config::allowed_ips_for "$effective_type" "$tunnel") || return 1
allowed_ips=$(config::allowed_ips_for "$tunnel") || return 1
log::section "Adding client: ${full_name}"
[[ -z "$ip" ]] && ip=$(ip::next_for_type "$effective_type") || return 1
log::wg_add "Name: ${full_name}"
log::wg_add "Type: ${type}"
log::wg_add "IP: ${ip}"
log::wg_add "Tunnel: ${tunnel} (${allowed_ips})"
log::wg_add "Endpoint: $(config::endpoint)"
log::wg_add "Rule: ${rule:-none}"
keys::generate_pair "$full_name" || return 1
peers::create_client_config "$full_name" "$effective_type" "$ip" "$allowed_ips" || return 1
[[ -n "$subtype" ]] && peers::set_meta "$full_name" "subtype" "$subtype"
if [[ -n "$group" ]]; then
if ! group::exists "$group"; then
log::wg_warning "Group '${group}' not found — skipping group assignment"
else
group::add_peer "$group" "$full_name"
log::wg "Added to group: ${group}"
fi
# Allocate IP
if [[ -n "$ip" ]]; then
subnet::require_ip_valid_for "$resolved_cidr" "$ip" || return 1
else
ip=$(ip::next_for_subnet "$resolved_cidr") || return 1
fi
cmd::add::_log_plan "$full_name" "$type" "$resolved_type" \
"$subnet_name" "$resolved_cidr" "$ip" "$tunnel" "$allowed_ips" "$rule"
keys::generate_pair "$full_name" || return 1
peers::create_client_config "$full_name" "$resolved_type" "$ip" "$allowed_ips" || return 1
# Write meta
peers::set_meta "$full_name" "type" "$resolved_type"
peers::set_meta "$full_name" "rule" "$rule"
if [[ -n "$subnet_name" ]]; then
peers::set_meta "$full_name" "subnet" "$subnet_name"
fi
cmd::add::_assign_group "$full_name" "$group"
local public_key
public_key=$(keys::public "$full_name") || return 1
peers::add_to_server "$full_name" "$public_key" "$ip" || return 1
public_key=$(keys::public "$full_name") || return 1
peers::add_to_server "$full_name" "$public_key" "$ip" || return 1
rule::apply "$rule" "$ip" || return 1
peers::reload || return 1
[[ -n "$rule" ]] && rule::apply "$rule" "$ip" || return 1
peers::reload || return 1
# Auto-attach to identity
identity::auto_attach "$full_name" "$resolved_type"
log::wg_success "Client added successfully: ${full_name} (${ip}) [${tunnel} tunnel]"
cmd::add::_show_result "$full_name" "${subtype:-$type}"
log::wg_success "Client added: ${full_name} (${ip}) [${tunnel} tunnel]"
cmd::add::_show_result "$full_name" "$resolved_type" "$show_qr"
}
function cmd::add::_resolve_type() {
local type="$1" subtype="$2"
if [[ "$type" == "guest" && -n "$subtype" ]]; then
local valid_subtypes="desktop laptop phone tablet"
if ! echo "$valid_subtypes" | grep -qw "$subtype"; then
log::error "Invalid subtype: ${subtype} (valid: desktop, laptop, phone, tablet)"
return 1
fi
echo "guest-${subtype}"
else
echo "$type"
# ============================================
# Internal helpers
# ============================================
function cmd::add::_log_plan() {
local full_name="${1:-}" type="${2:-}" resolved_type="${3:-}" \
subnet_name="${4:-}" resolved_cidr="${5:-}" ip="${6:-}" \
tunnel="${7:-}" allowed_ips="${8:-}" rule="${9:-}"
log::wg_add "Name: ${full_name}"
log::wg_add "Type: ${resolved_type}"
[[ -n "$subnet_name" ]] && log::wg_add "Subnet: ${subnet_name} (${resolved_cidr})"
log::wg_add "IP: ${ip}"
log::wg_add "Tunnel: ${tunnel} (${allowed_ips})"
log::wg_add "Endpoint: $(config::endpoint)"
log::wg_add "Rule: ${rule}"
}
function cmd::add::_assign_group() {
local full_name="${1:-}" group="${2:-}"
[[ -z "$group" ]] && return 0
if ! group::exists "$group"; then
log::wg_warning "Group '${group}' not found — skipping group assignment"
return 0
fi
}
function cmd::add::_default_rule() {
local type="$1"
config::is_guest_type "$type" && echo "guest" || echo "user"
group::add_peer "$group" "$full_name"
log::wg "Added to group: ${group}"
}
function cmd::add::_show_result() {
local full_name="$1" display_type="$2"
if cmd::add::is_mobile "$display_type"; then
local full_name="${1:-}" type="${2:-}" show_qr="${3:-false}"
if $show_qr || cmd::add::is_mobile "$type"; then
log::section "Client QR"
keys::qr "$full_name"
else
log::section "Client Config"

View file

@ -6,6 +6,7 @@
function cmd::block::on_load() {
flag::register --name
flag::register --identity
flag::register --type
flag::register --force
flag::register --quiet
@ -14,8 +15,6 @@ function cmd::block::on_load() {
flag::register --proto
flag::register --subnet
flag::register --block-name
# System - NET Services
flag::register --service
}
@ -26,6 +25,7 @@ function cmd::block::on_load() {
function cmd::block::help() {
cat <<EOF
Usage: wgctl block --name <name> [options]
or: wgctl block --identity <identity> [options]
Block a client entirely or restrict access to specific IPs/ports/subnets/services.
All block rules are persisted and restored on WireGuard restart.
@ -34,39 +34,38 @@ but have specific traffic restricted (shown as 'restricted' in list).
Options:
--name <name> Client name (e.g. phone-nuno)
--identity <name> Block all peers belonging to an identity
--type <type> Device type (optional, combines with --name)
--ip <ip> Block access to specific IP (repeatable)
--subnet <cidr> Block access to subnet (repeatable)
--port <ip:port:proto> Block specific port, e.g. 10.0.0.210:9000:tcp (repeatable)
--service <name> Block a named service (e.g. proxmox, truenas:web-ui) (repeatable)
--port <ip:port:proto> Block specific port (repeatable)
--service <name> Block a named service (repeatable)
--block-name <name> Optional name for this block rule
--quiet Suppress output (used by group block)
Examples:
wgctl block --name phone-nuno
wgctl block --identity nuno
wgctl block --name nuno --type phone
wgctl block --name phone-nuno --ip 10.0.0.210
wgctl block --name phone-nuno --subnet 10.0.0.0/24
wgctl block --name phone-nuno --port 10.0.0.210:9000:tcp
wgctl block --name phone-nuno --service proxmox
wgctl block --name phone-nuno --service truenas:web-ui --block-name "no truenas ui"
wgctl ban --name phone-nuno
EOF
}
# ============================================
# Block Run
# Run
# ============================================
function cmd::block::run() {
local name="" type="" block_name=""
local name="" identity="" type="" block_name=""
local ips=() subnets=() ports=() services=()
local quiet=false force=false
local changed=false
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--identity) identity="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;;
--ip) ips+=("$2"); shift 2 ;;
--block-name) block_name="$2"; shift 2 ;;
@ -75,16 +74,27 @@ function cmd::block::run() {
--quiet) quiet=true; shift ;;
--subnet) subnets+=("$2"); shift 2 ;;
--port) ports+=("$2"); shift 2 ;;
--help) cmd::block::help; return ;;
--help) cmd::block::help; return ;;
*)
log::error "Unknown flag: $1"
cmd::block::help
return 1 ;;
return 1
;;
esac
done
[[ -z "$name" ]] && log::error "Missing required flag: --name" && \
cmd::block::help && return 1
# --identity: block all peers for this identity
if [[ -n "$identity" ]]; then
cmd::block::_block_identity "$identity" "$quiet" \
"${ips[@]+"${ips[@]}"}" || return 1
return 0
fi
[[ -z "$name" ]] && {
log::error "Missing required flag: --name or --identity"
cmd::block::help
return 1
}
name=$(peers::resolve_and_require "$name" "$type") || return 1
@ -113,6 +123,8 @@ function cmd::block::run() {
fi
fi
local changed=false
# Block specific IPs
for ip in "${ips[@]}"; do
ip::require_valid "$ip"
@ -137,7 +149,7 @@ function cmd::block::run() {
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 "${client_ip}:${b_port}:${b_proto:-tcp} has been blocked for ${name}"
$quiet || log::wg_success "${client_ip}:${b_port}:${b_proto:-tcp} has been blocked for ${name}"
done
# Block services
@ -149,13 +161,12 @@ function cmd::block::run() {
return 1
fi
# Check if already blocked
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 || \
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 || \
@ -186,10 +197,8 @@ function cmd::block::run() {
done
[[ ${#ips[@]} -gt 0 || ${#ports[@]} -gt 0 || \
${#subnets[@]} -gt 0 ]] && changed=true
${#subnets[@]} -gt 0 ]] && changed=true
# Reapply in correct order: rule ACCEPT first, then peer DROP rules
# Only reorder if rules were actually added
if $changed; then
local peer_rule
peer_rule=$(peers::get_meta "$name" "rule")
@ -203,6 +212,45 @@ function cmd::block::run() {
return 0
}
# ============================================
# Identity block
# ============================================
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
}
# ============================================
# Helpers
# ============================================
function cmd::block::_get_endpoint() {
local name="$1" public_key="$2"
local endpoint
@ -218,10 +266,7 @@ function cmd::block::_block_all() {
local client_ip="${2:?client_ip required}"
local quiet="${3:-false}"
# Apply fw rules and remove from server
block::apply_full "$name" "$client_ip"
# Mark as directly blocked
block::set_direct "$name" "$client_ip" "true"
$quiet || log::wg_success "${name} has been blocked."

View file

@ -89,9 +89,6 @@ function cmd::inspect::_peer_info() {
local activity_current
activity_current=$(peers::format_activity_current "$public_key")
local subtype
subtype=$(peers::get_meta "$name" "subtype")
local rule_extends=""
if [[ -n "$rule" ]]; then
local rule_file
@ -117,7 +114,7 @@ function cmd::inspect::_peer_info() {
printf "\n"
ui::row "Name" "$name" "${INSPECT_LABEL_WIDTH}"
ui::row "IP" "$ip" "${INSPECT_LABEL_WIDTH}"
ui::row "Type" "$(peers::display_type "$type" "$subtype")" "${INSPECT_LABEL_WIDTH}"
ui::row "Type" "$(peers::display_type "$type")" "${INSPECT_LABEL_WIDTH}"
ui::row "Rule" "$rule_display" "${INSPECT_LABEL_WIDTH}"
ui::row "Status" "$(echo -e "$status")" "${INSPECT_LABEL_WIDTH}"
ui::row "Endpoint" "${endpoint:-}" "${INSPECT_LABEL_WIDTH}"

View file

@ -8,6 +8,7 @@ function cmd::list::on_load() {
flag::register --type
flag::register --rule
flag::register --group
flag::register --identity
flag::register --online
flag::register --offline
flag::register --restricted
@ -28,9 +29,10 @@ Usage: wgctl list [options]
List all WireGuard clients.
Options:
--type <type> Filter by device type (desktop, laptop, phone, tablet, guest)
--type <type> Filter by device type (desktop, laptop, phone, tablet)
--rule <rule> Filter by assigned rule
--group <group> Filter by group membership
--identity <name> Filter by identity (show all peers for an identity)
--online Show only connected clients
--offline Show only disconnected clients
--blocked Show only fully blocked clients (removed from WireGuard)
@ -47,9 +49,10 @@ Status values:
Examples:
wgctl list
wgctl list --type guest
wgctl list --type phone
wgctl list --rule user
wgctl list --group family
wgctl list --identity nuno
wgctl list --online
wgctl list --blocked
wgctl list --restricted
@ -87,8 +90,8 @@ function cmd::list::_render_footer() {
function cmd::list::_render_summary() {
local group_summary="${1:-}"
local -n _rule_counts="$2"
local filter_desc="${3:-}"
# Count total from rule_counts (only filtered peers)
local total=0
for r in "${!_rule_counts[@]}"; do
(( total += _rule_counts[$r] )) || true
@ -108,7 +111,7 @@ function cmd::list::_render_summary() {
}
# ============================================
# Detail Card (show_client)
# Detail Card
# ============================================
function cmd::list::show_client() {
@ -130,11 +133,10 @@ function cmd::list::show_client() {
local public_key
public_key=$(keys::public "$name" 2>/dev/null || echo "unknown")
# Meta type is authoritative; IP reverse lookup is fallback for pre-migration peers
local type
type=$(peers::get_type_from_ip "$ip")
local subtype
subtype=$(peers::get_meta "$name" "subtype")
type=$(peers::get_meta "$name" "type" 2>/dev/null)
[[ -z "$type" ]] && type=$(peers::get_type_from_ip "$ip")
local endpoint="—"
local ep
@ -155,7 +157,7 @@ function cmd::list::show_client() {
last_ts=$(monitor::last_attempt "$name")
local status
status=$(peers::format_status "$name" "$public_key" \
status=$(peers::format_status_verbose "$name" "$public_key" \
"$is_blocked" "$is_restricted" "$handshake_ts" "$last_ts")
local last_seen
@ -174,7 +176,7 @@ function cmd::list::show_client() {
ui::section "Client: ${name}"
ui::row "IP" "$ip"
ui::row "Type" "$(peers::display_type "$type" "$subtype")"
ui::row "Type" "$(peers::display_type "$type")"
ui::row "Status" "$(echo -e "$status")"
ui::row "Endpoint" "$endpoint"
ui::row "Last seen" "$last_seen"
@ -197,7 +199,7 @@ function cmd::list::show_client() {
# ============================================
function cmd::list::run() {
local filter_type="" filter_rule="" filter_group=""
local filter_type="" filter_rule="" filter_group="" filter_identity=""
local online_only=false offline_only=false
local restricted_only=false blocked_only=false allowed_only=false
local detailed=false single_name=""
@ -207,6 +209,7 @@ function cmd::list::run() {
--type) filter_type="$2"; shift 2 ;;
--rule) filter_rule="$2"; shift 2 ;;
--group) filter_group="$2"; shift 2 ;;
--identity) filter_identity="$2"; shift 2 ;;
--online) online_only=true; shift ;;
--offline) offline_only=true; shift ;;
--restricted) restricted_only=true; shift ;;
@ -223,7 +226,6 @@ function cmd::list::run() {
esac
done
# Single detail card
if [[ -n "$single_name" ]]; then
cmd::list::show_client "$single_name"
return 0
@ -237,21 +239,17 @@ function cmd::list::run() {
return 0
fi
# ── Precompute everything ──────────────────
cmd::list::_precompute_all
# ── Detailed mode ──────────────────────────
if $detailed; then
log::section "WireGuard Clients"
cmd::list::_iter_confs "$filter_type" cmd::list::_show_client_safe
return 0
fi
# ── Build filter description ───────────────
local filter_desc=""
cmd::list::_build_filter_desc
# ── Table view ─────────────────────────────
declare -A rule_counts=() group_counts=()
_list_header_printed=false
@ -267,10 +265,12 @@ function cmd::list::run() {
fi
}
# ============================================
# Iteration
# ============================================
function cmd::list::_iter_confs() {
# Usage: cmd::list::_iter_confs <filter_type> <callback>
local filter_type="$1"
local callback="$2"
local filter_type="$1" callback="$2"
local dir
dir="$(ctx::clients)"
@ -278,17 +278,27 @@ function cmd::list::_iter_confs() {
[[ -f "$conf" ]] || continue
local client_name
client_name=$(basename "$conf" .conf)
local ip="${p_ips[$client_name]:-}"
if [[ -z "$ip" ]]; then
ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1)
# Identity filter — skip peers not in the identity set
if [[ ${#p_identity_filter[@]} -gt 0 && \
-z "${p_identity_filter[$client_name]:-}" ]]; then
continue
fi
local type
type=$(peers::get_type_from_ip "$ip")
local ip="${p_ips[$client_name]:-}"
[[ -z "$ip" ]] && ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1)
# p_types is authoritative — set during precompute from meta + IP fallback
local type="${p_types[$client_name]:-unknown}"
[[ -n "$filter_type" && "$type" != "$filter_type" ]] && continue
"$callback" "$client_name" "$ip" "$type"
done
}
# ============================================
# Row rendering
# ============================================
function cmd::list::_render_row() {
local client_name="$1" ip="$2" type="$3"
@ -299,9 +309,8 @@ function cmd::list::_render_row() {
local is_restricted="${p_restricted[$client_name]:-false}"
local last_ts="${p_last_ts[$client_name]:-}"
# Apply status filters
if $online_only; then peers::is_online "$client_name" "$handshake_ts" "$last_ts" || return 0; fi
if $offline_only; then peers::is_offline "$client_name" "$handshake_ts" "$last_ts" || return 0; fi
if $online_only; then peers::is_online "$client_name" "$handshake_ts" "$last_ts" || return 0; fi
if $offline_only; then peers::is_offline "$client_name" "$handshake_ts" "$last_ts" || return 0; fi
if $restricted_only && [[ "$is_restricted" != "true" ]]; then return 0; fi
if $blocked_only && [[ "$is_blocked" != "true" ]]; then return 0; fi
if $allowed_only && { [[ "$is_blocked" == "true" ]] || \
@ -312,18 +321,16 @@ function cmd::list::_render_row() {
[[ "$all_groups" != *"$filter_group"* ]] && return 0
fi
# Format display values
local status last_seen display_type rule group_display
status=$(peers::format_status "$client_name" "$pubkey" \
status=$(peers::format_status_verbose "$client_name" "$pubkey" \
"$is_blocked" "$is_restricted" "$handshake_ts" "$last_ts")
last_seen=$(peers::format_last_seen "$client_name" "$pubkey" \
"$is_blocked" "$last_ts" "" "$handshake_ts")
display_type=$(peers::display_type "$type" "${p_subtypes[$client_name]:-}")
display_type=$(peers::display_type "$type")
rule="${p_rules[$client_name]:-}"
if [[ -n "$filter_rule" && "$rule" != "$filter_rule" ]]; then return 0; fi
# Print header on first match
if [[ "${_list_header_printed:-false}" == "false" ]]; then
log::section "WireGuard Clients"
cmd::list::_render_header $has_groups
@ -336,7 +343,6 @@ function cmd::list::_render_row() {
padded_status=$(ui::pad_status "$status" 25)
if $has_groups; then
# Use main group for display, fall back to first group, then —
local main_group="${p_main_groups[$client_name]:-}"
if [[ -n "$main_group" ]]; then
group_display="$main_group"
@ -364,22 +370,28 @@ function cmd::list::_render_row() {
}
# ============================================
# Private helpers
# Precompute
# ============================================
function cmd::list::_precompute_all() {
# Peer data
declare -gA p_ips=() p_rules=() p_subtypes=() p_last_ts=() p_last_evt=() p_main_groups=()
while IFS="|" read -r name ip rule subtype last_ts last_evt main_group; do
# Peer data — field 4 is 'type' from peer_data_v2
declare -gA p_ips=() p_rules=() p_types=() p_last_ts=() p_last_evt=() p_main_groups=()
while IFS="|" read -r name ip rule type last_ts last_evt main_group; do
[[ -z "$name" ]] && continue
p_ips["$name"]="$ip"
p_rules["$name"]="${rule:-}"
p_subtypes["$name"]="$subtype"
p_types["$name"]="${type:-}"
p_last_ts["$name"]="$last_ts"
p_last_evt["$name"]="$last_evt"
p_main_groups["$name"]="${main_group:-}"
done < <(json::peer_data "$(ctx::clients)" "$(ctx::meta)" "$(ctx::events_log)")
# Fill type from IP for peers missing meta type (pre-migration peers)
for name in "${!p_ips[@]}"; do
[[ -n "${p_types[$name]:-}" ]] && continue
p_types["$name"]=$(peers::get_type_from_ip "${p_ips[$name]}")
done
# WireGuard handshakes + endpoints
declare -gA wg_handshakes=() wg_endpoints=()
while IFS=$'\t' read -r pubkey ts; do
@ -417,6 +429,19 @@ function cmd::list::_precompute_all() {
done < <(json::peer_group_map "$groups_dir")
fi
# Resolve identity filter into a peer set
declare -gA p_identity_filter=()
if [[ -n "$filter_identity" ]]; then
identity::require_exists "$filter_identity" || return 1
while IFS= read -r peer_name; do
[[ -n "$peer_name" ]] && p_identity_filter["$peer_name"]=1
done < <(identity::peers "$filter_identity")
if [[ ${#p_identity_filter[@]} -eq 0 ]]; then
log::wg_warning "Identity '${filter_identity}' has no peers"
return 0
fi
fi
# Transfer/activity data — keyed by pubkey
declare -gA p_rx=() p_tx=() p_activity=()
while IFS="|" read -r pubkey rx tx level; do
@ -427,65 +452,6 @@ function cmd::list::_precompute_all() {
done < <(json::peer_transfer "$(config::interface)")
}
# function cmd::list::_precompute_all() {
# # Peer data
# declare -gA p_ips=() p_rules=() p_subtypes=() p_last_ts=() p_last_evt=()
# while IFS="|" read -r name ip rule subtype last_ts last_evt; do
# [[ -z "$name" ]] && continue
# p_ips["$name"]="$ip"
# p_rules["$name"]="${rule:-—}"
# p_subtypes["$name"]="$subtype"
# p_last_ts["$name"]="$last_ts"
# p_last_evt["$name"]="$last_evt"
# done < <(json::peer_data "$(ctx::clients)" "$(ctx::meta)" "$(ctx::events_log)")
# # WireGuard handshakes + endpoints
# declare -gA wg_handshakes=() wg_endpoints=()
# while IFS=$'\t' read -r pubkey ts; do
# [[ -n "$pubkey" ]] && wg_handshakes["$pubkey"]="$ts"
# done < <(wg show "$(config::interface)" latest-handshakes 2>/dev/null)
# while IFS=$'\t' read -r pubkey endpoint; do
# [[ -n "$pubkey" ]] && wg_endpoints["$pubkey"]="$endpoint"
# done < <(wg show "$(config::interface)" endpoints 2>/dev/null)
# # Block/restricted status
# declare -gA p_blocked=() p_restricted=()
# cmd::list::_precompute_block_status p_blocked p_restricted
# # Public keys
# declare -gA p_pubkeys=()
# local dir
# dir="$(ctx::clients)"
# for kf in "${dir}"/*_public.key; do
# [[ -f "$kf" ]] || continue
# local kname
# kname=$(basename "$kf" _public.key)
# p_pubkeys["$kname"]=$(cat "$kf" 2>/dev/null || echo "")
# done
# # Groups
# has_groups=false
# declare -gA peer_group_map=()
# local groups_dir
# groups_dir="$(ctx::groups)"
# local group_files=("${groups_dir}"/*.group)
# if [[ -f "${group_files[0]}" ]]; then
# has_groups=true
# while IFS=":" read -r peer_name group_name; do
# [[ -n "$peer_name" ]] && peer_group_map["$peer_name"]="$group_name"
# done < <(json::peer_group_map "$groups_dir")
# fi
# # Transfer/activity data — keyed by pubkey
# declare -gA p_rx=() p_tx=() p_activity=()
# while IFS="|" read -r pubkey rx tx level; do
# [[ -z "$pubkey" ]] && continue
# p_rx["$pubkey"]="$rx"
# p_tx["$pubkey"]="$tx"
# p_activity["$pubkey"]="$level"
# done < <(json::peer_transfer "$(config::interface)")
# }
function cmd::list::_precompute_block_status() {
local -n _blocked="$1"
local -n _restricted="$2"
@ -500,7 +466,6 @@ function cmd::list::_precompute_block_status() {
_restricted["$name"]=false
fi
# Blocked = removed from WG server
local pubkey
pubkey=$(keys::public "$name" 2>/dev/null || echo "")
if [[ -n "$pubkey" ]] && ! echo "$wg_peers" | grep -qF "$pubkey"; then
@ -511,11 +476,16 @@ function cmd::list::_precompute_block_status() {
done < <(peers::all)
}
# ============================================
# Filter helpers
# ============================================
function cmd::list::_build_filter_desc() {
filter_desc=""
[[ -n "$filter_type" ]] && filter_desc+="type=${filter_type} "
[[ -n "$filter_rule" ]] && filter_desc+="rule=${filter_rule} "
[[ -n "$filter_group" ]] && filter_desc+="group=${filter_group} "
[[ -n "$filter_type" ]] && filter_desc+="type=${filter_type} "
[[ -n "$filter_rule" ]] && filter_desc+="rule=${filter_rule} "
[[ -n "$filter_group" ]] && filter_desc+="group=${filter_group} "
[[ -n "$filter_identity" ]] && filter_desc+="identity=${filter_identity} "
$online_only && filter_desc+="online "
$offline_only && filter_desc+="offline "
$blocked_only && filter_desc+="blocked "

View file

@ -58,5 +58,6 @@ function cmd::qr::run() {
name=$(peers::resolve_and_require "$name" "$type") || return 1
log::section "Client QR: ${name}"
keys::qr "$name"
}

View file

@ -105,9 +105,5 @@ function cmd::rename::_rename_files() {
sed -i "s/^# ${name}$/# ${new_name}/" "$(config::config_file)"
block::rename "$name" "$new_name"
local old_meta new_meta
old_meta=$(peers::meta_path "$name")
new_meta=$(peers::meta_path "$new_name")
[[ -f "$old_meta" ]] && mv "$old_meta" "$new_meta"
peers::rename_meta "$name" "$new_name"
}

View file

@ -7,11 +7,13 @@ function cmd::test::section_destructive() {
test::section "Destructive (modifying state)"
# Cleanup from any previous failed run
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
"$WGCTL_BINARY" remove --name laptop-testunit2 --force > /dev/null 2>&1 || true
"$WGCTL_BINARY" remove --name laptop-testunit2b --force > /dev/null 2>&1 || true
"$WGCTL_BINARY" group remove --name testgroup --force > /dev/null 2>&1 || true
"$WGCTL_BINARY" group remove --name testgroup2 --force > /dev/null 2>&1 || true
"$WGCTL_BINARY" net rm --name test-block-svc --force > /dev/null 2>&1 || true
"$WGCTL_BINARY" unblock --name phone-testunit > /dev/null 2>&1 || true
"$WGCTL_BINARY" unblock --name phone-testunit > /dev/null 2>&1 || true
cmd::test::_destructive_peer
cmd::test::_destructive_block_unblock
@ -28,15 +30,15 @@ function cmd::test::_destructive_peer() {
}
function cmd::test::_destructive_block_unblock() {
cmd::test::run_cmd "block peer" "blocked" block --name phone-testunit
cmd::test::run_cmd "list shows blocked" "blocked" list --blocked
cmd::test::run_cmd "unblock peer" "unblocked" unblock --name phone-testunit
cmd::test::run_cmd "block peer" "blocked" block --name phone-testunit
cmd::test::run_cmd "list shows blocked" "phone-testunit" list --blocked
cmd::test::run_cmd "unblock peer" "unblocked" unblock --name phone-testunit
cmd::test::run_cmd "block peer --ip" "blocked for" \
cmd::test::run_cmd "block peer --ip" "blocked for" \
block --name phone-testunit --ip 10.0.0.99
cmd::test::run_cmd "list shows restricted" "restricted" \
cmd::test::run_cmd "list shows restricted" "restricted" \
list --name phone-testunit
cmd::test::run_cmd "unblock peer --ip" "unblocked" \
cmd::test::run_cmd "unblock peer --ip" "unblocked" \
unblock --name phone-testunit --ip 10.0.0.99
}
@ -63,12 +65,11 @@ function cmd::test::_destructive_rule() {
}
function cmd::test::_destructive_groups() {
cmd::test::run_cmd "group add" "created" group add --name testgroup --desc "Test group"
cmd::test::run_cmd "group add" "created" group add --name testgroup --desc "Test group"
cmd::test::run_cmd "group peer add" "Added" group peer add --name testgroup --peer phone-testunit
cmd::test::run_cmd "group block" "blocked" group block --name testgroup
cmd::test::run_cmd "group unblock" "unblocked" group unblock --name testgroup
# M:N group block tracking
"$WGCTL_BINARY" group add --name testgroup2 --desc "Test group 2" > /dev/null 2>&1
"$WGCTL_BINARY" group peer add --name testgroup2 --peer phone-testunit > /dev/null 2>&1
@ -76,12 +77,11 @@ function cmd::test::_destructive_groups() {
cmd::test::run_cmd "group block second" "blocked" group block --name testgroup2
"$WGCTL_BINARY" group unblock --name testgroup > /dev/null 2>&1
cmd::test::run_cmd "peer stays blocked after partial unblock" "blocked" list --blocked
cmd::test::run_cmd "peer stays blocked after partial unblock" "phone-testunit" list --blocked
"$WGCTL_BINARY" group unblock --name testgroup2 > /dev/null 2>&1
cmd::test::run_cmd "peer unblocked after all groups unblock" "phone-testunit" list --allowed
# Direct block overrides group block
"$WGCTL_BINARY" group block --name testgroup > /dev/null 2>&1
cmd::test::run_cmd "direct unblock overrides group block" "unblocked" unblock --name phone-testunit
@ -92,22 +92,33 @@ function cmd::test::_destructive_groups() {
function cmd::test::_destructive_identity() {
test::section "Destructive: identity auto-attach/detach"
# Add verifies auto-attach
# Cleanup from any previous failed run
"$WGCTL_BINARY" remove --name laptop-testunit2 --force > /dev/null 2>&1 || true
"$WGCTL_BINARY" remove --name laptop-testunit2b --force > /dev/null 2>&1 || true
# Add — verify auto-attach to identity "testunit2"
cmd::test::run_cmd "add attaches to identity" "added" \
add --name testunit2 --type laptop
cmd::test::run_cmd "identity shows testunit2" "testunit2" \
identity show --name testunit
cmd::test::run_cmd "identity created for testunit2" "laptop-testunit2" \
identity show --name testunit2
# Rename verifies identity::rename_peer
cmd::test::run_cmd "rename updates identity" "renamed" \
# Rename — verify identity::rename_peer moves peer to new identity "testunit2b"
cmd::test::run_cmd "rename peer" "renamed" \
rename --name laptop-testunit2 --new-name laptop-testunit2b
cmd::test::run_cmd "identity reflects rename" "testunit2b" \
identity show --name testunit
cmd::test::run_cmd "identity reflects rename (new identity)" "laptop-testunit2b" \
identity show --name testunit2b
# Remove verifies auto-detach
"$WGCTL_BINARY" remove --name laptop-testunit2b --force > /dev/null 2>&1 || true
cmd::test::run_cmd_fails "old identity gone after rename" \
identity show --name testunit2
# Remove — verify auto-detach cleans up identity file
cmd::test::run_cmd "remove detaches from identity" "removed" \
remove --name laptop-testunit2b --force
cmd::test::run_cmd_fails "identity cleaned up after remove" \
identity show --name testunit2b
}
function cmd::test::_destructive_cleanup() {

View file

@ -9,6 +9,10 @@ WGCTL_BINARY="$(command -v wgctl)"
# Helpers
# ============================================
function cmd::test::_strip_ansi() {
sed 's/\x1b\[[0-9;]*m//g'
}
function cmd::test::run_cmd() {
local desc="$1" expected="${2:-}"
shift 2
@ -17,30 +21,64 @@ function cmd::test::run_cmd() {
tmp=$(mktemp)
set +e
timeout 30 "$WGCTL_BINARY" "$@" > "$tmp" 2>&1 &
local pid=$!
wait $pid
timeout 30 "$WGCTL_BINARY" "$@" > "$tmp" 2>&1
exit_code=$?
set -e
# Reset terminal color in case command output left ANSI state dirty
printf "\033[0m" >&2
if [[ $exit_code -eq 124 ]]; then
test::warn "${desc} (timed out after 30s)"
rm -f "$tmp"
return 1
fi
local clean
clean=$(cmd::test::_strip_ansi < "$tmp")
if [[ $exit_code -ne 0 ]]; then
test::fail "${desc}"
local msg="${desc}"
[[ -n "$expected" ]] && msg="${desc} (expected '${expected}', command failed)"
test::fail "$msg"
if [[ "${WGCTL_TEST_VERBOSE:-false}" == "true" ]]; then
printf " Output: %s\n" "$(cat "$tmp")"
printf " Output: %s\n" "$(echo "$clean" | head -3 | tr '\n' ' ')"
fi
rm -f "$tmp"
return 1
fi
if [[ -n "$expected" ]] && ! grep -qF "$expected" "$tmp"; then
if [[ -n "$expected" ]] && ! echo "$clean" | grep -qF "$expected"; then
local actual
actual=$(head -3 "$tmp" | tr '\n' ' ' | sed 's/ */ /g' | cut -c1-100)
actual=$(echo "$clean" | head -3 | tr '\n' ' ' | sed 's/ */ /g' | cut -c1-100)
test::fail "${desc} (expected '${expected}', got: '${actual}')"
rm -f "$tmp"
return 1
fi
test::pass "$desc"
rm -f "$tmp"
}
function cmd::test::run_cmd_any() {
local desc="$1" expected="${2:-}"
shift 2
local tmp
tmp=$(mktemp)
set +e
timeout 30 "$WGCTL_BINARY" "$@" > "$tmp" 2>&1
set -e
printf "\033[0m" >&2
local clean
clean=$(cmd::test::_strip_ansi < "$tmp")
if [[ -n "$expected" ]] && ! echo "$clean" | grep -qF "$expected"; then
local actual
actual=$(echo "$clean" | head -3 | tr '\n' ' ' | sed 's/ */ /g' | cut -c1-100)
test::fail "${desc} (expected '${expected}', got: '${actual}')"
rm -f "$tmp"
return 1
@ -54,13 +92,15 @@ function cmd::test::run_cmd_fails() {
local desc="$1"
shift
set +e
local tmp exit_code
tmp=$(mktemp)
timeout 10 setsid "$WGCTL_BINARY" "$@" > "$tmp" 2>&1
set +e
timeout 10 "$WGCTL_BINARY" "$@" > "$tmp" 2>&1
exit_code=$?
set -e
printf "\033[0m" >&2
rm -f "$tmp"
if [[ $exit_code -eq 124 ]]; then
@ -96,13 +136,13 @@ function cmd::test::run_all_integration_sections() {
function cmd::test::section_list() {
test::section "List"
cmd::test::run_cmd "list" "WireGuard Clients" list
cmd::test::run_cmd "list --online" "" list --online
cmd::test::run_cmd "list --offline" "" list --offline
cmd::test::run_cmd "list --blocked" "" list --blocked
cmd::test::run_cmd "list --type phone" "" list --type phone
cmd::test::run_cmd "list --detailed" "Client:" list --detailed
cmd::test::run_cmd "list --name phone-nuno" "phone-nuno" list --name phone-nuno
cmd::test::run_cmd "list" "NAME" list
cmd::test::run_cmd "list --online" "" list --online
cmd::test::run_cmd "list --offline" "" list --offline
cmd::test::run_cmd "list --blocked" "" list --blocked
cmd::test::run_cmd "list --type phone" "phone" list --type phone
cmd::test::run_cmd "list --detailed" "IP:" list --detailed
cmd::test::run_cmd "list --name phone-nuno" "phone-nuno" list --name phone-nuno
}
function cmd::test::section_inspect() {
@ -115,14 +155,14 @@ function cmd::test::section_inspect() {
function cmd::test::section_config() {
test::section "Config & QR"
cmd::test::run_cmd "config --name phone-nuno" "PrivateKey" config --name phone-nuno
cmd::test::run_cmd "config --name nuno --type phone" "PrivateKey" config --name nuno --type phone
cmd::test::run_cmd "qr --name phone-nuno" "" qr --name phone-nuno
cmd::test::run_cmd "config --name phone-nuno" "PrivateKey" config --name phone-nuno
cmd::test::run_cmd "config --name nuno --type phone" "PrivateKey" config --name nuno --type phone
cmd::test::run_cmd "qr --name phone-nuno" "" qr --name phone-nuno
}
function cmd::test::section_rules() {
test::section "Rules"
cmd::test::run_cmd "rule list" "guest" rule list
cmd::test::run_cmd "rule list" "user" rule list
cmd::test::run_cmd "rule show --name guest" "Description" rule show --name guest
cmd::test::run_cmd "rule show --name user" "Description" rule show --name user
cmd::test::run_cmd "rule show --name admin" "Description" rule show --name admin
@ -131,35 +171,35 @@ function cmd::test::section_rules() {
function cmd::test::section_groups() {
test::section "Groups"
cmd::test::run_cmd "group list" "Groups" group list
cmd::test::run_cmd "group show --name family" "Peers:" group show --name family
cmd::test::run_cmd_fails "group show nonexistent" group show --name nonexistent
cmd::test::run_cmd "group list" "Groups" group list
cmd::test::run_cmd "group show --name family" "Peers:" group show --name family
cmd::test::run_cmd_fails "group show nonexistent" group show --name nonexistent
}
function cmd::test::section_audit() {
test::section "Audit"
cmd::test::run_cmd "audit" "passed" audit
cmd::test::run_cmd "audit --peer phone-nuno" "passed" audit --peer phone-nuno
cmd::test::run_cmd "audit --type phone" "passed" audit --type phone
cmd::test::run_cmd_any "audit" "passed" audit
cmd::test::run_cmd_any "audit --peer phone-nuno" "passed" audit --peer phone-nuno
cmd::test::run_cmd_any "audit --type phone" "passed" audit --type phone
}
function cmd::test::section_logs() {
test::section "Logs"
cmd::test::run_cmd "logs" "Activity" logs
cmd::test::run_cmd "logs --name phone-nuno" "Activity" logs --name phone-nuno
cmd::test::run_cmd "logs --fw" "Activity" logs --fw
cmd::test::run_cmd "logs --wg" "Activity" logs --wg
cmd::test::run_cmd "logs" "Activity" logs
cmd::test::run_cmd "logs --name phone-nuno" "Activity" logs --name phone-nuno
cmd::test::run_cmd "logs --fw" "Activity" logs --fw
cmd::test::run_cmd "logs --wg" "Activity" logs --wg
}
function cmd::test::section_fw() {
test::section "Firewall"
cmd::test::run_cmd "fw list" "FORWARD" fw list
cmd::test::run_cmd "fw list --peer phone-nuno" "" fw list --peer phone-nuno
cmd::test::run_cmd "fw list --no-nflog" "" fw list --no-nflog
cmd::test::run_cmd "fw list --no-accept" "" fw list --no-accept
cmd::test::run_cmd "fw list --no-drop" "" fw list --no-drop
cmd::test::run_cmd "fw nat" "PREROUTING" fw nat
cmd::test::run_cmd "fw count" "TOTAL" fw count
cmd::test::run_cmd "fw list" "FORWARD" fw list
cmd::test::run_cmd "fw list --peer phone-nuno" "" fw list --peer phone-nuno
cmd::test::run_cmd "fw list --no-nflog" "" fw list --no-nflog
cmd::test::run_cmd "fw list --no-accept" "" fw list --no-accept
cmd::test::run_cmd "fw list --no-drop" "" fw list --no-drop
cmd::test::run_cmd "fw nat" "PREROUTING" fw nat
cmd::test::run_cmd "fw count" "TOTAL" fw count
}
function cmd::test::section_net() {
@ -175,26 +215,24 @@ function cmd::test::section_net() {
cmd::test::run_cmd "net add port again" "Added" net add --name test-svc:web --port 9999:tcp
cmd::test::run_cmd "net rm all ports" "Removed" net rm --name test-svc:ports --force
cmd::test::run_cmd "net rm service" "Removed" net rm --name test-svc --force
cmd::test::run_cmd_fails "net show nonexistent" net show --name nonexistent-svc
cmd::test::run_cmd_fails "net add port no service" net add --name nonexistent:web --port 80:tcp
cmd::test::run_cmd_fails "net show nonexistent" net show --name nonexistent-svc
cmd::test::run_cmd_fails "net add port no service" net add --name nonexistent:web --port 80:tcp
}
function cmd::test::section_subnet() {
test::section "Subnet"
# Cleanup from any previous failed run
"$WGCTL_BINARY" subnet rm --name test-subnet-2 --force > /dev/null 2>&1 || true
"$WGCTL_BINARY" subnet rm --name test-subnet --force > /dev/null 2>&1 || true
"$WGCTL_BINARY" subnet rm --name test-subnet-2 > /dev/null 2>&1 || true
"$WGCTL_BINARY" subnet rm --name test-subnet > /dev/null 2>&1 || true
cmd::test::run_cmd "subnet list" "desktop" subnet list
cmd::test::run_cmd "subnet show desktop" "Subnet" subnet show --name desktop
cmd::test::run_cmd "subnet show guests group" "group" subnet show --name guests
cmd::test::run_cmd_fails "subnet show nonexistent" subnet show --name nonexistent
cmd::test::run_cmd "subnet list" "desktop" subnet list
cmd::test::run_cmd "subnet show desktop" "Subnet" subnet show --name desktop
cmd::test::run_cmd "subnet show guests group" "group" subnet show --name guests
cmd::test::run_cmd_fails "subnet show nonexistent" subnet show --name nonexistent
cmd::test::run_cmd "subnet add" "added" \
subnet add --name test-subnet --subnet 10.1.250.0/24 --type iot --desc "Test"
cmd::test::run_cmd "subnet list shows new" "test-subnet" \
subnet list
cmd::test::run_cmd_fails "subnet rename in-use (desktop)" \
subnet rename --name desktop --new-name workstation
cmd::test::run_cmd "subnet rename unused" "renamed" \
@ -207,7 +245,7 @@ function cmd::test::section_subnet() {
function cmd::test::section_identity() {
test::section "Identity"
cmd::test::run_cmd "identity list" "" identity list
cmd::test::run_cmd "identity list" "" identity list
cmd::test::run_cmd "identity migrate --dry-run" "Dry run" identity migrate --dry-run
cmd::test::run_cmd "identity show nuno" "nuno" identity show --name nuno
cmd::test::run_cmd_fails "identity show nonexistent" identity show --name nonexistent

View file

@ -41,10 +41,6 @@ function cmd::test::assert_false() {
# ============================================
function cmd::test::run_all_unit_sections() {
load_module subnet
load_module ip
load_module identity
cmd::test::unit_subnet
cmd::test::unit_ip
cmd::test::unit_identity
@ -55,16 +51,16 @@ function cmd::test::unit_subnet() {
load_module subnet
# subnet::prefix
cmd::test::assert "subnet::prefix /24" "$(subnet::prefix '10.1.3.0/24')" "10.1.3"
cmd::test::assert "subnet::prefix /16" "$(subnet::prefix '10.1.0.0/16')" "10.1.0"
cmd::test::assert "subnet::mask /24" "$(subnet::mask '10.1.3.0/24')" "24"
cmd::test::assert "subnet::mask /16" "$(subnet::mask '10.1.0.0/16')" "16"
cmd::test::assert "subnet::base_ip" "$(subnet::base_ip '10.1.3.0/24')" "10.1.3.0"
cmd::test::assert "subnet::prefix /24" "$(subnet::prefix '10.1.3.0/24')" "10.1.3"
cmd::test::assert "subnet::prefix /16" "$(subnet::prefix '10.1.0.0/16')" "10.1.0"
cmd::test::assert "subnet::mask /24" "$(subnet::mask '10.1.3.0/24')" "24"
cmd::test::assert "subnet::mask /16" "$(subnet::mask '10.1.0.0/16')" "16"
cmd::test::assert "subnet::base_ip" "$(subnet::base_ip '10.1.3.0/24')" "10.1.3.0"
# subnet::contains
cmd::test::assert_true "subnet::contains inside" subnet::contains "10.1.3.0/24" "10.1.3.5"
cmd::test::assert_true "subnet::contains boundary" subnet::contains "10.1.3.0/24" "10.1.3.254"
cmd::test::assert_false "subnet::contains outside" subnet::contains "10.1.3.0/24" "10.1.4.1"
cmd::test::assert_true "subnet::contains inside" subnet::contains "10.1.3.0/24" "10.1.3.5"
cmd::test::assert_true "subnet::contains boundary" subnet::contains "10.1.3.0/24" "10.1.3.254"
cmd::test::assert_false "subnet::contains outside" subnet::contains "10.1.3.0/24" "10.1.4.1"
cmd::test::assert_false "subnet::contains wrong net" subnet::contains "10.1.3.0/24" "192.168.1.1"
# subnet::is_valid_cidr
@ -100,7 +96,6 @@ function cmd::test::unit_identity() {
test::section "Unit: identity inference"
load_module identity
# _parse_peer_name via identity::infer
cmd::test::assert "infer phone-nuno" "$(identity::infer 'phone-nuno')" "nuno|phone|1"
cmd::test::assert "infer phone-nuno-2" "$(identity::infer 'phone-nuno-2')" "nuno|phone|2"
cmd::test::assert "infer desktop-zephyr" "$(identity::infer 'desktop-zephyr')" "zephyr|desktop|1"

View file

@ -6,6 +6,7 @@
function cmd::unblock::on_load() {
flag::register --name
flag::register --identity
flag::register --type
flag::register --force
flag::register --quiet
@ -14,8 +15,6 @@ function cmd::unblock::on_load() {
flag::register --proto
flag::register --subnet
flag::register --all
# System - NET Services
flag::register --service
}
@ -26,12 +25,14 @@ function cmd::unblock::on_load() {
function cmd::unblock::help() {
cat <<EOF
Usage: wgctl unblock --name <name> [options]
or: wgctl unblock --identity <identity> [options]
Remove block rules for a client. Without specific flags, performs a full unblock.
Direct unblock overrides any group blocks.
Options:
--name <name> Client name (e.g. phone-nuno)
--identity <name> Unblock all peers belonging to an identity
--type <type> Device type (optional, combines with --name)
--ip <ip> Unblock specific IP (repeatable)
--subnet <cidr> Unblock specific subnet (repeatable)
@ -42,40 +43,36 @@ Options:
Examples:
wgctl unblock --name phone-nuno
wgctl unblock --identity nuno
wgctl unblock --name nuno --type phone
wgctl unblock --name phone-nuno --ip 10.0.0.210
wgctl unblock --name phone-nuno --service proxmox
wgctl unblock --name phone-nuno --service truenas:web-ui
wgctl unban --name phone-nuno
EOF
}
# ============================================
# Unblock Run
# Run
# ============================================
function cmd::unblock::run() {
local name=""
local type=""
local ips=()
local subnets=()
local ports=()
local services=()
local all=false
local quiet=false
local name="" identity="" type=""
local ips=() subnets=() ports=() services=()
local all=false quiet=false force=false
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;;
--ip) ips+=("$2"); shift 2 ;;
--force) force=true; shift ;;
--quiet) quiet=true; shift ;;
--subnet) subnets+=("$2"); shift 2 ;;
--port) ports+=("$2"); shift 2 ;;
--service) services+=("$2"); shift 2 ;;
--all) all=true; shift ;;
--help) cmd::unblock::help; return ;;
--name) name="$2"; shift 2 ;;
--identity) identity="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;;
--ip) ips+=("$2"); shift 2 ;;
--force) force=true; shift ;;
--quiet) quiet=true; shift ;;
--subnet) subnets+=("$2"); shift 2 ;;
--port) ports+=("$2"); shift 2 ;;
--service) services+=("$2"); shift 2 ;;
--all) all=true; shift ;;
--help) cmd::unblock::help; return ;;
*)
log::error "Unknown flag: $1"
cmd::unblock::help
@ -84,30 +81,33 @@ function cmd::unblock::run() {
esac
done
# --identity: unblock all peers for this identity
if [[ -n "$identity" ]]; then
cmd::unblock::_unblock_identity "$identity" "$quiet" || return 1
return 0
fi
if [[ -z "$name" ]]; then
log::error "Missing required flag: --name"
log::error "Missing required flag: --name or --identity"
cmd::unblock::help
return 1
fi
name=$(peers::resolve_and_require "$name" "$type") || return 1
# Check if actually blocked
if ! peers::is_blocked "$name" && ! block::has_file "$name"; then
log::wg_warning "Client is not blocked: ${name}"
return 0
fi
# Default to full unblock if no specific flags given
if [[ ${#ips[@]} -eq 0 && ${#subnets[@]} -eq 0 && ${#ports[@]} -eq 0 && ${#services[@]} -eq 0 ]]; then
if [[ ${#ips[@]} -eq 0 && ${#subnets[@]} -eq 0 && \
${#ports[@]} -eq 0 && ${#services[@]} -eq 0 ]]; then
all=true
fi
local client_ip
client_ip=$(peers::get_ip "$name") || return 1
# $quiet || log::section "Unblocking client: ${name} (${client_ip})"
if $all; then
cmd::unblock::_unblock_all "$name" "$client_ip" "$quiet"
return 0
@ -117,14 +117,14 @@ function cmd::unblock::run() {
for ip in "${ips[@]}"; do
fw::unblock_ip "$client_ip" "$ip"
block::remove_rule "$name" "ip" "$ip"
$quiet || log::wg_success "${ip} has been unblocked for ${name}"
$quiet || log::wg_success "${ip} has been unblocked for ${name}"
done
# Unblock specific subnets
for subnet in "${subnets[@]}"; do
fw::unblock_subnet "$client_ip" "$subnet"
block::remove_rule "$name" "subnet" "$subnet"
$quiet || log::wg_success "${subnet} has been unblocked for ${name}"
$quiet || log::wg_success "${subnet} has been unblocked for ${name}"
done
# Unblock specific ports
@ -133,8 +133,8 @@ function cmd::unblock::run() {
IFS=":" read -r target port proto <<< "$entry"
proto="${proto:-tcp}"
fw::unblock_port "$client_ip" "$target" "$port" "$proto"
block::remove_rule "$name" "port" "$b_target" "$b_port" "$b_proto"
$quiet || log::wg_success "${client_ip}:${b_port}:${b_proto:-tcp} has been unblocked for ${name}"
block::remove_rule "$name" "port" "$target" "$port" "$proto"
$quiet || log::wg_success "${target}:${port}:${proto} has been unblocked for ${name}"
done
# Unblock services
@ -146,7 +146,6 @@ function cmd::unblock::run() {
return 1
fi
# Check if actually blocked
local is_blocked=false
for resolved in "${resolved_lines[@]}"; do
if [[ "$resolved" == *:*:* ]]; then
@ -180,27 +179,66 @@ function cmd::unblock::run() {
$quiet || log::wg_success "${svc} has been unblocked for ${name}"
done
# Clean up block file if now empty
block::cleanup "$name"
return 0
}
# ============================================
# Identity unblock
# ============================================
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
}
# ============================================
# Helpers
# ============================================
function cmd::unblock::_unblock_all() {
local name="${1:?}" client_ip="${2:?}" quiet="${3:-false}"
# Direct unblock overrides everything — clear all block state
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")
[[ -n "$rule" ]] && rule::exists "$rule" && \
if [[ -n "$rule" ]] && rule::exists "$rule"; then
rule::apply "$rule" "$client_ip" "$name"
fi
local groups
groups=$(block::get_groups "$name")
@ -209,6 +247,5 @@ function cmd::unblock::_unblock_all() {
fi
$quiet || log::wg_success "${name} has been unblocked."
return 0
}

View file

@ -60,6 +60,7 @@ function json::net_resolve() { python3 "$JSON_HELPER" net_resolve
function json::net_reverse_lookup() { python3 "$JSON_HELPER" net_reverse_lookup "$@" </dev/null; }
function json::block_is_empty() { python3 "$JSON_HELPER" block_is_empty "$@" </dev/null; }
function json::group_has_peer() { python3 "$JSON_HELPER" group_has_peer "$@" </dev/null; }
# Subnet wrappers
function json::subnet_lookup() { python3 "$JSON_HELPER" subnet_lookup "$@" </dev/null; }
function json::subnet_type() { python3 "$JSON_HELPER" subnet_type "$@" </dev/null; }
@ -72,6 +73,8 @@ function json::subnet_remove() { python3 "$JSON_HELPER" subnet_remove
function json::subnet_rename() { python3 "$JSON_HELPER" subnet_rename "$@" </dev/null; }
function json::subnet_peers() { python3 "$JSON_HELPER" subnet_peers "$@" </dev/null; }
function json::subnet_exists() { python3 "$JSON_HELPER" subnet_exists "$@" </dev/null; }
function json::subnet_default_rule() { python3 "$JSON_HELPER" subnet_default_rule "$@" </dev/null; }
function json::subnet_list_names() { python3 "$JSON_HELPER" subnet_list_names "$@" </dev/null; }
# Identity wrappers
function json::identity_list() { python3 "$JSON_HELPER" identity_list "$@" </dev/null; }

View file

@ -540,31 +540,38 @@ def count(file, key):
print(0)
def audit_fw_counts(clients_dir):
"""Return peer_name:fw_count pairs from iptables and client configs"""
import glob, subprocess
"""Return peer_name:fw_count pairs from iptables"""
import glob, subprocess, re
# Get iptables output once
try:
result = subprocess.run(
['iptables', '-L', 'FORWARD', '-n'],
['iptables', '-L', 'FORWARD', '-n', '-v'],
capture_output=True, text=True
)
fw_output = result.stdout
except:
fw_output = ""
fw_lines = result.stdout.splitlines()
except Exception:
fw_lines = []
# Build ip->name and count rules
for conf in glob.glob(f"{clients_dir}/*.conf"):
# Filter to only data lines (skip headers and blanks)
# In -v output, source IP is in column 8 (0-indexed)
# Format: pkts bytes target prot opt in out source destination [options]
rule_lines = [l for l in fw_lines if l.strip() and not l.startswith('Chain') and not l.startswith(' pkts')]
for conf in sorted(glob.glob(f"{clients_dir}/*.conf")):
name = os.path.basename(conf).replace('.conf', '')
try:
with open(conf) as f:
ip = ''
for line in f:
if line.startswith('Address'):
ip = line.split('=')[1].strip().split('/')[0]
count = fw_output.count(ip)
print(f"{name}:{count}")
break
except:
if not ip:
continue
# Count lines where source column exactly matches the peer IP
count = sum(1 for l in rule_lines if re.search(r'\s' + re.escape(ip) + r'\s', l))
print(f"{name}:{count}")
except Exception:
pass
def peer_group_map(groups_dir):
@ -599,55 +606,6 @@ def peer_groups(groups_dir, peer_name):
except:
pass
def peer_data(clients_dir, meta_dir, events_log):
import glob
meta = {}
for f in glob.glob(f"{meta_dir}/*.meta"):
name = os.path.basename(f).replace('.meta', '')
try:
with open(f) as mf:
meta[name] = json.load(mf)
except:
meta[name] = {}
last_events = {}
try:
with open(events_log) as f:
for line in f:
try:
e = json.loads(line.strip())
client = e.get('client', '')
if client:
last_events[client] = e
except:
pass
except:
pass
for conf in sorted(glob.glob(f"{clients_dir}/*.conf")):
name = os.path.basename(conf).replace('.conf', '')
ip = ''
try:
with open(conf) as f:
for line in f:
if line.startswith('Address'):
ip = line.split('=')[1].strip().split('/')[0]
break
except:
pass
m = meta.get(name, {})
rule = m.get('rule', '')
subtype = m.get('subtype', '')
main_group = m.get('main_group', '')
last_event = last_events.get(name, {})
last_ts = last_event.get('timestamp', '') # raw ISO, no formatting
last_evt = last_event.get('event', '') # fixed: was last_event
print(f"{name}|{ip}|{rule}|{subtype}|{last_ts}|{last_evt}|{main_group}")
def iso_to_ts(iso_str):
"""Convert ISO timestamp to unix timestamp"""
try:
@ -1972,7 +1930,7 @@ def identity_migrate(identities_dir, clients_dir, meta_dir, dry_run):
peer_name = os.path.basename(conf).replace('.conf', '')
parsed = _parse_peer_name(peer_name)
if not parsed:
print(f"skip|{peer_name}||0")
print(f"skip|{peer_name}")
continue
peer_type, identity_name, index = parsed
if identity_name not in grouped:
@ -2005,13 +1963,12 @@ def identity_exists(file):
# peer_data update — adds type field from meta
# ============================================
# NOTE: This replaces the existing peer_data function.
# The new version reads 'type' from meta directly instead of inferring from subtype.
# The new version reads 'type' from meta directly.
# Output format: name|ip|rule|type|last_ts|last_evt|main_group
# (field 4 is now 'type' instead of 'subtype')
def peer_data_v2(clients_dir, meta_dir, events_log):
def peer_data(clients_dir, meta_dir, events_log):
"""
Updated peer_data that reads 'type' from meta instead of 'subtype'.
Updated peer_data that reads 'type' from meta.
Output: name|ip|rule|type|last_ts|last_evt|main_group
"""
import glob
@ -2062,6 +2019,35 @@ def peer_data_v2(clients_dir, meta_dir, events_log):
print(f"{name}|{ip}|{rule}|{peer_type}|{last_ts}|{last_evt}|{main_group}")
def subnet_default_rule(file, name, type_key=''):
"""
Return the default_rule for a subnet entry, or empty string if none set.
For scalar: subnet_default_rule(file, "desktop") -> ""
For group: subnet_default_rule(file, "guests", "phone") -> "guest"
"""
data = _subnet_read(file)
if name not in data:
print('')
return
entry = data[name]
if _subnet_is_group(entry):
key = type_key if type_key else 'none'
child = entry.get(key, {})
print(child.get('default_rule', ''))
else:
print(entry.get('default_rule', ''))
def subnet_list_names(file):
"""
List all top-level subnet names, one per line.
Used for dynamic flag registration in commands.
Output: one name per line (e.g. desktop, laptop, guests, servers, iot)
"""
data = _subnet_read(file)
for name in data.keys():
print(name)
commands = {
'get': lambda args: get(args[0], args[1]),
'set': lambda args: set_key(args[0], args[1], args[2]),
@ -2083,7 +2069,7 @@ commands = {
'audit_fw_counts': lambda args: audit_fw_counts(args[0]),
'peer_group_map': lambda args: peer_group_map(args[0]),
'peer_groups': lambda args: peer_groups(args[0], args[1]),
# 'peer_data': lambda args: peer_data(args[0], args[1], args[2]),
'peer_data': lambda args: peer_data(args[0], args[1], args[2]),
'iso_to_ts': lambda args: iso_to_ts(args[0]),
'rule_list_data': lambda args: rule_list_data(args[0], args[1]),
'group_list_data': lambda args: group_list_data(args[0], args[1]),
@ -2099,7 +2085,6 @@ commands = {
'create_group': lambda args: create_group(args[0], args[1], args[2]),
'parse_event': lambda args: parse_event(args[0]),
'parse_fw_event': lambda args: parse_fw_event(args[0]),
'peer_transfer': lambda args: peer_transfer(args[0]),
'remove_events_filtered': lambda args: remove_events_filtered(
args[0], args[1], args[2], args[3], args[4]=='true', args[5]=='true', args[6] if len(args)>6 else ''),
'peer_transfer': lambda args: peer_transfer(args[0]),
@ -2183,7 +2168,8 @@ commands = {
'identity_migrate': lambda args: identity_migrate(args[0], args[1], args[2], args[3]),
'identity_infer': lambda args: identity_infer(args[0]),
'identity_exists': lambda args: identity_exists(args[0]),
'peer_data': lambda args: peer_data_v2(args[0], args[1], args[2]),
'subnet_default_rule': lambda args: subnet_default_rule(args[0], args[1], args[2] if len(args) > 2 else ''),
'subnet_list_names': lambda args: subnet_list_names(args[0]),
}
if __name__ == '__main__':

View file

@ -158,7 +158,7 @@ function internal::get_context_icon() {
function internal::log::info() { internal::log INFO "$*"; }
function internal::log::warn() { internal::log WARN "$*"; }
function internal::log::error() { internal::log ERROR "$*"; }
function internal::log::success() { internal::log SUCCESS "$*"; }
function internal::log::success() { internal::log OK "$*"; }
function internal::log::debug() { internal::log DEBUG "$*"; }
# ============================================

View file

@ -1,5 +1,5 @@
{
"phone-fred": "94.63.0.129",
"phone-fred": "176.223.61.130",
"phone-helena": "148.69.46.73",
"phone-nuno": "94.63.0.129",
"tablet-nuno": "148.69.202.5",
@ -7,5 +7,7 @@
"guest-zephyr-test": "94.63.0.129",
"desktop-roboclean": "46.189.215.231",
"laptop-nuno": "94.63.0.129",
"phone-luis": "176.223.61.15"
"phone-luis": "176.223.61.15",
"phone-helena-2": "148.69.192.157",
"desktop-zephyr": "86.120.152.74"
}

View file

@ -104,6 +104,8 @@ function block::rename() {
old_file=$(block::file "$name")
new_file=$(block::file "$new_name")
[[ -f "$old_file" ]] && mv "$old_file" "$new_file"
return 0
}
function block::clear_full_block() {

View file

@ -48,7 +48,18 @@ function config::_init_defaults() {
function config::validate() {
local errors=()
# Required fields
# Server key and config files
if [[ ! -f "$_WG_SERVER_PUBLIC_KEY_FILE" ]]; then
errors+=("Server public key not found: ${_WG_SERVER_PUBLIC_KEY_FILE}")
fi
if [[ ! -f "$_WG_SERVER_PRIVATE_KEY_FILE" ]]; then
errors+=("Server private key not found: ${_WG_SERVER_PRIVATE_KEY_FILE}")
fi
if [[ ! -f "$_WG_CONFIG" ]]; then
errors+=("WireGuard config not found: ${_WG_CONFIG}")
fi
# Required config values
local endpoint
endpoint=$(config::endpoint)
if [[ -z "$endpoint" ]]; then
@ -60,9 +71,9 @@ function config::validate() {
local port
port=$(config::port)
if [[ -z "$port" ]]; then
errors+=("WG_LISTEN_PORT is not set")
errors+=("WG_PORT is not set")
elif ! [[ "$port" =~ ^[0-9]+$ ]] || (( port < 1 || port > 65535 )); then
errors+=("WG_LISTEN_PORT must be a valid port number (1-65535)")
errors+=("WG_PORT must be a valid port number (1-65535)")
fi
local dns
@ -79,13 +90,7 @@ function config::validate() {
errors+=("WG_SUBNET is not set — required for IP allocation")
fi
local interface
interface=$(config::interface)
if [[ -z "$interface" ]]; then
errors+=("WG_INTERFACE is not set, defaulting to wg0")
fi
# Warn-only fields
# Warn-only
local lan
lan=$(config::lan)
if [[ -z "$lan" ]]; then
@ -140,38 +145,6 @@ function config::load() {
_WG_TUNNEL_SPLIT="${_WG_SUBNET}, ${_WG_LAN}"
}
# ============================================
# Device Type → Subnet Mapping
# ============================================
declare -gA DEVICE_SUBNETS=(
[desktop]="10.1.1"
[laptop]="10.1.2"
[phone]="10.1.3"
[tablet]="10.1.4"
[guest]="10.1.100"
[guest-desktop]="10.1.101"
[guest-laptop]="10.1.102"
[guest-phone]="10.1.103"
[guest-tablet]="10.1.104"
)
# ============================================
# Tunnel Modes
# ============================================
declare -gA DEVICE_TUNNEL_MODE=(
[desktop]="split"
[laptop]="split"
[phone]="split"
[tablet]="split"
[guest]="split"
[guest-desktop]="split"
[guest-laptop]="split"
[guest-phone]="split"
[guest-tablet]="split"
)
# ============================================
# Accessors
# ============================================
@ -194,46 +167,8 @@ function config::activity_current_med() { echo "$_ACTIVITY_TOTAL_MED_BYTES"; }
function config::activity_current_high() { echo "$_ACTIVITY_TOTAL_HIGH_BYTES"; }
function config::server_public_key() { cat "$_WG_SERVER_PUBLIC_KEY_FILE"; }
function config::device_types() {
local types
{ set +u; types="${!DEVICE_SUBNETS[@]}"; set -u; }
echo "$types"
}
function config::is_valid_type() {
local type="$1"
local subnet
subnet=$(config::subnet_for "$type")
[[ -n "$subnet" ]]
}
function config::is_guest_type() {
local type="$1"
[[ "$type" == "guest" || "$type" == guest-* ]]
}
function config::subnet_for() {
local type="$1"
local result
{ set +u; result="${DEVICE_SUBNETS[$type]:-}"; set -u; }
echo "$result"
}
function config::default_tunnel_for() {
local type="$1"
local result
{ set +u; result="${DEVICE_TUNNEL_MODE[$type]:-split}"; set -u; }
echo "$result"
}
function config::allowed_ips_for() {
local type="$1"
local tunnel="${2:-}"
if [[ -z "$tunnel" ]]; then
tunnel=$(config::default_tunnel_for "$type")
fi
local tunnel="${2:-split}"
case "$tunnel" in
full) echo "$_WG_TUNNEL_FULL" ;;
split) echo "$_WG_TUNNEL_SPLIT" ;;
@ -243,24 +178,3 @@ function config::allowed_ips_for() {
;;
esac
}
# ============================================
# Validation
# ============================================
function config::validate() {
if [[ ! -f "$_WG_SERVER_PUBLIC_KEY_FILE" ]]; then
log::error "Server public key not found: ${_WG_SERVER_PUBLIC_KEY_FILE}"
exit 1
fi
if [[ ! -f "$_WG_SERVER_PRIVATE_KEY_FILE" ]]; then
log::error "Server private key not found: ${_WG_SERVER_PRIVATE_KEY_FILE}"
exit 1
fi
if [[ ! -f "$_WG_CONFIG" ]]; then
log::error "WireGuard config not found: ${_WG_CONFIG}"
exit 1
fi
}

View file

@ -35,6 +35,31 @@ function identity::require_not_exists() {
fi
}
# identity::require_exists_for_flag <identity_name>
# Used by commands to validate --identity value before proceeding.
function identity::require_exists_for_flag() {
local identity_name="${1:-}"
[[ -z "$identity_name" ]] && {
log::error "Missing value for --identity"
return 1
}
# Identity may not exist yet for add (it will be created)
# Only require existence for commands that read from it
return 0
}
# identity::require_has_peers <identity_name>
# Used by block/unblock/list to ensure identity has peers to operate on.
function identity::require_has_peers() {
local identity_name="${1:-}"
local peers
peers=$(identity::peers "$identity_name")
if [[ -z "$peers" ]]; then
log::error "Identity '${identity_name}' has no peers"
return 1
fi
}
# ===========================================================================
# Peer name inference
# ===========================================================================
@ -64,6 +89,25 @@ function identity::next_index() {
json::identity_next_index "$id_file" "$peer_type" 2>/dev/null || echo 1
}
# identity::next_peer_name <identity_name> <type>
# Returns the full peer name for the next device of a given type
# for an identity. Creates the name with the correct index.
# e.g. identity::next_peer_name helena phone → phone-helena-2
# (if phone-helena already exists, index 1 is taken)
function identity::next_peer_name() {
local identity_name="${1:-}" peer_type="${2:-}"
[[ -z "$identity_name" || -z "$peer_type" ]] && return 1
local index
index=$(identity::next_index "$identity_name" "$peer_type")
if [[ "$index" -eq 1 ]]; then
echo "${peer_type}-${identity_name}"
else
echo "${peer_type}-${identity_name}-${index}"
fi
}
# ===========================================================================
# Auto-attach (called from wgctl add)
# ===========================================================================

View file

@ -15,41 +15,75 @@ function ip::is_assigned() {
ip::assigned | grep -q "^${candidate}$"
}
function ip::next_for_type() {
local type="$1"
local subnet
subnet=$(config::subnet_for "$type")
# ip::next_for_subnet <cidr>
# Finds the next unassigned host IP within a CIDR.
# Replaces ip::next_for_type for the subnet-aware allocation path.
function ip::next_for_subnet() {
local cidr="${1:-}"
if [[ -z "$subnet" ]]; then
log::error "Unknown device type: ${type}"
if [[ -z "$cidr" ]]; then
log::error "No subnet CIDR provided for IP allocation"
return 1
fi
for i in $(seq 1 254); do
local candidate="${subnet}.${i}"
if ! ip::is_assigned "$candidate"; then
echo "$candidate"
return 0
fi
local prefix
prefix=$(subnet::prefix "$cidr")
local candidate
for i in $(subnet::host_range "$cidr"); do
candidate="${prefix}.${i}"
ip::is_assigned "$candidate" || { echo "$candidate"; return 0; }
done
log::error "No available IPs in subnet ${subnet}.0/24"
log::error "No available IPs in subnet ${cidr}"
return 1
}
# ============================================
# Validation
# ============================================
function ip::is_valid() {
local ip="$1"
[[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}(/([0-9]|[1-2][0-9]|3[0-2]))?$ ]]
}
local ip="${1:-}"
[[ -z "$ip" ]] && return 1
# Strip CIDR mask if present
local addr="${ip%%/*}"
# Structural check — 4 octets, optional /mask
[[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}(/([0-9]|[1-2][0-9]|3[0-2]))?$ ]] || return 1
# Octet range check — each must be 0-255
local IFS='.'
local -a octets
read -ra octets <<< "$addr"
for octet in "${octets[@]}"; do
(( octet >= 0 && octet <= 255 )) || return 1
done
return 0
}
function ip::is_cidr() {
[[ "$1" == *"/"* ]]
}
# ip::is_valid_for_subnet <cidr> <ip>
# Convenience wrapper — validates an IP against a specific subnet.
# Delegates to subnet::ip_valid_for which handles all the checks.
function ip::is_valid_for_subnet() {
local cidr="${1:-}" ip="${2:-}"
subnet::ip_valid_for "$cidr" "$ip"
}
# ip::require_valid_for_subnet <cidr> <ip>
# Errors and returns 1 if the IP is not valid for the subnet.
# Used when a manual --ip override is provided.
function ip::require_valid_for_subnet() {
local cidr="${1:-}" ip="${2:-}"
subnet::require_ip_valid_for "$cidr" "$ip"
}
function ip::validate() {
local ip="$1"
if ! ip::is_valid "$ip"; then

View file

@ -106,6 +106,5 @@ function keys::qr() {
return 1
fi
log::wg_qr "QR code for: ${name}"
qrencode -t ansiutf8 < "$conf"
}

View file

@ -8,7 +8,7 @@ function peers::create_client_config() {
local name="$1"
local type="$2"
local ip="$3"
local allowed_ips="${4:-$(config::allowed_ips_for "$type" "$(config::default_tunnel_for "$type")")}"
local allowed_ips="${4:-$(config::allowed_ips_for "split")}"
local conf
conf="$(ctx::clients)/${name}.conf"
@ -118,16 +118,9 @@ function peers::list() {
local public_key
public_key=$(keys::public "$client_name" 2>/dev/null || echo "unknown")
# Determine type from IP
local type="unknown"
for t in $(config::device_types); do
local subnet
subnet=$(config::subnet_for "$t")
if string::starts_with "$ip" "$subnet"; then
type="$t"
break
fi
done
local type
type=$(peers::get_meta "$client_name" "type" 2>/dev/null)
[[ -z "$type" ]] && type=$(peers::get_type_from_ip "$ip")
printf " %-30s %-15s %-10s %s\n" \
"$client_name" "$ip" "$type" "$public_key"
@ -146,12 +139,12 @@ function peers::list_by_type() {
local ip
ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1)
local subnet
subnet=$(config::subnet_for "$filter_type")
local type
type=$(peers::get_meta "$client_name" "type" 2>/dev/null)
[[ -z "$type" ]] && type=$(peers::get_type_from_ip "$ip")
if string::starts_with "$ip" "$subnet"; then
[[ "$type" == "$filter_type" ]] && \
printf " %-30s %-15s\n" "$client_name" "$ip"
fi
done
}
@ -160,11 +153,6 @@ function peers::exists_in_server() {
grep -q "^# ${name}$" "$(config::config_file)"
}
# function peers::is_blocked() {
# local name="${1:-}"
# peers::exists_in_server "$name" && return 1 || return 0
# }
function peers::is_blocked() {
local name="${1:-}"
block::is_blocked "$name"
@ -183,25 +171,12 @@ function peers::get_type() {
local name="$1"
local ip
ip=$(peers::get_ip "$name")
[[ -z "$ip" ]] && echo "unknown" && return
local type="unknown"
for t in $(config::device_types); do
local subnet
subnet=$(config::subnet_for "$t")
if string::starts_with "$ip" "${subnet}."; then
type="$t"
break
fi
done
echo "$type"
[[ -z "$ip" ]] && echo "unknown" && return 0
peers::get_type_from_ip "$ip"
}
function peers::default_rule() {
local name="$1"
local type
type=$(peers::get_type "$name")
config::is_guest_type "$type" && echo "guest" || echo "user"
echo "user"
}
function peers::effective_rule() {
@ -305,7 +280,7 @@ function peers::resolve_name() {
local type="${2:-}"
if [[ -n "$type" ]]; then
if ! config::is_valid_type "$type"; then
if ! subnet::exists "$type"; then
log::error "Invalid device type: ${type}"
return 1
fi
@ -333,6 +308,15 @@ function peers::resolve_and_require() {
echo "$resolved"
}
function peers::rename_meta() {
local name="${1:-}" new_name="${2:-}"
local old_meta new_meta
old_meta=$(peers::meta_path "$name")
new_meta=$(peers::meta_path "$new_name")
[[ -f "$old_meta" ]] && mv "$old_meta" "$new_meta"
return 0
}
# ============================================
# Cleanup
# ============================================
@ -377,22 +361,6 @@ function peers::format_last_seen() {
esac
}
# function peers::format_status() {
# local name="${1:-}" public_key="${2:-}" is_blocked="${3:-false}"
# local is_restricted="${4:-false}" handshake_ts="${5:-0}" last_ts="${6:-}"
# local state
# state=$(peers::connection_state "$is_blocked" "$is_restricted" \
# "$handshake_ts" "$last_ts")
# local conn_str modifier color
# IFS="|" read -r conn_str modifier color <<< "$state"
# local display="$conn_str"
# [[ -n "$modifier" ]] && display="${conn_str} (${modifier})"
# echo -e "${color}${display}\033[0m"
# }
function peers::format_status() {
local name="${1:-}" public_key="${2:-}" is_blocked="${3:-false}"
local is_restricted="${4:-false}" handshake_ts="${5:-0}" last_ts="${6:-}"
@ -448,14 +416,8 @@ function peers::format_status_verbose() {
}
function peers::display_type() {
local type="${1:-}" subtype="${2:-}"
if config::is_guest_type "$type" && [[ -n "$subtype" && "$subtype" != "0" ]]; then
echo "guest/${subtype}"
elif config::is_guest_type "$type"; then
echo "guest"
else
echo "$type"
fi
local type="${1:-}" _subtype="${2:-}"
echo "${type:-unknown}"
}
# ============================================
@ -516,17 +478,10 @@ function peers::last_seen_data() {
function peers::get_type_from_ip() {
local ip="${1:-}"
[[ -z "$ip" ]] && echo "unknown" && return 0
local type="unknown"
for t in $(config::device_types); do
local subnet
subnet=$(config::subnet_for "$t")
string::starts_with "$ip" "${subnet}." && type="$t" && break
done
echo "$type"
subnet::type_from_ip "$ip"
}
# ============================================
# Activity
# ============================================

View file

@ -112,7 +112,6 @@ function rule::apply() {
log::debug "rule::apply: peer_name=$peer_name ip=$client_ip"
# Check if already applied
if rule::is_applied "$rule_name" "$client_ip"; then
log::wg "Rule '${rule_name}' already applied to: ${client_ip}"
[[ -n "$peer_name" ]] && peers::set_meta "$peer_name" "rule" "$rule_name"
@ -149,7 +148,6 @@ function rule::apply() {
fw::allow_port "$client_ip" "$target" "$port" "$proto"
done < <(rule::get "$rule_name" "allow_ports")
# Persist rule assignment
[[ -n "$peer_name" ]] && peers::set_meta "$peer_name" "rule" "$rule_name"
# DNS redirect
@ -221,26 +219,38 @@ function rule::unapply() {
local dns_redirect
dns_redirect=$(rule::get "$rule_name" "dns_redirect")
if [[ "$dns_redirect" == "true" ]]; then
local subtype
subtype=$(peers::get_meta "$peer_name" "subtype")
local subnet
if [[ -n "$subtype" ]]; then
subnet=$(config::subnet_for "$subtype")
else
local peer_type
peer_type=$(peers::get_type "$peer_name") || true
[[ -z "$peer_type" ]] && peer_type="phone"
subnet=$(config::subnet_for "$peer_type")
fi
rule::remove_dns_redirect "${subnet}.0/24"
local peer_ip peer_subnet
peer_ip=$(peers::get_ip "$peer_name")
peer_subnet=$(echo "$peer_ip" | cut -d'.' -f1-3)
rule::remove_dns_redirect "${peer_subnet}.0/24"
fi
# Clear rule from meta
[[ -n "$peer_name" ]] && peers::set_meta "$peer_name" "rule" ""
log::debug "Removed rule '${rule_name}' from: ${client_ip}"
}
# ============================================
# Bulk Operations
# ============================================
# rule::full_restore_peer <peer_name> <client_ip>
# Flush and fully restore all fw rules for a peer — rule rules + block rules.
# Use this instead of calling rule::apply + block::restore_rules_for separately
# to ensure block rules are never left missing after a flush.
function rule::full_restore_peer() {
local peer_name="${1:-}" client_ip="${2:-}"
[[ -z "$peer_name" || -z "$client_ip" ]] && return 1
fw::flush_peer "$client_ip"
local rule_name
rule_name=$(peers::get_meta "$peer_name" "rule")
[[ -n "$rule_name" ]] && rule::apply "$rule_name" "$client_ip" "$peer_name"
block::restore_rules_for "$peer_name" "$client_ip"
}
function rule::reapply_all() {
local rule_name="${1:-}"
rule::require_exists "$rule_name" || return 1
@ -254,9 +264,7 @@ function rule::reapply_all() {
local client_ip
client_ip=$(peers::get_ip "$peer_name")
[[ -z "$client_ip" ]] && continue
# FLUSH first to ensure clean ordering
fw::flush_peer "$client_ip"
rule::apply "$rule_name" "$client_ip" "$peer_name"
rule::full_restore_peer "$peer_name" "$client_ip"
(( count++ )) || true
done
@ -265,12 +273,10 @@ function rule::reapply_all() {
function rule::restore_all() {
while IFS= read -r peer_name; do
# Skip blocked peers - no fw rules needed when blocked
block::is_blocked "$peer_name" && continue
local rule_name
rule_name=$(peers::get_meta "$peer_name" "rule")
[[ -z "$rule_name" ]] && continue
if ! rule::exists "$rule_name"; then
@ -282,7 +288,8 @@ function rule::restore_all() {
client_ip=$(peers::get_ip "$peer_name")
[[ -z "$client_ip" ]] && continue
rule::apply "$rule_name" "$client_ip"
# full_restore_peer ensures block rules are restored alongside rule rules
rule::full_restore_peer "$peer_name" "$client_ip"
done < <(peers::all)
log::wg "Rules restored for all peers"
}
@ -333,12 +340,7 @@ function rule::render_flat() {
}
function rule::render_entries() {
# Renders allow/block entries for a rule name with annotations and DNS
# Usage: rule::render_entries <rule_name> <indent>
# indent: 4 for rule show, 4 for inspect (same)
local rule_name="${1:-}" indent="${2:-4}"
local rule_file
rule_file="$(rule::path "$rule_name")"
local allow_ports allow_ips block_ips block_ports dns
allow_ports=$(rule::get "$rule_name" "allow_ports" 2>/dev/null || true)
@ -362,7 +364,6 @@ function rule::render_entries() {
}
function rule::render_own_entries() {
# Renders own (non-inherited) entries for a rule
local rule_name="${1:-}"
local rule_file
rule_file="$(rule::path "$rule_name")"
@ -374,11 +375,8 @@ function rule::render_own_entries() {
block_ports=$(json::get "$rule_file" "block_ports" 2>/dev/null || true)
dns=$(json::get "$rule_file" "dns_redirect" 2>/dev/null || true)
local has_own=false
local combined="${allow_ports}${allow_ips}${block_ips}${block_ports}"
[[ -n "${combined//[$'\n']/}" ]] && has_own=true
$has_own || return 0
[[ -z "${combined//[$'\n']/}" ]] && return 0
while IFS= read -r e; do
[[ -z "$e" ]] && continue
@ -397,7 +395,6 @@ function rule::render_own_entries() {
}
function rule::render_extends_tree() {
# Renders full inheritance tree for a rule
local rule_name="${1:-}"
local rule_file
rule_file="$(rule::path "$rule_name")"
@ -413,7 +410,6 @@ function rule::render_extends_tree() {
rule::render_entries "$base_name"
done
# Own rules after inherited
local own_output
own_output=$(rule::render_own_entries "$rule_name")
if [[ -n "$own_output" ]]; then

View file

@ -1,17 +1,153 @@
#!/usr/bin/env bash
# subnet.module.sh — subnet map lookups, resolution, and validation
# subnet.module.sh — subnet map lookups, resolution, validation, and CIDR utilities
# All subnet data lives in subnets.json; this module wraps json:: calls
# and provides hardcoded fallbacks for safety.
# ===========================================================================
# Hardcoded fallbacks (mirrors production subnets.json)
# Used when subnets.json lookup fails — keeps existing peers working.
# These match the legacy DEVICE_SUBNETS / DEVICE_TUNNEL_MODE maps in config.module.sh.
# ===========================================================================
# ======================================================
# CIDR Utilities
# Pure functions — no external dependencies, no side effects.
# Suitable for unit testing.
# ======================================================
function subnet::_hardcoded_subnet() {
# subnet::prefix <cidr>
# Returns the first three octets of a CIDR (the allocation prefix).
# Example: 10.1.3.0/24 -> 10.1.3
function subnet::prefix() {
echo "${1%%/*}" | cut -d'.' -f1-3
}
# subnet::base_ip <cidr>
# Returns the network address without the mask.
# Example: 10.1.3.0/24 -> 10.1.3.0
function subnet::base_ip() {
echo "${1%%/*}"
}
# subnet::mask <cidr>
# Returns the prefix length.
# Example: 10.1.3.0/24 -> 24
function subnet::mask() {
echo "${1##*/}"
}
# subnet::host_range <cidr>
# Returns the iterable host range for a subnet.
# Currently supports /24 (1-254). Mask-aware implementation deferred.
function subnet::host_range() {
local cidr="${1:-}"
local mask
mask=$(subnet::mask "$cidr")
case "$mask" in
24) seq 1 254 ;;
*)
# Fallback for non-/24 — still seq 1 254, but noted for future upgrade
seq 1 254
;;
esac
}
# subnet::contains <cidr> <ip>
# Returns 0 if the IP falls within the CIDR, 1 otherwise.
# Example: subnet::contains 10.1.3.0/24 10.1.3.5 -> 0 (true)
function subnet::contains() {
local cidr="${1:-}" ip="${2:-}"
[[ -z "$cidr" || -z "$ip" ]] && return 1
local prefix mask
prefix=$(subnet::prefix "$cidr")
mask=$(subnet::mask "$cidr")
# For /24: check that the first three octets match
case "$mask" in
24)
local ip_prefix
ip_prefix=$(echo "$ip" | cut -d'.' -f1-3)
[[ "$ip_prefix" == "$prefix" ]]
;;
*)
# Delegate to Python for non-/24 subnets
python3 -c "
import ipaddress, sys
try:
net = ipaddress.ip_network('${cidr}', strict=False)
addr = ipaddress.ip_address('${ip}')
sys.exit(0 if addr in net else 1)
except Exception:
sys.exit(1)
"
;;
esac
}
# subnet::is_valid_cidr <cidr>
# Returns 0 if the string is a valid CIDR notation.
function subnet::is_valid_cidr() {
local cidr="${1:-}"
[[ "$cidr" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/([0-9]|[1-2][0-9]|3[0-2])$ ]] || return 1
# Also validate each octet is 0-255
local ip
ip=$(subnet::base_ip "$cidr")
local IFS='.'
read -ra octets <<< "$ip"
for octet in "${octets[@]}"; do
(( octet >= 0 && octet <= 255 )) || return 1
done
return 0
}
# subnet::ip_valid_for <cidr> <ip>
# Returns 0 if the IP is a valid host address within the given CIDR.
# Excludes network address (.0) and broadcast address (.255) for /24.
function subnet::ip_valid_for() {
local cidr="${1:-}" ip="${2:-}"
[[ -z "$cidr" || -z "$ip" ]] && return 1
# Must be a valid IP first
ip::is_valid "$ip" || return 1
# Must be within the subnet
subnet::contains "$cidr" "$ip" || return 1
# Must not be network or broadcast address (for /24)
local mask
mask=$(subnet::mask "$cidr")
if [[ "$mask" == "24" ]]; then
local last_octet
last_octet=$(echo "$ip" | cut -d'.' -f4)
[[ "$last_octet" == "0" || "$last_octet" == "255" ]] && return 1
fi
return 0
}
# subnet::require_valid_cidr <cidr>
# Errors and exits if the CIDR is not valid.
function subnet::require_valid_cidr() {
local cidr="${1:-}"
if ! subnet::is_valid_cidr "$cidr"; then
log::error "Invalid CIDR notation: '${cidr}'"
return 1
fi
}
# subnet::require_ip_valid_for <cidr> <ip>
# Errors and exits if the IP is not a valid host for the subnet.
function subnet::require_ip_valid_for() {
local cidr="${1:-}" ip="${2:-}"
if ! subnet::ip_valid_for "$cidr" "$ip"; then
log::error "IP '${ip}' is not a valid host address in subnet '${cidr}'"
return 1
fi
}
# ======================================================
# Hardcoded Fallbacks
# Mirror of production subnets.json.
# Used only when subnets.json lookup fails.
# ======================================================
function subnet::_hardcoded_cidr() {
local type="${1:-}" subnet_name="${2:-}"
# If a subnet name was given, try legacy guest-* mapping first
if [[ -n "$subnet_name" ]]; then
case "$subnet_name" in
guests) echo "10.1.100.0/24" ;;
@ -26,7 +162,6 @@ function subnet::_hardcoded_subnet() {
laptop) echo "10.1.2.0/24" ;;
phone) echo "10.1.3.0/24" ;;
tablet) echo "10.1.4.0/24" ;;
# Legacy guest-* types — kept for existing peers during migration
guest) echo "10.1.100.0/24" ;;
guest-desktop) echo "10.1.101.0/24" ;;
guest-laptop) echo "10.1.102.0/24" ;;
@ -57,13 +192,12 @@ function subnet::_hardcoded_type() {
}
function subnet::_hardcoded_tunnel_mode() {
# All current subnets use split — placeholder for future full-tunnel entries
echo "split"
}
# ===========================================================================
# Core resolution
# ===========================================================================
# ======================================================
# Core Resolution
# ======================================================
# subnet::lookup <subnet_name> [type_key]
# Returns the CIDR for a given subnet name and optional type.
@ -76,64 +210,48 @@ function subnet::lookup() {
echo "$result"
return 0
fi
subnet::_hardcoded_subnet "" "$subnet_name"
subnet::_hardcoded_cidr "" "$subnet_name"
}
# subnet::resolve_for_add <type> [subnet_name]
# Main entry point for wgctl add.
# Returns the CIDR to allocate from.
# Resolution order:
# 1. subnet_name given + type given -> subnets[subnet_name][type]
# 2. subnet_name given, no type -> subnets[subnet_name]["none"]
# 3. no subnet_name -> subnets[type] (scalar, type-native)
# 4. fallback -> hardcoded map
# Main entry point for wgctl add — returns the CIDR to allocate from.
function subnet::resolve_for_add() {
local peer_type="${1:-}" subnet_name="${2:-}"
local result
if [[ -n "$subnet_name" ]]; then
# Try with type key first
# Group entry: try type-specific child first, then "none" slot
if [[ -n "$peer_type" ]]; then
result=$(json::subnet_lookup "$(ctx::subnets)" "$subnet_name" "$peer_type" 2>/dev/null) || true
if [[ -n "$result" ]]; then echo "$result"; return 0; fi
[[ -n "$result" ]] && { echo "$result"; return 0; }
fi
# Fall back to "none" slot in group, or scalar entry
result=$(json::subnet_lookup "$(ctx::subnets)" "$subnet_name" 2>/dev/null) || true
if [[ -n "$result" ]]; then echo "$result"; return 0; fi
# Hardcoded fallback for subnet name
subnet::_hardcoded_subnet "" "$subnet_name"
[[ -n "$result" ]] && { echo "$result"; return 0; }
subnet::_hardcoded_cidr "" "$subnet_name"
return 0
fi
# No subnet_name — resolve from type (native allocation)
if [[ -n "$peer_type" ]]; then
result=$(json::subnet_lookup "$(ctx::subnets)" "$peer_type" 2>/dev/null) || true
if [[ -n "$result" ]]; then echo "$result"; return 0; fi
[[ -n "$result" ]] && { echo "$result"; return 0; }
fi
subnet::_hardcoded_subnet "$peer_type"
subnet::_hardcoded_cidr "$peer_type"
}
# subnet::type_for_add <type_flag> [subnet_name]
# Returns the canonical type string to store in meta.
# If --subnet given and it's a scalar, type comes from subnets.json entry.
# If --subnet is a group, type comes from --type flag (or "none").
# If no --subnet, type comes from --type flag directly.
function subnet::type_for_add() {
local type_flag="${1:-}" subnet_name="${2:-}"
local result
if [[ -n "$subnet_name" ]]; then
result=$(json::subnet_type "$(ctx::subnets)" "$subnet_name" "$type_flag" 2>/dev/null) || true
if [[ -n "$result" ]]; then echo "$result"; return 0; fi
[[ -n "$result" ]] && { echo "$result"; return 0; }
fi
# No subnet or lookup failed — use the type flag directly
if [[ -n "$type_flag" ]]; then
echo "$type_flag"
else
echo "none"
fi
echo "${type_flag:-none}"
}
# subnet::tunnel_mode <subnet_name> [type_key]
@ -142,25 +260,31 @@ function subnet::tunnel_mode() {
local subnet_name="${1:-}" type_key="${2:-}"
local result
result=$(json::subnet_tunnel_mode "$(ctx::subnets)" "$subnet_name" "$type_key" 2>/dev/null) || true
if [[ -n "$result" ]]; then echo "$result"; return 0; fi
[[ -n "$result" ]] && { echo "$result"; return 0; }
subnet::_hardcoded_tunnel_mode
}
# subnet::type_from_ip <ip>
# Reverse-lookup: given a peer's IP, return its type.
# Tries meta file first (by peer name), then subnets.json, then hardcoded.
function subnet::type_from_ip() {
local ip="${1:-}"
local result
[[ -z "$ip" ]] && echo "unknown" && return 0
# Fast path: hardcoded map covers all production subnets — pure bash, no subshell
local type
type=$(subnet::_hardcoded_type "$ip")
if [[ "$type" != "unknown" ]]; then
echo "$type"
return 0
fi
# Slow path: Python lookup for dynamically-added subnets not in hardcoded map
local result
result=$(json::subnet_for_ip "$(ctx::subnets)" "$ip" 2>/dev/null) || true
if [[ -n "$result" ]]; then
# result is "subnet_name|type_key"
echo "${result##*|}"
return 0
fi
subnet::_hardcoded_type "$ip"
echo "unknown"
}
# subnet::name_from_ip <ip>
@ -169,26 +293,34 @@ function subnet::name_from_ip() {
local ip="${1:-}"
local result
result=$(json::subnet_for_ip "$(ctx::subnets)" "$ip" 2>/dev/null) || true
if [[ -n "$result" ]]; then
echo "${result%%|*}"
return 0
fi
[[ -n "$result" ]] && { echo "${result%%|*}"; return 0; }
echo ""
}
# ===========================================================================
# Validation
# ===========================================================================
# Returns the default rule for a subnet, or empty string if none configured.
# Called by add.command.sh to resolve the rule before falling back to "user".
function subnet::default_rule() {
local subnet_name="${1:-}" type_key="${2:-}"
[[ -z "$subnet_name" ]] && return 0
json::subnet_default_rule "$(ctx::subnets)" "$subnet_name" "$type_key" 2>/dev/null || true
}
# subnet::list_names
# Returns all top-level subnet names, one per line.
# Used by commands to dynamically register --<subnet> flags.
function subnet::list_names() {
json::subnet_list_names "$(ctx::subnets)" 2>/dev/null || true
}
# ======================================================
# Validation
# ======================================================
# subnet::exists <name>
# Returns 0 if subnet exists in subnets.json, 1 otherwise.
function subnet::exists() {
local name="${1:-}"
json::subnet_exists "$(ctx::subnets)" "$name" 2>/dev/null
}
# subnet::require_exists <name>
# Errors and exits if subnet doesn't exist.
function subnet::require_exists() {
local name="${1:-}"
if ! subnet::exists "$name"; then
@ -197,9 +329,6 @@ function subnet::require_exists() {
fi
}
# subnet::peers_using <name>
# Returns comma-separated list of peer names using this subnet (from meta).
# Empty string if none.
function subnet::peers_using() {
local subnet_name="${1:-}"
local peers
@ -213,19 +342,14 @@ function subnet::peers_using() {
}
# ===========================================================================
# Display helpers
# ===========================================================================
# ======================================================
# Display Data
# ======================================================
# subnet::list_data
# Returns all subnet entries formatted for display.
# Output per line: display_name|subnet|type|tunnel_mode|desc|is_group|group_parent
function subnet::list_data() {
json::subnet_list "$(ctx::subnets)" 2>/dev/null || true
}
# subnet::show_data <name>
# Returns detail lines for a single subnet entry.
function subnet::show_data() {
local name="${1:-}"
json::subnet_show "$(ctx::subnets)" "$name"

4
wgctl
View file

@ -9,9 +9,9 @@ LOG_LEVEL=DEBUG
# Modules
# ============================================
load_module config
load_module ui
load_module ip
load_module ui
load_module config
load_module keys
load_module peers
load_module firewall