fix: prefer direct login browser access with proxy fallback

This commit is contained in:
Rixuan Shao
2026-08-25 11:35:20 +08:00
parent 63443fda67
commit 4dd58ecb80
10 changed files with 335 additions and 23 deletions
+198 -10
View File
@@ -2,8 +2,10 @@ import asyncio
import os
import shutil
import time
import urllib.request
from contextlib import asynccontextmanager
from pathlib import Path
from urllib.parse import urlsplit
from fastapi import FastAPI, HTTPException, Request, Response
import uvicorn
@@ -28,6 +30,16 @@ if LOGIN_DESKTOP_MODE not in {"native", "novnc"}:
IDLE_TIMEOUT_SECONDS = max(300, int(os.getenv("LOGIN_DESKTOP_IDLE_TIMEOUT_SECONDS", "1800")))
STOP_AFTER_EXPORT_SECONDS = max(0, int(os.getenv("LOGIN_DESKTOP_STOP_AFTER_EXPORT_SECONDS", "60")))
STATUS_CACHE_SECONDS = max(1, int(os.getenv("LOGIN_DESKTOP_STATUS_CACHE_SECONDS", "15")))
LOGIN_NETWORK_MODE = str(os.getenv("LOGIN_DESKTOP_PROXY_MODE", "auto")).strip().lower()
if LOGIN_NETWORK_MODE not in {"auto", "direct", "proxy"}:
LOGIN_NETWORK_MODE = "auto"
LOGIN_PROXY_SERVER = str(os.getenv("LOGIN_DESKTOP_PROXY", "http://proxy:7890")).strip()
LOGIN_PREFLIGHT_TIMEOUT_SECONDS = max(
3, int(os.getenv("LOGIN_DESKTOP_PREFLIGHT_TIMEOUT_SECONDS", "15"))
)
LOGIN_NETWORK_CACHE_SECONDS = max(
0, int(os.getenv("LOGIN_DESKTOP_NETWORK_CACHE_SECONDS", "30"))
)
GENERIC_WWW_NAMES = {
"",
"我的",
@@ -51,6 +63,64 @@ GENERIC_WWW_NAMES = {
}
class LoginNetworkError(RuntimeError):
"""Raised when the login browser cannot reach Douyin."""
def __init__(self, message, *, checks=None):
super().__init__(message)
self.checks = checks or {}
def _safe_proxy_label(proxy_server):
if not proxy_server:
return ""
try:
parsed = urlsplit(proxy_server)
host = parsed.hostname or ""
port = f":{parsed.port}" if parsed.port else ""
return f"{host}{port}" if host else "configured proxy"
except ValueError:
return "configured proxy"
def _probe_login_target(proxy_server=None, timeout_seconds=15):
"""Probe Douyin without inheriting the process proxy environment."""
if proxy_server:
handlers = [
urllib.request.ProxyHandler(
{"http": proxy_server, "https": proxy_server}
)
]
else:
handlers = [urllib.request.ProxyHandler({})]
opener = urllib.request.build_opener(*handlers)
request = urllib.request.Request(
REMOTE_LOGIN_URL,
headers={"User-Agent": "DouYinSparkFlow-login-preflight/1"},
)
started = time.monotonic()
try:
with opener.open(request, timeout=timeout_seconds) as response:
response.read(256)
status = int(getattr(response, "status", 200))
if status >= 400:
raise RuntimeError(f"HTTP {status}")
return {
"ok": True,
"status": status,
"latency_ms": round((time.monotonic() - started) * 1000),
}
except Exception as exc:
error = str(exc) or exc.__class__.__name__
if proxy_server:
error = error.replace(proxy_server, _safe_proxy_label(proxy_server))
return {
"ok": False,
"error": error[:240],
"latency_ms": round((time.monotonic() - started) * 1000),
}
class LoginDesktopManager:
def __init__(self):
self._lock = asyncio.Lock()
@@ -63,6 +133,83 @@ class LoginDesktopManager:
self._status_checked_at = 0.0
self._idle_monitor_task = None
self._scheduled_stop_task = None
self._network_route = None
self._network_checks = {}
self._network_checked_at = 0.0
def _network_payload(self):
route = dict(self._network_route or {})
return {
"mode": LOGIN_NETWORK_MODE,
"selected": route.get("mode", ""),
"proxy": _safe_proxy_label(LOGIN_PROXY_SERVER) if route.get("mode") == "proxy" else "",
"checked_at": route.get("checked_at", ""),
"checks": dict(self._network_checks),
}
def _invalidate_network_route(self):
self._network_route = None
self._network_checked_at = 0.0
async def _select_network_route(self, *, force=False):
now = time.monotonic()
if (
not force
and self._network_route
and now - self._network_checked_at < LOGIN_NETWORK_CACHE_SECONDS
):
return dict(self._network_route)
checks = {}
candidates = []
if LOGIN_NETWORK_MODE in {"auto", "direct"}:
candidates.append(("direct", None))
if LOGIN_NETWORK_MODE in {"auto", "proxy"} and LOGIN_PROXY_SERVER:
candidates.append(("proxy", LOGIN_PROXY_SERVER))
for mode, proxy_server in candidates:
result = await asyncio.to_thread(
_probe_login_target,
proxy_server,
LOGIN_PREFLIGHT_TIMEOUT_SECONDS,
)
checks[mode] = result
if result.get("ok"):
route = {
"mode": mode,
"proxy": _safe_proxy_label(proxy_server),
"checked_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
self._network_checks = checks
self._network_route = route
self._network_checked_at = now
return dict(route)
self._network_checks = checks
self._network_route = None
self._network_checked_at = now
if LOGIN_NETWORK_MODE == "direct":
message = "无法直连抖音创作者中心,请检查服务器网络出口"
elif LOGIN_NETWORK_MODE == "proxy":
message = f"代理 {_safe_proxy_label(LOGIN_PROXY_SERVER)} 无法访问抖音创作者中心"
else:
message = "直连和代理都无法访问抖音创作者中心"
raise LoginNetworkError(message, checks=checks)
async def network_preflight(self, *, force=True):
try:
route = await self._select_network_route(force=force)
return {
"ok": True,
"route": route,
"network": self._network_payload(),
}
except LoginNetworkError as exc:
return {
"ok": False,
"error": str(exc),
"network": self._network_payload(),
}
def mark_activity(self):
self._last_activity = time.monotonic()
@@ -136,6 +283,7 @@ class LoginDesktopManager:
except Exception:
pass
self.playwright = None
route = await self._select_network_route()
self.playwright = await async_playwright().start()
launch_args = [
"--start-maximized",
@@ -158,11 +306,18 @@ class LoginDesktopManager:
"--renderer-process-limit=2",
]
)
launch_options = {
"headless": False,
"viewport": {"width": 1600, "height": 1000},
"args": launch_args,
}
if route["mode"] == "proxy":
launch_options["proxy"] = {"server": LOGIN_PROXY_SERVER}
else:
launch_args.append("--no-proxy-server")
self.context = await self.playwright.chromium.launch_persistent_context(
str(PROFILE_DIR),
headless=False,
viewport={"width": 1600, "height": 1000},
args=launch_args,
**launch_options,
)
self.page = self.context.pages[0] if self.context.pages else await self.context.new_page()
@@ -227,7 +382,12 @@ class LoginDesktopManager:
await page.bring_to_front()
except Exception:
pass
return {"ok": True, "url": page.url, "mode": LOGIN_DESKTOP_MODE}
return {
"ok": True,
"url": page.url,
"mode": LOGIN_DESKTOP_MODE,
"network": self._network_payload(),
}
async def status(self):
now = time.monotonic()
@@ -247,6 +407,7 @@ class LoginDesktopManager:
"unique_id": "",
"current_url": "",
"profile_dir": str(PROFILE_DIR),
"network": self._network_payload(),
}
self._status_cache = payload
self._status_checked_at = now
@@ -272,6 +433,7 @@ class LoginDesktopManager:
"unique_id": "",
"current_url": "",
"profile_dir": str(PROFILE_DIR),
"network": self._network_payload(),
}
self._status_cache = payload
self._status_checked_at = now
@@ -295,6 +457,7 @@ class LoginDesktopManager:
"unique_id": unique_id,
"current_url": current_url,
"profile_dir": str(PROFILE_DIR),
"network": self._network_payload(),
}
self._status_cache = payload
self._status_checked_at = now
@@ -309,7 +472,7 @@ class LoginDesktopManager:
try:
page = await self._get_active_page()
if page.url.startswith(REMOTE_LOGIN_URL):
return {"ok": True, "url": page.url}
return {"ok": True, "url": page.url, "network": self._network_payload()}
refresh_url = f"{REMOTE_LOGIN_URL}?qr_refresh={int(time.time() * 1000)}"
try:
await page.goto(refresh_url, wait_until="commit", timeout=30000)
@@ -318,7 +481,7 @@ class LoginDesktopManager:
await self.start()
page = await self._get_active_page()
await page.goto(refresh_url, wait_until="commit", timeout=30000)
return {"ok": True, "url": page.url}
return {"ok": True, "url": page.url, "network": self._network_payload()}
finally:
self._page_operation_lock.release()
@@ -516,6 +679,14 @@ async def health():
return {"ok": True}
@app.get("/preflight")
async def preflight():
result = await manager.network_preflight(force=True)
if not result.get("ok"):
raise HTTPException(status_code=502, detail=result)
return result
@app.get("/status")
async def status():
return await manager.status()
@@ -523,8 +694,13 @@ async def status():
@app.post("/open-login")
async def open_login():
await manager.open_login()
return {"ok": True}
try:
return await manager.open_login()
except LoginNetworkError as exc:
raise HTTPException(
status_code=502,
detail={"code": "LOGIN_NETWORK_UNAVAILABLE", "message": str(exc), "checks": exc.checks},
) from exc
@app.post("/reset")
@@ -546,7 +722,13 @@ async def focus():
@app.post("/refresh-qr")
async def refresh_qr():
return await manager.refresh_login_qr()
try:
return await manager.refresh_login_qr()
except LoginNetworkError as exc:
raise HTTPException(
status_code=502,
detail={"code": "LOGIN_NETWORK_UNAVAILABLE", "message": str(exc), "checks": exc.checks},
) from exc
@app.post("/export")
@@ -566,7 +748,13 @@ async def export():
async def login_qr():
if manager._page_operation_lock.locked():
raise HTTPException(status_code=503, detail="login page is busy; retry shortly")
page = await manager._get_active_page()
try:
page = await manager._get_active_page()
except LoginNetworkError as exc:
raise HTTPException(
status_code=502,
detail={"code": "LOGIN_NETWORK_UNAVAILABLE", "message": str(exc), "checks": exc.checks},
) from exc
expired = await page.locator('[class*="qrcode_expired"]').count()
if expired and await page.locator('[class*="qrcode_expired"]').first.is_visible():
raise HTTPException(status_code=409, detail="login QR code has expired")
@@ -72,9 +72,9 @@ class DeploymentContractTests(unittest.TestCase):
compose = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8")
self.assertIn("# Build proxies must never leak", dockerfile)
self.assertIn("http_proxy=", dockerfile)
self.assertGreaterEqual(compose.count("http_proxy: http://proxy:7890"), 4)
self.assertGreaterEqual(compose.count("https_proxy: http://proxy:7890"), 4)
self.assertGreaterEqual(compose.count("no_proxy:"), 4)
self.assertGreaterEqual(compose.count("http_proxy: http://proxy:7890"), 3)
self.assertGreaterEqual(compose.count("https_proxy: http://proxy:7890"), 3)
self.assertGreaterEqual(compose.count("no_proxy:"), 3)
def test_sensitive_ports_bind_to_loopback_by_default(self):
text = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8")
@@ -114,6 +114,20 @@ class DeploymentContractTests(unittest.TestCase):
for entry in ("logs/", "config.json", "usersData.json", "webui_settings.json"):
self.assertIn(entry, dockerignore)
def test_login_desktop_uses_direct_first_network_route(self):
compose = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8")
env_example = (REPO_ROOT / ".env.example").read_text(encoding="utf-8")
server = (SOURCE_ROOT / "login_desktop_server.py").read_text(encoding="utf-8")
login_block = compose.split(" login-desktop:", 1)[1].split(" scheduler:", 1)[0]
self.assertIn("LOGIN_DESKTOP_PROXY_MODE: ${LOGIN_DESKTOP_PROXY_MODE:-auto}", login_block)
self.assertIn("LOGIN_DESKTOP_PROXY: ${LOGIN_DESKTOP_PROXY:-http://proxy:7890}", login_block)
self.assertNotIn("HTTP_PROXY: http://proxy:7890", login_block)
self.assertIn("LOGIN_DESKTOP_PROXY_MODE=auto", env_example)
self.assertIn('candidates.append(("direct", None))', server)
self.assertIn('candidates.append(("proxy", LOGIN_PROXY_SERVER))', server)
self.assertIn('"--no-proxy-server"', server)
self.assertIn('"/preflight"', server)
def test_login_desktop_resource_controls_are_configured(self):
compose = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8")
start_script = (SOURCE_ROOT / "scripts" / "start_login_desktop.sh").read_text(encoding="utf-8")
@@ -0,0 +1,76 @@
from unittest import IsolatedAsyncioTestCase
from unittest.mock import patch
from login_desktop_server import LoginDesktopManager, LoginNetworkError
class LoginNetworkTests(IsolatedAsyncioTestCase):
async def test_auto_mode_prefers_direct_route(self):
manager = LoginDesktopManager()
with (
patch("login_desktop_server.LOGIN_NETWORK_MODE", "auto"),
patch("login_desktop_server.LOGIN_PROXY_SERVER", "http://proxy:7890"),
patch(
"login_desktop_server._probe_login_target",
return_value={"ok": True, "status": 200, "latency_ms": 10},
) as probe,
):
route = await manager._select_network_route(force=True)
self.assertEqual("direct", route["mode"])
probe.assert_called_once_with(None, 15)
async def test_auto_mode_falls_back_to_proxy(self):
manager = LoginDesktopManager()
with (
patch("login_desktop_server.LOGIN_NETWORK_MODE", "auto"),
patch("login_desktop_server.LOGIN_PROXY_SERVER", "http://proxy:7890"),
patch(
"login_desktop_server._probe_login_target",
side_effect=[
{"ok": False, "error": "direct failed"},
{"ok": True, "status": 200, "latency_ms": 20},
],
) as probe,
):
route = await manager._select_network_route(force=True)
self.assertEqual("proxy", route["mode"])
self.assertIsNone(probe.call_args_list[0].args[0])
self.assertEqual("http://proxy:7890", probe.call_args_list[1].args[0])
async def test_auto_mode_reports_both_failures(self):
manager = LoginDesktopManager()
with (
patch("login_desktop_server.LOGIN_NETWORK_MODE", "auto"),
patch("login_desktop_server.LOGIN_PROXY_SERVER", "http://proxy:7890"),
patch(
"login_desktop_server._probe_login_target",
side_effect=[
{"ok": False, "error": "direct failed"},
{"ok": False, "error": "proxy failed"},
],
),
):
with self.assertRaises(LoginNetworkError) as caught:
await manager._select_network_route(force=True)
self.assertIn("直连和代理", str(caught.exception))
self.assertEqual({"direct", "proxy"}, set(caught.exception.checks))
async def test_network_preflight_exposes_selected_route_without_credentials(self):
manager = LoginDesktopManager()
with (
patch("login_desktop_server.LOGIN_NETWORK_MODE", "proxy"),
patch("login_desktop_server.LOGIN_PROXY_SERVER", "http://user:secret@proxy:7890"),
patch(
"login_desktop_server._probe_login_target",
return_value={"ok": True, "status": 200, "latency_ms": 5},
),
):
result = await manager.network_preflight(force=True)
self.assertTrue(result["ok"])
self.assertEqual("proxy", result["route"]["mode"])
self.assertEqual("proxy:7890", result["network"]["proxy"])
self.assertNotIn("secret", repr(result))
+11 -1
View File
@@ -295,7 +295,17 @@ def call_login_desktop(path: str, *, method: str = "GET", payload: dict | None =
return json.loads(body) if body.strip() else {}
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"login-desktop API error {exc.code}: {body}") from exc
message = body
try:
payload = json.loads(body)
detail = payload.get("detail") if isinstance(payload, dict) else None
if isinstance(detail, dict):
message = str(detail.get("message") or detail.get("code") or body)
elif detail:
message = str(detail)
except (TypeError, ValueError):
pass
raise RuntimeError(f"login-desktop API error {exc.code}: {message}") from exc
except (urllib.error.URLError, TimeoutError) as exc:
reason = getattr(exc, "reason", exc)
raise RuntimeError(f"login-desktop unavailable: {reason}") from exc
+11
View File
@@ -448,9 +448,20 @@
if (retries > 1 && workspace.state === "active") {
if (qrStatus) qrStatus.textContent = "浏览器正在生成二维码,继续等待...";
refreshLoginQr(1400, retries - 1);
} else if (qrStatus) {
qrStatus.textContent = "登录页面在规定时间内没有生成二维码,请稍后重试。";
}
return;
}
if (response.status === 409) {
if (qrStatus) qrStatus.textContent = "二维码已过期,请点击刷新二维码。";
return;
}
if (response.status === 502) {
const data = await response.json().catch(() => ({}));
if (qrStatus) qrStatus.textContent = data.error || "无法访问抖音创作者中心,请检查服务器网络出口。";
return;
}
if (!response.ok) throw new Error(String(response.status));
const blob = await response.blob();
const previous = qrImage.dataset.objectUrl || "";