From d9fba4e8d83820521b6c2d4810a22bac60707ef8 Mon Sep 17 00:00:00 2001 From: Yorhel Date: Mon, 17 Feb 2025 15:10:44 +0100 Subject: [PATCH] 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. --- FU.pm | 232 ++++++++++++++++++++---------- FU.xs | 42 ++++++ c/fcgi.c | 427 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ t/fcgi.t | 162 +++++++++++++++++++++ 4 files changed, 784 insertions(+), 79 deletions(-) create mode 100644 c/fcgi.c create mode 100644 t/fcgi.t diff --git a/FU.pm b/FU.pm index cd8bc41..ad19d87 100644 --- a/FU.pm +++ b/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! _ }, - 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 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 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 diff --git a/FU.xs b/FU.xs index 759fd89..b42663f 100644 --- a/FU.xs +++ b/FU.xs @@ -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: <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() diff --git a/c/fcgi.c b/c/fcgi.c new file mode 100644 index 0000000..20368a5 --- /dev/null +++ b/c/fcgi.c @@ -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= '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); + } +} diff --git a/t/fcgi.t b/t/fcgi.t new file mode 100644 index 0000000..5262488 --- /dev/null +++ b/t/fcgi.t @@ -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;