#! /usr/bin/perl

use strict;
use warnings;
use File::Basename qw(dirname);
use File::Temp qw(tempfile);
use Getopt::Long;
use IO::Socket::UNIX;
use Net::FastCGI;
use Net::FastCGI::Constant qw(:common :type :flag :role :protocol_status);
use Net::FastCGI::IO qw(:all);
use Net::FastCGI::Protocol qw(:all);
use POSIX qw(:sys_wait_h getcwd);
use Socket qw(SOMAXCONN SOCK_STREAM);

my $master_pid = $$;
my %child_procs;
my $base_dir = getcwd;
my $pass_authz;
my $verbose = 0;

$SIG{CHLD} = sub {};
$SIG{HUP} = sub {};
$SIG{TERM} = sub {
    if ($$ == $master_pid) {
        kill "TERM", $_
            for sort keys %child_procs;
    }
    exit 0;
};

chdir "/"
    or die "failed to chdir to /:$!";
main();

sub verbose_print {
    return unless $verbose;
    print STDERR "fastcgi-cgi:$$:@_\n";
}

sub main {
    my $sockfn;
    my $max_workers = "inf" + 1;

    GetOptions(
        "listen=s"      => \$sockfn,
        "max-workers=i" => \$max_workers,
        "pass-authz"    => \$pass_authz,
        "verbose"       => sub { ++$verbose },
        "help"          => sub {
            print_help();
            exit 0;
        },
    ) or exit 1;

    my $listen_sock;
    if (defined $sockfn) {
        unlink $sockfn;
        $listen_sock = IO::Socket::UNIX->new(
            Listen => SOMAXCONN,
            Local  => $sockfn,
            Type   => SOCK_STREAM,
        ) or die "failed to create unix socket at $sockfn:$!";
    } else {
        die "stdin is no a socket"
            unless -S STDIN;
        $listen_sock = IO::Socket::UNIX->new;
        $listen_sock->fdopen(fileno(STDIN), "w")
            or die "failed to open unix socket:$!";
    }
    verbose_print("accepting connections");

    while (1) {
        my $wait_opt = 0;
        if (keys %child_procs < $max_workers) {
            if (my $sock = $listen_sock->accept) {
                verbose_print("accepted new connection");
                my $pid = fork;
                die "fork failed:$!"
                    unless defined $pid;
                if ($pid == 0) {
                    close $listen_sock;
                    handle_connection($sock);
                    exit 0;
                }
                $sock->close;
                $child_procs{$pid} = 1;
            }
            $wait_opt = WNOHANG;
        } else {
            verbose_print("reached max-workers");
            $wait_opt = 0;
        }
        my $kid = waitpid(-1, $wait_opt);
        if ($kid > 0) {
            verbose_print("collected pid $kid");
            delete $child_procs{$kid};
        }
    }
}

sub handle_connection {
    my $sock = shift;
    my ($type, $req_id, $content);
    my $cur_req_id;
    my $params = "";
    my $input_fh;

    verbose_print("handling new request");

    # wait for FCGI_BEGIN_REQUEST
    ($type, $req_id, $content) = fetch_record($sock);
    die "expected FCGI_BEGIN_REQUEST, but got $type"
        unless $type == FCGI_BEGIN_REQUEST;
    my ($role, $flags) = parse_begin_request_body($content);
    die "unexpected role:$role"
        unless $role == FCGI_RESPONDER;
    $cur_req_id = $req_id;
    verbose_print("received FCGI_BEGIN_REQUEST");

    # accumulate FCGI_PARAMS
    while (1) {
        ($type, $req_id, $content) = fetch_record($sock);
        last if $type != FCGI_PARAMS;
        die "unexpected request id"
            if $cur_req_id != $req_id;
        $params .= $content;
    }
    my $env = parse_params($params);
    die "SCRIPT_FILENAME not defined"
        unless $env->{SCRIPT_FILENAME};
    $env->{SCRIPT_FILENAME} = "$base_dir/$env->{SCRIPT_FILENAME}"
        if $env->{SCRIPT_FILENAME} !~ m{^/};
    delete $env->{HTTP_AUTHORIZATION}
        unless $pass_authz;
    verbose_print("received FCGI_PARAMS");

    # accumulate FCGI_STDIN
    while (1) {
        die "received unexpected record: $type"
            if $type != FCGI_STDIN;
        die "unexpected request id"
            if $cur_req_id != $req_id;
        last if length $content == 0;
        if (!$input_fh) {
            $input_fh = tempfile()
                or die "failed to create temporary file:$!";
        }
        print $input_fh $content;
        ($type, $req_id, $content) = fetch_record($sock);
    }
    if ($input_fh) {
        flush $input_fh;
        seek $input_fh, 0, 0
            or die "seek failed:$!";
    } else {
        open $input_fh, "<", "/dev/null"
            or die "failed to open /dev/null:$!";
    }
    verbose_print("received FCGI_STDIN");

    # create pipes for stdout and stderr
    pipe(my $stdout_rfh, my $stdout_wfh)
        or die "pipe failed:$!";
    pipe(my $stderr_rfh, my $stderr_wfh)
        or die "pipe failed:$!";

    # fork the CGI application
    verbose_print("spawning $env->{SCRIPT_FILENAME}");
    my $pid = fork;
    die "fork failed:$!"
        unless defined $pid;
    if ($pid == 0) {
        close $sock;
        close $stdout_rfh;
        close $stderr_rfh;
        open STDERR, ">&", $stderr_wfh
            or die "failed to dup STDERR";
        open STDIN, "<&", $input_fh
            or die "failed to dup STDIN";
        open STDOUT, ">&", $stdout_wfh
            or die "failed to dup STDOUT";
        close $stderr_wfh;
        close $input_fh;
        close $stdout_wfh;
        $ENV{$_} = $env->{$_}
            for sort keys %$env;
        chdir dirname($env->{SCRIPT_FILENAME});
        exec $env->{SCRIPT_FILENAME};
        die "failed to spawn $env->{SCRIPT_FILENAME}:$!";
    }
    close $stdout_wfh;
    close $stderr_wfh;

    verbose_print("waiting for response");

    # send response
    while ($stdout_rfh || $stderr_rfh) {
        my $rin = '';
        vec($rin, fileno $stdout_rfh, 1) = 1
            if $stdout_rfh;
        vec($rin, fileno $stderr_rfh, 1) = 1
            if $stderr_rfh;
        vec($rin, fileno $sock, 1) = 1;
        if (select($rin, undef, undef, undef) <= 0) {
            next;
        }
        if ($stdout_rfh && vec($rin, fileno $stdout_rfh, 1)) {
            verbose_print("forwarding STDOUT");
            transfer($sock, FCGI_STDOUT, $cur_req_id, $stdout_rfh)
                or undef $stdout_rfh;
        }
        if ($stderr_rfh && vec($rin, fileno $stderr_rfh, 1)) {
            verbose_print("forwarding STDERR");
            transfer($sock, FCGI_STDERR, $cur_req_id, $stderr_rfh)
                or undef $stderr_rfh;
        }
        if (vec($rin, fileno $sock, 1)) {
            # atually means that the client has closed the connection, terminate the CGI process the same way apache does
            verbose_print("client has closed connection; killing myself in 3 seconds");
            kill 'TERM', $pid;
            $SIG{ALRM} = sub {
                kill 'KILL', $pid;
            };
            alarm 3;
            last;
        }
    }

    verbose_print("response complete");

    # close (closing without sending FCGI_END_REQUEST indicates to the client that the connection is not persistent)
    close $sock;

    # wait for child process to die
    verbose_print("waiting for CGI process to exit");
    while (waitpid($pid, 0) != $pid) {
    }
    verbose_print("exitting");
}

sub fetch_record {
    my $sock = shift;
    my ($type, $req_id, $content) = read_record($sock)
      or die "failed to read FCGI record:$!";
    die "unexpected request id:null"
        if $req_id == FCGI_NULL_REQUEST_ID;
    ($type, $req_id, $content);
}

sub transfer {
    my ($sock, $type, $req_id, $fh) = @_;
    my $buf;

    while (1) {
        my $ret = sysread $fh, $buf, 61440;
        next if (!defined $ret) && $! == Errno::EINTR;
        $buf = "" unless $ret; # send zero-length record to indicate EOS
        last;
    }
    write_record($sock, $type, $req_id, $buf)
        or die "failed to write FCGI response:$!";
    return length $buf;
}

sub print_help {
    # do not use Pod::Usage, since we are fatpacking this script
    print << "EOT";
Usage:
    $0 [options]

Options:
  --listen=sockfn    path to the UNIX socket.  If specified, the program will
                     create a UNIX socket at given path replacing the existing
                     file (should it exist).  If not, file descriptor zero (0)
                     will be used as the UNIX socket for accepting new
                     connections.
  --max-workers=nnn  maximum number of CGI processes (default: unlimited)
  --pass-authz       if set, preserves HTTP_AUTHORIZATION parameter
  --verbose          verbose mode

EOT
}
