aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Changelog26
-rwxr-xr-xclient2
-rw-r--r--config/lacme-certs.conf2
-rw-r--r--config/lacme.conf34
-rw-r--r--config/nginx.conf18
-rwxr-xr-xlacme293
-rw-r--r--lacme.md45
-rwxr-xr-xwebserver26
8 files changed, 300 insertions, 146 deletions
diff --git a/Changelog b/Changelog
index 6f212b0..59d5153 100644
--- a/Changelog
+++ b/Changelog
@@ -1,3 +1,29 @@
+lacme (0.3) upstream;
+
+ + When parsing config-cert files and directories (default "lacme-certs.conf
+ lacme-certs.conf.d"), import the default section of files read earlier.
+ + new-cert: create certificate files atomically.
+ + webserver: allow listening to multiple addresses (useful when
+ dual-stack IPv4/IPv6 is not supported). Listen to a UNIX-domain
+ socket by default </var/run/lacme.socket>.
+ + webserver: don't install temporary iptables by default. Hosts
+ without a public HTTP daemon listening on port 80 need to set the
+ 'listen' option to [::] and/or 0.0.0.0, and possibly set the
+ 'iptables' option to Yes.
+ + Change 'min-days' default from 10 to 21, to avoid expiration notices
+ from Let's Encrypt when auto-renewal is done by a cronjob.
+ + Provide nginx configuration snippet.
+ - Ensure lacme's config file descriptor is not passed to the accountd
+ or webserver components.
+ - new-cert: sort section names if not passed explicitely.
+ - new-cert: new CLI option "min-days" overriding the value found in
+ the configuration file.
+ - new-cert: mark the basicConstraints (CA:FALSE) and keyUsage x509v3
+ extensions as critical in the CSR, following upstream fix of
+ Boulder's issue #565.
+
+ -- Guilhem Moulin <guilhem@guilhem.org> Sun, 19 Feb 2017 13:08:41 +0100
+
lacme (0.2) upstream;
+ Honor Retry-After headers for certificate issuance and challenge
diff --git a/client b/client
index 3bf0bad..cd94ed8 100755
--- a/client
+++ b/client
@@ -85,7 +85,7 @@ my $NONCE;
#
my $CONFIG = do {
- my $conf = do { local $/ = undef, <$CONFFILE> };
+ my $conf = do { local $/ = undef; <$CONFFILE> };
close $CONFFILE or die "Can't close: $!";
my $h = Config::Tiny::->read_string($conf) or die Config::Tiny::->errstr()."\n";
$h->{_} //= {};
diff --git a/config/lacme-certs.conf b/config/lacme-certs.conf
index 12fcd54..97d588a 100644
--- a/config/lacme-certs.conf
+++ b/config/lacme-certs.conf
@@ -28,7 +28,7 @@
# For an existing certificate, the minimum number of days before its
# expiration date the section is considered for re-issuance.
#
-#min-days = 10
+#min-days = 21
# Path to the issuer's certificate. This is used for certificate-chain
# and to verify the validity of each issued certificate. Specifying an
diff --git a/config/lacme.conf b/config/lacme.conf
index c5efb03..874bb1f 100644
--- a/config/lacme.conf
+++ b/config/lacme.conf
@@ -16,18 +16,16 @@
# since the two processes communicate through a socket pair. See the
# "accountd" section below for details.
#
-#socket = /run/user/1000/S.lacme
+#socket =
# username to drop privileges to (setting both effective and real uid).
# Preserve root privileges if the value is empty (not recommended).
-# Default: "nobody".
#
-#user = lacme
+#user = nobody
# groupname to drop privileges to (setting both effective and real gid,
# and also setting the list of supplementary gids to that single group).
# Preserve root privileges if the value is empty (not recommended).
-# Default: "nogroup".
#
#group = nogroup
@@ -35,11 +33,11 @@
#
#command = /usr/lib/lacme/client
-# Root URI of the ACME server. NOTE: Use the staging server for testing
-# as it has relaxed rate-limiting.
+# Root URI of the ACME server. NOTE: Use the staging server
+# <https://acme-staging.api.letsencrypt.org/> for testing as it has
+# relaxed rate-limiting.
#
#server = https://acme-v01.api.letsencrypt.org/
-#server = https://acme-staging.api.letsencrypt.org/
# Timeout in seconds after which the client stops polling the ACME
# server and considers the request failed.
@@ -61,17 +59,17 @@
[webserver]
-# Specify the local address to listen on, in the form ADDRESS[:PORT].
+# Comma- or space-separated list of addresses to listen on, for instance
+# "0.0.0.0:80 [::]:80".
#
-#listen = 0.0.0.0:80
-#listen = [::]:80
+#listen = /var/run/lacme.socket
-# If a webserver is already running, specify a non-existent directory
-# under which the webserver is configured to serve GET requests for
-# challenge files under "/.well-known/acme-challenge/" (for each virtual
-# hosts requiring authorization) as static files.
+# Non-existent directory under which an external HTTP daemon is
+# configured to serve GET requests for challenge files under
+# "/.well-known/acme-challenge/" (for each virtual host requiring
+# authorization) as static files.
#
-#challenge-directory = /var/www/acme-challenge
+#challenge-directory =
# username to drop privileges to (setting both effective and real uid).
# Preserve root privileges if the value is empty (not recommended).
@@ -92,7 +90,7 @@
# ADDRESS[:PORT] specified with listen. Theses rules are automatically
# removed once lacme(1) exits.
#
-#iptables = Yes
+#iptables = No
[accountd]
@@ -103,13 +101,13 @@
# username to drop privileges to (setting both effective and real uid).
# Preserve root privileges if the value is empty.
#
-#user = root
+#user =
# groupname to drop privileges to (setting both effective and real gid,
# and also setting the list of supplementary gids to that single group).
# Preserve root privileges if the value is empty.
#
-#group = root
+#group =
# Path to the lacme-accountd(1) executable.
#
diff --git a/config/nginx.conf b/config/nginx.conf
new file mode 100644
index 0000000..f842c12
--- /dev/null
+++ b/config/nginx.conf
@@ -0,0 +1,18 @@
+# Let nginx serve ACME requests directly, or pass them to lacme's
+# webserver component.
+#
+# This file needs to be sourced to the server directives (at least the
+# non-ssl one) of each virtual host requiring authorization.
+
+location /.well-known/acme-challenge/ {
+ # Pass ACME requests to lacme's webserver component
+ proxy_pass http://unix:/var/run/lacme.socket;
+
+ ## Alternatively, you can let nginx serve the requests by
+ ## setting 'challenge-directory' to '/var/www/acme-challenge' in
+ ## lacme's configuration file
+ # alias /var/www/acme-challenge/;
+ # default_type application/jose+json;
+ # disable_symlinks on from=$document_root;
+ # autoindex off;
+}
diff --git a/lacme b/lacme
index cb49818..d7a416e 100755
--- a/lacme
+++ b/lacme
@@ -30,7 +30,8 @@ use File::Temp ();
use Getopt::Long qw/:config posix_default no_ignore_case gnu_getopt auto_version/;
use List::Util 'first';
use POSIX ();
-use Socket qw/AF_UNIX PF_INET PF_INET6 PF_UNIX PF_UNSPEC INADDR_ANY IN6ADDR_ANY
+use Socket qw/AF_UNIX AF_INET AF_INET6 PF_UNIX PF_INET PF_INET6 PF_UNSPEC
+ INADDR_ANY IN6ADDR_ANY IPPROTO_IPV6
SOCK_STREAM SOL_SOCKET SO_REUSEADDR SHUT_RDWR/;
use Config::Tiny ();
@@ -60,7 +61,7 @@ sub usage(;$$) {
}
exit $rv;
}
-usage(1) unless GetOptions(\%OPTS, qw/config=s config-certs=s@ socket=s agreement-uri=s quiet|q debug help|h/);
+usage(1) unless GetOptions(\%OPTS, qw/config=s config-certs=s@ socket=s agreement-uri=s min-days=i quiet|q debug help|h/);
usage(0) if $OPTS{help};
$COMMAND = shift(@ARGV) // usage(1, "Missing command");
@@ -68,6 +69,7 @@ $COMMAND = $COMMAND =~ /\A(new-reg|reg=\p{Print}*|new-cert|revoke-cert)\z/ ? $1
: usage(1, "Invalid command: $COMMAND"); # validate and untaint $COMMAND
@ARGV = map { /\A(\p{Print}*)\z/ ? $1 : die } @ARGV; # untaint @ARGV
+sub set_FD_CLOEXEC($$);
my $CONFFILENAME = $OPTS{config} // first { -f $_ }
( "./$NAME.conf"
, ($ENV{XDG_CONFIG_HOME} // "$ENV{HOME}/.config")."/lacme/$NAME.conf"
@@ -77,8 +79,9 @@ do {
die "Error: Can't find configuration file\n" unless defined $CONFFILENAME;
print STDERR "Using configuration file: $CONFFILENAME\n" if $OPTS{debug};
open $CONFFILE, '<', $CONFFILENAME or die "Can't open $CONFFILENAME: $!\n";
- my $conf = do { local $/ = undef, <$CONFFILE> };
+ my $conf = do { local $/ = undef; <$CONFFILE> };
# don't close $CONFFILE so we can pass it to the client
+ set_FD_CLOEXEC($CONFFILE, 1);
my $h = Config::Tiny::->read_string($conf) or die Config::Tiny::->errstr()."\n";
my $defaults = delete $h->{_} // {};
@@ -93,12 +96,12 @@ do {
map {$_ => undef} qw/server timeout SSL_verify SSL_version SSL_cipher_list/
},
webserver => {
- listen => ':80',
- 'challenge-directory' => '/var/www/acme-challenge',
+ listen => '/var/run/lacme.socket',
+ 'challenge-directory' => undef,
user => 'www-data',
group => 'www-data',
command => '/usr/lib/lacme/webserver',
- iptables => 'Yes'
+ iptables => 'No'
},
accountd => {
@@ -145,14 +148,10 @@ sub gen_csr(%) {
"[ req_distinguished_name ]\n",
"[ v3_req ]\n",
- # XXX Golang errors on extensions marked critical
- # https://github.com/letsencrypt/boulder/issues/565
- #"basicConstraints = critical, CA:FALSE\n",
- "basicConstraints = CA:FALSE\n",
+ "basicConstraints = critical, CA:FALSE\n",
"subjectKeyIdentifier = hash\n"
);
- #$config->print("keyUsage = critical, $args{keyUsage}\n") if defined $args{keyUsage};
- $config->print("keyUsage = $args{keyUsage}\n") if defined $args{keyUsage};
+ $config->print("keyUsage = critical, $args{keyUsage}\n") if defined $args{keyUsage};
$config->print("subjectAltName = $args{subjectAltName}\n") if defined $args{subjectAltName};
$config->close() or die "Can't close: $!";
@@ -272,12 +271,9 @@ sub set_FD_CLOEXEC($$) {
#############################################################################
-# Try to spawn a webserver to serve ACME challenges, and return the
-# temporary challenge directory.
-# If a webserver is already listening, symlink the 'challenge-directory'
-# configuration option to the temporary challenge directory.
-# Otherwise, bind(2) a socket, pass its fileno to the webserver
-# component, and optionally install iptables rules.
+# If 'listen' is not empty, bind socket(s) to the given addresse(s) and
+# spawn webserver(s) to serve ACME challenge reponses.
+# The temporary challenge directory is returned.
#
sub spawn_webserver() {
# create a temporary directory; give write access to the ACME client
@@ -285,73 +281,151 @@ sub spawn_webserver() {
my $tmpdir = File::Temp::->newdir(CLEANUP => 1, TMPDIR => 1) // die;
chmod 0755, $tmpdir or die "Can't chmod: $!";
if ((my $username = $CONFIG->{client}->{user}) ne '') {
- my $uid = getpwnam($username) // die "Can't getgrnam($username): $!";
+ my $uid = getpwnam($username) // die "Can't getpwnam($username): $!";
chown($uid, -1, $tmpdir) or die "Can't chown: $!";
}
my $conf = $CONFIG->{webserver};
- my ($fam, $addr, $port) = (PF_INET, $conf->{listen}, 80);
- $port = $1 if $addr =~ s/:(\d+)$//;
- $addr = Socket::inet_ntop(PF_INET, INADDR_ANY) if $addr eq '';
- $fam = PF_INET6 if $addr =~ s/^\[(.+)\]$/$1/;
-
- my $proto = getprotobyname("tcp") // die;
- socket(my $srv, $fam, SOCK_STREAM, $proto) or die "socket: $!";
- setsockopt($srv, SOL_SOCKET, SO_REUSEADDR, pack("l", 1)) or die "setsockopt: $!";
- $addr = Socket::inet_pton($fam, $addr) // die "Invalid address $conf->{listen}\n";
- my $sockaddr = $fam == PF_INET ? Socket::pack_sockaddr_in($port, $addr)
- : $fam == PF_INET6 ? Socket::pack_sockaddr_in6($port, $addr)
- : die;
-
- # try to bind aginst the specified address:port
- bind($srv, $sockaddr) or do {
- die "Can't bind to $conf->{listen}: $!" if $! != EADDRINUSE;
- print STDERR "[$$] Using existing webserver on $conf->{listen}\n" if $OPTS{debug};
- my $dir = $conf->{'challenge-directory'};
+
+ # parse and pack addresses to listen to
+ my @sockaddr;
+ foreach my $a (split /[[:blank:],]\s*/, $conf->{listen}) {
+ my $sockaddr;
+ if ($a =~ /\A\//) { # absolute path to a unix domain socket
+ $sockaddr = Socket::pack_sockaddr_un($a);
+ } elsif ($a =~ /\A(\d+(?:\.\d+){3})(?::(\d+))?\z/) {
+ my $n = Socket::inet_pton(AF_INET, $1);
+ $sockaddr = Socket::pack_sockaddr_in($2 // 80, $n) if defined $n;
+ } elsif ($a =~ /\A\[([[:xdigit:]:.]{2,39})\](?::(\d+))?\z/) {
+ my $n = Socket::inet_pton(AF_INET6, $1);
+ $sockaddr = Socket::pack_sockaddr_in6($2 // 80, $n) if defined $n;
+ }
+ die "Invalid address: $a\n" unless defined $sockaddr;
+ push @sockaddr, $sockaddr;
+ }
+
+ # symlink the 'challenge-directory' configuration option to the
+ # temporary challenge directory (so an existing httpd can directly
+ # serve ACME challenge reponses).
+ if (defined (my $dir = $conf->{'challenge-directory'})) {
+ print STDERR "[$$] Using existing webserver on $dir\n" if $OPTS{debug};
symlink $tmpdir, $dir or die "Can't symlink $dir -> $tmpdir: $!";
push @CLEANUP, sub() {
print STDERR "Unlinking $dir\n" if $OPTS{debug};
unlink $dir or warn "Warning: Can't unlink $dir: $!";
+ }
+ }
+ elsif (!@sockaddr) {
+ die "'challenge-directory' option of section [webserver] is required when 'listen' is empty\n";
+ }
+
+ # create socket(s) and spawn webserver(s)
+ my @sockaddr4;
+ foreach my $sockaddr (@sockaddr) {
+ my $domain = Socket::sockaddr_family($sockaddr) // die;
+ socket(my $sock, $domain, SOCK_STREAM, 0) or die "socket: $!";
+ setsockopt($sock, SOL_SOCKET, SO_REUSEADDR, pack("l", 1))
+ if $domain == AF_INET or $domain == AF_INET6;
+
+ my $p; # pretty-print the address/port
+ if ($domain == AF_UNIX) {
+ $p = Socket::unpack_sockaddr_un($sockaddr);
+ } elsif ($domain == AF_INET) {
+ my ($port, $addr) = Socket::unpack_sockaddr_in($sockaddr);
+ $p = Socket::inet_ntop($domain, $addr).":$port";
+ } elsif ($domain == AF_INET6) {
+ my ($port, $addr) = Socket::unpack_sockaddr_in6($sockaddr);
+ $p = "[".Socket::inet_ntop($domain, $addr)."]:$port";
+ }
+
+ if ($domain == AF_UNIX) {
+ # bind(2) with a loose umask(2) to allow anyone to connect
+ my $umask = umask(0111) // die "umask: $!";
+ my $path = Socket::unpack_sockaddr_un($sockaddr);
+ bind($sock, $sockaddr) or die "Couldn't bind to $p: $!";
+ push @CLEANUP, sub() {
+ print STDERR "Unlinking $path\n" if $OPTS{debug};
+ unlink $path or warn "Warning: Can't unlink $path: $!";
+ };
+ umask($umask) // die "umask: $!";
+ }
+ else {
+ bind($sock, $sockaddr) or die "Couldn't bind to $p: $!";
+ }
+
+ listen($sock, 5) or die "listen: $!";
+
+ # spawn a webserver component bound to the given socket
+ my $pid = fork() // "fork: $!";
+ unless ($pid) {
+ drop_privileges($conf->{user}, $conf->{group}, $tmpdir);
+ set_FD_CLOEXEC($sock, 0);
+ $ENV{DEBUG} = $OPTS{debug};
+ # use execve(2) rather than a Perl pseudo-process to ensure that
+ # the child doesn't have access to the parent's memory
+ exec $conf->{command}, fileno($sock) or die;
+ }
+
+ print STDERR "[$$] Forking ACME webserver bound to $p, child PID $pid\n" if $OPTS{debug};
+ set_FD_CLOEXEC($sock, 1);
+ push @CLEANUP, sub() {
+ print STDERR "[$$] Shutting down ACME webserver bound to $p\n" if $OPTS{debug};
+ shutdown($sock, SHUT_RDWR) or warn "shutdown: $!";
+ kill 15 => $pid;
+ waitpid $pid => 0;
};
- return $tmpdir;
- };
- listen($srv, 5) or die "listen: $!";
- # spawn the webserver component
- my $pid = fork() // "fork: $!";
- unless ($pid) {
- drop_privileges($conf->{user}, $conf->{group}, $tmpdir);
- set_FD_CLOEXEC($srv, 0);
- $ENV{DEBUG} = $OPTS{debug};
- # use execve(2) rather than a Perl pseudo-process to ensure that
- # the child doesn't have access to the parent's memory
- exec $conf->{command}, fileno($srv) or die;
+ # on dual-stack ipv4/ipv6, we'll need to open the port for the
+ # v4-mapped address as well
+ if ($domain == AF_INET6) {
+ my $v6only = getsockopt($sock, Socket::IPPROTO_IPV6, Socket::IPV6_V6ONLY)
+ // die "getsockopt(IPV6_V6ONLY): $!";
+ my ($port, $addr) = Socket::unpack_sockaddr_in6($sockaddr);
+ my $mask = "\xFF" x 12 . "\x00" x 4;
+ my $prefix = "\x00" x 10 . "\xFF" x 2 . "\x00" x 4;
+
+ if (unpack('i', $v6only) == 0) {
+ if ($addr eq IN6ADDR_ANY) {
+ push @sockaddr4, Socket::pack_sockaddr_in($port, INADDR_ANY);
+ } elsif (($addr & $mask) eq $prefix) {
+ my $v4 = substr($addr, 12);
+ push @sockaddr4, Socket::pack_sockaddr_in($port, $v4);
+ }
+ }
+ }
}
- print STDERR "[$$] Forking ACME webserver, child PID $pid\n" if $OPTS{debug};
- set_FD_CLOEXEC($srv, 1);
- push @CLEANUP, sub() {
- print STDERR "[$$] Shutting down ACME webserver\n" if $OPTS{debug};
- shutdown($srv, SHUT_RDWR) or warn "shutdown: $!";
- kill 15 => $pid;
- waitpid $pid => 0;
- };
+ # allow incoming traffic on the given addresses
+ if (lc ($conf->{iptables} // 'No') eq 'yes') {
+ iptables_save(AF_INET, @sockaddr, @sockaddr4);
+ iptables_save(AF_INET6, @sockaddr);
+ }
+
+ return $tmpdir;
+}
- return $tmpdir if lc ($conf->{iptables} // 'Yes') eq 'no';
+
+#############################################################################
+# Save current iptables/ipv6tables to a temporary file and install
+# temporary rules to open the given addresses/ports.
+sub iptables_save($@) {
+ my $domain = shift;
+ my @sockaddr = grep { Socket::sockaddr_family($_) == $domain } @_;
+ return unless @sockaddr; # no address in that domain
# install iptables
- my $iptables_bin = $fam == PF_INET ? 'iptables' : $fam == PF_INET6 ? 'ip6tables' : die;
+ my $iptables_bin = $domain == AF_INET ? 'iptables' : $domain == AF_INET6 ? 'ip6tables' : die;
my $iptables_tmp = File::Temp::->new(TMPDIR => 1) // die;
set_FD_CLOEXEC($iptables_tmp, 1);
- my $pid2 = fork() // die "fork: $!";
- unless ($pid2) {
+ my $pid = fork() // die "fork: $!";
+ unless ($pid) {
open STDIN, '<', '/dev/null' or die "Can't open /dev/null: $!";
open STDOUT, '>&', $iptables_tmp or die "Can't dup: $!";
$| = 1; # turn off buffering for STDOUT
exec "/sbin/$iptables_bin-save", "-c" or die;
}
- waitpid $pid2 => 0;
+ waitpid $pid => 0;
die "Error: /sbin/$iptables_bin-save exited with value ".($? >> 8) if $? > 0;
# seek back to the begining, as we'll restore directly from the
@@ -361,32 +435,39 @@ sub spawn_webserver() {
seek($iptables_tmp, SEEK_SET, 0) or die "Can't seek: $!";
push @CLEANUP, sub() {
- print STDERR "[$$] Restoring iptables\n" if $OPTS{debug};
- my $pid2 = fork() // die "fork: $!";
- unless ($pid2) {
+ print STDERR "[$$] Restoring $iptables_bin\n" if $OPTS{debug};
+ my $pid = fork() // die "fork: $!";
+ unless ($pid) {
open STDIN, '<&', $iptables_tmp or die "Can't dup: $!";
open STDOUT, '>', '/dev/null' or die "Can't open /dev/null: $!";
exec "/sbin/$iptables_bin-restore", "-c" or die;
}
- waitpid $pid2 => 0;
+ waitpid $pid => 0;
warn "Warning: /sbin/$iptables_bin-restore exited with value ".($? >> 8) if $? > 0;
};
- # it's safe to install the new iptables to open $port now that the
- # restore hook is in place
- my $mask = $fam == PF_INET ? ($addr eq INADDR_ANY ? '0' : '32')
- : $fam == PF_INET6 ? ($addr eq IN6ADDR_ANY ? '0' : '128')
- : die;
- my $dest = Socket::inet_ntop($fam, $addr) .'/'. $mask;
- system ("/sbin/$iptables_bin", qw/-I INPUT -p tcp -m tcp -m state/,
- '-d', $dest, '--dport', $port,
- '--state', 'NEW,ESTABLISHED', '-j', 'ACCEPT') == 0 or die;
- system ("/sbin/$iptables_bin", qw/-I OUTPUT -p tcp -m tcp -m state/,
- '-s', $dest, '--sport', $port,
- '--state', 'ESTABLISHED', '-j', 'ACCEPT') == 0 or die;
+ # it's safe to install the new iptables to open $addr:$port now that
+ # the restore hook is in place
- return $tmpdir;
+ foreach my $sockaddr (@sockaddr) {
+ my ($port, $addr, $mask);
+ if ($domain == AF_INET) {
+ ($port, $addr) = Socket::unpack_sockaddr_in($sockaddr);
+ $mask = $addr eq INADDR_ANY ? '0' : '32';
+ } elsif ($domain == AF_INET6) {
+ ($port, $addr) = Socket::unpack_sockaddr_in6($sockaddr);
+ $mask = $addr eq IN6ADDR_ANY ? '0' : '128';
+ }
+
+ my $dest = Socket::inet_ntop($domain, $addr) .'/'. $mask;
+ system ("/sbin/$iptables_bin", qw/-I INPUT -p tcp -m tcp -m state/,
+ '-d', $dest, '--dport', $port,
+ '--state', 'NEW,ESTABLISHED', '-j', 'ACCEPT') == 0 or die;
+ system ("/sbin/$iptables_bin", qw/-I OUTPUT -p tcp -m tcp -m state/,
+ '-s', $dest, '--sport', $port,
+ '--state', 'ESTABLISHED', '-j', 'ACCEPT') == 0 or die;
+ }
}
@@ -523,19 +604,35 @@ sub spawn($@) {
#############################################################################
# Install the certificate
#
-sub install_cert($$@) {
+sub install_cert($$;$) {
my $filename = shift;
my $x509 = shift;
-
- open my $fh, '>', $filename or die "Can't open $filename: $!";
- print $fh $x509;
- foreach (@_) { # append the chain
- open my $fh2, '<', $_ or die "Can't open $_: $!";
- my $ca = do { local $/ = undef; $fh2->getline() };
- print $fh $ca;
- close $fh2 or die "Can't close: $!";
+ my @chain = grep !/\A\s*\z/, @_; # ignore empty CAfile
+
+ my ($dirname, $basename) =
+ $filename =~ /\A(.*)\/([^\/]+)\z/ ? ($1, $2) : ('.', $filename);
+ my $fh = File::Temp::->new(UNLINK => 0, DIR => $dirname,
+ TEMPLATE => "$basename.XXXXXX") // die;
+
+ eval {
+ my $umask = umask() // die "umask: $!";
+ chmod(0644 &~ $umask, $fh) or die "chmod: $!";
+ $fh->print($x509) or die "Can't print: $!";
+ foreach (@chain) { # append the chain
+ open my $fh2, '<', $_ or die "Can't open $_: $!";
+ my $ca = do { local $/ = undef; $fh2->getline() };
+ $fh2->close() or die "Can't close: $!";
+ $fh->print($ca) or die "Can't print: $!";
+ }
+ $fh->close() or die "Can't close: $!";
+ };
+ my $path = $fh->filename();
+ if ($@) {
+ print STDERR "Unlinking $path\n" if $OPTS{debug};
+ unlink $path or warn "Can't unlink $path: $!";
+ die $@;
}
- close $fh or die "Can't close: $!";
+ rename($path, $filename) or die "Can't rename $path to $filename: $!";
}
@@ -562,7 +659,7 @@ elsif ($COMMAND eq 'new-cert') {
my $conffiles = defined $OPTS{'config-certs'} ? $OPTS{'config-certs'}
: defined $CONFIG->{_}->{'config-certs'} ? [ split(/\s+/, $CONFIG->{_}->{'config-certs'}) ]
: [ "$NAME-certs.conf", "$NAME-certs.conf.d/" ];
- my $conf;
+ my ($conf, %defaults);
foreach my $conffile (@$conffiles) {
$conffile = ($CONFFILENAME =~ s#[^/]+\z##r).$conffile unless $conffile =~ /\A\//;
my @filenames;
@@ -582,26 +679,32 @@ elsif ($COMMAND eq 'new-cert') {
foreach my $filename (sort @filenames) {
print STDERR "Reading $filename\n" if $OPTS{debug};
my $h = Config::Tiny::->read($filename) or die Config::Tiny::->errstr()."\n";
- my $defaults = delete $h->{_} // {};
+ my $def = delete $h->{_} // {};
+ $defaults{$_} = $def->{$_} foreach keys %$def;
my @valid = qw/certificate certificate-chain certificate-key min-days CAfile
hash keyUsage subject subjectAltName chown chmod notify/;
foreach my $s (keys %$h) {
$conf->{$s} = { map { $_ => delete $h->{$s}->{$_} } @valid };
die "Unknown option(s) in [$s]: ".join(', ', keys %{$h->{$s}})."\n" if %{$h->{$s}};
- $conf->{$s}->{$_} //= $defaults->{$_} foreach keys %$defaults;
+ $conf->{$s}->{$_} //= $defaults{$_} foreach keys %defaults;
}
}
}
my $challenge_dir;
my $rv = 0;
- foreach my $s (@ARGV ? @ARGV : keys %$conf) {
+ foreach my $s (@ARGV ? @ARGV : sort (keys %$conf)) {
my $conf = $conf->{$s} // do {
print STDERR "Warning: No such section $s, skipping\n";
$rv = 1;
next;
};
+ if ($OPTS{debug}) {
+ print STDERR "Configuration option for $s:\n";
+ print " $_ = $conf->{$_}\n" foreach grep { defined $conf->{$_} } (sort keys %$conf);
+ }
+
my $certtype = first { defined $conf->{$_} } qw/certificate certificate-chain/;
unless (defined $certtype) {
print STDERR "[$s] Warning: Missing 'certificate' and 'certificate-chain', skipping\n";
@@ -611,8 +714,8 @@ elsif ($COMMAND eq 'new-cert') {
# skip certificates that expire at least $conf->{'min-days'} days in the future
if (-f $conf->{$certtype} and defined (my $t = x509_enddate($conf->{$certtype}))) {
- my $d = $conf->{'min-days'} // 10;
- if ($d > 0 and $t - time > $d*86400) {
+ my $d = $OPTS{'min-days'} // $conf->{'min-days'} // 21;
+ if ($d >= 0 and $t - time > $d*86400) {
my $d = POSIX::strftime('%Y-%m-%d %H:%M:%S UTC', gmtime($t));
print STDERR "[$s] Valid until $d, skipping\n" unless $OPTS{quiet};
next;
diff --git a/lacme.md b/lacme.md
index f5b5559..4146515 100644
--- a/lacme.md
+++ b/lacme.md
@@ -51,13 +51,12 @@ with its own executable:
4. For certificate issuances (`new-cert` command), an optional
webserver (specified with the *command* option of the [`[webserver]`
section](#webserver-section) of the configuration file), which is
- spawned by the “master” `lacme` process when no service is listening
- on the HTTP port. (The only challenge type currently supported by
- `lacme` is `http-01`, which requires a webserver to answer
- challenges.) That webserver only processes `GET` and `HEAD` requests
- under the `/.well-known/acme-challenge/` URI.
- By default some [`iptables`(8)] rules are automatically installed to
- open the HTTP port, and removed afterwards.
+ spawned by the “master” `lacme`. (The only challenge type currently
+ supported by `lacme` is `http-01`, which requires a webserver to
+ answer challenges.) That webserver only processes `GET` and `HEAD`
+ requests under the `/.well-known/acme-challenge/` URI.
+ Moreover temporary [`iptables`(8)] rules can be automatically
+ installed to open the HTTP port.
Commands
========
@@ -88,7 +87,7 @@ Commands
When specified, the list of *CONTACT* information and the agreement
*URI* are sent to the server to replace the existing values.
-`lacme` [`--config-certs=`*FILE*] `new-cert` [*SECTION* …]
+`lacme` [`--config-certs=`*FILE*] [`--min-days=`*INT*] `new-cert` [*SECTION* …]
: Read the certificate configuration *FILE* (see the **[certificate
configuration file](#certificate-configuration-file)** section below
@@ -228,18 +227,19 @@ This section is used for configuring the [ACME] webserver.
*listen*
-: Specify the local address to listen on, in the form
- `ADDRESS[:PORT]`. If `ADDRESS` is enclosed with brackets ‘[’/‘]’
- then it denotes an IPv6; an empty `ADDRESS` means `0.0.0.0`.
- Default: `:80`.
+: Comma- or space-separated list of addresses to listen on. Valid
+ addresses are of the form `IPV4:PORT`, `[IPV6]:PORT` (where the
+ `:PORT` suffix is optional and defaults to the HTTP port 80), or an
+ absolute path of a UNIX-domain socket (created with mode `0666`).
+ Default: `/var/run/lacme.socket`.
*challenge-directory*
-: If a webserver is already running, specify a non-existent directory
- under which the webserver is configured to serve `GET` requests for
- challenge files under `/.well-known/acme-challenge/` (for each
- virtual hosts requiring authorization) as static files.
- Default: `/var/www/acme-challenge`.
+: Specify a non-existent directory under which an external HTTP daemon
+ is configured to serve `GET` requests for challenge files under
+ `/.well-known/acme-challenge/` (for each virtual host requiring
+ authorization) as static files.
+ This option is required when *listen* is empty.
*user*
@@ -263,10 +263,10 @@ This section is used for configuring the [ACME] webserver.
*iptables*
-: Whether to automatically install [`iptables`(8)] rules to open the
- `ADDRESS[:PORT]` specified with *listen*. Theses rules are
+: Whether to automatically install temporary [`iptables`(8)] rules to
+ open the `ADDRESS[:PORT]` specified with *listen*. The rules are
automatically removed once `lacme` exits.
- Default: `Yes`.
+ Default: `No`.
`[accountd]` section
---------------------
@@ -339,7 +339,10 @@ Valid options are:
: For an existing certificate, the minimum number of days before its
expiration date the section is considered for re-issuance.
- Default: `10`.
+ A negative value forces reissuance, while the number `0` limits
+ reissuance to expired certificates.
+ Default: the value of the CLI option `--min-days`, or `21` if there
+ is no such option.
*CAfile*
diff --git a/webserver b/webserver
index e97fe00..7914762 100755
--- a/webserver
+++ b/webserver
@@ -38,12 +38,9 @@ use warnings;
# not a problem since FD can be bound as root prior to the execve(2).
use Errno 'EINTR';
-use Socket qw/AF_INET AF_INET6/;
+use Socket qw/AF_UNIX AF_INET AF_INET6/;
# Untaint and fdopen(3) the listening socket
-# TODO: we could even take multiple file descriptors and select(2)
-# between them; this could be useful to listen on two sockets, one for
-# INET and one for INET6
(shift @ARGV // die) =~ /\A(\d+)\z/ or die;
open my $S, '+<&=', $1 or die "fdopen $1: $!";
my $ROOT = '/.well-known/acme-challenge';
@@ -57,13 +54,22 @@ sub info($$$) {
# get a string representation of the peer's address
my $fam = Socket::sockaddr_family($sockaddr);
- my (undef, $ip) =
- $fam == AF_INET ? Socket::unpack_sockaddr_in($sockaddr) :
- $fam == AF_INET6 ? Socket::unpack_sockaddr_in6($sockaddr) :
- die;
- my $addr = Socket::inet_ntop($fam, $ip);
+ my $peer;
- print STDERR $msg." from [$addr]".(defined $req ? ": $req" : "")."\n";
+ if ($fam == AF_UNIX) {
+ $peer = Socket::unpack_sockaddr_un($sockaddr);
+ } else {
+ my (undef, $ip) =
+ $fam == AF_INET ? Socket::unpack_sockaddr_in($sockaddr) :
+ $fam == AF_INET6 ? Socket::unpack_sockaddr_in6($sockaddr) :
+ die;
+ $peer = Socket::inet_ntop($fam, $ip);
+ }
+
+ $msg .= " from [$peer]" if defined $peer and $peer ne '';
+ $msg .= ": $req" if defined $req;
+
+ print STDERR $msg, "\n";
}
while (1) {