From 00d6be076641b026cb9a87ee3b7dd759c139f735 Mon Sep 17 00:00:00 2001 From: Nuno Duque Nunes Date: Wed, 27 May 2026 16:46:09 +0000 Subject: [PATCH] add export,import features/add tests --- commands/import.command.sh | 288 ++++++++++++++++++ commands/test/integration.sh | 57 ++++ commands/test/unit.sh | 43 ++- core/json.sh | 5 + core/json_helper.py | 13 + core/lib/__pycache__/importer.cpython-311.pyc | Bin 0 -> 11732 bytes core/lib/importer.py | 184 +++++++++++ 7 files changed, 588 insertions(+), 2 deletions(-) create mode 100644 commands/import.command.sh create mode 100644 core/lib/__pycache__/importer.cpython-311.pyc create mode 100644 core/lib/importer.py 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 0000000000000000000000000000000000000000..25a65f1398b494915fb5621b600f3c6d30f12034 GIT binary patch literal 11732 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 literal 0 HcmV?d00001 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