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