Контроль срока действия TLS сертификата с помощью Icinga2

Контроль срока действия TLS сертификата с помощью Icinga2

Roman Bogachev VMware Specialist | Drone Pilot | Traveler

Отслеживаем количество дней до истечения срока сертификата TLS

Шаг 1. Установка дополнительных пакетов

CentOS/RedHat

1
yum install perl-Date-Calc perl-Crypt-OpenSSL-X509

Ubuntu/Debian

1
apt-get install libdate-calc-perl libcrypt-openssl-x509-perl

Шаг 2. Добавляем скрипт проверки

Добавим скрипт проверки сертификата, для этого создаем новый файл /usr/lib64/nagios/plugins/check_tls_certificate_expiration со следующим содержимым:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
#!/usr/bin/env perl

use strict;
use warnings;
use Getopt::Long;
use Date::Calc qw(Delta_Days Parse_Date Today);
use Crypt::OpenSSL::X509;

# Basic Arguments
our $ARG_WARNING_DAYS = 14;
our $ARG_CRITICAL_DAYS = 7;

# Arguments for network check
our $ARG_ADDRESS = '';
our $ARG_HOSTNAME = ''; # Only used for HTTP SNI
our $ARG_PORT = 443;
our $ARG_OPENSSL = '/usr/bin/openssl';
our $ARG_STARTTLS = '';

# Argument for file check
our $ARG_FILE = '';
our $ARG_COMMON_NAME = '';

main();

sub main {
parseArguments();
my $certificate = retrieveCertificate();

my $x509 = Crypt::OpenSSL::X509->new_from_string(
$certificate, Crypt::OpenSSL::X509::FORMAT_PEM
);

decideExitCode(
calculateDaysLeft($x509->notAfter()),
extractCommonName($x509->subject())
);
}

sub decideExitCode {
my ( $daysLeft, $commonName ) = @_;

if ( length($ARG_COMMON_NAME) != 0 ) {
# Common name check wanted
if ( $ARG_COMMON_NAME ne $commonName ) {
printf("CRITICAL - Common name does not match. Got: %s\n",
$commonName);
exit(2);
}
}

# Check if certificate is already expired
# Display an appropriate string then
if ( $daysLeft <= 0 ) {
printf("CRITICAL - Certificate is expired\n");
exit(2);
}

# Days left check
if ( $daysLeft <= $ARG_CRITICAL_DAYS ) {
printf("CRITICAL - %d days left\n", $daysLeft);
exit(2);
}
elsif ( $daysLeft <= $ARG_WARNING_DAYS ) {
printf("WARNING - %d days left\n", $daysLeft);
exit(1);
}
else {
printf("OK - %d days left\n", $daysLeft);
exit(0);
}
}

sub parseArguments {
GetOptions (
'address=s' => \$ARG_ADDRESS,
'port=s' => \$ARG_PORT,
'hostname=s' => \$ARG_HOSTNAME,
'common-name=s' => \$ARG_COMMON_NAME,
'file=s' => \$ARG_FILE,
'warn=i' => \$ARG_WARNING_DAYS,
'crit=i' => \$ARG_CRITICAL_DAYS,
'openssl=s' => \$ARG_OPENSSL,
'starttls=s' => \$ARG_STARTTLS
);

validateArguments();
}

sub validateArguments {
# Common Arguments
if ( ! isInteger($ARG_WARNING_DAYS) ) {
exitUnknown("Argument --warn is not numeric");
}

if ( ! isInteger($ARG_CRITICAL_DAYS) ) {
exitUnknown("Argument --crit is not numeric");
}

if ( $ARG_CRITICAL_DAYS > $ARG_WARNING_DAYS ) {
exitUnknown(sprintf(
'Critical value (%d) is greater than warning value (%d)',
$ARG_CRITICAL_DAYS,
$ARG_WARNING_DAYS
));
}

# Decide which mode
if ( length ($ARG_FILE) == 0 ) {
# Network mode
# Validate network parameter
validateNetworkArguments();
}
else {
# File mode
# Validate file parameter
validateFileArguments();
}
}

sub validateNetworkArguments {
if ( ! -e $ARG_OPENSSL ) {
exitUnknown(sprintf("OpenSSL not found under %s", $ARG_OPENSSL));
}

if ( length($ARG_ADDRESS) == 0) {
exitUnknown("Argument --address is not set");
}

if ( ! isInteger($ARG_PORT) ) {
exitUnknown("Argument --port is not numeric");
}

if ( $ARG_PORT <= 0 || $ARG_PORT >= 65535 ) {
exitUnknown("Argument --port is out of bounds! Valid: 1-65535");
}
}

sub validateFileArguments {
if ( ! -e $ARG_FILE ) {
exitUnknown(sprintf(
"Certificate under %s not found",
$ARG_FILE
));
}
}

sub isInteger {
my $possibleInteger = shift;

return 1 if ( $possibleInteger =~ /\d+/ );
return 0;
}

sub exitUnknown {
printf("UNKNOWN - %s\n", shift);
exit 3;
}

sub retrieveCertificate {
my $certificate = '';

if ( length($ARG_FILE) == 0 ) {
# Network mode
# Fetch certificate and return it as a string

# Check if we have to set SNI
my $sniPart = '';

if ( length($ARG_HOSTNAME) != 0 ) {
$sniPart = sprintf("-servername %s", $ARG_HOSTNAME);
}

# Check if we have to set starttls protocol
my $starttlsPart = '';

if ( length($ARG_STARTTLS) != 0 ) {
$starttlsPart = sprintf("-starttls %s", $ARG_STARTTLS);
}

# Build command
my $command = sprintf(
"echo \"\" | %s s_client -connect %s:%d %s %s 2> /dev/null | %s x509 2> /dev/null",
$ARG_OPENSSL,
$ARG_ADDRESS,
$ARG_PORT,
$starttlsPart,
$sniPart,
$ARG_OPENSSL
);

$certificate = `$command`;
}
else {
# File mode
# Fetch certificate from file and return it as string
open(CERT, $ARG_FILE);
my $line = '';

while ( $line = <CERT> ) {
$certificate .= $line;
}

close(CERT);
}

if ( $certificate !~ /BEGIN CERTIFICATE/ ) {
exitUnknown("Didn't receive a certificate");
}

return $certificate;
}

sub calculateDaysLeft {
my $expireDate = shift;

my ($year, $month, $day) = Parse_Date($expireDate);
my ( $nowYear, $nowMonth, $nowDay) = Today();

return Delta_Days($nowYear, $nowMonth, $nowDay,
$year, $month, $day);
}

sub extractCommonName {
my $possibleCommonName = shift;

if ( $possibleCommonName =~ m/CN=(.*)/ ) {
# Strip possible E-Mail address
my $cn = $1;
$cn =~ s/,.*//;

return $cn;
}
else {
exitUnknown("No common name given");
}
}

Заранее обращаю внимание, что, при необходимости, можно изменить порог для проверки срока истечения сертификата:

1
2
our $ARG_WARNING_DAYS = 14;
our $ARG_CRITICAL_DAYS = 7;

Шаг 3. Добавляем команду в Icinga2

В конфигурационный файл commands.conf добавляем команду и ключи выполнения:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
object CheckCommand "tls_certificate_expiration" {
import "plugin-check-command"

command = [ PluginDir + "/check_tls_certificate_expiration" ]

arguments = {
"--address" = "$tls_address$"
"--port" = "$tls_port$"
"--hostname" = "$tls_hostname$"
"--common-name" = "$tls_common_name$"
"--file" = "$tls_file$"
"--warn" = "$tls_warn$"
"--crit" = "$tls_crit$"
"--openssl" = "$tls_openssl$"
"--starttls" = "$tls_protocol$"
}

vars.tls_address = "$address$"
}

Шаг 4. Добавляем сервис

Добавляем сервис для мониторинга на примере домена моего блога (не забудьте, что обязательно должен быть указан объект хоста):

1
2
3
4
5
6
7
8
9
10
11
12
13
14

object Host "bogachev.biz" {
(...)
}

object Service "tls_bogachev.biz" {
host_name = "bogachev.biz"
display_name = "Certificate expires of BOGACHEV.BIZ"
check_interval = 1d

check_command = "tls_certificate_expiration"

vars.tls_hostname = "bogachev.biz"
}

Перезагружаем сервис Icinga2 и проверяем.

Если мы хотим использовать проверку для нескольких хостов, то используем apply Service.

Для того, чтобы добавить сервисную группу для проверки TLS сертификата, то добавим следующие строки в конфигурационный файл groups.conf

1
2
3
4
object ServiceGroup "tls_certificate_expiration" {
display_name = "TLS certificate expiration"
assign where service.check_command == "tls_certificate_expiration"
}

Проверка HTTPS сертификата через SNI (Server Name Indication)

1
2
3
4
5
6
7
8
9
10
11
12
object Service "tls-bogachev.biz" {
import "generic-service"

check_command = "tls_certificate_expiration"
host_name = "bogachev.biz"

vars.tls_hostname = "bogachev.biz"
vars.tls_common_name = "bogachev.biz"
// Note: address is automatically set to the host's address
// Note: port is default 443
// Note: If you skip tls_common_name common name checking is disabled
}

Для проверки локального сертификата, например для приложения, используем следующую команду:

1
2
3
4
5
6
7
8
9
object Service "tls-bogachev" {
import "generic-service"

check_command = "tls_certificate_expiration"
host_name = "bogachev.local"

vars.tls_file = "/path/to/CA.pem"
// Note: address is automatically set to the host's address
}