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 /acmeman | |
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.
Diffstat (limited to 'acmeman')
-rwxr-xr-x | acmeman | 535 |
1 files changed, 7 insertions, 528 deletions
@@ -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)"); - } -} + |