UNPKG

@bsv/overlay-express

Version:
318 lines 13.3 kB
import chalk from 'chalk'; /** * JanitorService runs health checks on SHIP and SLAP outputs. * It validates domain names and checks /health endpoints to ensure services are operational. * * When a service is down, it increments a "down" counter. When healthy, it decrements. * If the down counter reaches hostDownRevokeScore, the output is deleted and optionally * the domain is added to the persistent ban list to prevent GASP re-sync. */ export class JanitorService { mongoDb; logger; requestTimeoutMs; hostDownRevokeScore; banService; autoBanOnRemoval; constructor(config) { this.mongoDb = config.mongoDb; this.logger = config.logger ?? console; this.requestTimeoutMs = config.requestTimeoutMs ?? 10000; this.hostDownRevokeScore = config.hostDownRevokeScore ?? 3; this.banService = config.banService; this.autoBanOnRemoval = config.autoBanOnRemoval ?? true; } /** * Runs a full pass of health checks on all SHIP and SLAP outputs. * Returns a detailed report of the results. */ async run() { const startedAt = new Date(); this.logger.log(chalk.blue('Running janitor health checks...')); let shipResults = []; let slapResults = []; let removed = 0; let banned = 0; try { const shipCheckResult = await this.checkTopicOutputs('shipRecords', 'topic'); shipResults = shipCheckResult.results; removed += shipCheckResult.removed; banned += shipCheckResult.banned; const slapCheckResult = await this.checkTopicOutputs('slapRecords', 'service'); slapResults = slapCheckResult.results; removed += slapCheckResult.removed; banned += slapCheckResult.banned; this.logger.log(chalk.green('Janitor health checks completed')); } catch (error) { this.logger.error(chalk.red('Error during health checks:'), error); throw error; } const completedAt = new Date(); const allResults = [...shipResults, ...slapResults]; return { startedAt, completedAt, durationMs: completedAt.getTime() - startedAt.getTime(), shipResults, slapResults, summary: { totalChecked: allResults.length, healthy: allResults.filter(r => r.healthy).length, unhealthy: allResults.filter(r => !r.healthy).length, removed, banned } }; } /** * Checks a single URL's health endpoint. Used by the admin dashboard for on-demand checks. */ async checkHost(url) { const startTime = Date.now(); if (!this.isValidDomain(url)) { return { healthy: false, responseTimeMs: Date.now() - startTime, error: 'Invalid domain' }; } try { const fullURL = url.startsWith('http') ? url : `https://${url}`; const healthURL = new URL('/health', fullURL).toString(); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), this.requestTimeoutMs); try { const response = await fetch(healthURL, { method: 'GET', signal: controller.signal, headers: { Accept: 'application/json' } }); clearTimeout(timeout); const responseTimeMs = Date.now() - startTime; if (!response.ok) { return { healthy: false, responseTimeMs, statusCode: response.status, error: `HTTP ${response.status}` }; } const data = await response.json(); const healthy = (data?.status === 'ok' && data?.ready !== false) || (data?.ready === true && data?.live !== false); return { healthy, responseTimeMs, statusCode: response.status, error: healthy ? undefined : 'Unexpected response' }; } catch (error) { clearTimeout(timeout); const responseTimeMs = Date.now() - startTime; if (error.name === 'AbortError') { return { healthy: false, responseTimeMs, error: 'Timeout' }; } return { healthy: false, responseTimeMs, error: error.message ?? 'Connection failed' }; } } catch (error) { return { healthy: false, responseTimeMs: Date.now() - startTime, error: error.message ?? 'Invalid URL' }; } } /** * Gets health status for all records without modifying them. * Used by the dashboard to display current state. */ async getHealthStatus() { const shipCollection = this.mongoDb.collection('shipRecords'); const slapCollection = this.mongoDb.collection('slapRecords'); const [shipOutputs, slapOutputs] = await Promise.all([ shipCollection.find({}).toArray(), slapCollection.find({}).toArray() ]); const shipResults = shipOutputs.map(output => ({ txid: output.txid, outputIndex: output.outputIndex, domain: this.extractURLFromOutput(output) ?? 'unknown', topic: output.topic, identityKey: output.identityKey, createdAt: output.createdAt, healthy: true, // Will be updated if health check is run downCount: typeof output.down === 'number' ? output.down : 0 })); const slapResults = slapOutputs.map(output => ({ txid: output.txid, outputIndex: output.outputIndex, domain: this.extractURLFromOutput(output) ?? 'unknown', service: output.service, identityKey: output.identityKey, createdAt: output.createdAt, healthy: true, downCount: typeof output.down === 'number' ? output.down : 0 })); return { ship: shipResults, slap: slapResults }; } /** * Checks all outputs for a specific collection and returns results. */ async checkTopicOutputs(collectionName, typeField) { const results = []; let removed = 0; let banned = 0; try { const collection = this.mongoDb.collection(collectionName); const outputs = await collection.find({}).toArray(); this.logger.log(chalk.cyan(`Checking ${outputs.length} ${collectionName} outputs...`)); for (const output of outputs) { const result = await this.checkOutput(output, collection, typeField); results.push(result); if (result.error === 'REMOVED') { removed++; } } // Count auto-bans that happened during this run if (this.banService !== undefined && this.autoBanOnRemoval) { banned = removed; // Each removal triggers a ban } } catch (error) { this.logger.error(chalk.red(`Error checking ${collectionName} outputs:`), error); } return { results, removed, banned }; } /** * Checks a single output for health and returns the result. */ async checkOutput(output, collection, typeField) { const domain = this.extractURLFromOutput(output) ?? 'unknown'; const baseResult = { txid: output.txid, outputIndex: output.outputIndex, domain, identityKey: output.identityKey, createdAt: output.createdAt, healthy: false, downCount: typeof output.down === 'number' ? output.down : 0 }; if (typeField === 'topic') { baseResult.topic = output.topic; } else { baseResult.service = output.service; } try { if (domain === 'unknown') { baseResult.error = 'No URL found'; await this.handleUnhealthyOutput(output, collection, domain); return baseResult; } if (!this.isValidDomain(domain)) { baseResult.error = 'Invalid domain'; await this.handleUnhealthyOutput(output, collection, domain); return baseResult; } const healthResult = await this.checkHost(domain); baseResult.healthy = healthResult.healthy; baseResult.responseTimeMs = healthResult.responseTimeMs; baseResult.statusCode = healthResult.statusCode; baseResult.error = healthResult.error; if (healthResult.healthy) { await this.handleHealthyOutput(output, collection); baseResult.downCount = Math.max(0, baseResult.downCount - 1); } else { const wasRemoved = await this.handleUnhealthyOutput(output, collection, domain); baseResult.downCount++; if (wasRemoved) { baseResult.error = 'REMOVED'; } } } catch (error) { baseResult.error = error.message ?? 'Check failed'; await this.handleUnhealthyOutput(output, collection, domain).catch(() => { }); } return baseResult; } /** * Extracts URL from output record */ extractURLFromOutput(output) { try { if (typeof output.domain === 'string') { return output.domain; } if (typeof output.url === 'string') { return output.url; } if (typeof output.serviceURL === 'string') { return output.serviceURL; } if (Array.isArray(output.protocols) && output.protocols.length > 0) { const httpsProtocol = output.protocols.find((p) => typeof p === 'string' && p.startsWith('https://')); if (httpsProtocol !== undefined) { return httpsProtocol; } } return null; } catch { return null; } } /** * Validates if a string is a valid domain name */ isValidDomain(url) { try { const parsedURL = new URL(url.startsWith('http') ? url : `https://${url}`); const hostname = parsedURL.hostname; const domainRegex = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$/i; const localhostRegex = /^localhost$/i; const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; return domainRegex.test(hostname) || localhostRegex.test(hostname) || ipv4Regex.test(hostname); } catch { return false; } } /** * Handles a healthy output by decrementing its down counter */ async handleHealthyOutput(output, collection) { try { const currentDown = typeof output.down === 'number' ? output.down : 0; if (currentDown > 0) { await collection.updateOne({ _id: output._id }, { $inc: { down: -1 } }); } } catch (error) { this.logger.error(chalk.red(`Error handling healthy output ${String(output.txid)}:${String(output.outputIndex)}:`), error); } } /** * Handles an unhealthy output by incrementing its down counter. * If the threshold is reached, deletes the record and optionally bans the domain. * Returns true if the record was removed. */ async handleUnhealthyOutput(output, collection, domain) { try { const currentDown = typeof output.down === 'number' ? output.down : 0; const newDown = currentDown + 1; if (newDown >= this.hostDownRevokeScore) { this.logger.log(chalk.red(`Removing output ${String(output.txid)}:${String(output.outputIndex)} (down: ${newDown} >= ${this.hostDownRevokeScore})`)); await collection.deleteOne({ _id: output._id }); // Auto-ban the domain and outpoint to prevent GASP re-sync if (this.banService !== undefined && this.autoBanOnRemoval) { const txid = output.txid; const outputIndex = output.outputIndex; await this.banService.banOutpoint(txid, outputIndex, `Auto-banned by janitor: host down ${newDown} consecutive checks`, domain); if (typeof domain === 'string' && domain !== 'unknown') { await this.banService.banDomain(domain, `Auto-banned by janitor: host unresponsive after ${newDown} checks`); } } return true; } else { await collection.updateOne({ _id: output._id }, { $inc: { down: 1 } }); return false; } } catch (error) { this.logger.error(chalk.red(`Error handling unhealthy output ${String(output.txid)}:${String(output.outputIndex)}:`), error); return false; } } } //# sourceMappingURL=JanitorService.js.map