From 8f3360c63160a924654ad27a874e24153f5e799a Mon Sep 17 00:00:00 2001 From: Nuno Duque Nunes Date: Wed, 27 May 2026 04:11:59 +0000 Subject: [PATCH] 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 # ============================================