forked from test2/Amnezia-Web-Panel-main
Add WG/AWG client config ZIP export and backup restore.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2821,6 +2821,157 @@ async def api_protocol_backup_download(request: Request, server_id: int, req: Ba
|
||||
ssh.disconnect()
|
||||
|
||||
|
||||
WG_BACKUP_BASES = {'awg', 'awg2', 'awg_legacy', 'wireguard'}
|
||||
|
||||
|
||||
def _safe_conf_filename(name: str, used: set) -> str:
|
||||
base = re.sub(r'[^\w.\-]+', '_', str(name or '').strip(), flags=re.UNICODE).strip('._') or 'client'
|
||||
if len(base) > 80:
|
||||
base = base[:80]
|
||||
candidate = f'{base}.conf'
|
||||
idx = 2
|
||||
while candidate.lower() in used:
|
||||
candidate = f'{base}_{idx}.conf'
|
||||
idx += 1
|
||||
used.add(candidate.lower())
|
||||
return candidate
|
||||
|
||||
|
||||
@app.post('/api/servers/{server_id}/backups/restore', tags=["Protocols"])
|
||||
async def api_protocol_backup_restore(request: Request, server_id: int, req: BackupDownloadRequest):
|
||||
"""Restore a protocol backup archive into the remote container/host paths."""
|
||||
if not _check_admin(request):
|
||||
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||
if not is_valid_protocol(req.protocol):
|
||||
return JSONResponse({'error': 'Unknown protocol'}, status_code=400)
|
||||
ssh = None
|
||||
try:
|
||||
data = load_data()
|
||||
if server_id >= len(data['servers']):
|
||||
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
||||
container = protocol_container_name(req.protocol)
|
||||
if not container:
|
||||
return JSONResponse({'error': 'Unknown protocol'}, status_code=400)
|
||||
server = data['servers'][server_id]
|
||||
ssh = get_ssh(server)
|
||||
ssh.connect()
|
||||
result = BackupManager(ssh).restore_backup(req.protocol, container, req.filename)
|
||||
if result.get('status') == 'error':
|
||||
return JSONResponse({'error': result.get('message', 'Failed to restore backup')}, status_code=500)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.exception("Error restoring protocol backup")
|
||||
return JSONResponse({'error': str(e)}, status_code=500)
|
||||
finally:
|
||||
if ssh:
|
||||
ssh.disconnect()
|
||||
|
||||
|
||||
@app.post('/api/servers/{server_id}/backups/export-clients', tags=["Protocols"])
|
||||
async def api_protocol_export_clients(request: Request, server_id: int, req: ProtocolRequest):
|
||||
"""Download a ZIP with all reconstructable WireGuard/AWG client .conf files."""
|
||||
if not _check_admin(request):
|
||||
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||
if not is_valid_protocol(req.protocol):
|
||||
return JSONResponse({'error': 'Unknown protocol'}, status_code=400)
|
||||
base = protocol_base(req.protocol)
|
||||
if base not in WG_BACKUP_BASES:
|
||||
return JSONResponse(
|
||||
{'error': 'Client config export is only available for WireGuard / AmneziaWG'},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
ssh = None
|
||||
tmp_path = None
|
||||
try:
|
||||
data = load_data()
|
||||
if server_id >= len(data['servers']):
|
||||
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
||||
server = data['servers'][server_id]
|
||||
proto_info = server.get('protocols', {}).get(req.protocol, {})
|
||||
port = proto_info.get('port', '55424')
|
||||
ssh = get_ssh(server)
|
||||
ssh.connect()
|
||||
manager = get_protocol_manager(ssh, req.protocol)
|
||||
clients = _manager_call(manager, 'get_clients', req.protocol) or []
|
||||
|
||||
fd, tmp_path = tempfile.mkstemp(prefix='amnezia-clients-', suffix='.zip')
|
||||
os.close(fd)
|
||||
used_names = set()
|
||||
exported = 0
|
||||
skipped = []
|
||||
|
||||
with zipfile.ZipFile(tmp_path, 'w', compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for client in clients:
|
||||
ud = client.get('userData') or {}
|
||||
client_id = client.get('clientId') or ''
|
||||
client_name = ud.get('clientName') or client_id[:12] or 'client'
|
||||
if ud.get('externalClient') or not ud.get('clientPrivateKey'):
|
||||
skipped.append(f'{client_name}: no private key (external/native)')
|
||||
continue
|
||||
try:
|
||||
config = _manager_call(
|
||||
manager, 'get_client_config', req.protocol, client_id, server['host'], port
|
||||
)
|
||||
except Exception as exc:
|
||||
skipped.append(f'{client_name}: {exc}')
|
||||
continue
|
||||
if not config:
|
||||
skipped.append(f'{client_name}: empty config')
|
||||
continue
|
||||
filename = _safe_conf_filename(client_name, used_names)
|
||||
zf.writestr(filename, config if config.endswith('\n') else config + '\n')
|
||||
exported += 1
|
||||
|
||||
readme = [
|
||||
f'Protocol: {req.protocol}',
|
||||
f'Server: {server.get("name") or server.get("host")}',
|
||||
f'Host: {server.get("host")}',
|
||||
f'Exported: {exported}',
|
||||
f'Skipped: {len(skipped)}',
|
||||
'',
|
||||
]
|
||||
if skipped:
|
||||
readme.append('Skipped clients:')
|
||||
readme.extend(f'- {line}' for line in skipped)
|
||||
zf.writestr('README.txt', '\n'.join(readme) + '\n')
|
||||
|
||||
ssh.disconnect()
|
||||
ssh = None
|
||||
|
||||
if exported == 0:
|
||||
try:
|
||||
os.remove(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
tmp_path = None
|
||||
return JSONResponse(
|
||||
{'error': 'No client configs could be exported (missing private keys?)'},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
safe_proto = BackupManager.safe_protocol(req.protocol)
|
||||
stamp = datetime.now().strftime('%Y%m%dT%H%M%S')
|
||||
download_name = f'{safe_proto}-clients-{stamp}.zip'
|
||||
return FileResponse(
|
||||
tmp_path,
|
||||
media_type='application/zip',
|
||||
filename=download_name,
|
||||
background=BackgroundTask(lambda p=tmp_path: os.path.exists(p) and os.remove(p)),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Error exporting client configs")
|
||||
if tmp_path and os.path.exists(tmp_path):
|
||||
try:
|
||||
os.remove(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
return JSONResponse({'error': str(e)}, status_code=500)
|
||||
finally:
|
||||
if ssh:
|
||||
ssh.disconnect()
|
||||
|
||||
|
||||
@app.post('/api/servers/{server_id}/container/toggle', tags=["Protocols"])
|
||||
async def api_container_toggle(request: Request, server_id: int, req: ProtocolRequest):
|
||||
"""Start or stop a protocol Docker container."""
|
||||
|
||||
@@ -168,3 +168,76 @@ printf '%s\n' "$archive"
|
||||
path = (out or '').strip().splitlines()[-1] if (out or '').strip() else ''
|
||||
name = path.rsplit('/', 1)[-1] if path else ''
|
||||
return {'status': 'success', 'protocol': protocol, 'backup': {'name': name, 'path': path}}
|
||||
|
||||
def restore_backup(self, protocol, container_name, filename):
|
||||
"""Restore protocol files from a previously created backup archive."""
|
||||
safe_name = self.safe_filename(filename)
|
||||
if not safe_name:
|
||||
return {'status': 'error', 'message': 'Invalid backup filename'}
|
||||
|
||||
safe_proto = self.safe_protocol(protocol)
|
||||
remote_archive = f'{self.BACKUP_ROOT}/{safe_proto}/{safe_name}'
|
||||
paths = self._paths_for(protocol, container_name)
|
||||
container_paths = ' '.join(shlex.quote(p) for p in paths['container'])
|
||||
host_paths = ' '.join(shlex.quote(p) for p in paths['host'])
|
||||
archive_q = shlex.quote(remote_archive)
|
||||
container_q = shlex.quote(str(container_name or ''))
|
||||
protocol_q = shlex.quote(str(protocol))
|
||||
|
||||
script = f"""
|
||||
set -eu
|
||||
umask 077
|
||||
archive={archive_q}
|
||||
container={container_q}
|
||||
protocol={protocol_q}
|
||||
if [ ! -f "$archive" ]; then
|
||||
echo "Backup archive not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
work_dir=$(mktemp -d /tmp/amnezia-restore-XXXXXX)
|
||||
cleanup() {{ rm -rf "$work_dir"; }}
|
||||
trap cleanup EXIT
|
||||
tar -xzf "$archive" -C "$work_dir"
|
||||
if [ ! -f "$work_dir/backup-info.json" ]; then
|
||||
echo "Invalid backup archive (missing backup-info.json)" >&2
|
||||
exit 1
|
||||
fi
|
||||
restore_host_path() {{
|
||||
src="$1"
|
||||
staged="$work_dir/host$src"
|
||||
if [ -e "$staged" ]; then
|
||||
mkdir -p "$(dirname "$src")"
|
||||
rm -rf "$src"
|
||||
cp -a "$staged" "$src"
|
||||
fi
|
||||
}}
|
||||
restore_container_path() {{
|
||||
src="$1"
|
||||
staged="$work_dir/container$src"
|
||||
if [ -n "$container" ] && [ -e "$staged" ] && docker inspect "$container" >/dev/null 2>&1; then
|
||||
if [ -d "$staged" ]; then
|
||||
docker exec "$container" mkdir -p "$src" >/dev/null 2>&1 || true
|
||||
docker cp "$staged/." "$container:$src/" >/dev/null 2>&1 || true
|
||||
else
|
||||
parent=$(dirname "$src")
|
||||
docker exec "$container" mkdir -p "$parent" >/dev/null 2>&1 || true
|
||||
docker cp "$staged" "$container:$src" >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
}}
|
||||
for p in {host_paths}; do restore_host_path "$p"; done
|
||||
for p in {container_paths}; do restore_container_path "$p"; done
|
||||
if [ -n "$container" ] && docker inspect "$container" >/dev/null 2>&1; then
|
||||
docker restart "$container" >/dev/null 2>&1 || true
|
||||
# Prefer bringing the WireGuard/AWG interface back up after file restore
|
||||
if docker exec "$container" test -x /opt/amnezia/start.sh >/dev/null 2>&1; then
|
||||
docker exec "$container" /opt/amnezia/start.sh >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
printf 'restored:%s\\n' "$protocol"
|
||||
""".strip()
|
||||
|
||||
out, err, code = self.ssh.run_sudo_command(script, timeout=180)
|
||||
if code != 0:
|
||||
return {'status': 'error', 'message': err or out or 'Failed to restore backup'}
|
||||
return {'status': 'success', 'protocol': protocol, 'filename': safe_name}
|
||||
|
||||
+65
-2
@@ -751,12 +751,16 @@
|
||||
</div>
|
||||
<input type="hidden" id="backupProto" value="" />
|
||||
<div class="form-hint" style="margin-bottom:var(--space-md);">{{ _('backup_desc') }}</div>
|
||||
<div class="config-actions" style="margin-bottom:var(--space-md);">
|
||||
<div class="config-actions" style="margin-bottom:var(--space-md); flex-wrap:wrap;">
|
||||
<button class="btn btn-primary btn-sm" onclick="createProtocolBackup()" id="createBackupBtn" style="flex:1">
|
||||
{{ _('create_backup') }}
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm hidden" onclick="exportProtocolClients()" id="exportClientsBtn" style="flex:1">
|
||||
{{ _('export_client_configs') }}
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="loadProtocolBackups()">{{ _('refresh') }}</button>
|
||||
</div>
|
||||
<div class="form-hint hidden" id="exportClientsHint" style="margin-bottom:var(--space-md);">{{ _('export_client_configs_hint') }}</div>
|
||||
<div id="backupLoading" class="form-hint hidden">{{ _('loading') }}</div>
|
||||
<div id="backupEmpty" class="empty-state hidden" style="padding:var(--space-xl);">
|
||||
<div class="empty-icon">{{ icon('package') }}</div>
|
||||
@@ -2014,6 +2018,10 @@
|
||||
try { return new Date(mtime * 1000).toLocaleString(); } catch (_) { return '—'; }
|
||||
}
|
||||
|
||||
function isWgBackupFamily(proto) {
|
||||
return ['awg', 'awg2', 'awg_legacy', 'wireguard'].includes(protoBase(proto));
|
||||
}
|
||||
|
||||
function backupDownloadUrl(proto, filename) {
|
||||
return `/api/servers/${SERVER_ID}/backups/download`;
|
||||
}
|
||||
@@ -2021,6 +2029,9 @@
|
||||
async function showProtocolBackups(proto) {
|
||||
document.getElementById('backupProto').value = proto;
|
||||
document.getElementById('backupModalTitle').textContent = `${_('backups')} — ${getProtoTitle(proto)}`;
|
||||
const wg = isWgBackupFamily(proto);
|
||||
document.getElementById('exportClientsBtn').classList.toggle('hidden', !wg);
|
||||
document.getElementById('exportClientsHint').classList.toggle('hidden', !wg);
|
||||
openModal('backupModal');
|
||||
await loadProtocolBackups();
|
||||
}
|
||||
@@ -2041,12 +2052,13 @@
|
||||
return;
|
||||
}
|
||||
list.innerHTML = backups.map(b => `
|
||||
<div class="protocol-info-item" style="align-items:center; gap:var(--space-sm);">
|
||||
<div class="protocol-info-item" style="align-items:center; gap:var(--space-sm); flex-wrap:wrap;">
|
||||
<div style="min-width:0; flex:1;">
|
||||
<div class="protocol-info-value" style="word-break:break-all;">${b.name}</div>
|
||||
<div class="protocol-info-label">${formatBytes(b.size)} • ${formatBackupTime(b.mtime)}</div>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm" onclick="downloadProtocolBackup('${proto}', '${b.name}')">${_('download')}</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="restoreProtocolBackup('${proto}', '${b.name}')">${_('restore_protocol_backup')}</button>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (err) {
|
||||
@@ -2074,6 +2086,57 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function exportProtocolClients() {
|
||||
const proto = document.getElementById('backupProto').value;
|
||||
const btn = document.getElementById('exportClientsBtn');
|
||||
const oldText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = _('exporting_client_configs');
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${SERVER_ID}/backups/export-clients`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ protocol: proto })
|
||||
});
|
||||
if (!res.ok) {
|
||||
let message = 'Export failed';
|
||||
try {
|
||||
const data = await res.json();
|
||||
message = data.error || message;
|
||||
} catch (_) {}
|
||||
throw new Error(message);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const cd = res.headers.get('content-disposition') || '';
|
||||
const match = cd.match(/filename="?([^"]+)"?/i);
|
||||
const filename = match ? match[1] : `${proto}-clients.zip`;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast(_('export_client_configs_done'), 'success');
|
||||
} catch (err) {
|
||||
showToast(_('error') + ': ' + err.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = oldText;
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreProtocolBackup(proto, filename) {
|
||||
if (!confirm(_('restore_protocol_backup_confirm'))) return;
|
||||
try {
|
||||
await apiCall(`/api/servers/${SERVER_ID}/backups/restore`, 'POST', { protocol: proto, filename });
|
||||
showToast(_('restore_protocol_backup_done'), 'success');
|
||||
} catch (err) {
|
||||
showToast(_('error') + ': ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadProtocolBackup(proto, filename) {
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${SERVER_ID}/backups/download`, {
|
||||
|
||||
@@ -505,11 +505,18 @@
|
||||
"nginx_dns_hint": "Create this DNS record before installation:",
|
||||
"backup": "Backup",
|
||||
"backups": "Backups",
|
||||
"backup_desc": "Backups are created on the server and contain valuable protocol files needed to restore or recreate it later.",
|
||||
"backup_desc": "Backups are stored on the server and include protocol files (config, keys, clientsTable). For WireGuard / AWG you can also download a ZIP of all client .conf files.",
|
||||
"create_backup": "Create backup",
|
||||
"creating_backup": "Creating backup...",
|
||||
"backup_created": "Backup created",
|
||||
"no_backups": "No backups yet",
|
||||
"no_backups_desc": "Create the first backup for this protocol.",
|
||||
"export_client_configs": "Download all .conf",
|
||||
"export_client_configs_hint": "ZIP with all WireGuard / AmneziaWG client configs (when the private key is stored).",
|
||||
"exporting_client_configs": "Preparing ZIP…",
|
||||
"export_client_configs_done": "Client configs ZIP downloaded",
|
||||
"restore_protocol_backup": "Restore",
|
||||
"restore_protocol_backup_confirm": "Restore this backup on the server? Current protocol files will be overwritten and the container will restart.",
|
||||
"restore_protocol_backup_done": "Backup restored",
|
||||
"refresh": "Refresh"
|
||||
}
|
||||
|
||||
@@ -503,13 +503,20 @@
|
||||
"site_editor": "Редактор сайта",
|
||||
"site_saved": "Сайт сохранён",
|
||||
"nginx_dns_hint": "Перед установкой создайте DNS-запись:",
|
||||
"backup": "Backup",
|
||||
"backup": "Бекап",
|
||||
"backups": "Бекапы",
|
||||
"backup_desc": "Бекапы создаются на сервере и содержат ценные файлы протокола, нужные для восстановления или повторного запуска без потерь.",
|
||||
"backup_desc": "Бекапы создаются на сервере и содержат файлы протокола (конфиг, ключи, clientsTable). Для WireGuard / AWG можно также скачать ZIP со всеми клиентскими .conf.",
|
||||
"create_backup": "Создать бекап",
|
||||
"creating_backup": "Создание бекапа...",
|
||||
"backup_created": "Бекап создан",
|
||||
"no_backups": "Бекапов пока нет",
|
||||
"no_backups_desc": "Создайте первый бекап для этого протокола.",
|
||||
"export_client_configs": "Скачать все .conf",
|
||||
"export_client_configs_hint": "ZIP со всеми клиентскими конфигами WireGuard / AmneziaWG (если сохранён приватный ключ).",
|
||||
"exporting_client_configs": "Готовлю ZIP…",
|
||||
"export_client_configs_done": "ZIP с конфигами скачан",
|
||||
"restore_protocol_backup": "Восстановить",
|
||||
"restore_protocol_backup_confirm": "Восстановить этот бекап на сервер? Текущие файлы протокола будут перезаписаны, контейнер перезапустится.",
|
||||
"restore_protocol_backup_done": "Бекап восстановлен",
|
||||
"refresh": "Обновить"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user