diff options
author | Sergey Poznyakoff <gray@gnu.org.ua> | 2017-02-05 18:48:25 +0100 |
---|---|---|
committer | Sergey Poznyakoff <gray@gnu.org.ua> | 2017-02-05 18:48:25 +0100 |
commit | c80afcff90c76233fb10e40dce2fa9083383072b (patch) | |
tree | 8e4c175380ceee11094639dfd27d3ba934ab9100 /acmeman | |
download | acmeman-c80afcff90c76233fb10e40dce2fa9083383072b.tar.gz acmeman-c80afcff90c76233fb10e40dce2fa9083383072b.tar.bz2 |
Initial commit
Diffstat (limited to 'acmeman')
-rw-r--r-- | acmeman | 552 |
1 files changed, 552 insertions, 0 deletions
@@ -0,0 +1,552 @@ +#! /usr/bin/perl +use strict; +use Protocol::ACME; +use Protocol::ACME::Challenge::LocalFile; +use Crypt::Format; +use Crypt::OpenSSL::PKCS10 qw(:const); +use Crypt::OpenSSL::RSA; +use Crypt::OpenSSL::X509; +use File::Basename; +use File::Path qw(make_path); +use DateTime::Format::Strptime; +use WWW::Curl::Easy; +use Pod::Usage; +use Pod::Man; +use Getopt::Long qw(:config gnu_getopt no_ignore_case); +use POSIX qw(strftime time floor); + +our $VERSION = '0.90'; + +=head1 NAME + +acmeman - manages ACME certificates + +=head1 SYNOPSIS + +=head1 DESCRIPTION + +=head1 OPTIONS + +=head1 AUTHOR + +=cut + +my $progname = basename($0); +my $progdescr = "manages ACME certificates"; +my $debug; +my $dry_run; +my $acme_host = 'acme-staging.api.letsencrypt.org'; +my %acme_endpoint = (prod => 'acme-v01.api.letsencrypt.org', + staging => 'acme-staging.api.letsencrypt.org'); +my $letsencrypt_root_cert_url = + 'https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem'; +my $www_root; +my $time_delta = 86400; +my $force = 0; + +my $account_key; +my $challenge; + +use constant { + EX_OK => 0, + EX_USAGE => 64, + EX_DATAERR => 65, + EX_NOINPUT => 66, + EX_SOFTWARE => 70, + EX_OSFILE => 72, + EX_CANTCREAT => 73, + EX_NOPERM => 77, + EX_CONFIG => 78 +}; + +sub error { + my $msg = shift; + local %_ = @_; + print STDERR "$progname: " if defined($progname); + print STDERR "$_{prefix}: " if defined($_{prefix}); + print STDERR "$msg\n" +} + +sub debug { + my $l = shift; + error(join(' ',@_), prefix => 'DEBUG') if $debug >= $l; +} + +sub abend { + my $code = shift; + print STDERR "$progname: " if defined($progname); + print STDERR "@_\n"; + exit $code; +} + +my $filename_arg; +my %filename_pattern; + +sub make_filename { + my ($type, $arg) = @_; + abend(EX_SOFTWARE, "no $type in \%filename_pattern") + unless exists $filename_pattern{$type}; + my $res = $filename_pattern{$type}; + $res =~ s{$filename_arg}{$arg}g; + return $res; +} + +sub prep_dir { + my $dir = dirname(shift); + if (! -d $dir) { + debug(2, "creating directory $dir"); + my @created = make_path("$dir", { error => \my $err } ); + if (@$err) { + for my $diag (@$err) { + my ($file, $message) = %$diag; + if ($file eq '') { + error($message); + } else { + error("problem unlinking $file: $message"); + } + } + exit(EX_CANTCREAT); + } + } +} + + +sub debug_to_loglevel { + my @lev = ('err', 'info', 'debug'); + return $lev[$debug > $#lev ? $#lev : $debug]; +} + +sub make_csr { + my $cn = shift; + my $req = Crypt::OpenSSL::PKCS10->new(2048); + $req->set_subject("/CN=$cn"); + foreach my $name (@_) { + $req->add_ext(Crypt::OpenSSL::PKCS10::NID_subject_alt_name, $name); + } + $req->add_ext_final(); + $req->sign(); + if (exists($filename_pattern{key})) { + my $filename = make_filename('key', $cn); + debug(2, "writing $filename"); + prep_dir($filename); + my $u = umask(077); + $req->write_pem_pk($filename); + umask($u); + } + return $req->get_pem_req(); +} + +sub save_crt { + my $type = shift; + my $domain = shift; + + my $filename = make_filename($type, $domain); + debug(2, "writing $filename"); + prep_dir($filename); + open(my $fd, '>', $filename); + + foreach my $der (@_) { + my $pem = Crypt::Format::der2pem($der, 'CERTIFICATE'); + print $fd $pem; + print $fd "\n"; + } + close $fd; +} + +sub domain_cert_expires { + my $domain = shift; + my $crt = make_filename('cert', $domain); + if (-f $crt) { + my $x509 = Crypt::OpenSSL::X509->new_from_file($crt); + my $expiry = $x509->notAfter(); + + my $strp = DateTime::Format::Strptime->new( + pattern => '%b %d %H:%M:%S %Y %Z', + time_zone => 'GMT' + ); + my $ts = $strp->parse_datetime($expiry)->epoch; + my $now = time(); + if ($now < $ts) { + my $hours = floor(($ts - $now) / 3600); + my $in; + if ($hours > 24) { + my $days = floor($hours / 24); + $in = "in $days days"; + } elsif ($hours == 24) { + $in = "in one day"; + } else { + $in = "today"; + } + debug(1, "$crt expires on $expiry, $in"); + if ($now + $time_delta < $ts) { + debug(1, "$crt expires on $expiry, $in"); + return 0; + } else { + debug(1, "will renew $crt (expires on $expiry, $in)"); + } + } else { + debug(1, "will renew $crt"); + } + } + return 1; +} + +sub register_domain_certificate { + my $domain = shift; + debug(1, "CN=$domain, alternatives=@_"); + + my $acme = Protocol::ACME->new( + host => $acme_host, + account_key => { + buffer => $account_key->get_private_key_string(), + format => 'PEM' }, + loglevel => debug_to_loglevel() + ); + + eval { + $acme->directory(); + $acme->register(); + $acme->accept_tos(); + + $acme->authz($domain); + $acme->handle_challenge($challenge); + $acme->check_challenge(); + $acme->cleanup_challenge($challenge); + + my $csr = make_csr($domain, @_); + my $cert = $acme->sign({ format => 'PEM', buffer => $csr }); + my $chain = $acme->chain(); + if (exists($filename_pattern{chain})) { + save_crt('cert', $domain, $cert); + save_crt('chain', $domain, $chain); + } else { + save_crt('cert', $domain, $cert, $chain); + } + }; + if ($@) { + if (UNIVERSAL::isa($@, 'Protocol::ACME::Exception')) { + error("$domain: can't update certificate: $@->{status}"); + if (exists($@->{error})) { + error("$domain: $@->{error}{status} $@->{error}{detail}"); + } else { + error("$domain: $@->{detail} $@->{type}"); + } + } else { + abend(EX_SOFTWARE, $@); + } + } +} + +sub dequote { + my ($arg) = @_; + $arg =~ s{^"(.*?)"$}{$1}; + return $arg; +} + +sub uniq { + my %h; + map { + if (exists($h{$_})) { + () + } else { + $h{$_} = 1; + $_ + } + } @_; +} + +sub examine_http_config { + my ($file, $vhosts, $state, $optional) = @_; + debug(2, "reading apache configuration file \"$file\""); + if (open(my $fd, '<', $file)) { + my $server_name; + my @server_aliases; + my $line; + + use constant { + 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 = STATE_INITIAL unless defined $state; + + while (<$fd>) { + ++$line; + chomp; + s/^\s+//; + next if /^(#.*)?$/; + if (/^include(optional)?\s+(.+?)\s*$/i) { + debug(2, "$file:$line: state $state: Include$1 $2"); + http_include(dequote($2), $vhosts, $state, $1 ne ''); + next; + } + + if ($state == STATE_INITIAL) { + if (/^<VirtualHost/i) { + $state = STATE_VIRTUAL_HOST; + $server_name = undef; + @server_aliases = (); + } elsif (/^<(?:(?i)Macro)\s+LetsEncryptChallenge/) { + $state = STATE_MACRO_CHALLENGE; + } elsif (/^<(?:(?i)Macro)\s+LetsEncryptServer\s+(.+?)\s*>/) { + $state = STATE_MACRO_SERVER; + $filename_arg = $1; + $filename_arg =~ s{\$}{\\\$}; + } + } elsif ($state == STATE_VIRTUAL_HOST + || $state == STATE_USE_CHALLENGE) { + if (m{</VirtualHost}i) { + unshift @server_aliases, $server_name + if defined $server_name; + if ($state == STATE_USE_CHALLENGE && @server_aliases) { + my @names = uniq(@server_aliases); + debug(1, "$file:$line: will handle @names"); + push @{$vhosts}, \@names; + } + $state = STATE_INITIAL; + } elsif (/^(?:(?i)Use)\s+LetsEncryptChallenge/) { + $state = STATE_USE_CHALLENGE; + # } elsif (/^Use\s+LetsEncryptSSL\s+(\S+)/i) { + # debug(1, "$file: $1"); + # push @{$vhosts}, [ $1 ]; + } elsif (/^(?:(?i)ServerName)\s+(\S+)/) { + $server_name = dequote($1); + } elsif (/^(?:(?i)ServerAlias)\s+(.+)\s*$/) { + push @server_aliases, + map { /^\*\./ ? () : dequoe($_) } split /\s+/, $1; + } + } elsif ($state == STATE_MACRO_CHALLENGE) { + if (m{^</macro}i) { + $state = STATE_INITIAL; + } elsif (m{^(?:(?i)Alias)\s+/.well-known/acme-challenge\s+(.+)}) { + my $dir = dequote($1); + $dir =~ s{/.well-known/acme-challenge$}{}; + $www_root = $dir; + debug(1, "ACME challenge root dir: $www_root"); + } + } elsif ($state == STATE_MACRO_SERVER) { + if (m{^</macro}i) { + $state = STATE_INITIAL; + } elsif (/(?:(?i)SSLCertificate((?:Key)|(?:Chain))?File)\s+(.+)/) { + $filename_pattern{lc($1 ? $1 : 'cert')} = $2; + } + } + } + close $fd; + } elsif ($optional) { + debug(1, "optional include file \"$file\" doesn't exist"); + } else { + error("can't open file \"$file\": $!"); + return 0; + } + return 1; +} + +sub http_include { + my ($pattern, $vhosts, $state, $optional) = @_; + $pattern =~ s{/*$}{}; + $pattern .= '/*' if -d $pattern; + foreach my $file (glob $pattern) { + examine_http_config($file, $vhosts, $state, $optional); + } +} +# ## +my $apache_layout; + +sub incdir { + if (exists($apache_layout->{incdir})) { + if (ref($apache_layout->{incdir}) eq 'CODE') { + return &{$apache_layout->{incdir}}; + } else { + return $apache_layout->{incdir}; + } + } + return dirname($apache_layout->{config}); +} + +sub get_root_cert { + my $name = shift; + prep_dir($name); + + debug(1, "downloading $letsencrypt_root_cert_url to \"$name\""); + open(my $fd, '>', $name) + or abend(EX_CANTCREAT, "can't open \"$name\" for writing: $!"); + my $curl = WWW::Curl::Easy->new; + $curl->setopt(CURLOPT_HEADER,0); + $curl->setopt(CURLOPT_URL, $letsencrypt_root_cert_url); + $curl->setopt(CURLOPT_WRITEDATA, $fd); + my $rc = $curl->perform; + my $response_code = $curl->getinfo(CURLINFO_HTTP_CODE); + if ($rc) { + error("error downloading certificate from $letsencrypt_root_cert_url"); + error($curl->errbuf); + abend(EX_NOINPUT, $curl->strerror($rc)); + } elsif ($response_code != 200) { + error("error downloading certificate from $letsencrypt_root_cert_url"); + abend(EX_NOINPUT, "unexpected response code: $response_code"); + } + close $fd; +} + +sub initial_setup { + if (defined($www_root)) { + error("acmeman macros seem to be already installed"); + abend(EX_NOPERM, "use --force to continue") unless $force; + } + if ($#ARGV == -1) { + $www_root = '/var/www/acme'; + } elsif ($#ARGV == 0) { + $www_root = $ARGV[0]; + } else { + abend(EX_USAGE, "too many arguments in setup mode"); + } + my $filename = incdir() . "/httpd-letsencrypt.conf"; + if (-e $filename) { + error("the file \"$filename\" already exists"); + abend(EX_NOPERM, "use --force to continue") unless $force; + } + debug(1, "writing $filename"); + unless ($dry_run) { + prep_dir($filename); + open(my $fd, '>', $filename) + or abend(EX_CANTCREAT, "can't open \"$filename\" for writing: $!"); + print $fd <<EOT; +<Macro LetsEncryptChallenge> + Alias /.well-known/acme-challenge $www_root/.well-known/acme-challenge + <Directory $www_root/.well-known/acme-challenge> + Options None + Require all granted + </Directory> +</Macro> + +<Macro LetsEncryptServer \$domain> + ServerName \$domain + SSLEngine on + SSLProtocol all -SSLv2 -SSLv3 + SSLHonorCipherOrder on + SSLCipherSuite EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:EECDH+RC4:RSA+RC4:!MD5 + SSLCertificateFile /etc/ssl/acme/\$domain/cert.pem + SSLCertificateKeyFile /etc/ssl/acme/private/\$domain/privkey.pem + SSLCACertificateFile /etc/ssl/acme/lets-encrypt-x3-cross-signed.pem +</Macro> +EOT +; + close $fd; + + get_root_cert('/etc/ssl/acme/lets-encrypt-x3-cross-signed.pem'); + + if (exists($apache_layout->{post_setup})) { + &{$apache_layout->{post_setup}}($filename); + } + } + error("created file \"$filename\"", prefix => 'NOTE'); + error("please, enable mod_macro and make sure your Apache configuration includes this file", prefix => 'NOTE'); + exit(EX_OK); +} + +my %apache_layout_tab = ( + slackware => { config => '/etc/httpd/httpd.conf', + incdir => '/etc/httpd/extra' + }, + debian => { config => '/etc/apache2/apache2.conf', + incdir => sub { + for my $dir ('/etc/apache2/conf-available', + '/etc/apache2/conf.d') { + return $dir if -d $dir; + } + warn 'none of the expected configuration directories found; falling back to /etc/apache2'; + return '/etc/apache2'; + }, + post_setup => sub { + my ($filename) = @_; + my $dir = dirname($filename); + my $name = basename($filename); + if ($dir eq '/etc/apache2/conf-available') { + chdir('/etc/apache2/conf-enabled'); + symlink "../$name", $name; + } + } + }, + rh => { config => '/etc/httpd/conf/httpd.conf', + incdir => '/etc/httpd/conf.d' + } +); + +my $setup; + +GetOptions("h" => sub { + pod2usage(-message => "$progname: $progdescr", + -exitstatus => EX_OK); + }, + "help" => sub { + pod2usage(-exitstatus => EX_OK, -verbose => 2); + }, + "usage" => sub { + pod2usage(-exitstatus => EX_OK, -verbose => 0); + }, + "debug|d+" => \$debug, + "dry-run|n" => \$dry_run, + "force|F" => \$force, + "time-delta|D=n" => \$time_delta, + "layout|l=s" => sub { + my ($opt, $arg) = @_; + abend(EX_USAGE, "unknown layout: $arg") + unless exists $apache_layout_tab{$arg}; + $apache_layout = $apache_layout_tab{$arg}; + }, + "setup|s" => \$setup, + "config-file|f=s" => sub { + $apache_layout = { config => $_[1] } + } +) or exit(EX_USAGE); + +if ($dry_run) { + ++$debug; + $acme_host = $acme_endpoint{staging}; +} else { + $acme_host = $acme_endpoint{prod}; +} + +unless (defined($apache_layout)) { + while (my ($name, $layout) = each %apache_layout_tab) { + if (-f $layout->{config}) { + debug(1, "assuming Apache layout \"$name\""); + $apache_layout = $layout; + last; + } + } +} + +unless (defined($apache_layout)) { + abend(EX_OSFILE, "no suitable apache configuration file found"); +} + +my @virthost; +examine_http_config($apache_layout->{config}, \@virthost) or exit(EX_OSFILE); +initial_setup if $setup; +debug(1, "nothing to do") unless @virthost; + +# Check challenge root directory +if (defined($www_root)) { + prep_dir("$www_root/file"); +} else { + abend(EX_CONFIG, "ACME challenge root directory not defined"); +} +# Check filename patterns +abend(EX_CONFIG, "filename patterns not defined") + unless (defined($filename_arg) && defined($filename_pattern{cert})); + +$account_key = Crypt::OpenSSL::RSA->generate_key(2048); +$challenge = Protocol::ACME::Challenge::LocalFile->new({www_root => $www_root}); + +foreach my $vhost (@virthost) { + if ($force || domain_cert_expires(${$vhost}[0])) { + register_domain_certificate(@$vhost); + } +} + + + |