From 267b6bc07fc06e415b259ff6341e24a0ffb731a3 Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Sun, 13 Sep 2026 23:49:36 +0200 Subject: [PATCH 1/2] Fix IPv4 delays caused by IPv6 lookups This PR fixes delays when IPv6 DNS lookups stall otherwise working IPv4 connections. The earlier changes ([04c74877](https://github.com/webmin/webmin/commit/04c74877c39c33283052ee2a8e7ccfe09448fd4e) and [8a6f7784](https://github.com/webmin/webmin/commit/8a6f778410aee972f87736d408175b4628311a22)) added fallback across available IPv4 and IPv6 addresses, which this PR preserves. Resolving both families upfront appears to have been an implementation choice, yet now IPv6 is resolved only when no IPv4 connection succeeds. Unit tests and real socket compatibility tests passed on Alma 10 and Debian 13. --- t/README.md | 14 ++ t/fixtures/open-socket-server.py | 145 +++++++++++++++++++ t/web-lib-funcs-open-socket-vm.t | 240 +++++++++++++++++++++++++++++++ t/web-lib-funcs-open-socket.t | 98 +++++++++++++ web-lib-funcs.pl | 91 ++++++------ 5 files changed, 540 insertions(+), 48 deletions(-) create mode 100644 t/fixtures/open-socket-server.py create mode 100644 t/web-lib-funcs-open-socket-vm.t create mode 100644 t/web-lib-funcs-open-socket.t diff --git a/t/README.md b/t/README.md index eb2a52949..41829aa4c 100644 --- a/t/README.md +++ b/t/README.md @@ -19,6 +19,20 @@ WEBMIN_COMPILE_T_FILTER='^\./acl/' prove t/compile.t # one module `prove` and Test::More are core, though on RPM-based distros, you need `perl-Test-Harness`. +## Socket compatibility tests on a VM + +`web-lib-funcs-open-socket.t` tests address selection without network access. + +Run the additional compatibility tests as root on a disposable Linux VM with Webmin, Python 3, OpenSSL, and IPv6 loopback support: + +```sh +WEBMIN_OPEN_SOCKET_VM_TEST=1 timeout 90 prove -v t/web-lib-funcs-open-socket-vm.t +``` + +The VM test uses temporary loopback servers for HTTP, HTTPS, CONNECT proxies, passive FTP, and service greetings. It checks IPv4 and IPv6 fallback, source binding, and error reporting without changing service configuration. It skips unless explicitly enabled. + +By default, it tests `open_socket` from this checkout with the installed Webmin helpers. Set `WEBMIN_OPEN_SOCKET_SOURCE` to another `web-lib-funcs.pl` to compare versions. + ## Coverage reports ```sh diff --git a/t/fixtures/open-socket-server.py b/t/fixtures/open-socket-server.py new file mode 100644 index 000000000..83aa616b0 --- /dev/null +++ b/t/fixtures/open-socket-server.py @@ -0,0 +1,145 @@ +#!/usr/bin/python3 +"""Loopback-only HTTP, TLS, CONNECT, FTP and greeting fixtures.""" +import json +import os +import pathlib +import signal +import socket +import socketserver +import ssl +import subprocess +import sys +import threading + +if sys.platform != 'linux' or os.geteuid() != 0: + raise SystemExit('Disposable root Linux VM required') +os.umask(0o077) +work = pathlib.Path(sys.argv[1]) +key, cert = work / 'server.key', work / 'server.crt' +subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', + '-days', '1', '-subj', '/CN=four.invalid', '-keyout', str(key), '-out', str(cert)], + check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) +context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) +context.load_cert_chain(cert, key) +context.set_servername_callback(lambda connection, name, _: setattr(connection, 'fixture_sni', name)) + +class Server(socketserver.ThreadingTCPServer): + daemon_threads = True + +class Server6(Server): + address_family = socket.AF_INET6 + def server_bind(self): + self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) + super().server_bind() + +class Handler(socketserver.BaseRequestHandler): + def handle(self): + connection = self.request + connection.settimeout(8) + if self.server.mode == 'greeting': + connection.sendall(b'220 loopback fixture ready\r\n') + return + if self.server.mode == 'ftp': + self.ftp(connection) + return + if self.server.mode == 'https': + connection = context.wrap_socket(connection, server_side=True) + tunnel = '' + for _ in range(2): + reader = connection.makefile('rb') + request = reader.readline().decode().strip() + if not request: + return + headers = {} + while True: + line = reader.readline().decode().strip() + if not line: + break + name, value = line.split(':', 1) + headers[name.lower()] = value.strip() + if request.startswith('CONNECT '): + # Terminate the fixture TLS connection here; never forward traffic. + tunnel = request + reader.close() + connection.sendall(b'HTTP/1.0 200 Connection established\r\n\r\n') + connection = context.wrap_socket(connection, server_side=True) + continue + posted = reader.read(int(headers.get('content-length', 0))).decode() + body = json.dumps({'request': request, 'host': headers.get('host'), + 'peer': connection.getpeername()[0], 'sni': getattr(connection, 'fixture_sni', None), + 'tunnel': tunnel, 'posted': posted}).encode() + connection.sendall(b'HTTP/1.0 200 OK\r\nContent-Length: ' + str(len(body)).encode() + + b'\r\nConnection: close\r\n\r\n' + body) + reader.close() + connection.close() + return + + def ftp(self, connection): + reader = connection.makefile('rb') + connection.sendall(b'220 loopback FTP fixture\r\n') + data = None + content = b'passive FTP marker\n' + try: + for line in reader: + command = line.decode().strip().split(' ', 1)[0] + if command == 'USER': + response = '331 Password required' + elif command == 'PASS': + response = '230 Logged in' + elif command == 'TYPE': + response = '200 Binary mode' + elif command == 'SIZE': + response = '213 ' + str(len(content)) + elif command in ('PASV', 'EPSV'): + data = socket.socket(self.server.address_family) + data.settimeout(8) + data.bind((self.server.server_address[0], 0)) + data.listen(1) + port = data.getsockname()[1] + response = (f'229 Entering Extended Passive Mode (|||{port}|)' if command == 'EPSV' + else f'227 Entering Passive Mode (127,0,0,1,{port//256},{port%256})') + elif command == 'RETR': + connection.sendall(b'150 Sending data\r\n') + stream, _ = data.accept() + stream.sendall(content) + stream.close() + data.close() + data = None + response = '226 Transfer complete' + elif command == 'QUIT': + connection.sendall(b'221 Goodbye\r\n') + return + else: + response = '500 Unsupported fixture command' + connection.sendall(response.encode() + b'\r\n') + finally: + if data: + data.close() + reader.close() + +servers, reservations, ports = [], [], {} +for name, family, mode in [('http4', 4, 'http'), ('http6', 6, 'http'), + ('https4', 4, 'https'), ('https6', 6, 'https'), ('proxy4', 4, 'http'), + ('proxy6', 6, 'http'), ('ftp4', 4, 'ftp'), ('ftp6', 6, 'ftp'), ('greeting', 4, 'greeting')]: + server = (Server if family == 4 else Server6)(('127.0.0.1' if family == 4 else '::1', 0), Handler) + server.mode = mode + ports[name] = server.server_address[1] + # Reserve the unused IPv4 endpoint so fallback tests reliably get refusal. + reservation = socket.socket() + reservation.bind(('127.0.0.1' if family == 6 else '127.0.0.2', ports[name])) + reservations.append(reservation) + servers.append(server) + threading.Thread(target=server.serve_forever, daemon=True).start() +refused = socket.socket() +refused.bind(('127.0.0.1', 0)) +ports['refused'] = refused.getsockname()[1] +(work / 'ports.json').write_text(json.dumps(ports)) +signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) +try: + signal.pause() +finally: + for server in servers: + server.server_close() + for reservation in reservations: + reservation.close() + refused.close() diff --git a/t/web-lib-funcs-open-socket-vm.t b/t/web-lib-funcs-open-socket-vm.t new file mode 100644 index 000000000..30b571756 --- /dev/null +++ b/t/web-lib-funcs-open-socket-vm.t @@ -0,0 +1,240 @@ +#!/usr/bin/perl +use strict; +use warnings; +no warnings qw(once redefine); +use Test::More; +use File::Temp qw(tempdir); +use FindBin; +use JSON::PP qw(decode_json); +use Symbol qw(gensym); +use POSIX qw(WNOHANG); + +plan skip_all => 'Set WEBMIN_OPEN_SOCKET_VM_TEST=1 on a disposable Linux VM' + unless ($ENV{'WEBMIN_OPEN_SOCKET_VM_TEST'} || '') eq '1'; +$^O eq 'linux' && $< == 0 or die 'Disposable root Linux VM required'; +my $source = $ENV{'WEBMIN_OPEN_SOCKET_SOURCE'} || + "$FindBin::Bin/../web-lib-funcs.pl"; +my $task = tempdir('socket-compat-XXXXXX', DIR => '/tmp', CLEANUP => 1); +note("Fixture directory: $task"); +my $server_pid; +END { + if ($server_pid) { + kill('TERM', $server_pid); + waitpid($server_pid, 0); + } +} +$server_pid = fork(); +defined($server_pid) or die $!; +if (!$server_pid) { + open(STDOUT, '>', "$task/server.log") or die $!; + open(STDERR, '>&', STDOUT) or die $!; + exec('python3', "$FindBin::Bin/fixtures/open-socket-server.py", $task); + die $!; +} +for (1..100) { + last if -s "$task/ports.json"; + my $exited = waitpid($server_pid, WNOHANG); + if ($exited == $server_pid) { + $server_pid = undef; + die "Fixture server exited: see $task/server.log"; + } + select(undef, undef, undef, 0.1); +} +open(my $ports_file, '<', "$task/ports.json") or die $!; +my $ports = decode_json(do { local $/; <$ports_file> }); +close($ports_file); + +$ENV{'WEBMIN_CONFIG'} = '/etc/webmin'; +$ENV{'WEBMIN_VAR'} = '/var/webmin'; +open(my $mc, '<', '/etc/webmin/miniserv.conf') or die $!; +my ($root) = map { /^root=(.*)/ ? $1 : () } <$mc>; +close($mc); +chdir("$root/webmin") or die $!; +$0 = "$root/webmin/socket-compat-test.pl"; +unshift(@INC, $root); +require WebminCore; +WebminCore->import(); +init_config(); +$main::error_must_die = 1; + +# Substitute only the exact original or staged function; use installed callers. +open(my $source_file, '<', $source) or die $!; +my $code = do { local $/; <$source_file> }; +close($source_file); +$code =~ /\n(sub open_socket\n\{.*?\n\})\n\n=head2 download_timeout/s or die 'Cannot extract open_socket'; +eval "package WebminCore; no strict; no warnings 'redefine'; $1"; +die $@ if $@; +*main::open_socket = \&WebminCore::open_socket; + +# Limit fixture DNS to loopback while preserving the real literal-IP handling. +my $original4 = \&WebminCore::to_ipaddress; +my $original6 = \&WebminCore::to_ip6address; +my %addresses = ( + 'four.invalid' => [['127.0.0.1'], []], + 'six.invalid' => [[], ['::1']], + 'dual.invalid' => [['127.0.0.1'], ['::1']], + 'multiple.invalid' => [['127.0.0.2', '127.0.0.1'], ['::1']], + 'missing.invalid' => [[], []], +); +*WebminCore::to_ipaddress = sub { + return $original4->(@_) unless exists $addresses{$_[0]}; + my @result = @{$addresses{$_[0]}->[0]}; + return wantarray ? @result : $result[0]; +}; +*WebminCore::to_ip6address = sub { + return $original6->(@_) unless exists $addresses{$_[0]}; + my @result = @{$addresses{$_[0]}->[1]}; + return wantarray ? @result : $result[0]; +}; +# No configured proxy credentials or unrelated HTTP cache writes enter the fixtures. +local @WebminCore::gconfig{qw(http_proxy ftp_proxy proxy_user proxy_pass proxy_fallback bind_proxy)}; +local *WebminCore::write_to_http_cache = sub {}; +local *WebminCore::no_proxy = sub { 0 }; + +sub download +{ + my ($host, $port, $ssl, $post) = @_; + my ($body, $error); + if (defined $post) { + http_post($host, $port, '/marker', $post, \$body, \$error, + undef, $ssl, undef, undef, 5, 0, 1); + } + else { + http_download($host, $port, '/marker', \$body, \$error, + undef, $ssl, undef, undef, 5, 0, 1); + } + is($error, undef, 'request succeeds'); + my $result = eval { decode_json($body || '') }; + ok($result, 'server returned the fixture response') or diag($body || '(empty)'); + return $result || {}; +} + +for my $case ( + ['HTTP IPv4 literal', '127.0.0.1', 'http4', 0], + ['HTTP IPv6 literal', '::1', 'http6', 0], + ['HTTP IPv4 hostname', 'four.invalid', 'http4', 0], + ['HTTP IPv6-only hostname', 'six.invalid', 'http6', 0], + ['HTTP dual-stack IPv4 preference', 'dual.invalid', 'http4', 0], + ['HTTP second IPv4 address', 'multiple.invalid', 'http4', 0], + ['HTTP IPv6 fallback', 'dual.invalid', 'http6', 0], + ['HTTPS IPv4', 'four.invalid', 'https4', 1], + ['HTTPS IPv6', 'six.invalid', 'https6', 1], + ['HTTPS IPv6 fallback', 'dual.invalid', 'https6', 1], +) { + subtest $case->[0] => sub { + my (undef, $host, $port, $ssl) = @$case; + my $reply = download($host, $ports->{$port}, $ssl); + is($reply->{'host'}, $host, 'HTTP Host is unchanged'); + is($reply->{'request'}, 'GET /marker HTTP/1.0', 'request reaches the endpoint'); + is($reply->{'sni'}, $host, 'TLS SNI is unchanged') if $ssl; + }; +} + +subtest 'POST request body' => sub { + my $reply = download('four.invalid', $ports->{'https4'}, 1, 'fixture=marker'); + is($reply->{'posted'}, 'fixture=marker', 'HTTPS POST body is preserved'); +}; + +for my $family (4, 6) { + for my $ssl (0, 1) { + subtest "HTTP proxy over IPv$family with TLS=$ssl" => sub { + local $WebminCore::gconfig{'http_proxy'} = "http://dual.invalid:$ports->{'proxy'.$family}"; + my $reply = download('origin.invalid', 8443, $ssl); + is($reply->{'host'}, 'origin.invalid', 'origin HTTP Host is preserved'); + is($reply->{'request'}, $ssl ? 'GET /marker HTTP/1.0' : + 'GET http://origin.invalid:8443/marker HTTP/1.0', 'proxy request format is preserved'); + if ($ssl) { + is($reply->{'tunnel'}, 'CONNECT origin.invalid:8443 HTTP/1.0', 'CONNECT negotiation succeeds'); + is($reply->{'sni'}, 'origin.invalid', 'TLS uses the origin hostname'); + } + }; + } +} + +subtest 'HTTP proxy failure with direct fallback' => sub { + local $WebminCore::gconfig{'http_proxy'} = "http://four.invalid:$ports->{'refused'}"; + local $WebminCore::gconfig{'proxy_fallback'} = 1; + my $reply = download('four.invalid', $ports->{'http4'}, 0); + is($reply->{'request'}, 'GET /marker HTTP/1.0', 'falls back to a direct request'); +}; +subtest 'HTTP proxy failure without fallback' => sub { + local $WebminCore::gconfig{'http_proxy'} = "http://four.invalid:$ports->{'refused'}"; + my ($body, $error); + http_download('four.invalid', $ports->{'http4'}, '/', \$body, \$error, + undef, 0, undef, undef, 5, 0, 1); + like($error, qr/^Failed to connect to four\.invalid:/, 'proxy connection failure reaches the caller'); +}; + +subtest 'Configured outgoing IPv4 address' => sub { + local $WebminCore::gconfig{'bind_proxy'} = '127.0.0.2'; + my $reply = download('four.invalid', $ports->{'http4'}, 0); + is($reply->{'peer'}, '127.0.0.2', 'configured source address is used'); +}; +subtest 'Explicit outgoing IPv4 address' => sub { + local $WebminCore::gconfig{'bind_proxy'} = '127.0.0.2'; + my $handle = make_http_connection('four.invalid', $ports->{'http4'}, 0, + 'GET', '/marker', [['Host', 'four.invalid']], '127.0.0.3'); + ok(ref($handle), 'connection succeeds with explicit bind address'); + my ($body, $error); + complete_http_download($handle, \$body, \$error, undef, undef, + 'four.invalid', $ports->{'http4'}, undef, 0, 1, 5); + is($error, undef, 'response succeeds'); + is(decode_json($body)->{'peer'}, '127.0.0.3', 'explicit source address takes precedence'); +}; + +for my $case ( + ['DNS failure', 'missing.invalid', qr/Failed to lookup IP address/], + ['Connection refusal', 'four.invalid', qr/Failed to connect to four\.invalid:/], +) { + subtest $case->[0] => sub { + my $fh = gensym(); + my $error; + is(open_socket($case->[1], $ports->{'refused'}, $fh, \$error), undef, 'returns failure'); + like($error, $case->[2], 'returns the expected error'); + close($fh) if defined(fileno($fh)); + }; +} +subtest 'Unavailable source address' => sub { + local $WebminCore::gconfig{'bind_proxy'} = '192.0.2.123'; + my $fh = gensym(); + my $error; + is(open_socket('four.invalid', $ports->{'http4'}, $fh, \$error), undef, 'bind failure is reported'); + like($error, qr/^Failed to bind to source address :/, 'bind error is preserved'); + close($fh) if defined(fileno($fh)); +}; +subtest 'Error without a reference' => sub { + my $fh = gensym(); + eval { open_socket('missing.invalid', 443, $fh) }; + like($@, qr/Failed to lookup IP address/, 'throws for callers without an error reference'); +}; + +{ + package FixtureCaller; + sub greeting { + my ($port) = @_; + my $error; + WebminCore::open_socket('127.0.0.1', $port, 'GREETING', \$error); + die $error if $error; + my $line = ; + close(GREETING); + return $line; + } +} +is(FixtureCaller::greeting($ports->{'greeting'}), "220 loopback fixture ready\r\n", + 'a named handle in another package can read a service greeting'); + +for my $family (4, 6) { + subtest "Passive FTP over IPv$family" => sub { + my $file = "$task/ftp-$family.txt"; + my $error; + my $host = $family == 4 ? 'four.invalid' : 'six.invalid'; + my $result = ftp_download($host, '/marker.txt', $file, \$error, undef, + 'fixture', 'fixture', $ports->{'ftp'.$family}, 1, 5); + is($error, undef, 'FTP download has no error'); + ok($result, 'FTP reports success'); + open(my $fh, '<', $file) or die $!; + is(do { local $/; <$fh> }, "passive FTP marker\n", 'control and data connections work'); + close($fh); + }; +} +done_testing(); diff --git a/t/web-lib-funcs-open-socket.t b/t/web-lib-funcs-open-socket.t new file mode 100644 index 000000000..65ce3dc18 --- /dev/null +++ b/t/web-lib-funcs-open-socket.t @@ -0,0 +1,98 @@ +#!/usr/bin/perl + +use strict; +use warnings; +no warnings qw(once redefine); +use Test::More; +use FindBin; +use Socket; +use Errno qw(ECONNREFUSED); + +my (@events, %connect_ok); + +# Exercise the real address selection without opening workstation sockets. +BEGIN { + *CORE::GLOBAL::socket = sub (*$$$) { return 1; }; + *CORE::GLOBAL::connect = sub (*$) { + my ($fh, $address) = @_; + my $family = sockaddr_family($address); + my $ip = $family == AF_INET() ? + inet_ntoa((unpack_sockaddr_in($address))[1]) : + inet_ntop(AF_INET6(), (unpack_sockaddr_in6($address))[1]); + push(@events, "connect $ip"); + $! = ECONNREFUSED unless $connect_ok{$ip}; + return $connect_ok{$ip} ? 1 : 0; + }; + } + +require "$FindBin::Bin/../web-lib-funcs.pl"; +*main::callers_package = sub { $_[0] }; +*main::supports_ipv6 = sub { 1 }; +*main::error = sub { die "$_[0]\n" }; + +sub run_case +{ +my ($v4, $v6, $ok, $without_error_ref) = @_; +@events = (); +%connect_ok = map { $_ => 1 } @$ok; +local %main::gconfig; +local *main::to_ipaddress = sub { + push(@events, 'lookup IPv4'); + return @$v4; + }; +local *main::to_ip6address = sub { + push(@events, 'lookup IPv6'); + return @$v6; + }; +my $buffer = ''; +open(my $fh, '>', \$buffer) or die $!; +my $error; +my $ip = eval { open_socket('probe.example', 443, $fh, + $without_error_ref ? undef : \$error) }; +my $exception = $@; +close($fh); +return ($ip, $error, $exception); +} + +my ($ip, $error, $exception) = run_case( + ['192.0.2.1'], ['2001:db8::1'], ['192.0.2.1']); +is($ip, '192.0.2.1', 'connects over IPv4'); +is_deeply(\@events, ['lookup IPv4', 'connect 192.0.2.1'], + 'a working IPv4 connection never waits for IPv6 DNS'); +is($error, undef, 'successful connection has no error'); +is($exception, '', 'successful connection does not throw'); + +($ip) = run_case(['192.0.2.1', '192.0.2.2'], ['2001:db8::1'], ['192.0.2.2']); +is($ip, '192.0.2.2', 'tries the next IPv4 address after a connection failure'); +is_deeply(\@events, ['lookup IPv4', 'connect 192.0.2.1', 'connect 192.0.2.2'], + 'a later working IPv4 address also avoids IPv6 DNS'); + +($ip, $error) = run_case( + ['192.0.2.1', '192.0.2.2'], ['2001:db8::1'], ['2001:db8::1']); +is($ip, '2001:db8::1', 'falls back to IPv6 when every IPv4 connection fails'); +is_deeply(\@events, ['lookup IPv4', 'connect 192.0.2.1', 'connect 192.0.2.2', + 'lookup IPv6', 'connect 2001:db8::1'], 'resolves IPv6 after the IPv4 attempts'); +is($error, undef, 'IPv6 fallback clears earlier connection failures'); + +($ip) = run_case([], ['2001:db8::1'], ['2001:db8::1']); +is($ip, '2001:db8::1', 'supports an IPv6-only hostname'); + +($ip, $error) = run_case([], [], []); +is($ip, undef, 'missing DNS records fail'); +is($error, 'Failed to lookup IP address for probe.example', 'reports DNS failure'); + +($ip, $error) = run_case(['192.0.2.1'], [], []); +is($ip, undef, 'an unsuccessful IPv4 connection fails when there is no IPv6'); +like($error, qr/^Failed to connect to probe\.example:443 : /, + 'preserves the connection error when IPv6 DNS is empty'); + +($ip, $error) = run_case(['192.0.2.1'], ['2001:db8::1'], []); +is($ip, undef, 'fails when neither address family connects'); +like($error, qr/^Failed to IPv6 connect to probe\.example:443 : /, + 'reports the last connection failure'); + +(undef, undef, $exception) = run_case([], [], [], 1); +is($exception, "Failed to lookup IP address for probe.example\n", + 'callers without an error reference still receive an exception'); + +done_testing(); diff --git a/web-lib-funcs.pl b/web-lib-funcs.pl index 197a2c14e..e32e3399a 100755 --- a/web-lib-funcs.pl +++ b/web-lib-funcs.pl @@ -4060,61 +4060,56 @@ if ($gconfig{'debug_what_net'}) { &webmin_debug_log('TCP', "host=$host port=$port"); } -# Lookup all IPv4 and v6 addresses for the host -my @ips = &to_ipaddress($host); -push(@ips, &to_ip6address($host)); -if (!@ips) { - my $msg = "Failed to lookup IP address for $host"; - if ($err) { $$err = $msg; return undef; } - else { &error($msg); } - } - -# Try each of the resolved IPs -my $msg; +# Try IPv4 first so a missing AAAA record cannot delay a working connection. +my ($msg, $gotip); my $proto = getprotobyname("tcp"); -my $gotip; -foreach my $ip (@ips) { - $msg = undef; - if (&check_ipaddress($ip)) { - # Create IPv4 socket and connection - if (!socket($fh, PF_INET(), SOCK_STREAM, $proto)) { - $msg = "Failed to create socket : $!"; - next; - } - my $addr = inet_aton($ip); - if ($gconfig{'bind_proxy'}) { - # BIND to outgoing IP - if (!bind($fh, pack_sockaddr_in(0, inet_aton($bindip)))) { - $msg = "Failed to bind to source address : $!"; +foreach my $lookup (\&to_ipaddress, \&to_ip6address) { + my @ips = &$lookup($host); + foreach my $ip (@ips) { + $msg = undef; + if (&check_ipaddress($ip)) { + # Create IPv4 socket and connection + if (!socket($fh, PF_INET(), SOCK_STREAM, $proto)) { + $msg = "Failed to create socket : $!"; + next; + } + my $addr = inet_aton($ip); + if ($gconfig{'bind_proxy'}) { + # BIND to outgoing IP + if (!bind($fh, pack_sockaddr_in(0, inet_aton($bindip)))) { + $msg = "Failed to bind to source address : $!"; + next; + } + } + if (!connect($fh, pack_sockaddr_in($port, $addr))) { + $msg = "Failed to connect to $host:$port : $!"; next; } } - if (!connect($fh, pack_sockaddr_in($port, $addr))) { - $msg = "Failed to connect to $host:$port : $!"; - next; + else { + # Create IPv6 socket and connection + if (!&supports_ipv6()) { + $msg = "IPv6 connections are not supported"; + next; + } + if (!socket($fh, PF_INET6(), SOCK_STREAM, $proto)) { + $msg = "Failed to create IPv6 socket : $!"; + next; + } + my $addr = inet_pton(AF_INET6(), $ip); + if (!connect($fh, pack_sockaddr_in6($port, $addr))) { + $msg = "Failed to IPv6 connect to $host:$port : $!"; + next; + } } + $gotip = $ip; + last; # If we got this far, it worked } - else { - # Create IPv6 socket and connection - if (!&supports_ipv6()) { - $msg = "IPv6 connections are not supported"; - next; - } - if (!socket($fh, PF_INET6(), SOCK_STREAM, $proto)) { - $msg = "Failed to create IPv6 socket : $!"; - next; - } - my $addr = inet_pton(AF_INET6(), $ip); - if (!connect($fh, pack_sockaddr_in6($port, $addr))) { - $msg = "Failed to IPv6 connect to $host:$port : $!"; - next; - } - } - $gotip = $ip; - last; # If we got this far, it worked + # Resolve IPv6 only after all IPv4 connection attempts have failed. + last if ($gotip); } -if ($msg) { - # Last attempt failed +if (!$gotip) { + $msg ||= "Failed to lookup IP address for $host"; if ($err) { $$err = $msg; return undef; } else { &error($msg); } } From 8a20f7ea34fb8549669b6789d0963a2bbd8100f0 Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Mon, 14 Sep 2026 01:36:41 +0200 Subject: [PATCH 2/2] Fix socket tests without Socket6 --- t/web-lib-funcs-open-socket.t | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/t/web-lib-funcs-open-socket.t b/t/web-lib-funcs-open-socket.t index 65ce3dc18..69eccc387 100644 --- a/t/web-lib-funcs-open-socket.t +++ b/t/web-lib-funcs-open-socket.t @@ -5,7 +5,8 @@ use warnings; no warnings qw(once redefine); use Test::More; use FindBin; -use Socket; +# Import IPv6 helpers even when the optional Socket6 module is absent. +use Socket qw(:DEFAULT inet_pton inet_ntop); use Errno qw(ECONNREFUSED); my (@events, %connect_ok);