UNPKG

@bsv/overlay-express

Version:
424 lines (377 loc) 13.9 kB
import { Db } from 'mongodb' import chalk from 'chalk' import { BanService } from './BanService.js' /** * Configuration for the Janitor Service */ export interface JanitorConfig { mongoDb: Db logger?: typeof console requestTimeoutMs?: number hostDownRevokeScore?: number /** Optional BanService for persisting bans when hosts are removed */ banService?: BanService /** Whether to auto-ban domains when they exceed the down threshold (default: true) */ autoBanOnRemoval?: boolean } /** * Health status of a single record */ export interface HostHealthResult { txid: string outputIndex: number domain: string topic?: string service?: string identityKey?: string createdAt?: Date healthy: boolean downCount: number responseTimeMs?: number statusCode?: number error?: string } /** * Summary report from a janitor run */ export interface JanitorReport { startedAt: Date completedAt: Date durationMs: number shipResults: HostHealthResult[] slapResults: HostHealthResult[] summary: { totalChecked: number healthy: number unhealthy: number removed: number banned: number } } /** * 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 { private readonly mongoDb: Db private readonly logger: typeof console private readonly requestTimeoutMs: number private readonly hostDownRevokeScore: number private readonly banService?: BanService private readonly autoBanOnRemoval: boolean constructor (config: JanitorConfig) { 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 (): Promise<JanitorReport> { const startedAt = new Date() this.logger.log(chalk.blue('Running janitor health checks...')) let shipResults: HostHealthResult[] = [] let slapResults: HostHealthResult[] = [] 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: string): Promise<{ healthy: boolean, responseTimeMs: number, statusCode?: number, error?: string }> { 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: any) { 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: any) { 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 (): Promise<{ ship: HostHealthResult[], slap: HostHealthResult[] }> { 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: HostHealthResult[] = shipOutputs.map(output => ({ txid: output.txid as string, outputIndex: output.outputIndex as number, domain: this.extractURLFromOutput(output) ?? 'unknown', topic: output.topic as string, identityKey: output.identityKey as string, createdAt: output.createdAt as Date, healthy: true, // Will be updated if health check is run downCount: typeof output.down === 'number' ? output.down : 0 })) const slapResults: HostHealthResult[] = slapOutputs.map(output => ({ txid: output.txid as string, outputIndex: output.outputIndex as number, domain: this.extractURLFromOutput(output) ?? 'unknown', service: output.service as string, identityKey: output.identityKey as string, createdAt: output.createdAt as Date, 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. */ private async checkTopicOutputs ( collectionName: string, typeField: 'topic' | 'service' ): Promise<{ results: HostHealthResult[], removed: number, banned: number }> { const results: HostHealthResult[] = [] 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. */ private async checkOutput ( output: Record<string, any>, collection: any, typeField: 'topic' | 'service' ): Promise<HostHealthResult> { const domain = this.extractURLFromOutput(output) ?? 'unknown' const baseResult: HostHealthResult = { txid: output.txid as string, outputIndex: output.outputIndex as number, domain, identityKey: output.identityKey as string, createdAt: output.createdAt as Date, healthy: false, downCount: typeof output.down === 'number' ? output.down : 0 } if (typeField === 'topic') { baseResult.topic = output.topic as string } else { baseResult.service = output.service as string } 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: any) { baseResult.error = error.message ?? 'Check failed' await this.handleUnhealthyOutput(output, collection, domain).catch(() => {}) } return baseResult } /** * Extracts URL from output record */ private extractURLFromOutput (output: Record<string, any>): string | null { 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: any) => 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 */ private isValidDomain (url: string): boolean { 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 */ private async handleHealthyOutput (output: Record<string, any>, collection: any): Promise<void> { 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. */ private async handleUnhealthyOutput (output: Record<string, any>, collection: any, domain?: string): Promise<boolean> { 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 as string const outputIndex = output.outputIndex as number 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 } } }