fix(btpanel): 一键登录 URL 去掉安全入口路径
tmp_token 登录在 :port/login,非 :port/{admin}/login,后者 nginx 404。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
# 2026-06-21 宝塔一键登录 URL 路径修复
|
||||
|
||||
## 摘要
|
||||
|
||||
临时登录链接误拼为 `https://ip:port/{安全入口}/login?tmp_token=...`,宝塔实际入口为 `https://ip:port/login?tmp_token=...`(带安全入口路径返回 nginx 404)。
|
||||
|
||||
## 动机
|
||||
|
||||
生产 curl:`/1f78dd53/login?tmp_token=...` → 404;`/login?tmp_token=...` → 302。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/infrastructure/btpanel/ssh_login.py` — `_panel_port_base`、`_login_path_only`、`_discover_panel_login_base_url`
|
||||
- `tests/test_btpanel_ssh_login.py`
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_btpanel_ssh_login.py -q
|
||||
POST /api/btpanel/servers/3/login-url # url 应为 ...:port/login?tmp_token=
|
||||
```
|
||||
@@ -39,6 +39,19 @@ SSH_PANEL_BASE_CMD = (
|
||||
"\""
|
||||
)
|
||||
|
||||
# 临时登录在 :port/login,不在 :port/{安全入口}/login(后者 nginx 404)
|
||||
SSH_PANEL_LOGIN_BASE_CMD = (
|
||||
f"P={_BT_PANEL_PYTHON}; test -x \"$P\" || P=python3; "
|
||||
"$P -c \""
|
||||
"import os; P='/www/server/panel/data'; "
|
||||
"port=open(os.path.join(P,'port.pl')).read().strip() or '8888'; "
|
||||
"ssl=os.path.isfile(os.path.join(P,'ssl.pl')); "
|
||||
"scheme='https' if ssl else 'http'; "
|
||||
"host='__HOST__'; "
|
||||
"print(f'{scheme}://{host}:{port}')"
|
||||
"\""
|
||||
)
|
||||
|
||||
DETECT_BT_CMD = "test -f /www/server/panel/class/common.py && echo BT_OK || echo BT_NO"
|
||||
|
||||
|
||||
@@ -50,6 +63,23 @@ async def detect_bt_panel_installed(server: Server) -> bool:
|
||||
return "BT_OK" in (result.get("stdout") or "")
|
||||
|
||||
|
||||
def _panel_port_base(url: str) -> str:
|
||||
"""``https://host:port`` — strip path (安全入口不属于 tmp_token 登录路径)."""
|
||||
parsed = urlparse((url or "").strip())
|
||||
if not parsed.scheme or not parsed.netloc:
|
||||
return (url or "").strip().rstrip("/")
|
||||
return f"{parsed.scheme}://{parsed.netloc}"
|
||||
|
||||
|
||||
def _login_path_only(path: str) -> str:
|
||||
"""``/1f78dd53/login`` → ``/login``."""
|
||||
path = (path or "").strip() or "/login"
|
||||
idx = path.find("/login")
|
||||
if idx >= 0:
|
||||
return path[idx:]
|
||||
return path if path.startswith("/") else f"/{path}"
|
||||
|
||||
|
||||
def _rewrite_login_host(url: str, prefer_host: str) -> str:
|
||||
"""Replace panel hostname with server.domain so browser can reach the sub-server."""
|
||||
prefer_host = prefer_host.strip()
|
||||
@@ -57,7 +87,7 @@ def _rewrite_login_host(url: str, prefer_host: str) -> str:
|
||||
return url
|
||||
parsed = urlparse(url)
|
||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
path = parsed.path or "/login"
|
||||
path = _login_path_only(parsed.path or "/login")
|
||||
query = parsed.query
|
||||
suffix = f"{path}?{query}" if query else path
|
||||
return f"{parsed.scheme}://{prefer_host}:{port}{suffix}"
|
||||
@@ -75,15 +105,21 @@ def normalize_temp_login_url(
|
||||
raise ValueError("missing tmp_token")
|
||||
|
||||
if _FULL_LOGIN_RE.match(line):
|
||||
return _rewrite_login_host(line, prefer_host)
|
||||
parsed = urlparse(line)
|
||||
path = _login_path_only(parsed.path or "/login")
|
||||
port_base = _panel_port_base(line)
|
||||
rebuilt = f"{port_base}{path}"
|
||||
if parsed.query:
|
||||
rebuilt = f"{rebuilt}?{parsed.query}"
|
||||
return _rewrite_login_host(rebuilt, prefer_host)
|
||||
|
||||
if _REL_LOGIN_RE.match(line):
|
||||
base = (base_url or "").strip().rstrip("/")
|
||||
base = _panel_port_base((base_url or "").strip())
|
||||
if not base:
|
||||
raise RuntimeError(
|
||||
"宝塔返回相对登录路径,需要面板 base_url;请在连接配置保存地址或先 SSH 获取 API"
|
||||
)
|
||||
path = line if line.startswith("/") else f"/{line}"
|
||||
path = _login_path_only(line if line.startswith("/") else f"/{line}")
|
||||
return _rewrite_login_host(f"{base}{path}", prefer_host)
|
||||
|
||||
raise RuntimeError(f"未获得有效临时登录链接: {line[:120]}")
|
||||
@@ -98,7 +134,20 @@ def _extract_temp_login_line(stdout: str) -> str:
|
||||
return lines[-1] if lines else ""
|
||||
|
||||
|
||||
async def _discover_panel_login_base_url(server: Server) -> str:
|
||||
host = (server.domain or "127.0.0.1").strip()
|
||||
cmd = wrap_sudo_nopasswd(SSH_PANEL_LOGIN_BASE_CMD.replace("__HOST__", host), server.username)
|
||||
result = await exec_ssh_command(server, cmd, timeout=25)
|
||||
if result.get("status") != "success":
|
||||
raise RuntimeError(result.get("stderr") or result.get("stdout") or "SSH 探测面板地址失败")
|
||||
base = (result.get("stdout") or "").strip().splitlines()[-1].strip()
|
||||
if not base.startswith("http"):
|
||||
raise RuntimeError(f"SSH 未解析出面板 login base: {base[:120]}")
|
||||
return _panel_port_base(base)
|
||||
|
||||
|
||||
async def _discover_panel_base_url(server: Server) -> str:
|
||||
"""Browser panel entry with security path (not for tmp_token login)."""
|
||||
host = (server.domain or "127.0.0.1").strip()
|
||||
cmd = wrap_sudo_nopasswd(SSH_PANEL_BASE_CMD.replace("__HOST__", host), server.username)
|
||||
result = await exec_ssh_command(server, cmd, timeout=25)
|
||||
@@ -115,7 +164,7 @@ async def resolve_temp_login_url(server: Server, raw: str) -> str:
|
||||
line = raw.strip()
|
||||
base: str | None = None
|
||||
if _REL_LOGIN_RE.match(line):
|
||||
base = await _discover_panel_base_url(server)
|
||||
base = await _discover_panel_login_base_url(server)
|
||||
return normalize_temp_login_url(
|
||||
line,
|
||||
base_url=base,
|
||||
@@ -137,7 +186,7 @@ async def ssh_temp_login_url(server: Server, *, base_url: str | None = None) ->
|
||||
if _REL_LOGIN_RE.match(line) and (base_url or "").strip():
|
||||
return normalize_temp_login_url(
|
||||
line,
|
||||
base_url=base_url.strip().rstrip("/"),
|
||||
base_url=_panel_port_base(base_url.strip()),
|
||||
prefer_host=server.domain or "",
|
||||
)
|
||||
return await resolve_temp_login_url(server, line)
|
||||
|
||||
@@ -46,16 +46,25 @@ def test_normalize_temp_login_relative_with_base_url():
|
||||
base_url="https://1.2.3.4:8888/secret",
|
||||
prefer_host="1.2.3.4",
|
||||
)
|
||||
assert url == "https://1.2.3.4:8888/secret/login?tmp_token=abc123"
|
||||
assert url == "https://1.2.3.4:8888/login?tmp_token=abc123"
|
||||
|
||||
|
||||
def test_normalize_temp_login_full_url_strips_admin_path():
|
||||
url = normalize_temp_login_url(
|
||||
"https://127.0.0.1:8888/secret/login?tmp_token=abc123",
|
||||
base_url=None,
|
||||
prefer_host="5.6.7.8",
|
||||
)
|
||||
assert url == "https://5.6.7.8:8888/login?tmp_token=abc123"
|
||||
|
||||
|
||||
def test_normalize_temp_login_full_url_rewrites_host():
|
||||
url = normalize_temp_login_url(
|
||||
"https://127.0.0.1:8888/secret/login?tmp_token=abc123",
|
||||
"https://127.0.0.1:8888/login?tmp_token=abc123",
|
||||
base_url="https://1.2.3.4:8888/secret",
|
||||
prefer_host="5.6.7.8",
|
||||
)
|
||||
assert url == "https://5.6.7.8:8888/secret/login?tmp_token=abc123"
|
||||
assert url == "https://5.6.7.8:8888/login?tmp_token=abc123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -75,5 +84,5 @@ async def test_ssh_temp_login_url_accepts_relative_path():
|
||||
server,
|
||||
base_url="https://5.6.7.8:8888/abc123",
|
||||
)
|
||||
assert url == "https://5.6.7.8:8888/abc123/login?tmp_token=VHj687eKwrPKr5dn"
|
||||
assert url == "https://5.6.7.8:8888/login?tmp_token=VHj687eKwrPKr5dn"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user