修复中控

This commit is contained in:
dekun
2026-05-30 15:19:38 +08:00
parent 4cd5a48dc1
commit a67d7aa58b
7 changed files with 447 additions and 53 deletions
+2 -1
View File
@@ -14,8 +14,9 @@
# - 若该用户已存在且设置 NAV_ADMIN_UPDATE_PASSWORD=1:用 NAV_ADMIN_PASSWORD 覆盖其密码(改完请删去或置 0,避免每次启动重置)。
# NAV_ADMIN_UPDATE_PASSWORD=1
# 数据库(默认 SQLite 文件在当前工作目录)
# 数据库(默认 SQLite 文件在当前工作目录;生产建议放 instance/ 且勿提交 Git
# NAV_DATABASE_URL=sqlite:///nav_local.db
# NAV_DATABASE_URL=sqlite:///instance/nav_local.db
# 仅 python app.py 直连启动时生效
# NAV_HOST=0.0.0.0
+148 -45
View File
@@ -93,6 +93,103 @@ def _apply_session_cookie_settings(app: Flask) -> None:
app.config["REMEMBER_COOKIE_SECURE"] = False
_HUB_SESSION_COOKIE = "hub_sess"
def _hub_api_login(base: str, username: str, password: str) -> tuple[str | None, str | None]:
"""服务端登录中控,返回 (session_token, error_detail)。"""
payload = json.dumps({"username": username, "password": password}).encode("utf-8")
req = urllib.request.Request(
f"{base.rstrip('/')}/api/auth/login",
data=payload,
headers={"Content-Type": "application/json", "Accept": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
raw = resp.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as e:
try:
err_body = e.read().decode("utf-8", errors="replace")
detail = json.loads(err_body).get("detail", err_body)
except Exception:
detail = str(e)
return None, detail or "中控登录失败"
except Exception as e:
return None, f"无法连接中控: {e}"
try:
data = json.loads(raw)
except json.JSONDecodeError:
return None, "中控返回非 JSON"
if not data.get("ok"):
return None, data.get("detail") or "登录失败"
token = (data.get("session_token") or "").strip()
if not token:
return None, "中控未返回 session_token,请升级云端 hub 后重试"
return token, None
def _hub_instance_open_url(
base: str, session_token: str, exchange_id: str, next_path: str, *, embed: bool = True
) -> tuple[str | None, str | None]:
"""已登录中控会话下签发实例 SSO 打开链接。"""
params = {"exchange_id": str(exchange_id), "next": next_path or "/"}
if embed:
params["embed"] = "1"
q = urlencode(params)
req = urllib.request.Request(
f"{base.rstrip('/')}/api/instance/open-url?{q}",
headers={
"Accept": "application/json",
"Cookie": f"{_HUB_SESSION_COOKIE}={session_token}",
},
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
raw = resp.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as e:
try:
err_body = e.read().decode("utf-8", errors="replace")
detail = json.loads(err_body).get("detail", err_body)
except Exception:
detail = str(e)
return None, detail or "无法签发实例链接"
except Exception as e:
return None, f"无法连接中控: {e}"
try:
data = json.loads(raw)
except json.JSONDecodeError:
return None, "中控返回非 JSON"
url = (data.get("url") or "").strip()
if not data.get("ok") or not url:
return None, data.get("detail") or "无法签发实例链接"
return url, None
def _resolve_hub_embed_service(body: dict) -> tuple[str | None, str | None, str | None]:
"""返回 (base_origin, default_next_path, error_detail)。"""
sid = body.get("service_id")
base = (body.get("base_url") or "").strip().rstrip("/")
next_path = (body.get("next") or "/monitor").strip() or "/monitor"
if not next_path.startswith("/"):
next_path = "/" + next_path
if sid:
svc = db.session.get(Service, int(sid))
if not svc or not svc.is_hub_embed():
return None, None, "服务不存在或未标记为中控"
base = svc.build_origin()
if not body.get("next") or next_path == "/monitor":
p = (svc.path or "/monitor").strip() or "/monitor"
next_path = p if p.startswith("/") else "/" + p
if not base:
return None, None, "缺少 base_url"
return base, next_path, None
def create_app() -> Flask:
app = Flask(__name__)
app.config["SECRET_KEY"] = os.environ.get("NAV_SECRET_KEY") or secrets.token_hex(32)
@@ -178,65 +275,71 @@ def create_app() -> Flask:
避免浏览器在跨站 iframe 里丢弃 Set-Cookie。
"""
body = request.get_json(silent=True) or {}
sid = body.get("service_id")
base = (body.get("base_url") or "").strip().rstrip("/")
next_path = (body.get("next") or "/monitor").strip() or "/monitor"
if not next_path.startswith("/"):
next_path = "/" + next_path
username = (body.get("username") or os.environ.get("NAV_HUB_USERNAME") or "").strip()
password = body.get("password")
if password is None:
password = os.environ.get("NAV_HUB_PASSWORD") or ""
password = str(password)
if sid:
svc = db.session.get(Service, int(sid))
if not svc or not svc.is_hub_embed():
return jsonify({"ok": False, "detail": "服务不存在或未标记为中控"}), 400
base = svc.build_origin()
if not next_path or next_path == "/monitor":
p = (svc.path or "/monitor").strip() or "/monitor"
next_path = p if p.startswith("/") else "/" + p
if not base:
return jsonify({"ok": False, "detail": "缺少 base_url"}), 400
base, next_path, err = _resolve_hub_embed_service(body)
if err:
return jsonify({"ok": False, "detail": err}), 400
if not username or not password:
return jsonify({"ok": False, "detail": "缺少中控用户名或密码(可配置 NAV_HUB_USERNAME / NAV_HUB_PASSWORD"}), 400
payload = json.dumps({"username": username, "password": password}).encode("utf-8")
req = urllib.request.Request(
f"{base}/api/auth/login",
data=payload,
headers={"Content-Type": "application/json", "Accept": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
raw = resp.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as e:
try:
err_body = e.read().decode("utf-8", errors="replace")
detail = json.loads(err_body).get("detail", err_body)
except Exception:
detail = str(e)
return jsonify({"ok": False, "detail": detail or "中控登录失败"}), 401
except Exception as e:
return jsonify({"ok": False, "detail": f"无法连接中控: {e}"}), 502
try:
data = json.loads(raw)
except json.JSONDecodeError:
return jsonify({"ok": False, "detail": "中控返回非 JSON"}), 502
if not data.get("ok"):
return jsonify({"ok": False, "detail": data.get("detail") or "登录失败"}), 401
token = data.get("session_token")
if not token:
return jsonify({"ok": False, "detail": "中控未返回 session_token,请升级云端 hub 后重试"}), 502
token, err = _hub_api_login(base, username, password)
if err:
status = 401 if "登录" in err or "401" in err else 502
return jsonify({"ok": False, "detail": err}), status
q = urlencode({"token": token, "next": next_path})
embed_url = f"{base}/embed-auth?{q}"
return jsonify({"ok": True, "embed_auth_url": embed_url, "next": next_path})
@app.route("/api/embed/hub-instance-url", methods=["POST"])
@csrf.exempt
@login_required
def api_embed_hub_instance_url():
"""
本地导航代签实例 SSO:服务端登录中控后调用 /api/instance/open-url。
用于 LocalNav iframe 直接打开实例(避免中控内再嵌一层 iframe)。
"""
body = request.get_json(silent=True) or {}
exchange_id = str(body.get("exchange_id") or "").strip()
next_path = (body.get("next") or "/").strip() or "/"
if not next_path.startswith("/"):
next_path = "/" + next_path
if not exchange_id:
return jsonify({"ok": False, "detail": "缺少 exchange_id"}), 400
username = (body.get("username") or os.environ.get("NAV_HUB_USERNAME") or "").strip()
password = body.get("password")
if password is None:
password = os.environ.get("NAV_HUB_PASSWORD") or ""
password = str(password)
base, _, err = _resolve_hub_embed_service(body)
if err:
return jsonify({"ok": False, "detail": err}), 400
if not username or not password:
return jsonify({"ok": False, "detail": "缺少中控用户名或密码(可配置 NAV_HUB_USERNAME / NAV_HUB_PASSWORD"}), 400
token, err = _hub_api_login(base, username, password)
if err:
status = 401 if "登录" in err or "401" in err else 502
return jsonify({"ok": False, "detail": err}), status
url, err = _hub_instance_open_url(
base,
token,
exchange_id,
next_path,
embed=(body.get("embed") or "1").strip().lower() not in ("0", "false", "no"),
)
if err:
return jsonify({"ok": False, "detail": err}), 502
return jsonify({"ok": True, "url": url, "exchange_id": exchange_id, "next": next_path})
# ---------- 分组管理 ----------
@app.route("/admin/groups")
@login_required
Binary file not shown.
+4 -1
View File
@@ -54,7 +54,10 @@ class Service(db.Model):
proto = (self.scheme or "http").strip().lower()
if proto not in ("http", "https"):
proto = "http"
return f"{proto}://{self.host}:{self.port}"
port = int(self.port or (443 if proto == "https" else 80))
if (proto == "https" and port == 443) or (proto == "http" and port == 80):
return f"{proto}://{self.host}"
return f"{proto}://{self.host}:{port}"
def build_url(self) -> str:
p = (self.path or "/").strip()
+13
View File
@@ -348,6 +348,19 @@ a:hover {
background: rgba(61, 139, 253, 0.1);
}
#frame-back-hub {
color: #9ecbff;
border-color: rgba(61, 139, 253, 0.45);
background: rgba(61, 139, 253, 0.12);
font-weight: 600;
}
#frame-back-hub:hover {
color: #fff;
border-color: #3d8bfd;
background: rgba(61, 139, 253, 0.22);
}
.frame-title {
font-size: 0.92rem;
font-weight: 600;
+267 -3
View File
@@ -106,6 +106,15 @@
<button type="button" class="btn-frame-back" id="frame-back-overview" title="返回服务总览">
总览
</button>
<button
type="button"
class="btn-frame-back"
id="frame-back-hub"
title="返回复盘中控监控区"
hidden
>
← 返回中控
</button>
<span class="frame-title" id="current-service-name"></span>
</div>
<div class="frame-toolbar-actions">
@@ -118,6 +127,15 @@
>
中控登录
</button>
<button
type="button"
class="btn btn-secondary btn-toolbar-refresh"
id="frame-instance-sso"
title="通过本地导航代签实例 SSO(需 NAV_HUB_USERNAME / NAV_HUB_PASSWORD"
hidden
>
实例免密
</button>
<button type="button" class="btn btn-secondary btn-toolbar-refresh" id="frame-refresh">
刷新
</button>
@@ -132,7 +150,7 @@
</div>
</div>
<div class="frame-wrap">
<iframe id="svc-frame" title="内嵌服务" hidden></iframe>
<iframe id="svc-frame" name="svc-frame" title="内嵌服务" hidden></iframe>
</div>
</div>
</div>
@@ -181,18 +199,55 @@
var btnRefresh = document.getElementById("frame-refresh");
var btnForceRefresh = document.getElementById("frame-force-refresh");
var btnBack = document.getElementById("frame-back-overview");
var btnBackHub = document.getElementById("frame-back-hub");
var btnHubLogin = document.getElementById("frame-hub-login");
var btnInstanceSso = document.getElementById("frame-instance-sso");
var currentBaseUrl = "";
var currentOpenUrl = "";
var currentEmbedKind = "";
var currentServiceId = "";
var currentOrigin = "";
var currentNextPath = "/monitor";
var currentViewMode = "service";
var hubReturnState = null;
var instanceNavCtx = null;
function normalizeOrigin(raw) {
if (!raw) return "";
try {
var u = new URL(raw.indexOf("://") >= 0 ? raw : "http://" + raw);
var port = u.port;
if (u.protocol === "https:" && (!port || port === "443")) {
return u.protocol + "//" + u.hostname;
}
if (u.protocol === "http:" && (!port || port === "80")) {
return u.protocol + "//" + u.hostname;
}
return u.origin;
} catch (e) {
return String(raw).replace(/\/+$/, "");
}
}
function originsCompatible(expected, actual) {
var e = normalizeOrigin(expected);
var a = normalizeOrigin(actual);
if (!e || !a) return true;
return e === a;
}
function isHubEmbed(kind) {
return (kind || "").toLowerCase() === "hub";
}
function toggleInstanceBackBtn(show) {
if (btnBackHub) btnBackHub.hidden = !show;
}
function toggleInstanceSsoBtn(show) {
if (btnInstanceSso) btnInstanceSso.hidden = !show;
}
function toggleHubLoginBtn(show) {
if (btnHubLogin) btnHubLogin.hidden = !show;
}
@@ -202,6 +257,48 @@
frame.src = url;
}
function iframeLooksLikeHub(href) {
if (!href) return false;
var hubOrigin = normalizeOrigin(currentOrigin);
var h = String(href);
if (hubOrigin && h.indexOf(hubOrigin) !== 0) return false;
return (
/\/monitor(\?|#|$)/.test(h) ||
h.indexOf("/embed-auth") >= 0 ||
(h.indexOf("/login") >= 0 && h.indexOf("embed=1") >= 0) ||
h.indexOf("/settings") >= 0
);
}
function syncHubInstanceBackBtn() {
if (!isHubEmbed(currentEmbedKind) || frameStack.hidden || !currentBaseUrl) {
toggleInstanceBackBtn(false);
toggleInstanceSsoBtn(false);
return;
}
var onHub = false;
try {
onHub = iframeLooksLikeHub(frame.contentWindow.location.href);
} catch (e) {
onHub = false;
}
if (onHub) {
currentViewMode = "hub";
toggleInstanceBackBtn(false);
toggleInstanceSsoBtn(false);
toggleHubLoginBtn(true);
return;
}
currentViewMode = "hub-instance";
toggleInstanceBackBtn(true);
toggleHubLoginBtn(false);
toggleInstanceSsoBtn(!!(instanceNavCtx && instanceNavCtx.exchangeId));
}
if (frame) {
frame.addEventListener("load", syncHubInstanceBackBtn);
}
function hubLoginViaProxy(done) {
if (!currentServiceId && !currentOrigin) {
if (done) done(false, "未选择中控服务");
@@ -234,12 +331,127 @@
window.addEventListener("message", function (ev) {
var data = ev.data;
if (!data || data.type !== "hub:login-ok") return;
if (!data || !data.type) return;
if (data.type === "hub:login-ok") {
if (data.embed_auth_url) {
applyIframeUrl(data.embed_auth_url);
}
return;
}
if (data.type === "hub:open-instance-nav") {
instanceNavCtx = {
exchangeId: String(data.exchangeId || ""),
nextPath: data.nextPath || "/",
title: data.title || "交易所实例",
serviceId: currentServiceId,
};
currentViewMode = "hub-instance";
if (data.title) nameEl.textContent = data.title;
toggleHubLoginBtn(false);
toggleInstanceBackBtn(true);
toggleInstanceSsoBtn(!!instanceNavCtx.exchangeId);
return;
}
if (data.type !== "hub:open-instance") return;
if (frameStack.hidden || !currentServiceId) return;
if (!originsCompatible(currentOrigin, ev.origin)) {
console.warn(
"[LocalNav] hub:open-instance origin 不匹配,已忽略",
normalizeOrigin(currentOrigin),
normalizeOrigin(ev.origin)
);
return;
}
if (!data.url) return;
try {
if (ev.source) {
ev.source.postMessage({ type: "hub:open-instance-ack", ok: true }, ev.origin || "*");
}
} catch (e) {}
hubReturnState = {
openUrl: currentOpenUrl,
baseUrl: currentBaseUrl,
name: nameEl.textContent,
embedKind: currentEmbedKind,
serviceId: currentServiceId,
origin: currentOrigin,
nextPath: currentNextPath,
};
instanceNavCtx = {
exchangeId: String(data.exchangeId || ""),
nextPath: data.nextPath || "/",
title: data.title || "交易所实例",
serviceId: currentServiceId,
};
currentViewMode = "hub-instance";
nameEl.textContent = instanceNavCtx.title;
toggleHubLoginBtn(false);
toggleInstanceBackBtn(true);
applyIframeUrl(data.url);
});
function refreshInstanceViaProxy(done) {
if (!instanceNavCtx || !instanceNavCtx.exchangeId) {
if (done) done(false, null, "缺少实例上下文");
return;
}
fetch("/api/embed/hub-instance-url", {
method: "POST",
headers: { "Content-Type": "application/json", "Accept": "application/json" },
body: JSON.stringify({
service_id: parseInt(instanceNavCtx.serviceId, 10) || undefined,
exchange_id: instanceNavCtx.exchangeId,
next: instanceNavCtx.nextPath || "/",
embed: "1",
}),
})
.then(function (r) {
return r.json().then(function (j) {
return { ok: r.ok, j: j };
});
})
.then(function (res) {
if (res.ok && res.j.ok && res.j.url) {
if (done) done(true, res.j.url, null);
return;
}
if (done) done(false, null, (res.j && res.j.detail) || "无法重新打开实例");
})
.catch(function (e) {
if (done) done(false, null, String(e));
});
}
function returnToHubMonitor() {
var st = hubReturnState;
currentViewMode = "hub";
instanceNavCtx = null;
hubReturnState = null;
toggleInstanceBackBtn(false);
toggleInstanceSsoBtn(false);
if (st) {
currentOpenUrl = st.openUrl || currentOpenUrl;
currentBaseUrl = st.baseUrl || st.openUrl || currentBaseUrl;
currentEmbedKind = st.embedKind || currentEmbedKind;
currentServiceId = st.serviceId || currentServiceId;
currentOrigin = st.origin || currentOrigin;
currentNextPath = st.nextPath || currentNextPath;
nameEl.textContent = st.name || nameEl.textContent;
}
toggleHubLoginBtn(isHubEmbed(currentEmbedKind));
if (isHubEmbed(currentEmbedKind) && hubAutoLogin) {
hubLoginViaProxy(function (ok) {
if (!ok) applyIframeUrl(currentOpenUrl || currentBaseUrl);
syncHubInstanceBackBtn();
});
return;
}
applyIframeUrl(currentOpenUrl || currentBaseUrl);
syncHubInstanceBackBtn();
}
function setActive(el) {
links.forEach(function (a) {
a.classList.remove("active");
@@ -264,6 +476,10 @@
currentServiceId = meta.serviceId || "";
currentOrigin = meta.origin || "";
currentNextPath = meta.nextPath || "/monitor";
currentViewMode = isHubEmbed(currentEmbedKind) ? "hub" : "service";
instanceNavCtx = null;
hubReturnState = null;
toggleInstanceBackBtn(false);
nameEl.textContent = name || "";
dashboard.hidden = true;
frameStack.hidden = false;
@@ -307,12 +523,33 @@
}
function reloadUrl() {
if (currentViewMode === "hub-instance") {
refreshInstanceViaProxy(function (ok, url, err) {
if (ok && url) applyIframeUrl(url);
else if (err) window.alert(err);
});
return;
}
var u = currentOpenUrl || currentBaseUrl;
if (!u) return;
frame.src = buildCacheBustUrl(u, false);
}
function forceReloadUrl() {
if (currentViewMode === "hub-instance") {
refreshInstanceViaProxy(function (ok, url, err) {
if (!ok || !url) {
if (err) window.alert(err);
return;
}
frame.src = "about:blank";
frame.onload = function () {
frame.onload = null;
frame.src = buildCacheBustUrl(url, true);
};
});
return;
}
var u = currentOpenUrl || currentBaseUrl;
if (!u) return;
frame.src = "about:blank";
@@ -328,11 +565,15 @@
currentEmbedKind = "";
currentServiceId = "";
currentOrigin = "";
currentViewMode = "service";
instanceNavCtx = null;
hubReturnState = null;
frame.src = "about:blank";
frame.hidden = true;
frameStack.hidden = true;
dashboard.hidden = false;
toggleHubLoginBtn(false);
toggleInstanceSsoBtn(false);
setActive(null);
}
@@ -368,7 +609,24 @@
btnHubLogin.disabled = true;
hubLoginViaProxy(function (ok, err) {
btnHubLogin.disabled = false;
if (!ok && err) window.alert(err);
if (!ok && err) {
window.alert("中控登录失败:\n" + err + "\n\n请检查 LocalNav .env 的 NAV_HUB_USERNAME / NAV_HUB_PASSWORD 是否与云端 hub .env 一致。");
}
});
});
}
if (btnInstanceSso) {
btnInstanceSso.addEventListener("click", function () {
btnInstanceSso.disabled = true;
refreshInstanceViaProxy(function (ok, url, err) {
btnInstanceSso.disabled = false;
if (ok && url) {
applyIframeUrl(url);
syncHubInstanceBackBtn();
return;
}
if (err) window.alert("实例免密失败:\n" + err);
});
});
}
@@ -384,6 +642,12 @@
btnBack.addEventListener("click", function () {
showDashboard();
});
if (btnBackHub) {
btnBackHub.addEventListener("click", function () {
returnToHubMonitor();
});
}
})();
</script>
{% endblock %}
+11 -1
View File
@@ -264,7 +264,17 @@ HUB_EMBED_ORIGINS=http://192.168.8.6:5070
将 `192.168.8.6:5070` 换成你本机访问 LocalNav 的完整 Origin(含协议与端口)。多台电脑可逗号分隔。
**四实例(币安/Gate/OKX**建议仍从中控点「打开实例」(SSO 免密);若在 LocalNav 直接嵌实例 URL,需在 iframe 内各自登录,或同样存在跨站 Cookie 限制
**四实例(币安/Gate/OKX)**:从中控点「实例 / 策略交易 / 复盘」时,**最新版**会由中控 `postMessage` 通知本地导航,在**同一层 iframe** 打开实例 SSO 链接(避免「导航 → 中控 → 实例」三层嵌套导致 Cookie 失效、反复要密码)。工具栏会出现 **「← 中控」** 返回监控区;刷新会由本地导航服务端代签新的 SSO 链接(须已配置 `NAV_HUB_USERNAME` / `NAV_HUB_PASSWORD`
四实例 `.env` 建议:
```env
APP_COOKIE_SECURE=true
APP_ALLOW_HUB_EMBED=true
HUB_EMBED_PARENT_ORIGINS=https://你的中控域名,http://192.168.x.x:5070
```
`5070` 换成本地导航实际地址;与中控相同的 `HUB_BRIDGE_TOKEN` 必填。)
---