* Copyright (C) 2023 Red Hat, Inc.
* SPDX-License-Identifier: LGPL-2.1-or-later
*/
import React, { useContext, useEffect, useState } from 'react';
import cockpit from 'cockpit';
import { Button } from '@patternfly/react-core/dist/esm/components/Button/index.js';
import { ClipboardCopy } from '@patternfly/react-core/dist/esm/components/ClipboardCopy/index.js';
import { EmptyState, EmptyStateBody } from '@patternfly/react-core/dist/esm/components/EmptyState/index.js';
import { FormGroup, FormFieldGroup, FormFieldGroupHeader, FormHelperText } from '@patternfly/react-core/dist/esm/components/Form/index.js';
import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
import { Grid } from '@patternfly/react-core/dist/esm/layouts/Grid/index.js';
import { HelperText, HelperTextItem } from '@patternfly/react-core/dist/esm/components/HelperText/index';
import { InputGroup } from '@patternfly/react-core/dist/esm/components/InputGroup/index.js';
import { Popover } from '@patternfly/react-core/dist/esm/components/Popover/index.js';
import { Radio } from '@patternfly/react-core/dist/esm/components/Radio/index.js';
import { Content } from "@patternfly/react-core/dist/esm/components/Content/index.js";
import { TextInput } from '@patternfly/react-core/dist/esm/components/TextInput/index.js';
import { HelpIcon, TrashIcon } from '@patternfly/react-icons';
import { Name, NetworkModal, dialogSave } from "./dialogs-common";
import { ModelContext } from './model-context';
import { useDialogs } from 'dialogs.jsx';
import { validate_ip, validate_ipv4, validate_ipv6 } from './utils';
import './wireguard.scss';
import { useInit } from 'hooks';
const _ = cockpit.gettext;
function addressesToString(settings) {
const addresses = settings.ipv4.address_data.concat(settings.ipv6.address_data);
return addresses.map(addr => addr.address + "/" + addr.prefix).join(", ");
}
function stringToAddresses(str) {
const ipv4 = [];
const ipv6 = [];
str.split(/[\s,]+/).forEach(strAddress => {
const parts = strAddress.split("/");
if (parts.length > 2) {
throw new Error(_("Addresses are not formatted correctly"));
}
const [address, prefix] = parts;
if (validate_ipv6(address)) {
const defaultPrefix = "128";
ipv6.push({ address, prefix: prefix ?? defaultPrefix });
} else if (validate_ipv4(address)) {
const defaultPrefix = "32";
ipv4.push({ address, prefix: prefix ?? defaultPrefix });
} else {
throw cockpit.format(_("Invalid IP address '$0'"), address);
}
});
return [ipv4, ipv6];
}
export function WireGuardDialog({ settings, connection, dev }) {
const Dialogs = useDialogs();
const idPrefix = "network-wireguard-settings";
const model = useContext(ModelContext);
const [iface, setIface] = useState(settings.connection.interface_name);
const [isPrivKeyGenerated, setIsPrivKeyGenerated] = useState(true);
const [generatedPrivateKey, setGeneratedPrivateKey] = useState("");
const [pastedPrivateKey, setPastedPrivatedKey] = useState("");
const [publicKey, setPublicKey] = useState("");
const [listenPort, setListenPort] = useState(settings.wireguard.listen_port);
const [addresses, setAddresses] = useState(addressesToString(settings));
const [dialogError, setDialogError] = useState("");
const [peers, setPeers] = useState(settings.wireguard.peers.map(peer => ({ ...peer, allowedIps: peer.allowedIps?.join(",") ?? '' })));
useInit(async () => {
try {
await cockpit.script("command -v wg");
} catch (e) {
setDialogError(_("wireguard-tools package is not installed"));
return;
}
if (connection?.[" priv"].path) {
const objpath = connection[" priv"].path;
const [result] = await model.client.call(objpath, "org.freedesktop.NetworkManager.Settings.Connection", "GetSecrets", ["wireguard"]);
setGeneratedPrivateKey(result.wireguard["private-key"].v);
} else {
generatePrivateKey();
}
});
useEffect(() => {
const privateKey = isPrivKeyGenerated ? generatedPrivateKey : pastedPrivateKey;
if (privateKey === "") {
setPublicKey("");
return;
}
async function getPublicKey() {
try {
const key = await cockpit.spawn(["wg", "pubkey"], { err: 'message' }).input(privateKey.trim());
setPublicKey(key.trim());
} catch (e) {
console.error("Failed to call wg pubkey:", e.message);
setPublicKey('');
}
}
getPublicKey();
}, [isPrivKeyGenerated, generatedPrivateKey, pastedPrivateKey]);
async function generatePrivateKey() {
try {
const key = await cockpit.spawn(["wg", "genkey"]);
setGeneratedPrivateKey(key.trim());
} catch (e) {
setDialogError(e.message);
}
}
function validatePeer(peer, index) {
const endpoint = peer.endpoint?.trim();
if (endpoint) {
const split = endpoint.split(":");
const port = Number(split.at(-1));
const address = split.slice(0, -1).join(":").replace(/^\[|]$/g, '');
if (!validate_ip(address)) {
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);
}
if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw cockpit.format(_("Peer #$0 has invalid endpoint port. Port must be a number."), index + 1);
}
}
return ({ ...peer, allowedIps: peer.allowedIps.trim().split(',') });
}
function onSubmit() {
const private_key = isPrivKeyGenerated ? generatedPrivateKey : pastedPrivateKey;
let ipv4_addr;
let ipv6_addr;
let peersArr;
const listen_port = Number(listenPort);
try {
[ipv4_addr, ipv6_addr] = stringToAddresses(addresses);
if (isNaN(listen_port)) {
throw new Error(_("Listen port must be a number"));
}
peersArr = peers.map((peer, index) => {
return validatePeer(peer, index);
});
} catch (e) {
setDialogError(typeof e === 'string' ? e : e.message);
return;
}
function createAddressesObj(ipv4, ipv6) {
const addresses = {};
if (ipv4.length > 0) {
addresses.ipv4 = {
address_data: ipv4,
method: "manual",
dns: [],
dns_search: [],
};
} else {
addresses.ipv4 = { method: "disabled" };
}
if (ipv6.length > 0) {
addresses.ipv6 = {
address_data: ipv6,
addr_gen_mode: 1,
method: "manual",
dns: [],
dns_search: [],
};
} else {
addresses.ipv6 = { method: "disabled" };
}
return addresses;
}
function createSettingsObj() {
return {
...settings,
connection: {
...settings.connection,
id: `con-${iface}`,
interface_name: iface,
type: 'wireguard'
},
wireguard: {
private_key,
listen_port,
peers: peersArr,
},
...createAddressesObj(ipv4_addr, ipv6_addr),
};
}
dialogSave({
connection,
dev,
model,
settings: createSettingsObj(),
onClose: Dialogs.close,
setDialogError
});
}
return (
<NetworkModal
title={!connection ? _("Add WireGuard VPN") : _("Edit WireGuard VPN")}
onSubmit={onSubmit}
dialogError={dialogError}
idPrefix={idPrefix}
submitDisabled={!iface || !addresses || !generatedPrivateKey}
isCreateDialog={!connection}
>
<Name idPrefix={idPrefix} iface={iface} setIface={setIface} />
<FormGroup label={_("Private key")} fieldId={idPrefix + '-private-key-input'} isInline hasNoPaddingTop>
<Radio label={_("Generated")} name="private-key" id={idPrefix + '-generated-key'} defaultChecked onChange={() => { setIsPrivKeyGenerated(true) }} />
<Radio label={_("Paste existing key")} name="private-key" id={idPrefix + '-paste-key'} onChange={() => { setIsPrivKeyGenerated(false) }} />
{isPrivKeyGenerated
? <InputGroup className='pf-v6-u-pt-sm'>
<Flex className='pf-v6-u-w-100' spaceItems={{ default: 'spaceItemsSm' }}>
<FlexItem grow={{ default: 'grow' }}>
<ClipboardCopy isReadOnly id={idPrefix + '-private-key-input'} className='pf-v6-u-font-family-monospace pf-v6-u-w-100'>{generatedPrivateKey}</ClipboardCopy>
</FlexItem>
{connection && <FlexItem>
<Button variant='secondary' onClick={generatePrivateKey}>{_("Regenerate")}</Button>
</FlexItem>}
</Flex>
</InputGroup>
: <InputGroup className='pf-v6-u-pt-sm'>
<TextInput id={idPrefix + '-private-key-input'}
className='pf-v6-u-font-family-monospace'
value={pastedPrivateKey}
onChange={(_, val) => setPastedPrivatedKey(val)}
isDisabled={isPrivKeyGenerated}
/>
</InputGroup>}
</FormGroup>
<FormGroup label={_("Public key")}>
{(isPrivKeyGenerated || publicKey)
? <ClipboardCopy isReadOnly className='pf-v6-u-font-family-monospace' id={idPrefix + '-public-key'}>{publicKey}</ClipboardCopy>
: <Flex className='placeholder-text' alignItems={{ default: 'alignItemsCenter' }}><Content component="p">{_("Public key will be generated when a valid private key is entered")}</Content></Flex>}
</FormGroup>
<FormGroup label={_("Listen port")} fieldId={idPrefix + '-listen-port-input'}>
<Flex>
<TextInput id={idPrefix + '-listen-port-input'} className='wg-listen-port' value={listenPort} onChange={(_, val) => { setListenPort(val) }} />
{!parseInt(listenPort) && <FormHelperText>
<HelperText>
<HelperTextItem>{_("Will be set to \"Automatic\"")}</HelperTextItem>
</HelperText>
</FormHelperText>}
</Flex>
</FormGroup>
<FormGroup label={_("IP addresses")} fieldId={idPrefix + '-addresses-input'}>
<TextInput id={idPrefix + '-addresses-input'} value={addresses} onChange={(_, val) => { setAddresses(val) }} placeholder="Example, 10.0.0.1/24, 2001:db8:cafe::1/64" />
<FormHelperText>
<HelperText>
<HelperTextItem>{_("Multiple addresses can be specified using commas or spaces as delimiters.")}</HelperTextItem>
</HelperText>
</FormHelperText>
</FormGroup>
<FormFieldGroup
header={
<FormFieldGroupHeader
className='pf-v6-u-align-items-center'
titleText={{
text: (
<Flex className='pf-m-space-items-none'>
<FlexItem>
<Content component="p">{_("Peers")}</Content>
</FlexItem>
<FlexItem>
<Popover
bodyContent={
<p>{_("Peers are other machines that connect with this one. Public keys from other machines will be shared with each other.")}</p>
}
footerContent={
<p>{_("Endpoint acting as a \"server\" need to be specified as host:port, otherwise it can be left empty.")}</p>
}
>
<Button icon={<HelpIcon />} variant='plain' />
</Popover>
</FlexItem>
</Flex>
)
}}
actions={
<Button
variant='secondary'
onClick={() => setPeers(peers => [...peers, { publicKey: '', endpoint: '', allowedIps: '' }])}
>
{_("Add peer")}
</Button>
}
/>
}
className='dynamic-form-group'
>
{(peers.length !== 0)
? peers.map((peer, i) => (
<Grid key={i} hasGutter id={idPrefix + '-peer-' + i}>
<FormGroup className='pf-m-6-col-on-md' label={_("Public key")} fieldId={idPrefix + '-publickey-peer-' + i}>
<TextInput
value={peer.publicKey}
onChange={(_, val) => {
setPeers(peers => peers.map((peer, index) => i === index ? { ...peer, publicKey: val } : peer));
}}
id={idPrefix + '-publickey-peer-' + i}
/>
</FormGroup>
<FormGroup className='pf-m-3-col-on-md' label={_("Endpoint")} fieldId={idPrefix + '-endpoint-peer-' + i}>
<TextInput
value={peer.endpoint}
onChange={(_, val) => {
setPeers(peers => peers.map((peer, index) => i === index ? { ...peer, endpoint: val } : peer));
}}
id={idPrefix + '-endpoint-peer-' + i}
/>
</FormGroup>
<FormGroup className='pf-m-3-col-on-md' label={_("Allowed IPs")} fieldId={idPrefix + '-allowedips-peer-' + i}>
<TextInput
value={peer.allowedIps}
onChange={(_, val) => {
setPeers(peers => peers.map((peer, index) => i === index ? { ...peer, allowedIps: val } : peer));
}}
id={idPrefix + '-allowedips-peer-' + i}
/>
</FormGroup>
<FormGroup className='pf-m-1-col-on-md remove-button-group'>
<Button icon={<TrashIcon />}
variant='plain'
id={idPrefix + '-btn-close-peer-' + i}
size='sm'
onClick={() => {
setPeers(peers => peers.filter((_, index) => i !== index));
}}
/>
</FormGroup>
</Grid>
))
: <EmptyState>
<EmptyStateBody>{_("No peers added.")}</EmptyStateBody>
</EmptyState>
}
</FormFieldGroup>
</NetworkModal>
);
}
export function getWireGuardGhostSettings({ newIfaceName }) {
return {
connection: {
id: `con-${newIfaceName}`,
interface_name: newIfaceName
},
wireguard: {
listen_port: 0,
private_key: "",
peers: []
},
ipv4: {
address_data: []
},
ipv6: {
address_data: []
},
};
}