diff options
author | Sergey Poznyakoff <gray@gnu.org.ua> | 2019-10-18 15:43:18 +0300 |
---|---|---|
committer | Sergey Poznyakoff <gray@gnu.org.ua> | 2019-10-18 15:43:18 +0300 |
commit | 7e22b3181f963e62a44620336bdcd7d40baacb3a (patch) | |
tree | 48882634e30ab86c3ca9f0a62e12db9e2563c93e | |
parent | d62fd8fbc9c272e79a58c30e964702c52d1f56c9 (diff) | |
download | acmeman-7e22b3181f963e62a44620336bdcd7d40baacb3a.tar.gz acmeman-7e22b3181f963e62a44620336bdcd7d40baacb3a.tar.bz2 |
Switch to ACMEv2
* Makefile.PL: Require Net::ACME2
* lib/App/Acmeman.pm: Rewrite using Net::ACME2.
Avoid re-creating account key/id.
* lib/App/Acmeman/Config.pm: Provide default for verbose.
-rw-r--r-- | Makefile.PL | 3 | ||||
-rw-r--r-- | lib/App/Acmeman.pm | 242 | ||||
-rw-r--r-- | lib/App/Acmeman/Config.pm | 2 |
3 files changed, 160 insertions, 87 deletions
diff --git a/Makefile.PL b/Makefile.PL index f7195d4..3b41713 100644 --- a/Makefile.PL +++ b/Makefile.PL @@ -14,14 +14,13 @@ my %makefile_args = ( PREREQ_PM => { 'Getopt::Long' => 2.34, 'File::Path' => 2.08, 'File::Basename' => 2.84, 'Test::NoWarnings' => 0, 'Crypt::RSA::Parse' => 0.043, - 'Protocol::ACME' => 1.01, - 'Protocol::ACME::Challenge::LocalFile' => 1.01, + 'Net::ACME2' => 0.33, 'Crypt::Format' => 0.06, 'Crypt::OpenSSL::PKCS10' => 0.16, 'Crypt::OpenSSL::RSA' => 0.28, 'Crypt::OpenSSL::X509' => 1.804, 'DateTime::Format::Strptime' => 1.42, 'LWP::UserAgent' => 6.05, diff --git a/lib/App/Acmeman.pm b/lib/App/Acmeman.pm index ad5899a..b95f87f 100644 --- a/lib/App/Acmeman.pm +++ b/lib/App/Acmeman.pm @@ -1,11 +1,10 @@ package App::Acmeman; use strict; use warnings; -use Protocol::ACME; -use Protocol::ACME::Challenge::LocalFile; +use Net::ACME2::LetsEncrypt; 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); @@ -22,26 +21,24 @@ 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 = '2.02'; +our $VERSION = '2.90'; 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', + _acme_host => 'production', _command => 'renew', _option => { config_file => '/etc/acmeman.conf' }, _domains => [] }, $class; @@ -97,13 +94,13 @@ 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 acme_host { shift->{_acme_host} } sub option { my ($self,$opt) = @_; return $self->{_option}{$opt}; } @@ -308,16 +305,12 @@ sub renew { $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) { @@ -402,12 +395,89 @@ 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 save_challenge { + my ($self,$challenge) = @_; + my $file = File::Spec->catfile($self->cf->get(qw(core rootdir)), $challenge->get_path); + if (open(my $fh, '>', $file)) { + print $fh $self->acme->make_key_authorization($challenge); + close $fh; + debug(3, "wrote challenge file $file"); + } else { + error("can't open $file for writing: $!"); + die; + } +} + +sub acme { + my $self = shift; + my $key_id; + my $account_key; + + my $idfile = File::Spec->catfile($self->cf->get('core','rootdir'), + 'account.key_id'); + my $keyfile = File::Spec->catfile($self->cf->get('core','rootdir'), + 'account.pem'); + if (-r $idfile) { + if (open(my $fh, '<', $idfile)) { + chomp($key_id = <$fh>); + close $fh; + debug(3, "using key_id $key_id"); + } else { + error("can't open $idfile for reading: $!"); + } + } + + if (-r $keyfile) { + if (open(my $fh, '<', $keyfile)) { + local $/ = undef; + $account_key = Crypt::OpenSSL::RSA->new_private_key(<$fh>); + close $fh; + } else { + error("can't open $keyfile for reading: $!"); + } + } else { + $account_key = Crypt::OpenSSL::RSA->generate_key($self->cf->get('core', 'key-size')); + } + + unless ($self->{_acme}) { + my $acme = Net::ACME2::LetsEncrypt->new( + environment => $self->acme_host, + key => $account_key->get_private_key_string(), + key_id => $key_id + ); + $self->{_acme} = $acme; + + unless ($acme->key_id()) { + # Create new account + debug(3, "creating account"); + my $terms_url = $acme->get_terms_of_service(); + $acme->create_account(termsOfServiceAgreed => 1); + debug(3, "saving account credentials"); + + if (open(my $fh, '>', $idfile)) { + print $fh $acme->key_id(); + close $fh; + } else { + error("can't open $idfile for writing: $!"); + } + + if (open(my $fh, '>', $keyfile)) { + print $fh $account_key->get_private_key_string(); + close $fh; + } else { + error("can't open $idfile for writing: $!"); + } + } + } + return $self->{_acme}; +} + sub register_domain_certificate { my ($self,$domain) = @_; my $key_size = $self->cf->get('domain', $domain, 'key-size') || $self->cf->get('core', 'key-size'); @@ -419,83 +489,94 @@ sub register_domain_certificate { } 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() + my $acme = $self->acme; + + # Create order + my $order = $acme->create_order( + identifiers => [ + map { { type => 'dns', value => $_ } } $domain->names + ] ); + debug(3, "$domain: created order"); - eval { - $acme->directory(); - $acme->register(); - $acme->accept_tos(); + my $authz = $acme->get_authorization(($order->authorizations())[0]); + + my ($challenge) = grep { $_->type() eq 'http-01' } $authz->challenges(); + if (!$challenge) { + error("$domain: no challenge of acceptable type received"); + return 0; + } - foreach my $name ($domain->names) { - $acme->authz($name); - $acme->handle_challenge($self->challenge); - $acme->check_challenge(); - $acme->cleanup_challenge($self->challenge); - } + debug(3, "$domain: serving challenge"); + $self->save_challenge($challenge); + $acme->accept_challenge($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); + # FIXME + my $ret; + while (($ret = $acme->poll_authorization($authz)) eq 'pending') { + sleep 1 + } + if ($ret ne 'valid') { + error("$domain: can't renew certificate: authorization: $ret"); + return 0; + } + + my $csr = $self->make_csr($domain, $key_size); + my $status = $acme->finalize_order($order, $csr->get_pem_req()); + while ($status eq 'pending') { + sleep 1; + $status = $order->status() + } + + unless ($status eq 'valid') { + error("$domain: can't renew certificate: finalize: $ret"); + return 0; + } + my $chain = $acme->get_certificate_chain($order); + + 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); + + my $cert; + if ($chain =~ /(^-+BEGIN\s+CERTIFICATE-+$ + .+? + ^-+END\s+CERTIFICATE-+$) + (.+)/msx) { + $cert = $1; + ($chain = $2) =~ s/^\s+//s; + } else { + $cert = $chain; # FIXME: not sure if that's right } - }; - 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; + + 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 $chain; + print $fd "\n"; + print $fd $csr->get_pem_pk(); + print $fd "\n"; + umask($u); } + return 1; } sub make_csr { my ($self, $dom, $keysize) = @_; my $req = Crypt::OpenSSL::PKCS10->new($keysize); @@ -506,26 +587,19 @@ sub make_csr { $req->add_ext_final(); $req->sign(); return $req; } sub save_crt { - my $self = shift; - my $domain = shift; - my $type = shift; + my ($self, $domain, $type, $pem) = @_; 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"; - } + print $fd $pem; close $fd; return $filename; } } sub runcmd { diff --git a/lib/App/Acmeman/Config.pm b/lib/App/Acmeman/Config.pm index 6badae3..6707ee0 100644 --- a/lib/App/Acmeman/Config.pm +++ b/lib/App/Acmeman/Config.pm @@ -115,13 +115,13 @@ __DATA__ time-delta = NUMBER :default=86400 source = STRING :default=apache :array check-alt-names = BOOL :default=0 check-dns = BOOL :default=1 my-ip = STRING :array key-size = NUMBER :default=4096 - verbose = NUMBER + verbose = NUMBER :default=0 [files ANY] type = STRING :re="^(single|split)$" certificate-file = STRING key-file = STRING ca-file = STRING argument = STRING |