mirror of
https://github.com/Kizuren/statusPage.git
synced 2025-12-21 21:16:09 +01:00
336 lines
No EOL
12 KiB
JavaScript
336 lines
No EOL
12 KiB
JavaScript
import {promises as fs, watchFile} from 'fs';
|
|
import net from 'net';
|
|
let config = (await import('./config.js')).default;
|
|
|
|
watchFile('./config.js', async ()=>{ // Dynamically reload config and watch it for changes.
|
|
try {
|
|
config = (await import('./config.js?refresh='+Date.now())).default;
|
|
console.log('Reloaded config file.')
|
|
} catch(e) {
|
|
console.error(e);
|
|
}
|
|
});
|
|
|
|
const statusFile = './static/status.json';
|
|
|
|
const delay = async t=>new Promise(r=>setTimeout(r, t));
|
|
const handlize = s=>s.toLowerCase().replace(/[^a-z0-9]/g, ' ').trim().replace(/\s+/g, '-');
|
|
const checkContent = async (content, criterion, negate=false) => {
|
|
if(typeof criterion=='string') {
|
|
return content.includes(criterion)!=negate;
|
|
} else if(Array.isArray(criterion)) {
|
|
return criterion[negate?'some':'every'](c=>content.includes(c))!=negate;
|
|
} else if(criterion instanceof RegExp) {
|
|
return (!!content.match(criterion))!=negate;
|
|
} else if(typeof criterion=='function') {
|
|
return (!!await Promise.resolve(criterion(content)))!=negate;
|
|
} else {
|
|
throw new Error('Invalid content check criterion.')
|
|
}
|
|
};
|
|
const sendTelegramMessage = async (text) => {
|
|
const url = `https://api.telegram.org/bot${config.telegram.botToken}/sendMessage`;
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
chat_id: config.telegram.chatId,
|
|
text: text,
|
|
}),
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`[Telegram] Failed to send message: ${response.statusText}`);
|
|
}
|
|
return await response.json();
|
|
};
|
|
const sendDiscordMessage = async (text) => {
|
|
try {
|
|
const response = await fetch(config.discord.webhookUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ content: text })
|
|
});
|
|
const data = await response.json();
|
|
return data;
|
|
} catch (error) {
|
|
console.error('Error sending Discord message:', error);
|
|
}
|
|
};
|
|
const sendSMSMessage = async (text) => {
|
|
const response = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${config.twilio.accountSid}/Messages.json`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'Authorization': `Basic ${Buffer.from(`${config.twilio.accountSid}:${config.twilio.accountToken}`).toString('base64')}`
|
|
},
|
|
body: new URLSearchParams({
|
|
To: config.twilio.toNumber,
|
|
From: config.twilio.twilioNumber,
|
|
Body: text
|
|
})
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`[Twilio-SMS] Failed to send message: ${response.statusText}`);
|
|
}
|
|
return await response.json();
|
|
};
|
|
const sendSlackMessage = async (text) => {
|
|
const url = 'https://slack.com/api/chat.postMessage';
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${config.slack.botToken}`
|
|
},
|
|
body: JSON.stringify({
|
|
channel: config.slack.channelId,
|
|
text: text,
|
|
})
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`[Slack] Failed to send message: ${response.statusText}`);
|
|
}
|
|
return await response.json();
|
|
};
|
|
const sendEmailMessage = async (text) => {
|
|
const response = await fetch('https://api.sendgrid.com/v3/mail/send', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${config.sendgrid.apiKey}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
personalizations: [{ to: [{ email: config.sendgrid.toEmail }] }],
|
|
from: { email: config.sendgrid.toFromEmail },
|
|
subject: "aPulse — Server Status Notification",
|
|
content: [{ type: "text/plain", value: text }]
|
|
})
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`[SendGrid-Email] Failed to send message: ${response.statusText}`);
|
|
}
|
|
return await response.json();
|
|
};
|
|
const sendNotification = async (message) => {
|
|
try {
|
|
if(config.telegram?.botToken && config.telegram?.chatId)
|
|
await sendTelegramMessage(message);
|
|
if(config.slack?.botToken && config.slack?.channelId)
|
|
await sendSlackMessage(message);
|
|
if(config.discord?.webhookUrl)
|
|
await sendDiscordMessage(message);
|
|
if(config.twilio?.accountSid && config.twilio?.accountToken && config.twilio?.toNumber && config.twilio?.twilioNumber)
|
|
await sendSMSMessage(message);
|
|
if(config.sendgrid?.apiKey && config.sendgrid?.toEmail && config.sendgrid?.toFromEmail)
|
|
await sendEmailMessage(message);
|
|
} catch (error) {
|
|
console.error('Error sending notification:', error);
|
|
}
|
|
}
|
|
|
|
const checkOnionService = async (host, port) => {
|
|
try {
|
|
return new Promise((resolve) => {
|
|
const socket = new net.Socket();
|
|
socket.setTimeout(5000);
|
|
|
|
socket.on('connect', () => {
|
|
socket.destroy();
|
|
resolve({ success: true, duration: 0 });
|
|
});
|
|
|
|
socket.on('error', (err) => {
|
|
resolve({ success: false, error: err.message });
|
|
});
|
|
|
|
socket.on('timeout', () => {
|
|
socket.destroy();
|
|
resolve({ success: false, error: 'Connection timeout' });
|
|
});
|
|
|
|
socket.connect(port, host);
|
|
});
|
|
} catch (e) {
|
|
return { success: false, error: e.message };
|
|
}
|
|
};
|
|
|
|
while(true) {
|
|
config.verbose && console.log('🔄 Pulse');
|
|
let startPulse = Date.now();
|
|
let status;
|
|
try {
|
|
try {
|
|
status = JSON.parse((await fs.readFile(statusFile)).toString()); // We re-read the file each time in case it was manually modified.
|
|
} catch(e) {
|
|
if (e instanceof SyntaxError) {
|
|
console.error(`Syntax error in status.json file [${statusFile}]:`, e);
|
|
} else {
|
|
console.error(`Could not find status.json file [${statusFile}], will create it.`);
|
|
}
|
|
}
|
|
status = status || {};
|
|
status.sites = status.sites || {};
|
|
status.config = {
|
|
interval : config.interval,
|
|
nDataPoints : config.nDataPoints,
|
|
responseTimeGood : config.responseTimeGood,
|
|
responseTimeWarning : config.responseTimeWarning,
|
|
};
|
|
|
|
status.ui = [];
|
|
|
|
let siteIds = [];
|
|
for(let site of config.sites) {
|
|
config.verbose && console.log(`⏳ Site: ${site.name || site.id}`);
|
|
let siteId = site.id || handlize(site.name) || 'site';
|
|
let i = 1; let siteId_ = siteId;
|
|
while(siteIds.includes(siteId)) {siteId = siteId_+'-'+(++i)} // Ensure a unique site id
|
|
siteIds.push(siteId);
|
|
|
|
status.sites[siteId] = status.sites[siteId] || {};
|
|
let site_ = status.sites[siteId]; // shortcut ref
|
|
site_.name = site.name || site_.name;
|
|
site_.endpoints = site_.endpoints || {};
|
|
|
|
let endpointIds = [];
|
|
status.ui.push([siteId, endpointIds]);
|
|
try {
|
|
for(let endpoint of site.endpoints) {
|
|
let endpointStatus = {
|
|
t : Date.now(),// time
|
|
};
|
|
config.verbose && console.log(`\tFetching endpoint: ${endpoint.url}`);
|
|
let endpointId = endpoint.id || handlize(endpoint.name) || 'endpoint';
|
|
let i = 1; let endpointId_ = endpointId;
|
|
while(endpointIds.includes(endpointId)) {endpointId = endpointId_+'-'+(++i)} // Ensure a unique endpoint id
|
|
endpointIds.push(endpointId);
|
|
|
|
site_.endpoints[endpointId] = site_.endpoints[endpointId] || {};
|
|
let endpoint_ = site_.endpoints[endpointId]; // shortcut ref
|
|
endpoint_.name = endpoint.name || endpoint_.name;
|
|
if(endpoint.link!==false)
|
|
endpoint_.link = endpoint.link || endpoint.url;
|
|
endpoint_.logs = endpoint_.logs || [];
|
|
let start;
|
|
|
|
try {
|
|
if (endpoint.url.includes('.onion')) {
|
|
const [host, port] = endpoint.url.split(':');
|
|
host = endpoint.id.includes('-POWERFUL') ? '192.168.0.106' : '127.0.0.1';
|
|
const result = await checkOnionService(host, port || 8333);
|
|
|
|
endpointStatus.t = Date.now();
|
|
endpointStatus.dur = 0;
|
|
endpointStatus.ttfb = 0;
|
|
|
|
if (!result.success) {
|
|
endpointStatus.err = `Onion service check failed: ${result.error}`;
|
|
}
|
|
} else {
|
|
performance.clearResourceTimings();
|
|
start = performance.now();
|
|
let response = await fetch(endpoint.url, {
|
|
signal: AbortSignal.timeout(config.timeout),
|
|
...endpoint.request,
|
|
});
|
|
let content = await response.text();
|
|
await delay(0); // Ensures that the entry was registered.
|
|
let perf = performance.getEntriesByType('resource')[0];
|
|
if(perf) {
|
|
endpointStatus.dur = perf.responseEnd - perf.startTime; // total request duration
|
|
endpointStatus.dns = perf.domainLookupEnd - perf.domainLookupStart; // DNS Lookup
|
|
endpointStatus.tcp = perf.connectEnd - perf.connectStart; // TCP handshake time
|
|
endpointStatus.ttfb = perf.responseStart - perf.requestStart; // time to first byte -> Latency
|
|
endpointStatus.dll = perf.responseEnd - perf.responseStart; // time for content download
|
|
} else { // backup in case entry was not registered
|
|
endpointStatus.dur = performance.now() - start;
|
|
endpointStatus.ttfb = endpointStatus.dur;
|
|
config.verbose && console.log(`\tCould not use PerformanceResourceTiming API to measure request.`);
|
|
}
|
|
|
|
// HTTP Status Check
|
|
if(!endpoint.validStatus && !response.ok) {
|
|
endpointStatus.err = `HTTP Status ${response.status}: ${response.statusText}`;
|
|
continue;
|
|
} else if(endpoint.validStatus && ((Array.isArray(endpoint.validStatus) && !endpoint.validStatus.includes(response.status)) || (!Array.isArray(endpoint.validStatus) && endpoint.validStatus!=response.status))) {
|
|
endpointStatus.err = `HTTP Status ${response.status}: ${response.statusText}`;
|
|
continue;
|
|
}
|
|
|
|
// Content checks
|
|
if(endpoint.mustFind && !await checkContent(content, endpoint.mustFind)) {
|
|
endpointStatus.err = '"mustFind" check failed';
|
|
continue;
|
|
}
|
|
if(endpoint.mustNotFind && !await checkContent(content, endpoint.mustNotFind, true)) {
|
|
endpointStatus.err = '"mustNotFind" check failed';
|
|
continue;
|
|
}
|
|
if(endpoint.customCheck && typeof endpoint.customCheck == 'function' && !await Promise.resolve(endpoint.customCheck(content, response))) {
|
|
endpointStatus.err = '"customCheck" check failed';
|
|
continue;
|
|
}
|
|
}
|
|
} catch(e) {
|
|
endpointStatus.err = String(e);
|
|
if(!endpointStatus.dur) {
|
|
endpointStatus.dur = performance.now() - start;
|
|
endpointStatus.ttfb = endpointStatus.dur;
|
|
}
|
|
} finally {
|
|
endpoint_.logs.push(endpointStatus);
|
|
if(endpoint_.logs.length > config.logsMaxDatapoints) // Remove old datapoints
|
|
endpoint_.logs.splice(0, endpoint_.logs.length - config.logsMaxDatapoints);
|
|
if(endpointStatus.err) {
|
|
endpoint.consecutiveErrors = (endpoint.consecutiveErrors || 0) + 1;
|
|
endpoint.consecutiveHighLatency = 0;
|
|
config.verbose && console.log(`\t🔥 ${site.name || siteId} — ${endpoint.name || endpointId} [${endpointStatus.ttfb.toFixed(2)}ms]`);
|
|
config.verbose && console.log(`\t→ ${endpointStatus.err}`);
|
|
try {
|
|
if(endpoint.consecutiveErrors>=config.consecutiveErrorsNotify) {
|
|
/*await*/ sendNotification( // Don't await to prevent blocking/delaying next pulse
|
|
`🔥 ERROR\n`+
|
|
`${site.name || siteId} — ${endpoint.name || endpointId} [${endpointStatus.ttfb.toFixed(2)}ms]\n`+
|
|
`→ ${endpointStatus.err}`+
|
|
(endpoint.link!==false?`\n→ ${endpoint.link || endpoint.url}`:'')
|
|
);
|
|
}
|
|
} catch(e) {console.error(e);}
|
|
} else {
|
|
endpoint.consecutiveErrors = 0;
|
|
let emoji = '🟢';
|
|
if(endpointStatus.ttfb>config.responseTimeWarning) {
|
|
emoji = '🟥';
|
|
endpoint.consecutiveHighLatency = (endpoint.consecutiveHighLatency || 0) + 1;
|
|
} else {
|
|
endpoint.consecutiveHighLatency = 0;
|
|
if(endpointStatus.ttfb>config.responseTimeGood)
|
|
emoji = '🔶';
|
|
}
|
|
config.verbose && console.log(`\t${emoji} ${site.name || siteId} — ${endpoint.name || endpointId} [${endpointStatus.ttfb.toFixed(2)}ms]`);
|
|
try {
|
|
if(endpoint.consecutiveHighLatency>=config.consecutiveHighLatencyNotify) {
|
|
/*await*/ sendNotification( // Don't await to prevent blocking/delaying next pulse
|
|
`🟥 High Latency\n`+
|
|
`${site.name || siteId} — ${endpoint.name || endpointId} [${endpointStatus.ttfb.toFixed(2)}ms]\n`+
|
|
(endpoint.link!==false?`\n→ ${endpoint.link || endpoint.url}`:'')
|
|
);
|
|
}
|
|
} catch(e) {console.error(e);}
|
|
}
|
|
}
|
|
}
|
|
} catch(e) {
|
|
console.error(e);
|
|
}
|
|
config.verbose && console.log(' ');//New line
|
|
}
|
|
status.lastPulse = Date.now();
|
|
await fs.writeFile(statusFile, JSON.stringify(status, undefined, config.readableStatusJson?2:undefined));
|
|
} catch(e) {
|
|
console.error(e);
|
|
}
|
|
config.verbose && console.log('✅ Done');
|
|
await delay(config.interval * 60_000 - (Date.now() - startPulse));
|
|
} |