Template
Fix mita crash loop when server config has no users.
This commit is contained in:
@@ -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.7"
|
CURRENT_VERSION = "v2.6.8"
|
||||||
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'))
|
||||||
|
|||||||
+179
-50
@@ -22,6 +22,11 @@ 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'
|
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):
|
def _q(value):
|
||||||
@@ -179,30 +184,80 @@ 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):
|
def _make_bootstrap_client(self):
|
||||||
"""Ensure mita systemd unit is up and RPC socket answers."""
|
return {
|
||||||
self.ssh.run_sudo_command(
|
'id': secrets.token_hex(8),
|
||||||
f"systemctl enable {self.SERVICE_NAME} 2>/dev/null || true",
|
'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,
|
timeout=30,
|
||||||
)
|
)
|
||||||
self.ssh.run_sudo_command(
|
return 'no user found' in (journal or '').lower()
|
||||||
f"systemctl start {self.SERVICE_NAME} 2>/dev/null || "
|
|
||||||
f"systemctl restart {self.SERVICE_NAME} 2>/dev/null || true",
|
def _heal_mita_store(self, log=None):
|
||||||
timeout=60,
|
"""Break the systemd crash loop caused by empty users in server.conf.pb.
|
||||||
)
|
|
||||||
# Official package expects the operating user in group `mita`.
|
`mita run` auto-starts the proxy when portBindings exist; with zero users
|
||||||
user_out, _, _ = self.ssh.run_command('id -un 2>/dev/null || echo root')
|
it FATAL-exits and never keeps the RPC socket up for `mita apply`.
|
||||||
op_user = (user_out or 'root').strip() or 'root'
|
Wiping the store lets the daemon stay IDLE so we can re-apply a valid config.
|
||||||
if op_user != 'root':
|
"""
|
||||||
self.ssh.run_sudo_command(
|
if log is not None:
|
||||||
f"usermod -a -G mita {_q(op_user)} 2>/dev/null || true",
|
log.append('healing mita store (empty users / crash loop)')
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
if not self._wait_for_rpc(timeout=45):
|
|
||||||
# Stale socket / crashed daemon — hard restart once.
|
|
||||||
self.ssh.run_sudo_command(
|
self.ssh.run_sudo_command(
|
||||||
f"systemctl stop {self.SERVICE_NAME} 2>/dev/null || true; "
|
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 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}",
|
f"systemctl start {self.SERVICE_NAME}",
|
||||||
timeout=60,
|
timeout=60,
|
||||||
)
|
)
|
||||||
@@ -212,9 +267,60 @@ class MieruManager:
|
|||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
'mita systemd daemon is not ready (RPC socket missing). '
|
'mita daemon still not ready after heal. '
|
||||||
f'journal: {(journal or "").strip()[-500:]}'
|
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,
|
||||||
|
)
|
||||||
|
# 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 / 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:
|
if log is not None:
|
||||||
log.append('mita daemon is active')
|
log.append('mita daemon is active')
|
||||||
|
|
||||||
@@ -242,6 +348,7 @@ class MieruManager:
|
|||||||
return self.ssh.run_sudo_command(cmd, timeout=timeout)
|
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 not c.get('enabled', True):
|
if not c.get('enabled', True):
|
||||||
@@ -251,12 +358,10 @@ class MieruManager:
|
|||||||
if not username or not password:
|
if not username or not password:
|
||||||
continue
|
continue
|
||||||
users.append({'name': username, 'password': password})
|
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:
|
if not users:
|
||||||
users = [{
|
bootstrap = self._make_bootstrap_client()
|
||||||
'name': f'panel_{_rand_token(6)}',
|
users = [{'name': bootstrap['username'], 'password': bootstrap['password']}]
|
||||||
'password': _rand_token(20),
|
|
||||||
}]
|
|
||||||
return {
|
return {
|
||||||
'portBindings': [{'port': int(port), 'protocol': 'TCP'}],
|
'portBindings': [{'port': int(port), 'protocol': 'TCP'}],
|
||||||
'users': users,
|
'users': users,
|
||||||
@@ -305,25 +410,51 @@ class MieruManager:
|
|||||||
))
|
))
|
||||||
|
|
||||||
def _restart_proxy(self):
|
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)
|
self._mita_cli(['stop'], timeout=30)
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
last_err = ''
|
last_err = ''
|
||||||
for attempt in range(1, 4):
|
for attempt in range(1, 4):
|
||||||
out, err, code = self._mita_cli(['start'], timeout=60)
|
out, err, code = self._mita_cli(['start'], timeout=60)
|
||||||
if code == 0:
|
if code == 0:
|
||||||
# Confirm RUNNING (daemon may report success then die).
|
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
if self._proxy_running():
|
if self._proxy_running():
|
||||||
return
|
return
|
||||||
last_err = (out or err or 'mita start returned ok but status is not RUNNING').strip()
|
last_err = (out or err or 'mita start returned ok but status is not RUNNING').strip()
|
||||||
else:
|
else:
|
||||||
last_err = (err or out or 'mita start failed').strip()
|
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:
|
if self._is_rpc_error(last_err) or attempt < 3:
|
||||||
self.ssh.run_sudo_command(
|
self.ssh.run_sudo_command(
|
||||||
f"systemctl restart {self.SERVICE_NAME} 2>/dev/null || true",
|
f"systemctl restart {self.SERVICE_NAME} 2>/dev/null || true",
|
||||||
timeout=60,
|
timeout=60,
|
||||||
)
|
)
|
||||||
self._wait_for_rpc(timeout=30)
|
if not self._wait_for_rpc(timeout=30):
|
||||||
|
self._heal_mita_store()
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
continue
|
continue
|
||||||
break
|
break
|
||||||
@@ -338,7 +469,8 @@ class MieruManager:
|
|||||||
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)
|
||||||
|
|
||||||
@@ -440,16 +572,7 @@ fi
|
|||||||
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)
|
||||||
# Keep clients empty in panel DB, but seed a real mita user so start works.
|
bootstrap = self._make_bootstrap_client()
|
||||||
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])
|
self._write_clients([bootstrap])
|
||||||
log.append(f'Prepared {self.base_dir}')
|
log.append(f'Prepared {self.base_dir}')
|
||||||
|
|
||||||
@@ -474,7 +597,8 @@ fi
|
|||||||
|
|
||||||
def start_service(self):
|
def start_service(self):
|
||||||
self._ensure_daemon()
|
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):
|
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)
|
||||||
@@ -495,6 +619,19 @@ fi
|
|||||||
raise RuntimeError(f'Invalid JSON config: {e}') from e
|
raise RuntimeError(f'Invalid JSON config: {e}') from e
|
||||||
if not isinstance(parsed, dict):
|
if not isinstance(parsed, dict):
|
||||||
raise RuntimeError('Config must be a JSON object')
|
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)
|
self._apply_config(parsed, reload_only=False)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -561,16 +698,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]
|
||||||
# Keep at least bootstrap so mita never has empty users.
|
clients = self._ensure_bootstrap_clients(clients)
|
||||||
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
|
||||||
@@ -580,6 +708,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
|
||||||
|
|||||||
Reference in New Issue
Block a user