diff --git a/pkg/networkmanager/dialogs-common.jsx b/pkg/networkmanager/dialogs-common.jsx index 519ec18cb264..bad4c2d812eb 100644 --- a/pkg/networkmanager/dialogs-common.jsx +++ b/pkg/networkmanager/dialogs-common.jsx @@ -19,6 +19,7 @@ import React, { useContext, useEffect, useState } from 'react'; import cockpit from 'cockpit'; +import * as packagekit from 'packagekit.js'; import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js"; import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox/index.js"; @@ -35,11 +36,14 @@ import { IpSettingsDialog } from './ip-settings.jsx'; import { TeamDialog, getGhostSettings as getTeamGhostSettings } from './team.jsx'; import { TeamPortDialog } from './teamport.jsx'; import { VlanDialog, getGhostSettings as getVlanGhostSettings } from './vlan.jsx'; +import { WireGuardDialog, getWireGuardGhostSettings } from './wireguard.jsx'; import { MtuDialog } from './mtu.jsx'; import { MacDialog } from './mac.jsx'; import { ModalError } from 'cockpit-components-inline-notification.jsx'; import { ModelContext } from './model-context.jsx'; import { useDialogs } from "dialogs.jsx"; +import { install_dialog } from "cockpit-components-install-dialog.jsx"; +import { read_os_release } from "os-release.js"; import { apply_group_member, @@ -141,7 +145,7 @@ export const Name = ({ idPrefix, iface, setIface }) => { ); }; -export const NetworkModal = ({ dialogError, help, idPrefix, title, onSubmit, children, isFormHorizontal, isCreateDialog }) => { +export const NetworkModal = ({ dialogError, help, idPrefix, title, onSubmit, children, isFormHorizontal, isCreateDialog, submitDisabled = false }) => { const Dialogs = useDialogs(); return ( @@ -152,7 +156,7 @@ export const NetworkModal = ({ dialogError, help, idPrefix, title, onSubmit, chi title={title} footer={ <> - + } + + + : + setPastedPrivatedKey(val)} + isDisabled={isPrivKeyGenerated} + /> + } + + + {(isPrivKeyGenerated || publicKey) + ? {publicKey} + : {_("Public key will be generated when a valid private key is entered")}} + + + + { setListenPort(val) }} /> + {!parseInt(listenPort) && + + {_("Will be set to \"Automatic\"")} + + } + + + + { setAddresses(val) }} placeholder="Example, 10.0.0.1/24, 1.2.3.4/24" /> + + + {_("Multiple addresses can be specified using commas or spaces as delimiters.")} + + + + + {_("Peers are other machines that connect with this one. Public keys from other machines will be shared with each other.")}

+ } + footerContent={ +

{_("Endpoint acting as a \"server\" need to be specified as host:port, otherwise it can be left empty.")}

+ } + > + +
+ + + } + /> + } + className='dynamic-form-group' + > + {(peers.length !== 0) + ? peers.map((peer, i) => ( + + + { + setPeers(peers => peers.map((peer, index) => i === index ? { ...peer, publicKey: val } : peer)); + }} + id={idPrefix + '-publickey-peer-' + i} + /> + + + { + setPeers(peers => peers.map((peer, index) => i === index ? { ...peer, endpoint: val } : peer)); + }} + id={idPrefix + '-endpoint-peer-' + i} + /> + + + { + setPeers(peers => peers.map((peer, index) => i === index ? { ...peer, allowedIps: val } : peer)); + }} + id={idPrefix + '-allowedips-peer-' + i} + /> + + + + + + )) + : + {_("No peers added.")} + + } +
+ + ); +} + +export function getWireGuardGhostSettings({ newIfaceName }) { + return { + connection: { + id: `con-${newIfaceName}`, + interface_name: newIfaceName + }, + wireguard: { + listen_port: 0, + private_key: "", + peers: [] + }, + ipv4: { + addresses: [] + } + }; +} diff --git a/pkg/networkmanager/wireguard.scss b/pkg/networkmanager/wireguard.scss new file mode 100644 index 000000000000..23f4fb37257d --- /dev/null +++ b/pkg/networkmanager/wireguard.scss @@ -0,0 +1,38 @@ +@import "global-variables.scss"; +@import "@patternfly/patternfly/utilities/Text/text.scss"; +@import "@patternfly/patternfly/utilities/Spacing/spacing.scss"; +@import "@patternfly/patternfly/utilities/Sizing/sizing.scss"; + +.placeholder-text { + color: var(--pf-v5-global--Color--200); + block-size: 2.25rem; +} + +.dynamic-form-group { + .pf-v5-c-empty-state { + padding: 0; + } + + .pf-v5-c-form__field-group-body { + .pf-v5-c-form__group { + display: block; + } + + .remove-button-group { + // Move 'Remove' button the the end of the row + grid-column: -1; + // Move 'Remove' button to the bottom of the line so as to align with the other form fields + display: flex; + align-items: flex-end; + } + } + + // We use FormFieldGroup PF component for the nested look and for ability to add buttons to the header + // However we want to save space and not add indent to the left so we need to override it + .pf-v5-c-form__field-group-body { + // Stretch content fully + --pf-v5-c-form__field-group-body--GridColumn: 1 / -1; + // Reduce padding at the top + --pf-v5-c-form__field-group-body--PaddingTop: var(--pf-v5-global--spacer--xs); + } +} diff --git a/test/run b/test/run index a2e6839ada19..8beee5eaa8a7 100755 --- a/test/run +++ b/test/run @@ -12,7 +12,7 @@ PREPARE_OPTS="" RUN_OPTS="" ALL_TESTS="$(test/common/run-tests --test-dir test/verify -l)" -RE_NETWORKING='Networking|Bonding|TestBridge|Firewall|Team|IPA|AD' +RE_NETWORKING='Networking|Bonding|TestBridge|WireGuard|Firewall|Team|IPA|AD' RE_STORAGE='Storage' RE_EXPENSIVE='HostSwitching|MultiMachine|Updates|Superuser|Kdump|Pages' diff --git a/test/verify/check-networkmanager-wireguard b/test/verify/check-networkmanager-wireguard new file mode 100755 index 000000000000..ba6c6481bc90 --- /dev/null +++ b/test/verify/check-networkmanager-wireguard @@ -0,0 +1,194 @@ +#!/usr/bin/python3 -cimport os, sys; os.execv(os.path.dirname(sys.argv[1]) + "/../common/pywrap", sys.argv) + +import subprocess +import sys + +import netlib +import packagelib +import testlib + + +class TestWireGuard(packagelib.PackageCase, netlib.NetworkCase): + provision = { + "machine1": {"address": "192.168.100.11/24", "restrict": False}, + "machine2": {"address": "192.168.100.12/24", "restrict": False}, + } + + def testVPN(self): + m1 = self.machines["machine1"] + m2 = self.machines["machine2"] + b = self.browser + + self.login_and_go("/network") + + # Peer 1 (client) + m1_port = 51820 + m1_ip4 = "10.0.0.1" + m1_ip6 = "2001::1" + b.click("button:contains('Add VPN')") + b.wait_visible("#network-wireguard-settings-dialog") + iface_name = b.val("#network-wireguard-settings-interface-name-input") + b.wait_visible("button:contains('Save'):disabled") + if m1.image in ["rhel4edge", "centos-8-stream"] or m1.image.startswith("rhel-8"): + b.wait_visible(".pf-v5-c-alert:contains('wireguard-tools package is not installed')") + b.click("button:contains('Cancel')") + b.wait_not_present("#network-ip-settings-dialog") + b.wait_visible("#networking") + b.wait_not_present(f"#networking-interfaces th:contains('{iface_name}')") + # Skip the rest of the tests for images without wireguard-tools + # As without it private/public key, connection over IPv4/IPv6 etc can't be tested + return + + # Peer 2 (server) + m2_port = 51820 + m2_ip4 = "10.0.0.2" + m2_ip6 = "2001::2" + if not m2.ostree_image: + m2.execute(f"firewall-cmd --add-port={m2_port}/udp") + m2.execute("wg genkey > private") + m2_pubkey = m2.execute("wg pubkey < private").strip() + m2.execute("ip link add dev wg0 type wireguard") + m2.execute(f"ip addr add {m2_ip4}/24 dev wg0") + m2.execute("wg set wg0 private-key ./private") + m2.execute(f"wg set wg0 listen-port {m2_port}") + m2.execute("ip link set wg0 up") + + # Validate each field, enter the right value, and then proceed to the next field + # + # check private-key + b.click("#network-wireguard-settings-paste-key") + b.set_input_text("#network-wireguard-settings-private-key-input", "incorrect key") + b.set_input_text("#network-wireguard-settings-addresses-input", m1_ip4) + b.click("button:contains('Save')") + b.wait_visible(".pf-v5-c-alert:contains('key must be 32 bytes base64 encoded')") + b.click("#network-wireguard-settings-generated-key") + + # check public-key + b.wait_not_val("#network-wireguard-settings-public-key input", "") + m1_pubkey = b.val("#network-wireguard-settings-public-key input") + + # check listen-port + b.set_input_text("#network-wireguard-settings-listen-port-input", "66000") + b.click("button:contains('Save')") + b.wait_visible(".pf-v5-c-alert:contains('out of range')") + b.set_input_text("#network-wireguard-settings-listen-port-input", "sometext") + b.click("button:contains('Save')") + b.wait_visible(".pf-v5-c-alert:contains('Listen port must be a number')") + b.set_input_text("#network-wireguard-settings-listen-port-input", str(m1_port)) + + # check ip addresses + b.set_input_text("#network-wireguard-settings-addresses-input", "10.0.0.1/24/56") + b.click("button:contains('Save')") + b.wait_visible(".pf-v5-c-alert:contains('Addresses are not formatted correctly')") + b.set_input_text("#network-wireguard-settings-addresses-input", "10.0.0") + b.click("button:contains('Save')") + b.wait_visible(".pf-v5-c-alert:contains('Invalid address 10.0.0')") + b.set_input_text("#network-wireguard-settings-addresses-input", "ten.one") + b.click("button:contains('Save')") + b.wait_visible(".pf-v5-c-alert:contains('Invalid address ten.one')") + b.set_input_text("#network-wireguard-settings-addresses-input", "10 1") + b.click("button:contains('Save')") + b.wait_visible(".pf-v5-c-alert:contains('Invalid address 10')") + b.set_input_text("#network-wireguard-settings-addresses-input", "1.2.3.4/") + b.click("button:contains('Save')") + b.wait_visible(".pf-v5-c-alert:contains('Invalid prefix or netmask')") + b.set_input_text("#network-wireguard-settings-addresses-input", "1.2.3.4 , 5.6.7.8 1.2.3.4.5") + b.click("button:contains('Save')") + b.wait_visible(".pf-v5-c-alert:contains('Invalid address 1.2.3.4.5')") + b.set_input_text("#network-wireguard-settings-addresses-input", f"{m1_ip4}/24 1.2.3.4") + + # peer + b.click("button:contains('Add peer')") + b.wait_visible("#network-wireguard-settings-peer-0") + b.set_input_text("#network-wireguard-settings-publickey-peer-0", m2_pubkey) + b.set_input_text("#network-wireguard-settings-endpoint-peer-0", f"192.168.100.12") + b.set_input_text("#network-wireguard-settings-allowedips-peer-0", m2_ip4) + b.click("button:contains('Save')") + b.wait_visible(".pf-v5-c-alert:contains('Peer #1 has invalid endpoint. It must be specified as host:port, e.g. 1.2.3.4:51820 or example.com:51820')") + b.set_input_text("#network-wireguard-settings-endpoint-peer-0", f"192.168.100.12:somestring") + b.click("button:contains('Save')") + b.wait_visible(".pf-v5-c-alert:contains('Peer #1 has invalid endpoint port. Port must be a number.')") + b.click("button:contains('Add peer')") + b.wait_visible("#network-wireguard-settings-peer-1") + b.set_input_text("#network-wireguard-settings-publickey-peer-1", m2_pubkey) + b.set_input_text("#network-wireguard-settings-endpoint-peer-1", f"192.168.100.12:{m2_port}") + b.set_input_text("#network-wireguard-settings-allowedips-peer-1", m2_ip4) + b.click("button#network-wireguard-settings-btn-close-peer-0") + b.wait_not_present("#network-wireguard-settings-peer-1") + b.click("button:contains('Save')") + b.wait_not_present("#network-wireguard-settings-dialog") + b.wait_in_text(f"#networking-interfaces th:contains('{iface_name}') + td", f"1.2.3.4/32, {m1_ip4}/24") + + # endpoint and port is not necessary for a peer if that peer estalishes the connectio first (i.e. the client) + m2.execute(f"wg set wg0 peer {m1_pubkey} allowed-ips {m1_ip4}/32") + + # check connection over ipv4 + try: + m1.execute(f"ping -c 5 {m2_ip4}") + except (subprocess.CalledProcessError, testlib.Error): + print("-------- status on m1 ----------", file=sys.stderr) + m1.execute("set -x; ip a >&2; ip route >&2; nmcli c >&2; wg >&2") + print("-------- status on m2 ----------", file=sys.stderr) + m2.execute("set -x; ip a >&2; ip route >&2; nmcli c >&2; wg >&2") + raise + + # check connection over ipv6 + m2.execute(f"ip addr add {m2_ip6}/64 dev wg0") + m2.execute(f"wg set wg0 peer {m1_pubkey} allowed-ips {m1_ip4}/32,{m1_ip6}") + + b.click(f"#networking-interfaces button:contains('{iface_name}')") + b.click("#networking-edit-wg") + b.wait_visible("#network-wireguard-settings-dialog") + b.set_input_text("#network-wireguard-settings-allowedips-peer-0", f"{m2_ip4}/32,{m2_ip6}") + b.click("button:contains('Save')") + b.wait_not_present("#network-wireguard-settings-dialog") + + m1.execute("until wg show wg0 | grep -q 'allowed ips.*2001::2/128'; do sleep 1; done") + + b.click("#networking-edit-ipv6") + b.wait_visible("#network-ip-settings-dialog") + b.select_from_dropdown("#network-ip-settings-select-method", "manual") + b.set_input_text("#network-ip-settings-address-0", m1_ip6) + b.set_input_text("#network-ip-settings-netmask-0", "64") + b.set_input_text("#network-ip-settings-gateway-0", "::") + b.click("button:contains('Save')") + b.wait_not_present("#network-ip-settings-dialog") + self.wait_for_iface_setting("IPv6", "Address 2001:0:0:0:0:0:0:1/64") + + m1.execute(f"until ip a show dev {iface_name} | grep -q 'inet6 {m1_ip6}/64 scope global'; do sleep 0.3; done", timeout=10) + + try: + m1.execute(f"ping -6 -c 5 {m2_ip6}") + except (subprocess.CalledProcessError, testlib.Error): + print("-------- status on m1 ----------", file=sys.stderr) + m1.execute("set -x; ip a >&2; ip -6 route >&2; nmcli c >&2; wg >&2") + print("-------- status on m2 ----------", file=sys.stderr) + m2.execute("set -x; ip a >&2; ip -6 route >&2; nmcli c >&2; wg >&2") + raise + + # install wireguard-tools from the install dialog + if not m1.ostree_image: + b.go("/network") + m1.execute("pkcon remove -y wireguard-tools") + self.createPackage("wireguard-tools", "1", "1") + self.enableRepo() + # HACK: Packagekit on Arch Linux does not deal well with detecting new repositories + if m1.image == "arch": + m1.execute("systemctl restart packagekit") + m1.execute("pkcon refresh force") + b.click("button:contains('Add VPN')") + b.wait_visible("#dialog button:contains('Install'):enabled") + b.click("#dialog button:contains('Install')") + b.wait_not_present("#dialog") + b.wait_visible("#network-wireguard-settings-dialog") + b.wait_visible(".pf-v5-c-alert") + b.click("button:contains('Cancel')") + + # lastly delete the interface + b.click(f"#networking-interfaces button:contains('{iface_name}')") + b.click("#network-interface-delete") + b.wait_not_present(f"#networking-interfaces th:contains('{iface_name}')") + + +if __name__ == "__main__": + testlib.test_main()