Compare commits

..
8 changed files with 768 additions and 2 deletions
+99 -2
View File
@@ -23,7 +23,7 @@ import time
import urllib.request import urllib.request
import zipfile import zipfile
import signal import signal
from datetime import datetime from datetime import datetime, timezone
import io import io
from fastapi.responses import JSONResponse, RedirectResponse, HTMLResponse, StreamingResponse, FileResponse from fastapi.responses import JSONResponse, RedirectResponse, HTMLResponse, StreamingResponse, FileResponse
from starlette.background import BackgroundTask from starlette.background import BackgroundTask
@@ -103,7 +103,7 @@ else:
application_path = os.path.dirname(__file__) application_path = os.path.dirname(__file__)
DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export
CURRENT_VERSION = "v2.7.0" CURRENT_VERSION = "v2.7.1"
RELEASES_REPO_URL = repo_url() RELEASES_REPO_URL = repo_url()
RELEASES_API_LATEST = api_latest_url() RELEASES_API_LATEST = api_latest_url()
BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin')) BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin'))
@@ -4159,6 +4159,103 @@ async def api_protocol_backup_restore(request: Request, server_id: int, req: Bac
return JSONResponse({'error': str(e)}, status_code=500) return JSONResponse({'error': str(e)}, status_code=500)
@app.get('/api/servers/{server_id}/migrate/export', tags=["Servers"])
async def api_server_migrate_export(
request: Request,
server_id: int,
include_protocols: bool = True,
):
"""Export users/connections (+ protocol state) for domain-preserving server migration."""
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
try:
data = await load_data_async()
if server_id >= len(data['servers']):
return JSONResponse({'error': 'Server not found'}, status_code=404)
server = data['servers'][server_id]
from managers.migrate_manager import export_migrate_zip
def _do_export():
ssh = None
try:
if include_protocols:
ssh = get_ssh(server)
ssh.connect()
return export_migrate_zip(
ssh,
data,
server_id,
include_protocol_backups=include_protocols,
protocol_container_name_fn=protocol_container_name,
)
finally:
if ssh:
ssh.disconnect()
zip_bytes, summary = await asyncio.to_thread(_do_export)
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
safe_name = re.sub(r'[^\w.-]+', '_', server.get('name') or server.get('host') or 'server')
filename = f'amnezia-migrate-{safe_name}-{stamp}.zip'
headers = {
'Content-Disposition': f'attachment; filename="{filename}"',
'X-Migrate-Users': str(summary.get('users', 0)),
'X-Migrate-Connections': str(summary.get('connections', 0)),
'X-Migrate-Protocols': ','.join(summary.get('protocols') or []),
}
return StreamingResponse(io.BytesIO(zip_bytes), media_type='application/zip', headers=headers)
except Exception as e:
logger.exception('Server migrate export failed')
return JSONResponse({'error': str(e)}, status_code=500)
@app.post('/api/servers/{server_id}/migrate/import', tags=["Servers"])
async def api_server_migrate_import(
request: Request,
server_id: int,
file: UploadFile = File(...),
restore_protocols: bool = True,
):
"""Import migrate ZIP onto this server (remap users/connections, restore protocol state)."""
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
try:
content = await file.read()
if not content:
return JSONResponse({'error': 'Empty file'}, status_code=400)
from managers.migrate_manager import import_migrate_zip
async with DATA_LOCK:
data = load_data()
if server_id >= len(data['servers']):
return JSONResponse({'error': 'Server not found'}, status_code=404)
server = data['servers'][server_id]
def _do_import():
ssh = None
try:
if restore_protocols:
ssh = get_ssh(server)
ssh.connect()
return import_migrate_zip(
ssh,
data,
server_id,
content,
restore_protocols=restore_protocols,
protocol_container_name_fn=protocol_container_name,
)
finally:
if ssh:
ssh.disconnect()
result = await asyncio.to_thread(_do_import)
save_data(data)
return {'status': 'success', **result}
except Exception as e:
logger.exception('Server migrate import failed')
return JSONResponse({'error': str(e)}, status_code=400)
@app.post('/api/servers/{server_id}/backups/export-clients', tags=["Protocols"]) @app.post('/api/servers/{server_id}/backups/export-clients', tags=["Protocols"])
async def api_protocol_export_clients(request: Request, server_id: int, req: ProtocolRequest): async def api_protocol_export_clients(request: Request, server_id: int, req: ProtocolRequest):
"""Download a ZIP with all reconstructable WireGuard/AWG client .conf files.""" """Download a ZIP with all reconstructable WireGuard/AWG client .conf files."""
+489
View File
@@ -0,0 +1,489 @@
"""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.'
),
}
+110
View File
@@ -647,6 +647,40 @@
</div> </div>
</div> </div>
<!-- ===== Migrate users / protocol state ===== -->
<div class="modal-backdrop" id="migrateModal">
<div class="modal" style="max-width: 560px;">
<div class="modal-header">
<div>
<h2 class="modal-title">{{ _('migrate_title') }}</h2>
<div class="text-muted text-sm">{{ _('migrate_desc') }}</div>
</div>
<button class="modal-close" onclick="closeModal('migrateModal')">×</button>
</div>
<div class="form-hint" style="margin-bottom: var(--space-md);">{{ _('migrate_hint') }}</div>
<div class="flex" style="flex-direction: column; gap: var(--space-md);">
<div>
<div class="form-label">{{ _('migrate_export_label') }}</div>
<button type="button" class="btn btn-secondary" id="migrateExportBtn" onclick="exportServerMigrate()"
style="width:100%; justify-content:center; gap:var(--space-sm);">
⬇️ {{ _('migrate_export_btn') }}
</button>
</div>
<div style="border-top: 1px solid var(--border-color); padding-top: var(--space-md);">
<div class="form-label">{{ _('migrate_import_label') }}</div>
<input type="file" id="migrateFile" accept=".zip,application/zip" style="display:none;"
onchange="importServerMigrate(event)">
<button type="button" class="btn btn-primary" id="migrateImportBtn"
onclick="document.getElementById('migrateFile').click()"
style="width:100%; justify-content:center; gap:var(--space-sm);">
⬆️ {{ _('migrate_import_btn') }}
</button>
<div class="form-hint" style="margin-top: var(--space-xs);">{{ _('migrate_import_hint') }}</div>
</div>
</div>
</div>
</div>
<!-- ===== Install Protocol Modal ===== --> <!-- ===== Install Protocol Modal ===== -->
<div class="modal-backdrop" id="installModal"> <div class="modal-backdrop" id="installModal">
<div class="modal"> <div class="modal">
@@ -1378,6 +1412,9 @@
<button class="btn btn-secondary" onclick="closeModal('managementModal'); checkServer();" style="justify-content: flex-start; padding: 12px 20px;"> <button class="btn btn-secondary" onclick="closeModal('managementModal'); checkServer();" style="justify-content: flex-start; padding: 12px 20px;">
<span style="font-size: 1.2rem; margin-right: 8px; display:inline-flex;">${uiIcon('refresh')}</span> ${_('check_server_services')} <span style="font-size: 1.2rem; margin-right: 8px; display:inline-flex;">${uiIcon('refresh')}</span> ${_('check_server_services')}
</button> </button>
<button class="btn btn-primary" onclick="closeModal('managementModal'); openModal('migrateModal');" style="justify-content: flex-start; padding: 12px 20px;">
<span style="font-size: 1.2rem; margin-right: 8px;">📦</span> ${_('migrate_title')}
</button>
<button class="btn btn-warning" onclick="closeModal('managementModal'); rebootServer();" style="justify-content: flex-start; padding: 12px 20px;"> <button class="btn btn-warning" onclick="closeModal('managementModal'); rebootServer();" style="justify-content: flex-start; padding: 12px 20px;">
<span style="font-size: 1.2rem; margin-right: 8px; display:inline-flex;">${uiIcon('refresh')}</span> ${_('reboot_server')} <span style="font-size: 1.2rem; margin-right: 8px; display:inline-flex;">${uiIcon('refresh')}</span> ${_('reboot_server')}
</button> </button>
@@ -1391,6 +1428,79 @@
`; `;
} }
async function exportServerMigrate() {
const btn = document.getElementById('migrateExportBtn');
if (btn) btn.disabled = true;
showToast(_('migrate_exporting'), 'info');
try {
const res = await fetch(`/api/servers/${SERVER_ID}/migrate/export?include_protocols=true`, {
credentials: 'same-origin',
});
if (!res.ok) {
let err = _('error');
try {
const data = await res.json();
err = data.error || err;
} catch (_) {}
throw new Error(err);
}
const blob = await res.blob();
const cd = res.headers.get('Content-Disposition') || '';
const match = /filename="?([^"]+)"?/i.exec(cd);
const filename = match ? match[1] : `amnezia-migrate-${SERVER_ID}.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(_('migrate_export_done'), 'success');
} catch (err) {
showToast(`${_('error')}: ${err.message}`, 'error');
} finally {
if (btn) btn.disabled = false;
}
}
async function importServerMigrate(e) {
const file = e.target.files && e.target.files[0];
e.target.value = '';
if (!file) return;
if (!confirm(_('migrate_import_confirm'))) return;
const btn = document.getElementById('migrateImportBtn');
if (btn) btn.disabled = true;
showToast(_('migrate_importing'), 'info');
try {
const form = new FormData();
form.append('file', file);
const res = await fetch(`/api/servers/${SERVER_ID}/migrate/import?restore_protocols=true`, {
method: 'POST',
body: form,
credentials: 'same-origin',
});
const data = await res.json().catch(() => ({}));
if (!res.ok || data.status !== 'success') {
throw new Error(data.error || `HTTP ${res.status}`);
}
const added = (data.connections && data.connections.added) || 0;
const protos = (data.protocols_restored || []).join(', ') || '—';
showToast(
`${_('migrate_import_done')} (+${added} ${_('connections') || 'conn'}; ${protos})`,
'success'
);
if (data.connect_domain) {
showToast(`${_('migrate_dns_hint')}: ${data.connect_domain}`, 'info');
}
setTimeout(() => checkServer(), 1500);
} catch (err) {
showToast(`${_('error')}: ${err.message}`, 'error');
} finally {
if (btn) btn.disabled = false;
}
}
function openManagementModal() { function openManagementModal() {
renderManagementModal(); renderManagementModal();
openModal('managementModal'); openModal('managementModal');
+14
View File
@@ -415,6 +415,20 @@
"server_connect_domain_current": "Endpoint in configs", "server_connect_domain_current": "Endpoint in configs",
"server_connect_domain_ssh": "SSH", "server_connect_domain_ssh": "SSH",
"server_connect_domain_saved": "Connection domain saved", "server_connect_domain_saved": "Connection domain saved",
"migrate_title": "Migrate users",
"migrate_desc": "Move clients to a new server with the same domain",
"migrate_hint": "Export keeps users, bindings and protocol state (keys/peers). On the new server: add it with the new IP and the same connect_domain → install the same protocols → import the ZIP → update the DNS A-record. Client configs stay unchanged.",
"migrate_export_label": "Export from this server",
"migrate_export_btn": "Download migration pack (.zip)",
"migrate_exporting": "Building migration pack…",
"migrate_export_done": "Migration pack downloaded",
"migrate_import_label": "Import onto this server",
"migrate_import_btn": "Import migration pack (.zip)",
"migrate_import_hint": "Install the same protocols on this server first. Do not use Move Connections — it regenerates keys.",
"migrate_import_confirm": "Import users and protocol state onto this server? Existing client_id values will be kept.",
"migrate_importing": "Importing migration…",
"migrate_import_done": "Migration imported",
"migrate_dns_hint": "Update the DNS A-record for domain",
"server_endpoint_label": "Endpoint", "server_endpoint_label": "Endpoint",
"support_title": "Support the project", "support_title": "Support the project",
"support_intro": "If this panel helps you, you can support development. Choose a convenient method below.", "support_intro": "If this panel helps you, you can support development. Choose a convenient method below.",
+14
View File
@@ -399,6 +399,20 @@
"server_ssl_hint": "هنگام نصب Hysteria / NGINX روی این سرور استفاده می‌شود (Let\u0027s Encrypt).", "server_ssl_hint": "هنگام نصب Hysteria / NGINX روی این سرور استفاده می‌شود (Let\u0027s Encrypt).",
"server_connect_domain": "دامنه اتصال کلاینت", "server_connect_domain": "دامنه اتصال کلاینت",
"server_connect_domain_hint": "در پیکربندی VPN به‌جای IP استفاده می‌شود. هنگام انتقال سرور، رکورد DNS A را به‌روز کنید.", "server_connect_domain_hint": "در پیکربندی VPN به‌جای IP استفاده می‌شود. هنگام انتقال سرور، رکورد DNS A را به‌روز کنید.",
"migrate_title": "Migrate users",
"migrate_desc": "Move clients to a new server with the same domain",
"migrate_hint": "Export keeps users, bindings and protocol state. On the new server install the same protocols, import the ZIP, then update DNS.",
"migrate_export_label": "Export from this server",
"migrate_export_btn": "Download migration pack (.zip)",
"migrate_exporting": "Building migration pack…",
"migrate_export_done": "Migration pack downloaded",
"migrate_import_label": "Import onto this server",
"migrate_import_btn": "Import migration pack (.zip)",
"migrate_import_hint": "Install the same protocols first. Do not use Move Connections.",
"migrate_import_confirm": "Import users and protocol state onto this server?",
"migrate_importing": "Importing migration…",
"migrate_import_done": "Migration imported",
"migrate_dns_hint": "Update the DNS A-record for domain",
"container_logs": "لاگ‌های کانتینر", "container_logs": "لاگ‌های کانتینر",
"logs_btn": "📋 Logs", "logs_btn": "📋 Logs",
"logs_live": "زنده", "logs_live": "زنده",
+14
View File
@@ -399,6 +399,20 @@
"server_ssl_hint": "Utilisé lors de l\u0027installation de Hysteria / NGINX sur ce serveur (Let\u0027s Encrypt).", "server_ssl_hint": "Utilisé lors de l\u0027installation de Hysteria / NGINX sur ce serveur (Let\u0027s Encrypt).",
"server_connect_domain": "Domaine de connexion client", "server_connect_domain": "Domaine de connexion client",
"server_connect_domain_hint": "Utilisé dans les configs VPN à la place de l\u0027IP. Lors d\u0027un déplacement du serveur, mettez à jour l\u0027enregistrement DNS A.", "server_connect_domain_hint": "Utilisé dans les configs VPN à la place de l\u0027IP. Lors d\u0027un déplacement du serveur, mettez à jour l\u0027enregistrement DNS A.",
"migrate_title": "Migrer les utilisateurs",
"migrate_desc": "Déplacer les clients vers un nouveau serveur avec le même domaine",
"migrate_hint": "L\u0027export conserve les utilisateurs, liaisons et l\u0027état des protocoles. Sur le nouveau serveur: installer les mêmes protocoles, importer le ZIP, puis mettre à jour le DNS.",
"migrate_export_label": "Exporter depuis ce serveur",
"migrate_export_btn": "Télécharger le pack (.zip)",
"migrate_exporting": "Création du pack…",
"migrate_export_done": "Pack téléchargé",
"migrate_import_label": "Importer sur ce serveur",
"migrate_import_btn": "Importer le pack (.zip)",
"migrate_import_hint": "Installez d\u0027abord les mêmes protocoles. N\u0027utilisez pas Move Connections.",
"migrate_import_confirm": "Importer les utilisateurs et l\u0027état des protocoles sur ce serveur ?",
"migrate_importing": "Import en cours…",
"migrate_import_done": "Migration importée",
"migrate_dns_hint": "Mettez à jour l\u0027enregistrement DNS A du domaine",
"container_logs": "Logs du conteneur", "container_logs": "Logs du conteneur",
"logs_btn": "📋 Logs", "logs_btn": "📋 Logs",
"logs_live": "Temps réel", "logs_live": "Temps réel",
+14
View File
@@ -415,6 +415,20 @@
"server_connect_domain_current": "Endpoint в конфигах", "server_connect_domain_current": "Endpoint в конфигах",
"server_connect_domain_ssh": "SSH", "server_connect_domain_ssh": "SSH",
"server_connect_domain_saved": "Домен подключения сохранён", "server_connect_domain_saved": "Домен подключения сохранён",
"migrate_title": "Миграция пользователей",
"migrate_desc": "Перенос клиентов на новый сервер с тем же доменом",
"migrate_hint": "Экспорт сохраняет пользователей, привязки и состояние протоколов (ключи/peers). На новом сервере: добавьте сервер с новым IP и тем же connect_domain → установите те же протоколы → импортируйте ZIP → обновите DNS A-запись. Конфиги клиентов не меняются.",
"migrate_export_label": "Экспорт с этого сервера",
"migrate_export_btn": "Скачать пакет миграции (.zip)",
"migrate_exporting": "Создаём пакет миграции…",
"migrate_export_done": "Пакет миграции скачан",
"migrate_import_label": "Импорт на этот сервер",
"migrate_import_btn": "Импортировать пакет (.zip)",
"migrate_import_hint": "Сначала установите те же протоколы на этом сервере. Не используйте «Перенос подключений» — он создаёт новые ключи.",
"migrate_import_confirm": "Импортировать пользователей и состояние протоколов на этот сервер? Существующие client_id сохранятся.",
"migrate_importing": "Импорт миграции…",
"migrate_import_done": "Миграция импортирована",
"migrate_dns_hint": "Обновите DNS A-запись для домена",
"server_endpoint_label": "Endpoint", "server_endpoint_label": "Endpoint",
"support_title": "Поддержать проект", "support_title": "Поддержать проект",
"support_intro": "Если панель вам помогает, можно поддержать разработку. Выберите удобный способ ниже.", "support_intro": "Если панель вам помогает, можно поддержать разработку. Выберите удобный способ ниже.",
+14
View File
@@ -399,6 +399,20 @@
"server_ssl_hint": "安装 Hysteria / NGINX 时自动填入(Let\u0027s Encrypt)。", "server_ssl_hint": "安装 Hysteria / NGINX 时自动填入(Let\u0027s Encrypt)。",
"server_connect_domain": "客户端连接域名", "server_connect_domain": "客户端连接域名",
"server_connect_domain_hint": "在 VPN 配置中替代 IP 使用。迁移服务器时只需更新 DNS A 记录。", "server_connect_domain_hint": "在 VPN 配置中替代 IP 使用。迁移服务器时只需更新 DNS A 记录。",
"migrate_title": "Migrate users",
"migrate_desc": "Move clients to a new server with the same domain",
"migrate_hint": "Export keeps users, bindings and protocol state. On the new server install the same protocols, import the ZIP, then update DNS.",
"migrate_export_label": "Export from this server",
"migrate_export_btn": "Download migration pack (.zip)",
"migrate_exporting": "Building migration pack…",
"migrate_export_done": "Migration pack downloaded",
"migrate_import_label": "Import onto this server",
"migrate_import_btn": "Import migration pack (.zip)",
"migrate_import_hint": "Install the same protocols first. Do not use Move Connections.",
"migrate_import_confirm": "Import users and protocol state onto this server?",
"migrate_importing": "Importing migration…",
"migrate_import_done": "Migration imported",
"migrate_dns_hint": "Update the DNS A-record for domain",
"container_logs": "容器日志", "container_logs": "容器日志",
"logs_btn": "📋 日志", "logs_btn": "📋 日志",
"logs_live": "实时", "logs_live": "实时",