This commit is contained in:
dekun
2026-05-30 16:01:46 +08:00
parent 1993c7b4b1
commit e96a386d35
5 changed files with 240 additions and 8 deletions
+4
View File
@@ -58,3 +58,7 @@
# NAV_GATE_EXECUTOR_HOST=exec.你的域名
# NAV_GATE_SCOUT_PORT=443
# NAV_GATE_EXECUTOR_PORT=443
# iframe 内自动代登录(须与云端 gate_scout PM2 的 NAV_EMBED_SESSION=1 配合)
# NAV_GATE_SCOUT_USERNAME=admin
# NAV_GATE_SCOUT_PASSWORD=你的扫单密码
# NAV_GATE_SCOUT_AUTO_LOGIN=1
+131 -4
View File
@@ -190,6 +190,85 @@ def _resolve_hub_embed_service(body: dict) -> tuple[str | None, str | None, str
return base, next_path, None
def _resolve_gate_scout_embed_service(
body: dict,
) -> tuple[str | None, str | None, str | None, str | None]:
"""返回 (base_origin, default_next_path, embed_kind, error_detail)。"""
sid = body.get("service_id")
base = (body.get("base_url") or "").strip().rstrip("/")
next_path = (body.get("next") or "/dashboard").strip() or "/dashboard"
embed_kind = (body.get("embed_kind") or "").strip().lower()
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_gate_scout_embed():
return None, None, None, "服务不存在或未标记为 Gate 扫单嵌入"
base = svc.build_origin()
embed_kind = (svc.embed_kind or "").strip().lower()
if not body.get("next") or next_path == "/dashboard":
p = (svc.path or "/dashboard").strip() or "/dashboard"
next_path = p if p.startswith("/") else "/" + p
if not base:
return None, None, None, "缺少 base_url"
if embed_kind in ("gate_exec", "exec"):
login_path = "/login"
else:
login_path = "/api/auth/login"
return base, next_path, login_path, None
def _gate_scout_api_login(
base: str, username: str, password: str, *, login_path: str, next_path: str
) -> tuple[str | None, str | None]:
"""服务端登录 Gate 扫单/执行器,返回 (完整 embed_auth_url, error_detail)。"""
payload = json.dumps(
{"username": username, "password": password, "embed": "1", "next": next_path}
).encode("utf-8")
req = urllib.request.Request(
f"{base.rstrip('/')}{login_path}",
data=payload,
headers={
"Content-Type": "application/json",
"Accept": "application/json",
"X-Nav-Embed": "1",
},
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 "Gate 扫单登录失败"
except Exception as e:
return None, f"无法连接 Gate 服务: {e}"
try:
data = json.loads(raw)
except json.JSONDecodeError:
return None, "Gate 服务返回非 JSON"
if not data.get("ok"):
return None, data.get("detail") or "登录失败"
embed_url = (data.get("embed_auth_url") or "").strip()
if embed_url.startswith("/"):
embed_url = base.rstrip("/") + embed_url
if not embed_url:
token = (data.get("session_token") or "").strip()
if token:
q = urlencode({"token": token, "next": next_path, "embed": "1"})
embed_url = f"{base.rstrip('/')}/embed-auth?{q}"
if not embed_url:
return None, "未返回 embed_auth_url,请确认云端已设置 NAV_EMBED_SESSION=1 并重启 PM2"
return embed_url, None
def create_app() -> Flask:
app = Flask(__name__)
app.config["SECRET_KEY"] = os.environ.get("NAV_SECRET_KEY") or secrets.token_hex(32)
@@ -265,6 +344,8 @@ def create_app() -> Flask:
"index.html",
grouped=grouped,
hub_auto_login=os.environ.get("NAV_HUB_AUTO_LOGIN", "").strip() == "1",
gate_scout_auto_login=os.environ.get("NAV_GATE_SCOUT_AUTO_LOGIN", "").strip()
== "1",
)
@app.route("/api/embed/hub-login", methods=["POST"])
@@ -297,6 +378,47 @@ def create_app() -> Flask:
embed_url = f"{base}/embed-auth?{q}"
return jsonify({"ok": True, "embed_auth_url": embed_url, "next": next_path})
@app.route("/api/embed/gate-scout-login", methods=["POST"])
@csrf.exempt
@login_required
def api_embed_gate_scout_login():
"""
本地导航代登录 Gate 扫描端/执行器:服务端请求 /api/auth/login 或 /login
再让 iframe 打开 /embed-auth 写入 SameSite=None Cookie。
"""
body = request.get_json(silent=True) or {}
username = (
body.get("username") or os.environ.get("NAV_GATE_SCOUT_USERNAME") or ""
).strip()
password = body.get("password")
if password is None:
password = os.environ.get("NAV_GATE_SCOUT_PASSWORD") or ""
password = str(password)
base, next_path, login_path, err = _resolve_gate_scout_embed_service(body)
if err:
return jsonify({"ok": False, "detail": err}), 400
if not username or not password:
return jsonify(
{
"ok": False,
"detail": "缺少 Gate 扫单用户名或密码(可配置 NAV_GATE_SCOUT_USERNAME / NAV_GATE_SCOUT_PASSWORD",
}
), 400
embed_url, err = _gate_scout_api_login(
base,
username,
password,
login_path=login_path,
next_path=next_path,
)
if err:
status = 401 if "登录" in err or "401" in err or "密码" in err else 502
return jsonify({"ok": False, "detail": err}), status
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
@@ -620,12 +742,12 @@ def _ensure_gate_scout_services() -> None:
db.session.flush()
defs = (
("Gate 扫描端", scout_host, scout_port, scout_path, 0),
("Gate 下单执行器", exec_host, exec_port, exec_path, 10),
("Gate 扫描端", scout_host, scout_port, scout_path, 0, "gate_scout"),
("Gate 下单执行器", exec_host, exec_port, exec_path, 10, "gate_exec"),
)
added = 0
updated = 0
for name, h, port, path, order in defs:
for name, h, port, path, order, embed_k in defs:
existing = Service.query.filter_by(group_id=g.id, name=name).first()
if existing:
if update_existing and (
@@ -633,11 +755,16 @@ def _ensure_gate_scout_services() -> None:
or existing.host != h
or existing.port != port
or existing.path != path
or (existing.embed_kind or "") != embed_k
):
existing.scheme = scheme
existing.host = h
existing.port = port
existing.path = path
existing.embed_kind = embed_k
updated += 1
elif not (existing.embed_kind or "").strip():
existing.embed_kind = embed_k
updated += 1
continue
db.session.add(
@@ -649,7 +776,7 @@ def _ensure_gate_scout_services() -> None:
path=path,
sort_order=order,
group_id=g.id,
embed_kind="",
embed_kind=embed_k,
)
)
added += 1
+2
View File
@@ -50,6 +50,8 @@ class ServiceForm(FlaskForm):
choices=[
("", "普通(直接打开路径)"),
("hub", "复盘中控(云端 hub,需 embed-auth 登录)"),
("gate_scout", "Gate 扫描端(iframe 代登录)"),
("gate_exec", "Gate 执行器(iframe 代登录)"),
],
default="",
validators=[Optional()],
+17 -2
View File
@@ -66,12 +66,12 @@ class Service(db.Model):
return f"{self.build_origin()}{p}"
def build_open_url(self) -> str:
"""导航 iframe 首次打开的地址(中控走 login?embed=1 以便写入会话)。"""
"""导航 iframe 首次打开的地址(中控 / Gate 扫单走 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":
if kind == "hub" or kind in ("gate_scout", "gate_exec", "scout", "exec"):
from urllib.parse import urlencode
q = urlencode({"embed": "1", "next": next_path})
@@ -80,3 +80,18 @@ class Service(db.Model):
def is_hub_embed(self) -> bool:
return (self.embed_kind or "").strip().lower() == "hub"
def is_gate_scout_embed(self) -> bool:
return (self.embed_kind or "").strip().lower() in (
"gate_scout",
"gate_exec",
"scout",
"exec",
)
def gate_scout_api_login_path(self) -> str:
"""服务端代登录使用的 API 路径。"""
kind = (self.embed_kind or "").strip().lower()
if kind in ("gate_exec", "exec"):
return "/login"
return "/api/auth/login"
+86 -2
View File
@@ -118,6 +118,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-gate-login"
title="通过本地导航代登录 Gate 扫单(需配置 NAV_GATE_SCOUT_USERNAME / NAV_GATE_SCOUT_PASSWORD"
hidden
>
Gate 登录
</button>
<button
type="button"
class="btn btn-secondary btn-toolbar-refresh"
@@ -159,6 +168,7 @@
<script>
(function () {
var hubAutoLogin = {{ 'true' if hub_auto_login else 'false' }};
var gateScoutAutoLogin = {{ 'true' if gate_scout_auto_login else 'false' }};
var layoutMain = document.getElementById("layout-main");
var btnSidebarCollapse = document.getElementById("sidebar-collapse");
var btnSidebarExpand = document.getElementById("sidebar-expand");
@@ -201,6 +211,7 @@
var btnBack = document.getElementById("frame-back-overview");
var btnBackHub = document.getElementById("frame-back-hub");
var btnHubLogin = document.getElementById("frame-hub-login");
var btnGateLogin = document.getElementById("frame-gate-login");
var btnInstanceSso = document.getElementById("frame-instance-sso");
var currentBaseUrl = "";
var currentOpenUrl = "";
@@ -240,6 +251,11 @@
return (kind || "").toLowerCase() === "hub";
}
function isGateScoutEmbed(kind) {
var k = (kind || "").toLowerCase();
return k === "gate_scout" || k === "gate_exec" || k === "scout" || k === "exec";
}
function toggleInstanceBackBtn(show) {
if (btnBackHub) btnBackHub.hidden = !show;
}
@@ -252,6 +268,10 @@
if (btnHubLogin) btnHubLogin.hidden = !show;
}
function toggleGateLoginBtn(show) {
if (btnGateLogin) btnGateLogin.hidden = !show;
}
function applyIframeUrl(url) {
if (!url) return;
frame.src = url;
@@ -329,6 +349,36 @@
});
}
function gateScoutLoginViaProxy(done) {
if (!currentServiceId && !currentOrigin) {
if (done) done(false, "未选择 Gate 服务");
return;
}
var body = { service_id: parseInt(currentServiceId, 10) || undefined, next: currentNextPath };
fetch("/api/embed/gate-scout-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) {
applyIframeUrl(res.j.embed_auth_url);
toggleGateLoginBtn(false);
if (done) done(true);
return;
}
if (done) done(false, (res.j && res.j.detail) || "Gate 登录失败");
})
.catch(function (e) {
if (done) done(false, String(e));
});
}
window.addEventListener("message", function (ev) {
var data = ev.data;
if (!data || !data.type) return;
@@ -480,19 +530,36 @@
instanceNavCtx = null;
hubReturnState = null;
toggleInstanceBackBtn(false);
toggleGateLoginBtn(false);
nameEl.textContent = name || "";
dashboard.hidden = true;
frameStack.hidden = false;
frame.hidden = false;
toggleHubLoginBtn(isHubEmbed(currentEmbedKind));
toggleHubLoginBtn(isHubEmbed(currentEmbedKind) && !hubAutoLogin);
toggleGateLoginBtn(isGateScoutEmbed(currentEmbedKind) && !gateScoutAutoLogin);
if (isHubEmbed(currentEmbedKind) && hubAutoLogin) {
hubLoginViaProxy(function (ok, err) {
if (!ok) applyIframeUrl(url);
if (!ok) {
applyIframeUrl(url);
toggleHubLoginBtn(true);
}
});
var nav = preferredNav || findNavLink(url);
setActive(nav);
return;
}
if (isGateScoutEmbed(currentEmbedKind) && gateScoutAutoLogin) {
gateScoutLoginViaProxy(function (ok, err) {
if (!ok) {
applyIframeUrl(url);
toggleGateLoginBtn(true);
if (err) console.warn("[LocalNav] Gate 代登录失败:", err);
}
});
var navGate = preferredNav || findNavLink(url);
setActive(navGate);
return;
}
applyIframeUrl(url);
var nav = preferredNav || findNavLink(url);
setActive(nav);
@@ -573,6 +640,7 @@
frameStack.hidden = true;
dashboard.hidden = false;
toggleHubLoginBtn(false);
toggleGateLoginBtn(false);
toggleInstanceSsoBtn(false);
setActive(null);
}
@@ -604,6 +672,22 @@
});
});
if (btnGateLogin) {
btnGateLogin.addEventListener("click", function () {
btnGateLogin.disabled = true;
gateScoutLoginViaProxy(function (ok, err) {
btnGateLogin.disabled = false;
if (!ok && err) {
window.alert(
"Gate 登录失败:\n" +
err +
"\n\n请检查 LocalNav .env 的 NAV_GATE_SCOUT_USERNAME / NAV_GATE_SCOUT_PASSWORD,以及云端 PM2 的 NAV_EMBED_SESSION / NAV_EMBED_ORIGINS。"
);
}
});
});
}
if (btnHubLogin) {
btnHubLogin.addEventListener("click", function () {
btnHubLogin.disabled = true;