From 24157694f0c98740e9e5660c0b521d8e83594cc5 Mon Sep 17 00:00:00 2001 From: Sergey Poznyakoff Date: Sun, 12 Feb 2017 21:18:08 +0100 Subject: Support creation of SAN certificates shared between several virtual hosts * acmeman: New option --alt-names New macro LetsEncryptReference Coalesce virtual hosts referring to the same server name. --- acmeman | 215 +++++++++++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 179 insertions(+), 36 deletions(-) diff --git a/acmeman b/acmeman index 59af82c..a747a47 100755 --- a/acmeman +++ b/acmeman @@ -40,10 +40,11 @@ acmeman - manages ACME certificates =head1 SYNOPSIS B -[B<-Fdn>] +[B<-Fadn>] [B<-D> I] [B<-f> I] [B<-l> B|B|B] +[B<--alt-names>] [B<--config-file=>I] [B<--debug>] [B<--dry-run>] @@ -134,13 +135,13 @@ B is run. To use the created certificate, create a new B block that contains the following statement: -Use LetsEncryptServer I + Use LetsEncryptServer DOMAIN where I is the name used in the B statement of the plain HTTP configuration. Copy the B statements (if any), and add the -rest of configuration statements. Note, that you need not use the B -statement, as it will be included when the B macro is -expanded. +rest of configuration statements. Note, that you need not use the +B statement, as it will be included when the B +macro is expanded. Example: @@ -156,23 +157,47 @@ Example: ServerAlias www.example.com ... - -=head1 OPTIONS -=over 4 +Alternatively, you can use the B macro, which differs from +B in that it configures only SSL settings, without the +B statement, which therefore must be included explicitly: -=item B<-h> + + ServerName example.org + ServerAlias www.example.com + Use LetsEncryptSSL example.org + ... + -Prints a short usage summary. - -=item B<--help> +LetsEncrypt limits the number of certificates requested for a single +registered domain per week (at the time of this writing - 20). To avoid +hitting that limit, you may wish to use the same certificate for different +virtual hosts. The special macro B is provided for +that purpose. Suppose, for example, that you wish to configure server +name B to use the same certificate as B +(configured in the example above). You then declare the virtual host +for the plain HTTP as follows: -Prints detailed user manual. + + ServerName git.example.org + Use LetsEncryptReference example.org + ... + -=item B<--usage> +The argument to the B macro indicates the CN name of +the certificate to which the current server name (and aliases, if any) are +to be added as alternative names. The corresponding virtual host for SSL +will use the B macro to configure the correct certificate: -Outputs a terse reminder of the command line syntax along with a -list of available options. + + ServerName git.example.org + Use LetsEncryptSSL example.org + ... + + +=head1 OPTIONS + +=over 4 =item B<-D>, B<--tile-delta=>I @@ -187,6 +212,14 @@ Force renewal of certificates, no matter their expire date. With B<--setup>, force installing the B file even if it already exists. +=item B<-a>, B<--alt-names> + +Compare the list of alternative names of each certificate with the one +gathered from the Apache configuration, and reissue the certificate +if the two lists don't match. This uses an ad-hoc logic, due to the +deficiency of the underlying X509 module, and therefore is not enabled +by default. + =item B<-d>, B<--debug> Increase debugging level. Multiple options accumulate. Three debugging @@ -230,6 +263,25 @@ production. Implies B<--debug>. Set up the B infrastructure files. =back + +The following options are informational: + +=over 4 + +=item B<-h> + +Prints a short usage summary. + +=item B<--help> + +Prints detailed user manual. + +=item B<--usage> + +Outputs a terse reminder of the command line syntax along with a +list of available options. + +=back =head1 AUTHOR @@ -248,6 +300,7 @@ my $letsencrypt_root_cert_url = 'https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem'; my $time_delta = 86400; my $force = 0; +my $check_alt_names = 0; my %select; # Hash of selected domain names # Variables read from the Apache configuration files @@ -368,6 +421,41 @@ sub domain_cert_expires { my $crt = make_filename('cert', $domain); if (-f $crt) { my $x509 = Crypt::OpenSSL::X509->new_from_file($crt); + + my $exts = $x509->extensions_by_name(); + if (exists($exts->{subjectAltName})) { + my $msg = $check_alt_names + ? 'will renew' : 'use -a to trigger renewal'; + + # FIXME: Crypt::OpenSSL::X509 returns extensions as strings, + # instead of as ASN.1 objects. Until it is fixed, the + # following naive logic is implemented to split the string into + # names: + my @names = split /\.\.+/, $exts->{subjectAltName}->value(); + # First value is irrelevant + shift @names; + # Prepare sorted arrays of alternative names and requested + # names (@vh). + @names = sort @names; + my @vh = sort ($domain, @_); + # Compare them + if ($#vh != $#names) { + debug(1, "$crt: number of subject names changed; $msg"); + return 1 if $check_alt_names; + } else { + for (my $i = 0; $i <= $#names; $i++) { + if ($names[$i] ne $vh[$i]) { + debug(1, "$crt: subject names differ; $msg"); + if ($check_alt_names) { + return 1; + } else { + last; + } + } + } + } + } + my $expiry = $x509->notAfter(); my $strp = DateTime::Format::Strptime->new( @@ -484,8 +572,9 @@ sub examine_http_config { STATE_INITIAL => 0, # Initial state STATE_VIRTUAL_HOST => 1, # In VirtualHost block STATE_USE_CHALLENGE => 2, # Ditto, but challenge macro was used - STATE_MACRO_CHALLENGE => 3, # In LetsEncryptChallenge macro - STATE_MACRO_SERVER => 4 + STATE_USE_REFERENCE => 3, # Ditto, but reference macro was used + STATE_MACRO_CHALLENGE => 4, # In LetsEncryptChallenge macro + STATE_MACRO_SSL => 5 }; state $state = STATE_INITIAL; @@ -494,6 +583,7 @@ sub examine_http_config { if (open(my $fd, '<', $file)) { my $server_name; my @server_aliases; + my $reference; my $line; while (<$fd>) { @@ -512,33 +602,53 @@ sub examine_http_config { $state = STATE_VIRTUAL_HOST; $server_name = undef; @server_aliases = (); + $reference = undef; } elsif (/^ServerRoot\s+(.+)/i) { $server_root = dequote($1); } elsif (/^<(?:(?i)Macro)\s+LetsEncryptChallenge/) { $state = STATE_MACRO_CHALLENGE; - } elsif (/^<(?:(?i)Macro)\s+LetsEncryptServer\s+(.+?)\s*>/) { - $state = STATE_MACRO_SERVER; + } elsif (/^<(?:(?i)Macro)\s+LetsEncryptSSL\s+(.+?)\s*>/) { + $state = STATE_MACRO_SSL; $filename_arg = $1; $filename_arg =~ s{\$}{\\\$}; } } elsif ($state == STATE_VIRTUAL_HOST - || $state == STATE_USE_CHALLENGE) { + || $state == STATE_USE_CHALLENGE + || $state == STATE_USE_REFERENCE) { if (m{ + + RewriteEngine On + RewriteRule /.well-known/acme-challenge - [L] + - - ServerName \$domain + + Use LetsEncryptChallenge + Alias /.dummy/\$domain /dev/null + + + SSLEngine on SSLProtocol all -SSLv2 -SSLv3 SSLHonorCipherOrder on @@ -662,6 +780,12 @@ sub initial_setup { SSLCertificateKeyFile /etc/ssl/acme/\$domain/privkey.pem SSLCACertificateFile /etc/ssl/acme/lets-encrypt-x3-cross-signed.pem + + + ServerName \$domain + Use LetsEncryptSSL \$domain + + EOT ; close $fd; @@ -677,6 +801,23 @@ EOT exit(EX_OK); } +sub coalesce { + my $ref = shift; + debug(1, "coalescing virtual hosts"); + my $i = 0; + my @vhost; + foreach my $ent (sort { $a->{names}[0] cmp $b->{names}[0] } + map { { ord => $i++, names => $_ } } @{$ref}) { + if (@vhost && $vhost[-1]->{names}[0] eq $ent->{names}[0]) { + shift @{$ent->{names}}; + push $vhost[-1]->{names}, @{$ent->{names}}; + } else { + push @vhost, $ent; + } + } + @{$ref} = map { $_->{names} } sort { $a->{ord} <=> $b->{ord} } @vhost; +} + my %apache_layout_tab = ( slackware => { config => '/etc/httpd/httpd.conf', incdir => '/etc/httpd/extra' @@ -730,7 +871,8 @@ GetOptions("h" => sub { "setup|s" => \$setup, "config-file|f=s" => sub { $apache_layout = { config => $_[1] } - } + }, + "alt-names|a" => \$check_alt_names ) or exit(EX_USAGE); if ($dry_run) { @@ -764,6 +906,7 @@ if ($setup) { examine_http_config($apache_layout->{config}) or exit(EX_OSFILE); } debug(1, "nothing to do") unless @virthost; +coalesce \@virthost; # Check challenge root directory if (defined($www_root)) { @@ -779,7 +922,7 @@ $account_key = Crypt::OpenSSL::RSA->generate_key(4096); $challenge = Protocol::ACME::Challenge::LocalFile->new({www_root => $www_root}); foreach my $vhost (@virthost) { - if ($force || domain_cert_expires(${$vhost}[0])) { + if ($force || domain_cert_expires(@{$vhost})) { register_domain_certificate(@$vhost); } } -- cgit v1.2.1