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
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) |
|
})
|
|
|