rea-agents 0.3.0 → 0.4.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 +124 -20
- package/bridge/hopper_bridge.py +141 -13
- package/dist/application/AnalysisProvider.js +12 -1
- package/dist/application/ArtifactExtraction.js +166 -0
- package/dist/application/ArtifactGraphConstruction.js +257 -0
- package/dist/application/ArtifactInventory.js +253 -0
- package/dist/application/BinarySession.js +164 -14
- package/dist/application/CompositeProvider.js +73 -0
- package/dist/application/DirectAnalysis.js +27 -3
- package/dist/application/Doctor.js +35 -6
- package/dist/application/EnhancedTools.js +8 -7
- package/dist/application/EvidenceBundleCommands.js +29 -0
- package/dist/application/EvidenceBundleFiles.js +127 -0
- package/dist/application/EvidenceLedger.js +225 -18
- package/dist/application/FilesystemSnapshot.js +124 -0
- package/dist/application/LinuxHopper.js +186 -0
- package/dist/application/LoopbackReplay.js +195 -37
- package/dist/application/ProcessHarness.js +200 -191
- package/dist/application/ProcessNormalization.js +44 -0
- package/dist/application/ProcessOwnership.js +84 -0
- package/dist/application/ProcessSampling.js +284 -0
- package/dist/application/RealHopperAssertions.js +105 -0
- package/dist/application/ReferenceSourceImport.js +182 -0
- package/dist/application/ReferenceSourceImportEntries.js +122 -0
- package/dist/application/ReferenceSourceImportPolicy.js +73 -0
- package/dist/application/ReferenceSourceImportTypes.js +18 -0
- package/dist/application/ReferenceSourceVcsAdapter.js +34 -0
- package/dist/application/Setup.js +192 -41
- package/dist/application/Uninstall.js +130 -0
- package/dist/application/runtime.js +8 -1
- package/dist/artifacts/ArtifactPaths.js +51 -0
- package/dist/artifacts/ArtifactProvider.js +130 -0
- package/dist/artifacts/ArtifactReader.js +9 -0
- package/dist/artifacts/AsarArtifactReader.js +62 -0
- package/dist/artifacts/DirectoryArtifactReader.js +107 -0
- package/dist/artifacts/MachOSliceArtifactReader.js +66 -0
- package/dist/artifacts/SafeOutputTree.js +199 -0
- package/dist/artifacts/StreamBytes.js +10 -0
- package/dist/artifacts/ZipArtifactReader.js +109 -0
- package/dist/cli.js +149 -2
- package/dist/config.js +35 -1
- package/dist/contracts/artifactComparisonExample.js +95 -0
- package/dist/contracts/artifactToolContracts.js +84 -0
- package/dist/contracts/enhancedInputs.js +4 -0
- package/dist/contracts/functionComparisonExample.js +57 -0
- package/dist/contracts/investigationExamples.js +118 -0
- package/dist/contracts/nativeToolContracts.js +52 -0
- package/dist/contracts/processCaptureExample.js +25 -0
- package/dist/contracts/toolContractExamples.js +69 -0
- package/dist/contracts/toolContracts.js +99 -36
- package/dist/contracts/toolOutputSchemas.js +154 -82
- package/dist/contracts/unknownContractExamples.js +33 -0
- package/dist/domain/artifactComparison.js +273 -0
- package/dist/domain/artifactGraph.js +194 -0
- package/dist/domain/artifactInventoryEvidence.js +150 -0
- package/dist/domain/binaryTarget.js +55 -0
- package/dist/domain/bundleComparison.js +266 -0
- package/dist/domain/callPath.js +346 -0
- package/dist/domain/changedBehavior.js +294 -0
- package/dist/domain/errors.js +192 -4
- package/dist/domain/evidence.js +41 -10
- package/dist/domain/evidenceBundle.js +187 -6
- package/dist/domain/functionComparison.js +201 -0
- package/dist/domain/functionComparisonNormalization.js +112 -0
- package/dist/domain/functionComparisonResults.js +54 -0
- package/dist/domain/functionComparisonSchemas.js +82 -0
- package/dist/domain/functionDossierEvidence.js +171 -0
- package/dist/domain/hopperValues.js +14 -7
- package/dist/domain/nativeInspection.js +142 -0
- package/dist/domain/processCapture.js +152 -54
- package/dist/domain/processComparison.js +106 -0
- package/dist/domain/reconstructionUnknowns.js +90 -0
- package/dist/domain/reconstructionVerification.js +285 -0
- package/dist/domain/reconstructionVerificationSchemas.js +126 -0
- package/dist/domain/referenceSourceClassification.js +496 -0
- package/dist/domain/referenceSourceGraph.js +376 -0
- package/dist/domain/referenceSourceImportParsing.js +235 -0
- package/dist/domain/referenceSourcePolicy.js +1 -0
- package/dist/domain/residualUnknown.js +239 -0
- package/dist/domain/staticRuntimeCorrelation.js +375 -0
- package/dist/hopper/BridgeLauncher.js +40 -3
- package/dist/hopper/HopperClient.js +14 -5
- package/dist/hopper/HopperProvider.js +57 -22
- package/dist/identity.js +1 -0
- package/dist/main.js +5 -1
- package/dist/native/CommandRunner.js +156 -0
- package/dist/native/NativeMacOSProvider.js +306 -0
- package/dist/native/NativeMachoInspection.js +135 -0
- package/dist/native/parsers/codesign.js +55 -0
- package/dist/native/parsers/demangle.js +26 -0
- package/dist/native/parsers/dyldInfo.js +25 -0
- package/dist/native/parsers/lipo.js +67 -0
- package/dist/native/parsers/otool.js +193 -0
- package/dist/native/parsers/plist.js +23 -0
- package/dist/reference/ReferenceSourceReader.js +73 -0
- package/dist/reference/ReferenceSourceReaderEntries.js +206 -0
- package/dist/reference/ReferenceSourceReaderErrors.js +19 -0
- package/dist/reference/ReferenceSourceReaderFile.js +119 -0
- package/dist/reference/ReferenceSourceReaderPaths.js +23 -0
- package/dist/reference/ReferenceSourceReaderTypes.js +2 -0
- package/dist/reference/ReferenceSourceReaderValidate.js +71 -0
- package/dist/server/createServer.js +31 -5
- package/dist/server/recordDerivedEvidence.js +10 -0
- package/dist/server/registerArtifactComparisonTool.js +62 -0
- package/dist/server/registerArtifactTools.js +6 -0
- package/dist/server/registerBundleComparisonTool.js +47 -0
- package/dist/server/registerEnhancedTools.js +64 -12
- package/dist/server/registerEvidenceTools.js +36 -0
- package/dist/server/registerFunctionComparisonTool.js +68 -0
- package/dist/server/registerInvestigationTools.js +224 -0
- package/dist/server/registerNativeTools.js +6 -0
- package/dist/server/registerOfficialTools.js +47 -14
- package/dist/server/registerProcessComparisonTool.js +106 -0
- package/dist/server/registerSessionTools.js +179 -70
- package/dist/server/sessionEvidence.js +28 -0
- package/dist/server/sessionToolPolicies.js +64 -0
- package/dist/server/toolRegistrationOptions.js +7 -0
- package/dist/server/toolResult.js +8 -5
- package/install.sh +198 -0
- package/package.json +18 -1
- package/scripts/rea.mjs +5 -1
- package/skills/rea-analysis/SKILL.md +77 -2
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
const RELEASES_URL = "https://www.hopperapp.com/include/files-api.php?request=releases&public=true";
|
|
10
|
+
const DOWNLOAD_PREFIX = "https://www.hopperapp.com:443/downloader/public/";
|
|
11
|
+
const MAX_PACKAGE_BYTES = 100_000_000;
|
|
12
|
+
/** Parse an os-release document without executing its shell syntax. */
|
|
13
|
+
export const parseLinuxDistribution = (text) => {
|
|
14
|
+
const fields = new Map();
|
|
15
|
+
for (const line of text.split("\n")) {
|
|
16
|
+
const match = /^([A-Z_]+)=(.*)$/u.exec(line.trim());
|
|
17
|
+
if (match === null)
|
|
18
|
+
continue;
|
|
19
|
+
const key = match[1];
|
|
20
|
+
const encoded = match[2];
|
|
21
|
+
if (key === undefined || encoded === undefined)
|
|
22
|
+
continue;
|
|
23
|
+
const value = encoded.startsWith('"') && encoded.endsWith('"')
|
|
24
|
+
? encoded.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\\\", "\\")
|
|
25
|
+
: encoded;
|
|
26
|
+
fields.set(key, value);
|
|
27
|
+
}
|
|
28
|
+
const id = (fields.get("ID") ?? "unknown").toLowerCase();
|
|
29
|
+
const versionId = fields.get("VERSION_ID");
|
|
30
|
+
const packageFamily = packageFamilyFor(id, fields.get("ID_LIKE"));
|
|
31
|
+
const supported = id === "arch" ||
|
|
32
|
+
(id === "ubuntu" && versionAtLeast(versionId, 24)) ||
|
|
33
|
+
(id === "fedora" && versionAtLeast(versionId, 41));
|
|
34
|
+
return {
|
|
35
|
+
id,
|
|
36
|
+
...(versionId === undefined ? {} : { versionId }),
|
|
37
|
+
packageFamily,
|
|
38
|
+
supported,
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
/** Read and classify the current Linux distribution. */
|
|
42
|
+
export const readLinuxDistribution = async () => {
|
|
43
|
+
try {
|
|
44
|
+
return parseLinuxDistribution(await readFile("/etc/os-release", "utf8"));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
/** Download, verify, install, and read back the official Hopper Linux package. */
|
|
51
|
+
export const installLinuxHopper = async (host = systemLinuxHopperInstallHost(), options = {}) => {
|
|
52
|
+
if (options.signal?.aborted === true)
|
|
53
|
+
return { status: "failed", reason: "cancelled" };
|
|
54
|
+
const distribution = await host.distribution();
|
|
55
|
+
if (distribution?.supported !== true ||
|
|
56
|
+
distribution.packageFamily === undefined)
|
|
57
|
+
return { status: "failed", reason: "unsupported_host" };
|
|
58
|
+
let temporary;
|
|
59
|
+
try {
|
|
60
|
+
const metadataDownload = await host.download(RELEASES_URL, options);
|
|
61
|
+
if (!metadataDownload.ok)
|
|
62
|
+
return { status: "failed", reason: "release_metadata" };
|
|
63
|
+
const releases = parseReleases(metadataDownload.bytes);
|
|
64
|
+
if (releases === undefined)
|
|
65
|
+
return { status: "failed", reason: "release_metadata" };
|
|
66
|
+
const release = releases[releaseKey(distribution.packageFamily)];
|
|
67
|
+
if (!release.filename.startsWith(DOWNLOAD_PREFIX))
|
|
68
|
+
return { status: "failed", reason: "release_metadata" };
|
|
69
|
+
const archiveDownload = await host.download(release.filename, options);
|
|
70
|
+
if (!archiveDownload.ok)
|
|
71
|
+
return { status: "failed", reason: "download" };
|
|
72
|
+
if (!packageIntegrityMatches(archiveDownload.bytes, release))
|
|
73
|
+
return { status: "failed", reason: "integrity" };
|
|
74
|
+
temporary = await host.createTemporaryDirectory();
|
|
75
|
+
const archive = join(temporary, `hopper.${distribution.packageFamily}`);
|
|
76
|
+
await host.writeArchive(archive, archiveDownload.bytes);
|
|
77
|
+
if (!(await host.installPackage(distribution.packageFamily, archive)))
|
|
78
|
+
return { status: "failed", reason: "authorization_or_package_manager" };
|
|
79
|
+
const launcherPath = "/opt/hopper/bin/Hopper";
|
|
80
|
+
if (!(await host.launcherReady(launcherPath)))
|
|
81
|
+
return { status: "failed", reason: "launcher_missing" };
|
|
82
|
+
return { status: "installed", launcherPath };
|
|
83
|
+
}
|
|
84
|
+
catch (cause) {
|
|
85
|
+
if (isAbortCause(cause))
|
|
86
|
+
return { status: "failed", reason: "cancelled" };
|
|
87
|
+
return { status: "failed", reason: "download" };
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
if (temporary !== undefined)
|
|
91
|
+
await host.cleanup(temporary);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
/** Canonical launcher path for a legacy user-local Linux Hopper installation. */
|
|
95
|
+
export const linuxHopperLauncherPath = (home) => join(home, ".local/share/rea/hopper/bin/Hopper");
|
|
96
|
+
const systemLinuxHopperInstallHost = () => ({
|
|
97
|
+
distribution: readLinuxDistribution,
|
|
98
|
+
async download(url, options) {
|
|
99
|
+
const response = await fetch(url, {
|
|
100
|
+
headers: { accept: "application/json", "user-agent": "rea-installer" },
|
|
101
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
102
|
+
});
|
|
103
|
+
return {
|
|
104
|
+
ok: response.ok,
|
|
105
|
+
bytes: new Uint8Array(await response.arrayBuffer()),
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-")),
|
|
109
|
+
writeArchive: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }),
|
|
110
|
+
installPackage: installSystemPackage,
|
|
111
|
+
async launcherReady(path) {
|
|
112
|
+
try {
|
|
113
|
+
await access(path);
|
|
114
|
+
const linked = await execFileAsync("ldd", [path]);
|
|
115
|
+
return linuxSharedLibrariesAvailable(`${linked.stdout}\n${linked.stderr}`);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
cleanup: (path) => rm(path, { recursive: true, force: true }),
|
|
122
|
+
});
|
|
123
|
+
/** Interpret ldd output conservatively so a present but broken launcher is unhealthy. */
|
|
124
|
+
export const linuxSharedLibrariesAvailable = (output) => !/(?:=>\s+not found|error while loading shared libraries)/iu.test(output);
|
|
125
|
+
const installSystemPackage = async (family, archive) => {
|
|
126
|
+
try {
|
|
127
|
+
const command = linuxPackageManagerCommand(family, archive, process.getuid?.() === 0);
|
|
128
|
+
await execFileAsync(command.executable, command.args);
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
/** Select the native package manager and authorization boundary without shell evaluation. */
|
|
136
|
+
export const linuxPackageManagerCommand = (family, archive, isRoot) => {
|
|
137
|
+
const command = family === "deb"
|
|
138
|
+
? ["apt-get", "install", "-y", archive]
|
|
139
|
+
: family === "rpm"
|
|
140
|
+
? ["dnf", "install", "-y", archive]
|
|
141
|
+
: ["pacman", "-U", "--noconfirm", archive];
|
|
142
|
+
const executable = isRoot ? command[0] : "pkexec";
|
|
143
|
+
if (executable === undefined)
|
|
144
|
+
throw new Error("package command is empty");
|
|
145
|
+
return { executable, args: isRoot ? command.slice(1) : command };
|
|
146
|
+
};
|
|
147
|
+
const parseReleases = (bytes) => {
|
|
148
|
+
try {
|
|
149
|
+
const parsed = JSON.parse(new TextDecoder().decode(bytes));
|
|
150
|
+
const result = releasesSchema.safeParse(parsed);
|
|
151
|
+
return result.success ? result.data : undefined;
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
const packageIntegrityMatches = (bytes, release) => bytes.byteLength === Number(release.file_length) &&
|
|
158
|
+
bytes.byteLength <= MAX_PACKAGE_BYTES &&
|
|
159
|
+
createHash("sha1").update(bytes).digest("hex") === release.file_hash;
|
|
160
|
+
const packageFamilyFor = (id, idLike) => {
|
|
161
|
+
const identities = new Set([
|
|
162
|
+
id,
|
|
163
|
+
...(idLike?.toLowerCase().split(/\s+/u) ?? []),
|
|
164
|
+
]);
|
|
165
|
+
if (identities.has("ubuntu") || identities.has("debian"))
|
|
166
|
+
return "deb";
|
|
167
|
+
if (identities.has("fedora") || identities.has("rhel"))
|
|
168
|
+
return "rpm";
|
|
169
|
+
if (identities.has("arch"))
|
|
170
|
+
return "arch";
|
|
171
|
+
return undefined;
|
|
172
|
+
};
|
|
173
|
+
const versionAtLeast = (version, minimum) => version !== undefined &&
|
|
174
|
+
Number.parseInt(version.split(".")[0] ?? "0", 10) >= minimum;
|
|
175
|
+
const releaseKey = (family) => family === "deb" ? "Ubuntu / Mint" : family === "rpm" ? "Fedora" : "Arch";
|
|
176
|
+
const isAbortCause = (cause) => cause instanceof Error && cause.name === "AbortError";
|
|
177
|
+
const releaseSchema = z.object({
|
|
178
|
+
filename: z.string().url(),
|
|
179
|
+
file_length: z.string().regex(/^\d+$/u),
|
|
180
|
+
file_hash: z.string().regex(/^[a-f0-9]{40}$/u),
|
|
181
|
+
});
|
|
182
|
+
const releasesSchema = z.object({
|
|
183
|
+
"Ubuntu / Mint": releaseSchema,
|
|
184
|
+
Fedora: releaseSchema,
|
|
185
|
+
Arch: releaseSchema,
|
|
186
|
+
});
|
|
@@ -1,34 +1,171 @@
|
|
|
1
|
-
import { createServer } from "node:http";
|
|
1
|
+
import { createServer, } from "node:http";
|
|
2
2
|
import { WebSocketServer } from "ws";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
3
|
+
class ReplayRecorder {
|
|
4
|
+
scenario;
|
|
5
|
+
events = [];
|
|
6
|
+
started = Date.now();
|
|
7
|
+
truncated = false;
|
|
8
|
+
constructor(scenario) {
|
|
9
|
+
this.scenario = scenario;
|
|
10
|
+
}
|
|
11
|
+
atMs() {
|
|
12
|
+
return (Math.floor((Date.now() - this.started) /
|
|
13
|
+
this.scenario.normalization.time_bucket_ms) * this.scenario.normalization.time_bucket_ms);
|
|
14
|
+
}
|
|
15
|
+
record(event) {
|
|
16
|
+
if (this.events.length < this.scenario.limits.protocol_events)
|
|
17
|
+
this.events.push({ sequence: this.events.length, ...event });
|
|
18
|
+
else
|
|
19
|
+
this.truncated = true;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const readRequestBody = async (request, limit) => {
|
|
23
|
+
const chunks = [];
|
|
24
|
+
let bytes = 0;
|
|
25
|
+
for await (const chunk of request) {
|
|
26
|
+
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
27
|
+
bytes += value.byteLength;
|
|
28
|
+
if (bytes > limit)
|
|
29
|
+
return undefined;
|
|
30
|
+
chunks.push(value);
|
|
31
|
+
}
|
|
32
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
33
|
+
};
|
|
34
|
+
const createHttpHandler = (recorder) => {
|
|
35
|
+
const calls = new Map();
|
|
36
|
+
return async (request, response) => {
|
|
14
37
|
const method = request.method ?? "GET";
|
|
15
38
|
const path = new URL(request.url ?? "/", "http://127.0.0.1").pathname;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
39
|
+
const body = await readRequestBody(request, recorder.scenario.limits.protocol_body_bytes);
|
|
40
|
+
if (body === undefined) {
|
|
41
|
+
recorder.truncated = true;
|
|
42
|
+
response.statusCode = 413;
|
|
43
|
+
response.end();
|
|
44
|
+
recorder.record({
|
|
45
|
+
at_ms: recorder.atMs(),
|
|
46
|
+
protocol: "http",
|
|
47
|
+
direction: "request",
|
|
48
|
+
method,
|
|
49
|
+
path,
|
|
50
|
+
data: "<body-over-limit>",
|
|
51
|
+
outcome: "unmatched",
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const index = recorder.scenario.replay.http.findIndex((route) => {
|
|
56
|
+
if (route.method !== method || route.path !== path)
|
|
57
|
+
return false;
|
|
58
|
+
if (route.request_body !== undefined && route.request_body !== body)
|
|
59
|
+
return false;
|
|
60
|
+
return Object.entries(route.request_headers).every(([name, value]) => request.headers[name.toLowerCase()] === value);
|
|
61
|
+
});
|
|
62
|
+
const route = recorder.scenario.replay.http[index];
|
|
63
|
+
const used = index < 0 ? 0 : (calls.get(index) ?? 0);
|
|
64
|
+
const outcome = route === undefined
|
|
65
|
+
? "unmatched"
|
|
66
|
+
: used >= route.max_calls
|
|
67
|
+
? "script_exhausted"
|
|
68
|
+
: "matched";
|
|
69
|
+
recorder.record({
|
|
70
|
+
at_ms: recorder.atMs(),
|
|
71
|
+
protocol: "http",
|
|
72
|
+
direction: "request",
|
|
73
|
+
method,
|
|
74
|
+
path,
|
|
75
|
+
data: body.length === 0 ? "" : "<redacted-request-body>",
|
|
76
|
+
outcome,
|
|
77
|
+
});
|
|
78
|
+
if (route === undefined || outcome === "script_exhausted") {
|
|
79
|
+
response.statusCode = route === undefined ? 404 : 409;
|
|
80
|
+
response.end();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
calls.set(index, used + 1);
|
|
84
|
+
if (route.delay_ms > 0)
|
|
85
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, route.delay_ms));
|
|
86
|
+
if (route.disconnect) {
|
|
87
|
+
response.destroy();
|
|
88
|
+
recorder.record({
|
|
89
|
+
at_ms: recorder.atMs(),
|
|
90
|
+
protocol: "http",
|
|
91
|
+
direction: "response",
|
|
92
|
+
method,
|
|
93
|
+
path,
|
|
94
|
+
data: "",
|
|
95
|
+
outcome: "disconnected",
|
|
96
|
+
});
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
response.statusCode = route.status;
|
|
100
|
+
for (const [name, value] of Object.entries(route.response_headers))
|
|
101
|
+
response.setHeader(name, value);
|
|
102
|
+
response.end(route.body);
|
|
103
|
+
recorder.record({
|
|
104
|
+
at_ms: recorder.atMs(),
|
|
22
105
|
protocol: "http",
|
|
23
106
|
direction: "response",
|
|
24
107
|
method,
|
|
25
108
|
path,
|
|
26
|
-
data: body,
|
|
109
|
+
data: route.body,
|
|
110
|
+
outcome: "matched",
|
|
27
111
|
});
|
|
28
|
-
}
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
const sendWebSocketScript = async (client, connection, recorder) => {
|
|
115
|
+
const scripts = recorder.scenario.replay.websocket_connections;
|
|
116
|
+
const script = scripts[connection - 1];
|
|
117
|
+
if (scripts.length > 0 && script === undefined) {
|
|
118
|
+
recorder.record({
|
|
119
|
+
at_ms: recorder.atMs(),
|
|
120
|
+
protocol: "websocket",
|
|
121
|
+
direction: "sent",
|
|
122
|
+
method: null,
|
|
123
|
+
path: "/ws",
|
|
124
|
+
data: "",
|
|
125
|
+
outcome: "script_exhausted",
|
|
126
|
+
});
|
|
127
|
+
client.terminate();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const messages = script?.messages ??
|
|
131
|
+
recorder.scenario.replay.websocket_messages.map((data) => ({
|
|
132
|
+
data,
|
|
133
|
+
delay_ms: 0,
|
|
134
|
+
}));
|
|
135
|
+
for (const message of messages) {
|
|
136
|
+
if (message.delay_ms > 0)
|
|
137
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, message.delay_ms));
|
|
138
|
+
if (client.readyState !== 1)
|
|
139
|
+
return;
|
|
140
|
+
client.send(message.data);
|
|
141
|
+
recorder.record({
|
|
142
|
+
at_ms: recorder.atMs(),
|
|
143
|
+
protocol: "websocket",
|
|
144
|
+
direction: "sent",
|
|
145
|
+
method: null,
|
|
146
|
+
path: "/ws",
|
|
147
|
+
data: message.data,
|
|
148
|
+
outcome: "matched",
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
if (script?.disconnect_after === true) {
|
|
152
|
+
client.close();
|
|
153
|
+
recorder.record({
|
|
154
|
+
at_ms: recorder.atMs(),
|
|
155
|
+
protocol: "websocket",
|
|
156
|
+
direction: "sent",
|
|
157
|
+
method: null,
|
|
158
|
+
path: "/ws",
|
|
159
|
+
data: "",
|
|
160
|
+
outcome: "disconnected",
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const createWebSocketReplay = (server, recorder) => {
|
|
165
|
+
let connections = 0;
|
|
29
166
|
const websocket = new WebSocketServer({
|
|
30
167
|
noServer: true,
|
|
31
|
-
maxPayload:
|
|
168
|
+
maxPayload: recorder.scenario.limits.protocol_body_bytes,
|
|
32
169
|
clientTracking: true,
|
|
33
170
|
});
|
|
34
171
|
server.on("upgrade", (request, socket, head) => {
|
|
@@ -39,25 +176,27 @@ export const startLoopbackReplay = async (scenario) => {
|
|
|
39
176
|
websocket.handleUpgrade(request, socket, head, (client) => websocket.emit("connection", client, request));
|
|
40
177
|
});
|
|
41
178
|
websocket.on("connection", (client) => {
|
|
179
|
+
connections += 1;
|
|
180
|
+
if (connections > recorder.scenario.limits.connections) {
|
|
181
|
+
recorder.truncated = true;
|
|
182
|
+
client.terminate();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
42
185
|
client.on("error", () => undefined);
|
|
43
|
-
client.on("message", (value) => record({
|
|
186
|
+
client.on("message", (value) => recorder.record({
|
|
187
|
+
at_ms: recorder.atMs(),
|
|
44
188
|
protocol: "websocket",
|
|
45
189
|
direction: "received",
|
|
46
190
|
method: null,
|
|
47
191
|
path: "/ws",
|
|
48
192
|
data: value.toString(),
|
|
193
|
+
outcome: "matched",
|
|
49
194
|
}));
|
|
50
|
-
|
|
51
|
-
client.send(message);
|
|
52
|
-
record({
|
|
53
|
-
protocol: "websocket",
|
|
54
|
-
direction: "sent",
|
|
55
|
-
method: null,
|
|
56
|
-
path: "/ws",
|
|
57
|
-
data: message,
|
|
58
|
-
});
|
|
59
|
-
}
|
|
195
|
+
void sendWebSocketScript(client, connections, recorder);
|
|
60
196
|
});
|
|
197
|
+
return websocket;
|
|
198
|
+
};
|
|
199
|
+
const listen = async (server) => {
|
|
61
200
|
await new Promise((resolveListen, rejectListen) => {
|
|
62
201
|
server.once("error", rejectListen);
|
|
63
202
|
server.listen(0, "127.0.0.1", () => {
|
|
@@ -66,14 +205,33 @@ export const startLoopbackReplay = async (scenario) => {
|
|
|
66
205
|
});
|
|
67
206
|
});
|
|
68
207
|
const address = server.address();
|
|
69
|
-
if (address === null || typeof address === "string")
|
|
70
|
-
await closeServer(server);
|
|
208
|
+
if (address === null || typeof address === "string")
|
|
71
209
|
throw new Error("loopback replay did not acquire a TCP port");
|
|
210
|
+
return address.port;
|
|
211
|
+
};
|
|
212
|
+
const closeServer = (server) => new Promise((resolveClose, rejectClose) => {
|
|
213
|
+
server.close((error) => error === undefined ? resolveClose() : rejectClose(error));
|
|
214
|
+
});
|
|
215
|
+
/** Start bounded HTTP and WebSocket replay endpoints on IPv4 loopback only. */
|
|
216
|
+
export const startLoopbackReplay = async (scenario) => {
|
|
217
|
+
const recorder = new ReplayRecorder(scenario);
|
|
218
|
+
const server = createServer(createHttpHandler(recorder));
|
|
219
|
+
const websocket = createWebSocketReplay(server, recorder);
|
|
220
|
+
let port;
|
|
221
|
+
try {
|
|
222
|
+
port = await listen(server);
|
|
223
|
+
}
|
|
224
|
+
catch (cause) {
|
|
225
|
+
await closeServer(server).catch(() => undefined);
|
|
226
|
+
throw cause;
|
|
72
227
|
}
|
|
73
228
|
return {
|
|
74
|
-
httpUrl: `http://127.0.0.1:${String(
|
|
75
|
-
websocketUrl: `ws://127.0.0.1:${String(
|
|
76
|
-
events,
|
|
229
|
+
httpUrl: `http://127.0.0.1:${String(port)}`,
|
|
230
|
+
websocketUrl: `ws://127.0.0.1:${String(port)}/ws`,
|
|
231
|
+
events: recorder.events,
|
|
232
|
+
get truncated() {
|
|
233
|
+
return recorder.truncated;
|
|
234
|
+
},
|
|
77
235
|
async close() {
|
|
78
236
|
for (const client of websocket.clients)
|
|
79
237
|
client.terminate();
|