#!/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 flag::register --rule 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 flag::register --migrate command::mixin json_output } # ============================================ # Help # ============================================ function cmd::identity::help() { cat < [options] Manage peer identities — group peers by person/device owner. Subcommands: list List all identities show --name Show identity details with peers and rule tree add --name Create a new identity remove --name Remove an identity migrate Migrate peers to identities rule assign --name --rule Assign rule to identity Blocked if peer already has rule directly [--migrate] Remove conflicting direct peer rules first rule unassign --name --rule Remove rule from identity rule unassign --name --all Remove all rules from identity options --name --strict-rule Set strict rule mode options --name --auto-apply Set auto apply Examples: wgctl identity list wgctl identity show --name nuno wgctl identity add --name alice wgctl identity rule assign --name nuno --rule admin wgctl identity rule assign --name nuno --rule user --migrate wgctl identity rule unassign --name nuno --rule admin wgctl identity options --name nuno --strict-rule true EOF } # ============================================ # Run # ============================================ function cmd::identity::run() { local subcmd="${1:-list}" shift || true if command::json && [[ "$subcmd" == "list" ]]; then cmd::identity::_output_json return 0 fi 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 | ui::sort_rows 1) 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 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" # Rules tree local identity_rules identity_rules=$(identity::rules "$name") if [[ -n "$identity_rules" ]]; then printf "\n \033[2m── Rules \033[0m%s\n\n" \ "$(printf '\033[2m─%.0s' {1..38})" ui::rule::identity_block "$name" "$strict" --no-header fi 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="" migrate=false while [[ $# -gt 0 ]]; do case "$1" in --name) name="$2"; shift 2 ;; --rule) rule="$2"; shift 2 ;; --migrate) migrate=true; shift ;; *) 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 conflicts=() while IFS= read -r peer_name; do [[ -z "$peer_name" ]] && continue local peer_rule peer_rule=$(peers::get_meta "$peer_name" "rule" 2>/dev/null) [[ "$peer_rule" == "$rule" ]] && conflicts+=("$peer_name") done < <(identity::peers "$name") if [[ ${#conflicts[@]} -gt 0 ]]; then if ! $migrate; then log::error "The following peers have '${rule}' as a direct rule: ${conflicts[*]}" log::error "Use --migrate to remove direct rules and let the identity rule take over." return 1 fi # Migrate — remove direct rules from conflicting peers for peer_name in "${conflicts[@]}"; do local ip ip=$(peers::get_ip "$peer_name") rule::unapply "$rule" "$ip" log::wg "Migrated '${rule}' from peer '${peer_name}' to identity '${name}'" done fi local exit_code=0 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 } function cmd::identity::_output_json() { local data data=$(identity::list_data 2>/dev/null) local -a identities=() while IFS='|' read -r name peer_count types rules policy; do [[ -z "$name" ]] && continue # Build rules array local rules_json="[]" if [[ -n "$rules" ]]; then local rules_array rules_array=$(echo "$rules" | tr ',' '\n' | \ while IFS= read -r r; do [[ -n "$r" ]] && printf '"%s",' "$r"; done | sed 's/,$//') rules_json="[${rules_array}]" fi # Build types array (was comma-separated string) local types_json="[]" if [[ -n "$types" ]]; then local types_array types_array=$(echo "$types" | tr ',' '\n' | \ while IFS= read -r t; do [[ -n "$t" ]] && printf '"%s",' "$t"; done | sed 's/,$//') types_json="[${types_array}]" fi identities+=("$(printf '{"name":"%s","peer_count":%s,"types":%s,"rules":%s,"policy":"%s"}' \ "$name" "$peer_count" "$types_json" "$rules_json" "$policy")") done <<< "$data" local count=${#identities[@]} local array array=$(printf '%s\n' "${identities[@]:-}" | paste -sd ',' -) printf '{"identities":[%s]}' "${array:-}" | json::envelope "identity list" "$count" }