#!perl -w

use strict;
use warnings;

=head1 NAME

B<App::CamelPKI::SysV::Apache> - Modeling the Camel-PKI web server.

=head1 SYNOPSIS

=for My::Tests::Below "synopsis" begin

   use App::CamelPKI::SysV::Apache;
   use App::CamelPKI::Error;

   my $apache = load App::CamelPKI::SysV::Apache($directory);
   $apache->set_keys(-certificate => $cert, -key => $key,
                     -certification_chain => [ $opcacert, $rootcacert ]);
   $apache->https_port(443);
   try {
       $apache->start();
   } catch App::CamelPKI::Error::OtherProcess with {
       die "Dude, your Apache is out of whack!" if $apache->is_wedged;
       die "Could not start Apache: " .
           $apache->tail_error_logfile();
   };
   $apache->update_crl($crl);
   $apache->stop();

=for My::Tests::Below "synopsis" end

=head1 DESCRIPTION

Instances of I<App::CamelPKI::SysV::Apache> each represent an Apache Web
server that serves the App-PKI application.  I<App::CamelPKI::SysV::Apache>
encapsulates all the system- and distribution-specific knowledge
needed to run an Apache web server: it knows how to create a
configuration file, start and stop it, manage its PID file, log files,
and so on.

In the current implementation, an instance of
I<App::CamelPKI::SysV::Apache> only listens to one TCP port in HTTP/S, and
the URLs are (mostly) interpreted relative to L<App::CamelPKI>'s standard
URL namespace.  The essential feature of I<App::CamelPKI::SysV::Apache>
that the default, Catalyst-provided server lacks
(C<camel_pki_server.pl>) is the support for client-side authentication
using SSL certificates.  Thanks to this feature, App-PKI is able to
use itself for its own authentication needs.

=cut

package App::CamelPKI::SysV::Apache;

use IO::File;
use IO::Socket::INET;
use Fcntl qw(:seek);
use File::Slurp;
use File::Spec::Functions qw(catfile catdir);
use App::CamelPKI::Error;
use App::CamelPKI::RestrictedClassMethod qw(:Restricted);
use App::CamelPKI::Sys qw(fork_and_do);
use App::CamelPKI::Certificate;

=head1 CONSTRUCTOR AND METHODS

=head2 load($directory)

Creates and returns an instance of I<App::CamelPKI::SysV::Apache> by
loading it from the file system.  Like all constructors that take a
directory as argument, I<load> is subdued to capability discipline
using L<App::CamelPKI::RestrictedClassMethod>.

$directory is the path where the server's various persistent files are
stored (configuration file, PID file, cryptographic keys
etc). $directory must exist, but can be empty.

=cut

sub load : Restricted {
    my ($class, $homedir) = @_;
    # Some Apaches don't interpret relative paths properly:
    $homedir = File::Spec->rel2abs($homedir);
    my $self = bless {
                      homedir => $homedir,
                     }, $class;
    $self->_try_and_parse_config_file;
    $self->tail_error_logfile;
    return $self;
}

=head2 https_port()

=head2 https_port($portnum)

Gets or set the port on which the daemon will listen for HTTP/S
requests.  The default value is 443 if the current process is
privileged enough to bind to it, or 3443 otherwise.  This port number
is persisted onto disk and therefore only needs to be set once.

=head2 test_php_directory()

=head2 test_php_directory($dir)

=head2 test_php_directory(undef)

Gets, sets or disables the test PHP script directory in this instance
of I<App::CamelPKI::SysV::Apache>.  The default is to disable this feature,
which only serves for Camel-PKI's self-tests (unit and integration).

The value of I<test_php_directory> is persisted to disk, so that it need
not be reset at each construction.  It only takes effect the next time
the server is restarted with L</start>.

=head2 has_camel_pki()

=head2 has_camel_pki($boolean)

Gets or sets the "has App-PKI" flag, which defaults to true.
Instances of I<App::CamelPKI::SysV::Apache> that have I<has_camel_pki()> set
to false do not contain the Camel-PKI application.  Again, this is only
useful for tests.

The value of I<has_camel_pki> is persisted to disk, so that it need not
be reset at each construction.  It only takes effect the next time the
server is restarted with L</start>.

=cut

{
    my %defaults =
        (https_port =>
         (IO::Socket::INET->new(LocalPort => 443, ReuseAddr => 1) ?
          443 : 3443),
         test_php_directory => undef,
         has_camel_pki => 1);

    foreach my $persistent_field (keys %defaults) {
        my $getsetter = sub {
            my ($self, @set) = @_;
            if (@set) {
                ($self->{$persistent_field}) = @set;
                $self->_write_config_file(); # Persist
            }
            unless (exists($self->{$persistent_field})) {
                $self->{$persistent_field} = $defaults{$persistent_field};
            }
            return $self->{$persistent_field};
        };
        no strict "refs"; *{$persistent_field} = $getsetter;
    }
}

=head2 set_keys(-certificate => $cert, -key => $key,
                -certification_chain => \@chain)

Installs key material that will allow this Apache daemon to
authenticate itself to its HTTP/S clients ($cert and $key, which must
be instances of L<App::CamelPKI::Certificate> and L<App::CamelPKI::PrivateKey>
respectively), and also to verify the identity of HTTP/S clients that
themselves use a certificate (@chain, which is a list of instances of
L<App::CamelPKI::Certificate>; see also L</update_crl>).  If $cert is a
self-signed certificate, C<-certification_chain> and its parameter
\@chain may be omitted.

=cut

sub set_keys {
    throw App::CamelPKI::Error::Internal("WRONG_NUMBER_ARGS")
        unless (@_ % 2);
    my ($self, %keys) = @_;
    while(my ($k, $v) = each %keys) {
        if ($k eq "-certificate") {
            write_file($self->_certificate_filename, $v->serialize());
        } elsif ($k eq "-key") {
            write_file($self->_key_filename, $v->serialize());
        } elsif ($k eq "-certification_chain") {
            write_file($self->_ca_bundle_filename,
                       join("", map { $_->serialize } @$v));
        } else {
            throw App::CamelPKI::Error::Internal
                ("INCORRECT_ARGS",
                 -details => "Unknown named option $k");
        }
    }
}

=head2 is_operational()

Returns true if and only if the ad-hoc cryptographic material has been
added to this Web server using L</set_keys>.

=cut

# The above POD is ambiguous on purpose: ->is_operational may someday
# return true even if there is no CA chain available.
sub is_operational {
    my ($self) = @_;
    -r $self->_key_filename && -r $self->_certificate_filename &&
        -r $self->_ca_bundle_filename;
}

=head2 certificate()

Returns the Web server's SSL certificate, as an instance of
L<App::CamelPKI::Certificate>.

=cut

sub certificate {
    App::CamelPKI::Certificate->load(shift->_certificate_filename);
}

=head2 update_crl($crl)

Given $crl, an instance of L<App::CamelPKI::CRL>, verifies the signature
thereof and stores it into this Apache server if and only if it
matches one of the CAs previously installed using L</set_keys>'
C<-certificate_chain> named option, B<and> $crl is older than any CRL
previously added with I<update_crl()>.  If these security checks are
successful and Apache is already running, it will be restarted so as
to take the new CRL into account immediately.

Note that a Web server works perfectly without a CRL, and therefore
calling I<update_crl> is optional.  However, remember that CRLs have
expiration dates: once a CRL has been installed using this method, one
should plan for a suitable mechanism (e.g. a crontab entry) that will
download updated CRLs on a regular basis and submit them using
I<update_crl()>.

=cut

sub update_crl { "UNIMPLEMENTED" }

=head2 start(%opts)

Starts the daemon synchronously, meaning that I<start> will only
return control to its caller after ensuring that the Apache process
wrote its PID file and bound to its TCP port. I<start()> is
idempotent, and terminates immediately if the serveur is already up.

An L<App::CamelPKI::Error/App::CamelPKI::Error::OtherProcess> exception will be
thrown if the server doesn't answer within L</async_timeout> seconds.
An L<App::CamelPKI::Error/App::CamelPKI::Error::User> exception will be thrown
if one attempts to I<start()> the server before providing it with its
certificate and key with L</set_keys>.

Available named options are:

=over

=item I<< -strace => $strace_logfile >>

Starts Apache under the C<strace> debug command, storing all results
into $strace_logfile.

=item I<< -X => 1 >>

Starts Apache with the C<-X> option, which causes it to launch only
one worker and to not detach from the terminal.

=item I<< -gdb => 1 >>

=item I<< -gdb => $tty >>

Starts Apache under the GNU debugger attached to tty $tty (or the
current tty, if the value 1 is specified).  Incompatible with
I<-strace>.  If this option is specified, I<start()> will not time out
after L</async_timeout> seconds, but will instead wait an unlimited
amount of time for the server to come up.

=item I<< -exec => 1 >>

Don't fork a subprocess, use the C<exec> system call instead (see
L<perlfunc/exec>) to run Apache directly (or more usefully, some
combination of Apache and a debugger, according to the above named
options).  The current UNIX process will turn into Apache, and the
I<start> method will therefore never return.

=back

=cut

sub start {
    throw App::CamelPKI::Error::Internal("WRONG_NUMBER_ARGS")
        unless (@_ % 2);
    my ($self, %opts) = @_;
    throw App::CamelPKI::Error::OtherProcess("Apache is wedged")
        if ($self->is_wedged);
    return if $self->is_started;

    $self->_write_config_file();
    my (@debugprecmd, @dashX);
    my $timeout = 1;
    if (defined(my $stracefile = delete $opts{-strace})) {
        @debugprecmd = ("strace", -o => $stracefile,
                   qw(-f -s 2000));
    } elsif (my $tty = delete $opts{-gdb}) {
        @debugprecmd = ("gdb", ( ($tty eq "1") ? () : ("-tty=$tty") ),
                        "--args");
        $timeout = 0;
    }
    if (delete $opts{-X}) { @dashX = qw(-X); }

    my @fullcmdline =
        (@debugprecmd,
         $self->_apache_bin, @dashX, -f => $self->_config_filename);
    if ($opts{-exec}) {
        exec(@fullcmdline) or
            throw App::CamelPKI::Error::OtherProcess("cannot exec() Apache",
                                                -cmdline => \@fullcmdline);
    } else {
        # Double fork(), so we don't have to bother with zombies :
        fork_and_do { fork_and_do {
            exec @fullcmdline;
        } };
    }

    if ($timeout) {
        $self->_wait_for(sub { $self->is_started })
            or throw App::CamelPKI::Error::OtherProcess("Cannot start Apache");
    } else {
        while(! $self->is_started) { sleep(1); }
    };
    return;
}

=head2 stop()

Stops the daemon synchronously, meaning that I<stop> will only return
control to its caller after ensuring that the Apache process whose PID
is in the PID file is terminated, and the TCP port is closed.  Like
L</start>, this method is idempotent and returns immediately if the
server was already down.

An exception of class L<App::CamelPKI::Error/App::CamelPKI::Error::OtherProcess>
will be thrown if the server still hasn't stopped after
L</async_timeout> seconds.

Note that the "started" or "stopped" state is persisted to the
filesystem using the usual UNIX PID file mechanism; therefore it is
not necessary to use the same Perl object (or even the same process)
to L</start> and I<stop()> a given server.

=cut

sub stop {
    my ($self) = @_;
    throw App::CamelPKI::Error::OtherProcess("Apache is wedged")
        if ($self->is_wedged);
    return # Not wedged and not started means stopped
        if ! defined(my $pid = $self->_process_ready);

    kill TERM => $pid;

    $self->_wait_for(sub { $self->is_stopped })
        or throw App::CamelPKI::Error::OtherProcess("Cannot stop Apache");
    return;
}

=head2 is_started()

Returns true iff the PID file currently contains the PID of a live
Apache process, B<and> one can connect to the TCP port.

=cut

sub is_started {
    my ($self) = @_;
    $self->_process_ready && $self->_port_ready;
}

=head2 is_stopped()

Returns true iff the PID file (if it exists at all) contains something
that is not the PID of a live Apache process, B<and> the TCP port is
closed.

=cut

sub is_stopped {
    my ($self) = @_;
    (! $self->_process_ready) && (! $self->_port_ready);
}

=head2 is_wedged()

Returns true iff neither L</is_stopped>, nor L</is_started> are true
(e.g. if the TCP port is taken, but not by us).  One cannot call
L</start> or L</stop> against an instance of I<App::CamelPKI::SysV::Apache>
that I<is_wedged()> (L<App::CamelPKI::Error/App::CamelPKI::Error::OtherProcess>
exceptions would be thrown).  More generally, neither can one call any
method that act upon other processes such as L</update_crl>.  The
systems administrator therefore needs to take manual corrective action
to get out of this state.

=cut

sub is_wedged {
    my ($self) = @_;
    $self->_process_ready xor $self->_port_ready;
}

=head2 is_current_interpreter()

Returns true iff the Perl interpreter we're currently running under is
a mod_perl belonging to this object's I<App::CamelPKI::SysV::Apache>
instance.

=cut

# UNIMPLEMENTED - In order to do this, we could do add a PerlSetVar
# with some UUID to the config file, and check that it is set below.
sub is_current_interpreter { undef }

=head2 is_running_under()

Returns true iff the Perl interpreter currently running is mod_perl.
Contrary to L</is_current_interpreter>, this method returns true even
if called from within B<another> Apache container; in other words it
doesn't look at $self, and indeed it can be called as a class method
too.

=cut

sub is_running_under {
    # We test not only that a version-discriminating subroutine
    # exists, but also that it is implemented in C (as an XSUB), hence
    # the magic with the B package. Thus we don't get fooled with
    # ::bootstrap getting defined as a side effect of Apache::DB
    # loading, or something.
    my @discriminating_symbols =
        (
         2 => "ModPerl::Util::exit",
         1.99 => "ModPerl::Const::compile",
         1 => "Apache::ModuleConfig::bootstrap",
        );
      require B;
      while(my ($version, $discrimsymbol) =
            splice(@discriminating_symbols, 0, 2)) {
          no strict "refs"; my $ref = \&{$discrimsymbol};
          next if (!defined $ref); # Does not seem to ever
          # happen in Perl, but you never know.
          my $bref = B::svref_2object($ref);
          next if (! defined $bref);
          next if (! $bref->XSUB());
          return $version;
      }
      return undef;
}

=head2 async_timeout()

=head2 async_timeout($timeout)

Gets or sets the maximum time (in seconds) that L</start> and L</stop>
will wait for the Apache server to come up (resp. down).  The default
value is 20 seconds; it does B<not> get persisted, and therefore must
be set by caller code after each L</load>.

=cut

sub async_timeout {
    my ($self, @set) = @_;
    ($self->{async_timeout}) = @set if (@set);
    return ( $self->{async_timeout} ||= 120);
}

=head2 tail_error_logfile()

Returns the amount of text that was appended to the error log file
since the object was created since the previous call to
I<tail_eror_logfile()> (or barring that, to L</load>).  Returns undef
if the log file does not exist (yet).

=cut

sub tail_error_logfile {
    my ($self) = @_;
    my $log = new IO::File($self->_error_log_filename);
    $self->{offset} = 0, return if (! defined $log);

    my $retval;
    if (defined wantarray) {
        $log->seek($self->{offset}, SEEK_CUR)
            or throw App::CamelPKI::Error::IO
                ("cannot seek", -IOfile => $self->_error_log_filename);
        $retval = join('', $log->getlines);
    } else { # Notamment à la construction
        $log->seek(0, SEEK_END)
            or throw App::CamelPKI::Error::IO
                ("cannot seek to end of file",
                 -IOfile => $self->_error_log_filename);
    }

    $self->{offset} = $log->tell();
    return $retval;
}

=head1 TODO

In B<App::CamelPKI::Apache>'s current implementation, only Apache 2 for
Ubuntu Edgy is supported.  However, the encapsulation of the class
makes it easy to support other environments, without changing anything
in the rest of Camel-PKI.

=begin internals

=head1 INTERNAL METHODS

=head2 _apache_bin()

Returns the path to Apache's binary.

=cut

sub _apache_bin { "/usr/sbin/apache2" }

=head2 _has_module_built_in($module_shortname)

Returns true if apache has $module_shortname (e.g. "mime") built-in.

=cut

use File::Slurp;
sub _has_module_built_in {
	my ($class, $modulename) = @_;
	my $bin = read_file($class->_apache_bin);
	if ($bin =~ m/mod_$modulename/) {
		return 1;
	}
	return 0;
}

=head2 _pid_filename()

Returns the path to this Apache daemon's PID file.

=cut

sub _pid_filename { catfile(shift->{homedir}, "apache.pid") }

=head2 _config_filename()

Returns the path to this daemon's configuration file (which is
automagically generated by L</_write_config_file>).

=cut

sub _config_filename { catfile(shift->{homedir}, "httpd-DO-NOT-EDIT.conf") }


=head2 _error_log_filename()

Returns the path to this Apache dameon's error log file.

=cut

sub _error_log_filename { catfile(shift->{homedir}, "error.log") }

=head2 _certificate_filename()

Returns the path to this Apache daemon's SSL server certificate file.

=cut

sub _certificate_filename { catfile(shift->{homedir}, "webserver.crt") }

=head2 _key_filename()

Returns the path to this Apache daemon's SSL private key.

=cut

sub _key_filename { catfile(shift->{homedir}, "webserver.key") }

=head2 _ca_bundle_filename()

Returns the path to the file that contains the concatenated PEM
certificates in the certification chain.

=cut

sub _ca_bundle_filename { catfile(shift->{homedir}, "ca-bundle.crt") }

=head2 _port_ready()

=head2 _port_ready($port)

Returns true iff TCP port $port is open on IPv4 localhost (127.0.0.1).
$port is L</https_port> by default.

Note that the check is performed by attempting to connect to the port
on 127.0.0.1, and closing the port right away.  A strange error log
message may ensue.

=cut

sub _port_ready {
    my ($self, $port) = @_;
    $port ||= $self->https_port;
    defined(IO::Socket::INET->new(PeerHost => "127.0.0.1",
                                  PeerPort => $port));
}

=head2 _process_ready()

Returns true (specifically, the PID of the parent Apache process) iff
the file pointed to by L</_pid_filename> contains the PID of a live
process whose name is "apache" (ou "apache2" ou "httpd", depending on
the distribution).

=cut

sub _process_ready {
    my ($self) = @_;
    return if ! -f $self->_pid_filename;
    chomp(my $pid = read_file($self->_pid_filename));
    my $cmdline = readlink("/proc/$pid/exe"); # No catfile() jazz,
    # /proc is not portable anyway
    return $pid if (defined($cmdline) && $cmdline eq $self->_apache_bin);
    return;
}

=head2 _wait_for($sub)

Waits until $sub, a CODE reference called without arguments in scalar
context, returns true; but waits no more than L</async_timeout>
seconds.  Returns whatever $sub returned the last time it was called.

=cut

sub _wait_for {
    my ($self, $sub) = @_;
    my $retval;
    for(1 .. $self->async_timeout) {
        last if $retval = $sub->();
        sleep(1);
    }
    return $retval;
}

=head2 _write_config_file()

Writes the configuration file into the path returned by
L</_config_file>.

=cut

sub _write_config_file {
    my ($self) = @_;

    # TODO: refrain from overwriting the file when it is more recent
    # than App/PKI/SysV/Apache.pm, so as to cut some slack to wise-guy
    # sysadmins.

    my $banner = <<'BANNER';
# Automatically generated Apache configuration file.
# Do not edit - Your changes will be lost.  Repeat:
#  ____                      _              _ _ _   _
# |  _ \  ___    _ __   ___ | |_    ___  __| (_) |_| |
# | | | |/ _ \  | '_ \ / _ \| __|  / _ \/ _` | | __| |
# | |_| | (_) | | | | | (_) | |_  |  __/ (_| | | |_|_|
# |____/ \___/  |_| |_|\___/ \__|  \___|\__,_|_|\__(_)
#
# See "perldoc App::CamelPKI::SysV::Apache".
BANNER

    my $homedir   = $self->{homedir};
    my $port      = $self->https_port;
    my $pidfile   = $self->_pid_filename;
    my $certfile  = $self->_certificate_filename;
    my $keyfile   = $self->_key_filename;
    my $ca_bundle = $self->_ca_bundle_filename;
    my $error_log = $self->_error_log_filename;

    # Propagate -Iblib/lib to the Apache server, so that it can load
    # Camel-PKI from the build directory if needed.
    my $perlswitches;
    if (my @blibs = grep { m/\bblib\W+lib\b/ } @INC) {
        my $perlincs = join(" ", map { "-I$_" } @blibs);
        $perlswitches = <<"PERLSWITCHES";
# As seen on http://www.catalystframework.org/calendar/2005/7:
PerlSwitches $perlincs
PERLSWITCHES
    }

    # FIXME: this is Gentoo- and  Edgy-specific.
    my %module_paths =
        (alias => "/usr/lib/apache2/modules/mod_alias.so",
         perl => "/usr/lib/apache2/modules/mod_perl.so",
         php5 => "/usr/lib/apache2/modules/libphp5.so",
         ssl  => "/usr/lib/apache2/modules/mod_ssl.so",
         mime  => "/usr/lib/apache2/modules/mod_mime.so",
        );
    my @mods_to_configure = grep { $self->_has_module_built_in($_) }
   		(keys %module_paths);
    push @mods_to_configure, qw(perl ssl); # Unconditionally needed

    my $phpstuff = "";
    if (defined(my $phpdir = $self->test_php_directory)) {
        push(@mods_to_configure, "php5", "alias", "mime");
        $phpstuff = <<"PHPSTUFF";
#### PHP test directory
Alias /t/php "$phpdir"
<location /t/php>
  AddType application/x-httpd-php .php
</location>
PHPSTUFF
    }

    my $modmime= "";
    if (grep { $_ eq "mime" } @mods_to_configure) {
    	$modmime = <<"MODMIME";
### Attic: mod_mime boilerplate configuration
# We basically don't use this, unfortunately both Ubuntu's
# and Gentoo's Apaches have default configurations that do
# not match the actual filesystem layout :-(
TypesConfig /etc/mime.types
MODMIME
    }

    my $loadmodules = join("", map { <<"LOADMODULE" }
LoadModule ${_}_module ${\$module_paths{$_}}
LOADMODULE
	    (grep { ! $self->_has_module_built_in("$_") }
             @mods_to_configure));

    my $catalyststuff = "";
    $catalyststuff = <<"CATALYST_STUFF" if $self->has_camel_pki();
#### Main application configuration
PerlModule App::CamelPKI
<LocationMatch "^(?!/t/)">
        SetHandler          modperl
        PerlResponseHandler App::CamelPKI
</LocationMatch>
CATALYST_STUFF

    write_file($self->_config_filename, <<"CONFIG");
$banner

$loadmodules

ServerName     Camel-PKI
ServerRoot     $homedir

Listen $port

#### Files
PidFile               $pidfile
SSLCertificateFile    $certfile
SSLCertificateKeyFile $keyfile
SSLCACertificateFile  $ca_bundle
ErrorLog              $error_log
LockFile              $homedir/httpd.lock
ScoreBoardFile        $homedir/httpd.scoreboard

#### SSL configuration
SSLEngine on
# Dissect SSL connection info into \$r->subprocess_env:
SSLOptions +StdEnvVars +ExportCertData
# Ask for a certificate every time, but do not barf if none is given:
SSLVerifyClient optional
# Work around some of the rampant OpenSSL braindamage:
SSLVerifyDepth 250
# Loads all digests into OpenSSL by side effect, thereby allowing mod_ssl
# to grok SHA-256 certificates (which it normally doesn't):
PerlModule Crypt::OpenSSL::CA

$phpstuff

$perlswitches
$catalyststuff

$modmime

CONFIG
}

=head2 _try_and_parse_config_file()

Retrieves persistent information from the configuration file
(e.g. port number and PHP directory) and populates this object's
fields accordingly.  Returns true if the parsing was successful.
Returns false if there was no configuration file to parse.  Throws an
exception in any other case.

=cut

sub _try_and_parse_config_file {
    my ($self) = @_;
    return if ! -f $self->_config_filename();
    my $configtext = read_file($self->_config_filename());
    (($self->{https_port}) = $configtext =~ m/Listen (\d+)/ )
        or throw App::CamelPKI::Error::State
            ("Configuration file was tampered with",
             -config_file => $self->_config_filename());
    ($self->{test_php_directory}) =
        $configtext =~ m|Alias /t/php "(.*)"|; # Optional
    $self->{has_camel_pki} =
        ($configtext =~ m/PerlModule App::CamelPKI/) ? 1 : 0;
    return 1;
}

require My::Tests::Below unless caller;
1;

__END__

=head1 TEST SUITE

=cut

use Test::More no_plan => 1;
use Test::Group;
use Fatal qw(mkdir);
use File::Slurp;
use File::Spec::Functions qw(catdir catfile);
use LWP::Simple ();
use Crypt::OpenSSL::CA;
use App::CamelPKI::Error;
use App::CamelPKI::Time;
use App::CamelPKI::Certificate;
use App::CamelPKI::PrivateKey;
use App::CamelPKI::CRL;
use App::CamelPKI::Sys qw(fork_and_do);
use App::CamelPKI::Test qw(%test_entity_certs %test_keys_plaintext
                      %test_rootca_certs);

=head2 Prologue

According to L</SYNOPSIS>, the Apache server needs a home directory, a
certificate, a private key, a certification chain and a CRL.

=cut

my $rootcacert = App::CamelPKI::Certificate->parse
    ($test_rootca_certs{rsa1024});
my $rootcakey = App::CamelPKI::PrivateKey->parse
    ($test_keys_plaintext{rsa1024});
my $crl = new Crypt::OpenSSL::CA::X509_CRL();
$crl->set_issuer_DN($rootcacert->get_subject_DN);
$crl->set_extension("crlNumber", "0x01", -critical => 1);
my $now = App::CamelPKI::Time->now;
$crl->set_lastUpdate($now->advance_days(-1));
$crl->set_nextUpdate($now->advance_days(+1));
$crl = App::CamelPKI::CRL->parse
    ($crl->sign($rootcakey->as_crypt_openssl_ca_privatekey, "sha256"));
ok(! $crl->is_member($rootcacert), "test CRL OK");

=head3 fresh_directory()

Returns a fresh, empty directory for tests.

=cut

{
    my $unique = 0;
    sub fresh_directory {
        mkdir(my $retval = catdir(My::Tests::Below->tempdir(),
                                  "apache" . $unique++));
        return $retval;
    }
}

=head3 make_apache_operational($in_directory)

Instruments $in_directory for use as an Apache home directory, that
is, the C<$directory> parameter to L</load>.  The various
cryptographic materials and configuration files will be set up if they
aren't already, and a new, unused port will be allocated.  Returns
$in_directory.

=cut

{
    my $freshport = 12346;
    sub make_apache_operational {
        my ($directory) = @_;
        mkdir($directory) unless -d $directory;

        my $apache = load App::CamelPKI::SysV::Apache($directory);
        return if $apache->is_operational;

        $apache->set_keys
            (-certificate => App::CamelPKI::Certificate->parse
             ($test_entity_certs{rsa1024}),
             -key => App::CamelPKI::PrivateKey->parse
             ($test_keys_plaintext{rsa1024}),
             -certification_chain => [ $rootcacert ]);
        $apache->https_port($freshport++);

        return $directory;
    }
}

=pod

=head2 Tests proper

=cut

my $directory = fresh_directory;
END { App::CamelPKI::SysV::Apache->load($directory)->stop()
    if defined $directory; }

test "Quiet state" => sub {
    my $apache = load App::CamelPKI::SysV::Apache($directory);
    is($apache->https_port, 3443, "We probably aren't root");
    ok($apache->has_camel_pki);
    is($apache->test_php_directory, undef);
    ok(! $apache->is_started);
    ok($apache->is_stopped);
    ok(! $apache->is_operational);
    ok(! $apache->is_wedged);
};

test "synopsis" => sub {
    App::CamelPKI::SysV::Apache->load($directory)->stop();

    my $cert = App::CamelPKI::Certificate->parse($test_entity_certs{rsa1024});
    my $key = App::CamelPKI::PrivateKey->parse($test_keys_plaintext{rsa1024});
    my $opcacert = $rootcacert; # Heh.
    my $code = My::Tests::Below->pod_code_snippet("synopsis");
    ok($code =~ s/\bmy /our /g);
    ok($code =~ s/443/12345/g); # Since we are not root
    eval "package Synopsis; $code"; die $@ if $@;
    is($Synopsis::apache->https_port, 12345, "port number was remembered");
    ok($Synopsis::apache->is_operational);
};

make_apache_operational($directory);

test "->start() and ->stop() idempotent" => sub {
    App::CamelPKI::SysV::Apache->load($directory)->stop();

    my $apache = load App::CamelPKI::SysV::Apache($directory);
    ok(! $apache->is_started());
    $apache->start();
    ok($apache->is_started());
    $apache->start();
    ok($apache->is_started(), "->start() idempotent");
    $apache->stop();
    ok(! $apache->is_started());
    $apache->stop();
    ok(! $apache->is_started(), "->stop() idempotent");
};

test "IPC and persistence" => sub {
    App::CamelPKI::SysV::Apache->load($directory)->stop();

    my $apache = load App::CamelPKI::SysV::Apache($directory);
    is($apache->https_port, 12345, "port number was persisted");
    ok($apache->is_operational, "key material was persisted");

    ok(! $apache->is_started);
    waitpid(fork_and_do {
        my $apache = load App::CamelPKI::SysV::Apache($directory);
        $apache->start();
    }, 0);
    ok($apache->is_started, "started/stopped status was persisted");
    $apache->stop();
    ok(! $apache->is_started, "can stop from another process than "
       . "the one that started");
};

test "->tail_error_logfile" => sub {
    App::CamelPKI::SysV::Apache->load($directory)->stop();

    my $apache = load App::CamelPKI::SysV::Apache($directory);
    is($apache->tail_error_logfile(), "");
    $apache->start();
    like($apache->tail_error_logfile(), qr/resuming normal operations/i);
    is($apache->tail_error_logfile(), "");
};

test "->is_wedged()" => sub {
    App::CamelPKI::SysV::Apache->load($directory)->stop();

    my $apache = load App::CamelPKI::SysV::Apache($directory);
    ok(! $apache->is_wedged());
    $apache->start();
    ok(! $apache->is_wedged());
    chomp(my $pid = read_file($apache->_pid_filename()));
    like($pid, qr/^\d+$/);
    unlink($apache->_pid_filename());
    ok($apache->is_wedged());

    ok(kill(TERM => $pid), "manual corrective action");
    ok($apache->_wait_for(sub { ! $apache->is_wedged }), "unwedged");
};

test "->is_running_under returns false in a normal Perl" => sub {
    ok(! App::CamelPKI::SysV::Apache->is_running_under);
};


test "App::CamelPKI service" => sub {
    my $webserver = App::CamelPKI::SysV::Apache->load($directory);
    $webserver->start();
    my $ca = LWP::Simple::get("https://localhost:12345/ca/certificate_pem");
    like($ca, qr/BEGIN CERTIFICATE/)
        or warn $webserver->tail_error_logfile;
};

mkdir(my $phpdir = catdir(My::Tests::Below->tempdir, "php"));

test "PHP pages in t/php" => sub {
    my $webserver = App::CamelPKI::SysV::Apache->load($directory);
    is($webserver->test_php_directory, undef);
    $webserver->test_php_directory($phpdir);
    $webserver->stop(); $webserver->start();
    $webserver = App::CamelPKI::SysV::Apache->load($directory);
    is($webserver->test_php_directory, $phpdir,
       "test_php_directory persistent");
    write_file(catfile($phpdir, "phpinfo.php"), <<"PHPINFO");
<?php
phpinfo();
?>
PHPINFO
    my $phpinfo = LWP::Simple::get
        ("https://localhost:12345/t/php/phpinfo.php");
    like($phpinfo, qr/www\.php\.net/);
};

use IO::Socket::SSL;
use LWP::UserAgent;
use App::CamelPKI::Test qw(http_request_prepare http_request_execute);
test "SSL client w/ certificate" => sub {
    my $webserver = App::CamelPKI::SysV::Apache->load($directory);
    unless ($webserver->test_php_directory) {
        $webserver->test_php_directory($phpdir);
        $webserver->stop();
    }
    $webserver->start();

    write_file(catfile($phpdir, "ssl_vars.php"), <<'PHP_SSL_VARS');
$_SERVER["HTTPS"]             = <?php print $_SERVER["HTTPS"] ?>

$_SERVER["SSL_CLIENT_VERIFY"] = <?php print $_SERVER["SSL_CLIENT_VERIFY"] ?>

$_SERVER["SSL_CLIENT_S_DN"]   = <?php print $_SERVER["SSL_CLIENT_S_DN"] ?>

PHP_SSL_VARS

    my $req = http_request_prepare
        ('https://localhost:12345/t/php/ssl_vars.php');
    my $response = http_request_execute($req);
    die $response->content unless $response->is_success;
    like($response->content, qr/HTTPS.* = on/);
    like($response->content, qr/SSL_CLIENT_VERIFY.* = NONE/);

    my %opts = (-certificate => $test_entity_certs{"rsa1024"},
                -key => $test_keys_plaintext{"rsa1024"});
    $req = http_request_prepare
        ('https://localhost:12345/t/php/ssl_vars.php', %opts);
    $response = http_request_execute($req, %opts);
    die $response->content unless $response->is_success;
    like($response->content, qr/HTTPS.* = on/);
    like($response->content, qr/SSL_CLIENT_VERIFY.* = SUCCESS/);
    like($response->content, qr/SSL_CLIENT_S_DN.* = .*CN=John Doe/);
};

use App::CamelPKI::Test qw(certificate_chain_ok);
use App::CamelPKI::CertTemplate;

=head2 SHA-256 authentication failure regression suite

Trying to authenticate to an I<App::CamelPKI::SysV::Apache> instance using
SHA256 client certificates used to elicit a cryptic error message.
This is because mod_ssl only knows about the hash algorithms from the
TLsv1 suite out of the box (and SHA256 is not one of these).  The
following two tests exercise that.

The current solution is to add a "PerlModule Crypt::OpenSSL::CA" that
calls C<OpenSSL_add_all_digests()> as a side effect, but we need to
find a better way lest every server in Camel-PKI have to contain a
mod_perl just for that.

=cut

sub make_bogus_keypair_using_hash {
    my ($hash, $admincertfile, $adminkeyfile) = @_;

    write_file($adminkeyfile, $test_keys_plaintext{"rsa1024"});
    my $qualifier = ($hash =~ m/sha.*256/i) ? "_sha256" : "";
    write_file($admincertfile, $test_entity_certs{"rsa1024$qualifier"});
}

sub ok_connect_no_hiccups {
    my ($webserver, $admincertfile, $adminkeyfile) = @_;
    $webserver->tail_error_logfile;
    local @LWP::Protocol::http::EXTRA_SOCK_OPTS;
    @LWP::Protocol::http::EXTRA_SOCK_OPTS =
        (SSL_use_cert => 1,
         SSL_cert_file => $admincertfile, SSL_key_file => $adminkeyfile);
    my $ua = new LWP::UserAgent;
    my $port = $webserver->https_port;
    my $response = $ua->get("https://localhost:$port/no/such/uri");
    is($response->code, 404, "500 would be bad") or
        diag $response->content;
    unlike($webserver->tail_error_logfile,
           qr/certificate signature failure/, <<"EXPLANATION");
``certificate signature failure'' is the message one gets when mod_ssl
attempts to validate a certificate whose hash algorithm it doesn't
know about.
EXPLANATION
}

my $sha256directory = fresh_directory;
END { App::CamelPKI::SysV::Apache->load($sha256directory)->stop()
    if defined $sha256directory; }

test "witness experiment: authenticating with hand-made".
    " sha1 client certificates" => sub {
    make_apache_operational($sha256directory);
    my $webserver = App::CamelPKI::SysV::Apache->load($sha256directory);
    $webserver->has_camel_pki(0);
    $webserver->start();

    mkdir(my $keysdir = catdir(My::Tests::Below->tempdir, "sha1keys"));
    my $admincertfile = catfile($keysdir, "admin.pem");
    my $adminkeyfile = catfile($keysdir, "admin.key");
    make_bogus_keypair_using_hash("sha1", $admincertfile, $adminkeyfile);
    ok_connect_no_hiccups($webserver, $admincertfile, $adminkeyfile);
};

test "REGRESSION: authenticating with sha256 client certificates" => sub {
    make_apache_operational($sha256directory);
    my $webserver = App::CamelPKI::SysV::Apache->load($sha256directory);
    $webserver->has_camel_pki(0);
    $webserver->start();

    mkdir(my $keysdir = catdir(My::Tests::Below->tempdir, "sha256keys"));
    my $admincertfile = catfile($keysdir, "admin.pem");
    my $adminkeyfile = catfile($keysdir, "admin.key");
    make_bogus_keypair_using_hash("sha256", $admincertfile, $adminkeyfile);
    ok_connect_no_hiccups($webserver, $admincertfile, $adminkeyfile);
};

=end internals

=cut
