#!/bin/sh #! -*-perl-*- eval 'exec perl -x -wS $0 ${1+"$@"}' if 0; # Copyright (C) 2017, 2018 Sergey Poznyakoff # # 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, 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 . use strict; use feature 'state'; use Protocol::ACME; use Protocol::ACME::Challenge::LocalFile; use Crypt::Format; use Crypt::OpenSSL::PKCS10 qw(:const); use Crypt::OpenSSL::RSA; use Crypt::OpenSSL::X509; use File::Basename; use File::Path qw(make_path); use DateTime::Format::Strptime; use LWP::UserAgent; use LWP::Protocol::https; use Socket qw(inet_ntoa); use Sys::Hostname; use Pod::Usage; use Pod::Man; use Getopt::Long qw(:config gnu_getopt no_ignore_case auto_version); use POSIX qw(strftime time floor); use App::Acmeman::Config; use App::Acmeman::Domain qw(:files); use Data::Dumper; use Text::ParseWords; our $VERSION = '1.10'; =head1 NAME App::Acmeman - manages ACME certificates =head1 SYNOPSIS B [B<-Fadns>] [B<-D> I] [B<-f> I] [B<--alt-names>] [B<--config-file=>I] [B<--debug>] [B<--dry-run>] [B<--force>] [B<--stage>] [B<--time-delta=>I] [I...] B B<--setup> | B<-S> [B<-Fdn>] [B<--config-file=>I] [B<--debug>] [B<--dry-run>] [B<--force>] B [B<-h>] [B<--help>] [B<--usage>] =head1 DESCRIPTION A tool for automatic creation and renewal of ACME (LetsEncrypt) SSL certificates. The list of domains to handle can be obtained from B or B configuration files, or from both. If the default B configuration file doesn't exist, the program scans B configuration files for a list of domains. B is normally run periodically as a cronjob. If you plan to serve SSL protected domains using apache, you can skip right to the B section. The following is a short introduction to the B configuration. For a detailed discussion, see the B section below. The configuration file, B, consists of statements, which have the form B=I>, grouped into sections, declared as B<[I]> (square brackets are part of the syntax). Empty lines and comments (introduced by a hash sign) are ignored. Domains which require LetsEncrypt certificates are declared in B section. Each section introduces a single domain. E.g.: [domain example.com] alt = www.example.com files = default This section instructs B that a certificate is needed for domain B, using B as its alternative name, The B statement identifies the name of a B section containing rules for creating certificate files for that domain. This section must be defined elsewhere in the configuration file. For example: [files default] type = split certificate-file = /etc/ssl/acme/$domain/cert.pem key-file = /etc/ssl/acme/$domain/privkey.pem ca-file = /etc/ssl/acme/$domain/ca.pem argument = $domain This definition tells B that it should store certificate, certficate key, and certificate authority chain in three separate files. Names of these files will be created by replacing the B<$domain> string in the corresponding definition with the domain name from the B section, In fact, the B section above is the default one. It will be created implicitly if no other B section is defined in the configuration file. Moreover, the string B is the default identifier, which is used if the B section lacks the B keyword. The special section B<[core]> contains basic settings that control the program behavior. One of the important settings is B, which declares an external source from which domain settings must be obtained. As of B version 1.05, the following sources are available: B, B, and B. Consider the following configuration: [core] source = apache It instructs B to read domain settings from Apache configuration files. This is basically the configuration that is used in the absense of the configuration file. See the B section for a detailed discussion of this operation mode. =head1 CONFIGURATION Configuration file controls the operation of B. By default, its name is B. If it is absent, B falls back to the legacy operation mode, scanning Apache configuration files for domains that use LetsEncrypt SSL certificates. See the B section below for a detailed description. The configuration file has a traditional line-oriented syntax. Comments are introduced with a hash sign. Empty lines are ignored. Leading and trailing whitespace is removed prior to parsing. Long statements can be split over several physical lines by ending each line excepting the last one with a backslash immediately followed by a newline character. Statements have the following syntax: KEYWORD = VALUES where I stands for a symbolic name consisting of alphanumeric characters, dashes and underscores, and I stands for any sequence of characters. Statements are grouped in sections. Each section is identified by its name and optional arguments. The section begins with the construct [NAME] or, if arguments are present: [NAME ARG1 ARG2 ...] The square brackets are part of the syntax. The statements in the configuration file form a directed graph. Often in this document we will identify the statement by its I, i.e. a list of section name, arguments and the keyword, separated by dots. For example, the path B corresponds to the following configuration file fragment: [files apache] type = single The following describes the available sections and keywords =head2 B<[core]> This section defines the behavior of the program as a whole. =over 4 =item BI Defines the root directory to use instead of the default . Root directory is the directory under which the F<.well-known/acme-challenge> subdirectory is located. =item BI Sets the time window before the actual expiration time, when the certificate becomes eligible for renewal. I is time in seconds. The default value is 86400, which means that B will attempt to renew any certificate that expires within 24 hours. The command line option B<--time-delta> overrides this setting. =item BI Defines the command to be run at the end of the run if at least one certificate has been updated. Normally this command reloads the httpd server (or whatever server is using the certificates). If more than one B statements are defined, they will be run in sequence, in the same order as they appeared in the configuration file. =item BI [I...] Defines additional source of information. B version 1.05 is shipped with three sources: B, B, and B. The B module is an empty source. Command line arguments are ignored. Use this source if all domains are described in the configuration file. The B source module is the default. It scans B configuration files as described in section B. One argument is allowed. If supplied, it defines the apache configuration layout. Allowed values are: B, B, B and B (for Red Hat). Without arguments, the layout will be autodetected. The B source reads domain names from one or more disk files. A mandatory argument specifies the name of the directory where the files are located. This mode is suitable for use with B pattern files. Multiple B statements can be defined. They will be processed sequentially. =item BI Identifies the B<[files]> section which describes how to create certificate files for domains which lack explicit B keyword. Default I is B. See the description of the B statement in B section. =item BI When set to B, it instructs the program to compare the list of alternative names of each certificate with the one gathered from the Apache configuration, and reissue the certificate if the two lists don't match. This uses an ad-hoc logic, due to the deficiency of the underlying X509 module, and therefore is not enabled by default. Valid values for I are: B<1>, B, B, or B, for true, and B<0>, B, B, or B for false (all values case-insensitive). =item BI When set to B (the default), the program will check whether each host name has an A DNS record pointing back to one of the IP addresses of the server. Hostnames which don't satisfy this condition will be ignored. The IP of the server is determined by looking up the A record for its hostname. This can be overridden using the B configuration statement. =item BI Size of the RSA key to use, in bits. Default is 4096. =item B=I [I...] Declares IP address (or addresses) of this server. Use this keyword if the server IP cannot be reliably determined by resolving its hostname. Special I B<$hostip> stands for the IP retreived by resolving the hostname. =back =head2 B<[domain I]> Declares the domain for which a certificate should be maintained. I is the canonical name for the domain. Alternative names can be specified using the B keyword. =over 4 =item BI Identifies the B<[files]> section which describes how to create certificate files for this domain. In the absense of this statement, the B statement from the B<[core]> section will be used. =item BI Defines alternative name for the certificate. Multiple B statements are allowed. =item BI Size of the RSA key to use, in bits. If not set, the B setting is used. =item BI Run I after successful update. If not given, the B commands will be run. =back =head2 B<[files I]> The B section instructs B how to create certificate files. It is applied to particular domains by placing the B> statement in the coresponding domain sections. The I arguments to the keywords below can contain references to a I, which will be replaced by the actual domain name when handling this section for a particular domain. By default, this meta-variable is B<$domain>. =over 4 =item BB|B Type of the certificate to create. When set to B, a single certificate file will be created. Its name is determined by the B statement. The file will contain the certificate, certificate chain and the signature, in this order. When set to B, the certificate, certificate chain and the signature will be saved to three distinct files, whose names are defined by B, B, and B, correspondingly. If B is not defined, only certificate and key files will be created. The default is B. =item BI Defines the name of the certificate file for this domain. This statement is mandatory. =item BI Defines the name of the certificate key file. This statement must be present if B is set to B. =item BI Defines the name of the certificate authority file. This statement may be present if B is set to B. =item BI Defines the name of the meta-variable in I arguments, which will be replaced with the actual domain name. Default is B<$domain>. =back =head1 SOURCES =head2 null [core] source = null Declares empty source. This means that B will handle only domain names explicitly declared in the configuration file using the B setting. =head2 apache [core] source = apache [--server-root=DIR] [LAYOUT] This is the default source. It assumes Apache httpd, version 2.4 or later (although only minor changes are necessary to make it work with version 2.2). The optional I argument defines the layout of the apache configuration files. Allowed layout values are: B, B, B and B (for Red Hat). If not supplied, the layout is determined automatically. Use the B<--server-root> option to supply the name of the server root directory, if for some reason the module is unable to determine it automatically. A special directory should be configured for receiving ACME challenges. The package provides two Apache macros: for serving ACME challenges and declaring SSL virtual hosts. Upon startup the program scans Apache configuration for virtual hosts that use ACME certificates, checks their expiration times, and renews those of the certificates that are nearing their expiration times within a predefined number of seconds (24 hours by default). If any of the certificates were updated during the run, B will restart the B server. =head3 Setup To set up the necessary infrastructure, run B. It will create the configuration file B, defining two macros for SSL-enabled sites (B is needed). Finally, it will create the directory B, which will be used for receiving and serving ACME challenges. If another directory is preferred, it can be specified as an argument to B. The tool will try to determine the layout of the Apache configuration files and place the created file accordingly, so that it will be included into the main configuration file. It will print the name of the created file at the end of the run. You are advised to ensure that the file is included and that the module B is loaded prior to it. You may also wish to revise B and edit the paths to SSL files configured there. By default, the directory F> will be created for each domain name needing SSL, and two files will be placed there: F, containing the leaf and intermediate certificates for that domain, and F, containing the private key for that domain. The program will refuse to overwrite existing files B, unless given the B<--force> (B<-F>) option. =head3 Configuring SSL To declare that a virtual host needs SSL certificate, add the following line to the Apache B block serving plain HTTP for that host: Use LetsEncryptChallenge This will instruct B to request a certificate for that virtual host. The hostname declared with the B statement will be used as the B for the certificate, and any names declared via B statements will form the list of alternative names (obviously, wildcards are not allowed). If such a certificate doesn't exist, it will be requested and created when B is run. To use the created certificate, create a new B block that contains the following statement: Use LetsEncryptServer DOMAIN where I is the name used in the B statement of the plain HTTP configuration. Copy the B statements (if any), and add the rest of configuration statements. Note, that you need not use the B statement, as it will be included when the B macro is expanded. Example: ServerName example.org ServerAlias www.example.com Use LetsEncryptChallenge ... Use LetsEncryptServer example.org ServerAlias www.example.com ... Alternatively, you can use the B macro, which differs from B in that it configures only SSL settings, without the B statement, which therefore must be included explicitly: ServerName example.org ServerAlias www.example.com Use LetsEncryptSSL example.org ... LetsEncrypt limits the number of certificates requested for a single registered domain per week (at the time of this writing - 20). To avoid hitting that limit, you may wish to use the same certificate for different virtual hosts. The special macro B is provided for that purpose. Suppose, for example, that you wish to configure server name B to use the same certificate as B (configured in the example above). You then declare the virtual host for the plain HTTP as follows: ServerName git.example.org Use LetsEncryptReference example.org ... The argument to the B macro indicates the CN name of the certificate to which the current server name (and aliases, if any) are to be added as alternative names. The corresponding virtual host for SSL will use the B macro to configure the correct certificate: ServerName git.example.org Use LetsEncryptSSL example.org ... =head2 file [core] source = file PATTERN [--ignore=RX] [--host=HOST] Domain names will be read from files matching I. The argument can be a single file or directory name, or a valid globbing pattern. If I is a directory name, the module will read all files from that directory, except those matching the following perl regexp: C<^\.|~$|\.bak$|^#.*#$>. The default regexp can be overridden using the B<--ignore> (B<-i>) option. The input files must contain exactly one domain name per line. No empty lines or comments are allowed. The first domain name will become the B of the issued certificate. The rest of domain names will form alternative names. If the B<--host> (B<-h>) option is used, only one certificate will be issued. The I will be used as its B. All the domain names read from the input files will form the list of its alternative names. =head1 OPTIONS =over 4 =item B<-D>, B<--time-delta=>I Sets the time window before the actual expiration time, when the certificate becomes eligible for renewal. I is time in seconds. The default value is 86400, which means that B will attempt to renew any certificate that expires within 24 hours. This option overrides the B configuration setting. =item B<-F>, B<--force> Force renewal of certificates, no matter their expire date. With B<--setup>, force installing the B file even if it already exists. =item B<-a>, B<--alt-names> Compare the list of alternative names of each certificate with the one gathered from the Apache configuration, and reissue the certificate if the two lists don't match. This uses an ad-hoc logic, due to the deficiency of the underlying X509 module, and therefore is not enabled by default. This option overrides the B configuration setting. =item B<-d>, B<--debug> Increase debugging level. Multiple options accumulate. Three debugging levels are implemented: =over 8 =item B<-d> List certificates being renewed. =item B<-dd> List files being created. Show basic information about ACME transactions for each certificate. =item B<-ddd> Verbosely report parsing of Apache configuration files. Show detailed debugging information about ACME transactions for each certificate. =back =item B<-f>, B<--config-file=>I Read configuration from I, instead of the default F. =item B<-n>, B<--dry-run> Don't modify any files, just print what would have been done. Implies B<--debug>. =item B<-S>, B<--setup> Set up the B infrastructure files. =item B<-s>, B<--stage> Use LetsEncrypt staging server. =back The following options are informational: =over 4 =item B<-h> Prints a short usage summary. =item B<--help> Prints detailed user manual. =item B<--usage> Outputs a terse reminder of the command line syntax along with a list of available options. =back =head1 AUTHOR Sergey Poznyakoff =cut my $progname = basename($0); my $progdescr = "manages ACME certificates"; my $debug = 0; my $dry_run; my $acme_host = 'prod'; my %acme_endpoint = (prod => 'acme-v01.api.letsencrypt.org', staging => 'acme-staging.api.letsencrypt.org'); my $letsencrypt_root_cert_url = 'https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem'; my $force = 0; my %select; # Hash of selected domain names my $config_file = '/etc/acmeman.conf'; my $config; my $account_key; my $challenge; use constant { EX_OK => 0, EX_USAGE => 64, EX_DATAERR => 65, EX_NOINPUT => 66, EX_SOFTWARE => 70, EX_OSFILE => 72, EX_CANTCREAT => 73, EX_NOPERM => 77, EX_CONFIG => 78 }; sub error { my $msg = shift; local %_ = @_; print STDERR "$progname: " if defined($progname); print STDERR "$_{prefix}: " if defined($_{prefix}); print STDERR "$msg\n" } sub debug { my $l = shift; error(join(' ',@_), prefix => 'DEBUG') if $debug >= $l; } sub abend { my $code = shift; print STDERR "$progname: " if defined($progname); print STDERR "@_\n"; exit $code; } sub prep_dir { my $dir = dirname(shift); if (! -d $dir) { debug(3, "creating directory $dir"); my @created = make_path("$dir", { error => \my $err } ); if (@$err) { for my $diag (@$err) { my ($file, $message) = %$diag; if ($file eq '') { error($message); } else { error("mkdir $file: $message"); } } exit(EX_CANTCREAT); } } } sub runcmd { my $cmd = shift; debug(1, "running $cmd"); unless ($dry_run) { system($cmd); if ($? == -1) { error("$cmd: failed to execute: $!"); } elsif ($? & 127) { error("$cmd: died on signal ".($? & 127)); } elsif (my $code = ($? >> 8)) { error("$cmd: exited with code $code"); } } } sub debug_to_loglevel { my @lev = ('err', 'info', 'debug'); return $lev[$debug > $#lev ? $#lev : $debug]; } sub make_csr { my ($dom, $keysize) = @_; my $req = Crypt::OpenSSL::PKCS10->new($keysize); $req->set_subject("/CN=".$dom->cn); $req->add_ext(Crypt::OpenSSL::PKCS10::NID_subject_alt_name, join(',', map { "DNS:$_" } $dom->alt)) if $dom->alt > 0; $req->add_ext_final(); $req->sign(); return $req; } sub save_crt { my $domain = shift; my $type = shift; if (my $filename = $domain->file($type)) { debug(3, "writing $filename"); prep_dir($filename); open(my $fd, '>', $filename); foreach my $der (@_) { my $pem = Crypt::Format::der2pem($der, 'CERTIFICATE'); print $fd $pem; print $fd "\n"; } close $fd; return $filename; } } sub selected_domain { my $dom = shift; return 1 unless %select; return grep { $select{$_} } $dom->names; } sub domain_cert_expires { my $domain = shift; my $crt = $domain->certificate_file; if (-f $crt) { my $x509 = Crypt::OpenSSL::X509->new_from_file($crt); my $exts = $x509->extensions_by_name(); if (exists($exts->{subjectAltName})) { my $msg = $config->get(qw(core check-alt-names)) ? 'will renew' : 'use -a to trigger renewal'; my @names = map { s/^DNS://; $_ } split /,\s*/, $exts->{subjectAltName}->to_string(); my @missing; foreach my $vh (sort { length($b) <=> length($a) } $domain->names) { unless (grep { $_ eq $vh } @names) { push @missing, $vh; } } if (@missing) { debug(1, "$crt: the following SANs are missing: " . join(', ', @missing) ."; $msg"); return 1 if $config->get(qw(core check-alt-names)); } } my $expiry = $x509->notAfter(); my $strp = DateTime::Format::Strptime->new( pattern => '%b %d %H:%M:%S %Y %Z', time_zone => 'GMT' ); my $ts = $strp->parse_datetime($expiry)->epoch; my $now = time(); if ($now < $ts) { my $hours = floor(($ts - $now) / 3600); my $in; if ($hours > 24) { my $days = floor($hours / 24); $in = "in $days days"; } elsif ($hours == 24) { $in = "in one day"; } else { $in = "today"; } debug(2, "$crt expires on $expiry, $in"); if ($now + $config->get(qw(core time-delta)) < $ts) { return 0; } else { debug(2, "will renew $crt (expires on $expiry, $in)"); } } else { debug(2, "will renew $crt"); } } return 1; } sub register_domain_certificate { my $domain = shift; my $key_size = $config->get('domain', $domain, 'key-size') || $config->get('core', 'key-size'); if ($debug) { my $crt = $domain->certificate_file; my $alt = join(',', $domain->alt); if (-f $crt) { debug(1, "renewing $crt: CN=$domain, alternatives=$alt, key_size=$key_size"); } else { debug(1, "issuing $crt: CN=$domain, alternatives=$alt, key_size=$key_size"); } } return 1 if $dry_run; $account_key = Crypt::OpenSSL::RSA->generate_key($key_size); my $acme = Protocol::ACME->new( host => $acme_endpoint{$acme_host}, account_key => { buffer => $account_key->get_private_key_string(), format => 'PEM' }, loglevel => debug_to_loglevel() ); eval { $acme->directory(); $acme->register(); $acme->accept_tos(); foreach my $name ($domain->names) { $acme->authz($name); $acme->handle_challenge($challenge); $acme->check_challenge(); $acme->cleanup_challenge($challenge); } my $csr = make_csr($domain, $key_size); my $cert = $acme->sign({ format => 'PEM', buffer => $csr->get_pem_req() }); my $chain = $acme->chain(); if (my $filename = $domain->file(KEY_FILE)) { debug(3, "writing $filename"); prep_dir($filename); my $u = umask(077); $csr->write_pem_pk($filename); umask($u); if ($filename = $domain->file(CA_FILE)) { save_crt($domain, CA_FILE, $chain); } save_crt($domain, CERT_FILE, $cert); } else { $filename = $domain->certificate_file; debug(3, "writing $filename"); prep_dir($filename); my $u = umask(077); open(my $fd, '>', $filename) or abend(EX_CANTCREAT, "can't open $filename for writing: $!"); print $fd Crypt::Format::der2pem($cert, 'CERTIFICATE'); print $fd "\n"; print $fd Crypt::Format::der2pem($chain, 'CERTIFICATE'); print $fd "\n"; print $fd $csr->get_pem_pk(); print $fd "\n"; umask($u); } }; if ($@) { if (UNIVERSAL::isa($@, 'Protocol::ACME::Exception')) { error("$domain: can't renew certificate: $@->{status}"); if (exists($@->{error})) { error("$domain: $@->{error}{status} $@->{error}{detail}"); } else { error("$domain: $@->{detail} $@->{type}"); } } elsif (ref($@) == '') { chomp $@; error("$domain: failed to renew certificate: $@"); } else { error("$domain: failed to renew certificate"); print STDERR Dumper([$@]); } return 0; } return 1; } sub get_root_cert { my $name = shift; prep_dir($name) unless $dry_run; debug(1, "downloading $letsencrypt_root_cert_url to \"$name\""); my $ua = LWP::UserAgent->new; my $response = $ua->get($letsencrypt_root_cert_url); if ($response->is_success) { unless ($dry_run) { open(my $fd, '>', $name) or abend(EX_CANTCREAT, "can't open \"$name\" for writing: $!"); print $fd $response->decoded_content; close $fd; } } else { error("error downloading certificate from $letsencrypt_root_cert_url"); abend(EX_NOINPUT, $response->status_line); } } sub initial_setup { get_root_cert('/etc/ssl/acme/lets-encrypt-x3-cross-signed.pem'); unless ($config->isset(qw(core source))) { require App::Acmeman::Source::Apache; my $src = new App::Acmeman::Source::Apache; $src->configure($config); $config->set(qw(core source), $src) unless $config->success; $config->clrerr; } foreach my $src ($config->get(qw(core source))) { unless ($src->setup(dry_run => $dry_run, force => $force)) { exit(1); } } exit(EX_OK); } sub coalesce { my $ref = shift; debug(2, "coalescing virtual hosts"); my $i = 0; my @domlist; foreach my $ent (sort { $a->{domain} cmp $b->{domain} } map { { ord => $i++, domain => $_ } } @{$ref}) { if (@domlist && $domlist[-1]->{domain}->cn eq $ent->{domain}->cn) { $domlist[-1]->{domain} += $ent->{domain}; } else { push @domlist, $ent; } } @{$ref} = map { $_->{domain} } sort { $a->{ord} <=> $b->{ord} } @domlist; } sub resolve { my $host = shift; if (my @addrs = gethostbyname($host)) { return map { inet_ntoa($_) } @addrs[4 .. $#addrs]; } else { error("$host doesn't resolve"); } return (); } sub myip { my $host = shift; state $ips; unless ($ips) { $ips = {}; my $addhost; if ($config->isset(qw(core my-ip))) { $addhost = 0; foreach my $ip ($config->get(qw(core my-ip))) { if ($ip eq '$hostip') { $addhost = 1; } else { $ips->{$ip} = 1; } } } else { $addhost = 1; } if ($addhost) { foreach my $ip (resolve(hostname())) { $ips->{$ip} = 1; } } } return $ips->{$host}; } sub host_ns_ok { my $host = shift; foreach my $ip (resolve($host)) { return 1 if myip($ip); } return 0 } sub collect { my $aref = shift; return unless $config->isset('domain'); my $err; while (my ($k, $v) = each %{$config->get('domain')}) { my $dom; my $ft; if ($config->get(qw(core check-dns))) { my @res = grep { host_ns_ok($_) } ($k, ($v->{alt} ? @{$v->{alt}} : ())); if (@res) { $k = shift @res; $v->{alt} = @res ? \@res : undef; } else { error("ignoring $k: none of its names resolves to our IP"); next; } } if (exists($v->{files})) { if (my $fref = $config->get('files', $v->{files})) { $dom = new App::Acmeman::Domain( cn => $k, alt => $v->{alt}, postrenew => $v->{postrenew}, %{$fref}); } else { error("files.$v->{files} is referenced from [domain $k], but never declared"); ++$err; next; } } else { $dom = new App::Acmeman::Domain( cn => $k, alt => $v->{alt}, postrenew => $v->{postrenew}, %{$config->get('files', $config->get(qw(core files)))}); } push @$aref, $dom; } exit(1) if $err; } my $setup; my $time_delta; my $check_alt_names; GetOptions("h" => sub { pod2usage(-message => "$progname: $progdescr", -exitstatus => EX_OK); }, "help" => sub { pod2usage(-exitstatus => EX_OK, -verbose => 2); }, "usage" => sub { pod2usage(-exitstatus => EX_OK, -verbose => 0); }, "debug|d+" => \$debug, "dry-run|n" => \$dry_run, "stage|s" => sub { $acme_host = 'staging' }, "force|F" => \$force, "time-delta|D=n" => \$time_delta, "setup|S" => \$setup, "alt-names|a" => \$check_alt_names, "config-file|f=s" => \$config_file ) or exit(EX_USAGE); ++$debug if $dry_run; sub cb_parse_bool { my ($k, $vref) = @_; my %bt = ( 0 => 0, off => 0, false => 0, no => 0, 1 => 1, on => 1, true => 1, yes => 1 ); my $res = $bt{lc($$vref)}; if (defined($res)) { $$vref = $res; return undef; } return "not a boolean: $$vref"; } my %syntax = ( core => { section => { postrenew => { array => 1 }, rootdir => { default => '/var/www/acme' }, files => 1, 'time-delta' => { default => 86400 }, source => { default => [ 'apache' ], array => 1 }, 'check-alt-names' => { default => 0, parser => \&cb_parse_bool }, 'check-dns' => { default => 1, parser => \&cb_parse_bool }, 'my-ip' => { array => 1 }, 'key-size' => { re => '^\d+$', default => 4096 } } }, files => { section => { '*' => { section => { type => { re => 'single|split', default => 'split' }, 'certificate-file' => { mandatory => 1 }, 'key-file' => 1, 'ca-file' => 1, argument => 1, } } } }, domain => { section => { '*' => { section => { alt => { array => 1 }, files => 1, 'key-size' => { re => '^\d+$' }, postrenew => 0 } } } } ); sub file_type_fixup { my $err; $config->set(qw(core files default)) unless $config->isset(qw(core files)); unless ($config->isset(qw(files))) { if ($config->get(qw(core files)) ne 'default') { error("section files." . $config->get(qw(core files))." not defined"); ++$err; } } unless ($config->isset(qw(files default))) { $config->set(qw(files default type), 'split'); $config->set(qw(files default key-file), '/etc/ssl/acme/$domain/privkey.pem'); $config->set(qw(files default certificate-file), '/etc/ssl/acme/$domain/cert.pem'); $config->set(qw(files default ca-file), '/etc/ssl/acme/$domain/ca.pem'); } if ($config->isset(qw(files))) { while (my ($k, $v) = each %{$config->get(qw(files))}) { if ($v->{type} eq 'single') { unless (exists($v->{'certificate-file'})) { error("files.$k.certificate-file not defined"); ++$err; } else { if (exists($v->{'key-file'})) { error("files.$k.key-file ignored"); } if (exists($v->{'ca-file'})) { error("files.$k.ca-file ignored"); } } } else { unless (exists($v->{'key-file'})) { error("files.$k.key-file not defined"); ++$err; } unless (exists($v->{'certificate-file'})) { error("files.$k.ca-file not defined"); ++$err; } } } } if (my $files = $config->get(qw(core files))) { unless ($config->isset('files', $files)) { error("files.$files is referenced from [core], but never declared"); ++$err; } } exit(1) if $err; } my @domlist; @select{map { lc } @ARGV} = (1) x @ARGV; $config = new App::Acmeman::Config($config_file, syntax => \%syntax, defaults => { 'core.source' => 'apache', 'core.key-size' => 4096 }); if ($config->success) { if (my @source = $config->get(qw(core source))) { foreach my $s (@source) { my ($name, @args) = quotewords('\s+', 0, $s); my $pack = 'App::Acmeman::Source::' . ucfirst($name); my $obj = eval "use $pack; new $pack(\@args);"; if ($@) { abend(EX_CONFIG, $@); } $obj->configure($config); $config->set(qw(core source), $obj); } } if ($time_delta) { $config->set(qw(core time-delta), $time_delta); } if ($check_alt_names) { $config->set(qw(core check-alt-names), $check_alt_names); } $config->finalize; } unless ($config->success) { foreach my $err ($config->errors) { error($err); } exit(1); } initial_setup if $setup; file_type_fixup; #print Dumper([$config]);exit; collect \@domlist; debug(1, "nothing to do") unless @domlist; coalesce \@domlist; # Check challenge root directory prep_dir($config->get(qw(core rootdir)).'/file'); # # FIXME Check filename patterns # abend(EX_CONFIG, "filename patterns not defined") # unless (defined($filename_arg) && defined($filename_pattern{cert})); $challenge = Protocol::ACME::Challenge::LocalFile->new({ www_root => $config->get(qw(core rootdir)) }); my $renewed = 0; foreach my $vhost (@domlist) { next unless selected_domain($vhost); if ($force || domain_cert_expires($vhost)) { if (register_domain_certificate($vhost)) { if (my $cmd = $vhost->postrenew) { runcmd($cmd); } else { $renewed++; } } } } if ($renewed) { if ($config->isset(qw(core postrenew))) { foreach my $cmd ($config->get(qw(core postrenew))) { runcmd($cmd); } } else { error("certificates changed, but no postrenew command is defined (core.postrenew)"); } }