@coffeerunhobby/mcp-ha-connect 2.4.3 → 2.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -1
- package/dist/net/adapters/omada.d.ts +71 -0
- package/dist/net/adapters/omada.js +865 -0
- package/dist/net/adapters/omada.js.map +1 -0
- package/dist/net/model.d.ts +415 -0
- package/dist/net/model.js +26 -0
- package/dist/net/model.js.map +1 -0
- package/dist/net/validate.d.ts +22 -0
- package/dist/net/validate.js +87 -0
- package/dist/net/validate.js.map +1 -0
- package/dist/omadaClient/accessControl.js +4 -17
- package/dist/omadaClient/accessControl.js.map +1 -1
- package/dist/omadaClient/index.d.ts +7 -0
- package/dist/omadaClient/index.js +11 -0
- package/dist/omadaClient/index.js.map +1 -1
- package/dist/omadaClient/networkConfig.d.ts +54 -0
- package/dist/omadaClient/networkConfig.js +79 -0
- package/dist/omadaClient/networkConfig.js.map +1 -0
- package/dist/omadaClient/request.d.ts +2 -1
- package/dist/omadaClient/request.js +8 -6
- package/dist/omadaClient/request.js.map +1 -1
- package/dist/tools/common.d.ts +3 -1
- package/dist/tools/common.js +2 -2
- package/dist/tools/common.js.map +1 -1
- package/dist/tools/index.js +4 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/net/index.d.ts +125 -0
- package/dist/tools/net/index.js +193 -0
- package/dist/tools/net/index.js.map +1 -0
- package/dist/tools/omada/graph.js +3 -1
- package/dist/tools/omada/graph.js.map +1 -1
- package/dist/utils/ipv4.d.ts +14 -0
- package/dist/utils/ipv4.js +37 -0
- package/dist/utils/ipv4.js.map +1 -0
- package/dist/utils/redact.d.ts +11 -2
- package/dist/utils/redact.js +25 -7
- package/dist/utils/redact.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,865 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Omada adapter for the vendor-neutral network model (src/net/model.ts).
|
|
3
|
+
*
|
|
4
|
+
* 2.4.5 covers networks (LAN networks / VLANs) and Wi-Fi (SSIDs). Rules, groups, switch
|
|
5
|
+
* ports and mDNS arrive with 2.4.6 and 2.4.7; until then those calls fail with
|
|
6
|
+
* NetError UNSUPPORTED.
|
|
7
|
+
*
|
|
8
|
+
* Omada specifics handled here:
|
|
9
|
+
* - networks have no "guest" purpose and no isolation flag in the API (guest is per
|
|
10
|
+
* SSID), so both are reported unsupported; IPv6 can be switched off but not on;
|
|
11
|
+
* - WPA2/WPA3 mixed mode is not in the API (WPA, WPA2, WPA/WPA2 or WPA3 only);
|
|
12
|
+
* - updating an SSID requires resending its password: the current one is read and sent
|
|
13
|
+
* back internally and never appears in a plan, step, error or log;
|
|
14
|
+
* - Omada lists every SSID again under a pseudo WLAN group "gateway" without detail.
|
|
15
|
+
*/
|
|
16
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
17
|
+
import { lookup } from 'node:dns/promises';
|
|
18
|
+
import { formatMac, normalizeMac } from '../../omadaClient/client.js';
|
|
19
|
+
import { ALWAYS_PROFILE_NAME } from '../../omadaClient/network.js';
|
|
20
|
+
import { accessControlLockKey, serialize } from '../../omadaClient/serialize.js';
|
|
21
|
+
import { inSubnet, ipToNumber } from '../../utils/ipv4.js';
|
|
22
|
+
import { redactSecrets } from '../../utils/redact.js';
|
|
23
|
+
import { NetError, } from '../model.js';
|
|
24
|
+
import { MAX_NETWORK_NAME, MAX_WIFI_NAME, checkBands, checkDhcp, checkGateway, checkName, checkNetworkUnique, checkVlan } from '../validate.js';
|
|
25
|
+
const PSEUDO_WLAN_ID = 'gateway';
|
|
26
|
+
/** Omada LAN network purpose: 0 = VLAN only (switches), 1 = interface (routed by the gateway). */
|
|
27
|
+
const PURPOSE_VLAN = 0;
|
|
28
|
+
const PURPOSE_INTERFACE = 1;
|
|
29
|
+
const BAND_BITS = { '2.4': 1, '5': 2, '6': 4 };
|
|
30
|
+
/** Listing order (Object.keys would put '5' and '6' before '2.4'). */
|
|
31
|
+
const BANDS = ['2.4', '5', '6'];
|
|
32
|
+
const PMF = { required: 1, optional: 2, off: 3 };
|
|
33
|
+
/** WPA-Personal versions (pskSetting.versionPsk); 3 = WPA/WPA2 is not offered by the model. */
|
|
34
|
+
const PSK_VERSION = { wpa2: 2, wpa3: 4 };
|
|
35
|
+
const SECURITY_NONE = 0;
|
|
36
|
+
const SECURITY_WPA_PERSONAL = 3;
|
|
37
|
+
const ENCRYPTION_AES = 3;
|
|
38
|
+
/** Writable fields of a LAN network (v2 create/modify body). */
|
|
39
|
+
const NETWORK_FIELDS = [
|
|
40
|
+
'name', 'purpose', 'interfaceIds', 'vlanType', 'vlans', 'vlan', 'application', 'gatewaySubnet', 'dhcpSettingsVO', 'domain',
|
|
41
|
+
'igmpSnoopEnable', 'mldSnoopEnable', 'dhcpL2RelayEnable', 'dhcpGuard', 'dhcpv6Guard', 'lanNetworkIpv6Config', 'allLan', 'qosQueueEnable', 'queueId',
|
|
42
|
+
];
|
|
43
|
+
const DHCP_FIELDS = ['enable', 'ipRangePool', 'dhcpns', 'priDns', 'sndDns', 'leasetime', 'gateway', 'dhcpNextServer', 'options'];
|
|
44
|
+
/** Writable fields of an SSID's basic config (update-basic-config body). */
|
|
45
|
+
const SSID_FIELDS = [
|
|
46
|
+
'name', 'band', 'guestNetEnable', 'security', 'oweEnable', 'broadcast', 'vlanEnable', 'vlanId', 'pskSetting', 'mloEnable', 'pmfMode',
|
|
47
|
+
'enable11r', 'hidePwd', 'greEnable', 'vlanSetting', 'prohibitWifiShare',
|
|
48
|
+
];
|
|
49
|
+
const pick = (raw, fields) => Object.fromEntries(fields.filter((f) => raw[f] !== undefined).map((f) => [f, raw[f]]));
|
|
50
|
+
export class OmadaNetworkController {
|
|
51
|
+
client;
|
|
52
|
+
options;
|
|
53
|
+
vendor = 'omada';
|
|
54
|
+
id;
|
|
55
|
+
executors = new WeakMap();
|
|
56
|
+
/** The controller's IPv4 addresses; 'unknown' when its host name can't be resolved (then every routed network is protected). */
|
|
57
|
+
controllerAddresses = [];
|
|
58
|
+
constructor(client, options = {}) {
|
|
59
|
+
this.client = client;
|
|
60
|
+
this.options = options;
|
|
61
|
+
this.id = `omada:${options.siteId ?? 'default'}`;
|
|
62
|
+
}
|
|
63
|
+
async capabilities() {
|
|
64
|
+
return {
|
|
65
|
+
vendor: 'omada',
|
|
66
|
+
networks: { create: 'native', isolation: 'unsupported', ipv6: 'unsupported', guestPurpose: 'unsupported' },
|
|
67
|
+
wifi: { create: 'native', bands: ['2.4', '5', '6'], security: ['open', 'wpa2', 'wpa3'], pmf: 'native', fastRoaming: 'native', bandSteering: 'unsupported' },
|
|
68
|
+
ports: { profiles: 'unsupported', assign: 'unsupported' },
|
|
69
|
+
rules: { between: 'unsupported', toInternet: 'unsupported', deviceMatch: 'unsupported', wifiMatch: 'unsupported', stateful: 'unsupported', schedule: 'unsupported' },
|
|
70
|
+
groups: { address: 'unsupported', mac: 'unsupported' },
|
|
71
|
+
reservations: 'native',
|
|
72
|
+
mdnsRepeater: 'unsupported',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
// ---- reads -----------------------------------------------------------------------
|
|
76
|
+
async resolveController() {
|
|
77
|
+
const host = this.options.controllerHost;
|
|
78
|
+
if (!host) {
|
|
79
|
+
this.controllerAddresses = [];
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
this.controllerAddresses = [ipToNumber(host)];
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// a host name
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const resolve = this.options.resolveHost ?? (async (name) => (await lookup(name, { all: true, family: 4 })).map((a) => a.address));
|
|
91
|
+
const addresses = await resolve(host);
|
|
92
|
+
this.controllerAddresses = addresses.length ? addresses.map(ipToNumber) : 'unknown';
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
this.controllerAddresses = 'unknown';
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async snapshot() {
|
|
99
|
+
await this.resolveController();
|
|
100
|
+
const siteId = this.client.resolveSiteId(this.options.siteId);
|
|
101
|
+
const networksRaw = await this.client.networkConfig.listLanNetworks(siteId);
|
|
102
|
+
const networks = networksRaw.map((raw) => this.toNetwork(raw));
|
|
103
|
+
const ssids = [];
|
|
104
|
+
const seen = new Set();
|
|
105
|
+
for (const wlan of await this.client.networkConfig.listSsidSummaries(siteId)) {
|
|
106
|
+
if (wlan.wlanId === PSEUDO_WLAN_ID)
|
|
107
|
+
continue;
|
|
108
|
+
for (const s of wlan.ssidList ?? []) {
|
|
109
|
+
if (seen.has(s.ssidId))
|
|
110
|
+
continue;
|
|
111
|
+
seen.add(s.ssidId);
|
|
112
|
+
ssids.push({ wlanId: wlan.wlanId, ssidId: s.ssidId, detail: await this.client.networkConfig.getSsidDetail(wlan.wlanId, s.ssidId, siteId) });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const clients = (await this.client.listClients(siteId)).map((c) => this.toClient(c, networks));
|
|
116
|
+
const alwaysProfileId = (await this.client.accessControl.listTimeRanges(siteId)).find((p) => p.name === ALWAYS_PROFILE_NAME)?.profileId;
|
|
117
|
+
const macGroups = await this.client.listMacGroups(siteId);
|
|
118
|
+
return { siteId, networksRaw, networks, ssids, clients, macGroups, ...(alwaysProfileId ? { alwaysProfileId } : {}) };
|
|
119
|
+
}
|
|
120
|
+
toNetwork(raw) {
|
|
121
|
+
const dhcp = (raw.dhcpSettingsVO ?? {});
|
|
122
|
+
const pool = (dhcp.ipRangePool ?? [])[0];
|
|
123
|
+
const purpose = raw.purpose === PURPOSE_VLAN ? 'vlanOnly' : 'normal';
|
|
124
|
+
const gateway = typeof raw.gatewaySubnet === 'string' ? raw.gatewaySubnet : undefined;
|
|
125
|
+
return {
|
|
126
|
+
id: String(raw.id),
|
|
127
|
+
name: String(raw.name),
|
|
128
|
+
purpose,
|
|
129
|
+
vlan: Number(raw.vlan ?? 1),
|
|
130
|
+
...(gateway ? { gateway } : {}),
|
|
131
|
+
...(purpose === 'normal'
|
|
132
|
+
? {
|
|
133
|
+
dhcp: {
|
|
134
|
+
enabled: dhcp.enable === true,
|
|
135
|
+
...(pool ? { range: { from: String(pool.ipaddrStart), to: String(pool.ipaddrEnd) } } : {}),
|
|
136
|
+
...(typeof dhcp.leasetime === 'number' ? { leaseMinutes: dhcp.leasetime } : {}),
|
|
137
|
+
...(dhcp.dhcpns === 'manual' ? { dns: [dhcp.priDns, dhcp.sndDns].filter((d) => typeof d === 'string' && d !== '') } : {}),
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
: {}),
|
|
141
|
+
ipv6: raw.lanNetworkIpv6Config?.enable === 1,
|
|
142
|
+
isolated: false,
|
|
143
|
+
protected: this.isProtected(raw),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
isProtected(raw) {
|
|
147
|
+
if (raw.primary === true)
|
|
148
|
+
return true;
|
|
149
|
+
if (typeof raw.gatewaySubnet !== 'string')
|
|
150
|
+
return false;
|
|
151
|
+
// Fail closed: if the controller's address is unknown, any routed network could be its own.
|
|
152
|
+
if (this.controllerAddresses === 'unknown')
|
|
153
|
+
return true;
|
|
154
|
+
const subnet = raw.gatewaySubnet;
|
|
155
|
+
return this.controllerAddresses.some((address) => inSubnet(address, subnet));
|
|
156
|
+
}
|
|
157
|
+
toClient(raw, networks) {
|
|
158
|
+
// A device still waiting for DHCP reports no (or an empty) address.
|
|
159
|
+
const ip = typeof raw.ip === 'string' && isIpv4(raw.ip) ? raw.ip : undefined;
|
|
160
|
+
let network;
|
|
161
|
+
if (typeof raw.vid === 'number')
|
|
162
|
+
network = networks.find((n) => n.vlan === raw.vid);
|
|
163
|
+
if (!network && ip)
|
|
164
|
+
network = networks.find((n) => n.gateway && inSubnet(ipToNumber(ip), n.gateway));
|
|
165
|
+
return {
|
|
166
|
+
mac: formatMac(String(raw.mac)),
|
|
167
|
+
...(typeof raw.name === 'string' ? { name: raw.name } : {}),
|
|
168
|
+
...(ip ? { ip } : {}),
|
|
169
|
+
...(network ? { network: { id: network.id } } : {}),
|
|
170
|
+
...(typeof raw.ssid === 'string' && raw.ssid ? { wifi: { name: raw.ssid } } : {}),
|
|
171
|
+
online: raw.active !== false,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
toWifi(ssid, networks, macGroups, alwaysProfileId) {
|
|
175
|
+
const d = ssid.detail;
|
|
176
|
+
const psk = (d.pskSetting ?? {});
|
|
177
|
+
const security = securityOf(d, psk);
|
|
178
|
+
const band = Number(d.band ?? 0);
|
|
179
|
+
const filter = (d.macFilter ?? {});
|
|
180
|
+
const group = macGroups.find((g) => g.groupId === filter.macFilterId);
|
|
181
|
+
return {
|
|
182
|
+
id: ssid.ssidId,
|
|
183
|
+
name: String(d.name),
|
|
184
|
+
network: { id: this.ssidNetwork(d, networks)?.id ?? '' },
|
|
185
|
+
bands: BANDS.filter((b) => (band & BAND_BITS[b]) !== 0),
|
|
186
|
+
security,
|
|
187
|
+
pmf: d.pmfMode === PMF.required ? 'required' : d.pmfMode === PMF.off ? 'off' : 'optional',
|
|
188
|
+
clientIsolation: d.guestNetEnable === true,
|
|
189
|
+
hidden: d.broadcast === false,
|
|
190
|
+
fastRoaming: d.enable11r === true,
|
|
191
|
+
macFilter: filter.macFilterEnable ? { policy: filter.policy === 1 ? 'allow' : 'deny', group: group ? { id: group.groupId } : { id: String(filter.macFilterId) } } : null,
|
|
192
|
+
// Omada has no on/off flag: omada_setSsidEnabled switches an SSID off with a radio-off WLAN schedule on the 24/7 profile.
|
|
193
|
+
enabled: d.ssidEnable !== false && !radioOffAlways(d, alwaysProfileId),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
/** The network an SSID puts its clients on: its custom VLAN mapping, else the untagged primary network. */
|
|
197
|
+
ssidNetwork(detail, networks) {
|
|
198
|
+
const setting = (detail.vlanSetting ?? {});
|
|
199
|
+
const mapped = setting.customConfig?.lanNetworkVlanIds;
|
|
200
|
+
if (setting.mode === 1 && mapped) {
|
|
201
|
+
const id = Object.keys(mapped)[0];
|
|
202
|
+
const found = networks.find((n) => n.id === id);
|
|
203
|
+
if (found)
|
|
204
|
+
return found;
|
|
205
|
+
}
|
|
206
|
+
if (detail.vlanEnable === true && typeof detail.vlanId === 'number')
|
|
207
|
+
return networks.find((n) => n.vlan === detail.vlanId);
|
|
208
|
+
return networks.find((n) => n.vlan === 1) ?? networks.find((n) => n.protected);
|
|
209
|
+
}
|
|
210
|
+
/** Every network an SSID puts devices on (Omada can map one SSID to several). */
|
|
211
|
+
ssidNetworkIds(detail, networks) {
|
|
212
|
+
const setting = (detail.vlanSetting ?? {});
|
|
213
|
+
const mapped = setting.customConfig?.lanNetworkVlanIds;
|
|
214
|
+
const ids = setting.mode === 1 && mapped ? Object.keys(mapped) : [];
|
|
215
|
+
const single = this.ssidNetwork(detail, networks)?.id;
|
|
216
|
+
return [...new Set([...ids, ...(single ? [single] : [])])];
|
|
217
|
+
}
|
|
218
|
+
async protectedResources() {
|
|
219
|
+
const networks = (await this.snapshot()).networks.filter((n) => n.protected);
|
|
220
|
+
return { networks: networks.map((n) => ({ id: n.id })), ports: [], devices: [] };
|
|
221
|
+
}
|
|
222
|
+
async listNetworks() {
|
|
223
|
+
return (await this.snapshot()).networks;
|
|
224
|
+
}
|
|
225
|
+
async listWifi() {
|
|
226
|
+
const snap = await this.snapshot();
|
|
227
|
+
const groups = snap.macGroups;
|
|
228
|
+
return snap.ssids.map((s) => this.toWifi(s, snap.networks, groups, snap.alwaysProfileId));
|
|
229
|
+
}
|
|
230
|
+
async listClients() {
|
|
231
|
+
return (await this.snapshot()).clients;
|
|
232
|
+
}
|
|
233
|
+
async listReservations() {
|
|
234
|
+
const rows = await this.client.accessControl.listDhcpReservations(this.options.siteId);
|
|
235
|
+
return rows
|
|
236
|
+
.filter((r) => typeof r.ip === 'string')
|
|
237
|
+
.map((r) => ({ mac: formatMac(r.mac), ip: String(r.ip), network: { id: r.netId }, ...(r.description ? { description: String(r.description) } : {}) }));
|
|
238
|
+
}
|
|
239
|
+
async listPortProfiles() {
|
|
240
|
+
throw notYet('switch port profiles', '2.4.7');
|
|
241
|
+
}
|
|
242
|
+
async listPorts() {
|
|
243
|
+
throw notYet('switch ports', '2.4.7');
|
|
244
|
+
}
|
|
245
|
+
async listRules() {
|
|
246
|
+
throw notYet('rules', '2.4.6');
|
|
247
|
+
}
|
|
248
|
+
async listGroups() {
|
|
249
|
+
throw notYet('groups', '2.4.6');
|
|
250
|
+
}
|
|
251
|
+
async getMdnsRepeater() {
|
|
252
|
+
throw notYet('the mDNS repeater', '2.4.7');
|
|
253
|
+
}
|
|
254
|
+
// ---- plan / apply ------------------------------------------------------------------
|
|
255
|
+
async plan(change) {
|
|
256
|
+
return await this.planWith(change, await this.snapshot());
|
|
257
|
+
}
|
|
258
|
+
async apply(plan, options = {}) {
|
|
259
|
+
const siteId = this.client.resolveSiteId(this.options.siteId);
|
|
260
|
+
// One write at a time per site, shared with the Omada access control tools.
|
|
261
|
+
return await serialize(accessControlLockKey(siteId), async () => {
|
|
262
|
+
const snap = await this.snapshot();
|
|
263
|
+
const fresh = await this.planWith(plan.change, snap);
|
|
264
|
+
if (fresh.basis !== plan.basis) {
|
|
265
|
+
throw new NetError('CONFLICT', 'The controller changed after this change was planned; nothing was written. Plan it again.');
|
|
266
|
+
}
|
|
267
|
+
if (fresh.wouldLockOut.length > 0 && !options.allowLockout) {
|
|
268
|
+
throw new NetError('LOCKOUT', `This would cut off ${fresh.wouldLockOut.length} online device(s): ${describeClients(fresh.wouldLockOut)}. Nothing was changed; repeat with allowLockout if that is intended.`, {
|
|
269
|
+
clients: fresh.wouldLockOut,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
const execute = this.executors.get(fresh);
|
|
273
|
+
if (!execute)
|
|
274
|
+
throw new NetError('INVALID', 'Nothing to apply');
|
|
275
|
+
return await execute(snap);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
async planWith(change, snap) {
|
|
279
|
+
const built = await this.build(change, snap);
|
|
280
|
+
const plan = {
|
|
281
|
+
change: redactSecrets(change),
|
|
282
|
+
vendor: 'omada',
|
|
283
|
+
steps: built.steps,
|
|
284
|
+
effects: built.effects,
|
|
285
|
+
wouldLockOut: built.wouldLockOut,
|
|
286
|
+
basis: fingerprint(snap, built.reads),
|
|
287
|
+
};
|
|
288
|
+
this.executors.set(plan, built.execute);
|
|
289
|
+
return plan;
|
|
290
|
+
}
|
|
291
|
+
async build(change, snap) {
|
|
292
|
+
switch (change.kind) {
|
|
293
|
+
case 'network':
|
|
294
|
+
if (change.op === 'create')
|
|
295
|
+
return this.buildNetworkCreate(change.value, snap);
|
|
296
|
+
if (change.op === 'update')
|
|
297
|
+
return this.buildNetworkUpdate(change.value.ref, change.value.set, snap);
|
|
298
|
+
if (change.op === 'delete')
|
|
299
|
+
return await this.buildNetworkDelete(change.ref, snap);
|
|
300
|
+
break;
|
|
301
|
+
case 'wifi':
|
|
302
|
+
if (change.op === 'create')
|
|
303
|
+
return await this.buildWifiCreate(change.value, snap);
|
|
304
|
+
if (change.op === 'update')
|
|
305
|
+
return await this.buildWifiUpdate(change.value.ref, change.value.set, change.passphrase, snap);
|
|
306
|
+
if (change.op === 'delete')
|
|
307
|
+
return this.buildWifiDelete(change.ref, snap);
|
|
308
|
+
break;
|
|
309
|
+
default:
|
|
310
|
+
throw notYet(`${change.kind} changes`, change.kind === 'rule' || change.kind === 'group' ? '2.4.6' : change.kind === 'reservation' ? 'a later release (use omada_setDhcpReservation)' : '2.4.7');
|
|
311
|
+
}
|
|
312
|
+
throw new NetError('INVALID', 'This change is not supported');
|
|
313
|
+
}
|
|
314
|
+
// ---- networks ------------------------------------------------------------------------
|
|
315
|
+
buildNetworkCreate(value, snap) {
|
|
316
|
+
const name = checkName(value.name, MAX_NETWORK_NAME, 'Network');
|
|
317
|
+
if (value.purpose === 'guest')
|
|
318
|
+
throw unsupported('a guest network type (Omada makes a Wi-Fi network a guest network instead: clientIsolation)', 'networks.guestPurpose');
|
|
319
|
+
if (value.isolated)
|
|
320
|
+
throw unsupported('network isolation (it comes as rules in 2.4.6)', 'networks.isolation');
|
|
321
|
+
if (value.ipv6)
|
|
322
|
+
throw unsupported('switching IPv6 on (do it in the controller web UI)', 'networks.ipv6');
|
|
323
|
+
const vlan = checkVlan(value.vlan);
|
|
324
|
+
const vlanOnly = value.purpose === 'vlanOnly';
|
|
325
|
+
if (vlanOnly && (value.gateway !== undefined || value.dhcp?.enabled)) {
|
|
326
|
+
throw new NetError('INVALID', 'A switch-only network has no gateway and no DHCP; leave them out, or use purpose normal');
|
|
327
|
+
}
|
|
328
|
+
const gateway = vlanOnly ? undefined : checkGateway(value.gateway ?? '');
|
|
329
|
+
const dhcp = vlanOnly || !gateway ? undefined : checkDhcp(gateway, value.dhcp ?? { enabled: false });
|
|
330
|
+
checkNetworkUnique({ name, vlan, gateway }, snap.networks);
|
|
331
|
+
const primary = snap.networksRaw.find((n) => n.primary === true);
|
|
332
|
+
const body = {
|
|
333
|
+
name,
|
|
334
|
+
purpose: vlanOnly ? PURPOSE_VLAN : PURPOSE_INTERFACE,
|
|
335
|
+
vlan,
|
|
336
|
+
igmpSnoopEnable: false,
|
|
337
|
+
...(vlanOnly
|
|
338
|
+
? {}
|
|
339
|
+
: {
|
|
340
|
+
// Carried on the same gateway LAN ports as the primary network.
|
|
341
|
+
interfaceIds: primary?.interfaceIds ?? [],
|
|
342
|
+
vlanType: 0,
|
|
343
|
+
application: 0,
|
|
344
|
+
gatewaySubnet: gateway,
|
|
345
|
+
dhcpSettingsVO: dhcpBody(dhcp),
|
|
346
|
+
mldSnoopEnable: false,
|
|
347
|
+
dhcpL2RelayEnable: false,
|
|
348
|
+
dhcpGuard: { enable: false },
|
|
349
|
+
dhcpv6Guard: { enable: false },
|
|
350
|
+
lanNetworkIpv6Config: { proto: 0, enable: 0 },
|
|
351
|
+
}),
|
|
352
|
+
};
|
|
353
|
+
return {
|
|
354
|
+
steps: [{ action: 'create', object: 'omada.lanNetwork', name, detail: vlanOnly ? `VLAN ${vlan}, switches only` : `VLAN ${vlan}, ${gateway}` }],
|
|
355
|
+
effects: [
|
|
356
|
+
vlanOnly ? `New VLAN ${vlan} '${name}' on the switches (no gateway address).` : `New network '${name}': VLAN ${vlan}, gateway ${gateway}, DHCP ${dhcp?.enabled ? `${dhcp.range?.from}-${dhcp.range?.to}` : 'off'}, IPv6 off.`,
|
|
357
|
+
'No device is on it yet: a Wi-Fi network or a switch port must carry this VLAN first.',
|
|
358
|
+
],
|
|
359
|
+
wouldLockOut: [],
|
|
360
|
+
reads: { networks: true },
|
|
361
|
+
execute: async (s) => {
|
|
362
|
+
await this.client.networkConfig.createLanNetwork(body, s.siteId);
|
|
363
|
+
const created = (await this.client.networkConfig.listLanNetworks(s.siteId)).find((n) => n.name === name);
|
|
364
|
+
if (!created)
|
|
365
|
+
throw new NetError('NOT_FOUND', `Created network '${name}' but could not find it again`);
|
|
366
|
+
return { applied: true, object: this.toNetwork(created), owned: [] };
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
buildNetworkUpdate(ref, set, snap) {
|
|
371
|
+
const current = resolve(snap.networks, ref, 'network');
|
|
372
|
+
const raw = snap.networksRaw.find((n) => String(n.id) === current.id);
|
|
373
|
+
if (set.purpose !== undefined && set.purpose !== current.purpose)
|
|
374
|
+
throw new NetError('INVALID', 'A network\'s purpose can\'t change; create a new network instead');
|
|
375
|
+
if (current.purpose === 'vlanOnly' && (set.gateway !== undefined || set.dhcp !== undefined)) {
|
|
376
|
+
throw new NetError('INVALID', `'${current.name}' is switch-only: it has no gateway and no DHCP to change`);
|
|
377
|
+
}
|
|
378
|
+
if (set.isolated)
|
|
379
|
+
throw unsupported('network isolation (it comes as rules in 2.4.6)', 'networks.isolation');
|
|
380
|
+
if (set.ipv6 === true && !current.ipv6)
|
|
381
|
+
throw unsupported('switching IPv6 on (do it in the controller web UI)', 'networks.ipv6');
|
|
382
|
+
const addressing = (set.vlan !== undefined && set.vlan !== current.vlan) || (set.gateway !== undefined && set.gateway !== current.gateway);
|
|
383
|
+
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`);
|
|
385
|
+
}
|
|
386
|
+
const name = set.name !== undefined ? checkName(set.name, MAX_NETWORK_NAME, 'Network') : current.name;
|
|
387
|
+
const vlan = set.vlan !== undefined ? checkVlan(set.vlan) : current.vlan;
|
|
388
|
+
const gateway = set.gateway !== undefined ? checkGateway(set.gateway) : current.gateway;
|
|
389
|
+
const others = snap.networks.filter((n) => n.id !== current.id);
|
|
390
|
+
checkNetworkUnique({ name: name !== current.name ? name : undefined, vlan: vlan !== current.vlan ? vlan : undefined, gateway: gateway !== current.gateway ? gateway : undefined }, others);
|
|
391
|
+
// An update names only what changes: the rest of the current DHCP settings (range, lease, DNS) stays.
|
|
392
|
+
// Every address pool the network has (Omada allows several; the model shows the first).
|
|
393
|
+
const pools = ((raw.dhcpSettingsVO ?? {}).ipRangePool ?? []).map((p) => ({ from: String(p.ipaddrStart), to: String(p.ipaddrEnd) }));
|
|
394
|
+
// An update names only what changes: unmentioned DHCP settings, and all pools unless a range is given, stay.
|
|
395
|
+
let dhcp;
|
|
396
|
+
if (set.dhcp !== undefined && gateway) {
|
|
397
|
+
if (set.dhcp.enabled) {
|
|
398
|
+
const ranges = set.dhcp.range ? [set.dhcp.range] : pools;
|
|
399
|
+
if (ranges.length === 0)
|
|
400
|
+
checkDhcp(gateway, set.dhcp); // refuses: DHCP on without a range
|
|
401
|
+
for (const range of ranges)
|
|
402
|
+
checkDhcp(gateway, { ...current.dhcp, ...set.dhcp, range });
|
|
403
|
+
dhcp = set.dhcp;
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
dhcp = { enabled: false };
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (set.dhcp === undefined && gateway !== current.gateway && gateway && current.dhcp?.enabled) {
|
|
410
|
+
// The kept pools are sent again as they are: each must fit the new subnet.
|
|
411
|
+
for (const range of pools) {
|
|
412
|
+
try {
|
|
413
|
+
checkDhcp(gateway, { enabled: true, range });
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
throw new NetError('INVALID', `The current DHCP range ${range.from}-${range.to} doesn't fit ${gateway}; give a new range with the gateway`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
const body = { ...pick(raw, NETWORK_FIELDS), name, vlan };
|
|
421
|
+
if (gateway)
|
|
422
|
+
body.gatewaySubnet = gateway;
|
|
423
|
+
const keptDhcp = pick((raw.dhcpSettingsVO ?? {}), DHCP_FIELDS);
|
|
424
|
+
if (gateway && current.gateway && gateway !== current.gateway && typeof keptDhcp.gateway === 'string' && keptDhcp.gateway !== '') {
|
|
425
|
+
// The router address DHCP hands out: follow the interface when it was the interface, else it must fit.
|
|
426
|
+
const oldIp = current.gateway.split('/')[0];
|
|
427
|
+
const newIp = gateway.split('/')[0];
|
|
428
|
+
if (keptDhcp.gateway === oldIp)
|
|
429
|
+
keptDhcp.gateway = newIp;
|
|
430
|
+
else if (!isIpv4(keptDhcp.gateway) || !inSubnet(ipToNumber(keptDhcp.gateway), gateway)) {
|
|
431
|
+
throw new NetError('INVALID', `The DHCP gateway ${keptDhcp.gateway} is outside ${gateway}; change it in the controller web UI first`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
if (dhcp?.dns !== undefined) {
|
|
435
|
+
// Given DNS servers replace the kept ones (an empty list = automatic).
|
|
436
|
+
delete keptDhcp.priDns;
|
|
437
|
+
delete keptDhcp.sndDns;
|
|
438
|
+
}
|
|
439
|
+
body.dhcpSettingsVO = dhcp ? { ...keptDhcp, ...dhcpBody(dhcp, keptDhcp) } : keptDhcp;
|
|
440
|
+
if (set.ipv6 === false && current.ipv6)
|
|
441
|
+
body.lanNetworkIpv6Config = { proto: 0, enable: 0 };
|
|
442
|
+
const disruptive = addressing || (dhcp !== undefined && !dhcp.enabled && current.dhcp?.enabled === true);
|
|
443
|
+
// Devices relying on IPv6 lose their connection when it is switched off.
|
|
444
|
+
const ipv6Off = set.ipv6 === false && current.ipv6;
|
|
445
|
+
const affected = disruptive || ipv6Off ? snap.clients.filter((c) => c.online && c.network && 'id' in c.network && c.network.id === current.id) : [];
|
|
446
|
+
const changes = describeNetworkChanges(current, { name, vlan, gateway, dhcp, ipv6: set.ipv6 });
|
|
447
|
+
if (changes.length === 0)
|
|
448
|
+
throw new NetError('INVALID', `Nothing to change on '${current.name}'`);
|
|
449
|
+
return {
|
|
450
|
+
steps: [{ action: 'update', object: 'omada.lanNetwork', name: current.name, detail: changes.join('; ') }],
|
|
451
|
+
effects: [
|
|
452
|
+
`'${current.name}': ${changes.join('; ')}.`,
|
|
453
|
+
...(disruptive ? ['Devices on this network lose their address until they reconnect.'] : []),
|
|
454
|
+
...(ipv6Off ? ['Devices on this network lose their IPv6 addresses; IPv4 keeps working.'] : []),
|
|
455
|
+
],
|
|
456
|
+
wouldLockOut: affected,
|
|
457
|
+
reads: { networks: true },
|
|
458
|
+
execute: async (s) => {
|
|
459
|
+
await this.client.networkConfig.updateLanNetwork(current.id, body, s.siteId);
|
|
460
|
+
const after = (await this.client.networkConfig.listLanNetworks(s.siteId)).find((n) => String(n.id) === current.id);
|
|
461
|
+
return { applied: true, ...(after ? { object: this.toNetwork(after) } : {}), owned: [] };
|
|
462
|
+
},
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
async buildNetworkDelete(ref, snap) {
|
|
466
|
+
const current = resolve(snap.networks, ref, 'network');
|
|
467
|
+
if (current.protected)
|
|
468
|
+
throw new NetError('PROTECTED', `'${current.name}' carries the controller or the primary network and can't be deleted`);
|
|
469
|
+
const users = await this.networkUsers(current, snap);
|
|
470
|
+
if (users.length)
|
|
471
|
+
throw new NetError('IN_USE', `'${current.name}' is used by ${users.join(', ')}; change those first`, { users });
|
|
472
|
+
const affected = snap.clients.filter((c) => c.online && c.network && 'id' in c.network && c.network.id === current.id);
|
|
473
|
+
return {
|
|
474
|
+
steps: [{ action: 'delete', object: 'omada.lanNetwork', name: current.name }],
|
|
475
|
+
effects: [`Network '${current.name}' (VLAN ${current.vlan}) is removed.`],
|
|
476
|
+
wouldLockOut: affected,
|
|
477
|
+
reads: { networks: true },
|
|
478
|
+
execute: async (s) => {
|
|
479
|
+
await this.client.networkConfig.deleteLanNetwork(current.id, s.siteId);
|
|
480
|
+
return { applied: true, owned: [] };
|
|
481
|
+
},
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
/** Everything that still references a network: SSIDs, port profiles, DHCP reservations, ACL rules. */
|
|
485
|
+
async networkUsers(network, snap) {
|
|
486
|
+
const users = [];
|
|
487
|
+
for (const s of snap.ssids)
|
|
488
|
+
if (this.ssidNetworkIds(s.detail, snap.networks).includes(network.id))
|
|
489
|
+
users.push(`Wi-Fi '${String(s.detail.name)}'`);
|
|
490
|
+
for (const p of await this.client.networkConfig.listLanProfiles(snap.siteId)) {
|
|
491
|
+
const ids = [p.nativeNetworkId, ...(p.tagNetworkIds ?? []), ...(p.untagNetworkIds ?? [])];
|
|
492
|
+
if (ids.includes(network.id))
|
|
493
|
+
users.push(`port profile '${String(p.name)}'`);
|
|
494
|
+
}
|
|
495
|
+
for (const r of await this.client.accessControl.listDhcpReservations(snap.siteId))
|
|
496
|
+
if (r.netId === network.id)
|
|
497
|
+
users.push(`DHCP reservation ${formatMac(r.mac)}`);
|
|
498
|
+
for (const [kind, list] of [['gateway', 'osg-acls'], ['switch', 'osw-acls'], ['access point', 'eap-acls']]) {
|
|
499
|
+
for (const a of await this.client.networkConfig.listAcls(list, snap.siteId)) {
|
|
500
|
+
const ids = [...(a.sourceType === 0 ? (a.sourceIds ?? []) : []), ...(a.destinationType === 0 ? (a.destinationIds ?? []) : [])];
|
|
501
|
+
if (ids.includes(network.id))
|
|
502
|
+
users.push(`${kind} rule '${String(a.description)}'`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return users;
|
|
506
|
+
}
|
|
507
|
+
// ---- Wi-Fi -------------------------------------------------------------------------------
|
|
508
|
+
async buildWifiCreate(value, snap) {
|
|
509
|
+
const name = checkName(value.name, MAX_WIFI_NAME, 'Wi-Fi');
|
|
510
|
+
if (snap.ssids.some((s) => String(s.detail.name).toLowerCase() === name.toLowerCase()))
|
|
511
|
+
throw new NetError('EXISTS', `A Wi-Fi network named '${name}' already exists`);
|
|
512
|
+
if (value.enabled === false)
|
|
513
|
+
throw unsupported('creating a Wi-Fi network switched off (use omada_setSsidEnabled afterwards)', 'wifi.create');
|
|
514
|
+
const network = wifiNetwork(snap.networks, value.network);
|
|
515
|
+
const wlanId = snap.ssids[0]?.wlanId ?? (await this.client.networkConfig.listSsidSummaries(snap.siteId)).find((w) => w.wlanId !== PSEUDO_WLAN_ID)?.wlanId;
|
|
516
|
+
if (!wlanId)
|
|
517
|
+
throw new NetError('NOT_FOUND', 'No WLAN group to add the Wi-Fi network to');
|
|
518
|
+
const template = snap.ssids[0]?.detail;
|
|
519
|
+
const macFilter = value.macFilter ? await this.macFilterBody(value.macFilter, snap) : undefined;
|
|
520
|
+
const body = {
|
|
521
|
+
name,
|
|
522
|
+
deviceType: typeof template?.deviceType === 'number' ? template.deviceType : 1,
|
|
523
|
+
band: bandBits(checkBands(value.bands)),
|
|
524
|
+
guestNetEnable: value.clientIsolation,
|
|
525
|
+
broadcast: !value.hidden,
|
|
526
|
+
mloEnable: false,
|
|
527
|
+
pmfMode: PMF[value.pmf],
|
|
528
|
+
enable11r: value.fastRoaming,
|
|
529
|
+
hidePwd: true,
|
|
530
|
+
// An open network with a MAC filter is created locked (WPA2, a password nobody knows) and only
|
|
531
|
+
// opened once the filter is in place, so it is never open without its filter.
|
|
532
|
+
...securityBody(value.security === 'open' && macFilter ? 'wpa2' : value.security, value.security === 'open' && !macFilter ? undefined : temporaryPassphrase()),
|
|
533
|
+
...vlanBody(network),
|
|
534
|
+
};
|
|
535
|
+
const openAfterFilter = value.security === 'open' && macFilter !== undefined;
|
|
536
|
+
return {
|
|
537
|
+
steps: [
|
|
538
|
+
{ action: 'create', object: 'omada.ssid', name, detail: `on '${network.name}' (VLAN ${network.vlan}), ${value.security}, bands ${value.bands.join('/')}` },
|
|
539
|
+
...(macFilter ? [{ action: 'update', object: 'omada.ssidMacFilter', name, detail: macFilter.summary }] : []),
|
|
540
|
+
...(openAfterFilter ? [{ action: 'update', object: 'omada.ssid', name, detail: 'security → open (after the filter is in place)' }] : []),
|
|
541
|
+
],
|
|
542
|
+
effects: [
|
|
543
|
+
`New Wi-Fi '${name}' on network '${network.name}'.`,
|
|
544
|
+
...(value.security === 'open'
|
|
545
|
+
? ['It is open: anyone in range can join.']
|
|
546
|
+
: [`It gets a random temporary password nobody knows, so nobody can join yet: set the real password in the controller web UI (Network Config → WLAN → ${name}).`]),
|
|
547
|
+
...(value.clientIsolation ? ['Omada guest mode: devices on it can\'t reach each other or private networks.'] : []),
|
|
548
|
+
],
|
|
549
|
+
wouldLockOut: [],
|
|
550
|
+
reads: { networks: true, ssids: true, macGroups: macFilter !== undefined },
|
|
551
|
+
execute: async (s) => {
|
|
552
|
+
await this.client.networkConfig.createSsid(wlanId, body, s.siteId);
|
|
553
|
+
const created = (await this.client.networkConfig.listSsidSummaries(s.siteId))
|
|
554
|
+
.filter((w) => w.wlanId !== PSEUDO_WLAN_ID)
|
|
555
|
+
.flatMap((w) => (w.ssidList ?? []).map((x) => ({ wlanId: w.wlanId, ...x })))
|
|
556
|
+
.find((x) => x.ssidName === name);
|
|
557
|
+
if (!created)
|
|
558
|
+
throw new NetError('NOT_FOUND', `Created Wi-Fi '${name}' but could not find it again`);
|
|
559
|
+
if (macFilter)
|
|
560
|
+
await this.client.networkConfig.updateSsidMacFilter(created.wlanId, created.ssidId, macFilter.body, s.siteId);
|
|
561
|
+
if (openAfterFilter) {
|
|
562
|
+
const locked = await this.client.networkConfig.getSsidDetail(created.wlanId, created.ssidId, s.siteId);
|
|
563
|
+
const open = { ...pick(locked, SSID_FIELDS), security: SECURITY_NONE };
|
|
564
|
+
delete open.pskSetting;
|
|
565
|
+
await this.client.networkConfig.updateSsidBasic(created.wlanId, created.ssidId, open, s.siteId);
|
|
566
|
+
}
|
|
567
|
+
const detail = await this.client.networkConfig.getSsidDetail(created.wlanId, created.ssidId, s.siteId);
|
|
568
|
+
return { applied: true, object: this.toWifi({ wlanId: created.wlanId, ssidId: created.ssidId, detail }, s.networks, await this.client.listMacGroups(s.siteId), s.alwaysProfileId), owned: [] };
|
|
569
|
+
},
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
async buildWifiUpdate(ref, set, passphrase, snap) {
|
|
573
|
+
const ssid = resolveSsid(snap.ssids, ref);
|
|
574
|
+
const groups = snap.macGroups;
|
|
575
|
+
const current = this.toWifi(ssid, snap.networks, groups, snap.alwaysProfileId);
|
|
576
|
+
if (set.enabled !== undefined && set.enabled !== current.enabled)
|
|
577
|
+
throw unsupported('switching a Wi-Fi network on or off here (use omada_setSsidEnabled)', 'wifi.enabled');
|
|
578
|
+
// RADIUS and per-device keys can't be resent through the basic config without losing them.
|
|
579
|
+
if ((current.security === 'enterprise' || current.security === 'ppsk') && set.security === undefined) {
|
|
580
|
+
throw new NetError('INVALID', `'${current.name}' uses ${current.security} security, which these tools can't keep; change it in the controller web UI`);
|
|
581
|
+
}
|
|
582
|
+
const raw = ssid.detail;
|
|
583
|
+
const body = pick(raw, SSID_FIELDS);
|
|
584
|
+
const changes = [];
|
|
585
|
+
let disconnects = false;
|
|
586
|
+
let isolates = false;
|
|
587
|
+
if (set.name !== undefined && set.name !== current.name) {
|
|
588
|
+
const name = checkName(set.name, MAX_WIFI_NAME, 'Wi-Fi');
|
|
589
|
+
if (snap.ssids.some((s) => s.ssidId !== ssid.ssidId && String(s.detail.name).toLowerCase() === name.toLowerCase()))
|
|
590
|
+
throw new NetError('EXISTS', `A Wi-Fi network named '${name}' already exists`);
|
|
591
|
+
body.name = name;
|
|
592
|
+
changes.push(`name → ${name}`);
|
|
593
|
+
disconnects = true;
|
|
594
|
+
}
|
|
595
|
+
if (set.bands !== undefined && bandBits(checkBands(set.bands)) !== Number(raw.band)) {
|
|
596
|
+
body.band = bandBits(set.bands);
|
|
597
|
+
changes.push(`bands → ${set.bands.join('/')}`);
|
|
598
|
+
disconnects = true;
|
|
599
|
+
}
|
|
600
|
+
if (set.network !== undefined) {
|
|
601
|
+
const network = wifiNetwork(snap.networks, set.network);
|
|
602
|
+
// Compare with every network the SSID maps to: narrowing a multi-network mapping is a change too.
|
|
603
|
+
const mapped = this.ssidNetworkIds(raw, snap.networks);
|
|
604
|
+
if (mapped.length !== 1 || mapped[0] !== network.id) {
|
|
605
|
+
Object.assign(body, vlanBody(network));
|
|
606
|
+
changes.push(`network → ${network.name}`);
|
|
607
|
+
disconnects = true;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
const rotate = passphrase?.kind === 'temporary';
|
|
611
|
+
let temporaryKey = false;
|
|
612
|
+
if ((set.security !== undefined && set.security !== current.security) || rotate) {
|
|
613
|
+
const security = set.security ?? current.security;
|
|
614
|
+
const existingKey = (raw.pskSetting ?? {}).securityKey;
|
|
615
|
+
// No shared password to keep (open, Enterprise, PPSK, or none stored): a temporary one nobody knows.
|
|
616
|
+
temporaryKey = security !== 'open' && (rotate || typeof existingKey !== 'string' || existingKey === '' || ['open', 'enterprise', 'ppsk'].includes(current.security));
|
|
617
|
+
const key = security === 'open' ? undefined : temporaryKey ? temporaryPassphrase() : existingKey;
|
|
618
|
+
Object.assign(body, securityBody(security, key));
|
|
619
|
+
changes.push(rotate ? 'new temporary password' : `security → ${security}`);
|
|
620
|
+
disconnects = true;
|
|
621
|
+
}
|
|
622
|
+
const toggles = [
|
|
623
|
+
['pmf', 'pmfMode', set.pmf !== undefined ? PMF[set.pmf] : undefined],
|
|
624
|
+
['clientIsolation', 'guestNetEnable', set.clientIsolation],
|
|
625
|
+
['hidden', 'broadcast', set.hidden !== undefined ? !set.hidden : undefined],
|
|
626
|
+
['fastRoaming', 'enable11r', set.fastRoaming],
|
|
627
|
+
];
|
|
628
|
+
for (const [key, field, value] of toggles) {
|
|
629
|
+
if (value !== undefined && value !== raw[field]) {
|
|
630
|
+
body[field] = value;
|
|
631
|
+
changes.push(`${key} → ${String(set[key])}`);
|
|
632
|
+
// Devices without PMF support can't join a "required" SSID: treat any PMF change as disconnecting.
|
|
633
|
+
if (key === 'pmf')
|
|
634
|
+
disconnects = true;
|
|
635
|
+
// Omada guest mode also cuts devices off from every private network (e.g. Home Assistant).
|
|
636
|
+
if (key === 'clientIsolation' && value === true)
|
|
637
|
+
isolates = true;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
const macFilter = set.macFilter !== undefined ? await this.macFilterChange(set.macFilter, current, snap) : undefined;
|
|
641
|
+
if (changes.length === 0 && !macFilter)
|
|
642
|
+
throw new NetError('INVALID', `Nothing to change on '${current.name}'`);
|
|
643
|
+
const connected = snap.clients.filter((c) => c.online && c.wifi && 'name' in c.wifi && c.wifi.name === current.name);
|
|
644
|
+
const lockOut = disconnects || isolates ? connected : macFilter?.lockOut(connected) ?? [];
|
|
645
|
+
return {
|
|
646
|
+
steps: [
|
|
647
|
+
...(changes.length ? [{ action: 'update', object: 'omada.ssid', name: current.name, detail: changes.join('; ') }] : []),
|
|
648
|
+
...(macFilter ? [{ action: 'update', object: 'omada.ssidMacFilter', name: current.name, detail: macFilter.summary }] : []),
|
|
649
|
+
],
|
|
650
|
+
effects: [
|
|
651
|
+
...(changes.length ? [`'${current.name}': ${changes.join('; ')}.`] : []),
|
|
652
|
+
...(macFilter ? [`'${current.name}' MAC filter: ${macFilter.summary}.`] : []),
|
|
653
|
+
...(disconnects ? ['Every device on it is disconnected and must join again.'] : []),
|
|
654
|
+
...(isolates ? ['Omada guest mode: devices on it lose access to each other and to every private network (e.g. Home Assistant).'] : []),
|
|
655
|
+
...(temporaryKey
|
|
656
|
+
? [`A random temporary password nobody knows was set: set the real password in the controller web UI (Network Config → WLAN → ${String(body.name ?? current.name)}).`]
|
|
657
|
+
: []),
|
|
658
|
+
],
|
|
659
|
+
wouldLockOut: lockOut,
|
|
660
|
+
reads: { networks: true, ssids: true, macGroups: macFilter !== undefined },
|
|
661
|
+
execute: async (s) => {
|
|
662
|
+
// Whenever the SSID ends up open (opened, or moved while open), its new filter goes in first.
|
|
663
|
+
const opening = body.security === SECURITY_NONE;
|
|
664
|
+
if (opening && macFilter)
|
|
665
|
+
await this.client.networkConfig.updateSsidMacFilter(ssid.wlanId, ssid.ssidId, macFilter.body, s.siteId);
|
|
666
|
+
if (changes.length)
|
|
667
|
+
await this.client.networkConfig.updateSsidBasic(ssid.wlanId, ssid.ssidId, body, s.siteId);
|
|
668
|
+
if (!opening && macFilter)
|
|
669
|
+
await this.client.networkConfig.updateSsidMacFilter(ssid.wlanId, ssid.ssidId, macFilter.body, s.siteId);
|
|
670
|
+
const detail = await this.client.networkConfig.getSsidDetail(ssid.wlanId, ssid.ssidId, s.siteId);
|
|
671
|
+
return { applied: true, object: this.toWifi({ ...ssid, detail }, s.networks, await this.client.listMacGroups(s.siteId), s.alwaysProfileId), owned: [] };
|
|
672
|
+
},
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
buildWifiDelete(ref, snap) {
|
|
676
|
+
const ssid = resolveSsid(snap.ssids, ref);
|
|
677
|
+
const name = String(ssid.detail.name);
|
|
678
|
+
if (snap.ssids.length === 1)
|
|
679
|
+
throw new NetError('PROTECTED', `'${name}' is the only Wi-Fi network; it can't be deleted here`);
|
|
680
|
+
const connected = snap.clients.filter((c) => c.online && c.wifi && 'name' in c.wifi && c.wifi.name === name);
|
|
681
|
+
return {
|
|
682
|
+
steps: [{ action: 'delete', object: 'omada.ssid', name }],
|
|
683
|
+
effects: [`Wi-Fi '${name}' is removed; its devices are disconnected.`],
|
|
684
|
+
wouldLockOut: connected,
|
|
685
|
+
reads: { ssids: true },
|
|
686
|
+
execute: async (s) => {
|
|
687
|
+
await this.client.networkConfig.deleteSsid(ssid.wlanId, ssid.ssidId, s.siteId);
|
|
688
|
+
return { applied: true, owned: [] };
|
|
689
|
+
},
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
async macFilterBody(filter, snap) {
|
|
693
|
+
const groups = snap.macGroups;
|
|
694
|
+
const group = resolve(groups.map((g) => ({ ...g, id: g.groupId })), filter.group, 'MAC group');
|
|
695
|
+
return {
|
|
696
|
+
body: { macFilterEnable: true, policy: filter.policy === 'allow' ? 1 : 0, macFilterId: group.groupId },
|
|
697
|
+
summary: `${filter.policy} list = ${group.name}`,
|
|
698
|
+
members: new Set((group.macAddressList ?? []).map((e) => normalizeMac(e.macAddress))),
|
|
699
|
+
policy: filter.policy,
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
async macFilterChange(filter, current, snap) {
|
|
703
|
+
if (filter === null) {
|
|
704
|
+
if (!current.macFilter)
|
|
705
|
+
return undefined;
|
|
706
|
+
return { body: { macFilterEnable: false }, summary: 'off', lockOut: () => [] };
|
|
707
|
+
}
|
|
708
|
+
if (!filter)
|
|
709
|
+
return undefined;
|
|
710
|
+
const built = await this.macFilterBody(filter, snap);
|
|
711
|
+
return {
|
|
712
|
+
body: built.body,
|
|
713
|
+
summary: built.summary,
|
|
714
|
+
lockOut: (connected) => connected.filter((c) => (built.policy === 'allow' ? !built.members.has(normalizeMac(c.mac)) : built.members.has(normalizeMac(c.mac)))),
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
// ---- helpers -----------------------------------------------------------------------------
|
|
719
|
+
function notYet(what, release) {
|
|
720
|
+
return new NetError('UNSUPPORTED', `Changing ${what} through the net_ tools arrives in ${release}`, { capability: what });
|
|
721
|
+
}
|
|
722
|
+
function unsupported(what, capability) {
|
|
723
|
+
return new NetError('UNSUPPORTED', `Omada can't do ${what} through its API`, { capability });
|
|
724
|
+
}
|
|
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
|
+
/** The network a Wi-Fi network may put its devices on: one with a gateway (and so an address for them). */
|
|
745
|
+
function wifiNetwork(networks, ref) {
|
|
746
|
+
const network = resolve(networks, ref, 'network');
|
|
747
|
+
if (network.purpose === 'vlanOnly')
|
|
748
|
+
throw new NetError('INVALID', `'${network.name}' is switch-only (no gateway, no DHCP); Wi-Fi clients would get no address`);
|
|
749
|
+
return network;
|
|
750
|
+
}
|
|
751
|
+
function isIpv4(value) {
|
|
752
|
+
try {
|
|
753
|
+
ipToNumber(value);
|
|
754
|
+
return true;
|
|
755
|
+
}
|
|
756
|
+
catch {
|
|
757
|
+
return false;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
function resolveSsid(ssids, ref) {
|
|
761
|
+
return resolve(ssids.map((s) => ({ ...s, id: s.ssidId, name: String(s.detail.name) })), ref, 'Wi-Fi network');
|
|
762
|
+
}
|
|
763
|
+
function bandBits(bands) {
|
|
764
|
+
return bands.reduce((bits, b) => bits | BAND_BITS[b], 0);
|
|
765
|
+
}
|
|
766
|
+
/** The model's name for an SSID's security (raw security + WPA-Personal version). */
|
|
767
|
+
function securityOf(detail, psk) {
|
|
768
|
+
if (detail.security === SECURITY_NONE)
|
|
769
|
+
return 'open';
|
|
770
|
+
if (detail.security === 2)
|
|
771
|
+
return 'enterprise';
|
|
772
|
+
if (detail.security === 4 || detail.security === 5)
|
|
773
|
+
return 'ppsk';
|
|
774
|
+
return { 1: 'wpa1', 2: 'wpa2', 3: 'wpa1wpa2', 4: 'wpa3' }[Number(psk.versionPsk)] ?? 'wpa2';
|
|
775
|
+
}
|
|
776
|
+
function securityBody(security, key) {
|
|
777
|
+
if (security === 'open')
|
|
778
|
+
return { security: SECURITY_NONE };
|
|
779
|
+
if (security === 'wpa2wpa3')
|
|
780
|
+
throw unsupported('WPA2/WPA3 mixed mode (choose wpa2 or wpa3)', 'wifi.security');
|
|
781
|
+
if (security !== 'wpa2' && security !== 'wpa3')
|
|
782
|
+
throw new NetError('INVALID', `${security} can't be set through the tools; choose wpa2 or wpa3`);
|
|
783
|
+
if (!key)
|
|
784
|
+
throw new NetError('INVALID', 'No password to keep for this Wi-Fi network');
|
|
785
|
+
return {
|
|
786
|
+
security: SECURITY_WPA_PERSONAL,
|
|
787
|
+
pskSetting: { securityKey: key, versionPsk: PSK_VERSION[security], encryptionPsk: ENCRYPTION_AES, gikRekeyPskEnable: false },
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
/** Untagged (primary, VLAN 1) or tagged into the network's VLAN. */
|
|
791
|
+
function vlanBody(network) {
|
|
792
|
+
if (network.vlan === 1)
|
|
793
|
+
return { vlanEnable: false, vlanSetting: { mode: 0, customConfig: {} } };
|
|
794
|
+
return { vlanEnable: false, vlanSetting: { mode: 1, customConfig: { customMode: 0, lanNetworkVlanIds: { [network.id]: [network.vlan] } } } };
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* DHCP fields for the model's settings. With `kept` (an update), what the request doesn't
|
|
798
|
+
* mention stays as it is: the lease, the DNS mode and servers, and anything the model
|
|
799
|
+
* doesn't describe (options, next server), which the caller merges underneath.
|
|
800
|
+
*/
|
|
801
|
+
function dhcpBody(dhcp, kept) {
|
|
802
|
+
if (!dhcp?.enabled)
|
|
803
|
+
return { enable: false };
|
|
804
|
+
const dns = dhcp.dns === undefined
|
|
805
|
+
? kept
|
|
806
|
+
? {}
|
|
807
|
+
: { dhcpns: 'auto' }
|
|
808
|
+
: dhcp.dns.length
|
|
809
|
+
? { dhcpns: 'manual', priDns: dhcp.dns[0], ...(dhcp.dns[1] ? { sndDns: dhcp.dns[1] } : {}) }
|
|
810
|
+
: { dhcpns: 'auto' };
|
|
811
|
+
return {
|
|
812
|
+
enable: true,
|
|
813
|
+
...(dhcp.range ? { ipRangePool: [{ ipaddrStart: dhcp.range.from, ipaddrEnd: dhcp.range.to }] } : {}),
|
|
814
|
+
...(dhcp.leaseMinutes !== undefined ? { leasetime: dhcp.leaseMinutes } : kept ? {} : { leasetime: 120 }),
|
|
815
|
+
...dns,
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
/** Whether omada_setSsidEnabled has switched this SSID off (radio off, around the clock). */
|
|
819
|
+
function radioOffAlways(detail, alwaysProfileId) {
|
|
820
|
+
const schedule = (detail.wlanSchedule ?? {});
|
|
821
|
+
return Boolean(alwaysProfileId) && schedule.wlanScheduleEnable === true && schedule.action === 0 && schedule.scheduleId === alwaysProfileId;
|
|
822
|
+
}
|
|
823
|
+
/** 32 random characters a WPA password accepts; set, never returned. */
|
|
824
|
+
function temporaryPassphrase() {
|
|
825
|
+
return randomBytes(24).toString('base64').replace(/[^A-Za-z0-9]/g, 'x');
|
|
826
|
+
}
|
|
827
|
+
function describeNetworkChanges(current, next) {
|
|
828
|
+
const out = [];
|
|
829
|
+
if (next.name !== current.name)
|
|
830
|
+
out.push(`name → ${next.name}`);
|
|
831
|
+
if (next.vlan !== current.vlan)
|
|
832
|
+
out.push(`VLAN → ${next.vlan}`);
|
|
833
|
+
if (next.gateway !== current.gateway)
|
|
834
|
+
out.push(`gateway → ${next.gateway}`);
|
|
835
|
+
if (next.dhcp && !next.dhcp.enabled)
|
|
836
|
+
out.push('DHCP → off');
|
|
837
|
+
if (next.dhcp?.enabled) {
|
|
838
|
+
const parts = [
|
|
839
|
+
...(next.dhcp.range ? [`range ${next.dhcp.range.from}-${next.dhcp.range.to}`] : []),
|
|
840
|
+
...(next.dhcp.leaseMinutes !== undefined ? [`lease ${next.dhcp.leaseMinutes} min`] : []),
|
|
841
|
+
...(next.dhcp.dns !== undefined ? [`DNS ${next.dhcp.dns.join(', ') || 'automatic'}`] : []),
|
|
842
|
+
];
|
|
843
|
+
out.push(`DHCP → ${parts.join(', ') || 'on'}`);
|
|
844
|
+
}
|
|
845
|
+
if (next.ipv6 === false && current.ipv6)
|
|
846
|
+
out.push('IPv6 → off');
|
|
847
|
+
return out;
|
|
848
|
+
}
|
|
849
|
+
function describeClients(clients) {
|
|
850
|
+
return clients.map((c) => `${c.name ?? '?'} (${c.mac})`).join(', ');
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* Fingerprint of what a plan read, secrets stripped (the Wi-Fi password is never hashed:
|
|
854
|
+
* a hash of a short secret leaks it to anyone who can guess).
|
|
855
|
+
*/
|
|
856
|
+
function fingerprint(snap, reads) {
|
|
857
|
+
const material = {
|
|
858
|
+
networks: reads.networks ? snap.networksRaw : undefined,
|
|
859
|
+
ssids: reads.ssids ? redactSecrets(snap.ssids) : undefined,
|
|
860
|
+
// A MAC filter plan resolved a group by name: a renamed or replaced group must not slip through.
|
|
861
|
+
macGroups: reads.macGroups ? snap.macGroups : undefined,
|
|
862
|
+
};
|
|
863
|
+
return createHash('sha256').update(JSON.stringify(material)).digest('hex').slice(0, 16);
|
|
864
|
+
}
|
|
865
|
+
//# sourceMappingURL=omada.js.map
|