From 944407621f313c15f6cfd53267da1ddbdaceec9f Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Wed, 28 Jun 2017 17:19:46 +0200 Subject: webserver: allow listening to multiple addresses. (Useful when dual-stack IPv4/IPv6 is not supported.) Also, change the default to listen to a UNIX-domain socket . Moreover temporary iptables rules are no longer installed. 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. --- lacme | 225 +++++++++++++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 154 insertions(+), 71 deletions(-) (limited to 'lacme') 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; + } } -- cgit v1.2.3