LCOV - code coverage report
Current view: top level - pkg/networkmanager - wireguard.jsx Coverage Total Hit
Test: cockpit Lines: 98.1 % 316 310
Test Date: 2026-07-17 14:32:59

            Line data    Source code
       1              : /*
       2              :  * Copyright (C) 2023 Red Hat, Inc.
       3              :  * SPDX-License-Identifier: LGPL-2.1-or-later
       4              :  */
       5              : 
       6           37 : import React, { useContext, useEffect, useState } from 'react';
       7           37 : import cockpit from 'cockpit';
       8              : import { Button } from '@patternfly/react-core/dist/esm/components/Button/index.js';
       9              : import { ClipboardCopy } from '@patternfly/react-core/dist/esm/components/ClipboardCopy/index.js';
      10              : import { EmptyState, EmptyStateBody } from '@patternfly/react-core/dist/esm/components/EmptyState/index.js';
      11              : import { FormGroup, FormFieldGroup, FormFieldGroupHeader, FormHelperText } from '@patternfly/react-core/dist/esm/components/Form/index.js';
      12              : import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
      13              : import { Grid } from '@patternfly/react-core/dist/esm/layouts/Grid/index.js';
      14              : import { HelperText, HelperTextItem } from '@patternfly/react-core/dist/esm/components/HelperText/index';
      15              : import { InputGroup } from '@patternfly/react-core/dist/esm/components/InputGroup/index.js';
      16              : import { Popover } from '@patternfly/react-core/dist/esm/components/Popover/index.js';
      17              : import { Radio } from '@patternfly/react-core/dist/esm/components/Radio/index.js';
      18              : import { Content } from "@patternfly/react-core/dist/esm/components/Content/index.js";
      19              : import { TextInput } from '@patternfly/react-core/dist/esm/components/TextInput/index.js';
      20              : import { HelpIcon, TrashIcon } from '@patternfly/react-icons';
      21              : 
      22              : import { Name, NetworkModal, dialogSave } from "./dialogs-common";
      23              : import { ModelContext } from './model-context';
      24              : import { useDialogs } from 'dialogs.jsx';
      25              : import { validate_ip, validate_ipv4, validate_ipv6 } from './utils';
      26              : 
      27              : import './wireguard.scss';
      28              : import { useInit } from 'hooks';
      29              : 
      30           37 : const _ = cockpit.gettext;
      31              : 
      32            2 : function addressesToString(settings) {
      33            2 :     const addresses = settings.ipv4.address_data.concat(settings.ipv6.address_data);
      34            1 :     return addresses.map(addr => addr.address + "/" + addr.prefix).join(", ");
      35            2 : }
      36              : 
      37            2 : function stringToAddresses(str) {
      38            2 :     const ipv4 = [];
      39            2 :     const ipv6 = [];
      40              : 
      41            2 :     str.split(/[\s,]+/).forEach(strAddress => {
      42            2 :         const parts = strAddress.split("/");
      43            1 :         if (parts.length > 2) {
      44            1 :             throw new Error(_("Addresses are not formatted correctly"));
      45            1 :         }
      46              : 
      47            2 :         const [address, prefix] = parts;
      48              : 
      49            1 :         if (validate_ipv6(address)) {
      50            1 :             const defaultPrefix = "128";
      51            1 :             ipv6.push({ address, prefix: prefix ?? defaultPrefix });
      52            1 :         } else if (validate_ipv4(address)) {
      53            2 :             const defaultPrefix = "32";
      54            1 :             ipv4.push({ address, prefix: prefix ?? defaultPrefix });
      55            1 :         } else {
      56            1 :             throw cockpit.format(_("Invalid IP address '$0'"), address);
      57            1 :         }
      58            2 :     });
      59              : 
      60            2 :     return [ipv4, ipv6];
      61            2 : }
      62              : 
      63            2 : export function WireGuardDialog({ settings, connection, dev }) {
      64            2 :     const Dialogs = useDialogs();
      65            2 :     const idPrefix = "network-wireguard-settings";
      66            2 :     const model = useContext(ModelContext);
      67              : 
      68            2 :     const [iface, setIface] = useState(settings.connection.interface_name);
      69            2 :     const [isPrivKeyGenerated, setIsPrivKeyGenerated] = useState(true);
      70            2 :     const [generatedPrivateKey, setGeneratedPrivateKey] = useState("");
      71            2 :     const [pastedPrivateKey, setPastedPrivatedKey] = useState("");
      72            2 :     const [publicKey, setPublicKey] = useState("");
      73            2 :     const [listenPort, setListenPort] = useState(settings.wireguard.listen_port);
      74            2 :     const [addresses, setAddresses] = useState(addressesToString(settings));
      75            2 :     const [dialogError, setDialogError] = useState("");
      76            1 :     const [peers, setPeers] = useState(settings.wireguard.peers.map(peer => ({ ...peer, allowedIps: peer.allowedIps?.join(",") ?? '' })));
      77              : 
      78              :     // Additional check for `wg` after install_dialog for non-packagekit and el8 environments
      79            2 :     useInit(async () => {
      80            2 :         try {
      81            2 :             await cockpit.script("command -v wg");
      82            1 :         } catch (e) {
      83            1 :             setDialogError(_("wireguard-tools package is not installed"));
      84            1 :             return;
      85            1 :         }
      86              : 
      87            1 :         if (connection?.[" priv"].path) {
      88            1 :             const objpath = connection[" priv"].path;
      89            1 :             const [result] = await model.client.call(objpath, "org.freedesktop.NetworkManager.Settings.Connection", "GetSecrets", ["wireguard"]);
      90            1 :             setGeneratedPrivateKey(result.wireguard["private-key"].v);
      91            1 :         } else {
      92            2 :             generatePrivateKey();
      93            2 :         }
      94            2 :     });
      95              : 
      96            2 :     useEffect(() => {
      97            1 :         const privateKey = isPrivKeyGenerated ? generatedPrivateKey : pastedPrivateKey;
      98            2 :         if (privateKey === "") {
      99            2 :             setPublicKey("");
     100            2 :             return;
     101            2 :         }
     102              : 
     103            2 :         async function getPublicKey() {
     104            2 :             try {
     105            2 :                 const key = await cockpit.spawn(["wg", "pubkey"], { err: 'message' }).input(privateKey.trim());
     106            2 :                 setPublicKey(key.trim());
     107            1 :             } catch (e) {
     108            1 :                 console.error("Failed to call wg pubkey:", e.message);
     109            1 :                 setPublicKey('');
     110            1 :             }
     111            2 :         }
     112              : 
     113            2 :         getPublicKey();
     114            2 :     }, [isPrivKeyGenerated, generatedPrivateKey, pastedPrivateKey]);
     115              : 
     116            2 :     async function generatePrivateKey() {
     117            2 :         try {
     118            2 :             const key = await cockpit.spawn(["wg", "genkey"]);
     119            2 :             setGeneratedPrivateKey(key.trim());
     120            1 :         } catch (e) {
     121            1 :             setDialogError(e.message);
     122            1 :         }
     123            2 :     }
     124              : 
     125            1 :     function validatePeer(peer, index) {
     126            1 :         const endpoint = peer.endpoint?.trim();
     127            1 :         if (endpoint) {
     128            1 :             const split = endpoint.split(":");
     129              :             // port should be after last ':'
     130            1 :             const port = Number(split.at(-1));
     131            1 :             const address = split.slice(0, -1).join(":").replace(/^\[|]$/g, '');
     132              : 
     133            1 :             if (!validate_ip(address)) {
     134            1 :                 throw cockpit.format(_("Peer #$0 has invalid endpoint. It must be specified as host:port, e.g. 1.2.3.4:51820, [2001:db8::1]:51820 or example.com:51820"), index + 1);
     135            1 :             }
     136              : 
     137            1 :             if (!Number.isInteger(port) || port < 0 || port > 65535) {
     138            1 :                 throw cockpit.format(_("Peer #$0 has invalid endpoint port. Port must be a number."), index + 1);
     139            1 :             }
     140            1 :         }
     141              : 
     142            1 :         return ({ ...peer, allowedIps: peer.allowedIps.trim().split(',') });
     143            1 :     }
     144              : 
     145            2 :     function onSubmit() {
     146            1 :         const private_key = isPrivKeyGenerated ? generatedPrivateKey : pastedPrivateKey;
     147              : 
     148              :         // Validate Addresses before submit
     149              :         // Also validate listenPort as PF TextInput[type=number] accepts normal text as well on firefox
     150              :         // See - https://github.com/patternfly/patternfly-react/issues/9391
     151            2 :         let ipv4_addr;
     152            2 :         let ipv6_addr;
     153            2 :         let peersArr;
     154            2 :         const listen_port = Number(listenPort);
     155            2 :         try {
     156            2 :             [ipv4_addr, ipv6_addr] = stringToAddresses(addresses);
     157              : 
     158            1 :             if (isNaN(listen_port)) {
     159            1 :                 throw new Error(_("Listen port must be a number"));
     160            1 :             }
     161              : 
     162            1 :             peersArr = peers.map((peer, index) => {
     163            1 :                 return validatePeer(peer, index);
     164            1 :             });
     165            1 :         } catch (e) {
     166            1 :             setDialogError(typeof e === 'string' ? e : e.message);
     167            1 :             return;
     168            1 :         }
     169              : 
     170            2 :         function createAddressesObj(ipv4, ipv6) {
     171            2 :             const addresses = {};
     172              : 
     173            2 :             if (ipv4.length > 0) {
     174            2 :                 addresses.ipv4 = {
     175            2 :                     address_data: ipv4,
     176            2 :                     method: "manual",
     177            2 :                     dns: [],
     178            2 :                     dns_search: [],
     179            2 :                 };
     180            1 :             } else {
     181            1 :                 addresses.ipv4 = { method: "disabled" };
     182            1 :             }
     183              : 
     184            1 :             if (ipv6.length > 0) {
     185            1 :                 addresses.ipv6 = {
     186            1 :                     address_data: ipv6,
     187              :                     // "stable-privacy" use hashing method for IPv6 autoconfiguration
     188            1 :                     addr_gen_mode: 1,
     189            1 :                     method: "manual",
     190            1 :                     dns: [],
     191            1 :                     dns_search: [],
     192            1 :                 };
     193            1 :             } else {
     194            2 :                 addresses.ipv6 = { method: "disabled" };
     195            2 :             }
     196              : 
     197            2 :             return addresses;
     198            2 :         }
     199              : 
     200            2 :         function createSettingsObj() {
     201            2 :             return {
     202            2 :                 ...settings,
     203            2 :                 connection: {
     204            2 :                     ...settings.connection,
     205            2 :                     id: `con-${iface}`,
     206            2 :                     interface_name: iface,
     207            2 :                     type: 'wireguard'
     208            2 :                 },
     209            2 :                 wireguard: {
     210            2 :                     private_key,
     211            2 :                     listen_port,
     212            2 :                     peers: peersArr,
     213            2 :                 },
     214            2 :                 ...createAddressesObj(ipv4_addr, ipv6_addr),
     215            2 :             };
     216            2 :         }
     217              : 
     218            2 :         dialogSave({
     219            2 :             connection,
     220            2 :             dev,
     221            2 :             model,
     222            2 :             settings: createSettingsObj(),
     223            2 :             onClose: Dialogs.close,
     224            2 :             setDialogError
     225            2 :         });
     226            2 :     }
     227              : 
     228            2 :     return (
     229            2 :         <NetworkModal
     230            1 :             title={!connection ? _("Add WireGuard VPN") : _("Edit WireGuard VPN")}
     231            2 :             onSubmit={onSubmit}
     232            2 :             dialogError={dialogError}
     233            2 :             idPrefix={idPrefix}
     234            2 :             submitDisabled={!iface || !addresses || !generatedPrivateKey}
     235            2 :             isCreateDialog={!connection}
     236              :         >
     237            2 :             <Name idPrefix={idPrefix} iface={iface} setIface={setIface} />
     238            2 :             <FormGroup label={_("Private key")} fieldId={idPrefix + '-private-key-input'} isInline hasNoPaddingTop>
     239            0 :                 <Radio label={_("Generated")} name="private-key" id={idPrefix + '-generated-key'} defaultChecked onChange={() => { setIsPrivKeyGenerated(true) }} />
     240            0 :                 <Radio label={_("Paste existing key")} name="private-key" id={idPrefix + '-paste-key'} onChange={() => { setIsPrivKeyGenerated(false) }} />
     241              : 
     242            2 :                 {isPrivKeyGenerated
     243            2 :                     ? <InputGroup className='pf-v6-u-pt-sm'>
     244            2 :                         <Flex className='pf-v6-u-w-100' spaceItems={{ default: 'spaceItemsSm' }}>
     245            2 :                             <FlexItem grow={{ default: 'grow' }}>
     246            2 :                                 <ClipboardCopy isReadOnly id={idPrefix + '-private-key-input'} className='pf-v6-u-font-family-monospace pf-v6-u-w-100'>{generatedPrivateKey}</ClipboardCopy>
     247            2 :                             </FlexItem>
     248            1 :                             {connection && <FlexItem>
     249            1 :                                 <Button variant='secondary' onClick={generatePrivateKey}>{_("Regenerate")}</Button>
     250            1 :                             </FlexItem>}
     251            2 :                         </Flex>
     252            2 :                     </InputGroup>
     253            1 :                     : <InputGroup className='pf-v6-u-pt-sm'>
     254            1 :                         <TextInput id={idPrefix + '-private-key-input'}
     255            1 :                             className='pf-v6-u-font-family-monospace'
     256            1 :                             value={pastedPrivateKey}
     257            0 :                             onChange={(_, val) => setPastedPrivatedKey(val)}
     258            1 :                             isDisabled={isPrivKeyGenerated}
     259            1 :                         />
     260            1 :                     </InputGroup>}
     261            2 :             </FormGroup>
     262            2 :             <FormGroup label={_("Public key")}>
     263            1 :                 {(isPrivKeyGenerated || publicKey)
     264            2 :                     ? <ClipboardCopy isReadOnly className='pf-v6-u-font-family-monospace' id={idPrefix + '-public-key'}>{publicKey}</ClipboardCopy>
     265            1 :                     : <Flex className='placeholder-text' alignItems={{ default: 'alignItemsCenter' }}><Content component="p">{_("Public key will be generated when a valid private key is entered")}</Content></Flex>}
     266            2 :             </FormGroup>
     267            2 :             <FormGroup label={_("Listen port")} fieldId={idPrefix + '-listen-port-input'}>
     268            2 :                 <Flex>
     269            1 :                     <TextInput id={idPrefix + '-listen-port-input'} className='wg-listen-port' value={listenPort} onChange={(_, val) => { setListenPort(val) }} />
     270            2 :                     {!parseInt(listenPort) && <FormHelperText>
     271            2 :                         <HelperText>
     272            2 :                             <HelperTextItem>{_("Will be set to \"Automatic\"")}</HelperTextItem>
     273            2 :                         </HelperText>
     274            2 :                     </FormHelperText>}
     275            2 :                 </Flex>
     276            2 :             </FormGroup>
     277            2 :             <FormGroup label={_("IP addresses")} fieldId={idPrefix + '-addresses-input'}>
     278            2 :                 <TextInput id={idPrefix + '-addresses-input'} value={addresses} onChange={(_, val) => { setAddresses(val) }} placeholder="Example, 10.0.0.1/24, 2001:db8:cafe::1/64" />
     279            2 :                 <FormHelperText>
     280            2 :                     <HelperText>
     281            2 :                         <HelperTextItem>{_("Multiple addresses can be specified using commas or spaces as delimiters.")}</HelperTextItem>
     282            2 :                     </HelperText>
     283            2 :                 </FormHelperText>
     284            2 :             </FormGroup>
     285            2 :             <FormFieldGroup
     286            2 :                 header={
     287            2 :                     <FormFieldGroupHeader
     288            2 :                         className='pf-v6-u-align-items-center'
     289            2 :                         titleText={{
     290            2 :                             text: (
     291            2 :                                 <Flex className='pf-m-space-items-none'>
     292            2 :                                     <FlexItem>
     293            2 :                                         <Content component="p">{_("Peers")}</Content>
     294            2 :                                     </FlexItem>
     295            2 :                                     <FlexItem>
     296            2 :                                         <Popover
     297            2 :                                             bodyContent={
     298            2 :                                                 <p>{_("Peers are other machines that connect with this one. Public keys from other machines will be shared with each other.")}</p>
     299              :                                             }
     300            2 :                                             footerContent={
     301            2 :                                                 <p>{_("Endpoint acting as a \"server\" need to be specified as host:port, otherwise it can be left empty.")}</p>
     302              :                                             }
     303              :                                         >
     304            2 :                                             <Button icon={<HelpIcon />} variant='plain' />
     305            2 :                                         </Popover>
     306            2 :                                     </FlexItem>
     307            2 :                                 </Flex>
     308              :                             )
     309            2 :                         }}
     310            2 :                         actions={
     311            2 :                             <Button
     312            2 :                                 variant='secondary'
     313            1 :                                 onClick={() => setPeers(peers => [...peers, { publicKey: '', endpoint: '', allowedIps: '' }])}
     314              :                             >
     315            2 :                                 {_("Add peer")}
     316            2 :                             </Button>
     317              :                         }
     318            2 :                     />
     319              :                 }
     320            2 :                 className='dynamic-form-group'
     321              :             >
     322            2 :                 {(peers.length !== 0)
     323            1 :                     ? peers.map((peer, i) => (
     324            1 :                         <Grid key={i} hasGutter id={idPrefix + '-peer-' + i}>
     325            1 :                             <FormGroup className='pf-m-6-col-on-md' label={_("Public key")} fieldId={idPrefix + '-publickey-peer-' + i}>
     326            1 :                                 <TextInput
     327            1 :                                     value={peer.publicKey}
     328            1 :                                     onChange={(_, val) => {
     329            1 :                                         setPeers(peers => peers.map((peer, index) => i === index ? { ...peer, publicKey: val } : peer));
     330            1 :                                     }}
     331            1 :                                     id={idPrefix + '-publickey-peer-' + i}
     332            1 :                                 />
     333            1 :                             </FormGroup>
     334            1 :                             <FormGroup className='pf-m-3-col-on-md' label={_("Endpoint")} fieldId={idPrefix + '-endpoint-peer-' + i}>
     335            1 :                                 <TextInput
     336            1 :                                     value={peer.endpoint}
     337            1 :                                     onChange={(_, val) => {
     338            1 :                                         setPeers(peers => peers.map((peer, index) => i === index ? { ...peer, endpoint: val } : peer));
     339            1 :                                     }}
     340            1 :                                     id={idPrefix + '-endpoint-peer-' + i}
     341            1 :                                 />
     342            1 :                             </FormGroup>
     343            1 :                             <FormGroup className='pf-m-3-col-on-md' label={_("Allowed IPs")} fieldId={idPrefix + '-allowedips-peer-' + i}>
     344            1 :                                 <TextInput
     345            1 :                                     value={peer.allowedIps}
     346            1 :                                     onChange={(_, val) => {
     347            1 :                                         setPeers(peers => peers.map((peer, index) => i === index ? { ...peer, allowedIps: val } : peer));
     348            1 :                                     }}
     349            1 :                                     id={idPrefix + '-allowedips-peer-' + i}
     350            1 :                                 />
     351            1 :                             </FormGroup>
     352            1 :                             <FormGroup className='pf-m-1-col-on-md remove-button-group'>
     353            1 :                                 <Button icon={<TrashIcon />}
     354            1 :                                     variant='plain'
     355            1 :                                     id={idPrefix + '-btn-close-peer-' + i}
     356            1 :                                     size='sm'
     357            0 :                                     onClick={() => {
     358            0 :                                         setPeers(peers => peers.filter((_, index) => i !== index));
     359            0 :                                     }}
     360            1 :                                 />
     361            1 :                             </FormGroup>
     362            1 :                         </Grid>
     363            1 :                     ))
     364            2 :                     : <EmptyState>
     365            2 :                         <EmptyStateBody>{_("No peers added.")}</EmptyStateBody>
     366            2 :                     </EmptyState>
     367              :                 }
     368            2 :             </FormFieldGroup>
     369            2 :         </NetworkModal>
     370              :     );
     371            2 : }
     372              : 
     373           35 : export function getWireGuardGhostSettings({ newIfaceName }) {
     374           35 :     return {
     375           35 :         connection: {
     376           35 :             id: `con-${newIfaceName}`,
     377           35 :             interface_name: newIfaceName
     378           35 :         },
     379           35 :         wireguard: {
     380           35 :             listen_port: 0,
     381           35 :             private_key: "",
     382           35 :             peers: []
     383           35 :         },
     384           35 :         ipv4: {
     385           35 :             address_data: []
     386           35 :         },
     387           35 :         ipv6: {
     388           35 :             address_data: []
     389           35 :         },
     390           35 :     };
     391           35 : }
        

Generated by: LCOV version 2.0-1