wldx-admin-cli 1.1.0 → 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,153 @@
1
+ // The plan of a config import: every object of the export against the target PBX. new (not there), same, differs
2
+ // (there and different: overwritten only when chosen) or blocked (cannot be written, with the reason), with warnings
3
+ // that do not stop it (a sound or music on hold class missing on the target, a queue member who is not a user there,
4
+ // a value outside the PBX). The target's own objects are made portable the same way, so both sides compare by name.
5
+ import {keyOf, refsOf, splitKey, toPortable} from './refs.mjs';
6
+
7
+ // JSON with sorted keys and numbers as strings: two objects compare equal whatever the order of their fields.
8
+ const stable = (v) =>
9
+ v && typeof v === 'object'
10
+ ? Array.isArray(v)
11
+ ? `[${v.map(stable).join(',')}]`
12
+ : `{${Object.keys(v)
13
+ .sort()
14
+ .map((k) => `${JSON.stringify(k)}:${stable(v[k])}`)
15
+ .join(',')}}`
16
+ : JSON.stringify(typeof v === 'number' ? String(v) : v); // the PBX answers 0 or "0" for the same value
17
+
18
+ const OUTSIDE_NAMES = {sounds: 'sound', moh: 'music on hold', phonebooks: 'phonebook'};
19
+
20
+ // portable: the export ({kinds, soft, outside, external, unresolved, bound}); target: readKinds of the target;
21
+ // checks: {serial, host, licenses: {type: free}, sounds, moh, phonebooks} (a list not given is not checked).
22
+ export function makePlan(portable, target, {serial, host, licenses, sounds, moh, phonebooks}) {
23
+ const targetPortable = toPortable(target, {serial, host});
24
+ const theirs = new Map();
25
+ const ids = {};
26
+ const twice = new Set(); // names that two objects of a kind share on the target: ambiguous
27
+ for (const [kind, list] of Object.entries(target)) {
28
+ if (!Array.isArray(list)) continue;
29
+ for (const o of list) {
30
+ const key = keyOf(kind, o.name);
31
+ if (Object.hasOwn(ids, key)) twice.add(key);
32
+ ids[key] = {
33
+ id: o.id,
34
+ ...(o.dn ? {dn: o.dn} : {}),
35
+ name: o.name,
36
+ ...(kind === 'trunks-sip' && typeof o.data?.name === 'string' ? {login: o.data.name} : {}),
37
+ };
38
+ }
39
+ }
40
+ for (const [kind, list] of Object.entries(targetPortable.kinds))
41
+ for (const o of list) theirs.set(keyOf(kind, o.name), o.data);
42
+
43
+ const items = [];
44
+ const bound = new Set((portable.bound ?? []).map((b) => keyOf(b.kind, b.name)));
45
+ for (const [kind, list] of Object.entries(portable.kinds)) {
46
+ const counts = new Map();
47
+ for (const o of list) counts.set(o.name, (counts.get(o.name) ?? 0) + 1);
48
+ for (const o of list) {
49
+ const key = keyOf(kind, o.name);
50
+ const it = {
51
+ key,
52
+ kind,
53
+ name: o.name,
54
+ data: o.data,
55
+ warnings: [],
56
+ needs: [...new Set(refsOf(o.data).map((r) => keyOf(r.kind, r.name)))].filter((k) => k !== key),
57
+ };
58
+ if (twice.has(key)) Object.assign(it, {state: 'blocked', reason: `two objects named ${o.name} on ${host}`});
59
+ else if (counts.get(o.name) > 1)
60
+ Object.assign(it, {state: 'blocked', reason: `two objects named ${o.name} in the export`});
61
+ else if (bound.has(key)) Object.assign(it, {state: 'blocked', reason: 'bound to the source PBX'});
62
+ else if (target[kind] && !Array.isArray(target[kind]))
63
+ Object.assign(it, {state: 'blocked', reason: `${kind} cannot be read on ${host}: ${target[kind].error}`});
64
+ else if (!theirs.has(key)) it.state = 'new';
65
+ else if (kind === 'users' && theirs.get(key).name !== o.data.name)
66
+ Object.assign(it, {state: 'blocked', reason: `extension ${o.name} is ${theirs.get(key).name} on ${host}`});
67
+ else it.state = stable(theirs.get(key)) === stable(o.data) ? 'same' : 'differs';
68
+ items.push(it);
69
+ }
70
+ }
71
+
72
+ // New users take a license of their type each; beyond the free ones they cannot be made.
73
+ if (licenses) {
74
+ const free = {...licenses};
75
+ for (const it of items.filter((i) => i.kind === 'users' && i.state === 'new')) {
76
+ const type = it.data.licenseType;
77
+ if (!type) continue;
78
+ if ((free[type] ?? 0) <= 0) Object.assign(it, {state: 'blocked', reason: `no free ${type} license on ${host}`});
79
+ else free[type] -= 1;
80
+ }
81
+ }
82
+
83
+ // A reference must be in the export (and writable) or on the target; repeated until nothing changes, so a block
84
+ // travels up the chain.
85
+ const byKey = new Map(items.map((i) => [i.key, i]));
86
+ for (let changed = true; changed; ) {
87
+ changed = false;
88
+ for (const it of items) {
89
+ if (it.state === 'blocked') continue;
90
+ for (const need of it.needs) {
91
+ const mine = byKey.get(need);
92
+ // on the target already, or in the export and writable
93
+ if (Object.hasOwn(ids, need) || (mine && mine.state !== 'blocked')) continue;
94
+ const why = mine ? `${need} is blocked: ${mine.reason}` : `neither in the export nor on ${host}`;
95
+ Object.assign(it, {state: 'blocked', reason: `needs ${need}: ${why}`});
96
+ changed = true;
97
+ break;
98
+ }
99
+ }
100
+ }
101
+
102
+ // Warnings, {type, value, path?, text}: values outside the transfer missing on the target (sound, moh, phonebook;
103
+ // not-checked when the target's list was not read), queue members who are not users there (member), values outside
104
+ // the PBX (external), references to nothing on the source (unresolved).
105
+ const has = {sounds, moh, phonebooks};
106
+ const TYPE = {sounds: 'sound', moh: 'moh', phonebooks: 'phonebook'};
107
+ const warn = (key, w) => byKey.get(key)?.warnings.push(w);
108
+ for (const x of portable.outside ?? []) {
109
+ const list = has[x.kind];
110
+ const w = {type: TYPE[x.kind], value: x.value, path: x.path};
111
+ if (!list)
112
+ warn(x.from, {
113
+ ...w,
114
+ type: 'not-checked',
115
+ text: `${OUTSIDE_NAMES[x.kind]} ${x.value} (${x.path}) was not checked on ${host}`,
116
+ });
117
+ else if (!list.map(String).includes(String(x.value)))
118
+ warn(x.from, {...w, text: `${OUTSIDE_NAMES[x.kind]} ${x.value} is not on ${host} (${x.path})`});
119
+ }
120
+ for (const [from, keys] of Object.entries(portable.soft ?? {})) {
121
+ for (const k of keys) {
122
+ const mine = byKey.get(k);
123
+ if (Object.hasOwn(ids, k) || (mine && mine.state !== 'blocked')) continue;
124
+ const value = splitKey(k).name;
125
+ if (!Array.isArray(target.users))
126
+ warn(from, {
127
+ type: 'not-checked',
128
+ value,
129
+ text: `the user ${value} was not checked on ${host} (its users were not read)`,
130
+ });
131
+ else warn(from, {type: 'member', value, text: `${value} is not a user on ${host} (nor a user of the export)`});
132
+ }
133
+ }
134
+ for (const x of portable.external ?? [])
135
+ warn(keyOf(x.kind, x.name), {
136
+ type: 'external',
137
+ value: x.what,
138
+ path: x.path,
139
+ text: `${x.path} is outside the PBX (${x.what}), kept as it is: check it works for ${host}`,
140
+ });
141
+ for (const x of portable.unresolved ?? [])
142
+ warn(keyOf(x.kind, x.name), {
143
+ type: 'unresolved',
144
+ value: x.value,
145
+ path: x.path,
146
+ text: `${x.path} = ${x.value} pointed at nothing on the source, kept as it is`,
147
+ });
148
+
149
+ const counts = {new: 0, same: 0, differs: 0, blocked: 0};
150
+ for (const it of items) counts[it.state] += 1;
151
+ const welcomeMails = items.filter((i) => i.kind === 'users' && i.state === 'new' && i.data.email).length;
152
+ return {host, items, counts, welcomeMails, target: ids};
153
+ }
@@ -0,0 +1,251 @@
1
+ // References of a config transfer. toPortable() turns every value that points outside its object (an id, a name or
2
+ // an LDAP dn of another object, the PBX itself, a secret, an object outside the PBX) into a marker, so the objects
3
+ // mean the same on any PBX; fromPortable() turns the markers back into the values of the target PBX. A last scan
4
+ // marks an object `bound` when the serial or the host of the source PBX is still in one of its strings.
5
+ //
6
+ // {"$ref": "<kind>/<name>", "as": "id"|"name"|"dn"|"login", "number"?: true} another object
7
+ // {"$self": "pbx"} the PBX (pbxid=<serial>,dc=wildix)
8
+ // {"$secret": "<path>"} a secret, never exported
9
+ // {"$external": "<what>", "value": …} outside the PBX, kept as it is
10
+
11
+ // The parameters of dialplan applications that point at objects, found live on WMS 7.11 (an empty rule per
12
+ // application: the PBX names what it requires). `by` is the form the PBX uses first.
13
+ const ID = 'id';
14
+ const NAME = 'name';
15
+ export const APP_REFS = {
16
+ Queue: {queueId: ['queues', ID], mohClass: ['moh']},
17
+ Timetable: {timetableId: ['timetables', ID], dialplan: ['dialplans', NAME]},
18
+ IVR: {ivrName: ['ivr', NAME]},
19
+ GotoIfSwitchIsOn: {switchId: ['switches', ID], dialplan: ['dialplans', NAME]},
20
+ GotoIf3SwitchIsOnOrBlinking: {
21
+ switchId: ['switches', ID],
22
+ dialplanOn: ['dialplans', NAME],
23
+ dialplanBlinking: ['dialplans', NAME],
24
+ },
25
+ FeaturePaging: {groupId: ['paging', ID]},
26
+ DialTrunk: {trunk: ['trunks-sip', NAME]},
27
+ DialHuntGroup: {huntGroup: ['trunk-groups', NAME]},
28
+ GotoIfUserStatus: {user: ['users', NAME, 'soft'], dialplan: ['dialplans', NAME]},
29
+ Goto: {dialplan: ['dialplans', NAME]},
30
+ GotoIf: {dialplan: ['dialplans', NAME]},
31
+ GotoIfBusyCongested: {dialplan: ['dialplans', NAME]},
32
+ VerifyCaller: {dialplan: ['dialplans', NAME]},
33
+ CorrectOutgoingNumber: {dialplan: ['dialplans', NAME]},
34
+ GotoIfGroupCount: {dialplan: ['dialplans', NAME]},
35
+ DialRemotePbx: {dialplan: ['dialplans', NAME]},
36
+ LookUpCidNameInPhonebook: {phonebookId: ['phonebooks']},
37
+ LookUpDestCidNameInPhonebook: {phonebookId: ['phonebooks']},
38
+ Playback: {filename: ['sounds']},
39
+ };
40
+ // Kinds a transfer does not carry: their values stay, and the plan checks the target has them.
41
+ const OUTSIDE = new Set(['moh', 'sounds', 'phonebooks']);
42
+ export const EXTERNAL = {VoiceBot: {botId: 'voicebot'}, DialRemotePbx: {pbx: 'remote-pbx'}};
43
+
44
+ // Secret fields of a trunk ("key" alone is not one: it names a codec).
45
+ const SECRET_KEY = /secret|password|passwd|^register$|apikey|token/i;
46
+ const PBXID = /^pbxid=([^,]+),dc=wildix$/i;
47
+
48
+ // Values that mean the same on every PBX: nothing, "all", a dialplan expression, a built-in (negative) id.
49
+ const literal = (v) =>
50
+ v === undefined || v === null || v === '' || v === 'all' || /\$\{/.test(String(v)) || /^-\d+$/.test(String(v));
51
+
52
+ export const keyOf = (kind, name) => `${kind}/${name}`;
53
+ export function splitKey(key) {
54
+ const at = key.indexOf('/');
55
+ return {kind: key.slice(0, at), name: key.slice(at + 1)};
56
+ }
57
+
58
+ const isMarker = (v) =>
59
+ v &&
60
+ typeof v === 'object' &&
61
+ !Array.isArray(v) &&
62
+ ('$ref' in v || '$self' in v || '$secret' in v || '$external' in v);
63
+
64
+ // Lookup tables of the objects of a snapshot, per kind: by id, dn, name and (trunks) login.
65
+ function indexOf(snapshot) {
66
+ const idx = {};
67
+ for (const [kind, objects] of Object.entries(snapshot)) {
68
+ if (!Array.isArray(objects)) continue;
69
+ const t = {id: new Map(), dn: new Map(), name: new Map(), login: new Map()};
70
+ for (const o of objects) {
71
+ t.id.set(String(o.id), o.name);
72
+ if (o.dn) t.dn.set(String(o.dn).toLowerCase(), o.name);
73
+ t.name.set(String(o.name), o.name);
74
+ if (kind === 'trunks-sip' && o.data?.name) t.login.set(String(o.data.name), o.name);
75
+ }
76
+ idx[kind] = t;
77
+ }
78
+ return idx;
79
+ }
80
+
81
+ export function toPortable(snapshot, {serial, host}) {
82
+ const idx = indexOf(snapshot);
83
+ const out = {kinds: {}, errors: {}, needs: {}, soft: {}, bound: [], external: [], unresolved: [], outside: []};
84
+
85
+ for (const [kind, objects] of Object.entries(snapshot)) {
86
+ if (!Array.isArray(objects)) {
87
+ out.errors[kind] = objects?.error ?? 'not read';
88
+ continue;
89
+ }
90
+ out.kinds[kind] = objects.map((o) => {
91
+ const key = keyOf(kind, o.name);
92
+ const needs = new Set();
93
+ const soft = new Set();
94
+ const at = {kind, name: o.name};
95
+
96
+ // A value pointing at an object of `target`: a marker, or the value kept and noted.
97
+ const ref = (value, target, path, order = [ID, 'dn', NAME, 'login'], how) => {
98
+ if (literal(value)) return value;
99
+ // A soft reference (a user by extension) stays as it is: an extension means the same on any PBX.
100
+ if (how === 'soft') {
101
+ soft.add(keyOf(target, String(value)));
102
+ return value;
103
+ }
104
+ if (OUTSIDE.has(target)) {
105
+ out.outside.push({...at, path, kind: target, value: String(value), from: key});
106
+ return value;
107
+ }
108
+ const t = idx[target];
109
+ const s = String(value);
110
+ for (const as of order) {
111
+ const name = t?.[as]?.get(as === 'dn' ? s.toLowerCase() : s);
112
+ if (name === undefined) continue;
113
+ needs.add(keyOf(target, name));
114
+ return {$ref: keyOf(target, name), as, ...(typeof value === 'number' ? {number: true} : {})};
115
+ }
116
+ out.unresolved.push({...at, path, value: s});
117
+ return value;
118
+ };
119
+ const pathOf = (base, k) => (base ? `${base}.${k}` : k);
120
+ const data = structuredClone(o.data);
121
+
122
+ if (kind === 'dialplans') {
123
+ data.includedDialplans = (data.includedDialplans ?? []).map((x, i) => ({
124
+ ...x,
125
+ dialplanId: ref(x.dialplanId, 'dialplans', `includedDialplans[${i}].dialplanId`, [ID, NAME]),
126
+ }));
127
+ for (const [n, num] of (data.numbers ?? []).entries()) {
128
+ for (const [a, application] of (num.apps ?? []).entries()) {
129
+ const params = application.params;
130
+ if (!params || Array.isArray(params)) continue;
131
+ const base = `numbers[${n}].apps[${a}].params`;
132
+ for (const [p, [target, by, how]] of Object.entries(APP_REFS[application.name] ?? {})) {
133
+ if (!(p in params)) continue;
134
+ params[p] = ref(params[p], target, pathOf(base, p), by === ID ? [ID, NAME] : [NAME, ID, 'login'], how);
135
+ }
136
+ for (const [p, what] of Object.entries(EXTERNAL[application.name] ?? {})) {
137
+ if (literal(params[p])) continue;
138
+ out.external.push({...at, path: pathOf(base, p), what});
139
+ params[p] = {$external: what, value: params[p]};
140
+ }
141
+ }
142
+ }
143
+ }
144
+ if (kind === 'acl-groups') {
145
+ if (data.inherits?.dn)
146
+ data.inherits = {...data.inherits, dn: ref(data.inherits.dn, 'acl-groups', 'inherits.dn', ['dn'])};
147
+ data.rules = (data.rules ?? []).map((r, i) => ({
148
+ ...r,
149
+ group: ref(r.group, 'acl-groups', `rules[${i}].group`, ['dn']),
150
+ }));
151
+ // An admin rule about this PBX is about the target; one about another PBX (or when the serial is not known)
152
+ // stays as it is and is reported: giving a group admin rights on the target it did not have is not a guess.
153
+ data.adminRules = (data.adminRules ?? []).map((r, i) => {
154
+ const m = PBXID.exec(String(r.subject ?? ''));
155
+ if (!m) return r;
156
+ if (serial && m[1].toLowerCase() === String(serial).toLowerCase()) return {...r, subject: {$self: 'pbx'}};
157
+ out.unresolved.push({...at, path: `adminRules[${i}].subject`, value: String(r.subject)});
158
+ return r;
159
+ });
160
+ }
161
+ if (kind === 'users') {
162
+ if ('groupDn' in data) data.groupDn = ref(data.groupDn, 'acl-groups', 'groupDn', ['dn']);
163
+ for (const f of ['dialplan', 'faxDialplan']) if (f in data) data[f] = ref(data[f], 'dialplans', f, [NAME]);
164
+ }
165
+ if (kind === 'queues') {
166
+ if (data.settings && 'aclGroup' in data.settings)
167
+ data.settings.aclGroup = ref(data.settings.aclGroup, 'acl-groups', 'settings.aclGroup', ['dn', ID, NAME]);
168
+ for (const m of data.members ?? []) soft.add(keyOf('users', String(m).split(/[@,]/)[0]));
169
+ }
170
+ if (kind === 'trunk-groups') {
171
+ data.rules = (data.rules ?? []).map((r, i) => ({
172
+ ...r,
173
+ trunkName: ref(r.trunkName, 'trunks-sip', `rules[${i}].trunkName`, [NAME, 'login', ID]),
174
+ }));
175
+ }
176
+ if (kind === 'trunks-sip') {
177
+ if (data.settingsPbx && 'context' in data.settingsPbx)
178
+ data.settingsPbx.context = ref(data.settingsPbx.context, 'dialplans', 'settingsPbx.context', [NAME]);
179
+ hideSecrets(data, '');
180
+ }
181
+
182
+ out.needs[key] = [...needs].filter((k) => k !== key);
183
+ out.soft[key] = [...soft];
184
+ if (!out.soft[key].length) delete out.soft[key];
185
+ if (!out.needs[key].length) delete out.needs[key];
186
+ scan(data, '', (path, found) => out.bound.push({...at, path, found}), {serial, host});
187
+ return {name: o.name, data};
188
+ });
189
+ }
190
+ return out;
191
+ }
192
+
193
+ // Every non-empty secret of an object becomes {$secret: <its path>}.
194
+ function hideSecrets(o, base) {
195
+ for (const [k, v] of Object.entries(o)) {
196
+ const path = Array.isArray(o) ? `${base}[${k}]` : base ? `${base}.${k}` : k;
197
+ if (SECRET_KEY.test(k) && typeof v === 'string' && v !== '') o[k] = {$secret: path};
198
+ else if (v && typeof v === 'object' && !isMarker(v)) hideSecrets(v, path);
199
+ }
200
+ }
201
+
202
+ // The safety scan: strings (outside markers) that still hold the source serial or host.
203
+ function scan(v, path, hit, {serial, host}) {
204
+ if (typeof v === 'string') {
205
+ const s = v.toLowerCase();
206
+ if (serial && s.includes(String(serial).toLowerCase())) hit(path, 'serial');
207
+ else if (host && s.includes(String(host).toLowerCase())) hit(path, 'host');
208
+ return;
209
+ }
210
+ if (!v || typeof v !== 'object' || isMarker(v)) return;
211
+ for (const [k, x] of Array.isArray(v) ? v.entries() : Object.entries(v))
212
+ scan(x, Array.isArray(v) ? `${path}[${k}]` : path ? `${path}.${k}` : k, hit, {serial, host});
213
+ }
214
+
215
+ // Every $ref of an object, with where it is.
216
+ export function refsOf(data, path = '', out = []) {
217
+ if (!data || typeof data !== 'object') return out;
218
+ if (!Array.isArray(data) && typeof data.$ref === 'string') {
219
+ out.push({...splitKey(data.$ref), path});
220
+ return out;
221
+ }
222
+ for (const [k, v] of Array.isArray(data) ? data.entries() : Object.entries(data))
223
+ refsOf(v, Array.isArray(data) ? `${path}[${k}]` : path ? `${path}.${k}` : k, out);
224
+ return out;
225
+ }
226
+
227
+ export class MissingValue extends Error {}
228
+
229
+ // The markers of an object replaced by the values of the target: resolve({kind, name, as}) for a $ref,
230
+ // resolve.self() for the PBX, resolve.secret(path) for a secret. A $ref or secret without a value throws MissingValue.
231
+ export function fromPortable(kind, data, resolve) {
232
+ const walk = (v) => {
233
+ if (!v || typeof v !== 'object') return v;
234
+ if (Array.isArray(v)) return v.map(walk);
235
+ if (typeof v.$ref === 'string') {
236
+ const {kind: k, name} = splitKey(v.$ref);
237
+ const value = resolve({kind: k, name, as: v.as});
238
+ if (value === undefined) throw new MissingValue(`${kind}: ${v.$ref} is not on the target`);
239
+ return v.number ? Number(value) : value;
240
+ }
241
+ if ('$self' in v) return resolve.self();
242
+ if ('$secret' in v) {
243
+ const value = resolve.secret(v.$secret);
244
+ if (value === undefined) throw new MissingValue(`${kind}: the secret ${v.$secret} was not given`);
245
+ return value;
246
+ }
247
+ if ('$external' in v) return v.value;
248
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)]));
249
+ };
250
+ return walk(data);
251
+ }