Two separate reviews of the SSO branch and all 207 API routes (spec 12 §8.6). Every route was guarded as meant. Fixed, each with a test: - login: a directory 503 counts as a failed sign-in; a locked directory account is a refused password, not "unavailable" - open redirect after OpenID sign-in (a tab in next) - directory email matching only for non-admins without their own password; others connect on My settings (POST /api/me/identities/ldap); only admins disconnect a directory account - OpenID session check reads the refreshed ID token's claims too - connect is POST /api/auth/oidc/connect (CSRF header) - stored bind password, client secret and SMTP password tied to their server - 409 role_managed when a provider sets the role; empty Contrack PATCH is a 400 - @fastify/rate-limit on every route (RATE_LIMIT_PER_MINUTE), 10/min on Test buttons; a password asked again shares the login limiter Live tests pass against lldap 0.6.3, Samba 4 AD and Keycloak 26.8.1.
158 lines
6.6 KiB
TypeScript
158 lines
6.6 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
// GET /prov/... (07 §2): phones fetch their config with HTTP Basic auth (user = MAC, password =
|
|
// the device's provisioning password). No session. Unknown MAC or bad auth → 401, alike; wrong
|
|
// credentials are logged with the IP and count toward an IP block (provisioning/limiter.ts).
|
|
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 { 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';
|
|
import { yealink, yealinkCommon, yealinkPhonebook } from '../provisioning/templates/yealink.ts';
|
|
import { getSetting } from '../services/settings.ts';
|
|
import { NO_LIMIT } from '../auth/guards.ts';
|
|
|
|
/** File name → what it is. Anything else is a plain 404 (phones ask for files we don't serve). */
|
|
const FILES: {
|
|
re: RegExp;
|
|
/** null: the phonebook, in the device's own vendor's format. */
|
|
vendor: ProvisionContext['vendor'] | null;
|
|
type: string;
|
|
}[] = [
|
|
{ re: /^([0-9a-f]{12})\.cfg$/, vendor: 'yealink', type: 'text/plain' },
|
|
{ re: /^cfg([0-9a-f]{12})\.xml$/, vendor: 'grandstream', type: 'application/xml' },
|
|
// Yealink: phonebook/<mac>.xml; Grandstream appends /phonebook.xml to the path it's given.
|
|
{
|
|
re: /^phonebook\/([0-9a-f]{12})(?:\.xml|\/phonebook\.xml)$/,
|
|
vendor: null,
|
|
type: 'application/xml',
|
|
},
|
|
];
|
|
const YEALINK_COMMON = /^y0{10}[0-9a-f]{2}\.cfg$/;
|
|
|
|
/** The Basic auth credentials, or null when there are none. */
|
|
function basicAuth(req: FastifyRequest): { user: string; pass: string } | null {
|
|
const m = /^Basic ([A-Za-z0-9+/=]+)$/.exec(req.headers.authorization ?? '');
|
|
if (!m) return null;
|
|
const s = Buffer.from(m[1]!, 'base64').toString('utf8');
|
|
const i = s.indexOf(':');
|
|
return i < 0 ? null : { user: s.slice(0, i), pass: s.slice(i + 1) };
|
|
}
|
|
|
|
const same = (a: string, b: string) => {
|
|
const x = Buffer.from(a);
|
|
const y = Buffer.from(b);
|
|
return x.length === y.length && timingSafeEqual(x, y);
|
|
};
|
|
|
|
export async function provisioningRoutes(app: FastifyInstance) {
|
|
const { db } = app;
|
|
const ipv4 = (ip: string) => ip.replace(/^::ffff:/, '');
|
|
|
|
async function isLocal(ip: string) {
|
|
const v4 = ipv4(ip);
|
|
if (!isIPv4(v4)) return false;
|
|
const nets = new BlockList();
|
|
for (const n of await getSetting(db, 'network.localNets')) {
|
|
const [addr, bits] = n.split('/');
|
|
nets.addSubnet(addr!, Number(bits), 'ipv4');
|
|
}
|
|
return nets.check(v4, 'ipv4');
|
|
}
|
|
|
|
/** The config, or (phonebook) the directory in the phone's vendor's format. */
|
|
async function render(c: ProvisionContext, phonebook = false): Promise<string> {
|
|
if (!phonebook) return c.vendor === 'yealink' ? yealink(c) : grandstream(c);
|
|
const entries = await loadPhonebook(db, c.ownerUserId);
|
|
return c.vendor === 'yealink' ? yealinkPhonebook(entries) : grandstreamPhonebook(entries);
|
|
}
|
|
|
|
const deny = (reply: FastifyReply, code: number, text: string) =>
|
|
reply.code(code).header('Cache-Control', 'no-store').type('text/plain').send(text);
|
|
|
|
app.get('/prov/*', { config: NO_LIMIT }, async (req, reply) => {
|
|
if (!(await getSetting(db, 'provisioning.enabled'))) return deny(reply, 404, 'Not found');
|
|
const local = await isLocal(req.ip);
|
|
if ((await getSetting(db, 'provisioning.lanOnly')) && !local)
|
|
return deny(reply, 403, 'Forbidden');
|
|
if (!allowProvRequest(req.ip, local)) return deny(reply, 429, 'Too many requests');
|
|
|
|
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);
|
|
if (!kind) return deny(reply, 404, 'Not found');
|
|
const mac = kind.m![1]!;
|
|
|
|
const loaded = await loadContext(db, app.config, mac);
|
|
const found =
|
|
loaded &&
|
|
loaded.ctx.vendor !== 'generic' &&
|
|
(!kind.f.vendor || loaded.ctx.vendor === kind.f.vendor)
|
|
? loaded
|
|
: null;
|
|
const auth = basicAuth(req);
|
|
const ok =
|
|
found !== null &&
|
|
auth !== null &&
|
|
auth.user.toLowerCase() === mac &&
|
|
same(auth.pass, found.ctx.provPassword);
|
|
if (!ok) {
|
|
// An unknown MAC gets the same 401 as a known one, so the answers don't tell which MACs
|
|
// exist (08 §6). A phone asks without credentials first and retries: not a failure.
|
|
if (auth) {
|
|
recordProvFailure(req.ip);
|
|
req.log.warn(
|
|
{ ip: req.ip, mac },
|
|
found ? 'provisioning: wrong credentials' : 'provisioning: unknown MAC',
|
|
);
|
|
}
|
|
return reply
|
|
.code(401)
|
|
.header('WWW-Authenticate', 'Basic realm="selfphoneng"')
|
|
.header('Cache-Control', 'no-store')
|
|
.type('text/plain')
|
|
.send('Unauthorized');
|
|
}
|
|
await db
|
|
.update(devices)
|
|
.set({ lastProvisionedAt: sql`now()`, lastProvisionedIp: ipv4(req.ip) })
|
|
.where(eq(devices.id, found.id));
|
|
req.log.info({ ip: req.ip, mac }, 'provisioning: served');
|
|
return reply
|
|
.header('Cache-Control', 'no-store')
|
|
.type(kind.f.type)
|
|
.send(await render(found.ctx, kind.f.vendor === null));
|
|
});
|
|
}
|