diff options
-rw-r--r-- | Changes | 29 | ||||
-rw-r--r-- | Makefile.PL | 7 | ||||
-rwxr-xr-x | acmeman | 165 | ||||
-rw-r--r-- | lib/App/Acmeman.pm | 33 | ||||
-rw-r--r-- | lib/App/Acmeman/Config.pm | 4 | ||||
-rw-r--r-- | lib/App/Acmeman/Domain.pm | 1 | ||||
-rw-r--r-- | lib/App/Acmeman/Log.pm | 2 | ||||
-rw-r--r-- | lib/App/Acmeman/Source.pm | 3 | ||||
-rw-r--r-- | lib/App/Acmeman/Source/Apache.pm | 13 | ||||
-rw-r--r-- | lib/App/Acmeman/Source/Pound.pm | 225 |
10 files changed, 462 insertions, 20 deletions
@@ -1,3 +1,32 @@ +3.09 2023-01-22 + + - New domain source: pound. + +3.08 2021-06-11 + + - Allow for multiple per-domain postrenew statements. + +3.07 2021-02-12 + + - Change bugtracker address. + - Change root certificate URL and make it configurable. + +3.06 2020-06-15 + + - Improve error reporting + +3.05 2020-06-14 + + - Fix manifest + +3.04 2020-06-14 + + - Rewrite Apache configuration layout support. + - Improve Apache setup procedure. + - Make sure the generated cert.pem file is terminated with a + newline. + - Accept ServerName value with the scheme prefix. + 3.03 2019-12-23 - The following environment variables are set when running diff --git a/Makefile.PL b/Makefile.PL index ccc6a27..8ad492a 100644 --- a/Makefile.PL +++ b/Makefile.PL @@ -33,7 +33,8 @@ my %makefile_args = ( 'Sys::Hostname' => 1.16, 'Apache::Defaults' => 1.02, 'Apache::Config::Preproc' => 1.04, - 'Config::Parser' => 1.03 + 'Config::Parser' => 1.03, + 'File::BackupCopy' => 1.00 }, MIN_PERL_VERSION => 5.016001, META_MERGE => { @@ -44,6 +45,10 @@ my %makefile_args = ( url => 'git://git.gnu.org.ua/acmeman.git', web => 'http://git.gnu.org.ua/cgit/acmeman.git/', }, + bugtracker => { + web => 'https://puszcza.gnu.org.ua/bugs/?group=acmeman', + mailto => 'gray+acmeman@gnu.org.ua' + } }, provides => Module::Metadata->provides(version => '1.4', dir => 'lib') @@ -106,7 +106,7 @@ 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. -There are three main use cases +There are four main use cases: =head2 APACHE @@ -269,6 +269,57 @@ Finally, define the backends. =back +=head2 Pound + +B<Pound> is a light-weight proxy server available from L<https://github.com/graygnuorg/pound>. It is supported by the B<acmeman> source B<pound>, which +scans the B<pound> configuration file F</etc/pound.cfg>, and extracts domain +names from the B<Host> directives in B<ListenHTTP> sections that contain +the B<ACME> statement. Below is a short usage instruction for this source +module. For a detailed discussion, refer to the section B<SOURCE>, subsection +B<pound>. + +=over 4 + +=item 1. Add the B<ACME> directive to the B<ListenHTTP> section of your +F</etc/pound.cfg> file. Its argument is a directory on local file system +where ACME challenge files will be stored. Make sure that this directory +is the F<.well-known/acme-challenge> subdirectory of the B<rootdir> in your +B<acmeman> configuration file. + +=item 2. In the same B<ListenHTTP> section, define hostnames which will obtain +ACME certificates. Make sure to use B<Host> statements with exact string +matching algorithm. If serving several host names, use the B<Match OR> block. + +=back + +After these two steps, your listener section will look like: + + ListenHTTP + Address 0.0.0.0 + Port 80 + ACME "/var/lib/pound/acme/.well-known/acme-challenge" + Service + Match OR + Host "www.example.org" + Host "example.org" + End + ... + End + End + +=over 4 + +=item 3. Configure B<acmeman>. Use the B<pound> source and make sure +B<rootdir> is synchronized with the B<ACME> statement in F<pound.cfg>, as +described in point 1. E.g.: + + [core] + source = pound + rootdir = /var/lib/pound/acme + postrenew = /usr/bin/systemctl restart pound + +=back + =head2 Direct configuration Use direct configuration if none of the provided source types can @@ -426,6 +477,8 @@ it defines the apache configuration layout. Allowed values are: B<debian>, B<slackware>, B<suse> and B<rh> (for Red Hat). Without arguments, the layout will be autodetected. +The B<pound> source gathers host names from B<pound> configuration file. + The B<file> 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<haproxy> pattern files. @@ -531,6 +584,9 @@ setting is used. Run I<CMD> after successful update. If not given, the B<core.postrenew> commands will be run. +If more than one B<postrenew> statements are defined, they will be run in +sequence, in the same order as they appeared in the configuration file. + I<CMD> is run in the environment inherited from the calling B<acmeman> process with the following additional variables defined: @@ -763,6 +819,111 @@ If the B<--host> (B<-h>) option is used, only one certificate will be issued. The I<HOST> will be used as its B<CN>. All the domain names read from the input files will form the list of its alternative names. +=head2 pound + + [core] + source = pound [--config=FILE] [--host=HOST] \ + [--type=http|https] [--listener=NAME] \ + [--comment=TEXT] + +Domain names will be read from I<FILE> or, if it is not supplied, from +the default B<pound> configuration file F</etc/pound.cfg>. By default, +the module will scan B<ListenHTTP> sections that have B<ACME> directive +set and will extract domain names from B<Host> statements in its services. + +The B<--type> and B<--listener> options modify this behavior. The B<--type> +option instructs B<pound> which type of listeners to scan. Setting it to +B<http> means scanning only B<ListenHTTP> listeners (the default) and +setting it to B<https> means scanning B<ListenHTTPS> listeners. To +enable scanning both types of listeners, use B<--type=http --type=https>. +When scanning B<ListenHTTPS>, the module collects all domain names that +appear as arguments to B<Host> statements. + +If B<--listener> option is used, module will scan only the named listener. +To select multiple listeners, use several B<--listener> options. + +The B<--comment> option defines a text, which, when appearing at the +start of a comment line, enables host name collection. Such I<pragmatical> +comments may appear anywhere within listener and service sections and their +scope is limited by the corresponding section. When this option is used, +host collection is disabled by default. For example, assuming +B<--comment=acme>, the following configuration snippet (with irrelevant +statements replaced by ellipses) will result in issuing certificate for +C<example.org> and C<www.example.org>: + + ListenerHTTP + # acme + Service + Host -exact "example.org" + ... + End + + Service + Host -exact "www.example.org" + ... + End + End + +In contrast, when processing the following snippet, B<acmeman> will issue +certificate for C<example.org> only: + + ListenerHTTP + Service + # acme + Host -exact "example.org" + ... + End + + Service + Host -exact "www.example.org" + ... + End + End + +Furthermore, using B<no-I<TEXT>> at the start of a comment cancels +the effect of the previous pragmatic comment. This can be used for +better control of host selection: + + ListenerHTTP + Service + Match OR + # acme + Host -exact "example.org" + Host -exact "www.example.org" + # no-acme + Host -exact "test.example.org" + End + ... + End + End + + +If the B<--host> (B<-h>) option is used, only one certificate will be +issued. The I<HOST> will be used as its B<CN>. All the domain names read +from the input files will form the list of its alternative names. + +Notice the limitations of this module: + +=over 4 + +=item 1. Only B<Host> statements with exact string matching can be used. +When declaring multiple hosts it might be tempting to use regular expression +matching instead. Due to obvious reasons, the module won't be able to +cope with it. When declaring multiple hosts, always use the B<Match OR> +section, like this: + + Match OR + Host -exact "host1" + Host -exact "host2" + Host -exact "host3" + End + +=item 2. These B<Host> statements (or the enclosing B<Match OR> section) +must be declared in the B<Service> sections located under the B<ListenHTTP> +or B<ListenHTTPS> sections. Global B<Service> sections are not scanned. + +=back + =head1 OPTIONS =over 4 @@ -863,5 +1024,5 @@ GPLv3+: GNU GPL version 3 or later, see L<http://gnu.org/licenses/gpl.html> This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law. - + =cut diff --git a/lib/App/Acmeman.pm b/lib/App/Acmeman.pm index fafcb09..a4cea29 100644 --- a/lib/App/Acmeman.pm +++ b/lib/App/Acmeman.pm @@ -8,6 +8,7 @@ use Crypt::OpenSSL::RSA; use Crypt::OpenSSL::X509; use File::Basename; use File::Path qw(make_path); +use File::Spec; use DateTime::Format::Strptime; use LWP::UserAgent; use LWP::Protocol::https; @@ -24,12 +25,14 @@ use Text::ParseWords; use App::Acmeman::Log qw(:all :sysexits); use feature 'state'; -our $VERSION = '3.03'; +our $VERSION = '3.09'; my $progdescr = "manages ACME certificates"; -my $letsencrypt_root_cert_url = - 'https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem'; +our $acme_dir = '/etc/ssl/acme'; +our $letsencrypt_root_cert_basename = 'lets-encrypt-root.pem'; +our $letsencrypt_root_cert_url = + 'https://letsencrypt.org/certs/lets-encrypt-r3-cross-signed.pem'; sub new { my $class = shift; @@ -151,6 +154,7 @@ sub host_ns_ok { foreach my $ip ($self->resolve($host)) { return 1 if $self->myip($ip); } + error("$host does not resolve to our IP"); return 0 } @@ -203,7 +207,8 @@ sub setup { $self->prep_dir($self->cf->get(qw(core rootdir)).'/file'); - $self->get_root_cert('/etc/ssl/acme/lets-encrypt-x3-cross-signed.pem'); + $self->get_root_cert(File::Spec->catfile($acme_dir, + $letsencrypt_root_cert_basename)); foreach my $src ($self->cf->get(qw(core source))) { unless ($src->setup(dry_run => $self->dry_run_option, @@ -228,7 +233,11 @@ sub collect { || $self->host_ns_ok($_) } ($k, ($v->{alt} ? @{$v->{alt}} : ()))]; if (@$alt) { - $k = shift @$alt; + my $name = shift @$alt; + if ($name ne $k) { + error("$k: CN changed to $name, update your configuration"); + } + $k = $name; $alt = undef unless @$alt; } else { error("ignoring $k: none of its names resolves to our IP"); @@ -312,12 +321,14 @@ sub renew { foreach my $vhost ($self->selected_domains) { if ($self->force_option || $self->domain_cert_expires($vhost)) { if ($self->register_domain_certificate($vhost)) { - if (my $cmd = $vhost->postrenew) { + if (my $postrenew = $vhost->postrenew) { local $ENV{ACMEMAN_CERTIFICATE_FILE} = $vhost->certificate_file; local $ENV{ACMEMAN_DOMAIN_NAME} = $vhost; local $ENV{ACMEMAN_ALT_NAMES} = join(' ', $vhost->alt); - $self->runcmd($cmd, $vhost); + foreach my $cmd (@$postrenew) { + $self->runcmd($cmd, $vhost); + } } else { push @renewed, $vhost; } @@ -557,7 +568,13 @@ sub register_domain_certificate { sleep 1 } if ($ret ne 'valid') { - error("$domain: can't renew certificate: authorization: $ret"); + my $text = "authorization $ret"; + if (my ($ch) = grep { $_->type() eq 'http-01' } $authz->challenges()) { + if (my $err = $ch->error()) { + $text .= ': ' . $err->to_string; + } + } + error("$domain: can't renew certificate: $text"); return 0; } } diff --git a/lib/App/Acmeman/Config.pm b/lib/App/Acmeman/Config.pm index 7c05985..f5bce2a 100644 --- a/lib/App/Acmeman/Config.pm +++ b/lib/App/Acmeman/Config.pm @@ -52,7 +52,7 @@ sub mangle { if (my $fnode = $self->getnode('files')) { while (my ($k, $v) = each %{$fnode->subtree}) { $v->set('files', $k, 'type', 'split') - unless $v->has_key('type', 'split'); + unless $v->has_key('type'); if ($v->subtree('type') eq 'single') { unless ($v->has_key('certificate-file')) { $self->error("files.$k.certificate-file not defined"); @@ -143,7 +143,7 @@ __DATA__ alt = STRING :array files = STRING key-size = NUMBER - postrenew = STRING + postrenew = STRING :array diff --git a/lib/App/Acmeman/Domain.pm b/lib/App/Acmeman/Domain.pm index 46c3f1d..97ec5ca 100644 --- a/lib/App/Acmeman/Domain.pm +++ b/lib/App/Acmeman/Domain.pm @@ -9,7 +9,6 @@ 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, diff --git a/lib/App/Acmeman/Log.pm b/lib/App/Acmeman/Log.pm index 72242c0..02f5d65 100644 --- a/lib/App/Acmeman/Log.pm +++ b/lib/App/Acmeman/Log.pm @@ -1,4 +1,6 @@ package App::Acmeman::Log; +use strict; +use warnings; use File::Basename; use parent 'Exporter'; diff --git a/lib/App/Acmeman/Source.pm b/lib/App/Acmeman/Source.pm index abae342..95e7c03 100644 --- a/lib/App/Acmeman/Source.pm +++ b/lib/App/Acmeman/Source.pm @@ -35,7 +35,8 @@ sub is_set { sub define_domain { my $self = shift; my $cn = shift || croak "domain name must be given"; - $self->set('domain', $cn, 'files', 'default'); + $self->set('domain', $cn, 'files', 'default') + unless $self->is_set('domain', $cn, 'files'); } sub define_alias { diff --git a/lib/App/Acmeman/Source/Apache.pm b/lib/App/Acmeman/Source/Apache.pm index a9a6195..1f5f7ac 100644 --- a/lib/App/Acmeman/Source/Apache.pm +++ b/lib/App/Acmeman/Source/Apache.pm @@ -66,7 +66,8 @@ sub examine_http_config { foreach my $sect ($app->section(-name => "macro")) { if ($sect->value =~ m{^(?ix)letsencryptssl \s+ - (.+)}) { + (.+)} + && ! $self->is_set(qw(files apache))) { $self->set(qw(files apache argument), $1); map { if ($_->name =~ m{^(?ix) @@ -93,8 +94,10 @@ sub examine_http_config { } foreach my $sect ($app->section(-name => "virtualhost")) { - my ($server_name) = (map { $self->dequote($_->value) } - $sect->directive('servername')); + my ($server_name) = (map { + (my $s = $self->dequote($_->value)) =~ s{^https?://}{}; + $s + } $sect->directive('servername')); my @server_aliases = map { quotewords('\s+', 0, $self->dequote($_->value)) } $sect->directive('serveralias'); @@ -167,7 +170,7 @@ sub setup { debug(2, "writing $filename"); unless ($args{dry_run}) { my $challenge_dir = "$www_root/.well-known/acme-challenge"; - my $acme_dir = "/etc/ssl/acme"; + my $acme_dir = $App::Acmeman::acme_dir; foreach my $dir ($self->layout->incdir(), $challenge_dir, $acme_dir) { unless ($self->mkpath($dir)) { @@ -204,7 +207,7 @@ sub setup { SSLCipherSuite ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:AES:CAMELLIA:!DES-CBC3-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!aECDH:!EDH-DSS-DES-CBC3-SHA:!EDH-RSA-DES-CBC3-SHA:!KRB5-DES-CBC3-SHA SSLCertificateFile $acme_dir/\$domain/cert.pem SSLCertificateKeyFile $acme_dir/\$domain/privkey.pem - SSLCACertificateFile $acme_dir/lets-encrypt-x3-cross-signed.pem + SSLCACertificateFile $acme_dir/$App::Acmeman::letsencrypt_root_cert_basename </Macro> <Macro LetsEncryptServer \$domain> diff --git a/lib/App/Acmeman/Source/Pound.pm b/lib/App/Acmeman/Source/Pound.pm new file mode 100644 index 0000000..daf5373 --- /dev/null +++ b/lib/App/Acmeman/Source/Pound.pm @@ -0,0 +1,225 @@ +package App::Acmeman::Source::Pound; +use strict; +use warnings; +use parent 'App::Acmeman::Source'; +use Getopt::Long qw(GetOptionsFromArray :config gnu_getopt no_ignore_case); +use App::Acmeman::Log qw(:all); + +sub new { + my $class = shift; + my $cfgname = '/etc/pound.cfg'; + my $host; + my @listener; + my @types; + my $comment; + GetOptionsFromArray(\@_, + 'config|f=s' => \$cfgname, + 'host|h=s' => \$host, + 'listener=s@' => \@listener, + 'type=s@' => \@types, + 'comment=s' => \$comment + ); + my $self = bless { + cfgname => $cfgname, + host => $host, + }, $class; + if ($comment) { + $self->{comment} = qr($comment) + } + if (@listener) { + $self->{listener} = { map { $_ => 1 } @listener }; + } + if (!@types) { + @types = qw(http) + } + $self->{types} = { map { lc($_) => 1 } @types }; + + return $self; +} + +sub host { + my ($self, $arg) = @_; + + my $file; + while ($arg =~ s/\s*-(\w+)//) { + $file = 1 if $1 eq 'file'; + } + if ($file) { + $file = $self->dequote($arg); + if (open(my $fh, '<', $file)) { + my @hosts; + while (<$fh>) { + chomp; + s/^\s+//; + s/\s+$//; + next if (/^$/ || /^#/); + push @hosts, $_ + } + close($fh); + return @hosts; + } else { + error("$self->{cfgname}:$.: can't open $file: $!"); + return () + } + } + return ($self->dequote($arg)); +} + +sub lstn_ok { + my ($self, $s, $name) = @_; + if (defined($self->{listener})) { + return 0 unless defined($name); + return exists($self->{listener}{$name}) + } + return !defined($s) || $self->{types}{'http' . lc($s)}; +} + +sub scan { + my ($self) = @_; + debug(1, "initializing file list from $self->{cfgname}"); + if ($self->{host}) { + $self->define_domain($self->{host}); + } + open(my $fh, '<', $self->{cfgname}) + or do { + error("can't open $self->{cfgname}: $!"); + return 0; + }; + use constant { + ST_INIT => 0, + ST_LISTENER => 1, + ST_SERVICE => 2, + ST_EXPEND => 3, + ST_MATCH => 4, + ST_IGNORE => 5 + }; + my $state = ST_INIT; + my $acme; + my @collect_state; + my @lsthosts; + my @srvhosts; + my $endcnt; + while (<$fh>) { + chomp; + + s/^\s+//; + if ($self->{comment} && m{#\s*(no-)?$self->{comment}}) { + if (@collect_state) { + my $hint = 1; + if ($1) { + $hint = 0; + } + debug(4, "$self->{cfgname}:$.: hint=$hint"); + $collect_state[$#collect_state] = $hint; + } + } + + s/#.*//; + next if (/^$/); + + if ($state == ST_INIT) { + if (/^ListenHTTP(?:(?<s>S)?\s+"(?<name>.*)"\s*)?$/i) { + if ($self->lstn_ok($+{s}, $+{name})) { + debug(4, "$self->{cfgname}:$.: listener"); + $state = ST_LISTENER; + $acme = 0; + if (defined($+{s}) && uc($+{s}) eq 'S') { + $acme = 1; + } + push @collect_state, !defined($self->{comment}); + @lsthosts = (); + } else { + $state = ST_IGNORE; + } + } + } elsif ($state == ST_LISTENER) { + if (/^Service(?:\s+".*"\s*)?$/i) { + debug(4, "$self->{cfgname}:$.: service"); + push @collect_state, $collect_state[$#collect_state]; + $state = ST_SERVICE; + @srvhosts = (); + } elsif (/^ACME\s/i) { + $acme = 1; + } elsif (/^End$/i) { + debug(4, "$self->{cfgname}:$.: listener ends"); + pop @collect_state; + if ($acme && @lsthosts) { + if ($self->{host}) { + $self->define_alias($self->{host}, map { @$_ } @lsthosts); + } else { + foreach my $hosts (@lsthosts) { + my $cn = shift @{$hosts}; + $self->define_domain($cn); + $self->define_alias($cn, @{$hosts}) if @{$hosts}; + } + } + } + $state = ST_INIT; + } + } elsif ($state == ST_IGNORE) { + if (/^Service(?:\s+".*"\s*)?$/i || /^Match/i || /^Rewrite/i) { + $endcnt++; + } elsif (/^End$/i) { + if ($endcnt == 0) { + $state = ST_INIT; + } else { + $endcnt--; + } + } + } elsif ($state == ST_SERVICE) { + if (s/^Host\s+//i) { + if ($collect_state[$#collect_state]) { + if (my @hosts = $self->host($_)) { + debug(3, "$self->{cfgname}:$.: hosts ".join(',', @hosts)); + push @srvhosts, @hosts; + } + } + } elsif (/^Backend/i) { + $state = ST_EXPEND; + } elsif (/^Match/i) { + $state = ST_MATCH; + } elsif (/^End$/i) { + $state = ST_LISTENER; + if (@srvhosts) { + push @lsthosts, [ @srvhosts ]; + } + pop @collect_state; + debug(4, "$self->{cfgname}:$.: service ends"); + } + } elsif ($state == ST_MATCH) { + if (s/^Host\s+//i) { + if ($collect_state[$#collect_state]) { + if (my @hosts = $self->host($_)) { + debug(3, "$self->{cfgname}:$.: hosts ".join(',', @hosts)); + push @srvhosts, @hosts; + } + } + } elsif (/^End$/i) { + $state = ST_SERVICE; + } + } elsif ($state == ST_EXPEND) { + if (/^End$/i) { + $state = ST_SERVICE; + } + } + } + close $fh; + + if (@collect_state) { + error("$self->{cfgname}: parsing failed, " . (0+@collect_state) . + " states remained on stack"); + return 0 + } + + return 1; +} + +sub dequote { + my ($self, $arg) = @_; + if (defined($arg) && $arg =~ s{^\s*"(.*?)"\s*$}{$1}) { + $arg =~ s{\\([\\"])}{$1}g; + } + return $arg; +} + +1; |