feat(btpanel): 一键登录临时 token 有效期改为 24 小时

API set_temp_login 传 expire_time=86400;SSH 回退用自定义脚本替代 tools.py 3 小时默认。
This commit is contained in:
r
2026-06-22 05:47:49 +08:00
parent 45d21026b7
commit 0ec0aaf174
8 changed files with 245 additions and 9 deletions
@@ -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_timetoken 拼 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 通过
@@ -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_timetoken 拼 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 通过
@@ -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` 约束)。
@@ -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` 约束)。
+17 -1
View File
@@ -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(
@@ -0,0 +1,4 @@
"""Baota panel integration constants."""
# 一键登录临时 token / 会话上限(秒);与宝塔 set_temp_login expire_time 对齐
BT_TEMP_LOGIN_TTL_SECONDS = 86400
+51 -8
View File
@@ -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 执行失败")
+53
View File
@@ -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}"