-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathserver.js
More file actions
165 lines (145 loc) · 5.68 KB
/
Copy pathserver.js
File metadata and controls
165 lines (145 loc) · 5.68 KB
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
import { createHash, timingSafeEqual } from 'node:crypto';
import express from 'express';
import morgan from 'morgan';
import createSender from './send.js';
import formatEmail from './normalize.js';
// Exported factory: starts an Express "dead man's switch" server.
// key - shared secret callers must present to arm/clear timeouts
// emailConfig - provider config passed to ./send (selects SMTP/Mandrill/etc.)
// listenPort - optional port; falls back to PORT env var, then 3000
//
// Clients periodically PUT /still/alive/:id to (re)arm a per-id timer. If a
// client stops checking in, the timer fires and an alert email is sent.
export default async function createServer(key, emailConfig, listenPort) {
if (typeof key !== 'string' || key.length === 0) {
throw new TypeError('`key` must be a non-empty string.');
}
// Active timers keyed by id; each is replaced/cleared on the next check-in.
const timeouts = new Map();
const emailSender = await createSender(emailConfig);
const verifyKey = createKeyVerifier(key);
const app = express();
const port = listenPort || process.env.PORT || 3000;
app.use(morgan('dev'));
app.use(express.json());
const sendEmail = (opts) => {
emailSender(opts, (err, resp) => {
if (err) {
console.error('Email error:', err);
} else {
console.log('Email sent:', resp);
}
});
};
app.get('/', (_req, res) => {
res.send('ok');
});
// Arm (or re-arm) the watchdog timer for :id. Each call resets the countdown;
// the email only fires if no further check-in arrives before it elapses.
app.put('/still/alive/:id', (req, res) => {
const body = req.body ?? {};
if (!verifyKey(body.key)) {
return res.status(400).json({ error: 'bad request' });
}
// Validate the alert payload now so the caller gets immediate feedback
// instead of the email failing silently when the timer eventually fires.
const emailErrors = formatEmail.validate(body.email);
if (emailErrors.length) {
return res.status(400).json({ error: 'invalid email', details: emailErrors });
}
// Reject an unusable interval up front
const ms = toMilliseconds(body.interval);
if (!Number.isFinite(ms) || ms < 0) {
return res.status(400).json({ error: 'invalid interval' });
}
const { id } = req.params;
// Cancel any existing timer for this id so we restart the countdown clean.
if (timeouts.has(id)) {
clearTimeout(timeouts.get(id).timer);
timeouts.delete(id);
}
// Schedule the alert. If this id checks in again first, the timer above is
// cleared and this callback never runs. We also record when it will fire so
// the /active route can report each timer's remaining time.
timeouts.set(id, {
expiresAt: Date.now() + ms,
timer: setTimeout(() => {
sendEmail(body.email);
timeouts.delete(id);
}, ms),
});
res.json({ 'timeout set': body.interval });
});
// Manually cancel a pending timer for :id (e.g. on a clean shutdown).
app.put('/clear/:id', (req, res) => {
const body = req.body ?? {};
if (!verifyKey(body.key)) {
return res.status(400).json({ error: 'bad request' });
}
const { id } = req.params;
if (timeouts.has(id)) {
clearTimeout(timeouts.get(id).timer);
timeouts.delete(id);
return res.json({ cleared: true });
}
res.status(400).json({ error: 'no such timeout' });
});
// List every currently-armed watchdog timer with its id and when it will
// fire. Protected by the shared key, sent in the body like the other routes.
app.post('/active', (req, res) => {
const body = req.body ?? {};
if (!verifyKey(body.key)) {
return res.status(400).json({ error: 'bad request' });
}
const now = Date.now();
const active = Array.from(timeouts, ([id, timeout]) => ({
id,
expiresAt: new Date(timeout.expiresAt).toISOString(),
msRemaining: Math.max(0, timeout.expiresAt - now),
}));
res.json({ active });
});
if (listenPort === false) {
return app;
}
console.log(`app is listening on ${port}`);
app.listen(port);
return app;
}
// Build a comparator that checks a supplied key against the configured one.
// Both keys are hashed to a fixed-length digest and compared with
// crypto.timingSafeEqual, so the check is constant-time (no key leak via timing
// analysis) and -- because the digests are always the same length -- it neither
// throws nor reveals the key's length.
function createKeyVerifier(origKey) {
const origHash = createHash('sha256').update(origKey).digest();
return (compare) => {
// A missing or non-string key can't match; treat it as a failed auth
// (handled as a 400 by the route) rather than letting the hash throw.
if (typeof compare !== 'string') {
return false;
}
const compHash = createHash('sha256').update(compare).digest();
return timingSafeEqual(origHash, compHash);
};
}
// Convert an interval into milliseconds. Accepts a number (passed through as
// milliseconds) or an object with any of weeks/days/hours/minutes/seconds/
// milliseconds, which are summed.
function toMilliseconds(i) {
if (typeof i === 'number') {
return i;
}
// Return null for anything that isn't a number or a units object.
// Signals the route to reject with a clear error.
if (!i || typeof i !== 'object') {
return null;
}
// Roll each larger unit down into the next, accumulating to milliseconds.
const weeks = i.weeks || 0;
const days = (i.days || 0) + weeks * 7;
const hours = (i.hours || 0) + days * 24;
const minutes = (i.minutes || 0) + hours * 60;
const seconds = (i.seconds || 0) + minutes * 60;
return (i.milliseconds || 0) + seconds * 1000;
}