#! /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;