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()