Switch to new ACME v2 API
authorRaphaël Gertz <git@rapsys.eu>
Sun, 19 Apr 2020 12:31:59 +0000 (14:31 +0200)
committerRaphaël Gertz <git@rapsys.eu>
Sun, 19 Apr 2020 12:33:11 +0000 (14:33 +0200)
New 2.0.0 version
Fix dependancies list
Rename debug in verbose
Add conf action and feature
Add debian option
Check domain name for A or AAAA record presence
Rewrite login to match new API
Tie all hash to keep order stability between each run
Improve verbose and error message output
Rename base script
Cleanup

acme [new file with mode: 0755]
acmecert [deleted file]

diff --git a/acme b/acme
new file mode 100755 (executable)
index 0000000..553911e
--- /dev/null
+++ b/acme
@@ -0,0 +1,491 @@
+#! /usr/bin/perl
+
+# 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 of the License, 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/>.
+#
+# Copyright (C) 2016 - 2020 Raphaël Gertz <acme@rapsys.eu>
+
+# Best practice
+use strict;
+use warnings;
+
+# Load debug
+#BEGIN {
+#      # Allow use of print Dumper($this);
+#      use Data::Dumper;
+#      # Prepend current directory in include array
+#      # XXX: this will load ./Acme.pm instead of an other version from system tree
+#      unshift @INC, '.';
+#}
+
+# Add acl support to file tests
+use filetest qw(access);
+
+# Load dependancies
+use Carp qw(carp confess);
+use File::Copy qw(copy);
+use File::stat qw(stat);
+use File::Slurp qw(read_file write_file);
+use File::Spec qw(splitpath curdir);
+use JSON qw(decode_json);
+use Net::DNS qw();
+use Net::Domain::TLD qw(tld_exists);
+use Tie::IxHash;
+use Acme;
+
+# Load POSIX
+use POSIX qw(EXIT_SUCCESS EXIT_FAILURE);
+
+# Init verbose
+my $verbose = 0;
+
+# Init debian
+my $debian = undef;
+
+# Init action
+my $action = undef;
+
+# Init config
+my $config = undef;
+#XXX: tie to Tie::IxHash to keep a stable ordering of hash keys
+tie(%{$config}, 'Tie::IxHash');
+
+# Init content
+my $content = undef;
+
+# Init config file name
+my $configFilename = Acme::CONFIG;
+
+# Init domains
+my @domains = ();
+
+# Process verbose
+@ARGV = map { if ($_ eq '-v' or $_ eq '--verbose') { $verbose = 1; (); } else { $_; } } @ARGV;
+
+# Process debian
+@ARGV = map { if ($_ eq '-d' or $_ eq '--debian') { $debian = 1; (); } else { $_; } } @ARGV;
+
+# Process action
+unless(defined $ARGV[0] and $ARGV[0] =~ /^(cert|cron|conf)$/) {
+       print "Usage: $0 (cert|cron|conf) [-(v|-verbose)] [-(c|-config)[=/etc/acme/config]] [example.com] [...]\n";
+       exit EXIT_FAILURE;
+} else {
+       # Save action
+       $action = $ARGV[0];
+       # Remove it from args
+       splice(@ARGV, 0, 1);
+}
+
+# Process config and args
+for (my $i = 0; $i <= $#ARGV; $i++) {
+       # Match config args
+       if ($ARGV[$i] =~ /^(?:(\-c|\-\-config)(?:=(.+))?)$/) {
+               # Extract -c=$2 or --config=$2 syntax
+               if (defined($2)) {
+                       $configFilename = $2;
+                       splice(@ARGV, $i, 1);
+                       $i--;
+               # Extract -c $ARGV[$i+1] or --config $ARGV[$i+1] writable status
+               } elsif (defined($ARGV[$i+1])) {
+                       $configFilename = $ARGV[$i+1];
+                       splice(@ARGV, $i, 2);
+                       $i--;
+               # Check if cert or cron action
+               } elsif ($action eq 'cert' or $action eq 'cron') {
+                       print "Usage: $0 $action [-(c|-config)[=/etc/acme/config]] [example.com] [...]\n";
+                       exit EXIT_FAILURE;
+               }
+
+               # Check if file don't exists
+               if (defined($configFilename) and ! -f $configFilename) {
+                       # Extract config directory and filename
+                       my ($vol, $dir, $file) = File::Spec->splitpath($configFilename);
+
+                       # Check dir
+                       unless ($dir) {
+                               # Set as current dir if empty
+                               $dir = File::Spec->curdir();
+                       }
+
+                       # Verify that directory exists
+                       unless (-d $dir) {
+                               confess('Config directory '.$dir.' must exists: '.$!);
+                       }
+
+                       # Check that directory is writable
+                       unless (-w $dir) {
+                               confess('Config directory '.$dir.' must be writable: '.$!);
+                       }
+               }
+       }
+}
+
+# Check if conf action
+if ($action eq 'conf') {
+       # Configure json
+       my $js = JSON->new->utf8->pretty(1)->space_before(0)->filter_json_object(
+               sub {
+                       # Get source hash ref
+                       my ($x) = @_;
+                       # Init tied hash
+                       #XXX: tie to Tie::IxHash to keep a stable ordering of hash keys
+                       my $r = tie(my %r, 'Tie::IxHash');
+                       # Ordered loop
+                       map {
+                               # Insert key if present
+                               $r{$_} = $x->{$_} if (defined($x->{$_}));
+                       #XXX: Hash keys not present in this array will be dropped
+                       #XXX: Hash keys will be inserted in tied hash in this order
+                       #} sort keys %{$x};
+                       } (
+                               # Root key order
+                               'thumbprint', 'term', 'pending', 'certificates',
+                               # Domain key order
+                               'cert', 'key', 'account', 'mail', 'domain', 'domains', 'prod'
+                       );
+                       # Return the ordered hash
+                       return \%r;
+               }
+       );
+
+       # Check we have specified domains
+       unless (scalar(@ARGV) > 0) {
+               print "Usage: $0 $action [-(v|-verbose)] [-(d|-debian)] [-(c|-config)[=/etc/acme/config]] example.com[=www.example.com[,ftp.example.com]] [...]\n";
+               exit EXIT_FAILURE;
+       }
+
+       # Load config
+       unless(
+               #XXX: use eval to workaround a fatal in decode_json
+               eval {
+                       # Check file
+                       (-f $configFilename) and
+                       # Read it
+                       ($content = read_file($configFilename)) and
+                       # Decode it
+                       ($config = $js->decode($content))
+               }
+       ) {
+               # Warn with verbose
+               carp('Config file '.$configFilename.' not readable or invalid: '.$!) if ($verbose);
+
+               # Create a default config
+               #XXX: tie to Tie::IxHash to keep a stable ordering of hash keys
+               tie(%{$config}, 'Tie::IxHash', 'thumbprint' => Acme::THUMBPRINT, 'term' => Acme::TERM, 'pending' => Acme::PENDING, 'certificates' => []);
+       } else {
+               # Fix root infos when missing
+               $config->{thumbprint} = Acme::THUMBPRINT unless(defined($config->{thumbprint}));
+               $config->{term} = Acme::TERM unless(defined($config->{term}));
+               $config->{pending} = Acme::PENDING unless(defined($config->{pending}));
+               $config->{certificates} = [] unless(defined($config->{certificates}) and ref($config->{certificates}) eq 'ARRAY');
+       }
+
+       # Iterate on each certificates entry
+       for (my $i = 0; $i <= $#{$config->{certificates}}; $i++) {
+               # Set certificate
+               my $certificate = ${$config->{certificates}}[$i];
+
+               # Drop the entry when missing domain key
+               unless (defined($certificate->{domain})) {
+                       splice(@{$config->{certificates}}, $i, 1);
+               # Entry may be fixed
+               } else {
+                       # Init replace
+                       my $replace = undef;
+
+                       # Tie replace
+                       #XXX: tie to Tie::IxHash to keep a stable ordering of hash keys
+                       tie(%{$replace}, 'Tie::IxHash', cert => Acme::RH_CERTS.'/'.$certificate->{domain}.Acme::RH_SUFFIX, key => Acme::RH_PRIVATE.'/'.$certificate->{domain}.Acme::RH_SUFFIX, account => Acme::ACCOUNT, mail => Acme::MAIL.'@'.$certificate->{domain}, 'domain' => $certificate->{domain}, 'domains' => [], prod => 0);
+                       # Use debian path
+                       if ($debian) {
+                               $replace->{cert} = Acme::DEB_CERTS.'/'.$certificate->{domain}.Acme::DEB_CERTS_SUFFIX;
+                               $replace->{key} = Acme::DEB_PRIVATE.'/'.$certificate->{domain}.Acme::DEB_PRIVATE_SUFFIX;
+                       }
+
+                       # Fix cert entry
+                       $replace->{cert} = $certificate->{cert} if (defined($certificate->{cert}));
+
+                       # Fix key entry
+                       $replace->{key} = $certificate->{key} if (defined($certificate->{key}));
+
+                       # Fix account entry
+                       $replace->{account} = $certificate->{account} if (defined($certificate->{account}));
+
+                       # Fix mail entry
+                       $replace->{mail} = $certificate->{mail} if (defined($certificate->{mail}));
+
+                       # Fix domains entry
+                       $replace->{domains} = $certificate->{domains} if (defined($certificate->{domains}) and ref($certificate->{domains}) eq 'ARRAY');
+
+                       # Fix prod entry
+                       $replace->{prod} = $certificate->{prod} if (defined($certificate->{prod}));
+
+                       # Replace certificate
+                       ${$config->{certificates}}[$i] = $replace;
+               }
+       }
+
+       # Check that domains are present in config
+       map {
+               # Extract domain and domains
+               my ($domain, $domains) = split(/=/, $_);
+
+               # Transform domains
+               my @domains = defined($domains) ? map { $_ ? $_ : (); } split(/,/, $domains) : ();
+
+               # Check that domain
+               map {
+                       my $tld;
+
+                       # Extract tld
+                       unless (($tld) = $_ =~ m/\.(\w+)$/) {
+                               confess('Extract '.$_.' tld failed');
+                       }
+
+                       # Check if tld exists
+                       unless(Net::Domain::TLD::tld_exists($tld)) {
+                               confess('Extracted '.$_.' tld '.$tld.' do not exists');
+                       }
+
+                       # Search a record
+                       my $a = Net::DNS::Resolver->new->search($_, 'A', 'IN');
+
+                       # Search aaaa record
+                       my $aaaa = Net::DNS::Resolver->new->search($_, 'AAAA', 'IN');
+
+                       # Trigger error for unresolvable domain
+                       unless (
+                               # Check if either has a A or AAAA record
+                               scalar map {
+                                       ($_->type eq 'A' or $_->type eq 'AAAA') ? 1 : ();
+                               }
+                               # Merge both answer
+                               (
+                                       (defined $a and defined $a->answer) ? $a->answer : (),
+                                       (defined $aaaa and defined $aaaa->answer) ? $aaaa->answer : ()
+                               )
+                       ) {
+                               confess('Resolve '.$_.' to an A or AAAA record failed');
+                       }
+               } ($domain, @domains);
+
+               # Insert domain when missing
+               unless (scalar map { $_->{domain} eq $domain ? 1 : (); } @{$config->{certificates}}) {
+                       # Init certificate
+                       my $certificate = undef;
+
+                       # Tie certificate
+                       #XXX: tie to Tie::IxHash to keep a stable ordering of hash keys
+                       tie(%{$certificate}, 'Tie::IxHash', cert => undef, key => undef, account => Acme::ACCOUNT, mail => Acme::MAIL.'@'.$domain, 'domain' => $domain, 'domains' => [], prod => 0);
+
+                       # Use debian path
+                       if ($debian) {
+                               $certificate->{cert} = Acme::DEB_CERTS.'/'.$domain.Acme::DEB_CERTS_SUFFIX;
+                               $certificate->{key} = Acme::DEB_PRIVATE.'/'.$domain.Acme::DEB_PRIVATE_SUFFIX;
+                       # Use redhat path
+                       } else {
+                               $certificate->{cert} = Acme::RH_CERTS.'/'.$domain.Acme::RH_SUFFIX;
+                               $certificate->{key} = Acme::RH_PRIVATE.'/'.$domain.Acme::RH_SUFFIX;
+                       }
+
+                       # Add domains
+                       map {
+                               # Set subdomain
+                               my $subdomain = $_;
+
+                               # Check if already present
+                               unless (scalar map { $_ eq $subdomain ? 1 : (); } @{$certificate->{domains}}) {
+                                       # Add when not already present
+                                       ${$certificate->{domains}}[scalar @{$certificate->{domains}}] = $_;
+                               }
+                       } @domains;
+
+                       # Append certificate
+                       ${$config->{certificates}}[scalar @{$config->{certificates}}] = $certificate;
+               # Update domains when present
+               } else {
+                       # Loop on all certificate
+                       map {
+                               # Check that we are on the right domain
+                               if ($_->{domain} eq $domain) {
+                                       # Init certificate
+                                       my $certificate = $_;
+
+                                       # Reset domains
+                                       @{$certificate->{domains}} = ();
+
+                                       # Add domains
+                                       map {
+                                               # Set subdomain
+                                               my $subdomain = $_;
+
+                                               # Check if already present
+                                               unless (scalar map { $_ eq $subdomain ? 1 : (); } @{$certificate->{domains}}) {
+                                                       # Add when not already present
+                                                       ${$certificate->{domains}}[scalar @{$certificate->{domains}}] = $_;
+                                               }
+                                       } @domains;
+                               }
+                       } @{$config->{certificates}};
+               }
+       } @ARGV;
+
+       # Extract config directory and filename
+       my ($vol, $dir, $file) = File::Spec->splitpath($configFilename);
+
+       # Check dir
+       unless ($dir) {
+               # Set as current dir if empty
+               $dir = File::Spec->curdir();
+       }
+
+       # Backup old config if possible
+       if (-w $dir and -f $configFilename) {
+               my ($dt, $suffix) = undef;
+
+               # Extract datetime suffix
+               $suffix = ($dt = DateTime->from_epoch(epoch => stat($configFilename)->mtime))->ymd('').$dt->hms('');
+
+               # Rename old config
+               unless(copy($configFilename, $configFilename.'.'.$suffix)) {
+                       carp('Copy '.$configFilename.' to '.$configFilename.'.'.$suffix.' failed: '.$!);
+               }
+       # Check that directory is writable
+       } elsif (! -w $dir and -f $configFilename) {
+               confess('Config directory '.$dir.' must be writable: '.$!);
+       }
+
+       # Encode config in json
+       #XXX: emulate a tab indent file by replacing 3 space indent with tab
+       ($content = $js->encode($config)) =~ s/(\G|^)\s{3}/\t/gm;
+
+       # Write to file
+       write_file($configFilename, $content);
+
+       # Exit with success
+       exit EXIT_FAILURE;
+# Check if cert or cron action
+} elsif ($action eq 'cert' or $action eq 'cron') {
+       # Validate config
+       unless (
+               #XXX: use eval to workaround a fatal in decode_json
+               eval {
+                       # Check file
+                       (-f $configFilename) and
+                       # Read it
+                       ($content = read_file($configFilename)) and
+                       # Decode it
+                       ($config = decode_json($content)) and
+                       # Check certificates presence
+                       defined($config->{certificates}) and
+                       # Check certificates type
+                       ref($config->{certificates}) eq 'ARRAY' and
+                       # Check thumbprint presence
+                       defined($config->{thumbprint}) and
+                       # Check term presence
+                       defined($config->{term}) and
+                       # Check pending presence
+                       defined($config->{pending}) and
+                       # Check certificates array
+                       ! scalar map {
+                               unless(
+                                       defined($_->{cert}) and
+                                       defined($_->{key}) and
+                                       defined($_->{account}) and
+                                       defined($_->{mail}) and
+                                       defined($_->{domain}) and
+                                       defined($_->{domains}) and ref($_->{domains}) eq 'ARRAY' and
+                                       defined($_->{prod})
+                               ) {
+                                       1;
+                               } else {
+                                       ();
+                               }
+                       } @{$config->{certificates}}
+               }
+       ) {
+               confess('Config file '.$configFilename.' not readable or invalid: '.$!);
+       }
+# Unknown action
+} else {
+       #TODO: implement the new action
+       confess('Unknown '.$action.' action');
+}
+
+# Deal with specified domains
+if (scalar(@ARGV) > 0) {
+       # Check that domains are present in config
+       foreach my $domain (@ARGV) {
+               my $found = undef;
+               foreach my $certificate (@{$config->{certificates}}) {
+                       if ($certificate->{domain} eq $domain) {
+                               push(@domains, $certificate);
+                               $found = 1;
+                       }
+               }
+               unless($found) {
+                       print 'Domain '.$domain.' not found in config file '.$configFilename."\n";
+                       exit EXIT_FAILURE;
+               }
+       }
+# Without it
+} else {
+       # Populate domains array with available ones
+       foreach my $certificate (@{$config->{certificates}}) {
+               push(@domains, $certificate);
+       }
+}
+
+# Show conf usage
+if (scalar(@domains) < 1) {
+       print "Usage: $0 conf [-(v|-verbose)] [-(d|-debian)] [-(c|-config)[=/etc/acme/config]] example.com[=www.example.com[,ftp.example.com]] [...]\n";
+       exit EXIT_FAILURE;
+}
+
+# Deal with each domain
+foreach my $domain (@domains) {
+       # Skip certificate, in cron action, issued within the last 60 days
+       if ($action eq 'cron' and -f $domain->{cert} and stat($domain->{cert})->mtime >= (time() - 60*24*3600)) {
+               carp('Domain '.$domain->{domain}.' certificate '.$domain->{cert}.' skipped') if ($verbose);
+               next;
+       }
+       # Create new object
+       my $acme = Acme->new($verbose, $domain, {thumbprint => $config->{thumbprint}, pending => $config->{pending}, term => $config->{term}});
+
+       # Prepare environement
+       $acme->prepare();
+
+       # Generate required keys
+       $acme->genKeys();
+
+       # Directory
+       $acme->directory();
+
+       # Nonce
+       $acme->nonce();
+
+       # Account
+       $acme->account();
+
+       # Order
+       $acme->order();
+
+       # Generate csr
+       $acme->genCsr();
+
+       # Issue
+       $acme->issue();
+}
+
+# Exit with success
+exit EXIT_SUCCESS;
diff --git a/acmecert b/acmecert
deleted file mode 100755 (executable)
index 3f8a559..0000000
--- a/acmecert
+++ /dev/null
@@ -1,152 +0,0 @@
-#! /usr/bin/perl
-
-# 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 of the License, 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/>.
-#
-# Copyright (C) 2016 - 2017 Raphaël Gertz <acme@rapsys.eu>
-
-# Best practice
-use strict;
-use warnings;
-
-# Fix use of acl
-use filetest qw(access);
-
-# Load dependancies
-use File::stat qw(stat);
-use File::Slurp qw(read_file);
-use JSON qw(decode_json);
-use Acme;
-
-# Load POSIX
-use POSIX qw(EXIT_SUCCESS EXIT_FAILURE);
-
-# Init debug
-my $debug = 0;
-
-# Init config
-my $config = undef;
-
-# Init config file name
-my $configFilename = '/etc/acme/config';
-
-# Init domains
-my @domains = ();
-
-# Strip and enable debug
-@ARGV = map { if ($_ eq '-d') { $debug = 1; (); } else { $_; } } @ARGV;
-
-# Strip and enable debug
-for (my $i = 0; $i <= $#ARGV; $i++) {
-       # Match redhat types
-       if ($ARGV[$i] =~ /^(?:(\-c|\-\-config)(?:=(.+))?)$/) {
-               if (defined($2) && -f $2) {
-                       $configFilename = $2;
-                       splice(@ARGV, $i, 1);
-                       $i--;
-               # Extract next parameter
-               } elsif(defined($ARGV[$i+1]) && $ARGV[$i+1] =~ /^(.+)$/ && -f $1) {
-                       $configFilename = $1;
-                       splice(@ARGV, $i, 2);
-                       $i--;
-               # Set default
-               } else {
-                       print 'Config parameter without valid file name'."\n";
-                       exit EXIT_FAILURE;
-               }
-       }
-}
-
-# Load config
-unless (
-       #XXX: use eval to workaround a fatal in decode_json
-       eval {
-               # Check file
-               (-f $configFilename) &&
-               # Read it
-               ($config = read_file($configFilename)) &&
-               # Decode it
-               ($config = decode_json($config)) &&
-               # Check hash validity
-               defined($config->{certificates}) &&
-               # Check not empty
-               scalar($config->{certificates}) &&
-               # Check hash validity
-               defined($config->{thumbprint}) &&
-               # Check certificates array
-               ! scalar map {unless(defined($_->{cert}) && defined($_->{key}) && defined($_->{mail}) && defined($_->{domain}) && defined($_->{domains})) {1;} else {();}} @{$config->{certificates}}
-       }
-) {
-       print 'Config file '.$configFilename.' is not readable or invalid'."\n";
-       exit EXIT_FAILURE;
-}
-
-# Deal with specified domains
-if (scalar(@ARGV) > 0) {
-       # Check that domains are present in config
-       foreach my $domain (@ARGV) {
-               my $found = undef;
-               foreach my $certificate (@{$config->{certificates}}) {
-                       if ($certificate->{domain} eq $domain) {
-                               push(@domains, $certificate);
-                               $found = 1;
-                       }
-               }
-               unless($found) {
-                       print 'Domain '.$domain.' not found in config file '.$configFilename."\n";
-                       exit EXIT_FAILURE;
-               }
-       }
-# Without it
-} else {
-       # Populate domains array with available ones
-       foreach my $certificate (@{$config->{certificates}}) {
-               push(@domains, $certificate);
-       }
-}
-
-# Show usage
-if (scalar(@domains) < 1) {
-       print "Usage: $0 [-(c|-config)[=/etc/acme/config]] [example.com] [...]\n";
-       exit EXIT_FAILURE;
-}
-
-# Deal with each domain
-foreach my $domain (@domains) {
-       # Create new object
-       my $acme = Acme->new($debug, $domain, {thumbprint => $config->{thumbprint}, pending => $config->{pending}, term => $config->{term}});
-
-       # Prepare environement
-       $acme->prepare();
-
-       # Generate required keys
-       $acme->genKeys();
-
-       # Generate csr
-       $acme->genCsr();
-
-       # Directory
-       $acme->directory();
-
-       # Register
-       $acme->register();
-
-       # Authorize
-       $acme->authorize();
-
-       # Issue
-       $acme->issue();
-}
-
-# Exit with success
-exit EXIT_SUCCESS;