"""SSH remote bootstrap: enable Baota API, token, whitelist, base_url.""" from __future__ import annotations import base64 import json import logging import shlex from typing import Any from server.domain.models import Server from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command logger = logging.getLogger("nexus.btpanel.ssh_bootstrap") # Runs on sub-server as root; stdout = one JSON line _REMOTE_SCRIPT = r""" import json, os, secrets, sys, subprocess PANEL_ROOT = "/www/server/panel" DATA_DIR = os.path.join(PANEL_ROOT, "data") API_PATH = os.path.join(DATA_DIR, "api.json") def read_text(path, default=""): try: with open(path, "r", encoding="utf-8") as f: return f.read().strip() except FileNotFoundError: return default def emit(obj): print(json.dumps(obj, ensure_ascii=False)) sys.exit(0) def main(): if len(sys.argv) < 2: emit({"ok": False, "error": "bad_args", "message": "missing payload"}) try: payload = json.loads(sys.argv[1]) except json.JSONDecodeError: emit({"ok": False, "error": "bad_args", "message": "invalid payload"}) center_ip = (payload.get("center_ip") or "").strip() panel_host = (payload.get("panel_host") or "").strip() if not center_ip: emit({"ok": False, "error": "missing_center_ip", "message": "center_ip required"}) if not os.path.isdir(PANEL_ROOT): emit({"ok": False, "error": "bt_not_installed", "message": "panel not installed"}) port = read_text(os.path.join(DATA_DIR, "port.pl"), "8888") or "8888" admin_path = read_text(os.path.join(DATA_DIR, "admin_path.pl"), "") ssl = os.path.exists(os.path.join(DATA_DIR, "ssl.pl")) scheme = "https" if ssl else "http" host = panel_host or "127.0.0.1" base = f"{scheme}://{host}:{port}" if admin_path and admin_path != "/": base += "/" + admin_path.strip("/") api = {} if os.path.isfile(API_PATH): try: with open(API_PATH, "r", encoding="utf-8") as f: api = json.load(f) except Exception: api = {} actions = [] token = (api.get("token") or "").strip() if not token: token = secrets.token_hex(16) actions.append("created_token") if not api.get("open"): actions.append("enabled_api") api["open"] = True api["token"] = token limit = api.get("limit_addr") if not isinstance(limit, list): limit = [] if center_ip not in limit: limit.append(center_ip) actions.append("appended_whitelist") api["limit_addr"] = limit tmp = API_PATH + ".nexus.tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(api, f, ensure_ascii=False) os.chmod(tmp, 0o600) os.replace(tmp, API_PATH) try: subprocess.run(["/etc/init.d/bt", "reload"], capture_output=True, timeout=30) except Exception: pass emit({ "ok": True, "base_url": base, "api_key": token, "actions": actions, "verify_ssl": False, }) if __name__ == "__main__": main() """ def build_bootstrap_command(*, center_ip: str, panel_host: str) -> str: payload = json.dumps({"center_ip": center_ip, "panel_host": panel_host}, ensure_ascii=False) script_b64 = base64.b64encode(_REMOTE_SCRIPT.encode("utf-8")).decode("ascii") return ( f"echo {shlex.quote(script_b64)} | base64 -d > /tmp/nexus_bt_bootstrap.py && " f"python3 /tmp/nexus_bt_bootstrap.py {shlex.quote(payload)}; " f"rc=$?; rm -f /tmp/nexus_bt_bootstrap.py; exit $rc" ) def classify_ssh_result(result: dict[str, Any]) -> tuple[str, bool]: """Return (error_code, permanent).""" status = result.get("status") or "" stderr = (result.get("stderr") or "").lower() stdout = (result.get("stdout") or "").lower() if status == "connection_error": return "ssh_connection_failed", False if status == "timeout": return "ssh_timeout", False # SSH 认证失败(勿把远程脚本写 api.json 的 PermissionError 误判为永久失败) auth_markers = ( "permission denied (publickey)", "permission denied (password)", "permission denied, please try again", "authentication failed", "auth fail", "no more authentication methods", ) if any(m in stderr for m in auth_markers): return "ssh_auth_failed", True if status != "success" or int(result.get("exit_code") or 1) != 0: if "permission denied" in stdout and "api.json" in stdout: return "ssh_permission_denied", False return "ssh_command_failed", False return "", False def parse_bootstrap_stdout(stdout: str) -> dict[str, Any]: text = (stdout or "").strip() if not text: raise ValueError("empty bootstrap output") line = text.splitlines()[-1].strip() data = json.loads(line) if not isinstance(data, dict): raise ValueError("bootstrap output not object") if not data.get("ok"): code = str(data.get("error") or "bootstrap_failed") raise BootstrapRemoteError(code, str(data.get("message") or code)) return data class BootstrapRemoteError(Exception): def __init__(self, code: str, message: str): super().__init__(message) self.code = code async def ssh_bootstrap_panel(server: Server, *, center_ip: str) -> dict[str, Any]: cmd = build_bootstrap_command(center_ip=center_ip, panel_host=(server.domain or "").strip()) result = await exec_ssh_command(server, cmd, timeout=60, login_shell=False) err_code, permanent = classify_ssh_result(result) if err_code: raise BootstrapRemoteError(err_code, result.get("stderr") or err_code) try: return parse_bootstrap_stdout(result.get("stdout") or "") except BootstrapRemoteError: raise except (json.JSONDecodeError, ValueError) as exc: logger.warning("bootstrap parse failed server=%s: %s stdout=%s", server.id, exc, (result.get("stdout") or "")[:200]) raise BootstrapRemoteError("bootstrap_parse_failed", str(exc)) from exc