Skip to content

Commit afa8999

Browse files
Ferryx349cameri
andauthored
feat(nip66): publish kind 30166 and 10166 relay health events (#741)
* feat(nip66): publish kind 30166 and 10166 relay health events After each probe run, sign and store NIP-66 relay discovery and monitor announcement events from the monitor identity, bootstrap kind 0/10002 on first run, and persist via the parameterized replaceable event path. Fixes #696 * test(nip66): query event_tags table in 30166 integration assertion Knex already parses jsonb event_tags as an array; JSON.parse on that value caused CI failures. Assert the d tag via the event_tags table instead. * fix(nip66): address monitor bootstrap and discovery event review --------- Co-authored-by: Ricardo Cabral <me@ricardocabral.io>
1 parent 8d29f15 commit afa8999

12 files changed

Lines changed: 569 additions & 13 deletions

File tree

.changeset/nip66-publish-events.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"nostream": minor
3+
---
4+
5+
feat(nip66): publish kind 30166 and 10166 relay health events after probe runs
6+
7+
After each relay monitor probe run, sign and store NIP-66 relay discovery and monitor
8+
announcement events using the configured monitor identity, bootstrap kind 0/10002 on
9+
first run, and persist via the existing parameterized replaceable event path.
10+
11+
Fixes #696

src/app/relay-monitor-worker.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,16 @@ import { IRunnable } from '../@types/base'
22
import { IRelayProbeSnapshotStore, RelayProbeRunSnapshot } from '../@types/relay-probe-snapshot'
33
import { Settings } from '../@types/settings'
44
import { createLogger } from '../factories/logger-factory'
5+
import { INip66EventPublisher } from '../services/nip66-event-publisher'
56
import { shutdownMetricsTelemetry } from '../telemetry/metrics'
7+
import { getEffectiveProbeIntervalSeconds } from '../utils/nip66-events'
68
import { filterValidProbeTargets, resolveProbeTargets } from '../utils/relay-probe-targets'
79
import { deriveRelayProbeRunStatus, serializeProbeResults } from '../utils/relay-probe-snapshot'
810
import { runProbe } from '../utils/relay-probe'
911
import { ProbeOptions, ProbeResult } from '../utils/relay-probe/types'
1012

1113
const logger = createLogger('relay-monitor-worker')
1214

13-
const DEFAULT_PROBE_INTERVAL_SECONDS = 3600
14-
const MIN_PROBE_INTERVAL_SECONDS = 60
15-
1615
export type RunProbeFn = (relayUrl: string, options?: ProbeOptions) => Promise<ProbeResult>
1716

1817
export const buildProbeOptions = (settings: Settings): ProbeOptions => {
@@ -25,10 +24,7 @@ export const buildProbeOptions = (settings: Settings): ProbeOptions => {
2524
}
2625

2726
export const getProbeIntervalMs = (settings: Settings): number => {
28-
const configured = settings.nip66?.probeIntervalSeconds ?? DEFAULT_PROBE_INTERVAL_SECONDS
29-
const intervalSeconds = Math.max(configured, MIN_PROBE_INTERVAL_SECONDS)
30-
31-
return intervalSeconds * 1000
27+
return getEffectiveProbeIntervalSeconds(settings) * 1000
3228
}
3329

3430
export class RelayMonitorWorker implements IRunnable {
@@ -40,6 +36,7 @@ export class RelayMonitorWorker implements IRunnable {
4036
private readonly settings: () => Settings,
4137
private readonly snapshotStore: IRelayProbeSnapshotStore,
4238
private readonly probeRunner: RunProbeFn = runProbe,
39+
private readonly eventPublisher?: INip66EventPublisher,
4340
) {
4441
this.process
4542
.on('SIGINT', this.onExit.bind(this))
@@ -128,13 +125,18 @@ export class RelayMonitorWorker implements IRunnable {
128125
status: deriveRelayProbeRunStatus(results),
129126
}
130127

131-
const expirySeconds = Math.max(
132-
(currentSettings.nip66?.probeIntervalSeconds ?? DEFAULT_PROBE_INTERVAL_SECONDS) * 2,
133-
MIN_PROBE_INTERVAL_SECONDS * 2,
134-
)
128+
const expirySeconds = getEffectiveProbeIntervalSeconds(currentSettings) * 2
135129

136130
await this.snapshotStore.saveLatest(snapshot, expirySeconds)
137131
logger('saved probe snapshot for %d target(s) with status %s', valid.length, snapshot.status)
132+
133+
if (this.eventPublisher) {
134+
try {
135+
await this.eventPublisher.publishAfterProbe(snapshot, currentSettings)
136+
} catch (error) {
137+
logger.error('failed to publish NIP-66 events: %o', error)
138+
}
139+
}
138140
}
139141

140142
private onError(error: Error) {

src/constants/base.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ export enum EventKinds {
5353
REPLACEABLE_FIRST = 10000,
5454
// NIP-65: Relay List Metadata
5555
RELAY_LIST = 10002,
56+
// NIP-66: Relay monitor announcement
57+
RELAY_MONITOR_ANNOUNCEMENT = 10166,
5658
// Marmot Protocol MIP-00: KeyPackage Relay List
5759
MARMOT_KEY_PACKAGE_RELAY_LIST = 10051,
5860
// NIP-43: Membership List
@@ -71,6 +73,8 @@ export enum EventKinds {
7173
EPHEMERAL_LAST = 29999,
7274
// Parameterized replaceable events
7375
PARAMETERIZED_REPLACEABLE_FIRST = 30000,
76+
// NIP-66: Relay discovery
77+
RELAY_DISCOVERY = 30166,
7478
// Marmot Protocol MIP-00: KeyPackage (addressable, replaces legacy 443)
7579
MARMOT_KEY_PACKAGE = 30443,
7680
// NIP-89: Recommended Application Handlers
Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
import { RedisAdapter } from '../adapters/redis-adapter'
22
import { RelayMonitorWorker } from '../app/relay-monitor-worker'
33
import { getCacheClient } from '../cache/client'
4+
import { getMasterDbClient, getReadReplicaDbClient } from '../database/client'
45
import { createSettings } from './settings-factory'
6+
import { EventRepository } from '../repositories/event-repository'
7+
import { Nip66EventPublisher } from '../services/nip66-event-publisher'
58
import { RelayProbeSnapshotStore } from '../utils/relay-probe-snapshot'
9+
import { runProbe } from '../utils/relay-probe'
610

711
export const relayMonitorWorkerFactory = () => {
8-
const snapshotStore = new RelayProbeSnapshotStore(new RedisAdapter(getCacheClient()))
12+
const cache = new RedisAdapter(getCacheClient())
13+
const snapshotStore = new RelayProbeSnapshotStore(cache)
14+
const eventRepository = new EventRepository(getMasterDbClient(), getReadReplicaDbClient(), createSettings)
15+
const eventPublisher = new Nip66EventPublisher(eventRepository, cache)
916

10-
return new RelayMonitorWorker(process, createSettings, snapshotStore)
17+
return new RelayMonitorWorker(process, createSettings, snapshotStore, runProbe, eventPublisher)
1118
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { ICacheAdapter } from '../@types/adapters'
2+
import { ParameterizedReplaceableEvent, UnidentifiedEvent } from '../@types/event'
3+
import { RelayProbeRunSnapshot } from '../@types/relay-probe-snapshot'
4+
import { IEventRepository } from '../@types/repositories'
5+
import { Settings } from '../@types/settings'
6+
import { EventDeduplicationMetadataKey, EventTags } from '../constants/base'
7+
import { createLogger } from '../factories/logger-factory'
8+
import { getPublicKey, identifyEvent, isParameterizedReplaceableEvent, signEvent } from '../utils/event'
9+
import { getMonitorPrivateKey } from '../utils/monitor-identity'
10+
import {
11+
buildMonitorAnnouncementEvent,
12+
buildMonitorProfileEvent,
13+
buildMonitorRelayListEvent,
14+
buildRelayDiscoveryEvent,
15+
} from '../utils/nip66-events'
16+
const logger = createLogger('nip66-event-publisher')
17+
18+
export const NIP66_MONITOR_BOOTSTRAPPED_KEY = 'nip66:monitor:bootstrapped'
19+
20+
export interface INip66EventPublisher {
21+
publishAfterProbe(snapshot: RelayProbeRunSnapshot, settings: Settings): Promise<void>
22+
}
23+
24+
export class Nip66EventPublisher implements INip66EventPublisher {
25+
public constructor(
26+
private readonly eventRepository: IEventRepository,
27+
private readonly cache: ICacheAdapter,
28+
) {}
29+
30+
public async publishAfterProbe(snapshot: RelayProbeRunSnapshot, settings: Settings): Promise<void> {
31+
const privkey = getMonitorPrivateKey()
32+
33+
if (!privkey) {
34+
logger.warn('MONITOR_PRIVATE_KEY is not configured; skipping NIP-66 event publish')
35+
return
36+
}
37+
38+
const monitorPubkey = getPublicKey(privkey)
39+
const createdAt = Math.floor(Date.now() / 1000)
40+
41+
await this.ensureBootstrap(monitorPubkey, settings, privkey, createdAt)
42+
43+
await this.persistSignedEvent(buildMonitorAnnouncementEvent(settings, monitorPubkey, createdAt), privkey)
44+
45+
for (const result of snapshot.results) {
46+
await this.persistSignedEvent(buildRelayDiscoveryEvent(result, monitorPubkey, createdAt), privkey)
47+
}
48+
49+
logger('published NIP-66 events for %d probe target(s)', snapshot.results.length)
50+
}
51+
52+
private async ensureBootstrap(
53+
monitorPubkey: string,
54+
settings: Settings,
55+
privkey: string,
56+
createdAt: number,
57+
): Promise<void> {
58+
const bootstrapped = await this.cache.getKey(NIP66_MONITOR_BOOTSTRAPPED_KEY)
59+
60+
if (bootstrapped === monitorPubkey) {
61+
return
62+
}
63+
64+
const relayUrl = settings.info.relay_url
65+
66+
await this.persistSignedEvent(buildMonitorProfileEvent(monitorPubkey, createdAt), privkey)
67+
await this.persistSignedEvent(buildMonitorRelayListEvent(relayUrl, monitorPubkey, createdAt), privkey)
68+
69+
await this.cache.setKey(NIP66_MONITOR_BOOTSTRAPPED_KEY, monitorPubkey)
70+
logger('bootstrapped NIP-66 monitor identity for pubkey %s', monitorPubkey)
71+
}
72+
73+
private async persistSignedEvent(unsigned: UnidentifiedEvent, privkey: string): Promise<void> {
74+
const signed = await signEvent(privkey)(await identifyEvent(unsigned))
75+
76+
if (isParameterizedReplaceableEvent(signed)) {
77+
const [, deduplication] = signed.tags.find((tag) => tag.length >= 2 && tag[0] === EventTags.Deduplication) ?? [
78+
null,
79+
'',
80+
]
81+
82+
await this.eventRepository.upsert({
83+
...signed,
84+
[EventDeduplicationMetadataKey]: deduplication ? [deduplication] : [''],
85+
} as ParameterizedReplaceableEvent)
86+
87+
return
88+
}
89+
90+
await this.eventRepository.upsert(signed)
91+
}
92+
}

src/utils/monitor-identity.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
let monitorPrivateKeyCache: string | undefined
2+
3+
export const getMonitorPrivateKey = (): string | undefined => {
4+
if (monitorPrivateKeyCache) {
5+
return monitorPrivateKeyCache
6+
}
7+
8+
const configured = process.env.MONITOR_PRIVATE_KEY?.trim()
9+
10+
if (!configured) {
11+
return undefined
12+
}
13+
14+
monitorPrivateKeyCache = configured
15+
16+
return monitorPrivateKeyCache
17+
}
18+
19+
export const resetMonitorPrivateKeyCache = (): void => {
20+
monitorPrivateKeyCache = undefined
21+
}

src/utils/nip66-events.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { UnidentifiedEvent } from '../@types/event'
2+
import { Tag } from '../@types/base'
3+
import { StoredProbeResult } from '../@types/relay-probe-snapshot'
4+
import { Settings } from '../@types/settings'
5+
import { EventKinds, EventTags } from '../constants/base'
6+
7+
const DEFAULT_PROBE_INTERVAL_SECONDS = 3600
8+
const MIN_PROBE_INTERVAL_SECONDS = 60
9+
10+
export const getEffectiveProbeIntervalSeconds = (settings: Settings): number => {
11+
const configured = settings.nip66?.probeIntervalSeconds ?? DEFAULT_PROBE_INTERVAL_SECONDS
12+
13+
return Math.max(configured, MIN_PROBE_INTERVAL_SECONDS)
14+
}
15+
16+
const appendDnsProbeTags = (tags: Tag[], dns: StoredProbeResult['dns']): void => {
17+
if (dns.status === 'skipped') {
18+
tags.push(['dns', 'skipped'])
19+
return
20+
}
21+
22+
if (dns.status === 'error') {
23+
tags.push(['dns', '!resolved'])
24+
return
25+
}
26+
27+
tags.push(['dns', 'resolved'])
28+
}
29+
30+
const appendTlsProbeTags = (tags: Tag[], tls: StoredProbeResult['tls']): void => {
31+
if (tls.status === 'skipped') {
32+
tags.push(['ssl', 'skipped'])
33+
return
34+
}
35+
36+
if (tls.status === 'error') {
37+
tags.push(['ssl', '!valid'])
38+
return
39+
}
40+
41+
const valid = tls.data?.valid === true
42+
tags.push(['ssl', valid ? 'valid' : '!valid'])
43+
44+
if (tls.data?.expiresAt) {
45+
const expiresAtSeconds = Math.floor(new Date(tls.data.expiresAt).getTime() / 1000)
46+
tags.push(['ssl-expires', String(expiresAtSeconds)])
47+
}
48+
49+
if (tls.data?.issuer) {
50+
tags.push(['ssl-issuer', tls.data.issuer])
51+
}
52+
}
53+
54+
export const normalizeRelayUrlForDTag = (relayUrl: string): string => {
55+
const parsed = new URL(relayUrl)
56+
parsed.protocol = parsed.protocol.toLowerCase()
57+
parsed.hostname = parsed.hostname.toLowerCase()
58+
59+
if (
60+
(parsed.protocol === 'wss:' && parsed.port === '443') ||
61+
(parsed.protocol === 'ws:' && parsed.port === '80')
62+
) {
63+
parsed.port = ''
64+
}
65+
66+
let normalized = parsed.toString()
67+
68+
if ((parsed.pathname === '/' || parsed.pathname === '') && !normalized.endsWith('/')) {
69+
normalized = `${normalized}/`
70+
}
71+
72+
return normalized
73+
}
74+
75+
export const buildRelayDiscoveryEvent = (
76+
result: StoredProbeResult,
77+
monitorPubkey: string,
78+
createdAt: number,
79+
): UnidentifiedEvent => {
80+
const tags: Tag[] = [
81+
[EventTags.Deduplication, normalizeRelayUrlForDTag(result.target.relayUrl)],
82+
['n', result.target.networkType],
83+
]
84+
85+
if (result.wsRtt.status === 'ok' && typeof result.wsRtt.data?.rttOpenMs === 'number') {
86+
tags.push(['rtt-open', String(result.wsRtt.data.rttOpenMs)])
87+
}
88+
89+
appendDnsProbeTags(tags, result.dns)
90+
appendTlsProbeTags(tags, result.tls)
91+
92+
return {
93+
kind: EventKinds.RELAY_DISCOVERY,
94+
pubkey: monitorPubkey,
95+
created_at: createdAt,
96+
content: '',
97+
tags,
98+
}
99+
}
100+
101+
export const buildMonitorAnnouncementEvent = (
102+
settings: Settings,
103+
monitorPubkey: string,
104+
createdAt: number,
105+
): UnidentifiedEvent => {
106+
const nip66 = settings.nip66
107+
const timeouts = nip66?.timeouts
108+
109+
const tags: Tag[] = [
110+
['frequency', String(getEffectiveProbeIntervalSeconds(settings))],
111+
['c', 'ws'],
112+
['c', 'nip11'],
113+
['c', 'ssl'],
114+
['c', 'dns'],
115+
]
116+
117+
if (timeouts) {
118+
tags.push(['timeout', 'open', String(timeouts.wsRttMs)])
119+
tags.push(['timeout', 'nip11', String(timeouts.nip11Ms)])
120+
tags.push(['timeout', 'dns', String(timeouts.dnsMs)])
121+
tags.push(['timeout', 'ssl', String(timeouts.tlsMs)])
122+
}
123+
124+
return {
125+
kind: EventKinds.RELAY_MONITOR_ANNOUNCEMENT,
126+
pubkey: monitorPubkey,
127+
created_at: createdAt,
128+
content: '',
129+
tags,
130+
}
131+
}
132+
133+
export const buildMonitorProfileEvent = (monitorPubkey: string, createdAt: number): UnidentifiedEvent => {
134+
return {
135+
kind: EventKinds.SET_METADATA,
136+
pubkey: monitorPubkey,
137+
created_at: createdAt,
138+
content: JSON.stringify({
139+
name: 'Nostream Relay Monitor',
140+
about: 'Automated NIP-66 relay health monitor for this Nostream instance.',
141+
}),
142+
tags: [],
143+
}
144+
}
145+
146+
export const buildMonitorRelayListEvent = (
147+
relayUrl: string,
148+
monitorPubkey: string,
149+
createdAt: number,
150+
): UnidentifiedEvent => {
151+
return {
152+
kind: EventKinds.RELAY_LIST,
153+
pubkey: monitorPubkey,
154+
created_at: createdAt,
155+
content: '',
156+
tags: [
157+
[EventTags.Relay, relayUrl, 'read'],
158+
[EventTags.Relay, relayUrl, 'write'],
159+
],
160+
}
161+
}

0 commit comments

Comments
 (0)