From 53bffbd4fcb446ebe8c3cfd0c8a390346a32baa6 Mon Sep 17 00:00:00 2001 From: orohi Date: Tue, 28 Jul 2026 14:39:56 +0300 Subject: [PATCH] Fix Mieru mita start RPC EOF by waiting for daemon socket and seeding users. Co-authored-by: Cursor --- README.md | 3 + app.py | 2 +- managers/mieru_manager.py | 255 ++++++++++++++++++++++++++++++-------- 3 files changed, 205 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 1b11d95..e63f62b 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/app.py b/app.py index bae13df..87dfc49 100644 --- a/app.py +++ b/app.py @@ -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.5" 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')) diff --git a/managers/mieru_manager.py b/managers/mieru_manager.py index 0adf1c9..e75694e 100644 --- a/managers/mieru_manager.py +++ b/managers/mieru_manager.py @@ -14,12 +14,14 @@ 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}' +MITA_SOCK = '/var/run/mita.sock' def _q(value): @@ -144,7 +146,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 +179,84 @@ class MieruManager: def _write_clients(self, clients): 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): 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 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 { 'portBindings': [{'port': int(port), 'protocol': 'TCP'}], 'users': users, @@ -192,20 +265,75 @@ 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): + 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): meta = self._read_metadata() @@ -261,10 +389,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 +430,51 @@ 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) + # 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._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() + self._restart_proxy() def stop_service(self): 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) 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') + self._apply_config(parsed, reload_only=False) return True # ===================== CLIENTS ===================== @@ -369,6 +504,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 +550,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 +561,16 @@ fi def remove_client(self, protocol_type, client_id): clients = [c for c in self._read_clients() if c.get('id') != client_id] + # Keep at least bootstrap so mita never has empty users. + if not any(not c.get('bootstrap') for c in clients) and not any(c.get('bootstrap') for c in clients): + clients.append({ + 'id': secrets.token_hex(8), + 'name': 'panel-bootstrap', + 'username': f'panel_{_rand_token(6)}', + 'password': _rand_token(20), + 'enabled': True, + 'bootstrap': True, + }) self._write_clients(clients) self._sync_server(reload_only=True) return True