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

@@ -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(/(?<!\d)(1[0-9]{2})(\.0)(?!\d)/g, `${major}$2`);
}
/**
* 升级:Firefox 主版本变化时只改 UA / 版本相关字段(navigator.* 与 headers.* 里的字符串),其余全部保留;
* 新版本 Camoufox 多出来的指纹字段从 sample(同 OS 新生成的一份)补齐,已有字段不动。
* 返回 { identity, changed }。
*/
export function upgradeIdentity(identity, { firefoxVersion, sample, camoufoxVersion, now }) {
const cfg = { ...identity.config };
let changed = false;
if (firefoxVersion && identity.firefoxVersion !== firefoxVersion) {
for (const [k, v] of Object.entries(cfg)) {
if (typeof v !== "string" || !/^(navigator\.|headers\.)/.test(k)) continue;
const nv = bumpString(v, firefoxVersion);
if (nv !== v) cfg[k] = nv;
}
changed = true;
}
for (const [k, v] of Object.entries(fingerprintPart(sample ?? {}))) {
if (!(k in cfg)) {
cfg[k] = v;
changed = true;
}
}
if (camoufoxVersion && identity.camoufoxVersion !== camoufoxVersion) changed = true;
if (!changed) return { identity, changed: false };
return {
changed: true,
identity: {
...identity,
config: cfg,
firefoxVersion: firefoxVersion ?? identity.firefoxVersion,
camoufoxVersion: camoufoxVersion ?? identity.camoufoxVersion,
revision: (identity.revision ?? 1) + 1,
updatedAt: now,
},
};
}
/** GET /umt/identity 的公开视图:只含 UA、OS、版本与时间,不含指纹细节 */
export function publicIdentity(identity) {
const cfg = identity?.config ?? {};
return {
userAgent: cfg["navigator.userAgent"] ?? null,
os: identity?.os ?? null,
platform: cfg["navigator.platform"] ?? null,
firefoxVersion: identity?.firefoxVersion ?? null,
camoufoxVersion: identity?.camoufoxVersion ?? null,
fingerprintId: identity?.fingerprintId ?? null,
revision: identity?.revision ?? 1,
schema: identity?.schema ?? IDENTITY_SCHEMA,
createdAt: identity?.createdAt ?? null,
updatedAt: identity?.updatedAt ?? null,
};
}
/** 身份文件结构校验(损坏时重新生成) */
export function validIdentity(v) {
return (
!!v &&
typeof v === "object" &&
v.schema === IDENTITY_SCHEMA &&
v.config &&
typeof v.config === "object" &&
typeof v.config["navigator.userAgent"] === "string"
);
}
/**
* 站点代理(出口 IP 按站点统一):app 传来的 { server, username?, password? } 规范化;null = 直连。
* 只接受 http / https / socks4 / socks5,返回 Playwright 的 proxy 形状;非法返回 undefined。
*/
export function normalizeProxy(p) {
if (p === null) return null;
if (!p || typeof p !== "object" || typeof p.server !== "string") return undefined;
let u;
try {
u = new URL(p.server);
} catch {
return undefined;
}
if (!["http:", "https:", "socks4:", "socks5:"].includes(u.protocol) || !u.hostname) return undefined;
const out = { server: `${u.protocol}//${u.host}` };
const username = typeof p.username === "string" && p.username ? p.username : decodeURIComponent(u.username || "");
const password = typeof p.password === "string" && p.password ? p.password : decodeURIComponent(u.password || "");
if (username) out.username = username;
if (password) out.password = password;
return out;
}
export function sameProxy(a, b) {
const k = (p) => (p ? `${p.server}|${p.username ?? ""}|${p.password ?? ""}` : "direct");
return k(a) === k(b);
}
@@ -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(/<dir prefix="cwd">fonts<\/dir>/, `<dir>${path.join(installDir, "fonts")}</dir>`),
);
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,
});
}