Author: ichristod
Date: Thu Jun 4 06:55:13 2026
New Revision: 1934963
Log:
mod_proxy: add response handling tests
Add coverage for backend Content-Type handling, X-Forwarded-*
headers and request body preservation during balancer failover.
Add a simple TCP backend faker used by the tests.
Added:
httpd/httpd/trunk/test/modules/proxy/test_03_response.py (contents, props changed)
Modified:
httpd/httpd/trunk/test/modules/proxy/env.py
Modified: httpd/httpd/trunk/test/modules/proxy/env.py
==============================================================================
--- httpd/httpd/trunk/test/modules/proxy/env.py Thu Jun 4 06:45:04 2026 (r1934962)
+++ httpd/httpd/trunk/test/modules/proxy/env.py Thu Jun 4 06:55:13 2026 (r1934963)
@@ -1,22 +1,72 @@
import inspect
import logging
import os
-import subprocess
-from typing import Dict, Any
+import socket
+from threading import Thread
from pyhttpd.certs import CertificateSpec
-from pyhttpd.conf import HttpdConf
+
from pyhttpd.env import HttpdTestEnv, HttpdTestSetup
log = logging.getLogger(__name__)
+class TCPFaker:
+ # tcp backend for custom responses
+
+ def __init__(self, host, port):
+ self._thread = None
+ self._socket = None
+ self._host = host
+ self._port = port
+ self._done = False
+
+ def start(self):
+ def process():
+ self._socket.listen(1)
+ self._socket.settimeout(0.5)
+ self._process()
+
+ self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ self._socket.bind((self._host, self._port))
+ self._thread = Thread(target=process, daemon=True)
+ self._thread.start()
+
+ def stop(self):
+ self._done = True
+ self._thread.join(timeout=5)
+ self._socket.close()
+
+ def _make_response(self, data):
+ return """HTTP/1.1 200 OK
+Content-Type: text/html
+Content-Length: 5
+
+Hello""".encode()
+
+ def _process(self):
+ while not self._done:
+ try:
+ c, client_address = self._socket.accept()
+ try:
+ data = c.recv(4096)
+ c.sendall(self._make_response(data))
+ finally:
+ c.close()
+ except socket.timeout:
+ pass
+ except ConnectionAbortedError:
+ self._done = True
+
+
class ProxyTestSetup(HttpdTestSetup):
def __init__(self, env: 'HttpdTestEnv'):
super().__init__(env=env)
self.add_source_dir(os.path.dirname(inspect.getfile(ProxyTestSetup)))
- self.add_modules(["proxy", "proxy_http", "proxy_balancer", "lbmethod_byrequests"])
+ self.add_modules(["proxy", "proxy_http", "proxy_ajp", "proxy_balancer",
+ "lbmethod_byrequests", "remoteip"])
class ProxyTestEnv(HttpdTestEnv):
@@ -24,17 +74,20 @@ class ProxyTestEnv(HttpdTestEnv):
def __init__(self, pytestconfig=None):
super().__init__(pytestconfig=pytestconfig)
self.add_httpd_conf([
- ])
+ ])
self._d_reverse = f"reverse.{self.http_tld}"
self._d_forward = f"forward.{self.http_tld}"
self._d_mixed = f"mixed.{self.http_tld}"
- self.add_httpd_log_modules(["proxy", "proxy_http", "proxy_balancer", "lbmethod_byrequests", "ssl"])
+ self.add_httpd_log_modules(
+ ["proxy", "proxy_http", "proxy_balancer", "lbmethod_byrequests",
+ "ssl"])
self.add_cert_specs([
CertificateSpec(domains=[
self._d_forward, self._d_reverse, self._d_mixed
]),
- CertificateSpec(domains=[f"noh2.{self.http_tld}"], key_type='rsa2048'),
+ CertificateSpec(domains=[f"noh2.{self.http_tld}"],
+ key_type='rsa2048'),
])
def setup_httpd(self, setup: HttpdTestSetup = None):
Added: httpd/httpd/trunk/test/modules/proxy/test_03_response.py
==============================================================================
--- /dev/null 00:00:00 1970 (empty, because file is newly added)
+++ httpd/httpd/trunk/test/modules/proxy/test_03_response.py Thu Jun 4 06:55:13 2026 (r1934963)
@@ -0,0 +1,124 @@
+import re
+
+import pytest
+
+from pyhttpd.conf import HttpdConf
+from .env import TCPFaker
+
+
+class _ResponseFaker(TCPFaker):
+
+ def _make_response(self, data):
+ first_line = data.split(b"\r\n")[0].decode("latin-1")
+ path = first_line.split(" ")[1] if " " in first_line else "/"
+
+ if "/content-type" in path:
+ return """HTTP/1.1 200 OK\r\nContent-Type:
+ text/html\x00extra\r\n\r\n""".encode()
+
+ if "/forwarded" in path:
+ headers = data.split(b"\r\n\r\n")[0].decode("latin-1")
+ forwarded = []
+ for line in headers.split("\r\n"):
+ if line.lower().startswith("x-forwarded-"):
+ forwarded.append(line)
+ return ("HTTP/1.1 200 OK\r\n" + "\r\n".join(
+ forwarded) + "\r\n\r\n").encode()
+
+ if "/balancer" in path:
+ headers = data.split(b"\r\n\r\n")[0].decode("latin-1")
+ body_lines = []
+ for line in headers.split("\r\n"):
+ if ": " in line:
+ name, value = line.split(": ", 1)
+ body_lines.append(f"{name.upper()} = {value}")
+ body = "\r\n".join(body_lines)
+ return (
+ "HTTP/1.1 200 OK\r\n"
+ "Content-Type: text/plain\r\n"
+ f"Content-Length: {len(body.encode())}\r\n"
+ "\r\n"
+ + body
+ ).encode()
+
+ return super()._make_response(data)
+
+
+class TestProxyResponse:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ faker = _ResponseFaker("127.0.0.1", env.http_port2)
+ faker.start()
+
+ conf = HttpdConf(env)
+ conf.add([
+ '<Proxy "balancer://xqacluster">',
+ f' BalancerMember "http://127.0.0.1:{env.http_port2 + 1}"',
+ f' BalancerMember "http://127.0.0.1:{env.http_port2}"',
+ ' ProxySet forcerecovery=Off',
+ '</Proxy>',
+ ])
+ conf.start_vhost(domains=[f"test1.{env.http_tld}"], port=env.http_port,
+ doc_root="htdocs", with_ssl=False)
+ conf.add([
+ "ProxyPass /balancer balancer://xqacluster/balancer",
+ f"ProxyPass / http://127.0.0.1:{env.http_port2}/",
+ f"ProxyPassReverse / http://127.0.0.1:{env.http_port2}/",
+ ])
+ if env.has_shared_module("remoteip"):
+ conf.add([
+ "RemoteIPHeader X-Forwarded-For",
+ "RemoteIPTrustedProxy 0.0.0.0",
+ ])
+ conf.end_vhost()
+ conf.install()
+ assert env.apache_restart() == 0
+ yield
+ faker.stop()
+
+ # check Content-Type
+ def test_proxy_03_001(self, env):
+ if not env.httpd_is_at_least("2.4.55"):
+ pytest.skip(f'need at least httpd 2.4.55 for this')
+
+ r = env.curl_get(env.mkurl("http", "test1", "/content-type"))
+ assert r.response["status"] == 502
+
+ env.httpd_error_log.ignore_recent(
+ lognos=["AH01106", "AH10404"]
+ )
+
+ # checks X-Forwarded headers
+ def test_proxy_03_002(self, env):
+ if not env.httpd_is_at_least("2.4.54"):
+ pytest.skip(f'need at least httpd 2.4.54 for this')
+
+ r = env.curl_get(env.mkurl("http", "test1", "/forwarded"), options=[
+ '-H', 'Connection: close, X-Forwarded-For, X-Forwarded-Host, '
+ 'X-Forwarded-Server',
+ ])
+ assert r.response["status"] == 200
+ assert "x-forwarded-for" in r.response["header"]
+ assert "x-forwarded-host" in r.response["header"]
+ assert "x-forwarded-server" in r.response["header"]
+
+ # check body is preserved after failover
+ def test_proxy_03_003(self, env):
+ if not env.httpd_is_at_least("2.4.42"):
+ pytest.skip(f'need at least httpd 2.4.42 for this')
+
+ post_body = "123testing"
+ r = env.curl_get(
+ env.mkurl("http", "test1", "/balancer"),
+ options=['--data', post_body, '-H', 'Content-Type: text/plain; '
+ 'charset=utf-8']
+ )
+ assert r.response["status"] == 200
+ body = r.response["body"].decode("latin-1")
+ m = re.search(r'CONTENT-LENGTH = (\d+)', body, re.IGNORECASE)
+ assert m is not None
+ assert int(m.group(1)) == len(post_body)
+ env.httpd_error_log.ignore_recent(
+ lognos=["AH00957", "AH00959", "AH01114"]
+ )
lmpx.com only provides a reader for public news (NNTP) servers. It is not
affiliated with the servers or forums shown here and is not responsible for
the content of articles, which is written by their respective authors.