wgctl/commands/identity.command.sh
Nuno Duque Nunes 1a78dcf5da feat: display config, table layouts for all commands
- display.module.sh: style toggle per view (compact/table)
- display.json: default config with all views set to compact
- ctx::display: points to .wgctl/config/display.json
- list: _render_table with dynamic widths, colors, shared row_color/status_color
- group/identity/net/hosts/activity: _render_table added
- rule/subnet/policy: table UI functions + _render_table
- ui::peer::status_color: \033[2m for offline (dimmer, more readable)
- note: individual table layout refinements pending cleanup pass
- note: configurable colors per field deferred to display config v2
2026-05-27 03:32:31 +00:00

630 lines
No EOL
19 KiB
Bash

#!/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"
}