Browse Source

Ignore virtualenv

Fabrizio Furnari 1 month ago
parent
commit
daca17eb5f

+ 2 - 1
.gitignore

@@ -4,4 +4,5 @@
 /.mypy_cache/
 /.tox/
 __pycache__/
-*.pyc
+*.pyc
+venv/

+ 7 - 7
example_tests/01-simple.py

@@ -12,16 +12,16 @@ class BasicPassTest(BaseProxyTest):
         super().__init__()
         self.description = "Basic request"
         # Default address of proxy to listen is 127.0.0.1:4242
-        self.url = "http://localhost:4242/"
+        self.url = "http://127.0.0.1:4242"
 
         # Customize the haproxy path
         self.proxy_config = ProxyConfig(
             binary_path=Path.home() / "bin/haproxy"
         )
 
-    async def run_test(self):
-        """This is the bare minimum to run the test
-        """
-        await self.make_request()
-        # Returning True as this must always pass
-        return True
+    # def run_test(self):
+    #     """This is the bare minimum to run the test
+    #     """
+    #     self.make_request()
+    #     # Returning True as this must always pass
+    #     return True

+ 9 - 9
example_tests/02-basic_header_check.py

@@ -23,9 +23,9 @@ class FailHeaderCheck(BaseProxyTest):
             binary_path=Path.home() / "bin/haproxy"
         )
 
-    async def run_test(self):
-        await self.make_request()
-        return True
+    # def run_test(self):
+    #     self.make_request()
+    #     return True
 
 
 class PassHeaderCheck(BaseProxyTest):
@@ -54,9 +54,9 @@ class PassHeaderCheck(BaseProxyTest):
             response_headers={"X-Test": "1234"},
         )
 
-    async def run_test(self):
-        """The run_test() method is always the same as all logic
-        is defined entirely in the classes configuration.
-        """
-        await self.make_request()
-        return True
+    # def run_test(self):
+    #     """The run_test() method is always the same as all logic
+    #     is defined entirely in the classes configuration.
+    #     """
+    #     self.make_request()
+    #     return True

+ 3 - 3
example_tests/09-basic-logic.py

@@ -13,15 +13,15 @@ class ExampleLogicTest(BaseProxyTest):
             binary_path=Path.home() / "bin/haproxy"
         )
 
-    async def run_test(self):
+    def run_test(self):
         # Perform 2 requests with different headers
         self.headers = {"X-Test": "1234"}
-        res1 = await self.make_request()
+        res1 = self.make_request()
         print(f"Response 1 {res1}")
         # Check headers received by the backend
         print(self.backend.received_headers)
         self.headers = {"X-Another": "abcd"}
-        res2 = await self.make_request()
+        res2 = self.make_request()
         print(f"Response 2 {res2}")
         print(self.backend.received_headers)
         return True

+ 24 - 0
haproxy.cfg.tpl

@@ -0,0 +1,24 @@
+# HAProxy configuration template
+# {BACKEND_ADDRESS} will be replaced with the actual backend address
+
+global
+    daemon
+    log stdout local0
+    
+defaults
+    mode http
+    timeout connect 5000ms
+    timeout client 50000ms
+    timeout server 50000ms
+    option httplog
+    log global
+
+listen http
+    bind {{ listen_addr }}:{{ listen_port }}
+    option httplog
+    # TODO: add template directives
+    #http-request return status 200 content-type "text/plain" string "OK"
+    server dummy {{ backend_host }}:{{ backend_port }}
+
+
+

+ 94 - 0
httphound/backend.py

@@ -0,0 +1,94 @@
+"""
+Manages backend lifecycle
+"""
+
+import logging
+import asyncio
+
+from typing import Dict
+from dataclasses import dataclass, field
+from aiohttp import web
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class BackendConfig:
+    """Configuration for the test backend server"""
+    host: str = '127.0.0.1'
+    port: int = 9999
+    response_headers: Dict[str, str] = field(default_factory=dict)
+    response_body: str = "OK"
+    response_status: int = 200
+
+
+class DummyBackend:
+    """Dummy backend server using aiohttp"""
+
+    def __init__(self, config: BackendConfig):
+        self.config = config
+        self.app = None
+        self.runner = None
+        self.site = None
+        self.received_headers = {}
+        self.received_body = ""
+        self.request_count = 0
+
+        # async control
+        self.stop_event = None
+        self.server_task = None
+
+    async def request_handler(self, request):
+        """Handle incoming requests and validate them"""
+        self.request_count += 1
+        logger.debug(f"Requests count: {self.request_count}")
+        self.received_headers = dict(request.headers)
+        self.received_body = await request.text()
+
+        return web.Response(
+            status=self.config.response_status,
+            headers=self.config.response_headers,
+            text=self.config.response_body
+        )
+
+    async def _run_server(self):
+        """Start the dummy backend server"""
+
+        app = web.Application()
+        app.router.add_route('*', '/{path:.*}', self.request_handler)
+
+        self.runner = web.AppRunner(app)
+        await self.runner.setup()
+
+        self.site = web.TCPSite(
+            self.runner,
+            self.config.host,
+            self.config.port
+        )
+        await self.site.start()
+        logger.debug(f"Backend listening on {self.config.host}:{self.config.port}")
+        logger.debug(f"Site is {self.site}")
+        self.stop_event = asyncio.Event()
+        await self.stop_event.wait()
+
+    async def _start_async(self):
+        self.server_task = asyncio.create_task(self._run_server())
+        await asyncio.sleep(0.1)
+
+    async def _stop_async(self):
+        if self.stop_event:
+            self.stop_event.set()
+        if self.server_task:
+            await self.server_task
+        if self.site:
+            await self.site.stop()
+        if self.runner:
+            await self.runner.cleanup()
+
+    def start(self):
+        """Synchronous interface to start server"""
+        asyncio.run(self._start_async(), debug=True)
+
+    def stop(self):
+        """Synchronous interface to stop server"""
+        asyncio.run(self._stop_async())

+ 446 - 0
httphound/main.py

@@ -0,0 +1,446 @@
+#!/usr/bin/env python3
+
+import argparse
+
+import glob
+import importlib.util
+import logging
+import re
+import sys
+import time
+import os
+import asyncio
+
+from enum import StrEnum
+from pathlib import Path
+from typing import List, Optional, Tuple
+from abc import ABC, abstractmethod
+from dataclasses import dataclass, field
+
+import httpx
+from tabulate import tabulate
+from termcolor import colored
+
+from .backend import BackendConfig, DummyBackend
+from .proxy import ProxyConfig, ProxyManager
+
+logger = logging.getLogger(__name__)
+
+
+class TestStatus(StrEnum):
+    PASS = "green"
+    FAIL = "yellow"
+    ERROR = "red"
+
+
+@dataclass
+class TestResult:
+    """Result of a single test execution"""
+    test_id: str
+    description: str
+    status: TestStatus
+    duration: float
+    error_messages: Optional[List[str]] = field(default_factory=list)
+
+
+class BaseProxyTest(ABC):
+    """Base class for all HTTP tests
+
+    All test classes must inherit from this class and eventually
+    override the required parameters.
+
+    Attributes:
+        description (str): Description for the test that will be printed in overall summary
+        url (str): URL to run the test against (default: 'http://localhost:4242/')
+        method (str): HTTP method to use (default: GET)
+        headers (dict): A dictionary of headers that will be sent by the client
+            while performing the request.
+        body (str): A string that will be sent as request body (if applicable)
+        expected_status (int): The HTTP status that will be compared with the received
+            one. The test fails if doesnt' match (default: 200)
+        expected_headers (dict): A dictionary (header_name: header_value) of headers
+            that needs to be present in the response. The test fails if the header_name
+            is not present in the response or if header_value doesn't match.
+        expected_body_pattern (str): A regex used to match the body content. The test
+            fails if the match isn't found.
+        expected_header_patterns (dict): a dictionary (header_name: header_value_pattern)
+            that will be checked against received headers. If header_name is not present
+            in the response headers or header_value_pattern doesn't match the header
+            content, the test will fail.
+        forbidden_client_headers (list): A list of header names that needs to be *absent*
+            from the header list sent to the client. If any of the header name is found in
+            the response the test will fail.
+        expected_backend_headers (list): A list of headers that needs to be present in the
+            request sent to the backend. This is used to check if the reverse proxy deletes
+            one or more headers before forwarding the request to the backend. The test will
+            fail if one or more header are not present.
+        forbidden_backend_headers (list): Similar to the above parameter but the test will
+            fail instead if any of the header is found among the headers received by the
+            backend.
+        backend_header_patterns (dict): A dictionary (header_name: header_value_pattern)
+            that will be checked against the headers received by the backend. If the header
+            name is not present in the backend headers or header_value_pattern doesn't match
+            the header value received by the backend, the test will fail.
+    """
+
+    def __init__(self):
+        self.test_id = self.__class__.__name__
+        self.description = getattr(self, 'description',
+                                   'No description provided')
+        self.url = getattr(self, 'url', 'http://localhost:4242/')
+        self.method = getattr(self, 'method', 'GET')
+        self.headers = getattr(self, 'headers', {})
+        self.body = getattr(self, 'body', '')
+
+        # Test configuration
+        self.backend_config = getattr(self, 'backend_config', BackendConfig())
+        self.proxy_config = getattr(self, 'proxy_config', ProxyConfig())
+
+        # Validation rules
+        self.expected_status = getattr(self, 'expected_status', 200)
+        self.expected_headers = getattr(self, 'expected_headers', {})
+        self.expected_body_pattern = getattr(self, 'expected_body_pattern', None)
+        self.expected_header_patterns = getattr(self, 'expected_header_patterns', {})
+        self.forbidden_client_headers = getattr(self, 'forbidden_client_headers', [])
+        self.expected_backend_headers = getattr(self, 'expected_backend_headers', [])
+        self.forbidden_backend_headers = getattr(self, 'forbidden_backend_headers', [])
+        self.backend_header_patterns = getattr(self, 'backend_header_patterns', {})
+
+        # Runtime data
+        self.backend = None
+        self.proxy = None
+        self.response = None
+        self.response_headers = {}
+        self.response_body = ""
+
+    def setup(self):
+        logger.debug("Setting up test environment")
+        # Start backend
+        logger.debug(f"Instantiating backend with configuration: {self.backend_config}")
+        self.backend = DummyBackend(self.backend_config)
+        logger.debug("Starting backend")
+        self.backend.start()
+        time.sleep(999)
+
+        # Start proxy
+        logger.debug(f"Instantiating reverse proxy with configuration: {self.proxy_config}")
+        self.proxy = ProxyManager(self.proxy_config)
+        logger.debug(f"Starting reverse proxy with configuration: {self.backend_config}")
+        self.proxy.start(self.backend_config)
+        logger.debug("Sleeping for 0.1s before proceeding")
+        time.sleep(0.1)
+
+    def teardown(self):
+        logger.debug("Cleaning up test environment")
+        if self.backend:
+            logger.debug("Stopping backend")
+            asyncio.run(self.backend.stop())
+        if self.proxy:
+            logger.debug("Stopping reverse proxy")
+            self.proxy.stop()
+
+    def make_request(self):
+        """Make HTTP request through the proxy"""
+
+        request = httpx.Request(
+            method=self.method,
+            url=self.url,
+            headers=self.headers,
+            content=self.body)
+        logger.debug(f"Performing HTTP request: {request}")
+        with httpx.Client(http2=True) as client:
+            response = client.send(request=request)
+            logger.debug(f"Response: {response}")
+            self.response = response
+
+        return response
+
+    def validate_response(self) -> Tuple[TestStatus, Optional[List[str]]]:
+        """Validate the HTTP response"""
+        validation_errors = []
+        # Check status code
+        if self.response.status_code != self.expected_status:
+            logger.info(f"Expected status {self.expected_status}, got {self.response.status_code}")
+            validation_errors.append(
+                f"Expected status {self.expected_status}, got {self.response.status_code}")
+
+        # Check expected headers
+        for header, expected_value in self.expected_headers.items():
+            if header not in self.response_headers:
+                logger.info(f"Expected header '{header}' not found in response")
+                validation_errors.append(f"Expected header '{header}' not found in response")
+            else:
+                received_value = self.response_headers[header]
+                if received_value != expected_value:
+                    logger.info(
+                        f"Header '{header}' expected '{expected_value}', got '{received_value}'")
+                    validation_errors.append(
+                        f"Header '{header}' expected '{expected_value}', got '{received_value}'")
+
+        # Check header patterns
+        for header, pattern in self.expected_header_patterns.items():
+            if header not in self.response_headers:
+                logger.info(f"Header '{header}' for pattern matching not found")
+                validation_errors.append(f"Header '{header}' for pattern matching not found")
+
+            if not re.match(pattern, self.response_headers[header]):
+                logger.info(f"Header '{header}' value doesn't match pattern '{pattern}'")
+                validation_errors.append(
+                    f"Header '{header}' value doesn't match pattern '{pattern}'")
+
+        # Check forbidden client headers
+        for header in self.forbidden_client_headers:
+            if header in self.response_headers:
+                logger.info(f"Forbidden header '{header}' found in client response")
+                validation_errors.append(f"Forbidden header '{header}' found in client response")
+
+        # Check body pattern
+        if self.expected_body_pattern and not re.search(
+                self.expected_body_pattern, self.response_body):
+            logger.info(f"Response body doesn't match pattern '{self.expected_body_pattern}'")
+            validation_errors.append(
+                f"Response body doesn't match pattern '{self.expected_body_pattern}'")
+
+        # Check backend headers
+        for header in self.expected_backend_headers:
+            if header not in self.backend.received_headers:
+                logger.info(f"Expected header {header} in backend headers")
+                validation_errors.append(f"Expected header {header} in backend headers")
+
+        # Check backend headers that shouldn't be there
+        for header in self.forbidden_backend_headers:
+            if header in self.backend.received_headers:
+                logger.info(f"Forbidden header '{header}' found in backend request")
+                validation_errors.append(f"Forbidden header '{header}' found in backend request")
+
+        # Check backend header patterns
+        for header, pattern in self.backend_header_patterns.items():
+            if header not in self.backend.received_headers:
+                logger.info(f"Expected backend header '{header}' not found")
+                validation_errors.append(f"Expected backend header '{header}' not found")
+
+            if not re.match(pattern, self.backend.received_headers[header]):
+                logger.info(f"Backend header '{header}' doesn't match pattern '{pattern}'")
+                validation_errors.append(
+                    f"Backend header '{header}' doesn't match pattern '{pattern}'")
+
+        logger.debug("All checks done")
+        if validation_errors:
+            return TestStatus.FAIL, validation_errors
+        return TestStatus.PASS, None
+
+    #@abstractmethod
+    def run_test(self) -> bool:
+        """Run the actual test logic. Must be implemented by subclasses"""
+        self.make_request()
+        return True
+
+    def execute(self) -> TestResult:
+        """Execute the complete test"""
+        start_time = time.time()
+
+        try:
+            self.setup()
+            success = self.run_test()
+
+            if success:
+                logger.debug("Validating response...")
+                # Validate response if request was made
+                if self.response:
+                    validation_status, errors = self.validate_response()
+                    return TestResult(
+                        self.test_id,
+                        self.description,
+                        validation_status,
+                        time.time() - start_time,
+                        errors
+                    )
+            else:
+                return TestResult(
+                    self.test_id,
+                    self.description,
+                    TestStatus.ERROR,
+                    time.time() - start_time,
+                    ["Test logic failed"]
+                )
+
+        except Exception as e:
+            logger.exception(f"Test {self.test_id} failed with exception")
+            return TestResult(
+                self.test_id,
+                self.description,
+                TestStatus.ERROR,
+                time.time() - start_time,
+                [str(e)]
+            )
+        finally:
+            self.teardown()
+
+
+class TestRunner:
+    """Main test runner"""
+
+    def __init__(self):
+        self.results = []
+
+    def run_all_tests(self, tests: List[BaseProxyTest]) -> TestResult:
+        """Run all tests"""
+        logger.debug("Running all tests")
+        for test in tests:
+            logger.info(colored(f"Running test: {test.test_id}", "blue"))
+            result = test.execute()
+            logger.debug(f"Test result for {test.test_id}: {result}")
+            self.results.append(result)
+        return result
+
+    def print_summary(self):
+
+        total_tests = len(self.results)
+        passed_tests = sum(1 for r in self.results if r.status == TestStatus.PASS)
+        failed_tests = total_tests - passed_tests
+        total_time = sum(r.duration for r in self.results)
+
+        print("TEST EXECUTION SUMMARY")
+        print()
+        for result in self.results:
+            if result.status != TestStatus.PASS:
+                print(f"Test {colored(result.test_id, 'blue')} failed with following errors:")
+                for msg in result.error_messages:
+                    print(colored(f"\t{msg}", result.status.value))
+                print()
+
+        result_table = [["Status", "Name", "Description", "Duration"]]
+
+        for result in self.results:
+            status_color = colored(result.status.name, result.status.value)
+
+            result_table.append([status_color,
+                                 result.test_id,
+                                 result.description,
+                                 result.duration])
+
+        print(tabulate(result_table, headers="firstrow"))
+        print()
+        print(tabulate([["Total tests", "Passed", "Failed", "Total duration"],
+                        [total_tests, passed_tests, failed_tests, f"{total_time:.2f}"]]))
+
+
+def discover_tests(paths: List[str]) -> List[BaseProxyTest]:
+    """Discover all test classes in the passed paths and returns
+    a list of tests ready to be run
+    """
+    all_files = []
+    tests = []
+
+    for p in paths:
+        test_path = Path(p)
+
+        if not test_path.exists():
+            logger.error(f"{test_path} does not exists")
+            continue
+
+        logger.debug(f"Searching for test files in {test_path}")
+        if test_path.is_dir():
+            all_files.extend(test_path.glob("*.py"))
+        elif test_path.is_file():
+            if test_path.suffix == ".py":
+                all_files.append(test_path)
+        elif '*' in test_path or '?' in test_path or '[' in test_path:
+            globbed_files = glob.glob(test_path)
+            for f in globbed_files:
+                if os.path.isfile(f) and f.endswith(".py"):
+                    all_files.append(f)
+        else:
+            logger.error(f"Cannot find test files in {test_path}")
+            raise RuntimeError(f"Cannot find test files in {test_path}")
+
+    if not all_files:
+        logger.error(f"No test file to import from {paths}")
+        raise RuntimeError(f"Not test files to import from {paths}")
+
+    for test_file in all_files:
+        try:
+            spec = importlib.util.spec_from_file_location(
+                f"test_{test_file.stem}",
+                test_file,
+            )
+            module = importlib.util.module_from_spec(spec)
+            spec.loader.exec_module(module)
+
+            # Find all test classes
+            for attr_name in dir(module):
+                attr = getattr(module, attr_name)
+                if (isinstance(attr, type) and
+                    issubclass(attr, BaseProxyTest) and
+                        attr != BaseProxyTest):
+                    tests.append(attr())
+            logger.debug(f"Test classes: {tests}")
+        except Exception as e:
+            logger.error(f"Failed to load test file {test_file}: {e}")
+
+    return tests
+
+
+def setup_logging(level):
+    """Configure logging"""
+    logging.basicConfig(
+        level=level,
+        format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
+    )
+
+def main():
+    parser = argparse.ArgumentParser(description="Reverse roxy test tool")
+    parser.add_argument(
+        '--log-level', '-l',
+        type=str.upper,
+        choices=['DEBUG', 'INFO', 'WARNING', 'ERROR'],
+        default='INFO', help='Set logging level (Default: INFO)')
+    parser.add_argument(
+        'paths',
+        nargs='+',
+        help='Directories, file patterns, or individual Python files (.py)')
+    args = parser.parse_args()
+
+    log_level = getattr(logging, args.log_level.upper())
+    setup_logging(log_level)
+    logger = logging.getLogger("httphound")
+
+    logger.info("Starting httphound")
+    # a little Ascii art only in debug mode
+
+    hound = '''
+          __
+ \ ______/ V`-,
+  }        /~~
+ /_)^ --,r'
+|b      |b
+'''
+    logger.debug(hound)
+
+    try:
+        tests = discover_tests(args.paths)
+        logger.info(f"Discovered {len(tests)} tests in {args.paths}")
+
+        if not tests:
+            logger.info("No tests found")
+            sys.exit(0)
+
+        # Run tests
+        runner = TestRunner()
+        runner.run_all_tests(tests)
+
+        # Print summary
+        runner.print_summary()
+
+        # Exit with error code if any tests failed
+        failed_count = sum(1 for r in runner.results if not r.status == TestStatus.PASS)
+        if failed_count > 0:
+            raise RuntimeError(f"{failed_count} over {len(runner.results)} test failed")
+
+    except Exception as e:
+        logger.error(f"Test execution failed: {e}")
+        sys.exit(1)
+
+
+def start():
+    main()

+ 124 - 0
httphound/proxy.py

@@ -0,0 +1,124 @@
+"""
+Manages all proxy-related stuff
+"""
+import logging
+import os
+import subprocess
+import time
+
+from typing import Dict, Any
+from dataclasses import dataclass, field
+from jinja2 import Template
+
+from .backend import BackendConfig
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class ProxyConfig:
+    """Reverse proxy configuration"""
+    binary_path: str = "/usr/sbin/haproxy"
+    template_path: str = "haproxy.cfg.tpl"
+    working_dir: str = "/tmp/httphound"
+    listen_addr: str = "*"
+    listen_port: int = 4242
+    template_vars: Dict[str, Any] = field(default_factory=dict)
+
+
+class ProxyManager:
+    """Manages reverse proxy"""
+
+    def __init__(self, config: ProxyConfig):
+        self.config = config
+        self.process = None
+        self.config_file = None
+
+    def render_config(self, backend_config: BackendConfig) -> str:
+        """Render proxy configuration from template"""
+        template_vars = {
+            'listen_addr': self.config.listen_addr,
+            'listen_port': self.config.listen_port,
+            'backend_host': backend_config.host,
+            'backend_port': backend_config.port,
+            **self.config.template_vars
+        }
+
+        try:
+            with open(self.config.template_path, 'r', encoding="utf-8") as f:
+                template_content = f.read()
+        except FileNotFoundError:
+            logger.error(f"Cannot finf template {self.config.template_path}")
+            raise
+        template = Template(template_content)
+        rendered_template = template.render(**template_vars)
+        logger.debug(f"Rendered template: \n{rendered_template}\n")
+        return rendered_template
+
+    def start(self, backend_config: BackendConfig):
+        """Start the reverse proxy"""
+
+        # Create working directory
+        logger.debug(
+            f"Creating reverese proxy working dir: {self.config.working_dir}")
+        os.makedirs(self.config.working_dir, exist_ok=True)
+
+        # Render and write config file
+        try:
+            config_content = self.render_config(backend_config)
+        except Exception as e:
+            logger.error(f"Cannot render template: {str(e)}")
+            raise RuntimeError(f"Cannot render template: {str(e)}") from e
+
+        self.config_file = os.path.join(self.config.working_dir, "haproxy.cfg")
+        logger.debug(f"Writing configuration file {self.config_file}")
+        with open(self.config_file, 'w', encoding="utf-8") as f:
+            f.write(config_content)
+
+        # Start proxy process
+        # TODO: customize
+        cmd = [self.config.binary_path,
+               '-V',
+               '-db',
+               '-f', self.config_file,
+               ]
+        logger.debug(f"Running proxy cmd: {cmd}")
+
+        try:
+            self.process = subprocess.Popen(
+                cmd,
+                stdout=subprocess.PIPE,
+                stderr=subprocess.PIPE,
+                cwd=self.config.working_dir
+            )
+
+            # Give proxy time to start
+            logger.debug("Waiting 0.1s for proxy to start")
+            time.sleep(0.1)
+
+            if self.process.poll() is not None:
+                _, stderr = self.process.communicate()
+                raise RuntimeError(f"Proxy failed to start: {stderr.decode()}")
+
+            logger.debug(f"Proxy started with PID {self.process.pid}")
+
+        except FileNotFoundError as e:
+            raise RuntimeError(
+                f"Proxy binary not found: {self.config.binary_path}") from e
+
+    def stop(self):
+        """Stop the reverse proxy"""
+        logger.debug("Stopping reverse proxy")
+        if self.process and self.process.poll() is None:
+            self.process.terminate()
+            try:
+                self.process.wait(timeout=5)
+            except subprocess.TimeoutExpired:
+                self.process.kill()
+                self.process.wait()
+            logger.debug("Proxy stopped")
+
+        # Cleanup config file
+        if self.config_file and os.path.exists(self.config_file):
+            logger.debug(f"Removing config file {self.config_file}")
+            #os.remove(self.config_file)

+ 24 - 0
requirements.txt

@@ -0,0 +1,24 @@
+aiohappyeyeballs==2.6.1
+aiohttp==3.12.15
+aiosignal==1.4.0
+anyio==4.10.0
+attrs==25.3.0
+certifi==2025.8.3
+frozenlist==1.7.0
+h11==0.16.0
+h2==4.2.0
+hpack==4.1.0
+httpcore==1.0.9
+-e git+ssh://git@gitlab.wikimedia.org/repos/sre/httphound.git@c36118b3fdd1d35d2697b1fd42a95229b13c8d47#egg=httphound
+httpx==0.28.1
+hyperframe==6.1.0
+idna==3.10
+Jinja2==3.1.6
+MarkupSafe==3.0.2
+multidict==6.6.3
+propcache==0.3.2
+sniffio==1.3.1
+tabulate==0.9.0
+termcolor==3.1.0
+typing_extensions==4.14.1
+yarl==1.20.1

+ 28 - 0
setup.py

@@ -0,0 +1,28 @@
+from setuptools import find_packages, setup  # type: ignore
+
+INSTALL_REQUIRES = [
+    'aiohttp>=3.8.0',
+    'httpx==0.28.1',
+    'Jinja2==3.1.6',
+    'tabulate==0.9.0',
+    'termcolor==3.1.0',
+]
+
+SETUP_REQUIRES = [
+    'setuptools_scm>=3.2.0',
+]
+
+setup(
+    author='Fabrizio Furnari',
+    author_email='ffurnari@wikimedia.org',
+    description='HTTP reverse proxy testing framework',
+    install_requires=INSTALL_REQUIRES,
+    name='httphound',
+    packages=find_packages(exclude=["test_*"]),
+    setup_requires=SETUP_REQUIRES,
+    url='https://gitlab.wikimedia.org/repos/sre/httphound',
+    use_scm_version=True,
+    entry_points={
+        'console_scripts': ['httphound=httphound.main:start'],
+    },
+)