App-Acmeman

 view release on metacpan or  search on metacpan

lib/App/Acmeman.pm  view on Meta::CPAN

package App::Acmeman;
use strict;
use warnings;
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);
use File::Spec;
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);
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 = '3.10';

my $progdescr = "manages ACME certificates";

our $acme_dir = '/etc/ssl/acme';
our $letsencrypt_root_cert_basename = 'lets-encrypt-root.pem';
our $letsencrypt_root_cert_url =
    'https://letsencrypt.org/certs/lets-encrypt-r3-cross-signed.pem';

sub new {
    my $class = shift;
    my $self = bless {
	_progname => basename($0),
        _acme_host => 'production',
        _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},
	'version' => sub {
	    print "$0 version $VERSION\n";
	    exit(EX_OK)
	}
    ) or exit(EX_USAGE);
    ++$self->{_option}{debug} if $self->dry_run_option;

lib/App/Acmeman.pm  view on Meta::CPAN

			$vhost->certificate_file;
		    local $ENV{ACMEMAN_DOMAIN_NAME} = $vhost;
		    local $ENV{ACMEMAN_ALT_NAMES} = join(' ', $vhost->alt);
	    	    foreach my $cmd (@$postrenew) {
			$self->runcmd($cmd, $vhost);
		    }
		} else {
		    push @renewed, $vhost;
		}
	    }
	}
    }

    if (@renewed) {
	local $ENV{ACMEMAN_CERTIFICATE_COUNT} = @renewed;
	local $ENV{ACMEMAN_CERTIFICATE_FILE} =
	    join(' ', map { $_->certificate_file } @renewed);
	local $ENV{ACMEMAN_DOMAIN_NAME} =
	    join(' ', map { "$_" } @renewed);
	local $ENV{ACMEMAN_ALT_NAMES} =
	    join(' ', map { ($_->alt) } @renewed);
	if ($self->cf->is_set(qw(core postrenew))) {
	    foreach my $cmd (grep { defined($_) }
			          $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];
}

my @challenge_files;

END {
    if (@challenge_files) {
	debug(3, "removing challenge files");
	my $n = unlink @challenge_files;
	unless ($n == @challenge_files) {
	    error("some challenge files were not removed",
		  prefix => 'warning');
	}
    }
}

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");
	push @challenge_files, $file;
    } else {
	error("can't open $file for writing: $!");
	die;



( run in 0.753 second using v1.01-cache-2.11-cpan-39bf76dae61 )