feat: fully implement happy flow for persistant fingerprint generation

This commit is contained in:
zhom
2025-08-06 04:33:01 +04:00
parent ff35717cb5
commit b5b08a0196
20 changed files with 2531 additions and 1545 deletions
+293
View File
@@ -1,5 +1,6 @@
import { spawn } from "node:child_process";
import path from "node:path";
import { launchOptions } from "camoufox-js";
import type { LaunchOptions } from "camoufox-js/dist/utils.js";
import {
type CamoufoxConfig,
@@ -10,6 +11,194 @@ import {
saveCamoufoxConfig,
} from "./camoufox-storage.js";
/**
* Convert fingerprint-generator format to camoufox fingerprint format (reverse of convertCamoufoxToFingerprintGenerator)
* @param fingerprintObj The fingerprint-generator object
* @returns camoufox fingerprint object
*/
export function convertFingerprintGeneratorToCamoufox(
fingerprintObj: Record<string, any>,
): Record<string, any> {
const camoufoxData: Record<string, any> = {};
// Reverse mappings from fingerprint-generator structure to camoufox keys
const reverseMappings: Record<string, string> = {
// Navigator properties
"navigator.userAgent": "navigator.userAgent",
"navigator.platform": "navigator.platform",
"navigator.hardwareConcurrency": "navigator.hardwareConcurrency",
"navigator.maxTouchPoints": "navigator.maxTouchPoints",
"navigator.doNotTrack": "navigator.doNotTrack",
"navigator.appCodeName": "navigator.appCodeName",
"navigator.appName": "navigator.appName",
"navigator.appVersion": "navigator.appVersion",
"navigator.oscpu": "navigator.oscpu",
"navigator.product": "navigator.product",
"navigator.language": "navigator.language",
"navigator.languages": "navigator.languages",
"navigator.globalPrivacyControl": "navigator.globalPrivacyControl",
// Screen properties
"screen.width": "screen.width",
"screen.height": "screen.height",
"screen.availWidth": "screen.availWidth",
"screen.availHeight": "screen.availHeight",
"screen.availTop": "screen.availTop",
"screen.availLeft": "screen.availLeft",
"screen.colorDepth": "screen.colorDepth",
"screen.pixelDepth": "screen.pixelDepth",
"screen.outerWidth": "window.outerWidth",
"screen.outerHeight": "window.outerHeight",
"screen.innerWidth": "window.innerWidth",
"screen.innerHeight": "window.innerHeight",
"screen.screenX": "window.screenX",
"screen.screenY": "window.screenY",
"screen.pageXOffset": "screen.pageXOffset",
"screen.pageYOffset": "screen.pageYOffset",
"screen.devicePixelRatio": "window.devicePixelRatio",
"screen.clientWidth": "document.body.clientWidth",
"screen.clientHeight": "document.body.clientHeight",
// WebGL properties
"videoCard.vendor": "webGl:vendor",
"videoCard.renderer": "webGl:renderer",
// Headers
"headers.Accept-Encoding": "headers.Accept-Encoding",
// Battery
"battery.charging": "battery:charging",
"battery.chargingTime": "battery:chargingTime",
"battery.dischargingTime": "battery:dischargingTime",
};
// Apply reverse mappings
for (const [fingerprintPath, camoufoxKey] of Object.entries(
reverseMappings,
)) {
const pathParts = fingerprintPath.split(".");
let current = fingerprintObj;
// Navigate to the nested property
for (let i = 0; i < pathParts.length - 1; i++) {
const part = pathParts[i];
if (!current[part]) {
break;
}
current = current[part];
}
// Get the final value
const finalKey = pathParts[pathParts.length - 1];
if (current && current[finalKey] !== undefined) {
camoufoxData[camoufoxKey] = current[finalKey];
}
}
// Handle fonts separately
if (fingerprintObj.fonts && Array.isArray(fingerprintObj.fonts)) {
camoufoxData.fonts = fingerprintObj.fonts;
}
return camoufoxData;
}
/**
* Convert camoufox fingerprint format to fingerprint-generator format
* @param camoufoxFingerprint The camoufox fingerprint object
* @returns fingerprint-generator object
*/
function convertCamoufoxToFingerprintGenerator(
camoufoxFingerprint: Record<string, any>,
): any {
const fingerprintObj: Record<string, any> = {
navigator: {},
screen: {},
videoCard: {},
headers: {},
battery: {},
};
// Mapping from camoufox keys to fingerprint-generator structure based on the YAML
const mappings: Record<string, string> = {
// Navigator properties
"navigator.userAgent": "navigator.userAgent",
"navigator.platform": "navigator.platform",
"navigator.hardwareConcurrency": "navigator.hardwareConcurrency",
"navigator.maxTouchPoints": "navigator.maxTouchPoints",
"navigator.doNotTrack": "navigator.doNotTrack",
"navigator.appCodeName": "navigator.appCodeName",
"navigator.appName": "navigator.appName",
"navigator.appVersion": "navigator.appVersion",
"navigator.oscpu": "navigator.oscpu",
"navigator.product": "navigator.product",
"navigator.language": "navigator.language",
"navigator.languages": "navigator.languages",
"navigator.globalPrivacyControl": "navigator.globalPrivacyControl",
// Screen properties
"screen.width": "screen.width",
"screen.height": "screen.height",
"screen.availWidth": "screen.availWidth",
"screen.availHeight": "screen.availHeight",
"screen.availTop": "screen.availTop",
"screen.availLeft": "screen.availLeft",
"screen.colorDepth": "screen.colorDepth",
"screen.pixelDepth": "screen.pixelDepth",
"window.outerWidth": "screen.outerWidth",
"window.outerHeight": "screen.outerHeight",
"window.innerWidth": "screen.innerWidth",
"window.innerHeight": "screen.innerHeight",
"window.screenX": "screen.screenX",
"window.screenY": "screen.screenY",
"screen.pageXOffset": "screen.pageXOffset",
"screen.pageYOffset": "screen.pageYOffset",
"window.devicePixelRatio": "screen.devicePixelRatio",
"document.body.clientWidth": "screen.clientWidth",
"document.body.clientHeight": "screen.clientHeight",
// WebGL properties
"webGl:vendor": "videoCard.vendor",
"webGl:renderer": "videoCard.renderer",
// Headers
"headers.Accept-Encoding": "headers.Accept-Encoding",
// Battery
"battery:charging": "battery.charging",
"battery:chargingTime": "battery.chargingTime",
"battery:dischargingTime": "battery.dischargingTime",
};
// Apply mappings
for (const [camoufoxKey, fingerprintPath] of Object.entries(mappings)) {
if (camoufoxFingerprint[camoufoxKey] !== undefined) {
const pathParts = fingerprintPath.split(".");
let current = fingerprintObj;
// Navigate to the nested property, creating objects as needed
for (let i = 0; i < pathParts.length - 1; i++) {
const part = pathParts[i];
if (!current[part]) {
current[part] = {};
}
current = current[part];
}
// Set the final value
const finalKey = pathParts[pathParts.length - 1];
current[finalKey] = camoufoxFingerprint[camoufoxKey];
}
}
// Handle fonts separately
if (camoufoxFingerprint.fonts && Array.isArray(camoufoxFingerprint.fonts)) {
fingerprintObj.fonts = camoufoxFingerprint.fonts;
}
return fingerprintObj;
}
/**
* Start a Camoufox instance in a separate process
* @param options Camoufox launch options
@@ -21,6 +210,7 @@ export async function startCamoufoxProcess(
options: LaunchOptions = {},
profilePath?: string,
url?: string,
customConfig?: string,
): Promise<CamoufoxConfig> {
// Generate a unique ID for this instance
const id = generateCamoufoxId();
@@ -31,6 +221,7 @@ export async function startCamoufoxProcess(
options,
profilePath,
url,
customConfig,
};
// Save the configuration before starting the process
@@ -252,3 +443,105 @@ export async function stopAllCamoufoxProcesses(): Promise<void> {
const stopPromises = configs.map((config) => stopCamoufoxProcess(config.id));
await Promise.all(stopPromises);
}
interface GenerateConfigOptions {
proxy?: string;
maxWidth?: number;
maxHeight?: number;
geoip?: string | boolean;
blockImages?: boolean;
blockWebrtc?: boolean;
blockWebgl?: boolean;
executablePath?: string;
fingerprint?: string;
}
/**
* Generate Camoufox configuration using launchOptions
* @param options Configuration options
* @returns Promise resolving to the generated config JSON string
*/
export async function generateCamoufoxConfig(
options: GenerateConfigOptions,
): Promise<string> {
try {
// Build launch options
const launchOpts: LaunchOptions = {
// Always set these defaults
headless: false,
i_know_what_im_doing: true,
config: {
disableTheming: true,
showcursor: false,
},
};
// Always set geoip and blocking options
launchOpts.geoip = options.geoip !== undefined ? options.geoip : true;
if (options.blockImages) {
launchOpts.block_images = true;
}
if (options.blockWebrtc) {
launchOpts.block_webrtc = true;
}
if (options.blockWebgl) {
launchOpts.block_webgl = true;
}
if (options.executablePath) {
launchOpts.executable_path = options.executablePath;
}
// If fingerprint is provided, use it and ignore other options except executable_path and block_*
if (options.fingerprint) {
try {
const camoufoxFingerprint = JSON.parse(options.fingerprint);
// Convert camoufox fingerprint format to fingerprint-generator format
const fingerprintObj =
convertCamoufoxToFingerprintGenerator(camoufoxFingerprint);
launchOpts.fingerprint = fingerprintObj;
} catch (error) {
throw new Error(`Invalid fingerprint JSON: ${error}`);
}
} else {
// Use individual options to build configuration
if (options.proxy) {
launchOpts.proxy = options.proxy;
}
if (options.maxWidth && options.maxHeight) {
launchOpts.screen = {
maxWidth: options.maxWidth,
maxHeight: options.maxHeight,
};
}
}
// Generate the configuration using launchOptions
const generatedOptions = await launchOptions(launchOpts);
// Extract the environment variables that contain the config
const envVars = generatedOptions.env || {};
// Reconstruct the config from environment variables using getEnvVars utility
let configStr = "";
let chunkIndex = 1;
while (envVars[`CAMOU_CONFIG_${chunkIndex}`]) {
configStr += envVars[`CAMOU_CONFIG_${chunkIndex}`];
chunkIndex++;
}
if (!configStr) {
throw new Error("No configuration generated");
}
// Parse and return the config as JSON string
const config = JSON.parse(configStr);
return JSON.stringify(config);
} catch (error) {
throw new Error(`Failed to generate Camoufox config: ${error}`);
}
}
+1
View File
@@ -9,6 +9,7 @@ export interface CamoufoxConfig {
profilePath?: string;
url?: string;
processId?: number;
customConfig?: string; // JSON string of the fingerprint config
}
const STORAGE_DIR = path.join(tmp.tmpdir, "donutbrowser", "camoufox");
+55 -49
View File
@@ -1,6 +1,8 @@
import { launchServer } from "camoufox-js";
import { launchOptions } from "camoufox-js";
import type { LaunchOptions } from "camoufox-js/dist/utils.js";
import { type Browser, type BrowserServer, firefox } from "playwright-core";
import { getCamoufoxConfig, saveCamoufoxConfig } from "./camoufox-storage.js";
import { getEnvVars } from "./utils.js";
/**
* Run a Camoufox browser server as a worker process
@@ -73,62 +75,67 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
try {
// Prepare options for Camoufox
const camoufoxOptions = { ...config.options };
const camoufoxOptions: LaunchOptions = { ...config.options };
// Add profile path if provided
if (config.profilePath) {
camoufoxOptions.user_data_dir = config.profilePath;
}
// Theming
camoufoxOptions.disableTheming = true;
camoufoxOptions.showcursor = false;
// Set Firefox preferences for theming
if (!camoufoxOptions.firefox_user_prefs) {
camoufoxOptions.firefox_user_prefs = {};
if (camoufoxOptions.block_images) {
camoufoxOptions.block_images = true;
}
// Default to non-headless for visibility
if (camoufoxOptions.headless === undefined) {
camoufoxOptions.headless = false;
if (camoufoxOptions.block_webgl) {
camoufoxOptions.block_webgl = true;
}
// Launch the server with proper options
server = await launchServer({
ws_path: `/ws_${config.id}`,
os: camoufoxOptions.os,
block_images: camoufoxOptions.block_images,
block_webrtc: camoufoxOptions.block_webrtc,
block_webgl: camoufoxOptions.block_webgl,
disable_coop: camoufoxOptions.disable_coop,
geoip: camoufoxOptions.geoip,
humanize: camoufoxOptions.humanize,
locale: camoufoxOptions.locale,
addons: camoufoxOptions.addons,
fonts: camoufoxOptions.fonts,
custom_fonts_only: camoufoxOptions.custom_fonts_only,
exclude_addons: camoufoxOptions.exclude_addons,
screen: camoufoxOptions.screen,
window: camoufoxOptions.window,
fingerprint: camoufoxOptions.fingerprint,
ff_version: camoufoxOptions.ff_version,
headless: camoufoxOptions.headless,
main_world_eval: camoufoxOptions.main_world_eval,
executable_path: camoufoxOptions.executable_path,
firefox_user_prefs: camoufoxOptions.firefox_user_prefs,
proxy: camoufoxOptions.proxy,
enable_cache: camoufoxOptions.enable_cache,
args: camoufoxOptions.args,
env: camoufoxOptions.env,
debug: camoufoxOptions.debug,
virtual_display: camoufoxOptions.virtual_display,
webgl_config: camoufoxOptions.webgl_config,
config: {
disableTheming: true,
showcursor: false,
timezone: camoufoxOptions.timezone,
},
if (camoufoxOptions.block_webrtc) {
camoufoxOptions.block_webrtc = true;
}
// Check for headless mode from config (no environment variable check)
if (camoufoxOptions.headless) {
camoufoxOptions.headless = true;
}
// Always set these defaults
camoufoxOptions.i_know_what_im_doing = true;
camoufoxOptions.config = {
disableTheming: true,
showcursor: false,
...(camoufoxOptions.config || {}),
};
// Generate the configuration using launchOptions
const generatedOptions = await launchOptions(camoufoxOptions);
// If we have a custom config from Rust, use it directly as environment variables
let finalEnv = generatedOptions.env || {};
if (config.customConfig) {
try {
// Parse the custom config JSON string
const customConfigObj = JSON.parse(config.customConfig);
// Convert custom config to environment variables using getEnvVars
const customEnvVars = getEnvVars(customConfigObj);
// Merge custom config with generated config (custom takes precedence)
finalEnv = { ...finalEnv, ...customEnvVars };
} catch (error) {
console.error(
"Failed to parse custom config, using generated config:",
error,
);
}
}
// Launch the server with the final configuration
server = await firefox.launchServer({
...generatedOptions,
wsPath: `/ws_${config.id}`,
env: finalEnv,
});
// Connect to the server
@@ -140,8 +147,7 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
saveCamoufoxConfig(config);
// Monitor for window closure to handle Command+Q properly
// Monitor for window closure
const startWindowMonitoring = () => {
windowCheckInterval = setInterval(async () => {
try {
+87 -77
View File
@@ -1,6 +1,7 @@
import type { LaunchOptions } from "camoufox-js/dist/utils.js";
import { program } from "commander";
import {
generateCamoufoxConfig,
startCamoufoxProcess,
stopAllCamoufoxProcesses,
stopCamoufoxProcess,
@@ -152,88 +153,26 @@ program
// Command for Camoufox management
program
.command("camoufox")
.argument("<action>", "start, stop, or list Camoufox instances")
.argument(
"<action>",
"start, stop, list, or generate-config Camoufox instances",
)
.option("--id <id>", "Camoufox ID for stop command")
.option("--profile-path <path>", "profile directory path")
.option("--url <url>", "URL to open")
// Operating system fingerprinting
.option(
"--os <os>",
"OS to emulate (windows, macos, linux, or comma-separated list)",
)
// Blocking options
.option("--block-images", "block all images")
.option("--block-webrtc", "block WebRTC entirely")
// Config generation options
.option("--proxy <proxy>", "proxy URL for config generation")
.option("--max-width <width>", "maximum screen width", parseInt)
.option("--max-height <height>", "maximum screen height", parseInt)
.option("--geoip [ip]", "enable geoip or specify IP")
.option("--block-images", "block images")
.option("--block-webrtc", "block WebRTC")
.option("--block-webgl", "block WebGL")
// Security options
.option("--disable-coop", "disable Cross-Origin-Opener-Policy")
// Geolocation and IP
.option(
"--geoip <ip>",
"IP address for geolocation spoofing (or 'auto' for automatic)",
)
.option("--country <country>", "country code for geolocation")
.option("--timezone <timezone>", "timezone to spoof")
.option("--latitude <lat>", "latitude for geolocation", parseFloat)
.option("--longitude <lng>", "longitude for geolocation", parseFloat)
// UI and behavior
.option(
"--humanize [duration]",
"humanize cursor movement (optional max duration in seconds)",
(val) => (val ? parseFloat(val) : true),
)
.option("--executable-path <path>", "executable path")
.option("--fingerprint <json>", "fingerprint JSON string")
.option("--headless", "run in headless mode")
// Localization
.option("--locale <locale>", "locale(s) to use (comma-separated)")
// Extensions and fonts
.option("--addons <addons>", "Firefox addons to load (comma-separated paths)")
.option("--fonts <fonts>", "additional fonts to load (comma-separated)")
.option("--custom-fonts-only", "use only custom fonts, exclude OS fonts")
.option(
"--exclude-addons <addons>",
"default addons to exclude (comma-separated)",
)
// Screen and window
.option("--screen-min-width <width>", "minimum screen width", parseInt)
.option("--screen-max-width <width>", "maximum screen width", parseInt)
.option("--screen-min-height <height>", "minimum screen height", parseInt)
.option("--screen-max-height <height>", "maximum screen height", parseInt)
.option("--window-width <width>", "fixed window width", parseInt)
.option("--window-height <height>", "fixed window height", parseInt)
// Advanced options
.option("--ff-version <version>", "Firefox version to emulate", parseInt)
.option("--main-world-eval", "enable main world script evaluation")
.option("--webgl-vendor <vendor>", "WebGL vendor string")
.option("--webgl-renderer <renderer>", "WebGL renderer string")
// Proxy
.option(
"--proxy <proxy>",
"proxy URL (protocol://[username:password@]host:port)",
)
// Cache and performance
.option("--disable-cache", "disable browser cache (cache enabled by default)")
// Environment and debugging
.option("--virtual-display <display>", "virtual display number (e.g., :99)")
.option("--debug", "enable debug output")
.option("--args <args>", "additional browser arguments (comma-separated)")
.option("--env <env>", "environment variables (JSON string)")
// Firefox preferences
.option("--firefox-prefs <prefs>", "Firefox user preferences (JSON string)")
// Note: theming and cursor options are hardcoded and not user-configurable
.option("--custom-config <json>", "custom config JSON string")
.description("manage Camoufox browser instances")
.action(
@@ -349,6 +288,11 @@ program
if (options.virtualDisplay)
camoufoxOptions.virtual_display = options.virtualDisplay as string;
if (options.debug) camoufoxOptions.debug = true;
// Handle headless mode via flag instead of environment variable
if (options.headless) {
camoufoxOptions.headless = true;
}
if (options.args && typeof options.args === "string")
camoufoxOptions.args = options.args.split(",");
if (options.env && typeof options.env === "string") {
@@ -393,6 +337,9 @@ program
? options.profilePath
: undefined,
typeof options.url === "string" ? options.url : undefined,
typeof options.customConfig === "string"
? options.customConfig
: undefined,
);
console.log(
@@ -427,8 +374,71 @@ program
const configs = listCamoufoxConfigs();
console.log(JSON.stringify(configs));
process.exit(0);
} else if (action === "generate-config") {
try {
// Handle geoip option properly
let geoipValue: string | boolean = true; // Default to true
if (options.geoip !== undefined) {
if (typeof options.geoip === "boolean") {
geoipValue = options.geoip;
} else if (typeof options.geoip === "string") {
if (options.geoip === "true") {
geoipValue = true;
} else if (options.geoip === "false") {
geoipValue = false;
} else {
geoipValue = options.geoip; // IP address
}
}
}
const config = await generateCamoufoxConfig({
proxy:
typeof options.proxy === "string" ? options.proxy : undefined,
maxWidth:
typeof options.maxWidth === "number"
? options.maxWidth
: undefined,
maxHeight:
typeof options.maxHeight === "number"
? options.maxHeight
: undefined,
geoip: geoipValue,
blockImages:
typeof options.blockImages === "boolean"
? options.blockImages
: undefined,
blockWebrtc:
typeof options.blockWebrtc === "boolean"
? options.blockWebrtc
: undefined,
blockWebgl:
typeof options.blockWebgl === "boolean"
? options.blockWebgl
: undefined,
executablePath:
typeof options.executablePath === "string"
? options.executablePath
: undefined,
fingerprint:
typeof options.fingerprint === "string"
? options.fingerprint
: undefined,
});
console.log(config);
process.exit(0);
} catch (error: unknown) {
console.error(
`Failed to generate config: ${
error instanceof Error ? error.message : JSON.stringify(error)
}`,
);
process.exit(1);
}
} else {
console.error("Invalid action. Use 'start', 'stop', or 'list'");
console.error(
"Invalid action. Use 'start', 'stop', 'list', or 'generate-config'",
);
process.exit(1);
}
},
+37
View File
@@ -0,0 +1,37 @@
const OS_MAP: { [key: string]: "mac" | "win" | "lin" } = {
darwin: "mac",
linux: "lin",
win32: "win",
};
const OS_NAME: "mac" | "win" | "lin" = OS_MAP[process.platform];
export function getEnvVars(configMap: Record<string, string>) {
const envVars: {
[key: string]: string | number | boolean;
} = {};
let updatedConfigData: Uint8Array;
try {
updatedConfigData = new TextEncoder().encode(JSON.stringify(configMap));
} catch (e) {
console.error(`Error updating config: ${e}`);
process.exit(1);
}
const chunkSize = OS_NAME === "win" ? 2047 : 32767;
const configStr = new TextDecoder().decode(updatedConfigData);
for (let i = 0; i < configStr.length; i += chunkSize) {
const chunk = configStr.slice(i, i + chunkSize);
const envName = `CAMOU_CONFIG_${Math.floor(i / chunkSize) + 1}`;
try {
envVars[envName] = chunk;
} catch (e) {
console.error(`Error setting ${envName}: ${e}`);
process.exit(1);
}
}
return envVars;
}