1188 lines
39 KiB
JavaScript
1188 lines
39 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Superball Thrower Daemon
|
|
*
|
|
* A standalone Node.js daemon implementation of the Superball Thrower functionality
|
|
* extracted from the web-based thrower.html implementation.
|
|
*
|
|
* This daemon handles:
|
|
* - NOSTR event monitoring and processing
|
|
* - NIP-44 encryption/decryption
|
|
* - Superball protocol routing (SUP-01 through SUP-06)
|
|
* - Event queue management with delayed processing
|
|
* - Relay management and authentication testing
|
|
* - Thrower Information Document publishing (SUP-06)
|
|
* - Privacy-focused event forwarding with padding
|
|
*/
|
|
|
|
const WebSocket = require('ws');
|
|
const crypto = require('crypto');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
// Import NOSTR tools - these will be installed via npm
|
|
const { SimplePool, generateSecretKey, getPublicKey, finalizeEvent, nip44 } = require('nostr-tools');
|
|
|
|
// Configuration
|
|
class ThrowerConfig {
|
|
constructor(configPath = './config.json') {
|
|
this.configPath = configPath;
|
|
this.config = this.loadConfig();
|
|
}
|
|
|
|
loadConfig() {
|
|
try {
|
|
if (fs.existsSync(this.configPath)) {
|
|
const configData = fs.readFileSync(this.configPath, 'utf8');
|
|
return JSON.parse(configData);
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error loading config: ${error.message}`);
|
|
}
|
|
|
|
// Default configuration
|
|
return {
|
|
thrower: {
|
|
privateKey: null, // Will be generated if not provided
|
|
name: 'My Superball Thrower',
|
|
description: 'A privacy-focused Superball Thrower node',
|
|
banner: '',
|
|
icon: '',
|
|
adminPubkey: '',
|
|
contact: '',
|
|
supportedSups: '1,2,3,4,5,6',
|
|
software: 'https://git.laantungir.net/laantungir/super_ball.git',
|
|
version: '1.0.0',
|
|
privacyPolicy: '',
|
|
termsOfService: '',
|
|
refreshRate: 300,
|
|
maxDelay: 86460
|
|
},
|
|
relays: [
|
|
{ url: 'wss://relay.laantungir.net', type: '', authStatus: 'unknown' }
|
|
],
|
|
daemon: {
|
|
logLevel: 'info', // debug, info, warn, error
|
|
maxQueueSize: 1000,
|
|
maxLogEntries: 1000,
|
|
autoStart: false
|
|
}
|
|
};
|
|
}
|
|
|
|
saveConfig() {
|
|
try {
|
|
fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2));
|
|
return true;
|
|
} catch (error) {
|
|
console.error(`Error saving config: ${error.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
get(key) {
|
|
return key.split('.').reduce((obj, k) => obj && obj[k], this.config);
|
|
}
|
|
|
|
set(key, value) {
|
|
const keys = key.split('.');
|
|
const lastKey = keys.pop();
|
|
const target = keys.reduce((obj, k) => obj[k] = obj[k] || {}, this.config);
|
|
target[lastKey] = value;
|
|
}
|
|
}
|
|
|
|
// NOSTR Utilities
|
|
class NostrUtils {
|
|
static generateKeyPair() {
|
|
const privateKey = generateSecretKey();
|
|
const publicKey = getPublicKey(privateKey);
|
|
return { privateKey, publicKey };
|
|
}
|
|
|
|
static bytesToHex(bytes) {
|
|
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
|
|
static hexToBytes(hex) {
|
|
const bytes = new Uint8Array(hex.length / 2);
|
|
for (let i = 0; i < hex.length; i += 2) {
|
|
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
static async encryptNip44(content, senderPrivateKey, recipientPublicKey) {
|
|
const senderPrivateKeyHex = typeof senderPrivateKey === 'string' ?
|
|
senderPrivateKey : this.bytesToHex(senderPrivateKey);
|
|
|
|
const conversationKey = nip44.v2.utils.getConversationKey(
|
|
senderPrivateKeyHex,
|
|
recipientPublicKey
|
|
);
|
|
|
|
return nip44.v2.encrypt(content, conversationKey);
|
|
}
|
|
|
|
static async decryptNip44(encryptedContent, recipientPrivateKey, senderPublicKey) {
|
|
const recipientPrivateKeyHex = typeof recipientPrivateKey === 'string' ?
|
|
recipientPrivateKey : this.bytesToHex(recipientPrivateKey);
|
|
|
|
const conversationKey = nip44.v2.utils.getConversationKey(
|
|
recipientPrivateKeyHex,
|
|
senderPublicKey
|
|
);
|
|
|
|
return nip44.v2.decrypt(encryptedContent, conversationKey);
|
|
}
|
|
}
|
|
|
|
// Relay Management
|
|
class RelayManager {
|
|
constructor(config, logger) {
|
|
this.config = config;
|
|
this.logger = logger;
|
|
this.relays = config.get('relays') || [];
|
|
}
|
|
|
|
async testRelayAuthentication(relayUrl) {
|
|
return new Promise((resolve) => {
|
|
const timeout = setTimeout(() => {
|
|
this.logger.error(`Auth test timed out for ${relayUrl} (10s timeout)`);
|
|
ws.close();
|
|
resolve('error');
|
|
}, 10000);
|
|
|
|
const ws = new WebSocket(relayUrl);
|
|
let authRequired = false;
|
|
let publishAttempted = false;
|
|
|
|
ws.on('open', () => {
|
|
this.logger.info(`Connected to ${relayUrl} for auth test`);
|
|
|
|
// Generate test event with random key
|
|
const testKey = generateSecretKey();
|
|
const testEvent = {
|
|
kind: 1,
|
|
content: `Auth test ${Date.now()}`,
|
|
tags: [],
|
|
created_at: Math.floor(Date.now() / 1000)
|
|
};
|
|
|
|
const signedEvent = finalizeEvent(testEvent, testKey);
|
|
|
|
// Attempt to publish
|
|
const publishMsg = JSON.stringify(['EVENT', signedEvent]);
|
|
ws.send(publishMsg);
|
|
publishAttempted = true;
|
|
|
|
this.logger.info(`Sent test event ${signedEvent.id.substring(0, 16)}... to ${relayUrl}`);
|
|
});
|
|
|
|
ws.on('message', (data) => {
|
|
try {
|
|
const message = JSON.parse(data.toString());
|
|
this.logger.debug(`Response from ${relayUrl}: ${JSON.stringify(message)}`);
|
|
|
|
if (message[0] === 'AUTH') {
|
|
authRequired = true;
|
|
this.logger.info(`${relayUrl} requires AUTH - marking as read-only`);
|
|
clearTimeout(timeout);
|
|
ws.close();
|
|
resolve('auth-required');
|
|
} else if (message[0] === 'OK') {
|
|
if (publishAttempted && !authRequired) {
|
|
const accepted = message[2];
|
|
const reason = message[3] || '';
|
|
|
|
if (accepted) {
|
|
this.logger.info(`${relayUrl} accepted test event - read/write capable`);
|
|
clearTimeout(timeout);
|
|
ws.close();
|
|
resolve('no-auth');
|
|
} else {
|
|
this.logger.info(`${relayUrl} rejected test event: "${reason}" - marking as read-only`);
|
|
clearTimeout(timeout);
|
|
ws.close();
|
|
resolve('auth-required');
|
|
}
|
|
}
|
|
} else if (message[0] === 'NOTICE') {
|
|
const notice = message[1] || '';
|
|
this.logger.info(`Notice from ${relayUrl}: "${notice}"`);
|
|
if (notice.toLowerCase().includes('auth')) {
|
|
authRequired = true;
|
|
this.logger.info(`${relayUrl} notice indicates auth required`);
|
|
clearTimeout(timeout);
|
|
ws.close();
|
|
resolve('auth-required');
|
|
}
|
|
}
|
|
} catch (error) {
|
|
this.logger.error(`Error parsing message from ${relayUrl}: ${error.message}`);
|
|
}
|
|
});
|
|
|
|
ws.on('error', (error) => {
|
|
this.logger.error(`WebSocket error with ${relayUrl}: ${error.message}`);
|
|
clearTimeout(timeout);
|
|
resolve('error');
|
|
});
|
|
|
|
ws.on('close', (code, reason) => {
|
|
this.logger.info(`Connection closed to ${relayUrl} - Code: ${code}, Reason: ${reason}`);
|
|
clearTimeout(timeout);
|
|
if (!authRequired && publishAttempted) {
|
|
this.logger.info(`${relayUrl} closed without clear response - assuming no auth required`);
|
|
resolve('no-auth');
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
async testAllRelays() {
|
|
this.logger.info(`Testing authentication for ${this.relays.length} relays...`);
|
|
|
|
const testPromises = this.relays.map(async (relay, index) => {
|
|
try {
|
|
relay.authStatus = 'testing';
|
|
const result = await this.testRelayAuthentication(relay.url);
|
|
relay.authStatus = result;
|
|
relay.lastTested = Date.now();
|
|
return result;
|
|
} catch (error) {
|
|
this.logger.error(`Failed to test relay ${relay.url}: ${error.message}`);
|
|
relay.authStatus = 'error';
|
|
relay.lastTested = Date.now();
|
|
return 'error';
|
|
}
|
|
});
|
|
|
|
await Promise.allSettled(testPromises);
|
|
|
|
const results = this.relays.map(r => r.authStatus);
|
|
const readWriteCount = results.filter(r => r === 'no-auth').length;
|
|
const readOnlyCount = results.filter(r => r === 'auth-required').length;
|
|
const errorCount = results.filter(r => r === 'error').length;
|
|
|
|
this.logger.info(`Relay auth testing complete: ${readWriteCount} read/write, ${readOnlyCount} read-only, ${errorCount} errors`);
|
|
|
|
// Update relay capabilities based on auth status
|
|
this.relays.forEach(relay => {
|
|
if (relay.authStatus === 'auth-required') {
|
|
// Can only read from auth-required relays
|
|
relay.write = false;
|
|
// Keep read capability as configured
|
|
} else if (relay.authStatus === 'no-auth') {
|
|
// Keep both read and write capabilities as configured
|
|
} else if (relay.authStatus === 'error') {
|
|
// Disable both read and write for error relays
|
|
relay.read = false;
|
|
relay.write = false;
|
|
}
|
|
});
|
|
|
|
return { readWriteCount, readOnlyCount, errorCount };
|
|
}
|
|
|
|
getWriteCapableRelays() {
|
|
return this.relays.filter(relay =>
|
|
relay.write && (relay.authStatus === 'no-auth' || relay.authStatus === 'unknown')
|
|
);
|
|
}
|
|
|
|
getReadCapableRelays() {
|
|
return this.relays.filter(relay =>
|
|
relay.read && relay.authStatus !== 'error'
|
|
);
|
|
}
|
|
}
|
|
|
|
// Event Processing
|
|
class EventProcessor {
|
|
constructor(config, logger, relayManager) {
|
|
this.config = config;
|
|
this.logger = logger;
|
|
this.relayManager = relayManager;
|
|
this.eventQueue = [];
|
|
this.processedEventIds = new Set();
|
|
this.processedEvents = 0;
|
|
}
|
|
|
|
async handleIncomingEvent(event) {
|
|
// Deduplication check
|
|
if (this.processedEventIds.has(event.id)) {
|
|
this.logger.info(`Skipping duplicate event: ${event.id.substring(0, 16)}... (already processed)`);
|
|
return;
|
|
}
|
|
|
|
this.processedEventIds.add(event.id);
|
|
this.logger.info(`Received routing event: ${event.id.substring(0, 16)}...`);
|
|
|
|
try {
|
|
// Decrypt the event payload
|
|
let decryptedPayload = await this.decryptRoutingEvent(event);
|
|
|
|
if (!decryptedPayload) {
|
|
this.logger.error(`Failed to decrypt event ${event.id.substring(0, 16)}...`);
|
|
return;
|
|
}
|
|
|
|
this.logger.info(`First decryption successful for event ${event.id.substring(0, 16)}...`);
|
|
|
|
// Check payload type according to DAEMON.md protocol
|
|
if (decryptedPayload.padding !== undefined) {
|
|
this.logger.info(`Detected Type 2 (Padding Payload) - discarding padding and performing second decryption`);
|
|
|
|
const innerEvent = decryptedPayload.event;
|
|
this.logger.info(`Discarding padding: "${decryptedPayload.padding}"`);
|
|
|
|
// Second decryption to get the actual routing instructions
|
|
decryptedPayload = await this.decryptRoutingEvent(innerEvent);
|
|
|
|
if (!decryptedPayload) {
|
|
this.logger.error(`Failed to decrypt inner event ${innerEvent.id.substring(0, 16)}...`);
|
|
return;
|
|
}
|
|
|
|
this.logger.info(`Second decryption successful - found original routing instructions from builder`);
|
|
} else {
|
|
this.logger.info(`Detected Type 1 (Routing Payload) - processing routing instructions directly`);
|
|
}
|
|
|
|
this.logger.debug(`Final routing payload: ${JSON.stringify(decryptedPayload, null, 2)}`);
|
|
|
|
// Parse routing instructions
|
|
const { event: wrappedEvent, routing } = decryptedPayload;
|
|
|
|
if (!routing) {
|
|
this.logger.error(`No routing instructions found in final payload for ${event.id.substring(0, 16)}...`);
|
|
return;
|
|
}
|
|
|
|
if (!this.validateRoutingInstructions(routing)) {
|
|
this.logger.error(`Invalid routing instructions in event ${event.id.substring(0, 16)}...`);
|
|
return;
|
|
}
|
|
|
|
// Create queue item
|
|
const queueItem = {
|
|
id: event.id,
|
|
wrappedEvent,
|
|
routing,
|
|
receivedAt: Date.now(),
|
|
processAt: Date.now() + (routing.delay * 1000),
|
|
status: 'queued'
|
|
};
|
|
|
|
this.eventQueue.push(queueItem);
|
|
this.logger.info(`Event queued for processing in ${routing.delay}s: ${event.id.substring(0, 16)}...`);
|
|
|
|
// Schedule processing
|
|
setTimeout(() => this.processQueuedEvent(queueItem), routing.delay * 1000);
|
|
|
|
} catch (error) {
|
|
this.logger.error(`Error processing event ${event.id.substring(0, 16)}...: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
async decryptRoutingEvent(event) {
|
|
try {
|
|
const privateKeyHex = this.config.get('thrower.privateKey');
|
|
if (!privateKeyHex) {
|
|
throw new Error('No private key configured for decryption');
|
|
}
|
|
|
|
this.logger.debug(`Attempting decryption of event ${event.id} from ${event.pubkey}`);
|
|
|
|
const decrypted = await NostrUtils.decryptNip44(
|
|
event.content,
|
|
privateKeyHex,
|
|
event.pubkey
|
|
);
|
|
|
|
this.logger.debug(`Decryption successful! Decrypted length: ${decrypted.length}`);
|
|
return JSON.parse(decrypted);
|
|
|
|
} catch (error) {
|
|
this.logger.error(`Decryption failed: ${error.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
validateRoutingInstructions(routing) {
|
|
if (!routing || typeof routing !== 'object') return false;
|
|
if (!Array.isArray(routing.relays) || routing.relays.length === 0) return false;
|
|
if (typeof routing.delay !== 'number' || routing.delay < 0) return false;
|
|
if (!routing.audit || typeof routing.audit !== 'string') return false;
|
|
|
|
// Check maximum delay limit
|
|
const maxDelay = this.config.get('thrower.maxDelay') || 86460;
|
|
if (routing.delay > maxDelay) {
|
|
this.logger.error(`Routing delay ${routing.delay}s exceeds maximum allowed delay of ${maxDelay}s`);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
async processQueuedEvent(queueItem) {
|
|
this.logger.info(`Processing event ${queueItem.id.substring(0, 16)}...`);
|
|
queueItem.status = 'processing';
|
|
|
|
try {
|
|
const { wrappedEvent, routing } = queueItem;
|
|
|
|
this.logger.debug(`Decision point - routing.p = "${routing.p}" (${typeof routing.p})`);
|
|
|
|
if (routing.p) {
|
|
// Continue routing to next Superball with padding wrapper
|
|
await this.forwardToNextSuperball(wrappedEvent, routing);
|
|
} else {
|
|
// Final posting - post the wrapped event directly
|
|
await this.postFinalEvent(wrappedEvent, routing.relays);
|
|
}
|
|
|
|
this.processedEvents++;
|
|
|
|
// Remove from queue
|
|
const index = this.eventQueue.findIndex(item => item.id === queueItem.id);
|
|
if (index !== -1) {
|
|
this.eventQueue.splice(index, 1);
|
|
}
|
|
|
|
this.logger.info(`Successfully processed event ${queueItem.id.substring(0, 16)}...`);
|
|
|
|
} catch (error) {
|
|
this.logger.error(`Failed to process event ${queueItem.id.substring(0, 16)}...: ${error.message}`);
|
|
queueItem.status = 'failed';
|
|
}
|
|
}
|
|
|
|
async forwardToNextSuperball(event, routing) {
|
|
this.logger.info(`Forwarding to next Superball: ${routing.p.substring(0, 16)}...`);
|
|
|
|
// Create new ephemeral keypair
|
|
const ephemeralKey = generateSecretKey();
|
|
const ephemeralPubkey = getPublicKey(ephemeralKey);
|
|
|
|
// Generate padding based on add_padding_bytes instruction
|
|
let paddingData = '';
|
|
if (routing.add_padding_bytes && routing.add_padding_bytes > 0) {
|
|
const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
|
for (let i = 0; i < routing.add_padding_bytes; i++) {
|
|
paddingData += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
}
|
|
this.logger.info(`Generated ${paddingData.length} bytes of padding`);
|
|
}
|
|
|
|
// Create padding-only payload
|
|
const paddingPayload = {
|
|
event: event, // This is the still-encrypted inner event
|
|
padding: paddingData // Padding to discard
|
|
};
|
|
|
|
this.logger.debug(`Creating padding payload with ${paddingData.length} bytes of padding`);
|
|
|
|
// Encrypt padding payload to next Superball
|
|
const ephemeralKeyHex = NostrUtils.bytesToHex(ephemeralKey);
|
|
const encryptedContent = await NostrUtils.encryptNip44(
|
|
JSON.stringify(paddingPayload),
|
|
ephemeralKeyHex,
|
|
routing.p
|
|
);
|
|
|
|
// Create new routing event (forwarding to next hop)
|
|
const routingEvent = {
|
|
kind: 22222,
|
|
content: encryptedContent,
|
|
tags: [
|
|
['p', routing.p], // Next Superball
|
|
['p', routing.audit] // Audit tag (camouflage)
|
|
],
|
|
created_at: Math.floor(Date.now() / 1000)
|
|
};
|
|
|
|
// Sign with ephemeral key
|
|
const signedEvent = finalizeEvent(routingEvent, ephemeralKey);
|
|
|
|
this.logger.debug(`Padding-wrapped event to publish: ${JSON.stringify(signedEvent, null, 2)}`);
|
|
|
|
// Publish to specified relays
|
|
await this.publishToRelays(signedEvent, routing.relays);
|
|
|
|
this.logger.info(`Forwarded with padding layer to ${routing.p.substring(0, 16)}... (audit: ${routing.audit.substring(0, 16)}...)`);
|
|
}
|
|
|
|
async postFinalEvent(event, relays) {
|
|
this.logger.info(`Posting final event to ${relays.length} relays`);
|
|
await this.publishToRelays(event, relays);
|
|
this.logger.info(`Final event posted to relays: ${event.id.substring(0, 16)}...`);
|
|
}
|
|
|
|
async publishToRelays(event, relays) {
|
|
const pool = new SimplePool();
|
|
|
|
// Filter to only use relays that don't require auth (write-capable)
|
|
const writeRelays = relays.filter(relayUrl => {
|
|
const relay = this.relayManager.relays.find(r => r.url === relayUrl);
|
|
if (!relay) return true; // Default to allowing if relay not found
|
|
|
|
return relay.authStatus === 'no-auth' || relay.authStatus === 'unknown';
|
|
});
|
|
|
|
if (writeRelays.length === 0) {
|
|
throw new Error('No write-capable (non-AUTH) relays available for publishing');
|
|
}
|
|
|
|
if (writeRelays.length < relays.length) {
|
|
const skippedCount = relays.length - writeRelays.length;
|
|
this.logger.info(`Skipping ${skippedCount} AUTH-required relays, using ${writeRelays.length} write-capable relays`);
|
|
}
|
|
|
|
// Publish to each relay individually
|
|
const publishPromises = writeRelays.map(relayUrl => {
|
|
const promises = pool.publish([relayUrl], event);
|
|
return promises[0]
|
|
.then(result => ({ relayUrl, success: true, result }))
|
|
.catch(error => ({ relayUrl, success: false, error: error.message }));
|
|
});
|
|
|
|
try {
|
|
const results = await Promise.allSettled(publishPromises);
|
|
|
|
let successCount = 0;
|
|
let failureCount = 0;
|
|
const successfulRelays = [];
|
|
const failedRelays = [];
|
|
|
|
results.forEach((promiseResult, index) => {
|
|
if (promiseResult.status === 'fulfilled') {
|
|
const { relayUrl, success, result, error } = promiseResult.value;
|
|
|
|
if (success) {
|
|
successCount++;
|
|
successfulRelays.push(relayUrl);
|
|
this.logger.info(`✅ Published successfully to ${relayUrl}`);
|
|
} else {
|
|
failureCount++;
|
|
failedRelays.push({ relayUrl, error });
|
|
this.logger.error(`❌ Failed to publish to ${relayUrl}: ${error}`);
|
|
}
|
|
} else {
|
|
const relayUrl = writeRelays[index];
|
|
failureCount++;
|
|
failedRelays.push({ relayUrl, error: promiseResult.reason?.message || 'Unknown error' });
|
|
this.logger.error(`❌ Failed to publish to ${relayUrl}: ${promiseResult.reason?.message || 'Unknown error'}`);
|
|
}
|
|
});
|
|
|
|
this.logger.info(`Publishing summary: ${successCount} successful, ${failureCount} failed out of ${writeRelays.length} total relays`);
|
|
|
|
if (successCount > 0) {
|
|
this.logger.info(`Successfully published to: ${successfulRelays.join(', ')}`);
|
|
this.logger.debug(`Full published event: ${JSON.stringify(event, null, 2)}`);
|
|
}
|
|
|
|
if (failureCount > 0) {
|
|
const failureDetails = failedRelays.map(f => `${f.relayUrl}: ${f.error}`).join(', ');
|
|
this.logger.error(`Failed to publish to: ${failureDetails}`);
|
|
}
|
|
|
|
if (successCount === 0) {
|
|
throw new Error(`Failed to publish to any write-capable relay. Attempted: ${writeRelays.join(', ')}`);
|
|
}
|
|
|
|
} finally {
|
|
pool.close(writeRelays);
|
|
}
|
|
}
|
|
|
|
getQueueStatus() {
|
|
return {
|
|
queueLength: this.eventQueue.length,
|
|
processedEvents: this.processedEvents,
|
|
queueItems: this.eventQueue.map(item => ({
|
|
id: item.id.substring(0, 16) + '...',
|
|
status: item.status,
|
|
timeLeft: Math.max(0, Math.ceil((item.processAt - Date.now()) / 1000)),
|
|
relayCount: item.routing.relays.length,
|
|
delay: item.routing.delay,
|
|
nextHop: item.routing.p ? item.routing.p.substring(0, 16) + '...' : 'Final Posting'
|
|
}))
|
|
};
|
|
}
|
|
}
|
|
|
|
// Thrower Information Document Manager (SUP-06)
|
|
class ThrowerInfoManager {
|
|
constructor(config, logger, relayManager) {
|
|
this.config = config;
|
|
this.logger = logger;
|
|
this.relayManager = relayManager;
|
|
this.refreshInterval = null;
|
|
this.lastPublish = null;
|
|
}
|
|
|
|
async publishThrowerInfo() {
|
|
const privateKeyHex = this.config.get('thrower.privateKey');
|
|
if (!privateKeyHex) {
|
|
throw new Error('No private key configured for publishing');
|
|
}
|
|
|
|
const throwerInfo = this.config.get('thrower');
|
|
|
|
// Build tags array according to SUP-06
|
|
const tags = [];
|
|
|
|
if (throwerInfo.name) tags.push(['name', throwerInfo.name]);
|
|
if (throwerInfo.description) tags.push(['description', throwerInfo.description]);
|
|
if (throwerInfo.banner) tags.push(['banner', throwerInfo.banner]);
|
|
if (throwerInfo.icon) tags.push(['icon', throwerInfo.icon]);
|
|
if (throwerInfo.adminPubkey) tags.push(['pubkey', throwerInfo.adminPubkey]);
|
|
if (throwerInfo.contact) tags.push(['contact', throwerInfo.contact]);
|
|
if (throwerInfo.supportedSups) tags.push(['supported_sups', throwerInfo.supportedSups]);
|
|
if (throwerInfo.software) tags.push(['software', throwerInfo.software]);
|
|
if (throwerInfo.version) tags.push(['version', throwerInfo.version]);
|
|
if (throwerInfo.privacyPolicy) tags.push(['privacy_policy', throwerInfo.privacyPolicy]);
|
|
if (throwerInfo.termsOfService) tags.push(['terms_of_service', throwerInfo.termsOfService]);
|
|
tags.push(['refresh_rate', throwerInfo.refreshRate.toString()]);
|
|
tags.push(['max_delay', (throwerInfo.maxDelay || 86460).toString()]);
|
|
|
|
const eventTemplate = {
|
|
kind: 12222,
|
|
content: throwerInfo.content || '',
|
|
tags: tags,
|
|
created_at: Math.floor(Date.now() / 1000)
|
|
};
|
|
|
|
const privateKeyBytes = NostrUtils.hexToBytes(privateKeyHex);
|
|
const signedEvent = finalizeEvent(eventTemplate, privateKeyBytes);
|
|
|
|
this.logger.debug(`Thrower Info event to publish: ${JSON.stringify(signedEvent, null, 2)}`);
|
|
|
|
// Publish to write-capable relays
|
|
const writeRelays = this.relayManager.getWriteCapableRelays().map(r => r.url);
|
|
|
|
if (writeRelays.length === 0) {
|
|
throw new Error('No write-capable relays available for publishing Thrower Info');
|
|
}
|
|
|
|
const pool = new SimplePool();
|
|
|
|
try {
|
|
const publishPromises = writeRelays.map(relayUrl => {
|
|
const promises = pool.publish([relayUrl], signedEvent);
|
|
return promises[0]
|
|
.then(() => ({ relayUrl, success: true }))
|
|
.catch(error => ({ relayUrl, success: false, error: error.message }));
|
|
});
|
|
|
|
const results = await Promise.allSettled(publishPromises);
|
|
let successCount = 0;
|
|
|
|
results.forEach((promiseResult) => {
|
|
if (promiseResult.status === 'fulfilled') {
|
|
const { relayUrl, success, error } = promiseResult.value;
|
|
|
|
if (success) {
|
|
successCount++;
|
|
this.logger.info(`✅ Thrower info published successfully to ${relayUrl}`);
|
|
} else {
|
|
this.logger.error(`❌ Failed to publish thrower info to ${relayUrl}: ${error}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
if (successCount === 0) {
|
|
throw new Error(`Failed to publish thrower info to any relay (attempted: ${writeRelays.join(', ')})`);
|
|
}
|
|
|
|
this.logger.info(`Thrower info published to ${successCount} out of ${writeRelays.length} relays`);
|
|
this.lastPublish = signedEvent.created_at;
|
|
|
|
} finally {
|
|
pool.close(writeRelays);
|
|
}
|
|
|
|
// Also publish relay list (NIP-65) so web interface can show relay configuration
|
|
await this.publishRelayList();
|
|
}
|
|
|
|
async publishRelayList() {
|
|
const privateKeyHex = this.config.get('thrower.privateKey');
|
|
if (!privateKeyHex) {
|
|
throw new Error('No private key configured for publishing relay list');
|
|
}
|
|
|
|
this.logger.info('Publishing relay list (NIP-65) for web interface compatibility...');
|
|
|
|
const relays = this.config.get('relays') || [];
|
|
const relayTags = [];
|
|
|
|
// Add relay tags based on configuration and authentication status
|
|
relays.forEach(relay => {
|
|
if (relay.authStatus === 'error') {
|
|
// Skip relays that are completely broken
|
|
return;
|
|
}
|
|
|
|
// Add read capability if configured and relay is functional
|
|
if (relay.read && relay.authStatus !== 'error') {
|
|
relayTags.push(['r', relay.url, 'read']);
|
|
}
|
|
|
|
// Add write capability if configured and relay supports it
|
|
if (relay.write && (relay.authStatus === 'no-auth' || relay.authStatus === 'unknown')) {
|
|
relayTags.push(['r', relay.url, 'write']);
|
|
}
|
|
});
|
|
|
|
if (relayTags.length === 0) {
|
|
this.logger.warn('No relays available for relay list - skipping relay list publication');
|
|
return;
|
|
}
|
|
|
|
const relayListEvent = {
|
|
kind: 10002,
|
|
content: '',
|
|
tags: relayTags,
|
|
created_at: Math.floor(Date.now() / 1000)
|
|
};
|
|
|
|
const privateKeyBytes = NostrUtils.hexToBytes(privateKeyHex);
|
|
const signedEvent = finalizeEvent(relayListEvent, privateKeyBytes);
|
|
|
|
this.logger.debug(`Relay list event to publish: ${JSON.stringify(signedEvent, null, 2)}`);
|
|
|
|
// Publish to write-capable relays
|
|
const writeRelays = this.relayManager.getWriteCapableRelays().map(r => r.url);
|
|
|
|
if (writeRelays.length === 0) {
|
|
this.logger.warn('No write-capable relays available for publishing relay list');
|
|
return;
|
|
}
|
|
|
|
const pool = new SimplePool();
|
|
|
|
try {
|
|
const publishPromises = writeRelays.map(relayUrl => {
|
|
const promises = pool.publish([relayUrl], signedEvent);
|
|
return promises[0]
|
|
.then(() => ({ relayUrl, success: true }))
|
|
.catch(error => ({ relayUrl, success: false, error: error.message }));
|
|
});
|
|
|
|
const results = await Promise.allSettled(publishPromises);
|
|
let successCount = 0;
|
|
|
|
results.forEach((promiseResult) => {
|
|
if (promiseResult.status === 'fulfilled') {
|
|
const { relayUrl, success, error } = promiseResult.value;
|
|
|
|
if (success) {
|
|
successCount++;
|
|
this.logger.info(`✅ Relay list published successfully to ${relayUrl}`);
|
|
} else {
|
|
this.logger.error(`❌ Failed to publish relay list to ${relayUrl}: ${error}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
if (successCount === 0) {
|
|
this.logger.warn(`Failed to publish relay list to any relay (attempted: ${writeRelays.join(', ')})`);
|
|
} else {
|
|
this.logger.info(`Relay list published to ${successCount} out of ${writeRelays.length} relays`);
|
|
}
|
|
|
|
} finally {
|
|
pool.close(writeRelays);
|
|
}
|
|
}
|
|
|
|
startAutoPublish() {
|
|
const refreshRate = this.config.get('thrower.refreshRate') || 300;
|
|
|
|
if (this.refreshInterval) {
|
|
clearInterval(this.refreshInterval);
|
|
}
|
|
|
|
// Schedule republishing 10 seconds before refresh rate expires
|
|
const intervalMs = Math.max(10000, (refreshRate - 10) * 1000);
|
|
|
|
this.logger.info(`Starting Thrower Info auto-publish every ${intervalMs / 1000} seconds`);
|
|
|
|
this.refreshInterval = setInterval(async () => {
|
|
try {
|
|
this.logger.info('Auto-publishing Thrower Information Document...');
|
|
await this.publishThrowerInfo();
|
|
this.logger.info('Thrower Info auto-published successfully');
|
|
} catch (error) {
|
|
this.logger.error(`Auto-publish failed: ${error.message}`);
|
|
}
|
|
}, intervalMs);
|
|
}
|
|
|
|
stopAutoPublish() {
|
|
if (this.refreshInterval) {
|
|
clearInterval(this.refreshInterval);
|
|
this.refreshInterval = null;
|
|
this.logger.info('Stopped Thrower Info auto-publish');
|
|
}
|
|
}
|
|
}
|
|
|
|
// WebSocket Connection Manager
|
|
class WebSocketManager {
|
|
constructor(config, logger, eventProcessor) {
|
|
this.config = config;
|
|
this.logger = logger;
|
|
this.eventProcessor = eventProcessor;
|
|
this.connections = [];
|
|
this.subscriptionId = null;
|
|
}
|
|
|
|
async startMonitoring() {
|
|
const publicKey = this.config.get('thrower.publicKey');
|
|
if (!publicKey) {
|
|
throw new Error('No public key configured for monitoring');
|
|
}
|
|
|
|
const relays = this.config.get('relays') || [];
|
|
const monitoringRelays = relays.map(r => r.url);
|
|
|
|
this.logger.info(`Connecting to ${monitoringRelays.length} relays via WebSocket for kind 22222 events`);
|
|
|
|
// Generate unique subscription ID
|
|
this.subscriptionId = 'superball_' + Date.now();
|
|
|
|
// Subscribe to kind 22222 events with p tag matching this node's pubkey
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const subscriptionFilter = {
|
|
kinds: [22222],
|
|
'#p': [publicKey],
|
|
since: now
|
|
};
|
|
|
|
this.logger.info(`Subscription ID: ${this.subscriptionId}`);
|
|
this.logger.debug(`Subscription filter: ${JSON.stringify(subscriptionFilter)}`);
|
|
|
|
// Connect to each relay
|
|
monitoringRelays.forEach((relayUrl) => {
|
|
this.connectToRelay(relayUrl, subscriptionFilter);
|
|
});
|
|
|
|
return monitoringRelays.length;
|
|
}
|
|
|
|
connectToRelay(relayUrl, subscriptionFilter) {
|
|
this.logger.info(`Connecting to relay: ${relayUrl}`);
|
|
|
|
const ws = new WebSocket(relayUrl);
|
|
|
|
ws.on('open', () => {
|
|
this.logger.info(`Connected to relay: ${relayUrl}`);
|
|
|
|
// Send subscription request
|
|
const reqMessage = JSON.stringify([
|
|
'REQ',
|
|
this.subscriptionId,
|
|
subscriptionFilter
|
|
]);
|
|
|
|
this.logger.debug(`Sending REQ to ${relayUrl}: ${reqMessage}`);
|
|
ws.send(reqMessage);
|
|
});
|
|
|
|
ws.on('message', (data) => {
|
|
try {
|
|
const message = JSON.parse(data.toString());
|
|
this.logger.debug(`Received from ${relayUrl}: ${JSON.stringify(message).substring(0, 200)}...`);
|
|
|
|
// Handle different message types
|
|
if (message[0] === 'EVENT' && message[1] === this.subscriptionId) {
|
|
const nostrEvent = message[2];
|
|
this.logger.info(`Received EVENT from ${relayUrl}: ${nostrEvent.id.substring(0, 16)}...`);
|
|
this.logger.debug(`Full received event: ${JSON.stringify(nostrEvent, null, 2)}`);
|
|
this.eventProcessor.handleIncomingEvent(nostrEvent);
|
|
} else if (message[0] === 'EOSE' && message[1] === this.subscriptionId) {
|
|
this.logger.info(`End of stored events from ${relayUrl}`);
|
|
} else if (message[0] === 'NOTICE') {
|
|
this.logger.info(`Notice from ${relayUrl}: ${message[1]}`);
|
|
} else if (message[0] === 'OK') {
|
|
this.logger.debug(`OK response from ${relayUrl}: ${JSON.stringify(message)}`);
|
|
}
|
|
} catch (error) {
|
|
this.logger.error(`Error parsing message from ${relayUrl}: ${error.message}`);
|
|
}
|
|
});
|
|
|
|
ws.on('error', (error) => {
|
|
this.logger.error(`WebSocket error with ${relayUrl}: ${error.message}`);
|
|
});
|
|
|
|
ws.on('close', (code, reason) => {
|
|
this.logger.info(`Connection closed to ${relayUrl} - Code: ${code}, Reason: ${reason}`);
|
|
});
|
|
|
|
this.connections.push({ url: relayUrl, ws });
|
|
}
|
|
|
|
stopMonitoring() {
|
|
this.logger.info('Stopping WebSocket monitoring...');
|
|
|
|
this.connections.forEach(({ url, ws }) => {
|
|
if (ws.readyState === ws.OPEN) {
|
|
// Send CLOSE message for subscription
|
|
if (this.subscriptionId) {
|
|
const closeMsg = JSON.stringify(['CLOSE', this.subscriptionId]);
|
|
ws.send(closeMsg);
|
|
this.logger.info(`Sent CLOSE to ${url}`);
|
|
}
|
|
|
|
ws.close();
|
|
}
|
|
});
|
|
|
|
this.connections = [];
|
|
this.subscriptionId = null;
|
|
this.logger.info('WebSocket monitoring stopped');
|
|
}
|
|
}
|
|
|
|
// Logger
|
|
class Logger {
|
|
constructor(level = 'info') {
|
|
this.level = level;
|
|
this.levels = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
}
|
|
|
|
setLevel(level) {
|
|
this.level = level;
|
|
}
|
|
|
|
log(level, message) {
|
|
if (this.levels[level] >= this.levels[this.level]) {
|
|
const timestamp = new Date().toISOString();
|
|
console.log(`[${timestamp}] [${level.toUpperCase()}] ${message}`);
|
|
}
|
|
}
|
|
|
|
debug(message) { this.log('debug', message); }
|
|
info(message) { this.log('info', message); }
|
|
warn(message) { this.log('warn', message); }
|
|
error(message) { this.log('error', message); }
|
|
}
|
|
|
|
// Main Thrower Daemon Class
|
|
class ThrowerDaemon {
|
|
constructor(configPath) {
|
|
this.config = new ThrowerConfig(configPath);
|
|
this.logger = new Logger(this.config.get('daemon.logLevel') || 'info');
|
|
this.relayManager = new RelayManager(this.config, this.logger);
|
|
this.eventProcessor = new EventProcessor(this.config, this.logger, this.relayManager);
|
|
this.throwerInfoManager = new ThrowerInfoManager(this.config, this.logger, this.relayManager);
|
|
this.wsManager = new WebSocketManager(this.config, this.logger, this.eventProcessor);
|
|
this.running = false;
|
|
}
|
|
|
|
async initialize() {
|
|
this.logger.info('Initializing Superball Thrower Daemon...');
|
|
|
|
// Generate keypair if not configured
|
|
let privateKey = this.config.get('thrower.privateKey');
|
|
if (!privateKey) {
|
|
this.logger.info('No private key found, generating new keypair...');
|
|
const keyPair = NostrUtils.generateKeyPair();
|
|
privateKey = NostrUtils.bytesToHex(keyPair.privateKey);
|
|
const publicKey = NostrUtils.bytesToHex(keyPair.publicKey);
|
|
|
|
this.config.set('thrower.privateKey', privateKey);
|
|
this.config.set('thrower.publicKey', publicKey);
|
|
this.config.saveConfig();
|
|
|
|
this.logger.info(`Generated new keypair - Public key: ${publicKey}`);
|
|
} else {
|
|
// Derive public key from private key
|
|
const privateKeyBytes = NostrUtils.hexToBytes(privateKey);
|
|
const publicKey = getPublicKey(privateKeyBytes);
|
|
this.config.set('thrower.publicKey', publicKey);
|
|
this.logger.info(`Using existing keypair - Public key: ${publicKey}`);
|
|
}
|
|
|
|
this.logger.info('Daemon initialized successfully');
|
|
}
|
|
|
|
async start() {
|
|
if (this.running) {
|
|
this.logger.warn('Daemon is already running');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
this.logger.info('Starting Superball Thrower Daemon...');
|
|
|
|
// Test relay authentication
|
|
this.logger.info('Testing relay authentication capabilities...');
|
|
await this.relayManager.testAllRelays();
|
|
|
|
// Start WebSocket monitoring
|
|
const relayCount = await this.wsManager.startMonitoring();
|
|
this.logger.info(`Monitoring ${relayCount} relays for routing events`);
|
|
|
|
// Publish Thrower Info Document to announce availability
|
|
try {
|
|
this.logger.info('Publishing Thrower Information Document to announce availability...');
|
|
await this.throwerInfoManager.publishThrowerInfo();
|
|
this.logger.info('Thrower availability announced successfully');
|
|
} catch (error) {
|
|
this.logger.error(`Failed to announce thrower availability: ${error.message}`);
|
|
}
|
|
|
|
// Start auto-publishing Thrower Info Document
|
|
this.throwerInfoManager.startAutoPublish();
|
|
|
|
this.running = true;
|
|
this.logger.info('Superball Thrower Daemon started successfully');
|
|
|
|
} catch (error) {
|
|
this.logger.error(`Failed to start daemon: ${error.message}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async stop() {
|
|
if (!this.running) {
|
|
this.logger.warn('Daemon is not running');
|
|
return;
|
|
}
|
|
|
|
this.logger.info('Stopping Superball Thrower Daemon...');
|
|
|
|
// Stop WebSocket monitoring
|
|
this.wsManager.stopMonitoring();
|
|
|
|
// Stop auto-publishing
|
|
this.throwerInfoManager.stopAutoPublish();
|
|
|
|
// Clear event queue
|
|
this.eventProcessor.eventQueue = [];
|
|
this.eventProcessor.processedEventIds.clear();
|
|
|
|
this.running = false;
|
|
this.logger.info('Superball Thrower Daemon stopped successfully');
|
|
}
|
|
|
|
getStatus() {
|
|
const queueStatus = this.eventProcessor.getQueueStatus();
|
|
const relayStatus = {
|
|
total: this.relayManager.relays.length,
|
|
writeCapable: this.relayManager.getWriteCapableRelays().length,
|
|
readCapable: this.relayManager.getReadCapableRelays().length
|
|
};
|
|
|
|
return {
|
|
running: this.running,
|
|
publicKey: this.config.get('thrower.publicKey'),
|
|
throwerName: this.config.get('thrower.name'),
|
|
relays: relayStatus,
|
|
queue: queueStatus,
|
|
lastThrowerInfoPublish: this.throwerInfoManager.lastPublish
|
|
};
|
|
}
|
|
}
|
|
|
|
// CLI Interface
|
|
async function main() {
|
|
const args = process.argv.slice(2);
|
|
const command = args[0] || 'help';
|
|
const configPath = args.find(arg => arg.startsWith('--config='))?.split('=')[1] || './config.json';
|
|
|
|
const daemon = new ThrowerDaemon(configPath);
|
|
|
|
switch (command) {
|
|
case 'init':
|
|
await daemon.initialize();
|
|
console.log('Daemon initialized. Edit config.json and run "start" to begin.');
|
|
break;
|
|
|
|
case 'start':
|
|
await daemon.initialize();
|
|
await daemon.start();
|
|
|
|
// Handle graceful shutdown
|
|
process.on('SIGINT', async () => {
|
|
console.log('\nReceived SIGINT, shutting down gracefully...');
|
|
await daemon.stop();
|
|
process.exit(0);
|
|
});
|
|
|
|
process.on('SIGTERM', async () => {
|
|
console.log('\nReceived SIGTERM, shutting down gracefully...');
|
|
await daemon.stop();
|
|
process.exit(0);
|
|
});
|
|
|
|
// Keep the process running
|
|
setInterval(() => {
|
|
const status = daemon.getStatus();
|
|
if (status.running) {
|
|
console.log(`[${new Date().toISOString()}] Status: Running | Queue: ${status.queue.queueLength} | Processed: ${status.queue.processedEvents}`);
|
|
}
|
|
}, 60000); // Status update every minute
|
|
|
|
break;
|
|
|
|
case 'stop':
|
|
await daemon.stop();
|
|
break;
|
|
|
|
case 'status':
|
|
const status = daemon.getStatus();
|
|
console.log(JSON.stringify(status, null, 2));
|
|
break;
|
|
|
|
case 'test-relays':
|
|
await daemon.initialize();
|
|
const results = await daemon.relayManager.testAllRelays();
|
|
console.log(`Relay test results: ${results.readWriteCount} read/write, ${results.readOnlyCount} read-only, ${results.errorCount} errors`);
|
|
break;
|
|
|
|
case 'help':
|
|
default:
|
|
console.log(`
|
|
Superball Thrower Daemon
|
|
|
|
Usage: node daemon.js <command> [options]
|
|
|
|
Commands:
|
|
init Initialize daemon with new keypair and default config
|
|
start Start the thrower daemon
|
|
stop Stop the thrower daemon
|
|
status Show daemon status
|
|
test-relays Test relay authentication capabilities
|
|
help Show this help message
|
|
|
|
Options:
|
|
--config=<path> Path to config file (default: ./config.json)
|
|
|
|
Examples:
|
|
node daemon.js init
|
|
node daemon.js start
|
|
node daemon.js start --config=/etc/thrower/config.json
|
|
node daemon.js status
|
|
`);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Run CLI if this file is executed directly
|
|
if (require.main === module) {
|
|
main().catch(error => {
|
|
console.error('Fatal error:', error.message);
|
|
process.exit(1);
|
|
});
|
|
}
|
|
|
|
module.exports = { ThrowerDaemon, ThrowerConfig, NostrUtils }; |