diff --git a/docs/audit/2026-06-21-btpanel-temp-login-24h.md b/docs/audit/2026-06-21-btpanel-temp-login-24h.md new file mode 100644 index 00000000..1bf7a0b8 --- /dev/null +++ b/docs/audit/2026-06-21-btpanel-temp-login-24h.md @@ -0,0 +1,30 @@ +# 审计 — 2026-06-21 宝塔临时登录 24 小时 + +**Changelog**: `docs/changelog/2026-06-21-btpanel-temp-login-24h.md` + +## Step 3 规则扫描 + +| 文件 | 规则 | 结论 | +|------|------|------| +| `constants.py` | 单点 TTL 常量 | PASS | +| `btpanel_service.py` | API 传 expire_time,token 拼 URL | PASS | +| `ssh_login.py` | base64 远程脚本,无静默吞错 | PASS | + +## Closure + +| 项 | 状态 | 说明 | +|----|------|------| +| 默认 3h 临时登录 | FIXED | API + SSH 均 86400s | + +## 文件清单 + +- `server/infrastructure/btpanel/constants.py` +- `server/application/services/btpanel_service.py` +- `server/infrastructure/btpanel/ssh_login.py` +- `tests/test_btpanel_temp_login_ttl.py` +- `docs/changelog/2026-06-21-btpanel-temp-login-24h.md` + +## DoD + +- [x] expire_time = now + 86400 +- [x] pytest 通过 diff --git a/docs/audit/2026-06-22-btpanel-temp-login-24h.md b/docs/audit/2026-06-22-btpanel-temp-login-24h.md new file mode 100644 index 00000000..335c205a --- /dev/null +++ b/docs/audit/2026-06-22-btpanel-temp-login-24h.md @@ -0,0 +1,30 @@ +# 审计 — 2026-06-22 宝塔临时登录 24 小时 + +**Changelog**: `docs/changelog/2026-06-22-btpanel-temp-login-24h.md` + +## Step 3 规则扫描 + +| 文件 | 规则 | 结论 | +|------|------|------| +| `constants.py` | 单点 TTL 常量 | PASS | +| `btpanel_service.py` | API 传 expire_time,token 拼 URL | PASS | +| `ssh_login.py` | base64 远程脚本,无静默吞错 | PASS | + +## Closure + +| 项 | 状态 | 说明 | +|----|------|------| +| 默认 3h 临时登录 | FIXED | API + SSH 均 86400s | + +## 文件清单 + +- `server/infrastructure/btpanel/constants.py` +- `server/application/services/btpanel_service.py` +- `server/infrastructure/btpanel/ssh_login.py` +- `tests/test_btpanel_temp_login_ttl.py` +- `docs/changelog/2026-06-22-btpanel-temp-login-24h.md` + +## DoD + +- [x] expire_time = now + 86400 +- [x] pytest 通过 diff --git a/docs/changelog/2026-06-21-btpanel-temp-login-24h.md b/docs/changelog/2026-06-21-btpanel-temp-login-24h.md new file mode 100644 index 00000000..5693c9e8 --- /dev/null +++ b/docs/changelog/2026-06-21-btpanel-temp-login-24h.md @@ -0,0 +1,30 @@ +# 宝塔一键登录临时时效改为 24 小时 + +**日期**:2026-06-21 + +## 摘要 + +一键登录生成临时 `tmp_token` 时,`expire_time` 设为当前时间 + **86400 秒**(24 小时);SSH 回退不再调用 `tools.py get_temp_login_ipv4`(固定 3 小时),改为远程执行同等逻辑并传入 TTL。 + +## 动机 + +用户希望登录后面板会话与临时链接上限为 24 小时;宝塔默认 3 小时,且 `tmp_login_expire` 为生成链接时的绝对时间。 + +## 涉及文件 + +- `server/infrastructure/btpanel/constants.py` — `BT_TEMP_LOGIN_TTL_SECONDS = 86400` +- `server/application/services/btpanel_service.py` — API `set_temp_login` 传 `expire_time`,解析 `token` 拼 URL +- `server/infrastructure/btpanel/ssh_login.py` — `build_ssh_temp_login_command` +- `tests/test_btpanel_temp_login_ttl.py` + +## 迁移 / 重启 + +仅 API 变更;部署后端镜像即可,前端无改动。 + +## 验证 + +```bash +.venv/bin/pytest tests/test_btpanel_temp_login_ttl.py tests/test_btpanel_ssh_login.py -q +``` + +生产:`POST /api/btpanel/servers/{id}/login-url` 后登录,约 24 小时内不应提示「临时登录已过期」(仍受面板 `session_timeout.pl` 约束)。 diff --git a/docs/changelog/2026-06-22-btpanel-temp-login-24h.md b/docs/changelog/2026-06-22-btpanel-temp-login-24h.md new file mode 100644 index 00000000..60261762 --- /dev/null +++ b/docs/changelog/2026-06-22-btpanel-temp-login-24h.md @@ -0,0 +1,30 @@ +# 宝塔一键登录临时时效改为 24 小时 + +**日期**:2026-06-22 + +## 摘要 + +一键登录生成临时 `tmp_token` 时,`expire_time` 设为当前时间 + **86400 秒**(24 小时);SSH 回退不再调用 `tools.py get_temp_login_ipv4`(固定 3 小时),改为远程执行同等逻辑并传入 TTL。 + +## 动机 + +用户希望登录后面板会话与临时链接上限为 24 小时;宝塔默认 3 小时,且 `tmp_login_expire` 为生成链接时的绝对时间。 + +## 涉及文件 + +- `server/infrastructure/btpanel/constants.py` — `BT_TEMP_LOGIN_TTL_SECONDS = 86400` +- `server/application/services/btpanel_service.py` — API `set_temp_login` 传 `expire_time`,解析 `token` 拼 URL +- `server/infrastructure/btpanel/ssh_login.py` — `build_ssh_temp_login_command` +- `tests/test_btpanel_temp_login_ttl.py` + +## 迁移 / 重启 + +仅 API 变更;部署后端镜像即可,前端无改动。 + +## 验证 + +```bash +.venv/bin/pytest tests/test_btpanel_temp_login_ttl.py tests/test_btpanel_ssh_login.py -q +``` + +生产:`POST /api/btpanel/servers/{id}/login-url` 后登录,约 24 小时内不应提示「临时登录已过期」(仍受面板 `session_timeout.pl` 约束)。 diff --git a/server/application/services/btpanel_service.py b/server/application/services/btpanel_service.py index 2d3d001c..04545372 100644 --- a/server/application/services/btpanel_service.py +++ b/server/application/services/btpanel_service.py @@ -4,6 +4,7 @@ from __future__ import annotations import json import logging +import time from typing import Any from sqlalchemy.ext.asyncio import AsyncSession @@ -30,10 +31,13 @@ from server.infrastructure.btpanel.bootstrap_state import ( from server.infrastructure.btpanel.bootstrap_lock import bootstrap_lock from server.infrastructure.btpanel.source_ip import get_bt_panel_source_ip from server.infrastructure.btpanel.ssh_bootstrap import BootstrapRemoteError, ssh_bootstrap_panel +from server.infrastructure.btpanel.constants import BT_TEMP_LOGIN_TTL_SECONDS from server.infrastructure.btpanel.ssh_login import ( detect_bt_panel_installed, + normalize_temp_login_url, resolve_temp_login_url, ssh_temp_login_url, + _panel_port_base, ) from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl from server.infrastructure.database.server_repo import ServerRepositoryImpl @@ -456,8 +460,12 @@ class BtPanelService: creds: BtPanelCredentials, server: Server, ) -> str: + expire_time = int(time.time()) + BT_TEMP_LOGIN_TTL_SECONDS client = BtPanelClient(creds, server.id) - data = await client.post("/config?action=set_temp_login", {}) + data = await client.post( + "/config?action=set_temp_login", + {"expire_time": expire_time}, + ) if isinstance(data, dict): for key in ("url", "login_url", "msg"): val = data.get(key) @@ -469,6 +477,14 @@ class BtPanelService: val = inner.get(key) if isinstance(val, str) and "tmp_token=" in val: return await resolve_temp_login_url(server, val) + token = data.get("token") + if data.get("status") and isinstance(token, str) and "tmp_token=" not in token: + rel = f"/login?tmp_token={token}" + return normalize_temp_login_url( + rel, + base_url=_panel_port_base(creds.base_url), + prefer_host=server.domain or "", + ) raise BtPanelApiError("set_temp_login 未返回登录链接") async def proxy( diff --git a/server/infrastructure/btpanel/constants.py b/server/infrastructure/btpanel/constants.py new file mode 100644 index 00000000..023e728b --- /dev/null +++ b/server/infrastructure/btpanel/constants.py @@ -0,0 +1,4 @@ +"""Baota panel integration constants.""" + +# 一键登录临时 token / 会话上限(秒);与宝塔 set_temp_login expire_time 对齐 +BT_TEMP_LOGIN_TTL_SECONDS = 86400 diff --git a/server/infrastructure/btpanel/ssh_login.py b/server/infrastructure/btpanel/ssh_login.py index 2652a197..2fbc9327 100644 --- a/server/infrastructure/btpanel/ssh_login.py +++ b/server/infrastructure/btpanel/ssh_login.py @@ -2,26 +2,64 @@ from __future__ import annotations +import base64 import logging import re +import shlex from urllib.parse import urlparse from server.domain.models import Server +from server.infrastructure.btpanel.constants import BT_TEMP_LOGIN_TTL_SECONDS 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") -_BT_PANEL_TOOLS = "/www/server/panel/tools.py" _BT_PANEL_PYTHON = "/www/server/panel/pyenv/bin/python" _FULL_LOGIN_RE = re.compile(r"^https?://\S+.*tmp_token=", re.IGNORECASE) _REL_LOGIN_RE = re.compile(r"^/?login\?tmp_token=\S+", re.IGNORECASE) -SSH_TEMP_LOGIN_CMD = ( - f"test -f {_BT_PANEL_TOOLS} && " - f"P={_BT_PANEL_PYTHON}; test -x \"$P\" || P=python3; " - f"cd /www/server/panel && \"$P\" tools.py get_temp_login_ipv4 2>/dev/null | tail -3" -) +_BT_TEMP_LOGIN_SCRIPT = """import os, sys, time +sys.path.insert(0, "/www/server/panel") +import public +ttl = int({ttl}) +host = {host!r} +s_time = int(time.time()) +expire_time = s_time + ttl +public.M("temp_login").where("state=? and expire>?", (0, s_time)).delete() +token = public.GetRandomString(48) +salt = public.GetRandomString(12) +pdata = {{ + "token": public.md5(token + salt), + "salt": salt, + "state": 0, + "login_time": 0, + "login_addr": "", + "expire": expire_time, + "addtime": s_time, +}} +if not public.M("temp_login").count(): + pdata["id"] = 101 +if not public.M("temp_login").insert(pdata): + raise SystemExit("temp_login insert failed") +P = "/www/server/panel/data" +port = (public.readFile(os.path.join(P, "port.pl")) or "8888").strip() +scheme = "https" if os.path.isfile(os.path.join(P, "ssl.pl")) else "http" +print(f"{{scheme}}://{{host}}:{{port}}/login?tmp_token={{token}}") +""" + + +def build_ssh_temp_login_command(server: Server, *, ttl_seconds: int = BT_TEMP_LOGIN_TTL_SECONDS) -> str: + """Remote Python inserts temp_login row with custom TTL (tools.py 固定 3h,此处可配).""" + host = (server.domain or "127.0.0.1").strip() + script = _BT_TEMP_LOGIN_SCRIPT.format(ttl=ttl_seconds, host=host) + encoded = base64.b64encode(script.encode()).decode() + inner = ( + "test -d /www/server/panel && " + f"P={_BT_PANEL_PYTHON}; test -x \"$P\" || P=python3; " + f"echo {shlex.quote(encoded)} | base64 -d | \"$P\"" + ) + return wrap_sudo_nopasswd(inner, server.username) SSH_PANEL_BASE_CMD = ( f"P={_BT_PANEL_PYTHON}; test -x \"$P\" || P=python3; " @@ -172,8 +210,13 @@ 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: - cmd = wrap_sudo_nopasswd(SSH_TEMP_LOGIN_CMD, server.username) +async def ssh_temp_login_url( + server: Server, + *, + base_url: str | None = None, + ttl_seconds: int = BT_TEMP_LOGIN_TTL_SECONDS, +) -> str: + 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 执行失败") diff --git a/tests/test_btpanel_temp_login_ttl.py b/tests/test_btpanel_temp_login_ttl.py new file mode 100644 index 00000000..8f87acfa --- /dev/null +++ b/tests/test_btpanel_temp_login_ttl.py @@ -0,0 +1,53 @@ +"""Tests for 24h Baota temp login TTL.""" + +import base64 +import re +import time +from unittest.mock import AsyncMock, patch + +import pytest + +from server.application.services.btpanel_service import BtPanelService +from server.domain.models import Server +from server.infrastructure.btpanel.constants import BT_TEMP_LOGIN_TTL_SECONDS +from server.infrastructure.btpanel.credentials import BtPanelCredentials +from server.infrastructure.btpanel.ssh_login import build_ssh_temp_login_command + + +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 + match = re.search(r"echo ([^ |]+) \| base64", cmd) + assert match, cmd + script = base64.b64decode(match.group(1)).decode() + assert f"ttl = int({BT_TEMP_LOGIN_TTL_SECONDS})" in script + + +@pytest.mark.asyncio +async def test_login_url_via_api_passes_expire_time_and_builds_url_from_token(): + server = Server(id=2, name="s", domain="5.6.7.8") + creds = BtPanelCredentials( + base_url="https://5.6.7.8:33994", + api_key="key", + verify_ssl=False, + ) + svc = BtPanelService(db=None) # type: ignore[arg-type] + client = AsyncMock() + client.post = AsyncMock( + return_value={ + "status": True, + "token": "a" * 48, + "expire": int(time.time()) + BT_TEMP_LOGIN_TTL_SECONDS, + }, + ) + + before = int(time.time()) + with patch("server.application.services.btpanel_service.BtPanelClient", return_value=client): + url = await svc._login_url_via_api(creds, server) + after = int(time.time()) + + payload = client.post.await_args.args[1] + assert payload["expire_time"] >= before + BT_TEMP_LOGIN_TTL_SECONDS + assert payload["expire_time"] <= after + BT_TEMP_LOGIN_TTL_SECONDS + assert url == f"https://5.6.7.8:33994/login?tmp_token={'a' * 48}"