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(