增加反代
This commit is contained in:
+106
-2
@@ -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 token(exchange_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
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 | 15200~15203(仅本机) |
|
||||||
|
|
||||||
|
### 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**(Let’s 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)。
|
||||||
@@ -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 趋势 / 复盘相关(实例侧)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user