Skip to content

Commit a269eaa

Browse files
committed
Merge pull request #4 from ericmakesstuff/feature-ssl-certificate-monitor
Add Monitor: SSL Certificate Validation
2 parents f4336a6 + 476c986 commit a269eaa

18 files changed

+679
-22
lines changed

README.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
[![Quality Score](https://img.shields.io/scrutinizer/g/ericmakesstuff/laravel-server-monitor.svg?style=flat-square)](https://scrutinizer-ci.com/g/ericmakesstuff/laravel-server-monitor)
77
[![Total Downloads](https://img.shields.io/packagist/dt/ericmakesstuff/laravel-server-monitor.svg?style=flat-square)](https://packagist.org/packages/ericmakesstuff/laravel-server-monitor)
88

9-
This Laravel 5 package will periodically monitor the health of your server. Currently, it provides healthy/alarm status notifications for Disk Usage, as well as an HTTP Ping function to monitor the health of external services.
9+
This Laravel 5 package will periodically monitor the health of your server and website. Currently, it provides healthy/alarm status notifications for Disk Usage, an HTTP Ping function to monitor the health of external services, and a validation/expiration monitor for SSL Certificates.
1010

1111
Once installed, monitoring your server is very easy. Just issue this artisan command:
1212

@@ -17,8 +17,8 @@ php artisan monitor:run
1717
You can run only certain monitors at a time:
1818

1919
``` bash
20-
php artisan monitor:run DiskUsage
21-
php artisan monitor:run DiskUsage,HttpPing
20+
php artisan monitor:run HttpPing
21+
php artisan monitor:run SSLCertificate,DiskUsage
2222
```
2323

2424
## Installation and usage
@@ -77,6 +77,20 @@ The default monitor configurations are:
7777
'allowRedirects' => false,
7878
],
7979
],
80+
/*
81+
* SSLCertificate will download the SSL Certificate for the URL and validate that the domain
82+
* is covered and that it is not expired. Additionally, it can warn when the certificate is
83+
* approaching expiration.
84+
*/
85+
'SSLCertificate' => [
86+
[
87+
'url' => 'https://www.example.com/',
88+
],
89+
[
90+
'url' => 'https://www.example.com/',
91+
'alarmDaysBeforeExpiration' => [14, 7],
92+
],
93+
],
8094
```
8195

8296
## Scheduling

config/server-monitor.php

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,20 @@
4141
'allowRedirects' => false,
4242
],
4343
],
44+
/*
45+
* SSLCertificate will download the SSL Certificate for the URL and validate that the domain
46+
* is covered and that it is not expired. Additionally, it can warn when the certificate is
47+
* approaching expiration.
48+
*/
49+
'SSLCertificate' => [
50+
[
51+
'url' => 'https://www.example.com/',
52+
],
53+
[
54+
'url' => 'https://www.example.com/',
55+
'alarmDaysBeforeExpiration' => [14, 7],
56+
],
57+
],
4458
],
4559

4660
'notifications' => [
@@ -57,10 +71,13 @@
5771
* Slack requires the installation of the maknz/slack package.
5872
*/
5973
'events' => [
60-
'whenDiskUsageHealthy' => ['log'],
61-
'whenDiskUsageAlarm' => ['log', 'mail'],
62-
'whenHttpPingUp' => ['log'],
63-
'whenHttpPingDown' => ['log', 'mail'],
74+
'whenDiskUsageHealthy' => ['log'],
75+
'whenDiskUsageAlarm' => ['log', 'mail'],
76+
'whenHttpPingUp' => ['log'],
77+
'whenHttpPingDown' => ['log', 'mail'],
78+
'whenSSLCertificateValid' => ['log'],
79+
'whenSSLCertificateInvalid' => ['log', 'mail'],
80+
'whenSSLCertificateExpiring' => ['log', 'mail'],
6481
],
6582

6683
/*

src/Events/SSLCertificateExpiring.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
namespace EricMakesStuff\ServerMonitor\Events;
4+
5+
use EricMakesStuff\ServerMonitor\Monitors\SSLCertificateMonitor;
6+
7+
class SSLCertificateExpiring
8+
{
9+
/** @var \EricMakesStuff\ServerMonitor\Monitors\SSLCertificateMonitor|null */
10+
public $sslCertificateMonitor;
11+
12+
/**
13+
* @param \EricMakesStuff\ServerMonitor\Monitors\SSLCertificateMonitor|null $sslCertificateMonitor
14+
*/
15+
public function __construct(SSLCertificateMonitor $sslCertificateMonitor = null)
16+
{
17+
$this->sslCertificateMonitor = $sslCertificateMonitor;
18+
}
19+
}

src/Events/SSLCertificateInvalid.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
namespace EricMakesStuff\ServerMonitor\Events;
4+
5+
use EricMakesStuff\ServerMonitor\Monitors\SSLCertificateMonitor;
6+
7+
class SSLCertificateInvalid
8+
{
9+
/** @var \EricMakesStuff\ServerMonitor\Monitors\SSLCertificateMonitor|null */
10+
public $sslCertificateMonitor;
11+
12+
/**
13+
* @param \EricMakesStuff\ServerMonitor\Monitors\SSLCertificateMonitor|null $sslCertificateMonitor
14+
*/
15+
public function __construct(SSLCertificateMonitor $sslCertificateMonitor = null)
16+
{
17+
$this->sslCertificateMonitor = $sslCertificateMonitor;
18+
}
19+
}

src/Events/SSLCertificateValid.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
namespace EricMakesStuff\ServerMonitor\Events;
4+
5+
use EricMakesStuff\ServerMonitor\Monitors\SSLCertificateMonitor;
6+
7+
class SSLCertificateValid
8+
{
9+
/** @var \EricMakesStuff\ServerMonitor\Monitors\SSLCertificateMonitor|null */
10+
public $sslCertificateMonitor;
11+
12+
/**
13+
* @param \EricMakesStuff\ServerMonitor\Monitors\SSLCertificateMonitor $sslCertificateMonitor
14+
*/
15+
public function __construct(SSLCertificateMonitor $sslCertificateMonitor)
16+
{
17+
$this->sslCertificateMonitor = $sslCertificateMonitor;
18+
}
19+
}

src/Exceptions/InvalidConfiguration.php

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,21 @@ public static function cannotFindMonitor($monitorName)
1717

1818
public static function noUrlConfigured()
1919
{
20-
return new static ("No URL Configured.");
20+
return new static("No URL Configured.");
21+
}
22+
23+
public static function urlNotSecure()
24+
{
25+
return new static("URL Not Secure");
26+
}
27+
28+
public static function urlCouldNotBeParsed()
29+
{
30+
return new static("URL Could Not Be Parsed");
31+
}
32+
33+
public static function urlCouldNotBeDownloaded()
34+
{
35+
return new static("URL Could Not Be Downloaded");
2136
}
2237
}
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
<?php
2+
3+
namespace EricMakesStuff\ServerMonitor\Monitors;
4+
5+
use Carbon\Carbon;
6+
use EricMakesStuff\ServerMonitor\Events\SSLCertificateExpiring;
7+
use EricMakesStuff\ServerMonitor\Events\SSLCertificateInvalid;
8+
use EricMakesStuff\ServerMonitor\Events\SSLCertificateValid;
9+
use EricMakesStuff\ServerMonitor\Exceptions\InvalidConfiguration;
10+
11+
class SSLCertificateMonitor extends BaseMonitor
12+
{
13+
/** @var array */
14+
protected $certificateInfo;
15+
16+
/** @var string */
17+
protected $certificateExpiration;
18+
19+
/** @var string */
20+
protected $certificateDomain;
21+
22+
/** @var array */
23+
protected $certificateAdditionalDomains = [];
24+
25+
/** @var int */
26+
protected $certificateDaysUntilExpiration;
27+
28+
/** @var string */
29+
protected $url;
30+
31+
/** @var array */
32+
protected $alarmDaysBeforeExpiration = [28, 14, 7, 3, 2, 1, 0];
33+
34+
/**
35+
* @param array $config
36+
*/
37+
public function __construct(array $config)
38+
{
39+
if (!empty($config['url'])) {
40+
$this->url = $config['url'];
41+
}
42+
43+
if (!empty($config['alarmDaysBeforeExpiration'])) {
44+
$this->alarmDaysBeforeExpiration = $config['alarmDaysBeforeExpiration'];
45+
}
46+
}
47+
48+
/**
49+
* @throws InvalidConfiguration
50+
*/
51+
public function runMonitor()
52+
{
53+
$urlParts = $this->parseUrl($this->url);
54+
55+
try {
56+
$this->certificateInfo = $this->downloadCertificate($urlParts);
57+
} catch (\ErrorException $e) {
58+
event(new SSLCertificateInvalid($this));
59+
return false;
60+
} catch (\Exception $e) {
61+
throw InvalidConfiguration::urlCouldNotBeDownloaded();
62+
}
63+
64+
$this->processCertificate($this->certificateInfo);
65+
66+
if ($this->certificateDaysUntilExpiration < 0
67+
|| ! $this->hostCoveredByCertificate($urlParts['host'], $this->certificateDomain, $this->certificateAdditionalDomains)) {
68+
event(new SSLCertificateInvalid($this));
69+
} elseif (in_array($this->certificateDaysUntilExpiration, $this->alarmDaysBeforeExpiration)) {
70+
event(new SSLCertificateExpiring($this));
71+
} else {
72+
event(new SSLCertificateValid($this));
73+
}
74+
}
75+
76+
protected function downloadCertificate($urlParts)
77+
{
78+
$streamContext = stream_context_create([
79+
"ssl" => [
80+
"capture_peer_cert" => TRUE
81+
]
82+
]);
83+
84+
$streamClient = stream_socket_client("ssl://{$urlParts['host']}:443", $errno, $errstr, 30, STREAM_CLIENT_CONNECT, $streamContext);
85+
86+
$certificateContext = stream_context_get_params($streamClient);
87+
88+
return openssl_x509_parse($certificateContext['options']['ssl']['peer_certificate']);
89+
}
90+
91+
public function processCertificate($certificateInfo)
92+
{
93+
if (!empty($certificateInfo['subject']) && !empty($certificateInfo['subject']['CN'])) {
94+
$this->certificateDomain = $certificateInfo['subject']['CN'];
95+
}
96+
97+
if (!empty($certificateInfo['validTo_time_t'])) {
98+
$validTo = Carbon::createFromTimestampUTC($certificateInfo['validTo_time_t']);
99+
$this->certificateExpiration = $validTo->toDateString();
100+
$this->certificateDaysUntilExpiration = - $validTo->diffInDays(Carbon::now(), false);
101+
}
102+
103+
if (!empty($certificateInfo['extensions']) && !empty($certificateInfo['extensions']['subjectAltName'])) {
104+
$this->certificateAdditionalDomains = [];
105+
$domains = explode(', ', $certificateInfo['extensions']['subjectAltName']);
106+
foreach ($domains as $domain) {
107+
$this->certificateAdditionalDomains[] = str_replace('DNS:', '', $domain);
108+
}
109+
}
110+
}
111+
112+
public function hostCoveredByCertificate($host, $certificateHost, array $certificateAdditionalDomains = [])
113+
{
114+
if ($host == $certificateHost) {
115+
return true;
116+
}
117+
118+
// Determine if wildcard domain covers the host domain
119+
if ($certificateHost[0] == '*' && substr_count($host, '.') > 1) {
120+
$certificateHost = substr($certificateHost, 1);
121+
$host = substr($host, strpos($host, '.'));
122+
return $certificateHost == $host;
123+
}
124+
125+
// Determine if the host domain is in the certificate's additional domains
126+
return in_array($host, $certificateAdditionalDomains);
127+
}
128+
129+
protected function parseUrl($url)
130+
{
131+
if (empty($url)) {
132+
throw InvalidConfiguration::noUrlConfigured();
133+
}
134+
135+
$urlParts = parse_url($url);
136+
137+
if (!$urlParts) {
138+
throw InvalidConfiguration::urlCouldNotBeParsed();
139+
}
140+
141+
if (empty($urlParts['scheme']) || $urlParts['scheme'] != 'https') {
142+
throw InvalidConfiguration::urlNotSecure();
143+
}
144+
145+
return $urlParts;
146+
}
147+
148+
public function getUrl()
149+
{
150+
return $this->url;
151+
}
152+
153+
public function getCertificateInfo()
154+
{
155+
return $this->certificateInfo;
156+
}
157+
158+
public function getCertificateExpiration()
159+
{
160+
return $this->certificateExpiration;
161+
}
162+
163+
public function getCertificateDomain()
164+
{
165+
return $this->certificateDomain;
166+
}
167+
168+
public function getCertificateDaysUntilExpiration()
169+
{
170+
return $this->certificateDaysUntilExpiration;
171+
}
172+
173+
public function getAlarmDaysBeforeExpiration()
174+
{
175+
return $this->alarmDaysBeforeExpiration;
176+
}
177+
178+
public function getCertificateAdditionalDomains()
179+
{
180+
return $this->certificateAdditionalDomains;
181+
}
182+
}

0 commit comments

Comments
 (0)