provisioning: BLF keys, time zone table, company phonebook
Yealink: BLF keys from line key 2, named after the extension (or the number, e.g. a parking slot), with **N pickup; keys up to 33 past the last BLF are cleared so removed BLFs leave the phone. Both vendors get the time zone and NTP, and a phonebook URL that carries the phone's credentials (phones fetch it like the config). Time zones: packages/shared/src/phone-timezones.ts maps the US/Canada zones (with aliases), UTC and London to Yealink's offset and name and a POSIX string (Grandstream P64). Anything else is UTC, and the Phones page warns. /prov/phonebook/<mac>.xml (and <mac>/phonebook.xml, the name Grandstream asks for under its path): every extension, then the company contacts that have numbers, in the phone's vendor's format. Same Basic auth as the config. The config preview now masks both secrets wherever they appear (the phonebook URL carries the provisioning password) and says when the time zone falls back. Golden files for both templates and both phonebooks in src/provisioning/__golden__. e2e m10-t2: BLF keys, Central time, the phonebook fetched from the URL in the config, hints for the BLF targets, and the time zone warning. Vendor key names stay (verify per model) until a real phone is checked. Task: M10-T2
This commit is contained in:
1 parent
fa63908261
commit
6195aca2fb
17 files changed
+608
-27
No files matched your search
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<AddressBook>
|
||||
<Contact><LastName>Ann Lee</LastName><Phone><phonenumber>102</phonenumber><accountindex>1</accountindex></Phone></Contact>
|
||||
<Contact><LastName>Acme <Supplies> & "Parts"</LastName><Phone><phonenumber>13125551234</phonenumber><accountindex>1</accountindex></Phone><Phone><phonenumber>13125559876</phonenumber><accountindex>1</accountindex></Phone></Contact>
|
||||
</AddressBook>
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gs_provision version="1">
|
||||
<mac>001565abcdef</mac>
|
||||
<config version="1">
|
||||
<P271>1</P271> <!-- account 1 active -->
|
||||
<P270>101 Dan "D" <Doe> & Co x = 1</P270> <!-- account name -->
|
||||
<P47>pbx.example.com:5060</P47> <!-- SIP server -->
|
||||
<P35>101</P35> <!-- SIP user ID -->
|
||||
<P36>101</P36> <!-- auth ID -->
|
||||
<P34>Ab3dEf9hIj2kLm4nOp6qRs8t</P34> <!-- password -->
|
||||
<P3>Dan "D" <Doe> & Co x = 1</P3> <!-- display name -->
|
||||
<P33>*97</P33> <!-- voicemail -->
|
||||
<P130>0</P130> <!-- SIP transport -->
|
||||
<P212>2</P212> <!-- config upgrade via HTTPS -->
|
||||
<P237>pbx.example.com/prov</P237> <!-- config server path -->
|
||||
<P1360>001565abcdef</P1360> <!-- HTTP(S) username -->
|
||||
<P1361>Pr0vPassw0rdXyZ12345</P1361> <!-- HTTP(S) password -->
|
||||
<P30>pool.ntp.org</P30> <!-- NTP server -->
|
||||
<P64>CST6CDT,M3.2.0,M11.1.0</P64> <!-- time zone (POSIX) -->
|
||||
<P330>3</P330> <!-- phonebook download: HTTPS -->
|
||||
<P331>001565abcdef:Pr0vPassw0rdXyZ12345@pbx.example.com/prov/phonebook/001565abcdef</P331> <!-- phonebook path -->
|
||||
<P332>1440</P332> <!-- phonebook refresh (minutes) -->
|
||||
</config>
|
||||
</gs_provision>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<YealinkIPPhoneDirectory>
|
||||
<DirectoryEntry><Name>Ann Lee</Name><Telephone label="Extension">102</Telephone></DirectoryEntry>
|
||||
<DirectoryEntry><Name>Acme <Supplies> & "Parts"</Name><Telephone label="office">13125551234</Telephone><Telephone label="mobile">13125559876</Telephone></DirectoryEntry>
|
||||
</YealinkIPPhoneDirectory>
|
||||
@@ -0,0 +1,73 @@
|
||||
#!version:1.0.0.1
|
||||
## account 1
|
||||
account.1.enable = 1
|
||||
account.1.label = 101 Dan "D" <Doe> & Co x = 1
|
||||
account.1.display_name = Dan "D" <Doe> & Co x = 1
|
||||
account.1.auth_name = 101
|
||||
account.1.user_name = 101
|
||||
account.1.password = Ab3dEf9hIj2kLm4nOp6qRs8t
|
||||
account.1.sip_server.1.address = pbx.example.com
|
||||
account.1.sip_server.1.port = 5060
|
||||
account.1.sip_server.1.transport_type = 0
|
||||
account.1.sip_server.1.expires = 300
|
||||
account.1.dtmf.type = 1
|
||||
account.1.subscribe_mwi_to_vm = 1
|
||||
voice_mail.number.1 = *97
|
||||
features.pickup.direct_pickup_enable = 1
|
||||
features.pickup.direct_pickup_code = **
|
||||
features.pickup.group_pickup_enable = 1
|
||||
features.pickup.group_pickup_code = *8
|
||||
## BLF keys (line key 1 is the line); keys the PBX no longer uses are cleared
|
||||
linekey.2.type = 16
|
||||
linekey.2.line = 1
|
||||
linekey.2.value = 102
|
||||
linekey.2.label = Ann Lee
|
||||
linekey.2.pickup_value = **102
|
||||
linekey.3.type = 16
|
||||
linekey.3.line = 1
|
||||
linekey.3.value = 701
|
||||
linekey.3.label = 701
|
||||
linekey.3.pickup_value = **701
|
||||
linekey.4.type = 0
|
||||
linekey.5.type = 0
|
||||
linekey.6.type = 0
|
||||
linekey.7.type = 0
|
||||
linekey.8.type = 0
|
||||
linekey.9.type = 0
|
||||
linekey.10.type = 0
|
||||
linekey.11.type = 0
|
||||
linekey.12.type = 0
|
||||
linekey.13.type = 0
|
||||
linekey.14.type = 0
|
||||
linekey.15.type = 0
|
||||
linekey.16.type = 0
|
||||
linekey.17.type = 0
|
||||
linekey.18.type = 0
|
||||
linekey.19.type = 0
|
||||
linekey.20.type = 0
|
||||
linekey.21.type = 0
|
||||
linekey.22.type = 0
|
||||
linekey.23.type = 0
|
||||
linekey.24.type = 0
|
||||
linekey.25.type = 0
|
||||
linekey.26.type = 0
|
||||
linekey.27.type = 0
|
||||
linekey.28.type = 0
|
||||
linekey.29.type = 0
|
||||
linekey.30.type = 0
|
||||
linekey.31.type = 0
|
||||
linekey.32.type = 0
|
||||
linekey.33.type = 0
|
||||
## time
|
||||
local_time.time_zone = -6
|
||||
local_time.time_zone_name = United States-Central Time
|
||||
local_time.ntp_server1 = pool.ntp.org
|
||||
## phonebook
|
||||
remote_phonebook.data.1.url = https://001565abcdef:Pr0vPassw0rdXyZ12345@pbx.example.com/prov/phonebook/001565abcdef.xml
|
||||
remote_phonebook.data.1.name = Company
|
||||
## keep provisioning
|
||||
static.auto_provision.server.url = https://pbx.example.com/prov/
|
||||
static.auto_provision.server.username = 001565abcdef
|
||||
static.auto_provision.server.password = Pr0vPassw0rdXyZ12345
|
||||
static.auto_provision.repeat.enable = 1
|
||||
static.auto_provision.repeat.minutes = 1440
|
||||
@@ -1,13 +1,11 @@
|
||||
// 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 { asc, eq, inArray, isNull } from 'drizzle-orm';
|
||||
import { phoneTimezone, type PhoneTimezone, 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 { contacts, devices, extensions } from '../db/schema.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
|
||||
export type ProvisionContext = {
|
||||
@@ -19,11 +17,19 @@ export type ProvisionContext = {
|
||||
transport: 'udp';
|
||||
ext: { number: string; name: string; sipPassword: string };
|
||||
voicemailNumber: '*97';
|
||||
/** extensions.blf_numbers in order, labeled with the extension's name when there is one. */
|
||||
blf: { number: string; label: string }[];
|
||||
timezone: PhoneTimezone & { iana: string; supported: boolean };
|
||||
provUrl: string;
|
||||
provUser: string;
|
||||
provPassword: string;
|
||||
/** The phonebook with this phone's credentials in it (phones fetch it like the config). */
|
||||
phonebookUrl: string;
|
||||
ntpServer: string;
|
||||
};
|
||||
|
||||
export const NTP_SERVER = 'pool.ntp.org';
|
||||
|
||||
export const provisioningUrl = (config: Pick<Config, 'PUBLIC_HOSTNAME'>) =>
|
||||
`https://${config.PUBLIC_HOSTNAME}/prov/`;
|
||||
|
||||
@@ -40,6 +46,18 @@ export async function loadContext(
|
||||
.where(eq(devices.mac, mac));
|
||||
if (!row) return null;
|
||||
const { d, e } = row;
|
||||
const names = new Map(
|
||||
e.blfNumbers.length
|
||||
? (
|
||||
await db
|
||||
.select({ number: extensions.number, name: extensions.name })
|
||||
.from(extensions)
|
||||
.where(inArray(extensions.number, e.blfNumbers))
|
||||
).map((x) => [x.number, x.name])
|
||||
: [],
|
||||
);
|
||||
const iana = await getSetting(db, 'general.timezone');
|
||||
const provPassword = decrypt(d.provisioningPasswordEnc);
|
||||
return {
|
||||
id: d.id,
|
||||
ctx: {
|
||||
@@ -52,13 +70,41 @@ export async function loadContext(
|
||||
transport: 'udp',
|
||||
ext: { number: e.number, name: e.name, sipPassword: decrypt(e.sipPasswordEnc) },
|
||||
voicemailNumber: '*97',
|
||||
blf: e.blfNumbers.map((n) => ({ number: n, label: names.get(n) ?? n })),
|
||||
timezone: { ...phoneTimezone(iana), iana },
|
||||
provUrl: provisioningUrl(config),
|
||||
provUser: d.mac,
|
||||
provPassword: decrypt(d.provisioningPasswordEnc),
|
||||
provPassword,
|
||||
phonebookUrl: `https://${d.mac}:${provPassword}@${config.PUBLIC_HOSTNAME}/prov/phonebook/${d.mac}.xml`,
|
||||
ntpServer: NTP_SERVER,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type PhonebookEntry = { name: string; numbers: { label: string; number: string }[] };
|
||||
|
||||
/** The company phonebook (07 §2): every extension, then the company contacts, by name. */
|
||||
export async function loadPhonebook(db: Db): Promise<PhonebookEntry[]> {
|
||||
const exts = await db
|
||||
.select({ name: extensions.name, number: extensions.number })
|
||||
.from(extensions)
|
||||
.orderBy(asc(extensions.name));
|
||||
const company = await db
|
||||
.select({ name: contacts.name, numbers: contacts.numbers })
|
||||
.from(contacts)
|
||||
.where(isNull(contacts.ownerUserId))
|
||||
.orderBy(asc(contacts.name));
|
||||
return [
|
||||
...exts.map((x) => ({ name: x.name, numbers: [{ label: 'Extension', number: x.number }] })),
|
||||
...company.map((c) => ({
|
||||
name: c.name,
|
||||
numbers: (c.numbers as { label?: string; number?: string }[])
|
||||
.filter((n) => typeof n.number === 'string' && /^[0-9*#+]+$/.test(n.number))
|
||||
.map((n) => ({ label: n.label ?? '', number: n.number! })),
|
||||
})),
|
||||
].filter((x) => x.numbers.length);
|
||||
}
|
||||
|
||||
/** 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, ' ');
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Golden files for the phone templates (07 §3): __golden__/ holds what each vendor gets for one
|
||||
// fixed phone. Update with `vitest -u` and review the diff.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { phoneTimezone } from '@selfphoneng/shared';
|
||||
import type { PhonebookEntry, ProvisionContext } from './context.ts';
|
||||
import { grandstream, grandstreamPhonebook } from './templates/grandstream.ts';
|
||||
import { yealink, yealinkPhonebook } from './templates/yealink.ts';
|
||||
|
||||
const ctx = (over: Partial<ProvisionContext> = {}): ProvisionContext => ({
|
||||
mac: '001565abcdef',
|
||||
vendor: 'yealink',
|
||||
model: 'T54W',
|
||||
host: 'pbx.example.com',
|
||||
sipPort: 5060,
|
||||
transport: 'udp',
|
||||
ext: {
|
||||
number: '101',
|
||||
name: 'Dan "D" <Doe> & Co\r\nx = 1',
|
||||
sipPassword: 'Ab3dEf9hIj2kLm4nOp6qRs8t',
|
||||
},
|
||||
voicemailNumber: '*97',
|
||||
blf: [
|
||||
{ number: '102', label: 'Ann Lee' },
|
||||
{ number: '701', label: '701' },
|
||||
],
|
||||
timezone: { ...phoneTimezone('America/Chicago'), iana: 'America/Chicago' },
|
||||
provUrl: 'https://pbx.example.com/prov/',
|
||||
provUser: '001565abcdef',
|
||||
provPassword: 'Pr0vPassw0rdXyZ12345',
|
||||
phonebookUrl:
|
||||
'https://001565abcdef:Pr0vPassw0rdXyZ12345@pbx.example.com/prov/phonebook/001565abcdef.xml',
|
||||
ntpServer: 'pool.ntp.org',
|
||||
...over,
|
||||
});
|
||||
const book: PhonebookEntry[] = [
|
||||
{ name: 'Ann Lee', numbers: [{ label: 'Extension', number: '102' }] },
|
||||
{
|
||||
name: 'Acme <Supplies> & "Parts"',
|
||||
numbers: [
|
||||
{ label: 'office', number: '13125551234' },
|
||||
{ label: 'mobile', number: '13125559876' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
describe('phone templates', () => {
|
||||
it('Yealink config: account, BLF keys (the rest cleared), time, phonebook, provisioning', async () => {
|
||||
const out = yealink(ctx());
|
||||
await expect(out).toMatchFileSnapshot('./__golden__/yealink.cfg');
|
||||
// a name can't start a new line (and so a new setting)
|
||||
expect(out).not.toMatch(/^x = 1/m);
|
||||
expect(out).toContain('linekey.4.type = 0');
|
||||
expect(out).toContain('linekey.33.type = 0');
|
||||
expect(out).not.toContain('linekey.34');
|
||||
});
|
||||
|
||||
it('Grandstream config: XML-escaped values', async () => {
|
||||
const out = grandstream(ctx({ vendor: 'grandstream' }));
|
||||
await expect(out).toMatchFileSnapshot('./__golden__/grandstream.xml');
|
||||
expect(out).toContain('<P3>Dan "D" <Doe> & Co x = 1</P3>');
|
||||
});
|
||||
|
||||
it('phonebooks', async () => {
|
||||
await expect(yealinkPhonebook(book)).toMatchFileSnapshot('./__golden__/yealink-phonebook.xml');
|
||||
await expect(grandstreamPhonebook(book)).toMatchFileSnapshot(
|
||||
'./__golden__/grandstream-phonebook.xml',
|
||||
);
|
||||
});
|
||||
|
||||
it('a zone phones don’t know falls back to UTC', () => {
|
||||
expect(phoneTimezone('Asia/Tokyo')).toEqual({
|
||||
yealinkOffset: '0',
|
||||
yealinkName: 'UTC',
|
||||
posix: 'UTC0',
|
||||
supported: false,
|
||||
});
|
||||
expect(phoneTimezone('America/St_Johns').yealinkOffset).toBe('-3:30');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
// 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';
|
||||
import { xml, type PhonebookEntry, type ProvisionContext } from '../context.ts';
|
||||
|
||||
const TRANSPORT = { udp: 0, tcp: 1, tls: 2 } as const;
|
||||
|
||||
@@ -26,8 +26,33 @@ export function grandstream(c: ProvisionContext): string {
|
||||
p(237, `${c.host}/prov`, 'config server path'),
|
||||
p(1360, c.provUser, 'HTTP(S) username'),
|
||||
p(1361, c.provPassword, 'HTTP(S) password'),
|
||||
p(30, c.ntpServer, 'NTP server'),
|
||||
p(64, c.timezone.posix, 'time zone (POSIX)'),
|
||||
p(330, 3, 'phonebook download: HTTPS'),
|
||||
// the phone appends /phonebook.xml to this path
|
||||
p(331, c.phonebookUrl.replace(/^https:\/\//, '').replace(/\.xml$/, ''), 'phonebook path'),
|
||||
p(332, 1440, 'phonebook refresh (minutes)'),
|
||||
' </config>',
|
||||
'</gs_provision>',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** Phonebook XML (07 §2), (verify per model). */
|
||||
export function grandstreamPhonebook(entries: PhonebookEntry[]): string {
|
||||
return [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<AddressBook>',
|
||||
...entries.map(
|
||||
(e) =>
|
||||
` <Contact><LastName>${xml(e.name)}</LastName>${e.numbers
|
||||
.map(
|
||||
(n) =>
|
||||
`<Phone><phonenumber>${xml(n.number)}</phonenumber><accountindex>1</accountindex></Phone>`,
|
||||
)
|
||||
.join('')}</Contact>`,
|
||||
),
|
||||
'</AddressBook>',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
// 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';
|
||||
import { oneLine, xml, type PhonebookEntry, type ProvisionContext } from '../context.ts';
|
||||
|
||||
const TRANSPORT = { udp: 0, tcp: 1, tls: 2 } as const;
|
||||
/** Line key 1 is the line; BLF keys are 2…; keys past the last BLF up to here are cleared. */
|
||||
const LAST_KEY = 33;
|
||||
|
||||
export function yealink(c: ProvisionContext): string {
|
||||
const v = oneLine;
|
||||
@@ -26,6 +28,25 @@ export function yealink(c: ProvisionContext): string {
|
||||
'features.pickup.direct_pickup_code = **',
|
||||
'features.pickup.group_pickup_enable = 1',
|
||||
'features.pickup.group_pickup_code = *8',
|
||||
'## BLF keys (line key 1 is the line); keys the PBX no longer uses are cleared',
|
||||
...c.blf.flatMap((b, i) => [
|
||||
`linekey.${i + 2}.type = 16`,
|
||||
`linekey.${i + 2}.line = 1`,
|
||||
`linekey.${i + 2}.value = ${b.number}`,
|
||||
`linekey.${i + 2}.label = ${v(b.label)}`,
|
||||
`linekey.${i + 2}.pickup_value = **${b.number}`,
|
||||
]),
|
||||
...Array.from(
|
||||
{ length: LAST_KEY - c.blf.length - 1 },
|
||||
(_, i) => `linekey.${i + c.blf.length + 2}.type = 0`,
|
||||
),
|
||||
'## time',
|
||||
`local_time.time_zone = ${c.timezone.yealinkOffset}`,
|
||||
`local_time.time_zone_name = ${c.timezone.yealinkName}`,
|
||||
`local_time.ntp_server1 = ${c.ntpServer}`,
|
||||
'## phonebook',
|
||||
`remote_phonebook.data.1.url = ${c.phonebookUrl}`,
|
||||
'remote_phonebook.data.1.name = Company',
|
||||
'## keep provisioning',
|
||||
`static.auto_provision.server.url = ${c.provUrl}`,
|
||||
`static.auto_provision.server.username = ${c.provUser}`,
|
||||
@@ -36,5 +57,21 @@ export function yealink(c: ProvisionContext): string {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** Remote phonebook XML (07 §3.1). */
|
||||
export function yealinkPhonebook(entries: PhonebookEntry[]): string {
|
||||
return [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<YealinkIPPhoneDirectory>',
|
||||
...entries.map(
|
||||
(e) =>
|
||||
` <DirectoryEntry><Name>${xml(e.name)}</Name>${e.numbers
|
||||
.map((n) => `<Telephone label="${xml(n.label)}">${xml(n.number)}</Telephone>`)
|
||||
.join('')}</DirectoryEntry>`,
|
||||
),
|
||||
'</YealinkIPPhoneDirectory>',
|
||||
'',
|
||||
].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';
|
||||
@@ -151,14 +151,15 @@ export async function deviceRoutes(app: FastifyInstance) {
|
||||
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 = '********';
|
||||
// Both secrets wherever they appear (the phonebook URL carries the provisioning password).
|
||||
const content = render(ctx)
|
||||
.replaceAll(ctx.ext.sipPassword, '********')
|
||||
.replaceAll(ctx.provPassword, '********');
|
||||
return {
|
||||
file: d.vendor === 'yealink' ? `${d.mac}.cfg` : `cfg${d.mac}.xml`,
|
||||
content: render({
|
||||
...ctx,
|
||||
ext: { ...ctx.ext, sipPassword: masked },
|
||||
provPassword: masked,
|
||||
}),
|
||||
content,
|
||||
/** The company time zone isn't in the phone table: phones show UTC. */
|
||||
timezoneWarning: !ctx.timezone.supported,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -7,24 +7,25 @@ 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 { loadContext, loadPhonebook, 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 { grandstream, grandstreamPhonebook } from '../provisioning/templates/grandstream.ts';
|
||||
import { yealink, yealinkCommon, yealinkPhonebook } 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;
|
||||
/** null: the phonebook, in the device's own vendor's format. */
|
||||
vendor: ProvisionContext['vendor'] | null;
|
||||
type: string;
|
||||
}[] = [
|
||||
{ re: /^([0-9a-f]{12})\.cfg$/, vendor: 'yealink', render: yealink, type: 'text/plain' },
|
||||
{ re: /^([0-9a-f]{12})\.cfg$/, vendor: 'yealink', type: 'text/plain' },
|
||||
{ re: /^cfg([0-9a-f]{12})\.xml$/, vendor: 'grandstream', type: 'application/xml' },
|
||||
// Yealink: phonebook/<mac>.xml; Grandstream appends /phonebook.xml to the path it's given.
|
||||
{
|
||||
re: /^cfg([0-9a-f]{12})\.xml$/,
|
||||
vendor: 'grandstream',
|
||||
render: grandstream,
|
||||
re: /^phonebook\/([0-9a-f]{12})(?:\.xml|\/phonebook\.xml)$/,
|
||||
vendor: null,
|
||||
type: 'application/xml',
|
||||
},
|
||||
];
|
||||
@@ -60,6 +61,13 @@ export async function provisioningRoutes(app: FastifyInstance) {
|
||||
return nets.check(v4, 'ipv4');
|
||||
}
|
||||
|
||||
/** The config, or (phonebook) the directory in the phone's vendor's format. */
|
||||
async function render(c: ProvisionContext, phonebook = false): Promise<string> {
|
||||
if (!phonebook) return c.vendor === 'yealink' ? yealink(c) : grandstream(c);
|
||||
const entries = await loadPhonebook(db);
|
||||
return c.vendor === 'yealink' ? yealinkPhonebook(entries) : grandstreamPhonebook(entries);
|
||||
}
|
||||
|
||||
const deny = (reply: FastifyReply, code: number, text: string) =>
|
||||
reply.code(code).header('Cache-Control', 'no-store').type('text/plain').send(text);
|
||||
|
||||
@@ -78,7 +86,11 @@ export async function provisioningRoutes(app: FastifyInstance) {
|
||||
const mac = kind.m![1]!;
|
||||
|
||||
const found = await loadContext(db, app.config, mac);
|
||||
if (!found || found.ctx.vendor !== kind.f.vendor) {
|
||||
if (
|
||||
!found ||
|
||||
found.ctx.vendor === 'generic' ||
|
||||
(kind.f.vendor && 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');
|
||||
@@ -107,6 +119,6 @@ export async function provisioningRoutes(app: FastifyInstance) {
|
||||
return reply
|
||||
.header('Cache-Control', 'no-store')
|
||||
.type(kind.f.type)
|
||||
.send(kind.f.render(found.ctx));
|
||||
.send(await render(found.ctx, kind.f.vendor === null));
|
||||
});
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
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 { contacts, 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';
|
||||
@@ -184,6 +184,52 @@ describe.skipIf(!hasTestDb)('/api/devices and /prov', () => {
|
||||
).toBe(404);
|
||||
});
|
||||
|
||||
it('M10-T2: BLF keys named after their users, the time zone, and the phonebook', async () => {
|
||||
await req('POST', '/api/extensions', { number: '102', name: 'Ann Lee' });
|
||||
await req('PATCH', `/api/extensions/${extId}`, { blfNumbers: ['102', '701'] });
|
||||
await t.db.insert(contacts).values([
|
||||
{ name: 'Acme', numbers: [{ label: 'office', number: '13125551234' }] },
|
||||
{ name: 'Private', ownerUserId: null, numbers: [] }, // no numbers: left out
|
||||
]);
|
||||
const cfg = (await prov(`${device.mac}.cfg`, '192.168.1.60', device.provisioningPassword)).body;
|
||||
expect(cfg).toContain('linekey.2.value = 102\nlinekey.2.label = Ann Lee');
|
||||
expect(cfg).toContain('linekey.3.label = 701');
|
||||
expect(cfg).toContain('local_time.time_zone_name = United States-Central Time');
|
||||
expect(cfg).toContain(
|
||||
`remote_phonebook.data.1.url = https://${device.mac}:${device.provisioningPassword}@pbx.test/prov/phonebook/${device.mac}.xml`,
|
||||
);
|
||||
const book = await prov(
|
||||
`phonebook/${device.mac}.xml`,
|
||||
'192.168.1.60',
|
||||
device.provisioningPassword,
|
||||
);
|
||||
expect(book.statusCode).toBe(200);
|
||||
expect(book.body).toContain('<Name>Ann Lee</Name><Telephone label="Extension">102</Telephone>');
|
||||
expect(book.body).toContain('<Telephone label="office">13125551234</Telephone>');
|
||||
expect(book.body).not.toContain('Private');
|
||||
expect((await prov(`phonebook/${device.mac}.xml`, '192.168.1.60')).statusCode).toBe(401);
|
||||
// Grandstream asks for <path>/phonebook.xml and gets its own format
|
||||
const gs = (await req('GET', '/api/devices'))
|
||||
.json()
|
||||
.items.find((d: { vendor: string }) => d.vendor === 'grandstream');
|
||||
const gsBook = await prov(
|
||||
`phonebook/${gs.mac}/phonebook.xml`,
|
||||
'192.168.1.61',
|
||||
gs.provisioningPassword,
|
||||
gs.mac,
|
||||
);
|
||||
expect(gsBook.body).toContain('<AddressBook>');
|
||||
// the preview hides the password in the phonebook URL too
|
||||
const p = (await req('GET', `/api/devices/${device.id}/config-preview`)).json();
|
||||
expect(p.content).not.toContain(device.provisioningPassword);
|
||||
expect(p.timezoneWarning).toBe(false);
|
||||
await setSetting(t.db, 'general.timezone', 'Asia/Tokyo');
|
||||
const tokyo = (await req('GET', `/api/devices/${device.id}/config-preview`)).json();
|
||||
expect(tokyo.timezoneWarning).toBe(true);
|
||||
expect(tokyo.content).toContain('local_time.time_zone_name = UTC');
|
||||
await setSetting(t.db, 'general.timezone', 'America/Chicago');
|
||||
});
|
||||
|
||||
it('delete: the phone gets nothing any more', async () => {
|
||||
expect((await req('DELETE', `/api/devices/${device.id}`)).statusCode).toBe(200);
|
||||
expect(
|
||||
|
||||
Reference in new issue
Block a user