fix(btpanel): 未装宝塔时一键登录返回友好提示
Nexus CI/CD / test (push) Waiting to run
Nexus CI/CD / e2e (push) Blocked by required conditions
Nexus CI/CD / deploy (push) Blocked by required conditions
Nexus Pre-commit Checks / quick-check (push) Waiting to run

SSH/API 登录前检测面板;将 public 模块 Traceback 映射为可读中文;
bootstrap bt_not_installed 不再回退 SSH。AGENTS 约定改完推 Gitea。
This commit is contained in:
r
2026-07-09 01:25:54 +08:00
parent 9b583ccc91
commit a16ba169fd
7 changed files with 117 additions and 9 deletions
+7 -7
View File
@@ -19,13 +19,12 @@ cd frontend && npm run dev # :3000/app/
路径见 [linux-dev-paths.md](docs/project/linux-dev-paths.md)。 路径见 [linux-dev-paths.md](docs/project/linux-dev-paths.md)。
## Git本机快照 ## Git(本机 + Gitea
- 本地 `git commit` 用于回滚;**不 `git push`**(除非你明确要求) - 改完代码:**`git commit` `bash scripts/git-push.sh` 推到 Gitea**(凭据 `deploy/nexus-1panel.secrets.sh`);大仓库可用 `scripts/git-push-axs-full.sh`
- 已移除 `origin` 跟踪,避免误推;原远程:`http://66.154.115.8:3000/admin/Nexus.git`(需恢复时说一声)。 - 本地 commit 仍用于回滚;**不要漏推 Gitea**(用户约定 2026-07-09)。
- `.env``SECRETS.md``*.pem` 已在 `.gitignore`**勿提交**。 - `.env``SECRETS.md``*.pem``deploy/nexus-1panel.secrets.sh` 已在 `.gitignore`**勿提交**。
- 改代码仍写 `docs/changelog/YYYY-MM-DD-*.md`(≥10 行)。 - 改代码仍写 `docs/changelog/YYYY-MM-DD-*.md`(≥10 行)。
- 用户说「提交快照 / commit」时执行。
## 强制约束 ## 强制约束
@@ -40,14 +39,15 @@ cd frontend && npm run dev # :3000/app/
## 部署 ## 部署
默认 **本机 rsync → SSH 生产机**(不 push Gitea): 默认 **本机 rsync → SSH 生产机****代码先 push Gitea 再部署**(见上节 Git):
```bash ```bash
bash deploy/pre_deploy_check.sh bash deploy/pre_deploy_check.sh
bash scripts/git-push.sh # 或 git-push-axs-full.sh
bash deploy/deploy-production.sh # rsync + Docker upgrade --skip-git + 前端 bash deploy/deploy-production.sh # rsync + Docker upgrade --skip-git + 前端
``` ```
可选:`NEXUS_DEPLOY_VIA_GIT=1` 恢复远程 `git pull``git push` 当你明确要求时。 可选:`NEXUS_DEPLOY_VIA_GIT=1` 恢复远程 `git pull`
详情见功能指南 §1718。 详情见功能指南 §1718。
@@ -0,0 +1,28 @@
# 宝塔未安装时一键登录友好提示
**日期**2026-07-09
## 摘要
未安装宝塔的子机点击「宝塔一键登录」时,不再抛出 Python `ModuleNotFoundError: public` 堆栈,改为明确中文:`该机未检测到宝塔面板…`
## 动机
运维误对无宝塔机器点登录,SSH 回退脚本 `import public` 失败,前端只显示晦涩 Traceback。
## 涉及文件
| 路径 | 说明 |
|------|------|
| `server/infrastructure/btpanel/ssh_login.py` | `BtPanelNotInstalledError``humanize_ssh_login_error`、SSH 前检测 `common.py` |
| `server/application/services/btpanel_service.py` | bootstrap `bt_not_installed` 不再回退 SSH |
| `server/api/btpanel.py` | 400 + 友好 `detail` |
| `tests/test_btpanel_ssh_login.py` | 未安装 / public 模块报错映射 |
## 验证
```bash
.venv/bin/pytest tests/test_btpanel_ssh_login.py tests/test_btpanel_temp_login_ttl.py -q
```
对未装宝塔机器 `POST /api/btpanel/servers/{id}/login-url` → HTTP 400,detail 含「未检测到宝塔面板」。
+3
View File
@@ -28,6 +28,7 @@ from server.api.dependencies import get_db
from server.application.services.btpanel_service import BtPanelService from server.application.services.btpanel_service import BtPanelService
from server.domain.models import Admin from server.domain.models import Admin
from server.infrastructure.btpanel.client import BtPanelApiError from server.infrastructure.btpanel.client import BtPanelApiError
from server.infrastructure.btpanel.ssh_login import BtPanelNotInstalledError
logger = logging.getLogger("nexus.api.btpanel") logger = logging.getLogger("nexus.api.btpanel")
@@ -43,6 +44,8 @@ def _svc(db: AsyncSession) -> BtPanelService:
def _http_error(exc: Exception) -> HTTPException: def _http_error(exc: Exception) -> HTTPException:
if isinstance(exc, BtPanelNotInstalledError):
return HTTPException(status_code=400, detail=str(exc))
if isinstance(exc, ValueError): if isinstance(exc, ValueError):
return HTTPException(status_code=400, detail=str(exc)) return HTTPException(status_code=400, detail=str(exc))
if isinstance(exc, BtPanelApiError): if isinstance(exc, BtPanelApiError):
@@ -37,6 +37,7 @@ from server.infrastructure.btpanel.constants import (
BT_TEMP_LOGIN_TTL_SECONDS, BT_TEMP_LOGIN_TTL_SECONDS,
) )
from server.infrastructure.btpanel.ssh_login import ( from server.infrastructure.btpanel.ssh_login import (
BtPanelNotInstalledError,
detect_bt_panel_installed, detect_bt_panel_installed,
normalize_temp_login_url, normalize_temp_login_url,
resolve_temp_login_url, resolve_temp_login_url,
@@ -502,6 +503,8 @@ class BtPanelService:
) )
if not result.get("ok") and not result.get("skipped"): if not result.get("ok") and not result.get("skipped"):
code = str(result.get("error") or "bootstrap_failed") code = str(result.get("error") or "bootstrap_failed")
if code == "bt_not_installed":
raise BtPanelNotInstalledError()
if code in BOOTSTRAP_LOGIN_HARD_FAIL_ERRORS: if code in BOOTSTRAP_LOGIN_HARD_FAIL_ERRORS:
raise ValueError(f"获取宝塔 API 失败: {code}") raise ValueError(f"获取宝塔 API 失败: {code}")
logger.warning( logger.warning(
+46 -2
View File
@@ -16,11 +16,38 @@ from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
logger = logging.getLogger("nexus.btpanel.ssh") logger = logging.getLogger("nexus.btpanel.ssh")
_BT_PANEL_PYTHON = "/www/server/panel/pyenv/bin/python" _BT_PANEL_PYTHON = "/www/server/panel/pyenv/bin/python"
_BT_PANEL_NOT_INSTALLED_MSG = (
"该机未检测到宝塔面板,无法一键登录。"
"请先在子机安装宝塔面板,或仅使用 Nexus 终端/文件功能。"
)
_FULL_LOGIN_RE = re.compile(r"^https?://\S+.*tmp_token=", re.IGNORECASE) _FULL_LOGIN_RE = re.compile(r"^https?://\S+.*tmp_token=", re.IGNORECASE)
_REL_LOGIN_RE = re.compile(r"^/?login\?tmp_token=\S+", re.IGNORECASE) _REL_LOGIN_RE = re.compile(r"^/?login\?tmp_token=\S+", re.IGNORECASE)
_BT_NOT_INSTALLED_RE = re.compile(
r"BT_PANEL_NOT_INSTALLED|BT_NO|bt_not_installed|panel not installed|"
r"No module named ['\"]public['\"]|ModuleNotFoundError:.*\bpublic\b",
re.IGNORECASE,
)
class BtPanelNotInstalledError(RuntimeError):
"""Raised when SSH/API temp login requires Baota but panel is absent."""
def __init__(self, message: str | None = None) -> None:
super().__init__(message or _BT_PANEL_NOT_INSTALLED_MSG)
def humanize_ssh_login_error(stdout: str = "", stderr: str = "") -> str | None:
"""Map remote Python/shell noise to operator-facing Chinese."""
blob = f"{stdout}\n{stderr}".strip()
if not blob:
return None
if _BT_NOT_INSTALLED_RE.search(blob):
return _BT_PANEL_NOT_INSTALLED_MSG
return None
_BT_TEMP_LOGIN_SCRIPT = """import os, sys, time _BT_TEMP_LOGIN_SCRIPT = """import os, sys, time
sys.path.insert(0, "/www/server/panel") sys.path.insert(0, "/www/server/panel")
sys.path.insert(0, "/www/server/panel/class")
import public import public
ttl = int({ttl}) ttl = int({ttl})
host = {host!r} host = {host!r}
@@ -55,7 +82,8 @@ def build_ssh_temp_login_command(server: Server, *, ttl_seconds: int = BT_TEMP_L
script = _BT_TEMP_LOGIN_SCRIPT.format(ttl=ttl_seconds, host=host) script = _BT_TEMP_LOGIN_SCRIPT.format(ttl=ttl_seconds, host=host)
encoded = base64.b64encode(script.encode()).decode() encoded = base64.b64encode(script.encode()).decode()
inner = ( inner = (
"test -d /www/server/panel && " "test -f /www/server/panel/class/common.py || "
"{ echo BT_PANEL_NOT_INSTALLED >&2; exit 2; }; "
f"P={_BT_PANEL_PYTHON}; test -x \"$P\" || P=python3; " f"P={_BT_PANEL_PYTHON}; test -x \"$P\" || P=python3; "
f"echo {shlex.quote(encoded)} | base64 -d | \"$P\"" f"echo {shlex.quote(encoded)} | base64 -d | \"$P\""
) )
@@ -210,18 +238,34 @@ async def resolve_temp_login_url(server: Server, raw: str) -> str:
) )
def _raise_ssh_login_failure(result: dict) -> None:
stdout = result.get("stdout") or ""
stderr = result.get("stderr") or ""
friendly = humanize_ssh_login_error(stdout, stderr)
if friendly:
raise BtPanelNotInstalledError(friendly)
raise RuntimeError(stderr or stdout or "SSH 执行失败")
async def ssh_temp_login_url( async def ssh_temp_login_url(
server: Server, server: Server,
*, *,
base_url: str | None = None, base_url: str | None = None,
ttl_seconds: int = BT_TEMP_LOGIN_TTL_SECONDS, ttl_seconds: int = BT_TEMP_LOGIN_TTL_SECONDS,
) -> str: ) -> str:
if not await detect_bt_panel_installed(server):
raise BtPanelNotInstalledError()
cmd = build_ssh_temp_login_command(server, ttl_seconds=ttl_seconds) cmd = build_ssh_temp_login_command(server, ttl_seconds=ttl_seconds)
result = await exec_ssh_command(server, cmd, timeout=45) result = await exec_ssh_command(server, cmd, timeout=45)
if result.get("status") != "success": if result.get("status") != "success":
raise RuntimeError(result.get("stderr") or result.get("stdout") or "SSH 执行失败") _raise_ssh_login_failure(result)
line = _extract_temp_login_line(result.get("stdout") or "") line = _extract_temp_login_line(result.get("stdout") or "")
if not line or "tmp_token=" not in line: if not line or "tmp_token=" not in line:
blob = (result.get("stdout") or "") + "\n" + (result.get("stderr") or "")
friendly = humanize_ssh_login_error(blob, "")
if friendly:
raise BtPanelNotInstalledError(friendly)
raise RuntimeError( raise RuntimeError(
(result.get("stdout") or result.get("stderr") or "SSH 未返回临时登录链接")[:200] (result.get("stdout") or result.get("stderr") or "SSH 未返回临时登录链接")[:200]
) )
+29
View File
@@ -6,7 +6,9 @@ import pytest
from server.domain.models import Server from server.domain.models import Server
from server.infrastructure.btpanel.ssh_login import ( from server.infrastructure.btpanel.ssh_login import (
BtPanelNotInstalledError,
detect_bt_panel_installed, detect_bt_panel_installed,
humanize_ssh_login_error,
normalize_temp_login_url, normalize_temp_login_url,
_extract_temp_login_line, _extract_temp_login_line,
ssh_temp_login_url, ssh_temp_login_url,
@@ -67,10 +69,37 @@ def test_normalize_temp_login_full_url_rewrites_host():
assert url == "https://5.6.7.8:8888/login?tmp_token=abc123" assert url == "https://5.6.7.8:8888/login?tmp_token=abc123"
def test_humanize_ssh_login_error_public_module():
raw = (
'Traceback (most recent call last):\n'
' File "<stdin>", line 4, in <module>\n'
"ModuleNotFoundError: No module named 'public'\n"
)
msg = humanize_ssh_login_error(stderr=raw)
assert msg is not None
assert "未检测到宝塔面板" in msg
@pytest.mark.asyncio
async def test_ssh_temp_login_url_rejects_when_panel_missing():
server = Server(id=1, name="s", domain="1.2.3.4")
with patch(
"server.infrastructure.btpanel.ssh_login.detect_bt_panel_installed",
new_callable=AsyncMock,
return_value=False,
):
with pytest.raises(BtPanelNotInstalledError):
await ssh_temp_login_url(server)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_ssh_temp_login_url_accepts_relative_path(): async def test_ssh_temp_login_url_accepts_relative_path():
server = Server(id=1, name="s", domain="5.6.7.8") server = Server(id=1, name="s", domain="5.6.7.8")
with patch( with patch(
"server.infrastructure.btpanel.ssh_login.detect_bt_panel_installed",
new_callable=AsyncMock,
return_value=True,
), patch(
"server.infrastructure.btpanel.ssh_login.exec_ssh_command", "server.infrastructure.btpanel.ssh_login.exec_ssh_command",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value={ return_value={
+1
View File
@@ -18,6 +18,7 @@ def test_build_ssh_temp_login_command_uses_configured_ttl():
server = Server(id=1, name="s", domain="5.6.7.8") server = Server(id=1, name="s", domain="5.6.7.8")
cmd = build_ssh_temp_login_command(server, ttl_seconds=BT_TEMP_LOGIN_TTL_SECONDS) cmd = build_ssh_temp_login_command(server, ttl_seconds=BT_TEMP_LOGIN_TTL_SECONDS)
assert "get_temp_login_ipv4" not in cmd assert "get_temp_login_ipv4" not in cmd
assert "panel/class/common.py" in cmd
match = re.search(r"echo ([^ |]+) \| base64", cmd) match = re.search(r"echo ([^ |]+) \| base64", cmd)
assert match, cmd assert match, cmd
script = base64.b64decode(match.group(1)).decode() script = base64.b64decode(match.group(1)).decode()