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,152 +1,144 @@
#!/usr/bin/env bash
# ============================================ # ============================================
# Lifecycle # Lifecycle
# ============================================ # ============================================
function cmd::add::on_load() { function cmd::add::on_load() {
load_module subnet
load_module identity
flag::register --name flag::register --name
flag::register --identity
flag::register --type flag::register --type
flag::register --subtype flag::register --subnet
flag::register --rule flag::register --rule
flag::register --group flag::register --group
flag::register --ip flag::register --ip
flag::register --guest
flag::register --tunnel flag::register --tunnel
flag::register --show-config
flag::register --show-qr 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)
} }
# ============================================ # ============================================
# Help # Help
# ============================================ # ============================================
function cmd::add::help() { function cmd::add::help() {
cat <<EOF cat <<EOF
Usage: wgctl add --name <name> --type <type> [options] Usage: wgctl add --name <name> --type <type> [options]
or: wgctl add --identity <identity> --type <type> [options]
Add a new WireGuard client. Add a new WireGuard client.
Options: Options:
--name <name> Client name (e.g. nuno) --name <name> Client name (e.g. nuno) — combined with type: phone-nuno
--type <type> Device type: desktop, laptop, phone, tablet, guest --identity <name> Identity name — auto-names peer with next available index
--subtype <subtype> Guest subtype: desktop, laptop, phone, tablet (mostly used for guest) --type <type> Device type: desktop, laptop, phone, tablet, server, iot
--ip <ip> Override auto-assigned IP (optional) --subnet <subnet> Subnet to allocate from (default: type-native)
--guest Shorthand for --type guest --ip <ip> Override auto-assigned IP (optional)
--tunnel <mode> Tunnel mode: split|full (default: split) --tunnel <mode> Tunnel mode: split|full (default: from subnet)
--rule <rule> Assign rule on creation (default: user, guest types: guest) --rule <rule> Assign rule on creation (default: from subnet or user)
--group <group> Add to group on creation (group must exist) --group <group> Add to group on creation (group must exist)
--show-config Shows the WireGuard peer config --show-qr Show the WireGuard config as a QR code after creation
--show-qr Shows the WireGuard config in a QR Code
Subnet shorthands (equivalent to --subnet <name>):
Device Types and Subnets: --guests, --servers, --iot, ... (see: wgctl subnet list)
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
Examples: Examples:
wgctl add --name nuno --type phone wgctl add --name nuno --type phone
wgctl add --name nuno --type laptop --ip 10.1.2.5 wgctl add --identity nuno --type phone # auto-names phone-nuno (or phone-nuno-2)
wgctl add --name nuno --type phone --tunnel full wgctl add --identity nuno --type laptop
wgctl add --name guest1 --type phone --guest wgctl add --name zephyr --type desktop --guests
wgctl add --name restricted --type desktop wgctl add --identity zephyr --type desktop --guests
wgctl add --name dev --type laptop --rule dev-01 wgctl add --name visitor --type phone --guests --show-qr
wgctl add --name visitor --type guest --show-qr
EOF EOF
} }
# ============================================ # ============================================
# Validation # Validation
# ============================================ # ============================================
function cmd::add::validate() { function cmd::add::_validate() {
local name="$1" local name="$1" identity="$2" type="$3" ip="$4" tunnel="$5"
local type="$2"
local ip="$3" if [[ -z "$name" && -z "$identity" ]]; then
local tunnel="$4" log::error "Missing required flag: --name or --identity"
if [[ -z "$name" ]]; then
log::error "Missing required flag: --name"
return 1 return 1
fi fi
if [[ -z "$type" ]]; then if [[ -z "$type" ]]; then
log::error "Missing required flag: --type" log::error "Missing required flag: --type"
return 1 return 1
fi fi
if ! config::is_valid_type "$type"; then if ! json::subnet_exists "$(ctx::subnets)" "$type" 2>/dev/null; then
log::error "Invalid device type: ${type}" log::error "Unknown device type: '${type}'"
log::info "Valid types: $(config::device_types | tr ' ' ', ')" log::info "Use 'wgctl subnet list' to see valid types and subnets"
return 1 return 1
fi fi
if [[ -n "$tunnel" && "$tunnel" != "split" && "$tunnel" != "full" ]]; then 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 return 1
fi fi
if [[ -n "$ip" ]]; then if [[ -n "$ip" ]]; then
ip::require_valid "$ip" ip::require_valid "$ip"
fi fi
}
local full_name="${type}-${name}"
function cmd::add::_validate_not_exists() {
local full_name="$1"
if [[ -f "$(ctx::clients)/${full_name}.conf" ]]; then if [[ -f "$(ctx::clients)/${full_name}.conf" ]]; then
log::error "Client already exists: ${full_name}" log::error "Client already exists: ${full_name}"
return 1 return 1
fi fi
} }
# ============================================ # ============================================
# Display helpers # Display helpers
# ============================================ # ============================================
function cmd::add::is_mobile() { function cmd::add::is_mobile() {
local type="$1" local type="$1"
[[ "$type" == "phone" || "$type" == "tablet" ]] [[ "$type" == "phone" || "$type" == "tablet" ]]
} }
# ============================================ # ============================================
# Run # Run
# ============================================ # ============================================
function cmd::add::run() { function cmd::add::run() {
local name="" local name="" identity="" type="" subnet_name="" rule="" \
local type="" group="" ip="" tunnel="" show_qr=false
local subtype=""
local rule=""
local group=""
local ip=""
local tunnel=""
local guest=false
local show_config=false
local show_qr=false
# Parse flags
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--name) name="$2"; shift 2 ;; --name) name="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;; --identity) identity="$2"; shift 2 ;;
--subtype) subtype="$2"; shift 2 ;; --type) type="$2"; shift 2 ;;
--rule) rule="$2"; shift 2 ;; --subnet) subnet_name="$2"; shift 2 ;;
--group) group="$2"; shift 2 ;; --rule) rule="$2"; shift 2 ;;
--ip) ip="$2"; shift 2 ;; --group) group="$2"; shift 2 ;;
--guest) guest=true; shift ;; --ip) ip="$2"; shift 2 ;;
--tunnel) tunnel="$2"; shift 2 ;; --tunnel) tunnel="$2"; shift 2 ;;
--show-config) show_config=true; shift ;; --show-qr) show_qr=true; shift ;;
--show-qr) show_qr=true; shift ;; --help) cmd::add::help; return ;;
--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" log::error "Unknown flag: $1"
cmd::add::help cmd::add::help
@ -154,82 +146,112 @@ function cmd::add::run() {
;; ;;
esac esac
done done
$guest && type="guest" cmd::add::_validate "$name" "$identity" "$type" "$ip" "$tunnel" || return 1
local effective_type # Resolve full peer name
effective_type=$(cmd::add::_resolve_type "$type" "$subtype") || return 1 local full_name
if [[ -n "$identity" ]]; then
local full_name="${type}-${name}" full_name=$(identity::next_peer_name "$identity" "$type") || return 1
cmd::add::validate "$name" "$type" "$ip" "$tunnel" || return 1 log::info "Auto-named: ${full_name}"
[[ -z "$tunnel" ]] && tunnel=$(config::default_tunnel_for "$type")
[[ -z "$rule" ]] && rule=$(cmd::add::_default_rule "$type")
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
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
fi
local public_key
public_key=$(keys::public "$full_name") || return 1
peers::add_to_server "$full_name" "$public_key" "$ip" || return 1
[[ -n "$rule" ]] && rule::apply "$rule" "$ip" || return 1
peers::reload || return 1
log::wg_success "Client added successfully: ${full_name} (${ip}) [${tunnel} tunnel]"
cmd::add::_show_result "$full_name" "${subtype:-$type}"
}
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 else
echo "$type" full_name="${type}-${name}"
fi fi
cmd::add::_validate_not_exists "$full_name" || return 1
# 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 "$tunnel") || return 1
log::section "Adding client: ${full_name}"
# 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
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: ${full_name} (${ip}) [${tunnel} tunnel]"
cmd::add::_show_result "$full_name" "$resolved_type" "$show_qr"
} }
function cmd::add::_default_rule() { # ============================================
local type="$1" # Internal helpers
config::is_guest_type "$type" && echo "guest" || echo "user" # ============================================
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
group::add_peer "$group" "$full_name"
log::wg "Added to group: ${group}"
} }
function cmd::add::_show_result() { function cmd::add::_show_result() {
local full_name="$1" display_type="$2" local full_name="${1:-}" type="${2:-}" show_qr="${3:-false}"
if cmd::add::is_mobile "$display_type"; then if $show_qr || cmd::add::is_mobile "$type"; then
log::section "Client QR"
keys::qr "$full_name" keys::qr "$full_name"
else else
log::section "Client Config" log::section "Client Config"
cat "$(ctx::clients)/${full_name}.conf" cat "$(ctx::clients)/${full_name}.conf"
fi fi
} }

View file

@ -6,6 +6,7 @@
function cmd::block::on_load() { function cmd::block::on_load() {
flag::register --name flag::register --name
flag::register --identity
flag::register --type flag::register --type
flag::register --force flag::register --force
flag::register --quiet flag::register --quiet
@ -14,8 +15,6 @@ function cmd::block::on_load() {
flag::register --proto flag::register --proto
flag::register --subnet flag::register --subnet
flag::register --block-name flag::register --block-name
# System - NET Services
flag::register --service flag::register --service
} }
@ -26,6 +25,7 @@ function cmd::block::on_load() {
function cmd::block::help() { function cmd::block::help() {
cat <<EOF cat <<EOF
Usage: wgctl block --name <name> [options] 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. Block a client entirely or restrict access to specific IPs/ports/subnets/services.
All block rules are persisted and restored on WireGuard restart. 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: Options:
--name <name> Client name (e.g. phone-nuno) --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) --type <type> Device type (optional, combines with --name)
--ip <ip> Block access to specific IP (repeatable) --ip <ip> Block access to specific IP (repeatable)
--subnet <cidr> Block access to subnet (repeatable) --subnet <cidr> Block access to subnet (repeatable)
--port <ip:port:proto> Block specific port, e.g. 10.0.0.210:9000:tcp (repeatable) --port <ip:port:proto> Block specific port (repeatable)
--service <name> Block a named service (e.g. proxmox, truenas:web-ui) (repeatable) --service <name> Block a named service (repeatable)
--block-name <name> Optional name for this block rule --block-name <name> Optional name for this block rule
--quiet Suppress output (used by group block) --quiet Suppress output (used by group block)
Examples: Examples:
wgctl block --name phone-nuno wgctl block --name phone-nuno
wgctl block --identity nuno
wgctl block --name nuno --type phone wgctl block --name nuno --type phone
wgctl block --name phone-nuno --ip 10.0.0.210 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 proxmox
wgctl block --name phone-nuno --service truenas:web-ui --block-name "no truenas ui"
wgctl ban --name phone-nuno wgctl ban --name phone-nuno
EOF EOF
} }
# ============================================ # ============================================
# Block Run # Run
# ============================================ # ============================================
function cmd::block::run() { function cmd::block::run() {
local name="" type="" block_name="" local name="" identity="" type="" block_name=""
local ips=() subnets=() ports=() services=() local ips=() subnets=() ports=() services=()
local quiet=false force=false local quiet=false force=false
local changed=false
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--name) name="$2"; shift 2 ;; --name) name="$2"; shift 2 ;;
--identity) identity="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;; --type) type="$2"; shift 2 ;;
--ip) ips+=("$2"); shift 2 ;; --ip) ips+=("$2"); shift 2 ;;
--block-name) block_name="$2"; shift 2 ;; --block-name) block_name="$2"; shift 2 ;;
@ -75,16 +74,27 @@ function cmd::block::run() {
--quiet) quiet=true; shift ;; --quiet) quiet=true; shift ;;
--subnet) subnets+=("$2"); shift 2 ;; --subnet) subnets+=("$2"); shift 2 ;;
--port) ports+=("$2"); shift 2 ;; --port) ports+=("$2"); shift 2 ;;
--help) cmd::block::help; return ;; --help) cmd::block::help; return ;;
*) *)
log::error "Unknown flag: $1" log::error "Unknown flag: $1"
cmd::block::help cmd::block::help
return 1 ;; return 1
;;
esac esac
done done
[[ -z "$name" ]] && log::error "Missing required flag: --name" && \ # --identity: block all peers for this identity
cmd::block::help && return 1 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 name=$(peers::resolve_and_require "$name" "$type") || return 1
@ -113,6 +123,8 @@ function cmd::block::run() {
fi fi
fi fi
local changed=false
# Block specific IPs # Block specific IPs
for ip in "${ips[@]}"; do for ip in "${ips[@]}"; do
ip::require_valid "$ip" ip::require_valid "$ip"
@ -137,7 +149,7 @@ function cmd::block::run() {
fw::block_port "$client_ip" "$b_target" "$b_port" "${b_proto:-tcp}" fw::block_port "$client_ip" "$b_target" "$b_port" "${b_proto:-tcp}"
block::add_rule "$name" "$client_ip" "port" "${block_name:-}" \ block::add_rule "$name" "$client_ip" "port" "${block_name:-}" \
"$b_target" "$b_port" "${b_proto:-tcp}" "$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 done
# Block services # Block services
@ -149,13 +161,12 @@ function cmd::block::run() {
return 1 return 1
fi fi
# Check if already blocked
local already_blocked=true local already_blocked=true
for resolved in "${resolved_lines[@]}"; do for resolved in "${resolved_lines[@]}"; do
if [[ "$resolved" == *:*:* ]]; then if [[ "$resolved" == *:*:* ]]; then
local b_ip b_port b_proto local b_ip b_port b_proto
IFS=":" read -r b_ip b_port b_proto <<< "$resolved" 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; } { already_blocked=false; break; }
else else
fw::has_block_rule "$client_ip" "$resolved" 2>/dev/null || \ fw::has_block_rule "$client_ip" "$resolved" 2>/dev/null || \
@ -167,7 +178,7 @@ function cmd::block::run() {
$quiet || log::wg_warning "${svc} is already blocked for ${name}" $quiet || log::wg_warning "${svc} is already blocked for ${name}"
continue continue
fi fi
for resolved in "${resolved_lines[@]}"; do for resolved in "${resolved_lines[@]}"; do
if [[ "$resolved" == *:*:* ]]; then if [[ "$resolved" == *:*:* ]]; then
local b_ip b_port b_proto local b_ip b_port b_proto
@ -186,10 +197,8 @@ function cmd::block::run() {
done done
[[ ${#ips[@]} -gt 0 || ${#ports[@]} -gt 0 || \ [[ ${#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 if $changed; then
local peer_rule local peer_rule
peer_rule=$(peers::get_meta "$name" "rule") peer_rule=$(peers::get_meta "$name" "rule")
@ -203,6 +212,45 @@ function cmd::block::run() {
return 0 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() { function cmd::block::_get_endpoint() {
local name="$1" public_key="$2" local name="$1" public_key="$2"
local endpoint local endpoint
@ -218,10 +266,7 @@ function cmd::block::_block_all() {
local client_ip="${2:?client_ip required}" local client_ip="${2:?client_ip required}"
local quiet="${3:-false}" local quiet="${3:-false}"
# Apply fw rules and remove from server
block::apply_full "$name" "$client_ip" block::apply_full "$name" "$client_ip"
# Mark as directly blocked
block::set_direct "$name" "$client_ip" "true" block::set_direct "$name" "$client_ip" "true"
$quiet || log::wg_success "${name} has been blocked." $quiet || log::wg_success "${name} has been blocked."

View file

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

View file

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

View file

@ -89,7 +89,7 @@ function cmd::rename::run() {
# Update identity entry after successful rename # Update identity entry after successful rename
identity::rename_peer "$name" "$new_name" identity::rename_peer "$name" "$new_name"
log::wg_success "Client renamed: ${name}${new_name}" log::wg_success "Client renamed: ${name}${new_name}"
} }
@ -105,9 +105,5 @@ function cmd::rename::_rename_files() {
sed -i "s/^# ${name}$/# ${new_name}/" "$(config::config_file)" sed -i "s/^# ${name}$/# ${new_name}/" "$(config::config_file)"
block::rename "$name" "$new_name" block::rename "$name" "$new_name"
peers::rename_meta "$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"
} }

View file

@ -7,11 +7,13 @@ function cmd::test::section_destructive() {
test::section "Destructive (modifying state)" test::section "Destructive (modifying state)"
# Cleanup from any previous failed run # 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 testgroup --force > /dev/null 2>&1 || true
"$WGCTL_BINARY" group remove --name testgroup2 --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" 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_peer
cmd::test::_destructive_block_unblock cmd::test::_destructive_block_unblock
@ -28,15 +30,15 @@ function cmd::test::_destructive_peer() {
} }
function cmd::test::_destructive_block_unblock() { function cmd::test::_destructive_block_unblock() {
cmd::test::run_cmd "block peer" "blocked" block --name phone-testunit 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 "list shows blocked" "phone-testunit" list --blocked
cmd::test::run_cmd "unblock peer" "unblocked" unblock --name phone-testunit 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 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 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 unblock --name phone-testunit --ip 10.0.0.99
} }
@ -63,12 +65,11 @@ function cmd::test::_destructive_rule() {
} }
function cmd::test::_destructive_groups() { 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 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 block" "blocked" group block --name testgroup
cmd::test::run_cmd "group unblock" "unblocked" group unblock --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 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 "$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 cmd::test::run_cmd "group block second" "blocked" group block --name testgroup2
"$WGCTL_BINARY" group unblock --name testgroup > /dev/null 2>&1 "$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 "$WGCTL_BINARY" group unblock --name testgroup2 > /dev/null 2>&1
cmd::test::run_cmd "peer unblocked after all groups unblock" "phone-testunit" list --allowed 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 "$WGCTL_BINARY" group block --name testgroup > /dev/null 2>&1
cmd::test::run_cmd "direct unblock overrides group block" "unblocked" unblock --name phone-testunit 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() { function cmd::test::_destructive_identity() {
test::section "Destructive: identity auto-attach/detach" 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" \ cmd::test::run_cmd "add attaches to identity" "added" \
add --name testunit2 --type laptop add --name testunit2 --type laptop
cmd::test::run_cmd "identity shows testunit2" "testunit2" \ cmd::test::run_cmd "identity created for testunit2" "laptop-testunit2" \
identity show --name testunit identity show --name testunit2
# Rename verifies identity::rename_peer # Rename — verify identity::rename_peer moves peer to new identity "testunit2b"
cmd::test::run_cmd "rename updates identity" "renamed" \ cmd::test::run_cmd "rename peer" "renamed" \
rename --name laptop-testunit2 --new-name laptop-testunit2b rename --name laptop-testunit2 --new-name laptop-testunit2b
cmd::test::run_cmd "identity reflects rename" "testunit2b" \ cmd::test::run_cmd "identity reflects rename (new identity)" "laptop-testunit2b" \
identity show --name testunit identity show --name testunit2b
# Remove verifies auto-detach cmd::test::run_cmd_fails "old identity gone after rename" \
"$WGCTL_BINARY" remove --name laptop-testunit2b --force > /dev/null 2>&1 || true 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() { function cmd::test::_destructive_cleanup() {

View file

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

View file

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

View file

@ -540,31 +540,38 @@ def count(file, key):
print(0) print(0)
def audit_fw_counts(clients_dir): def audit_fw_counts(clients_dir):
"""Return peer_name:fw_count pairs from iptables and client configs""" """Return peer_name:fw_count pairs from iptables"""
import glob, subprocess import glob, subprocess, re
# Get iptables output once
try: try:
result = subprocess.run( result = subprocess.run(
['iptables', '-L', 'FORWARD', '-n'], ['iptables', '-L', 'FORWARD', '-n', '-v'],
capture_output=True, text=True capture_output=True, text=True
) )
fw_output = result.stdout fw_lines = result.stdout.splitlines()
except: except Exception:
fw_output = "" fw_lines = []
# Build ip->name and count rules # Filter to only data lines (skip headers and blanks)
for conf in glob.glob(f"{clients_dir}/*.conf"): # 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', '') name = os.path.basename(conf).replace('.conf', '')
try: try:
with open(conf) as f: with open(conf) as f:
ip = ''
for line in f: for line in f:
if line.startswith('Address'): if line.startswith('Address'):
ip = line.split('=')[1].strip().split('/')[0] ip = line.split('=')[1].strip().split('/')[0]
count = fw_output.count(ip)
print(f"{name}:{count}")
break 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 pass
def peer_group_map(groups_dir): def peer_group_map(groups_dir):
@ -599,55 +606,6 @@ def peer_groups(groups_dir, peer_name):
except: except:
pass 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): def iso_to_ts(iso_str):
"""Convert ISO timestamp to unix timestamp""" """Convert ISO timestamp to unix timestamp"""
try: try:
@ -1972,7 +1930,7 @@ def identity_migrate(identities_dir, clients_dir, meta_dir, dry_run):
peer_name = os.path.basename(conf).replace('.conf', '') peer_name = os.path.basename(conf).replace('.conf', '')
parsed = _parse_peer_name(peer_name) parsed = _parse_peer_name(peer_name)
if not parsed: if not parsed:
print(f"skip|{peer_name}||0") print(f"skip|{peer_name}")
continue continue
peer_type, identity_name, index = parsed peer_type, identity_name, index = parsed
if identity_name not in grouped: if identity_name not in grouped:
@ -2005,13 +1963,12 @@ def identity_exists(file):
# peer_data update — adds type field from meta # peer_data update — adds type field from meta
# ============================================ # ============================================
# NOTE: This replaces the existing peer_data function. # 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 # 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 Output: name|ip|rule|type|last_ts|last_evt|main_group
""" """
import glob 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}") 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 = { commands = {
'get': lambda args: get(args[0], args[1]), 'get': lambda args: get(args[0], args[1]),
'set': lambda args: set_key(args[0], args[1], args[2]), '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]), 'audit_fw_counts': lambda args: audit_fw_counts(args[0]),
'peer_group_map': lambda args: peer_group_map(args[0]), 'peer_group_map': lambda args: peer_group_map(args[0]),
'peer_groups': lambda args: peer_groups(args[0], args[1]), '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]), 'iso_to_ts': lambda args: iso_to_ts(args[0]),
'rule_list_data': lambda args: rule_list_data(args[0], args[1]), 'rule_list_data': lambda args: rule_list_data(args[0], args[1]),
'group_list_data': lambda args: group_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]), 'create_group': lambda args: create_group(args[0], args[1], args[2]),
'parse_event': lambda args: parse_event(args[0]), 'parse_event': lambda args: parse_event(args[0]),
'parse_fw_event': lambda args: parse_fw_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( '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 ''), 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]), '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_migrate': lambda args: identity_migrate(args[0], args[1], args[2], args[3]),
'identity_infer': lambda args: identity_infer(args[0]), 'identity_infer': lambda args: identity_infer(args[0]),
'identity_exists': lambda args: identity_exists(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__': if __name__ == '__main__':

View file

@ -158,7 +158,7 @@ function internal::get_context_icon() {
function internal::log::info() { internal::log INFO "$*"; } function internal::log::info() { internal::log INFO "$*"; }
function internal::log::warn() { internal::log WARN "$*"; } function internal::log::warn() { internal::log WARN "$*"; }
function internal::log::error() { internal::log ERROR "$*"; } 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 "$*"; } 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-helena": "148.69.46.73",
"phone-nuno": "94.63.0.129", "phone-nuno": "94.63.0.129",
"tablet-nuno": "148.69.202.5", "tablet-nuno": "148.69.202.5",
@ -7,5 +7,7 @@
"guest-zephyr-test": "94.63.0.129", "guest-zephyr-test": "94.63.0.129",
"desktop-roboclean": "46.189.215.231", "desktop-roboclean": "46.189.215.231",
"laptop-nuno": "94.63.0.129", "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") old_file=$(block::file "$name")
new_file=$(block::file "$new_name") new_file=$(block::file "$new_name")
[[ -f "$old_file" ]] && mv "$old_file" "$new_file" [[ -f "$old_file" ]] && mv "$old_file" "$new_file"
return 0
} }
function block::clear_full_block() { function block::clear_full_block() {

View file

@ -48,7 +48,18 @@ function config::_init_defaults() {
function config::validate() { function config::validate() {
local errors=() 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 local endpoint
endpoint=$(config::endpoint) endpoint=$(config::endpoint)
if [[ -z "$endpoint" ]]; then if [[ -z "$endpoint" ]]; then
@ -60,9 +71,9 @@ function config::validate() {
local port local port
port=$(config::port) port=$(config::port)
if [[ -z "$port" ]]; then 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 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 fi
local dns local dns
@ -79,13 +90,7 @@ function config::validate() {
errors+=("WG_SUBNET is not set — required for IP allocation") errors+=("WG_SUBNET is not set — required for IP allocation")
fi fi
local interface # Warn-only
interface=$(config::interface)
if [[ -z "$interface" ]]; then
errors+=("WG_INTERFACE is not set, defaulting to wg0")
fi
# Warn-only fields
local lan local lan
lan=$(config::lan) lan=$(config::lan)
if [[ -z "$lan" ]]; then if [[ -z "$lan" ]]; then
@ -140,38 +145,6 @@ function config::load() {
_WG_TUNNEL_SPLIT="${_WG_SUBNET}, ${_WG_LAN}" _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 # 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::activity_current_high() { echo "$_ACTIVITY_TOTAL_HIGH_BYTES"; }
function config::server_public_key() { cat "$_WG_SERVER_PUBLIC_KEY_FILE"; } 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() { function config::allowed_ips_for() {
local type="$1" local tunnel="${2:-split}"
local tunnel="${2:-}"
if [[ -z "$tunnel" ]]; then
tunnel=$(config::default_tunnel_for "$type")
fi
case "$tunnel" in case "$tunnel" in
full) echo "$_WG_TUNNEL_FULL" ;; full) echo "$_WG_TUNNEL_FULL" ;;
split) echo "$_WG_TUNNEL_SPLIT" ;; split) echo "$_WG_TUNNEL_SPLIT" ;;
@ -242,25 +177,4 @@ function config::allowed_ips_for() {
return 1 return 1
;; ;;
esac 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

@ -34,6 +34,31 @@ function identity::require_not_exists() {
return 1 return 1
fi 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 # Peer name inference
@ -64,6 +89,25 @@ function identity::next_index() {
json::identity_next_index "$id_file" "$peer_type" 2>/dev/null || echo 1 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) # Auto-attach (called from wgctl add)
# =========================================================================== # ===========================================================================

View file

@ -15,40 +15,74 @@ function ip::is_assigned() {
ip::assigned | grep -q "^${candidate}$" ip::assigned | grep -q "^${candidate}$"
} }
function ip::next_for_type() { # ip::next_for_subnet <cidr>
local type="$1" # Finds the next unassigned host IP within a CIDR.
local subnet # Replaces ip::next_for_type for the subnet-aware allocation path.
subnet=$(config::subnet_for "$type") 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 return 1
fi fi
for i in $(seq 1 254); do local prefix
local candidate="${subnet}.${i}" prefix=$(subnet::prefix "$cidr")
if ! ip::is_assigned "$candidate"; then
echo "$candidate" local candidate
return 0 for i in $(subnet::host_range "$cidr"); do
fi candidate="${prefix}.${i}"
ip::is_assigned "$candidate" || { echo "$candidate"; return 0; }
done done
log::error "No available IPs in subnet ${subnet}.0/24" log::error "No available IPs in subnet ${cidr}"
return 1 return 1
} }
# ============================================ # ============================================
# Validation # Validation
# ============================================ # ============================================
function ip::is_valid() { function ip::is_valid() {
local ip="$1" local ip="${1:-}"
[[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}(/([0-9]|[1-2][0-9]|3[0-2]))?$ ]] [[ -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() { function ip::is_cidr() {
[[ "$1" == *"/"* ]] [[ "$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() { function ip::validate() {
local ip="$1" local ip="$1"

View file

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

View file

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

View file

@ -70,7 +70,7 @@ function rule::is_applied() {
local target port proto local target port proto
IFS=":" read -r target port proto <<< "$first_port" IFS=":" read -r target port proto <<< "$first_port"
proto="${proto:-tcp}" proto="${proto:-tcp}"
fw::has_block_rule "$client_ip" "$target" "$proto" "$port" fw::has_block_rule "$client_ip" "$target" "$proto" "$port"
return $? return $?
fi fi
@ -112,7 +112,6 @@ function rule::apply() {
log::debug "rule::apply: peer_name=$peer_name ip=$client_ip" log::debug "rule::apply: peer_name=$peer_name ip=$client_ip"
# Check if already applied
if rule::is_applied "$rule_name" "$client_ip"; then if rule::is_applied "$rule_name" "$client_ip"; then
log::wg "Rule '${rule_name}' already applied to: ${client_ip}" log::wg "Rule '${rule_name}' already applied to: ${client_ip}"
[[ -n "$peer_name" ]] && peers::set_meta "$peer_name" "rule" "$rule_name" [[ -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" fw::allow_port "$client_ip" "$target" "$port" "$proto"
done < <(rule::get "$rule_name" "allow_ports") done < <(rule::get "$rule_name" "allow_ports")
# Persist rule assignment
[[ -n "$peer_name" ]] && peers::set_meta "$peer_name" "rule" "$rule_name" [[ -n "$peer_name" ]] && peers::set_meta "$peer_name" "rule" "$rule_name"
# DNS redirect # DNS redirect
@ -221,26 +219,38 @@ function rule::unapply() {
local dns_redirect local dns_redirect
dns_redirect=$(rule::get "$rule_name" "dns_redirect") dns_redirect=$(rule::get "$rule_name" "dns_redirect")
if [[ "$dns_redirect" == "true" ]]; then if [[ "$dns_redirect" == "true" ]]; then
local subtype local peer_ip peer_subnet
subtype=$(peers::get_meta "$peer_name" "subtype") peer_ip=$(peers::get_ip "$peer_name")
local subnet peer_subnet=$(echo "$peer_ip" | cut -d'.' -f1-3)
if [[ -n "$subtype" ]]; then rule::remove_dns_redirect "${peer_subnet}.0/24"
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"
fi fi
# Clear rule from meta
[[ -n "$peer_name" ]] && peers::set_meta "$peer_name" "rule" "" [[ -n "$peer_name" ]] && peers::set_meta "$peer_name" "rule" ""
log::debug "Removed rule '${rule_name}' from: ${client_ip}" 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() { function rule::reapply_all() {
local rule_name="${1:-}" local rule_name="${1:-}"
rule::require_exists "$rule_name" || return 1 rule::require_exists "$rule_name" || return 1
@ -254,9 +264,7 @@ function rule::reapply_all() {
local client_ip local client_ip
client_ip=$(peers::get_ip "$peer_name") client_ip=$(peers::get_ip "$peer_name")
[[ -z "$client_ip" ]] && continue [[ -z "$client_ip" ]] && continue
# FLUSH first to ensure clean ordering rule::full_restore_peer "$peer_name" "$client_ip"
fw::flush_peer "$client_ip"
rule::apply "$rule_name" "$client_ip" "$peer_name"
(( count++ )) || true (( count++ )) || true
done done
@ -265,12 +273,10 @@ function rule::reapply_all() {
function rule::restore_all() { function rule::restore_all() {
while IFS= read -r peer_name; do while IFS= read -r peer_name; do
# Skip blocked peers - no fw rules needed when blocked
block::is_blocked "$peer_name" && continue block::is_blocked "$peer_name" && continue
local rule_name local rule_name
rule_name=$(peers::get_meta "$peer_name" "rule") rule_name=$(peers::get_meta "$peer_name" "rule")
[[ -z "$rule_name" ]] && continue [[ -z "$rule_name" ]] && continue
if ! rule::exists "$rule_name"; then if ! rule::exists "$rule_name"; then
@ -282,7 +288,8 @@ function rule::restore_all() {
client_ip=$(peers::get_ip "$peer_name") client_ip=$(peers::get_ip "$peer_name")
[[ -z "$client_ip" ]] && continue [[ -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) done < <(peers::all)
log::wg "Rules restored for all peers" log::wg "Rules restored for all peers"
} }
@ -333,12 +340,7 @@ function rule::render_flat() {
} }
function rule::render_entries() { 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_name="${1:-}" indent="${2:-4}"
local rule_file
rule_file="$(rule::path "$rule_name")"
local allow_ports allow_ips block_ips block_ports dns local allow_ports allow_ips block_ips block_ports dns
allow_ports=$(rule::get "$rule_name" "allow_ports" 2>/dev/null || true) 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() { function rule::render_own_entries() {
# Renders own (non-inherited) entries for a rule
local rule_name="${1:-}" local rule_name="${1:-}"
local rule_file local rule_file
rule_file="$(rule::path "$rule_name")" 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) block_ports=$(json::get "$rule_file" "block_ports" 2>/dev/null || true)
dns=$(json::get "$rule_file" "dns_redirect" 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}" local combined="${allow_ports}${allow_ips}${block_ips}${block_ports}"
[[ -n "${combined//[$'\n']/}" ]] && has_own=true [[ -z "${combined//[$'\n']/}" ]] && return 0
$has_own || return 0
while IFS= read -r e; do while IFS= read -r e; do
[[ -z "$e" ]] && continue [[ -z "$e" ]] && continue
@ -397,7 +395,6 @@ function rule::render_own_entries() {
} }
function rule::render_extends_tree() { function rule::render_extends_tree() {
# Renders full inheritance tree for a rule
local rule_name="${1:-}" local rule_name="${1:-}"
local rule_file local rule_file
rule_file="$(rule::path "$rule_name")" rule_file="$(rule::path "$rule_name")"
@ -413,7 +410,6 @@ function rule::render_extends_tree() {
rule::render_entries "$base_name" rule::render_entries "$base_name"
done done
# Own rules after inherited
local own_output local own_output
own_output=$(rule::render_own_entries "$rule_name") own_output=$(rule::render_own_entries "$rule_name")
if [[ -n "$own_output" ]]; then if [[ -n "$own_output" ]]; then
@ -436,4 +432,4 @@ function rule::apply_dns_redirect() {
function rule::remove_dns_redirect() { function rule::remove_dns_redirect() {
local client_subnet="${1:-}" local client_subnet="${1:-}"
fw::nat_remove_dns_redirect "$client_subnet" "$(config::dns)" "$(config::interface)" fw::nat_remove_dns_redirect "$client_subnet" "$(config::dns)" "$(config::interface)"
} }

View file

@ -1,17 +1,153 @@
#!/usr/bin/env bash #!/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 # All subnet data lives in subnets.json; this module wraps json:: calls
# and provides hardcoded fallbacks for safety. # and provides hardcoded fallbacks for safety.
# =========================================================================== # ======================================================
# Hardcoded fallbacks (mirrors production subnets.json) # CIDR Utilities
# Used when subnets.json lookup fails — keeps existing peers working. # Pure functions — no external dependencies, no side effects.
# These match the legacy DEVICE_SUBNETS / DEVICE_TUNNEL_MODE maps in config.module.sh. # 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:-}" local type="${1:-}" subnet_name="${2:-}"
# If a subnet name was given, try legacy guest-* mapping first
if [[ -n "$subnet_name" ]]; then if [[ -n "$subnet_name" ]]; then
case "$subnet_name" in case "$subnet_name" in
guests) echo "10.1.100.0/24" ;; guests) echo "10.1.100.0/24" ;;
@ -26,7 +162,6 @@ function subnet::_hardcoded_subnet() {
laptop) echo "10.1.2.0/24" ;; laptop) echo "10.1.2.0/24" ;;
phone) echo "10.1.3.0/24" ;; phone) echo "10.1.3.0/24" ;;
tablet) echo "10.1.4.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) echo "10.1.100.0/24" ;;
guest-desktop) echo "10.1.101.0/24" ;; guest-desktop) echo "10.1.101.0/24" ;;
guest-laptop) echo "10.1.102.0/24" ;; guest-laptop) echo "10.1.102.0/24" ;;
@ -57,13 +192,12 @@ function subnet::_hardcoded_type() {
} }
function subnet::_hardcoded_tunnel_mode() { function subnet::_hardcoded_tunnel_mode() {
# All current subnets use split — placeholder for future full-tunnel entries
echo "split" echo "split"
} }
# =========================================================================== # ======================================================
# Core resolution # Core Resolution
# =========================================================================== # ======================================================
# subnet::lookup <subnet_name> [type_key] # subnet::lookup <subnet_name> [type_key]
# Returns the CIDR for a given subnet name and optional type. # Returns the CIDR for a given subnet name and optional type.
@ -76,64 +210,48 @@ function subnet::lookup() {
echo "$result" echo "$result"
return 0 return 0
fi fi
subnet::_hardcoded_subnet "" "$subnet_name" subnet::_hardcoded_cidr "" "$subnet_name"
} }
# subnet::resolve_for_add <type> [subnet_name] # subnet::resolve_for_add <type> [subnet_name]
# Main entry point for wgctl add. # Main entry point for wgctl add — returns the CIDR to allocate from.
# 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
function subnet::resolve_for_add() { function subnet::resolve_for_add() {
local peer_type="${1:-}" subnet_name="${2:-}" local peer_type="${1:-}" subnet_name="${2:-}"
local result local result
if [[ -n "$subnet_name" ]]; then 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 if [[ -n "$peer_type" ]]; then
result=$(json::subnet_lookup "$(ctx::subnets)" "$subnet_name" "$peer_type" 2>/dev/null) || true 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 fi
# Fall back to "none" slot in group, or scalar entry
result=$(json::subnet_lookup "$(ctx::subnets)" "$subnet_name" 2>/dev/null) || true result=$(json::subnet_lookup "$(ctx::subnets)" "$subnet_name" 2>/dev/null) || true
if [[ -n "$result" ]]; then echo "$result"; return 0; fi [[ -n "$result" ]] && { echo "$result"; return 0; }
# Hardcoded fallback for subnet name subnet::_hardcoded_cidr "" "$subnet_name"
subnet::_hardcoded_subnet "" "$subnet_name"
return 0 return 0
fi fi
# No subnet_name — resolve from type (native allocation) # No subnet_name — resolve from type (native allocation)
if [[ -n "$peer_type" ]]; then if [[ -n "$peer_type" ]]; then
result=$(json::subnet_lookup "$(ctx::subnets)" "$peer_type" 2>/dev/null) || true 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 fi
subnet::_hardcoded_subnet "$peer_type" subnet::_hardcoded_cidr "$peer_type"
} }
# subnet::type_for_add <type_flag> [subnet_name] # subnet::type_for_add <type_flag> [subnet_name]
# Returns the canonical type string to store in meta. # 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() { function subnet::type_for_add() {
local type_flag="${1:-}" subnet_name="${2:-}" local type_flag="${1:-}" subnet_name="${2:-}"
local result local result
if [[ -n "$subnet_name" ]]; then if [[ -n "$subnet_name" ]]; then
result=$(json::subnet_type "$(ctx::subnets)" "$subnet_name" "$type_flag" 2>/dev/null) || true 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 fi
# No subnet or lookup failed — use the type flag directly echo "${type_flag:-none}"
if [[ -n "$type_flag" ]]; then
echo "$type_flag"
else
echo "none"
fi
} }
# subnet::tunnel_mode <subnet_name> [type_key] # subnet::tunnel_mode <subnet_name> [type_key]
@ -142,25 +260,31 @@ function subnet::tunnel_mode() {
local subnet_name="${1:-}" type_key="${2:-}" local subnet_name="${1:-}" type_key="${2:-}"
local result local result
result=$(json::subnet_tunnel_mode "$(ctx::subnets)" "$subnet_name" "$type_key" 2>/dev/null) || true 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::_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() { function subnet::type_from_ip() {
local ip="${1:-}" 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 result=$(json::subnet_for_ip "$(ctx::subnets)" "$ip" 2>/dev/null) || true
if [[ -n "$result" ]]; then if [[ -n "$result" ]]; then
# result is "subnet_name|type_key"
echo "${result##*|}" echo "${result##*|}"
return 0 return 0
fi fi
subnet::_hardcoded_type "$ip" echo "unknown"
} }
# subnet::name_from_ip <ip> # subnet::name_from_ip <ip>
@ -169,26 +293,34 @@ function subnet::name_from_ip() {
local ip="${1:-}" local ip="${1:-}"
local result local result
result=$(json::subnet_for_ip "$(ctx::subnets)" "$ip" 2>/dev/null) || true result=$(json::subnet_for_ip "$(ctx::subnets)" "$ip" 2>/dev/null) || true
if [[ -n "$result" ]]; then [[ -n "$result" ]] && { echo "${result%%|*}"; return 0; }
echo "${result%%|*}"
return 0
fi
echo "" echo ""
} }
# =========================================================================== # Returns the default rule for a subnet, or empty string if none configured.
# Validation # 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() { function subnet::exists() {
local name="${1:-}" local name="${1:-}"
json::subnet_exists "$(ctx::subnets)" "$name" 2>/dev/null 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() { function subnet::require_exists() {
local name="${1:-}" local name="${1:-}"
if ! subnet::exists "$name"; then if ! subnet::exists "$name"; then
@ -197,9 +329,6 @@ function subnet::require_exists() {
fi 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() { function subnet::peers_using() {
local subnet_name="${1:-}" local subnet_name="${1:-}"
local peers 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() { function subnet::list_data() {
json::subnet_list "$(ctx::subnets)" 2>/dev/null || true 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() { function subnet::show_data() {
local name="${1:-}" local name="${1:-}"
json::subnet_show "$(ctx::subnets)" "$name" json::subnet_show "$(ctx::subnets)" "$name"

4
wgctl
View file

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