tassello 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,651 @@
1
+ // What nmcli prints, read, and the arguments it is given: for networking.ts,
2
+ // kept apart to be tested without NetworkManager.
3
+ //
4
+ // nmcli runs with --terse: tables are a line per row, their fields separated
5
+ // by colons (those of the values escaped, \:, as backslashes are, \\); the
6
+ // details of a device or connection are a "FIELD:value" line per field, the
7
+ // value as it is, and a blank line between devices or connections.
8
+ import { isInterfaceName, isUuid, needsPassword, settingsProblems, WRONG_PASSWORD, } from '../shared/networking.js';
9
+ // The fields asked of each, in this order (-f)
10
+ export const GENERAL_FIELDS = 'RUNNING,VERSION,STATE,CONNECTIVITY,NETWORKING,WIFI-HW,WIFI';
11
+ export const DEVICE_SECTIONS = 'GENERAL,CAPABILITIES,WIRED-PROPERTIES,WIFI-PROPERTIES,IP4,IP6';
12
+ export const CONNECTION_FIELDS = 'NAME,UUID,TYPE,TIMESTAMP,AUTOCONNECT,AUTOCONNECT-PRIORITY,READONLY,DEVICE,ACTIVE,STATE,FILENAME';
13
+ export const CONNECTION_SETTINGS = [
14
+ 'connection.id',
15
+ 'connection.uuid',
16
+ 'connection.type',
17
+ 'connection.interface-name',
18
+ 'connection.autoconnect',
19
+ 'connection.autoconnect-priority',
20
+ '802-11-wireless.ssid',
21
+ '802-11-wireless.mode',
22
+ '802-11-wireless.band',
23
+ '802-11-wireless.channel',
24
+ '802-11-wireless.hidden',
25
+ '802-11-wireless-security.key-mgmt',
26
+ '802-1x.identity',
27
+ 'vpn.service-type',
28
+ 'ipv4.method',
29
+ 'ipv4.addresses',
30
+ 'ipv4.gateway',
31
+ 'ipv4.dns',
32
+ ].join(',');
33
+ export const WIFI_FIELDS = 'SSID,SSID-HEX,BSSID,MODE,CHAN,FREQ,RATE,SIGNAL,SECURITY,DEVICE,ACTIVE';
34
+ // One line of a terse table: its fields, unescaped
35
+ export function terseFields(line) {
36
+ const fields = [];
37
+ let field = '';
38
+ for (let i = 0; i < line.length; i++) {
39
+ const c = line[i];
40
+ if (c === '\\' && i + 1 < line.length) {
41
+ field += line[++i];
42
+ }
43
+ else if (c === ':') {
44
+ fields.push(field);
45
+ field = '';
46
+ }
47
+ else {
48
+ field += c;
49
+ }
50
+ }
51
+ fields.push(field);
52
+ return fields;
53
+ }
54
+ function terseRows(output) {
55
+ return output
56
+ .split('\n')
57
+ .filter((line) => line.trim())
58
+ .map(terseFields);
59
+ }
60
+ // The details of devices or connections: a map per block, from the field
61
+ // (IP4.ADDRESS[2]: IP4.ADDRESS) to its values, in order
62
+ export function terseBlocks(output) {
63
+ return output
64
+ .split(/\n\s*\n/)
65
+ .map((block) => {
66
+ const fields = new Map();
67
+ for (const line of block.split('\n')) {
68
+ const colon = line.indexOf(':');
69
+ if (colon <= 0) {
70
+ continue;
71
+ }
72
+ const key = line.slice(0, colon).replace(/\[\d+\]$/, '');
73
+ fields.set(key, [...(fields.get(key) ?? []), line.slice(colon + 1)]);
74
+ }
75
+ return fields;
76
+ })
77
+ .filter((fields) => fields.size > 0);
78
+ }
79
+ function first(fields, key) {
80
+ return fields.get(key)?.[0] ?? '';
81
+ }
82
+ function yes(value) {
83
+ return value === 'yes' || value === 'on' || value === 'enabled';
84
+ }
85
+ // "100 (connected (externally))": 100, connected (externally)
86
+ function coded(value) {
87
+ const match = /^(\d+)\s*\((.*)\)$/.exec(value.trim());
88
+ return match ? { code: Number(match[1]), text: match[2] } : { code: 0, text: value.trim() };
89
+ }
90
+ // nmcli -f GENERAL_FIELDS general
91
+ export function parseGeneral(output) {
92
+ const [running = '', version = '', state = '', connectivity = '', networking = '', wifiHardware = '', wifi = ''] = terseRows(output)[0] ?? [];
93
+ return {
94
+ running: running === 'running',
95
+ version,
96
+ state,
97
+ connectivity,
98
+ networking: yes(networking),
99
+ wifiHardware: yes(wifiHardware),
100
+ wifi: yes(wifi),
101
+ };
102
+ }
103
+ // nmcli general permissions: "org.freedesktop.NetworkManager.network-control:yes"
104
+ export function parsePermissions(output) {
105
+ const granted = new Set(terseRows(output)
106
+ .filter(([, value]) => value === 'yes')
107
+ .map(([name]) => name.replace(/^org\.freedesktop\.NetworkManager\./, '')));
108
+ return {
109
+ control: granted.has('network-control'),
110
+ modify: granted.has('settings.modify.system'),
111
+ wifi: granted.has('enable-disable-wifi'),
112
+ scan: granted.has('wifi.scan'),
113
+ share: granted.has('wifi.share.protected') || granted.has('wifi.share.open'),
114
+ };
115
+ }
116
+ // nmcli -f DEVICE_SECTIONS device show
117
+ export function parseDevices(output) {
118
+ return terseBlocks(output)
119
+ .filter((fields) => first(fields, 'GENERAL.DEVICE'))
120
+ .map((fields) => {
121
+ const state = coded(first(fields, 'GENERAL.STATE'));
122
+ const reason = coded(first(fields, 'GENERAL.REASON'));
123
+ const carrier = fields.get('WIRED-PROPERTIES.CARRIER');
124
+ const mtu = Number(first(fields, 'GENERAL.MTU'));
125
+ const speed = first(fields, 'CAPABILITIES.SPEED');
126
+ return {
127
+ name: first(fields, 'GENERAL.DEVICE'),
128
+ type: first(fields, 'GENERAL.TYPE'),
129
+ state: state.code,
130
+ stateText: state.text,
131
+ reason: reason.code === 0 ? '' : reason.text,
132
+ connection: first(fields, 'GENERAL.CON-UUID') || null,
133
+ connectionName: first(fields, 'GENERAL.CONNECTION'),
134
+ external: state.text.includes('externally'),
135
+ software: yes(first(fields, 'GENERAL.IS-SOFTWARE')),
136
+ hwaddr: first(fields, 'GENERAL.HWADDR'),
137
+ mtu: mtu > 0 ? mtu : null,
138
+ carrier: carrier ? yes(carrier[0]) : null,
139
+ speed: speed === 'unknown' ? '' : speed,
140
+ driver: first(fields, 'GENERAL.DRIVER'),
141
+ vendor: first(fields, 'GENERAL.VENDOR'),
142
+ product: first(fields, 'GENERAL.PRODUCT'),
143
+ accessPoint: yes(first(fields, 'WIFI-PROPERTIES.AP')),
144
+ band24: yes(first(fields, 'WIFI-PROPERTIES.2GHZ')),
145
+ band5: yes(first(fields, 'WIFI-PROPERTIES.5GHZ')),
146
+ ipv4: fields.get('IP4.ADDRESS') ?? [],
147
+ ipv6: fields.get('IP6.ADDRESS') ?? [],
148
+ gateway: first(fields, 'IP4.GATEWAY'),
149
+ dns: fields.get('IP4.DNS') ?? [],
150
+ };
151
+ });
152
+ }
153
+ // Newer nmcli lists them as wifi and ethernet, older ones by their settings'
154
+ // names
155
+ function connectionType(type) {
156
+ return type === 'wifi' ? '802-11-wireless' : type === 'ethernet' ? '802-3-ethernet' : type;
157
+ }
158
+ export function connectionKind(type, mode) {
159
+ switch (connectionType(type)) {
160
+ case '802-11-wireless':
161
+ return mode === 'ap' ? 'hotspot' : 'wifi';
162
+ case '802-3-ethernet':
163
+ return 'ethernet';
164
+ case 'vpn':
165
+ case 'wireguard':
166
+ return 'vpn';
167
+ default:
168
+ return 'other';
169
+ }
170
+ }
171
+ // Of a saved Wi-Fi connection (802-11-wireless-security.key-mgmt): none is
172
+ // WEP; no security setting at all, open
173
+ export function keyManagementSecurity(keyManagement) {
174
+ switch (keyManagement) {
175
+ case 'none':
176
+ return 'wep';
177
+ case 'wpa-psk':
178
+ return 'wpa-psk';
179
+ case 'sae':
180
+ return 'sae';
181
+ case 'owe':
182
+ return 'owe';
183
+ case 'wpa-eap':
184
+ case 'wpa-eap-suite-b-192':
185
+ case 'ieee8021x':
186
+ return 'wpa-eap';
187
+ default:
188
+ return 'open';
189
+ }
190
+ }
191
+ // Of a network in range, as nmcli says it: WPA3 alone is SAE, with WPA2
192
+ // (transition mode) WPA2's password works
193
+ export function scanSecurity(text) {
194
+ if (text.includes('802.1X')) {
195
+ return 'wpa-eap';
196
+ }
197
+ if (/WPA[12]/.test(text)) {
198
+ return 'wpa-psk';
199
+ }
200
+ if (text.includes('WPA3')) {
201
+ return 'sae';
202
+ }
203
+ if (text.includes('WEP')) {
204
+ return 'wep';
205
+ }
206
+ if (text.includes('OWE')) {
207
+ return 'owe';
208
+ }
209
+ return 'open';
210
+ }
211
+ // "192.168.1.20/24, 192.168.1.21/24", "8.8.8.8,1.1.1.1"
212
+ function list(value) {
213
+ return value
214
+ .split(/[,\s]+/)
215
+ .map((item) => item.trim())
216
+ .filter((item) => item && item !== '--');
217
+ }
218
+ function settingNumber(value) {
219
+ const number = Number(value);
220
+ return Number.isFinite(number) ? number : 0;
221
+ }
222
+ // nmcli -f CONNECTION_FIELDS connection show, merged with the settings of
223
+ // each (nmcli -f CONNECTION_SETTINGS connection show uuid ...), by uuid. The
224
+ // devices tell which are another program's.
225
+ export function parseConnections(listOutput, settings, devices) {
226
+ const external = new Set(devices.filter((device) => device.external && device.connection).map((d) => d.connection));
227
+ return terseRows(listOutput)
228
+ .filter((row) => row.length >= 11)
229
+ .map(([name, uuid, type, timestamp, autoconnect, priority, readOnly, device, active, activeState, file]) => {
230
+ const own = settings.get(uuid) ?? new Map();
231
+ const setting = (key) => first(own, key);
232
+ const mode = setting('802-11-wireless.mode');
233
+ const kind = connectionKind(type, mode);
234
+ const wireless = kind === 'wifi' || kind === 'hotspot';
235
+ const keyManagement = setting('802-11-wireless-security.key-mgmt');
236
+ const band = setting('802-11-wireless.band');
237
+ const lastUsed = Number(timestamp);
238
+ return {
239
+ uuid,
240
+ name,
241
+ type: connectionType(type),
242
+ kind,
243
+ interface: setting('connection.interface-name'),
244
+ autoconnect: yes(autoconnect),
245
+ priority: settingNumber(priority),
246
+ lastUsed: lastUsed > 0 ? lastUsed * 1000 : null,
247
+ readOnly: yes(readOnly),
248
+ file,
249
+ device: yes(active) && device ? device : null,
250
+ activeState: yes(active) ? activeState || 'activated' : null,
251
+ external: external.has(uuid),
252
+ ssid: setting('802-11-wireless.ssid'),
253
+ security: wireless ? keyManagementSecurity(keyManagement) : null,
254
+ hidden: yes(setting('802-11-wireless.hidden')),
255
+ band: band === 'a' || band === 'bg' ? band : '',
256
+ channel: settingNumber(setting('802-11-wireless.channel')),
257
+ identity: setting('802-1x.identity'),
258
+ vpnService: setting('vpn.service-type').replace(/^org\.freedesktop\.NetworkManager\./, ''),
259
+ ipv4Method: setting('ipv4.method'),
260
+ addresses: list(setting('ipv4.addresses')),
261
+ gateway: setting('ipv4.gateway').replace(/^--$/, ''),
262
+ dns: list(setting('ipv4.dns')),
263
+ };
264
+ });
265
+ }
266
+ // Of nmcli -f CONNECTION_FIELDS connection show
267
+ export function connectionUuids(listOutput) {
268
+ return terseRows(listOutput)
269
+ .map((row) => row[1])
270
+ .filter((uuid) => uuid && isUuid(uuid));
271
+ }
272
+ // By uuid
273
+ export function settingsBlocks(output) {
274
+ return new Map(terseBlocks(output).map((fields) => [first(fields, 'connection.uuid'), fields]));
275
+ }
276
+ // The exact name, from its bytes: nmcli's SSID column is for reading, and
277
+ // not every name is UTF-8
278
+ function ssidFromHex(hex, shown) {
279
+ if (!/^([0-9a-f]{2})+$/i.test(hex)) {
280
+ return shown;
281
+ }
282
+ const bytes = Uint8Array.from(hex.match(/../g), (byte) => parseInt(byte, 16));
283
+ try {
284
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
285
+ }
286
+ catch {
287
+ return shown;
288
+ }
289
+ }
290
+ // nmcli -f WIFI_FIELDS device wifi list: the access points, by device and
291
+ // name, the strongest standing for the others; hidden ones (no name) left
292
+ // out. The device's own first, then the strongest.
293
+ export function parseWifiList(output) {
294
+ const networks = new Map();
295
+ for (const row of terseRows(output)) {
296
+ if (row.length < 11) {
297
+ continue;
298
+ }
299
+ const [shown, hex, bssid, mode, channel, frequency, rate, signal, securityText, device, active] = row;
300
+ const ssid = ssidFromHex(hex, shown);
301
+ if (!ssid || !device) {
302
+ continue;
303
+ }
304
+ const point = {
305
+ ssid,
306
+ device,
307
+ bssid,
308
+ signal: settingNumber(signal),
309
+ frequency: Number.parseInt(frequency, 10) || 0,
310
+ channel: settingNumber(channel),
311
+ rate,
312
+ securityText: securityText === '--' ? '' : securityText,
313
+ security: scanSecurity(securityText),
314
+ mode,
315
+ active: yes(active),
316
+ points: 1,
317
+ };
318
+ const key = `${device}\n${ssid}`;
319
+ const known = networks.get(key);
320
+ if (!known) {
321
+ networks.set(key, point);
322
+ continue;
323
+ }
324
+ // The one connected to, else the strongest
325
+ const better = point.active || (!known.active && point.signal > known.signal);
326
+ networks.set(key, {
327
+ ...(better ? point : known),
328
+ active: known.active || point.active,
329
+ points: known.points + 1,
330
+ });
331
+ }
332
+ return [...networks.values()].sort((a, b) => a.device.localeCompare(b.device) ||
333
+ Number(b.active) - Number(a.active) ||
334
+ b.signal - a.signal ||
335
+ a.ssid.localeCompare(b.ssid));
336
+ }
337
+ // The interface a page reaches the server through, from the address it
338
+ // reached (the socket's local address); null if none, or the robot itself
339
+ export function deviceOfAddress(devices, address) {
340
+ if (!address) {
341
+ return null;
342
+ }
343
+ // IPv4 in IPv6 (::ffff:192.168.1.20), and link-local scopes (fe80::1%wlan0)
344
+ const plain = address.replace(/^::ffff:(?=\d+\.\d+\.\d+\.\d+$)/i, '').replace(/%.*$/, '').toLowerCase();
345
+ if (plain === '::1' || plain.startsWith('127.')) {
346
+ return null;
347
+ }
348
+ const device = devices.find((d) => [...d.ipv4, ...d.ipv6].some((cidr) => cidr.split('/')[0].toLowerCase() === plain));
349
+ return device && device.type !== 'loopback' ? device.name : null;
350
+ }
351
+ // What nmcli said went wrong, for the page: without its "Error:" and its
352
+ // hints, and a word on what to do when the reason is a usual one
353
+ export function nmcliProblem(stderr, fallback) {
354
+ const lines = stderr
355
+ .split('\n')
356
+ .map((line) => line.trim())
357
+ .filter((line) => line && !line.startsWith('Hint:'));
358
+ let text = lines.join(' ').replace(/^Error:\s*/i, '');
359
+ text = text.replace(/^Connection activation failed:\s*/i, '').replace(/^\(\d+\)\s*/, '').replace(/\.$/, '');
360
+ if (!text) {
361
+ return fallback;
362
+ }
363
+ if (/not authorized|insufficient privileges|permission denied/i.test(text)) {
364
+ return `${text}: the server's user may not change the robot's networks (see the README: a polkit rule)`;
365
+ }
366
+ if (/secrets were required|no secrets|802\.1x supplicant/i.test(text)) {
367
+ return WRONG_PASSWORD;
368
+ }
369
+ if (/could not be found|no network with ssid/i.test(text)) {
370
+ return 'The network is not in range';
371
+ }
372
+ if (/IP configuration could not be reserved/i.test(text)) {
373
+ return 'The network gave no address (no DHCP server, or it did not answer)';
374
+ }
375
+ if (/timeout expired/i.test(text)) {
376
+ return 'NetworkManager took too long: it may still be trying';
377
+ }
378
+ if (/dnsmasq/i.test(text)) {
379
+ return `${text}: the access point needs dnsmasq (the dnsmasq-base package) to give addresses`;
380
+ }
381
+ return text;
382
+ }
383
+ function yesNo(value) {
384
+ return value ? 'yes' : 'no';
385
+ }
386
+ // The security settings: key management and secrets. password null: the
387
+ // secret saved is kept.
388
+ function securityArgs(settings) {
389
+ const password = settings.password;
390
+ const security = '802-11-wireless-security';
391
+ switch (settings.security) {
392
+ case 'open':
393
+ return [];
394
+ case 'owe':
395
+ return [`${security}.key-mgmt`, 'owe'];
396
+ case 'wep':
397
+ return [
398
+ `${security}.key-mgmt`,
399
+ 'none',
400
+ `${security}.wep-key-type`,
401
+ '1',
402
+ ...(password === null ? [] : [`${security}.wep-key0`, password]),
403
+ ];
404
+ case 'wpa-psk':
405
+ return [
406
+ `${security}.key-mgmt`,
407
+ 'wpa-psk',
408
+ ...(password === null ? [] : [`${security}.psk`, password]),
409
+ // As nmcli's own hotspots: WPA2 with AES only, which every client
410
+ // and driver of today handles
411
+ ...(settings.kind === 'hotspot'
412
+ ? [`${security}.proto`, 'rsn', `${security}.pairwise`, 'ccmp', `${security}.group`, 'ccmp']
413
+ : []),
414
+ ];
415
+ case 'sae':
416
+ return [`${security}.key-mgmt`, 'sae', ...(password === null ? [] : [`${security}.psk`, password])];
417
+ case 'wpa-eap':
418
+ return [
419
+ `${security}.key-mgmt`,
420
+ 'wpa-eap',
421
+ '802-1x.eap',
422
+ 'peap',
423
+ '802-1x.phase2-auth',
424
+ 'mschapv2',
425
+ '802-1x.identity',
426
+ settings.identity.trim(),
427
+ ...(password === null ? [] : ['802-1x.password', password]),
428
+ ];
429
+ }
430
+ }
431
+ function ipv4Args(settings) {
432
+ const { ipv4 } = settings;
433
+ const args = ['ipv4.method', ipv4];
434
+ args.push('ipv4.addresses', ipv4 === 'manual' || ipv4 === 'shared' ? settings.addresses.join(',') : '');
435
+ args.push('ipv4.gateway', ipv4 === 'manual' ? settings.gateway : '');
436
+ args.push('ipv4.dns', ipv4 === 'disabled' ? '' : settings.dns.join(','));
437
+ return args;
438
+ }
439
+ function wirelessArgs(settings) {
440
+ const args = ['802-11-wireless.ssid', settings.ssid, '802-11-wireless.hidden', yesNo(settings.hidden)];
441
+ if (settings.kind === 'hotspot') {
442
+ args.push('802-11-wireless.mode', 'ap', '802-11-wireless.band', settings.band);
443
+ args.push('802-11-wireless.channel', String(settings.band ? settings.channel : 0));
444
+ }
445
+ return args;
446
+ }
447
+ // nmcli connection add's arguments for the settings (checked already: see
448
+ // settingsProblems). The secrets are on nmcli's command line for the moment
449
+ // it takes to save them: activating the connection is another command.
450
+ export function createArgs(settings) {
451
+ const wireless = settings.kind !== 'ethernet';
452
+ const args = [
453
+ 'connection',
454
+ 'add',
455
+ 'type',
456
+ wireless ? 'wifi' : 'ethernet',
457
+ 'con-name',
458
+ settings.name.trim(),
459
+ // Not bound to an interface unless asked: any Wi-Fi adapter joins it
460
+ ...(settings.interface ? ['connection.interface-name', settings.interface] : []),
461
+ 'connection.autoconnect',
462
+ yesNo(settings.autoconnect),
463
+ 'connection.autoconnect-priority',
464
+ String(settings.priority),
465
+ ];
466
+ if (wireless) {
467
+ args.push(...wirelessArgs(settings), ...securityArgs(settings));
468
+ }
469
+ args.push(...ipv4Args(settings));
470
+ // An access point gives IPv4 addresses only
471
+ if (settings.kind === 'hotspot') {
472
+ args.push('ipv6.method', 'ignore');
473
+ }
474
+ return args;
475
+ }
476
+ // What to change of the saved connection to make it the settings: only
477
+ // what differs, so that what the widget does not show is kept
478
+ export function modification(current, settings) {
479
+ const general = [];
480
+ const name = settings.name.trim();
481
+ if (name !== current.name) {
482
+ general.push('connection.id', name);
483
+ }
484
+ if (settings.autoconnect !== current.autoconnect) {
485
+ general.push('connection.autoconnect', yesNo(settings.autoconnect));
486
+ }
487
+ if (settings.priority !== current.priority) {
488
+ general.push('connection.autoconnect-priority', String(settings.priority));
489
+ }
490
+ const args = [];
491
+ if (settings.interface !== current.interface) {
492
+ args.push('connection.interface-name', settings.interface);
493
+ }
494
+ if (settings.kind !== 'ethernet') {
495
+ const wireless = wirelessArgs(settings);
496
+ const before = wirelessArgs({
497
+ ...settings,
498
+ ssid: current.ssid,
499
+ hidden: current.hidden,
500
+ band: current.band,
501
+ channel: current.channel,
502
+ });
503
+ if (wireless.join('\n') !== before.join('\n')) {
504
+ args.push(...wireless);
505
+ }
506
+ const was = current.security ?? 'open';
507
+ const securityChanged = settings.security !== was ||
508
+ settings.password !== null ||
509
+ (settings.security === 'wpa-eap' && settings.identity.trim() !== current.identity);
510
+ if (securityChanged) {
511
+ if (was === 'wpa-eap' && settings.security !== 'wpa-eap') {
512
+ args.push('remove', '802-1x');
513
+ }
514
+ if (settings.security === 'open') {
515
+ if (was !== 'open') {
516
+ args.push('remove', '802-11-wireless-security');
517
+ }
518
+ }
519
+ else {
520
+ args.push(...securityArgs(settings));
521
+ }
522
+ }
523
+ }
524
+ const ipv4 = ipv4Args(settings);
525
+ const ipv4Before = ipv4Args({
526
+ ...settings,
527
+ ipv4: current.ipv4Method,
528
+ addresses: current.addresses,
529
+ gateway: current.gateway,
530
+ dns: current.dns,
531
+ });
532
+ if (ipv4.join('\n') !== ipv4Before.join('\n')) {
533
+ args.push(...ipv4);
534
+ }
535
+ return { args: [...general, ...args], reactivate: args.length > 0 };
536
+ }
537
+ // The secrets of a connection, to change a password found wrong
538
+ export function passwordArgs(connection, password) {
539
+ switch (connection.security) {
540
+ case 'wpa-eap':
541
+ return ['802-1x.password', password];
542
+ case 'wep':
543
+ return ['802-11-wireless-security.wep-key0', password];
544
+ default:
545
+ return ['802-11-wireless-security.psk', password];
546
+ }
547
+ }
548
+ const KINDS = ['wifi', 'hotspot', 'ethernet'];
549
+ const SECURITIES = ['open', 'owe', 'wep', 'wpa-psk', 'sae', 'wpa-eap'];
550
+ const METHODS = ['auto', 'manual', 'shared', 'disabled'];
551
+ function uuidOf(value) {
552
+ if (typeof value !== 'string' || !isUuid(value)) {
553
+ throw new Error('Invalid connection');
554
+ }
555
+ return value.toLowerCase();
556
+ }
557
+ function deviceOf(value) {
558
+ if (value === null || value === undefined || value === '') {
559
+ return null;
560
+ }
561
+ if (typeof value !== 'string' || !isInterfaceName(value)) {
562
+ throw new Error('Invalid interface');
563
+ }
564
+ return value;
565
+ }
566
+ function oneOf(value, values, what) {
567
+ if (!values.includes(value)) {
568
+ throw new Error(`Invalid ${what}`);
569
+ }
570
+ return value;
571
+ }
572
+ function addressList(value, what) {
573
+ if (!Array.isArray(value) || !value.every((item) => typeof item === 'string')) {
574
+ throw new Error(`Invalid ${what}`);
575
+ }
576
+ return value.map((item) => item.trim()).filter(Boolean);
577
+ }
578
+ // The settings a page sent, of the right types and checked: throws their
579
+ // first problem
580
+ export function checkSettings(value, creating) {
581
+ const s = (value !== null && typeof value === 'object' ? value : {});
582
+ const text = (key) => (typeof s[key] === 'string' ? s[key] : '');
583
+ const integer = (key) => (typeof s[key] === 'number' && Number.isInteger(s[key]) ? s[key] : NaN);
584
+ const kind = oneOf(s.kind, KINDS, 'kind of connection');
585
+ const wireless = kind !== 'ethernet';
586
+ const settings = {
587
+ kind,
588
+ name: text('name').trim(),
589
+ interface: text('interface').trim(),
590
+ autoconnect: s.autoconnect !== false,
591
+ priority: integer('priority'),
592
+ // As typed: a name or a password may start or end with spaces
593
+ ssid: wireless ? text('ssid') : '',
594
+ hidden: wireless && s.hidden === true,
595
+ security: wireless ? oneOf(s.security, SECURITIES, 'security') : 'open',
596
+ password: wireless && typeof s.password === 'string' ? s.password : null,
597
+ identity: wireless ? text('identity').trim() : '',
598
+ band: kind === 'hotspot' ? oneOf(s.band ?? '', ['', 'a', 'bg'], 'band') : '',
599
+ channel: kind === 'hotspot' ? integer('channel') : 0,
600
+ ipv4: oneOf(s.ipv4, METHODS, 'IPv4 method'),
601
+ addresses: addressList(s.addresses ?? [], 'addresses'),
602
+ gateway: text('gateway').trim(),
603
+ dns: addressList(s.dns ?? [], 'DNS servers'),
604
+ };
605
+ if (!needsPassword(settings.security)) {
606
+ settings.password = null;
607
+ }
608
+ const problem = Object.values(settingsProblems(settings, creating))[0];
609
+ if (problem) {
610
+ throw new Error(problem);
611
+ }
612
+ return settings;
613
+ }
614
+ // A request a page sent, of the right types and checked: throws why not
615
+ export function checkRequest(value) {
616
+ const request = (value !== null && typeof value === 'object' ? value : {});
617
+ switch (request.action) {
618
+ case 'connect':
619
+ if (request.password !== null && request.password !== undefined && typeof request.password !== 'string') {
620
+ throw new Error('Invalid password');
621
+ }
622
+ return {
623
+ action: 'connect',
624
+ uuid: uuidOf(request.uuid),
625
+ device: deviceOf(request.device),
626
+ password: typeof request.password === 'string' ? request.password : null,
627
+ };
628
+ case 'disconnect':
629
+ case 'delete':
630
+ case 'secrets':
631
+ return { action: request.action, uuid: uuidOf(request.uuid) };
632
+ case 'create':
633
+ return {
634
+ action: 'create',
635
+ settings: checkSettings(request.settings, true),
636
+ connect: request.connect === true,
637
+ device: deviceOf(request.device),
638
+ };
639
+ case 'modify':
640
+ return { action: 'modify', uuid: uuidOf(request.uuid), settings: checkSettings(request.settings, false) };
641
+ case 'wifi':
642
+ if (typeof request.enabled !== 'boolean') {
643
+ throw new Error('Wi-Fi on or off?');
644
+ }
645
+ return { action: 'wifi', enabled: request.enabled };
646
+ case 'rescan':
647
+ return { action: 'rescan', device: deviceOf(request.device) };
648
+ default:
649
+ throw new Error(`Unknown network action "${String(request.action)}"`);
650
+ }
651
+ }
@@ -17,6 +17,7 @@ import { BehaviorTrees } from './behavior_trees.js';
17
17
  import { CommandRunner } from './commands.js';
18
18
  import { Diagnostics } from './diagnostics.js';
19
19
  import { createRequestHandler } from './http.js';
20
+ import { Networking } from './networking.js';
20
21
  import { PanelStore } from './panels.js';
21
22
  import { Paths } from './paths.js';
22
23
  import { findPlugins, PluginHost, readPlugin } from './plugins.js';
@@ -92,6 +93,7 @@ export async function startServer(options) {
92
93
  const rosout = new Rosout(node, logger);
93
94
  const diagnostics = new Diagnostics(node, logger);
94
95
  const terminals = new Terminals(logger);
96
+ const networking = new Networking(logger);
95
97
  // Next to the panels
96
98
  const uploadsDir = path.join(path.dirname(panelsDir), 'images');
97
99
  const behaviorTrees = new BehaviorTrees(node, logger, uploadsDir);
@@ -139,11 +141,12 @@ export async function startServer(options) {
139
141
  rosout,
140
142
  diagnostics,
141
143
  terminals,
144
+ networking,
142
145
  behaviorTrees,
143
146
  behaviorTreeFiles,
144
147
  plugins: host,
145
148
  hostname: os.hostname(),
146
- }, ownPage(req.headers));
149
+ }, ownPage(req.headers), req.socket.localAddress ?? null);
147
150
  sessions.add(session);
148
151
  logger.debug(`Page connected from ${req.socket.remoteAddress}`);
149
152
  let alive = true;
@@ -176,6 +179,7 @@ export async function startServer(options) {
176
179
  commands.stopAll();
177
180
  systemd.stopAll();
178
181
  terminals.stopAll();
182
+ networking.stopAll();
179
183
  // Stops the robot if a page was driving it, while the node still runs
180
184
  for (const session of sessions) {
181
185
  session.close();