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
+5
View File
@@ -15,6 +15,11 @@ LOGIN_DESKTOP_PIDS_LIMIT=256
LOGIN_DESKTOP_IDLE_TIMEOUT_SECONDS=1800
LOGIN_DESKTOP_STOP_AFTER_EXPORT_SECONDS=60
LOGIN_DESKTOP_STATUS_CACHE_SECONDS=15
# Login browser uses direct access first; Mihomo is a fallback when direct access fails.
LOGIN_DESKTOP_PROXY_MODE=auto
LOGIN_DESKTOP_PROXY=http://proxy:7890
LOGIN_DESKTOP_PREFLIGHT_TIMEOUT_SECONDS=15
LOGIN_DESKTOP_NETWORK_CACHE_SECONDS=30
PROXY_BIND_ADDRESS=127.0.0.1
PROXY_HTTP_PORT=7890
PROXY_CONTROLLER_PORT=9090
+4
View File
@@ -1,3 +1,7 @@
## 2026-08-25
- Made login-browser networking direct-first with Mihomo fallback and explicit preflight errors.
# Changelog
## 2026-07-11
+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 || "";
+5
View File
@@ -225,6 +225,9 @@ WEB_PORT=8787
LOGIN_DESKTOP_BIND_ADDRESS=127.0.0.1
LOGIN_DESKTOP_WEB_PORT=8788
LOGIN_DESKTOP_PUBLIC_URL=/login-desktop/proxy/vnc.html?autoconnect=1&resize=scale&view_only=0&path=login-desktop/proxy/websockify
# 登录浏览器默认先直连抖音,直连失败时再尝试 Mihomo
LOGIN_DESKTOP_PROXY_MODE=auto
LOGIN_DESKTOP_PROXY=http://proxy:7890
# Mihomo 代理和控制端口默认仅绑定本机
PROXY_BIND_ADDRESS=127.0.0.1
@@ -233,6 +236,8 @@ PROXY_CONTROLLER_PORT=9090
PROXY_SUB_URL=
```
登录工作区的网络路径与发送任务分开处理。`LOGIN_DESKTOP_PROXY_MODE=auto` 时,登录浏览器会从 `login-desktop` 容器内先直连 `creator.douyin.com`;只有直连预检失败时才使用 `LOGIN_DESKTOP_PROXY`。发送任务容器仍可独立使用 Mihomo。若两条路径都不可用,登录页面会显示明确的网络错误,不会无限等待二维码。
#### `config.example.json` 与 `config.json` - 应用配置
仓库跟踪 `DouYinSparkFlow/config.example.json`;首次运行会生成被 Git 忽略的 `DouYinSparkFlow/config.json`。常用配置示例:
+4 -8
View File
@@ -77,14 +77,10 @@ services:
LOGIN_DESKTOP_IDLE_TIMEOUT_SECONDS: ${LOGIN_DESKTOP_IDLE_TIMEOUT_SECONDS:-1800}
LOGIN_DESKTOP_STOP_AFTER_EXPORT_SECONDS: ${LOGIN_DESKTOP_STOP_AFTER_EXPORT_SECONDS:-60}
LOGIN_DESKTOP_STATUS_CACHE_SECONDS: ${LOGIN_DESKTOP_STATUS_CACHE_SECONDS:-15}
HTTP_PROXY: http://proxy:7890
HTTPS_PROXY: http://proxy:7890
ALL_PROXY: socks5://proxy:7890
http_proxy: http://proxy:7890
https_proxy: http://proxy:7890
all_proxy: socks5://proxy:7890
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
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
LOGIN_DESKTOP_PROXY_MODE: ${LOGIN_DESKTOP_PROXY_MODE:-auto}
LOGIN_DESKTOP_PROXY: ${LOGIN_DESKTOP_PROXY:-http://proxy:7890}
LOGIN_DESKTOP_PREFLIGHT_TIMEOUT_SECONDS: ${LOGIN_DESKTOP_PREFLIGHT_TIMEOUT_SECONDS:-15}
LOGIN_DESKTOP_NETWORK_CACHE_SECONDS: ${LOGIN_DESKTOP_NETWORK_CACHE_SECONDS:-30}
ports:
- "${LOGIN_DESKTOP_BIND_ADDRESS:-127.0.0.1}:${LOGIN_DESKTOP_WEB_PORT:-8788}:6080"
command: bash /app/scripts/start_login_desktop.sh
+4 -1
View File
@@ -79,13 +79,16 @@ ssh -L 8788:127.0.0.1:8788 <user>@<server-ip>
### 登录工作区打不开
先确认 SSH 隧道仍在运行,再检查 `login-desktop` 容器。默认不需要把 8788 暴露到公网。
先确认 SSH 隧道仍在运行,再检查 `login-desktop` 容器。默认不需要把 8788 暴露到公网。登录浏览器默认优先直连抖音,代理只作为备用路径。
```bash
docker compose ps
docker compose logs -f login-desktop
docker compose exec login-desktop curl -fsS http://127.0.0.1:18090/preflight
```
如果预检失败,页面会显示直连和代理各自的检查结果;通常不需要修改发送任务使用的 Mihomo 配置。
### 账号保存后没有好友列表
进入 **账号与目标**,点击 **刷新好友列表**。刷新需要当前账号的登录态有效,如果登录态过期,回到 **登录工作区** 重新登录并保存。