You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

191 lines
5.6 KiB

/**
* NIP-66 relay monitor cron. Runs on the server; nsec stays in env, never sent to client.
* - On startup: publish kind 10166 (monitor announcement) once.
* - Every INTERVAL_MS: for each relay in RELAYS_TO_MONITOR, fetch NIP-11, build & publish 30166.
*
* Env:
* NIP66_MONITOR_NSEC - required; nsec for signing 30166/10166
* RELAYS_TO_MONITOR - optional; comma-separated wss:// URLs. Default: built-in list.
* PUBLISH_RELAYS - optional; comma-separated relays to publish to. Default: built-in list.
* INTERVAL_MS - optional; ms between full monitor runs (default 3600000 = 1h)
*/
import { finalizeEvent, nip19 } from 'nostr-tools'
import WebSocket from 'ws'
const RELAY_DISCOVERY_KIND = 30166
const RELAY_MONITOR_ANNOUNCEMENT_KIND = 10166
const DEFAULT_RELAYS_TO_MONITOR = [
'wss://theforest.nostr1.com',
'wss://orly-relay.imwald.eu',
'wss://nostr.land',
'wss://thecitadel.nostr1.com',
'wss://relay.damus.io',
'wss://relay.primal.net',
'wss://nos.lol'
]
const DEFAULT_PUBLISH_RELAYS = [
'wss://theforest.nostr1.com',
'wss://thecitadel.nostr1.com',
'wss://relay.damus.io',
'wss://relay.nostr.watch'
]
const INTERVAL_MS = Number(process.env.INTERVAL_MS) || 3600000 // 1 hour
function log (msg, data = {}) {
const ts = new Date().toISOString()
console.log(ts, '[nip66-cron]', msg, Object.keys(data).length ? JSON.stringify(data) : '')
}
function normalizeRelayUrl (url) {
try {
const u = url.replace(/^ws:\/\//, 'wss://')
const p = new URL(u.startsWith('wss://') ? u : `wss://${u}`)
p.pathname = p.pathname.replace(/\/+/g, '/').replace(/\/$/, '') || '/'
return p.toString()
} catch {
return url
}
}
/** Returns decoded secret key from env. Never log or expose process.env.NIP66_MONITOR_NSEC. */
function getSecretKey () {
const raw = process.env.NIP66_MONITOR_NSEC
if (!raw || typeof raw !== 'string') return null
try {
const { type, data } = nip19.decode(raw)
if (type !== 'nsec') return null
return data
} catch {
return null
}
}
async function fetchNip11 (relayUrl) {
const httpUrl = relayUrl.replace(/^wss:\/\//, 'https://').replace(/^ws:\/\//, 'http://')
try {
const res = await fetch(httpUrl, { headers: { Accept: 'application/nostr+json' } })
if (!res.ok) return null
return await res.json()
} catch (err) {
log('NIP-11 fetch failed', { url: relayUrl, err: err.message })
return null
}
}
function build30166 (relayUrl, nip11, sk) {
const d = normalizeRelayUrl(relayUrl)
const tags = [['d', d]]
const nips = nip11?.supported_nips
if (Array.isArray(nips)) {
for (const n of nips) tags.push(['N', String(n)])
}
const lim = nip11?.limitation
tags.push(['R', lim?.auth_required ? 'auth' : '!auth'])
tags.push(['R', lim?.payment_required ? 'payment' : '!payment'])
const draft = {
kind: RELAY_DISCOVERY_KIND,
created_at: Math.floor(Date.now() / 1000),
content: '',
tags
}
return finalizeEvent(draft, sk)
}
function build10166 (sk) {
const draft = {
kind: RELAY_MONITOR_ANNOUNCEMENT_KIND,
created_at: Math.floor(Date.now() / 1000),
content: '',
tags: [['frequency', '3600'], ['c', 'nip11'], ['c', 'ws']]
}
return finalizeEvent(draft, sk)
}
function parseListEnv (envVar, defaultList) {
const raw = process.env[envVar]
if (!raw || typeof raw !== 'string') return defaultList
return raw.split(',').map(s => s.trim()).filter(Boolean)
}
async function publishEvent (relayUrls, event) {
const msg = JSON.stringify(['EVENT', event])
let ok = 0
const conns = []
for (const url of relayUrls) {
try {
const ws = new WebSocket(url, { handshakeTimeout: 8000 })
await new Promise((resolve, reject) => {
ws.on('open', resolve)
ws.on('error', reject)
setTimeout(() => reject(new Error('open timeout')), 10000)
})
conns.push(ws)
ws.send(msg)
await new Promise((resolve) => {
const onResp = (data) => {
try {
const j = JSON.parse(data.toString())
if (j[0] === 'OK' && j[1] === event.id) {
ok++
if (j[2] === true) { /* accepted */ } else { log('Relay rejected event', { url, reason: j[2] }) }
}
} finally {
resolve()
}
}
ws.once('message', onResp)
setTimeout(resolve, 3000)
})
} catch (err) {
log('Publish relay error', { url, err: err.message })
}
}
for (const ws of conns) {
try { ws.close() } catch (_) {}
}
return ok
}
async function run10166 (sk, publishRelays) {
const event = build10166(sk)
log('Publishing 10166 (monitor announcement)')
const count = await publishEvent(publishRelays, event)
log('Published 10166', { successCount: count })
}
async function run30166Round (sk, relaysToMonitor, publishRelays) {
for (const relayUrl of relaysToMonitor) {
const nip11 = await fetchNip11(relayUrl)
if (!nip11) continue
const event = build30166(relayUrl, nip11, sk)
const count = await publishEvent(publishRelays, event)
log('Published 30166', { url: relayUrl, successCount: count })
}
}
async function main () {
const sk = getSecretKey()
if (!sk) {
log('No NIP66_MONITOR_NSEC set; exiting')
process.exit(0)
}
log('NIP-66 monitor cron started (nsec configured)')
const relaysToMonitor = parseListEnv('RELAYS_TO_MONITOR', DEFAULT_RELAYS_TO_MONITOR)
const publishRelays = parseListEnv('PUBLISH_RELAYS', DEFAULT_PUBLISH_RELAYS)
await run10166(sk, publishRelays)
const run = () => run30166Round(sk, relaysToMonitor, publishRelays)
await run()
setInterval(run, INTERVAL_MS)
}
main().catch((err) => {
console.error('[nip66-cron]', err)
process.exit(1)
})