provisioning: Linphone setup QR, "Set up a phone app"
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:
1 parent
6195aca2fb
commit
c1f8b04e7e
17 files changed
+631
-4
No files matched your search
@@ -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"><sip:pbx.example.com:5060;transport=udp></entry>',
|
||||
);
|
||||
expect(x).toContain(
|
||||
`<entry name="reg_identity">"Dan 'D' <Doe>" <sip:101@pbx.example.com></entry>`,
|
||||
);
|
||||
expect(x).toContain('<entry name="username">101</entry>');
|
||||
expect(x).toContain('<entry name="passwd">Ab3dEf9hIj2kLm4nOp6qRs8t</entry>');
|
||||
});
|
||||
});
|
||||
@@ -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(/"/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"><sip:${a.host}:${a.sipPort};transport=${a.transport}></entry>`,
|
||||
` <entry name="reg_identity">"${name}" <sip:${a.number}@${a.host}></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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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.");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user