diff --git a/.env.example b/.env.example index 7a65414..049a630 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app.py b/app.py index 74a4e4b..e1d5cc8 100644 --- a/app.py +++ b/app.py @@ -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 diff --git a/forms.py b/forms.py index db109bb..c8b4b4b 100644 --- a/forms.py +++ b/forms.py @@ -50,6 +50,8 @@ class ServiceForm(FlaskForm): choices=[ ("", "普通(直接打开路径)"), ("hub", "复盘中控(云端 hub,需 embed-auth 登录)"), + ("gate_scout", "Gate 扫描端(iframe 代登录)"), + ("gate_exec", "Gate 执行器(iframe 代登录)"), ], default="", validators=[Optional()], diff --git a/models.py b/models.py index 32d816e..1d8b6d5 100644 --- a/models.py +++ b/models.py @@ -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" diff --git a/templates/index.html b/templates/index.html index 984a714..bc9d1d4 100644 --- a/templates/index.html +++ b/templates/index.html @@ -118,6 +118,15 @@