summaryrefslogtreecommitdiffabout
authorSergey Poznyakoff <gray@gnu.org.ua>2019-08-21 13:16:42 (GMT)
committer Sergey Poznyakoff <gray@gnu.org.ua>2019-08-23 14:14:09 (GMT)
commit99d1298ce565acb55514deb472cd5d534d9e8e9b (patch) (side-by-side diff)
tree60f5c8c619563065c3c6dee8dffc7f898b213c79
parent3610ab59b2085c5eda3933690a973bad1760d3d4 (diff)
downloadacmeman-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 (more/less context) (ignore whitespace changes)
-rw-r--r--Makefile.PL2
-rwxr-xr-xacmeman535
-rw-r--r--lib/App/Acmeman.pm541
-rw-r--r--lib/App/Acmeman/Config.pm35
-rw-r--r--lib/App/Acmeman/Log.pm68
-rw-r--r--lib/App/Acmeman/Source.pm25
-rw-r--r--lib/App/Acmeman/Source/Apache.pm46
-rw-r--r--lib/App/Acmeman/Source/File.pm7
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,
diff --git a/acmeman b/acmeman
index ddd4703..7956dd2 100755
--- a/acmeman
+++ b/acmeman
@@ -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
--- a/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;
-
-use constant EX_CONFIG => 78;
-
-sub check_bool {
- my ($self, $vref, $prev_value, $locus) = @_;
- my %bt = (
- 0 => 0,
- off => 0,
- false => 0,
- no => 0,
- 1 => 1,
- on => 1,
- true => 1,
- yes => 1
- );
- my $res = $bt{lc($$vref)};
- unless (defined($res)) {
- $self->error("not a boolean: $$vref");
- return 0;
- }
-
- $$vref = $res;
- return 1;
-}
+use App::Acmeman::Log qw(debug_level :sysexits);
sub new {
my $class = shift;
@@ -48,6 +25,9 @@ sub mangle {
my $self = shift;
my $err;
+ if (debug_level() == 0 && $self->core->verbose) {
+ debug_level($self->core->verbose);
+ }
$self->set(qw(core files default))
unless $self->is_set(qw(core files));
@@ -123,8 +103,6 @@ sub mangle {
exit(EX_CONFIG) if $err;
}
-
-
1;
__DATA__
[core]
@@ -133,10 +111,11 @@ __DATA__
files = STRING
time-delta = NUMBER :default=86400
source = STRING :default=apache :array
- check-alt-names = STRING :default=0 :check=check_bool
- check-dns = STRING :default=1 :check=check_bool
+ check-alt-names = BOOL :default=0
+ check-dns = BOOL :default=1
my-ip = STRING :array
key-size = NUMBER :default=4096
+ verbose = NUMBER
[files ANY]
type = STRING :re="^(single|split)$"
certificate-file = STRING
diff --git a/lib/App/Acmeman/Log.pm b/lib/App/Acmeman/Log.pm
new file mode 100644
index 0000000..72242c0
--- a/dev/null
+++ b/lib/App/Acmeman/Log.pm
@@ -0,0 +1,68 @@
+package App::Acmeman::Log;
+use File::Basename;
+use parent 'Exporter';
+
+my @exv = qw(
+ EX_OK
+ EX_USAGE
+ EX_DATAERR
+ EX_NOINPUT
+ EX_SOFTWARE
+ EX_OSFILE
+ EX_CANTCREAT
+ EX_NOPERM
+ EX_CONFIG
+);
+
+my @fnv = qw(error debug abend debug_level);
+
+our @EXPORT_OK = (@fnv, @exv);
+
+our %EXPORT_TAGS = (
+ 'all' => [@fnv],
+ 'sysexits' => [@exv]);
+
+our $progname = basename($0);
+our $debug_level = 0;
+
+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 debug_level {
+ my $lev = shift;
+ if ($lev) {
+ $debug_level = $lev;
+ }
+ $debug_level;
+}
+
+sub error {
+ my $msg = shift;
+ local %_ = @_;
+ print STDERR "$progname: ";
+ print STDERR "$_{prefix}: " if defined($_{prefix});
+ print STDERR "$msg\n"
+}
+
+sub debug {
+ my $l = shift;
+ error(join(' ',@_), prefix => 'DEBUG') if $debug_level >= $l;
+}
+
+sub abend {
+ my $code = shift;
+ error(@_);
+ exit $code;
+}
+
+1;
+
diff --git a/lib/App/Acmeman/Source.pm b/lib/App/Acmeman/Source.pm
index 391daca..de1a666 100644
--- a/lib/App/Acmeman/Source.pm
+++ b/lib/App/Acmeman/Source.pm
@@ -4,22 +4,6 @@ use strict;
use warnings;
use Carp;
-sub debug {
- my $self = shift;
- if (defined(&::debug)) {
- ::debug(@_);
- }
-}
-
-sub error {
- my $self = shift;
- if (exists($self->{_cfg})) {
- $self->{_cfg}->error(@_);
- } else {
- carp @_;
- }
-}
-
sub set {
my $self = shift;
croak "improper use of the set method"
@@ -27,6 +11,13 @@ sub set {
return $self->{_cfg}->set(@_);
}
+sub add {
+ my $self = shift;
+ croak "improper use of the add method"
+ unless exists $self->{_cfg};
+ return $self->{_cfg}->add_value(@_);
+}
+
sub get {
my $self = shift;
croak "improper use of the get method"
@@ -44,7 +35,7 @@ sub define_alias {
my $self = shift;
my $cn = shift || croak "domain name must be given";
foreach my $alias (@_) {
- $self->set('domain', $cn, 'alt', $alias);
+ $self->add(['domain', $cn, 'alt'], $alias);
}
}
diff --git a/lib/App/Acmeman/Source/Apache.pm b/lib/App/Acmeman/Source/Apache.pm
index b429f89..f35c267 100644
--- a/lib/App/Acmeman/Source/Apache.pm
+++ b/lib/App/Acmeman/Source/Apache.pm
@@ -8,6 +8,7 @@ use File::Path qw(make_path);
use File::Spec;
use IPC::Open3;
use App::Acmeman::Apache::Layout;
+use App::Acmeman::Log qw(:all);
use parent 'App::Acmeman::Source';
use Getopt::Long qw(GetOptionsFromArray :config gnu_getopt no_ignore_case);
@@ -25,7 +26,7 @@ sub layout { shift->{_layout} }
sub scan {
my ($self) = @_;
- $self->debug(2, 'assuming Apache layout "'.$self->layout->name.'"');
+ debug(2, 'assuming Apache layout "'.$self->layout->name.'"');
$self->set(qw(core postrenew), $self->layout->restart_command);
return $self->examine_http_config($self->layout->config_file);
}
@@ -50,7 +51,7 @@ sub examine_http_config {
state $state = STATE_INITIAL;
- $self->debug(3, "reading apache configuration file \"$file\"");
+ debug(3, "reading apache configuration file \"$file\"");
if (open(my $fd, '<', $file)) {
my $server_name;
my @server_aliases;
@@ -63,7 +64,7 @@ sub examine_http_config {
s/^\s+//;
next if /^(#.*)?$/;
if (/^include(optional)?\s+(.+?)\s*$/i) {
- #$self->debug(3, "$file:$line: state $state: Include".($1||'')." $2");
+ #debug(3, "$file:$line: state $state: Include".($1||'')." $2");
$self->http_include($self->dequote($2), defined($1));
next;
}
@@ -93,7 +94,7 @@ sub examine_http_config {
my $cn = shift @server_aliases;
$self->define_domain($cn);
$self->define_alias($cn, @server_aliases);
- $self->debug(3, "$file:$line: will handle ".
+ debug(3, "$file:$line: will handle ".
join(',', $cn, @server_aliases));
} elsif ($state == STATE_USE_REFERENCE) {
$self->set('domain', $reference,
@@ -106,18 +107,18 @@ sub examine_http_config {
if ($state == STATE_VIRTUAL_HOST) {
$state = STATE_USE_CHALLENGE;
} elsif ($state == STATE_USE_CHALLENGE) {
- $self->error("$file:$line: duplicate use of LetsEncryptChallenge");
+ error("$file:$line: duplicate use of LetsEncryptChallenge");
} else {
- $self->error("$file:$line: LetsEncryptChallenge and LetsEncryptReference can't be used together");
+ error("$file:$line: LetsEncryptChallenge and LetsEncryptReference can't be used together");
}
} elsif (/^(?:(?i)Use)\s+LetsEncryptReference\s+(.+?)\s*$/) {
if ($state == STATE_VIRTUAL_HOST) {
$state = STATE_USE_REFERENCE;
$reference = $1;
} elsif ($state == STATE_USE_REFERENCE) {
- $self->error("$file:$line: duplicate use of LetsEncryptReference");
+ error("$file:$line: duplicate use of LetsEncryptReference");
} else {
- $self->error("$file:$line: LetsEncryptChallenge and LetsEncryptReference can't be used together");
+ error("$file:$line: LetsEncryptChallenge and LetsEncryptReference can't be used together");
}
} elsif (/^(?:(?i)ServerName)\s+(\S+)/) {
$server_name = $self->dequote($1);
@@ -133,7 +134,7 @@ sub examine_http_config {
my $dir = $self->dequote($1);
$dir =~ s{/.well-known/acme-challenge$}{};
$self->set(qw(core rootdir), $dir);
- $self->debug(3, "ACME challenge root dir: $dir");
+ debug(3, "ACME challenge root dir: $dir");
}
} elsif ($state == STATE_MACRO_SSL) {
if (m{^</macro}i) {
@@ -150,7 +151,7 @@ sub examine_http_config {
}
close $fd;
} else {
- $self->error("can't open file \"$file\": $!");
+ error("can't open file \"$file\": $!");
return 0;
}
return 1;
@@ -177,7 +178,7 @@ sub http_include {
$pattern = File::Spec->catfile($pattern, '*') if -d $pattern;
foreach my $file (glob $pattern) {
if ($optional && ! -e $file) {
- $self->debug(1, "optional include file \"$file\" doesn't exist");
+ debug(1, "optional include file \"$file\" doesn't exist");
next;
}
$self->examine_http_config($file);
@@ -191,9 +192,9 @@ sub mkpath {
for my $diag (@$err) {
my ($file, $message) = %$diag;
if ($file eq '') {
- $self->error($message);
+ error($message);
} else {
- $self->error("mkdir $file: $message");
+ error("mkdir $file: $message");
}
}
return 0;
@@ -206,16 +207,16 @@ sub setup {
my $filename = $self->layout->incdir() . "/httpd-letsencrypt.conf";
if (-e $filename) {
if ($args{force}) {
- ::error("the file \"$filename\" already exists",
- prefix => 'warning');
+ error("the file \"$filename\" already exists",
+ prefix => 'warning');
} else {
- ::error("the file \"$filename\" already exists");
- ::error("use --force to continue");
+ error("the file \"$filename\" already exists");
+ error("use --force to continue");
return 0;
}
}
my $www_root = $self->get(qw(core rootdir));
- $self->debug(2, "writing $filename");
+ debug(2, "writing $filename");
unless ($args{dry_run}) {
unless ($self->mkpath($self->layout->incdir())) {
return 0;
@@ -264,8 +265,9 @@ EOT
}
}
- ::error("created file \"$filename\"", prefix => 'note');
- ::error("please, enable mod_macro and make sure your Apache configuration includes this file", prefix => 'note');
+ error("created file \"$filename\"", prefix => 'note');
+ error("please, enable mod_macro and make sure your Apache configuration includes this file",
+ prefix => 'note');
return 1;
}
@@ -296,8 +298,8 @@ sub probe {
close $nullin;
close $nullout;
unless ($self->server_root) {
- ::error("can't deduce server root directory");
- ::error("use `source = apache --server-root=DIR' in [core] section of /etc/acmeman.conf to declare it");
+ error("can't deduce server root directory");
+ error("use `source = apache --server-root=DIR' in [core] section of /etc/acmeman.conf to declare it");
exit(1);
}
}
diff --git a/lib/App/Acmeman/Source/File.pm b/lib/App/Acmeman/Source/File.pm
index 9414e86..c622d51 100644
--- a/lib/App/Acmeman/Source/File.pm
+++ b/lib/App/Acmeman/Source/File.pm
@@ -6,6 +6,7 @@ use Carp;
use File::Spec;
use parent 'App::Acmeman::Source';
use Getopt::Long qw(GetOptionsFromArray :config gnu_getopt no_ignore_case);
+use App::Acmeman::Log qw(:all);
sub new {
my $class = shift;
@@ -28,7 +29,7 @@ sub new {
sub scan {
my ($self) = @_;
- $self->debug(1, "initializing file list from $self->{pattern}");
+ debug(1, "initializing file list from $self->{pattern}");
my $err = 0;
if ($self->{host}) {
$self->define_domain($self->{host});
@@ -42,10 +43,10 @@ sub scan {
sub load {
my ($self, $file) = @_;
- $self->debug(1, "reading $file");
+ debug(1, "reading $file");
open(my $fh, '<', $file)
or do {
- $self->error("can't open $file: $!");
+ error("can't open $file: $!");
return 0;
};
chomp(my @lines = <$fh>);

Return to:

Send suggestions and report system problems to the System administrator.