diff options
author | Sergey Poznyakoff <gray@gnu.org.ua> | 2019-08-21 16:16:42 +0300 |
---|---|---|
committer | Sergey Poznyakoff <gray@gnu.org.ua> | 2019-08-23 17:14:09 +0300 |
commit | 99d1298ce565acb55514deb472cd5d534d9e8e9b (patch) | |
tree | 60f5c8c619563065c3c6dee8dffc7f898b213c79 | |
parent | 3610ab59b2085c5eda3933690a973bad1760d3d4 (diff) | |
download | acmeman-99d1298ce565acb55514deb472cd5d534d9e8e9b.tar.gz acmeman-99d1298ce565acb55514deb472cd5d534d9e8e9b.tar.bz2 |
Move main functionality to a module
* acmeman: Use App::Acmeman.
* lib/App/Acmeman.pm: New module.
* lib/App/Acmeman/Config.pm (mangle): Reset debug_level if
necessary.
Use the BOOL data type.
* lib/App/Acmeman/Log.pm: New module.
* lib/App/Acmeman/Source.pm: Use functions from App::Acmeman::Log.
(add): New method.
(define_alias): Use add.
* lib/App/Acmeman/Source/Apache.pm: Use functions from App::Acmeman::Log.
* lib/App/Acmeman/Source/File.pm: Likewise.
-rw-r--r-- | Makefile.PL | 2 | ||||
-rwxr-xr-x | acmeman | 535 | ||||
-rw-r--r-- | lib/App/Acmeman.pm | 541 | ||||
-rw-r--r-- | lib/App/Acmeman/Config.pm | 35 | ||||
-rw-r--r-- | lib/App/Acmeman/Log.pm | 68 | ||||
-rw-r--r-- | lib/App/Acmeman/Source.pm | 25 | ||||
-rw-r--r-- | lib/App/Acmeman/Source/Apache.pm | 46 | ||||
-rw-r--r-- | lib/App/Acmeman/Source/File.pm | 7 |
8 files changed, 660 insertions, 599 deletions
diff --git a/Makefile.PL b/Makefile.PL index 1fad0ba..fdfffee 100644 --- a/Makefile.PL +++ b/Makefile.PL @@ -32,7 +32,7 @@ my %makefile_args = ( 'Text::ParseWords' => 3.27, 'Data::Dumper' => 0, 'Socket' => 0, - 'Sys::Hostname' => 1.16 + 'Sys::Hostname' => 1.16 }, MIN_PERL_VERSION => 5.006, @@ -2,49 +2,16 @@ #! -*-perl-*- eval 'exec perl -x -wS $0 ${1+"$@"}' if 0; -# Copyright (C) 2017-2019 Sergey Poznyakoff <gray@gnu.org> -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 3, or (at your option) -# any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. + use strict; -use feature 'state'; -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 LWP::UserAgent; -use LWP::Protocol::https; -use Socket qw(inet_ntoa); -use Sys::Hostname; -use Pod::Usage; -use Pod::Man; -use Getopt::Long qw(:config gnu_getopt no_ignore_case auto_version); -use POSIX qw(strftime time floor); -use App::Acmeman::Config; -use App::Acmeman::Domain qw(:files); -use Data::Dumper; -use Text::ParseWords; - -our $VERSION = '1.11'; +use warnings; +use App::Acmeman; + +App::Acmeman->new->run; =head1 NAME -App::Acmeman - manages ACME certificates +acmeman - manages ACME certificates =head1 SYNOPSIS @@ -611,495 +578,7 @@ Sergey Poznyakoff <gray@gnu.org> =cut -my $progname = basename($0); -my $progdescr = "manages ACME certificates"; -my $debug = 0; -my $dry_run; -my $acme_host = 'prod'; -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 $force = 0; -my %select; # Hash of selected domain names - -my $config_file = '/etc/acmeman.conf'; -my $config; - -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; -} - -sub prep_dir { - my $dir = dirname(shift); - if (! -d $dir) { - debug(3, "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("mkdir $file: $message"); - } - } - exit(EX_CANTCREAT); - } - } -} - -sub runcmd { - my $cmd = shift; - debug(1, "running $cmd"); - unless ($dry_run) { - system($cmd); - if ($? == -1) { - error("$cmd: failed to execute: $!"); - } elsif ($? & 127) { - error("$cmd: died on signal ".($? & 127)); - } elsif (my $code = ($? >> 8)) { - error("$cmd: exited with code $code"); - } - } -} - -sub debug_to_loglevel { - my @lev = ('err', 'info', 'debug'); - return $lev[$debug > $#lev ? $#lev : $debug]; -} - -sub make_csr { - my ($dom, $keysize) = @_; - my $req = Crypt::OpenSSL::PKCS10->new($keysize); - $req->set_subject("/CN=".$dom->cn); - $req->add_ext(Crypt::OpenSSL::PKCS10::NID_subject_alt_name, - join(',', map { "DNS:$_" } $dom->alt)) - if $dom->alt > 0; - $req->add_ext_final(); - $req->sign(); - return $req; -} - -sub save_crt { - my $domain = shift; - my $type = shift; - - if (my $filename = $domain->file($type)) { - debug(3, "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; - return $filename; - } -} - -sub selected_domain { - my $dom = shift; - return 1 unless %select; - return grep { $select{$_} } $dom->names; -} - -sub domain_cert_expires { - my $domain = shift; - my $crt = $domain->certificate_file; - if (-f $crt) { - my $x509 = Crypt::OpenSSL::X509->new_from_file($crt); - - my $exts = $x509->extensions_by_name(); - if (exists($exts->{subjectAltName})) { - my $msg = $config->get(qw(core check-alt-names)) - ? 'will renew' : 'use -a to trigger renewal'; - my @names = map { s/^DNS://; $_ } - split /,\s*/, $exts->{subjectAltName}->to_string(); - my @missing; - foreach my $vh (sort { length($b) <=> length($a) } $domain->names) { - unless (grep { $_ eq $vh } @names) { - push @missing, $vh; - } - } - if (@missing) { - debug(1, "$crt: the following SANs are missing: " - . join(', ', @missing) - ."; $msg"); - return 1 if $config->get(qw(core check-alt-names)); - } - } - - 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(2, "$crt expires on $expiry, $in"); - if ($now + $config->get(qw(core time-delta)) < $ts) { - return 0; - } else { - debug(2, "will renew $crt (expires on $expiry, $in)"); - } - } else { - debug(2, "will renew $crt"); - } - } - return 1; -} - -sub register_domain_certificate { - my $domain = shift; - - my $key_size = $config->get('domain', $domain, 'key-size') - || $config->get('core', 'key-size'); - - if ($debug) { - my $crt = $domain->certificate_file; - my $alt = join(',', $domain->alt); - if (-f $crt) { - debug(1, "renewing $crt: CN=$domain, alternatives=$alt, key_size=$key_size"); - } else { - debug(1, "issuing $crt: CN=$domain, alternatives=$alt, key_size=$key_size"); - } - } - - return 1 if $dry_run; - $account_key = Crypt::OpenSSL::RSA->generate_key($key_size); - - my $acme = Protocol::ACME->new( - host => $acme_endpoint{$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(); - - foreach my $name ($domain->names) { - $acme->authz($name); - $acme->handle_challenge($challenge); - $acme->check_challenge(); - $acme->cleanup_challenge($challenge); - } - - my $csr = make_csr($domain, $key_size); - my $cert = $acme->sign({ format => 'PEM', buffer => $csr->get_pem_req() }); - my $chain = $acme->chain(); - - if (my $filename = $domain->file(KEY_FILE)) { - debug(3, "writing $filename"); - prep_dir($filename); - my $u = umask(077); - $csr->write_pem_pk($filename); - umask($u); - - if ($filename = $domain->file(CA_FILE)) { - save_crt($domain, CA_FILE, $chain); - } - save_crt($domain, CERT_FILE, $cert); - } else { - $filename = $domain->certificate_file; - debug(3, "writing $filename"); - prep_dir($filename); - my $u = umask(077); - open(my $fd, '>', $filename) - or abend(EX_CANTCREAT, "can't open $filename for writing: $!"); - print $fd Crypt::Format::der2pem($cert, 'CERTIFICATE'); - print $fd "\n"; - print $fd Crypt::Format::der2pem($chain, 'CERTIFICATE'); - print $fd "\n"; - print $fd $csr->get_pem_pk(); - print $fd "\n"; - umask($u); - } - }; - if ($@) { - if (UNIVERSAL::isa($@, 'Protocol::ACME::Exception')) { - error("$domain: can't renew certificate: $@->{status}"); - if (exists($@->{error})) { - error("$domain: $@->{error}{status} $@->{error}{detail}"); - } else { - error("$domain: $@->{detail} $@->{type}"); - } - } elsif (ref($@) == '') { - chomp $@; - error("$domain: failed to renew certificate: $@"); - } else { - error("$domain: failed to renew certificate"); - print STDERR Dumper([$@]); - } - return 0; - } - return 1; -} - -sub get_root_cert { - my $name = shift; - - prep_dir($name) unless $dry_run; - - debug(1, "downloading $letsencrypt_root_cert_url to \"$name\""); - my $ua = LWP::UserAgent->new; - my $response = $ua->get($letsencrypt_root_cert_url); - if ($response->is_success) { - unless ($dry_run) { - open(my $fd, '>', $name) - or abend(EX_CANTCREAT, "can't open \"$name\" for writing: $!"); - print $fd $response->decoded_content; - close $fd; - } - } else { - error("error downloading certificate from $letsencrypt_root_cert_url"); - abend(EX_NOINPUT, $response->status_line); - } -} - -sub initial_setup { - get_root_cert('/etc/ssl/acme/lets-encrypt-x3-cross-signed.pem'); - - foreach my $src ($config->get(qw(core source))) { - unless ($src->setup(dry_run => $dry_run, force => $force)) { - exit(1); - } - } - - exit(EX_OK); -} - -sub coalesce { - my $ref = shift; - debug(2, "coalescing virtual hosts"); - my $i = 0; - my @domlist; - foreach my $ent (sort { $a->{domain} cmp $b->{domain} } - map { { ord => $i++, domain => $_ } } @{$ref}) { - if (@domlist && $domlist[-1]->{domain}->cn eq $ent->{domain}->cn) { - $domlist[-1]->{domain} += $ent->{domain}; - } else { - push @domlist, $ent; - } - } - @{$ref} = map { $_->{domain} } sort { $a->{ord} <=> $b->{ord} } @domlist; -} - -sub resolve { - my $host = shift; - if (my @addrs = gethostbyname($host)) { - return map { inet_ntoa($_) } @addrs[4 .. $#addrs]; - } else { - error("$host doesn't resolve"); - } - return (); -} - -sub myip { - my $host = shift; - state $ips; - unless ($ips) { - $ips = {}; - my $addhost; - - if ($config->is_set(qw(core my-ip))) { - $addhost = 0; - foreach my $ip ($config->get(qw(core my-ip))) { - if ($ip eq '$hostip') { - $addhost = 1; - } else { - $ips->{$ip} = 1; - } - } - } else { - $addhost = 1; - } - - if ($addhost) { - foreach my $ip (resolve(hostname())) { - $ips->{$ip} = 1; - } - } - } - return $ips->{$host}; -} - -sub host_ns_ok { - my $host = shift; - foreach my $ip (resolve($host)) { - return 1 if myip($ip); - } - return 0 -} - -sub collect { - my $aref = shift; - return unless $config->is_set('domain'); - my $err; - while (my ($k, $v) = each %{$config->get('domain')}) { - my $dom; - my $ft; - - if ($config->get(qw(core check-dns))) { - my @res = grep { host_ns_ok($_) } - ($k, ($v->{alt} ? @{$v->{alt}} : ())); - if (@res) { - $k = shift @res; - $v->{alt} = @res ? \@res : undef; - } else { - error("ignoring $k: none of its names resolves to our IP"); - next; - } - } - - if (exists($v->{files})) { - if (my $fref = $config->get('files', $v->{files})) { - $dom = new App::Acmeman::Domain( - cn => $k, - alt => $v->{alt}, - postrenew => $v->{postrenew}, - %{$fref}); - } else { - error("files.$v->{files} is referenced from [domain $k], but never declared"); - ++$err; - next; - } - } else { - $dom = new App::Acmeman::Domain( - cn => $k, - alt => $v->{alt}, - postrenew => $v->{postrenew}, - %{$config->get('files', $config->get(qw(core files)))}); - } - push @$aref, $dom; - } - exit(1) if $err; -} - -my $setup; -my $time_delta; -my $check_alt_names; - -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, - "stage|s" => sub { $acme_host = 'staging' }, - "force|F" => \$force, - "time-delta|D=n" => \$time_delta, - "setup|S" => \$setup, - "alt-names|a" => \$check_alt_names, - "config-file|f=s" => \$config_file -) or exit(EX_USAGE); - -++$debug if $dry_run; - -my @domlist; - -@select{map { lc } @ARGV} = (1) x @ARGV; - -$config = new App::Acmeman::Config($config_file); - -if ($time_delta) { - $config->set(qw(core time-delta), $time_delta); -} -if ($check_alt_names) { - $config->set(qw(core check-alt-names), $check_alt_names); -} - -initial_setup if $setup; - -#print Dumper([$config]);exit; -collect \@domlist; - -debug(1, "nothing to do") unless @domlist; -coalesce \@domlist; - -# Check challenge root directory -prep_dir($config->get(qw(core rootdir)).'/file'); - -$challenge = Protocol::ACME::Challenge::LocalFile->new({ - www_root => $config->get(qw(core rootdir)) -}); - -my $renewed = 0; -foreach my $vhost (@domlist) { - next unless selected_domain($vhost); - if ($force || domain_cert_expires($vhost)) { - if (register_domain_certificate($vhost)) { - if (my $cmd = $vhost->postrenew) { - runcmd($cmd); - } else { - $renewed++; - } - } - } -} - -if ($renewed) { - if ($config->is_set(qw(core postrenew))) { - foreach my $cmd ($config->get(qw(core postrenew))) { - runcmd($cmd); - } - } else { - error("certificates changed, but no postrenew command is defined (core.postrenew)"); - } -} + diff --git a/lib/App/Acmeman.pm b/lib/App/Acmeman.pm new file mode 100644 index 0000000..c867c1b --- /dev/null +++ b/lib/App/Acmeman.pm @@ -0,0 +1,541 @@ +package App::Acmeman; +use strict; +use warnings; +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 LWP::UserAgent; +use LWP::Protocol::https; +use Socket qw(inet_ntoa); +use Sys::Hostname; +use Pod::Usage; +use Pod::Man; +use Getopt::Long qw(:config gnu_getopt no_ignore_case auto_version); +use POSIX qw(strftime time floor); +use App::Acmeman::Config; +use App::Acmeman::Domain qw(:files); +use Data::Dumper; +use Text::ParseWords; +use App::Acmeman::Log qw(:all :sysexits); +use feature 'state'; + +our $VERSION = '1.11'; + +my $progdescr = "manages ACME certificates"; + +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'; + +sub new { + my $class = shift; + my $self = bless { + _progname => basename($0), + _acme_host => 'prod', + _command => 'renew', + _option => { + config_file => '/etc/acmeman.conf' + }, + _domains => [] + }, $class; + GetOptions( + "h" => sub { + pod2usage(-message => "$self->{_progname}: $progdescr", + -exitstatus => EX_OK); + }, + "help" => sub { + pod2usage(-exitstatus => EX_OK, -verbose => 2); + }, + "usage" => sub { + pod2usage(-exitstatus => EX_OK, -verbose => 0); + }, + "debug|d+" => \$self->{_option}{debug}, + "dry-run|n" => \$self->{_option}{dry_run}, + "stage|s" => sub { $self->{_acme_host} = 'staging' }, + "force|F" => \$self->{_option}{force}, + "time-delta|D=n" => \$self->{_option}{time_delta}, + "setup|S" => sub { $self->{_command} = 'setup' }, + "alt-names|a" => \$self->{_option}{check_alt_names}, + "config-file|f=s" => \$self->{_option}{config_file}, + ) or exit(EX_USAGE); + ++$self->{_option}{debug} if $self->dry_run_option; + debug_level($self->{_option}{debug}); + + $self->add_selected_domains(@ARGV); + + $self->{_cf} = new App::Acmeman::Config($self->option('config_file')); + my $v; + if ($v = $self->option('time_delta')) { + $self->cf->set(qw(core time-delta), $v); + } + if ($v = $self->option('check_alt_names')) { + $self->cf->set(qw(core check-alt-names), $v); + } + if ($v = $self->option('debug')) { + $self->cf->set(qw(core verbose), $v); + } else { + $self->option('debug', $self->cf->get(qw(core verbose))); + } + + debug_level($self->cf->core->verbose); + + return $self; +} + +sub run { + my $self = shift; + $self->${ \$self->{_command} }(); +} + +sub cf { shift->{_cf} } +sub progname { shift->{_progname} } +sub acme_host { $acme_endpoint{shift->{_acme_host}} } + +sub option { + my ($self,$opt) = @_; + return $self->{_option}{$opt}; +} + +sub force_option { shift->option('force') } +sub dry_run_option { shift->option('dry_run') } + +sub resolve { + my ($self, $host) = @_; + if (my @addrs = gethostbyname($host)) { + return map { inet_ntoa($_) } @addrs[4 .. $#addrs]; + } else { + error("$host doesn't resolve"); + } + return (); +} + +sub myip { + my ($self, $host) = @_; + state $ips; + unless ($ips) { + $ips = {}; + my $addhost; + + if ($self->cf->is_set(qw(core my-ip))) { + $addhost = 0; + foreach my $ip ($self->cf->get(qw(core my-ip))) { + if ($ip eq '$hostip') { + $addhost = 1; + } else { + $ips->{$ip} = 1; + } + } + } else { + $addhost = 1; + } + + if ($addhost) { + foreach my $ip ($self->resolve(hostname())) { + $ips->{$ip} = 1; + } + } + } + return $ips->{$host}; +} + +sub host_ns_ok { + my ($self, $host) = @_; + foreach my $ip ($self->resolve($host)) { + return 1 if $self->myip($ip); + } + return 0 +} + +sub prep_dir { + my ($self, $name) = @_; + my $dir = dirname($name); + if (! -d $dir) { + debug(3, "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("mkdir $file: $message"); + } + } + exit(EX_CANTCREAT); + } + } +} + +sub get_root_cert { + my $self = shift; + my $name = shift; + + $self->prep_dir($name) unless $self->dry_run_option; + + debug(1, "downloading $letsencrypt_root_cert_url to \"$name\""); + my $ua = LWP::UserAgent->new; + my $response = $ua->get($letsencrypt_root_cert_url); + if ($response->is_success) { + unless ($self->dry_run_option) { + open(my $fd, '>', $name) + or abend(EX_CANTCREAT, + "can't open \"$name\" for writing: $!"); + print $fd $response->decoded_content; + close $fd; + } + } else { + error("error downloading certificate from $letsencrypt_root_cert_url"); + abend(EX_NOINPUT, $response->status_line); + } +} + +sub setup { + my $self = shift; + + $self->prep_dir($self->cf->get(qw(core rootdir)).'/file'); + + $self->get_root_cert('/etc/ssl/acme/lets-encrypt-x3-cross-signed.pem'); + + foreach my $src ($self->cf->get(qw(core source))) { + unless ($src->setup(dry_run => $self->dry_run_option, + force => $self->force_option)) { + exit(1); + } + } + + exit(EX_OK); +} + +sub collect { + my $self = shift; + my $err; + my $node = $self->cf->getnode('domain') or return; + my $subs = $node->as_hash; + while (my ($k, $v) = each %$subs) { + my $dom; + my $ft; + + my $alt = [grep { !$self->cf->get(qw(core check-dns)) + || $self->host_ns_ok($_) } + ($k, ($v->{alt} ? @{$v->{alt}} : ()))]; + if (@$alt) { + $k = shift @$alt; + $alt = undef unless @$alt; + } else { + error("ignoring $k: none of its names resolves to our IP"); + next; + } + + if (exists($v->{files})) { + if (my $fref = $self->cf->getnode('files', $v->{files})) { + $dom = new App::Acmeman::Domain( + cn => $k, + alt => $alt, + postrenew => $v->{postrenew}, + %{$fref->as_hash}); + } else { + error("files.$v->{files} is referenced from [domain $k], but never declared"); + ++$err; + next; + } + } else { + $dom = new App::Acmeman::Domain( + cn => $k, + alt => $alt, + postrenew => $v->{postrenew}, + %{$self->cf->getnode('files', $self->cf->get(qw(core files)))->as_hash}); + } + $self->domains($dom); + } + exit(1) if $err; +} + +sub domains { + my $self = shift; + if (@_) { + push @{$self->{_domains}}, @_; + } + return @{$self->{_domains}}; +} + +sub coalesce { + my $self = shift; + debug(2, "coalescing virtual hosts"); + my $i = 0; + my @domlist; + foreach my $ent (sort { $a->{domain} cmp $b->{domain} } + map { { ord => $i++, domain => $_ } } $self->domains) { + if (@domlist && $domlist[-1]->{domain}->cn eq $ent->{domain}->cn) { + $domlist[-1]->{domain} += $ent->{domain}; + } else { + push @domlist, $ent; + } + } + @{$self->{_domains}} = + map { $_->{domain} } sort { $a->{ord} <=> $b->{ord} } @domlist; +} + +sub add_selected_domains { + my $self = shift; + if (@_) { + @{$self->{_selection}}{map { lc } @_} = (1) x @_; + } +} + +sub selected_domains { + my $self = shift; + return $self->domains unless $self->{_selection}; + return grep { $self->{_selection}{$_} } $self->domains; +} + +sub challenge { shift->{_challenge} } + +sub renew { + my $self = shift; + + $self->collect; + unless ($self->selected_domains) { + debug(1, "nothing to do"); + exit(0); + } + $self->coalesce; + + $self->{_challenge} = Protocol::ACME::Challenge::LocalFile->new({ + www_root => $self->cf->get(qw(core rootdir)) + }); + + my $renewed = 0; + foreach my $vhost ($self->selected_domains) { + if ($self->force_option || $self->domain_cert_expires($vhost)) { + if ($self->register_domain_certificate($vhost)) { + if (my $cmd = $vhost->postrenew) { + $self->runcmd($cmd); + } else { + $renewed++; + } + } + } + } + + if ($renewed) { + if ($self->cf->is_set(qw(core postrenew))) { + foreach my $cmd ($self->cf->get(qw(core postrenew))) { + $self->runcmd($cmd); + } + } else { + error("certificates changed, but no postrenew command is defined (core.postrenew)"); + } + } +} + +sub domain_cert_expires { + my ($self, $domain) = @_; + my $crt = $domain->certificate_file; + if (-f $crt) { + my $x509 = Crypt::OpenSSL::X509->new_from_file($crt); + + my $exts = $x509->extensions_by_name(); + if (exists($exts->{subjectAltName})) { + my $msg = $self->cf->get(qw(core check-alt-names)) + ? 'will renew' : 'use -a to trigger renewal'; + my @names = map { s/^DNS://; $_ } + split /,\s*/, $exts->{subjectAltName}->to_string(); + my @missing; + foreach my $vh (sort { length($b) <=> length($a) } $domain->names) { + unless (grep { $_ eq $vh } @names) { + push @missing, $vh; + } + } + if (@missing) { + debug(1, "$crt: the following SANs are missing: " + . join(', ', @missing) + . "; $msg"); + return 1 if $self->cf->get(qw(core check-alt-names)); + } + } + + 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(2, "$crt expires on $expiry, $in"); + if ($now + $self->cf->get(qw(core time-delta)) < $ts) { + return 0; + } else { + debug(2, "will renew $crt (expires on $expiry, $in)"); + } + } else { + debug(2, "will renew $crt"); + } + } + return 1; +} + +sub debug_to_loglevel { + my $self = shift; + my @lev = ('err', 'info', 'debug'); + my $v = $self->cf->core->verbose; + return $lev[$v > $#lev ? $#lev : $v]; +} + +sub register_domain_certificate { + my ($self,$domain) = @_; + + my $key_size = $self->cf->get('domain', $domain, 'key-size') + || $self->cf->get('core', 'key-size'); + + if ($self->cf->core->verbose > 0) { + my $crt = $domain->certificate_file; + my $alt = join(',', $domain->alt); + if (-f $crt) { + debug(1, "renewing $crt: CN=$domain, alternatives=$alt, key_size=$key_size"); + } else { + debug(1, "issuing $crt: CN=$domain, alternatives=$alt, key_size=$key_size"); + } + } + + return 1 if $self->dry_run_option; + my $account_key = Crypt::OpenSSL::RSA->generate_key($key_size); + + my $acme = Protocol::ACME->new( + host => $self->acme_host, + account_key => { buffer => $account_key->get_private_key_string(), + format => 'PEM' }, + loglevel => $self->debug_to_loglevel() + ); + + eval { + $acme->directory(); + $acme->register(); + $acme->accept_tos(); + + foreach my $name ($domain->names) { + $acme->authz($name); + $acme->handle_challenge($self->challenge); + $acme->check_challenge(); + $acme->cleanup_challenge($self->challenge); + } + + my $csr = $self->make_csr($domain, $key_size); + my $cert = $acme->sign({ format => 'PEM', + buffer => $csr->get_pem_req() }); + my $chain = $acme->chain(); + + if (my $filename = $domain->file(KEY_FILE)) { + debug(3, "writing $filename"); + $self->prep_dir($filename); + my $u = umask(077); + $csr->write_pem_pk($filename); + umask($u); + + if ($filename = $domain->file(CA_FILE)) { + $self->save_crt($domain, CA_FILE, $chain); + } + $self->save_crt($domain, CERT_FILE, $cert); + } else { + $filename = $domain->certificate_file; + debug(3, "writing $filename"); + $self->prep_dir($filename); + my $u = umask(077); + open(my $fd, '>', $filename) + or abend(EX_CANTCREAT, + "can't open $filename for writing: $!"); + print $fd Crypt::Format::der2pem($cert, 'CERTIFICATE'); + print $fd "\n"; + print $fd Crypt::Format::der2pem($chain, 'CERTIFICATE'); + print $fd "\n"; + print $fd $csr->get_pem_pk(); + print $fd "\n"; + umask($u); + } + }; + if ($@) { + if (UNIVERSAL::isa($@, 'Protocol::ACME::Exception')) { + error("$domain: can't renew certificate: $@->{status}"); + if (exists($@->{error})) { + error("$domain: $@->{error}{status} $@->{error}{detail}"); + } else { + error("$domain: $@->{detail} $@->{type}"); + } + } elsif (ref($@) eq '') { + chomp $@; + error("$domain: failed to renew certificate: $@"); + } else { + error("$domain: failed to renew certificate"); + print STDERR Dumper([$@]); + } + return 0; + } + return 1; +} + +sub make_csr { + my ($self, $dom, $keysize) = @_; + my $req = Crypt::OpenSSL::PKCS10->new($keysize); + $req->set_subject("/CN=".$dom->cn); + $req->add_ext(Crypt::OpenSSL::PKCS10::NID_subject_alt_name, + join(',', map { "DNS:$_" } $dom->alt)) + if $dom->alt > 0; + $req->add_ext_final(); + $req->sign(); + return $req; +} + +sub save_crt { + my $self = shift; + my $domain = shift; + my $type = shift; + + if (my $filename = $domain->file($type)) { + debug(3, "writing $filename"); + $self->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; + return $filename; + } +} + +sub runcmd { + my ($self,$cmd) = @_; + debug(3, "running $cmd"); + unless ($self->dry_run_option) { + system($cmd); + if ($? == -1) { + error("$cmd: failed to execute: $!"); + } elsif ($? & 127) { + error("$cmd: died on signal ".($? & 127)); + } elsif (my $code = ($? >> 8)) { + error("$cmd: exited with code $code"); + } + } +} + +1; diff --git a/lib/App/Acmeman/Config.pm b/lib/App/Acmeman/Config.pm index 6860428..25c940f 100644 --- a/lib/App/Acmeman/Config.pm +++ b/lib/App/Acmeman/Config.pm @@ -5,30 +5,7 @@ use warnings; use Carp; use parent 'Config::Parser::Ini'; use Text::ParseWords; - |