- 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]>
119 lines
4.8 KiB
TypeScript
119 lines
4.8 KiB
TypeScript
import { BROWSER_ONLY_COOKIES, type BrowserCookie } from "@umt/contracts";
|
|
|
|
// BR-2 凭据统一:站点账号里存的是 HTTP `Cookie:` 请求头串("a=1; b=2"),浏览器 profile 里是带域名/路径/期限的 Cookie 对象。
|
|
// 这里只做两者互转与比较;值是凭据,不打印、不进错误信息。
|
|
|
|
/** 注入时 Cookie 头串里没有期限信息:给 30 天,站点响应的 Set-Cookie 会按站点自己的期限覆盖 */
|
|
export const INJECTED_COOKIE_TTL_SEC = 30 * 24 * 3600;
|
|
|
|
/** "a=1; b=2" → [[a, 1], [b, 2]](同名后者覆盖;去掉空段与首尾空白) */
|
|
export function parseCookieHeader(header: string | null | undefined): Map<string, string> {
|
|
const out = new Map<string, string>();
|
|
for (const part of (header ?? "").split(";")) {
|
|
const i = part.indexOf("=");
|
|
if (i <= 0) continue;
|
|
const name = part.slice(0, i).trim();
|
|
if (!name || /[\s,;]/.test(name)) continue;
|
|
out.set(name, part.slice(i + 1).trim());
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Map → "a=1; b=2"(按名字排序,比较稳定) */
|
|
export function formatCookieHeader(jar: Map<string, string>): string {
|
|
return [...jar.entries()]
|
|
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
.map(([k, v]) => `${k}=${v}`)
|
|
.join("; ");
|
|
}
|
|
|
|
/** 多个 Cookie 头串合并,靠后的优先(用户填的 + 自动登录得到的会话 Cookie → 会话 Cookie 覆盖同名项) */
|
|
export function mergeCookieHeaders(...headers: (string | null | undefined)[]): string | null {
|
|
const jar = new Map<string, string>();
|
|
for (const h of headers) for (const [k, v] of parseCookieHeader(h)) jar.set(k, v);
|
|
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);
|
|
const y = parseCookieHeader(b);
|
|
if (x.size !== y.size) return false;
|
|
for (const [k, v] of x) if (y.get(k) !== v) return false;
|
|
return true;
|
|
}
|
|
|
|
/** 站点地址 → 注入用 Cookie 对象:域名 = 站点主机(host-only),路径 /,https 站点标 secure */
|
|
export function cookiesFromHeader(
|
|
header: string,
|
|
siteUrl: string,
|
|
nowSec = Math.floor(Date.now() / 1000),
|
|
): BrowserCookie[] {
|
|
const u = new URL(siteUrl);
|
|
const secure = u.protocol === "https:";
|
|
return [...parseCookieHeader(header)].map(([name, value]) => ({
|
|
name,
|
|
value,
|
|
domain: u.hostname,
|
|
path: "/",
|
|
expires: nowSec + INJECTED_COOKIE_TTL_SEC,
|
|
httpOnly: false,
|
|
secure,
|
|
sameSite: "Lax" as const,
|
|
}));
|
|
}
|
|
|
|
/** Cookie 的 domain 是否作用于该主机(host-only 精确匹配;.根域 匹配子域) */
|
|
export function cookieDomainMatches(cookieDomain: string, host: string): boolean {
|
|
const d = cookieDomain.toLowerCase().replace(/^\./, "");
|
|
const h = host.toLowerCase();
|
|
return h === d || h.endsWith(`.${d}`);
|
|
}
|
|
|
|
/**
|
|
* 浏览器读回的 Cookie → 站点 Cookie 头串。只取作用于站点主机、路径为根(或站点路径前缀)且未过期的,
|
|
* 其它域名(统计、CDN、第三方)的 Cookie 一律丢弃(回写只对该站域名)。
|
|
*/
|
|
export function headerFromCookies(
|
|
cookies: readonly BrowserCookie[],
|
|
siteUrl: string,
|
|
nowSec = Math.floor(Date.now() / 1000),
|
|
): string | null {
|
|
const u = new URL(siteUrl);
|
|
const jar = new Map<string, { v: string; specific: number }>();
|
|
for (const c of cookies) {
|
|
if (!cookieDomainMatches(c.domain, u.hostname)) continue;
|
|
if (c.expires !== undefined && c.expires > 0 && c.expires <= nowSec) continue;
|
|
const path = c.path || "/";
|
|
if (!u.pathname.startsWith(path)) continue;
|
|
// 同名多条:更具体的域名 / 路径优先(与浏览器发送顺序一致)
|
|
const specific = c.domain.replace(/^\./, "").length * 1000 + path.length;
|
|
const prev = jar.get(c.name);
|
|
if (!prev || specific >= prev.specific) jar.set(c.name, { v: c.value, specific });
|
|
}
|
|
if (!jar.size) return null;
|
|
return formatCookieHeader(new Map([...jar].map(([k, x]) => [k, x.v])));
|
|
}
|