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 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-30 03:17:06 +08:00
1 parent 6663afce7e
commit ebd07c9230
8 files changed
+746 -5

No files matched your search

@@ -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<string, string | undefined>; body?: Json; timeoutMs?: number; auth?: boolean } = {},
): Promise<Response> {
@@ -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<BrowserIdentity | null> {
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,旧元素引用作废。不检查控制者:接管中的会话重连后仍归原控制者。
+23 -1
View File
@@ -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);
+11
View File
@@ -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<ReopenSessionResult>;
// ── BR-4 浏览器 UA 与指纹统一、出口按站点 ──
/** 固定浏览器身份的公开部分(UA、OS、版本);上游没有时返回 null */
identity?(): Promise<BrowserIdentity | null>;
/**
* 该 profile 的出口:proxy = 站点开代理时与 HTTP 同一代理,null = 直连。上游没有接口时报 BrowserError("not_implemented")。
* restarted = 活动上下文出口不同、已被关闭(下次操作按新出口重建)。
*/
setEgress?(profileKey: string, proxy: BrowserEgressProxy | null): Promise<{ restarted: boolean }>;
}
export interface ReopenSessionRequest {
@@ -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<any> => 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<string, string> = { 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%[email protected]: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" });
});
});
+31
View File
@@ -304,6 +304,37 @@ export const BrowserCookie = z.object({
});
export type BrowserCookie = z.infer<typeof BrowserCookie>;
// ── 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<typeof BrowserIdentity>;
/** 浏览器会话出口代理(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<typeof SiteCookieSource>;
@@ -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<string, { server: string; username?: string; password?: string } | null>;
};
/** 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<string>();
const cookieJars = new Map<string, MockCamofoxCookie[]>();
const egress = new Map<string, { server: string; username?: string; password?: string } | null>();
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();