Skip to content
Merged
11 changes: 11 additions & 0 deletions .changeset/nip66-publish-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"nostream": minor
---

feat(nip66): publish kind 30166 and 10166 relay health events after probe runs

After each relay monitor probe run, sign and store NIP-66 relay discovery and monitor
announcement events using the configured monitor identity, bootstrap kind 0/10002 on
first run, and persist via the existing parameterized replaceable event path.

Fixes #696
24 changes: 13 additions & 11 deletions src/app/relay-monitor-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,16 @@ 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 { INip66EventPublisher } from '../services/nip66-event-publisher'
import { shutdownMetricsTelemetry } from '../telemetry/metrics'
import { getEffectiveProbeIntervalSeconds } from '../utils/nip66-events'
import { filterValidProbeTargets, resolveProbeTargets } from '../utils/relay-probe-targets'
import { deriveRelayProbeRunStatus, serializeProbeResults } 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<ProbeResult>

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

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
return getEffectiveProbeIntervalSeconds(settings) * 1000
}

export class RelayMonitorWorker implements IRunnable {
Expand All @@ -40,6 +36,7 @@ export class RelayMonitorWorker implements IRunnable {
private readonly settings: () => Settings,
private readonly snapshotStore: IRelayProbeSnapshotStore,
private readonly probeRunner: RunProbeFn = runProbe,
private readonly eventPublisher?: INip66EventPublisher,
) {
this.process
.on('SIGINT', this.onExit.bind(this))
Expand Down Expand Up @@ -128,13 +125,18 @@ export class RelayMonitorWorker implements IRunnable {
status: deriveRelayProbeRunStatus(results),
}

const expirySeconds = Math.max(
(currentSettings.nip66?.probeIntervalSeconds ?? DEFAULT_PROBE_INTERVAL_SECONDS) * 2,
MIN_PROBE_INTERVAL_SECONDS * 2,
)
const expirySeconds = getEffectiveProbeIntervalSeconds(currentSettings) * 2

await this.snapshotStore.saveLatest(snapshot, expirySeconds)
logger('saved probe snapshot for %d target(s) with status %s', valid.length, snapshot.status)

if (this.eventPublisher) {
try {
await this.eventPublisher.publishAfterProbe(snapshot, currentSettings)
} catch (error) {
logger.error('failed to publish NIP-66 events: %o', error)
}
}
}

private onError(error: Error) {
Expand Down
4 changes: 4 additions & 0 deletions src/constants/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export enum EventKinds {
REPLACEABLE_FIRST = 10000,
// NIP-65: Relay List Metadata
RELAY_LIST = 10002,
// NIP-66: Relay monitor announcement
RELAY_MONITOR_ANNOUNCEMENT = 10166,
// Marmot Protocol MIP-00: KeyPackage Relay List
MARMOT_KEY_PACKAGE_RELAY_LIST = 10051,
// NIP-43: Membership List
Expand All @@ -71,6 +73,8 @@ export enum EventKinds {
EPHEMERAL_LAST = 29999,
// Parameterized replaceable events
PARAMETERIZED_REPLACEABLE_FIRST = 30000,
// NIP-66: Relay discovery
RELAY_DISCOVERY = 30166,
// Marmot Protocol MIP-00: KeyPackage (addressable, replaces legacy 443)
MARMOT_KEY_PACKAGE = 30443,
// NIP-89: Recommended Application Handlers
Expand Down
11 changes: 9 additions & 2 deletions src/factories/relay-monitor-worker-factory.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { RedisAdapter } from '../adapters/redis-adapter'
import { RelayMonitorWorker } from '../app/relay-monitor-worker'
import { getCacheClient } from '../cache/client'
import { getMasterDbClient, getReadReplicaDbClient } from '../database/client'
import { createSettings } from './settings-factory'
import { EventRepository } from '../repositories/event-repository'
import { Nip66EventPublisher } from '../services/nip66-event-publisher'
import { RelayProbeSnapshotStore } from '../utils/relay-probe-snapshot'
import { runProbe } from '../utils/relay-probe'

export const relayMonitorWorkerFactory = () => {
const snapshotStore = new RelayProbeSnapshotStore(new RedisAdapter(getCacheClient()))
const cache = new RedisAdapter(getCacheClient())
const snapshotStore = new RelayProbeSnapshotStore(cache)
const eventRepository = new EventRepository(getMasterDbClient(), getReadReplicaDbClient(), createSettings)
const eventPublisher = new Nip66EventPublisher(eventRepository, cache)

return new RelayMonitorWorker(process, createSettings, snapshotStore)
return new RelayMonitorWorker(process, createSettings, snapshotStore, runProbe, eventPublisher)
}
92 changes: 92 additions & 0 deletions src/services/nip66-event-publisher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { ICacheAdapter } from '../@types/adapters'
import { ParameterizedReplaceableEvent, UnidentifiedEvent } from '../@types/event'
import { RelayProbeRunSnapshot } from '../@types/relay-probe-snapshot'
import { IEventRepository } from '../@types/repositories'
import { Settings } from '../@types/settings'
import { EventDeduplicationMetadataKey, EventTags } from '../constants/base'
import { createLogger } from '../factories/logger-factory'
import { getPublicKey, identifyEvent, isParameterizedReplaceableEvent, signEvent } from '../utils/event'
import { getMonitorPrivateKey } from '../utils/monitor-identity'
import {
buildMonitorAnnouncementEvent,
buildMonitorProfileEvent,
buildMonitorRelayListEvent,
buildRelayDiscoveryEvent,
} from '../utils/nip66-events'
const logger = createLogger('nip66-event-publisher')

export const NIP66_MONITOR_BOOTSTRAPPED_KEY = 'nip66:monitor:bootstrapped'

export interface INip66EventPublisher {
publishAfterProbe(snapshot: RelayProbeRunSnapshot, settings: Settings): Promise<void>
}

export class Nip66EventPublisher implements INip66EventPublisher {
public constructor(
private readonly eventRepository: IEventRepository,
private readonly cache: ICacheAdapter,
) {}

public async publishAfterProbe(snapshot: RelayProbeRunSnapshot, settings: Settings): Promise<void> {
const privkey = getMonitorPrivateKey()

if (!privkey) {
logger.warn('MONITOR_PRIVATE_KEY is not configured; skipping NIP-66 event publish')
return
}

const monitorPubkey = getPublicKey(privkey)
const createdAt = Math.floor(Date.now() / 1000)

await this.ensureBootstrap(monitorPubkey, settings, privkey, createdAt)

await this.persistSignedEvent(buildMonitorAnnouncementEvent(settings, monitorPubkey, createdAt), privkey)

for (const result of snapshot.results) {
await this.persistSignedEvent(buildRelayDiscoveryEvent(result, monitorPubkey, createdAt), privkey)
}

logger('published NIP-66 events for %d probe target(s)', snapshot.results.length)
}

private async ensureBootstrap(
monitorPubkey: string,
settings: Settings,
privkey: string,
createdAt: number,
): Promise<void> {
const bootstrapped = await this.cache.getKey(NIP66_MONITOR_BOOTSTRAPPED_KEY)

if (bootstrapped === monitorPubkey) {
return
}

const relayUrl = settings.info.relay_url

await this.persistSignedEvent(buildMonitorProfileEvent(monitorPubkey, createdAt), privkey)
await this.persistSignedEvent(buildMonitorRelayListEvent(relayUrl, monitorPubkey, createdAt), privkey)

await this.cache.setKey(NIP66_MONITOR_BOOTSTRAPPED_KEY, monitorPubkey)
logger('bootstrapped NIP-66 monitor identity for pubkey %s', monitorPubkey)
}

private async persistSignedEvent(unsigned: UnidentifiedEvent, privkey: string): Promise<void> {
const signed = await signEvent(privkey)(await identifyEvent(unsigned))

if (isParameterizedReplaceableEvent(signed)) {
const [, deduplication] = signed.tags.find((tag) => tag.length >= 2 && tag[0] === EventTags.Deduplication) ?? [
null,
'',
]

await this.eventRepository.upsert({
...signed,
[EventDeduplicationMetadataKey]: deduplication ? [deduplication] : [''],
} as ParameterizedReplaceableEvent)

return
}

await this.eventRepository.upsert(signed)
}
}
21 changes: 21 additions & 0 deletions src/utils/monitor-identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
let monitorPrivateKeyCache: string | undefined

export const getMonitorPrivateKey = (): string | undefined => {
if (monitorPrivateKeyCache) {
return monitorPrivateKeyCache
}

const configured = process.env.MONITOR_PRIVATE_KEY?.trim()

if (!configured) {
return undefined
}

monitorPrivateKeyCache = configured

return monitorPrivateKeyCache
}

export const resetMonitorPrivateKeyCache = (): void => {
monitorPrivateKeyCache = undefined
}
161 changes: 161 additions & 0 deletions src/utils/nip66-events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { UnidentifiedEvent } from '../@types/event'
import { Tag } from '../@types/base'
import { StoredProbeResult } from '../@types/relay-probe-snapshot'
import { Settings } from '../@types/settings'
import { EventKinds, EventTags } from '../constants/base'

const DEFAULT_PROBE_INTERVAL_SECONDS = 3600
const MIN_PROBE_INTERVAL_SECONDS = 60

export const getEffectiveProbeIntervalSeconds = (settings: Settings): number => {
const configured = settings.nip66?.probeIntervalSeconds ?? DEFAULT_PROBE_INTERVAL_SECONDS

return Math.max(configured, MIN_PROBE_INTERVAL_SECONDS)
}

const appendDnsProbeTags = (tags: Tag[], dns: StoredProbeResult['dns']): void => {
if (dns.status === 'skipped') {
tags.push(['dns', 'skipped'])
return
}

if (dns.status === 'error') {
tags.push(['dns', '!resolved'])
return
}

tags.push(['dns', 'resolved'])
}

const appendTlsProbeTags = (tags: Tag[], tls: StoredProbeResult['tls']): void => {
if (tls.status === 'skipped') {
tags.push(['ssl', 'skipped'])
return
}

if (tls.status === 'error') {
tags.push(['ssl', '!valid'])
return
}

const valid = tls.data?.valid === true
tags.push(['ssl', valid ? 'valid' : '!valid'])

if (tls.data?.expiresAt) {
const expiresAtSeconds = Math.floor(new Date(tls.data.expiresAt).getTime() / 1000)
tags.push(['ssl-expires', String(expiresAtSeconds)])
}

if (tls.data?.issuer) {
tags.push(['ssl-issuer', tls.data.issuer])
}
}

export const normalizeRelayUrlForDTag = (relayUrl: string): string => {
const parsed = new URL(relayUrl)
parsed.protocol = parsed.protocol.toLowerCase()
parsed.hostname = parsed.hostname.toLowerCase()

if (
(parsed.protocol === 'wss:' && parsed.port === '443') ||
(parsed.protocol === 'ws:' && parsed.port === '80')
) {
parsed.port = ''
}

let normalized = parsed.toString()

if ((parsed.pathname === '/' || parsed.pathname === '') && !normalized.endsWith('/')) {
normalized = `${normalized}/`
}

return normalized
}

export const buildRelayDiscoveryEvent = (
result: StoredProbeResult,
monitorPubkey: string,
createdAt: number,
): UnidentifiedEvent => {
const tags: Tag[] = [
[EventTags.Deduplication, normalizeRelayUrlForDTag(result.target.relayUrl)],
['n', result.target.networkType],
]
Comment thread
Ferryx349 marked this conversation as resolved.

if (result.wsRtt.status === 'ok' && typeof result.wsRtt.data?.rttOpenMs === 'number') {
tags.push(['rtt-open', String(result.wsRtt.data.rttOpenMs)])
}

appendDnsProbeTags(tags, result.dns)
appendTlsProbeTags(tags, result.tls)

return {
kind: EventKinds.RELAY_DISCOVERY,
pubkey: monitorPubkey,
created_at: createdAt,
content: '',
tags,
}
}

export const buildMonitorAnnouncementEvent = (
settings: Settings,
monitorPubkey: string,
createdAt: number,
): UnidentifiedEvent => {
const nip66 = settings.nip66
const timeouts = nip66?.timeouts

const tags: Tag[] = [
['frequency', String(getEffectiveProbeIntervalSeconds(settings))],
['c', 'ws'],
['c', 'nip11'],
['c', 'ssl'],
['c', 'dns'],
]

if (timeouts) {
tags.push(['timeout', 'open', String(timeouts.wsRttMs)])
tags.push(['timeout', 'nip11', String(timeouts.nip11Ms)])
tags.push(['timeout', 'dns', String(timeouts.dnsMs)])
tags.push(['timeout', 'ssl', String(timeouts.tlsMs)])
}

return {
kind: EventKinds.RELAY_MONITOR_ANNOUNCEMENT,
pubkey: monitorPubkey,
created_at: createdAt,
content: '',
tags,
}
}

export const buildMonitorProfileEvent = (monitorPubkey: string, createdAt: number): UnidentifiedEvent => {
return {
kind: EventKinds.SET_METADATA,
pubkey: monitorPubkey,
created_at: createdAt,
content: JSON.stringify({
name: 'Nostream Relay Monitor',
about: 'Automated NIP-66 relay health monitor for this Nostream instance.',
}),
tags: [],
}
}

export const buildMonitorRelayListEvent = (
relayUrl: string,
monitorPubkey: string,
createdAt: number,
): UnidentifiedEvent => {
return {
kind: EventKinds.RELAY_LIST,
pubkey: monitorPubkey,
created_at: createdAt,
content: '',
tags: [
[EventTags.Relay, relayUrl, 'read'],
[EventTags.Relay, relayUrl, 'write'],
],
}
}
Loading
Loading