#!/usr/bin/env bash # ============================================ # 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 --subnet flag::register --rule flag::register --group flag::register --ip flag::register --tunnel 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) — 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 --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 EOF } # ============================================ # Validation # ============================================ 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 ! 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')" return 1 fi if [[ -n "$ip" ]]; then ip::require_valid "$ip" fi } 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="" identity="" type="" subnet_name="" rule="" \ group="" ip="" tunnel="" show_qr=false while [[ $# -gt 0 ]]; do case "$1" in --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 return 1 ;; esac done 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 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" } # ============================================ # 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:-}" 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 }