#!/usr/bin/env bash # subnet.module.sh — subnet map lookups, resolution, validation, and CIDR utilities # All subnet data lives in subnets.json; this module wraps json:: calls # and provides hardcoded fallbacks for safety. # ====================================================== # CIDR Utilities # Pure functions — no external dependencies, no side effects. # Suitable for unit testing. # ====================================================== # subnet::prefix # 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 # 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 # Returns the prefix length. # Example: 10.1.3.0/24 -> 24 function subnet::mask() { echo "${1##*/}" } # subnet::host_range # 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 # 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 # 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 # 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 # 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 # Errors and exits if the IP is not a valid host for the subnet. function subnet::require_ip_valid_for() { local cidr="${1:-}" ip="${2:-}" if ! subnet::ip_valid_for "$cidr" "$ip"; then log::error "IP '${ip}' is not a valid host address in subnet '${cidr}'" return 1 fi } # ====================================================== # Hardcoded Fallbacks # Mirror of production subnets.json. # Used only when subnets.json lookup fails. # ====================================================== function subnet::_hardcoded_cidr() { local type="${1:-}" subnet_name="${2:-}" if [[ -n "$subnet_name" ]]; then case "$subnet_name" in guests) echo "10.1.100.0/24" ;; servers) echo "10.1.200.0/24" ;; iot) echo "10.1.210.0/24" ;; *) echo "10.1.0.0/24" ;; esac return 0 fi case "$type" in desktop) echo "10.1.1.0/24" ;; laptop) echo "10.1.2.0/24" ;; phone) echo "10.1.3.0/24" ;; tablet) echo "10.1.4.0/24" ;; guest) echo "10.1.100.0/24" ;; guest-desktop) echo "10.1.101.0/24" ;; guest-laptop) echo "10.1.102.0/24" ;; guest-phone) echo "10.1.103.0/24" ;; guest-tablet) echo "10.1.104.0/24" ;; server) echo "10.1.200.0/24" ;; iot) echo "10.1.210.0/24" ;; *) echo "10.1.0.0/24" ;; esac } function subnet::_hardcoded_type() { local ip="${1:-}" case "$ip" in 10.1.1.*) echo "desktop" ;; 10.1.2.*) echo "laptop" ;; 10.1.3.*) echo "phone" ;; 10.1.4.*) echo "tablet" ;; 10.1.100.*) echo "none" ;; 10.1.101.*) echo "desktop" ;; 10.1.102.*) echo "laptop" ;; 10.1.103.*) echo "phone" ;; 10.1.104.*) echo "tablet" ;; 10.1.200.*) echo "server" ;; 10.1.210.*) echo "iot" ;; *) echo "unknown" ;; esac } function subnet::_hardcoded_tunnel_mode() { echo "split" } # ====================================================== # Core Resolution # ====================================================== function subnet::policy() { local subnet_name="${1:-}" type_key="${2:-}" policy::for_subnet "$subnet_name" "$type_key" } # subnet::lookup [type_key] # Returns the CIDR for a given subnet name and optional type. # Falls back to hardcoded map on failure. function subnet::lookup() { local subnet_name="${1:-}" type_key="${2:-}" local result result=$(json::subnet_lookup "$(ctx::subnets)" "$subnet_name" "$type_key" 2>/dev/null) || true if [[ -n "$result" ]]; then echo "$result" return 0 fi subnet::_hardcoded_cidr "" "$subnet_name" } # subnet::resolve_for_add [subnet_name] # Main entry point for wgctl add — returns the CIDR to allocate from. function subnet::resolve_for_add() { local peer_type="${1:-}" subnet_name="${2:-}" local result if [[ -n "$subnet_name" ]]; then # Group entry: try type-specific child first, then "none" slot if [[ -n "$peer_type" ]]; then result=$(json::subnet_lookup "$(ctx::subnets)" "$subnet_name" "$peer_type" 2>/dev/null) || true [[ -n "$result" ]] && { echo "$result"; return 0; } fi result=$(json::subnet_lookup "$(ctx::subnets)" "$subnet_name" 2>/dev/null) || true [[ -n "$result" ]] && { echo "$result"; return 0; } subnet::_hardcoded_cidr "" "$subnet_name" return 0 fi # No subnet_name — resolve from type (native allocation) if [[ -n "$peer_type" ]]; then result=$(json::subnet_lookup "$(ctx::subnets)" "$peer_type" 2>/dev/null) || true [[ -n "$result" ]] && { echo "$result"; return 0; } fi subnet::_hardcoded_cidr "$peer_type" } # subnet::type_for_add [subnet_name] # Returns the canonical type string to store in meta. function subnet::type_for_add() { local type_flag="${1:-}" subnet_name="${2:-}" local result if [[ -n "$subnet_name" ]]; then result=$(json::subnet_type "$(ctx::subnets)" "$subnet_name" "$type_flag" 2>/dev/null) || true [[ -n "$result" ]] && { echo "$result"; return 0; } fi echo "${type_flag:-none}" } # subnet::tunnel_mode [type_key] # Returns "split" or "full" for the given subnet. function subnet::tunnel_mode() { local subnet_name="${1:-}" type_key="${2:-}" local policy_name policy_name=$(policy::for_subnet "$subnet_name" "$type_key") policy::tunnel_mode "$policy_name" } function subnet::type_from_ip() { local ip="${1:-}" [[ -z "$ip" ]] && echo "unknown" && return 0 # Fast path: hardcoded map covers all production subnets — pure bash, no subshell local type type=$(subnet::_hardcoded_type "$ip") if [[ "$type" != "unknown" ]]; then echo "$type" return 0 fi # Slow path: Python lookup for dynamically-added subnets not in hardcoded map local result result=$(json::subnet_for_ip "$(ctx::subnets)" "$ip" 2>/dev/null) || true if [[ -n "$result" ]]; then echo "${result##*|}" return 0 fi echo "unknown" } # subnet::name_from_ip # Returns the subnet name (e.g. "guests", "desktop") for an IP. function subnet::name_from_ip() { local ip="${1:-}" local result result=$(json::subnet_for_ip "$(ctx::subnets)" "$ip" 2>/dev/null) || true [[ -n "$result" ]] && { echo "${result%%|*}"; return 0; } echo "" } # Returns the default rule for a subnet, or empty string if none configured. # Called by add.command.sh to resolve the rule before falling back to "user". function subnet::default_rule() { local subnet_name="${1:-}" type_key="${2:-}" [[ -z "$subnet_name" ]] && return 0 local policy_name policy_name=$(policy::for_subnet "$subnet_name" "$type_key") policy::default_rule "$policy_name" } # subnet::list_names # Returns all top-level subnet names, one per line. # Used by commands to dynamically register -- flags. function subnet::list_names() { json::subnet_list_names "$(ctx::subnets)" 2>/dev/null || true } # ====================================================== # Validation # ====================================================== function subnet::exists() { local name="${1:-}" json::subnet_exists "$(ctx::subnets)" "$name" 2>/dev/null } function subnet::require_exists() { local name="${1:-}" if ! subnet::exists "$name"; then log::error "Subnet '${name}' not found. Use 'wgctl subnet list' to see available subnets." return 1 fi } function subnet::peers_using() { local subnet_name="${1:-}" local peers peers=$(json::subnet_peers \ "$(ctx::meta)" \ "$(ctx::clients)" \ "$subnet_name" \ "$(ctx::subnets)" \ 2>/dev/null) || true echo "$peers" | tr '\n' ',' | sed 's/,$//' } # ====================================================== # Display Data # ====================================================== function subnet::list_data() { json::subnet_list "$(ctx::subnets)" 2>/dev/null || true } function subnet::show_data() { local name="${1:-}" json::subnet_show "$(ctx::subnets)" "$name" }