LCOV - code coverage report
Current view: top level - pkg/networkmanager - ip-settings.jsx Coverage Total Hit
Test: cockpit Lines: 93.5 % 355 332
Test Date: 2026-07-17 07:33:01

            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 : };
        

Generated by: LCOV version 2.0-1