aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Changelog7
-rw-r--r--config/lacme.conf34
-rwxr-xr-xlacme225
-rw-r--r--lacme.md38
-rwxr-xr-xwebserver26
5 files changed, 212 insertions, 118 deletions
diff --git a/Changelog b/Changelog
index b23191f..fdb0775 100644
--- a/Changelog
+++ b/Changelog
@@ -3,6 +3,13 @@ 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.
- Ensure lacme's config file descriptor is not passed to the accountd
or webserver components.
- new-cert: sort section names if not passed explicitely.
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/lacme b/lacme
index a8c25fe..b4d09e8 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 ();
@@ -95,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 => {
@@ -270,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
@@ -283,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
@@ -359,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;
+ }
}
diff --git a/lacme.md b/lacme.md
index aec2cd1..94b40cb 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
========
@@ -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
---------------------
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) {