aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSergey Poznyakoff <gray@gnu.org.ua>2017-09-13 15:13:34 +0200
committerSergey Poznyakoff <gray@gnu.org.ua>2017-09-13 15:23:44 +0200
commit6599db656e097d8eb22921d2c2ce3451c8147563 (patch)
tree2b04a44ba67b088e0c62ac364c75b72ef928b8a9
parent36f66056a4ddbba8f300ef2fd15955e292e2755f (diff)
downloadacmeman-6599db656e097d8eb22921d2c2ce3451c8147563.tar.gz
acmeman-6599db656e097d8eb22921d2c2ce3451c8147563.tar.bz2
Rewrite
This patch introduces acmeman configuration file, which can be used to direct its action if a server other than Apache is used. It also can be instructed to store certificate, certificate chain, and certificate key in a single file, instead of three different ones. This can be used with such servers as pound(8). In the absense of a configuration file, the program operates as in previous versions. * MANIFEST: Update. * Makefile.PL: Update. * Changes: Update. * acmeman: Use configuration file if present. Apache configuration remains as a default source of TLS domains. Configuration file can override or complement it. * lib/App/Acmeman/Domain.pm: New file. * lib/App/Acmeman/Config.pm: New file. * lib/App/Acmeman/Source/Apache.pm: New file. * lib/App/Acmeman/Apache/Layout.pm: New file.
-rw-r--r--.gitignore1
-rw-r--r--Changes11
-rw-r--r--MANIFEST10
-rw-r--r--Makefile.PL25
-rwxr-xr-xacmeman977
-rw-r--r--lib/App/Acmeman/Apache/Layout.pm117
-rw-r--r--lib/App/Acmeman/Config.pm359
-rw-r--r--lib/App/Acmeman/Domain.pm138
-rw-r--r--lib/App/Acmeman/Source/Apache.pm284
9 files changed, 1493 insertions, 429 deletions
diff --git a/.gitignore b/.gitignore
index 24cd9e0..1bf5703 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
.emacs*
*~
+*.bak
/MYMETA.json
/MYMETA.yml
Makefile
diff --git a/Changes b/Changes
index d61a564..5352924 100644
--- a/Changes
+++ b/Changes
@@ -1,3 +1,14 @@
+1.04 2017-09-13
+
+This version introduces acmeman configuration file, which can be used to
+direct its action if a server other than Apache is used. It also can
+be instructed to store certificate, certificate chain, and certificate
+key in a single file, instead of three different ones. This can be used
+with such servers as pound(8).
+
+In the absense of a configuration file, the program operates as in
+previous versions.
+
1.00 2017-02-09
* Initial version (Git)
diff --git a/MANIFEST b/MANIFEST
index d7ed194..3fa916f 100644
--- a/MANIFEST
+++ b/MANIFEST
@@ -1,6 +1,10 @@
+acmeman
Changes
+inc/ExtUtils/AutoInstall.pm
+lib/App/Acmeman/Apache/Layout.pm
+lib/App/Acmeman/Config.pm
+lib/App/Acmeman/Domain.pm
+lib/App/Acmeman/Source/Apache.pm
LICENSE
Makefile.PL
-MANIFEST This list of files
-inc/ExtUtils/AutoInstall.pm
-acmeman
+MANIFEST This list of files
diff --git a/Makefile.PL b/Makefile.PL
index 1798216..edb81a9 100644
--- a/Makefile.PL
+++ b/Makefile.PL
@@ -1,11 +1,15 @@
# -*- perl -*-
use strict;
use lib 'inc';
+use Module::Metadata;
+
use ExtUtils::AutoInstall (
-core => {
'Getopt::Long' => 2.34,
'File::Path' => 2.08,
'File::Basename' => 2.84,
+ 'Test::NoWarnings' => 0,
+ 'Crypt::RSA::Parse' => 0.043,
'Protocol::ACME' => 1.01,
'Protocol::ACME::Challenge::LocalFile' => 1.01,
'Crypt::Format' => 0.06,
@@ -17,14 +21,29 @@ use ExtUtils::AutoInstall (
'LWP::Protocol::https' => 6.07,
'Pod::Usage' => 1.51,
'Pod::Man' => 2.25,
+ 'Text::ParseWords' => 3.27,
'Data::Dumper' => 0
}
);
-WriteMakefile(NAME => 'acmeman',
+WriteMakefile(NAME => 'App::Acmeman',
ABSTRACT_FROM => 'acmeman',
VERSION_FROM => 'acmeman',
AUTHOR => 'Sergey Poznyakoff <gray@gnu.org>',
- LICENSE => 'gpl',
- EXE_FILES => [ 'acmeman' ]
+ LICENSE => 'gpl_3',
+ EXE_FILES => [ 'acmeman' ],
+ MIN_PERL_VERSION => 5.006,
+ META_MERGE => {
+ 'meta-spec' => { version => 2 },
+ resources => {
+ repository => {
+ type => 'git',
+ url => 'git://git.gnu.org.ua/gsc/acmeman.git',
+ web => 'http://git.gnu.org.ua/cgit/gsc/acmeman.git/',
+ },
+ },
+ provides => Module::Metadata->provides(version => '1.4',
+ dir => 'lib')
+ }
+
);
diff --git a/acmeman b/acmeman
index bef9732..4a28e50 100755
--- a/acmeman
+++ b/acmeman
@@ -30,9 +30,12 @@ use Pod::Usage;
use Pod::Man;
use Getopt::Long qw(:config gnu_getopt no_ignore_case);
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.03';
+our $VERSION = '1.04';
=head1 NAME
@@ -43,32 +46,22 @@ acmeman - manages ACME certificates
B<acmeman>
[B<-Fadns>]
[B<-D> I<N>]
-[B<-I> I<DIR>]
[B<-f> I<FILE>]
-[B<-l> B<slackware>|B<debian>|B<rh>]
[B<--alt-names>]
[B<--config-file=>I<FILE>]
[B<--debug>]
[B<--dry-run>]
[B<--force>]
-[B<--incdir=>I<DIR>]
-[B<--include-directory=>I<DIR>]
-[B<--layout=>B<slackware>|B<debian>|B<rh>]
-[B<--restart=>I<COMMAND>]
[B<--stage>]
[B<--time-delta=>I<N>]
[I<DOMAIN>...]
B<acmeman> B<--setup> | B<-S>
[B<-Fdn>]
-[B<-f> I<FILE>]
-[B<-l> B<slackware>|B<debian>|B<rh>]
[B<--config-file=>I<FILE>]
[B<--debug>]
[B<--dry-run>]
[B<--force>]
-[B<--layout=>B<slackware>|B<debian>|B<rh>]
-[I<DIR>]
B<acmeman>
[B<-h>]
@@ -78,21 +71,258 @@ B<acmeman>
=head1 DESCRIPTION
A tool for automatic creation and renewal of ACME (LetsEncrypt) SSL
-certificates. It assumes that HTTP is served by Apache server, version
-2.4 or later (although only minor changes are necessary to make it work
-with version 2.2). Three most popular layouts of Apache configuration
-files are supported: Debian, Slackware, and Red Hat. A special
-directory should be configured for receiving ACME challenges.
+certificates. The list of domains to handle can be obtained from
+B<acmeman> or B<apache> configuration files, or from both. If the
+default B<acmeman> configuration file doesn't exist, the program
+scans B<apache> configuration files for a list of domains.
+
+B<Acmeman> is normally run periodically as a cronjob.
+
+If you plan to serve SSL protected domains using apache, you can skip
+right to the B<APACHE> section.
+
+The following is a short introduction to the B<acmeman> configuration. For
+a detailed discussion, see the B<CONFIGURATION> section below.
+
+The configuration file, B</etc/acmeman.conf>, consists of statements,
+which have the form B<I<KW>=I<VAL>>, grouped into sections, declared
+as B<[I<NAME>]> (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<domain>
+section. Each section introduces a single domain. E.g.:
+
+ [domain example.com]
+ alt = www.example.com
+ files = default
+
+This section instructs B<acmeman> that a certificate is needed for
+domain B<example.com>, using B<www.example.com> as its alternative name,
+The B<files> statement identifies the name of a B<files> 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<acmeman> 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<domain> section,
+
+In fact, the B<files> section above is the default one. It will be created
+implicitly if no other B<files> section is defined in the configuration file.
+Moreover, the string B<default> is the default identifier, which is used if
+the B<domain> section lacks the B<files> keyword.
+
+The special section B<[core]> contains basic settings that control the
+program behavior. One of the important settings is B<source>, which declares
+an external source from which domain settings must be obtained. As
+of B<acmeman> version 2.00, the only available external source is B<apache>.
+Consider the following configuration:
+
+ [core]
+ source = apache
+
+It instructs B<acmeman> 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<APACHE> section for a detailed discussion
+of this operation mode.
+
+=head1 CONFIGURATION
+
+Configuration file controls the operation of B<acmeman>. By default,
+its name is B</etc/acmeman.conf>. If it is absent, B<acmeman> falls
+back to the legacy operation mode, scanning Apache configuration files
+for domains that use LetsEncrypt SSL certificates. See the B<APACHE>
+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<KEYWORD> stands for a symbolic name consisting of alphanumeric
+characters, dashes and underscores, and I<VALUE> 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<path>, i.e. a
+list of section name, arguments and the keyword, separated by dots. For
+example, the path B<files.apache.type> corresponds to the following
+configuration file fragment:
+
+ [file 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 B<rootdir=>I<DIR>
+
+Defines the root directory to use instead of the default </var/www/acme>.
+Root directory is the directory under which the
+F<.well-known/acme-challenge> subdirectory is located.
+
+=item B<time-delta=>I<SECONDS>
+
+Sets the time window before the actual expiration time, when the certificate
+becomes eligible for renewal. I<N> is time in seconds. The default
+value is 86400, which means that B<acmeman> will attempt to renew any
+certificate that expires within 24 hours.
+
+The command line option B<--time-delta> overrides this setting.
+
+=item B<restart=>I<COMMAND>
+
+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<restart> statements are defined, they will be run in sequenct in the
+same order as they appeared in the configuration file.
+
+=item B<source=>I<ID> [I<LAYOUT>]
+
+Defines additional source of information. As of B<acmeman> version 2.00,
+only one I<ID> is supported: B<apache>. This source module scans apache
+configuration files as described in section B<APACHE>. The optional
+I<LAYOUT> argument defines the apache configuration layout. Allowed values
+are: B<debian>, B<slackware>, B<suse> and B<rh> (for Red Hat). If I<LAYOUT>
+is absent, it will be autodetected.
+
+=item B<files=>I<NAME>
+
+Identifies the B<[files]> section which describes how to create certificate
+files for domains which lack explicit B<files> keyword. Default I<NAME> is
+B<default>. See the description of the B<files> statement in B<domain>
+section.
+
+=item B<check-alt-names=>I<BOOL>
+
+When set to B<true>, 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<BOOL> are: B<1>, B<on>, B<true>, or B<yes>, for
+true, and B<0>, B<off>, B<false>, or B<no> for false (all values
+case-insensitive).
+
+=back
+
+=head2 B<[domain I<CN>]>
+
+Declares the domain for which a certificate should be maintained. I<CN> is
+the canonical name for the domain. Alternative names can be specified using
+the B<alt> keyword.
+
+=over 4
+
+=item B<files=>I<ID>
+
+Identifies the B<[files]> section which describes how to create certificate
+files for this domain. In the absense of this statement, the B<files>
+statement from the B<[core]> section will be used.
+
+=item B<alt=>I<NAME>
+
+Defines alternative name for the certificate. Multiple B<alt> statements
+are allowed.
+
+=back
+
+=head2 B<[files I<ID>]>
+
+The B<files> section instructs B<acmeman> how to create certificate files.
+It is applied to particular domains by placing the B<files=I<ID>> statement
+in the coresponding domain sections.
+
+The I<FILENAME> arguments to the keywords below can contain references to
+a I<meta-variable>, 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 B<type=>B<single>|B<split>
+
+Type of the certificate to create. When set to B<single>, a single
+certificate file will be created. Its name is determined by the
+B<certificate-file> statement. The file will contain the certificate,
+certificate chain and the signature, in this order.
+
+When set to B<split>, the certificate, certificate chain and the signature
+will be saved to three distinct files, whose names are defined by
+B<certificate-file>, B<ca-file>, and B<key-file>, correspondingly. If
+B<ca-file> is not defined, only certificate and key files will be created.
+
+The default is B<split>.
+
+=item B<certificate-file=>I<FILENAME>
+
+Defines the name of the certificate file for this domain. This statement
+is mandatory.
+
+=item B<key-file=>I<FILENAME>
+
+Defines the name of the certificate key file. This statement must be present
+if B<type> is set to B<split>.
+
+=item B<ca-file=>I<FILENAME>
+
+Defines the name of the certificate authority file. This statement may
+be present if B<type> is set to B<split>.
+
+=item B<argument=>I<STRING>
+
+Defines the name of the meta-variable in I<FILENAME> arguments, which will
+be replaced with the actual domain name. Default is B<$domain>.
+
+=back
+
+=head1 APACHE
+
+This is the default mode. It assumes Apache httpd, version 2.4 or later
+(although only minor changes are necessary to make it work with version 2.2).
+Four most popular layouts of Apache configuration files are supported:
+Debian, Slackware, SuSe, and Red Hat. 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.
-B<Acmeman> should be started periodically, as a cronjob. Upon startup,
-it scans Apache configuration for virtual hosts using 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<acmeman> will restart the B<httpd> server.
+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<acmeman> will restart the
+B<httpd> server.
=head2 Setup
@@ -114,12 +344,6 @@ will be created for each domain name needing SSL, and two files will be placed
there: F<cert.pem>, containing the leaf and intermediate certificates for that
domain, and F<privkey.pem>, containing the private key for that domain.
-If the program is unable to determine the Apache configuration layout, you can
-declare it using the B<--layout> option. It takes a single argument, one of:
-B<debian>, B<slackware>, or B<rh> (all lower case). Alternatively, you can
-specify the location of the main Apache configuration file, using the
-B<--config-file> option.
-
The program will refuse to overwrite existing files B<httpd-letsencrypt.conf>,
unless given the B<--force> (B<-F>) option.
@@ -206,12 +430,14 @@ will use the B<LetsEncryptSSL> macro to configure the correct certificate:
=over 4
-=item B<-D>, B<--tile-delta=>I<N>
+=item B<-D>, B<--time-delta=>I<N>
Sets the time window before the actual expiration time, when the certificate
becomes eligible for renewal. I<N> is time in seconds. The default
value is 86400, which means that B<acmeman> will attempt to renew any
certificate that expires within 24 hours.
+
+This option overrides the B<core.time-delta> configuration setting.
=item B<-F>, B<--force>
@@ -219,11 +445,6 @@ Force renewal of certificates, no matter their expire date. With B<--setup>,
force installing the B<httpd-letsencrypt.conf> file even if it already
exists.
-=item B<-I>, B<--incdir>, B<--include-directory=>I<DIR>
-
-Specifies base directory for Apache B<Include> and B<IncludeOptional>
-statements with relative pathnames.
-
=item B<-a>, B<--alt-names>
Compare the list of alternative names of each certificate with the one
@@ -231,6 +452,8 @@ 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<core.check-alt-names> configuration setting.
=item B<-d>, B<--debug>
@@ -257,22 +480,13 @@ debugging information about ACME transactions for each certificate.
=item B<-f>, B<--config-file=>I<FILE>
-Read I<FILE> as main Apache configuration file.
-
-=item B<-l>, B<--layout=>I<NAME>
-
-Defines Apache configuration file layout. Valid values for I<NAME> are:
-B<slackware>, B<debian>, and B<rh> (for Red Hat).
+Read configuration from I<FILE>, instead of the default F</etc/acmeman.conf>.
=item B<-n>, B<--dry-run>
Don't modify any files, just print what would have been done.
Implies B<--debug>.
-=item B<--restart=>I<COMMAND>
-
-Specifies the command to restart Apache daemon.
-
=item B<-S>, B<--setup>
Set up the B<acmeman> infrastructure files.
@@ -317,18 +531,11 @@ 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 $time_delta = 86400;
my $force = 0;
-my $check_alt_names = 0;
my %select; # Hash of selected domain names
-# Variables read from the Apache configuration files
-my $server_root;
-my $www_root; # Root directory for ACME challenges
-my @virthost; # List of virtual hosts using ACME certificates
-my $filename_arg; # Name of the formal argument to the LetsEncrypServer
-my %filename_pattern; # File name pattern.
- # Valid keys are: cert, key and chain.
+my $config_file = '/etc/acmeman.conf';
+my $config;
my $account_key;
my $challenge;
@@ -365,15 +572,6 @@ sub abend {
exit $code;
}
-sub make_filename {
- my ($type, $arg) = @_;
- abend(EX_SOFTWARE, "no $type in \%filename_pattern")
- unless exists $filename_pattern{$type};
- my $res = $filename_pattern{$type};
- $res =~ s{$filename_arg}{$arg}g;
- return $res;
-}
-
sub prep_dir {
my $dir = dirname(shift);
if (! -d $dir) {
@@ -385,7 +583,7 @@ sub prep_dir {
if ($file eq '') {
error($message);
} else {
- error("problem unlinking $file: $message");
+ error("mkdir $file: $message");
}
}
exit(EX_CANTCREAT);
@@ -400,50 +598,51 @@ sub debug_to_loglevel {
}
sub make_csr {
- my $cn = shift;
+ my $dom = shift;
my $req = Crypt::OpenSSL::PKCS10->new(4096);
- $req->set_subject("/CN=$cn");
+ $req->set_subject("/CN=".$dom->cn);
$req->add_ext(Crypt::OpenSSL::PKCS10::NID_subject_alt_name,
- join(',', map { "DNS:$_" } @_)) if @_;
+ join(',', map { "DNS:$_" } $dom->alt))
+ if $dom->alt > 0;
$req->add_ext_final();
$req->sign();
- if (exists($filename_pattern{key})) {
- my $filename = make_filename('key', $cn);
- debug(3, "writing $filename");
- prep_dir($filename);
- my $u = umask(077);
- $req->write_pem_pk($filename);
- umask($u);
- }
- return $req->get_pem_req();
+ return $req;
}
sub save_crt {
- my $type = shift;
my $domain = shift;
+ my $type = shift;
- my $filename = make_filename($type, $domain);
- debug(3, "writing $filename");
- prep_dir($filename);
- open(my $fd, '>', $filename);
+ 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";
+ foreach my $der (@_) {
+ my $pem = Crypt::Format::der2pem($der, 'CERTIFICATE');
+ print $fd $pem;
+ print $fd "\n";
+ }
+ close $fd;
+ return $filename;
}
- close $fd;
+}
+
+sub selected_domain {
+ my $dom = shift;
+ return 1 unless %select;
+ return grep { $select{$_} } $dom->names;
}
sub domain_cert_expires {
my $domain = shift;
- my $crt = make_filename('cert', $domain);
+ 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 = $check_alt_names
+ my $msg = $config->get(qw(core check-alt-names))
? 'will renew' : 'use -a to trigger renewal';
# FIXME: Crypt::OpenSSL::X509 returns extensions as strings,
# instead of as ASN.1 objects. Until it is fixed, the
@@ -451,8 +650,7 @@ sub domain_cert_expires {
# names:
my $blob = $exts->{subjectAltName}->value();
my @missing;
- foreach my $vh (map { lc } sort { length($b) <=> length($a) }
- uniq($domain, @_)) {
+ foreach my $vh (sort { length($b) <=> length($a) } $domain->names) {
unless ($blob =~ s/\Q$vh\E\b//) {
push @missing, $vh;
}
@@ -461,7 +659,7 @@ sub domain_cert_expires {
debug(1, "$crt: the following SANs are missing: "
. join(', ', @missing)
."; $msg");
- return 1 if $check_alt_names;
+ return 1 if $config->get(qw(core check-alt-names));
}
}
@@ -485,7 +683,7 @@ sub domain_cert_expires {
$in = "today";
}
debug(2, "$crt expires on $expiry, $in");
- if ($now + $time_delta < $ts) {
+ if ($now + $config->get(qw(core time-delta)) < $ts) {
return 0;
} else {
debug(2, "will renew $crt (expires on $expiry, $in)");
@@ -501,20 +699,19 @@ sub register_domain_certificate {
my $domain = shift;
if ($debug) {
- my $crt = make_filename('cert', $domain);
+ my $crt = $domain->certificate_file;
+ my $alt = join(',', $domain->alt);
if (-f $crt) {
- debug(1, "renewing $crt: CN=$domain, alternatives=@_");
+ debug(1, "renewing $crt: CN=$domain, alternatives=$alt");
} else {
- debug(1, "issuing $crt: CN=$domain, alternatives=@_");
+ debug(1, "issuing $crt: CN=$domain, alternatives=$alt");
}
}
return 1 if $dry_run;
my $acme = Protocol::ACME->new(
host => $acme_endpoint{$acme_host},
- account_key => {
- buffer => $account_key->get_private_key_string(),
- format => 'PEM' },
+ account_key => { buffer => $account_key->get_private_key_string(), format => 'PEM' },
loglevel => debug_to_loglevel()
);
@@ -523,21 +720,43 @@ sub register_domain_certificate {
$acme->register();
$acme->accept_tos();
- foreach my $name ($domain, @_) {
+ foreach my $name ($domain->names) {
$acme->authz($name);
$acme->handle_challenge($challenge);
$acme->check_challenge();
$acme->cleanup_challenge($challenge);
}
- my $csr = make_csr($domain, @_);
- my $cert = $acme->sign({ format => 'PEM', buffer => $csr });
+
+ my $csr = make_csr($domain);
+ my $cert = $acme->sign({ format => 'PEM', buffer => $csr->get_pem_req() });
my $chain = $acme->chain();
- if (exists($filename_pattern{chain})) {
- save_crt('cert', $domain, $cert);
- save_crt('chain', $domain, $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 {
- save_crt('cert', $domain, $cert, $chain);
- }
+ $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')) {
@@ -559,251 +778,44 @@ sub register_domain_certificate {
return 1;
}
-sub dequote {
- my ($arg) = @_;
- $arg =~ s{^"(.*?)"$}{$1};
- return $arg;
-}
-
-sub uniq {
- my %h;
- map {
- if (exists($h{$_})) {
- ()
- } else {
- $h{$_} = 1;
- $_
- }
- } @_;
-}
-
-sub examine_http_config {
- my ($file) = @_;
-
- use constant {
- STATE_INITIAL => 0, # Initial state
- STATE_VIRTUAL_HOST => 1, # In VirtualHost block
- STATE_USE_CHALLENGE => 2, # Ditto, but challenge macro was used
- STATE_USE_REFERENCE => 3, # Ditto, but reference macro was used
- STATE_MACRO_CHALLENGE => 4, # In LetsEncryptChallenge macro
- STATE_MACRO_SSL => 5
- };
-
- state $state = STATE_INITIAL;
-
- debug(3, "reading apache configuration file \"$file\"");
- if (open(my $fd, '<', $file)) {
- my $server_name;
- my @server_aliases;
- my $reference;
- my $line;
-
- while (<$fd>) {
- ++$line;
- chomp;
- s/^\s+//;
- next if /^(#.*)?$/;
- if (/^include(optional)?\s+(.+?)\s*$/i) {
- debug(3, "$file:$line: state $state: Include$1 $2");
- http_include(dequote($2), $1 ne '');
- next;
- }
-
- if ($state == STATE_INITIAL) {
- if (/^<VirtualHost/i) {
- $state = STATE_VIRTUAL_HOST;
- $server_name = undef;
- @server_aliases = ();
- $reference = undef;
- } elsif (/^ServerRoot\s+(.+)/i) {
- $server_root = dequote($1);
- } elsif (/^<(?:(?i)Macro)\s+LetsEncryptChallenge/) {
- $state = STATE_MACRO_CHALLENGE;
- } elsif (/^<(?:(?i)Macro)\s+LetsEncryptSSL\s+(.+?)\s*>/) {
- $state = STATE_MACRO_SSL;
- $filename_arg = $1;
- $filename_arg =~ s{\$}{\\\$};
- }
- } elsif ($state == STATE_VIRTUAL_HOST
- || $state == STATE_USE_CHALLENGE
- || $state == STATE_USE_REFERENCE) {
- if (m{</VirtualHost}i) {
- unshift @server_aliases, $server_name
- if defined $server_name;
- if (@server_aliases
- && (!%select || exists($select{$server_name}))) {
- if ($state == STATE_USE_CHALLENGE) {
- my @names = uniq(@server_aliases);
- debug(3, "$file:$line: will handle @names");
- push @virthost, \@names;
- } elsif ($state == STATE_USE_REFERENCE) {
- my @names = uniq(@server_aliases);
- unshift @names, $reference;
- debug(3, "$file:$line: reference: @names");
- push @virthost, \@names;
- }
- }
- $state = STATE_INITIAL;
- } elsif (/^(?:(?i)Use)\s+LetsEncryptChallenge/) {
- if ($state == STATE_VIRTUAL_HOST) {
- $state = STATE_USE_CHALLENGE;
- } elsif ($state == STATE_USE_CHALLENGE) {
- error("$file:$line: duplicate use of LetsEncryptChallenge");
- } else {
- error("$file:$line: LetsEncryptChallenge and LetsEncryptReference can't be used together");
- }
- } elsif (/^(?:(?i)Use)\s+LetsEncryptReference\s+(.+)\s*$/) {
- if ($state == STATE_VIRTUAL_HOST) {
- $state = STATE_USE_REFERENCE;
- $reference = $1;
- } elsif ($state == STATE_USE_REFERENCE) {
- error("$file:$line: duplicate use of LetsEncryptReference");
- } else {
- error("$file:$line: LetsEncryptChallenge and LetsEncryptReference can't be used together");
- }
- } elsif (/^(?:(?i)ServerName)\s+(\S+)/) {
- $server_name = dequote($1);
- } elsif (/^(?:(?i)ServerAlias)\s+(.+)\s*$/) {
- push @server_aliases,
- map { /^\*\./ ? () : dequote($_) } split /\s+/, $1;
- }
- } elsif ($state == STATE_MACRO_CHALLENGE) {
- if (m{^</macro}i) {
- $state = STATE_INITIAL;
- } elsif (m{^(?:(?i)Alias)\s+/.well-known/acme-challenge\s+(.+)}) {
- my $dir = dequote($1);
- $dir =~ s{/.well-known/acme-challenge$}{};
- $www_root = $dir;
- debug(3, "ACME challenge root dir: $www_root");
- }
- } elsif ($state == STATE_MACRO_SSL) {
- if (m{^</macro}i) {
- $state = STATE_INITIAL;
- } elsif (/(?:(?i)SSLCertificate((?:Key)|(?:Chain))?File)\s+(.+)/) {
- $filename_pattern{lc($1 ? $1 : 'cert')} = $2;
- }
- }
- }
- close $fd;
- } else {
- error("can't open file \"$file\": $!");
- return 0;
- }
- return 1;
-}
-
-sub http_include {
- my ($pattern, $optional) = @_;
- $pattern = "$server_root/$pattern" unless $pattern =~ m{^/};
- $pattern =~ s{/*$}{};
- $pattern .= '/*' if -d $pattern;
- foreach my $file (glob $pattern) {
- if ($optional && ! -e $file) {
- debug(1, "optional include file \"$file\" doesn't exist");
- next;
- }
- examine_http_config($file);
- }
-}
-# ##
-my $apache_layout;
-
-sub incdir {
- if (exists($apache_layout->{incdir})) {
- if (ref($apache_layout->{incdir}) eq 'CODE') {
- return &{$apache_layout->{incdir}};
- } else {
- return $apache_layout->{incdir};
- }
- }
- return dirname($apache_layout->{config});
-}
-
sub get_root_cert {
my $name = shift;
- prep_dir($name);
+
+ 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) {
- open(my $fd, '>', $name)
- or abend(EX_CANTCREAT, "can't open \"$name\" for writing: $!");
- print $fd $response->decoded_content;
- close $fd;
+ 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 {
- if (defined($www_root)) {
- error("acmeman macros seem to be already installed");
- abend(EX_NOPERM, "use --force to continue") unless $force;
+ get_root_cert('/etc/ssl/acme/lets-encrypt-x3-cross-signed.pem');
+ my $source = $config->get(qw(core source));
+ unless (defined($source)) {
+ require App::Acmeman::Source::Apache;
+ $source = new App::Acmeman::Source::Apache;
+ $source->configure($config);
+ $source = undef unless $config->success;
+ $config->clrerr;
}
- if ($#ARGV == -1) {
- $www_root = '/var/www/acme';
- } elsif ($#ARGV == 0) {
- $www_root = $ARGV[0];
- } else {
- abend(EX_USAGE, "too many arguments in setup mode");
- }
- my $filename = incdir() . "/httpd-letsencrypt.conf";
- if (-e $filename) {
- error("the file \"$filename\" already exists");
- abend(EX_NOPERM, "use --force to continue") unless $force;
- }
- debug(2, "writing $filename");
- unless ($dry_run) {
- prep_dir($filename);
- open(my $fd, '>', $filename)
- or abend(EX_CANTCREAT, "can't open \"$filename\" for writing: $!");
- print $fd <<EOT;
-<Macro LetsEncryptChallenge>
- Alias /.well-known/acme-challenge $www_root/.well-known/acme-challenge
- <Directory $www_root/.well-known/acme-challenge>
- Options None
- Require all granted
- </Directory>
- <IfModule mod_rewrite.c>
- RewriteEngine On
- RewriteRule /.well-known/acme-challenge - [L]
- </IfModule>
-</Macro>
-
-<Macro LetsEncryptReference \$domain>
- Use LetsEncryptChallenge
- Alias /.dummy/\$domain /dev/null
-</Macro>
-
-<Macro LetsEncryptSSL \$domain>
- SSLEngine on
- SSLProtocol all -SSLv2 -SSLv3
- SSLHonorCipherOrder on
- SSLCipherSuite EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:EECDH+RC4:RSA+RC4:!MD5
- SSLCertificateFile /etc/ssl/acme/\$domain/cert.pem
- SSLCertificateKeyFile /etc/ssl/acme/\$domain/privkey.pem
- SSLCACertificateFile /etc/ssl/acme/lets-encrypt-x3-cross-signed.pem
-</Macro>
-
-<Macro LetsEncryptServer \$domain>
- ServerName \$domain
- Use LetsEncryptSSL \$domain
-</Macro>
-
-EOT
-;
- close $fd;
-
- get_root_cert('/etc/ssl/acme/lets-encrypt-x3-cross-signed.pem');
- if (exists($apache_layout->{post_setup})) {
- &{$apache_layout->{post_setup}}($filename);
+ if ($source) {
+ unless ($source->setup(dry_run => $dry_run, force => $force)) {
+ exit(1);
}
}
- error("created file \"$filename\"", prefix => 'NOTE');
- error("please, enable mod_macro and make sure your Apache configuration includes this file", prefix => 'NOTE');
+
exit(EX_OK);
}
@@ -811,51 +823,51 @@ sub coalesce {
my $ref = shift;
debug(2, "coalescing virtual hosts");
my $i = 0;
- my @vhost;
- foreach my $ent (sort { $a->{names}[0] cmp $b->{names}[0] }
- map { { ord => $i++, names => $_ } } @{$ref}) {
- if (@vhost && $vhost[-1]->{names}[0] eq $ent->{names}[0]) {
- shift @{$ent->{names}};
- push @{$vhost[-1]->{names}}, @{$ent->{names}};
+ my @domlist;
+ foreach my $ent (sort { $a->{domain} cmp $b->{domain} }
+ map { { ord => $i++, domain => $_ } } @{$ref}) {
+ if (@domlist && $domlist[-1]->cn eq $ent->cn) {
+ $domlist[-1] += $ent;
} else {
- push @vhost, $ent;
+ push @domlist, $ent;
}
}
- @{$ref} = map { $_->{names} } sort { $a->{ord} <=> $b->{ord} } @vhost;
+ @{$ref} = map { $_->{domain} } sort { $a->{ord} <=> $b->{ord} } @domlist;
}
-
-my %apache_layout_tab = (
- slackware => { config => '/etc/httpd/httpd.conf',
- incdir => '/etc/httpd/extra',
- restart => '/etc/rc.d/rc.httpd restart'
- },
- debian => { config => '/etc/apache2/apache2.conf',
- incdir => sub {
- for my $dir ('/etc/apache2/conf-available',
- '/etc/apache2/conf.d') {
- return $dir if -d $dir;
- }
- warn 'none of the expected configuration directories found; falling back to /etc/apache2';
- return '/etc/apache2';
- },
- restart => 'service apache2 restart',
- post_setup => sub {
- my ($filename) = @_;
- my $dir = dirname($filename);
- my $name = basename($filename);
- if ($dir eq '/etc/apache2/conf-available') {
- chdir('/etc/apache2/conf-enabled');
- symlink "../conf-available/$name", $name;
- }
- }
- },
- rh => { config => '/etc/httpd/conf/httpd.conf',
- incdir => '/etc/httpd/conf.d',
- restart => 'service httpd restart'
+
+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 (exists($v->{files})) {
+ if (my $fref = $config->get('files', $v->{files})) {
+ $dom = new App::Acmeman::Domain(
+ cn => $k,
+ alt => $v->{alt},
+ %{$fref});
+ } else {
+ error("files.$k->{files} is referenced from [domain $k], but never declared");
+ ++$err;
+ next;
+ }
+ } else {
+ $dom = new App::Acmeman::Domain(
+ cn => $k,
+ alt => $v->{alt},
+ %{$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",
@@ -872,81 +884,200 @@ GetOptions("h" => sub {
"stage|s" => sub { $acme_host = 'staging' },
"force|F" => \$force,
"time-delta|D=n" => \$time_delta,
- "layout|l=s" => sub {
- my ($opt, $arg) = @_;
- abend(EX_USAGE, "unknown layout: $arg")
- unless exists $apache_layout_tab{$arg};
- $apache_layout = $apache_layout_tab{$arg};
- },
"setup|S" => \$setup,
- "config-file|f=s" => sub {
- $apache_layout ||= {};
- $apache_layout->{config} = $_[1]
- },
- "restart=s" => sub {
- $apache_layout ||= {};
- $apache_layout->{restart} = $_[1]
- },
- "include-directory|incdir|I=s" => sub {
- $apache_layout ||= {};
- $apache_layout->{incdir} = $_[1]
- },
- "alt-names|a" => \$check_alt_names
+ "alt-names|a" => \$check_alt_names,
+ "config-file|f=s" => \$config_file
) or exit(EX_USAGE);
++$debug if $dry_run;
-unless (defined($apache_layout)) {
- while (my ($name, $layout) = each %apache_layout_tab) {
- if (-f $layout->{config}) {
- debug(2, "assuming Apache layout \"$name\"");
- $apache_layout = $layout;
- last;
+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
+ );
+ if (my $res = $bt{lc($$vref)}) {
+ $$vref = $res;
+ return undef;
+ }
+ return "not a boolean: $$vref";
+}
+
+my %syntax = (
+ core => {
+ section => {
+ restart => { array => 1 },
+ rootdir => { default => '/var/www/acme' },
+ files => 1,
+ 'time-delta' => { default => 86400 },
+ source => 1,
+ 'check-alt-names' => { default => 0, parser => \&cb_check_bool }
+ }
+ },
+ 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
+ }
+ }
+ }
+ }
+);
+
+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;
}
-unless (defined($apache_layout)) {
- abend(EX_OSFILE, "no suitable apache configuration file found");
+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);
}
-$server_root = dirname($apache_layout->{config});
+my @domlist;
+
+@select{map { lc } @ARGV} = (1) x @ARGV;
+
+$config = new App::Acmeman::Config($config_file,
+ syntax => \%syntax,
+ defaults => {
+ 'core.source' => 'apache'
+ });
+if ($config->success) {
+ if (my $source = $config->get(qw(core source))) {
+ my ($name, @args) = quotewords('\s+', 0, $source);
+ 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 ($setup) {
- examine_http_config($apache_layout->{config}) or exit(EX_OSFILE);
- initial_setup
-} else {
- @select{@ARGV} = (1) x @ARGV;
- examine_http_config($apache_layout->{config}) or exit(EX_OSFILE);
+unless ($config->success) {
+ foreach my $err ($config->errors) {
+ error($err);
+ }
+ exit(1);
}
-debug(1, "nothing to do") unless @virthost;
-coalesce \@virthost;
+
+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
-if (defined($www_root)) {
- prep_dir("$www_root/file");
-} else {
- abend(EX_CONFIG, "ACME challenge root directory not defined");
-}
-# Check filename patterns
-abend(EX_CONFIG, "filename patterns not defined")
- unless (defined($filename_arg) && defined($filename_pattern{cert}));
+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}));
$account_key = Crypt::OpenSSL::RSA->generate_key(4096);
-$challenge = Protocol::ACME::Challenge::LocalFile->new({www_root => $www_root});
+$challenge = Protocol::ACME::Challenge::LocalFile->new({
+ www_root => $config->get(qw(core rootdir))
+});
my $renewed = 0;
-foreach my $vhost (@virthost) {
- if ($force || domain_cert_expires(@{$vhost})) {
- $renewed += register_domain_certificate(@$vhost);
+foreach my $vhost (@domlist) {
+ next unless selected_domain($vhost);
+ if ($force || domain_cert_expires($vhost)) {
+ $renewed += register_domain_certificate($vhost);
}
}
if ($renewed) {
- if ($apache_layout->{restart}) {
- debug(1, "running " . $apache_layout->{restart});
- exec($apache_layout->{restart}) unless $dry_run;
+ if ($config->isset(qw(core restart))) {
+ foreach my $cmd ($config->get(qw(core restart))) {
+ debug(1, "running $cmd");
+ system($cmd) unless $dry_run;
+ }
} else {
- error("apache restart needed, but no restart command is defined; use the --restart option");
+ error("certificates changed, but no restart command is defined (core.restart)");
}
}
diff --git a/lib/App/Acmeman/Apache/Layout.pm b/lib/App/Acmeman/Apache/Layout.pm
new file mode 100644
index 0000000..77e2f8f
--- /dev/null
+++ b/lib/App/Acmeman/Apache/Layout.pm
@@ -0,0 +1,117 @@
+package App::Acmeman::Apache::Layout;
+
+use strict;
+use warnings;
+use Carp;
+use File::Basename;
+
+require Exporter;
+our @ISA = qw(Exporter);
+
+my %apache_layout_tab = (
+ slackware => {
+ _config_file => '/etc/httpd/httpd.conf',
+ _incdir => '/etc/httpd/extra',
+ _restart => '/etc/rc.d/rc.httpd restart'
+ },
+ debian => {
+ _config_file => '/etc/apache2/apache2.conf',
+ _incdir => sub {
+ for my $dir ('/etc/apache2/conf-available',
+ '/etc/apache2/conf.d') {
+ return $dir if -d $dir;
+ }
+ carp 'none of the expected configuration directories found; falling back to /etc/apache2';
+ return '/etc/apache2';
+ },
+ _restart => 'service apache2 restart',
+ _post_setup => sub {
+ my ($filename) = @_;
+ my $dir = dirname($filename);
+ my $name = basename($filename);
+ if ($dir eq '/etc/apache2/conf-available') {
+ chdir('/etc/apache2/conf-enabled');
+ symlink "../conf-available/$name", $name;
+ }
+ }
+ },
+ rh => {
+ _config_file => '/etc/httpd/conf/httpd.conf',
+ _incdir => '/etc/httpd/conf.d',
+ _restart => 'service httpd restart'
+ },
+ suse => {
+ _config_file => '/etc/apache2/httpd.conf',
+ _incdir => '/etc/apache2/conf.d',
+ _restart => 'service httpd restart'
+ # or systemctl restart apache2.service
+ }
+);
+
+# new(NAME)
+# new()
+sub new {
+ my $class = shift;
+ my $self = bless { }, $class;
+ my $name;
+
+ if (@_ == 0) {
+ # Autodetect
+ while (my ($n, $layout) = each %apache_layout_tab) {
+ if (-f $layout->{_config_file}) {
+ debug(2, "assuming Apache layout \"$n\"");
+ $name = $n;
+ last;
+ }
+ }
+ croak "unrecognized Apache layout" unless defined $name;
+ } elsif (@_ == 1) {
+ $name = shift;
+ }
+
+ if (exists($apache_layout_tab{$name})) {
+ @{$self}{keys %{$apache_layout_tab{$name}}} =
+ values %{$apache_layout_tab{$name}};
+ } else {
+ croak "undefined Apache layout $name";
+ }
+
+ $self->{_layout_name} = $name;
+
+ return $self;
+}
+
+sub debug {
+ if (defined(&::debug)) {
+ ::debug(@_);
+ }
+}
+
+sub name {
+ my $self = shift;
+ return $self->{_layout_name};
+}
+
+sub config_file {
+ my $self = shift;
+ return $self->{_config_file};
+}
+
+sub restart_command {
+ my $self = shift;
+ return $self->{_restart};
+}
+
+sub incdir {
+ my $self = shift;
+ if (exists($self->{_incdir})) {
+ if (ref($self->{_incdir}) eq 'CODE') {
+ return &{$self->{_incdir}};
+ } else {
+ return $self->{_incdir};
+ }
+ }
+ return dirname($self->{_config_file});
+}
+
+1;
diff --git a/lib/App/Acmeman/Config.pm b/lib/App/Acmeman/Config.pm
new file mode 100644
index 0000000..bbe1f2b
--- /dev/null
+++ b/lib/App/Acmeman/Config.pm
@@ -0,0 +1,359 @@
+package App::Acmeman::Config;
+
+use strict;
+use warnings;
+use Carp;
+
+require Exporter;
+our @ISA = qw(Exporter);
+our $VERSION = "1.00";
+
+sub new {
+ my ($class, $filename, %args) = @_;
+ my $self = bless { _filename => $filename }, $class;
+ my $defaults;
+ my $v;
+
+ if (defined($v = delete $args{syntax})) {
+ $self->{_syntax} = $v;
+ }
+ $defaults = delete $args{defaults};
+
+ carp "unrecognized parameters" if keys(%args);
+ $self->{_conf} = {};
+ if (-e $filename) {
+ $self->_readconfig($filename, $self->{_conf});
+ } elsif ($defaults) {
+ while (my ($k, $v) = each %$defaults) {
+ $self->set(split(/\./, $k), $v);
+ }
+ } else {
+ $self->error("configuration file \"$filename\" does not exist");
+ }
+
+ if ($self->success && exists($self->{_syntax})) {
+ $self->_fixup($self->{_syntax}, $self->{_conf});
+ }
+ return $self;
+}
+
+sub error {
+ my ($self, $msg) = @_;
+ push @{$self->{_errors}}, $msg;
+}
+
+sub errors {
+ my $self = shift;
+ if (wantarray) {
+ return exists($self->{_errors}) ? (@{$self->{_errors}}) : ();
+ } elsif (!exists($self->{_errors})) {
+ return 0;
+ } else {
+ return 0 + @{$self->{_errors}};
+ }
+}
+
+sub clrerr {
+ my $self = shift;
+ delete $self->{_errors};
+}
+
+sub success {
+ my $self = shift;
+ return $self->errors == 0;
+}
+
+sub _parse_section {
+ my ($self, $conf, $input) = @_;
+ my $quote;
+ my $kw = $self->{_syntax} if exists $self->{_syntax};
+ while ($input ne '') {
+ my $name;
+ if (!defined($quote)) {
+ if ($input =~ /^"(.*)/) {
+ $quote = '';
+ $input = $1;
+ } elsif ($input =~ /^(.+?)(?:\s+|")(.*)/) {
+ $name = $1;
+ $input = $2;
+ } else {
+ $name = $input;
+ $input = '';
+ }
+ } else {
+ if ($input =~ /^([^\\"]*)\\(.)(.*)/) {
+ $quote .= $1 . $2;
+ $input = $3;
+ } elsif ($input =~ /^([^\\"]*)"\s*(.*)/) {
+ $name = $quote . $1;
+ $input = $2;
+ $quote = undef;
+ } else {
+ error("unparsable input $input");
+ exit(2);
+ }
+ }
+
+ if (defined($name)) {
+ $conf->{$name} = {} unless ref($conf->{$name}) eq 'HASH';
+ $conf = $conf->{$name};
+ if (defined($kw) and ref($kw) eq 'HASH') {
+ my $synt;
+ if (exists($kw->{$name})) {
+ $synt = $kw->{$name};
+ } elsif (exists($kw->{'*'})) {
+ $synt = $kw->{'*'};
+ if ($synt eq '*') {
+ $name = undef;
+ next;
+ }
+ }
+ if (defined($synt)
+ && ref($synt) eq 'HASH'
+ && exists($synt->{section})) {
+ $kw = $synt->{section};
+ } else {
+ $kw = undef;
+ }
+ } else {
+ $kw = undef;
+ }
+ $name = undef;
+ }
+ }
+ return ($conf, $kw);
+}
+
+sub _readconfig {
+ my ($self, $file, $conf) = @_;
+
+# debug(2, "reading $file");
+ open(my $fd, "<", $file)
+ or do {
+ $self->error("can't open configuration file $file: $!");
+ return;
+ };
+
+ my $line;
+ my $err;
+ my $section = $conf;
+ my $kw = $self->{_syntax};
+ my $include = 0;
+ my $rootname;
+
+ while (<$fd>) {
+ ++$line;
+ chomp;
+ if (/\\$/) {
+ chop;
+ $_ .= <$fd>;
+ redo;
+ }
+
+ s/^\s+//;
+ s/\s+$//;
+ s/#.*//;
+ next if ($_ eq "");
+
+ if (/^\[(.+?)\]$/) {
+ $include = 0;
+ my $arg = $1;
+ $arg =~ s/^\s+//;
+ $arg =~ s/\s+$//;
+ if ($arg eq 'include') {
+ $include = 1;
+ } else {
+ ($section, $kw) = $self->_parse_section($conf, $1);
+ if (exists($self->{_syntax}) && !defined($kw)) {
+ $self->error("$file:$line: unknown section");
+ }
+ }
+ } elsif (/([\w_-]+)\s*=\s*(.*)/) {
+ my ($k, $v) = ($1, $2);
+
+ if ($include) {
+ if ($k eq 'path') {
+ $self->_readconfig($v, $conf);
+ } elsif ($k eq 'pathopt') {
+ $self->_readconfig($v, $conf)
+ if -f $v;
+ } elsif ($k eq 'glob') {
+ foreach my $file (bsd_glob($v, 0)) {
+ $self->_readconfig($file, $conf);
+ }
+ } else {
+ $self->error("$file:$line: unknown keyword");
+ }
+ next;
+ }
+
+ if (defined($kw)) {
+ my $x = $kw->{$k};
+ if (!defined($x)) {
+ $self->error("$file:$line: unknown keyword $k");
+ next;
+ } elsif (ref($x) eq 'HASH') {
+ if (exists($x->{re})) {
+ if ($v !~ /$x->{re}/) {
+ $self->error("$file:$line: invalid value for $k");
+ next;
+ }
+ }
+
+ if (exists($x->{check})) {
+ if (my $errstr = &{$x->{check}}($k, $v)) {
+ $self->error("$file:$line: $errstr");
+ next;
+ }
+ }
+
+ if (exists($x->{parser})) {
+ if (my $errstr = &{$x->{parser}}($k, \$v)) {
+ $self->error("$file:$line: $errstr");
+ next;
+ }
+ }
+
+ if ($x->{array}) {
+ if (exists($section->{$k})) {
+ $v = [ @{$section->{$k}}, $v ];
+ } else {
+ $v = [ $v ]
+ }
+ }
+ }
+ }
+
+ $section->{$k} = $v;
+ } else {
+ $self->error("$file:$line: malformed line");
+ next;
+ }
+ }
+ close $fd;
+}
+
+sub _is_section_ref {
+ my ($ref) = @_;
+ return ref($ref) eq 'HASH';
+}
+
+sub _fixup {
+ my ($self, $kw, $section, @path) = @_;
+
+ while (my ($k, $d) = each %{$kw}) {
+ if (ref($d) eq 'HASH') {
+ if (exists($d->{default}) && !exists($section->{$k})) {
+ $section->{$k} = $d->{default};
+ }
+
+ if (exists($d->{section})) {
+ if ($k eq '*') {
+ while (my ($name, $vref) = each %{$section}) {
+ if (_is_section_ref($vref)) {
+ $self->_fixup($d->{section}, $vref, @_, $name);
+ }
+ }
+ } else {
+ my $temp;
+ unless (exists $section->{$k}) {
+ $section->{$k} = {} ;
+ $temp = 1;
+ }
+ $self->_fixup($d->{section}, $section->{$k}, @_, $k);
+ delete $section->{$k}
+ if $temp && keys(%{$section->{$k}}) == 0;
+ }
+ }
+
+ if ($d->{mandatory} && !exists($section->{$k})) {
+ $self->error(exists($d->{section})
+ ? "mandatory section ["
+ . join(' ', @_, $k)
+ . "] not present"
+ : "mandatory variable \""
+ . join('.', @_, $k)
+ . "\" not set");
+ }
+ }
+ }
+}
+
+sub _getref {
+ my $self = shift;
+
+ return undef unless exists $self->{_conf};
+ my $ref = $self->{_conf};
+ for (@_) {
+ carp "component undefined" unless defined $_;
+ return undef unless exists $ref->{$_};
+ $ref = $ref->{$_};
+ }
+ return $ref;
+}
+
+sub get {
+ my $self = shift;
+ if (my $ref = $self->_getref(@_)) {
+ if (ref($ref) eq 'ARRAY') {
+ if (wantarray) {
+ return (@{$ref});
+ } else {
+ return $ref->[0];
+ }
+ }
+ return $ref;
+ }
+}
+
+sub isset {
+ my $self = shift;
+ return defined $self->_getref(@_);
+}
+
+sub set {
+ my $self = shift;
+ $self->{_conf} = {} unless exists $self->{_conf};
+ my $ref = $self->{_conf};
+ my $synt = $self->{_syntax};
+
+# print "SET ".join('.',@_)."\n";
+ while (my $arg = shift) {
+ if ($synt) {
+ if (exists($synt->{$arg})) {
+ $synt = $synt->{$arg};
+ } elsif (exists($synt->{'*'})) {
+ $synt = $synt->{'*'};
+ } else {
+ croak "no such component in syntax: $arg";
+ }
+ }
+
+ if (@_ == 1) {
+ my $v = shift;
+ if ($synt && ref($synt) eq 'HASH') {
+ if ($synt->{array}) {
+ if (exists($ref->{$arg})) {
+ $v = [ @{$ref->{$arg}}, $v ];
+ } else {
+ $v = [ $v ];
+ }
+ }
+ }
+ $ref->{$arg} = $v;
+ return;
+ }
+
+ if ($synt) {
+ croak "component not a section: $arg"
+ unless $synt->{section};
+ $synt = $synt->{section};
+ }
+ $ref->{$arg} = {} unless exists $ref->{$arg};
+ $ref = $ref->{$arg};
+ }
+}
+
+1;
+
+
diff --git a/lib/App/Acmeman/Domain.pm b/lib/App/Acmeman/Domain.pm
new file mode 100644
index 0000000..64d4275
--- /dev/null
+++ b/lib/App/Acmeman/Domain.pm
@@ -0,0 +1,138 @@
+package App::Acmeman::Domain;
+
+use strict;
+use warnings;
+use Carp;
+
+require Exporter;
+our @ISA = qw(Exporter);
+our @EXPORT_OK = qw(CERT_FILE KEY_FILE CA_FILE);
+our %EXPORT_TAGS = ( files => [ qw(CERT_FILE KEY_FILE CA_FILE) ] );
+our $VERSION = '1.00';
+
+use constant {
+ CERT_FILE => 0,
+ KEY_FILE => 1,
+ CA_FILE => 2
+};
+
+sub uniq {
+ my %h;
+ map {
+ if (exists($h{$_})) {
+ ()
+ } else {
+ $h{$_} = 1;
+ $_
+ }
+ } @_;
+}
+
+sub new {
+ my ($class, %args) = @_;
+
+ my $self = bless { }, $class;
+ my $v;
+
+ if ($v = delete $args{cn}) {
+ $self->{_cn} = lc $v;
+ }
+ if ($v = delete $args{alt}) {
+ if (ref($v) eq 'ARRAY') {
+ $self->{_alt} = [ uniq(map { lc } @$v) ];
+ } else {
+ $self->{_alt} = [ lc $v ];
+ }
+ }
+ if ($v = delete $args{type}) {
+ $self->{_cert_type} = $v;
+ }
+ if (($v = delete $args{certificate_file})
+ || ($v = delete $args{'certificate-file'})) {
+ $self->{_file}[CERT_FILE] = $v;
+ }
+ if (($v = delete $args{ca_file})
+ || ($v = delete $args{'ca-file'})) {
+ $self->{_file}[CA_FILE] = $v;
+ }
+ if (($v = delete $args{key_file})
+ || ($v = delete $args{'key-file'})) {
+ $self->{_file}[KEY_FILE] = $v;
+ }
+
+ $v = delete($args{argument}) || '$domain';
+ $v =~ s{\$}{\\\$};
+ $self->{_argument} = qr($v);
+
+ croak "unrecognized arguments" if keys %args;
+ return $self;
+}
+
+sub names {
+ my $self = shift;
+ if (wantarray) {
+ return ($self->cn, $self->alt);
+ } else {
+ return $self->alt() + 1;
+ }
+}
+
+sub contains {
+ my $self = shift;
+ my $val = lc shift;
+ return grep { $_ eq $val } $self->names;
+}
+
+sub _domain_cmp {
+ my ($a,$b) = @_;
+
+ carp "righthand-side argument should be a App::Acmeman::Domain"
+ unless $b->isa('App::Acmeman::Domain');
+ return $a->{_cn} cmp $b->{_cn};
+}
+
+sub _domain_plus {
+ my ($a, $b) = @_;
+
+ carp "righthand-side argument should be a App::Acmeman::Domain"
+ unless $b->isa('App::Acmeman::Domain');
+
+ push @{$a->{_alt}}, $b->cn
+ unless $a->contains($b->cn);
+ @{$a->{_alt}} = uniq($a->alt, $b->alt);
+}
+
+use overload
+ cmp => \&_domain_cmp,
+ '+' => \&domain_plus,
+ '""' => sub { $_[0]->cn };
+
+sub cn {
+ my $self = shift;
+ return $self->{_cn};
+}
+
+sub alt {
+ my $self = shift;
+ if (wantarray) {
+ return exists($self->{_alt}) ? (@{$self->{_alt}}) : ();
+ } else {
+ return exists($self->{_alt}) ? (0+@{$self->{_alt}}) : 0;
+ }
+}
+
+sub file {
+ my ($self, $type) = @_;
+ return undef unless exists($self->{_file}[$type]);
+ my $res = $self->{_file}[$type];
+ $res =~ s{$self->{_argument}}{$self->cn}ge;
+ return $res;
+}
+
+sub certificate_file {
+ my $self = shift;
+ return $self->file(CERT_FILE);
+}
+
+1;
+
diff --git a/lib/App/Acmeman/Source/Apache.pm b/lib/App/Acmeman/Source/Apache.pm
new file mode 100644
index 0000000..f86c02f
--- /dev/null
+++ b/lib/App/Acmeman/Source/Apache.pm
@@ -0,0 +1,284 @@
+package App::Acmeman::Source::Apache;
+
+use strict;
+use warnings;
+use Carp;
+use feature 'state';
+use File::Path qw(make_path);
+
+require App::Acmeman::Apache::Layout;
+our @ISA = qw(App::Acmeman::Apache::Layout);
+
+sub new {
+ my $class = shift;
+ my $self = $class->SUPER::new(@_);
+ return $self;
+}
+
+sub debug {
+ if (defined(&::debug)) {
+ ::debug(@_);
+ }
+}
+
+sub configure {
+ my ($self, $config) = @_;
+ $config->set(qw(core restart), $self->restart_command);
+ $self->{_cfg} = $config;
+ return $self->examine_http_config($self->config_file);
+}
+
+sub set {
+ my $self = shift;
+ croak "improper use of the set method"
+ unless exists $self->{_cfg};
+ return $self->{_cfg}->set(@_);
+}
+
+sub get {
+ my $self = shift;
+ croak "improper use of the get method"
+ unless exists $self->{_cfg};
+ return $self->{_cfg}->get(@_);
+}
+
+sub error {
+ my $self = shift;
+ if (exists($self->{_cfg})) {
+ $self->{_cfg}->error(@_);
+ } else {
+ carp @_;
+ }
+}
+
+sub dequote {
+ my ($self, $arg) = @_;
+ $arg =~ s{^"(.*?)"$}{$1};
+ return $arg;
+}
+
+sub examine_http_config {
+ my ($self, $file) = @_;
+
+ use constant {
+ STATE_INITIAL => 0, # Initial state
+ STATE_VIRTUAL_HOST => 1, # In VirtualHost block
+ STATE_USE_CHALLENGE => 2, # Ditto, but challenge macro was used
+ STATE_USE_REFERENCE => 3, # Ditto, but reference macro was used
+ STATE_MACRO_CHALLENGE => 4, # In LetsEncryptChallenge macro
+ STATE_MACRO_SSL => 5
+ };
+
+ state $state = STATE_INITIAL;
+
+ debug(3, "reading apache configuration file \"$file\"");
+ if (open(my $fd, '<', $file)) {
+ my $server_name;
+ my @server_aliases;
+ my $reference;
+ my $line;
+
+ while (<$fd>) {
+ ++$line;
+ chomp;
+ s/^\s+//;
+ next if /^(#.*)?$/;
+ if (/^include(optional)?\s+(.+?)\s*$/i) {
+# debug(3, "$file:$line: state $state: Include$1 $2");
+ $self->http_include($self->dequote($2), defined($1));
+ next;
+ }
+
+ if ($state == STATE_INITIAL) {
+ if (/^<VirtualHost/i) {
+ $state = STATE_VIRTUAL_HOST;
+ $server_name = undef;
+ @server_aliases = ();
+ $reference = undef;
+ } elsif (/^ServerRoot\s+(.+)/i) {
+ $self->{_server_root} = $self->dequote($1);
+ } elsif (/^<(?:(?i)Macro)\s+LetsEncryptChallenge/) {
+ $state = STATE_MACRO_CHALLENGE;
+ } elsif (/^<(?:(?i)Macro)\s+LetsEncryptSSL\s+(.+?)\s*>/) {
+ $state = STATE_MACRO_SSL;
+ $self->set(qw(files apache argument), $1);
+ }
+ } elsif ($state == STATE_VIRTUAL_HOST
+ || $state == STATE_USE_CHALLENGE
+ || $state == STATE_USE_REFERENCE) {
+ if (m{</VirtualHost}i) {
+ unshift @server_aliases, $server_name
+ if defined $server_name;
+ if (@server_aliases) {
+ if ($state == STATE_USE_CHALLENGE) {
+ my $cn = shift @server_aliases;
+ $self->set('domain', $cn, 'files', 'apache');
+ foreach my $name (@server_aliases) {
+ $self->set('domain', $cn, 'alt', $name);
+ }
+ debug(3, "$file:$line: will handle ".
+ join(',', $cn, @server_aliases));
+ } elsif ($state == STATE_USE_REFERENCE) {
+ $self->set('domain', $reference, 'files', 'apache');
+ foreach my $name (@server_aliases) {
+ $self->set('domain', $reference,
+ 'alt', $name);
+ }
+ }
+ }
+ $state = STATE_INITIAL;
+ } elsif (/^(?:(?i)Use)\s+LetsEncryptChallenge/) {
+ if ($state == STATE_VIRTUAL_HOST) {
+ $state = STATE_USE_CHALLENGE;
+ } elsif ($state == STATE_USE_CHALLENGE) {
+ $self->error("$file:$line: duplicate use of LetsEncryptChallenge");
+ } else {
+ $self->error("$file:$line: LetsEncryptChallenge and LetsEncryptReference can't be used together");
+ }
+ } elsif (/^(?:(?i)Use)\s+LetsEncryptReference\s+(.+)\s*$/) {
+ if ($state == STATE_VIRTUAL_HOST) {
+ $state = STATE_USE_REFERENCE;
+ $reference = $1;
+ } elsif ($state == STATE_USE_REFERENCE) {
+ $self->error("$file:$line: duplicate use of LetsEncryptReference");
+ } else {
+ $self->error("$file:$line: LetsEncryptChallenge and LetsEncryptReference can't be used together");
+ }
+ } elsif (/^(?:(?i)ServerName)\s+(\S+)/) {
+ $server_name = $self->dequote($1);
+ } elsif (/^(?:(?i)ServerAlias)\s+(.+)\s*$/) {
+ push @server_aliases,
+ map { /^\*\./ ? () : $self->dequote($_) }
+ split /\s+/, $1;
+ }
+ } elsif ($state == STATE_MACRO_CHALLENGE) {
+ if (m{^</macro}i) {
+ $state = STATE_INITIAL;
+ } elsif (m{^(?:(?i)Alias)\s+/.well-known/acme-challenge\s+(.+)}) {
+ my $dir = $self->dequote($1);
+ $dir =~ s{/.well-known/acme-challenge$}{};
+ $self->set(qw(core rootdir), $dir);
+ debug(3, "ACME challenge root dir: $dir");
+ }
+ } elsif ($state == STATE_MACRO_SSL) {
+ if (m{^</macro}i) {
+ $state = STATE_INITIAL;
+ } elsif (/(?:(?i)SSLCertificate((?:Key)|(?:Chain))?File)\s+(.+)/) {
+ my %t = (
+ '' => 'certificate-file',
+ key => 'key-file',
+ chain => 'ca-file'
+ );
+ $self->set(qw(files apache), $t{lc($1||'')}, $2);
+ }
+ }
+ }
+ close $fd;
+ } else {
+ $self->error("can't open file \"$file\": $!");
+ return 0;
+ }
+ return 1;
+}
+
+sub http_include {
+ my ($self, $pattern, $optional) = @_;
+ $pattern = "$self->{_server_root}/$pattern" unless $pattern =~ m{^/};
+ $pattern =~ s{/*$}{};
+ $pattern .= '/*' if -d $pattern;
+ foreach my $file (glob $pattern) {
+ if ($optional && ! -e $file) {
+ debug(1, "optional include file \"$file\" doesn't exist");
+ next;
+ }
+ $self->examine_http_config($file);
+ }
+}
+
+sub mkpath {
+ my ($self, $dir) = @_;
+ my @created = make_path("$dir", { error => \my $err } );
+ if (@$err) {
+ for my $diag (@$err) {
+ my ($file, $message) = %$diag;
+ if ($file eq '') {
+ $self->error($message);
+ } else {
+ $self->error("mkdir $file: $message");
+ }
+ }
+ return 0;
+ }
+ return 1;
+}
+
+sub setup {
+ my ($self, %args) = @_;
+ my $filename = $self->incdir() . "/httpd-letsencrypt.conf";
+ if (-e $filename) {
+ if ($args{force}) {
+ ::error("the file \"$filename\" already exists",
+ prefix => 'warning');
+ } else {
+ ::error("the file \"$filename\" already exists");
+ ::error("use --force to continue");
+ return 0;
+ }
+ }
+ my $www_root = $self->get(qw(core rootdir));
+ debug(2, "writing $filename");
+ unless ($args{dry_run}) {
+ unless ($self->mkpath($self->incdir())) {
+ return 0;
+ }
+ open(my $fd, '>', $filename)
+ or croak "can't open \"$filename\" for writing: $!";
+ print $fd <<EOT;
+<Macro LetsEncryptChallenge>
+ Alias /.well-known/acme-challenge $www_root/.well-known/acme-challenge
+ <Directory $www_root/.well-known/acme-challenge>
+ Options None
+ Require all granted
+ </Directory>
+ <IfModule mod_rewrite.c>
+ RewriteEngine On
+ RewriteRule /.well-known/acme-challenge - [L]
+ </IfModule>
+</Macro>
+
+<Macro LetsEncryptReference \$domain>
+ Use LetsEncryptChallenge
+ Alias /.dummy/\$domain /dev/null
+</Macro>
+
+<Macro LetsEncryptSSL \$domain>
+ SSLEngine on
+ SSLProtocol all -SSLv2 -SSLv3
+ SSLHonorCipherOrder on
+ SSLCipherSuite EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:EECDH+RC4:RSA+RC4:!MD5
+ SSLCertificateFile /etc/ssl/acme/\$domain/cert.pem
+ SSLCertificateKeyFile /etc/ssl/acme/\$domain/privkey.pem
+ SSLCACertificateFile /etc/ssl/acme/lets-encrypt-x3-cross-signed.pem
+</Macro>
+
+<Macro LetsEncryptServer \$domain>
+ ServerName \$domain
+ Use LetsEncryptSSL \$domain
+</Macro>
+
+EOT
+;
+ close $fd;
+
+ if (exists($self->{_post_setup})) {
+ &{$self->{_post_setup}}($filename);
+ }
+ }
+
+ ::error("created file \"$filename\"", prefix => 'note');
+ ::error("please, enable mod_macro and make sure your Apache configuration includes this file", prefix => 'note');
+
+ return 1;
+}
+
+1;

Return to:

Send suggestions and report system problems to the System administrator.