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 29, 2023
1 parent bddb697 commit 18ad7ff
Show file tree
Hide file tree
Showing 8 changed files with 420 additions and 5 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
19 changes: 17 additions & 2 deletions 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 Expand Up @@ -1005,7 +1009,8 @@ export function NetworkManagerModel() {
props: {
Connection: { conv: conv_Object(type_Connection) },
Ip4Config: { conv: conv_Object(type_Ipv4Config) },
Ip6Config: { conv: conv_Object(type_Ipv6Config) }
Ip6Config: { conv: conv_Object(type_Ipv6Config) },
Vpn: { def: false }
// See below for "Group"
},

Expand Down Expand Up @@ -1306,6 +1311,16 @@ export function NetworkManagerModel() {
type_Settings);
};

self.list_active_connections = function() {
const result = [];
for (const path in objects) {
const obj = objects[path];
if (priv(obj).type === type_ActiveConnection)
result.push(obj);
}
return result;
};

/* Initialization.
*/

Expand Down
2 changes: 1 addition & 1 deletion pkg/networkmanager/ip-settings.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ export const IpSettingsDialog = ({ topic, connection, dev, settings }) => {
aria-label={_("Select method")}
onChange={(_, val) => setMethod(val)}
value={method}>
{get_ip_method_choices(topic, dev.DeviceType).map(choice => <FormSelectOption value={choice.choice} label={choice.title} key={choice.choice} />)}
{get_ip_method_choices(topic, dev?.DeviceType).map(choice => <FormSelectOption value={choice.choice} label={choice.title} key={choice.choice} />)}
</FormSelect>
<Tooltip content={_("Add address")}>
<Button variant="secondary"
Expand Down
12 changes: 11 additions & 1 deletion pkg/networkmanager/network-interface.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export const NetworkInterfacePage = ({

const dev_name = iface.Name;
const dev = iface.Device;
const active_connection = model.list_active_connections().find(ac => ac.Connection === iface.MainConnection);
const isManaged = iface && (!dev || is_managed(dev));

let ghostSettings = null;
Expand Down Expand Up @@ -144,6 +145,9 @@ export const NetworkInterfacePage = ({
}

function disconnect() {
if (active_connection.Vpn === true)
active_connection.deactivate();

if (!dev) {
console.log("Trying to switch off without a device?");
return;
Expand Down Expand Up @@ -672,8 +676,14 @@ export const NetworkInterfacePage = ({

let onoff;
if (isManaged) {
let isChecked = !!(dev && dev.ActiveConnection);
if (active_connection && active_connection.Vpn === true)
isChecked = true;
else if (!active_connection)
isChecked = false;

onoff = (
<Switch isChecked={!!(dev && dev.ActiveConnection)}
<Switch isChecked={isChecked}
isDisabled={!iface || (dev && dev.State == 20)}
onChange={(_event, enable) => enable ? connect() : disconnect()}
aria-label={_("Enable or disable the device")} />
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
200 changes: 200 additions & 0 deletions pkg/networkmanager/openvpn.jsx
Original file line number Diff line number Diff line change
@@ -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 [vpnSettings, 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 (!vpnSettings.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: {
...vpnSettings,
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} />
<FormFieldGroup
header={
<FormFieldGroupHeader titleText={{ text: _("Automatic") }}
titleDescription={_("Upload a .ovpn file to automatically fill in the details in the next (Manual) section")}
/>
}
>
<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>
</FormFieldGroup>
<FormFieldGroup header={ <FormFieldGroupHeader titleText={{ text: _("Manual") }} /> }>
<FormGroup label={_("Remote")}>
<TextInput id={idPrefix + '-remote-input'} value={vpnSettings.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>
</FormFieldGroup>
<FormFieldGroupExpandable header={ <FormFieldGroupHeader titleText={{ text: _("Advanced options") }} /> }>
<FormGroup />
</FormFieldGroupExpandable>
</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 18ad7ff

Please sign in to comment.