diff --git a/commands/add.command.sh b/commands/add.command.sh index e390ad6..450881b 100644 --- a/commands/add.command.sh +++ b/commands/add.command.sh @@ -1,152 +1,144 @@ -#!/usr/bin/env bash - # ============================================ # Lifecycle # ============================================ - + function cmd::add::on_load() { + load_module subnet + load_module identity + flag::register --name + flag::register --identity flag::register --type - flag::register --subtype + flag::register --subnet flag::register --rule flag::register --group flag::register --ip - flag::register --guest flag::register --tunnel - flag::register --show-config flag::register --show-qr + + # Dynamically register -- 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 # ============================================ - + function cmd::add::help() { cat < --type [options] - + or: wgctl add --identity --type [options] + Add a new WireGuard client. - + Options: - --name Client name (e.g. nuno) - --type Device type: desktop, laptop, phone, tablet, guest - --subtype Guest subtype: desktop, laptop, phone, tablet (mostly used for guest) - --ip Override auto-assigned IP (optional) - --guest Shorthand for --type guest - --tunnel Tunnel mode: split|full (default: split) - --rule Assign rule on creation (default: user, guest types: guest) - --group Add to group on creation (group must exist) - --show-config Shows the WireGuard peer config - --show-qr Shows the WireGuard config in a QR Code - -Device Types and Subnets: - desktop 10.1.1.x - laptop 10.1.2.x - phone 10.1.3.x - tablet 10.1.4.x - guest 10.1.100.x - -Rules: - Automatically assigned based on type (guest → guest rule, others → user rule). - Override with --rule. Manage rules with: wgctl rule help - -Tunnel Modes: - split Route only VPN subnet + LAN through WireGuard (default) - full Route all traffic through WireGuard - + --name Client name (e.g. nuno) — combined with type: phone-nuno + --identity Identity name — auto-names peer with next available index + --type Device type: desktop, laptop, phone, tablet, server, iot + --subnet Subnet to allocate from (default: type-native) + --ip Override auto-assigned IP (optional) + --tunnel Tunnel mode: split|full (default: from subnet) + --rule Assign rule on creation (default: from subnet or user) + --group Add to group on creation (group must exist) + --show-qr Show the WireGuard config as a QR code after creation + +Subnet shorthands (equivalent to --subnet ): + --guests, --servers, --iot, ... (see: wgctl subnet list) + Examples: wgctl add --name nuno --type phone - wgctl add --name nuno --type laptop --ip 10.1.2.5 - wgctl add --name nuno --type phone --tunnel full - wgctl add --name guest1 --type phone --guest - wgctl add --name restricted --type desktop - wgctl add --name dev --type laptop --rule dev-01 - wgctl add --name visitor --type guest --show-qr + wgctl add --identity nuno --type phone # auto-names phone-nuno (or phone-nuno-2) + wgctl add --identity nuno --type laptop + wgctl add --name zephyr --type desktop --guests + wgctl add --identity zephyr --type desktop --guests + wgctl add --name visitor --type phone --guests --show-qr EOF } - + # ============================================ # Validation # ============================================ - -function cmd::add::validate() { - local name="$1" - local type="$2" - local ip="$3" - local tunnel="$4" - - if [[ -z "$name" ]]; then - log::error "Missing required flag: --name" + +function cmd::add::_validate() { + local name="$1" identity="$2" type="$3" ip="$4" tunnel="$5" + + if [[ -z "$name" && -z "$identity" ]]; then + log::error "Missing required flag: --name or --identity" return 1 fi - + if [[ -z "$type" ]]; then log::error "Missing required flag: --type" return 1 fi - - if ! config::is_valid_type "$type"; then - log::error "Invalid device type: ${type}" - log::info "Valid types: $(config::device_types | tr ' ' ', ')" + + if ! json::subnet_exists "$(ctx::subnets)" "$type" 2>/dev/null; then + log::error "Unknown device type: '${type}'" + log::info "Use 'wgctl subnet list' to see valid types and subnets" return 1 fi - + if [[ -n "$tunnel" && "$tunnel" != "split" && "$tunnel" != "full" ]]; then - log::error "Invalid tunnel mode: ${tunnel} (use 'split' or 'full')" + log::error "Invalid tunnel mode: '${tunnel}' (use 'split' or 'full')" return 1 fi - + if [[ -n "$ip" ]]; then ip::require_valid "$ip" fi - - local full_name="${type}-${name}" - +} + +function cmd::add::_validate_not_exists() { + local full_name="$1" if [[ -f "$(ctx::clients)/${full_name}.conf" ]]; then log::error "Client already exists: ${full_name}" return 1 fi } - + # ============================================ # Display helpers # ============================================ - + function cmd::add::is_mobile() { local type="$1" [[ "$type" == "phone" || "$type" == "tablet" ]] } - + # ============================================ # Run # ============================================ - + function cmd::add::run() { - local name="" - local type="" - local subtype="" - local rule="" - local group="" - local ip="" - local tunnel="" - local guest=false - local show_config=false - local show_qr=false - - # Parse flags + local name="" identity="" type="" subnet_name="" rule="" \ + group="" ip="" tunnel="" show_qr=false + while [[ $# -gt 0 ]]; do case "$1" in - --name) name="$2"; shift 2 ;; - --type) type="$2"; shift 2 ;; - --subtype) subtype="$2"; shift 2 ;; - --rule) rule="$2"; shift 2 ;; - --group) group="$2"; shift 2 ;; - --ip) ip="$2"; shift 2 ;; - --guest) guest=true; shift ;; - --tunnel) tunnel="$2"; shift 2 ;; - --show-config) show_config=true; shift ;; - --show-qr) show_qr=true; shift ;; - --help) cmd::add::help; return ;; + --name) name="$2"; shift 2 ;; + --identity) identity="$2"; shift 2 ;; + --type) type="$2"; shift 2 ;; + --subnet) subnet_name="$2"; shift 2 ;; + --rule) rule="$2"; shift 2 ;; + --group) group="$2"; shift 2 ;; + --ip) ip="$2"; shift 2 ;; + --tunnel) tunnel="$2"; shift 2 ;; + --show-qr) show_qr=true; shift ;; + --help) cmd::add::help; return ;; + --*) + local flag_name="${1#--}" + if subnet::exists "$flag_name" 2>/dev/null; then + subnet_name="$flag_name" + shift + else + log::error "Unknown flag: $1" + cmd::add::help + return 1 + fi + ;; *) log::error "Unknown flag: $1" cmd::add::help @@ -154,82 +146,112 @@ function cmd::add::run() { ;; esac done - - $guest && type="guest" - - local effective_type - effective_type=$(cmd::add::_resolve_type "$type" "$subtype") || return 1 - - local full_name="${type}-${name}" - cmd::add::validate "$name" "$type" "$ip" "$tunnel" || return 1 - - [[ -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}" + + cmd::add::_validate "$name" "$identity" "$type" "$ip" "$tunnel" || return 1 + + # Resolve full peer name + local full_name + if [[ -n "$identity" ]]; then + full_name=$(identity::next_peer_name "$identity" "$type") || return 1 + log::info "Auto-named: ${full_name}" else - echo "$type" + full_name="${type}-${name}" 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" - config::is_guest_type "$type" && echo "guest" || echo "user" + +# ============================================ +# Internal helpers +# ============================================ + +function cmd::add::_log_plan() { + local full_name="${1:-}" type="${2:-}" resolved_type="${3:-}" \ + subnet_name="${4:-}" resolved_cidr="${5:-}" ip="${6:-}" \ + tunnel="${7:-}" allowed_ips="${8:-}" rule="${9:-}" + + log::wg_add "Name: ${full_name}" + log::wg_add "Type: ${resolved_type}" + [[ -n "$subnet_name" ]] && log::wg_add "Subnet: ${subnet_name} (${resolved_cidr})" + log::wg_add "IP: ${ip}" + log::wg_add "Tunnel: ${tunnel} (${allowed_ips})" + log::wg_add "Endpoint: $(config::endpoint)" + log::wg_add "Rule: ${rule}" +} + +function cmd::add::_assign_group() { + local full_name="${1:-}" group="${2:-}" + [[ -z "$group" ]] && return 0 + if ! group::exists "$group"; then + log::wg_warning "Group '${group}' not found — skipping group assignment" + return 0 + fi + group::add_peer "$group" "$full_name" + log::wg "Added to group: ${group}" } function cmd::add::_show_result() { - local full_name="$1" display_type="$2" - if cmd::add::is_mobile "$display_type"; then + local full_name="${1:-}" type="${2:-}" show_qr="${3:-false}" + if $show_qr || cmd::add::is_mobile "$type"; then + log::section "Client QR" keys::qr "$full_name" else log::section "Client Config" cat "$(ctx::clients)/${full_name}.conf" fi -} \ No newline at end of file +} diff --git a/commands/block.command.sh b/commands/block.command.sh index 1d737eb..48a2fa8 100644 --- a/commands/block.command.sh +++ b/commands/block.command.sh @@ -6,6 +6,7 @@ function cmd::block::on_load() { flag::register --name + flag::register --identity flag::register --type flag::register --force flag::register --quiet @@ -14,8 +15,6 @@ function cmd::block::on_load() { flag::register --proto flag::register --subnet flag::register --block-name - - # System - NET Services flag::register --service } @@ -26,6 +25,7 @@ function cmd::block::on_load() { function cmd::block::help() { cat < [options] + or: wgctl block --identity [options] Block a client entirely or restrict access to specific IPs/ports/subnets/services. All block rules are persisted and restored on WireGuard restart. @@ -34,39 +34,38 @@ but have specific traffic restricted (shown as 'restricted' in list). Options: --name Client name (e.g. phone-nuno) + --identity Block all peers belonging to an identity --type Device type (optional, combines with --name) --ip Block access to specific IP (repeatable) --subnet Block access to subnet (repeatable) - --port Block specific port, e.g. 10.0.0.210:9000:tcp (repeatable) - --service Block a named service (e.g. proxmox, truenas:web-ui) (repeatable) + --port Block specific port (repeatable) + --service Block a named service (repeatable) --block-name Optional name for this block rule --quiet Suppress output (used by group block) Examples: wgctl block --name phone-nuno + wgctl block --identity nuno wgctl block --name nuno --type phone wgctl block --name phone-nuno --ip 10.0.0.210 - wgctl block --name phone-nuno --subnet 10.0.0.0/24 - wgctl block --name phone-nuno --port 10.0.0.210:9000:tcp wgctl block --name phone-nuno --service proxmox - wgctl block --name phone-nuno --service truenas:web-ui --block-name "no truenas ui" wgctl ban --name phone-nuno EOF } # ============================================ -# Block Run +# Run # ============================================ function cmd::block::run() { - local name="" type="" block_name="" + local name="" identity="" type="" block_name="" local ips=() subnets=() ports=() services=() local quiet=false force=false - local changed=false while [[ $# -gt 0 ]]; do case "$1" in --name) name="$2"; shift 2 ;; + --identity) identity="$2"; shift 2 ;; --type) type="$2"; shift 2 ;; --ip) ips+=("$2"); shift 2 ;; --block-name) block_name="$2"; shift 2 ;; @@ -75,16 +74,27 @@ function cmd::block::run() { --quiet) quiet=true; shift ;; --subnet) subnets+=("$2"); shift 2 ;; --port) ports+=("$2"); shift 2 ;; - --help) cmd::block::help; return ;; + --help) cmd::block::help; return ;; *) log::error "Unknown flag: $1" cmd::block::help - return 1 ;; + return 1 + ;; esac done - [[ -z "$name" ]] && log::error "Missing required flag: --name" && \ - cmd::block::help && return 1 + # --identity: block all peers for this identity + if [[ -n "$identity" ]]; then + cmd::block::_block_identity "$identity" "$quiet" \ + "${ips[@]+"${ips[@]}"}" || return 1 + return 0 + fi + + [[ -z "$name" ]] && { + log::error "Missing required flag: --name or --identity" + cmd::block::help + return 1 + } name=$(peers::resolve_and_require "$name" "$type") || return 1 @@ -113,6 +123,8 @@ function cmd::block::run() { fi fi + local changed=false + # Block specific IPs for ip in "${ips[@]}"; do ip::require_valid "$ip" @@ -137,7 +149,7 @@ function cmd::block::run() { fw::block_port "$client_ip" "$b_target" "$b_port" "${b_proto:-tcp}" block::add_rule "$name" "$client_ip" "port" "${block_name:-}" \ "$b_target" "$b_port" "${b_proto:-tcp}" - $quiet || log::wg_success "${client_ip}:${b_port}:${b_proto:-tcp} has been blocked for ${name}" + $quiet || log::wg_success "${client_ip}:${b_port}:${b_proto:-tcp} has been blocked for ${name}" done # Block services @@ -149,13 +161,12 @@ function cmd::block::run() { return 1 fi - # Check if already blocked local already_blocked=true for resolved in "${resolved_lines[@]}"; do if [[ "$resolved" == *:*:* ]]; then local b_ip b_port b_proto IFS=":" read -r b_ip b_port b_proto <<< "$resolved" - fw::has_block_rule "$client_ip" "$b_ip" "$b_port" "$b_proto" 2>/dev/null || \ + fw::has_block_rule "$client_ip" "$b_ip" "$b_port" "$b_proto" 2>/dev/null || \ { already_blocked=false; break; } else fw::has_block_rule "$client_ip" "$resolved" 2>/dev/null || \ @@ -167,7 +178,7 @@ function cmd::block::run() { $quiet || log::wg_warning "${svc} is already blocked for ${name}" continue fi - + for resolved in "${resolved_lines[@]}"; do if [[ "$resolved" == *:*:* ]]; then local b_ip b_port b_proto @@ -186,10 +197,8 @@ function cmd::block::run() { done [[ ${#ips[@]} -gt 0 || ${#ports[@]} -gt 0 || \ - ${#subnets[@]} -gt 0 ]] && changed=true + ${#subnets[@]} -gt 0 ]] && changed=true - # Reapply in correct order: rule ACCEPT first, then peer DROP rules - # Only reorder if rules were actually added if $changed; then local peer_rule peer_rule=$(peers::get_meta "$name" "rule") @@ -203,6 +212,45 @@ function cmd::block::run() { return 0 } +# ============================================ +# Identity block +# ============================================ + +function cmd::block::_block_identity() { + local identity_name="${1:-}" quiet="${2:-false}" + shift 2 || true + + identity::require_exists "$identity_name" || return 1 + identity::require_has_peers "$identity_name" || return 1 + + local peers blocked=0 failed=0 + peers=$(identity::peers "$identity_name") + + while IFS= read -r peer_name; do + [[ -z "$peer_name" ]] && continue + if peers::is_blocked "$peer_name"; then + $quiet || log::wg_warning "${peer_name} is already blocked" + continue + fi + local client_ip + client_ip=$(peers::get_ip "$peer_name") || continue + monitor::update_endpoint_cache + if cmd::block::_block_all "$peer_name" "$client_ip" true; then + blocked=$(( blocked + 1 )) + else + failed=$(( failed + 1 )) + fi + done <<< "$peers" + + log::ok "Blocked ${blocked} peer(s) for identity '${identity_name}'" + [[ $failed -gt 0 ]] && log::warn "${failed} peer(s) failed to block" + return 0 +} + +# ============================================ +# Helpers +# ============================================ + function cmd::block::_get_endpoint() { local name="$1" public_key="$2" local endpoint @@ -218,10 +266,7 @@ function cmd::block::_block_all() { local client_ip="${2:?client_ip required}" local quiet="${3:-false}" - # Apply fw rules and remove from server block::apply_full "$name" "$client_ip" - - # Mark as directly blocked block::set_direct "$name" "$client_ip" "true" $quiet || log::wg_success "${name} has been blocked." diff --git a/commands/inspect.command.sh b/commands/inspect.command.sh index dd0bea6..e5d34b9 100644 --- a/commands/inspect.command.sh +++ b/commands/inspect.command.sh @@ -89,9 +89,6 @@ function cmd::inspect::_peer_info() { local activity_current activity_current=$(peers::format_activity_current "$public_key") - local subtype - subtype=$(peers::get_meta "$name" "subtype") - local rule_extends="" if [[ -n "$rule" ]]; then local rule_file @@ -117,7 +114,7 @@ function cmd::inspect::_peer_info() { printf "\n" ui::row "Name" "$name" "${INSPECT_LABEL_WIDTH}" ui::row "IP" "$ip" "${INSPECT_LABEL_WIDTH}" - ui::row "Type" "$(peers::display_type "$type" "$subtype")" "${INSPECT_LABEL_WIDTH}" + ui::row "Type" "$(peers::display_type "$type")" "${INSPECT_LABEL_WIDTH}" ui::row "Rule" "$rule_display" "${INSPECT_LABEL_WIDTH}" ui::row "Status" "$(echo -e "$status")" "${INSPECT_LABEL_WIDTH}" ui::row "Endpoint" "${endpoint:-—}" "${INSPECT_LABEL_WIDTH}" diff --git a/commands/list.command.sh b/commands/list.command.sh index d09586c..6d9b4a6 100644 --- a/commands/list.command.sh +++ b/commands/list.command.sh @@ -8,6 +8,7 @@ function cmd::list::on_load() { flag::register --type flag::register --rule flag::register --group + flag::register --identity flag::register --online flag::register --offline flag::register --restricted @@ -28,9 +29,10 @@ Usage: wgctl list [options] List all WireGuard clients. Options: - --type Filter by device type (desktop, laptop, phone, tablet, guest) + --type Filter by device type (desktop, laptop, phone, tablet) --rule Filter by assigned rule --group Filter by group membership + --identity Filter by identity (show all peers for an identity) --online Show only connected clients --offline Show only disconnected clients --blocked Show only fully blocked clients (removed from WireGuard) @@ -47,9 +49,10 @@ Status values: Examples: wgctl list - wgctl list --type guest + wgctl list --type phone wgctl list --rule user wgctl list --group family + wgctl list --identity nuno wgctl list --online wgctl list --blocked wgctl list --restricted @@ -87,8 +90,8 @@ function cmd::list::_render_footer() { function cmd::list::_render_summary() { local group_summary="${1:-}" local -n _rule_counts="$2" + local filter_desc="${3:-}" - # Count total from rule_counts (only filtered peers) local total=0 for r in "${!_rule_counts[@]}"; do (( total += _rule_counts[$r] )) || true @@ -108,7 +111,7 @@ function cmd::list::_render_summary() { } # ============================================ -# Detail Card (show_client) +# Detail Card # ============================================ function cmd::list::show_client() { @@ -130,11 +133,10 @@ function cmd::list::show_client() { local public_key public_key=$(keys::public "$name" 2>/dev/null || echo "unknown") + # Meta type is authoritative; IP reverse lookup is fallback for pre-migration peers local type - type=$(peers::get_type_from_ip "$ip") - - local subtype - subtype=$(peers::get_meta "$name" "subtype") + type=$(peers::get_meta "$name" "type" 2>/dev/null) + [[ -z "$type" ]] && type=$(peers::get_type_from_ip "$ip") local endpoint="—" local ep @@ -155,7 +157,7 @@ function cmd::list::show_client() { last_ts=$(monitor::last_attempt "$name") local status - status=$(peers::format_status "$name" "$public_key" \ + status=$(peers::format_status_verbose "$name" "$public_key" \ "$is_blocked" "$is_restricted" "$handshake_ts" "$last_ts") local last_seen @@ -174,7 +176,7 @@ function cmd::list::show_client() { ui::section "Client: ${name}" ui::row "IP" "$ip" - ui::row "Type" "$(peers::display_type "$type" "$subtype")" + ui::row "Type" "$(peers::display_type "$type")" ui::row "Status" "$(echo -e "$status")" ui::row "Endpoint" "$endpoint" ui::row "Last seen" "$last_seen" @@ -197,7 +199,7 @@ function cmd::list::show_client() { # ============================================ function cmd::list::run() { - local filter_type="" filter_rule="" filter_group="" + local filter_type="" filter_rule="" filter_group="" filter_identity="" local online_only=false offline_only=false local restricted_only=false blocked_only=false allowed_only=false local detailed=false single_name="" @@ -207,6 +209,7 @@ function cmd::list::run() { --type) filter_type="$2"; shift 2 ;; --rule) filter_rule="$2"; shift 2 ;; --group) filter_group="$2"; shift 2 ;; + --identity) filter_identity="$2"; shift 2 ;; --online) online_only=true; shift ;; --offline) offline_only=true; shift ;; --restricted) restricted_only=true; shift ;; @@ -223,7 +226,6 @@ function cmd::list::run() { esac done - # Single detail card if [[ -n "$single_name" ]]; then cmd::list::show_client "$single_name" return 0 @@ -237,21 +239,17 @@ function cmd::list::run() { return 0 fi - # ── Precompute everything ────────────────── cmd::list::_precompute_all - # ── Detailed mode ────────────────────────── if $detailed; then log::section "WireGuard Clients" cmd::list::_iter_confs "$filter_type" cmd::list::_show_client_safe return 0 fi - # ── Build filter description ─────────────── local filter_desc="" cmd::list::_build_filter_desc - # ── Table view ───────────────────────────── declare -A rule_counts=() group_counts=() _list_header_printed=false @@ -267,10 +265,12 @@ function cmd::list::run() { fi } +# ============================================ +# Iteration +# ============================================ + function cmd::list::_iter_confs() { - # Usage: cmd::list::_iter_confs - local filter_type="$1" - local callback="$2" + local filter_type="$1" callback="$2" local dir dir="$(ctx::clients)" @@ -278,17 +278,27 @@ function cmd::list::_iter_confs() { [[ -f "$conf" ]] || continue local client_name client_name=$(basename "$conf" .conf) - local ip="${p_ips[$client_name]:-}" - if [[ -z "$ip" ]]; then - ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1) + + # Identity filter — skip peers not in the identity set + if [[ ${#p_identity_filter[@]} -gt 0 && \ + -z "${p_identity_filter[$client_name]:-}" ]]; then + continue fi - local type - type=$(peers::get_type_from_ip "$ip") + + local ip="${p_ips[$client_name]:-}" + [[ -z "$ip" ]] && ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1) + + # p_types is authoritative — set during precompute from meta + IP fallback + local type="${p_types[$client_name]:-unknown}" [[ -n "$filter_type" && "$type" != "$filter_type" ]] && continue + "$callback" "$client_name" "$ip" "$type" done } +# ============================================ +# Row rendering +# ============================================ function cmd::list::_render_row() { local client_name="$1" ip="$2" type="$3" @@ -299,9 +309,8 @@ function cmd::list::_render_row() { local is_restricted="${p_restricted[$client_name]:-false}" local last_ts="${p_last_ts[$client_name]:-}" - # Apply status filters - if $online_only; then peers::is_online "$client_name" "$handshake_ts" "$last_ts" || return 0; fi - if $offline_only; then peers::is_offline "$client_name" "$handshake_ts" "$last_ts" || return 0; fi + if $online_only; then peers::is_online "$client_name" "$handshake_ts" "$last_ts" || return 0; fi + if $offline_only; then peers::is_offline "$client_name" "$handshake_ts" "$last_ts" || return 0; fi if $restricted_only && [[ "$is_restricted" != "true" ]]; then return 0; fi if $blocked_only && [[ "$is_blocked" != "true" ]]; then return 0; fi if $allowed_only && { [[ "$is_blocked" == "true" ]] || \ @@ -312,18 +321,16 @@ function cmd::list::_render_row() { [[ "$all_groups" != *"$filter_group"* ]] && return 0 fi - # Format display values local status last_seen display_type rule group_display - status=$(peers::format_status "$client_name" "$pubkey" \ + status=$(peers::format_status_verbose "$client_name" "$pubkey" \ "$is_blocked" "$is_restricted" "$handshake_ts" "$last_ts") last_seen=$(peers::format_last_seen "$client_name" "$pubkey" \ "$is_blocked" "$last_ts" "" "$handshake_ts") - display_type=$(peers::display_type "$type" "${p_subtypes[$client_name]:-}") + display_type=$(peers::display_type "$type") rule="${p_rules[$client_name]:-—}" if [[ -n "$filter_rule" && "$rule" != "$filter_rule" ]]; then return 0; fi - # Print header on first match if [[ "${_list_header_printed:-false}" == "false" ]]; then log::section "WireGuard Clients" cmd::list::_render_header $has_groups @@ -336,7 +343,6 @@ function cmd::list::_render_row() { padded_status=$(ui::pad_status "$status" 25) if $has_groups; then - # Use main group for display, fall back to first group, then — local main_group="${p_main_groups[$client_name]:-}" if [[ -n "$main_group" ]]; then group_display="$main_group" @@ -364,22 +370,28 @@ function cmd::list::_render_row() { } # ============================================ -# Private helpers +# Precompute # ============================================ function cmd::list::_precompute_all() { - # Peer data - declare -gA p_ips=() p_rules=() p_subtypes=() p_last_ts=() p_last_evt=() p_main_groups=() - while IFS="|" read -r name ip rule subtype last_ts last_evt main_group; do + # Peer data — field 4 is 'type' from peer_data_v2 + declare -gA p_ips=() p_rules=() p_types=() p_last_ts=() p_last_evt=() p_main_groups=() + while IFS="|" read -r name ip rule type last_ts last_evt main_group; do [[ -z "$name" ]] && continue p_ips["$name"]="$ip" p_rules["$name"]="${rule:-—}" - p_subtypes["$name"]="$subtype" + p_types["$name"]="${type:-}" p_last_ts["$name"]="$last_ts" p_last_evt["$name"]="$last_evt" p_main_groups["$name"]="${main_group:-}" done < <(json::peer_data "$(ctx::clients)" "$(ctx::meta)" "$(ctx::events_log)") + # Fill type from IP for peers missing meta type (pre-migration peers) + for name in "${!p_ips[@]}"; do + [[ -n "${p_types[$name]:-}" ]] && continue + p_types["$name"]=$(peers::get_type_from_ip "${p_ips[$name]}") + done + # WireGuard handshakes + endpoints declare -gA wg_handshakes=() wg_endpoints=() while IFS=$'\t' read -r pubkey ts; do @@ -417,6 +429,19 @@ function cmd::list::_precompute_all() { done < <(json::peer_group_map "$groups_dir") fi + # Resolve identity filter into a peer set + declare -gA p_identity_filter=() + if [[ -n "$filter_identity" ]]; then + identity::require_exists "$filter_identity" || return 1 + while IFS= read -r peer_name; do + [[ -n "$peer_name" ]] && p_identity_filter["$peer_name"]=1 + done < <(identity::peers "$filter_identity") + if [[ ${#p_identity_filter[@]} -eq 0 ]]; then + log::wg_warning "Identity '${filter_identity}' has no peers" + return 0 + fi + fi + # Transfer/activity data — keyed by pubkey declare -gA p_rx=() p_tx=() p_activity=() while IFS="|" read -r pubkey rx tx level; do @@ -427,65 +452,6 @@ function cmd::list::_precompute_all() { done < <(json::peer_transfer "$(config::interface)") } -# function cmd::list::_precompute_all() { -# # Peer data -# declare -gA p_ips=() p_rules=() p_subtypes=() p_last_ts=() p_last_evt=() -# while IFS="|" read -r name ip rule subtype last_ts last_evt; do -# [[ -z "$name" ]] && continue -# p_ips["$name"]="$ip" -# p_rules["$name"]="${rule:-—}" -# p_subtypes["$name"]="$subtype" -# p_last_ts["$name"]="$last_ts" -# p_last_evt["$name"]="$last_evt" -# done < <(json::peer_data "$(ctx::clients)" "$(ctx::meta)" "$(ctx::events_log)") - -# # WireGuard handshakes + endpoints -# declare -gA wg_handshakes=() wg_endpoints=() -# while IFS=$'\t' read -r pubkey ts; do -# [[ -n "$pubkey" ]] && wg_handshakes["$pubkey"]="$ts" -# done < <(wg show "$(config::interface)" latest-handshakes 2>/dev/null) -# while IFS=$'\t' read -r pubkey endpoint; do -# [[ -n "$pubkey" ]] && wg_endpoints["$pubkey"]="$endpoint" -# done < <(wg show "$(config::interface)" endpoints 2>/dev/null) - -# # Block/restricted status -# declare -gA p_blocked=() p_restricted=() -# cmd::list::_precompute_block_status p_blocked p_restricted - -# # Public keys -# declare -gA p_pubkeys=() -# local dir -# dir="$(ctx::clients)" -# for kf in "${dir}"/*_public.key; do -# [[ -f "$kf" ]] || continue -# local kname -# kname=$(basename "$kf" _public.key) -# p_pubkeys["$kname"]=$(cat "$kf" 2>/dev/null || echo "") -# done - -# # Groups -# has_groups=false -# declare -gA peer_group_map=() -# local groups_dir -# groups_dir="$(ctx::groups)" -# local group_files=("${groups_dir}"/*.group) -# if [[ -f "${group_files[0]}" ]]; then -# has_groups=true -# while IFS=":" read -r peer_name group_name; do -# [[ -n "$peer_name" ]] && peer_group_map["$peer_name"]="$group_name" -# done < <(json::peer_group_map "$groups_dir") -# fi - -# # Transfer/activity data — keyed by pubkey -# declare -gA p_rx=() p_tx=() p_activity=() -# while IFS="|" read -r pubkey rx tx level; do -# [[ -z "$pubkey" ]] && continue -# p_rx["$pubkey"]="$rx" -# p_tx["$pubkey"]="$tx" -# p_activity["$pubkey"]="$level" -# done < <(json::peer_transfer "$(config::interface)") -# } - function cmd::list::_precompute_block_status() { local -n _blocked="$1" local -n _restricted="$2" @@ -500,7 +466,6 @@ function cmd::list::_precompute_block_status() { _restricted["$name"]=false fi - # Blocked = removed from WG server local pubkey pubkey=$(keys::public "$name" 2>/dev/null || echo "") if [[ -n "$pubkey" ]] && ! echo "$wg_peers" | grep -qF "$pubkey"; then @@ -511,11 +476,16 @@ function cmd::list::_precompute_block_status() { done < <(peers::all) } +# ============================================ +# Filter helpers +# ============================================ + function cmd::list::_build_filter_desc() { filter_desc="" - [[ -n "$filter_type" ]] && filter_desc+="type=${filter_type} " - [[ -n "$filter_rule" ]] && filter_desc+="rule=${filter_rule} " - [[ -n "$filter_group" ]] && filter_desc+="group=${filter_group} " + [[ -n "$filter_type" ]] && filter_desc+="type=${filter_type} " + [[ -n "$filter_rule" ]] && filter_desc+="rule=${filter_rule} " + [[ -n "$filter_group" ]] && filter_desc+="group=${filter_group} " + [[ -n "$filter_identity" ]] && filter_desc+="identity=${filter_identity} " $online_only && filter_desc+="online " $offline_only && filter_desc+="offline " $blocked_only && filter_desc+="blocked " diff --git a/commands/qr.command.sh b/commands/qr.command.sh index ca4230b..4d537e1 100644 --- a/commands/qr.command.sh +++ b/commands/qr.command.sh @@ -58,5 +58,6 @@ function cmd::qr::run() { name=$(peers::resolve_and_require "$name" "$type") || return 1 + log::section "Client QR: ${name}" keys::qr "$name" } diff --git a/commands/rename.command.sh b/commands/rename.command.sh index 808757c..1dc38a9 100644 --- a/commands/rename.command.sh +++ b/commands/rename.command.sh @@ -89,7 +89,7 @@ function cmd::rename::run() { # Update identity entry after successful rename identity::rename_peer "$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)" block::rename "$name" "$new_name" - - local old_meta new_meta - old_meta=$(peers::meta_path "$name") - new_meta=$(peers::meta_path "$new_name") - [[ -f "$old_meta" ]] && mv "$old_meta" "$new_meta" + peers::rename_meta "$name" "$new_name" } \ No newline at end of file diff --git a/commands/test/destructive.sh b/commands/test/destructive.sh index 06e0442..df05fb7 100644 --- a/commands/test/destructive.sh +++ b/commands/test/destructive.sh @@ -7,11 +7,13 @@ function cmd::test::section_destructive() { test::section "Destructive (modifying state)" # Cleanup from any previous failed run - "$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true + "$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true + "$WGCTL_BINARY" remove --name laptop-testunit2 --force > /dev/null 2>&1 || true + "$WGCTL_BINARY" remove --name laptop-testunit2b --force > /dev/null 2>&1 || true "$WGCTL_BINARY" group remove --name testgroup --force > /dev/null 2>&1 || true "$WGCTL_BINARY" group remove --name testgroup2 --force > /dev/null 2>&1 || true "$WGCTL_BINARY" net rm --name test-block-svc --force > /dev/null 2>&1 || true - "$WGCTL_BINARY" unblock --name phone-testunit > /dev/null 2>&1 || true + "$WGCTL_BINARY" unblock --name phone-testunit > /dev/null 2>&1 || true cmd::test::_destructive_peer cmd::test::_destructive_block_unblock @@ -28,15 +30,15 @@ function cmd::test::_destructive_peer() { } function cmd::test::_destructive_block_unblock() { - cmd::test::run_cmd "block peer" "blocked" block --name phone-testunit - cmd::test::run_cmd "list shows blocked" "blocked" list --blocked - cmd::test::run_cmd "unblock peer" "unblocked" unblock --name phone-testunit + cmd::test::run_cmd "block peer" "blocked" block --name phone-testunit + cmd::test::run_cmd "list shows blocked" "phone-testunit" list --blocked + cmd::test::run_cmd "unblock peer" "unblocked" unblock --name phone-testunit - cmd::test::run_cmd "block peer --ip" "blocked for" \ + cmd::test::run_cmd "block peer --ip" "blocked for" \ block --name phone-testunit --ip 10.0.0.99 - cmd::test::run_cmd "list shows restricted" "restricted" \ + cmd::test::run_cmd "list shows restricted" "restricted" \ list --name phone-testunit - cmd::test::run_cmd "unblock peer --ip" "unblocked" \ + cmd::test::run_cmd "unblock peer --ip" "unblocked" \ unblock --name phone-testunit --ip 10.0.0.99 } @@ -63,12 +65,11 @@ function cmd::test::_destructive_rule() { } function cmd::test::_destructive_groups() { - cmd::test::run_cmd "group add" "created" group add --name testgroup --desc "Test group" + cmd::test::run_cmd "group add" "created" group add --name testgroup --desc "Test group" cmd::test::run_cmd "group peer add" "Added" group peer add --name testgroup --peer phone-testunit cmd::test::run_cmd "group block" "blocked" group block --name testgroup cmd::test::run_cmd "group unblock" "unblocked" group unblock --name testgroup - # M:N group block tracking "$WGCTL_BINARY" group add --name testgroup2 --desc "Test group 2" > /dev/null 2>&1 "$WGCTL_BINARY" group peer add --name testgroup2 --peer phone-testunit > /dev/null 2>&1 @@ -76,12 +77,11 @@ function cmd::test::_destructive_groups() { cmd::test::run_cmd "group block second" "blocked" group block --name testgroup2 "$WGCTL_BINARY" group unblock --name testgroup > /dev/null 2>&1 - cmd::test::run_cmd "peer stays blocked after partial unblock" "blocked" list --blocked + cmd::test::run_cmd "peer stays blocked after partial unblock" "phone-testunit" list --blocked "$WGCTL_BINARY" group unblock --name testgroup2 > /dev/null 2>&1 cmd::test::run_cmd "peer unblocked after all groups unblock" "phone-testunit" list --allowed - # Direct block overrides group block "$WGCTL_BINARY" group block --name testgroup > /dev/null 2>&1 cmd::test::run_cmd "direct unblock overrides group block" "unblocked" unblock --name phone-testunit @@ -92,22 +92,33 @@ function cmd::test::_destructive_groups() { function cmd::test::_destructive_identity() { test::section "Destructive: identity auto-attach/detach" - # Add verifies auto-attach + # Cleanup from any previous failed run + "$WGCTL_BINARY" remove --name laptop-testunit2 --force > /dev/null 2>&1 || true + "$WGCTL_BINARY" remove --name laptop-testunit2b --force > /dev/null 2>&1 || true + + # Add — verify auto-attach to identity "testunit2" cmd::test::run_cmd "add attaches to identity" "added" \ add --name testunit2 --type laptop - cmd::test::run_cmd "identity shows testunit2" "testunit2" \ - identity show --name testunit + cmd::test::run_cmd "identity created for testunit2" "laptop-testunit2" \ + identity show --name testunit2 - # Rename verifies identity::rename_peer - cmd::test::run_cmd "rename updates identity" "renamed" \ + # Rename — verify identity::rename_peer moves peer to new identity "testunit2b" + cmd::test::run_cmd "rename peer" "renamed" \ rename --name laptop-testunit2 --new-name laptop-testunit2b - cmd::test::run_cmd "identity reflects rename" "testunit2b" \ - identity show --name testunit + cmd::test::run_cmd "identity reflects rename (new identity)" "laptop-testunit2b" \ + identity show --name testunit2b - # Remove verifies auto-detach - "$WGCTL_BINARY" remove --name laptop-testunit2b --force > /dev/null 2>&1 || true + cmd::test::run_cmd_fails "old identity gone after rename" \ + identity show --name testunit2 + + # Remove — verify auto-detach cleans up identity file + cmd::test::run_cmd "remove detaches from identity" "removed" \ + remove --name laptop-testunit2b --force + + cmd::test::run_cmd_fails "identity cleaned up after remove" \ + identity show --name testunit2b } function cmd::test::_destructive_cleanup() { diff --git a/commands/test/integration.sh b/commands/test/integration.sh index 26741aa..c0ec5ce 100644 --- a/commands/test/integration.sh +++ b/commands/test/integration.sh @@ -9,6 +9,10 @@ WGCTL_BINARY="$(command -v wgctl)" # Helpers # ============================================ +function cmd::test::_strip_ansi() { + sed 's/\x1b\[[0-9;]*m//g' +} + function cmd::test::run_cmd() { local desc="$1" expected="${2:-}" shift 2 @@ -17,30 +21,64 @@ function cmd::test::run_cmd() { tmp=$(mktemp) set +e - timeout 30 "$WGCTL_BINARY" "$@" > "$tmp" 2>&1 & - local pid=$! - wait $pid + timeout 30 "$WGCTL_BINARY" "$@" > "$tmp" 2>&1 exit_code=$? set -e + # Reset terminal color in case command output left ANSI state dirty + printf "\033[0m" >&2 + if [[ $exit_code -eq 124 ]]; then test::warn "${desc} (timed out after 30s)" rm -f "$tmp" return 1 fi + local clean + clean=$(cmd::test::_strip_ansi < "$tmp") + if [[ $exit_code -ne 0 ]]; then - test::fail "${desc}" + local msg="${desc}" + [[ -n "$expected" ]] && msg="${desc} (expected '${expected}', command failed)" + test::fail "$msg" if [[ "${WGCTL_TEST_VERBOSE:-false}" == "true" ]]; then - printf " Output: %s\n" "$(cat "$tmp")" + printf " Output: %s\n" "$(echo "$clean" | head -3 | tr '\n' ' ')" fi rm -f "$tmp" return 1 fi - if [[ -n "$expected" ]] && ! grep -qF "$expected" "$tmp"; then + if [[ -n "$expected" ]] && ! echo "$clean" | grep -qF "$expected"; then local actual - actual=$(head -3 "$tmp" | tr '\n' ' ' | sed 's/ */ /g' | cut -c1-100) + actual=$(echo "$clean" | head -3 | tr '\n' ' ' | sed 's/ */ /g' | cut -c1-100) + test::fail "${desc} (expected '${expected}', got: '${actual}')" + rm -f "$tmp" + return 1 + fi + + test::pass "$desc" + rm -f "$tmp" +} + +function cmd::test::run_cmd_any() { + local desc="$1" expected="${2:-}" + shift 2 + + local tmp + tmp=$(mktemp) + + set +e + timeout 30 "$WGCTL_BINARY" "$@" > "$tmp" 2>&1 + set -e + + printf "\033[0m" >&2 + + local clean + clean=$(cmd::test::_strip_ansi < "$tmp") + + if [[ -n "$expected" ]] && ! echo "$clean" | grep -qF "$expected"; then + local actual + actual=$(echo "$clean" | head -3 | tr '\n' ' ' | sed 's/ */ /g' | cut -c1-100) test::fail "${desc} (expected '${expected}', got: '${actual}')" rm -f "$tmp" return 1 @@ -54,13 +92,15 @@ function cmd::test::run_cmd_fails() { local desc="$1" shift - set +e local tmp exit_code tmp=$(mktemp) - timeout 10 setsid "$WGCTL_BINARY" "$@" > "$tmp" 2>&1 + + set +e + timeout 10 "$WGCTL_BINARY" "$@" > "$tmp" 2>&1 exit_code=$? set -e + printf "\033[0m" >&2 rm -f "$tmp" if [[ $exit_code -eq 124 ]]; then @@ -96,13 +136,13 @@ function cmd::test::run_all_integration_sections() { function cmd::test::section_list() { test::section "List" - cmd::test::run_cmd "list" "WireGuard Clients" list - cmd::test::run_cmd "list --online" "" list --online - cmd::test::run_cmd "list --offline" "" list --offline - cmd::test::run_cmd "list --blocked" "" list --blocked - cmd::test::run_cmd "list --type phone" "" list --type phone - cmd::test::run_cmd "list --detailed" "Client:" list --detailed - cmd::test::run_cmd "list --name phone-nuno" "phone-nuno" list --name phone-nuno + cmd::test::run_cmd "list" "NAME" list + cmd::test::run_cmd "list --online" "" list --online + cmd::test::run_cmd "list --offline" "" list --offline + cmd::test::run_cmd "list --blocked" "" list --blocked + cmd::test::run_cmd "list --type phone" "phone" list --type phone + cmd::test::run_cmd "list --detailed" "IP:" list --detailed + cmd::test::run_cmd "list --name phone-nuno" "phone-nuno" list --name phone-nuno } function cmd::test::section_inspect() { @@ -115,14 +155,14 @@ function cmd::test::section_inspect() { function cmd::test::section_config() { test::section "Config & QR" - cmd::test::run_cmd "config --name phone-nuno" "PrivateKey" config --name phone-nuno - cmd::test::run_cmd "config --name nuno --type phone" "PrivateKey" config --name nuno --type phone - cmd::test::run_cmd "qr --name phone-nuno" "" qr --name phone-nuno + cmd::test::run_cmd "config --name phone-nuno" "PrivateKey" config --name phone-nuno + cmd::test::run_cmd "config --name nuno --type phone" "PrivateKey" config --name nuno --type phone + cmd::test::run_cmd "qr --name phone-nuno" "" qr --name phone-nuno } function cmd::test::section_rules() { test::section "Rules" - cmd::test::run_cmd "rule list" "guest" rule list + cmd::test::run_cmd "rule list" "user" rule list cmd::test::run_cmd "rule show --name guest" "Description" rule show --name guest cmd::test::run_cmd "rule show --name user" "Description" rule show --name user cmd::test::run_cmd "rule show --name admin" "Description" rule show --name admin @@ -131,35 +171,35 @@ function cmd::test::section_rules() { function cmd::test::section_groups() { test::section "Groups" - cmd::test::run_cmd "group list" "Groups" group list - cmd::test::run_cmd "group show --name family" "Peers:" group show --name family - cmd::test::run_cmd_fails "group show nonexistent" group show --name nonexistent + cmd::test::run_cmd "group list" "Groups" group list + cmd::test::run_cmd "group show --name family" "Peers:" group show --name family + cmd::test::run_cmd_fails "group show nonexistent" group show --name nonexistent } function cmd::test::section_audit() { test::section "Audit" - cmd::test::run_cmd "audit" "passed" audit - cmd::test::run_cmd "audit --peer phone-nuno" "passed" audit --peer phone-nuno - cmd::test::run_cmd "audit --type phone" "passed" audit --type phone + cmd::test::run_cmd_any "audit" "passed" audit + cmd::test::run_cmd_any "audit --peer phone-nuno" "passed" audit --peer phone-nuno + cmd::test::run_cmd_any "audit --type phone" "passed" audit --type phone } function cmd::test::section_logs() { test::section "Logs" - cmd::test::run_cmd "logs" "Activity" logs - cmd::test::run_cmd "logs --name phone-nuno" "Activity" logs --name phone-nuno - cmd::test::run_cmd "logs --fw" "Activity" logs --fw - cmd::test::run_cmd "logs --wg" "Activity" logs --wg + cmd::test::run_cmd "logs" "Activity" logs + cmd::test::run_cmd "logs --name phone-nuno" "Activity" logs --name phone-nuno + cmd::test::run_cmd "logs --fw" "Activity" logs --fw + cmd::test::run_cmd "logs --wg" "Activity" logs --wg } function cmd::test::section_fw() { test::section "Firewall" - cmd::test::run_cmd "fw list" "FORWARD" fw list - cmd::test::run_cmd "fw list --peer phone-nuno" "" fw list --peer phone-nuno - cmd::test::run_cmd "fw list --no-nflog" "" fw list --no-nflog - cmd::test::run_cmd "fw list --no-accept" "" fw list --no-accept - cmd::test::run_cmd "fw list --no-drop" "" fw list --no-drop - cmd::test::run_cmd "fw nat" "PREROUTING" fw nat - cmd::test::run_cmd "fw count" "TOTAL" fw count + cmd::test::run_cmd "fw list" "FORWARD" fw list + cmd::test::run_cmd "fw list --peer phone-nuno" "" fw list --peer phone-nuno + cmd::test::run_cmd "fw list --no-nflog" "" fw list --no-nflog + cmd::test::run_cmd "fw list --no-accept" "" fw list --no-accept + cmd::test::run_cmd "fw list --no-drop" "" fw list --no-drop + cmd::test::run_cmd "fw nat" "PREROUTING" fw nat + cmd::test::run_cmd "fw count" "TOTAL" fw count } function cmd::test::section_net() { @@ -175,26 +215,24 @@ function cmd::test::section_net() { cmd::test::run_cmd "net add port again" "Added" net add --name test-svc:web --port 9999:tcp cmd::test::run_cmd "net rm all ports" "Removed" net rm --name test-svc:ports --force cmd::test::run_cmd "net rm service" "Removed" net rm --name test-svc --force - cmd::test::run_cmd_fails "net show nonexistent" net show --name nonexistent-svc - cmd::test::run_cmd_fails "net add port no service" net add --name nonexistent:web --port 80:tcp + cmd::test::run_cmd_fails "net show nonexistent" net show --name nonexistent-svc + cmd::test::run_cmd_fails "net add port no service" net add --name nonexistent:web --port 80:tcp } function cmd::test::section_subnet() { test::section "Subnet" - # Cleanup from any previous failed run - "$WGCTL_BINARY" subnet rm --name test-subnet-2 --force > /dev/null 2>&1 || true - "$WGCTL_BINARY" subnet rm --name test-subnet --force > /dev/null 2>&1 || true + "$WGCTL_BINARY" subnet rm --name test-subnet-2 > /dev/null 2>&1 || true + "$WGCTL_BINARY" subnet rm --name test-subnet > /dev/null 2>&1 || true - cmd::test::run_cmd "subnet list" "desktop" subnet list - cmd::test::run_cmd "subnet show desktop" "Subnet" subnet show --name desktop - cmd::test::run_cmd "subnet show guests group" "group" subnet show --name guests - cmd::test::run_cmd_fails "subnet show nonexistent" subnet show --name nonexistent + cmd::test::run_cmd "subnet list" "desktop" subnet list + cmd::test::run_cmd "subnet show desktop" "Subnet" subnet show --name desktop + cmd::test::run_cmd "subnet show guests group" "group" subnet show --name guests + cmd::test::run_cmd_fails "subnet show nonexistent" subnet show --name nonexistent cmd::test::run_cmd "subnet add" "added" \ subnet add --name test-subnet --subnet 10.1.250.0/24 --type iot --desc "Test" cmd::test::run_cmd "subnet list shows new" "test-subnet" \ subnet list - cmd::test::run_cmd_fails "subnet rename in-use (desktop)" \ subnet rename --name desktop --new-name workstation cmd::test::run_cmd "subnet rename unused" "renamed" \ @@ -207,7 +245,7 @@ function cmd::test::section_subnet() { function cmd::test::section_identity() { test::section "Identity" - cmd::test::run_cmd "identity list" "" identity list + cmd::test::run_cmd "identity list" "" identity list cmd::test::run_cmd "identity migrate --dry-run" "Dry run" identity migrate --dry-run cmd::test::run_cmd "identity show nuno" "nuno" identity show --name nuno cmd::test::run_cmd_fails "identity show nonexistent" identity show --name nonexistent diff --git a/commands/test/unit.sh b/commands/test/unit.sh index 11afac7..61245b2 100644 --- a/commands/test/unit.sh +++ b/commands/test/unit.sh @@ -41,10 +41,6 @@ function cmd::test::assert_false() { # ============================================ function cmd::test::run_all_unit_sections() { - load_module subnet - load_module ip - load_module identity - cmd::test::unit_subnet cmd::test::unit_ip cmd::test::unit_identity @@ -55,16 +51,16 @@ function cmd::test::unit_subnet() { load_module subnet # subnet::prefix - cmd::test::assert "subnet::prefix /24" "$(subnet::prefix '10.1.3.0/24')" "10.1.3" - cmd::test::assert "subnet::prefix /16" "$(subnet::prefix '10.1.0.0/16')" "10.1.0" - cmd::test::assert "subnet::mask /24" "$(subnet::mask '10.1.3.0/24')" "24" - cmd::test::assert "subnet::mask /16" "$(subnet::mask '10.1.0.0/16')" "16" - cmd::test::assert "subnet::base_ip" "$(subnet::base_ip '10.1.3.0/24')" "10.1.3.0" + cmd::test::assert "subnet::prefix /24" "$(subnet::prefix '10.1.3.0/24')" "10.1.3" + cmd::test::assert "subnet::prefix /16" "$(subnet::prefix '10.1.0.0/16')" "10.1.0" + cmd::test::assert "subnet::mask /24" "$(subnet::mask '10.1.3.0/24')" "24" + cmd::test::assert "subnet::mask /16" "$(subnet::mask '10.1.0.0/16')" "16" + cmd::test::assert "subnet::base_ip" "$(subnet::base_ip '10.1.3.0/24')" "10.1.3.0" # subnet::contains - cmd::test::assert_true "subnet::contains inside" subnet::contains "10.1.3.0/24" "10.1.3.5" - cmd::test::assert_true "subnet::contains boundary" subnet::contains "10.1.3.0/24" "10.1.3.254" - cmd::test::assert_false "subnet::contains outside" subnet::contains "10.1.3.0/24" "10.1.4.1" + cmd::test::assert_true "subnet::contains inside" subnet::contains "10.1.3.0/24" "10.1.3.5" + cmd::test::assert_true "subnet::contains boundary" subnet::contains "10.1.3.0/24" "10.1.3.254" + cmd::test::assert_false "subnet::contains outside" subnet::contains "10.1.3.0/24" "10.1.4.1" cmd::test::assert_false "subnet::contains wrong net" subnet::contains "10.1.3.0/24" "192.168.1.1" # subnet::is_valid_cidr @@ -100,7 +96,6 @@ function cmd::test::unit_identity() { test::section "Unit: identity inference" load_module identity - # _parse_peer_name via identity::infer cmd::test::assert "infer phone-nuno" "$(identity::infer 'phone-nuno')" "nuno|phone|1" cmd::test::assert "infer phone-nuno-2" "$(identity::infer 'phone-nuno-2')" "nuno|phone|2" cmd::test::assert "infer desktop-zephyr" "$(identity::infer 'desktop-zephyr')" "zephyr|desktop|1" diff --git a/commands/unblock.command.sh b/commands/unblock.command.sh index d5984b2..164afd4 100644 --- a/commands/unblock.command.sh +++ b/commands/unblock.command.sh @@ -6,6 +6,7 @@ function cmd::unblock::on_load() { flag::register --name + flag::register --identity flag::register --type flag::register --force flag::register --quiet @@ -14,8 +15,6 @@ function cmd::unblock::on_load() { flag::register --proto flag::register --subnet flag::register --all - - # System - NET Services flag::register --service } @@ -26,12 +25,14 @@ function cmd::unblock::on_load() { function cmd::unblock::help() { cat < [options] + or: wgctl unblock --identity [options] Remove block rules for a client. Without specific flags, performs a full unblock. Direct unblock overrides any group blocks. Options: --name Client name (e.g. phone-nuno) + --identity Unblock all peers belonging to an identity --type Device type (optional, combines with --name) --ip Unblock specific IP (repeatable) --subnet Unblock specific subnet (repeatable) @@ -42,40 +43,36 @@ Options: Examples: wgctl unblock --name phone-nuno + wgctl unblock --identity nuno wgctl unblock --name nuno --type phone wgctl unblock --name phone-nuno --ip 10.0.0.210 wgctl unblock --name phone-nuno --service proxmox - wgctl unblock --name phone-nuno --service truenas:web-ui wgctl unban --name phone-nuno EOF } # ============================================ -# Unblock Run +# Run # ============================================ function cmd::unblock::run() { - local name="" - local type="" - local ips=() - local subnets=() - local ports=() - local services=() - local all=false - local quiet=false - + local name="" identity="" type="" + local ips=() subnets=() ports=() services=() + local all=false quiet=false force=false + while [[ $# -gt 0 ]]; do case "$1" in - --name) name="$2"; shift 2 ;; - --type) type="$2"; shift 2 ;; - --ip) ips+=("$2"); shift 2 ;; - --force) force=true; shift ;; - --quiet) quiet=true; shift ;; - --subnet) subnets+=("$2"); shift 2 ;; - --port) ports+=("$2"); shift 2 ;; - --service) services+=("$2"); shift 2 ;; - --all) all=true; shift ;; - --help) cmd::unblock::help; return ;; + --name) name="$2"; shift 2 ;; + --identity) identity="$2"; shift 2 ;; + --type) type="$2"; shift 2 ;; + --ip) ips+=("$2"); shift 2 ;; + --force) force=true; shift ;; + --quiet) quiet=true; shift ;; + --subnet) subnets+=("$2"); shift 2 ;; + --port) ports+=("$2"); shift 2 ;; + --service) services+=("$2"); shift 2 ;; + --all) all=true; shift ;; + --help) cmd::unblock::help; return ;; *) log::error "Unknown flag: $1" cmd::unblock::help @@ -84,30 +81,33 @@ function cmd::unblock::run() { esac done + # --identity: unblock all peers for this identity + if [[ -n "$identity" ]]; then + cmd::unblock::_unblock_identity "$identity" "$quiet" || return 1 + return 0 + fi + if [[ -z "$name" ]]; then - log::error "Missing required flag: --name" + log::error "Missing required flag: --name or --identity" cmd::unblock::help return 1 fi name=$(peers::resolve_and_require "$name" "$type") || return 1 - # Check if actually blocked if ! peers::is_blocked "$name" && ! block::has_file "$name"; then log::wg_warning "Client is not blocked: ${name}" return 0 fi - # Default to full unblock if no specific flags given - if [[ ${#ips[@]} -eq 0 && ${#subnets[@]} -eq 0 && ${#ports[@]} -eq 0 && ${#services[@]} -eq 0 ]]; then + if [[ ${#ips[@]} -eq 0 && ${#subnets[@]} -eq 0 && \ + ${#ports[@]} -eq 0 && ${#services[@]} -eq 0 ]]; then all=true fi local client_ip client_ip=$(peers::get_ip "$name") || return 1 - # $quiet || log::section "Unblocking client: ${name} (${client_ip})" - if $all; then cmd::unblock::_unblock_all "$name" "$client_ip" "$quiet" return 0 @@ -117,14 +117,14 @@ function cmd::unblock::run() { for ip in "${ips[@]}"; do fw::unblock_ip "$client_ip" "$ip" block::remove_rule "$name" "ip" "$ip" - $quiet || log::wg_success "${ip} has been unblocked for ${name}" + $quiet || log::wg_success "${ip} has been unblocked for ${name}" done # Unblock specific subnets for subnet in "${subnets[@]}"; do fw::unblock_subnet "$client_ip" "$subnet" block::remove_rule "$name" "subnet" "$subnet" - $quiet || log::wg_success "${subnet} has been unblocked for ${name}" + $quiet || log::wg_success "${subnet} has been unblocked for ${name}" done # Unblock specific ports @@ -133,8 +133,8 @@ function cmd::unblock::run() { IFS=":" read -r target port proto <<< "$entry" proto="${proto:-tcp}" fw::unblock_port "$client_ip" "$target" "$port" "$proto" - block::remove_rule "$name" "port" "$b_target" "$b_port" "$b_proto" - $quiet || log::wg_success "${client_ip}:${b_port}:${b_proto:-tcp} has been unblocked for ${name}" + block::remove_rule "$name" "port" "$target" "$port" "$proto" + $quiet || log::wg_success "${target}:${port}:${proto} has been unblocked for ${name}" done # Unblock services @@ -146,7 +146,6 @@ function cmd::unblock::run() { return 1 fi - # Check if actually blocked local is_blocked=false for resolved in "${resolved_lines[@]}"; do if [[ "$resolved" == *:*:* ]]; then @@ -180,27 +179,66 @@ function cmd::unblock::run() { $quiet || log::wg_success "${svc} has been unblocked for ${name}" done - # Clean up block file if now empty block::cleanup "$name" - return 0 } +# ============================================ +# Identity unblock +# ============================================ + +function cmd::unblock::_unblock_identity() { + local identity_name="${1:-}" quiet="${2:-false}" + + identity::require_exists "$identity_name" || return 1 + identity::require_has_peers "$identity_name" || return 1 + + local peers unblocked=0 skipped=0 + peers=$(identity::peers "$identity_name") + + while IFS= read -r peer_name; do + [[ -z "$peer_name" ]] && continue + if ! peers::is_blocked "$peer_name" && ! block::has_file "$peer_name"; then + skipped=$(( skipped + 1 )) + continue + fi + local client_ip + client_ip=$(peers::get_ip "$peer_name") || continue + + if cmd::unblock::_unblock_all "$peer_name" "$client_ip" true; then + unblocked=$(( unblocked + 1 )) + else + skipped=$(( skipped + 1 )) + fi + done <<< "$peers" + + if [[ $unblocked -eq 0 ]]; then + log::wg_warning "No peers were blocked for identity '${identity_name}'" + elif [[ $skipped -gt 0 ]]; then + log::ok "Unblocked ${unblocked} peer(s) for identity '${identity_name}' (${skipped} were not blocked)" + else + log::ok "Unblocked ${unblocked} peer(s) for identity '${identity_name}'" + fi + return 0 +} + +# ============================================ +# Helpers +# ============================================ + function cmd::unblock::_unblock_all() { local name="${1:?}" client_ip="${2:?}" quiet="${3:-false}" - # Direct unblock overrides everything — clear all block state block::set_direct "$name" "$client_ip" "false" block::clear_full_block "$name" - block::restore_peer "$name" "$client_ip" block::cleanup "$name" local rule rule=$(peers::get_meta "$name" "rule") - - [[ -n "$rule" ]] && rule::exists "$rule" && \ + if [[ -n "$rule" ]] && rule::exists "$rule"; then rule::apply "$rule" "$client_ip" "$name" + fi local groups groups=$(block::get_groups "$name") @@ -209,6 +247,5 @@ function cmd::unblock::_unblock_all() { fi $quiet || log::wg_success "${name} has been unblocked." - return 0 } \ No newline at end of file diff --git a/core/json.sh b/core/json.sh index 2c6aa4a..49232a1 100644 --- a/core/json.sh +++ b/core/json.sh @@ -60,6 +60,7 @@ function json::net_resolve() { python3 "$JSON_HELPER" net_resolve function json::net_reverse_lookup() { python3 "$JSON_HELPER" net_reverse_lookup "$@" name and count rules - for conf in glob.glob(f"{clients_dir}/*.conf"): + fw_lines = result.stdout.splitlines() + except Exception: + fw_lines = [] + + # Filter to only data lines (skip headers and blanks) + # In -v output, source IP is in column 8 (0-indexed) + # Format: pkts bytes target prot opt in out source destination [options] + rule_lines = [l for l in fw_lines if l.strip() and not l.startswith('Chain') and not l.startswith(' pkts')] + + for conf in sorted(glob.glob(f"{clients_dir}/*.conf")): name = os.path.basename(conf).replace('.conf', '') try: with open(conf) as f: + ip = '' for line in f: if line.startswith('Address'): ip = line.split('=')[1].strip().split('/')[0] - count = fw_output.count(ip) - print(f"{name}:{count}") break - except: + if not ip: + continue + # Count lines where source column exactly matches the peer IP + count = sum(1 for l in rule_lines if re.search(r'\s' + re.escape(ip) + r'\s', l)) + print(f"{name}:{count}") + except Exception: pass def peer_group_map(groups_dir): @@ -599,55 +606,6 @@ def peer_groups(groups_dir, peer_name): except: pass -def peer_data(clients_dir, meta_dir, events_log): - import glob - - meta = {} - for f in glob.glob(f"{meta_dir}/*.meta"): - name = os.path.basename(f).replace('.meta', '') - try: - with open(f) as mf: - meta[name] = json.load(mf) - except: - meta[name] = {} - - last_events = {} - try: - with open(events_log) as f: - for line in f: - try: - e = json.loads(line.strip()) - client = e.get('client', '') - if client: - last_events[client] = e - except: - pass - except: - pass - - for conf in sorted(glob.glob(f"{clients_dir}/*.conf")): - name = os.path.basename(conf).replace('.conf', '') - ip = '' - try: - with open(conf) as f: - for line in f: - if line.startswith('Address'): - ip = line.split('=')[1].strip().split('/')[0] - break - except: - pass - - m = meta.get(name, {}) - rule = m.get('rule', '') - subtype = m.get('subtype', '') - main_group = m.get('main_group', '') - - last_event = last_events.get(name, {}) - last_ts = last_event.get('timestamp', '') # raw ISO, no formatting - last_evt = last_event.get('event', '') # fixed: was last_event - - print(f"{name}|{ip}|{rule}|{subtype}|{last_ts}|{last_evt}|{main_group}") - def iso_to_ts(iso_str): """Convert ISO timestamp to unix timestamp""" try: @@ -1972,7 +1930,7 @@ def identity_migrate(identities_dir, clients_dir, meta_dir, dry_run): peer_name = os.path.basename(conf).replace('.conf', '') parsed = _parse_peer_name(peer_name) if not parsed: - print(f"skip|{peer_name}||0") + print(f"skip|{peer_name}") continue peer_type, identity_name, index = parsed if identity_name not in grouped: @@ -2005,13 +1963,12 @@ def identity_exists(file): # peer_data update — adds type field from meta # ============================================ # NOTE: This replaces the existing peer_data function. -# The new version reads 'type' from meta directly instead of inferring from subtype. +# The new version reads 'type' from meta directly. # Output format: name|ip|rule|type|last_ts|last_evt|main_group -# (field 4 is now 'type' instead of 'subtype') -def peer_data_v2(clients_dir, meta_dir, events_log): +def peer_data(clients_dir, meta_dir, events_log): """ - Updated peer_data that reads 'type' from meta instead of 'subtype'. + Updated peer_data that reads 'type' from meta. Output: name|ip|rule|type|last_ts|last_evt|main_group """ import glob @@ -2062,6 +2019,35 @@ def peer_data_v2(clients_dir, meta_dir, events_log): print(f"{name}|{ip}|{rule}|{peer_type}|{last_ts}|{last_evt}|{main_group}") +def subnet_default_rule(file, name, type_key=''): + """ + Return the default_rule for a subnet entry, or empty string if none set. + For scalar: subnet_default_rule(file, "desktop") -> "" + For group: subnet_default_rule(file, "guests", "phone") -> "guest" + """ + data = _subnet_read(file) + if name not in data: + print('') + return + entry = data[name] + if _subnet_is_group(entry): + key = type_key if type_key else 'none' + child = entry.get(key, {}) + print(child.get('default_rule', '')) + else: + print(entry.get('default_rule', '')) + +def subnet_list_names(file): + """ + List all top-level subnet names, one per line. + Used for dynamic flag registration in commands. + Output: one name per line (e.g. desktop, laptop, guests, servers, iot) + """ + data = _subnet_read(file) + for name in data.keys(): + print(name) + + commands = { 'get': lambda args: get(args[0], args[1]), 'set': lambda args: set_key(args[0], args[1], args[2]), @@ -2083,7 +2069,7 @@ commands = { 'audit_fw_counts': lambda args: audit_fw_counts(args[0]), 'peer_group_map': lambda args: peer_group_map(args[0]), 'peer_groups': lambda args: peer_groups(args[0], args[1]), - # 'peer_data': lambda args: peer_data(args[0], args[1], args[2]), + 'peer_data': lambda args: peer_data(args[0], args[1], args[2]), 'iso_to_ts': lambda args: iso_to_ts(args[0]), 'rule_list_data': lambda args: rule_list_data(args[0], args[1]), 'group_list_data': lambda args: group_list_data(args[0], args[1]), @@ -2099,7 +2085,6 @@ commands = { 'create_group': lambda args: create_group(args[0], args[1], args[2]), 'parse_event': lambda args: parse_event(args[0]), 'parse_fw_event': lambda args: parse_fw_event(args[0]), - 'peer_transfer': lambda args: peer_transfer(args[0]), 'remove_events_filtered': lambda args: remove_events_filtered( args[0], args[1], args[2], args[3], args[4]=='true', args[5]=='true', args[6] if len(args)>6 else ''), 'peer_transfer': lambda args: peer_transfer(args[0]), @@ -2183,7 +2168,8 @@ commands = { 'identity_migrate': lambda args: identity_migrate(args[0], args[1], args[2], args[3]), 'identity_infer': lambda args: identity_infer(args[0]), 'identity_exists': lambda args: identity_exists(args[0]), - 'peer_data': lambda args: peer_data_v2(args[0], args[1], args[2]), + 'subnet_default_rule': lambda args: subnet_default_rule(args[0], args[1], args[2] if len(args) > 2 else ''), + 'subnet_list_names': lambda args: subnet_list_names(args[0]), } if __name__ == '__main__': diff --git a/core/log.sh b/core/log.sh index 6fdf9dd..da86794 100644 --- a/core/log.sh +++ b/core/log.sh @@ -158,7 +158,7 @@ function internal::get_context_icon() { function internal::log::info() { internal::log INFO "$*"; } function internal::log::warn() { internal::log WARN "$*"; } function internal::log::error() { internal::log ERROR "$*"; } -function internal::log::success() { internal::log SUCCESS "$*"; } +function internal::log::success() { internal::log OK "$*"; } function internal::log::debug() { internal::log DEBUG "$*"; } # ============================================ diff --git a/daemon/endpoint_cache.json b/daemon/endpoint_cache.json index 947699e..7398222 100644 --- a/daemon/endpoint_cache.json +++ b/daemon/endpoint_cache.json @@ -1,5 +1,5 @@ { - "phone-fred": "94.63.0.129", + "phone-fred": "176.223.61.130", "phone-helena": "148.69.46.73", "phone-nuno": "94.63.0.129", "tablet-nuno": "148.69.202.5", @@ -7,5 +7,7 @@ "guest-zephyr-test": "94.63.0.129", "desktop-roboclean": "46.189.215.231", "laptop-nuno": "94.63.0.129", - "phone-luis": "176.223.61.15" + "phone-luis": "176.223.61.15", + "phone-helena-2": "148.69.192.157", + "desktop-zephyr": "86.120.152.74" } \ No newline at end of file diff --git a/modules/block.module.sh b/modules/block.module.sh index 58d5295..74e82c8 100644 --- a/modules/block.module.sh +++ b/modules/block.module.sh @@ -104,6 +104,8 @@ function block::rename() { old_file=$(block::file "$name") new_file=$(block::file "$new_name") [[ -f "$old_file" ]] && mv "$old_file" "$new_file" + + return 0 } function block::clear_full_block() { diff --git a/modules/config.module.sh b/modules/config.module.sh index 287c058..1e54c7d 100644 --- a/modules/config.module.sh +++ b/modules/config.module.sh @@ -48,7 +48,18 @@ function config::_init_defaults() { function config::validate() { local errors=() - # Required fields + # Server key and config files + if [[ ! -f "$_WG_SERVER_PUBLIC_KEY_FILE" ]]; then + errors+=("Server public key not found: ${_WG_SERVER_PUBLIC_KEY_FILE}") + fi + if [[ ! -f "$_WG_SERVER_PRIVATE_KEY_FILE" ]]; then + errors+=("Server private key not found: ${_WG_SERVER_PRIVATE_KEY_FILE}") + fi + if [[ ! -f "$_WG_CONFIG" ]]; then + errors+=("WireGuard config not found: ${_WG_CONFIG}") + fi + + # Required config values local endpoint endpoint=$(config::endpoint) if [[ -z "$endpoint" ]]; then @@ -60,9 +71,9 @@ function config::validate() { local port port=$(config::port) if [[ -z "$port" ]]; then - errors+=("WG_LISTEN_PORT is not set") + errors+=("WG_PORT is not set") elif ! [[ "$port" =~ ^[0-9]+$ ]] || (( port < 1 || port > 65535 )); then - errors+=("WG_LISTEN_PORT must be a valid port number (1-65535)") + errors+=("WG_PORT must be a valid port number (1-65535)") fi local dns @@ -79,13 +90,7 @@ function config::validate() { errors+=("WG_SUBNET is not set — required for IP allocation") fi - local interface - interface=$(config::interface) - if [[ -z "$interface" ]]; then - errors+=("WG_INTERFACE is not set, defaulting to wg0") - fi - - # Warn-only fields + # Warn-only local lan lan=$(config::lan) if [[ -z "$lan" ]]; then @@ -140,38 +145,6 @@ function config::load() { _WG_TUNNEL_SPLIT="${_WG_SUBNET}, ${_WG_LAN}" } -# ============================================ -# Device Type → Subnet Mapping -# ============================================ - -declare -gA DEVICE_SUBNETS=( - [desktop]="10.1.1" - [laptop]="10.1.2" - [phone]="10.1.3" - [tablet]="10.1.4" - [guest]="10.1.100" - [guest-desktop]="10.1.101" - [guest-laptop]="10.1.102" - [guest-phone]="10.1.103" - [guest-tablet]="10.1.104" -) - -# ============================================ -# Tunnel Modes -# ============================================ - -declare -gA DEVICE_TUNNEL_MODE=( - [desktop]="split" - [laptop]="split" - [phone]="split" - [tablet]="split" - [guest]="split" - [guest-desktop]="split" - [guest-laptop]="split" - [guest-phone]="split" - [guest-tablet]="split" -) - # ============================================ # Accessors # ============================================ @@ -194,46 +167,8 @@ function config::activity_current_med() { echo "$_ACTIVITY_TOTAL_MED_BYTES"; } function config::activity_current_high() { echo "$_ACTIVITY_TOTAL_HIGH_BYTES"; } function config::server_public_key() { cat "$_WG_SERVER_PUBLIC_KEY_FILE"; } -function config::device_types() { - local types - { set +u; types="${!DEVICE_SUBNETS[@]}"; set -u; } - echo "$types" -} - -function config::is_valid_type() { - local type="$1" - local subnet - subnet=$(config::subnet_for "$type") - [[ -n "$subnet" ]] -} - -function config::is_guest_type() { - local type="$1" - [[ "$type" == "guest" || "$type" == guest-* ]] -} - -function config::subnet_for() { - local type="$1" - local result - { set +u; result="${DEVICE_SUBNETS[$type]:-}"; set -u; } - echo "$result" -} - -function config::default_tunnel_for() { - local type="$1" - local result - { set +u; result="${DEVICE_TUNNEL_MODE[$type]:-split}"; set -u; } - echo "$result" -} - function config::allowed_ips_for() { - local type="$1" - local tunnel="${2:-}" - - if [[ -z "$tunnel" ]]; then - tunnel=$(config::default_tunnel_for "$type") - fi - + local tunnel="${2:-split}" case "$tunnel" in full) echo "$_WG_TUNNEL_FULL" ;; split) echo "$_WG_TUNNEL_SPLIT" ;; @@ -242,25 +177,4 @@ function config::allowed_ips_for() { return 1 ;; 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 -} +} \ No newline at end of file diff --git a/modules/identity.module.sh b/modules/identity.module.sh index e842fb9..5ce1e3d 100644 --- a/modules/identity.module.sh +++ b/modules/identity.module.sh @@ -34,6 +34,31 @@ function identity::require_not_exists() { return 1 fi } + +# identity::require_exists_for_flag +# 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 +# Used by block/unblock/list to ensure identity has peers to operate on. +function identity::require_has_peers() { + local identity_name="${1:-}" + local peers + peers=$(identity::peers "$identity_name") + if [[ -z "$peers" ]]; then + log::error "Identity '${identity_name}' has no peers" + return 1 + fi +} # =========================================================================== # Peer name inference @@ -64,6 +89,25 @@ function identity::next_index() { json::identity_next_index "$id_file" "$peer_type" 2>/dev/null || echo 1 } +# identity::next_peer_name +# 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) # =========================================================================== diff --git a/modules/ip.module.sh b/modules/ip.module.sh index ed60991..37b6408 100644 --- a/modules/ip.module.sh +++ b/modules/ip.module.sh @@ -15,40 +15,74 @@ function ip::is_assigned() { ip::assigned | grep -q "^${candidate}$" } -function ip::next_for_type() { - local type="$1" - local subnet - subnet=$(config::subnet_for "$type") - - if [[ -z "$subnet" ]]; then - log::error "Unknown device type: ${type}" +# ip::next_for_subnet +# Finds the next unassigned host IP within a CIDR. +# Replaces ip::next_for_type for the subnet-aware allocation path. +function ip::next_for_subnet() { + local cidr="${1:-}" + + if [[ -z "$cidr" ]]; then + log::error "No subnet CIDR provided for IP allocation" return 1 fi - - for i in $(seq 1 254); do - local candidate="${subnet}.${i}" - if ! ip::is_assigned "$candidate"; then - echo "$candidate" - return 0 - fi + + local prefix + prefix=$(subnet::prefix "$cidr") + + local candidate + for i in $(subnet::host_range "$cidr"); do + candidate="${prefix}.${i}" + ip::is_assigned "$candidate" || { echo "$candidate"; return 0; } done - - log::error "No available IPs in subnet ${subnet}.0/24" + + log::error "No available IPs in subnet ${cidr}" return 1 } + # ============================================ # Validation # ============================================ function ip::is_valid() { - local ip="$1" - [[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}(/([0-9]|[1-2][0-9]|3[0-2]))?$ ]] -} + local ip="${1:-}" + [[ -z "$ip" ]] && return 1 + # Strip CIDR mask if present + local addr="${ip%%/*}" + + # Structural check — 4 octets, optional /mask + [[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}(/([0-9]|[1-2][0-9]|3[0-2]))?$ ]] || return 1 + + # Octet range check — each must be 0-255 + local IFS='.' + local -a octets + read -ra octets <<< "$addr" + for octet in "${octets[@]}"; do + (( octet >= 0 && octet <= 255 )) || return 1 + done + + return 0 +} function ip::is_cidr() { [[ "$1" == *"/"* ]] } + +# ip::is_valid_for_subnet +# 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 +# Errors and returns 1 if the IP is not valid for the subnet. +# Used when a manual --ip override is provided. +function ip::require_valid_for_subnet() { + local cidr="${1:-}" ip="${2:-}" + subnet::require_ip_valid_for "$cidr" "$ip" +} function ip::validate() { local ip="$1" diff --git a/modules/keys.module.sh b/modules/keys.module.sh index b731df3..8769e3b 100644 --- a/modules/keys.module.sh +++ b/modules/keys.module.sh @@ -106,6 +106,5 @@ function keys::qr() { return 1 fi - log::wg_qr "QR code for: ${name}" qrencode -t ansiutf8 < "$conf" } diff --git a/modules/peers.module.sh b/modules/peers.module.sh index 96ddadd..53bccab 100644 --- a/modules/peers.module.sh +++ b/modules/peers.module.sh @@ -8,7 +8,7 @@ function peers::create_client_config() { local name="$1" local type="$2" local ip="$3" - local allowed_ips="${4:-$(config::allowed_ips_for "$type" "$(config::default_tunnel_for "$type")")}" + local allowed_ips="${4:-$(config::allowed_ips_for "split")}" local conf conf="$(ctx::clients)/${name}.conf" @@ -118,16 +118,9 @@ function peers::list() { local public_key public_key=$(keys::public "$client_name" 2>/dev/null || echo "unknown") - # Determine type from IP - local type="unknown" - for t in $(config::device_types); do - local subnet - subnet=$(config::subnet_for "$t") - if string::starts_with "$ip" "$subnet"; then - type="$t" - break - fi - done + local type + type=$(peers::get_meta "$client_name" "type" 2>/dev/null) + [[ -z "$type" ]] && type=$(peers::get_type_from_ip "$ip") printf " %-30s %-15s %-10s %s\n" \ "$client_name" "$ip" "$type" "$public_key" @@ -146,12 +139,12 @@ function peers::list_by_type() { local ip ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1) - local subnet - subnet=$(config::subnet_for "$filter_type") + local type + type=$(peers::get_meta "$client_name" "type" 2>/dev/null) + [[ -z "$type" ]] && type=$(peers::get_type_from_ip "$ip") - if string::starts_with "$ip" "$subnet"; then + [[ "$type" == "$filter_type" ]] && \ printf " %-30s %-15s\n" "$client_name" "$ip" - fi done } @@ -160,11 +153,6 @@ function peers::exists_in_server() { grep -q "^# ${name}$" "$(config::config_file)" } -# function peers::is_blocked() { -# local name="${1:-}" -# peers::exists_in_server "$name" && return 1 || return 0 -# } - function peers::is_blocked() { local name="${1:-}" block::is_blocked "$name" @@ -183,25 +171,12 @@ function peers::get_type() { local name="$1" local ip ip=$(peers::get_ip "$name") - [[ -z "$ip" ]] && echo "unknown" && return - - local type="unknown" - for t in $(config::device_types); do - local subnet - subnet=$(config::subnet_for "$t") - if string::starts_with "$ip" "${subnet}."; then - type="$t" - break - fi - done - echo "$type" + [[ -z "$ip" ]] && echo "unknown" && return 0 + peers::get_type_from_ip "$ip" } function peers::default_rule() { - local name="$1" - local type - type=$(peers::get_type "$name") - config::is_guest_type "$type" && echo "guest" || echo "user" + echo "user" } function peers::effective_rule() { @@ -305,7 +280,7 @@ function peers::resolve_name() { local type="${2:-}" if [[ -n "$type" ]]; then - if ! config::is_valid_type "$type"; then + if ! subnet::exists "$type"; then log::error "Invalid device type: ${type}" return 1 fi @@ -333,6 +308,15 @@ function peers::resolve_and_require() { echo "$resolved" } +function peers::rename_meta() { + local name="${1:-}" new_name="${2:-}" + local old_meta new_meta + old_meta=$(peers::meta_path "$name") + new_meta=$(peers::meta_path "$new_name") + [[ -f "$old_meta" ]] && mv "$old_meta" "$new_meta" + return 0 +} + # ============================================ # Cleanup # ============================================ @@ -377,22 +361,6 @@ function peers::format_last_seen() { esac } -# function peers::format_status() { -# local name="${1:-}" public_key="${2:-}" is_blocked="${3:-false}" -# local is_restricted="${4:-false}" handshake_ts="${5:-0}" last_ts="${6:-}" - -# local state -# state=$(peers::connection_state "$is_blocked" "$is_restricted" \ -# "$handshake_ts" "$last_ts") - -# local conn_str modifier color -# IFS="|" read -r conn_str modifier color <<< "$state" - -# local display="$conn_str" -# [[ -n "$modifier" ]] && display="${conn_str} (${modifier})" -# echo -e "${color}${display}\033[0m" -# } - function peers::format_status() { local name="${1:-}" public_key="${2:-}" is_blocked="${3:-false}" local is_restricted="${4:-false}" handshake_ts="${5:-0}" last_ts="${6:-}" @@ -448,14 +416,8 @@ function peers::format_status_verbose() { } function peers::display_type() { - local type="${1:-}" subtype="${2:-}" - if config::is_guest_type "$type" && [[ -n "$subtype" && "$subtype" != "0" ]]; then - echo "guest/${subtype}" - elif config::is_guest_type "$type"; then - echo "guest" - else - echo "$type" - fi + local type="${1:-}" _subtype="${2:-}" + echo "${type:-unknown}" } # ============================================ @@ -516,17 +478,10 @@ function peers::last_seen_data() { function peers::get_type_from_ip() { local ip="${1:-}" [[ -z "$ip" ]] && echo "unknown" && return 0 - local type="unknown" - for t in $(config::device_types); do - local subnet - subnet=$(config::subnet_for "$t") - string::starts_with "$ip" "${subnet}." && type="$t" && break - done - echo "$type" + subnet::type_from_ip "$ip" } - # ============================================ # Activity # ============================================ diff --git a/modules/rule.module.sh b/modules/rule.module.sh index 782d96c..18a0e37 100644 --- a/modules/rule.module.sh +++ b/modules/rule.module.sh @@ -70,7 +70,7 @@ function rule::is_applied() { local target port proto IFS=":" read -r target port proto <<< "$first_port" proto="${proto:-tcp}" - fw::has_block_rule "$client_ip" "$target" "$proto" "$port" + fw::has_block_rule "$client_ip" "$target" "$proto" "$port" return $? fi @@ -112,7 +112,6 @@ function rule::apply() { log::debug "rule::apply: peer_name=$peer_name ip=$client_ip" - # Check if already applied if rule::is_applied "$rule_name" "$client_ip"; then log::wg "Rule '${rule_name}' already applied to: ${client_ip}" [[ -n "$peer_name" ]] && peers::set_meta "$peer_name" "rule" "$rule_name" @@ -149,7 +148,6 @@ function rule::apply() { fw::allow_port "$client_ip" "$target" "$port" "$proto" done < <(rule::get "$rule_name" "allow_ports") - # Persist rule assignment [[ -n "$peer_name" ]] && peers::set_meta "$peer_name" "rule" "$rule_name" # DNS redirect @@ -221,26 +219,38 @@ function rule::unapply() { local dns_redirect dns_redirect=$(rule::get "$rule_name" "dns_redirect") if [[ "$dns_redirect" == "true" ]]; then - local subtype - subtype=$(peers::get_meta "$peer_name" "subtype") - local subnet - if [[ -n "$subtype" ]]; then - subnet=$(config::subnet_for "$subtype") - else - local peer_type - peer_type=$(peers::get_type "$peer_name") || true - [[ -z "$peer_type" ]] && peer_type="phone" - subnet=$(config::subnet_for "$peer_type") - fi - rule::remove_dns_redirect "${subnet}.0/24" + local peer_ip peer_subnet + peer_ip=$(peers::get_ip "$peer_name") + peer_subnet=$(echo "$peer_ip" | cut -d'.' -f1-3) + rule::remove_dns_redirect "${peer_subnet}.0/24" fi - # Clear rule from meta [[ -n "$peer_name" ]] && peers::set_meta "$peer_name" "rule" "" log::debug "Removed rule '${rule_name}' from: ${client_ip}" } +# ============================================ +# Bulk Operations +# ============================================ + +# rule::full_restore_peer +# Flush and fully restore all fw rules for a peer — rule rules + block rules. +# Use this instead of calling rule::apply + block::restore_rules_for separately +# to ensure block rules are never left missing after a flush. +function rule::full_restore_peer() { + local peer_name="${1:-}" client_ip="${2:-}" + [[ -z "$peer_name" || -z "$client_ip" ]] && return 1 + + fw::flush_peer "$client_ip" + + local rule_name + rule_name=$(peers::get_meta "$peer_name" "rule") + [[ -n "$rule_name" ]] && rule::apply "$rule_name" "$client_ip" "$peer_name" + + block::restore_rules_for "$peer_name" "$client_ip" +} + function rule::reapply_all() { local rule_name="${1:-}" rule::require_exists "$rule_name" || return 1 @@ -254,9 +264,7 @@ function rule::reapply_all() { local client_ip client_ip=$(peers::get_ip "$peer_name") [[ -z "$client_ip" ]] && continue - # FLUSH first to ensure clean ordering - fw::flush_peer "$client_ip" - rule::apply "$rule_name" "$client_ip" "$peer_name" + rule::full_restore_peer "$peer_name" "$client_ip" (( count++ )) || true done @@ -265,12 +273,10 @@ function rule::reapply_all() { function rule::restore_all() { while IFS= read -r peer_name; do - # Skip blocked peers - no fw rules needed when blocked block::is_blocked "$peer_name" && continue - + local rule_name rule_name=$(peers::get_meta "$peer_name" "rule") - [[ -z "$rule_name" ]] && continue if ! rule::exists "$rule_name"; then @@ -282,7 +288,8 @@ function rule::restore_all() { client_ip=$(peers::get_ip "$peer_name") [[ -z "$client_ip" ]] && continue - rule::apply "$rule_name" "$client_ip" + # full_restore_peer ensures block rules are restored alongside rule rules + rule::full_restore_peer "$peer_name" "$client_ip" done < <(peers::all) log::wg "Rules restored for all peers" } @@ -333,12 +340,7 @@ function rule::render_flat() { } function rule::render_entries() { - # Renders allow/block entries for a rule name with annotations and DNS - # Usage: rule::render_entries - # indent: 4 for rule show, 4 for inspect (same) local rule_name="${1:-}" indent="${2:-4}" - local rule_file - rule_file="$(rule::path "$rule_name")" local allow_ports allow_ips block_ips block_ports dns allow_ports=$(rule::get "$rule_name" "allow_ports" 2>/dev/null || true) @@ -362,7 +364,6 @@ function rule::render_entries() { } function rule::render_own_entries() { - # Renders own (non-inherited) entries for a rule local rule_name="${1:-}" local rule_file rule_file="$(rule::path "$rule_name")" @@ -374,11 +375,8 @@ function rule::render_own_entries() { block_ports=$(json::get "$rule_file" "block_ports" 2>/dev/null || true) dns=$(json::get "$rule_file" "dns_redirect" 2>/dev/null || true) - local has_own=false local combined="${allow_ports}${allow_ips}${block_ips}${block_ports}" - [[ -n "${combined//[$'\n']/}" ]] && has_own=true - - $has_own || return 0 + [[ -z "${combined//[$'\n']/}" ]] && return 0 while IFS= read -r e; do [[ -z "$e" ]] && continue @@ -397,7 +395,6 @@ function rule::render_own_entries() { } function rule::render_extends_tree() { - # Renders full inheritance tree for a rule local rule_name="${1:-}" local rule_file rule_file="$(rule::path "$rule_name")" @@ -413,7 +410,6 @@ function rule::render_extends_tree() { rule::render_entries "$base_name" done - # Own rules after inherited local own_output own_output=$(rule::render_own_entries "$rule_name") if [[ -n "$own_output" ]]; then @@ -436,4 +432,4 @@ function rule::apply_dns_redirect() { function rule::remove_dns_redirect() { local client_subnet="${1:-}" fw::nat_remove_dns_redirect "$client_subnet" "$(config::dns)" "$(config::interface)" -} +} \ No newline at end of file diff --git a/modules/subnet.module.sh b/modules/subnet.module.sh index ad5606b..9144f61 100644 --- a/modules/subnet.module.sh +++ b/modules/subnet.module.sh @@ -1,17 +1,153 @@ #!/usr/bin/env bash -# subnet.module.sh — subnet map lookups, resolution, and validation +# subnet.module.sh — subnet map lookups, resolution, validation, and CIDR utilities # All subnet data lives in subnets.json; this module wraps json:: calls # and provides hardcoded fallbacks for safety. -# =========================================================================== -# Hardcoded fallbacks (mirrors production subnets.json) -# Used when subnets.json lookup fails — keeps existing peers working. -# These match the legacy DEVICE_SUBNETS / DEVICE_TUNNEL_MODE maps in config.module.sh. -# =========================================================================== +# ====================================================== +# CIDR Utilities +# Pure functions — no external dependencies, no side effects. +# Suitable for unit testing. +# ====================================================== -function subnet::_hardcoded_subnet() { +# subnet::prefix +# 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 a subnet name was given, try legacy guest-* mapping first if [[ -n "$subnet_name" ]]; then case "$subnet_name" in guests) echo "10.1.100.0/24" ;; @@ -26,7 +162,6 @@ function subnet::_hardcoded_subnet() { laptop) echo "10.1.2.0/24" ;; phone) echo "10.1.3.0/24" ;; tablet) echo "10.1.4.0/24" ;; - # Legacy guest-* types — kept for existing peers during migration guest) echo "10.1.100.0/24" ;; guest-desktop) echo "10.1.101.0/24" ;; guest-laptop) echo "10.1.102.0/24" ;; @@ -57,13 +192,12 @@ function subnet::_hardcoded_type() { } function subnet::_hardcoded_tunnel_mode() { - # All current subnets use split — placeholder for future full-tunnel entries echo "split" } -# =========================================================================== -# Core resolution -# =========================================================================== +# ====================================================== +# Core Resolution +# ====================================================== # subnet::lookup [type_key] # Returns the CIDR for a given subnet name and optional type. @@ -76,64 +210,48 @@ function subnet::lookup() { echo "$result" return 0 fi - subnet::_hardcoded_subnet "" "$subnet_name" + subnet::_hardcoded_cidr "" "$subnet_name" } # subnet::resolve_for_add [subnet_name] -# Main entry point for wgctl add. -# Returns the CIDR to allocate from. -# Resolution order: -# 1. subnet_name given + type given -> subnets[subnet_name][type] -# 2. subnet_name given, no type -> subnets[subnet_name]["none"] -# 3. no subnet_name -> subnets[type] (scalar, type-native) -# 4. fallback -> hardcoded map +# Main entry point for wgctl add — returns the CIDR to allocate from. function subnet::resolve_for_add() { local peer_type="${1:-}" subnet_name="${2:-}" local result if [[ -n "$subnet_name" ]]; then - # Try with type key first + # Group entry: try type-specific child first, then "none" slot if [[ -n "$peer_type" ]]; then result=$(json::subnet_lookup "$(ctx::subnets)" "$subnet_name" "$peer_type" 2>/dev/null) || true - if [[ -n "$result" ]]; then echo "$result"; return 0; fi + [[ -n "$result" ]] && { echo "$result"; return 0; } fi - # Fall back to "none" slot in group, or scalar entry result=$(json::subnet_lookup "$(ctx::subnets)" "$subnet_name" 2>/dev/null) || true - if [[ -n "$result" ]]; then echo "$result"; return 0; fi - # Hardcoded fallback for subnet name - subnet::_hardcoded_subnet "" "$subnet_name" + [[ -n "$result" ]] && { echo "$result"; return 0; } + subnet::_hardcoded_cidr "" "$subnet_name" return 0 fi # No subnet_name — resolve from type (native allocation) if [[ -n "$peer_type" ]]; then result=$(json::subnet_lookup "$(ctx::subnets)" "$peer_type" 2>/dev/null) || true - if [[ -n "$result" ]]; then echo "$result"; return 0; fi + [[ -n "$result" ]] && { echo "$result"; return 0; } fi - subnet::_hardcoded_subnet "$peer_type" + subnet::_hardcoded_cidr "$peer_type" } # subnet::type_for_add [subnet_name] # Returns the canonical type string to store in meta. -# If --subnet given and it's a scalar, type comes from subnets.json entry. -# If --subnet is a group, type comes from --type flag (or "none"). -# If no --subnet, type comes from --type flag directly. function subnet::type_for_add() { local type_flag="${1:-}" subnet_name="${2:-}" local result if [[ -n "$subnet_name" ]]; then result=$(json::subnet_type "$(ctx::subnets)" "$subnet_name" "$type_flag" 2>/dev/null) || true - if [[ -n "$result" ]]; then echo "$result"; return 0; fi + [[ -n "$result" ]] && { echo "$result"; return 0; } fi - # No subnet or lookup failed — use the type flag directly - if [[ -n "$type_flag" ]]; then - echo "$type_flag" - else - echo "none" - fi + echo "${type_flag:-none}" } # subnet::tunnel_mode [type_key] @@ -142,25 +260,31 @@ function subnet::tunnel_mode() { local subnet_name="${1:-}" type_key="${2:-}" local result result=$(json::subnet_tunnel_mode "$(ctx::subnets)" "$subnet_name" "$type_key" 2>/dev/null) || true - if [[ -n "$result" ]]; then echo "$result"; return 0; fi + [[ -n "$result" ]] && { echo "$result"; return 0; } subnet::_hardcoded_tunnel_mode } -# subnet::type_from_ip -# Reverse-lookup: given a peer's IP, return its type. -# Tries meta file first (by peer name), then subnets.json, then hardcoded. function subnet::type_from_ip() { local ip="${1:-}" - local result + [[ -z "$ip" ]] && echo "unknown" && return 0 + # Fast path: hardcoded map covers all production subnets — pure bash, no subshell + local type + type=$(subnet::_hardcoded_type "$ip") + if [[ "$type" != "unknown" ]]; then + echo "$type" + return 0 + fi + + # Slow path: Python lookup for dynamically-added subnets not in hardcoded map + local result result=$(json::subnet_for_ip "$(ctx::subnets)" "$ip" 2>/dev/null) || true if [[ -n "$result" ]]; then - # result is "subnet_name|type_key" echo "${result##*|}" return 0 fi - subnet::_hardcoded_type "$ip" + echo "unknown" } # subnet::name_from_ip @@ -169,26 +293,34 @@ function subnet::name_from_ip() { local ip="${1:-}" local result result=$(json::subnet_for_ip "$(ctx::subnets)" "$ip" 2>/dev/null) || true - if [[ -n "$result" ]]; then - echo "${result%%|*}" - return 0 - fi + [[ -n "$result" ]] && { echo "${result%%|*}"; return 0; } echo "" } -# =========================================================================== -# Validation -# =========================================================================== +# Returns the default rule for a subnet, or empty string if none configured. +# Called by add.command.sh to resolve the rule before falling back to "user". +function subnet::default_rule() { + local subnet_name="${1:-}" type_key="${2:-}" + [[ -z "$subnet_name" ]] && return 0 + json::subnet_default_rule "$(ctx::subnets)" "$subnet_name" "$type_key" 2>/dev/null || true +} + +# subnet::list_names +# Returns all top-level subnet names, one per line. +# Used by commands to dynamically register -- flags. +function subnet::list_names() { + json::subnet_list_names "$(ctx::subnets)" 2>/dev/null || true +} + +# ====================================================== +# Validation +# ====================================================== -# subnet::exists -# Returns 0 if subnet exists in subnets.json, 1 otherwise. function subnet::exists() { local name="${1:-}" json::subnet_exists "$(ctx::subnets)" "$name" 2>/dev/null } -# subnet::require_exists -# Errors and exits if subnet doesn't exist. function subnet::require_exists() { local name="${1:-}" if ! subnet::exists "$name"; then @@ -197,9 +329,6 @@ function subnet::require_exists() { fi } -# subnet::peers_using -# Returns comma-separated list of peer names using this subnet (from meta). -# Empty string if none. function subnet::peers_using() { local subnet_name="${1:-}" local peers @@ -213,19 +342,14 @@ function subnet::peers_using() { } -# =========================================================================== -# Display helpers -# =========================================================================== +# ====================================================== +# Display Data +# ====================================================== -# subnet::list_data -# Returns all subnet entries formatted for display. -# Output per line: display_name|subnet|type|tunnel_mode|desc|is_group|group_parent function subnet::list_data() { json::subnet_list "$(ctx::subnets)" 2>/dev/null || true } -# subnet::show_data -# Returns detail lines for a single subnet entry. function subnet::show_data() { local name="${1:-}" json::subnet_show "$(ctx::subnets)" "$name" diff --git a/wgctl b/wgctl index 6065b2f..34efdc3 100755 --- a/wgctl +++ b/wgctl @@ -9,9 +9,9 @@ LOG_LEVEL=DEBUG # Modules # ============================================ -load_module config -load_module ui load_module ip +load_module ui +load_module config load_module keys load_module peers load_module firewall