@phi-code-admin/camofox-browser 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +571 -0
- package/Dockerfile +86 -0
- package/LICENSE +21 -0
- package/README.md +691 -0
- package/camofox.config.json +10 -0
- package/dist/plugin.js +616 -0
- package/lib/auth.js +134 -0
- package/lib/camoufox-executable.js +189 -0
- package/lib/config.js +153 -0
- package/lib/cookies.js +119 -0
- package/lib/downloads.js +168 -0
- package/lib/extract.js +74 -0
- package/lib/fly.js +54 -0
- package/lib/images.js +88 -0
- package/lib/inflight.js +16 -0
- package/lib/launcher.js +47 -0
- package/lib/macros.js +31 -0
- package/lib/metrics.js +184 -0
- package/lib/openapi.js +105 -0
- package/lib/persistence.js +89 -0
- package/lib/plugins.js +175 -0
- package/lib/proxy.js +277 -0
- package/lib/reporter.js +1102 -0
- package/lib/request-utils.js +59 -0
- package/lib/resources.js +76 -0
- package/lib/snapshot.js +41 -0
- package/lib/tmp-cleanup.js +108 -0
- package/lib/tracing.js +137 -0
- package/openclaw.plugin.json +268 -0
- package/package.json +148 -0
- package/plugin.js +616 -0
- package/plugin.ts +758 -0
- package/plugins/persistence/AGENTS.md +37 -0
- package/plugins/persistence/README.md +48 -0
- package/plugins/persistence/index.js +124 -0
- package/plugins/vnc/AGENTS.md +42 -0
- package/plugins/vnc/README.md +165 -0
- package/plugins/vnc/apt.txt +7 -0
- package/plugins/vnc/index.js +142 -0
- package/plugins/vnc/spawn.js +8 -0
- package/plugins/vnc/vnc-launcher.js +64 -0
- package/plugins/vnc/vnc-watcher.sh +82 -0
- package/plugins/youtube/AGENTS.md +25 -0
- package/plugins/youtube/apt.txt +1 -0
- package/plugins/youtube/index.js +206 -0
- package/plugins/youtube/post-install.sh +5 -0
- package/plugins/youtube/youtube.js +301 -0
- package/run.sh +37 -0
- package/scripts/exec.js +8 -0
- package/scripts/generate-openapi.js +24 -0
- package/scripts/install-plugin-deps.sh +63 -0
- package/scripts/plugin.js +342 -0
- package/scripts/postinstall.js +20 -0
- package/scripts/sync-version.js +25 -0
- package/server.js +6059 -0
- package/tsconfig.json +12 -0
package/lib/auth.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared auth middleware for camofox-browser.
|
|
3
|
+
*
|
|
4
|
+
* Extracts the duplicated auth pattern from cookie/storage_state endpoints
|
|
5
|
+
* into a reusable Express middleware factory.
|
|
6
|
+
*
|
|
7
|
+
* Policy (requireAuth / per-route):
|
|
8
|
+
* - If CAMOFOX_API_KEY is set, require Bearer token match (timing-safe).
|
|
9
|
+
* - If CAMOFOX_ACCESS_KEY is set, also accept it as an alternative (superkey).
|
|
10
|
+
* - If neither key set and NODE_ENV !== production, allow loopback (127.0.0.1 / ::1).
|
|
11
|
+
* - Otherwise, reject.
|
|
12
|
+
*
|
|
13
|
+
* Policy (accessKeyMiddleware / global):
|
|
14
|
+
* - If CAMOFOX_ACCESS_KEY is set, require Bearer match on all routes except
|
|
15
|
+
* /health, cookie import (when CAMOFOX_API_KEY set), and /stop (when CAMOFOX_ADMIN_KEY set).
|
|
16
|
+
* - If not set, pass through (backward-compatible).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import crypto from 'crypto';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Timing-safe string comparison.
|
|
23
|
+
*/
|
|
24
|
+
function timingSafeCompare(a, b) {
|
|
25
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
26
|
+
const bufA = Buffer.from(a);
|
|
27
|
+
const bufB = Buffer.from(b);
|
|
28
|
+
if (bufA.length !== bufB.length) {
|
|
29
|
+
// Compare against self to burn constant time, then return false
|
|
30
|
+
crypto.timingSafeEqual(bufA, bufA);
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
return crypto.timingSafeEqual(bufA, bufB);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Check if an address is loopback.
|
|
38
|
+
*/
|
|
39
|
+
function isLoopbackAddress(address) {
|
|
40
|
+
if (!address) return false;
|
|
41
|
+
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Create an Express middleware that enforces API key auth.
|
|
46
|
+
*
|
|
47
|
+
* Accepts CAMOFOX_API_KEY as primary token. When CAMOFOX_ACCESS_KEY is also
|
|
48
|
+
* configured, it is accepted as an alternative ("superkey") so that routes
|
|
49
|
+
* gated by both the global access-key middleware AND this per-route middleware
|
|
50
|
+
* don't require two different tokens in a single Authorization header.
|
|
51
|
+
*
|
|
52
|
+
* @param {object} config - Must have { apiKey, nodeEnv }; optionally { accessKey }
|
|
53
|
+
* @param {object} [options]
|
|
54
|
+
* @param {string} [options.errorMessage] - Custom error message when rejecting unauthenticated requests
|
|
55
|
+
* @returns {function} Express middleware (req, res, next)
|
|
56
|
+
*/
|
|
57
|
+
export function requireAuth(config, options = {}) {
|
|
58
|
+
const errorMessage = options.errorMessage ||
|
|
59
|
+
'This endpoint requires CAMOFOX_API_KEY except for loopback requests in non-production environments.';
|
|
60
|
+
|
|
61
|
+
return function requireAuthCheck(req, res, next) {
|
|
62
|
+
const auth = String(req.headers['authorization'] || '');
|
|
63
|
+
const match = auth.match(/^Bearer\s+(.+)$/i);
|
|
64
|
+
const token = match ? match[1]?.trim() : null;
|
|
65
|
+
|
|
66
|
+
// Accept API key
|
|
67
|
+
if (config.apiKey && token && timingSafeCompare(token, config.apiKey)) {
|
|
68
|
+
return next();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Accept access key as alternative (superkey)
|
|
72
|
+
if (config.accessKey && token && timingSafeCompare(token, config.accessKey)) {
|
|
73
|
+
return next();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// If any key is configured, a valid token was required -- reject
|
|
77
|
+
if (config.apiKey || config.accessKey) {
|
|
78
|
+
return res.status(403).json({ error: 'Forbidden' });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// No keys configured -- allow loopback in non-production
|
|
82
|
+
const remoteAddress = req.socket?.remoteAddress || '';
|
|
83
|
+
const allowUnauthedLocal = config.nodeEnv !== 'production' && isLoopbackAddress(remoteAddress);
|
|
84
|
+
if (!allowUnauthedLocal) {
|
|
85
|
+
return res.status(403).json({ error: errorMessage });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
next();
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Global access-key middleware factory.
|
|
94
|
+
*
|
|
95
|
+
* When CAMOFOX_ACCESS_KEY is set, requires `Authorization: Bearer <key>` on
|
|
96
|
+
* every route except:
|
|
97
|
+
* - GET /health (Docker/Fly healthcheck)
|
|
98
|
+
* - POST /sessions/:userId/cookies (only when CAMOFOX_API_KEY is also set -- has its own gate)
|
|
99
|
+
* - POST /stop (only when CAMOFOX_ADMIN_KEY is also set -- has its own gate)
|
|
100
|
+
*
|
|
101
|
+
* When a route's dedicated key is NOT configured, the access-key middleware
|
|
102
|
+
* does NOT exempt it -- defense-in-depth prevents unprotected endpoints.
|
|
103
|
+
*
|
|
104
|
+
* When CAMOFOX_ACCESS_KEY is not set, passes through (backward-compatible).
|
|
105
|
+
*
|
|
106
|
+
* @param {object} config - Must have { accessKey }; optionally { apiKey, adminKey }
|
|
107
|
+
* @returns {function} Express middleware (req, res, next)
|
|
108
|
+
*/
|
|
109
|
+
export function accessKeyMiddleware(config) {
|
|
110
|
+
return function accessKeyCheck(req, res, next) {
|
|
111
|
+
if (!config.accessKey) return next();
|
|
112
|
+
|
|
113
|
+
// Exempt healthcheck
|
|
114
|
+
if (req.path === '/health') return next();
|
|
115
|
+
|
|
116
|
+
// Exempt routes with their own dedicated auth -- but only when their key is configured.
|
|
117
|
+
// If the dedicated key is NOT set, the access key gates the route (defense-in-depth).
|
|
118
|
+
if (config.apiKey && req.method === 'POST' && /^\/sessions\/[^/]+\/cookies$/.test(req.path)) return next();
|
|
119
|
+
if (config.adminKey && req.method === 'POST' && req.path === '/stop') return next();
|
|
120
|
+
|
|
121
|
+
const auth = String(req.headers['authorization'] || '');
|
|
122
|
+
const match = auth.match(/^Bearer\s+(.+)$/i);
|
|
123
|
+
const token = match ? match[1]?.trim() : null;
|
|
124
|
+
if (!token || !timingSafeCompare(token, config.accessKey)) {
|
|
125
|
+
return res.status(401)
|
|
126
|
+
.set('WWW-Authenticate', 'Bearer realm="camofox"')
|
|
127
|
+
.json({ error: 'Unauthorized' });
|
|
128
|
+
}
|
|
129
|
+
next();
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Re-export utilities so server.js can still use them directly
|
|
134
|
+
export { timingSafeCompare, isLoopbackAddress };
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import {
|
|
3
|
+
accessSync,
|
|
4
|
+
constants,
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
realpathSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
statSync,
|
|
12
|
+
symlinkSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from 'fs';
|
|
15
|
+
import { dirname, join, resolve } from 'path';
|
|
16
|
+
import { platform, tmpdir } from 'os';
|
|
17
|
+
|
|
18
|
+
function assertExecutable(path) {
|
|
19
|
+
const stat = statSync(path);
|
|
20
|
+
if (!stat.isFile() && !stat.isSymbolicLink()) {
|
|
21
|
+
throw new Error(`Camoufox executable is not a file: ${path}`);
|
|
22
|
+
}
|
|
23
|
+
if (platform() !== 'win32') accessSync(path, constants.X_OK);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function nixStoreRoot(path) {
|
|
27
|
+
const match = path.match(/^\/nix\/store\/[^/]+/);
|
|
28
|
+
return match?.[0] || null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function collectDirs(root, maxDepth = 4) {
|
|
32
|
+
const dirs = [];
|
|
33
|
+
const queue = [{ dir: root, depth: 0 }];
|
|
34
|
+
const seen = new Set();
|
|
35
|
+
|
|
36
|
+
while (queue.length > 0) {
|
|
37
|
+
const { dir, depth } = queue.shift();
|
|
38
|
+
if (seen.has(dir)) continue;
|
|
39
|
+
seen.add(dir);
|
|
40
|
+
dirs.push(dir);
|
|
41
|
+
if (depth >= maxDepth) continue;
|
|
42
|
+
|
|
43
|
+
let entries = [];
|
|
44
|
+
try {
|
|
45
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
46
|
+
} catch {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
51
|
+
queue.push({ dir: join(dir, entry.name), depth: depth + 1 });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return dirs;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function findResourceDir(executablePath) {
|
|
59
|
+
const resolvedPath = realpathSync(executablePath);
|
|
60
|
+
const directDirs = [
|
|
61
|
+
dirname(executablePath),
|
|
62
|
+
dirname(resolvedPath),
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
const storeRoot = nixStoreRoot(resolvedPath) || nixStoreRoot(executablePath);
|
|
66
|
+
const likelyDirs = storeRoot
|
|
67
|
+
? [
|
|
68
|
+
storeRoot,
|
|
69
|
+
join(storeRoot, 'lib', 'camoufox'),
|
|
70
|
+
join(storeRoot, 'libexec', 'camoufox'),
|
|
71
|
+
join(storeRoot, 'share', 'camoufox'),
|
|
72
|
+
join(storeRoot, 'opt', 'camoufox'),
|
|
73
|
+
]
|
|
74
|
+
: [];
|
|
75
|
+
|
|
76
|
+
const allCandidates = [...directDirs, ...likelyDirs];
|
|
77
|
+
for (const dir of allCandidates) {
|
|
78
|
+
if (existsSync(join(dir, 'properties.json'))) return dir;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (storeRoot) {
|
|
82
|
+
for (const dir of collectDirs(storeRoot)) {
|
|
83
|
+
if (existsSync(join(dir, 'properties.json'))) return dir;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function ensureSymlink(target, linkPath, type = 'file') {
|
|
91
|
+
rmSync(linkPath, { force: true, recursive: true });
|
|
92
|
+
symlinkSync(target, linkPath, platform() === 'win32' && type === 'dir' ? 'junction' : type);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function shimRootFor(executablePath, resourceDir) {
|
|
96
|
+
const key = crypto
|
|
97
|
+
.createHash('sha256')
|
|
98
|
+
.update(`${realpathSync(executablePath)}\n${realpathSync(resourceDir)}`)
|
|
99
|
+
.digest('hex')
|
|
100
|
+
.slice(0, 16);
|
|
101
|
+
return join(tmpdir(), 'camofox-browser-external-camoufox', key);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function ensureLaunchShim(executablePath, resourceDir) {
|
|
105
|
+
const shimRoot = shimRootFor(executablePath, resourceDir);
|
|
106
|
+
mkdirSync(shimRoot, { recursive: true });
|
|
107
|
+
|
|
108
|
+
const shimExecutable = join(shimRoot, platform() === 'win32' ? 'camoufox.exe' : 'camoufox-bin');
|
|
109
|
+
ensureSymlink(realpathSync(executablePath), shimExecutable);
|
|
110
|
+
|
|
111
|
+
for (const name of ['properties.json', 'version.json']) {
|
|
112
|
+
const target = join(resourceDir, name);
|
|
113
|
+
if (existsSync(target)) ensureSymlink(realpathSync(target), join(shimRoot, name));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const fontconfig = join(resourceDir, 'fontconfig');
|
|
117
|
+
if (existsSync(fontconfig)) ensureSymlink(realpathSync(fontconfig), join(shimRoot, 'fontconfig'), 'dir');
|
|
118
|
+
|
|
119
|
+
return shimExecutable;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function camoufoxLaunchFileName() {
|
|
123
|
+
if (platform() === 'win32') return 'camoufox.exe';
|
|
124
|
+
if (platform() === 'darwin') return join('Camoufox.app', 'Contents', 'MacOS', 'camoufox');
|
|
125
|
+
return 'camoufox-bin';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function ensureCamoufoxJsCache(resourceDir, cacheDir, executablePath) {
|
|
129
|
+
const cacheVersion = join(cacheDir, 'version.json');
|
|
130
|
+
const cacheFontconfig = join(cacheDir, 'fontconfig');
|
|
131
|
+
const cacheProperties = join(cacheDir, 'properties.json');
|
|
132
|
+
const cacheExecutable = join(cacheDir, camoufoxLaunchFileName());
|
|
133
|
+
|
|
134
|
+
if (
|
|
135
|
+
existsSync(cacheVersion) &&
|
|
136
|
+
existsSync(cacheFontconfig) &&
|
|
137
|
+
existsSync(cacheProperties) &&
|
|
138
|
+
existsSync(cacheExecutable)
|
|
139
|
+
) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const versionFile = join(resourceDir, 'version.json');
|
|
144
|
+
const propertiesFile = join(resourceDir, 'properties.json');
|
|
145
|
+
const fontconfig = join(resourceDir, 'fontconfig');
|
|
146
|
+
if (!existsSync(versionFile) || !existsSync(propertiesFile) || !existsSync(fontconfig)) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`External Camoufox bundle at ${resourceDir} must include properties.json, version.json, and fontconfig/ for camoufox-js compatibility`
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
153
|
+
if (!existsSync(cacheVersion)) {
|
|
154
|
+
writeFileSync(cacheVersion, readFileSync(versionFile));
|
|
155
|
+
}
|
|
156
|
+
if (!existsSync(cacheProperties)) {
|
|
157
|
+
ensureSymlink(realpathSync(propertiesFile), cacheProperties);
|
|
158
|
+
}
|
|
159
|
+
if (!existsSync(cacheFontconfig)) {
|
|
160
|
+
ensureSymlink(realpathSync(fontconfig), cacheFontconfig, 'dir');
|
|
161
|
+
}
|
|
162
|
+
if (!existsSync(cacheExecutable)) {
|
|
163
|
+
mkdirSync(dirname(cacheExecutable), { recursive: true });
|
|
164
|
+
ensureSymlink(realpathSync(executablePath), cacheExecutable);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function prepareExternalCamoufoxExecutable(executablePath, { cacheDir } = {}) {
|
|
169
|
+
if (!executablePath) return null;
|
|
170
|
+
if (!cacheDir) throw new Error('cacheDir is required for external Camoufox executable preparation');
|
|
171
|
+
|
|
172
|
+
const resolvedExecutable = resolve(executablePath);
|
|
173
|
+
assertExecutable(resolvedExecutable);
|
|
174
|
+
|
|
175
|
+
const resourceDir = findResourceDir(resolvedExecutable);
|
|
176
|
+
if (!resourceDir) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`Could not find Camoufox resources for ${resolvedExecutable}. ` +
|
|
179
|
+
'Point the executable override at a Camoufox bundle that includes properties.json.'
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
ensureCamoufoxJsCache(resourceDir, cacheDir, resolvedExecutable);
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
executablePath: ensureLaunchShim(resolvedExecutable, resourceDir),
|
|
187
|
+
resourceDir,
|
|
188
|
+
};
|
|
189
|
+
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized environment configuration for camofox-browser.
|
|
3
|
+
*
|
|
4
|
+
* All process.env access is centralized here for auditability.
|
|
5
|
+
* flag plugin.ts or server.js for env-harvesting (env + network in same file).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { join, dirname } from 'path';
|
|
9
|
+
import { readFileSync } from 'fs';
|
|
10
|
+
import { fileURLToPath } from 'url';
|
|
11
|
+
import os from 'os';
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const ROOT_DIR = join(__dirname, '..');
|
|
15
|
+
|
|
16
|
+
/** @deprecated crashReporter config moved to Cloudflare Worker relay. */
|
|
17
|
+
function readCrashReporterConfig() {
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse PROXY_PORTS env var into an array of port numbers.
|
|
23
|
+
* Supports range ("10001-10010") or comma-separated ("10001,10002,10003").
|
|
24
|
+
* Falls back to single PROXY_PORT if PROXY_PORTS is not set.
|
|
25
|
+
*/
|
|
26
|
+
function parseProxyPorts(portsEnv, singlePort) {
|
|
27
|
+
if (portsEnv) {
|
|
28
|
+
if (portsEnv.includes('-')) {
|
|
29
|
+
const [start, end] = portsEnv.split('-').map(s => parseInt(s.trim(), 10));
|
|
30
|
+
if (!isNaN(start) && !isNaN(end) && end >= start) {
|
|
31
|
+
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const parsed = portsEnv.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n));
|
|
35
|
+
if (parsed.length > 0) return parsed;
|
|
36
|
+
}
|
|
37
|
+
if (singlePort) {
|
|
38
|
+
const p = parseInt(singlePort, 10);
|
|
39
|
+
if (!isNaN(p)) return [p];
|
|
40
|
+
}
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function inferProxyStrategy(explicitStrategy) {
|
|
45
|
+
if (explicitStrategy) return explicitStrategy;
|
|
46
|
+
return 'round_robin';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function camoufoxCacheDir(env = process.env) {
|
|
50
|
+
const home = os.homedir();
|
|
51
|
+
if (process.platform === 'darwin') return join(home, 'Library', 'Caches', 'camoufox');
|
|
52
|
+
if (process.platform === 'win32') {
|
|
53
|
+
const base = env.LOCALAPPDATA || join(home, 'AppData', 'Local');
|
|
54
|
+
return join(base, 'camoufox', 'camoufox', 'Cache');
|
|
55
|
+
}
|
|
56
|
+
return join(env.XDG_CACHE_HOME || join(home, '.cache'), 'camoufox');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function camoufoxExecutablePath(env = process.env) {
|
|
60
|
+
return (
|
|
61
|
+
env.CAMOUFOX_EXECUTABLE ||
|
|
62
|
+
env.CAMOUFOX_EXECUTABLE_PATH ||
|
|
63
|
+
env.CAMOFOX_EXECUTABLE_PATH ||
|
|
64
|
+
''
|
|
65
|
+
).trim();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function loadConfig() {
|
|
69
|
+
const externalCamoufoxExecutable = camoufoxExecutablePath();
|
|
70
|
+
return {
|
|
71
|
+
port: parseInt(process.env.CAMOFOX_PORT || process.env.PORT || '9377', 10),
|
|
72
|
+
nodeEnv: process.env.NODE_ENV || 'development',
|
|
73
|
+
flyMachineId: process.env.FLY_MACHINE_ID || '',
|
|
74
|
+
flyAppName: process.env.FLY_APP_NAME || '',
|
|
75
|
+
flyApiToken: process.env.FLY_API_TOKEN || '',
|
|
76
|
+
adminKey: process.env.CAMOFOX_ADMIN_KEY || '',
|
|
77
|
+
apiKey: process.env.CAMOFOX_API_KEY || '',
|
|
78
|
+
accessKey: (process.env.CAMOFOX_ACCESS_KEY || '').trim(),
|
|
79
|
+
cookiesDir: process.env.CAMOFOX_COOKIES_DIR || join(os.homedir(), '.camofox', 'cookies'),
|
|
80
|
+
profileDir: process.env.CAMOFOX_PROFILE_DIR || join(os.homedir(), '.camofox', 'profiles'),
|
|
81
|
+
tracesDir: process.env.CAMOFOX_TRACES_DIR || join(os.homedir(), '.camofox', 'traces'),
|
|
82
|
+
tracesMaxBytes: parseInt(process.env.CAMOFOX_TRACES_MAX_BYTES || String(50 * 1024 * 1024), 10),
|
|
83
|
+
tracesTtlHours: parseInt(process.env.CAMOFOX_TRACES_TTL_HOURS || '24', 10),
|
|
84
|
+
handlerTimeoutMs: parseInt(process.env.HANDLER_TIMEOUT_MS) || 30000,
|
|
85
|
+
maxConcurrentPerUser: parseInt(process.env.MAX_CONCURRENT_PER_USER) || 3,
|
|
86
|
+
sessionTimeoutMs: parseInt(process.env.SESSION_TIMEOUT_MS) || 600000,
|
|
87
|
+
tabInactivityMs: parseInt(process.env.TAB_INACTIVITY_MS) || 300000,
|
|
88
|
+
maxSessions: parseInt(process.env.MAX_SESSIONS) || 50,
|
|
89
|
+
maxTabsPerSession: parseInt(process.env.MAX_TABS_PER_SESSION) || 10,
|
|
90
|
+
maxTabsGlobal: parseInt(process.env.MAX_TABS_GLOBAL) || 50,
|
|
91
|
+
navigateTimeoutMs: parseInt(process.env.NAVIGATE_TIMEOUT_MS) || 25000,
|
|
92
|
+
buildrefsTimeoutMs: parseInt(process.env.BUILDREFS_TIMEOUT_MS) || 12000,
|
|
93
|
+
browserIdleTimeoutMs: parseInt(process.env.BROWSER_IDLE_TIMEOUT_MS) || 300000,
|
|
94
|
+
nativeMemRestartThresholdMb: parseInt(process.env.NATIVE_MEM_RESTART_THRESHOLD_MB) || 300,
|
|
95
|
+
camoufoxExecutablePath: externalCamoufoxExecutable,
|
|
96
|
+
camoufoxCacheDir: camoufoxCacheDir(),
|
|
97
|
+
prometheusEnabled: process.env.PROMETHEUS_ENABLED === '1' || process.env.PROMETHEUS_ENABLED === 'true',
|
|
98
|
+
proxy: {
|
|
99
|
+
strategy: inferProxyStrategy(process.env.PROXY_STRATEGY || ''),
|
|
100
|
+
providerName: process.env.PROXY_PROVIDER || 'decodo',
|
|
101
|
+
host: process.env.PROXY_HOST || '',
|
|
102
|
+
port: process.env.PROXY_PORT || '',
|
|
103
|
+
ports: parseProxyPorts(process.env.PROXY_PORTS, process.env.PROXY_PORT),
|
|
104
|
+
username: process.env.PROXY_USERNAME || '',
|
|
105
|
+
password: process.env.PROXY_PASSWORD || '',
|
|
106
|
+
backconnectHost: process.env.PROXY_BACKCONNECT_HOST || '',
|
|
107
|
+
backconnectPort: parseInt(process.env.PROXY_BACKCONNECT_PORT || '7000', 10),
|
|
108
|
+
country: process.env.PROXY_COUNTRY || '',
|
|
109
|
+
state: process.env.PROXY_STATE || '',
|
|
110
|
+
city: process.env.PROXY_CITY || '',
|
|
111
|
+
zip: process.env.PROXY_ZIP || '',
|
|
112
|
+
sessionDurationMinutes: parseInt(process.env.PROXY_SESSION_DURATION_MINUTES || '10', 10),
|
|
113
|
+
},
|
|
114
|
+
// Env vars forwarded to the server subprocess
|
|
115
|
+
serverEnv: {
|
|
116
|
+
PATH: process.env.PATH,
|
|
117
|
+
HOME: process.env.HOME,
|
|
118
|
+
NODE_ENV: process.env.NODE_ENV,
|
|
119
|
+
CAMOFOX_ADMIN_KEY: process.env.CAMOFOX_ADMIN_KEY,
|
|
120
|
+
CAMOFOX_API_KEY: process.env.CAMOFOX_API_KEY,
|
|
121
|
+
CAMOFOX_ACCESS_KEY: process.env.CAMOFOX_ACCESS_KEY,
|
|
122
|
+
CAMOFOX_COOKIES_DIR: process.env.CAMOFOX_COOKIES_DIR,
|
|
123
|
+
CAMOFOX_TRACES_DIR: process.env.CAMOFOX_TRACES_DIR,
|
|
124
|
+
CAMOFOX_TRACES_MAX_BYTES: process.env.CAMOFOX_TRACES_MAX_BYTES,
|
|
125
|
+
CAMOFOX_TRACES_TTL_HOURS: process.env.CAMOFOX_TRACES_TTL_HOURS,
|
|
126
|
+
CAMOUFOX_EXECUTABLE: process.env.CAMOUFOX_EXECUTABLE,
|
|
127
|
+
CAMOUFOX_EXECUTABLE_PATH: process.env.CAMOUFOX_EXECUTABLE_PATH,
|
|
128
|
+
CAMOFOX_EXECUTABLE_PATH: process.env.CAMOFOX_EXECUTABLE_PATH,
|
|
129
|
+
PROXY_STRATEGY: process.env.PROXY_STRATEGY,
|
|
130
|
+
PROXY_PROVIDER: process.env.PROXY_PROVIDER,
|
|
131
|
+
PROXY_HOST: process.env.PROXY_HOST,
|
|
132
|
+
PROXY_PORT: process.env.PROXY_PORT,
|
|
133
|
+
PROXY_PORTS: process.env.PROXY_PORTS,
|
|
134
|
+
PROXY_USERNAME: process.env.PROXY_USERNAME,
|
|
135
|
+
PROXY_PASSWORD: process.env.PROXY_PASSWORD,
|
|
136
|
+
PROXY_BACKCONNECT_HOST: process.env.PROXY_BACKCONNECT_HOST,
|
|
137
|
+
PROXY_BACKCONNECT_PORT: process.env.PROXY_BACKCONNECT_PORT,
|
|
138
|
+
PROXY_COUNTRY: process.env.PROXY_COUNTRY,
|
|
139
|
+
PROXY_STATE: process.env.PROXY_STATE,
|
|
140
|
+
PROXY_CITY: process.env.PROXY_CITY,
|
|
141
|
+
PROXY_ZIP: process.env.PROXY_ZIP,
|
|
142
|
+
PROXY_SESSION_DURATION_MINUTES: process.env.PROXY_SESSION_DURATION_MINUTES,
|
|
143
|
+
},
|
|
144
|
+
// Crash reporter (opt-in, reports sent to Cloudflare Worker relay)
|
|
145
|
+
crashReportEnabled: process.env.CAMOFOX_CRASH_REPORT_ENABLED !== 'false',
|
|
146
|
+
crashReportUrl: process.env.CAMOFOX_CRASH_REPORT_URL || '',
|
|
147
|
+
crashReportRepo: process.env.CAMOFOX_CRASH_REPORT_REPO,
|
|
148
|
+
crashReportRateLimit: parseInt(process.env.CAMOFOX_CRASH_REPORT_RATE_LIMIT, 10) || 10,
|
|
149
|
+
crashReporterConfig: readCrashReporterConfig(),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export { loadConfig };
|
package/lib/cookies.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cookie file reading and parsing for camofox-browser.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fs from 'fs/promises';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parse a Netscape-format cookie file into structured cookie objects.
|
|
10
|
+
* @param {string} text - Raw cookie file content
|
|
11
|
+
* @returns {Array<{name: string, value: string, domain: string, path: string, expires: number, httpOnly?: boolean, secure?: boolean}>}
|
|
12
|
+
*/
|
|
13
|
+
function parseNetscapeCookieFile(text) {
|
|
14
|
+
const cookies = [];
|
|
15
|
+
const cleaned = text.replace(/^\uFEFF/, '');
|
|
16
|
+
|
|
17
|
+
for (const rawLine of cleaned.split(/\r?\n/)) {
|
|
18
|
+
const line = rawLine.trim();
|
|
19
|
+
if (!line) continue;
|
|
20
|
+
if (line.startsWith('#') && !line.startsWith('#HttpOnly_')) continue;
|
|
21
|
+
|
|
22
|
+
let httpOnly = false;
|
|
23
|
+
let working = line;
|
|
24
|
+
if (working.startsWith('#HttpOnly_')) {
|
|
25
|
+
httpOnly = true;
|
|
26
|
+
working = working.replace(/^#HttpOnly_/, '');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const parts = working.split('\t');
|
|
30
|
+
if (parts.length < 7) continue;
|
|
31
|
+
|
|
32
|
+
const domain = parts[0];
|
|
33
|
+
const cookiePath = parts[2];
|
|
34
|
+
const secure = parts[3].toUpperCase() === 'TRUE';
|
|
35
|
+
const expires = Number(parts[4]);
|
|
36
|
+
const name = parts[5];
|
|
37
|
+
const value = parts.slice(6).join('\t');
|
|
38
|
+
|
|
39
|
+
cookies.push({ name, value, domain, path: cookiePath, expires, httpOnly, secure });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return cookies;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Read and parse cookies from a Netscape cookie file.
|
|
47
|
+
* @param {object} opts
|
|
48
|
+
* @param {string} opts.cookiesDir - Base directory for cookie files
|
|
49
|
+
* @param {string} opts.cookiesPath - Relative path to the cookie file within cookiesDir
|
|
50
|
+
* @param {string} [opts.domainSuffix] - Only include cookies whose domain ends with this suffix
|
|
51
|
+
* @param {number} [opts.maxBytes=5242880] - Maximum file size in bytes
|
|
52
|
+
* @returns {Promise<Array<{name: string, value: string, domain: string, path: string, expires: number, httpOnly: boolean, secure: boolean}>>}
|
|
53
|
+
*/
|
|
54
|
+
async function readCookieFile({ cookiesDir, cookiesPath, domainSuffix, maxBytes = 5 * 1024 * 1024 }) {
|
|
55
|
+
const resolved = path.resolve(cookiesDir, cookiesPath);
|
|
56
|
+
if (!resolved.startsWith(cookiesDir + path.sep)) {
|
|
57
|
+
throw new Error('cookiesPath must be a relative path within the cookies directory');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const stat = await fs.stat(resolved);
|
|
61
|
+
if (stat.size > maxBytes) {
|
|
62
|
+
throw new Error('Cookie file too large (max 5MB)');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const text = await fs.readFile(resolved, 'utf8');
|
|
66
|
+
let cookies = parseNetscapeCookieFile(text);
|
|
67
|
+
if (domainSuffix) {
|
|
68
|
+
cookies = cookies.filter((c) => c.domain.endsWith(domainSuffix));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return cookies.map((c) => ({
|
|
72
|
+
name: c.name,
|
|
73
|
+
value: c.value,
|
|
74
|
+
domain: c.domain,
|
|
75
|
+
path: c.path,
|
|
76
|
+
expires: c.expires,
|
|
77
|
+
httpOnly: !!c.httpOnly,
|
|
78
|
+
secure: !!c.secure,
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Import all cookies from the default bootstrap cookie file into a Playwright context.
|
|
84
|
+
* Intended for first-run session seeding before any persistent storage state exists.
|
|
85
|
+
* Missing file is treated as a no-op.
|
|
86
|
+
* @param {object} opts
|
|
87
|
+
* @param {string} opts.cookiesDir - Base directory for cookie files
|
|
88
|
+
* @param {object} opts.context - Playwright BrowserContext
|
|
89
|
+
* @param {string} [opts.cookiesPath='cookies.txt'] - Relative cookie file path within cookiesDir
|
|
90
|
+
* @param {object} [opts.logger=console] - Logger with warn()
|
|
91
|
+
* @returns {Promise<{imported: number, source: string|null}>}
|
|
92
|
+
*/
|
|
93
|
+
async function importBootstrapCookies({ cookiesDir, context, cookiesPath = 'cookies.txt', logger = console }) {
|
|
94
|
+
if (!cookiesDir || !context) {
|
|
95
|
+
return { imported: 0, source: null };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const resolved = path.resolve(cookiesDir, cookiesPath);
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const cookies = await readCookieFile({ cookiesDir, cookiesPath });
|
|
102
|
+
if (cookies.length === 0) {
|
|
103
|
+
return { imported: 0, source: resolved };
|
|
104
|
+
}
|
|
105
|
+
await context.addCookies(cookies);
|
|
106
|
+
return { imported: cookies.length, source: resolved };
|
|
107
|
+
} catch (err) {
|
|
108
|
+
if (err?.code === 'ENOENT') {
|
|
109
|
+
return { imported: 0, source: null };
|
|
110
|
+
}
|
|
111
|
+
logger?.warn?.('failed to import bootstrap cookies', {
|
|
112
|
+
cookiesPath: resolved,
|
|
113
|
+
error: err?.message || String(err),
|
|
114
|
+
});
|
|
115
|
+
return { imported: 0, source: resolved };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export { parseNetscapeCookieFile, readCookieFile, importBootstrapCookies };
|