diff --git a/forward_proxy.t b/forward_proxy.t new file mode 100644 index 00000000..53faabb7 --- /dev/null +++ b/forward_proxy.t @@ -0,0 +1,409 @@ +#!/usr/bin/perl + +# Tests for forward proxy support in proxy module. + +############################################################################### + +use warnings; +use strict; + +use Test::More; + +use IO::Socket::INET; +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; + +plan(skip_all => 'win32') if $^O eq 'MSWin32'; + +my $t = Test::Nginx->new()->has(qw/http proxy http_v2/)->plan(19); + +$t->write_file_expand('nginx.conf', <<'EOF'); + +%%TEST_GLOBALS%% + +daemon off; + +events { +} + +http { + %%TEST_GLOBALS_HTTP%% + + server { + listen 127.0.0.1:8080; + server_name localhost; + + location / { + forward_proxy on; + resolver 127.0.0.1:%%PORT_8980_UDP%% ipv6=off; + resolver_timeout 2s; + proxy_connect_timeout 2s; + proxy_read_timeout 2s; + proxy_send_timeout 2s; + } + } + + server { + listen 127.0.0.1:8082; + server_name localhost; + + http2 on; + + location / { + forward_proxy on; + resolver 127.0.0.1:%%PORT_8980_UDP%% ipv6=off; + resolver_timeout 2s; + proxy_connect_timeout 2s; + proxy_read_timeout 2s; + proxy_send_timeout 2s; + } + } + + server { + listen 127.0.0.1:8083; + server_name localhost; + + location / { + forward_proxy on; + proxy_connect_timeout 2s; + proxy_read_timeout 2s; + proxy_send_timeout 2s; + } + } + + server { + listen 127.0.0.1:8084; + server_name localhost; + + http2 on; + + location / { + forward_proxy on; + proxy_connect_timeout 2s; + proxy_read_timeout 2s; + proxy_send_timeout 2s; + } + } +} + +EOF + +my $dns_ready = $t->testdir() . '/dns.ready'; + +$t->run_daemon(\&origin_daemon, port(8081)); +$t->run_daemon(\&dns_daemon, $t, port(8980), $dns_ready); +$t->run(); +$t->waitforsocket('127.0.0.1:' . port(8081)); +$t->waitforfile($dns_ready) or die "Can't start dns daemon"; + +############################################################################### + +my $p = port(8081); + +my $r = http(<<"EOF"); +GET http://127.0.0.1:$p/path?foo=bar HTTP/1.1 +Host: wrong.example +Connection: close + +EOF +like($r, qr/^HTTP\/1\.1 200 OK/ms, 'h1 absolute-form GET'); +like($r, qr/X-URI: \/path\?foo=bar/i, 'h1 rewrites to origin-form'); +like($r, qr/X-Host: 127\.0\.0\.1:$p/i, 'h1 rewrites Host from target'); + +$r = http(<<"EOF"); +HEAD http://127.0.0.1:$p/head HTTP/1.1 +Host: ignored.example +Connection: close + +EOF +like($r, qr/X-Method: HEAD/i, 'h1 absolute-form HEAD'); +unlike($r, qr/body=HEAD/ms, 'h1 HEAD suppresses upstream body'); + +$r = http(<<"EOF", body => 'post-body'); +POST http://127.0.0.1:$p/post HTTP/1.1 +Host: ignored.example +Content-Length: 9 +Connection: close + +EOF +like($r, qr/X-Body: post-body/i, 'h1 POST forwards body'); + +like(http(<<"EOF"), qr/^HTTP\/1\.1 400 Bad Request/ms, +GET /origin-form HTTP/1.1 +Host: localhost +Connection: close + +EOF + 'h1 origin-form rejected'); + +like(http(<<"EOF"), qr/^HTTP\/1\.1 400 Bad Request/ms, +GET https://127.0.0.1:$p/secure HTTP/1.1 +Host: localhost +Connection: close + +EOF + 'h1 https target requires CONNECT'); + +like(http(<<"EOF"), qr/^HTTP\/1\.1 405 Not Allowed/ms, +CONNECT 127.0.0.1:$p HTTP/1.1 +Host: localhost +Connection: close + +EOF + 'h1 CONNECT rejected without tunnel_pass'); + +$r = http(<<"EOF"); +GET http://example.net:$p/resolve HTTP/1.1 +Host: ignored.example +Connection: close + +EOF +like($r, qr/^HTTP\/1\.1 200 OK/ms, 'h1 hostname target resolved'); +like($r, qr/X-Host: example\.net:$p/i, + 'h1 resolved target preserves Host'); + +$r = http_port(8083, <<"EOF"); +GET http://example.net:$p/no-resolver HTTP/1.1 +Host: localhost +Connection: close + +EOF +like($r, qr/^HTTP\/1\.1 502 Bad Gateway/ms, + 'h1 hostname target without resolver returns 502'); + +my ($headers, $body) = h2_forward( + path => '/h2?x=1', + host => "127.0.0.1:$p", +); +is($headers->{':status'}, '200', 'h2 forward proxy request'); +like($body, qr/method=GET\nuri=\/h2\?x=1\nhost=127\.0\.0\.1:$p\nbody=/s, + 'h2 uses :authority and :path as target'); + +($headers, $body) = h2_forward( + method => 'POST', + path => '/h2-post', + host => "127.0.0.1:$p", + body => 'h2-body', +); +like($body, qr/body=h2-body$/s, 'h2 POST forwards body'); + +($headers, $body) = h2_forward( + scheme => 'https', + path => '/secure', + host => "127.0.0.1:$p", +); +is($headers->{':status'}, '400', 'h2 https target rejected without CONNECT'); + +($headers, $body) = h2_forward( + path => '/h2-resolve', + host => "example.net:$p", +); +is($headers->{':status'}, '200', 'h2 hostname target resolved'); +like($body, qr/method=GET\nuri=\/h2-resolve\nhost=example\.net:$p\nbody=/s, + 'h2 resolved target preserves Host'); + +($headers, $body) = h2_forward( + port => 8084, + path => '/h2-no-resolver', + host => "example.net:$p", +); +is($headers->{':status'}, '502', + 'h2 hostname target without resolver returns 502'); + +############################################################################### + +sub h2_forward { + my (%args) = @_; + + my $s = Test::Nginx::HTTP2->new(port($args{port} || 8082), pure => 1); + my $sid = $s->new_stream({ + method => $args{method} || 'GET', + scheme => $args{scheme} || 'http', + path => $args{path} || '/', + host => $args{host} || 'localhost', + defined $args{body} ? (body => $args{body}) : (), + }); + + my $frames = $s->read(all => [{ sid => $sid, fin => 1 }], wait => 2); + my ($headers) = map { $_->{headers} } grep { $_->{type} eq 'HEADERS' } @$frames; + my $body = join('', map { $_->{data} } grep { $_->{type} eq 'DATA' } @$frames); + + return ($headers, $body); +} + +sub http_port { + my ($port, $request, %extra) = @_; + + my $socket = IO::Socket::INET->new( + Proto => 'tcp', + PeerAddr => '127.0.0.1:' . port($port), + ) + or die "Can't connect to nginx: $!\n"; + + return http($request, socket => $socket, %extra); +} + +sub origin_daemon { + my ($port) = @_; + + my $server = IO::Socket::INET->new( + Proto => 'tcp', + LocalAddr => '127.0.0.1', + LocalPort => $port, + Listen => 5, + Reuse => 1, + ) + or die "Can't create listening socket: $!\n"; + + while (my $client = $server->accept()) { + $client->autoflush(1); + + my ($headers, $rest) = read_headers($client); + next unless defined $headers; + + my ($request, @lines) = split(/\x0d?\x0a/, $headers); + my ($method, $uri) = split(/ /, $request, 3); + my (%headers_in, $body, $line); + + for $line (@lines) { + next unless length $line; + + my ($name, $value) = split(/:\s*/, $line, 2); + $headers_in{lc $name} = $value; + } + + if (($headers_in{'content-length'} || 0) > 0) { + $body = $rest || ''; + + if (length($body) < $headers_in{'content-length'}) { + read($client, my $chunk, + $headers_in{'content-length'} - length($body)); + $body .= $chunk; + } + + $body = substr($body, 0, $headers_in{'content-length'}); + } else { + $body = ''; + } + + my $payload = join("\n", + "method=$method", + "uri=$uri", + "host=" . ($headers_in{'host'} // ''), + "body=$body"); + + my $response = 'HTTP/1.1 200 OK' . CRLF + . 'X-Method: ' . $method . CRLF + . 'X-URI: ' . $uri . CRLF + . 'X-Host: ' . ($headers_in{'host'} // '') . CRLF + . 'X-Body: ' . $body . CRLF + . 'Content-Length: ' . length($payload) . CRLF + . 'Connection: close' . CRLF + . CRLF; + + print $client $response; + print $client $payload unless $method eq 'HEAD'; + + close $client; + } +} + +sub read_headers { + my ($client) = @_; + my $buf = ''; + + eval { + local $SIG{ALRM} = sub { die "timeout\n" }; + alarm(8); + + while ($buf !~ /\x0d?\x0a\x0d?\x0a/ms) { + my $chunk = ''; + my $n = $client->sysread($chunk, 4096); + die "unexpected eof\n" unless $n; + $buf .= $chunk; + } + + alarm(0); + }; + alarm(0); + + die $@ if $@ && $@ ne "unexpected eof\n"; + return undef if $@; + + $buf =~ /(.*?\x0d?\x0a\x0d?\x0a)(.*)/ms + or die "Can't parse headers\n"; + + return ($1, $2); +} + +sub dns_daemon { + my ($t, $port, $ready) = @_; + + my ($data, $recv_data); + my $socket = IO::Socket::INET->new( + LocalAddr => '127.0.0.1', + LocalPort => $port, + Proto => 'udp', + ) + or die "Can't create listening socket: $!\n"; + + open my $fh, '>', $ready or die "Can't create $ready: $!\n"; + close $fh; + + while (1) { + $socket->recv($recv_data, 65536); + $data = dns_reply($recv_data); + $socket->send($data); + } +} + +sub dns_reply { + my ($recv_data) = @_; + + my (@name, @rdata); + + use constant NOERROR => 0; + use constant A => 1; + use constant IN => 1; + + 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' && $type == A) { + push @rdata, dns_rd_addr(1, '127.0.0.1'); + } + + $len = @name; + return pack("n6 (C/a*)$len x n2", $id, 0x8180 | NOERROR, 1, + scalar @rdata, 0, 0, @name, $type, $class) . join('', @rdata); +} + +sub dns_rd_addr { + my ($ttl, $addr) = @_; + + my @octets = split(/\./, $addr); + + return pack('n3N nC4', 0xc00c, 1, 1, $ttl, scalar @octets, @octets); +} + +############################################################################### diff --git a/forward_proxy_conf.t b/forward_proxy_conf.t new file mode 100644 index 00000000..e0591c41 --- /dev/null +++ b/forward_proxy_conf.t @@ -0,0 +1,170 @@ +#!/usr/bin/perl + +# Tests for forward_proxy configuration conflicts and inheritance. + +############################################################################### + +use warnings; +use strict; + +use Test::More; + +BEGIN { use FindBin; chdir($FindBin::Bin); } + +use lib 'lib'; +use Test::Nginx; + +############################################################################### + +select STDERR; $| = 1; +select STDOUT; $| = 1; + +my $t = Test::Nginx->new()->has(qw/http proxy/)->plan(5); + +$t->write_file('error.log', ''); + +my ($rc, $out) = config_test($t, <<'EOF'); +daemon off; + +events { +} + +http { + server { + listen 127.0.0.1:%%PORT_8080%%; + server_name localhost; + + location / { + forward_proxy on; + proxy_pass http://127.0.0.1:%%PORT_8081%%; + } + } +} +EOF +ok($rc != 0 && $out =~ /incompatible with/ms, + 'same location rejects forward_proxy then proxy_pass'); + +($rc, $out) = config_test($t, <<'EOF'); +daemon off; + +events { +} + +http { + server { + listen 127.0.0.1:%%PORT_8082%%; + server_name localhost; + + location / { + proxy_pass http://127.0.0.1:%%PORT_8083%%; + forward_proxy on; + } + } +} +EOF +ok($rc != 0 && $out =~ /incompatible with/ms, + 'same location rejects proxy_pass then forward_proxy'); + +($rc, $out) = config_test($t, <<'EOF'); +daemon off; + +events { +} + +http { + server { + listen 127.0.0.1:%%PORT_8084%%; + server_name localhost; + + location / { + forward_proxy on; + + location /child { + proxy_pass http://127.0.0.1:%%PORT_8085%%; + } + } + } +} +EOF +ok($rc != 0 && $out =~ /incompatible with/ms, + 'inherited forward_proxy rejects child proxy_pass'); + +($rc, $out) = config_test($t, <<'EOF'); +daemon off; + +events { +} + +http { + server { + listen 127.0.0.1:%%PORT_8086%%; + server_name localhost; + + location / { + proxy_pass http://127.0.0.1:%%PORT_8087%%; + + location /child { + forward_proxy on; + } + } + } +} +EOF +ok($rc == 0, + 'child forward_proxy is allowed under parent proxy_pass'); + +($rc, $out) = config_test($t, <<'EOF'); +daemon off; + +events { +} + +http { + server { + listen 127.0.0.1:%%PORT_8088%%; + server_name localhost; + + location / { + forward_proxy on; + + location /child { + forward_proxy off; + proxy_pass http://127.0.0.1:%%PORT_8089%%; + } + } + } +} +EOF +ok($rc == 0, 'child forward_proxy off allows proxy_pass override'); + +############################################################################### + +sub config_test { + my ($t, $conf) = @_; + + my $testdir = $t->testdir(); + my $cmd = join ' ', + shell_quote($Test::Nginx::NGINX), + '-p', shell_quote($testdir . '/'), + '-c', shell_quote('nginx.conf'), + '-e', shell_quote('error.log'), + '-t', '2>&1'; + + $t->write_file_expand('nginx.conf', $conf); + mkdir $testdir . '/logs'; + + my $out = `$cmd`; + my $rc = $? >> 8; + + return ($rc, $out); +} + +sub shell_quote { + my ($value) = @_; + + $value =~ s/'/'"'"'/gms; + + return "'$value'"; +} + +############################################################################### diff --git a/forward_proxy_ipv6.t b/forward_proxy_ipv6.t new file mode 100644 index 00000000..d3dae82f --- /dev/null +++ b/forward_proxy_ipv6.t @@ -0,0 +1,123 @@ +#!/usr/bin/perl + +# Tests for forward proxy IPv6 targets. + +############################################################################### + +use warnings; +use strict; + +use Test::More; + +BEGIN { use FindBin; chdir($FindBin::Bin); } + +use lib 'lib'; +use Test::Nginx; +use Test::Nginx::HTTP2; + +############################################################################### + +select STDERR; $| = 1; +select STDOUT; $| = 1; + +plan(skip_all => 'win32') if $^O eq 'MSWin32'; + +my $t = Test::Nginx->new()->has(qw/http proxy http_v2/); + +$t->write_file_expand('nginx.conf', <<'EOF'); + +%%TEST_GLOBALS%% + +daemon off; + +events { +} + +http { + %%TEST_GLOBALS_HTTP%% + + server { + listen 127.0.0.1:8080; + server_name localhost; + + location / { + forward_proxy on; + proxy_connect_timeout 2s; + proxy_read_timeout 2s; + proxy_send_timeout 2s; + } + } + + server { + listen 127.0.0.1:8082; + server_name localhost; + + http2 on; + + location / { + forward_proxy on; + proxy_connect_timeout 2s; + proxy_read_timeout 2s; + proxy_send_timeout 2s; + } + } + + server { + listen [::1]:%%PORT_8081%%; + server_name localhost; + + location / { + add_header X-Method $request_method always; + add_header X-URI $request_uri always; + add_header X-Host $http_host always; + return 200 "method=$request_method\nuri=$request_uri\nhost=$http_host\n"; + } + } +} + +EOF + +$t->try_run('no inet6 support')->plan(4); + +############################################################################### + +my $p = port(8081); + +my $r = http(<<"EOF"); +GET http://[::1]:$p/ipv6?x=1 HTTP/1.1 +Host: ignored.example +Connection: close + +EOF +like($r, qr/^HTTP\/1\.1 200 OK/ms, 'h1 ipv6 forward proxy request'); +like($r, qr/X-Host: \[::1\]:$p/i, 'h1 ipv6 target preserves Host'); + +my ($headers, $body) = h2_forward( + path => '/h2-ipv6', + host => "[::1]:$p", +); +is($headers->{':status'}, '200', 'h2 ipv6 forward proxy request'); +like($body, qr/method=GET\nuri=\/h2-ipv6\nhost=\[::1\]:$p\n/s, + 'h2 ipv6 target preserves Host'); + +############################################################################### + +sub h2_forward { + my (%args) = @_; + + my $s = Test::Nginx::HTTP2->new(port(8082), pure => 1); + my $sid = $s->new_stream({ + method => 'GET', + scheme => 'http', + path => $args{path}, + host => $args{host}, + }); + + my $frames = $s->read(all => [{ sid => $sid, fin => 1 }], wait => 2); + my ($headers) = map { $_->{headers} } grep { $_->{type} eq 'HEADERS' } @$frames; + my $body = join('', map { $_->{data} } grep { $_->{type} eq 'DATA' } @$frames); + + return ($headers, $body); +} + +############################################################################### diff --git a/h2_tunnel.t b/h2_tunnel.t new file mode 100644 index 00000000..0816196b --- /dev/null +++ b/h2_tunnel.t @@ -0,0 +1,168 @@ +#!/usr/bin/perl + +# Tests for HTTP/2 CONNECT tunnel support. + +############################################################################### + +use warnings; +use strict; + +use Test::More; + +use IO::Socket::INET; +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; + +plan(skip_all => 'win32') if $^O eq 'MSWin32'; + +my $t = Test::Nginx->new()->has(qw/http http_v2 tunnel/)->plan(6); + +$t->write_file_expand('nginx.conf', <<'EOF'); + +%%TEST_GLOBALS%% + +daemon off; + +events { +} + +http { + %%TEST_GLOBALS_HTTP%% + + server { + listen 127.0.0.1:8080; + server_name localhost; + + http2 on; + + tunnel_pass 127.0.0.1:%%PORT_8081%%; + tunnel_connect_timeout 2s; + tunnel_read_timeout 2s; + tunnel_send_timeout 2s; + } + + server { + listen 127.0.0.1:8082; + server_name localhost; + + http2 on; + + location / { + return 204; + } + } +} + +EOF + +$t->run_daemon(\&tunnel_daemon, port(8081), 'h2'); +$t->run(); +$t->waitforsocket('127.0.0.1:' . port(8081)); + +############################################################################### + +my ($s, $sid, $headers, $body) = h2_connect(port(8080), + authority => 'ignored.example:443'); +is($headers->{':status'}, '200', 'h2 CONNECT status'); +like($body, qr/^READY h2 127\.0\.0\.1\x0d?\x0a$/s, + 'h2 CONNECT greeting'); + +$s->h2_body("hello" . CRLF, { body_more => 1 }); +like(h2_data($s, $sid), qr/^h2:hello\x0d?\x0a$/s, + 'h2 CONNECT relays first DATA frame'); + +$s->h2_body("again" . CRLF); +like(h2_data($s, $sid, fin => 1), qr/^h2:again\x0d?\x0a$/s, + 'h2 CONNECT relays final DATA frame'); + +($s, $sid, $headers) = h2_connect(port(8080)); +is($headers->{':status'}, '400', 'h2 CONNECT requires :authority'); + +($s, $sid, $headers) = h2_connect(port(8082), + authority => 'ignored.example:443'); +is($headers->{':status'}, '405', 'h2 CONNECT rejected when tunnel disabled'); + +############################################################################### + +sub h2_connect { + my ($port, %args) = @_; + + my $s = Test::Nginx::HTTP2->new($port, pure => 1); + my @headers = ({ name => ':method', value => 'CONNECT' }); + + if (defined $args{authority}) { + push @headers, { name => ':authority', value => $args{authority} }; + } + + my $sid = $s->new_stream({ + body_more => $args{body_more} ? 1 : 0, + headers => \@headers, + }); + + my $frames = $s->read(all => [{ sid => $sid, type => 'HEADERS' }], + wait => 2); + my ($headers) = map { $_->{headers} } grep { $_->{type} eq 'HEADERS' } @$frames; + my $body = join('', map { $_->{data} } grep { $_->{type} eq 'DATA' } @$frames); + + if ($headers->{':status'} eq '200') { + $body .= h2_data($s, $sid); + } + + return ($s, $sid, $headers, $body); +} + +sub h2_data { + my ($s, $sid, %extra) = @_; + + my $frames = $s->read(all => [ + { sid => $sid, type => 'DATA', defined $extra{fin} ? (fin => $extra{fin}) : () } + ], wait => 2); + + return join('', map { $_->{data} } grep { $_->{type} eq 'DATA' } @$frames); +} + +sub tunnel_daemon { + my ($port, $label) = @_; + + my $server = IO::Socket::INET->new( + Proto => 'tcp', + LocalAddr => '127.0.0.1', + LocalPort => $port, + Listen => 5, + Reuse => 1, + ) + or die "Can't create listening socket: $!\n"; + + local $SIG{PIPE} = 'IGNORE'; + + while (my $client = $server->accept()) { + $client->autoflush(1); + handle_tunnel_client($client, $label, + eval { $client->peerhost() } || 'unknown'); + } +} + +sub handle_tunnel_client { + my ($client, $label, $peer) = @_; + + print $client "READY $label $peer" . CRLF; + + while (my $line = <$client>) { + $line =~ s/\x0d?\x0a$//; + print $client $label . ':' . $line . CRLF; + } + + $client->close(); +} + +############################################################################### diff --git a/h3_forward_proxy.t b/h3_forward_proxy.t new file mode 100644 index 00000000..0c48fcb4 --- /dev/null +++ b/h3_forward_proxy.t @@ -0,0 +1,309 @@ +#!/usr/bin/perl + +# Tests for HTTP/3 forward proxy support. + +############################################################################### + +use warnings; +use strict; + +use Test::More; + +use IO::Socket::INET; +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; + +plan(skip_all => 'win32') if $^O eq 'MSWin32'; + +my $t = Test::Nginx->new()->has(qw/http http_v3 proxy cryptx/) + ->has_daemon('openssl')->plan(7); + +$t->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; + + location / { + forward_proxy on; + resolver 127.0.0.1:%%PORT_8982_UDP%% ipv6=off; + resolver_timeout 2s; + proxy_connect_timeout 2s; + proxy_read_timeout 2s; + proxy_send_timeout 2s; + } + } + + server { + listen 127.0.0.1:%%PORT_8981_UDP%% quic; + server_name localhost; + + location / { + forward_proxy on; + proxy_connect_timeout 2s; + proxy_read_timeout 2s; + proxy_send_timeout 2s; + } + } +} + +EOF + +$t->write_file('openssl.conf', <testdir(); +my $dns_ready = $t->testdir() . '/dns.ready'; + +system('openssl req -x509 -new ' + . "-config $d/openssl.conf -subj /CN=localhost/ " + . "-out $d/localhost.crt -keyout $d/localhost.key " + . ">>$d/openssl.out 2>&1") == 0 + or die "Can't create certificate for localhost: $!\n"; + +$t->run_daemon(\&origin_daemon, port(8081)); +$t->run_daemon(\&dns_daemon, $t, port(8982), $dns_ready); +$t->run(); +$t->waitforsocket('127.0.0.1:' . port(8081)); +$t->waitforfile($dns_ready) or die "Can't start dns daemon"; + +############################################################################### + +my $p = port(8081); +my ($headers, $body) = h3_forward( + path => '/h3?x=1', + host => "127.0.0.1:$p", +); +is($headers->{':status'}, '200', 'h3 forward proxy GET'); +like($body, qr/method=GET\nuri=\/h3\?x=1\nhost=127\.0\.0\.1:$p\nbody=/s, + 'h3 uses :authority and :path as target'); + +($headers, $body) = h3_forward( + method => 'POST', + path => '/h3-post', + host => "127.0.0.1:$p", + body => 'h3-body', +); +like($body, qr/body=h3-body$/s, 'h3 POST forwards body'); + +($headers, $body) = h3_forward( + scheme => 'https', + path => '/secure', + host => "127.0.0.1:$p", +); +is($headers->{':status'}, '400', 'h3 https target rejected without CONNECT'); + +($headers, $body) = h3_forward( + path => '/h3-resolve', + host => "example.net:$p", +); +is($headers->{':status'}, '200', 'h3 hostname target resolved'); +like($body, qr/method=GET\nuri=\/h3-resolve\nhost=example\.net:$p\nbody=/s, + 'h3 resolved target preserves Host'); + +($headers, $body) = h3_forward( + port => 8981, + path => '/h3-no-resolver', + host => "example.net:$p", +); +is($headers->{':status'}, '502', + 'h3 hostname target without resolver returns 502'); + +############################################################################### + +sub h3_forward { + my (%args) = @_; + + my $s = Test::Nginx::HTTP3->new($args{port}); + my $sid = $s->new_stream({ + method => $args{method} || 'GET', + scheme => $args{scheme} || 'http', + path => $args{path} || '/', + host => $args{host} || 'localhost', + defined $args{body} ? (body => $args{body}) : (), + }); + + my $frames = $s->read(all => [{ sid => $sid, fin => 1 }], wait => 2); + my ($headers) = map { $_->{headers} } grep { $_->{type} eq 'HEADERS' } @$frames; + my $body = join('', map { $_->{data} } grep { $_->{type} eq 'DATA' } @$frames); + + return ($headers, $body); +} + +sub dns_daemon { + my ($t, $port, $ready) = @_; + + my ($data, $recv_data); + my $socket = IO::Socket::INET->new( + LocalAddr => '127.0.0.1', + LocalPort => $port, + Proto => 'udp', + ) + or die "Can't create listening socket: $!\n"; + + open my $fh, '>', $ready or die "Can't create $ready: $!\n"; + close $fh; + + while (1) { + $socket->recv($recv_data, 65536); + $data = dns_reply($recv_data); + $socket->send($data); + } +} + +sub dns_reply { + my ($recv_data) = @_; + + my (@name, @rdata); + + use constant NOERROR => 0; + use constant A => 1; + use constant IN => 1; + + 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' && $type == A) { + push @rdata, dns_rd_addr(1, '127.0.0.1'); + } + + $len = @name; + return pack("n6 (C/a*)$len x n2", $id, 0x8180 | NOERROR, 1, + scalar @rdata, 0, 0, @name, $type, $class) . join('', @rdata); +} + +sub dns_rd_addr { + my ($ttl, $addr) = @_; + + my @octets = split(/\./, $addr); + + return pack('n3N nC4', 0xc00c, 1, 1, $ttl, scalar @octets, @octets); +} + +sub origin_daemon { + my ($port) = @_; + + my $server = IO::Socket::INET->new( + Proto => 'tcp', + LocalAddr => '127.0.0.1', + LocalPort => $port, + Listen => 5, + Reuse => 1, + ) + or die "Can't create listening socket: $!\n"; + + while (my $client = $server->accept()) { + $client->autoflush(1); + + my ($headers, $rest) = read_headers($client); + next unless defined $headers; + + my ($request, @lines) = split(/\x0d?\x0a/, $headers); + my ($method, $uri) = split(/ /, $request, 3); + my (%headers_in, $body, $line); + + for $line (@lines) { + next unless length $line; + + my ($name, $value) = split(/:\s*/, $line, 2); + $headers_in{lc $name} = $value; + } + + if (($headers_in{'content-length'} || 0) > 0) { + $body = $rest || ''; + + if (length($body) < $headers_in{'content-length'}) { + read($client, my $chunk, + $headers_in{'content-length'} - length($body)); + $body .= $chunk; + } + + $body = substr($body, 0, $headers_in{'content-length'}); + } else { + $body = ''; + } + + my $payload = join("\n", + "method=$method", + "uri=$uri", + "host=" . ($headers_in{'host'} // ''), + "body=$body"); + + my $response = 'HTTP/1.1 200 OK' . CRLF + . 'Content-Length: ' . length($payload) . CRLF + . 'Connection: close' . CRLF + . CRLF + . $payload; + + print $client $response; + close $client; + } +} + +sub read_headers { + my ($client) = @_; + my $buf = ''; + + eval { + local $SIG{ALRM} = sub { die "timeout\n" }; + alarm(8); + + while ($buf !~ /\x0d?\x0a\x0d?\x0a/ms) { + my $chunk = ''; + my $n = $client->sysread($chunk, 4096); + die "unexpected eof\n" unless $n; + $buf .= $chunk; + } + + alarm(0); + }; + alarm(0); + + die $@ if $@ && $@ ne "unexpected eof\n"; + return undef if $@; + + $buf =~ /(.*?\x0d?\x0a\x0d?\x0a)(.*)/ms + or die "Can't parse headers\n"; + + return ($1, $2); +} + +############################################################################### diff --git a/h3_tunnel.t b/h3_tunnel.t new file mode 100644 index 00000000..08c34741 --- /dev/null +++ b/h3_tunnel.t @@ -0,0 +1,181 @@ +#!/usr/bin/perl + +# Tests for HTTP/3 CONNECT tunnel support. + +############################################################################### + +use warnings; +use strict; + +use Test::More; + +use IO::Socket::INET; +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; + +plan(skip_all => 'win32') if $^O eq 'MSWin32'; + +my $t = Test::Nginx->new()->has(qw/http http_v3 tunnel cryptx/) + ->has_daemon('openssl')->plan(5); + +$t->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; + + tunnel_pass 127.0.0.1:%%PORT_8081%%; + tunnel_connect_timeout 2s; + tunnel_read_timeout 2s; + tunnel_send_timeout 2s; + } + + server { + listen 127.0.0.1:%%PORT_8981_UDP%% quic; + server_name localhost; + + location / { + return 204; + } + } +} + +EOF + +$t->write_file('openssl.conf', <testdir(); + +system('openssl req -x509 -new ' + . "-config $d/openssl.conf -subj /CN=localhost/ " + . "-out $d/localhost.crt -keyout $d/localhost.key " + . ">>$d/openssl.out 2>&1") == 0 + or die "Can't create certificate for localhost: $!\n"; + +$t->run_daemon(\&tunnel_daemon, port(8081), 'h3'); +$t->run(); +$t->waitforsocket('127.0.0.1:' . port(8081)); + +############################################################################### + +my ($s, $sid, $headers, $body) = h3_connect(undef, + authority => 'ignored.example:443'); +is($headers->{':status'}, '200', 'h3 CONNECT status'); +like($body, qr/^READY h3 127\.0\.0\.1\x0d?\x0a$/s, + 'h3 CONNECT greeting'); + +$s->h3_body("hello" . CRLF, $sid, { body_more => 1 }); +like(h3_data($s, $sid), qr/^h3:hello\x0d?\x0a$/s, + 'h3 CONNECT relays DATA frame'); + +($s, $sid, $headers) = h3_connect(undef); +is($headers->{':status'}, '400', 'h3 CONNECT requires :authority'); + +($s, $sid, $headers) = h3_connect(8981, + authority => 'ignored.example:443'); +is($headers->{':status'}, '405', 'h3 CONNECT rejected when tunnel disabled'); + +############################################################################### + +sub h3_connect { + my ($port, %args) = @_; + + my $s = defined $port ? Test::Nginx::HTTP3->new($port) + : Test::Nginx::HTTP3->new(); + my @headers = ({ name => ':method', value => 'CONNECT', mode => 0 }); + + if (defined $args{authority}) { + push @headers, { + name => ':authority', + value => $args{authority}, + mode => 2 + }; + } + + my $sid = $s->new_stream({ headers => \@headers, body_more => 1 }); + my $frames = $s->read(all => [{ sid => $sid, type => 'HEADERS' }], + wait => 2); + my ($headers) = map { $_->{headers} } grep { $_->{type} eq 'HEADERS' } @$frames; + my $body = ''; + + if ($headers->{':status'} eq '200') { + $body = h3_data($s, $sid); + } + + return ($s, $sid, $headers, $body); +} + +sub h3_data { + my ($s, $sid, %extra) = @_; + + my $frames = $s->read(all => [ + { sid => $sid, type => 'DATA', defined $extra{fin} ? (fin => $extra{fin}) : () } + ], wait => 2); + + return join('', map { $_->{data} } grep { $_->{type} eq 'DATA' } @$frames); +} + +sub tunnel_daemon { + my ($port, $label) = @_; + + my $server = IO::Socket::INET->new( + Proto => 'tcp', + LocalAddr => '127.0.0.1', + LocalPort => $port, + Listen => 5, + Reuse => 1, + ) + or die "Can't create listening socket: $!\n"; + + local $SIG{PIPE} = 'IGNORE'; + + while (my $client = $server->accept()) { + $client->autoflush(1); + handle_tunnel_client($client, $label, + eval { $client->peerhost() } || 'unknown'); + } +} + +sub handle_tunnel_client { + my ($client, $label, $peer) = @_; + + print $client "READY $label $peer" . CRLF; + + while (my $line = <$client>) { + $line =~ s/\x0d?\x0a$//; + print $client $label . ':' . $line . CRLF; + } + + $client->close(); +} + +############################################################################### diff --git a/lib/Test/Nginx.pm b/lib/Test/Nginx.pm index 0e757b7a..0bab76e8 100644 --- a/lib/Test/Nginx.pm +++ b/lib/Test/Nginx.pm @@ -136,6 +136,7 @@ sub has_module($) { referer => '(?s)^(?!.*--without-http_referer_module)', rewrite => '(?s)^(?!.*--without-http_rewrite_module)', proxy => '(?s)^(?!.*--without-http_proxy_module)', + tunnel => '(?s)^(?!.*--without-http_tunnel_module)', fastcgi => '(?s)^(?!.*--without-http_fastcgi_module)', uwsgi => '(?s)^(?!.*--without-http_uwsgi_module)', scgi => '(?s)^(?!.*--without-http_scgi_module)', diff --git a/tunnel.t b/tunnel.t new file mode 100644 index 00000000..edd7b4cf --- /dev/null +++ b/tunnel.t @@ -0,0 +1,580 @@ +#!/usr/bin/perl + +# Tests for http tunnel forward proxy support. + +############################################################################### + +use warnings; +use strict; + +use Test::More; + +use IO::Select; +use IO::Socket::INET; +use Socket qw/ CRLF /; + +BEGIN { use FindBin; chdir($FindBin::Bin); } + +use lib 'lib'; +use Test::Nginx; + +############################################################################### + +select STDERR; $| = 1; +select STDOUT; $| = 1; + +plan(skip_all => 'win32') if $^O eq 'MSWin32'; + +my $has_unix = eval { require IO::Socket::UNIX; 1 }; + +my $bind1 = IO::Socket::INET->new(LocalAddr => '127.0.0.2'); +my $bind2 = IO::Socket::INET->new(LocalAddr => '127.0.0.3'); +my $has_bind_retry = defined $bind1 && defined $bind2; + +close $bind1 if $bind1; +close $bind2 if $bind2; + +my $t = Test::Nginx->new()->has(qw/http tunnel/); + +$t->write_file_expand('nginx.conf', <<'EOF'); + +%%TEST_GLOBALS%% + +daemon off; + +events { +} + +http { + %%TEST_GLOBALS_HTTP%% + + map $http_x_tunnel_allow $allow_request { + allow 1; + default 0; + } + + map $upstream_addr $allow_bind { + ~:%%PORT_8083%%$ 0; + default 1; + } + + map $upstream_addr $bind_source { + ~:%%PORT_8083%%$ 127.0.0.3; + default 127.0.0.2; + } + + upstream error_retry { + server unix:%%TESTDIR%%/missing.sock; + server 127.0.0.1:%%PORT_8082%%; + } + + upstream error_off { + server unix:%%TESTDIR%%/missing.sock; + server 127.0.0.1:%%PORT_8082%%; + } + + upstream bind_retry { + server 127.0.0.1:%%PORT_8083%%; + server 127.0.0.1:%%PORT_8084%%; + } + + server { + listen 127.0.0.1:8080; + server_name localhost; + + tunnel_pass 127.0.0.1:%%PORT_8081%%; + tunnel_buffer_size 8k; + tunnel_connect_timeout 2s; + tunnel_read_timeout 2s; + tunnel_send_timeout 2s; + tunnel_send_lowat 0; + tunnel_socket_keepalive on; + } + + server { + listen 127.0.0.1:8086; + server_name localhost; + + tunnel_pass; + tunnel_buffer_size 8k; + tunnel_connect_timeout 2s; + tunnel_read_timeout 2s; + tunnel_send_timeout 2s; + } + + server { + listen 127.0.0.1:8087; + server_name localhost; + + tunnel_pass 127.0.0.1:%%PORT_8082%%; + tunnel_allow_upstream $allow_request; + tunnel_next_upstream denied; + tunnel_next_upstream_tries 2; + tunnel_next_upstream_timeout 5s; + tunnel_connect_timeout 2s; + tunnel_read_timeout 2s; + tunnel_send_timeout 2s; + } + + server { + listen 127.0.0.1:8088; + server_name localhost; + + tunnel_pass 127.0.0.1:%%PORT_8082%%; + tunnel_allow_upstream $allow_request; + tunnel_next_upstream off; + tunnel_connect_timeout 2s; + tunnel_read_timeout 2s; + tunnel_send_timeout 2s; + } + + server { + listen 127.0.0.1:8090; + server_name localhost; + + tunnel_pass error_retry; + tunnel_next_upstream error; + tunnel_next_upstream_tries 2; + tunnel_next_upstream_timeout 5s; + tunnel_connect_timeout 2s; + tunnel_read_timeout 2s; + tunnel_send_timeout 2s; + } + + server { + listen 127.0.0.1:8091; + server_name localhost; + + tunnel_pass error_off; + tunnel_next_upstream off; + tunnel_connect_timeout 2s; + tunnel_read_timeout 2s; + tunnel_send_timeout 2s; + } + + server { + listen 127.0.0.1:8092; + server_name localhost; + + tunnel_pass bind_retry; + tunnel_allow_upstream $allow_bind; + tunnel_next_upstream denied; + tunnel_next_upstream_tries 2; + tunnel_next_upstream_timeout 5s; + tunnel_bind $bind_source; + tunnel_bind_dynamic on; + tunnel_connect_timeout 2s; + tunnel_read_timeout 2s; + tunnel_send_timeout 2s; + } + + server { + listen 127.0.0.1:8093; + server_name localhost; + + tunnel_pass bind_retry; + tunnel_allow_upstream $allow_bind; + tunnel_next_upstream denied; + tunnel_next_upstream_tries 2; + tunnel_next_upstream_timeout 5s; + tunnel_bind $bind_source; + tunnel_connect_timeout 2s; + tunnel_read_timeout 2s; + tunnel_send_timeout 2s; + } + + server { + listen 127.0.0.1:8094; + server_name localhost; + + tunnel_pass unix:%%TESTDIR%%/unix.sock; + tunnel_connect_timeout 2s; + tunnel_read_timeout 2s; + tunnel_send_timeout 2s; + } + + server { + listen 127.0.0.1:8095; + server_name localhost; + + resolver 127.0.0.1:%%PORT_8980_UDP%% ipv6=off; + resolver_timeout 2s; + tunnel_pass $http_x_tunnel_target; + tunnel_connect_timeout 2s; + tunnel_read_timeout 2s; + tunnel_send_timeout 2s; + } + + server { + listen 127.0.0.1:8096; + server_name localhost; + + tunnel_pass $http_x_tunnel_target; + tunnel_connect_timeout 2s; + tunnel_read_timeout 2s; + tunnel_send_timeout 2s; + } +} + +EOF + +my $unix_path = $t->testdir() . '/unix.sock'; +my $dns_ready = $t->testdir() . '/dns.ready'; + +for my $backend ( + [ 8081, 'literal' ], + [ 8082, 'second' ], + [ 8083, 'bind-one' ], + [ 8084, 'bind-two' ], + [ 8085, 'resolved' ], +) { + $t->run_daemon(\&tunnel_daemon, port($backend->[0]), $backend->[1]); +} + +$t->run_daemon(\&tunnel_unix_daemon, $unix_path, 'unix') if $has_unix; +$t->run_daemon(\&dns_daemon, $t, port(8980), $dns_ready); + +$t->run(); +$t->waitforsocket('127.0.0.1:' . port($_)) for (8081 .. 8085); +$t->waitforfile($dns_ready) or die "Can't start dns daemon"; + +if ($has_unix) { + for (1 .. 50) { + last if -S $unix_path; + select undef, undef, undef, 0.1; + } +} + +$t->plan(20); + +############################################################################### + +like(front_get(8080), qr/405 Not Allowed/, 'non-CONNECT rejected'); + +my ($s, $headers) = tunnel_connect(8080, 'ignored.example:443'); +like($headers, qr/^HTTP\/1\.1 200 Connection Established/m, + 'literal CONNECT status'); +unlike($headers, qr/^Content-Length:/mi, + 'successful CONNECT omits Content-Length header'); +is(tunnel_read($s), 'READY literal 127.0.0.1', 'literal greeting'); +tunnel_write($s, 'hello'); +is(tunnel_read($s), 'literal:hello', 'literal data'); + +my $big = 'x' x 16384; +tunnel_write($s, $big); +is(tunnel_read($s), 'literal:' . $big, 'literal big data'); +close $s; + +my ($sp, $hp) = tunnel_connect(8080, 'ignored.example:443', + message => "pipelined" . CRLF); +like($hp, qr/^HTTP\/1\.1 200 Connection Established/m, + 'literal CONNECT status with pipelined data'); +is(tunnel_read($sp), 'READY literal 127.0.0.1', 'literal pipelined greeting'); +is(tunnel_read($sp), 'literal:pipelined', 'literal pipelined data'); +tunnel_write($sp, 'after'); +is(tunnel_read($sp), 'literal:after', 'literal post-handshake data'); +close $sp; + +my ($sd) = tunnel_connect(8086, '127.0.0.1:' . port(8082), + host => 'wrong.example:1111'); +is(tunnel_read($sd), 'READY second 127.0.0.1', + 'default tunnel_pass uses authority host and port'); +close $sd; + +my ($sn) = tunnel_connect(8087, 'ignored.example:443', + extra_headers => [ 'X-Tunnel-Allow: allow' ]); +is(tunnel_read($sn), 'READY second 127.0.0.1', + 'allow_upstream permits tunnel'); +close $sn; + +my ($sdenied, $hdenied) = tunnel_connect(8088, 'ignored.example:443'); +like($hdenied, qr/^HTTP\/1\.1 403 Forbidden/m, + 'allow_upstream denial returns 403'); +close $sdenied if $sdenied; + +my ($se) = tunnel_connect(8090, 'ignored.example:443'); +is(tunnel_read($se), 'READY second 127.0.0.1', + 'connect error retries next peer'); +close $se; + +my ($serr, $herr) = tunnel_connect(8091, 'ignored.example:443'); +like($herr, qr/^HTTP\/1\.1 502 Bad Gateway/m, + 'connect error without retry returns 502'); +close $serr if $serr; + +SKIP: { + skip '127.0.0.2 and 127.0.0.3 local addresses required', 2 + unless $has_bind_retry; + + my ($sb1) = tunnel_connect(8092, 'ignored.example:443'); + is(tunnel_read($sb1), 'READY bind-two 127.0.0.3', + 'dynamic bind reevaluated on retry'); + close $sb1; + + my ($sb2) = tunnel_connect(8093, 'ignored.example:443'); + is(tunnel_read($sb2), 'READY bind-two 127.0.0.2', + 'bind without dynamic keeps initial address'); + close $sb2; +} + +SKIP: { + skip 'IO::Socket::UNIX not installed', 1 unless $has_unix; + + my ($su) = tunnel_connect(8094, 'ignored.example:443'); + is(tunnel_read($su), 'READY unix unix', 'unix socket tunnel works'); + close $su; +} + +my ($sr) = tunnel_connect(8095, 'ignored.example:443', + extra_headers => [ 'X-Tunnel-Target: example.net:' . port(8085) ]); +is(tunnel_read($sr), 'READY resolved 127.0.0.1', 'resolver tunnel works'); +close $sr; + +my ($snr, $hnr) = tunnel_connect(8096, 'ignored.example:443', + extra_headers => [ 'X-Tunnel-Target: example.net:' . port(8085) ]); +like($hnr, qr/^HTTP\/1\.1 502 Bad Gateway/m, + 'missing resolver returns 502'); +close $snr if $snr; + +############################################################################### + +sub front_get { + my ($port) = @_; + + my $socket = IO::Socket::INET->new( + Proto => 'tcp', + PeerAddr => '127.0.0.1:' . port($port), + ) + or die "Can't connect to nginx: $!\n"; + + return http(< $socket); +GET / HTTP/1.0 +Host: localhost + +EOF +} + +sub tunnel_connect { + my ($port, $authority, %opts) = @_; + + my $s = IO::Socket::INET->new( + Proto => 'tcp', + PeerAddr => '127.0.0.1:' . port($port), + ) + or die "Can't connect to nginx: $!\n"; + + my $req = 'CONNECT ' . $authority . ' HTTP/1.1' . CRLF + . 'Host: ' . ($opts{host} || 'localhost') . CRLF; + + if ($opts{extra_headers}) { + $req .= join('', map { $_ . CRLF } @{$opts{extra_headers}}); + } + + $req .= CRLF; + + $req .= $opts{message} if defined $opts{message}; + + tunnel_send_raw($s, $req); + + my ($headers, $rest) = tunnel_read_headers($s); + + ${*$s}->{_tunnel_private} = { b => $rest || '' }; + + return ($s, $headers); +} + +sub tunnel_read_headers { + my ($s) = @_; + my $buf = ''; + + eval { + local $SIG{ALRM} = sub { die "timeout\n" }; + alarm(8); + + while ($buf !~ /\x0d?\x0a\x0d?\x0a/ms) { + my $chunk = ''; + my $n = $s->sysread($chunk, 4096); + die "unexpected eof\n" unless $n; + $buf .= $chunk; + } + + alarm(0); + }; + alarm(0); + die $@ if $@; + + $buf =~ /(.*?\x0d?\x0a\x0d?\x0a)(.*)/ms + or die "Can't parse headers\n"; + + return ($1, $2); +} + +sub tunnel_send_raw { + my ($s, $data) = @_; + + local $SIG{PIPE} = 'IGNORE'; + + while (length $data) { + my $n = $s->syswrite($data); + die "Can't write to tunnel socket: $!\n" unless $n; + substr($data, 0, $n, ''); + } +} + +sub tunnel_write { + my ($s, $line) = @_; + tunnel_send_raw($s, $line . CRLF); +} + +sub tunnel_read { + my ($s) = @_; + my $line = tunnel_getline($s); + $line =~ s/\x0d?\x0a$// if defined $line; + return $line; +} + +sub tunnel_getline { + my ($s) = @_; + + ${*$s}->{_tunnel_private} ||= { b => '' }; + my $ctx = ${*$s}->{_tunnel_private}; + + if ($ctx->{b} =~ /^(.*?\x0a)(.*)/ms) { + $ctx->{b} = $2; + return $1; + } + + $s->blocking(0); + + while (IO::Select->new($s)->can_read(3)) { + my $chunk = ''; + my $n = $s->sysread($chunk, 4096); + last unless $n; + + $ctx->{b} .= $chunk; + + if ($ctx->{b} =~ /^(.*?\x0a)(.*)/ms) { + $ctx->{b} = $2; + return $1; + } + } + + return; +} + +sub tunnel_daemon { + my ($port, $label) = @_; + + my $server = IO::Socket::INET->new( + Proto => 'tcp', + LocalAddr => '127.0.0.1', + LocalPort => $port, + Listen => 5, + Reuse => 1, + ) + or die "Can't create listening socket: $!\n"; + + local $SIG{PIPE} = 'IGNORE'; + + while (my $client = $server->accept()) { + $client->autoflush(1); + handle_tunnel_client($client, $label, + eval { $client->peerhost() } || 'unknown'); + } +} + +sub tunnel_unix_daemon { + my ($path, $label) = @_; + + unlink $path if -e $path; + + my $server = IO::Socket::UNIX->new( + Proto => 'tcp', + Local => $path, + Listen => 5, + Reuse => 1, + ) + or die "Can't create listening socket: $!\n"; + + local $SIG{PIPE} = 'IGNORE'; + + while (my $client = $server->accept()) { + $client->autoflush(1); + handle_tunnel_client($client, $label, 'unix'); + } +} + +sub handle_tunnel_client { + my ($client, $label, $peer) = @_; + + print $client "READY $label $peer" . CRLF; + + while (my $line = <$client>) { + $line =~ s/\x0d?\x0a$//; + print $client $label . ':' . $line . CRLF; + } + + $client->close(); +} + +sub dns_daemon { + my ($t, $port, $ready) = @_; + + my ($data, $recv_data); + my $socket = IO::Socket::INET->new( + LocalAddr => '127.0.0.1', + LocalPort => $port, + Proto => 'udp', + ) + or die "Can't create listening socket: $!\n"; + + open my $fh, '>', $ready or die "Can't create $ready: $!\n"; + close $fh; + + while (1) { + $socket->recv($recv_data, 65536); + $data = dns_reply($recv_data); + $socket->send($data); + } +} + +sub dns_reply { + my ($recv_data) = @_; + + my (@name, @rdata); + + use constant NOERROR => 0; + use constant A => 1; + use constant IN => 1; + + 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' && $type == A) { + push @rdata, dns_rd_addr(1, '127.0.0.1'); + } + + $len = @name; + return pack("n6 (C/a*)$len x n2", $id, 0x8180 | NOERROR, 1, + scalar @rdata, 0, 0, @name, $type, $class) . join('', @rdata); +} + +sub dns_rd_addr { + my ($ttl, $addr) = @_; + + my @octets = split(/\./, $addr); + + return pack('n3N nC4', 0xc00c, 1, 1, $ttl, scalar @octets, @octets); +} + +###############################################################################