@coffeerunhobby/mcp-ha-connect 2.4.7 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/.env.example +25 -0
  2. package/README.md +42 -10
  3. package/dist/config.d.ts +7 -0
  4. package/dist/config.js +22 -0
  5. package/dist/config.js.map +1 -1
  6. package/dist/index.js +3 -2
  7. package/dist/index.js.map +1 -1
  8. package/dist/net/adapters/omada.d.ts +46 -7
  9. package/dist/net/adapters/omada.js +432 -44
  10. package/dist/net/adapters/omada.js.map +1 -1
  11. package/dist/net/adapters/omadaPorts.d.ts +62 -0
  12. package/dist/net/adapters/omadaPorts.js +212 -0
  13. package/dist/net/adapters/omadaPorts.js.map +1 -0
  14. package/dist/net/adapters/omadaRules.d.ts +1 -1
  15. package/dist/net/adapters/omadaRules.js +2 -2
  16. package/dist/net/adapters/omadaRules.js.map +1 -1
  17. package/dist/net/adapters/ruijie.d.ts +59 -0
  18. package/dist/net/adapters/ruijie.js +363 -0
  19. package/dist/net/adapters/ruijie.js.map +1 -0
  20. package/dist/net/model.d.ts +37 -12
  21. package/dist/net/model.js.map +1 -1
  22. package/dist/omadaClient/accessControl.js +1 -1
  23. package/dist/omadaClient/accessControl.js.map +1 -1
  24. package/dist/omadaClient/index.d.ts +4 -0
  25. package/dist/omadaClient/index.js +3 -0
  26. package/dist/omadaClient/index.js.map +1 -1
  27. package/dist/omadaClient/networkConfig.d.ts +21 -0
  28. package/dist/omadaClient/networkConfig.js +41 -0
  29. package/dist/omadaClient/networkConfig.js.map +1 -1
  30. package/dist/ruijieClient/crypto.d.ts +20 -0
  31. package/dist/ruijieClient/crypto.js +57 -0
  32. package/dist/ruijieClient/crypto.js.map +1 -0
  33. package/dist/ruijieClient/index.d.ts +62 -0
  34. package/dist/ruijieClient/index.js +197 -0
  35. package/dist/ruijieClient/index.js.map +1 -0
  36. package/dist/server/common.d.ts +2 -0
  37. package/dist/server/common.js +2 -1
  38. package/dist/server/common.js.map +1 -1
  39. package/dist/server/http.d.ts +2 -0
  40. package/dist/server/http.js +2 -2
  41. package/dist/server/http.js.map +1 -1
  42. package/dist/server/stdio.d.ts +2 -0
  43. package/dist/server/stdio.js.map +1 -1
  44. package/dist/server/stream.d.ts +2 -0
  45. package/dist/server/stream.js +2 -1
  46. package/dist/server/stream.js.map +1 -1
  47. package/dist/tools/index.d.ts +3 -0
  48. package/dist/tools/index.js +9 -3
  49. package/dist/tools/index.js.map +1 -1
  50. package/dist/tools/net/index.d.ts +43 -2
  51. package/dist/tools/net/index.js +197 -12
  52. package/dist/tools/net/index.js.map +1 -1
  53. package/dist/tools/omada/listClients.js +1 -1
  54. package/dist/tools/omada/listClients.js.map +1 -1
  55. package/dist/upstreams.d.ts +8 -2
  56. package/dist/upstreams.js +56 -3
  57. package/dist/upstreams.js.map +1 -1
  58. package/dist/utils/redact.js +1 -1
  59. package/dist/utils/redact.js.map +1 -1
  60. package/dist/version.d.ts +1 -1
  61. package/dist/version.js +1 -1
  62. package/package.json +7 -7
@@ -2,8 +2,9 @@
2
2
  * Omada adapter for the vendor-neutral network model (src/net/model.ts).
3
3
  *
4
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.
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
@@ -23,6 +24,7 @@ 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';
25
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';
26
28
  const PSEUDO_WLAN_ID = 'gateway';
27
29
  /** Omada LAN network purpose: 0 = VLAN only (switches), 1 = interface (routed by the gateway). */
28
30
  const PURPOSE_VLAN = 0;
@@ -47,6 +49,36 @@ const SSID_FIELDS = [
47
49
  'name', 'band', 'guestNetEnable', 'security', 'oweEnable', 'broadcast', 'vlanEnable', 'vlanId', 'pskSetting', 'mloEnable', 'pmfMode',
48
50
  'enable11r', 'hidePwd', 'greEnable', 'vlanSetting', 'prohibitWifiShare',
49
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'];
50
82
  const pick = (raw, fields) => Object.fromEntries(fields.filter((f) => raw[f] !== undefined).map((f) => [f, raw[f]]));
51
83
  export class OmadaNetworkController {
52
84
  client;
@@ -56,6 +88,14 @@ export class OmadaNetworkController {
56
88
  executors = new WeakMap();
57
89
  /** The controller's IPv4 addresses; 'unknown' when its host name can't be resolved (then every routed network is protected). */
58
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 = [];
59
99
  constructor(client, options = {}) {
60
100
  this.client = client;
61
101
  this.options = options;
@@ -66,35 +106,46 @@ export class OmadaNetworkController {
66
106
  vendor: 'omada',
67
107
  networks: { create: 'native', isolation: 'unsupported', ipv6: 'unsupported', guestPurpose: 'unsupported' },
68
108
  wifi: { create: 'native', bands: ['2.4', '5', '6'], security: ['open', 'wpa2', 'wpa3'], pmf: 'native', fastRoaming: 'native', bandSteering: 'unsupported' },
69
- ports: { profiles: 'unsupported', assign: 'unsupported' },
109
+ // Port profiles are listed and assigned; they are created in the controller web UI.
110
+ ports: { profiles: 'unsupported', assign: 'native' },
70
111
  rules: { between: 'native', toInternet: 'native', deviceMatch: 'unsupported', wifiMatch: 'unsupported', stateful: 'native', schedule: 'unsupported' },
71
112
  groups: { address: 'unsupported', mac: 'unsupported' },
72
113
  reservations: 'native',
73
- mdnsRepeater: 'unsupported',
114
+ mdnsRepeater: 'native',
74
115
  };
75
116
  }
76
117
  // ---- reads -----------------------------------------------------------------------
77
118
  async resolveController() {
78
119
  const host = this.options.controllerHost;
79
- if (!host) {
80
- this.controllerAddresses = [];
81
- return;
82
- }
83
- try {
84
- this.controllerAddresses = [ipToNumber(host)];
85
- return;
86
- }
87
- catch {
88
- // a host name
89
- }
90
- try {
91
- const resolve = this.options.resolveHost ?? (async (name) => (await lookup(name, { all: true, family: 4 })).map((a) => a.address));
92
- const addresses = await resolve(host);
93
- this.controllerAddresses = addresses.length ? addresses.map(ipToNumber) : 'unknown';
94
- }
95
- catch {
96
- 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
+ }
97
147
  }
148
+ return all;
98
149
  }
99
150
  async snapshot() {
100
151
  await this.resolveController();
@@ -113,7 +164,7 @@ export class OmadaNetworkController {
113
164
  ssids.push({ wlanId: wlan.wlanId, ssidId: s.ssidId, detail: await this.client.networkConfig.getSsidDetail(wlan.wlanId, s.ssidId, siteId) });
114
165
  }
115
166
  }
116
- 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));
117
168
  const alwaysProfileId = (await this.client.accessControl.listTimeRanges(siteId)).find((p) => p.name === ALWAYS_PROFILE_NAME)?.profileId;
118
169
  const macGroups = await this.client.listMacGroups(siteId);
119
170
  const acls = (await this.client.networkConfig.listAcls('osg-acls', siteId)).sort((a, b) => Number(a.index ?? 0) - Number(b.index ?? 0));
@@ -153,26 +204,37 @@ export class OmadaNetworkController {
153
204
  return true;
154
205
  if (typeof raw.gatewaySubnet !== 'string')
155
206
  return false;
156
- // Fail closed: if the controller's address is unknown, any routed network could be its own.
157
- 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')
158
209
  return true;
159
210
  const subnet = raw.gatewaySubnet;
160
- return this.controllerAddresses.some((address) => inSubnet(address, subnet));
211
+ return this.hostAddresses.some((address) => inSubnet(address, subnet));
161
212
  }
162
- toClient(raw, networks) {
213
+ toClient(raw, networks, ssids) {
163
214
  // A device still waiting for DHCP reports no (or an empty) address.
164
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;
165
217
  let network;
166
- 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)
167
220
  network = networks.find((n) => n.vlan === raw.vid);
168
221
  if (!network && ip)
169
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);
170
231
  return {
171
232
  mac: formatMac(String(raw.mac)),
172
233
  ...(typeof raw.name === 'string' ? { name: raw.name } : {}),
173
234
  ...(ip ? { ip } : {}),
174
235
  ...(network ? { network: { id: network.id } } : {}),
175
- ...(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 } } : {}),
176
238
  online: raw.active !== false,
177
239
  };
178
240
  }
@@ -242,10 +304,13 @@ export class OmadaNetworkController {
242
304
  .map((r) => ({ mac: formatMac(r.mac), ip: String(r.ip), network: { id: r.netId }, ...(r.description ? { description: String(r.description) } : {}) }));
243
305
  }
244
306
  async listPortProfiles() {
245
- throw notYet('switch port profiles', '2.4.8');
307
+ return (await this.client.networkConfig.listPortProfiles(this.options.siteId)).map(toPortProfile);
246
308
  }
247
- async listPorts() {
248
- 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)));
249
314
  }
250
315
  async listRules() {
251
316
  const snap = await this.snapshot();
@@ -272,7 +337,261 @@ export class OmadaNetworkController {
272
337
  return kind === 'address' ? address : kind === 'mac' ? mac : [...address, ...mac];
273
338
  }
274
339
  async getMdnsRepeater() {
275
- 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
+ };
276
595
  }
277
596
  // ---- plan / apply ------------------------------------------------------------------
278
597
  async plan(change) {
@@ -300,13 +619,16 @@ export class OmadaNetworkController {
300
619
  }
301
620
  async planWith(change, snap) {
302
621
  const built = await this.build(change, snap);
622
+ const redacted = redactSecrets(change);
303
623
  const plan = {
304
- change: redactSecrets(change),
624
+ change: redacted,
305
625
  vendor: 'omada',
306
626
  steps: built.steps,
307
627
  effects: built.effects,
308
628
  wouldLockOut: built.wouldLockOut,
309
- 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() }),
310
632
  };
311
633
  this.executors.set(plan, built.execute);
312
634
  return plan;
@@ -329,6 +651,12 @@ export class OmadaNetworkController {
329
651
  if (change.op === 'delete')
330
652
  return this.buildWifiDelete(change.ref, snap);
331
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);
332
660
  case 'rule':
333
661
  if (change.op === 'create')
334
662
  return this.buildRuleCreate(change.value, snap);
@@ -340,7 +668,7 @@ export class OmadaNetworkController {
340
668
  return this.buildRuleMove(change.ref, change.position, snap);
341
669
  break;
342
670
  default:
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');
671
+ throw notYet(`${change.kind} changes`, change.kind === 'group' ? 'a later release (use omada_createGroup / omada_deleteGroup)' : 'a later release (use omada_setDhcpReservation)');
344
672
  }
345
673
  throw new NetError('INVALID', 'This change is not supported');
346
674
  }
@@ -352,7 +680,8 @@ export class OmadaNetworkController {
352
680
  ipGroups: snap.ipGroups,
353
681
  ipPortGroups: snap.ipPortGroups,
354
682
  ssids: snap.ssids.map((s) => ({ id: s.ssidId, name: String(s.detail.name) })),
355
- controllerAddresses: this.controllerAddresses,
683
+ // This server's addresses too: a rule must not cut it off the internet either.
684
+ controllerAddresses: this.hostAddresses,
356
685
  };
357
686
  }
358
687
  /** The rule as the model sees it, with its raw ACL; a rule the model can't express is refused. */
@@ -380,7 +709,7 @@ export class OmadaNetworkController {
380
709
  for (const state of states) {
381
710
  const risk = controllerRisk(today, controllerPath(state.acls, state.world));
382
711
  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.`);
712
+ throw new NetError('PROTECTED', `This could cut the controller or this server off the internet: ${risk}. Nothing was changed.`);
384
713
  }
385
714
  }
386
715
  buildRuleCreate(value, snap) {
@@ -803,7 +1132,7 @@ export class OmadaNetworkController {
803
1132
  throw unsupported('switching IPv6 on (do it in the controller web UI)', 'networks.ipv6');
804
1133
  const addressing = (set.vlan !== undefined && set.vlan !== current.vlan) || (set.gateway !== undefined && set.gateway !== current.gateway);
805
1134
  if (current.protected && addressing) {
806
- 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`);
807
1136
  }
808
1137
  const name = set.name !== undefined ? checkName(set.name, MAX_NETWORK_NAME, 'Network') : current.name;
809
1138
  const vlan = set.vlan !== undefined ? checkVlan(set.vlan) : current.vlan;
@@ -887,7 +1216,7 @@ export class OmadaNetworkController {
887
1216
  async buildNetworkDelete(ref, snap) {
888
1217
  const current = resolve(snap.networks, ref, 'network');
889
1218
  if (current.protected)
890
- 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`);
891
1220
  const users = await this.networkUsers(current, snap);
892
1221
  if (users.length)
893
1222
  throw new NetError('IN_USE', `'${current.name}' is used by ${users.join(', ')}; change those first`, { users });
@@ -903,7 +1232,7 @@ export class OmadaNetworkController {
903
1232
  },
904
1233
  };
905
1234
  }
906
- /** 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. */
907
1236
  async networkUsers(network, snap) {
908
1237
  const users = [];
909
1238
  for (const s of snap.ssids)
@@ -914,6 +1243,24 @@ export class OmadaNetworkController {
914
1243
  if (ids.includes(network.id))
915
1244
  users.push(`port profile '${String(p.name)}'`);
916
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
+ }
917
1264
  for (const r of await this.client.accessControl.listDhcpReservations(snap.siteId))
918
1265
  if (r.netId === network.id)
919
1266
  users.push(`DHCP reservation ${formatMac(r.mac)}`);
@@ -937,11 +1284,11 @@ export class OmadaNetworkController {
937
1284
  const wlanId = snap.ssids[0]?.wlanId ?? (await this.client.networkConfig.listSsidSummaries(snap.siteId)).find((w) => w.wlanId !== PSEUDO_WLAN_ID)?.wlanId;
938
1285
  if (!wlanId)
939
1286
  throw new NetError('NOT_FOUND', 'No WLAN group to add the Wi-Fi network to');
940
- const template = snap.ssids[0]?.detail;
941
1287
  const macFilter = value.macFilter ? await this.macFilterBody(value.macFilter, snap) : undefined;
1288
+ const deviceType = await this.wifiDeviceType(snap);
942
1289
  const body = {
943
1290
  name,
944
- deviceType: typeof template?.deviceType === 'number' ? template.deviceType : 1,
1291
+ deviceType,
945
1292
  band: bandBits(checkBands(value.bands)),
946
1293
  guestNetEnable: value.clientIsolation,
947
1294
  broadcast: !value.hidden,
@@ -970,6 +1317,8 @@ export class OmadaNetworkController {
970
1317
  ],
971
1318
  wouldLockOut: [],
972
1319
  reads: { networks: true, ssids: true, macGroups: macFilter !== undefined },
1320
+ // Which devices broadcast it: a change (an access point added, another gateway) is a CONFLICT.
1321
+ extra: { deviceType },
973
1322
  execute: async (s) => {
974
1323
  await this.client.networkConfig.createSsid(wlanId, body, s.siteId);
975
1324
  const created = (await this.client.networkConfig.listSsidSummaries(s.siteId))
@@ -991,6 +1340,24 @@ export class OmadaNetworkController {
991
1340
  },
992
1341
  };
993
1342
  }
1343
+ /**
1344
+ * Which devices broadcast a new SSID (bit 0 = access points, bit 1 = the gateway): the access
1345
+ * points when the site has any, the gateway only when it reports a radio (wirelessRouter).
1346
+ * Never copied from another SSID: an SSID put on a gateway without Wi-Fi can't be saved in the
1347
+ * web UI (live, ER605, 2026-10-21). The gateway's details come with a network's port list.
1348
+ */
1349
+ async wifiDeviceType(snap) {
1350
+ const devices = await this.client.listDevices(snap.siteId);
1351
+ const hasAccessPoints = devices.some((d) => d.type === 'ap');
1352
+ const first = snap.networks[0];
1353
+ const entries = devices.some((d) => d.type === 'gateway') && first ? await this.client.networkConfig.listNetworkPorts(first.id, first.vlan, snap.siteId) : [];
1354
+ const gatewayWifi = entries.some((e) => e.type === 'gateway' && e.gatewayInfo?.gatewayDetail?.wirelessRouter === true);
1355
+ const deviceType = (hasAccessPoints ? 1 : 0) + (gatewayWifi ? 2 : 0);
1356
+ if (deviceType === 0) {
1357
+ throw new NetError('UNSUPPORTED', 'Nothing could broadcast this Wi-Fi network: the site has no access point and the gateway has no Wi-Fi', { capability: 'wifi.create' });
1358
+ }
1359
+ return deviceType;
1360
+ }
994
1361
  async buildWifiUpdate(ref, set, passphrase, snap) {
995
1362
  const ssid = resolveSsid(snap.ssids, ref);
996
1363
  const groups = snap.macGroups;
@@ -1318,6 +1685,26 @@ function describeNetworkChanges(current, next) {
1318
1685
  out.push('IPv6 → off');
1319
1686
  return out;
1320
1687
  }
1688
+ /**
1689
+ * A controller record with its keys and every list sorted: the lists in port profiles and mDNS
1690
+ * rules are sets (ids of networks, services...), so the same state listed in another order
1691
+ * fingerprints the same.
1692
+ */
1693
+ function canonical(value) {
1694
+ if (Array.isArray(value)) {
1695
+ return value
1696
+ .map(canonical)
1697
+ .map((item) => [JSON.stringify(item), item])
1698
+ .sort(([a], [b]) => a.localeCompare(b))
1699
+ .map(([, item]) => item);
1700
+ }
1701
+ if (value && typeof value === 'object') {
1702
+ return Object.fromEntries(Object.entries(value)
1703
+ .sort(([a], [b]) => a.localeCompare(b))
1704
+ .map(([k, v]) => [k, canonical(v)]));
1705
+ }
1706
+ return value;
1707
+ }
1321
1708
  function describeClients(clients) {
1322
1709
  return clients.map((c) => `${c.name ?? '?'} (${c.mac})`).join(', ');
1323
1710
  }
@@ -1325,13 +1712,14 @@ function describeClients(clients) {
1325
1712
  * Fingerprint of what a plan read, secrets stripped (the Wi-Fi password is never hashed:
1326
1713
  * a hash of a short secret leaks it to anyone who can guess).
1327
1714
  */
1328
- function fingerprint(snap, reads) {
1715
+ function fingerprint(snap, reads, extra) {
1329
1716
  const material = {
1330
1717
  networks: reads.networks ? snap.networksRaw : undefined,
1331
1718
  ssids: reads.ssids ? redactSecrets(snap.ssids) : undefined,
1332
1719
  // A MAC filter plan resolved a group by name: a renamed or replaced group must not slip through.
1333
1720
  macGroups: reads.macGroups ? snap.macGroups : undefined,
1334
1721
  rules: reads.rules ? { acls: snap.acls, otherAcls: snap.otherAcls, ipGroups: snap.ipGroups, ipPortGroups: snap.ipPortGroups } : undefined,
1722
+ extra,
1335
1723
  };
1336
1724
  return createHash('sha256').update(JSON.stringify(material)).digest('hex').slice(0, 16);
1337
1725
  }