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.
- package/CHANGELOG.md +15 -0
- package/README.md +70 -7
- package/dist/server/networking.d.ts +41 -0
- package/dist/server/networking.js +508 -0
- package/dist/server/networking_format.d.ts +38 -0
- package/dist/server/networking_format.js +651 -0
- package/dist/server/server.js +5 -1
- package/dist/server/session.d.ts +4 -1
- package/dist/server/session.js +23 -2
- package/dist/shared/networking.d.ts +178 -0
- package/dist/shared/networking.js +248 -0
- package/dist/shared/protocol.d.ts +18 -2
- package/dist/types/client/connection.d.ts +9 -1
- package/dist/types/client/icons.d.ts +6 -0
- package/dist/types/client/sdk/index.d.ts +2 -1
- package/dist/types/shared/networking.d.ts +178 -0
- package/dist/types/shared/protocol.d.ts +18 -2
- package/dist/www/assets/Joystick.js +1 -1
- package/dist/www/assets/RobotModelView.js +2 -2
- package/dist/www/assets/TerminalView.js +2 -2
- package/dist/www/assets/icons.js +1 -1
- package/dist/www/assets/index.css +1 -1
- package/dist/www/assets/index.js +42 -42
- package/dist/www/assets/shared/tassello-sdk.js +1 -1
- package/docs/plugins.md +2 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/server/server.js
CHANGED
|
@@ -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();
|