LCOV - code coverage report
Current view: top level - pkg/networkmanager - ip-settings.jsx Coverage Total Hit
Test: cockpit Lines: 4.5 % 355 16
Test Date: 2026-06-17 06:28:00

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

Generated by: LCOV version 2.0-1