UNPKG

@bsv/overlay-express

Version:
328 lines 14.4 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.JanitorService = void 0; const chalk_1 = __importDefault(require("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. */ class JanitorService { constructor(config) { var _a, _b, _c, _d; this.mongoDb = config.mongoDb; this.logger = (_a = config.logger) !== null && _a !== void 0 ? _a : console; this.requestTimeoutMs = (_b = config.requestTimeoutMs) !== null && _b !== void 0 ? _b : 10000; this.hostDownRevokeScore = (_c = config.hostDownRevokeScore) !== null && _c !== void 0 ? _c : 3; this.banService = config.banService; this.autoBanOnRemoval = (_d = config.autoBanOnRemoval) !== null && _d !== void 0 ? _d : 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_1.default.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_1.default.green('Janitor health checks completed')); } catch (error) { this.logger.error(chalk_1.default.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) { var _a, _b; 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 === null || data === void 0 ? void 0 : data.status) === 'ok' && (data === null || data === void 0 ? void 0 : data.ready) !== false) || ((data === null || data === void 0 ? void 0 : data.ready) === true && (data === null || data === void 0 ? void 0 : 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: (_a = error.message) !== null && _a !== void 0 ? _a : 'Connection failed' }; } } catch (error) { return { healthy: false, responseTimeMs: Date.now() - startTime, error: (_b = error.message) !== null && _b !== void 0 ? _b : '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 => { var _a; return ({ txid: output.txid, outputIndex: output.outputIndex, domain: (_a = this.extractURLFromOutput(output)) !== null && _a !== void 0 ? _a : '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 => { var _a; return ({ txid: output.txid, outputIndex: output.outputIndex, domain: (_a = this.extractURLFromOutput(output)) !== null && _a !== void 0 ? _a : '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_1.default.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_1.default.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) { var _a, _b; const domain = (_a = this.extractURLFromOutput(output)) !== null && _a !== void 0 ? _a : '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 = (_b = error.message) !== null && _b !== void 0 ? _b : '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_1.default.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_1.default.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_1.default.red(`Error handling unhealthy output ${String(output.txid)}:${String(output.outputIndex)}:`), error); return false; } } } exports.JanitorService = JanitorService; //# sourceMappingURL=JanitorService.js.map