Files
Nexus/server/infrastructure/btpanel/ssh_bootstrap.py
T
r 3195787461 fix(btpanel): 非 root SSH bootstrap 自动 sudo 提权
ubuntu 等用户免密 sudo 下宝塔已装但脚本未提权导致 ssh_command_failed;附 ssh_timeout 运维清单。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 10:40:46 +08:00

186 lines
6.7 KiB
Python

"""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(PANEL_ROOT, "config", "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}"
# API 根路径不含安全入口;浏览器登录 URL 由 ssh_login 单独探测 admin_path
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 wrap_sudo_nopasswd(command: str, ssh_user: str | None) -> str:
"""Run remote command as root when SSH user is non-root (passwordless sudo)."""
user = (ssh_user or "root").strip() or "root"
if user == "root":
return command
return f"sudo -n bash -c {shlex.quote(command)}"
def classify_ssh_result(result: dict[str, Any]) -> tuple[str, bool]:
"""Return (error_code, permanent)."""
status = result.get("status") or ""
stderr_full = (result.get("stderr") or "")
stderr = stderr_full.lower()
stdout = (result.get("stdout") or "").lower()
if "没有可用的 ssh 凭据" in stderr_full or "没有可用的 SSH 凭据" in stderr_full:
return "ssh_no_credentials", True
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 "a password is required" in stderr and "sudo" in stderr:
return "ssh_sudo_required", True
exit_raw = result.get("exit_code")
exit_code = int(exit_raw) if exit_raw is not None else 1
if status != "success" or exit_code != 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())
cmd = wrap_sudo_nopasswd(cmd, server.username)
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