diff options
| author | Guilhem Moulin <guilhem@fripost.org> | 2017-06-28 22:33:37 +0200 | 
|---|---|---|
| committer | Guilhem Moulin <guilhem@fripost.org> | 2017-06-28 22:33:37 +0200 | 
| commit | 4a730d372818f86ae42dbe1d89ec63fc67c9f462 (patch) | |
| tree | f98bf6e24a4e0284472703b2e60c449037c24b9d | |
| parent | 871aa1f53d428f31902b4428f8bae11ccea8c5f7 (diff) | |
| parent | 99902d8737cd01b2788ec51b06d314a36135be2c (diff) | |
Merge branch 'master' into debian
| -rw-r--r-- | Changelog | 26 | ||||
| -rwxr-xr-x | client | 2 | ||||
| -rw-r--r-- | config/lacme-certs.conf | 2 | ||||
| -rw-r--r-- | config/lacme.conf | 34 | ||||
| -rw-r--r-- | config/nginx.conf | 18 | ||||
| -rwxr-xr-x | lacme | 293 | ||||
| -rw-r--r-- | lacme.md | 45 | ||||
| -rwxr-xr-x | webserver | 26 | 
8 files changed, 300 insertions, 146 deletions
| @@ -1,3 +1,29 @@ +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. +  + Change 'min-days' default from 10 to 21, to avoid expiration notices +    from Let's Encrypt when auto-renewal is done by a cronjob. +  + Provide nginx configuration snippet. +  - Ensure lacme's config file descriptor is not passed to the accountd +    or webserver components. +  - new-cert: sort section names if not passed explicitely. +  - new-cert: new CLI option "min-days" overriding the value found in +    the configuration file. +  - new-cert: mark the basicConstraints (CA:FALSE) and keyUsage x509v3 +    extensions as critical in the CSR, following upstream fix of +    Boulder's issue #565. + + -- Guilhem Moulin <guilhem@guilhem.org>  Sun, 19 Feb 2017 13:08:41 +0100 +  lacme (0.2) upstream;    + Honor Retry-After headers for certificate issuance and challenge @@ -85,7 +85,7 @@ my $NONCE;  #  my $CONFIG = do { -    my $conf = do { local $/ = undef, <$CONFFILE> }; +    my $conf = do { local $/ = undef; <$CONFFILE> };      close $CONFFILE or die "Can't close: $!";      my $h = Config::Tiny::->read_string($conf) or die Config::Tiny::->errstr()."\n";      $h->{_} //= {}; diff --git a/config/lacme-certs.conf b/config/lacme-certs.conf index 12fcd54..97d588a 100644 --- a/config/lacme-certs.conf +++ b/config/lacme-certs.conf @@ -28,7 +28,7 @@  # For an existing certificate, the minimum number of days before its  # expiration date the section is considered for re-issuance.  # -#min-days = 10 +#min-days = 21  # Path to the issuer's certificate.  This is used for certificate-chain  # and to verify the validity of each issued certificate.  Specifying an 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/config/nginx.conf b/config/nginx.conf new file mode 100644 index 0000000..f842c12 --- /dev/null +++ b/config/nginx.conf @@ -0,0 +1,18 @@ +# Let nginx serve ACME requests directly, or pass them to lacme's +# webserver component. +# +# This file needs to be sourced to the server directives (at least the +# non-ssl one) of each virtual host requiring authorization. + +location /.well-known/acme-challenge/ { +    # Pass ACME requests to lacme's webserver component +    proxy_pass http://unix:/var/run/lacme.socket; + +    ## Alternatively, you can let nginx serve the requests by +    ## setting 'challenge-directory' to '/var/www/acme-challenge' in +    ## lacme's configuration file +    # alias /var/www/acme-challenge/; +    # default_type application/jose+json; +    # disable_symlinks on from=$document_root; +    # autoindex off; +} @@ -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 (); @@ -60,7 +61,7 @@ sub usage(;$$) {      }      exit $rv;  } -usage(1) unless GetOptions(\%OPTS, qw/config=s config-certs=s@ socket=s agreement-uri=s quiet|q debug help|h/); +usage(1) unless GetOptions(\%OPTS, qw/config=s config-certs=s@ socket=s agreement-uri=s min-days=i quiet|q debug help|h/);  usage(0) if $OPTS{help};  $COMMAND = shift(@ARGV) // usage(1, "Missing command"); @@ -68,6 +69,7 @@ $COMMAND = $COMMAND =~ /\A(new-reg|reg=\p{Print}*|new-cert|revoke-cert)\z/ ? $1           : usage(1, "Invalid command: $COMMAND"); # validate and untaint $COMMAND  @ARGV = map { /\A(\p{Print}*)\z/ ? $1 : die } @ARGV; # untaint @ARGV +sub set_FD_CLOEXEC($$);  my $CONFFILENAME = $OPTS{config} // first { -f $_ }     ( "./$NAME.conf"     , ($ENV{XDG_CONFIG_HOME} // "$ENV{HOME}/.config")."/lacme/$NAME.conf" @@ -77,8 +79,9 @@ do {      die "Error: Can't find configuration file\n" unless defined $CONFFILENAME;      print STDERR "Using configuration file: $CONFFILENAME\n" if $OPTS{debug};      open $CONFFILE, '<', $CONFFILENAME or die "Can't open $CONFFILENAME: $!\n"; -    my $conf = do { local $/ = undef, <$CONFFILE> }; +    my $conf = do { local $/ = undef; <$CONFFILE> };      # don't close $CONFFILE so we can pass it to the client +    set_FD_CLOEXEC($CONFFILE, 1);      my $h = Config::Tiny::->read_string($conf) or die Config::Tiny::->errstr()."\n";      my $defaults = delete $h->{_} // {}; @@ -93,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 => { @@ -145,14 +148,10 @@ sub gen_csr(%) {          "[ req_distinguished_name ]\n",          "[ v3_req ]\n", -        # XXX Golang errors on extensions marked critical -        # https://github.com/letsencrypt/boulder/issues/565 -        #"basicConstraints     = critical, CA:FALSE\n", -        "basicConstraints     = CA:FALSE\n", +        "basicConstraints     = critical, CA:FALSE\n",          "subjectKeyIdentifier = hash\n"      ); -    #$config->print("keyUsage = critical, $args{keyUsage}\n")   if defined $args{keyUsage}; -    $config->print("keyUsage = $args{keyUsage}\n")             if defined $args{keyUsage}; +    $config->print("keyUsage = critical, $args{keyUsage}\n")   if defined $args{keyUsage};      $config->print("subjectAltName = $args{subjectAltName}\n") if defined $args{subjectAltName};      $config->close() or die "Can't close: $!"; @@ -272,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 @@ -285,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 @@ -361,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; +    }  } @@ -523,19 +604,35 @@ sub spawn($@) {  #############################################################################  # Install the certificate  # -sub install_cert($$@) { +sub install_cert($$;$) {      my $filename = shift;      my $x509 = shift; - -    open my $fh, '>', $filename or die "Can't open $filename: $!"; -    print $fh $x509; -    foreach (@_) { # append the chain -        open my $fh2, '<', $_ or die "Can't open $_: $!"; -        my $ca = do { local $/ = undef; $fh2->getline() }; -        print $fh $ca; -        close $fh2 or die "Can't close: $!"; +    my @chain = grep !/\A\s*\z/, @_; # ignore empty CAfile + +    my ($dirname, $basename) = +        $filename =~ /\A(.*)\/([^\/]+)\z/ ? ($1, $2) : ('.', $filename); +    my $fh = File::Temp::->new(UNLINK => 0, DIR => $dirname, +                               TEMPLATE => "$basename.XXXXXX") // die; + +    eval { +        my $umask = umask() // die "umask: $!"; +        chmod(0644 &~ $umask, $fh) or die "chmod: $!"; +        $fh->print($x509) or die "Can't print: $!"; +        foreach (@chain) { # append the chain +            open my $fh2, '<', $_ or die "Can't open $_: $!"; +            my $ca = do { local $/ = undef; $fh2->getline() }; +            $fh2->close() or die "Can't close: $!"; +            $fh->print($ca) or die "Can't print: $!"; +        } +        $fh->close() or die "Can't close: $!"; +    }; +    my $path = $fh->filename(); +    if ($@) { +        print STDERR "Unlinking $path\n" if $OPTS{debug}; +        unlink $path or warn "Can't unlink $path: $!"; +        die $@;      } -    close $fh or die "Can't close: $!"; +    rename($path, $filename) or die "Can't rename $path to $filename: $!";  } @@ -562,7 +659,7 @@ elsif ($COMMAND eq 'new-cert') {      my $conffiles = defined $OPTS{'config-certs'} ? $OPTS{'config-certs'}                    : defined $CONFIG->{_}->{'config-certs'} ? [ split(/\s+/, $CONFIG->{_}->{'config-certs'}) ]                    : [ "$NAME-certs.conf", "$NAME-certs.conf.d/" ]; -    my $conf; +    my ($conf, %defaults);      foreach my $conffile (@$conffiles) {          $conffile = ($CONFFILENAME =~ s#[^/]+\z##r).$conffile unless $conffile =~ /\A\//;          my @filenames; @@ -582,26 +679,32 @@ elsif ($COMMAND eq 'new-cert') {          foreach my $filename (sort @filenames) {              print STDERR "Reading $filename\n" if $OPTS{debug};              my $h = Config::Tiny::->read($filename) or die Config::Tiny::->errstr()."\n"; -            my $defaults = delete $h->{_} // {}; +            my $def = delete $h->{_} // {}; +            $defaults{$_} = $def->{$_} foreach keys %$def;              my @valid = qw/certificate certificate-chain certificate-key min-days CAfile                             hash keyUsage subject subjectAltName chown chmod notify/;              foreach my $s (keys %$h) {                  $conf->{$s} = { map { $_ => delete $h->{$s}->{$_} } @valid };                  die "Unknown option(s) in [$s]: ".join(', ', keys %{$h->{$s}})."\n" if %{$h->{$s}}; -                $conf->{$s}->{$_} //= $defaults->{$_} foreach keys %$defaults; +                $conf->{$s}->{$_} //= $defaults{$_} foreach keys %defaults;              }          }      }      my $challenge_dir;      my $rv = 0; -    foreach my $s (@ARGV ? @ARGV : keys %$conf) { +    foreach my $s (@ARGV ? @ARGV : sort (keys %$conf)) {          my $conf = $conf->{$s} // do {              print STDERR "Warning: No such section $s, skipping\n";              $rv = 1;              next;          }; +        if ($OPTS{debug}) { +            print STDERR "Configuration option for $s:\n"; +            print "    $_ = $conf->{$_}\n" foreach grep { defined $conf->{$_} } (sort keys %$conf); +        } +          my $certtype = first { defined $conf->{$_} } qw/certificate certificate-chain/;          unless (defined $certtype) {              print STDERR "[$s] Warning: Missing 'certificate' and 'certificate-chain', skipping\n"; @@ -611,8 +714,8 @@ elsif ($COMMAND eq 'new-cert') {          # skip certificates that expire at least $conf->{'min-days'} days in the future          if (-f $conf->{$certtype} and defined (my $t = x509_enddate($conf->{$certtype}))) { -            my $d = $conf->{'min-days'} // 10; -            if ($d > 0 and $t - time > $d*86400) { +            my $d = $OPTS{'min-days'} // $conf->{'min-days'} // 21; +            if ($d >= 0 and $t - time > $d*86400) {                  my $d = POSIX::strftime('%Y-%m-%d %H:%M:%S UTC', gmtime($t));                  print STDERR "[$s] Valid until $d, skipping\n" unless $OPTS{quiet};                  next; @@ -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  ======== @@ -88,7 +87,7 @@ Commands      When specified, the list of *CONTACT* information and the agreement      *URI* are sent to the server to replace the existing values. -`lacme` [`--config-certs=`*FILE*] `new-cert` [*SECTION* …] +`lacme` [`--config-certs=`*FILE*] [`--min-days=`*INT*] `new-cert` [*SECTION* …]  :   Read the certificate configuration *FILE* (see the **[certificate      configuration file](#certificate-configuration-file)** section below @@ -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  --------------------- @@ -339,7 +339,10 @@ Valid options are:  :   For an existing certificate, the minimum number of days before its      expiration date the section is considered for re-issuance. -    Default: `10`. +    A negative value forces reissuance, while the number `0` limits +    reissuance to expired certificates. +    Default: the value of the CLI option `--min-days`, or `21` if there +    is no such option.  *CAfile* @@ -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) { | 
