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.
@@ -0,0 +1,668 @@
1
+ // wx-admin system: the PBX itself — version and license, PBX-wide settings (features, SMTP, NTP, HTTP proxy, trusted
2
+ // IPs), open ports, upgrades and reboot, over the PBX REST API.
3
+ // Operations (OpenAPI tags PBX, License, SMTP, NTP, HTTP Proxy, TrustedIP, Upgrade, System):
4
+ // GET /PBX/version/, GET /PBX/ping/, GET /PBX/Ports/Status/ scope info (ping: no scope in the spec, guessed)
5
+ // GET /PBX/settings/license/, GET|PUT /PBX/settings/ scope pbx
6
+ // GET|PUT /PBX/settings/ntp/, GET|PUT /PBX/settings/httpProxy/ scope pbx
7
+ // GET|PUT /PBX/settings/smtp/, POST /PBX/settings/smtp/test/,
8
+ // GET /PBX/settings/smtp/test/status/{testId}/ scope smtp
9
+ // GET|PUT /PBX/settings/trusted-ips/ scope security (guessed; the spec says /TrustedIP/,
10
+ // which answers 404: the path of its curl sample works)
11
+ // GET|POST /PBX/Upgrade/, GET|POST|… /PBX/Upgrade/Check/, GET /PBX/Upgrade/Settings/ scope pbx
12
+ // PUT /PBX/System/Reboot/ scope pbxpower
13
+ // Passwords in the answers (SMTP, HTTP proxy) are never printed as text, and in JSON only with --show-secrets.
14
+ import {UsageError} from 'wldx-core/errors';
15
+ import {localTime, oneLine, paint, table} from 'wldx-core/format';
16
+ import {apiPath} from 'wldx-core/http';
17
+ import {pbxClient} from '../pbx.mjs';
18
+ import {readSecret} from '../secret.mjs';
19
+
20
+ // The features PUT /PBX/settings/ takes (one per request).
21
+ export const FEATURES = [
22
+ 'callControl',
23
+ 'cloudAnalytics',
24
+ 'mfa',
25
+ 'allowCollaboration6',
26
+ 'keepCallDialogAfterCall',
27
+ 'sms',
28
+ 'zabbixAgent',
29
+ 'xcaracal',
30
+ ];
31
+ // msmtp's "auth" values; the PBX answers "off" when there is none (the spec says false/true).
32
+ const SMTP_AUTH = ['off', 'on', 'plain', 'login', 'cram-md5', 'digest-md5', 'gssapi', 'ntlm'];
33
+ const SMTP_TIMEOUTS = ['2', '5', '10', '15', '20', '60'];
34
+ const ON_OFF = ['on', 'off'];
35
+ const HOST = /^[A-Za-z0-9]([A-Za-z0-9.-]{0,252})$|^\[?[0-9a-fA-F:]+\]?$/;
36
+ const IP_OR_NET = /^[0-9a-fA-F:.]{2,45}(\/\d{1,3})?$/;
37
+
38
+ const yesOption = (what) => ({yes: {type: 'boolean', description: `yes, ${what}`}});
39
+ const showSecrets = {
40
+ 'show-secrets': {
41
+ type: 'boolean',
42
+ description: 'with --output json: keep the password in the answer (never shown as text)',
43
+ },
44
+ };
45
+
46
+ const need = (ctx, why) => {
47
+ if (!ctx.values.yes) throw new UsageError(`${why}: confirm with --yes`, ctx.usage());
48
+ };
49
+
50
+ // The data without its password fields, unless JSON with --show-secrets; `passwordSet` says whether there is one.
51
+ function hidePasswords(data, ctx, keys = ['password']) {
52
+ if (!data || typeof data !== 'object') return data;
53
+ if (ctx.values['show-secrets'] && ctx.output === 'json') return data;
54
+ const out = {...data};
55
+ for (const k of keys) {
56
+ if (k in out) {
57
+ out[`${k}Set`] = Boolean(out[k]);
58
+ delete out[k];
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+
64
+ const checkHost = (v, name) => {
65
+ if (v !== '' && !HOST.test(v)) throw new UsageError(`--${name} is a host name or an IP address: ${v}`);
66
+ return v;
67
+ };
68
+
69
+ const checkPortValue = (v, name = 'port') => {
70
+ const n = Number(v);
71
+ if (!Number.isInteger(n) || n < 1 || n > 65535) throw new UsageError(`--${name} is a port (1-65535): ${v}`);
72
+ return n;
73
+ };
74
+
75
+ const onOff = (v) => (v === undefined ? undefined : v === 'on');
76
+
77
+ const sleep = (ms, signal) =>
78
+ new Promise((resolve) => {
79
+ if (signal?.aborted) return resolve();
80
+ const t = setTimeout(done, ms);
81
+ function done() {
82
+ clearTimeout(t);
83
+ signal?.removeEventListener('abort', done);
84
+ resolve();
85
+ }
86
+ signal?.addEventListener('abort', done, {once: true});
87
+ });
88
+
89
+ // Polls read() until done(result) or --wait seconds (default: seconds) have passed (or Ctrl-C); returns {result, finished}.
90
+ async function poll(ctx, read, done, seconds) {
91
+ const wait = Number(ctx.values.wait ?? seconds);
92
+ if (!Number.isFinite(wait) || wait < 0) throw new UsageError(`--wait is seconds: ${ctx.values.wait}`);
93
+ const until = ctx.clock() + wait * 1000;
94
+ for (;;) {
95
+ const result = await read();
96
+ if (done(result)) return {result, finished: true};
97
+ const left = until - ctx.clock();
98
+ if (left <= 0 || ctx.signal?.aborted) return {result, finished: false};
99
+ await sleep(Math.min(2000, left), ctx.signal);
100
+ }
101
+ }
102
+
103
+ const waitOption = (seconds) => ({
104
+ wait: {type: 'string', placeholder: 'SECONDS', description: `how long to wait for the result (default ${seconds})`},
105
+ });
106
+
107
+ // "1797206400" (unix seconds) → the local date; "*" → "no end".
108
+ const untilDate = (v) =>
109
+ v === '*' ? 'no end' : /^\d+$/.test(String(v ?? '')) ? localTime(Number(v) * 1000).slice(0, 10) : String(v ?? '');
110
+
111
+ // The license types with a total or a use: [[type, used, total]].
112
+ const licenseRows = (l) => {
113
+ const totals = l?.limitations?.licenseTypes ?? {};
114
+ const used = l?.licenseUsage ?? {};
115
+ return [...new Set([...Object.keys(totals), ...Object.keys(used)])]
116
+ .filter((t) => Number(totals[t]) || Number(used[t]))
117
+ .map((t) => [t, Number(used[t] ?? 0), Number(totals[t] ?? 0)]);
118
+ };
119
+
120
+ // The HTML of the upgrade logs (<b>…</b>) as plain text.
121
+ const plain = (s) => String(s ?? '').replace(/<[^>]*>/g, '');
122
+
123
+ const featureLine = (f, c) => [
124
+ f.name,
125
+ f.enabled ? c.green('on') : c.dim('off'),
126
+ oneLine(
127
+ `${f.label ?? ''}${f.available === false ? ' (not available)' : ''}${f.error ? ` — ${f.error}` : ''}${f.message ? ` (${f.message})` : ''}`,
128
+ 80,
129
+ ),
130
+ ];
131
+
132
+ async function current(client, path, scope) {
133
+ return (await client.call('GET', path, undefined, {scope})) ?? {};
134
+ }
135
+
136
+ export const groups = {
137
+ system: {
138
+ summary:
139
+ 'the PBX itself: version, license, settings (features, SMTP, NTP, proxy, trusted IPs), ports, upgrade, reboot',
140
+ commands: {
141
+ info: {
142
+ summary: 'version, ping and license summary of the PBX in one',
143
+ usage: 'wx-admin system info',
144
+ options: {},
145
+ args: [0, 0],
146
+ async run(ctx) {
147
+ const client = await pbxClient(ctx);
148
+ const version = await client.call('GET', '/PBX/version/', undefined, {scope: 'info'});
149
+ const start = ctx.clock();
150
+ const ping = await client.call('GET', '/PBX/ping/', undefined, {scope: 'info'});
151
+ const pingMs = ctx.clock() - start;
152
+ let license;
153
+ let licenseError;
154
+ try {
155
+ const l = await client.call('GET', '/PBX/settings/license/', undefined, {scope: 'pbx'});
156
+ license = {
157
+ activated: l?.activated,
158
+ domain: l?.domain,
159
+ licenses: licenseRows(l).map(([type, used, total]) => ({type, used, total})),
160
+ };
161
+ } catch (e) {
162
+ if (!e.status) throw e;
163
+ licenseError = e.message.split('\n')[0]; // e.g. an API key without pbx:read
164
+ }
165
+ return {host: client.target.host, version, ping, pingMs, license, licenseError};
166
+ },
167
+ text: (r, ctx) => {
168
+ const c = paint(ctx);
169
+ const lines = [`${c.bold(r.host)} WMS ${r.version} ping ${r.ping} (${r.pingMs} ms)`];
170
+ if (r.license) {
171
+ const list = r.license.licenses.map((l) => `${l.type} ${l.used}/${l.total}`).join(' · ');
172
+ lines.push(
173
+ `license ${r.license.activated ? 'activated' : c.red('not activated')}${list ? ` · ${list}` : ''}`,
174
+ );
175
+ } else lines.push(c.dim(`license: not readable (${r.licenseError})`));
176
+ return lines;
177
+ },
178
+ },
179
+ license: {
180
+ summary: 'the license of the PBX: limits, license types used and bought, options and until when',
181
+ usage: 'wx-admin system license',
182
+ options: {},
183
+ args: [0, 0],
184
+ async run(ctx) {
185
+ return (await pbxClient(ctx)).call('GET', '/PBX/settings/license/', undefined, {scope: 'pbx'});
186
+ },
187
+ text: (l, ctx) => {
188
+ const c = paint(ctx);
189
+ const lim = l.limitations ?? {};
190
+ const options = Object.entries(lim).filter(([, v]) => v && typeof v === 'object' && 'state' in v);
191
+ return [
192
+ `${c.bold(l.domain ?? '')} · ${l.activated ? 'activated' : c.red('not activated')}${lim.serial ? ` · serial ${lim.serial}` : ''}`,
193
+ `limits: ${lim.phones ?? '?'} phones · ${lim.trunks ?? '?'} trunks · ${lim.calls ?? '?'} calls · ${lim.rooms ?? '?'} rooms`,
194
+ ...table([['license', 'used', 'total'], ...licenseRows(l)]),
195
+ ...(options.length
196
+ ? [
197
+ 'options:',
198
+ ...table(
199
+ options.map(([k, v]) => [
200
+ ` ${k}`,
201
+ v.state ? c.green('on') : c.dim('off'),
202
+ v.state ? `until ${untilDate(v.until)}` : '',
203
+ ]),
204
+ ),
205
+ ]
206
+ : []),
207
+ ];
208
+ },
209
+ },
210
+ settings: {
211
+ summary: 'the settings of the PBX: name, version, cloud, language, license, features, network interfaces',
212
+ usage: 'wx-admin system settings',
213
+ options: {},
214
+ args: [0, 0],
215
+ async run(ctx) {
216
+ return (await pbxClient(ctx)).call('GET', '/PBX/settings/', undefined, {scope: 'pbx'});
217
+ },
218
+ text: (s, ctx) => {
219
+ const c = paint(ctx);
220
+ const lic = s.license ?? {};
221
+ return [
222
+ `${c.bold(s.pbxName ?? '')} (${s.host ?? ''}) · WMS ${s.version ?? '?'} · ${s.isCloud ? 'cloud' : 'on-premise'} · language ${s.defaultLanguage ?? '?'}${s.wmsNetworkRole && s.wmsNetworkRole !== 'null' ? ` · WMS network ${s.wmsNetworkRole}` : ''}`,
223
+ `license: ${lic.activated ? 'activated' : 'not activated'}${lic.maximumAvailableLicenseType ? `, up to ${lic.maximumAvailableLicenseType}` : ''}${lic.subscription ? ', subscription' : ''}`,
224
+ 'features (system settings-set):',
225
+ ...table((s.features ?? []).map((f) => featureLine(f, c))).map((l) => ` ${l}`),
226
+ ...((s.integrations ?? []).length
227
+ ? [`integrations: ${s.integrations.map((i) => `${i.name} ${i.enabled ? 'on' : 'off'}`).join(', ')}`]
228
+ : []),
229
+ ...((s.networkInterfaces ?? []).length
230
+ ? [
231
+ 'network:',
232
+ ...table(
233
+ s.networkInterfaces.map((n) => [
234
+ ` ${n.name}`,
235
+ n.alias ?? '',
236
+ n.activate ?? '',
237
+ n.address ? `${n.address}/${n.netmask}` : '',
238
+ ]),
239
+ ),
240
+ ]
241
+ : []),
242
+ ];
243
+ },
244
+ },
245
+ 'settings-set': {
246
+ summary: 'turn a PBX feature on or off (needs --yes: it changes the whole PBX)',
247
+ usage: 'wx-admin system settings-set --feature NAME (--enable | --disable) --yes',
248
+ details:
249
+ 'One feature per request, as the API takes it. The features and their state: wx-admin system settings.',
250
+ options: {
251
+ feature: {type: 'string', choices: FEATURES, description: 'the feature to change'},
252
+ enable: {type: 'boolean', description: 'turn it on'},
253
+ disable: {type: 'boolean', description: 'turn it off'},
254
+ ...yesOption('change the PBX'),
255
+ },
256
+ args: [0, 0],
257
+ async run(ctx) {
258
+ const {feature, enable, disable} = ctx.values;
259
+ if (!feature) throw new UsageError('--feature is needed', ctx.usage());
260
+ if (Boolean(enable) === Boolean(disable)) throw new UsageError('Give --enable or --disable', ctx.usage());
261
+ need(ctx, `Turning ${feature} ${enable ? 'on' : 'off'} changes the whole PBX`);
262
+ const result = await (await pbxClient(ctx)).call(
263
+ 'PUT',
264
+ '/PBX/settings/',
265
+ {features: [{name: feature, enabled: Boolean(enable)}]},
266
+ {scope: 'pbx'},
267
+ );
268
+ return {feature, enabled: Boolean(enable), result};
269
+ },
270
+ text: (r) => `${r.feature} turned ${r.enabled ? 'on' : 'off'}`,
271
+ },
272
+ smtp: {
273
+ summary: 'the SMTP settings the PBX sends mail with (the password is never shown)',
274
+ usage: 'wx-admin system smtp [--show-secrets]',
275
+ options: {...showSecrets},
276
+ args: [0, 0],
277
+ async run(ctx) {
278
+ return hidePasswords(await current(await pbxClient(ctx), '/PBX/settings/smtp/', 'smtp'), ctx);
279
+ },
280
+ text: (s) => smtpLines(s),
281
+ },
282
+ 'smtp-set': {
283
+ summary: 'change the SMTP settings (needs --yes: mail of the whole PBX goes through them)',
284
+ usage:
285
+ 'wx-admin system smtp-set [--host HOST] [--port N] [--from EMAIL] [--user NAME] [--password] [--auth METHOD] [--tls on|off] [--starttls on|off] [--timeout S] [--domain NAME] [--cloud-default on|off] --yes',
286
+ details:
287
+ 'What is not given is kept. --password reads the new password hidden from the terminal, or the first line of stdin.',
288
+ options: {
289
+ host: {type: 'string', placeholder: 'HOST', description: 'the SMTP server'},
290
+ port: {type: 'string', placeholder: 'N', description: 'its port'},
291
+ from: {type: 'string', placeholder: 'EMAIL', description: 'the sender of the mail of the PBX'},
292
+ user: {type: 'string', placeholder: 'NAME', description: 'the SMTP user'},
293
+ password: {type: 'boolean', description: 'set a new password, typed hidden or read from stdin'},
294
+ auth: {type: 'string', choices: SMTP_AUTH, description: 'the authentication method (off: none)'},
295
+ tls: {type: 'string', choices: ON_OFF, description: 'TLS from the start'},
296
+ starttls: {type: 'string', choices: ON_OFF, description: 'STARTTLS'},
297
+ timeout: {type: 'string', choices: SMTP_TIMEOUTS, description: 'seconds to wait for the server'},
298
+ domain: {type: 'string', placeholder: 'NAME', description: 'the HELO domain'},
299
+ 'cloud-default': {
300
+ type: 'string',
301
+ choices: ON_OFF,
302
+ description: 'use the default SMTP of Wildix (cloud PBX only)',
303
+ },
304
+ ...yesOption('change the SMTP settings'),
305
+ },
306
+ args: [0, 0],
307
+ async run(ctx) {
308
+ const v = ctx.values;
309
+ const change = {};
310
+ if (v.host !== undefined) change.host = checkHost(v.host, 'host');
311
+ if (v.port !== undefined) change.port = checkPortValue(v.port);
312
+ if (v.from !== undefined) change.from = v.from;
313
+ if (v.user !== undefined) change.user = v.user;
314
+ if (v.auth !== undefined) change.auth = v.auth;
315
+ if (v.tls !== undefined) change.tls = onOff(v.tls);
316
+ if (v.starttls !== undefined) change.tlsStartTls = onOff(v.starttls);
317
+ if (v.timeout !== undefined) change.timeout = Number(v.timeout);
318
+ if (v.domain !== undefined) change.domain = v.domain;
319
+ if (v['cloud-default'] !== undefined) change.isDefaultSettings = onOff(v['cloud-default']);
320
+ if (!Object.keys(change).length && !v.password) throw new UsageError('Nothing to change', ctx.usage());
321
+ need(ctx, 'The SMTP settings carry the mail of the whole PBX');
322
+ // A new host with the old password would send that password to the new host.
323
+ if (v.host !== undefined && !v.password)
324
+ throw new UsageError(
325
+ 'A new --host needs the password for it too: add --password (the current one is not sent to another host)',
326
+ ctx.usage(),
327
+ );
328
+ if (v.password) change.password = await readSecret(ctx, 'SMTP password: ');
329
+ const client = await pbxClient(ctx);
330
+ // The PUT wants auth, port and timeout: the current settings (password included, so it is kept) plus the change.
331
+ const body = {...(await current(client, '/PBX/settings/smtp/', 'smtp')), ...change};
332
+ const result = await client.call('PUT', '/PBX/settings/smtp/', body, {scope: 'smtp'});
333
+ return hidePasswords(result && typeof result === 'object' ? result : {...body, result}, {...ctx, values: {}});
334
+ },
335
+ text: (s) => ['SMTP settings saved', ...smtpLines(s)],
336
+ },
337
+ 'smtp-test': {
338
+ summary: 'send a test e-mail with the SMTP settings and show the log of the SMTP server',
339
+ usage: 'wx-admin system smtp-test --to EMAIL [--from EMAIL] [--body TEXT] [--wait SECONDS]',
340
+ details:
341
+ '--from defaults to the sender of the SMTP settings. The test runs on the PBX; its status is read until it is done.',
342
+ options: {
343
+ to: {type: 'string', placeholder: 'EMAIL', description: 'where to send the test e-mail'},
344
+ from: {
345
+ type: 'string',
346
+ placeholder: 'EMAIL',
347
+ description: 'the sender (default: the one of the SMTP settings)',
348
+ },
349
+ body: {type: 'string', placeholder: 'TEXT', description: 'the text of the e-mail'},
350
+ ...waitOption(60),
351
+ },
352
+ args: [0, 0],
353
+ async run(ctx) {
354
+ const {to, body} = ctx.values;
355
+ if (!to || !/^[^\s@]+@[^\s@]+$/.test(to)) throw new UsageError('--to is an e-mail address', ctx.usage());
356
+ const client = await pbxClient(ctx);
357
+ const from = ctx.values.from ?? (await current(client, '/PBX/settings/smtp/', 'smtp')).from;
358
+ if (!from) throw new UsageError('The SMTP settings have no sender: give --from');
359
+ const started = await client.call(
360
+ 'POST',
361
+ '/PBX/settings/smtp/test/',
362
+ {mailto: to, from, body: body ?? `Test e-mail from the PBX ${client.target.host} (wx-admin)`},
363
+ {scope: 'smtp'},
364
+ );
365
+ const testId = started?.testId || 'default';
366
+ const {result, finished} = await poll(
367
+ ctx,
368
+ () => client.call('GET', apiPath`/PBX/settings/smtp/test/status/${testId}/`, undefined, {scope: 'smtp'}),
369
+ (s) => s && s.queued === false,
370
+ 60,
371
+ );
372
+ return {to, from, testId, finished, ...(result ?? {})};
373
+ },
374
+ text: (r) => [
375
+ `Test e-mail to ${r.to} from ${r.from}: ${r.finished ? 'done' : 'still queued'}`,
376
+ ...String(r.content ?? '')
377
+ .split('\n')
378
+ .filter(Boolean)
379
+ .map((l) => ` ${l}`),
380
+ ],
381
+ },
382
+ ntp: {
383
+ summary: 'the NTP servers of the PBX and its time',
384
+ usage: 'wx-admin system ntp',
385
+ options: {},
386
+ args: [0, 0],
387
+ async run(ctx) {
388
+ return (await pbxClient(ctx)).call('GET', '/PBX/settings/ntp/', undefined, {scope: 'pbx'});
389
+ },
390
+ text: (n) => [`primary ${n.pntp || '-'}`, `secondary ${n.sntp || '-'}`, `PBX time ${n.time ?? ''}`],
391
+ },
392
+ 'ntp-set': {
393
+ summary: 'change the NTP servers (needs --yes: the clock of the whole PBX follows them)',
394
+ usage: 'wx-admin system ntp-set [--primary HOST] [--secondary HOST] --yes',
395
+ options: {
396
+ primary: {type: 'string', placeholder: 'HOST', description: 'the primary NTP server (name or IP)'},
397
+ secondary: {
398
+ type: 'string',
399
+ placeholder: 'HOST',
400
+ description: 'the secondary NTP server (empty to remove it)',
401
+ },
402
+ ...yesOption('change the NTP servers'),
403
+ },
404
+ args: [0, 0],
405
+ async run(ctx) {
406
+ const {primary, secondary} = ctx.values;
407
+ if (primary === undefined && secondary === undefined)
408
+ throw new UsageError('Give --primary and/or --secondary', ctx.usage());
409
+ if (primary !== undefined && !primary) throw new UsageError('--primary cannot be empty');
410
+ need(ctx, 'The NTP servers set the clock of the whole PBX');
411
+ const client = await pbxClient(ctx);
412
+ const now = await current(client, '/PBX/settings/ntp/', 'pbx');
413
+ const body = {
414
+ pntp: primary === undefined ? (now.pntp ?? '') : checkHost(primary, 'primary'),
415
+ sntp: secondary === undefined ? (now.sntp ?? '') : checkHost(secondary, 'secondary'),
416
+ };
417
+ const result = await client.call('PUT', '/PBX/settings/ntp/', body, {scope: 'pbx'});
418
+ return {...body, result};
419
+ },
420
+ text: (n) => `NTP servers: ${n.pntp}${n.sntp ? `, ${n.sntp}` : ''}`,
421
+ },
422
+ proxy: {
423
+ summary: 'the HTTP proxy of the PBX (for the support VPN; the password is never shown)',
424
+ usage: 'wx-admin system proxy [--show-secrets]',
425
+ options: {...showSecrets},
426
+ args: [0, 0],
427
+ async run(ctx) {
428
+ return hidePasswords(await current(await pbxClient(ctx), '/PBX/settings/httpProxy/', 'pbx'), ctx);
429
+ },
430
+ text: (p) => proxyLines(p),
431
+ },
432
+ 'proxy-set': {
433
+ summary: 'change the HTTP proxy (needs --yes: the support VPN of the PBX goes through it)',
434
+ usage:
435
+ 'wx-admin system proxy-set [--enable | --disable] [--host HOST] [--port N] [--auth none|basic|ntlm] [--login NAME] [--password] --yes',
436
+ details:
437
+ 'What is not given is kept. --password reads the new password hidden from the terminal, or the first line of stdin.',
438
+ options: {
439
+ enable: {type: 'boolean', description: 'use the proxy'},
440
+ disable: {type: 'boolean', description: 'do not use the proxy'},
441
+ host: {type: 'string', placeholder: 'HOST', description: 'the proxy host'},
442
+ port: {type: 'string', placeholder: 'N', description: 'the proxy port'},
443
+ auth: {type: 'string', choices: ['none', 'basic', 'ntlm'], description: 'how to sign in to the proxy'},
444
+ login: {type: 'string', placeholder: 'NAME', description: 'the proxy user'},
445
+ password: {type: 'boolean', description: 'set a new password, typed hidden or read from stdin'},
446
+ ...yesOption('change the proxy'),
447
+ },
448
+ args: [0, 0],
449
+ async run(ctx) {
450
+ const v = ctx.values;
451
+ if (v.enable && v.disable) throw new UsageError('Give --enable or --disable, not both');
452
+ const change = {};
453
+ if (v.enable || v.disable) change.enable = Boolean(v.enable);
454
+ if (v.host !== undefined) change.host = checkHost(v.host, 'host');
455
+ if (v.port !== undefined) change.port = String(checkPortValue(v.port));
456
+ if (v.auth !== undefined) change.auth_method = v.auth;
457
+ if (v.login !== undefined) change.login = v.login;
458
+ if (!Object.keys(change).length && !v.password) throw new UsageError('Nothing to change', ctx.usage());
459
+ need(ctx, 'The HTTP proxy carries the support VPN of the PBX');
460
+ if (v.host !== undefined && !v.password)
461
+ throw new UsageError(
462
+ 'A new --host needs the password for it too: add --password (the current one is not sent to another host)',
463
+ ctx.usage(),
464
+ );
465
+ if (v.password) change.password = await readSecret(ctx, 'Proxy password: ');
466
+ const client = await pbxClient(ctx);
467
+ const body = {...(await current(client, '/PBX/settings/httpProxy/', 'pbx')), ...change};
468
+ if (body.enable && (!body.host || !body.port))
469
+ throw new UsageError('An enabled proxy needs --host and --port');
470
+ const result = await client.call('PUT', '/PBX/settings/httpProxy/', body, {scope: 'pbx'});
471
+ return hidePasswords(result && typeof result === 'object' ? result : body, {...ctx, values: {}});
472
+ },
473
+ text: (p) => ['HTTP proxy saved', ...proxyLines(p)],
474
+ },
475
+ 'trusted-ip': {
476
+ summary: 'the trusted IP addresses of the PBX',
477
+ usage: 'wx-admin system trusted-ip',
478
+ options: {},
479
+ args: [0, 0],
480
+ async run(ctx) {
481
+ return (await pbxClient(ctx)).call('GET', '/PBX/settings/trusted-ips/', undefined, {scope: 'security'});
482
+ },
483
+ text: (r) => (r?.rows?.length ? r.rows : 'No trusted IPs'),
484
+ },
485
+ 'trusted-ip-set': {
486
+ summary: 'add or remove trusted IP addresses (needs --yes: the security of the whole PBX)',
487
+ usage: 'wx-admin system trusted-ip-set [--add IP]... [--remove IP]... [--clear] --yes',
488
+ details: 'An IP is an address or a network (192.168.0.0/24). --clear removes all of them first.',
489
+ options: {
490
+ add: {type: 'string', multiple: true, placeholder: 'IP', description: 'trust this address or network'},
491
+ remove: {type: 'string', multiple: true, placeholder: 'IP', description: 'stop trusting it'},
492
+ clear: {type: 'boolean', description: 'remove all of them first'},
493
+ ...yesOption('change the trusted IPs'),
494
+ },
495
+ args: [0, 0],
496
+ async run(ctx) {
497
+ const add = ctx.values.add ?? [];
498
+ const remove = ctx.values.remove ?? [];
499
+ if (!add.length && !remove.length && !ctx.values.clear)
500
+ throw new UsageError('Give --add, --remove or --clear', ctx.usage());
501
+ for (const ip of [...add, ...remove])
502
+ if (!IP_OR_NET.test(ip)) throw new UsageError(`Not an IP address or network: ${ip}`);
503
+ need(ctx, 'Trusted IPs change the security of the whole PBX');
504
+ const client = await pbxClient(ctx);
505
+ const now = ctx.values.clear
506
+ ? []
507
+ : ((await current(client, '/PBX/settings/trusted-ips/', 'security')).rows ?? []);
508
+ const missing = remove.filter((ip) => !now.includes(ip));
509
+ if (missing.length && !ctx.values.clear) throw new UsageError(`Not trusted now: ${missing.join(', ')}`);
510
+ const rows = [
511
+ ...now.filter((ip) => !remove.includes(ip)),
512
+ ...add.filter((ip, i) => !now.includes(ip) && add.indexOf(ip) === i),
513
+ ];
514
+ const result = await client.call('PUT', '/PBX/settings/trusted-ips/', {rows}, {scope: 'security'});
515
+ return result && typeof result === 'object' && Array.isArray(result.rows) ? result : {rows};
516
+ },
517
+ text: (r) => (r.rows.length ? ['Trusted IPs:', ...r.rows.map((ip) => ` ${ip}`)] : 'No trusted IPs'),
518
+ },
519
+ ports: {
520
+ summary: 'whether the ports of the PBX are open from outside (HTTP, SIP, RTP), as last checked',
521
+ usage: 'wx-admin system ports',
522
+ options: {},
523
+ args: [0, 0],
524
+ async run(ctx) {
525
+ return (await pbxClient(ctx)).call('GET', '/PBX/Ports/Status/', undefined, {scope: 'info'});
526
+ },
527
+ text: (r, ctx) => {
528
+ const c = paint(ctx);
529
+ const state = (s) =>
530
+ String(s) === '1' ? c.green('open') : String(s) === '0' ? c.red('closed') : String(s ?? '?');
531
+ return r?.records?.length
532
+ ? table(
533
+ r.records.map((p) => [
534
+ p.protocol,
535
+ p.transport,
536
+ p.port,
537
+ state(p.status),
538
+ `checked ${p.lastcheck ?? '?'}`,
539
+ ]),
540
+ )
541
+ : 'No ports checked';
542
+ },
543
+ },
544
+ upgrade: {
545
+ summary: 'the state of the last upgrade of the PBX and its log',
546
+ usage: 'wx-admin system upgrade',
547
+ options: {},
548
+ args: [0, 0],
549
+ async run(ctx) {
550
+ return (await pbxClient(ctx)).call('GET', '/PBX/Upgrade/', undefined, {scope: 'pbx'});
551
+ },
552
+ text: (u) => [
553
+ `upgrade ${u.isRunning ? `running, ${u.progress ?? 0}%` : 'not running'}${!u.isRunning && u.completed ? ' · the last one completed' : ''}`,
554
+ ...(u.log ?? [])
555
+ .map(plain)
556
+ .filter((l) => l.trim())
557
+ .map((l) => ` ${l}`),
558
+ ],
559
+ },
560
+ 'upgrade-check': {
561
+ summary: 'look for a new WMS version and packages (runs the check on the PBX and waits for it)',
562
+ usage: 'wx-admin system upgrade-check [--status] [--wait SECONDS]',
563
+ details:
564
+ 'The check updates the package lists of the PBX; it installs nothing. --status reads the last result (the PBX starts a new check when the last one is older than 3 minutes).',
565
+ options: {
566
+ status: {type: 'boolean', description: 'do not start a check: read the last result'},
567
+ ...waitOption(300),
568
+ },
569
+ args: [0, 0],
570
+ async run(ctx) {
571
+ const client = await pbxClient(ctx);
572
+ const read = () => client.call('GET', '/PBX/Upgrade/Check/', undefined, {scope: 'pbx'});
573
+ if (ctx.values.status) return read();
574
+ await client.call('POST', '/PBX/Upgrade/Check/', undefined, {scope: 'pbx'});
575
+ const {result, finished} = await poll(ctx, read, (r) => r?.check?.isRunning === false, 300);
576
+ if (!finished) ctx.info('The check is still running: wx-admin system upgrade-check --status');
577
+ return result;
578
+ },
579
+ text: (r, ctx) => {
580
+ const c = paint(ctx);
581
+ const check = r.check ?? {};
582
+ const news = r.newVersion
583
+ ? `new version ${c.bold(r.newVersion)}`
584
+ : r.newPackages
585
+ ? 'new packages'
586
+ : 'up to date';
587
+ return [
588
+ `repository ${r.repository ?? '?'} · ${check.isRunning ? 'check running' : news}${r.upgrade?.isRunning ? ' · an upgrade is running' : ''}`,
589
+ ...(check.isRunning
590
+ ? []
591
+ : [
592
+ `check ${check.withoutError === false ? c.red('had errors') : 'without errors'} · file system ${check.fileSystemStatus === false ? c.red('not ok') : 'ok'}`,
593
+ ]),
594
+ ...(check.log ?? [])
595
+ .map(plain)
596
+ .filter((l) => l.trim())
597
+ .map((l) => ` ${l}`),
598
+ ];
599
+ },
600
+ },
601
+ 'upgrade-settings': {
602
+ summary: 'how the PBX upgrades: repository, automatic upgrades, custom firmware server',
603
+ usage: 'wx-admin system upgrade-settings',
604
+ options: {},
605
+ args: [0, 0],
606
+ async run(ctx) {
607
+ return (await pbxClient(ctx)).call('GET', '/PBX/Upgrade/Settings/', undefined, {scope: 'pbx'});
608
+ },
609
+ text: (s) => {
610
+ const w = s.wmsrelease ?? {};
611
+ const f = s.customFirmwareServer ?? {};
612
+ return [
613
+ `repository ${w.repository ?? '?'} · automatic upgrade ${w.enabled ? `on, ${w.howly ?? '?'}` : 'off'}${w.email ? ` · log to ${w.email}` : ''}`,
614
+ `custom firmware server ${f.enabled ? `${f.url}${f.checkTls ? ' (TLS checked)' : ' (TLS not checked)'}` : 'off'}`,
615
+ ];
616
+ },
617
+ },
618
+ 'upgrade-start': {
619
+ summary: 'start the upgrade of the PBX (needs --yes); then follow it with system upgrade, and reboot',
620
+ usage: 'wx-admin system upgrade-start --yes',
621
+ details:
622
+ 'Run system upgrade-check first. The upgrade runs in the background: system upgrade shows its progress; when it is done, system reboot --yes.',
623
+ options: yesOption('upgrade the PBX'),
624
+ args: [0, 0],
625
+ async run(ctx) {
626
+ need(
627
+ ctx,
628
+ 'An upgrade changes the software of the whole PBX and is followed by a reboot that stops all calls',
629
+ );
630
+ return (await pbxClient(ctx)).call('POST', '/PBX/Upgrade/', undefined, {scope: 'pbx'});
631
+ },
632
+ text: (r) =>
633
+ r?.status === false
634
+ ? 'The PBX did not start the upgrade'
635
+ : 'Upgrade started: follow it with wx-admin system upgrade',
636
+ },
637
+ reboot: {
638
+ summary: 'reboot the PBX (needs --yes: every call stops)',
639
+ usage: 'wx-admin system reboot --yes',
640
+ options: yesOption('reboot the PBX now'),
641
+ args: [0, 0],
642
+ async run(ctx) {
643
+ need(ctx, 'A reboot stops every call and every device of the PBX for some minutes');
644
+ const result = await (await pbxClient(ctx)).call('PUT', '/PBX/System/Reboot/', undefined, {
645
+ scope: 'pbxpower',
646
+ });
647
+ return {rebooting: true, result};
648
+ },
649
+ text: () => 'The PBX is rebooting',
650
+ },
651
+ },
652
+ },
653
+ };
654
+
655
+ function smtpLines(s) {
656
+ return [
657
+ `server ${s.host || '-'}:${s.port ?? ''}${s.tls ? ' TLS' : ''}${s.tlsStartTls ? ' STARTTLS' : ''}${s.isDefaultSettings ? ' (the default SMTP of Wildix)' : ''}`,
658
+ `from ${s.from || '-'}`,
659
+ `auth ${s.auth ?? '?'}${s.user ? ` as ${s.user}` : ''}${s.passwordSet ? ' (password set)' : ''}`,
660
+ `HELO ${s.domain || '-'} · timeout ${s.timeout ?? '?'} s`,
661
+ ];
662
+ }
663
+
664
+ function proxyLines(p) {
665
+ return [
666
+ `HTTP proxy ${p.enable ? 'on' : 'off'}${p.host ? ` · ${p.host}:${p.port ?? ''}` : ''} · auth ${p.auth_method ?? 'none'}${p.login ? ` as ${p.login}` : ''}${p.passwordSet ? ' (password set)' : ''}`,
667
+ ];
668
+ }