aboutsummaryrefslogtreecommitdiff
path: root/acmeman
diff options
context:
space:
mode:
authorSergey Poznyakoff <gray@gnu.org.ua>2017-02-05 18:48:25 +0100
committerSergey Poznyakoff <gray@gnu.org.ua>2017-02-05 18:48:25 +0100
commitc80afcff90c76233fb10e40dce2fa9083383072b (patch)
tree8e4c175380ceee11094639dfd27d3ba934ab9100 /acmeman
downloadacmeman-c80afcff90c76233fb10e40dce2fa9083383072b.tar.gz
acmeman-c80afcff90c76233fb10e40dce2fa9083383072b.tar.bz2
Initial commit
Diffstat (limited to 'acmeman')
-rw-r--r--acmeman552
1 files changed, 552 insertions, 0 deletions
diff --git a/acmeman b/acmeman
new file mode 100644
index 0000000..3539a12
--- /dev/null
+++ b/acmeman
@@ -0,0 +1,552 @@
+#! /usr/bin/perl
+use strict;
+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 WWW::Curl::Easy;
+use Pod::Usage;
+use Pod::Man;
+use Getopt::Long qw(:config gnu_getopt no_ignore_case);
+use POSIX qw(strftime time floor);
+
+our $VERSION = '0.90';
+
+=head1 NAME
+
+acmeman - manages ACME certificates
+
+=head1 SYNOPSIS
+
+=head1 DESCRIPTION
+
+=head1 OPTIONS
+
+=head1 AUTHOR
+
+=cut
+
+my $progname = basename($0);
+my $progdescr = "manages ACME certificates";
+my $debug;
+my $dry_run;
+my $acme_host = 'acme-staging.api.letsencrypt.org';
+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 $www_root;
+my $time_delta = 86400;
+my $force = 0;
+
+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;
+}
+
+my $filename_arg;
+my %filename_pattern;
+
+sub make_filename {
+ my ($type, $arg) = @_;
+ abend(EX_SOFTWARE, "no $type in \%filename_pattern")
+ unless exists $filename_pattern{$type};
+ my $res = $filename_pattern{$type};
+ $res =~ s{$filename_arg}{$arg}g;
+ return $res;
+}
+
+sub prep_dir {
+ my $dir = dirname(shift);
+ if (! -d $dir) {
+ debug(2, "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("problem unlinking $file: $message");
+ }
+ }
+ exit(EX_CANTCREAT);
+ }
+ }
+}
+
+
+sub debug_to_loglevel {
+ my @lev = ('err', 'info', 'debug');
+ return $lev[$debug > $#lev ? $#lev : $debug];
+}
+
+sub make_csr {
+ my $cn = shift;
+ my $req = Crypt::OpenSSL::PKCS10->new(2048);
+ $req->set_subject("/CN=$cn");
+ foreach my $name (@_) {
+ $req->add_ext(Crypt::OpenSSL::PKCS10::NID_subject_alt_name, $name);
+ }
+ $req->add_ext_final();
+ $req->sign();
+ if (exists($filename_pattern{key})) {
+ my $filename = make_filename('key', $cn);
+ debug(2, "writing $filename");
+ prep_dir($filename);
+ my $u = umask(077);
+ $req->write_pem_pk($filename);
+ umask($u);
+ }
+ return $req->get_pem_req();
+}
+
+sub save_crt {
+ my $type = shift;
+ my $domain = shift;
+
+ my $filename = make_filename($type, $domain);
+ debug(2, "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;
+}
+
+sub domain_cert_expires {
+ my $domain = shift;
+ my $crt = make_filename('cert', $domain);
+ if (-f $crt) {
+ my $x509 = Crypt::OpenSSL::X509->new_from_file($crt);
+ 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(1, "$crt expires on $expiry, $in");
+ if ($now + $time_delta < $ts) {
+ debug(1, "$crt expires on $expiry, $in");
+ return 0;
+ } else {
+ debug(1, "will renew $crt (expires on $expiry, $in)");
+ }
+ } else {
+ debug(1, "will renew $crt");
+ }
+ }
+ return 1;
+}
+
+sub register_domain_certificate {
+ my $domain = shift;
+ debug(1, "CN=$domain, alternatives=@_");
+
+ my $acme = Protocol::ACME->new(
+ host => $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();
+
+ $acme->authz($domain);
+ $acme->handle_challenge($challenge);
+ $acme->check_challenge();
+ $acme->cleanup_challenge($challenge);
+
+ my $csr = make_csr($domain, @_);
+ my $cert = $acme->sign({ format => 'PEM', buffer => $csr });
+ my $chain = $acme->chain();
+ if (exists($filename_pattern{chain})) {
+ save_crt('cert', $domain, $cert);
+ save_crt('chain', $domain, $chain);
+ } else {
+ save_crt('cert', $domain, $cert, $chain);
+ }
+ };
+ if ($@) {
+ if (UNIVERSAL::isa($@, 'Protocol::ACME::Exception')) {
+ error("$domain: can't update certificate: $@->{status}");
+ if (exists($@->{error})) {
+ error("$domain: $@->{error}{status} $@->{error}{detail}");
+ } else {
+ error("$domain: $@->{detail} $@->{type}");
+ }
+ } else {
+ abend(EX_SOFTWARE, $@);
+ }
+ }
+}
+
+sub dequote {
+ my ($arg) = @_;
+ $arg =~ s{^"(.*?)"$}{$1};
+ return $arg;
+}
+
+sub uniq {
+ my %h;
+ map {
+ if (exists($h{$_})) {
+ ()
+ } else {
+ $h{$_} = 1;
+ $_
+ }
+ } @_;
+}
+
+sub examine_http_config {
+ my ($file, $vhosts, $state, $optional) = @_;
+ debug(2, "reading apache configuration file \"$file\"");
+ if (open(my $fd, '<', $file)) {
+ my $server_name;
+ my @server_aliases;
+ my $line;
+
+ use constant {
+ STATE_INITIAL => 0, # Initial state
+ STATE_VIRTUAL_HOST => 1, # In VirtualHost block
+ STATE_USE_CHALLENGE => 2, # Ditto, but challenge macro was used
+ STATE_MACRO_CHALLENGE => 3, # In LetsEncryptChallenge macro
+ STATE_MACRO_SERVER => 4
+ };
+
+ $state = STATE_INITIAL unless defined $state;
+
+ while (<$fd>) {
+ ++$line;
+ chomp;
+ s/^\s+//;
+ next if /^(#.*)?$/;
+ if (/^include(optional)?\s+(.+?)\s*$/i) {
+ debug(2, "$file:$line: state $state: Include$1 $2");
+ http_include(dequote($2), $vhosts, $state, $1 ne '');
+ next;
+ }
+
+ if ($state == STATE_INITIAL) {
+ if (/^<VirtualHost/i) {
+ $state = STATE_VIRTUAL_HOST;
+ $server_name = undef;
+ @server_aliases = ();
+ } elsif (/^<(?:(?i)Macro)\s+LetsEncryptChallenge/) {
+ $state = STATE_MACRO_CHALLENGE;
+ } elsif (/^<(?:(?i)Macro)\s+LetsEncryptServer\s+(.+?)\s*>/) {
+ $state = STATE_MACRO_SERVER;
+ $filename_arg = $1;
+ $filename_arg =~ s{\$}{\\\$};
+ }
+ } elsif ($state == STATE_VIRTUAL_HOST
+ || $state == STATE_USE_CHALLENGE) {
+ if (m{</VirtualHost}i) {
+ unshift @server_aliases, $server_name
+ if defined $server_name;
+ if ($state == STATE_USE_CHALLENGE && @server_aliases) {
+ my @names = uniq(@server_aliases);
+ debug(1, "$file:$line: will handle @names");
+ push @{$vhosts}, \@names;
+ }
+ $state = STATE_INITIAL;
+ } elsif (/^(?:(?i)Use)\s+LetsEncryptChallenge/) {
+ $state = STATE_USE_CHALLENGE;
+ # } elsif (/^Use\s+LetsEncryptSSL\s+(\S+)/i) {
+ # debug(1, "$file: $1");
+ # push @{$vhosts}, [ $1 ];
+ } elsif (/^(?:(?i)ServerName)\s+(\S+)/) {
+ $server_name = dequote($1);
+ } elsif (/^(?:(?i)ServerAlias)\s+(.+)\s*$/) {
+ push @server_aliases,
+ map { /^\*\./ ? () : dequoe($_) } split /\s+/, $1;
+ }
+ } elsif ($state == STATE_MACRO_CHALLENGE) {
+ if (m{^</macro}i) {
+ $state = STATE_INITIAL;
+ } elsif (m{^(?:(?i)Alias)\s+/.well-known/acme-challenge\s+(.+)}) {
+ my $dir = dequote($1);
+ $dir =~ s{/.well-known/acme-challenge$}{};
+ $www_root = $dir;
+ debug(1, "ACME challenge root dir: $www_root");
+ }
+ } elsif ($state == STATE_MACRO_SERVER) {
+ if (m{^</macro}i) {
+ $state = STATE_INITIAL;
+ } elsif (/(?:(?i)SSLCertificate((?:Key)|(?:Chain))?File)\s+(.+)/) {
+ $filename_pattern{lc($1 ? $1 : 'cert')} = $2;
+ }
+ }
+ }
+ close $fd;
+ } elsif ($optional) {
+ debug(1, "optional include file \"$file\" doesn't exist");
+ } else {
+ error("can't open file \"$file\": $!");
+ return 0;
+ }
+ return 1;
+}
+
+sub http_include {
+ my ($pattern, $vhosts, $state, $optional) = @_;
+ $pattern =~ s{/*$}{};
+ $pattern .= '/*' if -d $pattern;
+ foreach my $file (glob $pattern) {
+ examine_http_config($file, $vhosts, $state, $optional);
+ }
+}
+# ##
+my $apache_layout;
+
+sub incdir {
+ if (exists($apache_layout->{incdir})) {
+ if (ref($apache_layout->{incdir}) eq 'CODE') {
+ return &{$apache_layout->{incdir}};
+ } else {
+ return $apache_layout->{incdir};
+ }
+ }
+ return dirname($apache_layout->{config});
+}
+
+sub get_root_cert {
+ my $name = shift;
+ prep_dir($name);
+
+ debug(1, "downloading $letsencrypt_root_cert_url to \"$name\"");
+ open(my $fd, '>', $name)
+ or abend(EX_CANTCREAT, "can't open \"$name\" for writing: $!");
+ my $curl = WWW::Curl::Easy->new;
+ $curl->setopt(CURLOPT_HEADER,0);
+ $curl->setopt(CURLOPT_URL, $letsencrypt_root_cert_url);
+ $curl->setopt(CURLOPT_WRITEDATA, $fd);
+ my $rc = $curl->perform;
+ my $response_code = $curl->getinfo(CURLINFO_HTTP_CODE);
+ if ($rc) {
+ error("error downloading certificate from $letsencrypt_root_cert_url");
+ error($curl->errbuf);
+ abend(EX_NOINPUT, $curl->strerror($rc));
+ } elsif ($response_code != 200) {
+ error("error downloading certificate from $letsencrypt_root_cert_url");
+ abend(EX_NOINPUT, "unexpected response code: $response_code");
+ }
+ close $fd;
+}
+
+sub initial_setup {
+ if (defined($www_root)) {
+ error("acmeman macros seem to be already installed");
+ abend(EX_NOPERM, "use --force to continue") unless $force;
+ }
+ if ($#ARGV == -1) {
+ $www_root = '/var/www/acme';
+ } elsif ($#ARGV == 0) {
+ $www_root = $ARGV[0];
+ } else {
+ abend(EX_USAGE, "too many arguments in setup mode");
+ }
+ my $filename = incdir() . "/httpd-letsencrypt.conf";
+ if (-e $filename) {
+ error("the file \"$filename\" already exists");
+ abend(EX_NOPERM, "use --force to continue") unless $force;
+ }
+ debug(1, "writing $filename");
+ unless ($dry_run) {
+ prep_dir($filename);
+ open(my $fd, '>', $filename)
+ or abend(EX_CANTCREAT, "can't open \"$filename\" for writing: $!");
+ print $fd <<EOT;
+<Macro LetsEncryptChallenge>
+ Alias /.well-known/acme-challenge $www_root/.well-known/acme-challenge
+ <Directory $www_root/.well-known/acme-challenge>
+ Options None
+ Require all granted
+ </Directory>
+</Macro>
+
+<Macro LetsEncryptServer \$domain>
+ ServerName \$domain
+ SSLEngine on
+ SSLProtocol all -SSLv2 -SSLv3
+ SSLHonorCipherOrder on
+ SSLCipherSuite EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:EECDH+RC4:RSA+RC4:!MD5
+ SSLCertificateFile /etc/ssl/acme/\$domain/cert.pem
+ SSLCertificateKeyFile /etc/ssl/acme/private/\$domain/privkey.pem
+ SSLCACertificateFile /etc/ssl/acme/lets-encrypt-x3-cross-signed.pem
+</Macro>
+EOT
+;
+ close $fd;
+
+ get_root_cert('/etc/ssl/acme/lets-encrypt-x3-cross-signed.pem');
+
+ if (exists($apache_layout->{post_setup})) {
+ &{$apache_layout->{post_setup}}($filename);
+ }
+ }
+ error("created file \"$filename\"", prefix => 'NOTE');
+ error("please, enable mod_macro and make sure your Apache configuration includes this file", prefix => 'NOTE');
+ exit(EX_OK);
+}
+
+my %apache_layout_tab = (
+ slackware => { config => '/etc/httpd/httpd.conf',
+ incdir => '/etc/httpd/extra'
+ },
+ debian => { config => '/etc/apache2/apache2.conf',
+ incdir => sub {
+ for my $dir ('/etc/apache2/conf-available',
+ '/etc/apache2/conf.d') {
+ return $dir if -d $dir;
+ }
+ warn 'none of the expected configuration directories found; falling back to /etc/apache2';
+ return '/etc/apache2';
+ },
+ post_setup => sub {
+ my ($filename) = @_;
+ my $dir = dirname($filename);
+ my $name = basename($filename);
+ if ($dir eq '/etc/apache2/conf-available') {
+ chdir('/etc/apache2/conf-enabled');
+ symlink "../$name", $name;
+ }
+ }
+ },
+ rh => { config => '/etc/httpd/conf/httpd.conf',
+ incdir => '/etc/httpd/conf.d'
+ }
+);
+
+my $setup;
+
+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,
+ "force|F" => \$force,
+ "time-delta|D=n" => \$time_delta,
+ "layout|l=s" => sub {
+ my ($opt, $arg) = @_;
+ abend(EX_USAGE, "unknown layout: $arg")
+ unless exists $apache_layout_tab{$arg};
+ $apache_layout = $apache_layout_tab{$arg};
+ },
+ "setup|s" => \$setup,
+ "config-file|f=s" => sub {
+ $apache_layout = { config => $_[1] }
+ }
+) or exit(EX_USAGE);
+
+if ($dry_run) {
+ ++$debug;
+ $acme_host = $acme_endpoint{staging};
+} else {
+ $acme_host = $acme_endpoint{prod};
+}
+
+unless (defined($apache_layout)) {
+ while (my ($name, $layout) = each %apache_layout_tab) {
+ if (-f $layout->{config}) {
+ debug(1, "assuming Apache layout \"$name\"");
+ $apache_layout = $layout;
+ last;
+ }
+ }
+}
+
+unless (defined($apache_layout)) {
+ abend(EX_OSFILE, "no suitable apache configuration file found");
+}
+
+my @virthost;
+examine_http_config($apache_layout->{config}, \@virthost) or exit(EX_OSFILE);
+initial_setup if $setup;
+debug(1, "nothing to do") unless @virthost;
+
+# Check challenge root directory
+if (defined($www_root)) {
+ prep_dir("$www_root/file");
+} else {
+ abend(EX_CONFIG, "ACME challenge root directory not defined");
+}
+# Check filename patterns
+abend(EX_CONFIG, "filename patterns not defined")
+ unless (defined($filename_arg) && defined($filename_pattern{cert}));
+
+$account_key = Crypt::OpenSSL::RSA->generate_key(2048);
+$challenge = Protocol::ACME::Challenge::LocalFile->new({www_root => $www_root});
+
+foreach my $vhost (@virthost) {
+ if ($force || domain_cert_expires(${$vhost}[0])) {
+ register_domain_certificate(@$vhost);
+ }
+}
+
+
+

Return to:

Send suggestions and report system problems to the System administrator.