init feature
This commit is contained in:
parent
4ac25e283d
commit
8bb1de4976
25 changed files with 2483 additions and 554 deletions
284
commands/identity.command.sh
Normal file
284
commands/identity.command.sh
Normal file
|
|
@ -0,0 +1,284 @@
|
||||||
|
#!/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() {
|
||||||
|
flag::register --name
|
||||||
|
flag::register --peer
|
||||||
|
flag::register --dry-run
|
||||||
|
flag::register --force
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Help
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
function cmd::identity::help() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: wgctl identity <subcommand> [options]
|
||||||
|
|
||||||
|
Manage peer identities.
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
list List all identities
|
||||||
|
show --name <name> Show identity details and device status
|
||||||
|
add --name <name> Manually attach a peer to an identity
|
||||||
|
--peer <peer>
|
||||||
|
remove --name <name> Remove identity and all associated peers
|
||||||
|
migrate [--dry-run] Create identities from existing peer names
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
wgctl identity list
|
||||||
|
wgctl identity show --name nuno
|
||||||
|
wgctl identity add --name nuno --peer roboclean
|
||||||
|
wgctl identity remove --name zephyr
|
||||||
|
wgctl identity migrate --dry-run
|
||||||
|
wgctl identity migrate
|
||||||
|
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 "$@" ;;
|
||||||
|
--help) cmd::identity::help ;;
|
||||||
|
*)
|
||||||
|
log::error "Unknown subcommand '${subcmd}'. Available: list, show, add, remove, migrate"
|
||||||
|
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
|
||||||
|
|
||||||
|
ui::identity::header
|
||||||
|
while IFS='|' read -r name peer_count types; do
|
||||||
|
ui::identity::row "$name" "$peer_count" "$types"
|
||||||
|
done <<< "$data"
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
local data peer_count="0"
|
||||||
|
data=$(identity::show_data "$name")
|
||||||
|
|
||||||
|
while IFS='|' read -r key val type_val index_val; do
|
||||||
|
case "$key" in
|
||||||
|
name)
|
||||||
|
peer_count=$(echo "$data" | grep '^peer_count|' | cut -d'|' -f2)
|
||||||
|
ui::identity::detail_name "$val" "$peer_count"
|
||||||
|
;;
|
||||||
|
peer_count) ;; # consumed above
|
||||||
|
device)
|
||||||
|
local status=""
|
||||||
|
status=$(cmd::identity::_device_status "$val")
|
||||||
|
ui::identity::device_row "$val" "$type_val" "$index_val" "$status"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done <<< "$data"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmd::identity::_device_status() {
|
||||||
|
local peer_name="${1:-}"
|
||||||
|
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
|
||||||
|
is_blocked=$(peers::is_blocked "$peer_name")
|
||||||
|
is_restricted=$(peers::is_restricted "$peer_name")
|
||||||
|
pubkey=$(cat "$(ctx::clients)/${peer_name}.pub" 2>/dev/null) || pubkey=""
|
||||||
|
handshake_ts=$(wg show wg0 latest-handshakes 2>/dev/null \
|
||||||
|
| awk -v pk="$pubkey" '$1==pk{print $2}') || handshake_ts=0
|
||||||
|
|
||||||
|
local last_ts last_evt
|
||||||
|
last_ts=$(peers::get_meta "$peer_name" "last_ts" 2>/dev/null) || last_ts=""
|
||||||
|
last_evt=$(peers::get_meta "$peer_name" "last_evt" 2>/dev/null) || last_evt=""
|
||||||
|
|
||||||
|
local status
|
||||||
|
status=$(peers::format_status \
|
||||||
|
"$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"
|
||||||
|
}
|
||||||
|
|
@ -289,75 +289,6 @@ function cmd::list::_iter_confs() {
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
# function cmd::list::_render_row() {
|
|
||||||
# local client_name="$1" ip="$2" type="$3"
|
|
||||||
|
|
||||||
# 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]:-}"
|
|
||||||
|
|
||||||
# # 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
|
|
||||||
|
|
||||||
# if [[ -n "$filter_group" ]]; then
|
|
||||||
# local peer_group="${peer_group_map[$client_name]:-}"
|
|
||||||
# [[ "$peer_group" != "$filter_group" ]] && return 0
|
|
||||||
# fi
|
|
||||||
|
|
||||||
# # 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]:-—}"
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# # Update rule counts for summary (outer scope array)
|
|
||||||
# rule_counts["$rule"]=$(( ${rule_counts[$rule]:-0} + 1 )) || true
|
|
||||||
|
|
||||||
# # Pad status
|
|
||||||
# local padded_status
|
|
||||||
# padded_status=$(ui::pad_status "$status" 25)
|
|
||||||
|
|
||||||
# # Render row
|
|
||||||
# if $has_groups; then
|
|
||||||
# group_display="${peer_group_map[$client_name]:-—}"
|
|
||||||
|
|
||||||
# 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" \
|
|
||||||
# "$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
|
|
||||||
# }
|
|
||||||
|
|
||||||
function cmd::list::_render_row() {
|
function cmd::list::_render_row() {
|
||||||
local client_name="$1" ip="$2" type="$3"
|
local client_name="$1" ip="$2" type="$3"
|
||||||
|
|
|
||||||
|
|
@ -36,16 +36,14 @@ EOF
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
function cmd::remove::run() {
|
function cmd::remove::run() {
|
||||||
local name=""
|
local name="" type="" force=false
|
||||||
local type=""
|
|
||||||
local force=false
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--name) name="$2"; shift 2 ;;
|
--name) name="$2"; shift 2 ;;
|
||||||
--type) type="$2"; shift 2 ;;
|
--type) type="$2"; shift 2 ;;
|
||||||
--force) force=true; shift ;;
|
--force) force=true; shift ;;
|
||||||
--help) cmd::remove::help; return ;;
|
--help) cmd::remove::help; return ;;
|
||||||
*)
|
*)
|
||||||
log::error "Unknown flag: $1"
|
log::error "Unknown flag: $1"
|
||||||
cmd::remove::help
|
cmd::remove::help
|
||||||
|
|
@ -62,7 +60,6 @@ function cmd::remove::run() {
|
||||||
|
|
||||||
name=$(peers::resolve_and_require "$name" "$type") || return 1
|
name=$(peers::resolve_and_require "$name" "$type") || return 1
|
||||||
|
|
||||||
# Confirmation prompt unless --force
|
|
||||||
if ! $force; then
|
if ! $force; then
|
||||||
read -r -p "Are you sure you want to permanently remove '${name}'? [y/N] " confirm
|
read -r -p "Are you sure you want to permanently remove '${name}'? [y/N] " confirm
|
||||||
case "$confirm" in
|
case "$confirm" in
|
||||||
|
|
@ -76,27 +73,20 @@ function cmd::remove::run() {
|
||||||
|
|
||||||
log::section "Removing client: ${name}"
|
log::section "Removing client: ${name}"
|
||||||
|
|
||||||
local client_ip
|
local client_ip was_blocked=false
|
||||||
client_ip=$(peers::get_ip "$name")
|
client_ip=$(peers::get_ip "$name")
|
||||||
|
|
||||||
local was_blocked=false
|
|
||||||
peers::is_blocked "$name" && was_blocked=true
|
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}"
|
log::wg_success "Client removed: ${name}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# _cleanup kept as a shim — callers should prefer peers::purge directly
|
||||||
function cmd::remove::_cleanup() {
|
function cmd::remove::_cleanup() {
|
||||||
local name="${1:-}" client_ip="${2:-}" was_blocked="${3:-false}"
|
local name="${1:-}" client_ip="${2:-}" was_blocked="${3:-false}"
|
||||||
|
peers::purge "$name" "$client_ip" "$was_blocked"
|
||||||
[[ -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
|
|
||||||
}
|
}
|
||||||
|
|
@ -23,8 +23,8 @@ Rename an existing WireGuard client.
|
||||||
The client IP and keys are preserved, only the name changes.
|
The client IP and keys are preserved, only the name changes.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--name <name> Current client name (e.g. phone-phone-nuno)
|
--name <name> Current client name (e.g. phone-nuno)
|
||||||
--new-name <name> New client name (e.g. phone-nuno)
|
--new-name <name> New client name (e.g. laptop-nuno)
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
wgctl rename --name phone-phone-nuno --new-name phone-nuno
|
wgctl rename --name phone-phone-nuno --new-name phone-nuno
|
||||||
|
|
@ -37,10 +37,7 @@ EOF
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
function cmd::rename::run() {
|
function cmd::rename::run() {
|
||||||
local name=""
|
local name="" type="" new_name="" new_type=""
|
||||||
local type=""
|
|
||||||
local new_name=""
|
|
||||||
local new_type=""
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
|
|
@ -69,7 +66,7 @@ function cmd::rename::run() {
|
||||||
return 1
|
return 1
|
||||||
fi
|
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
|
new_name=$(peers::resolve_name "$new_name" "$new_type") || return 1
|
||||||
|
|
||||||
local dir
|
local dir
|
||||||
|
|
@ -88,10 +85,11 @@ function cmd::rename::run() {
|
||||||
log::section "Renaming client: ${name} → ${new_name}"
|
log::section "Renaming client: ${name} → ${new_name}"
|
||||||
|
|
||||||
cmd::rename::_rename_files "$name" "$new_name"
|
cmd::rename::_rename_files "$name" "$new_name"
|
||||||
|
|
||||||
# Reload WireGuard
|
|
||||||
peers::reload
|
peers::reload
|
||||||
|
|
||||||
|
# Update identity entry after successful rename
|
||||||
|
identity::rename_peer "$name" "$new_name"
|
||||||
|
|
||||||
log::wg_success "Client renamed: ${name} → ${new_name}"
|
log::wg_success "Client renamed: ${name} → ${new_name}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
272
commands/subnet.command.sh
Normal file
272
commands/subnet.command.sh
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# subnet.command.sh — manage the subnet map (subnets.json)
|
||||||
|
#
|
||||||
|
# Subcommands:
|
||||||
|
# wgctl subnet list
|
||||||
|
# wgctl subnet show --name <name>
|
||||||
|
# wgctl subnet add --name <name> --subnet <cidr> [--type <type>]
|
||||||
|
# [--tunnel-mode split|full] [--desc <desc>]
|
||||||
|
# [--group <parent>]
|
||||||
|
# wgctl subnet rm --name <name>
|
||||||
|
# wgctl subnet rename --name <old> --new-name <new>
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 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 <<EOF
|
||||||
|
Usage: wgctl subnet <subcommand> [options]
|
||||||
|
|
||||||
|
Manage the subnet map.
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
list List all configured subnets
|
||||||
|
show --name <name> Show details for a subnet
|
||||||
|
add --name <name> Add a new subnet entry
|
||||||
|
--subnet <cidr>
|
||||||
|
[--type <type>]
|
||||||
|
[--tunnel-mode split|full]
|
||||||
|
[--desc <desc>]
|
||||||
|
[--group <parent>]
|
||||||
|
rm --name <name> Remove a subnet (refused if in use)
|
||||||
|
rename --name <old> Rename a subnet (refused if in use)
|
||||||
|
--new-name <new>
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
ui::subnet::header
|
||||||
|
|
||||||
|
local prev_group=""
|
||||||
|
while IFS='|' read -r display_name subnet type_key tunnel_mode desc is_group group_parent; do
|
||||||
|
cmd::subnet::_maybe_group_separator "$is_group" "$group_parent" "$prev_group"
|
||||||
|
prev_group=$(cmd::subnet::_update_prev_group "$is_group" "$group_parent" "$prev_group")
|
||||||
|
ui::subnet::row "$display_name" "$subnet" "$type_key" "$tunnel_mode" "$desc" "$is_group"
|
||||||
|
done <<< "$data"
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
while IFS='|' read -r key val rest; do
|
||||||
|
case "$key" in
|
||||||
|
name)
|
||||||
|
ui::subnet::detail "$val" "$is_group"
|
||||||
|
;;
|
||||||
|
is_group)
|
||||||
|
is_group="$val"
|
||||||
|
ui::subnet::detail_field "Type" "$( [[ $val == true ]] && echo "group" || echo "scalar" )"
|
||||||
|
[[ "$val" == "true" ]] && ui::subnet::child_header
|
||||||
|
;;
|
||||||
|
subnet) ui::subnet::detail_field "Subnet" "$val" ;;
|
||||||
|
type) ui::subnet::detail_field "Device type" "$val" ;;
|
||||||
|
tunnel_mode) ui::subnet::detail_field "Tunnel" "$val" ;;
|
||||||
|
desc) ui::subnet::detail_field "Description" "$val" ;;
|
||||||
|
child)
|
||||||
|
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::child_row "$c_type" "$c_subnet" "$c_tunnel" "$c_desc"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done <<< "$data"
|
||||||
|
|
||||||
|
local peers_using
|
||||||
|
peers_using=$(subnet::peers_using "$name")
|
||||||
|
ui::subnet::peers_in_use "$peers_using"
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
# test.command.sh — wgctl test suite dispatcher
|
||||||
WGCTL_BINARY="$(command -v wgctl)"
|
# Delegates to commands/test/{integration,unit,destructive,fn}.sh
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Lifecycle
|
# Lifecycle
|
||||||
|
|
@ -11,8 +11,15 @@ function cmd::test::on_load() {
|
||||||
flag::register --section
|
flag::register --section
|
||||||
flag::register --fn
|
flag::register --fn
|
||||||
flag::register --function
|
flag::register --function
|
||||||
|
flag::register --unit
|
||||||
|
flag::register --integration
|
||||||
|
flag::register --verbose
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Help
|
||||||
|
# ============================================
|
||||||
|
|
||||||
function cmd::test::help() {
|
function cmd::test::help() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
Usage: wgctl test [options]
|
Usage: wgctl test [options]
|
||||||
|
|
@ -20,440 +27,61 @@ Usage: wgctl test [options]
|
||||||
Run the wgctl test suite.
|
Run the wgctl test suite.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--destructive Include tests that modify state (add/remove/block)
|
--unit Run unit tests (pure function tests, no side effects)
|
||||||
--section <name> Run only a specific section (list, rules, groups, audit, logs, fw)
|
--integration Run integration tests against the live binary (default)
|
||||||
|
--destructive Include destructive tests (add/remove/block state changes)
|
||||||
|
--section <name> Run only a specific section
|
||||||
|
--fn <function> 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:
|
Examples:
|
||||||
wgctl test
|
wgctl test
|
||||||
wgctl test --section rules
|
wgctl test --unit
|
||||||
|
wgctl test --unit --section subnet
|
||||||
|
wgctl test --integration --section rules
|
||||||
wgctl test --destructive
|
wgctl test --destructive
|
||||||
|
wgctl test --fn cmd::block::run
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Test helpers
|
# Loader
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
function cmd::test::run_cmd() {
|
function cmd::test::_load() {
|
||||||
local desc="$1"
|
local name="${1:-}"
|
||||||
local expected="${2:-}"
|
local path
|
||||||
shift 2
|
path="$(ctx::commands)/test/${name}.sh"
|
||||||
|
if [[ ! -f "$path" ]]; then
|
||||||
local tmp exit_code
|
log::error "Test file not found: ${path}"
|
||||||
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"
|
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
source "$path"
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Test sections
|
# Run (entrypoint)
|
||||||
# ============================================
|
|
||||||
|
|
||||||
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
|
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
function cmd::test::run() {
|
function cmd::test::run() {
|
||||||
local destructive=false section=""
|
local unit=false integration=false destructive=false
|
||||||
local fn=""
|
local section="" fn=""
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--destructive) destructive=true; shift ;;
|
--unit) unit=true; shift ;;
|
||||||
--section)
|
--integration) integration=true; shift ;;
|
||||||
util::require_flag "--section" "${2:-}" || return 1
|
--destructive) destructive=true; shift ;;
|
||||||
section="$2"; shift 2
|
--section) section="$2"; shift 2 ;;
|
||||||
;;
|
--fn|--function) fn="$2"; shift 2 ;;
|
||||||
--fn|--function) fn="$2"; shift 2 ;;
|
--verbose|-v) WGCTL_TEST_VERBOSE=true; shift ;;
|
||||||
--verbose|-v) WGCTL_TEST_VERBOSE=true; shift ;;
|
--help) cmd::test::help; return ;;
|
||||||
--help) cmd::test::help; return ;;
|
|
||||||
*)
|
*)
|
||||||
log::error "Unknown flag: $1"
|
log::error "Unknown flag: $1"
|
||||||
return 1
|
return 1
|
||||||
|
|
@ -461,46 +89,77 @@ function cmd::test::run() {
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
# After flag parsing:
|
# Function test — load fn.sh + integration.sh (needs run_cmd helpers)
|
||||||
if [[ -n "$fn" ]]; then
|
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
|
return
|
||||||
fi
|
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
|
test::reset
|
||||||
log::section "wgctl Test Suite"
|
log::section "wgctl Test Suite"
|
||||||
|
cmd::test::_dispatch_integration "$section"
|
||||||
if [[ -n "$section" ]]; then
|
$destructive && cmd::test::section_destructive
|
||||||
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
|
|
||||||
|
|
||||||
test::summary
|
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"
|
||||||
}
|
}
|
||||||
116
commands/test/destructive.sh
Normal file
116
commands/test/destructive.sh
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
#!/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" 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" "blocked" 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
|
||||||
|
|
||||||
|
# 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" "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" "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
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
# Add verifies auto-attach
|
||||||
|
cmd::test::run_cmd "add attaches to identity" "added" \
|
||||||
|
add --name testunit2 --type laptop
|
||||||
|
|
||||||
|
cmd::test::run_cmd "identity shows testunit2" "testunit2" \
|
||||||
|
identity show --name testunit
|
||||||
|
|
||||||
|
# Rename verifies identity::rename_peer
|
||||||
|
cmd::test::run_cmd "rename updates identity" "renamed" \
|
||||||
|
rename --name laptop-testunit2 --new-name laptop-testunit2b
|
||||||
|
|
||||||
|
cmd::test::run_cmd "identity reflects rename" "testunit2b" \
|
||||||
|
identity show --name testunit
|
||||||
|
|
||||||
|
# Remove verifies auto-detach
|
||||||
|
"$WGCTL_BINARY" remove --name laptop-testunit2b --force > /dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmd::test::_destructive_cleanup() {
|
||||||
|
cmd::test::run_cmd "remove phone peer" "removed" \
|
||||||
|
remove --name phone-testunit --force
|
||||||
|
}
|
||||||
88
commands/test/fn.sh
Normal file
88
commands/test/fn.sh
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
214
commands/test/integration.sh
Normal file
214
commands/test/integration.sh
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
#!/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::run_cmd() {
|
||||||
|
local desc="$1" expected="${2:-}"
|
||||||
|
shift 2
|
||||||
|
|
||||||
|
local tmp exit_code
|
||||||
|
tmp=$(mktemp)
|
||||||
|
|
||||||
|
set +e
|
||||||
|
timeout 30 "$WGCTL_BINARY" "$@" > "$tmp" 2>&1 &
|
||||||
|
local pid=$!
|
||||||
|
wait $pid
|
||||||
|
exit_code=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [[ $exit_code -eq 124 ]]; then
|
||||||
|
test::warn "${desc} (timed out after 30s)"
|
||||||
|
rm -f "$tmp"
|
||||||
|
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
|
||||||
|
local tmp exit_code
|
||||||
|
tmp=$(mktemp)
|
||||||
|
timeout 10 setsid "$WGCTL_BINARY" "$@" > "$tmp" 2>&1
|
||||||
|
exit_code=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
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" "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 --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" 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 --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"
|
||||||
|
# Cleanup from any previous failed run
|
||||||
|
"$WGCTL_BINARY" subnet rm --name test-subnet-2 --force > /dev/null 2>&1 || true
|
||||||
|
"$WGCTL_BINARY" subnet rm --name test-subnet --force > /dev/null 2>&1 || true
|
||||||
|
|
||||||
|
cmd::test::run_cmd "subnet list" "desktop" subnet list
|
||||||
|
cmd::test::run_cmd "subnet show desktop" "Subnet" subnet show --name desktop
|
||||||
|
cmd::test::run_cmd "subnet show guests group" "group" 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
|
||||||
|
}
|
||||||
110
commands/test/unit.sh
Normal file
110
commands/test/unit.sh
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
#!/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() {
|
||||||
|
load_module subnet
|
||||||
|
load_module ip
|
||||||
|
load_module identity
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# _parse_peer_name via identity::infer
|
||||||
|
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')" ""
|
||||||
|
}
|
||||||
1
core.sh
1
core.sh
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
WGCTL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
WGCTL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
source "${WGCTL_DIR}/core/log.sh"
|
||||||
source "${WGCTL_DIR}/core/context.sh"
|
source "${WGCTL_DIR}/core/context.sh"
|
||||||
source "${WGCTL_DIR}/core/utils.sh"
|
source "${WGCTL_DIR}/core/utils.sh"
|
||||||
source "${WGCTL_DIR}/core/module.sh"
|
source "${WGCTL_DIR}/core/module.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::bold() { printf "\033[1;37m%s\033[0m" "${1:-}"; }
|
||||||
function color::cyan() { printf "\033[0;36m%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::yellow() { printf "\033[1;33m%s\033[0m" "${1:-}"; }
|
||||||
|
function color::dim() { printf "\033[2m%s\033[0m" "$*"; }
|
||||||
|
|
@ -21,6 +21,7 @@ _CTX_RULES_BASE="${_CTX_RULES}/base"
|
||||||
_CTX_GROUPS="${_CTX_DATA}/groups"
|
_CTX_GROUPS="${_CTX_DATA}/groups"
|
||||||
_CTX_BLOCKS="${_CTX_DATA}/blocks"
|
_CTX_BLOCKS="${_CTX_DATA}/blocks"
|
||||||
_CTX_META="${_CTX_DATA}/meta"
|
_CTX_META="${_CTX_DATA}/meta"
|
||||||
|
_CTX_IDENTITY="${_CTX_DATA}/identities"
|
||||||
_CTX_DAEMON="${_CTX_DATA}/daemon"
|
_CTX_DAEMON="${_CTX_DATA}/daemon"
|
||||||
_CTX_NET="${_CTX_DATA}/services.json"
|
_CTX_NET="${_CTX_DATA}/services.json"
|
||||||
|
|
||||||
|
|
@ -43,6 +44,8 @@ function ctx::blocks() { echo "$_CTX_BLOCKS"; }
|
||||||
function ctx::meta() { echo "$_CTX_META"; }
|
function ctx::meta() { echo "$_CTX_META"; }
|
||||||
function ctx::daemon() { echo "$_CTX_DAEMON"; }
|
function ctx::daemon() { echo "$_CTX_DAEMON"; }
|
||||||
function ctx::net() { echo "$_CTX_NET"; }
|
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::events_log() { echo "$(ctx::daemon)/events.log"; }
|
||||||
function ctx::fw_events_log() { echo "$(ctx::daemon)/fw_events.log"; }
|
function ctx::fw_events_log() { echo "$(ctx::daemon)/fw_events.log"; }
|
||||||
|
|
||||||
|
|
@ -60,6 +63,11 @@ function ctx::meta::path() {
|
||||||
echo "$_CTX_META/$*"
|
echo "$_CTX_META/$*"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ctx::identity::path() {
|
||||||
|
local IFS="/"
|
||||||
|
echo "$_CTX_IDENTITY/$*"
|
||||||
|
}
|
||||||
|
|
||||||
function ctx::block::path() {
|
function ctx::block::path() {
|
||||||
local IFS="/"
|
local IFS="/"
|
||||||
echo "$_CTX_BLOCKS/$*"
|
echo "$_CTX_BLOCKS/$*"
|
||||||
|
|
|
||||||
25
core/json.sh
25
core/json.sh
|
|
@ -60,6 +60,31 @@ function json::net_resolve() { python3 "$JSON_HELPER" net_resolve
|
||||||
function json::net_reverse_lookup() { python3 "$JSON_HELPER" net_reverse_lookup "$@" </dev/null; }
|
function json::net_reverse_lookup() { python3 "$JSON_HELPER" net_reverse_lookup "$@" </dev/null; }
|
||||||
function json::block_is_empty() { python3 "$JSON_HELPER" block_is_empty "$@" </dev/null; }
|
function json::block_is_empty() { python3 "$JSON_HELPER" block_is_empty "$@" </dev/null; }
|
||||||
function json::group_has_peer() { python3 "$JSON_HELPER" group_has_peer "$@" </dev/null; }
|
function json::group_has_peer() { python3 "$JSON_HELPER" group_has_peer "$@" </dev/null; }
|
||||||
|
# Subnet wrappers
|
||||||
|
function json::subnet_lookup() { python3 "$JSON_HELPER" subnet_lookup "$@" </dev/null; }
|
||||||
|
function json::subnet_type() { python3 "$JSON_HELPER" subnet_type "$@" </dev/null; }
|
||||||
|
function json::subnet_tunnel_mode() { python3 "$JSON_HELPER" subnet_tunnel_mode "$@" </dev/null; }
|
||||||
|
function json::subnet_for_ip() { python3 "$JSON_HELPER" subnet_for_ip "$@" </dev/null; }
|
||||||
|
function json::subnet_list() { python3 "$JSON_HELPER" subnet_list "$@" </dev/null; }
|
||||||
|
function json::subnet_show() { python3 "$JSON_HELPER" subnet_show "$@" </dev/null; }
|
||||||
|
function json::subnet_add() { python3 "$JSON_HELPER" subnet_add "$@" </dev/null; }
|
||||||
|
function json::subnet_remove() { python3 "$JSON_HELPER" subnet_remove "$@" </dev/null; }
|
||||||
|
function json::subnet_rename() { python3 "$JSON_HELPER" subnet_rename "$@" </dev/null; }
|
||||||
|
function json::subnet_peers() { python3 "$JSON_HELPER" subnet_peers "$@" </dev/null; }
|
||||||
|
function json::subnet_exists() { python3 "$JSON_HELPER" subnet_exists "$@" </dev/null; }
|
||||||
|
|
||||||
|
# Identity wrappers
|
||||||
|
function json::identity_list() { python3 "$JSON_HELPER" identity_list "$@" </dev/null; }
|
||||||
|
function json::identity_show() { python3 "$JSON_HELPER" identity_show "$@" </dev/null; }
|
||||||
|
function json::identity_add_peer() { python3 "$JSON_HELPER" identity_add_peer "$@" </dev/null; }
|
||||||
|
function json::identity_remove_peer() { python3 "$JSON_HELPER" identity_remove_peer "$@" </dev/null; }
|
||||||
|
function json::identity_remove() { python3 "$JSON_HELPER" identity_remove "$@" </dev/null; }
|
||||||
|
function json::identity_next_index() { python3 "$JSON_HELPER" identity_next_index "$@" </dev/null; }
|
||||||
|
function json::identity_peers() { python3 "$JSON_HELPER" identity_peers "$@" </dev/null; }
|
||||||
|
function json::identity_migrate() { python3 "$JSON_HELPER" identity_migrate "$@" </dev/null; }
|
||||||
|
function json::identity_infer() { python3 "$JSON_HELPER" identity_infer "$@" </dev/null; }
|
||||||
|
function json::identity_exists() { python3 "$JSON_HELPER" identity_exists "$@" </dev/null; }
|
||||||
|
|
||||||
|
|
||||||
function json::peer_transfer() {
|
function json::peer_transfer() {
|
||||||
ACTIVITY_TOTAL_LOW="$(config::activity_total_low)" \
|
ACTIVITY_TOTAL_LOW="$(config::activity_total_low)" \
|
||||||
|
|
|
||||||
|
|
@ -1486,6 +1486,582 @@ def group_has_peer(file, peer_name):
|
||||||
except Exception:
|
except Exception:
|
||||||
print('false')
|
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_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"""
|
||||||
|
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', {})
|
||||||
|
types = sorted(set(d.get('type', '') for d in devices.values() if d.get('type')))
|
||||||
|
print(f"{name}|{len(peers)}|{','.join(types)}")
|
||||||
|
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}||0")
|
||||||
|
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 instead of inferring from subtype.
|
||||||
|
# Output format: name|ip|rule|type|last_ts|last_evt|main_group
|
||||||
|
# (field 4 is now 'type' instead of 'subtype')
|
||||||
|
|
||||||
|
def peer_data_v2(clients_dir, meta_dir, events_log):
|
||||||
|
"""
|
||||||
|
Updated peer_data that reads 'type' from meta instead of 'subtype'.
|
||||||
|
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}")
|
||||||
|
|
||||||
commands = {
|
commands = {
|
||||||
'get': lambda args: get(args[0], args[1]),
|
'get': lambda args: get(args[0], args[1]),
|
||||||
'set': lambda args: set_key(args[0], args[1], args[2]),
|
'set': lambda args: set_key(args[0], args[1], args[2]),
|
||||||
|
|
@ -1507,7 +2083,7 @@ commands = {
|
||||||
'audit_fw_counts': lambda args: audit_fw_counts(args[0]),
|
'audit_fw_counts': lambda args: audit_fw_counts(args[0]),
|
||||||
'peer_group_map': lambda args: peer_group_map(args[0]),
|
'peer_group_map': lambda args: peer_group_map(args[0]),
|
||||||
'peer_groups': lambda args: peer_groups(args[0], args[1]),
|
'peer_groups': lambda args: peer_groups(args[0], args[1]),
|
||||||
'peer_data': lambda args: peer_data(args[0], args[1], args[2]),
|
# 'peer_data': lambda args: peer_data(args[0], args[1], args[2]),
|
||||||
'iso_to_ts': lambda args: iso_to_ts(args[0]),
|
'iso_to_ts': lambda args: iso_to_ts(args[0]),
|
||||||
'rule_list_data': lambda args: rule_list_data(args[0], args[1]),
|
'rule_list_data': lambda args: rule_list_data(args[0], args[1]),
|
||||||
'group_list_data': lambda args: group_list_data(args[0], args[1]),
|
'group_list_data': lambda args: group_list_data(args[0], args[1]),
|
||||||
|
|
@ -1577,6 +2153,37 @@ commands = {
|
||||||
),
|
),
|
||||||
'block_is_empty': lambda args: block_is_empty(args[0]),
|
'block_is_empty': lambda args: block_is_empty(args[0]),
|
||||||
'group_has_peer': lambda args: group_has_peer(args[0], args[1]),
|
'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]),
|
||||||
|
'peer_data': lambda args: peer_data_v2(args[0], args[1], args[2]),
|
||||||
}
|
}
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ function internal::get_log_priority() {
|
||||||
DEBUG) echo 0 ;;
|
DEBUG) echo 0 ;;
|
||||||
INFO) echo 1 ;;
|
INFO) echo 1 ;;
|
||||||
SUCCESS) echo 1 ;;
|
SUCCESS) echo 1 ;;
|
||||||
|
OK) echo 1 ;;
|
||||||
WARN) echo 2 ;;
|
WARN) echo 2 ;;
|
||||||
ERROR) echo 3 ;;
|
ERROR) echo 3 ;;
|
||||||
*) echo 1 ;;
|
*) echo 1 ;;
|
||||||
|
|
@ -48,6 +49,7 @@ function internal::log() {
|
||||||
WARN) color="\033[1;33m" ;;
|
WARN) color="\033[1;33m" ;;
|
||||||
ERROR) color="\033[1;31m" ;;
|
ERROR) color="\033[1;31m" ;;
|
||||||
SUCCESS) color="\033[1;32m" ;;
|
SUCCESS) color="\033[1;32m" ;;
|
||||||
|
OK) color="\033[1;32m" ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
echo -e "${color}=> ${level}:\033[0m $*"
|
echo -e "${color}=> ${level}:\033[0m $*"
|
||||||
|
|
@ -200,7 +202,8 @@ function log::debug_context() {
|
||||||
function log::info() { log::context log info "$@"; }
|
function log::info() { log::context log info "$@"; }
|
||||||
function log::warn() { log::warn_context log warn "$@"; }
|
function log::warn() { log::warn_context log warn "$@"; }
|
||||||
function log::error() { log::error_context log error "$@"; }
|
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::debug() { log::debug_context log debug "$@"; }
|
||||||
|
|
||||||
function log::section() {
|
function log::section() {
|
||||||
|
|
@ -36,6 +36,12 @@ function module::is_auto_load() { declare -F "$(module::fn "$1" on_load)" >/dev/
|
||||||
function load_module() {
|
function load_module() {
|
||||||
local name="$1"
|
local name="$1"
|
||||||
|
|
||||||
|
# Wildcard: load all submodules in a directory
|
||||||
|
if [[ "$name" == *"/*" ]]; then
|
||||||
|
_load_module_dir "${name%/*}"
|
||||||
|
return $?
|
||||||
|
fi
|
||||||
|
|
||||||
module::loaded "$name" && return 0
|
module::loaded "$name" && return 0
|
||||||
|
|
||||||
local path
|
local path
|
||||||
|
|
@ -53,3 +59,20 @@ function load_module() {
|
||||||
|
|
||||||
return 0
|
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
|
||||||
|
}
|
||||||
12
core/ui.sh
12
core/ui.sh
|
|
@ -112,4 +112,16 @@ function ui::empty() {
|
||||||
# ui::empty "$var" && return 0
|
# ui::empty "$var" && return 0
|
||||||
# ui::empty "${array[*]}" && return 0
|
# ui::empty "${array[*]}" && return 0
|
||||||
[[ -z "${1// }" ]]
|
[[ -z "${1// }" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Prompt
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
function ui::confirm() {
|
||||||
|
local prompt="${1:-Are you sure?}"
|
||||||
|
local response
|
||||||
|
printf " %s [y/N] " "$prompt"
|
||||||
|
read -r response
|
||||||
|
[[ "${response,,}" == "y" || "${response,,}" == "yes" ]]
|
||||||
}
|
}
|
||||||
212
modules/identity.module.sh
Normal file
212
modules/identity.module.sh
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# identity.module.sh — identity file management and peer-name inference
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 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
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Peer name inference
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
# identity::infer <peer_name>
|
||||||
|
# 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 <identity_name> <type>
|
||||||
|
# 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
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Auto-attach (called from wgctl add)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
# identity::auto_attach <peer_name> <peer_type>
|
||||||
|
# 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" </dev/null
|
||||||
|
log::info "Attached '${peer_name}' to identity '${identity_name}' (${final_type} #${index})"
|
||||||
|
}
|
||||||
|
|
||||||
|
# identity::auto_detach <peer_name>
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Remove identity file if now empty
|
||||||
|
local remaining
|
||||||
|
remaining=$(json::identity_peers "$id_file" 2>/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 <identity_name> [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 <peer_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 <old_peer_name> <new_peer_name>
|
||||||
|
# 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" </dev/null
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove old entry
|
||||||
|
json::identity_remove_peer "$id_file" "$old_name" </dev/null
|
||||||
|
|
||||||
|
if [[ "$new_identity" == "$identity_name" ]]; then
|
||||||
|
# Same identity — update in place
|
||||||
|
json::identity_add_peer "$id_file" "$identity_name" "$new_name" "$new_type" "$new_index" </dev/null
|
||||||
|
else
|
||||||
|
# Identity changed (e.g. phone-nuno -> 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
|
||||||
|
# Clean up old identity if empty
|
||||||
|
local remaining
|
||||||
|
remaining=$(json::identity_peers "$id_file" 2>/dev/null) || true
|
||||||
|
if [[ -z "$remaining" ]]; then
|
||||||
|
rm -f "$id_file"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
@ -333,6 +333,29 @@ function peers::resolve_and_require() {
|
||||||
echo "$resolved"
|
echo "$resolved"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 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
|
# Display / Formatting
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
|
||||||
232
modules/subnet.module.sh
Normal file
232
modules/subnet.module.sh
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# subnet.module.sh — subnet map lookups, resolution, and validation
|
||||||
|
# All subnet data lives in subnets.json; this module wraps json:: calls
|
||||||
|
# and provides hardcoded fallbacks for safety.
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Hardcoded fallbacks (mirrors production subnets.json)
|
||||||
|
# Used when subnets.json lookup fails — keeps existing peers working.
|
||||||
|
# These match the legacy DEVICE_SUBNETS / DEVICE_TUNNEL_MODE maps in config.module.sh.
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
function subnet::_hardcoded_subnet() {
|
||||||
|
local type="${1:-}" subnet_name="${2:-}"
|
||||||
|
# If a subnet name was given, try legacy guest-* mapping first
|
||||||
|
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" ;;
|
||||||
|
# Legacy guest-* types — kept for existing peers during migration
|
||||||
|
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() {
|
||||||
|
# All current subnets use split — placeholder for future full-tunnel entries
|
||||||
|
echo "split"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Core resolution
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
# subnet::lookup <subnet_name> [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_subnet "" "$subnet_name"
|
||||||
|
}
|
||||||
|
|
||||||
|
# subnet::resolve_for_add <type> [subnet_name]
|
||||||
|
# Main entry point for wgctl add.
|
||||||
|
# Returns the CIDR to allocate from.
|
||||||
|
# Resolution order:
|
||||||
|
# 1. subnet_name given + type given -> subnets[subnet_name][type]
|
||||||
|
# 2. subnet_name given, no type -> subnets[subnet_name]["none"]
|
||||||
|
# 3. no subnet_name -> subnets[type] (scalar, type-native)
|
||||||
|
# 4. fallback -> hardcoded map
|
||||||
|
function subnet::resolve_for_add() {
|
||||||
|
local peer_type="${1:-}" subnet_name="${2:-}"
|
||||||
|
local result
|
||||||
|
|
||||||
|
if [[ -n "$subnet_name" ]]; then
|
||||||
|
# Try with type key first
|
||||||
|
if [[ -n "$peer_type" ]]; then
|
||||||
|
result=$(json::subnet_lookup "$(ctx::subnets)" "$subnet_name" "$peer_type" 2>/dev/null) || true
|
||||||
|
if [[ -n "$result" ]]; then echo "$result"; return 0; fi
|
||||||
|
fi
|
||||||
|
# Fall back to "none" slot in group, or scalar entry
|
||||||
|
result=$(json::subnet_lookup "$(ctx::subnets)" "$subnet_name" 2>/dev/null) || true
|
||||||
|
if [[ -n "$result" ]]; then echo "$result"; return 0; fi
|
||||||
|
# Hardcoded fallback for subnet name
|
||||||
|
subnet::_hardcoded_subnet "" "$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
|
||||||
|
if [[ -n "$result" ]]; then echo "$result"; return 0; fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
subnet::_hardcoded_subnet "$peer_type"
|
||||||
|
}
|
||||||
|
|
||||||
|
# subnet::type_for_add <type_flag> [subnet_name]
|
||||||
|
# Returns the canonical type string to store in meta.
|
||||||
|
# If --subnet given and it's a scalar, type comes from subnets.json entry.
|
||||||
|
# If --subnet is a group, type comes from --type flag (or "none").
|
||||||
|
# If no --subnet, type comes from --type flag directly.
|
||||||
|
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
|
||||||
|
if [[ -n "$result" ]]; then echo "$result"; return 0; fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# No subnet or lookup failed — use the type flag directly
|
||||||
|
if [[ -n "$type_flag" ]]; then
|
||||||
|
echo "$type_flag"
|
||||||
|
else
|
||||||
|
echo "none"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# subnet::tunnel_mode <subnet_name> [type_key]
|
||||||
|
# Returns "split" or "full" for the given subnet.
|
||||||
|
function subnet::tunnel_mode() {
|
||||||
|
local subnet_name="${1:-}" type_key="${2:-}"
|
||||||
|
local result
|
||||||
|
result=$(json::subnet_tunnel_mode "$(ctx::subnets)" "$subnet_name" "$type_key" 2>/dev/null) || true
|
||||||
|
if [[ -n "$result" ]]; then echo "$result"; return 0; fi
|
||||||
|
subnet::_hardcoded_tunnel_mode
|
||||||
|
}
|
||||||
|
|
||||||
|
# subnet::type_from_ip <ip>
|
||||||
|
# Reverse-lookup: given a peer's IP, return its type.
|
||||||
|
# Tries meta file first (by peer name), then subnets.json, then hardcoded.
|
||||||
|
function subnet::type_from_ip() {
|
||||||
|
local ip="${1:-}"
|
||||||
|
local result
|
||||||
|
|
||||||
|
result=$(json::subnet_for_ip "$(ctx::subnets)" "$ip" 2>/dev/null) || true
|
||||||
|
if [[ -n "$result" ]]; then
|
||||||
|
# result is "subnet_name|type_key"
|
||||||
|
echo "${result##*|}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
subnet::_hardcoded_type "$ip"
|
||||||
|
}
|
||||||
|
|
||||||
|
# subnet::name_from_ip <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
|
||||||
|
if [[ -n "$result" ]]; then
|
||||||
|
echo "${result%%|*}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Validation
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
# subnet::exists <name>
|
||||||
|
# Returns 0 if subnet exists in subnets.json, 1 otherwise.
|
||||||
|
function subnet::exists() {
|
||||||
|
local name="${1:-}"
|
||||||
|
json::subnet_exists "$(ctx::subnets)" "$name" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# subnet::require_exists <name>
|
||||||
|
# Errors and exits if subnet doesn't exist.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
# subnet::peers_using <name>
|
||||||
|
# Returns comma-separated list of peer names using this subnet (from meta).
|
||||||
|
# Empty string if none.
|
||||||
|
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 helpers
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
# subnet::list_data
|
||||||
|
# Returns all subnet entries formatted for display.
|
||||||
|
# Output per line: display_name|subnet|type|tunnel_mode|desc|is_group|group_parent
|
||||||
|
function subnet::list_data() {
|
||||||
|
json::subnet_list "$(ctx::subnets)" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# subnet::show_data <name>
|
||||||
|
# Returns detail lines for a single subnet entry.
|
||||||
|
function subnet::show_data() {
|
||||||
|
local name="${1:-}"
|
||||||
|
json::subnet_show "$(ctx::subnets)" "$name"
|
||||||
|
}
|
||||||
12
modules/ui.module.sh
Normal file
12
modules/ui.module.sh
Normal file
|
|
@ -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/*"
|
||||||
|
}
|
||||||
55
modules/ui/identity.module.sh
Normal file
55
modules/ui/identity.module.sh
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
#!/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
|
||||||
|
}
|
||||||
51
modules/ui/subnet.module.sh
Normal file
51
modules/ui/subnet.module.sh
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
#!/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::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::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//,/, }"
|
||||||
|
}
|
||||||
4
wgctl
4
wgctl
|
|
@ -9,8 +9,8 @@ LOG_LEVEL=DEBUG
|
||||||
# Modules
|
# Modules
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
load_module log
|
|
||||||
load_module config
|
load_module config
|
||||||
|
load_module ui
|
||||||
load_module ip
|
load_module ip
|
||||||
load_module keys
|
load_module keys
|
||||||
load_module peers
|
load_module peers
|
||||||
|
|
@ -20,6 +20,8 @@ load_module rule
|
||||||
load_module block
|
load_module block
|
||||||
load_module net
|
load_module net
|
||||||
load_module group
|
load_module group
|
||||||
|
load_module subnet
|
||||||
|
load_module identity
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Alias Map
|
# Alias Map
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue