Compare commits

...
14 changed files with 1422 additions and 140 deletions
+7 -1
View File
@@ -277,6 +277,9 @@ GitHub Actions workflows in `.github/workflows/`:
## 📋 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
* **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. |
| **Self-service** | Endpoints called by a regular user for their own data (`/api/my/*`). |
| **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. |
**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
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
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"host":"1.2.3.4","username":"root","password":"...","name":"new-srv"}' \
+204 -12
View File
@@ -23,7 +23,7 @@ import time
import urllib.request
import zipfile
import signal
from datetime import datetime
from datetime import datetime, timezone
import io
from fastapi.responses import JSONResponse, RedirectResponse, HTMLResponse, StreamingResponse, FileResponse
from starlette.background import BackgroundTask
@@ -103,7 +103,7 @@ else:
application_path = os.path.dirname(__file__)
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_API_LATEST = api_latest_url()
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
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:
salt = secrets.token_hex(16)
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)
if resolved:
entry, token_user = resolved
# Best-effort last-used tracking; swallow write errors so a flaky
# disk never blocks an API call from succeeding.
try:
if _touch_api_token(entry):
save_data(data)
except Exception as e:
logger.warning(f"Failed to touch API token last_used_at: {e}")
if _touch_api_token(entry):
token_id = entry.get('id')
if token_id:
_schedule_api_token_touch(token_id)
return token_user
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"])
async def api_add_server(request: Request, req: AddServerRequest):
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
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)
data = load_data()
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]
@@ -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)
@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"])
async def api_protocol_export_clients(request: Request, server_id: int, req: ProtocolRequest):
"""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/import', tags=["Settings"])
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):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
try:
@@ -6860,7 +7048,11 @@ async def api_backup_restore(request: Request, file: UploadFile = File(...)):
return JSONResponse({'error': 'Empty file'}, status_code=400)
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:
try:
+48 -6
View File
@@ -2,28 +2,52 @@
from __future__ import annotations
import gzip
import logging
import os
import subprocess
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
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:
stamp = datetime.now(timezone.utc).strftime('%Y-%m-%d_%H%M%S')
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:
"""Create a plain SQL dump of the panel PostgreSQL database."""
url = get_database_url()
params = get_pg_connection_params()
proc = subprocess.run(
[
'pg_dump',
'--dbname', url,
'-h', params['host'],
'-p', params['port'],
'-U', params['user'],
'-d', params['dbname'],
'--no-owner',
'--no-acl',
'--clean',
@@ -31,6 +55,7 @@ def export_database_sql() -> bytes:
],
capture_output=True,
check=False,
env=_pg_cli_env(params['password']),
)
if proc.returncode != 0:
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:
"""Restore panel data from a plain SQL dump produced by pg_dump."""
data = _decode_backup_bytes(data)
if not data or not data.strip():
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(
['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,
capture_output=True,
check=False,
env=_pg_cli_env(params['password']),
)
invalidate_data_cache()
if proc.returncode != 0:
err = proc.stderr.decode('utf-8', errors='replace').strip()
out = proc.stdout.decode('utf-8', errors='replace').strip()
raise RuntimeError(err or out or 'psql restore failed')
invalidate_data_cache()
logger.info('PostgreSQL backup restored successfully')
+31
View File
@@ -24,6 +24,37 @@ def get_database_url() -> str:
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():
global _pool
if _pool is not None:
+4
View File
@@ -33,6 +33,10 @@ services:
- "${APP_PORT:-5000}:5000"
environment:
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:-}
APP_PORT: "5000"
PORT: "5000"
+340 -55
View File
@@ -14,12 +14,21 @@ import re
import secrets
import shlex
import string
import time
from urllib.parse import quote
logger = logging.getLogger(__name__)
MIERU_RELEASE = '3.28.0'
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):
@@ -144,7 +153,8 @@ class MieruManager:
b64 = base64.b64encode((content or '').encode('utf-8')).decode('ascii')
script = (
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)
if code != 0:
@@ -176,14 +186,191 @@ class MieruManager:
def _write_clients(self, clients):
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):
clients = self._ensure_bootstrap_clients(clients)
users = []
for c in clients:
if c.get('enabled', True):
users.append({
'name': c.get('username') or c.get('name') or c.get('id'),
'password': c.get('password') or '',
})
if not c.get('enabled', True):
continue
username = (c.get('username') or c.get('name') or c.get('id') or '').strip()
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 {
'portBindings': [{'port': int(port), 'protocol': 'TCP'}],
'users': users,
@@ -192,25 +379,107 @@ class MieruManager:
}
def _apply_config(self, config, reload_only=False):
self._ensure_daemon()
self._write_file(self.config_path, json.dumps(config, indent=2))
out, err, code = self.ssh.run_sudo_command(
f"mita apply config {_q(self.config_path)} 2>&1",
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())
# 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:
self.ssh.run_sudo_command('mita reload 2>/dev/null || true', timeout=30)
else:
self.ssh.run_sudo_command('mita stop 2>/dev/null || true', timeout=30)
out2, err2, code2 = self.ssh.run_sudo_command('mita start 2>&1', timeout=60)
if code2 != 0:
raise RuntimeError((err2 or out2 or 'mita start failed').strip())
# users/loggingLevel can hot-reload; fall back to full restart.
reload_out, reload_err, reload_code = self._mita_cli(['reload'], timeout=30)
if reload_code == 0:
return
logger.info('mita reload failed, falling back to stop/start: %s',
(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):
meta = self._read_metadata()
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)
self._apply_config(config, reload_only=reload_only)
@@ -261,10 +530,7 @@ fi
if code != 0:
raise RuntimeError(f'Failed to install mita package: {err or out}')
log.append('Installed mita package')
self.ssh.run_sudo_command(
f"systemctl enable --now {self.SERVICE_NAME} 2>/dev/null || true",
timeout=30,
)
self._ensure_daemon(log)
def _build_share_uri(self, host, port, username, password, name=''):
user = quote(username or '', safe='')
@@ -305,42 +571,43 @@ fi
return {'status': 'error', 'message': 'Port must be between 1025 and 65535'}
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:
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._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:
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):
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)}")
return True
def start_service(self):
out, err, code = self.ssh.run_sudo_command('mita start 2>&1', timeout=60)
if code != 0:
raise RuntimeError((err or out or 'mita start failed').strip())
self._ensure_daemon()
# Re-apply panel clients (with bootstrap) then start — fixes empty-users store.
self._sync_server(reload_only=False)
def stop_service(self):
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)
def save_server_config(self, protocol_type=None, config_text=''):
self._write_file(self.config_path, config_text or '')
out, err, code = self.ssh.run_sudo_command(
f"mita apply config {_q(self.config_path)} 2>&1",
timeout=60,
)
if code != 0:
raise RuntimeError((err or out or 'mita apply config failed').strip())
self.ssh.run_sudo_command('mita stop 2>/dev/null || true', timeout=30)
self.ssh.run_sudo_command('mita start 2>&1', timeout=60)
raw = (config_text or '').strip()
if not raw:
raise RuntimeError('Config is empty')
try:
parsed = json.loads(raw)
except Exception as e:
raise RuntimeError(f'Invalid JSON config: {e}') from e
if not isinstance(parsed, dict):
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
# ===================== CLIENTS =====================
@@ -369,6 +650,8 @@ fi
clients = self._read_clients()
result = []
for c in clients:
if c.get('bootstrap'):
continue
cname = c.get('name') or c.get('id')
result.append({
'clientId': c.get('id'),
@@ -413,7 +696,7 @@ fi
port = int(port or meta.get('port') or self.DEFAULT_PORT)
clients = self._read_clients()
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 ''
if not client.get('enabled', True):
return ''
@@ -424,6 +707,7 @@ fi
def remove_client(self, protocol_type, 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._sync_server(reload_only=True)
return True
@@ -433,6 +717,7 @@ fi
for c in clients:
if c.get('id') == client_id:
c['enabled'] = bool(enabled)
clients = self._ensure_bootstrap_clients(clients)
self._write_clients(clients)
self._sync_server(reload_only=True)
return True
+489
View File
@@ -0,0 +1,489 @@
"""Export/import server user data + protocol state for domain-preserving migration.
Use case: new VPS IP, same connect_domain. Users keep existing VPN configs if:
1. Protocol crypto state is restored on the new host
2. Panel user_connections keep the same client_id values
3. DNS A-record for connect_domain points to the new IP
"""
from __future__ import annotations
import io
import json
import logging
import secrets
import shlex
import uuid
import zipfile
from datetime import datetime, timezone
from typing import Any, Optional
from managers.backup_manager import BackupManager
logger = logging.getLogger(__name__)
MIGRATE_FORMAT = 'amnezia-web-panel-migrate-v1'
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _safe_user_public(user: dict) -> dict:
"""User row suitable for re-import (keeps password_hash / share tokens)."""
return {
'id': user.get('id'),
'username': user.get('username'),
'password_hash': user.get('password_hash') or '',
'role': user.get('role') or 'user',
'enabled': bool(user.get('enabled', True)),
'created_at': user.get('created_at'),
'telegramId': user.get('telegramId'),
'email': user.get('email'),
'description': user.get('description'),
'traffic_limit': user.get('traffic_limit', 0),
'traffic_used': user.get('traffic_used', 0),
'traffic_total': user.get('traffic_total', 0),
'traffic_reset_strategy': user.get('traffic_reset_strategy', 'never'),
'last_reset_at': user.get('last_reset_at'),
'expiration_date': user.get('expiration_date'),
'expire_after_first_use': bool(user.get('expire_after_first_use')),
'expiration_days': int(user.get('expiration_days') or 0),
'remnawave_uuid': user.get('remnawave_uuid'),
'xui_email': user.get('xui_email'),
'share_enabled': bool(user.get('share_enabled')),
'share_token': user.get('share_token'),
'share_password_hash': user.get('share_password_hash'),
}
def _server_public_slice(server: dict, server_id: int) -> dict:
info = dict(server.get('server_info') or {})
protocols = {}
for key, val in (server.get('protocols') or {}).items():
if not isinstance(val, dict):
continue
protocols[key] = {
'installed': bool(val.get('installed')),
'port': val.get('port'),
'connect_domain': val.get('connect_domain') or '',
'container_name': val.get('container_name') or '',
}
return {
'old_server_id': server_id,
'name': server.get('name') or '',
'host': server.get('host') or '',
'ssh_port': int(server.get('ssh_port') or 22),
'connect_domain': (info.get('connect_domain') or '').strip(),
'ssl_domain': (info.get('ssl_domain') or '').strip(),
'ssl_email': (info.get('ssl_email') or '').strip(),
'protocols': protocols,
}
def build_panel_payload(data: dict, server_id: int) -> dict:
servers = data.get('servers') or []
if server_id < 0 or server_id >= len(servers):
raise ValueError('Server not found')
server = servers[server_id]
conns = [
dict(c) for c in (data.get('user_connections') or [])
if isinstance(c, dict) and int(c.get('server_id', -1)) == server_id
]
user_ids = {c.get('user_id') for c in conns if c.get('user_id')}
users = [
_safe_user_public(u) for u in (data.get('users') or [])
if isinstance(u, dict) and u.get('id') in user_ids
]
invites = [
dict(inv) for inv in (data.get('invite_links') or [])
if isinstance(inv, dict) and int(inv.get('server_id', -1)) == server_id
]
return {
'format': MIGRATE_FORMAT,
'exported_at': _now_iso(),
'server': _server_public_slice(server, server_id),
'users': users,
'user_connections': conns,
'invite_links': invites,
}
def _upload_bytes_sudo(ssh, content: bytes, remote_path: str) -> None:
tmp = f'/tmp/_amnz_mig_{secrets.token_hex(6)}'
sftp = ssh.client.open_sftp()
try:
with sftp.file(tmp, 'wb') as f:
f.write(content)
finally:
sftp.close()
parent = remote_path.rsplit('/', 1)[0]
ssh.run_sudo_command(
f"mkdir -p {shlex.quote(parent)} && "
f"mv {shlex.quote(tmp)} {shlex.quote(remote_path)} && "
f"chmod 0644 {shlex.quote(remote_path)}"
)
def _download_bytes(ssh, remote_path: str) -> bytes:
tmp = f'/tmp/_amnz_dl_{secrets.token_hex(6)}'
quoted_remote = shlex.quote(remote_path)
quoted_tmp = shlex.quote(tmp)
_, err, code = ssh.run_sudo_command(
f"test -f {quoted_remote} && cp {quoted_remote} {quoted_tmp} && chmod 0644 {quoted_tmp}"
)
if code != 0:
raise RuntimeError(err or f'Failed to stage {remote_path}')
sftp = ssh.client.open_sftp()
try:
buf = io.BytesIO()
with sftp.file(tmp, 'rb') as f:
buf.write(f.read())
return buf.getvalue()
finally:
sftp.close()
ssh.run_sudo_command(f'rm -f {quoted_tmp}')
def export_migrate_zip(
ssh,
data: dict,
server_id: int,
*,
include_protocol_backups: bool = True,
protocol_container_name_fn=None,
) -> tuple[bytes, dict]:
"""Build a migrate ZIP. Returns (zip_bytes, summary)."""
payload = build_panel_payload(data, server_id)
server = data['servers'][server_id]
protocols = server.get('protocols') or {}
installed = [
p for p, info in protocols.items()
if isinstance(info, dict) and info.get('installed')
]
buf = io.BytesIO()
summary = {
'users': len(payload['users']),
'connections': len(payload['user_connections']),
'invites': len(payload['invite_links']),
'protocols': [],
'protocol_errors': [],
'connect_domain': payload['server'].get('connect_domain') or '',
}
with zipfile.ZipFile(buf, 'w', compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr('panel.json', json.dumps(payload, indent=2, ensure_ascii=False))
protocol_files = []
if include_protocol_backups and installed and ssh is not None:
bm = BackupManager(ssh)
for proto in installed:
try:
container = ''
if protocol_container_name_fn:
container = protocol_container_name_fn(proto) or ''
info = protocols.get(proto) or {}
container = info.get('container_name') or container or ''
created = bm.create_backup(proto, container)
if created.get('status') != 'success':
summary['protocol_errors'].append({
'protocol': proto,
'error': created.get('message') or 'create backup failed',
})
continue
name = (created.get('backup') or {}).get('name')
path = (created.get('backup') or {}).get('path')
if not name or not path:
summary['protocol_errors'].append({
'protocol': proto,
'error': 'backup path missing',
})
continue
blob = _download_bytes(ssh, path)
arcname = f'protocols/{name}'
zf.writestr(arcname, blob)
protocol_files.append({
'protocol': proto,
'filename': name,
'archive': arcname,
'container': container,
})
summary['protocols'].append(proto)
except Exception as e:
logger.exception('Protocol backup failed for %s', proto)
summary['protocol_errors'].append({
'protocol': proto,
'error': str(e),
})
manifest = {
'format': MIGRATE_FORMAT,
'exported_at': payload['exported_at'],
'server': payload['server'],
'protocol_backups': protocol_files,
'counts': {
'users': summary['users'],
'connections': summary['connections'],
'protocol_backups': len(protocol_files),
},
'notes': [
'Point DNS A-record for connect_domain to the new server IP.',
'Import onto the new server after installing the same protocols.',
'Do not use Move Connections — it regenerates client keys.',
],
}
zf.writestr('manifest.json', json.dumps(manifest, indent=2, ensure_ascii=False))
return buf.getvalue(), summary
def _merge_users(data: dict, imported_users: list) -> dict[str, str]:
"""Merge users into panel data. Returns map old_user_id -> effective_user_id."""
id_map: dict[str, str] = {}
existing_by_id = {str(u.get('id')): u for u in data.get('users') or [] if u.get('id')}
existing_by_name = {
str(u.get('username') or '').lower(): u
for u in data.get('users') or []
if u.get('username')
}
for raw in imported_users:
if not isinstance(raw, dict):
continue
old_id = str(raw.get('id') or '')
username = (raw.get('username') or '').strip()
if not old_id and not username:
continue
if old_id and old_id in existing_by_id:
id_map[old_id] = old_id
continue
by_name = existing_by_name.get(username.lower()) if username else None
if by_name:
id_map[old_id] = str(by_name['id'])
continue
new_user = _safe_user_public(raw)
if not new_user.get('id'):
new_user['id'] = str(uuid.uuid4())
# Avoid unique username collisions with empty/duplicate names.
if not username:
username = f'user_{str(new_user["id"])[:8]}'
new_user['username'] = username
base = username
n = 2
while username.lower() in existing_by_name:
username = f'{base}_{n}'
n += 1
new_user['username'] = username
if new_user.get('role') == 'admin':
# Never import an extra admin silently — demote to user.
new_user['role'] = 'user'
data.setdefault('users', []).append(new_user)
existing_by_id[str(new_user['id'])] = new_user
existing_by_name[username.lower()] = new_user
if old_id:
id_map[old_id] = str(new_user['id'])
id_map[str(new_user['id'])] = str(new_user['id'])
return id_map
def _merge_connections(
data: dict,
imported_conns: list,
*,
target_server_id: int,
user_id_map: dict[str, str],
) -> dict:
existing = data.setdefault('user_connections', [])
existing_keys = {
(
str(c.get('user_id')),
str(c.get('client_id')),
str(c.get('protocol')),
int(c.get('server_id', -1)),
)
for c in existing
if isinstance(c, dict)
}
added = 0
skipped = 0
for raw in imported_conns:
if not isinstance(raw, dict):
continue
old_user = str(raw.get('user_id') or '')
user_id = user_id_map.get(old_user, old_user)
client_id = raw.get('client_id')
protocol = raw.get('protocol')
if not user_id or not client_id or not protocol:
skipped += 1
continue
key = (str(user_id), str(client_id), str(protocol), int(target_server_id))
if key in existing_keys:
skipped += 1
continue
conn = {
'id': raw.get('id') or str(uuid.uuid4()),
'user_id': user_id,
'server_id': target_server_id,
'protocol': protocol,
'client_id': client_id,
'name': raw.get('name') or '',
'xui_panel_id': raw.get('xui_panel_id') or '',
'created_at': raw.get('created_at') or _now_iso(),
'last_bytes': raw.get('last_bytes') or 0,
}
# Avoid id collisions
if any(c.get('id') == conn['id'] for c in existing):
conn['id'] = str(uuid.uuid4())
existing.append(conn)
existing_keys.add(key)
added += 1
return {'added': added, 'skipped': skipped}
def _apply_server_meta(target: dict, exported_server: dict) -> None:
"""Preserve connect_domain / protocol ports on target; never overwrite SSH host."""
info = dict(target.get('server_info') or {})
domain = (exported_server.get('connect_domain') or '').strip()
if domain and not (info.get('connect_domain') or '').strip():
info['connect_domain'] = domain
for key in ('ssl_domain', 'ssl_email'):
val = (exported_server.get(key) or '').strip()
if val and not (info.get(key) or '').strip():
info[key] = val
target['server_info'] = info
protocols = dict(target.get('protocols') or {})
for proto, meta in (exported_server.get('protocols') or {}).items():
if not isinstance(meta, dict):
continue
cur = dict(protocols.get(proto) or {})
if meta.get('port') and not cur.get('port'):
cur['port'] = meta.get('port')
if meta.get('connect_domain') and not cur.get('connect_domain'):
cur['connect_domain'] = meta.get('connect_domain')
if meta.get('installed'):
cur['installed'] = True
if meta.get('container_name') and not cur.get('container_name'):
cur['container_name'] = meta.get('container_name')
protocols[proto] = cur
target['protocols'] = protocols
def import_migrate_zip(
ssh,
data: dict,
target_server_id: int,
zip_bytes: bytes,
*,
restore_protocols: bool = True,
protocol_container_name_fn=None,
) -> dict:
if target_server_id < 0 or target_server_id >= len(data.get('servers') or []):
raise ValueError('Target server not found')
with zipfile.ZipFile(io.BytesIO(zip_bytes), 'r') as zf:
names = set(zf.namelist())
if 'panel.json' not in names:
raise ValueError('Invalid migrate archive: missing panel.json')
panel = json.loads(zf.read('panel.json').decode('utf-8'))
if panel.get('format') != MIGRATE_FORMAT:
raise ValueError(f"Unsupported migrate format: {panel.get('format')}")
manifest = {}
if 'manifest.json' in names:
try:
manifest = json.loads(zf.read('manifest.json').decode('utf-8'))
except Exception:
manifest = {}
user_map = _merge_users(data, panel.get('users') or [])
conn_stats = _merge_connections(
data,
panel.get('user_connections') or [],
target_server_id=target_server_id,
user_id_map=user_map,
)
_apply_server_meta(data['servers'][target_server_id], panel.get('server') or {})
# Optional invite links (remap server_id)
invites_added = 0
for inv in panel.get('invite_links') or []:
if not isinstance(inv, dict):
continue
token = inv.get('token')
if not token:
continue
existing_tokens = {i.get('token') for i in data.get('invite_links') or []}
if token in existing_tokens:
continue
item = dict(inv)
item['server_id'] = target_server_id
if not item.get('id'):
item['id'] = str(uuid.uuid4())
data.setdefault('invite_links', []).append(item)
invites_added += 1
restored = []
restore_errors = []
if restore_protocols and ssh is not None:
bm = BackupManager(ssh)
backups = manifest.get('protocol_backups') or []
# Fallback: scan protocols/ folder
if not backups:
for name in names:
if name.startswith('protocols/') and name.endswith('.tar.gz'):
backups.append({
'protocol': name.rsplit('/', 1)[-1].split('-', 1)[0],
'filename': name.rsplit('/', 1)[-1],
'archive': name,
})
target = data['servers'][target_server_id]
protocols = target.get('protocols') or {}
for item in backups:
proto = item.get('protocol')
filename = item.get('filename')
archive = item.get('archive') or f'protocols/{filename}'
if not proto or not filename or archive not in names:
continue
try:
blob = zf.read(archive)
remote = f'{BackupManager.BACKUP_ROOT}/{bm.safe_protocol(proto)}/{bm.safe_filename(filename) or filename}'
_upload_bytes_sudo(ssh, blob, remote)
container = ''
if protocol_container_name_fn:
container = protocol_container_name_fn(proto) or ''
info = protocols.get(proto) or {}
container = info.get('container_name') or item.get('container') or container or ''
result = bm.restore_backup(proto, container, filename)
if result.get('status') == 'success':
restored.append(proto)
# Mark installed in panel metadata
pinfo = dict(protocols.get(proto) or {})
pinfo['installed'] = True
if container:
pinfo['container_name'] = container
protocols[proto] = pinfo
else:
restore_errors.append({
'protocol': proto,
'error': result.get('message') or 'restore failed',
})
except Exception as e:
logger.exception('Failed restoring protocol %s', proto)
restore_errors.append({'protocol': proto, 'error': str(e)})
target['protocols'] = protocols
return {
'users_mapped': len(user_map),
'connections': conn_stats,
'invites_added': invites_added,
'protocols_restored': restored,
'protocol_errors': restore_errors,
'connect_domain': (panel.get('server') or {}).get('connect_domain') or '',
'hint': (
'Update DNS A-record for connect_domain to this server IP. '
'Existing client configs keep working if protocol state was restored.'
),
}
+136 -26
View File
@@ -314,6 +314,31 @@
</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 -->
<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);">
@@ -363,31 +388,6 @@
</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 -->
<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);">
@@ -647,6 +647,40 @@
</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 ===== -->
<div class="modal-backdrop" id="installModal">
<div class="modal">
@@ -1199,9 +1233,9 @@
{ 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: '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: '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: 'dns', category: 'services', icon: 'search', title: 'AmneziaDNS', descKey: 'dns_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;">
<span style="font-size: 1.2rem; margin-right: 8px; display:inline-flex;">${uiIcon('refresh')}</span> ${_('check_server_services')}
</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;">
<span style="font-size: 1.2rem; margin-right: 8px; display:inline-flex;">${uiIcon('refresh')}</span> ${_('reboot_server')}
</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() {
renderManagementModal();
openModal('managementModal');
+35 -20
View File
@@ -600,29 +600,34 @@
</div>
</div>
<!-- BLOCK: Simple Backup -->
<!-- BLOCK: Backup / Import -->
<div class="card" style="margin-top: var(--space-lg);">
<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; gap: var(--space-sm); flex-wrap: wrap;">
<a href="/api/settings/backup/download" class="btn btn-secondary"
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') }}
</a>
<a href="/api/settings/backup/download/json" class="btn btn-secondary"
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 class="form-label" style="margin-bottom: var(--space-sm);">{{ _('backup_export_label') }}</div>
<div style="display: flex; gap: var(--space-sm); flex-wrap: wrap;">
<a href="/api/settings/backup/download" class="btn btn-secondary"
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') }}
</a>
<a href="/api/settings/backup/download/json" class="btn btn-secondary"
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 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);">
<input type="file" id="backupFile" accept=".sql,.json" style="display: none;"
onchange="handleRestore(event)">
<button type="button" class="btn btn-secondary"
<input type="file" id="backupFile" accept=".sql,.sql.gz,.json,application/sql,application/gzip,application/json"
style="display: none;" onchange="handleRestore(event)">
<button type="button" class="btn btn-primary"
onclick="document.getElementById('backupFile').click()" id="restoreBtn"
style="gap:var(--space-sm);">
<span>⬆️</span> {{ _('restore_backup') }}
<span>⬆️</span> {{ _('import_backup') }}
</button>
<div class="form-hint" id="backupFileName"></div>
</div>
</div>
</div>
@@ -1667,30 +1672,40 @@
const file = e.target.files[0];
if (!file) return;
const nameEl = document.getElementById('backupFileName');
if (nameEl) nameEl.textContent = file.name;
if (!confirm(_('restore_confirm'))) {
e.target.value = '';
if (nameEl) nameEl.textContent = '';
return;
}
const btn = document.getElementById('restoreBtn');
btn.disabled = true;
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();
formData.append('file', file);
try {
const res = await fetch('/api/settings/backup/restore', {
const res = await fetch('/api/settings/backup/import', {
method: 'POST',
body: formData
body: formData,
credentials: 'same-origin',
});
const data = await res.json();
if (data.status === 'success') {
let data = {};
try {
data = await res.json();
} catch (_) {
data = {};
}
if (res.ok && data.status === 'success') {
showToast(_('restore_success'), 'success');
setTimeout(() => window.location.reload(), 2000);
} else {
showToast(data.error || _('invalid_backup_file'), 'error');
showToast(data.error || _('invalid_backup_file') + ` (HTTP ${res.status})`, 'error');
}
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
+22 -4
View File
@@ -362,13 +362,17 @@
"lang_zh": "中文 (Chinese)",
"lang_fa": "فارسی (Persian)",
"backup_title": "Simple Backup",
"backup_export_label": "Export",
"backup_import_label": "Import",
"download_backup": "Download PostgreSQL dump (.sql)",
"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_confirm": "Restore will overwrite all current panel data in the database.",
"restore_success": "Restore successful! Restarting...",
"invalid_backup_file": "Invalid backup file (.sql dump or legacy data.json)",
"import_backup": "Import database (.sql / .sql.gz / .json)",
"importing_backup": "Importing database...",
"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_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:",
@@ -411,6 +415,20 @@
"server_connect_domain_current": "Endpoint in configs",
"server_connect_domain_ssh": "SSH",
"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",
"support_title": "Support the project",
"support_intro": "If this panel helps you, you can support development. Choose a convenient method below.",
+28 -4
View File
@@ -344,13 +344,17 @@
"lang_zh": "中文 (Chinese)",
"lang_fa": "فارسی (Persian)",
"backup_title": "پشتیبان‌گیری ساده",
"backup_export_label": "خروجی",
"backup_import_label": "واردات",
"download_backup": "دانلود dump PostgreSQL (.sql)",
"download_backup_json": "خروجی JSON (قدیمی)",
"backup_hint": "Dump کامل پایگاه داده PostgreSQL پنل. برای پشتیبان کامل از .sql استفاده کنید؛ JSON برای نسخه‌های قدیمی.",
"backup_hint": "Dump کامل پایگاه داده PostgreSQL پنل (.sql / .sql.gz). JSON برای نسخه‌های قدیمی.",
"restore_backup": "بازیابی از .sql یا .json",
"restore_confirm": "بازیابی تمام داده‌های فعلی پنل در پایگاه داده را بازنویسی می‌کند.",
"restore_success": "بازیابی موفقیت‌آمیز بود! در حال راه‌اندازی مجدد...",
"invalid_backup_file": "فایل نامعتبر (dump .sql یا data.json قدیمی)",
"import_backup": "واردات پایگاه داده (.sql / .sql.gz / .json)",
"importing_backup": "در حال واردات پایگاه داده...",
"restore_confirm": "واردات تمام داده‌های فعلی پنل در پایگاه داده را بازنویسی می‌کند. ادامه؟",
"restore_success": "واردات موفقیت‌آمیز بود! در حال بارگذاری مجدد...",
"invalid_backup_file": "فایل نامعتبر (.sql، .sql.gz یا data.json قدیمی)",
"config_unavailable": "پیکربندی در دسترس نیست",
"config_unavailable_desc": "این کلاینت از طریق اپلیکیشن اصلی Amnezia ایجاد شده است.\\nکلید خصوصی فقط در دستگاه کاربر ذخیره می‌شود و توسط سرور قابل بازیابی نیست.",
"client_public_key": "کلید عمومی کلاینت:",
@@ -384,11 +388,31 @@
"naiveproxy_dns_hint": "قبل از نصب این رکورد DNS را بسازید:",
"naiveproxy_ports_warning": "هشدار: برای کار پایدار پورت‌های TCP آزاد ۸۰ و ۴۴۳ لازم است (Let\u0027s Encrypt روی ۸۰، پروکسی HTTPS روی ۴۴۳).",
"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_email": "ایمیل SSL (پیش‌فرض)",
"server_ssl_hint": "هنگام نصب Hysteria / NGINX روی این سرور استفاده می‌شود (Let\u0027s Encrypt).",
"server_connect_domain": "دامنه اتصال کلاینت",
"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": "لاگ‌های کانتینر",
"logs_btn": "📋 Logs",
"logs_live": "زنده",
+28 -4
View File
@@ -344,13 +344,17 @@
"lang_zh": "中文 (Chinese)",
"lang_fa": "فارسی (Persian)",
"backup_title": "Sauvegarde Simple",
"backup_export_label": "Export",
"backup_import_label": "Import",
"download_backup": "Télécharger le dump PostgreSQL (.sql)",
"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_confirm": "La restauration écrasera toutes les données actuelles du panneau dans la base.",
"restore_success": "Restauration réussie ! Redémarrage...",
"invalid_backup_file": "Fichier invalide (dump .sql ou ancien data.json)",
"import_backup": "Importer la base (.sql / .sql.gz / .json)",
"importing_backup": "Import de la base...",
"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_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 :",
@@ -384,11 +388,31 @@
"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_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 (102565535). 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_email": "Email SSL (par défaut)",
"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_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",
"logs_btn": "📋 Logs",
"logs_live": "Temps réel",
+22 -4
View File
@@ -362,13 +362,17 @@
"lang_zh": "中文 (Chinese)",
"lang_fa": "فارسی (Persian)",
"backup_title": "Резервное копирование",
"backup_export_label": "Экспорт",
"backup_import_label": "Импорт",
"download_backup": "Скачать дамп PostgreSQL (.sql)",
"download_backup_json": "Экспорт JSON (устар.)",
"backup_hint": "Полный дамп базы данных панели. Для бэкапа используйте .sql; JSON — для совместимости со старыми версиями.",
"backup_hint": "Полный дамп базы данных панели (.sql / .sql.gz). JSON — для совместимости со старыми версиями.",
"restore_backup": "Восстановить из .sql или .json",
"restore_confirm": "Восстановление перезапишет все текущие данные панели в базе.",
"restore_success": "Восстановление успешно! Перезагрузка...",
"invalid_backup_file": "Неверный файл (.sql дамп или устаревший data.json)",
"import_backup": "Импортировать базу (.sql / .sql.gz / .json)",
"importing_backup": "Импорт базы...",
"restore_confirm": "Импорт перезапишет все текущие данные панели в базе. Продолжить?",
"restore_success": "Импорт успешно завершён! Перезагрузка...",
"invalid_backup_file": "Неверный файл (.sql, .sql.gz или устаревший data.json)",
"config_unavailable": "Конфигурация недоступна",
"config_unavailable_desc": "Этот клиент был создан через нативное приложение Amnezia.\\nПриватный ключ хранится только на устройстве пользователя и не может быть восстановлен сервером.",
"client_public_key": "Публичный ключ клиента:",
@@ -411,6 +415,20 @@
"server_connect_domain_current": "Endpoint в конфигах",
"server_connect_domain_ssh": "SSH",
"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",
"support_title": "Поддержать проект",
"support_intro": "Если панель вам помогает, можно поддержать разработку. Выберите удобный способ ниже.",
+28 -4
View File
@@ -344,13 +344,17 @@
"lang_zh": "中文 (Chinese)",
"lang_fa": "فارسی (Persian)",
"backup_title": "简易备份",
"backup_export_label": "导出",
"backup_import_label": "导入",
"download_backup": "下载 PostgreSQL 转储 (.sql)",
"download_backup_json": "导出 JSON(旧版)",
"backup_hint": "面板 PostgreSQL 数据库的完整转储。请使用 .sql 进行完整备份;JSON 用于兼容旧版本。",
"backup_hint": "面板 PostgreSQL 数据库的完整转储.sql / .sql.gz)。JSON 用于兼容旧版本。",
"restore_backup": "从 .sql 或 .json 恢复",
"restore_confirm": "恢复将覆盖数据库中所有当前面板数据。",
"restore_success": "恢复成功!正在重启...",
"invalid_backup_file": "无效的备份文件(.sql 转储或旧版 data.json",
"import_backup": "导入数据库(.sql / .sql.gz / .json",
"importing_backup": "正在导入数据库...",
"restore_confirm": "导入将覆盖数据库中所有当前面板数据。继续?",
"restore_success": "导入成功!正在重新加载...",
"invalid_backup_file": "无效的备份文件(.sql、.sql.gz 或旧版 data.json",
"config_unavailable": "配置文件不可用",
"config_unavailable_desc": "此客户端是通过 Amnezia 原生应用创建的。\\n私钥仅存储在用户设备上,服务器无法恢复。",
"client_public_key": "客户端公钥:",
@@ -384,11 +388,31 @@
"naiveproxy_dns_hint": "安装前请创建此 DNS 记录:",
"naiveproxy_ports_warning": "注意:稳定运行需要空闲的 TCP 端口 80 和 443Let\u0027s Encrypt 使用 80HTTPS 代理使用 443)。",
"naiveproxy_client_hint": "稳定版。请勿使用 v2rayN。已确认 Karing 可用。其他客户端未测试。",
"mieru_desc": "Mierumita 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.Metatype: mieru)。",
"server_ssl_domain": "SSL 域名(默认)",
"server_ssl_email": "SSL 邮箱(默认)",
"server_ssl_hint": "安装 Hysteria / NGINX 时自动填入(Let\u0027s Encrypt)。",
"server_connect_domain": "客户端连接域名",
"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": "容器日志",
"logs_btn": "📋 日志",
"logs_live": "实时",