aboutsummaryrefslogtreecommitdiffstats
path: root/letsencrypt
diff options
context:
space:
mode:
authorGuilhem Moulin <guilhem@fripost.org>2016-06-13 23:14:00 +0200
committerGuilhem Moulin <guilhem@fripost.org>2016-06-13 23:14:00 +0200
commit08d9f95505bb11c3d1b6a8c649362ede7dab4138 (patch)
treeacc9aa7f86e28d61a0c3f26e30f8a07848f668a5 /letsencrypt
parentd038f67d1e075010d36272595ea845e2f57e55ac (diff)
Rename ‘letsencrypt-tiny’ to ‘lacme’.
Diffstat (limited to 'letsencrypt')
-rwxr-xr-xletsencrypt704
1 files changed, 0 insertions, 704 deletions
diff --git a/letsencrypt b/letsencrypt
deleted file mode 100755
index d11b569..0000000
--- a/letsencrypt
+++ /dev/null
@@ -1,704 +0,0 @@
-#!/usr/bin/perl -T
-
-#----------------------------------------------------------------------
-# Let's Encrypt ACME client
-# Copyright © 2016 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 <http://www.gnu.org/licenses/>.
-#----------------------------------------------------------------------
-
-use strict;
-use warnings;
-
-our $VERSION = '0.0.1';
-my $NAME = 'letsencrypt';
-
-use Errno qw/EADDRINUSE 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 qw/PF_INET PF_INET6 PF_UNIX INADDR_ANY IN6ADDR_ANY
- SOCK_STREAM SOL_SOCKET SO_REUSEADDR SHUT_RDWR/;
-
-use Config::Tiny ();
-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 agreement-uri=s debug help|h/);
-usage(0) if $OPTS{help};
-
-$COMMAND = shift(@ARGV) // usage(1, "Missing command");
-$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
-
-do {
- my $conffile = $OPTS{config} // first { -f $_ }
- ( "./$NAME.conf"
- , ($ENV{XDG_CONFIG_HOME} // "$ENV{HOME}/.config")."/letsencrypt-tiny/$NAME.conf"
- , "/etc/letsencrypt-tiny/$NAME.conf"
- );
- die "Error: Can't find configuration file\n" unless defined $conffile;
- print STDERR "Using configuration file: $conffile\n" if $OPTS{debug};
- open $CONFFILE, '<', $conffile or die "Can't open $conffile: $!\n";
- 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";
- my $defaults = delete $h->{_} // {};
- my %valid = (
- client => {
- socket => (defined $ENV{XDG_RUNTIME_DIR} ? "$ENV{XDG_RUNTIME_DIR}/S.letsencrypt" : undef),
- user => 'nobody',
- group => 'nogroup',
- command => '/usr/lib/letsencrypt-tiny/client',
- # the rest is for the ACME client
- map {$_ => undef} qw/server timeout SSL_verify SSL_version SSL_cipher_list/
- },
- webserver => {
- listen => ':80',
- 'challenge-directory' => '/var/www/acme-challenge',
- user => 'www-data',
- group => 'www-data',
- command => '/usr/lib/letsencrypt-tiny/webserver',
- iptables => '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;
-};
-
-# 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",
- # XXX Golang errors on extensions marked critical
- # https://github.com/letsencrypt/boulder/issues/565
- #"basicConstraints = critical, CA:FALSE\n",
- "basicConstraints = 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("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;
- if (defined $dt and $dt =~ s/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})//) {
- # RFC3339 datetime strings; assume epoch is on January 1 of $epoch_year
- my ($y, $m, $d, $h, $min, $s) = ($1, $2, $3, $4, $5, $6);
- my (undef,undef,undef,undef,undef,$epoch_year,undef,undef,undef) = gmtime(0);
- $t = 0;
- foreach (($epoch_year+1900) .. $y-1) {
- $t += 365*86400;
- $t += 86400 if ($_ % 4 == 0 and $_ % 100 != 0) or ($_ % 400 == 0); # leap
- }
-
- if ($m > 1) {
- my @m = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31);
- $m[1]++ if ($y % 4 == 0 and $y % 100 != 0) or ($y % 400 == 0); # leap
- $t += 86400*$m[$_] for (0 .. $m-2);
- }
-
- $t += 86400*($d-1);
- $t += $s + 60*($min + 60*$h);
-
- $dt =~ s/\A\.(\d{1,9})\d*//; # ignore nanosecs
-
- if ($dt =~ /\A([+-])(\d{2}):(\d{2})\z/) {
- my $tz = 60*($3 + 60*$2);
- $t = $1 eq '-' ? ($t+$tz) : ($t-$tz);
- }
- }
-
- 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: $!";
-}
-
-
-#############################################################################
-# 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.
-#
-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 getgrnam($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'};
- 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: $!";
- };
- 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;
- }
-
- 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;
- };
-
- return $tmpdir if lc ($conf->{iptables} // 'Yes') eq 'no';
-
- # install iptables
- my $iptables_bin = $fam == PF_INET ? 'iptables' : $fam == PF_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) {
- 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;
- die "Error: /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\n" if $OPTS{debug};
- my $pid2 = fork() // die "fork: $!";
- unless ($pid2) {
- 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;
- 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;
-
- return $tmpdir;
-}
-
-
-#############################################################################
-# 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 @stat;
- my $conf = $CONFIG->{client};
- 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 letsencrypt-accountd running?)\n";
- die "Error: insecure permissions on $sockname\n" if ($stat[2] & 0066) != 0;
-
- # connect(2) to the socket
- socket(my $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
- spawn({%$args{qw/in 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);
-}
-
-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}) {
- 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}) {
- close $out_rd or die "Can't close: $!";
- open STDOUT, '>&', $out_wd or die "Can't dup: $!";
- } else {
- open STDOUT, '>', '/dev/null' or die "Can't open /dev/null: $!";
- }
- 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}) {
- $out_wd->close() or die "Can't close: $!";
- ${$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 $? > 0;
- return $? > 255 ? ($? >> 8) : $? > 0 ? 1 : 0;
-}
-
-
-#############################################################################
-# Install the certificate
-#
-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: $!";
- }
- close $fh or die "Can't close: $!";
-}
-
-
-#############################################################################
-# new-reg [--agreement-uri=URI] [CONTACT ..]
-# reg=URI [--agreement-uri=URI] [CONTACT ..]
-#
-if ($COMMAND eq 'new-reg' or $COMMAND =~ /^reg=/) {
- die "Invalid registration URI (use the 'new-reg' command to determine the URI)\n"
- if $COMMAND eq 'reg=';
- $OPTS{'agreement-uri'} = $OPTS{'agreement-uri'} =~ /\A(\p{Print}+)\z/ ? $1
- : die "Invalid value for --agreement-uri\n"
- if defined $OPTS{'agreement-uri'};
-
- unshift @ARGV, ($OPTS{'agreement-uri'} // '');
- exit acme_client({}, @ARGV);
-}
-
-
-#############################################################################
-# new-cert [SECTION ..]
-# TODO: renewal without the account key, see
-# https://github.com/letsencrypt/acme-spec/pull/168
-#
-elsif ($COMMAND eq 'new-cert') {
- my $conf;
- do {
- my $conffile = $OPTS{'config-certs'} // $CONFIG->{_}->{'config-certs'} // first { -f $_ }
- ( "./$NAME-certs.conf"
- , ($ENV{XDG_CONFIG_HOME} // "$ENV{HOME}/.config")."/letsencrypt-tiny/$NAME-certs.conf"
- , "/etc/letsencrypt-tiny/$NAME-certs.conf"
- );
- die "Error: Can't find certificate configuration file\n" unless defined $conffile;
- my $h = Config::Tiny::->read($conffile) or die Config::Tiny::->errstr()."\n";
- my $defaults = delete $h->{_} // {};
- 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 : keys %$conf) {
- my $conf = $conf->{$s} // do {
- print STDERR "Warning: No such section $s, skipping\n";
- $rv = 1;
- next;
- };
-
- 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 = $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";
- next;
- }
- }
-
- # generate the CSR
- my $csr = gen_csr(%$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} //= '/usr/share/letsencrypt-tiny/lets-encrypt-x3-cross-signed.pem';
- 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);
- }
- if (defined $conf->{'certificate-chain'}) {
- print STDERR "Installing X.509 certificate chain $conf->{'certificate-chain'}\n";
- install_cert($conf->{'certificate-chain'}, $x509, $conf->{CAfile});
- }
-
- 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;
-}
-
-
-#############################################################################
-# revoke-cert FILE [FILE ..]
-#
-elsif ($COMMAND eq 'revoke-cert') {
- die "Nothing to revoke\n" unless @ARGV;
- my $rv = 0;
- 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;
-}