@bsv/overlay-express
Version:
BSV Blockchain Overlay Express
328 lines • 14.4 kB
JavaScript
;
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