Line data Source code
1 : /*
2 : * Copyright (C) 2023 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 35 : import React, { useContext, useEffect, useState } from 'react';
7 35 : 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 35 : 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 33 : export function getWireGuardGhostSettings({ newIfaceName }) {
374 33 : return {
375 33 : connection: {
376 33 : id: `con-${newIfaceName}`,
377 33 : interface_name: newIfaceName
378 33 : },
379 33 : wireguard: {
380 33 : listen_port: 0,
381 33 : private_key: "",
382 33 : peers: []
383 33 : },
384 33 : ipv4: {
385 33 : address_data: []
386 33 : },
387 33 : ipv6: {
388 33 : address_data: []
389 33 : },
390 33 : };
391 33 : }
|