3 Commits fab6c20162 ... 32e316aaab

Author SHA1 Message Date
  Fabrizio Furnari 32e316aaab doc update 1 month ago
  Fabrizio Furnari e5ce64ac17 Using assert in tests 1 month ago
  Fabrizio Furnari b023d2e4c6 Using custom template directives and not vars 1 month ago

+ 460 - 0
docs/api/configuration.md

@@ -0,0 +1,460 @@
+# Configuration
+
+Configuration objects for dummy backend servers and reverse proxies.
+
+## BackendConfig
+
+::: httphound.backend.BackendConfig
+    options:
+      show_root_heading: true
+      show_source: false
+
+### Overview
+
+`BackendConfig` defines the behavior of the dummy backend HTTP server used in tests.
+
+### Basic Usage
+```python
+from httphound.main import BackendConfig
+
+# Minimal configuration (all defaults)
+backend_config = BackendConfig()
+
+# Custom configuration
+backend_config = BackendConfig(
+    host="127.0.0.1",
+    port=9999,
+    response_status=200,
+    response_headers={"X-Custom": "value"},
+    response_body="Hello from backend"
+)
+```
+
+### Parameters
+
+#### `host: str`
+IP address for backend to bind to.
+
+**Default:** `"127.0.0.1"`
+```python
+BackendConfig(host="127.0.0.1")  # Localhost only
+BackendConfig(host="0.0.0.0")    # All interfaces
+```
+
+This is used also to populate HAProxy configuration file (when in **template** mode).
+
+#### `port: int`
+Port number for backend to listen on.
+
+**Default:** `9999`
+
+!!! warning "Type Matters"
+    Must be an integer, not a string!
+```python
+    BackendConfig(port=9999)   # correct
+    BackendConfig(port="9999") # wrong!
+```
+
+#### `response_status: int`
+HTTP status code to return when a request to the backend is made.
+
+**Default:** `200`
+```python
+BackendConfig(response_status=200)  # OK
+BackendConfig(response_status=201)  # Created
+BackendConfig(response_status=301)  # Moved Permanently
+BackendConfig(response_status=404)  # Not Found
+BackendConfig(response_status=500)  # Internal Server Error
+```
+
+#### `response_headers: Dict[str, str]`
+HTTP headers to include in response.
+
+**Default:** `{}` (empty dict)
+```python
+BackendConfig(response_headers={
+    "Content-Type": "application/json",
+    "X-Custom-Header": "value123",
+    "Cache-Control": "no-cache"
+})
+```
+
+!!! tip "Header Case"
+    Header names will be sent as specified. HTTP is case-insensitive for headers, but preserve the case you want. HAProxy usually lowercase these.
+
+#### `response_body: str`
+Response body content.
+
+**Default:** `"OK"`
+```python
+BackendConfig(response_body="Hello World")
+BackendConfig(response_body='{"status": "ok"}')
+BackendConfig(response_body="<html><body>Test</body></html>")
+```
+
+### Examples
+
+#### Mock JSON API Backend
+```python
+backend_config = BackendConfig(
+    port=9999,
+    response_status=200,
+    response_headers={
+        "Content-Type": "application/json",
+        "X-API-Version": "1.0"
+    },
+    response_body='{"status": "success", "data": [1, 2, 3]}'
+)
+```
+
+#### Error Response Backend
+```python
+backend_config = BackendConfig(
+    response_status=503,
+    response_headers={
+        "Content-Type": "text/plain",
+        "Retry-After": "300"
+    },
+    response_body="Service temporarily unavailable"
+)
+```
+
+#### Redirect Backend
+```python
+backend_config = BackendConfig(
+    response_status=301,
+    response_headers={
+        "Location": "https://example.com/new-location"
+    },
+    response_body=""
+)
+```
+
+---
+
+## ProxyConfig
+
+::: httphound.proxy.ProxyConfig
+    options:
+      show_root_heading: true
+      show_source: false
+
+### Overview
+
+`ProxyConfig` defines how HAProxy is configured and started. Supports two modes:
+
+* **Template mode**: Generate config from Jinja2 template (default)
+* **Production mode**: Use existing HAProxy configuration files
+
+### Basic Usage
+
+#### Template Mode (Default)
+```python
+from httphound.main import ProxyConfig
+from pathlib import Path
+
+proxy_config = ProxyConfig(
+    binary_path=Path.home() / "bin/haproxy",
+    template_path="haproxy.cfg.tpl",
+    listen_port=4242
+)
+```
+
+#### Production Mode
+```python
+proxy_config = ProxyConfig(
+    binary_path="/usr/sbin/haproxy",
+    config_mode="production",
+    production_config_path="/etc/haproxy/haproxy.cfg",
+    backend_name_to_patch="app_backend",
+    bind_address_override="*:4242"
+)
+```
+
+### Common Parameters
+
+#### `binary_path: str`
+Path to HAProxy binary. Must exists on the filesystem and executable as the user who runs the tests.
+
+**Default:** `"/usr/sbin/haproxy"`
+```python
+ProxyConfig(binary_path="/usr/sbin/haproxy")         # Default path if HAProxy is installed as debian package
+ProxyConfig(binary_path=Path.home() / "bin/haproxy")
+ProxyConfig(binary_path="/usr/local/bin/haproxy")
+```
+
+#### `working_dir: str`
+Temporary directory for generated configs.
+
+**Default:** `"/tmp/httphound"`
+```python
+ProxyConfig(working_dir="/tmp/httphound")
+ProxyConfig(working_dir="/tmp/my-tests")
+```
+
+#### `config_mode: str`
+Configuration mode: `"template"` or `"production"`.
+
+**Default:** `"template"`
+```python
+ProxyConfig(config_mode="template")     # Use Jinja2 template
+ProxyConfig(config_mode="production")   # Use existing config
+```
+
+#### `extra_args: List[str]`
+Additional command-line arguments for HAProxy.
+
+**Default:** `[]`
+```python
+ProxyConfig(extra_args=["-dM"])  # Memory debug mode
+ProxyConfig(extra_args=["-D"])   # Daemon mode
+```
+
+### Template Mode Parameters
+
+Used when `config_mode="template"` (default).
+
+#### `template_path: str`
+Path to Jinja2 template file.
+
+**Default:** `"haproxy.cfg.tpl"`
+```python
+ProxyConfig(template_path="haproxy.cfg.tpl")
+ProxyConfig(template_path="templates/my-haproxy.tpl")
+```
+
+#### `listen_addr: str`
+Address for HAProxy to bind to.
+
+**Default:** `"*"` (all interfaces)
+```python
+ProxyConfig(listen_addr="*")           # All interfaces
+ProxyConfig(listen_addr="127.0.0.1")   # Localhost only
+ProxyConfig(listen_addr="0.0.0.0")     # Explicit all
+```
+
+#### `listen_port: int`
+Port for HAProxy to listen on.
+
+**Default:** `4242`
+```python
+ProxyConfig(listen_port=4242)   # Default
+ProxyConfig(listen_port=8080)   # Custom
+```
+
+#### `template_vars: Optional[Dict[str, List[str]]]`
+Additional variables to pass to template. Each key corresponds to the HAProxy template section where the directives must be added.
+
+**Default:** `{}`
+```python
+ProxyConfig(template_vars={
+    "max_connections": 1000,
+    "timeout_client": "30s",
+    "template_directives": {
+        "global": [
+            "maxconn 1000",
+        ],
+        "defaults": [
+            "retries 3",
+        ],
+        "frontend_http": [
+            "http-request deny deny_status 404 if path_beg -i /deny",
+        ],
+    }
+})
+```
+
+These variables are rendered 
+
+**In template:**
+```jinja
+global
+    ...
+    {%- if template_directives["global"] -%}
+    {{ for directive in template_directives["global"] }}
+    {{ directive }}
+    {%- endfor -%}
+    {%- endif -%}
+
+defaults
+    ...
+    {%- if template_directives["defaults"] -%}
+    {{ for directive in template_directives["defaults"] }}
+    {{ directive }}
+    {%- endfor -%}
+    {%- endif -%}
+
+
+frontend http
+    {%- if template_directives["frontend_http"] -%}
+    {{ for directive in template_directives["frontend_http"] }}
+    {{ directive }}
+    {%- endfor -%}
+    {%- endif -%}
+
+```
+
+### Production Mode Parameters
+
+Used when `config_mode="production"`.
+
+#### `production_config_path: str`
+Path to main HAProxy configuration file.
+
+**Required in production mode**
+```python
+ProxyConfig(
+    config_mode="production",
+    production_config_path="/etc/haproxy/haproxy.cfg"
+)
+```
+
+#### `production_config_base_dir: str`
+Base directory containing config and includes (for multi-file configs).
+
+**Optional** - if set, entire directory tree is copied.
+```python
+ProxyConfig(
+    config_mode="production",
+    production_config_path="/etc/haproxy/haproxy.cfg",
+    production_config_base_dir="/etc/haproxy/conf.d"  # Copies whole directory
+)
+```
+
+#### `backend_name_to_patch: str`
+Name of backend section to patch with test backend address.
+
+**Default:** `"default_backend"`
+```python
+ProxyConfig(
+    config_mode="production",
+    production_config_path="/etc/haproxy/haproxy.cfg",
+    backend_name_to_patch="app_backend"  # Must match config
+)
+```
+
+**In HAProxy config:**
+```haproxy
+backend app_backend
+    server app1 10.0.1.10:8080
+    server app2 10.0.1.11:8080
+```
+
+**After patching:**
+```haproxy
+backend app_backend
+    server app1 127.0.0.1:9999  # Test backend
+    server app2 127.0.0.1:9999  # Test backend
+```
+
+#### `bind_address_override: str`
+Override all bind addresses to avoid port conflicts.
+
+**Optional** - if not set, original bind addresses are used.
+```python
+ProxyConfig(
+    config_mode="production",
+    production_config_path="/etc/haproxy/haproxy.cfg",
+    bind_address_override="*:4242"  # Override all binds
+)
+```
+
+**Original config:**
+```haproxy
+frontend web
+    bind *:80
+    bind *:443 ssl crt /path/to/cert
+```
+
+**After override:**
+```haproxy
+frontend web
+    bind *:4242
+    bind *:4242 ssl crt /path/to/cert
+```
+
+#### `skip_backend_injection: bool`
+If True, don't patch backend server addresses.
+
+**Default:** `False`
+```python
+ProxyConfig(
+    config_mode="production",
+    production_config_path="/etc/haproxy/haproxy.cfg",
+    skip_backend_injection=True  # Don't modify backends
+)
+```
+
+Use this when:
+- Production config already points to test backend
+- Tests doesn't need to receive backend responses (test proxy configuration only)
+- You've manually edited the config
+- Backend patching is too complex
+
+### Examples
+
+#### Simple Template Mode
+```python
+from pathlib import Path
+
+proxy_config = ProxyConfig(
+    binary_path=Path.home() / "bin/haproxy",
+    config_mode="template",
+    template_path="haproxy.cfg.tpl",
+    listen_port=4242
+)
+```
+
+#### Template with custom directives
+```python
+proxy_config = ProxyConfig(
+    binary_path="/usr/sbin/haproxy",
+    template_path="templates/advanced.tpl",
+    listen_port=8080,
+    template_directives={
+        "frontend_http": [
+            "http-request deny deny_status 404 if path_beg -i /deny",
+        ],
+    },
+)
+```
+
+#### Production Mode - Single File
+```python
+proxy_config = ProxyConfig(
+    binary_path="/usr/sbin/haproxy",
+    config_mode="production",
+    production_config_path="/etc/haproxy/haproxy.cfg",
+    backend_name_to_patch="app_backend",
+    bind_address_override="127.0.0.1:4242"
+)
+```
+
+#### Production Mode - Multi-File
+```python
+proxy_config = ProxyConfig(
+    binary_path="/usr/sbin/haproxy",
+    config_mode="production",
+    production_config_path="/etc/haproxy/haproxy.cfg",
+    production_config_base_dir="/etc/haproxy",  # Copy whole tree
+    backend_name_to_patch="web_backend",
+    bind_address_override="*:4242"
+)
+```
+
+#### Production Mode - Manual Backend
+```python
+# When production config already has correct backend address
+proxy_config = ProxyConfig(
+    config_mode="production",
+    production_config_path="/tmp/test-haproxy.cfg",
+    skip_backend_injection=True,  # Don't patch
+    bind_address_override="*:4242"
+)
+
+# Backend must match config
+backend_config = BackendConfig(
+    host="127.0.0.1",
+    port=8080  # Must match what's in config
+)
+```

+ 18 - 0
docs/cli.md

@@ -12,3 +12,21 @@ options:
                         Set logging level (Default: INFO)
                         Set logging level (Default: INFO)
   --parallel N, -p N    Run tests in parallel with max N concurrent tests
   --parallel N, -p N    Run tests in parallel with max N concurrent tests
 ```
 ```
+
+The CLI automatically handles test discovery and execution.
+
+```bash
+# Discover and run all tests in directory
+httphound tests/
+
+# Run specific test file
+httphound tests/test_headers.py
+
+# Run with glob pattern
+httphound "tests/**/test_*.py"
+
+# Multiple paths
+httphound tests/unit/ tests/integration/
+```
+
+* Parallel switch currently doesn't really work. Tests must be ran sequentially.

+ 4 - 0
example_tests/03-basic_header_regex.py

@@ -1,3 +1,4 @@
+import re
 from pathlib import Path
 from pathlib import Path
 from httphound.main import BaseProxyTest, BackendConfig, ProxyConfig
 from httphound.main import BaseProxyTest, BackendConfig, ProxyConfig
 
 
@@ -27,6 +28,9 @@ class FailHeaderRegexTest(BaseProxyTest):
 
 
     async def run_test(self):
     async def run_test(self):
         await self.make_request()
         await self.make_request()
+        print(self.response_headers)
+        assert "x-test" in self.response_headers
+        assert re.match(r"\d{1}$", self.response_headers["x-test"])
         return True
         return True
 
 
 
 

+ 48 - 0
example_tests/11-proxy-config.py

@@ -0,0 +1,48 @@
+"""
+Test custom proxy configuration (directives)
+"""
+
+from pathlib import Path
+from httphound.main import BaseProxyTest, BackendConfig, ProxyConfig
+
+class TemplateDirectivesTest(BaseProxyTest):
+    """Test using custom proxy config directives
+    """
+    def __init__(self):
+        super().__init__()
+        self.description = "Custom proxy config directives"
+
+        # backend will run on port 9999
+        self.backend_config = BackendConfig(
+            host='127.0.0.1',
+            port=9999,
+            response_status=200,
+            response_body='OK',
+        )
+
+        # configure haproxy to use production config
+        self.proxy_config = ProxyConfig(
+            binary_path=Path.home() / "bin/haproxy",
+            config_mode="template",
+            template_directives = {
+                "global": [
+                    "maxconn 1024",
+                ],
+                "defaults": [
+                    "timeout client 30s",
+                ],
+                "frontend_http": [
+                    "http-request deny deny_status 500 if { path_beg /test }",
+                ],
+            },
+        )
+
+        self.url = "http://127.0.0.1:4242/test"
+        self.expected_status = 500
+
+    async def run_test(self):
+        """Run the test"""
+        await self.make_request()
+        assert self.backend.request_count == 0
+
+        return True

+ 19 - 5
haproxy.cfg.tpl

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

+ 1 - 1
httphound/main.py

@@ -43,7 +43,7 @@ class TestResult:
 
 
 
 
 class BaseProxyTest(ABC):
 class BaseProxyTest(ABC):
-    """Base class for all HTTP tests - ASYNC VERSION
+    """Base class for all HTTP tests
     
     
     All test classes must inherit from this class and implement the async run_test() method.
     All test classes must inherit from this class and implement the async run_test() method.
     
     

+ 7 - 5
httphound/proxy.py

@@ -18,9 +18,11 @@ class ProxyConfig:
     """Reverse proxy configuration
     """Reverse proxy configuration
 
 
     Supports two modes:
     Supports two modes:
-    - template: Use jinja2 template with variable substitution (default)
-    - production: Use existing HAProxy config file(s) as-is or with minimal
-                  patching (with regexes)
+    
+      * template: Use jinja2 template with variable substitution (default)
+      * production: Use existing HAProxy config file(s) as-is or with minimal
+                    patching (with regexes)
+    
     """
     """
     binary_path: str = "/usr/sbin/haproxy"
     binary_path: str = "/usr/sbin/haproxy"
     working_dir: str = "/tmp/httphound"
     working_dir: str = "/tmp/httphound"
@@ -31,7 +33,7 @@ class ProxyConfig:
     template_path: Optional[str] = "haproxy.cfg.tpl"
     template_path: Optional[str] = "haproxy.cfg.tpl"
     listen_addr: str = "*"
     listen_addr: str = "*"
     listen_port: int = 4242
     listen_port: int = 4242
-    template_vars: Dict[str, Any] = field(default_factory=dict)
+    template_directives: Optional[Dict[str, List[str]]] = None
 
 
     # production mode
     # production mode
     production_config_file_path: Optional[str] = None # main config file path
     production_config_file_path: Optional[str] = None # main config file path
@@ -59,7 +61,7 @@ class ProxyManager:
             'listen_port': self.config.listen_port,
             'listen_port': self.config.listen_port,
             'backend_host': backend_config.host,
             'backend_host': backend_config.host,
             'backend_port': backend_config.port,
             'backend_port': backend_config.port,
-            **self.config.template_vars
+            'template_directives': self.config.template_directives,
         }
         }
 
 
         try:
         try: