/[pdpsoft]/trunk/nl.nikhef.ndpf.tools/renew_certs/dctcs-cli
ViewVC logotype

Contents of /trunk/nl.nikhef.ndpf.tools/renew_certs/dctcs-cli

Parent Directory Parent Directory | Revision Log Revision Log


Revision 2768 - (show annotations) (download)
Mon Mar 2 10:33:09 2015 UTC (6 years, 10 months ago) by dennisvd
File size: 13488 byte(s)
initial script to request host certificates via API calls to Digicert

1 #! /usr/bin/perl -w
2 #
3 # dctcs-cli.pl - DigiCert TCS server certificate generator CLI
4 # see below at end for documentation and arguments
5 #
6 # @(#)$Id$
7 # David Groep, Nikhef, 2015 - www.nikhef.nl/grid
8 #
9 # As per doc https://www.digicert.com/services/v2/documentation
10 #
11 use strict;
12 use LWP::UserAgent;
13 use LWP::Protocol::https;
14 use IO::Socket::SSL;
15 use JSON;
16 use Data::Dumper;
17 use Getopt::Long;
18 $Getopt::Long::ignorecase = 0;
19
20 # ###########################################################################
21 # basic configuration - you SHOULD probably change apikeyfile and orgid!
22 # where apikeyfile may be the empty string (will then ask key from STDIN)
23 #
24 my $resturl = "https://www.digicert.com/services/v2";
25 my $thishost = `hostname -f`; chomp($thishost);
26 my $dirprefix = "tcs-";
27 my $apikeylen = 47; # length in characters of API key, seems to be 47
28 #
29 my $orgid = "Nikhef"; # Nikhef's Org ID from DigiCert, see also GUI
30 my $apikeyfile = "/i/security/DigiCert/api.key"; # protected file with APIkey
31 #my $apikeyfile = "";
32
33 my $hostname;
34 my $dir; # target directory (default to tcs-<hostname>)
35 my $subdir=""; # target subdirecty (defaults to empty)
36 my $userelease=0; # use NDPF vlaai release mechanism
37 my $mode_req=0;
38 my $mode_retr=0;
39 my $basedir="";
40 my $product = "grid_host_ssl_multi_domain";
41 my $validity = 1;
42 my $approve = "";
43 my $help = 0;
44
45 &GetOptions(
46 'd|destdir=s' => \$dir,
47 's|subsir=s' => \$subdir,
48 'R|release' => \$userelease,
49 'K|keyfile=s' => \$apikeyfile,
50 'prefix=s' => \$dirprefix,
51 'P|product=s' => \$product,
52 'A|approve=s' => \$approve,
53 'O|orgid=s' => \$orgid,
54 'V|val|validity=i' => \$validity,
55 'r|request' => \$mode_req,
56 'i|install|retrieve' => \$mode_retr,
57 'h|help' => \$help
58 ) or exit 1;
59
60 if ( $help ) { &help; exit 0 }
61
62 # ###########################################################################
63 #
64 # validate options and input
65 die "Requires a mode (-r or -i) for running\n"
66 if ! $mode_req and ! $mode_retr;
67
68 $hostname = $ARGV[0] or die "No argument hostname";
69 $hostname =~ s/^$dirprefix//; # if you used the dirname instead ...
70 die "Invalid hostname $hostname\n"
71 if ( $hostname !~ /^[a-z][-0-9a-z]+\.[a-z][-0-9a-z\.]+$/ );
72
73 $dir = "$dirprefix$hostname" unless $dir;
74 $basedir=$dir;
75 if ( $subdir ne "" ) { $dir .= "/$subdir"; }
76
77 die "Invalid subdir (not a SUBdir) in $subdir\n" if $subdir =~ /^\//;
78 die "Cannot retrieve cert for $hostname if not requested this way,\n".
79 "and directory $dir does not exist\n" if ( $mode_retr and ! -d $dir );
80 die "Cannot re-use existing $dir for request\n"
81 if $mode_req and -d $dir;
82 die "TCS directory $dir has no orderID\n"
83 if $mode_retr and ! -e "$dir/orderid.txt";
84 die "Invalid validity (not a number or too long): $validity\n"
85 if $validity !~ /^\d+$/ or $validity > 3;
86 die "Cannot approve on installation (but on request only)\n"
87 if $mode_retr && $approve;
88
89 die "Expected argument FQDN\n" if ( $#ARGV < 0 );
90
91 # ###########################################################################
92 #
93 # read password if needed from file or STDIN
94 my $apikey;
95 if ( $apikeyfile ne "none" && $apikeyfile ne "" && -e $apikeyfile ) {
96 open FH,"<$apikeyfile";
97 $apikey = <FH>; chomp($apikey);
98 close FH;
99 } else {
100 print "Provide API key: ";
101 system("stty -echo");
102 $apikey = <STDIN>; chomp($apikey);
103 system("stty echo"); print "***\n";
104 }
105 die "Invalid API key length\n" if length($apikey) != $apikeylen;
106
107 # ###########################################################################
108 # setup defaults and LWP
109 #
110 # initialise UA
111 my $ua = LWP::UserAgent->new(ssl_opts => { verify_hostname => 1 });
112 $ua->agent("dctcs/0.2 (libwww-perl/$]; TERENA-TCS; $^O)");
113 $ua->default_header('X-DC-DEVKEY' => $apikey);
114 $ua->default_header('Content-Type' => "application/json");
115 $ua->default_header('Accept' => "application/json");
116
117 # ###########################################################################
118 # Actions: request or install
119 #
120 if ( $mode_req ) {
121 # #########################################################################
122 #
123 print "Creating request and order for $hostname\n";
124
125 if ( ! -d $basedir && $subdir ne "" ) {
126 mkdir $basedir or
127 die "Cannot create new basedir $basedir for $hostname: $!\n";
128 }
129 mkdir $dir or die "Cannot create new subdir $dir for $hostname: $!\n";
130
131 # generate request using OpenSSL
132 my $rc = system("openssl req -new -nodes -keyout $dir/key-$hostname.pem ".
133 "-out $dir/req-$hostname.pem -newkey rsa:2048 ".
134 "-subj '/C=NL/O=Nikhef/CN=$hostname'");
135 die "Generation of CSR in $dir failed: $rc\n" if $rc;
136
137 # construct request for DigiCert API v2
138 open CSR,"<$dir/req-$hostname.pem" or die "Cannot open CSR: $!\n";
139 my $csr = ""; while (<CSR>) { $csr .= $_; }; close CSR;
140
141 # if the org is named, we need to resolve it to an ID in the
142 # user's current container
143 if ( $orgid !~ /^\d+$/ ) { # it is a named org, so resolve
144 my $containerid = &getContainerId($ua);
145 print "Resolving org $orgid in container $containerid\n";
146
147 my $oanswer = &getDump($ua,"GET",
148 "container/$containerid/order/organization" );
149
150 foreach my $iorg ( @{$oanswer->{"organizations"}} ) {
151 if ( lc($iorg->{"name"}) eq lc($orgid) ) {
152 $orgid = $iorg->{"id"}; last;
153 }
154 }
155 die "Cannot resolve org $orgid via API\n" if ( $orgid !~ /^\d+$/ );
156 print "Processing order for org #$orgid in container #$containerid\n";
157 }
158
159 my %request = (
160 "certificate" => {
161 "common_name", "$hostname",
162 "dns_names" , [ ],
163 "csr", "$csr",
164 "signature_hash", "sha256"
165 },
166 "organization" => { "id", "$orgid" },
167 "validity_years", $validity,
168 "comments", "Requested on $thishost for NDPF host $hostname ".
169 "by ".getpwuid( $< ),
170 );
171
172 # populate subjectAltName, with at least the primary FQDN
173 $request{"certificate"}{"dns_names"} = [];
174 foreach my $hn ( @ARGV ) {
175 push @{$request{"certificate"}{"dns_names"}},$hn;
176 }
177
178 my $answer = &getDump($ua,"POST",
179 "order/certificate/$product",to_json(\%request) );
180
181 # store the OrderID, used by the installed, in a special file
182 if ( defined $answer->{"id"} ) {
183 open FH,">$dir/orderid.txt" and do {
184 print FH $answer->{"id"}."\n";
185 close FH;
186 };
187 };
188
189 # save the full JSON answer, including requestID, for later perusal
190 open FH,">$dir/response.json" and do {
191 print FH to_json($answer);
192 close FH;
193 };
194
195 # approval: needs a comment, and can only be done by Admins of CertCentral
196 if ( $approve ne "" ) {
197 my $requestid = $answer->{"requests"}[0]{"id"};
198 if ( $requestid =~ /^\d+$/ ) {
199 print "Approving request (attempt) for Request ID $requestid\n";
200 my %approval = (
201 "status" => "approved",
202 "processor_comment" => "By ".getpwuid( $< ).": $approve"
203 );
204 my $approvalresult = &getDump($ua,"PUTDUMP","request/$requestid/status",
205 to_json(\%approval));
206 print "Approved: $approvalresult\n";
207 } else {
208 warn "Invalid request ID found ($requestid), sorry!\n";
209 }
210 }
211 #
212 # #########################################################################
213
214 } elsif ( $mode_retr ) {
215
216 # #########################################################################
217 #
218
219 # works based on stored order number in dirctory
220 print "Retrieving order for $hostname\n";
221
222 open ORDERID,"<$dir/orderid.txt" or die "Cannot open OrderID $!\n";
223 my $orderid = <ORDERID>; chomp($orderid);
224 die "Invalid order ID $orderid\n" unless $orderid =~ /^\d+$/;
225
226 my $answer = &getDump($ua,"GET","order/certificate/$orderid");
227
228 die "No certificate for order $orderid (yet)\n"
229 unless defined $answer->{"certificate"}{"id"} and
230 $answer->{"certificate"}{"id"} =~ /^\d+$/;
231 my $certid = $answer->{"certificate"}{"id"};
232
233 print "Retrieving order $orderid certificate ID $certid\n";
234
235 my $certdata =
236 &getDump($ua,"GETDUMP","/certificate/$certid/download/format/pem_all");
237
238 # this splits the full PEM file of all into per-cert chunks with good naming
239 my $i = 0; my $str = ""; my $processing = 0; my $eecname=""; my $l;
240
241 my @certlist = split /[\r\n]+/, $certdata; # irrespective of EOLN in input
242 foreach $l ( @certlist ) {
243 if ( $l eq "-----BEGIN CERTIFICATE-----" ) {
244 $str = ""; $processing = 1;
245 }
246 if ( $l eq "-----END CERTIFICATE-----" ) {
247 my ($subj,$olstr,$cn,$fname);
248
249 $str .= "$l\n"; $processing = 0;
250 ( $olstr = $str ) =~ s/\n/\\n/gm;
251
252 $subj = `echo -ne "$olstr" | openssl x509 -noout -subject`;
253 chomp($subj); $subj =~ s/^[^=]+= *//;
254
255 ( $cn = $subj ) =~ s/^.*\/CN=//;
256 ( $fname = $cn ) =~ s/\s+//g;
257 open CF,">$dir/cert-$fname.pem" and do {
258 print CF $str;
259 close CF;
260 if ( $i == 0 and $cn eq $hostname ) {
261 $eecname = "$dir/cert-$fname.pem";
262 }
263 };
264 # informational output follows
265 print "Certificate $i (cert-$cn):\n";
266 print " Subject: $subj\n";
267 $i++;
268 }
269 if ( $processing ) { $str .= "$l\n"; }
270 }
271
272 # cater for subdir and release to usercert/userkey.pem
273 if ( $userelease and $eecname ) {
274 if ( $eecname ne "$dir/cert-$hostname.pem") {
275 die "Naming inconsistency for $eecname, oops! ".
276 "It is not $basedir/$subdir/cert-$hostname.pem\n";
277 }
278 if ( ( -e "$basedir/usercert.pem" && ! -l "$basedir/usercert.pem" )
279 || ( -e "$basedir/userkey.pem" && ! -l "$basedir/userkey.pem" ) ) {
280 warn "One of $basedir/user{key,cert}.pem not a symlink, release skipped"
281 } else {
282 unlink "$basedir/usercert,pem";
283 unlink "$basedir/userkey,pem";
284 $subdir = "." unless $subdir;
285 symlink "$subdir/key-$hostname.pem","$basedir/userkey.pem";
286 symlink "$subdir/cert-$hostname.pem","$basedir/usercert.pem";
287 open FH,">$basedir/release.state" and close FH;
288 }
289 }
290
291 #
292 # #########################################################################
293
294 }
295
296 exit 0;
297
298 # ###########################################################################
299 #
300
301 # getDump($ua,"(GET|PUT|GETDUMP|PUTDUMP|POST|POSTDUMP)",$url,[$content])
302 # where the "DUMP" modes will return plain text from the answer, but
303 # the default modes will return a perl object created from the JSON
304 #
305 sub getDump($$$$) {
306 my ($ua,$type,$request,$content) = @_;
307 my $data;
308 $type = "GET" unless (defined $type and $type ne "");
309 die "Invalid call with GET and contents\n"
310 if ( $type eq "GET" and defined $content and $content ne "");
311
312 my $outtype = $type;
313 $type =~ s/DUMP$//;
314
315 my $req = HTTP::Request->new($type => "$resturl/$request");
316 if ( ( $type eq "POST" || $type eq "PUT" )
317 and defined $content and $content ne "" ) {
318 $req->content($content);
319 }
320
321 my $res = $ua->request($req);
322
323 if ($res->is_success) {
324 if ( $outtype =~ /DUMP/ ) { $data = $res->content; }
325 else { $data = from_json($res->content); }
326 } else {
327 die "Invalid API call: ", $res->status_line, "\n";
328 }
329 return $data;
330 }
331
332 # example of a specific API wrapper
333 sub getContainerId($) {
334 my ($ua) = @_;
335 my $data;
336
337 my $req = HTTP::Request->new(GET => "$resturl/user/me");
338 my $res = $ua->request($req);
339
340 if ($res->is_success) {
341 $data = from_json($res->content);
342 } else {
343 die "Invalid API call: ", $res->status_line, "\n";
344 }
345 return $data->{container}{id};
346 }
347
348 # ###########################################################################
349 # Valid products
350 #
351 # 'name_id' => 'client_digital_signature_plus',
352 # 'name_id' => 'client_email_security_plus',
353 # 'name_id' => 'client_grid_premium',
354 # 'name_id' => 'client_grid_robot_email',
355 # 'name_id' => 'client_grid_robot_fqdn',
356 # 'name_id' => 'client_grid_robot_name',
357 # 'name_id' => 'client_premium',
358 # 'name_id' => 'code_signing',
359 # 'name_id' => 'code_signing_ev',
360 # 'name_id' => 'document_signing_org_1',
361 # 'name_id' => 'document_signing_org_2',
362 # 'name_id' => 'grid_host_ssl',
363 # 'name_id' => 'grid_host_ssl_multi_domain',
364 # 'name_id' => 'ssl_ev_multi_domain',
365 # 'name_id' => 'ssl_ev_plus',
366 # 'name_id' => 'ssl_multi_domain',
367 # 'name_id' => 'ssl_plus',
368 # 'name_id' => 'ssl_wildcard',
369 #
370
371 # ###########################################################################
372 # HELP
373
374 sub help() {
375
376 ( my $base = $0 ) =~ s/^.*\///;
377 print <<EOF;
378 Request and retrieve certificates from the TCS DigiCert service via the API
379
380 $base [-P product] [-R] [-s path] [-d basedir] [-A comment] [-K keyfile]
381 [-O orgid]
382 [-r|-i] hostname [altname ...]
383
384 -r enter REQUEST mode + either -r or -i required
385 -i enter INSTALLATION mode +
386
387 -P <product> order <product>, with "grid_host_ssl_multi_domain" the default
388 but "ssl_multi_domain" also useful. See below
389 -V validity validity request period in years (default: 1)
390 -K keyfile file with the API key for the user as a single line
391 -O orgid Organisation name or ID
392 -s subdir use <subdir> for key, cert, and orderid storage (no default)
393 -R use the NDPF vlaai symlink & release.state mechanism
394 which works best with subdir usage (will touch release.state)
395 -A comment Approve request as well, with "comment" (admins only)
396 --prefix=dir dir prefix (defaults to "tcs-")
397
398 All certs are requested with SHA256 digest. Other products that might
399 work with this script are:
400 grid_host_ssl, grid_host_ssl_multi_domain
401 ssl_ev_multi_domain, ssl_ev_plus
402 ssl_multi_domain, ssl_plus
403 But note that EV requires an extra approval step by the EV admin, and
404 that wildcard certs will mess up the directory naming.
405 EOF
406 return 0;
407 }

grid.support@nikhef.nl
ViewVC Help
Powered by ViewVC 1.1.28