forked from test2/Amnezia-Web-Panel-main
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58aad90dee | ||
|
|
bf1c6761fd | ||
|
|
c0ce490a15 | ||
|
|
3c95094fc8 |
@@ -408,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:
|
||||
@@ -419,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"}' \
|
||||
|
||||
@@ -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.5"
|
||||
CURRENT_VERSION = "v2.7.0"
|
||||
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}")
|
||||
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]
|
||||
@@ -6851,7 +6940,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 +6951,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
@@ -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')
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
+192
-54
@@ -21,7 +21,14 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
MIERU_RELEASE = '3.28.0'
|
||||
GITHUB_RELEASE = f'https://github.com/enfein/mieru/releases/download/v{MIERU_RELEASE}'
|
||||
MITA_SOCK = '/var/run/mita.sock'
|
||||
# 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):
|
||||
@@ -179,17 +186,123 @@ 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"systemctl enable {self.SERVICE_NAME} 2>/dev/null || true; "
|
||||
f"mkdir -p /var/run/mita /etc/mita 2>/dev/null || true",
|
||||
timeout=30,
|
||||
)
|
||||
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,
|
||||
)
|
||||
# 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'
|
||||
@@ -198,33 +311,32 @@ class MieruManager:
|
||||
f"usermod -a -G mita {_q(op_user)} 2>/dev/null || true",
|
||||
timeout=15,
|
||||
)
|
||||
if not self._wait_for_rpc(timeout=45):
|
||||
# Stale socket / crashed daemon — hard restart once.
|
||||
|
||||
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 stop {self.SERVICE_NAME} 2>/dev/null || true; "
|
||||
f"rm -f {_q(MITA_SOCK)} /var/run/mita/*.sock 2>/dev/null || true; "
|
||||
f"systemctl start {self.SERVICE_NAME}",
|
||||
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):
|
||||
journal, _, _ = self.ssh.run_sudo_command(
|
||||
f"journalctl -u {self.SERVICE_NAME} -n 40 --no-pager 2>&1",
|
||||
timeout=30,
|
||||
)
|
||||
raise RuntimeError(
|
||||
'mita systemd daemon is not ready (RPC socket missing). '
|
||||
f'journal: {(journal or "").strip()[-500:]}'
|
||||
)
|
||||
# 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
|
||||
while time.time() < deadline:
|
||||
sock_out, _, sock_code = self.ssh.run_sudo_command(
|
||||
f"test -S {_q(MITA_SOCK)} && echo ok"
|
||||
sock_check = (
|
||||
f"(test -S {_q(MITA_SOCK)} || test -S {_q(MITA_SOCK_LEGACY)}) && echo ok"
|
||||
)
|
||||
if sock_code == 0 and 'ok' in (sock_out or ''):
|
||||
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,
|
||||
@@ -232,7 +344,10 @@ class MieruManager:
|
||||
text = (status_out or '').upper()
|
||||
if status_code == 0 and ('IDLE' in text or 'RUNNING' in text):
|
||||
return True
|
||||
# Socket exists but CLI still races — brief pause.
|
||||
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
|
||||
|
||||
@@ -242,6 +357,7 @@ class MieruManager:
|
||||
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 not c.get('enabled', True):
|
||||
@@ -251,12 +367,10 @@ class MieruManager:
|
||||
if not username or not password:
|
||||
continue
|
||||
users.append({'name': username, 'password': password})
|
||||
# mita rejects / crashes on empty users during `mita start` (RPC EOF).
|
||||
# mita FATAL-exits on empty users during proxy start ("no user found").
|
||||
if not users:
|
||||
users = [{
|
||||
'name': f'panel_{_rand_token(6)}',
|
||||
'password': _rand_token(20),
|
||||
}]
|
||||
bootstrap = self._make_bootstrap_client()
|
||||
users = [{'name': bootstrap['username'], 'password': bootstrap['password']}]
|
||||
return {
|
||||
'portBindings': [{'port': int(port), 'protocol': 'TCP'}],
|
||||
'users': users,
|
||||
@@ -305,25 +419,51 @@ class MieruManager:
|
||||
))
|
||||
|
||||
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:
|
||||
# Confirm RUNNING (daemon may report success then die).
|
||||
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,
|
||||
)
|
||||
self._wait_for_rpc(timeout=30)
|
||||
if not self._wait_for_rpc(timeout=30):
|
||||
self._heal_mita_store()
|
||||
time.sleep(1)
|
||||
continue
|
||||
break
|
||||
@@ -338,7 +478,8 @@ class MieruManager:
|
||||
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)
|
||||
|
||||
@@ -440,16 +581,7 @@ fi
|
||||
self.ssh.run_sudo_command(f"mkdir -p {_q(self.base_dir)}")
|
||||
meta = {'port': port, 'release': MIERU_RELEASE}
|
||||
self._write_metadata(meta)
|
||||
# Keep clients empty in panel DB, but seed a real mita user so start works.
|
||||
self._write_clients([])
|
||||
bootstrap = {
|
||||
'id': secrets.token_hex(8),
|
||||
'name': 'panel-bootstrap',
|
||||
'username': f'panel_{_rand_token(6)}',
|
||||
'password': _rand_token(20),
|
||||
'enabled': True,
|
||||
'bootstrap': True,
|
||||
}
|
||||
bootstrap = self._make_bootstrap_client()
|
||||
self._write_clients([bootstrap])
|
||||
log.append(f'Prepared {self.base_dir}')
|
||||
|
||||
@@ -474,7 +606,8 @@ fi
|
||||
|
||||
def start_service(self):
|
||||
self._ensure_daemon()
|
||||
self._restart_proxy()
|
||||
# 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)
|
||||
@@ -495,6 +628,19 @@ fi
|
||||
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
|
||||
|
||||
@@ -561,16 +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]
|
||||
# Keep at least bootstrap so mita never has empty users.
|
||||
if not any(not c.get('bootstrap') for c in clients) and not any(c.get('bootstrap') for c in clients):
|
||||
clients.append({
|
||||
'id': secrets.token_hex(8),
|
||||
'name': 'panel-bootstrap',
|
||||
'username': f'panel_{_rand_token(6)}',
|
||||
'password': _rand_token(20),
|
||||
'enabled': True,
|
||||
'bootstrap': True,
|
||||
})
|
||||
clients = self._ensure_bootstrap_clients(clients)
|
||||
self._write_clients(clients)
|
||||
self._sync_server(reload_only=True)
|
||||
return True
|
||||
@@ -580,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
|
||||
|
||||
+26
-11
@@ -600,10 +600,12 @@
|
||||
</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>
|
||||
<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);">
|
||||
@@ -614,15 +616,18 @@
|
||||
<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');
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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": "کلید عمومی کلاینت:",
|
||||
|
||||
@@ -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 :",
|
||||
|
||||
@@ -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": "Публичный ключ клиента:",
|
||||
|
||||
@@ -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": "客户端公钥:",
|
||||
|
||||
Reference in New Issue
Block a user