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