11 KiB
07 — Phone provisioning (desk phones, softphones, mobile)
1. How it works
- Admin adds a device: MAC + vendor + model + extension (
/admin/devices). The api generatesprovisioning_password(20 chars). - The phone learns the provisioning URL in one of three ways: DHCP option 66
(
https://pbx.example.com/prov/), typed into the phone's web UI, or the vendor's redirect service (not supported in v1). - The phone requests its file over HTTPS with HTTP Basic auth: username = MAC (12 lowercase hex), password = provisioning password.
- The api renders the file from the vendor template with that extension's data.
Code: apps/api/src/provisioning/. Templates are TypeScript functions
(templates/yealink.ts, templates/grandstream.ts, templates/polycom.ts) that take a
ProvisionContext and return a string. No template engine dependency.
type ProvisionContext = {
mac: string; vendor: string; model?: string;
host: string; // PUBLIC_HOSTNAME
sipPort: number; transport: 'udp'|'tcp'|'tls';
ext: { number: string; name: string; sipPassword: string; voicemailNumber: '*97' };
blf: { number: string; label: string }[]; // from extensions.blf_numbers
timezone: { iana: string; yealinkOffset: string; yealinkName: string; posix: string };
provUrl: string; provUser: string; provPassword: string;
phonebookUrl: string;
ntpServer: string; // default 'pool.ntp.org'
dialRules: DialRule[]; // dialplan.ts: 911/9911, enabled routes, internal lengths, * codes
};
2. Endpoints (routes/provisioning.ts, no session auth)
| Path | Vendor | Notes |
|---|---|---|
GET /prov/<mac>.cfg |
Yealink (also Polycom master, see §3.3) | MAC lowercase. Decide by devices.vendor |
GET /prov/y0000000000<xx>.cfg |
Yealink common file | Return an empty valid file #!version:1.0.0.1\n |
GET /prov/cfg<mac>.xml |
Grandstream | |
GET /prov/phonebook/<mac>.xml |
both | company phonebook + extensions, vendor-specific XML |
GET /prov/linphone/<token>.xml |
Linphone (§5) | token auth instead of Basic |
Rules:
- Unknown MAC or wrong/missing Basic auth → the same 401 with
WWW-Authenticate: Basic realm="selfphoneng"(an unknown MAC was a 404 until 2026-10-21: the difference told which MACs exist, 08 §6). Both get logged with IP. Rate limit: 20 requests / 10 min per IP; after 10 failures, block that IP for 1 h. - If
provisioning.lanOnlyis true and the client IP isn't innetwork.localNets→ 403. - On success: update
last_provisioned_at,last_provisioned_ip. SetCache-Control: no-store. - Never serve over plain HTTP. The reverse proxy only listens on 443. (Old phones that can't do modern TLS are not supported.)
As built (M10-T1), routes/provisioning.ts + provisioning/limiter.ts:
- A request without an
Authorizationheader gets the 401 but isn't a failure: phones ask without credentials first and retry after the challenge. Failures are requests with credentials that don't match: a wrong password, an unknown MAC, or a file for another vendor; 10 within an hour block the IP for an hour (429). - The 20 requests / 10 min limit applies to clients outside
network.localNets: an office's phones rebooting together behind one address would pass it in a minute. Failures count for all. - The Basic auth user name is the 12-hex MAC (either case); it can't carry colons (Basic auth splits
at the first one). The Yealink common file
y0000000000xx.cfgis served without auth (it's empty). - Unrecognized file names (phones ask for several) are a plain 404 and not a failure.
- Desk phones are provisioned for UDP on
sip.udpPort. Templates in M10-T1 hold the account, voicemail, pickup codes and the provisioning settings; BLF keys, time zone and the phonebook come with M10-T2.scripts/e2e/m10-t1.mjsregisters a baresip phone with only the account from the served file. - Vendors offered: Yealink, Grandstream, generic (Polycom stays a stretch goal).
3. Templates
The key names below come from vendor documentation. Each one is marked (verify per model). Before marking M10 done, test on at least one real Yealink and one Grandstream phone, or the vendor's config validator.
3.1 Yealink (T3x/T4x/T5x)
#!version:1.0.0.1
## account 1
account.1.enable = 1
account.1.label = {ext.number} {ext.name}
account.1.display_name = {ext.name}
account.1.auth_name = {ext.number}
account.1.user_name = {ext.number}
account.1.password = {ext.sipPassword}
account.1.sip_server.1.address = {host}
account.1.sip_server.1.port = {sipPort}
account.1.sip_server.1.transport_type = {0 udp | 1 tcp | 2 tls}
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 (start at linekey 2; linekey 1 is the line)
linekey.{n}.type = 16
linekey.{n}.line = 1
linekey.{n}.value = {blf.number}
linekey.{n}.label = {blf.label}
linekey.{n}.pickup_value = **{blf.number}
## time
local_time.time_zone = {timezone.yealinkOffset}
local_time.time_zone_name = {timezone.yealinkName}
local_time.ntp_server1 = {ntpServer}
## phonebook
remote_phonebook.data.1.url = {phonebookUrl}
remote_phonebook.data.1.name = Company
## keep provisioning
static.auto_provision.server.url = {provUrl}
static.auto_provision.server.username = {provUser}
static.auto_provision.server.password = {provPassword}
static.auto_provision.repeat.enable = 1
static.auto_provision.repeat.minutes = 1440
Phonebook XML:
<YealinkIPPhoneDirectory>
<DirectoryEntry><Name>Dan Doe</Name><Telephone>101</Telephone></DirectoryEntry>
</YealinkIPPhoneDirectory>
3.2 Grandstream (GRP/GXP)
<?xml version="1.0" encoding="UTF-8"?>
<gs_provision version="1">
<mac>{mac}</mac>
<config version="1">
<P271>1</P271> <!-- account 1 active -->
<P270>{ext.number} {ext.name}</P270> <!-- account name -->
<P47>{host}:{sipPort}</P47> <!-- SIP server -->
<P35>{ext.number}</P35> <!-- SIP user ID -->
<P36>{ext.number}</P36> <!-- auth ID -->
<P34>{ext.sipPassword}</P34> <!-- password -->
<P3>{ext.name}</P3> <!-- display name -->
<P33>*97</P33> <!-- voicemail -->
<P130>{0 udp | 1 tcp | 2 tls}</P130>
<P212>2</P212> <!-- config upgrade via HTTPS -->
<P237>{host}/prov</P237> <!-- config server path -->
<P1360>{provUser}</P1360> <!-- HTTP(S) username -->
<P1361>{provPassword}</P1361> <!-- HTTP(S) password -->
<P30>{ntpServer}</P30>
<P64>{timezone.posix}</P64>
</config>
</gs_provision>
All P-values are (verify per model). BLF/MPK keys on Grandstream differ by model family, so they are left out of v1. Show a note in the UI. All XML values must be XML-escaped.
3.3 Polycom (VVX)
Stretch goal for M10. Serve <mac>.cfg as a master config whose CONFIG_FILES
points to phone-<mac>.cfg with reg.1.address, reg.1.auth.userId,
reg.1.auth.password, reg.1.server.1.address, msg.mwi.1.subscribe. Mark as experimental.
3.4 Timezone mapping
provisioning/timezones.ts: a table for at least the US/Canada zones (Eastern, Central,
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 GrandstreamP64are (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 = 0so removed BLFs disappear from the phone. - Phonebook: every extension, then the company contacts (
owner_user_idnull) 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 getsP330 = 3(HTTPS),P331= that path without.xmland 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 withvitest -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.
5. Softphone apps (desktop + mobile)
Recommended open source apps (put in the user's "Set up a phone app" page):
- Linphone (Android, iOS, Windows, macOS, Linux; GPL). Supports remote provisioning via QR code.
- MicroSIP (Windows; GPL). Manual setup.
- Baresip (Linux, CLI; BSD). Manual setup, also used in our tests.
Linphone provisioning:
GET /api/extensions/:id/softphone-qr(admin) orGET /api/me/softphone-qrreturns a PNG QR (viaqrcode) ofhttps://<host>/prov/linphone/<token>.xml.token= base64url(extensionId+.+expiryUnix+.+ HMAC-SHA256(APP_SECRET, extensionId + expiry)), valid 24 h. It is stateless, so there's no table. Regenerating the SIP password makes old configs useless anyway.- XML:
<?xml version="1.0" encoding="UTF-8"?>
<config xmlns="http://www.linphone.org/xsds/lpconfig.xsd">
<section name="proxy_0">
<entry name="reg_proxy"><sip:{host}:{sipPort};transport={udp|tcp|tls}></entry>
<entry name="reg_identity">"{name}" <sip:{ext}@{host}></entry>
<entry name="reg_expires">600</entry>
<entry name="reg_sendregister">1</entry>
</section>
<section name="auth_info_0">
<entry name="username">{ext}</entry>
<entry name="passwd">{sipPassword}</entry>
<entry name="domain">{host}</entry>
</section>
</config>
(verify against Linphone's remote provisioning docs)
As built (M10-T3), provisioning/linphone.ts:
- The
auth_info_0keys (username,passwd,domain) match liblinphone'sAuthInfoconfig reader; theproxy_0keys are its classic names and stay (verify) until a phone scans it. - Both QR endpoints return JSON
{url, qr, expiresAt}(qr= PNG data URL fromqrcode), not a bare PNG: the page shows the link too, for desktop Linphone. Each is in the audit log like revealing the SIP password (the config carries it). - The token is case-sensitive, so
/prov/linphone/…is matched before the other files are lower-cased; a bad or expired token is a 404 and counts as a failure (07 §2 limits).provisioning.lanOnlyapplies to it too: a mobile off the LAN can't fetch it then. - The display name's
"becomes'inreg_identity(it sits inside a SIP quoted string). - "Set up a phone app" is a section of
/me/settingsand of the admin Credentials tab.
Mobile limitation (state it plainly in the UI and admin guide): without push notifications, iOS and battery-optimized Android apps miss calls while in the background. A push gateway is a v2 item. For v1, recommend the desk phone + browser softphone, or call forwarding to a mobile number (CFNA) as the fallback.