feat(btpanel): 一键登录临时 token 有效期改为 24 小时
API set_temp_login 传 expire_time=86400;SSH 回退用自定义脚本替代 tools.py 3 小时默认。
This commit is contained in:
@@ -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 通过
|
||||
@@ -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 通过
|
||||
@@ -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` 约束)。
|
||||
@@ -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
|
||||
@@ -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 执行失败")
|
||||
|
||||
@@ -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}"
|
||||
Reference in New Issue
Block a user