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)。
|
路径见 [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`。
|
||||||
|
|
||||||
详情见功能指南 §17–18。
|
详情见功能指南 §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.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(
|
||||||
|
|||||||
@@ -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]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user