This commit is contained in:
dekun
2026-05-30 11:52:21 +08:00
parent fd5e333daf
commit 4cd5a48dc1
9 changed files with 298 additions and 16 deletions
+7
View File
@@ -34,3 +34,10 @@
# NAV_SESSION_COOKIE_SECURE=auto
# CSRF 校验仍失败时,可填前端访问的完整 Origin,多个用英文逗号分隔,例如:
# NAV_CSRF_TRUSTED_ORIGINS=https://nav.example.com
# ---------- 云端复盘中控 iframe 嵌入(与 manual_trading_hub 配合)----------
# 本地导航代登录中控(服务端请求云端 /api/auth/login,再打开 /embed-auth
# NAV_HUB_USERNAME=admin
# NAV_HUB_PASSWORD=你的中控密码
# 打开标记为「复盘中控」的服务时自动代登录(1=开启)
# NAV_HUB_AUTO_LOGIN=1
+90 -2
View File
@@ -1,9 +1,13 @@
import json
import os
import secrets
import urllib.error
import urllib.request
from pathlib import Path
from typing import Optional
from urllib.parse import urlencode
from flask import Flask, flash, redirect, render_template, request, url_for
from flask import Flask, flash, jsonify, redirect, render_template, request, url_for
from flask_login import LoginManager, current_user, login_required, login_user, logout_user
from flask_wtf.csrf import CSRFProtect
from werkzeug.middleware.proxy_fix import ProxyFix
@@ -159,7 +163,79 @@ def create_app() -> Flask:
.all()
)
grouped.append((g, svcs))
return render_template("index.html", grouped=grouped)
return render_template(
"index.html",
grouped=grouped,
hub_auto_login=os.environ.get("NAV_HUB_AUTO_LOGIN", "").strip() == "1",
)
@app.route("/api/embed/hub-login", methods=["POST"])
@csrf.exempt
@login_required
def api_embed_hub_login():
"""
本地导航代登录云端中控:服务端请求 hub /api/auth/login,返回 embed-auth URL。
避免浏览器在跨站 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
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
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("/admin/groups")
@@ -261,6 +337,7 @@ def create_app() -> Flask:
path=path,
sort_order=form.sort_order.data or 0,
group_id=form.group_id.data,
embed_kind=(form.embed_kind.data or "").strip(),
)
db.session.add(s)
db.session.commit()
@@ -296,6 +373,7 @@ def create_app() -> Flask:
s.path = (form.path.data or "").strip() or "/"
s.sort_order = form.sort_order.data or 0
s.group_id = form.group_id.data
s.embed_kind = (form.embed_kind.data or "").strip()
db.session.commit()
flash("服务已更新", "success")
return redirect(url_for("admin_services"))
@@ -352,6 +430,16 @@ def _migrate_schema() -> None:
)
)
print("[nav] 已为 services 表添加 scheme 列(默认 http)。", flush=True)
cols = {c["name"] for c in insp.get_columns("services")}
if "embed_kind" not in cols:
with db.engine.begin() as conn:
conn.execute(
text(
"ALTER TABLE services ADD COLUMN embed_kind VARCHAR(16) "
"NOT NULL DEFAULT ''"
)
)
print("[nav] 已为 services 表添加 embed_kind 列。", flush=True)
except Exception as exc:
print(f"[nav] 数据库结构迁移跳过或失败: {exc}", flush=True)
+9
View File
@@ -45,6 +45,15 @@ class ServiceForm(FlaskForm):
validators=[Optional(), NumberRange(min=-10**6, max=10**6)],
default=0,
)
embed_kind = SelectField(
"嵌入类型",
choices=[
("", "普通(直接打开路径)"),
("hub", "复盘中控(云端 hub,需 embed-auth 登录)"),
],
default="",
validators=[Optional()],
)
submit = SubmitField("保存")
def validate_path(self, field):
Binary file not shown.
+25 -4
View File
@@ -47,12 +47,33 @@ class Service(db.Model):
group_id = db.Column(
db.Integer, db.ForeignKey("service_groups.id"), nullable=False, index=True
)
# hub=复盘中控(iframe 嵌入需 /embed-auth);留空=普通内嵌
embed_kind = db.Column(db.String(16), nullable=False, default="", server_default="")
def build_origin(self) -> str:
proto = (self.scheme or "http").strip().lower()
if proto not in ("http", "https"):
proto = "http"
return f"{proto}://{self.host}:{self.port}"
def build_url(self) -> str:
p = (self.path or "/").strip()
if not p.startswith("/"):
p = "/" + p
proto = (self.scheme or "http").strip().lower()
if proto not in ("http", "https"):
proto = "http"
return f"{proto}://{self.host}:{self.port}{p}"
return f"{self.build_origin()}{p}"
def build_open_url(self) -> str:
"""导航 iframe 首次打开的地址(中控走 login?embed=1 以便写入会话)。"""
kind = (self.embed_kind or "").strip().lower()
next_path = (self.path or "/monitor").strip() or "/monitor"
if not next_path.startswith("/"):
next_path = "/" + next_path
if kind == "hub":
from urllib.parse import urlencode
q = urlencode({"embed": "1", "next": next_path})
return f"{self.build_origin()}/login?{q}"
return self.build_url()
def is_hub_embed(self) -> bool:
return (self.embed_kind or "").strip().lower() == "hub"
+10
View File
@@ -70,6 +70,16 @@
<div class="errors">{{ form.sort_order.errors[0] }}</div>
{% endif %}
</div>
<div class="form-row">
{{ form.embed_kind.label }}
{{ form.embed_kind() }}
<p class="hint" style="margin:0.35rem 0 0">
选「复盘中控」时:路径填 <code>/monitor</code>;本地 iframe 打开会先走中控登录页或代登录。
</p>
{% if form.embed_kind.errors %}
<div class="errors">{{ form.embed_kind.errors[0] }}</div>
{% endif %}
</div>
<div class="toolbar" style="margin-top: 1.25rem">
{{ form.submit(class="btn btn-primary", style="width: auto") }}
<a class="btn btn-secondary" href="{{ url_for('admin_services') }}">取消</a>
+1 -1
View File
@@ -49,7 +49,7 @@
<tbody>
{% for s in services %}
<tr>
<td>{{ s.name }}</td>
<td>{{ s.name }}{% if s.embed_kind == 'hub' %} <span class="hint">(中控)</span>{% endif %}</td>
<td><code style="font-size: 0.82rem">{{ s.build_url() }}</code></td>
<td>{{ s.group.name if s.group else '—' }}</td>
<td>{{ s.sort_order }}</td>
+126 -9
View File
@@ -41,7 +41,12 @@
href="#"
class="nav-link"
role="button"
data-url="{{ svc.build_url()|e }}"
data-url="{{ svc.build_open_url()|e }}"
data-base-url="{{ svc.build_url()|e }}"
data-origin="{{ svc.build_origin()|e }}"
data-next-path="{{ (svc.path or '/monitor')|e }}"
data-embed-kind="{{ (svc.embed_kind or '')|e }}"
data-service-id="{{ svc.id }}"
data-name="{{ svc.name | e }}"
>{{ svc.name }}</a
>
@@ -75,7 +80,12 @@
<button
type="button"
class="service-card"
data-url="{{ svc.build_url()|e }}"
data-url="{{ svc.build_open_url()|e }}"
data-base-url="{{ svc.build_url()|e }}"
data-origin="{{ svc.build_origin()|e }}"
data-next-path="{{ (svc.path or '/monitor')|e }}"
data-embed-kind="{{ (svc.embed_kind or '')|e }}"
data-service-id="{{ svc.id }}"
data-name="{{ svc.name | e }}"
>
<span class="service-card-title">{{ svc.name }}</span>
@@ -99,6 +109,15 @@
<span class="frame-title" id="current-service-name"></span>
</div>
<div class="frame-toolbar-actions">
<button
type="button"
class="btn btn-secondary btn-toolbar-refresh"
id="frame-hub-login"
title="通过本地导航代登录云端中控(需配置 NAV_HUB_USERNAME / NAV_HUB_PASSWORD"
hidden
>
中控登录
</button>
<button type="button" class="btn btn-secondary btn-toolbar-refresh" id="frame-refresh">
刷新
</button>
@@ -121,6 +140,7 @@
</div>
<script>
(function () {
var hubAutoLogin = {{ 'true' if hub_auto_login else 'false' }};
var layoutMain = document.getElementById("layout-main");
var btnSidebarCollapse = document.getElementById("sidebar-collapse");
var btnSidebarExpand = document.getElementById("sidebar-expand");
@@ -161,7 +181,64 @@
var btnRefresh = document.getElementById("frame-refresh");
var btnForceRefresh = document.getElementById("frame-force-refresh");
var btnBack = document.getElementById("frame-back-overview");
var btnHubLogin = document.getElementById("frame-hub-login");
var currentBaseUrl = "";
var currentOpenUrl = "";
var currentEmbedKind = "";
var currentServiceId = "";
var currentOrigin = "";
var currentNextPath = "/monitor";
function isHubEmbed(kind) {
return (kind || "").toLowerCase() === "hub";
}
function toggleHubLoginBtn(show) {
if (btnHubLogin) btnHubLogin.hidden = !show;
}
function applyIframeUrl(url) {
if (!url) return;
frame.src = url;
}
function hubLoginViaProxy(done) {
if (!currentServiceId && !currentOrigin) {
if (done) done(false, "未选择中控服务");
return;
}
var body = { service_id: parseInt(currentServiceId, 10) || undefined, next: currentNextPath };
fetch("/api/embed/hub-login", {
method: "POST",
headers: { "Content-Type": "application/json", "Accept": "application/json" },
body: JSON.stringify(body),
})
.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.embed_auth_url) {
currentBaseUrl = res.j.embed_auth_url.split("?")[0].replace(/\/embed-auth$/, "") + (currentNextPath || "/monitor");
applyIframeUrl(res.j.embed_auth_url);
if (done) done(true);
return;
}
if (done) done(false, (res.j && res.j.detail) || "中控登录失败");
})
.catch(function (e) {
if (done) done(false, String(e));
});
}
window.addEventListener("message", function (ev) {
var data = ev.data;
if (!data || data.type !== "hub:login-ok") return;
if (data.embed_auth_url) {
applyIframeUrl(data.embed_auth_url);
}
});
function setActive(el) {
links.forEach(function (a) {
@@ -178,14 +255,29 @@
return found;
}
function openService(url, name, preferredNav) {
function openService(url, name, preferredNav, meta) {
if (!url) return;
currentBaseUrl = url;
meta = meta || {};
currentOpenUrl = url;
currentBaseUrl = meta.baseUrl || url;
currentEmbedKind = meta.embedKind || "";
currentServiceId = meta.serviceId || "";
currentOrigin = meta.origin || "";
currentNextPath = meta.nextPath || "/monitor";
nameEl.textContent = name || "";
dashboard.hidden = true;
frameStack.hidden = false;
frame.hidden = false;
frame.src = url;
toggleHubLoginBtn(isHubEmbed(currentEmbedKind));
if (isHubEmbed(currentEmbedKind) && hubAutoLogin) {
hubLoginViaProxy(function (ok, err) {
if (!ok) applyIframeUrl(url);
});
var nav = preferredNav || findNavLink(url);
setActive(nav);
return;
}
applyIframeUrl(url);
var nav = preferredNav || findNavLink(url);
setActive(nav);
}
@@ -215,13 +307,13 @@
}
function reloadUrl() {
var u = currentBaseUrl;
var u = currentOpenUrl || currentBaseUrl;
if (!u) return;
frame.src = buildCacheBustUrl(u, false);
}
function forceReloadUrl() {
var u = currentBaseUrl;
var u = currentOpenUrl || currentBaseUrl;
if (!u) return;
frame.src = "about:blank";
frame.onload = function () {
@@ -232,19 +324,34 @@
function showDashboard() {
currentBaseUrl = "";
currentOpenUrl = "";
currentEmbedKind = "";
currentServiceId = "";
currentOrigin = "";
frame.src = "about:blank";
frame.hidden = true;
frameStack.hidden = true;
dashboard.hidden = false;
toggleHubLoginBtn(false);
setActive(null);
}
function readServiceMeta(el) {
return {
baseUrl: el.getAttribute("data-base-url") || el.getAttribute("data-url") || "",
embedKind: el.getAttribute("data-embed-kind") || "",
serviceId: el.getAttribute("data-service-id") || "",
origin: el.getAttribute("data-origin") || "",
nextPath: el.getAttribute("data-next-path") || "/monitor",
};
}
links.forEach(function (a) {
a.addEventListener("click", function (e) {
e.preventDefault();
var url = a.getAttribute("data-url");
var name = a.getAttribute("data-name") || "";
openService(url, name, a);
openService(url, name, a, readServiceMeta(a));
});
});
@@ -252,10 +359,20 @@
btn.addEventListener("click", function () {
var url = btn.getAttribute("data-url");
var name = btn.getAttribute("data-name") || "";
openService(url, name, null);
openService(url, name, null, readServiceMeta(btn));
});
});
if (btnHubLogin) {
btnHubLogin.addEventListener("click", function () {
btnHubLogin.disabled = true;
hubLoginViaProxy(function (ok, err) {
btnHubLogin.disabled = false;
if (!ok && err) window.alert(err);
});
});
}
btnRefresh.addEventListener("click", function () {
reloadUrl();
});
+30
View File
@@ -236,6 +236,36 @@ http://<本机局域网IP>:5070
部分网站(尤其银行、部分管理面板)通过 **`X-Frame-Options`** 或 **`Content-Security-Policy`** 禁止被嵌入 iframe,此时右侧区域可能为空白或浏览器控制台报错。这属于 **目标站点安全策略**,与本导航站实现无关。若必须统一入口,只能由目标服务侧放开嵌入策略,或改为新窗口打开(需改代码,非当前默认行为)。
### 8.6 云端「复盘中控」iframe 嵌入(LocalNav + manual_trading_hub
本地导航(如 `http://192.168.x.x:5070`)嵌入 **云端中控**`https://你的域名:5100`)时,浏览器会把中控 Cookie 视为**跨站第三方**,直接在 iframe 里登录常会「成功但进不去」。
**本地导航侧(本仓库)**
1. 「服务管理」→ 编辑中控条目 → **嵌入类型****「复盘中控」**,协议 HTTPS,路径填 `/monitor`
2. `.env` 配置(与云端中控账号一致):
```env
NAV_HUB_USERNAME=admin
NAV_HUB_PASSWORD=你的中控密码
NAV_HUB_AUTO_LOGIN=1
```
3. 重启 LocalNav。打开中控时会由**本地服务端**代登录,iframe 再打开 `/embed-auth?token=...` 写入会话。
4. 也可在内嵌工具栏点 **「中控登录」** 手动触发。
**云端中控侧(`crypto_monitor/manual_trading_hub`**
部署最新代码并重启 hub`.env` 增加:
```env
HUB_ALLOW_PUBLIC=true
HUB_ALLOW_EMBED=true
HUB_EMBED_ORIGINS=http://192.168.8.6:5070
```
将 `192.168.8.6:5070` 换成你本机访问 LocalNav 的完整 Origin(含协议与端口)。多台电脑可逗号分隔。
**四实例(币安/Gate/OKX)**:建议仍从中控内点「打开实例」(SSO 免密);若在 LocalNav 直接嵌实例 URL,需在 iframe 内各自登录,或同样存在跨站 Cookie 限制。
---
## 九、部署指南(以 Ubuntu 为例)