aboutsummaryrefslogtreecommitdiff
path: root/acmeman
diff options
context:
space:
mode:
authorSergey Poznyakoff <gray@gnu.org.ua>2019-08-21 16:16:42 +0300
committerSergey Poznyakoff <gray@gnu.org.ua>2019-08-23 17:14:09 +0300
commit99d1298ce565acb55514deb472cd5d534d9e8e9b (patch)
tree60f5c8c619563065c3c6dee8dffc7f898b213c79 /acmeman
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 (limited to 'acmeman')
-rwxr-xr-xacmeman535
1 files changed, 7 insertions, 528 deletions
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)");
- }
-}
+

Return to:

Send suggestions and report system problems to the System administrator.