@openclaw/voice-call 2026.3.1 → 2026.3.2
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/CHANGELOG.md +6 -0
- package/index.ts +27 -13
- package/package.json +1 -1
- package/src/cli.ts +1 -1
- package/src/manager/events.test.ts +75 -0
- package/src/manager/events.ts +25 -9
- package/src/manager.closed-loop.test.ts +218 -0
- package/src/manager.inbound-allowlist.test.ts +121 -0
- package/src/manager.notify.test.ts +53 -0
- package/src/manager.restore.test.ts +130 -0
- package/src/manager.test-harness.ts +125 -0
- package/src/manager.ts +119 -10
- package/src/providers/base.ts +10 -0
- package/src/providers/mock.ts +10 -0
- package/src/providers/plivo.ts +37 -0
- package/src/providers/shared/call-status.test.ts +24 -0
- package/src/providers/shared/call-status.ts +23 -0
- package/src/providers/telnyx.ts +33 -0
- package/src/providers/twilio/twiml-policy.test.ts +84 -0
- package/src/providers/twilio/twiml-policy.ts +91 -0
- package/src/providers/twilio.test.ts +70 -0
- package/src/providers/twilio.ts +94 -73
- package/src/runtime.test.ts +147 -0
- package/src/runtime.ts +123 -76
- package/src/tunnel.ts +1 -1
- package/src/types.ts +17 -0
- package/src/webhook/tailscale.ts +115 -0
- package/src/webhook-security.test.ts +42 -13
- package/src/webhook-security.ts +74 -0
- package/src/webhook.test.ts +105 -36
- package/src/webhook.ts +104 -171
- package/src/manager.test.ts +0 -467
package/src/runtime.ts
CHANGED
|
@@ -10,11 +10,8 @@ import { TwilioProvider } from "./providers/twilio.js";
|
|
|
10
10
|
import type { TelephonyTtsRuntime } from "./telephony-tts.js";
|
|
11
11
|
import { createTelephonyTtsProvider } from "./telephony-tts.js";
|
|
12
12
|
import { startTunnel, type TunnelResult } from "./tunnel.js";
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
setupTailscaleExposure,
|
|
16
|
-
VoiceCallWebhookServer,
|
|
17
|
-
} from "./webhook.js";
|
|
13
|
+
import { VoiceCallWebhookServer } from "./webhook.js";
|
|
14
|
+
import { cleanupTailscaleExposure, setupTailscaleExposure } from "./webhook/tailscale.js";
|
|
18
15
|
|
|
19
16
|
export type VoiceCallRuntime = {
|
|
20
17
|
config: VoiceCallConfig;
|
|
@@ -33,6 +30,49 @@ type Logger = {
|
|
|
33
30
|
debug?: (message: string) => void;
|
|
34
31
|
};
|
|
35
32
|
|
|
33
|
+
function createRuntimeResourceLifecycle(params: {
|
|
34
|
+
config: VoiceCallConfig;
|
|
35
|
+
webhookServer: VoiceCallWebhookServer;
|
|
36
|
+
}): {
|
|
37
|
+
setTunnelResult: (result: TunnelResult | null) => void;
|
|
38
|
+
stop: (opts?: { suppressErrors?: boolean }) => Promise<void>;
|
|
39
|
+
} {
|
|
40
|
+
let tunnelResult: TunnelResult | null = null;
|
|
41
|
+
let stopped = false;
|
|
42
|
+
|
|
43
|
+
const runStep = async (step: () => Promise<void>, suppressErrors: boolean) => {
|
|
44
|
+
if (suppressErrors) {
|
|
45
|
+
await step().catch(() => {});
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
await step();
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
setTunnelResult: (result) => {
|
|
53
|
+
tunnelResult = result;
|
|
54
|
+
},
|
|
55
|
+
stop: async (opts) => {
|
|
56
|
+
if (stopped) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
stopped = true;
|
|
60
|
+
const suppressErrors = opts?.suppressErrors ?? false;
|
|
61
|
+
await runStep(async () => {
|
|
62
|
+
if (tunnelResult) {
|
|
63
|
+
await tunnelResult.stop();
|
|
64
|
+
}
|
|
65
|
+
}, suppressErrors);
|
|
66
|
+
await runStep(async () => {
|
|
67
|
+
await cleanupTailscaleExposure(params.config);
|
|
68
|
+
}, suppressErrors);
|
|
69
|
+
await runStep(async () => {
|
|
70
|
+
await params.webhookServer.stop();
|
|
71
|
+
}, suppressErrors);
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
36
76
|
function isLoopbackBind(bind: string | undefined): boolean {
|
|
37
77
|
if (!bind) {
|
|
38
78
|
return false;
|
|
@@ -126,92 +166,99 @@ export async function createVoiceCallRuntime(params: {
|
|
|
126
166
|
const provider = resolveProvider(config);
|
|
127
167
|
const manager = new CallManager(config);
|
|
128
168
|
const webhookServer = new VoiceCallWebhookServer(config, manager, provider, coreConfig);
|
|
169
|
+
const lifecycle = createRuntimeResourceLifecycle({ config, webhookServer });
|
|
129
170
|
|
|
130
171
|
const localUrl = await webhookServer.start();
|
|
131
172
|
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
provider: config.tunnel.provider,
|
|
140
|
-
port: config.serve.port,
|
|
141
|
-
path: config.serve.path,
|
|
142
|
-
ngrokAuthToken: config.tunnel.ngrokAuthToken,
|
|
143
|
-
ngrokDomain: config.tunnel.ngrokDomain,
|
|
144
|
-
});
|
|
145
|
-
publicUrl = tunnelResult?.publicUrl ?? null;
|
|
146
|
-
} catch (err) {
|
|
147
|
-
log.error(
|
|
148
|
-
`[voice-call] Tunnel setup failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
149
|
-
);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
if (!publicUrl && config.tailscale?.mode !== "off") {
|
|
154
|
-
publicUrl = await setupTailscaleExposure(config);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const webhookUrl = publicUrl ?? localUrl;
|
|
173
|
+
// Wrap remaining initialization in try/catch so the webhook server is
|
|
174
|
+
// properly stopped if any subsequent step fails. Without this, the server
|
|
175
|
+
// keeps the port bound while the runtime promise rejects, causing
|
|
176
|
+
// EADDRINUSE on the next attempt. See: #32387
|
|
177
|
+
try {
|
|
178
|
+
// Determine public URL - priority: config.publicUrl > tunnel > legacy tailscale
|
|
179
|
+
let publicUrl: string | null = config.publicUrl ?? null;
|
|
158
180
|
|
|
159
|
-
|
|
160
|
-
(provider as TwilioProvider).setPublicUrl(publicUrl);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
if (provider.name === "twilio" && config.streaming?.enabled) {
|
|
164
|
-
const twilioProvider = provider as TwilioProvider;
|
|
165
|
-
if (ttsRuntime?.textToSpeechTelephony) {
|
|
181
|
+
if (!publicUrl && config.tunnel?.provider && config.tunnel.provider !== "none") {
|
|
166
182
|
try {
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
183
|
+
const nextTunnelResult = await startTunnel({
|
|
184
|
+
provider: config.tunnel.provider,
|
|
185
|
+
port: config.serve.port,
|
|
186
|
+
path: config.serve.path,
|
|
187
|
+
ngrokAuthToken: config.tunnel.ngrokAuthToken,
|
|
188
|
+
ngrokDomain: config.tunnel.ngrokDomain,
|
|
171
189
|
});
|
|
172
|
-
|
|
173
|
-
|
|
190
|
+
lifecycle.setTunnelResult(nextTunnelResult);
|
|
191
|
+
publicUrl = nextTunnelResult?.publicUrl ?? null;
|
|
174
192
|
} catch (err) {
|
|
175
|
-
log.
|
|
176
|
-
`[voice-call]
|
|
177
|
-
err instanceof Error ? err.message : String(err)
|
|
178
|
-
}`,
|
|
193
|
+
log.error(
|
|
194
|
+
`[voice-call] Tunnel setup failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
179
195
|
);
|
|
180
196
|
}
|
|
181
|
-
} else {
|
|
182
|
-
log.warn("[voice-call] Telephony TTS unavailable; streaming TTS disabled");
|
|
183
197
|
}
|
|
184
198
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
twilioProvider.setMediaStreamHandler(mediaHandler);
|
|
188
|
-
log.info("[voice-call] Media stream handler wired to provider");
|
|
199
|
+
if (!publicUrl && config.tailscale?.mode !== "off") {
|
|
200
|
+
publicUrl = await setupTailscaleExposure(config);
|
|
189
201
|
}
|
|
190
|
-
}
|
|
191
202
|
|
|
192
|
-
|
|
203
|
+
const webhookUrl = publicUrl ?? localUrl;
|
|
193
204
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
await tunnelResult.stop();
|
|
205
|
+
if (publicUrl && provider.name === "twilio") {
|
|
206
|
+
(provider as TwilioProvider).setPublicUrl(publicUrl);
|
|
197
207
|
}
|
|
198
|
-
await cleanupTailscaleExposure(config);
|
|
199
|
-
await webhookServer.stop();
|
|
200
|
-
};
|
|
201
208
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
209
|
+
if (provider.name === "twilio" && config.streaming?.enabled) {
|
|
210
|
+
const twilioProvider = provider as TwilioProvider;
|
|
211
|
+
if (ttsRuntime?.textToSpeechTelephony) {
|
|
212
|
+
try {
|
|
213
|
+
const ttsProvider = createTelephonyTtsProvider({
|
|
214
|
+
coreConfig,
|
|
215
|
+
ttsOverride: config.tts,
|
|
216
|
+
runtime: ttsRuntime,
|
|
217
|
+
});
|
|
218
|
+
twilioProvider.setTTSProvider(ttsProvider);
|
|
219
|
+
log.info("[voice-call] Telephony TTS provider configured");
|
|
220
|
+
} catch (err) {
|
|
221
|
+
log.warn(
|
|
222
|
+
`[voice-call] Failed to initialize telephony TTS: ${
|
|
223
|
+
err instanceof Error ? err.message : String(err)
|
|
224
|
+
}`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
} else {
|
|
228
|
+
log.warn("[voice-call] Telephony TTS unavailable; streaming TTS disabled");
|
|
229
|
+
}
|
|
207
230
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
231
|
+
const mediaHandler = webhookServer.getMediaStreamHandler();
|
|
232
|
+
if (mediaHandler) {
|
|
233
|
+
twilioProvider.setMediaStreamHandler(mediaHandler);
|
|
234
|
+
log.info("[voice-call] Media stream handler wired to provider");
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
await manager.initialize(provider, webhookUrl);
|
|
239
|
+
|
|
240
|
+
const stop = async () => await lifecycle.stop();
|
|
241
|
+
|
|
242
|
+
log.info("[voice-call] Runtime initialized");
|
|
243
|
+
log.info(`[voice-call] Webhook URL: ${webhookUrl}`);
|
|
244
|
+
if (publicUrl) {
|
|
245
|
+
log.info(`[voice-call] Public URL: ${publicUrl}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
config,
|
|
250
|
+
provider,
|
|
251
|
+
manager,
|
|
252
|
+
webhookServer,
|
|
253
|
+
webhookUrl,
|
|
254
|
+
publicUrl,
|
|
255
|
+
stop,
|
|
256
|
+
};
|
|
257
|
+
} catch (err) {
|
|
258
|
+
// If any step after the server started fails, clean up every provisioned
|
|
259
|
+
// resource (tunnel, tailscale exposure, and webhook server) so retries
|
|
260
|
+
// don't leak processes or keep the port bound.
|
|
261
|
+
await lifecycle.stop({ suppressErrors: true });
|
|
262
|
+
throw err;
|
|
263
|
+
}
|
|
217
264
|
}
|
package/src/tunnel.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -248,6 +248,23 @@ export type StopListeningInput = {
|
|
|
248
248
|
providerCallId: ProviderCallId;
|
|
249
249
|
};
|
|
250
250
|
|
|
251
|
+
// -----------------------------------------------------------------------------
|
|
252
|
+
// Call Status Verification (used on restart to verify persisted calls)
|
|
253
|
+
// -----------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
export type GetCallStatusInput = {
|
|
256
|
+
providerCallId: ProviderCallId;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
export type GetCallStatusResult = {
|
|
260
|
+
/** Provider-specific status string (e.g. "completed", "in-progress") */
|
|
261
|
+
status: string;
|
|
262
|
+
/** True when the provider confirms the call has ended */
|
|
263
|
+
isTerminal: boolean;
|
|
264
|
+
/** True when the status could not be determined (transient error) */
|
|
265
|
+
isUnknown?: boolean;
|
|
266
|
+
};
|
|
267
|
+
|
|
251
268
|
// -----------------------------------------------------------------------------
|
|
252
269
|
// Outbound Call Options
|
|
253
270
|
// -----------------------------------------------------------------------------
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import type { VoiceCallConfig } from "../config.js";
|
|
3
|
+
|
|
4
|
+
export type TailscaleSelfInfo = {
|
|
5
|
+
dnsName: string | null;
|
|
6
|
+
nodeId: string | null;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
function runTailscaleCommand(
|
|
10
|
+
args: string[],
|
|
11
|
+
timeoutMs = 2500,
|
|
12
|
+
): Promise<{ code: number; stdout: string }> {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
const proc = spawn("tailscale", args, {
|
|
15
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
let stdout = "";
|
|
19
|
+
proc.stdout.on("data", (data) => {
|
|
20
|
+
stdout += data;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const timer = setTimeout(() => {
|
|
24
|
+
proc.kill("SIGKILL");
|
|
25
|
+
resolve({ code: -1, stdout: "" });
|
|
26
|
+
}, timeoutMs);
|
|
27
|
+
|
|
28
|
+
proc.on("close", (code) => {
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
resolve({ code: code ?? -1, stdout });
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function getTailscaleSelfInfo(): Promise<TailscaleSelfInfo | null> {
|
|
36
|
+
const { code, stdout } = await runTailscaleCommand(["status", "--json"]);
|
|
37
|
+
if (code !== 0) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const status = JSON.parse(stdout);
|
|
43
|
+
return {
|
|
44
|
+
dnsName: status.Self?.DNSName?.replace(/\.$/, "") || null,
|
|
45
|
+
nodeId: status.Self?.ID || null,
|
|
46
|
+
};
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function getTailscaleDnsName(): Promise<string | null> {
|
|
53
|
+
const info = await getTailscaleSelfInfo();
|
|
54
|
+
return info?.dnsName ?? null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function setupTailscaleExposureRoute(opts: {
|
|
58
|
+
mode: "serve" | "funnel";
|
|
59
|
+
path: string;
|
|
60
|
+
localUrl: string;
|
|
61
|
+
}): Promise<string | null> {
|
|
62
|
+
const dnsName = await getTailscaleDnsName();
|
|
63
|
+
if (!dnsName) {
|
|
64
|
+
console.warn("[voice-call] Could not get Tailscale DNS name");
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const { code } = await runTailscaleCommand([
|
|
69
|
+
opts.mode,
|
|
70
|
+
"--bg",
|
|
71
|
+
"--yes",
|
|
72
|
+
"--set-path",
|
|
73
|
+
opts.path,
|
|
74
|
+
opts.localUrl,
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
if (code === 0) {
|
|
78
|
+
const publicUrl = `https://${dnsName}${opts.path}`;
|
|
79
|
+
console.log(`[voice-call] Tailscale ${opts.mode} active: ${publicUrl}`);
|
|
80
|
+
return publicUrl;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
console.warn(`[voice-call] Tailscale ${opts.mode} failed`);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function cleanupTailscaleExposureRoute(opts: {
|
|
88
|
+
mode: "serve" | "funnel";
|
|
89
|
+
path: string;
|
|
90
|
+
}): Promise<void> {
|
|
91
|
+
await runTailscaleCommand([opts.mode, "off", opts.path]);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function setupTailscaleExposure(config: VoiceCallConfig): Promise<string | null> {
|
|
95
|
+
if (config.tailscale.mode === "off") {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const mode = config.tailscale.mode === "funnel" ? "funnel" : "serve";
|
|
100
|
+
const localUrl = `http://127.0.0.1:${config.serve.port}${config.serve.path}`;
|
|
101
|
+
return setupTailscaleExposureRoute({
|
|
102
|
+
mode,
|
|
103
|
+
path: config.tailscale.path,
|
|
104
|
+
localUrl,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function cleanupTailscaleExposure(config: VoiceCallConfig): Promise<void> {
|
|
109
|
+
if (config.tailscale.mode === "off") {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const mode = config.tailscale.mode === "funnel" ? "funnel" : "serve";
|
|
114
|
+
await cleanupTailscaleExposureRoute({ mode, path: config.tailscale.path });
|
|
115
|
+
}
|
|
@@ -86,6 +86,18 @@ function twilioSignature(params: { authToken: string; url: string; postBody: str
|
|
|
86
86
|
return crypto.createHmac("sha1", params.authToken).update(dataToSign).digest("base64");
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
function expectReplayResultPair(
|
|
90
|
+
first: { ok: boolean; isReplay?: boolean; verifiedRequestKey?: string },
|
|
91
|
+
second: { ok: boolean; isReplay?: boolean; verifiedRequestKey?: string },
|
|
92
|
+
) {
|
|
93
|
+
expect(first.ok).toBe(true);
|
|
94
|
+
expect(first.isReplay).toBeFalsy();
|
|
95
|
+
expect(first.verifiedRequestKey).toBeTruthy();
|
|
96
|
+
expect(second.ok).toBe(true);
|
|
97
|
+
expect(second.isReplay).toBe(true);
|
|
98
|
+
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
|
|
99
|
+
}
|
|
100
|
+
|
|
89
101
|
describe("verifyPlivoWebhook", () => {
|
|
90
102
|
it("accepts valid V2 signature", () => {
|
|
91
103
|
const authToken = "test-auth-token";
|
|
@@ -196,12 +208,7 @@ describe("verifyPlivoWebhook", () => {
|
|
|
196
208
|
const first = verifyPlivoWebhook(ctx, authToken);
|
|
197
209
|
const second = verifyPlivoWebhook(ctx, authToken);
|
|
198
210
|
|
|
199
|
-
|
|
200
|
-
expect(first.isReplay).toBeFalsy();
|
|
201
|
-
expect(first.verifiedRequestKey).toBeTruthy();
|
|
202
|
-
expect(second.ok).toBe(true);
|
|
203
|
-
expect(second.isReplay).toBe(true);
|
|
204
|
-
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
|
|
211
|
+
expectReplayResultPair(first, second);
|
|
205
212
|
});
|
|
206
213
|
|
|
207
214
|
it("returns a stable request key when verification is skipped", () => {
|
|
@@ -245,12 +252,7 @@ describe("verifyTelnyxWebhook", () => {
|
|
|
245
252
|
const first = verifyTelnyxWebhook(ctx, pemPublicKey);
|
|
246
253
|
const second = verifyTelnyxWebhook(ctx, pemPublicKey);
|
|
247
254
|
|
|
248
|
-
|
|
249
|
-
expect(first.isReplay).toBeFalsy();
|
|
250
|
-
expect(first.verifiedRequestKey).toBeTruthy();
|
|
251
|
-
expect(second.ok).toBe(true);
|
|
252
|
-
expect(second.isReplay).toBe(true);
|
|
253
|
-
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
|
|
255
|
+
expectReplayResultPair(first, second);
|
|
254
256
|
});
|
|
255
257
|
|
|
256
258
|
it("returns a stable request key when verification is skipped", () => {
|
|
@@ -603,7 +605,6 @@ describe("verifyTwilioWebhook", () => {
|
|
|
603
605
|
expect(result.ok).toBe(false);
|
|
604
606
|
expect(result.verificationUrl).toBe("https://legitimate.example.com/voice/webhook");
|
|
605
607
|
});
|
|
606
|
-
|
|
607
608
|
it("returns a stable request key when verification is skipped", () => {
|
|
608
609
|
const ctx = {
|
|
609
610
|
headers: {},
|
|
@@ -619,4 +620,32 @@ describe("verifyTwilioWebhook", () => {
|
|
|
619
620
|
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
|
|
620
621
|
expect(second.isReplay).toBe(true);
|
|
621
622
|
});
|
|
623
|
+
|
|
624
|
+
it("succeeds when Twilio signs URL without port but server URL has port", () => {
|
|
625
|
+
const authToken = "test-auth-token";
|
|
626
|
+
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
|
|
627
|
+
// Twilio signs using URL without port.
|
|
628
|
+
const urlWithPort = "https://example.com:8443/voice/webhook";
|
|
629
|
+
const signedUrl = "https://example.com/voice/webhook";
|
|
630
|
+
|
|
631
|
+
const signature = twilioSignature({ authToken, url: signedUrl, postBody });
|
|
632
|
+
|
|
633
|
+
const result = verifyTwilioWebhook(
|
|
634
|
+
{
|
|
635
|
+
headers: {
|
|
636
|
+
host: "example.com:8443",
|
|
637
|
+
"x-twilio-signature": signature,
|
|
638
|
+
},
|
|
639
|
+
rawBody: postBody,
|
|
640
|
+
url: urlWithPort,
|
|
641
|
+
method: "POST",
|
|
642
|
+
},
|
|
643
|
+
authToken,
|
|
644
|
+
{ publicUrl: urlWithPort },
|
|
645
|
+
);
|
|
646
|
+
|
|
647
|
+
expect(result.ok).toBe(true);
|
|
648
|
+
expect(result.verificationUrl).toBe(signedUrl);
|
|
649
|
+
expect(result.verifiedRequestKey).toMatch(/^twilio:req:/);
|
|
650
|
+
});
|
|
622
651
|
});
|
package/src/webhook-security.ts
CHANGED
|
@@ -379,6 +379,41 @@ function isLoopbackAddress(address?: string): boolean {
|
|
|
379
379
|
return false;
|
|
380
380
|
}
|
|
381
381
|
|
|
382
|
+
function stripPortFromUrl(url: string): string {
|
|
383
|
+
try {
|
|
384
|
+
const parsed = new URL(url);
|
|
385
|
+
if (!parsed.port) {
|
|
386
|
+
return url;
|
|
387
|
+
}
|
|
388
|
+
parsed.port = "";
|
|
389
|
+
return parsed.toString();
|
|
390
|
+
} catch {
|
|
391
|
+
return url;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function setPortOnUrl(url: string, port: string): string {
|
|
396
|
+
try {
|
|
397
|
+
const parsed = new URL(url);
|
|
398
|
+
parsed.port = port;
|
|
399
|
+
return parsed.toString();
|
|
400
|
+
} catch {
|
|
401
|
+
return url;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function extractPortFromHostHeader(hostHeader?: string): string | undefined {
|
|
406
|
+
if (!hostHeader) {
|
|
407
|
+
return undefined;
|
|
408
|
+
}
|
|
409
|
+
try {
|
|
410
|
+
const parsed = new URL(`https://${hostHeader}`);
|
|
411
|
+
return parsed.port || undefined;
|
|
412
|
+
} catch {
|
|
413
|
+
return undefined;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
382
417
|
/**
|
|
383
418
|
* Result of Twilio webhook verification with detailed info.
|
|
384
419
|
*/
|
|
@@ -609,6 +644,45 @@ export function verifyTwilioWebhook(
|
|
|
609
644
|
return { ok: true, verificationUrl, isReplay, verifiedRequestKey: replayKey };
|
|
610
645
|
}
|
|
611
646
|
|
|
647
|
+
// Twilio webhook signatures can differ in whether port is included.
|
|
648
|
+
// Retry a small, deterministic set of URL variants before failing closed.
|
|
649
|
+
const variants = new Set<string>();
|
|
650
|
+
variants.add(verificationUrl);
|
|
651
|
+
variants.add(stripPortFromUrl(verificationUrl));
|
|
652
|
+
|
|
653
|
+
if (options?.publicUrl) {
|
|
654
|
+
try {
|
|
655
|
+
const publicPort = new URL(options.publicUrl).port;
|
|
656
|
+
if (publicPort) {
|
|
657
|
+
variants.add(setPortOnUrl(verificationUrl, publicPort));
|
|
658
|
+
}
|
|
659
|
+
} catch {
|
|
660
|
+
// ignore invalid publicUrl; primary verification already used best effort
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const hostHeaderPort = extractPortFromHostHeader(getHeader(ctx.headers, "host"));
|
|
665
|
+
if (hostHeaderPort) {
|
|
666
|
+
variants.add(setPortOnUrl(verificationUrl, hostHeaderPort));
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
for (const candidateUrl of variants) {
|
|
670
|
+
if (candidateUrl === verificationUrl) {
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
const isValidCandidate = validateTwilioSignature(authToken, signature, candidateUrl, params);
|
|
674
|
+
if (!isValidCandidate) {
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
const replayKey = createTwilioReplayKey({
|
|
678
|
+
verificationUrl: candidateUrl,
|
|
679
|
+
signature,
|
|
680
|
+
requestParams: params,
|
|
681
|
+
});
|
|
682
|
+
const isReplay = markReplay(twilioReplayCache, replayKey);
|
|
683
|
+
return { ok: true, verificationUrl: candidateUrl, isReplay, verifiedRequestKey: replayKey };
|
|
684
|
+
}
|
|
685
|
+
|
|
612
686
|
// Check if this is ngrok free tier - the URL might have different format
|
|
613
687
|
const isNgrokFreeTier =
|
|
614
688
|
verificationUrl.includes(".ngrok-free.app") || verificationUrl.includes(".ngrok.io");
|