From ca446c945dce6f8dcfd5f3b92851edc557ad12a1 Mon Sep 17 00:00:00 2001 From: Subho Date: Thu, 21 Sep 2023 13:14:51 +0530 Subject: [PATCH] networking: Add support for OpenVPN --- pkg/networkmanager/dialogs-common.jsx | 4 + pkg/networkmanager/interfaces.js | 6 +- pkg/networkmanager/network-main.jsx | 1 + pkg/networkmanager/openvpn.jsx | 124 +++++++++++++++ test/run | 2 +- test/verify/check-networkmanager-openvpn | 191 +++++++++++++++++++++++ 6 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 pkg/networkmanager/openvpn.jsx create mode 100755 test/verify/check-networkmanager-openvpn diff --git a/pkg/networkmanager/dialogs-common.jsx b/pkg/networkmanager/dialogs-common.jsx index bad4c2d812eb..fb0fb8b522e6 100644 --- a/pkg/networkmanager/dialogs-common.jsx +++ b/pkg/networkmanager/dialogs-common.jsx @@ -33,6 +33,7 @@ import { BondDialog, getGhostSettings as getBondGhostSettings } from './bond.jsx import { BridgeDialog, getGhostSettings as getBridgeGhostSettings } from './bridge.jsx'; import { BridgePortDialog } from './bridgeport.jsx'; import { IpSettingsDialog } from './ip-settings.jsx'; +import { OpenVPNDialog, getOpenVPNGhostSettings } from './openvpn.jsx'; import { TeamDialog, getGhostSettings as getTeamGhostSettings } from './team.jsx'; import { TeamPortDialog } from './teamport.jsx'; import { VlanDialog, getGhostSettings as getVlanGhostSettings } from './vlan.jsx'; @@ -203,6 +204,7 @@ export const NetworkAction = ({ buttonText, iface, connectionSettings, type }) = if (type == 'team') settings = getTeamGhostSettings({ newIfaceName }); if (type == 'bridge') settings = getBridgeGhostSettings({ newIfaceName }); if (type == 'wg') settings = getWireGuardGhostSettings({ newIfaceName }); + if (type == 'openvpn') settings = getOpenVPNGhostSettings({ newIfaceName }); } const properties = { connection: con, dev, settings }; @@ -234,6 +236,8 @@ export const NetworkAction = ({ buttonText, iface, connectionSettings, type }) = dlg = ; else if (type == 'wg') dlg = ; + else if (type == 'openvpn') + dlg = ; else if (type == 'mtu') dlg = ; else if (type == 'mac') diff --git a/pkg/networkmanager/interfaces.js b/pkg/networkmanager/interfaces.js index 181362ab8df5..bdb91df82c99 100644 --- a/pkg/networkmanager/interfaces.js +++ b/pkg/networkmanager/interfaces.js @@ -733,8 +733,12 @@ export function NetworkManagerModel() { } }; })); - } else { + } else delete result.wireguard; + + if (settings.vpn) { + set("vpn", "service-type", "s", settings.vpn['service-type']); + set("vpn", "data", "a{ss}", settings.vpn.data); } return result; diff --git a/pkg/networkmanager/network-main.jsx b/pkg/networkmanager/network-main.jsx index b623889d3afa..bf8c067ab048 100644 --- a/pkg/networkmanager/network-main.jsx +++ b/pkg/networkmanager/network-main.jsx @@ -139,6 +139,7 @@ export const NetworkPage = ({ privileged, operationInProgress, usage_monitor, pl const actions = privileged && ( <> + diff --git a/pkg/networkmanager/openvpn.jsx b/pkg/networkmanager/openvpn.jsx new file mode 100644 index 000000000000..ed8754c8e0c5 --- /dev/null +++ b/pkg/networkmanager/openvpn.jsx @@ -0,0 +1,124 @@ +import React, { useContext, useState } from 'react'; +import { Name, NetworkModal, dialogSave } from "./dialogs-common"; +import { FileUpload } from '@patternfly/react-core/dist/esm/components/FileUpload/index.js'; +import { FormGroup } from '@patternfly/react-core/dist/esm/components/Form/index.js'; +import { TextInput } from '@patternfly/react-core/dist/esm/components/TextInput/index.js'; +import cockpit from 'cockpit'; +import { ModelContext } from './model-context'; +import { useDialogs } from 'dialogs.jsx'; + +const _ = cockpit.gettext; + +export function OpenVPNDialog({ settings, connection, dev }) { + const Dialogs = useDialogs(); + const idPrefix = "network-openvpn-settings"; + const model = useContext(ModelContext); + + const [iface, setIface] = useState(settings.connection.interface_name); + const [remote, setRemote] = useState(""); + const [caCertName, setCaCertName] = useState(""); + const [caCertVal, setCaCertVal] = useState(""); + const [userCertName, setUserCertName] = useState(""); + const [userCertVal, setUserCertVal] = useState(""); + const [userKeyName, setUserKeyName] = useState(""); + const [userPrivateKey, setUserPrivateKey] = useState(""); + const [dialogError, setDialogError] = useState(""); + + async function onSubmit() { + const user = await cockpit.user(); + const caPath = `${user.home}/.cert/ca-${caCertName}.crt`; + const userCertPath = `${user.home}/.cert/cert-${userCertName}`; + const userKeyPath = `${user.home}/.cert/key-${userKeyName}.key`; + + try { + // check if remote or certificates are empty + if (!remote.trim()) + throw new Error(_("Remote cannot be empty.")); + if (!caCertVal.trim()) + throw new Error(_("CA certificate is empty.")); + if (!userCertVal.trim()) + throw new Error(_("User certificate is empty.")); + if (!userPrivateKey.trim()) + throw new Error(_("User private key is empty.")); + + await cockpit.script(`mkdir -p ${user.home}/.cert`); + await cockpit.script(`touch ${caPath} ${userCertPath} ${userKeyPath}`); + await cockpit.file(caPath).replace(caCertVal); + await cockpit.file(userCertPath).replace(userCertVal); + await cockpit.file(userKeyPath).replace(userPrivateKey); + } catch (e) { + setDialogError(e.message); + return; + } + + function createSettingsObject() { + return { + ...settings, + connection: { + ...settings.connection, + type: 'vpn', + }, + vpn: { + data: { + remote, + ca: caPath, + cert: userCertPath, + key: userKeyPath, + // hardcoding the bellow properties until we have a design for advanced dialog + 'cert-pass-flags': '0', + 'connection-type': 'tls', + dev: 'tun', + 'push-peer-info': 'yes', + 'remote-cert-tls': 'server' + }, + 'service-type': 'org.freedesktop.NetworkManager.openvpn' + } + }; + } + + dialogSave({ + connection, + dev, + model, + settings: createSettingsObject(), + onClose: Dialogs.close, + setDialogError, + }); + } + + return ( + + + + setRemote(val)} /> + + + setCaCertName(file.name)} type='text' onDataChange={(_, val) => setCaCertVal(val)} hideDefaultPreview /> + + + setUserCertName(file.name)} type='text' onDataChange={(_, val) => setUserCertVal(val)} hideDefaultPreview /> + + + setUserKeyName(file.name)} type='text' onDataChange={(_, val) => setUserPrivateKey(val)} hideDefaultPreview /> + + + + + + ); +} + +export function getOpenVPNGhostSettings({ newIfaceName }) { + return { + connection: { + id: `con-${newIfaceName}`, + interface_name: newIfaceName, + } + }; +} diff --git a/test/run b/test/run index 0757223d53cc..6d098b75b658 100755 --- a/test/run +++ b/test/run @@ -25,7 +25,7 @@ PREPARE_OPTS="" RUN_OPTS="" ALL_TESTS="$(test/common/run-tests --test-dir test/verify -l)" -RE_NETWORKING='Networking|Bonding|TestBridge|WireGuard|Firewall|Team|IPA|AD' +RE_NETWORKING='Networking|Bonding|TestBridge|WireGuard|OpenVPN|Firewall|Team|IPA|AD' RE_STORAGE='Storage' RE_EXPENSIVE='HostSwitching|MultiMachine|Updates|Superuser|Kdump|Pages' diff --git a/test/verify/check-networkmanager-openvpn b/test/verify/check-networkmanager-openvpn new file mode 100755 index 000000000000..c524988e52db --- /dev/null +++ b/test/verify/check-networkmanager-openvpn @@ -0,0 +1,191 @@ +#!/usr/bin/python3 -cimport os, sys; os.execv(os.path.dirname(sys.argv[1]) + "/../common/pywrap", sys.argv) + +import tempfile + +import testlib + + +# Deps: openvpn, network-manager-openvpn, easy-rsa (optional) +class TestOpenVPN(testlib.MachineCase): + provision = { + "machine1": {"address": "192.168.100.11/24", "restrict": False}, + "machine2": {"address": "192.168.100.12/24", "restrict": False}, + } + + def saveDialog(self): + b = self.browser + b.click("#network-openvpn-settings-save") + + def testOpenvpn(self): + m1 = self.machines["machine1"] + m2 = self.machines["machine2"] + b = self.browser + + # increasing productivity + keys_dir = "/etc/openvpn/server" + m1.execute("touch .hushlogin") + m2.execute("touch .hushlogin") + + ############################################################### + # SERVER # + ############################################################### + m2.execute("openssl genrsa -out ca.key") + m2.execute("openssl req -x509 -new -sha512 -nodes -key ca.key -days 7307 -out ca.crt -subj '/C=XX/ST=StateName/L=CityName/O=CompanyName/OU=CompanySectionName/CN=CommonNameOrHostname'") + m2.execute(f"openssl dhparam -out {keys_dir}/dh2048.pem 2048") + m2.execute("openssl genrsa -out server.key") + host_conf = """ " +[req] +default_md = sha512 +basicConstraints = CA:FALSE +keyUsage = nonRepudiation, digitalSignature, keyEncipherment +[req] +distinguished_name = req_distinguished_name +req_extensions = req_ext +prompt = no +[req_distinguished_name] +C = AU +ST = Victoria +L = Melbourne +O = My Company +OU = My Division +CN = testing.com +[req_ext] +subjectAltName = @alt_names +[alt_names] +DNS.1 = testing.com +DNS.2 = *.testing.com + " """ + m2.execute(f"echo {host_conf} >> host.conf") + m2.execute("openssl req -new -sha512 -nodes -key server.key -out server.csr -config host.conf") + host_ext_conf = """ " +basicConstraints = CA:FALSE +nsCertType = server +nsComment = "My First Certificate" +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid,issuer:always +keyUsage = nonRepudiation, digitalSignature, keyEncipherment +extendedKeyUsage = serverAuth +subjectAltName = @alt_names +[alt_names] +DNS.1 = testing.com +DNS.2 = *.testing.com + " """ + m2.execute(f"echo {host_ext_conf} >> host-ext.conf") + m2.execute("openssl x509 -req -sha512 -days 45 -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -extfile host-ext.conf") + + m2.execute("openssl genrsa -out client.key") + m2.execute("openssl req -new -sha512 -nodes -key client.key -out client.csr -config host.conf") + m2.execute("openssl x509 -req -sha512 -days 3650 -CA ca.crt -CAkey ca.key -in client.csr -set_serial 01 -out client.crt") + m2.execute(f"mv -t {keys_dir} ca.key ca.crt server.key server.crt client.key client.crt") + server_conf = """ " +port 1194 +proto udp +dev tun + +ca ca.crt +cert server.crt +key server.key +dh dh2048.pem + +server 10.8.0.0 255.255.255.0 + +keepalive 10 120 +data-ciphers-fallback AES-256-CBC + +persist-key +persist-tun + " """ + m2.execute(f"echo {server_conf} >> {keys_dir}/server.conf") + if 'fedora' in m2.image: + # TODO: in fedora systemd starting openvpn leads to permission errors + m2.execute("setenforce 0") + m2.execute("systemctl enable --now openvpn-server@server") + m2.execute("firewall-cmd --add-port=1194/udp") + + # create .ovpn file for client + ovpn_conf = """ "# start +client +dev tun +proto udp +remote 192.168.100.12 1194 udp +resolv-retry infinite +persist-key +persist-tun +remote-cert-tls server +data-ciphers-fallback AES-256-CBC + " """ + m2.execute(f"echo {ovpn_conf} >> {keys_dir}/test.ovpn") + m2.execute(f"echo '' >> {keys_dir}/test.ovpn") + m2.execute(f"cat {keys_dir}/ca.crt >> {keys_dir}/test.ovpn") + m2.execute(f"echo '' >> {keys_dir}/test.ovpn") + + m2.execute(f"echo '' >> {keys_dir}/test.ovpn") + m2.execute(f"cat {keys_dir}/client.crt >> {keys_dir}/test.ovpn") + m2.execute(f"echo '' >> {keys_dir}/test.ovpn") + + m2.execute(f"echo '' >> {keys_dir}/test.ovpn") + m2.execute(f"cat {keys_dir}/client.key >> {keys_dir}/test.ovpn") + m2.execute(f"echo '' >> {keys_dir}/test.ovpn") + + ############################################################### + # CLIENT # + ############################################################### + + # download the .ovpn file from the server to client + m1.execute("ssh-keygen -t ed25519 -C '' -f '/root/.ssh/id_ed25519' -N ''") + pubkey = m1.execute("cat .ssh/id_ed25519.pub") + m2.execute(f"echo '{pubkey.strip()}' >> .ssh/authorized_keys") + m1.execute(f"scp -o StrictHostKeyChecking=accept-new 192.168.100.12:{keys_dir}/test.ovpn .") + m1.execute(f"scp -o StrictHostKeyChecking=accept-new 192.168.100.12:{keys_dir}/client.* .") + m1.execute(f"scp -o StrictHostKeyChecking=accept-new 192.168.100.12:{keys_dir}/ca.crt .") + + # FIX: the client also doesn't work when selinux is enforcing + m1.execute("setenforce 0") + + self.login_and_go("/network") + b.click("#networking-add-openvpn") + b.wait_visible("#network-openvpn-settings-dialog") + iface_name = b.val("#network-openvpn-settings-interface-name-input") + + b.set_input_text("#network-openvpn-settings-remote-input", "192.168.100.12:1194:udp") + ca = m1.execute("cat ca.crt") + with tempfile.NamedTemporaryFile(mode='w', delete=False) as tf_ca: + b.upload_file(".pf-v5-c-file-upload:has(#network-openvpn-settings-ca-filename) input[type=file]", tf_ca.name) + self.saveDialog() + b.wait_visible(".pf-v5-c-alert:contains('CA certificate is empty.')") + b.click("#network-openvpn-settings-ca-group button:contains('Clear')") + tf_ca.write(ca) + b.upload_file(".pf-v5-c-file-upload:has(#network-openvpn-settings-ca-filename) input[type=file]", tf_ca.name) + + user_cert = m1.execute("cat client.crt") + with tempfile.NamedTemporaryFile(mode='w', delete=False) as tf_user_cert: + b.upload_file(".pf-v5-c-file-upload:has(#network-openvpn-settings-user-cert-filename) input[type=file]", tf_user_cert.name) + self.saveDialog() + b.wait_visible(".pf-v5-c-alert:contains('User certificate is empty.')") + b.click("#network-openvpn-settings-user-cert-group button:contains('Clear')") + tf_user_cert.write(user_cert) + b.upload_file(".pf-v5-c-file-upload:has(#network-openvpn-settings-user-cert-filename) input[type=file]", tf_user_cert.name) + + user_key = m1.execute("cat client.key") + with tempfile.NamedTemporaryFile(mode='w', delete=False) as tf_user_key: + b.upload_file(".pf-v5-c-file-upload:has(#network-openvpn-settings-user-key-filename) input[type=file]", tf_user_key.name) + self.saveDialog() + b.wait_visible(".pf-v5-c-alert:contains('User private key is empty.')") + b.click("#network-openvpn-settings-private-key-group button:contains('Clear')") + tf_user_key.write(user_key) + b.upload_file(".pf-v5-c-file-upload:has(#network-openvpn-settings-user-key-filename) input[type=file]", tf_user_key.name) + + self.saveDialog() + b.wait_not_present("#network-openvpn-settings-dialog") + b.click(f"#networking-interfaces button:contains('{iface_name}')") + b.click(".pf-v5-c-switch__toggle") + b.go("/network") + # FIX: the creation of bogus NM route, this could be either a problem with client config or server config not push the correct routes to client during initialization + m1.execute("until ip route | grep -q '192.168.100.12 via 172.27.0.2'; do sleep 1; done") + m1.execute("ip route del 192.168.100.12 via 172.27.0.2") + m1.execute("sleep 5; ping -c 1 10.8.0.1") + m2.execute("ping -c 1 10.8.0.6") + + +if __name__ == "__main__": + testlib.test_main()