From e538614408a8df5aa84bc7dc2f77c78d9358497c Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Mon, 10 Aug 2026 20:34:28 +0530 Subject: [PATCH 1/2] feat(nip66): add RelayMonitorWorker cluster worker and probe scheduler Add WORKER_TYPE=relay-monitor to schedule NIP-66 probes on a configurable interval, persist the latest snapshot in Redis, and fork the worker when nip66.enabled is true. --- .changeset/nip66-relay-monitor-worker.md | 5 + CONFIGURATION.md | 8 +- resources/default-settings.yaml | 5 +- src/@types/relay-probe-snapshot.ts | 15 ++ src/@types/settings.ts | 13 +- src/app/app.ts | 7 + src/app/relay-monitor-worker.ts | 161 +++++++++++++++++ src/factories/relay-monitor-worker-factory.ts | 11 ++ src/index.ts | 3 + src/utils/relay-probe-snapshot.ts | 50 ++++++ src/utils/relay-probe-targets.ts | 29 ++++ test/unit/app/relay-monitor-worker.spec.ts | 162 ++++++++++++++++++ test/unit/utils/relay-probe-snapshot.spec.ts | 72 ++++++++ test/unit/utils/relay-probe-targets.spec.ts | 47 +++++ 14 files changed, 573 insertions(+), 15 deletions(-) create mode 100644 .changeset/nip66-relay-monitor-worker.md create mode 100644 src/@types/relay-probe-snapshot.ts create mode 100644 src/app/relay-monitor-worker.ts create mode 100644 src/factories/relay-monitor-worker-factory.ts create mode 100644 src/utils/relay-probe-snapshot.ts create mode 100644 src/utils/relay-probe-targets.ts create mode 100644 test/unit/app/relay-monitor-worker.spec.ts create mode 100644 test/unit/utils/relay-probe-snapshot.spec.ts create mode 100644 test/unit/utils/relay-probe-targets.spec.ts diff --git a/.changeset/nip66-relay-monitor-worker.md b/.changeset/nip66-relay-monitor-worker.md new file mode 100644 index 00000000..de9a4b9c --- /dev/null +++ b/.changeset/nip66-relay-monitor-worker.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +feat(nip66): add RelayMonitorWorker cluster worker and probe scheduler diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 4ffe7209..dcf65ed4 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -195,10 +195,10 @@ The settings below are listed in alphabetical order by name. Please keep this ta | nip50.enabled | Enable or disable NIP-50 full-text search. Defaults to false. When enabled, clients can include a `search` field in REQ filters to perform text queries against event content. Requires the GIN full-text index migration. | | nip50.language | PostgreSQL text-search configuration name. Defaults to `simple` (language-agnostic tokenization). Set to `english`, `spanish`, etc. for stemming support. See [PostgreSQL text search configurations](https://www.postgresql.org/docs/current/textsearch-configuration.html). **Note:** The GIN index migration is built with the `simple` configuration. If you change this value, you must manually rebuild the index: `DROP INDEX CONCURRENTLY events_content_fts_idx; CREATE INDEX CONCURRENTLY events_content_fts_idx ON events USING gin (to_tsvector('', event_content));` — otherwise the planner cannot use the index and queries fall back to sequential scans. | | nip50.maxQueryLength | Maximum length of the search query string. Queries exceeding this are truncated. Defaults to 256. | -| nip66.dnsCacheTtlSeconds | DNS cache TTL in seconds for repeated probe lookups of the same hostname. Reserved for a future monitor worker. Defaults to 300. | -| nip66.enabled | Enable NIP-66 relay monitoring configuration. **Note:** this release only defines settings (no monitor worker yet); enabling is currently a no-op. Defaults to false. | -| nip66.probeIntervalSeconds | Seconds between scheduled relay probe runs. Reserved for a future monitor worker. Defaults to 3600. | -| nip66.targets | Public WebSocket URLs to probe (for example `wss://relay.example.com`). When empty, defaults to `info.relay_url`. Reserved for a future monitor worker. | +| nip66.dnsCacheTtlSeconds | DNS cache TTL in seconds for repeated probe lookups of the same hostname. Defaults to 300. | +| nip66.enabled | Enable NIP-66 relay monitoring. When true, starts a `relay-monitor` cluster worker that probes targets on an interval and stores the latest snapshot in Redis. Defaults to false. | +| nip66.probeIntervalSeconds | Seconds between scheduled relay probe runs. Defaults to 3600. | +| nip66.targets | Public WebSocket URLs to probe (for example `wss://relay.example.com`). When empty, defaults to `info.relay_url`. | | nip66.timeouts.dnsMs | DNS probe timeout in milliseconds. Defaults to 10000. | | nip66.timeouts.nip11Ms | NIP-11 fetch timeout in milliseconds. Defaults to 10000. | | nip66.timeouts.tlsMs | TLS probe timeout in milliseconds. Defaults to 10000. | diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml index 7e04e9ba..99ff9856 100755 --- a/resources/default-settings.yaml +++ b/resources/default-settings.yaml @@ -82,10 +82,9 @@ nip50: maxQueryLength: 256 nip66: # NIP-66 relay liveness monitoring. Disabled by default. - # Settings only in this release (no monitor worker yet); enabling is currently a no-op. - # Future versions may probe public relay URLs and publish kind 30166/10166 events. + # When enabled, a relay-monitor worker probes targets and stores the latest snapshot in Redis. enabled: false - # Seconds between scheduled probe runs (reserved for a future monitor worker). + # Seconds between scheduled probe runs. probeIntervalSeconds: 3600 timeouts: dnsMs: 10000 diff --git a/src/@types/relay-probe-snapshot.ts b/src/@types/relay-probe-snapshot.ts new file mode 100644 index 00000000..319c3fd3 --- /dev/null +++ b/src/@types/relay-probe-snapshot.ts @@ -0,0 +1,15 @@ +import { ProbeResult } from '../utils/relay-probe/types' + +export type RelayProbeRunStatus = 'ok' | 'partial' | 'failed' + +export interface RelayProbeRunSnapshot { + runAt: string + targets: string[] + results: ProbeResult[] + status: RelayProbeRunStatus +} + +export interface IRelayProbeSnapshotStore { + saveLatest(snapshot: RelayProbeRunSnapshot, expirySeconds?: number): Promise + getLatest(): Promise +} diff --git a/src/@types/settings.ts b/src/@types/settings.ts index 8d503b43..f4551421 100644 --- a/src/@types/settings.ts +++ b/src/@types/settings.ts @@ -282,30 +282,27 @@ export interface Nip66ProbeTimeouts { export interface Nip66Settings { /** - * Enable NIP-66 relay monitoring configuration. - * Note: this release only defines settings (no monitor worker yet), so - * enabling is currently a no-op. + * Enable NIP-66 relay monitoring. When true, the primary process starts a + * relay-monitor cluster worker that probes configured targets on an interval. * Defaults to false. */ enabled: boolean /** - * Interval in seconds between probe runs. - * Reserved for a future monitor worker. Defaults to 3600. + * Interval in seconds between probe runs. Defaults to 3600. */ probeIntervalSeconds: number /** * Per-check probe timeouts in milliseconds. - * Reserved for a future monitor worker. */ timeouts: Nip66ProbeTimeouts /** * Public relay WebSocket URLs to probe (for example wss://relay.example.com). - * When empty, a future worker will use info.relay_url. + * When empty, the monitor worker uses info.relay_url. */ targets: string[] /** * DNS cache TTL in seconds for repeated probes of the same hostname. - * Reserved for a future monitor worker. Defaults to 300. + * Defaults to 300. */ dnsCacheTtlSeconds: number } diff --git a/src/app/app.ts b/src/app/app.ts index cb247902..6ae8c6a2 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -108,6 +108,13 @@ export class App implements IRunnable { logCentered(`${mirrors.length} static-mirroring worker started`, width) } + if (settings.nip66?.enabled) { + createWorker({ + WORKER_TYPE: 'relay-monitor', + }) + logCentered('1 relay-monitor worker started', width) + } + const dvmWorkers = settings?.dvm?.workers if (Array.isArray(dvmWorkers) && dvmWorkers.length) { diff --git a/src/app/relay-monitor-worker.ts b/src/app/relay-monitor-worker.ts new file mode 100644 index 00000000..0669e8d8 --- /dev/null +++ b/src/app/relay-monitor-worker.ts @@ -0,0 +1,161 @@ +import { IRunnable } from '../@types/base' +import { IRelayProbeSnapshotStore, RelayProbeRunSnapshot } from '../@types/relay-probe-snapshot' +import { Settings } from '../@types/settings' +import { createLogger } from '../factories/logger-factory' +import { shutdownMetricsTelemetry } from '../telemetry/metrics' +import { filterValidProbeTargets, resolveProbeTargets } from '../utils/relay-probe-targets' +import { deriveRelayProbeRunStatus } from '../utils/relay-probe-snapshot' +import { runProbe } from '../utils/relay-probe' +import { ProbeOptions, ProbeResult } from '../utils/relay-probe/types' + +const logger = createLogger('relay-monitor-worker') + +const DEFAULT_PROBE_INTERVAL_SECONDS = 3600 +const MIN_PROBE_INTERVAL_SECONDS = 60 + +export type RunProbeFn = (relayUrl: string, options?: ProbeOptions) => Promise + +export const buildProbeOptions = (settings: Settings): ProbeOptions => { + const nip66 = settings.nip66 + + return { + timeouts: nip66?.timeouts, + dnsCacheTtlSeconds: nip66?.dnsCacheTtlSeconds, + } +} + +export const getProbeIntervalMs = (settings: Settings): number => { + const configured = settings.nip66?.probeIntervalSeconds ?? DEFAULT_PROBE_INTERVAL_SECONDS + const intervalSeconds = Math.max(configured, MIN_PROBE_INTERVAL_SECONDS) + + return intervalSeconds * 1000 +} + +export class RelayMonitorWorker implements IRunnable { + private interval: NodeJS.Timeout | undefined + private isRunning = false + + public constructor( + private readonly process: NodeJS.Process, + private readonly settings: () => Settings, + private readonly snapshotStore: IRelayProbeSnapshotStore, + private readonly probeRunner: RunProbeFn = runProbe, + ) { + this.process + .on('SIGINT', this.onExit.bind(this)) + .on('SIGHUP', this.onExit.bind(this)) + .on('SIGTERM', this.onExit.bind(this)) + .on('uncaughtException', this.onError.bind(this)) + .on('unhandledRejection', this.onError.bind(this)) + } + + public run(): void { + const currentSettings = this.settings() + + if (!currentSettings.nip66?.enabled) { + logger('NIP-66 relay monitoring is disabled; worker exiting') + return + } + + const intervalMs = getProbeIntervalMs(currentSettings) + logger('starting probe scheduler with interval %d ms', intervalMs) + + void this.runScheduledProbes() + + this.interval = setInterval(() => { + void this.runScheduledProbes() + }, intervalMs) + } + + private async runScheduledProbes(): Promise { + if (this.isRunning) { + logger('skipping scheduled probe run because previous run is still in progress') + return + } + + this.isRunning = true + + try { + await this.onSchedule() + } catch (error) { + this.onError(error as Error) + } finally { + this.isRunning = false + } + } + + private async onSchedule(): Promise { + const currentSettings = this.settings() + + if (!currentSettings.nip66?.enabled) { + logger('NIP-66 relay monitoring disabled during scheduled run; stopping scheduler') + this.close() + return + } + + const configuredTargets = resolveProbeTargets(currentSettings) + const { valid, invalid } = filterValidProbeTargets(configuredTargets) + + for (const target of invalid) { + logger.warn('skipping invalid probe target: %s', target) + } + + if (valid.length === 0) { + logger.warn('no valid probe targets configured; skipping probe run') + return + } + + const probeOptions = buildProbeOptions(currentSettings) + const results: ProbeResult[] = [] + + for (const target of valid) { + try { + results.push(await this.probeRunner(target, probeOptions)) + } catch (error) { + logger.error('probe run failed for %s: %o', target, error) + } + } + + if (results.length === 0) { + logger.warn('probe run produced no results') + return + } + + const snapshot: RelayProbeRunSnapshot = { + runAt: new Date().toISOString(), + targets: valid, + results, + status: deriveRelayProbeRunStatus(results), + } + + const expirySeconds = Math.max( + (currentSettings.nip66?.probeIntervalSeconds ?? DEFAULT_PROBE_INTERVAL_SECONDS) * 2, + MIN_PROBE_INTERVAL_SECONDS * 2, + ) + + await this.snapshotStore.saveLatest(snapshot, expirySeconds) + logger('saved probe snapshot for %d target(s) with status %s', valid.length, snapshot.status) + } + + private onError(error: Error) { + logger('error: %o', error) + throw error + } + + private onExit() { + logger('exiting') + void shutdownMetricsTelemetry().finally(() => { + this.close(() => { + this.process.exit(0) + }) + }) + } + + public close(callback?: () => void) { + logger('closing') + clearInterval(this.interval) + if (typeof callback === 'function') { + callback() + } + } +} diff --git a/src/factories/relay-monitor-worker-factory.ts b/src/factories/relay-monitor-worker-factory.ts new file mode 100644 index 00000000..0fc42248 --- /dev/null +++ b/src/factories/relay-monitor-worker-factory.ts @@ -0,0 +1,11 @@ +import { RedisAdapter } from '../adapters/redis-adapter' +import { RelayMonitorWorker } from '../app/relay-monitor-worker' +import { getCacheClient } from '../cache/client' +import { createSettings } from './settings-factory' +import { RelayProbeSnapshotStore } from '../utils/relay-probe-snapshot' + +export const relayMonitorWorkerFactory = () => { + const snapshotStore = new RelayProbeSnapshotStore(new RedisAdapter(getCacheClient())) + + return new RelayMonitorWorker(process, createSettings, snapshotStore) +} diff --git a/src/index.ts b/src/index.ts index d43c56a3..a1f9d1a9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import cluster from 'cluster' import { appFactory } from './factories/app-factory' import { dvmOrchestratorWorkerFactory } from './factories/dvm-orchestrator-worker-factory' import { maintenanceWorkerFactory } from './factories/maintenance-worker-factory' +import { relayMonitorWorkerFactory } from './factories/relay-monitor-worker-factory' import { staticMirroringWorkerFactory } from './factories/static-mirroring.worker-factory' import { workerFactory } from './factories/worker-factory' import { initializeMetricsTelemetry } from './telemetry/metrics' @@ -18,6 +19,8 @@ export const getRunner = () => { return maintenanceWorkerFactory() case 'static-mirroring': return staticMirroringWorkerFactory() + case 'relay-monitor': + return relayMonitorWorkerFactory() case 'dvm-orchestrator': return dvmOrchestratorWorkerFactory() default: diff --git a/src/utils/relay-probe-snapshot.ts b/src/utils/relay-probe-snapshot.ts new file mode 100644 index 00000000..2f1ef134 --- /dev/null +++ b/src/utils/relay-probe-snapshot.ts @@ -0,0 +1,50 @@ +import { ICacheAdapter } from '../@types/adapters' +import { IRelayProbeSnapshotStore, RelayProbeRunSnapshot } from '../@types/relay-probe-snapshot' + +export const RELAY_PROBE_SNAPSHOT_KEY = 'nip66:snapshot:latest' + +const jsonReplacer = (_key: string, value: unknown): unknown => { + if (value instanceof Date) { + return value.toISOString() + } + + return value +} + +export class RelayProbeSnapshotStore implements IRelayProbeSnapshotStore { + public constructor(private readonly cache: ICacheAdapter) {} + + public async saveLatest(snapshot: RelayProbeRunSnapshot, expirySeconds?: number): Promise { + await this.cache.setKey(RELAY_PROBE_SNAPSHOT_KEY, JSON.stringify(snapshot, jsonReplacer), expirySeconds) + } + + public async getLatest(): Promise { + const raw = await this.cache.getKey(RELAY_PROBE_SNAPSHOT_KEY) + + if (!raw) { + return null + } + + return JSON.parse(raw) as RelayProbeRunSnapshot + } +} + +export const deriveRelayProbeRunStatus = ( + results: RelayProbeRunSnapshot['results'], +): RelayProbeRunSnapshot['status'] => { + if (results.length === 0) { + return 'failed' + } + + const okCount = results.filter((result) => result.wsRtt.status === 'ok').length + + if (okCount === results.length) { + return 'ok' + } + + if (okCount === 0) { + return 'failed' + } + + return 'partial' +} diff --git a/src/utils/relay-probe-targets.ts b/src/utils/relay-probe-targets.ts new file mode 100644 index 00000000..02bf02b3 --- /dev/null +++ b/src/utils/relay-probe-targets.ts @@ -0,0 +1,29 @@ +import { Settings } from '../@types/settings' +import { parseProbeTarget } from './relay-probe' + +export const resolveProbeTargets = (settings: Settings): string[] => { + const configured = settings.nip66?.targets?.map((target) => target.trim()).filter(Boolean) ?? [] + + if (configured.length > 0) { + return configured + } + + const relayUrl = settings.info?.relay_url?.trim() + return relayUrl ? [relayUrl] : [] +} + +export const filterValidProbeTargets = (targets: string[]): { valid: string[]; invalid: string[] } => { + const valid: string[] = [] + const invalid: string[] = [] + + for (const target of targets) { + try { + parseProbeTarget(target) + valid.push(target) + } catch { + invalid.push(target) + } + } + + return { valid, invalid } +} diff --git a/test/unit/app/relay-monitor-worker.spec.ts b/test/unit/app/relay-monitor-worker.spec.ts new file mode 100644 index 00000000..7fba5726 --- /dev/null +++ b/test/unit/app/relay-monitor-worker.spec.ts @@ -0,0 +1,162 @@ +import EventEmitter from 'events' + +import chai from 'chai' +import Sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import { + buildProbeOptions, + getProbeIntervalMs, + RelayMonitorWorker, +} from '../../../src/app/relay-monitor-worker' +import { Settings } from '../../../src/@types/settings' +import * as metricsTelemetry from '../../../src/telemetry/metrics' +import { ProbeResult } from '../../../src/utils/relay-probe/types' + +chai.use(sinonChai) + +const { expect } = chai + +describe('RelayMonitorWorker', () => { + let sandbox: Sinon.SinonSandbox + let worker: RelayMonitorWorker + let fakeProcess: EventEmitter & { exit: Sinon.SinonStub } + let settings: Sinon.SinonStub + let settingsState: Settings + let snapshotStore: { + saveLatest: Sinon.SinonStub + getLatest: Sinon.SinonStub + } + let probeRunner: Sinon.SinonStub + + const probeResult = (): ProbeResult => + ({ + target: { + relayUrl: 'wss://relay.example.com', + hostname: 'relay.example.com', + networkType: 'clearnet', + httpOrigin: 'https://relay.example.com', + nip11Url: 'https://relay.example.com/.well-known/nostr.json', + wsUrl: 'wss://relay.example.com', + }, + checkedAt: new Date(), + dns: { status: 'ok', durationMs: 1 }, + tls: { status: 'ok', durationMs: 1 }, + wsRtt: { status: 'ok', durationMs: 1 }, + nip11: { status: 'ok', durationMs: 1 }, + }) as ProbeResult + + beforeEach(() => { + sandbox = Sinon.createSandbox() + sandbox.stub(metricsTelemetry, 'shutdownMetricsTelemetry').resolves() + + fakeProcess = Object.assign(new EventEmitter(), { + exit: sandbox.stub(), + }) as EventEmitter & { exit: Sinon.SinonStub } + + settingsState = { + info: { + relay_url: 'wss://relay.example.com', + }, + nip66: { + enabled: true, + probeIntervalSeconds: 60, + targets: [], + timeouts: { + dnsMs: 10000, + tlsMs: 10000, + wsRttMs: 10000, + nip11Ms: 10000, + }, + dnsCacheTtlSeconds: 300, + }, + } as Settings + + settings = sandbox.stub().callsFake(() => settingsState) + snapshotStore = { + saveLatest: sandbox.stub().resolves(), + getLatest: sandbox.stub().resolves(null), + } + probeRunner = sandbox.stub().resolves(probeResult()) + + worker = new RelayMonitorWorker(fakeProcess as unknown as NodeJS.Process, settings, snapshotStore, probeRunner) + }) + + afterEach(() => { + worker.close() + sandbox.restore() + }) + + it('does not start the scheduler when nip66 is disabled', () => { + settingsState.nip66!.enabled = false + const setIntervalStub = sandbox.stub(global, 'setInterval') + + worker.run() + + expect(setIntervalStub).to.not.have.been.called + expect(probeRunner).to.not.have.been.called + }) + + it('runs an initial probe and schedules subsequent runs', async () => { + const clock = sandbox.useFakeTimers() + + worker.run() + await Promise.resolve() + + expect(probeRunner).to.have.been.calledOnceWith('wss://relay.example.com', buildProbeOptions(settingsState)) + expect(snapshotStore.saveLatest).to.have.been.calledOnce + + probeRunner.resetHistory() + snapshotStore.saveLatest.resetHistory() + + await clock.tickAsync(60_000) + + expect(probeRunner).to.have.been.calledOnce + expect(snapshotStore.saveLatest).to.have.been.calledOnce + + clock.restore() + }) + + it('skips overlapping scheduled runs', async () => { + const clock = sandbox.useFakeTimers() + let resolveProbe: (() => void) | undefined + probeRunner.callsFake( + () => + new Promise((resolve) => { + resolveProbe = () => resolve(probeResult()) + }), + ) + + worker.run() + await Promise.resolve() + + expect(probeRunner).to.have.been.calledOnce + + const tickPromise = clock.tickAsync(60_000) + await Promise.resolve() + + expect(probeRunner).to.have.been.calledOnce + + resolveProbe?.() + await tickPromise + clock.restore() + }) + + it('builds probe options from nip66 settings', () => { + expect(buildProbeOptions(settingsState)).to.deep.equal({ + timeouts: settingsState.nip66!.timeouts, + dnsCacheTtlSeconds: 300, + }) + }) + + it('enforces a minimum probe interval', () => { + settingsState.nip66!.probeIntervalSeconds = 10 + expect(getProbeIntervalMs(settingsState)).to.equal(60_000) + }) + + it('calls close and then exits the process with code 0', async () => { + fakeProcess.emit('SIGTERM') + await new Promise((resolve) => setImmediate(resolve)) + expect(fakeProcess.exit).to.have.been.calledOnceWithExactly(0) + }) +}) diff --git a/test/unit/utils/relay-probe-snapshot.spec.ts b/test/unit/utils/relay-probe-snapshot.spec.ts new file mode 100644 index 00000000..f503dcb7 --- /dev/null +++ b/test/unit/utils/relay-probe-snapshot.spec.ts @@ -0,0 +1,72 @@ +import { expect } from 'chai' +import Sinon from 'sinon' + +import { ICacheAdapter } from '../../../src/@types/adapters' +import { RelayProbeRunSnapshot } from '../../../src/@types/relay-probe-snapshot' +import { + deriveRelayProbeRunStatus, + RelayProbeSnapshotStore, + RELAY_PROBE_SNAPSHOT_KEY, +} from '../../../src/utils/relay-probe-snapshot' +import { ProbeResult } from '../../../src/utils/relay-probe/types' + +describe('relay-probe-snapshot', () => { + let cache: { + getKey: Sinon.SinonStub + setKey: Sinon.SinonStub + } + let store: RelayProbeSnapshotStore + + const sampleResult = (wsStatus: 'ok' | 'error'): ProbeResult => + ({ + target: { + relayUrl: 'wss://relay.example.com', + hostname: 'relay.example.com', + networkType: 'clearnet', + httpOrigin: 'https://relay.example.com', + nip11Url: 'https://relay.example.com/.well-known/nostr.json', + wsUrl: 'wss://relay.example.com', + }, + checkedAt: new Date('2026-01-01T00:00:00.000Z'), + dns: { status: 'ok', durationMs: 1 }, + tls: { status: 'ok', durationMs: 1 }, + wsRtt: { status: wsStatus, durationMs: 1 }, + nip11: { status: 'ok', durationMs: 1 }, + }) as ProbeResult + + beforeEach(() => { + cache = { + getKey: Sinon.stub(), + setKey: Sinon.stub().resolves(true), + } + + store = new RelayProbeSnapshotStore(cache as unknown as ICacheAdapter) + }) + + it('saves and reads the latest snapshot from Redis', async () => { + const snapshot: RelayProbeRunSnapshot = { + runAt: '2026-01-01T00:00:00.000Z', + targets: ['wss://relay.example.com'], + results: [sampleResult('ok')], + status: 'ok', + } + + cache.getKey.callsFake(async () => { + const saved = cache.setKey.firstCall.args[1] as string + return saved + }) + + await store.saveLatest(snapshot, 7200) + const loaded = await store.getLatest() + + expect(cache.setKey).to.have.been.calledOnceWith(RELAY_PROBE_SNAPSHOT_KEY, Sinon.match.string, 7200) + expect(loaded).to.deep.equal(JSON.parse(JSON.stringify(snapshot, (_key, value) => (value instanceof Date ? value.toISOString() : value)))) + }) + + it('derives run status from ws RTT probe results', () => { + expect(deriveRelayProbeRunStatus([sampleResult('ok')])).to.equal('ok') + expect(deriveRelayProbeRunStatus([sampleResult('error')])).to.equal('failed') + expect(deriveRelayProbeRunStatus([sampleResult('ok'), sampleResult('error')])).to.equal('partial') + expect(deriveRelayProbeRunStatus([])).to.equal('failed') + }) +}) diff --git a/test/unit/utils/relay-probe-targets.spec.ts b/test/unit/utils/relay-probe-targets.spec.ts new file mode 100644 index 00000000..f4308b6f --- /dev/null +++ b/test/unit/utils/relay-probe-targets.spec.ts @@ -0,0 +1,47 @@ +import { expect } from 'chai' + +import { Settings } from '../../../src/@types/settings' +import { filterValidProbeTargets, resolveProbeTargets } from '../../../src/utils/relay-probe-targets' + +describe('relay-probe-targets', () => { + const baseSettings = { + info: { + relay_url: 'wss://relay.example.com', + }, + nip66: { + enabled: true, + probeIntervalSeconds: 3600, + targets: [], + timeouts: { + dnsMs: 10000, + tlsMs: 10000, + wsRttMs: 10000, + nip11Ms: 10000, + }, + dnsCacheTtlSeconds: 300, + }, + } as Settings + + it('falls back to info.relay_url when nip66.targets is empty', () => { + expect(resolveProbeTargets(baseSettings)).to.deep.equal(['wss://relay.example.com']) + }) + + it('uses configured nip66.targets when present', () => { + const settings = { + ...baseSettings, + nip66: { + ...baseSettings.nip66!, + targets: ['wss://one.example', 'wss://two.example'], + }, + } as Settings + + expect(resolveProbeTargets(settings)).to.deep.equal(['wss://one.example', 'wss://two.example']) + }) + + it('filters invalid probe targets', () => { + const filtered = filterValidProbeTargets(['wss://valid.example', 'not-a-url']) + + expect(filtered.valid).to.deep.equal(['wss://valid.example']) + expect(filtered.invalid).to.deep.equal(['not-a-url']) + }) +}) From 9a01d3d22a0eec1aa0a7aa4103dae26d57e9013c Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Mon, 10 Aug 2026 21:01:05 +0530 Subject: [PATCH 2/2] fix(nip66): block IPv6 NIP-11 targets and type stored probe snapshots Reject unbracketed IPv6 hostnames in isNip11FetchTargetSafe and introduce StoredProbeResult so Redis snapshots accurately use ISO date strings. --- src/@types/relay-probe-snapshot.ts | 37 ++++++++++++++++++-- src/app/relay-monitor-worker.ts | 4 +-- src/utils/relay-probe-snapshot.ts | 16 ++++++--- src/utils/relay-probe/nip11-probe.ts | 3 +- test/unit/utils/relay-probe-snapshot.spec.ts | 21 ++++++++--- test/unit/utils/relay-probe-target.spec.ts | 1 + 6 files changed, 68 insertions(+), 14 deletions(-) diff --git a/src/@types/relay-probe-snapshot.ts b/src/@types/relay-probe-snapshot.ts index 319c3fd3..867c3d2e 100644 --- a/src/@types/relay-probe-snapshot.ts +++ b/src/@types/relay-probe-snapshot.ts @@ -1,11 +1,42 @@ -import { ProbeResult } from '../utils/relay-probe/types' +import { + DnsRecord, + Nip11Result, + ProbeCheckResult, + ProbeResult, + ProbeTarget, + WsRttResult, +} from '../utils/relay-probe/types' export type RelayProbeRunStatus = 'ok' | 'partial' | 'failed' +export interface StoredDnsResult { + hostname: string + records: DnsRecord[] + fromCache: boolean + cacheExpiresAt?: string +} + +export interface StoredTlsResult { + valid: boolean + issuer?: string + subject?: string + expiresAt?: string + daysUntilExpiry?: number +} + +export interface StoredProbeResult { + target: ProbeTarget + checkedAt: string + dns: ProbeCheckResult + tls: ProbeCheckResult + wsRtt: ProbeCheckResult + nip11: ProbeCheckResult +} + export interface RelayProbeRunSnapshot { runAt: string targets: string[] - results: ProbeResult[] + results: StoredProbeResult[] status: RelayProbeRunStatus } @@ -13,3 +44,5 @@ export interface IRelayProbeSnapshotStore { saveLatest(snapshot: RelayProbeRunSnapshot, expirySeconds?: number): Promise getLatest(): Promise } + +export type ProbeRunStatusInput = Pick diff --git a/src/app/relay-monitor-worker.ts b/src/app/relay-monitor-worker.ts index 0669e8d8..76aa9506 100644 --- a/src/app/relay-monitor-worker.ts +++ b/src/app/relay-monitor-worker.ts @@ -4,7 +4,7 @@ import { Settings } from '../@types/settings' import { createLogger } from '../factories/logger-factory' import { shutdownMetricsTelemetry } from '../telemetry/metrics' import { filterValidProbeTargets, resolveProbeTargets } from '../utils/relay-probe-targets' -import { deriveRelayProbeRunStatus } from '../utils/relay-probe-snapshot' +import { deriveRelayProbeRunStatus, serializeProbeResults } from '../utils/relay-probe-snapshot' import { runProbe } from '../utils/relay-probe' import { ProbeOptions, ProbeResult } from '../utils/relay-probe/types' @@ -124,7 +124,7 @@ export class RelayMonitorWorker implements IRunnable { const snapshot: RelayProbeRunSnapshot = { runAt: new Date().toISOString(), targets: valid, - results, + results: serializeProbeResults(results), status: deriveRelayProbeRunStatus(results), } diff --git a/src/utils/relay-probe-snapshot.ts b/src/utils/relay-probe-snapshot.ts index 2f1ef134..4ceb4ffc 100644 --- a/src/utils/relay-probe-snapshot.ts +++ b/src/utils/relay-probe-snapshot.ts @@ -1,5 +1,11 @@ import { ICacheAdapter } from '../@types/adapters' -import { IRelayProbeSnapshotStore, RelayProbeRunSnapshot } from '../@types/relay-probe-snapshot' +import { + IRelayProbeSnapshotStore, + ProbeRunStatusInput, + RelayProbeRunSnapshot, + StoredProbeResult, +} from '../@types/relay-probe-snapshot' +import { ProbeResult } from './relay-probe/types' export const RELAY_PROBE_SNAPSHOT_KEY = 'nip66:snapshot:latest' @@ -11,6 +17,10 @@ const jsonReplacer = (_key: string, value: unknown): unknown => { return value } +export const serializeProbeResults = (results: ProbeResult[]): StoredProbeResult[] => { + return JSON.parse(JSON.stringify(results, jsonReplacer)) as StoredProbeResult[] +} + export class RelayProbeSnapshotStore implements IRelayProbeSnapshotStore { public constructor(private readonly cache: ICacheAdapter) {} @@ -29,9 +39,7 @@ export class RelayProbeSnapshotStore implements IRelayProbeSnapshotStore { } } -export const deriveRelayProbeRunStatus = ( - results: RelayProbeRunSnapshot['results'], -): RelayProbeRunSnapshot['status'] => { +export const deriveRelayProbeRunStatus = (results: ProbeRunStatusInput[]): RelayProbeRunSnapshot['status'] => { if (results.length === 0) { return 'failed' } diff --git a/src/utils/relay-probe/nip11-probe.ts b/src/utils/relay-probe/nip11-probe.ts index ceb9763b..2f63c7b6 100644 --- a/src/utils/relay-probe/nip11-probe.ts +++ b/src/utils/relay-probe/nip11-probe.ts @@ -55,7 +55,8 @@ export const isNip11FetchTargetSafe = (targetUrl: string): boolean => { } } - if (host.startsWith('[') && host.endsWith(']')) { + // IPv6 literal: URL.hostname is unbracketed (e.g. "::1"); NIP-11 fetch targets should not be IP literals. + if (host.includes(':')) { return false } diff --git a/test/unit/utils/relay-probe-snapshot.spec.ts b/test/unit/utils/relay-probe-snapshot.spec.ts index f503dcb7..cf614a24 100644 --- a/test/unit/utils/relay-probe-snapshot.spec.ts +++ b/test/unit/utils/relay-probe-snapshot.spec.ts @@ -7,6 +7,7 @@ import { deriveRelayProbeRunStatus, RelayProbeSnapshotStore, RELAY_PROBE_SNAPSHOT_KEY, + serializeProbeResults, } from '../../../src/utils/relay-probe-snapshot' import { ProbeResult } from '../../../src/utils/relay-probe/types' @@ -47,7 +48,7 @@ describe('relay-probe-snapshot', () => { const snapshot: RelayProbeRunSnapshot = { runAt: '2026-01-01T00:00:00.000Z', targets: ['wss://relay.example.com'], - results: [sampleResult('ok')], + results: serializeProbeResults([sampleResult('ok')]), status: 'ok', } @@ -60,13 +61,23 @@ describe('relay-probe-snapshot', () => { const loaded = await store.getLatest() expect(cache.setKey).to.have.been.calledOnceWith(RELAY_PROBE_SNAPSHOT_KEY, Sinon.match.string, 7200) - expect(loaded).to.deep.equal(JSON.parse(JSON.stringify(snapshot, (_key, value) => (value instanceof Date ? value.toISOString() : value)))) + expect(loaded).to.deep.equal(snapshot) + expect(loaded?.results[0].checkedAt).to.equal('2026-01-01T00:00:00.000Z') + }) + + it('serializes probe result dates to ISO strings', () => { + const stored = serializeProbeResults([sampleResult('ok')]) + + expect(stored[0].checkedAt).to.equal('2026-01-01T00:00:00.000Z') }) it('derives run status from ws RTT probe results', () => { - expect(deriveRelayProbeRunStatus([sampleResult('ok')])).to.equal('ok') - expect(deriveRelayProbeRunStatus([sampleResult('error')])).to.equal('failed') - expect(deriveRelayProbeRunStatus([sampleResult('ok'), sampleResult('error')])).to.equal('partial') + const storedOk = serializeProbeResults([sampleResult('ok')]) + const storedError = serializeProbeResults([sampleResult('error')]) + + expect(deriveRelayProbeRunStatus(storedOk)).to.equal('ok') + expect(deriveRelayProbeRunStatus(storedError)).to.equal('failed') + expect(deriveRelayProbeRunStatus([...storedOk, ...storedError])).to.equal('partial') expect(deriveRelayProbeRunStatus([])).to.equal('failed') }) }) diff --git a/test/unit/utils/relay-probe-target.spec.ts b/test/unit/utils/relay-probe-target.spec.ts index a99d2505..6bb073ab 100644 --- a/test/unit/utils/relay-probe-target.spec.ts +++ b/test/unit/utils/relay-probe-target.spec.ts @@ -33,6 +33,7 @@ describe('relay-probe safety helpers', () => { it('rejects unsafe NIP-11 fetch targets', () => { expect(isNip11FetchTargetSafe('https://relay.example.com/')).to.equal(true) expect(isNip11FetchTargetSafe('http://127.0.0.1/')).to.equal(false) + expect(isNip11FetchTargetSafe('http://[::1]/')).to.equal(false) expect(isNip11FetchTargetSafe('ftp://relay.example.com/')).to.equal(false) }) })