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:
Yorhel 2025-02-17 15:10:44 +01:00
parent 3e84a4f4d3
commit d9fba4e8d8
4 changed files with 784 additions and 79 deletions

232
FU.pm
View file

@ -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