diff --git a/README.md b/README.md index 8756b09..c238ebb 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,18 @@ CLOUDFLARE_USER_EMAIL=foo@silverstripe.com ``` +### Freshdesk Alerting + +When defined, upcoming certificate renewals will be created as Freshdesk tickets. As the certificate approaches expiration, the priority will be increased. If a new certificate is detected, the ticket will be closed. + +``` +FRESHDESK_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +FRESHDESK_USER_ID=123456789 +FRESHDESK_DOMAIN=https://foo.freshdesk.com +``` + +Additional options can be configured in the CMS, such as the group and product the ticket should be created as. + ### OpsGenie Alerting When defined, any upcoming certificate renewals will be created as alerts in OpsGenie if configured in the Settings section of the CMS. diff --git a/mysite/_config/config.yml b/mysite/_config/config.yml index 64dd463..9854094 100644 --- a/mysite/_config/config.yml +++ b/mysite/_config/config.yml @@ -8,6 +8,7 @@ SiteConfig: extensions: - OpsGenieSiteConfigExtension - SlackSiteConfigExtension + - FreshdeskSiteConfigExtension Domain: api_access: true Certificate: @@ -15,4 +16,4 @@ Certificate: RESTfulAPI: embedded_records: Domain: - - Certificates \ No newline at end of file + - Certificates diff --git a/mysite/code/Extensions/FreshdeskSiteConfigExtension.php b/mysite/code/Extensions/FreshdeskSiteConfigExtension.php new file mode 100644 index 0000000..b08811c --- /dev/null +++ b/mysite/code/Extensions/FreshdeskSiteConfigExtension.php @@ -0,0 +1,29 @@ + 'Boolean(0)', + 'FreshdeskGroupID' => 'Varchar(20)', + 'FreshdeskProductID' => 'Varchar(20)', + ]; + + public function updateCMSFields(FieldList $fields) + { + if (!Freshdesk::IsAvailable()) { + $fields->addFieldToTab('Root.Freshdesk', new LiteralField('MissingFreshdeskKey', '

Freshdesk environment variables not defined, integration is disabled

')); + } + + $fields->addFieldsToTab('Root.Freshdesk', [ + new CheckboxField('CreateFreshdeskTicket', 'Create Freshdesk tickets for certificates about to expire'), + $groupId = new NumericField('FreshdeskGroupID', 'Freshdesk Group ID'), + $productId = new NumericField('FreshdeskProductID', 'Freshdesk Product ID'), + ]); + + $groupId->setDescription('Numeric ID of the group to triage the ticket to'); + $productId->setDescription('Numeric ID of the product this ticket belongs to'); + } +} diff --git a/mysite/code/Jobs/CheckSSLCertificates.php b/mysite/code/Jobs/CheckSSLCertificates.php index c6f85fc..5f39d49 100644 --- a/mysite/code/Jobs/CheckSSLCertificates.php +++ b/mysite/code/Jobs/CheckSSLCertificates.php @@ -148,7 +148,7 @@ protected function notifyNewCertificate($site, $cert) $settings = [ 'username' => SiteConfig::current_site_config()->Title, 'channel' => SiteConfig::current_site_config()->SlackChannel, - 'icon' => SiteConfig::current_site_config()->SlackEmoji + 'icon' => SiteConfig::current_site_config()->SlackEmoji, ]; $client = new Maknz\Slack\Client(SLACK_WEBHOOK_URL, $settings); @@ -200,7 +200,7 @@ protected function notifyFailure($site, $error) $settings = [ 'username' => SiteConfig::current_site_config()->Title, 'channel' => SiteConfig::current_site_config()->SlackChannel, - 'icon' => SiteConfig::current_site_config()->SlackEmoji + 'icon' => SiteConfig::current_site_config()->SlackEmoji, ]; $client = new Maknz\Slack\Client(SLACK_WEBHOOK_URL, $settings); @@ -229,7 +229,7 @@ protected function notifyCommonNameMismatch($site, $error) $settings = [ 'username' => SiteConfig::current_site_config()->Title, 'channel' => SiteConfig::current_site_config()->SlackChannel, - 'icon' => SiteConfig::current_site_config()->SlackEmoji + 'icon' => SiteConfig::current_site_config()->SlackEmoji, ]; $client = new Maknz\Slack\Client(SLACK_WEBHOOK_URL, $settings); diff --git a/mysite/code/Jobs/CreateFreshdeskTickets.php b/mysite/code/Jobs/CreateFreshdeskTickets.php new file mode 100644 index 0000000..e7ff4ef --- /dev/null +++ b/mysite/code/Jobs/CreateFreshdeskTickets.php @@ -0,0 +1,129 @@ +CreateFreshdeskTicket) { + $this->log('Freshdesk not enabled, task not running', SS_Log::INFO); + + return; + } + + if (!$siteConfig->FreshdeskGroupID) { + $this->log('FreshdeskGroupId is empty or not numeric, task not running', SS_Log::INFO); + + return; + } + + $freshdesk = new Freshdesk(); + $domains = Domain::get()->filter(['Enabled' => 1]); + + $this->log('Number of existing domains: '.$domains->count(), SS_Log::INFO); + + $alertDays = [ + Freshdesk::PRIORITY_LOW => $siteConfig->OpsGenieDaysUntilP5, + Freshdesk::PRIORITY_MEDIUM => $siteConfig->OpsGenieDaysUntilP3, + Freshdesk::PRIORITY_HIGH => $siteConfig->OpsGenieDaysUntilP2, + Freshdesk::PRIORITY_URGENT => $siteConfig->OpsGenieDaysUntilP1, + ]; + + $startAlerting = max(array_values($alertDays)); + + foreach ($domains as $d) { + if (!$d->CurrentCertificate()->exists()) { + continue; + } + + $cert = $d->CurrentCertificate(); + + $this->log('Checking '.$d->Domain, SS_Log::INFO); + $this->log('Days until expiration: '.$cert->DaysUntilExpiration, SS_Log::DEBUG); + + // Skip if we're outside the alerting threshold + if ($cert->DaysUntilExpiration > $startAlerting) { + // If there is a current OpsGenie alert try and close it as the cert may have been updated + if ($d->FreshdeskID) { + $this->log('Closing existing Freshdesk ticket '.$d->FreshdeskID, SS_Log::INFO); + + $freshdesk->closeTicket($d->FreshdeskID, $this->createTicketBody($d, $cert, 'FreshdeskTicketClosed')); + + $d->FreshdeskID = ''; + $d->FreshdeskPriority = ''; + $d->write(); + } + + continue; + } + + $priority = $this->closestNumber($cert->DaysUntilExpiration, $alertDays); + $this->log('Current alert priority: '.$priority, SS_Log::DEBUG); + + // Create an Freshdesk ticket if one isn't already made + if (!$d->FreshdeskID) { + $this->log('Creating Freshdesk ticket', SS_Log::INFO); + + $ticket = $freshdesk->createTicket([ + 'subject' => $d->Domain.' certificate expires '.$cert->ValidTo, + 'description' => $this->createTicketBody($d, $cert), + 'priority' => $priority, + 'group_id' => (int) $siteConfig->FreshdeskGroupID, + 'product_id' => (int) $siteConfig->FreshdeskProductID, + 'requester_id' => (int) FRESHDESK_USER_ID, + 'status' => Freshdesk::STATUS_OPEN, + 'tags' => ['ssl', 'locksmith'], + ]); + + $d->FreshdeskID = $ticket->id; + $d->FreshdeskPriority = $priority; + + $d->write(); + } elseif ((int) $priority !== (int) $d->FreshdeskPriority) { + // Upgrade the priority if its different + $this->log('Upgrading Freshdesk ticket from '.$d->FreshdeskPriority.' to '.$priority, SS_Log::INFO); + $freshdesk->addNote($d->FreshdeskID, 'Escalating Freshdesk ticket priority from '.Freshdesk::PriorityAsString($d->FreshdeskPriority).' to '.Freshdesk::PriorityAsString($priority)); + + $freshdesk->updateTicket($d->FreshdeskID, [ + 'priority' => $priority, + ]); + + $d->FreshdeskPriority = $priority; + $d->write(); + } + } + } + + /** + * Creates the body of the ticket with information about the domain. + * + * @param $domain + * @param $cert + * @param string $template + * + * @return HTMLText + */ + private function createTicketBody($domain, $cert, $template = 'FreshdeskTicket') + { + $arrayData = new ArrayData([ + 'Domain' => $domain, + 'Certificate' => $cert, + ]); + + return $arrayData->renderWith($template)->RAW(); + } +} diff --git a/mysite/code/Jobs/DailyExpirationReminder.php b/mysite/code/Jobs/DailyExpirationReminder.php index a4946a1..45b51ef 100644 --- a/mysite/code/Jobs/DailyExpirationReminder.php +++ b/mysite/code/Jobs/DailyExpirationReminder.php @@ -69,6 +69,14 @@ public function process() $cert->ValidTo ); + if (Freshdesk::IsAvailable() && $d->FreshdeskID) { + $line .= sprintf( + ', <%s/helpdesk/tickets/%s|open in Freshdesk>', + FRESHDESK_DOMAIN, + $d->FreshdeskID + ); + } + $alerts[$priority][] = $line; } diff --git a/mysite/code/Model/Certificate.php b/mysite/code/Model/Certificate.php index 2936e50..4948551 100644 --- a/mysite/code/Model/Certificate.php +++ b/mysite/code/Model/Certificate.php @@ -80,4 +80,12 @@ public function getDaysUntilExpiration() // We use %r%a to ensure we provide a - if the number of days is a negative return $earlier->diff($later)->format('%r%a'); } + + /** + * @return bool True if this is a LE cert + */ + public function getIsLetsEncrypt() + { + return false !== stripos($this->Issuer, "Let's Encrypt"); + } } diff --git a/mysite/code/Model/Domain.php b/mysite/code/Model/Domain.php index 075235a..6545063 100644 --- a/mysite/code/Model/Domain.php +++ b/mysite/code/Model/Domain.php @@ -14,6 +14,8 @@ class Domain extends DataObject 'AlertPriority' => 'Text', 'AlertedCommonNameMismatch' => 'Boolean(0)', 'HasBeenChecked' => 'Boolean(0)', + 'FreshdeskID' => 'Text', + 'FreshdeskPriority' => 'Text', ]; private static $has_many = [ @@ -63,11 +65,20 @@ public function getCMSFields() ->setRows(1) ->setDescription('The ID of the OpsGenie alert for this domain. Set to empty if there is no alert'); + $fields->dataFieldByName('FreshdeskID') + ->setRows(1) + ->setDescription('The ID of the Freshdesk ticket for this domain. Set to empty if there is no alert'); + $fields->dataFieldByName('AlertPriority') ->setRows(1) ->setReadonly(true) ->setDescription('The current status of the OpsGenie alert (P5 to P1)'); + $fields->dataFieldByName('FreshdeskPriority') + ->setRows(1) + ->setReadonly(true) + ->setDescription('The current priority of the Freshdesk ticket (1 to 4)'); + $fields->addFieldToTab('Root.Certificates', GridField::create( 'Certificates', 'Certificates recorded for this domain', diff --git a/mysite/code/Services/Freshdesk.php b/mysite/code/Services/Freshdesk.php new file mode 100644 index 0000000..651223f --- /dev/null +++ b/mysite/code/Services/Freshdesk.php @@ -0,0 +1,166 @@ +request('tickets', 'POST', $params); + } + + /** + * Updates a ticket with the specified properties. + * + * @param int $id + * @param array $params + * + * @throws Exception + * + * @return bool|mixed + */ + public function updateTicket($id, $params = []) + { + return $this->request('tickets/'.$id, 'PUT', $params); + } + + /** + * Closes an ticket by marking it as resolved. An optional note can be added. + * + * @param int $id + * @param string $note + * + * @throws Exception + * + * @return bool|mixed + */ + public function closeTicket($id, $note = null) + { + if ($note) { + $this->addNote($id, $note); + } + + return $this->updateTicket($id, [ + 'status' => self::STATUS_RESOLVED, + ]); + } + + /** + * Adds a note to the ticket. + * + * @param int $id Ticket ID + * @param string $note + * @param bool $private If the note can't be seen by the customer + * + * @throws Exception + * + * @return bool|mixed + */ + public function addNote($id, $note, $private = true) + { + $params = [ + 'body' => $note, + 'private' => $private, + 'user_id' => FRESHDESK_USER_ID, + ]; + + return $this->request('tickets/'.$id.'/notes', 'POST', $params); + } + + /** + * @return bool True if the required environment variables are set + */ + public static function IsAvailable() + { + return defined('FRESHDESK_API_KEY') && + defined('FRESHDESK_DOMAIN') && + defined('FRESHDESK_USER_ID'); + } + + /** + * Returns a numeric priority for a string representation. + * + * @param $status + * + * @return string + */ + public static function PriorityAsString($status) + { + switch ($status) { + case self::PRIORITY_LOW: + return 'Low'; + case self::PRIORITY_MEDIUM: + return 'Medium'; + case self::PRIORITY_HIGH: + return 'High'; + case self::PRIORITY_URGENT: + return 'Urgent'; + default: + return 'Unknown'; + } + } + + /** + * Requests a resource from the OpsGenie API. + * + * @param string $url URL to query + * @param string $method + * @param array $params Array of request parameters to pass into the body + * + * @throws Exception + * + * @return bool|mixed + */ + protected function request($url, $method = 'GET', $params = []) + { + if (!self::IsAvailable()) { + throw new InvalidArgumentException('Freshdesk environment variables missing - request not sent'); + } + + if ('GET' === $method && !empty($params)) { + $url .= '?'.http_build_query($params); + } + + $c = curl_init(); + curl_setopt($c, CURLOPT_URL, FRESHDESK_DOMAIN.'/api/v2/'.$url); + curl_setopt($c, CURLOPT_CONNECTTIMEOUT, 5); + curl_setopt($c, CURLOPT_RETURNTRANSFER, 1); + curl_setopt($c, CURLOPT_USERPWD, FRESHDESK_API_KEY.':X'); + curl_setopt($c, CURLOPT_HTTPHEADER, [ + 'Content-Type:application/json', + ]); + + if ('POST' === $method) { + curl_setopt($c, CURLOPT_POST, 1); + curl_setopt($c, CURLOPT_POSTFIELDS, json_encode($params)); + } elseif ('GET' !== $method) { + curl_setopt($c, CURLOPT_CUSTOMREQUEST, $method); + curl_setopt($c, CURLOPT_POSTFIELDS, json_encode($params)); + } + + $result = curl_exec($c); + var_dump($result); + curl_close($c); + + return json_decode($result); + } +} diff --git a/mysite/code/Services/OpsGenie.php b/mysite/code/Services/OpsGenie.php index 941b961..38452ef 100644 --- a/mysite/code/Services/OpsGenie.php +++ b/mysite/code/Services/OpsGenie.php @@ -10,6 +10,8 @@ class OpsGenie * * @param string $id * + * @throws Exception + * * @return bool|mixed */ public function getRequestStatus($id) @@ -22,6 +24,8 @@ public function getRequestStatus($id) * * @param array $params * + * @throws Exception + * * @return bool|mixed */ public function getAlerts($params = []) @@ -34,6 +38,8 @@ public function getAlerts($params = []) * * @param array $params * + * @throws Exception + * * @return bool|mixed */ public function createAlert($params = []) @@ -60,6 +66,8 @@ public function createAlert($params = []) * * @param string $id * + * @throws Exception + * * @return bool|mixed */ public function getAlert($id) @@ -73,6 +81,8 @@ public function getAlert($id) * @param string $id * @param array $params * + * @throws Exception + * * @return bool|mixed */ public function closeAlert($id, $params = []) @@ -86,6 +96,8 @@ public function closeAlert($id, $params = []) * @param string $id * @param string $message * + * @throws Exception + * * @return bool|mixed */ public function updateAlertMessage($id, $message) @@ -101,6 +113,8 @@ public function updateAlertMessage($id, $message) * @param string $id * @param string $priority * + * @throws Exception + * * @return bool|mixed */ public function updateAlertPriority($id, $priority) diff --git a/mysite/code/Tasks/RunCreateFreshdeskTickets.php b/mysite/code/Tasks/RunCreateFreshdeskTickets.php new file mode 100644 index 0000000..b21300a --- /dev/null +++ b/mysite/code/Tasks/RunCreateFreshdeskTickets.php @@ -0,0 +1,19 @@ +process(); + } +} diff --git a/mysite/templates/FreshdeskTicket.ss b/mysite/templates/FreshdeskTicket.ss new file mode 100644 index 0000000..1647bcc --- /dev/null +++ b/mysite/templates/FreshdeskTicket.ss @@ -0,0 +1,18 @@ +

The certificate for $Domain.Domain is soon expiring at {$Certificate.ValidTo}. This ticket will escalate in priority until a new certificate is detected. Once detected this ticket will automatically resolve itself.

+ +<% if $Domain.Source != "Manual" %> +

This domain was added from $Domain.Source, so the certificate may be renewed automatically with {$Domain.Source}.

+<% end_if %> + +<% if $Certificate.IsLetsEncrypt %> +

The current certificate was issued by Let's Encrypt, so the certificate may be renewed automatically.

+<% end_if %> + +

Certificate Information

+ diff --git a/mysite/templates/FreshdeskTicketClosed.ss b/mysite/templates/FreshdeskTicketClosed.ss new file mode 100644 index 0000000..49615c8 --- /dev/null +++ b/mysite/templates/FreshdeskTicketClosed.ss @@ -0,0 +1,10 @@ +

The certificate for $Domain.Domain has been updated and this ticket will be resolved.

+ +

Certificate Information

+