#!/usr/bin/perl -w

# This file is part of Nivlheim.
#
# Nivlheim 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.
#
# Nivlheim 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 Nivlheim.  If not, see <http://www.gnu.org/licenses/>.


#****************************************************************************************
#   This script is a part of Nivlheim.
#   It takes no parameters. It creates a client certificate,
#   signs it with the Nivlheim CA Certificate, and returns it.
#   A previous, valid client certificate is required.
#   The hostname is determined by looking up the previous certificate
#   in the database and by looking at its common name.
#   The requirement is policed by Apache httpd, by placing the scripte
#   in the "secure" folder. Httpd must be configured to require a
#   valid client certificate for access to anything in this folder.
#****************************************************************************************

use strict;
use warnings;
use File::Copy;
use Log::Log4perl;
use MIME::Base64;
use Proc::PID::File;
use DBI;
use Crypt::OpenSSL::X509;
use LWP::Simple;
use Nivlheim::Database;

# There is a race condition with openssl, so until that is solved
# there can be only one instance of this script running at any time.
if (Proc::PID::File->running({"dir" => "/tmp", "name" => "reqcert"})) {
	print "Status: 503 Service unavailable\r\n";
	print "Retry-After: 10\r\n";
	print "Content-Type: text/plain\r\n\r\n";
	print "Too busy, please try again later.\n";
	exit;
}

# Config
my $confdir = "/var/www/nivlheim";
my $opensslconf = "/etc/nivlheim/openssl_ca.conf";
Log::Log4perl->init("$confdir/log4perl.conf");
my $logger = Log::Log4perl->get_logger("reqcert");

# Temporary files
my $id = int(rand()*100000000); # only used during the execution of this script
my $keyfile = "/tmp/user$id.key";
my $csrfile = "/tmp/user$id.csr";
my $crtfile = "/tmp/user$id.crt";
my $p12file = "/tmp/user$id.p12";

# Compute the client cert fingerprint
my $clientcert = $ENV{'SSL_CLIENT_CERT'};
my $x509 = Crypt::OpenSSL::X509->new_from_string($clientcert);
my $fingerprint = $x509->fingerprint_sha1();
$fingerprint =~ s/://g;

# Connect to Postgres
my $dbh = Nivlheim::Database->connect_to_db();
$dbh->begin_work;

# Check if the certificate has been revoked
my @row = $dbh->selectrow_array(
	"SELECT revoked FROM certificates WHERE fingerprint=?",
	undef, ($fingerprint));
if (@row && $row[0]) {
	# This certificate is revoked. Reject the client
	print "Status: 403 Forbidden\r\nContent-Type: text/plain\r\n\r\nYour certificate has been revoked.\n";
	exit;
}

# Determine hostname by looking up the host in the hostinfo table.
# Use the Common Name from the certificate as a fallback.
my $hostname;
@row = $dbh->selectrow_array("SELECT hostname FROM hostinfo WHERE certfp=?",
	undef, ($fingerprint));
if (@row && defined($row[0]) && $row[0] !~ /^\s*$/) { # check that hostname isn't null or empty
	$hostname = $row[0];
} else {
	$hostname = undef;
	if ($x509->subject() =~ /CN=(\S+)/) { $hostname = $1; }
}
if (!defined($hostname)) {
	print "Status: 500 Internal server error\r\nContent-Type: text/plain\r\n\r\n";
	print "Unable to determine the hostname.";
	$logger->debug("Unable to determine the hostname.");
	exit;
}
print "Status: 200 OK\r\nContent-Type: text/plain\r\n\r\n";
print "Your hostname is: $hostname\n";
$logger->debug("Hostname is: $hostname");

eval {
	# Generate client key
	my $cmd = "openssl genrsa -out $keyfile 4096 2>/dev/null";

	(system($cmd) == 0 and -f $keyfile) or die "Key generation failed: $?";
	$logger->debug("Key OK.");

	# Generate client certificate request
	$cmd = "openssl req -new -key $keyfile -out $csrfile -config $opensslconf";
	(system($cmd) == 0 and -f $csrfile) or die "Certificate request failed: $?";
	$logger->debug("Certificate request OK.");

	# find the id of the new certificate
	open(F, "$confdir/db/serial") or die "Could not read db/serial";
	$id = <F>; chomp $id;  # 6-sifret sertifikat-id fra openssl
	close(F);
	$logger->debug("The new id is $id");

	# Sign the certificate request with the CA certificate. This will generate crt- and pem-files.
	# openssl will store the pem-file in the certs directory (see openssl_ca.conf)
	$cmd = "openssl ca -batch -in $csrfile -cert $confdir/CA/nivlheimca.crt"
		." -keyfile $confdir/CA/nivlheimca.key"
		." -subj '/C=NO/ST=Norway/O=UiO/OU=USIT/CN=$hostname'"
		." -out $crtfile -config $opensslconf 2>/var/log/nivlheim/sign.log";
	if (!(system($cmd) == 0 and -f $crtfile)) {
		$logger->debug($cmd);
		die "Signing failed: $?";
	}
	$logger->debug("Signing OK.");

	# Clean up the generated pem file; don't need it
	unlink("$confdir/certs/$id.pem");

	# Keep the certificate in the database
	my ($previous, $first, $trusted_by_cfengine);
	@row = $dbh->selectrow_array("SELECT certid,first,trusted_by_cfengine FROM certificates WHERE fingerprint=?",
		undef, ($fingerprint));
	if (@row) {
		$previous = $row[0];
		$first = $row[1];
		$trusted_by_cfengine = $row[2];
	}
	my $cert;
	{
		open(my $F, $crtfile);
		binmode($F);
		local $/ = undef;
		$cert = <$F>;
		close($F);
	}
	$x509 = Crypt::OpenSSL::X509->new_from_string($cert);
	my $new_fingerprint = $x509->fingerprint_sha1();
	$new_fingerprint =~ s/://g;
	my $sth = $dbh->prepare("INSERT INTO certificates(issued,fingerprint,"
		."commonname,previous,first,cert,trusted_by_cfengine) VALUES(now(),?,?,?,?,?,?)");
	$sth->execute(($new_fingerprint, $hostname, $previous, $first, $cert, $trusted_by_cfengine))
		or die $DBI::errstr;
	$sth->finish;

	# Update the hostinfo and files tables
	$dbh->do("UPDATE hostinfo SET certfp=? WHERE certfp=?", undef,
		($new_fingerprint, $fingerprint)) or die $DBI::errstr;
	$dbh->do("UPDATE files SET certfp=? WHERE certfp=?", undef,
		($new_fingerprint, $fingerprint)) or die $DBI::errstr;
	$dbh->commit;
	# No need to update certfp in hostinfo_customfields, because it has ON UPDATE CASCADE.

	# Update the search cache, if the service is running
	get("http://localhost:4040/api/internal/replaceCertificate?old=$fingerprint&new=$new_fingerprint");

	# Generate a P12 file
	$cmd = "openssl pkcs12 -export -out $p12file -inkey $keyfile -in $crtfile "
		. "-CAfile $confdir/CA/nivlheimca.crt -password pass:";
	if (!(system($cmd) == 0 and -f $p12file)) {
		$logger->debug($cmd);
		die "P12 failed: $?";
	}

	# Display the generated certificate and key
	open(F, $crtfile) or die "Can't read crt file";
	while (<F>) { last if (/--BEGIN CERTIFICATE--/) };
	print; print while (<F>);
	close(F);
	open(F, $keyfile) or die "Can't read key file";
	print while (<F>);
	close(F);

	# Display the P12-file, base64-encoded
	open(F, $p12file);
	my $p12bytes;
	my $len = read(F, $p12bytes, 100000);
	close(F);
	print "-----BEGIN P12-----\n" . encode_base64($p12bytes, "\n") . "-----END P12-----\n";

	$logger->info("Created new cert with id $id for $hostname");
};
if ($@) {
	$logger->error("Failed to create new cert for $hostname: $@");
	print "Failed to create new certificate for $hostname:\n$@\n";
	$dbh->rollback;
}

# Clean up temp files
unlink($keyfile) if (-f $keyfile);
unlink($csrfile) if (-f $csrfile);
unlink($crtfile) if (-f $crtfile);
unlink($p12file) if (-f $p12file);

$dbh->disconnect;
