@coffeerunhobby/mcp-ha-connect 2.4.5 → 2.4.7
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/README.md +12 -5
- package/dist/index.js +4 -96
- package/dist/index.js.map +1 -1
- package/dist/net/adapters/omada.d.ts +60 -6
- package/dist/net/adapters/omada.js +507 -34
- package/dist/net/adapters/omada.js.map +1 -1
- package/dist/net/adapters/omadaRules.d.ts +141 -0
- package/dist/net/adapters/omadaRules.js +595 -0
- package/dist/net/adapters/omadaRules.js.map +1 -0
- package/dist/net/model.d.ts +2 -0
- package/dist/net/model.js.map +1 -1
- package/dist/omadaClient/accessControl.d.ts +7 -0
- package/dist/omadaClient/accessControl.js +43 -1
- package/dist/omadaClient/accessControl.js.map +1 -1
- package/dist/omadaClient/index.d.ts +2 -0
- package/dist/omadaClient/index.js +4 -0
- package/dist/omadaClient/index.js.map +1 -1
- package/dist/omadaClient/networkConfig.d.ts +9 -0
- package/dist/omadaClient/networkConfig.js +28 -0
- package/dist/omadaClient/networkConfig.js.map +1 -1
- package/dist/omadaClient/request.d.ts +4 -0
- package/dist/omadaClient/request.js +8 -0
- package/dist/omadaClient/request.js.map +1 -1
- package/dist/tools/net/index.d.ts +113 -1
- package/dist/tools/net/index.js +110 -3
- package/dist/tools/net/index.js.map +1 -1
- package/dist/tools/omada/accessControl.js +1 -0
- package/dist/tools/omada/accessControl.js.map +1 -1
- package/dist/upstreams.d.ts +47 -0
- package/dist/upstreams.js +194 -0
- package/dist/upstreams.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Omada adapter for the vendor-neutral network model (src/net/model.ts).
|
|
3
3
|
*
|
|
4
|
-
* 2.4.5 covers networks (LAN networks / VLANs) and Wi-Fi (SSIDs).
|
|
5
|
-
* ports and mDNS arrive with 2.4.
|
|
6
|
-
* NetError UNSUPPORTED.
|
|
4
|
+
* 2.4.5 covers networks (LAN networks / VLANs) and Wi-Fi (SSIDs); 2.4.7 rules (gateway
|
|
5
|
+
* ACLs, see omadaRules.ts) and listing groups. Switch ports and mDNS arrive with 2.4.8;
|
|
6
|
+
* until then those calls fail with NetError UNSUPPORTED.
|
|
7
7
|
*
|
|
8
8
|
* Omada specifics handled here:
|
|
9
9
|
* - networks have no "guest" purpose and no isolation flag in the API (guest is per
|
|
@@ -22,6 +22,7 @@ import { inSubnet, ipToNumber } from '../../utils/ipv4.js';
|
|
|
22
22
|
import { redactSecrets } from '../../utils/redact.js';
|
|
23
23
|
import { NetError, } from '../model.js';
|
|
24
24
|
import { MAX_NETWORK_NAME, MAX_WIFI_NAME, checkBands, checkDhcp, checkGateway, checkName, checkNetworkUnique, checkVlan } from '../validate.js';
|
|
25
|
+
import { GROUP_TYPE, controllerPath, controllerRisk, draftAsAcl, draftRule, isOwnedGroup, newRuleKey, ownedGroupName, resolve, ruleKey, tagged, toRule, } from './omadaRules.js';
|
|
25
26
|
const PSEUDO_WLAN_ID = 'gateway';
|
|
26
27
|
/** Omada LAN network purpose: 0 = VLAN only (switches), 1 = interface (routed by the gateway). */
|
|
27
28
|
const PURPOSE_VLAN = 0;
|
|
@@ -66,7 +67,7 @@ export class OmadaNetworkController {
|
|
|
66
67
|
networks: { create: 'native', isolation: 'unsupported', ipv6: 'unsupported', guestPurpose: 'unsupported' },
|
|
67
68
|
wifi: { create: 'native', bands: ['2.4', '5', '6'], security: ['open', 'wpa2', 'wpa3'], pmf: 'native', fastRoaming: 'native', bandSteering: 'unsupported' },
|
|
68
69
|
ports: { profiles: 'unsupported', assign: 'unsupported' },
|
|
69
|
-
rules: { between: '
|
|
70
|
+
rules: { between: 'native', toInternet: 'native', deviceMatch: 'unsupported', wifiMatch: 'unsupported', stateful: 'native', schedule: 'unsupported' },
|
|
70
71
|
groups: { address: 'unsupported', mac: 'unsupported' },
|
|
71
72
|
reservations: 'native',
|
|
72
73
|
mdnsRepeater: 'unsupported',
|
|
@@ -115,7 +116,11 @@ export class OmadaNetworkController {
|
|
|
115
116
|
const clients = (await this.client.listClients(siteId)).map((c) => this.toClient(c, networks));
|
|
116
117
|
const alwaysProfileId = (await this.client.accessControl.listTimeRanges(siteId)).find((p) => p.name === ALWAYS_PROFILE_NAME)?.profileId;
|
|
117
118
|
const macGroups = await this.client.listMacGroups(siteId);
|
|
118
|
-
|
|
119
|
+
const acls = (await this.client.networkConfig.listAcls('osg-acls', siteId)).sort((a, b) => Number(a.index ?? 0) - Number(b.index ?? 0));
|
|
120
|
+
const otherAcls = [...(await this.client.networkConfig.listAcls('osw-acls', siteId)), ...(await this.client.networkConfig.listAcls('eap-acls', siteId))];
|
|
121
|
+
const ipGroups = await this.client.networkConfig.listGroups(GROUP_TYPE.ip, siteId);
|
|
122
|
+
const ipPortGroups = await this.client.networkConfig.listGroups(GROUP_TYPE.ipPort, siteId);
|
|
123
|
+
return { siteId, networksRaw, networks, ssids, clients, macGroups, acls, otherAcls, ipGroups, ipPortGroups, ...(alwaysProfileId ? { alwaysProfileId } : {}) };
|
|
119
124
|
}
|
|
120
125
|
toNetwork(raw) {
|
|
121
126
|
const dhcp = (raw.dhcpSettingsVO ?? {});
|
|
@@ -237,19 +242,37 @@ export class OmadaNetworkController {
|
|
|
237
242
|
.map((r) => ({ mac: formatMac(r.mac), ip: String(r.ip), network: { id: r.netId }, ...(r.description ? { description: String(r.description) } : {}) }));
|
|
238
243
|
}
|
|
239
244
|
async listPortProfiles() {
|
|
240
|
-
throw notYet('switch port profiles', '2.4.
|
|
245
|
+
throw notYet('switch port profiles', '2.4.8');
|
|
241
246
|
}
|
|
242
247
|
async listPorts() {
|
|
243
|
-
throw notYet('switch ports', '2.4.
|
|
248
|
+
throw notYet('switch ports', '2.4.8');
|
|
244
249
|
}
|
|
245
250
|
async listRules() {
|
|
246
|
-
|
|
251
|
+
const snap = await this.snapshot();
|
|
252
|
+
const world = this.ruleWorld(snap);
|
|
253
|
+
return snap.acls.map((raw, i) => toRule(raw, i + 1, world));
|
|
247
254
|
}
|
|
248
|
-
async listGroups() {
|
|
249
|
-
|
|
255
|
+
async listGroups(kind) {
|
|
256
|
+
const snap = await this.snapshot();
|
|
257
|
+
const address = snap.ipGroups.map((g) => ({
|
|
258
|
+
id: String(g.groupId),
|
|
259
|
+
name: String(g.name),
|
|
260
|
+
kind: 'address',
|
|
261
|
+
entries: (g.ipList ?? []).map((e) => ({
|
|
262
|
+
value: Number(e.mask) === 32 ? e.ip : `${e.ip}/${e.mask}`,
|
|
263
|
+
...(e.description ? { name: e.description } : {}),
|
|
264
|
+
})),
|
|
265
|
+
}));
|
|
266
|
+
const mac = snap.macGroups.map((g) => ({
|
|
267
|
+
id: g.groupId,
|
|
268
|
+
name: g.name,
|
|
269
|
+
kind: 'mac',
|
|
270
|
+
entries: (g.macAddressList ?? []).map((e) => ({ value: formatMac(e.macAddress), ...(e.name ? { name: e.name } : {}) })),
|
|
271
|
+
}));
|
|
272
|
+
return kind === 'address' ? address : kind === 'mac' ? mac : [...address, ...mac];
|
|
250
273
|
}
|
|
251
274
|
async getMdnsRepeater() {
|
|
252
|
-
throw notYet('the mDNS repeater', '2.4.
|
|
275
|
+
throw notYet('the mDNS repeater', '2.4.8');
|
|
253
276
|
}
|
|
254
277
|
// ---- plan / apply ------------------------------------------------------------------
|
|
255
278
|
async plan(change) {
|
|
@@ -306,18 +329,417 @@ export class OmadaNetworkController {
|
|
|
306
329
|
if (change.op === 'delete')
|
|
307
330
|
return this.buildWifiDelete(change.ref, snap);
|
|
308
331
|
break;
|
|
332
|
+
case 'rule':
|
|
333
|
+
if (change.op === 'create')
|
|
334
|
+
return this.buildRuleCreate(change.value, snap);
|
|
335
|
+
if (change.op === 'update')
|
|
336
|
+
return this.buildRuleUpdate(change.value.ref, change.value.set, snap);
|
|
337
|
+
if (change.op === 'delete')
|
|
338
|
+
return this.buildRuleDelete(change.ref, snap);
|
|
339
|
+
if (change.op === 'move')
|
|
340
|
+
return this.buildRuleMove(change.ref, change.position, snap);
|
|
341
|
+
break;
|
|
309
342
|
default:
|
|
310
|
-
throw notYet(`${change.kind} changes`, change.kind === '
|
|
343
|
+
throw notYet(`${change.kind} changes`, change.kind === 'group' ? 'a later release (use omada_createGroup / omada_deleteGroup)' : change.kind === 'reservation' ? 'a later release (use omada_setDhcpReservation)' : '2.4.8');
|
|
311
344
|
}
|
|
312
345
|
throw new NetError('INVALID', 'This change is not supported');
|
|
313
346
|
}
|
|
347
|
+
// ---- rules (gateway ACLs; mapping and checks in omadaRules.ts) ---------------------------
|
|
348
|
+
ruleWorld(snap) {
|
|
349
|
+
return {
|
|
350
|
+
networks: snap.networks,
|
|
351
|
+
acls: snap.acls,
|
|
352
|
+
ipGroups: snap.ipGroups,
|
|
353
|
+
ipPortGroups: snap.ipPortGroups,
|
|
354
|
+
ssids: snap.ssids.map((s) => ({ id: s.ssidId, name: String(s.detail.name) })),
|
|
355
|
+
controllerAddresses: this.controllerAddresses,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
/** The rule as the model sees it, with its raw ACL; a rule the model can't express is refused. */
|
|
359
|
+
editableRule(ref, snap, what) {
|
|
360
|
+
const world = this.ruleWorld(snap);
|
|
361
|
+
const rules = snap.acls.map((raw, i) => ({ raw, rule: toRule(raw, i + 1, world) }));
|
|
362
|
+
const found = resolve(rules.map((r) => ({ ...r, id: r.rule.id, name: r.rule.name })), ref, 'rule');
|
|
363
|
+
if (found.rule.foreign)
|
|
364
|
+
throw new NetError('UNSUPPORTED', `Rule '${found.rule.name}' can't be ${what} through net_: ${found.rule.foreign.reason} Use the omada_ gateway ACL tools for it.`, { capability: 'rules' });
|
|
365
|
+
return { raw: found.raw, rule: found.rule };
|
|
366
|
+
}
|
|
367
|
+
checkPosition(position, count) {
|
|
368
|
+
if (!Number.isInteger(position) || position < 1 || position > count)
|
|
369
|
+
throw new NetError('INVALID', `Position must be 1 to ${count}`);
|
|
370
|
+
return position;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Refuses a change if any rule list it passes through (the end state, and the staged
|
|
374
|
+
* one where the rule is switched off) could expose some of the controller's (and this
|
|
375
|
+
* server's) internet traffic to a block that doesn't reach it today (controllerRisk).
|
|
376
|
+
* Waived only when the controller is certainly cut off already.
|
|
377
|
+
*/
|
|
378
|
+
guardController(snap, states) {
|
|
379
|
+
const today = controllerPath(snap.acls, this.ruleWorld(snap));
|
|
380
|
+
for (const state of states) {
|
|
381
|
+
const risk = controllerRisk(today, controllerPath(state.acls, state.world));
|
|
382
|
+
if (risk)
|
|
383
|
+
throw new NetError('PROTECTED', `This could cut the controller (and this server next to it) off the internet: ${risk}. Nothing was changed.`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
buildRuleCreate(value, snap) {
|
|
387
|
+
const world = this.ruleWorld(snap);
|
|
388
|
+
const draft = draftRule(value, world);
|
|
389
|
+
const position = value.position === undefined ? undefined : this.checkPosition(value.position, snap.acls.length + 1);
|
|
390
|
+
const moves = position !== undefined && position <= snap.acls.length;
|
|
391
|
+
const steps = [
|
|
392
|
+
...groupSteps(draft),
|
|
393
|
+
{ action: 'create', object: 'omada.gatewayAcl', name: draft.name, detail: draft.direction === 'lanToLan' ? 'LAN to LAN' : 'LAN to internet' },
|
|
394
|
+
...(moves ? [{ action: 'update', object: 'omada.gatewayAclOrder', name: draft.name, detail: `position ${position}` }] : []),
|
|
395
|
+
...(moves && draft.enabled ? [{ action: 'update', object: 'omada.gatewayAcl', name: draft.name, detail: 'switched on once in place' }] : []),
|
|
396
|
+
];
|
|
397
|
+
const at = position ?? snap.acls.length + 1;
|
|
398
|
+
const placed = draftAsAcl(draft, 'new-rule', world);
|
|
399
|
+
this.guardController(snap, [{ acls: placeAt(snap.acls, placed.acl, at), world: placed.world }]);
|
|
400
|
+
return {
|
|
401
|
+
steps,
|
|
402
|
+
effects: [...draft.effects, `It is rule ${at} of ${snap.acls.length + 1}; rules are checked in order and the first match decides.`],
|
|
403
|
+
wouldLockOut: [],
|
|
404
|
+
reads: { networks: true, ssids: true, rules: true },
|
|
405
|
+
execute: async (s) => {
|
|
406
|
+
// Every new rule carries a fresh tag: its description is unique, so the rule is found
|
|
407
|
+
// again (and undone) by it, never mistaken for one made elsewhere at the same moment.
|
|
408
|
+
const key = newRuleKey();
|
|
409
|
+
const description = tagged(draft.name, key);
|
|
410
|
+
const made = [];
|
|
411
|
+
// 'none': nothing sent; 'sent': the create was sent and may have happened.
|
|
412
|
+
let acl = 'none';
|
|
413
|
+
let aclId;
|
|
414
|
+
try {
|
|
415
|
+
const source = await this.materialize(draft.source, key, 'from', s.siteId, made);
|
|
416
|
+
const destination = await this.materialize(draft.destination, key, 'to', s.siteId, made);
|
|
417
|
+
// A rule that still has to move is created switched off, so it never acts from the wrong place.
|
|
418
|
+
const body = aclBody(draft, description, source, destination, false);
|
|
419
|
+
acl = 'sent';
|
|
420
|
+
await this.client.networkConfig.createGatewayAcl({ ...body, status: moves ? false : draft.enabled }, s.siteId);
|
|
421
|
+
const acls = await this.sortedAcls(s.siteId);
|
|
422
|
+
aclId = acls.find((a) => a.description === description)?.id;
|
|
423
|
+
if (!aclId)
|
|
424
|
+
throw new NetError('CONFLICT', `Created rule '${draft.name}' but could not find it again`);
|
|
425
|
+
if (moves) {
|
|
426
|
+
await this.reorder(acls, aclId, position, s.siteId);
|
|
427
|
+
if (draft.enabled)
|
|
428
|
+
await this.client.networkConfig.updateGatewayAcl(aclId, body, s.siteId);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
catch (error) {
|
|
432
|
+
throw await this.undoCreate(error, acl, description, made, s.siteId);
|
|
433
|
+
}
|
|
434
|
+
return await this.done(aclId, await this.sweepOwnedGroups(s.siteId), [
|
|
435
|
+
...made.map((g) => ({ object: g.type === GROUP_TYPE.ip ? 'omada.ipGroup' : 'omada.ipPortGroup', id: g.id ?? g.name })),
|
|
436
|
+
{ object: 'omada.gatewayAcl', id: aclId },
|
|
437
|
+
]);
|
|
438
|
+
},
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
buildRuleUpdate(ref, set, snap) {
|
|
442
|
+
const current = this.editableRule(ref, snap, 'changed');
|
|
443
|
+
const world = this.ruleWorld(snap);
|
|
444
|
+
const { position, ...fields } = set;
|
|
445
|
+
const next = { ...current.rule, ...fields };
|
|
446
|
+
const draft = draftRule(next, world, current);
|
|
447
|
+
const target = position === undefined ? undefined : this.checkPosition(position, snap.acls.length);
|
|
448
|
+
const moves = target !== undefined && target !== current.rule.position;
|
|
449
|
+
const changes = describeRuleChanges(current.rule, next);
|
|
450
|
+
if (changes.length === 0 && !moves)
|
|
451
|
+
throw new NetError('INVALID', `Nothing to change on rule '${current.rule.name}'`);
|
|
452
|
+
const oldKey = ruleKey(String(current.raw.description));
|
|
453
|
+
// Changed and moved: written switched off, moved, then switched on, so the new content never acts from the old place.
|
|
454
|
+
const staged = moves && changes.length > 0 && draft.enabled;
|
|
455
|
+
{
|
|
456
|
+
const id = String(current.raw.id);
|
|
457
|
+
const others = snap.acls.filter((a) => String(a.id) !== id);
|
|
458
|
+
const after = draftAsAcl(draft, id, world);
|
|
459
|
+
const states = [{ acls: placeAt(others, after.acl, target ?? current.rule.position), world: after.world }];
|
|
460
|
+
// Switched off on the way (staging, and the first step of undoing it): as if the rule were gone.
|
|
461
|
+
if (staged)
|
|
462
|
+
states.push({ acls: others, world });
|
|
463
|
+
this.guardController(snap, states);
|
|
464
|
+
}
|
|
465
|
+
const steps = [
|
|
466
|
+
...groupSteps(draft),
|
|
467
|
+
...(changes.length ? [{ action: 'update', object: 'omada.gatewayAcl', name: current.rule.name, detail: `${changes.join('; ')}${staged ? ' (switched off until in place)' : ''}` }] : []),
|
|
468
|
+
...(moves ? [{ action: 'update', object: 'omada.gatewayAclOrder', name: current.rule.name, detail: `position ${target}` }] : []),
|
|
469
|
+
...(staged ? [{ action: 'update', object: 'omada.gatewayAcl', name: current.rule.name, detail: 'switched on in place' }] : []),
|
|
470
|
+
];
|
|
471
|
+
return {
|
|
472
|
+
steps,
|
|
473
|
+
effects: [
|
|
474
|
+
...(changes.length ? [`'${current.rule.name}': ${changes.join('; ')}.`, ...draft.effects] : []),
|
|
475
|
+
...(moves ? [`It moves from position ${current.rule.position} to ${target} of ${snap.acls.length}.`] : []),
|
|
476
|
+
],
|
|
477
|
+
wouldLockOut: [],
|
|
478
|
+
reads: { networks: true, ssids: true, rules: true },
|
|
479
|
+
execute: async (s) => {
|
|
480
|
+
const aclId = String(current.raw.id);
|
|
481
|
+
const original = writableAcl(current.raw);
|
|
482
|
+
const originalOrder = orderOf(s.acls);
|
|
483
|
+
const made = [];
|
|
484
|
+
// 'sent' = the write was sent and may have happened, even if it reported an error.
|
|
485
|
+
let put = 'none';
|
|
486
|
+
let reorder = 'none';
|
|
487
|
+
try {
|
|
488
|
+
if (changes.length) {
|
|
489
|
+
const key = oldKey ?? (needsGroups(draft) ? newRuleKey() : undefined);
|
|
490
|
+
const source = await this.materialize(draft.source, key, 'from', s.siteId, made);
|
|
491
|
+
const destination = await this.materialize(draft.destination, key, 'to', s.siteId, made);
|
|
492
|
+
const body = aclBody(draft, tagged(draft.name, key), source, destination, current.raw.syslog === true);
|
|
493
|
+
put = 'sent';
|
|
494
|
+
await this.client.networkConfig.updateGatewayAcl(aclId, staged ? { ...body, status: false } : body, s.siteId);
|
|
495
|
+
if (moves) {
|
|
496
|
+
reorder = 'sent';
|
|
497
|
+
await this.reorder(await this.sortedAcls(s.siteId), aclId, target, s.siteId);
|
|
498
|
+
}
|
|
499
|
+
if (staged)
|
|
500
|
+
await this.client.networkConfig.updateGatewayAcl(aclId, body, s.siteId);
|
|
501
|
+
}
|
|
502
|
+
else if (moves) {
|
|
503
|
+
reorder = 'sent';
|
|
504
|
+
await this.reorder(await this.sortedAcls(s.siteId), aclId, target, s.siteId);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
catch (error) {
|
|
508
|
+
throw await this.undoUpdate(error, { aclId, original, originalOrder, put, reorder, made }, s.siteId);
|
|
509
|
+
}
|
|
510
|
+
// Only now, with everything in place: groups no rule uses any more.
|
|
511
|
+
return await this.done(aclId, await this.sweepOwnedGroups(s.siteId));
|
|
512
|
+
},
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
buildRuleDelete(ref, snap) {
|
|
516
|
+
const current = this.editableRule(ref, snap, 'deleted');
|
|
517
|
+
const ids = referencedIds(current.raw);
|
|
518
|
+
const otherUses = new Set([...snap.acls.filter((a) => a.id !== current.raw.id), ...snap.otherAcls].flatMap(referencedIds));
|
|
519
|
+
const released = [...snap.ipGroups, ...snap.ipPortGroups].filter((g) => isOwnedGroup(g) && ids.includes(String(g.groupId)) && !otherUses.has(String(g.groupId)));
|
|
520
|
+
this.guardController(snap, [{ acls: snap.acls.filter((a) => a.id !== current.raw.id), world: this.ruleWorld(snap) }]);
|
|
521
|
+
return {
|
|
522
|
+
steps: [
|
|
523
|
+
{ action: 'delete', object: 'omada.gatewayAcl', name: current.rule.name },
|
|
524
|
+
...released.map((g) => ({ action: 'delete', object: g.type === GROUP_TYPE.ipPort ? 'omada.ipPortGroup' : 'omada.ipGroup', name: String(g.name) })),
|
|
525
|
+
],
|
|
526
|
+
effects: [`Rule '${current.rule.name}' is removed${released.length ? ', with the address group it owns' : ''}.`],
|
|
527
|
+
wouldLockOut: [],
|
|
528
|
+
reads: { networks: true, ssids: true, rules: true },
|
|
529
|
+
execute: async (s) => {
|
|
530
|
+
const warnings = [];
|
|
531
|
+
try {
|
|
532
|
+
await this.client.networkConfig.deleteGatewayAcl(String(current.raw.id), s.siteId);
|
|
533
|
+
}
|
|
534
|
+
catch (error) {
|
|
535
|
+
// The delete may have happened even though it reported an error: look.
|
|
536
|
+
let gone;
|
|
537
|
+
try {
|
|
538
|
+
gone = !(await this.sortedAcls(s.siteId)).some((a) => String(a.id) === String(current.raw.id));
|
|
539
|
+
}
|
|
540
|
+
catch {
|
|
541
|
+
throw withNote(error, `whether rule '${current.rule.name}' was deleted is unknown; check net_listRules`);
|
|
542
|
+
}
|
|
543
|
+
if (!gone)
|
|
544
|
+
throw error;
|
|
545
|
+
warnings.push("The controller's answer was lost, but the rule is gone.");
|
|
546
|
+
}
|
|
547
|
+
return await this.done(undefined, [...warnings, ...(await this.sweepOwnedGroups(s.siteId))]);
|
|
548
|
+
},
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
buildRuleMove(ref, position, snap) {
|
|
552
|
+
const world = this.ruleWorld(snap);
|
|
553
|
+
const rules = snap.acls.map((raw, i) => ({ raw, rule: toRule(raw, i + 1, world) }));
|
|
554
|
+
// Moving touches only the order, so a rule the model can't otherwise express may move too.
|
|
555
|
+
const found = resolve(rules.map((r) => ({ ...r, id: r.rule.id, name: r.rule.name })), ref, 'rule');
|
|
556
|
+
const target = this.checkPosition(position, snap.acls.length);
|
|
557
|
+
if (target === found.rule.position)
|
|
558
|
+
throw new NetError('INVALID', `Rule '${found.rule.name}' is already at position ${target}`);
|
|
559
|
+
this.guardController(snap, [{ acls: placeAt(snap.acls.filter((a) => a.id !== found.raw.id), found.raw, target), world }]);
|
|
560
|
+
return {
|
|
561
|
+
steps: [{ action: 'update', object: 'omada.gatewayAclOrder', name: found.rule.name, detail: `position ${target}` }],
|
|
562
|
+
effects: [`'${found.rule.name}' moves from position ${found.rule.position} to ${target} of ${snap.acls.length}; rules are checked in order and the first match decides.`],
|
|
563
|
+
wouldLockOut: [],
|
|
564
|
+
reads: { networks: true, ssids: true, rules: true },
|
|
565
|
+
execute: async (s) => {
|
|
566
|
+
const originalOrder = orderOf(s.acls);
|
|
567
|
+
try {
|
|
568
|
+
await this.reorder(await this.sortedAcls(s.siteId), String(found.raw.id), target, s.siteId);
|
|
569
|
+
}
|
|
570
|
+
catch (error) {
|
|
571
|
+
// The reorder may have happened even though it reported an error: put the order back.
|
|
572
|
+
throw await this.restoreOrder(error, originalOrder, s.siteId);
|
|
573
|
+
}
|
|
574
|
+
return await this.done(String(found.raw.id), await this.sweepOwnedGroups(s.siteId));
|
|
575
|
+
},
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Existing ids for an endpoint, or the id of the group created for it. The group is
|
|
580
|
+
* tracked by its (unique) name before it is created, so a create whose answer or
|
|
581
|
+
* read-back got lost can still be found and undone.
|
|
582
|
+
*/
|
|
583
|
+
async materialize(endpoint, key, side, siteId, made) {
|
|
584
|
+
if ('ids' in endpoint)
|
|
585
|
+
return { type: endpoint.type, ids: endpoint.ids };
|
|
586
|
+
if (!key)
|
|
587
|
+
throw new NetError('INVALID', 'Internal: a rule that owns a group needs a tag');
|
|
588
|
+
const { groupType, body } = endpoint.newGroup;
|
|
589
|
+
// A name no group has yet: finding it by name afterwards (or in an undo) can only find this one.
|
|
590
|
+
const existing = await this.client.networkConfig.listGroups(groupType, siteId);
|
|
591
|
+
const tracked = { type: groupType, name: ownedGroupName(key, side, new Set(existing.map((g) => String(g.name)))) };
|
|
592
|
+
made.push(tracked);
|
|
593
|
+
await this.client.networkConfig.createGroup({ name: tracked.name, ...body }, siteId);
|
|
594
|
+
const group = (await this.client.networkConfig.listGroups(groupType, siteId)).find((g) => g.name === tracked.name);
|
|
595
|
+
if (!group)
|
|
596
|
+
throw new NetError('CONFLICT', `Created group '${tracked.name}' but could not find it again`);
|
|
597
|
+
tracked.id = String(group.groupId);
|
|
598
|
+
return { type: endpoint.type, ids: [tracked.id] };
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Undoes a create that failed part-way and returns the error to throw. A create that
|
|
602
|
+
* was sent may have happened even if it reported an error: the rule is looked up by
|
|
603
|
+
* its description and removed before its groups. When that can't be confirmed, the
|
|
604
|
+
* groups stay (a live rule must never point at deleted groups) and the error says so.
|
|
605
|
+
*/
|
|
606
|
+
async undoCreate(error, acl, description, made, siteId) {
|
|
607
|
+
const notes = [];
|
|
608
|
+
if (acl === 'sent') {
|
|
609
|
+
try {
|
|
610
|
+
const found = (await this.sortedAcls(siteId)).find((a) => a.description === description);
|
|
611
|
+
if (found)
|
|
612
|
+
await this.client.networkConfig.deleteGatewayAcl(String(found.id), siteId);
|
|
613
|
+
}
|
|
614
|
+
catch {
|
|
615
|
+
return withNote(error, `the rule may have been created; check net_listRules for '${description}' (its groups were kept)`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
notes.push(...(await this.dropGroups(made, siteId)));
|
|
619
|
+
return notes.length ? withNote(error, notes.join('; ')) : error;
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Undoes an update that failed part-way and returns the error to throw: puts the rule
|
|
623
|
+
* back as it was (if the change may have been written) and removes the groups made
|
|
624
|
+
* for it, unless the rule might still point at them.
|
|
625
|
+
*/
|
|
626
|
+
async undoUpdate(error, u, siteId) {
|
|
627
|
+
if (u.reorder === 'sent') {
|
|
628
|
+
try {
|
|
629
|
+
// Content changed too: switch it off first, so neither version acts from the wrong place while the order goes back.
|
|
630
|
+
if (u.put === 'sent')
|
|
631
|
+
await this.client.networkConfig.updateGatewayAcl(u.aclId, { ...u.original, status: false }, siteId);
|
|
632
|
+
await this.client.networkConfig.reorderGatewayAcls(u.originalOrder, siteId);
|
|
633
|
+
}
|
|
634
|
+
catch {
|
|
635
|
+
return withNote(error, 'the rule order could not be put back; check it with net_listRules (the groups made for the change were kept)');
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
if (u.put === 'sent') {
|
|
639
|
+
try {
|
|
640
|
+
await this.client.networkConfig.updateGatewayAcl(u.aclId, u.original, siteId);
|
|
641
|
+
}
|
|
642
|
+
catch {
|
|
643
|
+
return withNote(error, 'the rule could not be put back as it was; check it with net_listRules (the groups made for the change were kept)');
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
const notes = await this.dropGroups(u.made, siteId);
|
|
647
|
+
return notes.length ? withNote(error, notes.join('; ')) : error;
|
|
648
|
+
}
|
|
649
|
+
/** Puts the rule order back after a move that failed (it may have happened anyway); returns the error to throw. */
|
|
650
|
+
async restoreOrder(error, originalOrder, siteId) {
|
|
651
|
+
try {
|
|
652
|
+
await this.client.networkConfig.reorderGatewayAcls(originalOrder, siteId);
|
|
653
|
+
return error;
|
|
654
|
+
}
|
|
655
|
+
catch {
|
|
656
|
+
return withNote(error, 'the rule order could not be put back; check it with net_listRules');
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
/** Deletes groups made during a failed change (found by name when the id is unknown); returns what it couldn't remove. */
|
|
660
|
+
async dropGroups(made, siteId) {
|
|
661
|
+
const left = [];
|
|
662
|
+
for (const g of made) {
|
|
663
|
+
try {
|
|
664
|
+
const id = g.id ?? (await this.client.networkConfig.listGroups(g.type, siteId)).find((x) => x.name === g.name)?.groupId;
|
|
665
|
+
if (id)
|
|
666
|
+
await this.client.networkConfig.deleteGroup(g.type, String(id), siteId);
|
|
667
|
+
}
|
|
668
|
+
catch {
|
|
669
|
+
left.push(`group '${g.name}' may be left behind`);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
return left;
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Deletes every group a net_ rule owned that no ACL (gateway, switch or access point)
|
|
676
|
+
* references any more. Run after each successful rule change, so a cleanup that failed
|
|
677
|
+
* once is finished by the next change; a failure here is a warning, not an error (the
|
|
678
|
+
* change itself is done). Writes are serialized per site, so no rule is half-created
|
|
679
|
+
* meanwhile.
|
|
680
|
+
*/
|
|
681
|
+
async sweepOwnedGroups(siteId) {
|
|
682
|
+
try {
|
|
683
|
+
const acls = [
|
|
684
|
+
...(await this.client.networkConfig.listAcls('osg-acls', siteId)),
|
|
685
|
+
...(await this.client.networkConfig.listAcls('osw-acls', siteId)),
|
|
686
|
+
...(await this.client.networkConfig.listAcls('eap-acls', siteId)),
|
|
687
|
+
];
|
|
688
|
+
const used = new Set(acls.flatMap(referencedIds));
|
|
689
|
+
const left = [];
|
|
690
|
+
for (const type of [GROUP_TYPE.ip, GROUP_TYPE.ipPort]) {
|
|
691
|
+
for (const g of await this.client.networkConfig.listGroups(type, siteId)) {
|
|
692
|
+
const id = String(g.groupId);
|
|
693
|
+
if (!isOwnedGroup(g) || used.has(id))
|
|
694
|
+
continue;
|
|
695
|
+
await this.client.networkConfig.deleteGroup(type, id, siteId).catch(() => left.push(String(g.name)));
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
return left.length ? [`Could not remove the unused group(s) ${left.join(', ')} now; the next rule change removes them.`] : [];
|
|
699
|
+
}
|
|
700
|
+
catch {
|
|
701
|
+
return ['Could not check for unused rule groups now; the next rule change removes them.'];
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
async sortedAcls(siteId) {
|
|
705
|
+
return (await this.client.networkConfig.listAcls('osg-acls', siteId)).sort((a, b) => Number(a.index ?? 0) - Number(b.index ?? 0));
|
|
706
|
+
}
|
|
707
|
+
/** Moves one rule to `position` (1 = first); the others keep their order. */
|
|
708
|
+
async reorder(acls, aclId, position, siteId) {
|
|
709
|
+
const moving = acls.find((a) => String(a.id) === aclId);
|
|
710
|
+
if (!moving)
|
|
711
|
+
throw new NetError('NOT_FOUND', 'The rule disappeared before it could be moved');
|
|
712
|
+
const order = acls.filter((a) => String(a.id) !== aclId);
|
|
713
|
+
order.splice(Math.min(position, acls.length) - 1, 0, moving);
|
|
714
|
+
await this.client.networkConfig.reorderGatewayAcls(Object.fromEntries(order.map((a, i) => [String(a.id), i + 1])), siteId);
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* The result of a change that is done: the rule as it reads back, plus warnings. A
|
|
718
|
+
* failed read-back is a warning, never an error: the change is already applied.
|
|
719
|
+
*/
|
|
720
|
+
async done(aclId, warnings, owned = []) {
|
|
721
|
+
const notes = [...warnings];
|
|
722
|
+
let object;
|
|
723
|
+
if (aclId) {
|
|
724
|
+
try {
|
|
725
|
+
const snap = await this.snapshot();
|
|
726
|
+
const index = snap.acls.findIndex((a) => String(a.id) === aclId);
|
|
727
|
+
if (index >= 0)
|
|
728
|
+
object = toRule(snap.acls[index], index + 1, this.ruleWorld(snap));
|
|
729
|
+
}
|
|
730
|
+
catch {
|
|
731
|
+
notes.push(`The change is applied, but reading the rule back failed; check it with net_listRules (rule id ${aclId}).`);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
return { applied: true, ...(object ? { object } : {}), owned, ...(notes.length ? { warnings: notes } : {}) };
|
|
735
|
+
}
|
|
314
736
|
// ---- networks ------------------------------------------------------------------------
|
|
315
737
|
buildNetworkCreate(value, snap) {
|
|
316
738
|
const name = checkName(value.name, MAX_NETWORK_NAME, 'Network');
|
|
317
739
|
if (value.purpose === 'guest')
|
|
318
740
|
throw unsupported('a guest network type (Omada makes a Wi-Fi network a guest network instead: clientIsolation)', 'networks.guestPurpose');
|
|
319
741
|
if (value.isolated)
|
|
320
|
-
throw unsupported('network isolation (
|
|
742
|
+
throw unsupported('a network isolation flag (make a block rule from this network to the others with net_createRule)', 'networks.isolation');
|
|
321
743
|
if (value.ipv6)
|
|
322
744
|
throw unsupported('switching IPv6 on (do it in the controller web UI)', 'networks.ipv6');
|
|
323
745
|
const vlan = checkVlan(value.vlan);
|
|
@@ -376,7 +798,7 @@ export class OmadaNetworkController {
|
|
|
376
798
|
throw new NetError('INVALID', `'${current.name}' is switch-only: it has no gateway and no DHCP to change`);
|
|
377
799
|
}
|
|
378
800
|
if (set.isolated)
|
|
379
|
-
throw unsupported('network isolation (
|
|
801
|
+
throw unsupported('a network isolation flag (make a block rule from this network to the others with net_createRule)', 'networks.isolation');
|
|
380
802
|
if (set.ipv6 === true && !current.ipv6)
|
|
381
803
|
throw unsupported('switching IPv6 on (do it in the controller web UI)', 'networks.ipv6');
|
|
382
804
|
const addressing = (set.vlan !== undefined && set.vlan !== current.vlan) || (set.gateway !== undefined && set.gateway !== current.gateway);
|
|
@@ -715,32 +1137,82 @@ export class OmadaNetworkController {
|
|
|
715
1137
|
};
|
|
716
1138
|
}
|
|
717
1139
|
}
|
|
718
|
-
|
|
1140
|
+
/**
|
|
1141
|
+
* Every id an ACL's source or destination references. Endpoint type numbers differ between
|
|
1142
|
+
* gateway, switch and access point ACLs, so ids are not filtered by type: a network id
|
|
1143
|
+
* never equals a group id, and over-counting only keeps a group longer.
|
|
1144
|
+
*/
|
|
1145
|
+
function referencedIds(acl) {
|
|
1146
|
+
return [...(acl.sourceIds ?? []), ...(acl.destinationIds ?? [])].map(String);
|
|
1147
|
+
}
|
|
1148
|
+
/** A rule list with `acl` at `position` (1 = first). */
|
|
1149
|
+
function placeAt(acls, acl, position) {
|
|
1150
|
+
const list = [...acls];
|
|
1151
|
+
list.splice(Math.min(Math.max(position, 1), list.length + 1) - 1, 0, acl);
|
|
1152
|
+
return list;
|
|
1153
|
+
}
|
|
1154
|
+
/** The current rule order as reorderGatewayAcls takes it. */
|
|
1155
|
+
function orderOf(acls) {
|
|
1156
|
+
return Object.fromEntries(acls.map((a, i) => [String(a.id), i + 1]));
|
|
1157
|
+
}
|
|
1158
|
+
/** What a PUT must resend to put an ACL back exactly as it was. */
|
|
1159
|
+
function writableAcl(raw) {
|
|
1160
|
+
const fields = ['description', 'status', 'policy', 'protocols', 'sourceType', 'sourceIds', 'destinationType', 'destinationIds', 'syslog', 'direction', 'stateMode', 'states', 'timeRangeId'];
|
|
1161
|
+
return Object.fromEntries(fields.filter((f) => raw[f] !== undefined && raw[f] !== null).map((f) => [f, raw[f]]));
|
|
1162
|
+
}
|
|
1163
|
+
/** The original error, with what the undo couldn't do appended. */
|
|
1164
|
+
function withNote(error, note) {
|
|
1165
|
+
if (error instanceof NetError)
|
|
1166
|
+
return new NetError(error.code, `${error.message} (${note})`, error.details);
|
|
1167
|
+
return new Error(`${error instanceof Error ? error.message : String(error)} (${note})`);
|
|
1168
|
+
}
|
|
1169
|
+
function needsGroups(draft) {
|
|
1170
|
+
return 'newGroup' in draft.source || 'newGroup' in draft.destination;
|
|
1171
|
+
}
|
|
1172
|
+
function groupSteps(draft) {
|
|
1173
|
+
return [draft.source, draft.destination]
|
|
1174
|
+
.filter((e) => 'newGroup' in e)
|
|
1175
|
+
.map((e) => ({ action: 'create', object: e.newGroup.groupType === GROUP_TYPE.ipPort ? 'omada.ipPortGroup' : 'omada.ipGroup', name: e.label }));
|
|
1176
|
+
}
|
|
1177
|
+
function aclBody(draft, description, source, destination, syslog) {
|
|
1178
|
+
return {
|
|
1179
|
+
description,
|
|
1180
|
+
status: draft.enabled,
|
|
1181
|
+
policy: draft.policy,
|
|
1182
|
+
protocols: draft.protocols,
|
|
1183
|
+
sourceType: source.type,
|
|
1184
|
+
sourceIds: source.ids,
|
|
1185
|
+
destinationType: destination.type,
|
|
1186
|
+
destinationIds: destination.ids,
|
|
1187
|
+
syslog,
|
|
1188
|
+
direction: { lanToWan: draft.direction === 'lanToWan', lanToLan: draft.direction === 'lanToLan', wanInIds: [], vpnInIds: [] },
|
|
1189
|
+
// 0 = "Auto": replies to connections started on the other side pass (see omadaRules.ts).
|
|
1190
|
+
stateMode: 0,
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
function describeRuleChanges(current, next) {
|
|
1194
|
+
const out = [];
|
|
1195
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
1196
|
+
if (next.name !== current.name)
|
|
1197
|
+
out.push(`name → ${next.name}`);
|
|
1198
|
+
if (next.action !== current.action)
|
|
1199
|
+
out.push(`action → ${next.action}`);
|
|
1200
|
+
if (!same(next.from, current.from))
|
|
1201
|
+
out.push('source changed');
|
|
1202
|
+
if (!same(next.to, current.to))
|
|
1203
|
+
out.push('destination changed');
|
|
1204
|
+
if (!same(next.protocol, current.protocol))
|
|
1205
|
+
out.push(`protocol → ${typeof next.protocol === 'object' ? `ports ${next.protocol.tcpUdpPorts}` : next.protocol}`);
|
|
1206
|
+
if (next.enabled !== current.enabled)
|
|
1207
|
+
out.push(next.enabled ? 'switched on' : 'switched off');
|
|
1208
|
+
return out;
|
|
1209
|
+
}
|
|
719
1210
|
function notYet(what, release) {
|
|
720
1211
|
return new NetError('UNSUPPORTED', `Changing ${what} through the net_ tools arrives in ${release}`, { capability: what });
|
|
721
1212
|
}
|
|
722
1213
|
function unsupported(what, capability) {
|
|
723
1214
|
return new NetError('UNSUPPORTED', `Omada can't do ${what} through its API`, { capability });
|
|
724
1215
|
}
|
|
725
|
-
function resolve(items, ref, what) {
|
|
726
|
-
if ('id' in ref) {
|
|
727
|
-
const byId = items.find((i) => i.id === ref.id);
|
|
728
|
-
if (byId)
|
|
729
|
-
return byId;
|
|
730
|
-
throw new NetError('NOT_FOUND', `No ${what} with id '${ref.id}'`);
|
|
731
|
-
}
|
|
732
|
-
// Tools pass what the user typed as { name }; an exact id match wins.
|
|
733
|
-
const byId = items.find((i) => i.id === ref.name);
|
|
734
|
-
if (byId)
|
|
735
|
-
return byId;
|
|
736
|
-
const wanted = ref.name.trim().toLowerCase();
|
|
737
|
-
const matches = items.filter((i) => i.name.toLowerCase() === wanted);
|
|
738
|
-
if (matches.length === 1)
|
|
739
|
-
return matches[0];
|
|
740
|
-
if (matches.length > 1)
|
|
741
|
-
throw new NetError('AMBIGUOUS', `Several ${what}s are named '${ref.name}'; use the id`);
|
|
742
|
-
throw new NetError('NOT_FOUND', `${what[0].toUpperCase()}${what.slice(1)} '${ref.name}' not found. Available: ${items.map((i) => i.name).join(', ') || '(none)'}`);
|
|
743
|
-
}
|
|
744
1216
|
/** The network a Wi-Fi network may put its devices on: one with a gateway (and so an address for them). */
|
|
745
1217
|
function wifiNetwork(networks, ref) {
|
|
746
1218
|
const network = resolve(networks, ref, 'network');
|
|
@@ -859,6 +1331,7 @@ function fingerprint(snap, reads) {
|
|
|
859
1331
|
ssids: reads.ssids ? redactSecrets(snap.ssids) : undefined,
|
|
860
1332
|
// A MAC filter plan resolved a group by name: a renamed or replaced group must not slip through.
|
|
861
1333
|
macGroups: reads.macGroups ? snap.macGroups : undefined,
|
|
1334
|
+
rules: reads.rules ? { acls: snap.acls, otherAcls: snap.otherAcls, ipGroups: snap.ipGroups, ipPortGroups: snap.ipPortGroups } : undefined,
|
|
862
1335
|
};
|
|
863
1336
|
return createHash('sha256').update(JSON.stringify(material)).digest('hex').slice(0, 16);
|
|
864
1337
|
}
|