Line data Source code
1 : /*
2 : * Copyright (C) 2021 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 37 : import React, { useState, useContext, useEffect } from 'react';
7 37 : import cockpit from 'cockpit';
8 37 : import * as ipaddr from "ipaddr.js";
9 : import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
10 : import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
11 : import { FormFieldGroup, FormFieldGroupHeader, FormGroup } from "@patternfly/react-core/dist/esm/components/Form/index.js";
12 : import { FormSelect, FormSelectOption } from "@patternfly/react-core/dist/esm/components/FormSelect/index.js";
13 : import { Grid } from "@patternfly/react-core/dist/esm/layouts/Grid/index.js";
14 : import { Switch } from "@patternfly/react-core/dist/esm/components/Switch/index.js";
15 : import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput/index.js";
16 : import { Tooltip } from "@patternfly/react-core/dist/esm/components/Tooltip/index.js";
17 :
18 : import { PlusIcon, TrashIcon } from '@patternfly/react-icons';
19 :
20 : import { NetworkModal, dialogSave } from './dialogs-common.jsx';
21 : import { ModelContext } from './model-context.jsx';
22 : import { useDialogs } from "dialogs.jsx";
23 : import { ip_first_usable_address, ip_network_address, validate_ip, ip_prefix_from_text, ip4_prefix_from_text } from './utils.js';
24 :
25 37 : const _ = cockpit.gettext;
26 :
27 37 : const ip_method_choices = [
28 37 : { choice: 'auto', title: _("Automatic") },
29 37 : { choice: 'dhcp', title: _("Automatic (DHCP only)") },
30 37 : { choice: 'link-local', title: _("Link local") },
31 37 : { choice: 'manual', title: _("Manual") },
32 37 : { choice: 'ignore', title: _("Ignore") },
33 37 : { choice: 'shared', title: _("Shared") },
34 37 : { choice: 'disabled', title: _("Disabled") }
35 37 : ];
36 :
37 37 : const supported_ipv4_methods = ['auto', 'link-local', 'manual', 'shared', 'disabled'];
38 : // NM only supports a subset of IPv4 and IPv6 methods for wireguard
39 : // See: https://gitlab.freedesktop.org/NetworkManager/NetworkManager/-/blob/1.42.8/src/libnm-core-impl/nm-setting-wireguard.c#L1723
40 37 : const wg_supported_ipv4_methods = ['manual', 'disabled'];
41 37 : const wg_supported_ipv6_methods = ['link-local', 'manual', 'ignored', 'disabled'];
42 :
43 30 : export function get_ip_method_choices(topic, device_type) {
44 28 : if (topic === 'ipv4') {
45 28 : if (device_type === 'wireguard')
46 1 : return ip_method_choices.filter(item => wg_supported_ipv4_methods.includes(item.choice));
47 28 : return ip_method_choices.filter(item => supported_ipv4_methods.includes(item.choice));
48 28 : }
49 :
50 30 : if (device_type === 'wireguard')
51 1 : return ip_method_choices.filter(item => wg_supported_ipv6_methods.includes(item.choice));
52 :
53 : // IPv6 supports all the choices
54 30 : return ip_method_choices;
55 30 : }
56 :
57 7 : export const IpSettingsDialog = ({ topic, connection, dev, settings }) => {
58 7 : const Dialogs = useDialogs();
59 7 : const idPrefix = "network-ip-settings";
60 7 : const model = useContext(ModelContext);
61 :
62 7 : const params = settings[topic];
63 7 : const [addresses, setAddresses] = useState(params.address_data);
64 7 : const [defaultGateway, setDefaultGateway] = useState(params.gateway);
65 7 : const [gatewaySetExplicitly, setGatewaySetExplicitly] = useState(false);
66 7 : const [dialogError, setDialogError] = useState(undefined);
67 0 : const [dns, setDns] = useState(params.dns_data || []);
68 0 : const [dnsSearch, setDnsSearch] = useState(params.dns_search || []);
69 7 : const [ignoreAutoDns, setIgnoreAutoDns] = useState(params.ignore_auto_dns);
70 7 : const [ignoreAutoRoutes, setIgnoreAutoRoutes] = useState(params.ignore_auto_routes);
71 7 : const [method, setMethod] = useState(params.method);
72 7 : const [routes, setRoutes] = useState(params.route_data);
73 :
74 : // The link local, shared, and disabled methods can't take any
75 : // addresses, dns servers, or dns search domains. Routes,
76 : // however, are ok, even for "disabled" and "ignored". But
77 : // since that doesn't make sense, we remove routes as well for
78 : // these methods.
79 7 : const isOff = (method == "disabled" || method == "ignore");
80 7 : const canHaveExtra = !(method == "link-local" || method == "shared" || isOff);
81 :
82 : // The auto_*_btns only make sense when the address method
83 : // is "auto" or "dhcp".
84 7 : const canAuto = (method == "auto" || method == "dhcp");
85 :
86 4 : const prefixText = (topic == "ipv4") ? _("Prefix length or netmask") : _("Prefix length");
87 :
88 7 : useEffect(() => {
89 : // The manual method needs at least one address
90 7 : if (method == 'manual' && addresses.length == 0)
91 7 : setAddresses([{ address: "", prefix: "" }]);
92 :
93 3 : if (!canHaveExtra) {
94 3 : setAddresses([]);
95 3 : setDns([]);
96 3 : setDnsSearch([]);
97 3 : }
98 :
99 7 : if (isOff)
100 2 : setRoutes([]);
101 7 : }, [method, addresses.length, canHaveExtra, isOff]);
102 :
103 7 : const onSubmit = (_ev) => {
104 7 : const createSettingsObj = () => ({
105 7 : ...settings,
106 7 : [topic]: {
107 7 : ...settings[topic],
108 7 : method,
109 7 : address_data: addresses,
110 7 : gateway: defaultGateway,
111 7 : dns_data: dns,
112 7 : dns_search: dnsSearch,
113 7 : route_data: routes,
114 7 : ignore_auto_dns: ignoreAutoDns,
115 7 : ignore_auto_routes: ignoreAutoRoutes,
116 7 : }
117 7 : });
118 :
119 7 : dialogSave({
120 7 : model,
121 7 : dev,
122 7 : connection,
123 7 : settings: createSettingsObj(),
124 7 : setDialogError,
125 7 : onClose: Dialogs.close,
126 7 : });
127 7 : };
128 :
129 7 : const ipDefaultPrefix = (address) => {
130 1 : if (address.kind() === "ipv6") {
131 1 : return "64";
132 1 : }
133 :
134 : // use classful IPv4 prefixes when only host address is specified
135 7 : const octets = address.octets;
136 7 : if (octets[0] >= 0 && octets[0] <= 127) {
137 7 : return "8";
138 1 : } else if (octets[0] >= 128 && octets[0] <= 191) {
139 1 : return "16";
140 1 : } else if (octets[0] >= 192 && octets[0] <= 223) {
141 2 : return "24";
142 2 : }
143 :
144 1 : return "";
145 7 : };
146 :
147 7 : const addressHelper = (address_str, prefix_str, i, prefixField) => {
148 7 : const config = { address: address_str, prefix: prefix_str };
149 :
150 7 : if (!validate_ip(address_str)) {
151 7 : return config;
152 7 : }
153 :
154 7 : const address = ipaddr.parse(address_str);
155 1 : if (address.kind() !== topic) {
156 1 : return config;
157 1 : }
158 :
159 7 : if (prefix_str === "" && !prefixField) {
160 7 : config.prefix = ipDefaultPrefix(address);
161 7 : }
162 :
163 : // prefix_str can contain prefix or IPv4 subnet mask
164 7 : let numericPrefix;
165 7 : try {
166 2 : numericPrefix = (address.kind() === "ipv4") ? ip4_prefix_from_text(config.prefix) : ip_prefix_from_text(config.prefix);
167 2 : } catch (_e) {
168 2 : return config;
169 2 : }
170 :
171 : // do not set gateway for last three prefixes
172 : // /30 and /126 only has two usable addresses
173 : // /31 and /127 is a point-to-point link with no gateway
174 : // /32 and /128 is a single host address
175 2 : const maxPrefix = (address.kind() === "ipv4") ? 30 : 126;
176 :
177 7 : if (i === 0 && numericPrefix < maxPrefix && !gatewaySetExplicitly) {
178 7 : const netAddr = ip_network_address(address, numericPrefix);
179 7 : const firstAddr = ip_first_usable_address(address, numericPrefix);
180 7 : const addrCompactStr = address.toString();
181 :
182 : // do not set the default gateway automatically if the host address
183 : // is the first address in the subnet or network address
184 7 : if (firstAddr !== null && addrCompactStr !== firstAddr &&
185 7 : netAddr !== null && netAddr !== addrCompactStr) {
186 7 : setDefaultGateway(firstAddr);
187 2 : } else {
188 2 : setDefaultGateway("");
189 2 : }
190 2 : } else if (!gatewaySetExplicitly) {
191 : // reset
192 2 : setDefaultGateway("");
193 2 : }
194 :
195 7 : return config;
196 7 : };
197 :
198 0 : const removeAddress = (i) => {
199 : // also reset gateway when removing the last address
200 0 : if (addresses.length === 1) {
201 0 : setDefaultGateway("");
202 0 : setGatewaySetExplicitly(false);
203 0 : }
204 :
205 0 : setAddresses(addresses.filter((_, index) => index !== i));
206 0 : };
207 :
208 : // Prefer device type if the device exists, otherwise fallback to a connection type
209 : // of an existing connection that is down in which case the device may not exist.
210 1 : const deviceType = dev?.DeviceType ?? connection?.Settings.connection.type;
211 :
212 7 : return (
213 7 : <NetworkModal dialogError={dialogError}
214 7 : idPrefix={idPrefix}
215 7 : onSubmit={onSubmit}
216 4 : title={topic == "ipv4" ? _("IPv4 settings") : _("IPv6 settings")}
217 7 : isFormHorizontal={false}
218 : >
219 7 : <FormFieldGroup
220 7 : data-field='addresses'
221 7 : header={
222 7 : <FormFieldGroupHeader
223 7 : titleText={{ text: _("Addresses") }}
224 7 : actions={
225 7 : <Flex>
226 7 : <FormSelect className="network-ip-settings-method"
227 7 : id={idPrefix + "-select-method"}
228 7 : aria-label={_("Select method")}
229 7 : onChange={(_, val) => setMethod(val)}
230 7 : value={method}>
231 7 : {get_ip_method_choices(topic, deviceType).map(choice => <FormSelectOption value={choice.choice} label={choice.title} key={choice.choice} />)}
232 7 : </FormSelect>
233 7 : <Tooltip content={_("Add address")}>
234 7 : <Button icon={<PlusIcon />} variant="secondary"
235 7 : isDisabled={!canHaveExtra}
236 1 : onClick={() => setAddresses([...addresses, { address: "", prefix: "" }])}
237 7 : id={idPrefix + "-address-add"}
238 7 : aria-label={_("Add address")} />
239 7 : </Tooltip>
240 7 : </Flex>
241 : }
242 7 : />
243 : }
244 : >
245 7 : <Grid hasGutter>
246 7 : {addresses.map((address, i) => {
247 7 : return (
248 7 : <React.Fragment key={i}>
249 7 : <FormGroup fieldId={idPrefix + "-address-" + i} label={_("Address")} className="pf-m-6-col-on-sm">
250 7 : <TextInput id={idPrefix + "-address-" + i} value={address.address} onChange={(_event, value) => setAddresses(
251 7 : addresses.map((item, index) =>
252 7 : i === index
253 7 : ? addressHelper(value, item.prefix, i, false)
254 0 : : item
255 7 : ))} />
256 7 : </FormGroup>
257 7 : <FormGroup fieldId={idPrefix + "-netmask-" + i} label={prefixText} className="pf-m-6-col-on-sm">
258 7 : <TextInput id={idPrefix + "-netmask-" + i} value={address.prefix} onChange={(_event, value) => setAddresses(
259 7 : addresses.map((item, index) =>
260 7 : i === index
261 7 : ? addressHelper(item.address, value, i, true)
262 0 : : item
263 7 : ))} />
264 7 : </FormGroup>
265 7 : <FormGroup className="pf-m-1-col-on-sm remove-button-group">
266 7 : <Button variant='plain'
267 7 : isDisabled={method == 'manual' && i == 0}
268 0 : onClick={() => removeAddress(i)}
269 7 : aria-label={_("Remove item")}
270 7 : icon={<TrashIcon />} />
271 7 : </FormGroup>
272 7 : </React.Fragment>
273 : );
274 7 : })}
275 7 : {addresses.length > 0 &&
276 7 : <FormGroup fieldId={idPrefix + "-gateway"} label={_("Gateway")}>
277 7 : <TextInput id={idPrefix + "-gateway"}
278 7 : value={defaultGateway}
279 2 : onChange={(_event, value) => { setDefaultGateway(value); setGatewaySetExplicitly(true) }}
280 7 : />
281 7 : </FormGroup>
282 : }
283 7 : </Grid>
284 7 : </FormFieldGroup>
285 7 : <FormFieldGroup
286 7 : data-field='dns'
287 7 : header={
288 7 : <FormFieldGroupHeader
289 7 : titleText={{ text: _("DNS") }}
290 7 : actions={
291 7 : <Flex alignItems={{ default: 'alignItemsCenter' }}>
292 7 : <Switch
293 7 : isChecked={!ignoreAutoDns}
294 7 : isDisabled={!canAuto}
295 2 : onChange={(_event, value) => setIgnoreAutoDns(!value)}
296 7 : label={_("Automatic")} />
297 7 : <Tooltip content={_("Add DNS server")}>
298 7 : <Button icon={<PlusIcon />} variant="secondary"
299 7 : isDisabled={!canHaveExtra}
300 3 : onClick={() => setDns([...dns, ""])}
301 7 : id={idPrefix + "-dns-add"}
302 7 : aria-label={_("Add DNS server")} />
303 7 : </Tooltip>
304 7 : </Flex>
305 : }
306 7 : />
307 : }
308 : >
309 3 : {dns.map((server, i) => {
310 3 : return (
311 3 : <Grid key={i} hasGutter>
312 3 : <FormGroup fieldId={idPrefix + "-dns-server-" + i} label={_("Server")}>
313 3 : <TextInput id={idPrefix + "-dns-server-" + i} value={server} onChange={(_event, value) => setDns(
314 3 : dns.map((item, index) =>
315 3 : i === index
316 3 : ? value
317 0 : : item
318 3 : ))} />
319 3 : </FormGroup>
320 3 : <FormGroup className="pf-m-1-col-on-sm remove-button-group">
321 3 : <Button variant='plain'
322 3 : size="sm"
323 0 : onClick={() => setDns(dns.filter((_, index) => index !== i))}
324 3 : aria-label={_("Remove item")}
325 3 : icon={<TrashIcon />} />
326 3 : </FormGroup>
327 3 : </Grid>
328 : );
329 3 : })}
330 7 : </FormFieldGroup>
331 7 : <FormFieldGroup
332 7 : data-field='dns_search'
333 7 : header={
334 7 : <FormFieldGroupHeader
335 7 : titleText={{ text: _("DNS search domains") }}
336 7 : actions={
337 7 : <Flex alignItems={{ default: 'alignItemsCenter' }}>
338 7 : <Switch
339 7 : isChecked={!ignoreAutoDns}
340 7 : isDisabled={!canAuto}
341 0 : onChange={(_event, value) => setIgnoreAutoDns(!value)}
342 7 : label={_("Automatic")} />
343 7 : <Tooltip content={_("Add search domain")}>
344 7 : <Button icon={<PlusIcon />} variant="secondary"
345 7 : isDisabled={!canHaveExtra}
346 1 : onClick={() => setDnsSearch([...dnsSearch, ""])}
347 7 : id={idPrefix + "-dns-search-add"}
348 7 : aria-label={_("Add search domain")} />
349 7 : </Tooltip>
350 7 : </Flex>
351 : }
352 7 : />
353 : }
354 : >
355 1 : {dnsSearch.map((domain, i) => {
356 1 : return (
357 1 : <Grid key={i} hasGutter>
358 1 : <FormGroup fieldId={idPrefix + "-search-domain-" + i} label={_("Search domain")}>
359 1 : <TextInput id={idPrefix + "-search-domain-" + i} value={domain} onChange={(_event, value) => setDnsSearch(
360 1 : dnsSearch.map((item, index) =>
361 1 : i === index
362 1 : ? value
363 0 : : item
364 1 : ))} />
365 1 : </FormGroup>
366 1 : <FormGroup className="pf-m-1-col-on-sm remove-button-group">
367 1 : <Button variant='plain'
368 1 : size="sm"
369 0 : onClick={() => setDnsSearch(dnsSearch.filter((_, index) => index !== i))}
370 1 : aria-label={_("Remove item")}
371 1 : icon={<TrashIcon />} />
372 1 : </FormGroup>
373 1 : </Grid>
374 : );
375 1 : })}
376 7 : </FormFieldGroup>
377 7 : <FormFieldGroup
378 7 : data-field='routes'
379 7 : header={
380 7 : <FormFieldGroupHeader
381 7 : titleText={{ text: _("Routes") }}
382 7 : actions={
383 7 : <Flex alignItems={{ default: 'alignItemsCenter' }}>
384 7 : <Switch
385 7 : isChecked={!ignoreAutoRoutes}
386 7 : isDisabled={!canAuto}
387 0 : onChange={(_event, value) => setIgnoreAutoRoutes(!value)}
388 7 : label={_("Automatic")} />
389 7 : <Tooltip content={_("Add route")}>
390 7 : <Button icon={<PlusIcon />} variant="secondary"
391 7 : isDisabled={isOff}
392 3 : onClick={() => setRoutes([...routes, { dest: "", prefix: "", next_hop: "", metric: "" }])}
393 7 : id={idPrefix + "-route-add"}
394 7 : aria-label={_("Add route")} />
395 7 : </Tooltip>
396 7 : </Flex>
397 : }
398 7 : />
399 : }
400 : >
401 3 : {routes.map((route, i) => {
402 3 : return (
403 3 : <Grid key={i} hasGutter>
404 3 : <FormGroup fieldId={idPrefix + "-route-address-" + i} label={_("Address")} className="pf-m-3-col-on-sm">
405 2 : <TextInput id={idPrefix + "-route-address-" + i} value={route.dest} onChange={(_event, value) => setRoutes(
406 2 : routes.map((item, index) =>
407 2 : i === index
408 2 : ? { ...item, dest: value }
409 0 : : item
410 2 : ))} />
411 3 : </FormGroup>
412 3 : <FormGroup fieldId={idPrefix + "-route-netmask-" + i} label={prefixText} className="pf-m-4-col-on-sm">
413 2 : <TextInput id={idPrefix + "-route-netmask-" + i} value={route.prefix} onChange={(_event, value) => setRoutes(
414 2 : routes.map((item, index) =>
415 2 : i === index
416 2 : ? { ...item, prefix: value }
417 0 : : item
418 2 : ))} />
419 3 : </FormGroup>
420 3 : <FormGroup fieldId={idPrefix + "-route-gateway-" + i} label={_("Gateway")} className="pf-m-3-col-on-sm">
421 2 : <TextInput id={idPrefix + "-route-gateway-" + i} value={route.next_hop} onChange={(_event, value) => setRoutes(
422 2 : routes.map((item, index) =>
423 2 : i === index
424 2 : ? { ...item, next_hop: value }
425 0 : : item
426 2 : ))} />
427 3 : </FormGroup>
428 3 : <FormGroup fieldId={idPrefix + "-route-metric-" + i} label={_("Metric")} className="pf-m-2-col-on-sm">
429 2 : <TextInput id={idPrefix + "-route-metric-" + i} value={route.metric} onChange={(_event, value) => setRoutes(
430 2 : routes.map((item, index) =>
431 2 : i === index
432 2 : ? { ...item, metric: value }
433 0 : : item
434 2 : ))} />
435 3 : </FormGroup>
436 3 : <FormGroup className="pf-m-1-col-on-sm remove-button-group">
437 3 : <Button variant='plain'
438 3 : size="sm"
439 0 : onClick={() => setRoutes(routes.filter((_, index) => index !== i))}
440 3 : aria-label={_("Remove item")}
441 3 : icon={<TrashIcon />} />
442 3 : </FormGroup>
443 3 : </Grid>
444 : );
445 3 : })}
446 7 : </FormFieldGroup>
447 7 : </NetworkModal>
448 : );
449 7 : };
|