buzz-me-in 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +68 -0
- package/LICENSE +21 -0
- package/README.md +452 -0
- package/homekit.js +789 -0
- package/intercom.service +48 -0
- package/package.json +73 -0
- package/server.js +420 -0
- package/src/core/config.js +133 -0
- package/src/core/log.js +132 -0
- package/src/core/media-stream.js +335 -0
- package/src/core/mulaw-audio.js +233 -0
- package/src/core/return-audio-sdp.js +95 -0
- package/src/core/safe-equal.js +22 -0
- package/src/core/state.js +149 -0
- package/src/core/stream-auth.js +130 -0
- package/src/core/twilio-ids.js +27 -0
- package/src/core/types.js +145 -0
- package/src/core/ws-events-schema.js +110 -0
- package/twilio-api.js +53 -0
package/intercom.service
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=HomeKit Intercom Server
|
|
3
|
+
Documentation=https://github.com/atdr/buzz-me-in
|
|
4
|
+
# Start after the network is up AND after the tunnel service
|
|
5
|
+
# so the server is ready by the time Twilio can reach it.
|
|
6
|
+
After=network-online.target cloudflared.service
|
|
7
|
+
Wants=network-online.target
|
|
8
|
+
|
|
9
|
+
[Service]
|
|
10
|
+
Type=simple
|
|
11
|
+
User=pi
|
|
12
|
+
WorkingDirectory=/home/pi/intercom
|
|
13
|
+
|
|
14
|
+
# Load environment variables from .env file
|
|
15
|
+
EnvironmentFile=/home/pi/intercom/.env
|
|
16
|
+
|
|
17
|
+
# Resolved from PATH rather than hardcoded: the npm global prefix varies by
|
|
18
|
+
# install method (`npm prefix -g`) — /usr/local for Debian's apt nodejs, /usr
|
|
19
|
+
# for NodeSource — and systemd's default PATH covers both. If npm's prefix is
|
|
20
|
+
# elsewhere, such as under nvm, use an absolute ExecStart or prepend the
|
|
21
|
+
# prefix with Environment=PATH=...
|
|
22
|
+
ExecStart=/usr/bin/env buzz-me-in
|
|
23
|
+
|
|
24
|
+
# Sandboxing — the service only needs its working directory, the network,
|
|
25
|
+
# and a private /tmp (SDP files with SRTP keys live there).
|
|
26
|
+
# ProtectHome is intentionally NOT set: WorkingDirectory and the HAP
|
|
27
|
+
# pairing persist data live under /home/pi/intercom.
|
|
28
|
+
NoNewPrivileges=yes
|
|
29
|
+
PrivateTmp=yes
|
|
30
|
+
ProtectSystem=full
|
|
31
|
+
ProtectKernelTunables=yes
|
|
32
|
+
ProtectControlGroups=yes
|
|
33
|
+
RestrictSUIDSGID=yes
|
|
34
|
+
|
|
35
|
+
# Restart on any non-zero exit (crash, OOM, etc.)
|
|
36
|
+
# Do NOT restart on clean exit (code 0) to allow intentional shutdowns.
|
|
37
|
+
Restart=on-failure
|
|
38
|
+
RestartSec=5
|
|
39
|
+
|
|
40
|
+
# Give HAP-NodeJS time to unpublish the mDNS record gracefully on stop.
|
|
41
|
+
TimeoutStopSec=10
|
|
42
|
+
|
|
43
|
+
StandardOutput=journal
|
|
44
|
+
StandardError=journal
|
|
45
|
+
SyslogIdentifier=intercom
|
|
46
|
+
|
|
47
|
+
[Install]
|
|
48
|
+
WantedBy=multi-user.target
|
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "buzz-me-in",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "HomeKit-compatible apartment intercom via Twilio media streams",
|
|
5
|
+
"main": "server.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"buzz-me-in": "./server.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"server.js",
|
|
11
|
+
"homekit.js",
|
|
12
|
+
"twilio-api.js",
|
|
13
|
+
"src/",
|
|
14
|
+
"intercom.service",
|
|
15
|
+
".env.example"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"start": "node server.js",
|
|
19
|
+
"lint": "eslint .",
|
|
20
|
+
"format": "prettier --write .",
|
|
21
|
+
"format:check": "prettier --check .",
|
|
22
|
+
"check": "node --check server.js homekit.js twilio-api.js src/core/*.js",
|
|
23
|
+
"test": "DOTENV_CONFIG_PATH=/dev/null node --test",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"prepare": "husky || true"
|
|
26
|
+
},
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public",
|
|
30
|
+
"provenance": true
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/atdr/buzz-me-in.git"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/atdr/buzz-me-in",
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/atdr/buzz-me-in/issues"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=20"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"dotenv": "^17.4.2",
|
|
45
|
+
"hap-nodejs": "^0.14.2",
|
|
46
|
+
"qrcode-terminal": "^0.12.0",
|
|
47
|
+
"twilio": "^6.1.0",
|
|
48
|
+
"websocket": "^1.0.35",
|
|
49
|
+
"zod": "^4.5.4"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@commitlint/cli": "^21.2.2",
|
|
53
|
+
"@commitlint/config-conventional": "^21.0.2",
|
|
54
|
+
"@eslint/js": "^10.0.1",
|
|
55
|
+
"@types/websocket": "^1.0.10",
|
|
56
|
+
"eslint": "^10.9.1",
|
|
57
|
+
"eslint-config-prettier": "^10.1.8",
|
|
58
|
+
"globals": "^17.11.0",
|
|
59
|
+
"husky": "^9.1.7",
|
|
60
|
+
"lint-staged": "^17.4.1",
|
|
61
|
+
"prettier": "^3.9.6",
|
|
62
|
+
"typescript": "^7.0.2"
|
|
63
|
+
},
|
|
64
|
+
"lint-staged": {
|
|
65
|
+
"*.{js,cjs}": [
|
|
66
|
+
"eslint --fix",
|
|
67
|
+
"prettier --write"
|
|
68
|
+
],
|
|
69
|
+
"*.{md,yml,yaml,json}": [
|
|
70
|
+
"prettier --write"
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
}
|
package/server.js
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// based on https://github.com/twilio/media-streams/blob/master/node/basic/README.md
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
const http = require('http');
|
|
6
|
+
const twilio = require('twilio');
|
|
7
|
+
const WebSocketServer = require('websocket').server;
|
|
8
|
+
|
|
9
|
+
const config = require('./src/core/config');
|
|
10
|
+
const state = require('./src/core/state');
|
|
11
|
+
const { createRingbackWav } = require('./src/core/mulaw-audio');
|
|
12
|
+
const {
|
|
13
|
+
buildConnectStreamTwiml,
|
|
14
|
+
STREAM_TOKEN_PARAMETER_NAME,
|
|
15
|
+
verifyAndConsumeStreamToken,
|
|
16
|
+
} = require('./src/core/stream-auth');
|
|
17
|
+
const { normalizeCallSid } = require('./src/core/twilio-ids');
|
|
18
|
+
const homekit = require('./homekit');
|
|
19
|
+
const { createLogger } = require('./src/core/log');
|
|
20
|
+
const { safeEqualString } = require('./src/core/safe-equal');
|
|
21
|
+
const { MediaStream } = require('./src/core/media-stream');
|
|
22
|
+
/** @import { request as WebSocketRequest } from 'websocket' */
|
|
23
|
+
|
|
24
|
+
const HTTP_SERVER_PORT = config.port;
|
|
25
|
+
const STREAM_PATH = '/media';
|
|
26
|
+
const STREAM_START_TIMEOUT_MS = config.streamStartTimeoutMs;
|
|
27
|
+
const STATUS_BEARER_PREFIX = 'Bearer ';
|
|
28
|
+
const MAX_WS_UTF8_BYTES = config.wsMaxMessageBytes;
|
|
29
|
+
const SHUTDOWN_GRACE_MS = config.shutdownGraceMs;
|
|
30
|
+
let shuttingDown = false;
|
|
31
|
+
const activeWsConnections = new Set();
|
|
32
|
+
const logger = createLogger({ component: 'server' });
|
|
33
|
+
const mediaWsLogger = logger.child({ component: 'media-ws' });
|
|
34
|
+
const twimlLogger = logger.child({ component: 'twiml' });
|
|
35
|
+
const activeMediaStreamsByCallSid = new Map();
|
|
36
|
+
|
|
37
|
+
homekit.setOnHapSessionStarted((callSid) => {
|
|
38
|
+
const mediaStream = activeMediaStreamsByCallSid.get(callSid);
|
|
39
|
+
if (mediaStream) mediaStream.markHomekitSessionStarted();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Ringtone — one UK-style ringback cycle (400Hz+450Hz dual tone), generated
|
|
44
|
+
// in-process as a WAV buffer. Served at GET /ringtone for debug/manual
|
|
45
|
+
// checks. Runtime ringback is sent over the bidirectional media stream so
|
|
46
|
+
// Twilio keeps the same WebSocket connected.
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
const RINGTONE_WAV = createRingbackWav();
|
|
49
|
+
|
|
50
|
+
const wsserver = http.createServer(handleRequest);
|
|
51
|
+
|
|
52
|
+
// Enforce message bounds in the websocket library itself so oversized
|
|
53
|
+
// frames are rejected before assembly, instead of relying only on the
|
|
54
|
+
// per-message check in MediaStream (library defaults allow 1 MiB).
|
|
55
|
+
const WS_LIBRARY_MAX_BYTES = MAX_WS_UTF8_BYTES * 4;
|
|
56
|
+
const MAX_CONCURRENT_WS_CONNECTIONS = config.wsMaxConnections;
|
|
57
|
+
|
|
58
|
+
const mediaws = new WebSocketServer({
|
|
59
|
+
httpServer: wsserver,
|
|
60
|
+
autoAcceptConnections: false,
|
|
61
|
+
maxReceivedFrameSize: WS_LIBRARY_MAX_BYTES,
|
|
62
|
+
maxReceivedMessageSize: WS_LIBRARY_MAX_BYTES,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
state.setOnSessionStale((session) => {
|
|
66
|
+
logger.warn('Call session stale; ending session', {
|
|
67
|
+
callSid: session.callSid,
|
|
68
|
+
event: 'session-stale',
|
|
69
|
+
reason: session.clearedReason,
|
|
70
|
+
});
|
|
71
|
+
if (session.wsConnection && typeof session.wsConnection.close === 'function') {
|
|
72
|
+
try {
|
|
73
|
+
session.wsConnection.close();
|
|
74
|
+
} catch (err) {
|
|
75
|
+
logger.error('Failed to close stale call websocket', {
|
|
76
|
+
callSid: session.callSid,
|
|
77
|
+
event: 'stale-close-failed',
|
|
78
|
+
reason: 'close-threw',
|
|
79
|
+
error: err,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
homekit.endHapSession();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
function handleRequest(request, response) {
|
|
87
|
+
try {
|
|
88
|
+
if (shuttingDown) {
|
|
89
|
+
response.writeHead(503);
|
|
90
|
+
response.end('Server shutting down');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const path = request.url ? request.url.split('?')[0] : '';
|
|
94
|
+
if (request.method === 'POST' && path === '/twiml') {
|
|
95
|
+
handleTwimlRequest(request, response).catch((err) => {
|
|
96
|
+
twimlLogger.error('Unhandled TwiML handler failure', {
|
|
97
|
+
event: 'twiml-unhandled-error',
|
|
98
|
+
reason: 'handler-threw',
|
|
99
|
+
error: err,
|
|
100
|
+
});
|
|
101
|
+
if (!response.headersSent) response.writeHead(500);
|
|
102
|
+
response.end('Internal Server Error');
|
|
103
|
+
});
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const handler = request.method === 'GET' ? GET_ROUTES.get(path) : undefined;
|
|
107
|
+
if (handler) {
|
|
108
|
+
handler(request, response);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
response.writeHead(404);
|
|
112
|
+
response.end('Not Found');
|
|
113
|
+
} catch (err) {
|
|
114
|
+
logger.error('HTTP request handling failed', {
|
|
115
|
+
event: 'request-handler-error',
|
|
116
|
+
reason: 'handler-threw',
|
|
117
|
+
error: err,
|
|
118
|
+
});
|
|
119
|
+
if (!response.headersSent) response.writeHead(500);
|
|
120
|
+
response.end('Internal Server Error');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// HTTP routes
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
function buildTwiml(callSid) {
|
|
129
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
130
|
+
<Response>
|
|
131
|
+
${buildConnectStreamTwiml(callSid)}
|
|
132
|
+
</Response>`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function handleTwimlRequest(req, res) {
|
|
136
|
+
twimlLogger.info('Incoming TwiML request', {
|
|
137
|
+
event: 'twiml-request',
|
|
138
|
+
method: req.method,
|
|
139
|
+
path: req.url ? req.url.split('?')[0] : '',
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// eslint-disable-next-line no-useless-assignment -- assigned before first use inside try for readable error path
|
|
143
|
+
let rawBody = '';
|
|
144
|
+
try {
|
|
145
|
+
rawBody = await readRequestBody(req, 32 * 1024);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
twimlLogger.warn('TwiML body read failed', {
|
|
148
|
+
event: 'twiml-body-read-failed',
|
|
149
|
+
reason: 'invalid-request-body',
|
|
150
|
+
error: err,
|
|
151
|
+
});
|
|
152
|
+
res.writeHead(400);
|
|
153
|
+
res.end('Invalid request body');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!isValidTwilioRequest(req, rawBody)) {
|
|
158
|
+
twimlLogger.warn('TwiML request rejected', {
|
|
159
|
+
event: 'twiml-rejected',
|
|
160
|
+
reason: 'invalid-twilio-signature',
|
|
161
|
+
});
|
|
162
|
+
res.writeHead(403);
|
|
163
|
+
res.end('Forbidden');
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const formData = parseFormUrlEncoded(rawBody);
|
|
168
|
+
const callSid = normalizeCallSid(formData.CallSid);
|
|
169
|
+
const body = buildTwiml(callSid);
|
|
170
|
+
twimlLogger.info('TwiML response generated', {
|
|
171
|
+
event: 'twiml-response',
|
|
172
|
+
callSid: callSid || undefined,
|
|
173
|
+
});
|
|
174
|
+
res.writeHead(200, {
|
|
175
|
+
'Content-Type': 'text/xml',
|
|
176
|
+
'Content-Length': Buffer.byteLength(body),
|
|
177
|
+
});
|
|
178
|
+
res.end(body);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** @type {Map<string, (req: import('http').IncomingMessage, res: import('http').ServerResponse) => void>} */
|
|
182
|
+
const GET_ROUTES = new Map();
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* GET /ringtone
|
|
186
|
+
* UK-style ring tone WAV for debug/manual checks.
|
|
187
|
+
*/
|
|
188
|
+
GET_ROUTES.set('/ringtone', function (_req, res) {
|
|
189
|
+
res.writeHead(200, {
|
|
190
|
+
'Content-Type': 'audio/wav',
|
|
191
|
+
'Content-Length': RINGTONE_WAV.length,
|
|
192
|
+
'Cache-Control': 'no-store',
|
|
193
|
+
});
|
|
194
|
+
res.end(RINGTONE_WAV);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* GET /status — quick health/debug endpoint
|
|
199
|
+
* Returns the current active call info (callSid only, no credentials).
|
|
200
|
+
*/
|
|
201
|
+
GET_ROUTES.set('/status', function (req, res) {
|
|
202
|
+
if (!isAuthorizedForStatus(req)) {
|
|
203
|
+
res.writeHead(401);
|
|
204
|
+
res.end('Unauthorized');
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const body = JSON.stringify(state.getStatus());
|
|
208
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
209
|
+
res.end(body);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
GET_ROUTES.set('/healthz', function (_req, res) {
|
|
213
|
+
const body = JSON.stringify({ ok: true });
|
|
214
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
215
|
+
res.end(body);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
GET_ROUTES.set('/readyz', function (_req, res) {
|
|
219
|
+
const body = JSON.stringify({ ok: true });
|
|
220
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
221
|
+
res.end(body);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// WebSocket media stream
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
mediaws.on('request', function (request) {
|
|
229
|
+
/** @type {WebSocketRequest} */
|
|
230
|
+
const wsRequest = request;
|
|
231
|
+
if (shuttingDown) {
|
|
232
|
+
wsRequest.reject(503, 'Server shutting down');
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const path = wsRequest.resourceURL && wsRequest.resourceURL.pathname;
|
|
236
|
+
if (path !== STREAM_PATH) {
|
|
237
|
+
wsRequest.reject(404, 'Not found');
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
// Memory backstop only: cap total sockets so a connection flood can't grow
|
|
241
|
+
// unbounded. Only one Twilio call is ever authenticated at a time, and each
|
|
242
|
+
// un-started socket is dropped after config.streamStartTimeoutMs, so this is
|
|
243
|
+
// deliberately generous. Volumetric / per-source rate-limiting is delegated
|
|
244
|
+
// to the Cloudflare edge — the tunnel makes every socket look like localhost,
|
|
245
|
+
// so in-process source accounting is both unreliable and trivially bypassed
|
|
246
|
+
// by IP rotation.
|
|
247
|
+
if (activeWsConnections.size >= MAX_CONCURRENT_WS_CONNECTIONS) {
|
|
248
|
+
mediaWsLogger.warn('Media websocket connection rejected', {
|
|
249
|
+
event: 'media-ws-rejected',
|
|
250
|
+
reason: 'too-many-connections',
|
|
251
|
+
});
|
|
252
|
+
wsRequest.reject(503, 'Too many connections');
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const connection = wsRequest.accept(null, wsRequest.origin);
|
|
257
|
+
activeWsConnections.add(connection);
|
|
258
|
+
connection.on('close', () => activeWsConnections.delete(connection));
|
|
259
|
+
mediaWsLogger.info('Media websocket connection accepted', {
|
|
260
|
+
event: 'media-ws-accepted',
|
|
261
|
+
});
|
|
262
|
+
new MediaStream(connection, mediaStreamDeps);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
/** @type {import('./src/core/media-stream').MediaStreamDeps} */
|
|
266
|
+
const mediaStreamDeps = {
|
|
267
|
+
logger: mediaWsLogger,
|
|
268
|
+
state,
|
|
269
|
+
homekit,
|
|
270
|
+
registry: activeMediaStreamsByCallSid,
|
|
271
|
+
verifyStreamToken: verifyAndConsumeStreamToken,
|
|
272
|
+
tokenParameterName: STREAM_TOKEN_PARAMETER_NAME,
|
|
273
|
+
startTimeoutMs: STREAM_START_TIMEOUT_MS,
|
|
274
|
+
maxUtf8Bytes: MAX_WS_UTF8_BYTES,
|
|
275
|
+
mediaPayloadMaxBytes: config.twilioMediaPayloadMaxBytes,
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* @param {import('http').IncomingMessage} req
|
|
280
|
+
* @param {number} maxBytes
|
|
281
|
+
* @returns {Promise<string>}
|
|
282
|
+
*/
|
|
283
|
+
function readRequestBody(req, maxBytes) {
|
|
284
|
+
return new Promise((resolve, reject) => {
|
|
285
|
+
let total = 0;
|
|
286
|
+
const chunks = [];
|
|
287
|
+
req.on('data', (chunk) => {
|
|
288
|
+
total += chunk.length;
|
|
289
|
+
if (total > maxBytes) {
|
|
290
|
+
reject(new Error('Body too large'));
|
|
291
|
+
req.destroy();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
chunks.push(chunk);
|
|
295
|
+
});
|
|
296
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
297
|
+
req.on('error', reject);
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* @param {string} body
|
|
303
|
+
* @returns {Record<string, string | string[]>}
|
|
304
|
+
*/
|
|
305
|
+
function parseFormUrlEncoded(body) {
|
|
306
|
+
/** @type {Record<string, string | string[]>} */
|
|
307
|
+
const parsed = {};
|
|
308
|
+
const params = new URLSearchParams(body);
|
|
309
|
+
for (const [key, value] of params) {
|
|
310
|
+
if (Object.prototype.hasOwnProperty.call(parsed, key)) {
|
|
311
|
+
const existing = parsed[key];
|
|
312
|
+
parsed[key] = Array.isArray(existing) ? [...existing, value] : [existing, value];
|
|
313
|
+
} else {
|
|
314
|
+
parsed[key] = value;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return parsed;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* @param {import('http').IncomingMessage} req
|
|
322
|
+
* @param {string} rawBody
|
|
323
|
+
* @returns {boolean}
|
|
324
|
+
*/
|
|
325
|
+
function isValidTwilioRequest(req, rawBody) {
|
|
326
|
+
const signature = req.headers['x-twilio-signature'];
|
|
327
|
+
if (typeof signature !== 'string' || !signature) return false;
|
|
328
|
+
|
|
329
|
+
const url = new URL(req.url || '/twiml', config.twilioWebhookBaseUrl);
|
|
330
|
+
const requestUrl = `${config.twilioWebhookBaseUrl}${url.pathname}${url.search}`;
|
|
331
|
+
const params = parseFormUrlEncoded(rawBody);
|
|
332
|
+
|
|
333
|
+
return twilio.validateRequest(config.twilioAuthToken, signature, requestUrl, params);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* @param {import('http').IncomingMessage} req
|
|
338
|
+
* @returns {boolean}
|
|
339
|
+
*/
|
|
340
|
+
function isAuthorizedForStatus(req) {
|
|
341
|
+
const authHeader = req.headers.authorization;
|
|
342
|
+
if (typeof authHeader !== 'string' || !authHeader.startsWith(STATUS_BEARER_PREFIX)) {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
const provided = authHeader.slice(STATUS_BEARER_PREFIX.length);
|
|
346
|
+
return safeEqualString(provided, config.statusApiToken);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
// Start server
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
wsserver.listen(HTTP_SERVER_PORT, () => {
|
|
354
|
+
logger.info('Server listening', {
|
|
355
|
+
event: 'server-start',
|
|
356
|
+
port: HTTP_SERVER_PORT,
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
function beginShutdown(signal) {
|
|
361
|
+
if (shuttingDown) return;
|
|
362
|
+
shuttingDown = true;
|
|
363
|
+
const shutdownStartedAt = Date.now();
|
|
364
|
+
logger.info('Graceful shutdown started', {
|
|
365
|
+
event: 'shutdown-start',
|
|
366
|
+
reason: signal,
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
const forceExitTimer = setTimeout(() => {
|
|
370
|
+
logger.error('Shutdown grace period exceeded; forcing exit', {
|
|
371
|
+
event: 'shutdown-force-exit',
|
|
372
|
+
reason: 'grace-period-exceeded',
|
|
373
|
+
durationMs: SHUTDOWN_GRACE_MS,
|
|
374
|
+
});
|
|
375
|
+
process.exit(1);
|
|
376
|
+
}, SHUTDOWN_GRACE_MS);
|
|
377
|
+
if (typeof forceExitTimer.unref === 'function') forceExitTimer.unref();
|
|
378
|
+
|
|
379
|
+
state.clearActiveCall('server-shutdown');
|
|
380
|
+
homekit.shutdown();
|
|
381
|
+
|
|
382
|
+
for (const connection of activeWsConnections) {
|
|
383
|
+
try {
|
|
384
|
+
connection.close();
|
|
385
|
+
} catch {}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
wsserver.close(() => {
|
|
389
|
+
clearTimeout(forceExitTimer);
|
|
390
|
+
state.stop();
|
|
391
|
+
logger.info('Shutdown complete', {
|
|
392
|
+
event: 'shutdown-complete',
|
|
393
|
+
durationMs: Date.now() - shutdownStartedAt,
|
|
394
|
+
});
|
|
395
|
+
process.exit(0);
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
process.on('SIGINT', () => beginShutdown('SIGINT'));
|
|
400
|
+
process.on('SIGTERM', () => beginShutdown('SIGTERM'));
|
|
401
|
+
|
|
402
|
+
// Exit on unexpected errors instead of continuing in an undefined state;
|
|
403
|
+
// systemd restarts the service.
|
|
404
|
+
process.on('uncaughtException', (err) => {
|
|
405
|
+
logger.error('Uncaught exception; exiting', {
|
|
406
|
+
event: 'uncaught-exception',
|
|
407
|
+
reason: 'uncaught-exception',
|
|
408
|
+
error: err,
|
|
409
|
+
});
|
|
410
|
+
process.exit(1);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
process.on('unhandledRejection', (reason) => {
|
|
414
|
+
logger.error('Unhandled promise rejection; exiting', {
|
|
415
|
+
event: 'unhandled-rejection',
|
|
416
|
+
reason: 'unhandled-rejection',
|
|
417
|
+
error: reason instanceof Error ? reason : new Error(String(reason)),
|
|
418
|
+
});
|
|
419
|
+
process.exit(1);
|
|
420
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// DOTENV_CONFIG_PATH lets tests point at an empty file so the local .env
|
|
4
|
+
// cannot leak values into hermetic env-var assertions.
|
|
5
|
+
require('dotenv').config(
|
|
6
|
+
process.env.DOTENV_CONFIG_PATH ? { path: process.env.DOTENV_CONFIG_PATH } : undefined
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
function fail(message) {
|
|
10
|
+
throw new Error(`[config] ${message}`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function required(name) {
|
|
14
|
+
const value = process.env[name];
|
|
15
|
+
if (!value || !value.trim()) fail(`Missing required env var: ${name}`);
|
|
16
|
+
return value.trim();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function optional(name) {
|
|
20
|
+
const value = process.env[name];
|
|
21
|
+
return value && value.trim() ? value.trim() : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parsePositiveInt(name, fallback) {
|
|
25
|
+
const raw = process.env[name];
|
|
26
|
+
const value = raw && raw.trim() ? raw.trim() : fallback;
|
|
27
|
+
const parsed = Number.parseInt(value, 10);
|
|
28
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
29
|
+
fail(`${name} must be a positive integer`);
|
|
30
|
+
}
|
|
31
|
+
return parsed;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parsePort(name, fallback) {
|
|
35
|
+
const raw = process.env[name];
|
|
36
|
+
const value = raw && raw.trim() ? raw.trim() : fallback;
|
|
37
|
+
const port = Number.parseInt(value, 10);
|
|
38
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
39
|
+
fail(`${name} must be a valid TCP/UDP port`);
|
|
40
|
+
}
|
|
41
|
+
return port;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function validateRegex(name, value, regex, description) {
|
|
45
|
+
if (!regex.test(value)) {
|
|
46
|
+
fail(`${name} must match ${description}`);
|
|
47
|
+
}
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function sanitizeBaseUrl(input) {
|
|
52
|
+
let parsed;
|
|
53
|
+
try {
|
|
54
|
+
parsed = new URL(input);
|
|
55
|
+
} catch {
|
|
56
|
+
fail('TWILIO_WEBHOOK_BASE_URL must be a valid absolute URL');
|
|
57
|
+
}
|
|
58
|
+
if (!['https:', 'http:'].includes(parsed.protocol)) {
|
|
59
|
+
fail('TWILIO_WEBHOOK_BASE_URL must use http:// or https://');
|
|
60
|
+
}
|
|
61
|
+
parsed.pathname = '';
|
|
62
|
+
parsed.search = '';
|
|
63
|
+
parsed.hash = '';
|
|
64
|
+
return parsed.toString().replace(/\/$/, '');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const tunnelHostname = validateRegex(
|
|
68
|
+
'TUNNEL_HOSTNAME',
|
|
69
|
+
required('TUNNEL_HOSTNAME'),
|
|
70
|
+
/^(?=.{1,253}$)(?!-)[a-zA-Z0-9-]{1,63}(?<!-)(\.(?!-)[a-zA-Z0-9-]{1,63}(?<!-))*$/,
|
|
71
|
+
'a valid hostname'
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const streamAuthSecret = required('STREAM_AUTH_SECRET');
|
|
75
|
+
if (streamAuthSecret.length < 32) {
|
|
76
|
+
fail('STREAM_AUTH_SECRET must be at least 32 characters');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const statusApiToken = required('STATUS_API_TOKEN');
|
|
80
|
+
if (statusApiToken.length < 16) {
|
|
81
|
+
fail('STATUS_API_TOKEN must be at least 16 characters');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const twilioUnlockDigits = optional('TWILIO_UNLOCK_DIGITS') || 'w9w';
|
|
85
|
+
if (!/^[0-9A-Da-d*#wW]+$/.test(twilioUnlockDigits)) {
|
|
86
|
+
fail('TWILIO_UNLOCK_DIGITS may only contain DTMF digits 0-9, A-D, *, #, w, and W');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const twilioWebhookBaseUrl = sanitizeBaseUrl(
|
|
90
|
+
optional('TWILIO_WEBHOOK_BASE_URL') || `https://${tunnelHostname}`
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
module.exports = {
|
|
94
|
+
port: parsePort('PORT', '8080'),
|
|
95
|
+
tunnelHostname,
|
|
96
|
+
twilioWebhookBaseUrl,
|
|
97
|
+
twilioAccountSid: validateRegex(
|
|
98
|
+
'TWILIO_ACCOUNT_SID',
|
|
99
|
+
required('TWILIO_ACCOUNT_SID'),
|
|
100
|
+
/^AC[a-zA-Z0-9]{32}$/,
|
|
101
|
+
'AC followed by 32 alphanumeric chars'
|
|
102
|
+
),
|
|
103
|
+
twilioAuthToken: required('TWILIO_AUTH_TOKEN'),
|
|
104
|
+
twilioUnlockDigits,
|
|
105
|
+
twilioPhoneNumber: validateRegex(
|
|
106
|
+
'TWILIO_PHONE_NUMBER',
|
|
107
|
+
required('TWILIO_PHONE_NUMBER'),
|
|
108
|
+
/^\+[1-9]\d{1,14}$/,
|
|
109
|
+
'E.164 format (e.g. +15551234567)'
|
|
110
|
+
),
|
|
111
|
+
hapUsername: validateRegex(
|
|
112
|
+
'HAP_USERNAME',
|
|
113
|
+
required('HAP_USERNAME'),
|
|
114
|
+
/^[0-9A-F]{2}(:[0-9A-F]{2}){5}$/,
|
|
115
|
+
'MAC-style uppercase hex (AA:BB:CC:DD:EE:FF)'
|
|
116
|
+
),
|
|
117
|
+
hapPincode: validateRegex(
|
|
118
|
+
'HAP_PINCODE',
|
|
119
|
+
required('HAP_PINCODE'),
|
|
120
|
+
/^\d{3}-\d{2}-\d{3}$/,
|
|
121
|
+
'XXX-XX-XXX'
|
|
122
|
+
),
|
|
123
|
+
hapPort: parsePort('HAP_PORT', '47129'),
|
|
124
|
+
callSessionStaleSec: parsePositiveInt('CALL_SESSION_STALE_SEC', '900'),
|
|
125
|
+
wsMaxMessageBytes: parsePositiveInt('WS_MAX_MESSAGE_BYTES', '4096'),
|
|
126
|
+
wsMaxConnections: parsePositiveInt('WS_MAX_CONNECTIONS', '64'),
|
|
127
|
+
streamStartTimeoutMs: parsePositiveInt('STREAM_START_TIMEOUT_MS', '3000'),
|
|
128
|
+
twilioMediaPayloadMaxBytes: parsePositiveInt('TWILIO_MEDIA_PAYLOAD_MAX_BYTES', '512'),
|
|
129
|
+
shutdownGraceMs: parsePositiveInt('SHUTDOWN_GRACE_MS', '10000'),
|
|
130
|
+
streamAuthSecret,
|
|
131
|
+
streamAuthTtlSec: 90,
|
|
132
|
+
statusApiToken,
|
|
133
|
+
};
|