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(
|
||||
|
||||
@@ -7,11 +7,12 @@ import { useState } from 'react';
|
||||
import {
|
||||
DeviceCreateSchema,
|
||||
PHONE_VENDORS,
|
||||
phoneTimezone,
|
||||
type DeviceCreate,
|
||||
type DeviceDto,
|
||||
type PhoneVendor,
|
||||
} from '@selfphoneng/shared';
|
||||
import { devicesApi, extensionsApi } from '../../api/client.ts';
|
||||
import { devicesApi, extensionsApi, settingsApi } from '../../api/client.ts';
|
||||
import { ConfirmDialog, Dialog } from '../../components/Dialog.tsx';
|
||||
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
|
||||
|
||||
@@ -25,6 +26,8 @@ const fmtMac = (m: string) => m.match(/../g)!.join(':');
|
||||
export function PhonesPage() {
|
||||
const qc = useQueryClient();
|
||||
const phones = useQuery({ queryKey: ['devices'], queryFn: devicesApi.list });
|
||||
const settings = useQuery({ queryKey: ['settings'], queryFn: settingsApi.get });
|
||||
const tz = settings.data?.['general.timezone'];
|
||||
const [editing, setEditing] = useState<DeviceDto | 'new' | null>(null);
|
||||
const [deleting, setDeleting] = useState<DeviceDto | null>(null);
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['devices'] });
|
||||
@@ -48,6 +51,12 @@ export function PhonesPage() {
|
||||
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>
|
||||
{tz && !phoneTimezone(tz).supported && (
|
||||
<Alert>
|
||||
Desk phones don't know the time zone {tz}, so they show UTC. Phones know the US and Canada
|
||||
zones, UTC and London.
|
||||
</Alert>
|
||||
)}
|
||||
{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 && (
|
||||
|
||||
@@ -155,6 +155,21 @@ points to `phone-<mac>.cfg` with `reg.1.address`, `reg.1.auth.userId`,
|
||||
Mountain, Arizona, Pacific, Alaska, Hawaii, Atlantic, Newfoundland) plus UTC and London,
|
||||
mapping IANA → Yealink offset/name → POSIX TZ string. Anything else falls back to UTC with a UI warning.
|
||||
|
||||
As built (M10-T2):
|
||||
- The table is `packages/shared/src/phone-timezones.ts` (the Phones page warns from it too), with
|
||||
aliases (Toronto, Detroit, Winnipeg, Edmonton, Boise, Vancouver). Yealink names and the POSIX
|
||||
form for Grandstream `P64` are **(verify per model)**.
|
||||
- Yealink BLF keys start at line key 2, labeled with the extension's name (the number for
|
||||
anything else, e.g. a parking slot), and keys up to 33 past the last BLF are written `type = 0`
|
||||
so removed BLFs disappear from the phone.
|
||||
- Phonebook: every extension, then the company contacts (`owner_user_id` null) that have numbers.
|
||||
The URL in the config carries the phone's credentials
|
||||
(`https://<mac>:<password>@<host>/prov/phonebook/<mac>.xml`): phones fetch it like the config.
|
||||
Grandstream gets `P330 = 3` (HTTPS), `P331` = that path without `.xml` and asks for
|
||||
`<path>/phonebook.xml`, which is served too; `P332 = 1440`. All **(verify per model)**.
|
||||
- NTP server: `pool.ntp.org` (no setting). Golden files for both vendors and both phonebooks:
|
||||
`apps/api/src/provisioning/__golden__/` (update with `vitest -u`, review the diff).
|
||||
|
||||
## 4. Generic devices
|
||||
Vendor `generic`: no file is served. The device page shows manual settings instead:
|
||||
server, port, transport, username, password (reveal), voicemail `*97`.
|
||||
|
||||
@@ -18,6 +18,7 @@ export * from './queues.ts';
|
||||
export * from './conferences.ts';
|
||||
export * from './paging.ts';
|
||||
export * from './devices.ts';
|
||||
export * from './phone-timezones.ts';
|
||||
export * from './ivrs.ts';
|
||||
export * from './timeconditions.ts';
|
||||
export * from './announcements.ts';
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Company time zone → what desk phones want (07 §3.4): Yealink's offset and zone name, and a POSIX
|
||||
// TZ string (Grandstream). Yealink names and the Grandstream format are (verify per model).
|
||||
// Anything not listed falls back to UTC, and the Phones page says so.
|
||||
export type PhoneTimezone = { yealinkOffset: string; yealinkName: string; posix: string };
|
||||
|
||||
const US_DST = 'M3.2.0,M11.1.0';
|
||||
const EASTERN = {
|
||||
yealinkOffset: '-5',
|
||||
yealinkName: 'United States-Eastern Time',
|
||||
posix: `EST5EDT,${US_DST}`,
|
||||
};
|
||||
const CENTRAL = {
|
||||
yealinkOffset: '-6',
|
||||
yealinkName: 'United States-Central Time',
|
||||
posix: `CST6CDT,${US_DST}`,
|
||||
};
|
||||
const MOUNTAIN = {
|
||||
yealinkOffset: '-7',
|
||||
yealinkName: 'United States-Mountain Time',
|
||||
posix: `MST7MDT,${US_DST}`,
|
||||
};
|
||||
const PACIFIC = {
|
||||
yealinkOffset: '-8',
|
||||
yealinkName: 'United States-Pacific Time',
|
||||
posix: `PST8PDT,${US_DST}`,
|
||||
};
|
||||
export const UTC_PHONE_TZ: PhoneTimezone = {
|
||||
yealinkOffset: '0',
|
||||
yealinkName: 'UTC',
|
||||
posix: 'UTC0',
|
||||
};
|
||||
|
||||
export const PHONE_TIMEZONES: Record<string, PhoneTimezone> = {
|
||||
'America/New_York': EASTERN,
|
||||
'America/Detroit': EASTERN,
|
||||
'America/Toronto': EASTERN,
|
||||
'America/Chicago': CENTRAL,
|
||||
'America/Winnipeg': CENTRAL,
|
||||
'America/Denver': MOUNTAIN,
|
||||
'America/Edmonton': MOUNTAIN,
|
||||
'America/Boise': MOUNTAIN,
|
||||
'America/Phoenix': {
|
||||
yealinkOffset: '-7',
|
||||
yealinkName: 'United States-MST no DST',
|
||||
posix: 'MST7',
|
||||
},
|
||||
'America/Los_Angeles': PACIFIC,
|
||||
'America/Vancouver': PACIFIC,
|
||||
'America/Anchorage': {
|
||||
yealinkOffset: '-9',
|
||||
yealinkName: 'United States-Alaska Time',
|
||||
posix: `AKST9AKDT,${US_DST}`,
|
||||
},
|
||||
'Pacific/Honolulu': {
|
||||
yealinkOffset: '-10',
|
||||
yealinkName: 'United States-Hawaii-Aleutian',
|
||||
posix: 'HST10',
|
||||
},
|
||||
'America/Halifax': {
|
||||
yealinkOffset: '-4',
|
||||
yealinkName: 'Canada(Atlantic Time)',
|
||||
posix: `AST4ADT,${US_DST}`,
|
||||
},
|
||||
'America/St_Johns': {
|
||||
yealinkOffset: '-3:30',
|
||||
yealinkName: 'Canada-New Foundland(St.Johns)',
|
||||
posix: `NST3:30NDT,${US_DST}`,
|
||||
},
|
||||
'Europe/London': {
|
||||
yealinkOffset: '0',
|
||||
yealinkName: 'United Kingdom(London)',
|
||||
posix: 'GMT0BST,M3.5.0/1,M10.5.0',
|
||||
},
|
||||
UTC: UTC_PHONE_TZ,
|
||||
'Etc/UTC': UTC_PHONE_TZ,
|
||||
};
|
||||
|
||||
/** The phone settings for a zone, and whether it's really that zone (false = UTC fallback). */
|
||||
export function phoneTimezone(iana: string): PhoneTimezone & { supported: boolean } {
|
||||
const tz = PHONE_TIMEZONES[iana];
|
||||
return tz ? { ...tz, supported: true } : { ...UTC_PHONE_TZ, supported: false };
|
||||
}
|
||||
@@ -181,3 +181,9 @@ URL, user name and password from the drawer, and checks the preview masks the pa
|
||||
as the phone: `/prov/<mac>.cfg` answers 401 without credentials and the file with them, and a
|
||||
baresip phone given only the account from that file registers. The list then shows when it
|
||||
fetched its settings.
|
||||
|
||||
`m10-t2.mjs` (after `m1-t4.mjs`) gives 360 BLF keys for 361 and the parking slot 701 and a
|
||||
Yealink. From the phone's side: the config names the keys, clears the rest, has Central time, and
|
||||
its phonebook URL (with the phone's credentials in it) returns the extensions. Asterisk has hints
|
||||
for 361 and 701. With the company time zone set to Asia/Tokyo, the Phones page warns that phones
|
||||
show UTC (the zone is put back after).
|
||||
@@ -0,0 +1,113 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M10-T2 from the phone's side: a Yealink for 360 with BLF keys for 361 and 701 gets them named,
|
||||
// the company time zone, and a phonebook URL; following that URL (credentials and all, as the
|
||||
// phone does) returns the extensions and a company contact. Each BLF number has a hint in
|
||||
// Asterisk to subscribe to. The Phones page warns when the time zone isn't one phones know.
|
||||
// No real Yealink here: the hardware check stays open (09 M10).
|
||||
// Runs on m2-live, after m1-t4.mjs.
|
||||
// Usage: node scripts/e2e/m10-t2.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir>
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { launch } from './cdp.mjs';
|
||||
|
||||
const AST = 'sp-live-ast';
|
||||
const cli = (cmd) =>
|
||||
spawnSync('docker', ['exec', AST, 'asterisk', '-rx', cmd], { encoding: 'utf8' }).stdout ?? '';
|
||||
const [base, email, pw, shotsDir] = process.argv.slice(2);
|
||||
|
||||
const login = await fetch(`${base}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng' },
|
||||
body: JSON.stringify({ email, password: pw }),
|
||||
});
|
||||
const cookie = login.headers
|
||||
.getSetCookie()
|
||||
.map((c) => c.split(';')[0])
|
||||
.join('; ');
|
||||
const api = async (method, url, body) => {
|
||||
const r = await fetch(`${base}${url}`, {
|
||||
method,
|
||||
headers: {
|
||||
...(body ? { 'content-type': 'application/json' } : {}),
|
||||
'x-requested-with': 'selfphoneng',
|
||||
cookie,
|
||||
},
|
||||
body: body && JSON.stringify(body),
|
||||
});
|
||||
const json = await r.json();
|
||||
assert.ok(r.ok, `${method} ${url}: ${r.status} ${JSON.stringify(json)}`);
|
||||
return json;
|
||||
};
|
||||
|
||||
const p = await launch({ shotsDir });
|
||||
const settings = await api('GET', '/api/settings');
|
||||
try {
|
||||
await api('POST', '/api/extensions', { number: '361', name: 'Bea Busy' });
|
||||
const ext = await api('POST', '/api/extensions', { number: '360', name: 'Ray Reception' });
|
||||
await api('PATCH', `/api/extensions/${ext.id}`, { blfNumbers: ['361', '701'] });
|
||||
const dev = await api('POST', '/api/devices', {
|
||||
mac: '00:15:65:36:00:00',
|
||||
vendor: 'yealink',
|
||||
extensionId: ext.id,
|
||||
});
|
||||
const auth = (user, pass) => ({
|
||||
authorization: `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`,
|
||||
});
|
||||
|
||||
// The config, as the phone gets it.
|
||||
const cfg = await (
|
||||
await fetch(`${base}/prov/${dev.mac}.cfg`, { headers: auth(dev.mac, dev.provisioningPassword) })
|
||||
).text();
|
||||
const val = (k) => new RegExp(`^${k.replace(/\./g, '\\.')} = (.*)$`, 'm').exec(cfg)?.[1];
|
||||
assert.equal(val('linekey.2.value'), '361');
|
||||
assert.equal(val('linekey.2.label'), 'Bea Busy');
|
||||
assert.equal(val('linekey.3.value'), '701');
|
||||
assert.equal(val('linekey.4.type'), '0');
|
||||
assert.equal(val('local_time.time_zone_name'), 'United States-Central Time');
|
||||
console.log('config: BLF 361 "Bea Busy" and 701, the rest cleared; Central time');
|
||||
|
||||
// Each BLF number has a hint to subscribe to.
|
||||
const end = Date.now() + 30_000; // the new extensions reach Asterisk within seconds
|
||||
while (Date.now() < end && !/361@sp-local-numbers/.test(cli('core show hint 361')))
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
assert.match(cli('core show hint 361'), /361@sp-local-numbers/);
|
||||
assert.match(cli('core show hint 701'), /701@sp-parkedcalls/);
|
||||
console.log('Asterisk has hints for 361 and the 701 parking slot');
|
||||
|
||||
// The phonebook URL carries the phone's credentials; the phone fetches it as is.
|
||||
const pb = new URL(val('remote_phonebook.data.1.url'));
|
||||
assert.equal(pb.username, dev.mac);
|
||||
const book = await (
|
||||
await fetch(`${base}${pb.pathname}`, {
|
||||
headers: auth(decodeURIComponent(pb.username), decodeURIComponent(pb.password)),
|
||||
})
|
||||
).text();
|
||||
assert.match(book, /<Name>Bea Busy<\/Name><Telephone label="Extension">361<\/Telephone>/);
|
||||
assert.match(book, /<Name>Ray Reception<\/Name>/);
|
||||
console.log('phonebook from the URL in the config: the extensions are in it');
|
||||
|
||||
// A zone phones don't know: the Phones page says so.
|
||||
await api('PATCH', '/api/settings', { 'general.timezone': 'Asia/Tokyo' });
|
||||
await p.goto(`${base}/login`);
|
||||
await p.waitFor(`!!document.querySelector('input[type=email]')`);
|
||||
await p.fill('Email', email);
|
||||
await p.fill('Password', pw);
|
||||
await p.click('Sign in');
|
||||
await p.waitFor(`location.pathname === '/admin'`);
|
||||
await p.click('Phones');
|
||||
await p.waitFor(
|
||||
`document.body.innerText.includes("Desk phones don't know the time zone Asia/Tokyo")`,
|
||||
'time zone warning',
|
||||
);
|
||||
await p.shot('m10-t2-01-tz-warning');
|
||||
console.log('Asia/Tokyo: the Phones page warns that phones show UTC');
|
||||
console.log('M10-T2 (BLF, time zone, phonebook): PASS');
|
||||
} catch (e) {
|
||||
await p.shot('m10-t2-fail').catch(() => {});
|
||||
console.log(`FAIL: ${e.message}`);
|
||||
console.log(`browser errors: ${JSON.stringify(p.consoleErrors)}`);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await api('PATCH', '/api/settings', { 'general.timezone': settings['general.timezone'] });
|
||||
await p.close();
|
||||
}
|
||||
Reference in new issue
Block a user