provisioning: phones, /prov with Basic auth, rate limit and IP block
ci / check (push) Successful in 52s
ci / images (push) Successful in 1m44s

/api/devices CRUD (MAC in any format → 12 lowercase hex; a 20-char
provisioning password, stored encrypted and shown to admins, who type
it into the phone) and GET /:id/config-preview with passwords masked.

GET /prov/... per 07 §2: <mac>.cfg (Yealink), cfg<mac>.xml
(Grandstream), the empty Yealink common file. Basic auth with the MAC
and the device's password (timing-safe), 404 for unknown MACs or the
other vendor's file, 403 outside the local nets when LAN-only, 404 when
provisioning is off, Cache-Control no-store, last provisioned time and
IP recorded. Limits: 20 requests / 10 min per IP outside the local nets
(an office's phones rebooting together would pass it), and 10 failures
block the IP for an hour. A first request without credentials isn't a
failure: phones ask that way and retry after the 401.

Templates (pure functions of a ProvisionContext) hold the account,
voicemail, pickup codes and the provisioning settings; BLF, time zone
and phonebook come with M10-T2.

Web: Phones page (table, drawer with what to type into the phone,
config preview; manual settings for generic phones). e2e m10-t1: phone
added in the UI, 401 then the file, and a baresip phone registers with
only the account from it.

Also: the loadSnapshot test made its database inside the test (20 s);
under a full parallel run that timed out now and then. It now does it
in a hook like the others.

Task: M10-T1
This commit is contained in:
iamdoubz committed 2026-10-19 13:38:18 -05:00
1 parent dc79120de0
commit fa63908261
22 files changed
+1266 -43

No files matched your search

-1
View File
@@ -25,7 +25,6 @@ COPY --from=build /out/node_modules ./node_modules
COPY --from=build /src/apps/api/dist ./dist
COPY --from=build /src/apps/web/dist ./public
COPY --from=build /src/apps/api/src/db/migrations ./dist/db/migrations
# ponytail: provisioning templates (M10) get their COPY line when they exist.
ENV NODE_ENV=production
ENTRYPOINT ["/usr/bin/tini","--"]
CMD ["node","dist/index.js"]
+4
View File
@@ -27,6 +27,8 @@ import { queueRoutes } from './routes/queues.ts';
import { conferenceRoutes } from './routes/conferences.ts';
import { pagingRoutes } from './routes/paging.ts';
import { mohRoutes } from './routes/moh.ts';
import { deviceRoutes } from './routes/devices.ts';
import { provisioningRoutes } from './routes/provisioning.ts';
import { ivrRoutes } from './routes/ivrs.ts';
import { timeConditionRoutes } from './routes/timeconditions.ts';
import { announcementRoutes } from './routes/announcements.ts';
@@ -112,6 +114,8 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
app.register(conferenceRoutes);
app.register(pagingRoutes);
app.register(mohRoutes);
app.register(deviceRoutes);
app.register(provisioningRoutes);
app.register(ivrRoutes);
app.register(timeConditionRoutes);
app.register(announcementRoutes);
+72
View File
@@ -0,0 +1,72 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// What a vendor template gets (07 §1). Templates are pure functions of this; no I/O there.
// ponytail: M10-T1 has what a phone needs to register and stay provisioned; BLF keys, time zone
// and the phonebook (07 §3) arrive with M10-T2.
import { eq } from 'drizzle-orm';
import type { PhoneVendor } from '@selfphoneng/shared';
import type { Config } from '../config.ts';
import { decrypt } from '../crypto/secrets.ts';
import type { Db } from '../db/client.ts';
import { devices, extensions } from '../db/schema.ts';
import { getSetting } from '../services/settings.ts';
export type ProvisionContext = {
mac: string;
vendor: PhoneVendor;
model: string | null;
host: string; // PUBLIC_HOSTNAME
sipPort: number;
transport: 'udp';
ext: { number: string; name: string; sipPassword: string };
voicemailNumber: '*97';
provUrl: string;
provUser: string;
provPassword: string;
};
export const provisioningUrl = (config: Pick<Config, 'PUBLIC_HOSTNAME'>) =>
`https://${config.PUBLIC_HOSTNAME}/prov/`;
/** The device with this MAC and its extension, or null. */
export async function loadContext(
db: Db,
config: Pick<Config, 'PUBLIC_HOSTNAME'>,
mac: string,
): Promise<{ id: string; ctx: ProvisionContext } | null> {
const [row] = await db
.select({ d: devices, e: extensions })
.from(devices)
.innerJoin(extensions, eq(extensions.id, devices.extensionId))
.where(eq(devices.mac, mac));
if (!row) return null;
const { d, e } = row;
return {
id: d.id,
ctx: {
mac: d.mac,
vendor: d.vendor as PhoneVendor,
model: d.model,
host: config.PUBLIC_HOSTNAME,
// ponytail: desk phones register over UDP (they sit on the LAN); TLS when someone needs it.
sipPort: await getSetting(db, 'sip.udpPort'),
transport: 'udp',
ext: { number: e.number, name: e.name, sipPassword: decrypt(e.sipPasswordEnc) },
voicemailNumber: '*97',
provUrl: provisioningUrl(config),
provUser: d.mac,
provPassword: decrypt(d.provisioningPasswordEnc),
},
};
}
/** One line of text for a config value: no line breaks or control characters. */
// eslint-disable-next-line no-control-regex -- removing control characters is the point
export const oneLine = (v: string) => v.replace(/[\u0000-\u001f\u007f]/g, ' ');
export const xml = (v: string) =>
oneLine(v)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
+43
View File
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /prov limits (07 §2): 20 requests per 10 min per IP; 10 failures (unknown MAC, wrong password)
// block the IP for an hour. Phones on the local nets skip the request limit: an office's phones
// all rebooting at once would pass 20 in a minute. Failures count for everyone.
// ponytail: in-process memory like the login limiter; resets on restart (v1 has one api process).
const MIN = 60_000;
const REQUESTS = { max: 20, windowMs: 10 * MIN };
const FAILURES = { max: 10, windowMs: 60 * MIN, blockMs: 60 * MIN };
const requests = new Map<string, number[]>();
const failures = new Map<string, number[]>();
const blockedUntil = new Map<string, number>();
const recent = (m: Map<string, number[]>, ip: string, windowMs: number, now: number) => {
const list = (m.get(ip) ?? []).filter((t) => now - t < windowMs);
if (list.length) m.set(ip, list);
else m.delete(ip);
return list;
};
/** Counts this request; false when the IP is blocked or over its limit. */
export function allowProvRequest(ip: string, local: boolean, now = Date.now()): boolean {
if ((blockedUntil.get(ip) ?? 0) > now) return false;
blockedUntil.delete(ip);
if (local) return true;
const list = recent(requests, ip, REQUESTS.windowMs, now);
if (list.length >= REQUESTS.max) return false;
requests.set(ip, [...list, now]);
return true;
}
export function recordProvFailure(ip: string, now = Date.now()): void {
const list = [...recent(failures, ip, FAILURES.windowMs, now), now];
failures.set(ip, list);
if (list.length >= FAILURES.max) blockedUntil.set(ip, now + FAILURES.blockMs);
}
/** Tests only. */
export function resetProvLimiter(): void {
requests.clear();
failures.clear();
blockedUntil.clear();
}
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Grandstream GRP/GXP cfg<mac>.xml (07 §3.2). P-values from Grandstream's docs, (verify per
// model). BLF/MPK keys differ by model family: left out of v1.
import { xml, type ProvisionContext } from '../context.ts';
const TRANSPORT = { udp: 0, tcp: 1, tls: 2 } as const;
export function grandstream(c: ProvisionContext): string {
const p = (n: number, v: string | number, note: string) =>
` <P${n}>${xml(String(v))}</P${n}>${' '.repeat(Math.max(1, 34 - String(v).length))}<!-- ${note} -->`;
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<gs_provision version="1">',
` <mac>${c.mac}</mac>`,
' <config version="1">',
p(271, 1, 'account 1 active'),
p(270, `${c.ext.number} ${c.ext.name}`, 'account name'),
p(47, `${c.host}:${c.sipPort}`, 'SIP server'),
p(35, c.ext.number, 'SIP user ID'),
p(36, c.ext.number, 'auth ID'),
p(34, c.ext.sipPassword, 'password'),
p(3, c.ext.name, 'display name'),
p(33, c.voicemailNumber, 'voicemail'),
p(130, TRANSPORT[c.transport], 'SIP transport'),
p(212, 2, 'config upgrade via HTTPS'),
p(237, `${c.host}/prov`, 'config server path'),
p(1360, c.provUser, 'HTTP(S) username'),
p(1361, c.provPassword, 'HTTP(S) password'),
' </config>',
'</gs_provision>',
'',
].join('\n');
}
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Yealink T3x/T4x/T5x <mac>.cfg (07 §3.1). Key names from Yealink's docs, (verify per model).
import { oneLine, type ProvisionContext } from '../context.ts';
const TRANSPORT = { udp: 0, tcp: 1, tls: 2 } as const;
export function yealink(c: ProvisionContext): string {
const v = oneLine;
return [
'#!version:1.0.0.1',
'## account 1',
'account.1.enable = 1',
`account.1.label = ${v(`${c.ext.number} ${c.ext.name}`)}`,
`account.1.display_name = ${v(c.ext.name)}`,
`account.1.auth_name = ${c.ext.number}`,
`account.1.user_name = ${c.ext.number}`,
`account.1.password = ${c.ext.sipPassword}`,
`account.1.sip_server.1.address = ${c.host}`,
`account.1.sip_server.1.port = ${c.sipPort}`,
`account.1.sip_server.1.transport_type = ${TRANSPORT[c.transport]}`,
'account.1.sip_server.1.expires = 300',
'account.1.dtmf.type = 1',
'account.1.subscribe_mwi_to_vm = 1',
`voice_mail.number.1 = ${c.voicemailNumber}`,
'features.pickup.direct_pickup_enable = 1',
'features.pickup.direct_pickup_code = **',
'features.pickup.group_pickup_enable = 1',
'features.pickup.group_pickup_code = *8',
'## keep provisioning',
`static.auto_provision.server.url = ${c.provUrl}`,
`static.auto_provision.server.username = ${c.provUser}`,
`static.auto_provision.server.password = ${c.provPassword}`,
'static.auto_provision.repeat.enable = 1',
'static.auto_provision.repeat.minutes = 1440',
'',
].join('\n');
}
/** y0000000000<xx>.cfg, the model-wide file: nothing in it, but it must be valid. */
export const yealinkCommon = () => '#!version:1.0.0.1\n';
+164
View File
@@ -0,0 +1,164 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/devices (04 §7.3, 07 §1): desk phones. The provisioning password (20 chars) is made here,
// stored encrypted, and shown to admins: they type it into the phone. Admin only.
import { asc, eq } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
DeviceCreateSchema,
DeviceUpdateSchema,
type DeviceDto,
type ListResponse,
} from '@selfphoneng/shared';
import { requireAdmin } from '../auth/guards.ts';
import { decrypt, encrypt, randomAlnum } from '../crypto/secrets.ts';
import type { Db } from '../db/client.ts';
import { devices, extensions } from '../db/schema.ts';
import { badRequest, conflict, notFound } from '../errors.ts';
import { loadContext, provisioningUrl } from '../provisioning/context.ts';
import { grandstream } from '../provisioning/templates/grandstream.ts';
import { yealink } from '../provisioning/templates/yealink.ts';
import { audit } from '../services/audit.ts';
type Row = typeof devices.$inferSelect;
type Ext = { number: string; name: string };
const IdParams = z.object({ id: z.uuid() });
const isUniqueViolation = (e: unknown) =>
((e as { cause?: { code?: string } }).cause?.code ?? (e as { code?: string }).code) === '23505';
const fmtMac = (m: string) => m.match(/../g)!.join(':');
export async function deviceRoutes(app: FastifyInstance) {
const { db } = app;
app.addHook('preHandler', requireAdmin);
const toDto = (d: Row, e: Ext): DeviceDto => ({
id: d.id,
mac: d.mac,
vendor: d.vendor as DeviceDto['vendor'],
model: d.model,
label: d.label,
extensionId: d.extensionId,
extensionNumber: e.number,
extensionName: e.name,
provisioningUrl: provisioningUrl(app.config),
provisioningPassword: decrypt(d.provisioningPasswordEnc),
lastProvisionedAt: d.lastProvisionedAt?.toISOString() ?? null,
lastProvisionedIp: d.lastProvisionedIp,
createdAt: d.createdAt.toISOString(),
});
async function getDevice(id: string) {
const [r] = await db
.select({ d: devices, e: { number: extensions.number, name: extensions.name } })
.from(devices)
.innerJoin(extensions, eq(extensions.id, devices.extensionId))
.where(eq(devices.id, id));
if (!r) throw notFound('Phone not found');
return r;
}
async function getExt(dbx: Db, id: string): Promise<Ext> {
const [e] = await dbx
.select({ number: extensions.number, name: extensions.name })
.from(extensions)
.where(eq(extensions.id, id));
if (!e) throw badRequest('That user no longer exists');
return e;
}
app.get('/api/devices', async (): Promise<ListResponse<DeviceDto>> => {
const rows = await db
.select({ d: devices, e: { number: extensions.number, name: extensions.name } })
.from(devices)
.innerJoin(extensions, eq(extensions.id, devices.extensionId))
.orderBy(asc(extensions.number), asc(devices.mac));
return { items: rows.map((r) => toDto(r.d, r.e)), nextCursor: null };
});
app.get('/api/devices/:id', async (req) => {
const r = await getDevice(IdParams.parse(req.params).id);
return toDto(r.d, r.e);
});
app.post('/api/devices', async (req, reply) => {
const body = DeviceCreateSchema.parse(req.body);
const e = await getExt(db, body.extensionId);
let d: Row;
try {
[d] = (await db
.insert(devices)
.values({ ...body, provisioningPasswordEnc: encrypt(randomAlnum(20)) })
.returning()) as [Row];
} catch (err) {
if (isUniqueViolation(err)) throw conflict(`${fmtMac(body.mac)} is already added`);
throw err;
}
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'create',
entityType: 'device',
entityId: d.id,
summary: `Added ${body.vendor} phone ${fmtMac(d.mac)} for ${e.number} (${e.name})`,
diff: { after: body },
});
return reply.code(201).send(toDto(d, e));
});
app.patch('/api/devices/:id', async (req) => {
const { id } = IdParams.parse(req.params);
const body = DeviceUpdateSchema.parse(req.body);
await getDevice(id);
const e = body.extensionId ? await getExt(db, body.extensionId) : null;
let d: Row;
try {
[d] = (await db.update(devices).set(body).where(eq(devices.id, id)).returning()) as [Row];
} catch (err) {
if (isUniqueViolation(err)) throw conflict(`${fmtMac(body.mac!)} is already added`);
throw err;
}
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'update',
entityType: 'device',
entityId: id,
summary: `Updated phone ${fmtMac(d.mac)}`,
diff: { after: body },
});
return toDto(d, e ?? (await getDevice(id)).e);
});
app.delete('/api/devices/:id', async (req) => {
const { id } = IdParams.parse(req.params);
const { d } = await getDevice(id);
await db.delete(devices).where(eq(devices.id, id));
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'delete',
entityType: 'device',
entityId: id,
summary: `Removed phone ${fmtMac(d.mac)}`,
});
return { ok: true };
});
/** The file the phone would get, passwords masked (04 §7.3). */
app.get('/api/devices/:id/config-preview', async (req) => {
const { d } = await getDevice(IdParams.parse(req.params).id);
const render =
d.vendor === 'yealink' ? yealink : d.vendor === 'grandstream' ? grandstream : null;
if (!render) throw badRequest('Nothing is served to this kind of phone: set it up by hand');
const { ctx } = (await loadContext(db, app.config, d.mac))!;
const masked = '********';
return {
file: d.vendor === 'yealink' ? `${d.mac}.cfg` : `cfg${d.mac}.xml`,
content: render({
...ctx,
ext: { ...ctx.ext, sipPassword: masked },
provPassword: masked,
}),
};
});
}
+112
View File
@@ -0,0 +1,112 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// GET /prov/... (07 §2): phones fetch their config with HTTP Basic auth (user = MAC, password =
// the device's provisioning password). No session. Unknown MAC → 404, bad auth → 401; both are
// logged with the IP and count toward an IP block (provisioning/limiter.ts).
import { timingSafeEqual } from 'node:crypto';
import { BlockList, isIPv4 } from 'node:net';
import { eq, sql } from 'drizzle-orm';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { devices } from '../db/schema.ts';
import { loadContext, type ProvisionContext } from '../provisioning/context.ts';
import { allowProvRequest, recordProvFailure } from '../provisioning/limiter.ts';
import { grandstream } from '../provisioning/templates/grandstream.ts';
import { yealink, yealinkCommon } from '../provisioning/templates/yealink.ts';
import { getSetting } from '../services/settings.ts';
/** File name → what it is. Anything else is a plain 404 (phones ask for files we don't serve). */
const FILES: {
re: RegExp;
vendor: ProvisionContext['vendor'];
render: (c: ProvisionContext) => string;
type: string;
}[] = [
{ re: /^([0-9a-f]{12})\.cfg$/, vendor: 'yealink', render: yealink, type: 'text/plain' },
{
re: /^cfg([0-9a-f]{12})\.xml$/,
vendor: 'grandstream',
render: grandstream,
type: 'application/xml',
},
];
const YEALINK_COMMON = /^y0{10}[0-9a-f]{2}\.cfg$/;
/** The Basic auth credentials, or null when there are none. */
function basicAuth(req: FastifyRequest): { user: string; pass: string } | null {
const m = /^Basic ([A-Za-z0-9+/=]+)$/.exec(req.headers.authorization ?? '');
if (!m) return null;
const s = Buffer.from(m[1]!, 'base64').toString('utf8');
const i = s.indexOf(':');
return i < 0 ? null : { user: s.slice(0, i), pass: s.slice(i + 1) };
}
const same = (a: string, b: string) => {
const x = Buffer.from(a);
const y = Buffer.from(b);
return x.length === y.length && timingSafeEqual(x, y);
};
export async function provisioningRoutes(app: FastifyInstance) {
const { db } = app;
const ipv4 = (ip: string) => ip.replace(/^::ffff:/, '');
async function isLocal(ip: string) {
const v4 = ipv4(ip);
if (!isIPv4(v4)) return false;
const nets = new BlockList();
for (const n of await getSetting(db, 'network.localNets')) {
const [addr, bits] = n.split('/');
nets.addSubnet(addr!, Number(bits), 'ipv4');
}
return nets.check(v4, 'ipv4');
}
const deny = (reply: FastifyReply, code: number, text: string) =>
reply.code(code).header('Cache-Control', 'no-store').type('text/plain').send(text);
app.get('/prov/*', async (req, reply) => {
if (!(await getSetting(db, 'provisioning.enabled'))) return deny(reply, 404, 'Not found');
const local = await isLocal(req.ip);
if ((await getSetting(db, 'provisioning.lanOnly')) && !local)
return deny(reply, 403, 'Forbidden');
if (!allowProvRequest(req.ip, local)) return deny(reply, 429, 'Too many requests');
const file = (req.params as { '*': string })['*'].toLowerCase();
if (YEALINK_COMMON.test(file))
return reply.header('Cache-Control', 'no-store').type('text/plain').send(yealinkCommon());
const kind = FILES.map((f) => ({ f, m: f.re.exec(file) })).find((x) => x.m);
if (!kind) return deny(reply, 404, 'Not found');
const mac = kind.m![1]!;
const found = await loadContext(db, app.config, mac);
if (!found || found.ctx.vendor !== kind.f.vendor) {
recordProvFailure(req.ip);
req.log.warn({ ip: req.ip, mac }, 'provisioning: unknown MAC');
return deny(reply, 404, 'Not found');
}
const auth = basicAuth(req);
const ok =
auth !== null && auth.user.toLowerCase() === mac && same(auth.pass, found.ctx.provPassword);
if (!ok) {
// A phone asks without credentials first and retries after the 401: not a failure.
if (auth) {
recordProvFailure(req.ip);
req.log.warn({ ip: req.ip, mac }, 'provisioning: wrong credentials');
}
return reply
.code(401)
.header('WWW-Authenticate', 'Basic realm="selfphoneng"')
.header('Cache-Control', 'no-store')
.type('text/plain')
.send('Unauthorized');
}
await db
.update(devices)
.set({ lastProvisionedAt: sql`now()`, lastProvisionedIp: ipv4(req.ip) })
.where(eq(devices.id, found.id));
req.log.info({ ip: req.ip, mac }, 'provisioning: served');
return reply
.header('Cache-Control', 'no-store')
.type(kind.f.type)
.send(kind.f.render(found.ctx));
});
}
+193
View File
@@ -0,0 +1,193 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M10-T1: /api/devices and /prov (07 §1–§2): Basic auth, unknown MAC, rate limit and IP block,
// LAN-only, what's served.
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { eq } from 'drizzle-orm';
import { devices } from '../src/db/schema.ts';
import { resetProvLimiter } from '../src/provisioning/limiter.ts';
import { setSetting } from '../src/services/settings.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
describe.skipIf(!hasTestDb)('/api/devices and /prov', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let req: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
let extId: string;
let sipPassword: string;
let device: { id: string; mac: string; provisioningPassword: string };
/** A phone's request from `ip`, with Basic auth when `pass` is given. */
const prov = (file: string, ip = '192.168.1.20', pass?: string, user = device.mac) =>
t.app.inject({
method: 'GET',
url: `/prov/${file}`,
headers: {
'x-forwarded-for': ip,
...(pass !== undefined
? { authorization: `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}` }
: {}),
},
});
beforeAll(async () => {
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
const cookie = await t.login(ADMIN.email, ADMIN.password);
req = (method, url, payload) =>
t.app.inject({
method: method as 'GET',
url,
payload: payload as object,
headers: { ...H, cookie },
});
extId = (await req('POST', '/api/extensions', { number: '101', name: 'Dan "D" <Doe>' })).json()
.id;
sipPassword = (await req('GET', `/api/extensions/${extId}/sip-credentials`)).json().password;
});
afterAll(() => t?.close());
beforeEach(() => resetProvLimiter());
it('adds a phone: MAC in any format, a provisioning password, the URL to type in', async () => {
const r = await req('POST', '/api/devices', {
mac: '00:15:65:AB:CD:EF',
vendor: 'yealink',
model: 'T54W',
extensionId: extId,
});
expect(r.statusCode, r.body).toBe(201);
device = r.json();
expect(device).toMatchObject({
mac: '001565abcdef',
extensionNumber: '101',
provisioningUrl: 'https://pbx.test/prov/',
lastProvisionedAt: null,
});
expect(device.provisioningPassword).toMatch(/^[A-Za-z0-9]{20}$/);
const [row] = await t.db.select().from(devices);
expect(row!.provisioningPasswordEnc).not.toContain(device.provisioningPassword);
const again = await req('POST', '/api/devices', {
mac: '0015.65ab.cdef',
vendor: 'yealink',
extensionId: extId,
});
expect(again.statusCode).toBe(409);
for (const mac of ['00:15:65:ab:cd', 'zz:15:65:ab:cd:ef', '0015-65ab-cdef-00'])
expect(
(await req('POST', '/api/devices', { mac, vendor: 'yealink', extensionId: extId }))
.statusCode,
mac,
).toBe(400);
});
it('serves the Yealink file with Basic auth; asks for it first without counting a failure', async () => {
const first = await prov(`${device.mac}.cfg`);
expect(first.statusCode).toBe(401);
expect(first.headers['www-authenticate']).toBe('Basic realm="selfphoneng"');
const r = await prov(`${device.mac}.cfg`, '192.168.1.20', device.provisioningPassword);
expect(r.statusCode).toBe(200);
expect(r.headers['cache-control']).toBe('no-store');
expect(r.body).toContain('account.1.user_name = 101');
expect(r.body).toContain(`account.1.password = ${sipPassword}`);
expect(r.body).toContain('account.1.display_name = Dan "D" <Doe>');
expect(r.body).toContain('static.auto_provision.server.url = https://pbx.test/prov/');
// the phone may send its MAC upper case (Basic auth can't carry colons in the user name)
const upper = await prov(
`${device.mac.toUpperCase()}.cfg`,
'192.168.1.20',
device.provisioningPassword,
device.mac.toUpperCase(),
);
expect(upper.statusCode).toBe(200);
const [row] = await t.db.select().from(devices).where(eq(devices.id, device.id));
expect(row!.lastProvisionedAt).not.toBeNull();
expect(row!.lastProvisionedIp).toBe('192.168.1.20');
// the common file needs no password (it's empty)
expect((await prov('y000000000044.cfg')).body).toBe('#!version:1.0.0.1\n');
});
it('unknown MAC and the other vendor’s file are 404; ten failures block the IP', async () => {
expect((await prov('001565000000.cfg')).statusCode).toBe(404);
expect((await prov(`cfg${device.mac}.xml`)).statusCode).toBe(404); // a Yealink here
expect((await prov('anything.boot')).statusCode).toBe(404);
for (let i = 0; i < 8; i++)
expect((await prov(`${device.mac}.cfg`, '192.168.1.20', 'guess')).statusCode).toBe(401);
// 10 failures (2 unknown MACs, 8 wrong passwords): even the right password is refused now
expect(
(await prov(`${device.mac}.cfg`, '192.168.1.20', device.provisioningPassword)).statusCode,
).toBe(429);
// another phone is fine
expect(
(await prov(`${device.mac}.cfg`, '192.168.1.21', device.provisioningPassword)).statusCode,
).toBe(200);
});
it('outside the local nets: 20 requests per 10 minutes; LAN-only refuses them', async () => {
for (let i = 0; i < 20; i++)
expect(
(await prov(`${device.mac}.cfg`, '203.0.113.9', device.provisioningPassword)).statusCode,
).toBe(200);
expect(
(await prov(`${device.mac}.cfg`, '203.0.113.9', device.provisioningPassword)).statusCode,
).toBe(429);
await setSetting(t.db, 'provisioning.lanOnly', true);
expect(
(await prov(`${device.mac}.cfg`, '198.51.100.7', device.provisioningPassword)).statusCode,
).toBe(403);
expect(
(await prov(`${device.mac}.cfg`, '10.1.2.3', device.provisioningPassword)).statusCode,
).toBe(200);
await setSetting(t.db, 'provisioning.lanOnly', false);
await setSetting(t.db, 'provisioning.enabled', false);
expect(
(await prov(`${device.mac}.cfg`, '10.1.2.3', device.provisioningPassword)).statusCode,
).toBe(404);
await setSetting(t.db, 'provisioning.enabled', true);
});
it('Grandstream: XML, values escaped', async () => {
const r = await req('POST', '/api/devices', {
mac: '000B82123456',
vendor: 'grandstream',
extensionId: extId,
});
const gs = r.json();
const file = await prov(
'cfg000b82123456.xml',
'192.168.1.30',
gs.provisioningPassword,
'000b82123456',
);
expect(file.statusCode).toBe(200);
expect(file.headers['content-type']).toMatch(/application\/xml/);
expect(file.body).toContain('<P35>101</P35>');
expect(file.body).toContain('<P3>Dan &quot;D&quot; &lt;Doe&gt;</P3>');
expect(file.body).toContain(`<P1361>${gs.provisioningPassword}</P1361>`);
});
it('config preview masks the passwords; generic phones have no file', async () => {
const p = (await req('GET', `/api/devices/${device.id}/config-preview`)).json();
expect(p.file).toBe('001565abcdef.cfg');
expect(p.content).toContain('account.1.password = ********');
expect(p.content).not.toContain(sipPassword);
expect(p.content).not.toContain(device.provisioningPassword);
const generic = (
await req('POST', '/api/devices', {
mac: '112233445566',
vendor: 'generic',
extensionId: extId,
})
).json();
expect((await req('GET', `/api/devices/${generic.id}/config-preview`)).statusCode).toBe(400);
expect(
(await prov('112233445566.cfg', '192.168.1.40', generic.provisioningPassword, '112233445566'))
.statusCode,
).toBe(404);
});
it('delete: the phone gets nothing any more', async () => {
expect((await req('DELETE', `/api/devices/${device.id}`)).statusCode).toBe(200);
expect(
(await prov(`${device.mac}.cfg`, '192.168.1.50', device.provisioningPassword)).statusCode,
).toBe(404);
});
});
+42 -38
View File
@@ -202,45 +202,49 @@ describe.skipIf(!hasTestDb)('generator apply', () => {
});
describe.skipIf(!hasTestDb)('loadSnapshot', () => {
// The database is made in a hook (60 s): under a full parallel run, creating and migrating it
// can pass the 20 s a test gets.
let testDb: Awaited<ReturnType<typeof createTestDb>>;
beforeAll(async () => {
testDb = await createTestDb();
});
afterAll(() => testDb?.drop());
it('decrypts SIP passwords and resolves caller ID DIDs and admin flags', async () => {
const { db, drop } = await createTestDb();
try {
const { setSecretKey, encrypt } = await import('../src/crypto/secrets.ts');
setSecretKey(Buffer.alloc(32, 3).toString('base64'));
const s = await import('../src/db/schema.ts');
const [u] = await db
.insert(s.users)
.values({ email: 'a@example.com', displayName: 'A', passwordHash: 'x', role: 'admin' })
.returning();
const [t] = await db
.insert(s.trunks)
.values({ name: 't', serverHost: 'h.example' })
.returning();
const [d] = await db
.insert(s.dids)
.values({ number: '13125551234', trunkId: t!.id, destination: { type: 'hangup' } })
.returning();
await db.insert(s.extensions).values({
const { db } = testDb;
const { setSecretKey, encrypt } = await import('../src/crypto/secrets.ts');
setSecretKey(Buffer.alloc(32, 3).toString('base64'));
const s = await import('../src/db/schema.ts');
const [u] = await db
.insert(s.users)
.values({ email: 'a@example.com', displayName: 'A', passwordHash: 'x', role: 'admin' })
.returning();
const [t] = await db
.insert(s.trunks)
.values({ name: 't', serverHost: 'h.example' })
.returning();
const [d] = await db
.insert(s.dids)
.values({ number: '13125551234', trunkId: t!.id, destination: { type: 'hangup' } })
.returning();
await db.insert(s.extensions).values({
number: '101',
userId: u!.id,
name: 'Dan',
sipPasswordEnc: encrypt('Ab3dEf9hIj2kLm4nOp6qRs8t'),
voicemailPinEnc: encrypt('482913'),
outboundCallerIdDidId: d!.id,
});
const snap = await loadSnapshot(db, new Date('2026-10-17T12:00:00Z'));
expect(snap.today).toBe('2026-10-17');
expect(snap.extensions).toEqual([
expect.objectContaining({
number: '101',
userId: u!.id,
name: 'Dan',
sipPasswordEnc: encrypt('Ab3dEf9hIj2kLm4nOp6qRs8t'),
voicemailPinEnc: encrypt('482913'),
outboundCallerIdDidId: d!.id,
});
const snap = await loadSnapshot(db, new Date('2026-10-17T12:00:00Z'));
expect(snap.today).toBe('2026-10-17');
expect(snap.extensions).toEqual([
expect.objectContaining({
number: '101',
sipPassword: 'Ab3dEf9hIj2kLm4nOp6qRs8t',
outboundCallerId: '13125551234',
emergencyCallerId: null,
isAdmin: true,
}),
]);
} finally {
await drop();
}
sipPassword: 'Ab3dEf9hIj2kLm4nOp6qRs8t',
outboundCallerId: '13125551234',
emergencyCallerId: null,
isAdmin: true,
}),
]);
});
});
+12
View File
@@ -34,6 +34,9 @@ import type {
ConferenceCreate,
ConferenceDto,
ConferenceUpdate,
DeviceCreate,
DeviceDto,
DeviceUpdate,
PagingGroupCreate,
PagingGroupDto,
PagingGroupUpdate,
@@ -287,6 +290,15 @@ export const conferencesApi = {
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/conferences/${id}`),
};
export const devicesApi = {
list: () => api<ListResponse<DeviceDto>>('GET', '/api/devices').then((r) => r.items),
create: (d: DeviceCreate) => api<DeviceDto>('POST', '/api/devices', d),
update: (id: string, d: DeviceUpdate) => api<DeviceDto>('PATCH', `/api/devices/${id}`, d),
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/devices/${id}`),
configPreview: (id: string) =>
api<{ file: string; content: string }>('GET', `/api/devices/${id}/config-preview`),
};
export const pagingApi = {
list: () => api<ListResponse<PagingGroupDto>>('GET', '/api/paging-groups').then((r) => r.items),
create: (g: PagingGroupCreate) => api<PagingGroupDto>('POST', '/api/paging-groups', g),
+3
View File
@@ -19,6 +19,7 @@ import { RingGroupsPage } from './pages/admin/RingGroupsPage.tsx';
import { QueuesPage } from './pages/admin/QueuesPage.tsx';
import { ConferencesPage } from './pages/admin/ConferencesPage.tsx';
import { PagingPage } from './pages/admin/PagingPage.tsx';
import { PhonesPage } from './pages/admin/PhonesPage.tsx';
import { IvrsPage } from './pages/admin/IvrsPage.tsx';
import { OfficeHoursPage } from './pages/admin/OfficeHoursPage.tsx';
import { BlockedPage } from './pages/admin/BlockedPage.tsx';
@@ -61,6 +62,8 @@ const router = createBrowserRouter([
<ConferencesPage />
) : p.path === 'paging' ? (
<PagingPage />
) : p.path === 'devices' ? (
<PhonesPage />
) : p.path === 'ivrs' ? (
<IvrsPage />
) : p.path === 'hours' ? (
+1 -1
View File
@@ -22,7 +22,7 @@ export const ADMIN_PAGES = [
{ path: 'conferences', label: 'Conferences', milestone: null },
{ path: 'paging', label: 'Paging', milestone: null },
{ path: 'sounds', label: 'Sounds & music', milestone: null },
{ path: 'devices', label: 'Phones', milestone: 'M10' },
{ path: 'devices', label: 'Phones', milestone: null },
{ path: 'contacts', label: 'Contacts', milestone: 'M10' },
{ path: 'calls', label: 'Call history', milestone: null },
{ path: 'recordings', label: 'Recordings', milestone: null },
+327
View File
@@ -0,0 +1,327 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Phones (05 §2, 07): desk phones the PBX sets up. Table: MAC, vendor, model, user, last
// provisioned. The drawer shows what to type into the phone, a config preview, and for generic
// phones the manual settings (07 §4).
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import {
DeviceCreateSchema,
PHONE_VENDORS,
type DeviceCreate,
type DeviceDto,
type PhoneVendor,
} from '@selfphoneng/shared';
import { devicesApi, extensionsApi } from '../../api/client.ts';
import { ConfirmDialog, Dialog } from '../../components/Dialog.tsx';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
const VENDOR: Record<PhoneVendor, string> = {
yealink: 'Yealink',
grandstream: 'Grandstream',
generic: 'Other (set up by hand)',
};
const fmtMac = (m: string) => m.match(/../g)!.join(':');
export function PhonesPage() {
const qc = useQueryClient();
const phones = useQuery({ queryKey: ['devices'], queryFn: devicesApi.list });
const [editing, setEditing] = useState<DeviceDto | 'new' | null>(null);
const [deleting, setDeleting] = useState<DeviceDto | null>(null);
const refresh = () => qc.invalidateQueries({ queryKey: ['devices'] });
const remove = useMutation({
mutationFn: (id: string) => devicesApi.remove(id),
onSuccess: async () => {
setDeleting(null);
await refresh();
},
});
return (
<section className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="text-xl font-semibold">Phones</h1>
<Button variant="primary" onClick={() => setEditing('new')}>
Add phone
</Button>
</div>
<p className="max-w-prose text-sm text-zinc-500">
Add a desk phone by its MAC address (on a sticker under the phone) and it sets itself up:
account, voicemail and pickup keys.
</p>
{phones.isError && <Alert>{phones.error.message}</Alert>}
{phones.data?.length === 0 && <p className="text-sm text-zinc-500">No phones yet.</p>}
{phones.data && phones.data.length > 0 && (
<div className="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-800">
<table className="w-full text-left text-sm">
<thead className="text-zinc-500">
<tr>
<th className="px-3 py-2 font-medium">MAC</th>
<th className="px-3 py-2 font-medium">Phone</th>
<th className="px-3 py-2 font-medium">User</th>
<th className="px-3 py-2 font-medium">Last set up</th>
<th className="px-3 py-2" />
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
{phones.data.map((d) => (
<tr key={d.id}>
<td className="px-3 py-2 font-mono">{fmtMac(d.mac)}</td>
<td className="px-3 py-2">
{VENDOR[d.vendor].replace(' (set up by hand)', '')} {d.model}
{d.label && <span className="text-zinc-500"> · {d.label}</span>}
</td>
<td className="px-3 py-2">
{d.extensionNumber} {d.extensionName}
</td>
<td className="px-3 py-2 text-zinc-500">
{d.lastProvisionedAt
? `${new Date(d.lastProvisionedAt).toLocaleString()} from ${d.lastProvisionedIp}`
: 'Never'}
</td>
<td className="px-3 py-2 text-right whitespace-nowrap">
<Button onClick={() => setEditing(d)}>Open</Button>{' '}
<Button variant="ghost" onClick={() => setDeleting(d)}>
Delete
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{editing && (
<PhoneDrawer
phone={editing === 'new' ? null : editing}
onClose={() => setEditing(null)}
onSaved={async (d) => {
await refresh();
setEditing(d);
}}
/>
)}
<ConfirmDialog
open={deleting !== null}
title="Delete phone"
message={
<p>Delete phone {deleting && fmtMac(deleting.mac)}? It stops getting its settings.</p>
}
confirmLabel="Delete"
busy={remove.isPending}
error={remove.error?.message}
onCancel={() => {
setDeleting(null);
remove.reset();
}}
onConfirm={() => deleting && remove.mutate(deleting.id)}
/>
</section>
);
}
function PhoneDrawer({
phone,
onClose,
onSaved,
}: {
phone: DeviceDto | null;
onClose: () => void;
onSaved: (d: DeviceDto) => Promise<void>;
}) {
const exts = useQuery({ queryKey: ['extensions'], queryFn: extensionsApi.list });
const [form, setForm] = useState({
mac: phone ? fmtMac(phone.mac) : '',
vendor: phone?.vendor ?? ('yealink' as PhoneVendor),
model: phone?.model ?? '',
label: phone?.label ?? '',
extensionId: phone?.extensionId ?? '',
});
const [errors, setErrors] = useState<Record<string, string>>({});
const save = useMutation({
mutationFn: (body: DeviceCreate) =>
phone ? devicesApi.update(phone.id, body) : devicesApi.create(body),
onSuccess: onSaved,
});
function submit() {
const parsed = DeviceCreateSchema.safeParse({
...form,
model: form.model.trim() || null,
label: form.label.trim() || null,
});
if (!parsed.success) {
setErrors(Object.fromEntries(parsed.error.issues.map((i) => [String(i.path[0]), i.message])));
return;
}
setErrors({});
save.mutate(parsed.data);
}
const set = (k: keyof typeof form) => (v: string) => setForm((f) => ({ ...f, [k]: v }));
return (
<Dialog
open
drawer
onClose={onClose}
title={phone ? `Phone ${fmtMac(phone.mac)}` : 'New phone'}
footer={
<>
<Button onClick={onClose}>Close</Button>
<Button variant="primary" onClick={submit} disabled={save.isPending}>
Save
</Button>
</>
}
>
<div className="flex flex-col gap-4">
{phone && <SetupInfo phone={phone} />}
<Field label="MAC address" error={errors.mac} hint="Like 00:15:65:ab:cd:ef">
{(p) => (
<input
{...p}
className={`${inputClass} font-mono`}
value={form.mac}
onChange={(e) => set('mac')(e.target.value)}
/>
)}
</Field>
<Field label="Make" error={errors.vendor}>
{(p) => (
<select
{...p}
className={inputClass}
value={form.vendor}
onChange={(e) => set('vendor')(e.target.value)}
>
{PHONE_VENDORS.map((v) => (
<option key={v} value={v}>
{VENDOR[v]}
</option>
))}
</select>
)}
</Field>
<Field label="Model" error={errors.model} hint="Optional, like T54W">
{(p) => (
<input
{...p}
className={inputClass}
value={form.model}
onChange={(e) => set('model')(e.target.value)}
/>
)}
</Field>
<Field label="Where it is" error={errors.label} hint="Optional, like Front desk">
{(p) => (
<input
{...p}
className={inputClass}
value={form.label}
onChange={(e) => set('label')(e.target.value)}
/>
)}
</Field>
<Field label="User" error={errors.extensionId}>
{(p) => (
<select
{...p}
className={inputClass}
value={form.extensionId}
onChange={(e) => set('extensionId')(e.target.value)}
>
<option value="">Pick a user…</option>
{exts.data?.map((x) => (
<option key={x.id} value={x.id}>
{x.number} {x.name}
</option>
))}
</select>
)}
</Field>
{form.vendor === 'grandstream' && (
<p className="text-sm text-zinc-500">
Grandstream line keys differ by model, so busy-lamp keys aren't set: add them on the
phone.
</p>
)}
{save.error && <Alert>{save.error.message}</Alert>}
</div>
</Dialog>
);
}
/** What to type into the phone, or (generic) the settings to enter by hand. */
function SetupInfo({ phone }: { phone: DeviceDto }) {
const [preview, setPreview] = useState<string | null>(null);
const load = useMutation({
mutationFn: () => devicesApi.configPreview(phone.id),
onSuccess: (r) => setPreview(r.content),
});
const creds = useQuery({
queryKey: ['sip-credentials', phone.extensionId],
queryFn: () => extensionsApi.credentials(phone.extensionId),
enabled: phone.vendor === 'generic',
});
const row = (k: string, v: string) => (
<div className="contents">
<dt className="text-zinc-500">{k}</dt>
<dd className="font-mono break-all">{v}</dd>
</div>
);
if (phone.vendor === 'generic')
return (
<section
aria-label="Manual settings"
className="rounded-md bg-zinc-50 p-3 text-sm dark:bg-zinc-900"
>
<p className="mb-2">Enter these on the phone:</p>
{creds.data && (
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
{row('Server', creds.data.server)}
{row('Port', String(creds.data.port))}
{row('Transport', creds.data.transport.toUpperCase())}
{row('Username', creds.data.username)}
{row('Password', creds.data.password)}
{row('Voicemail', '*97')}
</dl>
)}
</section>
);
return (
<section
aria-label="Set up the phone"
className="rounded-md bg-zinc-50 p-3 text-sm dark:bg-zinc-900"
>
<p className="mb-2">
In the phone's web page (Settings → Auto Provision), or with DHCP option 66, set:
</p>
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
{row('Server URL', phone.provisioningUrl)}
{row('Username', phone.mac)}
{row('Password', phone.provisioningPassword)}
</dl>
<p className="mt-2 text-zinc-500">
Then reboot the phone.{' '}
{phone.lastProvisionedAt
? 'It last fetched its settings ' +
new Date(phone.lastProvisionedAt).toLocaleString() +
'.'
: 'It hasn’t fetched its settings yet.'}
</p>
<div className="mt-2">
<Button onClick={() => (preview ? setPreview(null) : load.mutate())}>
{preview ? 'Hide config' : 'Preview config'}
</Button>
</div>
{load.error && <Alert>{load.error.message}</Alert>}
{preview && (
<pre
aria-label="Config preview"
className="mt-2 max-h-80 overflow-auto rounded bg-white p-2 text-xs dark:bg-zinc-950"
>
{preview}
</pre>
)}
</section>
);
}