From c0ce490a15ae6b41123e096c38667588760112ec Mon Sep 17 00:00:00 2001 From: orohi Date: Mon, 3 Aug 2026 12:10:42 +0300 Subject: [PATCH] Fix mita crash loop when server config has no users. --- app.py | 2 +- managers/mieru_manager.py | 227 ++++++++++++++++++++++++++++++-------- 2 files changed, 179 insertions(+), 50 deletions(-) diff --git a/app.py b/app.py index 9c5ee45..e7ecbf4 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.7" +CURRENT_VERSION = "v2.6.8" 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 e75694e..76d5248 100644 --- a/managers/mieru_manager.py +++ b/managers/mieru_manager.py @@ -22,6 +22,11 @@ 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' +# 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): @@ -179,17 +184,109 @@ 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)} /var/run/mita/*.sock " + f"{_q(MITA_CONFIG_PB)} {_q(MITA_CONFIG_JSON)} 2>/dev/null || true", + timeout=60, + ) + 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 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 daemon still not ready after heal. ' + f'journal: {(journal or "").strip()[-500:]}' + ) + 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", 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' @@ -198,23 +295,32 @@ class MieruManager: 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): - # 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:]}' + # Crash loop / stale socket — wipe broken store and recover. + if self._daemon_needs_heal(): + self._heal_mita_store(log) + else: + 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): + # Last resort: heal even if journal didn't match yet. + self._heal_mita_store(log) if log is not None: log.append('mita daemon is active') @@ -242,6 +348,7 @@ class MieruManager: 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 not c.get('enabled', True): @@ -251,12 +358,10 @@ class MieruManager: if not username or not password: continue users.append({'name': username, 'password': password}) - # mita rejects / crashes on empty users during `mita start` (RPC EOF). + # mita FATAL-exits on empty users during proxy start ("no user found"). if not users: - users = [{ - 'name': f'panel_{_rand_token(6)}', - 'password': _rand_token(20), - }] + bootstrap = self._make_bootstrap_client() + users = [{'name': bootstrap['username'], 'password': bootstrap['password']}] return { 'portBindings': [{'port': int(port), 'protocol': 'TCP'}], 'users': users, @@ -305,25 +410,51 @@ class MieruManager: )) 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: - # 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 '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, ) - self._wait_for_rpc(timeout=30) + if not self._wait_for_rpc(timeout=30): + self._heal_mita_store() time.sleep(1) continue break @@ -338,7 +469,8 @@ class MieruManager: 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) @@ -440,16 +572,7 @@ fi 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, - } + bootstrap = self._make_bootstrap_client() self._write_clients([bootstrap]) log.append(f'Prepared {self.base_dir}') @@ -474,7 +597,8 @@ fi def start_service(self): self._ensure_daemon() - self._restart_proxy() + # Re-apply panel clients (with bootstrap) then start — fixes empty-users store. + self._sync_server(reload_only=False) def stop_service(self): self.ssh.run_sudo_command('mita stop 2>/dev/null || true', timeout=30) @@ -495,6 +619,19 @@ fi 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 @@ -561,16 +698,7 @@ 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, - }) + clients = self._ensure_bootstrap_clients(clients) self._write_clients(clients) self._sync_server(reload_only=True) return True @@ -580,6 +708,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