FU: Add FastCGI support + bunch of fixes
I initially planned to only implement the bare minimum to support FastCGI under nginx, but ended up implementing the full protocol instead. This is more code than I had expected and the code is also less trivial than I had hoped. Will need to do more testing, pretty sure there's bugs left. Also TODO: test under alternative process managers + document FU_LISTEN_PROTO. I've also removed the max_request_body setting, this is something that really ought to be configured in the web server instead.
This commit is contained in:
parent
3e84a4f4d3
commit
d9fba4e8d8
4 changed files with 784 additions and 79 deletions
232
FU.pm
232
FU.pm
|
|
@ -25,7 +25,6 @@ sub fu() { $fu }
|
|||
sub debug { state $v = 0; $v = $_[0] if @_; $v }
|
||||
sub log_slow_pages { state $v = 0; $v = $_[0] if @_; $v }
|
||||
sub log_queries { state $v = 0; $v = $_[0] if @_; $v }
|
||||
sub max_request_body { state $v = 10*1024*1024; $v = $_[0] if @_; $v }
|
||||
|
||||
sub mime_types() { state $v = {qw{
|
||||
7z application/x-7z-compressed
|
||||
|
|
@ -136,18 +135,7 @@ my %onerr = (
|
|||
Congratulations!</small>
|
||||
_
|
||||
},
|
||||
413 => sub {
|
||||
fu->_error_page(413, '413 - Request Entity Too Large', <<~_);
|
||||
That's an odd way of saying that you were probably trying to upload a large
|
||||
file. Too large, in fact, for the server to handle. If you believe this
|
||||
error to be mistaken, you can ask the site admin to increase the maximum
|
||||
allowed upload size.
|
||||
_
|
||||
},
|
||||
500 => \&_err_500,
|
||||
'*' => sub($code, @) {
|
||||
fu->_error_page($code, "$code - Unknown error", 'Welp, something went wrong processing your request.');
|
||||
},
|
||||
);
|
||||
sub on_error :prototype($&) { $onerr{$_[0]} = $_[1] }
|
||||
|
||||
|
|
@ -176,25 +164,25 @@ sub _monitor {
|
|||
|
||||
|
||||
sub _decode_utf8 {
|
||||
return if !defined $_[0];
|
||||
fu->error(400, 'Invalid UTF-8 in request') if !utf8::decode($_[0]);
|
||||
# Disallow any control codes, except for x09 (tab), x0a (newline) and x0d (carriage return)
|
||||
fu->error(400, 'Invalid control character in request') if $_[0] =~ /[\x00-\x08\x0b\x0c\x0e-\x1f]/;
|
||||
}
|
||||
|
||||
our $hdrname_re = qr/[!#\$\%&'\*\+-\.^_`\|~0-9a-zA-Z]+/;
|
||||
our $hdrname_re = qr/[!#\$\%&'\*\+-\.^_`\|~0-9a-zA-Z]{1,127}/;
|
||||
our $method_re = qr/(?:GET|POST|DELETE|OPTIONS|PUT|PATCH|QUERY)/;
|
||||
|
||||
# rfc7230 used as reference, though strict conformance is not a goal.
|
||||
# Does not limit size of headers, so not suitable for deployment in untrusted networks.
|
||||
sub _http_read_request($sock, $req) {
|
||||
sub _read_req_http($sock, $req) {
|
||||
local $/ = "\r\n";
|
||||
my $line = $sock->getline;
|
||||
fu->error(400, 'Client disconnect before request was read') if !defined $line;
|
||||
fu->error(400, 'Invalid request') if $line !~ /^(GET|POST|DELETE|OPTIONS|PUT|PATCH|QUERY)\s+(\S+)\s+HTTP\/1\.[01]\r\n$/;
|
||||
fu->error(400, 'Invalid request') if $line !~ /^($method_re)\s+(\S+)\s+HTTP\/1\.[01]\r\n$/;
|
||||
$req->{method} = $1;
|
||||
($req->{path}, $req->{qs}) = split /\?/, $2 =~ s{^https?://[^/]+/}{/}r, 2;
|
||||
$req->{path} =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg;
|
||||
_decode_utf8 $req->{path};
|
||||
_decode_utf8 $req->{qs} if defined $req->{qs};
|
||||
|
||||
while (1) {
|
||||
# Turns out header line folding has been officially deprecated, so I'm
|
||||
|
|
@ -204,7 +192,6 @@ sub _http_read_request($sock, $req) {
|
|||
last if $line eq "\r\n";
|
||||
fu->error(400, 'Invalid request header syntax') if $line !~ /^($hdrname_re):\s*(.+)\r\n$/;
|
||||
my($hdr, $val) = (lc $1, $2 =~ s/\s*$//r);
|
||||
_decode_utf8 $val;
|
||||
if (exists $req->{hdr}{$hdr}) {
|
||||
$req->{hdr}{$hdr} .= ($hdr eq 'cookie' ? '; ' : ', ') . $val;
|
||||
} else {
|
||||
|
|
@ -215,7 +202,6 @@ sub _http_read_request($sock, $req) {
|
|||
fu->error(400, 'Unexpected Transfer-Encoding request header') if $req->{hdr}{'transfer-encoding'};
|
||||
my $len = $req->{hdr}{'content-length'} // 0;
|
||||
fu->error(400, 'Invalid Content-Length request header') if $len !~ /^(?:0|[1-9][0-9]*)$/;
|
||||
fu->error(413, 'Request body too large') if $len > max_request_body;
|
||||
|
||||
$req->{body} = '';
|
||||
while ($len > 0) {
|
||||
|
|
@ -225,6 +211,34 @@ sub _http_read_request($sock, $req) {
|
|||
}
|
||||
|
||||
|
||||
sub _read_req($c) {
|
||||
if ($c->{fcgi_obj}) {
|
||||
my $r = $c->{fcgi_obj}->read_req($REQ->{hdr}, $REQ);
|
||||
# Only FUFE_ABORT is an error we can recover from, in all other
|
||||
# cases we have not properly consumed the request from the socket
|
||||
# so we'll leave the protocol in an invalid state in case we do
|
||||
# attempt to respond.
|
||||
# All other errors suggest a misconfigured web server, anyway.
|
||||
if ($r == -6) { fu->error(400, 'Client disconnect before request was read') }
|
||||
elsif ($r) {
|
||||
warn $r == -1 ? "Unexpected EOF while reading from FastCGI socket\n"
|
||||
: $r == -2 ? "I/O error while reading from FastCGI socket\n"
|
||||
: $r == -3 ? "FastCGI protocol error\n"
|
||||
: $r == -4 ? "Too long FastCGI parameter\n"
|
||||
: $r == -5 ? "Too long request body\n" : undef;
|
||||
delete $c->{fcgi_obj};
|
||||
fu->error(-1);
|
||||
}
|
||||
fu->error(400, 'Invalid request') if !$REQ->{method} || $REQ->{method} !~ /^$method_re$/ || !$REQ->{path};
|
||||
} else {
|
||||
_read_req_http($c->{client_sock}, $REQ);
|
||||
}
|
||||
|
||||
# The HTTP reader above and the FastCGI XS reader operate on bytes.
|
||||
# Decode these into Unicode strings and check for special characters.
|
||||
_decode_utf8 $_ for ($REQ->{path}, $REQ->{qs}, values $REQ->{hdr}->%*);
|
||||
}
|
||||
|
||||
|
||||
sub _is_done($e) { ref $@ eq 'FU::err' && $@->[0] == 200 }
|
||||
|
||||
|
|
@ -235,20 +249,14 @@ sub _log_err($e) {
|
|||
}
|
||||
|
||||
sub _do_req($c) {
|
||||
if ($c->{monitor} && _monitor) {
|
||||
warn "File change detected, restarting process.\n" if debug;
|
||||
FU::Util::fdpass_send(fileno($c->{supervisor_sock}), fileno($c->{client_sock}), 'f0000');
|
||||
exit;
|
||||
}
|
||||
|
||||
local $REQ = {};
|
||||
local $REQ = { hdr => {} };
|
||||
local $fu = bless {}, 'FU::obj';
|
||||
|
||||
$REQ->{ip} = $c->{client_sock} isa 'IO::Socket::INET' ? $c->{client_sock}->peerhost : '127.0.0.1';
|
||||
fu->reset;
|
||||
|
||||
my $ok = eval {
|
||||
_http_read_request($c->{client_sock}, $REQ);
|
||||
_read_req $c;
|
||||
|
||||
for my $h (@before_request) { $h->() }
|
||||
|
||||
|
|
@ -266,6 +274,7 @@ sub _do_req($c) {
|
|||
}
|
||||
1;
|
||||
};
|
||||
return if !$ok && ref $@ eq 'FU::err' && $@->[0] == -1;
|
||||
my $err = $ok || _is_done($@) ? undef : $@;
|
||||
_log_err $err;
|
||||
|
||||
|
|
@ -283,24 +292,51 @@ sub _do_req($c) {
|
|||
|
||||
if ($err) {
|
||||
fu->reset;
|
||||
my($code, $msg) = ref $@ eq 'FU::err' ? $@->@* : (500, $err);
|
||||
my($code, $msg) = ref $err eq 'FU::err' ? $err->@* : (500, $err);
|
||||
eval {
|
||||
($onerr{$code} || $onerr{'*'})->($code, $msg);
|
||||
($onerr{$code} || $onerr{500})->($code, $msg);
|
||||
1;
|
||||
} || _err_500();
|
||||
}
|
||||
|
||||
fu->_flush($c->{client_sock});
|
||||
$c->{client_sock}->close;
|
||||
|
||||
exit if $c->{max_reqs} && !--$c->{max_reqs};
|
||||
fu->_flush($c->{fcgi_obj} || $c->{client_sock});
|
||||
}
|
||||
|
||||
|
||||
sub _run_loop($c) {
|
||||
my $stop = 0;
|
||||
local $SIG{HUP} = 'IGNORE';
|
||||
local $SIG{TERM} = $SIG{INT} = sub { $stop = 1 };
|
||||
|
||||
my sub passclient {
|
||||
FU::Util::fdpass_send(fileno($c->{supervisor_sock}), fileno($c->{client_sock}), 'f0000')
|
||||
if $c->{supervisor_sock} && $c->{client_sock};
|
||||
exit;
|
||||
}
|
||||
|
||||
while (!$stop) {
|
||||
$c->{client_sock} ||= $c->{listen_sock}->accept || next;
|
||||
$c->{fcgi_obj} ||= $c->{listen_proto} eq 'fcgi' && FU::fcgi::new(fileno $c->{client_sock}, $c->{proc});
|
||||
|
||||
if ($c->{monitor} && _monitor) {
|
||||
warn "File change detected, restarting process.\n" if debug;
|
||||
passclient;
|
||||
}
|
||||
|
||||
_do_req $c;
|
||||
|
||||
$c->{client_sock} = $c->{fcgi_obj} = undef if !($c->{fcgi_obj} && $c->{fcgi_obj}->keepalive);
|
||||
|
||||
passclient if $c->{max_reqs} && !--$c->{max_reqs};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
sub _supervisor($c) {
|
||||
my ($rsock, $wsock) = IO::Socket->socketpair(IO::Socket::AF_UNIX(), IO::Socket::SOCK_STREAM(), IO::Socket::PF_UNSPEC());
|
||||
|
||||
my %childs; # pid => 1: spawned, 2: signalled ready
|
||||
$SIG{CHLD} = sub { $wsock->syswrite('c0000',1) };
|
||||
$SIG{CHLD} = sub { $wsock->syswrite('c0000',5) };
|
||||
$SIG{HUP} = $SIG{TERM} = $SIG{INT} = sub($sig,@) {
|
||||
kill 'TERM', keys %childs;
|
||||
return if $sig eq 'HUP';
|
||||
|
|
@ -314,13 +350,16 @@ sub _supervisor($c) {
|
|||
fcntl $wsock, Fcntl::F_SETFD(), 0;
|
||||
|
||||
$ENV{FU_MONITOR} = $c->{monitor};
|
||||
$ENV{FU_PROC} = $c->{proc};
|
||||
$ENV{FU_MAX_REQS} = $c->{max_reqs};
|
||||
$ENV{FU_DEBUG} = debug;
|
||||
$ENV{FU_SUPERVISOR_FD} = fileno $wsock;
|
||||
$ENV{FU_LISTEN_FD} = fileno $c->{listen_sock};
|
||||
$ENV{FU_LISTEN_PROTO} = $c->{listen_proto};
|
||||
|
||||
my $err = 0;
|
||||
my @client_fd;
|
||||
my $msg = '';
|
||||
while (1) {
|
||||
while ((my $pid = waitpid(-1, POSIX::WNOHANG())) > 0) {
|
||||
$err = 1 if POSIX::WIFEXITED($?) && POSIX::WEXITSTATUS($?) != 0;
|
||||
|
|
@ -344,9 +383,9 @@ sub _supervisor($c) {
|
|||
} elsif ($err) {
|
||||
# In error state, wait with loading the script until we've received a request.
|
||||
# Otherwise we'll end up in an infinite spawning loop if the script doesn't start properly.
|
||||
my $sock = $c->{listen_sock}->accept() or die $!;
|
||||
fcntl $sock, Fcntl::F_SETFD, 0 if $sock;
|
||||
$ENV{FU_CLIENT_FD} = fileno $sock;
|
||||
$client = $c->{listen_sock}->accept() or die $!;
|
||||
fcntl $client, Fcntl::F_SETFD, 0;
|
||||
$ENV{FU_CLIENT_FD} = fileno $client;
|
||||
}
|
||||
exec $^X, (map "-I$_", @INC), $0;
|
||||
exit 1;
|
||||
|
|
@ -355,21 +394,24 @@ sub _supervisor($c) {
|
|||
$childs{$pid} = 1;
|
||||
}
|
||||
|
||||
# Assumption: we never get short reads.
|
||||
my ($fd, $msg) = FU::Util::fdpass_recv(fileno($rsock), 5);
|
||||
my ($fd, $msgadd) = FU::Util::fdpass_recv(fileno($rsock), 500);
|
||||
push @client_fd, $fd if $fd;
|
||||
next if !$msg;
|
||||
next if $msg eq 'c0000'; # child died
|
||||
next if $msg eq 'f0000'; # child is about to exit and passed a client fd to us
|
||||
|
||||
if ($msg =~ /^r/) { # child ready
|
||||
my $pid = unpack 'V', substr $msg, 1;
|
||||
$childs{$pid} = 2 if $childs{$pid};
|
||||
$err = 0;
|
||||
next if !defined $msgadd;
|
||||
$msg .= $msgadd;
|
||||
while ($msg =~ s/^(.)(....)//s) {
|
||||
my($cmd, $arg) = ($1, $2);
|
||||
next if $cmd eq 'c'; # child died
|
||||
next if $cmd eq 'f'; # child is about to exit and passed a client fd to us
|
||||
if ($cmd eq 'r') { # child ready
|
||||
my $pid = unpack 'V', $arg;
|
||||
$childs{$pid} = 2 if $childs{$pid};
|
||||
$err = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
sub _spawn {
|
||||
state %c;
|
||||
return if keys %c && !@_; # already checked if we need to spawn
|
||||
|
|
@ -381,6 +423,7 @@ sub _spawn {
|
|||
proc => $ENV{FU_PROC} // 1,
|
||||
monitor => $ENV{FU_MONITOR} // 0,
|
||||
max_reqs => $ENV{FU_MAX_REQS} // 0,
|
||||
listen_proto => $ENV{FU_LISTEN_PROTO},
|
||||
listen_sock => $ENV{FU_LISTEN_FD} && IO::Socket->new_from_fd($ENV{FU_LISTEN_FD}, 'r'),
|
||||
client_sock => $ENV{FU_CLIENT_FD} && IO::Socket->new_from_fd($ENV{FU_CLIENT_FD}, 'r+'),
|
||||
supervisor_sock => $ENV{FU_SUPERVISOR_FD} && IO::Socket->new_from_fd($ENV{FU_SUPERVISOR_FD}, 'w'),
|
||||
|
|
@ -405,9 +448,11 @@ sub _spawn {
|
|||
return if !@_ && !$need_supervisor;
|
||||
|
||||
if (!$c{listen_sock}) {
|
||||
my $addr = $c{fcgi} || $c{http};
|
||||
# TODO: check if stdin is a fastcgi sock
|
||||
$c{listen_proto} //= $c{fcgi} ? 'fcgi' : 'http';
|
||||
my $addr = $c{$c{listen_proto}};
|
||||
$c{listen_sock} = IO::Socket->new(
|
||||
Listen => 5,
|
||||
Listen => 10 * $c{proc},
|
||||
Type => IO::Socket::SOCK_STREAM(),
|
||||
$addr =~ m{^(unix:|/)(.+)$} ? do {
|
||||
my $path = ($1 eq '/' ? '/' : '').$2;
|
||||
|
|
@ -427,16 +472,11 @@ sub _spawn {
|
|||
_supervisor \%c;
|
||||
} else {
|
||||
$c{supervisor_sock}->syswrite('r'.pack 'V', $$) if $c{supervisor_sock};
|
||||
my $stop = 0;
|
||||
local $SIG{HUP} = 'IGNORE';
|
||||
local $SIG{TERM} = $SIG{INT} = sub { $stop = 1 };
|
||||
_do_req \%c if $c{client_sock};
|
||||
while (!$stop) {
|
||||
_do_req \%c if ($c{client_sock} = $c{listen_sock}->accept);
|
||||
}
|
||||
_run_loop \%c;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
sub run(%conf) {
|
||||
confess "FU::run() called with configuration options, but FU has already been loaded with -spawn" if keys %conf;
|
||||
# Clean up any state we may have accumulated during initialization.
|
||||
|
|
@ -492,9 +532,9 @@ sub error($,$code,$msg=$code) { die bless [$code,$msg], 'FU::err' }
|
|||
sub reset {
|
||||
my $r = $FU::REQ;
|
||||
$r->{status} = 200;
|
||||
$r->{reshdr} = [
|
||||
$r->{reshdr} = {
|
||||
'content-type', 'text/html; charset=UTF-8',
|
||||
];
|
||||
};
|
||||
$r->{resbody} = '';
|
||||
}
|
||||
|
||||
|
|
@ -509,19 +549,16 @@ sub _validate_header($hdr, $val) {
|
|||
|
||||
sub add_header($, $hdr, $val) {
|
||||
_validate_header($hdr, $val);
|
||||
push $FU::REQ->{reshdr}->@*, lc $hdr, $val;
|
||||
$hdr = lc $hdr;
|
||||
my $h = $FU::REQ->{reshdr};
|
||||
if (!defined $h->{$hdr}) { $h->{$hdr} = $val }
|
||||
elsif (ref $h->{$hdr}) { push $h->{$hdr}->@*, $val }
|
||||
else { $h->{$hdr} = [ $h->{$hdr}, $val ] }
|
||||
}
|
||||
|
||||
sub set_header($, $hdr, $val=undef) {
|
||||
_validate_header($hdr, $val);
|
||||
$hdr = lc $hdr;
|
||||
# Not very efficient *shrug*
|
||||
my @r;
|
||||
for my ($ihdr, $ival) ($FU::REQ->{reshdr}->@*) {
|
||||
push @r, $ihdr, $ival if $ihdr ne $hdr;
|
||||
}
|
||||
push @r, $hdr, $val if defined $val;
|
||||
$FU::REQ->{reshdr} = \@r;
|
||||
$FU::REQ{reshdr}{ lc $hdr } = $val;
|
||||
}
|
||||
|
||||
sub _error_page($, $code, $title, $msg) {
|
||||
|
|
@ -550,7 +587,7 @@ sub _error_page($, $code, $title, $msg) {
|
|||
|
||||
sub _flush($, $sock) {
|
||||
my $r = $FU::REQ;
|
||||
$sock->printf("HTTP/1.0 %d Hello\r\n", $r->{status});
|
||||
# TODO: output compression would be nice
|
||||
|
||||
if ($r->{status} == 204) {
|
||||
fu->set_header('content-length', undef);
|
||||
|
|
@ -558,14 +595,28 @@ sub _flush($, $sock) {
|
|||
} else {
|
||||
fu->set_header('content-length', length $r->{resbody});
|
||||
}
|
||||
for my ($hdr, $val) ($r->{reshdr}->@*) {
|
||||
$r->{resbody} = '' if (fu->method//'') eq 'HEAD' || $r->{status} == 204;
|
||||
|
||||
if ($sock isa 'FU::fcgi') {
|
||||
$sock->print('Status: ');
|
||||
$sock->print($r->{status});
|
||||
$sock->print("\r\n");
|
||||
} else {
|
||||
$sock->printf("HTTP/1.0 %d Hello\r\n", $r->{status});
|
||||
}
|
||||
|
||||
for my ($hdr, $val) ($r->{reshdr}->%*) {
|
||||
utf8::encode($hdr);
|
||||
utf8::encode($val);
|
||||
$sock->printf("%s: %s\r\n", $hdr, $val);
|
||||
for (!defined $val ? () : ref $val ? @$val : ($val)) {
|
||||
utf8::encode($_);
|
||||
$sock->print($hdr);
|
||||
$sock->print(': ');
|
||||
$sock->print($_);
|
||||
$sock->print("\r\n");
|
||||
}
|
||||
}
|
||||
$sock->print("\r\n");
|
||||
|
||||
$sock->print($r->{resbody}) if (fu->method//'') ne 'HEAD' && $r->{status} != 204;
|
||||
$sock->print($r->{resbody});
|
||||
$sock->flush;
|
||||
}
|
||||
|
||||
|
|
@ -705,10 +756,12 @@ listen on a UNIX socket. E.g.
|
|||
./your-script.pl --http=unix:/path/to/socket
|
||||
|
||||
B<WARNING:> The built-in HTTP server is only intended for local development
|
||||
setups, it is NOT suitable for production deployments in its current form. It
|
||||
does not enforce a limit on request header size, does not support HTTPS and has
|
||||
no provisions for extracting the client IP address when behind a reverse proxy.
|
||||
Please use FastCGI instead for internet-facing deployments.
|
||||
setups, it is NOT suitable for production deployments. It has no timeouts, does
|
||||
not enforce limits on request size, does not support HTTPS and will never
|
||||
adequately support keep-alive. You could put it behind a reverse proxy, but it
|
||||
currently also lacks provisions for extracting the client IP address from the
|
||||
request headers, so that's not ideal either. Much better to use FastCGI in
|
||||
combination with a proper web server for internet-facing deployments.
|
||||
|
||||
=item FU_FCGI=addr
|
||||
|
||||
|
|
@ -717,6 +770,27 @@ Please use FastCGI instead for internet-facing deployments.
|
|||
Like the HTTP counterpart above, but listen on a FastCGI socket instead. If
|
||||
this option is set, it takes precedence over the HTTP option.
|
||||
|
||||
Nginx and Apache will, in their default configuration, use a separate
|
||||
connection per request. If you have a more esoteric setup, you should probably
|
||||
be aware of the following: this implementation does not support multiplexing or
|
||||
pipelining. It does support keepalive, but this come with a few caveats:
|
||||
|
||||
=over
|
||||
|
||||
=item * You should not attempt to keep more connections alive than the
|
||||
configured number of worker processes, otherwise new connection attempts will
|
||||
stall indefinitely.
|
||||
|
||||
=item * When using C<--monitor> mode, the file modification check is performed
|
||||
I<after> each request rather than before, so clients may get a response from
|
||||
stale code.
|
||||
|
||||
=item * When worker processes shut down, either through C<--max-reqs> or in
|
||||
response to a signal, there is the possibility that an incoming request on an
|
||||
existing connection gets interrupted.
|
||||
|
||||
=back
|
||||
|
||||
=item FU_PROC=n
|
||||
|
||||
=item --proc=n
|
||||
|
|
@ -737,8 +811,8 @@ significant cost in performance - better not enable this in production.
|
|||
|
||||
Worker processes can automatically restart after handling a number of requests.
|
||||
Set to 0 (the default) to disable this feature. This option can be useful when
|
||||
your worker processes keep accumulating memory over time. A little pruning here
|
||||
and there can never hurt.
|
||||
your worker processes keep accumulating memory over time. A little pruning now
|
||||
and then can never hurt.
|
||||
|
||||
=item FU_DEBUG=0/1
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue