add export,import features/add tests

This commit is contained in:
Nuno Duque Nunes 2026-05-27 16:46:09 +00:00
parent 8f3360c631
commit 00d6be0766
7 changed files with 588 additions and 2 deletions

288
commands/import.command.sh Normal file
View file

@ -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 <<EOF
Usage: wgctl import --file <file> [options]
Import a wgctl JSON export bundle.
Options:
--file <path> Path to export bundle (required)
--peer <name> 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"
}

View file

@ -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() {
@ -367,3 +371,56 @@ function cmd::test::section_logs_clean() {
"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)
}

View file

@ -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
@ -252,3 +259,35 @@ function cmd::test::unit_json_output() {
_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"
}

View file

@ -161,3 +161,8 @@ function json::peer_transfer_delta() {
python3 "$JSON_HELPER" peer_transfer_delta "$@" </dev/null
}
# Importer
function json::import_peer() { python3 "$JSON_HELPER" import_peer "$@" </dev/null; }
function json::import_identity() { python3 "$JSON_HELPER" import_identity "$@" </dev/null; }
function json::import_full() { python3 "$JSON_HELPER" import_full "$@" </dev/null; }
function json::import_get_field() { python3 "$JSON_HELPER" import_get_field "$@" </dev/null; }

View file

@ -2178,6 +2178,19 @@ commands = {
args[11] if len(args) > 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 ─────────────────────────────────────────────────────────────────────

Binary file not shown.

184
core/lib/importer.py Normal file
View file

@ -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 '')