diff --git a/.env.example b/.env.example index 01234e4..f0926b9 100644 --- a/.env.example +++ b/.env.example @@ -8,7 +8,7 @@ SPARKFLOW_SESSION_COOKIE_SECURE=0 # Keep noVNC and proxy control ports local by default. Use an SSH tunnel for remote access. LOGIN_DESKTOP_BIND_ADDRESS=127.0.0.1 LOGIN_DESKTOP_WEB_PORT=8788 -LOGIN_DESKTOP_PUBLIC_URL=http://127.0.0.1:8788/vnc.html?autoconnect=1&resize=scale&view_only=0 +LOGIN_DESKTOP_PUBLIC_URL=/login-desktop/proxy/vnc.html?autoconnect=1&resize=scale&view_only=0&path=login-desktop/proxy/websockify PROXY_BIND_ADDRESS=127.0.0.1 PROXY_HTTP_PORT=7890 PROXY_CONTROLLER_PORT=9090 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3155ae5..cc1cd0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## 2026-07-11 +- Added an authenticated same-origin noVNC HTTP/WebSocket proxy and synchronous mobile popup handling so login tasks work from phones without exposing port 8788 publicly. + - Fixed the server image to provision Node.js 22 explicitly for protocol mode and excluded runtime logs/configuration from the Docker build context. - Reworked the Web console into a responsive unified operations dashboard with local Lucide icons, clearer account/target status, and safer confirmation dialogs. diff --git a/DouYinSparkFlow/requirements.txt b/DouYinSparkFlow/requirements.txt index b6f2d8e..90a8129 100644 --- a/DouYinSparkFlow/requirements.txt +++ b/DouYinSparkFlow/requirements.txt @@ -20,3 +20,4 @@ rich==14.2.0 typing_extensions==4.15.0 urllib3==2.5.0 uvicorn==0.34.0 +websockets==15.0.1 diff --git a/DouYinSparkFlow/tests/test_deployment_contract.py b/DouYinSparkFlow/tests/test_deployment_contract.py index d268125..d75d64b 100644 --- a/DouYinSparkFlow/tests/test_deployment_contract.py +++ b/DouYinSparkFlow/tests/test_deployment_contract.py @@ -76,6 +76,8 @@ class DeploymentContractTests(unittest.TestCase): text = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8") self.assertIn("SPARKFLOW_LOGIN_DESKTOP_API_URL: http://login-desktop:18090", text) self.assertIn("SPARKFLOW_LOGIN_DESKTOP_PUBLIC_URL", text) + self.assertIn("/login-desktop/proxy/vnc.html", text) + self.assertIn("SPARKFLOW_LOGIN_DESKTOP_NOVNC_WS_URL", text) def test_installers_preserve_runtime_config_and_do_not_require_bash_on_windows(self): server = (REPO_ROOT / "deploy" / "install-server.sh").read_text(encoding="utf-8") diff --git a/DouYinSparkFlow/tests/test_webui_safety.py b/DouYinSparkFlow/tests/test_webui_safety.py index 8301eb6..8ab7da2 100644 --- a/DouYinSparkFlow/tests/test_webui_safety.py +++ b/DouYinSparkFlow/tests/test_webui_safety.py @@ -1,4 +1,4 @@ -import errno +import errno import os import tempfile import time @@ -122,6 +122,54 @@ class WebUiSafetyTests(unittest.TestCase): app_module.login_desktop_public_url(request), ) + def test_login_desktop_defaults_to_authenticated_same_origin_proxy(self): + request = type("Request", (), {"url": type("Url", (), {"hostname": "example", "scheme": "https"})()})() + with ( + patch.dict( + os.environ, + { + "SPARKFLOW_LOGIN_DESKTOP_PUBLIC_URL": "", + }, + clear=False, + ), + patch.object(app_module, "get_app_settings", return_value={}), + ): + url = app_module.login_desktop_public_url(request) + + self.assertTrue(url.startswith("/login-desktop/proxy/vnc.html?")) + self.assertIn("path=login-desktop/proxy/websockify", url) + + def test_login_desktop_http_proxy_requires_auth_and_forwards_assets(self): + client = TestClient(app_module.app) + unauthenticated = client.get( + "/login-desktop/proxy/vnc.html", + follow_redirects=False, + ) + self.assertEqual(303, unauthenticated.status_code) + self.assertEqual("/login", unauthenticated.headers["location"]) + + with ( + patch.object(app_module, "current_user", return_value="admin"), + patch.object( + app_module, + "fetch_login_desktop_asset", + return_value=(200, {"Content-Type": "text/html"}, b"noVNC"), + ) as fetch_asset, + ): + response = client.get("/login-desktop/proxy/vnc.html?autoconnect=1") + + self.assertEqual(200, response.status_code) + self.assertEqual("text/html", response.headers["content-type"]) + self.assertIn("noVNC", response.text) + fetch_asset.assert_called_once_with("vnc.html", "autoconnect=1") + + def test_mobile_login_popup_opens_before_async_request(self): + script = (Path(app_module.STATIC_DIR) / "app.js").read_text(encoding="utf-8") + block_start = script.index('document.querySelectorAll(".login-desktop-open")') + block_end = script.index('document.querySelectorAll(".login-desktop-save")', block_start) + block = script[block_start:block_end] + self.assertLess(block.index("window.open(publicUrl"), block.index('await postForm("/login-desktop/open")')) + def test_schedule_sync_writes_configured_window_to_shared_spool(self): with tempfile.TemporaryDirectory() as temp_dir: cron_path = Path(temp_dir) / "root" diff --git a/DouYinSparkFlow/webui/app.py b/DouYinSparkFlow/webui/app.py index b1a973e..2ba30ac 100644 --- a/DouYinSparkFlow/webui/app.py +++ b/DouYinSparkFlow/webui/app.py @@ -1,3 +1,4 @@ +import asyncio import json import logging import os @@ -5,10 +6,13 @@ from datetime import datetime, timedelta, timezone from pathlib import Path import urllib.error import urllib.request +from urllib.parse import quote from contextlib import asynccontextmanager import uvicorn -from fastapi import FastAPI, Request +import websockets +from websockets.exceptions import ConnectionClosed +from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse, Response from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates @@ -207,10 +211,37 @@ def login_desktop_public_url(request: Request) -> str: if configured_url: return configured_url - host = request.url.hostname or "127.0.0.1" - scheme = str(settings.get("login_desktop_public_scheme") or "http").strip() or "http" - port = coerce_int(settings.get("login_desktop_public_port"), 8788, minimum=1) - return f"{scheme}://{host}:{port}/vnc.html?autoconnect=1&resize=scale&view_only=0" + return ( + "/login-desktop/proxy/vnc.html" + "?autoconnect=1&resize=scale&view_only=0" + "&path=login-desktop/proxy/websockify" + ) + + +def login_desktop_novnc_http_url() -> str: + return str(os.getenv("SPARKFLOW_LOGIN_DESKTOP_NOVNC_URL") or "http://login-desktop:6080").rstrip("/") + + +def login_desktop_novnc_ws_url() -> str: + return str(os.getenv("SPARKFLOW_LOGIN_DESKTOP_NOVNC_WS_URL") or "ws://login-desktop:6080/websockify") + + +def fetch_login_desktop_asset(asset_path: str, query: str = ""): + safe_path = quote(str(asset_path or "vnc.html").lstrip("/"), safe="/._-") + url = f"{login_desktop_novnc_http_url()}/{safe_path}" + if query: + url = f"{url}?{query}" + upstream_request = urllib.request.Request(url, method="GET") + try: + with urllib.request.urlopen(upstream_request, timeout=20) as upstream: + headers = { + key: value + for key, value in upstream.headers.items() + if key.lower() in {"content-type", "content-encoding", "cache-control", "etag", "last-modified"} + } + return upstream.status, headers, upstream.read() + except (urllib.error.URLError, TimeoutError) as exc: + raise RuntimeError(f"login-desktop noVNC proxy failed: {exc}") from exc def call_login_desktop(path: str, *, method: str = "GET", payload: dict | None = None, timeout: int = 20) -> dict: @@ -888,6 +919,84 @@ def create_app(): }, ) + @app.get("/login-desktop/proxy") + async def login_desktop_proxy_root(request: Request): + maybe_redirect = require_user(request) + if maybe_redirect: + return maybe_redirect + return RedirectResponse(login_desktop_public_url(request), status_code=307) + + @app.get("/login-desktop/proxy/{asset_path:path}") + async def login_desktop_proxy_asset(request: Request, asset_path: str): + maybe_redirect = require_user(request) + if maybe_redirect: + return maybe_redirect + try: + status, headers, content = await asyncio.to_thread( + fetch_login_desktop_asset, + asset_path, + request.url.query, + ) + return Response(content=content, status_code=status, headers=headers) + except RuntimeError as exc: + return PlainTextResponse(str(exc), status_code=502) + + @app.websocket("/login-desktop/proxy/websockify") + async def login_desktop_proxy_websocket(websocket: WebSocket): + if not current_user(websocket): + await websocket.close(code=4401) + return + + requested_protocols = [ + item.strip() + for item in websocket.headers.get("sec-websocket-protocol", "").split(",") + if item.strip() + ] + accepted = False + try: + async with websockets.connect( + login_desktop_novnc_ws_url(), + subprotocols=requested_protocols or None, + open_timeout=10, + close_timeout=5, + ) as upstream: + await websocket.accept(subprotocol=upstream.subprotocol) + accepted = True + + async def client_to_upstream(): + while True: + message = await websocket.receive() + if message["type"] == "websocket.disconnect": + return + if message.get("bytes") is not None: + await upstream.send(message["bytes"]) + elif message.get("text") is not None: + await upstream.send(message["text"]) + + async def upstream_to_client(): + async for message in upstream: + if isinstance(message, bytes): + await websocket.send_bytes(message) + else: + await websocket.send_text(message) + + relays = { + asyncio.create_task(client_to_upstream()), + asyncio.create_task(upstream_to_client()), + } + done, pending = await asyncio.wait(relays, return_when=asyncio.FIRST_COMPLETED) + for task in pending: + task.cancel() + await asyncio.gather(*pending, return_exceptions=True) + for task in done: + task.result() + except (ConnectionClosed, WebSocketDisconnect): + pass + except Exception as exc: + logger.warning("login desktop WebSocket proxy failed: %s", exc) + if not accepted: + await websocket.close(code=1011) + @app.get("/login-desktop/status") async def login_desktop_status(request: Request): maybe_redirect = require_user(request) diff --git a/DouYinSparkFlow/webui/static/app.css b/DouYinSparkFlow/webui/static/app.css index 32835be..5d83e65 100644 --- a/DouYinSparkFlow/webui/static/app.css +++ b/DouYinSparkFlow/webui/static/app.css @@ -1610,6 +1610,21 @@ textarea { flex-direction: column; } + .login-actions .button-row { + display: grid; + grid-template-columns: 1fr; + } + + .login-actions .button-row .button { + width: 100%; + justify-content: center; + } + + .desktop-frame { + aspect-ratio: auto; + min-height: 68vh; + } + .overview-strip, .metric-grid { grid-template-columns: 1fr; diff --git a/DouYinSparkFlow/webui/static/app.js b/DouYinSparkFlow/webui/static/app.js index b4723d6..57938da 100644 --- a/DouYinSparkFlow/webui/static/app.js +++ b/DouYinSparkFlow/webui/static/app.js @@ -409,13 +409,22 @@ document.querySelectorAll(".login-desktop-open").forEach((button) => { button.addEventListener("click", async () => { + // Mobile browsers block window.open after an awaited request. Open the + // authenticated same-origin workspace while the click gesture is active. + const popup = publicUrl + ? window.open(publicUrl, "_blank", "noopener") + : null; try { await postForm("/login-desktop/open"); loadFrame(); - window.open(publicUrl, "_blank", "noopener"); - setStatus("请在登录工作区完成登录,然后保存登录态。"); + if (!popup && frame) { + frame.scrollIntoView({ behavior: "smooth", block: "start" }); + setStatus("???????????????????????"); + } else { + setStatus("????????????????????????"); + } } catch (error) { - setStatus(`打开登录工作区失败:${error.message}`, "danger"); + setStatus(`??????????${error.message}`, "danger"); } }); }); diff --git a/DouYinSparkFlow/webui/templates/dashboard.html b/DouYinSparkFlow/webui/templates/dashboard.html index 6d6ab94..0d166b7 100644 --- a/DouYinSparkFlow/webui/templates/dashboard.html +++ b/DouYinSparkFlow/webui/templates/dashboard.html @@ -210,7 +210,7 @@
展开后检查登录桌面状态。
+???????????????????????????????????? 8788 ???