]> Sergey Matveev's repositories - public-inbox.git/blob - t/httpd-corner.t
daemon: set TCP_DEFER_ACCEPT on everything but NNTP
[public-inbox.git] / t / httpd-corner.t
1 # Copyright (C) 2016-2019 all contributors <meta@public-inbox.org>
2 # License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
3 # note: our HTTP server should be standalone and capable of running
4 # generic PSGI/Plack apps.
5 use strict;
6 use warnings;
7 use Test::More;
8 use Time::HiRes qw(gettimeofday tv_interval);
9
10 foreach my $mod (qw(Plack::Util Plack::Builder
11                         HTTP::Date HTTP::Status IPC::Run)) {
12         eval "require $mod";
13         plan skip_all => "$mod missing for httpd-corner.t" if $@;
14 }
15
16 use Digest::SHA qw(sha1_hex);
17 use File::Temp qw/tempdir/;
18 use IO::Socket;
19 use IO::Socket::UNIX;
20 use Fcntl qw(:seek);
21 use Socket qw(IPPROTO_TCP TCP_NODELAY);
22 use POSIX qw(mkfifo);
23 require './t/common.perl';
24 my $tmpdir = tempdir('httpd-corner-XXXXXX', TMPDIR => 1, CLEANUP => 1);
25 my $fifo = "$tmpdir/fifo";
26 ok(defined mkfifo($fifo, 0777), 'created FIFO');
27 my $err = "$tmpdir/stderr.log";
28 my $out = "$tmpdir/stdout.log";
29 my $httpd = 'blib/script/public-inbox-httpd';
30 my $psgi = "./t/httpd-corner.psgi";
31 my %opts = (
32         LocalAddr => '127.0.0.1',
33         ReuseAddr => 1,
34         Proto => 'tcp',
35         Type => SOCK_STREAM,
36         Listen => 1024,
37 );
38 my $sock = IO::Socket::INET->new(%opts);
39 my $defer_accept_val;
40 if ($^O eq 'linux') {
41         setsockopt($sock, IPPROTO_TCP, Socket::TCP_DEFER_ACCEPT(), 5) or die;
42         my $x = getsockopt($sock, IPPROTO_TCP, Socket::TCP_DEFER_ACCEPT());
43         defined $x or die "getsockopt: $!";
44         $defer_accept_val = unpack('i', $x);
45         if ($defer_accept_val <= 0) {
46                 die "unexpected TCP_DEFER_ACCEPT value: $defer_accept_val";
47         }
48 }
49
50 my $upath = "$tmpdir/s";
51 my $unix = IO::Socket::UNIX->new(
52         Listen => 1024,
53         Type => SOCK_STREAM,
54         Local => $upath
55 );
56 ok($unix, 'UNIX socket created');
57 my $pid;
58 END { kill 'TERM', $pid if defined $pid };
59 my $spawn_httpd = sub {
60         my (@args) = @_;
61         my $cmd = [ $httpd, @args, "--stdout=$out", "--stderr=$err", $psgi ];
62         $pid = spawn_listener(undef, $cmd, [ $sock, $unix ]);
63         ok(defined $pid, 'forked httpd process successfully');
64 };
65
66 {
67         ok($sock, 'sock created');
68         $spawn_httpd->('-W0');
69 }
70
71 {
72         my $conn = conn_for($sock, 'streaming callback');
73         $conn->write("GET /callback HTTP/1.0\r\n\r\n");
74         ok($conn->read(my $buf, 8192), 'read response');
75         my ($head, $body) = split(/\r\n\r\n/, $buf);
76         is($body, "hello world\n", 'callback body matches expected');
77 }
78
79 {
80         my $conn = conn_for($sock, 'getline-die');
81         $conn->write("GET /getline-die HTTP/1.1\r\nHost: example.com\r\n\r\n");
82         ok($conn->read(my $buf, 8192), 'read some response');
83         like($buf, qr!HTTP/1\.1 200\b[^\r]*\r\n!, 'got some sort of header');
84         is($conn->read(my $nil, 8192), 0, 'read EOF');
85         $conn = undef;
86         my $after = capture($err);
87         is(scalar(grep(/GETLINE FAIL/, @$after)), 1, 'failure logged');
88         is(scalar(grep(/CLOSE FAIL/, @$after)), 1, 'body->close not called');
89 }
90
91 {
92         my $conn = conn_for($sock, 'close-die');
93         $conn->write("GET /close-die HTTP/1.1\r\nHost: example.com\r\n\r\n");
94         ok($conn->read(my $buf, 8192), 'read some response');
95         like($buf, qr!HTTP/1\.1 200\b[^\r]*\r\n!, 'got some sort of header');
96         is($conn->read(my $nil, 8192), 0, 'read EOF');
97         $conn = undef;
98         my $after = capture($err);
99         is(scalar(grep(/GETLINE FAIL/, @$after)), 0, 'getline not failed');
100         is(scalar(grep(/CLOSE FAIL/, @$after)), 1, 'body->close not called');
101 }
102
103 {
104         my $conn = conn_for($sock, 'excessive header');
105         $SIG{PIPE} = 'IGNORE';
106         $conn->write("GET /callback HTTP/1.0\r\n");
107         foreach my $i (1..500000) {
108                 last unless $conn->write("X-xxxxxJunk-$i: omg\r\n");
109         }
110         ok(!$conn->write("\r\n"), 'broken request');
111         ok($conn->read(my $buf, 8192), 'read response');
112         my ($head, $body) = split(/\r\n\r\n/, $buf);
113         like($head, qr/\b400\b/, 'got 400 response');
114 }
115
116 {
117         my $conn = conn_for($sock, 'excessive body Content-Length');
118         $SIG{PIPE} = 'IGNORE';
119         my $n = (10 * 1024 * 1024) + 1;
120         $conn->write("PUT /sha1 HTTP/1.0\r\nContent-Length: $n\r\n\r\n");
121         ok($conn->read(my $buf, 8192), 'read response');
122         my ($head, $body) = split(/\r\n\r\n/, $buf);
123         like($head, qr/\b413\b/, 'got 413 response');
124 }
125
126 {
127         my $conn = conn_for($sock, 'excessive body chunked');
128         $SIG{PIPE} = 'IGNORE';
129         my $n = (10 * 1024 * 1024) + 1;
130         $conn->write("PUT /sha1 HTTP/1.1\r\nTransfer-Encoding: chunked\r\n");
131         $conn->write("\r\n".sprintf("%x\r\n", $n));
132         ok($conn->read(my $buf, 8192), 'read response');
133         my ($head, $body) = split(/\r\n\r\n/, $buf);
134         like($head, qr/\b413\b/, 'got 413 response');
135 }
136
137 {
138         my $conn = conn_for($sock, 'chunk with pipeline');
139         my $n = 10;
140         my $payload = 'b'x$n;
141         $conn->write("PUT /sha1 HTTP/1.1\r\nTransfer-Encoding: chunked\r\n");
142         $conn->write("\r\n".sprintf("%x\r\n", $n));
143         $conn->write($payload . "\r\n0\r\n\r\nGET /empty HTTP/1.0\r\n\r\n");
144         $conn->read(my $buf, 4096);
145         my $lim = 0;
146         $lim++ while ($conn->read($buf, 4096, bytes::length($buf)) && $lim < 9);
147         my $exp = sha1_hex($payload);
148         like($buf, qr!\r\n\r\n${exp}HTTP/1\.0 200 OK\r\n!s,
149                 'chunk parser can handled pipelined requests');
150 }
151
152 # Unix domain sockets
153 {
154         my $u = IO::Socket::UNIX->new(Type => SOCK_STREAM, Peer => $upath);
155         ok($u, 'unix socket connected');
156         $u->write("GET /host-port HTTP/1.0\r\n\r\n");
157         $u->read(my $buf, 4096);
158         like($buf, qr!\r\n\r\n127\.0\.0\.1:0\z!,
159                 'set REMOTE_ADDR and REMOTE_PORT for Unix socket');
160 }
161
162 sub conn_for {
163         my ($sock, $msg) = @_;
164         my $conn = IO::Socket::INET->new(
165                                 PeerAddr => $sock->sockhost,
166                                 PeerPort => $sock->sockport,
167                                 Proto => 'tcp',
168                                 Type => SOCK_STREAM);
169         ok($conn, "connected for $msg");
170         $conn->autoflush(1);
171         setsockopt($conn, IPPROTO_TCP, TCP_NODELAY, 1);
172         return $conn;
173 }
174
175 {
176         my $conn = conn_for($sock, 'host-port');
177         $conn->write("GET /host-port HTTP/1.0\r\n\r\n");
178         $conn->read(my $buf, 4096);
179         my ($head, $body) = split(/\r\n\r\n/, $buf);
180         my ($addr, $port) = split(/:/, $body);
181         is($addr, $conn->sockhost, 'host matches addr');
182         is($port, $conn->sockport, 'port matches');
183 }
184
185 # graceful termination
186 {
187         my $conn = conn_for($sock, 'graceful termination via slow header');
188         $conn->write("GET /slow-header HTTP/1.0\r\n" .
189                         "X-Check-Fifo: $fifo\r\n\r\n");
190         open my $f, '>', $fifo or die "open $fifo: $!\n";
191         $f->autoflush(1);
192         ok(print($f "hello\n"), 'wrote something to fifo');
193         my $kpid = $pid;
194         $pid = undef;
195         is(kill('TERM', $kpid), 1, 'started graceful shutdown');
196         ok(print($f "world\n"), 'wrote else to fifo');
197         close $f or die "close fifo: $!\n";
198         $conn->read(my $buf, 8192);
199         my ($head, $body) = split(/\r\n\r\n/, $buf, 2);
200         like($head, qr!\AHTTP/1\.[01] 200 OK!, 'got 200 for slow-header');
201         is($body, "hello\nworld\n", 'read expected body');
202         is(waitpid($kpid, 0), $kpid, 'reaped httpd');
203         is($?, 0, 'no error');
204         $spawn_httpd->('-W0');
205 }
206
207 {
208         my $conn = conn_for($sock, 'graceful termination via slow-body');
209         $conn->write("GET /slow-body HTTP/1.0\r\n" .
210                         "X-Check-Fifo: $fifo\r\n\r\n");
211         open my $f, '>', $fifo or die "open $fifo: $!\n";
212         $f->autoflush(1);
213         my $buf;
214         $conn->sysread($buf, 8192);
215         like($buf, qr!\AHTTP/1\.[01] 200 OK!, 'got 200 for slow-body');
216         like($buf, qr!\r\n\r\n!, 'finished HTTP response header');
217
218         foreach my $c ('a'..'c') {
219                 $c .= "\n";
220                 ok(print($f $c), 'wrote line to fifo');
221                 $conn->sysread($buf, 8192);
222                 is($buf, $c, 'got trickle for reading');
223         }
224         my $kpid = $pid;
225         $pid = undef;
226         is(kill('TERM', $kpid), 1, 'started graceful shutdown');
227         ok(print($f "world\n"), 'wrote else to fifo');
228         close $f or die "close fifo: $!\n";
229         $conn->sysread($buf, 8192);
230         is($buf, "world\n", 'read expected body');
231         is($conn->sysread($buf, 8192), 0, 'got EOF from server');
232         is(waitpid($kpid, 0), $kpid, 'reaped httpd');
233         is($?, 0, 'no error');
234         $spawn_httpd->('-W0');
235 }
236
237 sub delay { select(undef, undef, undef, shift || rand(0.02)) }
238
239 my $str = 'abcdefghijklmnopqrstuvwxyz';
240 my $len = length $str;
241 is($len, 26, 'got the alphabet');
242 my $check_self = sub {
243         my ($conn) = @_;
244         $conn->read(my $buf, 4096);
245         my ($head, $body) = split(/\r\n\r\n/, $buf, 2);
246         like($head, qr/\r\nContent-Length: 40\r\n/s, 'got expected length');
247         is($body, sha1_hex($str), 'read expected body');
248 };
249
250 SKIP: {
251         my $have_curl = 0;
252         foreach my $p (split(':', $ENV{PATH})) {
253                 -x "$p/curl" or next;
254                 $have_curl = 1;
255                 last;
256         }
257         my $ntest = 2;
258         $have_curl or skip('curl(1) missing', $ntest);
259         my $url = 'http://' . $sock->sockhost . ':' . $sock->sockport . '/sha1';
260         my ($r, $w);
261         pipe($r, $w) or die "pipe: $!";
262         my $cmd = [qw(curl --tcp-nodelay --no-buffer -T- -HExpect: -sS), $url];
263         my ($out, $err) = ('', '');
264         my $h = IPC::Run::start($cmd, $r, \$out, \$err);
265         $w->autoflush(1);
266         foreach my $c ('a'..'z') {
267                 print $w $c or die "failed to write to curl: $!";
268                 delay();
269         }
270         close $w or die "close write pipe: $!";
271         close $r or die "close read pipe: $!";
272         IPC::Run::finish($h);
273         is($?, 0, 'curl exited successfully');
274         is($err, '', 'no errors from curl');
275         is($out, sha1_hex($str), 'read expected body');
276 }
277
278 {
279         my $conn = conn_for($sock, '1.1 pipeline together');
280         $conn->write("PUT /sha1 HTTP/1.1\r\nUser-agent: hello\r\n\r\n" .
281                         "PUT /sha1 HTTP/1.1\r\n\r\n");
282         my $buf = '';
283         my @r;
284         until (scalar(@r) >= 2) {
285                 my $r = $conn->sysread(my $tmp, 4096);
286                 die $! unless defined $r;
287                 die "EOF <$buf>" unless $r;
288                 $buf .= $tmp;
289                 @r = ($buf =~ /\r\n\r\n([a-f0-9]{40})/g);
290         }
291         is(2, scalar @r, 'got 2 responses');
292         my $i = 3;
293         foreach my $hex (@r) {
294                 is($hex, sha1_hex(''), "read expected body $i");
295                 $i++;
296         }
297 }
298
299 {
300         my $conn = conn_for($sock, 'no TCP_CORK on empty body');
301         $conn->write("GET /empty HTTP/1.1\r\nHost:example.com\r\n\r\n");
302         my $buf = '';
303         my $t0 = [ gettimeofday ];
304         until ($buf =~ /\r\n\r\n/s) {
305                 $conn->sysread($buf, 4096, length($buf));
306         }
307         my $elapsed = tv_interval($t0, [ gettimeofday ]);
308         ok($elapsed < 0.190, 'no 200ms TCP cork delay on empty body');
309 }
310
311 {
312         my $conn = conn_for($sock, 'graceful termination during slow request');
313         $conn->write("PUT /sha1 HTTP/1.0\r\n");
314         delay();
315         $conn->write("Content-Length: $len\r\n");
316         delay();
317         $conn->write("\r\n");
318         my $kpid = $pid;
319         $pid = undef;
320         is(kill('TERM', $kpid), 1, 'started graceful shutdown');
321         delay();
322         my $n = 0;
323         foreach my $c ('a'..'z') {
324                 $n += $conn->write($c);
325         }
326         is($n, $len, 'wrote alphabet');
327         $check_self->($conn);
328         is(waitpid($kpid, 0), $kpid, 'reaped httpd');
329         is($?, 0, 'no error');
330         $spawn_httpd->('-W0');
331 }
332
333 # various DoS attacks against the chunk parser:
334 {
335         local $SIG{PIPE} = 'IGNORE';
336         my $conn = conn_for($sock, '1.1 chunk header excessive');
337         $conn->write("PUT /sha1 HTTP/1.1\r\nTransfer-Encoding:chunked\r\n\r\n");
338         my $n = 0;
339         my $w;
340         while ($w = $conn->write('ffffffff')) {
341                 $n += $w;
342         }
343         ok($!, 'got error set in $!');
344         is($w, undef, 'write error happened');
345         ok($n > 0, 'was able to write');
346         my $r = $conn->read(my $buf, 66666);
347         ok($r > 0, 'got non-empty response');
348         like($buf, qr!HTTP/1\.\d 400 !, 'got 400 response');
349
350         $conn = conn_for($sock, '1.1 chunk trailer excessive');
351         $conn->write("PUT /sha1 HTTP/1.1\r\nTransfer-Encoding:chunked\r\n\r\n");
352         is($conn->syswrite("1\r\na"), 4, 'wrote first header + chunk');
353         delay();
354         $n = 0;
355         while ($w = $conn->write("\r")) {
356                 $n += $w;
357         }
358         ok($!, 'got error set in $!');
359         ok($n > 0, 'wrote part of chunk end (\r)');
360         $r = $conn->read($buf, 66666);
361         ok($r > 0, 'got non-empty response');
362         like($buf, qr!HTTP/1\.\d 400 !, 'got 400 response');
363 }
364
365 {
366         my $conn = conn_for($sock, '1.1 chunked close trickle');
367         $conn->write("PUT /sha1 HTTP/1.1\r\nConnection:close\r\n");
368         $conn->write("Transfer-encoding: chunked\r\n\r\n");
369         foreach my $x ('a'..'z') {
370                 delay();
371                 $conn->write('1');
372                 delay();
373                 $conn->write("\r");
374                 delay();
375                 $conn->write("\n");
376                 delay();
377                 $conn->write($x);
378                 delay();
379                 $conn->write("\r");
380                 delay();
381                 $conn->write("\n");
382         }
383         $conn->write('0');
384         delay();
385         $conn->write("\r");
386         delay();
387         $conn->write("\n");
388         delay();
389         $conn->write("\r");
390         delay();
391         $conn->write("\n");
392         delay();
393         $check_self->($conn);
394 }
395
396 {
397         my $conn = conn_for($sock, '1.1 chunked close');
398         $conn->write("PUT /sha1 HTTP/1.1\r\nConnection:close\r\n");
399         my $xlen = sprintf('%x', $len);
400         $conn->write("Transfer-Encoding: chunked\r\n\r\n$xlen\r\n" .
401                 "$str\r\n0\r\n\r\n");
402         $check_self->($conn);
403 }
404
405 {
406         my $conn = conn_for($sock, 'chunked body + pipeline');
407         $conn->write("PUT /sha1 HTTP/1.1\r\n" .
408                         "Transfer-Encoding: chunked\r\n");
409         delay();
410         $conn->write("\r\n1\r\n");
411         delay();
412         $conn->write('a');
413         delay();
414         $conn->write("\r\n0\r\n\r\nPUT /sha1 HTTP/1.1\r\n");
415         delay();
416
417         my $buf = '';
418         until ($buf =~ /\r\n\r\n[a-f0-9]{40}\z/) {
419                 $conn->sysread(my $tmp, 4096);
420                 $buf .= $tmp;
421         }
422         my ($head, $body) = split(/\r\n\r\n/, $buf, 2);
423         like($head, qr/\r\nContent-Length: 40\r\n/s, 'got expected length');
424         is($body, sha1_hex('a'), 'read expected body');
425
426         $conn->write("Connection: close\r\n");
427         $conn->write("Content-Length: $len\r\n\r\n$str");
428         $check_self->($conn);
429 }
430
431 {
432         my $conn = conn_for($sock, 'trickle header, one-shot body + pipeline');
433         $conn->write("PUT /sha1 HTTP/1.0\r\n" .
434                         "Connection: keep-alive\r\n");
435         delay();
436         $conn->write("Content-Length: $len\r\n\r\n${str}PUT");
437         my $buf = '';
438         until ($buf =~ /\r\n\r\n[a-f0-9]{40}\z/) {
439                 $conn->sysread(my $tmp, 4096);
440                 $buf .= $tmp;
441         }
442         my ($head, $body) = split(/\r\n\r\n/, $buf, 2);
443         like($head, qr/\r\nContent-Length: 40\r\n/s, 'got expected length');
444         is($body, sha1_hex($str), 'read expected body');
445
446         $conn->write(" /sha1 HTTP/1.0\r\nContent-Length: $len\r\n\r\n$str");
447         $check_self->($conn);
448 }
449
450 {
451         my $conn = conn_for($sock, 'trickle body');
452         $conn->write("PUT /sha1 HTTP/1.0\r\n");
453         $conn->write("Content-Length: $len\r\n\r\n");
454         my $beg = substr($str, 0, 10);
455         my $end = substr($str, 10);
456         is($beg . $end, $str, 'substr setup correct');
457         delay();
458         $conn->write($beg);
459         delay();
460         $conn->write($end);
461         $check_self->($conn);
462 }
463
464 {
465         my $conn = conn_for($sock, 'one-shot write');
466         $conn->write("PUT /sha1 HTTP/1.0\r\n" .
467                         "Content-Length: $len\r\n\r\n$str");
468         $check_self->($conn);
469 }
470
471 {
472         my $conn = conn_for($sock, 'trickle header, one-shot body');
473         $conn->write("PUT /sha1 HTTP/1.0\r\n");
474         delay();
475         $conn->write("Content-Length: $len\r\n\r\n$str");
476         $check_self->($conn);
477 }
478
479 {
480         my $conn = conn_for($sock, '1.1 Connnection: close');
481         $conn->write("PUT /sha1 HTTP/1.1\r\nConnection:close\r\n");
482         delay();
483         $conn->write("Content-Length: $len\r\n\r\n$str");
484         $check_self->($conn);
485 }
486
487 {
488         my $conn = conn_for($sock, '1.1 pipeline start');
489         $conn->write("PUT /sha1 HTTP/1.1\r\n\r\nPUT");
490         my $buf = '';
491         until ($buf =~ /\r\n\r\n[a-f0-9]{40}\z/) {
492                 $conn->sysread(my $tmp, 4096);
493                 $buf .= $tmp;
494         }
495         my ($head, $body) = split(/\r\n\r\n/, $buf, 2);
496         like($head, qr/\r\nContent-Length: 40\r\n/s, 'got expected length');
497         is($body, sha1_hex(''), 'read expected body');
498
499         # 2nd request
500         $conn->write(" /sha1 HTTP/1.1\r\n\r\n");
501         $buf = '';
502         until ($buf =~ /\r\n\r\n[a-f0-9]{40}\z/) {
503                 $conn->sysread(my $tmp, 4096);
504                 $buf .= $tmp;
505         }
506         ($head, $body) = split(/\r\n\r\n/, $buf, 2);
507         like($head, qr/\r\nContent-Length: 40\r\n/s, 'got expected length');
508         is($body, sha1_hex(''), 'read expected body #2');
509 }
510
511 SKIP: {
512         skip 'TCP_DEFER_ACCEPT is Linux-only', 1 if $^O ne 'linux';
513         my $var = Socket::TCP_DEFER_ACCEPT();
514         defined(my $x = getsockopt($sock, IPPROTO_TCP, $var)) or die;
515         is(unpack('i', $x), $defer_accept_val,
516                 'TCP_DEFER_ACCEPT unchanged if previously set');
517 };
518
519 done_testing();
520
521 sub capture {
522         my ($f) = @_;
523         open my $fh, '+<', $f or die "failed to open $f: $!\n";
524         local $/ = "\n";
525         my @r = <$fh>;
526         truncate($fh, 0) or die "truncate failed on $f: $!\n";
527         \@r
528 }
529
530 1;