diff --git a/commands/export.command.sh b/commands/export.command.sh new file mode 100644 index 0000000..cf434c2 --- /dev/null +++ b/commands/export.command.sh @@ -0,0 +1,304 @@ +#!/usr/bin/env bash +# commands/export.command.sh + +function cmd::export::on_load() { + flag::register --peer + flag::register --identity + flag::register --all + flag::register --out + flag::register --conf-only + flag::register --meta-only + flag::register --no-config + flag::register --no-peers + flag::register --force +} + +function cmd::export::help() { + cat < Export a single peer (conf, meta, groups, identity, blocks) + --identity Export an identity + --all Full backup (all peers, rules, identities, groups, etc.) + --out Write to file instead of stdout + --conf-only Export peer conf only (with --peer) + --meta-only Export peer meta only (with --peer) + --no-config Skip wgctl.json (with --all) + --no-peers Skip peer confs (with --all) + --force Overwrite existing output file + +Examples: + wgctl export --peer phone-nuno + wgctl export --peer phone-nuno --out phone-nuno.json + wgctl export --identity nuno --out nuno.json + wgctl export --all --out backup.json + wgctl export --all --no-config --out data-only.json +EOF +} + +function cmd::export::run() { + local peer="" identity="" all=false out="" + local conf_only=false meta_only=false + local no_config=false no_peers=false force=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --peer) peer="$2"; shift 2 ;; + --identity) identity="$2"; shift 2 ;; + --all) all=true; shift ;; + --out) out="$2"; shift 2 ;; + --conf-only) conf_only=true; shift ;; + --meta-only) meta_only=true; shift ;; + --no-config) no_config=true; shift ;; + --no-peers) no_peers=true; shift ;; + --force) force=true; shift ;; + --help) cmd::export::help; return ;; + *) log::error "Unknown flag: $1"; return 1 ;; + esac + done + + # Validate + local mode_count=0 + [[ -n "$peer" ]] && (( mode_count++ )) || true + [[ -n "$identity" ]] && (( mode_count++ )) || true + $all && (( mode_count++ )) || true + + if [[ "$mode_count" -eq 0 ]]; then + log::error "Specify --peer, --identity, or --all" + cmd::export::help + return 1 + fi + if [[ "$mode_count" -gt 1 ]]; then + log::error "Only one of --peer, --identity, --all can be used at a time" + return 1 + fi + + # Check output file + if [[ -n "$out" && -f "$out" && ! $force ]]; then + log::error "Output file already exists: ${out} (use --force to overwrite)" + return 1 + fi + + local json="" + if [[ -n "$peer" ]]; then + json=$(cmd::export::_peer "$peer" "$conf_only" "$meta_only") || return 1 + elif [[ -n "$identity" ]]; then + json=$(cmd::export::_identity "$identity") || return 1 + elif $all; then + json=$(cmd::export::_full "$no_config" "$no_peers") || return 1 + fi + + if [[ -n "$out" ]]; then + echo "$json" > "$out" + log::wg_success "Exported to ${out}" + else + echo "$json" + fi +} + +# ====================================================== +# Peer export +# ====================================================== + +function cmd::export::_peer() { + local name="${1:-}" conf_only="${2:-false}" meta_only="${3:-false}" + + peers::require_exists "$name" || return 1 + + local conf_file + conf_file="$(ctx::clients)/${name}.conf" + [[ ! -f "$conf_file" ]] && log::error "Client conf not found: ${conf_file}" && return 1 + + local conf_b64 + conf_b64=$(base64 -w 0 < "$conf_file" 2>/dev/null || base64 < "$conf_file") + + if $conf_only; then + cmd::export::_envelope "peer_conf" \ + "$(printf '{"name":"%s","conf":"%s"}' "$name" "$conf_b64")" + return 0 + fi + + # Meta + local meta_file meta_json="{}" + meta_file="$(ctx::meta)/${name}.meta" + [[ -f "$meta_file" ]] && meta_json=$(cat "$meta_file") + + if $meta_only; then + cmd::export::_envelope "peer_meta" \ + "$(printf '{"name":"%s","meta":%s}' "$name" "$meta_json")" + return 0 + fi + + # Public key + local public_key="" + local key_file + key_file="$(ctx::clients)/${name}_public.key" + [[ -f "$key_file" ]] && public_key=$(cat "$key_file") + + # IP + local ip + ip=$(peers::get_ip "$name") + + # Type + local peer_type + peer_type=$(peers::get_type "$name" 2>/dev/null || echo "") + + # Direct rule + local direct_rule + direct_rule=$(peers::get_meta "$name" "rule" 2>/dev/null || echo "") + + # Identity + local identity + identity=$(peers::get_identity "$name" 2>/dev/null || echo "") + + # Groups + local -a group_list=() + while IFS= read -r g; do + [[ -n "$g" ]] && group_list+=("\"$g\"") + done < <(json::peer_groups "$(ctx::groups)" "$name" 2>/dev/null) + local groups_json="[]" + [[ ${#group_list[@]} -gt 0 ]] && \ + groups_json="[$(printf '%s,' "${group_list[@]}" | sed 's/,$//')]" + + # Blocks + local block_file is_blocked="false" block_json="null" + block_file="$(ctx::blocks)/${name}.block" + if [[ -f "$block_file" ]]; then + is_blocked="true" + block_json=$(base64 -w 0 < "$block_file" 2>/dev/null || base64 < "$block_file") + block_json="\"${block_json}\"" + fi + + local peer_data + peer_data=$(printf \ + '{"name":"%s","ip":"%s","type":"%s","public_key":"%s","conf":"%s","meta":%s,"identity":"%s","groups":%s,"direct_rule":"%s","blocks":{"is_blocked":%s,"block_file":%s}}' \ + "$name" "$ip" "$peer_type" "$public_key" "$conf_b64" \ + "$meta_json" "$identity" "$groups_json" "$direct_rule" \ + "$is_blocked" "$block_json") + + cmd::export::_envelope "peer" "$peer_data" +} + +# ====================================================== +# Identity export +# ====================================================== + +function cmd::export::_identity() { + local name="${1:-}" + identity::require_exists "$name" || return 1 + + local id_file + id_file="$(ctx::identities)/${name}.identity" + local id_json + id_json=$(cat "$id_file") + + cmd::export::_envelope "identity" \ + "$(printf '{"name":"%s","identity":%s}' "$name" "$id_json")" +} + +# ====================================================== +# Full backup +# ====================================================== + +function cmd::export::_full() { + local no_config="${1:-false}" no_peers="${2:-false}" + local version + version=$(wgctl::version 2>/dev/null || echo "unknown") + + python3 "$(ctx::json_helper)" export_full \ + "$(ctx::clients)" \ + "$(ctx::meta)" \ + "$(ctx::rules)" \ + "$(ctx::identities)" \ + "$(ctx::groups)" \ + "$(ctx::blocks)" \ + "$(ctx::config_file)" \ + "$(ctx::policies)" \ + "$(ctx::subnets)" \ + "$(ctx::net)" \ + "$(ctx::hosts)" \ + "$no_config" \ + "$no_peers" \ + "$version" \ + 2>/dev/null +} + +# Helper — peer data without envelope (used by full backup) +function cmd::export::_peer_data() { + local name="${1:-}" + local conf_file + conf_file="$(ctx::clients)/${name}.conf" + [[ ! -f "$conf_file" ]] && return 0 + + local conf_b64 + conf_b64=$(base64 -w 0 < "$conf_file" 2>/dev/null || base64 < "$conf_file") + + local meta_file meta_json="{}" + meta_file="$(ctx::meta)/${name}.meta" + [[ -f "$meta_file" ]] && meta_json=$(cat "$meta_file") + + local public_key="" + local key_file + key_file="$(ctx::clients)/${name}_public.key" + [[ -f "$key_file" ]] && public_key=$(cat "$key_file") + + local ip + ip=$(peers::get_ip "$name") + + local peer_type + peer_type=$(peers::get_type "$name" 2>/dev/null || echo "") + + local direct_rule + direct_rule=$(peers::get_meta "$name" "rule" 2>/dev/null || echo "") + + local identity + identity=$(peers::get_identity "$name" 2>/dev/null || echo "") + + local -a group_list=() + while IFS= read -r g; do + [[ -n "$g" ]] && group_list+=("\"$g\"") + done < <(json::peer_groups "$(ctx::groups)" "$name" 2>/dev/null) + local groups_json="[]" + [[ ${#group_list[@]} -gt 0 ]] && \ + groups_json="[$(printf '%s,' "${group_list[@]}" | sed 's/,$//')]" + + local block_file is_blocked="false" block_json="null" + block_file="$(ctx::blocks)/${name}.block" + if [[ -f "$block_file" ]]; then + is_blocked="true" + block_json="\"$(base64 -w 0 < "$block_file" 2>/dev/null || base64 < "$block_file")\"" + fi + + printf \ + '{"name":"%s","ip":"%s","type":"%s","public_key":"%s","conf":"%s","meta":%s,"identity":"%s","groups":%s,"direct_rule":"%s","blocks":{"is_blocked":%s,"block_file":%s}}' \ + "$name" "$ip" "$peer_type" "$public_key" "$conf_b64" \ + "$meta_json" "$identity" "$groups_json" "$direct_rule" \ + "$is_blocked" "$block_json" +} + +# ====================================================== +# Envelope helper +# ====================================================== + +function cmd::export::_envelope() { + local export_type="${1:-}" data="${2:-}" + local version ts + version=$(wgctl::version 2>/dev/null || echo "unknown") + ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + printf '{"wgctl_version":"%s","export_type":"%s","exported_at":"%s","data":%s}\n' \ + "$version" "$export_type" "$ts" "$data" +} + +function cmd::export::_compact_json() { + local file="$1" + python3 -c " +import json, sys +try: + print(json.dumps(json.load(open('${file}')))) +except Exception as e: + print('{}', file=sys.stderr) +" 2>/dev/null +} \ No newline at end of file diff --git a/commands/import.command.sh b/commands/import.command.sh new file mode 100644 index 0000000..95c6780 --- /dev/null +++ b/commands/import.command.sh @@ -0,0 +1,288 @@ +#!/usr/bin/env bash +# commands/import.command.sh + +function cmd::import::on_load() { + flag::register --file + flag::register --peer + flag::register --dry-run + flag::register --force +} + +function cmd::import::help() { + cat < [options] + +Import a wgctl JSON export bundle. + +Options: + --file Path to export bundle (required) + --peer Import only this peer from a full backup + --dry-run Show what would be imported without making changes + --force Overwrite existing data + +Export types handled: + peer Single peer bundle + peer_conf Peer conf only + peer_meta Peer meta only + identity Identity bundle + full Full backup + +Examples: + wgctl import --file backup.json + wgctl import --file backup.json --peer phone-nuno + wgctl import --file phone-nuno.json --dry-run + wgctl import --file backup.json --force +EOF +} + +function cmd::import::run() { + local file="" peer="" dry_run=false force=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --file) file="$2"; shift 2 ;; + --peer) peer="$2"; shift 2 ;; + --dry-run) dry_run=true; shift ;; + --force) force=true; shift ;; + --help) cmd::import::help; return ;; + *) log::error "Unknown flag: $1"; return 1 ;; + esac + done + + [[ -z "$file" ]] && log::error "Missing required flag: --file" && return 1 + [[ ! -f "$file" ]] && log::error "File not found: ${file}" && return 1 + + # Read export metadata via Python + local export_type export_version + export_type=$(json::import_get_field \ + "$file" "export_type" 2>/dev/null) + export_version=$(json::import_get_field \ + "$file" "wgctl_version" 2>/dev/null) + + [[ -z "$export_type" ]] && \ + log::error "Invalid export file: ${file}" && return 1 + + # Version check + local current_version + current_version=$(wgctl::version 2>/dev/null || echo "unknown") + if [[ "$export_version" != "$current_version" ]]; then + log::wg_warning "Export version (${export_version}) differs from current (${current_version})" + fi + + log::section "wgctl Import" + printf " File: %s\n" "$file" + printf " Type: %s\n" "$export_type" + $dry_run && printf " Mode: \033[2mdry run\033[0m\n" + printf "\n" + + case "$export_type" in + peer) cmd::import::_peer "$file" "$dry_run" "$force" ;; + peer_conf) cmd::import::_peer_conf "$file" "$dry_run" "$force" ;; + peer_meta) cmd::import::_peer_meta "$file" "$dry_run" "$force" ;; + identity) cmd::import::_identity "$file" "$dry_run" "$force" ;; + full) + if [[ -n "$peer" ]]; then + cmd::import::_peer_from_full "$file" "$peer" "$dry_run" "$force" + else + cmd::import::_full "$file" "$dry_run" "$force" + fi + ;; + *) + log::error "Unknown export type: ${export_type}" + return 1 + ;; + esac +} + +# ====================================================== +# Helpers +# ====================================================== + +function cmd::import::_print_results() { + while IFS= read -r line; do + [[ -z "$line" ]] && continue + case "$line" in + error:*) log::error "${line#error:}" ;; + skip:*) printf " \033[2mskip: %s (already exists)\033[0m\n" "${line#skip:}" ;; + peer:*) printf " ✓ peer: %s\n" "${line#peer:}" ;; + group:*) printf " ✓ group: %s\n" "${line#group:}" ;; + *) printf " ✓ %s\n" "$line" ;; + esac + done +} + +# ====================================================== +# Peer import +# ====================================================== + +function cmd::import::_peer() { + local file="${1:-}" dry_run="${2:-false}" force="${3:-false}" + + local name + name=$(json::import_get_field "$file" "data" "name" 2>/dev/null) + [[ -z "$name" ]] && log::error "Could not read peer name from export" && return 1 + + $dry_run && \ + printf " \033[2m[dry-run]\033[0m Would import peer '%s'\n" "$name" && return 0 + + local err + err=$(json::import_peer \ + "$file" "data" "$name" \ + "$(ctx::clients)" "$(ctx::meta)" \ + "$(ctx::groups)" "$(ctx::blocks)" \ + "$($force && echo true || echo false)" \ + 2>&1 >/dev/null) || { log::error "$err"; return 1; } + + json::import_peer \ + "$file" "data" "$name" \ + "$(ctx::clients)" "$(ctx::meta)" \ + "$(ctx::groups)" "$(ctx::blocks)" \ + "$($force && echo true || echo false)" \ + 2>/dev/null | cmd::import::_print_results + + log::wg_success "Imported peer '${name}'" +} + +# ====================================================== +# Peer conf only +# ====================================================== + +function cmd::import::_peer_conf() { + local file="${1:-}" dry_run="${2:-false}" force="${3:-false}" + + local name + name=$(json::import_get_field "$file" "data" "name" 2>/dev/null) + local conf_file="$(ctx::clients)/${name}.conf" + + if [[ -f "$conf_file" ]] && ! $force; then + log::error "Peer conf '${name}' already exists. Use --force to overwrite." + return 1 + fi + + $dry_run && \ + printf " \033[2m[dry-run]\033[0m Would import conf for '%s'\n" "$name" && return 0 + + python3 -c " +import json, base64 +d = json.load(open('${file}')) +conf = base64.b64decode(d['data']['conf']).decode() +open('${conf_file}', 'w').write(conf) +" 2>/dev/null || { log::error "Failed to write conf"; return 1; } + + printf " ✓ conf\n" + log::wg_success "Imported conf for '${name}'" +} + +# ====================================================== +# Peer meta only +# ====================================================== + +function cmd::import::_peer_meta() { + local file="${1:-}" dry_run="${2:-false}" force="${3:-false}" + + local name + name=$(json::import_get_field "$file" "data" "name" 2>/dev/null) + + [[ ! -f "$(ctx::clients)/${name}.conf" ]] && \ + log::error "Peer '${name}' does not exist — import the peer conf first" && return 1 + + $dry_run && \ + printf " \033[2m[dry-run]\033[0m Would import meta for '%s'\n" "$name" && return 0 + + python3 -c " +import json +d = json.load(open('${file}')) +meta = d['data']['meta'] +open('$(ctx::meta)/${name}.meta', 'w').write(json.dumps(meta, indent=2)) +" 2>/dev/null || { log::error "Failed to write meta"; return 1; } + + printf " ✓ meta\n" + log::wg_success "Imported meta for '${name}'" +} + +# ====================================================== +# Identity import +# ====================================================== + +function cmd::import::_identity() { + local file="${1:-}" dry_run="${2:-false}" force="${3:-false}" + + local name + name=$(json::import_get_field "$file" "data" "name" 2>/dev/null) + + $dry_run && \ + printf " \033[2m[dry-run]\033[0m Would import identity '%s'\n" "$name" && return 0 + + local err + err=$(json::import_identity \ + "$file" "$name" \ + "$(ctx::identities)" "$(ctx::clients)" \ + "$($force && echo true || echo false)" \ + 2>&1 >/dev/null) || { log::error "$err"; return 1; } + + json::import_identity \ + "$file" "$name" \ + "$(ctx::identities)" "$(ctx::clients)" \ + "$($force && echo true || echo false)" \ + 2>/dev/null | cmd::import::_print_results + + log::wg_success "Imported identity '${name}'" +} + +# ====================================================== +# Single peer from full backup +# ====================================================== + +function cmd::import::_peer_from_full() { + local file="${1:-}" name="${2:-}" dry_run="${3:-false}" force="${4:-false}" + + $dry_run && \ + printf " \033[2m[dry-run]\033[0m Would import peer '%s' from backup\n" "$name" && return 0 + + local err + err=$(json::import_peer \ + "$file" "peers" "$name" \ + "$(ctx::clients)" "$(ctx::meta)" \ + "$(ctx::groups)" "$(ctx::blocks)" \ + "$($force && echo true || echo false)" \ + 2>&1 >/dev/null) || { log::error "$err"; return 1; } + + json::import_peer \ + "$file" "peers" "$name" \ + "$(ctx::clients)" "$(ctx::meta)" \ + "$(ctx::groups)" "$(ctx::blocks)" \ + "$($force && echo true || echo false)" \ + 2>/dev/null | cmd::import::_print_results + + log::wg_success "Imported peer '${name}' from backup" +} + +# ====================================================== +# Full backup import +# ====================================================== + +function cmd::import::_full() { + local file="${1:-}" dry_run="${2:-false}" force="${3:-false}" + + local peer_count + peer_count=$(json::import_get_field \ + "$file" "data" "peers" 2>/dev/null | python3 -c \ + "import json,sys; print(len(json.load(sys.stdin)))" 2>/dev/null || echo "?") + + printf " Importing full backup (%s peers)...\n\n" "$peer_count" + + $dry_run && \ + log::wg_warning "Dry run — no changes would be made" && return 0 + + json::import_full \ + "$file" \ + "$(ctx::clients)" "$(ctx::meta)" \ + "$(ctx::rules)" "$(ctx::identities)" \ + "$(ctx::groups)" "$(ctx::blocks)" \ + "$(ctx::policies)" "$(ctx::subnets)" \ + "$(ctx::net)" "$(ctx::hosts)" \ + "$($force && echo true || echo false)" \ + 2>/dev/null | cmd::import::_print_results + + log::wg_success "Import complete" +} \ No newline at end of file diff --git a/commands/test/integration.sh b/commands/test/integration.sh index 10c24bf..1af590d 100644 --- a/commands/test/integration.sh +++ b/commands/test/integration.sh @@ -138,6 +138,8 @@ function cmd::test::run_all_integration_sections() { cmd::test::section_peer_cmd cmd::test::section_group_purge cmd::test::section_logs_clean + cmd::test::section_display + cmd::test::section_export } function cmd::test::section_list() { @@ -174,6 +176,8 @@ function cmd::test::section_config() { 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 + cmd::test::run_cmd "config migrate --dry-run" "" config migrate --dry-run + cmd::test::run_cmd_fails "config missing --name" config } function cmd::test::section_rules() { @@ -366,4 +370,57 @@ function cmd::test::section_logs_clean() { cmd::test::run_cmd "logs clean --force" \ "keepalive" \ logs clean --force +} + +function cmd::test::section_export() { + test::section "Export" + + # Single peer export + cmd::test::run_cmd "export --peer" '"export_type":"peer"' export --peer phone-nuno + cmd::test::run_cmd "export --peer has name" '"name":"phone-nuno"' export --peer phone-nuno + cmd::test::run_cmd "export --peer has conf" '"conf":' export --peer phone-nuno + cmd::test::run_cmd "export --peer has identity" '"identity":"nuno"' export --peer phone-nuno + cmd::test::run_cmd "export --peer has groups" '"groups":' export --peer phone-nuno + cmd::test::run_cmd "export --peer has blocks" '"blocks":' export --peer phone-nuno + cmd::test::run_cmd "export --peer conf-only" '"export_type":"peer_conf"' export --peer phone-nuno --conf-only + cmd::test::run_cmd "export --peer meta-only" '"export_type":"peer_meta"' export --peer phone-nuno --meta-only + cmd::test::run_cmd_fails "export missing flag" export + # Identity export + cmd::test::run_cmd "export --identity" '"export_type":"identity"' export --identity nuno + + # Full backup + cmd::test::run_cmd "export --all" '"export_type"' export --all + cmd::test::run_cmd "export --all is full" '"full"' export --all + cmd::test::run_cmd "export --all has peers" '"peers"' export --all + cmd::test::run_cmd "export --all has rules" '"rules"' export --all + cmd::test::run_cmd "export --all has identities" '"identities"' export --all + cmd::test::run_cmd "export --all has config" '"config"' export --all + cmd::test::run_cmd "export --all no-config" '"peers"' export --all --no-config + # --no-config should NOT have config section + local no_config_out + no_config_out=$(wgctl export --all --no-config 2>/dev/null) + if echo "$no_config_out" | grep -qF '"config":'; then + test::fail "export --all --no-config should not have config section" + else + test::pass "export --all --no-config has no config section" + fi + + # Export to file + local tmp_file="/tmp/wgctl_test_export_$$.json" + cmd::test::run_cmd "export --peer --out file" "Exported to" export --peer phone-nuno --out "$tmp_file" + [[ -f "$tmp_file" ]] && test::pass "export --out file exists" \ + || test::fail "export --out file not created" + rm -f "$tmp_file" + + # Version in export + cmd::test::run_cmd "export has version" '"wgctl_version":' export --peer phone-nuno +} + +function cmd::test::section_display() { + test::section "Display config" + + cmd::test::run_cmd "display config exists" "" \ + config show --name phone-nuno # just check wgctl works, not display directly + + # Test style via unit tests (see unit section) } \ No newline at end of file diff --git a/commands/test/unit.sh b/commands/test/unit.sh index bc52335..9fa2ebe 100644 --- a/commands/test/unit.sh +++ b/commands/test/unit.sh @@ -49,6 +49,8 @@ function cmd::test::run_all_unit_sections() { cmd::test::unit_parse_since cmd::test::unit_group_status cmd::test::unit_json_output + cmd::test::unit_display + cmd::test::unit_version } function cmd::test::unit_subnet() { @@ -225,6 +227,8 @@ function cmd::test::unit_group_status() { function cmd::test::unit_json_output() { test::section "Unit: JSON output" + command::_load_mixins 2>/dev/null || true + # json::envelope produces valid structure local result result=$(echo '{"peers":[]}' | json::envelope "list" "0") @@ -235,8 +239,11 @@ function cmd::test::unit_json_output() { # command::mixin registration load_command list - cmd::test::assert_true "json_output mixin registered" "declare -f command::mixin::json_output::register >/dev/null 2>&1" - cmd::test::assert_true "command::json accessor exists" "declare -f command::json >/dev/null 2>&1" + cmd::test::assert_true "json_output mixin registered" \ + declare -f command::mixin::json_output::register >/dev/null 2>&1 + + cmd::test::assert_true "command::json accessor exists" \ + declare -f command::json >/dev/null 2>&1 # json::error_envelope local err_result @@ -251,4 +258,36 @@ function cmd::test::unit_json_output() { cmd::test::assert_true "command::json true" "command::json" _COMMAND_JSON=false cmd::test::assert_false "command::json false" "command::json" +} + +function cmd::test::unit_display() { + test::section "Unit: display config" + + load_module display + + # Default style is compact + cmd::test::assert "display::style peer_list default" \ + "$(display::style "peer_list")" "compact" + + # is_compact returns true for compact + display::is_compact "peer_list" && \ + test::pass "display::is_compact peer_list" || \ + test::fail "display::is_compact peer_list" + + # is_table returns false for compact + display::is_table "peer_list" && \ + test::fail "display::is_table should be false for compact" || \ + test::pass "display::is_table returns false for compact" + + # Unknown view defaults to compact + cmd::test::assert "display::style unknown view" \ + "$(display::style "nonexistent_view")" "compact" +} + +function cmd::test::unit_version() { + test::section "Unit: wgctl version" + local ver + ver=$(wgctl::version 2>/dev/null) + [[ -n "$ver" ]] && test::pass "wgctl::version returns value: $ver" \ + || test::fail "wgctl::version returns empty" } \ No newline at end of file diff --git a/core/json.sh b/core/json.sh index 31a9ab9..3fd68db 100644 --- a/core/json.sh +++ b/core/json.sh @@ -161,3 +161,8 @@ function json::peer_transfer_delta() { python3 "$JSON_HELPER" peer_transfer_delta "$@" 11 else 'false', + args[12] if len(args) > 12 else 'false', + args[13] if len(args) > 13 else 'unknown'), + + # Import + 'import_peer': lambda args: __import__('lib.importer', fromlist=['import_peer']).import_peer( + args[0], args[1], args[2], args[3], args[4], + args[5], args[6], args[7] if len(args) > 7 else 'false'), + 'import_identity': lambda args: __import__('lib.importer', fromlist=['import_identity']).import_identity( + args[0], args[1], args[2], args[3], + args[4] if len(args) > 4 else 'false'), + 'import_full': lambda args: __import__('lib.importer', fromlist=['import_full']).import_full( + args[0], args[1], args[2], args[3], args[4], args[5], + args[6], args[7], args[8], args[9], args[10], + args[11] if len(args) > 11 else 'false'), + 'import_get_field': lambda args: __import__('lib.importer', fromlist=['import_get_field']).import_get_field(args[0], *args[1:]), } # ── Main ───────────────────────────────────────────────────────────────────── diff --git a/core/lib/__pycache__/importer.cpython-311.pyc b/core/lib/__pycache__/importer.cpython-311.pyc new file mode 100644 index 0000000..25a65f1 Binary files /dev/null and b/core/lib/__pycache__/importer.cpython-311.pyc differ diff --git a/core/lib/importer.py b/core/lib/importer.py new file mode 100644 index 0000000..52d33f1 --- /dev/null +++ b/core/lib/importer.py @@ -0,0 +1,184 @@ +import os +import json +import sys +import glob + +def import_peer(file, data_key, name, clients_dir, meta_dir, + groups_dir, blocks_dir, force): + """ + Import a single peer from an export bundle. + data_key: 'data' for peer export, 'peers' for full backup + Returns: list of imported items as strings + """ + import base64, os + + d = json.load(open(file)) + + if data_key == 'data': + peer = d['data'] + else: + # Find peer in full backup peers array + peers = d['data'].get('peers', []) + peer = next((p for p in peers if p['name'] == name), None) + if not peer: + print(f"error: peer '{name}' not found in backup", file=sys.stderr) + sys.exit(1) + + imported = [] + + # conf + conf_path = os.path.join(clients_dir, f"{name}.conf") + if os.path.exists(conf_path) and force != 'true': + print(f"error: peer '{name}' already exists, use --force to overwrite", + file=sys.stderr) + sys.exit(1) + os.makedirs(clients_dir, exist_ok=True) + conf = base64.b64decode(peer['conf']).decode() + open(conf_path, 'w').write(conf) + imported.append('conf') + + # meta + meta = peer.get('meta', {}) + if meta: + os.makedirs(meta_dir, exist_ok=True) + open(os.path.join(meta_dir, f"{name}.meta"), 'w').write( + json.dumps(meta, indent=2)) + imported.append('meta') + + # groups + for grp in peer.get('groups', []): + grp_file = os.path.join(groups_dir, f"{grp}.group") + if os.path.exists(grp_file): + try: + g = json.load(open(grp_file)) + if name not in g.get('peers', []): + g.setdefault('peers', []).append(name) + open(grp_file, 'w').write(json.dumps(g, indent=2)) + imported.append(f"group:{grp}") + except Exception: + pass + + # blocks + blocks = peer.get('blocks', {}) + if blocks.get('is_blocked') and blocks.get('block_file'): + os.makedirs(blocks_dir, exist_ok=True) + block_data = base64.b64decode(blocks['block_file']) + open(os.path.join(blocks_dir, f"{name}.block"), 'wb').write(block_data) + imported.append('block') + + print('\n'.join(imported)) + + +def import_identity(file, name, identities_dir, clients_dir, force): + """Import an identity from an export bundle.""" + import os + + d = json.load(open(file)) + id_data = d['data'].get('identity', d['data']) + + # Check all referenced peers exist + peers = id_data.get('peers', []) + missing = [p for p in peers + if not os.path.exists(os.path.join(clients_dir, f"{p}.conf"))] + if missing: + print(f"error: missing peers: {' '.join(missing)}", file=sys.stderr) + sys.exit(1) + + id_file = os.path.join(identities_dir, f"{name}.identity") + if os.path.exists(id_file) and force != 'true': + print(f"error: identity '{name}' already exists, use --force to overwrite", + file=sys.stderr) + sys.exit(1) + + os.makedirs(identities_dir, exist_ok=True) + open(id_file, 'w').write(json.dumps(id_data, indent=2)) + print('identity') + + +def import_full(file, clients_dir, meta_dir, rules_dir, identities_dir, + groups_dir, blocks_dir, policies_file, subnets_file, + net_file, hosts_file, force): + """Import a full backup bundle.""" + import base64, os, glob + + d = json.load(open(file)) + data = d['data'] + results = [] + + # Peers + for peer in data.get('peers', []): + name = peer.get('name', '') + if not name: + continue + try: + conf_path = os.path.join(clients_dir, f"{name}.conf") + if os.path.exists(conf_path) and force != 'true': + results.append(f"skip:{name}") + continue + os.makedirs(clients_dir, exist_ok=True) + conf = base64.b64decode(peer['conf']).decode() + open(conf_path, 'w').write(conf) + + meta = peer.get('meta', {}) + if meta: + os.makedirs(meta_dir, exist_ok=True) + open(os.path.join(meta_dir, f"{name}.meta"), 'w').write( + json.dumps(meta, indent=2)) + + blocks = peer.get('blocks', {}) + if blocks.get('is_blocked') and blocks.get('block_file'): + os.makedirs(blocks_dir, exist_ok=True) + block_data = base64.b64decode(blocks['block_file']) + open(os.path.join(blocks_dir, f"{name}.block"), 'wb').write(block_data) + + results.append(f"peer:{name}") + except Exception as e: + results.append(f"error:{name}:{e}") + + # Rules + os.makedirs(rules_dir, exist_ok=True) + for rule in data.get('rules', []): + name = rule.get('name', '') + if name: + open(os.path.join(rules_dir, f"{name}.rule"), 'w').write( + json.dumps(rule, indent=2)) + results.append('rules') + + # Identities + os.makedirs(identities_dir, exist_ok=True) + for identity in data.get('identities', []): + name = identity.get('name', '') + if name: + open(os.path.join(identities_dir, f"{name}.identity"), 'w').write( + json.dumps(identity, indent=2)) + results.append('identities') + + # Groups + os.makedirs(groups_dir, exist_ok=True) + for grp in data.get('groups', []): + name = grp.get('name', '') + if name: + open(os.path.join(groups_dir, f"{name}.group"), 'w').write( + json.dumps(grp, indent=2)) + results.append('groups') + + # Flat JSON files + for key, path in [('policies', policies_file), ('subnets', subnets_file), + ('services', net_file), ('hosts', hosts_file)]: + section = data.get(key) + if section is not None: + open(path, 'w').write(json.dumps(section, indent=2)) + results.append(key) + + print('\n'.join(results)) + + +def import_get_field(file, *keys): + """Get a field from export JSON. Keys are dot-separated path.""" + d = json.load(open(file)) + val = d + for k in keys: + val = val.get(k, '') + if not val: + break + print(val if val else '') \ No newline at end of file diff --git a/wgctl b/wgctl index 919f79d..e80f81c 100755 --- a/wgctl +++ b/wgctl @@ -5,6 +5,10 @@ source "$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)/core.sh" LOG_LEVEL=DEBUG +WGCTL_VERSION="0.7.1" + +function wgctl::version() { echo "$WGCTL_VERSION"; } + # ============================================ # Modules # ============================================