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
This commit is contained in:
parent
a7b05547f5
commit
8f3360c631
3 changed files with 488 additions and 0 deletions
304
commands/export.command.sh
Normal file
304
commands/export.command.sh
Normal file
|
|
@ -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 <<EOF
|
||||
Usage: wgctl export [options]
|
||||
|
||||
Export wgctl data as a portable JSON bundle.
|
||||
|
||||
Options:
|
||||
--peer <name> Export a single peer (conf, meta, groups, identity, blocks)
|
||||
--identity <name> Export an identity
|
||||
--all Full backup (all peers, rules, identities, groups, etc.)
|
||||
--out <file> 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
|
||||
}
|
||||
|
|
@ -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 ─────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
4
wgctl
4
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
|
||||
# ============================================
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue