From e9edb115354478bff53e38a9f111a5dd901f124d Mon Sep 17 00:00:00 2001 From: Ad Schellevis Date: Wed, 31 May 2023 16:04:43 +0200 Subject: [PATCH] VPN: OpenVPN: Instances (MVC) (#6584) * VPN: OpenVPN: Instances - add new module using the same approach as introduced for IPsec in 23.1. Since we likely can't easily migrate the old cruft, we better focus on offering the correct options for openvpn following upstream documentation. o add boilerplate o implement a solution to keep vpnid's unique so device creation for legacy and mvc can function in similar ways. o add some of the main "helper" options for clients and servers o Implement certificate logic, selecting a certificate also implies an authority (which we validate) o hook CRL generation into the exising openvpn_refresh_crls() event o attach already refactored authentication to new MVC as well, OpenVPN->getInstanceById() is responsible for feeding the data needed during authentication and overwrite generation. o when in client mode and in need for a username+password combination, flush these to file and link in "auth-user-pass" o routes (remote) and push routes (local), combine IPv4 and IPv6 for ease of administration, o keep alive [push] ping-[restart] defined as seperate fields for validation o add various "push" to client options in Miscellaneous section o add "auth-gen-token" lifetime for https://github.com/opnsense/core/issues/6135 o allow selection of redirect-gateway type for https://github.com/opnsense/core/issues/6220 o move tls-auth/crypt into separate static keys objects (tab in instances page) o hook existing events (ovpn_event.py) and make sure they locate the server using getServerById() when needed o use getInstanceById in openvpn_prepare() to return both legacy as MVC device configuration o add ovpn_service_control.php for service control [stop|start|restart|configure] and glue this in openvpn_services() via configd o change openvpn_interfaces() to use isEnabled() method on the model to query if any (legacy/mvc) instances are enabled o move openvpn_config() from openvpn.inc to widget and extend with MVC instances o extend ovpn_status.py to parse "instance-" sockets as well, since the filename doesn't explain the role, we're using the status call to figure out the use. uuid's are keys in this case o server_id type to str in kill_session.py so we can match either legacy or mvc sockets o hook ExportController to OpenVPN model using getInstanceById() to glue the Client Export utility to both components o extend connection status with mvc sessions (descriptions) --------- Co-authored-by: Franco Fichtner --- src/etc/inc/plugins.inc.d/openvpn.inc | 144 +++--- .../OPNsense/OpenVPN/Api/ExportController.php | 99 ++-- .../OpenVPN/Api/InstancesController.php | 107 +++++ .../OpenVPN/Api/ServiceController.php | 41 +- .../OPNsense/OpenVPN/InstancesController.php | 49 ++ .../OPNsense/OpenVPN/forms/dialogInstance.xml | 333 +++++++++++++ .../OpenVPN/forms/dialogStaticKey.xml | 21 + .../mvc/app/models/OPNsense/Core/ACL/ACL.xml | 7 + .../app/models/OPNsense/Core/Menu/Menu.xml | 1 + .../app/models/OPNsense/OpenVPN/Export.xml | 2 +- .../OpenVPN/FieldTypes/InstanceField.php | 63 +++ .../OpenVPN/FieldTypes/RemoteHostField.php | 95 ++++ .../OpenVPN/FieldTypes/VPNIdField.php | 97 ++++ .../app/models/OPNsense/OpenVPN/OpenVPN.php | 445 ++++++++++++++++++ .../app/models/OPNsense/OpenVPN/OpenVPN.xml | 260 ++++++++++ .../app/views/OPNsense/OpenVPN/instances.volt | 171 +++++++ .../scripts/openvpn/client_connect.php | 32 +- src/opnsense/scripts/openvpn/kill_session.py | 6 +- .../scripts/openvpn/ovpn_service_control.php | 183 +++++++ src/opnsense/scripts/openvpn/ovpn_status.py | 18 +- .../scripts/openvpn/user_pass_verify.php | 18 +- .../conf/actions.d/actions_openvpn.conf | 30 ++ src/www/widgets/widgets/openvpn.widget.php | 46 ++ 23 files changed, 2076 insertions(+), 192 deletions(-) create mode 100644 src/opnsense/mvc/app/controllers/OPNsense/OpenVPN/Api/InstancesController.php create mode 100644 src/opnsense/mvc/app/controllers/OPNsense/OpenVPN/InstancesController.php create mode 100644 src/opnsense/mvc/app/controllers/OPNsense/OpenVPN/forms/dialogInstance.xml create mode 100644 src/opnsense/mvc/app/controllers/OPNsense/OpenVPN/forms/dialogStaticKey.xml create mode 100644 src/opnsense/mvc/app/models/OPNsense/OpenVPN/FieldTypes/InstanceField.php create mode 100644 src/opnsense/mvc/app/models/OPNsense/OpenVPN/FieldTypes/RemoteHostField.php create mode 100644 src/opnsense/mvc/app/models/OPNsense/OpenVPN/FieldTypes/VPNIdField.php create mode 100644 src/opnsense/mvc/app/views/OPNsense/OpenVPN/instances.volt create mode 100755 src/opnsense/scripts/openvpn/ovpn_service_control.php diff --git a/src/etc/inc/plugins.inc.d/openvpn.inc b/src/etc/inc/plugins.inc.d/openvpn.inc index 4092dc136..801950274 100644 --- a/src/etc/inc/plugins.inc.d/openvpn.inc +++ b/src/etc/inc/plugins.inc.d/openvpn.inc @@ -1,8 +1,8 @@ - * Copyright (C) 2016 Deciso B.V. * Copyright (C) 2008 Scott Ullrich * Copyright (C) 2006 Fernando Lemos * Copyright (C) 2005 Peter Allgeyer @@ -72,32 +72,38 @@ function openvpn_services() } } + foreach ((new OPNsense\OpenVPN\OpenVPN())->Instances->Instance->iterateItems() as $key => $node) { + if (!empty((string)$node->enabled)) { + $services[] = [ + 'description' => "OpenVPN {$node->role} " . htmlspecialchars($node->description), + 'pidfile' => "/var/run/ovpn-instance-{$key}.pid", + 'configd' => [ + 'start' => ["openvpn start {$key}"], + 'restart' => ["openvpn restart {$key}"], + 'stop' => ["openvpn stop {$key}"], + ], + 'id' => $key, + 'name' => "openvpn" + ]; + } + } + return $services; } function openvpn_interfaces() { - global $config; - - $interfaces = array(); - - foreach (array('server', 'client') as $mode) { - if (isset($config['openvpn']["openvpn-{$mode}"])) { - foreach ($config['openvpn']["openvpn-{$mode}"] as $settings) { - if (empty($settings['disable'])) { - $oic = array('enable' => true); - $oic['if'] = 'openvpn'; - $oic['descr'] = 'OpenVPN'; - $oic['type'] = 'group'; - $oic['virtual'] = true; - $oic['networks'] = array(); - $interfaces['openvpn'] = $oic; - break 2; - } - } - } + $interfaces = []; + if ((new OPNsense\OpenVPN\OpenVPN())->isEnabled()) { + $interfaces['openvpn'] = [ + 'enable' => true, + 'if' => 'openvpn', + 'descr' => 'OpenVPN', + 'type' => 'group', + 'virtual' => true, + 'networks' => [] + ]; } - return $interfaces; } @@ -119,6 +125,16 @@ function openvpn_devices() } } + foreach ((new OPNsense\OpenVPN\OpenVPN())->Instances->Instance->iterateItems() as $key => $node) { + $mode = ((string)$node->role)[0]; + $name = "ovpn{$mode}{$node->vpnid}"; + $names[$name] = [ + 'descr' => sprintf('ovpn%s%s (OpenVPN %s %s)', $mode, $node->vpnid, (string)$node->role == 'server' ? gettext('Server') : gettext('Client'), $node->description), + 'ifdescr' => (string)$node->description, + 'name' => $name + ]; + } + return [[ 'function' => 'openvpn_prepare', /* XXX not the same as real configuration */ 'configurable' => false, @@ -135,7 +151,7 @@ function openvpn_xmlrpc_sync() $result[] = array( 'description' => gettext('OpenVPN'), - 'section' => 'openvpn', + 'section' => 'openvpn,OPNsense.OpenVPN', 'id' => 'openvpn', 'services' => ["openvpn"], ); @@ -194,36 +210,14 @@ function openvpn_create_key() return $rslt; } -function openvpn_vpnid_used($vpnid) -{ - global $config; - - if (isset($config['openvpn']['openvpn-server'])) { - foreach ($config['openvpn']['openvpn-server'] as $settings) { - if ($vpnid == $settings['vpnid']) { - return true; - } - } - } - - if (isset($config['openvpn']['openvpn-client'])) { - foreach ($config['openvpn']['openvpn-client'] as $settings) { - if ($vpnid == $settings['vpnid']) { - return true; - } - } - } - - return false; -} - function openvpn_vpnid_next() { - $vpnid = 1; - while (openvpn_vpnid_used($vpnid)) { - $vpnid++; + $vpnids = (new OPNsense\OpenVPN\OpenVPN())->usedVPNIds(); + for ($vpnid = 1; true ; $vpnid++) { + if (!in_array($vpnid, $vpnids)) { + return $vpnid; + } } - return $vpnid; } function openvpn_port_used($prot, $interface, $port, $curvpnid = 0) @@ -1043,16 +1037,12 @@ function openvpn_csc_conf_write($settings, $server, $target_filename = null) function openvpn_prepare($device) { - global $config; - - foreach (['server', 'client'] as $mode) { - if (!empty($config['openvpn']["openvpn-{$mode}"])) { - foreach ($config['openvpn']["openvpn-{$mode}"] as $settings) { - if ($device == "ovpn{$mode[0]}{$settings['vpnid']}") { - openvpn_reconfigure($mode, $settings, true); - return; - } - } + if (str_starts_with($device, 'ovpn')) { + $vpnid = preg_replace("/[^0-9]/", "", $device); + $settings = (new OPNsense\OpenVPN\OpenVPN())->getInstanceById($vpnid); + if ($settings) { + // XXX: split device creation and legacy configure? + openvpn_reconfigure($settings['role'], $settings, true); } } } @@ -1118,34 +1108,6 @@ function openvpn_configure_do($verbose = false, $interface = '', $carp_event = f } -function openvpn_config() -{ - global $config; - $result = []; - foreach (['openvpn-server', 'openvpn-client'] as $section) { - $result[$section] = []; - if (!empty($config['openvpn'][$section])) { - foreach ($config['openvpn'][$section] as $settings) { - if (empty($settings) || isset($settings['disable'])) { - continue; - } - $server = []; - $default_port = ($section == 'openvpn-server') ? 1194 : ''; - $server['port'] = ($settings['local_port']) ? $settings['local_port'] : $default_port; - $server['mode'] = $settings['mode']; - if (empty($settings['description'])) { - $settings['description'] = ($section == 'openvpn-server') ? 'Server' : 'Client'; - } - $server['name'] = "{$settings['description']} {$settings['protocol']}:{$settings['local_port']}"; - $server['vpnid'] = $settings['vpnid']; - $result[$section][] = $server; - } - } - } - return $result; -} - - function openvpn_create_dirs() { @mkdir('/var/etc/openvpn-csc', 0750); @@ -1306,4 +1268,12 @@ function openvpn_refresh_crls() } } } + foreach ((new OPNsense\OpenVPN\OpenVPN())->Instances->Instance->iterateItems() as $key => $node) { + if (!empty((string)$node->enabled) && !empty((string)$node->crl)) { + $fpath = "/var/etc/openvpn/server-{$key}.crl-verify"; + $crl = lookup_crl((string)$node->crl); + file_put_contents($fpath, !empty($crl['text']) ? base64_decode($crl['text']) : ''); + @chmod($fpath, 0644); + } + } } diff --git a/src/opnsense/mvc/app/controllers/OPNsense/OpenVPN/Api/ExportController.php b/src/opnsense/mvc/app/controllers/OPNsense/OpenVPN/Api/ExportController.php index 769684837..9feef9dc6 100644 --- a/src/opnsense/mvc/app/controllers/OPNsense/OpenVPN/Api/ExportController.php +++ b/src/opnsense/mvc/app/controllers/OPNsense/OpenVPN/Api/ExportController.php @@ -33,6 +33,7 @@ use OPNsense\Base\UserException; use OPNsense\Core\Config; use OPNsense\Core\Backend; use OPNsense\Trust\Store; +use OPNsense\OpenVPN\OpenVPN; use OPNsense\OpenVPN\Export; use OPNsense\OpenVPN\ExportFactory; @@ -94,15 +95,34 @@ class ExportController extends ApiControllerBase */ private function openvpnServers($active = true) { - if (isset(Config::getInstance()->object()->openvpn)) { - foreach (Config::getInstance()->object()->openvpn->children() as $key => $value) { + $cfg = Config::getInstance()->object(); + if (isset($cfg->openvpn)) { + foreach ($cfg->openvpn->children() as $key => $server) { if ($key == 'openvpn-server') { - if (empty($value->disable) || !$active) { - yield $value; + if (empty($server->disable) || !$active) { + $name = empty($server->description) ? "server" : (string)$server->description; + $name .= " " . $server->protocol . ":" . $server->local_port; + yield [ + 'name' => $name, + 'mode' => (string)$server->mode, + 'vpnid' => (string)$server->vpnid + ]; } } } } + foreach ((new OpenVPN())->Instances->Instance->iterateItems() as $node_uuid => $node){ + if (!empty((string)$node->enabled) && $node->role == 'server') { + $name = empty($node->description) ? "server" : (string)$node->description; + $name .= " " . $node->proto . ":" . $node->port; + yield [ + 'name' => $name, + 'mode' => !empty((string)$node->authmode) ? 'server_tls_user' : '', + 'vpnid' => $node_uuid + ]; + } + } + } /** @@ -132,19 +152,19 @@ class ExportController extends ApiControllerBase { $result = array(); $serverModel = $this->getModel()->getServer($vpnid); - $server = $this->findServer($vpnid); + $server = (new OpenVPN())->getInstanceById($vpnid); // hostname if (!empty((string)$serverModel->hostname)) { $result["hostname"] = (string)$serverModel->hostname; - } else { + } elseif (!empty($server['interface'])) { $allInterfaces = $this->getInterfaces(); - if (!empty($allInterfaces[(string)$server->interface])) { - if (strstr((string)$server->protocol, "6") !== false) { - if (!empty($allInterfaces[(string)$server->interface]['ipv6'])) { - $result["hostname"] = $allInterfaces[(string)$server->interface]['ipv6'][0]['ipaddr']; + if (!empty($allInterfaces[$server['interface']])) { + if (strstr($server['protocol'], "6") !== false) { + if (!empty($allInterfaces[$server['interface']]['ipv6'])) { + $result["hostname"] = $allInterfaces[$server['interface']]['ipv6'][0]['ipaddr']; } - } elseif (!empty($allInterfaces[(string)$server->interface]['ipv4'])) { - $result["hostname"] = $allInterfaces[(string)$server->interface]['ipv4'][0]['ipaddr']; + } elseif (!empty($allInterfaces[$server['interface']]['ipv4'])) { + $result["hostname"] = $allInterfaces[$server['interface']]['ipv4'][0]['ipaddr']; } } } @@ -152,29 +172,13 @@ class ExportController extends ApiControllerBase foreach ($serverModel->iterateItems() as $field => $value) { if (!empty((string)$value)) { $result[$field] = (string)$value; - } elseif (!empty((string)$server->$field) || !isset($result[$field])) { - $result[$field] = (string)$server->$field; + } elseif (!empty($server[$field]) || !isset($result[$field])) { + $result[$field] = $server[$field] ?? null; } } return $result; } - /** - * find server by vpnid - * @param string $vpnid reference - * @return mixed|null - */ - private function findServer($vpnid) - { - foreach ($this->openvpnServers() as $server) { - if ((string)$server->vpnid == $vpnid) { - return $server; - } - } - return null; - } - - /** * list providers * @return array list of configured openvpn providers (servers) @@ -184,15 +188,8 @@ class ExportController extends ApiControllerBase { $result = array(); foreach ($this->openvpnServers() as $server) { - $vpnid = (string)$server->vpnid; - $result[$vpnid] = array(); - // visible name - $result[$vpnid]["name"] = empty($server->description) ? "server" : (string)$server->description; - $result[$vpnid]["name"] .= " " . $server->protocol . ":" . $server->local_port; - // relevant properties - $result[$vpnid]["mode"] = (string)$server->mode; - $result[$vpnid]["vpnid"] = $vpnid; - $result[$vpnid] = array_merge($result[$vpnid], $this->configuredSettings($vpnid)); + $vpnid = $server['vpnid']; + $result[$vpnid] = array_merge($server, $this->configuredSettings($vpnid)); } return $result; } @@ -210,12 +207,12 @@ class ExportController extends ApiControllerBase "users" => [] ] ]; - $server = $this->findServer($vpnid); + $server = (new OpenVPN())->getInstanceById($vpnid); if ($server !== null) { // collect certificates for this server's ca if (isset(Config::getInstance()->object()->cert)) { foreach (Config::getInstance()->object()->cert as $cert) { - if (isset($cert->refid) && isset($cert->caref) && (string)$server->caref == $cert->caref) { + if (isset($cert->refid) && isset($cert->caref) && $server['caref'] == $cert->caref) { $result[(string)$cert->refid] = array( "description" => (string)$cert->descr, "users" => array() @@ -319,28 +316,16 @@ class ExportController extends ApiControllerBase { $response = array("result" => "failed"); if ($this->request->isPost()) { - $server = $this->findServer($vpnid); + $server = (new OpenVPN())->getInstanceById($vpnid); if ($server !== null) { // fetch server config data - $config = array(); - foreach ( - array('disable', 'description', 'local_port', 'protocol', 'crypto', 'digest', - 'tunnel_networkv6', 'reneg-sec', 'local_network', 'local_networkv6', - 'tunnel_network', 'compression', 'passtos', 'shared_key', 'mode', - 'dev_mode', 'tls', 'tlsmode', 'client_mgmt_port') as $field - ) { - if (isset($server->$field) && $server->$field !== "") { - $config[$field] = (string)$server->$field; - } else { - $config[$field] = null; - } - } + $config = $server; // fetch associated certificate data, add to config $config['server_ca_chain'] = ''; $config['server_subject_name'] = null; $config['server_cert_is_srv'] = null; - if (!empty($server->certref)) { - $cert = (new Store())->getCertificate((string)$server->certref); + if (!empty($server['certref'])) { + $cert = (new Store())->getCertificate($server['certref']); if ($cert) { $config['server_cert_is_srv'] = $cert['is_server']; $config['server_subject_name'] = $cert['name'] ?? ''; diff --git a/src/opnsense/mvc/app/controllers/OPNsense/OpenVPN/Api/InstancesController.php b/src/opnsense/mvc/app/controllers/OPNsense/OpenVPN/Api/InstancesController.php new file mode 100644 index 000000000..59de1f5e2 --- /dev/null +++ b/src/opnsense/mvc/app/controllers/OPNsense/OpenVPN/Api/InstancesController.php @@ -0,0 +1,107 @@ +searchBase( + 'Instances.Instance', + ['description', 'role', 'dev_type', 'enabled'] + ); + } + public function getAction($uuid = null) + { + return $this->getBase('instance', 'Instances.Instance', $uuid); + } + public function addAction() + { + return $this->addBase('instance', 'Instances.Instance'); + } + public function setAction($uuid = null) + { + return $this->setBase('instance', 'Instances.Instance', $uuid); + } + public function delAction($uuid) + { + return $this->delBase('Instances.Instance', $uuid); + } + public function toggleAction($uuid, $enabled = null) + { + return $this->toggleBase('Instances.Instance', $uuid, $enabled); + } + + /** + * static key administration + */ + public function searchStaticKeyAction() + { + return $this->searchBase('StaticKeys.StaticKey', ['description']); + } + public function getStaticKeyAction($uuid = null) + { + return $this->getBase('statickey', 'StaticKeys.StaticKey', $uuid); + } + public function addStaticKeyAction() + { + return $this->addBase('statickey', 'StaticKeys.StaticKey'); + } + public function setStaticKeyAction($uuid = null) + { + return $this->setBase('statickey', 'StaticKeys.StaticKey', $uuid); + } + public function delStaticKeyAction($uuid) + { + return $this->delStaticKeyBase('StaticKeys.StaticKey', $uuid); + } + + public function genKeyAction() + { + $key = (new Backend())->configdRun("openvpn genkey"); + if (strpos($key, '-----BEGIN') > 0) { + return [ + 'result' => 'ok', + 'key' => trim($key) + ]; + } + return ['result' => 'failed']; + } +} diff --git a/src/opnsense/mvc/app/controllers/OPNsense/OpenVPN/Api/ServiceController.php b/src/opnsense/mvc/app/controllers/OPNsense/OpenVPN/Api/ServiceController.php index f5a2dbc8f..530657776 100644 --- a/src/opnsense/mvc/app/controllers/OPNsense/OpenVPN/Api/ServiceController.php +++ b/src/opnsense/mvc/app/controllers/OPNsense/OpenVPN/Api/ServiceController.php @@ -31,6 +31,7 @@ namespace OPNsense\OpenVPN\Api; use OPNsense\Base\ApiControllerBase; use OPNsense\Core\Config; use OPNsense\Core\Backend; +use OPNsense\OpenVPN\OpenVPN; /** * Class ServiceController @@ -46,10 +47,19 @@ class ServiceController extends ApiControllerBase if (!empty($config->openvpn->$cnf_section)) { foreach ($config->openvpn->$cnf_section as $cnf) { if (!empty((string)$cnf->vpnid)) { - $config_payload[(string)$cnf->vpnid] = $cnf; + $config_payload[(string)$cnf->vpnid] = [ + 'description' => (string)$cnf->description ?? '', + 'enabled' => empty((string)$cnf->disable) ? '1' : '0' + ]; } } } + foreach ((new OpenVPN())->Instances->Instance->iterateItems() as $node_uuid => $node){ + $config_payload[$node_uuid] = [ + 'enabled' => (string)$node->enabled, + 'description' => (string)$node->description + ]; + } return $config_payload; } @@ -79,7 +89,7 @@ class ServiceController extends ApiControllerBase $stats['connected_since'] = date('Y-m-d H:i:s', $stats['timestamp']); } if (!empty($config_payload[$idx])) { - $stats['description'] = (string)$config_payload[$idx]->description ?? ''; + $stats['description'] = (string)$config_payload[$idx]['description']; } if (!empty($stats['client_list'])) { foreach ($stats['client_list'] as $client) { @@ -95,12 +105,12 @@ class ServiceController extends ApiControllerBase } // add non running enabled servers foreach ($config_payload as $idx => $cnf) { - if (!in_array($idx, $vpnids) && empty((string)$cnf->disable)) { + if (!in_array($idx, $vpnids) && !empty($cnf['enabled'])) { $records[] = [ 'id' => $idx, 'service_id' => "openvpn/" . $idx, 'type' => $role, - 'description' => (string)$cnf->description ?? '', + 'description' => $cnf['description'], ]; } } @@ -189,7 +199,7 @@ class ServiceController extends ApiControllerBase $this->sessionClose(); - (new Backend())-> configdpRun('service start', ['openvpn', $id]); + (new Backend())->configdpRun('service start', ['openvpn', $id]); return ['result' => 'ok']; } @@ -206,7 +216,7 @@ class ServiceController extends ApiControllerBase $this->sessionClose(); - (new Backend())-> configdpRun('service stop', ['openvpn', $id]); + (new Backend())->configdpRun('service stop', ['openvpn', $id]); return ['result' => 'ok']; } @@ -223,7 +233,24 @@ class ServiceController extends ApiControllerBase $this->sessionClose(); - (new Backend())-> configdpRun('service restart', ['openvpn', $id]); + (new Backend())->configdpRun('service restart', ['openvpn', $id]); + + return ['result' => 'ok']; + } + + /** + * @param int $id server/client id to restart + * @return array + */ + public function reconfigureAction() + { + if (!$this->request->isPost()) { + return ['result' => 'failed']; + } + + $this->sessionClose(); + + (new Backend())->configdpRun('openvpn configure'); return ['result' => 'ok']; } diff --git a/src/opnsense/mvc/app/controllers/OPNsense/OpenVPN/InstancesController.php b/src/opnsense/mvc/app/controllers/OPNsense/OpenVPN/InstancesController.php new file mode 100644 index 000000000..7690ae4f6 --- /dev/null +++ b/src/opnsense/mvc/app/controllers/OPNsense/OpenVPN/InstancesController.php @@ -0,0 +1,49 @@ +view->pick('OPNsense/OpenVPN/instances'); + $this->view->formDialogInstance = $this->getForm('dialogInstance'); + $this->view->formDialogStaticKey = $this->getForm('dialogStaticKey'); + } +} diff --git a/src/opnsense/mvc/app/controllers/OPNsense/OpenVPN/forms/dialogInstance.xml b/src/opnsense/mvc/app/controllers/OPNsense/OpenVPN/forms/dialogInstance.xml new file mode 100644 index 000000000..e5d37b455 --- /dev/null +++ b/src/opnsense/mvc/app/controllers/OPNsense/OpenVPN/forms/dialogInstance.xml @@ -0,0 +1,333 @@ + + + header + + + + instance.vpnid + + + + text + + + instance.role + + dropdown + + Define the role of this instance + + + + instance.description + + text + You may enter a description here for your reference (not parsed). + + + instance.enabled + + checkbox + + + instance.proto + + dropdown + Use protocol for communicating with remote host. + + + instance.port + + text + Portnumber to use, defaults to 1194 when in server role, nobind for clients + + + instance.local + + text + + Optional IP address for bind. If specified, OpenVPN will bind to this address only. + If unspecified, OpenVPN will bind to all interfaces. + + + + instance.dev_type + + dropdown + true + + Choose the type of tunnel, OSI Layer 3 [tun] is the most common option to route IPv4 or IPv6 traffic, + [tap] offers Ethernet 802.3 (OSI Layer 2) connectivity between hosts and is usually combined with a bridge. + + + + instance.verb + + dropdown + true + Output verbosity level (0..9) + + + instance.maxclients + + true + + text + Specify the maximum number of clients allowed to concurrently connect to this server. + + + instance.keepalive_interval + + true + text + Ping interval in seconds. 0 to disable keep alive + + + instance.keepalive_timeout + + true + text + Causes OpenVPN to restart after n seconds pass without reception of a ping or other packet from remote. + + + instance.server + + text + + + This directive will set up an OpenVPN server which will allocate addresses to clients out of the given network/netmask. + The server itself will take the .1 address of the given network for use as the server-side endpoint of the local TUN/TAP interface + + + + instance.server_ipv6 + + text + + + This directive will set up an OpenVPN server which will allocate addresses to clients out of the given network/netmask. + The server itself will take the next base address (+1) of the given network for use as the server-side endpoint of the local TUN/TAP interface + + + + instance.topology + + dropdown + + + Configure virtual addressing topology when running in --dev tun mode. + This directive has no meaning in --dev tap mode, which always uses a subnet topology. + + + + instance.remote + + select_multiple + true + + Remote host name or IP address with optional port, examples: + my.remote.local dead:beaf:: my.remote.local:1494 [dead:beaf::]:1494 192.168.1.1:1494 + + + + header + + + + instance.cert + + dropdown + Select a certificate to use for this service. + + + instance.crl + + dropdown + Select a certificate revocation list to use for this service. + + + instance.verify_client_cert + + dropdown + Specify if the client is required to offer a certificate. + + + instance.tls_key + + dropdown + + Add an additional layer of HMAC authentication on top of the TLS control channel to mitigate DoS attacks and attacks on the TLS stack. + The prefixed mode determines if this measurement is only used for authentication (--tls-auth) or includes encryption (--tls-crypt). + + + + instance.data-ciphers + + select_multiple + true + + Restrict the allowed ciphers to be negotiated to the ciphers in this list. + + + instance.data-ciphers-fallback + + dropdown + true + + + Configure a cipher that is used to fall back to if we could not determine which cipher the peer is willing to use. + This option should only be needed to connect to peers that are running OpenVPN 2.3 or older versions, + and have been configured with --enable-small (typically used on routers or other embedded devices). + + + + header + + + + instance.authmode + + select_multiple + + Select authentication methods to use, leave empty if no challenge response authentication is needed. + + + instance.local_group + + dropdown + + Restrict access to users in the selected local group. Please be aware that other authentication backends will refuse to authenticate when using this option. + + + instance.username_as_common_name + + checkbox + true + + Use the authenticated username as the common-name, rather than the common-name from the client certificate. + + + instance.strictusercn + + checkbox + + When authenticating users, enforce a match between the Common Name of the client certificate and the username given at login. + + + instance.username + + text + + (optional) Username to send to the server for authentication when required. + + + instance.password + + password + + Password belonging to the user specified above + + + instance.reneg-sec + + text + Renegotiate data channel key after n seconds (default=3600). +When using a one time password, be advised that your connection will automatically drop because your password is not valid anymore. +Set to 0 to disable, remember to change your client as well. + + + + instance.auth-gen-token + + text + + After successful user/password authentication, + the OpenVPN server will with this option generate a temporary authentication token and push that to the client. + On the following renegotiations, the OpenVPN client will pass this token instead of the users password. + On the server side the server will do the token authentication internally and it will + NOT do any additional authentications against configured external user/password authentication mechanisms. + When set to 0, the token will never expire, any other value specifies the lifetime in seconds. + + + + header + + + + instance.push_route + + select_multiple + + true + These are the networks accessible on this host, these are pushed via route{-ipv6} clauses in OpenVPN to the client. + + + instance.route + + select_multiple + + true + Remote networks for the server, add route to routing table after connection is established + + + header + + + + instance.various_flags + + select_multiple + Various less frequently used yes/no options which can be set for this instance. + + + instance.redirect_gateway + + select_multiple + + Automatically execute routing commands to cause all outgoing IP traffic to be redirected over the VPN. + + + instance.register_dns + + checkbox + + Run ipconfig /flushdns and ipconfig /registerdns on connection initiation. This is known to kick Windows into recognizing pushed DNS servers. + + + instance.dns_domain + + text + + Set Connection-specific DNS Suffix. + + + instance.dns_domain_search + + select_multiple + + true + + Add name to the domain search list. Repeat this option to add more entries. Up to 10 domains are supported + + + + instance.dns_servers + + select_multiple + + true + + Set primary domain name server IPv4 or IPv6 address. Repeat this option to set secondary DNS server addresses. + + + + instance.ntp_servers + + select_multiple + + true + + Set primary NTP server address (Network Time Protocol). Repeat this option to set secondary NTP server addresses. + + + diff --git a/src/opnsense/mvc/app/controllers/OPNsense/OpenVPN/forms/dialogStaticKey.xml b/src/opnsense/mvc/app/controllers/OPNsense/OpenVPN/forms/dialogStaticKey.xml new file mode 100644 index 000000000..f1a9d2a84 --- /dev/null +++ b/src/opnsense/mvc/app/controllers/OPNsense/OpenVPN/forms/dialogStaticKey.xml @@ -0,0 +1,21 @@ + + + statickey.description + + text + You may enter a description here for your reference (not parsed). + + + statickey.mode + + 220px + dropdown + Define the use of this key, authentication (--tls-auth) or authentication and encryption (--tls-crypt) + + + statickey.key + + textbox + Paste an OpenVPN Static key. Or generate one with the button. + + \ No newline at end of file diff --git a/src/opnsense/mvc/app/models/OPNsense/Core/ACL/ACL.xml b/src/opnsense/mvc/app/models/OPNsense/Core/ACL/ACL.xml index 70e81bc5d..5edd42167 100644 --- a/src/opnsense/mvc/app/models/OPNsense/Core/ACL/ACL.xml +++ b/src/opnsense/mvc/app/models/OPNsense/Core/ACL/ACL.xml @@ -747,6 +747,13 @@ api/openvpn/client_overwrites/* + + VPN: OpenVPN: Instances + + ui/openvpn/instances + api/openvpn/instances/* + + VPN: OpenVPN: Server diff --git a/src/opnsense/mvc/app/models/OPNsense/Core/Menu/Menu.xml b/src/opnsense/mvc/app/models/OPNsense/Core/Menu/Menu.xml index 285de0de0..c64becc9b 100644 --- a/src/opnsense/mvc/app/models/OPNsense/Core/Menu/Menu.xml +++ b/src/opnsense/mvc/app/models/OPNsense/Core/Menu/Menu.xml @@ -204,6 +204,7 @@ + diff --git a/src/opnsense/mvc/app/models/OPNsense/OpenVPN/Export.xml b/src/opnsense/mvc/app/models/OPNsense/OpenVPN/Export.xml index 4c25ec080..10050ad39 100644 --- a/src/opnsense/mvc/app/models/OPNsense/OpenVPN/Export.xml +++ b/src/opnsense/mvc/app/models/OPNsense/OpenVPN/Export.xml @@ -5,7 +5,7 @@ - + Y diff --git a/src/opnsense/mvc/app/models/OPNsense/OpenVPN/FieldTypes/InstanceField.php b/src/opnsense/mvc/app/models/OPNsense/OpenVPN/FieldTypes/InstanceField.php new file mode 100644 index 000000000..e6cfbd0aa --- /dev/null +++ b/src/opnsense/mvc/app/models/OPNsense/OpenVPN/FieldTypes/InstanceField.php @@ -0,0 +1,63 @@ +internalChildnodes as $node) { + $uuid = $node->getAttributes()['uuid'] ?? null; + if (!$node->getInternalIsVirtual() && $uuid) { + $files = [ + 'cnfFilename' => "/var/etc/openvpn/instance-{$uuid}.conf", + 'pidFilename' => "/var/run/ovpn-instance-{$uuid}.pid", + 'sockFilename' => "/var/etc/openvpn/instance-{$uuid}.sock", + 'statFilename' => "/var/etc/openvpn/instance-{$uuid}.stat", + 'csoDirectory' => "/var/etc/openvpn-csc/$node->vpnid", + '__devnode' => "{$node->dev_type}{$node->vpnid}", + '__devname' => "ovpn".((string)$node->role)[0]."{$node->vpnid}", + ]; + foreach ($files as $name => $payload) { + $new_item = new TextField(); + $new_item->setInternalIsVirtual(); + $new_item->setValue($payload); + $node->addChildNode($name, $new_item); + } + } + } + return parent::actionPostLoadingEvent(); + } +} diff --git a/src/opnsense/mvc/app/models/OPNsense/OpenVPN/FieldTypes/RemoteHostField.php b/src/opnsense/mvc/app/models/OPNsense/OpenVPN/FieldTypes/RemoteHostField.php new file mode 100644 index 000000000..ffaadc726 --- /dev/null +++ b/src/opnsense/mvc/app/models/OPNsense/OpenVPN/FieldTypes/RemoteHostField.php @@ -0,0 +1,95 @@ +internalValue) as $opt) { + $result[$opt] = array("value" => $opt, "selected" => 1); + } + return $result; + } + + /** + * @inheritdoc + */ + public function getValidators() + { + $validators = parent::getValidators(); + if ($this->internalValue != null) { + $validators[] = new CallbackValidator( + [ + "callback" => function ($value) { + $errors = []; + foreach (explode(',', $value) as $this_remote) { + $parts = []; + if (substr_count($this_remote, ':') > 1) { + foreach (explode(']', $this_remote) as $part) { + $parts[] = ltrim($part, '[:'); + } + } else { + $parts = explode(':', $this_remote); + } + if ( + filter_var($parts[0], FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) === false && + filter_var($parts[0], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6) === false + ) { + $errors[] = sprintf(gettext("hostname %s is not a valid hostname."), $parts[0]); + } elseif ( + isset($parts[1]) && + filter_var($parts[1], FILTER_VALIDATE_INT, + ['options' => ['min_range' => 1, 'max_range' => 65535]] + ) === false + ) { + $errors[] = sprintf(gettext("port %s not valid."), $parts[1]); + } + } + return $errors; + } + ] + ); + } + return $validators; + } +} \ No newline at end of file diff --git a/src/opnsense/mvc/app/models/OPNsense/OpenVPN/FieldTypes/VPNIdField.php b/src/opnsense/mvc/app/models/OPNsense/OpenVPN/FieldTypes/VPNIdField.php new file mode 100644 index 000000000..abb36d663 --- /dev/null +++ b/src/opnsense/mvc/app/models/OPNsense/OpenVPN/FieldTypes/VPNIdField.php @@ -0,0 +1,97 @@ +getParentModel()->usedVPNIds(); + } + } + + /** + * {@inheritdoc} + */ + public function setValue($value) + { + if ($value == '') { + // enforce default when not set + for ($i = 1; true ; $i++) { + if (!in_array($i, self::$internalLegacyVPNids)) { + $this->internalValue = (string)$i; + $this_uuid = $this->getParentNode()->getAttributes()['uuid']; + self::$internalLegacyVPNids[$this_uuid] = $i; + break; + } + } + } else { + parent::setValue($value); + } + } + + /** + * retrieve field validators for this field type + * @return array returns list of validators + */ + public function getValidators() + { + $validators = parent::getValidators(); + $vpnids = self::$internalLegacyVPNids; + $this_uuid = $this->getParentNode()->getAttributes()['uuid']; + + $validators[] = new CallbackValidator( + [ + "callback" => function ($value) use ($vpnids, $this_uuid) { + foreach ($vpnids as $key => $vpnid) { + if ($vpnid == $value && $key != $this_uuid) { + return [gettext('Value should be unique')]; + } + } + return []; + } + ] + ); + + return $validators; + } +} diff --git a/src/opnsense/mvc/app/models/OPNsense/OpenVPN/OpenVPN.php b/src/opnsense/mvc/app/models/OPNsense/OpenVPN/OpenVPN.php index d3899c7fe..0fc07fa27 100644 --- a/src/opnsense/mvc/app/models/OPNsense/OpenVPN/OpenVPN.php +++ b/src/opnsense/mvc/app/models/OPNsense/OpenVPN/OpenVPN.php @@ -28,7 +28,12 @@ namespace OPNsense\OpenVPN; +use Phalcon\Messages\Message; use OPNsense\Base\BaseModel; +use OPNsense\Trust\Store; +use OPNsense\Core\Config; +use OPNsense\Firewall\Util; + /** * Class OpenVPN @@ -36,6 +41,82 @@ use OPNsense\Base\BaseModel; */ class OpenVPN extends BaseModel { + /** + * {@inheritdoc} + */ + public function performValidation($validateFullModel = false) + { + $messages = parent::performValidation($validateFullModel); + $instances = []; + foreach ($this->getFlatNodes() as $key => $node) { + if ($validateFullModel || $node->isFieldChanged()) { + $tagName = $node->getInternalXMLTagName(); + $parentNode = $node->getParentNode(); + $parentKey = $parentNode->__reference; + $parentTagName = $parentNode->getInternalXMLTagName(); + if ($parentTagName === 'Instance') { + $instances[$parentKey] = $parentNode; + } + } + } + + // validate changed instances + foreach ($instances as $key => $instance) { + if ($instance->role == 'client') { + if (empty((string)$instance->remote)) { + $messages->appendMessage(new Message(gettext("Remote required"), $key . ".remote")); + } + if (empty((string)$instance->username) xor empty((string)$instance->password)) { + $messages->appendMessage( + new Message( + gettext("When ussing password authentication, both username and password are required"), + $key . ".username" + ) + ); + } + } + if (!empty((string)$instance->cert)) { + if ($instance->cert->isFieldChanged() || $validateFullModel) { + $tmp = Store::getCertificate((string)$instance->cert); + if (empty($tmp) || !isset($tmp['ca'])) { + $messages->appendMessage(new Message( + gettext("Unable to locate a Certificate Authority for this certificate"), $key . ".cert" + )); + } + } + } else { + if ( + $instance->cert->isFieldChanged() || + $instance->verify_client_cert->isFieldChanged() || + $validateFullModel + ) { + if ((string)$node->verify_client_cert != 'none') { + $messages->appendMessage(new Message( + gettext("To validate a certificate, one has to be provided "), $key . ".verify_client_cert" + )); + } + } + } + if (( + $instance->keepalive_interval->isFieldChanged() || + $instance->keepalive_timeout->isFieldChanged() || + $validateFullModel + ) && (int)(string)$instance->keepalive_timeout < (int)(string)$instance->keepalive_interval + ) { + $messages->appendMessage(new Message( + gettext("Timeout should be larger than interval"), $key . ".keepalive_timeout" + )); + } + } + return $messages; + } + + /** + * Retrieve overwrite content in legacy format + * @param string $server_id vpnid + * @param string $common_name certificate common name (or username when specified) + * @return array legacy overwrite data + */ public function getOverwrite($server_id, $common_name) { $result = []; @@ -92,4 +173,368 @@ class OpenVPN extends BaseModel } return $result; } + + /** + * The VPNid sequence is used for device creation, in which case we can't use uuid's due to their size + * @return list of vpn id's used by legacy or mvc instances + */ + public function usedVPNIds() + { + $result = []; + $cfg = Config::getInstance()->object(); + foreach (['openvpn-server', 'openvpn-client'] as $ref) { + if (isset($cfg->openvpn) && isset($cfg->openvpn->$ref)) { + foreach ($cfg->openvpn->$ref as $item) { + if (isset($item->vpnid)) { + $result[] = (string)$item->vpnid; + } + } + } + } + foreach ($this->Instances->Instance->iterateItems() as $node_uuid => $node){ + if ((string)$node->vpnid != '') { + $result[$node_uuid] = (string)$node->vpnid; + } + } + return $result; + } + + /** + * @return bool true when there is any enabled tunnel (legacy and/or mvc) + */ + public function isEnabled() + { + $cfg = Config::getInstance()->object(); + foreach (['openvpn-server', 'openvpn-client'] as $ref) { + if (isset($cfg->openvpn) && isset($cfg->openvpn->$ref)) { + foreach ($cfg->openvpn->$ref as $item) { + if (empty((string)$item->disable)) { + return true; + } + } + } + } + foreach ($this->Instances->Instance->iterateItems() as $node_uuid => $node){ + if (!empty((string)$node->enabled)) { + return true; + } + } + return false; + + } + + /** + * Find unique instance properties, either from legacy or mvc model + * Offers glue between both worlds. + * @param string $server_id vpnid (either numerical or uuid) + * @return array selection of relevant fields for downstream processes + */ + public function getInstanceById($server_id, $role=null) + { + // travers model first, two key types are valid, the id used in the device (numeric) or the uuid + foreach ($this->Instances->Instance->iterateItems() as $node_uuid => $node){ + if ( + !empty((string)$node->enabled) && + ((string)$node->vpnid == $server_id || $server_id == $node_uuid) && + ($role == null || $role == (string)$node->role) + ) { + // find static key + $this_tls = null; + $this_mode = null; + if (!empty((string)$node->tls_key)) { + $tlsnode = $this->getNodeByReference("StaticKeys.StaticKey.{$node->tls_key}"); + if (!empty($node->tls_key)) { + $this_mode = (string)$tlsnode->mode; + $this_tls = base64_encode((string)$tlsnode->key); + } + } + // find caref + $this_caref = null; + if (isset(Config::getInstance()->object()->cert)) { + foreach (Config::getInstance()->object()->cert as $cert) { + if (isset($cert->refid) && (string)$node->cert == $cert->refid) { + $this_caref = (string)$cert->caref; + } + } + } + return [ + 'role' => (string)$node->role, + 'vpnid' => $server_id, + 'authmode' => (string)$node->authmode, + 'local_group' => (string)$node->local_group, + 'strictusercn' => (string)$node->strictusercn, + 'dev_mode' => (string)$node->dev_type, + 'topology_subnet' => $node->topology == 'subnet' ? '1' : '0', + 'local_port' => (string)$node->port, + 'protocol' => (string)$node->proto, + 'mode' => !empty((string)$node->authmode) ? 'server_tls_user' : '', + 'reneg-sec' => (string)$node->{'reneg-sec'}, + 'tls' => $this_tls, + 'tlsmode' => $this_mode, + 'certref' => (string)$node->cert, + 'caref' => $this_caref, + 'description' => (string)$node->description + ]; + } + } + // when not found, try to locate the server in our legacy pool + $cfg = Config::getInstance()->object(); + foreach (['openvpn-server', 'openvpn-client'] as $section) { + if (!isset($cfg->openvpn) || !isset($cfg->openvpn->$section)) { + continue; + } + foreach ($cfg->openvpn->$section as $item) { + $this_role = explode('-', $section)[1]; + // XXX: previous legacy code did not check if the instance is enabled, we might want to revise that + if ( + isset($item->vpnid) && + $item->vpnid == $server_id && + ($role == null || $role == $this_role) + ) { + return [ + 'role' => $this_role, + 'vpnid' => (string)$item->vpnid, + 'authmode' => (string)$item->authmode, + 'local_group' => (string)$item->local_group, + 'cso_login_matching' => (string)$item->username_as_common_name, + 'strictusercn' => (string)$item->strictusercn, + 'dev_mode' => (string)$item->dev_mode, + 'topology_subnet' => (string)$item->topology_subnet, + 'local_port' => (string)$item->local_port, + 'protocol' => (string)$item->protocol, + 'mode' => (string)$item->local_port, + 'reneg-sec' => (string)$item->{'reneg-sec'}, + 'tls' => (string)$item->tls, + 'tlsmode' => (string)$item->tlsmode, + 'certref' => (string)$item->certref, + 'caref' => (string)$item->caref, + 'description' => (string)$item->description, + // legacy only (backwards compatibility) + 'compression' => (string)$item->compression, + 'crypto' => (string)$item->crypto, + 'digest' => (string)$item->digest, + 'interface' => (string)$item->interface, + ]; + } + } + } + return null; + } + + /** + * Convert options into a openvpn config file on disk + * @param string $filename target filename + * @return null + */ + private function writeConfig($filename, $options) + { + $output = ''; + foreach ($options as $key => $value) { + if ($value === null) { + $output .= $key . "\n"; + } elseif (str_starts_with($key, '<')) { + $output .= $key ."\n"; + $output .= trim($value)."\n"; + $output .= "Instances->Instance->iterateItems() as $node_uuid => $node){ + if (!empty((string)$node->enabled) && ($uuid == null || $node_uuid == $uuid)) { + $options = ['push' => [], 'route' => [], 'route-ipv6' => []]; + // mode specific settings + if ($node->role == 'client') { + $options['client'] = null; + $options['dev'] = "ovpnc{$node->vpnid}"; + $options['remote'] = []; + foreach (explode(',', (string)$node->remote) as $this_remote) { + $parts = []; + if (substr_count($this_remote, ':') > 1) { + foreach (explode(']', $this_remote) as $part) { + $parts[] = ltrim($part, '[:'); + } + } else { + $parts = explode(':', $this_remote); + } + $options['remote'][] = implode(' ', $parts); + } + if (empty((string)$node->port) && empty((string)$node->local)) { + $options['nobind'] = null; + } + if (!empty((string)$node->username) && !empty((string)$node->password)) { + $options['auth-user-pass'] = [ + "filename" => "/var/etc/openvpn/instance-{$node_uuid}.up", + "content" => "{$node->username}\n{$node->password}\n" + ]; + } + // XXX: In some cases it might be practical to drop privileges, for server mode this will be + // more difficult due to the associated script actions (and their requirements). + //$options['user'] = 'openvpn'; + //$options['group'] = 'openvpn'; + } else { + $event_script = '/usr/local/opnsense/scripts/openvpn/ovpn_event.py'; + $options['dev'] = "ovpns{$node->vpnid}"; + $options['ping-timer-rem'] = null; + $options['topology'] = (string)$node->topology; + $options['dh'] = '/usr/local/etc/inc/plugins.inc.d/openvpn/dh.rfc7919'; + if (!empty((string)$node->crl) && !empty((string)$node->cert)) { + // updated via plugins_configure('crl'); + $options['crl-verify'] = "/var/etc/openvpn/server-{$node_uuid}.crl-verify"; + } + if (!empty((string)$node->server)) { + $parts = explode('/', (string)$node->server); + $options['server'] = $parts[0] . " " . Util::CIDRToMask($parts[1]); + } + if (!empty((string)$node->server_ipv6)) { + $options['server-ipv6'] = (string)$node->server_ipv6; + } + if (!empty((string)$node->username_as_common_name)) { + $options['username-as-common-name'] = null; + } + // server only setttings + if (!empty((string)$node->server) || !empty((string)$node->server_ipv6)) { + $options['client-config-dir'] = "/var/etc/openvpn-csc/{$node->vpnid}"; + // hook event handlers + if (!empty((string)$node->authmode)) { + $options['auth-user-pass-verify'] = "\"{$event_script} --defer '{$node->vpnid}'\" via-env"; + $options['learn-address'] = "\"{$event_script} '{$node->vpnid}'\""; + } else { + // client specific profiles are being deployed using the connect event when no auth is used + $options['client-connect'] = "\"{$event_script} '{$node->vpnid}'\""; + } + $options['client-disconnect'] = "\"{$event_script} '{$node->vpnid}'\""; + $options['tls-verify'] = "\"{$event_script} '{$node->vpnid}'\""; + } + + if (!empty((string)$node->maxclients)) { + $options['max-clients'] = (string)$node->maxclients; + } + if (empty((string)$node->local) && str_starts_with((string)$node->proto, 'udp')) { + // assume multihome when no bind address is specified for udp + $options['multihome'] = null; + } + + // push options + if (!empty((string)$node->redirect_gateway)) { + $options['push'][] = "\"redirect-gateway {$node->redirect_gateway}\""; + } + if (!empty((string)$node->register_dns)) { + $options['push'][] = "\"register-dns\""; + } + if (!empty((string)$node->dns_domain)) { + $options['push'][] = "\"dhcp-option DOMAIN {$node->dns_domain}\""; + } + if (!empty((string)$node->dns_domain_search)) { + foreach (explode(',', (string)$node->dns_domain_search) as $opt) { + $options['push'][] = "\"dhcp-option DOMAIN-SEARCH {$opt}\""; + } + } + if (!empty((string)$node->dns_servers)) { + foreach (explode(',', (string)$node->dns_servers) as $opt) { + $options['push'][] = "\"dhcp-option DNS {$opt}\""; + } + } + if (!empty((string)$node->ntp_servers)) { + foreach (explode(',', (string)$node->ntp_servers) as $opt) { + $options['push'][] = "\"dhcp-option NTP {$opt}\""; + } + } + } + $options['persist-tun'] = null; + $options['persist-key'] = null; + if (!empty((string)$node->keepalive_interval) && !empty((string)$node->keepalive_timeout)) { + $options['keepalive'] = "{$node->keepalive_interval} {$node->keepalive_timeout}"; + } + + $options['dev-type'] = (string)$node->dev_type; + $options['dev-node'] = "/dev/{$node->dev_type}{$node->vpnid}"; + $options['script-security'] = '3'; + $options['writepid'] = $node->pidFilename; + $options['daemon'] = "openvpn_{$node->role}{$node->vpnid}"; + $options['management'] = "{$node->sockFilename} unix"; + $options['proto'] = (string)$node->proto; + $options['verb'] = (string)$node->verb; + $options['verify-client-cert'] = (string)$node->verify_client_cert; + + foreach ( + ['reneg-sec', 'auth-gen-token', 'port', 'local', 'data-ciphers', 'data-ciphers-fallback'] as $opt + ) { + if ((string)$node->$opt != '') { + $options[$opt] = str_replace(',', ':', (string)$node->$opt); + } + } + if (!empty((string)$node->various_flags)) { + foreach (explode(',', (string)$node->various_flags) as $opt) { + $options[$opt] = null; + } + } + + // routes (ipv4, ipv6 local or push) + foreach (['route', 'push_route'] as $type) { + foreach (explode(',', (string)$node->$type) as $item) { + if (empty($item)) { + continue; + } elseif (strpos($item, ":") === false) { + $parts = explode('/', (string)$item); + $item = $parts[0] . " " . Util::CIDRToMask($parts[1] ?? '32'); + $target_fieldname = "route"; + } else { + $target_fieldname = "route-ipv6"; + } + if ($type == 'push_route') { + $options['push'][] = "\"{$target_fieldname} $item\""; + } else { + $options[$target_fieldname][] = $item; + } + } + } + + if (!empty((string)$node->tls_key)) { + $tlsnode = $this->getNodeByReference("StaticKeys.StaticKey.{$node->tls_key}"); + if ($tlsnode) { + $options["mode}>"] = (string)$tlsnode->key; + if ($tlsnode->mode == 'auth') { + $options['key-direction'] = $node->role == 'server' ? '0' : '1'; + } + } + } + + if (!empty((string)$node->cert)) { + $tmp = Store::getCertificate((string)$node->cert); + if ($tmp && isset($tmp['prv'])) { + $options[''] = $tmp['prv']; + $options[''] = $tmp['crt']; + if (isset($tmp['ca'])) { + $options[''] = $tmp['ca']['crt']; + } + } + } + // dump to file + $this->writeConfig($node->cnfFilename, $options); + } + } + } } diff --git a/src/opnsense/mvc/app/models/OPNsense/OpenVPN/OpenVPN.xml b/src/opnsense/mvc/app/models/OPNsense/OpenVPN/OpenVPN.xml index d59e959a6..a11dcec95 100644 --- a/src/opnsense/mvc/app/models/OPNsense/OpenVPN/OpenVPN.xml +++ b/src/opnsense/mvc/app/models/OPNsense/OpenVPN/OpenVPN.xml @@ -93,5 +93,265 @@ + + + + Y + + + 1 + Y + + + Y + tun + + tun + tap + + + + Y + 3 + + 0 (No output except fatal errors.) + 1 (Normal) + 2 (Normal) + 3 (Normal) + 4 (Normal) + 5 (log packets) + 6 (debug) + 7 (debug) + 8 (debug) + 9 (debug) + 10 (debug) + 11 (debug) + + + + Y + udp + + UDP + UDP (IPv4) + UDP (IPv6) + TCP + TCP (IPv4) + TCP (IPv6) + + + + + + N + N + N + + + Y + subnet + + net30 + p2p + subnet + + + + + + server + Y + + Client + Server + + + + N + N + + + N + N + + + , + N + Y + N + + + , + N + Y + N + + + N + None + Please select a valid certificate from the list + + + N + crl + None + Please select a valid certificate from the list + + + Y + require + + none + required + + + + N + Y + + AES-256-GCM + AES-128-GCM + CHACHA20-POLY1305 + + + + N + + AES-256-GCM + AES-128-GCM + CHACHA20-POLY1305 + + + + + + OPNsense.OpenVPN.OpenVPN + StaticKeys.StaticKey + mode,description + [%s] %s + + + N + + + N + Y + None + Local Database + + + N + + + N + Y + + client-to-client + duplicate-cn + passtos + persist-remote-ip + route-nopull + route-noexec + remote-random + + + + 0 + Y + + + 0 + Y + + + + + + + 1 + 65535 + + + 10 + 0 + 65535 + + + 60 + 0 + 65535 + + + 0 + 65535 + N + + + 0 + 65535 + N + + + N + Y + + local + autolocal + default + bypass dhcp + bypass dns + block local + ipv6 (default) + not ipv4 (default) + + + + 0 + Y + + + N + + + N + , + Y + + + N + N + , + Y + + + N + N + , + Y + + + N + + + + + + + Y + crypt + + auth (Authenticate control channel packets) + crypt (Encrypt and authenticate all control channel packets) + + + + Y + A key is required, generate one with the button + + + N + + + diff --git a/src/opnsense/mvc/app/views/OPNsense/OpenVPN/instances.volt b/src/opnsense/mvc/app/views/OPNsense/OpenVPN/instances.volt new file mode 100644 index 000000000..e7a6cb9fe --- /dev/null +++ b/src/opnsense/mvc/app/views/OPNsense/OpenVPN/instances.volt @@ -0,0 +1,171 @@ +{# + # Copyright (c) 2023 Deciso B.V. + # All rights reserved. + # + # Redistribution and use in source and binary forms, with or without modification, + # are permitted provided that the following conditions are met: + # + # 1. Redistributions of source code must retain the above copyright notice, + # this list of conditions and the following disclaimer. + # + # 2. Redistributions in binary form must reproduce the above copyright notice, + # this list of conditions and the following disclaimer in the documentation + # and/or other materials provided with the distribution. + # + # THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, + # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + # AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + # AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, + # OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + # POSSIBILITY OF SUCH DAMAGE. + #} + + + + + +
+
+ + + + + + + + + + + + + + + + + + + +
{{ lang._('ID') }}{{ lang._('Enabled') }}{{ lang._('Description') }}{{ lang._('Role') }}{{ lang._('Type') }}{{ lang._('Commands') }}
+ + +
+
+ +
+ +

+
+
+
+ + + + + + + + + + + + + + + + + +
{{ lang._('ID') }}{{ lang._('Description') }}{{ lang._('Commands') }}
+ + +
+
+
+ +{{ partial("layout_partials/base_dialog",['fields':formDialogInstance,'id':'DialogInstance','label':lang._('Edit Instance')])}} +{{ partial("layout_partials/base_dialog",['fields':formDialogStaticKey,'id':'DialogStaticKey','label':lang._('Edit Static Key')])}} + diff --git a/src/opnsense/scripts/openvpn/client_connect.php b/src/opnsense/scripts/openvpn/client_connect.php index 4e63dfa3b..83b55216a 100755 --- a/src/opnsense/scripts/openvpn/client_connect.php +++ b/src/opnsense/scripts/openvpn/client_connect.php @@ -27,7 +27,7 @@ * POSSIBILITY OF SUCH DAMAGE. */ -require_once("config.inc"); +require_once("legacy_bindings.inc"); require_once("util.inc"); require_once("plugins.inc.d/openvpn.inc"); @@ -36,25 +36,19 @@ openlog("openvpn", LOG_ODELAY, LOG_AUTH); $common_name = getenv("common_name"); $vpnid = getenv("auth_server"); $config_file = getenv("config_file"); -if (isset($config['openvpn']['openvpn-server'])) { - foreach ($config['openvpn']['openvpn-server'] as $server) { - if ($server['vpnid'] == $vpnid) { - // XXX: Eventually we should move the responsibility to determine if we do want to write a file - // to here instead of the configuration file (always call event, filter relevant). - $cso = (new OPNsense\OpenVPN\OpenVPN())->getOverwrite($vpnid, $common_name); - if (empty($cso)) { - $cso = array("common_name" => $common_name); - } - if (!empty($config_file)) { - $cso_filename = openvpn_csc_conf_write($cso, $server, $config_file); - if (!empty($cso_filename)) { - syslog(LOG_NOTICE, "client config created @ {$cso_filename}"); - } - } else { - syslog(LOG_NOTICE, "unable to write client config for {$common_name}, missing target filename"); - } - break; +$server = (new OPNsense\OpenVPN\OpenVPN())->getInstanceById($vpnid, 'server'); +if ($server) { + $cso = (new OPNsense\OpenVPN\OpenVPN())->getOverwrite($vpnid, $common_name); + if (empty($cso)) { + $cso = array("common_name" => $common_name); + } + if (!empty($config_file)) { + $cso_filename = openvpn_csc_conf_write($cso, $server, $config_file); + if (!empty($cso_filename)) { + syslog(LOG_NOTICE, "client config created @ {$cso_filename}"); } + } else { + syslog(LOG_NOTICE, "unable to write client config for {$common_name}, missing target filename"); } } diff --git a/src/opnsense/scripts/openvpn/kill_session.py b/src/opnsense/scripts/openvpn/kill_session.py index 0bf0a103a..c2257181f 100755 --- a/src/opnsense/scripts/openvpn/kill_session.py +++ b/src/opnsense/scripts/openvpn/kill_session.py @@ -59,13 +59,15 @@ def ovpn_cmd(filename, cmd): if __name__ == '__main__': parser = argparse.ArgumentParser() - parser.add_argument('server_id', help='server/client id (where to find socket)', type=int) + parser.add_argument('server_id', help='server/client id (where to find socket)', type=str) parser.add_argument('session_id', help='session id (address+port) or common name') args = parser.parse_args() socket_name = None for filename in glob.glob("/var/etc/openvpn/*.sock"): basename = os.path.basename(filename) - if basename in ['client%d.sock'%args.server_id, 'server%d.sock'%args.server_id]: + if basename in [ + 'client%s.sock'%args.server_id, 'server%s.sock'%args.server_id, 'instance-%s.sock'%args.server_id + ]: socket_name = filename break if socket_name: diff --git a/src/opnsense/scripts/openvpn/ovpn_service_control.php b/src/opnsense/scripts/openvpn/ovpn_service_control.php new file mode 100755 index 000000000..489d9449a --- /dev/null +++ b/src/opnsense/scripts/openvpn/ovpn_service_control.php @@ -0,0 +1,183 @@ +#!/usr/local/bin/php +__devnode}")) { + mwexecf('/sbin/ifconfig %s create', [$instance->__devnode]); + } + if (!does_interface_exist($instance->__devname)) { + mwexecf('/sbin/ifconfig %s name %s', [$instance->__devnode, $instance->__devname]); + mwexecf('/sbin/ifconfig %s group openvpn', [$instance->__devname]); + } +} + +function ovpn_start($instance, $fhandle) +{ + setup_interface($instance); + if (!isvalidpid($instance->pidFilename)) { + if ($instance->role == 'server') { + if (is_file($instance->csoDirectory)) { + unlink($instance->csoDirectory); + } + @mkdir($instance->csoDirectory, 0750, true); + } + if (!mwexecf('/usr/local/sbin/openvpn --config %s', $instance->cnfFilename)) { + $pid = waitforpid($instance->pidFilename, 10); + if ($pid) { + syslog(LOG_NOTICE, "OpenVPN {$instance->role} {$instance->vpnid} instance started on PID {$pid}."); + } else { + syslog(LOG_WARNING, "OpenVPN {$instance->role} {$instance->vpnid} instance start timed out."); + } + } + // write instance details + $data = [ + 'md5' => md5_file($instance->cnfFilename), + 'vpnid' => (string)$instance->vpnid, + 'devname' => (string)$instance->__devname, + ]; + fseek($fhandle, 0); + ftruncate($fhandle, 0); + fwrite($fhandle, json_encode($data)); + } +} + +function ovpn_stop($instance) +{ + killbypid($instance->pidFilename); + @unlink($instance->pidFilename); + @unlink($instance->sockFilename); +} + +function ovpn_instance_stats($instance, $fhandle) +{ + fseek($fhandle, 0); + $data = json_decode(stream_get_contents($fhandle) ?? '', true) ?? []; + $data['has_changed'] = ($data['md5'] ?? '') != @md5_file($instance->cnfFilename); + foreach (['vpnid', 'devname'] as $fieldname) { + $data[$fieldname] = $data[$fieldname] ?? null; + } + return $data; +} + + +$opts = getopt('ah', [], $optind); +$args = array_slice($argv, $optind); + +/* setup syslog logging */ +openlog("openvpn", LOG_ODELAY, LOG_AUTH); + +if (isset($opts['h']) || empty($args) || !in_array($args[0], ['start', 'stop', 'restart', 'configure'])) { + echo "Usage: ovpn_service_control.php [-a] [-h] [stop|start|restart|configure] [uuid]\n\n"; + echo "\t-a all instances\n"; +} elseif (isset($opts['a']) || !empty($args[1])) { + $mdl = new OPNsense\OpenVPN\OpenVPN(); + $instance_id = $args[1] ?? null; + $action = $args[0]; + + if ($action != 'stop') { + $mdl->generateInstanceConfig($instance_id); + } + $instance_ids = []; + foreach ($mdl->Instances->Instance->iterateItems() as $key => $node) { + if (empty((string)$node->enabled)) { + continue; + } + if ($instance_id != null && $key != $instance_id) { + continue; + } + $instance_ids[] = $key; + $statHandle = fopen($node->statFilename, "a+"); + if (flock($statHandle, LOCK_EX)) { + $instance_stats = ovpn_instance_stats($node, $statHandle); + switch ($action) { + case 'stop': + ovpn_stop($node); + break; + case 'start': + ovpn_start($node, $statHandle); + break; + case 'restart': + ovpn_stop($node); + ovpn_start($node, $statHandle); + break; + case 'configure': + if ($instance_stats['has_changed']) { + ovpn_stop($node); + ovpn_start($node, $statHandle); + } + break; + } + // cleanup old interface when needed + if (!empty($instance_stats['devname']) && $instance_stats['devname'] != $node->__devname) { + legacy_interface_destroy($instance_stats['devname']); + } + flock($statHandle, LOCK_UN); + } + fclose($statHandle); + } + /** + * When -a is specified, cleaup up old or disabled instances + */ + if ($instance_id == null) { + $to_clean = []; + foreach (glob('/var/etc/openvpn/instance-*') as $filename) { + $uuid = explode('.', explode('/var/etc/openvpn/instance-', $filename)[1])[0]; + if (!in_array($uuid, $instance_ids)) { + if (!isset($to_clean[$uuid])) { + $to_clean[$uuid] = ['filenames' => [], 'stat' => []]; + } + $to_clean[$uuid]['filenames'][] = $filename; + if (str_ends_with($filename, '.stat')) { + $to_clean[$uuid]['stat'] = json_decode(file_get_contents($filename) ?? '', true) ?? []; + } + } + } + foreach ($to_clean as $uuid => $payload) { + $pidfile = "/var/run/ovpn-instance-{$uuid}.pid"; + if (isvalidpid($pidfile)) { + killbypid($pidfile); + } + @unlink($pidfile); + if (is_array($payload['stat']) && !empty($payload['stat']['devname'])) { + legacy_interface_destroy($payload['stat']['devname']); + } + foreach ($payload['filenames'] as $filename) { + @unlink($filename); + } + } + } + closelog(); +} + diff --git a/src/opnsense/scripts/openvpn/ovpn_status.py b/src/opnsense/scripts/openvpn/ovpn_status.py index cdd7d17df..99dc574ec 100755 --- a/src/opnsense/scripts/openvpn/ovpn_status.py +++ b/src/opnsense/scripts/openvpn/ovpn_status.py @@ -113,8 +113,22 @@ def main(params): response = {} for filename in glob.glob('/var/etc/openvpn/*.sock'): bname = os.path.basename(filename)[:-5] - this_id = bname[6:] - if bname.startswith('server') and 'server' in params.options: + this_id = bname[9:] if bname.startswith('inst') else bname[6:] + if bname.startswith('instance-'): + this_status = ovpn_status(filename) + role = 'client' if 'bytes_received' in this_status else 'server' + if role not in response: + response[role] = {} + if role == 'server' and 'server' in params.options: + response['server'][this_id] = this_status + if 'status' not in response['server'][this_id]: + # p2p mode, no client_list or routing_table + response['server'][this_id].update(ovpn_state(filename)) + elif role == 'client' and 'client' in params.options: + response['client'][this_id] = ovpn_state(filename) + if response['client'][this_id]['status'] != 'failed': + response['client'][this_id].update(this_status) + elif bname.startswith('server') and 'server' in params.options: if 'server' not in response: response['server'] = {} response['server'][this_id] = ovpn_status(filename) diff --git a/src/opnsense/scripts/openvpn/user_pass_verify.php b/src/opnsense/scripts/openvpn/user_pass_verify.php index 69702786b..518d637eb 100755 --- a/src/opnsense/scripts/openvpn/user_pass_verify.php +++ b/src/opnsense/scripts/openvpn/user_pass_verify.php @@ -33,22 +33,6 @@ require_once("util.inc"); require_once("interfaces.inc"); require_once("plugins.inc.d/openvpn.inc"); -/** - * @param string $serverid server identifier - * @return array|null openvpn server properties - */ -function get_openvpn_server($serverid) -{ - global $config; - if (isset($config['openvpn']['openvpn-server'])) { - foreach ($config['openvpn']['openvpn-server'] as $server) { - if ($server['vpnid'] == $serverid) { - return $server; - } - } - } - return null; -} /** * Parse provisioning properties supplied by the authenticator @@ -108,7 +92,7 @@ function do_auth($common_name, $serverid, $method, $auth_file) } } } - $a_server = $serverid !== null ? get_openvpn_server($serverid) : null; + $a_server = $serverid !== null ? (new OPNsense\OpenVPN\OpenVPN())->getInstanceById($serverid, 'server') : null; if ($a_server == null) { return "OpenVPN '$serverid' was not found. Denying authentication for user {$username}"; } elseif (!empty($a_server['strictusercn']) && $username != $common_name) { diff --git a/src/opnsense/service/conf/actions.d/actions_openvpn.conf b/src/opnsense/service/conf/actions.d/actions_openvpn.conf index 134037b72..6c0da4b08 100644 --- a/src/opnsense/service/conf/actions.d/actions_openvpn.conf +++ b/src/opnsense/service/conf/actions.d/actions_openvpn.conf @@ -9,3 +9,33 @@ command:/usr/local/opnsense/scripts/openvpn/kill_session.py parameters: %s %s type:script_output message:Kill OpenVPN session %s - %s + +[genkey] +command:/usr/local/sbin/openvpn --genkey secret /dev/stdout +parameters: +type:script_output +message: Generate new OpenVPN static key + +[start] +command:/usr/local/opnsense/scripts/openvpn/ovpn_service_control.php +parameters: start %s +type:script +message: start openvpn instance %s + +[stop] +command:/usr/local/opnsense/scripts/openvpn/ovpn_service_control.php +parameters: stop %s +type:script +message: stop openvpn instance %s + +[restart] +command:/usr/local/opnsense/scripts/openvpn/ovpn_service_control.php +parameters: restart %s +type:script +message: restart openvpn instance %s + +[configure] +command:/usr/local/opnsense/scripts/openvpn/ovpn_service_control.php +parameters: -a configure +type:script +message: configure openvpn instances diff --git a/src/www/widgets/widgets/openvpn.widget.php b/src/www/widgets/widgets/openvpn.widget.php index ff47238fb..9d810c57b 100644 --- a/src/www/widgets/widgets/openvpn.widget.php +++ b/src/www/widgets/widgets/openvpn.widget.php @@ -29,6 +29,52 @@ require_once("guiconfig.inc"); require_once("plugins.inc.d/openvpn.inc"); + +function openvpn_config() +{ + global $config; + $result = []; + foreach (['openvpn-server', 'openvpn-client'] as $section) { + $result[$section] = []; + if (!empty($config['openvpn'][$section])) { + foreach ($config['openvpn'][$section] as $settings) { + if (empty($settings) || isset($settings['disable'])) { + continue; + } + $server = []; + $default_port = ($section == 'openvpn-server') ? 1194 : ''; + $server['port'] = !empty($settings['local_port']) ? $settings['local_port'] : $default_port; + $server['mode'] = $settings['mode']; + if (empty($settings['description'])) { + $settings['description'] = ($section == 'openvpn-server') ? 'Server' : 'Client'; + } + $server['name'] = "{$settings['description']} {$settings['protocol']}:{$server['port']}"; + $server['vpnid'] = $settings['vpnid']; + $result[$section][] = $server; + } + } + } + + foreach ((new OPNsense\OpenVPN\OpenVPN())->Instances->Instance->iterateItems() as $key => $node) { + if (!empty((string)$node->enabled)) { + $section = "openvpn-{$node->role}"; + $default_port = ($section == 'openvpn-server') ? 1194 : ''; + $default_desc = ($section == 'openvpn-server') ? 'Server' : 'Client'; + $server = [ + 'port' => !empty((string)$node->port) ? (string)$node->port : $default_port, + 'mode' => (string)$node->role, + 'description' => !empty((string)$node->description) ? (string)$node->description : $default_desc, + 'name' => "{$node->description} {$node->proto}:{$node->port}", + 'vpnid' => $key + ]; + $result[$section][] = $server; + } + } + + return $result; +} + + $openvpn_status = json_decode(configd_run('openvpn connections client,server'), true) ?? []; $openvpn_cfg = openvpn_config(); foreach ($openvpn_cfg as $section => &$ovpncfg) {