aboutsummaryrefslogtreecommitdiffstats
path: root/lacme
diff options
context:
space:
mode:
Diffstat (limited to 'lacme')
-rwxr-xr-xlacme293
1 files changed, 198 insertions, 95 deletions
diff --git a/lacme b/lacme
index cb49818..d7a416e 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 ();
@@ -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;