From 68a79d9fc914a4ad8b2dbd74c40a26ce50cb4225 Mon Sep 17 00:00:00 2001 From: Liu Dongmiao <473522+liudongmiao@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:10:00 +0800 Subject: [PATCH 1/2] Tests: websocket tests over HTTP/2 and HTTP/3. --- h2_proxy_websocket.t | 403 +++++++++++++++++++++++++++++++++++++++++ h3_proxy_websocket.t | 416 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 819 insertions(+) create mode 100644 h2_proxy_websocket.t create mode 100644 h3_proxy_websocket.t diff --git a/h2_proxy_websocket.t b/h2_proxy_websocket.t new file mode 100644 index 00000000..772527ec --- /dev/null +++ b/h2_proxy_websocket.t @@ -0,0 +1,403 @@ +#!/usr/bin/perl + +# (C) Maxim Dounin +# (C) Liu Dongmiao +# (C) Nginx, Inc. + +# Tests for HTTP/2 protocol with http proxy websockets support. + +############################################################################### + +use warnings; +use strict; + +use Test::More; + +use IO::Poll; +use IO::Socket::INET; + +BEGIN { use FindBin; chdir($FindBin::Bin); } + +use lib 'lib'; +use Test::Nginx; +use Test::Nginx::HTTP2; + +############################################################################### + +select STDERR; $| = 1; +select STDOUT; $| = 1; + +eval { + require Protocol::WebSocket::Handshake::Server; + require Protocol::WebSocket::Frame; +}; + +plan(skip_all => 'Protocol::WebSocket not installed') if $@; + +my $t = Test::Nginx->new()->has(qw/http http_v2 proxy/) + ->write_file_expand('nginx.conf', <<'EOF'); + +%%TEST_GLOBALS%% + +daemon off; + +events { +} + +http { + %%TEST_GLOBALS_HTTP%% + + http2 on; + + server { + listen 127.0.0.1:8080; + server_name localhost; + + http2_extended_connect on; + + location / { + accept_extended_connect on; + + proxy_pass http://127.0.0.1:8081; + proxy_http_version 1.1; + proxy_read_timeout 2s; + send_timeout 2s; + } + + location /no_upgrade { + accept_extended_connect on; + + proxy_pass http://127.0.0.1:8083; + proxy_http_version 1.1; + } + } + + server { + listen 127.0.0.1:8082; + server_name localhost; + + location / { + proxy_pass http://127.0.0.1:8081; + proxy_http_version 1.1; + } + } + + server { + listen 127.0.0.1:8083; + server_name localhost; + + location / { + return 200 "SEE-THIS"; + } + } +} + +EOF + +$t->run_daemon(\&websocket_fake_daemon, $t); +$t->try_run('no accept_extended_connect')->plan(38); + +$t->waitforsocket('127.0.0.1:' . port(8081)) + or die "Can't start test backend"; + +############################################################################### + +# establish websocket connection + +my $s = websocket_connect(); +ok($s, "websocket handshake"); + +SKIP: { + skip "handshake failed", 22 unless $s; + + # send a frame + + websocket_write($s, 'foo'); + is(websocket_read($s), 'bar', "websocket response"); + + # send some big frame + + websocket_write($s, 'foo' x 16384); + like(websocket_read($s), qr/^(bar){16384}$/, "websocket big response"); + + # send multiple frames + + for my $i (1 .. 10) { + websocket_write($s, ('foo' x 16384) . $i); + websocket_write($s, 'bazz' . $i); + } + + for my $i (1 .. 10) { + like(websocket_read($s), qr/^(bar){16384}\d+$/, "websocket $i"); + is(websocket_read($s), 'bazz' . $i, "websocket small $i"); + } +} + +# establish websocket connection with some pipelined data +# and make sure they are correctly passed upstream + +undef $s; +$s = websocket_connect("foo"); +ok($s, "handshake pipelined"); + +SKIP: { + skip "handshake failed", 2 unless $s; + + is(websocket_read($s), "bar", "response pipelined"); + + websocket_write($s, "foo"); + is(websocket_read($s), "bar", "next to pipelined"); +} + +# make sure the synthesized handshake is passed upstream +# and the upgrade headers are not returned to the client + +undef $s; +$s = websocket_connect(); +ok($s, "handshake headers"); + +SKIP: { + skip "handshake failed", 6 unless $s; + + is($s->{headers}->{'upgrade'}, undef, 'no upgrade header'); + is($s->{headers}->{'sec-websocket-accept'}, undef, 'no accept header'); + + my $r = $t->read_file('handshake'); + + like($r, qr!^GET / HTTP/1\.1!, 'upstream request line'); + like($r, qr/^Upgrade: websocket/mi, 'upstream upgrade header'); + is(scalar(() = $r =~ /^Sec-WebSocket-Key:/mig), 1, + 'upstream key header'); + like($r, qr/^Sec-WebSocket-Key: \S{24}\r$/mi, 'upstream key value'); +} + +is(connect_status(method => 'GET'), 400, 'protocol with GET'); +is(connect_status(port => 8082), 405, 'accept_extended_connect off'); + +# an upstream that answers 200 instead of 101 does not establish a tunnel, +# and its response is not returned to the client + +my ($status, $body) = connect_response(path => '/no_upgrade'); +is($status, 502, 'no upgrade'); +unlike($body, qr/SEE-THIS/, 'no upgrade body'); + +$s = Test::Nginx::HTTP2->new(port(8080), pure => 1); +my $frames = $s->read(all => [{ type => 'SETTINGS' }]); + +my ($frame) = grep { $_->{type} eq "SETTINGS" } @$frames; +is($frame->{8}, 1, 'settings enable connect protocol'); + +############################################################################### + +sub websocket_connect { + my ($message) = @_; + + my $s = Test::Nginx::HTTP2->new(); + my $sid = $s->new_stream({ body_more => 1, + headers => connect_headers() }); + + $s->h2_window(2**30, $sid); + $s->h2_window(2**30); + + $s->h2_body(Protocol::WebSocket::Frame->new($message)->to_bytes, + { body_more => 1 }) if defined $message; + + my $frames = $s->read(all => [{ sid => $sid, fin => 0x4 }]); + + my ($frame) = grep { $_->{type} eq "HEADERS" } @$frames; + return unless $frame && $frame->{headers}->{':status'} eq '200'; + + return { s => $s, sid => $sid, headers => $frame->{headers}, + frame => Protocol::WebSocket::Frame->new() }; +} + +sub websocket_write { + my ($c, $message) = @_; + my $buf = Protocol::WebSocket::Frame->new($message)->to_bytes; + my $s = $c->{s}; + + # unlike HTTP/1.1, sending is bounded by flow control, and h2_body() + # sends nothing at all if the windows are too small; wait for nginx + # to replenish them, keeping the data that arrives meanwhile + + for (1 .. 100) { + last if length($buf) <= $s->{conn_window} + && length($buf) <= $s->{streams}{$c->{sid}}; + + my $frames = $s->read(all => [{ type => 'WINDOW_UPDATE' }]); + + websocket_recv($c, $frames); + } + + $s->h2_body($buf, { body_more => 1 }); +} + +sub websocket_read { + my ($c) = @_; + + my $got = $c->{frame}->next(); + return $got if defined $got; + + for (1 .. 100) { + my $frames = $c->{s}->read(all => [{ sid => $c->{sid}, + type => 'DATA' }]); + + last unless websocket_recv($c, $frames); + + $got = $c->{frame}->next(); + return $got if defined $got; + } + + return $got; +} + +sub websocket_recv { + my ($c, $frames) = @_; + my $data = ''; + + $data .= $_->{data} for grep { $_->{type} eq "DATA" } @$frames; + + # note that append() clears its argument + + my $len = length $data; + $c->{frame}->append($data) if $len; + + return $len; +} + +sub connect_headers { + my (%extra) = @_; + my $method = $extra{method} || 'CONNECT'; + my $protocol = $extra{protocol} || 'websocket'; + my $path = $extra{path} || '/'; + + return [ + { name => ':method', value => $method, mode => 1 }, + { name => ':protocol', value => $protocol, mode => 2 }, + { name => ':scheme', value => 'http', mode => 1 }, + { name => ':path', value => $path, mode => 1 }, + { name => ':authority', value => 'localhost', mode => 1 }, + { name => 'sec-websocket-version', value => '13', mode => 2 }, + { name => 'sec-websocket-key', value => 'ignored', mode => 2 }]; +} + +sub connect_status { + my ($status) = connect_response(@_); + return $status; +} + +sub connect_response { + my (%extra) = @_; + + my $s = Test::Nginx::HTTP2->new($extra{port} ? port($extra{port}) : ()); + my $sid = $s->new_stream({ headers => connect_headers(%extra) }); + my $frames = $s->read(all => [{ sid => $sid, fin => 1 }]); + + my ($frame) = grep { $_->{type} eq "HEADERS" } @$frames; + my $body = join '', map { $_->{data} } + grep { $_->{type} eq "DATA" } @$frames; + + return ($frame->{headers}->{':status'}, $body); +} + +############################################################################### + +sub websocket_fake_daemon { + my ($t) = @_; + + my $server = IO::Socket::INET->new( + Proto => 'tcp', + LocalAddr => '127.0.0.1:' . port(8081), + Listen => 5, + Reuse => 1 + ) + or die "Can't create listening socket: $!\n"; + + while (my $client = $server->accept()) { + websocket_handle_client($client, $t); + } +} + +sub websocket_handle_client { + my ($client, $t) = @_; + + $client->autoflush(1); + $client->blocking(0); + + my $poll = IO::Poll->new; + + my $hs = Protocol::WebSocket::Handshake::Server->new; + my $frame = Protocol::WebSocket::Frame->new; + my $buffer = ''; + my $handshake = ''; + my $closed; + my $n; + + log2c("(new connection $client)"); + + while (1) { + $poll->mask($client => ($buffer ? POLLIN|POLLOUT : POLLIN)); + my $p = $poll->poll(0.5); + log2c("(poll $p)"); + + foreach ($poll->handles(POLLIN)) { + $n = $client->sysread(my $chunk, 65536); + return unless $n; + + log2i($chunk); + + if (!$hs->is_done) { + $handshake .= $chunk; + + unless (defined $hs->parse($chunk)) { + log2c("(error: " . $hs->error . ")"); + return; + } + + if ($hs->is_done) { + $t->write_file('handshake', $handshake); + $buffer = $hs->to_string; + log2o($buffer); + } + + log2c("(parse: $chunk)"); + } + + $frame->append($chunk); + + while (defined(my $message = $frame->next)) { + my $f; + + if ($frame->is_close) { + log2c("(close frame)"); + $closed = 1; + $f = $frame->new(type => 'close') + ->to_bytes; + } else { + $message =~ s/foo/bar/g; + $f = $frame->new($message)->to_bytes; + } + + log2o($f); + $buffer .= $f; + } + } + + foreach my $writer ($poll->handles(POLLOUT)) { + next unless length $buffer; + $n = $writer->syswrite($buffer); + substr $buffer, 0, $n, ''; + } + + if ($closed && length $buffer == 0) { + log2c("(closed)"); + return; + } + } +} + +sub log2i { Test::Nginx::log_core('|| <<', @_); } +sub log2o { Test::Nginx::log_core('|| >>', @_); } +sub log2c { Test::Nginx::log_core('||', @_); } + +############################################################################### diff --git a/h3_proxy_websocket.t b/h3_proxy_websocket.t new file mode 100644 index 00000000..32063b2b --- /dev/null +++ b/h3_proxy_websocket.t @@ -0,0 +1,416 @@ +#!/usr/bin/perl + +# (C) Maxim Dounin +# (C) Liu Dongmiao +# (C) Nginx, Inc. + +# Tests for HTTP/3 protocol with http proxy websockets support. + +############################################################################### + +use warnings; +use strict; + +use Test::More; + +use IO::Poll; +use IO::Socket::INET; + +BEGIN { use FindBin; chdir($FindBin::Bin); } + +use lib 'lib'; +use Test::Nginx; +use Test::Nginx::HTTP3; + +############################################################################### + +select STDERR; $| = 1; +select STDOUT; $| = 1; + +eval { + require Protocol::WebSocket::Handshake::Server; + require Protocol::WebSocket::Frame; +}; + +plan(skip_all => 'Protocol::WebSocket not installed') if $@; + +my $t = Test::Nginx->new()->has(qw/http http_v3 proxy cryptx/) + ->has_daemon('openssl') + ->write_file_expand('nginx.conf', <<'EOF'); + +%%TEST_GLOBALS%% + +daemon off; + +events { +} + +http { + %%TEST_GLOBALS_HTTP%% + + ssl_certificate_key localhost.key; + ssl_certificate localhost.crt; + + server { + listen 127.0.0.1:%%PORT_8980_UDP%% quic; + server_name localhost; + + http3_extended_connect on; + + location / { + accept_extended_connect on; + + proxy_pass http://127.0.0.1:8081; + proxy_http_version 1.1; + proxy_read_timeout 2s; + send_timeout 2s; + } + + location /no_upgrade { + accept_extended_connect on; + + proxy_pass http://127.0.0.1:8083; + proxy_http_version 1.1; + } + } + + server { + listen 127.0.0.1:%%PORT_8981_UDP%% quic; + server_name localhost; + + location / { + proxy_pass http://127.0.0.1:8081; + proxy_http_version 1.1; + } + } + + server { + listen 127.0.0.1:8083; + server_name localhost; + + location / { + return 200 "SEE-THIS"; + } + } +} + +EOF + +$t->write_file('openssl.conf', <testdir(); + +foreach my $name ('localhost') { + system('openssl req -x509 -new ' + . "-config $d/openssl.conf -subj /CN=$name/ " + . "-out $d/$name.crt -keyout $d/$name.key " + . ">>$d/openssl.out 2>&1") == 0 + or die "Can't create certificate for $name: $!\n"; +} + +$t->run_daemon(\&websocket_fake_daemon, $t); +$t->try_run('no accept_extended_connect')->plan(38); + +$t->waitforsocket('127.0.0.1:' . port(8081)) + or die "Can't start test backend"; + +############################################################################### + +# establish websocket connection + +my $s = websocket_connect(); +ok($s, "websocket handshake"); + +SKIP: { + skip "handshake failed", 22 unless $s; + + # send a frame + + websocket_write($s, 'foo'); + is(websocket_read($s), 'bar', "websocket response"); + + # send some big frame; Test::Nginx::HTTP3 neither paces nor + # retransmits, so large transfers are unreliable, hence the + # smaller size + + websocket_write($s, 'foo' x 300); + like(websocket_read($s), qr/^(bar){300}$/, "websocket big response"); + + # send multiple frames + + for my $i (1 .. 10) { + websocket_write($s, ('foo' x 300) . $i); + websocket_write($s, 'bazz' . $i); + } + + for my $i (1 .. 10) { + like(websocket_read($s), qr/^(bar){300}\d+$/, "websocket $i"); + is(websocket_read($s), 'bazz' . $i, "websocket small $i"); + } +} + +# establish websocket connection with some pipelined data +# and make sure they are correctly passed upstream + +undef $s; +$s = websocket_connect("foo"); +ok($s, "handshake pipelined"); + +SKIP: { + skip "handshake failed", 2 unless $s; + + is(websocket_read($s), "bar", "response pipelined"); + + websocket_write($s, "foo"); + is(websocket_read($s), "bar", "next to pipelined"); +} + +# make sure the synthesized handshake is passed upstream +# and the upgrade headers are not returned to the client + +undef $s; +$s = websocket_connect(); +ok($s, "handshake headers"); + +SKIP: { + skip "handshake failed", 6 unless $s; + + is($s->{headers}->{'upgrade'}, undef, 'no upgrade header'); + is($s->{headers}->{'sec-websocket-accept'}, undef, 'no accept header'); + + my $r = $t->read_file('handshake'); + + like($r, qr!^GET / HTTP/1\.1!, 'upstream request line'); + like($r, qr/^Upgrade: websocket/mi, 'upstream upgrade header'); + is(scalar(() = $r =~ /^Sec-WebSocket-Key:/mig), 1, + 'upstream key header'); + like($r, qr/^Sec-WebSocket-Key: \S{24}\r$/mi, 'upstream key value'); +} + +is(connect_status(method => 'GET'), 400, 'protocol with GET'); +is(connect_status(port => 8981), 405, 'accept_extended_connect off'); + +# an upstream that answers 200 instead of 101 does not establish a tunnel, +# and its response is not returned to the client + +my ($status, $body) = connect_response(path => '/no_upgrade'); +is($status, 502, 'no upgrade'); +unlike($body, qr/SEE-THIS/, 'no upgrade body'); + +my $c = Test::Nginx::HTTP3->new(8980); +my $frames = $c->read(all => [{ type => 'SETTINGS' }]); + +my ($frame) = grep { $_->{type} eq "SETTINGS" } @$frames; +is($frame->{8}, 1, 'settings enable connect protocol'); + +############################################################################### + +sub websocket_connect { + my ($message) = @_; + + my $s = Test::Nginx::HTTP3->new(); + my $sid = $s->new_stream({ body_more => 1, + headers => connect_headers() }); + + $s->h3_max_data(2**30, $sid); + $s->h3_max_data(2**30); + + $s->h3_body(Protocol::WebSocket::Frame->new($message)->to_bytes, + $sid, { body_more => 1 }) if defined $message; + + my $frames = $s->read(all => [{ sid => $sid, type => 'HEADERS' }]); + + my ($frame) = grep { $_->{type} eq "HEADERS" } @$frames; + return unless $frame && $frame->{headers}->{':status'} eq '200'; + + return { s => $s, sid => $sid, headers => $frame->{headers}, + frame => Protocol::WebSocket::Frame->new() }; +} + +sub websocket_write { + my ($c, $message) = @_; + my $buf = Protocol::WebSocket::Frame->new($message)->to_bytes; + + # unlike HTTP/1.1, the data has to fit into QUIC packets + + while (length $buf) { + $c->{s}->h3_body(substr($buf, 0, 1200, ''), $c->{sid}, + { body_more => 1 }); + } +} + +sub websocket_read { + my ($c) = @_; + + my $got = $c->{frame}->next(); + return $got if defined $got; + + for (1 .. 100) { + my $frames = $c->{s}->read(all => [{ sid => $c->{sid}, + type => 'DATA' }]); + + last unless websocket_recv($c, $frames); + + $got = $c->{frame}->next(); + return $got if defined $got; + } + + return $got; +} + +sub websocket_recv { + my ($c, $frames) = @_; + my $data = ''; + + $data .= $_->{data} for grep { $_->{type} eq "DATA" } @$frames; + + # note that append() clears its argument + + my $len = length $data; + $c->{frame}->append($data) if $len; + + return $len; +} + +sub connect_headers { + my (%extra) = @_; + my $method = $extra{method} || 'CONNECT'; + my $protocol = $extra{protocol} || 'websocket'; + my $path = $extra{path} || '/'; + + return [ + { name => ':method', value => $method }, + { name => ':protocol', value => $protocol }, + { name => ':scheme', value => 'https' }, + { name => ':path', value => $path }, + { name => ':authority', value => 'localhost' }, + { name => 'sec-websocket-version', value => '13' }, + { name => 'sec-websocket-key', value => 'ignored' }]; +} + +sub connect_status { + my ($status) = connect_response(@_); + return $status; +} + +sub connect_response { + my (%extra) = @_; + + my $s = Test::Nginx::HTTP3->new($extra{port} || 8980); + my $sid = $s->new_stream({ headers => connect_headers(%extra) }); + my $frames = $s->read(all => [{ sid => $sid, fin => 1 }]); + + my ($frame) = grep { $_->{type} eq "HEADERS" } @$frames; + my $body = join '', map { $_->{data} } + grep { $_->{type} eq "DATA" } @$frames; + + return ($frame->{headers}->{':status'}, $body); +} + +############################################################################### + +sub websocket_fake_daemon { + my ($t) = @_; + + my $server = IO::Socket::INET->new( + Proto => 'tcp', + LocalAddr => '127.0.0.1:' . port(8081), + Listen => 5, + Reuse => 1 + ) + or die "Can't create listening socket: $!\n"; + + while (my $client = $server->accept()) { + websocket_handle_client($client, $t); + } +} + +sub websocket_handle_client { + my ($client, $t) = @_; + + $client->autoflush(1); + $client->blocking(0); + + my $poll = IO::Poll->new; + + my $hs = Protocol::WebSocket::Handshake::Server->new; + my $frame = Protocol::WebSocket::Frame->new; + my $buffer = ''; + my $handshake = ''; + my $closed; + my $n; + + log2c("(new connection $client)"); + + while (1) { + $poll->mask($client => ($buffer ? POLLIN|POLLOUT : POLLIN)); + my $p = $poll->poll(0.5); + log2c("(poll $p)"); + + foreach ($poll->handles(POLLIN)) { + $n = $client->sysread(my $chunk, 65536); + return unless $n; + + log2i($chunk); + + if (!$hs->is_done) { + $handshake .= $chunk; + + unless (defined $hs->parse($chunk)) { + log2c("(error: " . $hs->error . ")"); + return; + } + + if ($hs->is_done) { + $t->write_file('handshake', $handshake); + $buffer = $hs->to_string; + log2o($buffer); + } + + log2c("(parse: $chunk)"); + } + + $frame->append($chunk); + + while (defined(my $message = $frame->next)) { + my $f; + + if ($frame->is_close) { + log2c("(close frame)"); + $closed = 1; + $f = $frame->new(type => 'close') + ->to_bytes; + } else { + $message =~ s/foo/bar/g; + $f = $frame->new($message)->to_bytes; + } + + log2o($f); + $buffer .= $f; + } + } + + foreach my $writer ($poll->handles(POLLOUT)) { + next unless length $buffer; + $n = $writer->syswrite($buffer); + substr $buffer, 0, $n, ''; + } + + if ($closed && length $buffer == 0) { + log2c("(closed)"); + return; + } + } +} + +sub log2i { Test::Nginx::log_core('|| <<', @_); } +sub log2o { Test::Nginx::log_core('|| >>', @_); } +sub log2c { Test::Nginx::log_core('||', @_); } + +############################################################################### From 74b6733c7905413acb8127c46aaef10ff870ba3d Mon Sep 17 00:00:00 2001 From: Liu Dongmiao <473522+liudongmiao@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:20:00 +0800 Subject: [PATCH 2/2] Tests: tunnel tests over HTTP/2 and HTTP/3. The h2.t change reflects that CONNECT with ":scheme" and ":path" is now rejected as malformed instead of not allowed. --- h2.t | 12 ++- h2_tunnel.t | 272 ++++++++++++++++++++++++++++++++++++++++++++++++ h3_tunnel.t | 292 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 575 insertions(+), 1 deletion(-) create mode 100644 h2_tunnel.t create mode 100644 h3_tunnel.t diff --git a/h2.t b/h2.t index db83eb9e..a5da7bdd 100644 --- a/h2.t +++ b/h2.t @@ -26,7 +26,7 @@ select STDERR; $| = 1; select STDOUT; $| = 1; my $t = Test::Nginx->new()->has(qw/http http_v2 proxy rewrite charset gzip/) - ->plan(143); + ->plan(144); $t->write_file_expand('nginx.conf', <<'EOF'); @@ -306,6 +306,16 @@ $s = Test::Nginx::HTTP2->new(); $sid = $s->new_stream({ method => 'CONNECT' }); $frames = $s->read(all => [{ sid => $sid, fin => 1 }]); +($frame) = grep { $_->{type} eq "HEADERS" } @$frames; +is($frame->{headers}->{':status'}, 400, 'CONNECT with scheme and path'); + +$s = Test::Nginx::HTTP2->new(); +$sid = $s->new_stream({ headers => [ + { name => ':method', value => 'CONNECT', mode => 1 }, + { name => ':authority', value => 'localhost:' . port(8080), + mode => 1 }]}); +$frames = $s->read(all => [{ sid => $sid, fin => 1 }]); + ($frame) = grep { $_->{type} eq "HEADERS" } @$frames; is($frame->{headers}->{':status'}, 405, 'CONNECT - not allowed'); diff --git a/h2_tunnel.t b/h2_tunnel.t new file mode 100644 index 00000000..a5aa3100 --- /dev/null +++ b/h2_tunnel.t @@ -0,0 +1,272 @@ +#!/usr/bin/perl + +# (C) Eugene Grebenschikov +# (C) Liu Dongmiao +# (C) Nginx, Inc. + +# Tests for HTTP/2 protocol with http tunnel module. + +############################################################################### + +use warnings; +use strict; + +use Test::More; + +use Socket qw/ CRLF /; + +BEGIN { use FindBin; chdir($FindBin::Bin); } + +use lib 'lib'; +use Test::Nginx; +use Test::Nginx::HTTP2; + +############################################################################### + +select STDERR; $| = 1; +select STDOUT; $| = 1; + +my $t = Test::Nginx->new()->has(qw/http http_v2 tunnel rewrite/) + ->write_file_expand('nginx.conf', <<'EOF'); + +%%TEST_GLOBALS%% + +daemon off; + +events { +} + +http { + %%TEST_GLOBALS_HTTP%% + + http2 on; + + upstream tunnel_upstream { + server 127.0.0.1:8083; + } + + resolver 127.0.0.1:%%PORT_8987_UDP%%; + resolver_timeout 1s; + + tunnel_read_timeout 1s; + tunnel_connect_timeout 1s; + + server { + listen 127.0.0.1:8080; + server_name localhost; + + location / { + if ($request_method = CONNECT) { + tunnel_pass; + error_page 502 504 /50x.html; + break; + } + } + } + + server { + listen 127.0.0.1:8081; + server_name localhost; + + tunnel_pass tunnel_upstream; + } + + server { + listen 127.0.0.1:8082; + server_name localhost; + + tunnel_pass $host:$request_port; + } + + server { + listen 127.0.0.1:8083; + server_name localhost; + + location / { + return 200 "SEE-THIS"; + } + } +} + +EOF + +$t->write_file('index.html', 'SUCCESS'); +$t->write_file('50x.html', 'ERROR'); + +$t->run_daemon(\&dns_daemon, $t)->waitforfile($t->testdir . '/' . port(8987)) + or die "dns daemon failed to start\n"; + +$t->try_run('no tunnel')->plan(21); + +############################################################################### + +my $p = port(8083); + +like(http2_get('/'), qr/SUCCESS/, 'GET'); +like(proxy_get('/', "127.0.0.1:$p", port(8080)), qr/SEE-THIS/, 'CONNECT IP'); +like(proxy_get('/', "example.net:$p", port(8080)), + qr/SEE-THIS/, 'CONNECT hostname'); +like(proxy_get('/', "example.net:$p/", port(8080)), + qr/400/, 'CONNECT authority with path'); +like(proxy_get('/', "example.net:$p?", port(8080)), + qr/400/, 'CONNECT authority with query'); +like(proxy_get('/', "example.net:$p#", port(8080)), + qr/400/, 'CONNECT authority with fragment'); +like(proxy_get('/', "user:pass\@example.net:$p", port(8080)), + qr/400/, 'CONNECT authority with userinfo'); +like(proxy_get('/', "http://example.net:$p", port(8080)), + qr/400/, 'CONNECT authority with scheme'); +like(proxy_get('/', 'example.net', port(8080)), + qr/400/, 'CONNECT no colon'); +like(proxy_get('/', 'example.net:', port(8080)), + qr/400/, 'CONNECT no port'); +like(proxy_get('/', ":$p", port(8080)), + qr/400/, 'CONNECT no host'); +like(proxy_get('/', ':', port(8080)), + qr/400/, 'CONNECT no host and port'); +like(proxy_get('/', 'example.net:65536', port(8080)), + qr/400/, 'CONNECT wrong port'); +like(proxy_get('/', 'example.net:0', port(8080)), + qr/400/, 'CONNECT zero port'); +like(proxy_get('/', 'example.net:$p', port(8080)), + qr/400/, 'CONNECT rubbish port'); +like(proxy_get('/', undef, port(8080)), + qr/400/, 'CONNECT no authority'); +like(proxy_get('/', "127.0.0.1:$p", port(8080), scheme => 'http'), + qr/400/, 'CONNECT with scheme'); +like(proxy_get('/', "127.0.0.1:$p", port(8080), path => '/'), + qr/400/, 'CONNECT with path'); +like(proxy_get('/', '127.0.0.1:' . port(8084), port(8080)), qr/ERROR/, + 'tunnel error page'); +like(proxy_get('/', '127.0.0.3:80', port(8081)), + qr/SEE-THIS/, 'tunnel static upstream'); +like(proxy_get('/', "example.net:$p", port(8082)), + qr/SEE-THIS/, 'tunnel explicit'); + +############################################################################### + +sub proxy_get { + my ($uri, $host, $proxy_port, %extra) = @_; + + my $s = Test::Nginx::HTTP2->new($proxy_port); + my $sid = $s->new_stream({ body_more => 1, + headers => connect_headers($host, %extra) }); + + my $req = "GET $uri HTTP/1.0" . CRLF . 'Host: localhost' . CRLF . CRLF; + + $s->h2_body($req, { body_more => 1 }); + + my $frames = $s->read(all => [{ sid => $sid, type => 'HEADERS' }, + { sid => $sid, type => 'DATA' }]); + + my ($frame) = grep { $_->{type} eq "HEADERS" } @$frames; + my $status = $frame->{headers}->{':status'}; + + my $body = $status eq '200' ? '' : $status; + $body .= $_->{data} for grep { $_->{type} eq "DATA" } @$frames; + + return $body; +} + +sub connect_headers { + my ($authority, %extra) = @_; + my @h = ({ name => ':method', value => 'CONNECT', mode => 1 }); + + push @h, { name => ':scheme', value => $extra{scheme}, mode => 1 } + if defined $extra{scheme}; + push @h, { name => ':path', value => $extra{path}, mode => 1 } + if defined $extra{path}; + push @h, { name => ':authority', value => $authority, mode => 1 } + if defined $authority; + + return \@h; +} + +sub http2_get { + my ($uri) = @_; + + my $s = Test::Nginx::HTTP2->new(port(8080)); + my $sid = $s->new_stream({ path => $uri }); + my $frames = $s->read(all => [{ sid => $sid, fin => 1 }]); + + my $body = ''; + $body .= $_->{data} for grep { $_->{type} eq "DATA" } @$frames; + + return $body; +} + +############################################################################### + +sub reply_handler { + my ($recv_data, $port, %extra) = @_; + + my (@name, @rdata); + + use constant NOERROR => 0; + use constant A => 1; + use constant IN => 1; + + # default values + + my ($hdr, $rcode, $ttl) = (0x8180, NOERROR, 3600); + + # decode name + + my ($len, $offset) = (undef, 12); + while (1) { + $len = unpack("\@$offset C", $recv_data); + last if $len == 0; + $offset++; + push @name, unpack("\@$offset A$len", $recv_data); + $offset += $len; + } + + $offset -= 1; + my ($id, $type, $class) = unpack("n x$offset n2", $recv_data); + + my $name = join('.', @name); + if ($name eq 'example.net') { + if ($type == A) { + push @rdata, rd_addr($ttl, '127.0.0.1'); + } + } + + $len = @name; + pack("n6 (C/a*)$len x n2", $id, $hdr | $rcode, 1, scalar @rdata, + 0, 0, @name, $type, $class) . join('', @rdata); +} + +sub rd_addr { + my ($ttl, $addr) = @_; + + my $code = 'split(/\./, $addr)'; + + return pack 'n3N', 0xc00c, A, IN, $ttl if $addr eq ''; + + pack 'n3N nC4', 0xc00c, A, IN, $ttl, eval "scalar $code", eval($code); +} + +sub dns_daemon { + my ($t) = @_; + + my ($data, $recv_data); + my $socket = IO::Socket::INET->new( + LocalAddr => '127.0.0.1', + LocalPort => port(8987), + Proto => 'udp', + ) + or die "Can't create listening socket: $!\n"; + + # signal we are ready + + open my $fh, '>', $t->testdir() . '/' . port(8987); + close $fh; + + while (1) { + $socket->recv($recv_data, 65536); + $data = reply_handler($recv_data); + $socket->send($data); + } +} + +############################################################################### diff --git a/h3_tunnel.t b/h3_tunnel.t new file mode 100644 index 00000000..f0d47617 --- /dev/null +++ b/h3_tunnel.t @@ -0,0 +1,292 @@ +#!/usr/bin/perl + +# (C) Eugene Grebenschikov +# (C) Liu Dongmiao +# (C) Nginx, Inc. + +# Tests for HTTP/3 protocol with http tunnel module. + +############################################################################### + +use warnings; +use strict; + +use Test::More; + +use Socket qw/ CRLF /; + +BEGIN { use FindBin; chdir($FindBin::Bin); } + +use lib 'lib'; +use Test::Nginx; +use Test::Nginx::HTTP3; + +############################################################################### + +select STDERR; $| = 1; +select STDOUT; $| = 1; + +my $t = Test::Nginx->new()->has(qw/http http_v3 tunnel rewrite cryptx/) + ->has_daemon('openssl') + ->write_file_expand('nginx.conf', <<'EOF'); + +%%TEST_GLOBALS%% + +daemon off; + +events { +} + +http { + %%TEST_GLOBALS_HTTP%% + + ssl_certificate_key localhost.key; + ssl_certificate localhost.crt; + + upstream tunnel_upstream { + server 127.0.0.1:8083; + } + + resolver 127.0.0.1:%%PORT_8987_UDP%%; + resolver_timeout 1s; + + tunnel_read_timeout 1s; + tunnel_connect_timeout 1s; + + server { + listen 127.0.0.1:%%PORT_8980_UDP%% quic; + server_name localhost; + + location / { + if ($request_method = CONNECT) { + tunnel_pass; + error_page 502 504 /50x.html; + break; + } + } + } + + server { + listen 127.0.0.1:%%PORT_8981_UDP%% quic; + server_name localhost; + + tunnel_pass tunnel_upstream; + } + + server { + listen 127.0.0.1:%%PORT_8982_UDP%% quic; + server_name localhost; + + tunnel_pass $host:$request_port; + } + + server { + listen 127.0.0.1:8083; + server_name localhost; + + location / { + return 200 "SEE-THIS"; + } + } +} + +EOF + +$t->write_file('index.html', 'SUCCESS'); +$t->write_file('50x.html', 'ERROR'); + +$t->write_file('openssl.conf', <testdir(); + +foreach my $name ('localhost') { + system('openssl req -x509 -new ' + . "-config $d/openssl.conf -subj /CN=$name/ " + . "-out $d/$name.crt -keyout $d/$name.key " + . ">>$d/openssl.out 2>&1") == 0 + or die "Can't create certificate for $name: $!\n"; +} + +$t->run_daemon(\&dns_daemon, $t)->waitforfile($t->testdir . '/' . port(8987)) + or die "dns daemon failed to start\n"; + +$t->try_run('no tunnel')->plan(21); + +############################################################################### + +my $p = port(8083); + +like(http3_get('/'), qr/SUCCESS/, 'GET'); +like(proxy_get('/', "127.0.0.1:$p", 8980), qr/SEE-THIS/, 'CONNECT IP'); +like(proxy_get('/', "example.net:$p", 8980), + qr/SEE-THIS/, 'CONNECT hostname'); +like(proxy_get('/', "example.net:$p/", 8980), + qr/400/, 'CONNECT authority with path'); +like(proxy_get('/', "example.net:$p?", 8980), + qr/400/, 'CONNECT authority with query'); +like(proxy_get('/', "example.net:$p#", 8980), + qr/400/, 'CONNECT authority with fragment'); +like(proxy_get('/', "user:pass\@example.net:$p", 8980), + qr/400/, 'CONNECT authority with userinfo'); +like(proxy_get('/', "http://example.net:$p", 8980), + qr/400/, 'CONNECT authority with scheme'); +like(proxy_get('/', 'example.net', 8980), + qr/400/, 'CONNECT no colon'); +like(proxy_get('/', 'example.net:', 8980), + qr/400/, 'CONNECT no port'); +like(proxy_get('/', ":$p", 8980), + qr/400/, 'CONNECT no host'); +like(proxy_get('/', ':', 8980), + qr/400/, 'CONNECT no host and port'); +like(proxy_get('/', 'example.net:65536', 8980), + qr/400/, 'CONNECT wrong port'); +like(proxy_get('/', 'example.net:0', 8980), + qr/400/, 'CONNECT zero port'); +like(proxy_get('/', 'example.net:$p', 8980), + qr/400/, 'CONNECT rubbish port'); +like(proxy_get('/', undef, 8980), + qr/400/, 'CONNECT no authority'); +like(proxy_get('/', "127.0.0.1:$p", 8980, scheme => 'http'), + qr/400/, 'CONNECT with scheme'); +like(proxy_get('/', "127.0.0.1:$p", 8980, path => '/'), + qr/400/, 'CONNECT with path'); +like(proxy_get('/', '127.0.0.1:' . port(8084), 8980), qr/ERROR/, + 'tunnel error page'); +like(proxy_get('/', '127.0.0.3:80', 8981), + qr/SEE-THIS/, 'tunnel static upstream'); +like(proxy_get('/', "example.net:$p", 8982), + qr/SEE-THIS/, 'tunnel explicit'); + +############################################################################### + +sub proxy_get { + my ($uri, $host, $proxy_port, %extra) = @_; + + my $s = Test::Nginx::HTTP3->new($proxy_port); + my $sid = $s->new_stream({ body_more => 1, + headers => connect_headers($host, %extra) }); + + my $req = "GET $uri HTTP/1.0" . CRLF . 'Host: localhost' . CRLF . CRLF; + + $s->h3_body($req, $sid, { body_more => 1 }); + + my $frames = $s->read(all => [{ sid => $sid, type => 'HEADERS' }, + { sid => $sid, type => 'DATA' }]); + + my ($frame) = grep { $_->{type} eq "HEADERS" } @$frames; + my $status = $frame->{headers}->{':status'}; + + my $body = $status eq '200' ? '' : $status; + $body .= $_->{data} for grep { $_->{type} eq "DATA" } @$frames; + + return $body; +} + +sub connect_headers { + my ($authority, %extra) = @_; + my @h = ({ name => ':method', value => 'CONNECT' }); + + push @h, { name => ':scheme', value => $extra{scheme} } + if defined $extra{scheme}; + push @h, { name => ':path', value => $extra{path} } + if defined $extra{path}; + push @h, { name => ':authority', value => $authority } + if defined $authority; + + return \@h; +} + +sub http3_get { + my ($uri) = @_; + + my $s = Test::Nginx::HTTP3->new(8980); + my $sid = $s->new_stream({ path => $uri }); + my $frames = $s->read(all => [{ sid => $sid, fin => 1 }]); + + my $body = ''; + $body .= $_->{data} for grep { $_->{type} eq "DATA" } @$frames; + + return $body; +} + +############################################################################### + +sub reply_handler { + my ($recv_data, $port, %extra) = @_; + + my (@name, @rdata); + + use constant NOERROR => 0; + use constant A => 1; + use constant IN => 1; + + # default values + + my ($hdr, $rcode, $ttl) = (0x8180, NOERROR, 3600); + + # decode name + + my ($len, $offset) = (undef, 12); + while (1) { + $len = unpack("\@$offset C", $recv_data); + last if $len == 0; + $offset++; + push @name, unpack("\@$offset A$len", $recv_data); + $offset += $len; + } + + $offset -= 1; + my ($id, $type, $class) = unpack("n x$offset n2", $recv_data); + + my $name = join('.', @name); + if ($name eq 'example.net') { + if ($type == A) { + push @rdata, rd_addr($ttl, '127.0.0.1'); + } + } + + $len = @name; + pack("n6 (C/a*)$len x n2", $id, $hdr | $rcode, 1, scalar @rdata, + 0, 0, @name, $type, $class) . join('', @rdata); +} + +sub rd_addr { + my ($ttl, $addr) = @_; + + my $code = 'split(/\./, $addr)'; + + return pack 'n3N', 0xc00c, A, IN, $ttl if $addr eq ''; + + pack 'n3N nC4', 0xc00c, A, IN, $ttl, eval "scalar $code", eval($code); +} + +sub dns_daemon { + my ($t) = @_; + + my ($data, $recv_data); + my $socket = IO::Socket::INET->new( + LocalAddr => '127.0.0.1', + LocalPort => port(8987), + Proto => 'udp', + ) + or die "Can't create listening socket: $!\n"; + + # signal we are ready + + open my $fh, '>', $t->testdir() . '/' . port(8987); + close $fh; + + while (1) { + $socket->recv($recv_data, 65536); + $data = reply_handler($recv_data); + $socket->send($data); + } +} + +###############################################################################