Compare commits

...
10 changed files with 550 additions and 96 deletions
+6
View File
@@ -277,6 +277,9 @@ GitHub Actions workflows in `.github/workflows/`:
## 📋 Fix / changelog (this fork) ## 📋 Fix / changelog (this fork)
### v2.6.5
* **Mieru install fix** — wait for `/var/run/mita.sock`, seed a bootstrap user (empty `users` caused `mita start` RPC EOF), retry apply/start with systemd restart.
### v2.6.4 ### v2.6.4
* **Mieru (mita v3.28.0)** — optional per-server install from [enfein/mieru](https://github.com/enfein/mieru): native Debian/RPM package, no Docker. Marketplace + server card, TCP port at install, user connections with `mierus://` share links. Pinned release **v3.28.0** (GitHub has no v2.8.0 tag). * **Mieru (mita v3.28.0)** — optional per-server install from [enfein/mieru](https://github.com/enfein/mieru): native Debian/RPM package, no Docker. Marketplace + server card, TCP port at install, user connections with `mierus://` share links. Pinned release **v3.28.0** (GitHub has no v2.8.0 tag).
@@ -416,6 +419,9 @@ TOKEN="awp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# List panel users # List panel users
curl -H "Authorization: Bearer $TOKEN" http://your-panel:5000/api/users curl -H "Authorization: Bearer $TOKEN" http://your-panel:5000/api/users
# List SSH servers (no credentials in response)
curl -H "Authorization: Bearer $TOKEN" http://your-panel:5000/api/servers
# Add a server # Add a server
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"host":"1.2.3.4","username":"root","password":"...","name":"new-srv"}' \ -d '{"host":"1.2.3.4","username":"root","password":"...","name":"new-srv"}' \
+98 -9
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.4" CURRENT_VERSION = "v2.6.9"
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
# disk never blocks an API call from succeeding.
try:
if _touch_api_token(entry): if _touch_api_token(entry):
save_data(data) token_id = entry.get('id')
except Exception as e: if token_id:
logger.warning(f"Failed to touch API token last_used_at: {e}") _schedule_api_token_touch(token_id)
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]
+26 -5
View File
@@ -3,15 +3,23 @@
from __future__ import annotations from __future__ import annotations
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 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'
@@ -19,11 +27,14 @@ def backup_filename() -> str:
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 +42,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()
@@ -44,12 +56,21 @@ 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."""
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() 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']),
) )
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()
+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"
+322 -37
View File
@@ -14,12 +14,21 @@ import re
import secrets import secrets
import shlex import shlex
import string import string
import time
from urllib.parse import quote from urllib.parse import quote
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MIERU_RELEASE = '3.28.0' MIERU_RELEASE = '3.28.0'
GITHUB_RELEASE = f'https://github.com/enfein/mieru/releases/download/v{MIERU_RELEASE}' GITHUB_RELEASE = f'https://github.com/enfein/mieru/releases/download/v{MIERU_RELEASE}'
# Official mita UDS (v3.x). Older docs sometimes mention /var/run/mita.sock.
MITA_SOCK = '/var/run/mita/mita.sock'
MITA_SOCK_LEGACY = '/var/run/mita.sock'
# Official package persists applied config here. If this file has portBindings
# but no users, `mita run` (systemd) auto-starts the proxy and FATAL-exits
# with "socks5 server listening failed: no user found", crashing the daemon.
MITA_CONFIG_PB = '/etc/mita/server.conf.pb'
MITA_CONFIG_JSON = '/etc/mita/server.conf.json'
def _q(value): def _q(value):
@@ -144,7 +153,8 @@ class MieruManager:
b64 = base64.b64encode((content or '').encode('utf-8')).decode('ascii') b64 = base64.b64encode((content or '').encode('utf-8')).decode('ascii')
script = ( script = (
f"mkdir -p $(dirname {_q(path)}) && " f"mkdir -p $(dirname {_q(path)}) && "
f"echo {_q(b64)} | base64 -d > {_q(path)}" f"echo {_q(b64)} | base64 -d > {_q(path)} && "
f"chmod 644 {_q(path)}"
) )
out, err, code = self.ssh.run_sudo_command(f"sh -c {_q(script)}", timeout=30) out, err, code = self.ssh.run_sudo_command(f"sh -c {_q(script)}", timeout=30)
if code != 0: if code != 0:
@@ -176,14 +186,191 @@ class MieruManager:
def _write_clients(self, clients): def _write_clients(self, clients):
self._write_file(self.clients_path, json.dumps(clients, indent=2)) self._write_file(self.clients_path, json.dumps(clients, indent=2))
def _make_bootstrap_client(self):
return {
'id': secrets.token_hex(8),
'name': 'panel-bootstrap',
'username': f'panel_{_rand_token(6)}',
'password': _rand_token(20),
'enabled': True,
'bootstrap': True,
}
def _ensure_bootstrap_clients(self, clients):
"""Guarantee at least one enabled user so mita never starts with empty users."""
clients = [c for c in (clients or []) if isinstance(c, dict)]
enabled = [
c for c in clients
if c.get('enabled', True)
and (c.get('username') or c.get('name') or c.get('id'))
and (c.get('password') or '').strip()
]
if enabled:
return clients
bootstrap = next((c for c in clients if c.get('bootstrap')), None)
if bootstrap:
bootstrap['enabled'] = True
if not (bootstrap.get('password') or '').strip():
bootstrap['password'] = _rand_token(20)
if not (bootstrap.get('username') or '').strip():
bootstrap['username'] = f'panel_{_rand_token(6)}'
return clients
clients.append(self._make_bootstrap_client())
return clients
def _daemon_needs_heal(self):
failed, _, _ = self.ssh.run_sudo_command(
f"systemctl is-failed {self.SERVICE_NAME} 2>/dev/null"
)
if (failed or '').strip() == 'failed':
return True
active, _, _ = self.ssh.run_sudo_command(
f"systemctl is-active {self.SERVICE_NAME} 2>/dev/null"
)
# Daemon is up — ignore historical journal lines from earlier crashes.
if (active or '').strip() == 'active':
return False
journal, _, _ = self.ssh.run_sudo_command(
f"journalctl -u {self.SERVICE_NAME} -n 30 --no-pager --since '10 min ago' 2>&1",
timeout=30,
)
return 'no user found' in (journal or '').lower()
def _heal_mita_store(self, log=None):
"""Break the systemd crash loop caused by empty users in server.conf.pb.
`mita run` auto-starts the proxy when portBindings exist; with zero users
it FATAL-exits and never keeps the RPC socket up for `mita apply`.
Wiping the store lets the daemon stay IDLE so we can re-apply a valid config.
"""
if log is not None:
log.append('healing mita store (empty users / crash loop)')
self.ssh.run_sudo_command(
f"systemctl stop {self.SERVICE_NAME} 2>/dev/null || true; "
f"systemctl reset-failed {self.SERVICE_NAME} 2>/dev/null || true; "
f"rm -f {_q(MITA_SOCK)} {_q(MITA_SOCK_LEGACY)} /var/run/mita/*.sock "
f"{_q(MITA_CONFIG_PB)} {_q(MITA_CONFIG_JSON)} 2>/dev/null || true; "
f"mkdir -p /var/run/mita /etc/mita 2>/dev/null || true",
timeout=60,
)
# systemd StartLimitBurst: wait out "Start request repeated too quickly".
time.sleep(6)
clients = self._ensure_bootstrap_clients(self._read_clients())
self._write_clients(clients)
meta = self._read_metadata()
port = int(meta.get('port') or self.DEFAULT_PORT)
config = self._build_server_config(port, clients)
self._write_file(self.config_path, json.dumps(config, indent=2))
self.ssh.run_sudo_command(
f"systemctl reset-failed {self.SERVICE_NAME} 2>/dev/null || true; "
f"systemctl start {self.SERVICE_NAME}",
timeout=60,
)
if not self._wait_for_rpc(timeout=60):
# Second attempt after another rate-limit window.
self.ssh.run_sudo_command(
f"systemctl reset-failed {self.SERVICE_NAME} 2>/dev/null || true; "
f"systemctl restart {self.SERVICE_NAME}",
timeout=60,
)
time.sleep(3)
if not self._wait_for_rpc(timeout=45):
journal, _, _ = self.ssh.run_sudo_command(
f"journalctl -u {self.SERVICE_NAME} -n 50 --no-pager 2>&1",
timeout=30,
)
raise RuntimeError(
'mita daemon still not ready after heal. '
f'journal: {(journal or "").strip()[-600:]}'
)
out, err, code = self._mita_cli(
['apply', 'config', _q(self.config_path)],
timeout=60,
)
if code != 0:
raise RuntimeError(
f'mita apply after heal failed: {(err or out or "").strip()}'
)
if log is not None:
log.append('mita config re-applied with bootstrap user')
def _ensure_daemon(self, log=None):
"""Ensure mita systemd unit is up and RPC socket answers."""
self.ssh.run_sudo_command(
f"systemctl enable {self.SERVICE_NAME} 2>/dev/null || true; "
f"mkdir -p /var/run/mita /etc/mita 2>/dev/null || true",
timeout=30,
)
# Official package expects the operating user in group `mita`.
user_out, _, _ = self.ssh.run_command('id -un 2>/dev/null || echo root')
op_user = (user_out or 'root').strip() or 'root'
if op_user != 'root':
self.ssh.run_sudo_command(
f"usermod -a -G mita {_q(op_user)} 2>/dev/null || true",
timeout=15,
)
if self._daemon_needs_heal():
self._heal_mita_store(log)
if log is not None:
log.append('mita daemon is active')
return
self.ssh.run_sudo_command(
f"systemctl start {self.SERVICE_NAME} 2>/dev/null || "
f"systemctl restart {self.SERVICE_NAME} 2>/dev/null || true",
timeout=60,
)
if not self._wait_for_rpc(timeout=45):
# Crash loop / wrong socket / rate-limit — wipe store and recover.
self._heal_mita_store(log)
if log is not None:
log.append('mita daemon is active')
def _wait_for_rpc(self, timeout=30):
deadline = time.time() + timeout
sock_check = (
f"(test -S {_q(MITA_SOCK)} || test -S {_q(MITA_SOCK_LEGACY)}) && echo ok"
)
while time.time() < deadline:
# Prefer CLI status: if it answers IDLE/RUNNING, RPC is up
# regardless of which sock path we expected.
status_out, _, status_code = self.ssh.run_sudo_command(
'mita status 2>&1',
timeout=20,
)
text = (status_out or '').upper()
if status_code == 0 and ('IDLE' in text or 'RUNNING' in text):
return True
sock_out, _, sock_code = self.ssh.run_sudo_command(sock_check)
if sock_code == 0 and 'ok' in (sock_out or ''):
time.sleep(1)
continue
time.sleep(1.5)
return False
def _mita_cli(self, args, timeout=60):
"""Run mita CLI as root so group/socket ACL is not an issue."""
cmd = f"mita {' '.join(args)} 2>&1"
return self.ssh.run_sudo_command(cmd, timeout=timeout)
def _build_server_config(self, port, clients): def _build_server_config(self, port, clients):
clients = self._ensure_bootstrap_clients(clients)
users = [] users = []
for c in clients: for c in clients:
if c.get('enabled', True): if not c.get('enabled', True):
users.append({ continue
'name': c.get('username') or c.get('name') or c.get('id'), username = (c.get('username') or c.get('name') or c.get('id') or '').strip()
'password': c.get('password') or '', password = (c.get('password') or '').strip()
}) if not username or not password:
continue
users.append({'name': username, 'password': password})
# mita FATAL-exits on empty users during proxy start ("no user found").
if not users:
bootstrap = self._make_bootstrap_client()
users = [{'name': bootstrap['username'], 'password': bootstrap['password']}]
return { return {
'portBindings': [{'port': int(port), 'protocol': 'TCP'}], 'portBindings': [{'port': int(port), 'protocol': 'TCP'}],
'users': users, 'users': users,
@@ -192,25 +379,107 @@ class MieruManager:
} }
def _apply_config(self, config, reload_only=False): def _apply_config(self, config, reload_only=False):
self._ensure_daemon()
self._write_file(self.config_path, json.dumps(config, indent=2)) self._write_file(self.config_path, json.dumps(config, indent=2))
out, err, code = self.ssh.run_sudo_command( out, err, code = self._mita_cli(
f"mita apply config {_q(self.config_path)} 2>&1", ['apply', 'config', _q(self.config_path)],
timeout=60,
)
if code != 0:
# 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, timeout=60,
) )
if code != 0: if code != 0:
raise RuntimeError((err or out or 'mita apply config failed').strip()) raise RuntimeError((err or out or 'mita apply config failed').strip())
if reload_only: if reload_only:
self.ssh.run_sudo_command('mita reload 2>/dev/null || true', timeout=30) # users/loggingLevel can hot-reload; fall back to full restart.
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: else:
self.ssh.run_sudo_command('mita stop 2>/dev/null || true', timeout=30) last_err = (err or out or 'mita start failed').strip()
out2, err2, code2 = self.ssh.run_sudo_command('mita start 2>&1', timeout=60) if 'no user found' in last_err.lower() or self._daemon_needs_heal():
if code2 != 0: self._heal_mita_store()
raise RuntimeError((err2 or out2 or 'mita start failed').strip()) continue
if self._is_rpc_error(last_err) or attempt < 3:
self.ssh.run_sudo_command(
f"systemctl restart {self.SERVICE_NAME} 2>/dev/null || true",
timeout=60,
)
if not self._wait_for_rpc(timeout=30):
self._heal_mita_store()
time.sleep(1)
continue
break
journal, _, _ = self.ssh.run_sudo_command(
f"journalctl -u {self.SERVICE_NAME} -n 30 --no-pager 2>&1",
timeout=30,
)
raise RuntimeError(
f'{last_err}. journal: {(journal or "").strip()[-400:]}'
)
def _sync_server(self, reload_only=True): def _sync_server(self, reload_only=True):
meta = self._read_metadata() meta = self._read_metadata()
port = int(meta.get('port') or self.DEFAULT_PORT) port = int(meta.get('port') or self.DEFAULT_PORT)
clients = self._read_clients() clients = self._ensure_bootstrap_clients(self._read_clients())
self._write_clients(clients)
config = self._build_server_config(port, clients) config = self._build_server_config(port, clients)
self._apply_config(config, reload_only=reload_only) self._apply_config(config, reload_only=reload_only)
@@ -261,10 +530,7 @@ fi
if code != 0: if code != 0:
raise RuntimeError(f'Failed to install mita package: {err or out}') raise RuntimeError(f'Failed to install mita package: {err or out}')
log.append('Installed mita package') log.append('Installed mita package')
self.ssh.run_sudo_command( self._ensure_daemon(log)
f"systemctl enable --now {self.SERVICE_NAME} 2>/dev/null || true",
timeout=30,
)
def _build_share_uri(self, host, port, username, password, name=''): def _build_share_uri(self, host, port, username, password, name=''):
user = quote(username or '', safe='') user = quote(username or '', safe='')
@@ -305,23 +571,22 @@ fi
return {'status': 'error', 'message': 'Port must be between 1025 and 65535'} return {'status': 'error', 'message': 'Port must be between 1025 and 65535'}
log = [] log = []
try:
if not self._mita_installed(): if not self._mita_installed():
self._install_package(log) self._install_package(log)
else: else:
log.append(f'mita already installed, configuring panel (v{MIERU_RELEASE})') 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)}") 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)
self._write_clients([]) bootstrap = self._make_bootstrap_client()
self._write_clients([bootstrap])
log.append(f'Prepared {self.base_dir}') log.append(f'Prepared {self.base_dir}')
try: config = self._build_server_config(port, [bootstrap])
config = self._build_server_config(port, [])
self._apply_config(config, reload_only=False) self._apply_config(config, reload_only=False)
except Exception as e:
return {'status': 'error', 'message': str(e), 'log': log}
self._open_firewall_port(port) self._open_firewall_port(port)
log.append(f'Started mita proxy on TCP {port}') log.append(f'Started mita proxy on TCP {port}')
return { return {
@@ -331,6 +596,8 @@ fi
'port': str(port), 'port': str(port),
'release': MIERU_RELEASE, 'release': MIERU_RELEASE,
} }
except Exception as e:
return {'status': 'error', 'message': str(e), 'log': log}
def remove_container(self, protocol_type=None): def remove_container(self, protocol_type=None):
self.ssh.run_sudo_command('mita stop 2>/dev/null || true', timeout=30) self.ssh.run_sudo_command('mita stop 2>/dev/null || true', timeout=30)
@@ -338,9 +605,9 @@ fi
return True return True
def start_service(self): def start_service(self):
out, err, code = self.ssh.run_sudo_command('mita start 2>&1', timeout=60) self._ensure_daemon()
if code != 0: # Re-apply panel clients (with bootstrap) then start — fixes empty-users store.
raise RuntimeError((err or out or 'mita start failed').strip()) self._sync_server(reload_only=False)
def stop_service(self): def stop_service(self):
self.ssh.run_sudo_command('mita stop 2>/dev/null || true', timeout=30) self.ssh.run_sudo_command('mita stop 2>/dev/null || true', timeout=30)
@@ -352,15 +619,29 @@ fi
return self._read_file(self.config_path) return self._read_file(self.config_path)
def save_server_config(self, protocol_type=None, config_text=''): def save_server_config(self, protocol_type=None, config_text=''):
self._write_file(self.config_path, config_text or '') raw = (config_text or '').strip()
out, err, code = self.ssh.run_sudo_command( if not raw:
f"mita apply config {_q(self.config_path)} 2>&1", raise RuntimeError('Config is empty')
timeout=60, try:
) parsed = json.loads(raw)
if code != 0: except Exception as e:
raise RuntimeError((err or out or 'mita apply config failed').strip()) raise RuntimeError(f'Invalid JSON config: {e}') from e
self.ssh.run_sudo_command('mita stop 2>/dev/null || true', timeout=30) if not isinstance(parsed, dict):
self.ssh.run_sudo_command('mita start 2>&1', timeout=60) raise RuntimeError('Config must be a JSON object')
users = parsed.get('users')
if not isinstance(users, list) or not any(
isinstance(u, dict) and (u.get('name') or '').strip()
and ((u.get('password') or '').strip() or (u.get('hashedPassword') or '').strip())
for u in users
):
bootstrap = self._make_bootstrap_client()
parsed['users'] = [{
'name': bootstrap['username'],
'password': bootstrap['password'],
}]
clients = self._ensure_bootstrap_clients(self._read_clients())
self._write_clients(clients)
self._apply_config(parsed, reload_only=False)
return True return True
# ===================== CLIENTS ===================== # ===================== CLIENTS =====================
@@ -369,6 +650,8 @@ fi
clients = self._read_clients() clients = self._read_clients()
result = [] result = []
for c in clients: for c in clients:
if c.get('bootstrap'):
continue
cname = c.get('name') or c.get('id') cname = c.get('name') or c.get('id')
result.append({ result.append({
'clientId': c.get('id'), 'clientId': c.get('id'),
@@ -413,7 +696,7 @@ fi
port = int(port or meta.get('port') or self.DEFAULT_PORT) port = int(port or meta.get('port') or self.DEFAULT_PORT)
clients = self._read_clients() clients = self._read_clients()
client = next((c for c in clients if c.get('id') == client_id), None) client = next((c for c in clients if c.get('id') == client_id), None)
if not client: if not client or client.get('bootstrap'):
return '' return ''
if not client.get('enabled', True): if not client.get('enabled', True):
return '' return ''
@@ -424,6 +707,7 @@ fi
def remove_client(self, protocol_type, client_id): def remove_client(self, protocol_type, client_id):
clients = [c for c in self._read_clients() if c.get('id') != client_id] clients = [c for c in self._read_clients() if c.get('id') != client_id]
clients = self._ensure_bootstrap_clients(clients)
self._write_clients(clients) self._write_clients(clients)
self._sync_server(reload_only=True) self._sync_server(reload_only=True)
return True return True
@@ -433,6 +717,7 @@ fi
for c in clients: for c in clients:
if c.get('id') == client_id: if c.get('id') == client_id:
c['enabled'] = bool(enabled) c['enabled'] = bool(enabled)
clients = self._ensure_bootstrap_clients(clients)
self._write_clients(clients) self._write_clients(clients)
self._sync_server(reload_only=True) self._sync_server(reload_only=True)
return True return True
+26 -26
View File
@@ -314,6 +314,31 @@
</div> </div>
</div> </div>
<!-- Mieru Card -->
<div class="card card-hover protocol-card protocol-mieru" id="proto-mieru">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
<div class="protocol-icon">{{ icon('zap') }}</div>
<div class="flex gap-sm" id="mieru-ctrl" style="display:none!important;"></div>
</div>
<div class="protocol-name">Mieru <span
style="font-size:0.65rem; background:var(--accent, #6366f1); color:#fff; padding:2px 6px; border-radius:8px; vertical-align:middle;">v3.28.0</span></div>
<div class="protocol-desc">
{{ _('mieru_desc') }}
</div>
<div class="protocol-status" id="mieru-status">
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
</div>
<div id="mieru-info" class="hidden">
<div class="protocol-info" id="mieru-info-grid"></div>
</div>
<div class="flex gap-sm" id="mieru-actions">
<button class="btn btn-primary btn-sm" onclick="openInstallModal('mieru')" id="mieru-install-btn"
style="flex:1">
{{ _('install') }}
</button>
</div>
</div>
<!-- Hysteria Card --> <!-- Hysteria Card -->
<div class="card card-hover protocol-card protocol-hysteria" id="proto-hysteria"> <div class="card card-hover protocol-card protocol-hysteria" id="proto-hysteria">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);"> <div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
@@ -363,31 +388,6 @@
</div> </div>
</div> </div>
<!-- Mieru Card -->
<div class="card card-hover protocol-card protocol-mieru" id="proto-mieru">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
<div class="protocol-icon">{{ icon('zap') }}</div>
<div class="flex gap-sm" id="mieru-ctrl" style="display:none!important;"></div>
</div>
<div class="protocol-name">Mieru <span
style="font-size:0.65rem; background:var(--accent, #6366f1); color:#fff; padding:2px 6px; border-radius:8px; vertical-align:middle;">v3.28.0</span></div>
<div class="protocol-desc">
{{ _('mieru_desc') }}
</div>
<div class="protocol-status" id="mieru-status">
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
</div>
<div id="mieru-info" class="hidden">
<div class="protocol-info" id="mieru-info-grid"></div>
</div>
<div class="flex gap-sm" id="mieru-actions">
<button class="btn btn-primary btn-sm" onclick="openInstallModal('mieru')" id="mieru-install-btn"
style="flex:1">
{{ _('install') }}
</button>
</div>
</div>
<!-- WireGuard Card --> <!-- WireGuard Card -->
<div class="card card-hover protocol-card protocol-wireguard" id="proto-wireguard"> <div class="card card-hover protocol-card protocol-wireguard" id="proto-wireguard">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);"> <div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
@@ -1199,9 +1199,9 @@
{ proto: 'awg_legacy', category: 'protocols', icon: 'radio', title: 'AmneziaWG Legacy', descKey: 'awg_legacy_desc' }, { proto: 'awg_legacy', category: 'protocols', icon: 'radio', title: 'AmneziaWG Legacy', descKey: 'awg_legacy_desc' },
{ proto: 'xray', category: 'protocols', icon: 'zap', title: 'Xray (VLESS-Reality)', descKey: 'xray_desc' }, { proto: 'xray', category: 'protocols', icon: 'zap', title: 'Xray (VLESS-Reality)', descKey: 'xray_desc' },
{ proto: 'telemt', category: 'protocols', icon: 'plane', title: 'Telemt (Telegram Proxy)', descKey: 'telemt_desc' }, { proto: 'telemt', category: 'protocols', icon: 'plane', title: 'Telemt (Telegram Proxy)', descKey: 'telemt_desc' },
{ proto: 'mieru', category: 'protocols', icon: 'zap', title: 'Mieru', descKey: 'mieru_desc', badge: 'v3.28.0' },
{ proto: 'hysteria', category: 'protocols', icon: 'refresh', title: 'Hysteria 2', descKey: 'hysteria_desc' }, { proto: 'hysteria', category: 'protocols', icon: 'refresh', title: 'Hysteria 2', descKey: 'hysteria_desc' },
{ proto: 'naiveproxy', category: 'protocols', icon: 'link', title: 'NaiveProxy', descKey: 'naiveproxy_desc', badge: 'STABLE' }, { proto: 'naiveproxy', category: 'protocols', icon: 'link', title: 'NaiveProxy', descKey: 'naiveproxy_desc', badge: 'STABLE' },
{ proto: 'mieru', category: 'protocols', icon: 'zap', title: 'Mieru', descKey: 'mieru_desc', badge: 'v3.28.0' },
{ proto: 'wireguard', category: 'protocols', icon: 'lock', title: 'WireGuard', descKey: 'wireguard_desc' }, { proto: 'wireguard', category: 'protocols', icon: 'lock', title: 'WireGuard', descKey: 'wireguard_desc' },
{ proto: 'dns', category: 'services', icon: 'search', title: 'AmneziaDNS', descKey: 'dns_desc' }, { proto: 'dns', category: 'services', icon: 'search', title: 'AmneziaDNS', descKey: 'dns_desc' },
{ proto: 'adguard', category: 'services', icon: 'shield-check', title: 'AdGuard Home', descKey: 'adguard_desc' }, { proto: 'adguard', category: 'services', icon: 'shield-check', title: 'AdGuard Home', descKey: 'adguard_desc' },
+6
View File
@@ -384,6 +384,12 @@
"naiveproxy_dns_hint": "قبل از نصب این رکورد DNS را بسازید:", "naiveproxy_dns_hint": "قبل از نصب این رکورد DNS را بسازید:",
"naiveproxy_ports_warning": "هشدار: برای کار پایدار پورت‌های TCP آزاد ۸۰ و ۴۴۳ لازم است (Let\u0027s Encrypt روی ۸۰، پروکسی HTTPS روی ۴۴۳).", "naiveproxy_ports_warning": "هشدار: برای کار پایدار پورت‌های TCP آزاد ۸۰ و ۴۴۳ لازم است (Let\u0027s Encrypt روی ۸۰، پروکسی HTTPS روی ۴۴۳).",
"naiveproxy_client_hint": "نسخه پایدار. از v2rayN استفاده نکنید. در Karing تأیید شده. سایر کلاینت‌ها آزمایش نشده‌اند.", "naiveproxy_client_hint": "نسخه پایدار. از v2rayN استفاده نکنید. در Karing تأیید شده. سایر کلاینت‌ها آزمایش نشده‌اند.",
"mieru_desc": "Mieru (mita v3.28.0) — پروکسی TCP بومی enfein/mieru. بدون Docker. کلاینت: mieru یا Clash.Meta/mihomo.",
"mieru_version": "نسخه",
"mieru_port_hint": "پورت TCP برای mita (۱۰۲۵–۶۵۵۳۵). در فایروال سرور باز کنید.",
"mieru_install_hint": "بسته رسمی mita را از GitHub v3.28.0 نصب می‌کند (Debian/RPM). لینوکس با systemd لازم است.",
"mieru_ports_warning": "هشدار: پورت TCP انتخابی باید آزاد و در فایروال مجاز باشد.",
"mieru_client_hint": "لینک‌ها با فرمت mierus://. در کلاینت mieru یا Clash.Meta (type: mieru) وارد کنید.",
"server_ssl_domain": "دامنه SSL (پیش‌فرض)", "server_ssl_domain": "دامنه SSL (پیش‌فرض)",
"server_ssl_email": "ایمیل SSL (پیش‌فرض)", "server_ssl_email": "ایمیل SSL (پیش‌فرض)",
"server_ssl_hint": "هنگام نصب Hysteria / NGINX روی این سرور استفاده می‌شود (Let\u0027s Encrypt).", "server_ssl_hint": "هنگام نصب Hysteria / NGINX روی این سرور استفاده می‌شود (Let\u0027s Encrypt).",
+6
View File
@@ -384,6 +384,12 @@
"naiveproxy_dns_hint": "Créez cet enregistrement DNS avant l\u0027installation :", "naiveproxy_dns_hint": "Créez cet enregistrement DNS avant l\u0027installation :",
"naiveproxy_ports_warning": "Attention : les ports TCP 80 et 443 doivent être libres pour un fonctionnement stable (Let\u0027s Encrypt sur 80, proxy HTTPS sur 443).", "naiveproxy_ports_warning": "Attention : les ports TCP 80 et 443 doivent être libres pour un fonctionnement stable (Let\u0027s Encrypt sur 80, proxy HTTPS sur 443).",
"naiveproxy_client_hint": "Version stable. N\u0027utilisez PAS v2rayN. Fonctionne avec Karing. Autres clients non testés.", "naiveproxy_client_hint": "Version stable. N\u0027utilisez PAS v2rayN. Fonctionne avec Karing. Autres clients non testés.",
"mieru_desc": "Mieru (mita v3.28.0) — proxy TCP natif enfein/mieru. Pas besoin de Docker. Client : mieru ou Clash.Meta/mihomo.",
"mieru_version": "Version",
"mieru_port_hint": "Port TCP pour mita (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_domain": "Domaine SSL (par défaut)",
"server_ssl_email": "Email SSL (par défaut)", "server_ssl_email": "Email SSL (par défaut)",
"server_ssl_hint": "Utilisé lors de l\u0027installation de Hysteria / NGINX sur ce serveur (Let\u0027s Encrypt).", "server_ssl_hint": "Utilisé lors de l\u0027installation de Hysteria / NGINX sur ce serveur (Let\u0027s Encrypt).",
+6
View File
@@ -384,6 +384,12 @@
"naiveproxy_dns_hint": "安装前请创建此 DNS 记录:", "naiveproxy_dns_hint": "安装前请创建此 DNS 记录:",
"naiveproxy_ports_warning": "注意:稳定运行需要空闲的 TCP 端口 80 和 443Let\u0027s Encrypt 使用 80HTTPS 代理使用 443)。", "naiveproxy_ports_warning": "注意:稳定运行需要空闲的 TCP 端口 80 和 443Let\u0027s Encrypt 使用 80HTTPS 代理使用 443)。",
"naiveproxy_client_hint": "稳定版。请勿使用 v2rayN。已确认 Karing 可用。其他客户端未测试。", "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_domain": "SSL 域名(默认)",
"server_ssl_email": "SSL 邮箱(默认)", "server_ssl_email": "SSL 邮箱(默认)",
"server_ssl_hint": "安装 Hysteria / NGINX 时自动填入(Let\u0027s Encrypt)。", "server_ssl_hint": "安装 Hysteria / NGINX 时自动填入(Let\u0027s Encrypt)。",