provisioning: Linphone setup QR, "Set up a phone app"
ci / check (push) Successful in 53s
ci / images (push) Successful in 24s

GET /api/extensions/:id/softphone-qr (admin) and /api/me/softphone-qr
return {url, qr, expiresAt}: the link Linphone fetches and its QR as a
PNG data URL (qrcode), valid 24 h. Both are audited like revealing the
SIP password. JSON rather than a bare PNG, so the page can show the
link for desktop Linphone too.

The token is stateless per 07 §5: base64url(extensionId.expiry.hmac),
HMAC-SHA256 with APP_SECRET, checked timing-safe. /prov/linphone/
<token>.xml matches it before the other file names are lower-cased
(the token is case-sensitive); a bad or expired token is a 404 and a
counted failure. auth_info keys checked against liblinphone's config
reader; proxy keys stay (verify).

Web: a "Set up a phone app" section on My settings and in the admin
Credentials tab: Linphone with the QR, MicroSIP/Baresip by hand, and
the plain warning about mobile apps without push. e2e m10-t3: as a
user, the QR's link gives the XML and a baresip phone registers with
only what it holds.

Task: M10-T3
This commit is contained in:
iamdoubz committed 2026-10-19 14:16:28 -05:00
1 parent 6195aca2fb
commit c1f8b04e7e
17 files changed
+631 -4

No files matched your search

+2
View File
@@ -20,10 +20,12 @@
"fastify": "^5.12.7",
"nodemailer": "10.0.11",
"pg": "^8.23.1",
"qrcode": "1.5.4",
"zod": "^4.6.7"
},
"devDependencies": {
"@types/pg": "^8.23.1",
"@types/qrcode": "1.5.6",
"@types/ws": "^8.18.2",
"drizzle-kit": "^0.31.11"
}
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M10-T3: the stateless Linphone token (07 §5) and the XML.
import { describe, expect, it } from 'vitest';
import { linphoneToken, linphoneXml, verifyLinphoneToken } from './linphone.ts';
const SECRET = Buffer.alloc(32, 7).toString('base64');
const OTHER = Buffer.alloc(32, 8).toString('base64');
const ID = '00000000-0000-4000-8000-000000000101';
describe('linphone token', () => {
it('round-trips for 24 h, then expires', () => {
const now = Date.UTC(2026, 9, 19, 12);
const { token, expiresAt } = linphoneToken(SECRET, ID, now);
expect(expiresAt).toBe('2026-10-20T12:00:00.000Z');
expect(token).toMatch(/^[A-Za-z0-9_-]+$/);
expect(verifyLinphoneToken(SECRET, token, now + 23 * 3600_000)).toBe(ID);
expect(verifyLinphoneToken(SECRET, token, now + 24 * 3600_000 + 1000)).toBeNull();
});
it('refuses another secret, a changed id or expiry, and junk', () => {
const now = Date.now();
const { token } = linphoneToken(SECRET, ID, now);
expect(verifyLinphoneToken(OTHER, token, now)).toBeNull();
const [id, exp, mac] = Buffer.from(token, 'base64url').toString().split('.');
const forge = (s: string) => Buffer.from(s).toString('base64url');
expect(
verifyLinphoneToken(SECRET, forge(`${ID.replace('101', '102')}.${exp}.${mac}`), now),
).toBeNull();
expect(
verifyLinphoneToken(SECRET, forge(`${id}.${Number(exp) + 3600}.${mac}`), now),
).toBeNull();
expect(verifyLinphoneToken(SECRET, 'nope', now)).toBeNull();
expect(verifyLinphoneToken(SECRET, forge('a.b'), now)).toBeNull();
});
});
describe('linphone XML', () => {
it('proxy and auth sections, values escaped', () => {
const x = linphoneXml({
host: 'pbx.example.com',
sipPort: 5060,
transport: 'udp',
number: '101',
name: 'Dan "D" <Doe>',
sipPassword: 'Ab3dEf9hIj2kLm4nOp6qRs8t',
});
expect(x).toContain(
'<entry name="reg_proxy">&lt;sip:pbx.example.com:5060;transport=udp&gt;</entry>',
);
expect(x).toContain(
`<entry name="reg_identity">"Dan 'D' &lt;Doe&gt;" &lt;sip:101@pbx.example.com&gt;</entry>`,
);
expect(x).toContain('<entry name="username">101</entry>');
expect(x).toContain('<entry name="passwd">Ab3dEf9hIj2kLm4nOp6qRs8t</entry>');
});
});
+71
View File
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Linphone remote provisioning (07 §5): a QR of https://<host>/prov/linphone/<token>.xml. The token
// is stateless: base64url(extensionId.expiry.hmac), valid 24 h, HMAC-SHA256 with APP_SECRET.
// Regenerating the SIP password makes older configs useless anyway.
import { createHmac, timingSafeEqual } from 'node:crypto';
import QRCode from 'qrcode';
import { xml } from './context.ts';
const TTL_S = 24 * 3600;
const sign = (secret: string, id: string, exp: number) =>
createHmac('sha256', Buffer.from(secret, 'base64')).update(`${id}.${exp}`).digest('hex');
export function linphoneToken(secret: string, extensionId: string, now = Date.now()) {
const exp = Math.floor(now / 1000) + TTL_S;
return {
token: Buffer.from(`${extensionId}.${exp}.${sign(secret, extensionId, exp)}`).toString(
'base64url',
),
expiresAt: new Date(exp * 1000).toISOString(),
};
}
/** The extension id in a valid, unexpired token, or null. */
export function verifyLinphoneToken(secret: string, token: string, now = Date.now()) {
const parts = Buffer.from(token, 'base64url').toString('utf8').split('.');
if (parts.length !== 3) return null;
const [id, expS, mac] = parts as [string, string, string];
const exp = Number(expS);
if (!/^[0-9a-f-]{36}$/.test(id) || !Number.isInteger(exp) || exp * 1000 < now) return null;
const want = Buffer.from(sign(secret, id, exp));
const got = Buffer.from(mac);
return want.length === got.length && timingSafeEqual(want, got) ? id : null;
}
export type LinphoneAccount = {
host: string;
sipPort: number;
transport: 'udp' | 'tcp' | 'tls';
number: string;
name: string;
sipPassword: string;
};
/** The remote provisioning XML. auth_info keys checked against liblinphone; proxy keys (verify). */
export function linphoneXml(a: LinphoneAccount): string {
const name = xml(a.name).replace(/&quot;/g, "'");
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<config xmlns="http://www.linphone.org/xsds/lpconfig.xsd">',
' <section name="proxy_0">',
` <entry name="reg_proxy">&lt;sip:${a.host}:${a.sipPort};transport=${a.transport}&gt;</entry>`,
` <entry name="reg_identity">"${name}" &lt;sip:${a.number}@${a.host}&gt;</entry>`,
' <entry name="reg_expires">600</entry>',
' <entry name="reg_sendregister">1</entry>',
' </section>',
' <section name="auth_info_0">',
` <entry name="username">${a.number}</entry>`,
` <entry name="passwd">${xml(a.sipPassword)}</entry>`,
` <entry name="domain">${a.host}</entry>`,
' </section>',
'</config>',
'',
].join('\n');
}
/** The link Linphone fetches and its QR (a PNG data URL); both stop working after 24 h. */
export async function softphoneQr(secret: string, host: string, extensionId: string) {
const { token, expiresAt } = linphoneToken(secret, extensionId);
const url = `https://${host}/prov/linphone/${token}.xml`;
return { url, qr: await QRCode.toDataURL(url, { margin: 2, width: 280 }), expiresAt };
}
+17
View File
@@ -10,6 +10,7 @@ import {
type ExtensionDto,
type ListResponse,
type SipCredentials,
type SoftphoneQr,
} from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
import { extensions, userRuntimeState } from '../db/schema.ts';
@@ -25,6 +26,7 @@ import {
import { audit } from '../services/audit.ts';
import { assertNumberAvailable, nextFreeNumber } from '../services/numbering.ts';
import { assertMohClass } from './moh.ts';
import { softphoneQr } from '../provisioning/linphone.ts';
import { findReferences } from '../services/destinations.ts';
import { getSetting } from '../services/settings.ts';
@@ -243,4 +245,19 @@ export async function extensionRoutes(app: FastifyInstance) {
webUsername: e.webrtcEnabled ? `${e.number}-web` : null,
};
});
/** Linphone setup QR (07 §5). It carries the SIP password, so it's audited like a reveal. */
app.get('/api/extensions/:id/softphone-qr', async (req): Promise<SoftphoneQr> => {
const { id } = IdParams.parse(req.params);
const e = await getExt(db, id);
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'reveal_sip_credentials',
entityType: 'extension',
entityId: id,
summary: `Made a phone app setup QR for extension ${e.number}`,
});
return softphoneQr(app.config.APP_SECRET, app.config.PUBLIC_HOSTNAME, id);
});
}
+17
View File
@@ -12,8 +12,10 @@ import {
type ListResponse,
type MeDto,
type VoicemailDto,
type SoftphoneQr,
type WebRtcCreds,
} from '@selfphoneng/shared';
import { softphoneQr } from '../provisioning/linphone.ts';
import { decrypt, encrypt, isTrivialPin } from '../crypto/secrets.ts';
import { setDnd, setForward } from '../services/astdb.ts';
import { audit } from '../services/audit.ts';
@@ -175,6 +177,21 @@ export async function meRoutes(app: FastifyInstance) {
return myState(req.user!.id);
});
/** "Set up a phone app" (07 §5): a Linphone setup QR for my own extension. */
app.get('/api/me/softphone-qr', async (req): Promise<SoftphoneQr> => {
const [ext] = await db.select().from(extensions).where(eq(extensions.userId, req.user!.id));
if (!ext) throw notFound("You don't have an extension yet. Ask your admin.");
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'reveal_sip_credentials',
entityType: 'extension',
entityId: ext.id,
summary: `Made a phone app setup QR for extension ${ext.number}`,
});
return softphoneQr(app.config.APP_SECRET, app.config.PUBLIC_HOSTNAME, ext.id);
});
app.get('/api/me/webrtc', async (req): Promise<WebRtcCreds> => {
const [ext] = await db.select().from(extensions).where(eq(extensions.userId, req.user!.id));
if (!ext) throw notFound("You don't have an extension yet. Ask your admin.");
+30 -2
View File
@@ -6,7 +6,9 @@ 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 { decrypt } from '../crypto/secrets.ts';
import { devices, extensions } from '../db/schema.ts';
import { linphoneXml, verifyLinphoneToken } from '../provisioning/linphone.ts';
import { loadContext, loadPhonebook, type ProvisionContext } from '../provisioning/context.ts';
import { allowProvRequest, recordProvFailure } from '../provisioning/limiter.ts';
import { grandstream, grandstreamPhonebook } from '../provisioning/templates/grandstream.ts';
@@ -78,7 +80,33 @@ export async function provisioningRoutes(app: FastifyInstance) {
return deny(reply, 403, 'Forbidden');
if (!allowProvRequest(req.ip, local)) return deny(reply, 429, 'Too many requests');
const file = (req.params as { '*': string })['*'].toLowerCase();
const raw = (req.params as { '*': string })['*'];
// Linphone (07 §5): the token is the credential (case matters), no Basic auth.
const lin = /^linphone\/([A-Za-z0-9_-]{20,200})\.xml$/.exec(raw);
if (lin) {
const extId = verifyLinphoneToken(app.config.APP_SECRET, lin[1]!);
const [e] = extId ? await db.select().from(extensions).where(eq(extensions.id, extId)) : [];
if (!e) {
recordProvFailure(req.ip);
req.log.warn({ ip: req.ip }, 'provisioning: bad or expired Linphone token');
return deny(reply, 404, 'Not found');
}
req.log.info({ ip: req.ip, ext: e.number }, 'provisioning: Linphone config served');
return reply
.header('Cache-Control', 'no-store')
.type('application/xml')
.send(
linphoneXml({
host: app.config.PUBLIC_HOSTNAME,
sipPort: await getSetting(db, 'sip.udpPort'),
transport: 'udp',
number: e.number,
name: e.name,
sipPassword: decrypt(e.sipPasswordEnc),
}),
);
}
const file = raw.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);
+23 -1
View File
@@ -4,7 +4,7 @@
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { eq } from 'drizzle-orm';
import { contacts, devices } from '../src/db/schema.ts';
import { auditLog, 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';
@@ -230,6 +230,28 @@ describe.skipIf(!hasTestDb)('/api/devices and /prov', () => {
await setSetting(t.db, 'general.timezone', 'America/Chicago');
});
it('M10-T3: Linphone QR (admin and me), the token is the credential, audited', async () => {
const r = await req('GET', `/api/extensions/${extId}/softphone-qr`);
expect(r.statusCode, r.body).toBe(200);
const { url, qr } = r.json();
expect(qr).toMatch(/^data:image\/png;base64,/);
expect(url).toMatch(/^https:\/\/pbx\.test\/prov\/linphone\/[A-Za-z0-9_-]+\.xml$/);
const path = new URL(url).pathname.slice('/prov/'.length);
const x = await prov(path, '203.0.113.20'); // a mobile phone, no Basic auth
expect(x.statusCode).toBe(200);
expect(x.body).toContain('<entry name="username">101</entry>');
expect(x.body).toContain(`<entry name="passwd">${sipPassword}</entry>`);
// the token is case-sensitive: a lower-cased one is just a bad token
expect((await prov(path.toLowerCase(), '203.0.113.21')).statusCode).toBe(404);
expect((await prov('linphone/AAAAAAAAAAAAAAAAAAAAAAAA.xml', '203.0.113.21')).statusCode).toBe(
404,
);
const logged = JSON.stringify(await t.db.select().from(auditLog));
expect(logged).toContain('Made a phone app setup QR for extension 101');
// a user with no extension gets a 404 from /api/me
expect((await req('GET', '/api/me/softphone-qr')).statusCode).toBe(404);
});
it('delete: the phone gets nothing any more', async () => {
expect((await req('DELETE', `/api/devices/${device.id}`)).statusCode).toBe(200);
expect(
+3
View File
@@ -51,6 +51,7 @@ import type {
SettingsDto,
SipCredentials,
SoundDto,
SoftphoneQr,
MohClassDto,
VoipmsDidDto,
TimeConditionCreate,
@@ -140,6 +141,7 @@ export const usersApi = {
export const extensionsApi = {
list: () => api<ListResponse<ExtensionDto>>('GET', '/api/extensions').then((r) => r.items),
get: (id: string) => api<ExtensionDto>('GET', `/api/extensions/${id}`),
softphoneQr: (id: string) => api<SoftphoneQr>('GET', `/api/extensions/${id}/softphone-qr`),
create: (e: ExtensionCreate) => api<ExtensionDto>('POST', '/api/extensions', e),
update: (id: string, e: ExtensionUpdate) =>
api<ExtensionDto>('PATCH', `/api/extensions/${id}`, e),
@@ -214,6 +216,7 @@ export const meApi = {
calls: (cursor?: string) =>
api<ListResponse<MyCallDto>>('GET', `/api/me/calls${cursor ? `?cursor=${cursor}` : ''}`),
get: () => api<MeDto>('GET', '/api/me'),
softphoneQr: () => api<SoftphoneQr>('GET', '/api/me/softphone-qr'),
update: (m: MeUpdate) => api<MeDto>('PATCH', '/api/me', m),
webrtc: () => api<WebRtcCreds>('GET', '/api/me/webrtc'),
/** Log in to (unpause) or out of (pause) a queue I'm an agent of. */
+49
View File
@@ -0,0 +1,49 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// "Set up a phone app" (07 §5): recommended apps, a Linphone setup QR (24 h), and the plain
// truth about mobile apps without push. Used on /me/settings and the admin Credentials tab.
import { useMutation } from '@tanstack/react-query';
import type { SoftphoneQr } from '@selfphoneng/shared';
import { Alert, Button } from './ui.tsx';
export function PhoneAppSetup({ load }: { load: () => Promise<SoftphoneQr> }) {
const qr = useMutation({ mutationFn: load });
return (
<div className="flex flex-col gap-3 text-sm">
<p>
<strong>Linphone</strong> (Android, iPhone, Windows, Mac, Linux) sets itself up from a QR
code: in the app, choose <em>Fetch remote configuration</em> (or <em>Scan QR code</em>) and
point the camera at the code below. On a computer, paste the link instead.
</p>
<p className="text-zinc-600 dark:text-zinc-400">
<strong>MicroSIP</strong> (Windows) and <strong>Baresip</strong> (Linux) work too, set up by
hand: an admin can see the server, user name and password.
</p>
{!qr.data ? (
<Button className="self-start" onClick={() => qr.mutate()} disabled={qr.isPending}>
Show setup QR code
</Button>
) : (
<div className="flex flex-col gap-2">
<img
src={qr.data.qr}
alt="Linphone setup QR code"
width={280}
height={280}
className="rounded bg-white"
/>
<p className="font-mono text-xs break-all">{qr.data.url}</p>
<p className="text-xs text-zinc-500">
Works until {new Date(qr.data.expiresAt).toLocaleString()}. It carries your phone
password: don't share it.
</p>
</div>
)}
{qr.error && <Alert>{qr.error.message}</Alert>}
<p className="text-xs text-zinc-500">
On a mobile phone, apps can miss calls while they're in the background (iPhones and
battery-saving Android phones stop them). For calls you can't miss, use a desk phone or this
browser, or forward unanswered calls to your mobile number.
</p>
</div>
);
}
+5
View File
@@ -20,6 +20,7 @@ import { didsApi, extensionsApi, usersApi } from '../../api/client.ts';
import { CLASS_LABEL } from './OutboundPage.tsx';
import { Dialog } from '../../components/Dialog.tsx';
import { MohOptions } from '../../components/MohOptions.tsx';
import { PhoneAppSetup } from '../../components/PhoneAppSetup.tsx';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
type Tab = 'general' | 'phone' | 'calling' | 'emergency' | 'credentials';
@@ -440,6 +441,10 @@ function CredentialsTab({ ext }: { ext: ExtensionDto }) {
)}
{done && <p className="mt-2 text-green-700 dark:text-green-400">New password saved.</p>}
</div>
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
<h3 className="mb-2 font-medium">Phone app</h3>
<PhoneAppSetup load={() => extensionsApi.softphoneQr(ext.id)} />
</div>
</div>
);
}
+8 -1
View File
@@ -1,11 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// My settings (05 §4): call forwarding, do not disturb, voicemail PIN and email options.
// Softphone devices and notifications live in the softphone panel; phone apps (QR) come in M10.
// Softphone devices and notifications live in the softphone panel. "Set up a phone app" (07 §5).
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Link } from 'react-router';
import { formatNumber, type MeDto, type MeUpdate } from '@selfphoneng/shared';
import { meApi } from '../../api/client.ts';
import { PhoneAppSetup } from '../../components/PhoneAppSetup.tsx';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
/** "102" stays as is; outside numbers get their usual format. */
@@ -60,6 +61,12 @@ function SettingsForms({ me }: { me: MeDto }) {
<Forwarding me={me} />
</section>
{me.voicemailEnabled && <Voicemail me={me} />}
<section aria-labelledby="app-h" className="flex flex-col gap-3">
<h2 id="app-h" className="text-lg font-semibold">
Set up a phone app
</h2>
<PhoneAppSetup load={meApi.softphoneQr} />
</section>
<section className="flex flex-col gap-2">
<h2 className="text-lg font-semibold">Password</h2>
<Link className="text-sm text-sky-700 underline dark:text-sky-400" to="/change-password">