fix: support mobile login workspace

This commit is contained in:
Rixuan Shao
2026-07-11 21:05:59 +08:00
parent 32d58940ff
commit 472b3e8304
12 changed files with 208 additions and 18 deletions
+1 -1
View File
@@ -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
+2
View File
@@ -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.
+1
View File
@@ -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
@@ -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")
+49 -1
View File
@@ -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"<html>noVNC</html>"),
) 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"
+114 -5
View File
@@ -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)
+15
View File
@@ -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;
+12 -3
View File
@@ -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");
}
});
});
@@ -210,7 +210,7 @@
<div class="login-actions">
<div class="login-status-panel">
<strong>登录状态</strong>
<p class="muted compact" id="login-desktop-status-text">展开后检查登录桌面状态。</p>
<p class="muted compact" id="login-desktop-status-text">???????????????????????????????????? 8788 ???</p>
</div>
<div class="button-row">
<button class="button button-primary login-desktop-open" type="button">
+1 -1
View File
@@ -221,7 +221,7 @@ WEB_PORT=8787
# noVNC 默认仅允许本机或 SSH 隧道访问
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
# Mihomo 代理和控制端口默认仅绑定本机
PROXY_BIND_ADDRESS=127.0.0.1
+3 -1
View File
@@ -44,7 +44,9 @@ services:
NO_PROXY: localhost,127.0.0.1,login-desktop,douyin.com,amemv.com,snssdk.com,bytedance.com,pstatp.com,volccdn.com,bytescm.com,byted.net,douyinstatic.com,bytecdn.cn,byteimg.com,bytegoofy.com,toutiaostatic.com
SPARKFLOW_LOGIN_DESKTOP_API_URL: http://login-desktop:18090
LOGIN_DESKTOP_PUBLIC_PORT: ${LOGIN_DESKTOP_WEB_PORT:-8788}
SPARKFLOW_LOGIN_DESKTOP_PUBLIC_URL: ${LOGIN_DESKTOP_PUBLIC_URL:-http://127.0.0.1:8788/vnc.html?autoconnect=1&resize=scale&view_only=0}
SPARKFLOW_LOGIN_DESKTOP_PUBLIC_URL: ${LOGIN_DESKTOP_PUBLIC_URL:-/login-desktop/proxy/vnc.html?autoconnect=1&resize=scale&view_only=0&path=login-desktop/proxy/websockify}
SPARKFLOW_LOGIN_DESKTOP_NOVNC_URL: http://login-desktop:6080
SPARKFLOW_LOGIN_DESKTOP_NOVNC_WS_URL: ws://login-desktop:6080/websockify
SPARKFLOW_SESSION_COOKIE_SECURE: ${SPARKFLOW_SESSION_COOKIE_SECURE:-0}
ports:
- "${WEB_BIND_ADDRESS:-0.0.0.0}:${WEB_PORT:-8787}:8787"
+7 -5
View File
@@ -21,19 +21,21 @@
![控制台概览](images/usage-overview.png)
## 3. 打开登录工作区
## 3. ???????
进入 **登录工作区**,在远端浏览器里完成抖音扫码、验证码或其他人工验证步骤。
?? **?????**????????????????????????????
noVNC 默认只监听服务器的 `127.0.0.1:8788`。远程服务器先在本地建立 SSH 隧道:
noVNC ????????? `127.0.0.1:8788`?????????????????????????????? **???????** ??????? 8788 ??????
???????? noVNC?????? SSH ???
```bash
ssh -L 8788:127.0.0.1:8788 <user>@<server-ip>
```
然后点击 **新窗口打开**,或访问 `http://127.0.0.1:8788/vnc.html?autoconnect=1&resize=scale&view_only=0`。登录完成后点击 **保存当前账号**,面板会把登录态写入账号配置。
???? `http://127.0.0.1:8788/vnc.html?autoconnect=1&resize=scale&view_only=0`???????? **??????**???????????????
![登录工作区](images/usage-login-workspace.png)
![?????](images/usage-login-workspace.png)
## 4. 维护账号与目标好友