"""Export/import server user data + protocol state for domain-preserving migration. Use case: new VPS IP, same connect_domain. Users keep existing VPN configs if: 1. Protocol crypto state is restored on the new host 2. Panel user_connections keep the same client_id values 3. DNS A-record for connect_domain points to the new IP """ from __future__ import annotations import io import json import logging import secrets import shlex import uuid import zipfile from datetime import datetime, timezone from typing import Any, Optional from managers.backup_manager import BackupManager logger = logging.getLogger(__name__) MIGRATE_FORMAT = 'amnezia-web-panel-migrate-v1' def _now_iso() -> str: return datetime.now(timezone.utc).isoformat() def _safe_user_public(user: dict) -> dict: """User row suitable for re-import (keeps password_hash / share tokens).""" return { 'id': user.get('id'), 'username': user.get('username'), 'password_hash': user.get('password_hash') or '', 'role': user.get('role') or 'user', 'enabled': bool(user.get('enabled', True)), 'created_at': user.get('created_at'), 'telegramId': user.get('telegramId'), 'email': user.get('email'), 'description': user.get('description'), 'traffic_limit': user.get('traffic_limit', 0), 'traffic_used': user.get('traffic_used', 0), 'traffic_total': user.get('traffic_total', 0), 'traffic_reset_strategy': user.get('traffic_reset_strategy', 'never'), 'last_reset_at': user.get('last_reset_at'), 'expiration_date': user.get('expiration_date'), 'expire_after_first_use': bool(user.get('expire_after_first_use')), 'expiration_days': int(user.get('expiration_days') or 0), 'remnawave_uuid': user.get('remnawave_uuid'), 'xui_email': user.get('xui_email'), 'share_enabled': bool(user.get('share_enabled')), 'share_token': user.get('share_token'), 'share_password_hash': user.get('share_password_hash'), } def _server_public_slice(server: dict, server_id: int) -> dict: info = dict(server.get('server_info') or {}) protocols = {} for key, val in (server.get('protocols') or {}).items(): if not isinstance(val, dict): continue protocols[key] = { 'installed': bool(val.get('installed')), 'port': val.get('port'), 'connect_domain': val.get('connect_domain') or '', 'container_name': val.get('container_name') or '', } return { 'old_server_id': server_id, 'name': server.get('name') or '', 'host': server.get('host') or '', 'ssh_port': int(server.get('ssh_port') or 22), 'connect_domain': (info.get('connect_domain') or '').strip(), 'ssl_domain': (info.get('ssl_domain') or '').strip(), 'ssl_email': (info.get('ssl_email') or '').strip(), 'protocols': protocols, } def build_panel_payload(data: dict, server_id: int) -> dict: servers = data.get('servers') or [] if server_id < 0 or server_id >= len(servers): raise ValueError('Server not found') server = servers[server_id] conns = [ dict(c) for c in (data.get('user_connections') or []) if isinstance(c, dict) and int(c.get('server_id', -1)) == server_id ] user_ids = {c.get('user_id') for c in conns if c.get('user_id')} users = [ _safe_user_public(u) for u in (data.get('users') or []) if isinstance(u, dict) and u.get('id') in user_ids ] invites = [ dict(inv) for inv in (data.get('invite_links') or []) if isinstance(inv, dict) and int(inv.get('server_id', -1)) == server_id ] return { 'format': MIGRATE_FORMAT, 'exported_at': _now_iso(), 'server': _server_public_slice(server, server_id), 'users': users, 'user_connections': conns, 'invite_links': invites, } def _upload_bytes_sudo(ssh, content: bytes, remote_path: str) -> None: tmp = f'/tmp/_amnz_mig_{secrets.token_hex(6)}' sftp = ssh.client.open_sftp() try: with sftp.file(tmp, 'wb') as f: f.write(content) finally: sftp.close() parent = remote_path.rsplit('/', 1)[0] ssh.run_sudo_command( f"mkdir -p {shlex.quote(parent)} && " f"mv {shlex.quote(tmp)} {shlex.quote(remote_path)} && " f"chmod 0644 {shlex.quote(remote_path)}" ) def _download_bytes(ssh, remote_path: str) -> bytes: tmp = f'/tmp/_amnz_dl_{secrets.token_hex(6)}' quoted_remote = shlex.quote(remote_path) quoted_tmp = shlex.quote(tmp) _, err, code = ssh.run_sudo_command( f"test -f {quoted_remote} && cp {quoted_remote} {quoted_tmp} && chmod 0644 {quoted_tmp}" ) if code != 0: raise RuntimeError(err or f'Failed to stage {remote_path}') sftp = ssh.client.open_sftp() try: buf = io.BytesIO() with sftp.file(tmp, 'rb') as f: buf.write(f.read()) return buf.getvalue() finally: sftp.close() ssh.run_sudo_command(f'rm -f {quoted_tmp}') def export_migrate_zip( ssh, data: dict, server_id: int, *, include_protocol_backups: bool = True, protocol_container_name_fn=None, ) -> tuple[bytes, dict]: """Build a migrate ZIP. Returns (zip_bytes, summary).""" payload = build_panel_payload(data, server_id) server = data['servers'][server_id] protocols = server.get('protocols') or {} installed = [ p for p, info in protocols.items() if isinstance(info, dict) and info.get('installed') ] buf = io.BytesIO() summary = { 'users': len(payload['users']), 'connections': len(payload['user_connections']), 'invites': len(payload['invite_links']), 'protocols': [], 'protocol_errors': [], 'connect_domain': payload['server'].get('connect_domain') or '', } with zipfile.ZipFile(buf, 'w', compression=zipfile.ZIP_DEFLATED) as zf: zf.writestr('panel.json', json.dumps(payload, indent=2, ensure_ascii=False)) protocol_files = [] if include_protocol_backups and installed and ssh is not None: bm = BackupManager(ssh) for proto in installed: try: container = '' if protocol_container_name_fn: container = protocol_container_name_fn(proto) or '' info = protocols.get(proto) or {} container = info.get('container_name') or container or '' created = bm.create_backup(proto, container) if created.get('status') != 'success': summary['protocol_errors'].append({ 'protocol': proto, 'error': created.get('message') or 'create backup failed', }) continue name = (created.get('backup') or {}).get('name') path = (created.get('backup') or {}).get('path') if not name or not path: summary['protocol_errors'].append({ 'protocol': proto, 'error': 'backup path missing', }) continue blob = _download_bytes(ssh, path) arcname = f'protocols/{name}' zf.writestr(arcname, blob) protocol_files.append({ 'protocol': proto, 'filename': name, 'archive': arcname, 'container': container, }) summary['protocols'].append(proto) except Exception as e: logger.exception('Protocol backup failed for %s', proto) summary['protocol_errors'].append({ 'protocol': proto, 'error': str(e), }) manifest = { 'format': MIGRATE_FORMAT, 'exported_at': payload['exported_at'], 'server': payload['server'], 'protocol_backups': protocol_files, 'counts': { 'users': summary['users'], 'connections': summary['connections'], 'protocol_backups': len(protocol_files), }, 'notes': [ 'Point DNS A-record for connect_domain to the new server IP.', 'Import onto the new server after installing the same protocols.', 'Do not use Move Connections — it regenerates client keys.', ], } zf.writestr('manifest.json', json.dumps(manifest, indent=2, ensure_ascii=False)) return buf.getvalue(), summary def _merge_users(data: dict, imported_users: list) -> dict[str, str]: """Merge users into panel data. Returns map old_user_id -> effective_user_id.""" id_map: dict[str, str] = {} existing_by_id = {str(u.get('id')): u for u in data.get('users') or [] if u.get('id')} existing_by_name = { str(u.get('username') or '').lower(): u for u in data.get('users') or [] if u.get('username') } for raw in imported_users: if not isinstance(raw, dict): continue old_id = str(raw.get('id') or '') username = (raw.get('username') or '').strip() if not old_id and not username: continue if old_id and old_id in existing_by_id: id_map[old_id] = old_id continue by_name = existing_by_name.get(username.lower()) if username else None if by_name: id_map[old_id] = str(by_name['id']) continue new_user = _safe_user_public(raw) if not new_user.get('id'): new_user['id'] = str(uuid.uuid4()) # Avoid unique username collisions with empty/duplicate names. if not username: username = f'user_{str(new_user["id"])[:8]}' new_user['username'] = username base = username n = 2 while username.lower() in existing_by_name: username = f'{base}_{n}' n += 1 new_user['username'] = username if new_user.get('role') == 'admin': # Never import an extra admin silently — demote to user. new_user['role'] = 'user' data.setdefault('users', []).append(new_user) existing_by_id[str(new_user['id'])] = new_user existing_by_name[username.lower()] = new_user if old_id: id_map[old_id] = str(new_user['id']) id_map[str(new_user['id'])] = str(new_user['id']) return id_map def _merge_connections( data: dict, imported_conns: list, *, target_server_id: int, user_id_map: dict[str, str], ) -> dict: existing = data.setdefault('user_connections', []) existing_keys = { ( str(c.get('user_id')), str(c.get('client_id')), str(c.get('protocol')), int(c.get('server_id', -1)), ) for c in existing if isinstance(c, dict) } added = 0 skipped = 0 for raw in imported_conns: if not isinstance(raw, dict): continue old_user = str(raw.get('user_id') or '') user_id = user_id_map.get(old_user, old_user) client_id = raw.get('client_id') protocol = raw.get('protocol') if not user_id or not client_id or not protocol: skipped += 1 continue key = (str(user_id), str(client_id), str(protocol), int(target_server_id)) if key in existing_keys: skipped += 1 continue conn = { 'id': raw.get('id') or str(uuid.uuid4()), 'user_id': user_id, 'server_id': target_server_id, 'protocol': protocol, 'client_id': client_id, 'name': raw.get('name') or '', 'xui_panel_id': raw.get('xui_panel_id') or '', 'created_at': raw.get('created_at') or _now_iso(), 'last_bytes': raw.get('last_bytes') or 0, } # Avoid id collisions if any(c.get('id') == conn['id'] for c in existing): conn['id'] = str(uuid.uuid4()) existing.append(conn) existing_keys.add(key) added += 1 return {'added': added, 'skipped': skipped} def _apply_server_meta(target: dict, exported_server: dict) -> None: """Preserve connect_domain / protocol ports on target; never overwrite SSH host.""" info = dict(target.get('server_info') or {}) domain = (exported_server.get('connect_domain') or '').strip() if domain and not (info.get('connect_domain') or '').strip(): info['connect_domain'] = domain for key in ('ssl_domain', 'ssl_email'): val = (exported_server.get(key) or '').strip() if val and not (info.get(key) or '').strip(): info[key] = val target['server_info'] = info protocols = dict(target.get('protocols') or {}) for proto, meta in (exported_server.get('protocols') or {}).items(): if not isinstance(meta, dict): continue cur = dict(protocols.get(proto) or {}) if meta.get('port') and not cur.get('port'): cur['port'] = meta.get('port') if meta.get('connect_domain') and not cur.get('connect_domain'): cur['connect_domain'] = meta.get('connect_domain') if meta.get('installed'): cur['installed'] = True if meta.get('container_name') and not cur.get('container_name'): cur['container_name'] = meta.get('container_name') protocols[proto] = cur target['protocols'] = protocols def import_migrate_zip( ssh, data: dict, target_server_id: int, zip_bytes: bytes, *, restore_protocols: bool = True, protocol_container_name_fn=None, ) -> dict: if target_server_id < 0 or target_server_id >= len(data.get('servers') or []): raise ValueError('Target server not found') with zipfile.ZipFile(io.BytesIO(zip_bytes), 'r') as zf: names = set(zf.namelist()) if 'panel.json' not in names: raise ValueError('Invalid migrate archive: missing panel.json') panel = json.loads(zf.read('panel.json').decode('utf-8')) if panel.get('format') != MIGRATE_FORMAT: raise ValueError(f"Unsupported migrate format: {panel.get('format')}") manifest = {} if 'manifest.json' in names: try: manifest = json.loads(zf.read('manifest.json').decode('utf-8')) except Exception: manifest = {} user_map = _merge_users(data, panel.get('users') or []) conn_stats = _merge_connections( data, panel.get('user_connections') or [], target_server_id=target_server_id, user_id_map=user_map, ) _apply_server_meta(data['servers'][target_server_id], panel.get('server') or {}) # Optional invite links (remap server_id) invites_added = 0 for inv in panel.get('invite_links') or []: if not isinstance(inv, dict): continue token = inv.get('token') if not token: continue existing_tokens = {i.get('token') for i in data.get('invite_links') or []} if token in existing_tokens: continue item = dict(inv) item['server_id'] = target_server_id if not item.get('id'): item['id'] = str(uuid.uuid4()) data.setdefault('invite_links', []).append(item) invites_added += 1 restored = [] restore_errors = [] if restore_protocols and ssh is not None: bm = BackupManager(ssh) backups = manifest.get('protocol_backups') or [] # Fallback: scan protocols/ folder if not backups: for name in names: if name.startswith('protocols/') and name.endswith('.tar.gz'): backups.append({ 'protocol': name.rsplit('/', 1)[-1].split('-', 1)[0], 'filename': name.rsplit('/', 1)[-1], 'archive': name, }) target = data['servers'][target_server_id] protocols = target.get('protocols') or {} for item in backups: proto = item.get('protocol') filename = item.get('filename') archive = item.get('archive') or f'protocols/{filename}' if not proto or not filename or archive not in names: continue try: blob = zf.read(archive) remote = f'{BackupManager.BACKUP_ROOT}/{bm.safe_protocol(proto)}/{bm.safe_filename(filename) or filename}' _upload_bytes_sudo(ssh, blob, remote) container = '' if protocol_container_name_fn: container = protocol_container_name_fn(proto) or '' info = protocols.get(proto) or {} container = info.get('container_name') or item.get('container') or container or '' result = bm.restore_backup(proto, container, filename) if result.get('status') == 'success': restored.append(proto) # Mark installed in panel metadata pinfo = dict(protocols.get(proto) or {}) pinfo['installed'] = True if container: pinfo['container_name'] = container protocols[proto] = pinfo else: restore_errors.append({ 'protocol': proto, 'error': result.get('message') or 'restore failed', }) except Exception as e: logger.exception('Failed restoring protocol %s', proto) restore_errors.append({'protocol': proto, 'error': str(e)}) target['protocols'] = protocols return { 'users_mapped': len(user_map), 'connections': conn_stats, 'invites_added': invites_added, 'protocols_restored': restored, 'protocol_errors': restore_errors, 'connect_domain': (panel.get('server') or {}).get('connect_domain') or '', 'hint': ( 'Update DNS A-record for connect_domain to this server IP. ' 'Existing client configs keep working if protocol state was restored.' ), }