fix(HEL-564): 数据中枢入口固定内网 IP,不再按主机名推导

- 主站桌面端/移动端「数据中枢」入口固定 http://192.168.200.11:8766/admin/
  (XIAOBAI_DATAHUB_URL 仍可覆盖):域名只反代 8765,推导出的 <域名>:8766 打不开,
  同时避免数据中枢被外网摸到
- 中枢 _review_url 不再从 Host 头推导,统一取 REVIEW_PUBLIC_URL,
  默认 http://192.168.200.11:8765;compose 与 .env.example 示例值同步
- 补回归测试:Host 为域名且未配 REVIEW_PUBLIC_URL 时登录链接仍为固定内网地址
- 文档写明 Cookie 按门牌区分的边界与部署说明

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
施工员
2026-09-16 15:23:45 +08:00
co-authored by multica-agent
parent b402b3b2a8
commit a4dbe2bcf8
12 changed files with 50 additions and 27 deletions
+6 -2
View File
@@ -286,6 +286,10 @@ docker compose restart xiaobai-review
不一致时控制台无法校验会话,页面会停在门禁面板。 不一致时控制台无法校验会话,页面会停在门禁面板。
- `REVIEW_BASE_URL` 是中枢访问主站的地址(同一 compose 网络内用服务名 - `REVIEW_BASE_URL` 是中枢访问主站的地址(同一 compose 网络内用服务名
`http://xiaobai-review:8765`);`REVIEW_PUBLIC_URL` 是浏览器可达的主站地址, `http://xiaobai-review:8765`);`REVIEW_PUBLIC_URL` 是浏览器可达的主站地址,
留空则按当前主机名推导 留空则用固定内网地址 `http://192.168.200.11:8765`
- 会话 cookie 靠"同主机不同端口"共享,因此主站与中枢必须对浏览器暴露在同一主机名下; - 主站右上角「数据中枢」入口固定指向 `http://192.168.200.11:8766/admin/`,不按浏览器
当前主机名推导(域名 `xiaobaifupan.com` 只反代 8765,推导出的 `<域名>:8766` 打不开),
外网也就摸不到这个页面。
- 会话 cookie 按"门牌"(域名 / IP)区分:只有用 `http://192.168.200.11:8765` 登录主站,
点入口才免登录直通中枢;用域名登录主站再点入口会落到门禁面板,按提示登录一次即可。
8766 与 8765 同样只在可信内网开放。 8766 与 8765 同样只在可信内网开放。
+1 -1
View File
@@ -117,7 +117,7 @@ compose.yaml
.env.example 环境变量模板(复制为 .env 后填写) .env.example 环境变量模板(复制为 .env 后填写)
``` ```
管理员的配置入口在数据中枢(页面右上角「数据中枢」按钮,指向 8766):数据源凭证、模型池、会员与邀请码都在那里维护,数据仍存在主站同一份 SQLite 里。主站自身只保留「行情管理」面板(Tushare Token、后台刷新开关、手动刷新与补数)。所有用户读取同一份 SQLite 行情快照。`.env` 中的 Tushare 和平台 LLM 配置只用于初始化系统配置。 管理员的配置入口在数据中枢(页面右上角「数据中枢」按钮,固定指向 `http://192.168.200.11:8766/admin/`):数据源凭证、模型池、会员与邀请码都在那里维护,数据仍存在主站同一份 SQLite 里。主站自身只保留「行情管理」面板(Tushare Token、后台刷新开关、手动刷新与补数)。所有用户读取同一份 SQLite 行情快照。`.env` 中的 Tushare 和平台 LLM 配置只用于初始化系统配置。
数据中枢用主站的管理员账号进入,没有独立账号;桥接令牌 `HUB_ADMIN_TOKEN` 需在主站与中枢两侧 `.env` 填成同一个值。详见 [xiaobai-datahub/README.md](xiaobai-datahub/README.md)。 数据中枢用主站的管理员账号进入,没有独立账号;桥接令牌 `HUB_ADMIN_TOKEN` 需在主站与中枢两侧 `.env` 填成同一个值。详见 [xiaobai-datahub/README.md](xiaobai-datahub/README.md)。
+1 -1
View File
@@ -21,7 +21,7 @@ services:
DATAHUB_TOKEN: "${DATAHUB_TOKEN:?DATAHUB_TOKEN must be set}" DATAHUB_TOKEN: "${DATAHUB_TOKEN:?DATAHUB_TOKEN must be set}"
HUB_ADMIN_TOKEN: "${HUB_ADMIN_TOKEN:?HUB_ADMIN_TOKEN must be set}" HUB_ADMIN_TOKEN: "${HUB_ADMIN_TOKEN:?HUB_ADMIN_TOKEN must be set}"
REVIEW_BASE_URL: "${REVIEW_BASE_URL:-http://xiaobai-review:8765}" REVIEW_BASE_URL: "${REVIEW_BASE_URL:-http://xiaobai-review:8765}"
REVIEW_PUBLIC_URL: "${REVIEW_PUBLIC_URL:-}" REVIEW_PUBLIC_URL: "${REVIEW_PUBLIC_URL:-http://192.168.200.11:8765}"
TUSHARE_TOKEN: "${TUSHARE_TOKEN:-}" TUSHARE_TOKEN: "${TUSHARE_TOKEN:-}"
IFIND_REFRESH_TOKEN: "${IFIND_REFRESH_TOKEN:-}" IFIND_REFRESH_TOKEN: "${IFIND_REFRESH_TOKEN:-}"
IFIND_ACCESS_TOKEN: "${IFIND_ACCESS_TOKEN:-}" IFIND_ACCESS_TOKEN: "${IFIND_ACCESS_TOKEN:-}"
+5 -5
View File
@@ -717,16 +717,16 @@
"bytes": 4712, "bytes": 4712,
"lines": 106 "lines": 106
}, },
{
"path": "frontend/shared/admin.js",
"bytes": 4500,
"lines": 109
},
{ {
"path": "backend/data/providers/tushare_helpers.py", "path": "backend/data/providers/tushare_helpers.py",
"bytes": 4406, "bytes": 4406,
"lines": 124 "lines": 124
}, },
{
"path": "frontend/shared/admin.js",
"bytes": 4376,
"lines": 107
},
{ {
"path": "backend/features/market/routes.py", "path": "backend/features/market/routes.py",
"bytes": 4276, "bytes": 4276,
+2 -1
View File
@@ -5247,7 +5247,8 @@
function dataHubConsoleUrl() { function dataHubConsoleUrl() {
if (global.XIAOBAI_DATAHUB_URL) return String(global.XIAOBAI_DATAHUB_URL); if (global.XIAOBAI_DATAHUB_URL) return String(global.XIAOBAI_DATAHUB_URL);
return global.location.protocol + "//" + global.location.hostname + ":8766/admin/"; // HEL-564: 固定内网地址,见 frontend/shared/admin.js 同名函数。
return "http://192.168.200.11:8766/admin/";
} }
function openDataHubConsole() { function openDataHubConsole() {
+3 -1
View File
@@ -51,7 +51,9 @@ async function openAdminSettings(refreshOnly = false) {
function dataHubConsoleUrl() { function dataHubConsoleUrl() {
if (window.XIAOBAI_DATAHUB_URL) return String(window.XIAOBAI_DATAHUB_URL); if (window.XIAOBAI_DATAHUB_URL) return String(window.XIAOBAI_DATAHUB_URL);
return `${window.location.protocol}//${window.location.hostname}:8766/admin/`; // HEL-564: 固定内网地址。域名只反代 8765,按主机名推导会拼出打不开的
// <域名>:8766;数据中枢也不该被外网摸到。
return "http://192.168.200.11:8766/admin/";
} }
function openDataHubConsole() { function openDataHubConsole() {
+3 -2
View File
@@ -9,8 +9,9 @@ DATAHUB_TOKEN=
REVIEW_BASE_URL=http://xiaobai-review:8765 REVIEW_BASE_URL=http://xiaobai-review:8765
HUB_ADMIN_TOKEN= HUB_ADMIN_TOKEN=
# 浏览器可达的主站地址;留空时按请求 Host 推导 http://<host>:8765。 # 浏览器可达的主站地址,用于门禁面板的「去主站登录」跳转。留空时用固定内网地址
REVIEW_PUBLIC_URL= # http://192.168.200.11:8765HEL-564:不再按请求 Host 推导,域名只反代 8765)。
REVIEW_PUBLIC_URL=http://192.168.200.11:8765
# Tushare Pro token. Stored encrypted after first launch; never returned by API or admin pages. # Tushare Pro token. Stored encrypted after first launch; never returned by API or admin pages.
TUSHARE_TOKEN= TUSHARE_TOKEN=
+3 -3
View File
@@ -48,7 +48,7 @@ cp .env.example .env
控制台**没有自己的账号体系**,也不再有独立登录页和改密页: 控制台**没有自己的账号体系**,也不再有独立登录页和改密页:
- 登录状态取自主站 `xiaobai_session` cookie。主站与中枢同主机不同端口,浏览器会自动带上该 cookie,因此在主站登录后直接打开 8766 即可进入;未登录会看到门禁面板并给出主站登录入口 - 登录状态取自主站 `xiaobai_session` cookie。cookie 按"门牌"(域名 / IP)区分,只有用 `http://192.168.200.11:8765` 登录主站,浏览器才会把它带到 `192.168.200.11:8766`,此时打开 8766 免登录直通;用域名登录主站再进 8766 会看到门禁面板,按提示登录一次即可
- 仅管理员可进。每个页面与每个 `/admin/api/*` 接口都在服务端校验会话与 `role=admin`,非管理员一律 403,前端隐藏与否不作为权限依据。 - 仅管理员可进。每个页面与每个 `/admin/api/*` 接口都在服务端校验会话与 `role=admin`,非管理员一律 403,前端隐藏与否不作为权限依据。
- 校验方式是服务间桥接:中枢把 cookie 交给主站 `/api/hub-admin/session` 换回用户身份,结果缓存数秒。桥接凭 `HUB_ADMIN_TOKEN`(与主站 `.env` 同名变量必须一致),主站在任何处理器之前先校验它。 - 校验方式是服务间桥接:中枢把 cookie 交给主站 `/api/hub-admin/session` 换回用户身份,结果缓存数秒。桥接凭 `HUB_ADMIN_TOKEN`(与主站 `.env` 同名变量必须一致),主站在任何处理器之前先校验它。
- CSRF 令牌由会话派生(HMAC),随 `GET /admin/api/session` 下发,写操作必须带 `X-CSRF-Token` - CSRF 令牌由会话派生(HMAC),随 `GET /admin/api/session` 下发,写操作必须带 `X-CSRF-Token`
@@ -61,14 +61,14 @@ cp .env.example .env
|---|---|---| |---|---|---|
| `HUB_ADMIN_TOKEN` | 主站 + 中枢 | 服务间桥接令牌,两侧必须一致,缺失则控制台无法校验会话 | | `HUB_ADMIN_TOKEN` | 主站 + 中枢 | 服务间桥接令牌,两侧必须一致,缺失则控制台无法校验会话 |
| `REVIEW_BASE_URL` | 中枢 | 中枢访问主站的地址(容器内一般是服务名,如 `http://xiaobai-review:8765` | | `REVIEW_BASE_URL` | 中枢 | 中枢访问主站的地址(容器内一般是服务名,如 `http://xiaobai-review:8765` |
| `REVIEW_PUBLIC_URL` | 中枢 | 浏览器可达的主站地址,用于门禁的登录跳转;留空则按当前主机名推导 | | `REVIEW_PUBLIC_URL` | 中枢 | 浏览器可达的主站地址,用于门禁的登录跳转;留空则用固定内网地址 `http://192.168.200.11:8765` |
## 从主站迁入的两块配置 ## 从主站迁入的两块配置
- **模型池**:按供应商组织(同一 API 地址下可挂多个模型),填好地址与 Key 后可自动拉取 `/models` 勾选纳入;供应商不支持或拉取失败时用卡内「手动录入」兜底。主 / 辅模型分工在「调用编排」里指定。密钥加密存于主站,界面只回显后四位。 - **模型池**:按供应商组织(同一 API 地址下可挂多个模型),填好地址与 Key 后可自动拉取 `/models` 勾选纳入;供应商不支持或拉取失败时用卡内「手动录入」兜底。主 / 辅模型分工在「调用编排」里指定。密钥加密存于主站,界面只回显后四位。
- **会员与邀请码**:会员开通 / 续期 / 停用、每日调用额度,以及一次性邀请码的生成、复制、作废。注册必须提交有效邀请码,每个码只能成功注册一次(并发提交也只有一个成功)。列表只显示掩码,完整码仅在生成瞬间与「复制」动作中可得。 - **会员与邀请码**:会员开通 / 续期 / 停用、每日调用额度,以及一次性邀请码的生成、复制、作废。注册必须提交有效邀请码,每个码只能成功注册一次(并发提交也只有一个成功)。列表只显示掩码,完整码仅在生成瞬间与「复制」动作中可得。
数据仍归主站所有(同一个 `review.db`),中枢只是唯一的管理入口;主站页面上原本的模型池与会员管理分区已移除,「数据中枢」按钮指向 8766 数据仍归主站所有(同一个 `review.db`),中枢只是唯一的管理入口;主站页面上原本的模型池与会员管理分区已移除,「数据中枢」按钮固定指向 `http://192.168.200.11:8766/admin/`HEL-564,不按主机名推导)
## Docker(独立 compose,不改现网 review 服务) ## Docker(独立 compose,不改现网 review 服务)
+1 -1
View File
@@ -14,7 +14,7 @@ services:
DATAHUB_TOKEN: "${DATAHUB_TOKEN:?DATAHUB_TOKEN must be set}" DATAHUB_TOKEN: "${DATAHUB_TOKEN:?DATAHUB_TOKEN must be set}"
HUB_ADMIN_TOKEN: "${HUB_ADMIN_TOKEN:?HUB_ADMIN_TOKEN must be set}" HUB_ADMIN_TOKEN: "${HUB_ADMIN_TOKEN:?HUB_ADMIN_TOKEN must be set}"
REVIEW_BASE_URL: "${REVIEW_BASE_URL:-http://xiaobai-review:8765}" REVIEW_BASE_URL: "${REVIEW_BASE_URL:-http://xiaobai-review:8765}"
REVIEW_PUBLIC_URL: "${REVIEW_PUBLIC_URL:-}" REVIEW_PUBLIC_URL: "${REVIEW_PUBLIC_URL:-http://192.168.200.11:8765}"
TUSHARE_TOKEN: "${TUSHARE_TOKEN:-}" TUSHARE_TOKEN: "${TUSHARE_TOKEN:-}"
IFIND_REFRESH_TOKEN: "${IFIND_REFRESH_TOKEN:-}" IFIND_REFRESH_TOKEN: "${IFIND_REFRESH_TOKEN:-}"
IFIND_ACCESS_TOKEN: "${IFIND_ACCESS_TOKEN:-}" IFIND_ACCESS_TOKEN: "${IFIND_ACCESS_TOKEN:-}"
+6 -8
View File
@@ -11,6 +11,7 @@ from urllib.parse import unquote, urlparse
from datahub.hub import Hub from datahub.hub import Hub
from datahub.logutil import configure_logging, get_logger from datahub.logutil import configure_logging, get_logger
from datahub.serving import ApiError, parse_query from datahub.serving import ApiError, parse_query
from datahub.settings import DEFAULT_REVIEW_PUBLIC_URL
from datahub.siteauth import SITE_SESSION_COOKIE, SiteBridgeError from datahub.siteauth import SITE_SESSION_COOKIE, SiteBridgeError
LOGGER = get_logger() LOGGER = get_logger()
@@ -212,15 +213,12 @@ class HubRequestHandler(BaseHTTPRequestHandler):
def _review_url(self) -> str: def _review_url(self) -> str:
"""Browser-reachable review site URL. """Browser-reachable review site URL.
In production both services sit on the same host behind different HEL-564: fixed to REVIEW_PUBLIC_URL (default the intranet address)
ports, so the console derives the site URL from the Host header the instead of being derived from the browser's Host header — a domain
browser used; REVIEW_PUBLIC_URL overrides that when they do not. that only proxies 8765 would send the console back to a dead port.
""" """
configured = self.hub.settings.review_public_url configured = self.hub.settings.review_public_url or DEFAULT_REVIEW_PUBLIC_URL
if configured: return configured.rstrip("/")
return configured.rstrip("/")
host = (self.headers.get("Host") or "").split(":")[0] or "127.0.0.1"
return f"http://{host}:8765"
def _login_url(self) -> str: def _login_url(self) -> str:
return f"{self._review_url()}/login/" return f"{self._review_url()}/login/"
+4 -1
View File
@@ -10,6 +10,9 @@ ROOT = Path(__file__).resolve().parents[1]
DEFAULT_DB_PATH = Path(os.environ.get("DATAHUB_DB_PATH") or (ROOT / "data" / "datahub.db")) DEFAULT_DB_PATH = Path(os.environ.get("DATAHUB_DB_PATH") or (ROOT / "data" / "datahub.db"))
DEFAULT_BACKUP_DIR = Path(os.environ.get("DATAHUB_BACKUP_DIR") or (ROOT / "data" / "backups")) DEFAULT_BACKUP_DIR = Path(os.environ.get("DATAHUB_BACKUP_DIR") or (ROOT / "data" / "backups"))
DEFAULT_CONFIG_PATH = ROOT / "config" / "hub-quality.config.json" DEFAULT_CONFIG_PATH = ROOT / "config" / "hub-quality.config.json"
# HEL-564: 浏览器可达的主站地址固定成内网 IP,不再按请求 Host 推导——域名只
# 反代 8765,推导出来的地址在域名访问下必然打不开。
DEFAULT_REVIEW_PUBLIC_URL = "http://192.168.200.11:8765"
def _load_quality(path: Path) -> dict[str, Any]: def _load_quality(path: Path) -> dict[str, Any]:
@@ -128,7 +131,7 @@ def load_settings(
encryption_key=str(environ.get("DATAHUB_ENCRYPTION_KEY") or "").strip(), encryption_key=str(environ.get("DATAHUB_ENCRYPTION_KEY") or "").strip(),
api_token=str(environ.get("DATAHUB_TOKEN") or "").strip(), api_token=str(environ.get("DATAHUB_TOKEN") or "").strip(),
review_base_url=str(environ.get("REVIEW_BASE_URL") or "http://xiaobai-review:8765").strip(), review_base_url=str(environ.get("REVIEW_BASE_URL") or "http://xiaobai-review:8765").strip(),
review_public_url=str(environ.get("REVIEW_PUBLIC_URL") or "").strip(), review_public_url=str(environ.get("REVIEW_PUBLIC_URL") or DEFAULT_REVIEW_PUBLIC_URL).strip(),
hub_admin_token=str(environ.get("HUB_ADMIN_TOKEN") or "").strip(), hub_admin_token=str(environ.get("HUB_ADMIN_TOKEN") or "").strip(),
tushare_token=str(environ.get("TUSHARE_TOKEN") or "").strip(), tushare_token=str(environ.get("TUSHARE_TOKEN") or "").strip(),
ifind_refresh_token=str(environ.get("IFIND_REFRESH_TOKEN") or "").strip(), ifind_refresh_token=str(environ.get("IFIND_REFRESH_TOKEN") or "").strip(),
+15 -1
View File
@@ -16,7 +16,7 @@ from datahub.crypto import SecretVault
from datahub.httpapp import make_handler from datahub.httpapp import make_handler
from datahub.hub import Hub from datahub.hub import Hub
from datahub.logutil import JsonFormatter from datahub.logutil import JsonFormatter
from datahub.settings import Settings from datahub.settings import DEFAULT_REVIEW_PUBLIC_URL, Settings
from tests.fixtures import StubSiteAuth, fake_transport from tests.fixtures import StubSiteAuth, fake_transport
@@ -87,6 +87,20 @@ class AdminTests(unittest.TestCase):
self.assertFalse(payload["authenticated"]) self.assertFalse(payload["authenticated"])
self.assertEqual(payload["login_url"], "http://127.0.0.1:8765/login/") self.assertEqual(payload["login_url"], "http://127.0.0.1:8765/login/")
def test_login_url_ignores_the_browser_host_header(self) -> None:
"""HEL-564: the login link is the fixed intranet address.
Deriving it from Host sent admins to <域名>:8765 under a domain that
only proxies the review site, so the console now sticks to
REVIEW_PUBLIC_URL (default DEFAULT_REVIEW_PUBLIC_URL).
"""
self.hub.settings.review_public_url = ""
req = Request(self.base + "/admin/api/session", headers={"Host": "xiaobaifupan.com"})
with self.assertRaises(HTTPError) as ctx:
urlopen(req, timeout=5)
payload = json.loads(ctx.exception.read().decode())
self.assertEqual(payload["login_url"], f"{DEFAULT_REVIEW_PUBLIC_URL}/login/")
def test_non_admin_site_accounts_are_refused(self) -> None: def test_non_admin_site_accounts_are_refused(self) -> None:
self.site_auth.add_session("member-session", StubSiteAuth.MEMBER) self.site_auth.add_session("member-session", StubSiteAuth.MEMBER)
with self.assertRaises(HTTPError) as ctx: with self.assertRaises(HTTPError) as ctx: