-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathindex.js
364 lines (345 loc) · 15.1 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
const https = require('https');
const http = require('http');
const dns = require('dns').promises;
const AWS = require('aws-sdk');
AWS.config.update({
region: process.env.REGION,
endpoint: process.env.ENDPOINT, // dynamodb has local deployments available
});
const SES = new AWS.SES({apiVersion: '2010-12-01'});
const RULE_SOURCES = process.env.RULE_SOURCES.split(';')
const MAX_AGE = parseInt(process.env.MAX_AGE || "7") * 1000 * 60 * 60 * 24
const CONTACTS = process.env.CONTACTS.split(';').map(c=>c.trim())
if (CONTACTS.length >= Math.log2(Number.MAX_SAFE_INTEGER)) console.error("There are more contacts than bits available in max number")
const FROM = process.env.FROM
const TEMPLATE = process.env.TEMPLATE
const WEBHOOKTEMPLATE = process.env.WEBHOOK_TEMPLATE || '{"text":"<pre>${this.body}</pre>"}'
const TableName = process.env.TABLE_NAME || "MonitorStatus"
const MAX_REDIRECTS = process.env.MAX_REDIRECTS || 10
// Rule record format (space separated): timeout(seconds) retries contact_bitfield url operator content
// 4kb max for TXT record ( depending on dns server )
const docClient = new AWS.DynamoDB.DocumentClient()
async function handleState (data) {
if (data.$response.hasNextPage()) {
return data.Items.concat(await data.$response.nextPage().promise().then(handleState))
} else {
return data.Items
}
}
const state = docClient.scan({TableName, Select: "ALL_ATTRIBUTES"}).promise().then(handleState).then(items=>new Map(items.map(i=>[i.url, i])))
/**
* Get list of messages to send to user based on result
* This function assumes that the rule is in a error state transition
* @param result {{rule: object, timeout: boolean, content: boolean, expiring: boolean, code: number, error: string}} result of checking rule
* @param duration {number} seconds that the rule was in error state or 0
*/
function getMessages(result, duration) {
const messages = []
const rule = result.rule
if (result.code !== 0) messages.push(`FAIL: ${rule.url} returned code ${result.code}`)
if (result.timeout) messages.push(`FAIL: ${rule.url} timed out`)
if (result.error) messages.push(`FAIL: The request to ${rule.url} failed with: ${result.error}`)
if (!result.content && (result.code !== 0 || result.timeout || result.error)) return messages;
let downtime = Math.floor(duration / (24 * 60 * 60)) + "d "
duration %= 24 * 60 * 60
downtime += Math.floor(duration / (60 * 60)) + "h "
duration %= 60 * 60
downtime += Math.floor( duration / 60) + "m "
duration %= 60
downtime += duration + "s"
switch (rule.operator) {
case '=':
messages.push(result.content
? `FAIL: The content of ${rule.url} did not include "${rule.content}"`
: `PASS: ${rule.url} is responding and confirmed to include "${rule.content}" (Downtime: ${downtime})`
)
break
case '!=':
messages.push(result.content
? `FAIL: The content of ${rule.url} included "${rule.content}"`
: `PASS: ${rule.url} is responding and confirmed to not include "${rule.content}" (Downtime: ${downtime})`
)
break
case '~':
messages.push(result.content
? `FAIL: The content of ${rule.url} did not match "${rule.content}"`
: `PASS: ${rule.url} is responding and confirmed to match "${rule.content}" (Downtime: ${downtime})`
)
break
case '!~':
messages.push(result.content
? `FAIL: The content of ${rule.url} matched "${rule.content}"`
: `PASS: ${rule.url} is responding and confirmed to not match "${rule.content}" (Downtime: ${downtime})`
)
break
default:
messages.push(`The rule for ${rule.url} refers to an unsupported operator ${rule.operator}`)
}
return messages
}
/**
* Helper to update the stored state of failed rules
* @param url {string} url of rule
* @param time {number} milliseconds since epoc that the error was first detected
* @param expiring {boolean} true if the certificate will expire soon
* @return {Promise<void>}
*/
async function setState(url, time, expiring) {
// TODO this assumes that there is only one rule per url, that might be ok but we need to test if url fragments can be included to distinguish
const s = await state
try {
if (time === 0 && !expiring) {
await docClient.delete({TableName, Key: {url}}).promise()
s.delete(url)
} else {
await docClient.put({TableName, Item: {url, time, expiring}}).promise()
s.set(url, {time, expiring})
}
} catch (e) {
console.error(e)
}
}
/**
* Helper to send emails
* @param addresses {string[]} array of email addresses
* @param body {string} body to include in email
*/
async function sendMail(addresses, body) {
// TODO add smtp config and if provided use https://nodemailer.com/about/
// If only one url in body, add domain name as 'feature'
let feature = Array.from(body.matchAll('https?://([^:/ ]+)'))
if (feature.length === 1) feature = feature[0][1]
else feature = ""
return SES.sendTemplatedEmail({
Destination: {
ToAddresses: addresses,
},
ConfigurationSetName: "rendering_failure_event",
Source: FROM,
Template: TEMPLATE,
TemplateData: JSON.stringify({ERRORS: body, FEATURE: feature}),
}).promise()
}
/**
* Helper to make webhook requests
* @param urls {string[]} array of urls to POST to
* @param body {string} body of POST request
*/
async function sendWebhooks(urls, body) {
body = body.replace(/[&<>'"]/g,
tag => ({
'&': '&',
'<': '<',
'>': '>',
"'": ''',
'"': '"'
}[tag]));
body = new Function(`return \`${WEBHOOKTEMPLATE}\`;`).call({body})
return Promise.allSettled(urls.map(url=>new Promise((resolve, reject)=>{
const req = (url.startsWith("https://") ? https : http).request(url, {method: "POST", headers: {
'Content-Type': 'application/json',
'Content-Length': body.length
}})
req.on('error', reject)
req.end(body, resolve)
})))
}
/**
* Helper to parse TXT DNS records containing rules
* @param raw_rules {string[][]} array of TXT records as returned by dns.resolveTXT
* @return {{timeout: number, retries: number, contact_bitfield: number, url: string, operator: string, content: string}[]}
*/
function parseRules(raw_rules) {
const rules = [];
for (const rule of raw_rules) {
let [timeout, retries, contact_bitfield, url, operator, ...content] = rule.join('').split(' ')
timeout = parseInt(timeout) * 1000
retries = parseInt(retries)
contact_bitfield = parseInt(contact_bitfield) // contact_bitfield.match('^[0-9]+$') ? parseInt(contact_bitfield) : atob(contact_bitfield) //TODO replace with https://github.com/i5ik/Uint1Array to allow more than ~58 contacts
rules.push({timeout, retries, contact_bitfield, url, operator, content: content.join(' ')})
}
return rules;
}
/**
* Makes request and handles response
* @param url {string} URL of request
* @param rule {{url: string, time: number, expiring: boolean, operator: string}} rule that is being checked
* @param max_age {number} The maximum remaining time before a certificate expires in days
* @param result {rule: object, timeout: boolean, content: boolean, expiring: boolean, code: number, error: string, retries: number} result object to resolve
* @param resolve {Function} callback to resolve result
* @param retries {number}
* @param redirects {number}
*/
function get(url, rule, max_age, result, resolve, retries, redirects) {
(url.startsWith('https') ? https : http).get(url, {timeout: rule.timeout}, function (res) {
// Check return code
if (res.statusCode >= 300 && res.statusCode < 400) {
// Handle redirect
if (Number.isInteger(redirects) && redirects > MAX_REDIRECTS) {
result.error = "too many redirects"
resolve(result)
return;
}
if (url === res.headers.location) {
result.error = "redirect loop"
resolve(result)
return
}
get(res.headers.location, rule, max_age, result, resolve, retries, (redirects || 0) + 1)
return
}
if ((res.statusCode < 200 || res.statusCode >= 400) && res.statusCode !== 100) {
if (retries <= 0) {
result.code = res.statusCode
resolve(result)
} else {
setTimeout(()=>get(url, rule, max_age, result, resolve, retries-1, redirects), 1000)
}
return
}
if (rule.url.startsWith('https')) {
// Check cert expiry
res.socket.on('connect', () => {
// This should always occur before the 'end' event (hopefully)
const raw_valid_to = res.socket.getPeerCertificate().valid_to
const valid_to = Date.parse(raw_valid_to)
if (isNaN(valid_to)) console.error(`Couldn't parse certificate expiry for ${rule.url}: ${raw_valid_to}`, res.socket.getPeerCertificate())
else result.expiring = valid_to - Date.now() < max_age;
})
}
//Check body content
let body = ''
res.on('data', (chunk)=>{
body += chunk
})
res.on("end", ()=>{
let fail_value = false
let operator = rule.operator
if (operator.startsWith('!')) {
fail_value = true
operator = operator.slice(1)
}
switch (operator) {
case '=':
result.content = fail_value === body.includes(rule.content)
break
case '~':
result.content = fail_value === (body.match(rule.content) === null)
break
default:
result.content = true
}
resolve(result)
})
}).setTimeout(rule.timeout, function () {
if (retries <= 0) {
result.timeout = true
resolve(result)
} else {
setTimeout(()=>get(url, rule, max_age, result, resolve, retries-1, redirects), 1000)
}
}).on('error', err=>{
if (retries <= 0) {
switch (err.code) {
case 'EPROTO':
result.error = `SSL handshake failed`
break;
case 'EAI_AGAIN':
setTimeout(()=>get(url, rule, max_age, result, resolve, retries-1, redirects), 1000)
return
default:
result.error = err.message
break;
}
resolve(result)
} else {
setTimeout(()=>get(url, rule, max_age, result, resolve, retries-1, redirects), 1000)
}
});
}
/**
* Check that the url passes the rules criteria
* @param rule {{url: string, time: number, expiring: boolean, retries: number}} rule to evaluate
* @param max_age {number} The maximum remaining time before a certificate expires in days
* @return {Promise<{rule: object, timeout: boolean, content: boolean, expiring: boolean, code: number, error: string, retries: number}>}
*/
function check(rule, max_age) {
return new Promise(resolve=>{
const result = {rule, timeout: false, content: false, expiring: false, code: 0, error: ""};
try {
get(rule.url, rule, max_age, result, resolve, rule.retries || 0, 0)
} catch (e) {
result.error = e.message
resolve(result)
}
})
}
function main(rule_sources, max_age, contacts) {
return Promise.allSettled(rule_sources.map(source =>
dns.resolveTxt(source)
.then(parseRules)
.then(rules => Promise.all(rules.map(rule=>check(rule, max_age))) // Check all rules
.then(async results => { // Pair rule state change to notification recipient
const contact_message = new Map()
for (const result of results) {
const {time, expiring} = (await state).get(result.rule.url) || {time: 0, expiring: false}
const error = result.timeout || result.content || result.code !== 0 || !!result.error
if ((time !== 0 || expiring) === error) continue // Not entering or exiting error state, all is good, skip
let new_time = time
if (time !== 0 && !error) new_time = 0
else if (time === 0 && error) new_time = Date.now()
if (((time === 0) === error) || expiring !== result.expiring) await setState(result.rule.url, new_time, expiring)
const messages = getMessages(result, Math.floor((Date.now() - time) / 1000))
// Map results to contacts
let bitfield = result.rule.contact_bitfield
for (let i = 0; bitfield; ++i) {
if (bitfield & 1) {
const contact = contacts[i]
if (contact) {
let message = contact_message.get(contact)
if (message === undefined) {
message = []
contact_message.set(contact, message)
}
message.push(messages)
}
}
bitfield >>= 1
}
}
return contact_message
})
)
)).then(settled => settled.reduce((acc, cur)=> { // Filter any failed rules and merge per rule_source notifications
if (cur.status === "fulfilled" && cur.value) {
for (const [address, errors] of cur.value.entries()) {
const allerr = (acc.get(address) || [])
allerr.push(...errors)
acc.set(address, allerr)
}
} else {
console.error(`Error fetching rules from ${cur.reason.hostname}`, cur.reason.code || cur.reason)
}
return acc
}, new Map())).then(async (messages)=>{ // Send notifications
for ( const [address, errors] of messages.entries()) {
try {
const body = errors.flat().join('\r\n')
if (address.startsWith("https://") || address.startsWith("http://")) {
await sendWebhooks([address], body)
} else {
await sendMail([address], body) // TODO BCC all addresses with the same body to reduce the number of SES requests
}
} catch (e) {
console.error(e)
}
}
return messages
});
}
exports.poll = async (event, context) => {
await main(RULE_SOURCES, MAX_AGE, CONTACTS)
}
exports.status = async (event, context) => {
// TODO dump state variable as html, include rules.url as "UP"
}