mirror of
https://github.com/xroche/httrack.git
synced 2026-07-23 17:19:17 +03:00
* Reap a stalled TLS handshake with the per-slot --timeout back_wait only runs its per-slot timeout check when the local gestion_timeout flag is armed. The CONNECTING, WAIT_DNS and receiving handlers arm it, but the STATUS_SSL_WAIT_HANDSHAKE handler did not, so a peer that completes the TCP connect and never speaks TLS left SSL_connect returning WANT_READ until --max-time fired, ignoring --timeout entirely. Arm the flag in the handshake handler and start a fresh timeout window when the slot enters the handshake, so it is measured from there rather than from the connect. The generic check already reaps any status > 0 slot once armed; give it a distinct message instead of the generic "Receive Time Out". Test 59 crawls a server that accepts the connect and stays silent: it ends in --timeout seconds with the fix, and hangs until the kill guard without it. Closes #607 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Xavier Roche <roche@httrack.com> * Cover the handshake timeout window, and share the ephemeral-bind helper Test 59 only bounded the crawl from above, so an engine that reaped every handshake instantly passed it, and nothing exercised the timeout_refresh at the handshake entry: on loopback the connect is instant, so the handshake's own window is indistinguishable from the connect's. Add a floor to the first case, and a second one behind a proxy that takes 4s to answer CONNECT. The handshake must still get its full --timeout=5 from there (~9s); sharing the connect's clock reaps at ~5s. Dropping the refresh now fails the test, as does collapsing the window to zero. The stall server grows a proxy mode for that, and takes its listening socket from a new proxytestlib bind_ephemeral(), which replaces the same boilerplate in the socks5 and proxy servers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Xavier Roche <roche@httrack.com> --------- Signed-off-by: Xavier Roche <roche@httrack.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
152 lines
5.1 KiB
Python
152 lines
5.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Shared helpers for the local proxy test servers.
|
|
|
|
A CONNECT proxy in front of an origin, as used by proxy-https-server.py (TLS
|
|
origin, #85) and proxy-connect-server.py (plain origin, #564). socks5-server.py
|
|
reuses the relay only; its origin is specialised for keep-alive reuse.
|
|
Importable because Python puts the running script's directory on sys.path.
|
|
"""
|
|
import http.server
|
|
import os
|
|
import socket
|
|
import socketserver
|
|
import ssl
|
|
import sys
|
|
import threading
|
|
|
|
PROXY_LOG = "proxy.log"
|
|
ORIGIN_LOG = "origin-headers.log"
|
|
|
|
|
|
def bind_ephemeral():
|
|
"""Listening socket on a free loopback port, and that port."""
|
|
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
srv.bind(("127.0.0.1", 0))
|
|
srv.listen(16)
|
|
return srv, srv.getsockname()[1]
|
|
|
|
|
|
def pipe(src, dst):
|
|
"""Relay bytes one way until EOF, then tear both ends down."""
|
|
try:
|
|
while True:
|
|
data = src.recv(65536)
|
|
if not data:
|
|
break
|
|
dst.sendall(data)
|
|
except OSError:
|
|
pass
|
|
finally:
|
|
for sock in (src, dst):
|
|
try:
|
|
sock.shutdown(socket.SHUT_RDWR)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def make_origin(logdir, body):
|
|
class Origin(http.server.BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
# the request line proves origin-form vs absolute-URI (#564)
|
|
with open(os.path.join(logdir, ORIGIN_LOG), "a") as handle:
|
|
handle.write(self.requestline + "\n")
|
|
for key in self.headers.keys():
|
|
handle.write(key + "\n")
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/html")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def log_message(self, *args):
|
|
pass
|
|
|
|
return Origin
|
|
|
|
|
|
def start_origin(logdir, body, certfile=None):
|
|
"""Serve body on an ephemeral port, over TLS when certfile is given."""
|
|
httpd = socketserver.TCPServer(("127.0.0.1", 0), make_origin(logdir, body))
|
|
if certfile is not None:
|
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
ctx.load_cert_chain(certfile)
|
|
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
|
|
port = httpd.socket.getsockname()[1]
|
|
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
|
return port
|
|
|
|
|
|
def handle_client(conn, logdir, mode, default_port):
|
|
rfile = conn.makefile("rb")
|
|
request_line = rfile.readline().decode("latin-1").strip()
|
|
auth = None
|
|
while True:
|
|
line = rfile.readline().decode("latin-1")
|
|
if line in ("\r\n", "\n", ""):
|
|
break
|
|
key, _, value = line.partition(":")
|
|
if key.strip().lower() == "proxy-authorization":
|
|
auth = value.strip()
|
|
with open(os.path.join(logdir, PROXY_LOG), "a") as handle:
|
|
handle.write(request_line + "\n")
|
|
if auth is not None:
|
|
handle.write("AUTH " + auth + "\n")
|
|
parts = request_line.split()
|
|
# CONNECT-only: reject the classic absolute-URI form a normal proxy accepts
|
|
if not (len(parts) >= 2 and parts[0] == "CONNECT"):
|
|
conn.sendall(b"HTTP/1.0 501 Not Implemented\r\n\r\n")
|
|
conn.close()
|
|
return
|
|
if mode == "flood":
|
|
# 200, then endless headers with no blank line: the client must not hang
|
|
try:
|
|
conn.sendall(b"HTTP/1.0 200 Connection established\r\n")
|
|
while True:
|
|
conn.sendall(b"X-Pad: 0123456789\r\n")
|
|
except OSError:
|
|
pass
|
|
conn.close()
|
|
return
|
|
host, _, port = parts[1].partition(":")
|
|
try:
|
|
# default_port only backstops a portless CONNECT; httrack sends host:port
|
|
upstream = socket.create_connection((host, int(port or default_port)))
|
|
except OSError:
|
|
conn.sendall(b"HTTP/1.0 502 Bad Gateway\r\n\r\n")
|
|
conn.close()
|
|
return
|
|
conn.sendall(b"HTTP/1.0 200 Connection established\r\n\r\n")
|
|
threading.Thread(target=pipe, args=(conn, upstream), daemon=True).start()
|
|
pipe(upstream, conn)
|
|
|
|
|
|
def start_proxy(logdir, mode, default_port):
|
|
srv, port = bind_ephemeral()
|
|
|
|
def accept_loop():
|
|
while True:
|
|
conn, _ = srv.accept()
|
|
threading.Thread(
|
|
target=handle_client,
|
|
args=(conn, logdir, mode, default_port),
|
|
daemon=True,
|
|
).start()
|
|
|
|
threading.Thread(target=accept_loop, daemon=True).start()
|
|
return port
|
|
|
|
|
|
def serve(logdir, origin_body, default_port, mode="ok", certfile=None):
|
|
"""Start the origin+proxy pair, announce both ports, then block forever."""
|
|
for name in (PROXY_LOG, ORIGIN_LOG):
|
|
open(os.path.join(logdir, name), "w").close()
|
|
origin_port = start_origin(logdir, origin_body, certfile)
|
|
proxy_port = start_proxy(logdir, mode, default_port)
|
|
# Keep the port lines the caller parses LF: Windows would emit \r\n.
|
|
sys.stdout.reconfigure(newline="\n")
|
|
print("ORIGIN %d" % origin_port, flush=True)
|
|
print("PROXY %d" % proxy_port, flush=True)
|
|
print("ready", flush=True)
|
|
threading.Event().wait()
|