integrations: webhooks and API tokens
ci / check (push) Successful in 54s
ci / images (push) Successful in 21s

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:
iamdoubz committed 2026-10-19 19:40:47 -05:00
1 parent d229ed2c45
commit 60846afddd
17 files changed
+1319 -8

No files matched your search

+5 -2
View File
@@ -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, {
+18 -1
View File
@@ -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();
+36
View File
@@ -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 };
}
+2
View File
@@ -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');
+6 -1
View File
@@ -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);
};
}
+221
View File
@@ -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 };
});
}
+1 -1
View File
@@ -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)
});
+6
View File
@@ -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',
+209
View File
@@ -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;
}
}
});
}
+249
View File
@@ -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 },
});
});
});
+25
View File
@@ -1,6 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Typed fetch wrappers (05 §1). Every request sends X-Requested-With (CSRF, 04 §2.2).
import type {
ApiTokenDto,
WebhookDeliveryDto,
WebhookDto,
WebhookInput,
AnnouncementCreate,
AnnouncementDto,
AnnouncementUpdate,
@@ -364,6 +368,27 @@ export const announcementsApi = {
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/announcements/${id}`),
};
export const webhooksApi = {
list: () => api<ListResponse<WebhookDto>>('GET', '/api/webhooks').then((r) => r.items),
create: (w: WebhookInput) => api<WebhookDto>('POST', '/api/webhooks', w),
update: (id: string, w: Partial<WebhookInput>) =>
api<WebhookDto>('PATCH', `/api/webhooks/${id}`, w),
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/webhooks/${id}`),
test: (id: string) =>
api<{ ok: boolean; status: number | null; error?: string }>('POST', `/api/webhooks/${id}/test`),
deliveries: (id: string) =>
api<ListResponse<WebhookDeliveryDto>>('GET', `/api/webhooks/${id}/deliveries`).then(
(r) => r.items,
),
};
export const apiTokensApi = {
list: () => api<ListResponse<ApiTokenDto>>('GET', '/api/api-tokens').then((r) => r.items),
create: (name: string) =>
api<{ id: string; name: string; token: string }>('POST', '/api/api-tokens', { name }),
revoke: (id: string) => api<{ ok: true }>('DELETE', `/api/api-tokens/${id}`),
};
export const blockedApi = {
list: () => api<ListResponse<BlockedDto>>('GET', '/api/blocked-numbers').then((r) => r.items),
create: (b: BlockedCreate) => api<BlockedDto>('POST', '/api/blocked-numbers', b),
+271 -3
View File
@@ -1,10 +1,25 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Settings (05 §2). M3: Email and Emergency; M7: Recording; M11: Network (public IP watch), Security (phone ACL, provisioning, SIP TLS), Backups.
// Settings (05 §2). M3: Email and Emergency; M7: Recording; M11: Network (public IP watch), Security (phone ACL, provisioning, SIP TLS), Backups,
// Webhooks, API tokens.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { NavLink, useParams } from 'react-router';
import { formatNumber, type SettingsDto } from '@selfphoneng/shared';
import { didsApi, extensionsApi, settingsApi, systemApi } from '../../api/client.ts';
import {
WEBHOOK_EVENTS,
formatNumber,
type SettingsDto,
type WebhookDto,
type WebhookEvent,
} from '@selfphoneng/shared';
import {
apiTokensApi,
didsApi,
extensionsApi,
settingsApi,
systemApi,
webhooksApi,
} from '../../api/client.ts';
import { ConfirmDialog } from '../../components/Dialog.tsx';
import { SoundPicker } from '../../components/SoundPicker.tsx';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
@@ -15,6 +30,8 @@ const TABS = [
{ id: 'network', label: 'Network' },
{ id: 'security', label: 'Security' },
{ id: 'backups', label: 'Backups' },
{ id: 'webhooks', label: 'Webhooks' },
{ id: 'tokens', label: 'API tokens' },
] as const;
export function SettingsPage() {
@@ -46,6 +63,8 @@ export function SettingsPage() {
{settings.data && tab === 'network' && <NetworkTab s={settings.data} />}
{settings.data && tab === 'security' && <SecurityTab s={settings.data} />}
{settings.data && tab === 'backups' && <BackupsTab s={settings.data} />}
{settings.data && tab === 'webhooks' && <WebhooksTab s={settings.data} />}
{tab === 'tokens' && <TokensTab />}
</section>
);
}
@@ -634,3 +653,252 @@ function BackupsTab({ s }: { s: SettingsDto }) {
</div>
);
}
/** Webhooks (04 §9): where events are POSTed, signed with each webhook's secret. */
function WebhooksTab({ s }: { s: SettingsDto }) {
const qc = useQueryClient();
const save = useSave();
const list = useQuery({ queryKey: ['webhooks'], queryFn: webhooksApi.list });
const refresh = () => void qc.invalidateQueries({ queryKey: ['webhooks'] });
const [url, setUrl] = useState('');
const [events, setEvents] = useState<WebhookEvent[]>(['call.ended']);
const add = useMutation({
mutationFn: () => webhooksApi.create({ url: url.trim(), events }),
onSuccess: () => {
setUrl('');
refresh();
},
});
return (
<div className="flex flex-col gap-6">
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault();
add.mutate();
}}
>
<Field label="Send events to" hint="An https:// address that accepts a POST">
{(p) => (
<input
{...p}
className={inputClass}
type="url"
placeholder="https://example.com/selfphoneng"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
)}
</Field>
<fieldset className="flex flex-col gap-1">
<legend className="mb-1 text-sm font-medium">Events</legend>
{WEBHOOK_EVENTS.map((ev) => (
<label key={ev} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={events.includes(ev)}
onChange={(e) =>
setEvents(e.target.checked ? [...events, ev] : events.filter((x) => x !== ev))
}
/>
<code>{ev}</code>
</label>
))}
</fieldset>
<div>
<Button type="submit" variant="primary" disabled={add.isPending}>
Add webhook
</Button>
</div>
{add.error && <Alert>{add.error.message}</Alert>}
</form>
<ul aria-label="Webhooks" className="flex flex-col gap-3">
{list.data?.map((w) => (
<WebhookItem key={w.id} w={w} onChange={refresh} />
))}
</ul>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={s['webhooks.allowPrivate']}
onChange={(e) => save.mutate({ 'webhooks.allowPrivate': e.target.checked })}
/>
Allow addresses on private networks (10.x, 192.168.x, localhost …)
</label>
<p className="-mt-4 text-xs text-zinc-500">
Off by default, so a webhook can't reach services inside your network. Turn it on for a
receiver in your own network (home lab).
</p>
{save.error && <Alert>{save.error.message}</Alert>}
<p className="text-xs text-zinc-500">
Each POST has the headers X-SelfPhoneNG-Event, X-SelfPhoneNG-Timestamp and
X-SelfPhoneNG-Signature: sha256= the hex HMAC-SHA256 of timestamp + &quot;.&quot; + body
with the webhook&apos;s secret. A failed POST is tried again after 1 and 5 minutes, half an
hour, 2 and 12 hours.
</p>
</div>
);
}
function WebhookItem({ w, onChange }: { w: WebhookDto; onChange: () => void }) {
const [showLog, setShowLog] = useState(false);
const [confirm, setConfirm] = useState(false);
const log = useQuery({
queryKey: ['webhook-deliveries', w.id],
queryFn: () => webhooksApi.deliveries(w.id),
enabled: showLog,
});
const test = useMutation({ mutationFn: () => webhooksApi.test(w.id) });
const toggle = useMutation({
mutationFn: () => webhooksApi.update(w.id, { enabled: !w.enabled }),
onSuccess: onChange,
});
const remove = useMutation({ mutationFn: () => webhooksApi.remove(w.id), onSuccess: onChange });
return (
<li className="flex flex-col gap-2 rounded-md border border-zinc-200 p-3 text-sm dark:border-zinc-800">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-medium break-all">{w.url}</span>
<span className="text-zinc-500">{w.enabled ? 'On' : 'Off'}</span>
</div>
<p className="text-zinc-500">{w.events.join(', ')}</p>
<p>
Secret: <code className="break-all">{w.secret}</code>
</p>
<div className="flex flex-wrap gap-2">
<Button onClick={() => test.mutate()} disabled={test.isPending}>
Send test
</Button>
<Button onClick={() => toggle.mutate()}>{w.enabled ? 'Turn off' : 'Turn on'}</Button>
<Button onClick={() => setShowLog(!showLog)}>
{showLog ? 'Hide deliveries' : 'Deliveries'}
</Button>
<Button variant="danger" onClick={() => setConfirm(true)}>
Remove
</Button>
</div>
{test.data && (
<p role="status">
{test.data.ok
? `Test sent: the server answered ${test.data.status}.`
: `Test failed: ${test.data.error ?? `the server answered ${test.data.status}`}`}
</p>
)}
{showLog && (
<ul aria-label="Deliveries" className="text-xs text-zinc-600 dark:text-zinc-400">
{log.data?.length === 0 && <li>Nothing sent yet.</li>}
{log.data?.map((d) => (
<li key={d.id}>
{new Date(d.createdAt).toLocaleString()} <code>{d.event}</code> {d.status}
{d.responseCode !== null && ` (${d.responseCode})`}
{d.attempt > 1 && `, ${d.attempt} tries`}
</li>
))}
</ul>
)}
<ConfirmDialog
open={confirm}
title="Remove webhook"
message={<p>Stop sending events to {w.url}?</p>}
confirmLabel="Remove"
onConfirm={() => remove.mutate()}
onCancel={() => setConfirm(false)}
busy={remove.isPending}
error={remove.error?.message}
/>
</li>
);
}
/** API tokens (04 §2.3): Bearer tokens for scripts, acting as the admin who made them. */
function TokensTab() {
const qc = useQueryClient();
const list = useQuery({ queryKey: ['api-tokens'], queryFn: apiTokensApi.list });
const [name, setName] = useState('');
const [revoking, setRevoking] = useState<{ id: string; name: string } | null>(null);
const create = useMutation({
mutationFn: () => apiTokensApi.create(name.trim()),
onSuccess: () => {
setName('');
void qc.invalidateQueries({ queryKey: ['api-tokens'] });
},
});
const revoke = useMutation({
mutationFn: (id: string) => apiTokensApi.revoke(id),
onSuccess: () => {
setRevoking(null);
void qc.invalidateQueries({ queryKey: ['api-tokens'] });
},
});
return (
<div className="flex flex-col gap-6">
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault();
create.mutate();
}}
>
<Field label="Token name" hint="What uses it, like “CRM” or “backup script”">
{(p) => (
<input
{...p}
className={`${inputClass} max-w-sm`}
value={name}
onChange={(e) => setName(e.target.value)}
/>
)}
</Field>
<div>
<Button type="submit" variant="primary" disabled={create.isPending}>
Create token
</Button>
</div>
{create.error && <Alert>{create.error.message}</Alert>}
{create.data && (
<div
role="status"
className="flex flex-col gap-1 rounded-md bg-amber-50 p-3 text-sm text-amber-900 dark:bg-amber-950 dark:text-amber-100"
>
<p className="font-medium">Copy the token now: it isn&apos;t shown again.</p>
<code aria-label="New token" className="break-all">
{create.data.token}
</code>
</div>
)}
</form>
<p className="-mt-2 text-xs text-zinc-500">
Send it as the header Authorization: Bearer &lt;token&gt;. A token can do everything its
admin can, except manage tokens. It stops working when revoked or when the admin is
disabled.
</p>
{list.data?.length === 0 && <p className="text-sm text-zinc-500">No tokens.</p>}
<ul aria-label="API tokens" className="divide-y divide-zinc-200 dark:divide-zinc-800">
{list.data?.map((t) => (
<li key={t.id} className="flex items-center justify-between gap-3 py-2 text-sm">
<span>
<span className="font-medium">{t.name}</span>{' '}
<code className="text-zinc-500">{t.tokenPrefix}…</code>
<span className="block text-xs text-zinc-500">
By {t.createdBy}, last used{' '}
{t.lastUsedAt ? new Date(t.lastUsedAt).toLocaleString() : 'never'}
</span>
</span>
<Button variant="danger" onClick={() => setRevoking(t)}>
Revoke
</Button>
</li>
))}
</ul>
<ConfirmDialog
open={revoking !== null}
title="Revoke token"
message={<p>Anything using {revoking?.name} stops working right away.</p>}
confirmLabel="Revoke"
onConfirm={() => revoking && revoke.mutate(revoking.id)}
onCancel={() => setRevoking(null)}
busy={revoke.isPending}
error={revoke.error?.message}
/>
</div>
);
}