identity.command.sh
· 19 KiB · Bash
Ham
#!/usr/bin/env bash
# identity.command.sh — manage peer identities
#
# Subcommands:
# wgctl identity list
# wgctl identity show --name <name>
# wgctl identity add --name <name> --peer <peer>
# wgctl identity remove --name <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 <<EOF
Usage: wgctl identity <subcommand> [options]
Manage peer identities — group peers by person/device owner.
Subcommands:
list List all identities
show --name <n> Show identity details with peers and rule tree
add --name <n> Create a new identity
remove --name <n> Remove an identity
migrate Migrate peers to identities
rule assign --name <n> --rule <r> Assign rule to identity
Blocked if peer already has rule directly
[--migrate] Remove conflicting direct peer rules first
rule unassign --name <n> --rule <r> Remove rule from identity
rule unassign --name <n> --all Remove all rules from identity
options --name <n> --strict-rule <bool> Set strict rule mode
options --name <n> --auto-apply <bool> 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
if display::is_table "identity_list"; then
cmd::identity::_render_table "$data"
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::_render_table() {
local data="${1:-}"
[[ -z "$data" ]] && return 0
printf "\n %-20s %-8s %-20s %s\n" "NAME" "PEERS" "RULES" "POLICY"
printf " %s\n" "$(printf '─%.0s' {1..65})"
while IFS='|' read -r name peer_count types rules policy; do
[[ -z "$name" ]] && continue
local rules_display
rules_display=$(echo "$rules" | sed 's/,/, /g')
ui::identity::list_row_table "$name" "$peer_count" "$rules_display" "$policy"
done <<< "$data"
printf " %s\n\n" "$(printf '─%.0s' {1..65})"
}
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
log::ok "Added '${peer}' to identity '${name}' (${peer_type} #${index})"
}
function cmd::identity::_require_peer_exists() {
local peer="${1:-}"
if [[ ! -f "$(ctx::clients)/${peer}.conf" ]]; then
log::error "Peer '${peer}' not found"
return 1
fi
}
function cmd::identity::_resolve_peer_type() {
local peer="${1:-}" identity_name="${2:-}"
local inferred
inferred=$(identity::infer "$peer")
if [[ -n "$inferred" ]]; then
echo "$inferred" | cut -d'|' -f2
else
peers::get_meta "$peer" "type" 2>/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
log::ok "Identity '${name}' removed"
}
function cmd::identity::_remove_all_peers() {
local peers="${1:-}"
while IFS= read -r peer_name; do
[[ -z "$peer_name" ]] && continue
cmd::identity::_remove_peer "$peer_name"
done <<< "$peers"
}
function cmd::identity::_remove_peer() {
local peer_name="${1:-}"
local client_ip was_blocked=false
client_ip=$(peers::get_ip "$peer_name" 2>/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"
}
| 1 | #!/usr/bin/env bash |
| 2 | # identity.command.sh — manage peer identities |
| 3 | # |
| 4 | # Subcommands: |
| 5 | # wgctl identity list |
| 6 | # wgctl identity show --name <name> |
| 7 | # wgctl identity add --name <name> --peer <peer> |
| 8 | # wgctl identity remove --name <name> |
| 9 | # wgctl identity migrate [--dry-run] |
| 10 | |
| 11 | # ============================================ |
| 12 | # Lifecycle |
| 13 | # ============================================ |
| 14 | |
| 15 | function cmd::identity::on_load() { |
| 16 | load_module identity |
| 17 | load_module policy |
| 18 | |
| 19 | flag::register --name |
| 20 | flag::register --peer |
| 21 | flag::register --dry-run |
| 22 | flag::register --force |
| 23 | flag::register --rule |
| 24 | flag::register --policy |
| 25 | flag::register --set-strict-rule |
| 26 | flag::register --unset-strict-rule |
| 27 | flag::register --set-auto-apply |
| 28 | flag::register --unset-auto-apply |
| 29 | flag::register --field |
| 30 | flag::register --value |
| 31 | flag::register --migrate |
| 32 | |
| 33 | command::mixin json_output |
| 34 | } |
| 35 | |
| 36 | # ============================================ |
| 37 | # Help |
| 38 | # ============================================ |
| 39 | |
| 40 | function cmd::identity::help() { |
| 41 | cat <<EOF |
| 42 | Usage: wgctl identity <subcommand> [options] |
| 43 | |
| 44 | Manage peer identities — group peers by person/device owner. |
| 45 | |
| 46 | Subcommands: |
| 47 | list List all identities |
| 48 | show --name <n> Show identity details with peers and rule tree |
| 49 | add --name <n> Create a new identity |
| 50 | remove --name <n> Remove an identity |
| 51 | migrate Migrate peers to identities |
| 52 | |
| 53 | rule assign --name <n> --rule <r> Assign rule to identity |
| 54 | Blocked if peer already has rule directly |
| 55 | [--migrate] Remove conflicting direct peer rules first |
| 56 | rule unassign --name <n> --rule <r> Remove rule from identity |
| 57 | rule unassign --name <n> --all Remove all rules from identity |
| 58 | |
| 59 | options --name <n> --strict-rule <bool> Set strict rule mode |
| 60 | options --name <n> --auto-apply <bool> Set auto apply |
| 61 | |
| 62 | Examples: |
| 63 | wgctl identity list |
| 64 | wgctl identity show --name nuno |
| 65 | wgctl identity add --name alice |
| 66 | wgctl identity rule assign --name nuno --rule admin |
| 67 | wgctl identity rule assign --name nuno --rule user --migrate |
| 68 | wgctl identity rule unassign --name nuno --rule admin |
| 69 | wgctl identity options --name nuno --strict-rule true |
| 70 | EOF |
| 71 | } |
| 72 | |
| 73 | # ============================================ |
| 74 | # Run |
| 75 | # ============================================ |
| 76 | |
| 77 | function cmd::identity::run() { |
| 78 | local subcmd="${1:-list}" |
| 79 | shift || true |
| 80 | |
| 81 | if command::json && [[ "$subcmd" == "list" ]]; then |
| 82 | cmd::identity::_output_json |
| 83 | return 0 |
| 84 | fi |
| 85 | |
| 86 | case "$subcmd" in |
| 87 | list) cmd::identity::_list "$@" ;; |
| 88 | show) cmd::identity::_show "$@" ;; |
| 89 | add) cmd::identity::_add "$@" ;; |
| 90 | remove) cmd::identity::_remove "$@" ;; |
| 91 | migrate) cmd::identity::_migrate "$@" ;; |
| 92 | rule) cmd::identity::_rule "$@" ;; |
| 93 | options) cmd::identity::_options "$@" ;; |
| 94 | --help) cmd::identity::help ;; |
| 95 | *) |
| 96 | log::error "Unknown subcommand '${subcmd}'. Available: list, show, add, remove, migrate, rule, options" |
| 97 | return 1 |
| 98 | ;; |
| 99 | esac |
| 100 | } |
| 101 | |
| 102 | # ============================================ |
| 103 | # Subcommands |
| 104 | # ============================================ |
| 105 | |
| 106 | function cmd::identity::_list() { |
| 107 | local data |
| 108 | data=$(identity::list_data | ui::sort_rows 1) |
| 109 | |
| 110 | if [[ -z "$data" ]]; then |
| 111 | log::info "No identities found. Run 'wgctl identity migrate' to create from existing peers." |
| 112 | return 0 |
| 113 | fi |
| 114 | |
| 115 | if display::is_table "identity_list"; then |
| 116 | cmd::identity::_render_table "$data" |
| 117 | return 0 |
| 118 | fi |
| 119 | |
| 120 | echo "" |
| 121 | while IFS='|' read -r name peer_count types rules policy; do |
| 122 | local rules_display |
| 123 | rules_display=$(echo "$rules" | sed 's/,/, /g') |
| 124 | ui::identity::list_row_compact "$name" "$peer_count" "$rules_display" "$policy" |
| 125 | done <<< "$data" |
| 126 | echo "" |
| 127 | } |
| 128 | |
| 129 | function cmd::identity::_show() { |
| 130 | local name="" |
| 131 | while [[ $# -gt 0 ]]; do |
| 132 | case "$1" in |
| 133 | --name) name="$2"; shift 2 ;; |
| 134 | --help) cmd::identity::help; return ;; |
| 135 | *) log::error "Unknown flag: $1"; return 1 ;; |
| 136 | esac |
| 137 | done |
| 138 | |
| 139 | [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } |
| 140 | identity::require_exists "$name" || return 1 |
| 141 | |
| 142 | # Gather identity-level metadata |
| 143 | local policy strict auto rules_list peer_count |
| 144 | policy=$(identity::policy "$name") |
| 145 | strict=$(identity::rule_flags "$name" "strict_rule") |
| 146 | auto=$(identity::rule_flags "$name" "auto_apply") |
| 147 | rules_list=$(identity::rules "$name" | tr '\n' ',' | sed 's/,$//' | sed 's/,/, /g') |
| 148 | |
| 149 | local data |
| 150 | data=$(identity::show_data "$name") |
| 151 | peer_count=$(echo "$data" | grep '^peer_count|' | cut -d'|' -f2) |
| 152 | |
| 153 | # Precompute handshakes once for all peers |
| 154 | declare -A _id_handshakes=() |
| 155 | while IFS=$'\t' read -r pk ts; do |
| 156 | [[ -n "$pk" ]] && _id_handshakes["$pk"]="$ts" |
| 157 | done < <(wg show "$(config::interface)" latest-handshakes 2>/dev/null) |
| 158 | |
| 159 | # Header |
| 160 | echo "" |
| 161 | ui::row "Identity" "$name" |
| 162 | ui::row "Policy" "$policy" |
| 163 | ui::row "Rules" "${rules_list:-—}" |
| 164 | ui::row "Strict rule" "$(ui::bool "$strict")" |
| 165 | ui::row "Auto apply" "$(ui::bool "$auto")" |
| 166 | ui::row "Peers" "$peer_count" |
| 167 | echo "" |
| 168 | |
| 169 | # Device list |
| 170 | while IFS='|' read -r key val type_val index_val; do |
| 171 | case "$key" in |
| 172 | name|peer_count) ;; |
| 173 | device) |
| 174 | local status="" |
| 175 | status=$(cmd::identity::_device_status "$val" _id_handshakes) |
| 176 | ui::identity::device_row "$val" "$type_val" "$index_val" "$status" |
| 177 | ;; |
| 178 | esac |
| 179 | done <<< "$data" |
| 180 | |
| 181 | # Rules tree |
| 182 | local identity_rules |
| 183 | identity_rules=$(identity::rules "$name") |
| 184 | if [[ -n "$identity_rules" ]]; then |
| 185 | printf "\n \033[2m── Rules \033[0m%s\n\n" \ |
| 186 | "$(printf '\033[2m─%.0s' {1..38})" |
| 187 | ui::rule::identity_block "$name" "$strict" --no-header |
| 188 | fi |
| 189 | |
| 190 | echo "" |
| 191 | } |
| 192 | |
| 193 | function cmd::identity::_render_table() { |
| 194 | local data="${1:-}" |
| 195 | [[ -z "$data" ]] && return 0 |
| 196 | |
| 197 | printf "\n %-20s %-8s %-20s %s\n" "NAME" "PEERS" "RULES" "POLICY" |
| 198 | printf " %s\n" "$(printf '─%.0s' {1..65})" |
| 199 | while IFS='|' read -r name peer_count types rules policy; do |
| 200 | [[ -z "$name" ]] && continue |
| 201 | local rules_display |
| 202 | rules_display=$(echo "$rules" | sed 's/,/, /g') |
| 203 | ui::identity::list_row_table "$name" "$peer_count" "$rules_display" "$policy" |
| 204 | done <<< "$data" |
| 205 | printf " %s\n\n" "$(printf '─%.0s' {1..65})" |
| 206 | } |
| 207 | |
| 208 | function cmd::identity::_device_status() { |
| 209 | local peer_name="${1:-}" |
| 210 | local -n _handshakes="${2:-__empty_map}" |
| 211 | |
| 212 | local peer_ip |
| 213 | peer_ip=$(peers::get_ip "$peer_name" 2>/dev/null) || return 0 |
| 214 | [[ -z "$peer_ip" ]] && return 0 |
| 215 | |
| 216 | local is_blocked is_restricted pubkey handshake_ts |
| 217 | peers::is_blocked "$peer_name" && is_blocked="true" || is_blocked="false" |
| 218 | peers::is_restricted "$peer_name" && is_restricted="true" || is_restricted="false" |
| 219 | |
| 220 | pubkey="$(keys::public "$peer_name")" |
| 221 | handshake_ts="${_handshakes[$pubkey]:-0}" |
| 222 | |
| 223 | local last_ts |
| 224 | last_ts=$(peers::get_meta "$peer_name" "last_ts" 2>/dev/null) || last_ts="" |
| 225 | |
| 226 | local status |
| 227 | status=$(peers::format_status_verbose \ |
| 228 | "$peer_name" "$pubkey" "$is_blocked" "$is_restricted" "$handshake_ts" "$last_ts") |
| 229 | echo " — ${status}" |
| 230 | } |
| 231 | |
| 232 | function cmd::identity::_add() { |
| 233 | local name="" peer="" |
| 234 | while [[ $# -gt 0 ]]; do |
| 235 | case "$1" in |
| 236 | --name) name="$2"; shift 2 ;; |
| 237 | --peer) peer="$2"; shift 2 ;; |
| 238 | --help) cmd::identity::help; return ;; |
| 239 | *) log::error "Unknown flag: $1"; return 1 ;; |
| 240 | esac |
| 241 | done |
| 242 | |
| 243 | [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } |
| 244 | [[ -z "$peer" ]] && { log::error "Missing required flag: --peer"; return 1; } |
| 245 | |
| 246 | cmd::identity::_require_peer_exists "$peer" || return 1 |
| 247 | |
| 248 | local peer_type index |
| 249 | peer_type=$(cmd::identity::_resolve_peer_type "$peer" "$name") |
| 250 | index=$(identity::next_index "$name" "$peer_type") |
| 251 | |
| 252 | local id_file |
| 253 | id_file=$(ctx::identity::path "${name}.identity") |
| 254 | json::identity_add_peer "$id_file" "$name" "$peer" "$peer_type" "$index" </dev/null |
| 255 | log::ok "Added '${peer}' to identity '${name}' (${peer_type} #${index})" |
| 256 | } |
| 257 | |
| 258 | function cmd::identity::_require_peer_exists() { |
| 259 | local peer="${1:-}" |
| 260 | if [[ ! -f "$(ctx::clients)/${peer}.conf" ]]; then |
| 261 | log::error "Peer '${peer}' not found" |
| 262 | return 1 |
| 263 | fi |
| 264 | } |
| 265 | |
| 266 | function cmd::identity::_resolve_peer_type() { |
| 267 | local peer="${1:-}" identity_name="${2:-}" |
| 268 | local inferred |
| 269 | inferred=$(identity::infer "$peer") |
| 270 | if [[ -n "$inferred" ]]; then |
| 271 | echo "$inferred" | cut -d'|' -f2 |
| 272 | else |
| 273 | peers::get_meta "$peer" "type" 2>/dev/null || echo "none" |
| 274 | fi |
| 275 | } |
| 276 | |
| 277 | function cmd::identity::_remove() { |
| 278 | local name="" force=false |
| 279 | while [[ $# -gt 0 ]]; do |
| 280 | case "$1" in |
| 281 | --name) name="$2"; shift 2 ;; |
| 282 | --force) force=true; shift ;; |
| 283 | --help) cmd::identity::help; return ;; |
| 284 | *) log::error "Unknown flag: $1"; return 1 ;; |
| 285 | esac |
| 286 | done |
| 287 | |
| 288 | [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } |
| 289 | identity::require_exists "$name" || return 1 |
| 290 | |
| 291 | local peers |
| 292 | peers=$(identity::peers "$name") |
| 293 | |
| 294 | if [[ -n "$peers" ]]; then |
| 295 | local peer_list="${peers//$'\n'/, }" |
| 296 | log::warn "This will permanently remove identity '${name}' and ALL associated peers:" |
| 297 | log::warn " ${peer_list}" |
| 298 | |
| 299 | if ! $force; then |
| 300 | ui::confirm "Continue?" || { log::info "Aborted"; return 0; } |
| 301 | fi |
| 302 | |
| 303 | cmd::identity::_remove_all_peers "$peers" |
| 304 | peers::reload || return 1 |
| 305 | fi |
| 306 | |
| 307 | local id_file |
| 308 | id_file=$(ctx::identity::path "${name}.identity") |
| 309 | json::identity_remove "$id_file" </dev/null |
| 310 | log::ok "Identity '${name}' removed" |
| 311 | } |
| 312 | |
| 313 | function cmd::identity::_remove_all_peers() { |
| 314 | local peers="${1:-}" |
| 315 | while IFS= read -r peer_name; do |
| 316 | [[ -z "$peer_name" ]] && continue |
| 317 | cmd::identity::_remove_peer "$peer_name" |
| 318 | done <<< "$peers" |
| 319 | } |
| 320 | |
| 321 | function cmd::identity::_remove_peer() { |
| 322 | local peer_name="${1:-}" |
| 323 | local client_ip was_blocked=false |
| 324 | |
| 325 | client_ip=$(peers::get_ip "$peer_name" 2>/dev/null) || client_ip="" |
| 326 | peers::is_blocked "$peer_name" && was_blocked=true |
| 327 | |
| 328 | peers::purge "$peer_name" "$client_ip" "$was_blocked" || return 1 |
| 329 | log::ok "Removed peer '${peer_name}'" |
| 330 | } |
| 331 | |
| 332 | function cmd::identity::_migrate() { |
| 333 | local dry_run="false" |
| 334 | while [[ $# -gt 0 ]]; do |
| 335 | case "$1" in |
| 336 | --dry-run) dry_run="true"; shift ;; |
| 337 | --help) cmd::identity::help; return ;; |
| 338 | *) log::error "Unknown flag: $1"; return 1 ;; |
| 339 | esac |
| 340 | done |
| 341 | |
| 342 | [[ "$dry_run" == "true" ]] && log::info "Dry run — no files will be written" |
| 343 | echo "" |
| 344 | |
| 345 | [[ "$dry_run" == "false" ]] && mkdir -p "$(ctx::identities)" |
| 346 | |
| 347 | local created=0 skipped=0 output |
| 348 | output=$(json::identity_migrate \ |
| 349 | "$(ctx::identities)" \ |
| 350 | "$(ctx::clients)" \ |
| 351 | "$(ctx::meta)" \ |
| 352 | "$dry_run") |
| 353 | |
| 354 | while IFS='|' read -r action identity_name peer_name peer_type index; do |
| 355 | case "$action" in |
| 356 | create) |
| 357 | ui::identity::migrate_create "$peer_name" "$identity_name" "$peer_type" "$index" |
| 358 | (( created++ )) || true |
| 359 | ;; |
| 360 | skip) |
| 361 | ui::identity::migrate_skip "$peer_name" |
| 362 | (( skipped++ )) || true |
| 363 | ;; |
| 364 | esac |
| 365 | done <<< "$output" |
| 366 | |
| 367 | ui::identity::migrate_summary "$created" "$skipped" "$dry_run" |
| 368 | } |
| 369 | |
| 370 | function cmd::identity::_rule() { |
| 371 | local subcmd="${1:-show}" |
| 372 | shift || true |
| 373 | |
| 374 | case "$subcmd" in |
| 375 | assign) cmd::identity::_rule_assign "$@" ;; |
| 376 | unassign) cmd::identity::_rule_unassign "$@" ;; |
| 377 | show) cmd::identity::_rule_show "$@" ;; |
| 378 | *) |
| 379 | log::error "Unknown rule subcommand '${subcmd}'. Available: assign, unassign, show" |
| 380 | return 1 |
| 381 | ;; |
| 382 | esac |
| 383 | } |
| 384 | |
| 385 | function cmd::identity::_rule_assign() { |
| 386 | local name="" rule="" migrate=false |
| 387 | while [[ $# -gt 0 ]]; do |
| 388 | case "$1" in |
| 389 | --name) name="$2"; shift 2 ;; |
| 390 | --rule) rule="$2"; shift 2 ;; |
| 391 | --migrate) migrate=true; shift ;; |
| 392 | *) log::error "Unknown flag: $1"; return 1 ;; |
| 393 | esac |
| 394 | done |
| 395 | |
| 396 | [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } |
| 397 | [[ -z "$rule" ]] && { log::error "Missing required flag: --rule"; return 1; } |
| 398 | identity::require_exists "$name" || return 1 |
| 399 | rule::exists "$rule" || { log::error "Rule not found: ${rule}"; return 1; } |
| 400 | |
| 401 | local conflicts=() |
| 402 | while IFS= read -r peer_name; do |
| 403 | [[ -z "$peer_name" ]] && continue |
| 404 | local peer_rule |
| 405 | peer_rule=$(peers::get_meta "$peer_name" "rule" 2>/dev/null) |
| 406 | [[ "$peer_rule" == "$rule" ]] && conflicts+=("$peer_name") |
| 407 | done < <(identity::peers "$name") |
| 408 | |
| 409 | if [[ ${#conflicts[@]} -gt 0 ]]; then |
| 410 | if ! $migrate; then |
| 411 | log::error "The following peers have '${rule}' as a direct rule: ${conflicts[*]}" |
| 412 | log::error "Use --migrate to remove direct rules and let the identity rule take over." |
| 413 | return 1 |
| 414 | fi |
| 415 | # Migrate — remove direct rules from conflicting peers |
| 416 | for peer_name in "${conflicts[@]}"; do |
| 417 | local ip |
| 418 | ip=$(peers::get_ip "$peer_name") |
| 419 | rule::unapply "$rule" "$ip" |
| 420 | log::wg "Migrated '${rule}' from peer '${peer_name}' to identity '${name}'" |
| 421 | done |
| 422 | fi |
| 423 | |
| 424 | local exit_code=0 |
| 425 | identity::add_rule "$name" "$rule" || exit_code=$? |
| 426 | |
| 427 | if [[ $exit_code -eq 2 ]]; then |
| 428 | log::warn "Rule '${rule}' is already assigned to identity '${name}'" |
| 429 | return 0 |
| 430 | fi |
| 431 | |
| 432 | log::ok "Rule '${rule}' assigned to identity '${name}'" |
| 433 | |
| 434 | # Reapply rules if auto_apply |
| 435 | local auto |
| 436 | auto=$(identity::rule_flags "$name" "auto_apply") |
| 437 | if [[ "$auto" != "false" ]]; then |
| 438 | log::info "Reapplying rules for all peers in identity '${name}'..." |
| 439 | identity::reapply_rules "$name" |
| 440 | log::ok "Rules reapplied" |
| 441 | fi |
| 442 | |
| 443 | # Warn about strict_rule |
| 444 | if policy::strict_rule "$(identity::policy "$name")"; then |
| 445 | log::warn "Strict rule is enabled for identity '${name}' — peer rules will not be additive" |
| 446 | fi |
| 447 | } |
| 448 | |
| 449 | function cmd::identity::_rule_unassign() { |
| 450 | local name="" rule="" all=false |
| 451 | while [[ $# -gt 0 ]]; do |
| 452 | case "$1" in |
| 453 | --name) name="$2"; shift 2 ;; |
| 454 | --rule) rule="$2"; shift 2 ;; |
| 455 | --all) all=true; shift ;; |
| 456 | *) log::error "Unknown flag: $1"; return 1 ;; |
| 457 | esac |
| 458 | done |
| 459 | |
| 460 | [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } |
| 461 | identity::require_exists "$name" || return 1 |
| 462 | |
| 463 | if $all; then |
| 464 | local rules |
| 465 | rules=$(identity::rules "$name") |
| 466 | if [[ -z "$rules" ]]; then |
| 467 | log::warn "Identity '${name}' has no rules assigned" |
| 468 | return 0 |
| 469 | fi |
| 470 | identity::clear_rules "$name" |
| 471 | log::ok "All rules removed from identity '${name}'" |
| 472 | cmd::identity::_reapply_after_unassign "$name" |
| 473 | return 0 |
| 474 | fi |
| 475 | |
| 476 | [[ -z "$rule" ]] && { |
| 477 | log::error "Missing required flag: --rule (or use --all to remove all)" |
| 478 | return 1 |
| 479 | } |
| 480 | |
| 481 | identity::remove_rule "$name" "$rule" |
| 482 | local exit_code=$? |
| 483 | if [[ $exit_code -ne 0 ]]; then |
| 484 | log::error "Rule '${rule}' is not assigned to identity '${name}'" |
| 485 | return 1 |
| 486 | fi |
| 487 | |
| 488 | log::ok "Rule '${rule}' removed from identity '${name}'" |
| 489 | cmd::identity::_reapply_after_unassign "$name" |
| 490 | } |
| 491 | |
| 492 | function cmd::identity::_reapply_after_unassign() { |
| 493 | local name="${1:-}" |
| 494 | local auto |
| 495 | auto=$(identity::rule_flags "$name" "auto_apply") |
| 496 | if [[ "$auto" != "false" ]]; then |
| 497 | log::info "Reapplying rules for all peers in identity '${name}'..." |
| 498 | identity::reapply_rules "$name" |
| 499 | log::ok "Rules reapplied" |
| 500 | else |
| 501 | log::info "Note: auto_apply is disabled — run 'wgctl audit --fix' to update fw rules" |
| 502 | fi |
| 503 | } |
| 504 | |
| 505 | function cmd::identity::_rule_show() { |
| 506 | local name="" |
| 507 | while [[ $# -gt 0 ]]; do |
| 508 | case "$1" in |
| 509 | --name) name="$2"; shift 2 ;; |
| 510 | *) log::error "Unknown flag: $1"; return 1 ;; |
| 511 | esac |
| 512 | done |
| 513 | |
| 514 | [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } |
| 515 | identity::require_exists "$name" || return 1 |
| 516 | |
| 517 | local rules policy strict auto |
| 518 | rules=$(identity::rules "$name") |
| 519 | policy=$(identity::policy "$name") |
| 520 | strict=$(identity::rule_flags "$name" "strict_rule") |
| 521 | auto=$(identity::rule_flags "$name" "auto_apply") |
| 522 | |
| 523 | echo "" |
| 524 | ui::row "Identity" "$name" |
| 525 | ui::row "Policy" "$policy" |
| 526 | ui::row "Strict rule" "$(ui::bool "$strict")" |
| 527 | ui::row "Auto apply" "$(ui::bool "$auto")" |
| 528 | echo "" |
| 529 | |
| 530 | if [[ -z "$rules" ]]; then |
| 531 | ui::row "Rules" "— none assigned" |
| 532 | else |
| 533 | printf " %-20s\n" "Rules:" |
| 534 | while IFS= read -r rule_name; do |
| 535 | [[ -z "$rule_name" ]] && continue |
| 536 | printf " · %s\n" "$rule_name" |
| 537 | done <<< "$rules" |
| 538 | fi |
| 539 | echo "" |
| 540 | } |
| 541 | |
| 542 | function cmd::identity::_options() { |
| 543 | local name="" new_policy="" |
| 544 | local set_strict="" set_auto="" |
| 545 | |
| 546 | while [[ $# -gt 0 ]]; do |
| 547 | case "$1" in |
| 548 | --name) name="$2"; shift 2 ;; |
| 549 | --policy) new_policy="$2"; shift 2 ;; |
| 550 | --set-strict-rule) set_strict="true"; shift ;; |
| 551 | --unset-strict-rule) set_strict="false"; shift ;; |
| 552 | --set-auto-apply) set_auto="true"; shift ;; |
| 553 | --unset-auto-apply) set_auto="false"; shift ;; |
| 554 | *) log::error "Unknown flag: $1"; return 1 ;; |
| 555 | esac |
| 556 | done |
| 557 | |
| 558 | [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } |
| 559 | identity::require_exists "$name" || return 1 |
| 560 | |
| 561 | local changed=false |
| 562 | |
| 563 | if [[ -n "$new_policy" ]]; then |
| 564 | policy::require_exists "$new_policy" || return 1 |
| 565 | identity::set_policy "$name" "$new_policy" |
| 566 | log::ok "Policy set to '${new_policy}' for identity '${name}'" |
| 567 | changed=true |
| 568 | fi |
| 569 | |
| 570 | if [[ -n "$set_strict" ]]; then |
| 571 | identity::set_rule_flag "$name" "strict_rule" "$set_strict" |
| 572 | if [[ "$set_strict" == "true" ]]; then |
| 573 | log::ok "Strict rule enabled for identity '${name}' — peer rules will not be additive" |
| 574 | else |
| 575 | log::ok "Strict rule disabled for identity '${name}' — peer rules will be additive" |
| 576 | fi |
| 577 | changed=true |
| 578 | fi |
| 579 | |
| 580 | if [[ -n "$set_auto" ]]; then |
| 581 | identity::set_rule_flag "$name" "auto_apply" "$set_auto" |
| 582 | if [[ "$set_auto" == "true" ]]; then |
| 583 | log::ok "Auto apply enabled for identity '${name}'" |
| 584 | else |
| 585 | log::ok "Auto apply disabled for identity '${name}'" |
| 586 | fi |
| 587 | changed=true |
| 588 | fi |
| 589 | |
| 590 | if ! $changed; then |
| 591 | cmd::identity::_rule_show --name "$name" |
| 592 | fi |
| 593 | } |
| 594 | |
| 595 | function cmd::identity::_output_json() { |
| 596 | local data |
| 597 | data=$(identity::list_data 2>/dev/null) |
| 598 | |
| 599 | local -a identities=() |
| 600 | while IFS='|' read -r name peer_count types rules policy; do |
| 601 | [[ -z "$name" ]] && continue |
| 602 | |
| 603 | # Build rules array |
| 604 | local rules_json="[]" |
| 605 | if [[ -n "$rules" ]]; then |
| 606 | local rules_array |
| 607 | rules_array=$(echo "$rules" | tr ',' '\n' | \ |
| 608 | while IFS= read -r r; do [[ -n "$r" ]] && printf '"%s",' "$r"; done | sed 's/,$//') |
| 609 | rules_json="[${rules_array}]" |
| 610 | fi |
| 611 | |
| 612 | # Build types array (was comma-separated string) |
| 613 | local types_json="[]" |
| 614 | if [[ -n "$types" ]]; then |
| 615 | local types_array |
| 616 | types_array=$(echo "$types" | tr ',' '\n' | \ |
| 617 | while IFS= read -r t; do [[ -n "$t" ]] && printf '"%s",' "$t"; done | sed 's/,$//') |
| 618 | types_json="[${types_array}]" |
| 619 | fi |
| 620 | |
| 621 | identities+=("$(printf '{"name":"%s","peer_count":%s,"types":%s,"rules":%s,"policy":"%s"}' \ |
| 622 | "$name" "$peer_count" "$types_json" "$rules_json" "$policy")") |
| 623 | done <<< "$data" |
| 624 | |
| 625 | local count=${#identities[@]} |
| 626 | local array |
| 627 | array=$(printf '%s\n' "${identities[@]:-}" | paste -sd ',' -) |
| 628 | printf '{"identities":[%s]}' "${array:-}" | json::envelope "identity list" "$count" |
| 629 | } |
| 630 |