From 5a96c2c06e20fe7aecc972c790630aed70342bbc Mon Sep 17 00:00:00 2001 From: =?utf8?q?Rapha=C3=ABl=20Gertz?= Date: Sun, 19 Apr 2020 14:31:59 +0200 Subject: [PATCH] Switch to new ACME v2 API 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 | 491 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ acmecert | 152 ----------------- 2 files changed, 491 insertions(+), 152 deletions(-) create mode 100755 acme delete mode 100755 acmecert diff --git a/acme b/acme new file mode 100755 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 . +# +# Copyright (C) 2016 - 2020 Raphaël Gertz + +# 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 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 . -# -# Copyright (C) 2016 - 2017 Raphaël Gertz - -# 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; -- 2.41.0