-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathupdate-samlmd-idps.pl
executable file
·374 lines (302 loc) · 15.7 KB
/
update-samlmd-idps.pl
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
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
#!/usr/bin/env perl
# @author Guy Halse http://orcid.org/0000-0002-9388-8592
# @copyright Copyright (c) 2017, Tertiary Education and Research Network of South Africa
# @license https://github.com/tenet-ac-za/monitoring-plugins/blob/master/LICENSE MIT License
#
use strict;
use warnings;
use 5.10.0;
use experimental 'smartmatch';
use Getopt::Long;
use Pod::Usage;
use LWP::UserAgent;
use XML::XPath;
use XML::XPath::XMLParser;
use Config::YAML;
use MIME::Base64::URLSafe;
use Crypt::CBC;
use Crypt::OpenSSL::X509;
use Date::Parse;
use Encode;
sub emailToken($$)
{
my ($email, $key) = @_;
my $cipher = Crypt::CBC->new(-key => $key, -cipher => 'DES', -pbkdf=>'pbkdf2');
my $ciphertext = $cipher->encrypt($email);
return MIME::Base64::URLSafe::encode($ciphertext);
}
# Read a config file
my $baseConfig = exists($ENV{'OMD_ROOT'}) ? $ENV{'OMD_ROOT'} . '/etc/samlmd-idps.cfg' : '/etc/samlmd-idps.cfg';
$baseConfig = 'samlmd-idps.cfg' unless -f $baseConfig;
$baseConfig = "/dev/null" unless -f $baseConfig; # allow an empty config file
my $c = Config::YAML->new(
config => $baseConfig,
nagiosConfigDir => (exists($ENV{'OMD_ROOT'}) ? $ENV{'OMD_ROOT'} . '/etc/nagios/conf.d' : '/etc/nagios/conf.d'),
nagiosConfigFile => 'samlmd-generated-idps.cfg',
nagiosCheckCommand => '',
nagiosRestartCommand => '',
metadataURL => '',
disableContacts => [],
allow401Auth => [],
tokenKey => 'changeme',
);
GetOptions($c,
'config|c=s',
'verbose|v!',
'force|f!',
'restart|reload|r!',
'nagiosConfigDir|C=s',
'nagiosConfigFile|F=s',
'metadataURL|U=s',
'birkURL|B=s',
'birkURN=s',
'tokenKey|key|k=s',
'write|w=s',
'dump|D!',
'help|h|?',
) or pod2usage(2);
pod2usage(-exitval=>1, -verbose=>2) if defined $c->{'help'};
$c->read($c->{'config'}) if defined $c->{'config'};
# allow the config file to be saved
if (defined $c->{'write'}) {
$c->set('_outfile', $c->{'write'});
printf STDERR "Writing config to '%s'\n", $c->{'write'};
delete($c->{'write'});
$c->write;
exit;
}
# allow config to be dumped for debugging
if (defined $c->{'dump'}) {
use Data::Dumper;
print Dumper($c);
exit;
}
# First, some sanity checks
die('nagiosConfigDir does not exist') unless -d $c->{'nagiosConfigDir'};
die('nagiosConfigFile is not writable') unless -w $c->{'nagiosConfigDir'} . '/' . $c->{'nagiosConfigFile'} or (!-e $c->{'nagiosConfigDir'} . '/' . $c->{'nagiosConfigFile'} and -w $c->{'nagiosConfigDir'});
die('metadataURL is mandatory') unless defined $c->{'metadataURL'} and $c->{'metadataURL'};
# Get a last modified timestamp
my $idpsConfLastModified = 0;
$idpsConfLastModified = (stat $c->{'nagiosConfigDir'} . '/' . $c->{'nagiosConfigFile'})[9] if (-e $c->{'nagiosConfigDir'} . '/' . $c->{'nagiosConfigFile'});
# Get the metadata
my $ua = LWP::UserAgent->new(
'timeout' => 10,
'env_proxy' => 1,
'cookie_jar' => {},
);
my $response = $ua->get($c->{'metadataURL'});
die("Unable to get metadata file") unless $response->is_success and $response->header('Content-Type') eq 'application/xml';
printf(STDERR "Successfully fetched metadata from %s\n", $c->{'metadataURL'}) if defined $c->{'verbose'};
# check freshness
if ($response->last_modified() < $idpsConfLastModified) {
printf(STDERR "Nothing to do (Last-Modified %d < %d)%s\n", $response->last_modified(), $idpsConfLastModified, defined $c->{'force'} ? ' [FORCED]' : '') if defined $c->{'verbose'};
exit if (!defined $c->{'force'});
}
# Parse the metadata as XML
my $xp = XML::XPath->new('xml' => $response->decoded_content);
die("Unable to parse metadata as XML") unless defined $xp and $xp;
$xp->set_namespace('md', 'urn:oasis:names:tc:SAML:2.0:metadata');
$xp->set_namespace('shibmd', 'urn:mace:shibboleth:metadata:1.0');
die("XML does not contain <md:EntitiesDescriptor> as root") unless $xp->find('/md:EntitiesDescriptor');
# Find the entities
my $entities = $xp->find('/md:EntitiesDescriptor/md:EntityDescriptor');
die("Metadata does not seem to contain any entities") unless $entities->isa('XML::XPath::NodeSet');
printf(STDERR "Metadata contains %d entities\n", $entities->size) if defined $c->{'verbose'};
# write a file header
open(my $nagConf, '>', $c->{nagiosConfigDir} . '/' . $c->{nagiosConfigFile})
or die('error opening $c->{nagiosConfigFile} for writing');
printf $nagConf "# Autogenerated: %s\n", scalar localtime();
print $nagConf "# Changes to this file will be overwritten - do not edit\n#\n";
my %seenContacts = ();
my %seenScopes = ();
# Iterate through the entities
foreach my $entity ($entities->get_nodelist) {
my $entityID = $entity->getAttribute('entityID');
unless ($xp->find('md:IDPSSODescriptor', $entity)) {
printf(STDERR "Skipping %s, not an <md:IDPSSODescriptor>\n", $entityID);
next;
}
printf("ENTITY %s\n", $entityID) if defined $c->{'verbose'};
# SAFIRE/WAYF proxies
my $birkifiedEntityID = $entityID;
if (defined $c->{'birkURL'}) {
my $birkURL = $c->{'birkURL'};
$birkURL =~ s/^(https?:\/\/)//;
$birkifiedEntityID =~ s{^(https?:\/\/)(.*)$}{$1${birkURL}/$2}gi;
$birkifiedEntityID = defined $c->{'birkURN'} . ':' . $entityID if defined $c->{'birkURN'} and $birkifiedEntityID eq $entityID;
}
# record details of the metadata certs
my @certs;
foreach my $cert ($xp->find('md:IDPSSODescriptor/md:KeyDescriptor/ds:KeyInfo/ds:X509Data/ds:X509Certificate', $entity)->get_nodelist) {
my $x509 = Crypt::OpenSSL::X509->new_from_string("-----BEGIN CERTIFICATE-----\n".$cert->string_value."\n-----END CERTIFICATE-----\n", Crypt::OpenSSL::X509::FORMAT_PEM);
my $subject = $x509->subject_name->get_entry_by_type('CN')->value(); $subject =~ s/[^\w .-]/_/g;
push @certs, sprintf("%d/%s", str2time($x509->notAfter), $subject);
}
my $primaryScope = $xp->find('md:IDPSSODescriptor/md:Extensions/shibmd:Scope', $entity)->shift()->string_value;
my $displayScope = $primaryScope;
if (exists($seenScopes{$primaryScope}) and $seenScopes{$primaryScope}) {
my $id = $entity->getAttribute('ID');
$id =~ m/(\d+)$/;
printf("SCOPE %s was duplicated, entity ID is %s, adding suffix %d\n", $primaryScope, $id, $1) if defined $c->{'verbose'} and defined $1;
$primaryScope = sprintf('%s-%d', $primaryScope, $1) if defined $1;
}
$seenScopes{$primaryScope}++;
printf("SCOPE %s\n", $primaryScope) if defined $c->{'verbose'};
# merge any additional contacts from the config file into our XML node list
if (defined $c->{'additionalContacts'}) {
foreach my $addC (grep { $_->{'entityID'} eq $entityID } @{$c->{'additionalContacts'}}) {
printf(STDERR "ADDITIONAL CONTACT %s for %s\n", $addC->{'mail'}, $entityID) if defined $c->{'verbose'};
my $contactNode = XML::XPath::Node::Element->new('md:ContactPerson', $entity->getPrefix());
$contactNode->appendAttribute(XML::XPath::Node::Attribute->new('contactType', 'monitoring', $entity->getPrefix()));
if ($addC->{'mail'}) {
my $mailNode = XML::XPath::Node::Element->new('md:EmailAddress', $entity->getPrefix());
$mailNode->appendChild(XML::XPath::Node::Text->new($addC->{'mail'}));
$contactNode->appendChild($mailNode);
}
if ($addC->{'givenName'}) {
my $givenNameNode = XML::XPath::Node::Element->new('md:GivenName', $entity->getPrefix());
$givenNameNode->appendChild(XML::XPath::Node::Text->new($addC->{'givenName'}));
$contactNode->appendChild($givenNameNode);
}
if ($addC->{'sn'}) {
my $snNode = XML::XPath::Node::Element->new('md:SurName', $entity->getPrefix());
$snNode->appendChild(XML::XPath::Node::Text->new($addC->{'sn'}));
$contactNode->appendChild($snNode);
}
$entity->appendChild($contactNode);
}
}
my $contacts = $xp->find('md:ContactPerson[@contactType = "technical" or @contactType = "support" or @contactType = "monitoring"]', $entity);
foreach my $contact ($contacts->get_nodelist) {
my $givenName = $xp->findvalue('md:GivenName', $contact);
my $sn = $xp->findvalue('md:SurName', $contact);
my $displayName = join(' ', $givenName, $sn);
$displayName =~ s/\s*$//;
my $mail = $xp->findvalue('md:EmailAddress', $contact);
$mail =~ s/^mailto://;
# Sanity check the contact
if ($mail !~ m/^([^@]+)\@[a-z0-9\.]+$/i) {
printf(STDERR "Skipping contact %s in entity %s\n", $mail, $entityID) if defined $c->{'verbose'};
next;
}
my($defangedLocalPart) = lc($1); $defangedLocalPart =~ s/\W+/_/g;
next if $seenContacts{sprintf("%s-%s", $primaryScope, $defangedLocalPart)}++;
printf(STDERR "CONTACT %s\n", $mail) if defined $c->{'verbose'};
printf $nagConf "# CONTACT %s (entityID=%s%s)\n", $mail, $entityID, ($xp->findvalue('@contactType', $contact) eq 'monitoring' ? ' source='.$c->{'nagiosConfigFile'} : '');
print $nagConf "# AUTOGENERATED - DO NOT EDIT!\n";
print $nagConf "define contact {\n";
printf $nagConf " contact_name samlmd-c-%s-%s\n", $primaryScope, $defangedLocalPart;
printf $nagConf " alias %s\n", encode('utf-8', $displayName) if $displayName;
print $nagConf " use samlmd-generated-contact\n";
printf $nagConf " contactgroups samlmd-cg-%s\n", $primaryScope;
printf $nagConf " email %s\n", $mail;
# Disable contacts that were excluded
if ($mail ~~ $c->{disableContacts}) { # smartmatch
print $nagConf " host_notification_options n\n";
print $nagConf " host_notifications_enabled 0\n";
print $nagConf " service_notification_options n\n";
print $nagConf " service_notifications_enabled 0\n";
}
printf $nagConf " _UNSUBSCRIBE_TOKEN %s\n", emailToken($mail, $c->{'tokenKey'});
print $nagConf "}\n\n";
}
# Create this as a contact group even if there is only one contact to ease the rest of the configuration
printf $nagConf "# CONTACTGROUP %s\n", $primaryScope;
print $nagConf "# AUTOGENERATED - DO NOT EDIT!\n";
print $nagConf "define contactgroup {\n";
printf $nagConf " contactgroup_name samlmd-cg-%s\n", $primaryScope;
printf $nagConf " alias IdP: %s\n", $primaryScope;
print $nagConf "}\n\n";
printf $nagConf "# IDP %s (entityID=%s)\n", $primaryScope, $entityID;
print $nagConf "# AUTOGENERATED - DO NOT EDIT!\n";
print $nagConf "define service {\n";
printf $nagConf " service_description idp-%s\n", $primaryScope;
print $nagConf " use samlmd-generated-idp\n";
printf $nagConf " contact_groups samlmd-cg-%s\n", $primaryScope;
printf $nagConf " display_name IdP: %s\n", $displayScope;
printf $nagConf " _ALLOWAUTH401 %d\n", ($entityID ~~ $c->{'allow401Auth'}) ? 1 : 0;
printf $nagConf " _BIRKIFIEDENTITYID %s\n", $birkifiedEntityID if defined $c->{'birkURL'};
printf $nagConf " _CERTINFO %s\n", join('|', @certs) if @certs;
printf $nagConf " _ENTITYID %s\n", $entityID;
printf $nagConf " _INSTITUTION %s\n", encode('utf-8', $xp->findvalue("md:Organization/md:OrganizationName[\@xml:lang='en']", $entity));
printf $nagConf " _PRIMARYSCOPE %s\n", $displayScope;
print $nagConf "}\n\n";
# Service dependencies stop us sending notification when radsecproxy itself is broken
printf $nagConf "# SERVICEDEPENDENCY %s\n", $primaryScope;
print $nagConf "# AUTOGENERATED - DO NOT EDIT!\n";
print $nagConf "define servicedependency {\n";
print $nagConf " use samlmd-generated-servicedependency\n";
printf $nagConf " dependent_service_description idp-%s\n", $primaryScope;
print $nagConf "}\n\n";
# Service escalations allow us to notify the SOC if nobody resolves the problem
printf $nagConf "# SERVICEESCALATION %s\n", $primaryScope;
print $nagConf "# AUTOGENERATED - DO NOT EDIT!\n";
print $nagConf "define serviceescalation {\n";
printf $nagConf " service_description idp-%s\n", $primaryScope;
print $nagConf " use samlmd-generated-serviceescalation\n";
printf $nagConf " contact_groups samlmd-cg-%s\n", $primaryScope;
print $nagConf "}\n\n";
}
# Do we need to restart naemon
if (defined $c->{'restart'} and $c->{'nagiosRestartCommand'}) {
if ($c->{'nagiosCheckCommand'}) {
system($c->{'nagiosCheckCommand'}) == 0
or die ('nagios configuration failed, bailing out of restart');
}
print STDERR "Restarting monitoring process\n" if defined $c->{'verbose'};
system($c->{'nagiosRestartCommand'});
}
__END__
=head1 NAME
update-samlmd-idps.pl - create Nagios-style monitoring config from SAML metadata
=head1 SYNOPSIS
update-samlmd-idps.pl [-c C<config>] [--restart] [options...]
=head1 OPTIONS
=over 8
=item B<--config>=C<file>, -c C<file>
Specify the location of a YAML config file. Defaults to C<$OMD_ROOT/etc/samlmd-idps.cfg>, and failing that looks for C<samlmd-idps.cfg> in the current directory.
=item B<--verbose>, -v
Produce verbose output to STDERR
=item B<--force>, -f
Force writing a new Nagios config even if freshness tests say it is not necessary.
=item B<--restart>, --reload, -r
Restart/reload the monitoring system (requires that B<nagiosRestartCommand> is set in the config file).
=item B<--nagiosConfigDir>=C<dir>, -C C<dir>
Set the location of the Nagios config directory. Defaults to C<$OMD_ROOT/etc/nagios/conf.d>.
=item B<--nagiosConfigFile>=C<file>, -F C<file>
Set the location of the generated config file, relative to B<nagiosConfigDir>. Defaults to C<samlmd-generated-idps.cfg>.
=item B<--metadataURL>=C<url>, -U C<url>
Set the URL to fetch metadata from. Must me a method supported by L<LWP::UserAgent>.
=item B<--birkURL>=C<url>, -B C<url>, B<--birkURN>=C<url>
Set the base URL (or URN prefix) for the BIRK IdP Proxy (WAYF/SAFIRE). Note that you must set a URL if you want to use a URN.
=item B<--tokenKey>=C<string>, -k C<string>
Set an encryption key that is used to generate an opaque opt-out "unsubscribe" token for each contact defination. Defaults to C<changeme>.
=item B<--write>=C<file>, -w C<file>
Writes a new config file to C<file> (can be used to bootstrap a config file).
=item B<--dump>, -D
Dump the config with Data::Dumper for debugging.
=item B<--help>, -h, -?
Display usage information.
=back
=head1 CONFIG FILE
B<update-samlmd-idps.pl> expects a YAML config file. All of the L<OPTIONS> above can also be expressed in the config file -- the primary option should be used as the YAML key. The following additional options exist:
=over 8
=item B<disableContacts>
A list of email addresses that should never receive notifications.
=item B<allow401Auth>
A list of entityIDs that are allowed to generate a 401 Authorization Required response rather than 200 OK with a username field.
=back
=head2 SAMPLE CONFIG
# This is the configuration for update-samlmd-idps.pl
---
nagiosConfigDir: /omd/sites/mysite/etc/nagios/conf.d
nagiosConfigFile: samlmd-generated-idps.cfg
nagiosCheckCommand: naemon -v /omd/sites/mysite/tmp/naemon/naemon.cfg
nagiosRestartCommand: omd restart naemon
metadataURL: https://metadata.example.ac.za/
disableContacts:
allow401Auth:
- http://example.ac.za/shibboleth
=head1 LICENSE
This software is released under an MIT license.