Template
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9b9857460 | ||
|
|
58aad90dee | ||
|
|
bf1c6761fd | ||
|
|
c0ce490a15 | ||
|
|
3c95094fc8 | ||
|
|
53bffbd4fc | ||
|
|
53638cf122 | ||
|
|
1f1234d217 |
@@ -277,6 +277,9 @@ GitHub Actions workflows in `.github/workflows/`:
|
|||||||
|
|
||||||
## 📋 Fix / changelog (this fork)
|
## 📋 Fix / changelog (this fork)
|
||||||
|
|
||||||
|
### v2.6.5
|
||||||
|
* **Mieru install fix** — wait for `/var/run/mita.sock`, seed a bootstrap user (empty `users` caused `mita start` RPC EOF), retry apply/start with systemd restart.
|
||||||
|
|
||||||
### v2.6.4
|
### v2.6.4
|
||||||
* **Mieru (mita v3.28.0)** — optional per-server install from [enfein/mieru](https://github.com/enfein/mieru): native Debian/RPM package, no Docker. Marketplace + server card, TCP port at install, user connections with `mierus://` share links. Pinned release **v3.28.0** (GitHub has no v2.8.0 tag).
|
* **Mieru (mita v3.28.0)** — optional per-server install from [enfein/mieru](https://github.com/enfein/mieru): native Debian/RPM package, no Docker. Marketplace + server card, TCP port at install, user connections with `mierus://` share links. Pinned release **v3.28.0** (GitHub has no v2.8.0 tag).
|
||||||
|
|
||||||
@@ -405,7 +408,7 @@ Routes are grouped in the docs as:
|
|||||||
| **Users** | Panel user accounts and the connections assigned to them. |
|
| **Users** | Panel user accounts and the connections assigned to them. |
|
||||||
| **Self-service** | Endpoints called by a regular user for their own data (`/api/my/*`). |
|
| **Self-service** | Endpoints called by a regular user for their own data (`/api/my/*`). |
|
||||||
| **Sharing** | Public, token-protected configuration sharing — no panel session required. |
|
| **Sharing** | Public, token-protected configuration sharing — no panel session required. |
|
||||||
| **Settings** | Panel-wide settings, Telegram bot, Remnawave sync, JSON backup/restore. |
|
| **Settings** | Panel-wide settings, Telegram bot, Remnawave sync, SQL/JSON backup export & import. |
|
||||||
| **API Tokens** | Create and revoke bearer tokens for external integrations. |
|
| **API Tokens** | Create and revoke bearer tokens for external integrations. |
|
||||||
|
|
||||||
**Authentication for external integrations** — both session cookies and `Authorization: Bearer <token>` are accepted on every admin endpoint. Example:
|
**Authentication for external integrations** — both session cookies and `Authorization: Bearer <token>` are accepted on every admin endpoint. Example:
|
||||||
@@ -416,6 +419,9 @@ TOKEN="awp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
|||||||
# List panel users
|
# List panel users
|
||||||
curl -H "Authorization: Bearer $TOKEN" http://your-panel:5000/api/users
|
curl -H "Authorization: Bearer $TOKEN" http://your-panel:5000/api/users
|
||||||
|
|
||||||
|
# List SSH servers (no credentials in response)
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" http://your-panel:5000/api/servers
|
||||||
|
|
||||||
# Add a server
|
# Add a server
|
||||||
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||||
-d '{"host":"1.2.3.4","username":"root","password":"...","name":"new-srv"}' \
|
-d '{"host":"1.2.3.4","username":"root","password":"...","name":"new-srv"}' \
|
||||||
|
|||||||
@@ -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.6.4"
|
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'))
|
||||||
@@ -1354,6 +1354,30 @@ def _touch_api_token(token_entry: dict) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_api_token_touch(token_id: str) -> None:
|
||||||
|
"""Persist API token last_used_at without blocking the request handler."""
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
return
|
||||||
|
loop.create_task(_persist_api_token_touch(token_id))
|
||||||
|
|
||||||
|
|
||||||
|
async def _persist_api_token_touch(token_id: str) -> None:
|
||||||
|
try:
|
||||||
|
async with DATA_LOCK:
|
||||||
|
data = await load_data_async()
|
||||||
|
entry = next(
|
||||||
|
(t for t in data.get('api_tokens', []) if t.get('id') == token_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not entry or not _touch_api_token(entry):
|
||||||
|
return
|
||||||
|
await asyncio.to_thread(save_data, data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to touch API token last_used_at: {e}")
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
salt = secrets.token_hex(16)
|
salt = secrets.token_hex(16)
|
||||||
h = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000)
|
h = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000)
|
||||||
@@ -2944,18 +2968,83 @@ def _check_admin(request):
|
|||||||
resolved = _resolve_api_token(data, raw_token)
|
resolved = _resolve_api_token(data, raw_token)
|
||||||
if resolved:
|
if resolved:
|
||||||
entry, token_user = resolved
|
entry, token_user = resolved
|
||||||
# Best-effort last-used tracking; swallow write errors so a flaky
|
if _touch_api_token(entry):
|
||||||
# disk never blocks an API call from succeeding.
|
token_id = entry.get('id')
|
||||||
try:
|
if token_id:
|
||||||
if _touch_api_token(entry):
|
_schedule_api_token_touch(token_id)
|
||||||
save_data(data)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to touch API token last_used_at: {e}")
|
|
||||||
return token_user
|
return token_user
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_admin_async(request):
|
||||||
|
"""Async variant for hot paths (server list/ping) — avoids blocking the loop."""
|
||||||
|
user = get_current_user(request)
|
||||||
|
if user and user['role'] in ('admin', 'support'):
|
||||||
|
return user
|
||||||
|
|
||||||
|
auth_header = request.headers.get('Authorization', '')
|
||||||
|
if auth_header.lower().startswith('bearer '):
|
||||||
|
raw_token = auth_header[7:].strip()
|
||||||
|
data = await load_data_async()
|
||||||
|
resolved = _resolve_api_token(data, raw_token)
|
||||||
|
if resolved:
|
||||||
|
entry, token_user = resolved
|
||||||
|
if _touch_api_token(entry):
|
||||||
|
token_id = entry.get('id')
|
||||||
|
if token_id:
|
||||||
|
_schedule_api_token_touch(token_id)
|
||||||
|
return token_user
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _public_vpn_server_view(server: dict, server_id: int) -> dict:
|
||||||
|
"""Safe server list view for API consumers (no SSH secrets)."""
|
||||||
|
server_info = dict(server.get('server_info') or {})
|
||||||
|
for key in list(server_info.keys()):
|
||||||
|
if key not in ('uname', 'ssl_domain', 'ssl_email', 'connect_domain'):
|
||||||
|
server_info.pop(key, None)
|
||||||
|
|
||||||
|
protocols = {}
|
||||||
|
for key, info in (server.get('protocols') or {}).items():
|
||||||
|
if not isinstance(info, dict):
|
||||||
|
continue
|
||||||
|
protocols[key] = {
|
||||||
|
'installed': bool(info.get('installed')),
|
||||||
|
'port': info.get('port'),
|
||||||
|
'running': info.get('running'),
|
||||||
|
'container_exists': info.get('container_exists'),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'id': server_id,
|
||||||
|
'name': server.get('name') or server.get('host') or '',
|
||||||
|
'host': server.get('host') or '',
|
||||||
|
'ssh_port': int(server.get('ssh_port') or 22),
|
||||||
|
'username': server.get('username') or '',
|
||||||
|
'auth': 'key' if server.get('private_key') else 'password',
|
||||||
|
'has_password': bool(server.get('password')),
|
||||||
|
'has_private_key': bool(server.get('private_key')),
|
||||||
|
'server_info': server_info,
|
||||||
|
'protocols': protocols,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get('/api/servers', tags=["Servers"])
|
||||||
|
async def api_list_servers(request: Request):
|
||||||
|
"""List SSH servers in the panel inventory (credentials are never returned)."""
|
||||||
|
if not await _check_admin_async(request):
|
||||||
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||||
|
data = await load_data_async()
|
||||||
|
servers = [
|
||||||
|
_public_vpn_server_view(s, idx)
|
||||||
|
for idx, s in enumerate(data.get('servers', []))
|
||||||
|
if isinstance(s, dict)
|
||||||
|
]
|
||||||
|
return {'servers': servers, 'total': len(servers)}
|
||||||
|
|
||||||
|
|
||||||
@app.post('/api/servers/add', tags=["Servers"])
|
@app.post('/api/servers/add', tags=["Servers"])
|
||||||
async def api_add_server(request: Request, req: AddServerRequest):
|
async def api_add_server(request: Request, req: AddServerRequest):
|
||||||
if not _check_admin(request):
|
if not _check_admin(request):
|
||||||
@@ -3166,9 +3255,9 @@ async def api_server_ping(request: Request, server_id: int):
|
|||||||
measures RTT, immediately closes. Runs on the asyncio loop so the page
|
measures RTT, immediately closes. Runs on the asyncio loop so the page
|
||||||
can issue many pings in parallel without blocking each other.
|
can issue many pings in parallel without blocking each other.
|
||||||
"""
|
"""
|
||||||
if not _check_admin(request):
|
if not await _check_admin_async(request):
|
||||||
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||||
data = load_data()
|
data = await load_data_async()
|
||||||
if server_id >= len(data['servers']):
|
if server_id >= len(data['servers']):
|
||||||
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
||||||
server = data['servers'][server_id]
|
server = data['servers'][server_id]
|
||||||
@@ -4070,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."""
|
||||||
@@ -6851,7 +7037,9 @@ async def api_backup_download_json(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@app.post('/api/settings/backup/restore', tags=["Settings"])
|
@app.post('/api/settings/backup/restore', tags=["Settings"])
|
||||||
|
@app.post('/api/settings/backup/import', tags=["Settings"])
|
||||||
async def api_backup_restore(request: Request, file: UploadFile = File(...)):
|
async def api_backup_restore(request: Request, file: UploadFile = File(...)):
|
||||||
|
"""Import panel database from a .sql / .sql.gz dump or legacy data.json."""
|
||||||
if not _check_admin(request):
|
if not _check_admin(request):
|
||||||
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||||
try:
|
try:
|
||||||
@@ -6860,7 +7048,11 @@ async def api_backup_restore(request: Request, file: UploadFile = File(...)):
|
|||||||
return JSONResponse({'error': 'Empty file'}, status_code=400)
|
return JSONResponse({'error': 'Empty file'}, status_code=400)
|
||||||
|
|
||||||
filename = (file.filename or '').lower()
|
filename = (file.filename or '').lower()
|
||||||
is_json = filename.endswith('.json') or content.lstrip().startswith(b'{')
|
is_gzip = filename.endswith('.gz') or content[:2] == b'\x1f\x8b'
|
||||||
|
is_json = (
|
||||||
|
not is_gzip
|
||||||
|
and (filename.endswith('.json') or content.lstrip()[:1] in (b'{', b'['))
|
||||||
|
)
|
||||||
|
|
||||||
if is_json:
|
if is_json:
|
||||||
try:
|
try:
|
||||||
|
|||||||
+48
-6
@@ -2,28 +2,52 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gzip
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from .connection import get_database_url
|
from .connection import close_pool, get_pg_connection_params
|
||||||
from .store import invalidate_data_cache
|
from .store import invalidate_data_cache
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _pg_cli_env(password: str) -> dict[str, str]:
|
||||||
|
env = os.environ.copy()
|
||||||
|
if password:
|
||||||
|
env['PGPASSWORD'] = password
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
def backup_filename() -> str:
|
def backup_filename() -> str:
|
||||||
stamp = datetime.now(timezone.utc).strftime('%Y-%m-%d_%H%M%S')
|
stamp = datetime.now(timezone.utc).strftime('%Y-%m-%d_%H%M%S')
|
||||||
return f'amnezia_panel_backup_{stamp}.sql'
|
return f'amnezia_panel_backup_{stamp}.sql'
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_backup_bytes(data: bytes) -> bytes:
|
||||||
|
"""Accept plain .sql or gzip-compressed dumps (.sql.gz / gzip magic)."""
|
||||||
|
if not data:
|
||||||
|
return data
|
||||||
|
if data[:2] == b'\x1f\x8b':
|
||||||
|
try:
|
||||||
|
return gzip.decompress(data)
|
||||||
|
except OSError as e:
|
||||||
|
raise ValueError(f'Invalid gzip backup: {e}') from e
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
def export_database_sql() -> bytes:
|
def export_database_sql() -> bytes:
|
||||||
"""Create a plain SQL dump of the panel PostgreSQL database."""
|
"""Create a plain SQL dump of the panel PostgreSQL database."""
|
||||||
url = get_database_url()
|
params = get_pg_connection_params()
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
[
|
[
|
||||||
'pg_dump',
|
'pg_dump',
|
||||||
'--dbname', url,
|
'-h', params['host'],
|
||||||
|
'-p', params['port'],
|
||||||
|
'-U', params['user'],
|
||||||
|
'-d', params['dbname'],
|
||||||
'--no-owner',
|
'--no-owner',
|
||||||
'--no-acl',
|
'--no-acl',
|
||||||
'--clean',
|
'--clean',
|
||||||
@@ -31,6 +55,7 @@ def export_database_sql() -> bytes:
|
|||||||
],
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
check=False,
|
check=False,
|
||||||
|
env=_pg_cli_env(params['password']),
|
||||||
)
|
)
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
err = proc.stderr.decode('utf-8', errors='replace').strip()
|
err = proc.stderr.decode('utf-8', errors='replace').strip()
|
||||||
@@ -42,18 +67,35 @@ def export_database_sql() -> bytes:
|
|||||||
|
|
||||||
def restore_database_sql(data: bytes) -> None:
|
def restore_database_sql(data: bytes) -> None:
|
||||||
"""Restore panel data from a plain SQL dump produced by pg_dump."""
|
"""Restore panel data from a plain SQL dump produced by pg_dump."""
|
||||||
|
data = _decode_backup_bytes(data)
|
||||||
if not data or not data.strip():
|
if not data or not data.strip():
|
||||||
raise ValueError('Empty backup file')
|
raise ValueError('Empty backup file')
|
||||||
url = get_database_url()
|
|
||||||
|
# Drop live pool connections so --clean DROP TABLE is not blocked.
|
||||||
|
try:
|
||||||
|
close_pool()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning('close_pool before restore failed: %s', e)
|
||||||
|
|
||||||
|
params = get_pg_connection_params()
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
['psql', '--dbname', url, '-v', 'ON_ERROR_STOP=1', '-q'],
|
[
|
||||||
|
'psql',
|
||||||
|
'-h', params['host'],
|
||||||
|
'-p', params['port'],
|
||||||
|
'-U', params['user'],
|
||||||
|
'-d', params['dbname'],
|
||||||
|
'-v', 'ON_ERROR_STOP=1',
|
||||||
|
'-q',
|
||||||
|
],
|
||||||
input=data,
|
input=data,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
check=False,
|
check=False,
|
||||||
|
env=_pg_cli_env(params['password']),
|
||||||
)
|
)
|
||||||
|
invalidate_data_cache()
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
err = proc.stderr.decode('utf-8', errors='replace').strip()
|
err = proc.stderr.decode('utf-8', errors='replace').strip()
|
||||||
out = proc.stdout.decode('utf-8', errors='replace').strip()
|
out = proc.stdout.decode('utf-8', errors='replace').strip()
|
||||||
raise RuntimeError(err or out or 'psql restore failed')
|
raise RuntimeError(err or out or 'psql restore failed')
|
||||||
invalidate_data_cache()
|
|
||||||
logger.info('PostgreSQL backup restored successfully')
|
logger.info('PostgreSQL backup restored successfully')
|
||||||
|
|||||||
@@ -24,6 +24,37 @@ def get_database_url() -> str:
|
|||||||
return os.environ.get('DATABASE_URL', DEFAULT_DATABASE_URL).strip()
|
return os.environ.get('DATABASE_URL', DEFAULT_DATABASE_URL).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def get_pg_connection_params() -> dict[str, str]:
|
||||||
|
"""Connection parameters for pg_dump/psql (same source as the app pool)."""
|
||||||
|
from psycopg.conninfo import conninfo_to_dict
|
||||||
|
|
||||||
|
url = get_database_url()
|
||||||
|
scheme, _, rest = url.partition('://')
|
||||||
|
if scheme.startswith('postgresql'):
|
||||||
|
url = f'postgresql://{rest}'
|
||||||
|
|
||||||
|
info = conninfo_to_dict(url)
|
||||||
|
params = {
|
||||||
|
'host': str(info.get('host') or 'localhost'),
|
||||||
|
'port': str(info.get('port') or '5432'),
|
||||||
|
'user': str(info.get('user') or 'amnezia'),
|
||||||
|
'password': str(info.get('password') or ''),
|
||||||
|
'dbname': str(info.get('dbname') or 'amnezia_panel'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prefer discrete env vars when set (Dokploy / compose); avoids URL encoding issues.
|
||||||
|
if os.environ.get('POSTGRES_USER', '').strip():
|
||||||
|
params['user'] = os.environ['POSTGRES_USER'].strip()
|
||||||
|
if os.environ.get('POSTGRES_PASSWORD', '').strip():
|
||||||
|
params['password'] = os.environ['POSTGRES_PASSWORD'].strip()
|
||||||
|
if os.environ.get('POSTGRES_DB', '').strip():
|
||||||
|
params['dbname'] = os.environ['POSTGRES_DB'].strip()
|
||||||
|
if os.environ.get('POSTGRES_PORT', '').strip():
|
||||||
|
params['port'] = os.environ['POSTGRES_PORT'].strip()
|
||||||
|
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
def get_pool():
|
def get_pool():
|
||||||
global _pool
|
global _pool
|
||||||
if _pool is not None:
|
if _pool is not None:
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ services:
|
|||||||
- "${APP_PORT:-5000}:5000"
|
- "${APP_PORT:-5000}:5000"
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://${POSTGRES_USER:-amnezia}:${POSTGRES_PASSWORD:-amnezia}@db:5432/${POSTGRES_DB:-amnezia_panel}
|
DATABASE_URL: postgresql://${POSTGRES_USER:-amnezia}:${POSTGRES_PASSWORD:-amnezia}@db:5432/${POSTGRES_DB:-amnezia_panel}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-amnezia}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-amnezia}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-amnezia_panel}
|
||||||
|
POSTGRES_PORT: "5432"
|
||||||
SECRET_KEY: ${SECRET_KEY:-}
|
SECRET_KEY: ${SECRET_KEY:-}
|
||||||
APP_PORT: "5000"
|
APP_PORT: "5000"
|
||||||
PORT: "5000"
|
PORT: "5000"
|
||||||
|
|||||||
+340
-55
@@ -14,12 +14,21 @@ import re
|
|||||||
import secrets
|
import secrets
|
||||||
import shlex
|
import shlex
|
||||||
import string
|
import string
|
||||||
|
import time
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
MIERU_RELEASE = '3.28.0'
|
MIERU_RELEASE = '3.28.0'
|
||||||
GITHUB_RELEASE = f'https://github.com/enfein/mieru/releases/download/v{MIERU_RELEASE}'
|
GITHUB_RELEASE = f'https://github.com/enfein/mieru/releases/download/v{MIERU_RELEASE}'
|
||||||
|
# Official mita UDS (v3.x). Older docs sometimes mention /var/run/mita.sock.
|
||||||
|
MITA_SOCK = '/var/run/mita/mita.sock'
|
||||||
|
MITA_SOCK_LEGACY = '/var/run/mita.sock'
|
||||||
|
# Official package persists applied config here. If this file has portBindings
|
||||||
|
# but no users, `mita run` (systemd) auto-starts the proxy and FATAL-exits
|
||||||
|
# with "socks5 server listening failed: no user found", crashing the daemon.
|
||||||
|
MITA_CONFIG_PB = '/etc/mita/server.conf.pb'
|
||||||
|
MITA_CONFIG_JSON = '/etc/mita/server.conf.json'
|
||||||
|
|
||||||
|
|
||||||
def _q(value):
|
def _q(value):
|
||||||
@@ -144,7 +153,8 @@ class MieruManager:
|
|||||||
b64 = base64.b64encode((content or '').encode('utf-8')).decode('ascii')
|
b64 = base64.b64encode((content or '').encode('utf-8')).decode('ascii')
|
||||||
script = (
|
script = (
|
||||||
f"mkdir -p $(dirname {_q(path)}) && "
|
f"mkdir -p $(dirname {_q(path)}) && "
|
||||||
f"echo {_q(b64)} | base64 -d > {_q(path)}"
|
f"echo {_q(b64)} | base64 -d > {_q(path)} && "
|
||||||
|
f"chmod 644 {_q(path)}"
|
||||||
)
|
)
|
||||||
out, err, code = self.ssh.run_sudo_command(f"sh -c {_q(script)}", timeout=30)
|
out, err, code = self.ssh.run_sudo_command(f"sh -c {_q(script)}", timeout=30)
|
||||||
if code != 0:
|
if code != 0:
|
||||||
@@ -176,14 +186,191 @@ class MieruManager:
|
|||||||
def _write_clients(self, clients):
|
def _write_clients(self, clients):
|
||||||
self._write_file(self.clients_path, json.dumps(clients, indent=2))
|
self._write_file(self.clients_path, json.dumps(clients, indent=2))
|
||||||
|
|
||||||
|
def _make_bootstrap_client(self):
|
||||||
|
return {
|
||||||
|
'id': secrets.token_hex(8),
|
||||||
|
'name': 'panel-bootstrap',
|
||||||
|
'username': f'panel_{_rand_token(6)}',
|
||||||
|
'password': _rand_token(20),
|
||||||
|
'enabled': True,
|
||||||
|
'bootstrap': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _ensure_bootstrap_clients(self, clients):
|
||||||
|
"""Guarantee at least one enabled user so mita never starts with empty users."""
|
||||||
|
clients = [c for c in (clients or []) if isinstance(c, dict)]
|
||||||
|
enabled = [
|
||||||
|
c for c in clients
|
||||||
|
if c.get('enabled', True)
|
||||||
|
and (c.get('username') or c.get('name') or c.get('id'))
|
||||||
|
and (c.get('password') or '').strip()
|
||||||
|
]
|
||||||
|
if enabled:
|
||||||
|
return clients
|
||||||
|
bootstrap = next((c for c in clients if c.get('bootstrap')), None)
|
||||||
|
if bootstrap:
|
||||||
|
bootstrap['enabled'] = True
|
||||||
|
if not (bootstrap.get('password') or '').strip():
|
||||||
|
bootstrap['password'] = _rand_token(20)
|
||||||
|
if not (bootstrap.get('username') or '').strip():
|
||||||
|
bootstrap['username'] = f'panel_{_rand_token(6)}'
|
||||||
|
return clients
|
||||||
|
clients.append(self._make_bootstrap_client())
|
||||||
|
return clients
|
||||||
|
|
||||||
|
def _daemon_needs_heal(self):
|
||||||
|
failed, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"systemctl is-failed {self.SERVICE_NAME} 2>/dev/null"
|
||||||
|
)
|
||||||
|
if (failed or '').strip() == 'failed':
|
||||||
|
return True
|
||||||
|
active, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"systemctl is-active {self.SERVICE_NAME} 2>/dev/null"
|
||||||
|
)
|
||||||
|
# Daemon is up — ignore historical journal lines from earlier crashes.
|
||||||
|
if (active or '').strip() == 'active':
|
||||||
|
return False
|
||||||
|
journal, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"journalctl -u {self.SERVICE_NAME} -n 30 --no-pager --since '10 min ago' 2>&1",
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
return 'no user found' in (journal or '').lower()
|
||||||
|
|
||||||
|
def _heal_mita_store(self, log=None):
|
||||||
|
"""Break the systemd crash loop caused by empty users in server.conf.pb.
|
||||||
|
|
||||||
|
`mita run` auto-starts the proxy when portBindings exist; with zero users
|
||||||
|
it FATAL-exits and never keeps the RPC socket up for `mita apply`.
|
||||||
|
Wiping the store lets the daemon stay IDLE so we can re-apply a valid config.
|
||||||
|
"""
|
||||||
|
if log is not None:
|
||||||
|
log.append('healing mita store (empty users / crash loop)')
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
f"systemctl stop {self.SERVICE_NAME} 2>/dev/null || true; "
|
||||||
|
f"systemctl reset-failed {self.SERVICE_NAME} 2>/dev/null || true; "
|
||||||
|
f"rm -f {_q(MITA_SOCK)} {_q(MITA_SOCK_LEGACY)} /var/run/mita/*.sock "
|
||||||
|
f"{_q(MITA_CONFIG_PB)} {_q(MITA_CONFIG_JSON)} 2>/dev/null || true; "
|
||||||
|
f"mkdir -p /var/run/mita /etc/mita 2>/dev/null || true",
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
# systemd StartLimitBurst: wait out "Start request repeated too quickly".
|
||||||
|
time.sleep(6)
|
||||||
|
|
||||||
|
clients = self._ensure_bootstrap_clients(self._read_clients())
|
||||||
|
self._write_clients(clients)
|
||||||
|
meta = self._read_metadata()
|
||||||
|
port = int(meta.get('port') or self.DEFAULT_PORT)
|
||||||
|
config = self._build_server_config(port, clients)
|
||||||
|
self._write_file(self.config_path, json.dumps(config, indent=2))
|
||||||
|
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
f"systemctl reset-failed {self.SERVICE_NAME} 2>/dev/null || true; "
|
||||||
|
f"systemctl start {self.SERVICE_NAME}",
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
if not self._wait_for_rpc(timeout=60):
|
||||||
|
# Second attempt after another rate-limit window.
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
f"systemctl reset-failed {self.SERVICE_NAME} 2>/dev/null || true; "
|
||||||
|
f"systemctl restart {self.SERVICE_NAME}",
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
time.sleep(3)
|
||||||
|
if not self._wait_for_rpc(timeout=45):
|
||||||
|
journal, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"journalctl -u {self.SERVICE_NAME} -n 50 --no-pager 2>&1",
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
raise RuntimeError(
|
||||||
|
'mita daemon still not ready after heal. '
|
||||||
|
f'journal: {(journal or "").strip()[-600:]}'
|
||||||
|
)
|
||||||
|
out, err, code = self._mita_cli(
|
||||||
|
['apply', 'config', _q(self.config_path)],
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
if code != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f'mita apply after heal failed: {(err or out or "").strip()}'
|
||||||
|
)
|
||||||
|
if log is not None:
|
||||||
|
log.append('mita config re-applied with bootstrap user')
|
||||||
|
|
||||||
|
def _ensure_daemon(self, log=None):
|
||||||
|
"""Ensure mita systemd unit is up and RPC socket answers."""
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
f"systemctl enable {self.SERVICE_NAME} 2>/dev/null || true; "
|
||||||
|
f"mkdir -p /var/run/mita /etc/mita 2>/dev/null || true",
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
# Official package expects the operating user in group `mita`.
|
||||||
|
user_out, _, _ = self.ssh.run_command('id -un 2>/dev/null || echo root')
|
||||||
|
op_user = (user_out or 'root').strip() or 'root'
|
||||||
|
if op_user != 'root':
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
f"usermod -a -G mita {_q(op_user)} 2>/dev/null || true",
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._daemon_needs_heal():
|
||||||
|
self._heal_mita_store(log)
|
||||||
|
if log is not None:
|
||||||
|
log.append('mita daemon is active')
|
||||||
|
return
|
||||||
|
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
f"systemctl start {self.SERVICE_NAME} 2>/dev/null || "
|
||||||
|
f"systemctl restart {self.SERVICE_NAME} 2>/dev/null || true",
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
if not self._wait_for_rpc(timeout=45):
|
||||||
|
# Crash loop / wrong socket / rate-limit — wipe store and recover.
|
||||||
|
self._heal_mita_store(log)
|
||||||
|
if log is not None:
|
||||||
|
log.append('mita daemon is active')
|
||||||
|
|
||||||
|
def _wait_for_rpc(self, timeout=30):
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
sock_check = (
|
||||||
|
f"(test -S {_q(MITA_SOCK)} || test -S {_q(MITA_SOCK_LEGACY)}) && echo ok"
|
||||||
|
)
|
||||||
|
while time.time() < deadline:
|
||||||
|
# Prefer CLI status: if it answers IDLE/RUNNING, RPC is up
|
||||||
|
# regardless of which sock path we expected.
|
||||||
|
status_out, _, status_code = self.ssh.run_sudo_command(
|
||||||
|
'mita status 2>&1',
|
||||||
|
timeout=20,
|
||||||
|
)
|
||||||
|
text = (status_out or '').upper()
|
||||||
|
if status_code == 0 and ('IDLE' in text or 'RUNNING' in text):
|
||||||
|
return True
|
||||||
|
sock_out, _, sock_code = self.ssh.run_sudo_command(sock_check)
|
||||||
|
if sock_code == 0 and 'ok' in (sock_out or ''):
|
||||||
|
time.sleep(1)
|
||||||
|
continue
|
||||||
|
time.sleep(1.5)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _mita_cli(self, args, timeout=60):
|
||||||
|
"""Run mita CLI as root so group/socket ACL is not an issue."""
|
||||||
|
cmd = f"mita {' '.join(args)} 2>&1"
|
||||||
|
return self.ssh.run_sudo_command(cmd, timeout=timeout)
|
||||||
|
|
||||||
def _build_server_config(self, port, clients):
|
def _build_server_config(self, port, clients):
|
||||||
|
clients = self._ensure_bootstrap_clients(clients)
|
||||||
users = []
|
users = []
|
||||||
for c in clients:
|
for c in clients:
|
||||||
if c.get('enabled', True):
|
if not c.get('enabled', True):
|
||||||
users.append({
|
continue
|
||||||
'name': c.get('username') or c.get('name') or c.get('id'),
|
username = (c.get('username') or c.get('name') or c.get('id') or '').strip()
|
||||||
'password': c.get('password') or '',
|
password = (c.get('password') or '').strip()
|
||||||
})
|
if not username or not password:
|
||||||
|
continue
|
||||||
|
users.append({'name': username, 'password': password})
|
||||||
|
# mita FATAL-exits on empty users during proxy start ("no user found").
|
||||||
|
if not users:
|
||||||
|
bootstrap = self._make_bootstrap_client()
|
||||||
|
users = [{'name': bootstrap['username'], 'password': bootstrap['password']}]
|
||||||
return {
|
return {
|
||||||
'portBindings': [{'port': int(port), 'protocol': 'TCP'}],
|
'portBindings': [{'port': int(port), 'protocol': 'TCP'}],
|
||||||
'users': users,
|
'users': users,
|
||||||
@@ -192,25 +379,107 @@ class MieruManager:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def _apply_config(self, config, reload_only=False):
|
def _apply_config(self, config, reload_only=False):
|
||||||
|
self._ensure_daemon()
|
||||||
self._write_file(self.config_path, json.dumps(config, indent=2))
|
self._write_file(self.config_path, json.dumps(config, indent=2))
|
||||||
out, err, code = self.ssh.run_sudo_command(
|
out, err, code = self._mita_cli(
|
||||||
f"mita apply config {_q(self.config_path)} 2>&1",
|
['apply', 'config', _q(self.config_path)],
|
||||||
timeout=60,
|
timeout=60,
|
||||||
)
|
)
|
||||||
if code != 0:
|
if code != 0:
|
||||||
raise RuntimeError((err or out or 'mita apply config failed').strip())
|
# Recover from transient EOF / unavailable RPC.
|
||||||
|
if self._is_rpc_error(out, err):
|
||||||
|
self._ensure_daemon()
|
||||||
|
out, err, code = self._mita_cli(
|
||||||
|
['apply', 'config', _q(self.config_path)],
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
if code != 0:
|
||||||
|
raise RuntimeError((err or out or 'mita apply config failed').strip())
|
||||||
|
|
||||||
if reload_only:
|
if reload_only:
|
||||||
self.ssh.run_sudo_command('mita reload 2>/dev/null || true', timeout=30)
|
# users/loggingLevel can hot-reload; fall back to full restart.
|
||||||
else:
|
reload_out, reload_err, reload_code = self._mita_cli(['reload'], timeout=30)
|
||||||
self.ssh.run_sudo_command('mita stop 2>/dev/null || true', timeout=30)
|
if reload_code == 0:
|
||||||
out2, err2, code2 = self.ssh.run_sudo_command('mita start 2>&1', timeout=60)
|
return
|
||||||
if code2 != 0:
|
logger.info('mita reload failed, falling back to stop/start: %s',
|
||||||
raise RuntimeError((err2 or out2 or 'mita start failed').strip())
|
(reload_err or reload_out or '').strip())
|
||||||
|
|
||||||
|
self._restart_proxy()
|
||||||
|
|
||||||
|
def _is_rpc_error(self, *parts):
|
||||||
|
text = ' '.join(str(p or '') for p in parts).lower()
|
||||||
|
return any(token in text for token in (
|
||||||
|
'rpc error',
|
||||||
|
'unavailable',
|
||||||
|
'error reading from server',
|
||||||
|
'eof',
|
||||||
|
'no such file or directory',
|
||||||
|
'mita.sock',
|
||||||
|
'connection refused',
|
||||||
|
))
|
||||||
|
|
||||||
|
def _restart_proxy(self):
|
||||||
|
# Always push a config that includes users before start — recovers hosts
|
||||||
|
# whose /etc/mita/server.conf.pb lost the users list.
|
||||||
|
try:
|
||||||
|
meta = self._read_metadata()
|
||||||
|
port = int(meta.get('port') or self.DEFAULT_PORT)
|
||||||
|
clients = self._ensure_bootstrap_clients(self._read_clients())
|
||||||
|
self._write_clients(clients)
|
||||||
|
config = self._build_server_config(port, clients)
|
||||||
|
self._write_file(self.config_path, json.dumps(config, indent=2))
|
||||||
|
apply_out, apply_err, apply_code = self._mita_cli(
|
||||||
|
['apply', 'config', _q(self.config_path)],
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
if apply_code != 0 and self._is_rpc_error(apply_out, apply_err):
|
||||||
|
self._heal_mita_store()
|
||||||
|
elif apply_code != 0:
|
||||||
|
logger.warning(
|
||||||
|
'mita apply before start failed: %s',
|
||||||
|
(apply_err or apply_out or '').strip(),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning('pre-start config sync failed: %s', e)
|
||||||
|
|
||||||
|
self._mita_cli(['stop'], timeout=30)
|
||||||
|
time.sleep(1)
|
||||||
|
last_err = ''
|
||||||
|
for attempt in range(1, 4):
|
||||||
|
out, err, code = self._mita_cli(['start'], timeout=60)
|
||||||
|
if code == 0:
|
||||||
|
time.sleep(1)
|
||||||
|
if self._proxy_running():
|
||||||
|
return
|
||||||
|
last_err = (out or err or 'mita start returned ok but status is not RUNNING').strip()
|
||||||
|
else:
|
||||||
|
last_err = (err or out or 'mita start failed').strip()
|
||||||
|
if 'no user found' in last_err.lower() or self._daemon_needs_heal():
|
||||||
|
self._heal_mita_store()
|
||||||
|
continue
|
||||||
|
if self._is_rpc_error(last_err) or attempt < 3:
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
f"systemctl restart {self.SERVICE_NAME} 2>/dev/null || true",
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
if not self._wait_for_rpc(timeout=30):
|
||||||
|
self._heal_mita_store()
|
||||||
|
time.sleep(1)
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
journal, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"journalctl -u {self.SERVICE_NAME} -n 30 --no-pager 2>&1",
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
raise RuntimeError(
|
||||||
|
f'{last_err}. journal: {(journal or "").strip()[-400:]}'
|
||||||
|
)
|
||||||
|
|
||||||
def _sync_server(self, reload_only=True):
|
def _sync_server(self, reload_only=True):
|
||||||
meta = self._read_metadata()
|
meta = self._read_metadata()
|
||||||
port = int(meta.get('port') or self.DEFAULT_PORT)
|
port = int(meta.get('port') or self.DEFAULT_PORT)
|
||||||
clients = self._read_clients()
|
clients = self._ensure_bootstrap_clients(self._read_clients())
|
||||||
|
self._write_clients(clients)
|
||||||
config = self._build_server_config(port, clients)
|
config = self._build_server_config(port, clients)
|
||||||
self._apply_config(config, reload_only=reload_only)
|
self._apply_config(config, reload_only=reload_only)
|
||||||
|
|
||||||
@@ -261,10 +530,7 @@ fi
|
|||||||
if code != 0:
|
if code != 0:
|
||||||
raise RuntimeError(f'Failed to install mita package: {err or out}')
|
raise RuntimeError(f'Failed to install mita package: {err or out}')
|
||||||
log.append('Installed mita package')
|
log.append('Installed mita package')
|
||||||
self.ssh.run_sudo_command(
|
self._ensure_daemon(log)
|
||||||
f"systemctl enable --now {self.SERVICE_NAME} 2>/dev/null || true",
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _build_share_uri(self, host, port, username, password, name=''):
|
def _build_share_uri(self, host, port, username, password, name=''):
|
||||||
user = quote(username or '', safe='')
|
user = quote(username or '', safe='')
|
||||||
@@ -305,42 +571,43 @@ fi
|
|||||||
return {'status': 'error', 'message': 'Port must be between 1025 and 65535'}
|
return {'status': 'error', 'message': 'Port must be between 1025 and 65535'}
|
||||||
|
|
||||||
log = []
|
log = []
|
||||||
if not self._mita_installed():
|
|
||||||
self._install_package(log)
|
|
||||||
else:
|
|
||||||
log.append(f'mita already installed, configuring panel (v{MIERU_RELEASE})')
|
|
||||||
|
|
||||||
self.ssh.run_sudo_command(f"mkdir -p {_q(self.base_dir)}")
|
|
||||||
meta = {'port': port, 'release': MIERU_RELEASE}
|
|
||||||
self._write_metadata(meta)
|
|
||||||
self._write_clients([])
|
|
||||||
log.append(f'Prepared {self.base_dir}')
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
config = self._build_server_config(port, [])
|
if not self._mita_installed():
|
||||||
|
self._install_package(log)
|
||||||
|
else:
|
||||||
|
log.append(f'mita already installed, configuring panel (v{MIERU_RELEASE})')
|
||||||
|
self._ensure_daemon(log)
|
||||||
|
|
||||||
|
self.ssh.run_sudo_command(f"mkdir -p {_q(self.base_dir)}")
|
||||||
|
meta = {'port': port, 'release': MIERU_RELEASE}
|
||||||
|
self._write_metadata(meta)
|
||||||
|
bootstrap = self._make_bootstrap_client()
|
||||||
|
self._write_clients([bootstrap])
|
||||||
|
log.append(f'Prepared {self.base_dir}')
|
||||||
|
|
||||||
|
config = self._build_server_config(port, [bootstrap])
|
||||||
self._apply_config(config, reload_only=False)
|
self._apply_config(config, reload_only=False)
|
||||||
|
self._open_firewall_port(port)
|
||||||
|
log.append(f'Started mita proxy on TCP {port}')
|
||||||
|
return {
|
||||||
|
'status': 'success',
|
||||||
|
'message': f'Mieru v{MIERU_RELEASE} installed',
|
||||||
|
'log': log,
|
||||||
|
'port': str(port),
|
||||||
|
'release': MIERU_RELEASE,
|
||||||
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {'status': 'error', 'message': str(e), 'log': log}
|
return {'status': 'error', 'message': str(e), 'log': log}
|
||||||
|
|
||||||
self._open_firewall_port(port)
|
|
||||||
log.append(f'Started mita proxy on TCP {port}')
|
|
||||||
return {
|
|
||||||
'status': 'success',
|
|
||||||
'message': f'Mieru v{MIERU_RELEASE} installed',
|
|
||||||
'log': log,
|
|
||||||
'port': str(port),
|
|
||||||
'release': MIERU_RELEASE,
|
|
||||||
}
|
|
||||||
|
|
||||||
def remove_container(self, protocol_type=None):
|
def remove_container(self, protocol_type=None):
|
||||||
self.ssh.run_sudo_command('mita stop 2>/dev/null || true', timeout=30)
|
self.ssh.run_sudo_command('mita stop 2>/dev/null || true', timeout=30)
|
||||||
self.ssh.run_sudo_command(f"rm -rf {_q(self.base_dir)}")
|
self.ssh.run_sudo_command(f"rm -rf {_q(self.base_dir)}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def start_service(self):
|
def start_service(self):
|
||||||
out, err, code = self.ssh.run_sudo_command('mita start 2>&1', timeout=60)
|
self._ensure_daemon()
|
||||||
if code != 0:
|
# Re-apply panel clients (with bootstrap) then start — fixes empty-users store.
|
||||||
raise RuntimeError((err or out or 'mita start failed').strip())
|
self._sync_server(reload_only=False)
|
||||||
|
|
||||||
def stop_service(self):
|
def stop_service(self):
|
||||||
self.ssh.run_sudo_command('mita stop 2>/dev/null || true', timeout=30)
|
self.ssh.run_sudo_command('mita stop 2>/dev/null || true', timeout=30)
|
||||||
@@ -352,15 +619,29 @@ fi
|
|||||||
return self._read_file(self.config_path)
|
return self._read_file(self.config_path)
|
||||||
|
|
||||||
def save_server_config(self, protocol_type=None, config_text=''):
|
def save_server_config(self, protocol_type=None, config_text=''):
|
||||||
self._write_file(self.config_path, config_text or '')
|
raw = (config_text or '').strip()
|
||||||
out, err, code = self.ssh.run_sudo_command(
|
if not raw:
|
||||||
f"mita apply config {_q(self.config_path)} 2>&1",
|
raise RuntimeError('Config is empty')
|
||||||
timeout=60,
|
try:
|
||||||
)
|
parsed = json.loads(raw)
|
||||||
if code != 0:
|
except Exception as e:
|
||||||
raise RuntimeError((err or out or 'mita apply config failed').strip())
|
raise RuntimeError(f'Invalid JSON config: {e}') from e
|
||||||
self.ssh.run_sudo_command('mita stop 2>/dev/null || true', timeout=30)
|
if not isinstance(parsed, dict):
|
||||||
self.ssh.run_sudo_command('mita start 2>&1', timeout=60)
|
raise RuntimeError('Config must be a JSON object')
|
||||||
|
users = parsed.get('users')
|
||||||
|
if not isinstance(users, list) or not any(
|
||||||
|
isinstance(u, dict) and (u.get('name') or '').strip()
|
||||||
|
and ((u.get('password') or '').strip() or (u.get('hashedPassword') or '').strip())
|
||||||
|
for u in users
|
||||||
|
):
|
||||||
|
bootstrap = self._make_bootstrap_client()
|
||||||
|
parsed['users'] = [{
|
||||||
|
'name': bootstrap['username'],
|
||||||
|
'password': bootstrap['password'],
|
||||||
|
}]
|
||||||
|
clients = self._ensure_bootstrap_clients(self._read_clients())
|
||||||
|
self._write_clients(clients)
|
||||||
|
self._apply_config(parsed, reload_only=False)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# ===================== CLIENTS =====================
|
# ===================== CLIENTS =====================
|
||||||
@@ -369,6 +650,8 @@ fi
|
|||||||
clients = self._read_clients()
|
clients = self._read_clients()
|
||||||
result = []
|
result = []
|
||||||
for c in clients:
|
for c in clients:
|
||||||
|
if c.get('bootstrap'):
|
||||||
|
continue
|
||||||
cname = c.get('name') or c.get('id')
|
cname = c.get('name') or c.get('id')
|
||||||
result.append({
|
result.append({
|
||||||
'clientId': c.get('id'),
|
'clientId': c.get('id'),
|
||||||
@@ -413,7 +696,7 @@ fi
|
|||||||
port = int(port or meta.get('port') or self.DEFAULT_PORT)
|
port = int(port or meta.get('port') or self.DEFAULT_PORT)
|
||||||
clients = self._read_clients()
|
clients = self._read_clients()
|
||||||
client = next((c for c in clients if c.get('id') == client_id), None)
|
client = next((c for c in clients if c.get('id') == client_id), None)
|
||||||
if not client:
|
if not client or client.get('bootstrap'):
|
||||||
return ''
|
return ''
|
||||||
if not client.get('enabled', True):
|
if not client.get('enabled', True):
|
||||||
return ''
|
return ''
|
||||||
@@ -424,6 +707,7 @@ fi
|
|||||||
|
|
||||||
def remove_client(self, protocol_type, client_id):
|
def remove_client(self, protocol_type, client_id):
|
||||||
clients = [c for c in self._read_clients() if c.get('id') != client_id]
|
clients = [c for c in self._read_clients() if c.get('id') != client_id]
|
||||||
|
clients = self._ensure_bootstrap_clients(clients)
|
||||||
self._write_clients(clients)
|
self._write_clients(clients)
|
||||||
self._sync_server(reload_only=True)
|
self._sync_server(reload_only=True)
|
||||||
return True
|
return True
|
||||||
@@ -433,6 +717,7 @@ fi
|
|||||||
for c in clients:
|
for c in clients:
|
||||||
if c.get('id') == client_id:
|
if c.get('id') == client_id:
|
||||||
c['enabled'] = bool(enabled)
|
c['enabled'] = bool(enabled)
|
||||||
|
clients = self._ensure_bootstrap_clients(clients)
|
||||||
self._write_clients(clients)
|
self._write_clients(clients)
|
||||||
self._sync_server(reload_only=True)
|
self._sync_server(reload_only=True)
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -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.'
|
||||||
|
),
|
||||||
|
}
|
||||||
+136
-26
@@ -314,6 +314,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Mieru Card -->
|
||||||
|
<div class="card card-hover protocol-card protocol-mieru" id="proto-mieru">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
|
||||||
|
<div class="protocol-icon">{{ icon('zap') }}</div>
|
||||||
|
<div class="flex gap-sm" id="mieru-ctrl" style="display:none!important;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="protocol-name">Mieru <span
|
||||||
|
style="font-size:0.65rem; background:var(--accent, #6366f1); color:#fff; padding:2px 6px; border-radius:8px; vertical-align:middle;">v3.28.0</span></div>
|
||||||
|
<div class="protocol-desc">
|
||||||
|
{{ _('mieru_desc') }}
|
||||||
|
</div>
|
||||||
|
<div class="protocol-status" id="mieru-status">
|
||||||
|
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
|
||||||
|
</div>
|
||||||
|
<div id="mieru-info" class="hidden">
|
||||||
|
<div class="protocol-info" id="mieru-info-grid"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-sm" id="mieru-actions">
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="openInstallModal('mieru')" id="mieru-install-btn"
|
||||||
|
style="flex:1">
|
||||||
|
{{ _('install') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Hysteria Card -->
|
<!-- Hysteria Card -->
|
||||||
<div class="card card-hover protocol-card protocol-hysteria" id="proto-hysteria">
|
<div class="card card-hover protocol-card protocol-hysteria" id="proto-hysteria">
|
||||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
|
||||||
@@ -363,31 +388,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mieru Card -->
|
|
||||||
<div class="card card-hover protocol-card protocol-mieru" id="proto-mieru">
|
|
||||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
|
|
||||||
<div class="protocol-icon">{{ icon('zap') }}</div>
|
|
||||||
<div class="flex gap-sm" id="mieru-ctrl" style="display:none!important;"></div>
|
|
||||||
</div>
|
|
||||||
<div class="protocol-name">Mieru <span
|
|
||||||
style="font-size:0.65rem; background:var(--accent, #6366f1); color:#fff; padding:2px 6px; border-radius:8px; vertical-align:middle;">v3.28.0</span></div>
|
|
||||||
<div class="protocol-desc">
|
|
||||||
{{ _('mieru_desc') }}
|
|
||||||
</div>
|
|
||||||
<div class="protocol-status" id="mieru-status">
|
|
||||||
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
|
|
||||||
</div>
|
|
||||||
<div id="mieru-info" class="hidden">
|
|
||||||
<div class="protocol-info" id="mieru-info-grid"></div>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-sm" id="mieru-actions">
|
|
||||||
<button class="btn btn-primary btn-sm" onclick="openInstallModal('mieru')" id="mieru-install-btn"
|
|
||||||
style="flex:1">
|
|
||||||
{{ _('install') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- WireGuard Card -->
|
<!-- WireGuard Card -->
|
||||||
<div class="card card-hover protocol-card protocol-wireguard" id="proto-wireguard">
|
<div class="card card-hover protocol-card protocol-wireguard" id="proto-wireguard">
|
||||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
|
||||||
@@ -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">
|
||||||
@@ -1199,9 +1233,9 @@
|
|||||||
{ proto: 'awg_legacy', category: 'protocols', icon: 'radio', title: 'AmneziaWG Legacy', descKey: 'awg_legacy_desc' },
|
{ proto: 'awg_legacy', category: 'protocols', icon: 'radio', title: 'AmneziaWG Legacy', descKey: 'awg_legacy_desc' },
|
||||||
{ proto: 'xray', category: 'protocols', icon: 'zap', title: 'Xray (VLESS-Reality)', descKey: 'xray_desc' },
|
{ proto: 'xray', category: 'protocols', icon: 'zap', title: 'Xray (VLESS-Reality)', descKey: 'xray_desc' },
|
||||||
{ proto: 'telemt', category: 'protocols', icon: 'plane', title: 'Telemt (Telegram Proxy)', descKey: 'telemt_desc' },
|
{ proto: 'telemt', category: 'protocols', icon: 'plane', title: 'Telemt (Telegram Proxy)', descKey: 'telemt_desc' },
|
||||||
|
{ proto: 'mieru', category: 'protocols', icon: 'zap', title: 'Mieru', descKey: 'mieru_desc', badge: 'v3.28.0' },
|
||||||
{ proto: 'hysteria', category: 'protocols', icon: 'refresh', title: 'Hysteria 2', descKey: 'hysteria_desc' },
|
{ proto: 'hysteria', category: 'protocols', icon: 'refresh', title: 'Hysteria 2', descKey: 'hysteria_desc' },
|
||||||
{ proto: 'naiveproxy', category: 'protocols', icon: 'link', title: 'NaiveProxy', descKey: 'naiveproxy_desc', badge: 'STABLE' },
|
{ proto: 'naiveproxy', category: 'protocols', icon: 'link', title: 'NaiveProxy', descKey: 'naiveproxy_desc', badge: 'STABLE' },
|
||||||
{ proto: 'mieru', category: 'protocols', icon: 'zap', title: 'Mieru', descKey: 'mieru_desc', badge: 'v3.28.0' },
|
|
||||||
{ proto: 'wireguard', category: 'protocols', icon: 'lock', title: 'WireGuard', descKey: 'wireguard_desc' },
|
{ proto: 'wireguard', category: 'protocols', icon: 'lock', title: 'WireGuard', descKey: 'wireguard_desc' },
|
||||||
{ proto: 'dns', category: 'services', icon: 'search', title: 'AmneziaDNS', descKey: 'dns_desc' },
|
{ proto: 'dns', category: 'services', icon: 'search', title: 'AmneziaDNS', descKey: 'dns_desc' },
|
||||||
{ proto: 'adguard', category: 'services', icon: 'shield-check', title: 'AdGuard Home', descKey: 'adguard_desc' },
|
{ proto: 'adguard', category: 'services', icon: 'shield-check', title: 'AdGuard Home', descKey: 'adguard_desc' },
|
||||||
@@ -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');
|
||||||
|
|||||||
+35
-20
@@ -600,29 +600,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- BLOCK: Simple Backup -->
|
<!-- BLOCK: Backup / Import -->
|
||||||
<div class="card" style="margin-top: var(--space-lg);">
|
<div class="card" style="margin-top: var(--space-lg);">
|
||||||
<h3 class="card-title" style="margin-bottom: var(--space-lg);">📤 {{ _('backup_title') }}</h3>
|
<h3 class="card-title" style="margin-bottom: var(--space-lg);">📤 {{ _('backup_title') }}</h3>
|
||||||
<div style="display: flex; flex-direction: column; gap: var(--space-md);">
|
<div style="display: flex; flex-direction: column; gap: var(--space-md);">
|
||||||
<div style="display: flex; gap: var(--space-sm); flex-wrap: wrap;">
|
<div>
|
||||||
<a href="/api/settings/backup/download" class="btn btn-secondary"
|
<div class="form-label" style="margin-bottom: var(--space-sm);">{{ _('backup_export_label') }}</div>
|
||||||
style="flex:1; min-width:200px; text-decoration:none; display:flex; align-items:center; justify-content:center; gap:var(--space-sm);">
|
<div style="display: flex; gap: var(--space-sm); flex-wrap: wrap;">
|
||||||
<span>⬇️</span> {{ _('download_backup') }}
|
<a href="/api/settings/backup/download" class="btn btn-secondary"
|
||||||
</a>
|
style="flex:1; min-width:200px; text-decoration:none; display:flex; align-items:center; justify-content:center; gap:var(--space-sm);">
|
||||||
<a href="/api/settings/backup/download/json" class="btn btn-secondary"
|
<span>⬇️</span> {{ _('download_backup') }}
|
||||||
style="flex:1; min-width:200px; text-decoration:none; display:flex; align-items:center; justify-content:center; gap:var(--space-sm);">
|
</a>
|
||||||
<span>📄</span> {{ _('download_backup_json') }}
|
<a href="/api/settings/backup/download/json" class="btn btn-secondary"
|
||||||
</a>
|
style="flex:1; min-width:200px; text-decoration:none; display:flex; align-items:center; justify-content:center; gap:var(--space-sm);">
|
||||||
|
<span>📄</span> {{ _('download_backup_json') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="border-top: 1px solid var(--border-color); padding-top: var(--space-md);">
|
<div style="border-top: 1px solid var(--border-color); padding-top: var(--space-md);">
|
||||||
|
<div class="form-label" style="margin-bottom: var(--space-sm);">{{ _('backup_import_label') }}</div>
|
||||||
<div style="display: flex; flex-direction: column; gap: var(--space-sm);">
|
<div style="display: flex; flex-direction: column; gap: var(--space-sm);">
|
||||||
<input type="file" id="backupFile" accept=".sql,.json" style="display: none;"
|
<input type="file" id="backupFile" accept=".sql,.sql.gz,.json,application/sql,application/gzip,application/json"
|
||||||
onchange="handleRestore(event)">
|
style="display: none;" onchange="handleRestore(event)">
|
||||||
<button type="button" class="btn btn-secondary"
|
<button type="button" class="btn btn-primary"
|
||||||
onclick="document.getElementById('backupFile').click()" id="restoreBtn"
|
onclick="document.getElementById('backupFile').click()" id="restoreBtn"
|
||||||
style="gap:var(--space-sm);">
|
style="gap:var(--space-sm);">
|
||||||
<span>⬆️</span> {{ _('restore_backup') }}
|
<span>⬆️</span> {{ _('import_backup') }}
|
||||||
</button>
|
</button>
|
||||||
|
<div class="form-hint" id="backupFileName"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1667,30 +1672,40 @@
|
|||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
|
const nameEl = document.getElementById('backupFileName');
|
||||||
|
if (nameEl) nameEl.textContent = file.name;
|
||||||
|
|
||||||
if (!confirm(_('restore_confirm'))) {
|
if (!confirm(_('restore_confirm'))) {
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
|
if (nameEl) nameEl.textContent = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const btn = document.getElementById('restoreBtn');
|
const btn = document.getElementById('restoreBtn');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
const originalText = btn.innerHTML;
|
const originalText = btn.innerHTML;
|
||||||
btn.innerHTML = `<div class="spinner" style="width:14px; height:14px;"></div> ${_('loading')}`;
|
btn.innerHTML = `<div class="spinner" style="width:14px; height:14px;"></div> ${_('importing_backup')}`;
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/settings/backup/restore', {
|
const res = await fetch('/api/settings/backup/import', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData,
|
||||||
|
credentials: 'same-origin',
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
let data = {};
|
||||||
if (data.status === 'success') {
|
try {
|
||||||
|
data = await res.json();
|
||||||
|
} catch (_) {
|
||||||
|
data = {};
|
||||||
|
}
|
||||||
|
if (res.ok && data.status === 'success') {
|
||||||
showToast(_('restore_success'), 'success');
|
showToast(_('restore_success'), 'success');
|
||||||
setTimeout(() => window.location.reload(), 2000);
|
setTimeout(() => window.location.reload(), 2000);
|
||||||
} else {
|
} else {
|
||||||
showToast(data.error || _('invalid_backup_file'), 'error');
|
showToast(data.error || _('invalid_backup_file') + ` (HTTP ${res.status})`, 'error');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(`${_('error')}: ` + err.message, 'error');
|
showToast(`${_('error')}: ` + err.message, 'error');
|
||||||
|
|||||||
+22
-4
@@ -362,13 +362,17 @@
|
|||||||
"lang_zh": "中文 (Chinese)",
|
"lang_zh": "中文 (Chinese)",
|
||||||
"lang_fa": "فارسی (Persian)",
|
"lang_fa": "فارسی (Persian)",
|
||||||
"backup_title": "Simple Backup",
|
"backup_title": "Simple Backup",
|
||||||
|
"backup_export_label": "Export",
|
||||||
|
"backup_import_label": "Import",
|
||||||
"download_backup": "Download PostgreSQL dump (.sql)",
|
"download_backup": "Download PostgreSQL dump (.sql)",
|
||||||
"download_backup_json": "Export JSON (legacy)",
|
"download_backup_json": "Export JSON (legacy)",
|
||||||
"backup_hint": "Full PostgreSQL database dump of the panel. Use .sql for complete backup; JSON export is compatible with older versions.",
|
"backup_hint": "Full PostgreSQL database dump of the panel (.sql / .sql.gz). JSON export is compatible with older versions.",
|
||||||
"restore_backup": "Restore from .sql or .json",
|
"restore_backup": "Restore from .sql or .json",
|
||||||
"restore_confirm": "Restore will overwrite all current panel data in the database.",
|
"import_backup": "Import database (.sql / .sql.gz / .json)",
|
||||||
"restore_success": "Restore successful! Restarting...",
|
"importing_backup": "Importing database...",
|
||||||
"invalid_backup_file": "Invalid backup file (.sql dump or legacy data.json)",
|
"restore_confirm": "Import will overwrite all current panel data in the database. Continue?",
|
||||||
|
"restore_success": "Import successful! Reloading...",
|
||||||
|
"invalid_backup_file": "Invalid backup file (.sql, .sql.gz or legacy data.json)",
|
||||||
"config_unavailable": "Configuration unavailable",
|
"config_unavailable": "Configuration unavailable",
|
||||||
"config_unavailable_desc": "This client was created via the native Amnezia app.\\nThe private key is stored only on the user\u0027s device and cannot be recovered by the server.",
|
"config_unavailable_desc": "This client was created via the native Amnezia app.\\nThe private key is stored only on the user\u0027s device and cannot be recovered by the server.",
|
||||||
"client_public_key": "Client public key:",
|
"client_public_key": "Client public key:",
|
||||||
@@ -411,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.",
|
||||||
|
|||||||
+28
-4
@@ -344,13 +344,17 @@
|
|||||||
"lang_zh": "中文 (Chinese)",
|
"lang_zh": "中文 (Chinese)",
|
||||||
"lang_fa": "فارسی (Persian)",
|
"lang_fa": "فارسی (Persian)",
|
||||||
"backup_title": "پشتیبانگیری ساده",
|
"backup_title": "پشتیبانگیری ساده",
|
||||||
|
"backup_export_label": "خروجی",
|
||||||
|
"backup_import_label": "واردات",
|
||||||
"download_backup": "دانلود dump PostgreSQL (.sql)",
|
"download_backup": "دانلود dump PostgreSQL (.sql)",
|
||||||
"download_backup_json": "خروجی JSON (قدیمی)",
|
"download_backup_json": "خروجی JSON (قدیمی)",
|
||||||
"backup_hint": "Dump کامل پایگاه داده PostgreSQL پنل. برای پشتیبان کامل از .sql استفاده کنید؛ JSON برای نسخههای قدیمی.",
|
"backup_hint": "Dump کامل پایگاه داده PostgreSQL پنل (.sql / .sql.gz). JSON برای نسخههای قدیمی.",
|
||||||
"restore_backup": "بازیابی از .sql یا .json",
|
"restore_backup": "بازیابی از .sql یا .json",
|
||||||
"restore_confirm": "بازیابی تمام دادههای فعلی پنل در پایگاه داده را بازنویسی میکند.",
|
"import_backup": "واردات پایگاه داده (.sql / .sql.gz / .json)",
|
||||||
"restore_success": "بازیابی موفقیتآمیز بود! در حال راهاندازی مجدد...",
|
"importing_backup": "در حال واردات پایگاه داده...",
|
||||||
"invalid_backup_file": "فایل نامعتبر (dump .sql یا data.json قدیمی)",
|
"restore_confirm": "واردات تمام دادههای فعلی پنل در پایگاه داده را بازنویسی میکند. ادامه؟",
|
||||||
|
"restore_success": "واردات موفقیتآمیز بود! در حال بارگذاری مجدد...",
|
||||||
|
"invalid_backup_file": "فایل نامعتبر (.sql، .sql.gz یا data.json قدیمی)",
|
||||||
"config_unavailable": "پیکربندی در دسترس نیست",
|
"config_unavailable": "پیکربندی در دسترس نیست",
|
||||||
"config_unavailable_desc": "این کلاینت از طریق اپلیکیشن اصلی Amnezia ایجاد شده است.\\nکلید خصوصی فقط در دستگاه کاربر ذخیره میشود و توسط سرور قابل بازیابی نیست.",
|
"config_unavailable_desc": "این کلاینت از طریق اپلیکیشن اصلی Amnezia ایجاد شده است.\\nکلید خصوصی فقط در دستگاه کاربر ذخیره میشود و توسط سرور قابل بازیابی نیست.",
|
||||||
"client_public_key": "کلید عمومی کلاینت:",
|
"client_public_key": "کلید عمومی کلاینت:",
|
||||||
@@ -384,11 +388,31 @@
|
|||||||
"naiveproxy_dns_hint": "قبل از نصب این رکورد DNS را بسازید:",
|
"naiveproxy_dns_hint": "قبل از نصب این رکورد DNS را بسازید:",
|
||||||
"naiveproxy_ports_warning": "هشدار: برای کار پایدار پورتهای TCP آزاد ۸۰ و ۴۴۳ لازم است (Let\u0027s Encrypt روی ۸۰، پروکسی HTTPS روی ۴۴۳).",
|
"naiveproxy_ports_warning": "هشدار: برای کار پایدار پورتهای TCP آزاد ۸۰ و ۴۴۳ لازم است (Let\u0027s Encrypt روی ۸۰، پروکسی HTTPS روی ۴۴۳).",
|
||||||
"naiveproxy_client_hint": "نسخه پایدار. از v2rayN استفاده نکنید. در Karing تأیید شده. سایر کلاینتها آزمایش نشدهاند.",
|
"naiveproxy_client_hint": "نسخه پایدار. از v2rayN استفاده نکنید. در Karing تأیید شده. سایر کلاینتها آزمایش نشدهاند.",
|
||||||
|
"mieru_desc": "Mieru (mita v3.28.0) — پروکسی TCP بومی enfein/mieru. بدون Docker. کلاینت: mieru یا Clash.Meta/mihomo.",
|
||||||
|
"mieru_version": "نسخه",
|
||||||
|
"mieru_port_hint": "پورت TCP برای mita (۱۰۲۵–۶۵۵۳۵). در فایروال سرور باز کنید.",
|
||||||
|
"mieru_install_hint": "بسته رسمی mita را از GitHub v3.28.0 نصب میکند (Debian/RPM). لینوکس با systemd لازم است.",
|
||||||
|
"mieru_ports_warning": "هشدار: پورت TCP انتخابی باید آزاد و در فایروال مجاز باشد.",
|
||||||
|
"mieru_client_hint": "لینکها با فرمت mierus://. در کلاینت mieru یا Clash.Meta (type: mieru) وارد کنید.",
|
||||||
"server_ssl_domain": "دامنه SSL (پیشفرض)",
|
"server_ssl_domain": "دامنه SSL (پیشفرض)",
|
||||||
"server_ssl_email": "ایمیل SSL (پیشفرض)",
|
"server_ssl_email": "ایمیل SSL (پیشفرض)",
|
||||||
"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": "زنده",
|
||||||
|
|||||||
+28
-4
@@ -344,13 +344,17 @@
|
|||||||
"lang_zh": "中文 (Chinese)",
|
"lang_zh": "中文 (Chinese)",
|
||||||
"lang_fa": "فارسی (Persian)",
|
"lang_fa": "فارسی (Persian)",
|
||||||
"backup_title": "Sauvegarde Simple",
|
"backup_title": "Sauvegarde Simple",
|
||||||
|
"backup_export_label": "Export",
|
||||||
|
"backup_import_label": "Import",
|
||||||
"download_backup": "Télécharger le dump PostgreSQL (.sql)",
|
"download_backup": "Télécharger le dump PostgreSQL (.sql)",
|
||||||
"download_backup_json": "Exporter JSON (ancien)",
|
"download_backup_json": "Exporter JSON (ancien)",
|
||||||
"backup_hint": "Dump complet de la base PostgreSQL du panneau. Utilisez .sql pour une sauvegarde complète ; JSON pour l'ancien format.",
|
"backup_hint": "Dump complet de la base PostgreSQL du panneau (.sql / .sql.gz). JSON pour l'ancien format.",
|
||||||
"restore_backup": "Restaurer depuis .sql ou .json",
|
"restore_backup": "Restaurer depuis .sql ou .json",
|
||||||
"restore_confirm": "La restauration écrasera toutes les données actuelles du panneau dans la base.",
|
"import_backup": "Importer la base (.sql / .sql.gz / .json)",
|
||||||
"restore_success": "Restauration réussie ! Redémarrage...",
|
"importing_backup": "Import de la base...",
|
||||||
"invalid_backup_file": "Fichier invalide (dump .sql ou ancien data.json)",
|
"restore_confirm": "L'import écrasera toutes les données actuelles du panneau dans la base. Continuer ?",
|
||||||
|
"restore_success": "Import réussi ! Rechargement...",
|
||||||
|
"invalid_backup_file": "Fichier invalide (.sql, .sql.gz ou ancien data.json)",
|
||||||
"config_unavailable": "Configuration indisponible",
|
"config_unavailable": "Configuration indisponible",
|
||||||
"config_unavailable_desc": "Ce client a été créé via l\u0027application native Amnezia.\\nLa clé privée est stockée uniquement sur l\u0027appareil de l\u0027utilisateur et ne peut pas être récupérée par le serveur.",
|
"config_unavailable_desc": "Ce client a été créé via l\u0027application native Amnezia.\\nLa clé privée est stockée uniquement sur l\u0027appareil de l\u0027utilisateur et ne peut pas être récupérée par le serveur.",
|
||||||
"client_public_key": "Clé publique du client :",
|
"client_public_key": "Clé publique du client :",
|
||||||
@@ -384,11 +388,31 @@
|
|||||||
"naiveproxy_dns_hint": "Créez cet enregistrement DNS avant l\u0027installation :",
|
"naiveproxy_dns_hint": "Créez cet enregistrement DNS avant l\u0027installation :",
|
||||||
"naiveproxy_ports_warning": "Attention : les ports TCP 80 et 443 doivent être libres pour un fonctionnement stable (Let\u0027s Encrypt sur 80, proxy HTTPS sur 443).",
|
"naiveproxy_ports_warning": "Attention : les ports TCP 80 et 443 doivent être libres pour un fonctionnement stable (Let\u0027s Encrypt sur 80, proxy HTTPS sur 443).",
|
||||||
"naiveproxy_client_hint": "Version stable. N\u0027utilisez PAS v2rayN. Fonctionne avec Karing. Autres clients non testés.",
|
"naiveproxy_client_hint": "Version stable. N\u0027utilisez PAS v2rayN. Fonctionne avec Karing. Autres clients non testés.",
|
||||||
|
"mieru_desc": "Mieru (mita v3.28.0) — proxy TCP natif enfein/mieru. Pas besoin de Docker. Client : mieru ou Clash.Meta/mihomo.",
|
||||||
|
"mieru_version": "Version",
|
||||||
|
"mieru_port_hint": "Port TCP pour mita (1025–65535). Ouvrez-le dans le pare-feu du serveur.",
|
||||||
|
"mieru_install_hint": "Installe le paquet officiel mita depuis GitHub v3.28.0 (Debian/RPM). Linux avec systemd requis.",
|
||||||
|
"mieru_ports_warning": "Attention : le port TCP choisi doit être libre et autorisé dans le pare-feu.",
|
||||||
|
"mieru_client_hint": "Liens au format mierus://. Importez dans le client mieru ou Clash.Meta (type: mieru).",
|
||||||
"server_ssl_domain": "Domaine SSL (par défaut)",
|
"server_ssl_domain": "Domaine SSL (par défaut)",
|
||||||
"server_ssl_email": "Email SSL (par défaut)",
|
"server_ssl_email": "Email SSL (par défaut)",
|
||||||
"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",
|
||||||
|
|||||||
+22
-4
@@ -362,13 +362,17 @@
|
|||||||
"lang_zh": "中文 (Chinese)",
|
"lang_zh": "中文 (Chinese)",
|
||||||
"lang_fa": "فارسی (Persian)",
|
"lang_fa": "فارسی (Persian)",
|
||||||
"backup_title": "Резервное копирование",
|
"backup_title": "Резервное копирование",
|
||||||
|
"backup_export_label": "Экспорт",
|
||||||
|
"backup_import_label": "Импорт",
|
||||||
"download_backup": "Скачать дамп PostgreSQL (.sql)",
|
"download_backup": "Скачать дамп PostgreSQL (.sql)",
|
||||||
"download_backup_json": "Экспорт JSON (устар.)",
|
"download_backup_json": "Экспорт JSON (устар.)",
|
||||||
"backup_hint": "Полный дамп базы данных панели. Для бэкапа используйте .sql; JSON — для совместимости со старыми версиями.",
|
"backup_hint": "Полный дамп базы данных панели (.sql / .sql.gz). JSON — для совместимости со старыми версиями.",
|
||||||
"restore_backup": "Восстановить из .sql или .json",
|
"restore_backup": "Восстановить из .sql или .json",
|
||||||
"restore_confirm": "Восстановление перезапишет все текущие данные панели в базе.",
|
"import_backup": "Импортировать базу (.sql / .sql.gz / .json)",
|
||||||
"restore_success": "Восстановление успешно! Перезагрузка...",
|
"importing_backup": "Импорт базы...",
|
||||||
"invalid_backup_file": "Неверный файл (.sql дамп или устаревший data.json)",
|
"restore_confirm": "Импорт перезапишет все текущие данные панели в базе. Продолжить?",
|
||||||
|
"restore_success": "Импорт успешно завершён! Перезагрузка...",
|
||||||
|
"invalid_backup_file": "Неверный файл (.sql, .sql.gz или устаревший data.json)",
|
||||||
"config_unavailable": "Конфигурация недоступна",
|
"config_unavailable": "Конфигурация недоступна",
|
||||||
"config_unavailable_desc": "Этот клиент был создан через нативное приложение Amnezia.\\nПриватный ключ хранится только на устройстве пользователя и не может быть восстановлен сервером.",
|
"config_unavailable_desc": "Этот клиент был создан через нативное приложение Amnezia.\\nПриватный ключ хранится только на устройстве пользователя и не может быть восстановлен сервером.",
|
||||||
"client_public_key": "Публичный ключ клиента:",
|
"client_public_key": "Публичный ключ клиента:",
|
||||||
@@ -411,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": "Если панель вам помогает, можно поддержать разработку. Выберите удобный способ ниже.",
|
||||||
|
|||||||
+28
-4
@@ -344,13 +344,17 @@
|
|||||||
"lang_zh": "中文 (Chinese)",
|
"lang_zh": "中文 (Chinese)",
|
||||||
"lang_fa": "فارسی (Persian)",
|
"lang_fa": "فارسی (Persian)",
|
||||||
"backup_title": "简易备份",
|
"backup_title": "简易备份",
|
||||||
|
"backup_export_label": "导出",
|
||||||
|
"backup_import_label": "导入",
|
||||||
"download_backup": "下载 PostgreSQL 转储 (.sql)",
|
"download_backup": "下载 PostgreSQL 转储 (.sql)",
|
||||||
"download_backup_json": "导出 JSON(旧版)",
|
"download_backup_json": "导出 JSON(旧版)",
|
||||||
"backup_hint": "面板 PostgreSQL 数据库的完整转储。请使用 .sql 进行完整备份;JSON 用于兼容旧版本。",
|
"backup_hint": "面板 PostgreSQL 数据库的完整转储(.sql / .sql.gz)。JSON 用于兼容旧版本。",
|
||||||
"restore_backup": "从 .sql 或 .json 恢复",
|
"restore_backup": "从 .sql 或 .json 恢复",
|
||||||
"restore_confirm": "恢复将覆盖数据库中所有当前面板数据。",
|
"import_backup": "导入数据库(.sql / .sql.gz / .json)",
|
||||||
"restore_success": "恢复成功!正在重启...",
|
"importing_backup": "正在导入数据库...",
|
||||||
"invalid_backup_file": "无效的备份文件(.sql 转储或旧版 data.json)",
|
"restore_confirm": "导入将覆盖数据库中所有当前面板数据。继续?",
|
||||||
|
"restore_success": "导入成功!正在重新加载...",
|
||||||
|
"invalid_backup_file": "无效的备份文件(.sql、.sql.gz 或旧版 data.json)",
|
||||||
"config_unavailable": "配置文件不可用",
|
"config_unavailable": "配置文件不可用",
|
||||||
"config_unavailable_desc": "此客户端是通过 Amnezia 原生应用创建的。\\n私钥仅存储在用户设备上,服务器无法恢复。",
|
"config_unavailable_desc": "此客户端是通过 Amnezia 原生应用创建的。\\n私钥仅存储在用户设备上,服务器无法恢复。",
|
||||||
"client_public_key": "客户端公钥:",
|
"client_public_key": "客户端公钥:",
|
||||||
@@ -384,11 +388,31 @@
|
|||||||
"naiveproxy_dns_hint": "安装前请创建此 DNS 记录:",
|
"naiveproxy_dns_hint": "安装前请创建此 DNS 记录:",
|
||||||
"naiveproxy_ports_warning": "注意:稳定运行需要空闲的 TCP 端口 80 和 443(Let\u0027s Encrypt 使用 80,HTTPS 代理使用 443)。",
|
"naiveproxy_ports_warning": "注意:稳定运行需要空闲的 TCP 端口 80 和 443(Let\u0027s Encrypt 使用 80,HTTPS 代理使用 443)。",
|
||||||
"naiveproxy_client_hint": "稳定版。请勿使用 v2rayN。已确认 Karing 可用。其他客户端未测试。",
|
"naiveproxy_client_hint": "稳定版。请勿使用 v2rayN。已确认 Karing 可用。其他客户端未测试。",
|
||||||
|
"mieru_desc": "Mieru(mita v3.28.0)— enfein/mieru 原生 TCP 代理,无需 Docker。客户端:mieru 或 Clash.Meta/mihomo。",
|
||||||
|
"mieru_version": "版本",
|
||||||
|
"mieru_port_hint": "mita 的 TCP 端口(1025–65535)。请在服务器防火墙中放行。",
|
||||||
|
"mieru_install_hint": "从 GitHub v3.28.0 安装官方 mita 包(Debian/RPM)。需要带 systemd 的 Linux。",
|
||||||
|
"mieru_ports_warning": "注意:所选 TCP 端口必须空闲,并在防火墙中允许。",
|
||||||
|
"mieru_client_hint": "分享链接为 mierus:// 格式。可导入 mieru 客户端或 Clash.Meta(type: mieru)。",
|
||||||
"server_ssl_domain": "SSL 域名(默认)",
|
"server_ssl_domain": "SSL 域名(默认)",
|
||||||
"server_ssl_email": "SSL 邮箱(默认)",
|
"server_ssl_email": "SSL 邮箱(默认)",
|
||||||
"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": "实时",
|
||||||
|
|||||||
Reference in New Issue
Block a user