@coffeerunhobby/mcp-ha-connect 2.4.7 → 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.
- package/.env.example +10 -0
- package/README.md +18 -8
- package/dist/config.d.ts +2 -0
- package/dist/config.js +4 -0
- package/dist/config.js.map +1 -1
- package/dist/net/adapters/omada.d.ts +39 -7
- package/dist/net/adapters/omada.js +410 -42
- package/dist/net/adapters/omada.js.map +1 -1
- package/dist/net/adapters/omadaPorts.d.ts +62 -0
- package/dist/net/adapters/omadaPorts.js +212 -0
- package/dist/net/adapters/omadaPorts.js.map +1 -0
- package/dist/net/adapters/omadaRules.d.ts +1 -1
- package/dist/net/adapters/omadaRules.js +2 -2
- package/dist/net/adapters/omadaRules.js.map +1 -1
- package/dist/net/model.d.ts +36 -11
- package/dist/net/model.js.map +1 -1
- package/dist/omadaClient/accessControl.js +1 -1
- package/dist/omadaClient/accessControl.js.map +1 -1
- package/dist/omadaClient/index.d.ts +4 -0
- package/dist/omadaClient/index.js +3 -0
- package/dist/omadaClient/index.js.map +1 -1
- package/dist/omadaClient/networkConfig.d.ts +21 -0
- package/dist/omadaClient/networkConfig.js +41 -0
- package/dist/omadaClient/networkConfig.js.map +1 -1
- package/dist/tools/index.js +1 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/net/index.d.ts +36 -1
- package/dist/tools/net/index.js +140 -11
- package/dist/tools/net/index.js.map +1 -1
- package/dist/tools/omada/listClients.js +1 -1
- package/dist/tools/omada/listClients.js.map +1 -1
- package/dist/upstreams.d.ts +1 -0
- package/dist/upstreams.js +2 -1
- package/dist/upstreams.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +6 -6
|
@@ -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.
|
|
6
|
-
*
|
|
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
|
-
|
|
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: '
|
|
114
|
+
mdnsRepeater: 'native',
|
|
74
115
|
};
|
|
75
116
|
}
|
|
76
117
|
// ---- reads -----------------------------------------------------------------------
|
|
77
118
|
async resolveController() {
|
|
78
119
|
const host = this.options.controllerHost;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
...(
|
|
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
|
-
|
|
307
|
+
return (await this.client.networkConfig.listPortProfiles(this.options.siteId)).map(toPortProfile);
|
|
246
308
|
}
|
|
247
|
-
async listPorts() {
|
|
248
|
-
|
|
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
|
-
|
|
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:
|
|
624
|
+
change: redacted,
|
|
305
625
|
vendor: 'omada',
|
|
306
626
|
steps: built.steps,
|
|
307
627
|
effects: built.effects,
|
|
308
628
|
wouldLockOut: built.wouldLockOut,
|
|
309
|
-
|
|
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)' :
|
|
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
|
-
|
|
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
|
|
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)}`);
|
|
@@ -1318,6 +1665,26 @@ function describeNetworkChanges(current, next) {
|
|
|
1318
1665
|
out.push('IPv6 → off');
|
|
1319
1666
|
return out;
|
|
1320
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
|
+
}
|
|
1321
1688
|
function describeClients(clients) {
|
|
1322
1689
|
return clients.map((c) => `${c.name ?? '?'} (${c.mac})`).join(', ');
|
|
1323
1690
|
}
|
|
@@ -1325,13 +1692,14 @@ function describeClients(clients) {
|
|
|
1325
1692
|
* Fingerprint of what a plan read, secrets stripped (the Wi-Fi password is never hashed:
|
|
1326
1693
|
* a hash of a short secret leaks it to anyone who can guess).
|
|
1327
1694
|
*/
|
|
1328
|
-
function fingerprint(snap, reads) {
|
|
1695
|
+
function fingerprint(snap, reads, extra) {
|
|
1329
1696
|
const material = {
|
|
1330
1697
|
networks: reads.networks ? snap.networksRaw : undefined,
|
|
1331
1698
|
ssids: reads.ssids ? redactSecrets(snap.ssids) : undefined,
|
|
1332
1699
|
// A MAC filter plan resolved a group by name: a renamed or replaced group must not slip through.
|
|
1333
1700
|
macGroups: reads.macGroups ? snap.macGroups : undefined,
|
|
1334
1701
|
rules: reads.rules ? { acls: snap.acls, otherAcls: snap.otherAcls, ipGroups: snap.ipGroups, ipPortGroups: snap.ipPortGroups } : undefined,
|
|
1702
|
+
extra,
|
|
1335
1703
|
};
|
|
1336
1704
|
return createHash('sha256').update(JSON.stringify(material)).digest('hex').slice(0, 16);
|
|
1337
1705
|
}
|