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..73466857b7d4 --- /dev/null +++ b/pkg/networkmanager/openvpn.jsx @@ -0,0 +1,200 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { Name, NetworkModal, dialogSave } from "./dialogs-common"; +import { FileUpload } from '@patternfly/react-core/dist/esm/components/FileUpload/index.js'; +import { FormFieldGroup, FormFieldGroupExpandable, FormFieldGroupHeader, 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; + +// TODO: clean it +async function ovpnToJSON(ovpn) { + const configFile = "/tmp/temp.ovpn"; // TODO: use a random name for temporary files + await cockpit.file("/tmp/ovpn-to-json.py").replace( + `import configparser +import json +import subprocess +import os + +with open("${configFile}", "w") as ovpn_file: + ovpn_file.write("""${ovpn}""") + +subprocess.run(["nmcli", "con", "import", "--temporary", "type", "openvpn", "file", "${configFile}"], stdout=subprocess.DEVNULL) + +config_object = configparser.ConfigParser() +file = open("/run/NetworkManager/system-connections/temp.nmconnection","r") +config_object.read_file(file) +output_dict=dict() +sections=config_object.sections() +for section in sections: + items=config_object.items(section) + output_dict[section]=dict(items) + +json_string=json.dumps(output_dict) +print(json_string) +file.close() + +# clean up the temporary file +os.remove("/tmp/temp.ovpn") +os.remove("/tmp/ovpn-to-json.py") +subprocess.run(["nmcli", "con", "del", "temp"], stdout=subprocess.DEVNULL) +`); + const json = await cockpit.spawn(["python", "/tmp/ovpn-to-json.py"], { superuser: 'try' }); + return json; +} + +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 [configFileName, setConfigFileName] = useState(""); + const [configVal, setConfigVal] = useState(""); + const [caFileName, setCaFileName] = useState(""); + const [caVal, setCaVal] = useState(""); + const [certFileName, setCertFileName] = useState(""); + const [certVal, setCertVal] = useState(""); + const [keyFileName, setKeyFileName] = useState(""); + const [keyVal, setKeyVal] = useState(""); + const [dialogError, setDialogError] = useState(""); + const [vpnSetings, setVpnSettings] = useState({ + remote: '', + }); // TODO: eventually there should a list of proper defaults instead of an empty object + + useEffect(() => { + if (!configVal) return; + + async function getConfigJSON() { + try { + const json = await ovpnToJSON(configVal); + const vpnObj = JSON.parse(json).vpn; + setVpnSettings(vpnObj); + + setCaFileName(vpnObj.ca.split("/").at(-1)); + setCertFileName(vpnObj.cert.split("/").at(-1)); + setKeyFileName(vpnObj.key.split("/").at(-1)); + + const [ca, cert, key] = await Promise.all([ + cockpit.file(vpnObj.ca, { superuser: 'try' }).read(), + cockpit.file(vpnObj.cert, { superuser: 'try' }).read(), + cockpit.file(vpnObj.key, { superuser: 'try' }).read(), + ]); + setCaVal(ca); + setCertVal(cert); + setKeyVal(key); + } catch (e) { + setDialogError(e.message); + } + } + getConfigJSON(); + }, [configFileName, configVal]); + + async function onSubmit() { + const user = await cockpit.user(); + const caPath = `${user.home}/.cert/${caFileName}-ca.pem`; + const userCertPath = `${user.home}/.cert/${certFileName}-cert.pem`; + const userKeyPath = `${user.home}/.cert/${keyFileName}-key.pem`; + + try { + // check if remote or certificates are empty + if (!vpnSetings.remote.trim()) + throw new Error(_("Remote cannot be empty.")); + if (!caVal.trim()) + throw new Error(_("CA certificate is empty.")); + if (!certVal.trim()) + throw new Error(_("User certificate is empty.")); + if (!keyVal.trim()) + throw new Error(_("User private key is empty.")); + + await cockpit.spawn(["mkdir", "-p", `${user.home}/.cert/nm-openvpn`]); + await Promise.all([cockpit.file(caPath).replace(caVal), + cockpit.file(userCertPath).replace(certVal), + cockpit.file(userKeyPath).replace(keyVal) + ]); + } catch (e) { + setDialogError(e.message); + return; + } + + function createSettingsObject() { + return { + ...settings, + connection: { + ...settings.connection, + type: 'vpn', + }, + vpn: { + data: { + ...vpnSetings, + ca: caPath, + cert: userCertPath, + key: userKeyPath, + 'connection-type': 'tls', // this is not an openvpn option, rather specific to NM + }, + 'service-type': 'org.freedesktop.NetworkManager.openvpn' + } + }; + } + + dialogSave({ + connection, + dev, + model, + settings: createSettingsObject(), + onClose: Dialogs.close, + setDialogError, + }); + } + + return ( + + + + } + > + + setConfigFileName(file.name)} type='text' onDataChange={(_, val) => setConfigVal(val)} hideDefaultPreview onClearClick={() => { setConfigFileName(''); setConfigVal('') }} /> + + + }> + + setVpnSettings(settings => ({ ...settings, remote: val }))} /> + + + setCaFileName(file.name)} type='text' onDataChange={(_, val) => setCaVal(val)} hideDefaultPreview onClearClick={() => { setCaFileName(''); setCaVal('') }} /> + + + setCertFileName(file.name)} type='text' onDataChange={(_, val) => setCertVal(val)} hideDefaultPreview onClearClick={() => { setCertFileName(''); setCertVal('') }} /> + + + setKeyFileName(file.name)} type='text' onDataChange={(_, val) => setKeyVal(val)} hideDefaultPreview onClearClick={() => { setKeyFileName(''); setKeyVal('') }} /> + + + }> + + + + ); +} + +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..d781d93c9d08 --- /dev/null +++ b/test/verify/check-networkmanager-openvpn @@ -0,0 +1,185 @@ +#!/usr/bin/python3 -cimport os, sys; os.execv(os.path.dirname(sys.argv[1]) + "/../common/pywrap", sys.argv) + +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.0.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 ''; cat {keys_dir}/ca.crt; echo ''; }} >> {keys_dir}/test.ovpn") + m2.execute(f"{{ echo ''; cat {keys_dir}/client.crt; echo ''; }} >> {keys_dir}/test.ovpn") + m2.execute(f"{{ echo ''; cat {keys_dir}/client.key; echo ''; }} >> {keys_dir}/test.ovpn") + + ############################################################### + # CLIENT # + ############################################################### + + # download the .ovpn file from the server to client + m2.download(f"{keys_dir}/test.ovpn", "/tmp/test.ovpn") + m2.download(f"{keys_dir}/client.crt", "/tmp/client.crt") + m2.download(f"{keys_dir}/client.key", "/tmp/client.key") + m2.download(f"{keys_dir}/ca.crt", "/tmp/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") + + b.upload_file(".pf-v5-c-file-upload:has(#network-openvpn-settings-ca-filename) input[type=file]", "/tmp/empty-ca.crt") + self.saveDialog() + b.wait_visible(".pf-v5-c-alert:contains('CA certificate is empty.')") + b.upload_file(".pf-v5-c-file-upload:has(#network-openvpn-settings-ca-filename) input[type=file]", "/tmp/ca.crt") + + b.upload_file(".pf-v5-c-file-upload:has(#network-openvpn-settings-user-cert-filename) input[type=file]", "/tmp/empty-client.crt") + self.saveDialog() + b.wait_visible(".pf-v5-c-alert:contains('User certificate is empty.')") + b.upload_file(".pf-v5-c-file-upload:has(#network-openvpn-settings-user-cert-filename) input[type=file]", "/tmp/client.crt") + + b.upload_file(".pf-v5-c-file-upload:has(#network-openvpn-settings-user-key-filename) input[type=file]", "/tmp/empty-client.key") + self.saveDialog() + b.wait_visible(".pf-v5-c-alert:contains('User private key is empty.')") + b.upload_file(".pf-v5-c-file-upload:has(#network-openvpn-settings-user-key-filename) input[type=file]", "/tmp/client.key") + + 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.0.0.1") + m2.execute("ping -c 1 10.0.0.6") + + # delete the connection and check with just the .ovpn config file + m1.execute("nmcli con del con-openvpn0") + b.wait_not_present(f"#networking-interfaces button:contains('{iface_name}')") + b.click("#networking-add-openvpn") + b.wait_visible("#network-openvpn-settings-dialog") + b.upload_file(".pf-v5-c-file-upload:has(#network-openvpn-settings-config-filename) input[type=file]", "/tmp/test.ovpn") + b.wait_not_val("#network-openvpn-settings-remote-input", "") + 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") + 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.0.0.1") + m2.execute("ping -c 1 10.0.0.6") + + +if __name__ == "__main__": + testlib.test_main()