diff options
-rw-r--r-- | Changelog | 7 | ||||
-rw-r--r-- | config/lacme.conf | 34 | ||||
-rwxr-xr-x | lacme | 225 | ||||
-rw-r--r-- | lacme.md | 38 | ||||
-rwxr-xr-x | webserver | 26 |
5 files changed, 212 insertions, 118 deletions
@@ -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. # @@ -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; + } } @@ -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 --------------------- @@ -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) { |