fix(btpanel): 未装宝塔时一键登录返回友好提示
SSH/API 登录前检测面板;将 public 模块 Traceback 映射为可读中文; bootstrap bt_not_installed 不再回退 SSH。AGENTS 约定改完推 Gitea。
This commit is contained in:
@@ -19,13 +19,12 @@ cd frontend && npm run dev # :3000/app/
|
||||
|
||||
路径见 [linux-dev-paths.md](docs/project/linux-dev-paths.md)。
|
||||
|
||||
## Git(仅本机快照)
|
||||
## Git(本机 + Gitea)
|
||||
|
||||
- 本地 `git commit` 用于回滚;**不 `git push`**(除非你明确要求)。
|
||||
- 已移除 `origin` 跟踪,避免误推;原远程:`http://66.154.115.8:3000/admin/Nexus.git`(需恢复时说一声)。
|
||||
- `.env`、`SECRETS.md`、`*.pem` 已在 `.gitignore`,**勿提交**。
|
||||
- 改完代码:**`git commit` 后 `bash scripts/git-push.sh` 推到 Gitea**(凭据 `deploy/nexus-1panel.secrets.sh`);大仓库可用 `scripts/git-push-axs-full.sh`。
|
||||
- 本地 commit 仍用于回滚;**不要漏推 Gitea**(用户约定 2026-07-09)。
|
||||
- `.env`、`SECRETS.md`、`*.pem`、`deploy/nexus-1panel.secrets.sh` 已在 `.gitignore`,**勿提交**。
|
||||
- 改代码仍写 `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 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 + 前端
|
||||
```
|
||||
|
||||
可选:`NEXUS_DEPLOY_VIA_GIT=1` 恢复远程 `git pull`。仅 `git push` 当你明确要求时。
|
||||
可选:`NEXUS_DEPLOY_VIA_GIT=1` 恢复远程 `git pull`。
|
||||
|
||||
详情见功能指南 §17–18。
|
||||
|
||||
|
||||
@@ -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 含「未检测到宝塔面板」。
|
||||
@@ -28,6 +28,7 @@ from server.api.dependencies import get_db
|
||||
from server.application.services.btpanel_service import BtPanelService
|
||||
from server.domain.models import Admin
|
||||
from server.infrastructure.btpanel.client import BtPanelApiError
|
||||
from server.infrastructure.btpanel.ssh_login import BtPanelNotInstalledError
|
||||
|
||||
logger = logging.getLogger("nexus.api.btpanel")
|
||||
|
||||
@@ -43,6 +44,8 @@ def _svc(db: AsyncSession) -> BtPanelService:
|
||||
|
||||
|
||||
def _http_error(exc: Exception) -> HTTPException:
|
||||
if isinstance(exc, BtPanelNotInstalledError):
|
||||
return HTTPException(status_code=400, detail=str(exc))
|
||||
if isinstance(exc, ValueError):
|
||||
return HTTPException(status_code=400, detail=str(exc))
|
||||
if isinstance(exc, BtPanelApiError):
|
||||
|
||||
@@ -37,6 +37,7 @@ from server.infrastructure.btpanel.constants import (
|
||||
BT_TEMP_LOGIN_TTL_SECONDS,
|
||||
)
|
||||
from server.infrastructure.btpanel.ssh_login import (
|
||||
BtPanelNotInstalledError,
|
||||
detect_bt_panel_installed,
|
||||
normalize_temp_login_url,
|
||||
resolve_temp_login_url,
|
||||
@@ -502,6 +503,8 @@ class BtPanelService:
|
||||
)
|
||||
if not result.get("ok") and not result.get("skipped"):
|
||||
code = str(result.get("error") or "bootstrap_failed")
|
||||
if code == "bt_not_installed":
|
||||
raise BtPanelNotInstalledError()
|
||||
if code in BOOTSTRAP_LOGIN_HARD_FAIL_ERRORS:
|
||||
raise ValueError(f"获取宝塔 API 失败: {code}")
|
||||
logger.warning(
|
||||
|
||||
@@ -16,11 +16,38 @@ from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
||||
logger = logging.getLogger("nexus.btpanel.ssh")
|
||||
|
||||
_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)
|
||||
_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
|
||||
sys.path.insert(0, "/www/server/panel")
|
||||
sys.path.insert(0, "/www/server/panel/class")
|
||||
import public
|
||||
ttl = int({ttl})
|
||||
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)
|
||||
encoded = base64.b64encode(script.encode()).decode()
|
||||
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"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(
|
||||
server: Server,
|
||||
*,
|
||||
base_url: str | None = None,
|
||||
ttl_seconds: int = BT_TEMP_LOGIN_TTL_SECONDS,
|
||||
) -> str:
|
||||
if not await detect_bt_panel_installed(server):
|
||||
raise BtPanelNotInstalledError()
|
||||
|
||||
cmd = build_ssh_temp_login_command(server, ttl_seconds=ttl_seconds)
|
||||
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 执行失败")
|
||||
_raise_ssh_login_failure(result)
|
||||
line = _extract_temp_login_line(result.get("stdout") or "")
|
||||
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(
|
||||
(result.get("stdout") or result.get("stderr") or "SSH 未返回临时登录链接")[:200]
|
||||
)
|
||||
|
||||
@@ -6,7 +6,9 @@ import pytest
|
||||
|
||||
from server.domain.models import Server
|
||||
from server.infrastructure.btpanel.ssh_login import (
|
||||
BtPanelNotInstalledError,
|
||||
detect_bt_panel_installed,
|
||||
humanize_ssh_login_error,
|
||||
normalize_temp_login_url,
|
||||
_extract_temp_login_line,
|
||||
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"
|
||||
|
||||
|
||||
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
|
||||
async def test_ssh_temp_login_url_accepts_relative_path():
|
||||
server = Server(id=1, name="s", domain="5.6.7.8")
|
||||
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",
|
||||
new_callable=AsyncMock,
|
||||
return_value={
|
||||
|
||||
@@ -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")
|
||||
cmd = build_ssh_temp_login_command(server, ttl_seconds=BT_TEMP_LOGIN_TTL_SECONDS)
|
||||
assert "get_temp_login_ipv4" not in cmd
|
||||
assert "panel/class/common.py" in cmd
|
||||
match = re.search(r"echo ([^ |]+) \| base64", cmd)
|
||||
assert match, cmd
|
||||
script = base64.b64decode(match.group(1)).decode()
|
||||
|
||||
Reference in New Issue
Block a user