From 05aafaa0c740d7a17e6e3f17634ae6990187f22d Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Sun, 19 Feb 2017 12:28:39 +0100 Subject: wibble --- client | 2 +- lacme | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client b/client index 3bf0bad..cd94ed8 100755 --- a/client +++ b/client @@ -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/lacme b/lacme index cb49818..1951ed7 100755 --- a/lacme +++ b/lacme @@ -77,7 +77,7 @@ 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 my $h = Config::Tiny::->read_string($conf) or die Config::Tiny::->errstr()."\n"; -- cgit v1.2.3 From fc117d6513dfa1e6287927a9b95ac0558eaea951 Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Sun, 19 Feb 2017 13:21:38 +0100 Subject: config-cert: import the default section of files already read. --- Changelog | 7 +++++++ lacme | 12 +++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/Changelog b/Changelog index 6f212b0..0336e5b 100644 --- a/Changelog +++ b/Changelog @@ -1,3 +1,10 @@ +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. + + -- Guilhem Moulin Sun, 19 Feb 2017 13:08:41 +0100 + lacme (0.2) upstream; + Honor Retry-After headers for certificate issuance and challenge diff --git a/lacme b/lacme index 1951ed7..6c7f48d 100755 --- a/lacme +++ b/lacme @@ -562,7 +562,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,13 +582,14 @@ 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; } } } @@ -602,6 +603,11 @@ elsif ($COMMAND eq 'new-cert') { 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"; -- cgit v1.2.3 From bbbd329e9a1274d0a7bfb7b741894f5417b43538 Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Sun, 19 Feb 2017 13:23:51 +0100 Subject: Ensure lacme's config file descriptor has the FD_CLOEXEC bit set. --- Changelog | 2 ++ lacme | 2 ++ 2 files changed, 4 insertions(+) diff --git a/Changelog b/Changelog index 0336e5b..d9aacd0 100644 --- a/Changelog +++ b/Changelog @@ -2,6 +2,8 @@ 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. + - Ensure lacme's config file descriptor is not passed to the accountd + or webserver components. -- Guilhem Moulin Sun, 19 Feb 2017 13:08:41 +0100 diff --git a/lacme b/lacme index 6c7f48d..8cbed17 100755 --- a/lacme +++ b/lacme @@ -68,6 +68,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" @@ -79,6 +80,7 @@ do { open $CONFFILE, '<', $CONFFILENAME or die "Can't open $CONFFILENAME: $!\n"; 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->{_} // {}; -- cgit v1.2.3 From de585094c458a36a387277544bda5f4004bbb03c Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Sun, 19 Feb 2017 13:24:07 +0100 Subject: new-cert: sort section names if not passed explicitely. --- Changelog | 1 + lacme | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Changelog b/Changelog index d9aacd0..a622a5d 100644 --- a/Changelog +++ b/Changelog @@ -4,6 +4,7 @@ lacme (0.3) upstream; lacme-certs.conf.d"), import the default section of files read earlier. - Ensure lacme's config file descriptor is not passed to the accountd or webserver components. + - new-cert: sort section names if not passed explicitely. -- Guilhem Moulin Sun, 19 Feb 2017 13:08:41 +0100 diff --git a/lacme b/lacme index 8cbed17..f9b3530 100755 --- a/lacme +++ b/lacme @@ -598,7 +598,7 @@ elsif ($COMMAND eq 'new-cert') { 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; -- cgit v1.2.3 From 84f6363da57ccc3a58fc72f60cf51ca70cea34f6 Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Sun, 19 Feb 2017 13:36:11 +0100 Subject: new-cert: new CLI option "min-days" --- Changelog | 2 ++ lacme | 6 +++--- lacme.md | 7 +++++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/Changelog b/Changelog index a622a5d..accd89c 100644 --- a/Changelog +++ b/Changelog @@ -5,6 +5,8 @@ lacme (0.3) upstream; - 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. -- Guilhem Moulin Sun, 19 Feb 2017 13:08:41 +0100 diff --git a/lacme b/lacme index f9b3530..75c1465 100755 --- a/lacme +++ b/lacme @@ -60,7 +60,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"); @@ -619,8 +619,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'} // 10; + 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; diff --git a/lacme.md b/lacme.md index f5b5559..aec2cd1 100644 --- a/lacme.md +++ b/lacme.md @@ -88,7 +88,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 @@ -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 `10` if there + is no such option. *CAfile* -- cgit v1.2.3 From 23f051faf049e5020b81e6bf419e35f3d5054da2 Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Wed, 22 Feb 2017 10:14:31 +0100 Subject: Changelog: prefix bugfixes with '+'. --- Changelog | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Changelog b/Changelog index accd89c..035451c 100644 --- a/Changelog +++ b/Changelog @@ -1,6 +1,6 @@ lacme (0.3) upstream; - - When parsing config-cert files and directories (default "lacme-certs.conf + + When parsing config-cert files and directories (default "lacme-certs.conf lacme-certs.conf.d"), import the default section of files read earlier. - Ensure lacme's config file descriptor is not passed to the accountd or webserver components. -- cgit v1.2.3 From 1426a858ae1c4da30f777110e1253fa36bac2b41 Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Wed, 22 Feb 2017 10:19:56 +0100 Subject: new-cert: mark basicConstraints and keyUsage x509v3 extensions as critical in the CSR. Boulder's issue #565 "Golang errors on extensions marked critical" was fixed upstream, cf. https://github.com/letsencrypt/boulder/issues/565 . --- Changelog | 3 +++ lacme | 8 ++------ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/Changelog b/Changelog index 035451c..451eace 100644 --- a/Changelog +++ b/Changelog @@ -7,6 +7,9 @@ lacme (0.3) upstream; - 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 Sun, 19 Feb 2017 13:08:41 +0100 diff --git a/lacme b/lacme index 75c1465..b654c7d 100755 --- a/lacme +++ b/lacme @@ -147,14 +147,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: $!"; -- cgit v1.2.3 From f4af28d7e526bd56a78225daf84d11cdf96bd611 Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Wed, 22 Feb 2017 10:51:08 +0100 Subject: new-cert: create certificate files atomically. --- Changelog | 1 + lacme | 27 ++++++++++++++++++--------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/Changelog b/Changelog index 451eace..b23191f 100644 --- a/Changelog +++ b/Changelog @@ -2,6 +2,7 @@ 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. - 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/lacme b/lacme index b654c7d..a8c25fe 100755 --- a/lacme +++ b/lacme @@ -524,16 +524,25 @@ sub spawn($@) { 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 = @_; + + my $tmp = "$filename.new"; + open my $fh, '>', $tmp or die "Can't open $tmp: $!"; + eval { + $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: $!"; + }; + if ($@) { + unlink $tmp or warn "Can't unlink $tmp: $!"; + die $@; } - close $fh or die "Can't close: $!"; + rename($tmp, $filename) or die "Can't rename $tmp to $filename: $!"; } -- cgit v1.2.3 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. --- Changelog | 7 ++ config/lacme.conf | 34 ++++----- lacme | 225 +++++++++++++++++++++++++++++++++++++----------------- lacme.md | 38 ++++----- webserver | 26 ++++--- 5 files changed, 212 insertions(+), 118 deletions(-) diff --git a/Changelog b/Changelog index b23191f..fdb0775 100644 --- a/Changelog +++ b/Changelog @@ -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 . + + 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 +# 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/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; + } } diff --git a/lacme.md b/lacme.md index aec2cd1..94b40cb 100644 --- a/lacme.md +++ b/lacme.md @@ -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 --------------------- diff --git a/webserver b/webserver index e97fe00..7914762 100755 --- a/webserver +++ b/webserver @@ -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) { -- cgit v1.2.3 From 80c3a95a95ed268905fa87a398748f94628eed44 Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Wed, 28 Jun 2017 21:26:00 +0200 Subject: new-cert: use File::Temp for the temporary cert filename. This ensures we aren't overwritting existing /path/to/srv.pem.new files. --- lacme | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/lacme b/lacme index b4d09e8..fd90d1e 100755 --- a/lacme +++ b/lacme @@ -604,14 +604,19 @@ sub spawn($@) { ############################################################################# # Install the certificate # -sub install_cert($$@) { +sub install_cert($$;$) { my $filename = shift; my $x509 = shift; - my @chain = @_; + 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; - my $tmp = "$filename.new"; - open my $fh, '>', $tmp or die "Can't open $tmp: $!"; 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 $_: $!"; @@ -621,11 +626,13 @@ sub install_cert($$@) { } $fh->close() or die "Can't close: $!"; }; + my $path = $fh->filename(); if ($@) { - unlink $tmp or warn "Can't unlink $tmp: $!"; + print STDERR "Unlinking $path\n" if $OPTS{debug}; + unlink $path or warn "Can't unlink $path: $!"; die $@; } - rename($tmp, $filename) or die "Can't rename $tmp to $filename: $!"; + rename($path, $filename) or die "Can't rename $path to $filename: $!"; } -- cgit v1.2.3 From 40a54d2ad35630b1c8a7cd88791db032a7983d4d Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Wed, 28 Jun 2017 21:33:40 +0200 Subject: Change the default 'min-days' from 10 to 21. This avoids expiration notices from Let's Encrypt when auto-renewal is done by a cronjob: Let's Encrypt sends a notice 19 (then 9) days before expiration. --- Changelog | 2 ++ config/lacme-certs.conf | 2 +- lacme | 2 +- lacme.md | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Changelog b/Changelog index fdb0775..0619ffd 100644 --- a/Changelog +++ b/Changelog @@ -10,6 +10,8 @@ lacme (0.3) upstream; 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. - 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-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/lacme b/lacme index fd90d1e..d7a416e 100755 --- a/lacme +++ b/lacme @@ -714,7 +714,7 @@ 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 = $OPTS{'min-days'} // $conf->{'min-days'} // 10; + 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}; diff --git a/lacme.md b/lacme.md index 94b40cb..4146515 100644 --- a/lacme.md +++ b/lacme.md @@ -341,7 +341,7 @@ Valid options are: expiration date the section is considered for re-issuance. A negative value forces reissuance, while the number `0` limits reissuance to expired certificates. - Default: the value of the CLI option `--min-days`, or `10` if there + Default: the value of the CLI option `--min-days`, or `21` if there is no such option. *CAfile* -- cgit v1.2.3 From 99902d8737cd01b2788ec51b06d314a36135be2c Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Wed, 28 Jun 2017 22:11:04 +0200 Subject: Provide nginx configuration snippet. --- Changelog | 1 + config/nginx.conf | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 config/nginx.conf diff --git a/Changelog b/Changelog index 0619ffd..59d5153 100644 --- a/Changelog +++ b/Changelog @@ -12,6 +12,7 @@ lacme (0.3) upstream; '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. 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; +} -- cgit v1.2.3