diff --git a/commands/identity.command.sh b/commands/identity.command.sh new file mode 100644 index 0000000..4b2643f --- /dev/null +++ b/commands/identity.command.sh @@ -0,0 +1,284 @@ +#!/usr/bin/env bash +# identity.command.sh — manage peer identities +# +# Subcommands: +# wgctl identity list +# wgctl identity show --name +# wgctl identity add --name --peer +# wgctl identity remove --name +# wgctl identity migrate [--dry-run] + +# ============================================ +# Lifecycle +# ============================================ + +function cmd::identity::on_load() { + flag::register --name + flag::register --peer + flag::register --dry-run + flag::register --force +} + +# ============================================ +# Help +# ============================================ + +function cmd::identity::help() { + cat < [options] + +Manage peer identities. + +Subcommands: + list List all identities + show --name Show identity details and device status + add --name Manually attach a peer to an identity + --peer + remove --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 || echo "none" + fi +} + +function cmd::identity::_remove() { + local name="" force=false + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + --force) force=true; shift ;; + --help) cmd::identity::help; return ;; + *) log::error "Unknown flag: $1"; return 1 ;; + esac + done + + [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } + identity::require_exists "$name" || return 1 + + local peers + peers=$(identity::peers "$name") + + if [[ -n "$peers" ]]; then + local peer_list="${peers//$'\n'/, }" + log::warn "This will permanently remove identity '${name}' and ALL associated peers:" + log::warn " ${peer_list}" + + if ! $force; then + ui::confirm "Continue?" || { log::info "Aborted"; return 0; } + fi + + cmd::identity::_remove_all_peers "$peers" + peers::reload || return 1 + fi + + local id_file + id_file=$(ctx::identity::path "${name}.identity") + json::identity_remove "$id_file" /dev/null) || client_ip="" + peers::is_blocked "$peer_name" && was_blocked=true + + peers::purge "$peer_name" "$client_ip" "$was_blocked" || return 1 + log::ok "Removed peer '${peer_name}'" +} + +function cmd::identity::_migrate() { + local dry_run="false" + while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) dry_run="true"; shift ;; + --help) cmd::identity::help; return ;; + *) log::error "Unknown flag: $1"; return 1 ;; + esac + done + + [[ "$dry_run" == "true" ]] && log::info "Dry run — no files will be written" + echo "" + + [[ "$dry_run" == "false" ]] && mkdir -p "$(ctx::identities)" + + local created=0 skipped=0 output + output=$(json::identity_migrate \ + "$(ctx::identities)" \ + "$(ctx::clients)" \ + "$(ctx::meta)" \ + "$dry_run") + + while IFS='|' read -r action identity_name peer_name peer_type index; do + case "$action" in + create) + ui::identity::migrate_create "$peer_name" "$identity_name" "$peer_type" "$index" + (( created++ )) || true + ;; + skip) + ui::identity::migrate_skip "$peer_name" + (( skipped++ )) || true + ;; + esac + done <<< "$output" + + ui::identity::migrate_summary "$created" "$skipped" "$dry_run" +} \ No newline at end of file diff --git a/commands/list.command.sh b/commands/list.command.sh index 69cbd46..d09586c 100644 --- a/commands/list.command.sh +++ b/commands/list.command.sh @@ -289,75 +289,6 @@ function cmd::list::_iter_confs() { 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() { local client_name="$1" ip="$2" type="$3" diff --git a/commands/remove.command.sh b/commands/remove.command.sh index ff5a3e9..dac3af8 100644 --- a/commands/remove.command.sh +++ b/commands/remove.command.sh @@ -36,16 +36,14 @@ EOF # ============================================ function cmd::remove::run() { - local name="" - local type="" - local force=false + local name="" type="" force=false while [[ $# -gt 0 ]]; do case "$1" in - --name) name="$2"; shift 2 ;; - --type) type="$2"; shift 2 ;; - --force) force=true; shift ;; - --help) cmd::remove::help; return ;; + --name) name="$2"; shift 2 ;; + --type) type="$2"; shift 2 ;; + --force) force=true; shift ;; + --help) cmd::remove::help; return ;; *) log::error "Unknown flag: $1" cmd::remove::help @@ -62,7 +60,6 @@ function cmd::remove::run() { name=$(peers::resolve_and_require "$name" "$type") || return 1 - # Confirmation prompt unless --force if ! $force; then read -r -p "Are you sure you want to permanently remove '${name}'? [y/N] " confirm case "$confirm" in @@ -76,27 +73,20 @@ function cmd::remove::run() { log::section "Removing client: ${name}" - local client_ip + local client_ip was_blocked=false client_ip=$(peers::get_ip "$name") - - local was_blocked=false 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}" } +# _cleanup kept as a shim — callers should prefer peers::purge directly function cmd::remove::_cleanup() { 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 - - [[ -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 + peers::purge "$name" "$client_ip" "$was_blocked" } \ No newline at end of file diff --git a/commands/rename.command.sh b/commands/rename.command.sh index a76213d..808757c 100644 --- a/commands/rename.command.sh +++ b/commands/rename.command.sh @@ -23,8 +23,8 @@ Rename an existing WireGuard client. The client IP and keys are preserved, only the name changes. Options: - --name Current client name (e.g. phone-phone-nuno) - --new-name New client name (e.g. phone-nuno) + --name Current client name (e.g. phone-nuno) + --new-name New client name (e.g. laptop-nuno) Examples: wgctl rename --name phone-phone-nuno --new-name phone-nuno @@ -37,10 +37,7 @@ EOF # ============================================ function cmd::rename::run() { - local name="" - local type="" - local new_name="" - local new_type="" + local name="" type="" new_name="" new_type="" while [[ $# -gt 0 ]]; do case "$1" in @@ -69,7 +66,7 @@ function cmd::rename::run() { return 1 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 local dir @@ -88,10 +85,11 @@ function cmd::rename::run() { log::section "Renaming client: ${name} → ${new_name}" cmd::rename::_rename_files "$name" "$new_name" - - # Reload WireGuard peers::reload + # Update identity entry after successful rename + identity::rename_peer "$name" "$new_name" + log::wg_success "Client renamed: ${name} → ${new_name}" } diff --git a/commands/subnet.command.sh b/commands/subnet.command.sh new file mode 100644 index 0000000..e80bde7 --- /dev/null +++ b/commands/subnet.command.sh @@ -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 +# wgctl subnet add --name --subnet [--type ] +# [--tunnel-mode split|full] [--desc ] +# [--group ] +# wgctl subnet rm --name +# wgctl subnet rename --name --new-name + +# ============================================ +# 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 < [options] + +Manage the subnet map. + +Subcommands: + list List all configured subnets + show --name Show details for a subnet + add --name Add a new subnet entry + --subnet + [--type ] + [--tunnel-mode split|full] + [--desc ] + [--group ] + rm --name Remove a subnet (refused if in use) + rename --name Rename a subnet (refused if in use) + --new-name + +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 +} \ No newline at end of file diff --git a/commands/test.command.sh b/commands/test.command.sh index 866421c..a961170 100644 --- a/commands/test.command.sh +++ b/commands/test.command.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash - -WGCTL_BINARY="$(command -v wgctl)" +# test.command.sh — wgctl test suite dispatcher +# Delegates to commands/test/{integration,unit,destructive,fn}.sh # ============================================ # Lifecycle @@ -11,8 +11,15 @@ function cmd::test::on_load() { flag::register --section flag::register --fn flag::register --function + flag::register --unit + flag::register --integration + flag::register --verbose } +# ============================================ +# Help +# ============================================ + function cmd::test::help() { cat < Run only a specific section (list, rules, groups, audit, logs, fw) + --unit Run unit tests (pure function tests, no side effects) + --integration Run integration tests against the live binary (default) + --destructive Include destructive tests (add/remove/block state changes) + --section Run only a specific section + --fn 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: wgctl test - wgctl test --section rules + wgctl test --unit + wgctl test --unit --section subnet + wgctl test --integration --section rules wgctl test --destructive + wgctl test --fn cmd::block::run EOF } # ============================================ -# Test helpers +# Loader # ============================================ -function cmd::test::run_cmd() { - local desc="$1" - local expected="${2:-}" - shift 2 - - local tmp exit_code - 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" +function cmd::test::_load() { + local name="${1:-}" + local path + path="$(ctx::commands)/test/${name}.sh" + if [[ ! -f "$path" ]]; then + log::error "Test file not found: ${path}" 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 # 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 + source "$path" } # ============================================ -# Test sections -# ============================================ - -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 +# Run (entrypoint) # ============================================ function cmd::test::run() { - local destructive=false section="" - local fn="" + local unit=false integration=false destructive=false + local section="" fn="" while [[ $# -gt 0 ]]; do case "$1" in - --destructive) destructive=true; shift ;; - --section) - util::require_flag "--section" "${2:-}" || return 1 - section="$2"; shift 2 - ;; - --fn|--function) fn="$2"; shift 2 ;; - --verbose|-v) WGCTL_TEST_VERBOSE=true; shift ;; - --help) cmd::test::help; return ;; + --unit) unit=true; shift ;; + --integration) integration=true; shift ;; + --destructive) destructive=true; shift ;; + --section) section="$2"; shift 2 ;; + --fn|--function) fn="$2"; shift 2 ;; + --verbose|-v) WGCTL_TEST_VERBOSE=true; shift ;; + --help) cmd::test::help; return ;; *) log::error "Unknown flag: $1" return 1 @@ -461,46 +89,77 @@ function cmd::test::run() { esac done - # After flag parsing: + # Function test — load fn.sh + integration.sh (needs run_cmd helpers) 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 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 log::section "wgctl Test Suite" - - if [[ -n "$section" ]]; then - 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 - + cmd::test::_dispatch_integration "$section" + $destructive && cmd::test::section_destructive 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" } \ No newline at end of file diff --git a/commands/test/destructive.sh b/commands/test/destructive.sh new file mode 100644 index 0000000..06e0442 --- /dev/null +++ b/commands/test/destructive.sh @@ -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 +} \ No newline at end of file diff --git a/commands/test/fn.sh b/commands/test/fn.sh new file mode 100644 index 0000000..1c0de42 --- /dev/null +++ b/commands/test/fn.sh @@ -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 +} \ No newline at end of file diff --git a/commands/test/integration.sh b/commands/test/integration.sh new file mode 100644 index 0000000..26741aa --- /dev/null +++ b/commands/test/integration.sh @@ -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 +} \ No newline at end of file diff --git a/commands/test/unit.sh b/commands/test/unit.sh new file mode 100644 index 0000000..11afac7 --- /dev/null +++ b/commands/test/unit.sh @@ -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')" "" +} \ No newline at end of file diff --git a/core.sh b/core.sh index 2a4b78f..0a1d78a 100644 --- a/core.sh +++ b/core.sh @@ -6,6 +6,7 @@ WGCTL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${WGCTL_DIR}/core/log.sh" source "${WGCTL_DIR}/core/context.sh" source "${WGCTL_DIR}/core/utils.sh" source "${WGCTL_DIR}/core/module.sh" diff --git a/core/color.sh b/core/color.sh index 6e0e7f1..32893ee 100644 --- a/core/color.sh +++ b/core/color.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::cyan() { printf "\033[0;36m%s\033[0m" "${1:-}"; } function color::yellow() { printf "\033[1;33m%s\033[0m" "${1:-}"; } +function color::dim() { printf "\033[2m%s\033[0m" "$*"; } \ No newline at end of file diff --git a/core/context.sh b/core/context.sh index a05379e..911ff5c 100644 --- a/core/context.sh +++ b/core/context.sh @@ -21,6 +21,7 @@ _CTX_RULES_BASE="${_CTX_RULES}/base" _CTX_GROUPS="${_CTX_DATA}/groups" _CTX_BLOCKS="${_CTX_DATA}/blocks" _CTX_META="${_CTX_DATA}/meta" +_CTX_IDENTITY="${_CTX_DATA}/identities" _CTX_DAEMON="${_CTX_DATA}/daemon" _CTX_NET="${_CTX_DATA}/services.json" @@ -43,6 +44,8 @@ function ctx::blocks() { echo "$_CTX_BLOCKS"; } function ctx::meta() { echo "$_CTX_META"; } function ctx::daemon() { echo "$_CTX_DAEMON"; } 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::fw_events_log() { echo "$(ctx::daemon)/fw_events.log"; } @@ -60,6 +63,11 @@ function ctx::meta::path() { echo "$_CTX_META/$*" } +function ctx::identity::path() { + local IFS="/" + echo "$_CTX_IDENTITY/$*" +} + function ctx::block::path() { local IFS="/" echo "$_CTX_BLOCKS/$*" diff --git a/core/json.sh b/core/json.sh index 9f0dc78..2c6aa4a 100644 --- a/core/json.sh +++ b/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 "$@" "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 = { 'get': lambda args: get(args[0], args[1]), '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]), 'peer_group_map': lambda args: peer_group_map(args[0]), '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]), 'rule_list_data': lambda args: rule_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]), '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__': diff --git a/modules/log.module.sh b/core/log.sh similarity index 98% rename from modules/log.module.sh rename to core/log.sh index cd73483..6fdf9dd 100644 --- a/modules/log.module.sh +++ b/core/log.sh @@ -15,6 +15,7 @@ function internal::get_log_priority() { DEBUG) echo 0 ;; INFO) echo 1 ;; SUCCESS) echo 1 ;; + OK) echo 1 ;; WARN) echo 2 ;; ERROR) echo 3 ;; *) echo 1 ;; @@ -48,6 +49,7 @@ function internal::log() { WARN) color="\033[1;33m" ;; ERROR) color="\033[1;31m" ;; SUCCESS) color="\033[1;32m" ;; + OK) color="\033[1;32m" ;; esac echo -e "${color}=> ${level}:\033[0m $*" @@ -200,7 +202,8 @@ function log::debug_context() { function log::info() { log::context log info "$@"; } function log::warn() { log::warn_context log warn "$@"; } 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::section() { diff --git a/core/module.sh b/core/module.sh index 29c0ef0..5bd16d2 100644 --- a/core/module.sh +++ b/core/module.sh @@ -36,6 +36,12 @@ function module::is_auto_load() { declare -F "$(module::fn "$1" on_load)" >/dev/ function load_module() { local name="$1" + # Wildcard: load all submodules in a directory + if [[ "$name" == *"/*" ]]; then + _load_module_dir "${name%/*}" + return $? + fi + module::loaded "$name" && return 0 local path @@ -53,3 +59,20 @@ function load_module() { 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 +} \ No newline at end of file diff --git a/core/ui.sh b/core/ui.sh index c1be533..086671d 100644 --- a/core/ui.sh +++ b/core/ui.sh @@ -112,4 +112,16 @@ function ui::empty() { # ui::empty "$var" && return 0 # ui::empty "${array[*]}" && return 0 [[ -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" ]] } \ No newline at end of file diff --git a/modules/identity.module.sh b/modules/identity.module.sh new file mode 100644 index 0000000..e842fb9 --- /dev/null +++ b/modules/identity.module.sh @@ -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 +# 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 +# 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 +# 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" +# 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) || true + if [[ -z "$remaining" ]]; then + rm -f "$id_file" + log::info "Identity '${identity_name}' removed (no remaining peers)" + fi +} + +# =========================================================================== +# Peer queries +# =========================================================================== + +# identity::peers [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 +# 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 +# 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" 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) || true + if [[ -z "$remaining" ]]; then + rm -f "$id_file" + fi + fi +} \ No newline at end of file diff --git a/modules/peers.module.sh b/modules/peers.module.sh index 44216bf..96ddadd 100644 --- a/modules/peers.module.sh +++ b/modules/peers.module.sh @@ -333,6 +333,29 @@ function peers::resolve_and_require() { 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 # ============================================ diff --git a/modules/subnet.module.sh b/modules/subnet.module.sh new file mode 100644 index 0000000..ad5606b --- /dev/null +++ b/modules/subnet.module.sh @@ -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 [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 [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 [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 [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 +# 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 +# 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 +# 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 +# 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 +# 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 +# Returns detail lines for a single subnet entry. +function subnet::show_data() { + local name="${1:-}" + json::subnet_show "$(ctx::subnets)" "$name" +} \ No newline at end of file diff --git a/modules/ui.module.sh b/modules/ui.module.sh new file mode 100644 index 0000000..995a81d --- /dev/null +++ b/modules/ui.module.sh @@ -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/*" +} \ No newline at end of file diff --git a/modules/ui/identity.module.sh b/modules/ui/identity.module.sh new file mode 100644 index 0000000..ae77cde --- /dev/null +++ b/modules/ui/identity.module.sh @@ -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 +} \ No newline at end of file diff --git a/modules/ui/subnet.module.sh b/modules/ui/subnet.module.sh new file mode 100644 index 0000000..987c35c --- /dev/null +++ b/modules/ui/subnet.module.sh @@ -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//,/, }" +} \ No newline at end of file diff --git a/wgctl b/wgctl index 2d19499..6065b2f 100755 --- a/wgctl +++ b/wgctl @@ -9,8 +9,8 @@ LOG_LEVEL=DEBUG # Modules # ============================================ -load_module log load_module config +load_module ui load_module ip load_module keys load_module peers @@ -20,6 +20,8 @@ load_module rule load_module block load_module net load_module group +load_module subnet +load_module identity # ============================================ # Alias Map