fix(btpanel): 非 root SSH bootstrap 自动 sudo 提权
ubuntu 等用户免密 sudo 下宝塔已装但脚本未提权导致 ssh_command_failed;附 ssh_timeout 运维清单。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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}
|
||||
```
|
||||
@@ -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 <IP> 22
|
||||
```
|
||||
@@ -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:
|
||||
|
||||
@@ -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 "")
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user