aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README85
-rwxr-xr-xclient433
-rw-r--r--config/letsencrypt-certs.conf56
-rw-r--r--config/letsencrypt.conf86
-rwxr-xr-xletsencrypt957
-rw-r--r--letsencrypt.1413
-rwxr-xr-xwebserver100
7 files changed, 1501 insertions, 629 deletions
diff --git a/README b/README
index 4de2a20..69a1859 100644
--- a/README
+++ b/README
@@ -1,32 +1,55 @@
+Requesting new Certificate Issuance with the ACME protocol generally
+works as follows:
+
+ 1. Generate a Certificate Signing Request. This requires access to
+ the private part of the server key.
+ 2. Issue an issuance request against the ACME server.
+ 3. Answer the ACME Identifier Validation Challenges. The challenge
+ type "http-01" requires a webserver to listen on port 80 for each
+ address for which an authorization request is issued; if there is
+ no running webserver, root privileges are required to bind against
+ port 80 and to install firewall rules to temporarily open the port.
+ 4. Install the certificate (after verification) and restart the
+ service. This usually requires root access as well.
+
+Steps 1,3,4 need to be run on the host for which an authorization
+request is issued. However the the issuance itself (step 2) could be
+done from another machine. Furthermore, each ACME command (step 2), as
+well as the key authorization token in step 3, need to be signed using
+an account key. The account key can be stored on another machine, or
+even on a smartcard.
+
+_______________________________________________________________________
+
letsencrypt is a tiny ACME client written with process isolation and
-minimal privileges in mind. It is divided into three components:
-
-1. The "master" process, which runs as root and is the only component
- with access to the private key material (both account and server
- keys). It is only used to fork the other components (after dropping
- privileges), and to sign ACME requests (JSON Web Signatures); for
- certificate issuance ("new-cert" command), it is also used to
- generate the Certificate Signing Request, then to verify the validity
- of the issued certificate, and optionally to reload or restart
- services using "--notify".
-
-2. The actual ACME client, which runs as the user specified with
- "--runas" (or root if the option is omitted). It builds ACME
- requests and dialogues with the remote ACME server. All requests
- need to be signed with the account key, but this process doesn't need
- direct access to any private key material: instead, it write the data
- to be signed to a pipe shared with the master process, which in turns
- replies with its SHA-256 signature.
-
-3. An optional webserver, which is spawned by the master process (when
- nothing is listening on localhost:80); socat(1) is used to listen on
- port 80 and to change the user (owner) and group of the process to
- "www-data:www-data". (The only challenge type currently supported by
- letsencrypt-tiny is "http-01", hence a webserver is required.) Some
- iptables rules are automatically added to open port 80, and removed
- afterwards. The web server only processes GET requests under the
- "/.well-known/acme-challenge" URI.
- If a webserver is already listening on port 80, it needs to be
- configured to serve these URIs (for each virtual-hosts requiring
- authorization) as static files under the
- "/var/www/acme-challenge" root directory, which must not exist.
+minimal privileges in mind. It is divided into four components, each
+with its own executable:
+
+ * A process to manage the account key and issue SHA-256 signatures
+ needed for each ACME command. (This process binds to a UNIX-domain
+ socket to reply to signature requests from the ACME client.) One
+ can use the UNIX-domain socket forwarding facility of OpenSSH 6.7
+ and later to run this process on a different host.
+
+ * A "master" process, which runs as root and is the only component
+ with access to the private key material of the server keys. It is
+ used to fork the ACME client (and optionally the ACME webserver)
+ after dropping root privileges. For certificate issuances,
+ it also generates Certificate Signing Requests, then verifies the
+ validity of the issued certificate, and optionally reloads or
+ restarts services when the notify option is set.
+
+ * An actual ACME client, which builds ACME commands and dialogues with
+ the remote ACME server. Since ACME commands need to be signed with
+ the account key, the "master" process passes the UNIX-domain socket
+ of the account key manager to the ACME client: data signatures are
+ requested by writing the data to be signed to the socket.
+
+ * For certificate issuances, an optional webserver, which is spawned
+ by the "master" process when no service is listening on the HTTP
+ port. (The only challenge type currently supported is "http-01",
+ which requires a webserver to answer challenges.) That webserver
+ only processes GET and HEAD requests under the
+ "/.well-known/acme-challenge/" URI. By default some iptables(1)
+ rules are automatically installed to open the HTTP port, and removed
+ afterwards.
diff --git a/client b/client
index 5cf0139..70150ca 100755
--- a/client
+++ b/client
@@ -2,7 +2,7 @@
#----------------------------------------------------------------------
# Let's Encrypt ACME client
-# Copyright © 2015 Guilhem Moulin <guilhem@fripost.org>
+# Copyright © 2015,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
@@ -21,98 +21,170 @@
use strict;
use warnings;
+# Usage: client COMMAND CONFIG_FD SOCKET_FD [ARGUMENTS]
+#
+# fdopen(3) the file descriptor SOCKET_FD (corresponding to the
+# listening letsencrypt-accountd socket), connect(2) to it to retrieve
+# the account key's public parameters and later send data to be signed
+# by the master component (using the account key).
+#
+# CONFIG_FD is a read-only file descriptor associated with the
+# configuration file at pos 0. (This is needed since this process
+# doesn't know its name and might not have read access to it.)
+#
+# NOTE: one needs to chdir(2) to an appropriate ACME challenge directory
+# before running this program, since challenge tokens are (on purpose)
+# only written to the current directory. If COMMAND is challenge-less,
+# one should chdir(2) to the root directory "/" instead.
+#
+# NOTE: one should run this program as an unprivileged user:group such
+# as "nobody:nogroup"; bind(2)'ing to a restricted UNIX socket (for
+# instance own by another user and created with umask 0177) is not a
+# problem since SOCKET_FD can be bound as root prior to the execve(2).
+
+my $PROTOCOL_VERSION = 1;
+
use LWP::UserAgent ();
-use Crypt::OpenSSL::RSA ();
-use Crypt::OpenSSL::Bignum ();
use MIME::Base64 qw/encode_base64 encode_base64url/;
use JSON ();
use Digest::SHA qw/sha256 sha256_hex/;
+use Config::Tiny ();
+
# Clean up PATH
$ENV{PATH} = join ':', qw{/usr/bin /bin};
delete @ENV{qw/IFS CDPATH ENV BASH_ENV/};
my $COMMAND = shift @ARGV // die;
-my $PUBKEY = shift @ARGV // die;
-die unless grep {$COMMAND eq $_} qw/new-reg new-cert revoke-cert/;
-my $TIMEOUT = 10;
+# Untaint and fdopen(3) the configuration file and listening socket
+(shift @ARGV // die) =~ /\A(\d+)\z/ or die;
+open my $CONFFILE, '<&=', $1 or die "fdopen $1: $!";
+(shift @ARGV // die) =~ /\A(\d+)\z/ or die;
+open my $S, '+<&=', $1 or die "fdopen $1: $!";
-# Read the public key and build the JSON Web Key (RFC 7517)
-my $JWK = do {
- open my $fh, '<', $PUBKEY or die "Can't open $PUBKEY: $!";
- my $str = do { local $/ = undef; <$fh> };
- my $pubkey = Crypt::OpenSSL::RSA->new_public_key($str) or die;
- close $fh;
- my ($n, $e) = $pubkey->get_key_parameters();
- $_ = encode_base64url($_->to_bin()) foreach ($n, $e);
+#############################################################################
+# Read the protocol version and JSON Web Key (RFC 7517) from the
+# letsencrypt-accountd socket
+#
+die "Error: Invalid client version\n" unless
+ $S->getline() =~ /\A(\d+) OK(?:.*)\r\n\z/ and $1 == $PROTOCOL_VERSION;
+my $JWK = JSON::->new->decode($S->getline());
- { kty => 'RSA', n => $n, e => $e }
-};
-my $JSON = JSON::->new->utf8->canonical(); # breaks hashes otherwise
-my $JWK_dgst64 = encode_base64url(sha256($JSON->encode($JWK)));
+# JSON keys need to be sorted lexicographically (for instance in the thumbprint)
+sub json() { JSON::->new->utf8->canonical(); }
+
+my $JWK_thumbprint = encode_base64url(sha256(json()->encode($JWK)));
my $NONCE;
-# Send an HTTP request to the ACME server
-my $UA = LWP::UserAgent::->new( ssl_opts => {
- verify_hostname => 1,
- SSL_version => 'SSLv23:!TLSv1_1:!TLSv1:!SSLv3:!SSLv2',
- SSL_cipher_list => 'EECDH+AESGCM:!MEDIUM:!LOW:!EXP:!aNULL:!eNULL'
-});
+#############################################################################
+# Parse configuration (already validated by the master) and create the
+# LWP::UserAgent object
+#
+
+my $CONFIG = do {
+ my $conf = do { local $/ = undef, <$CONFFILE> };
+ close $CONFFILE or die "Can't close: $!";
+ my $h = Config::Tiny::->read_string($conf) or die Config::Tiny::->errstr()."\n";
+ $h->{_} //= {};
+ $h->{client}->{$_} //= $h->{_}->{$_} foreach keys %{$h->{_}}; # add defaults
+ $h->{client};
+};
+my $UA = do {
+ my %args = %$CONFIG;
+ my $verify = lc (delete $args{SSL_verify} // 'Yes') eq 'no' ? 0 : 1;
+ my %ssl_opts = ( verify_hostname => $verify );
+ $ssl_opts{$_} = $args{$_} foreach grep /^SSL_/, keys %args;
+ LWP::UserAgent::->new( ssl_opts => \%ssl_opts );
+} // die "Can't create LWP::UserAgent object";
+
+
+#############################################################################
+# Send an HTTP request to the ACME server. If $json is defined, send
+# its encoding as the request content, with "application/json" as
+# Content-Type.
+#
sub request($$;$) {
my ($method, $uri, $json) = @_;
- print STDERR ">>> $method $uri <<<\n" if $ENV{DEBUG};
+ print STDERR "[$$] >>> $method $uri <<<\n" if $ENV{DEBUG};
my $req = HTTP::Request::->new($method => $uri) or die "Can't $method $uri";
if (defined $json) {
- $req->header('Content-Type' => 'application/json');
- $req->content($JSON->encode($json));
+ $req->content_type('application/json');
+ $req->content(json()->encode($json));
}
my $r = $UA->request($req) or die "Can't $method $uri";
- print STDERR ">>> ", $r->status_line, "\n", $r->headers->as_string, "\n" if $ENV{DEBUG};
- $NONCE = $r->header('Replay-Nonce') // die;
- my $t = $r->header('Content-Type');
+ $NONCE = $r->header('Replay-Nonce'); # undef $NONCE if the header is missing
+ print STDERR "[$$] >>> ", $r->status_line, "\n", $r->headers->as_string, "\n" if $ENV{DEBUG};
+ return $r;
+}
- my $content = $r->decoded_content();
- if (defined $t and $t =~ /\Aapplication\/(?:[a-z]+\+)?json\z/) {
- $content = $JSON->decode($content);
- print STDERR $JSON->pretty->encode($content), "\n" if $ENV{DEBUG};
- }
- elsif (defined $t and $t eq 'application/pkix-cert') {
- print STDERR encode_base64($content), "\n" if $ENV{DEBUG};
- }
- else {
- print STDERR $content, "\n" if $ENV{DEBUG};
- }
- unless ($r->is_success) {
- my $msg = $r->status_line;
- $msg .= " (".$content->{detail}.")" if ref $content and defined $content->{detail};
- die $msg, "\n";
+# List all 'Links' headers with the relationship $rel (RFC 5988)
+sub request_link_rel($$) {
+ my ($r, $rel) = @_;
+ grep defined, map
+ { /\A<([^>]+)>.*;\s*rel=([^;]+)/
+ ; my ($link, $rels) = ($1, $2 // '')
+ ; (grep { $rel eq $_ } map { /^"(.*)"/ ? $1 : $_ } split(/\s+/, $rels)) ? $link : undef
+ }
+ $r->header('Link');
+}
+
+# The request's Status Line; if the Content-Type is
+# application/problem+json, parse the decoded content as JSON and add
+# the value of the 'detail' field to the Status Line.
+# https://tools.ietf.org/html/draft-ietf-appsawg-http-problem
+sub request_status_line($) {
+ my $r = shift;
+ my $msg = $r->status_line;
+ if ($r->content_type() eq 'application/problem+json') {
+ my $content = json()->decode($r->decoded_content());
+ print STDERR json()->pretty->encode($content), "\n" if $ENV{DEBUG};
+ $msg .= " (".$content->{detail}.")" if defined $content->{detail};
}
+ return $msg;
+}
+# Parse and return the request's decoded content as JSON; or print the
+# Status Line and fail if the request failed.
+# If $dump is set, also pretty-print the decoded content.
+sub request_json_decode($;$) {
+ my $r = shift;
+ my $dump = shift || $ENV{DEBUG};
+ die request_status_line($r), "\n" unless $r->is_success();
+ my $content = $r->decoded_content();
+
+ die "Content-Type: ".$r->content_type()." is not application/json\n"
+ unless $r->content_type() eq 'application/json';
+ $content = json()->decode($content);
+
+ print STDERR json()->pretty->encode($content), "\n" if $dump;
return $content;
}
-# ACME client
-# https://tools.ietf.org/html/draft-ietf-acme-acme-01
+#############################################################################
+# JSON-encode the hash reference $h and send it to the ACME server $uri
+# encapsulated it in a JSON Web Signature (JWS).
+# https://tools.ietf.org/html/draft-ietf-acme-acme
+#
sub acme($$) {
my ($uri, $h) = @_;
+ # the ACME server MUST provide a Replay-Nonce header field in
+ # response to a HEAD request for any valid resource
+ request(HEAD => $uri) unless defined $NONCE;
+
# Produce the JSON Web Signature: RFC 7515 section 5
- my $payload = encode_base64url($JSON->encode($h));
+ my $payload = encode_base64url(json()->encode($h));
my %header = ( alg => 'RS256', jwk => $JWK );
- my $protected = encode_base64url($JSON->encode({ %header, nonce => $NONCE }));
+ my $protected = encode_base64url(json()->encode({ %header, nonce => $NONCE }));
my $data = $protected .'.'. $payload;
- print STDERR "Requesting a SHA-256 signature for ", $data, "\n" if $ENV{DEBUG};
- STDOUT->printflush($data, "\n");
-
- # Ask for an (hex) sig
- my $sig = do { local $_ = <STDIN>; chomp; $_ };
- $sig = encode_base64url(pack('H*', $sig));
- print STDERR "Got SHA-256 signature ", $sig, "\n" if $ENV{DEBUG};
+ $S->printflush($data, "\r\n");
+ my $sig = $S->getline();
+ $sig =~ s/\r\n\z// or die;
# Flattened JSON Serialization, RFC 7515 section 7.2.2
request(POST => $uri, {
@@ -123,147 +195,150 @@ sub acme($$) {
});
}
-
-# Query the root ACME directory to initialize the nonce and get the resources URIs
-my %RES = %{ request(GET => "https://acme-v01.api.letsencrypt.org/directory") };
+my $SERVER_URI = $CONFIG->{server} // 'https://acme-v01.api.letsencrypt.org/';
+$SERVER_URI .= '/' unless substr($SERVER_URI, -1, 1) eq '/';
+
+my %RES;
+# Get the resource URI from the directory
+sub acme_resource($%) {
+ my $r = shift;
+ # Query the root ACME directory to initialize the nonce and get the resources URIs
+ %RES = %{ request_json_decode(request(GET => $SERVER_URI.'directory')) } unless %RES;
+ my $uri = $RES{$r} // die "Missing ressource for \"$r\"\n";
+ acme($uri, {resource => $r, @_});
+}
+#############################################################################
+# new-reg AGREEMENT_URI [CONTACT ..]
+#
if ($COMMAND eq 'new-reg') {
+ my $agreement = shift @ARGV;
print STDERR "Requesting new registration ".(@ARGV ? ("for ".join(', ', @ARGV)) : "")."\n";
- my $uri = "https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf";
- my $dgst = sha256_hex($UA->get($uri)->decoded_content());
- die "Error: The CA's subscriber agreement (URL $uri) has changed!\n" if
- $dgst ne '33d233c8ab558ba6c8ebc370a509acdded8b80e5d587aa5d192193f35226540f';
-
- acme($RES{'new-reg'}, {
- resource => 'new-reg',
- contact => [ map {"mailto:$_"} @ARGV ],
- agreement => $uri,
- });
- exit;
-}
+ my %h = (contact => \@ARGV);
+ $h{agreement} = $agreement if $agreement ne '';
+ my $r = acme_resource('new-reg', %h);
-if ($COMMAND eq 'revoke-cert') {
- print STDERR "Requesting revocation for\n";
- for my $cert (@ARGV) {
- open my $fh1, '-|', qw/openssl x509 -noout -subject -serial -fingerprint -sha256/, '-in', $cert
- or die "Can't run x509(1ssl): $!";
- my ($subject, $serial, $fingerprint) = map { s/[^=]+=\s*//; chomp; $_ } <$fh1>;
- close $fh1;
-
- print STDERR "\n\tSubject: $subject\n",
- "\tSerial: $serial\n",
- "\tSHA-256 fingerprint: $fingerprint\n";
+ my ($terms) = request_link_rel($r, 'terms-of-service');
+ request_json_decode($r,1) if $r->is_success() and $ENV{DEBUG}; # pretty-print the JSON
+ print STDERR request_status_line($r), "\n";
+ print STDERR "Subscriber Agreement URI: $terms\n" if defined $terms;
+ print STDERR "Registration URI: ", $r->header('Location'), "\n";
+ exit ($r->is_success() ? 0 : 1);
+}
- open my $fh2, '-|', qw/openssl x509 -outform DER/, '-in', $cert or die "Can't run x509(1ssl): $!";
- my $der = do { local $/ = undef; <$fh2> };
- close $fh2;
- acme($RES{'revoke-cert'}, {
- resource => 'revoke-cert',
- certificate => encode_base64url($der)
- });
- }
- exit;
+#############################################################################
+# reg=URI AGREEMENT_URI [CONTACT ..]
+#
+elsif ($COMMAND =~ /\Areg=(\p{Print}+)\Z/) {
+ die "Empty registration URI (use the 'new-reg' command to determine the URI)\n" if $1 eq '';
+ my $uri = $SERVER_URI.$1;
+ my $agreement = shift @ARGV;
+
+ my %h = (resource => 'reg');
+ $h{agreement} = $agreement if $agreement ne '';
+ $h{contact} = \@ARGV if @ARGV; # don't empty the contact list
+ my $r = acme($uri, \%h);
+
+ my ($terms) = request_link_rel($r, 'terms-of-service');
+ $r->is_success() ? request_json_decode($r,1) # pretty-print the JSON
+ : print STDERR request_status_line($r), "\n";
+ print STDERR "Subscriber Agreement URI: $terms\n" if defined $terms;
+ exit ($r->is_success() ? 0 : 1);
}
-# $COMMAND eq 'new-cert'
-my ($CSR, $CHALLENGE_DIR, $X509) = @ARGV;
-$CHALLENGE_DIR = $CHALLENGE_DIR =~ /\A(\/\p{Print}+)\z/ ? $1 :
- die "Error: Challenge directory is not absolute: $CHALLENGE_DIR";
-
-# Parse the Certificate Signing Request
-# XXX use a library instead, perhaps Crypt::OpenSSL::PKCS10
-my @domains = do {
- my @req = (qw/openssl req -noout/, '-in', $CSR);
-
- 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)+/;
- my %domains;
-
- open my $fh1, '-|', @req, '-subject' or die "Can't run req(1ssl): $!";
- my $subject = <$fh1>;
- close $fh1;
- $domains{$1} = 1 if $subject =~ /\Asubject=.*\/CN=($RE_domain)\n\z/o;
-
- open my $fh2, '-|', @req, '-text', '-reqopt', 'no_header,no_version,no_subject,no_pubkey,no_sigdump'
- or die "Can't run req(1ssl): $!";
- while (<$fh2>) {
- /\A\s+X509v3 Subject Alternative Name:/ or next;
- my $san = <$fh2>;
- foreach (split /,/, $san) {
- chomp;
- s/\A\s*//;
- next unless s/\ADNS://;
- if (/\A$RE_domain\z/o) {
- $domains{$_} = 1;
- }
- else {
- warn "WARNING: Ignoring invalid domain $_\n";
- }
+#############################################################################
+# new-cert AUTHZ [AUTHZ ..]
+# Read the CSR (in DER format) from STDIN, print the cert (in PEM format
+# to STDOUT)
+#
+elsif ($COMMAND eq 'new-cert') {
+ die unless @ARGV;
+ foreach my $domain (@ARGV) {
+ print STDERR "Processing new DNS authz for $domain\n" if $ENV{DEBUG};
+ my $r = acme_resource('new-authz', identifier => {type => 'dns', value => $domain});
+
+ my ($challenge) = grep {$_->{type} eq 'http-01'}
+ @{request_json_decode($r)->{challenges} // []};
+ die "Missing 'http-01' challenge in server response" unless defined $challenge;
+ die "Invalid challenge token ".($challenge->{token} // '')."\n"
+ unless ($challenge->{token} // '') =~ /\A[A-Za-z0-9_\-]+\z/;
+
+ my $keyAuthorization = $challenge->{token}.'.'.$JWK_thumbprint;
+
+ # serve $keyAuthorization at http://$domain/.well-known/acme-challenge/$challenge->{token}
+ if (-e $challenge->{token}) {
+ print STDERR "WARNING: File exists: $challenge->{token}\n";
+ }
+ else {
+ open my $fh, '>', $challenge->{token} or die "Can't open $challenge->{token}: $!";
+ $fh->print($keyAuthorization);
+ $fh->close() or die "Can't close: $!";
}
- last;
- }
- close $fh2;
-
- keys %domains;
-};
-print STDERR "Found domain(s): ".join(", ", @domains), "\n" if $ENV{DEBUG};
-
-# Process DNS Authorizations
-foreach my $domain (@domains) {
- print STDERR "Processing new DNS authz for $domain\n" if $ENV{DEBUG};
- my $challenges = acme($RES{'new-authz'}, {
- resource => 'new-authz',
- identifier => { type => 'dns', value => $domain }
- });
- die "No challenge in server response" unless defined $challenges->{challenges};
- my ($challenge) = grep {$_->{type} eq 'http-01'} @{$challenges->{challenges}};
- my $keyAuthorization = $challenge->{token}.'.'.$JWK_dgst64;
-
- # serve $keyAuthorization at http://$domain/.well-known/acme-challenge/$challenge->{token}
- my $filename = $CHALLENGE_DIR.'/'.$challenge->{token};
- if (-e $filename) {
- warn "WARNING: File exists: $filename\n";
- }
- else {
- open my $fh, '>', $filename or die "Can't open $filename: $!";
- print $fh $keyAuthorization;
- close $fh;
+ $r = acme($challenge->{uri}, {
+ resource => 'challenge',
+ keyAuthorization => $keyAuthorization
+ });
+ # wait until the status become 'valid'
+ for ( my $i = 0, my $status;
+ $status = request_json_decode($r)->{status} // 'pending',
+ $status ne 'valid';
+ $r = request('GET' => $challenge->{uri}), $i++ ) {
+ die "Error: Invalid challenge for $domain (status: ".$status.")\n" if $status ne 'pending';
+ die "Timeout exceeded while waiting for challenge to pass ($domain)\n"
+ if $i >= ($CONFIG->{timeout} // 10);
+ sleep 1;
+ }
}
- acme($challenge->{uri}, {
- resource => 'challenge',
- keyAuthorization => $keyAuthorization
- });
+ my $csr = do { local $/ = undef; <STDIN> };
+ my $r = acme_resource('new-cert', csr => encode_base64url($csr));
+ die request_status_line($r), "\n" unless $r->is_success();
+ my $uri = $r->header('Location');
+ # https://acme-v01.api.letsencrypt.org/acme/cert/$serial
+ print STDERR "Certificate URI: $uri\n";
- for (my $i=0;; $i++) {
- my $status = request('GET' => $challenge->{uri})->{status} // 'pending';
- die "Error: Invalid challenge for $domain\n" if $status eq 'invalid';
- last if $status eq 'valid';
- die "Timeout exceeded while waiting for challenge to pass ($domain)\n" if $i >= $TIMEOUT;
+ # wait for the cert
+ for (my $i = 0; $r->decoded_content() eq ''; $r = request('GET' => $uri), $i++) {
+ die request_status_line($r), "\n" unless $r->is_success();
+ die "Timeout exceeded while waiting for certificate\n"
+ if $i >= ($CONFIG->{timeout} // 10);
sleep 1;
}
-}
-
+ my $der = $r->decoded_content();
+
+ # conversion DER -> PEM
+ 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: $!";
+ exec qw/openssl x509 -inform DER -outform PEM/ or die;
+ }
+ $rd->close() or die "Can't close: $!";
+ $wd->print($der);
+ $wd->close() or die "Can't close: $!";
-do {
- print STDERR "Processing new CSR\n" if $ENV{DEBUG};
- open my $fh1, '-|', qw/openssl req -outform DER/, '-in', $CSR or die "Can't run req(1ssl): $!";
- my $req = do { local $/ = undef; <$fh1> };
- close $fh1;
+ waitpid $pid => 0;
+ die $? if $? > 0;
+}
- # The server also gives the cert URI in its 'Location' header in
- # https://acme-v01.api.letsencrypt.org/acme/cert/$serial
- my $x509 = acme($RES{'new-cert'}, {
- resource => 'new-cert',
- csr => encode_base64url($req)
- });
- open my $fh2, '|-', qw/openssl x509 -inform DER/, '-out', $X509 or die "Can't run x509(1ssl): $!";
- print $fh2 $x509;
- close $fh2;
-};
+#############################################################################
+# revoke-cert
+# The certificate to revoke is passed (in DER format) to STDIN; this
+# is required since the ACME client might not have read access to the
+# X.509 file
+#
+elsif ($COMMAND eq 'revoke-cert') {
+ die if @ARGV;
+ my $der = do { local $/ = undef; <STDIN> };
+ close STDIN or die "Can't close: $!";
+
+ my $r = acme_resource('revoke-cert', certificate => encode_base64url($der));
+ exit 0 if $r->is_success();
+ die request_status_line($r), "\n";
+}
diff --git a/config/letsencrypt-certs.conf b/config/letsencrypt-certs.conf
new file mode 100644
index 0000000..5613ef6
--- /dev/null
+++ b/config/letsencrypt-certs.conf
@@ -0,0 +1,56 @@
+# Each non-default section denotes a separate certificate issuance.
+# Options in the default section apply to each sections.
+
+# Message digest to sign the Certificate Signing Request with.
+#hash = sha512
+
+# Comma-separated list of Key Usages, see x509v3_config(5ssl).
+#keyUsage = digitalSignature, keyEncipherment
+
+#[www]
+
+# Where to store the issued certificate (in PEM format).
+#certificate = /etc/nginx/ssl/srv.pem
+
+# Where to store the issued certificate, concatenated with the content
+# of the file specified specified with the CAfile option (in PEM format).
+#certificate-chain = /etc/nginx/ssl/srv.chain.pem
+
+# Path the service's private key. This option is required.
+#certificate-key = /etc/nginx/ssl/srv.key
+
+# For an existing certificate, the minimum number of days before its
+# expiration date the section is considered for re-issuance.
+#min-days = 10
+
+# Path to the issuer's certificate. This is used for certificate-chain
+# and to verify the validity of each issued certificate. Specifying an
+# empty value skip certificate validation.
+#CAfile = /usr/share/letsencrypt-tiny/lets-encrypt-x1-cross-signed.pem
+
+# Subject field of the Certificate Signing Request. This option is
+# required.
+#subject = /CN=example.org
+
+# Comma-separated list of Subject Alternative Names.
+#subjectAltName = DNS:example.org,DNS:www.example.org
+
+# username[:groupname] to chown the issued certificate and
+# certificate-chain with.
+#chown = root:root
+
+# octal mode to chmod the issued certificate and certificate-chain with.
+#chmod = 0644
+
+# Command to pass the the system's command shell ("/bin/sh -c") after
+# successful installation of the certificate and/or certificate-chain.
+#notify = /bin/systemctl restart nginx
+
+
+#[smtp]
+#certificate-key = /etc/postfix/ssl/srv.key
+#certificate-chain = /etc/postfix/ssl/srv.pem
+#subject = /CN=smtp.example.org
+#notify = /bin/systemctl restart postfix
+
+; vim:ft=dosini
diff --git a/config/letsencrypt.conf b/config/letsencrypt.conf
new file mode 100644
index 0000000..1502020
--- /dev/null
+++ b/config/letsencrypt.conf
@@ -0,0 +1,86 @@
+# For certificate issuance (new-cert command), specify the certificate
+# configuration file to use
+#
+#config-certs = config/letsencrypt-certs.conf
+
+[client]
+# The value of "socket" specifies the letsencrypt-accountd(1)
+# UNIX-domain socket to connect to for signature requests from the ACME
+# client. letsencrypt aborts if the socket is readable or writable by
+# other users, or if its parent directory is writable by other users.
+# Default: "$XDG_RUNTIME_DIR/S.letsencrypt" if the XDG_RUNTIME_DIR
+# environment variable is set.
+#
+#socket = /run/user/1000/S.letsencrypt
+
+# username to drop privileges to (setting both effective and real uid).
+# Preserve root privileges if the value is empty (not recommended).
+# Default: "nobody".
+#
+#user = letsencrypt
+
+# groupname to drop privileges to (setting both effective and real gid,
+# and also setting the list of supplementary gids to that single group).
+# Preserve root privileges if the value is empty (not recommended).
+#
+#group = nogroup
+
+# Path to the ACME client executable.
+#command = /usr/lib/letsencrypt-tiny/client
+
+# Root URI of the ACME server. NOTE: Use the staging server for testing
+# as it has relaxed ratelimit.
+#
+#server = https://acme-v01.api.letsencrypt.org/
+#server = https://acme-staging.api.letsencrypt.org/
+
+# Timeout in seconds after which the client stops polling the ACME
+# server and considers the request failed.
+#
+#timeout = 10
+
+# Whether to verify the server certificate chain.
+#SSL_verify = yes
+
+# Specify the version of the SSL protocol used to transmit data.
+#SSL_version = SSLv23:!TLSv1_1:!TLSv1:!SSLv3:!SSLv2
+
+# Specify the cipher list for the connection.
+#SSL_cipher_list = EECDH+AESGCM:!MEDIUM:!LOW:!EXP:!aNULL:!eNULL
+
+
+[webserver]
+
+# Specify the local address to listen on, in the form ADDRESS[:PORT].
+#
+#listen = 0.0.0.0:80
+#listen = [::]:80
+
+# If a webserver is already running, specify a non-existent directory
+# under which the webserver is configured to serve GET requests for
+# challenge files under "/.well-known/acme-challenge/" (for each virtual
+# hosts requiring authorization) as static files.
+#
+#challenge-directory = /var/www/acme-challenge
+
+# username to drop privileges to (setting both effective and real uid).
+# Preserve root privileges if the value is empty (not recommended).
+#
+#user = www-data
+
+# groupname to drop privileges to (setting both effective and real gid,
+# and also setting the list of supplementary gids to that single group).
+# Preserve root privileges if the value is empty (not recommended).
+#
+#user = www-data
+
+# Path to the ACME webserver executable.
+#command = /usr/lib/letsencrypt-tiny/webserver
+
+# Whether to automatically install iptables(1) rules to open the
+# ADDRESS[:PORT] specified with listen. Theses rules are automatically
+# removed once letsencrypt exits.
+#
+#iptables = Yes
+
+; vim:ft=dosini
diff --git a/letsencrypt b/letsencrypt
index b6235cf..23659d5 100755
--- a/letsencrypt
+++ b/letsencrypt
@@ -1,8 +1,8 @@
-#!/bin/bash
+#!/usr/bin/perl -T
#----------------------------------------------------------------------
-# Tiny Let's Encrypt ACME client
-# Copyright © 2015 Guilhem Moulin <guilhem@fripost.org>
+# 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
@@ -18,286 +18,687 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#----------------------------------------------------------------------
-set -ue
-set -o pipefail
-PATH=/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
-NAME=$(basename $0)
-
-WWW_USER=www-data
-ACME_WEBSERVER=/usr/lib/letsencrypt-tiny/webserver
-ACME_CLIENT=/usr/lib/letsencrypt-tiny/client
-CAfile=/usr/share/letsencrypt-tiny/lets-encrypt-x1-cross-signed.pem
-
-declare COMMAND ACCOUNTKEY
-declare -l GENKEY
-declare RUNAS QUIET= DEBUG=
-
-declare SRVCRT= CHAIN CSR SRVKEY
-declare -i MIN_AGE=0
-declare -l HASH=
-declare SUBJECT=/
-declare SAN=
-# https://security.stackexchange.com/questions/24106/which-key-usages-are-required-by-each-key-exchange-method
-declare KEYUSAGE='digitalSignature, keyEncipherment, keyCertSign'
-declare -a NOTIFY=()
-
-usage() {
- local msg="${1:-}"
- if [ "$msg" ]; then
- echo "$NAME: $msg" >&2
- echo "Try '$NAME --help' or consult the manpage for more information." >&2
- exit 1
- fi
- cat <<- EOF
- Usage: $NAME [OPTIONS] new-reg ACCOUNTKEY [EMAIL ..]
- or: $NAME [OPTIONS] new-cert ACCOUNTKEY --output=CERT {--csr=CSR | CSR Options }
- or: $NAME [OPTIONS] revoke-cert ACCOUNTKEY CERT [CERT ..]
- Consult the manpage for more information.
- EOF
- exit 0
+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: $!";
+ }
+
+ 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: $!";
+ }
+ # use execve(2) rather than a Perl pseudo-process to ensure that
+ # the child doesn't have access to the parent's memory
+ 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: $!";
}
-# Generate a key pair with mode 0600
-genkey() {
- local key="$1" genkey= bits=
- case "$GENKEY" in
- rsa) genkey=genrsa;;
- rsa:*) genkey=genrsa; bits=${GENKEY#*:};;
- *) echo "Error: invalid key type ${GENKEY%%:*}" >&2; exit 1;;
- esac
- install --mode=0600 /dev/null "$key" # atomic creation with mode 0600
- openssl "$genkey" $bits >"$key"
+
+#############################################################################
+# 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-x1-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;
}
-# Parse options
-declare -a ARGV=()
-while [ $# -gt 0 ]; do
- case "$1" in
- --genkey) GENKEY=RSA;;
- --genkey=*) GENKEY="${1#*=}";;
- --runas=*) RUNAS="${1#*=}";;
- --help|-\?) usage;;
- --quiet|-q) QUIET=1;;
- --debug) DEBUG=1;;
-
- --output=*) SRVCRT="${1#*=}";;
- --min-age=*) MIN_AGE="${1#*=}";;
- --chain) CHAIN=;;
- --chain=*) CHAIN="${1#*=}";;
- --csr=*) CSR="${1#*=}";;
- --key=*) SRVKEY="${1#*=}";;
- --hash=*) HASH="${1#*=}";;
- --subject=*) SUBJECT="${1#*=}";;
- --san=*) SAN="${1#*=}";;
- --keyusage=*) KEYUSAGE="${1#*=}";;
- --notify=*) NOTIFY+=( "${1#*=}" );;
-
- --) shift; ARGV+=( "$@" ); break ;;
- -*) usage "unknown option '${1%%=*}'";;
- *) if [ ${ACCOUNTKEY+x} ]; then
- ARGV+=( "$1" )
- else
- [ ${COMMAND+x} ] && ACCOUNTKEY="$1" || COMMAND="$1"
- fi
- esac
- shift
-done
-
-[ "${COMMAND:-}" ] || usage "missing command"
-[ "$COMMAND" = 'new-reg' -o "$COMMAND" = 'new-cert' -o "$COMMAND" = 'revoke-cert' ] ||
- usage "invalid command $COMMAND"
-[ ${#ARGV[@]} -eq 0 -o "$COMMAND" = 'new-reg' -o "$COMMAND" = 'revoke-cert' ] ||
- usage "invalid argument ${ARGV[0]}"
-
-[ "${ACCOUNTKEY:-}" ] || usage "missing account key"
-if ! [ -f "$ACCOUNTKEY" -a -s "$ACCOUNTKEY" ]; then
- if [ "$COMMAND" = 'new-reg' -a "${GENKEY+x}" ]; then
- genkey "$ACCOUNTKEY"
- else
- echo "Error: keyfile '$ACCOUNTKEY' does not exist." >&2
- [[ ! "$COMMAND" =~ ^new- ]] || echo "Use 'new-reg --genkey' to generate and register an RSA key with mode 0600." >&2
- exit 1
- fi
-fi
-
-
-declare -a TMPFILES=()
-declare CHALLENGE_DIR IPTABLES_SAVE
-cleanup() {
- set +e
- [ ! ${IPTABLES_SAVE+x} ] || iptables-restore -c <"$IPTABLES_SAVE"
- pkill -TERM -P $$
- [ ${#TMPFILES[@]} -eq 0 ] || rm -${DEBUG:+v}f "${TMPFILES[@]}"
- [ ! ${CHALLENGE_DIR+x} ] || rm -${DEBUG:+v}rf "$CHALLENGE_DIR"
+#############################################################################
+# 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;
}
-trap cleanup EXIT SIGINT
-
-
-# Extract the public part of the user key
-accountpub=$(mktemp --tmpdir XXXXXX.pub)
-TMPFILES+=( "$accountpub" )
-openssl pkey -pubout <"$ACCOUNTKEY" >"$accountpub"
-chmod 0644 "$accountpub"
-
-
-if [ "$COMMAND" = 'revoke-cert' ]; then
- if [ ${#ARGV[@]} -eq 0 ]; then
- echo "Error: Nothing to revoke" >&2
- exit 1
- fi
-elif [ "$COMMAND" = 'new-cert' ]; then
- if [ ! "${SRVCRT:-}" ]; then
- echo "Error: Missing --output" >&2
- exit 1
- fi
- if [ -s "$SRVCRT" ] && [ $MIN_AGE -gt 0 ] && \
- exp=$(openssl x509 -noout -enddate <"$SRVCRT" 2>/dev/null) && \
- [ $(( $(date -d "${exp#*=}" +%s) - $(date +%s))) -gt $MIN_AGE ]; then
- [ ! "$DEBUG" ] || echo "Expiration date ($(date -d"${exp#*=}")) is too far away, come back later." >&2
- exit 0
- fi
-
- # Generate a Certificate Signing Request if need be
- if [ ${CSR+x} ]; then
- if [ -z "$CSR" -o ! -s "$CSR" ]; then
- echo "Error: Missing Certificate Signing Request $CSR. (One of --csr or --key must be set.)" >&2
- exit 1
- fi
- else
- CSR=$(mktemp --tmpdir XXXXXX.csr)
- config=$(mktemp --tmpdir XXXXXX.cnf)
- TMPFILES+=( "$CSR" "$config" )
- chmod 0644 "$CSR"
-
- if [ "$HASH" ]; then
- case "$HASH" in
- md5|rmd160|sha1|sha224|sha256|sha384|sha512) HASH="-$HASH";;
- *) echo "Invalid digest algorithm: $HASH" >&2; exit 1;;
- esac
- fi
- if [ ! "${SRVKEY:-}" ] || [ ! -s "$SRVKEY" ]; then
- if [ "${SRVKEY:-}" -a "${GENKEY+x}" ]; then
- genkey "$SRVKEY"
- else
- echo "Error: Missing private server key. Use new-cert --genkey --key=... to generate" >&2
- exit 1
- fi
- fi
-
- [ ! "$DEBUG" ] || echo 'Generating Certificate Signing Request...' >&2
- cat >"$config" <<- EOF
- [ req ]
- distinguished_name = req_distinguished_name
- req_extensions = v3_req
-
- [ req_distinguished_name ]
-
- [ v3_req ]
- ${SAN:+subjectAltName= $SAN}
- # XXX Golang errors on extensions marked critical
- # https://github.com/letsencrypt/boulder/issues/565
- #basicConstraints = critical, CA:FALSE
- #keyUsage = critical${KEYUSAGE:+, $KEYUSAGE}
- basicConstraints = CA:FALSE
- keyUsage = $KEYUSAGE
- subjectKeyIdentifier = hash
- EOF
- openssl req -new -batch -key "$SRVKEY" $HASH -subj "$SUBJECT" -config "$config" -reqexts v3_req >"$CSR"
- fi
- [ ! "$DEBUG" ] || openssl req -noout -text <"$CSR"
-
- CHALLENGE_DIR=$(mktemp --tmpdir -d acme-challenge.XXXXXX)
- x509=$(mktemp --tmpdir XXXXXX.pem)
- TMPFILES+=( "$x509" )
-
- [ ! "${RUNAS:-}" ] || chown "$RUNAS" "$CHALLENGE_DIR" "$x509"
- chgrp "$(id -g -- "$WWW_USER")" "$CHALLENGE_DIR"
- chmod 0750 "$CHALLENGE_DIR"
-
- # Make sure a webserver is configured to server ACME challenges
- if nc -z 127.0.0.1 80; then
- [ ! "$DEBUG" ] || echo "Using existing webserver" >&2
- ln -${DEBUG:+v}Ts "$CHALLENGE_DIR" /var/www/acme-challenge
- TMPFILES+=( "/var/www/acme-challenge" )
- else
- temp=$(mktemp --tmpdir)
- TMPFILES+=( "$temp" )
- iptables-save -c >"$temp"
- IPTABLES_SAVE="$temp"
- iptables -I INPUT -p tcp -m tcp --dport 80 -m state --state NEW,ESTABLISHED -j ACCEPT
- iptables -I OUTPUT -p tcp -m tcp --sport 80 -m state --state ESTABLISHED -j ACCEPT
- (
- [ ! "$DEBUG" ] || echo "Starting ACME webserver in $CHALLENGE_DIR" >&2
- cd "$CHALLENGE_DIR" || exit 1
- # use the "su" otion rather than "setuid/setgid" since while setgid
- # changes the primary group of the process, it doesn't drop other
- # group related privileges
- exec socat \
- TCP-LISTEN:80,su="$WWW_USER",reuseaddr,fork,max-children=5 \
- EXEC:"$ACME_WEBSERVER"
- )&
- fi
-
- ARGV=( "$CSR" "$CHALLENGE_DIR" "$x509" )
-fi
-
-
-pipe=$(mktemp --tmpdir -u XXXXXX.fifo)
-mkfifo -m0600 "$pipe"
-TMPFILES+=( "$pipe" )
-
-# Wait for signing requests from the ACME slave
-acme_client() {
- if [ "${RUNAS:-}" ]; then
- sudo -u "$RUNAS" ${DEBUG:+DEBUG="$DEBUG"} -- $ACME_CLIENT "$@"
- else
- [ ${RUNAS+x} ] || echo "WARNING: Use --runas=USER to specify a user to drop permissions to." >&2
- DEBUG="$DEBUG" $ACME_CLIENT "$@"
- fi
+
+
+#############################################################################
+#
+else {
+ die "Unknown command $COMMAND"
}
-acme_client "$COMMAND" "$accountpub" ${ARGV[0]+"${ARGV[@]}"} <"$pipe" |
-while read data; do
- echo -n "$data" | openssl dgst -sha256 -sign "$ACCOUNTKEY" -hex | sed 's/.*=\s*//'
-done >"$pipe"
-
-if [ "$COMMAND" != 'new-cert' ]; then
- [ "$QUIET" ] || echo OK
-else
- # Ensure the cert's pubkey matches that of the CSR, and that it's signed by the intended CA
- if [ ! -s "$x509" ] ||
- ! diff <(openssl req -in "$CSR" -pubkey -noout) \
- <(openssl x509 -in "$x509" -pubkey -noout) >/dev/null ||
- ! openssl verify -CAfile "$CAfile" -purpose sslserver -x509_strict <"$x509" >/dev/null; then
- echo "Error: Got an invalid X.509 certificate from the ACME server!" >&2
- exit 1
- fi
-
- # if it doesn't exist, create the file with mode 0644 minus the process's umask(2)
- [ -e "$SRVCRT" ] || touch "$SRVCRT"
- cat "$x509" >"$SRVCRT"
- [ ! "$DEBUG" ] || openssl x509 -noout -text <"$SRVCRT"
-
- # add the CA chain
- if [ ${CHAIN+x} ]; then
- if [ "${CHAIN:-$SRVCRT}" = "$SRVCRT" ]; then
- cat "$CAfile" >>"$SRVCRT"
- else
- [ -e "$CHAIN" ] || touch "$CHAIN"
- cat "$SRVCRT" "$CAfile" >"$CHAIN"
- fi
- fi
-
- if [ ! "$QUIET" ]; then
- echo "X.509 certificate $SRVCRT has been updated or renewed"
- echo
- openssl x509 -noout \
- -text -certopt no_header,no_version,no_pubkey,no_sigdump \
- -fingerprint -sha256 <"$SRVCRT"
- fi
-
- for (( i=0; i<${#NOTIFY[@]}; i++ )); do
- ${NOTIFY[$i]}
- done
-fi
+
+END {
+ local $?;
+ $_->() foreach reverse @CLEANUP;
+}
diff --git a/letsencrypt.1 b/letsencrypt.1
index a16e165..2175bd2 100644
--- a/letsencrypt.1
+++ b/letsencrypt.1
@@ -1,187 +1,362 @@
-.TH LETSENCRYPT "1" "DECEMBER 2015" "Tiny Let's Encrypt ACME client" "User Commands"
+.TH LETSENCRYPT "1" "MARCH 2016" "Tiny Let's Encrypt ACME client" "User Commands"
.SH NAME
letsencrypt \- Tiny Let's Encrypt ACME client
.SH SYNOPSIS
-.B letsencrypt\fR [\fIOPTION\fR ...] \fICOMMAND\fR \fIACCOUNTKEY\fR [\fIARGUMENT\fR ...]
+.B letsencrypt\fR [\fB\-\-config=\fIFILENAME\fR]
+[\fB\-\-socket=\fIPATH\fR] [\fIOPTION\fR ...] \fICOMMAND\fR
+[\fIARGUMENT\fR ...]
.SH DESCRIPTION
.PP
.B letsencrypt\fR is a tiny ACME client written with process isolation
and minimal privileges in mind.
-It is divided into three components:
-
-.nr step 1 1
-.IP \n[step]. 8
-The \(lqmaster\(rq process, which runs as root and is the only component
-with access to the private key material (both account and server keys).
-It is only used to fork the other components (after dropping
-privileges), and to sign ACME requests (JSON Web Signatures); for
-certificate issuance (\fBnew-cert\fR command), it is also used to
-generate the Certificate Signing Request, then to verify the validity of
-the issued certificate, and optionally to reload or restart services
-using \fB--notify\fR.
-
-.IP \n[step]. 8
-The actual ACME client, which runs as the user specified with
-\fB--runas\fR (or root if the option is omitted). It builds ACME
-requests and dialogues with the remote ACME server. All requests need
-to be signed with the account key, but this process doesn't need direct
-access to any private key material: instead, it write the data to be
-signed to a pipe shared with the master process, which in turns replies
-with its SHA-256 signature.
-
-.IP \n+[step].
-An optional webserver, which is spawned by the master process (when
-nothing is listening on localhost:80); \fBsocat\fR(1) is used to listen
-on port 80 and to change the user (owner) and group of the process to
-\(lqwww-data:www-data\(rq.
+It is divided into four components, each with its own executable:
+
+.IP \[bu] 4
+A \fIletsencrypt\-accountd\fR(1) process to manage the account key and
+issue SHA\-256 signatures needed for each ACME command.
+(This process binds to a UNIX\-domain socket to reply to signature
+requests from the ACME client.)
+One can use the UNIX\-domain socket forwarding facility of OpenSSH 6.7
+and later to run \fIletsencrypt\-accountd\fR(1) and \fBletsencrypt\fR on
+different hosts.
+
+.IP \[bu] 4
+A \(lqmaster\(rq \fBletsencrypt\fR process, which runs as root and is
+the only component with access to the private key material of the server
+keys.
+It is used to fork the ACME client (and optionally the ACME webserver)
+after dropping root privileges.
+For certificate issuances (\fBnew\-cert\fR command), it also generates
+Certificate Signing Requests, then verifies the validity of the issued
+certificate, and optionally reloads or restarts services when the
+\fInotify\fR option is set.
+
+.IP \[bu] 4
+An actual ACME client (specified with the \fIcommand\fR option of the
+\(lq[client]\(rq section of the configuration file), which builds ACME
+commands and dialogues with the remote ACME server.
+Since ACME commands need to be signed with the account key, the
+\(lqmaster\(rq \fBletsencrypt\fR process passes the
+\fIletsencrypt\-accountd\fR(1) UNIX\-domain socket to the ACME client:
+data signatures are requested by writing the data to be signed to the
+socket.
+
+.IP \[bu] 4
+For certificate issuances (\fBnew\-cert\fR command), an optional
+webserver (specified with the \fIcommand\fR option of the
+\(lq[webserver]\(rq section of the configuration file), which is spawned
+by the \(lqmaster\(rq \fBletsencrypt\fR process when no service is
+listening on the HTTP port.
(The only challenge type currently supported by \fBletsencrypt\fR is
-\(lqhttp-01\(rq, hence a webserver is required.)
-Some iptables rules are automatically added to open port 80, and
-removed afterwards.
-The web server only processes GET requests under the
-\(lq/.well-known/acme-challenge\(rq URI.
-If a webserver is already listening on port 80, it needs to be
-configured to serve these URIs (for each virtual-hosts requiring
-authorization) as static files under the \(lq/var/www/acme-challenge\(rq
-root directory, which must not exist.
+\(lqhttp\-01\(rq, which requires a webserver to answer challenges.)
+That webserver only processes GET and HEAD requests under the
+\(lq/.well\-known/acme\-challenge/\(rq URI.
+By default some \fIiptables\fR(1) rules are automatically installed to
+open the HTTP port, and removed afterwards.
+
+.SH COMMANDS
+.TP
+.B letsencrypt \fR[\fB\-\-agreement\-uri=\fIURI\fR]\fB \fBnew\-reg \fR[\fICONTACT\fR ...]
+Register the account key managed by \fIletsencrypt\-accountd\fR(1). A
+list of \fICONTACT\fR information (such as \(lqmaito:\(rq
+URIs) can be specified in order for the server to contact the client for
+issues related to this registration (such as notifications about
+server\-initiated revocations).
+
+\fB\-\-agreement\-uri=\fR can be used to specify a \fIURI\fR referring
+to a subscriber agreement or terms of service provided by the server;
+adding this options indicates the client's agreement with the referenced
+terms. Note that the server might require the client to agree to
+subscriber agreement before performing any further actions.
+
+If the account key is already registered, \fBletsencrypt\fR prints the
+URI of the existing registration and aborts.
+
+.TP
+.B letsencrypt \fR[\fB\-\-agreement\-uri=\fIURI\fR]\fB \fBreg=\fIURI\fR \fR[\fICONTACT\fR ...]
+
+Dump or edit the registration \fIURI\fR (relative to the ACME server URI,
+which is specified with the \fIserver\fR option of the \(lq[client]\(rq
+section of the configuration file).
+
+When specified, the list of \fICONTACT\fR information and the agreement
+\fIURI\fR are sent to the server to replace the existing values.
+
+.TP
+.B letsencrypt \fR[\fB\-\-config\-certs=\fIFILE\fR]\fB \fBnew\-cert \fR[\fISECTION\fR ...]
+
+Read the certificate configuration \fIFILE\fR (see the \fBCERTIFICATE
+CONFIGURATION FILE\fR section below for the configuration options), and
+request new Certificate Issuance for each of its sections (or the given
+list of \fISECTION\fRs).
+
+.TP
+.B letsencrypt \fBrevoke\-cert \fIFILE\fR [\fIFILE\fR ...]
+
+Request that the given certificate(s) \fIFILE\fR(s) be revoked. For
+this command, \fIletsencrypt\-accountd\fR(1) can be pointed to either
+the account key or the server's private key.
+
.SH GENERIC OPTIONS
.TP
-.B \-\-runas=\fIusername\fR
-Username to run the ACME client as. This user does not need access to
-any private key material. The ACME client runs as root when this option
-is omited (not recommended).
+.B \-\-config=\fIfilename\fR
+Use \fIfilename\fR as configuration file. See the \fBCONFIGURATION
+FILE\fR section below for the configuration options.
.TP
-.B \-?\fR, \fB\-\-help\fR
-Display a brief help and exit.
+.B \-\-socket=\fIpath\fR
+Use \fIpath\fR as the \fIletsencrypt\-accountd\fR(1) UNIX\-domain socket
+to connect to for signature requests from the ACME client.
+\fBletsencrypt\fR aborts if \fIpath\fR is readable or writable by
+other users, or if its parent directory is writable by other users.
+This overrides the \fIsocket\fR option of the \(lq[client]\(rq section
+of the configuration file.
.TP
-.B \-q\fR, \fB\-\-quiet\fR
-Try to be quiet.
+.B \-?\fR, \fB\-\-help\fR
+Display a brief help and exit.
.TP
.B \-\-debug
Turn on debug mode.
-.SH ACCOUNT KEY REGISTRATION
-.B letsencrypt\fR [\fIOPTION\fR ...] \fBnew\-reg\fR \fIACCOUNTKEY\fR [\fIEMAIL\fR ...]
+.SH CONFIGURATION FILE
+If \fB\-\-config=\fR is not given, \fBletsencrypt\fR uses the first
+existing configuration file among
+\fI./letsencrypt.conf\fR,
+\fI$XDG_CONFIG_HOME/letsencrypt\-tiny/letsencrypt.conf\fR (or
+\fI~/.config/letsencrypt\-tiny/letsencrypt.conf\fR if the
+XDG_CONFIG_HOME environment variable is not set), and
+\fI/etc/letsencrypt\-tiny/letsencrypt.conf\fR.
+Valid options are:
-Registers the given \fIACCOUNTKEY\fR. An optional list of \fIEMAIL\fR
-addresses can be given as contact information.
+.TP
+Default section
+.RS
+.TP
+.I config\-certs
+For certificate issuances (\fBnew\-cert\fR command), specify the
+certificate configuration file to use (see the \fBCERTIFICATE
+CONFIGURATION FILE\fR section below for the configuration options).
+.RE
.TP
-.B \-\-genkey\fR[\fB=\fIALGO\fR[:\fIBITS\fR]]
-Automatically generate the \fIACCOUNTKEY\fR (with mode 0600) if it does
-not exist, using a \fIBITS\fR\-long \fIALGO\fR key.
-The \fBopenssl\fR(1) default length is used when \fIBITS\fR is omited.
-The default \fIALGO\fRrithm is \(lqRSA\(rq, which is also the only one
-currently supported.
+\(lq[client]\(rq section
+This section is used for configuring the ACME client (which takes care
+of ACME commands and dialogues with the remote ACME server).
+.RS
+.TP
+.I socket
+See \fB\-\-socket=\fR.
+Default: \(lq$XDG_RUNTIME_DIR/S.letsencrypt\(rq if the XDG_RUNTIME_DIR
+environment variable is set.
-.SH CERTIFICATE ISSUANCE
-.B letsencrypt\fR [\fIOPTION\fR ...] \fBnew\-cert\fR \fIACCOUNTKEY\fR
-\fB\-\-output=\fICERT\fR \fB\-\-csr=\fIFILE\fR
+.TP
+.I user
+The username to drop privileges to (setting both effective and real
+uid).
+Preserve root privileges if the value is empty (not recommended).
+Default: \(lqnobody\(rq.
-.B letsencrypt\fR [\fIOPTION\fR ...] \fBnew\-cert\fR \fIACCOUNTKEY\fR
-\fB\-\-output=\fICERT\fR \fB\-\-key=\fIFILE\fR [\fB\-\-hash=\fIALGO\fR]
-[\fB\-\-subject=\fISTRING\fR] [\fB\-\-san=\fISTRING\fR]
-[\fB\-\-keyusage=\fISTRING\fR]
+.TP
+.I group
+The groupname to drop privileges to (setting both effective and real
+gid, and also setting the list of supplementary gids to that single
+group). Preserve root privileges if the value is empty (not
+recommended).
+Default: \(lqnogroup\(rq.
-Request a new Certificate Issuance using the given \fIACCOUNTKEY\fR.
-The Certificate Signing Request can be supplied directly using
-\fB\-\-csr\fR, or generated from the server key (\fB\-\-key\fR) using
-options \fB\-\-hash\fR, \fB\-\-subject\fR, \fB\-\-san\fR and \fB\-\-keyusage\fR.
+.TP
+.I command
+Path to the ACME client executable.
+Default: \(lq/usr/lib/letsencrypt\-tiny/client\(rq.
-The issued X.509 certificate is then validated, and upon success is
-placed (in PEM format) into the file specified with \fB\-\-output\fR; the
-optional \fB\-\-chain\fR option can be used to append the issuer
-certificate as well.
+.TP
+.I server
+Root URI of the ACME server.
+Default: \(lqhttps://acme\-v01.api.letsencrypt.org/\(rq.
.TP
-.B \-\-min-age=\fISECONDS
-Skip the issuance if the certificate specified by \fB\-\-output\fR exists
-and its expiration date is more than \fISECONDS\fR ahead.
+.I timeout
+Timeout in seconds after which the client stops polling the ACME server
+and considers the request failed.
+Default: \(lq10\(rq.
.TP
-.B \-\-csr=\fIFILE
-Certificate Signing Request to send (alternatively, use \fB\-\-key\fR to
-generate it from the private server key).
+.I SSL_verify
+Whether to verify the server certificate chain.
+Default: \(lqYes\(rq.
.TP
-.B \-\-key=\fIFILE
-Server private key used to generate the Certificate Signing Request when
-\fB\-\-csr\fR is omitted. (Use \fB\-\-genkey\fR to generate it
-automatically.)
+.I SSL_version
+Specify the version of the SSL protocol used to transmit data.
.TP
-.B \-\-genkey\fR[\fB=\fIALGO\fR[:\fIBITS\fR]]
-Automatically generate the server private key (with mode 0600) if it
-does not exist, using a \fIBITS\fR\-long \fIALGO\fR key.
-The \fBopenssl\fR(1) default length is used when \fIBITS\fR is omited.
-The default \fIALGO\fRrithm is \(lqRSA\(rq, which is also the only one
-currently supported.
+.I SSL_cipher_list
+Specify the cipher list for the connection.
+.RE
.TP
-.B \-\-hash=\fIDGST
-Message digest to sign the Certificate Signing Request with.
+\(lq[webserver]\(rq section
+This section is used for configuring the ACME webserver.
+.RS
.TP
-.B \-\-subject=\fR/\fItype0\fR=\fIvalue0\fR/\fItype1\fR=\fIvalue1\fR/\fItype2\fR=...
-Subject name to use in the Certificate Signing Request.
+.I listen
+Specify the local address to listen on, in the form
+\fIADDRESS\fR[:\fIPORT\fR].
+If \fIADDRESS\fR is enclosed with brackets \(oq[\(cq/\(oq]\(cq then it
+denotes an IPv6; an empty \fIADDRESS\fR means \(oq0.0.0.0\(cq.
+Default: \(lq:80\(rq.
.TP
-.B \-\-san=\fItype0\fR:\fIvalue1\fR,\fItype1\fR:\fIvalue1\fR,\fItype2\fR:...
-Comma-separated list of Subject Alternative Names. The only \fItype\fR
-currently supported is \(lqDNS\(rq, to specify an alternative domain
-name.
+.I challenge\-directory
+If a webserver is already running, specify a non\-existent directory
+under which the webserver is configured to serve GET requests for
+challenge files under \(lq/.well\-known/acme\-challenge/\(rq (for each
+virtual hosts requiring authorization) as static files.
+Default: \(lq/var/www/acme\-challenge\(rq.
.TP
-.B \-\-keyusage=\fISTRING
-Comma-separated list of Key Usages, see x509v3_config(5ssl).
-(Default: \(lqdigitalSignature,keyEncipherment,keyCertSign\(rq.)
+.I user
+The username to drop privileges to (setting both effective and real
+uid).
+Preserve root privileges if the value is empty (not recommended).
+Default: \(lqwww\-data\(rq.
.TP
-.B \-\-output=\fIFILE
-Where to copy the issued (signed) X.509 certificate.
+.I group
+The groupname to drop privileges to (setting both effective and real
+gid, and also setting the list of supplementary gids to that single
+group). Preserve root privileges if the value is empty (not
+recommended).
+Default: \(lqwww\-data\(rq.
.TP
-.B \-\-chain=\fR[\fIFILE\fR]
-Store the server certificate along with its intermediate CA in
-\fIFILE\fR. If \fIFILE\fR is empty or omitted, use the file specified
-with \fB\-\-output\fR instead.
+.I command
+Path to the ACME webserver executable.
+Default: \(lq/usr/lib/letsencrypt\-tiny/webserver\(rq.
.TP
-.B \-\-notify=\fICOMMAND\fR
-Command to run upon success. (This option can be repeated to run
-multiple commands.)
+.I iptables
+Whether to automatically install \fIiptables\fR(1) rules to open the
+\fIADDRESS\fR[:\fIPORT\fR] specified with \fIlisten\fR.
+Theses rules are automatically removed once \fBletsencrypt\fR exits.
+Default: \(lqYes\(rq.
+.RE
+
+
+.SH CERTIFICATE CONFIGURATION FILE
+For certificate issuances (\fBnew\-cert\fR command), a separate file is
+used to configure paths to the certificate and key, as well as the
+subject, subjectAltName, etc. to generate Certificate Signing Requests.
+If \fB\-\-config\-certs=\fR is not given, and if the
+\fIconfig\-certs\fR configuration option is absent,
+then \fBletsencrypt\fR uses the first existing configuration file among
+\fI./letsencrypt\-certs.conf\fR,
+\fI$XDG_CONFIG_HOME/letsencrypt\-tiny/letsencrypt\-certs.conf\fR (or
+\fI~/.config/letsencrypt\-tiny/letsencrypt\-certs.conf\fR if the
+XDG_CONFIG_HOME environment variable is not set), and
+\fI/etc/letsencrypt\-tiny/letsencrypt\-certs.conf\fR.
+Each section denotes a separate certificate issuance.
+Valid options are:
+.TP
+.I certificate
+Where to store the issued certificate (in PEM format).
+At least one of \fIcertificate\fR or \fIcertificate\-chain\fR is
+required.
+
+.TP
+.I certificate\-chain
+Where to store the issued certificate, concatenated with the content of
+the file specified specified with the \fICAfile\fR option (in PEM
+format).
+At least one of \fIcertificate\fR or \fIcertificate\-chain\fR is
+required.
-.SH CERTIFICATE REVOKATION
-.B letsencrypt\fR [\fIOPTION\fR ...] \fBrevoke\-cert\fR {\fIACCOUNTKEY\fR|\fISVRKEY\fR} \fIFILE\fR [\fIFILE\fR ...]
+.TP
+.I certificate\-key
+Path the service's private key. This option is required. The following
+command can be used to generate a new 4096\-bits RSA key in PEM format
+with mode 0600:
-Request that the given certificate(s) \fIFILE\fR(s) be revoked. The
-first argument after the command name can be either the account key file
-or the private part of the certificate(s) to revoke.
+.nf
+ openssl genrsa 4096 | install -m0600 /dev/stdin /path/to/priv.key
+.fi
+.TP
+.I min\-days
+For an existing certificate, the minimum number of days before its
+expiration date the section is considered for re\-issuance.
+Default: \(lq10\(rq.
-.SH EXAMPLES
.TP
-letsencrypt \-\-runas=letsencrypt new\-reg \-\-genkey=RSA:4096 /etc/ssl/letsencrypt.key admin@fripost.org
-Register a new account key \(lq/etc/ssl/letsencrypt.key\(rq using
-\(lqadmin@fripost.org\(rq as contact information. A 4096-bits long RSA
-key is generated (with mode 0600) if the key file does not exists.
+.I CAfile
+Path to the issuer's certificate. This is used for
+\fIcertificate\-chain\fR and to verify the validity of each issued
+certificate.
+Specifying an empty value skip certificate validation.
+Default: \(lq/usr/share/letsencrypt\-tiny/lets\-encrypt\-x1\-cross\-signed.pem\(rq.
+
+.TP
+.I hash
+Message digest to sign the Certificate Signing Request with.
+
+.TP
+.I keyUsage
+Comma\-separated list of Key Usages, see \fIx509v3_config\fR(5ssl).
+
+.TP
+.I subject
+Subject field of the Certificate Signing Request, in the form
+\fR/\fItype0\fR=\fIvalue0\fR/\fItype1\fR=\fIvalue1\fR/\fItype2\fR=...
+This option is required.
+
+.TP
+.I subjectAltName
+Comma\-separated list of Subject Alternative Names, in the form
+\fItype0\fR:\fIvalue1\fR,\fItype1\fR:\fIvalue1\fR,\fItype2\fR:...
+The only \fItype\fR currently supported is \(lqDNS\(rq, to specify an
+alternative domain name.
+
+.TP
+.I chown
+An optional \fIusername\fR[:\fIgroupname\fR] to chown the issued
+\fIcertificate\fR and \fIcertificate\-chain\fR with.
+
+.TP
+.I chmod
+An optional octal mode to chmod the issued \fIcertificate\fR and
+\fIcertificate\-chain\fR with.
.TP
-letsencrypt \-\-runas=letsencrypt new\-cert /etc/ssl/letsencrypt.key \-\-output=/etc/nginx/ssl/www.fripost.org.pem \-\-chain \-\-key=/etc/nginx/ssl/www.fripost.org.key \-\-hash=SHA512 \-\-subject=/O=Fripost/CN=fripost.org \-\-san=DNS:fripost.org,DNS:www.fripost.org,DNS:wiki.fripost.org \-\-min-age=432000 \-\-notify='systemctl restart nginx'
-Request issuance of a new (chained) certificate, but only if it doesn't exist or expires in less than 5 days; restart nginx upon success.
+.I notify
+Command to pass the the system's command shell (\(lq/bin/sh \-c\(rq)
+after successful installation of the \fIcertificate\fR and/or
+\fIcertificate\-chain\fR.
+
+
+.SH EXAMPLES
+
+.nf
+ ~$ sudo letsencrypt new-reg mailto:noreply@example.com
+ ~$ sudo letsencrypt reg=/acme/reg/137760 --agreement-uri=https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf
+ ~$ sudo letsencrypt new-cert
+ ~$ sudo letsencrypt revoke-cert /path/to/server/certificate.pem
+.fi
+
+.SH SEE ALSO
+\fBletsencrypt\-accountd\fR(1)
.SH AUTHOR
Written by Guilhem Moulin
diff --git a/webserver b/webserver
index e5e040d..0fe2979 100755
--- a/webserver
+++ b/webserver
@@ -1,8 +1,8 @@
#!/usr/bin/perl -T
#----------------------------------------------------------------------
-# Let's Encrypt ACME client (minimal webserver for answering challenges)
-# Copyright © 2015 Guilhem Moulin <guilhem@fripost.org>
+# Let's Encrypt ACME client (webserver component)
+# Copyright © 2015,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
@@ -21,30 +21,86 @@
use strict;
use warnings;
+# Usage: webserver FD
+#
+# fdopen(3) the file descriptor FD (corresponding to a listening
+# socket), then keep accept(2)'ing connections and consider each
+# connection as the verification of an ACME challenge, i.e., serve the
+# requested challenge token.
+#
+# NOTE: one needs to chdir(2) to an appropriate ACME challenge directory
+# before running this program, since challenge tokens are (on purpose)
+# only looked for in the current directory.
+#
+# NOTE: one should run this program as an unprivileged user:group such
+# as "www-data:www-data"; bind(2)'ing to a privileged port such as 80 is
+# not a problem since FD can be bound as root prior to the execve(2).
+
+use Errno 'EINTR';
+use Socket qw/AF_INET AF_INET6/;
+
+# Untaint and fdopen(3) the listening socket
+# TODO: we could even take multiple file descriptors and select(2)
+# between them; this could be useful to listen on two sockets, one for
+# INET and one for INET6
+(shift @ARGV // die) =~ /\A(\d+)\z/ or die;
+open my $S, '+<&=', $1 or die "fdopen $1: $!";
my $ROOT = '/.well-known/acme-challenge';
-$_ = <STDIN> // exit;
-my $proto = s/ HTTP\/(1\.[01])\r\n\z// ? $1 : die "Error: Bad request\n";
-my $method = s/\A(GET|HEAD) // ? $1 : die "Error: Bad request\n";
+close STDIN or die "close: $!";
+close STDOUT or die "close: $!";
+
+sub info($$$) {
+ my ($sockaddr, $msg, $req) = @_;
+ $req =~ s/\r\n\z// if defined $req;
+
+ # get a string representation of the peer's address
+ my $fam = Socket::sockaddr_family($sockaddr);
+ my (undef, $ip) =
+ $fam == AF_INET ? Socket::unpack_sockaddr_in($sockaddr) :
+ $fam == AF_INET6 ? Socket::unpack_sockaddr_in6($sockaddr) :
+ die;
+ my $addr = Socket::inet_ntop($fam, $ip);
-# Consume the headers (and ignore them)
-while (defined (my $h = <STDIN>)) { last if $h eq "\r\n" };
+ print STDERR $msg." from [$addr]".(defined $req ? ": $req" : "")."\n";
+}
+
+while (1) {
+ my $sockaddr = accept(my $conn, $S) or do {
+ next if $! == EINTR; # try again if accept(2) was interrupted by a signal
+ die "accept: $!";
+ };
+ my $req = $conn->getline();
+ info($sockaddr, "[$$] Incoming connection", $req) if $ENV{DEBUG};
+ next unless defined $req;
-my ($status_line, $content_type, $content);
-if (/\A\Q$ROOT\E\/([A-Za-z0-9_\-]+)\z/ and -f $1) {
- if (open my $fh, '<', $1) {
- ($status_line, $content_type) = ('200 OK', 'application/jose+json');
- $content = do { local $/ = undef; <$fh> };
- close $fh;
+ if ($req !~ s/\A(GET|HEAD) (.*) HTTP\/(1\.[01])\r\n\z/$2/) {
+ info($sockaddr, "Error: Bad request", $req);
+ next;
}
- else {
- $status_line = '403 Forbidden';
+ my ($method, $proto) = ($1, $3);
+
+ # Consume (and ignore) the headers
+ while (defined (my $h = $conn->getline())) { last if $h eq "\r\n" };
+
+ my ($status_line, $content_type, $content);
+ if ($req =~ /\A\Q$ROOT\E\/([A-Za-z0-9_\-]+)\z/ and -f $1) {
+ if (open my $fh, '<', $1) { # only open files in the cwd
+ ($status_line, $content_type) = ('200 OK', 'application/jose+json');
+ $content = do { local $/ = undef; $fh->getline() };
+ $fh->close() or die "close: $!";
+ }
+ else {
+ $status_line = '403 Forbidden';
+ }
}
-}
-print "HTTP/$proto ", ($status_line // '404 Not Found'), "\r\n";
-print "Content-Type: $content_type\r\n" if defined $content_type;
-print "Content-Length: ".length($content)."\r\n" if defined $content;
-print "Connection: close\r\n";
-print "\r\n";
-print $content if defined $content and $method eq 'GET';
+ $conn->print( "HTTP/$proto ", ($status_line // '404 Not Found'), "\r\n" );
+ $conn->print( "Content-Type: $content_type\r\n" ) if defined $content_type;
+ $conn->print( "Content-Length: ".length($content)."\r\n" ) if defined $content;
+ $conn->print( "Connection: close\r\n" );
+ $conn->print( "\r\n" );
+ $conn->print( $content ) if defined $content and $method eq 'GET';
+
+ $conn->close() or die "close: $!";
+}