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

+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);