diff --git a/AGENTS.md b/AGENTS.md index bc7837b7..2e23c966 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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。 diff --git a/docs/changelog/2026-07-09-btpanel-not-installed-hint.md b/docs/changelog/2026-07-09-btpanel-not-installed-hint.md new file mode 100644 index 00000000..7a35974e --- /dev/null +++ b/docs/changelog/2026-07-09-btpanel-not-installed-hint.md @@ -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 含「未检测到宝塔面板」。 diff --git a/server/api/btpanel.py b/server/api/btpanel.py index b627cfa6..cf7362b7 100644 --- a/server/api/btpanel.py +++ b/server/api/btpanel.py @@ -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): diff --git a/server/application/services/btpanel_service.py b/server/application/services/btpanel_service.py index fc1bfe03..3380cd45 100644 --- a/server/application/services/btpanel_service.py +++ b/server/application/services/btpanel_service.py @@ -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( diff --git a/server/infrastructure/btpanel/ssh_login.py b/server/infrastructure/btpanel/ssh_login.py index 2fbc9327..5f7fd8f4 100644 --- a/server/infrastructure/btpanel/ssh_login.py +++ b/server/infrastructure/btpanel/ssh_login.py @@ -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] ) diff --git a/tests/test_btpanel_ssh_login.py b/tests/test_btpanel_ssh_login.py index 351bda83..9f09e396 100644 --- a/tests/test_btpanel_ssh_login.py +++ b/tests/test_btpanel_ssh_login.py @@ -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 "", line 4, in \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={ diff --git a/tests/test_btpanel_temp_login_ttl.py b/tests/test_btpanel_temp_login_ttl.py index 8f87acfa..a7f73fe3 100644 --- a/tests/test_btpanel_temp_login_ttl.py +++ b/tests/test_btpanel_temp_login_ttl.py @@ -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()