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) { | 
