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
@@ -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