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