@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.
- package/.env.example +10 -0
- package/README.md +25 -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 +96 -10
- package/dist/net/adapters/omada.js +909 -68
- 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 +141 -0
- package/dist/net/adapters/omadaRules.js +595 -0
- package/dist/net/adapters/omadaRules.js.map +1 -0
- package/dist/net/model.d.ts +38 -11
- package/dist/net/model.js.map +1 -1
- package/dist/omadaClient/accessControl.js +4 -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 +30 -0
- package/dist/omadaClient/networkConfig.js +69 -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 +148 -1
- package/dist/tools/net/index.js +246 -10
- 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
|
@@ -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).
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
69
|
-
|
|
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: '
|
|
114
|
+
mdnsRepeater: 'native',
|
|
73
115
|
};
|
|
74
116
|
}
|
|
75
117
|
// ---- reads -----------------------------------------------------------------------
|
|
76
118
|
async resolveController() {
|
|
77
119
|
const host = this.options.controllerHost;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
...(
|
|
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
|
-
|
|
307
|
+
return (await this.client.networkConfig.listPortProfiles(this.options.siteId)).map(toPortProfile);
|
|
241
308
|
}
|
|
242
|
-
async listPorts() {
|
|
243
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
624
|
+
change: redacted,
|
|
282
625
|
vendor: 'omada',
|
|
283
626
|
steps: built.steps,
|
|
284
627
|
effects: built.effects,
|
|
285
628
|
wouldLockOut: built.wouldLockOut,
|
|
286
|
-
|
|
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 === '
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
}
|