mirror of
https://github.com/webmin/webmin.git
synced 2026-09-16 18:40:39 +01:00
This PR fixes delays when IPv6 DNS lookups stall otherwise working IPv4 connections. The earlier changes ([04c74877](04c74877c3) and [8a6f7784](8a6f778410)) 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.
146 lines
5.9 KiB
Python
146 lines
5.9 KiB
Python
#!/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()
|