增加反代

This commit is contained in:
dekun
2026-05-25 11:49:53 +08:00
parent 1e36086465
commit 325f12c0a7
10 changed files with 442 additions and 18 deletions
+106 -2
View File
@@ -1,11 +1,24 @@
"""中控调用实例 API 时的鉴权辅助(各 crypto_monitor_* 的 login_required 共用)。"""
"""中控调用实例 API 时的鉴权;实例浏览器 SSO(复用 HUB_BRIDGE_TOKEN)。"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import secrets
import threading
import time
from typing import Any
from flask import request
# 中控打开实例链接有效期(秒),默认 2 小时
HUB_SSO_TTL_SEC = int(os.getenv("HUB_SSO_TTL_SEC", "7200"))
_used_nonces: dict[str, float] = {}
_nonce_lock = threading.Lock()
def hub_bridge_token() -> str:
return (os.getenv("HUB_BRIDGE_TOKEN") or "").strip()
@@ -18,3 +31,94 @@ def request_allowed(session_logged_in: bool, auth_disabled: bool) -> bool:
if tok and request.headers.get("X-Hub-Token") == tok:
return True
return False
def safe_next_path(raw: str | None) -> str:
"""仅允许站内相对路径,防止开放重定向。"""
p = (raw or "/").strip()
if not p.startswith("/") or p.startswith("//"):
return "/"
if "://" in p:
return "/"
return p
def _sso_secret() -> str:
return hub_bridge_token()
def _b64url_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode().rstrip("=")
def _b64url_decode(data: str) -> bytes:
pad = "=" * (-len(data) % 4)
return base64.urlsafe_b64decode(data + pad)
def _prune_used_nonces() -> None:
now = time.time()
with _nonce_lock:
dead = [k for k, exp in _used_nonces.items() if exp <= now]
for k in dead:
del _used_nonces[k]
def mint_hub_sso_token(exchange_key: str, next_path: str = "/") -> str | None:
"""签发实例浏览器 SSO tokenexchange_key 与 hub_bridge install_on_app 的 exchange 一致)。"""
secret = _sso_secret()
ex = (exchange_key or "").strip().lower()
if not secret or not ex:
return None
payload = {
"ex": ex,
"exp": int(time.time()) + max(60, HUB_SSO_TTL_SEC),
"nonce": secrets.token_urlsafe(16),
"next": safe_next_path(next_path),
}
body = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode())
sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest()
return f"{body}.{sig}"
def verify_hub_sso_token(
token: str | None, expected_exchange: str
) -> tuple[bool, str, str | None]:
"""
校验 SSO token。成功返回 (True, next_path, None);失败返回 (False, '/', 原因)。
单次使用:nonce 用过后在 exp 之前不可复用。
"""
secret = _sso_secret()
expected = (expected_exchange or "").strip().lower()
if not secret or not expected:
return False, "/", "未配置 HUB_BRIDGE_TOKEN"
raw = (token or "").strip()
if "." not in raw:
return False, "/", "token 无效"
body, sig = raw.rsplit(".", 1)
try:
expect_sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expect_sig, sig):
return False, "/", "签名校验失败"
payload = json.loads(_b64url_decode(body).decode())
except Exception:
return False, "/", "token 解析失败"
if not isinstance(payload, dict):
return False, "/", "payload 无效"
if str(payload.get("ex") or "").lower() != expected:
return False, "/", "实例不匹配"
try:
exp = int(payload.get("exp") or 0)
except (TypeError, ValueError):
return False, "/", "exp 无效"
if exp < int(time.time()):
return False, "/", "链接已过期"
nonce = str(payload.get("nonce") or "")
if not nonce:
return False, "/", "nonce 缺失"
_prune_used_nonces()
with _nonce_lock:
if nonce in _used_nonces:
return False, "/", "链接已使用"
_used_nonces[nonce] = float(exp)
return True, safe_next_path(str(payload.get("next") or "/")), None
+18 -2
View File
@@ -9,9 +9,9 @@ import json
import time
from functools import wraps
from flask import current_app, get_flashed_messages, jsonify, request, session
from flask import current_app, get_flashed_messages, jsonify, redirect, request, session
from hub_auth import request_allowed
from hub_auth import request_allowed, safe_next_path, verify_hub_sso_token
def _hub_auth_required(f):
@@ -244,6 +244,22 @@ def register_hub_routes(app):
return jsonify({"ok": False, "msg": "预览不存在或已过期"}), 404
return jsonify({"ok": True, "preview": preview})
@app.route("/hub-sso")
def hub_sso_login():
"""中控签发的临时链接:写入 session 后跳转,直链访问仍走 /login。"""
auth_disabled = bool(current_app.config.get("HUB_AUTH_DISABLED"))
next_arg = request.args.get("next")
if auth_disabled:
session["logged_in"] = True
return redirect(safe_next_path(next_arg))
ex = str((_ctx().get("exchange") or "")).strip().lower()
token = (request.args.get("token") or "").strip()
ok, next_path, _err = verify_hub_sso_token(token, ex)
if ok:
session["logged_in"] = True
return redirect(next_path)
return redirect("/login")
def _latest_preview_id():
get_db = _ctx().get("get_db")
+9 -3
View File
@@ -11,7 +11,9 @@ HUB_PORT=5100
# 与四实例 .env 中 HUB_BRIDGE_TOKEN 相同的长随机串
# 中控 → 各 Flask:请求头 X-Hub-Token
# 中控 → 各子代理:请求头 X-Control-Token(可与子代理 CONTROL_TOKEN 同值,hub 会用 HUB_BRIDGE_TOKEN 转发)
# 中控「打开实例」SSO 链接也复用此令牌签名(默认 2 小时内有效、单次使用)
# HUB_BRIDGE_TOKEN=your-long-random-token
# HUB_SSO_TTL_SEC=7200
# 逗号分隔的账户 id,强制关闭(不参与监控/全局全平;设置页对应行勾选框灰掉)
# 留空 = 不强制关闭;仅不想用 OKX 时可设 HUB_DISABLED_IDS=1
@@ -30,12 +32,16 @@ HUB_TRUST_LAN=true
# 登录保持天数(默认 7
# HUB_SESSION_DAYS=7
# 浏览器打开的复盘/实例链接:把 127.0.0.1 换成 Ubuntu 内网 IP 或域名(中控本机调 API 仍用 127.0.0.1
# 例:用手机/另一台电脑访问中控时必填,否则「交易复盘」会指向你自己电脑的 localhost
# 浏览器打开的实例/复盘链接(hub_settings 里 flask_url 为 127.0.0.1 时替换为对外地址
# 局域网:填内网 IP,见《局域网与反代部署说明.md》
# HUB_PUBLIC_ORIGIN=http://192.168.1.100
# 或只写主机名:HUB_PUBLIC_HOST=192.168.1.100
# 反代:各实例 flask_url 建议直接写 https 域名,可不设此项
# HUB_PUBLIC_HOST=192.168.1.100
# HUB_PUBLIC_SCHEME=http
# 四实例网页登录(直链反代/IP:端口 访问时输入;中控点「打开实例」免输)
# 各 crypto_monitor_*/.env 统一:APP_USERNAME=... APP_PASSWORD=...
# 监控区 /api/monitor/board 聚合超时(秒,默认 agent 8 / flask 10
# HUB_AGENT_TIMEOUT=8
# HUB_FLASK_TIMEOUT=10
+37 -1
View File
@@ -35,7 +35,9 @@ from hub_web_auth import (
expected_username,
verify_credentials,
)
from hub_auth import HUB_SSO_TTL_SEC, mint_hub_sso_token, safe_next_path
from url_public import browser_url, default_review_url, public_origin
from urllib.parse import urlencode
try:
from exchange_orders import symbols_match as _symbols_match
@@ -52,7 +54,7 @@ HUB_BRIDGE_TOKEN = (os.getenv("HUB_BRIDGE_TOKEN") or os.getenv("CONTROL_TOKEN")
_trust_raw = (os.getenv("HUB_TRUST_LAN", "true") or "").strip().lower()
HUB_TRUST_LAN = _trust_raw not in ("0", "false", "no", "off")
DIR = Path(__file__).resolve().parent
HUB_BUILD = "20260525-okx-tpsl2"
HUB_BUILD = "20260525-hub-sso"
HUB_AGENT_TIMEOUT = float(os.getenv("HUB_AGENT_TIMEOUT", "8"))
HUB_FLASK_TIMEOUT = float(os.getenv("HUB_FLASK_TIMEOUT", "10"))
_board_key_prices_raw = (os.getenv("HUB_BOARD_KEY_PRICES", "true") or "").strip().lower()
@@ -523,6 +525,40 @@ async def api_monitor_board():
}
def _require_hub_logged_in(request: Request) -> None:
if password_required() and not validate_session_token(request.cookies.get(SESSION_COOKIE)):
raise HTTPException(status_code=401, detail="未登录中控")
@app.get("/api/instance/open-url")
def api_instance_open_url(request: Request, exchange_id: str, next: str = "/"):
"""已登录中控时生成实例 SSO 打开链接(2h 有效、单次使用,复用 HUB_BRIDGE_TOKEN)。"""
_require_hub_logged_in(request)
if not HUB_BRIDGE_TOKEN:
raise HTTPException(status_code=503, detail="未配置 HUB_BRIDGE_TOKEN,无法签发实例打开链接")
ex = _find_exchange(exchange_id)
if not ex:
raise HTTPException(status_code=404, detail="未知交易所 id")
base = browser_url((ex.get("flask_url") or "").strip()).rstrip("/")
if not base:
raise HTTPException(status_code=400, detail="该账户未配置 flask_url")
ex_key = (ex.get("key") or "").strip().lower()
if not ex_key:
raise HTTPException(status_code=400, detail="该账户缺少 key(用于 SSO 校验)")
nxt = safe_next_path(next)
token = mint_hub_sso_token(ex_key, nxt)
if not token:
raise HTTPException(status_code=503, detail="签发 SSO 失败")
q = urlencode({"token": token, "next": nxt})
return {
"ok": True,
"url": f"{base}/hub-sso?{q}",
"expires_in": HUB_SSO_TTL_SEC,
"exchange_id": exchange_id,
"exchange_key": ex_key,
}
class CloseAllBody(BaseModel):
exclude_ids: list[str] = Field(default_factory=list)
+30 -8
View File
@@ -17,6 +17,22 @@
return r;
}
async function openInstanceInBrowser(exchangeId, nextPath) {
const next = nextPath || "/";
try {
const q = new URLSearchParams({ exchange_id: String(exchangeId), next });
const r = await apiFetch("/api/instance/open-url?" + q.toString());
const j = await r.json();
if (j.ok && j.url) {
window.open(j.url, "_blank", "noopener");
return;
}
showToast(j.detail || "无法生成打开链接", true);
} catch (e) {
showToast(String(e), true);
}
}
async function initAuth() {
try {
const r = await fetch("/api/auth/status");
@@ -312,6 +328,13 @@
}
function bindMonitorInteractions(box) {
box.querySelectorAll(".btn-open-instance").forEach((btn) => {
btn.onclick = (ev) => {
ev.preventDefault();
ev.stopPropagation();
openInstanceInBrowser(btn.dataset.exId, btn.dataset.next || "/");
};
});
box.querySelectorAll(".btn-close-ex").forEach((btn) => {
btn.onclick = () => closeOne(btn.dataset.id);
});
@@ -703,7 +726,6 @@
kmap[k.id] = k;
});
const flaskOpen = row.flask_url_browser || row.flask_url;
const strategyUrl = flaskOpen ? esc(flaskOpen.replace(/\/$/, "") + "/strategy") : "";
let html = `<div class="fs-head">
<div>
<h2 class="fs-title">${esc(row.name)}</h2>
@@ -711,8 +733,8 @@
</div>
<div class="fs-head-actions">
<button type="button" class="ghost btn-expand-back">返回监控</button>
${flaskOpen ? `<a class="btn-link" href="${esc(flaskOpen)}" target="_blank" rel="noopener">打开实例</a>` : ""}
${strategyUrl ? `<a class="btn-link" href="${strategyUrl}" target="_blank" rel="noopener">策略交易</a>` : ""}
${flaskOpen ? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/">打开实例</a>` : ""}
${flaskOpen ? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/strategy">策略交易</a>` : ""}
<button type="button" class="danger btn-close-ex" data-id="${esc(row.id)}">全平</button>
</div>
</div>`;
@@ -937,12 +959,12 @@
const online = row.http_ok && agOk;
const cardCls = online ? "card-online" : "card-offline";
const dotCls = online ? "ok" : "bad";
const review = row.review_url
? `<a class="btn-link" href="${esc(row.review_url)}" target="_blank" rel="noopener">复盘</a>`
: "";
const flaskOpen = row.flask_url_browser || row.flask_url;
const openFlask = flaskOpen
? `<a class="btn-link" href="${esc(flaskOpen)}" target="_blank" rel="noopener">实例</a>`
? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/">实例</a>`
: "";
const openReview = flaskOpen
? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/records">复盘</a>`
: "";
return `<div class="card ${cardCls}" data-ex-id="${esc(row.id)}">
<div class="card-head card-expand-zone" title="点击放大全屏">
@@ -955,7 +977,7 @@
</div>
<div class="card-actions">
${openFlask}
${review}
${openReview}
<button type="button" class="danger btn-close-ex" data-id="${esc(row.id)}">全平</button>
</div>
</div>
+1 -1
View File
@@ -109,6 +109,6 @@
</div>
<div id="toast"></div>
<script src="/assets/app.js?v=20260525-okx-tpsl2"></script>
<script src="/assets/app.js?v=20260525-hub-sso"></script>
</body>
</html>
+1 -1
View File
@@ -186,7 +186,7 @@ curl -s http://127.0.0.1:5100/api/ping
| **机器人单** | 来自实例 `/api/hub/monitor``order_monitors`(active),为本地监控计划,**不等于**交易所条件单 |
| **关键位** | 仅 `capabilities``key` 的户;展示门控摘要(`/api/price_snapshot` |
| **趋势计划** | 仅当该户勾选 **监控趋势计划** 时展示 `trend_pullback_plans`active |
| **实例 / 复盘** | 「实例」→ 该户 Flask(**实盘下单、关键位、策略交易 `/strategy`、复盘**);「复盘」→ `/records`。若配置 **`HUB_PUBLIC_ORIGIN`**,外链替换 `127.0.0.1` |
| **实例 / 复盘** | 「实例」「策略交易」「复盘」经中控签发 **SSO 链接**(默认 2h、单次)打开,**免输**实例 `APP_USERNAME/PASSWORD`;直链实例 IP/域名仍走 `/login`。局域网与反代配置 **[局域网与反代部署说明.md](./局域网与反代部署说明.md)** |
| **关键位列表** | 来自 `/api/hub/monitor` + `/api/price_snapshot`;Flask 未连通时卡片提示原因;**Gate 趋势户**无关键位块 |
| **该户全平** | `POST` 子代理 `/emergency/close-all`,仅平该 API Key 仓位 |
| **全局紧急全平** | 对所有已启用户依次全平(不含 `HUB_DISABLED_IDS` 强制关闭的 id |
@@ -0,0 +1,226 @@
# 中控 · 局域网与反代部署说明
本文说明在 **局域网(IP + 端口)****宝塔/Nginx 反代(域名)** 两种场景下,如何配置中控与各实例,并实现:
- **从中控** 点「实例 / 策略交易 / 复盘」→ **免输入** 实例网页密码(SSO 临时链接,默认 **2 小时** 内有效、**单次使用**
- **浏览器直链** 实例地址(反代域名或 `http://IP:端口`)→ 进入 **`/login`**,输入统一 **`APP_USERNAME` / `APP_PASSWORD`**
SSO 签名复用 **`HUB_BRIDGE_TOKEN`**(与中控调实例 API 相同,四所 `.env``manual_trading_hub/.env` 保持一致)。
---
## 一、两种访问方式对照
| 项目 | 局域网 | 反代(域名) |
|------|--------|----------------|
| 中控地址 | `http://内网IP:5100` | `https://hub.你的域名.com` |
| 实例地址(浏览器) | `http://内网IP:5004` 等 | `https://okx.你的域名.com` 等 |
| `hub_settings``flask_url` | 建议写 **`http://内网IP:端口`** | 建议写 **`https://该实例域名`**(与浏览器一致) |
| 中控本机调实例 API | 可与浏览器相同;同机也可用 `http://127.0.0.1:端口` + `HUB_PUBLIC_ORIGIN` | 同机可用 `127.0.0.1:端口` 或域名(需 Nginx 转发 `X-Hub-Token` |
| `HUB_PUBLIC_ORIGIN` | 若 `flask_url``127.0.0.1`**必填** `http://内网IP` | 若 `flask_url` 已是完整域名,**可不设** |
| 宝塔 | 可不装反代,直连端口 | 每实例一个站点 + SSL;中控单独站点 |
| 直链登录 | 实例 `/login` | 实例 `/login` |
| 从中控打开 | `/hub-sso?token=...` 自动登录 | 同上 |
---
## 二、共用环境变量(必配)
### 2.1 中控 `manual_trading_hub/.env`
```bash
HUB_BRIDGE_TOKEN=请填一长串随机字符
HUB_USERNAME=admin # 中控登录(建议设置)
HUB_PASSWORD=你的中控密码
HUB_SSO_TTL_SEC=7200 # 可选,默认 7200 = 2 小时
```
### 2.2 四个实例 `crypto_monitor_*/.env`
每个目录相同(**直链**时用这套登录实例网页):
```bash
HUB_BRIDGE_TOKEN=与中控完全相同
APP_USERNAME=统一用户名
APP_PASSWORD=统一密码
# 云上切勿 APP_AUTH_DISABLED=true
```
### 2.3 子代理
`CONTROL_TOKEN` 可与 `HUB_BRIDGE_TOKEN` 相同;子代理只监听 `127.0.0.1`**不要**对公网暴露 `15200``15203`
---
## 三、局域网部署(IP + 端口)
适用:家里/办公室内网,例如服务器 `192.168.8.6`
### 3.1 端口约定(示例,以你实际为准)
| 服务 | 端口 |
|------|------|
| 中控 hub | 5100 |
| OKX Flask | 5004 |
| 币安 Flask | 5001 |
| Gate 训练 | 5000 |
| Gate 趋势 | 5002 |
| agent | 1520015203(仅本机) |
### 3.2 系统设置 `hub_settings.json`(网页「系统设置」保存)
浏览器里你会打开的地址,应使用 **内网 IP**,不要用 `127.0.0.1`(否则别的电脑上的浏览器会连到你本机):
```json
{
"flask_url": "http://192.168.8.6:5004",
"agent_url": "http://127.0.0.1:15201"
}
```
说明:
- **`flask_url`**:给浏览器用的实例页地址 → 写 **`http://192.168.8.6:端口`**
- **`agent_url`**:仅中控服务器访问 → 写 **`http://127.0.0.1:1520x`**
各账户按上表改端口即可。
### 3.3 可选:`flask_url` 仍写 127.0.0.1 时
若坚持 `flask_url``http://127.0.0.1:5004`(仅 hub 与本机 Flask 同机),在中控 `.env` 增加:
```bash
HUB_PUBLIC_ORIGIN=http://192.168.8.6
```
中控会把返回给前端的链接从 `127.0.0.1` 替换为 `192.168.8.6`(端口保留)。
### 3.4 访问方式
1. 中控:`http://192.168.8.6:5100` → 登录中控 → 点「实例」→ 新标签进入 OKX,**无需**再输实例密码。
2. 直链:`http://192.168.8.6:5004` → 出现登录页 → 输入 `APP_USERNAME` / `APP_PASSWORD`
### 3.5 防火墙
内网自用:放行 `5100`、各 `APP_PORT`;**不要**对公网开放 agent 端口。
---
## 四、反代部署(域名 + 宝塔)
适用:云服务器,对外用 HTTPS 域名。
### 4.1 域名规划(示例)
| 站点 | 反代到 |
|------|--------|
| `hub.example.com` | `127.0.0.1:5100` |
| `okx.example.com` | `127.0.0.1:5004` |
| `binance.example.com` | `127.0.0.1:5001` |
| `gate.example.com` | `127.0.0.1:5000` |
| `gate-bot.example.com` | `127.0.0.1:5002` |
Flask / hub 进程仍只监听 **127.0.0.1**`0.0.0.0` 本机端口,由 Nginx 对外提供 HTTPS。
### 4.2 宝塔操作要点
1. 每个域名 → **反向代理** → 目标 `http://127.0.0.1:对应端口`
2. 申请 **SSL**Lets Encrypt)。
3. **不要**再给实例站加一层宝塔「访问密码」(避免与 Flask `/login` 重复);直链鉴权用 **`APP_USERNAME` / `APP_PASSWORD`** 即可。
4. 自定义 Nginx 配置中保留 WebSocket/大 body 如需;确保代理头:
```nginx
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
```
中控请求实例 API 时会带 **`X-Hub-Token`**,Nginx 默认会转发请求头,一般无需额外配置。
### 4.3 `hub_settings` 示例(反代)
```json
{
"flask_url": "https://okx.example.com",
"agent_url": "http://127.0.0.1:15201"
}
```
- 浏览器与 SSO 链接使用 **`https://okx.example.com`**。
- 中控服务器拉 `/api/hub/*` 仍走本机 `agent_url``flask_url` 用域名时,hub 会请求 `https://okx.example.com/api/...`(同机可通即可)。
同机部署时也可:
- `flask_url`: `http://127.0.0.1:5004`
- `HUB_PUBLIC_ORIGIN`: `https://okx.example.com`
仅当**所有实例共用一个对外 IP、靠端口区分**时才适合用 `HUB_PUBLIC_ORIGIN`**每实例独立域名**时,请直接在 `flask_url` 写该实例域名。
### 4.4 中控 `.env`(反代建议)
```bash
HUB_BRIDGE_TOKEN=...
HUB_USERNAME=...
HUB_PASSWORD=...
HUB_COOKIE_SECURE=true # 中控为 HTTPS 时建议开启
```
### 4.5 访问方式
1. `https://hub.example.com` 登录中控 → 点「打开实例」→ `https://okx.example.com/hub-sso?...` → 进入系统。
2. 地址栏直接输入 `https://okx.example.com``/login` → 实例账号密码。
---
## 五、SSO 行为说明(2 小时)
| 项 | 说明 |
|----|------|
| 有效期 | 默认 **7200 秒(2 小时)**`HUB_SSO_TTL_SEC` 可改 |
| 单次使用 | 同一链接成功登录后 **不能再用**;需在中控重新点「打开实例」 |
| 密钥 | 复用 **`HUB_BRIDGE_TOKEN`** |
| 直链 | 无 token → 正常 **`/login`** |
---
## 六、部署与重启顺序
```bash
cd /opt/crypto_monitor
# 各实例
pm2 restart crypto_okx crypto_binance crypto_gate crypto_gate_bot # 名称以你为准
cd manual_trading_hub
pm2 restart manual-trading-hub manual-agent-binance manual-agent-okx manual-agent-gate manual-agent-gate-bot
```
`hub_settings``.env` 后重启 **hub + 对应实例 Flask**`hub_bridge``/hub-sso` 在实例进程内)。
---
## 七、验收清单
- [ ] 四实例 `.env` 与中控 `HUB_BRIDGE_TOKEN` 一致
- [ ] 四实例 `APP_USERNAME` / `APP_PASSWORD` 一致
- [ ] 局域网:`flask_url``http://IP:端口`;反代:`flask_url``https://域名`
- [ ] 已登录中控 → 点「实例」→ **无**实例登录页
- [ ] 隐身窗口直链实例域名/IP → **有** `/login`
- [ ] 复制「打开实例」完整 URL,用过一次后再开 → 失效并回到登录页
---
## 八、常见问题
**Q:从中控打开仍要登录?**
- 检查实例是否已 `git pull` 并重启(需有 `/hub-sso`)。
- `HUB_BRIDGE_TOKEN` 是否四所一致。
- `hub_settings` 里该账户 `key` 是否与 `install_on_app(exchange=...)` 一致(如 `okx``binance``gate``gate_bot`)。
**Q:直链也要登录中控?**
- 不应。直链只走实例 `/login`。若跳到中控,检查是否点错链接或 Nginx 配错站点。
**Q:链接多久失效?**
- 签发后 **2 小时**内且 **未使用过**;过期或已用需在中控重新点打开。
更多故障见 [常见问题.md](./常见问题.md)、[部署文档.md](./部署文档.md)。
+13
View File
@@ -192,6 +192,19 @@ HUB_PUBLIC_ORIGIN=http://192.168.8.6
**可以**。中控聚合监控与全平;复盘、下单、关键位维护进各实例网页。实例 Flask/agent 建议 `127.0.0.1` + 与中控相同的 `HUB_BRIDGE_TOKEN`
### 4.3 从中控「打开实例」仍要输密码
**完整说明**[局域网与反代部署说明.md](./局域网与反代部署说明.md)
**常见原因**
1. 四实例未重启,`/hub-sso` 未加载(启动日志勿长期 `[hub_bridge] ImportError`)。
2. `HUB_BRIDGE_TOKEN` 与四实例 `.env` 不一致。
3. `hub_settings` 里该户 `key` 与实例 `install_on_app(exchange=...)` 不一致(如 `okx``gate_bot`)。
4. 浏览器仍用旧书签直链首页,未从中控点「实例」(直链本来就要登录)。
**直链**`http://IP:端口``https://实例域名` → 使用各实例 **`APP_USERNAME` / `APP_PASSWORD`**(四所建议统一)。
---
## 五、Gate 趋势 / 复盘相关(实例侧)
+1
View File
@@ -3,6 +3,7 @@
本文档说明在 **Ubuntu / Linux** 上部署 **manual_trading_hub**(复盘系统中控:监控区、系统设置、登录保护)的推荐步骤。
- 功能与界面:[使用说明.md](./使用说明.md)
- **局域网 IP:端口 / 反代域名、中控打开实例免登录**[局域网与反代部署说明.md](./局域网与反代部署说明.md)
- 故障实录:[常见问题.md](./常见问题.md)
- 环境变量模板:[.env.example](./.env.example)