修复中控
This commit is contained in:
@@ -33,6 +33,8 @@ APP_AUTH_DISABLED=true
|
|||||||
# 允许复盘中控 iframe 内嵌本实例(与 hub 域名一致;默认已开启)
|
# 允许复盘中控 iframe 内嵌本实例(与 hub 域名一致;默认已开启)
|
||||||
# APP_ALLOW_HUB_EMBED=true
|
# APP_ALLOW_HUB_EMBED=true
|
||||||
# HUB_EMBED_PARENT_ORIGINS=https://hub.example.com
|
# HUB_EMBED_PARENT_ORIGINS=https://hub.example.com
|
||||||
|
# HTTPS 且中控与实例不同域名时必开,否则 hub-sso 登录态在 iframe 内无法保存
|
||||||
|
# APP_COOKIE_SECURE=true
|
||||||
# Flask 会话密钥(必须替换为长随机字符串)
|
# Flask 会话密钥(必须替换为长随机字符串)
|
||||||
FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET
|
FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET
|
||||||
|
|
||||||
|
|||||||
+41
-2
@@ -9,7 +9,15 @@ import json
|
|||||||
import time
|
import time
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from flask import current_app, get_flashed_messages, jsonify, redirect, request, session
|
from flask import (
|
||||||
|
current_app,
|
||||||
|
flash,
|
||||||
|
get_flashed_messages,
|
||||||
|
jsonify,
|
||||||
|
redirect,
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
)
|
||||||
|
|
||||||
from hub_auth import request_allowed
|
from hub_auth import request_allowed
|
||||||
from hub_sso import safe_next_path, verify_hub_sso_token
|
from hub_sso import safe_next_path, verify_hub_sso_token
|
||||||
@@ -109,9 +117,32 @@ def install_on_app(
|
|||||||
"views": views,
|
"views": views,
|
||||||
}
|
}
|
||||||
install_hub_embed_headers(app)
|
install_hub_embed_headers(app)
|
||||||
|
configure_hub_embed_session(app)
|
||||||
register_hub_routes(app)
|
register_hub_routes(app)
|
||||||
|
|
||||||
|
|
||||||
|
def configure_hub_embed_session(app):
|
||||||
|
"""HTTPS 跨域 iframe 内嵌时须 SameSite=None + Secure,否则 hub-sso 写入的 session 会丢失。"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
allowed = (os.getenv("APP_ALLOW_HUB_EMBED") or "true").strip().lower() in (
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
"on",
|
||||||
|
)
|
||||||
|
if not allowed:
|
||||||
|
return
|
||||||
|
secure = (os.getenv("APP_COOKIE_SECURE") or "").strip().lower()
|
||||||
|
if secure not in ("1", "true", "yes", "on"):
|
||||||
|
return
|
||||||
|
app.config.update(
|
||||||
|
SESSION_COOKIE_SECURE=True,
|
||||||
|
SESSION_COOKIE_SAMESITE="None",
|
||||||
|
SESSION_COOKIE_HTTPONLY=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def install_hub_embed_headers(app):
|
def install_hub_embed_headers(app):
|
||||||
"""允许复盘中控 iframe 内嵌打开本实例(须与 hub 的 HUB_EMBED_ORIGINS 或域名一致)。"""
|
"""允许复盘中控 iframe 内嵌打开本实例(须与 hub 的 HUB_EMBED_ORIGINS 或域名一致)。"""
|
||||||
import os
|
import os
|
||||||
@@ -286,10 +317,18 @@ def register_hub_routes(app):
|
|||||||
return redirect(safe_next_path(next_arg))
|
return redirect(safe_next_path(next_arg))
|
||||||
ex = str((_ctx().get("exchange") or "")).strip().lower()
|
ex = str((_ctx().get("exchange") or "")).strip().lower()
|
||||||
token = (request.args.get("token") or "").strip()
|
token = (request.args.get("token") or "").strip()
|
||||||
ok, next_path, _err = verify_hub_sso_token(token, ex)
|
ok, next_path, err = verify_hub_sso_token(token, ex)
|
||||||
if ok:
|
if ok:
|
||||||
session["logged_in"] = True
|
session["logged_in"] = True
|
||||||
|
session.modified = True
|
||||||
return redirect(next_path)
|
return redirect(next_path)
|
||||||
|
hint = err or "校验失败"
|
||||||
|
flash(
|
||||||
|
f"中控 SSO 未生效({hint})。"
|
||||||
|
"请确认中控与实例 .env 中 HUB_BRIDGE_TOKEN 一致,"
|
||||||
|
f"且中控设置里该账户 key 为「{ex}」。"
|
||||||
|
"直链实例地址仍需输入 APP_PASSWORD。"
|
||||||
|
)
|
||||||
return redirect("/login")
|
return redirect("/login")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ HUB_TRUST_LAN=true
|
|||||||
# 四实例允许被中控 iframe 内嵌(各 crypto_monitor_*/.env,与 hub 同步部署)
|
# 四实例允许被中控 iframe 内嵌(各 crypto_monitor_*/.env,与 hub 同步部署)
|
||||||
# APP_ALLOW_HUB_EMBED=true
|
# APP_ALLOW_HUB_EMBED=true
|
||||||
# HUB_EMBED_PARENT_ORIGINS=https://hub.example.com
|
# HUB_EMBED_PARENT_ORIGINS=https://hub.example.com
|
||||||
|
# HTTPS 跨子域 iframe 时四实例还须 APP_COOKIE_SECURE=true(见 crypto_monitor_*/.env.example)
|
||||||
|
|
||||||
# 浏览器打开的实例/复盘链接(hub_settings 里 flask_url 为 127.0.0.1 时替换为对外地址)
|
# 浏览器打开的实例/复盘链接(hub_settings 里 flask_url 为 127.0.0.1 时替换为对外地址)
|
||||||
# 局域网:填内网 IP,见《局域网与反代部署说明.md》
|
# 局域网:填内网 IP,见《局域网与反代部署说明.md》
|
||||||
|
|||||||
@@ -18,25 +18,55 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let instanceFrameUrl = "";
|
let instanceFrameUrl = "";
|
||||||
|
/** @type {{ exchangeId: string, nextPath: string, title: string } | null} */
|
||||||
|
let instanceFrameCtx = null;
|
||||||
|
|
||||||
|
async function fetchInstanceOpenUrl(exchangeId, nextPath) {
|
||||||
|
const next = nextPath || "/";
|
||||||
|
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) {
|
||||||
|
throw new Error(j.detail || "无法生成打开链接");
|
||||||
|
}
|
||||||
|
return j.url;
|
||||||
|
}
|
||||||
|
|
||||||
async function openInstance(exchangeId, nextPath, opts) {
|
async function openInstance(exchangeId, nextPath, opts) {
|
||||||
const options = opts || {};
|
const options = opts || {};
|
||||||
const newTab = !!options.newTab;
|
const newTab = !!options.newTab;
|
||||||
const next = nextPath || "/";
|
const next = nextPath || "/";
|
||||||
try {
|
try {
|
||||||
const q = new URLSearchParams({ exchange_id: String(exchangeId), next });
|
const url = await fetchInstanceOpenUrl(exchangeId, next);
|
||||||
const r = await apiFetch("/api/instance/open-url?" + q.toString());
|
|
||||||
const j = await r.json();
|
|
||||||
if (!j.ok || !j.url) {
|
|
||||||
showToast(j.detail || "无法生成打开链接", true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (newTab) {
|
if (newTab) {
|
||||||
window.open(j.url, "_blank", "noopener");
|
window.open(url, "_blank", "noopener");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const row = lastMonitorRows.find((x) => String(x.id) === String(exchangeId));
|
const row = lastMonitorRows.find((x) => String(x.id) === String(exchangeId));
|
||||||
openInstanceFrame(j.url, row ? row.name : exchangeId);
|
const title = row ? row.name : exchangeId;
|
||||||
|
instanceFrameCtx = { exchangeId: String(exchangeId), nextPath: next, title };
|
||||||
|
openInstanceFrame(url, title);
|
||||||
|
} catch (e) {
|
||||||
|
showToast(String(e), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshInstanceFrame() {
|
||||||
|
if (!instanceFrameCtx) {
|
||||||
|
if (instanceFrameUrl) {
|
||||||
|
const frame = document.getElementById("instance-frame");
|
||||||
|
if (frame) frame.src = instanceFrameUrl;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const url = await fetchInstanceOpenUrl(
|
||||||
|
instanceFrameCtx.exchangeId,
|
||||||
|
instanceFrameCtx.nextPath
|
||||||
|
);
|
||||||
|
instanceFrameUrl = url;
|
||||||
|
const frame = document.getElementById("instance-frame");
|
||||||
|
if (frame) frame.src = url;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(String(e), true);
|
showToast(String(e), true);
|
||||||
}
|
}
|
||||||
@@ -63,6 +93,7 @@
|
|||||||
const shell = document.getElementById("instance-frame-shell");
|
const shell = document.getElementById("instance-frame-shell");
|
||||||
const frame = document.getElementById("instance-frame");
|
const frame = document.getElementById("instance-frame");
|
||||||
instanceFrameUrl = "";
|
instanceFrameUrl = "";
|
||||||
|
instanceFrameCtx = null;
|
||||||
if (frame) frame.src = "about:blank";
|
if (frame) frame.src = "about:blank";
|
||||||
if (shell) {
|
if (shell) {
|
||||||
shell.classList.add("hidden");
|
shell.classList.add("hidden");
|
||||||
@@ -979,13 +1010,15 @@
|
|||||||
const newTab = document.getElementById("instance-frame-newtab");
|
const newTab = document.getElementById("instance-frame-newtab");
|
||||||
const frame = document.getElementById("instance-frame");
|
const frame = document.getElementById("instance-frame");
|
||||||
if (back) back.onclick = () => closeInstanceFrame();
|
if (back) back.onclick = () => closeInstanceFrame();
|
||||||
if (refresh && frame) {
|
if (refresh) refresh.onclick = () => refreshInstanceFrame();
|
||||||
refresh.onclick = () => {
|
|
||||||
if (instanceFrameUrl) frame.src = instanceFrameUrl;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (newTab) {
|
if (newTab) {
|
||||||
newTab.onclick = () => {
|
newTab.onclick = () => {
|
||||||
|
if (instanceFrameCtx) {
|
||||||
|
openInstance(instanceFrameCtx.exchangeId, instanceFrameCtx.nextPath, {
|
||||||
|
newTab: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (instanceFrameUrl) window.open(instanceFrameUrl, "_blank", "noopener");
|
if (instanceFrameUrl) window.open(instanceFrameUrl, "_blank", "noopener");
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="toast"></div>
|
<div id="toast"></div>
|
||||||
<script src="/assets/app.js?v=20260530-hub-iframe"></script>
|
<script src="/assets/app.js?v=20260530-hub-sso-fix"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -226,7 +226,8 @@ HUB_PUBLIC_ORIGIN=http://192.168.8.6
|
|||||||
1. 四实例未重启,`/hub-sso` 未加载(启动日志勿长期 `[hub_bridge] ImportError`)。
|
1. 四实例未重启,`/hub-sso` 未加载(启动日志勿长期 `[hub_bridge] ImportError`)。
|
||||||
2. `HUB_BRIDGE_TOKEN` 与四实例 `.env` 不一致。
|
2. `HUB_BRIDGE_TOKEN` 与四实例 `.env` 不一致。
|
||||||
3. `hub_settings` 里该户 `key` 与实例 `install_on_app(exchange=...)` 不一致(如 `okx`、`gate_bot`)。
|
3. `hub_settings` 里该户 `key` 与实例 `install_on_app(exchange=...)` 不一致(如 `okx`、`gate_bot`)。
|
||||||
4. 浏览器仍用旧书签直链首页,未从中控点「实例」(直链本来就要登录)。
|
4. **HTTPS 跨域 iframe**:中控与实例不同域名时,四实例须 `APP_COOKIE_SECURE=true`(使 session Cookie 为 `SameSite=None`),否则 SSO 成功仍跳 `/login`。
|
||||||
|
5. 浏览器仍用旧书签直链首页,未从中控点「实例」(直链本来就要登录)。
|
||||||
|
|
||||||
**直链**:`http://IP:端口` 或 `https://实例域名` → 使用各实例 **`APP_USERNAME` / `APP_PASSWORD`**(四所建议统一)。
|
**直链**:`http://IP:端口` 或 `https://实例域名` → 使用各实例 **`APP_USERNAME` / `APP_PASSWORD`**(四所建议统一)。
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user