Skip to content

Commit

Permalink
networking: Add support for OpenVPN
Browse files Browse the repository at this point in the history
  • Loading branch information
subhoghoshX committed Oct 6, 2023
1 parent 0a8d0c3 commit 5f07b02
Show file tree
Hide file tree
Showing 6 changed files with 383 additions and 2 deletions.
4 changes: 4 additions & 0 deletions pkg/networkmanager/dialogs-common.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -234,6 +236,8 @@ export const NetworkAction = ({ buttonText, iface, connectionSettings, type }) =
dlg = <BridgeDialog {...properties} />;
else if (type == 'wg')
dlg = <WireGuardDialog {...properties} />;
else if (type == 'openvpn')
dlg = <OpenVPNDialog {...properties} />;
else if (type == 'mtu')
dlg = <MtuDialog {...properties} />;
else if (type == 'mac')
Expand Down
6 changes: 5 additions & 1 deletion pkg/networkmanager/interfaces.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions pkg/networkmanager/network-main.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ export const NetworkPage = ({ privileged, operationInProgress, usage_monitor, pl
const actions = privileged && (
<>
<NetworkAction buttonText={_("Add VPN")} type='wg' />
<NetworkAction buttonText={_("Add OpenVPN")} type='openvpn' />
<NetworkAction buttonText={_("Add bond")} type='bond' />
<NetworkAction buttonText={_("Add team")} type='team' />
<NetworkAction buttonText={_("Add bridge")} type='bridge' />
Expand Down
187 changes: 187 additions & 0 deletions pkg/networkmanager/openvpn.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
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 { 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 (
<NetworkModal
title={!connection ? _("Add OpenVPN") : _("Edit OpenVPN settings")}
isCreateDialog={!connection}
onSubmit={onSubmit}
dialogError={dialogError}
idPrefix={idPrefix}
>
<Name idPrefix={idPrefix} iface={iface} setIface={setIface} />
<FormGroup label={_("OpenVPN config")} id={idPrefix + '-config-group'}>
<FileUpload id={idPrefix + '-config'} filename={configFileName} onFileInputChange={(_, file) => setConfigFileName(file.name)} type='text' onDataChange={(_, val) => setConfigVal(val)} hideDefaultPreview onClearClick={() => { setConfigFileName(''); setConfigVal('') }} />
</FormGroup>
<FormGroup label={_("Remote")}>
<TextInput id={idPrefix + '-remote-input'} value={vpnSetings.remote} onChange={(_, val) => setVpnSettings(settings => ({ ...settings, remote: val }))} />
</FormGroup>
<FormGroup label={_("CA certificate")} id={idPrefix + '-ca-group'}>
<FileUpload id={idPrefix + '-ca'} filename={caFileName} onFileInputChange={(_, file) => setCaFileName(file.name)} type='text' onDataChange={(_, val) => setCaVal(val)} hideDefaultPreview onClearClick={() => { setCaFileName(''); setCaVal('') }} />
</FormGroup>
<FormGroup label={_("User certificate")} id={idPrefix + '-user-cert-group'}>
<FileUpload id={idPrefix + '-user-cert'} filename={certFileName} onFileInputChange={(_, file) => setCertFileName(file.name)} type='text' onDataChange={(_, val) => setCertVal(val)} hideDefaultPreview onClearClick={() => { setCertFileName(''); setCertVal('') }} />
</FormGroup>
<FormGroup label={_("User private key")} id={idPrefix + '-private-key-group'}>
<FileUpload id={idPrefix + '-user-key'} filename={keyFileName} onFileInputChange={(_, file) => setKeyFileName(file.name)} type='text' onDataChange={(_, val) => setKeyVal(val)} hideDefaultPreview onClearClick={() => { setKeyFileName(''); setKeyVal('') }} />
</FormGroup>
</NetworkModal>
);
}

export function getOpenVPNGhostSettings({ newIfaceName }) {
return {
connection: {
id: `con-${newIfaceName}`,
interface_name: newIfaceName,
}
};
}
2 changes: 1 addition & 1 deletion test/run
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
Loading

0 comments on commit 5f07b02

Please sign in to comment.