integrations: webhooks and API tokens
API tokens (Settings -> API tokens): Authorization: Bearer sp_<40 base62>, stored as sha256, shown once. A token acts as its admin with their current role and stops when they're disabled; Bearer requests skip the CSRF header and never fall back to the cookie. A token can't manage tokens. Webhooks (Settings -> Webhooks): call.started/answered/ended, voicemail.new, emergency.call, trunk/extension registration changes, from the live state; queued per webhook, sent every 10 s with X-SelfPhoneNG-Signature (HMAC of timestamp.body), retried after 1 m, 5 m, 30 m, 2 h, 12 h, then failed. Private/loopback targets are refused in the connection's own DNS lookup (no rebinding), unless allowed. Send test and a delivery log per webhook. The e2e found voicemail.new missing for mailboxes without a user: the voicemail scan now queues it for every mailbox. vmpin.test.ts gets 180 s: Git Bash forks make it ~85 s on Windows (the earlier flaky gate run). Task: M11-T5
This commit is contained in:
1 parent
d229ed2c45
commit
60846afddd
17 files changed
+1319
-8
No files matched your search
+5
-2
@@ -36,6 +36,7 @@ import { blockedRoutes } from './routes/blocked.ts';
|
||||
import { callRoutes } from './routes/calls.ts';
|
||||
import { recordingRoutes } from './routes/recordings.ts';
|
||||
import { liveCallRoutes } from './routes/livecalls.ts';
|
||||
import { integrationRoutes } from './routes/integrations.ts';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
@@ -78,12 +79,13 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
|
||||
app.setErrorHandler(errorHandler);
|
||||
|
||||
// CSRF (04 §2.2): browsers can't add this header cross-site without CORS, and CORS is off.
|
||||
// ponytail: required on every unsafe /api request, not only cookie-authenticated ones; the
|
||||
// Bearer-token exemption arrives with API tokens in M11.
|
||||
// API-token requests (Authorization: Bearer) don't need it: requireAuth then ignores the cookie,
|
||||
// and a cross-site page can't set Authorization either.
|
||||
app.addHook('onRequest', async (req, reply) => {
|
||||
if (
|
||||
req.url.startsWith('/api/') &&
|
||||
!SAFE_METHODS.has(req.method) &&
|
||||
!req.headers.authorization?.startsWith('Bearer ') &&
|
||||
req.headers['x-requested-with'] !== 'selfphoneng'
|
||||
)
|
||||
return sendError(reply, forbidden('Missing X-Requested-With header', 'csrf'));
|
||||
@@ -144,6 +146,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
|
||||
app.register(callRoutes);
|
||||
app.register(recordingRoutes);
|
||||
app.register(liveCallRoutes);
|
||||
app.register(integrationRoutes);
|
||||
|
||||
// Always registered: reply.sendFile also streams voicemail audio (with Range) from the spool.
|
||||
app.register(fastifyStatic, {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import type { users } from '../db/schema.ts';
|
||||
import { forbidden, unauthorized } from '../errors.ts';
|
||||
import { COOKIE, getSessionUser, setSessionCookie } from './sessions.ts';
|
||||
import { getTokenUser } from './tokens.ts';
|
||||
|
||||
export type CurrentUser = typeof users.$inferSelect;
|
||||
|
||||
@@ -11,11 +12,27 @@ declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
user: CurrentUser | null;
|
||||
sessionId: string | null;
|
||||
/** Set when the request came with an API token instead of a session. */
|
||||
apiTokenId?: string;
|
||||
}
|
||||
}
|
||||
|
||||
/** Sets req.user from the session cookie. Enforces must_change_password outside /api/auth/*. */
|
||||
/**
|
||||
* Sets req.user from an API token (Authorization: Bearer, 04 §2.3) or else the session cookie.
|
||||
* Enforces must_change_password outside /api/auth/*.
|
||||
*/
|
||||
export async function requireAuth(req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
const auth = req.headers.authorization;
|
||||
if (auth?.startsWith('Bearer ')) {
|
||||
const t = await getTokenUser(req.server.db, auth.slice(7).trim());
|
||||
if (!t) throw unauthorized();
|
||||
req.user = t.user;
|
||||
req.sessionId = null;
|
||||
req.apiTokenId = t.tokenId;
|
||||
if (t.user.mustChangePassword)
|
||||
throw forbidden('Change your password first', 'password_change_required');
|
||||
return;
|
||||
}
|
||||
const token = req.cookies[COOKIE];
|
||||
const found = token ? await getSessionUser(req.server.db, token) : null;
|
||||
if (!found) throw unauthorized();
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// API tokens (04 §2.3): `Authorization: Bearer sp_<40 base62>`, stored as sha256, acting as the
|
||||
// admin who made them.
|
||||
import { createHash, randomInt } from 'node:crypto';
|
||||
import { and, eq, isNull } from 'drizzle-orm';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { apiTokens, users } from '../db/schema.ts';
|
||||
|
||||
const BASE62 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
export const TOKEN_RE = /^sp_[A-Za-z0-9]{40}$/;
|
||||
export const hashToken = (t: string) => createHash('sha256').update(t).digest('hex');
|
||||
|
||||
export function generateToken(): string {
|
||||
let s = 'sp_';
|
||||
for (let i = 0; i < 40; i++) s += BASE62[randomInt(62)];
|
||||
return s;
|
||||
}
|
||||
|
||||
/** The token's (active) user, or null for an unknown, revoked or malformed token. */
|
||||
export async function getTokenUser(db: Db, token: string) {
|
||||
if (!TOKEN_RE.test(token)) return null;
|
||||
const [row] = await db
|
||||
.select({ id: apiTokens.id, user: users })
|
||||
.from(apiTokens)
|
||||
.innerJoin(users, eq(users.id, apiTokens.createdBy))
|
||||
.where(
|
||||
and(
|
||||
eq(apiTokens.tokenHash, hashToken(token)),
|
||||
isNull(apiTokens.revokedAt),
|
||||
eq(users.isActive, true),
|
||||
),
|
||||
);
|
||||
if (!row) return null;
|
||||
await db.update(apiTokens).set({ lastUsedAt: new Date() }).where(eq(apiTokens.id, row.id));
|
||||
return { user: row.user, tokenId: row.id };
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { watchCdr } from './services/cdr.ts';
|
||||
import { watchQueueCalls } from './services/queuestats.ts';
|
||||
import { watchPinChanges } from './services/vmpin.ts';
|
||||
import { startJobs } from './jobs.ts';
|
||||
import { watchWebhooks } from './services/webhooks.ts';
|
||||
|
||||
let config;
|
||||
try {
|
||||
@@ -77,6 +78,7 @@ const voicemail = watchVoicemail(
|
||||
);
|
||||
app.decorate('voicemail', voicemail);
|
||||
app.decorate('live', live);
|
||||
watchWebhooks(db, live, app.log);
|
||||
|
||||
await runMigrations(db);
|
||||
app.log.info('database migrated');
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { LiveState } from './live/state.ts';
|
||||
import { cleanupRecordings } from './routes/recordings.ts';
|
||||
import { audit } from './services/audit.ts';
|
||||
import { backupDue, runBackup } from './services/backup.ts';
|
||||
import { deliverDue } from './services/webhooks.ts';
|
||||
import type { SendMail } from './services/mail.ts';
|
||||
import { getSetting, setSetting } from './services/settings.ts';
|
||||
|
||||
@@ -232,5 +233,9 @@ export function startJobs(d: JobDeps): () => void {
|
||||
};
|
||||
tick();
|
||||
const timer = setInterval(tick, 60_000);
|
||||
return () => clearInterval(timer);
|
||||
const hooks = setInterval(() => run('webhookDelivery', () => deliverDue(d.db)), 10_000);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
clearInterval(hooks);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// /api/webhooks (04 §9) and /api/api-tokens (04 §2.3). Admin only.
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { and, desc, eq, isNull } from 'drizzle-orm';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
ApiTokenInputSchema,
|
||||
WebhookInputSchema,
|
||||
type ApiTokenDto,
|
||||
type ListResponse,
|
||||
type WebhookDeliveryDto,
|
||||
type WebhookDto,
|
||||
type WebhookEvent,
|
||||
} from '@selfphoneng/shared';
|
||||
import { requireAdmin } from '../auth/guards.ts';
|
||||
import { generateToken, hashToken } from '../auth/tokens.ts';
|
||||
import { decrypt, encrypt } from '../crypto/secrets.ts';
|
||||
import { apiTokens, users, webhookDeliveries, webhooks } from '../db/schema.ts';
|
||||
import { forbidden, notFound } from '../errors.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
import { postSigned } from '../services/webhooks.ts';
|
||||
|
||||
type Hook = typeof webhooks.$inferSelect;
|
||||
const toDto = (h: Hook): WebhookDto => ({
|
||||
id: h.id,
|
||||
url: h.url,
|
||||
events: h.events as WebhookEvent[],
|
||||
enabled: h.enabled,
|
||||
secret: decrypt(h.secretEnc),
|
||||
createdAt: h.createdAt.toISOString(),
|
||||
});
|
||||
const IdParams = z.object({ id: z.uuid() });
|
||||
|
||||
export async function integrationRoutes(app: FastifyInstance) {
|
||||
const { db } = app;
|
||||
app.addHook('preHandler', requireAdmin);
|
||||
const act = (req: FastifyRequest) => ({ actorUserId: req.user!.id, actorIp: req.ip });
|
||||
async function getHook(id: string) {
|
||||
const [h] = await db.select().from(webhooks).where(eq(webhooks.id, id));
|
||||
if (!h) throw notFound('Webhook not found');
|
||||
return h;
|
||||
}
|
||||
|
||||
app.get('/api/webhooks', async (): Promise<ListResponse<WebhookDto>> => ({
|
||||
items: (await db.select().from(webhooks).orderBy(webhooks.createdAt)).map(toDto),
|
||||
nextCursor: null,
|
||||
}));
|
||||
|
||||
app.post('/api/webhooks', async (req, reply) => {
|
||||
const body = WebhookInputSchema.parse(req.body);
|
||||
const [h] = (await db
|
||||
.insert(webhooks)
|
||||
.values({ ...body, secretEnc: encrypt(randomBytes(32).toString('hex')) })
|
||||
.returning()) as [Hook];
|
||||
await audit(db, {
|
||||
...act(req),
|
||||
action: 'create',
|
||||
entityType: 'webhook',
|
||||
entityId: h.id,
|
||||
summary: `Added webhook ${h.url}`,
|
||||
diff: { after: body },
|
||||
});
|
||||
return reply.code(201).send(toDto(h));
|
||||
});
|
||||
|
||||
app.patch('/api/webhooks/:id', async (req) => {
|
||||
const { id } = IdParams.parse(req.params);
|
||||
const before = await getHook(id);
|
||||
const body = WebhookInputSchema.partial().parse(req.body);
|
||||
const [h] = (await db
|
||||
.update(webhooks)
|
||||
.set({ ...body, updatedAt: new Date() })
|
||||
.where(eq(webhooks.id, id))
|
||||
.returning()) as [Hook];
|
||||
await audit(db, {
|
||||
...act(req),
|
||||
action: 'update',
|
||||
entityType: 'webhook',
|
||||
entityId: id,
|
||||
summary: `Changed webhook ${h.url}`,
|
||||
diff: {
|
||||
before: { url: before.url, events: before.events, enabled: before.enabled },
|
||||
after: body,
|
||||
},
|
||||
});
|
||||
return toDto(h);
|
||||
});
|
||||
|
||||
app.delete('/api/webhooks/:id', async (req) => {
|
||||
const { id } = IdParams.parse(req.params);
|
||||
const h = await getHook(id);
|
||||
await db.delete(webhooks).where(eq(webhooks.id, id));
|
||||
await audit(db, {
|
||||
...act(req),
|
||||
action: 'delete',
|
||||
entityType: 'webhook',
|
||||
entityId: id,
|
||||
summary: `Removed webhook ${h.url}`,
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Sends a `webhook.test` event now, not queued: the answer shows right away. */
|
||||
app.post('/api/webhooks/:id/test', async (req) => {
|
||||
const h = await getHook(IdParams.parse(req.params).id);
|
||||
const body = JSON.stringify({
|
||||
id: crypto.randomUUID(),
|
||||
event: 'webhook.test',
|
||||
createdAt: new Date().toISOString(),
|
||||
data: { message: 'Test from SelfPhoneNG' },
|
||||
});
|
||||
try {
|
||||
const status = await postSigned(
|
||||
h.url,
|
||||
'webhook.test',
|
||||
body,
|
||||
decrypt(h.secretEnc),
|
||||
await getSetting(db, 'webhooks.allowPrivate'),
|
||||
);
|
||||
return { ok: status >= 200 && status < 300, status };
|
||||
} catch (e) {
|
||||
return { ok: false, status: null, error: (e as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
app.get(
|
||||
'/api/webhooks/:id/deliveries',
|
||||
async (req): Promise<ListResponse<WebhookDeliveryDto>> => {
|
||||
const h = await getHook(IdParams.parse(req.params).id);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(webhookDeliveries)
|
||||
.where(eq(webhookDeliveries.webhookId, h.id))
|
||||
.orderBy(desc(webhookDeliveries.id))
|
||||
.limit(50);
|
||||
return {
|
||||
items: rows.map((d) => ({
|
||||
id: d.id,
|
||||
event: d.event,
|
||||
status: d.status as WebhookDeliveryDto['status'],
|
||||
attempt: d.attempt,
|
||||
responseCode: d.responseCode,
|
||||
createdAt: d.createdAt.toISOString(),
|
||||
nextAttemptAt: d.nextAttemptAt?.toISOString() ?? null,
|
||||
})),
|
||||
nextCursor: null,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// A token can't make or revoke tokens: a leaked one shouldn't be able to dig in deeper.
|
||||
const noToken = (req: FastifyRequest) => {
|
||||
if (req.apiTokenId) throw forbidden('Manage API tokens from the web page');
|
||||
};
|
||||
|
||||
app.get('/api/api-tokens', async (req): Promise<ListResponse<ApiTokenDto>> => {
|
||||
noToken(req);
|
||||
const rows = await db
|
||||
.select({ t: apiTokens, by: users.email })
|
||||
.from(apiTokens)
|
||||
.innerJoin(users, eq(users.id, apiTokens.createdBy))
|
||||
.where(isNull(apiTokens.revokedAt))
|
||||
.orderBy(apiTokens.createdAt);
|
||||
return {
|
||||
items: rows.map(({ t, by }) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
tokenPrefix: t.tokenPrefix,
|
||||
createdBy: by,
|
||||
createdAt: t.createdAt.toISOString(),
|
||||
lastUsedAt: t.lastUsedAt?.toISOString() ?? null,
|
||||
})),
|
||||
nextCursor: null,
|
||||
};
|
||||
});
|
||||
|
||||
/** The only time the token is shown. */
|
||||
app.post('/api/api-tokens', async (req, reply) => {
|
||||
noToken(req);
|
||||
const { name } = ApiTokenInputSchema.parse(req.body);
|
||||
const token = generateToken();
|
||||
const [t] = await db
|
||||
.insert(apiTokens)
|
||||
.values({
|
||||
name,
|
||||
tokenPrefix: token.slice(0, 7),
|
||||
tokenHash: hashToken(token),
|
||||
createdBy: req.user!.id,
|
||||
})
|
||||
.returning();
|
||||
await audit(db, {
|
||||
...act(req),
|
||||
action: 'create',
|
||||
entityType: 'api_token',
|
||||
entityId: t!.id,
|
||||
summary: `Created API token ${name}`,
|
||||
});
|
||||
return reply.code(201).send({ id: t!.id, name, token });
|
||||
});
|
||||
|
||||
app.delete('/api/api-tokens/:id', async (req) => {
|
||||
noToken(req);
|
||||
const { id } = IdParams.parse(req.params);
|
||||
const [t] = await db
|
||||
.update(apiTokens)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(and(eq(apiTokens.id, id), isNull(apiTokens.revokedAt)))
|
||||
.returning();
|
||||
if (!t) throw notFound('Token not found');
|
||||
await audit(db, {
|
||||
...act(req),
|
||||
action: 'delete',
|
||||
entityType: 'api_token',
|
||||
entityId: id,
|
||||
summary: `Revoked API token ${t.name}`,
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
@@ -36,5 +36,5 @@ describe.skipIf(!hasSh)('sp-vm-pincheck', () => {
|
||||
];
|
||||
for (const box of ['101', '2000', '55'])
|
||||
for (const pin of pins) expect(script(box, pin), `${box} ${pin}`).toBe(api(box, pin));
|
||||
}, 60_000); // 54 shells: slow on Windows under a full parallel run
|
||||
}, 180_000); // ponytail: 54 runs; Git Bash forks make that ~85 s on Windows (instant on Linux)
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import type { Db } from '../db/client.ts';
|
||||
import { extensions, users, voicemailMessages } from '../db/schema.ts';
|
||||
import { MailNotConfigured, type SendMail } from './mail.ts';
|
||||
import { getSetting } from './settings.ts';
|
||||
import { enqueueWebhook } from './webhooks.ts';
|
||||
|
||||
export type VoicemailDeps = {
|
||||
db: Db;
|
||||
@@ -99,6 +100,11 @@ type Box = {
|
||||
};
|
||||
|
||||
async function announce(d: VoicemailDeps, box: Box, row: Row) {
|
||||
// Every mailbox, with or without a user (the live message is only for the user's own).
|
||||
void enqueueWebhook(d.db, 'voicemail.new', {
|
||||
...toVoicemailDto(row),
|
||||
extension: box.number,
|
||||
}).catch((err: unknown) => d.log.error({ err }, 'webhook enqueue failed'));
|
||||
if (box.userId)
|
||||
d.live.emit('message', {
|
||||
type: 'voicemail.new',
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Webhooks (04 §9): events from the live state are queued in webhook_deliveries, then a job POSTs
|
||||
// them, signed, with retries. Private/loopback targets are refused unless webhooks.allowPrivate.
|
||||
import { createHmac, randomUUID } from 'node:crypto';
|
||||
import { lookup } from 'node:dns';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import { BlockList, isIP, type LookupFunction } from 'node:net';
|
||||
import { and, arrayContains, asc, eq, lte } from 'drizzle-orm';
|
||||
import type { LiveMessage, WebhookEvent } from '@selfphoneng/shared';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { webhookDeliveries, webhooks } from '../db/schema.ts';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
import { getSetting } from './settings.ts';
|
||||
|
||||
const blocked = new BlockList();
|
||||
for (const [net, bits] of [
|
||||
['0.0.0.0', 8],
|
||||
['10.0.0.0', 8],
|
||||
['100.64.0.0', 10],
|
||||
['127.0.0.0', 8],
|
||||
['169.254.0.0', 16],
|
||||
['172.16.0.0', 12],
|
||||
['192.168.0.0', 16],
|
||||
['224.0.0.0', 3], // multicast and reserved
|
||||
] as const)
|
||||
blocked.addSubnet(net, bits, 'ipv4');
|
||||
for (const [net, bits] of [
|
||||
['::', 127], // :: and ::1 (IPv4-mapped addresses match the IPv4 rules)
|
||||
['fc00::', 7],
|
||||
['fe80::', 10],
|
||||
['ff00::', 8],
|
||||
] as const)
|
||||
blocked.addSubnet(net, bits, 'ipv6');
|
||||
|
||||
export const isPrivateAddress = (ip: string) => blocked.check(ip, isIP(ip) === 6 ? 'ipv6' : 'ipv4');
|
||||
|
||||
export class BlockedTarget extends Error {
|
||||
constructor(ip: string) {
|
||||
super(`${ip} is a private address (Settings → Webhooks allows them)`);
|
||||
}
|
||||
}
|
||||
|
||||
/** DNS lookup that refuses private addresses: checked at connect time, so rebinding can't dodge it. */
|
||||
const publicLookup: LookupFunction = (host, opts, cb) =>
|
||||
lookup(host, opts, (err, addr, family) => {
|
||||
if (err) return cb(err, addr, family);
|
||||
const all = Array.isArray(addr) ? addr : [{ address: addr, family }];
|
||||
const bad = all.find((a) => isPrivateAddress(a.address));
|
||||
if (bad) return cb(new BlockedTarget(bad.address), addr, family);
|
||||
cb(null, addr, family);
|
||||
});
|
||||
|
||||
/** POSTs a signed body; resolves to the HTTP status. No redirects are followed. */
|
||||
export function postSigned(
|
||||
url: string,
|
||||
event: string,
|
||||
body: string,
|
||||
secret: string,
|
||||
allowPrivate: boolean,
|
||||
): Promise<number> {
|
||||
const u = new URL(url);
|
||||
const host = u.hostname.replace(/^\[|\]$/g, '');
|
||||
if (!allowPrivate && isIP(host) && isPrivateAddress(host))
|
||||
return Promise.reject(new BlockedTarget(host)); // literal IPs skip the lookup
|
||||
const ts = String(Math.floor(Date.now() / 1000));
|
||||
const sig = createHmac('sha256', secret).update(`${ts}.${body}`).digest('hex');
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = (u.protocol === 'https:' ? https : http).request(
|
||||
u,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'content-length': Buffer.byteLength(body),
|
||||
'user-agent': 'SelfPhoneNG-Webhook',
|
||||
'x-selfphoneng-event': event,
|
||||
'x-selfphoneng-timestamp': ts,
|
||||
'x-selfphoneng-signature': `sha256=${sig}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
...(allowPrivate ? {} : { lookup: publicLookup }),
|
||||
},
|
||||
(res) => {
|
||||
res.resume();
|
||||
resolve(res.statusCode ?? 0);
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
/** Queues one delivery per enabled webhook that wants this event. */
|
||||
export async function enqueueWebhook(db: Db, event: WebhookEvent, data: unknown) {
|
||||
const hooks = await db
|
||||
.select({ id: webhooks.id })
|
||||
.from(webhooks)
|
||||
.where(and(eq(webhooks.enabled, true), arrayContains(webhooks.events, [event])));
|
||||
if (!hooks.length) return;
|
||||
const payload = { id: randomUUID(), event, createdAt: new Date().toISOString(), data };
|
||||
await db.insert(webhookDeliveries).values(
|
||||
hooks.map((h) => ({
|
||||
webhookId: h.id,
|
||||
event,
|
||||
payload,
|
||||
status: 'pending',
|
||||
nextAttemptAt: new Date(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** Waits after a failed attempt 1, 2, … 5; after the 6th try it's failed (04 §9). */
|
||||
const RETRY_MS = [60_000, 5 * 60_000, 30 * 60_000, 2 * 3600_000, 12 * 3600_000];
|
||||
|
||||
/** Sends what's due (the webhookDelivery job, every 10 s). */
|
||||
export async function deliverDue(db: Db, now = new Date()) {
|
||||
const due = await db
|
||||
.select({ d: webhookDeliveries, url: webhooks.url, secretEnc: webhooks.secretEnc })
|
||||
.from(webhookDeliveries)
|
||||
.innerJoin(webhooks, eq(webhooks.id, webhookDeliveries.webhookId))
|
||||
.where(
|
||||
and(
|
||||
eq(webhookDeliveries.status, 'pending'),
|
||||
eq(webhooks.enabled, true),
|
||||
lte(webhookDeliveries.nextAttemptAt, now),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(webhookDeliveries.id))
|
||||
.limit(20);
|
||||
const allowPrivate = await getSetting(db, 'webhooks.allowPrivate');
|
||||
await Promise.all(
|
||||
due.map(async ({ d, url, secretEnc }) => {
|
||||
const code = await postSigned(
|
||||
url,
|
||||
d.event,
|
||||
JSON.stringify(d.payload),
|
||||
decrypt(secretEnc),
|
||||
allowPrivate,
|
||||
).catch(() => null);
|
||||
const attempt = d.attempt + 1;
|
||||
const ok = code !== null && code >= 200 && code < 300;
|
||||
const retryIn = RETRY_MS[attempt - 1];
|
||||
await db
|
||||
.update(webhookDeliveries)
|
||||
.set({
|
||||
attempt,
|
||||
responseCode: code,
|
||||
status: ok ? 'done' : retryIn ? 'pending' : 'failed',
|
||||
nextAttemptAt: ok || !retryIn ? null : new Date(now.getTime() + retryIn),
|
||||
})
|
||||
.where(eq(webhookDeliveries.id, d.id));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Turns live-state messages into webhook events (voicemail.new comes from services/voicemail.ts). */
|
||||
export function watchWebhooks(
|
||||
db: Db,
|
||||
live: { on(e: 'message', fn: (m: LiveMessage) => void): unknown },
|
||||
log: { error(o: object, m: string): void },
|
||||
) {
|
||||
const calls = new Map<string, { answered: boolean; call: unknown }>();
|
||||
const trunks = new Map<string, string>();
|
||||
const online = new Map<string, boolean>();
|
||||
const send = (event: WebhookEvent, data: unknown) =>
|
||||
void enqueueWebhook(db, event, data).catch((err: unknown) =>
|
||||
log.error({ err, event }, 'webhook enqueue failed'),
|
||||
);
|
||||
live.on('message', (m) => {
|
||||
switch (m.type) {
|
||||
case 'call.updated': {
|
||||
const known = calls.get(m.data.linkedid);
|
||||
if (!known) send('call.started', m.data);
|
||||
if (m.data.answeredAt && !known?.answered) send('call.answered', m.data);
|
||||
calls.set(m.data.linkedid, { answered: !!m.data.answeredAt, call: m.data });
|
||||
break;
|
||||
}
|
||||
case 'call.removed': {
|
||||
const known = calls.get(m.data.linkedid);
|
||||
calls.delete(m.data.linkedid);
|
||||
if (known)
|
||||
send('call.ended', { ...(known.call as object), endedAt: new Date().toISOString() });
|
||||
break;
|
||||
}
|
||||
case 'emergency.call':
|
||||
send('emergency.call', m.data);
|
||||
break;
|
||||
case 'trunk.updated': {
|
||||
const prev = trunks.get(m.data.trunkId);
|
||||
trunks.set(m.data.trunkId, m.data.registration);
|
||||
if (prev && prev !== m.data.registration)
|
||||
send('trunk.registration_changed', { ...m.data, previous: prev });
|
||||
break;
|
||||
}
|
||||
case 'extension.updated': {
|
||||
const prev = online.get(m.data.number);
|
||||
online.set(m.data.number, m.data.online);
|
||||
if (prev !== undefined && prev !== m.data.online)
|
||||
send('extension.registration_changed', {
|
||||
number: m.data.number,
|
||||
name: m.data.name,
|
||||
online: m.data.online,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M11-T5: API tokens (04 §2.3) and webhooks (04 §9).
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { createServer, type IncomingHttpHeaders } from 'node:http';
|
||||
import { join } from 'node:path';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { users, webhookDeliveries } from '../src/db/schema.ts';
|
||||
import { setSetting } from '../src/services/settings.ts';
|
||||
import { deliverDue, isPrivateAddress, watchWebhooks } from '../src/services/webhooks.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
it('isPrivateAddress', () => {
|
||||
for (const ip of [
|
||||
'127.0.0.1',
|
||||
'10.1.2.3',
|
||||
'172.20.0.1',
|
||||
'192.168.1.1',
|
||||
'169.254.169.254',
|
||||
'0.0.0.0',
|
||||
'::1',
|
||||
'::',
|
||||
'fe80::1',
|
||||
'fd00::1',
|
||||
'::ffff:7f00:1',
|
||||
])
|
||||
expect(isPrivateAddress(ip), ip).toBe(true);
|
||||
for (const ip of ['8.8.8.8', '203.0.113.9', '2001:4860:4860::8888', '::ffff:8.8.8.8'])
|
||||
expect(isPrivateAddress(ip), ip).toBe(false);
|
||||
});
|
||||
|
||||
describe.skipIf(!hasTestDb)('API tokens and webhooks', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let admin: string;
|
||||
let req: (
|
||||
m: string,
|
||||
url: string,
|
||||
payload?: unknown,
|
||||
headers?: Record<string, string>,
|
||||
) => Promise<LightMyRequestResponse>;
|
||||
// The receiving end: answers with `answer`, keeps what it got.
|
||||
const got: { headers: IncomingHttpHeaders; body: string }[] = [];
|
||||
let answer = 200;
|
||||
const server = createServer((r, res) => {
|
||||
let body = '';
|
||||
r.on('data', (c) => (body += c));
|
||||
r.on('end', () => {
|
||||
got.push({ headers: r.headers, body });
|
||||
res.writeHead(answer).end();
|
||||
});
|
||||
});
|
||||
let url: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestApp();
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
admin = await t.login(ADMIN.email, ADMIN.password);
|
||||
req = (method, u, payload, headers = { ...H, cookie: admin }) =>
|
||||
t.app.inject({ method: method as 'GET', url: u, payload: payload as object, headers });
|
||||
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
|
||||
url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/hook`;
|
||||
});
|
||||
afterAll(async () => {
|
||||
server.close();
|
||||
await t.close();
|
||||
});
|
||||
|
||||
it('a token is shown once and acts as its admin, without the CSRF header', async () => {
|
||||
const r = await req('POST', '/api/api-tokens', { name: 'CRM' });
|
||||
expect(r.statusCode).toBe(201);
|
||||
const { token, id } = r.json();
|
||||
expect(token).toMatch(/^sp_[A-Za-z0-9]{40}$/);
|
||||
const list = (await req('GET', '/api/api-tokens')).json().items;
|
||||
expect(list).toEqual([
|
||||
expect.objectContaining({
|
||||
id,
|
||||
name: 'CRM',
|
||||
tokenPrefix: token.slice(0, 7),
|
||||
createdBy: ADMIN.email,
|
||||
lastUsedAt: null,
|
||||
}),
|
||||
]);
|
||||
expect(JSON.stringify(list)).not.toContain(token);
|
||||
|
||||
const bearer = { authorization: `Bearer ${token}` };
|
||||
expect((await req('GET', '/api/extensions', undefined, bearer)).statusCode).toBe(200);
|
||||
const made = await req('POST', '/api/blocked-numbers', { number: '3125550100' }, bearer);
|
||||
expect(made.statusCode).toBe(201);
|
||||
expect((await req('GET', '/api/api-tokens')).json().items[0].lastUsedAt).not.toBeNull();
|
||||
// A token can't manage tokens.
|
||||
expect((await req('POST', '/api/api-tokens', { name: 'more' }, bearer)).statusCode).toBe(403);
|
||||
expect((await req('GET', '/api/api-tokens', undefined, bearer)).statusCode).toBe(403);
|
||||
// Wrong, malformed or with a cookie that would work: the token decides.
|
||||
for (const a of [`Bearer sp_${'x'.repeat(40)}`, 'Bearer nope', `Bearer ${token}x`])
|
||||
expect(
|
||||
(await req('GET', '/api/extensions', undefined, { authorization: a, cookie: admin }))
|
||||
.statusCode,
|
||||
).toBe(401);
|
||||
|
||||
expect((await req('DELETE', `/api/api-tokens/${id}`)).statusCode).toBe(200);
|
||||
expect((await req('GET', '/api/extensions', undefined, bearer)).statusCode).toBe(401);
|
||||
expect((await req('GET', '/api/api-tokens')).json().items).toEqual([]);
|
||||
});
|
||||
|
||||
it("a token stops working when its admin is disabled, and is a user's when demoted", async () => {
|
||||
const other = await t.addUser('ops@example.com', 'ops-password-123', 'admin');
|
||||
const c = await t.login('ops@example.com', 'ops-password-123');
|
||||
const { token } = (
|
||||
await req('POST', '/api/api-tokens', { name: 'ops' }, { ...H, cookie: c })
|
||||
).json();
|
||||
const bearer = { authorization: `Bearer ${token}` };
|
||||
await t.db.update(users).set({ role: 'user' }).where(eq(users.id, other.id));
|
||||
expect((await req('GET', '/api/extensions', undefined, bearer)).statusCode).toBe(403);
|
||||
await t.db.update(users).set({ isActive: false }).where(eq(users.id, other.id));
|
||||
expect((await req('GET', '/api/extensions', undefined, bearer)).statusCode).toBe(401);
|
||||
// Users can't make tokens.
|
||||
const u = await t.addUser('pat@example.com', 'pat-password-123');
|
||||
void u;
|
||||
const pc = await t.login('pat@example.com', 'pat-password-123');
|
||||
expect(
|
||||
(await req('POST', '/api/api-tokens', { name: 'x' }, { ...H, cookie: pc })).statusCode,
|
||||
).toBe(403);
|
||||
});
|
||||
|
||||
it('webhooks: validation, private targets refused by default, signed test', async () => {
|
||||
expect(
|
||||
(await req('POST', '/api/webhooks', { url: 'ftp://x.example/', events: ['call.ended'] }))
|
||||
.statusCode,
|
||||
).toBe(400);
|
||||
expect((await req('POST', '/api/webhooks', { url, events: [] })).statusCode).toBe(400);
|
||||
expect((await req('POST', '/api/webhooks', { url, events: ['nope'] })).statusCode).toBe(400);
|
||||
const h = (
|
||||
await req('POST', '/api/webhooks', { url, events: ['call.started', 'call.ended'] })
|
||||
).json();
|
||||
expect(h.secret).toMatch(/^[0-9a-f]{64}$/);
|
||||
|
||||
let r = (await req('POST', `/api/webhooks/${h.id}/test`)).json();
|
||||
expect(r).toMatchObject({ ok: false, error: expect.stringContaining('private address') });
|
||||
// A name that resolves to loopback is refused at connect time too.
|
||||
await req('PATCH', `/api/webhooks/${h.id}`, { url: url.replace('127.0.0.1', 'localhost') });
|
||||
r = (await req('POST', `/api/webhooks/${h.id}/test`)).json();
|
||||
expect(r).toMatchObject({ ok: false, error: expect.stringContaining('private address') });
|
||||
expect(got).toEqual([]);
|
||||
|
||||
await req('PATCH', `/api/webhooks/${h.id}`, { url });
|
||||
await setSetting(t.db, 'webhooks.allowPrivate', true);
|
||||
r = (await req('POST', `/api/webhooks/${h.id}/test`)).json();
|
||||
expect(r).toEqual({ ok: true, status: 200 });
|
||||
const [m] = got;
|
||||
expect(m!.headers['x-selfphoneng-event']).toBe('webhook.test');
|
||||
const ts = m!.headers['x-selfphoneng-timestamp'] as string;
|
||||
expect(m!.headers['x-selfphoneng-signature']).toBe(
|
||||
`sha256=${createHmac('sha256', h.secret).update(`${ts}.${m!.body}`).digest('hex')}`,
|
||||
);
|
||||
expect(JSON.parse(m!.body)).toMatchObject({
|
||||
event: 'webhook.test',
|
||||
data: { message: 'Test from SelfPhoneNG' },
|
||||
});
|
||||
});
|
||||
|
||||
it('live events are queued, delivered and retried 1m, 5m, 30m, 2h, 12h, then failed', async () => {
|
||||
got.length = 0;
|
||||
watchWebhooks(t.db, t.live, t.app.log);
|
||||
const call = {
|
||||
linkedid: 'L1',
|
||||
startedAt: new Date().toISOString(),
|
||||
direction: 'inbound' as const,
|
||||
from: { number: '3125550100' },
|
||||
to: { number: '201' },
|
||||
state: 'ringing' as const,
|
||||
extensions: ['201'],
|
||||
channels: [],
|
||||
recording: false,
|
||||
};
|
||||
t.live.emit('message', { type: 'call.updated', data: call });
|
||||
t.live.emit('message', {
|
||||
type: 'call.updated',
|
||||
data: { ...call, state: 'talking', answeredAt: call.startedAt },
|
||||
});
|
||||
t.live.emit('message', { type: 'call.removed', data: { linkedid: 'L1' } });
|
||||
await new Promise((r) => setTimeout(r, 200)); // the inserts are fire-and-forget
|
||||
await deliverDue(t.db);
|
||||
// call.answered isn't subscribed.
|
||||
expect(got.map((g) => JSON.parse(g.body).event)).toEqual(['call.started', 'call.ended']);
|
||||
expect(JSON.parse(got[1]!.body).data).toMatchObject({
|
||||
linkedid: 'L1',
|
||||
endedAt: expect.any(String),
|
||||
});
|
||||
|
||||
answer = 500;
|
||||
got.length = 0;
|
||||
t.live.emit('message', { type: 'call.updated', data: { ...call, linkedid: 'L2' } });
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
let now = Date.now();
|
||||
const gaps: number[] = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await deliverDue(t.db, new Date(now));
|
||||
const [d] = await t.db
|
||||
.select()
|
||||
.from(webhookDeliveries)
|
||||
.where(eq(webhookDeliveries.event, 'call.started'))
|
||||
.orderBy(webhookDeliveries.id)
|
||||
.offset(1);
|
||||
if (d!.nextAttemptAt) {
|
||||
gaps.push(Math.round((d!.nextAttemptAt.getTime() - now) / 60_000));
|
||||
now = d!.nextAttemptAt.getTime();
|
||||
} else expect(d).toMatchObject({ status: 'failed', attempt: 6, responseCode: 500 });
|
||||
}
|
||||
expect(gaps).toEqual([1, 5, 30, 120, 720]);
|
||||
expect(got).toHaveLength(6);
|
||||
await deliverDue(t.db, new Date(now + 86_400_000));
|
||||
expect(got).toHaveLength(6);
|
||||
answer = 200;
|
||||
|
||||
const del = (
|
||||
await req(
|
||||
'GET',
|
||||
`/api/webhooks/${(await req('GET', '/api/webhooks')).json().items[0].id}/deliveries`,
|
||||
)
|
||||
).json().items;
|
||||
expect(del.map((d: { status: string }) => d.status)).toEqual(['failed', 'done', 'done']);
|
||||
});
|
||||
|
||||
it('voicemail.new fires for a mailbox without a user too', async () => {
|
||||
await req('POST', '/api/webhooks', { url, events: ['voicemail.new'] });
|
||||
await req('POST', '/api/extensions', { number: '205', name: 'Front desk' });
|
||||
const inbox = join(t.spoolDir, 'default', '205', 'INBOX');
|
||||
mkdirSync(inbox, { recursive: true });
|
||||
writeFileSync(join(inbox, 'msg0000.wav'), '');
|
||||
writeFileSync(
|
||||
join(inbox, 'msg0000.txt'),
|
||||
'[message]\ncallerid="Bo" <3125550111>\norigtime=1792300385\nmsg_id=1792300385-205\nduration=4\n',
|
||||
);
|
||||
await t.app.voicemail.scan('205');
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
const [d] = await t.db
|
||||
.select()
|
||||
.from(webhookDeliveries)
|
||||
.where(eq(webhookDeliveries.event, 'voicemail.new'));
|
||||
expect(d!.payload).toMatchObject({
|
||||
event: 'voicemail.new',
|
||||
data: { extension: '205', callerNumber: '3125550111', durationS: 4 },
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user