diff --git a/commands/add.command.sh b/commands/add.command.sh index e390ad6..abb8211 100644 --- a/commands/add.command.sh +++ b/commands/add.command.sh @@ -3,150 +3,145 @@ # ============================================ # Lifecycle # ============================================ - + function cmd::add::on_load() { + load_module subnet + load_module identity + load_module policy + 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 (overrides policy) + --rule Peer rule (default: from policy default_rule or none) + --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 --identity nuno --type phone + wgctl add --name zephyr --type desktop --guests + wgctl add --identity zephyr --type desktop --guests + wgctl add --name visitor --type phone --guests --show-qr wgctl add --name dev --type laptop --rule dev-01 - wgctl add --name visitor --type guest --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 +149,165 @@ 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 effective policy + local identity_name="${identity:-$(identity::get_name "$full_name")}" + local effective_policy + effective_policy=$(policy::effective "$subnet_name" "$resolved_type" "$identity_name") + + # Resolve tunnel mode — flag overrides policy + if [[ -z "$tunnel" ]]; then + tunnel=$(policy::tunnel_mode "$effective_policy") + fi + + # Resolve peer rule — explicit flag overrides policy default_rule + if [[ -z "$rule" ]]; then + rule=$(policy::default_rule "$effective_policy") + fi + + # Validate rule if set + if [[ -n "$rule" ]]; then + rule::exists "$rule" || { log::error "Rule not found: ${rule}"; return 1; } + fi + + 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:---}" "$effective_policy" + + keys::generate_pair "$full_name" || return 1 + peers::create_client_config "$full_name" "$resolved_type" "$ip" "$allowed_ips" || return 1 + + # Write meta — type, subnet, rule (if set) + peers::set_meta "$full_name" "type" "$resolved_type" + if [[ -n "$subnet_name" ]]; then + peers::set_meta "$full_name" "subnet" "$subnet_name" + fi + if [[ -n "$rule" ]]; then + peers::set_meta "$full_name" "rule" "$rule" + 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 + + # Apply peer rule if set + if [[ -n "$rule" ]]; then + rule::apply "$rule" "$ip" "$full_name" || return 1 + fi + + # Auto-attach to identity and apply identity rule if set + identity::auto_attach "$full_name" "$resolved_type" + cmd::add::_apply_identity_rule "$full_name" "$ip" "$identity_name" "$effective_policy" "$rule" + + peers::reload || return 1 + + 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:-}" policy="${10:-}" + + 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}" + log::wg_add "Policy: ${policy}" +} + +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::_apply_identity_rule() { + local full_name="${1:-}" ip="${2:-}" identity_name="${3:-}" \ + effective_policy="${4:-}" peer_rule="${5:-}" + + [[ -z "$identity_name" ]] && return 0 + + local rules + rules=$(identity::rules "$identity_name") + + if [[ -z "$rules" ]]; then + # No identity rules — warn if no peer rule either + if [[ -z "$peer_rule" ]]; then + policy::warn_no_rule "$full_name" + fi + return 0 + fi + + # Apply all identity rules + rule::_apply_identity_rule "$full_name" "$ip" + + # Warn based on strict_rule + local strict + strict=$(identity::rule_flags "$identity_name" "strict_rule") + if [[ "$strict" == "true" ]]; then + local rule_list + rule_list=$(echo "$rules" | tr '\n' ',' | sed 's/,$//') + policy::warn_strict_rule "$identity_name" "$effective_policy" "$rule_list" + elif [[ -n "$peer_rule" ]]; then + local rule_list + rule_list=$(echo "$rules" | tr '\n' ',' | sed 's/,$//') + policy::warn_additive_rule "$identity_name" "$rule_list" "$peer_rule" + fi } 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/identity.command.sh b/commands/identity.command.sh new file mode 100644 index 0000000..a56c0ca --- /dev/null +++ b/commands/identity.command.sh @@ -0,0 +1,536 @@ +#!/usr/bin/env bash +# identity.command.sh — manage peer identities +# +# Subcommands: +# wgctl identity list +# wgctl identity show --name +# wgctl identity add --name --peer +# wgctl identity remove --name +# wgctl identity migrate [--dry-run] + +# ============================================ +# Lifecycle +# ============================================ + +function cmd::identity::on_load() { + load_module identity + load_module policy + + flag::register --name + flag::register --peer + flag::register --dry-run + flag::register --force + # rule subcommand flags + flag::register --rule + # options subcommand flags + flag::register --policy + flag::register --set-strict-rule + flag::register --unset-strict-rule + flag::register --set-auto-apply + flag::register --unset-auto-apply + flag::register --field + flag::register --value +} + +# ============================================ +# Help +# ============================================ + +function cmd::identity::help() { + cat < [options] + +Manage peer identities. + +Subcommands: + list List all identities + show --name Show identity details and device status + add --name Manually attach a peer to an identity + --peer + remove --name Remove identity and all associated peers + migrate [--dry-run] Create identities from existing peer names + + rule assign --name Assign a rule to an identity + --rule + rule unassign --name Remove rule from an identity + rule show --name Show current identity rule + + options --name Set identity options + [--policy ] + [--set-strict-rule | --unset-strict-rule] + [--set-auto-apply | --unset-auto-apply] + +Examples: + wgctl identity list + wgctl identity show --name nuno + wgctl identity rule assign --name nuno --rule admin + wgctl identity rule unassign --name nuno + wgctl identity options --name guests-identity --policy guest + wgctl identity options --name nuno --set-strict-rule +EOF +} + +# ============================================ +# Run +# ============================================ + +function cmd::identity::run() { + local subcmd="${1:-list}" + shift || true + + case "$subcmd" in + list) cmd::identity::_list "$@" ;; + show) cmd::identity::_show "$@" ;; + add) cmd::identity::_add "$@" ;; + remove) cmd::identity::_remove "$@" ;; + migrate) cmd::identity::_migrate "$@" ;; + rule) cmd::identity::_rule "$@" ;; + options) cmd::identity::_options "$@" ;; + --help) cmd::identity::help ;; + *) + log::error "Unknown subcommand '${subcmd}'. Available: list, show, add, remove, migrate, rule, options" + return 1 + ;; + esac +} + +# ============================================ +# Subcommands +# ============================================ + +function cmd::identity::_list() { + local data + data=$(identity::list_data) + + if [[ -z "$data" ]]; then + log::info "No identities found. Run 'wgctl identity migrate' to create from existing peers." + return 0 + fi + + echo "" + while IFS='|' read -r name peer_count types rules policy; do + local rules_display + rules_display=$(echo "$rules" | sed 's/,/, /g') + ui::identity::list_row_compact "$name" "$peer_count" "$rules_display" "$policy" + done <<< "$data" + echo "" +} + +function cmd::identity::_show() { + local name="" + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + --help) cmd::identity::help; return ;; + *) log::error "Unknown flag: $1"; return 1 ;; + esac + done + + [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } + identity::require_exists "$name" || return 1 + + # Gather identity-level metadata + local policy strict auto rules_list peer_count + policy=$(identity::policy "$name") + strict=$(identity::rule_flags "$name" "strict_rule") + auto=$(identity::rule_flags "$name" "auto_apply") + rules_list=$(identity::rules "$name" | tr '\n' ',' | sed 's/,$//' | sed 's/,/, /g') + + local data + data=$(identity::show_data "$name") + peer_count=$(echo "$data" | grep '^peer_count|' | cut -d'|' -f2) + + # Precompute handshakes once for all peers in this identity + declare -A _id_handshakes=() + while IFS=$'\t' read -r pk ts; do + [[ -n "$pk" ]] && _id_handshakes["$pk"]="$ts" + done < <(wg show "$(config::interface)" latest-handshakes 2>/dev/null) + + # Header + echo "" + ui::row "Identity" "$name" + ui::row "Policy" "$policy" + ui::row "Rules" "${rules_list:-—}" + ui::row "Strict rule" "$(ui::bool "$strict")" + ui::row "Auto apply" "$(ui::bool "$auto")" + ui::row "Peers" "$peer_count" + echo "" + + # Device list + while IFS='|' read -r key val type_val index_val; do + case "$key" in + name|peer_count) ;; + device) + local status="" + status=$(cmd::identity::_device_status "$val" _id_handshakes) + ui::identity::device_row "$val" "$type_val" "$index_val" "$status" + ;; + esac + done <<< "$data" + + echo "" +} + + +function cmd::identity::_device_status() { + local peer_name="${1:-}" + local -n _handshakes="${2:-__empty_map}" + + local peer_ip + peer_ip=$(peers::get_ip "$peer_name" 2>/dev/null) || return 0 + [[ -z "$peer_ip" ]] && return 0 + + local is_blocked is_restricted pubkey handshake_ts + peers::is_blocked "$peer_name" && is_blocked="true" || is_blocked="false" + peers::is_restricted "$peer_name" && is_restricted="true" || is_restricted="false" + + pubkey="$(keys::public "$peer_name")" + handshake_ts="${_handshakes[$pubkey]:-0}" + + local last_ts + last_ts=$(peers::get_meta "$peer_name" "last_ts" 2>/dev/null) || last_ts="" + + local status + status=$(peers::format_status_verbose \ + "$peer_name" "$pubkey" "$is_blocked" "$is_restricted" "$handshake_ts" "$last_ts") + echo " — ${status}" +} + +function cmd::identity::_add() { + local name="" peer="" + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + --peer) peer="$2"; shift 2 ;; + --help) cmd::identity::help; return ;; + *) log::error "Unknown flag: $1"; return 1 ;; + esac + done + + [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } + [[ -z "$peer" ]] && { log::error "Missing required flag: --peer"; return 1; } + + cmd::identity::_require_peer_exists "$peer" || return 1 + + local peer_type index + peer_type=$(cmd::identity::_resolve_peer_type "$peer" "$name") + index=$(identity::next_index "$name" "$peer_type") + + local id_file + id_file=$(ctx::identity::path "${name}.identity") + json::identity_add_peer "$id_file" "$name" "$peer" "$peer_type" "$index" /dev/null || echo "none" + fi +} + +function cmd::identity::_remove() { + local name="" force=false + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + --force) force=true; shift ;; + --help) cmd::identity::help; return ;; + *) log::error "Unknown flag: $1"; return 1 ;; + esac + done + + [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } + identity::require_exists "$name" || return 1 + + local peers + peers=$(identity::peers "$name") + + if [[ -n "$peers" ]]; then + local peer_list="${peers//$'\n'/, }" + log::warn "This will permanently remove identity '${name}' and ALL associated peers:" + log::warn " ${peer_list}" + + if ! $force; then + ui::confirm "Continue?" || { log::info "Aborted"; return 0; } + fi + + cmd::identity::_remove_all_peers "$peers" + peers::reload || return 1 + fi + + local id_file + id_file=$(ctx::identity::path "${name}.identity") + json::identity_remove "$id_file" /dev/null) || client_ip="" + peers::is_blocked "$peer_name" && was_blocked=true + + peers::purge "$peer_name" "$client_ip" "$was_blocked" || return 1 + log::ok "Removed peer '${peer_name}'" +} + +function cmd::identity::_migrate() { + local dry_run="false" + while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) dry_run="true"; shift ;; + --help) cmd::identity::help; return ;; + *) log::error "Unknown flag: $1"; return 1 ;; + esac + done + + [[ "$dry_run" == "true" ]] && log::info "Dry run — no files will be written" + echo "" + + [[ "$dry_run" == "false" ]] && mkdir -p "$(ctx::identities)" + + local created=0 skipped=0 output + output=$(json::identity_migrate \ + "$(ctx::identities)" \ + "$(ctx::clients)" \ + "$(ctx::meta)" \ + "$dry_run") + + while IFS='|' read -r action identity_name peer_name peer_type index; do + case "$action" in + create) + ui::identity::migrate_create "$peer_name" "$identity_name" "$peer_type" "$index" + (( created++ )) || true + ;; + skip) + ui::identity::migrate_skip "$peer_name" + (( skipped++ )) || true + ;; + esac + done <<< "$output" + + ui::identity::migrate_summary "$created" "$skipped" "$dry_run" +} + +function cmd::identity::_rule() { + local subcmd="${1:-show}" + shift || true + + case "$subcmd" in + assign) cmd::identity::_rule_assign "$@" ;; + unassign) cmd::identity::_rule_unassign "$@" ;; + show) cmd::identity::_rule_show "$@" ;; + *) + log::error "Unknown rule subcommand '${subcmd}'. Available: assign, unassign, show" + return 1 + ;; + esac +} + +function cmd::identity::_rule_assign() { + local name="" rule="" + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + --rule) rule="$2"; shift 2 ;; + *) log::error "Unknown flag: $1"; return 1 ;; + esac + done + + [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } + [[ -z "$rule" ]] && { log::error "Missing required flag: --rule"; return 1; } + identity::require_exists "$name" || return 1 + rule::exists "$rule" || { log::error "Rule not found: ${rule}"; return 1; } + + local exit_code + identity::add_rule "$name" "$rule" || exit_code=$? + + if [[ $exit_code -eq 2 ]]; then + log::warn "Rule '${rule}' is already assigned to identity '${name}'" + return 0 + fi + + log::ok "Rule '${rule}' assigned to identity '${name}'" + + # Reapply rules if auto_apply + local auto + auto=$(identity::rule_flags "$name" "auto_apply") + if [[ "$auto" != "false" ]]; then + log::info "Reapplying rules for all peers in identity '${name}'..." + identity::reapply_rules "$name" + log::ok "Rules reapplied" + fi + + # Warn about strict_rule + if policy::strict_rule "$(identity::policy "$name")"; then + log::warn "Strict rule is enabled for identity '${name}' — peer rules will not be additive" + fi +} + +function cmd::identity::_rule_unassign() { + local name="" rule="" all=false + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + --rule) rule="$2"; shift 2 ;; + --all) all=true; shift ;; + *) log::error "Unknown flag: $1"; return 1 ;; + esac + done + + [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } + identity::require_exists "$name" || return 1 + + if $all; then + local rules + rules=$(identity::rules "$name") + if [[ -z "$rules" ]]; then + log::warn "Identity '${name}' has no rules assigned" + return 0 + fi + identity::clear_rules "$name" + log::ok "All rules removed from identity '${name}'" + cmd::identity::_reapply_after_unassign "$name" + return 0 + fi + + [[ -z "$rule" ]] && { + log::error "Missing required flag: --rule (or use --all to remove all)" + return 1 + } + + identity::remove_rule "$name" "$rule" + local exit_code=$? + if [[ $exit_code -ne 0 ]]; then + log::error "Rule '${rule}' is not assigned to identity '${name}'" + return 1 + fi + + log::ok "Rule '${rule}' removed from identity '${name}'" + cmd::identity::_reapply_after_unassign "$name" +} + +function cmd::identity::_reapply_after_unassign() { + local name="${1:-}" + local auto + auto=$(identity::rule_flags "$name" "auto_apply") + if [[ "$auto" != "false" ]]; then + log::info "Reapplying rules for all peers in identity '${name}'..." + identity::reapply_rules "$name" + log::ok "Rules reapplied" + else + log::info "Note: auto_apply is disabled — run 'wgctl audit --fix' to update fw rules" + fi +} + +function cmd::identity::_rule_show() { + local name="" + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + *) log::error "Unknown flag: $1"; return 1 ;; + esac + done + + [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } + identity::require_exists "$name" || return 1 + + local rules policy strict auto + rules=$(identity::rules "$name") + policy=$(identity::policy "$name") + strict=$(identity::rule_flags "$name" "strict_rule") + auto=$(identity::rule_flags "$name" "auto_apply") + + echo "" + ui::row "Identity" "$name" + ui::row "Policy" "$policy" + ui::row "Strict rule" "$(ui::bool "$strict")" + ui::row "Auto apply" "$(ui::bool "$auto")" + echo "" + + if [[ -z "$rules" ]]; then + ui::row "Rules" "— none assigned" + else + printf " %-20s\n" "Rules:" + while IFS= read -r rule_name; do + [[ -z "$rule_name" ]] && continue + printf " · %s\n" "$rule_name" + done <<< "$rules" + fi + echo "" +} + +function cmd::identity::_options() { + local name="" new_policy="" + local set_strict="" set_auto="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + --policy) new_policy="$2"; shift 2 ;; + --set-strict-rule) set_strict="true"; shift ;; + --unset-strict-rule) set_strict="false"; shift ;; + --set-auto-apply) set_auto="true"; shift ;; + --unset-auto-apply) set_auto="false"; shift ;; + *) log::error "Unknown flag: $1"; return 1 ;; + esac + done + + [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } + identity::require_exists "$name" || return 1 + + local changed=false + + if [[ -n "$new_policy" ]]; then + policy::require_exists "$new_policy" || return 1 + identity::set_policy "$name" "$new_policy" + log::ok "Policy set to '${new_policy}' for identity '${name}'" + changed=true + fi + + if [[ -n "$set_strict" ]]; then + identity::set_rule_flag "$name" "strict_rule" "$set_strict" + if [[ "$set_strict" == "true" ]]; then + log::ok "Strict rule enabled for identity '${name}' — peer rules will not be additive" + else + log::ok "Strict rule disabled for identity '${name}' — peer rules will be additive" + fi + changed=true + fi + + if [[ -n "$set_auto" ]]; then + identity::set_rule_flag "$name" "auto_apply" "$set_auto" + if [[ "$set_auto" == "true" ]]; then + log::ok "Auto apply enabled for identity '${name}'" + else + log::ok "Auto apply disabled for identity '${name}'" + fi + changed=true + fi + + if ! $changed; then + cmd::identity::_rule_show --name "$name" + fi +} \ No newline at end of file diff --git a/commands/inspect.command.sh b/commands/inspect.command.sh index dd0bea6..e148b01 100644 --- a/commands/inspect.command.sh +++ b/commands/inspect.command.sh @@ -89,12 +89,9 @@ 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_file="" local rule_extends="" if [[ -n "$rule" ]]; then - local rule_file rule_file="$(rule::path "$rule" 2>/dev/null)" || true if [[ -n "$rule_file" ]]; then local ext=() @@ -117,8 +114,8 @@ 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 "Rule" "$rule_display" "${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}" ui::row "Last seen" "$last_seen" "${INSPECT_LABEL_WIDTH}" @@ -130,22 +127,81 @@ function cmd::inspect::_peer_info() { return 0 } +# function cmd::inspect::_rule_info() { +# local name="${1:-}" +# local rule +# rule=$(peers::get_meta "$name" "rule") +# [[ -z "$rule" ]] && return 0 +# rule::exists "$rule" || return 0 + +# cmd::inspect::_section "Rule: ${rule}" + +# if ui::rule::tree "$rule"; then +# # printf "\n" +# : # no-op +# else +# # No inheritance — flat view +# rule::render_flat "$rule" +# fi +# return 0 +# } + +function cmd::inspect::_rule_separator() { + local line_width=20 + local total=$INSPECT_WIDTH + local pad=$(( (total - line_width) / 2 )) + printf "\n%*s\033[2m%s\033[0m\n\n" "$pad" "" "$(printf '─%.0s' $(seq 1 $line_width))" +} + function cmd::inspect::_rule_info() { local name="${1:-}" local rule rule=$(peers::get_meta "$name" "rule") - [[ -z "$rule" ]] && return 0 - rule::exists "$rule" || return 0 - - cmd::inspect::_section "Rule: ${rule}" - - if rule::render_extends_tree "$rule"; then - # printf "\n" - : # no-op - else - # No inheritance — flat view - rule::render_flat "$rule" + + local identity_name identity_rules strict + identity_name=$(identity::get_name "$name") + if [[ -n "$identity_name" ]]; then + identity_rules=$(identity::rules "$identity_name") + strict=$(identity::rule_flags "$identity_name" "strict_rule") fi + + # Skip section entirely if nothing to show + [[ -z "$rule" && -z "$identity_rules" ]] && return 0 + + # Build section header + local header="Rules" + [[ -n "$rule" ]] && header="${header}: ${rule}" + [[ -n "$identity_name" && -n "$identity_rules" ]] && \ + header="${header} · identity:${identity_name}" + + cmd::inspect::_section "$header" + + # Identity block first + if [[ -n "$identity_name" && -n "$identity_rules" ]]; then + ui::rule::identity_block "$identity_name" "$strict" + fi + + # Peer rule block — only if set and not suppressed + if [[ -n "$rule" ]]; then + rule::exists "$rule" || return 0 + + if [[ -n "$identity_rules" ]]; then + # Both identity and peer rules exist — show peer block with same pattern + printf "\n \033[0;37m· peer:%s\033[0m\n" "$name" + ui::rule::_peer_rule_entry "$rule" + else + # Only peer rule — render directly without peer: label + printf "\n" + if rule::render_extends_tree "$rule"; then + : + else + rule::render_flat "$rule" + fi + fi + elif [[ "$strict" == "true" && -n "$rule" ]]; then + printf "\n \033[2mpeer rule '%s' suppressed by strict policy\033[0m\n" "$rule" + fi + return 0 } diff --git a/commands/list.command.sh b/commands/list.command.sh index 69cbd46..a7c95a1 100644 --- a/commands/list.command.sh +++ b/commands/list.command.sh @@ -5,9 +5,13 @@ # ============================================ function cmd::list::on_load() { + load_module identity + load_module ui + flag::register --type flag::register --rule flag::register --group + flag::register --identity flag::register --online flag::register --offline flag::register --restricted @@ -28,87 +32,31 @@ Usage: wgctl list [options] List all WireGuard clients. Options: - --type Filter by device type (desktop, laptop, phone, tablet, guest) + --type Filter by device type --rule Filter by assigned rule --group Filter by group membership + --identity Filter by identity --online Show only connected clients --offline Show only disconnected clients - --blocked Show only fully blocked clients (removed from WireGuard) - --restricted Show only restricted clients (specific IP/port blocks applied) + --blocked Show only fully blocked clients + --restricted Show only restricted clients --allowed Show only unrestricted clients - --detailed Show full detail cards for all clients + --detailed Show detailed view grouped by identity --name Show detail card for a single client -Status values: - online Connected (recent handshake) - offline Not connected - blocked Removed from WireGuard server (wgctl block --name) - restricted In WireGuard but with specific access rules (wgctl block --ip/--service) - Examples: wgctl list - wgctl list --type guest - wgctl list --rule user - wgctl list --group family + wgctl list --type phone + wgctl list --identity nuno wgctl list --online wgctl list --blocked - wgctl list --restricted wgctl list --detailed wgctl list --name phone-nuno EOF } # ============================================ -# Header / Footer -# ============================================ - -function cmd::list::_render_header() { - local has_groups="$1" - if $has_groups; then - printf "\n %-28s %-15s %-13s %-12s %-12s %-22s %s\n" \ - "NAME" "IP" "TYPE" "RULE" "GROUP" "STATUS" "LAST SEEN" - printf " %s\n" "$(printf '─%.0s' {1..135})" - else - printf "\n %-28s %-15s %-13s %-12s %-22s %s\n" \ - "NAME" "IP" "TYPE" "RULE" "STATUS" "LAST SEEN" - printf " %s\n" "$(printf '─%.0s' {1..107})" - fi -} - -function cmd::list::_render_footer() { - local has_groups="$1" - if $has_groups; then - printf " %s\n" "$(printf '─%.0s' {1..135})" - else - printf " %s\n" "$(printf '─%.0s' {1..107})" - fi -} - -function cmd::list::_render_summary() { - local group_summary="${1:-}" - local -n _rule_counts="$2" - - # Count total from rule_counts (only filtered peers) - local total=0 - for r in "${!_rule_counts[@]}"; do - (( total += _rule_counts[$r] )) || true - done - - local summary="" - for r in "${!_rule_counts[@]}"; do - summary+="${_rule_counts[$r]} ${r}, " - done - summary="${summary%, }" - - if [[ -n "$group_summary" ]]; then - printf "\n Showing %s peers [%s] — %s\n\n" "$total" "$summary" "$group_summary" - else - printf "\n Showing %s peers [%s]\n\n" "$total" "$summary" - fi -} - -# ============================================ -# Detail Card (show_client) +# Detail Card # ============================================ function cmd::list::show_client() { @@ -131,10 +79,8 @@ function cmd::list::show_client() { public_key=$(keys::public "$name" 2>/dev/null || echo "unknown") 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 +101,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 +120,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 +143,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 +153,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 +170,6 @@ function cmd::list::run() { esac done - # Single detail card if [[ -n "$single_name" ]]; then cmd::list::show_client "$single_name" return 0 @@ -237,40 +183,55 @@ 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 + # Resolve identity filter + 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 + + log::section "WireGuard Clients" + + # Collect all filtered rows first (needed for dynamic column widths) + local collected_rows="" + collected_rows=$(cmd::list::_collect_all_rows | ui::sort_rows) + + if [[ -z "$collected_rows" ]]; then + log::wg_warning "No results found" 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 - - cmd::list::_iter_confs "$filter_type" cmd::list::_render_row - - if [[ "$_list_header_printed" == "true" ]]; then - cmd::list::_render_footer $has_groups - local group_summary="" - cmd::list::_build_group_summary - cmd::list::_render_summary "$group_summary" rule_counts "$filter_desc" - else - log::wg_warning "No results found${filter_desc:+ for: ${filter_desc}}" + if $detailed; then + cmd::list::_render_detailed "$collected_rows" + cmd::list::_render_summary_from_rows "$collected_rows" + return 0 fi + + local style + style=$(ui::peer::list_style) + + case "$style" in + table) cmd::list::_render_table ;; + compact) cmd::list::_render_compact "$collected_rows" ;; + *) cmd::list::_render_compact "$collected_rows" ;; + esac } -function cmd::list::_iter_confs() { - # Usage: cmd::list::_iter_confs - local filter_type="$1" - local callback="$2" +# ============================================ +# Row collection (single pass, all filters) +# ============================================ + +function cmd::list::_collect_all_rows() { + # Outputs pipe-delimited rows for peers that pass all filters + # Fields: name|ip|type|rule|group|status|last_seen|is_blocked|is_restricted local dir dir="$(ctx::clients)" @@ -278,86 +239,238 @@ 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) + [[ -z "$client_name" ]] && continue + + # Identity filter + 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) + [[ -z "$ip" ]] && continue + + local type="${p_types[$client_name]:-unknown}" [[ -n "$filter_type" && "$type" != "$filter_type" ]] && continue - "$callback" "$client_name" "$ip" "$type" + + local pubkey="${p_pubkeys[$client_name]:-}" + local handshake_ts="${wg_handshakes[$pubkey]:-0}" + local is_blocked="${p_blocked[$client_name]:-false}" + local is_restricted="${p_restricted[$client_name]:-false}" + local last_ts="${p_last_ts[$client_name]:-}" + local rule="${p_rules[$client_name]:-}" + local group="${p_main_groups[$client_name]:-}" + + # Apply status filters + if $online_only; then peers::is_online "$client_name" "$handshake_ts" "$last_ts" || continue; fi + if $offline_only; then peers::is_offline "$client_name" "$handshake_ts" "$last_ts" || continue; fi + if $restricted_only && [[ "$is_restricted" != "true" ]]; then continue; fi + if $blocked_only && [[ "$is_blocked" != "true" ]]; then continue; fi + if $allowed_only && { [[ "$is_blocked" == "true" ]] || \ + [[ "$is_restricted" == "true" ]]; }; then continue; fi + + # Apply rule/group filters + if [[ -n "$filter_rule" && "$rule" != "$filter_rule" ]]; then continue; fi + if [[ -n "$filter_group" ]]; then + local all_groups="${peer_group_map[$client_name]:-}" + [[ "$all_groups" != *"$filter_group"* ]] && continue + fi + + # Resolve status + local state + state=$(peers::connection_state "$is_blocked" "$is_restricted" "$handshake_ts" "$last_ts") + local status="${state%%|*}" + + # Resolve last seen + local last_seen="—" + if [[ "$is_blocked" == "true" && -n "$last_ts" && "$last_ts" != "0" ]]; then + local attempt_ts + attempt_ts=$(json::iso_to_ts "$last_ts") + last_seen=$(fmt::datetime_short "$attempt_ts") + elif [[ -n "$handshake_ts" && "$handshake_ts" != "0" ]]; then + last_seen=$(fmt::datetime_short "$handshake_ts") + fi + + printf "%s|%s|%s|%s|%s|%s|%s|%s|%s\n" \ + "$client_name" "$ip" "$type" \ + "${rule:--}" "${group:--}" \ + "$status" "$last_seen" \ + "$is_blocked" "$is_restricted" done } -# function cmd::list::_render_row() { -# local client_name="$1" ip="$2" type="$3" +# ============================================ +# Compact render +# ============================================ -# local pubkey="${p_pubkeys[$client_name]:-}" -# local handshake_ts="${wg_handshakes[$pubkey]:-0}" -# local is_blocked="${p_blocked[$client_name]:-false}" -# local is_restricted="${p_restricted[$client_name]:-false}" -# local last_ts="${p_last_ts[$client_name]:-}" +function cmd::list::_render_compact() { + local rows="${1:-}" -# # 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 $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" ]] || \ -# [[ "$is_restricted" == "true" ]]; }; then return 0; fi + # Measure column widths from pure values (fields 1-5, no labels) + local w_name w_ip w_type w_rule w_group + w_name=$(ui::measure_col "$rows" 1 14) + w_ip=$(ui::measure_col "$rows" 2 13) + w_type=$(ui::measure_col "$rows" 3 7) + w_rule=$(ui::measure_col "$rows" 4 4) + w_group=$(ui::measure_col "$rows" 5 4) -# if [[ -n "$filter_group" ]]; then -# local peer_group="${peer_group_map[$client_name]:-}" -# [[ "$peer_group" != "$filter_group" ]] && return 0 -# fi + echo "" + while IFS='|' read -r name ip type rule group status last_seen is_blocked is_restricted; do + [[ -z "$name" ]] && continue + ui::peer::list_row_compact \ + "$w_name" "$w_ip" "$w_type" "$w_rule" "$w_group" \ + "$name" "$ip" "$type" "$rule" "$group" \ + "$status" "$last_seen" "$is_blocked" "$is_restricted" + done <<< "$rows" + echo "" -# # Format display values -# local status last_seen display_type rule group_display -# status=$(peers::format_status "$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]:-}") -# rule="${p_rules[$client_name]:-—}" + cmd::list::_render_summary_from_rows "$rows" +} -# if [[ -n "$filter_rule" && "$rule" != "$filter_rule" ]]; then return 0; fi +# ============================================ +# Table render (kept for config switching) +# ============================================ -# # Print header on first match -# if [[ "${_list_header_printed:-false}" == "false" ]]; then -# log::section "WireGuard Clients" -# cmd::list::_render_header $has_groups -# _list_header_printed=true -# fi +function cmd::list::_render_table() { + declare -A rule_counts=() group_counts=() + _list_header_printed=false -# # Update rule counts for summary (outer scope array) -# rule_counts["$rule"]=$(( ${rule_counts[$rule]:-0} + 1 )) || true + cmd::list::_iter_confs_table -# # Pad status -# local padded_status -# padded_status=$(ui::pad_status "$status" 25) + if [[ "$_list_header_printed" == "true" ]]; then + cmd::list::_render_footer $has_groups + local group_summary="" + cmd::list::_build_group_summary + printf "\n Showing peers\n\n" + else + log::wg_warning "No results found" + fi +} -# # Render row -# if $has_groups; then -# group_display="${peer_group_map[$client_name]:-—}" +function cmd::list::_iter_confs_table() { + local dir + dir="$(ctx::clients)" + for conf in "${dir}"/*.conf; do + [[ -f "$conf" ]] || continue + local client_name + client_name=$(basename "$conf" .conf) + [[ -z "$client_name" ]] && continue -# if [[ -n "${peer_group_map[$client_name]:-}" ]]; then -# group_counts["$group_display"]=$(( ${group_counts[$group_display]:-0} + 1 )) || true -# fi + if [[ ${#p_identity_filter[@]} -gt 0 && \ + -z "${p_identity_filter[$client_name]:-}" ]]; then + continue + fi -# local rule_col_width=12 group_col_width=12 -# [[ "$rule" == "—" ]] && rule_col_width=14 -# [[ "$group_display" == "—" ]] && group_col_width=14 -# printf " %-28s %-15s %-13s %-${rule_col_width}s %-${group_col_width}s %s %s\n" \ -# "$client_name" "$ip" "$display_type" "$rule" \ -# "$group_display" "$padded_status" "$last_seen" -# else -# local rule_col_width=12 -# [[ "$rule" == "—" ]] && rule_col_width=14 -# printf " %-28s %-15s %-13s %-${rule_col_width}s %s %s\n" \ -# "$client_name" "$ip" "$display_type" "$rule" \ -# "$padded_status" "$last_seen" -# fi -# } + local ip="${p_ips[$client_name]:-}" + [[ -z "$ip" ]] && ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1) + + local type="${p_types[$client_name]:-unknown}" + [[ -n "$filter_type" && "$type" != "$filter_type" ]] && continue + + cmd::list::_render_row "$client_name" "$ip" "$type" + done +} + +# ============================================ +# Detailed render (grouped by identity) +# ============================================ + +function cmd::list::_render_detailed() { + local rows="${1:-}" + + # Measure widths + local w_name w_ip w_type w_rule w_group w_subnet + w_name=$(ui::measure_col "$rows" 1 14) + w_ip=$(ui::measure_col "$rows" 2 13) + w_type=$(ui::measure_col "$rows" 3 7) + w_rule=$(ui::measure_col "$rows" 4 4) + w_group=$(ui::measure_col "$rows" 5 4) + # subnet not in rows — use fixed width + w_subnet=10 + + # Group by identity + declare -A identity_rows=() + local no_identity_rows="" + + while IFS='|' read -r name ip type rule group status last_seen is_blocked is_restricted; do + [[ -z "$name" ]] && continue + local id_name + id_name=$(identity::get_name "$name") + local row="${name}|${ip}|${type}|${rule}|${group}|${status}|${last_seen}|${is_blocked}|${is_restricted}" + if [[ -n "$id_name" ]]; then + identity_rows["$id_name"]+="${row}"$'\n' + else + no_identity_rows+="${row}"$'\n' + fi + done <<< "$rows" + + echo "" + + # Render identity groups (sorted) + for id_name in $(echo "${!identity_rows[@]}" | tr ' ' '\n' | sort); do + ui::peer::list_identity_header "$id_name" + while IFS='|' read -r name ip type rule group status last_seen is_blocked is_restricted; do + [[ -z "$name" ]] && continue + local subnet + subnet=$(peers::get_meta "$name" "subnet" 2>/dev/null) || subnet="-" + [[ -z "$subnet" ]] && subnet="-" + ui::peer::list_row_detailed \ + "$w_name" "$w_ip" "$w_type" "$w_rule" "$w_group" "$w_subnet" \ + "$name" "$ip" "$type" "$rule" "$group" "$subnet" \ + "$status" "$last_seen" "$is_blocked" "$is_restricted" + done < <(echo "${identity_rows[$id_name]}" | ui::sort_rows) + done + + # Render peers without identity (no "other" header if empty) + if [[ -n "$no_identity_rows" ]]; then + local trimmed + trimmed=$(echo "$no_identity_rows" | grep -v '^$') + if [[ -n "$trimmed" ]]; then + ui::peer::list_identity_header "other" + while IFS='|' read -r name ip type rule group status last_seen is_blocked is_restricted; do + [[ -z "$name" ]] && continue + local subnet + subnet=$(peers::get_meta "$name" "subnet" 2>/dev/null) || subnet="—" + [[ -z "$subnet" ]] && subnet="—" + ui::peer::list_row_detailed \ + "$w_name" "$w_ip" "$w_type" "$w_rule" "$w_group" "$w_subnet" \ + "$name" "$ip" "$type" "$rule" "$group" "$subnet" \ + "$status" "$last_seen" "$is_blocked" "$is_restricted" + done <<< "$trimmed" + fi + fi + + echo "" +} + +# ============================================ +# Summary +# ============================================ + +function cmd::list::_render_summary_from_rows() { + local rows="${1:-}" + declare -A rule_counts=() + local total=0 + + while IFS='|' read -r name ip type rule rest; do + [[ -z "$name" ]] && continue + (( total++ )) || true + rule_counts["${rule:-—}"]=$(( ${rule_counts[${rule:-—}]:-0} + 1 )) || true + done <<< "$rows" + + local summary="" + for r in "${!rule_counts[@]}"; do + summary+="${rule_counts[$r]} ${r}, " + done + summary="${summary%, }" + + printf " Showing %s peers [%s]\n\n" "$total" "$summary" +} + +# ============================================ +# Table row rendering +# ============================================ function cmd::list::_render_row() { local client_name="$1" ip="$2" type="$3" @@ -368,9 +481,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" ]] || \ @@ -381,20 +493,17 @@ 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" \ + local status last_seen display_type rule + 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 _list_header_printed=true fi @@ -405,51 +514,39 @@ 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" - else - group_display="${peer_group_map[$client_name]:-—}" - fi - - if [[ -n "${peer_group_map[$client_name]:-}" ]]; then - group_counts["$group_display"]=$(( ${group_counts[$group_display]:-0} + 1 )) || true - fi - - local rule_col_width=12 group_col_width=12 - [[ "$rule" == "—" ]] && rule_col_width=14 - [[ "$group_display" == "—" ]] && group_col_width=14 - printf " %-28s %-15s %-13s %-${rule_col_width}s %-${group_col_width}s %s %s\n" \ + local group_display="${main_group:-${peer_group_map[$client_name]:-—}}" + printf " %-28s %-15s %-13s %-12s %-12s %s %s\n" \ "$client_name" "$ip" "$display_type" "$rule" \ "$group_display" "$padded_status" "$last_seen" else - local rule_col_width=12 - [[ "$rule" == "—" ]] && rule_col_width=14 - printf " %-28s %-15s %-13s %-${rule_col_width}s %s %s\n" \ + printf " %-28s %-15s %-13s %-12s %s %s\n" \ "$client_name" "$ip" "$display_type" "$rule" \ "$padded_status" "$last_seen" fi } # ============================================ -# 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 + 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_rules["$name"]="${rule:-}" + 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)") - # WireGuard handshakes + endpoints + for name in "${!p_ips[@]}"; do + [[ -n "${p_types[$name]:-}" ]] && continue + p_types["$name"]=$(peers::get_type_from_ip "${p_ips[$name]}") + done + declare -gA wg_handshakes=() wg_endpoints=() while IFS=$'\t' read -r pubkey ts; do [[ -n "$pubkey" ]] && wg_handshakes["$pubkey"]="$ts" @@ -458,11 +555,9 @@ function cmd::list::_precompute_all() { [[ -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)" @@ -473,7 +568,6 @@ function cmd::list::_precompute_all() { p_pubkeys["$kname"]=$(cat "$kf" 2>/dev/null || echo "") done - # Groups + main group has_groups=false declare -gA peer_group_map=() local groups_dir @@ -486,75 +580,10 @@ function cmd::list::_precompute_all() { 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)") + # Identity precompute (for --identity filter) + declare -gA p_identity_filter=() } -# 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" @@ -569,7 +598,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 @@ -580,15 +608,16 @@ function cmd::list::_precompute_block_status() { done < <(peers::all) } -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} " - $online_only && filter_desc+="online " - $offline_only && filter_desc+="offline " - $blocked_only && filter_desc+="blocked " - filter_desc="${filter_desc% }" +# ============================================ +# Header / Footer (table layout) +# ============================================ + +function cmd::list::_render_header() { + ui::peer::list_header_table "$1" +} + +function cmd::list::_render_footer() { + ui::peer::list_footer_table "$1" } function cmd::list::_build_group_summary() { diff --git a/commands/policy.command.sh b/commands/policy.command.sh new file mode 100644 index 0000000..b9b0149 --- /dev/null +++ b/commands/policy.command.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash +# policy.command.sh — manage policies +# +# Subcommands: +# wgctl policy list +# wgctl policy show --name +# wgctl policy add --name [--tunnel-mode split|full] +# [--default-rule ] [--strict-rule] [--no-auto-apply] +# [--desc ] +# wgctl policy rm --name +# wgctl policy set --name --field --value + +# ============================================ +# Lifecycle +# ============================================ + +function cmd::policy::on_load() { + load_module policy + + flag::register --name + flag::register --tunnel-mode + flag::register --default-rule + flag::register --strict-rule + flag::register --no-strict-rule + flag::register --auto-apply + flag::register --no-auto-apply + flag::register --desc + flag::register --field + flag::register --value +} + +# ============================================ +# Help +# ============================================ + +function cmd::policy::help() { + cat < [options] + +Manage policies. Policies define behavioral flags for subnets and identities. + +Subcommands: + list List all policies + show --name Show policy details + add --name Add a new policy + [--tunnel-mode split|full] + [--default-rule ] + [--strict-rule] + [--no-auto-apply] + [--desc ] + rm --name Remove a policy (built-ins cannot be removed) + set --name Set a single field on a policy + --field + --value + +Fields: + tunnel_mode split|full + default_rule rule name (or empty to clear) + strict_rule true|false + auto_apply true|false + desc description string + +Built-in policies (cannot be removed): default, guest, trusted, server, iot + +Examples: + wgctl policy list + wgctl policy show --name guest + wgctl policy add --name contractor --default-rule contractor --strict-rule + wgctl policy set --name contractor --field tunnel-mode --value full + wgctl policy rm --name contractor +EOF +} + +# ============================================ +# Run +# ============================================ + +function cmd::policy::run() { + local subcmd="${1:-list}" + shift || true + + case "$subcmd" in + list) cmd::policy::_list "$@" ;; + show) cmd::policy::_show "$@" ;; + add) cmd::policy::_add "$@" ;; + rm) cmd::policy::_rm "$@" ;; + set) cmd::policy::_set "$@" ;; + --help) cmd::policy::help ;; + *) + log::error "Unknown subcommand '${subcmd}'. Available: list, show, add, rm, set" + return 1 + ;; + esac +} + +# ============================================ +# Subcommands +# ============================================ + +function cmd::policy::_list() { + local data + data=$(policy::list_data) + + if [[ -z "$data" ]]; then + log::info "No policies defined." + return 0 + fi + + echo "" + while IFS='|' read -r name tunnel default_rule strict auto desc; do + ui::policy::list_row "$name" "$default_rule" "$strict" "$auto" + done <<< "$data" + echo "" +} + +function cmd::policy::_show() { + local name="" + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + --help) cmd::policy::help; return ;; + *) log::error "Unknown flag: $1"; return 1 ;; + esac + done + + [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } + policy::require_exists "$name" || return 1 + + local dr tunnel strict auto + dr=$(policy::default_rule "$name") + tunnel=$(policy::tunnel_mode "$name") + strict=$(policy::strict_rule "$name" && echo "yes" || echo "no") + auto=$(policy::auto_apply "$name" && echo "yes" || echo "no") + + local rule_val="-" + [[ -n "$dr" ]] && rule_val="$dr" + + local strict_padded + strict_padded=$(printf "%-4s" "$strict") + + # First line — mirrors list format + echo "" + printf " \033[1m%-14s\033[0m \033[2mrule:\033[0m %-16s \033[2mstrict:\033[0m %s\n" \ + "$name" "$rule_val" "$strict_padded" + echo "" + + # Detail section + local desc + desc=$(policy::get "$name" "desc") + [[ -n "$desc" ]] && printf " \033[2mDescription:\033[0m %s\n" "$desc" + printf " \033[2mTunnel:\033[0m %s\n" "$tunnel" + printf " \033[2mAuto apply:\033[0m %s\n" "$auto" + echo "" +} + +function cmd::policy::_add() { + local name="" tunnel_mode="split" default_rule="" \ + strict_rule="false" auto_apply="true" desc="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + --tunnel-mode) tunnel_mode="$2"; shift 2 ;; + --default-rule) default_rule="$2"; shift 2 ;; + --strict-rule) strict_rule="true"; shift ;; + --no-strict-rule) strict_rule="false"; shift ;; + --no-auto-apply) auto_apply="false"; shift ;; + --auto-apply) auto_apply="true"; shift ;; + --desc) desc="$2"; shift 2 ;; + --help) cmd::policy::help; return ;; + *) log::error "Unknown flag: $1"; return 1 ;; + esac + done + + [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } + + case "$tunnel_mode" in + split|full) ;; + *) log::error "Invalid --tunnel-mode '${tunnel_mode}'. Use: split, full"; return 1 ;; + esac + + json::policy_add "$(ctx::policies)" "$name" "$tunnel_mode" \ + "$default_rule" "$strict_rule" "$auto_apply" "$desc" + log::ok "Policy '${name}' added" +} + +function cmd::policy::_rm() { + local name="" + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + --help) cmd::policy::help; return ;; + *) log::error "Unknown flag: $1"; return 1 ;; + esac + done + + [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } + policy::require_exists "$name" || return 1 + + json::policy_remove "$(ctx::policies)" "$name" + log::ok "Policy '${name}' removed" +} + +function cmd::policy::_set() { + local name="" field="" value="" + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + --field) field="$2"; shift 2 ;; + --value) value="$2"; shift 2 ;; + --help) cmd::policy::help; return ;; + *) log::error "Unknown flag: $1"; return 1 ;; + esac + done + + [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } + [[ -z "$field" ]] && { log::error "Missing required flag: --field"; return 1; } + [[ -z "$value" ]] && { log::error "Missing required flag: --value"; return 1; } + + policy::require_exists "$name" || return 1 + + # Normalise field name (allow tunnel-mode as well as tunnel_mode) + field="${field//-/_}" + + case "$field" in + tunnel_mode) + case "$value" in + split|full) ;; + *) log::error "Invalid value '${value}' for tunnel_mode. Use: split, full"; return 1 ;; + esac + ;; + strict_rule|auto_apply) + case "$value" in + true|false) ;; + *) log::error "Invalid value '${value}' for ${field}. Use: true, false"; return 1 ;; + esac + ;; + default_rule|desc) ;; + *) + log::error "Unknown field '${field}'. Valid: tunnel_mode, default_rule, strict_rule, auto_apply, desc" + return 1 + ;; + esac + + json::policy_set_field "$(ctx::policies)" "$name" "$field" "$value" + log::ok "Policy '${name}': ${field} = ${value}" +} \ No newline at end of file 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/remove.command.sh b/commands/remove.command.sh index ff5a3e9..dac3af8 100644 --- a/commands/remove.command.sh +++ b/commands/remove.command.sh @@ -36,16 +36,14 @@ EOF # ============================================ function cmd::remove::run() { - local name="" - local type="" - local force=false + local name="" type="" force=false while [[ $# -gt 0 ]]; do case "$1" in - --name) name="$2"; shift 2 ;; - --type) type="$2"; shift 2 ;; - --force) force=true; shift ;; - --help) cmd::remove::help; return ;; + --name) name="$2"; shift 2 ;; + --type) type="$2"; shift 2 ;; + --force) force=true; shift ;; + --help) cmd::remove::help; return ;; *) log::error "Unknown flag: $1" cmd::remove::help @@ -62,7 +60,6 @@ function cmd::remove::run() { name=$(peers::resolve_and_require "$name" "$type") || return 1 - # Confirmation prompt unless --force if ! $force; then read -r -p "Are you sure you want to permanently remove '${name}'? [y/N] " confirm case "$confirm" in @@ -76,27 +73,20 @@ function cmd::remove::run() { log::section "Removing client: ${name}" - local client_ip + local client_ip was_blocked=false client_ip=$(peers::get_ip "$name") - - local was_blocked=false peers::is_blocked "$name" && was_blocked=true - cmd::remove::_cleanup "$name" "$client_ip" "$was_blocked" || return 1 + peers::purge "$name" "$client_ip" "$was_blocked" || return 1 + + # Detach from identity after successful removal + identity::auto_detach "$name" + log::wg_success "Client removed: ${name}" } +# _cleanup kept as a shim — callers should prefer peers::purge directly function cmd::remove::_cleanup() { local name="${1:-}" client_ip="${2:-}" was_blocked="${3:-false}" - - [[ -n "$client_ip" ]] && fw::flush_peer "$client_ip" - peers::remove_from_server "$name" || return 1 - peers::remove_client_config "$name" || return 1 - keys::remove "$name" || return 1 - group::remove_peer_from_all "$name" || return 1 - - [[ -n "$client_ip" ]] && $was_blocked && fw::unblock_all "$client_ip" - block::remove_file "$name" 2>/dev/null || true - peers::remove_meta "$name" 2>/dev/null || true - peers::reload || return 1 + peers::purge "$name" "$client_ip" "$was_blocked" } \ No newline at end of file diff --git a/commands/rename.command.sh b/commands/rename.command.sh index a76213d..1dc38a9 100644 --- a/commands/rename.command.sh +++ b/commands/rename.command.sh @@ -23,8 +23,8 @@ Rename an existing WireGuard client. The client IP and keys are preserved, only the name changes. Options: - --name Current client name (e.g. phone-phone-nuno) - --new-name New client name (e.g. phone-nuno) + --name Current client name (e.g. phone-nuno) + --new-name New client name (e.g. laptop-nuno) Examples: wgctl rename --name phone-phone-nuno --new-name phone-nuno @@ -37,10 +37,7 @@ EOF # ============================================ function cmd::rename::run() { - local name="" - local type="" - local new_name="" - local new_type="" + local name="" type="" new_name="" new_type="" while [[ $# -gt 0 ]]; do case "$1" in @@ -69,7 +66,7 @@ function cmd::rename::run() { return 1 fi - name=$(peers::resolve_and_require "$name" "$type") || return 1 + name=$(peers::resolve_and_require "$name" "$type") || return 1 new_name=$(peers::resolve_name "$new_name" "$new_type") || return 1 local dir @@ -88,10 +85,11 @@ function cmd::rename::run() { log::section "Renaming client: ${name} → ${new_name}" cmd::rename::_rename_files "$name" "$new_name" - - # Reload WireGuard peers::reload + # Update identity entry after successful rename + identity::rename_peer "$name" "$new_name" + log::wg_success "Client renamed: ${name} → ${new_name}" } @@ -107,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/rule.command.sh b/commands/rule.command.sh index c66c674..10ff3cf 100644 --- a/commands/rule.command.sh +++ b/commands/rule.command.sh @@ -358,13 +358,15 @@ function cmd::rule::show() { ui::row "Group" "${group:-—}" ui::row "DNS" "$dns_display" + printf "\n" # ── Extends + own rules ──────────────────────── if rule::render_extends_tree "$name"; then # Has inheritance — tree already rendered - printf "\n" + : else # No inheritance — flat view rule::render_flat "$name" + printf "\n" fi # ── Resolved ────────────────────────────────── @@ -381,7 +383,6 @@ function cmd::rule::show() { while IFS= read -r e; do [[ -n "$e" ]] && net::print_entry "-" "$e"; done \ <<< "$res_block_ips"$'\n'"$res_block_ports" printf "\n" - fi # ── Peers ───────────────────────────────────── @@ -389,7 +390,10 @@ function cmd::rule::show() { mapfile -t peer_list < <(peers::with_rule "$name") local peer_count=${#peer_list[@]} + + ui::empty "$peer_count" && return 0 + printf "\n" printf "\033[0;37m── Peers (%s) \033[0m%s\n\n" \ "$(color::gray "${peer_count}")" \ "$(printf '\033[0;37m─%.0s' {1..35})" diff --git a/commands/subnet.command.sh b/commands/subnet.command.sh new file mode 100644 index 0000000..04b8c6f --- /dev/null +++ b/commands/subnet.command.sh @@ -0,0 +1,294 @@ +#!/usr/bin/env bash +# subnet.command.sh — manage the subnet map (subnets.json) +# +# Subcommands: +# wgctl subnet list +# wgctl subnet show --name +# wgctl subnet add --name --subnet [--type ] +# [--tunnel-mode split|full] [--desc ] +# [--group ] +# wgctl subnet rm --name +# wgctl subnet rename --name --new-name + +# ============================================ +# Lifecycle +# ============================================ + +function cmd::subnet::on_load() { + flag::register --name + flag::register --subnet + flag::register --type + flag::register --tunnel-mode + flag::register --desc + flag::register --group + flag::register --new-name +} + +# ============================================ +# Help +# ============================================ + +function cmd::subnet::help() { + cat < [options] + +Manage the subnet map. + +Subcommands: + list List all configured subnets + show --name Show details for a subnet + add --name Add a new subnet entry + --subnet + [--type ] + [--tunnel-mode split|full] + [--desc ] + [--group ] + rm --name Remove a subnet (refused if in use) + rename --name Rename a subnet (refused if in use) + --new-name + +Examples: + wgctl subnet list + wgctl subnet show --name guests + wgctl subnet add --name iot-cctv --subnet 10.1.211.0/24 --type iot + wgctl subnet add --name desktop --subnet 10.1.101.0/24 --group guests + wgctl subnet rm --name iot-cctv + wgctl subnet rename --name iot-cctv --new-name cctv +EOF +} + +# ============================================ +# Run +# ============================================ + +function cmd::subnet::run() { + local subcmd="${1:-list}" + shift || true + + case "$subcmd" in + list) cmd::subnet::_list "$@" ;; + show) cmd::subnet::_show "$@" ;; + add) cmd::subnet::_add "$@" ;; + rm) cmd::subnet::_rm "$@" ;; + rename) cmd::subnet::_rename "$@" ;; + --help) cmd::subnet::help ;; + *) + log::error "Unknown subcommand '${subcmd}'. Available: list, show, add, rm, rename" + return 1 + ;; + esac +} + +# ============================================ +# Subcommands +# ============================================ + +function cmd::subnet::_list() { + local data + data=$(subnet::list_data) + + if [[ -z "$data" ]]; then + log::info "No subnets defined." + return 0 + fi + + echo "" + local prev_group="" + while IFS='|' read -r display_name subnet type_key tunnel_mode desc is_group group_parent; do + if [[ "$is_group" == "true" ]]; then + # Print group parent header when we encounter first child + if [[ "$group_parent" != "$prev_group" ]]; then + [[ -n "$prev_group" ]] && ui::subnet::group_separator + ui::subnet::row_group_parent "$group_parent" + prev_group="$group_parent" + fi + ui::subnet::row_group_child "$type_key" "$subnet" "$tunnel_mode" + else + # Scalar entry + [[ -n "$prev_group" ]] && ui::subnet::group_separator + prev_group="" + ui::subnet::row_scalar "$display_name" "$subnet" "$tunnel_mode" + fi + done <<< "$data" + echo "" +} + +function cmd::subnet::_maybe_group_separator() { + local is_group="${1:-}" group_parent="${2:-}" prev_group="${3:-}" + if [[ "$is_group" == "true" && "$group_parent" != "$prev_group" && -n "$prev_group" ]]; then + ui::subnet::group_separator + elif [[ "$is_group" == "false" && -n "$prev_group" ]]; then + ui::subnet::group_separator + fi +} + +function cmd::subnet::_update_prev_group() { + local is_group="${1:-}" group_parent="${2:-}" prev_group="${3:-}" + if [[ "$is_group" == "true" ]]; then + echo "$group_parent" + else + echo "" + fi +} + +function cmd::subnet::_show() { + local name="" + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + --help) cmd::subnet::help; return ;; + *) log::error "Unknown flag: $1"; return 1 ;; + esac + done + + [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } + subnet::require_exists "$name" || return 1 + + local data + data=$(subnet::show_data "$name") + + local is_group="false" + local show_name="" show_subnet="" show_tunnel="" show_desc="" + + while IFS='|' read -r key val rest; do + case "$key" in + name) show_name="$val" ;; + is_group) is_group="$val" ;; + subnet) show_subnet="$val" ;; + tunnel_mode) show_tunnel="$val" ;; + desc) show_desc="$val" ;; + esac + done <<< "$data" + + if [[ "$is_group" == "true" ]]; then + # Group display + ui::subnet::show_group "$show_name" + + while IFS='|' read -r key val rest; do + [[ "$key" != "child" ]] && continue + local c_type="$val" + local c_subnet c_tunnel c_desc + c_subnet=$(echo "$rest" | cut -d'|' -f1) + c_tunnel=$(echo "$rest" | cut -d'|' -f2) + c_desc=$(echo "$rest" | cut -d'|' -f3) + ui::subnet::show_child_row "$c_type" "$c_subnet" "$c_tunnel" "$c_desc" + done <<< "$data" + + local peers_using + peers_using=$(subnet::peers_using "$name") + ui::subnet::show_peers_annotated "$peers_using" "$(ctx::subnets)" + else + # Scalar display + ui::subnet::show_scalar "$show_name" "$show_subnet" "$show_tunnel" "$show_desc" + + local peers_using + peers_using=$(subnet::peers_using "$name") + ui::subnet::show_peers "$peers_using" + fi + + echo "" +} + +function cmd::subnet::_add() { + local name="" cidr="" type_key="" tunnel_mode="split" desc="" group_parent="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + --subnet) cidr="$2"; shift 2 ;; + --type) type_key="$2"; shift 2 ;; + --tunnel-mode) tunnel_mode="$2"; shift 2 ;; + --desc) desc="$2"; shift 2 ;; + --group) group_parent="$2"; shift 2 ;; + --help) cmd::subnet::help; return ;; + *) log::error "Unknown flag: $1"; return 1 ;; + esac + done + + [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } + [[ -z "$cidr" ]] && { log::error "Missing required flag: --subnet"; return 1; } + + cmd::subnet::_validate_tunnel_mode "$tunnel_mode" || return 1 + cmd::subnet::_validate_cidr "$cidr" || return 1 + + json::subnet_add "$(ctx::subnets)" "$name" "$cidr" \ + "${type_key:-$name}" "$tunnel_mode" "$desc" "$group_parent" + + log::ok "Subnet '${name}' added (${cidr})" +} + +function cmd::subnet::_rm() { + local name="" + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + --help) cmd::subnet::help; return ;; + *) log::error "Unknown flag: $1"; return 1 ;; + esac + done + + [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } + subnet::require_exists "$name" || return 1 + + local peers_using + peers_using=$(subnet::peers_using "$name") + + if [[ -n "$peers_using" ]]; then + log::error "Cannot remove subnet '${name}' — in use by: ${peers_using//,/, }" + log::error "Migrate or remove those peers first." + return 1 + fi + + json::subnet_remove "$(ctx::subnets)" "$name" "" + log::ok "Subnet '${name}' removed" +} + +function cmd::subnet::_rename() { + local name="" new_name="" + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + --new-name) new_name="$2"; shift 2 ;; + --help) cmd::subnet::help; return ;; + *) log::error "Unknown flag: $1"; return 1 ;; + esac + done + + [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } + [[ -z "$new_name" ]] && { log::error "Missing required flag: --new-name"; return 1; } + subnet::require_exists "$name" || return 1 + + local peers_using + peers_using=$(subnet::peers_using "$name") + if [[ -n "$peers_using" ]]; then + log::error "Cannot rename subnet '${name}' — configs already distributed to: ${peers_using//,/, }" + log::error "Client configs reference the subnet CIDR which cannot change after distribution." + return 1 + fi + + json::subnet_rename "$(ctx::subnets)" "$name" "$new_name" "" + log::ok "Subnet '${name}' renamed to '${new_name}'" +} + +# ============================================ +# Validation Helpers +# ============================================ + +function cmd::subnet::_validate_tunnel_mode() { + local mode="${1:-}" + case "$mode" in + split|full) return 0 ;; + *) + log::error "Invalid --tunnel-mode '${mode}'. Use: split, full" + return 1 + ;; + esac +} + +function cmd::subnet::_validate_cidr() { + local cidr="${1:-}" + if ! echo "$cidr" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$'; then + log::error "Invalid CIDR format: '${cidr}'" + return 1 + fi +} \ No newline at end of file diff --git a/commands/test.command.sh b/commands/test.command.sh index 866421c..a961170 100644 --- a/commands/test.command.sh +++ b/commands/test.command.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash - -WGCTL_BINARY="$(command -v wgctl)" +# test.command.sh — wgctl test suite dispatcher +# Delegates to commands/test/{integration,unit,destructive,fn}.sh # ============================================ # Lifecycle @@ -11,8 +11,15 @@ function cmd::test::on_load() { flag::register --section flag::register --fn flag::register --function + flag::register --unit + flag::register --integration + flag::register --verbose } +# ============================================ +# Help +# ============================================ + function cmd::test::help() { cat < Run only a specific section (list, rules, groups, audit, logs, fw) + --unit Run unit tests (pure function tests, no side effects) + --integration Run integration tests against the live binary (default) + --destructive Include destructive tests (add/remove/block state changes) + --section Run only a specific section + --fn Run a specific function test block + --verbose Show command output on failure + +Integration sections: + list, inspect, config, rules, groups, audit, logs, fw, net, subnet, identity + +Unit sections: + subnet, identity, ip Examples: wgctl test - wgctl test --section rules + wgctl test --unit + wgctl test --unit --section subnet + wgctl test --integration --section rules wgctl test --destructive + wgctl test --fn cmd::block::run EOF } # ============================================ -# Test helpers +# Loader # ============================================ -function cmd::test::run_cmd() { - local desc="$1" - local expected="${2:-}" - shift 2 - - local tmp exit_code - tmp=$(mktemp) - - set +e # disable exit on error (return 1) - - timeout 30 "$WGCTL_BINARY" "$@" > "$tmp" 2>&1 & - local pid=$! - wait $pid - exit_code=$? - - set -e # re-enable exit on error - - if [[ $exit_code -eq 124 ]]; then - test::warn "${desc} (timed out after 30s)" - rm -f "$tmp" +function cmd::test::_load() { + local name="${1:-}" + local path + path="$(ctx::commands)/test/${name}.sh" + if [[ ! -f "$path" ]]; then + log::error "Test file not found: ${path}" return 1 fi - - if [[ $exit_code -ne 0 ]]; then - test::fail "${desc}" - if [[ "${WGCTL_TEST_VERBOSE:-false}" == "true" ]]; then - printf " Output: %s\n" "$(cat "$tmp")" - fi - rm -f "$tmp" - return 1 - fi - - if [[ -n "$expected" ]] && ! grep -qF "$expected" "$tmp"; then - local actual - actual=$(head -3 "$tmp" | 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_fails() { - local desc="$1" - shift - - set +e # disable exit on error (return 1) - - local tmp exit_code - tmp=$(mktemp) - timeout 10 setsid "$WGCTL_BINARY" "$@" > "$tmp" 2>&1 - exit_code=$? - - set -e # re-enable exit on error - - rm -f "$tmp" - - if [[ $exit_code -eq 124 ]]; then - test::warn "${desc} (timed out)" - return 1 - fi - - if [[ $exit_code -eq 0 ]]; then - test::fail "${desc} (expected failure but succeeded)" - return 1 - fi - - test::pass "$desc" -} - -function cmd::test::run_function() { - local fn="$1" - - local namespace - namespace=$(echo "$fn" | cut -d':' -f3) - load_command "$namespace" 2>/dev/null || true - - test::reset - log::section "Function Test: ${fn}" - - case "$fn" in - cmd::block::run) cmd::test::fn_block ;; - cmd::unblock::run) cmd::test::fn_unblock ;; - cmd::remove::run) cmd::test::fn_remove ;; - cmd::rule::assign) cmd::test::fn_rule_assign ;; - cmd::rename::run) cmd::test::fn_rename ;; - cmd::remove::run) cmd::test::fn_remove ;; - cmd::unblock::run) cmd::test::fn_unblock ;; - *) - log::error "No function test defined for: ${fn}" - return 1 - ;; - esac - - test::summary + source "$path" } # ============================================ -# Test 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 --type guest" "" list --type guest - cmd::test::run_cmd "list --detailed" "Client:" list --detailed - cmd::test::run_cmd "list --name phone-nuno" "phone-nuno" list --name phone-nuno -} - -function cmd::test::section_inspect() { - test::section "Inspect" - cmd::test::run_cmd "inspect --name phone-nuno" "IP:" inspect --name phone-nuno - cmd::test::run_cmd "inspect --name nuno --type phone" "IP:" inspect --name nuno --type phone - cmd::test::run_cmd "inspect --name phone-nuno --config" "PrivateKey" inspect --name phone-nuno --config - cmd::test::run_cmd_fails "inspect nonexistent peer" inspect --name nonexistent-peer -} - -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 -} - -function cmd::test::section_rules() { - test::section "Rules" - cmd::test::run_cmd "rule list" "guest" 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 - cmd::test::run_cmd_fails "rule show nonexistent" rule show --name nonexistent -} - -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 -} - -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 -} - -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 --type guest" "Activity" logs --type guest - 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 -} -function cmd::test::section_net() { - test::section "Net" - - "$WGCTL_BINARY" net rm --name test-svc --force > /dev/null 2>&1 || true - - cmd::test::run_cmd "net add service" "added" \ - net add --name test-svc --ip 10.0.0.99 --desc "Test service" - cmd::test::run_cmd "net add port" "Added" \ - net add --name test-svc:web --port 9999:tcp - cmd::test::run_cmd "net list" "test-svc" \ - net list - cmd::test::run_cmd "net list --detailed" "web" \ - net list --detailed - cmd::test::run_cmd "net show" "9999" \ - net show --name test-svc - cmd::test::run_cmd "net rm port" "Removed" \ - net rm --name test-svc:web --force - 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 -} - -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" 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 - - # ── Add test peer ────────────────────────── - cmd::test::run_cmd "add phone peer" "added successfully" \ - add --name testunit --type phone - - # ── Direct 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 - - # ── Specific IP block/unblock ────────────── - 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" \ - list --name phone-testunit - cmd::test::run_cmd "unblock peer --ip" "unblocked" \ - unblock --name phone-testunit --ip 10.0.0.99 - - # ── Service block/unblock ────────────────── - "$WGCTL_BINARY" net add --name test-block-svc \ - --ip 10.0.0.99 > /dev/null 2>&1 - "$WGCTL_BINARY" net add --name test-block-svc:web \ - --port 9999:tcp > /dev/null 2>&1 - cmd::test::run_cmd "block peer --service (ip)" "blocked" \ - block --name phone-testunit --service test-block-svc - cmd::test::run_cmd "block already blocked service" "already" \ - block --name phone-testunit --service test-block-svc - cmd::test::run_cmd "unblock peer --service (ip)" "unblocked" \ - unblock --name phone-testunit --service test-block-svc - cmd::test::run_cmd "unblock not blocked service" "not blocked" \ - unblock --name phone-testunit --service test-block-svc - cmd::test::run_cmd "block peer --service (port)" "blocked" \ - block --name phone-testunit --service test-block-svc:web - cmd::test::run_cmd "unblock peer --service (port)" "unblocked" \ - unblock --name phone-testunit --service test-block-svc:web - "$WGCTL_BINARY" net rm --name test-block-svc --force > /dev/null 2>&1 || true - - # ── Rule assign/unassign ─────────────────── - cmd::test::run_cmd "rule assign" "Assigned" \ - rule assign --name user --peer phone-testunit - cmd::test::run_cmd "rule unassign" "Unassigned" \ - rule unassign --peer phone-testunit - "$WGCTL_BINARY" rule assign --name user --peer phone-testunit \ - > /dev/null 2>&1 || true - - # ── Group basic operations ───────────────── - 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 - - cmd::test::run_cmd "group block first group" "blocked" \ - group block --name testgroup - cmd::test::run_cmd "group block second group" "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 - - "$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 - - # ── Cleanup ──────────────────────────────── - cmd::test::run_cmd "group remove" "removed" \ - group remove --name testgroup --force - "$WGCTL_BINARY" group remove --name testgroup2 --force > /dev/null 2>&1 || true - - cmd::test::run_cmd "remove phone peer" "removed" \ - remove --name phone-testunit --force -} - -# ============================================ -# Function Blocks -# ============================================ - -function cmd::test::fn_block() { - test::section "cmd::block::run" - - # Setup - "$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true - "$WGCTL_BINARY" add --name testunit --type phone > /dev/null 2>&1 - - # Tests - cmd::test::run_cmd "block peer" "blocked" block --name phone-testunit - cmd::test::run_cmd "block already blocked" "already" block --name phone-testunit - - "$WGCTL_BINARY" unblock --name phone-testunit > /dev/null 2>&1 || true - cmd::test::run_cmd "block with --type" "blocked" block --name testunit --type phone - - cmd::test::run_cmd_fails "block nonexistent" block --name truly-nonexistent-xyz - - # Cleanup - "$WGCTL_BINARY" unblock --name phone-testunit > /dev/null 2>&1 || true - "$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true -} - -function cmd::test::fn_remove() { - test::section "cmd::remove::run" - - # Setup - "$WGCTL_BINARY" remove --name phone-testunit --force \ - > /dev/null 2>&1 || true - "$WGCTL_BINARY" add --name testunit --type phone \ - > /dev/null 2>&1 - - # Tests - # Skip the interactive prompt test — it hangs waiting for input - # cmd::test::run_cmd_fails "remove without --force" \ - # remove --name phone-testunit - cmd::test::run_cmd "remove with --force" "removed" \ - remove --name phone-testunit --force - cmd::test::run_cmd_fails "remove nonexistent" \ - remove --name nonexistent-peer --force - cmd::test::run_cmd_fails "remove missing --name" \ - remove --force - - # Cleanup already done by tests -} - -function cmd::test::fn_rename() { - test::section "cmd::rename::run" - - # Setup - "$WGCTL_BINARY" remove --name phone-testunit --force \ - > /dev/null 2>&1 || true - "$WGCTL_BINARY" remove --name phone-testunit2 --force \ - > /dev/null 2>&1 || true - "$WGCTL_BINARY" add --name testunit --type phone \ - > /dev/null 2>&1 - - # Tests - cmd::test::run_cmd "rename peer" "renamed" \ - rename --name phone-testunit --new-name phone-testunit2 - cmd::test::run_cmd_fails "rename to existing" \ - rename --name phone-testunit2 --new-name phone-nuno - cmd::test::run_cmd_fails "rename nonexistent" \ - rename --name phone-nonexistent --new-name phone-testunit - cmd::test::run_cmd_fails "rename missing --new-name" \ - rename --name phone-testunit2 - - # Cleanup - "$WGCTL_BINARY" remove --name phone-testunit2 --force \ - > /dev/null 2>&1 || true -} - -function cmd::test::fn_unblock() { - test::section "cmd::unblock::run" - - # Setup — add and block a peer - "$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true - "$WGCTL_BINARY" add --name testunit --type phone > /dev/null 2>&1 - "$WGCTL_BINARY" block --name phone-testunit > /dev/null 2>&1 - - # Tests - cmd::test::run_cmd "unblock peer" "unblocked" unblock --name phone-testunit - cmd::test::run_cmd "unblock not blocked" "not blocked" unblock --name phone-testunit - cmd::test::run_cmd_fails "unblock nonexistent" unblock --name nonexistent-peer - cmd::test::run_cmd_fails "unblock missing --name" unblock - - # Cleanup - "$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true -} - -function cmd::test::fn_rule_assign() { - test::section "cmd::rule::assign" - - "$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true - "$WGCTL_BINARY" add --name testunit --type phone > /dev/null 2>&1 - - cmd::test::run_cmd "rule assign" "Assigned" \ - rule assign --name admin --peer phone-testunit - - "$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true -} - -# ============================================ -# Run +# Run (entrypoint) # ============================================ function cmd::test::run() { - local destructive=false section="" - local fn="" + local unit=false integration=false destructive=false + local section="" fn="" while [[ $# -gt 0 ]]; do case "$1" in - --destructive) destructive=true; shift ;; - --section) - util::require_flag "--section" "${2:-}" || return 1 - section="$2"; shift 2 - ;; - --fn|--function) fn="$2"; shift 2 ;; - --verbose|-v) WGCTL_TEST_VERBOSE=true; shift ;; - --help) cmd::test::help; return ;; + --unit) unit=true; shift ;; + --integration) integration=true; shift ;; + --destructive) destructive=true; shift ;; + --section) section="$2"; shift 2 ;; + --fn|--function) fn="$2"; shift 2 ;; + --verbose|-v) WGCTL_TEST_VERBOSE=true; shift ;; + --help) cmd::test::help; return ;; *) log::error "Unknown flag: $1" return 1 @@ -461,46 +89,77 @@ function cmd::test::run() { esac done - # After flag parsing: + # Function test — load fn.sh + integration.sh (needs run_cmd helpers) if [[ -n "$fn" ]]; then - cmd::test::run_function "$fn" + cmd::test::_load integration || return 1 + cmd::test::_load fn || return 1 + test::reset + log::section "Function Test: ${fn}" + cmd::test::_dispatch_fn "$fn" + test::summary return fi + # Unit tests + if $unit; then + cmd::test::_load unit || return 1 + test::reset + log::section "wgctl Unit Tests" + cmd::test::_dispatch_unit "$section" + test::summary + return + fi + + # Integration tests (default, also when --integration is explicit) + cmd::test::_load integration || return 1 + cmd::test::_load destructive || return 1 test::reset log::section "wgctl Test Suite" - - if [[ -n "$section" ]]; then - case "$section" in - list) cmd::test::section_list ;; - inspect) cmd::test::section_inspect ;; - config) cmd::test::section_config ;; - rules) cmd::test::section_rules ;; - groups) cmd::test::section_groups ;; - audit) cmd::test::section_audit ;; - logs) cmd::test::section_logs ;; - fw) cmd::test::section_fw ;; - net) cmd::test::section_net ;; - destructive) cmd::test::section_destructive ;; - *) - log::error "Unknown section: $section" - return 1 - ;; - esac - else - cmd::test::section_list - cmd::test::section_inspect - cmd::test::section_config - cmd::test::section_rules - cmd::test::section_groups - cmd::test::section_audit - cmd::test::section_logs - cmd::test::section_fw - fi - - if $destructive; then - cmd::test::section_destructive - fi - + cmd::test::_dispatch_integration "$section" + $destructive && cmd::test::section_destructive test::summary +} + +# ============================================ +# Dispatch Helpers +# ============================================ + +function cmd::test::_dispatch_unit() { + local section="${1:-}" + if [[ -n "$section" ]]; then + local fn="cmd::test::unit_${section}" + if ! declare -f "$fn" > /dev/null 2>&1; then + log::error "No unit section: ${section}" + return 1 + fi + "$fn" + else + cmd::test::run_all_unit_sections + fi +} + +function cmd::test::_dispatch_integration() { + local section="${1:-}" + if [[ -n "$section" ]]; then + if [[ "$section" == "destructive" ]]; then + cmd::test::section_destructive + return + fi + local fn="cmd::test::section_${section}" + if ! declare -f "$fn" > /dev/null 2>&1; then + log::error "No integration section: ${section}" + return 1 + fi + "$fn" + else + cmd::test::run_all_integration_sections + fi +} + +function cmd::test::_dispatch_fn() { + local fn="${1:-}" + local namespace + namespace=$(echo "$fn" | cut -d':' -f3) + load_command "$namespace" 2>/dev/null || true + cmd::test::run_function "$fn" } \ No newline at end of file diff --git a/commands/test/destructive.sh b/commands/test/destructive.sh new file mode 100644 index 0000000..df05fb7 --- /dev/null +++ b/commands/test/destructive.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# test/destructive.sh — tests that modify system state +# Sourced by test.command.sh — do not execute directly. +# Requires run_cmd / run_cmd_fails from integration.sh to be sourced first. + +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 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 + + cmd::test::_destructive_peer + cmd::test::_destructive_block_unblock + cmd::test::_destructive_service_block + cmd::test::_destructive_rule + cmd::test::_destructive_groups + cmd::test::_destructive_identity + cmd::test::_destructive_cleanup +} + +function cmd::test::_destructive_peer() { + cmd::test::run_cmd "add phone peer" "added" \ + add --name testunit --type phone +} + +function cmd::test::_destructive_block_unblock() { + 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" \ + block --name phone-testunit --ip 10.0.0.99 + cmd::test::run_cmd "list shows restricted" "restricted" \ + list --name phone-testunit + cmd::test::run_cmd "unblock peer --ip" "unblocked" \ + unblock --name phone-testunit --ip 10.0.0.99 +} + +function cmd::test::_destructive_service_block() { + "$WGCTL_BINARY" net add --name test-block-svc \ + --ip 10.0.0.99 > /dev/null 2>&1 + "$WGCTL_BINARY" net add --name test-block-svc:web \ + --port 9999:tcp > /dev/null 2>&1 + + cmd::test::run_cmd "block peer --service (ip)" "blocked" block --name phone-testunit --service test-block-svc + cmd::test::run_cmd "block already blocked service" "already" block --name phone-testunit --service test-block-svc + cmd::test::run_cmd "unblock peer --service (ip)" "unblocked" unblock --name phone-testunit --service test-block-svc + cmd::test::run_cmd "unblock not blocked service" "not blocked" unblock --name phone-testunit --service test-block-svc + cmd::test::run_cmd "block peer --service (port)" "blocked" block --name phone-testunit --service test-block-svc:web + cmd::test::run_cmd "unblock peer --service (port)" "unblocked" unblock --name phone-testunit --service test-block-svc:web + + "$WGCTL_BINARY" net rm --name test-block-svc --force > /dev/null 2>&1 || true +} + +function cmd::test::_destructive_rule() { + cmd::test::run_cmd "rule assign" "Assigned" rule assign --name user --peer phone-testunit + cmd::test::run_cmd "rule unassign" "Unassigned" rule unassign --peer phone-testunit + "$WGCTL_BINARY" rule assign --name user --peer phone-testunit > /dev/null 2>&1 || true +} + +function cmd::test::_destructive_groups() { + 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 + + "$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 + + cmd::test::run_cmd "group block first" "blocked" group block --name testgroup + 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" "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 + + "$WGCTL_BINARY" group block --name testgroup > /dev/null 2>&1 + cmd::test::run_cmd "direct unblock overrides group block" "unblocked" unblock --name phone-testunit + + cmd::test::run_cmd "group remove" "removed" group remove --name testgroup --force + "$WGCTL_BINARY" group remove --name testgroup2 --force > /dev/null 2>&1 || true +} + +function cmd::test::_destructive_identity() { + test::section "Destructive: identity auto-attach/detach" + + # 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 created for testunit2" "laptop-testunit2" \ + identity show --name testunit2 + + # 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 (new identity)" "laptop-testunit2b" \ + identity show --name testunit2b + + 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() { + cmd::test::run_cmd "remove phone peer" "removed" \ + remove --name phone-testunit --force +} \ No newline at end of file diff --git a/commands/test/fn.sh b/commands/test/fn.sh new file mode 100644 index 0000000..1c0de42 --- /dev/null +++ b/commands/test/fn.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# test/fn.sh — individual function test blocks +# Sourced by test.command.sh — do not execute directly. +# Requires run_cmd / run_cmd_fails from integration.sh to be sourced first. + +function cmd::test::run_function() { + local fn="$1" + case "$fn" in + cmd::block::run) cmd::test::fn_block ;; + cmd::unblock::run) cmd::test::fn_unblock ;; + cmd::remove::run) cmd::test::fn_remove ;; + cmd::rename::run) cmd::test::fn_rename ;; + cmd::rule::assign) cmd::test::fn_rule_assign ;; + *) + log::error "No function test defined for: ${fn}" + return 1 + ;; + esac +} + +function cmd::test::fn_block() { + test::section "cmd::block::run" + + "$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true + "$WGCTL_BINARY" add --name testunit --type phone > /dev/null 2>&1 + + cmd::test::run_cmd "block peer" "blocked" block --name phone-testunit + cmd::test::run_cmd "block already blocked" "already" block --name phone-testunit + "$WGCTL_BINARY" unblock --name phone-testunit > /dev/null 2>&1 || true + cmd::test::run_cmd "block with --type" "blocked" block --name testunit --type phone + cmd::test::run_cmd_fails "block nonexistent" block --name truly-nonexistent-xyz + + "$WGCTL_BINARY" unblock --name phone-testunit > /dev/null 2>&1 || true + "$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true +} + +function cmd::test::fn_unblock() { + test::section "cmd::unblock::run" + + "$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true + "$WGCTL_BINARY" add --name testunit --type phone > /dev/null 2>&1 + "$WGCTL_BINARY" block --name phone-testunit > /dev/null 2>&1 + + cmd::test::run_cmd "unblock peer" "unblocked" unblock --name phone-testunit + cmd::test::run_cmd "unblock not blocked" "not blocked" unblock --name phone-testunit + cmd::test::run_cmd_fails "unblock nonexistent" unblock --name nonexistent-peer + cmd::test::run_cmd_fails "unblock missing --name" unblock + + "$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true +} + +function cmd::test::fn_remove() { + test::section "cmd::remove::run" + + "$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true + "$WGCTL_BINARY" add --name testunit --type phone > /dev/null 2>&1 + + cmd::test::run_cmd "remove with --force" "removed" remove --name phone-testunit --force + cmd::test::run_cmd_fails "remove nonexistent" remove --name nonexistent-peer --force + cmd::test::run_cmd_fails "remove missing --name" remove --force +} + +function cmd::test::fn_rename() { + test::section "cmd::rename::run" + + "$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true + "$WGCTL_BINARY" remove --name phone-testunit2 --force > /dev/null 2>&1 || true + "$WGCTL_BINARY" add --name testunit --type phone > /dev/null 2>&1 + + cmd::test::run_cmd "rename peer" "renamed" rename --name phone-testunit --new-name phone-testunit2 + cmd::test::run_cmd_fails "rename to existing" rename --name phone-testunit2 --new-name phone-nuno + cmd::test::run_cmd_fails "rename nonexistent" rename --name phone-nonexistent --new-name phone-testunit + cmd::test::run_cmd_fails "rename missing --new-name" rename --name phone-testunit2 + + "$WGCTL_BINARY" remove --name phone-testunit2 --force > /dev/null 2>&1 || true +} + +function cmd::test::fn_rule_assign() { + test::section "cmd::rule::assign" + + "$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true + "$WGCTL_BINARY" add --name testunit --type phone > /dev/null 2>&1 + + cmd::test::run_cmd "rule assign" "Assigned" \ + rule assign --name admin --peer phone-testunit + + "$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true +} \ No newline at end of file diff --git a/commands/test/integration.sh b/commands/test/integration.sh new file mode 100644 index 0000000..b861127 --- /dev/null +++ b/commands/test/integration.sh @@ -0,0 +1,252 @@ +#!/usr/bin/env bash +# test/integration.sh — integration test sections +# Tests run against the live wgctl binary. +# Sourced by test.command.sh — do not execute directly. + +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 + + local tmp exit_code + tmp=$(mktemp) + + set +e + 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 + 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" "$(echo "$clean" | head -3 | tr '\n' ' ')" + fi + rm -f "$tmp" + return 1 + fi + + 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 + 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 + fi + + test::pass "$desc" + rm -f "$tmp" +} + +function cmd::test::run_cmd_fails() { + local desc="$1" + shift + + local tmp exit_code + tmp=$(mktemp) + + 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 + test::warn "${desc} (timed out)" + return 1 + fi + + if [[ $exit_code -eq 0 ]]; then + test::fail "${desc} (expected failure but succeeded)" + return 1 + fi + + test::pass "$desc" +} + +# ============================================ +# Sections +# ============================================ + +function cmd::test::run_all_integration_sections() { + cmd::test::section_list + cmd::test::section_inspect + cmd::test::section_config + cmd::test::section_rules + cmd::test::section_groups + cmd::test::section_audit + cmd::test::section_logs + cmd::test::section_fw + cmd::test::section_net + cmd::test::section_subnet + cmd::test::section_identity +} + +function cmd::test::section_list() { + test::section "List" + cmd::test::run_cmd "list" "rule:" 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" "rule:" list --detailed + cmd::test::run_cmd "list --name phone-nuno" "phone-nuno" list --name phone-nuno +} + +function cmd::test::section_inspect() { + test::section "Inspect" + cmd::test::run_cmd "inspect --name phone-nuno" "IP:" inspect --name phone-nuno + cmd::test::run_cmd "inspect --name nuno --type phone" "IP:" inspect --name nuno --type phone + cmd::test::run_cmd "inspect --name phone-nuno --config" "PrivateKey" inspect --name phone-nuno --config + cmd::test::run_cmd_fails "inspect nonexistent" inspect --name nonexistent-peer +} + +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 +} + +function cmd::test::section_rules() { + test::section "Rules" + 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 + cmd::test::run_cmd_fails "rule show nonexistent" rule show --name nonexistent +} + +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 +} + +function cmd::test::section_audit() { + test::section "Audit" + 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 +} + +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 +} + +function cmd::test::section_net() { + test::section "Net" + "$WGCTL_BINARY" net rm --name test-svc --force > /dev/null 2>&1 || true + + cmd::test::run_cmd "net add service" "added" net add --name test-svc --ip 10.0.0.99 --desc "Test service" + cmd::test::run_cmd "net add port" "Added" net add --name test-svc:web --port 9999:tcp + cmd::test::run_cmd "net list" "test-svc" net list + cmd::test::run_cmd "net list --detailed" "web" net list --detailed + cmd::test::run_cmd "net show" "9999" net show --name test-svc + cmd::test::run_cmd "net rm port" "Removed" net rm --name test-svc:web --force + 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 +} + +function cmd::test::section_subnet() { + test::section "Subnet" + "$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" "tunnel:" subnet show --name desktop + cmd::test::run_cmd "subnet show guests group" "guests" 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" \ + subnet rename --name test-subnet --new-name test-subnet-2 + cmd::test::run_cmd "subnet rm" "removed" \ + subnet rm --name test-subnet-2 + cmd::test::run_cmd_fails "subnet rm nonexistent" \ + subnet rm --name nonexistent-subnet +} + +function cmd::test::section_identity() { + test::section "Identity" + 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 +} \ No newline at end of file diff --git a/commands/test/unit.sh b/commands/test/unit.sh new file mode 100644 index 0000000..61245b2 --- /dev/null +++ b/commands/test/unit.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# test/unit.sh — unit test sections +# Tests pure functions directly — no binary, no state changes. +# Sourced by test.command.sh — do not execute directly. + +# ============================================ +# Helpers +# ============================================ + +function cmd::test::assert() { + local desc="${1:-}" result="${2:-}" expected="${3:-}" + if [[ "$result" == "$expected" ]]; then + test::pass "$desc" + else + test::fail "${desc} (expected '${expected}', got '${result}')" + fi +} + +function cmd::test::assert_true() { + local desc="${1:-}" + shift + if "$@" 2>/dev/null; then + test::pass "$desc" + else + test::fail "$desc (expected true, got false)" + fi +} + +function cmd::test::assert_false() { + local desc="${1:-}" + shift + if ! "$@" 2>/dev/null; then + test::pass "$desc" + else + test::fail "$desc (expected false, got true)" + fi +} + +# ============================================ +# Sections +# ============================================ + +function cmd::test::run_all_unit_sections() { + cmd::test::unit_subnet + cmd::test::unit_ip + cmd::test::unit_identity +} + +function cmd::test::unit_subnet() { + test::section "Unit: subnet CIDR utilities" + 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" + + # 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_false "subnet::contains wrong net" subnet::contains "10.1.3.0/24" "192.168.1.1" + + # subnet::is_valid_cidr + cmd::test::assert_true "is_valid_cidr valid" subnet::is_valid_cidr "10.1.3.0/24" + cmd::test::assert_true "is_valid_cidr /16" subnet::is_valid_cidr "10.1.0.0/16" + cmd::test::assert_false "is_valid_cidr no mask" subnet::is_valid_cidr "10.1.3.0" + cmd::test::assert_false "is_valid_cidr bad octet" subnet::is_valid_cidr "999.1.3.0/24" + cmd::test::assert_false "is_valid_cidr empty" subnet::is_valid_cidr "" + + # subnet::ip_valid_for + cmd::test::assert_true "ip_valid_for valid host" subnet::ip_valid_for "10.1.3.0/24" "10.1.3.5" + cmd::test::assert_false "ip_valid_for network addr" subnet::ip_valid_for "10.1.3.0/24" "10.1.3.0" + cmd::test::assert_false "ip_valid_for broadcast" subnet::ip_valid_for "10.1.3.0/24" "10.1.3.255" + cmd::test::assert_false "ip_valid_for wrong subnet" subnet::ip_valid_for "10.1.3.0/24" "10.1.4.1" + cmd::test::assert_false "ip_valid_for invalid ip" subnet::ip_valid_for "10.1.3.0/24" "not-an-ip" +} + +function cmd::test::unit_ip() { + test::section "Unit: ip validation" + load_module ip + + cmd::test::assert_true "ip::is_valid plain" ip::is_valid "10.1.3.5" + cmd::test::assert_true "ip::is_valid cidr" ip::is_valid "10.1.3.0/24" + cmd::test::assert_false "ip::is_valid empty" ip::is_valid "" + cmd::test::assert_false "ip::is_valid hostname" ip::is_valid "phone-nuno" + cmd::test::assert_false "ip::is_valid bad oct" ip::is_valid "999.1.3.5" + + cmd::test::assert_true "ip::is_cidr with mask" ip::is_cidr "10.1.3.0/24" + cmd::test::assert_false "ip::is_cidr without mask" ip::is_cidr "10.1.3.5" +} + +function cmd::test::unit_identity() { + test::section "Unit: identity inference" + load_module identity + + 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" + cmd::test::assert "infer laptop-nuno" "$(identity::infer 'laptop-nuno')" "nuno|laptop|1" + cmd::test::assert "infer no convention" "$(identity::infer 'roboclean')" "" + cmd::test::assert "infer guest-zephyr" "$(identity::infer 'guest-zephyr')" "" +} \ No newline at end of file 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.sh b/core.sh index 2a4b78f..0a1d78a 100644 --- a/core.sh +++ b/core.sh @@ -6,6 +6,7 @@ WGCTL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${WGCTL_DIR}/core/log.sh" source "${WGCTL_DIR}/core/context.sh" source "${WGCTL_DIR}/core/utils.sh" source "${WGCTL_DIR}/core/module.sh" diff --git a/core/color.sh b/core/color.sh index 6e0e7f1..32893ee 100644 --- a/core/color.sh +++ b/core/color.sh @@ -13,3 +13,4 @@ function color::gray() { printf "\033[0;37m%s\033[0m" "${1:-}"; } function color::bold() { printf "\033[1;37m%s\033[0m" "${1:-}"; } function color::cyan() { printf "\033[0;36m%s\033[0m" "${1:-}"; } function color::yellow() { printf "\033[1;33m%s\033[0m" "${1:-}"; } +function color::dim() { printf "\033[2m%s\033[0m" "$*"; } \ No newline at end of file diff --git a/core/context.sh b/core/context.sh index a05379e..911ff5c 100644 --- a/core/context.sh +++ b/core/context.sh @@ -21,6 +21,7 @@ _CTX_RULES_BASE="${_CTX_RULES}/base" _CTX_GROUPS="${_CTX_DATA}/groups" _CTX_BLOCKS="${_CTX_DATA}/blocks" _CTX_META="${_CTX_DATA}/meta" +_CTX_IDENTITY="${_CTX_DATA}/identities" _CTX_DAEMON="${_CTX_DATA}/daemon" _CTX_NET="${_CTX_DATA}/services.json" @@ -43,6 +44,8 @@ function ctx::blocks() { echo "$_CTX_BLOCKS"; } function ctx::meta() { echo "$_CTX_META"; } function ctx::daemon() { echo "$_CTX_DAEMON"; } function ctx::net() { echo "$_CTX_NET"; } +function ctx::identities() { echo "${_CTX_IDENTITY}"; } +function ctx::subnets() { echo "${_CTX_DATA}/subnets.json"; } function ctx::events_log() { echo "$(ctx::daemon)/events.log"; } function ctx::fw_events_log() { echo "$(ctx::daemon)/fw_events.log"; } @@ -60,6 +63,11 @@ function ctx::meta::path() { echo "$_CTX_META/$*" } +function ctx::identity::path() { + local IFS="/" + echo "$_CTX_IDENTITY/$*" +} + function ctx::block::path() { local IFS="/" echo "$_CTX_BLOCKS/$*" diff --git a/core/fmt.sh b/core/fmt.sh index 64a37bc..56a2e4a 100644 --- a/core/fmt.sh +++ b/core/fmt.sh @@ -34,6 +34,28 @@ function fmt::datetime_iso() { python3 "$FMT_HELPER" fmt_datetime "$iso" "$FMT_DATETIME" +# Returns a compact datetime — just time if today, short date+time if older. +# Respects configured date format. Returns "—" for empty/zero timestamps. +function fmt::datetime_short() { + local ts="${1:-}" + [[ -z "$ts" || "$ts" == "0" ]] && echo "—" && return 0 + + local today ts_day + today=$(date +%Y-%m-%d) + ts_day=$(date -d "@${ts}" +%Y-%m-%d 2>/dev/null) || { echo "—"; return 0; } + + if [[ "$ts_day" == "$today" ]]; then + date -d "@${ts}" +"%H:%M" 2>/dev/null || echo "—" + else + case "$_FMT_DATE_FORMAT" in + iso) date -d "@${ts}" +"%m-%d %H:%M" 2>/dev/null || echo "—" ;; + eu*) date -d "@${ts}" +"%d/%m %H:%M" 2>/dev/null || echo "—" ;; + *) date -d "@${ts}" +"%m-%d %H:%M" 2>/dev/null || echo "—" ;; + esac + fi +} + function fmt::set_date_format() { local format="$1" case "$format" in diff --git a/core/json.sh b/core/json.sh index 9f0dc78..3dc474c 100644 --- a/core/json.sh +++ b/core/json.sh @@ -60,6 +60,51 @@ 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: @@ -1486,6 +1444,938 @@ def group_has_peer(file, peer_name): except Exception: print('false') +# ============================================ +# Subnet Map +# ============================================ + +def _subnet_read(file): + """Read subnets.json, return dict or empty dict""" + try: + if not os.path.exists(file): + return {} + with open(file) as f: + content = f.read().strip() + if not content: + return {} + return json.loads(content) + except Exception: + return {} + +def _subnet_write(file, data): + """Write subnets.json""" + os.makedirs(os.path.dirname(file), exist_ok=True) + with open(file, 'w') as f: + json.dump(data, f, indent=2) + +def _subnet_is_group(entry): + """Return True if a subnet entry is a nested group (like 'guests')""" + return isinstance(entry, dict) and 'subnet' not in entry + +def subnet_lookup(file, name, type_key=''): + """ + Resolve a subnet name (and optional type) to a CIDR string. + For scalar entries: subnet_lookup(file, "desktop") -> "10.1.1.0/24" + For group entries: subnet_lookup(file, "guests", "phone") -> "10.1.103.0/24" + For group with no type: subnet_lookup(file, "guests") -> "10.1.100.0/24" (none slot) + Prints the CIDR on success, nothing and exits 1 on failure. + """ + data = _subnet_read(file) + if name not in data: + sys.exit(1) + entry = data[name] + if _subnet_is_group(entry): + key = type_key if type_key else 'none' + if key not in entry: + sys.exit(1) + print(entry[key]['subnet']) + else: + print(entry['subnet']) + +def subnet_type(file, name, type_key=''): + """ + Return the type string for a subnet entry. + For scalar: subnet_type(file, "desktop") -> "desktop" + For group: subnet_type(file, "guests", "phone") -> "phone" + subnet_type(file, "guests") -> "none" + """ + data = _subnet_read(file) + if name not in data: + sys.exit(1) + entry = data[name] + if _subnet_is_group(entry): + key = type_key if type_key else 'none' + if key not in entry: + sys.exit(1) + # For group entries the type IS the child key + print(key if key != 'none' else 'none') + else: + print(entry.get('type', name)) + +def subnet_tunnel_mode(file, name, type_key=''): + """Return tunnel_mode for a subnet entry""" + data = _subnet_read(file) + if name not in data: + print('split') # safe default + return + entry = data[name] + if _subnet_is_group(entry): + key = type_key if type_key else 'none' + child = entry.get(key, {}) + print(child.get('tunnel_mode', 'split')) + else: + print(entry.get('tunnel_mode', 'split')) + +def subnet_for_ip(file, ip): + """ + Reverse lookup: given a peer IP, find which subnet name (and type) it belongs to. + Output: name|type (e.g. "guests|phone" or "desktop|desktop") + Returns nothing (exit 0) if not found — caller falls back to hardcoded map. + """ + import ipaddress + try: + peer_addr = ipaddress.ip_address(ip) + except ValueError: + return + + data = _subnet_read(file) + for name, entry in data.items(): + if _subnet_is_group(entry): + for type_key, child in entry.items(): + try: + network = ipaddress.ip_network(child['subnet'], strict=False) + if peer_addr in network: + print(f"{name}|{type_key}") + return + except Exception: + continue + else: + try: + network = ipaddress.ip_network(entry['subnet'], strict=False) + if peer_addr in network: + peer_type = entry.get('type', name) + print(f"{name}|{peer_type}") + return + except Exception: + continue + +def subnet_list(file): + """ + List all subnets for display. + Output per line: name|subnet|type|tunnel_mode|desc|is_group + For group entries, outputs one line per child: name.type|subnet|type|tunnel_mode|desc|true + """ + data = _subnet_read(file) + for name, entry in data.items(): + if _subnet_is_group(entry): + for type_key, child in entry.items(): + display_name = f"{name}.{type_key}" if type_key != 'none' else name + print(f"{display_name}|{child.get('subnet','')}|" + f"{type_key}|{child.get('tunnel_mode','split')}|" + f"{child.get('desc','')}|true|{name}") + else: + print(f"{name}|{entry.get('subnet','')}|" + f"{entry.get('type',name)}|{entry.get('tunnel_mode','split')}|" + f"{entry.get('desc','')}|false|{name}") + +def subnet_show(file, name): + """Show a single subnet entry (scalar or group) in detail""" + data = _subnet_read(file) + if name not in data: + print(f"Error: Subnet '{name}' not found", file=sys.stderr) + sys.exit(1) + entry = data[name] + if _subnet_is_group(entry): + print(f"name|{name}") + print(f"is_group|true") + for type_key, child in entry.items(): + print(f"child|{type_key}|{child.get('subnet','')}|" + f"{child.get('tunnel_mode','split')}|{child.get('desc','')}") + else: + print(f"name|{name}") + print(f"is_group|false") + print(f"subnet|{entry.get('subnet','')}") + print(f"type|{entry.get('type',name)}") + print(f"tunnel_mode|{entry.get('tunnel_mode','split')}") + print(f"desc|{entry.get('desc','')}") + +def subnet_add(file, name, subnet, type_key, tunnel_mode, desc, group_parent=''): + """ + Add a new subnet entry. + If group_parent is set, adds as a child under that group key. + Otherwise adds as a scalar entry. + """ + data = _subnet_read(file) + entry = { + 'subnet': subnet, + 'tunnel_mode': tunnel_mode or 'split', + 'desc': desc or '' + } + if group_parent: + # Adding a child to an existing group, or creating a new group + if group_parent not in data: + data[group_parent] = {} + elif not _subnet_is_group(data[group_parent]): + print(f"Error: '{group_parent}' exists but is not a group", file=sys.stderr) + sys.exit(1) + data[group_parent][type_key or 'none'] = entry + else: + # Scalar entry — type stored explicitly + entry['type'] = type_key or name + data[name] = entry + _subnet_write(file, data) + +def subnet_remove(file, name, peers_using): + """ + Remove a subnet entry. peers_using is a comma-separated list of peer names + currently using this subnet (passed in from bash after meta scan). + If non-empty, refuses with error. + """ + if peers_using: + peers = [p for p in peers_using.split(',') if p] + if peers: + print(f"Error: Subnet '{name}' is in use by: {', '.join(peers)}", file=sys.stderr) + sys.exit(1) + data = _subnet_read(file) + if '.' in name: + # Removing a group child: e.g. "guests.phone" + parent, child_key = name.split('.', 1) + if parent not in data or not _subnet_is_group(data[parent]): + print(f"Error: Group '{parent}' not found", file=sys.stderr) + sys.exit(1) + if child_key not in data[parent]: + print(f"Error: '{child_key}' not found in group '{parent}'", file=sys.stderr) + sys.exit(1) + del data[parent][child_key] + if not data[parent]: + del data[parent] # remove empty group + else: + if name not in data: + print(f"Error: Subnet '{name}' not found", file=sys.stderr) + sys.exit(1) + del data[name] + _subnet_write(file, data) + +def subnet_rename(file, old_name, new_name, peers_using): + """ + Rename a subnet entry. Hard refusal if any peers reference it. + peers_using: comma-separated peer names from bash meta scan. + """ + if peers_using: + peers = [p for p in peers_using.split(',') if p] + if peers: + print(f"Error: Cannot rename subnet '{old_name}' — in use by: {', '.join(peers)}", file=sys.stderr) + sys.exit(1) + data = _subnet_read(file) + if old_name not in data: + print(f"Error: Subnet '{old_name}' not found", file=sys.stderr) + sys.exit(1) + if new_name in data: + print(f"Error: Subnet '{new_name}' already exists", file=sys.stderr) + sys.exit(1) + data[new_name] = data.pop(old_name) + _subnet_write(file, data) + +def subnet_peers(meta_dir, clients_dir, subnet_name, subnets_file): + """ + Find all peers using a subnet. + Two-pass check: + 1. Meta field: peer has "subnet": subnet_name in their .meta file + 2. IP fallback: peer's IP falls within the subnet's CIDR(s) + (catches peers added before meta stored subnet explicitly) + Output: one peer name per line. + """ + import glob + import ipaddress + + # Resolve all CIDRs covered by this subnet name + data = _subnet_read(subnets_file) + cidrs = [] + if subnet_name in data: + entry = data[subnet_name] + if _subnet_is_group(entry): + for child in entry.values(): + try: + cidrs.append(ipaddress.ip_network(child['subnet'], strict=False)) + except Exception: + pass + else: + try: + cidrs.append(ipaddress.ip_network(entry['subnet'], strict=False)) + except Exception: + pass + + printed = set() + + for conf in sorted(glob.glob(f"{clients_dir}/*.conf")): + peer_name = os.path.basename(conf).replace('.conf', '') + + # Pass 1: check meta field + meta_file = os.path.join(meta_dir, f"{peer_name}.meta") + try: + with open(meta_file) as f: + meta = json.load(f) + if meta.get('subnet', '') == subnet_name: + if peer_name not in printed: + print(peer_name) + printed.add(peer_name) + continue + except Exception: + pass + + # Pass 2: IP reverse lookup against subnet CIDRs + if not cidrs: + continue + + peer_ip = '' + try: + with open(conf) as f: + for line in f: + if line.startswith('Address'): + peer_ip = line.split('=')[1].strip().split('/')[0] + break + except Exception: + continue + + if not peer_ip: + continue + + try: + addr = ipaddress.ip_address(peer_ip) + if any(addr in cidr for cidr in cidrs): + if peer_name not in printed: + print(peer_name) + printed.add(peer_name) + except Exception: + continue + + +def subnet_exists(file, name): + """Check if a subnet name exists (scalar or group). Exits 0/1.""" + data = _subnet_read(file) + if '.' in name: + parent, child_key = name.split('.', 1) + exists = parent in data and _subnet_is_group(data[parent]) and child_key in data[parent] + else: + exists = name in data + sys.exit(0 if exists else 1) + + +# ============================================ +# Identity System +# ============================================ + +def identity_rules(file): + """ + Return all rules assigned to an identity, one per line. + Reads from 'rules' array (1:N). Falls back to 'rule' scalar for migration. + """ + data = _identity_read(file) + if not data: + return + # Support legacy scalar 'rule' field + rules = data.get('rules', []) + if not rules and data.get('rule'): + rules = [data['rule']] + for r in rules: + if r: + print(r) + +def identity_add_rule(file, identity_name, rule_name): + """ + Add a rule to an identity's rules array. + Warns if already present (prints warning to stderr, exits 2). + Creates identity file if it doesn't exist. + """ + data = _identity_read(file) or _identity_init(identity_name) + rules = data.get('rules', []) + # Migrate legacy scalar field + if 'rule' in data and data['rule']: + if data['rule'] not in rules: + rules.append(data['rule']) + del data['rule'] + if rule_name in rules: + print(f"Warning: Rule '{rule_name}' is already assigned to identity '{identity_name}'", + file=sys.stderr) + sys.exit(2) + rules.append(rule_name) + data['rules'] = rules + _identity_write(file, data) + +def identity_remove_rule(file, rule_name): + """ + Remove a specific rule from an identity's rules array. + Exits 1 if rule not found. + """ + data = _identity_read(file) + if not data: + print(f"Error: Identity not found", file=sys.stderr) + sys.exit(1) + rules = data.get('rules', []) + if rule_name not in rules: + print(f"Error: Rule '{rule_name}' not assigned to this identity", file=sys.stderr) + sys.exit(1) + rules.remove(rule_name) + data['rules'] = rules + _identity_write(file, data) + +def identity_clear_rules(file): + """Remove all rules from an identity.""" + data = _identity_read(file) + if not data: + return + data['rules'] = [] + data.pop('rule', None) # remove legacy scalar too + _identity_write(file, data) + +def identity_has_rule(file, rule_name): + """Exit 0 if identity has this rule, 1 otherwise.""" + data = _identity_read(file) + if not data: + sys.exit(1) + rules = data.get('rules', []) + if not rules and data.get('rule'): + rules = [data['rule']] + sys.exit(0 if rule_name in rules else 1) + +def _identity_read(file): + """Read an identity file, return dict or None""" + try: + if not os.path.exists(file): + return None + with open(file) as f: + content = f.read().strip() + if not content: + return None + return json.loads(content) + except Exception: + return None + +def _identity_write(file, data): + """Write an identity file""" + os.makedirs(os.path.dirname(file), exist_ok=True) + with open(file, 'w') as f: + json.dump(data, f, indent=2) + +def _identity_init(name): + """Return empty identity structure""" + return { + 'name': name, + 'peers': [], + 'devices': {} + } + +def _parse_peer_name(peer_name): + """ + Parse a peer name into (type, identity, index). + phone-nuno -> ('phone', 'nuno', 1) + phone-nuno-2 -> ('phone', 'nuno', 2) + desktop-zephyr -> ('desktop', 'zephyr', 1) + laptop-nuno -> ('laptop', 'nuno', 1) + Returns None if name doesn't match convention. + + Convention: {type}-{identity}[-{index}] + Known types: desktop, laptop, phone, tablet, server, iot, none + """ + known_types = {'desktop', 'laptop', 'phone', 'tablet', 'server', 'iot', 'none'} + parts = peer_name.split('-') + if len(parts) < 2: + return None + peer_type = parts[0] + if peer_type not in known_types: + return None + # Check if last part is a numeric index + if len(parts) >= 3 and parts[-1].isdigit(): + index = int(parts[-1]) + identity = '-'.join(parts[1:-1]) + else: + index = 1 + identity = '-'.join(parts[1:]) + if not identity: + return None + return (peer_type, identity, index) + +def identity_list(identities_dir): + """ + List all identities with peer count, rules and policy. + Output per line: name|peer_count|types|rules|policy + """ + import glob + for id_file in sorted(glob.glob(f"{identities_dir}/*.identity")): + try: + with open(id_file) as f: + data = json.load(f) + name = data.get('name', '') + peers = data.get('peers', []) + devices = data.get('devices', {}) + rules = data.get('rules', []) + # Migrate legacy scalar rule field + if not rules and data.get('rule'): + rules = [data['rule']] + policy = data.get('policy', 'default') + types = sorted(set( + d.get('type', '') for d in devices.values() if d.get('type') + )) + print(f"{name}|{len(peers)}|{','.join(types)}|{','.join(rules)}|{policy}") + except Exception: + continue + +def identity_show(file): + """Show identity details""" + data = _identity_read(file) + if not data: + print("Error: Identity not found", file=sys.stderr) + sys.exit(1) + print(f"name|{data.get('name','')}") + print(f"peer_count|{len(data.get('peers',[]))}") + for peer_name, dev in data.get('devices', {}).items(): + print(f"device|{peer_name}|{dev.get('type','')}|{dev.get('index',1)}") + +def identity_add_peer(file, identity_name, peer_name, peer_type, index): + """Add a peer to an identity file, creating it if needed""" + data = _identity_read(file) or _identity_init(identity_name) + if peer_name not in data['peers']: + data['peers'].append(peer_name) + data['devices'][peer_name] = { + 'type': peer_type, + 'index': int(index) + } + _identity_write(file, data) + +def identity_remove_peer(file, peer_name): + """Remove a peer from an identity file""" + data = _identity_read(file) + if not data: + return + data['peers'] = [p for p in data['peers'] if p != peer_name] + data['devices'].pop(peer_name, None) + _identity_write(file, data) + +def identity_remove(file): + """Delete an identity file — existence check done in bash""" + try: + os.remove(file) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + +def identity_next_index(file, peer_type): + """ + Return the next available index for a given type within an identity. + phone-nuno exists (index 1) -> returns 2 + No phones exist -> returns 1 + """ + data = _identity_read(file) + if not data: + print(1) + return + existing = [ + d.get('index', 1) + for d in data.get('devices', {}).values() + if d.get('type') == peer_type + ] + if not existing: + print(1) + return + # Find lowest unused index starting from 1 + used = set(existing) + i = 1 + while i in used: + i += 1 + print(i) + +def identity_peers(file, filter_type=''): + """ + List peers belonging to an identity, optionally filtered by type. + Output: one peer name per line. + """ + data = _identity_read(file) + if not data: + return + for peer_name in data.get('peers', []): + if filter_type: + dev = data.get('devices', {}).get(peer_name, {}) + if dev.get('type') != filter_type: + continue + print(peer_name) + +def identity_migrate(identities_dir, clients_dir, meta_dir, dry_run): + """ + Scan all peer configs and auto-create identity files from name convention. + dry_run: 'true' -> print what would be done, no writes. + Output per action: action|identity|peer|type|index + """ + import glob + + is_dry = dry_run == 'true' + grouped = {} # identity_name -> [(peer_name, type, index)] + + for conf in sorted(glob.glob(f"{clients_dir}/*.conf")): + peer_name = os.path.basename(conf).replace('.conf', '') + parsed = _parse_peer_name(peer_name) + if not parsed: + print(f"skip|{peer_name}") + continue + peer_type, identity_name, index = parsed + if identity_name not in grouped: + grouped[identity_name] = [] + grouped[identity_name].append((peer_name, peer_type, index)) + + for identity_name, peers in sorted(grouped.items()): + id_file = os.path.join(identities_dir, f"{identity_name}.identity") + for peer_name, peer_type, index in peers: + print(f"create|{identity_name}|{peer_name}|{peer_type}|{index}") + if not is_dry: + identity_add_peer(id_file, identity_name, peer_name, peer_type, index) + +def identity_infer(peer_name): + """ + Parse a peer name and print identity|type|index, or nothing if no match. + Used by add.command.sh to auto-attach on vanilla wgctl add. + """ + parsed = _parse_peer_name(peer_name) + if parsed: + peer_type, identity_name, index = parsed + print(f"{identity_name}|{peer_type}|{index}") + +def identity_exists(file): + """Exit 0 if identity file exists and is valid, else exit 1""" + data = _identity_read(file) + sys.exit(0 if data is not None else 1) + +# ============================================ +# peer_data update — adds type field from meta +# ============================================ +# NOTE: This replaces the existing peer_data function. +# The new version reads 'type' from meta directly. +# Output format: name|ip|rule|type|last_ts|last_evt|main_group + +def peer_data(clients_dir, meta_dir, events_log): + """ + Updated peer_data that reads 'type' from meta. + Output: name|ip|rule|type|last_ts|last_evt|main_group + """ + 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 Exception: + 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 Exception: + pass + except Exception: + 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 Exception: + pass + + m = meta.get(name, {}) + rule = m.get('rule', '') + peer_type = m.get('type', '') + main_group = m.get('main_group', '') + + last_event = last_events.get(name, {}) + last_ts = last_event.get('timestamp', '') + last_evt = last_event.get('event', '') + + 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) + + # ============================================ +# Policy System +# ============================================ + +_POLICY_DEFAULTS = { + "default": { + "tunnel_mode": "split", + "default_rule": None, + "strict_rule": False, + "auto_apply": True, + "desc": "Default policy" + }, + "guest": { + "tunnel_mode": "split", + "default_rule": "guest", + "strict_rule": True, + "auto_apply": True, + "desc": "Guest access policy" + }, + "trusted": { + "tunnel_mode": "split", + "default_rule": None, + "strict_rule": False, + "auto_apply": True, + "desc": "Trusted device policy" + }, + "server": { + "tunnel_mode": "split", + "default_rule": None, + "strict_rule": False, + "auto_apply": True, + "desc": "Server policy" + }, + "iot": { + "tunnel_mode": "split", + "default_rule": None, + "strict_rule": False, + "auto_apply": True, + "desc": "IoT device policy" + } +} + +def _policy_read(file): + """Read policies.json, fall back to hardcoded defaults if missing.""" + try: + if not os.path.exists(file): + return dict(_POLICY_DEFAULTS) + with open(file) as f: + content = f.read().strip() + if not content: + return dict(_POLICY_DEFAULTS) + data = json.loads(content) + # Merge with defaults so hardcoded policies always exist + merged = dict(_POLICY_DEFAULTS) + merged.update(data) + return merged + except Exception: + return dict(_POLICY_DEFAULTS) + +def _policy_write(file, data): + """Write policies.json.""" + os.makedirs(os.path.dirname(file), exist_ok=True) + with open(file, 'w') as f: + json.dump(data, f, indent=2) + +def _policy_get_entry(data, name): + """Get a policy entry, falling back to 'default' policy values for missing fields.""" + entry = data.get(name, {}) + default = data.get('default', _POLICY_DEFAULTS.get('default', {})) + # Merge: entry fields override default + resolved = dict(default) + resolved.update(entry) + return resolved + +def policy_get(file, name, field=''): + """ + Get a policy entry or a specific field from it. + policy_get(file, "guest") -> prints all fields as key|value lines + policy_get(file, "guest", "tunnel_mode") -> prints "split" + policy_get(file, "guest", "strict_rule") -> prints "true" or "false" + """ + data = _policy_read(file) + entry = _policy_get_entry(data, name) + + if field: + val = entry.get(field) + if val is None: + print('') + elif isinstance(val, bool): + print('true' if val else 'false') + else: + print(val) + else: + for k, v in entry.items(): + if isinstance(v, bool): + print(f"{k}|{'true' if v else 'false'}") + elif v is None: + print(f"{k}|") + else: + print(f"{k}|{v}") + +def policy_list(file): + """ + List all policies. + Output per line: name|tunnel_mode|default_rule|strict_rule|auto_apply|desc + """ + data = _policy_read(file) + for name, raw_entry in data.items(): + entry = _policy_get_entry(data, name) + tunnel_mode = entry.get('tunnel_mode', 'split') + default_rule = entry.get('default_rule') or '' + strict_rule = 'true' if entry.get('strict_rule', False) else 'false' + auto_apply = 'true' if entry.get('auto_apply', True) else 'false' + desc = entry.get('desc', '') + print(f"{name}|{tunnel_mode}|{default_rule}|{strict_rule}|{auto_apply}|{desc}") + +def policy_exists(file, name): + """Exit 0 if policy exists, 1 otherwise.""" + data = _policy_read(file) + sys.exit(0 if name in data else 1) + +def policy_add(file, name, tunnel_mode, default_rule, strict_rule, auto_apply, desc): + """Add or update a policy entry.""" + data = _policy_read(file) + data[name] = { + 'tunnel_mode': tunnel_mode or 'split', + 'default_rule': default_rule if default_rule else None, + 'strict_rule': strict_rule == 'true', + 'auto_apply': auto_apply != 'false', + 'desc': desc or '' + } + _policy_write(file, data) + +def policy_remove(file, name): + """Remove a policy. Refuses to remove hardcoded defaults.""" + if name in _POLICY_DEFAULTS: + print(f"Error: Cannot remove built-in policy '{name}'", file=sys.stderr) + sys.exit(1) + data = _policy_read(file) + if name not in data: + print(f"Error: Policy '{name}' not found", file=sys.stderr) + sys.exit(1) + del data[name] + _policy_write(file, data) + +def policy_set_field(file, name, field, value): + """Set a single field on an existing policy.""" + data = _policy_read(file) + if name not in data: + print(f"Error: Policy '{name}' not found", file=sys.stderr) + sys.exit(1) + entry = data[name] + if field in ('strict_rule', 'auto_apply'): + entry[field] = value == 'true' + elif field == 'default_rule': + entry[field] = value if value else None + else: + entry[field] = value + data[name] = entry + _policy_write(file, data) + +def subnet_policy(subnets_file, subnet_name, type_key=''): + """ + Get the policy name for a subnet entry. + Falls back to 'default' if no policy set. + """ + data = _subnet_read(subnets_file) + if subnet_name not in data: + print('default') + return + entry = data[subnet_name] + if _subnet_is_group(entry): + key = type_key if type_key else 'none' + child = entry.get(key, {}) + print(child.get('policy', 'default')) + else: + print(entry.get('policy', 'default')) + +def json_get_nested(file, *keys): + """ + Get a nested field from a JSON file. + json_get_nested(file, "rule_flags", "strict_rule") + Output: the value as a string, or empty string if not found. + """ + try: + with open(file) as f: + data = json.load(f) + val = data + for key in keys: + if not isinstance(val, dict): + print('') + return + val = val.get(key) + if val is None: + print('') + return + if isinstance(val, bool): + print('true' if val else 'false') + elif val is None: + print('') + else: + print(val) + except Exception: + print('') + +def json_set_nested(file, *args): + """ + Set a nested field in a JSON file. + Args: file, key1, key2, ..., value + json_set_nested(file, "rule_flags", "strict_rule", "true") + Creates intermediate dicts as needed. + """ + if len(args) < 2: + return + keys = args[:-1] + value = args[-1] + + try: + if os.path.exists(file): + with open(file) as f: + data = json.load(f) + else: + data = {} + + # Navigate/create nested structure + target = data + for key in keys[:-1]: + if key not in target or not isinstance(target[key], dict): + target[key] = {} + target = target[key] + + # Coerce value types + final_key = keys[-1] + if value == 'true': + target[final_key] = True + elif value == 'false': + target[final_key] = False + else: + target[final_key] = value + + with open(file, 'w') as f: + json.dump(data, f, indent=2) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + commands = { 'get': lambda args: get(args[0], args[1]), 'set': lambda args: set_key(args[0], args[1], args[2]), @@ -1523,7 +2413,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]), @@ -1577,6 +2466,54 @@ commands = { ), 'block_is_empty': lambda args: block_is_empty(args[0]), 'group_has_peer': lambda args: group_has_peer(args[0], args[1]), + + # Subnet commands: + 'subnet_lookup': lambda args: subnet_lookup(args[0], args[1], args[2] if len(args) > 2 else ''), + 'subnet_type': lambda args: subnet_type(args[0], args[1], args[2] if len(args) > 2 else ''), + 'subnet_tunnel_mode': lambda args: subnet_tunnel_mode(args[0], args[1], args[2] if len(args) > 2 else ''), + 'subnet_for_ip': lambda args: subnet_for_ip(args[0], args[1]), + 'subnet_list': lambda args: subnet_list(args[0]), + 'subnet_show': lambda args: subnet_show(args[0], args[1]), + 'subnet_add': lambda args: subnet_add( + args[0], args[1], args[2], args[3], + args[4] if len(args) > 4 else 'split', + args[5] if len(args) > 5 else '', + args[6] if len(args) > 6 else '' + ), + 'subnet_remove': lambda args: subnet_remove(args[0], args[1], args[2] if len(args) > 2 else ''), + 'subnet_rename': lambda args: subnet_rename(args[0], args[1], args[2], args[3] if len(args) > 3 else ''), + 'subnet_peers': lambda args: subnet_peers(args[0], args[1], args[2], args[3]), + 'subnet_exists': lambda args: subnet_exists(args[0], args[1]), + + # Identity commands: + 'identity_list': lambda args: identity_list(args[0]), + 'identity_show': lambda args: identity_show(args[0]), + 'identity_add_peer': lambda args: identity_add_peer(args[0], args[1], args[2], args[3], args[4]), + 'identity_remove_peer':lambda args: identity_remove_peer(args[0], args[1]), + 'identity_remove': lambda args: identity_remove(args[0]), + 'identity_next_index': lambda args: identity_next_index(args[0], args[1]), + 'identity_peers': lambda args: identity_peers(args[0], args[1] if len(args) > 1 else ''), + '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]), + '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]), + + # Policy commands: + 'policy_get': lambda args: policy_get(args[0], args[1], args[2] if len(args) > 2 else ''), + 'policy_list': lambda args: policy_list(args[0]), + 'policy_exists': lambda args: policy_exists(args[0], args[1]), + 'policy_add': lambda args: policy_add(args[0], args[1], args[2], args[3], args[4], args[5], args[6] if len(args) > 6 else ''), + 'policy_remove': lambda args: policy_remove(args[0], args[1]), + 'policy_set_field': lambda args: policy_set_field(args[0], args[1], args[2], args[3]), + 'subnet_policy': lambda args: subnet_policy(args[0], args[1], args[2] if len(args) > 2 else ''), + 'get_nested': lambda args: json_get_nested(args[0], *args[1:]), + 'set_nested': lambda args: json_set_nested(args[0], *args[1:]), + 'identity_rules': lambda args: identity_rules(args[0]), + 'identity_add_rule': lambda args: identity_add_rule(args[0], args[1], args[2]), + 'identity_remove_rule': lambda args: identity_remove_rule(args[0], args[1]), + 'identity_clear_rules': lambda args: identity_clear_rules(args[0]), + 'identity_has_rule': lambda args: identity_has_rule(args[0], args[1]), } if __name__ == '__main__': diff --git a/modules/log.module.sh b/core/log.sh similarity index 98% rename from modules/log.module.sh rename to core/log.sh index cd73483..da86794 100644 --- a/modules/log.module.sh +++ b/core/log.sh @@ -15,6 +15,7 @@ function internal::get_log_priority() { DEBUG) echo 0 ;; INFO) echo 1 ;; SUCCESS) echo 1 ;; + OK) echo 1 ;; WARN) echo 2 ;; ERROR) echo 3 ;; *) echo 1 ;; @@ -48,6 +49,7 @@ function internal::log() { WARN) color="\033[1;33m" ;; ERROR) color="\033[1;31m" ;; SUCCESS) color="\033[1;32m" ;; + OK) color="\033[1;32m" ;; esac echo -e "${color}=> ${level}:\033[0m $*" @@ -156,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 "$*"; } # ============================================ @@ -200,7 +202,8 @@ function log::debug_context() { function log::info() { log::context log info "$@"; } function log::warn() { log::warn_context log warn "$@"; } function log::error() { log::error_context log error "$@"; } -function log::success() { log::success_context log success "$@"; } +function log::ok() { internal::log OK "$@"; } +function log::success() { log::ok "$@"; } function log::debug() { log::debug_context log debug "$@"; } function log::section() { diff --git a/core/module.sh b/core/module.sh index 29c0ef0..5bd16d2 100644 --- a/core/module.sh +++ b/core/module.sh @@ -36,6 +36,12 @@ function module::is_auto_load() { declare -F "$(module::fn "$1" on_load)" >/dev/ function load_module() { local name="$1" + # Wildcard: load all submodules in a directory + if [[ "$name" == *"/*" ]]; then + _load_module_dir "${name%/*}" + return $? + fi + module::loaded "$name" && return 0 local path @@ -53,3 +59,20 @@ function load_module() { return 0 } + +function _load_module_dir() { + local dir="${1:-}" + local module_dir + module_dir="$(ctx::modules)/${dir}" + + if [[ ! -d "$module_dir" ]]; then + log::error "Module directory not found: ${dir} (${module_dir})" + return 1 + fi + + for file in "${module_dir}"/*.module.sh; do + [[ -f "$file" ]] || continue + local subname="${dir}/$(basename "${file%.module.sh}")" + load_module "$subname" + done +} \ No newline at end of file diff --git a/core/ui.sh b/core/ui.sh index c1be533..fe78485 100644 --- a/core/ui.sh +++ b/core/ui.sh @@ -77,6 +77,48 @@ function ui::center() { printf "%${pad}s%s%${rpad}s" "" "$text" "" } +# ui::measure_col [min_width] +# Scans pipe-delimited data and returns the max visible width +# of the field at field_index (1-based), with optional minimum. +# Strips ANSI codes before measuring. +# Usage: +# name_width=$(ui::measure_col "$data" 1 10) +# ip_width=$(ui::measure_col "$data" 2 14) +function ui::measure_col() { + local data="${1:-}" field_index="${2:-1}" min_width="${3:-0}" + local max=$min_width + + while IFS='|' read -r line; do + local val + val=$(echo "$line" | cut -d'|' -f"$field_index") + # Strip ANSI codes for accurate measurement + local clean + clean=$(echo "$val" | sed 's/\x1b\[[0-9;]*m//g') + local len=${#clean} + (( len > max )) && max=$len + done <<< "$data" + + echo $max +} + +# ui::measure_cols +# Measure multiple columns at once, returns space-separated widths. +# Usage: read -r w1 w2 w3 <<< $(ui::measure_cols "$data" 1 2 3) +function ui::measure_cols() { + local data="${1:-}" + shift + local widths=() + for idx in "$@"; do + widths+=("$(ui::measure_col "$data" "$idx")") + done + echo "${widths[*]}" +} + +function ui::sort_rows() { + local field="${1:-1}" + sort -t'|' -k"${field},${field}V" +} + function ui::firewall_rule() { local rule="$1" if [[ "$rule" =~ ACCEPT|DNAT ]]; then @@ -109,7 +151,29 @@ function ui::skip_if_empty() { } function ui::empty() { - # ui::empty "$var" && return 0 - # ui::empty "${array[*]}" && return 0 - [[ -z "${1// }" ]] + local val="${1:-}" + # Empty string or whitespace only + [[ -z "${val// }" ]] && return 0 + # Numeric zero + [[ "$val" =~ ^[0-9]+$ ]] && [[ "$val" -eq 0 ]] && return 0 + return 1 +} + +# Usage: ui::bool "$value" [yes_label] [no_label] +# Default labels: yes / no +function ui::bool() { + local val="${1:-}" yes="${2:-yes}" no="${3:-no}" + [[ "$val" == "true" ]] && echo "$yes" || echo "$no" +} + +# ============================================ +# Prompt +# ============================================ + +function ui::confirm() { + local prompt="${1:-Are you sure?}" + local response + printf " %s [y/N] " "$prompt" + read -r response + [[ "${response,,}" == "y" || "${response,,}" == "yes" ]] } \ No newline at end of file diff --git a/daemon/endpoint_cache.json b/daemon/endpoint_cache.json index 947699e..f7c1f06 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.130", + "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 new file mode 100644 index 0000000..e688ba9 --- /dev/null +++ b/modules/identity.module.sh @@ -0,0 +1,422 @@ +#!/usr/bin/env bash +# identity.module.sh — identity file management and peer-name inference + +declare -gA __empty_map=() + +# =========================================================================== +# Path helpers +# =========================================================================== + +function identity::path() { + local name="${1:-}" + echo "$(ctx::identities)/${name}.identity" +} + +# =========================================================================== +# Existence checks +# =========================================================================== + +function identity::exists() { + local name="${1:-}" + json::identity_exists "$(identity::path "$name")" 2>/dev/null +} + +function identity::require_exists() { + local name="${1:-}" + if ! identity::exists "$name"; then + log::error "Identity '${name}' not found. Use 'wgctl identity list' to see all identities." + return 1 + fi +} + +function identity::require_not_exists() { + local name="${1:-}" + if identity::exists "$name"; then + log::error "Identity '${name}' already 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 +# =========================================================================== + +# identity::infer +# Parses a peer name and returns "identity_name|type|index" if it matches +# the naming convention, or empty string if not. +# phone-nuno -> "nuno|phone|1" +# phone-nuno-2 -> "nuno|phone|2" +# roboclean -> "" (no type prefix) +function identity::infer() { + local peer_name="${1:-}" + json::identity_infer "$peer_name" 2>/dev/null || true +} + +# identity::next_index +# Returns the next available device index for a type within an identity. +# If identity doesn't exist yet, returns 1. +function identity::next_index() { + local identity_name="${1:-}" peer_type="${2:-}" + local id_file + id_file=$(identity::path "$identity_name") + if [[ ! -f "$id_file" ]]; then + echo 1 + return 0 + fi + 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) +# =========================================================================== + +# identity::auto_attach +# Infers identity from peer name and adds the peer to the identity file. +# Creates the identity file if it doesn't exist. +# Silent — no output. Logs a note on success, silently skips if no match. +function identity::auto_attach() { + local peer_name="${1:-}" peer_type="${2:-}" + local inferred + inferred=$(identity::infer "$peer_name") + [[ -z "$inferred" ]] && return 0 + + local identity_name type_inferred index + identity_name=$(echo "$inferred" | cut -d'|' -f1) + type_inferred=$(echo "$inferred" | cut -d'|' -f2) + index=$(echo "$inferred" | cut -d'|' -f3) + + # Use the explicit type if provided, otherwise use inferred type + local final_type="${peer_type:-$type_inferred}" + + local id_file + id_file=$(identity::path "$identity_name") + + json::identity_add_peer "$id_file" "$identity_name" "$peer_name" "$final_type" "$index" +# Removes a peer from its identity file when the peer is deleted. +# If the identity has no remaining peers, removes the identity file too. +function identity::auto_detach() { + local peer_name="${1:-}" + local inferred + inferred=$(identity::infer "$peer_name") + [[ -z "$inferred" ]] && return 0 + + local identity_name + identity_name=$(echo "$inferred" | cut -d'|' -f1) + local id_file + id_file=$(identity::path "$identity_name") + [[ ! -f "$id_file" ]] && return 0 + + json::identity_remove_peer "$id_file" "$peer_name" /dev/null) || true + if [[ -z "$remaining" ]]; then + rm -f "$id_file" + log::info "Identity '${identity_name}' removed (no remaining peers)" + fi +} + +# =========================================================================== +# Peer queries +# =========================================================================== + +# identity::peers [type_filter] +# Returns peer names belonging to an identity, one per line. +# Optional type_filter limits to peers of a specific type. +function identity::peers() { + local identity_name="${1:-}" type_filter="${2:-}" + local id_file + id_file=$(identity::path "$identity_name") + json::identity_peers "$id_file" "$type_filter" 2>/dev/null || true +} + +# identity::get_name +# Returns the identity name for a given peer (via inference). +function identity::get_name() { + local peer_name="${1:-}" + local inferred + inferred=$(identity::infer "$peer_name") + [[ -n "$inferred" ]] && echo "${inferred%%|*}" +} + +# =========================================================================== +# Data for commands +# =========================================================================== + +function identity::list_data() { + json::identity_list "$(ctx::identities)" 2>/dev/null || true +} + +function identity::show_data() { + local name="${1:-}" + json::identity_show "$(identity::path "$name")" 2>/dev/null +} + +# =========================================================================== +# Rename helper (called from rename.command.sh) +# =========================================================================== + +# identity::rename_peer +# Updates identity file entry when a peer is renamed. +# Re-infers identity from old name, removes old entry, adds new entry. +function identity::rename_peer() { + local old_name="${1:-}" new_name="${2:-}" + + local old_inferred + old_inferred=$(identity::infer "$old_name") + [[ -z "$old_inferred" ]] && return 0 + + local identity_name old_type old_index + identity_name=$(echo "$old_inferred" | cut -d'|' -f1) + old_type=$(echo "$old_inferred" | cut -d'|' -f2) + old_index=$(echo "$old_inferred" | cut -d'|' -f3) + + local id_file + id_file=$(identity::path "$identity_name") + [[ ! -f "$id_file" ]] && return 0 + + # Infer new identity context from new name + local new_inferred new_identity new_type new_index + new_inferred=$(identity::infer "$new_name") + if [[ -n "$new_inferred" ]]; then + new_identity=$(echo "$new_inferred" | cut -d'|' -f1) + new_type=$(echo "$new_inferred" | cut -d'|' -f2) + new_index=$(echo "$new_inferred" | cut -d'|' -f3) + else + # New name doesn't match convention — detach cleanly + json::identity_remove_peer "$id_file" "$old_name" phone-helena) — move to new identity file + local new_id_file + new_id_file=$(identity::path "$new_identity") + json::identity_add_peer "$new_id_file" "$new_identity" "$new_name" "$new_type" "$new_index" /dev/null) || true + if [[ -z "$remaining" ]]; then + rm -f "$id_file" + fi + fi +} + + +# identity::policy +# Returns the policy name assigned to an identity, or "default". +function identity::policy() { + local identity_name="${1:-}" + local id_file + id_file=$(ctx::identity::path "${identity_name}.identity") + [[ ! -f "$id_file" ]] && echo "default" && return 0 + local result + result=$(json::get "$id_file" "policy" 2>/dev/null) || true + echo "${result:-default}" +} + +# identity::set_policy +# Sets the policy on an identity file. +function identity::set_policy() { + local identity_name="${1:-}" policy_name="${2:-}" + local id_file + id_file=$(ctx::identity::path "${identity_name}.identity") + if [[ ! -f "$id_file" ]]; then + log::error "Identity '${identity_name}' not found" + return 1 + fi + json::set "$id_file" "policy" "$policy_name" +} + +# identity::rule_flags +# Returns a specific rule_flag from the identity file. +# Falls back to the policy's value if not explicitly set on the identity. +function identity::rule_flags() { + local identity_name="${1:-}" flag="${2:-}" + local id_file + id_file=$(ctx::identity::path "${identity_name}.identity") + + local result + result=$(json::get_nested "$id_file" "rule_flags" "$flag" 2>/dev/null) || true + + if [[ -n "$result" ]]; then + echo "$result" + return 0 + fi + + # Fall back to policy value + local policy_name + policy_name=$(identity::policy "$identity_name") + policy::get "$policy_name" "$flag" +} + +# identity::set_rule_flag +# Sets a rule_flag directly on the identity file. +function identity::set_rule_flag() { + local identity_name="${1:-}" flag="${2:-}" value="${3:-}" + local id_file + id_file=$(ctx::identity::path "${identity_name}.identity") + if [[ ! -f "$id_file" ]]; then + log::error "Identity '${identity_name}' not found" + return 1 + fi + json::set_nested "$id_file" "rule_flags" "$flag" "$value" +} + +# identity::reapply_rules +# Reapply the identity rule to all peers in this identity. +# Respects auto_apply flag — if false, does nothing. +function identity::reapply_rules() { + local identity_name="${1:-}" + + # Check auto_apply + local auto + auto=$(identity::rule_flags "$identity_name" "auto_apply") + [[ "$auto" == "false" ]] && return 0 + + local identity_rule + identity_rule=$(identity::rule "$identity_name") + [[ -z "$identity_rule" ]] && return 0 + + local peers + peers=$(identity::peers "$identity_name") + [[ -z "$peers" ]] && return 0 + + while IFS= read -r peer_name; do + [[ -z "$peer_name" ]] && continue + local client_ip + client_ip=$(peers::get_ip "$peer_name") || continue + rule::full_restore_peer "$peer_name" "$client_ip" + done <<< "$peers" +} + +# identity::rules +# Returns all rules assigned to an identity, one per line. +# Empty output if no rules assigned. +function identity::rules() { + local identity_name="${1:-}" + local id_file + id_file=$(ctx::identity::path "${identity_name}.identity") + [[ ! -f "$id_file" ]] && return 0 + json::identity_rules "$id_file" 2>/dev/null || true +} + +# identity::has_rule +# Returns 0 if identity has this rule, 1 otherwise. +function identity::has_rule() { + local identity_name="${1:-}" rule_name="${2:-}" + local id_file + id_file=$(ctx::identity::path "${identity_name}.identity") + json::identity_has_rule "$id_file" "$rule_name" 2>/dev/null +} + +# identity::add_rule +# Adds a rule to an identity. Warns if already present (exit 2). +function identity::add_rule() { + local identity_name="${1:-}" rule_name="${2:-}" + local id_file + id_file=$(ctx::identity::path "${identity_name}.identity") + + local exit_code=0 + json::identity_add_rule "$id_file" "$identity_name" "$rule_name" 2>/dev/null || exit_code=$? + return $exit_code +} + +# identity::remove_rule +# Removes a specific rule from an identity. +function identity::remove_rule() { + local identity_name="${1:-}" rule_name="${2:-}" + local id_file + id_file=$(ctx::identity::path "${identity_name}.identity") + json::identity_remove_rule "$id_file" "$rule_name" 2>/dev/null +} + +# identity::clear_rules +# Removes all rules from an identity. +function identity::clear_rules() { + local identity_name="${1:-}" + local id_file + id_file=$(ctx::identity::path "${identity_name}.identity") + json::identity_clear_rules "$id_file" 2>/dev/null +} + +# identity::reapply_rules +# Reapply all identity rules to all peers in this identity. +# Respects auto_apply flag. +function identity::reapply_rules() { + local identity_name="${1:-}" + + local auto + auto=$(identity::rule_flags "$identity_name" "auto_apply") + [[ "$auto" == "false" ]] && return 0 + + local rules + rules=$(identity::rules "$identity_name") + [[ -z "$rules" ]] && return 0 + + local peers + peers=$(identity::peers "$identity_name") + [[ -z "$peers" ]] && return 0 + + while IFS= read -r peer_name; do + [[ -z "$peer_name" ]] && continue + local client_ip + client_ip=$(peers::get_ip "$peer_name") || continue + rule::full_restore_peer "$peer_name" "$client_ip" + done <<< "$peers" +} \ No newline at end of file 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 44216bf..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,38 @@ 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 +# ============================================ + +function peers::purge() { + local name="${1:-}" client_ip="${2:-}" was_blocked="${3:-false}" + + [[ -n "$client_ip" ]] && fw::flush_peer "$client_ip" + peers::remove_from_server "$name" || return 1 + peers::remove_client_config "$name" || return 1 + keys::remove "$name" || return 1 + group::remove_peer_from_all "$name" || return 1 + + if [[ -n "$client_ip" ]] && $was_blocked; then + fw::unblock_all "$client_ip" + fi + + block::remove_file "$name" 2>/dev/null || true + peers::remove_meta "$name" 2>/dev/null || true + peers::reload || return 1 +} + + # ============================================ # Display / Formatting # ============================================ @@ -354,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:-}" @@ -425,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}" } # ============================================ @@ -493,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/policy.module.sh b/modules/policy.module.sh new file mode 100644 index 0000000..4e71fce --- /dev/null +++ b/modules/policy.module.sh @@ -0,0 +1,200 @@ +#!/usr/bin/env bash +# policy.module.sh — policy system +# Policies define behavioral flags for subnets, identities, and future contexts. +# Chain: Subnet → Policy → Identity → Peer + +# ====================================================== +# Hardcoded Fallbacks +# Mirror of policies.json built-in policies. +# Used when policies.json lookup fails. +# ====================================================== + +declare -gA _POLICY_TUNNEL_MODE=( + [default]="split" + [guest]="split" + [trusted]="split" + [server]="split" + [iot]="split" +) + +declare -gA _POLICY_DEFAULT_RULE=( + [default]="" + [guest]="guest" + [trusted]="" + [server]="" + [iot]="" +) + +declare -gA _POLICY_STRICT_RULE=( + [default]="false" + [guest]="true" + [trusted]="false" + [server]="false" + [iot]="false" +) + +declare -gA _POLICY_AUTO_APPLY=( + [default]="true" + [guest]="true" + [trusted]="true" + [server]="true" + [iot]="true" +) + +function policy::_hardcoded_field() { + local name="${1:-}" field="${2:-}" + case "$field" in + tunnel_mode) echo "${_POLICY_TUNNEL_MODE[$name]:-split}" ;; + default_rule) echo "${_POLICY_DEFAULT_RULE[$name]:-}" ;; + strict_rule) echo "${_POLICY_STRICT_RULE[$name]:-false}" ;; + auto_apply) echo "${_POLICY_AUTO_APPLY[$name]:-true}" ;; + *) echo "" ;; + esac +} + +# ====================================================== +# Core Accessors +# ====================================================== + +function ctx::policies() { echo "${_CTX_DATA}/policies.json"; } + +function policy::exists() { + local name="${1:-}" + json::policy_exists "$(ctx::policies)" "$name" 2>/dev/null +} + +function policy::require_exists() { + local name="${1:-}" + if ! policy::exists "$name"; then + log::error "Policy '${name}' not found. Use 'wgctl policy list' to see available policies." + return 1 + fi +} + +function policy::get() { + local name="${1:-}" field="${2:-}" + local result + result=$(json::policy_get "$(ctx::policies)" "$name" "$field" 2>/dev/null) || true + if [[ -n "$result" ]]; then + echo "$result" + return 0 + fi + # Fallback to hardcoded + [[ -n "$field" ]] && policy::_hardcoded_field "$name" "$field" +} + +function policy::tunnel_mode() { + local name="${1:-default}" + policy::get "$name" "tunnel_mode" +} + +function policy::default_rule() { + local name="${1:-default}" + policy::get "$name" "default_rule" +} + +function policy::strict_rule() { + local name="${1:-default}" + local val + val=$(policy::get "$name" "strict_rule") + [[ "$val" == "true" ]] +} + +function policy::auto_apply() { + local name="${1:-default}" + local val + val=$(policy::get "$name" "auto_apply") + [[ "$val" != "false" ]] +} + +function policy::list_data() { + json::policy_list "$(ctx::policies)" 2>/dev/null || true +} + +# ====================================================== +# Subnet Policy Resolution +# ====================================================== + +# policy::for_subnet [type_key] +# Returns the policy name for a subnet entry. +# Falls back to "default" if no policy set on the subnet. +function policy::for_subnet() { + local subnet_name="${1:-}" type_key="${2:-}" + local result + result=$(json::subnet_policy "$(ctx::subnets)" "$subnet_name" "$type_key" 2>/dev/null) || true + echo "${result:-default}" +} + +# policy::resolve_for_add [type_key] +# Returns the fully resolved policy for use during wgctl add. +# Output: policy_name +function policy::resolve_for_add() { + local subnet_name="${1:-}" type_key="${2:-}" + if [[ -z "$subnet_name" ]]; then + echo "default" + return 0 + fi + policy::for_subnet "$subnet_name" "$type_key" +} + +# ====================================================== +# Identity Policy Resolution +# ====================================================== + +# policy::for_identity +# Returns the policy name stored in an identity file. +# Falls back to "default". +function policy::for_identity() { + local identity_name="${1:-}" + local id_file + id_file=$(ctx::identity::path "${identity_name}.identity") + local result + result=$(json::get "$id_file" "policy" 2>/dev/null) || true + echo "${result:-default}" +} + +# policy::effective +# Returns the effective policy name for a peer being added. +# Priority: identity policy > subnet policy > default +function policy::effective() { + local subnet_name="${1:-}" type_key="${2:-}" identity_name="${3:-}" + + # Identity policy takes precedence if explicitly set + if [[ -n "$identity_name" ]]; then + local id_policy + id_policy=$(policy::for_identity "$identity_name") + if [[ "$id_policy" != "default" ]]; then + echo "$id_policy" + return 0 + fi + fi + + # Subnet policy next + if [[ -n "$subnet_name" ]]; then + local subnet_policy + subnet_policy=$(policy::for_subnet "$subnet_name" "$type_key") + echo "$subnet_policy" + return 0 + fi + + echo "default" +} + +# ====================================================== +# Warning Helpers (called from add.command.sh) +# ====================================================== + +function policy::warn_strict_rule() { + local identity_name="${1:-}" policy_name="${2:-}" identity_rule="${3:-}" + log::warn "Identity '${identity_name}' has policy '${policy_name}' with strict rule enabled — peer rule will not be applied, '${identity_rule}' is the only active rule" +} + +function policy::warn_additive_rule() { + local identity_name="${1:-}" identity_rule="${1:-}" peer_rule="${2:-}" + log::info "Identity '${identity_name}' has strict rule disabled — '${identity_rule}' and '${peer_rule}' will both apply" +} + +function policy::warn_no_rule() { + local peer_name="${1:-}" + log::warn "'${peer_name}' has no rule assigned — peer has unrestricted access" +} diff --git a/modules/rule.module.sh b/modules/rule.module.sh index 782d96c..58ba2b3 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 @@ -99,33 +99,21 @@ function rule::is_applied() { # Rule Application # ============================================ -function rule::apply() { - local rule_name="${1:?rule_name required}" - local client_ip="${2:?client_ip required}" - local peer_name="${3:-}" +function rule::_apply_entries() { + local rule_name="${1:?}" client_ip="${2:?}" rule::require_exists "$rule_name" || return 1 - if [[ -z "$peer_name" ]]; then - peer_name=$(peers::find_by_ip "$client_ip") - fi - - 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" + log::debug "Rule '${rule_name}' already applied to: ${client_ip}" return 0 fi - # Process block_ips while IFS= read -r block_ip; do [[ -z "$block_ip" ]] && continue fw::block_ip "$client_ip" "$block_ip" done < <(rule::get "$rule_name" "block_ips") - # Process block_ports while IFS= read -r entry; do [[ -z "$entry" ]] && continue local target port proto @@ -134,13 +122,11 @@ function rule::apply() { fw::block_port "$client_ip" "$target" "$port" "$proto" done < <(rule::get "$rule_name" "block_ports") - # Process allow_ips (inserted before blocks) while IFS= read -r allow_ip; do [[ -z "$allow_ip" ]] && continue fw::allow_ip "$client_ip" "$allow_ip" done < <(rule::get "$rule_name" "allow_ips") - # Process allow_ports (highest priority) while IFS= read -r entry; do [[ -z "$entry" ]] && continue local target port proto @@ -149,28 +135,46 @@ 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 local dns_redirect dns_redirect=$(rule::get "$rule_name" "dns_redirect") if [[ "$dns_redirect" == "true" ]]; then - local peer_subnet + local peer_name peer_subnet + peer_name=$(peers::find_by_ip "$client_ip") peer_subnet=$(peers::get_ip "$peer_name" | cut -d'.' -f1-3) if ! fw::_nat_exists -i wg0 -s "${peer_subnet}.0/24" \ -p udp --dport 53 -j DNAT \ --to-destination "$(config::dns):53" 2>/dev/null; then rule::apply_dns_redirect "${peer_subnet}.0/24" - log::debug "dns_redirect: applied for ${peer_subnet}.0/24" - else - log::debug "dns_redirect: already applied for ${peer_subnet}.0/24" fi fi log::debug "Applied rule '${rule_name}' to: ${client_ip}" } +function rule::apply_transient() { + # Apply rule entries without touching peer meta + # Used for identity rules and other transient applications + local rule_name="${1:?}" client_ip="${2:?}" + log::debug "rule::apply_transient: $rule_name -> $client_ip" + rule::_apply_entries "$rule_name" "$client_ip" +} + +function rule::apply() { + local rule_name="${1:?}" client_ip="${2:?}" peer_name="${3:-}" + + rule::require_exists "$rule_name" || return 1 + + log::debug "rule::apply: peer_name=${peer_name:-} ip=$client_ip" + + rule::_apply_entries "$rule_name" "$client_ip" || return 1 + + # Write to peer meta — only for explicit peer rule assignment + if [[ -z "$peer_name" ]]; then + peer_name=$(peers::find_by_ip "$client_ip") + fi + [[ -n "$peer_name" ]] && peers::set_meta "$peer_name" "rule" "$rule_name" +} + function rule::unapply() { local rule_name="${1:-}" client_ip="${2:-}" @@ -221,56 +225,93 @@ 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}" } -function rule::reapply_all() { - local rule_name="${1:-}" - rule::require_exists "$rule_name" || return 1 +# ============================================ +# Bulk Operations +# ============================================ - local peers=() - mapfile -t peers < <(peers::with_rule "$rule_name") - [[ ${#peers[@]} -eq 0 ]] && return 0 - - local count=0 - for peer_name in "${peers[@]}"; do - local client_ip - client_ip=$(peers::get_ip "$peer_name") - [[ -z "$client_ip" ]] && continue - # FLUSH first to ensure clean ordering +function rule::_apply_identity_rule() { + local peer_name="${1:-}" client_ip="${2:-}" + + local identity_name + identity_name=$(identity::get_name "$peer_name") + [[ -z "$identity_name" ]] && return 0 + + local rules + rules=$(identity::rules "$identity_name") + [[ -z "$rules" ]] && return 0 + + local strict + strict=$(identity::rule_flags "$identity_name" "strict_rule") + + if [[ "$strict" == "true" ]]; then + # Strict: flush and apply only identity rules — peer rule ignored fw::flush_peer "$client_ip" - rule::apply "$rule_name" "$client_ip" "$peer_name" - (( count++ )) || true - done + while IFS= read -r rule_name; do + [[ -z "$rule_name" ]] && continue + rule::exists "$rule_name" && rule::apply_transient "$rule_name" "$client_ip" || true + done <<< "$rules" + else + # Additive: apply identity rules on top of peer rule + while IFS= read -r rule_name; do + [[ -z "$rule_name" ]] && continue + rule::exists "$rule_name" && rule::apply_transient "$rule_name" "$client_ip" || true + done <<< "$rules" + fi +} - log::wg_success "Rule '${rule_name}' re-applied to ${count} peers" +# 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 peer_rule + peer_rule=$(peers::get_meta "$peer_name" "rule") + + local strict + strict=$(rule::_get_identity_strict "$peer_name") + + if [[ "$strict" == "true" ]]; then + # Strict mode: only identity rules apply + rule::_apply_identity_rule "$peer_name" "$client_ip" + else + # Normal mode: peer rule + identity rules (additive) + [[ -n "$peer_rule" ]] && rule::apply "$peer_rule" "$client_ip" "$peer_name" + rule::_apply_identity_rule "$peer_name" "$client_ip" + fi + + block::restore_rules_for "$peer_name" "$client_ip" +} + +function rule::_get_identity_strict() { + local peer_name="${1:-}" + local identity_name + identity_name=$(identity::get_name "$peer_name") + [[ -z "$identity_name" ]] && echo "false" && return 0 + identity::rule_flags "$identity_name" "strict_rule" } 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 +323,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" } @@ -292,136 +334,19 @@ function rule::restore_all() { # ============================================ function rule::render_flat() { - local rule_name="${1:-}" - - local allow_ports allow_ips block_ips block_ports dns - allow_ports=$(rule::get "$rule_name" "allow_ports") - allow_ips=$(rule::get "$rule_name" "allow_ips") - block_ips=$(rule::get "$rule_name" "block_ips") - block_ports=$(rule::get "$rule_name" "block_ports") - dns=$(rule::get_own "$rule_name" "dns_redirect") - - local has_content=false - [[ -n "${allow_ports}${allow_ips}${block_ips}${block_ports}" ]] && \ - has_content=true - - if ! $has_content; then - printf "\n full access (no restrictions)\n" - return 0 - fi - - if [[ -n "$allow_ports" || -n "$allow_ips" ]]; then - printf "\n" - while IFS= read -r e; do - [[ -z "$e" ]] && continue - net::print_entry "+" "$e" 2 - done <<< "$allow_ports"$'\n'"$allow_ips" - fi - - if [[ -n "$block_ips" || -n "$block_ports" ]]; then - printf "\n" - while IFS= read -r e; do - [[ -z "$e" ]] && continue - net::print_entry "-" "$e" 2 - done <<< "$block_ips"$'\n'"$block_ports" - fi - - [[ "${dns,,}" == "true" ]] && \ - net::print_dns_redirect "$(config::dns)" 6 "DNS" - - return 0 + ui::rule::flat "$1" } - + 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) - allow_ips=$(rule::get "$rule_name" "allow_ips" 2>/dev/null || true) - block_ips=$(rule::get "$rule_name" "block_ips" 2>/dev/null || true) - block_ports=$(rule::get "$rule_name" "block_ports" 2>/dev/null || true) - dns=$(rule::get_own "$rule_name" "dns_redirect") - - while IFS= read -r e; do - [[ -z "$e" ]] && continue - net::print_entry "+" "$e" - done <<< "$allow_ports"$'\n'"$allow_ips" - - while IFS= read -r e; do - [[ -z "$e" ]] && continue - net::print_entry "-" "$e" - done <<< "$block_ips"$'\n'"$block_ports" - - [[ "${dns,,}" == "true" ]] && \ - net::print_dns_redirect "$(config::dns)" 6 "DNS" + ui::rule::entries "$1" } - + 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")" - - local allow_ports allow_ips block_ips block_ports dns - allow_ports=$(json::get "$rule_file" "allow_ports" 2>/dev/null || true) - allow_ips=$(json::get "$rule_file" "allow_ips" 2>/dev/null || true) - block_ips=$(json::get "$rule_file" "block_ips" 2>/dev/null || true) - block_ports=$(json::get "$rule_file" "block_ports" 2>/dev/null || true) - dns=$(json::get "$rule_file" "dns_redirect" 2>/dev/null || true) - - local has_own=false - local combined="${allow_ports}${allow_ips}${block_ips}${block_ports}" - [[ -n "${combined//[$'\n']/}" ]] && has_own=true - - $has_own || return 0 - - while IFS= read -r e; do - [[ -z "$e" ]] && continue - net::print_entry "+" "$e" - done <<< "$allow_ports"$'\n'"$allow_ips" - - while IFS= read -r e; do - [[ -z "$e" ]] && continue - net::print_entry "-" "$e" - done <<< "$block_ips"$'\n'"$block_ports" - - [[ "${dns,,}" == "true" ]] && \ - net::print_dns_redirect "$(config::dns)" 6 "DNS" - - return 0 + ui::rule::own_entries "$1" } - + 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")" - - local extends_raw=() - mapfile -t extends_raw < <(json::get "$rule_file" "extends" 2>/dev/null || true) - - [[ ${#extends_raw[@]} -eq 0 || -z "${extends_raw[0]:-}" ]] && return 1 - - for base_name in "${extends_raw[@]}"; do - [[ -z "$base_name" ]] && continue - printf "\n \033[0;37m↳ %s\033[0m\n" "$base_name" - 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 - printf "\n \033[0;37mOwn:\033[0m\n" - printf "%s\n" "$own_output" - fi - - return 0 + ui::rule::tree "$1" } # ============================================ @@ -436,4 +361,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 new file mode 100644 index 0000000..b02e94f --- /dev/null +++ b/modules/subnet.module.sh @@ -0,0 +1,362 @@ +#!/usr/bin/env bash +# subnet.module.sh — subnet map lookups, resolution, validation, and CIDR utilities +# All subnet data lives in subnets.json; this module wraps json:: calls +# and provides hardcoded fallbacks for safety. + +# ====================================================== +# CIDR Utilities +# Pure functions — no external dependencies, no side effects. +# Suitable for unit testing. +# ====================================================== + +# subnet::prefix +# Returns the first three octets of a CIDR (the allocation prefix). +# Example: 10.1.3.0/24 -> 10.1.3 +function subnet::prefix() { + echo "${1%%/*}" | cut -d'.' -f1-3 +} + +# subnet::base_ip +# Returns the network address without the mask. +# Example: 10.1.3.0/24 -> 10.1.3.0 +function subnet::base_ip() { + echo "${1%%/*}" +} + +# subnet::mask +# Returns the prefix length. +# Example: 10.1.3.0/24 -> 24 +function subnet::mask() { + echo "${1##*/}" +} + +# subnet::host_range +# Returns the iterable host range for a subnet. +# Currently supports /24 (1-254). Mask-aware implementation deferred. +function subnet::host_range() { + local cidr="${1:-}" + local mask + mask=$(subnet::mask "$cidr") + case "$mask" in + 24) seq 1 254 ;; + *) + # Fallback for non-/24 — still seq 1 254, but noted for future upgrade + seq 1 254 + ;; + esac +} + +# subnet::contains +# Returns 0 if the IP falls within the CIDR, 1 otherwise. +# Example: subnet::contains 10.1.3.0/24 10.1.3.5 -> 0 (true) +function subnet::contains() { + local cidr="${1:-}" ip="${2:-}" + [[ -z "$cidr" || -z "$ip" ]] && return 1 + + local prefix mask + prefix=$(subnet::prefix "$cidr") + mask=$(subnet::mask "$cidr") + + # For /24: check that the first three octets match + case "$mask" in + 24) + local ip_prefix + ip_prefix=$(echo "$ip" | cut -d'.' -f1-3) + [[ "$ip_prefix" == "$prefix" ]] + ;; + *) + # Delegate to Python for non-/24 subnets + python3 -c " +import ipaddress, sys +try: + net = ipaddress.ip_network('${cidr}', strict=False) + addr = ipaddress.ip_address('${ip}') + sys.exit(0 if addr in net else 1) +except Exception: + sys.exit(1) +" + ;; + esac +} + +# subnet::is_valid_cidr +# Returns 0 if the string is a valid CIDR notation. +function subnet::is_valid_cidr() { + local cidr="${1:-}" + [[ "$cidr" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/([0-9]|[1-2][0-9]|3[0-2])$ ]] || return 1 + # Also validate each octet is 0-255 + local ip + ip=$(subnet::base_ip "$cidr") + local IFS='.' + read -ra octets <<< "$ip" + for octet in "${octets[@]}"; do + (( octet >= 0 && octet <= 255 )) || return 1 + done + return 0 +} + +# subnet::ip_valid_for +# Returns 0 if the IP is a valid host address within the given CIDR. +# Excludes network address (.0) and broadcast address (.255) for /24. +function subnet::ip_valid_for() { + local cidr="${1:-}" ip="${2:-}" + [[ -z "$cidr" || -z "$ip" ]] && return 1 + + # Must be a valid IP first + ip::is_valid "$ip" || return 1 + + # Must be within the subnet + subnet::contains "$cidr" "$ip" || return 1 + + # Must not be network or broadcast address (for /24) + local mask + mask=$(subnet::mask "$cidr") + if [[ "$mask" == "24" ]]; then + local last_octet + last_octet=$(echo "$ip" | cut -d'.' -f4) + [[ "$last_octet" == "0" || "$last_octet" == "255" ]] && return 1 + fi + + return 0 +} + +# subnet::require_valid_cidr +# Errors and exits if the CIDR is not valid. +function subnet::require_valid_cidr() { + local cidr="${1:-}" + if ! subnet::is_valid_cidr "$cidr"; then + log::error "Invalid CIDR notation: '${cidr}'" + return 1 + fi +} + +# subnet::require_ip_valid_for +# Errors and exits if the IP is not a valid host for the subnet. +function subnet::require_ip_valid_for() { + local cidr="${1:-}" ip="${2:-}" + if ! subnet::ip_valid_for "$cidr" "$ip"; then + log::error "IP '${ip}' is not a valid host address in subnet '${cidr}'" + return 1 + fi +} + +# ====================================================== +# Hardcoded Fallbacks +# Mirror of production subnets.json. +# Used only when subnets.json lookup fails. +# ====================================================== + +function subnet::_hardcoded_cidr() { + local type="${1:-}" subnet_name="${2:-}" + if [[ -n "$subnet_name" ]]; then + case "$subnet_name" in + guests) echo "10.1.100.0/24" ;; + servers) echo "10.1.200.0/24" ;; + iot) echo "10.1.210.0/24" ;; + *) echo "10.1.0.0/24" ;; + esac + return 0 + fi + case "$type" in + desktop) echo "10.1.1.0/24" ;; + laptop) echo "10.1.2.0/24" ;; + phone) echo "10.1.3.0/24" ;; + tablet) echo "10.1.4.0/24" ;; + guest) echo "10.1.100.0/24" ;; + guest-desktop) echo "10.1.101.0/24" ;; + guest-laptop) echo "10.1.102.0/24" ;; + guest-phone) echo "10.1.103.0/24" ;; + guest-tablet) echo "10.1.104.0/24" ;; + server) echo "10.1.200.0/24" ;; + iot) echo "10.1.210.0/24" ;; + *) echo "10.1.0.0/24" ;; + esac +} + +function subnet::_hardcoded_type() { + local ip="${1:-}" + case "$ip" in + 10.1.1.*) echo "desktop" ;; + 10.1.2.*) echo "laptop" ;; + 10.1.3.*) echo "phone" ;; + 10.1.4.*) echo "tablet" ;; + 10.1.100.*) echo "none" ;; + 10.1.101.*) echo "desktop" ;; + 10.1.102.*) echo "laptop" ;; + 10.1.103.*) echo "phone" ;; + 10.1.104.*) echo "tablet" ;; + 10.1.200.*) echo "server" ;; + 10.1.210.*) echo "iot" ;; + *) echo "unknown" ;; + esac +} + +function subnet::_hardcoded_tunnel_mode() { + echo "split" +} + +# ====================================================== +# Core Resolution +# ====================================================== + +function subnet::policy() { + local subnet_name="${1:-}" type_key="${2:-}" + policy::for_subnet "$subnet_name" "$type_key" +} + +# subnet::lookup [type_key] +# Returns the CIDR for a given subnet name and optional type. +# Falls back to hardcoded map on failure. +function subnet::lookup() { + local subnet_name="${1:-}" type_key="${2:-}" + local result + result=$(json::subnet_lookup "$(ctx::subnets)" "$subnet_name" "$type_key" 2>/dev/null) || true + if [[ -n "$result" ]]; then + echo "$result" + return 0 + fi + subnet::_hardcoded_cidr "" "$subnet_name" +} + +# subnet::resolve_for_add [subnet_name] +# Main entry point for wgctl add — returns the CIDR to allocate from. +function subnet::resolve_for_add() { + local peer_type="${1:-}" subnet_name="${2:-}" + local result + + if [[ -n "$subnet_name" ]]; then + # Group entry: try type-specific child first, then "none" slot + if [[ -n "$peer_type" ]]; then + result=$(json::subnet_lookup "$(ctx::subnets)" "$subnet_name" "$peer_type" 2>/dev/null) || true + [[ -n "$result" ]] && { echo "$result"; return 0; } + fi + result=$(json::subnet_lookup "$(ctx::subnets)" "$subnet_name" 2>/dev/null) || true + [[ -n "$result" ]] && { echo "$result"; return 0; } + subnet::_hardcoded_cidr "" "$subnet_name" + return 0 + fi + + # No subnet_name — resolve from type (native allocation) + if [[ -n "$peer_type" ]]; then + result=$(json::subnet_lookup "$(ctx::subnets)" "$peer_type" 2>/dev/null) || true + [[ -n "$result" ]] && { echo "$result"; return 0; } + fi + + subnet::_hardcoded_cidr "$peer_type" +} + +# subnet::type_for_add [subnet_name] +# Returns the canonical type string to store in meta. +function subnet::type_for_add() { + local type_flag="${1:-}" subnet_name="${2:-}" + local result + + if [[ -n "$subnet_name" ]]; then + result=$(json::subnet_type "$(ctx::subnets)" "$subnet_name" "$type_flag" 2>/dev/null) || true + [[ -n "$result" ]] && { echo "$result"; return 0; } + fi + + echo "${type_flag:-none}" +} + +# subnet::tunnel_mode [type_key] +# Returns "split" or "full" for the given subnet. +function subnet::tunnel_mode() { + local subnet_name="${1:-}" type_key="${2:-}" + local policy_name + policy_name=$(policy::for_subnet "$subnet_name" "$type_key") + policy::tunnel_mode "$policy_name" +} + +function subnet::type_from_ip() { + local ip="${1:-}" + [[ -z "$ip" ]] && echo "unknown" && return 0 + + # Fast path: hardcoded map covers all production subnets — pure bash, no subshell + local type + type=$(subnet::_hardcoded_type "$ip") + if [[ "$type" != "unknown" ]]; then + echo "$type" + return 0 + fi + + # Slow path: Python lookup for dynamically-added subnets not in hardcoded map + local result + result=$(json::subnet_for_ip "$(ctx::subnets)" "$ip" 2>/dev/null) || true + if [[ -n "$result" ]]; then + echo "${result##*|}" + return 0 + fi + + echo "unknown" +} + +# subnet::name_from_ip +# Returns the subnet name (e.g. "guests", "desktop") for an IP. +function subnet::name_from_ip() { + local ip="${1:-}" + local result + result=$(json::subnet_for_ip "$(ctx::subnets)" "$ip" 2>/dev/null) || true + [[ -n "$result" ]] && { echo "${result%%|*}"; return 0; } + echo "" +} + +# Returns the default rule for a subnet, or empty string if none configured. +# Called by add.command.sh to resolve the rule before falling back to "user". +function subnet::default_rule() { + local subnet_name="${1:-}" type_key="${2:-}" + [[ -z "$subnet_name" ]] && return 0 + local policy_name + policy_name=$(policy::for_subnet "$subnet_name" "$type_key") + policy::default_rule "$policy_name" +} + +# subnet::list_names +# Returns all top-level subnet names, one per line. +# Used by commands to dynamically register -- flags. +function subnet::list_names() { + json::subnet_list_names "$(ctx::subnets)" 2>/dev/null || true +} + +# ====================================================== +# Validation +# ====================================================== + +function subnet::exists() { + local name="${1:-}" + json::subnet_exists "$(ctx::subnets)" "$name" 2>/dev/null +} + +function subnet::require_exists() { + local name="${1:-}" + if ! subnet::exists "$name"; then + log::error "Subnet '${name}' not found. Use 'wgctl subnet list' to see available subnets." + return 1 + fi +} + +function subnet::peers_using() { + local subnet_name="${1:-}" + local peers + peers=$(json::subnet_peers \ + "$(ctx::meta)" \ + "$(ctx::clients)" \ + "$subnet_name" \ + "$(ctx::subnets)" \ + 2>/dev/null) || true + echo "$peers" | tr '\n' ',' | sed 's/,$//' +} + + +# ====================================================== +# Display Data +# ====================================================== + +function subnet::list_data() { + json::subnet_list "$(ctx::subnets)" 2>/dev/null || true +} + +function subnet::show_data() { + local name="${1:-}" + json::subnet_show "$(ctx::subnets)" "$name" +} diff --git a/modules/ui.module.sh b/modules/ui.module.sh new file mode 100644 index 0000000..995a81d --- /dev/null +++ b/modules/ui.module.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# ui.module.sh — wgctl rendering layer +# Loads all submodules from ui/ and provides any truly shared +# rendering primitives used across multiple submodules. + +# ============================================ +# Lifecycle +# ============================================ + +function ui::on_load() { + load_module "ui/*" +} \ No newline at end of file diff --git a/modules/ui/identity.module.sh b/modules/ui/identity.module.sh new file mode 100644 index 0000000..a4bab5d --- /dev/null +++ b/modules/ui/identity.module.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# ui/identity.module.sh — rendering for identity data +# All functions pure rendering — no writes, no state changes. + +function ui::identity::header() { + printf " %-20s %-7s %s\n" "IDENTITY" "PEERS" "DEVICE TYPES" + ui::divider 54 +} + +function ui::identity::row() { + local name="${1:-}" peer_count="${2:-}" types="${3:-}" + local types_display="${types//,/, }" + [[ -z "$types_display" ]] && types_display="—" + printf " %-20s %-7s %s\n" "$name" "$peer_count" "$types_display" +} + +function ui::identity::detail_name() { + local name="${1:-}" peer_count="${2:-}" + echo "" + ui::row "Identity" "$name" + ui::row "Peers" "$peer_count" + echo "" +} + +function ui::identity::device_row() { + local peer_name="${1:-}" dev_type="${2:-}" \ + dev_index="${3:-1}" status="${4:-}" + local suffix="" + [[ "$dev_index" -gt 1 ]] && suffix=" (#${dev_index})" + printf " · %-24s %-10s%s%s\n" \ + "$peer_name" "$dev_type" "$suffix" "$status" +} + +function ui::identity::migrate_create() { + local peer_name="${1:-}" identity_name="${2:-}" \ + peer_type="${3:-}" index="${4:-}" + printf " %s %-22s → identity %-14s %-10s #%s\n" \ + "$(color::green "+")" "$peer_name" "$identity_name" "$peer_type" "$index" +} + +function ui::identity::migrate_skip() { + local peer_name="${1:-}" + printf " %s %-22s (no convention match)\n" \ + "$(color::dim "·")" "$peer_name" +} + +function ui::identity::migrate_summary() { + local created="${1:-0}" skipped="${2:-0}" dry_run="${3:-false}" + echo "" + if [[ "$dry_run" == "true" ]]; then + log::info "Would create entries for ${created} peers (${skipped} skipped)" + else + log::ok "Created identity entries for ${created} peers (${skipped} skipped)" + fi +} + +function ui::identity::list_row_compact() { + local name="${1:-}" peer_count="${2:-}" rules_list="${3:-}" policy="${4:-}" + local peer_word="peers" + [[ "$peer_count" -eq 1 ]] && peer_word="peer" + + local peers_col="${peer_count} ${peer_word}" + local rules_val="-" + [[ -n "$rules_list" ]] && rules_val="$rules_list" + + # Pad rules_val to fixed width before adding any ANSI + local pad=16 + local rules_padded + rules_padded=$(printf "%-${pad}s" "$rules_val") + + printf " \033[1m%-20s\033[0m %-10s \033[2mrules:\033[0m %s \033[2mpolicy:\033[0m %s\n" \ + "$name" "$peers_col" "$rules_padded" "$policy" +} + + +function ui::identity::list_row_table() { + local name="${1:-}" peer_count="${2:-}" types="${3:-}" + local types_display="${types//,/, }" + [[ -z "$types_display" ]] && types_display="—" + printf " %-20s %-7s %s\n" "$name" "$peer_count" "$types_display" +} \ No newline at end of file diff --git a/modules/ui/peer.module.sh b/modules/ui/peer.module.sh new file mode 100644 index 0000000..2772b4f --- /dev/null +++ b/modules/ui/peer.module.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# ui/peer.module.sh — rendering for peer list data +# Both compact (tableless) and table layouts kept for future config switching. + +_LIST_STYLE="${LIST_STYLE:-compact}" + +function ui::peer::list_style() { + echo "$_LIST_STYLE" +} + +# ====================================================== +# Compact layout (tableless) +# ====================================================== + +function ui::peer::list_row_compact() { + local w_name="${1:-22}" w_ip="${2:-14}" w_type="${3:-9}" \ + w_rule="${4:-6}" w_group="${5:-6}" + shift 5 + local name="${1:-}" ip="${2:-}" type="${3:-}" rule="${4:-}" \ + group="${5:-}" status="${6:-}" last_seen="${7:-}" \ + is_blocked="${8:-false}" is_restricted="${9:-false}" + + local status_color="\033[0;37m" + if [[ "$is_blocked" == "true" ]]; then + status_color="\033[1;31m" + elif [[ "$is_restricted" == "true" ]]; then + status_color="\033[1;33m" + elif [[ "$status" == "online" ]]; then + status_color="\033[1;32m" + fi + + local ls_color="\033[0;37m" + [[ "$status" == "online" ]] && ls_color="\033[1;32m" + + local rule_val="${rule:--}" + local group_val="${group:--}" + + # Pad name, ip, type — pure ASCII, safe for printf + local name_pad ip_pad type_pad status_pad + name_pad=$(printf "%-${w_name}s" "$name") + ip_pad=$(printf "%-${w_ip}s" "$ip") + type_pad=$(printf "%-${w_type}s" "$type") + status_pad=$(printf "%-8s" "$status") + + # Padding for label+value fields — compute trailing spaces manually + # so ANSI codes in labels don't confuse printf width calculation + local rule_pad_n group_pad_n + rule_pad_n=$(( w_rule - ${#rule_val} )) + group_pad_n=$(( w_group - ${#group_val} )) + [[ $rule_pad_n -lt 0 ]] && rule_pad_n=0 + [[ $group_pad_n -lt 0 ]] && group_pad_n=0 + + printf " %s %s %s \033[2mrule:\033[0m %s%*s \033[2mgroup:\033[0m %s%*s %b%s\033[0m %b%s\033[0m\n" \ + "$name_pad" "$ip_pad" "$type_pad" \ + "$rule_val" "$rule_pad_n" "" \ + "$group_val" "$group_pad_n" "" \ + "$status_color" "$status_pad" \ + "$ls_color" "$last_seen" +} + +# ====================================================== +# Table layout (kept for config switching) +# ====================================================== + +function ui::peer::list_header_table() { + local has_groups="${1:-false}" + if $has_groups; then + printf "\n %-28s %-15s %-13s %-12s %-12s %-22s %s\n" \ + "NAME" "IP" "TYPE" "RULE" "GROUP" "STATUS" "LAST SEEN" + printf " %s\n" "$(printf '─%.0s' {1..135})" + else + printf "\n %-28s %-15s %-13s %-12s %-22s %s\n" \ + "NAME" "IP" "TYPE" "RULE" "STATUS" "LAST SEEN" + printf " %s\n" "$(printf '─%.0s' {1..107})" + fi +} + +function ui::peer::list_footer_table() { + local has_groups="${1:-false}" + if $has_groups; then + printf " %s\n" "$(printf '─%.0s' {1..135})" + else + printf " %s\n" "$(printf '─%.0s' {1..107})" + fi +} + +function ui::peer::list_row_table() { + local has_groups="${1:-false}" + shift + local name="${1:-}" ip="${2:-}" type="${3:-}" rule="${4:-}" \ + group="${5:-}" status="${6:-}" last_seen="${7:-}" + + local padded_status + padded_status=$(ui::pad_status "$status" 25) + + if $has_groups; then + printf " %-28s %-15s %-13s %-12s %-12s %s %s\n" \ + "$name" "$ip" "$type" "${rule:-—}" "${group:-—}" \ + "$padded_status" "$last_seen" + else + printf " %-28s %-15s %-13s %-12s %s %s\n" \ + "$name" "$ip" "$type" "${rule:-—}" \ + "$padded_status" "$last_seen" + fi +} + +# ====================================================== +# Detailed layout (grouped by identity) +# ====================================================== + +function ui::peer::list_identity_header() { + local identity_name="${1:-}" + printf "\n \033[1m%s\033[0m\n" "$identity_name" +} + +function ui::peer::list_row_detailed() { + local w_name="${1:-22}" w_ip="${2:-14}" w_type="${3:-9}" \ + w_rule="${4:-6}" w_group="${5:-6}" w_subnet="${6:-8}" + shift 6 + local name="${1:-}" ip="${2:-}" type="${3:-}" rule="${4:-}" \ + group="${5:-}" subnet="${6:-}" status="${7:-}" last_seen="${8:-}" \ + is_blocked="${9:-false}" is_restricted="${10:-false}" + + local status_color="\033[0;37m" + if [[ "$is_blocked" == "true" ]]; then + status_color="\033[1;31m" + elif [[ "$is_restricted" == "true" ]]; then + status_color="\033[1;33m" + elif [[ "$status" == "online" ]]; then + status_color="\033[1;32m" + fi + + local ls_color="\033[0;37m" + [[ "$status" == "online" ]] && ls_color="\033[1;32m" + + local rule_val="${rule:-—}" + local group_val="${group:-—}" + local subnet_val="${subnet:-—}" + + local name_pad ip_pad type_pad status_pad + name_pad=$(printf "%-${w_name}s" "$name") + ip_pad=$(printf "%-${w_ip}s" "$ip") + type_pad=$(printf "%-${w_type}s" "$type") + status_pad=$(printf "%-8s" "$status") + + local rule_pad_n group_pad_n subnet_pad_n + rule_pad_n=$(( w_rule - ${#rule_val} )) + group_pad_n=$(( w_group - ${#group_val} )) + subnet_pad_n=$(( w_subnet - ${#subnet_val} )) + [[ $rule_pad_n -lt 0 ]] && rule_pad_n=0 + [[ $group_pad_n -lt 0 ]] && group_pad_n=0 + [[ $subnet_pad_n -lt 0 ]] && subnet_pad_n=0 + + printf " · %s %s %s \033[2mrule:\033[0m %s%*s \033[2mgroup:\033[0m %s%*s \033[2msubnet:\033[0m %s%*s %b%s\033[0m %b%s\033[0m\n" \ + "$name_pad" "$ip_pad" "$type_pad" \ + "$rule_val" "$rule_pad_n" "" \ + "$group_val" "$group_pad_n" "" \ + "$subnet_val" "$subnet_pad_n" "" \ + "$status_color" "$status_pad" \ + "$ls_color" "$last_seen" +} \ No newline at end of file diff --git a/modules/ui/rule.module.sh b/modules/ui/rule.module.sh new file mode 100644 index 0000000..c385775 --- /dev/null +++ b/modules/ui/rule.module.sh @@ -0,0 +1,257 @@ +#!/usr/bin/env bash +# ui/rule.module.sh — rendering for rule data +# Replaces rule::render_* functions from rule.module.sh. +# All functions pure rendering — no writes, no state changes. + +# ====================================================== +# Entry Rendering (shared primitives) +# ====================================================== + +# ui::rule::entries [indent] +# Renders the fully resolved entries for a rule (allow + block). +function ui::rule::entries() { + local rule_name="${1:-}" indent="${2:-4}" + + local allow_ports allow_ips block_ips block_ports dns + allow_ports=$(rule::get "$rule_name" "allow_ports" 2>/dev/null || true) + allow_ips=$(rule::get "$rule_name" "allow_ips" 2>/dev/null || true) + block_ips=$(rule::get "$rule_name" "block_ips" 2>/dev/null || true) + block_ports=$(rule::get "$rule_name" "block_ports" 2>/dev/null || true) + dns=$(rule::get_own "$rule_name" "dns_redirect") + + while IFS= read -r e; do + [[ -z "$e" ]] && continue + net::print_entry "+" "$e" "$indent" + done <<< "$allow_ports"$'\n'"$allow_ips" + + while IFS= read -r e; do + [[ -z "$e" ]] && continue + net::print_entry "-" "$e" "$indent" + done <<< "$block_ips"$'\n'"$block_ports" + + [[ "${dns,,}" == "true" ]] && \ + net::print_dns_redirect "$(config::dns)" 6 "DNS" +} + +# ui::rule::own_entries [indent] +# Renders only the rule's own (non-inherited) entries. +function ui::rule::own_entries() { + local rule_name="${1:-}" indent="${2:-4}" + local rule_file + rule_file="$(rule::path "$rule_name")" || return 0 + [[ -z "$rule_file" ]] && return 0 + + local allow_ports allow_ips block_ips block_ports dns + allow_ports=$(json::get "$rule_file" "allow_ports" 2>/dev/null || true) + allow_ips=$(json::get "$rule_file" "allow_ips" 2>/dev/null || true) + block_ips=$(json::get "$rule_file" "block_ips" 2>/dev/null || true) + block_ports=$(json::get "$rule_file" "block_ports" 2>/dev/null || true) + dns=$(json::get "$rule_file" "dns_redirect" 2>/dev/null || true) + + local combined="${allow_ports}${allow_ips}${block_ips}${block_ports}" + [[ -z "${combined//[$'\n']/}" ]] && return 0 + + while IFS= read -r e; do + [[ -z "$e" ]] && continue + net::print_entry "+" "$e" "$indent" + done <<< "$allow_ports"$'\n'"$allow_ips" + + while IFS= read -r e; do + [[ -z "$e" ]] && continue + net::print_entry "-" "$e" "$indent" + done <<< "$block_ips"$'\n'"$block_ports" + + [[ "${dns,,}" == "true" ]] && \ + net::print_dns_redirect "$(config::dns)" 6 "DNS" +} + +# ui::rule::flat +# Renders the full resolved entries as a flat list. +function ui::rule::flat() { + local rule_name="${1:-}" + + local allow_ports allow_ips block_ips block_ports dns + allow_ports=$(rule::get "$rule_name" "allow_ports") + allow_ips=$(rule::get "$rule_name" "allow_ips") + block_ips=$(rule::get "$rule_name" "block_ips") + block_ports=$(rule::get "$rule_name" "block_ports") + dns=$(rule::get_own "$rule_name" "dns_redirect") + + local has_content=false + [[ -n "${allow_ports}${allow_ips}${block_ips}${block_ports}" ]] && has_content=true + + if ! $has_content; then + printf "\n full access (no restrictions)\n" + return 0 + fi + + if [[ -n "$allow_ports" || -n "$allow_ips" ]]; then + printf "\n" + while IFS= read -r e; do + [[ -z "$e" ]] && continue + net::print_entry "+" "$e" 2 + done <<< "$allow_ports"$'\n'"$allow_ips" + fi + + if [[ -n "$block_ips" || -n "$block_ports" ]]; then + printf "\n" + while IFS= read -r e; do + [[ -z "$e" ]] && continue + net::print_entry "-" "$e" 2 + done <<< "$block_ips"$'\n'"$block_ports" + fi + + [[ "${dns,,}" == "true" ]] && \ + net::print_dns_redirect "$(config::dns)" 6 "DNS" +} + +# ====================================================== +# Shared Base Renderer +# ====================================================== + +# ui::rule::_render_bases [entry_indent] [label_indent] +# Renders a list of base rule names with newlines between them. +# Used by both ui::rule::tree and ui::rule::_identity_rule_entry. +function ui::rule::_render_bases() { + local -n _bases="$1" + local entry_indent="${2:-6}" label_indent="${3:-4}" + local label_pad + label_pad=$(printf '%*s' "$label_indent" '') + + local first=true + for base_name in "${_bases[@]}"; do + [[ -z "$base_name" ]] && continue + $first || printf "\n" + first=false + printf "%s\033[0;37m↳ %s\033[0m\n" "$label_pad" "$base_name" + ui::rule::entries "$base_name" "$entry_indent" + done +} + +# ====================================================== +# Tree Rendering +# ====================================================== + +# ui::rule::tree +# Renders a rule's extends tree — one level deep with own entries. +# Returns 1 if rule has no extends (caller can fall back to flat). +function ui::rule::tree() { + local rule_name="${1:-}" + local rule_file + rule_file="$(rule::path "$rule_name")" || return 1 + [[ -z "$rule_file" ]] && return 1 + + local extends_raw=() + mapfile -t extends_raw < <(json::get "$rule_file" "extends" 2>/dev/null || true) || true + + if [[ ${#extends_raw[@]} -eq 0 || -z "${extends_raw[0]:-}" ]]; then + return 1 + fi + + ui::rule::_render_bases extends_raw 6 4 + + local own_output + own_output=$(ui::rule::own_entries "$rule_name" 6) + if [[ -n "$own_output" ]]; then + printf "\n \033[0;37mOwn:\033[0m\n" + printf "%s\n" "$own_output" + fi + + return 0 +} + +# ====================================================== +# Identity Rule Block +# ====================================================== + +# ui::rule::identity_block +# Renders the full identity rule block in inspect. +function ui::rule::identity_block() { + local identity_name="${1:-}" strict="${2:-false}" + + local rules + rules=$(identity::rules "$identity_name") + [[ -z "$rules" ]] && return 0 + + printf "\n \033[0;37m· identity:%s\033[0m\n" "$identity_name" + + local first=true + while IFS= read -r rule_name; do + [[ -z "$rule_name" ]] && continue + $first || printf "\n" + first=false + ui::rule::_identity_rule_entry "$rule_name" + done <<< "$rules" + + if [[ "$strict" == "true" ]]; then + printf "\n \033[2m(strict — peer rule suppressed)\033[0m\n" + fi +} + +# ui::rule::_identity_rule_entry +# Renders one rule within an identity block. +function ui::rule::_identity_rule_entry() { + local rule_name="${1:-}" + local rule_file + rule_file="$(rule::path "$rule_name")" || return 0 + + printf " \033[0;37m↳ %s\033[0m\n" "$rule_name" + + local extends_raw=() + mapfile -t extends_raw < <(json::get "$rule_file" "extends" 2>/dev/null || true) || true + + if [[ ${#extends_raw[@]} -gt 0 && -n "${extends_raw[0]:-}" ]]; then + # Rule has extends — render one level deep using shared helper + ui::rule::_render_bases extends_raw 10 8 + + local own_output + own_output=$(ui::rule::own_entries "$rule_name" 10) + if [[ -n "$own_output" ]]; then + printf "\n \033[0;37mOwn:\033[0m\n" + printf "%s\n" "$own_output" + fi + else + # Leaf rule — show own entries or note full access + local own_output + own_output=$(ui::rule::own_entries "$rule_name" 8) + if [[ -n "$own_output" ]]; then + printf "%s\n" "$own_output" + else + printf " \033[2mfull access (no restrictions)\033[0m\n" + fi + fi +} + +# ====================================================== +# Peer Entry +# ====================================================== + +function ui::rule::_peer_rule_entry() { + local rule_name="${1:-}" + local rule_file + rule_file="$(rule::path "$rule_name")" || return 0 + + printf " \033[0;37m↳ %s\033[0m\n" "$rule_name" + + local extends_raw=() + mapfile -t extends_raw < <(json::get "$rule_file" "extends" 2>/dev/null || true) || true + + if [[ ${#extends_raw[@]} -gt 0 && -n "${extends_raw[0]:-}" ]]; then + ui::rule::_render_bases extends_raw 10 8 + + local own_output + own_output=$(ui::rule::own_entries "$rule_name" 10) + if [[ -n "$own_output" ]]; then + printf "\n \033[0;37mOwn:\033[0m\n" + printf "%s\n" "$own_output" + fi + else + local own_output + own_output=$(ui::rule::own_entries "$rule_name" 8) + if [[ -n "$own_output" ]]; then + printf "%s\n" "$own_output" + else + printf " \033[2mfull access (no restrictions)\033[0m\n" + fi + fi +} \ No newline at end of file diff --git a/modules/ui/subnet.module.sh b/modules/ui/subnet.module.sh new file mode 100644 index 0000000..9c7d9c5 --- /dev/null +++ b/modules/ui/subnet.module.sh @@ -0,0 +1,213 @@ +#!/usr/bin/env bash +# ui/subnet.module.sh — rendering for subnet data +# All functions pure rendering — no writes, no state changes. + +function ui::subnet::header() { + printf " %-14s %-18s %-10s %-8s %s\n" \ + "NAME" "SUBNET" "TYPE" "TUNNEL" "DESCRIPTION" + ui::divider 70 +} + +function ui::policy::list_row() { + local name="${1:-}" default_rule="${2:-}" strict="${3:-}" auto="${4:-}" + + local rule_val="-" + [[ -n "$default_rule" ]] && rule_val="$default_rule" + + local rule_padded + rule_padded=$(printf "%-16s" "$rule_val") + + local strict_display + [[ "$strict" == "true" ]] && strict_display="yes" || strict_display="no" + local strict_padded + strict_padded=$(printf "%-4s" "$strict_display") + + local auto_display="" + [[ "$auto" == "false" ]] && auto_display=" \033[2mauto:\033[0m no" + + printf " %-14s \033[2mrule:\033[0m %s \033[2mstrict:\033[0m %s%s\n" \ + "$name" "$rule_padded" "$strict_padded" "$auto_display" +} + +function ui::policy::detail_field() { + local key="${1:-}" value="${2:-}" + ui::row "$key" "$value" +} + + +function ui::subnet::row() { + local display_name="${1:-}" subnet="${2:-}" type_key="${3:-}" \ + tunnel_mode="${4:-}" desc="${5:-}" is_group="${6:-false}" + local name_col="$display_name" + [[ "$is_group" == "true" ]] && name_col=" ${display_name}" + printf " %-14s %-18s %-10s %-8s %s\n" \ + "$name_col" "$subnet" "$type_key" "$tunnel_mode" "$desc" +} + +function ui::subnet::row_scalar() { + local name="${1:-}" subnet="${2:-}" tunnel="${3:-split}" + local tunnel_val + tunnel_val=$(printf "%-8s" "$tunnel") + printf " %-14s %-18s \033[2mtunnel:\033[0m %s\n" \ + "$name" "$subnet" "$tunnel_val" +} + +function ui::subnet::row_group_parent() { + local name="${1:-}" + printf " \033[1m%s\033[0m\n" "$name" +} + +function ui::subnet::row_group_child() { + local type_key="${1:-}" subnet="${2:-}" tunnel="${3:-split}" + local tunnel_val + tunnel_val=$(printf "%-8s" "$tunnel") + printf " · %-10s %-18s \033[2mtunnel:\033[0m %s\n" \ + "$type_key" "$subnet" "$tunnel_val" +} + +function ui::subnet::group_separator() { + echo "" +} + +function ui::subnet::detail() { + local name="${1:-}" is_group="${2:-false}" + ui::row "Name" "$name" + ui::row "Type" "$( [[ "$is_group" == "true" ]] && echo "group" || echo "scalar" )" +} + +function ui::subnet::detail_field() { + local key="${1:-}" value="${2:-}" + ui::row "$key" "$value" +} + +function ui::subnet::child_header() { + printf "\n" + printf " %-12s %-18s %-8s %s\n" "TYPE" "SUBNET" "TUNNEL" "DESCRIPTION" + ui::divider 56 +} + +function ui::subnet::child_row() { + local type_key="${1:-}" subnet="${2:-}" tunnel_mode="${3:-}" desc="${4:-}" + printf " %-12s %-18s %-8s %s\n" "$type_key" "$subnet" "$tunnel_mode" "$desc" +} + +function ui::subnet::peers_in_use() { + local peers_csv="${1:-}" + [[ -z "$peers_csv" ]] && return 0 + echo "" + ui::row "Peers using" "${peers_csv//,/, }" +} + +function ui::subnet::show_scalar() { + local name="${1:-}" subnet="${2:-}" tunnel="${3:-}" desc="${4:-}" + echo "" + printf " \033[1m%-16s\033[0m %-18s \033[2mtunnel:\033[0m %s\n" \ + "$name" "$subnet" "$tunnel" + echo "" + [[ -n "$desc" ]] && printf " \033[2mDescription:\033[0m %s\n" "$desc" +} + +function ui::subnet::show_group() { + local name="${1:-}" + echo "" + printf " \033[1m%s\033[0m\n" "$name" + echo "" +} + +function ui::subnet::show_child_row() { + local type_key="${1:-}" subnet="${2:-}" tunnel="${3:-}" desc="${4:-}" + local desc_part="" + [[ -n "$desc" ]] && desc_part=" $desc" + printf " · %-10s %-18s \033[2mtunnel:\033[0m %-8s%s\n" \ + "$type_key" "$subnet" "$tunnel" "$desc_part" +} + +function ui::subnet::show_peers() { + local peers_csv="${1:-}" + [[ -z "$peers_csv" ]] && return 0 + + echo "" + local peers_arr=() + IFS=',' read -ra peers_arr <<< "$peers_csv" + local count="${#peers_arr[@]}" + + printf " Peers (%s):\n" "$count" + + # Group peers by identity, then by subnet child (for groups) + # Build: identity -> list of peer names + declare -A identity_peers_map=() + local no_identity=() + + for peer in "${peers_arr[@]}"; do + peer="${peer// /}" + [[ -z "$peer" ]] && continue + local id_name + id_name=$(identity::get_name "$peer") + if [[ -n "$id_name" ]]; then + identity_peers_map["$id_name"]+="${peer}," + else + no_identity+=("$peer") + fi + done + + # Print identity groups on same line + for id_name in "${!identity_peers_map[@]}"; do + local peer_list="${identity_peers_map[$id_name]%,}" + printf " · %s\n" "${peer_list//,/, }" + done + + # Print peers without identity one per line + for peer in "${no_identity[@]}"; do + printf " · %s\n" "$peer" + done +} + +function ui::subnet::show_peers_annotated() { + # For group subnets — peers annotated with their subnet child + local peers_csv="${1:-}" subnets_file="${2:-}" + [[ -z "$peers_csv" ]] && return 0 + + local peers_arr=() + IFS=',' read -ra peers_arr <<< "$peers_csv" + local count="${#peers_arr[@]}" + + echo "" + printf " Peers (%s):\n" "$count" + + declare -A identity_peers_map=() # identity -> "peer→child,peer→child" + local no_identity=() + + for peer in "${peers_arr[@]}"; do + peer="${peer// /}" + [[ -z "$peer" ]] && continue + + # Find which child subnet this peer's IP belongs to + local peer_ip child_name="" + peer_ip=$(peers::get_ip "$peer" 2>/dev/null) || peer_ip="" + if [[ -n "$peer_ip" ]]; then + local result + result=$(json::subnet_for_ip "$subnets_file" "$peer_ip" 2>/dev/null) || true + [[ -n "$result" ]] && child_name="${result##*|}" + fi + + local annotated="${peer}" + [[ -n "$child_name" ]] && annotated="${peer} \033[2m→ ${child_name}\033[0m" + + local id_name + id_name=$(identity::get_name "$peer") + if [[ -n "$id_name" ]]; then + identity_peers_map["$id_name"]+="${annotated}," + else + no_identity+=("$annotated") + fi + done + + for id_name in "${!identity_peers_map[@]}"; do + local peer_list="${identity_peers_map[$id_name]%,}" + printf " · %b\n" "${peer_list//,/, }" + done + + for peer in "${no_identity[@]}"; do + printf " · %b\n" "$peer" + done +} \ No newline at end of file diff --git a/wgctl b/wgctl index 2d19499..bb82c82 100755 --- a/wgctl +++ b/wgctl @@ -1,5 +1,5 @@ #!/usr/bin/env bash -set -Eeuo pipefail +set -uo pipefail source "$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)/core.sh" @@ -9,9 +9,9 @@ LOG_LEVEL=DEBUG # Modules # ============================================ -load_module log -load_module config load_module ip +load_module ui +load_module config load_module keys load_module peers load_module firewall @@ -20,6 +20,8 @@ load_module rule load_module block load_module net load_module group +load_module subnet +load_module identity # ============================================ # Alias Map