3195787461
ubuntu 等用户免密 sudo 下宝塔已装但脚本未提权导致 ssh_command_failed;附 ssh_timeout 运维清单。 Co-authored-by: Cursor <cursoragent@cursor.com>
186 lines
6.7 KiB
Python
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
|