wldx-admin-cli 0.0.0-stage → 1.2.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.
@@ -0,0 +1,808 @@
1
+ // wx-admin trunk: the trunks of a PBX (SIP, PSTN gateways, FXO gateways), trunk groups and price lists, over the PBX
2
+ // REST API (tags Trunks, Trunk groups, Trunks Price; Company API Key scope `trunks`).
3
+ //
4
+ // The model of the API: GET /Trunks/Sip/ (/Pstn/, /Fxo/) lists the trunks of a kind with every setting; there is no
5
+ // GET by id. A SIP trunk is {id, title, name, register, settingsPbx: {host, port, secret, context, …}, settingsWms:
6
+ // {enableReg, regSecret, …}, status}; SIP trunks are created and deleted, PSTN and FXO ones only changed. A write
7
+ // answers "Success": the commands read the list again. A PUT gets the whole trunk (the PBX wants every field): update
8
+ // reads it, applies the changes and writes it back.
9
+ // Secrets: settingsPbx.secret, settingsWms.regSecret and `register` (the SIP register string holds the password) are
10
+ // never shown in text, and in JSON they are masked as ******** unless --show-secrets. A masked value written back
11
+ // (update --from a file made with get) keeps the secret the trunk has.
12
+ // Price lists are CSV files on the PBX: create and update send one as multipart (name, upload); export returns it.
13
+ // Checked live: only the reads (list, get, groups, prices, price-export); writes only with tests (trunks carry calls).
14
+ import path from 'node:path';
15
+ import fs from 'node:fs';
16
+ import {UsageError} from 'wldx-core/errors';
17
+ import {readForUpload, refuseUnsafeWrite} from 'wldx-core/files';
18
+ import {oneLine, paint, table} from 'wldx-core/format';
19
+ import {apiPath} from 'wldx-core/http';
20
+ import {pbxClient} from '../pbx.mjs';
21
+ import {readSecret} from 'wldx-core/secret';
22
+ import {findRecord, readJsonInput, wholeNumber} from './routing.mjs';
23
+
24
+ const SCOPE = {scope: 'trunks'};
25
+ const KINDS = ['sip', 'pstn', 'fxo'];
26
+ const LIST_PATH = {sip: '/Trunks/Sip/', pstn: '/Trunks/Pstn/', fxo: '/Trunks/Fxo/'};
27
+ const trunkPath = {
28
+ sip: (id) => apiPath`/Trunks/Sip/${id}`,
29
+ pstn: (id) => apiPath`/Trunks/Pstn/${id}/`,
30
+ fxo: (id) => apiPath`/Trunks/Fxo/${id}/`,
31
+ };
32
+ const KIND_NAME = {sip: 'SIP', pstn: 'PSTN', fxo: 'FXO'};
33
+
34
+ export const MASK = '********';
35
+ // passwords and secrets, the SIP register string (it embeds the password), API keys and tokens
36
+ const SECRET_KEY = /secret|password|passwd|^register$|^key$|apikey|token/i;
37
+
38
+ // A copy with every non-empty secret replaced by MASK.
39
+ export function maskSecrets(value) {
40
+ if (Array.isArray(value)) return value.map(maskSecrets);
41
+ if (!value || typeof value !== 'object') return value;
42
+ return Object.fromEntries(
43
+ Object.entries(value).map(([k, v]) => [
44
+ k,
45
+ SECRET_KEY.test(k) && typeof v === 'string' && v !== '' ? MASK : maskSecrets(v),
46
+ ]),
47
+ );
48
+ }
49
+ const shown = (ctx, data) => (ctx.values['show-secrets'] ? data : maskSecrets(data));
50
+
51
+ // The name a trunk is known by: SIP name, PSTN name, FXO username.
52
+ const trunkName = (t) => t.name ?? t.username ?? t.settingsPbx?.peername ?? '';
53
+
54
+ async function listKind(client, kind) {
55
+ return (await client.call('GET', LIST_PATH[kind], undefined, SCOPE))?.records ?? [];
56
+ }
57
+
58
+ // A trunk by id, title or name, of one kind or of any ({kind, trunk}); an id of two kinds needs --type.
59
+ async function findTrunk(client, ref, kind) {
60
+ const kinds = kind ? [kind] : KINDS;
61
+ const s = String(ref);
62
+ const all = [];
63
+ for (const k of kinds) for (const t of await listKind(client, k)) all.push({kind: k, trunk: t});
64
+ const byId = /^\d+$/.test(s) ? all.filter((x) => String(x.trunk.id) === s) : [];
65
+ if (byId.length > 1)
66
+ throw new UsageError(
67
+ `Trunks of several kinds have the id ${s} (${byId.map((x) => KIND_NAME[x.kind]).join(', ')}): give --type`,
68
+ );
69
+ if (byId[0]) return byId[0];
70
+ // By title or name: the exact one first, else without case; two matches ask for the id (or --type).
71
+ const lower = s.toLowerCase();
72
+ const exact = all.filter((x) => x.trunk.title === s || trunkName(x.trunk) === s);
73
+ const pool = exact.length
74
+ ? exact
75
+ : all.filter(
76
+ (x) =>
77
+ String(x.trunk.title ?? '').toLowerCase() === lower || String(trunkName(x.trunk)).toLowerCase() === lower,
78
+ );
79
+ if (pool.length > 1)
80
+ throw new UsageError(
81
+ `Several trunks are named ${s} (${pool.map((x) => `${KIND_NAME[x.kind]} ${x.trunk.id}`).join(', ')}): give the id and --type`,
82
+ );
83
+ if (!pool.length) throw new UsageError(`No ${kind ? `${KIND_NAME[kind]} ` : ''}trunk ${s} (wx-admin trunk list)`);
84
+ return pool[0];
85
+ }
86
+
87
+ const statusOf = (kind, t) => {
88
+ if (kind === 'sip')
89
+ return `out ${t.status?.outgoing?.title ?? t.status?.outgoing?.state ?? '-'}, in ${t.status?.incoming?.title ?? t.status?.incoming?.state ?? '-'}`;
90
+ if (typeof t.status?.active === 'boolean') return t.status.active ? 'active' : 'inactive';
91
+ return '';
92
+ };
93
+ const hostOf = (kind, t) =>
94
+ kind === 'sip'
95
+ ? `${t.settingsPbx?.host ?? ''}${t.settingsPbx?.port ? `:${t.settingsPbx.port}` : ''}`
96
+ : (t.address ?? '');
97
+ const contextOf = (kind, t) =>
98
+ kind === 'fxo'
99
+ ? [
100
+ ...new Set(
101
+ [t.settingsMgw?.ports ?? []]
102
+ .flat()
103
+ .map((p) => p?.context)
104
+ .filter(Boolean),
105
+ ),
106
+ ].join(',')
107
+ : (t.settingsPbx?.context ?? '');
108
+ const row = (kind, t) => [
109
+ KIND_NAME[kind],
110
+ t.id,
111
+ t.title ?? '',
112
+ kind === 'sip' ? trunkName(t) : (t.technology ?? trunkName(t)),
113
+ hostOf(kind, t),
114
+ contextOf(kind, t),
115
+ statusOf(kind, t),
116
+ ];
117
+
118
+ function trunkLines(r, ctx) {
119
+ const {kind} = r;
120
+ const c = paint(ctx);
121
+ const set = (v) => (v ? 'set' : 'none');
122
+ const lines = [`${c.bold(r.title ?? trunkName(r))} (${KIND_NAME[kind]}, id ${r.id}) · name ${trunkName(r)}`];
123
+ if (kind === 'sip') {
124
+ const p = r.settingsPbx ?? {};
125
+ lines.push(
126
+ `host ${hostOf(kind, r) || '-'} · transport ${p.transportWildix ?? '-'} · context ${p.context ?? '-'} · login ${p.userName ?? '-'}`,
127
+ );
128
+ lines.push(
129
+ `codecs ${
130
+ (p.audioCodecs ?? [])
131
+ .filter((x) => x.enable)
132
+ .map((x) => x.key)
133
+ .join(', ') || '-'
134
+ } · dtmf ${p.dtmfMode ?? '-'} · srtp ${p.forceSrtp ? 'on' : 'off'}`,
135
+ );
136
+ lines.push(`registration ${r.settingsWms?.enableReg ? 'on' : 'off'} · ${statusOf(kind, r)}`);
137
+ lines.push(`password ${set(p.secret)}${r.settingsWms?.regSecret ? ' · proxy password set' : ''}`);
138
+ } else {
139
+ lines.push(
140
+ `${kind === 'pstn' ? `technology ${r.technology ?? '-'} · ports ${r.ports ?? '-'} · ` : `model ${r.model ?? '-'} · `}address ${r.address || '-'} · context ${contextOf(kind, r) || '-'}`,
141
+ );
142
+ if (statusOf(kind, r)) lines.push(statusOf(kind, r));
143
+ }
144
+ return lines;
145
+ }
146
+
147
+ // Every field of a new SIP trunk (as WMS makes them, seen live); the options fill in the rest.
148
+ function sipTemplate() {
149
+ return {
150
+ title: '',
151
+ name: '',
152
+ register: '',
153
+ settingsPbx: {
154
+ audioCodecs: [
155
+ {key: 'alaw', value: 20, enable: true},
156
+ {key: 'ulaw', value: 20, enable: true},
157
+ {key: 'g729', value: 20, enable: true},
158
+ ],
159
+ allowTransfer: false,
160
+ cidBody: '',
161
+ cidHeader: '',
162
+ context: 'main',
163
+ countryCode: 0,
164
+ isoCode: '',
165
+ disallow: 'all',
166
+ diversionHeader: '',
167
+ dtmfMode: 'rfc2833',
168
+ forceSrtp: false,
169
+ fromDomain: '',
170
+ fromName: '',
171
+ fromNumber: '',
172
+ fromUser: '',
173
+ historyInfoHeader: '',
174
+ host: '',
175
+ insecure: 'invite,port',
176
+ language: '',
177
+ peername: '',
178
+ port: '',
179
+ preserveOrigCid: true,
180
+ priceList: 0,
181
+ privacyHeader: '',
182
+ qualify: true,
183
+ rfcPayload: 101,
184
+ rport: 'invite',
185
+ secret: '',
186
+ sessionExpires: 600,
187
+ sessionMinse: 90,
188
+ sessionTimer: true,
189
+ ssrcChange: false,
190
+ symmetricRtp: true,
191
+ thirtyEightPtUdptl: true,
192
+ thirtyEightPtErrorCorrection: 'redundancy',
193
+ thirtyEightPtMaxDatagram: 400,
194
+ toneZone: '',
195
+ transportWildix: 'udp',
196
+ trustRpid: true,
197
+ type: 'friend',
198
+ userName: '',
199
+ videoCodecs: false,
200
+ playRemoteMoh: false,
201
+ contactNumber: '',
202
+ kernelLevelMedia: false,
203
+ },
204
+ settingsWms: {
205
+ dnsServer: '',
206
+ enableReg: false,
207
+ expiry: '',
208
+ natIp: '',
209
+ outboundProxy: '',
210
+ outboundProxyPort: '',
211
+ overwriteReg: false,
212
+ regFromUser: '',
213
+ regHost: '',
214
+ regPort: '',
215
+ regSecret: '',
216
+ regUsername: '',
217
+ },
218
+ };
219
+ }
220
+
221
+ const SETTINGS_KEYS = ['settingsPbx', 'settingsWms', 'settingsMgw'];
222
+ const isObject = (v) => v && typeof v === 'object' && !Array.isArray(v);
223
+
224
+ // The trunk with the fields of src applied (the settings objects one level deep); a masked secret keeps the current one.
225
+ function merged(current, src) {
226
+ const out = structuredClone(current);
227
+ for (const [k, v] of Object.entries(src ?? {})) {
228
+ if (k === 'id' || k === 'kind' || k === 'status') continue;
229
+ if (SETTINGS_KEYS.includes(k) && isObject(v)) {
230
+ out[k] = {...(out[k] ?? {})};
231
+ for (const [sk, sv] of Object.entries(v)) if (sv !== MASK) out[k][sk] = sv;
232
+ } else if (v !== MASK) out[k] = v;
233
+ }
234
+ return out;
235
+ }
236
+
237
+ const PORT = /^\d{1,5}$/;
238
+ const HOST = /^[A-Za-z0-9.-]{1,253}$/;
239
+ const TRANSPORTS = ['udp', 'tcp', 'tls'];
240
+
241
+ const SIP_OPTIONS = {
242
+ host: {
243
+ type: 'string',
244
+ placeholder: 'HOST',
245
+ description: 'the SIP proxy of the carrier (dynamic: the carrier registers to the PBX)',
246
+ },
247
+ port: {type: 'string', placeholder: 'N', description: 'its port (empty: the default)'},
248
+ user: {type: 'string', placeholder: 'LOGIN', description: 'the login the carrier gave (the name of the trunk)'},
249
+ context: {
250
+ type: 'string',
251
+ placeholder: 'DIALPLAN',
252
+ description: 'the dialplan for the calls that come in from the trunk',
253
+ },
254
+ transport: {type: 'string', choices: TRANSPORTS, description: 'the SIP transport'},
255
+ secret: {type: 'boolean', description: 'set the password: typed hidden, or the first line of stdin'},
256
+ };
257
+
258
+ // The SIP options applied to a trunk body (changes it).
259
+ async function applySipOptions(ctx, body) {
260
+ const v = ctx.values;
261
+ const p = body.settingsPbx;
262
+ if (v.host !== undefined) {
263
+ if (!HOST.test(v.host)) throw new UsageError(`Not a host: ${v.host}`);
264
+ p.host = v.host;
265
+ }
266
+ if (v.port !== undefined) {
267
+ if (v.port !== '' && (!PORT.test(v.port) || Number(v.port) > 65535)) throw new UsageError(`Not a port: ${v.port}`);
268
+ p.port = v.port;
269
+ }
270
+ if (v.user !== undefined) {
271
+ if (!/^\S{1,128}$/.test(v.user)) throw new UsageError(`Not a login: ${v.user}`);
272
+ body.name = v.user;
273
+ p.peername = v.user;
274
+ p.userName = v.user;
275
+ }
276
+ if (v.transport !== undefined) p.transportWildix = v.transport;
277
+ if (v.secret) {
278
+ if (v.from === '-') throw new UsageError('--secret reads stdin: give --from as a file');
279
+ const secret = await readSecret(ctx, 'Trunk password: ');
280
+ if (!secret) throw new UsageError('An empty password');
281
+ p.secret = secret;
282
+ }
283
+ }
284
+
285
+ const checkContext = (c) => {
286
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(c)) throw new UsageError(`Not a dialplan name: ${c}`);
287
+ return c;
288
+ };
289
+
290
+ // ---------------------------------------------------------------------------------------------------------------------
291
+ // Trunk groups: {id, name, description, rules: [{trunkName, remove, prepend, maxCalls, timeout}]}, tried in order.
292
+
293
+ const GROUPS = '/Trunks/Groups/';
294
+ const groupPath = (id) => apiPath`/Trunks/Groups/${id}`;
295
+ const GROUP_NAME = /^[0-9a-zA-Z_]{1,64}$/;
296
+ const PREPEND = /^\+?[0-9*#]*$/;
297
+
298
+ // A rule with the fields WMS sends (remove 0 and prepend '' when not given).
299
+ function groupRule(r) {
300
+ if (!isObject(r) || typeof r.trunkName !== 'string' || !r.trunkName)
301
+ throw new UsageError('A rule needs trunkName (the name of a trunk)');
302
+ const out = {
303
+ ...r,
304
+ trunkName: r.trunkName,
305
+ remove: wholeNumber(r.remove ?? 0, `${r.trunkName}: remove`, 0, 64),
306
+ prepend: String(r.prepend ?? ''),
307
+ };
308
+ if (!PREPEND.test(out.prepend))
309
+ throw new UsageError(`${r.trunkName}: prepend is digits, * and # (a + first): ${out.prepend}`);
310
+ for (const k of ['maxCalls', 'timeout'])
311
+ if (r[k] !== undefined && r[k] !== '') out[k] = wholeNumber(r[k], `${r.trunkName}: ${k}`, 0, 100000);
312
+ return out;
313
+ }
314
+
315
+ // --trunk "NAME remove=1 prepend=0 maxCalls=2 timeout=30" or JSON.
316
+ function parseRule(spec) {
317
+ const s = String(spec).trim();
318
+ if (s.startsWith('{')) {
319
+ try {
320
+ return groupRule(JSON.parse(s));
321
+ } catch (e) {
322
+ if (e instanceof UsageError) throw e;
323
+ throw new UsageError(`--trunk is not JSON: ${e.message}`);
324
+ }
325
+ }
326
+ const [trunkName, ...pairs] = s.split(/\s+/);
327
+ const rule = {trunkName};
328
+ for (const p of pairs) {
329
+ const at = p.indexOf('=');
330
+ const key = p.slice(0, at);
331
+ if (at < 1 || !['remove', 'prepend', 'maxCalls', 'timeout'].includes(key))
332
+ throw new UsageError(`--trunk takes remove=, prepend=, maxCalls=, timeout=: ${p}`);
333
+ rule[key] = p.slice(at + 1);
334
+ }
335
+ return groupRule(rule);
336
+ }
337
+
338
+ const ruleText = (r) => {
339
+ const extra = [
340
+ r.remove ? `-${r.remove}` : '',
341
+ r.prepend ? `+"${r.prepend}"` : '',
342
+ r.maxCalls ? `max ${r.maxCalls}` : '',
343
+ r.timeout ? `${r.timeout}s` : '',
344
+ ].filter(Boolean);
345
+ return extra.length ? `${r.trunkName}(${extra.join(' ')})` : r.trunkName;
346
+ };
347
+ const groupLines = (g) => [
348
+ `${g.name}${g.id !== undefined ? ` (id ${g.id})` : ''}${g.description ? ` — ${g.description}` : ''}`,
349
+ ...((g.rules ?? []).length ? g.rules.map((r, i) => `${String(i + 1).padStart(3)} ${ruleText(r)}`) : [' no trunks']),
350
+ ];
351
+
352
+ // A note when a rule names a trunk the PBX does not have (the PBX decides).
353
+ async function noteUnknownTrunks(ctx, client, rules) {
354
+ const names = new Set();
355
+ for (const k of KINDS) for (const t of await listKind(client, k)) names.add(trunkName(t));
356
+ for (const r of rules)
357
+ if (!names.has(r.trunkName))
358
+ ctx.info(`Note: no trunk is named ${r.trunkName} (wx-admin trunk list); the PBX decides.`);
359
+ }
360
+
361
+ const RULE_HELP = [
362
+ 'A --trunk is the name of a trunk (wx-admin trunk list) and what to do to the number before it is sent there:',
363
+ '--trunk "carrier1 remove=1 prepend=+39 maxCalls=10 timeout=30" (remove digits first, then prepend). The trunks are',
364
+ 'tried in the order given. Or JSON: --trunk \'{"trunkName": "carrier1", "remove": 1}\'.',
365
+ ].join('\n');
366
+
367
+ // ---------------------------------------------------------------------------------------------------------------------
368
+ // Price lists: {id, name, items: [{dialprefix, destination, rate, minDuration, billingBlock, connectCharge, startDay,
369
+ // stopDay, startTime, stopTime}]}, made from a CSV file.
370
+
371
+ const PRICES = '/Trunks/Prices/';
372
+ const pricePath = (id) => apiPath`/Trunks/Prices/${id}/`;
373
+
374
+ async function findPrice(client, ref) {
375
+ return findRecord(
376
+ (await client.call('GET', PRICES, undefined, SCOPE))?.records ?? [],
377
+ ref,
378
+ 'price list',
379
+ 'wx-admin trunk prices',
380
+ 'name',
381
+ );
382
+ }
383
+
384
+ function priceForm(ctx, name, file, extra = {}) {
385
+ const form = new FormData();
386
+ form.append('name', name);
387
+ if (file) {
388
+ const data = readForUpload(path.resolve(ctx.cwd ?? process.cwd(), file), ctx.env);
389
+ form.append('upload', new Blob([data], {type: 'text/csv'}), path.basename(file));
390
+ }
391
+ for (const [k, v] of Object.entries(extra)) form.append(k, v);
392
+ return form;
393
+ }
394
+
395
+ const priceLine = (p) => `${p.name} (id ${p.id}) · ${(p.items ?? []).length} rates`;
396
+
397
+ // ---------------------------------------------------------------------------------------------------------------------
398
+
399
+ const TYPE = {type: 'string', choices: KINDS, description: 'the kind of trunk'};
400
+ const SHOW = {
401
+ 'show-secrets': {type: 'boolean', description: 'show the passwords in the JSON (masked otherwise; never in text)'},
402
+ };
403
+
404
+ export const groups = {
405
+ trunk: {
406
+ summary: 'trunks (SIP, PSTN, FXO), trunk groups and price lists: list, show, create, change, delete',
407
+ commands: {
408
+ list: {
409
+ summary: 'the trunks of the PBX: kind, name, host, dialplan, registration',
410
+ usage: 'wx-admin trunk list [--type sip|pstn|fxo] [--show-secrets]',
411
+ details:
412
+ 'JSON: the answer of the PBX for one --type, else {sip, pstn, fxo} lists. Passwords are masked unless --show-secrets.',
413
+ options: {type: TYPE, ...SHOW},
414
+ args: [0, 0],
415
+ async run(ctx) {
416
+ const client = await pbxClient(ctx);
417
+ if (ctx.values.type)
418
+ return shown(ctx, await client.call('GET', LIST_PATH[ctx.values.type], undefined, SCOPE));
419
+ const out = {};
420
+ for (const k of KINDS) out[k] = await listKind(client, k);
421
+ return shown(ctx, out);
422
+ },
423
+ text: (r, ctx) => {
424
+ const rows = r.records
425
+ ? r.records.map((t) => row(ctx.values.type, t))
426
+ : KINDS.flatMap((k) => (r[k] ?? []).map((t) => row(k, t)));
427
+ return rows.length ? table(rows) : 'No trunks';
428
+ },
429
+ },
430
+ get: {
431
+ summary: 'one trunk and its settings (passwords never in text)',
432
+ usage: 'wx-admin trunk get <trunk> [--type sip|pstn|fxo] [--show-secrets]',
433
+ details: 'A trunk is its id, title or name. The JSON (with a `kind`) is what trunk update --from takes.',
434
+ options: {type: TYPE, ...SHOW},
435
+ args: [1, 1],
436
+ async run(ctx) {
437
+ const {kind, trunk} = await findTrunk(await pbxClient(ctx), ctx.positionals[0], ctx.values.type);
438
+ return shown(ctx, {kind, ...trunk});
439
+ },
440
+ text: trunkLines,
441
+ },
442
+ create: {
443
+ summary: 'create a SIP trunk',
444
+ usage:
445
+ 'wx-admin trunk create <title> --host HOST --user LOGIN [--secret] [--port N] [--context DIALPLAN] [--transport udp|tcp|tls] [--register] [--from FILE]',
446
+ details: [
447
+ 'The password is typed hidden, or read from the first line of stdin: never an argument.',
448
+ '--from reads a trunk as JSON (trunk get --output json of another one, without its secrets); the options win.',
449
+ 'Every other setting is what WMS gives a new trunk.',
450
+ ].join('\n'),
451
+ options: {
452
+ ...SIP_OPTIONS,
453
+ register: {type: 'boolean', description: 'register the PBX to the carrier (outgoing registration)'},
454
+ from: {type: 'string', placeholder: 'FILE', description: 'the trunk as JSON (a file)'},
455
+ },
456
+ args: [1, 1],
457
+ async run(ctx) {
458
+ const v = ctx.values;
459
+ const title = ctx.positionals[0];
460
+ if (!/^\S(.{0,98}\S)?$/.test(title)) throw new UsageError(`A trunk needs a title: ${JSON.stringify(title)}`);
461
+ const src = v.from ? await readJsonInput(ctx, v.from) : {};
462
+ if (src.kind && src.kind !== 'sip')
463
+ throw new UsageError(`Only SIP trunks are created; the file holds a ${src.kind} trunk`);
464
+ // Masked passwords of the file are left empty: the trunk's own one must be given.
465
+ if (JSON.stringify(src).includes(MASK) && !v.secret)
466
+ throw new UsageError('The file holds masked passwords: give the password with --secret');
467
+ const body = merged(sipTemplate(), src);
468
+ body.title = title;
469
+ body.register = typeof src.register === 'string' && src.register !== MASK ? src.register : '';
470
+ await applySipOptions(ctx, body);
471
+ if (v.context !== undefined) body.settingsPbx.context = checkContext(v.context);
472
+ if (v.register) body.settingsWms.enableReg = true;
473
+ if (!body.settingsPbx.host)
474
+ throw new UsageError('--host is needed (dynamic: the carrier registers to the PBX)', ctx.usage());
475
+ if (!body.name) throw new UsageError('--user is needed (the login of the trunk)', ctx.usage());
476
+ const client = await pbxClient(ctx);
477
+ await client.call('POST', LIST_PATH.sip, body, SCOPE);
478
+ const made = (await listKind(client, 'sip')).find((t) => t.name === body.name && t.title === title);
479
+ return shown(ctx, {kind: 'sip', ...(made ?? {title, name: body.name})});
480
+ },
481
+ text: (r, ctx) => [
482
+ `Created SIP trunk ${r.title}${r.id !== undefined ? ` (id ${r.id})` : ''}`,
483
+ ...(r.settingsPbx ? trunkLines(r, ctx).slice(1) : []),
484
+ ],
485
+ },
486
+ update: {
487
+ summary: 'change a trunk (needs --yes: a wrong setting stops its calls)',
488
+ usage:
489
+ 'wx-admin trunk update <trunk> [--type sip|pstn|fxo] [--title TEXT] [--context DIALPLAN] [--host HOST] [--port N] [--user LOGIN] [--transport udp|tcp|tls] [--secret] [--from FILE] --yes',
490
+ details: [
491
+ '--host, --port, --user, --transport and --secret are for SIP trunks; --context for SIP and PSTN (FXO: per port, with --from).',
492
+ '--from reads the fields to change as JSON (trunk get --output json, edited); masked passwords in it are kept as they are.',
493
+ 'The trunk is written whole: what is not changed stays.',
494
+ ].join('\n'),
495
+ options: {
496
+ type: TYPE,
497
+ title: {type: 'string', placeholder: 'TEXT', description: 'the new title'},
498
+ ...SIP_OPTIONS,
499
+ from: {
500
+ type: 'string',
501
+ placeholder: 'FILE',
502
+ description: 'the fields to change as JSON (a file, or - for stdin)',
503
+ },
504
+ yes: {type: 'boolean', description: 'yes, change the trunk'},
505
+ ...SHOW,
506
+ },
507
+ args: [1, 1],
508
+ async run(ctx) {
509
+ const v = ctx.values;
510
+ const sipOnly = ['host', 'port', 'user', 'transport', 'secret'].filter(
511
+ (k) => v[k] !== undefined && v[k] !== false,
512
+ );
513
+ if (v.title === undefined && v.context === undefined && !v.from && !sipOnly.length)
514
+ throw new UsageError('Nothing to change', ctx.usage());
515
+ if (!v.yes)
516
+ throw new UsageError(
517
+ 'Changing a trunk can stop the calls that go through it: confirm with --yes',
518
+ ctx.usage(),
519
+ );
520
+ // A new host with the old password would send that password to the new host.
521
+ if (v.host !== undefined && !v.secret)
522
+ throw new UsageError(
523
+ 'A new --host needs the password for it too: add --secret (the current one is not sent to another host)',
524
+ ctx.usage(),
525
+ );
526
+ const client = await pbxClient(ctx);
527
+ const {kind, trunk} = await findTrunk(client, ctx.positionals[0], v.type);
528
+ if (sipOnly.length && kind !== 'sip')
529
+ throw new UsageError(
530
+ `--${sipOnly[0]} is for SIP trunks; ${trunk.title ?? trunk.id} is ${KIND_NAME[kind]}: use --from`,
531
+ );
532
+ if (v.context !== undefined && kind === 'fxo')
533
+ throw new UsageError('An FXO trunk has a dialplan per port: use --from with settingsMgw.ports');
534
+ const src = v.from ? await readJsonInput(ctx, v.from) : {};
535
+ if (src.kind && src.kind !== kind)
536
+ throw new UsageError(`The file holds a ${src.kind} trunk; ${trunk.title ?? trunk.id} is ${kind}`);
537
+ const body = merged(trunk, src);
538
+ if (v.title !== undefined) body.title = v.title;
539
+ if (kind === 'sip') await applySipOptions(ctx, body);
540
+ if (v.context !== undefined) {
541
+ body.settingsPbx = {...(body.settingsPbx ?? {}), context: checkContext(v.context)};
542
+ }
543
+ await client.call('PUT', trunkPath[kind](trunk.id), body, SCOPE);
544
+ return shown(ctx, {kind, ...body});
545
+ },
546
+ text: (r, ctx) => [
547
+ `Updated ${KIND_NAME[r.kind]} trunk ${r.title ?? trunkName(r)} (id ${r.id})`,
548
+ ...trunkLines(r, ctx).slice(1),
549
+ ],
550
+ },
551
+ delete: {
552
+ summary: 'delete a SIP trunk (needs --yes: its calls stop)',
553
+ usage: 'wx-admin trunk delete <trunk> --yes',
554
+ options: {yes: {type: 'boolean', description: 'yes, delete it'}},
555
+ args: [1, 1],
556
+ async run(ctx) {
557
+ if (!ctx.values.yes)
558
+ throw new UsageError(
559
+ 'Deleting a trunk stops the calls that go through it: confirm with --yes',
560
+ ctx.usage(),
561
+ );
562
+ const client = await pbxClient(ctx);
563
+ const {trunk} = await findTrunk(client, ctx.positionals[0], 'sip');
564
+ await client.call('DELETE', trunkPath.sip(trunk.id), undefined, SCOPE);
565
+ return {deleted: {id: trunk.id, title: trunk.title, name: trunk.name}};
566
+ },
567
+ text: (r) => `Deleted SIP trunk ${r.deleted.title} (id ${r.deleted.id})`,
568
+ },
569
+ groups: {
570
+ summary: 'the trunk groups (trunks tried in order for outgoing calls)',
571
+ usage: 'wx-admin trunk groups [<group>]',
572
+ details: 'With a group (its id or name): its trunks in order.',
573
+ options: {},
574
+ args: [0, 1],
575
+ async run(ctx) {
576
+ const r = await (await pbxClient(ctx)).call('GET', GROUPS, undefined, SCOPE);
577
+ return ctx.positionals[0]
578
+ ? findRecord(r?.records ?? [], ctx.positionals[0], 'trunk group', 'wx-admin trunk groups', 'name')
579
+ : r;
580
+ },
581
+ text: (r) =>
582
+ r.records
583
+ ? r.records.length
584
+ ? table(
585
+ r.records.map((g) => [
586
+ g.id,
587
+ g.name,
588
+ oneLine((g.rules ?? []).map(ruleText).join(' → '), 70),
589
+ g.description ?? '',
590
+ ]),
591
+ )
592
+ : 'No trunk groups'
593
+ : groupLines(r),
594
+ },
595
+ 'group-create': {
596
+ summary: 'create a trunk group',
597
+ usage: 'wx-admin trunk group-create <name> --trunk SPEC... [--description TEXT]',
598
+ details: RULE_HELP,
599
+ options: {
600
+ trunk: {
601
+ type: 'string',
602
+ multiple: true,
603
+ placeholder: 'SPEC',
604
+ description: 'a trunk of the group and its number changes',
605
+ },
606
+ description: {type: 'string', placeholder: 'TEXT', description: 'what the group is for'},
607
+ },
608
+ args: [1, 1],
609
+ async run(ctx) {
610
+ const name = ctx.positionals[0];
611
+ if (!GROUP_NAME.test(name)) throw new UsageError(`A trunk group name is letters, digits and _: ${name}`);
612
+ if (!ctx.values.trunk?.length)
613
+ throw new UsageError('Give the trunks of the group: --trunk NAME', ctx.usage());
614
+ const body = {name, description: ctx.values.description ?? '', rules: ctx.values.trunk.map(parseRule)};
615
+ const client = await pbxClient(ctx);
616
+ await noteUnknownTrunks(ctx, client, body.rules);
617
+ await client.call('POST', GROUPS, body, SCOPE);
618
+ return (
619
+ ((await client.call('GET', GROUPS, undefined, SCOPE))?.records ?? []).find((g) => g.name === name) ?? body
620
+ );
621
+ },
622
+ text: (g) => [`Created trunk group ${groupLines(g)[0]}`, ...groupLines(g).slice(1)],
623
+ },
624
+ 'group-update': {
625
+ summary: 'change a trunk group: name, description, trunks (--trunk replaces them all)',
626
+ usage: 'wx-admin trunk group-update <group> [--name NAME] [--description TEXT] [--trunk SPEC]... [--yes]',
627
+ details: RULE_HELP,
628
+ options: {
629
+ name: {type: 'string', placeholder: 'NAME', description: 'the new name'},
630
+ description: {type: 'string', placeholder: 'TEXT', description: 'the new description'},
631
+ trunk: {
632
+ type: 'string',
633
+ multiple: true,
634
+ placeholder: 'SPEC',
635
+ description: 'the trunks of the group, in order (they replace all of them)',
636
+ },
637
+ yes: {type: 'boolean', description: 'yes, replace the trunks of the group'},
638
+ },
639
+ args: [1, 1],
640
+ async run(ctx) {
641
+ const v = ctx.values;
642
+ if (v.name === undefined && v.description === undefined && !v.trunk?.length)
643
+ throw new UsageError('Nothing to change', ctx.usage());
644
+ if (v.trunk?.length && !v.yes)
645
+ throw new UsageError('--trunk replaces every outgoing route of the group: confirm with --yes', ctx.usage());
646
+ const client = await pbxClient(ctx);
647
+ const current = findRecord(
648
+ (await client.call('GET', GROUPS, undefined, SCOPE))?.records ?? [],
649
+ ctx.positionals[0],
650
+ 'trunk group',
651
+ 'wx-admin trunk groups',
652
+ 'name',
653
+ );
654
+ const body = {
655
+ name: v.name ?? current.name,
656
+ description: v.description ?? current.description ?? '',
657
+ rules: v.trunk?.length ? v.trunk.map(parseRule) : (current.rules ?? []).map(groupRule),
658
+ };
659
+ if (!GROUP_NAME.test(body.name))
660
+ throw new UsageError(`A trunk group name is letters, digits and _: ${body.name}`);
661
+ if (v.trunk?.length) await noteUnknownTrunks(ctx, client, body.rules);
662
+ await client.call('PUT', groupPath(current.id), body, SCOPE);
663
+ return {id: current.id, ...body};
664
+ },
665
+ text: (g) => [`Updated trunk group ${groupLines(g)[0]}`, ...groupLines(g).slice(1)],
666
+ },
667
+ 'group-delete': {
668
+ summary: 'delete trunk groups (needs --yes: dialplans that dial them fail)',
669
+ usage: 'wx-admin trunk group-delete <group>... --yes',
670
+ options: {yes: {type: 'boolean', description: 'yes, delete them'}},
671
+ args: [1, 50],
672
+ async run(ctx) {
673
+ if (!ctx.values.yes)
674
+ throw new UsageError(
675
+ 'Deleting a trunk group stops the calls the dialplans send to it: confirm with --yes',
676
+ ctx.usage(),
677
+ );
678
+ const client = await pbxClient(ctx);
679
+ const records = (await client.call('GET', GROUPS, undefined, SCOPE))?.records ?? [];
680
+ const found = ctx.positionals.map((ref) =>
681
+ findRecord(records, ref, 'trunk group', 'wx-admin trunk groups', 'name'),
682
+ );
683
+ for (const g of found) await client.call('DELETE', groupPath(g.id), undefined, SCOPE);
684
+ return {deleted: found.map(({id, name}) => ({id, name}))};
685
+ },
686
+ text: (r) =>
687
+ `Deleted trunk ${r.deleted.length === 1 ? 'group' : 'groups'} ${r.deleted.map((g) => `${g.name} (id ${g.id})`).join(', ')}`,
688
+ },
689
+ prices: {
690
+ summary: 'the price lists of the trunks (call costs); with one, its rates',
691
+ usage: 'wx-admin trunk prices [<pricelist>]',
692
+ details: 'A price list is its id or its name.',
693
+ options: {},
694
+ args: [0, 1],
695
+ async run(ctx) {
696
+ const client = await pbxClient(ctx);
697
+ return ctx.positionals[0]
698
+ ? findPrice(client, ctx.positionals[0])
699
+ : client.call('GET', PRICES, undefined, SCOPE);
700
+ },
701
+ text: (r) => {
702
+ if (r.records)
703
+ return r.records.length
704
+ ? table(r.records.map((p) => [p.id, p.name, `${(p.items ?? []).length} rates`]))
705
+ : 'No price lists';
706
+ const rates = (r.items ?? []).map((i) => [
707
+ i.dialprefix,
708
+ i.destination,
709
+ i.rate,
710
+ `min ${i.minDuration}s`,
711
+ `block ${i.billingBlock}s`,
712
+ `connect ${i.connectCharge}`,
713
+ `days ${i.startDay}-${i.stopDay}`,
714
+ `${i.startTime}-${i.stopTime}`,
715
+ ]);
716
+ return [priceLine(r), ...(rates.length ? table(rates) : ['no rates'])];
717
+ },
718
+ },
719
+ 'price-create': {
720
+ summary: 'create a price list from a CSV file (the format of price-export)',
721
+ usage: 'wx-admin trunk price-create <name> --file CSV',
722
+ details:
723
+ 'The CSV columns: dialprefix, destination, rate, min_duration, billing_block, connect_charge, start_day, stop_day, start_time, stop_time.',
724
+ options: {file: {type: 'string', placeholder: 'CSV', description: 'the rates as CSV'}},
725
+ args: [1, 1],
726
+ async run(ctx) {
727
+ const name = ctx.positionals[0];
728
+ if (!/^\S(.{0,98}\S)?$/.test(name))
729
+ throw new UsageError(`A price list needs a name: ${JSON.stringify(name)}`);
730
+ if (!ctx.values.file) throw new UsageError('--file is needed', ctx.usage());
731
+ const form = priceForm(ctx, name, ctx.values.file);
732
+ return (await pbxClient(ctx)).request('POST', PRICES, {data: form, ...SCOPE});
733
+ },
734
+ text: (p) => `Created price list ${priceLine(p)}`,
735
+ },
736
+ 'price-update': {
737
+ summary: 'rename a price list, or replace its rates from a CSV file',
738
+ usage: 'wx-admin trunk price-update <pricelist> [--name NAME] [--file CSV]',
739
+ options: {
740
+ name: {type: 'string', placeholder: 'NAME', description: 'the new name'},
741
+ file: {type: 'string', placeholder: 'CSV', description: 'the new rates as CSV (they replace all of them)'},
742
+ },
743
+ args: [1, 1],
744
+ async run(ctx) {
745
+ const v = ctx.values;
746
+ if (v.name === undefined && !v.file)
747
+ throw new UsageError('Nothing to change: give --name or --file', ctx.usage());
748
+ const client = await pbxClient(ctx);
749
+ const current = await findPrice(client, ctx.positionals[0]);
750
+ const form = priceForm(ctx, v.name ?? current.name, v.file, {method: 'PUT'});
751
+ return client.request('POST', pricePath(current.id), {data: form, ...SCOPE});
752
+ },
753
+ text: (p) => `Updated price list ${priceLine(p)}`,
754
+ },
755
+ 'price-delete': {
756
+ summary: 'delete price lists (needs --yes)',
757
+ usage: 'wx-admin trunk price-delete <pricelist>... --yes',
758
+ options: {yes: {type: 'boolean', description: 'yes, delete them'}},
759
+ args: [1, 50],
760
+ async run(ctx) {
761
+ if (!ctx.values.yes)
762
+ throw new UsageError(
763
+ 'Deleting a price list loses its rates (export it first): confirm with --yes',
764
+ ctx.usage(),
765
+ );
766
+ const client = await pbxClient(ctx);
767
+ const found = [];
768
+ for (const ref of ctx.positionals) found.push(await findPrice(client, ref));
769
+ for (const p of found) await client.call('DELETE', pricePath(p.id), undefined, SCOPE);
770
+ return {deleted: found.map(({id, name}) => ({id, name}))};
771
+ },
772
+ text: (r) =>
773
+ `Deleted price ${r.deleted.length === 1 ? 'list' : 'lists'} ${r.deleted.map((p) => `${p.name} (id ${p.id})`).join(', ')}`,
774
+ },
775
+ 'price-export': {
776
+ summary: 'a price list as CSV (to stdout, or to a new file)',
777
+ usage: 'wx-admin trunk price-export <pricelist> [--to FILE]',
778
+ options: {
779
+ to: {type: 'string', placeholder: 'FILE', description: 'write it to this file (which must not exist)'},
780
+ },
781
+ args: [1, 1],
782
+ async run(ctx) {
783
+ const client = await pbxClient(ctx);
784
+ // The PBX exports any id (a header only for one it does not have): find it first.
785
+ const p = await findPrice(client, ctx.positionals[0]);
786
+ const csv = await client.request('GET', apiPath`/Trunks/Prices/${p.id}/export`, {
787
+ responseType: 'buffer',
788
+ ...SCOPE,
789
+ });
790
+ const data = Buffer.isBuffer(csv) ? csv : Buffer.from(typeof csv === 'string' ? csv : JSON.stringify(csv));
791
+ if (!ctx.values.to) return {id: p.id, name: p.name, csv: data.toString('utf8')};
792
+ const file = path.resolve(ctx.cwd ?? process.cwd(), ctx.values.to);
793
+ refuseUnsafeWrite(file, ctx.env);
794
+ try {
795
+ fs.writeFileSync(file, data, {flag: 'wx'});
796
+ } catch (e) {
797
+ if (e.code === 'EEXIST')
798
+ throw new UsageError(`${ctx.values.to} exists: choose another file (nothing was overwritten)`);
799
+ throw e;
800
+ }
801
+ return {id: p.id, name: p.name, file, bytes: data.length};
802
+ },
803
+ text: (r) =>
804
+ r.file ? `Exported ${r.name} (id ${r.id}, ${r.bytes} bytes) to ${r.file}` : r.csv.replace(/\n$/, ''),
805
+ },
806
+ },
807
+ },
808
+ };