provisioning: phones, /prov with Basic auth, rate limit and IP block
/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:
1 parent
dc79120de0
commit
fa63908261
22 files changed
+1266
-43
No files matched your search
@@ -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"]
|
||||
@@ -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);
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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 "D" <Doe></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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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' ? (
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user