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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user