Compare commits

..
12 changed files with 468 additions and 120 deletions
+4 -1
View File
@@ -408,7 +408,7 @@ Routes are grouped in the docs as:
| **Users** | Panel user accounts and the connections assigned to them. | | **Users** | Panel user accounts and the connections assigned to them. |
| **Self-service** | Endpoints called by a regular user for their own data (`/api/my/*`). | | **Self-service** | Endpoints called by a regular user for their own data (`/api/my/*`). |
| **Sharing** | Public, token-protected configuration sharing — no panel session required. | | **Sharing** | Public, token-protected configuration sharing — no panel session required. |
| **Settings** | Panel-wide settings, Telegram bot, Remnawave sync, JSON backup/restore. | | **Settings** | Panel-wide settings, Telegram bot, Remnawave sync, SQL/JSON backup export & import. |
| **API Tokens** | Create and revoke bearer tokens for external integrations. | | **API Tokens** | Create and revoke bearer tokens for external integrations. |
**Authentication for external integrations** — both session cookies and `Authorization: Bearer <token>` are accepted on every admin endpoint. Example: **Authentication for external integrations** — both session cookies and `Authorization: Bearer <token>` are accepted on every admin endpoint. Example:
@@ -419,6 +419,9 @@ TOKEN="awp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# List panel users # List panel users
curl -H "Authorization: Bearer $TOKEN" http://your-panel:5000/api/users curl -H "Authorization: Bearer $TOKEN" http://your-panel:5000/api/users
# List SSH servers (no credentials in response)
curl -H "Authorization: Bearer $TOKEN" http://your-panel:5000/api/servers
# Add a server # Add a server
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"host":"1.2.3.4","username":"root","password":"...","name":"new-srv"}' \ -d '{"host":"1.2.3.4","username":"root","password":"...","name":"new-srv"}' \
+106 -11
View File
@@ -103,7 +103,7 @@ else:
application_path = os.path.dirname(__file__) application_path = os.path.dirname(__file__)
DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export
CURRENT_VERSION = "v2.6.5" CURRENT_VERSION = "v2.7.0"
RELEASES_REPO_URL = repo_url() RELEASES_REPO_URL = repo_url()
RELEASES_API_LATEST = api_latest_url() RELEASES_API_LATEST = api_latest_url()
BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin')) BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin'))
@@ -1354,6 +1354,30 @@ def _touch_api_token(token_entry: dict) -> bool:
return True return True
def _schedule_api_token_touch(token_id: str) -> None:
"""Persist API token last_used_at without blocking the request handler."""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
loop.create_task(_persist_api_token_touch(token_id))
async def _persist_api_token_touch(token_id: str) -> None:
try:
async with DATA_LOCK:
data = await load_data_async()
entry = next(
(t for t in data.get('api_tokens', []) if t.get('id') == token_id),
None,
)
if not entry or not _touch_api_token(entry):
return
await asyncio.to_thread(save_data, data)
except Exception as e:
logger.warning(f"Failed to touch API token last_used_at: {e}")
def hash_password(password: str) -> str: def hash_password(password: str) -> str:
salt = secrets.token_hex(16) salt = secrets.token_hex(16)
h = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000) h = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000)
@@ -2944,18 +2968,83 @@ def _check_admin(request):
resolved = _resolve_api_token(data, raw_token) resolved = _resolve_api_token(data, raw_token)
if resolved: if resolved:
entry, token_user = resolved entry, token_user = resolved
# Best-effort last-used tracking; swallow write errors so a flaky if _touch_api_token(entry):
# disk never blocks an API call from succeeding. token_id = entry.get('id')
try: if token_id:
if _touch_api_token(entry): _schedule_api_token_touch(token_id)
save_data(data)
except Exception as e:
logger.warning(f"Failed to touch API token last_used_at: {e}")
return token_user return token_user
return None return None
async def _check_admin_async(request):
"""Async variant for hot paths (server list/ping) — avoids blocking the loop."""
user = get_current_user(request)
if user and user['role'] in ('admin', 'support'):
return user
auth_header = request.headers.get('Authorization', '')
if auth_header.lower().startswith('bearer '):
raw_token = auth_header[7:].strip()
data = await load_data_async()
resolved = _resolve_api_token(data, raw_token)
if resolved:
entry, token_user = resolved
if _touch_api_token(entry):
token_id = entry.get('id')
if token_id:
_schedule_api_token_touch(token_id)
return token_user
return None
def _public_vpn_server_view(server: dict, server_id: int) -> dict:
"""Safe server list view for API consumers (no SSH secrets)."""
server_info = dict(server.get('server_info') or {})
for key in list(server_info.keys()):
if key not in ('uname', 'ssl_domain', 'ssl_email', 'connect_domain'):
server_info.pop(key, None)
protocols = {}
for key, info in (server.get('protocols') or {}).items():
if not isinstance(info, dict):
continue
protocols[key] = {
'installed': bool(info.get('installed')),
'port': info.get('port'),
'running': info.get('running'),
'container_exists': info.get('container_exists'),
}
return {
'id': server_id,
'name': server.get('name') or server.get('host') or '',
'host': server.get('host') or '',
'ssh_port': int(server.get('ssh_port') or 22),
'username': server.get('username') or '',
'auth': 'key' if server.get('private_key') else 'password',
'has_password': bool(server.get('password')),
'has_private_key': bool(server.get('private_key')),
'server_info': server_info,
'protocols': protocols,
}
@app.get('/api/servers', tags=["Servers"])
async def api_list_servers(request: Request):
"""List SSH servers in the panel inventory (credentials are never returned)."""
if not await _check_admin_async(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
data = await load_data_async()
servers = [
_public_vpn_server_view(s, idx)
for idx, s in enumerate(data.get('servers', []))
if isinstance(s, dict)
]
return {'servers': servers, 'total': len(servers)}
@app.post('/api/servers/add', tags=["Servers"]) @app.post('/api/servers/add', tags=["Servers"])
async def api_add_server(request: Request, req: AddServerRequest): async def api_add_server(request: Request, req: AddServerRequest):
if not _check_admin(request): if not _check_admin(request):
@@ -3166,9 +3255,9 @@ async def api_server_ping(request: Request, server_id: int):
measures RTT, immediately closes. Runs on the asyncio loop so the page measures RTT, immediately closes. Runs on the asyncio loop so the page
can issue many pings in parallel without blocking each other. can issue many pings in parallel without blocking each other.
""" """
if not _check_admin(request): if not await _check_admin_async(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403) return JSONResponse({'error': 'Forbidden'}, status_code=403)
data = load_data() data = await load_data_async()
if server_id >= len(data['servers']): if server_id >= len(data['servers']):
return JSONResponse({'error': 'Server not found'}, status_code=404) return JSONResponse({'error': 'Server not found'}, status_code=404)
server = data['servers'][server_id] server = data['servers'][server_id]
@@ -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/restore', tags=["Settings"])
@app.post('/api/settings/backup/import', tags=["Settings"])
async def api_backup_restore(request: Request, file: UploadFile = File(...)): async def api_backup_restore(request: Request, file: UploadFile = File(...)):
"""Import panel database from a .sql / .sql.gz dump or legacy data.json."""
if not _check_admin(request): if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403) return JSONResponse({'error': 'Forbidden'}, status_code=403)
try: try:
@@ -6860,7 +6951,11 @@ async def api_backup_restore(request: Request, file: UploadFile = File(...)):
return JSONResponse({'error': 'Empty file'}, status_code=400) return JSONResponse({'error': 'Empty file'}, status_code=400)
filename = (file.filename or '').lower() filename = (file.filename or '').lower()
is_json = filename.endswith('.json') or content.lstrip().startswith(b'{') is_gzip = filename.endswith('.gz') or content[:2] == b'\x1f\x8b'
is_json = (
not is_gzip
and (filename.endswith('.json') or content.lstrip()[:1] in (b'{', b'['))
)
if is_json: if is_json:
try: try:
+48 -6
View File
@@ -2,28 +2,52 @@
from __future__ import annotations from __future__ import annotations
import gzip
import logging import logging
import os
import subprocess import subprocess
from datetime import datetime, timezone from datetime import datetime, timezone
from .connection import get_database_url from .connection import close_pool, get_pg_connection_params
from .store import invalidate_data_cache from .store import invalidate_data_cache
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _pg_cli_env(password: str) -> dict[str, str]:
env = os.environ.copy()
if password:
env['PGPASSWORD'] = password
return env
def backup_filename() -> str: def backup_filename() -> str:
stamp = datetime.now(timezone.utc).strftime('%Y-%m-%d_%H%M%S') stamp = datetime.now(timezone.utc).strftime('%Y-%m-%d_%H%M%S')
return f'amnezia_panel_backup_{stamp}.sql' return f'amnezia_panel_backup_{stamp}.sql'
def _decode_backup_bytes(data: bytes) -> bytes:
"""Accept plain .sql or gzip-compressed dumps (.sql.gz / gzip magic)."""
if not data:
return data
if data[:2] == b'\x1f\x8b':
try:
return gzip.decompress(data)
except OSError as e:
raise ValueError(f'Invalid gzip backup: {e}') from e
return data
def export_database_sql() -> bytes: def export_database_sql() -> bytes:
"""Create a plain SQL dump of the panel PostgreSQL database.""" """Create a plain SQL dump of the panel PostgreSQL database."""
url = get_database_url() params = get_pg_connection_params()
proc = subprocess.run( proc = subprocess.run(
[ [
'pg_dump', 'pg_dump',
'--dbname', url, '-h', params['host'],
'-p', params['port'],
'-U', params['user'],
'-d', params['dbname'],
'--no-owner', '--no-owner',
'--no-acl', '--no-acl',
'--clean', '--clean',
@@ -31,6 +55,7 @@ def export_database_sql() -> bytes:
], ],
capture_output=True, capture_output=True,
check=False, check=False,
env=_pg_cli_env(params['password']),
) )
if proc.returncode != 0: if proc.returncode != 0:
err = proc.stderr.decode('utf-8', errors='replace').strip() err = proc.stderr.decode('utf-8', errors='replace').strip()
@@ -42,18 +67,35 @@ def export_database_sql() -> bytes:
def restore_database_sql(data: bytes) -> None: def restore_database_sql(data: bytes) -> None:
"""Restore panel data from a plain SQL dump produced by pg_dump.""" """Restore panel data from a plain SQL dump produced by pg_dump."""
data = _decode_backup_bytes(data)
if not data or not data.strip(): if not data or not data.strip():
raise ValueError('Empty backup file') raise ValueError('Empty backup file')
url = get_database_url()
# Drop live pool connections so --clean DROP TABLE is not blocked.
try:
close_pool()
except Exception as e:
logger.warning('close_pool before restore failed: %s', e)
params = get_pg_connection_params()
proc = subprocess.run( proc = subprocess.run(
['psql', '--dbname', url, '-v', 'ON_ERROR_STOP=1', '-q'], [
'psql',
'-h', params['host'],
'-p', params['port'],
'-U', params['user'],
'-d', params['dbname'],
'-v', 'ON_ERROR_STOP=1',
'-q',
],
input=data, input=data,
capture_output=True, capture_output=True,
check=False, check=False,
env=_pg_cli_env(params['password']),
) )
invalidate_data_cache()
if proc.returncode != 0: if proc.returncode != 0:
err = proc.stderr.decode('utf-8', errors='replace').strip() err = proc.stderr.decode('utf-8', errors='replace').strip()
out = proc.stdout.decode('utf-8', errors='replace').strip() out = proc.stdout.decode('utf-8', errors='replace').strip()
raise RuntimeError(err or out or 'psql restore failed') raise RuntimeError(err or out or 'psql restore failed')
invalidate_data_cache()
logger.info('PostgreSQL backup restored successfully') logger.info('PostgreSQL backup restored successfully')
+31
View File
@@ -24,6 +24,37 @@ def get_database_url() -> str:
return os.environ.get('DATABASE_URL', DEFAULT_DATABASE_URL).strip() return os.environ.get('DATABASE_URL', DEFAULT_DATABASE_URL).strip()
def get_pg_connection_params() -> dict[str, str]:
"""Connection parameters for pg_dump/psql (same source as the app pool)."""
from psycopg.conninfo import conninfo_to_dict
url = get_database_url()
scheme, _, rest = url.partition('://')
if scheme.startswith('postgresql'):
url = f'postgresql://{rest}'
info = conninfo_to_dict(url)
params = {
'host': str(info.get('host') or 'localhost'),
'port': str(info.get('port') or '5432'),
'user': str(info.get('user') or 'amnezia'),
'password': str(info.get('password') or ''),
'dbname': str(info.get('dbname') or 'amnezia_panel'),
}
# Prefer discrete env vars when set (Dokploy / compose); avoids URL encoding issues.
if os.environ.get('POSTGRES_USER', '').strip():
params['user'] = os.environ['POSTGRES_USER'].strip()
if os.environ.get('POSTGRES_PASSWORD', '').strip():
params['password'] = os.environ['POSTGRES_PASSWORD'].strip()
if os.environ.get('POSTGRES_DB', '').strip():
params['dbname'] = os.environ['POSTGRES_DB'].strip()
if os.environ.get('POSTGRES_PORT', '').strip():
params['port'] = os.environ['POSTGRES_PORT'].strip()
return params
def get_pool(): def get_pool():
global _pool global _pool
if _pool is not None: if _pool is not None:
+4
View File
@@ -33,6 +33,10 @@ services:
- "${APP_PORT:-5000}:5000" - "${APP_PORT:-5000}:5000"
environment: environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-amnezia}:${POSTGRES_PASSWORD:-amnezia}@db:5432/${POSTGRES_DB:-amnezia_panel} DATABASE_URL: postgresql://${POSTGRES_USER:-amnezia}:${POSTGRES_PASSWORD:-amnezia}@db:5432/${POSTGRES_DB:-amnezia_panel}
POSTGRES_USER: ${POSTGRES_USER:-amnezia}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-amnezia}
POSTGRES_DB: ${POSTGRES_DB:-amnezia_panel}
POSTGRES_PORT: "5432"
SECRET_KEY: ${SECRET_KEY:-} SECRET_KEY: ${SECRET_KEY:-}
APP_PORT: "5000" APP_PORT: "5000"
PORT: "5000" PORT: "5000"
+200 -62
View File
@@ -21,7 +21,14 @@ logger = logging.getLogger(__name__)
MIERU_RELEASE = '3.28.0' MIERU_RELEASE = '3.28.0'
GITHUB_RELEASE = f'https://github.com/enfein/mieru/releases/download/v{MIERU_RELEASE}' GITHUB_RELEASE = f'https://github.com/enfein/mieru/releases/download/v{MIERU_RELEASE}'
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): def _q(value):
@@ -179,17 +186,123 @@ class MieruManager:
def _write_clients(self, clients): def _write_clients(self, clients):
self._write_file(self.clients_path, json.dumps(clients, indent=2)) self._write_file(self.clients_path, json.dumps(clients, indent=2))
def _make_bootstrap_client(self):
return {
'id': secrets.token_hex(8),
'name': 'panel-bootstrap',
'username': f'panel_{_rand_token(6)}',
'password': _rand_token(20),
'enabled': True,
'bootstrap': True,
}
def _ensure_bootstrap_clients(self, clients):
"""Guarantee at least one enabled user so mita never starts with empty users."""
clients = [c for c in (clients or []) if isinstance(c, dict)]
enabled = [
c for c in clients
if c.get('enabled', True)
and (c.get('username') or c.get('name') or c.get('id'))
and (c.get('password') or '').strip()
]
if enabled:
return clients
bootstrap = next((c for c in clients if c.get('bootstrap')), None)
if bootstrap:
bootstrap['enabled'] = True
if not (bootstrap.get('password') or '').strip():
bootstrap['password'] = _rand_token(20)
if not (bootstrap.get('username') or '').strip():
bootstrap['username'] = f'panel_{_rand_token(6)}'
return clients
clients.append(self._make_bootstrap_client())
return clients
def _daemon_needs_heal(self):
failed, _, _ = self.ssh.run_sudo_command(
f"systemctl is-failed {self.SERVICE_NAME} 2>/dev/null"
)
if (failed or '').strip() == 'failed':
return True
active, _, _ = self.ssh.run_sudo_command(
f"systemctl is-active {self.SERVICE_NAME} 2>/dev/null"
)
# Daemon is up — ignore historical journal lines from earlier crashes.
if (active or '').strip() == 'active':
return False
journal, _, _ = self.ssh.run_sudo_command(
f"journalctl -u {self.SERVICE_NAME} -n 30 --no-pager --since '10 min ago' 2>&1",
timeout=30,
)
return 'no user found' in (journal or '').lower()
def _heal_mita_store(self, log=None):
"""Break the systemd crash loop caused by empty users in server.conf.pb.
`mita run` auto-starts the proxy when portBindings exist; with zero users
it FATAL-exits and never keeps the RPC socket up for `mita apply`.
Wiping the store lets the daemon stay IDLE so we can re-apply a valid config.
"""
if log is not None:
log.append('healing mita store (empty users / crash loop)')
self.ssh.run_sudo_command(
f"systemctl stop {self.SERVICE_NAME} 2>/dev/null || true; "
f"systemctl reset-failed {self.SERVICE_NAME} 2>/dev/null || true; "
f"rm -f {_q(MITA_SOCK)} {_q(MITA_SOCK_LEGACY)} /var/run/mita/*.sock "
f"{_q(MITA_CONFIG_PB)} {_q(MITA_CONFIG_JSON)} 2>/dev/null || true; "
f"mkdir -p /var/run/mita /etc/mita 2>/dev/null || true",
timeout=60,
)
# systemd StartLimitBurst: wait out "Start request repeated too quickly".
time.sleep(6)
clients = self._ensure_bootstrap_clients(self._read_clients())
self._write_clients(clients)
meta = self._read_metadata()
port = int(meta.get('port') or self.DEFAULT_PORT)
config = self._build_server_config(port, clients)
self._write_file(self.config_path, json.dumps(config, indent=2))
self.ssh.run_sudo_command(
f"systemctl reset-failed {self.SERVICE_NAME} 2>/dev/null || true; "
f"systemctl start {self.SERVICE_NAME}",
timeout=60,
)
if not self._wait_for_rpc(timeout=60):
# Second attempt after another rate-limit window.
self.ssh.run_sudo_command(
f"systemctl reset-failed {self.SERVICE_NAME} 2>/dev/null || true; "
f"systemctl restart {self.SERVICE_NAME}",
timeout=60,
)
time.sleep(3)
if not self._wait_for_rpc(timeout=45):
journal, _, _ = self.ssh.run_sudo_command(
f"journalctl -u {self.SERVICE_NAME} -n 50 --no-pager 2>&1",
timeout=30,
)
raise RuntimeError(
'mita daemon still not ready after heal. '
f'journal: {(journal or "").strip()[-600:]}'
)
out, err, code = self._mita_cli(
['apply', 'config', _q(self.config_path)],
timeout=60,
)
if code != 0:
raise RuntimeError(
f'mita apply after heal failed: {(err or out or "").strip()}'
)
if log is not None:
log.append('mita config re-applied with bootstrap user')
def _ensure_daemon(self, log=None): def _ensure_daemon(self, log=None):
"""Ensure mita systemd unit is up and RPC socket answers.""" """Ensure mita systemd unit is up and RPC socket answers."""
self.ssh.run_sudo_command( 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, 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`. # Official package expects the operating user in group `mita`.
user_out, _, _ = self.ssh.run_command('id -un 2>/dev/null || echo root') user_out, _, _ = self.ssh.run_command('id -un 2>/dev/null || echo root')
op_user = (user_out or 'root').strip() or 'root' op_user = (user_out or 'root').strip() or 'root'
@@ -198,41 +311,43 @@ class MieruManager:
f"usermod -a -G mita {_q(op_user)} 2>/dev/null || true", f"usermod -a -G mita {_q(op_user)} 2>/dev/null || true",
timeout=15, 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): if not self._wait_for_rpc(timeout=45):
# Stale socket / crashed daemon — hard restart once. # Crash loop / wrong socket / rate-limit — wipe store and recover.
self.ssh.run_sudo_command( self._heal_mita_store(log)
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}",
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:]}'
)
if log is not None: if log is not None:
log.append('mita daemon is active') log.append('mita daemon is active')
def _wait_for_rpc(self, timeout=30): def _wait_for_rpc(self, timeout=30):
deadline = time.time() + timeout deadline = time.time() + timeout
sock_check = (
f"(test -S {_q(MITA_SOCK)} || test -S {_q(MITA_SOCK_LEGACY)}) && echo ok"
)
while time.time() < deadline: while time.time() < deadline:
sock_out, _, sock_code = self.ssh.run_sudo_command( # Prefer CLI status: if it answers IDLE/RUNNING, RPC is up
f"test -S {_q(MITA_SOCK)} && echo ok" # 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 ''): if sock_code == 0 and 'ok' in (sock_out or ''):
status_out, _, status_code = self.ssh.run_sudo_command( time.sleep(1)
'mita status 2>&1', continue
timeout=20,
)
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.
time.sleep(1.5) time.sleep(1.5)
return False return False
@@ -242,6 +357,7 @@ class MieruManager:
return self.ssh.run_sudo_command(cmd, timeout=timeout) return self.ssh.run_sudo_command(cmd, timeout=timeout)
def _build_server_config(self, port, clients): def _build_server_config(self, port, clients):
clients = self._ensure_bootstrap_clients(clients)
users = [] users = []
for c in clients: for c in clients:
if not c.get('enabled', True): if not c.get('enabled', True):
@@ -251,12 +367,10 @@ class MieruManager:
if not username or not password: if not username or not password:
continue continue
users.append({'name': username, 'password': password}) 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: if not users:
users = [{ bootstrap = self._make_bootstrap_client()
'name': f'panel_{_rand_token(6)}', users = [{'name': bootstrap['username'], 'password': bootstrap['password']}]
'password': _rand_token(20),
}]
return { return {
'portBindings': [{'port': int(port), 'protocol': 'TCP'}], 'portBindings': [{'port': int(port), 'protocol': 'TCP'}],
'users': users, 'users': users,
@@ -305,25 +419,51 @@ class MieruManager:
)) ))
def _restart_proxy(self): 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) self._mita_cli(['stop'], timeout=30)
time.sleep(1) time.sleep(1)
last_err = '' last_err = ''
for attempt in range(1, 4): for attempt in range(1, 4):
out, err, code = self._mita_cli(['start'], timeout=60) out, err, code = self._mita_cli(['start'], timeout=60)
if code == 0: if code == 0:
# Confirm RUNNING (daemon may report success then die).
time.sleep(1) time.sleep(1)
if self._proxy_running(): if self._proxy_running():
return return
last_err = (out or err or 'mita start returned ok but status is not RUNNING').strip() last_err = (out or err or 'mita start returned ok but status is not RUNNING').strip()
else: else:
last_err = (err or out or 'mita start failed').strip() 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: if self._is_rpc_error(last_err) or attempt < 3:
self.ssh.run_sudo_command( self.ssh.run_sudo_command(
f"systemctl restart {self.SERVICE_NAME} 2>/dev/null || true", f"systemctl restart {self.SERVICE_NAME} 2>/dev/null || true",
timeout=60, timeout=60,
) )
self._wait_for_rpc(timeout=30) if not self._wait_for_rpc(timeout=30):
self._heal_mita_store()
time.sleep(1) time.sleep(1)
continue continue
break break
@@ -338,7 +478,8 @@ class MieruManager:
def _sync_server(self, reload_only=True): def _sync_server(self, reload_only=True):
meta = self._read_metadata() meta = self._read_metadata()
port = int(meta.get('port') or self.DEFAULT_PORT) port = int(meta.get('port') or self.DEFAULT_PORT)
clients = self._read_clients() clients = self._ensure_bootstrap_clients(self._read_clients())
self._write_clients(clients)
config = self._build_server_config(port, clients) config = self._build_server_config(port, clients)
self._apply_config(config, reload_only=reload_only) self._apply_config(config, reload_only=reload_only)
@@ -440,16 +581,7 @@ fi
self.ssh.run_sudo_command(f"mkdir -p {_q(self.base_dir)}") self.ssh.run_sudo_command(f"mkdir -p {_q(self.base_dir)}")
meta = {'port': port, 'release': MIERU_RELEASE} meta = {'port': port, 'release': MIERU_RELEASE}
self._write_metadata(meta) self._write_metadata(meta)
# Keep clients empty in panel DB, but seed a real mita user so start works. bootstrap = self._make_bootstrap_client()
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,
}
self._write_clients([bootstrap]) self._write_clients([bootstrap])
log.append(f'Prepared {self.base_dir}') log.append(f'Prepared {self.base_dir}')
@@ -474,7 +606,8 @@ fi
def start_service(self): def start_service(self):
self._ensure_daemon() 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): def stop_service(self):
self.ssh.run_sudo_command('mita stop 2>/dev/null || true', timeout=30) self.ssh.run_sudo_command('mita stop 2>/dev/null || true', timeout=30)
@@ -495,6 +628,19 @@ fi
raise RuntimeError(f'Invalid JSON config: {e}') from e raise RuntimeError(f'Invalid JSON config: {e}') from e
if not isinstance(parsed, dict): if not isinstance(parsed, dict):
raise RuntimeError('Config must be a JSON object') 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) self._apply_config(parsed, reload_only=False)
return True return True
@@ -561,16 +707,7 @@ fi
def remove_client(self, protocol_type, client_id): def remove_client(self, protocol_type, client_id):
clients = [c for c in self._read_clients() if c.get('id') != client_id] clients = [c for c in self._read_clients() if c.get('id') != client_id]
# Keep at least bootstrap so mita never has empty users. clients = self._ensure_bootstrap_clients(clients)
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,
})
self._write_clients(clients) self._write_clients(clients)
self._sync_server(reload_only=True) self._sync_server(reload_only=True)
return True return True
@@ -580,6 +717,7 @@ fi
for c in clients: for c in clients:
if c.get('id') == client_id: if c.get('id') == client_id:
c['enabled'] = bool(enabled) c['enabled'] = bool(enabled)
clients = self._ensure_bootstrap_clients(clients)
self._write_clients(clients) self._write_clients(clients)
self._sync_server(reload_only=True) self._sync_server(reload_only=True)
return True return True
+35 -20
View File
@@ -600,29 +600,34 @@
</div> </div>
</div> </div>
<!-- BLOCK: Simple Backup --> <!-- BLOCK: Backup / Import -->
<div class="card" style="margin-top: var(--space-lg);"> <div class="card" style="margin-top: var(--space-lg);">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">📤 {{ _('backup_title') }}</h3> <h3 class="card-title" style="margin-bottom: var(--space-lg);">📤 {{ _('backup_title') }}</h3>
<div style="display: flex; flex-direction: column; gap: var(--space-md);"> <div style="display: flex; flex-direction: column; gap: var(--space-md);">
<div style="display: flex; gap: var(--space-sm); flex-wrap: wrap;"> <div>
<a href="/api/settings/backup/download" class="btn btn-secondary" <div class="form-label" style="margin-bottom: var(--space-sm);">{{ _('backup_export_label') }}</div>
style="flex:1; min-width:200px; text-decoration:none; display:flex; align-items:center; justify-content:center; gap:var(--space-sm);"> <div style="display: flex; gap: var(--space-sm); flex-wrap: wrap;">
<span>⬇️</span> {{ _('download_backup') }} <a href="/api/settings/backup/download" class="btn btn-secondary"
</a> style="flex:1; min-width:200px; text-decoration:none; display:flex; align-items:center; justify-content:center; gap:var(--space-sm);">
<a href="/api/settings/backup/download/json" class="btn btn-secondary" <span>⬇️</span> {{ _('download_backup') }}
style="flex:1; min-width:200px; text-decoration:none; display:flex; align-items:center; justify-content:center; gap:var(--space-sm);"> </a>
<span>📄</span> {{ _('download_backup_json') }} <a href="/api/settings/backup/download/json" class="btn btn-secondary"
</a> style="flex:1; min-width:200px; text-decoration:none; display:flex; align-items:center; justify-content:center; gap:var(--space-sm);">
<span>📄</span> {{ _('download_backup_json') }}
</a>
</div>
</div> </div>
<div style="border-top: 1px solid var(--border-color); padding-top: var(--space-md);"> <div style="border-top: 1px solid var(--border-color); padding-top: var(--space-md);">
<div class="form-label" style="margin-bottom: var(--space-sm);">{{ _('backup_import_label') }}</div>
<div style="display: flex; flex-direction: column; gap: var(--space-sm);"> <div style="display: flex; flex-direction: column; gap: var(--space-sm);">
<input type="file" id="backupFile" accept=".sql,.json" style="display: none;" <input type="file" id="backupFile" accept=".sql,.sql.gz,.json,application/sql,application/gzip,application/json"
onchange="handleRestore(event)"> style="display: none;" onchange="handleRestore(event)">
<button type="button" class="btn btn-secondary" <button type="button" class="btn btn-primary"
onclick="document.getElementById('backupFile').click()" id="restoreBtn" onclick="document.getElementById('backupFile').click()" id="restoreBtn"
style="gap:var(--space-sm);"> style="gap:var(--space-sm);">
<span>⬆️</span> {{ _('restore_backup') }} <span>⬆️</span> {{ _('import_backup') }}
</button> </button>
<div class="form-hint" id="backupFileName"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -1667,30 +1672,40 @@
const file = e.target.files[0]; const file = e.target.files[0];
if (!file) return; if (!file) return;
const nameEl = document.getElementById('backupFileName');
if (nameEl) nameEl.textContent = file.name;
if (!confirm(_('restore_confirm'))) { if (!confirm(_('restore_confirm'))) {
e.target.value = ''; e.target.value = '';
if (nameEl) nameEl.textContent = '';
return; return;
} }
const btn = document.getElementById('restoreBtn'); const btn = document.getElementById('restoreBtn');
btn.disabled = true; btn.disabled = true;
const originalText = btn.innerHTML; const originalText = btn.innerHTML;
btn.innerHTML = `<div class="spinner" style="width:14px; height:14px;"></div> ${_('loading')}`; btn.innerHTML = `<div class="spinner" style="width:14px; height:14px;"></div> ${_('importing_backup')}`;
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
try { try {
const res = await fetch('/api/settings/backup/restore', { const res = await fetch('/api/settings/backup/import', {
method: 'POST', method: 'POST',
body: formData body: formData,
credentials: 'same-origin',
}); });
const data = await res.json(); let data = {};
if (data.status === 'success') { try {
data = await res.json();
} catch (_) {
data = {};
}
if (res.ok && data.status === 'success') {
showToast(_('restore_success'), 'success'); showToast(_('restore_success'), 'success');
setTimeout(() => window.location.reload(), 2000); setTimeout(() => window.location.reload(), 2000);
} else { } else {
showToast(data.error || _('invalid_backup_file'), 'error'); showToast(data.error || _('invalid_backup_file') + ` (HTTP ${res.status})`, 'error');
} }
} catch (err) { } catch (err) {
showToast(`${_('error')}: ` + err.message, 'error'); showToast(`${_('error')}: ` + err.message, 'error');
+8 -4
View File
@@ -362,13 +362,17 @@
"lang_zh": "中文 (Chinese)", "lang_zh": "中文 (Chinese)",
"lang_fa": "فارسی (Persian)", "lang_fa": "فارسی (Persian)",
"backup_title": "Simple Backup", "backup_title": "Simple Backup",
"backup_export_label": "Export",
"backup_import_label": "Import",
"download_backup": "Download PostgreSQL dump (.sql)", "download_backup": "Download PostgreSQL dump (.sql)",
"download_backup_json": "Export JSON (legacy)", "download_backup_json": "Export JSON (legacy)",
"backup_hint": "Full PostgreSQL database dump of the panel. Use .sql for complete backup; JSON export is compatible with older versions.", "backup_hint": "Full PostgreSQL database dump of the panel (.sql / .sql.gz). JSON export is compatible with older versions.",
"restore_backup": "Restore from .sql or .json", "restore_backup": "Restore from .sql or .json",
"restore_confirm": "Restore will overwrite all current panel data in the database.", "import_backup": "Import database (.sql / .sql.gz / .json)",
"restore_success": "Restore successful! Restarting...", "importing_backup": "Importing database...",
"invalid_backup_file": "Invalid backup file (.sql dump or legacy data.json)", "restore_confirm": "Import will overwrite all current panel data in the database. Continue?",
"restore_success": "Import successful! Reloading...",
"invalid_backup_file": "Invalid backup file (.sql, .sql.gz or legacy data.json)",
"config_unavailable": "Configuration unavailable", "config_unavailable": "Configuration unavailable",
"config_unavailable_desc": "This client was created via the native Amnezia app.\\nThe private key is stored only on the user\u0027s device and cannot be recovered by the server.", "config_unavailable_desc": "This client was created via the native Amnezia app.\\nThe private key is stored only on the user\u0027s device and cannot be recovered by the server.",
"client_public_key": "Client public key:", "client_public_key": "Client public key:",
+8 -4
View File
@@ -344,13 +344,17 @@
"lang_zh": "中文 (Chinese)", "lang_zh": "中文 (Chinese)",
"lang_fa": "فارسی (Persian)", "lang_fa": "فارسی (Persian)",
"backup_title": "پشتیبان‌گیری ساده", "backup_title": "پشتیبان‌گیری ساده",
"backup_export_label": "خروجی",
"backup_import_label": "واردات",
"download_backup": "دانلود dump PostgreSQL (.sql)", "download_backup": "دانلود dump PostgreSQL (.sql)",
"download_backup_json": "خروجی JSON (قدیمی)", "download_backup_json": "خروجی JSON (قدیمی)",
"backup_hint": "Dump کامل پایگاه داده PostgreSQL پنل. برای پشتیبان کامل از .sql استفاده کنید؛ JSON برای نسخه‌های قدیمی.", "backup_hint": "Dump کامل پایگاه داده PostgreSQL پنل (.sql / .sql.gz). JSON برای نسخه‌های قدیمی.",
"restore_backup": "بازیابی از .sql یا .json", "restore_backup": "بازیابی از .sql یا .json",
"restore_confirm": "بازیابی تمام داده‌های فعلی پنل در پایگاه داده را بازنویسی می‌کند.", "import_backup": "واردات پایگاه داده (.sql / .sql.gz / .json)",
"restore_success": "بازیابی موفقیت‌آمیز بود! در حال راه‌اندازی مجدد...", "importing_backup": "در حال واردات پایگاه داده...",
"invalid_backup_file": "فایل نامعتبر (dump .sql یا data.json قدیمی)", "restore_confirm": "واردات تمام داده‌های فعلی پنل در پایگاه داده را بازنویسی می‌کند. ادامه؟",
"restore_success": "واردات موفقیت‌آمیز بود! در حال بارگذاری مجدد...",
"invalid_backup_file": "فایل نامعتبر (.sql، .sql.gz یا data.json قدیمی)",
"config_unavailable": "پیکربندی در دسترس نیست", "config_unavailable": "پیکربندی در دسترس نیست",
"config_unavailable_desc": "این کلاینت از طریق اپلیکیشن اصلی Amnezia ایجاد شده است.\\nکلید خصوصی فقط در دستگاه کاربر ذخیره می‌شود و توسط سرور قابل بازیابی نیست.", "config_unavailable_desc": "این کلاینت از طریق اپلیکیشن اصلی Amnezia ایجاد شده است.\\nکلید خصوصی فقط در دستگاه کاربر ذخیره می‌شود و توسط سرور قابل بازیابی نیست.",
"client_public_key": "کلید عمومی کلاینت:", "client_public_key": "کلید عمومی کلاینت:",
+8 -4
View File
@@ -344,13 +344,17 @@
"lang_zh": "中文 (Chinese)", "lang_zh": "中文 (Chinese)",
"lang_fa": "فارسی (Persian)", "lang_fa": "فارسی (Persian)",
"backup_title": "Sauvegarde Simple", "backup_title": "Sauvegarde Simple",
"backup_export_label": "Export",
"backup_import_label": "Import",
"download_backup": "Télécharger le dump PostgreSQL (.sql)", "download_backup": "Télécharger le dump PostgreSQL (.sql)",
"download_backup_json": "Exporter JSON (ancien)", "download_backup_json": "Exporter JSON (ancien)",
"backup_hint": "Dump complet de la base PostgreSQL du panneau. Utilisez .sql pour une sauvegarde complète ; JSON pour l'ancien format.", "backup_hint": "Dump complet de la base PostgreSQL du panneau (.sql / .sql.gz). JSON pour l'ancien format.",
"restore_backup": "Restaurer depuis .sql ou .json", "restore_backup": "Restaurer depuis .sql ou .json",
"restore_confirm": "La restauration écrasera toutes les données actuelles du panneau dans la base.", "import_backup": "Importer la base (.sql / .sql.gz / .json)",
"restore_success": "Restauration réussie ! Redémarrage...", "importing_backup": "Import de la base...",
"invalid_backup_file": "Fichier invalide (dump .sql ou ancien data.json)", "restore_confirm": "L'import écrasera toutes les données actuelles du panneau dans la base. Continuer ?",
"restore_success": "Import réussi ! Rechargement...",
"invalid_backup_file": "Fichier invalide (.sql, .sql.gz ou ancien data.json)",
"config_unavailable": "Configuration indisponible", "config_unavailable": "Configuration indisponible",
"config_unavailable_desc": "Ce client a été créé via l\u0027application native Amnezia.\\nLa clé privée est stockée uniquement sur l\u0027appareil de l\u0027utilisateur et ne peut pas être récupérée par le serveur.", "config_unavailable_desc": "Ce client a été créé via l\u0027application native Amnezia.\\nLa clé privée est stockée uniquement sur l\u0027appareil de l\u0027utilisateur et ne peut pas être récupérée par le serveur.",
"client_public_key": "Clé publique du client :", "client_public_key": "Clé publique du client :",
+8 -4
View File
@@ -362,13 +362,17 @@
"lang_zh": "中文 (Chinese)", "lang_zh": "中文 (Chinese)",
"lang_fa": "فارسی (Persian)", "lang_fa": "فارسی (Persian)",
"backup_title": "Резервное копирование", "backup_title": "Резервное копирование",
"backup_export_label": "Экспорт",
"backup_import_label": "Импорт",
"download_backup": "Скачать дамп PostgreSQL (.sql)", "download_backup": "Скачать дамп PostgreSQL (.sql)",
"download_backup_json": "Экспорт JSON (устар.)", "download_backup_json": "Экспорт JSON (устар.)",
"backup_hint": "Полный дамп базы данных панели. Для бэкапа используйте .sql; JSON — для совместимости со старыми версиями.", "backup_hint": "Полный дамп базы данных панели (.sql / .sql.gz). JSON — для совместимости со старыми версиями.",
"restore_backup": "Восстановить из .sql или .json", "restore_backup": "Восстановить из .sql или .json",
"restore_confirm": "Восстановление перезапишет все текущие данные панели в базе.", "import_backup": "Импортировать базу (.sql / .sql.gz / .json)",
"restore_success": "Восстановление успешно! Перезагрузка...", "importing_backup": "Импорт базы...",
"invalid_backup_file": "Неверный файл (.sql дамп или устаревший data.json)", "restore_confirm": "Импорт перезапишет все текущие данные панели в базе. Продолжить?",
"restore_success": "Импорт успешно завершён! Перезагрузка...",
"invalid_backup_file": "Неверный файл (.sql, .sql.gz или устаревший data.json)",
"config_unavailable": "Конфигурация недоступна", "config_unavailable": "Конфигурация недоступна",
"config_unavailable_desc": "Этот клиент был создан через нативное приложение Amnezia.\\nПриватный ключ хранится только на устройстве пользователя и не может быть восстановлен сервером.", "config_unavailable_desc": "Этот клиент был создан через нативное приложение Amnezia.\\nПриватный ключ хранится только на устройстве пользователя и не может быть восстановлен сервером.",
"client_public_key": "Публичный ключ клиента:", "client_public_key": "Публичный ключ клиента:",
+8 -4
View File
@@ -344,13 +344,17 @@
"lang_zh": "中文 (Chinese)", "lang_zh": "中文 (Chinese)",
"lang_fa": "فارسی (Persian)", "lang_fa": "فارسی (Persian)",
"backup_title": "简易备份", "backup_title": "简易备份",
"backup_export_label": "导出",
"backup_import_label": "导入",
"download_backup": "下载 PostgreSQL 转储 (.sql)", "download_backup": "下载 PostgreSQL 转储 (.sql)",
"download_backup_json": "导出 JSON(旧版)", "download_backup_json": "导出 JSON(旧版)",
"backup_hint": "面板 PostgreSQL 数据库的完整转储。请使用 .sql 进行完整备份;JSON 用于兼容旧版本。", "backup_hint": "面板 PostgreSQL 数据库的完整转储.sql / .sql.gz)。JSON 用于兼容旧版本。",
"restore_backup": "从 .sql 或 .json 恢复", "restore_backup": "从 .sql 或 .json 恢复",
"restore_confirm": "恢复将覆盖数据库中所有当前面板数据。", "import_backup": "导入数据库(.sql / .sql.gz / .json",
"restore_success": "恢复成功!正在重启...", "importing_backup": "正在导入数据库...",
"invalid_backup_file": "无效的备份文件(.sql 转储或旧版 data.json", "restore_confirm": "导入将覆盖数据库中所有当前面板数据。继续?",
"restore_success": "导入成功!正在重新加载...",
"invalid_backup_file": "无效的备份文件(.sql、.sql.gz 或旧版 data.json",
"config_unavailable": "配置文件不可用", "config_unavailable": "配置文件不可用",
"config_unavailable_desc": "此客户端是通过 Amnezia 原生应用创建的。\\n私钥仅存储在用户设备上,服务器无法恢复。", "config_unavailable_desc": "此客户端是通过 Amnezia 原生应用创建的。\\n私钥仅存储在用户设备上,服务器无法恢复。",
"client_public_key": "客户端公钥:", "client_public_key": "客户端公钥:",