aboutsummaryrefslogtreecommitdiffstats
path: root/lacme
diff options
context:
space:
mode:
authorGuilhem Moulin <guilhem@fripost.org>2017-06-28 17:19:46 +0200
committerGuilhem Moulin <guilhem@fripost.org>2017-06-28 22:09:43 +0200
commit944407621f313c15f6cfd53267da1ddbdaceec9f (patch)
tree1602c3136d28ac54dafec995a7b6d0a6e83ff8e2 /lacme
parentf4af28d7e526bd56a78225daf84d11cdf96bd611 (diff)
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 </var/run/lacme.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.
Diffstat (limited to 'lacme')
-rwxr-xr-xlacme225
1 files changed, 154 insertions, 71 deletions
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;
+ }
}