Fix Mieru mita start RPC EOF by waiting for daemon socket and seeding users.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-28 14:39:56 +03:00
co-authored by Cursor
parent 53638cf122
commit 53bffbd4fc
3 changed files with 205 additions and 55 deletions
+3
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).
+1 -1
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.5"
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'))
+201 -54
View File
@@ -14,12 +14,14 @@ 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}'
MITA_SOCK = '/var/run/mita.sock'
def _q(value): def _q(value):
@@ -144,7 +146,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 +179,84 @@ 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 _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",
timeout=30,
)
self.ssh.run_sudo_command(
f"systemctl start {self.SERVICE_NAME} 2>/dev/null || "
f"systemctl restart {self.SERVICE_NAME} 2>/dev/null || true",
timeout=60,
)
# Official package expects the operating user in group `mita`.
user_out, _, _ = self.ssh.run_command('id -un 2>/dev/null || echo root')
op_user = (user_out or 'root').strip() or 'root'
if op_user != 'root':
self.ssh.run_sudo_command(
f"usermod -a -G mita {_q(op_user)} 2>/dev/null || true",
timeout=15,
)
if not self._wait_for_rpc(timeout=45):
# Stale socket / crashed daemon — hard restart once.
self.ssh.run_sudo_command(
f"systemctl stop {self.SERVICE_NAME} 2>/dev/null || true; "
f"rm -f {_q(MITA_SOCK)} /var/run/mita/*.sock 2>/dev/null || true; "
f"systemctl start {self.SERVICE_NAME}",
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:
log.append('mita daemon is active')
def _wait_for_rpc(self, timeout=30):
deadline = time.time() + timeout
while time.time() < deadline:
sock_out, _, sock_code = self.ssh.run_sudo_command(
f"test -S {_q(MITA_SOCK)} && echo ok"
)
if sock_code == 0 and 'ok' in (sock_out or ''):
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
# Socket exists but CLI still races — brief pause.
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):
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 rejects / crashes on empty users during `mita start` (RPC EOF).
if not users:
users = [{
'name': f'panel_{_rand_token(6)}',
'password': _rand_token(20),
}]
return { return {
'portBindings': [{'port': int(port), 'protocol': 'TCP'}], 'portBindings': [{'port': int(port), 'protocol': 'TCP'}],
'users': users, 'users': users,
@@ -192,20 +265,75 @@ 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, timeout=60,
) )
if code != 0: 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: 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.
else: reload_out, reload_err, reload_code = self._mita_cli(['reload'], timeout=30)
self.ssh.run_sudo_command('mita stop 2>/dev/null || true', timeout=30) if reload_code == 0:
out2, err2, code2 = self.ssh.run_sudo_command('mita start 2>&1', timeout=60) return
if code2 != 0: logger.info('mita reload failed, falling back to stop/start: %s',
raise RuntimeError((err2 or out2 or 'mita start failed').strip()) (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):
self._mita_cli(['stop'], timeout=30)
time.sleep(1)
last_err = ''
for attempt in range(1, 4):
out, err, code = self._mita_cli(['start'], timeout=60)
if code == 0:
# Confirm RUNNING (daemon may report success then die).
time.sleep(1)
if self._proxy_running():
return
last_err = (out or err or 'mita start returned ok but status is not RUNNING').strip()
else:
last_err = (err or out or 'mita start failed').strip()
if self._is_rpc_error(last_err) or attempt < 3:
self.ssh.run_sudo_command(
f"systemctl restart {self.SERVICE_NAME} 2>/dev/null || true",
timeout=60,
)
self._wait_for_rpc(timeout=30)
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()
@@ -261,10 +389,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,42 +430,51 @@ 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 = []
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: 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)
# Keep clients empty in panel DB, but seed a real mita user so start works.
self._write_clients([])
bootstrap = {
'id': secrets.token_hex(8),
'name': 'panel-bootstrap',
'username': f'panel_{_rand_token(6)}',
'password': _rand_token(20),
'enabled': True,
'bootstrap': True,
}
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._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: except Exception as e:
return {'status': 'error', 'message': str(e), 'log': log} 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): 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)
self.ssh.run_sudo_command(f"rm -rf {_q(self.base_dir)}") self.ssh.run_sudo_command(f"rm -rf {_q(self.base_dir)}")
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: self._restart_proxy()
raise RuntimeError((err or out or 'mita start failed').strip())
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 +486,16 @@ 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')
self._apply_config(parsed, reload_only=False)
return True return True
# ===================== CLIENTS ===================== # ===================== CLIENTS =====================
@@ -369,6 +504,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 +550,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 +561,16 @@ 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.
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