diff --git a/docs/changelog/2026-06-21-btpanel-bootstrap-sudo.md b/docs/changelog/2026-06-21-btpanel-bootstrap-sudo.md new file mode 100644 index 00000000..f7895d43 --- /dev/null +++ b/docs/changelog/2026-06-21-btpanel-bootstrap-sudo.md @@ -0,0 +1,28 @@ +# 2026-06-21 宝塔 bootstrap 非 root SSH 自动 sudo + +## 摘要 + +ubuntu 等非 root SSH 用户(如 `preset_id=2`)可连机且宝塔已装,但 bootstrap 脚本未提权导致读 `port.pl` 失败,批量报 `ssh_command_failed`。现对 bootstrap、宝塔检测、面板地址探测、临时登录 SSH 统一 `sudo -n bash -c`。 + +## 动机 + +生产抽样 #135/#136/#147:SSH✅、`sudo -n`✅、root 下宝塔✅,非 sudo bootstrap 崩溃。 + +## 涉及文件 + +- `server/infrastructure/btpanel/ssh_bootstrap.py` — `wrap_sudo_nopasswd`、`ssh_sudo_required` +- `server/infrastructure/btpanel/ssh_login.py` — 检测/探测/登录命令提权 +- `tests/test_btpanel_ssh_bootstrap.py` +- `docs/reports/2026-06-21-btpanel-ssh-timeout-servers.md` — 超时机器运维清单 + +## 迁移 / 重启 + +- 部署 API 后对未配置服务器重跑批量 bootstrap。 + +## 验证 + +```bash +.venv/bin/pytest tests/test_btpanel_ssh_bootstrap.py tests/test_btpanel_ssh_login.py -q +POST /api/btpanel/servers/135/bootstrap # ubuntu 抽样 +POST /api/btpanel/servers/batch-bootstrap {"all_unconfigured": true} +``` diff --git a/docs/reports/2026-06-21-btpanel-ssh-timeout-servers.md b/docs/reports/2026-06-21-btpanel-ssh-timeout-servers.md new file mode 100644 index 00000000..554643c3 --- /dev/null +++ b/docs/reports/2026-06-21-btpanel-ssh-timeout-servers.md @@ -0,0 +1,37 @@ +# 宝塔 bootstrap SSH 超时机器清单 + +生成时间:2026-06-21 02:40 UTC + +## 说明 + +以下服务器在批量 bootstrap(任务 #119 / #121)中报告 `ssh_timeout`:中心机 SSH 在超时内无响应。 +请在云平台核对:实例是否运行、公网 IP 是否与 Nexus `domain` 一致、安全组是否放行 **22** 端口。 + +**合计:17 台** + +| ID | 名称 | SSH IP (domain) | preset_id | 分类 | 失败任务 | +|----|------|-----------------|-----------|------|----------| +| 4 | 武汉念曦焱网络科技有限公司 | 8.163.90.186 | — | 商城-待扫 | #119,#121 | +| 22 | 广州申迹网络科技有限公司 | 47.107.163.227 | — | 鞋靴 | #119,#121 | +| 24 | 子-东莞启楷网络科技有限公司 | 42.121.221.178 | — | 情趣/成人 | #119,#121 | +| 41 | 海南铭维网络科技有限公司-子 | 120.79.223.248 | — | 商城-待扫 | #119,#121 | +| 51 | 安庆驰探电子商务有限公司-子 | 120.25.48.230 | — | 商城-待扫 | #119,#121 | +| 101 | 东莞炎且网络科技有限公司 | 47.121.181.238 | — | 情趣/成人 | #119,#121 | +| 200 | 湖北昂翊电子商务有限公司 | 47.96.165.168 | — | 商城-待扫 | #119,#121 | +| 225 | 武汉市运来万电子有限公司 | 47.113.192.161 | — | 机器人 | #119,#121 | +| 313 | 武汉市温胜夜科技有限公司 | 42.193.171.106 | — | 包袋/配饰 | #119,#121 | +| 314 | 子-武汉市温胜夜科技有限公司 | 159.75.124.238 | — | 包袋/配饰 | #119,#121 | +| 315 | 漫窗星(武汉市)电子商务有限公司 | 159.75.25.162 | — | 机器人 | #119,#121 | +| 316 | 子-漫窗星(武汉市)电子商务有限公司 | 111.230.99.21 | — | 机器人 | #119,#121 | +| 317 | 武汉市雾霜月百货有限公司 | 129.204.187.80 | — | 机器人 | #119,#121 | +| 318 | 子-武汉市雾霜月百货有限公司 | 1.14.158.107 | — | 机器人 | #119,#121 | +| 332 | 湖北优点鼎电子商务有限公司 | 47.121.187.133 | — | 机器人 | #119,#121 | +| 367 | 冲量夏序 | 120.79.11.13 | — | 商城-待扫 | #119,#121 | +| 371 | 冲量银海-泽初 | 47.107.181.56 | — | 鞋靴 | #119,#121 | + +## 运维检查命令(中心机) + +```bash +# 替换 IP +nc -zv -w 5 22 +``` diff --git a/server/infrastructure/btpanel/ssh_bootstrap.py b/server/infrastructure/btpanel/ssh_bootstrap.py index 6afcee71..b4213e06 100644 --- a/server/infrastructure/btpanel/ssh_bootstrap.py +++ b/server/infrastructure/btpanel/ssh_bootstrap.py @@ -107,6 +107,14 @@ def build_bootstrap_command(*, center_ip: str, panel_host: str) -> str: ) +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 "" @@ -130,6 +138,8 @@ def classify_ssh_result(result: dict[str, Any]) -> tuple[str, bool]: ) 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: @@ -161,6 +171,7 @@ class BootstrapRemoteError(Exception): 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: diff --git a/server/infrastructure/btpanel/ssh_login.py b/server/infrastructure/btpanel/ssh_login.py index 6cd7e1e4..3e538e74 100644 --- a/server/infrastructure/btpanel/ssh_login.py +++ b/server/infrastructure/btpanel/ssh_login.py @@ -7,6 +7,7 @@ import re from urllib.parse import urlparse from server.domain.models import Server +from server.infrastructure.btpanel.ssh_bootstrap import wrap_sudo_nopasswd from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command logger = logging.getLogger("nexus.btpanel.ssh") @@ -42,7 +43,8 @@ DETECT_BT_CMD = "test -f /www/server/panel/class/common.py && echo BT_OK || echo async def detect_bt_panel_installed(server: Server) -> bool: - result = await exec_ssh_command(server, DETECT_BT_CMD, timeout=20) + cmd = wrap_sudo_nopasswd(DETECT_BT_CMD, server.username) + result = await exec_ssh_command(server, cmd, timeout=20) if result.get("status") != "success": return False return "BT_OK" in (result.get("stdout") or "") @@ -98,7 +100,7 @@ def _extract_temp_login_line(stdout: str) -> str: async def _discover_panel_base_url(server: Server) -> str: host = (server.domain or "127.0.0.1").strip() - cmd = SSH_PANEL_BASE_CMD.replace("__HOST__", host) + cmd = wrap_sudo_nopasswd(SSH_PANEL_BASE_CMD.replace("__HOST__", host), server.username) result = await exec_ssh_command(server, cmd, timeout=25) if result.get("status") != "success": raise RuntimeError(result.get("stderr") or result.get("stdout") or "SSH 探测面板地址失败") @@ -122,7 +124,8 @@ async def resolve_temp_login_url(server: Server, raw: str) -> str: async def ssh_temp_login_url(server: Server, *, base_url: str | None = None) -> str: - result = await exec_ssh_command(server, SSH_TEMP_LOGIN_CMD, timeout=45) + cmd = wrap_sudo_nopasswd(SSH_TEMP_LOGIN_CMD, server.username) + result = await exec_ssh_command(server, cmd, timeout=45) if result.get("status") != "success": raise RuntimeError(result.get("stderr") or result.get("stdout") or "SSH 执行失败") line = _extract_temp_login_line(result.get("stdout") or "") diff --git a/tests/test_btpanel_ssh_bootstrap.py b/tests/test_btpanel_ssh_bootstrap.py index f9d2b30f..d717de97 100644 --- a/tests/test_btpanel_ssh_bootstrap.py +++ b/tests/test_btpanel_ssh_bootstrap.py @@ -21,6 +21,7 @@ from server.infrastructure.btpanel.ssh_bootstrap import ( build_bootstrap_command, classify_ssh_result, parse_bootstrap_stdout, + wrap_sudo_nopasswd, ) from server.domain.models import Server @@ -140,6 +141,28 @@ def test_build_bootstrap_command_includes_payload(): assert "nexus_bt_bootstrap.py" in cmd +def test_wrap_sudo_nopasswd_root_unchanged(): + inner = "echo hi" + assert wrap_sudo_nopasswd(inner, "root") == inner + + +def test_wrap_sudo_nopasswd_ubuntu(): + inner = "echo hi" + wrapped = wrap_sudo_nopasswd(inner, "ubuntu") + assert wrapped.startswith("sudo -n bash -c ") + assert inner in wrapped + + +def test_classify_sudo_password_required(): + code, permanent = classify_ssh_result({ + "status": "success", + "exit_code": 1, + "stderr": "sudo: a password is required", + }) + assert code == "ssh_sudo_required" + assert permanent is True + + def test_classify_ssh_result_success_exit_zero(): ok_stdout = json.dumps( {"ok": True, "base_url": "https://x:8888", "api_key": "t", "actions": [], "verify_ssl": False}