wldx-admin-cli 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/pbx.mjs ADDED
@@ -0,0 +1,369 @@
1
+ // The PBX a command works on, and how it signs in there. Two kinds of access:
2
+ // - the x-bees login (wx-admin login, shared with wx and the wildix-auth skill): the user's own PBX, named in the
3
+ // login token, with the rights of that user (their ACL);
4
+ // - access saved for a PBX (wx-admin pbx add): a Company API Key (wsk-v1-…, recommended by Wildix), a Simple Token
5
+ // (deprecated by Wildix) or Basic (user and password; disabled on a PBX unless ALLOW_BASIC_AUTH=true).
6
+ // Which PBX: --pbx HOST, else the default PBX (wx-admin pbx use), else the PBX of the x-bees login.
7
+ // WILDIX_PBX_API_KEY with WILDIX_PBX_HOST gives a Company API Key for that one PBX without saving it (CI, scripts):
8
+ // the key is sent to WILDIX_PBX_HOST only, never to a --pbx of the command line (which an agent may have built from
9
+ // fetched text), and --all-pbx is refused while it is set.
10
+ //
11
+ // Company API Keys (docs.wildix.com, "Company API Key for the PBX API"): made by a root admin in WMS, PBX →
12
+ // Integrations → Company API Keys; shown once; valid 1 hour to 1 year; scopes {resource}:read for GET/HEAD and
13
+ // {resource}:* for the rest, or pbx:read / pbx:*; a key may be bound to a PBX user (else it acts as admin).
14
+ // Saved access lives in $WILDIX_CONFIG_DIR/admin/pbx/<host>.json (folder 0700, files 0600, written atomically).
15
+ // Secrets are never printed, never put on a command line or into the environment of a child process; the API key
16
+ // or token goes only to its own PBX over https, and redirects are refused.
17
+ import crypto from 'node:crypto';
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import {configDir, getToken, jwtClaims, pickProfile, savedSession} from 'wldx-core/auth';
21
+ import {AuthError, UsageError} from 'wldx-core/errors';
22
+ import {userAgent} from 'wldx-core/program';
23
+
24
+ export const AUTH_TYPES = ['apikey', 'token', 'basic'];
25
+ const IPV4 = /^(25[0-5]|2[0-4]\d|1?\d?\d)(\.(25[0-5]|2[0-4]\d|1?\d?\d)){3}$/;
26
+ const HOSTNAME = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
27
+ export const API_KEY = /^wsk-v1-[A-Za-z0-9]{93}$/;
28
+
29
+ // A PBX host as given: a name without a dot is a cloud PBX (<name>.wildixin.com); else a host name or an IPv4.
30
+ export function normalizeHost(input) {
31
+ const v = String(input ?? '')
32
+ .trim()
33
+ .toLowerCase()
34
+ .replace(/^https?:\/\//, '')
35
+ .replace(/\/.*$/, '');
36
+ const host = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(v) ? `${v}.wildixin.com` : v;
37
+ if (!IPV4.test(host) && !HOSTNAME.test(host)) throw new UsageError(`Not a PBX host: ${input}`);
38
+ return host;
39
+ }
40
+
41
+ export function checkPort(port) {
42
+ const n = Number(port);
43
+ if (!Number.isInteger(n) || n < 1 || n > 65535) throw new UsageError(`Not a port: ${port}`);
44
+ return n;
45
+ }
46
+
47
+ // A PBX as --pbx (or a command argument) names it: the serial of a known PBX (saved access, or the PBX of the x-bees
48
+ // login), bare or as <serial>.wildixin.com, else a host as normalizeHost takes it. A serial is 8 to 16 hex digits;
49
+ // it is looked up first, as a bare name would otherwise be read as <name>.wildixin.com. An unknown serial stays
50
+ // <serial>.wildixin.com (DNS may not know it: pbx add and pbx check record the serial of a PBX for --pbx).
51
+ export function pbxHostOf(ref, ctx = {}) {
52
+ const s = String(ref ?? '')
53
+ .trim()
54
+ .toLowerCase()
55
+ .replace(/^https?:\/\//, '')
56
+ .replace(/\/.*$/, '')
57
+ .replace(/\.wildixin\.com$/, '');
58
+ if (/^[0-9a-f]{8,16}$/.test(s)) {
59
+ const saved = savedPbxs(ctx.env ?? process.env).find((p) => p.serial && p.serial.toLowerCase() === s);
60
+ if (saved) return saved.host;
61
+ const login = loginPbxOf({env: ctx.env ?? process.env, profile: ctx.profile});
62
+ if (login?.serial && login.serial.toLowerCase() === s) return login.host;
63
+ }
64
+ return normalizeHost(ref);
65
+ }
66
+
67
+ const pbxDir = (env) => path.join(configDir(env), 'admin', 'pbx');
68
+ const accessFile = (host, env) => path.join(pbxDir(env), `${host}.json`);
69
+ const settingsFile = (env) => path.join(configDir(env), 'wx-admin.json');
70
+
71
+ function readJson(file) {
72
+ try {
73
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
74
+ } catch {
75
+ return undefined;
76
+ }
77
+ }
78
+
79
+ function writePrivate(file, data) {
80
+ const dir = path.dirname(file);
81
+ fs.mkdirSync(dir, {recursive: true, mode: 0o700});
82
+ try {
83
+ fs.chmodSync(dir, 0o700);
84
+ } catch {
85
+ // not ours to change: the file itself is still 0600
86
+ }
87
+ const tmp = path.join(dir, `.${path.basename(file)}.${crypto.randomBytes(6).toString('hex')}`);
88
+ fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, {mode: 0o600, flag: 'wx'});
89
+ fs.renameSync(tmp, file);
90
+ }
91
+
92
+ const readSettings = (env) => readJson(settingsFile(env)) ?? {};
93
+
94
+ // The saved access of a PBX ({host, port, auth, …secret}), or undefined.
95
+ export function savedAccess(host, env = process.env) {
96
+ const a = readJson(accessFile(host, env));
97
+ return a && AUTH_TYPES.includes(a.auth) && a.host === host ? a : undefined;
98
+ }
99
+
100
+ // The PBX with saved access: [{host, port, auth, default}], never a secret.
101
+ export function savedPbxs(env = process.env) {
102
+ let names;
103
+ try {
104
+ names = fs
105
+ .readdirSync(pbxDir(env))
106
+ .filter((n) => n.endsWith('.json') && !n.startsWith('.'))
107
+ .sort();
108
+ } catch {
109
+ return [];
110
+ }
111
+ const byDefault = readSettings(env).defaultPbx;
112
+ return names
113
+ .map((n) => savedAccess(n.slice(0, -'.json'.length), env))
114
+ .filter(Boolean)
115
+ .map((a) => ({host: a.host, port: a.port, auth: a.auth, serial: a.serial ?? null, default: a.host === byDefault}));
116
+ }
117
+
118
+ export function saveAccess(access, env = process.env) {
119
+ if (!AUTH_TYPES.includes(access.auth)) throw new UsageError(`Unknown kind of access: ${access.auth}`);
120
+ writePrivate(accessFile(access.host, env), access);
121
+ }
122
+
123
+ export function removeAccess(host, env = process.env) {
124
+ const file = accessFile(host, env);
125
+ if (!fs.existsSync(file)) return false;
126
+ fs.rmSync(file);
127
+ if (readSettings(env).defaultPbx === host) setDefaultPbx(undefined, env);
128
+ return true;
129
+ }
130
+
131
+ export const defaultPbx = (env = process.env) => {
132
+ const host = readSettings(env).defaultPbx;
133
+ return host && savedAccess(host, env) ? host : undefined;
134
+ };
135
+
136
+ export function setDefaultPbx(host, env = process.env) {
137
+ const s = readSettings(env);
138
+ if (host) s.defaultPbx = host;
139
+ else delete s.defaultPbx;
140
+ writePrivate(settingsFile(env), s);
141
+ }
142
+
143
+ // The PBX of the x-bees login of the profile, from the saved session (no network), or undefined.
144
+ export function loginPbxOf(ctx) {
145
+ try {
146
+ const email = pickProfile(ctx.env, ctx.profile);
147
+ const set = savedSession(email, ctx.env);
148
+ return set ? loginPbx(set.IdToken) : undefined;
149
+ } catch {
150
+ return undefined;
151
+ }
152
+ }
153
+
154
+ // The PBX of the x-bees login and its port, from the claims of the token.
155
+ export function loginPbx(token) {
156
+ const c = jwtClaims(token);
157
+ const host = c['custom:pbx_domain'];
158
+ if (!host) return undefined;
159
+ return {
160
+ host: String(host).toLowerCase(),
161
+ port: c['custom:pbx_port'] ? Number(c['custom:pbx_port']) : 443,
162
+ serial: c['custom:pbx_serial'] ? String(c['custom:pbx_serial']) : null,
163
+ };
164
+ }
165
+
166
+ // Which PBX and how: {host, port, auth: 'login'|'apikey'|'token'|'basic', header: () => Promise<string>, by}.
167
+ export async function resolvePbx(ctx) {
168
+ const {env} = ctx;
169
+ const wanted = ctx.pbx ? pbxHostOf(ctx.pbx, ctx) : defaultPbx(env);
170
+ const by = ctx.pbx ? 'option' : wanted ? 'default' : 'login';
171
+ if (env.WILDIX_PBX_API_KEY) {
172
+ const key = env.WILDIX_PBX_API_KEY.trim();
173
+ if (!API_KEY.test(key))
174
+ throw new UsageError('WILDIX_PBX_API_KEY is not a Company API Key (wsk-v1- and 93 letters and digits)');
175
+ if (!env.WILDIX_PBX_HOST)
176
+ throw new UsageError('WILDIX_PBX_API_KEY needs WILDIX_PBX_HOST: the one PBX the key may be sent to');
177
+ const host = pbxHostOf(env.WILDIX_PBX_HOST, ctx);
178
+ if (ctx.pbx && pbxHostOf(ctx.pbx, ctx) !== host)
179
+ throw new UsageError(
180
+ `WILDIX_PBX_API_KEY is for ${host} (WILDIX_PBX_HOST), not ${pbxHostOf(ctx.pbx, ctx)}: the key is not sent anywhere else`,
181
+ );
182
+ const port = env.WILDIX_PBX_PORT ? checkPort(env.WILDIX_PBX_PORT) : 443;
183
+ return {host, port, auth: 'apikey', by: 'environment', fromEnvironment: true, header: async () => `Bearer ${key}`};
184
+ }
185
+ if (wanted) {
186
+ const a = savedAccess(wanted, env);
187
+ if (a) return {host: a.host, port: a.port ?? 443, auth: a.auth, by, header: async () => authHeader(a)};
188
+ }
189
+ // The x-bees login: its own PBX only.
190
+ let token;
191
+ try {
192
+ token = await (ctx.freshToken ? ctx.freshToken() : getToken({profile: ctx.profile, env, fetchImpl: ctx.fetch}));
193
+ } catch (e) {
194
+ if (e instanceof AuthError && !wanted) {
195
+ throw new AuthError(
196
+ `No PBX to work on: add access to one (wx-admin pbx add <host>), or log in with an x-bees admin account (wx-admin login <e-mail>). ${e.message}`,
197
+ );
198
+ }
199
+ throw e;
200
+ }
201
+ const own = loginPbx(token);
202
+ if (!own)
203
+ throw new AuthError('This x-bees login is not linked to a PBX: add access to a PBX with wx-admin pbx add <host>');
204
+ if (wanted && wanted !== own.host) {
205
+ const serial = /^([0-9a-f]{8,16})\.wildixin\.com$/.exec(wanted)?.[1];
206
+ if (serial)
207
+ throw new AuthError(
208
+ `No known PBX has the serial ${serial}: add the PBX by its name (wx-admin pbx add <name>), which records its serial`,
209
+ );
210
+ throw new AuthError(
211
+ `No access saved for ${wanted}, and the x-bees login belongs to ${own.host}. Add it: wx-admin pbx add ${wanted}`,
212
+ );
213
+ }
214
+ return {...own, auth: 'login', by, header: async () => `Bearer ${await (ctx.freshToken ? ctx.freshToken() : token)}`};
215
+ }
216
+
217
+ function authHeader(a) {
218
+ if (a.auth === 'basic') return `Basic ${Buffer.from(`${a.username}:${a.password}`).toString('base64')}`;
219
+ return `Bearer ${a.auth === 'apikey' ? a.key : a.token}`;
220
+ }
221
+
222
+ export class PbxError extends Error {
223
+ constructor(message, status) {
224
+ super(message);
225
+ this.name = 'PbxError';
226
+ this.status = status;
227
+ if (status === 401) this.exitCode = 2; // as when not logged in: the access must be renewed
228
+ }
229
+ }
230
+
231
+ // What a refusal means, by the kind of access. scope: the resource of the route (dialplan, colleagues…).
232
+ function hint(status, target, method, scope) {
233
+ const key = target.auth === 'apikey';
234
+ if (status === 401) {
235
+ return key
236
+ ? 'The PBX did not accept the API key: it may have expired or been deleted (WMS → PBX → Integrations → Company API Keys).'
237
+ : target.auth === 'login'
238
+ ? 'The PBX did not accept the x-bees login: log in again (wx-admin login).'
239
+ : 'The PBX did not accept the access: check it (wx-admin pbx check) or add it again (Basic works only with ALLOW_BASIC_AUTH=true on the PBX).';
240
+ }
241
+ if (status === 403) {
242
+ const need = scope ? `${scope}:${['GET', 'HEAD'].includes(method) ? 'read' : '*'}` : undefined;
243
+ return key
244
+ ? `The API key may not do this${need ? `: it needs the scope ${need} (or pbx:${need.endsWith(':read') ? 'read' : '*'})` : ''}; if the key is bound to a PBX user, that extension must exist on this PBX.`
245
+ : 'The PBX refuses this to this user: it needs the Admin role and the ACL of the menu.';
246
+ }
247
+ if (status === 404) return 'Not found on this PBX (an id that does not exist, or a PBX without this API).';
248
+ return '';
249
+ }
250
+
251
+ // A client of the PBX REST API (https://<pbx>/api/v1). call(method, path, body) unwraps {"type":"result","result":X}
252
+ // to X and turns {"type":"error",…} and HTTP errors into a PbxError with a hint.
253
+ // access: a saved-access object to use instead of resolving one ({host, port, auth, …}): pbx add checks a new access
254
+ // with it before anything is saved.
255
+ export async function pbxClient(ctx, {access} = {}) {
256
+ const target = access
257
+ ? {
258
+ host: access.host,
259
+ port: access.port ?? 443,
260
+ auth: access.auth,
261
+ by: 'option',
262
+ header: async () => authHeader(access),
263
+ }
264
+ : await resolvePbx(ctx);
265
+ // WILDIX_PBX_URL replaces the address only with WILDIX_ALLOW_CUSTOM_URL=1 (tests against a local server).
266
+ const custom = ctx.env.WILDIX_PBX_URL && ctx.env.WILDIX_ALLOW_CUSTOM_URL === '1';
267
+ const base = custom
268
+ ? ctx.env.WILDIX_PBX_URL.replace(/\/+$/, '')
269
+ : `https://${target.host}${target.port && target.port !== 443 ? `:${target.port}` : ''}`;
270
+ // options.api 'v2': the routes the PBX serves at /api/v2 (call-group managers); else /api/v1.
271
+ const root = (version) => `${base}/api/${version === 'v2' ? 'v2' : 'v1'}`;
272
+ const fetchImpl = ctx.fetch ?? globalThis.fetch;
273
+ const checkPath = (apiPath) => {
274
+ if (typeof apiPath !== 'string' || !apiPath.startsWith('/') || apiPath.includes('..') || /[\s#]/.test(apiPath))
275
+ throw new UsageError(`Not an API path: ${apiPath}`);
276
+ };
277
+
278
+ // One request. options: scope (the API-key resource, for the hint of a 403); json (a body sent as JSON); data and
279
+ // contentType (a body sent as it is: a FormData for multipart, a Buffer…); responseType 'json' (default: the
280
+ // answer unwrapped), 'buffer' (the bytes, e.g. a recording) or 'response' (the Response, not checked).
281
+ async function request(method, apiPath, options = {}) {
282
+ checkPath(apiPath);
283
+ const headers = {
284
+ 'user-agent': userAgent(),
285
+ accept: options.responseType === 'buffer' ? '*/*' : 'application/json',
286
+ authorization: await target.header(),
287
+ };
288
+ let payload;
289
+ if (options.json !== undefined) {
290
+ headers['content-type'] = 'application/json';
291
+ payload = JSON.stringify(options.json);
292
+ } else if (options.data !== undefined) {
293
+ payload = options.data;
294
+ if (options.contentType) headers['content-type'] = options.contentType;
295
+ }
296
+ let res;
297
+ try {
298
+ res = await fetchImpl(root(options.api) + apiPath, {
299
+ method,
300
+ headers,
301
+ body: payload,
302
+ redirect: 'error',
303
+ signal: AbortSignal.timeout(options.timeoutMs ?? 60_000),
304
+ });
305
+ } catch (e) {
306
+ const serialHost = /^[0-9a-f]{8,16}\.wildixin\.com$/.test(target.host) && e.cause?.code === 'ENOTFOUND';
307
+ throw new PbxError(
308
+ `The PBX ${target.host} did not answer (${e.cause?.code ?? e.message})${serialHost ? '. A serial works with --pbx for a PBX wx-admin knows: add it by its name (wx-admin pbx add <name>), which records its serial' : ''}`,
309
+ );
310
+ }
311
+ if (options.responseType === 'response') return res;
312
+ if (options.responseType === 'buffer' && res.ok) {
313
+ const type = res.headers.get('content-type') ?? '';
314
+ if (!type.includes('application/json')) return Buffer.from(await res.arrayBuffer());
315
+ }
316
+ const text = await res.text();
317
+ let data;
318
+ try {
319
+ data = text ? JSON.parse(text) : {};
320
+ } catch {
321
+ data = {text};
322
+ }
323
+ if (!res.ok || data?.type === 'error') {
324
+ const reason = data?.reason ?? data?.text?.slice(0, 300) ?? '';
325
+ // errors: a list of messages, or {field: {code, reason}} (validation)
326
+ const errs = data?.errors;
327
+ const details =
328
+ Array.isArray(errs) && errs.length
329
+ ? ` ${JSON.stringify(errs).slice(0, 500)}`
330
+ : errs && typeof errs === 'object' && Object.keys(errs).length
331
+ ? ` (${Object.entries(errs)
332
+ .map(([k, v]) => `${k}: ${v?.reason ?? v?.code ?? JSON.stringify(v)}`)
333
+ .join('; ')
334
+ .slice(0, 500)})`
335
+ : '';
336
+ const why = hint(res.status, target, method, options.scope);
337
+ const err = new PbxError(
338
+ `HTTP ${res.status} from ${target.host} (${method} /api/${options.api === 'v2' ? 'v2' : 'v1'}${apiPath.split('?')[0]}): ${reason}${details}${why ? `\n${why}` : ''}`,
339
+ res.status,
340
+ );
341
+ err.errors = Array.isArray(data?.errors) ? data.errors : [];
342
+ throw err;
343
+ }
344
+ return data?.type === 'result' ? data.result : data;
345
+ }
346
+
347
+ return {
348
+ target,
349
+ request,
350
+ // call(method, path, body, {scope}): a JSON request (the common case).
351
+ call: (method, apiPath, body, options = {}) => request(method, apiPath, {...options, json: body}),
352
+ };
353
+ }
354
+
355
+ // The serial of a PBX (its settings, else its license), or null when the access may read neither.
356
+ export async function readSerial(client) {
357
+ for (const [path, pick] of [
358
+ ['/PBX/settings/', (r) => r?.pbxSerial],
359
+ ['/PBX/settings/license/', (r) => r?.hwserial ?? r?.limitations?.serial],
360
+ ]) {
361
+ try {
362
+ const v = pick(await client.call('GET', path, undefined, {scope: 'pbx'}));
363
+ if (typeof v === 'string' && /^[0-9a-f]{8,16}$/i.test(v)) return v.toLowerCase();
364
+ } catch {
365
+ // not readable with this access: try the next one
366
+ }
367
+ }
368
+ return null;
369
+ }
package/src/secret.mjs ADDED
@@ -0,0 +1,42 @@
1
+ // Reads a secret (an API key, a token, a password) without echo: hidden typing in a terminal, else the first line
2
+ // of stdin (`… | wx-admin pbx add …`). Never from a command line, where other processes can read it.
3
+ import {UsageError} from 'wldx-core/errors';
4
+
5
+ export async function readSecret(ctx, prompt) {
6
+ const input = ctx.stdin;
7
+ if (input.isTTY && typeof input.setRawMode === 'function') {
8
+ ctx.stderr.write(prompt);
9
+ input.setRawMode(true);
10
+ input.resume();
11
+ input.setEncoding('utf8');
12
+ try {
13
+ return await new Promise((resolve, reject) => {
14
+ let value = '';
15
+ const onData = (chunk) => {
16
+ for (const ch of chunk) {
17
+ if (ch === '\r' || ch === '\n') {
18
+ input.off('data', onData);
19
+ ctx.stderr.write('\n');
20
+ return resolve(value);
21
+ }
22
+ if (ch === '\u0003') {
23
+ input.off('data', onData);
24
+ ctx.stderr.write('\n');
25
+ return reject(new UsageError('Cancelled'));
26
+ }
27
+ if (ch === '\u007f' || ch === '\b') value = value.slice(0, -1);
28
+ else value += ch;
29
+ }
30
+ return undefined;
31
+ };
32
+ input.on('data', onData);
33
+ });
34
+ } finally {
35
+ input.setRawMode(false);
36
+ input.pause();
37
+ }
38
+ }
39
+ const chunks = [];
40
+ for await (const c of input) chunks.push(Buffer.from(c));
41
+ return Buffer.concat(chunks).toString('utf8').split(/\r?\n/)[0];
42
+ }
@@ -0,0 +1,3 @@
1
+ import {readFileSync} from 'node:fs';
2
+
3
+ export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;