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)
### v2.6.5
* **Mieru install fix** — wait for `/var/run/mita.sock`, seed a bootstrap user (empty `users` caused `mita start` RPC EOF), retry apply/start with systemd restart.
### v2.6.4
* **Mieru (mita v3.28.0)** — optional per-server install from [enfein/mieru](https://github.com/enfein/mieru): native Debian/RPM package, no Docker. Marketplace + server card, TCP port at install, user connections with `mierus://` share links. Pinned release **v3.28.0** (GitHub has no v2.8.0 tag).
@@ -416,6 +419,9 @@ TOKEN="awp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# List panel users
curl -H "Authorization: Bearer $TOKEN" http://your-panel:5000/api/users
# List SSH servers (no credentials in response)
curl -H "Authorization: Bearer $TOKEN" http://your-panel:5000/api/servers
# Add a server
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"host":"1.2.3.4","username":"root","password":"...","name":"new-srv"}' \
+99 -10
View File
@@ -103,7 +103,7 @@ else:
application_path = os.path.dirname(__file__)
DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export
CURRENT_VERSION = "v2.6.4"
CURRENT_VERSION = "v2.6.9"
RELEASES_REPO_URL = repo_url()
RELEASES_API_LATEST = api_latest_url()
BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin'))
@@ -1354,6 +1354,30 @@ def _touch_api_token(token_entry: dict) -> bool:
return True
def _schedule_api_token_touch(token_id: str) -> None:
"""Persist API token last_used_at without blocking the request handler."""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
loop.create_task(_persist_api_token_touch(token_id))
async def _persist_api_token_touch(token_id: str) -> None:
try:
async with DATA_LOCK:
data = await load_data_async()
entry = next(
(t for t in data.get('api_tokens', []) if t.get('id') == token_id),
None,
)
if not entry or not _touch_api_token(entry):
return
await asyncio.to_thread(save_data, data)
except Exception as e:
logger.warning(f"Failed to touch API token last_used_at: {e}")
def hash_password(password: str) -> str:
salt = secrets.token_hex(16)
h = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000)
@@ -2944,18 +2968,83 @@ def _check_admin(request):
resolved = _resolve_api_token(data, raw_token)
if resolved:
entry, token_user = resolved
# Best-effort last-used tracking; swallow write errors so a flaky
# disk never blocks an API call from succeeding.
try:
if _touch_api_token(entry):
save_data(data)
except Exception as e:
logger.warning(f"Failed to touch API token last_used_at: {e}")
if _touch_api_token(entry):
token_id = entry.get('id')
if token_id:
_schedule_api_token_touch(token_id)
return token_user
return None
async def _check_admin_async(request):
"""Async variant for hot paths (server list/ping) — avoids blocking the loop."""
user = get_current_user(request)
if user and user['role'] in ('admin', 'support'):
return user
auth_header = request.headers.get('Authorization', '')
if auth_header.lower().startswith('bearer '):
raw_token = auth_header[7:].strip()
data = await load_data_async()
resolved = _resolve_api_token(data, raw_token)
if resolved:
entry, token_user = resolved
if _touch_api_token(entry):
token_id = entry.get('id')
if token_id:
_schedule_api_token_touch(token_id)
return token_user
return None
def _public_vpn_server_view(server: dict, server_id: int) -> dict:
"""Safe server list view for API consumers (no SSH secrets)."""
server_info = dict(server.get('server_info') or {})
for key in list(server_info.keys()):
if key not in ('uname', 'ssl_domain', 'ssl_email', 'connect_domain'):
server_info.pop(key, None)
protocols = {}
for key, info in (server.get('protocols') or {}).items():
if not isinstance(info, dict):
continue
protocols[key] = {
'installed': bool(info.get('installed')),
'port': info.get('port'),
'running': info.get('running'),
'container_exists': info.get('container_exists'),
}
return {
'id': server_id,
'name': server.get('name') or server.get('host') or '',
'host': server.get('host') or '',
'ssh_port': int(server.get('ssh_port') or 22),
'username': server.get('username') or '',
'auth': 'key' if server.get('private_key') else 'password',
'has_password': bool(server.get('password')),
'has_private_key': bool(server.get('private_key')),
'server_info': server_info,
'protocols': protocols,
}
@app.get('/api/servers', tags=["Servers"])
async def api_list_servers(request: Request):
"""List SSH servers in the panel inventory (credentials are never returned)."""
if not await _check_admin_async(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
data = await load_data_async()
servers = [
_public_vpn_server_view(s, idx)
for idx, s in enumerate(data.get('servers', []))
if isinstance(s, dict)
]
return {'servers': servers, 'total': len(servers)}
@app.post('/api/servers/add', tags=["Servers"])
async def api_add_server(request: Request, req: AddServerRequest):
if not _check_admin(request):
@@ -3166,9 +3255,9 @@ async def api_server_ping(request: Request, server_id: int):
measures RTT, immediately closes. Runs on the asyncio loop so the page
can issue many pings in parallel without blocking each other.
"""
if not _check_admin(request):
if not await _check_admin_async(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
data = load_data()
data = await load_data_async()
if server_id >= len(data['servers']):
return JSONResponse({'error': 'Server not found'}, status_code=404)
server = data['servers'][server_id]
+26 -5
View File
@@ -3,15 +3,23 @@
from __future__ import annotations
import logging
import os
import subprocess
from datetime import datetime, timezone
from .connection import get_database_url
from .connection import 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'
@@ -19,11 +27,14 @@ def backup_filename() -> str:
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 +42,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()
@@ -44,12 +56,21 @@ def restore_database_sql(data: bytes) -> None:
"""Restore panel data from a plain SQL dump produced by pg_dump."""
if not data or not data.strip():
raise ValueError('Empty backup file')
url = get_database_url()
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']),
)
if proc.returncode != 0:
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()
def get_pg_connection_params() -> dict[str, str]:
"""Connection parameters for pg_dump/psql (same source as the app pool)."""
from psycopg.conninfo import conninfo_to_dict
url = get_database_url()
scheme, _, rest = url.partition('://')
if scheme.startswith('postgresql'):
url = f'postgresql://{rest}'
info = conninfo_to_dict(url)
params = {
'host': str(info.get('host') or 'localhost'),
'port': str(info.get('port') or '5432'),
'user': str(info.get('user') or 'amnezia'),
'password': str(info.get('password') or ''),
'dbname': str(info.get('dbname') or 'amnezia_panel'),
}
# Prefer discrete env vars when set (Dokploy / compose); avoids URL encoding issues.
if os.environ.get('POSTGRES_USER', '').strip():
params['user'] = os.environ['POSTGRES_USER'].strip()
if os.environ.get('POSTGRES_PASSWORD', '').strip():
params['password'] = os.environ['POSTGRES_PASSWORD'].strip()
if os.environ.get('POSTGRES_DB', '').strip():
params['dbname'] = os.environ['POSTGRES_DB'].strip()
if os.environ.get('POSTGRES_PORT', '').strip():
params['port'] = os.environ['POSTGRES_PORT'].strip()
return params
def get_pool():
global _pool
if _pool is not None:
+4
View File
@@ -33,6 +33,10 @@ services:
- "${APP_PORT:-5000}:5000"
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-amnezia}:${POSTGRES_PASSWORD:-amnezia}@db:5432/${POSTGRES_DB:-amnezia_panel}
POSTGRES_USER: ${POSTGRES_USER:-amnezia}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-amnezia}
POSTGRES_DB: ${POSTGRES_DB:-amnezia_panel}
POSTGRES_PORT: "5432"
SECRET_KEY: ${SECRET_KEY:-}
APP_PORT: "5000"
PORT: "5000"
+340 -55
View File
@@ -14,12 +14,21 @@ import re
import secrets
import shlex
import string
import time
from urllib.parse import quote
logger = logging.getLogger(__name__)
MIERU_RELEASE = '3.28.0'
GITHUB_RELEASE = f'https://github.com/enfein/mieru/releases/download/v{MIERU_RELEASE}'
# Official mita UDS (v3.x). Older docs sometimes mention /var/run/mita.sock.
MITA_SOCK = '/var/run/mita/mita.sock'
MITA_SOCK_LEGACY = '/var/run/mita.sock'
# Official package persists applied config here. If this file has portBindings
# but no users, `mita run` (systemd) auto-starts the proxy and FATAL-exits
# with "socks5 server listening failed: no user found", crashing the daemon.
MITA_CONFIG_PB = '/etc/mita/server.conf.pb'
MITA_CONFIG_JSON = '/etc/mita/server.conf.json'
def _q(value):
@@ -144,7 +153,8 @@ class MieruManager:
b64 = base64.b64encode((content or '').encode('utf-8')).decode('ascii')
script = (
f"mkdir -p $(dirname {_q(path)}) && "
f"echo {_q(b64)} | base64 -d > {_q(path)}"
f"echo {_q(b64)} | base64 -d > {_q(path)} && "
f"chmod 644 {_q(path)}"
)
out, err, code = self.ssh.run_sudo_command(f"sh -c {_q(script)}", timeout=30)
if code != 0:
@@ -176,14 +186,191 @@ class MieruManager:
def _write_clients(self, clients):
self._write_file(self.clients_path, json.dumps(clients, indent=2))
def _make_bootstrap_client(self):
return {
'id': secrets.token_hex(8),
'name': 'panel-bootstrap',
'username': f'panel_{_rand_token(6)}',
'password': _rand_token(20),
'enabled': True,
'bootstrap': True,
}
def _ensure_bootstrap_clients(self, clients):
"""Guarantee at least one enabled user so mita never starts with empty users."""
clients = [c for c in (clients or []) if isinstance(c, dict)]
enabled = [
c for c in clients
if c.get('enabled', True)
and (c.get('username') or c.get('name') or c.get('id'))
and (c.get('password') or '').strip()
]
if enabled:
return clients
bootstrap = next((c for c in clients if c.get('bootstrap')), None)
if bootstrap:
bootstrap['enabled'] = True
if not (bootstrap.get('password') or '').strip():
bootstrap['password'] = _rand_token(20)
if not (bootstrap.get('username') or '').strip():
bootstrap['username'] = f'panel_{_rand_token(6)}'
return clients
clients.append(self._make_bootstrap_client())
return clients
def _daemon_needs_heal(self):
failed, _, _ = self.ssh.run_sudo_command(
f"systemctl is-failed {self.SERVICE_NAME} 2>/dev/null"
)
if (failed or '').strip() == 'failed':
return True
active, _, _ = self.ssh.run_sudo_command(
f"systemctl is-active {self.SERVICE_NAME} 2>/dev/null"
)
# Daemon is up — ignore historical journal lines from earlier crashes.
if (active or '').strip() == 'active':
return False
journal, _, _ = self.ssh.run_sudo_command(
f"journalctl -u {self.SERVICE_NAME} -n 30 --no-pager --since '10 min ago' 2>&1",
timeout=30,
)
return 'no user found' in (journal or '').lower()
def _heal_mita_store(self, log=None):
"""Break the systemd crash loop caused by empty users in server.conf.pb.
`mita run` auto-starts the proxy when portBindings exist; with zero users
it FATAL-exits and never keeps the RPC socket up for `mita apply`.
Wiping the store lets the daemon stay IDLE so we can re-apply a valid config.
"""
if log is not None:
log.append('healing mita store (empty users / crash loop)')
self.ssh.run_sudo_command(
f"systemctl stop {self.SERVICE_NAME} 2>/dev/null || true; "
f"systemctl reset-failed {self.SERVICE_NAME} 2>/dev/null || true; "
f"rm -f {_q(MITA_SOCK)} {_q(MITA_SOCK_LEGACY)} /var/run/mita/*.sock "
f"{_q(MITA_CONFIG_PB)} {_q(MITA_CONFIG_JSON)} 2>/dev/null || true; "
f"mkdir -p /var/run/mita /etc/mita 2>/dev/null || true",
timeout=60,
)
# systemd StartLimitBurst: wait out "Start request repeated too quickly".
time.sleep(6)
clients = self._ensure_bootstrap_clients(self._read_clients())
self._write_clients(clients)
meta = self._read_metadata()
port = int(meta.get('port') or self.DEFAULT_PORT)
config = self._build_server_config(port, clients)
self._write_file(self.config_path, json.dumps(config, indent=2))
self.ssh.run_sudo_command(
f"systemctl reset-failed {self.SERVICE_NAME} 2>/dev/null || true; "
f"systemctl start {self.SERVICE_NAME}",
timeout=60,
)
if not self._wait_for_rpc(timeout=60):
# Second attempt after another rate-limit window.
self.ssh.run_sudo_command(
f"systemctl reset-failed {self.SERVICE_NAME} 2>/dev/null || true; "
f"systemctl restart {self.SERVICE_NAME}",
timeout=60,
)
time.sleep(3)
if not self._wait_for_rpc(timeout=45):
journal, _, _ = self.ssh.run_sudo_command(
f"journalctl -u {self.SERVICE_NAME} -n 50 --no-pager 2>&1",
timeout=30,
)
raise RuntimeError(
'mita daemon still not ready after heal. '
f'journal: {(journal or "").strip()[-600:]}'
)
out, err, code = self._mita_cli(
['apply', 'config', _q(self.config_path)],
timeout=60,
)
if code != 0:
raise RuntimeError(
f'mita apply after heal failed: {(err or out or "").strip()}'
)
if log is not None:
log.append('mita config re-applied with bootstrap user')
def _ensure_daemon(self, log=None):
"""Ensure mita systemd unit is up and RPC socket answers."""
self.ssh.run_sudo_command(
f"systemctl enable {self.SERVICE_NAME} 2>/dev/null || true; "
f"mkdir -p /var/run/mita /etc/mita 2>/dev/null || true",
timeout=30,
)
# Official package expects the operating user in group `mita`.
user_out, _, _ = self.ssh.run_command('id -un 2>/dev/null || echo root')
op_user = (user_out or 'root').strip() or 'root'
if op_user != 'root':
self.ssh.run_sudo_command(
f"usermod -a -G mita {_q(op_user)} 2>/dev/null || true",
timeout=15,
)
if self._daemon_needs_heal():
self._heal_mita_store(log)
if log is not None:
log.append('mita daemon is active')
return
self.ssh.run_sudo_command(
f"systemctl start {self.SERVICE_NAME} 2>/dev/null || "
f"systemctl restart {self.SERVICE_NAME} 2>/dev/null || true",
timeout=60,
)
if not self._wait_for_rpc(timeout=45):
# Crash loop / wrong socket / rate-limit — wipe store and recover.
self._heal_mita_store(log)
if log is not None:
log.append('mita daemon is active')
def _wait_for_rpc(self, timeout=30):
deadline = time.time() + timeout
sock_check = (
f"(test -S {_q(MITA_SOCK)} || test -S {_q(MITA_SOCK_LEGACY)}) && echo ok"
)
while time.time() < deadline:
# Prefer CLI status: if it answers IDLE/RUNNING, RPC is up
# regardless of which sock path we expected.
status_out, _, status_code = self.ssh.run_sudo_command(
'mita status 2>&1',
timeout=20,
)
text = (status_out or '').upper()
if status_code == 0 and ('IDLE' in text or 'RUNNING' in text):
return True
sock_out, _, sock_code = self.ssh.run_sudo_command(sock_check)
if sock_code == 0 and 'ok' in (sock_out or ''):
time.sleep(1)
continue
time.sleep(1.5)
return False
def _mita_cli(self, args, timeout=60):
"""Run mita CLI as root so group/socket ACL is not an issue."""
cmd = f"mita {' '.join(args)} 2>&1"
return self.ssh.run_sudo_command(cmd, timeout=timeout)
def _build_server_config(self, port, clients):
clients = self._ensure_bootstrap_clients(clients)
users = []
for c in clients:
if c.get('enabled', True):
users.append({
'name': c.get('username') or c.get('name') or c.get('id'),
'password': c.get('password') or '',
})
if not c.get('enabled', True):
continue
username = (c.get('username') or c.get('name') or c.get('id') or '').strip()
password = (c.get('password') or '').strip()
if not username or not password:
continue
users.append({'name': username, 'password': password})
# mita FATAL-exits on empty users during proxy start ("no user found").
if not users:
bootstrap = self._make_bootstrap_client()
users = [{'name': bootstrap['username'], 'password': bootstrap['password']}]
return {
'portBindings': [{'port': int(port), 'protocol': 'TCP'}],
'users': users,
@@ -192,25 +379,107 @@ class MieruManager:
}
def _apply_config(self, config, reload_only=False):
self._ensure_daemon()
self._write_file(self.config_path, json.dumps(config, indent=2))
out, err, code = self.ssh.run_sudo_command(
f"mita apply config {_q(self.config_path)} 2>&1",
out, err, code = self._mita_cli(
['apply', 'config', _q(self.config_path)],
timeout=60,
)
if code != 0:
raise RuntimeError((err or out or 'mita apply config failed').strip())
# Recover from transient EOF / unavailable RPC.
if self._is_rpc_error(out, err):
self._ensure_daemon()
out, err, code = self._mita_cli(
['apply', 'config', _q(self.config_path)],
timeout=60,
)
if code != 0:
raise RuntimeError((err or out or 'mita apply config failed').strip())
if reload_only:
self.ssh.run_sudo_command('mita reload 2>/dev/null || true', timeout=30)
else:
self.ssh.run_sudo_command('mita stop 2>/dev/null || true', timeout=30)
out2, err2, code2 = self.ssh.run_sudo_command('mita start 2>&1', timeout=60)
if code2 != 0:
raise RuntimeError((err2 or out2 or 'mita start failed').strip())
# users/loggingLevel can hot-reload; fall back to full restart.
reload_out, reload_err, reload_code = self._mita_cli(['reload'], timeout=30)
if reload_code == 0:
return
logger.info('mita reload failed, falling back to stop/start: %s',
(reload_err or reload_out or '').strip())
self._restart_proxy()
def _is_rpc_error(self, *parts):
text = ' '.join(str(p or '') for p in parts).lower()
return any(token in text for token in (
'rpc error',
'unavailable',
'error reading from server',
'eof',
'no such file or directory',
'mita.sock',
'connection refused',
))
def _restart_proxy(self):
# Always push a config that includes users before start — recovers hosts
# whose /etc/mita/server.conf.pb lost the users list.
try:
meta = self._read_metadata()
port = int(meta.get('port') or self.DEFAULT_PORT)
clients = self._ensure_bootstrap_clients(self._read_clients())
self._write_clients(clients)
config = self._build_server_config(port, clients)
self._write_file(self.config_path, json.dumps(config, indent=2))
apply_out, apply_err, apply_code = self._mita_cli(
['apply', 'config', _q(self.config_path)],
timeout=60,
)
if apply_code != 0 and self._is_rpc_error(apply_out, apply_err):
self._heal_mita_store()
elif apply_code != 0:
logger.warning(
'mita apply before start failed: %s',
(apply_err or apply_out or '').strip(),
)
except Exception as e:
logger.warning('pre-start config sync failed: %s', e)
self._mita_cli(['stop'], timeout=30)
time.sleep(1)
last_err = ''
for attempt in range(1, 4):
out, err, code = self._mita_cli(['start'], timeout=60)
if code == 0:
time.sleep(1)
if self._proxy_running():
return
last_err = (out or err or 'mita start returned ok but status is not RUNNING').strip()
else:
last_err = (err or out or 'mita start failed').strip()
if 'no user found' in last_err.lower() or self._daemon_needs_heal():
self._heal_mita_store()
continue
if self._is_rpc_error(last_err) or attempt < 3:
self.ssh.run_sudo_command(
f"systemctl restart {self.SERVICE_NAME} 2>/dev/null || true",
timeout=60,
)
if not self._wait_for_rpc(timeout=30):
self._heal_mita_store()
time.sleep(1)
continue
break
journal, _, _ = self.ssh.run_sudo_command(
f"journalctl -u {self.SERVICE_NAME} -n 30 --no-pager 2>&1",
timeout=30,
)
raise RuntimeError(
f'{last_err}. journal: {(journal or "").strip()[-400:]}'
)
def _sync_server(self, reload_only=True):
meta = self._read_metadata()
port = int(meta.get('port') or self.DEFAULT_PORT)
clients = self._read_clients()
clients = self._ensure_bootstrap_clients(self._read_clients())
self._write_clients(clients)
config = self._build_server_config(port, clients)
self._apply_config(config, reload_only=reload_only)
@@ -261,10 +530,7 @@ fi
if code != 0:
raise RuntimeError(f'Failed to install mita package: {err or out}')
log.append('Installed mita package')
self.ssh.run_sudo_command(
f"systemctl enable --now {self.SERVICE_NAME} 2>/dev/null || true",
timeout=30,
)
self._ensure_daemon(log)
def _build_share_uri(self, host, port, username, password, name=''):
user = quote(username or '', safe='')
@@ -305,42 +571,43 @@ fi
return {'status': 'error', 'message': 'Port must be between 1025 and 65535'}
log = []
if not self._mita_installed():
self._install_package(log)
else:
log.append(f'mita already installed, configuring panel (v{MIERU_RELEASE})')
self.ssh.run_sudo_command(f"mkdir -p {_q(self.base_dir)}")
meta = {'port': port, 'release': MIERU_RELEASE}
self._write_metadata(meta)
self._write_clients([])
log.append(f'Prepared {self.base_dir}')
try:
config = self._build_server_config(port, [])
if not self._mita_installed():
self._install_package(log)
else:
log.append(f'mita already installed, configuring panel (v{MIERU_RELEASE})')
self._ensure_daemon(log)
self.ssh.run_sudo_command(f"mkdir -p {_q(self.base_dir)}")
meta = {'port': port, 'release': MIERU_RELEASE}
self._write_metadata(meta)
bootstrap = self._make_bootstrap_client()
self._write_clients([bootstrap])
log.append(f'Prepared {self.base_dir}')
config = self._build_server_config(port, [bootstrap])
self._apply_config(config, reload_only=False)
self._open_firewall_port(port)
log.append(f'Started mita proxy on TCP {port}')
return {
'status': 'success',
'message': f'Mieru v{MIERU_RELEASE} installed',
'log': log,
'port': str(port),
'release': MIERU_RELEASE,
}
except Exception as e:
return {'status': 'error', 'message': str(e), 'log': log}
self._open_firewall_port(port)
log.append(f'Started mita proxy on TCP {port}')
return {
'status': 'success',
'message': f'Mieru v{MIERU_RELEASE} installed',
'log': log,
'port': str(port),
'release': MIERU_RELEASE,
}
def remove_container(self, protocol_type=None):
self.ssh.run_sudo_command('mita stop 2>/dev/null || true', timeout=30)
self.ssh.run_sudo_command(f"rm -rf {_q(self.base_dir)}")
return True
def start_service(self):
out, err, code = self.ssh.run_sudo_command('mita start 2>&1', timeout=60)
if code != 0:
raise RuntimeError((err or out or 'mita start failed').strip())
self._ensure_daemon()
# Re-apply panel clients (with bootstrap) then start — fixes empty-users store.
self._sync_server(reload_only=False)
def stop_service(self):
self.ssh.run_sudo_command('mita stop 2>/dev/null || true', timeout=30)
@@ -352,15 +619,29 @@ fi
return self._read_file(self.config_path)
def save_server_config(self, protocol_type=None, config_text=''):
self._write_file(self.config_path, config_text or '')
out, err, code = self.ssh.run_sudo_command(
f"mita apply config {_q(self.config_path)} 2>&1",
timeout=60,
)
if code != 0:
raise RuntimeError((err or out or 'mita apply config failed').strip())
self.ssh.run_sudo_command('mita stop 2>/dev/null || true', timeout=30)
self.ssh.run_sudo_command('mita start 2>&1', timeout=60)
raw = (config_text or '').strip()
if not raw:
raise RuntimeError('Config is empty')
try:
parsed = json.loads(raw)
except Exception as e:
raise RuntimeError(f'Invalid JSON config: {e}') from e
if not isinstance(parsed, dict):
raise RuntimeError('Config must be a JSON object')
users = parsed.get('users')
if not isinstance(users, list) or not any(
isinstance(u, dict) and (u.get('name') or '').strip()
and ((u.get('password') or '').strip() or (u.get('hashedPassword') or '').strip())
for u in users
):
bootstrap = self._make_bootstrap_client()
parsed['users'] = [{
'name': bootstrap['username'],
'password': bootstrap['password'],
}]
clients = self._ensure_bootstrap_clients(self._read_clients())
self._write_clients(clients)
self._apply_config(parsed, reload_only=False)
return True
# ===================== CLIENTS =====================
@@ -369,6 +650,8 @@ fi
clients = self._read_clients()
result = []
for c in clients:
if c.get('bootstrap'):
continue
cname = c.get('name') or c.get('id')
result.append({
'clientId': c.get('id'),
@@ -413,7 +696,7 @@ fi
port = int(port or meta.get('port') or self.DEFAULT_PORT)
clients = self._read_clients()
client = next((c for c in clients if c.get('id') == client_id), None)
if not client:
if not client or client.get('bootstrap'):
return ''
if not client.get('enabled', True):
return ''
@@ -424,6 +707,7 @@ fi
def remove_client(self, protocol_type, client_id):
clients = [c for c in self._read_clients() if c.get('id') != client_id]
clients = self._ensure_bootstrap_clients(clients)
self._write_clients(clients)
self._sync_server(reload_only=True)
return True
@@ -433,6 +717,7 @@ fi
for c in clients:
if c.get('id') == client_id:
c['enabled'] = bool(enabled)
clients = self._ensure_bootstrap_clients(clients)
self._write_clients(clients)
self._sync_server(reload_only=True)
return True
+26 -26
View File
@@ -314,6 +314,31 @@
</div>
</div>
<!-- Mieru Card -->
<div class="card card-hover protocol-card protocol-mieru" id="proto-mieru">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
<div class="protocol-icon">{{ icon('zap') }}</div>
<div class="flex gap-sm" id="mieru-ctrl" style="display:none!important;"></div>
</div>
<div class="protocol-name">Mieru <span
style="font-size:0.65rem; background:var(--accent, #6366f1); color:#fff; padding:2px 6px; border-radius:8px; vertical-align:middle;">v3.28.0</span></div>
<div class="protocol-desc">
{{ _('mieru_desc') }}
</div>
<div class="protocol-status" id="mieru-status">
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
</div>
<div id="mieru-info" class="hidden">
<div class="protocol-info" id="mieru-info-grid"></div>
</div>
<div class="flex gap-sm" id="mieru-actions">
<button class="btn btn-primary btn-sm" onclick="openInstallModal('mieru')" id="mieru-install-btn"
style="flex:1">
{{ _('install') }}
</button>
</div>
</div>
<!-- Hysteria Card -->
<div class="card card-hover protocol-card protocol-hysteria" id="proto-hysteria">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
@@ -363,31 +388,6 @@
</div>
</div>
<!-- Mieru Card -->
<div class="card card-hover protocol-card protocol-mieru" id="proto-mieru">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
<div class="protocol-icon">{{ icon('zap') }}</div>
<div class="flex gap-sm" id="mieru-ctrl" style="display:none!important;"></div>
</div>
<div class="protocol-name">Mieru <span
style="font-size:0.65rem; background:var(--accent, #6366f1); color:#fff; padding:2px 6px; border-radius:8px; vertical-align:middle;">v3.28.0</span></div>
<div class="protocol-desc">
{{ _('mieru_desc') }}
</div>
<div class="protocol-status" id="mieru-status">
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
</div>
<div id="mieru-info" class="hidden">
<div class="protocol-info" id="mieru-info-grid"></div>
</div>
<div class="flex gap-sm" id="mieru-actions">
<button class="btn btn-primary btn-sm" onclick="openInstallModal('mieru')" id="mieru-install-btn"
style="flex:1">
{{ _('install') }}
</button>
</div>
</div>
<!-- WireGuard Card -->
<div class="card card-hover protocol-card protocol-wireguard" id="proto-wireguard">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
@@ -1199,9 +1199,9 @@
{ proto: 'awg_legacy', category: 'protocols', icon: 'radio', title: 'AmneziaWG Legacy', descKey: 'awg_legacy_desc' },
{ proto: 'xray', category: 'protocols', icon: 'zap', title: 'Xray (VLESS-Reality)', descKey: 'xray_desc' },
{ proto: 'telemt', category: 'protocols', icon: 'plane', title: 'Telemt (Telegram Proxy)', descKey: 'telemt_desc' },
{ proto: 'mieru', category: 'protocols', icon: 'zap', title: 'Mieru', descKey: 'mieru_desc', badge: 'v3.28.0' },
{ proto: 'hysteria', category: 'protocols', icon: 'refresh', title: 'Hysteria 2', descKey: 'hysteria_desc' },
{ proto: 'naiveproxy', category: 'protocols', icon: 'link', title: 'NaiveProxy', descKey: 'naiveproxy_desc', badge: 'STABLE' },
{ proto: 'mieru', category: 'protocols', icon: 'zap', title: 'Mieru', descKey: 'mieru_desc', badge: 'v3.28.0' },
{ proto: 'wireguard', category: 'protocols', icon: 'lock', title: 'WireGuard', descKey: 'wireguard_desc' },
{ proto: 'dns', category: 'services', icon: 'search', title: 'AmneziaDNS', descKey: 'dns_desc' },
{ proto: 'adguard', category: 'services', icon: 'shield-check', title: 'AdGuard Home', descKey: 'adguard_desc' },
+6
View File
@@ -384,6 +384,12 @@
"naiveproxy_dns_hint": "قبل از نصب این رکورد DNS را بسازید:",
"naiveproxy_ports_warning": "هشدار: برای کار پایدار پورت‌های TCP آزاد ۸۰ و ۴۴۳ لازم است (Let\u0027s Encrypt روی ۸۰، پروکسی HTTPS روی ۴۴۳).",
"naiveproxy_client_hint": "نسخه پایدار. از v2rayN استفاده نکنید. در Karing تأیید شده. سایر کلاینت‌ها آزمایش نشده‌اند.",
"mieru_desc": "Mieru (mita v3.28.0) — پروکسی TCP بومی enfein/mieru. بدون Docker. کلاینت: mieru یا Clash.Meta/mihomo.",
"mieru_version": "نسخه",
"mieru_port_hint": "پورت TCP برای mita (۱۰۲۵–۶۵۵۳۵). در فایروال سرور باز کنید.",
"mieru_install_hint": "بسته رسمی mita را از GitHub v3.28.0 نصب می‌کند (Debian/RPM). لینوکس با systemd لازم است.",
"mieru_ports_warning": "هشدار: پورت TCP انتخابی باید آزاد و در فایروال مجاز باشد.",
"mieru_client_hint": "لینک‌ها با فرمت mierus://. در کلاینت mieru یا Clash.Meta (type: mieru) وارد کنید.",
"server_ssl_domain": "دامنه SSL (پیش‌فرض)",
"server_ssl_email": "ایمیل SSL (پیش‌فرض)",
"server_ssl_hint": "هنگام نصب Hysteria / NGINX روی این سرور استفاده می‌شود (Let\u0027s Encrypt).",
+6
View File
@@ -384,6 +384,12 @@
"naiveproxy_dns_hint": "Créez cet enregistrement DNS avant l\u0027installation :",
"naiveproxy_ports_warning": "Attention : les ports TCP 80 et 443 doivent être libres pour un fonctionnement stable (Let\u0027s Encrypt sur 80, proxy HTTPS sur 443).",
"naiveproxy_client_hint": "Version stable. N\u0027utilisez PAS v2rayN. Fonctionne avec Karing. Autres clients non testés.",
"mieru_desc": "Mieru (mita v3.28.0) — proxy TCP natif enfein/mieru. Pas besoin de Docker. Client : mieru ou Clash.Meta/mihomo.",
"mieru_version": "Version",
"mieru_port_hint": "Port TCP pour mita (102565535). Ouvrez-le dans le pare-feu du serveur.",
"mieru_install_hint": "Installe le paquet officiel mita depuis GitHub v3.28.0 (Debian/RPM). Linux avec systemd requis.",
"mieru_ports_warning": "Attention : le port TCP choisi doit être libre et autorisé dans le pare-feu.",
"mieru_client_hint": "Liens au format mierus://. Importez dans le client mieru ou Clash.Meta (type: mieru).",
"server_ssl_domain": "Domaine SSL (par défaut)",
"server_ssl_email": "Email SSL (par défaut)",
"server_ssl_hint": "Utilisé lors de l\u0027installation de Hysteria / NGINX sur ce serveur (Let\u0027s Encrypt).",
+6
View File
@@ -384,6 +384,12 @@
"naiveproxy_dns_hint": "安装前请创建此 DNS 记录:",
"naiveproxy_ports_warning": "注意:稳定运行需要空闲的 TCP 端口 80 和 443Let\u0027s Encrypt 使用 80HTTPS 代理使用 443)。",
"naiveproxy_client_hint": "稳定版。请勿使用 v2rayN。已确认 Karing 可用。其他客户端未测试。",
"mieru_desc": "Mierumita v3.28.0)— enfein/mieru 原生 TCP 代理,无需 Docker。客户端:mieru 或 Clash.Meta/mihomo。",
"mieru_version": "版本",
"mieru_port_hint": "mita 的 TCP 端口(1025–65535)。请在服务器防火墙中放行。",
"mieru_install_hint": "从 GitHub v3.28.0 安装官方 mita 包(Debian/RPM)。需要带 systemd 的 Linux。",
"mieru_ports_warning": "注意:所选 TCP 端口必须空闲,并在防火墙中允许。",
"mieru_client_hint": "分享链接为 mierus:// 格式。可导入 mieru 客户端或 Clash.Metatype: mieru)。",
"server_ssl_domain": "SSL 域名(默认)",
"server_ssl_email": "SSL 邮箱(默认)",
"server_ssl_hint": "安装 Hysteria / NGINX 时自动填入(Let\u0027s Encrypt)。",