@coffeerunhobby/mcp-ha-connect 2.4.6 → 2.4.8

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.
Files changed (37) hide show
  1. package/.env.example +10 -0
  2. package/README.md +25 -8
  3. package/dist/config.d.ts +2 -0
  4. package/dist/config.js +4 -0
  5. package/dist/config.js.map +1 -1
  6. package/dist/net/adapters/omada.d.ts +96 -10
  7. package/dist/net/adapters/omada.js +909 -68
  8. package/dist/net/adapters/omada.js.map +1 -1
  9. package/dist/net/adapters/omadaPorts.d.ts +62 -0
  10. package/dist/net/adapters/omadaPorts.js +212 -0
  11. package/dist/net/adapters/omadaPorts.js.map +1 -0
  12. package/dist/net/adapters/omadaRules.d.ts +141 -0
  13. package/dist/net/adapters/omadaRules.js +595 -0
  14. package/dist/net/adapters/omadaRules.js.map +1 -0
  15. package/dist/net/model.d.ts +38 -11
  16. package/dist/net/model.js.map +1 -1
  17. package/dist/omadaClient/accessControl.js +4 -1
  18. package/dist/omadaClient/accessControl.js.map +1 -1
  19. package/dist/omadaClient/index.d.ts +4 -0
  20. package/dist/omadaClient/index.js +3 -0
  21. package/dist/omadaClient/index.js.map +1 -1
  22. package/dist/omadaClient/networkConfig.d.ts +30 -0
  23. package/dist/omadaClient/networkConfig.js +69 -0
  24. package/dist/omadaClient/networkConfig.js.map +1 -1
  25. package/dist/tools/index.js +1 -1
  26. package/dist/tools/index.js.map +1 -1
  27. package/dist/tools/net/index.d.ts +148 -1
  28. package/dist/tools/net/index.js +246 -10
  29. package/dist/tools/net/index.js.map +1 -1
  30. package/dist/tools/omada/listClients.js +1 -1
  31. package/dist/tools/omada/listClients.js.map +1 -1
  32. package/dist/upstreams.d.ts +1 -0
  33. package/dist/upstreams.js +2 -1
  34. package/dist/upstreams.js.map +1 -1
  35. package/dist/version.d.ts +1 -1
  36. package/dist/version.js +1 -1
  37. package/package.json +6 -6
@@ -1,9 +1,10 @@
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). Rules, groups, switch
5
- * ports and mDNS arrive with 2.4.7 and 2.4.8; until then those calls fail with
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; 2.4.8 switch ports (the networks a port
6
+ * carries), listing port profiles and the mDNS repeater (see omadaPorts.ts). Creating
7
+ * port profiles, groups and reservations is refused with NetError UNSUPPORTED.
7
8
  *
8
9
  * Omada specifics handled here:
9
10
  * - networks have no "guest" purpose and no isolation flag in the API (guest is per
@@ -22,6 +23,8 @@ import { inSubnet, ipToNumber } from '../../utils/ipv4.js';
22
23
  import { redactSecrets } from '../../utils/redact.js';
23
24
  import { NetError, } from '../model.js';
24
25
  import { MAX_NETWORK_NAME, MAX_WIFI_NAME, checkBands, checkDhcp, checkGateway, checkName, checkNetworkUnique, checkVlan } from '../validate.js';
26
+ import { GROUP_TYPE, controllerPath, controllerRisk, draftAsAcl, draftRule, isOwnedGroup, newRuleKey, ownedGroupName, resolve, ruleKey, tagged, toRule, } from './omadaRules.js';
27
+ import { MDNS_GATEWAY, TAGS, cutOff, describeNetworks, linkPorts, toMdns, toPortNetworks, toPortProfile } from './omadaPorts.js';
25
28
  const PSEUDO_WLAN_ID = 'gateway';
26
29
  /** Omada LAN network purpose: 0 = VLAN only (switches), 1 = interface (routed by the gateway). */
27
30
  const PURPOSE_VLAN = 0;
@@ -46,6 +49,36 @@ const SSID_FIELDS = [
46
49
  'name', 'band', 'guestNetEnable', 'security', 'oweEnable', 'broadcast', 'vlanEnable', 'vlanId', 'pskSetting', 'mloEnable', 'pmfMode',
47
50
  'enable11r', 'hidePwd', 'greEnable', 'vlanSetting', 'prohibitWifiShare',
48
51
  ];
52
+ /** Lists a switch's entry under a network has (live, 6.3.0.45: always all six). */
53
+ const AFFECTED_LISTS = ['nativePorts', 'untagPorts', 'tagPorts', 'nativeLags', 'untagLags', 'tagLags'];
54
+ /** A switch's `affectedPorts` with every list in place, or undefined: then which of its ports carry the network can't be told. */
55
+ function affectedPortsOf(device) {
56
+ const affected = device.switchInfo?.affectedPorts;
57
+ return affected && AFFECTED_LISTS.every((k) => Array.isArray(affected[k])) ? affected : undefined;
58
+ }
59
+ /**
60
+ * Which ports of one switch carry each network (a network that doesn't list the switch reaches
61
+ * none of them); undefined when a network lists the switch twice or without all its lists.
62
+ */
63
+ function affectedOf(carrying, mac) {
64
+ const out = new Map();
65
+ for (const { id, devices } of carrying) {
66
+ const entries = devices.filter((d) => typeof d.mac === 'string' && normalizeMac(d.mac) === normalizeMac(mac));
67
+ if (entries.length > 1)
68
+ return undefined;
69
+ if (!entries.length) {
70
+ out.set(id, {});
71
+ continue;
72
+ }
73
+ const affected = affectedPortsOf(entries[0]);
74
+ if (!affected)
75
+ return undefined;
76
+ out.set(id, affected);
77
+ }
78
+ return out;
79
+ }
80
+ /** Port fields a port change depends on (link state and counters change all the time and are left out). */
81
+ const PORT_FIELDS = ['port', 'name', 'profileId', 'profileOverrideEnable', 'lagPort'];
49
82
  const pick = (raw, fields) => Object.fromEntries(fields.filter((f) => raw[f] !== undefined).map((f) => [f, raw[f]]));
50
83
  export class OmadaNetworkController {
51
84
  client;
@@ -55,6 +88,14 @@ export class OmadaNetworkController {
55
88
  executors = new WeakMap();
56
89
  /** The controller's IPv4 addresses; 'unknown' when its host name can't be resolved (then every routed network is protected). */
57
90
  controllerAddresses = [];
91
+ /** This server's IPv4 addresses; 'unset' when not configured, 'unknown' when one can't be resolved. */
92
+ serverAddresses = 'unset';
93
+ /**
94
+ * The controller's and this server's addresses together: their networks are protected and rules
95
+ * must not cut them off the internet. 'unknown' when either can't be resolved (then every routed
96
+ * network is protected); this server's are left out while not configured.
97
+ */
98
+ hostAddresses = [];
58
99
  constructor(client, options = {}) {
59
100
  this.client = client;
60
101
  this.options = options;
@@ -65,35 +106,46 @@ export class OmadaNetworkController {
65
106
  vendor: 'omada',
66
107
  networks: { create: 'native', isolation: 'unsupported', ipv6: 'unsupported', guestPurpose: 'unsupported' },
67
108
  wifi: { create: 'native', bands: ['2.4', '5', '6'], security: ['open', 'wpa2', 'wpa3'], pmf: 'native', fastRoaming: 'native', bandSteering: 'unsupported' },
68
- ports: { profiles: 'unsupported', assign: 'unsupported' },
69
- rules: { between: 'unsupported', toInternet: 'unsupported', deviceMatch: 'unsupported', wifiMatch: 'unsupported', stateful: 'unsupported', schedule: 'unsupported' },
109
+ // Port profiles are listed and assigned; they are created in the controller web UI.
110
+ ports: { profiles: 'unsupported', assign: 'native' },
111
+ rules: { between: 'native', toInternet: 'native', deviceMatch: 'unsupported', wifiMatch: 'unsupported', stateful: 'native', schedule: 'unsupported' },
70
112
  groups: { address: 'unsupported', mac: 'unsupported' },
71
113
  reservations: 'native',
72
- mdnsRepeater: 'unsupported',
114
+ mdnsRepeater: 'native',
73
115
  };
74
116
  }
75
117
  // ---- reads -----------------------------------------------------------------------
76
118
  async resolveController() {
77
119
  const host = this.options.controllerHost;
78
- if (!host) {
79
- this.controllerAddresses = [];
80
- return;
81
- }
82
- try {
83
- this.controllerAddresses = [ipToNumber(host)];
84
- return;
85
- }
86
- catch {
87
- // a host name
88
- }
89
- try {
90
- const resolve = this.options.resolveHost ?? (async (name) => (await lookup(name, { all: true, family: 4 })).map((a) => a.address));
91
- const addresses = await resolve(host);
92
- this.controllerAddresses = addresses.length ? addresses.map(ipToNumber) : 'unknown';
93
- }
94
- catch {
95
- this.controllerAddresses = 'unknown';
120
+ this.controllerAddresses = host ? await this.addressesOf([host]) : [];
121
+ const server = this.options.serverHosts ?? [];
122
+ this.serverAddresses = server.length ? await this.addressesOf(server) : 'unset';
123
+ const both = [this.controllerAddresses, this.serverAddresses === 'unset' ? [] : this.serverAddresses];
124
+ this.hostAddresses = both.some((a) => a === 'unknown') ? 'unknown' : both.flatMap((a) => a);
125
+ }
126
+ /** IPv4 addresses of hosts given by address or name; 'unknown' if any can't be resolved. */
127
+ async addressesOf(hosts) {
128
+ const all = [];
129
+ for (const host of hosts) {
130
+ try {
131
+ all.push(ipToNumber(host));
132
+ continue;
133
+ }
134
+ catch {
135
+ // a host name
136
+ }
137
+ try {
138
+ const resolve = this.options.resolveHost ?? (async (name) => (await lookup(name, { all: true, family: 4 })).map((a) => a.address));
139
+ const addresses = await resolve(host);
140
+ if (!addresses.length)
141
+ return 'unknown';
142
+ all.push(...addresses.map(ipToNumber));
143
+ }
144
+ catch {
145
+ return 'unknown';
146
+ }
96
147
  }
148
+ return all;
97
149
  }
98
150
  async snapshot() {
99
151
  await this.resolveController();
@@ -112,10 +164,14 @@ export class OmadaNetworkController {
112
164
  ssids.push({ wlanId: wlan.wlanId, ssidId: s.ssidId, detail: await this.client.networkConfig.getSsidDetail(wlan.wlanId, s.ssidId, siteId) });
113
165
  }
114
166
  }
115
- const clients = (await this.client.listClients(siteId)).map((c) => this.toClient(c, networks));
167
+ const clients = (await this.client.listClients(siteId)).map((c) => this.toClient(c, networks, ssids));
116
168
  const alwaysProfileId = (await this.client.accessControl.listTimeRanges(siteId)).find((p) => p.name === ALWAYS_PROFILE_NAME)?.profileId;
117
169
  const macGroups = await this.client.listMacGroups(siteId);
118
- return { siteId, networksRaw, networks, ssids, clients, macGroups, ...(alwaysProfileId ? { alwaysProfileId } : {}) };
170
+ const acls = (await this.client.networkConfig.listAcls('osg-acls', siteId)).sort((a, b) => Number(a.index ?? 0) - Number(b.index ?? 0));
171
+ const otherAcls = [...(await this.client.networkConfig.listAcls('osw-acls', siteId)), ...(await this.client.networkConfig.listAcls('eap-acls', siteId))];
172
+ const ipGroups = await this.client.networkConfig.listGroups(GROUP_TYPE.ip, siteId);
173
+ const ipPortGroups = await this.client.networkConfig.listGroups(GROUP_TYPE.ipPort, siteId);
174
+ return { siteId, networksRaw, networks, ssids, clients, macGroups, acls, otherAcls, ipGroups, ipPortGroups, ...(alwaysProfileId ? { alwaysProfileId } : {}) };
119
175
  }
120
176
  toNetwork(raw) {
121
177
  const dhcp = (raw.dhcpSettingsVO ?? {});
@@ -148,26 +204,37 @@ export class OmadaNetworkController {
148
204
  return true;
149
205
  if (typeof raw.gatewaySubnet !== 'string')
150
206
  return false;
151
- // Fail closed: if the controller's address is unknown, any routed network could be its own.
152
- if (this.controllerAddresses === 'unknown')
207
+ // Fail closed: if the controller's or this server's address is unknown, any routed network could be its own.
208
+ if (this.hostAddresses === 'unknown')
153
209
  return true;
154
210
  const subnet = raw.gatewaySubnet;
155
- return this.controllerAddresses.some((address) => inSubnet(address, subnet));
211
+ return this.hostAddresses.some((address) => inSubnet(address, subnet));
156
212
  }
157
- toClient(raw, networks) {
213
+ toClient(raw, networks, ssids) {
158
214
  // A device still waiting for DHCP reports no (or an empty) address.
159
215
  const ip = typeof raw.ip === 'string' && isIpv4(raw.ip) ? raw.ip : undefined;
216
+ const ssid = typeof raw.ssid === 'string' && raw.ssid ? raw.ssid : undefined;
160
217
  let network;
161
- if (typeof raw.vid === 'number')
218
+ // Wireless clients report vid 0 (the field is for wired ones; no network has VLAN 0).
219
+ if (typeof raw.vid === 'number' && raw.wireless !== true)
162
220
  network = networks.find((n) => n.vlan === raw.vid);
163
221
  if (!network && ip)
164
222
  network = networks.find((n) => n.gateway && inSubnet(ipToNumber(ip), n.gateway));
223
+ if (!network && ssid) {
224
+ // No address yet: the network its Wi-Fi puts it on, when that is a single one.
225
+ const found = ssids.filter((s) => s.detail.name === ssid);
226
+ const ids = found.length === 1 ? this.ssidNetworkIds(found[0].detail, networks) : [];
227
+ if (ids.length === 1)
228
+ network = networks.find((n) => n.id === ids[0]);
229
+ }
230
+ const port = Number(raw.port);
165
231
  return {
166
232
  mac: formatMac(String(raw.mac)),
167
233
  ...(typeof raw.name === 'string' ? { name: raw.name } : {}),
168
234
  ...(ip ? { ip } : {}),
169
235
  ...(network ? { network: { id: network.id } } : {}),
170
- ...(typeof raw.ssid === 'string' && raw.ssid ? { wifi: { name: raw.ssid } } : {}),
236
+ ...(ssid ? { wifi: { name: ssid } } : {}),
237
+ ...(raw.wireless !== true && typeof raw.switchMac === 'string' && Number.isInteger(port) && port > 0 ? { port: { device: { mac: formatMac(raw.switchMac) }, port } } : {}),
171
238
  online: raw.active !== false,
172
239
  };
173
240
  }
@@ -237,19 +304,294 @@ export class OmadaNetworkController {
237
304
  .map((r) => ({ mac: formatMac(r.mac), ip: String(r.ip), network: { id: r.netId }, ...(r.description ? { description: String(r.description) } : {}) }));
238
305
  }
239
306
  async listPortProfiles() {
240
- throw notYet('switch port profiles', '2.4.8');
307
+ return (await this.client.networkConfig.listPortProfiles(this.options.siteId)).map(toPortProfile);
241
308
  }
242
- async listPorts() {
243
- throw notYet('switch ports', '2.4.8');
309
+ async listPorts(device) {
310
+ const snap = await this.snapshot();
311
+ const world = await this.portWorld(snap);
312
+ const switches = device ? [this.resolveSwitch(world, device)] : world.switches;
313
+ return switches.flatMap((sw) => sw.ports.map((p) => this.toPort(sw, p, snap)));
244
314
  }
245
315
  async listRules() {
246
- throw notYet('rules', '2.4.7');
316
+ const snap = await this.snapshot();
317
+ const world = this.ruleWorld(snap);
318
+ return snap.acls.map((raw, i) => toRule(raw, i + 1, world));
247
319
  }
248
- async listGroups() {
249
- throw notYet('groups', '2.4.7');
320
+ async listGroups(kind) {
321
+ const snap = await this.snapshot();
322
+ const address = snap.ipGroups.map((g) => ({
323
+ id: String(g.groupId),
324
+ name: String(g.name),
325
+ kind: 'address',
326
+ entries: (g.ipList ?? []).map((e) => ({
327
+ value: Number(e.mask) === 32 ? e.ip : `${e.ip}/${e.mask}`,
328
+ ...(e.description ? { name: e.description } : {}),
329
+ })),
330
+ }));
331
+ const mac = snap.macGroups.map((g) => ({
332
+ id: g.groupId,
333
+ name: g.name,
334
+ kind: 'mac',
335
+ entries: (g.macAddressList ?? []).map((e) => ({ value: formatMac(e.macAddress), ...(e.name ? { name: e.name } : {}) })),
336
+ }));
337
+ return kind === 'address' ? address : kind === 'mac' ? mac : [...address, ...mac];
250
338
  }
251
339
  async getMdnsRepeater() {
252
- throw notYet('the mDNS repeater', '2.4.8');
340
+ return toMdns(await this.client.networkConfig.listMdnsRules(this.options.siteId)).repeater;
341
+ }
342
+ // ---- switch ports and the mDNS repeater (mapping in omadaPorts.ts) ------------------------
343
+ async portWorld(snap) {
344
+ const adopted = await this.client.listDevices(snap.siteId);
345
+ const devices = adopted.filter((d) => d.type === 'switch');
346
+ if (!devices.length)
347
+ return { switches: [] };
348
+ let topology;
349
+ try {
350
+ topology = await this.client.networkConfig.getTopology(snap.siteId);
351
+ }
352
+ catch {
353
+ // Unknown topology: every port is protected (linkPorts undefined), listing still works.
354
+ }
355
+ const carrying = await this.carrying(snap.networks, snap.siteId);
356
+ const switches = [];
357
+ for (const d of devices) {
358
+ const mac = String(d.mac);
359
+ const detail = await this.client.networkConfig.getSwitch(mac, snap.siteId);
360
+ const ports = detail.portList ?? [];
361
+ let links = topology ? linkPorts(topology, mac, adopted.map((a) => String(a.mac))) : undefined;
362
+ // A link on a port the switch doesn't have: the topology is stale, so the real port is unknown.
363
+ if (links && [...links.keys()].some((n) => !ports.some((p) => Number(p.port) === n)))
364
+ links = undefined;
365
+ const affected = affectedOf(carrying, mac);
366
+ switches.push({ mac, name: String(d.name ?? mac), ports, ...(links ? { links } : {}), ...(affected ? { affected } : {}) });
367
+ }
368
+ return { switches };
369
+ }
370
+ /** For each network, the devices whose ports carry it. */
371
+ async carrying(networks, siteId) {
372
+ const out = [];
373
+ for (const n of networks)
374
+ out.push({ id: n.id, devices: await this.client.networkConfig.listNetworkPorts(n.id, n.vlan, siteId) });
375
+ return out;
376
+ }
377
+ /** A switch by name, or by MAC (as { mac } or as its id). */
378
+ resolveSwitch(world, device) {
379
+ if ('name' in device)
380
+ return resolve(world.switches.map((s) => ({ ...s, id: s.mac })), device, 'switch');
381
+ const wanted = 'mac' in device ? device.mac : device.id;
382
+ const found = normalizeMac(wanted) === '' ? undefined : world.switches.find((s) => normalizeMac(s.mac) === normalizeMac(wanted));
383
+ if (!found)
384
+ throw new NetError('NOT_FOUND', `No switch with MAC '${wanted}'. Switches: ${world.switches.map((s) => `${s.name} (${s.mac})`).join(', ') || '(none)'}`);
385
+ return found;
386
+ }
387
+ /**
388
+ * Why a port can't be reassigned, or undefined. Never: an uplink, the port the controller
389
+ * or this server is on (allowLockout doesn't change that). When where they are can't be
390
+ * told (address unknown or not configured, or inside the site but not seen on it), ports
391
+ * with online devices are left alone; empty ports can still be assigned.
392
+ */
393
+ portProtection(sw, raw, seen, clients, networks) {
394
+ if (!sw.links)
395
+ return `the controller's topology doesn't show which port links switch '${sw.name}' to the network`;
396
+ const link = sw.links.get(Number(raw.port));
397
+ if (link)
398
+ return link;
399
+ if (raw.lagPort === true)
400
+ return 'it is part of a link aggregation (LAG); change it in the controller web UI';
401
+ const hosts = [
402
+ { who: 'the controller', addresses: this.controllerAddresses },
403
+ { who: 'this server', addresses: this.serverAddresses },
404
+ ];
405
+ const on = (c, addresses) => c.ip !== undefined && addresses.includes(ipToNumber(c.ip));
406
+ for (const { who, addresses } of hosts) {
407
+ if (Array.isArray(addresses) && seen.some((c) => on(c, addresses)))
408
+ return `${who} is on it`;
409
+ }
410
+ if (seen.length === 0)
411
+ return undefined;
412
+ for (const { who, addresses } of hosts) {
413
+ if (addresses === 'unset')
414
+ return `${who}'s address isn't configured (MCP_SERVER_ADDRESSES), so its port can't be told; ports with devices on them are left alone`;
415
+ if (addresses === 'unknown')
416
+ return `${who}'s address can't be resolved, so its port can't be told; ports with devices on them are left alone`;
417
+ const inSite = (a) => networks.some((n) => n.gateway && inSubnet(a, n.gateway));
418
+ // Located only when seen on a switch port: on Wi-Fi or straight on the gateway, what it hangs off can't be told.
419
+ if (addresses.some((a) => inSite(a) && !clients.some((c) => c.port !== undefined && on(c, [a])))) {
420
+ return `${who} isn't seen on a switch port, so its port can't be told; ports with devices on them are left alone`;
421
+ }
422
+ }
423
+ return undefined;
424
+ }
425
+ toPort(sw, raw, snap) {
426
+ const port = Number(raw.port);
427
+ const seen = snap.clients.filter((c) => c.online && c.port && 'mac' in c.port.device && normalizeMac(c.port.device.mac) === normalizeMac(sw.mac) && c.port.port === port);
428
+ const protection = this.portProtection(sw, raw, seen, snap.clients, snap.networks);
429
+ const networks = sw.affected ? toPortNetworks(sw.affected, port) : undefined;
430
+ return {
431
+ device: { mac: formatMac(sw.mac) },
432
+ port,
433
+ ...(typeof raw.name === 'string' ? { name: raw.name } : {}),
434
+ ...(typeof raw.profileId === 'string' ? { profile: { id: raw.profileId } } : {}),
435
+ ...(networks ? { networks } : {}),
436
+ customSettings: raw.profileOverrideEnable === true,
437
+ seen,
438
+ uplink: sw.links?.has(port) === true,
439
+ ...(protection ? { protected: protection } : {}),
440
+ };
441
+ }
442
+ /**
443
+ * Gives a port its own networks (untagged + tagged); its other settings keep following its
444
+ * profile. What it carries before and after comes from each network's list of ports.
445
+ */
446
+ async buildPortAssign(target, wanted, snap) {
447
+ const world = await this.portWorld(snap);
448
+ const sw = this.resolveSwitch(world, target.device);
449
+ const raw = sw.ports.find((p) => Number(p.port) === target.port);
450
+ if (!raw)
451
+ throw new NetError('NOT_FOUND', `Switch '${sw.name}' has no port ${target.port} (ports ${sw.ports.map((p) => p.port).join(', ')})`);
452
+ const where = `port ${target.port} on '${sw.name}'`;
453
+ const port = this.toPort(sw, raw, snap);
454
+ if (port.protected)
455
+ throw new NetError('PROTECTED', `${where} can't be reassigned here: ${port.protected}`);
456
+ if (port.customSettings) {
457
+ throw new NetError('UNSUPPORTED', `${where} has its own settings (profile override); change its networks in the controller web UI`, { capability: 'ports.assign' });
458
+ }
459
+ if (wanted.untagged?.length)
460
+ throw new NetError('UNSUPPORTED', 'Further untagged networks besides the native one are not offered here', { capability: 'ports.assign' });
461
+ if (!wanted.native)
462
+ throw new NetError('INVALID', `${where} needs an untagged (native) network`);
463
+ const native = resolve(snap.networks, wanted.native, 'network');
464
+ const tagged = Array.isArray(wanted.tagged) ? [...new Set(wanted.tagged.map((r) => resolve(snap.networks, r, 'network')))] : wanted.tagged;
465
+ if (Array.isArray(tagged) && tagged.includes(native))
466
+ throw new NetError('INVALID', `'${native.name}' can't be both untagged and tagged on a port`);
467
+ const goal = { native: { id: native.id }, tagged: Array.isArray(tagged) ? (tagged.length ? tagged.map((n) => ({ id: n.id })) : 'none') : tagged };
468
+ // What it carries now, as each network lists it ("all tagged" is every network but the native one).
469
+ const flat = (c) => {
470
+ const nativeId = c.native && 'id' in c.native ? c.native.id : undefined;
471
+ const list = (refs) => refs.map((r) => ('id' in r ? r.id : r.name)).sort();
472
+ const taggedIds = c.tagged === 'all' ? snap.networks.map((n) => n.id).filter((id) => id !== nativeId) : c.tagged === 'none' ? [] : c.tagged.map((r) => ('id' in r ? r.id : r.name));
473
+ return JSON.stringify([nativeId, list(c.untagged ?? []), [...taggedIds].sort()]);
474
+ };
475
+ if (port.networks && flat(port.networks) === flat(goal))
476
+ throw new NetError('INVALID', `${where} already carries that (${describeNetworks(goal, snap.networks)})`);
477
+ const affected = cutOff(port.seen, port.networks, goal);
478
+ const body = {
479
+ // Both required by the controller; unchanged.
480
+ name: raw.name,
481
+ profileId: raw.profileId,
482
+ profileOverrideEnable: false,
483
+ profileVlanOverrideEnable: true,
484
+ nativeNetworkId: native.id,
485
+ networkTagsSetting: goal.tagged === 'all' ? TAGS.all : goal.tagged === 'none' ? TAGS.none : TAGS.custom,
486
+ tagNetworkIds: Array.isArray(goal.tagged) ? goal.tagged.map((r) => ('id' in r ? r.id : r.name)) : [],
487
+ untagNetworkIds: [],
488
+ };
489
+ const now = port.networks ? describeNetworks(port.networks, snap.networks) : 'unknown';
490
+ return {
491
+ steps: [{ action: 'update', object: 'omada.switchPort', name: `${sw.name} port ${target.port}`, detail: `${now} → ${describeNetworks(goal, snap.networks)}` }],
492
+ effects: [
493
+ `${where} carries ${describeNetworks(goal, snap.networks)}; its other settings keep following profile '${String(raw.profileName ?? raw.profileId ?? 'unknown')}'.`,
494
+ ...(affected.length ? [`Devices on it lose their network: ${affected.map((c) => c.name ?? c.mac).join(', ')}.`] : []),
495
+ ],
496
+ wouldLockOut: affected,
497
+ reads: { networks: true },
498
+ // Known here: a switch without known links has every port protected (refused above).
499
+ // In a fixed order: the same state listed differently by the controller is no change.
500
+ extra: {
501
+ switch: sw.mac,
502
+ ports: sw.ports.map((p) => pick(p, PORT_FIELDS)).sort((a, b) => Number(a.port) - Number(b.port)),
503
+ links: [...sw.links].sort((a, b) => a[0] - b[0]),
504
+ // In the order of the networks, which the fingerprint already covers as listed.
505
+ carried: sw.affected ? [...sw.affected].map(([id, a]) => [id, canonical(a)]) : null,
506
+ },
507
+ execute: async (s) => {
508
+ await this.client.networkConfig.updateSwitchPort(sw.mac, target.port, body, s.siteId);
509
+ let after;
510
+ try {
511
+ after = affectedOf(await this.carrying(s.networks, s.siteId), sw.mac);
512
+ }
513
+ catch {
514
+ return { applied: true, owned: [], warnings: ['The port is changed, but reading it back failed; check it with net_listPorts.'] };
515
+ }
516
+ const carried = after ? toPortNetworks(after, target.port) : undefined;
517
+ if (!carried || flat(carried) !== flat(goal)) {
518
+ const shown = carried ? describeNetworks(carried, s.networks) : 'something that can\'t be told';
519
+ throw new NetError('CONFLICT', `The controller accepted the change, but ${where} carries ${shown}; check it with net_listPorts.`);
520
+ }
521
+ return { applied: true, object: this.toPort({ ...sw, affected: after }, raw, s), owned: [] };
522
+ },
523
+ };
524
+ }
525
+ async buildMdnsSet(enabled, refs, snap) {
526
+ const rulesRaw = await this.client.networkConfig.listMdnsRules(snap.siteId);
527
+ const { repeater, rule, networkIds: currentIds } = toMdns(rulesRaw);
528
+ if (repeater.foreign)
529
+ throw new NetError('UNSUPPORTED', repeater.foreign.reason, { capability: 'mdnsRepeater' });
530
+ if (!rule) {
531
+ throw new NetError('UNSUPPORTED', 'There is no mDNS rule yet: create one in the controller web UI (Settings > Services > mDNS, type Gateway, with the services to repeat); after that it can be changed here.', {
532
+ capability: 'mdnsRepeater',
533
+ });
534
+ }
535
+ // The networks given, or the ones the rule keeps: each must exist and be routed.
536
+ const chosen = refs
537
+ ? [...new Set(refs.map((r) => resolve(snap.networks, r, 'network')))]
538
+ : currentIds.map((id) => {
539
+ const found = snap.networks.find((n) => n.id === id);
540
+ if (!found)
541
+ throw new NetError('INVALID', `The mDNS rule repeats over a network that no longer exists (${id}); give the networks to repeat between`);
542
+ return found;
543
+ });
544
+ const ids = chosen.map((n) => {
545
+ if (n.purpose !== 'normal')
546
+ throw new NetError('INVALID', `'${n.name}' is switch-only; the gateway repeats mDNS only between routed networks`);
547
+ return n.id;
548
+ });
549
+ if (enabled && ids.length < 2)
550
+ throw new NetError('INVALID', 'The mDNS repeater needs at least two networks to repeat between');
551
+ const same = ids.length === currentIds.length && ids.every((id) => currentIds.includes(id));
552
+ if (same && enabled === repeater.enabled)
553
+ throw new NetError('INVALID', 'Nothing to change on the mDNS repeater');
554
+ const name = (id) => snap.networks.find((n) => n.id === id)?.name ?? id;
555
+ const changes = [
556
+ ...(enabled !== repeater.enabled ? [enabled ? 'switched on' : 'switched off'] : []),
557
+ ...(same ? [] : [`networks ${currentIds.map(name).join(', ') || '(none)'} → ${ids.map(name).join(', ')}`]),
558
+ ];
559
+ const body = {
560
+ name: rule.name,
561
+ status: enabled,
562
+ // The services the rule repeats are kept as they are (the controller requires them).
563
+ profileIds: rule.profileIds,
564
+ type: MDNS_GATEWAY,
565
+ osg: { serviceNetworks: ids, clientNetworks: ids },
566
+ };
567
+ return {
568
+ steps: [{ action: 'update', object: 'omada.mdnsRule', name: String(rule.name), detail: changes.join('; ') }],
569
+ effects: [
570
+ enabled
571
+ ? `mDNS (Bonjour) announcements are repeated between ${ids.map(name).join(', ')}: devices there discover each other's services (AirPlay, Chromecast, HomeKit, printers). Discovery only: traffic still follows the rules. The services the rule repeats stay as set in the controller.`
572
+ : 'mDNS announcements are no longer repeated between networks; devices only discover services on their own network.',
573
+ ],
574
+ wouldLockOut: [],
575
+ reads: { networks: true },
576
+ extra: { mdns: rulesRaw.map(canonical) },
577
+ execute: async (s) => {
578
+ await this.client.networkConfig.updateMdnsRule(String(rule.id), body, s.siteId);
579
+ let after;
580
+ try {
581
+ after = toMdns(await this.client.networkConfig.listMdnsRules(s.siteId));
582
+ }
583
+ catch {
584
+ return { applied: true, owned: [], warnings: ['The mDNS repeater is changed, but reading it back failed; check it with net_getMdnsRepeater.'] };
585
+ }
586
+ const stored = after.networkIds;
587
+ const services = (r) => [...new Set((r?.profileIds ?? []).map(String))].sort().join(',');
588
+ const sameRule = after.rule?.id === rule.id && after.rule?.name === rule.name && services(after.rule) === services(rule);
589
+ if (after.repeater.foreign || !sameRule || after.repeater.enabled !== enabled || stored.length !== ids.length || !ids.every((id) => stored.includes(id))) {
590
+ throw new NetError('CONFLICT', 'The controller accepted the mDNS change but shows something else now; check it with net_getMdnsRepeater.');
591
+ }
592
+ return { applied: true, object: after.repeater, owned: [] };
593
+ },
594
+ };
253
595
  }
254
596
  // ---- plan / apply ------------------------------------------------------------------
255
597
  async plan(change) {
@@ -277,13 +619,16 @@ export class OmadaNetworkController {
277
619
  }
278
620
  async planWith(change, snap) {
279
621
  const built = await this.build(change, snap);
622
+ const redacted = redactSecrets(change);
280
623
  const plan = {
281
- change: redactSecrets(change),
624
+ change: redacted,
282
625
  vendor: 'omada',
283
626
  steps: built.steps,
284
627
  effects: built.effects,
285
628
  wouldLockOut: built.wouldLockOut,
286
- basis: fingerprint(snap, built.reads),
629
+ // What was agreed to: this change (a basis can't vouch for another one) and the devices it
630
+ // cuts off (a different set at apply time is a CONFLICT, allowLockout or not).
631
+ basis: fingerprint(snap, built.reads, { change: redacted, extra: built.extra, lockOut: built.wouldLockOut.map((c) => normalizeMac(c.mac)).sort() }),
287
632
  };
288
633
  this.executors.set(plan, built.execute);
289
634
  return plan;
@@ -306,18 +651,424 @@ export class OmadaNetworkController {
306
651
  if (change.op === 'delete')
307
652
  return this.buildWifiDelete(change.ref, snap);
308
653
  break;
654
+ case 'port':
655
+ return await this.buildPortAssign(change.port, change.networks, snap);
656
+ case 'portProfile':
657
+ throw unsupported('creating or deleting port profiles here (do it in the controller web UI)', 'ports.profiles');
658
+ case 'mdnsRepeater':
659
+ return await this.buildMdnsSet(change.enabled, change.networks, snap);
660
+ case 'rule':
661
+ if (change.op === 'create')
662
+ return this.buildRuleCreate(change.value, snap);
663
+ if (change.op === 'update')
664
+ return this.buildRuleUpdate(change.value.ref, change.value.set, snap);
665
+ if (change.op === 'delete')
666
+ return this.buildRuleDelete(change.ref, snap);
667
+ if (change.op === 'move')
668
+ return this.buildRuleMove(change.ref, change.position, snap);
669
+ break;
309
670
  default:
310
- throw notYet(`${change.kind} changes`, change.kind === 'rule' || change.kind === 'group' ? '2.4.7' : change.kind === 'reservation' ? 'a later release (use omada_setDhcpReservation)' : '2.4.8');
671
+ throw notYet(`${change.kind} changes`, change.kind === 'group' ? 'a later release (use omada_createGroup / omada_deleteGroup)' : 'a later release (use omada_setDhcpReservation)');
311
672
  }
312
673
  throw new NetError('INVALID', 'This change is not supported');
313
674
  }
675
+ // ---- rules (gateway ACLs; mapping and checks in omadaRules.ts) ---------------------------
676
+ ruleWorld(snap) {
677
+ return {
678
+ networks: snap.networks,
679
+ acls: snap.acls,
680
+ ipGroups: snap.ipGroups,
681
+ ipPortGroups: snap.ipPortGroups,
682
+ ssids: snap.ssids.map((s) => ({ id: s.ssidId, name: String(s.detail.name) })),
683
+ // This server's addresses too: a rule must not cut it off the internet either.
684
+ controllerAddresses: this.hostAddresses,
685
+ };
686
+ }
687
+ /** The rule as the model sees it, with its raw ACL; a rule the model can't express is refused. */
688
+ editableRule(ref, snap, what) {
689
+ const world = this.ruleWorld(snap);
690
+ const rules = snap.acls.map((raw, i) => ({ raw, rule: toRule(raw, i + 1, world) }));
691
+ const found = resolve(rules.map((r) => ({ ...r, id: r.rule.id, name: r.rule.name })), ref, 'rule');
692
+ if (found.rule.foreign)
693
+ 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' });
694
+ return { raw: found.raw, rule: found.rule };
695
+ }
696
+ checkPosition(position, count) {
697
+ if (!Number.isInteger(position) || position < 1 || position > count)
698
+ throw new NetError('INVALID', `Position must be 1 to ${count}`);
699
+ return position;
700
+ }
701
+ /**
702
+ * Refuses a change if any rule list it passes through (the end state, and the staged
703
+ * one where the rule is switched off) could expose some of the controller's (and this
704
+ * server's) internet traffic to a block that doesn't reach it today (controllerRisk).
705
+ * Waived only when the controller is certainly cut off already.
706
+ */
707
+ guardController(snap, states) {
708
+ const today = controllerPath(snap.acls, this.ruleWorld(snap));
709
+ for (const state of states) {
710
+ const risk = controllerRisk(today, controllerPath(state.acls, state.world));
711
+ if (risk)
712
+ throw new NetError('PROTECTED', `This could cut the controller or this server off the internet: ${risk}. Nothing was changed.`);
713
+ }
714
+ }
715
+ buildRuleCreate(value, snap) {
716
+ const world = this.ruleWorld(snap);
717
+ const draft = draftRule(value, world);
718
+ const position = value.position === undefined ? undefined : this.checkPosition(value.position, snap.acls.length + 1);
719
+ const moves = position !== undefined && position <= snap.acls.length;
720
+ const steps = [
721
+ ...groupSteps(draft),
722
+ { action: 'create', object: 'omada.gatewayAcl', name: draft.name, detail: draft.direction === 'lanToLan' ? 'LAN to LAN' : 'LAN to internet' },
723
+ ...(moves ? [{ action: 'update', object: 'omada.gatewayAclOrder', name: draft.name, detail: `position ${position}` }] : []),
724
+ ...(moves && draft.enabled ? [{ action: 'update', object: 'omada.gatewayAcl', name: draft.name, detail: 'switched on once in place' }] : []),
725
+ ];
726
+ const at = position ?? snap.acls.length + 1;
727
+ const placed = draftAsAcl(draft, 'new-rule', world);
728
+ this.guardController(snap, [{ acls: placeAt(snap.acls, placed.acl, at), world: placed.world }]);
729
+ return {
730
+ steps,
731
+ effects: [...draft.effects, `It is rule ${at} of ${snap.acls.length + 1}; rules are checked in order and the first match decides.`],
732
+ wouldLockOut: [],
733
+ reads: { networks: true, ssids: true, rules: true },
734
+ execute: async (s) => {
735
+ // Every new rule carries a fresh tag: its description is unique, so the rule is found
736
+ // again (and undone) by it, never mistaken for one made elsewhere at the same moment.
737
+ const key = newRuleKey();
738
+ const description = tagged(draft.name, key);
739
+ const made = [];
740
+ // 'none': nothing sent; 'sent': the create was sent and may have happened.
741
+ let acl = 'none';
742
+ let aclId;
743
+ try {
744
+ const source = await this.materialize(draft.source, key, 'from', s.siteId, made);
745
+ const destination = await this.materialize(draft.destination, key, 'to', s.siteId, made);
746
+ // A rule that still has to move is created switched off, so it never acts from the wrong place.
747
+ const body = aclBody(draft, description, source, destination, false);
748
+ acl = 'sent';
749
+ await this.client.networkConfig.createGatewayAcl({ ...body, status: moves ? false : draft.enabled }, s.siteId);
750
+ const acls = await this.sortedAcls(s.siteId);
751
+ aclId = acls.find((a) => a.description === description)?.id;
752
+ if (!aclId)
753
+ throw new NetError('CONFLICT', `Created rule '${draft.name}' but could not find it again`);
754
+ if (moves) {
755
+ await this.reorder(acls, aclId, position, s.siteId);
756
+ if (draft.enabled)
757
+ await this.client.networkConfig.updateGatewayAcl(aclId, body, s.siteId);
758
+ }
759
+ }
760
+ catch (error) {
761
+ throw await this.undoCreate(error, acl, description, made, s.siteId);
762
+ }
763
+ return await this.done(aclId, await this.sweepOwnedGroups(s.siteId), [
764
+ ...made.map((g) => ({ object: g.type === GROUP_TYPE.ip ? 'omada.ipGroup' : 'omada.ipPortGroup', id: g.id ?? g.name })),
765
+ { object: 'omada.gatewayAcl', id: aclId },
766
+ ]);
767
+ },
768
+ };
769
+ }
770
+ buildRuleUpdate(ref, set, snap) {
771
+ const current = this.editableRule(ref, snap, 'changed');
772
+ const world = this.ruleWorld(snap);
773
+ const { position, ...fields } = set;
774
+ const next = { ...current.rule, ...fields };
775
+ const draft = draftRule(next, world, current);
776
+ const target = position === undefined ? undefined : this.checkPosition(position, snap.acls.length);
777
+ const moves = target !== undefined && target !== current.rule.position;
778
+ const changes = describeRuleChanges(current.rule, next);
779
+ if (changes.length === 0 && !moves)
780
+ throw new NetError('INVALID', `Nothing to change on rule '${current.rule.name}'`);
781
+ const oldKey = ruleKey(String(current.raw.description));
782
+ // Changed and moved: written switched off, moved, then switched on, so the new content never acts from the old place.
783
+ const staged = moves && changes.length > 0 && draft.enabled;
784
+ {
785
+ const id = String(current.raw.id);
786
+ const others = snap.acls.filter((a) => String(a.id) !== id);
787
+ const after = draftAsAcl(draft, id, world);
788
+ const states = [{ acls: placeAt(others, after.acl, target ?? current.rule.position), world: after.world }];
789
+ // Switched off on the way (staging, and the first step of undoing it): as if the rule were gone.
790
+ if (staged)
791
+ states.push({ acls: others, world });
792
+ this.guardController(snap, states);
793
+ }
794
+ const steps = [
795
+ ...groupSteps(draft),
796
+ ...(changes.length ? [{ action: 'update', object: 'omada.gatewayAcl', name: current.rule.name, detail: `${changes.join('; ')}${staged ? ' (switched off until in place)' : ''}` }] : []),
797
+ ...(moves ? [{ action: 'update', object: 'omada.gatewayAclOrder', name: current.rule.name, detail: `position ${target}` }] : []),
798
+ ...(staged ? [{ action: 'update', object: 'omada.gatewayAcl', name: current.rule.name, detail: 'switched on in place' }] : []),
799
+ ];
800
+ return {
801
+ steps,
802
+ effects: [
803
+ ...(changes.length ? [`'${current.rule.name}': ${changes.join('; ')}.`, ...draft.effects] : []),
804
+ ...(moves ? [`It moves from position ${current.rule.position} to ${target} of ${snap.acls.length}.`] : []),
805
+ ],
806
+ wouldLockOut: [],
807
+ reads: { networks: true, ssids: true, rules: true },
808
+ execute: async (s) => {
809
+ const aclId = String(current.raw.id);
810
+ const original = writableAcl(current.raw);
811
+ const originalOrder = orderOf(s.acls);
812
+ const made = [];
813
+ // 'sent' = the write was sent and may have happened, even if it reported an error.
814
+ let put = 'none';
815
+ let reorder = 'none';
816
+ try {
817
+ if (changes.length) {
818
+ const key = oldKey ?? (needsGroups(draft) ? newRuleKey() : undefined);
819
+ const source = await this.materialize(draft.source, key, 'from', s.siteId, made);
820
+ const destination = await this.materialize(draft.destination, key, 'to', s.siteId, made);
821
+ const body = aclBody(draft, tagged(draft.name, key), source, destination, current.raw.syslog === true);
822
+ put = 'sent';
823
+ await this.client.networkConfig.updateGatewayAcl(aclId, staged ? { ...body, status: false } : body, s.siteId);
824
+ if (moves) {
825
+ reorder = 'sent';
826
+ await this.reorder(await this.sortedAcls(s.siteId), aclId, target, s.siteId);
827
+ }
828
+ if (staged)
829
+ await this.client.networkConfig.updateGatewayAcl(aclId, body, s.siteId);
830
+ }
831
+ else if (moves) {
832
+ reorder = 'sent';
833
+ await this.reorder(await this.sortedAcls(s.siteId), aclId, target, s.siteId);
834
+ }
835
+ }
836
+ catch (error) {
837
+ throw await this.undoUpdate(error, { aclId, original, originalOrder, put, reorder, made }, s.siteId);
838
+ }
839
+ // Only now, with everything in place: groups no rule uses any more.
840
+ return await this.done(aclId, await this.sweepOwnedGroups(s.siteId));
841
+ },
842
+ };
843
+ }
844
+ buildRuleDelete(ref, snap) {
845
+ const current = this.editableRule(ref, snap, 'deleted');
846
+ const ids = referencedIds(current.raw);
847
+ const otherUses = new Set([...snap.acls.filter((a) => a.id !== current.raw.id), ...snap.otherAcls].flatMap(referencedIds));
848
+ const released = [...snap.ipGroups, ...snap.ipPortGroups].filter((g) => isOwnedGroup(g) && ids.includes(String(g.groupId)) && !otherUses.has(String(g.groupId)));
849
+ this.guardController(snap, [{ acls: snap.acls.filter((a) => a.id !== current.raw.id), world: this.ruleWorld(snap) }]);
850
+ return {
851
+ steps: [
852
+ { action: 'delete', object: 'omada.gatewayAcl', name: current.rule.name },
853
+ ...released.map((g) => ({ action: 'delete', object: g.type === GROUP_TYPE.ipPort ? 'omada.ipPortGroup' : 'omada.ipGroup', name: String(g.name) })),
854
+ ],
855
+ effects: [`Rule '${current.rule.name}' is removed${released.length ? ', with the address group it owns' : ''}.`],
856
+ wouldLockOut: [],
857
+ reads: { networks: true, ssids: true, rules: true },
858
+ execute: async (s) => {
859
+ const warnings = [];
860
+ try {
861
+ await this.client.networkConfig.deleteGatewayAcl(String(current.raw.id), s.siteId);
862
+ }
863
+ catch (error) {
864
+ // The delete may have happened even though it reported an error: look.
865
+ let gone;
866
+ try {
867
+ gone = !(await this.sortedAcls(s.siteId)).some((a) => String(a.id) === String(current.raw.id));
868
+ }
869
+ catch {
870
+ throw withNote(error, `whether rule '${current.rule.name}' was deleted is unknown; check net_listRules`);
871
+ }
872
+ if (!gone)
873
+ throw error;
874
+ warnings.push("The controller's answer was lost, but the rule is gone.");
875
+ }
876
+ return await this.done(undefined, [...warnings, ...(await this.sweepOwnedGroups(s.siteId))]);
877
+ },
878
+ };
879
+ }
880
+ buildRuleMove(ref, position, snap) {
881
+ const world = this.ruleWorld(snap);
882
+ const rules = snap.acls.map((raw, i) => ({ raw, rule: toRule(raw, i + 1, world) }));
883
+ // Moving touches only the order, so a rule the model can't otherwise express may move too.
884
+ const found = resolve(rules.map((r) => ({ ...r, id: r.rule.id, name: r.rule.name })), ref, 'rule');
885
+ const target = this.checkPosition(position, snap.acls.length);
886
+ if (target === found.rule.position)
887
+ throw new NetError('INVALID', `Rule '${found.rule.name}' is already at position ${target}`);
888
+ this.guardController(snap, [{ acls: placeAt(snap.acls.filter((a) => a.id !== found.raw.id), found.raw, target), world }]);
889
+ return {
890
+ steps: [{ action: 'update', object: 'omada.gatewayAclOrder', name: found.rule.name, detail: `position ${target}` }],
891
+ 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.`],
892
+ wouldLockOut: [],
893
+ reads: { networks: true, ssids: true, rules: true },
894
+ execute: async (s) => {
895
+ const originalOrder = orderOf(s.acls);
896
+ try {
897
+ await this.reorder(await this.sortedAcls(s.siteId), String(found.raw.id), target, s.siteId);
898
+ }
899
+ catch (error) {
900
+ // The reorder may have happened even though it reported an error: put the order back.
901
+ throw await this.restoreOrder(error, originalOrder, s.siteId);
902
+ }
903
+ return await this.done(String(found.raw.id), await this.sweepOwnedGroups(s.siteId));
904
+ },
905
+ };
906
+ }
907
+ /**
908
+ * Existing ids for an endpoint, or the id of the group created for it. The group is
909
+ * tracked by its (unique) name before it is created, so a create whose answer or
910
+ * read-back got lost can still be found and undone.
911
+ */
912
+ async materialize(endpoint, key, side, siteId, made) {
913
+ if ('ids' in endpoint)
914
+ return { type: endpoint.type, ids: endpoint.ids };
915
+ if (!key)
916
+ throw new NetError('INVALID', 'Internal: a rule that owns a group needs a tag');
917
+ const { groupType, body } = endpoint.newGroup;
918
+ // A name no group has yet: finding it by name afterwards (or in an undo) can only find this one.
919
+ const existing = await this.client.networkConfig.listGroups(groupType, siteId);
920
+ const tracked = { type: groupType, name: ownedGroupName(key, side, new Set(existing.map((g) => String(g.name)))) };
921
+ made.push(tracked);
922
+ await this.client.networkConfig.createGroup({ name: tracked.name, ...body }, siteId);
923
+ const group = (await this.client.networkConfig.listGroups(groupType, siteId)).find((g) => g.name === tracked.name);
924
+ if (!group)
925
+ throw new NetError('CONFLICT', `Created group '${tracked.name}' but could not find it again`);
926
+ tracked.id = String(group.groupId);
927
+ return { type: endpoint.type, ids: [tracked.id] };
928
+ }
929
+ /**
930
+ * Undoes a create that failed part-way and returns the error to throw. A create that
931
+ * was sent may have happened even if it reported an error: the rule is looked up by
932
+ * its description and removed before its groups. When that can't be confirmed, the
933
+ * groups stay (a live rule must never point at deleted groups) and the error says so.
934
+ */
935
+ async undoCreate(error, acl, description, made, siteId) {
936
+ const notes = [];
937
+ if (acl === 'sent') {
938
+ try {
939
+ const found = (await this.sortedAcls(siteId)).find((a) => a.description === description);
940
+ if (found)
941
+ await this.client.networkConfig.deleteGatewayAcl(String(found.id), siteId);
942
+ }
943
+ catch {
944
+ return withNote(error, `the rule may have been created; check net_listRules for '${description}' (its groups were kept)`);
945
+ }
946
+ }
947
+ notes.push(...(await this.dropGroups(made, siteId)));
948
+ return notes.length ? withNote(error, notes.join('; ')) : error;
949
+ }
950
+ /**
951
+ * Undoes an update that failed part-way and returns the error to throw: puts the rule
952
+ * back as it was (if the change may have been written) and removes the groups made
953
+ * for it, unless the rule might still point at them.
954
+ */
955
+ async undoUpdate(error, u, siteId) {
956
+ if (u.reorder === 'sent') {
957
+ try {
958
+ // Content changed too: switch it off first, so neither version acts from the wrong place while the order goes back.
959
+ if (u.put === 'sent')
960
+ await this.client.networkConfig.updateGatewayAcl(u.aclId, { ...u.original, status: false }, siteId);
961
+ await this.client.networkConfig.reorderGatewayAcls(u.originalOrder, siteId);
962
+ }
963
+ catch {
964
+ return withNote(error, 'the rule order could not be put back; check it with net_listRules (the groups made for the change were kept)');
965
+ }
966
+ }
967
+ if (u.put === 'sent') {
968
+ try {
969
+ await this.client.networkConfig.updateGatewayAcl(u.aclId, u.original, siteId);
970
+ }
971
+ catch {
972
+ 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)');
973
+ }
974
+ }
975
+ const notes = await this.dropGroups(u.made, siteId);
976
+ return notes.length ? withNote(error, notes.join('; ')) : error;
977
+ }
978
+ /** Puts the rule order back after a move that failed (it may have happened anyway); returns the error to throw. */
979
+ async restoreOrder(error, originalOrder, siteId) {
980
+ try {
981
+ await this.client.networkConfig.reorderGatewayAcls(originalOrder, siteId);
982
+ return error;
983
+ }
984
+ catch {
985
+ return withNote(error, 'the rule order could not be put back; check it with net_listRules');
986
+ }
987
+ }
988
+ /** Deletes groups made during a failed change (found by name when the id is unknown); returns what it couldn't remove. */
989
+ async dropGroups(made, siteId) {
990
+ const left = [];
991
+ for (const g of made) {
992
+ try {
993
+ const id = g.id ?? (await this.client.networkConfig.listGroups(g.type, siteId)).find((x) => x.name === g.name)?.groupId;
994
+ if (id)
995
+ await this.client.networkConfig.deleteGroup(g.type, String(id), siteId);
996
+ }
997
+ catch {
998
+ left.push(`group '${g.name}' may be left behind`);
999
+ }
1000
+ }
1001
+ return left;
1002
+ }
1003
+ /**
1004
+ * Deletes every group a net_ rule owned that no ACL (gateway, switch or access point)
1005
+ * references any more. Run after each successful rule change, so a cleanup that failed
1006
+ * once is finished by the next change; a failure here is a warning, not an error (the
1007
+ * change itself is done). Writes are serialized per site, so no rule is half-created
1008
+ * meanwhile.
1009
+ */
1010
+ async sweepOwnedGroups(siteId) {
1011
+ try {
1012
+ const acls = [
1013
+ ...(await this.client.networkConfig.listAcls('osg-acls', siteId)),
1014
+ ...(await this.client.networkConfig.listAcls('osw-acls', siteId)),
1015
+ ...(await this.client.networkConfig.listAcls('eap-acls', siteId)),
1016
+ ];
1017
+ const used = new Set(acls.flatMap(referencedIds));
1018
+ const left = [];
1019
+ for (const type of [GROUP_TYPE.ip, GROUP_TYPE.ipPort]) {
1020
+ for (const g of await this.client.networkConfig.listGroups(type, siteId)) {
1021
+ const id = String(g.groupId);
1022
+ if (!isOwnedGroup(g) || used.has(id))
1023
+ continue;
1024
+ await this.client.networkConfig.deleteGroup(type, id, siteId).catch(() => left.push(String(g.name)));
1025
+ }
1026
+ }
1027
+ return left.length ? [`Could not remove the unused group(s) ${left.join(', ')} now; the next rule change removes them.`] : [];
1028
+ }
1029
+ catch {
1030
+ return ['Could not check for unused rule groups now; the next rule change removes them.'];
1031
+ }
1032
+ }
1033
+ async sortedAcls(siteId) {
1034
+ return (await this.client.networkConfig.listAcls('osg-acls', siteId)).sort((a, b) => Number(a.index ?? 0) - Number(b.index ?? 0));
1035
+ }
1036
+ /** Moves one rule to `position` (1 = first); the others keep their order. */
1037
+ async reorder(acls, aclId, position, siteId) {
1038
+ const moving = acls.find((a) => String(a.id) === aclId);
1039
+ if (!moving)
1040
+ throw new NetError('NOT_FOUND', 'The rule disappeared before it could be moved');
1041
+ const order = acls.filter((a) => String(a.id) !== aclId);
1042
+ order.splice(Math.min(position, acls.length) - 1, 0, moving);
1043
+ await this.client.networkConfig.reorderGatewayAcls(Object.fromEntries(order.map((a, i) => [String(a.id), i + 1])), siteId);
1044
+ }
1045
+ /**
1046
+ * The result of a change that is done: the rule as it reads back, plus warnings. A
1047
+ * failed read-back is a warning, never an error: the change is already applied.
1048
+ */
1049
+ async done(aclId, warnings, owned = []) {
1050
+ const notes = [...warnings];
1051
+ let object;
1052
+ if (aclId) {
1053
+ try {
1054
+ const snap = await this.snapshot();
1055
+ const index = snap.acls.findIndex((a) => String(a.id) === aclId);
1056
+ if (index >= 0)
1057
+ object = toRule(snap.acls[index], index + 1, this.ruleWorld(snap));
1058
+ }
1059
+ catch {
1060
+ notes.push(`The change is applied, but reading the rule back failed; check it with net_listRules (rule id ${aclId}).`);
1061
+ }
1062
+ }
1063
+ return { applied: true, ...(object ? { object } : {}), owned, ...(notes.length ? { warnings: notes } : {}) };
1064
+ }
314
1065
  // ---- networks ------------------------------------------------------------------------
315
1066
  buildNetworkCreate(value, snap) {
316
1067
  const name = checkName(value.name, MAX_NETWORK_NAME, 'Network');
317
1068
  if (value.purpose === 'guest')
318
1069
  throw unsupported('a guest network type (Omada makes a Wi-Fi network a guest network instead: clientIsolation)', 'networks.guestPurpose');
319
1070
  if (value.isolated)
320
- throw unsupported('network isolation (it comes as rules in 2.4.7)', 'networks.isolation');
1071
+ throw unsupported('a network isolation flag (make a block rule from this network to the others with net_createRule)', 'networks.isolation');
321
1072
  if (value.ipv6)
322
1073
  throw unsupported('switching IPv6 on (do it in the controller web UI)', 'networks.ipv6');
323
1074
  const vlan = checkVlan(value.vlan);
@@ -376,12 +1127,12 @@ export class OmadaNetworkController {
376
1127
  throw new NetError('INVALID', `'${current.name}' is switch-only: it has no gateway and no DHCP to change`);
377
1128
  }
378
1129
  if (set.isolated)
379
- throw unsupported('network isolation (it comes as rules in 2.4.7)', 'networks.isolation');
1130
+ throw unsupported('a network isolation flag (make a block rule from this network to the others with net_createRule)', 'networks.isolation');
380
1131
  if (set.ipv6 === true && !current.ipv6)
381
1132
  throw unsupported('switching IPv6 on (do it in the controller web UI)', 'networks.ipv6');
382
1133
  const addressing = (set.vlan !== undefined && set.vlan !== current.vlan) || (set.gateway !== undefined && set.gateway !== current.gateway);
383
1134
  if (current.protected && addressing) {
384
- throw new NetError('PROTECTED', `'${current.name}' carries the controller or the primary network; its VLAN and gateway can't be changed here`);
1135
+ throw new NetError('PROTECTED', `'${current.name}' carries the controller, this server or the primary network; its VLAN and gateway can't be changed here`);
385
1136
  }
386
1137
  const name = set.name !== undefined ? checkName(set.name, MAX_NETWORK_NAME, 'Network') : current.name;
387
1138
  const vlan = set.vlan !== undefined ? checkVlan(set.vlan) : current.vlan;
@@ -465,7 +1216,7 @@ export class OmadaNetworkController {
465
1216
  async buildNetworkDelete(ref, snap) {
466
1217
  const current = resolve(snap.networks, ref, 'network');
467
1218
  if (current.protected)
468
- throw new NetError('PROTECTED', `'${current.name}' carries the controller or the primary network and can't be deleted`);
1219
+ throw new NetError('PROTECTED', `'${current.name}' carries the controller, this server or the primary network and can't be deleted`);
469
1220
  const users = await this.networkUsers(current, snap);
470
1221
  if (users.length)
471
1222
  throw new NetError('IN_USE', `'${current.name}' is used by ${users.join(', ')}; change those first`, { users });
@@ -481,7 +1232,7 @@ export class OmadaNetworkController {
481
1232
  },
482
1233
  };
483
1234
  }
484
- /** Everything that still references a network: SSIDs, port profiles, DHCP reservations, ACL rules. */
1235
+ /** Everything that still references a network: SSIDs, port profiles, switch ports carrying it untagged, DHCP reservations, ACL rules. */
485
1236
  async networkUsers(network, snap) {
486
1237
  const users = [];
487
1238
  for (const s of snap.ssids)
@@ -492,6 +1243,24 @@ export class OmadaNetworkController {
492
1243
  if (ids.includes(network.id))
493
1244
  users.push(`port profile '${String(p.name)}'`);
494
1245
  }
1246
+ // Devices on a port that carries it untagged would be left without a network; a switch whose
1247
+ // lists can't be read may have such a port (other devices, like the gateway, have no switch ports).
1248
+ for (const d of await this.client.networkConfig.listNetworkPorts(network.id, network.vlan, snap.siteId)) {
1249
+ if (d.type !== 'switch' && d.switchInfo === undefined)
1250
+ continue;
1251
+ const name = `switch '${String(d.name ?? d.mac)}'`;
1252
+ const a = affectedPortsOf(d);
1253
+ if (!a) {
1254
+ users.push(`${name} (which of its ports carry it can't be read)`);
1255
+ continue;
1256
+ }
1257
+ const nums = (...keys) => [...new Set(keys.flatMap((k) => a[k].map(Number)))].sort((x, y) => x - y);
1258
+ const ports = nums('nativePorts', 'untagPorts');
1259
+ const lags = nums('nativeLags', 'untagLags');
1260
+ const on = [...(ports.length ? [`ports ${ports.join(', ')}`] : []), ...(lags.length ? [`LAGs ${lags.join(', ')}`] : [])];
1261
+ if (on.length)
1262
+ users.push(`${name} ${on.join(' and ')} (untagged)`);
1263
+ }
495
1264
  for (const r of await this.client.accessControl.listDhcpReservations(snap.siteId))
496
1265
  if (r.netId === network.id)
497
1266
  users.push(`DHCP reservation ${formatMac(r.mac)}`);
@@ -715,32 +1484,82 @@ export class OmadaNetworkController {
715
1484
  };
716
1485
  }
717
1486
  }
718
- // ---- helpers -----------------------------------------------------------------------------
1487
+ /**
1488
+ * Every id an ACL's source or destination references. Endpoint type numbers differ between
1489
+ * gateway, switch and access point ACLs, so ids are not filtered by type: a network id
1490
+ * never equals a group id, and over-counting only keeps a group longer.
1491
+ */
1492
+ function referencedIds(acl) {
1493
+ return [...(acl.sourceIds ?? []), ...(acl.destinationIds ?? [])].map(String);
1494
+ }
1495
+ /** A rule list with `acl` at `position` (1 = first). */
1496
+ function placeAt(acls, acl, position) {
1497
+ const list = [...acls];
1498
+ list.splice(Math.min(Math.max(position, 1), list.length + 1) - 1, 0, acl);
1499
+ return list;
1500
+ }
1501
+ /** The current rule order as reorderGatewayAcls takes it. */
1502
+ function orderOf(acls) {
1503
+ return Object.fromEntries(acls.map((a, i) => [String(a.id), i + 1]));
1504
+ }
1505
+ /** What a PUT must resend to put an ACL back exactly as it was. */
1506
+ function writableAcl(raw) {
1507
+ const fields = ['description', 'status', 'policy', 'protocols', 'sourceType', 'sourceIds', 'destinationType', 'destinationIds', 'syslog', 'direction', 'stateMode', 'states', 'timeRangeId'];
1508
+ return Object.fromEntries(fields.filter((f) => raw[f] !== undefined && raw[f] !== null).map((f) => [f, raw[f]]));
1509
+ }
1510
+ /** The original error, with what the undo couldn't do appended. */
1511
+ function withNote(error, note) {
1512
+ if (error instanceof NetError)
1513
+ return new NetError(error.code, `${error.message} (${note})`, error.details);
1514
+ return new Error(`${error instanceof Error ? error.message : String(error)} (${note})`);
1515
+ }
1516
+ function needsGroups(draft) {
1517
+ return 'newGroup' in draft.source || 'newGroup' in draft.destination;
1518
+ }
1519
+ function groupSteps(draft) {
1520
+ return [draft.source, draft.destination]
1521
+ .filter((e) => 'newGroup' in e)
1522
+ .map((e) => ({ action: 'create', object: e.newGroup.groupType === GROUP_TYPE.ipPort ? 'omada.ipPortGroup' : 'omada.ipGroup', name: e.label }));
1523
+ }
1524
+ function aclBody(draft, description, source, destination, syslog) {
1525
+ return {
1526
+ description,
1527
+ status: draft.enabled,
1528
+ policy: draft.policy,
1529
+ protocols: draft.protocols,
1530
+ sourceType: source.type,
1531
+ sourceIds: source.ids,
1532
+ destinationType: destination.type,
1533
+ destinationIds: destination.ids,
1534
+ syslog,
1535
+ direction: { lanToWan: draft.direction === 'lanToWan', lanToLan: draft.direction === 'lanToLan', wanInIds: [], vpnInIds: [] },
1536
+ // 0 = "Auto": replies to connections started on the other side pass (see omadaRules.ts).
1537
+ stateMode: 0,
1538
+ };
1539
+ }
1540
+ function describeRuleChanges(current, next) {
1541
+ const out = [];
1542
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
1543
+ if (next.name !== current.name)
1544
+ out.push(`name → ${next.name}`);
1545
+ if (next.action !== current.action)
1546
+ out.push(`action → ${next.action}`);
1547
+ if (!same(next.from, current.from))
1548
+ out.push('source changed');
1549
+ if (!same(next.to, current.to))
1550
+ out.push('destination changed');
1551
+ if (!same(next.protocol, current.protocol))
1552
+ out.push(`protocol → ${typeof next.protocol === 'object' ? `ports ${next.protocol.tcpUdpPorts}` : next.protocol}`);
1553
+ if (next.enabled !== current.enabled)
1554
+ out.push(next.enabled ? 'switched on' : 'switched off');
1555
+ return out;
1556
+ }
719
1557
  function notYet(what, release) {
720
1558
  return new NetError('UNSUPPORTED', `Changing ${what} through the net_ tools arrives in ${release}`, { capability: what });
721
1559
  }
722
1560
  function unsupported(what, capability) {
723
1561
  return new NetError('UNSUPPORTED', `Omada can't do ${what} through its API`, { capability });
724
1562
  }
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
1563
  /** The network a Wi-Fi network may put its devices on: one with a gateway (and so an address for them). */
745
1564
  function wifiNetwork(networks, ref) {
746
1565
  const network = resolve(networks, ref, 'network');
@@ -846,6 +1665,26 @@ function describeNetworkChanges(current, next) {
846
1665
  out.push('IPv6 → off');
847
1666
  return out;
848
1667
  }
1668
+ /**
1669
+ * A controller record with its keys and every list sorted: the lists in port profiles and mDNS
1670
+ * rules are sets (ids of networks, services...), so the same state listed in another order
1671
+ * fingerprints the same.
1672
+ */
1673
+ function canonical(value) {
1674
+ if (Array.isArray(value)) {
1675
+ return value
1676
+ .map(canonical)
1677
+ .map((item) => [JSON.stringify(item), item])
1678
+ .sort(([a], [b]) => a.localeCompare(b))
1679
+ .map(([, item]) => item);
1680
+ }
1681
+ if (value && typeof value === 'object') {
1682
+ return Object.fromEntries(Object.entries(value)
1683
+ .sort(([a], [b]) => a.localeCompare(b))
1684
+ .map(([k, v]) => [k, canonical(v)]));
1685
+ }
1686
+ return value;
1687
+ }
849
1688
  function describeClients(clients) {
850
1689
  return clients.map((c) => `${c.name ?? '?'} (${c.mac})`).join(', ');
851
1690
  }
@@ -853,12 +1692,14 @@ function describeClients(clients) {
853
1692
  * Fingerprint of what a plan read, secrets stripped (the Wi-Fi password is never hashed:
854
1693
  * a hash of a short secret leaks it to anyone who can guess).
855
1694
  */
856
- function fingerprint(snap, reads) {
1695
+ function fingerprint(snap, reads, extra) {
857
1696
  const material = {
858
1697
  networks: reads.networks ? snap.networksRaw : undefined,
859
1698
  ssids: reads.ssids ? redactSecrets(snap.ssids) : undefined,
860
1699
  // A MAC filter plan resolved a group by name: a renamed or replaced group must not slip through.
861
1700
  macGroups: reads.macGroups ? snap.macGroups : undefined,
1701
+ rules: reads.rules ? { acls: snap.acls, otherAcls: snap.otherAcls, ipGroups: snap.ipGroups, ipPortGroups: snap.ipPortGroups } : undefined,
1702
+ extra,
862
1703
  };
863
1704
  return createHash('sha256').update(JSON.stringify(material)).digest('hex').slice(0, 16);
864
1705
  }