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 # NAV_SESSION_COOKIE_SECURE=auto
# CSRF 校验仍失败时,可填前端访问的完整 Origin,多个用英文逗号分隔,例如: # CSRF 校验仍失败时,可填前端访问的完整 Origin,多个用英文逗号分隔,例如:
# NAV_CSRF_TRUSTED_ORIGINS=https://nav.example.com # 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 os
import secrets import secrets
import urllib.error
import urllib.request
from pathlib import Path from pathlib import Path
from typing import Optional 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_login import LoginManager, current_user, login_required, login_user, logout_user
from flask_wtf.csrf import CSRFProtect from flask_wtf.csrf import CSRFProtect
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
@@ -159,7 +163,79 @@ def create_app() -> Flask:
.all() .all()
) )
grouped.append((g, svcs)) 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") @app.route("/admin/groups")
@@ -261,6 +337,7 @@ def create_app() -> Flask:
path=path, path=path,
sort_order=form.sort_order.data or 0, sort_order=form.sort_order.data or 0,
group_id=form.group_id.data, group_id=form.group_id.data,
embed_kind=(form.embed_kind.data or "").strip(),
) )
db.session.add(s) db.session.add(s)
db.session.commit() db.session.commit()
@@ -296,6 +373,7 @@ def create_app() -> Flask:
s.path = (form.path.data or "").strip() or "/" s.path = (form.path.data or "").strip() or "/"
s.sort_order = form.sort_order.data or 0 s.sort_order = form.sort_order.data or 0
s.group_id = form.group_id.data s.group_id = form.group_id.data
s.embed_kind = (form.embed_kind.data or "").strip()
db.session.commit() db.session.commit()
flash("服务已更新", "success") flash("服务已更新", "success")
return redirect(url_for("admin_services")) return redirect(url_for("admin_services"))
@@ -352,6 +430,16 @@ def _migrate_schema() -> None:
) )
) )
print("[nav] 已为 services 表添加 scheme 列(默认 http)。", flush=True) 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: except Exception as exc:
print(f"[nav] 数据库结构迁移跳过或失败: {exc}", flush=True) 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)], validators=[Optional(), NumberRange(min=-10**6, max=10**6)],
default=0, default=0,
) )
embed_kind = SelectField(
"嵌入类型",
choices=[
("", "普通(直接打开路径)"),
("hub", "复盘中控(云端 hub,需 embed-auth 登录)"),
],
default="",
validators=[Optional()],
)
submit = SubmitField("保存") submit = SubmitField("保存")
def validate_path(self, field): 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( group_id = db.Column(
db.Integer, db.ForeignKey("service_groups.id"), nullable=False, index=True 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: def build_url(self) -> str:
p = (self.path or "/").strip() p = (self.path or "/").strip()
if not p.startswith("/"): if not p.startswith("/"):
p = "/" + p p = "/" + p
proto = (self.scheme or "http").strip().lower() return f"{self.build_origin()}{p}"
if proto not in ("http", "https"):
proto = "http" def build_open_url(self) -> str:
return f"{proto}://{self.host}:{self.port}{p}" """导航 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> <div class="errors">{{ form.sort_order.errors[0] }}</div>
{% endif %} {% endif %}
</div> </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"> <div class="toolbar" style="margin-top: 1.25rem">
{{ form.submit(class="btn btn-primary", style="width: auto") }} {{ form.submit(class="btn btn-primary", style="width: auto") }}
<a class="btn btn-secondary" href="{{ url_for('admin_services') }}">取消</a> <a class="btn btn-secondary" href="{{ url_for('admin_services') }}">取消</a>
+1 -1
View File
@@ -49,7 +49,7 @@
<tbody> <tbody>
{% for s in services %} {% for s in services %}
<tr> <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><code style="font-size: 0.82rem">{{ s.build_url() }}</code></td>
<td>{{ s.group.name if s.group else '—' }}</td> <td>{{ s.group.name if s.group else '—' }}</td>
<td>{{ s.sort_order }}</td> <td>{{ s.sort_order }}</td>
+126 -9
View File
@@ -41,7 +41,12 @@
href="#" href="#"
class="nav-link" class="nav-link"
role="button" 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 }}" data-name="{{ svc.name | e }}"
>{{ svc.name }}</a >{{ svc.name }}</a
> >
@@ -75,7 +80,12 @@
<button <button
type="button" type="button"
class="service-card" 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 }}" data-name="{{ svc.name | e }}"
> >
<span class="service-card-title">{{ svc.name }}</span> <span class="service-card-title">{{ svc.name }}</span>
@@ -99,6 +109,15 @@
<span class="frame-title" id="current-service-name"></span> <span class="frame-title" id="current-service-name"></span>
</div> </div>
<div class="frame-toolbar-actions"> <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 type="button" class="btn btn-secondary btn-toolbar-refresh" id="frame-refresh">
刷新 刷新
</button> </button>
@@ -121,6 +140,7 @@
</div> </div>
<script> <script>
(function () { (function () {
var hubAutoLogin = {{ 'true' if hub_auto_login else 'false' }};
var layoutMain = document.getElementById("layout-main"); var layoutMain = document.getElementById("layout-main");
var btnSidebarCollapse = document.getElementById("sidebar-collapse"); var btnSidebarCollapse = document.getElementById("sidebar-collapse");
var btnSidebarExpand = document.getElementById("sidebar-expand"); var btnSidebarExpand = document.getElementById("sidebar-expand");
@@ -161,7 +181,64 @@
var btnRefresh = document.getElementById("frame-refresh"); var btnRefresh = document.getElementById("frame-refresh");
var btnForceRefresh = document.getElementById("frame-force-refresh"); var btnForceRefresh = document.getElementById("frame-force-refresh");
var btnBack = document.getElementById("frame-back-overview"); var btnBack = document.getElementById("frame-back-overview");
var btnHubLogin = document.getElementById("frame-hub-login");
var currentBaseUrl = ""; 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) { function setActive(el) {
links.forEach(function (a) { links.forEach(function (a) {
@@ -178,14 +255,29 @@
return found; return found;
} }
function openService(url, name, preferredNav) { function openService(url, name, preferredNav, meta) {
if (!url) return; 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 || ""; nameEl.textContent = name || "";
dashboard.hidden = true; dashboard.hidden = true;
frameStack.hidden = false; frameStack.hidden = false;
frame.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); var nav = preferredNav || findNavLink(url);
setActive(nav); setActive(nav);
} }
@@ -215,13 +307,13 @@
} }
function reloadUrl() { function reloadUrl() {
var u = currentBaseUrl; var u = currentOpenUrl || currentBaseUrl;
if (!u) return; if (!u) return;
frame.src = buildCacheBustUrl(u, false); frame.src = buildCacheBustUrl(u, false);
} }
function forceReloadUrl() { function forceReloadUrl() {
var u = currentBaseUrl; var u = currentOpenUrl || currentBaseUrl;
if (!u) return; if (!u) return;
frame.src = "about:blank"; frame.src = "about:blank";
frame.onload = function () { frame.onload = function () {
@@ -232,19 +324,34 @@
function showDashboard() { function showDashboard() {
currentBaseUrl = ""; currentBaseUrl = "";
currentOpenUrl = "";
currentEmbedKind = "";
currentServiceId = "";
currentOrigin = "";
frame.src = "about:blank"; frame.src = "about:blank";
frame.hidden = true; frame.hidden = true;
frameStack.hidden = true; frameStack.hidden = true;
dashboard.hidden = false; dashboard.hidden = false;
toggleHubLoginBtn(false);
setActive(null); 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) { links.forEach(function (a) {
a.addEventListener("click", function (e) { a.addEventListener("click", function (e) {
e.preventDefault(); e.preventDefault();
var url = a.getAttribute("data-url"); var url = a.getAttribute("data-url");
var name = a.getAttribute("data-name") || ""; var name = a.getAttribute("data-name") || "";
openService(url, name, a); openService(url, name, a, readServiceMeta(a));
}); });
}); });
@@ -252,10 +359,20 @@
btn.addEventListener("click", function () { btn.addEventListener("click", function () {
var url = btn.getAttribute("data-url"); var url = btn.getAttribute("data-url");
var name = btn.getAttribute("data-name") || ""; 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 () { btnRefresh.addEventListener("click", function () {
reloadUrl(); reloadUrl();
}); });
+30
View File
@@ -236,6 +236,36 @@ http://<本机局域网IP>:5070
部分网站(尤其银行、部分管理面板)通过 **`X-Frame-Options`** 或 **`Content-Security-Policy`** 禁止被嵌入 iframe,此时右侧区域可能为空白或浏览器控制台报错。这属于 **目标站点安全策略**,与本导航站实现无关。若必须统一入口,只能由目标服务侧放开嵌入策略,或改为新窗口打开(需改代码,非当前默认行为)。 部分网站(尤其银行、部分管理面板)通过 **`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 为例) ## 九、部署指南(以 Ubuntu 为例)