Skip to content

Commit ff4113b

Browse files
Merge pull request #34 from JavaScriptSolidServer/issue-33-prober-verified
Prober: verify allowlist + online-ever relays (capped per sweep)
2 parents 6eb8f61 + 267b3d0 commit ff4113b

3 files changed

Lines changed: 53 additions & 9 deletions

File tree

.env.example

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@ PORT=3000
2222
# Relay-health prober (node probe.js / npm run probe). Dials ONLY a curated
2323
# allowlist (never the harvested directory) and writes online/uptime/latency +
2424
# NIP-11 auth/payment flags. Expand the allowlist deliberately.
25-
# PROBE_RELAYS=wss://relay.damus.io,wss://nos.lol # allowlist (default: small built-in set)
25+
# Targets = this allowlist PLUS already-verified relays (online >=1x), capped.
26+
# PROBE_RELAYS=wss://relay.damus.io,wss://nos.lol # allowlist seed (default: small built-in set)
2627
# PROBE_INTERVAL=86400000 # sweep cadence in ms (default 86400000 = daily)
2728
# PROBE_CONCURRENCY=25 # relays probed in parallel
2829
# PROBE_TIMEOUT=7000 # per-relay connect/HTTP timeout in ms
30+
# PROBE_MAX=1000 # cap relays dialed per sweep
2931
# RUN_ONCE=1 # single sweep then exit (or pass --once)

src/prober.js

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,28 @@ export const DEFAULT_PROBE_RELAYS = [
2323
'wss://purplepag.es', 'wss://nostr.mom',
2424
];
2525

26-
/** The curated set of relays the prober is allowed to dial (screened). */
26+
/** The curated allowlist of relays the prober always dials (screened). */
2727
export function proberRelays(env = process.env) {
2828
const raw = env.PROBE_RELAYS ? env.PROBE_RELAYS.split(',') : DEFAULT_PROBE_RELAYS;
2929
return [...new Set(raw.map((u) => safeRelayUrl(String(u).trim())).filter(Boolean))];
3030
}
3131

32+
/**
33+
* Build the sweep target set: the allowlist plus already-verified candidates
34+
* (relays that have been online ≥ 1×), screened and capped. Brand-new
35+
* harvested candidates are NOT included — only relays that already proved
36+
* themselves, so a freshly-published relay list can't make us dial new hosts.
37+
*/
38+
export function selectTargets(allowlist, candidates, cap = 1000) {
39+
const set = new Set(allowlist);
40+
for (const c of candidates) {
41+
if (set.size >= cap) break;
42+
const u = safeRelayUrl(c);
43+
if (u) set.add(u);
44+
}
45+
return [...set].slice(0, cap);
46+
}
47+
3248
export const USAGE = `beacon relay-health prober
3349
3450
Usage: node probe.js [flags] (or: node src/prober.js [flags])
@@ -38,11 +54,13 @@ Flags (override the matching env var):
3854
--concurrency <n> relays probed in parallel (env PROBE_CONCURRENCY, default 25)
3955
--timeout <ms> per-relay connect/HTTP timeout (env PROBE_TIMEOUT, default 7000)
4056
--interval <ms> sweep interval when scheduling (env PROBE_INTERVAL, default 86400000 = daily)
57+
--max <n> cap relays probed per sweep (env PROBE_MAX, default 1000)
4158
-h, --help show this help
4259
43-
Targets: a curated allowlist only (env PROBE_RELAYS, comma list; default: a
44-
small built-in set of well-known relays). The harvested directory is NEVER
45-
dialed — expand the allowlist deliberately.`;
60+
Targets: the allowlist (env PROBE_RELAYS, comma list; default: a small built-in
61+
set) PLUS already-verified relays (online ≥ 1×), capped at --max. Brand-new
62+
harvested candidates are never dialed — only relays that already proved
63+
themselves, so a freshly-published relay list can't redirect our outbound.`;
4664

4765
/** Parse prober CLI flags into an overlay (only keys the user passed). */
4866
export function parseArgs(argv = process.argv.slice(2)) {
@@ -55,6 +73,7 @@ export function parseArgs(argv = process.argv.slice(2)) {
5573
else if (a === '--concurrency') { const v = numNext(i); if (v !== undefined) { out.concurrency = v; i++; } }
5674
else if (a === '--timeout') { const v = numNext(i); if (v !== undefined) { out.timeout = v; i++; } }
5775
else if (a === '--interval') { const v = numNext(i); if (v !== undefined) { out.interval = v; i++; } }
76+
else if (a === '--max') { const v = numNext(i); if (v !== undefined) { out.max = v; i++; } }
5877
}
5978
return out;
6079
}
@@ -67,6 +86,7 @@ export function proberConfig(env = process.env, argv = process.argv.slice(2)) {
6786
interval: flags.interval ?? pos(env.PROBE_INTERVAL, 86_400_000),
6887
concurrency: flags.concurrency ?? pos(env.PROBE_CONCURRENCY, 25),
6988
timeout: flags.timeout ?? pos(env.PROBE_TIMEOUT, 7_000),
89+
cap: flags.max ?? pos(env.PROBE_MAX, 1_000),
7090
once: flags.once || env.RUN_ONCE === '1' || env.RUN_ONCE === 'true',
7191
help: !!flags.help,
7292
};
@@ -144,15 +164,23 @@ async function mapPool(items, concurrency, fn) {
144164
await Promise.all(Array.from({ length: Math.max(1, Math.min(concurrency, items.length)) }, worker));
145165
}
146166

147-
/** One sweep over the curated allowlist (never the harvested directory). */
167+
/** Sweep targets: allowlist ∪ already-verified relays (stalest first), capped. */
168+
async function sweepTargets(db, cfg) {
169+
const verified = await db.collection(RELAY_DIRECTORY)
170+
.find({ checksOnline: { $gte: 1 } }, { projection: { relay: 1, _id: 0 } })
171+
.sort({ lastChecked: 1 }).limit(cfg.cap).toArray();
172+
return selectTargets(proberRelays(), verified.map((d) => d.relay), cfg.cap);
173+
}
174+
175+
/** One sweep over the allowlist + verified candidates (never new harvest). */
148176
export async function sweepOnce(db, cfg) {
149-
const relays = proberRelays(); // allowlist only — attacker-fed data is never dialed
177+
const relays = await sweepTargets(db, cfg);
150178
let online = 0;
151179
await mapPool(relays, cfg.concurrency, async (relay) => {
152180
try { if ((await probeRelay(db, relay, cfg)).online) online++; }
153181
catch (e) { console.error(`[beacon] probe error ${relay}: ${e.message}`); }
154182
});
155-
console.log(`[beacon] relay sweep (allowlist): ${online}/${relays.length} online`);
183+
console.log(`[beacon] relay sweep: ${online}/${relays.length} online (allowlist + verified, cap ${cfg.cap})`);
156184
return { total: relays.length, online };
157185
}
158186

test/prober.test.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// manual smoke run, not here.
44
import test from 'node:test';
55
import assert from 'node:assert/strict';
6-
import { parseArgs, proberConfig, relayInfoUrl, nip11Flags, proberRelays, DEFAULT_PROBE_RELAYS } from '../src/prober.js';
6+
import { parseArgs, proberConfig, relayInfoUrl, nip11Flags, proberRelays, DEFAULT_PROBE_RELAYS, selectTargets } from '../src/prober.js';
77

88
test('parseArgs: flags', () => {
99
assert.deepEqual(parseArgs(['--once']), { once: true });
@@ -37,6 +37,20 @@ test('proberRelays: PROBE_RELAYS overrides; junk/SSRF entries dropped', () => {
3737
assert.deepEqual(r, ['wss://relay.example.com/', 'wss://nos.lol/']); // loopback + junk screened out
3838
});
3939

40+
test('selectTargets: allowlist + verified candidates, screened, deduped, capped', () => {
41+
const allow = ['wss://relay.damus.io/', 'wss://nos.lol/'];
42+
const cands = ['wss://verified-a.example.com/', 'ws://127.0.0.1/', 'wss://relay.damus.io/', 'wss://verified-b.example.com/'];
43+
const out = selectTargets(allow, cands, 1000);
44+
assert.deepEqual(out, ['wss://relay.damus.io/', 'wss://nos.lol/', 'wss://verified-a.example.com/', 'wss://verified-b.example.com/']);
45+
// SSRF candidate dropped; damus de-duped against allowlist
46+
});
47+
48+
test('selectTargets: respects the cap', () => {
49+
const allow = ['wss://a.example.com/'];
50+
const cands = ['wss://b.example.com/', 'wss://c.example.com/', 'wss://d.example.com/'];
51+
assert.deepEqual(selectTargets(allow, cands, 2), ['wss://a.example.com/', 'wss://b.example.com/']);
52+
});
53+
4054
test('proberConfig: env applies, non-positive falls back to default', () => {
4155
const c = proberConfig({ PROBE_CONCURRENCY: '40', PROBE_TIMEOUT: '0', RUN_ONCE: '1' }, []);
4256
assert.equal(c.concurrency, 40);

0 commit comments

Comments
 (0)