diff --git a/security/acme-client/Makefile b/security/acme-client/Makefile index 59485d2d6..dea8204ec 100644 --- a/security/acme-client/Makefile +++ b/security/acme-client/Makefile @@ -1,5 +1,5 @@ PLUGIN_NAME= acme-client -PLUGIN_VERSION= 1.0 +PLUGIN_VERSION= 1.1 PLUGIN_COMMENT= Let's Encrypt client PLUGIN_MAINTAINER= opnsense@moov.de diff --git a/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/ActionsController.php b/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/ActionsController.php new file mode 100644 index 000000000..512accd09 --- /dev/null +++ b/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/ActionsController.php @@ -0,0 +1,46 @@ +view->title = "Let's Encrypt Restart Actions"; + // include form definitions + $this->view->formDialogAction = $this->getForm("dialogAction"); + // choose template + $this->view->pick('OPNsense/AcmeClient/actions'); + } +} diff --git a/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/Api/AccountsController.php b/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/Api/AccountsController.php index 8eaf12334..a47695edc 100644 --- a/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/Api/AccountsController.php +++ b/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/Api/AccountsController.php @@ -202,7 +202,7 @@ class AccountsController extends ApiControllerBase $grid = new UIModelGrid($mdlAcme->accounts->account); return $grid->fetchBindRequest( $this->request, - array("enabled", "name", "email","accountid"), + array("enabled", "name", "email"), "name" ); } diff --git a/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/Api/ActionsController.php b/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/Api/ActionsController.php new file mode 100644 index 000000000..2c9c666ea --- /dev/null +++ b/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/Api/ActionsController.php @@ -0,0 +1,209 @@ +"failed","validations" => array()); + // perform validation + $valMsgs = $mdl->performValidation(); + foreach ($valMsgs as $field => $msg) { + // replace absolute path to attribute for relative one at uuid. + if ($node != null) { + $fieldnm = str_replace($node->__reference, $reference, $msg->getField()); + $result["validations"][$fieldnm] = $msg->getMessage(); + } else { + $result["validations"][$msg->getField()] = $msg->getMessage(); + } + } + + // serialize model to config and save when there are no validation errors + if (count($result['validations']) == 0) { + // save config if validated correctly + $mdl->serializeToConfig(); + + Config::getInstance()->save(); + $result = array("result" => "saved"); + } + + return $result; + } + + /** + * retrieve action settings or return defaults + * @param $uuid item unique id + * @return array + */ + public function getAction($uuid = null) + { + $mdlAcme = new AcmeClient(); + if ($uuid != null) { + $node = $mdlAcme->getNodeByReference('actions.action.'.$uuid); + if ($node != null) { + // return node + return array("action" => $node->getNodes()); + } + } else { + // generate new node, but don't save to disc + $node = $mdlAcme->actions->action->add(); + return array("action" => $node->getNodes()); + } + return array(); + } + + /** + * update action with given properties + * @param $uuid item unique id + * @return array + */ + public function setAction($uuid) + { + if ($this->request->isPost() && $this->request->hasPost("action")) { + $mdlAcme = new AcmeClient(); + if ($uuid != null) { + $node = $mdlAcme->getNodeByReference('actions.action.'.$uuid); + if ($node != null) { + $node->setNodes($this->request->getPost("action")); + return $this->save($mdlAcme, $node, "action"); + } + } + } + return array("result"=>"failed"); + } + + /** + * add new action and set with attributes from post + * @return array + */ + public function addAction() + { + $result = array("result"=>"failed"); + if ($this->request->isPost() && $this->request->hasPost("action")) { + $mdlAcme = new AcmeClient(); + $node = $mdlAcme->actions->action->Add(); + $node->setNodes($this->request->getPost("action")); + return $this->save($mdlAcme, $node, "action"); + } + return $result; + } + + /** + * delete action by uuid + * @param $uuid item unique id + * @return array status + */ + public function delAction($uuid) + { + $result = array("result"=>"failed"); + if ($this->request->isPost()) { + $mdlAcme = new AcmeClient(); + if ($uuid != null) { + if ($mdlAcme->actions->action->del($uuid)) { + // if item is removed, serialize to config and save + $mdlAcme->serializeToConfig(); + Config::getInstance()->save(); + $result['result'] = 'deleted'; + } else { + $result['result'] = 'not found'; + } + } + } + return $result; + } + + /** + * toggle action by uuid (enable/disable) + * @param $uuid item unique id + * @param $enabled desired state enabled(1)/disabled(0), leave empty for toggle + * @return array status + */ + public function toggleAction($uuid, $enabled = null) + { + + $result = array("result" => "failed"); + if ($this->request->isPost()) { + $mdlAcme = new AcmeClient(); + if ($uuid != null) { + $node = $mdlAcme->getNodeByReference('actions.action.' . $uuid); + if ($node != null) { + if ($enabled == "0" || $enabled == "1") { + $node->enabled = (string)$enabled; + } elseif ((string)$node->enabled == "1") { + $node->enabled = "0"; + } else { + $node->enabled = "1"; + } + $result['result'] = $node->enabled; + // if item has toggled, serialize to config and save + $mdlAcme->serializeToConfig(); + Config::getInstance()->save(); + } + } + } + return $result; + } + + /** + * search actions + * @return array + */ + public function searchAction() + { + $this->sessionClose(); + $mdlAcme = new AcmeClient(); + $grid = new UIModelGrid($mdlAcme->actions->action); + return $grid->fetchBindRequest( + $this->request, + array("enabled", "name", "description"), + "name" + ); + } +} diff --git a/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/Api/CertificatesController.php b/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/Api/CertificatesController.php index c326ed77a..fcba67fa2 100644 --- a/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/Api/CertificatesController.php +++ b/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/Api/CertificatesController.php @@ -203,7 +203,7 @@ class CertificatesController extends ApiControllerBase $grid = new UIModelGrid($mdlAcme->certificates->certificate); return $grid->fetchBindRequest( $this->request, - array("enabled", "name", "altNames", "description","certificateid"), + array("enabled", "name", "altNames", "description"), "name" ); } diff --git a/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/Api/SettingsController.php b/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/Api/SettingsController.php index 30f6b7d30..b62ce4f80 100644 --- a/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/Api/SettingsController.php +++ b/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/Api/SettingsController.php @@ -34,6 +34,7 @@ use \OPNsense\Core\Backend; use \OPNsense\Cron\Cron; use \OPNsense\Core\Config; use \OPNsense\Base\UIModelGrid; +use \OPNsense\AcmeClient\AcmeClient; /** * Class SettingsController @@ -48,12 +49,10 @@ class SettingsController extends ApiMutableModelControllerBase * create new cron job or return already available one * @return array status action */ - public function fetchRBCronAction() + public function fetchCronIntegrationAction() { $result = array("result" => "no change"); - // TODO: How to force the system to write-out the cronjob? - if ($this->request->isPost()) { $mdlAcme = $this->getModel(); $backend = new Backend(); @@ -81,7 +80,7 @@ class SettingsController extends ApiMutableModelControllerBase // cron item just created. $mdlAcme->serializeToConfig($validateFullModel = false, $disable_validation = true); Config::getInstance()->save(); - // Regenerate the crontab + // Refresh the crontab $backend->configdRun('template reload OPNsense/Cron'); $result['result'] = "new"; $result['uuid'] = $cron_uuid; @@ -92,11 +91,13 @@ class SettingsController extends ApiMutableModelControllerBase } elseif ((string)$mdlAcme->settings->UpdateCron != "" and ((string)$mdlAcme->settings->autoRenewal == "0" or (string)$mdlAcme->settings->enabled == "0")) { + // Get UUID, clean existin entry $cron_uuid = (string)$mdlAcme->settings->UpdateCron; $mdlAcme->settings->UpdateCron = null; $mdlCron = new Cron(); + // Delete the cronjob item if ($mdlCron->jobs->job->del($cron_uuid)) { - // if item is removed, serialize to config and save + // If item is removed, serialize to config and save $mdlCron->serializeToConfig(); $mdlAcme->serializeToConfig($validateFullModel = false, $disable_validation = true); Config::getInstance()->save(); @@ -111,4 +112,269 @@ class SettingsController extends ApiMutableModelControllerBase return $result; } + + /** + * integrate with HAProxy plugin or return if already done + * @return array status action + */ + public function fetchHAProxyIntegrationAction() + { + $result = array("result" => "no change"); + + if ($this->request->isPost()) { + $mdlAcme = $this->getModel(); + + // Check if the required plugin is installed + if ((string)$mdlAcme->isPluginInstalled('os-haproxy') != "1") { + $this->getLogger()->error("LE check: HAProxy plugin is NOT installed, skipping integration"); + return($result); + } + + // Setup only if AcmeClient and HAProxy integration is enabled. + // NOTE: We provide HAProxy integration no matter if the HAProxy plugin + // is actually enabled or not. This should avoid confusion. + if ((string)$mdlAcme->settings->haproxyIntegration == "1" and + (string)$mdlAcme->settings->enabled == "1") { + $mdlHAProxy = new \OPNsense\HAProxy\HAProxy(); + $backend = new Backend(); + + // Get current status of HAProxy integration by running various checks. + $integration_found = false; // Switch to TRUE if something is found. + $integration_complete = true; // Switch to FALSE if anything is missing. + $integration_changes = false; // Switch to TRUE if config was changes. + + // Check: HAProxy ACL + $acl_ref = (string)$mdlAcme->settings->haproxyAclRef; + if (!empty($acl_ref)) { + $integration_found = true; // We found something. + // Make sure the item was not deleted. + if ($mdlHAProxy->getByAclID($acl_ref) === null) { + $this->getLogger()->error("LE check: HAProxy integration is incomplete: ACL item not found"); + $integration_complete = false; // Item is broken. + } + } else { + $integration_complete = false; // Item is missing. + } + + // Check: HAProxy action + $action_ref = (string)$mdlAcme->settings->haproxyActionRef; + if (!empty($action_ref)) { + $integration_found = true; // We found something. + // Make sure the item was not deleted. + if ($mdlHAProxy->getByActionID($action_ref) === null) { + $this->getLogger()->error("LE check: HAProxy integration is incomplete: action item not found"); + $integration_complete = false; // Item is broken. + } + } else { + $integration_complete = false; // Item is missing. + } + + // Check: HAProxy server + $server_ref = (string)$mdlAcme->settings->haproxyServerRef; + if (!empty($server_ref)) { + $integration_found = true; // We found something. + // Make sure the item was not deleted. + if ($mdlHAProxy->getByServerID($server_ref) === null) { + $this->getLogger()->error("LE check: HAProxy integration is incomplete: server item not found"); + $integration_complete = false; // Item is broken. + } + } else { + $integration_complete = false; // Item is missing. + } + + // Check: HAProxy backend + $backend_ref = (string)$mdlAcme->settings->haproxyBackendRef; + if (!empty($backend_ref)) { + $integration_found = true; // We found something. + // Make sure the item was not deleted. + if ($mdlHAProxy->getByBackendID($backend_ref) === null) { + $this->getLogger()->error("LE check: HAProxy integration is incomplete: backend item not found"); + $integration_complete = false; // Item is broken. + } + } else { + $integration_complete = false; // Item is missing. + } + + // Check if HAProxy integration is already complete. + if ($integration_found and $integration_complete) { + $this->getLogger()->error("LE check: HAProxy integration is complete"); + } else { + $integration_changes = true; + // Check if we need to remove relics of incomplete HAProxy integration. + // NOTE: We try to automatically repair a broken HAProxy integration, + // although the user may have deleted some items intentionally. + // As long as the HAProxy integration is enabled we assume that + // this is an error that should *automatically* be fixed. + if ($integration_found and !$integration_complete) { + // NOTE: We ignore the return value of the del() calls + // too keep this as simple as possible. + $this->getLogger()->error("LE check: HAProxy integration is incomplete, removing relics"); + // Remove obsolete backend item + if (!empty($backend_ref)) { + if ($mdlHAProxy->backends->backend->del($backend_ref)) { + $this->getLogger()->error("LE HAProxy integration: deleted obsolete backend item"); + } + } + // Remove obsolete server item + if (!empty($server_ref)) { + if ($mdlHAProxy->servers->server->del($server_ref)) { + $this->getLogger()->error("LE HAProxy integration: deleted obsolete server item"); + } + } + // Remove obsolete action item + if (!empty($action_ref)) { + if ($mdlHAProxy->actions->action->del($action_ref)) { + $this->getLogger()->error("LE HAProxy integration: deleted obsolete action item"); + } + } + // Remove obsolete ACL item + if (!empty($acl_ref)) { + if ($mdlHAProxy->acls->acl->del($acl_ref)) { + $this->getLogger()->error("LE HAProxy integration: deleted obsolete ACL item"); + } + } + // TODO: Remove obsolete ACL link from frontends + + // NOTE: We don't clear the settings refs here, because they + // will be overwritten later anyway. + $result['result'] = "repaired"; + } else { + $this->getLogger()->error("LE check: HAProxy integration initializing"); + $result['result'] = "new"; + } + + // Get TCP port for internal acme webserver from config. + $acme_port = (string)$mdlAcme->settings->challengePort; + + // Add a new HAProxy ACL + $acl_uuid = $mdlHAProxy->newAcl( + "find_acme_challenge", + "Added by Let's Encrypt plugin", + "path_starts_with", + "0", + "/.well-known/acme-challenge/" + ); + //$this->getLogger()->error("LE acl: ${acl_uuid}"); + + // Add a new HAProxy backend + $backend_uuid = $mdlHAProxy->newBackend( + "1", + "acme_challenge_backend", + "Added by Let's Encrypt plugin", + "http", + "source", + "", + "" + ); + //$this->getLogger()->error("LE backend: ${backend_uuid}"); + + // Add a new HAProxy action + $action_uuid = $mdlHAProxy->newAction( + "redirect_acme_challenges", + "Added by Let's Encrypt plugin", + "if", + "", + "and", + "use_backend", + // Use the new backend uuid in field "useBackend" + $backend_uuid, + "", + "", + "", + "" + ); + //$this->getLogger()->error("LE action: ${action_uuid}"); + // NOTE: This action is linked to frontends. + $action_ref = $action_uuid; + + // Add a new HAProxy server + $server_uuid = $mdlHAProxy->newServer( + "acme_challenge_host", + "Added by Let's Encrypt plugin", + "127.0.0.1", + $acme_port, + "active", + "0", + "0", + "" + ); + //$this->getLogger()->error("LE server: ${server_uuid}"); + + // Update hidden fields to signal that HAProxy integration is complete. + $mdlAcme->settings->haproxyAclRef = $acl_uuid; + $mdlAcme->settings->haproxyActionRef = $action_uuid; + $mdlAcme->settings->haproxyServerRef = $server_uuid; + $mdlAcme->settings->haproxyBackendRef = $backend_uuid; + + // Link new ACL to HAProxy action + $link_acl_result = $mdlHAProxy->linkAclToAction($acl_uuid,$action_uuid); + //$this->getLogger()->error("LE link acl result: ${link_acl_result}"); + + // Link new server to HAProxy backend + $link_server_result = $mdlHAProxy->linkServerToBackend($server_uuid,$backend_uuid); + //$this->getLogger()->error("LE link server result: ${link_server_result}"); + } + + // Ensure HAProxy frontend additions have been applied. + foreach ($mdlAcme->getNodeByReference('validations.validation')->__items as $validation) { + // Find all (enabled) validation methods with HAProxy integration. + if ((string)$validation->enabled == "1" and + (string)$validation->method == "http01" and + (string)$validation->http_service == "haproxy") { + //$this->getLogger()->error("LE HAProxy DEBUG: checking validation method: " . (string)$validation->name); + $_frontends = explode(',', $validation->http_haproxyFrontends); + // Walk through all linked frontends. + foreach ($_frontends as $_frontend) { + //$this->getLogger()->error("LE HAProxy DEBUG: checking frontend: ${_frontend}"); + $frontend = $mdlHAProxy->getByFrontendID($_frontend); + // Make sure the frontend was found in config. + if (!empty((string)$frontend->id)) { + // Check if the HAProxy ACME Action is linked to this frontend. + $_actions = $frontend->linkedActions; + if (strpos($_actions,$action_ref) !== false) { + // Match! Nothing to do. + } else { + // Link to ACME Action is currently missing: add it! + if (!empty((string)$_actions)) { + // Extend existing string. + $_actions .= ",${action_ref}"; + } else { + // First linked Action for this frontend. + $_actions = $action_ref; + } + // Add modified list of linked Actions to frontend. + $frontend->linkedActions = $_actions; + $this->getLogger()->error("LE HAProxy integration: updating frontend ${_frontend}"); + // We need to write changes to config. + $integration_changes = true; + } + } + } + } + } + + // Changes made to configuration? + if ($integration_changes === true) { + $this->getLogger()->error("LE HAProxy integration: saving updated configuration"); + // Save updated configuration. + // Do NOT validate because the current in-memory model doesn't know about the + // HAProxy items just created. + // FIXME: works, but still leads to "Related item not found" errors in the log file + $mdlHAProxy->serializeToConfig($validateFullModel = false, $disable_validation = true); + $mdlAcme->serializeToConfig($validateFullModel = false, $disable_validation = true); + Config::getInstance()->save(); + + // Reconfigure HAProxy + $backend->configdRun('template reload OPNsense/HAProxy'); + $response = $backend->configdRun("haproxy restart"); + } + + } else { + // NOTE: HAProxy integration is NOT removed if the user disables it, because + // we might destroy changes made by the user when doing so. + } + } + + return $result; + } } diff --git a/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/Api/ValidationsController.php b/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/Api/ValidationsController.php index fbe9f18a7..70a3cafc0 100644 --- a/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/Api/ValidationsController.php +++ b/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/Api/ValidationsController.php @@ -202,7 +202,7 @@ class ValidationsController extends ApiControllerBase $grid = new UIModelGrid($mdlAcme->validations->validation); return $grid->fetchBindRequest( $this->request, - array("enabled", "name", "description","validationid"), + array("enabled", "name", "description"), "name" ); } diff --git a/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/forms/dialogAction.xml b/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/forms/dialogAction.xml new file mode 100644 index 000000000..51254439c --- /dev/null +++ b/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/forms/dialogAction.xml @@ -0,0 +1,42 @@ +
+ + action.enabled + + checkbox + Enable this restart action. + + + action.name + + text + Name to identify this restart action. + + + action.description + + text + Description for this restart action. + + + action.type + + dropdown + Pre-defined commands for this restart action. + + + + header + + + action.configd + + dropdown + Select a pre-defined system command which should be run for this action. + + + action.custom + + textbox + Specify a custom commands which should be run for this action. + +
diff --git a/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/forms/dialogCertificate.xml b/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/forms/dialogCertificate.xml index 9dee719d4..da015154c 100644 --- a/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/forms/dialogCertificate.xml +++ b/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/forms/dialogCertificate.xml @@ -23,7 +23,7 @@ select_multiple true -
NOTE:Cannot be altered once the certificate was signed by the Let's Encrypt Authority! You need to create a new certificate to add additional names.
]]>
+
NOTE:You need to forcefully re-issue the certificate if you change "Alt Names" after the certificate was signed by the Let's Encrypt Authority! Use the "issue" button in the Commands column in this case.
]]>
Enter FQDN here. Finish with TAB. @@ -38,6 +38,14 @@ dropdown + + certificate.restartActions + + select_multiple + + true + Choose the actions that should be run after certificate renewal. Basically every application requires a quick restart to reload the updated certificate. If you don't configure a restart action, the in-memory certificate may expire and cause security warnings and other issues. + certificate.autoRenewal diff --git a/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/forms/dialogValidation.xml b/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/forms/dialogValidation.xml index 871c41907..81f703af4 100644 --- a/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/forms/dialogValidation.xml +++ b/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/forms/dialogValidation.xml @@ -21,7 +21,7 @@ validation.method dropdown - + @@ -41,7 +41,7 @@ validation.http_opn_autodiscovery checkbox -
NOTE:This will ONLY work if the official IP addresses are LOCALLY configured on your OPNsense firewall.
]]>
+
NOTE:This will ONLY work if the official IP addresses are LOCALLY configured on your OPNsense firewall.
]]>
validation.http_opn_interface @@ -58,19 +58,25 @@
NOTE:This will ONLY work if the official IP addresses are LOCALLY configured on your OPNsense firewall.
]]>
Enter IP addresses here. Finish each with TAB.
- - /^((([0-9a-zA-Z._\-\*]+\.[0-9a-zA-Z._\-\*]+(-[0-9]+)?)([,]){0,1}))*/u + /^.{1,16384}$/u lower - Please provide a valid FQDN, i.e. www.example.com or mail.example.com. + Please provide a valid FQDN, i.e. www.example.com or mail.example.com. Field length is limited to 16384 characters. - + + /^(?!0).*$/ + + Related item not found N @@ -124,16 +178,34 @@ - + + /^(?!0).*$/ + + Related item not found N Y + + + + OPNsense.AcmeClient.AcmeClient + actions.action + name + + /^(?!0).*$/ + + + + Related restart action not found + Y + N + 1 Y @@ -165,12 +237,12 @@ Y - /^([0-9a-zA-Z._]){1,255}$/u + /^.{1,255}$/u Should be a string between 1 and 255 characters. N - /^([\t\n\v\f\r 0-9a-zA-Z.:\-,_()\x{00A0}-\x{FFFF}]){1,255}$/u + /^.{1,255}$/u Should be a string between 1 and 255 characters. @@ -186,10 +258,7 @@ opnsense OPNsense port forward (specify Interface or IP) - + HAProxy HTTP Frontend Integration (OPNsense plugin) @@ -208,32 +277,26 @@ N Y - + Y dns_nsupdate @@ -369,5 +432,45 @@ + + + + N + + + 1 + Y + + + Y + /^.{1,255}$/u + Should be a string between 1 and 255 characters. + + + N + /^.{1,255}$/u + Should be a string between 1 and 255 characters. + + + Y + + Restart OPNsense Web UI + Restart HAProxy (OPNsense plugin) + System or Plugin Command (select below) + Custom Command (specify below) + + + + + /^(?!.*(Let\'s\ Encrypt|acme|[fF]irmware))([\S\s]{1,255})/ + + Select a command from the list. + N + + + N + + + diff --git a/security/acme-client/src/opnsense/mvc/app/models/OPNsense/AcmeClient/Menu/Menu.xml b/security/acme-client/src/opnsense/mvc/app/models/OPNsense/AcmeClient/Menu/Menu.xml index d030891d5..97de62f21 100644 --- a/security/acme-client/src/opnsense/mvc/app/models/OPNsense/AcmeClient/Menu/Menu.xml +++ b/security/acme-client/src/opnsense/mvc/app/models/OPNsense/AcmeClient/Menu/Menu.xml @@ -9,9 +9,11 @@ - + + + - + diff --git a/security/acme-client/src/opnsense/mvc/app/views/OPNsense/AcmeClient/actions.volt b/security/acme-client/src/opnsense/mvc/app/views/OPNsense/AcmeClient/actions.volt new file mode 100644 index 000000000..fbb8f54c5 --- /dev/null +++ b/security/acme-client/src/opnsense/mvc/app/views/OPNsense/AcmeClient/actions.volt @@ -0,0 +1,87 @@ +{# + +Copyright (C) 2017 Frank Wall +OPNsense® is Copyright © 2014-2015 by 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._('Enabled') }}{{ lang._('Name') }}{{ lang._('Description') }}{{ lang._('Commands') }}{{ lang._('ID') }}
+ + +
+
+
+ +{# include dialogs #} +{{ partial("layout_partials/base_dialog",['fields':formDialogAction,'id':'DialogAction','label':'Edit Restart Action'])}} diff --git a/security/acme-client/src/opnsense/mvc/app/views/OPNsense/AcmeClient/certificates.volt b/security/acme-client/src/opnsense/mvc/app/views/OPNsense/AcmeClient/certificates.volt index 07391082f..8f3ac324f 100644 --- a/security/acme-client/src/opnsense/mvc/app/views/OPNsense/AcmeClient/certificates.volt +++ b/security/acme-client/src/opnsense/mvc/app/views/OPNsense/AcmeClient/certificates.volt @@ -269,12 +269,14 @@ POSSIBILITY OF SUCH DAMAGE. { if (gridParams['sign'] != undefined) { var uuid=$(this).data("row-id"); - stdDialogRemoveItem('Sign/renew selected certificate?',function() { - ajaxCall(url=gridParams['sign'] + uuid, - sendData={},callback=function(data,status){ + stdDialogRemoveItem('Forcefully (re-)issue the selected certificate?',function() { + // Handle HAProxy integration (no-op if not applicable) + ajaxCall(url="/api/acmeclient/settings/fetchHAProxyIntegration", sendData={}, callback=function(data,status) { + ajaxCall(url=gridParams['sign'] + uuid,sendData={},callback=function(data,status){ // reload grid after sign $("#"+gridId).bootgrid("reload"); }); + }); }); } else { console.log("[grid] action sign missing") @@ -311,9 +313,12 @@ POSSIBILITY OF SUCH DAMAGE. */ $("#signallcertsAct").click(function(){ //$("#signallcertsAct_progress").addClass("fa fa-spinner fa-pulse"); - ajaxCall(url="/api/acmeclient/service/signallcerts", sendData={}, callback=function(data,status) { - // when done, disable progress animation. - //$("#signallcertsAct_progress").removeClass("fa fa-spinner fa-pulse"); + // Handle HAProxy integration (no-op if not applicable) + ajaxCall(url="/api/acmeclient/settings/fetchHAProxyIntegration", sendData={}, callback=function(data,status) { + ajaxCall(url="/api/acmeclient/service/signallcerts", sendData={}, callback=function(data,status) { + // when done, disable progress animation. + //$("#signallcertsAct_progress").removeClass("fa fa-spinner fa-pulse"); + }); }); }); diff --git a/security/acme-client/src/opnsense/mvc/app/views/OPNsense/AcmeClient/settings.volt b/security/acme-client/src/opnsense/mvc/app/views/OPNsense/AcmeClient/settings.volt index 011da5daa..a1eca2613 100644 --- a/security/acme-client/src/opnsense/mvc/app/views/OPNsense/AcmeClient/settings.volt +++ b/security/acme-client/src/opnsense/mvc/app/views/OPNsense/AcmeClient/settings.volt @@ -88,7 +88,12 @@ POSSIBILITY OF SUCH DAMAGE. } }); - ajaxCall(url="/api/acmeclient/settings/fetchRBCron", sendData={}, callback=function(data,status) { + // Handle cron integration + ajaxCall(url="/api/acmeclient/settings/fetchCronIntegration", sendData={}, callback=function(data,status) { + }); + + // Handle HAProxy integration + ajaxCall(url="/api/acmeclient/settings/fetchHAProxyIntegration", sendData={}, callback=function(data,status) { }); // when done, disable progress animation @@ -120,12 +125,15 @@ POSSIBILITY OF SUCH DAMAGE. }); } - ajaxCall(url="/api/acmeclient/settings/fetchRBCron", sendData={}, callback=function(data,status) { - }); - - // when done, disable progress animation - $('[id*="reconfigureAct_progress"]').each(function(){ - $(this).removeClass("fa fa-spinner fa-pulse"); + // Handle cron integration + ajaxCall(url="/api/acmeclient/settings/fetchCronIntegration", sendData={}, callback=function(data,status) { + // Handle HAProxy integration + ajaxCall(url="/api/acmeclient/settings/fetchHAProxyIntegration", sendData={}, callback=function(data,status) { + // when done, disable progress animation + $('[id*="reconfigureAct_progress"]').each(function(){ + $(this).removeClass("fa fa-spinner fa-pulse"); + }); + }); }); }); } diff --git a/security/acme-client/src/opnsense/scripts/OPNsense/AcmeClient/certhelper.php b/security/acme-client/src/opnsense/scripts/OPNsense/AcmeClient/certhelper.php index 807b5a740..6c4c6aa0a 100755 --- a/security/acme-client/src/opnsense/scripts/OPNsense/AcmeClient/certhelper.php +++ b/security/acme-client/src/opnsense/scripts/OPNsense/AcmeClient/certhelper.php @@ -42,7 +42,9 @@ require_once("certs.inc"); require_once("legacy_bindings.inc"); require_once("interfaces.inc"); require_once("util.inc"); +require_once("system.inc"); // required for Web UI restart action // Some stuff requires the almighty MVC framework. +use OPNsense\Core\Backend; use OPNsense\Core\Config; use OPNsense\Base; use OPNsense\AcmeClient\AcmeClient; @@ -112,6 +114,9 @@ function cert_action_validator($opt_cert_id) $modelObj = new OPNsense\AcmeClient\AcmeClient; + // Store certs here after successful issue/renewal. Required for restart actions. + $restart_certs = Array(); + // Search for cert ID in configuration $configObj = Config::getInstance()->object(); if (isset($configObj->OPNsense->AcmeClient->certificates)) { @@ -204,9 +209,12 @@ function cert_action_validator($opt_cert_id) // Start acme client to issue or renew certificate $val_result = run_acme_validation($certObj, $valObj, $acctObj); if (!$val_result) { + log_error("AcmeClient: issued/renewed certificate: " . (string)$certObj->name); // Import certificate to Cert Manager if (!import_certificate($certObj, $modelObj)) { //echo "DEBUG: cert import done\n"; + // Prepare certificate for restart action + $restart_certs[] = $certObj; } else { log_error("AcmeClient: unable to import certificate: " . (string)$certObj->name); if (isset($options["A"])) { @@ -214,6 +222,8 @@ function cert_action_validator($opt_cert_id) } return(1); } + } elseif ($val_result == '99') { + // Renewal not required. Do nothing. } else { // validation failure log_error("AcmeClient: validation for certificate failed: " . (string)$certObj->name); @@ -247,6 +257,17 @@ function cert_action_validator($opt_cert_id) log_error("AcmeClient: no LE certificates found in configuration"); return(1); } + + // Run restart actions if an operation was successful. + if (!empty($restart_certs)) { + // Execute restart actions. + if (!run_restart_actions($restart_certs, $modelObj)) { + # Success. + } else { + log_error("AcmeClient: failed to execute some restart actions"); + } + } + return(0); } @@ -389,8 +410,6 @@ function run_acme_account_registration($acctObj, $certObj, $modelObj) // Run acme client with HTTP-01 or DNS-01 validation to issue/renew certificate function run_acme_validation($certObj, $valObj, $acctObj) { - // TODO: add support for other HTTP-01 validation services/methods - global $options; // Collect account information @@ -417,7 +436,7 @@ function run_acme_validation($certObj, $valObj, $acctObj) // Preparation to run acme client $acme_args = eval_optional_acme_args(); $proc_env = array(); // env variables for proc_open() - $proc_env['PATH'] = '/sbin:/bin:/usr/sbin:/usr/bin:/usr/games:/usr/local/sbin:/usr/local/bin:/root/bin'; + $proc_env['PATH'] = '/sbin:/bin:/usr/sbin:/usr/bin:/usr/games:/usr/local/sbin:/usr/local/bin'; $proc_desc = array( // descriptor array for proc_open() 0 => array("pipe", "r"), // stdin 1 => array("pipe", "w"), // stdout @@ -426,7 +445,15 @@ function run_acme_validation($certObj, $valObj, $acctObj) $proc_pipes = array(); // Do we need to issue or renew the certificate? - $acme_action = !empty((string)$certObj->lastUpdate) ? "renew" : "issue"; + if (!empty((string)$certObj->lastUpdate) and !isset($options["F"])) { + $acme_action = "renew"; + } else { + // Default: Issue a new certificate. + // If "-F" is specified, forcefully re-issue the cert, no matter if it's required. + // NOTE: This is useful if altNames were changed or when switching + // from acme staging to acme production servers. + $acme_action = "issue"; + } // Calculate next renewal date $last_update = !empty((string)$certObj->lastUpdate) ? (string)$certObj->lastUpdate : 0; @@ -437,12 +464,12 @@ function run_acme_validation($certObj, $valObj, $acctObj) $renew_interval = (string)$certObj->renewInterval; $next_update = $last_update_time->add(new \DateInterval('P'.$renew_interval.'D')); - // Check if it's time to renew, otherwise report success + // Check if it's time to renew the cert. if (isset($options["F"]) or ($current_time >= $next_update)) { $renew_cert = true; } else { - // Renewal not yet required, report success - return(0); + // Renewal not yet required, report special code + return(99); } // Try HTTP-01 or DNS-01 validation? @@ -841,6 +868,148 @@ function import_certificate($certObj, $modelObj) return(0); } +function run_restart_actions($certlist, $modelObj) +{ + global $config; + $return = 0; + + // NOTE: Do NOT run any restart action twice, collect duplicates first. + $restart_actions = Array(); + + // Check if there's something to do. + if (!empty($certlist) and is_array($certlist)) { + // Extract cert object + foreach ($certlist as $certObj) { + // Make sure the object is functional. + if (empty($certObj->id)) { + log_error("AcmeClient: failed to query certificate for restart action"); + continue; + } + // Extract restart actions + $_actions = explode(',', $certObj->restartActions); + // Walk through all linked restart actions. + $_actions = explode(',', $certObj->restartActions); + foreach ($_actions as $_action ) { + // Extract restart action + $action = $modelObj->getByActionID($_action); + // Make sure the object is functional. + if ($action === null) { + log_error("AcmeClient: failed to retrieve restart action from certificate"); + } else { + // Ignore disabled restart actions (even if they are still + // linked to a certificated). + if ((string)$action->enabled === "0") { + continue; + } + // Store by UUID, automatically eliminates duplicates. + $restart_actions[$_action] = $action; + } + } + } + } + + // Run the collected restart actions. + if (!empty($restart_actions) and is_array($restart_actions)) { + // Required to run pre-defined commands. + $backend = new Backend(); + // Extract cert object + foreach ($restart_actions as $action) { + // Run pre-defined or custom command? + log_error("AcmeClient: running restart action: " . $action->name); + switch ((string)$action->type) { + case 'restart_gui': + $response = system_webgui_configure(); + break; + case 'restart_haproxy': + $response = $backend->configdRun("haproxy restart"); + break; + case 'configd': + // Make sure a configd command was specified. + if (empty((string)$action->configd)) { + log_error("AcmeClient: no configd command specified for restart action: " . $action->name); + $result = '1'; + continue; // Continue with next action. + } + $response = $backend->configdRun((string)$action->configd); + break; + case 'custom': + // Make sure a custom command was specified. + if (empty((string)$action->custom)) { + log_error("AcmeClient: no custom command specified for restart action: " . $action->name); + $result = '1'; + continue; // Continue with next action. + } + + // Prepare to run the command. + $proc_env = array(); // env variables for proc_open() + $proc_env['PATH'] = '/sbin:/bin:/usr/sbin:/usr/bin:/usr/games:/usr/local/sbin:/usr/local/bin'; + $proc_desc = array( // descriptor array for proc_open() + 0 => array("pipe", "r"), // stdin + 1 => array("pipe", "w"), // stdout + 2 => array("pipe", "w") // stderr + ); + $proc_pipes = array(); + $proc_stdout = ''; + $proc_stderr = ''; + $result = ''; // exit code (or '99' in case of timeout) + + // TODO: Make the timeout configurable. + $timeout = '600'; + $starttime = time(); + + $proc_cmd = (string)$action->custom; + $proc = proc_open($proc_cmd, $proc_desc, $proc_pipes, null, $proc_env); + + // Make sure the resource could be setup properly + if (is_resource($proc)) { + fclose($proc_pipes[0]); + + // Wait until process terminates normally + while(is_resource($proc)) + { + $proc_stdout .= stream_get_contents($proc_pipes[1]); + $proc_stderr .= stream_get_contents($proc_pipes[2]); + + // Check if timeout is reached + if(($timeout !== false) and ((time() - $starttime) > $timeout)) + { + // Terminate process if timeout is reached + log_error("AcmeClient: timeout running restart action: " . $action->name); + proc_terminate($proc, 9); + $result = '99'; + break; + } + + // Check if process terminated normally + $status = proc_get_status($proc); + if(!$status['running']) + { + fclose($proc_pipes[1]); + fclose($proc_pipes[2]); + proc_close($proc); + $result = $status['exitcode']; + break; + } + + usleep(100000); + } + } else { + log_error("AcmeClient: unable to initiate restart action: " . $action->name); + continue; // Continue with next action. + } + $return = $result; + break; + default: + log_error("AcmeClient: an invalid restart action was specified: " . (string)$action->type); + $return = 1; + continue; // Continue with next action. + } + } + } + + return($return); +} + // taken from certs.inc function local_cert_get_subject_array($str_crt, $decode = true) { diff --git a/security/acme-client/src/opnsense/service/templates/OPNsense/AcmeClient/lighttpd-acme-challenge.conf b/security/acme-client/src/opnsense/service/templates/OPNsense/AcmeClient/lighttpd-acme-challenge.conf index fe1e2557b..711dd073a 100644 --- a/security/acme-client/src/opnsense/service/templates/OPNsense/AcmeClient/lighttpd-acme-challenge.conf +++ b/security/acme-client/src/opnsense/service/templates/OPNsense/AcmeClient/lighttpd-acme-challenge.conf @@ -84,6 +84,7 @@ compress.cache-dir = "/tmp/acmelighttpdcompress/" compress.filetype = ("text/plain","text/css", "text/xml", "text/javascript" ) server.max-request-size = 4096 +server.tag = "lighttpd/ACME" expire.url = ( "" => "access 10 hours" )