Skip to content

Commit eda267c

Browse files
committed
Tighten benchmark safety planning
Resolve public-looking benchmark targets through DNS before applying the safety gate, and keep unresolved or mixed-public answers in the public tier. Require unsafe public-target overrides to name the target on the command line and to set load and duration explicitly. Make dry-run mode perform discovery planning for runnable scenarios while still avoiding benchmark load, so inbox plans show the resolved actor and inbox destination before a real run. #744 #784 Assisted-by: Codex:gpt-5.5
1 parent a2a6799 commit eda267c

6 files changed

Lines changed: 432 additions & 27 deletions

File tree

packages/cli/src/bench/action.test.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ async function spawnTarget() {
1919
benchmarkMode: true,
2020
});
2121
let keyPairs: CryptoKeyPair[] | undefined;
22+
const requests: { method: string; path: string }[] = [];
2223
federation
2324
.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
2425
if (identifier !== "alice") return null;
@@ -51,6 +52,8 @@ async function spawnTarget() {
5152
hostname: "127.0.0.1",
5253
silent: true,
5354
fetch: (request: Request) => {
55+
const url = new URL(request.url);
56+
requests.push({ method: request.method, path: url.pathname });
5457
if (request.method === "POST") {
5558
inboxUserAgent = request.headers.get("user-agent");
5659
}
@@ -61,6 +64,7 @@ async function spawnTarget() {
6164
return {
6265
url: new URL(server.url!),
6366
inboxUserAgent: () => inboxUserAgent,
67+
requests: () => requests.slice(),
6468
close: () => server.close(true),
6569
};
6670
}
@@ -213,12 +217,48 @@ test("runBench - dry run prints a plan and sends nothing", async () => {
213217
});
214218
assert.strictEqual(code, 0);
215219
assert.match(output, /dry run/i);
216-
assert.match(output, /No requests were sent/);
220+
assert.match(output, /\/inbox/);
221+
assert.match(output, /No benchmark load was sent/);
222+
const requests = target.requests();
223+
assert.ok(requests.some((r) => r.method === "GET"));
224+
assert.ok(!requests.some((r) => r.method === "POST"));
217225
} finally {
218226
await target.close();
219227
}
220228
});
221229

230+
test("runBench - unsafe override requires an explicit CLI target", async () => {
231+
const file = await writeSuite(`version: 1
232+
target: https://example.com
233+
scenarios:
234+
- name: wf
235+
type: webfinger
236+
recipient: "acct:alice@example.com"
237+
load: { rate: 1/s }
238+
duration: 1ms
239+
`);
240+
let code = -1;
241+
let message = "";
242+
await runBench(command({ scenario: file, allowUnsafeTarget: true }), {
243+
exit: (c) => {
244+
code = c;
245+
},
246+
writeOutput: () => Promise.resolve(),
247+
log: (m) => {
248+
message = m;
249+
},
250+
fetch: (input) => {
251+
const url = new URL(input instanceof Request ? input.url : input);
252+
if (url.pathname.includes("/bench/stats")) {
253+
return Promise.resolve(new Response("not found", { status: 404 }));
254+
}
255+
return Promise.resolve(new Response("ok"));
256+
},
257+
});
258+
assert.strictEqual(code, 2);
259+
assert.match(message, /--target/);
260+
});
261+
222262
test("runBench - refuses an unsafe public target (exit 2)", async () => {
223263
const file = await writeSuite(`version: 1
224264
target: https://example.com

packages/cli/src/bench/action.ts

Lines changed: 165 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { writeFile } from "node:fs/promises";
2+
import type { DocumentLoader } from "@fedify/vocab-runtime";
23
import process from "node:process";
34
import { getContextLoader, getDocumentLoader } from "../docloader.ts";
45
import { buildFleet } from "./actor/fleet.ts";
56
import type { BenchCommand } from "./command.ts";
7+
import { discoverInbox, selectInbox } from "./discovery/discover.ts";
68
import {
79
buildReport,
810
buildScenarioResult,
@@ -18,20 +20,25 @@ import {
1820
type ResolvedScenario,
1921
type ResolvedSuite,
2022
} from "./scenario/normalize.ts";
21-
import type { Suite } from "./scenario/types.ts";
23+
import type { LoadConfig, Suite } from "./scenario/types.ts";
2224
import { validateSuite } from "./scenario/validate.ts";
2325
import {
2426
assertInboxDestinationAllowed,
2527
assertTargetAllowed,
28+
assertUnsafeOverrideAllowed,
2629
UnsafeTargetError,
2730
} from "./safety/gate.ts";
28-
import { classifyTarget } from "./safety/tiers.ts";
31+
import {
32+
classifyResolvedTarget,
33+
type ResolveTargetAddresses,
34+
} from "./safety/tiers.ts";
2935
import { runnerFor } from "./scenarios/registry.ts";
3036
import {
3137
resolveAdvertiseHost,
3238
spawnSyntheticServer,
3339
type SyntheticServer,
3440
} from "./server/synthetic.ts";
41+
import { convertUrlIfHandle } from "../webfinger/lib.ts";
3542

3643
/** Injectable dependencies for {@link runBench}, overridable in tests. */
3744
export interface RunBenchDeps {
@@ -46,6 +53,8 @@ export interface RunBenchDeps {
4653
readonly log?: (message: string) => void;
4754
/** Fetch implementation. */
4855
readonly fetch?: typeof fetch;
56+
/** Hostname resolver used for target risk classification. */
57+
readonly resolveTargetAddresses?: ResolveTargetAddresses;
4958
}
5059

5160
/** The scenario types that need the synthetic actor/key server. */
@@ -109,19 +118,26 @@ export default async function runBench(
109118
return void exit(2);
110119
}
111120

112-
if (command.dryRun) {
113-
await writeOutput(renderPlan(suite), command.output);
114-
return void exit(0);
115-
}
116-
117-
const tier = classifyTarget(suite.target);
121+
const tier = await classifyResolvedTarget(
122+
suite.target,
123+
deps.resolveTargetAddresses,
124+
);
118125
const probe = await probeBenchmarkMode(suite.target, fetchImpl);
119126
try {
127+
if (!command.dryRun) {
128+
assertUnsafeOverrideAllowed({
129+
tier,
130+
benchmarkMode: probe.benchmarkMode,
131+
allowUnsafe: command.allowUnsafeTarget,
132+
explicitCliTarget: command.target != null,
133+
scenarios: unsafeOverrideScenarios(validated),
134+
});
135+
}
120136
assertTargetAllowed({
121137
tier,
122138
benchmarkMode: probe.benchmarkMode,
123139
allowUnsafe: command.allowUnsafeTarget,
124-
dryRun: false,
140+
dryRun: command.dryRun,
125141
});
126142
} catch (error) {
127143
if (error instanceof UnsafeTargetError) {
@@ -136,20 +152,6 @@ export default async function runBench(
136152
// same-machine (loopback) target; a non-loopback target needs an advertised,
137153
// reachable host (--advertise-host). Without one, refuse signed scenarios
138154
// rather than let every signed delivery fail key lookup.
139-
if (
140-
tier !== "loopback" && command.advertiseHost == null &&
141-
suite.scenarios.some((s) => SIGNED_TYPES.has(s.type))
142-
) {
143-
log(
144-
"Signed scenarios (inbox) need the benchmark's synthetic actor server to " +
145-
"be reachable from the target. A loopback target reaches it " +
146-
"automatically; for a non-loopback target, pass --advertise-host with " +
147-
"an address the target can reach (the synthetic server then binds all " +
148-
"interfaces), or use a read scenario such as webfinger.",
149-
);
150-
return void exit(2);
151-
}
152-
153155
const allowPrivateAddress = tier !== "public";
154156
const documentLoader = await getDocumentLoader({
155157
allowPrivateAddress,
@@ -170,6 +172,43 @@ export default async function runBench(
170172
advertised: command.advertiseHost != null,
171173
});
172174

175+
if (command.dryRun) {
176+
try {
177+
await writeOutput(
178+
await renderPlan(suite, {
179+
documentLoader,
180+
contextLoader,
181+
allowPrivateAddress,
182+
assertDestinationAllowed,
183+
}),
184+
command.output,
185+
);
186+
return void exit(0);
187+
} catch (error) {
188+
log(error instanceof Error ? error.message : String(error));
189+
return void exit(2);
190+
}
191+
}
192+
193+
// The target dereferences the synthetic actor server while verifying
194+
// signatures. By default that server is loopback-only, reachable just by a
195+
// same-machine (loopback) target; a non-loopback target needs an advertised,
196+
// reachable host (--advertise-host). Without one, refuse signed scenarios
197+
// rather than let every signed delivery fail key lookup.
198+
if (
199+
tier !== "loopback" && command.advertiseHost == null &&
200+
suite.scenarios.some((s) => SIGNED_TYPES.has(s.type))
201+
) {
202+
log(
203+
"Signed scenarios (inbox) need the benchmark's synthetic actor server to " +
204+
"be reachable from the target. A loopback target reaches it " +
205+
"automatically; for a non-loopback target, pass --advertise-host with " +
206+
"an address the target can reach (the synthetic server then binds all " +
207+
"interfaces), or use a read scenario such as webfinger.",
208+
);
209+
return void exit(2);
210+
}
211+
173212
let fleet: SyntheticServer | undefined;
174213
const startedAt = new Date().toISOString();
175214
try {
@@ -278,7 +317,17 @@ async function defaultWriteOutput(
278317
await writeFile(outputPath, content, { encoding: "utf-8" });
279318
}
280319

281-
function renderPlan(suite: ResolvedSuite): string {
320+
interface DryRunPlanContext {
321+
readonly documentLoader: DocumentLoader;
322+
readonly contextLoader: DocumentLoader;
323+
readonly allowPrivateAddress: boolean;
324+
readonly assertDestinationAllowed: (url: URL) => void;
325+
}
326+
327+
async function renderPlan(
328+
suite: ResolvedSuite,
329+
context: DryRunPlanContext,
330+
): Promise<string> {
282331
const lines = [
283332
"Fedify benchmark plan (dry run)",
284333
"",
@@ -289,8 +338,13 @@ function renderPlan(suite: ResolvedSuite): string {
289338
lines.push(
290339
`- ${scenario.name} (${scenario.type}): ${describePlan(scenario)}`,
291340
);
341+
lines.push(...await describeDiscoveryPlan(scenario, suite, context));
292342
}
293-
lines.push("", "No requests were sent.");
343+
lines.push(
344+
"",
345+
"No benchmark load was sent. Discovery and stats probe requests may " +
346+
"have been sent.",
347+
);
294348
return `${lines.join("\n")}\n`;
295349
}
296350

@@ -300,3 +354,89 @@ function describePlan(scenario: ResolvedScenario): string {
300354
: `closed-loop concurrency ${scenario.load.concurrency}`;
301355
return `${load}, duration ${scenario.durationMs}ms, signing ${scenario.signing}`;
302356
}
357+
358+
async function describeDiscoveryPlan(
359+
scenario: ResolvedScenario,
360+
suite: ResolvedSuite,
361+
context: DryRunPlanContext,
362+
): Promise<string[]> {
363+
switch (scenario.type) {
364+
case "inbox":
365+
return await describeInboxDiscoveryPlan(scenario, context);
366+
case "webfinger":
367+
return describeWebFingerPlan(scenario, suite.target);
368+
default:
369+
return [" discovery: not available for this scenario type"];
370+
}
371+
}
372+
373+
async function describeInboxDiscoveryPlan(
374+
scenario: ResolvedScenario,
375+
context: DryRunPlanContext,
376+
): Promise<string[]> {
377+
const lines: string[] = [];
378+
for (const recipient of scenario.recipients) {
379+
const discovered = await discoverInbox(recipient, {
380+
documentLoader: context.documentLoader,
381+
contextLoader: context.contextLoader,
382+
allowPrivateAddress: context.allowPrivateAddress,
383+
});
384+
const inbox = selectInbox(discovered, scenario.inbox);
385+
lines.push(
386+
` recipient ${recipient}: actor ${discovered.actorUri.href}, ` +
387+
`inbox ${inbox.href}`,
388+
);
389+
lines.push(
390+
` destination safety: ${describeDestinationSafety(inbox, context)}`,
391+
);
392+
}
393+
return lines;
394+
}
395+
396+
function describeWebFingerPlan(
397+
scenario: ResolvedScenario,
398+
target: URL,
399+
): string[] {
400+
const recipients = scenario.recipients.length > 0
401+
? scenario.recipients
402+
: [target.href];
403+
return recipients.map((recipient) => {
404+
const resource = convertUrlIfHandle(recipient).href;
405+
const url = new URL("/.well-known/webfinger", target);
406+
url.searchParams.set("resource", resource);
407+
return ` webfinger ${resource}: GET ${url.href}`;
408+
});
409+
}
410+
411+
function describeDestinationSafety(
412+
inbox: URL,
413+
context: DryRunPlanContext,
414+
): string {
415+
try {
416+
context.assertDestinationAllowed(inbox);
417+
return "allowed";
418+
} catch (error) {
419+
if (error instanceof UnsafeTargetError) {
420+
return `would be refused: ${error.message}`;
421+
}
422+
throw error;
423+
}
424+
}
425+
426+
function unsafeOverrideScenarios(
427+
suite: Suite,
428+
): Parameters<typeof assertUnsafeOverrideAllowed>[0]["scenarios"] {
429+
const defaultDuration = suite.defaults?.duration != null;
430+
const defaultLoad = hasExplicitLoad(suite.defaults?.load);
431+
return suite.scenarios.map((scenario) => ({
432+
name: scenario.name,
433+
explicitDuration: scenario.duration != null || defaultDuration,
434+
explicitLoad: hasExplicitLoad(scenario.load) || defaultLoad,
435+
}));
436+
}
437+
438+
function hasExplicitLoad(load: LoadConfig | undefined): boolean {
439+
return load != null &&
440+
(("rate" in load && load.rate != null) ||
441+
("concurrency" in load && load.concurrency != null));
442+
}

0 commit comments

Comments
 (0)