#!/usr/bin/perl -T

#----------------------------------------------------------------------
# ACME client written with process isolation and minimal privileges in mind
# Copyright © 2015-2021 Guilhem Moulin <guilhem@fripost.org>
#
# 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 <https://www.gnu.org/licenses/>.
#----------------------------------------------------------------------

use v5.14.2;
use strict;
use warnings;

our $VERSION = '0.8.2';
my $NAME = 'lacme';

use Errno 'EINTR';
use Fcntl qw/F_GETFD F_SETFD FD_CLOEXEC O_CREAT O_EXCL O_WRONLY SEEK_SET/;
use File::Basename 'dirname';
use File::Temp ();
use Getopt::Long qw/:config posix_default no_ignore_case gnu_getopt auto_version/;
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 JSON ();
use Net::SSLeay 1.46 ();

# Clean up PATH
$ENV{PATH} = join ':', qw{/usr/bin /bin};
delete @ENV{qw/IFS CDPATH ENV BASH_ENV/};

my ($COMMAND, %OPTS, $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 force
    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 env_fallback($$) {
    my $v = $ENV{ shift() };
    return (defined $v and $v ne "") ? $v : shift;
}
sub spec_expand($) {
    my $str = shift;
    $str =~ s#%(.)# my $x =
          $1 eq "C" ? ($< == 0 ? "@@localstatedir@@/cache" : env_fallback(XDG_CACHE_HOME => "$ENV{HOME}/.cache"))
        : $1 eq "E" ? ($< == 0 ? "@@sysconfdir@@" : env_fallback(XDG_CONFIG_HOME => "$ENV{HOME}/.config"))
        : $1 eq "g" ? (getgrgid((split /\s/,$()[0]))[0]
        : $1 eq "G" ? $( =~ s/\s.*//r
        : $1 eq "h" ? (getpwuid($<))[7]
        : $1 eq "u" ? (getpwuid($<))[0]
        : $1 eq "U" ? $<
        : $1 eq "t" ? ($< == 0 ? "@@runstatedir@@" : $ENV{XDG_RUNTIME_DIR})
        : $1 eq "T" ? env_fallback(TMPDIR => "/tmp")
        : $1 eq "%" ? "%"
        : die "Error: \"$str\" has unknown specifier %$1\n";
        die "Error: Undefined expansion %$1 in \"$str\"\n" unless defined $x;
        $x;
    #ge;
    return $str;
}

my $CONFFILENAME = spec_expand($OPTS{config} // "%E/lacme/$NAME.conf");
do {
    print STDERR "Using configuration file: $CONFFILENAME\n" if $OPTS{debug};
    open my $fh, '<', $CONFFILENAME or die "Can't open $CONFFILENAME: $!\n";
    my $conf = do { local $/ = undef; <$fh> };
    close $fh or die "close: $!";

    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  => '%t/S.lacme',
            user    => '@@lacme_client_user@@',
            group   => '@@lacme_client_group@@',
            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                  => '@@lacme_www_user@@',
            group                 => '@@lacme_www_group@@',
            command               => '@@libexecdir@@/lacme/webserver',
            iptables              => 'No'

        },
        accountd => {
            user    => '',
            group   => '',
            command => '@@bindir@@/lacme-accountd',
            config  => '',
            privkey => '',
            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->print("tlsfeature = $args{tlsfeature}\n") if defined $args{tlsfeature};
    $config->close() or die "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 "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 "dup: $!";
            open STDOUT, '>&', \*STDERR or die "dup: $!";
            exec qw/openssl req -noout -text -inform DER/ or die;
        }
        $rd->close() or die "close: $!";
        $wd->print($csr);
        $wd->close() or die "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 "getgrnam($group)", ($! ? ": $!" : "\n");
        $) = "$gid $gid";
        die "setgroups: $!" if $@;
        POSIX::setgid($gid) or die "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 "getpwnam($user)", ($! ? ": $!" : "\n");
        POSIX::setuid($uid) or die "setuid: $!";
        die "Couldn't setuid/seteuid" unless $< == $uid and $> == $uid; # safety check
    }

    # sanitize environment
    my $term = $ENV{TERM};
    my @ent = getpwuid($<) or die "getpwuid($<): $!";
    %ENV = ( USER => $ent[0], LOGNAME => $ent[0], HOME => $ent[7], SHELL => $ent[8] );
    $ENV{PATH} = $< == 0 ? "/usr/sbin:/usr/bin:/sbin:/bin" : "/usr/bin:/bin";
    $ENV{TERM} = $term if defined $term; # preserve $TERM

    chdir $dir or die "chdir($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 "fcntl F_GETFD: $!";
    my $flags2 = $set ? ($flags | FD_CLOEXEC) : ($flags & ~FD_CLOEXEC);
    return if $flags == $flags2;
    fcntl($fd, F_SETFD, $flags2) or die "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() {
    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;
    }

    # Use existing HTTPd to serve challenge files using 'challenge-directory'
    # as document root
    if (defined (my $dir = $conf->{'challenge-directory'})) {
        $dir = spec_expand($dir);
        print STDERR "[$$] Using existing webserver on $dir\n" if $OPTS{debug};
        # lacme(8) doesn't have the list of challenge files to delete on
        # cleanup -- instead, we unlink all files and fails at
        # initialization stage when the challenge directory is not empty

        opendir my $dh, $dir or die "opendir($dir): $!\n";
        while (readdir $dh) {
            die "Error: Refusing to use non-empty challenge directory $dir\n"
                unless $_ eq '.' or $_ eq '..';
        }
        closedir $dh or die "closedir: $!";
        undef $dh;

        # use a "lock file" (NFS-friendly) to avoid concurrent usages
        my $lockfile = ".$NAME.lock";
        sysopen(my $fh, "$dir/$lockfile", O_CREAT|O_EXCL|O_WRONLY, 0600)
            or die "Can't create lockfile in challenge directory: $!";
        print $fh $$, "\n";
        close $fh or die "close: $!";
        undef $fh;

        push @CLEANUP, sub() {
            if (opendir(my $dh, $dir)) {
                my @files = grep { $_ ne '.' and $_ ne '..' and $_ ne $lockfile } readdir $dh;
                closedir $dh or warn "closedir: $!";
                push @files, $lockfile; # unlink $lockfile last
                foreach (@files) {
                    die unless /\A(.+)\z/; # untaint
                    unlink "$dir/$1" or warn "unlink($dir/$1): $!";
                }
            } else {
                warn "opendir($dir): $!\n";
            }
        };
        return $dir; # ignore 'listen' and 'iptables'
    }

    die "'challenge-directory' option is required in section [webserver] when 'listen' is empty\n"
        unless @sockaddr;

    # 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, TEMPLATE => "acme-challenge.XXXXXXXXXX") // die;
    chmod 0755, $tmpdir or die "chmod: $!";
    if ((my $username = $CONFIG->{client}->{user}) ne '') {
        my $uid = getpwnam($username) // die "getpwnam($username)", ($! ? ": $!" : "\n");
        chown($uid, -1, $tmpdir) or die "chown: $!";
    }

    # 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;
            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: Couldn't unlink $path: $!";
            };
            umask($umask) // die;
        }
        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);
            open STDIN, '<', '/dev/null' or die "open(/dev/null): $!";
            set_FD_CLOEXEC($sock, 0);
            $ENV{DEBUG} = $OPTS{debug} // 0;
            # use execve(2) rather than a Perl pseudo-process to ensure that
            # the child doesn't have access to the parent's memory
            my ($cmd, @args) = split(/\s+/, $conf->{command}) or die "Empty webserver command\n";
            exec { $cmd } $cmd, @args, 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};
            kill 15 => $pid;
            waitpid $pid => 0;
            shutdown($sock, SHUT_RDWR) or warn "shutdown: $!";
        };

        # 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 "open(/dev/null): $!";
        open STDOUT, '>&', $iptables_tmp or die "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 "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 "dup: $!";
            open STDOUT, '>', '/dev/null'   or die "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})) {
        warn "Setting 'privkey' in lacme.conf's [accountd] section is deprecated and will become an error in a future release! "
            ."Set it in lacme-accountd.conf instead.\n" if $accountd->{privkey} ne '';
        my $GPG_TTY = $ENV{GPG_TTY};
        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}, '/');
            $client->close() or die "close: $!";
            open STDIN,  '<&', $s or die "dup: $!";
            open STDOUT, '>&', $s or die "dup: $!";
            set_FD_CLOEXEC($s, 1);
            $ENV{GPG_TTY} = $GPG_TTY if defined $GPG_TTY;
            my ($cmd, @args) = split(/\s+/, $accountd->{command}) or die "Empty accountd command\n";
            $_ = spec_expand($_) foreach ($cmd, @args); # expand %-specifiers after privilege drop and whitespace split
            push @args, '--stdio';
            push @args, '--config='.$accountd->{config} if $accountd->{config} ne '';
            push @args, '--privkey='.$accountd->{privkey} if $accountd->{privkey} ne ''; # XXX deprecated in 0.8.0
            push @args, '--quiet' unless lc $accountd->{quiet} eq 'no';
            push @args, '--debug' if $OPTS{debug};
            exec { $cmd } $cmd, @args or die;
        }
        print STDERR "[$$] Forking lacme-accountd, child PID $pid\n" if $OPTS{debug};
        $s->close() or die "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 = spec_expand($OPTS{socket} // $conf->{socket});
        $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 = dirname($sockname);
        @stat = stat($dirname) or die "Error: stat($dirname): $!\n";
        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: $!";
        }
    }
    set_FD_CLOEXEC($client, 1);

    my $client_config;
    do {
        my $tmp = File::Temp::->new(TMPDIR => 1, TEMPLATE => "lacme-client.conf.json-XXXXXXXXXX", UNLINK => 1) // die;
        print $tmp JSON::->new->encode($conf);
        open $client_config, "<", $tmp->filename() or die "open: $!";
    };

    # use execve(2) rather than a Perl pseudo-process to ensure that the
    # child doesn't have access to the parent's memory
    my ($cmd, @args2) = split(/\s+/, $conf->{command}) or die "Empty client command\n";
    my @fileno = map { fileno($_) =~ /^(\d+)$/ ? $1 : die } ($client_config, $client); # untaint fileno
    my $rv = spawn({in => $args->{in}, out => $args->{out}, child => sub() {
        drop_privileges($conf->{user}, $conf->{group}, $args->{chdir} // '/');
        umask(0022) // die;
        set_FD_CLOEXEC($_, 0) for ($client_config, $client);
        $ENV{DEBUG} = $OPTS{debug} // 0;
    }}, $cmd, @args2, $COMMAND, @fileno, @args);
    close $client_config or die "close: $!\n";

    if (defined $cleanup) {
        @CLEANUP = grep { $_ ne $cleanup } @CLEANUP;
        $cleanup->();
    }
    return $rv;
}

sub spawn($@) {
    my $args = shift;
    my ($cmd, @args) = @_;

    # 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 "close: $!";
            open STDIN, '<&', $in_rd or die "dup: $!";
        } else {
            open STDIN, '<', '/dev/null' or die "open(/dev/null): $!";
        }
        if (!defined $args->{out}) {
            open STDOUT, '>', '/dev/null' or die "open(/dev/null): $!";
        } elsif (ref $args->{out} ne 'GLOB') {
            close $out_rd or die "close: $!";
            open STDOUT, '>&', $out_wd or die "dup: $!";
        } elsif (fileno(STDOUT) != fileno($args->{out})) {
            open STDOUT, '>&', $args->{out} or die "dup: $!";
        }
        exec { $cmd } $cmd, @args or die;
    }
    push @CLEANUP, sub() {
        kill 15 => $pid;
        waitpid $pid => 0;
    };

    # parent
    print STDERR "[$$] Forking $cmd, child PID $pid\n" if $OPTS{debug};
    if (defined $args->{in}) {
        $in_rd->close() or die "close: $!";
        $in_wd->print($args->{in});
        $in_wd->close() or die "close: $!";
    }
    if (defined $args->{out} and ref $args->{out} ne 'GLOB') {
        $out_wd->close() or die "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 "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 ($path, $content, %args) = @_;

    my $fh = File::Temp::->new(TEMPLATE => "$path.XXXXXXXXXX", UNLINK => 0) // die;
    my $path_tmp = $fh->filename();

    eval {
        $fh->print($content) or die "print: $!";

        my $mode;
        if ((my $m = $args{mode}) ne "") {
            die "Not an octal string: $m\n" unless $m =~ /^[0-9]+$/;
            $mode = oct($m);
        } else {
            my $umask = umask() // die;
            $mode = 0644 &~ $umask;
        }
        chmod($mode, $fh) or die "chown: $!";

        if ((my $owner = $args{owner}) ne "") {
            my ($user, $group) = split /:/, $owner, 2;
            my $uid = getpwnam($user) // die "getpwnam($user)", ($! ? ": $!" : "\n");
            my $gid = getgrnam($group) // die "getgrnam($group)", ($! ? ": $!" : "\n") if defined $group;
            chown($uid, $gid // -1, $fh) or die "chown: $!";
        }

        $fh->close() or die "close: $!";
    };

    if ($@) {
        print STDERR "Unlinking $path_tmp\n" if $OPTS{debug};
        unlink $path_tmp or warn "unlink($path_tmp): $!";
        die $@;
    } else {
        # atomically replace $path if it exists
        rename($path_tmp, $path) or die "rename($path_tmp, $path): $!";
    }
}


#############################################################################
# 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') {
    $OPTS{'min-days'} = -1 if $OPTS{force};
    $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/" ];
    $_ = spec_expand($_) foreach @$conffiles;
    my ($conf, %defaults);
    foreach my $conffile (@$conffiles) {
        $conffile = dirname($CONFFILENAME) .'/'. $conffile unless $conffile =~ /\A\//;
        my @filenames;
        unless ($conffile =~ s#/\z## or -d $conffile) {
            @filenames = ($conffile);
        } else {
            opendir my $dh, $conffile or die "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 or die "closedir: $!";
        }
        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 tlsfeature
                           owner chown mode 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 STDERR "    $_ = $conf->{$_}\n" foreach grep { defined $conf->{$_} } (sort keys %$conf);
        }

        my @certpaths = grep {defined $_ and $_ ne ""} @$conf{qw/certificate-chain certificate/};
        unless (@certpaths) {
            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 $certpaths[0] and defined (my $t = x509_enddate($certpaths[0]))) {
            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 keyUsage subject subjectAltName tlsfeature 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 $chain;
        print STDERR "[$s] Will request authorization for: ".join(", ", @authz), "\n" if $OPTS{debug};
        if (acme_client({chdir => $challenge_dir, in => $csr, out => \$chain}, @authz)) {
            print STDERR "[$s] Error: Couldn't issue X.509 certificate!\n";
            $rv = 1;
            next;
        }

        my $cert;
        eval {
            my $mem = Net::SSLeay::BIO_s_mem() or die;
            my $bio = Net::SSLeay::BIO_new($mem) or die;
            die "incomplete write" unless
                Net::SSLeay::BIO_write($bio, $chain) == length($chain);
            my $x509 = Net::SSLeay::PEM_read_bio_X509($bio);
            $cert = Net::SSLeay::PEM_get_string_X509($x509);
            Net::SSLeay::BIO_free($bio) or die;
        };
        if ($@) {
            print STDERR "[$s] Error: Received bogus X.509 certificate from ACME server!\n";
            $rv = 1;
            next;
        }

        # extract pubkeys from CSR and cert, and ensure they match
        # XXX would be nice to use X509_get_X509_PUBKEY and X509_REQ_get_X509_PUBKEY here,
        # or EVP_PKEY_cmp(), but unfortunately Net::SSLeay 1.88 doesn't support these
        my ($cert_pubkey, $csr_pubkey);
        spawn({in => $cert, out => \$cert_pubkey}, qw/openssl x509 -inform PEM -noout -pubkey/);
        spawn({in => $csr,  out => \$csr_pubkey }, qw/openssl req  -inform DER -noout -pubkey/);
        unless (defined $cert_pubkey and defined $csr_pubkey and $cert_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 bundle
        if ((my $CAfile = $conf->{CAfile} // '@@datadir@@/lacme/ca-certificates.crt') ne '') {
            my %args = (in => $cert);
            $args{out} = \*STDERR if $OPTS{debug};
            my @options = ('-trusted', $CAfile, '-purpose', 'sslserver', '-x509_strict');
            push @options, '-show_chain' if $OPTS{debug};
            if (spawn(\%args, 'openssl', 'verify', @options)) {
                print STDERR "[$s] Error: Received invalid X.509 certificate from ACME server!\n";
                $rv = 1;
                next;
            }
        }

        # install certificate
        my %install_opts = (
            mode  => $conf->{mode}  // $conf->{chmod} // "",
            owner => $conf->{owner} // $conf->{chown} // ""
        );
        if ((my $path = $conf->{'certificate'} // "") ne "") {
            print STDERR "Installing X.509 certificate $path\n";
            install_cert($path => $cert, %install_opts);
        }
        if ((my $path = $conf->{'certificate-chain'} // "") ne "") {
            print STDERR "Installing X.509 certificate chain $path\n";
            install_cert($path => $chain, %install_opts);
        }

        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 $cert;
        close $fh or die $! ?
            "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 $! ?
            "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 $! ?
            "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;
}
