From ebd07c923001dfc6aaa531a8b2ee3b37a0b884c8 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 30 Sep 2026 03:17:06 +0800 Subject: [PATCH] feat(browser): fixed Windows identity and per-site egress in umt-cookies plugin (BR-4) - browser:launching hook generates a Windows fingerprint once (camoufox-js, os=windows), persists it to /root/.camofox/umt-identity.json and reuses it on every launch; Firefox/Camoufox upgrades only bump UA/version fields - Windows fontconfig with absolute font dir (upstream path was broken) - GET /umt/identity (public UA/OS/version) and PUT /umt/sessions/:id/proxy (per-context proxy; Firefox no longer uses container HTTP_PROXY env) - adapter identity()/setEgress(), splitBrowserOnlyCookies for cf_clearance/__cf_bm Co-Authored-By: Claude Opus 5.5 --- .../camofox/plugins/umt-cookies/identity.js | 157 +++++++++++ .../opt/camofox/plugins/umt-cookies/index.js | 265 +++++++++++++++++- .../adapters/src/browser/camofox/client.ts | 52 +++- packages/adapters/src/browser/cookies.ts | 24 +- packages/adapters/src/browser/provider.ts | 11 + .../adapters/src/browser/umt-plugin.test.ts | 178 ++++++++++++ packages/contracts/src/browser.ts | 31 ++ packages/testkit/src/browser/mock-camofox.ts | 33 +++ 8 files changed, 746 insertions(+), 5 deletions(-) create mode 100644 docker/browser-desktop/root/opt/camofox/plugins/umt-cookies/identity.js create mode 100644 packages/adapters/src/browser/umt-plugin.test.ts diff --git a/docker/browser-desktop/root/opt/camofox/plugins/umt-cookies/identity.js b/docker/browser-desktop/root/opt/camofox/plugins/umt-cookies/identity.js new file mode 100644 index 00000000..b3a8e572 --- /dev/null +++ b/docker/browser-desktop/root/opt/camofox/plugins/umt-cookies/identity.js @@ -0,0 +1,157 @@ +/** + * 固定浏览器身份(BR-4)的纯函数部分:不依赖 camoufox-js,单测直接导入(packages/adapters/src/browser/umt-identity.test.ts)。 + * + * Camoufox 的指纹是一份 config 字典(navigator.* / screen.* / webGl:* / fonts / 各种 seed …),经 CAMOU_CONFIG_n 环境变量 + * 交给浏览器进程。身份文件保存"指纹部分"的 config;每次启动把上游生成的 config 里的指纹部分整份换成身份文件里的, + * 只保留运行期字段(humanize、locale、geolocation、timezone、WebRTC 地址、扩展路径)。 + */ + +export const IDENTITY_SCHEMA = 1; + +/** 运行期字段:由 camofox 启动参数决定(CAMOFOX_LOCALE、代理 geoip、humanize、扩展),不进身份文件 */ +const RUNTIME_KEYS = new Set(["humanize", "addons", "allowMainWorld", "timezone"]); +const RUNTIME_PREFIXES = ["humanize:", "locale:", "geolocation:", "webrtc:"]; + +export function isRuntimeKey(key) { + return RUNTIME_KEYS.has(key) || RUNTIME_PREFIXES.some((p) => key.startsWith(p)); +} + +/** 从启动参数的 env 里拼回 config(CAMOU_CONFIG_1..n 按序拼接) */ +export function decodeConfig(env) { + const parts = []; + for (let i = 1; env && typeof env[`CAMOU_CONFIG_${i}`] === "string"; i++) parts.push(env[`CAMOU_CONFIG_${i}`]); + if (!parts.length) return null; + try { + const v = JSON.parse(parts.join("")); + return v && typeof v === "object" && !Array.isArray(v) ? v : null; + } catch { + return null; + } +} + +/** 把 config 写回 env:先删掉旧的 CAMOU_CONFIG_n,再按 Linux 的分片长度(32767)切片 */ +export function encodeConfig(env, config) { + for (const k of Object.keys(env)) if (/^CAMOU_CONFIG_\d+$/.test(k)) delete env[k]; + const s = JSON.stringify(config); + const chunk = 32767; + for (let i = 0; i < s.length; i += chunk) env[`CAMOU_CONFIG_${Math.floor(i / chunk) + 1}`] = s.slice(i, i + chunk); + return env; +} + +/** 指纹部分(去掉运行期字段) */ +export function fingerprintPart(config) { + const out = {}; + for (const [k, v] of Object.entries(config ?? {})) if (!isRuntimeKey(k)) out[k] = v; + return out; +} + +/** 上游生成的 config + 身份 → 实际启动用的 config:运行期字段取上游,其余一律取身份 */ +export function mergeLaunchConfig(upstream, identityConfig) { + const out = {}; + for (const [k, v] of Object.entries(upstream ?? {})) if (isRuntimeKey(k)) out[k] = v; + return { ...out, ...fingerprintPart(identityConfig) }; +} + +/** Firefox 主版本号:UA 里的 rv:NNN.0 / Firefox/NNN.0 */ +export function firefoxMajorOf(ua) { + const m = /(?:rv:|Firefox\/)(\d{2,3})\.\d/.exec(String(ua ?? "")); + return m ? m[1] : null; +} + +/** 与 camoufox-js fromBrowserforge 相同的版本号替换:三位数主版本 + ".0" */ +function bumpString(s, major) { + return s.replaceAll(/(? (p ? `${p.server}|${p.username ?? ""}|${p.password ?? ""}` : "direct"); + return k(a) === k(b); +} diff --git a/docker/browser-desktop/root/opt/camofox/plugins/umt-cookies/index.js b/docker/browser-desktop/root/opt/camofox/plugins/umt-cookies/index.js index 4757aab5..a5ef8f9d 100644 --- a/docker/browser-desktop/root/opt/camofox/plugins/umt-cookies/index.js +++ b/docker/browser-desktop/root/opt/camofox/plugins/umt-cookies/index.js @@ -7,14 +7,166 @@ * GET /umt/sessions/:userId/cookies?urls=a,b → 读作用于这些地址的 Cookie(活动上下文优先,否则读落盘文件) * GET /umt/instance → { instanceId, startedAt }:插件加载时生成,进程重启即变 * (BR-3:app 据此发现容器重启,把会话标为需要重新连接) - * Cookie 值只在请求/响应体里,不写日志(只记个数)。 + * GET /umt/identity → 固定浏览器身份的公开部分:UA、OS、版本、生成时间(BR-4) + * PUT /umt/sessions/:userId/proxy { proxy: {server, username?, password?} | null } + * → 该 profile 的出口:站点开代理 = 与 HTTP 同一代理;null = 直连 + * Cookie 值只在请求/响应体里,不写日志(只记个数);代理只记主机,不记账号口令。 + * + * BR-4 固定浏览器身份(docs/runbook/browser.md §11):`browser:launching` 钩子里,首次用 camoufox-js 生成一份 os=windows + * 的指纹 config 落盘到持久卷(缺省 /root/.camofox/umt-identity.json,与 profile 同一个卷),之后每次启动把上游随机生成的 + * 指纹部分整份换成它;Camoufox / Firefox 升级时只改 UA / 版本字段。字体按 Windows 走 Camoufox 自带的 fontconfig。 + * 出口(UMT_BROWSER_PROXY_MODE,缺省 site):Firefox 不用容器的 HTTP(S)_PROXY 环境变量(network.proxy.type = 0 直连), + * 按 profile 在 `session:creating` 里给 BrowserContext 设代理(Playwright 按上下文代理,Juggler 原生支持);env = 保持旧行为。 */ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import express from "express"; import { getUserPersistencePaths } from "../../lib/persistence.js"; +import { + decodeConfig, + encodeConfig, + fingerprintPart, + firefoxMajorOf, + IDENTITY_SCHEMA, + mergeLaunchConfig, + normalizeProxy, + publicIdentity, + sameProxy, + upgradeIdentity, + validIdentity, +} from "./identity.js"; const INSTANCE = { instanceId: randomUUID(), startedAt: new Date().toISOString() }; +const IDENTITY_OS = "windows"; +/** 身份里固定的 Firefox 首选项(WebGL2 开关与 WebGL 取样配套) */ +const IDENTITY_PREFS = ["webgl.enable-webgl2", "webgl.force-enabled"]; + +async function camoufoxApi() { + const [main, pkg] = await Promise.all([import("camoufox-js"), import("camoufox-js/dist/pkgman.js")]); + return { launchOptions: main.launchOptions, installedVerStr: pkg.installedVerStr }; +} + +/** 用 camoufox-js 按目标 OS 生成一份完整指纹(与上游启动时的生成流程相同,只是 os 固定) */ +async function sampleIdentity(executablePath) { + const { launchOptions } = await camoufoxApi(); + const out = await launchOptions({ + os: IDENTITY_OS, + headless: false, + i_know_what_im_doing: true, + exclude_addons: ["UBO"], + env: {}, + ...(executablePath ? { executable_path: executablePath } : {}), + }); + const config = fingerprintPart(decodeConfig(out.env) ?? {}); + const prefs = {}; + for (const k of IDENTITY_PREFS) if (out.firefoxUserPrefs?.[k] !== undefined) prefs[k] = out.firefoxUserPrefs[k]; + return { config, prefs }; +} + +function createIdentityStore({ file, log }) { + let cached = null; + let loading = null; + const save = async (id) => { + await fs.mkdir(path.dirname(file), { recursive: true }); + const tmp = `${file}.${process.pid}.tmp`; + await fs.writeFile(tmp, JSON.stringify(id, null, 2), { mode: 0o600 }); + await fs.rename(tmp, file); + }; + const load = async (executablePath) => { + const { installedVerStr } = await camoufoxApi(); + let full = null; + try { + full = installedVerStr(); + } catch {} + const major = full ? String(full).split(".")[0] : null; + let id = null; + try { + const v = JSON.parse(await fs.readFile(file, "utf8")); + if (validIdentity(v)) id = v; + else { + log("warn", "umt identity file invalid; regenerating", { file }); + await fs.rename(file, `${file}.bad-${Date.now()}`).catch(() => {}); + } + } catch (err) { + if (err.code !== "ENOENT") { + log("warn", "umt identity file unreadable; regenerating", { file, error: err.message }); + await fs.rename(file, `${file}.bad-${Date.now()}`).catch(() => {}); + } + } + const now = new Date().toISOString(); + if (!id) { + const s = await sampleIdentity(executablePath); + id = { + schema: IDENTITY_SCHEMA, + os: IDENTITY_OS, + config: s.config, + prefs: s.prefs, + firefoxVersion: firefoxMajorOf(s.config["navigator.userAgent"]) ?? major, + camoufoxVersion: full, + fingerprintId: createHash("sha256").update(JSON.stringify(s.config)).digest("hex").slice(0, 12), + revision: 1, + createdAt: now, + updatedAt: now, + }; + await save(id); + log("info", "umt identity created", { + os: id.os, + fingerprintId: id.fingerprintId, + firefoxVersion: id.firefoxVersion, + }); + } else if ((major && id.firefoxVersion !== major) || (full && id.camoufoxVersion !== full)) { + const sample = await sampleIdentity(executablePath).catch(() => null); + const r = upgradeIdentity(id, { firefoxVersion: major, sample: sample?.config, camoufoxVersion: full, now }); + if (r.changed) { + id = r.identity; + await save(id); + log("info", "umt identity upgraded (UA / version fields only)", { + fingerprintId: id.fingerprintId, + revision: id.revision, + firefoxVersion: id.firefoxVersion, + }); + } + } + return id; + }; + return { + get: (executablePath) => { + if (cached) return Promise.resolve(cached); + loading ??= load(executablePath) + .then((id) => { + cached = id; + return id; + }) + .finally(() => { + loading = null; + }); + return loading; + }, + }; +} + +/** + * Windows 字体:Camoufox 自带 fontconfig/windows(只含自带字体目录 + Windows 缺省字族映射),但目录写的是相对当前目录的 + * `fonts`;浏览器进程的当前目录是 camofox 的 /opt/camofox,不是安装目录。复制一份,把字体目录改成绝对路径。 + */ +async function windowsFontconfig(installDir, log) { + const src = path.join(installDir, "fontconfig", IDENTITY_OS, "fonts.conf"); + try { + const text = await fs.readFile(src, "utf8"); + const dir = path.join(os.tmpdir(), "umt-fontconfig-windows"); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile( + path.join(dir, "fonts.conf"), + text.replace(/fonts<\/dir>/, `${path.join(installDir, "fonts")}`), + ); + return dir; + } catch (err) { + log("warn", "umt identity: windows fontconfig not found; fonts follow upstream", { error: err.message }); + return null; + } +} const FIELDS = ["name", "value", "domain", "path", "expires", "httpOnly", "secure", "sameSite"]; @@ -46,6 +198,110 @@ function matches(c, urls, nowSec) { export async function register(app, ctx) { const { log } = ctx; const profileDir = process.env.CAMOFOX_PROFILE_DIR || ctx.config.profileDir; + // 身份与代理表放在 profile 目录的上一级(同一个持久卷 /root/.camofox) + const stateDir = process.env.UMT_STATE_DIR || path.dirname(profileDir || "/root/.camofox/profiles"); + const installDir = process.env.CAMOUFOX_INSTALL_DIR || "/opt/camoufox"; + const identityMode = (process.env.UMT_BROWSER_IDENTITY || "fixed").toLowerCase(); + const proxyMode = (process.env.UMT_BROWSER_PROXY_MODE || "site").toLowerCase(); + const identity = createIdentityStore({ + file: process.env.UMT_IDENTITY_FILE || path.join(stateDir, "umt-identity.json"), + log, + }); + + // ── BR-4 固定浏览器身份 ── + ctx.events.on("browser:launching", async ({ options }) => { + if (identityMode === "fixed") { + try { + const id = await identity.get(options.executablePath); + const merged = mergeLaunchConfig(decodeConfig(options.env) ?? {}, id.config); + options.env = encodeConfig({ ...(options.env ?? {}) }, merged); + const fc = await windowsFontconfig(installDir, log); + if (fc) options.env.FONTCONFIG_PATH = fc; + options.firefoxUserPrefs = { ...(options.firefoxUserPrefs ?? {}), ...(id.prefs ?? {}) }; + log("info", "umt identity applied", { os: id.os, fingerprintId: id.fingerprintId, revision: id.revision }); + } catch (err) { + // 身份不可用时照常启动(上游随机指纹),app 读 /umt/identity 失败会退回内置 UA + log("error", "umt identity failed; launching with upstream fingerprint", { error: err.message }); + } + } + // 出口按站点:不走容器环境变量里的代理;上游自己的代理池(CAMOFOX 代理配置)优先 + if (proxyMode === "site" && !options.proxy) { + options.firefoxUserPrefs = { ...(options.firefoxUserPrefs ?? {}), "network.proxy.type": 0 }; + } + }); + + app.get("/umt/identity", ctx.auth(), async (_req, res) => { + if (identityMode !== "fixed") return res.status(404).json({ error: "fixed identity disabled" }); + try { + res.json(publicIdentity(await identity.get())); + } catch (err) { + log("error", "umt identity read failed", { error: err.message }); + res.status(500).json({ error: ctx.safeError(err) }); + } + }); + + // ── BR-4 出口按站点 ── + const proxyFile = path.join(stateDir, "umt-proxies.json"); + /** userId → 代理(null = 直连);持久化,camofox 重启后 app 重开会话前也会重新下发 */ + const proxies = new Map(); + /** userId → 活动上下文创建时实际用的代理 */ + const applied = new Map(); + try { + const saved = JSON.parse(await fs.readFile(proxyFile, "utf8")); + for (const [k, v] of Object.entries(saved ?? {})) { + const p = normalizeProxy(v); + if (p !== undefined) proxies.set(k, p); + } + } catch {} + const saveProxies = async () => { + await fs.mkdir(stateDir, { recursive: true }); + const tmp = `${proxyFile}.${process.pid}.tmp`; + await fs.writeFile(tmp, JSON.stringify(Object.fromEntries(proxies)), { mode: 0o600 }); + await fs.rename(tmp, proxyFile); + }; + const hostOf = (p) => (p ? p.server.replace(/^[a-z0-9]+:\/\//, "") : null); + + ctx.events.on("session:creating", ({ userId, contextOptions }) => { + if (proxyMode !== "site") return; + if (contextOptions.proxy) { + applied.set(userId, contextOptions.proxy); + return; + } + const p = proxies.get(userId) ?? null; + if (p) contextOptions.proxy = { ...p }; + applied.set(userId, p); + log("info", "umt session egress", { userId, egress: p ? "proxy" : "direct", proxyHost: hostOf(p) }); + }); + ctx.events.on("session:destroyed", ({ userId }) => applied.delete(userId)); + + app.put("/umt/sessions/:userId/proxy", ctx.auth(), express.json({ limit: "8kb" }), async (req, res) => { + try { + const userId = ctx.normalizeUserId(req.params.userId); + const p = normalizeProxy(req.body?.proxy === undefined ? undefined : req.body.proxy); + if (p === undefined) return res.status(400).json({ error: "proxy must be {server} or null" }); + const before = proxies.has(userId) ? proxies.get(userId) : undefined; + proxies.set(userId, p); + if (before === undefined || !sameProxy(before, p)) await saveProxies(); + // 活动上下文的出口与新设置不同:关掉它(持久化插件先落盘登录状态),下次请求按新出口重建 + let restarted = false; + const live = ctx.sessions.get(userId); + if (proxyMode === "site" && live && applied.has(userId) && !sameProxy(applied.get(userId), p)) { + await ctx.destroySession(userId, { reason: "umt_egress_changed" }); + restarted = true; + } + log("info", "umt egress set", { + reqId: req.reqId, + userId, + egress: p ? "proxy" : "direct", + proxyHost: hostOf(p), + restarted, + }); + res.json({ ok: true, userId, egress: p ? "proxy" : "direct", mode: proxyMode, restarted }); + } catch (err) { + log("error", "umt egress set failed", { reqId: req.reqId, error: err.message }); + res.status(500).json({ error: ctx.safeError(err) }); + } + }); app.post("/umt/sessions/:userId/cookies", ctx.auth(), express.json({ limit: "512kb" }), async (req, res) => { try { @@ -108,5 +364,8 @@ export async function register(app, ctx) { app.get("/umt/instance", ctx.auth(), (_req, res) => res.json(INSTANCE)); - log("info", "umt-cookies plugin: registered GET/POST /umt/sessions/:userId/cookies, GET /umt/instance"); + log("info", "umt-cookies plugin: registered cookies / instance / identity / egress endpoints", { + identityMode, + proxyMode, + }); } diff --git a/packages/adapters/src/browser/camofox/client.ts b/packages/adapters/src/browser/camofox/client.ts index 2c9f45d3..5229f147 100644 --- a/packages/adapters/src/browser/camofox/client.ts +++ b/packages/adapters/src/browser/camofox/client.ts @@ -19,7 +19,9 @@ import type { BrowserCapabilities, BrowserControlLease, BrowserCookie, + BrowserEgressProxy, BrowserHealth, + BrowserIdentity, BrowserSession, BrowserSnapshot, BrowserTab, @@ -175,7 +177,7 @@ export class CamofoxBrowserProvider implements BrowserProvider { } private async request( - method: "GET" | "POST" | "DELETE", + method: "GET" | "POST" | "PUT" | "DELETE", path: string, opts: { query?: Record; body?: Json; timeoutMs?: number; auth?: boolean } = {}, ): Promise { @@ -386,6 +388,54 @@ export class CamofoxBrowserProvider implements BrowserProvider { return typeof v === "string" || typeof v === "number" ? String(v) : null; } + /** + * 固定浏览器身份(BR-4):umt 插件 `GET /umt/identity`(UA、OS、版本、生成时间,不含指纹细节); + * 插件不在或身份关闭(404)返回 null。 + */ + async identity(): Promise { + let body: Json; + try { + body = await this.json(await this.request("GET", "/umt/identity", { timeoutMs: 10_000 }), "/umt/identity"); + } catch (e) { + if (e instanceof BrowserError && e.httpStatus === 404) return null; + throw e; + } + const userAgent = str(body.userAgent); + if (!userAgent) return null; + return { + userAgent, + os: str(body.os) ?? null, + platform: str(body.platform) ?? null, + firefoxVersion: str(body.firefoxVersion) ?? null, + camoufoxVersion: str(body.camoufoxVersion) ?? null, + fingerprintId: str(body.fingerprintId) ?? null, + revision: int(body.revision) ?? 1, + createdAt: str(body.createdAt) ?? null, + updatedAt: str(body.updatedAt) ?? null, + }; + } + + /** + * 该 profile 的出口(BR-4 出口 IP 按站点统一):umt 插件 `PUT /umt/sessions/{userId}/proxy`。 + * proxy = null 直连;活动上下文出口不同时插件会先关掉它(落盘登录状态)。插件不在报 not_implemented。 + */ + async setEgress(profileKey: string, proxy: BrowserEgressProxy | null): Promise<{ restarted: boolean }> { + try { + const body = await this.json( + await this.request("PUT", `/umt/sessions/${encodeURIComponent(profileKey)}/proxy`, { + body: { proxy } as unknown as Json, + timeoutMs: 15_000, + }), + "/umt/sessions/{userId}/proxy", + ); + return { restarted: body.restarted === true }; + } catch (e) { + if (e instanceof BrowserError && e.httpStatus === 404) + throw new BrowserError("not_implemented", "浏览器服务不支持按站点设出口(镜像未带新版 umt-cookies 插件)"); + throw e; + } + } + /** * 重建会话的上游上下文(BR-3):上游重启 / 回收后旧标签页都不在了。先(可选)注入 Cookie,再用同一 userId / sessionKey * 开新标签页并导航到 url;旧的打开中标签页标 lost,旧元素引用作废。不检查控制者:接管中的会话重连后仍归原控制者。 diff --git a/packages/adapters/src/browser/cookies.ts b/packages/adapters/src/browser/cookies.ts index c85bd030..5b7d6ed4 100644 --- a/packages/adapters/src/browser/cookies.ts +++ b/packages/adapters/src/browser/cookies.ts @@ -1,4 +1,4 @@ -import type { BrowserCookie } from "@umt/contracts"; +import { BROWSER_ONLY_COOKIES, type BrowserCookie } from "@umt/contracts"; // BR-2 凭据统一:站点账号里存的是 HTTP `Cookie:` 请求头串("a=1; b=2"),浏览器 profile 里是带域名/路径/期限的 Cookie 对象。 // 这里只做两者互转与比较;值是凭据,不打印、不进错误信息。 @@ -34,6 +34,28 @@ export function mergeCookieHeaders(...headers: (string | null | undefined)[]): s return jar.size ? formatCookieHeader(jar) : null; } +/** 仅浏览器用的 Cookie(Cloudflare 人机验证):名字精确匹配,或 cf_chl_ 前缀(挑战过程中的临时 Cookie) */ +export function isBrowserOnlyCookie(name: string): boolean { + return (BROWSER_ONLY_COOKIES as readonly string[]).includes(name) || name.startsWith("cf_chl_"); +} + +/** + * 浏览器 → HTTP 方向:剔除仅浏览器用的 Cookie(BR-4)。返回剩下的头串(没有剩余为 null)与被剔除的名字(不含值)。 + */ +export function splitBrowserOnlyCookies(header: string | null | undefined): { + header: string | null; + browserOnly: string[]; +} { + const jar = parseCookieHeader(header); + const browserOnly: string[] = []; + for (const name of [...jar.keys()]) + if (isBrowserOnlyCookie(name)) { + jar.delete(name); + browserOnly.push(name); + } + return { header: jar.size ? formatCookieHeader(jar) : null, browserOnly: browserOnly.sort() }; +} + /** 两个 Cookie 头串是否等价(忽略顺序与空白) */ export function sameCookieHeader(a: string | null | undefined, b: string | null | undefined): boolean { const x = parseCookieHeader(a); diff --git a/packages/adapters/src/browser/provider.ts b/packages/adapters/src/browser/provider.ts index 74b65921..b40f5b17 100644 --- a/packages/adapters/src/browser/provider.ts +++ b/packages/adapters/src/browser/provider.ts @@ -2,7 +2,9 @@ import type { BrowserCapabilities, BrowserControlLease, BrowserCookie, + BrowserEgressProxy, BrowserHealth, + BrowserIdentity, BrowserLeaseHolderKind, BrowserProviderKind, BrowserSession, @@ -90,6 +92,15 @@ export interface BrowserProvider { * 原有打开中的标签页一律标 lost。不检查控制者(接管中的会话也能重连,控制权不变)。 */ reopenSession?(sessionId: string, req: ReopenSessionRequest): Promise; + + // ── BR-4 浏览器 UA 与指纹统一、出口按站点 ── + /** 固定浏览器身份的公开部分(UA、OS、版本);上游没有时返回 null */ + identity?(): Promise; + /** + * 该 profile 的出口:proxy = 站点开代理时与 HTTP 同一代理,null = 直连。上游没有接口时报 BrowserError("not_implemented")。 + * restarted = 活动上下文出口不同、已被关闭(下次操作按新出口重建)。 + */ + setEgress?(profileKey: string, proxy: BrowserEgressProxy | null): Promise<{ restarted: boolean }>; } export interface ReopenSessionRequest { diff --git a/packages/adapters/src/browser/umt-plugin.test.ts b/packages/adapters/src/browser/umt-plugin.test.ts new file mode 100644 index 00000000..d63d88af --- /dev/null +++ b/packages/adapters/src/browser/umt-plugin.test.ts @@ -0,0 +1,178 @@ +import { createMockCamofox, MOCK_CAMOFOX_IDENTITY, type MockCamofox } from "@umt/testkit"; +import { afterEach, describe, expect, it } from "vitest"; +import { createCamofoxBrowserProvider, splitBrowserOnlyCookies, staticBrowserSecrets } from "./index.ts"; + +// BR-4:umt-cookies 插件的固定身份纯函数(docker/browser-desktop/.../umt-cookies/identity.js)、 +// 适配器的 identity() / setEgress()、仅浏览器 Cookie 的剔除。 + +// 插件是容器里的纯 JS(不进 tsc 模块图):按路径动态导入 +const PLUGIN = new URL( + "../../../../docker/browser-desktop/root/opt/camofox/plugins/umt-cookies/identity.js", + import.meta.url, +).href; +// biome-ignore lint/suspicious/noExplicitAny: 插件是无类型 JS +const plugin = async (): Promise => import(PLUGIN); + +const UA152 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0"; + +describe("umt-cookies 插件:固定身份", () => { + it("config ⇄ CAMOU_CONFIG_n 分片往返;旧分片先清掉", async () => { + const p = await plugin(); + const big = { "navigator.userAgent": UA152, pad: "x".repeat(70_000) }; + const env: Record = { CAMOU_CONFIG_1: "stale", CAMOU_CONFIG_9: "stale", PATH: "/bin" }; + p.encodeConfig(env, big); + expect(Object.keys(env).filter((k) => k.startsWith("CAMOU_CONFIG_"))).toEqual([ + "CAMOU_CONFIG_1", + "CAMOU_CONFIG_2", + "CAMOU_CONFIG_3", + ]); + expect(env.PATH).toBe("/bin"); + expect(p.decodeConfig(env)).toEqual(big); + expect(p.decodeConfig({})).toBeNull(); + }); + + it("启动 config:运行期字段取上游,指纹字段一律取身份", async () => { + const p = await plugin(); + const upstream = { + "navigator.userAgent": "Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0", + "screen.width": 1920, + humanize: true, + "humanize:maxTime": 2, + "locale:language": "zh", + "geolocation:latitude": 1, + timezone: "Asia/Shanghai", + addons: ["/a"], + "webrtc:ipv4": "1.2.3.4", + }; + const identity = { "navigator.userAgent": UA152, "navigator.platform": "Win32", "screen.width": 2560 }; + expect(p.mergeLaunchConfig(upstream, identity)).toEqual({ + humanize: true, + "humanize:maxTime": 2, + "locale:language": "zh", + "geolocation:latitude": 1, + timezone: "Asia/Shanghai", + addons: ["/a"], + "webrtc:ipv4": "1.2.3.4", + "navigator.userAgent": UA152, + "navigator.platform": "Win32", + "screen.width": 2560, + }); + expect(p.fingerprintPart(upstream)).toEqual({ + "navigator.userAgent": upstream["navigator.userAgent"], + "screen.width": 1920, + }); + }); + + it("Firefox 升级:只改 navigator / headers 里的版本号,其余保留;新字段从样本补齐;版本相同不改", async () => { + const p = await plugin(); + const id = { + schema: 1, + os: "windows", + firefoxVersion: "140", + camoufoxVersion: "140.0-a", + fingerprintId: "fp1", + revision: 1, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + config: { + "navigator.userAgent": UA152.replaceAll("152.0", "140.0"), + "navigator.appVersion": "5.0 (Windows)", + "headers.User-Agent": UA152.replaceAll("152.0", "140.0"), + "screen.width": 2560, + "webGl:renderer": "ANGLE 140.0 build", + "canvas:seed": 42, + }, + }; + const sample = { "navigator.userAgent": "other", "audio:seed": 7, "screen.width": 1 }; + const r = p.upgradeIdentity(id, { firefoxVersion: "152", sample, camoufoxVersion: "152.0.4", now: "N" }); + expect(r.changed).toBe(true); + expect(r.identity.config).toEqual({ + "navigator.userAgent": UA152, + "navigator.appVersion": "5.0 (Windows)", + "headers.User-Agent": UA152, + "screen.width": 2560, + "webGl:renderer": "ANGLE 140.0 build", + "canvas:seed": 42, + "audio:seed": 7, + }); + expect(r.identity).toMatchObject({ firefoxVersion: "152", revision: 2, fingerprintId: "fp1", updatedAt: "N" }); + expect(p.publicIdentity(r.identity)).toEqual({ + userAgent: UA152, + os: "windows", + platform: null, + firefoxVersion: "152", + camoufoxVersion: "152.0.4", + fingerprintId: "fp1", + revision: 2, + schema: 1, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "N", + }); + const same = p.upgradeIdentity(r.identity, { firefoxVersion: "152", sample, camoufoxVersion: "152.0.4", now: "M" }); + expect(same.changed).toBe(false); + expect(p.validIdentity(r.identity)).toBe(true); + expect(p.validIdentity({ schema: 1, config: {} })).toBe(false); + expect(p.firefoxMajorOf(UA152)).toBe("152"); + }); + + it("出口代理规范化:口令从 URL 拆出;非法协议拒绝;null = 直连", async () => { + const p = await plugin(); + expect(p.normalizeProxy(null)).toBeNull(); + expect(p.normalizeProxy({ server: "http://u%40x:p%3Aw@172.17.0.1:7891/" })).toEqual({ + server: "http://172.17.0.1:7891", + username: "u@x", + password: "p:w", + }); + expect(p.normalizeProxy({ server: "socks5://h:1080", username: "a", password: "b" })).toEqual({ + server: "socks5://h:1080", + username: "a", + password: "b", + }); + expect(p.normalizeProxy({ server: "ftp://h" })).toBeUndefined(); + expect(p.normalizeProxy({})).toBeUndefined(); + expect(p.sameProxy(null, null)).toBe(true); + expect(p.sameProxy({ server: "http://a:1" }, null)).toBe(false); + }); +}); + +describe("仅浏览器 Cookie", () => { + it("剔除 cf_clearance / __cf_bm / cf_chl_*,只返回名字", () => { + expect(splitBrowserOnlyCookies("a=1; cf_clearance=S; __cf_bm=T; cf_chl_rc_m=1")).toEqual({ + header: "a=1", + browserOnly: ["__cf_bm", "cf_chl_rc_m", "cf_clearance"], + }); + expect(splitBrowserOnlyCookies("cf_clearance=S")).toEqual({ header: null, browserOnly: ["cf_clearance"] }); + expect(splitBrowserOnlyCookies(null)).toEqual({ header: null, browserOnly: [] }); + }); +}); + +describe("camofox 适配器:身份与出口", () => { + let mock: MockCamofox | null = null; + afterEach(async () => { + await mock?.close(); + mock = null; + }); + const provider = (m: MockCamofox) => + createCamofoxBrowserProvider({ + providerId: "11111111-1111-4111-8111-111111111111", + baseUrl: m.url, + secrets: staticBrowserSecrets({ "provider:11111111-1111-4111-8111-111111111111": "k" }), + }); + + it("identity() 读插件身份;setEgress() 下发代理 / 直连", async () => { + mock = await createMockCamofox({ accessKey: "k" }); + const p = provider(mock); + expect(await p.identity!()).toMatchObject({ userAgent: MOCK_CAMOFOX_IDENTITY.userAgent, os: "windows" }); + expect(await p.setEgress!("site-a", { server: "http://127.0.0.1:7891" })).toEqual({ restarted: false }); + expect(await p.setEgress!("site-b", null)).toEqual({ restarted: false }); + expect(mock.egress.get("site-a")).toEqual({ server: "http://127.0.0.1:7891" }); + expect(mock.egress.get("site-b")).toBeNull(); + }); + + it("旧插件(没有接口):identity() 为 null,setEgress() 报 not_implemented", async () => { + mock = await createMockCamofox({ accessKey: "k", cookieApi: "none" }); + const p = provider(mock); + expect(await p.identity!()).toBeNull(); + await expect(p.setEgress!("x", null)).rejects.toMatchObject({ kind: "not_implemented" }); + }); +}); diff --git a/packages/contracts/src/browser.ts b/packages/contracts/src/browser.ts index ff56542d..58c78390 100644 --- a/packages/contracts/src/browser.ts +++ b/packages/contracts/src/browser.ts @@ -304,6 +304,37 @@ export const BrowserCookie = z.object({ }); export type BrowserCookie = z.infer; +// ── BR-4 浏览器 UA 与指纹统一 ── + +/** 固定浏览器身份的公开部分(umt 插件 GET /umt/identity):不含指纹细节 */ +export const BrowserIdentity = z.object({ + userAgent: z.string().min(1), + os: z.string().nullable(), + platform: z.string().nullable(), + firefoxVersion: z.string().nullable(), + camoufoxVersion: z.string().nullable(), + /** 首次生成时的指纹摘要(升级只改 UA / 版本,摘要不变) */ + fingerprintId: z.string().nullable(), + /** 每次升级改写 UA / 版本字段 +1 */ + revision: z.number().int(), + createdAt: z.string().nullable(), + updatedAt: z.string().nullable(), +}); +export type BrowserIdentity = z.infer; + +/** 浏览器会话出口代理(Playwright 形状);null = 直连 */ +export interface BrowserEgressProxy { + server: string; + username?: string; + password?: string; +} + +/** + * Cloudflare 的人机验证 Cookie:与取得它的浏览器(UA、IP、TLS 指纹)绑定,HTTP 端(Deno)TLS 指纹不同,拿去用多半无效还可能 + * 触发风控。浏览器回写账号时剔除,只留在浏览器 profile 里("仅浏览器")。 + */ +export const BROWSER_ONLY_COOKIES = ["cf_clearance", "__cf_bm"] as const; + /** 站点账号登录态(Cookie)来源:手填 / 账密自动登录 / 浏览器回写 */ export const SiteCookieSource = z.enum(["manual", "login", "browser"]); export type SiteCookieSource = z.infer; diff --git a/packages/testkit/src/browser/mock-camofox.ts b/packages/testkit/src/browser/mock-camofox.ts index 6096c57f..ccc99619 100644 --- a/packages/testkit/src/browser/mock-camofox.ts +++ b/packages/testkit/src/browser/mock-camofox.ts @@ -126,6 +126,22 @@ export type MockCamofox = MockHttp & { restart(opts?: { loseProfiles?: boolean }): void; /** 当前实例标识 */ readonly instanceId: string; + /** 各 profile 的出口(`PUT /umt/sessions/:userId/proxy`,BR-4):null = 直连 */ + egress: Map; +}; + +/** mock 的固定浏览器身份(`GET /umt/identity`,BR-4) */ +export const MOCK_CAMOFOX_IDENTITY = { + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0", + os: "windows", + platform: "Win32", + firefoxVersion: "152", + camoufoxVersion: "152.0.4-mock", + fingerprintId: "mockfp000001", + revision: 1, + schema: 1, + createdAt: "2026-09-30T00:00:00.000Z", + updatedAt: "2026-09-30T00:00:00.000Z", }; /** 1x1 透明 PNG */ @@ -331,6 +347,7 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise< const actions: MockCamofoxAction[] = []; const loggedIn = new Set(); const cookieJars = new Map(); + const egress = new Map(); const cookieApi = opts.cookieApi ?? "plugin"; let instanceId = `inst-${crypto.randomUUID()}`; /** 重启前存在过的 tabId:之后访问返回 410 */ @@ -795,6 +812,21 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise< }, ], [/^\/umt\/instance$/, () => (cookieApi === "plugin" ? { json: { instanceId } } : err(404, "no route"))], + [ + /^\/umt\/identity$/, + () => (cookieApi === "plugin" ? { json: { ...MOCK_CAMOFOX_IDENTITY } } : err(404, "no route")), + ], + [ + /^\/umt\/sessions\/([^/]+)\/proxy$/, + (m, _q, req, b) => { + if (cookieApi !== "plugin" || req.method !== "PUT") return err(404, "no route"); + const userId = decodeURIComponent(m[1]!); + const p = b.proxy as { server?: string } | null | undefined; + if (p !== null && (!p || typeof p.server !== "string")) return err(400, "proxy must be {server} or null"); + egress.set(userId, p === null ? null : { ...(p as { server: string }) }); + return { json: { ok: true, userId, egress: p ? "proxy" : "direct", mode: "site", restarted: false } }; + }, + ], [ /^\/umt\/sessions\/([^/]+)\/cookies$/, (m, q, req, b) => { @@ -858,6 +890,7 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise< actions, loggedIn, cookieJars, + egress, expireLogin(userId?: string) { if (userId) loggedIn.delete(userId); else loggedIn.clear();