#!/usr/bin/perl -T #---------------------------------------------------------------------- # ACME client written with process isolation and minimal privileges in mind # Copyright © 2016-2017 Guilhem Moulin # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . #---------------------------------------------------------------------- use v5.14.2; use strict; use warnings; our $VERSION = '0.3'; my $NAME = 'lacme'; use Errno 'EINTR'; use Fcntl qw/F_GETFD F_SETFD FD_CLOEXEC SEEK_SET/; 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 1.95 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 (); use Date::Parse (); use Net::SSLeay (); # Clean up PATH $ENV{PATH} = join ':', qw{/usr/bin /bin}; delete @ENV{qw/IFS CDPATH ENV BASH_ENV/}; my ($COMMAND, %OPTS, $CONFFILE, $CONFIG, @CLEANUP); $SIG{$_} = sub() { exit 1 } foreach qw/INT TERM/; # run the END block upon SIGINT/SIGTERM ############################################################################# # Parse and validate configuration # sub usage(;$$) { my $rv = shift // 0; if ($rv) { my $msg = shift; print STDERR $msg."\n" if defined $msg; print STDERR "Try '$NAME --help' or consult the manpage for more information.\n"; } else { print STDERR "Usage: $NAME [--config=FILE] [--socket=PATH] [OPTIONS] COMMAND [ARGUMENT ..]\n" ."Consult the manpage for more information.\n"; } exit $rv; } usage(1) unless GetOptions(\%OPTS, qw/config=s config-certs=s@ socket=s register tos-agreed deactivate min-days=i quiet|q debug help|h/); usage(0) if $OPTS{help}; $COMMAND = shift(@ARGV) // usage(1, "Missing command"); $COMMAND = $COMMAND =~ /\A(account|newOrder|new-cert|revokeCert|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" , "@@sysconfdir@@/lacme/$NAME.conf" ); 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> }; # 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->{_} // {}; my $accountd = defined $OPTS{socket} ? 0 : exists $h->{accountd} ? 1 : 0; my %valid = ( client => { socket => (defined $ENV{XDG_RUNTIME_DIR} ? "$ENV{XDG_RUNTIME_DIR}/S.lacme" : undef), user => 'nobody', group => 'nogroup', command => '@@libexecdir@@/lacme/client', # the rest is for the ACME client map {$_ => undef} qw/server timeout SSL_verify SSL_version SSL_cipher_list/ }, webserver => { listen => '@@runstatedir@@/lacme-www.socket', 'challenge-directory' => undef, user => 'www-data', group => 'www-data', command => '@@libexecdir@@/lacme/webserver', iptables => 'No' }, accountd => { user => '', group => '', command => '@@bindir@@/lacme-accountd', config => '@@sysconfdir@@/lacme/lacme-accountd.conf', privkey => undef, quiet => 'Yes', } ); foreach my $s (keys %valid) { my $h = delete $h->{$s} // {}; my %v = map { $_ => delete $h->{$_} // $valid{$s}->{$_} } keys %{$valid{$s}}; die "Unknown option(s) in [$s]: ".join(', ', keys %$h)."\n" if %$h; $h->{$_} //= $defaults->{$_} foreach keys %$defaults; $CONFIG->{$s} = \%v; } die "Invalid section(s): ".join(', ', keys %$h)."\n" if %$h; $CONFIG->{_} = $defaults; delete $CONFIG->{accountd} unless $accountd; $OPTS{quiet} = 0 if $OPTS{debug}; }; # Regular expressions for domain validation my $RE_LABEL = qr/[0-9a-z](?:[0-9a-z\x2D]{0,61}[0-9a-z])?/aai; my $RE_DOMAIN = qr/$RE_LABEL(?:\.$RE_LABEL)+/; ############################################################################# # Generate a Certificate Signing Request (in DER format) # sub gen_csr(%) { my %args = @_; return unless defined $args{'certificate-key'} and defined $args{subject}; return if defined $args{hash} and !grep { $args{hash} eq $_ } qw/md5 rmd160 sha1 sha224 sha256 sha384 sha512/; my $config = File::Temp::->new(SUFFIX => '.conf', TMPDIR => 1) // die; $config->print( "[ req ]\n", "distinguished_name = req_distinguished_name\n", "req_extensions = v3_req\n", "[ req_distinguished_name ]\n", "[ v3_req ]\n", "basicConstraints = critical, CA:FALSE\n", "subjectKeyIdentifier = hash\n" ); $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: $!"; my @args = (qw/-new -batch -key/, $args{'certificate-key'}); push @args, "-$args{hash}" if defined $args{hash}; push @args, '-subj', $args{subject}, '-config', $config->filename(), qw/-reqexts v3_req/; open my $fh, '-|', qw/openssl req -outform DER/, @args or die "fork: $!"; my $csr = do { local $/ = undef; <$fh> }; close $fh or $! ? die "Can't close: $!" : return; if ($OPTS{debug}) { # print out the CSR in text form pipe my $rd, my $wd or die "pipe: $!"; my $pid = fork // die "fork: $!"; unless ($pid) { open STDIN, '<&', $rd or die "Can't dup: $!"; open STDOUT, '>&', \*STDERR or die "Can't dup: $!"; exec qw/openssl req -noout -text -inform DER/ or die; } $rd->close() or die "Can't close: $!"; $wd->print($csr); $wd->close() or die "Can't close: $!"; waitpid $pid => 0; die $? if $? > 0; } return $csr; } ############################################################################# # Get the timestamp of the given cert's expiration date. # Internally the expiration date is stored as a RFC3339 string (such as # yyyy-mm-ddThh:mm:ssZ); we convert it to a timestamp manually. # sub x509_enddate($) { my $filename = shift; my ($bio, $x509, $time, $dt); $bio = Net::SSLeay::BIO_new_file($filename, 'r'); $x509 = Net::SSLeay::PEM_read_bio_X509($bio) if defined $bio; $time = Net::SSLeay::X509_get_notAfter($x509) if defined $x509; $dt = Net::SSLeay::P_ASN1_TIME_get_isotime($time) if defined $time; my $t = Date::Parse::str2time($dt) if defined $dt; Net::SSLeay::X509_free($x509) if defined $x509; Net::SSLeay::BIO_free($bio) if defined $bio; return $t; } ############################################################################# # Drop privileges and chdir afterwards # sub drop_privileges($$$) { my ($user, $group, $dir) = @_; # set effective and real gid; also set the list of supplementary gids to that single gid if ($group ne '') { my $gid = getgrnam($group) // die "Can't getgrnam($group): $!"; $) = "$gid $gid"; die "Can't setgroups: $!" if $@; POSIX::setgid($gid) or die "Can't setgid: $!"; die "Couldn't setgid/setguid" unless $( eq "$gid $gid" and $) eq "$gid $gid"; # safety check } # set effective and real uid if ($user ne '') { my $uid = getpwnam($user) // die "Can't getpwnam($user): $!"; POSIX::setuid($uid) or die "Can't setuid: $!"; die "Couldn't setuid/seteuid" unless $< == $uid and $> == $uid; # safety check } chdir $dir or die "Can't chdir to $dir: $!"; } ############################################################################# # Ensure the FD_CLOEXEC bit is $set on $fd # sub set_FD_CLOEXEC($$) { my ($fd, $set) = @_; my $flags = fcntl($fd, F_GETFD, 0) or die "Can't fcntl F_GETFD: $!"; my $flags2 = $set ? ($flags | FD_CLOEXEC) : ($flags & ~FD_CLOEXEC); return if $flags == $flags2; fcntl($fd, F_SETFD, $flags2) or die "Can't fcntl F_SETFD: $!"; } ############################################################################# # 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 # and read access to the 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 getpwnam($username): $!"; chown($uid, -1, $tmpdir) or die "Can't chown: $!"; } my $conf = $CONFIG->{webserver}; # 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; }; # 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); } } } } # 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; } ############################################################################# # 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 = $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 $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 "/usr/sbin/$iptables_bin-save", "-c" or die; } waitpid $pid => 0; die "Error: /usr/sbin/$iptables_bin-save exited with value ".($? >> 8) if $? > 0; # seek back to the begining, as we'll restore directly from the # handle and not from the file. XXX if there was a way in Perl to # use open(2) with the O_TMPFILE flag we would use that to avoid # creating a file to start with seek($iptables_tmp, SEEK_SET, 0) or die "Can't seek: $!"; push @CLEANUP, sub() { 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 "/usr/sbin/$iptables_bin-restore", "-c" or die; } waitpid $pid => 0; warn "Warning: /usr/sbin/$iptables_bin-restore exited with value ".($? >> 8) if $? > 0; }; # it's safe to install the new iptables to open $addr:$port now that # the restore hook is in place 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 ("/usr/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 ("/usr/sbin/$iptables_bin", qw/-I OUTPUT -p tcp -m tcp -m state/, '-s', $dest, '--sport', $port, '--state', 'ESTABLISHED', '-j', 'ACCEPT') == 0 or die; } } ############################################################################# # Spawn the client component, and wait for it to return. # If $args->{in} is defined, the data is written to the client's STDIN. # If $args->{out} is defined, its value is set to client's STDOUT data. # sub acme_client($@) { my $args = shift; my @args = @_; my ($client, $cleanup); my $conf = $CONFIG->{client}; if (defined (my $accountd = $CONFIG->{accountd})) { socketpair($client, my $s, AF_UNIX, SOCK_STREAM, PF_UNSPEC) or die "socketpair: $!"; my $pid = fork() // "fork: $!"; unless ($pid) { drop_privileges($accountd->{user}, $accountd->{group}, '/'); set_FD_CLOEXEC($s, 0); $client->close() or die "Can't close: $!"; my @cmd = ($accountd->{command}, '--conn-fd='.fileno($s)); push @cmd, '--config='.$accountd->{config} if defined $accountd->{config}; push @cmd, '--privkey='.$accountd->{privkey} if defined $accountd->{privkey}; push @cmd, '--quiet' unless lc $accountd->{quiet} eq 'no'; push @cmd, '--debug' if $OPTS{debug}; exec { $cmd[0] } @cmd or die; } print STDERR "[$$] Forking lacme-accountd, child PID $pid\n" if $OPTS{debug}; $s->close() or die "Can't close: $!"; $cleanup = sub() { print STDERR "[$$] Shutting down lacme-accountd\n" if $OPTS{debug}; shutdown($client, SHUT_RDWR) or warn "shutdown: $!"; $client->close() or warn "close: $!"; }; push @CLEANUP, $cleanup; } else { my @stat; my $sockname = $OPTS{socket} // $conf->{socket} // die "Missing socket option\n"; $sockname = $sockname =~ /\A(\p{Print}+)\z/ ? $1 : die "Invalid socket name\n"; # untaint $sockname # ensure we're the only user with write access to the parent dir my $dirname = $sockname =~ s/[^\/]+$//r; @stat = stat($dirname) or die "Can't stat $dirname: $!"; die "Error: insecure permissions on $dirname\n" if ($stat[2] & 0022) != 0; # ensure we're the only user with read/write access to the socket @stat = stat($sockname) or die "Can't stat $sockname: $! (Is lacme-accountd running?)\n"; die "Error: insecure permissions on $sockname\n" if ($stat[2] & 0066) != 0; # connect(2) to the socket socket($client, PF_UNIX, SOCK_STREAM, 0) or die "socket: $!"; my $sockaddr = Socket::sockaddr_un($sockname) // die "Invalid address $sockname\n"; until (connect($client, $sockaddr)) { next if $! == EINTR; # try again if connect(2) was interrupted by a signal die "connect: $!"; } } # use execve(2) rather than a Perl pseudo-process to ensure that the # child doesn't have access to the parent's memory my @fileno = map { fileno($_) =~ /^(\d+)$/ ? $1 : die } ($CONFFILE, $client); # untaint fileno set_FD_CLOEXEC($client, 1); my $rv = spawn({in => $args->{in}, out => $args->{out}, child => sub() { drop_privileges($conf->{user}, $conf->{group}, $args->{chdir} // '/'); set_FD_CLOEXEC($_, 0) foreach ($CONFFILE, $client); seek($CONFFILE, SEEK_SET, 0) or die "Can't seek: $!"; $ENV{DEBUG} = $OPTS{debug}; }}, $conf->{command}, $COMMAND, @fileno, @args); if (defined $cleanup) { @CLEANUP = grep { $_ ne $cleanup } @CLEANUP; $cleanup->(); } return $rv; } sub spawn($@) { my $args = shift; my @exec = @_; # create communication pipes if needed my ($in_rd, $in_wd, $out_rd, $out_wd); if (defined $args->{in}) { pipe $in_rd, $in_wd or die "pipe: $!"; } if (defined $args->{out} and ref $args->{out} ne 'GLOB') { pipe $out_rd, $out_wd or die "pipe: $!"; } my $pid = fork() // "fork: $!"; unless ($pid) { # child $args->{child}->() if defined $args->{child}; if (defined $args->{in}) { close $in_wd or die "Can't close: $!"; open STDIN, '<&', $in_rd or die "Can't dup: $!"; } else { open STDIN, '<', '/dev/null' or die "Can't open /dev/null: $!"; } if (!defined $args->{out}) { open STDOUT, '>', '/dev/null' or die "Can't open /dev/null: $!"; } elsif (ref $args->{out} ne 'GLOB') { close $out_rd or die "Can't close: $!"; open STDOUT, '>&', $out_wd or die "Can't dup: $!"; } elsif (fileno(STDOUT) != fileno($args->{out})) { open STDOUT, '>&', $args->{out} or die "Can't dup: $!"; } exec { $exec[0] } @exec or die; } push @CLEANUP, sub() { kill 15 => $pid; waitpid $pid => 0; }; # parent print STDERR "[$$] Forking $exec[0], child PID $pid\n" if $OPTS{debug}; if (defined $args->{in}) { $in_rd->close() or die "Can't close: $!"; $in_wd->print($args->{in}); $in_wd->close() or die "Can't close: $!"; } if (defined $args->{out} and ref $args->{out} ne 'GLOB') { $out_wd->close() or die "Can't close: $!"; if (ref $args->{out} eq 'CODE') { $args->{out}->($out_rd); } elsif (ref $args->{out} eq 'SCALAR') { ${$args->{out}} = do { local $/ = undef; $out_rd->getline() }; } $out_rd->close() or die "Can't close: $!"; } waitpid $pid => 0; pop @CLEANUP; undef ${$args->{out}} if defined $args->{out} and ref $args->{out} eq 'SCALAR' and $? > 0; return $? > 255 ? ($? >> 8) : $? > 0 ? 1 : 0; } ############################################################################# # Install the certificate (optionally excluding the chain of trust) # sub install_cert($$;$) { my ($filename, $chain, $leafonly) = @_; 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: $!"; if ($leafonly) { # keep only the leaf certificate pipe my $rd, my $wd or die "Can't pipe: $!"; my $pid = fork // die "Can't fork: $!"; unless ($pid) { open STDIN, '<&', $rd or die "Can't dup: $!"; open STDOUT, '>&', $fh or die "Can't dup: $!"; exec qw/openssl x509 -outform PEM/ or die; } $rd->close() or die "Can't close: $!"; $wd->print($chain); $wd->close() or die "Can't close: $!"; waitpid $pid => 0; die $? if $? > 0; } else { $fh->print($chain) 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 $@; } rename($path, $filename) or die "Can't rename $path to $filename: $!"; } ############################################################################# # account [--tos-agreed] [CONTACT ..] # if ($COMMAND eq 'account') { my $flags = 0; $flags |= 1 if $OPTS{'register'}; $flags |= 2 if $OPTS{'tos-agreed'}; $flags |= 4 if $OPTS{'deactivate'}; exit acme_client({out => \*STDOUT}, $flags, @ARGV); } ############################################################################# # newOrder [SECTION ..] # elsif ($COMMAND eq 'newOrder' or $COMMAND eq 'new-cert') { $COMMAND = 'newOrder'; 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, %defaults); foreach my $conffile (@$conffiles) { $conffile = ($CONFFILENAME =~ s#[^/]+\z##r).$conffile unless $conffile =~ /\A\//; my @filenames; unless ($conffile =~ s#/\z## or -d $conffile) { @filenames = ($conffile); } else { opendir my $dh, $conffile or die "Can't opendir $conffile: $!\n"; while (readdir $dh) { if (/\.conf\z/) { push @filenames, "$conffile/$_"; } elsif ($_ ne '.' and $_ ne '..') { warn "$conffile/$_ has unknown suffix, skipping\n"; } } closedir $dh; } 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 $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; } } } my $challenge_dir; my $rv = 0; 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"; $rv = 1; next; } # 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'} // 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; } } # generate the CSR my $csr = gen_csr(map {$_ => $conf->{$_}} qw/certificate-key subject subjectAltName keyUsage hash/) // do { print STDERR "[$s] Warning: Couldn't generate CSR, skipping\n"; $rv = 1; next; }; # spawn the webserver if not done already $challenge_dir //= spawn_webserver(); # list all authorization domains to request my @authz; push @authz, $1 if defined $conf->{subject} =~ /\A.*\/CN=($RE_DOMAIN)\z/o; if (defined $conf->{subjectAltName}) { foreach my $d (split /,/, $conf->{subjectAltName}) { next unless $d =~ s/\A\s*DNS://; if ($d =~ /\A$RE_DOMAIN\z/o) { push @authz, $d unless grep {$_ eq $d} @authz; } else { print STDERR "[$s] Warning: Ignoring invalid domain $d\n"; } } } my ($x509, $csr_pubkey, $x509_pubkey); print STDERR "[$s] Will request authorization for: ".join(", ", @authz), "\n" if $OPTS{debug}; if (acme_client({chdir => $challenge_dir, in => $csr, out => \$x509}, @authz)) { print STDERR "[$s] Error: Couldn't issue X.509 certificate!\n"; $rv = 1; next; } # extract pubkeys from CSR and cert, and ensure they match spawn({in => $csr, out => \$csr_pubkey }, qw/openssl req -inform DER -noout -pubkey/); spawn({in => $x509, out => \$x509_pubkey}, qw/openssl x509 -inform PEM -noout -pubkey/); unless (defined $x509_pubkey and defined $csr_pubkey and $x509_pubkey eq $csr_pubkey) { print STDERR "[$s] Error: Received bogus X.509 certificate from ACME server!\n"; $rv = 1; next; }; # verify certificate validity against the CA $conf->{CAfile} //= '@@datadir@@/lacme/ca-certificates.crt'; if ($conf->{CAfile} ne '' and spawn({in => $x509}, 'openssl', 'verify', '-CAfile', $conf->{CAfile}, qw/-purpose sslserver -x509_strict/)) { print STDERR "[$s] Error: Received invalid X.509 certificate from ACME server!\n"; $rv = 1; next; } # install certificate if (defined $conf->{'certificate'}) { print STDERR "Installing X.509 certificate $conf->{'certificate'}\n"; install_cert($conf->{'certificate'}, $x509, 1); } if (defined $conf->{'certificate-chain'}) { print STDERR "Installing X.509 certificate chain $conf->{'certificate-chain'}\n"; install_cert($conf->{'certificate-chain'}, $x509); } if (defined $conf->{chown}) { my ($user, $group) = split /:/, $conf->{chown}, 2; my $uid = getpwnam($user) // die "Can't getpwnam($user): $!"; my $gid = defined $group ? (getgrnam($group) // die "Can't getgrnam($group): $!") : -1; foreach (grep defined, @$conf{qw/certificate certificate-chain/}) { chown($uid, $gid, $_) or die "Can't chown: $!"; } } if (defined $conf->{chmod}) { my $mode = oct($conf->{chmod}) // die; foreach (grep defined, @$conf{qw/certificate certificate-chain/}) { chmod($mode, $_) or die "Can't chown: $!"; } } my @certopts = join ',', qw/no_header no_version no_pubkey no_sigdump/; open my $fh, '|-', qw/openssl x509 -noout -fingerprint -sha256 -text -certopt/, @certopts or die "fork: $!"; print $fh $x509; close $fh or die $! ? "Can't close: $!" : "Error: x509(1ssl) exited with value ".($? >> 8)."\n"; if (defined $conf->{notify}) { print STDERR "Running notification command `$conf->{notify}`\n"; if (system($conf->{notify}) != 0) { print STDERR "Warning: notification command exited with value ".($? >> 8)."\n"; $rv = 1; } } } undef $challenge_dir; exit $rv; } ############################################################################# # revokeCert FILE [FILE ..] # elsif ($COMMAND eq 'revokeCert' or $COMMAND eq 'revoke-cert') { die "Nothing to revoke\n" unless @ARGV; my $rv = 0; $COMMAND = 'revokeCert'; foreach my $filename (@ARGV) { print STDERR "Revoking $filename\n"; # conversion PEM -> DER open my $fh, '-|', qw/openssl x509 -outform DER -in/, $filename or die "fork: $!"; my $der = do { local $/ = undef; <$fh> }; close $fh or die $! ? "Can't close: $!" : "Error: x509(1ssl) exited with value ".($? >> 8)."\n"; my @certopts = join ',', qw/no_header no_version no_pubkey no_sigdump no_extensions/; open my $fh2, '|-', qw/openssl x509 -inform DER -noout -fingerprint -sha256 -text -certopt/, @certopts or die "fork: $!"; print $fh2 $der; close $fh2 or die $! ? "Can't close: $!" : "Error: x509(1ssl) exited with value ".($? >> 8)."\n"; if (acme_client({in => $der})) { print STDERR "Warning: Couldn't revoke $filename\n"; $rv = 1; } } exit $rv; } ############################################################################# # else { die "Unknown command $COMMAND" } END { local $?; $_->() foreach reverse @CLEANUP; }