Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/nip66-v2-advanced-probes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"nostream": minor
---

feat(nip66): add read/write RTT probes, NIP-42 detection, mirror targets, and enriched kind 30166 tags

Extends the relay monitor with WebSocket read/write probes, optional NIP-42 auth detection, probing of `mirroring.static[]` peers, and richer NIP-66 discovery and monitor announcement events.

Closes #794
3 changes: 2 additions & 1 deletion CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,8 @@ The settings below are listed in alphabetical order by name. Please keep this ta
| 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.targets | Public WebSocket URLs to probe (for example `wss://relay.example.com`). When empty, defaults to `info.relay_url`. Addresses from `mirroring.static[]` are probed for the admin snapshot but are not published as kind `30166` discovery events. |
| nip66.geohash | Optional NIP-52 geohash describing this monitor's network vantage. Published on kind `10166` monitor announcements when set. |
| 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. |
Expand Down
18 changes: 17 additions & 1 deletion resources/admin/assets/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -517,7 +517,23 @@
},
{ tlsDaysUntilExpiry },
)
const wsRtt = formatProbeCheckDetail(result.wsRtt, (data) => `${data.rttOpenMs} ms`)
const wsRtt = formatProbeCheckDetail(result.wsRtt, (data) => {
const parts = [`open ${data.rttOpenMs} ms`]

if (typeof data.rttReadMs === 'number') {
parts.push(`read ${data.rttReadMs} ms`)
}

if (typeof data.rttWriteMs === 'number') {
parts.push(`write ${data.rttWriteMs} ms`)
}

if (data.nip42AuthRequired === true) {
parts.push('NIP-42 auth required')
}

return parts.join(' · ')
})
const nip11 = formatProbeCheckDetail(result.nip11, (data) => {
const name = data?.name ? ` ${data.name}` : ''
const supportedNips = Array.isArray(data?.supportedNips) ? data.supportedNips : null
Expand Down
4 changes: 4 additions & 0 deletions src/@types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,10 @@ export interface Nip66Settings {
* Defaults to 300.
*/
dnsCacheTtlSeconds: number
/**
* Optional NIP-52 geohash describing this monitor's network vantage for kind 10166 events.
*/
geohash?: string
}

export interface Nip05Settings {
Expand Down
3 changes: 3 additions & 0 deletions src/app/relay-monitor-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { shutdownMetricsTelemetry } from '../telemetry/metrics'
import { filterValidProbeTargets, resolveProbeTargets } from '../utils/relay-probe-targets'
import { deriveRelayProbeRunStatus, serializeProbeResults } from '../utils/relay-probe-snapshot'
import { getEffectiveProbeIntervalSeconds, getProbeIntervalMs } from '../utils/nip66-schedule'
import { getMonitorPrivateKey } from '../utils/monitor-identity'
import { runProbe } from '../utils/relay-probe'
import { ProbeOptions, ProbeResult } from '../utils/relay-probe/types'

Expand All @@ -16,10 +17,12 @@ export type RunProbeFn = (relayUrl: string, options?: ProbeOptions) => Promise<P

export const buildProbeOptions = (settings: Settings): ProbeOptions => {
const nip66 = settings.nip66
const monitorPrivateKey = getMonitorPrivateKey()

return {
timeouts: nip66?.timeouts,
dnsCacheTtlSeconds: nip66?.dnsCacheTtlSeconds,
...(monitorPrivateKey ? { monitorPrivateKey } : {}),
}
}

Expand Down
27 changes: 24 additions & 3 deletions src/services/nip66-event-publisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import {
buildMonitorRelayListEvent,
buildRelayDiscoveryEvent,
} from '../utils/nip66-events'
import { filterValidProbeTargets } from '../utils/relay-probe-targets'
import { normalizeRelayUrlForDTag } from '../utils/nip66-events'
import { filterValidProbeTargets, resolvePublicProbeTargetKeys } from '../utils/relay-probe-targets'

const logger = createLogger('nip66-event-publisher')

Expand Down Expand Up @@ -45,11 +46,31 @@ export class Nip66EventPublisher implements INip66EventPublisher {

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

const publishableTargetKeys = resolvePublicProbeTargetKeys(settings)
let publishedTargets = 0

for (const result of snapshot.results) {
let targetKey: string

try {
targetKey = normalizeRelayUrlForDTag(result.target.relayUrl)
} catch {
continue
}

if (!publishableTargetKeys.has(targetKey)) {
continue
}

await this.persistSignedEvent(buildRelayDiscoveryEvent(result, monitorPubkey, createdAt), privkey)
publishedTargets += 1
}

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

private async ensureBootstrap(
Expand All @@ -72,7 +93,7 @@ export class Nip66EventPublisher implements INip66EventPublisher {
return
}

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

await this.cache.setKey(NIP66_MONITOR_BOOTSTRAPPED_KEY, monitorPubkey, NIP66_MONITOR_BOOTSTRAP_TTL_SECONDS)
Expand Down
56 changes: 47 additions & 9 deletions src/utils/nip66-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,25 @@ import { Tag } from '../@types/base'
import { StoredProbeResult } from '../@types/relay-probe-snapshot'
import { Settings } from '../@types/settings'
import { EventKinds, EventTags } from '../constants/base'
import { geohashSchema } from '../schemas/base-schema'
import { appendNip11DiscoveryTags, appendWsProbeDiscoveryTags } from './nip66-nip11-tags'
import { getEffectiveProbeIntervalSeconds } from './nip66-schedule'

/** Matches default kind 30166 content limit in resources/default-settings.yaml. */
export const MAX_RELAY_DISCOVERY_CONTENT_LENGTH = 102_400

export const relayDiscoveryContentFromNip11 = (rawDocument?: string): string => {
if (!rawDocument) {
return ''
}

if (rawDocument.length > MAX_RELAY_DISCOVERY_CONTENT_LENGTH) {
return ''
}

return rawDocument
}

const appendDnsProbeTags = (tags: Tag[], dns: StoredProbeResult['dns']): void => {
if (dns.status === 'skipped') {
tags.push(['dns', 'skipped'])
Expand Down Expand Up @@ -74,18 +91,21 @@ export const buildRelayDiscoveryEvent = (
['n', result.target.networkType],
]

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

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

const nip11Content =
result.nip11.status === 'ok' && typeof result.nip11.data?.rawDocument === 'string'
? relayDiscoveryContentFromNip11(result.nip11.data.rawDocument)
: ''

return {
kind: EventKinds.RELAY_DISCOVERY,
pubkey: monitorPubkey,
created_at: createdAt,
content: '',
content: nip11Content,
Comment thread
Ferryx349 marked this conversation as resolved.
tags,
}
}
Expand All @@ -100,14 +120,25 @@ export const buildMonitorAnnouncementEvent = (

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

const geohash = settings.nip66?.geohash?.trim()

if (geohash && geohashSchema.safeParse(geohash).success) {
tags.push(['g', geohash])
}

if (timeouts) {
tags.push(['timeout', 'open', String(timeouts.wsRttMs)])
tags.push(['timeout', 'read', String(timeouts.wsRttMs)])
tags.push(['timeout', 'write', String(timeouts.wsRttMs)])
tags.push(['timeout', 'nip11', String(timeouts.nip11Ms)])
tags.push(['timeout', 'dns', String(timeouts.dnsMs)])
tags.push(['timeout', 'ssl', String(timeouts.tlsMs)])
Expand All @@ -122,14 +153,21 @@ export const buildMonitorAnnouncementEvent = (
}
}

export const buildMonitorProfileEvent = (monitorPubkey: string, createdAt: number): UnidentifiedEvent => {
export const buildMonitorProfileEvent = (
monitorPubkey: string,
createdAt: number,
settings?: Settings,
): UnidentifiedEvent => {
const relayName = settings?.info?.name?.trim()

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.',
name: relayName ? `${relayName} (self-hosted monitor)` : 'Nostream self-hosted relay monitor',
about:
'Self-hosted NIP-66 monitor for this Nostream relay. Measurements reflect this relay’s network vantage only and are not authoritative for the wider network.',
}),
tags: [],
}
Expand Down
65 changes: 65 additions & 0 deletions src/utils/nip66-nip11-tags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Tag } from '../@types/base'
import { StoredProbeResult } from '../@types/relay-probe-snapshot'
import { Nip11Limitation } from './relay-probe/types'

const requirementTag = (name: string, required: boolean | undefined): Tag | undefined => {
if (required === undefined) {
return undefined
}

return ['R', required ? name : `!${name}`]
}

export const appendNip11DiscoveryTags = (tags: Tag[], result: StoredProbeResult): void => {
const nip11 = result.nip11.status === 'ok' ? result.nip11.data : undefined

if (!nip11) {
return
}

for (const nip of nip11.supportedNips ?? []) {
tags.push(['N', String(nip)])
}

const limitation: Nip11Limitation | undefined = nip11.limitation

const requirementTags = [
requirementTag('auth', limitation?.authRequired),
requirementTag('writes', limitation?.restrictedWrites),
requirementTag('payment', limitation?.paymentRequired),
requirementTag(
'pow',
limitation?.minPowDifficulty === undefined ? undefined : limitation.minPowDifficulty > 0,
),
].filter((tag): tag is Tag => tag !== undefined)

tags.push(...requirementTags)

for (const kind of nip11.acceptedKinds ?? []) {
tags.push(['k', String(kind)])
}
}

export const appendWsProbeDiscoveryTags = (tags: Tag[], result: StoredProbeResult): void => {
const ws = result.wsRtt.status === 'ok' ? result.wsRtt.data : undefined

if (!ws) {
return
}

if (typeof ws.rttOpenMs === 'number') {
tags.push(['rtt-open', String(ws.rttOpenMs)])
}

if (typeof ws.rttReadMs === 'number') {
tags.push(['rtt-read', String(ws.rttReadMs)])
}

if (typeof ws.rttWriteMs === 'number') {
tags.push(['rtt-write', String(ws.rttWriteMs)])
}

if (ws.nip42AuthRequired === true) {
tags.push(['R', 'auth'])
}
}
72 changes: 68 additions & 4 deletions src/utils/relay-probe-targets.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,79 @@
import { Settings } from '../@types/settings'
import { parseProbeTarget } from './relay-probe'
import { normalizeRelayUrlForDTag } from './nip66-events'

export const resolveProbeTargets = (settings: Settings): string[] => {
const addUniqueTarget = (targets: string[], seen: Set<string>, candidate: string | undefined): void => {
const trimmed = candidate?.trim()

if (!trimmed) {
return
}

let dedupeKey: string

try {
dedupeKey = normalizeRelayUrlForDTag(trimmed)
} catch {
dedupeKey = trimmed.toLowerCase()
}

if (seen.has(dedupeKey)) {
return
}

seen.add(dedupeKey)
targets.push(trimmed)
}

/** Targets eligible for public kind 30166 publish (never includes mirroring.static peers). */
export const resolvePublicProbeTargets = (settings: Settings): string[] => {
const targets: string[] = []
const seen = new Set<string>()
const configured = settings.nip66?.targets?.map((target) => target.trim()).filter(Boolean) ?? []

if (configured.length > 0) {
return configured
for (const target of configured) {
addUniqueTarget(targets, seen, target)
}
} else {
addUniqueTarget(targets, seen, settings.info?.relay_url)
}

return targets
}

export const resolvePublicProbeTargetKeys = (settings: Settings): Set<string> => {
const keys = new Set<string>()

for (const target of resolvePublicProbeTargets(settings)) {
try {
keys.add(normalizeRelayUrlForDTag(target))
} catch {
keys.add(target.toLowerCase())
}
}

return keys
}

/** All probe targets: public targets plus configured static mirrors (operator snapshot only). */
export const resolveProbeTargets = (settings: Settings): string[] => {
const targets = resolvePublicProbeTargets(settings)
const seen = new Set<string>()

for (const target of targets) {
try {
seen.add(normalizeRelayUrlForDTag(target))
} catch {
seen.add(target.toLowerCase())
}
}

for (const mirror of settings.mirroring?.static ?? []) {
addUniqueTarget(targets, seen, mirror.address)
}
Comment thread
Ferryx349 marked this conversation as resolved.

const relayUrl = settings.info?.relay_url?.trim()
return relayUrl ? [relayUrl] : []
return targets
}

export const filterValidProbeTargets = (targets: string[]): { valid: string[]; invalid: string[] } => {
Expand Down
Loading
Loading