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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user