增加反代

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 from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os import os
import secrets
import threading
import time
from typing import Any
from flask import request 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: def hub_bridge_token() -> str:
return (os.getenv("HUB_BRIDGE_TOKEN") or "").strip() 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: if tok and request.headers.get("X-Hub-Token") == tok:
return True return True
return False 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 import time
from functools import wraps 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): def _hub_auth_required(f):
@@ -244,6 +244,22 @@ def register_hub_routes(app):
return jsonify({"ok": False, "msg": "预览不存在或已过期"}), 404 return jsonify({"ok": False, "msg": "预览不存在或已过期"}), 404
return jsonify({"ok": True, "preview": preview}) 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(): def _latest_preview_id():
get_db = _ctx().get("get_db") get_db = _ctx().get("get_db")
+9 -3
View File
@@ -11,7 +11,9 @@ HUB_PORT=5100
# 与四实例 .env 中 HUB_BRIDGE_TOKEN 相同的长随机串 # 与四实例 .env 中 HUB_BRIDGE_TOKEN 相同的长随机串
# 中控 → 各 Flask:请求头 X-Hub-Token # 中控 → 各 Flask:请求头 X-Hub-Token
# 中控 → 各子代理:请求头 X-Control-Token(可与子代理 CONTROL_TOKEN 同值,hub 会用 HUB_BRIDGE_TOKEN 转发) # 中控 → 各子代理:请求头 X-Control-Token(可与子代理 CONTROL_TOKEN 同值,hub 会用 HUB_BRIDGE_TOKEN 转发)
# 中控「打开实例」SSO 链接也复用此令牌签名(默认 2 小时内有效、单次使用)
# HUB_BRIDGE_TOKEN=your-long-random-token # HUB_BRIDGE_TOKEN=your-long-random-token
# HUB_SSO_TTL_SEC=7200
# 逗号分隔的账户 id,强制关闭(不参与监控/全局全平;设置页对应行勾选框灰掉) # 逗号分隔的账户 id,强制关闭(不参与监控/全局全平;设置页对应行勾选框灰掉)
# 留空 = 不强制关闭;仅不想用 OKX 时可设 HUB_DISABLED_IDS=1 # 留空 = 不强制关闭;仅不想用 OKX 时可设 HUB_DISABLED_IDS=1
@@ -30,12 +32,16 @@ HUB_TRUST_LAN=true
# 登录保持天数(默认 7 # 登录保持天数(默认 7
# HUB_SESSION_DAYS=7 # HUB_SESSION_DAYS=7
# 浏览器打开的复盘/实例链接:把 127.0.0.1 换成 Ubuntu 内网 IP 或域名(中控本机调 API 仍用 127.0.0.1 # 浏览器打开的实例/复盘链接(hub_settings 里 flask_url 为 127.0.0.1 时替换为对外地址
# 例:用手机/另一台电脑访问中控时必填,否则「交易复盘」会指向你自己电脑的 localhost # 局域网:填内网 IP,见《局域网与反代部署说明.md》
# HUB_PUBLIC_ORIGIN=http://192.168.1.100 # 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 # HUB_PUBLIC_SCHEME=http
# 四实例网页登录(直链反代/IP:端口 访问时输入;中控点「打开实例」免输)
# 各 crypto_monitor_*/.env 统一:APP_USERNAME=... APP_PASSWORD=...
# 监控区 /api/monitor/board 聚合超时(秒,默认 agent 8 / flask 10 # 监控区 /api/monitor/board 聚合超时(秒,默认 agent 8 / flask 10
# HUB_AGENT_TIMEOUT=8 # HUB_AGENT_TIMEOUT=8
# HUB_FLASK_TIMEOUT=10 # HUB_FLASK_TIMEOUT=10
+37 -1
View File
@@ -35,7 +35,9 @@ from hub_web_auth import (
expected_username, expected_username,
verify_credentials, 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 url_public import browser_url, default_review_url, public_origin
from urllib.parse import urlencode
try: try:
from exchange_orders import symbols_match as _symbols_match 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() _trust_raw = (os.getenv("HUB_TRUST_LAN", "true") or "").strip().lower()
HUB_TRUST_LAN = _trust_raw not in ("0", "false", "no", "off") HUB_TRUST_LAN = _trust_raw not in ("0", "false", "no", "off")
DIR = Path(__file__).resolve().parent 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_AGENT_TIMEOUT = float(os.getenv("HUB_AGENT_TIMEOUT", "8"))
HUB_FLASK_TIMEOUT = float(os.getenv("HUB_FLASK_TIMEOUT", "10")) HUB_FLASK_TIMEOUT = float(os.getenv("HUB_FLASK_TIMEOUT", "10"))
_board_key_prices_raw = (os.getenv("HUB_BOARD_KEY_PRICES", "true") or "").strip().lower() _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): class CloseAllBody(BaseModel):
exclude_ids: list[str] = Field(default_factory=list) exclude_ids: list[str] = Field(default_factory=list)
+30 -8
View File
@@ -17,6 +17,22 @@
return r; 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() { async function initAuth() {
try { try {
const r = await fetch("/api/auth/status"); const r = await fetch("/api/auth/status");
@@ -312,6 +328,13 @@
} }
function bindMonitorInteractions(box) { 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) => { box.querySelectorAll(".btn-close-ex").forEach((btn) => {
btn.onclick = () => closeOne(btn.dataset.id); btn.onclick = () => closeOne(btn.dataset.id);
}); });
@@ -703,7 +726,6 @@
kmap[k.id] = k; kmap[k.id] = k;
}); });
const flaskOpen = row.flask_url_browser || row.flask_url; const flaskOpen = row.flask_url_browser || row.flask_url;
const strategyUrl = flaskOpen ? esc(flaskOpen.replace(/\/$/, "") + "/strategy") : "";
let html = `<div class="fs-head"> let html = `<div class="fs-head">
<div> <div>
<h2 class="fs-title">${esc(row.name)}</h2> <h2 class="fs-title">${esc(row.name)}</h2>
@@ -711,8 +733,8 @@
</div> </div>
<div class="fs-head-actions"> <div class="fs-head-actions">
<button type="button" class="ghost btn-expand-back">返回监控</button> <button type="button" class="ghost btn-expand-back">返回监控</button>
${flaskOpen ? `<a class="btn-link" href="${esc(flaskOpen)}" target="_blank" rel="noopener">打开实例</a>` : ""} ${flaskOpen ? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/">打开实例</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="/strategy">策略交易</a>` : ""}
<button type="button" class="danger btn-close-ex" data-id="${esc(row.id)}">全平</button> <button type="button" class="danger btn-close-ex" data-id="${esc(row.id)}">全平</button>
</div> </div>
</div>`; </div>`;
@@ -937,12 +959,12 @@
const online = row.http_ok && agOk; const online = row.http_ok && agOk;
const cardCls = online ? "card-online" : "card-offline"; const cardCls = online ? "card-online" : "card-offline";
const dotCls = online ? "ok" : "bad"; 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 flaskOpen = row.flask_url_browser || row.flask_url;
const openFlask = flaskOpen 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)}"> return `<div class="card ${cardCls}" data-ex-id="${esc(row.id)}">
<div class="card-head card-expand-zone" title="点击放大全屏"> <div class="card-head card-expand-zone" title="点击放大全屏">
@@ -955,7 +977,7 @@
</div> </div>
<div class="card-actions"> <div class="card-actions">
${openFlask} ${openFlask}
${review} ${openReview}
<button type="button" class="danger btn-close-ex" data-id="${esc(row.id)}">全平</button> <button type="button" class="danger btn-close-ex" data-id="${esc(row.id)}">全平</button>
</div> </div>
</div> </div>
+1 -1
View File
@@ -109,6 +109,6 @@
</div> </div>
<div id="toast"></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> </body>
</html> </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),为本地监控计划,**不等于**交易所条件单 | | **机器人单** | 来自实例 `/api/hub/monitor``order_monitors`(active),为本地监控计划,**不等于**交易所条件单 |
| **关键位** | 仅 `capabilities``key` 的户;展示门控摘要(`/api/price_snapshot` | | **关键位** | 仅 `capabilities``key` 的户;展示门控摘要(`/api/price_snapshot` |
| **趋势计划** | 仅当该户勾选 **监控趋势计划** 时展示 `trend_pullback_plans`active | | **趋势计划** | 仅当该户勾选 **监控趋势计划** 时展示 `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 趋势户**无关键位块 | | **关键位列表** | 来自 `/api/hub/monitor` + `/api/price_snapshot`;Flask 未连通时卡片提示原因;**Gate 趋势户**无关键位块 |
| **该户全平** | `POST` 子代理 `/emergency/close-all`,仅平该 API Key 仓位 | | **该户全平** | `POST` 子代理 `/emergency/close-all`,仅平该 API Key 仓位 |
| **全局紧急全平** | 对所有已启用户依次全平(不含 `HUB_DISABLED_IDS` 强制关闭的 id | | **全局紧急全平** | 对所有已启用户依次全平(不含 `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` **可以**。中控聚合监控与全平;复盘、下单、关键位维护进各实例网页。实例 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 趋势 / 复盘相关(实例侧) ## 五、Gate 趋势 / 复盘相关(实例侧)
+1
View File
@@ -3,6 +3,7 @@
本文档说明在 **Ubuntu / Linux** 上部署 **manual_trading_hub**(复盘系统中控:监控区、系统设置、登录保护)的推荐步骤。 本文档说明在 **Ubuntu / Linux** 上部署 **manual_trading_hub**(复盘系统中控:监控区、系统设置、登录保护)的推荐步骤。
- 功能与界面:[使用说明.md](./使用说明.md) - 功能与界面:[使用说明.md](./使用说明.md)
- **局域网 IP:端口 / 反代域名、中控打开实例免登录**:[局域网与反代部署说明.md](./局域网与反代部署说明.md)
- 故障实录:[常见问题.md](./常见问题.md) - 故障实录:[常见问题.md](./常见问题.md)
- 环境变量模板:[.env.example](./.env.example) - 环境变量模板:[.env.example](./.env.example)