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

42
FU.xs
View file

@ -25,6 +25,7 @@
#include "c/jsonfmt.c"
#include "c/jsonparse.c"
#include "c/fdpass.c"
#include "c/fcgi.c"
#include "c/libpq.h"
#include "c/pgtypes.c"
#include "c/pgconn.c"
@ -55,11 +56,16 @@ PROTOTYPES: DISABLE
TYPEMAP: <<EOT
TYPEMAP
fufcgi * FUFCGI
fupg_conn * FUPG_CONN
fupg_txn * FUPG_TXN
fupg_st * FUPG_ST
INPUT
FUFCGI
if (sv_derived_from($arg, \"FU::fcgi\")) $var = (fufcgi *)SvIVX(SvRV($arg));
else fu_confess(\"invalid FastCGI object\");
FUPG_CONN
if (sv_derived_from($arg, \"FU::Pg::conn\")) $var = (fupg_conn *)SvIVX(SvRV($arg));
else fu_confess(\"invalid connection object\");
@ -96,6 +102,42 @@ void fdpass_recv(int socket, UV len)
XSRETURN(fufdpass_recv(aTHX_ ax, socket, len));
MODULE = FU PACKAGE = FU::fcgi
void new(int fd, int maxproc)
CODE:
fufcgi *ctx = safemalloc(sizeof(*ctx));
ctx->fd = fd;
ctx->maxproc = maxproc;
ctx->reqid = ctx->keepconn = ctx->len = ctx->off = 0;
ST(0) = fu_selfobj(ctx, "FU::fcgi");
void read_req(fufcgi *ctx, SV *headers, SV *params)
CODE:
ST(0) = sv_2mortal(newSViv(fufcgi_read_req(aTHX_ ctx, headers, params)));
ctx->off = 8;
void keepalive(fufcgi *ctx)
CODE:
ST(0) = ctx->keepconn ? &PL_sv_yes : &PL_sv_no;
void print(fufcgi *ctx, SV *sv)
CODE:
STRLEN len;
const char *buf = SvPVbyte(sv, len);
fufcgi_print(ctx, buf, len);
void flush(fufcgi *ctx)
CODE:
fufcgi_flush(ctx);
ctx->off = ctx->len = ctx->reqid = 0;
void DESTROY(fufcgi *ctx)
CODE:
safefree(ctx);
MODULE = FU PACKAGE = FU::Pg
void _load_libpq()

427
c/fcgi.c Normal file
View file

@ -0,0 +1,427 @@
#define FCGI_BEGIN_REQUEST 1
#define FCGI_ABORT_REQUEST 2
#define FCGI_END_REQUEST 3
#define FCGI_PARAMS 4
#define FCGI_STDIN 5
#define FCGI_STDOUT 6
#define FCGI_STDERR 7
#define FCGI_DATA 8
#define FCGI_GET_VALUES 9
#define FCGI_GET_VALUES_RESULT 10
#define FCGI_UNKNOWN_TYPE 11
#define FUFE_OK 0
#define FUFE_EOF -1 /* protocol-level EOF */
#define FUFE_IO -2
#define FUFE_PROTO -3
#define FUFE_PLEN -4
#define FUFE_CLEN -5
#define FUFE_ABORT -6 /* explicit abort or client-level EOF */
typedef struct {
SV *self;
int fd;
int maxproc;
int keepconn;
int reqid;
HV *headers;
HV *params;
/* Single buffer for reading & writing, we only do one thing at a time */
char buf[8 + 65536 + 256]; /* fits a maximum-length fcgi record */
int len; /* total number of bytes in the buffer */
int off; /* number of bytes consumed */
} fufcgi;
typedef struct {
unsigned char type;
unsigned short id;
int len;
char *data;
} fufcgi_rec;
/* Incremental param length & name parser */
typedef enum {
FUFC_INIT, FUFC_L1, FUFC_L2, FUFC_L3,
FUFC_V0, FUFC_V1, FUFC_V2, FUFC_V3,
FUFC_N0, FUFC_NX
} fufcgi_paramstate;
typedef struct {
int namelen;
int vallen;
int state;
int namerd;
char *name;
char namebuf[128]; /* We don't support longer param names */
} fufcgi_param;
/* Returns NULL on error or ptr to value (or 'end' if !done) */
static char *fufcgi_param_parse(fufcgi_param *p, char *buf, char *end) {
while (buf < end) {
switch (p->state) {
case FUFC_INIT:
p->vallen = p->namerd = 0;
if (*buf & 0x80) {
p->namelen = (*buf & 0x1f) << 24;
p->state = FUFC_L1;
} else {
p->namelen = *buf;
p->state = FUFC_V0;
}
break;
case FUFC_L1:
p->namelen |= ((unsigned char)*buf) << 16;
p->state = FUFC_L2;
break;
case FUFC_L2:
p->namelen |= ((unsigned char)*buf) << 8;
p->state = FUFC_L3;
break;
case FUFC_L3:
p->namelen |= (unsigned char)*buf;
p->state = FUFC_V0;
if (p->namelen > (int)sizeof(p->namebuf)) return NULL;
break;
case FUFC_V0:
if (*buf & 0x80) {
p->vallen = (*buf & 0x1f) << 24;
p->state = FUFC_V1;
} else {
p->vallen = *buf;
p->state = p->namelen ? FUFC_N0 : FUFC_INIT;
}
break;
case FUFC_V1:
p->vallen |= ((unsigned char)*buf) << 16;
if (p->vallen) return NULL; /* Let's just disallow param values > 64 KiB */
p->state = FUFC_V2;
break;
case FUFC_V2:
p->vallen |= ((unsigned char)*buf) << 8;
p->state = FUFC_V3;
break;
case FUFC_V3:
p->vallen |= (unsigned char)*buf;
p->state = FUFC_N0;
break;
case FUFC_N0:
if (p->namelen <= end - buf) {
p->name = buf;
p->state = FUFC_INIT;
return buf + p->namelen;
} else {
p->name = p->namebuf;
p->name[0] = *buf;
p->namerd = 1;
p->state = FUFC_NX;
}
break;
case FUFC_NX:
p->name[p->namerd++] = *buf;
if (p->namerd == p->namelen) {
p->state = FUFC_INIT;
return buf + 1;
}
break;
}
buf++;
}
return buf;
}
static int fufcgi_fill(fufcgi *ctx, int len) {
if ((int)sizeof(ctx->buf) - ctx->off < len) {
memmove(ctx->buf, ctx->buf+ctx->off, ctx->len - ctx->off);
ctx->len -= ctx->off;
ctx->off = 0;
}
while (ctx->len - ctx->off < len) {
ssize_t r = read(ctx->fd, ctx->buf+ctx->len, sizeof(ctx->buf) - ctx->len);
if (r <= 0) return r == 0 ? FUFE_EOF : FUFE_IO;
ctx->len += r;
}
return FUFE_OK;
}
static int fufcgi_read_record(fufcgi *ctx, fufcgi_rec *rec) {
int r;
if ((r = fufcgi_fill(ctx, 8)) != FUFE_OK) return r;
if (ctx->buf[ctx->off] != 1) return FUFE_PROTO; /* version */
rec->type = ctx->buf[ctx->off+1];
rec->id = fu_frombeU(16, ctx->buf+ctx->off+2);
rec->len = fu_frombeU(16, ctx->buf+ctx->off+4);
int pad = ctx->buf[ctx->off+6];
ctx->off += 8;
if ((r = fufcgi_fill(ctx, rec->len + pad)) != FUFE_OK) return r;
rec->data = ctx->buf + ctx->off;
ctx->off += rec->len + pad;
return FUFE_OK;
}
/* Unbuffered write of a single record, first 8 bytes of 'buf' are filled out
* by this function, record contents must come after. */
static int fufcgi_write_record(fufcgi *ctx, fufcgi_rec *hdr, char *buf) {
buf[0] = 1;
buf[1] = hdr->type;
fu_tobeU(16, buf+2, hdr->id);
fu_tobeU(16, buf+4, hdr->len);
buf[6] = 0;
buf[7] = 0;
int len = hdr->len + 8;
while (len > 0) {
int r = write(ctx->fd, buf, len);
if (r <= 0) return r == 0 ? FUFE_EOF : FUFE_IO;
buf += r;
len -= r;
}
return FUFE_OK;
}
static int fufcgi_handle_values(fufcgi *ctx, fufcgi_rec *rec, char *buf) {
int reslen = 8;
char *param = rec->data;
char *end = rec->data + rec->len;
fufcgi_param p;
p.state = FUFC_INIT;
while (param < end) {
if ((param = fufcgi_param_parse(&p, param, end)) == NULL) return FUFE_PLEN;
if (p.state != FUFC_INIT) return FUFE_PROTO;
if (p.vallen > end - param) return FUFE_PROTO;
if (reslen >= 100) return FUFE_PROTO; /* implies requested params were duplicated */
if (p.namelen == 14 && memcmp(p.name, "FCGI_MAX_CONNS", 14) == 0) {
memcpy(buf+reslen, "\x0e\0FCGI_MAX_CONNS", 16);
int l = sprintf(buf+reslen+16, "%d", ctx->maxproc);
buf[reslen+1] = l;
reslen += 16 + l;
} else if (p.namelen == 13 && memcmp(p.name, "FCGI_MAX_REQS", 13) == 0) {
memcpy(buf+reslen, "\x0d\0FCGI_MAX_REQS", 15);
int l = sprintf(buf+reslen+15, "%d", ctx->maxproc);
buf[reslen+1] = l;
reslen += 15 + l;
} else if (p.namelen == 15 && memcmp(p.name, "FCGI_MPXS_CONNS", 15) == 0) {
memcpy(buf+reslen, "\x0f\1FCGI_MPXS_CONNS0", 18);
reslen += 18;
}
param += p.vallen;
}
rec->type = FCGI_GET_VALUES_RESULT;
rec->len = reslen - 8;
return fufcgi_write_record(ctx, rec, buf);
}
/* Read a PARAMS/STDIN/ABORT record corresponding to the current id, starts
* reading a new request if id=0. */
static int fufcgi_read_req_record(fufcgi *ctx, fufcgi_rec *rec) {
int r;
char tmp[128]; /* Large enough for a FCGI_GET_VALUES_RESULT */
while (1) {
if ((r = fufcgi_read_record(ctx, rec)) != FUFE_OK) return r;
switch (rec->type) {
case FCGI_PARAMS:
case FCGI_STDIN:
case FCGI_ABORT_REQUEST:
if (rec->id != ctx->reqid) return FUFE_PROTO;
return FUFE_OK;
case FCGI_BEGIN_REQUEST:
if (!rec->id || rec->id == ctx->reqid) return FUFE_PROTO;
if (rec->len != 8) return FUFE_PROTO;
ctx->keepconn = rec->data[2] & 1;
if (rec->data[0] != 0 || rec->data[1] != 1) { /* FCGI_RESPONDER */
memcpy(tmp+8, "\0\0\0\0\3\0\0\0", 8); /* FCGI_UNKNOWN_ROLE */
rec->type = FCGI_END_REQUEST;
rec->len = 8;
if ((r = fufcgi_write_record(ctx, rec, tmp)) != FUFE_OK) return r;
if (!ctx->keepconn) return FUFE_EOF;
} else if (ctx->reqid) {
memcpy(tmp+8, "\0\0\0\0\1\0\0\0", 8); /* FCGI_CANT_MPX_CONN */
rec->type = FCGI_END_REQUEST;
rec->len = 8;
if ((r = fufcgi_write_record(ctx, rec, tmp)) != FUFE_OK) return r;
if (!ctx->keepconn) return FUFE_EOF;
} else {
ctx->reqid = rec->id;
}
break;
case FCGI_GET_VALUES:
if (rec->id) return FUFE_PROTO;
if ((r = fufcgi_handle_values(ctx, rec, tmp)) != FUFE_OK) return r;
break;
default:
memset(tmp+8, 0, 8);
tmp[8] = rec->type;
rec->type = FCGI_UNKNOWN_TYPE;
rec->len = 8;
rec->id = 0;
if ((r = fufcgi_write_record(ctx, rec, tmp)) != FUFE_OK) return r;
break;
}
}
}
static int fufcgi_read_params(pTHX_ fufcgi *ctx, fufcgi_rec *rec) {
int r;
fufcgi_param p;
p.state = FUFC_INIT;
SV *valsv = NULL;
char *val = NULL;
int valleft = 0;
while (1) {
if ((r = fufcgi_read_req_record(ctx, rec)) != FUFE_OK) return r;
if (rec->type == FCGI_ABORT_REQUEST) return FUFE_OK;
if (rec->type != FCGI_PARAMS) return FUFE_PROTO;
if (rec->len == 0) return p.state != FUFC_INIT || valleft ? FUFE_PROTO : FUFE_OK;
char *buf = rec->data;
char *end = rec->data + rec->len;
while (buf < end) {
if (valleft) {
r = valleft > end - buf ? end - buf : valleft;
if (val) {
memcpy(val, buf, r);
val += r;
}
valleft -= r;
buf += r;
if (val && !valleft) {
*val = 0;
SvCUR_set(valsv, p.vallen);
}
continue;
}
if ((buf = fufcgi_param_parse(&p, buf, end)) == NULL) return FUFE_PLEN;
if (p.state != FUFC_INIT) break;
valsv = NULL;
val = NULL;
valleft = p.vallen;
/* https://www.rfc-editor.org/rfc/rfc3875 */
/* Request header */
if (p.namelen > 5 && memcmp(p.name, "HTTP_", 5) == 0) {
p.namelen -= 5;
p.name += 5;
for (r=0; r<p.namelen; r++)
p.name[r] = p.name[r] == '_' ? '-' : p.name[r] >= 'A' && p.name[r] <= 'Z' ? p.name[r] | 0x20 : p.name[r];
valsv = newSV(p.vallen+1);
hv_store(ctx->headers, p.name, p.namelen, valsv, 0);
} else if (p.namelen == 14 && memcmp(p.name, "CONTENT_LENGTH", 14) == 0) {
valsv = newSV(p.vallen+1);
hv_stores(ctx->headers, "content-length", valsv);
} else if (p.namelen == 12 && memcmp(p.name, "CONTENT_TYPE", 12) == 0) {
valsv = newSV(p.vallen+1);
hv_stores(ctx->headers, "content-type", valsv);
} else if (p.namelen == 11 && memcmp(p.name, "REMOTE_ADDR", 11) == 0) {
valsv = newSV(p.vallen+1);
hv_stores(ctx->params, "ip", valsv);
} else if (p.namelen == 12 && memcmp(p.name, "QUERY_STRING", 12) == 0) {
valsv = newSV(p.vallen+1);
hv_stores(ctx->params, "qs", valsv);
} else if (p.namelen == 14 && memcmp(p.name, "REQUEST_METHOD", 14) == 0) {
valsv = newSV(p.vallen+1);
hv_stores(ctx->params, "method", valsv);
/* Not in rfc3875; there's no standardized parameter for the URI,
* but every FastCGI-capable web server includes this one */
} else if (p.namelen == 11 && memcmp(p.name, "REQUEST_URI", 11) == 0) {
valsv = newSV(p.vallen+1);
hv_stores(ctx->params, "path", valsv);
} else { /* ignore */ }
if (valsv) {
SvPOK_only(valsv);
val = SvPVX(valsv);
*val = 0; /* in case vallen = 0 */
}
}
}
}
static int fufcgi_read_req(pTHX_ fufcgi *ctx, SV *headers, SV *params) {
if (ctx->reqid) fu_confess("Invalid attempt to read FastCGI request before finishing the previous one");
fufcgi_rec rec;
int r;
ctx->off = ctx->len = 0;
ctx->headers = (HV *)SvRV(headers);
ctx->params = (HV *)SvRV(params);
if ((r = fufcgi_read_params(aTHX_ ctx, &rec)) != FUFE_OK) return r;
int stdinlen = 0;
SV **contentlength = hv_fetchs(ctx->headers, "content-length", 0);
if (contentlength && *contentlength) {
UV uv = 0;
char *v = SvPV_nolen(*contentlength);
if (*v && !grok_atoUV(v, &uv, NULL)) return FUFE_CLEN;
if (uv >= INT_MAX) return FUFE_CLEN;
stdinlen = uv;
}
SV *sv = newSV(stdinlen+1);
hv_stores(ctx->params, "body", sv);
SvPOK_only(sv);
char *stdinbuf = SvPVX(sv);
int stdinleft = stdinlen;
while (1) {
if (rec.type == FCGI_ABORT_REQUEST) return FUFE_ABORT;
else if (rec.type == FCGI_PARAMS) {
if (rec.len != 0) return FUFE_PROTO;
} else if (rec.type == FCGI_STDIN) {
if (rec.len == 0) {
*stdinbuf = 0;
SvCUR_set(sv, stdinlen - stdinleft);
return stdinleft == 0 ? FUFE_OK : FUFE_ABORT;
}
if (rec.len > stdinleft) return FUFE_PROTO;
memcpy(stdinbuf, rec.data, rec.len);
stdinbuf += rec.len;
stdinleft -= rec.len;
} else {
return FUFE_PROTO;
}
if ((r = fufcgi_read_req_record(ctx, &rec)) != FUFE_OK) return r;
}
}
static void fufcgi_flush(fufcgi *ctx) {
fufcgi_rec hdr;
if (ctx->off > 8) {
hdr.len = ctx->off - 8;
hdr.type = FCGI_STDOUT;
hdr.id = ctx->reqid;
fufcgi_write_record(ctx, &hdr, ctx->buf);
ctx->off = 8;
}
}
static void fufcgi_print(fufcgi *ctx, const char *buf, int len) {
int r;
while (len > 0) {
r = len > 65535 - ctx->off ? 65535 - ctx->off : len;
memcpy(ctx->buf+ctx->off, buf, r);
ctx->off += r;
len -= r;
buf += r;
if (ctx->off >= 65535) fufcgi_flush(ctx);
}
}

162
t/fcgi.t Normal file
View file

@ -0,0 +1,162 @@
use v5.36;
use Test::More;
use IO::Socket qw/AF_UNIX SOCK_STREAM PF_UNSPEC/;
use FU::XS;
my($f, $local, $remote);
sub start {
($local, $remote) = IO::Socket->socketpair(AF_UNIX, SOCK_STREAM, PF_UNSPEC);
$f = FU::fcgi::new(fileno $local, 123);
}
sub record($id, $type, $data) {
my $pad = rand > 0.5 ? int rand(50) : 0;
my $msg = pack('CCnnCC', 1, $type, $id, length($data), $pad, 0) . $data . ("\0"x$pad);
is $remote->syswrite($msg, length($msg)), length($msg);
}
sub begin($id=1, $role=1, $keep=0) {
record $id, 1, "\0".chr($role).($keep?"\1":"\0")."\0\0\0\0\0"
}
sub iserr($code) {
is $f->read_req({}, {}), $code;
}
sub isrec($hdr, $par, $code=0) {
is $f->read_req(my $rhdr = {}, my $rpar = {}), $code;
is_deeply $rhdr, $hdr;
is_deeply $rpar, $par;
}
sub isrecv($data) {
is $remote->sysread(my $buf, length $data), length $data;
is $buf, $data;
}
start;
$remote->close;
iserr -1;
start;
is $remote->syswrite("\0\0\0\0\0\0\0\0", 8), 8;
iserr -3;
start;
begin 1, 2;
record 1, 4, "";
start;
begin 3, 2, 1;
begin 1, 1, 1;
begin 2, 1, 1;
record 1, 4, "";
record 0, 10, "";
record 1, 5, "";
isrec {}, {body => ''};
isrecv "\1\3\0\3\0\x08\0\0"."\0\0\0\0\3\0\0\0"; # end request 3, unknown role
isrecv "\1\3\0\2\0\x08\0\0"."\0\0\0\0\1\0\0\0"; # end request 2, can't multiplex
isrecv "\1\x0b\0\0\0\x08\0\0"."\x0a\0\0\0\0\0\0\0"; # unknown type, 10
start;
begin;
record 1, 4, "\x0e\2C";
record 1, 4, "ONTENT_";
record 1, 4, "LENGTH";
record 1, 4, "1";
record 1, 4, "2\x80\x00";
record 1, 4, "\x00\x09";
record 1, 4, "\x80";
record 1, 4, "\x00\x00";
record 1, 4, "\x04HTTP_H_S";
record 1, 4, "T";
record 1, 4, "tes";
record 1, 4, "t";
record 1, 4, "";
record 1, 5, "012";
record 1, 5, "34567890";
record 1, 5, "1";
record 1, 5, "";
isrec {'content-length',12, 'h-st' => 'test'}, {body => '012345678901'};
start;
begin 5, 1, 1;
record 5, 4, "\x0e\x01CONTENT_LENGTH5\x0c\x05CONTENT_TYPEtext/";
record 5, 4, "\x0b\x04REMOTE_ADDRaddr\x0c\x05QUERY_STRINGquery";
record 5, 4, "\x0e\x04REQUEST_METHODPOST\x0b\x06REQUEST_URI/p\x81t\x55/";
record 5, 4, "";
record 5, 5, "hello";
record 5, 5, "";
isrec
{ 'content-length', 5, 'content-type', 'text/' },
{ ip => 'addr', body => 'hello', qs => 'query', path => "/p\x81t\x55/", method => 'POST' };
$f->print("Status: 200\r\n");
$f->print("Something else");
$f->flush;
isrecv "\1\6\0\5\0\x1b\0\0"."Status: 200\r\nSomething else";
# Same connection:
begin;
record 1, 4, "\x00\x00\x06\x00HTTP_x\x00\x00";
record 1, 4, "";
record 1, 5, "";
isrec { x => '' }, { body => ''};
start;
begin;
record 1, 4, "\x40\x01this is too short";
record 1, 4, "";
iserr -3;
start;
begin;
record 1, 4, "\x01\x40this is too short";
record 1, 4, "";
iserr -3;
start;
begin;
record 1, 5, "";
iserr -3;
start;
begin;
record 1, 4, "\x0e\x03CONTENT_LENGTH123";
record 1, 4, "";
record 1, 5, "too short";
record 1, 5, "";
isrec {'content-length',123}, {body=>'too short'}, -6;
start;
begin;
record 1, 4, "\x0e\x00CONTENT_LENGTH";
record 1, 4, "";
record 1, 5, "";
isrec {'content-length',''}, {body=>''};
start;
begin;
record 1, 4, "\x80\x00\x01\x00\x00".('A'x256);
iserr -4;
start;
begin;
record 1, 4, "\x01\x80\x01\x00\x00".('A'x256);
iserr -4;
start;
begin;
record 1, 4, "";
record 0, 9, "\x0d\0FCGI_MAX_REQS\x0e\0FCGI_MAX_CONNS\2\3hi987\x0f\0FCGI_MPXS_CONNS";
record 1, 5, "";
isrec {}, {body => ''};
isrecv "\1\x0a\0\0\0\x37\0\0"."\x0d\3FCGI_MAX_REQS123\x0e\3FCGI_MAX_CONNS123\x0f\1FCGI_MPXS_CONNS0";
start;
begin;
record 1, 4, "\x0c\x05CONTENT_TYPEsomet";
record 1, 2, "";
isrec {'content-type','somet'}, {body => ''}, -6;
done_testing;