@loadmill/droid-cua 2.41.0 → 2.43.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/README.md +20 -0
- package/build/index.js +66 -8
- package/build/src/browser/connection.js +5 -0
- package/build/src/browser/factory.js +1 -0
- package/build/src/browser/playwright/connection.js +3 -0
- package/build/src/cli/headless-runner.js +55 -3
- package/build/src/commands/help.js +2 -0
- package/build/src/core/execution-engine.js +15 -0
- package/build/src/core/report/render-html-report.js +65 -7
- package/build/src/core/report/run-log-model.js +65 -0
- package/build/src/core/report/run-report-model.js +1 -0
- package/build/src/core/report/run-report-sections.js +15 -0
- package/build/src/core/tool-handlers/assert.js +45 -2
- package/build/src/core/tool-handlers/query-network.js +92 -0
- package/build/src/device/openai.js +25 -0
- package/build/src/modes/execution-mode.js +10 -1
- package/build/src/network/browser-capture.js +110 -0
- package/build/src/network/certificate-authority.js +205 -0
- package/build/src/network/certificate-install.js +242 -0
- package/build/src/network/local-proxy.js +287 -0
- package/build/src/network/network-capture.js +243 -0
- package/build/src/network/query-network.js +215 -0
- package/build/src/network/run-network-capture.js +107 -0
- package/build/src/prompts/execution.js +10 -6
- package/package.json +2 -1
- package/skill/droid-cua/SKILL.md +1 -1
- package/skill/droid-cua/references/authoring-tests.md +16 -0
- package/skill/droid-cua/references/onboarding-and-troubleshooting.md +10 -0
- package/skill/droid-cua/references/running-and-healing.md +20 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { readFile, writeFile, mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { getCertificatePaths, loadCertificateAuthority } from "./certificate-authority.js";
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
const MISSING = "__DROID_CERT_MISSING__";
|
|
9
|
+
const APEX_CERTS = "/apex/com.android.conscrypt/cacerts";
|
|
10
|
+
const SYSTEM_CERTS = "/system/etc/security/cacerts";
|
|
11
|
+
function commandError(error) {
|
|
12
|
+
const output = [error?.stderr, error?.stdout, error?.message].find((value) => typeof value === "string" && value.trim());
|
|
13
|
+
return String(output || "Command failed").trim();
|
|
14
|
+
}
|
|
15
|
+
async function run(file, args, options = {}) {
|
|
16
|
+
try {
|
|
17
|
+
return await execFileAsync(file, args, { timeout: 120000, maxBuffer: 4 * 1024 * 1024, ...options });
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
throw new Error(`${file} ${args.slice(0, 3).join(" ")}: ${commandError(error)}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
async function adb(adbPath, serial, args, options) {
|
|
24
|
+
return run(adbPath, ["-s", serial, ...args], options);
|
|
25
|
+
}
|
|
26
|
+
async function shell(adbPath, serial, script) {
|
|
27
|
+
return adb(adbPath, serial, ["shell", script]);
|
|
28
|
+
}
|
|
29
|
+
async function requireAndroidRoot(adbPath, serial) {
|
|
30
|
+
try {
|
|
31
|
+
await adb(adbPath, serial, ["root"]);
|
|
32
|
+
await adb(adbPath, serial, ["wait-for-device"]);
|
|
33
|
+
const { stdout } = await adb(adbPath, serial, ["shell", "id", "-u"]);
|
|
34
|
+
if (stdout.trim() !== "0")
|
|
35
|
+
throw new Error("ADB did not gain root access.");
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
throw new Error(`This Android Emulator image does not provide the root access needed for system CA trust. Use a rootable AOSP or Google APIs image. ${commandError(error)}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function readRemoteCertificate(adbPath, serial, directory, fileName, namespacePid) {
|
|
42
|
+
const remote = `${directory}/${fileName}`;
|
|
43
|
+
const inner = `[ -f ${remote} ] && cat ${remote} || printf ${MISSING}`;
|
|
44
|
+
const result = namespacePid
|
|
45
|
+
? await shell(adbPath, serial, `nsenter --mount=/proc/${namespacePid}/ns/mnt -- sh -c \"${inner}\"`)
|
|
46
|
+
: await shell(adbPath, serial, inner);
|
|
47
|
+
return result.stdout.trim() === MISSING ? null : result.stdout;
|
|
48
|
+
}
|
|
49
|
+
export async function selectAndroidCertificateFileName(ca, readExisting) {
|
|
50
|
+
const hash = ca.androidFileName.slice(0, -2);
|
|
51
|
+
for (let index = 0; index < 100; index += 1) {
|
|
52
|
+
const name = `${hash}.${index}`;
|
|
53
|
+
const existing = await readExisting(name);
|
|
54
|
+
if (existing === null || existing.trim() === ca.certificate.trim())
|
|
55
|
+
return { name, alreadyPresent: existing !== null };
|
|
56
|
+
}
|
|
57
|
+
throw new Error("The Android certificate store has too many certificates with the same subject hash.");
|
|
58
|
+
}
|
|
59
|
+
async function chooseCertificateName(adbPath, serial, directory, ca) {
|
|
60
|
+
return selectAndroidCertificateFileName(ca, (name) => readRemoteCertificate(adbPath, serial, directory, name));
|
|
61
|
+
}
|
|
62
|
+
async function isTrustedInNamespaces(adbPath, serial, directory, ca, pids) {
|
|
63
|
+
const hash = ca.androidFileName.slice(0, -2);
|
|
64
|
+
const results = await Promise.all(pids.map(async (pid) => {
|
|
65
|
+
for (let index = 0; index < 100; index += 1) {
|
|
66
|
+
const current = await readRemoteCertificate(adbPath, serial, directory, `${hash}.${index}`, pid);
|
|
67
|
+
if (current?.trim() === ca.certificate.trim())
|
|
68
|
+
return true;
|
|
69
|
+
if (current === null)
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}));
|
|
74
|
+
return results.every(Boolean);
|
|
75
|
+
}
|
|
76
|
+
export async function getAndroidAvdName(adbPath, serial) {
|
|
77
|
+
const { stdout } = await adb(adbPath, serial, ["emu", "avd", "name"]);
|
|
78
|
+
const name = stdout.split(/\r?\n/).map((line) => line.trim()).find((line) => line && line !== "OK");
|
|
79
|
+
if (!name)
|
|
80
|
+
throw new Error("Could not identify the Android Virtual Device for certificate preparation.");
|
|
81
|
+
return name;
|
|
82
|
+
}
|
|
83
|
+
async function readPreparedDevices() {
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(await readFile(getCertificatePaths().preparedDevices, "utf8"));
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (error?.code === "ENOENT")
|
|
89
|
+
return {};
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export async function hasPreparedAndroidCertificates() {
|
|
94
|
+
return Object.keys(await readPreparedDevices()).length > 0;
|
|
95
|
+
}
|
|
96
|
+
export async function recordPreparedAndroidCertificate(adbPath, serial, fingerprint) {
|
|
97
|
+
const prepared = await readPreparedDevices();
|
|
98
|
+
prepared[await getAndroidAvdName(adbPath, serial)] = fingerprint;
|
|
99
|
+
await writeFile(getCertificatePaths().preparedDevices, JSON.stringify(prepared, null, 2), { mode: 0o600 });
|
|
100
|
+
}
|
|
101
|
+
async function matchingPreparedAvd(adbPath, serial, ca = null) {
|
|
102
|
+
const prepared = await readPreparedDevices();
|
|
103
|
+
if (Object.keys(prepared).length === 0)
|
|
104
|
+
return null;
|
|
105
|
+
const avdName = await getAndroidAvdName(adbPath, serial);
|
|
106
|
+
if (!prepared[avdName])
|
|
107
|
+
return null;
|
|
108
|
+
const activeCa = ca || await loadCertificateAuthority();
|
|
109
|
+
if (prepared[avdName] !== activeCa.fingerprint) {
|
|
110
|
+
throw new Error(`The active network certificate has changed since ${avdName} was prepared. Run Tools → Install Network Certificate again.`);
|
|
111
|
+
}
|
|
112
|
+
return { avdName, ca: activeCa };
|
|
113
|
+
}
|
|
114
|
+
async function pushCertificate(adbPath, serial, ca, fileName) {
|
|
115
|
+
const tempDir = await mkdtemp(path.join(os.tmpdir(), "droid-network-ca-"));
|
|
116
|
+
const localPath = path.join(tempDir, fileName);
|
|
117
|
+
const remotePath = `/data/local/tmp/${fileName}`;
|
|
118
|
+
try {
|
|
119
|
+
await writeFile(localPath, ca.certificate, { mode: 0o600 });
|
|
120
|
+
await adb(adbPath, serial, ["push", localPath, remotePath]);
|
|
121
|
+
return remotePath;
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async function getZygotePids(adbPath, serial) {
|
|
128
|
+
const { stdout } = await shell(adbPath, serial, "pidof zygote zygote64 || true");
|
|
129
|
+
return [...new Set(stdout.trim().split(/\s+/).filter((value) => /^\d+$/.test(value)))];
|
|
130
|
+
}
|
|
131
|
+
async function requireMountCapabilities(adbPath, serial, directory) {
|
|
132
|
+
const { stdout } = await shell(adbPath, serial, `test -d '${directory}' && command -v nsenter && command -v mount && command -v chcon`);
|
|
133
|
+
if (!stdout.trim())
|
|
134
|
+
throw new Error("The certificate store or required mount tools are unavailable.");
|
|
135
|
+
const pids = await getZygotePids(adbPath, serial);
|
|
136
|
+
if (pids.length === 0)
|
|
137
|
+
throw new Error("No Android Zygote process was found.");
|
|
138
|
+
try {
|
|
139
|
+
await adb(adbPath, serial, ["shell", "nsenter", `--mount=/proc/${pids[0]}/ns/mnt`, "--", "true"]);
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
throw new Error(`This emulator does not allow entry into application mount namespaces. ${commandError(error)}`);
|
|
143
|
+
}
|
|
144
|
+
return pids;
|
|
145
|
+
}
|
|
146
|
+
async function installMountedAndroid(adbPath, serial, ca, directory) {
|
|
147
|
+
let zygotes;
|
|
148
|
+
try {
|
|
149
|
+
zygotes = await requireMountCapabilities(adbPath, serial, directory);
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
throw new Error(`This Android emulator cannot use Droid's system CA mount strategy. ${commandError(error)}`);
|
|
153
|
+
}
|
|
154
|
+
if (await isTrustedInNamespaces(adbPath, serial, directory, ca, zygotes))
|
|
155
|
+
return { changed: false };
|
|
156
|
+
const workDir = "/data/local/tmp/droid-cua-cacerts";
|
|
157
|
+
await shell(adbPath, serial, `mkdir -p '${workDir}' && cp '${directory}'/* '${workDir}/' && chmod 755 '${workDir}'`);
|
|
158
|
+
const { name } = await chooseCertificateName(adbPath, serial, workDir, ca);
|
|
159
|
+
const staged = await pushCertificate(adbPath, serial, ca, name);
|
|
160
|
+
await shell(adbPath, serial, `cp '${staged}' '${workDir}/${name}' && chown root:root '${workDir}'/* && chmod 644 '${workDir}'/* && chcon u:object_r:system_file:s0 '${workDir}' '${workDir}'/*`);
|
|
161
|
+
try {
|
|
162
|
+
// Zygote namespaces supply trust to newly launched apps.
|
|
163
|
+
for (const pid of zygotes) {
|
|
164
|
+
await adb(adbPath, serial, ["shell", "nsenter", `--mount=/proc/${pid}/ns/mnt`, "--", "mount", "--bind", workDir, directory]);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
throw new Error(`This emulator does not allow the required certificate-store bind mount. ${commandError(error)}`);
|
|
169
|
+
}
|
|
170
|
+
// Already running apps keep their own mount namespaces until restarted.
|
|
171
|
+
for (const pid of zygotes) {
|
|
172
|
+
const { stdout } = await shell(adbPath, serial, `ps -o PID -P ${pid} | tail -n +2`);
|
|
173
|
+
for (const appPid of stdout.trim().split(/\s+/).filter((value) => /^\d+$/.test(value))) {
|
|
174
|
+
try {
|
|
175
|
+
await adb(adbPath, serial, ["shell", "nsenter", `--mount=/proc/${appPid}/ns/mnt`, "--", "mount", "--bind", workDir, directory]);
|
|
176
|
+
}
|
|
177
|
+
catch { } // An app may exit while its mount is being updated.
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const installed = await readRemoteCertificate(adbPath, serial, directory, name, zygotes[0]);
|
|
181
|
+
if (installed?.trim() !== ca.certificate.trim())
|
|
182
|
+
throw new Error("The Droid CA is not visible from Android's application mount namespace.");
|
|
183
|
+
return { changed: true };
|
|
184
|
+
}
|
|
185
|
+
export async function inspectNetworkDevice({ platform, deviceId, adbPath = process.env.DROID_CUA_ANDROID_ADB_PATH || "adb" }) {
|
|
186
|
+
if (platform === "ios") {
|
|
187
|
+
if (process.platform !== "darwin")
|
|
188
|
+
throw new Error("iOS Simulator certificate setup requires macOS.");
|
|
189
|
+
const { stdout } = await run("xcrun", ["simctl", "list", "devices", "--json"]);
|
|
190
|
+
const devices = JSON.parse(stdout).devices;
|
|
191
|
+
for (const [runtime, entries] of Object.entries(devices)) {
|
|
192
|
+
const simulator = entries.find((entry) => entry.udid === deviceId);
|
|
193
|
+
if (simulator)
|
|
194
|
+
return { platform, deviceId, osVersion: runtime.match(/iOS-([\d-]+)/)?.[1]?.replaceAll("-", ".") || runtime };
|
|
195
|
+
}
|
|
196
|
+
throw new Error("The connected device is not an available iOS Simulator.");
|
|
197
|
+
}
|
|
198
|
+
if (platform !== "android" || !/^emulator-\d+$/.test(deviceId)) {
|
|
199
|
+
throw new Error("Network certificate setup supports local iOS Simulators and Android Emulators only.");
|
|
200
|
+
}
|
|
201
|
+
const { stdout } = await adb(adbPath, deviceId, ["shell", "getprop", "ro.build.version.sdk"]);
|
|
202
|
+
const apiLevel = Number(stdout.trim());
|
|
203
|
+
if (!Number.isInteger(apiLevel) || apiLevel < 1) {
|
|
204
|
+
throw new Error(`Could not determine this Android Emulator's API level: ${stdout.trim() || "no value returned"}.`);
|
|
205
|
+
}
|
|
206
|
+
if (apiLevel > 36) {
|
|
207
|
+
const version = apiLevel === 37 ? "Android 17 (API 37)" : `Android API ${apiLevel}`;
|
|
208
|
+
throw new Error(`This emulator runs ${version}. Droid supports certificate setup through API 36, so installation stopped before checking root access. Use a rootable AOSP or Google APIs emulator on API 36 or earlier, without a Google Play image.`);
|
|
209
|
+
}
|
|
210
|
+
return { platform, deviceId, osVersion: `Android API ${apiLevel}`, apiLevel };
|
|
211
|
+
}
|
|
212
|
+
export async function installNetworkCertificate(target, ca, options = {}) {
|
|
213
|
+
if (target.platform === "ios") {
|
|
214
|
+
await run("xcrun", ["simctl", "keychain", target.deviceId, "add-root-cert", options.certificatePath]);
|
|
215
|
+
return { changed: true };
|
|
216
|
+
}
|
|
217
|
+
const adbPath = options.adbPath || process.env.DROID_CUA_ANDROID_ADB_PATH || "adb";
|
|
218
|
+
await requireAndroidRoot(adbPath, target.deviceId);
|
|
219
|
+
return installMountedAndroid(adbPath, target.deviceId, ca, target.apiLevel >= 34 ? APEX_CERTS : SYSTEM_CERTS);
|
|
220
|
+
}
|
|
221
|
+
export async function ensureCliAndroidCertificate(deviceId, ca, options = {}) {
|
|
222
|
+
const adbPath = options.adbPath || process.env.DROID_CUA_ANDROID_ADB_PATH || "adb";
|
|
223
|
+
const target = await inspectNetworkDevice({ platform: "android", deviceId, adbPath });
|
|
224
|
+
await requireAndroidRoot(adbPath, deviceId);
|
|
225
|
+
const directory = target.apiLevel >= 34 ? APEX_CERTS : SYSTEM_CERTS;
|
|
226
|
+
const pids = await requireMountCapabilities(adbPath, deviceId, directory);
|
|
227
|
+
if (await isTrustedInNamespaces(adbPath, deviceId, directory, ca, pids))
|
|
228
|
+
return { changed: false };
|
|
229
|
+
if (!await matchingPreparedAvd(adbPath, deviceId, ca)) {
|
|
230
|
+
throw new Error("The Android Emulator does not trust Droid's active network CA. Install it with Tools → Install Network Certificate or provision matching trust before running the CLI.");
|
|
231
|
+
}
|
|
232
|
+
return installNetworkCertificate(target, ca, { adbPath });
|
|
233
|
+
}
|
|
234
|
+
export async function reapplyPreparedAndroidCertificate(deviceId, options = {}) {
|
|
235
|
+
const adbPath = options.adbPath || process.env.DROID_CUA_ANDROID_ADB_PATH || "adb";
|
|
236
|
+
const prepared = await matchingPreparedAvd(adbPath, deviceId);
|
|
237
|
+
if (!prepared)
|
|
238
|
+
return null;
|
|
239
|
+
const target = await inspectNetworkDevice({ platform: "android", deviceId, adbPath });
|
|
240
|
+
const result = await installNetworkCertificate(target, prepared.ca, { adbPath });
|
|
241
|
+
return { ...result, avdName: prepared.avdName };
|
|
242
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { generateKeyPairSync, randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import https from "node:https";
|
|
4
|
+
import net from "node:net";
|
|
5
|
+
import forge from "node-forge";
|
|
6
|
+
import { loadCertificateAuthority } from "./certificate-authority.js";
|
|
7
|
+
import { BODY_CAPTURE_LIMIT } from "./network-capture.js";
|
|
8
|
+
export const NETWORK_PROXY_HOST = "127.0.0.1";
|
|
9
|
+
export const NETWORK_PROXY_PORT = 8765;
|
|
10
|
+
function destination(authority) {
|
|
11
|
+
const url = new URL(`http://${authority}`);
|
|
12
|
+
const port = Number(url.port || 443);
|
|
13
|
+
if (!url.hostname || !Number.isInteger(port) || port < 1 || port > 65535) {
|
|
14
|
+
throw new Error("Invalid proxy destination.");
|
|
15
|
+
}
|
|
16
|
+
return { host: url.hostname.replace(/^\[|\]$/g, ""), port };
|
|
17
|
+
}
|
|
18
|
+
function leafCertificate(host, ca) {
|
|
19
|
+
const { privateKey, publicKey } = generateKeyPairSync("rsa", {
|
|
20
|
+
modulusLength: 2048,
|
|
21
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
22
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
23
|
+
});
|
|
24
|
+
const root = forge.pki.certificateFromPem(ca.certificate);
|
|
25
|
+
const certificate = forge.pki.createCertificate();
|
|
26
|
+
certificate.publicKey = forge.pki.publicKeyFromPem(publicKey);
|
|
27
|
+
certificate.serialNumber = randomBytes(16).toString("hex").replace(/^./, "1");
|
|
28
|
+
certificate.validity.notBefore = new Date(Date.now() - 60_000);
|
|
29
|
+
certificate.validity.notAfter = new Date(Math.min(Date.now() + 24 * 60 * 60 * 1000, Date.parse(root.validity.notAfter)));
|
|
30
|
+
certificate.setSubject([{ name: "commonName", value: host }]);
|
|
31
|
+
certificate.setIssuer(root.subject.attributes);
|
|
32
|
+
certificate.setExtensions([
|
|
33
|
+
{ name: "basicConstraints", cA: false, critical: true },
|
|
34
|
+
{ name: "keyUsage", digitalSignature: true, keyEncipherment: true, critical: true },
|
|
35
|
+
{ name: "extKeyUsage", serverAuth: true },
|
|
36
|
+
{ name: "subjectAltName", altNames: net.isIP(host) ? [{ type: 7, ip: host }] : [{ type: 2, value: host }] },
|
|
37
|
+
]);
|
|
38
|
+
certificate.sign(forge.pki.privateKeyFromPem(ca.privateKey), forge.md.sha256.create());
|
|
39
|
+
return { key: privateKey, cert: forge.pki.certificateToPem(certificate) };
|
|
40
|
+
}
|
|
41
|
+
function observeBody(stream, type, flowId, headers, onEvent) {
|
|
42
|
+
let bytes = 0;
|
|
43
|
+
let retained = 0;
|
|
44
|
+
let finished = false;
|
|
45
|
+
const chunks = [];
|
|
46
|
+
stream.on("data", (chunk) => {
|
|
47
|
+
bytes += chunk.length;
|
|
48
|
+
const length = Math.min(chunk.length, BODY_CAPTURE_LIMIT - retained);
|
|
49
|
+
if (length > 0) {
|
|
50
|
+
chunks.push(Buffer.from(chunk.subarray(0, length)));
|
|
51
|
+
retained += length;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
function finish(complete) {
|
|
55
|
+
if (finished)
|
|
56
|
+
return;
|
|
57
|
+
finished = true;
|
|
58
|
+
onEvent({ type, flowId, headers, body: Buffer.concat(chunks), bytes, truncated: bytes > retained, complete });
|
|
59
|
+
}
|
|
60
|
+
stream.once("end", () => finish(true));
|
|
61
|
+
stream.once("aborted", () => finish(false));
|
|
62
|
+
stream.once("error", () => finish(false));
|
|
63
|
+
stream.once("close", () => finish(false));
|
|
64
|
+
}
|
|
65
|
+
function forward(request, response, target, onError, onEvent) {
|
|
66
|
+
const flowId = onEvent ? randomUUID() : null;
|
|
67
|
+
if (onEvent) {
|
|
68
|
+
onEvent({ type: "request", flowId, method: request.method, url: target.href, headers: request.headers });
|
|
69
|
+
observeBody(request, "request_body", flowId, request.headers, onEvent);
|
|
70
|
+
}
|
|
71
|
+
const headers = { ...request.headers };
|
|
72
|
+
delete headers["proxy-connection"];
|
|
73
|
+
delete headers["proxy-authorization"];
|
|
74
|
+
const outgoing = (target.protocol === "https:" ? https : http).request(target, {
|
|
75
|
+
method: request.method,
|
|
76
|
+
headers,
|
|
77
|
+
agent: false,
|
|
78
|
+
}, (upstream) => {
|
|
79
|
+
if (onEvent) {
|
|
80
|
+
onEvent({ type: "response", flowId, status: upstream.statusCode || 502, headers: upstream.headers });
|
|
81
|
+
observeBody(upstream, "response_body", flowId, upstream.headers, onEvent);
|
|
82
|
+
}
|
|
83
|
+
response.writeHead(upstream.statusCode || 502, upstream.headers);
|
|
84
|
+
upstream.once("aborted", () => response.destroy());
|
|
85
|
+
upstream.once("error", (error) => {
|
|
86
|
+
onError(error, { flowId, phase: "upstream" });
|
|
87
|
+
response.destroy();
|
|
88
|
+
});
|
|
89
|
+
upstream.pipe(response);
|
|
90
|
+
});
|
|
91
|
+
outgoing.on("error", (error) => {
|
|
92
|
+
onError(error, { flowId, phase: "upstream" });
|
|
93
|
+
if (!response.headersSent) {
|
|
94
|
+
if (onEvent) {
|
|
95
|
+
onEvent({ type: "response", flowId, status: 502, headers: {} });
|
|
96
|
+
onEvent({ type: "response_body", flowId, headers: {}, body: Buffer.alloc(0), bytes: 0, truncated: false, complete: true });
|
|
97
|
+
}
|
|
98
|
+
response.writeHead(502);
|
|
99
|
+
}
|
|
100
|
+
response.end();
|
|
101
|
+
});
|
|
102
|
+
request.on("error", () => outgoing.destroy());
|
|
103
|
+
response.on("close", () => outgoing.destroy());
|
|
104
|
+
request.pipe(outgoing);
|
|
105
|
+
}
|
|
106
|
+
export function createNetworkProxy({ host = NETWORK_PROXY_HOST, port = NETWORK_PROXY_PORT, onError = () => { }, onEvent = null, loadCa = loadCertificateAuthority } = {}) {
|
|
107
|
+
let server;
|
|
108
|
+
let ca;
|
|
109
|
+
let intercepting = false;
|
|
110
|
+
let activeEvents = null;
|
|
111
|
+
const leaves = new Map();
|
|
112
|
+
const sockets = new Set();
|
|
113
|
+
const interceptedSockets = new Set();
|
|
114
|
+
function handleError(error, { flowId, connectionId, phase = "proxy", emit = null } = {}) {
|
|
115
|
+
if (emit)
|
|
116
|
+
emit({ type: "error", flowId, connectionId, phase, code: error.code || null, message: error.message });
|
|
117
|
+
onError(error);
|
|
118
|
+
}
|
|
119
|
+
async function destroySockets(group) {
|
|
120
|
+
await Promise.all([...group].map((socket) => new Promise((resolve) => {
|
|
121
|
+
if (socket.destroyed)
|
|
122
|
+
return resolve();
|
|
123
|
+
socket.once("close", resolve);
|
|
124
|
+
socket.destroy();
|
|
125
|
+
})));
|
|
126
|
+
}
|
|
127
|
+
function rejectRequest(response, error, emit, status = 400) {
|
|
128
|
+
handleError(error, { emit });
|
|
129
|
+
if (response.headersSent) {
|
|
130
|
+
response.destroy();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
response.writeHead(status);
|
|
134
|
+
response.end();
|
|
135
|
+
}
|
|
136
|
+
function isSelf(target) {
|
|
137
|
+
return target.port === server?.address()?.port && [host, "localhost", "127.0.0.1"].includes(target.host);
|
|
138
|
+
}
|
|
139
|
+
function handleRequest(request, response) {
|
|
140
|
+
const emit = activeEvents;
|
|
141
|
+
let target;
|
|
142
|
+
try {
|
|
143
|
+
target = new URL(request.url.startsWith("http:") ? request.url : `http://${request.headers.host}${request.url}`);
|
|
144
|
+
if (isSelf({ host: target.hostname, port: Number(target.port || 80) }))
|
|
145
|
+
throw new Error("Proxy loop rejected.");
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
rejectRequest(response, error, emit);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
forward(request, response, target, (error, details) => handleError(error, { ...details, emit }), emit);
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
rejectRequest(response, error, emit, 502);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function forwardTunnel(socket, head, target) {
|
|
159
|
+
const upstream = net.connect(target.port, target.host);
|
|
160
|
+
sockets.add(upstream);
|
|
161
|
+
upstream.once("connect", () => {
|
|
162
|
+
socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
163
|
+
if (head.length)
|
|
164
|
+
upstream.write(head);
|
|
165
|
+
socket.pipe(upstream).pipe(socket);
|
|
166
|
+
});
|
|
167
|
+
upstream.on("error", (error) => {
|
|
168
|
+
handleError(error);
|
|
169
|
+
if (!socket.destroyed)
|
|
170
|
+
socket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
|
171
|
+
});
|
|
172
|
+
socket.on("error", () => upstream.destroy());
|
|
173
|
+
socket.on("close", () => upstream.destroy());
|
|
174
|
+
upstream.on("close", () => sockets.delete(upstream));
|
|
175
|
+
}
|
|
176
|
+
function interceptTls(request, socket, head, target, emit) {
|
|
177
|
+
const connectionId = randomUUID();
|
|
178
|
+
emit?.({ type: "connect", connectionId, host: target.host, port: target.port });
|
|
179
|
+
let leaf = leaves.get(target.host);
|
|
180
|
+
try {
|
|
181
|
+
if (!leaf) {
|
|
182
|
+
leaf = leafCertificate(target.host, ca);
|
|
183
|
+
leaves.set(target.host, leaf);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
socket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
|
188
|
+
handleError(error, { connectionId, phase: "certificate", emit });
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
let decryptedRequests = 0;
|
|
192
|
+
let tlsEstablished = false;
|
|
193
|
+
const secureServer = https.createServer(leaf, (decryptedRequest, decryptedResponse) => {
|
|
194
|
+
decryptedRequests++;
|
|
195
|
+
try {
|
|
196
|
+
const url = new URL(decryptedRequest.url, `https://${request.url}`);
|
|
197
|
+
forward(decryptedRequest, decryptedResponse, url, (error, details) => handleError(error, { ...details, connectionId, emit }), emit);
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
rejectRequest(decryptedResponse, error, emit, error?.code === "ERR_INVALID_URL" ? 400 : 502);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
secureServer.on("secureConnection", () => { tlsEstablished = true; });
|
|
204
|
+
secureServer.on("tlsClientError", (error) => handleError(error, { connectionId, phase: "tls", emit }));
|
|
205
|
+
secureServer.on("clientError", (error) => handleError(error, { connectionId, phase: "client", emit }));
|
|
206
|
+
interceptedSockets.add(socket);
|
|
207
|
+
socket.on("close", () => {
|
|
208
|
+
interceptedSockets.delete(socket);
|
|
209
|
+
emit?.({ type: "connect_end", connectionId, decryptedRequests, tlsEstablished });
|
|
210
|
+
});
|
|
211
|
+
socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
212
|
+
if (head.length)
|
|
213
|
+
socket.unshift(head);
|
|
214
|
+
secureServer.emit("connection", socket);
|
|
215
|
+
socket.resume();
|
|
216
|
+
}
|
|
217
|
+
function handleConnect(request, socket, head) {
|
|
218
|
+
let target;
|
|
219
|
+
try {
|
|
220
|
+
target = destination(request.url);
|
|
221
|
+
if (isSelf(target))
|
|
222
|
+
throw new Error("Proxy loop rejected.");
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
226
|
+
handleError(error);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (intercepting)
|
|
230
|
+
interceptTls(request, socket, head, target, activeEvents);
|
|
231
|
+
else
|
|
232
|
+
forwardTunnel(socket, head, target);
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
get address() { return server?.address() || null; },
|
|
236
|
+
async start() {
|
|
237
|
+
if (server)
|
|
238
|
+
return server.address();
|
|
239
|
+
const next = http.createServer(handleRequest);
|
|
240
|
+
next.on("connect", handleConnect);
|
|
241
|
+
next.on("connection", (socket) => {
|
|
242
|
+
sockets.add(socket);
|
|
243
|
+
socket.on("close", () => sockets.delete(socket));
|
|
244
|
+
});
|
|
245
|
+
try {
|
|
246
|
+
await new Promise((resolve, reject) => {
|
|
247
|
+
next.once("error", reject);
|
|
248
|
+
next.listen(port, host, () => {
|
|
249
|
+
next.off("error", reject);
|
|
250
|
+
resolve();
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
next.close();
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
server = next;
|
|
259
|
+
return server.address();
|
|
260
|
+
},
|
|
261
|
+
async beginInterception(emit = onEvent) {
|
|
262
|
+
if (intercepting)
|
|
263
|
+
throw new Error("Proxy interception is already active.");
|
|
264
|
+
ca = await loadCa();
|
|
265
|
+
leaves.clear();
|
|
266
|
+
activeEvents = emit;
|
|
267
|
+
intercepting = true;
|
|
268
|
+
await destroySockets(sockets);
|
|
269
|
+
},
|
|
270
|
+
async endInterception() {
|
|
271
|
+
intercepting = false;
|
|
272
|
+
activeEvents = null;
|
|
273
|
+
ca = null;
|
|
274
|
+
leaves.clear();
|
|
275
|
+
await destroySockets(interceptedSockets);
|
|
276
|
+
},
|
|
277
|
+
async stop() {
|
|
278
|
+
if (!server)
|
|
279
|
+
return;
|
|
280
|
+
const current = server;
|
|
281
|
+
server = null;
|
|
282
|
+
await this.endInterception();
|
|
283
|
+
await destroySockets(sockets);
|
|
284
|
+
await new Promise((resolve) => current.close(resolve));
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
}
|