From: Raphaƫl Gertz Date: Tue, 20 Jun 2017 16:17:20 +0000 (+0200) Subject: Rewrite code based on config file X-Git-Tag: 2.0.3~20 X-Git-Url: https://git.rapsys.eu/acme/commitdiff_plain/d6c3820cb889a6bba190b5c0c3b04032494ae2ea?ds=sidebyside Rewrite code based on config file Version 0.8 --- diff --git a/Acme.pm b/Acme.pm index 1f5675f..adcc99e 100644 --- a/Acme.pm +++ b/Acme.pm @@ -22,16 +22,20 @@ package Acme; use strict; use warnings; +# Fix use of acl +use filetest qw(access); + # Symbol export use Exporter; our @ISA = qw(Exporter); -our @EXPORT_OK = qw(DS CERT_DIR KEY_DIR REQUEST_CSR ACCOUNT_KEY SERVER_KEY SERVER_CRT CONFIG); +our @EXPORT_OK = qw(VERSION); # Load dependancies use Carp qw(carp confess); use Date::Parse qw(str2time); use Digest::SHA qw(sha256_base64); use Email::Valid; +use File::Copy qw(copy); use File::Path qw(make_path); use File::Slurp qw(read_file write_file); use File::Temp; # qw( :seekable ); @@ -43,6 +47,9 @@ use Net::Domain::TLD; use POSIX qw(EXIT_FAILURE); use Tie::IxHash; +# Debug +use Data::Dumper; + # Documentation links #XXX: see https://letsencrypt.github.io/acme-spec/ (probably based on https://ietf-wg-acme.github.io/acme/) #XXX: see jwk rfc http://www.rfc-editor.org/rfc/rfc7517.txt @@ -50,30 +57,9 @@ use Tie::IxHash; # Set constants use constant { - # Directory separator - DS => '/', - - # Directory for certificates - CERT_DIR => 'cert', - - # Directory for keys - KEY_DIR => 'key', - - # Directory for pending cache - PENDING_DIR => 'pending', - # Request certificate file name REQUEST_CSR => 'request.der', - # Account key file name - ACCOUNT_KEY => 'account.pem', - - # Server private key - SERVER_KEY => 'server.pem', - - # Server public certificate - SERVER_CRT => 'server.crt', - # rsa KEY_TYPE => 'rsa', @@ -84,13 +70,9 @@ use constant { ACME_CERT => 'https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem', ACME_DIR => 'https://acme-staging.api.letsencrypt.org/directory', ACME_PROD_DIR => 'https://acme-v01.api.letsencrypt.org/directory', - ACME_TERMS => 'https://letsencrypt.org/documents/LE-SA-v1.1.1-August-1-2016.pdf', # Version - VERSION => 'v0.7', - - # Config - CONFIG => '/etc/acme/config' + VERSION => 'v0.8', }; # User agent object @@ -124,7 +106,7 @@ tie(%{$jwk{jwk}{jwk}}, 'Tie::IxHash', e => undef, kty => uc(KEY_TYPE), n => unde # Constructor sub new { # Extract params - my ($class, $mail, $debug, $prod, @domains) = @_; + my ($class, $debug, $domain, $config) = @_; # Create self hash my $self = {}; @@ -135,21 +117,27 @@ sub new { # Save debug $self->{debug} = $debug; - # Save prod - $self->{prod} = $prod; + # Save domain + $self->{domain} = $domain; + + # Save config + $self->{config} = $config; + + # Save domains + @{$self->{domains}} = ($domain->{domain}, @{$domain->{domains}}); # Add extra check to mail validity #XXX: mxcheck fail if there is only a A record on the domain my $ev = Email::Valid->new(-fqdn => 1, -tldcheck => 1, -mxcheck => 1); # Show error if check fail - if (! defined $ev->address($mail)) { + if (! defined $ev->address($self->{domain}{mail})) { map { carp 'failed check: '.$_ if ($self->{debug}) } $ev->details(); confess 'Email::Valid->address failed'; } # Save mail - $self->{mail} = $mail; + $self->{mail} = $self->{domain}{mail}; # Create resolver my $res = new Net::DNS::Resolver(); @@ -177,10 +165,7 @@ sub new { confess 'search recursively A record for '.$_.' failed'; } } - } @domains; - - # Save domains - @{$self->{domains}} = @domains; + } @{$self->{domains}}; # Return class reference return $self; @@ -190,19 +175,58 @@ sub new { sub prepare { my ($self) = @_; + # Extract cert directory and filename + my ($certFile, $certDir) = File::Spec->splitpath($self->{domain}{cert}); + + # Extract key directory and filename + my ($keyFile, $keyDir) = File::Spec->splitpath($self->{domain}{key}); + + # Extract account directory and filename + my ($accountFile, $accountDir) = File::Spec->splitpath($self->{domain}{account}); + # Create all paths - make_path(CERT_DIR, KEY_DIR, PENDING_DIR.'/'.$self->{mail}.'.'.($self->{prod} ? 'prod' : 'staging'), {error => \my $err}); - if (@$err) { - map { - my ($file, $msg) = %$_; - carp ($file eq '' ? '' : $file.': ').$msg if ($self->{debug}); - } @$err; - confess 'make_path failed'; + { + make_path($certDir, $keyDir, $accountDir, $self->{config}{pending}.'/'.$self->{mail}.'.'.($self->{domain}{prod} ? 'prod' : 'staging'), {error => \my $err}); + if (@$err) { + map { + my ($file, $msg) = %$_; + carp ($file eq '' ? '' : $file.': ').$msg if ($self->{debug}); + } @$err; + confess 'make_path failed'; + } } # Create user agent $ua = LWP::UserAgent->new; - $ua->agent(__PACKAGE__.'/'.VERSION) + $ua->agent(__PACKAGE__.'/'.VERSION); + + # Check that certificate is writable + unless (-w $certDir || -w $self->{domain}{cert}) { + confess('Directory '.$certDir.' or file '.$self->{domain}{cert}.' must be writable: '.$!); + } + + # Check that key is writable + unless (-r $self->{domain}{key} || -w $keyDir) { + confess('File '.$self->{domain}{key}.' must be readable or directory '.$keyDir.' must be writable: '.$!); + } + + # Check that account is writable + unless (-r $self->{domain}{account} || -w $accountDir) { + confess('File '.$self->{domain}{account}.' must be readable or directory '.$accountDir.' must be writable: '.$!); + } + + # Backup old certificate if possible + if (-w $certDir && -f $self->{domain}{cert}) { + my ($dt, $suffix) = undef; + + # Extract datetime suffix + $suffix = ($dt = DateTime->from_epoch(epoch => stat($self->{domain}{cert})->mtime))->ymd('').$dt->hms(''); + + # Rename old certificate + unless(copy($self->{domain}{cert}, $self->{domain}{cert}.'.'.$suffix)) { + carp('Copy '.$self->{domain}{cert}.' to '.$self->{domain}{cert}.'.'.$suffix.' failed: '.$!); + } + } } # Drop stderr @@ -239,7 +263,7 @@ sub genKeys { # Restore stderr _restoreStdErr(); } - } (KEY_DIR.DS.ACCOUNT_KEY, KEY_DIR.DS.SERVER_KEY); + } ($self->{domain}{account}, $self->{domain}{key}); # Extract modulus and publicExponent jwk #XXX: same here we tie to keep ordering @@ -252,12 +276,12 @@ sub genKeys { # Extract to binary from int, trim leading zeros and convert to base64 url chomp ($self->{account}{jwk}{jwk}{e} = encode_base64url(pack("N", $1) =~ s/^\0+//r)); } - } capturex('openssl', ('rsa', '-text', '-in', KEY_DIR.DS.ACCOUNT_KEY, '-noout', '-modulus')); + } capturex('openssl', ('rsa', '-text', '-in', $self->{domain}{account}, '-noout', '-modulus')); # Drop stderr _dropStdErr(); # Extract account public key - $self->{account}{pubkey} = join('', map { chomp; $_; } capturex('openssl', ('rsa', '-in', KEY_DIR.DS.ACCOUNT_KEY, '-pubout'))); + $self->{account}{pubkey} = join('', map { chomp; $_; } capturex('openssl', ('rsa', '-in', $self->{domain}{account}, '-pubout'))); # Restore stderr _restoreStdErr(); @@ -273,18 +297,21 @@ sub genCsr { # Openssl config template my $oct = File::Temp->new(); + # Save data start position + my $pos = tell DATA; + # Load template from data map { s/__EMAIL_ADDRESS__/$self->{mail}/; s/__COMMON_NAME__/$self->{domains}[0]/; print $oct $_; } ; - # Close data - close(DATA); + # Reseek data + seek(DATA, $pos, 0); # Append domain names my $i = 1; map { print $oct 'DNS.'.$i++.' = '.$_."\n"; } @{$self->{domains}}; # Generate csr - capturex('openssl', ('req', '-new', '-outform', 'DER', '-key', KEY_DIR.DS.SERVER_KEY, '-config', $oct->filename, '-out', CERT_DIR.DS.REQUEST_CSR)); + capturex('openssl', ('req', '-new', '-outform', 'DER', '-key', $self->{domain}{key}, '-config', $oct->filename, '-out', $self->{config}{pending}.'/'.$self->{mail}.'.'.($self->{domain}{prod} ? 'prod' : 'staging').'/'.REQUEST_CSR)); # Close oct close($oct); @@ -338,7 +365,7 @@ sub _post { close($stf); # Generate digest of stf - my $signature = encode_base64url(join('', capturex('openssl', ('dgst', '-sha256', '-binary', '-sign', KEY_DIR.DS.ACCOUNT_KEY, $stf->filename))) =~ s/^\0+//r); + my $signature = encode_base64url(join('', capturex('openssl', ('dgst', '-sha256', '-binary', '-sign', $self->{domain}{account}, $stf->filename))) =~ s/^\0+//r); # Create a request my $req = HTTP::Request->new(POST => $uri); @@ -398,23 +425,10 @@ sub _httpCheck { # Create a request my $req = HTTP::Request->new(GET => 'http://'.$domain.'/.well-known/acme-challenge/'.$token); - # Load config if available - my $config = undef; - if ( - #XXX: use eval to workaround a fatal in from_json - defined eval { - # Check that file exists - -f CONFIG && - # Read it - ($config = read_file(CONFIG)) && - # Decode it - ($config = from_json($config)) && - # Check defined - $config->{thumbprint} - } - ) { + # Check if thumbprint is writeable + if (-w $self->{config}{thumbprint}) { # Try to write thumbprint - write_file($config->{thumbprint}, $self->{account}{thumbprint}); + write_file($self->{config}{thumbprint}, $self->{account}{thumbprint}); } # Get request @@ -443,7 +457,7 @@ sub register { # Post new-reg request #XXX: contact array may contain a tel:+33612345678 for example - my $res = $self->_post($self->{'new-reg'}, {resource => 'new-reg', contact => ['mailto:'.$self->{mail}], agreement => ACME_TERMS}); + my $res = $self->_post($self->{'new-reg'}, {resource => 'new-reg', contact => ['mailto:'.$self->{mail}], agreement => $self->{term}}); # Handle error unless ($res->is_success || $res->code eq 409) { @@ -482,7 +496,7 @@ sub authorize { my $content = undef; # Init file - my $file = PENDING_DIR.'/'.$self->{mail}.'.'.($self->{prod} ? 'prod' : 'staging').'/'.$_; + my $file = $self->{config}{pending}.'/'.$self->{mail}.'.'.($self->{domain}{prod} ? 'prod' : 'staging').'/'.$_; # Load auth request content or post a new one #TODO: add more check on cache file ??? @@ -624,23 +638,10 @@ sub authorize { } map { $self->{challenges}{$_}{status} eq 'pending' ? $_ : (); } keys %{$self->{challenges}}; } - # Load config if available - my $config = undef; - if ( - #XXX: use eval to workaround a fatal in from_json - defined eval { - # Check that file exists - -f CONFIG && - # Read it - ($config = read_file(CONFIG)) && - # Decode it - ($config = from_json($config)) && - # Check defined - $config->{thumbprint} - } - ) { + # Check if thumbprint is writeable + if (-w $self->{config}{thumbprint}) { # Try to write thumbprint - write_file($config->{thumbprint}, ''); + write_file($self->{config}{thumbprint}, ''); } # Stop here with remaining chanllenge @@ -671,7 +672,7 @@ sub issue { my ($self) = @_; # Open csr file - open(my $fh, '<', CERT_DIR.DS.REQUEST_CSR) or die $!; + open(my $fh, '<', $self->{config}{pending}.'/'.$self->{mail}.'.'.($self->{domain}{prod} ? 'prod' : 'staging').'/'.REQUEST_CSR) or die $!; # Load csr my $csr = encode_base64url(join('', <$fh>) =~ s/^\0+//r); @@ -688,7 +689,7 @@ sub issue { } # Open crt file - open($fh, '>', CERT_DIR.DS.SERVER_CRT) or die $!; + open($fh, '>', $self->{domain}{cert}) or die $!; # Convert to pem print $fh '-----BEGIN CERTIFICATE-----'."\n".encode_base64($res->content).'-----END CERTIFICATE-----'."\n"; @@ -711,7 +712,7 @@ sub issue { close($fh) or die $!; # Print success - carp 'Success, pem certificate in '.CERT_DIR.DS.SERVER_CRT if ($self->{debug}); + carp 'Success, pem certificate in '.$self->{domain}{cert} if ($self->{debug}); } 1; diff --git a/acmecert b/acmecert index f8694cb..3f8a559 100755 --- a/acmecert +++ b/acmecert @@ -19,7 +19,13 @@ use strict; use warnings; -# Load Acme +# 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 @@ -28,44 +34,119 @@ use POSIX qw(EXIT_SUCCESS EXIT_FAILURE); # Init debug my $debug = 0; -# Init prod -my $prod = 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 prod -@ARGV = map { if ($_ eq '-p') { $prod = 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(@ARGV) < 2) { - print "Usage: $0 user\@example.com www.example.com [example.com] [...]\n"; +if (scalar(@domains) < 1) { + print "Usage: $0 [-(c|-config)[=/etc/acme/config]] [example.com] [...]\n"; exit EXIT_FAILURE; } -# Create new object -my $acme = Acme->new(shift @ARGV, $debug, $prod, @ARGV); +# 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(); + # Prepare environement + $acme->prepare(); -# Generate required keys -$acme->genKeys(); + # Generate required keys + $acme->genKeys(); -# Generate csr -$acme->genCsr(); + # Generate csr + $acme->genCsr(); -# Directory -$acme->directory(); + # Directory + $acme->directory(); -# Register -$acme->register(); + # Register + $acme->register(); -# Authorize -$acme->authorize(); + # Authorize + $acme->authorize(); -# Issue -$acme->issue(); + # Issue + $acme->issue(); +} # Exit with success exit EXIT_SUCCESS; diff --git a/acmecert.1 b/acmecert.1 index cbacb9b..81bf2d8 100644 --- a/acmecert.1 +++ b/acmecert.1 @@ -1,30 +1,18 @@ .\" Manpage for acmecert. .\" Contact acme@rapsys.eu to correct errors or typos. -.TH man 1 "05 Apr 2017" "0.7" "acmecert man page" +.TH man 1 "20 Jun 2017" "0.8" "acmecert man page" .SH NAME -acmecert \- generate a single certificate +acmecert \- generate all certificate listed in configuration file or provided subset .SH SYNOPSIS -acmecert [-(r|-redhat|d|-debian)] [example.com[,www.example.com,...]] [...] +acmecert [-d] [-(c|-config)[=acme.config]] [example.com] [...] .SH DESCRIPTION -acmecert is a basic script generating a single certificate based on parameters. +acmecert is a basic script generating all certificate listed in configuration. .SH OPTIONS -The acmecert takes options. +The acmecert script takes one option. Use -d for debug directive. - -Use -p for production mode. - -These directives require to be followed by an email address and the domain and alternative domain list. -.SH EXAMPLE 1 -.TP -.B acmecert -d webmaster@example.com example.com www.example.com ssl.example.com -will generate a certificate for example.com with www.example.com and ssl.example.com alternatives domains with debug mode active. -.SH EXAMPLE 2 -.TP -.B acmecert -p webmaster@example.com example.com www.example.com ssl.example.com -will generate a certificate for example.com with www.example.com and ssl.example.com alternatives domains with production mode active. .SH SEE ALSO -acmecron(1),acmecert(1) +acmeconf(1),acmecron(1) .SH BUGS No known bugs. .SH AUTHOR diff --git a/acmeconf b/acmeconf index 10595c8..c34ee25 100755 --- a/acmeconf +++ b/acmeconf @@ -34,7 +34,7 @@ my @debian = (); # Init root my %root = (); -tie(%root, 'Tie::IxHash', thumbprint => '/etc/acme/thumbprint', certificates => []); +tie(%root, 'Tie::IxHash', thumbprint => '/etc/acme/thumbprint', term => 'https://letsencrypt.org/documents/LE-SA-v1.1.1-August-1-2016.pdf', pending => '/tmp/acme.pending', certificates => []); # Init prod my $prod = 0; @@ -42,6 +42,8 @@ my $prod = 0; # Strip and enable prod @ARGV = map { if ($_ eq '-p') { $prod = 1; (); } else { $_; } } @ARGV; +use Data::Dumper; + # Strip and enable debug for (my $i = 0; $i <= $#ARGV; $i++) { # Match redhat types @@ -68,12 +70,28 @@ for (my $i = 0; $i <= $#ARGV; $i++) { } else { push(@debian, ['www.example.com','example.com','...']); } + # Match term + } elsif ($ARGV[$i] =~ /^(?:(\-t|\-\-term)(?:=(https:\/\/letsencrypt\.org\/documents\/[a-zA-Z0-9\._-]+\.pdf))?)$/) { + if (defined($2)) { + $root{term} = $2; + splice(@ARGV, $i, 1); + $i--; + # Extract next parameter + } elsif(defined($ARGV[$i+1]) && $ARGV[$i+1] =~ /^(https:\/\/letsencrypt\.org\/documents\/[a-zA-Z0-9\._-]+\.pdf)$/) { + $root{term} = $1; + splice(@ARGV, $i, 2); + $i--; + # Set default + } else { + print 'Term parameter without valid link'."\n"; + exit EXIT_FAILURE; + } } } # Show usage -if (scalar(@ARGV) < 1) { - print "Usage: $0 [(-d|--debian)[=example.com[,...]] [(-r|--redhat)[=example.com[,...]]] [...] > /etc/acme/config\n"; +if (scalar(@redhat) < 1 && scalar(@debian) < 1) { + print "Usage: $0 [(-d|--debian)[=example.com[,...]] [(-r|--redhat)[=example.com[,...]]] [(-t|--term)[=https://letsencrypt.org/documents/LE-SA-v1.1.1-August-1-2016.pdf]] [...] > /etc/acme/config\n"; exit EXIT_FAILURE; } diff --git a/acmeconf.1 b/acmeconf.1 index 4fdc92a..beda4b5 100644 --- a/acmeconf.1 +++ b/acmeconf.1 @@ -1,20 +1,22 @@ .\" Manpage for acmeconf. .\" Contact acme@rapsys.eu to correct errors or typos. -.TH man 1 "05 Apr 2017" "0.7" "acmeconf man page" +.TH man 1 "20 Jun 2017" "0.8" "acmeconf man page" .SH NAME acmeconf \- create a new configuration template .SH SYNOPSIS -acmeconf [-(r|-redhat|d|-debian)] [example.com[,www.example.com,...]] [...] +acmeconf [-(r|-redhat|d|-debian)] [example.com[,www.example.com,...]] [-(t|-term)[=https://letsencrypt.org/documents/LE-SA-v1.1.1-August-1-2016.pdf]] [...] > /etc/acme/config .SH DESCRIPTION acmeconf is a basic script generating a template configuration for generating letsencrypt certificate. .SH OPTIONS -The acmeconf takes options. +The acmeconf script takes multiple options. Use -r or --redhat directive for a distribution using redhat certificate path (/etc/pki/tls). Use -d or --debian for a distribution using debian like certificate base path (/etc/ssl). These directives can be followed by domain and alternative(s) domain(s) list each separated by a coma, the first one of the list will be used as principal domain name. + +Use -t or --term for letsencrypt license agreement. .SH EXAMPLE 1 .TP .B acmeconf -r example.com,www.example.com,ssl.example.com > /etc/acme/config diff --git a/acmecron b/acmecron index 0ffeaad..365399e 100755 --- a/acmecron +++ b/acmecron @@ -20,21 +20,16 @@ use strict; use warnings; # Fix use of acl -use filetest 'access'; +use filetest qw(access); # Load dependancies -use Carp qw(carp confess); -use DateTime; -use File::Path qw(make_path); use File::stat qw(stat); -use File::Spec; -use File::Slurp qw(read_file write_file); +use File::Slurp qw(read_file); use JSON qw(decode_json); -use IPC::System::Simple qw(capturex $EXITVAL); -use Acme qw(CERT_DIR CONFIG DS KEY_DIR SERVER_CRT SERVER_KEY ACCOUNT_KEY); +use Acme; # Load POSIX -use POSIX qw(strftime EXIT_SUCCESS EXIT_FAILURE); +use POSIX qw(EXIT_SUCCESS EXIT_FAILURE); # Init debug my $debug = 0; @@ -42,21 +37,44 @@ 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; -# Check config file -if (! -f CONFIG) { - print 'Config file '.CONFIG.' do not exists'."\n"; - exit EXIT_FAILURE; +# 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(CONFIG)) && + ($config = read_file($configFilename)) && # Decode it ($config = decode_json($config)) && # Check hash validity @@ -69,120 +87,70 @@ unless ( ! scalar map {unless(defined($_->{cert}) && defined($_->{key}) && defined($_->{mail}) && defined($_->{domain}) && defined($_->{domains})) {1;} else {();}} @{$config->{certificates}} } ) { - print 'Config file '.CONFIG.' is not readable or invalid'."\n"; + print 'Config file '.$configFilename.' is not readable or invalid'."\n"; exit EXIT_FAILURE; } -# Deal with certificates -foreach (@{$config->{certificates}}) { - # Init variables - my ($mtime, $dt, $suffix) = undef; - - # Skip certificate without 60 days - if (-f $_->{cert} && ($mtime = stat($_->{cert})->mtime) >= (time() - 60*24*3600)) { - next; - } - - # Extract cert directory and filename - my (undef, $certDir) = File::Spec->splitpath($_->{cert}); - - # Check that certificate is writable - unless (-w $certDir || -w $_->{cert}) { - carp('directory '.$certDir.' or file '.$_->{cert}.' must be writable: '.$!); - next; - } - - # Check that key directory exists - if (! -d KEY_DIR) { - # Create all paths - make_path(KEY_DIR, {error => \my $err}); - if (@$err) { - map { - my ($file, $msg) = %$_; - carp ($file eq '' ? '' : $file.': ').$msg if ($debug); - } @$err; - confess 'make_path failed'; +# 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; + } } - } - - # Unlink if is a symlink - if (-l KEY_DIR.DS.SERVER_KEY) { - unless(unlink(KEY_DIR.DS.SERVER_KEY)) { - carp('unlink '.KEY_DIR.DS.SERVER_KEY.' failed: '.$!); - next; + unless($found) { + print 'Domain '.$domain.' not found in config file '.$configFilename."\n"; + exit EXIT_FAILURE; } } - - # Symlink to key - unless(symlink($_->{key}, KEY_DIR.DS.SERVER_KEY)) { - carp('symlink '.$_->{key}.' to '.KEY_DIR.DS.SERVER_KEY.' failed: '.$!); - next; +# Without it +} else { + # Populate domains array with available ones + foreach my $certificate (@{$config->{certificates}}) { + push(@domains, $certificate); } +} - # Unlink if is a symlink - if (-l KEY_DIR.DS.ACCOUNT_KEY) { - unless(unlink(KEY_DIR.DS.ACCOUNT_KEY)) { - carp('unlink '.KEY_DIR.DS.ACCOUNT_KEY.' failed: '.$!); - next; - } - } +# Show usage +if (scalar(@domains) < 1) { + print "Usage: $0 [-(c|-config)[=/etc/acme/config]] [example.com] [...]\n"; + exit EXIT_FAILURE; +} - # Symlink to key - unless(symlink($_->{account}, KEY_DIR.DS.ACCOUNT_KEY)) { - carp('symlink '.$_->{account}.' to '.KEY_DIR.DS.ACCOUNT_KEY.' failed: '.$!); +# Deal with each domain +foreach my $domain (@domains) { + # Skip certificate without 60 days + if (-f $domain->{cert} && stat($domain->{cert})->mtime >= (time() - 60*24*3600)) { next; } - # Init args - my @args = @{$_->{domains}}; - - # Prepend mail and domain to other args - unshift(@args, $_->{mail}, $_->{domain}); - - # Preprend prod - if (defined $_->{prod} && $_->{prod}) { - unshift(@args, '-p'); - } + # Create new object + my $acme = Acme->new($debug, $domain, {thumbprint => $config->{thumbprint}, pending => $config->{pending}, term => $config->{term}}); - # Preprend debug - if ($debug) { - unshift(@args, '-d'); - } + # Prepare environement + $acme->prepare(); - # Run acmecert with args - my @out = capturex([0..1], 'acmecert', @args); + # Generate required keys + $acme->genKeys(); - # Deal with error - if ($EXITVAL != 0) { - print join("\n", @out) if ($debug); - carp('acmecert '.join(', ', @args).' failed: '.$!); - next; - } + # Generate csr + $acme->genCsr(); - # Read cert - my $content; - unless($content = read_file(CERT_DIR.DS.SERVER_CRT)) { - carp('read_file '.CERT_DIR.DS.SERVER_CRT.' failed: '.$!); - next; - } + # Directory + $acme->directory(); - # Handle old certificate - if (-w $certDir && -f $_->{cert}) { - # Extract datetime suffix - $suffix = ($dt = DateTime->from_epoch(epoch => $mtime))->ymd('').$dt->hms(''); + # Register + $acme->register(); - # Rename old certificate - unless(rename($_->{cert}, $_->{cert}.'.'.$suffix)) { - carp('rename '.$_->{cert}.' to '.$_->{cert}.'.'.$suffix.' failed: '.$!); - next; - } - } + # Authorize + $acme->authorize(); - # Save cert - unless(write_file($_->{cert}, $content)) { - carp('write_file '.$_->{cert}.' failed: '.$!); - next; - } + # Issue + $acme->issue(); } # Exit with success diff --git a/acmecron.1 b/acmecron.1 index f4d36ab..db610c6 100644 --- a/acmecron.1 +++ b/acmecron.1 @@ -1,14 +1,16 @@ .\" Manpage for acmecron. .\" Contact acme@rapsys.eu to correct errors or typos. -.TH man 1 "05 Apr 2017" "0.7" "acmecron man page" +.TH man 1 "20 Jun 2017" "0.8" "acmecron man page" .SH NAME -acmecron \- generate all certificate listed in configuration file if required +acmecron \- generate all certificate listed in configuration file or provided subset if required .SH SYNOPSIS -acmecron [-d] +acmecron [-d] [-(c|-config)[=acme.config]] [example.com] [...] .SH DESCRIPTION -acmecron is a basic script generating all certificate listed in configuration if not present or older than 60 days. It will run acmecert with right options for every listed certificate if required. +acmecron is a basic script generating all certificate listed in configuration if not present or older than 60 days. .SH OPTIONS -The acmeconf takes one option for enabling debug mode. +The acmecron script takes one option. + +Use -d for debug directive. .SH SEE ALSO acmeconf(1),acmecert(1) .SH BUGS