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>
);
}
-1
View File
@@ -648,7 +648,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"]
+1 -1
View File
@@ -386,7 +386,7 @@ Admin resources (all `requireAdmin`; standard CRUD = `GET /x`, `POST /x`, `GET /
| `/api/paging-groups` | members are set via `memberIds: [uuid]` in POST/PATCH (no order). Not a destination, so delete never returns 409 |
| `/api/sounds` | upload is `POST /api/sounds?name=&filename=` with the raw file as the body (like greetings, no multipart), `GET /:id/audio`, `PATCH /:id {name}`, `DELETE /:id` (409 with `usedBy`: IVR greeting/invalid sound, announcements, the recording notice), `POST /tts` (M12). M6 lists prompts only; MOH classes and files come with M8 |
| `/api/moh-classes` | `POST {name}` (slug, not `default`), `DELETE /:id` (409 with `usedBy` while an extension or queue uses it), `POST /:id/files?name=&filename=` (raw file body, like sounds), `GET /:id/files/:fileId/audio`, `DELETE /:id/files/:fileId`. No rename: the name is the folder and is stored on extensions and queues. Extensions and queues may only pick `default` or an existing class (400) |
| `/api/devices` | `GET /:id/config-preview` (rendered provisioning file, passwords masked) |
| `/api/devices` | `GET /:id/config-preview` (rendered provisioning file, passwords masked). As built (M10-T1): body `{mac (any format), vendor, model, label, extensionId}`; the DTO carries `provisioningUrl` and the `provisioningPassword` (admins type it into the phone); MAC taken → 409; preview of a generic phone → 400 |
| `/api/blocked-numbers` | |
| `/api/contacts` | company phonebook; `POST /import` CSV (`name,company,email,label1,number1,...`) |
| `/api/webhooks` | `POST /:id/test`, `GET /:id/deliveries` |
+1 -1
View File
@@ -40,7 +40,7 @@ Left sidebar navigation, in this order:
| Conferences | `/admin/conferences` | number, name, PIN, admin PIN, max members, record. As built (M8-T3): admins see the PINs in the list (they hand them out); an admin PIN needs a different room PIN |
| Paging | `/admin/paging` | number, name, members, duplex. As built (M8-T4): members are checkboxes (no order); the empty state explains `*80<ext>` |
| Sounds & music | `/admin/sounds` | Prompts list with play buttons, upload (drag-drop), TTS (M12). MOH classes with their files (M8). As built (M8-T5): a **Music on hold** section: make a set (slug name), upload, play and remove its files, delete it. The user drawer's Phone tab and the queue form pick a set from a select. An **Announcements** section (play a sound, then a destination) lives here too: no page of its own (M6-T5) |
| Phones (devices) | `/admin/devices` | Table: MAC, vendor, model, user, last provisioned. Form: MAC (any format → normalized), vendor, model, extension. Shows the provisioning URL to type into the phone, and "Preview config" |
| Phones (devices) | `/admin/devices` | Table: MAC, vendor, model, user, last provisioned. Form: MAC (any format → normalized), vendor, model, extension. Shows the provisioning URL to type into the phone, and "Preview config". As built (M10-T1): the drawer shows URL, user name (MAC) and password; a generic phone shows the manual settings instead (07 §4); Grandstream gets a note that line keys aren't set |
| Contacts | `/admin/contacts` | Company phonebook, CSV import |
| Call history | `/admin/calls` | Filters + table + CSV export; a row expands to show the CDR legs and plays the recording |
| Recordings | `/admin/recordings` | Searchable list, play, download, delete |
+15
View File
@@ -46,6 +46,21 @@ Rules:
- On success: update `last_provisioned_at`, `last_provisioned_ip`. Set `Cache-Control: no-store`.
- Never serve over plain HTTP. The reverse proxy only listens on 443. (Old phones that can't do modern TLS are not supported.)
As built (M10-T1), `routes/provisioning.ts` + `provisioning/limiter.ts`:
- A request **without** an `Authorization` header gets the 401 but isn't a failure: phones ask without
credentials first and retry after the challenge. Failures are unknown MACs (or a file for another
vendor) and wrong credentials; 10 within an hour block the IP for an hour (429).
- The 20 requests / 10 min limit applies to clients **outside** `network.localNets`: an office's
phones rebooting together behind one address would pass it in a minute. Failures count for all.
- The Basic auth user name is the 12-hex MAC (either case); it can't carry colons (Basic auth splits
at the first one). The Yealink common file `y0000000000xx.cfg` is served without auth (it's empty).
- Unrecognized file names (phones ask for several) are a plain 404 and not a failure.
- Desk phones are provisioned for UDP on `sip.udpPort`. Templates in M10-T1 hold the account,
voicemail, pickup codes and the provisioning settings; BLF keys, time zone and the phonebook come
with M10-T2. `scripts/e2e/m10-t1.mjs` registers a baresip phone with only the account from the
served file.
- Vendors offered: Yealink, Grandstream, generic (Polycom stays a stretch goal).
## 3. Templates
The key names below come from vendor documentation. Each one is marked
+47
View File
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Desk phones the PBX provisions (02 devices, 07). Admin only.
import { z } from 'zod';
export const PHONE_VENDORS = ['yealink', 'grandstream', 'generic'] as const;
export type PhoneVendor = (typeof PHONE_VENDORS)[number];
/** Any way of writing a MAC (00:15:65:AB:CD:EF, 0015.65ab.cdef, …) → 12 lowercase hex. */
export const Mac = z
.string()
.trim()
.transform((v, ctx) => {
const hex = v.toLowerCase().replace(/[^0-9a-f]/g, '');
if (hex.length === 12 && /^[0-9a-f:.\-\s]+$/i.test(v)) return hex;
ctx.addIssue({ code: 'custom', message: 'A MAC address like 00:15:65:ab:cd:ef' });
return z.NEVER;
});
const DeviceFields = z.object({
mac: Mac,
vendor: z.enum(PHONE_VENDORS),
model: z.string().trim().max(40).nullable(),
label: z.string().trim().max(60).nullable(),
extensionId: z.uuid('Pick a user'),
});
export const DeviceCreateSchema = DeviceFields.partial().required({
mac: true,
vendor: true,
extensionId: true,
});
export const DeviceUpdateSchema = DeviceFields.partial();
export type DeviceCreate = z.input<typeof DeviceCreateSchema>;
export type DeviceUpdate = z.input<typeof DeviceUpdateSchema>;
export type DeviceDto = z.output<typeof DeviceFields> & {
id: string;
extensionNumber: string;
extensionName: string;
/** What to type into the phone (or hand out with DHCP option 66). */
provisioningUrl: string;
/** Basic auth for the phone: the MAC and this password. Admins see it to type it in. */
provisioningPassword: string;
lastProvisionedAt: string | null;
lastProvisionedIp: string | null;
createdAt: string;
};
+1
View File
@@ -17,6 +17,7 @@ export * from './ringgroups.ts';
export * from './queues.ts';
export * from './conferences.ts';
export * from './paging.ts';
export * from './devices.ts';
export * from './ivrs.ts';
export * from './timeconditions.ts';
export * from './announcements.ts';
+6
View File
@@ -175,3 +175,9 @@ the right one the list comes from `getServersInfo`. **Import numbers from voip.m
two numbers with its description as the label, the list marks it added, Phone numbers shows it,
and the api log never contains the API password. The trunk is created turned off, so nothing
registers to the real voip.ms.
`m10-t1.mjs` (after `m1-t4.mjs`) adds a Yealink on **Phones** (a short MAC is refused), reads the
URL, user name and password from the drawer, and checks the preview masks the passwords. Then,
as the phone: `/prov/<mac>.cfg` answers 401 without credentials and the file with them, and a
baresip phone given only the account from that file registers. The list then shows when it
fetched its settings.
+149
View File
@@ -0,0 +1,149 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M10-T1: a Yealink added on the Phones page; its drawer shows the URL, user and password to type
// into the phone and a preview with the passwords masked. Then the phone's side: /prov/<mac>.cfg
// answers 401 without credentials and the file with them, and a baresip phone registers with
// nothing but the account from that file. The page then shows when and from where it was fetched.
// No real Yealink here: the hardware check stays open (09 M10).
// Runs on m2-live, after m1-t4.mjs.
// Usage: node scripts/e2e/m10-t1.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir>
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { launch } from './cdp.mjs';
import { startPhones } from './phones.mjs';
const NET = 'sp-live';
const AST = 'sp-live-ast';
const MAC = '001565123456';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const cli = (cmd) =>
spawnSync('docker', ['exec', AST, 'asterisk', '-rx', cmd], { encoding: 'utf8' }).stdout ?? '';
const [base, email, pw, shotsDir] = process.argv.slice(2);
const login = await fetch(`${base}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng' },
body: JSON.stringify({ email, password: pw }),
});
const cookie = login.headers
.getSetCookie()
.map((c) => c.split(';')[0])
.join('; ');
const ext = await (
await fetch(`${base}/api/extensions`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng', cookie },
body: JSON.stringify({ number: '350', name: 'Desk Phone Dee' }),
})
).json();
assert.ok(ext.id, JSON.stringify(ext));
const waitUntil = async (fn, what, ms = 30_000) => {
const end = Date.now() + ms;
while (Date.now() < end) {
const v = await fn();
if (v) return v;
await sleep(300);
}
throw new Error(`timeout waiting for: ${what}`);
};
const pick = (label, text) =>
p.evaluate(`(() => {
const s = [...document.querySelectorAll('dialog[open] select')].find(s => s.labels?.[0]?.textContent === ${JSON.stringify(label)});
const o = [...s.options].find(o => o.textContent.includes(${JSON.stringify(text)}));
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value').set.call(s, o.value);
s.dispatchEvent(new Event('change', { bubbles: true }));
return true;
})()`);
/** The value next to `term` in the drawer's setup box. */
const setupValue = (term) =>
p.evaluate(`(() => {
const dt = [...document.querySelectorAll('[aria-label="Set up the phone"] dt')].find(d => d.textContent === ${JSON.stringify(term)});
return dt?.nextElementSibling?.textContent ?? '';
})()`);
let phones;
const p = await launch({ shotsDir });
try {
await p.goto(`${base}/login`);
await p.waitFor(`!!document.querySelector('input[type=email]')`);
await p.fill('Email', email);
await p.fill('Password', pw);
await p.click('Sign in');
await p.waitFor(`location.pathname === '/admin'`);
// Add the phone.
await p.click('Phones');
await p.click('Add phone');
await p.waitFor(`!!document.querySelector('dialog[open]')`);
await p.fill('MAC address', '00:15:65:12:34:5', 'dialog[open]');
await p.fill('Model', 'T54W', 'dialog[open]');
await p.waitFor(
`[...document.querySelectorAll('dialog[open] option')].some(o => o.textContent.includes('Desk Phone Dee'))`,
'users listed',
);
await pick('User', 'Desk Phone Dee');
await p.click('Save', 'dialog[open]');
await p.waitFor(
`document.querySelector('dialog[open]').innerText.includes('A MAC address like')`,
'short MAC refused',
);
await p.fill('MAC address', '00:15:65:12:34:56', 'dialog[open]');
await p.click('Save', 'dialog[open]');
await p.waitFor(`!!document.querySelector('[aria-label="Set up the phone"]')`, 'saved');
assert.equal(await setupValue('Username'), MAC);
assert.match(await setupValue('Server URL'), /\/prov\/$/);
const provPassword = await setupValue('Password');
assert.match(provPassword, /^[A-Za-z0-9]{20}$/);
await p.click('Preview config', 'dialog[open]');
await p.waitFor(`!!document.querySelector('[aria-label="Config preview"]')`, 'preview');
const preview = await p.evaluate(
`document.querySelector('[aria-label="Config preview"]').textContent`,
);
assert.match(preview, /account\.1\.user_name = 350/);
assert.match(preview, /account\.1\.password = \*{8}/);
assert.ok(!preview.includes(provPassword), 'preview shows the provisioning password');
await p.shot('m10-t1-01-phone');
console.log('phone 00:15:65:12:34:56 added in the UI (a short MAC refused); preview masked');
// The phone's side.
const url = `${base}/prov/${MAC}.cfg`;
assert.equal((await fetch(url)).status, 401, 'no credentials: 401');
const r = await fetch(url, {
headers: { authorization: `Basic ${Buffer.from(`${MAC}:${provPassword}`).toString('base64')}` },
});
assert.equal(r.status, 200);
const file = await r.text();
const val = (k) => new RegExp(`^${k.replace(/\./g, '\\.')} = (.*)$`, 'm').exec(file)?.[1];
assert.equal(val('account.1.user_name'), '350');
console.log('the phone gets 401 without credentials and its file with them');
// A phone that knows only what the file says registers.
phones = startPhones({
name: 'sp-live-phones-m10t1',
net: NET,
ast: AST,
phones: [
{ ext: val('account.1.user_name'), password: val('account.1.password'), answer: 'auto' },
],
});
await waitUntil(
() => /350\/sip:.*Avail/.test(cli('pjsip show contacts')),
'350 registers',
180_000,
);
console.log('a phone with only the account from the file registers as 350');
await p.goto(`${base}/admin/devices`);
await p.waitFor(`document.body.innerText.includes('Yealink T54W')`, 'listed');
assert.doesNotMatch(await p.text(), /\bNever\b/);
await p.shot('m10-t1-02-list');
console.log('Phones lists it with the time and IP it fetched its settings from');
console.log('M10-T1 (phones, provisioning): PASS');
} catch (e) {
await p.shot('m10-t1-fail').catch(() => {});
console.log(`FAIL: ${e.message}`);
console.log(`browser errors: ${JSON.stringify(p.consoleErrors)}`);
process.exitCode = 1;
} finally {
await p.close();
phones?.stop();
}