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