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