From 8f3360c63160a924654ad27a874e24153f5e799a Mon Sep 17 00:00:00 2001 From: Nuno Duque Nunes Date: Wed, 27 May 2026 04:11:59 +0000 Subject: [PATCH 1/2] feat: wgctl export command - export --peer: single peer export with conf/meta/identity/groups/blocks - export --identity: identity export - export --all: full backup with all sections - export --conf-only, --meta-only: selective peer export - export --no-config, --no-peers: selective full export - export --out: write to file - json_helper: export_full(), _export_peer_data() Python functions - base64 encoding for conf and block files - valid JSON output via Python for full backup --- commands/export.command.sh | 304 +++++++++++++++++++++++++++++++++++++ core/json_helper.py | 180 ++++++++++++++++++++++ wgctl | 4 + 3 files changed, 488 insertions(+) create mode 100644 commands/export.command.sh 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/core/json_helper.py b/core/json_helper.py index 972f1f5..7302cd9 100644 --- a/core/json_helper.py +++ b/core/json_helper.py @@ -1627,6 +1627,180 @@ def display_load(file): print(f"Error: {e}", file=sys.stderr) sys.exit(1) +def _export_peer_data(name, clients_dir, meta_dir, identities_dir, groups_dir, blocks_dir): + """Build peer data dict for export.""" + import glob, base64, os + + conf_path = os.path.join(clients_dir, f"{name}.conf") + if not os.path.exists(conf_path): + return None + + with open(conf_path, 'rb') as f: + conf_b64 = base64.b64encode(f.read()).decode() + + # Public key + key_path = os.path.join(clients_dir, f"{name}_public.key") + public_key = open(key_path).read().strip() if os.path.exists(key_path) else '' + + # IP from conf + ip = '' + with open(conf_path) as f: + for line in f: + if line.startswith('Address'): + ip = line.split('=')[1].strip().split('/')[0] + break + + # Meta + meta = {} + meta_path = os.path.join(meta_dir, f"{name}.meta") + if os.path.exists(meta_path): + try: + with open(meta_path) as f: + meta = json.load(f) + except Exception: + pass + + direct_rule = meta.get('rule', '') + + # Identity + type — scan identity files once for both + identity = '' + peer_type = meta.get('type', '') + for id_file in sorted(glob.glob(os.path.join(identities_dir, '*.identity'))): + try: + with open(id_file) as f: + id_data = json.load(f) + if name in id_data.get('peers', []): + identity = id_data.get('name', '') + if not peer_type: + peer_type = id_data.get('devices', {}).get(name, {}).get('type', '') + break + except Exception: + pass + + # Groups + peer_groups = [] + for grp_file in sorted(glob.glob(os.path.join(groups_dir, '*.group'))): + try: + with open(grp_file) as f: + g = json.load(f) + if name in g.get('peers', []): + peer_groups.append(g.get('name', '')) + except Exception: + pass + + # Blocks + blocks = {'is_blocked': False, 'block_file': None} + block_path = os.path.join(blocks_dir, f"{name}.block") + if os.path.exists(block_path): + with open(block_path, 'rb') as f: + blocks = { + 'is_blocked': True, + 'block_file': base64.b64encode(f.read()).decode() + } + + return { + 'name': name, + 'ip': ip, + 'type': peer_type, + 'public_key': public_key, + 'conf': conf_b64, + 'meta': meta, + 'identity': identity, + 'groups': peer_groups, + 'direct_rule': direct_rule, + 'blocks': blocks + } + + +def export_full(clients_dir, meta_dir, rules_dir, identities_dir, + groups_dir, blocks_dir, config_file, + policies_file, subnets_file, net_file, hosts_file, + no_config, no_peers, version): + """Build full wgctl export as JSON.""" + import glob, os + from datetime import datetime, timezone + + data = {} + + # Config + if no_config != 'true' and os.path.exists(config_file): + try: + with open(config_file) as f: + data['config'] = json.load(f) + except Exception: + data['config'] = {} + + # Peers + if no_peers != 'true': + peers = [] + for conf_path in sorted(glob.glob(f"{clients_dir}/*.conf")): + name = os.path.basename(conf_path).replace('.conf', '') + try: + peer = _export_peer_data( + name, clients_dir, meta_dir, identities_dir, + groups_dir, blocks_dir) + if peer: + peers.append(peer) + except Exception: + pass + data['peers'] = peers + + # Rules + rules = [] + for rule_file in sorted( + glob.glob(f"{rules_dir}/*.rule") + + glob.glob(f"{rules_dir}/base/*.rule") + ): + try: + with open(rule_file) as f: + rules.append(json.load(f)) + except Exception: + pass + data['rules'] = rules + + # Identities + identities = [] + for id_file in sorted(glob.glob(f"{identities_dir}/*.identity")): + try: + with open(id_file) as f: + identities.append(json.load(f)) + except Exception: + pass + data['identities'] = identities + + # Groups + groups = [] + for grp_file in sorted(glob.glob(f"{groups_dir}/*.group")): + try: + with open(grp_file) as f: + groups.append(json.load(f)) + except Exception: + pass + data['groups'] = groups + + # Flat JSON files + for key, path in [ + ('policies', policies_file), + ('subnets', subnets_file), + ('services', net_file), + ('hosts', hosts_file), + ]: + if os.path.exists(path): + try: + with open(path) as f: + data[key] = json.load(f) + except Exception: + pass + + ts = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') + result = { + 'wgctl_version': version, + 'export_type': 'full', + 'exported_at': ts, + 'data': data, + } + print(json.dumps(result)) + # ====================================================== def _net_read(file): @@ -1998,6 +2172,12 @@ commands = { 'peer_history_lookup': lambda args: peer_history_lookup(args[0], args[1]), 'config_load': lambda args: config_load(args[0]), 'display_load': lambda args: display_load(args[0]), + 'export_full': lambda args: export_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', + args[12] if len(args) > 12 else 'false', + args[13] if len(args) > 13 else 'unknown'), } # ── Main ───────────────────────────────────────────────────────────────────── 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 # ============================================ From 00d6be076641b026cb9a87ee3b7dd759c139f735 Mon Sep 17 00:00:00 2001 From: Nuno Duque Nunes Date: Wed, 27 May 2026 16:46:09 +0000 Subject: [PATCH 2/2] 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