diff --git a/examples/docker/AGENTS.md b/examples/docker/AGENTS.md new file mode 100644 index 0000000..d2b03b0 --- /dev/null +++ b/examples/docker/AGENTS.md @@ -0,0 +1,18 @@ +# Instructions for testing + +1. Run a docker compose in background for the specified feature e.g. `docker compose -f docker-compose.yml up -d` +2. Check if it is running by running `docker ps` and verifying the container is up +3. If the container is not running, check the logs with `docker logs ` to diagnose any issues +4. In the top each file, you can find the instructions to test and check if it is working. +5. If everything is working tear down the container with `docker compose -f docker-compose.yml down` +6. To ensure the container is properly shut down, use `docker compose -f docker-compose.yml down --remove-orphans` to remove any orphaned containers. + +# In case you find issues + +**DONT TEAR DOWN THE CONTAINERS** + +1. Investigate the source code in src/* +2. Try to fix it. +3. After the code is changed, build it again: `docker build -t byjg/easy-haproxy:5.0.0 -f build/Dockerfile --no-cache .` and start the tests again. + + diff --git a/examples/docker/docker-compose-changed-label.yml b/examples/docker/docker-compose-changed-label.yml index fedf7bc..320781a 100644 --- a/examples/docker/docker-compose-changed-label.yml +++ b/examples/docker/docker-compose-changed-label.yml @@ -7,11 +7,6 @@ # - Useful for running multiple EasyHAProxy instances # - Custom label configuration (haproxy.* instead of easyhaproxy.*) # -# REQUIREMENTS (run these first): -# ```bash -# # Add to /etc/hosts (idempotent) -# grep -q "host1.local" /etc/hosts || echo "127.0.0.1 host1.local" | sudo tee -a /etc/hosts -# ``` # # HOW TO START: # ```bash @@ -38,7 +33,10 @@ services: haproxy: - image: byjg/easy-haproxy:5.0.0 + build: + context: ../../ + dockerfile: build/Dockerfile + image: byjg/easy-haproxy:local volumes: - /var/run/docker.sock:/var/run/docker.sock environment: diff --git a/examples/docker/docker-compose-cloudflare.yml b/examples/docker/docker-compose-cloudflare.yml index 6295c4c..660dc96 100644 --- a/examples/docker/docker-compose-cloudflare.yml +++ b/examples/docker/docker-compose-cloudflare.yml @@ -15,8 +15,7 @@ # echo "" >> cloudflare_ips.lst # curl -s https://www.cloudflare.com/ips-v6 >> cloudflare_ips.lst # -# # Add to /etc/hosts (idempotent) -# grep -q "myapp.local" /etc/hosts || echo "127.0.0.1 myapp.local" | sudo tee -a /etc/hosts + # ``` # # HOW TO START: @@ -47,7 +46,10 @@ services: haproxy: - image: byjg/easy-haproxy:5.0.0 + build: + context: ../.. + dockerfile: build/Dockerfile + image: byjg/easy-haproxy:local volumes: - /var/run/docker.sock:/var/run/docker.sock # Mount Cloudflare IP list @@ -62,11 +64,9 @@ services: - "80:80/tcp" - "1936:1936/tcp" - # Web application behind Cloudflare + # Web application behind Cloudflare (header-echo server for testing) webapp: - image: byjg/static-httpserver - environment: - TITLE: "App Behind Cloudflare" + build: ./python-app labels: easyhaproxy.http.host: myapp.local easyhaproxy.http.port: 80 diff --git a/examples/docker/docker-compose-ip-whitelist.yml b/examples/docker/docker-compose-ip-whitelist.yml index 3b30d01..6498573 100644 --- a/examples/docker/docker-compose-ip-whitelist.yml +++ b/examples/docker/docker-compose-ip-whitelist.yml @@ -8,12 +8,7 @@ # - Custom HTTP status code for blocked requests # - Admin panel or sensitive application protection # -# REQUIREMENTS (run these first): -# ```bash -# # Add to /etc/hosts (idempotent) -# grep -q "admin.local" /etc/hosts || echo "127.0.0.1 admin.local" | sudo tee -a /etc/hosts -# -# # IMPORTANT: Update the allowed_ips in this file (line 52) with your actual IPs! +# # IMPORTANT: Update the easyhaproxy.http.plugin.ip_whitelist.allowed_ips with your actual IPs! # # Default allows localhost and private networks for testing # ``` # @@ -25,7 +20,7 @@ # HOW TO VERIFY IT'S WORKING: # ```bash # # Test from localhost (127.0.0.1 is whitelisted) -# curl http://admin.local/ +# curl -k -H "Host: admin.local" http://127.0.0.1/ # # Expected: 200 OK - Access granted # # # Test from non-whitelisted IP @@ -47,7 +42,10 @@ services: haproxy: - image: byjg/easy-haproxy:5.0.0 + build: + context: ../../ + dockerfile: build/Dockerfile + image: byjg/easy-haproxy:local volumes: - /var/run/docker.sock:/var/run/docker.sock environment: diff --git a/examples/docker/docker-compose-jwt-validator.yml b/examples/docker/docker-compose-jwt-validator.yml index 794ad21..6c976c2 100644 --- a/examples/docker/docker-compose-jwt-validator.yml +++ b/examples/docker/docker-compose-jwt-validator.yml @@ -12,9 +12,6 @@ # ```bash # # Generate SSL certificates and JWT keys (from project root) # cd ../.. && ./examples/generate-keys.sh && cd examples/docker -# -# # Add to /etc/hosts (idempotent) -# grep -q "api.local" /etc/hosts || echo "127.0.0.1 api.local" | sudo tee -a /etc/hosts # ``` # # HOW TO START: @@ -25,7 +22,7 @@ # HOW TO VERIFY IT'S WORKING: # ```bash # # Test without token (should fail) -# curl http://api.local/ +# curl -k -H "Host: api.local" http://127.0.0.1/ # # Expected: HTTP 403 - Missing Authorization HTTP header # # # Generate test JWT at https://jwt.io with: @@ -35,7 +32,7 @@ # # # Test with valid token # TOKEN="eyJhbGc..." # Replace with your generated token -# curl -H "Authorization: Bearer $TOKEN" http://api.local/ +# curl -k -H "Host: host1.local" -H "Authorization: Bearer $TOKEN" http://api.local/ # # Expected: 200 OK with API response # # # View HAProxy stats @@ -53,7 +50,10 @@ services: haproxy: - image: byjg/easy-haproxy:5.0.0 + build: + context: ../.. + dockerfile: build/Dockerfile + image: byjg/easy-haproxy:local volumes: - /var/run/docker.sock:/var/run/docker.sock # Mount the public key for JWT verification diff --git a/examples/docker/docker-compose-multi-containers.yml b/examples/docker/docker-compose-multi-containers.yml index f5a17d5..5c40c41 100644 --- a/examples/docker/docker-compose-multi-containers.yml +++ b/examples/docker/docker-compose-multi-containers.yml @@ -46,7 +46,10 @@ services: haproxy: - image: byjg/easy-haproxy:5.0.0 + build: + context: ../../ + dockerfile: build/Dockerfile + image: byjg/easy-haproxy:local volumes: - /var/run/docker.sock:/var/run/docker.sock environment: @@ -56,7 +59,8 @@ services: HAPROXY_PASSWORD: password HAPROXY_STATS_PORT: 1936 ports: - - 19901:19901 + - 19901:19901 + - 1936:1936 nginx: diff --git a/examples/docker/docker-compose-php-fpm.yml b/examples/docker/docker-compose-php-fpm.yml index 9d5b07f..b1adb1d 100644 --- a/examples/docker/docker-compose-php-fpm.yml +++ b/examples/docker/docker-compose-php-fpm.yml @@ -9,11 +9,6 @@ # - PATH_INFO support for RESTful routing # - Custom document root and index file configuration # -# REQUIREMENTS (run these first): -# ```bash -# # Add to /etc/hosts (idempotent) -# grep -q "phpapp.local" /etc/hosts || echo "127.0.0.1 phpapp.local" | sudo tee -a /etc/hosts -# ``` # # HOW TO START: # ```bash @@ -23,15 +18,15 @@ # HOW TO VERIFY IT'S WORKING: # ```bash # # Test main page -# curl http://phpapp.local/ +# curl -k -H "Host: phpapp.local" http://127.0.0.1/ # # Expected: 200 OK with PHP environment info # # # Test PHP info page -# curl http://phpapp.local/info.php +# -k -H "Host: phpapp.local" http://127.0.0.1/info.php # # Expected: phpinfo() output # # # Test PATH_INFO routing -# curl http://phpapp.local/test-path-info.php/users/123 +# -k -H "Host: phpapp.local" http://127.0.0.1/test-path-info.php/users/123 # # Expected: PATH_INFO=/users/123 # # # View HAProxy stats @@ -49,7 +44,10 @@ services: haproxy: - image: byjg/easy-haproxy:5.0.0 + build: + context: ../../ + dockerfile: build/Dockerfile + image: byjg/easy-haproxy:local volumes: - /var/run/docker.sock:/var/run/docker.sock environment: diff --git a/examples/docker/docker-compose-plugins-combined.yml b/examples/docker/docker-compose-plugins-combined.yml index a4730ec..31dadd1 100644 --- a/examples/docker/docker-compose-plugins-combined.yml +++ b/examples/docker/docker-compose-plugins-combined.yml @@ -20,9 +20,6 @@ # echo "" >> cloudflare_ips.lst # curl -s https://www.cloudflare.com/ips-v6 >> cloudflare_ips.lst # -# # Add to /etc/hosts (idempotent) -# grep -q "website.local" /etc/hosts || echo "127.0.0.1 website.local api.local admin.local" | sudo tee -a /etc/hosts -# ``` # # HOW TO START: # ```bash @@ -32,21 +29,21 @@ # HOW TO VERIFY IT'S WORKING: # ```bash # # Test public website (Cloudflare + path blocking) -# curl http://website.local/ +# curl -k -H "Host: website.local" http://127.0.0.1/ # # Expected: 200 OK -# curl http://website.local/admin +# curl -k -H "Host: website.local" http://127.0.0.1/admin # # Expected: HTTP 404 - Path blocked # # # Test protected API (JWT required) -# curl http://api.local/ +# curl -k -H "Host: api.local" http://127.0.0.1/ # # Expected: HTTP 403 - Missing Authorization header # # Generate JWT at https://jwt.io (see jwt-validator example for details) # TOKEN="eyJhbGc..." # Replace with your token -# curl -H "Authorization: Bearer $TOKEN" http://api.local/ +# curl -H "Host: api.local" -H "Authorization: Bearer $TOKEN" http://127.0.0.1/ # # Expected: 200 OK # # # Test admin panel (IP whitelist) -# curl http://admin.local/ +# curl -k -H "Host: admin.local" http://127.0.0.1/ # # Expected: 200 OK from localhost # # # View HAProxy stats @@ -65,7 +62,10 @@ services: haproxy: - image: byjg/easy-haproxy:5.0.0 + build: + context: ../../ + dockerfile: build/Dockerfile + image: byjg/easy-haproxy:local volumes: - /var/run/docker.sock:/var/run/docker.sock - ./cloudflare_ips.lst:/etc/haproxy/cloudflare_ips.lst:ro diff --git a/examples/docker/docker-compose.yml b/examples/docker/docker-compose.yml index ad245b2..6d4f470 100644 --- a/examples/docker/docker-compose.yml +++ b/examples/docker/docker-compose.yml @@ -9,9 +9,6 @@ # - HAProxy stats interface # # REQUIREMENTS (run these first): -# ```bash -# # Add to /etc/hosts (idempotent) -# grep -q "host1.local" /etc/hosts || echo "127.0.0.1 host1.local host2.local" | sudo tee -a /etc/hosts # # # Generate SSL certificates # cd ../.. && ./examples/generate-keys.sh && cd examples/docker @@ -51,7 +48,10 @@ services: haproxy: - image: byjg/easy-haproxy:5.0.0 + build: + context: ../.. + dockerfile: build/Dockerfile + image: byjg/easy-haproxy:local volumes: - /var/run/docker.sock:/var/run/docker.sock - ./host2.local.pem:/certs/haproxy/host2.local.pem diff --git a/examples/docker/python-app/Dockerfile b/examples/docker/python-app/Dockerfile new file mode 100644 index 0000000..b355409 --- /dev/null +++ b/examples/docker/python-app/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY server.py . + +RUN chmod +x server.py + +EXPOSE 8080 + +CMD ["python3", "server.py"] \ No newline at end of file diff --git a/examples/docker/python-app/server.py b/examples/docker/python-app/server.py new file mode 100644 index 0000000..4b9e0a4 --- /dev/null +++ b/examples/docker/python-app/server.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Simple HTTP server that echoes all request headers""" + +from http.server import HTTPServer, BaseHTTPRequestHandler +import json + +class HeaderEchoHandler(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.end_headers() + + # Collect all headers + headers = dict(self.headers) + + # Add the client IP as seen by this server + response = { + 'headers': headers, + 'client_ip': self.client_address[0], + 'x_forwarded_for': self.headers.get('X-Forwarded-For', 'NOT SET') + } + + self.wfile.write(json.dumps(response, indent=2).encode()) + + def log_message(self, format, *args): + # Log to stdout + print(f"{self.address_string()} - {format % args}") + +if __name__ == '__main__': + port = 8080 + server = HTTPServer(('0.0.0.0', port), HeaderEchoHandler) + print(f'Header echo server running on port {port}...') + server.serve_forever() \ No newline at end of file diff --git a/examples/docker/test_docker_compose.py b/examples/docker/test_docker_compose.py new file mode 100644 index 0000000..073d8f2 --- /dev/null +++ b/examples/docker/test_docker_compose.py @@ -0,0 +1,933 @@ +""" +Pytest test suite for EasyHAProxy Docker Compose examples + +These tests verify the functionality of various docker-compose configurations. +Tests are organized by compose file and can be run individually or as a suite. + +Requirements: +- pytest +- requests +- PyJWT +- cryptography +- docker-compose + +Usage: + # Run all tests + pytest test_docker_compose.py -v + + # Run specific test class + pytest test_docker_compose.py::TestBasicSSL -v + + # Run specific test + pytest test_docker_compose.py::TestBasicSSL::test_https_host1 -v + + # Run with markers + pytest test_docker_compose.py -m ssl -v +""" + +import subprocess +import time +import os +from pathlib import Path +import pytest +import requests +import jwt as jwt_lib +from typing import Generator + +# Base directory for docker-compose files +BASE_DIR = Path(__file__).parent.absolute() + + +@pytest.fixture(scope="session", autouse=True) +def generate_ssl_certificates(): + """ + Generate SSL certificates once for all tests that require them. + This runs automatically at the start of the test session. + """ + script_path = BASE_DIR.parent / "generate-keys.sh" + + # Check if script exists + if not script_path.exists(): + pytest.skip(f"SSL certificate generation script not found: {script_path}") + + # Run the script from the examples directory + result = subprocess.run( + ["bash", str(script_path)], + cwd=BASE_DIR.parent, + capture_output=True, + text=True + ) + + if result.returncode != 0: + pytest.fail(f"Failed to generate SSL certificates:\n{result.stderr}") + + yield + # No cleanup needed - certificates can be reused + + +class DockerComposeFixture: + """Helper class to manage docker-compose lifecycle""" + + def __init__(self, compose_file: str, startup_wait: int = 3, build: bool = True): + self.compose_file = str(BASE_DIR / compose_file) + self.startup_wait = startup_wait + self.build = build + + def up(self): + """Start docker-compose services""" + cmd = ["docker", "compose", "-f", self.compose_file, "up", "-d"] + if self.build: + cmd.append("--build") + subprocess.run( + cmd, + check=True, + capture_output=True + ) + time.sleep(self.startup_wait) + + def down(self): + """Stop and remove docker-compose services""" + subprocess.run( + ["docker", "compose", "-f", self.compose_file, "down", "--remove-orphans"], + check=True, + capture_output=True + ) + + +@pytest.fixture +def docker_compose_basic_ssl() -> Generator[None, None, None]: + """Fixture for docker-compose.yml (Basic SSL)""" + fixture = DockerComposeFixture("docker-compose.yml") + fixture.up() + yield + fixture.down() + + +@pytest.fixture +def docker_compose_jwt_validator() -> Generator[None, None, None]: + """Fixture for docker-compose-jwt-validator.yml""" + fixture = DockerComposeFixture("docker-compose-jwt-validator.yml") + fixture.up() + yield + fixture.down() + + +@pytest.fixture +def docker_compose_multi_containers() -> Generator[None, None, None]: + """Fixture for docker-compose-multi-containers.yml""" + fixture = DockerComposeFixture("docker-compose-multi-containers.yml") + fixture.up() + yield + fixture.down() + + +@pytest.fixture +def docker_compose_php_fpm() -> Generator[None, None, None]: + """Fixture for docker-compose-php-fpm.yml""" + fixture = DockerComposeFixture("docker-compose-php-fpm.yml") + fixture.up() + yield + fixture.down() + + +@pytest.fixture +def docker_compose_plugins_combined() -> Generator[None, None, None]: + """Fixture for docker-compose-plugins-combined.yml""" + fixture = DockerComposeFixture("docker-compose-plugins-combined.yml") + fixture.up() + yield + fixture.down() + + +@pytest.fixture +def docker_compose_ip_whitelist() -> Generator[None, None, None]: + """Fixture for docker-compose-ip-whitelist.yml""" + fixture = DockerComposeFixture("docker-compose-ip-whitelist.yml") + fixture.up() + yield + fixture.down() + + +@pytest.fixture +def docker_compose_cloudflare() -> Generator[None, None, None]: + """Fixture for docker-compose-cloudflare.yml""" + # Set up cloudflare_ips.lst with Docker network for testing + cloudflare_ips_path = BASE_DIR / "cloudflare_ips.lst" + + # Download Cloudflare IPs + subprocess.run( + ["curl", "-s", "https://www.cloudflare.com/ips-v4"], + stdout=open(cloudflare_ips_path, 'w'), + check=True + ) + with open(cloudflare_ips_path, 'a') as f: + f.write("\n") + + subprocess.run( + ["curl", "-s", "https://www.cloudflare.com/ips-v6"], + stdout=open(cloudflare_ips_path, 'a'), + check=True + ) + + # Add Docker private network range so HAProxy treats test requests as from Cloudflare + # Docker bridge networks are typically in 172.16.0.0/12 range + with open(cloudflare_ips_path, 'a') as f: + f.write("\n") + f.write("172.16.0.0/12\n") # Docker private network range + + fixture = DockerComposeFixture("docker-compose-cloudflare.yml") + fixture.up() + yield + fixture.down() + + +@pytest.fixture +def jwt_token() -> str: + """Generate a valid JWT token for testing""" + private_key_path = BASE_DIR / "jwt_private.pem" + with open(private_key_path, 'r') as f: + private_key = f.read() + + payload = { + 'iss': 'https://auth.example.com/', + 'aud': 'https://api.example.com', + 'exp': 9999999999 + } + + token = jwt_lib.encode(payload, private_key, algorithm='RS256') + return token + + +# ============================================================================= +# Test: docker-compose.yml - Basic SSL Setup +# ============================================================================= + +@pytest.mark.ssl +class TestBasicSSL: + """Tests for basic SSL setup with two virtual hosts""" + + def test_haproxy_config(self, docker_compose_basic_ssl): + """Test HAProxy configuration has SSL and redirect configurations""" + result = subprocess.run( + ["docker", "exec", "docker-haproxy-1", "cat", "/etc/haproxy/haproxy.cfg"], + capture_output=True, + text=True, + check=True + ) + config = result.stdout + + # Test HTTPS backend for host1 + https_host1_block = extract_backend_block(config, "srv_host1_local_443") + assert https_host1_block, "Backend srv_host1_local_443 not found" + assert "mode http" in https_host1_block + + # Test HTTPS backend for host2 + https_host2_block = extract_backend_block(config, "srv_host2_local_443") + assert https_host2_block, "Backend srv_host2_local_443 not found" + assert "mode http" in https_host2_block + + # Verify SSL frontend exists and binds to port 443 + assert "frontend https_in_443" in config or "bind *:443" in config + + # Verify HTTP to HTTPS redirect + # Check for redirect rules in HTTP frontend or backends + assert "redirect scheme https" in config or "location: https://" in config + + def test_https_host1(self, docker_compose_basic_ssl): + """Test HTTPS access to host1.local""" + response = requests.get( + "https://127.0.0.1/", + headers={"Host": "host1.local"}, + verify=False + ) + assert response.status_code == 200 + + def test_https_host2(self, docker_compose_basic_ssl): + """Test HTTPS access to host2.local""" + response = requests.get( + "https://127.0.0.1/", + headers={"Host": "host2.local"}, + verify=False + ) + assert response.status_code == 200 + + def test_http_redirect_host1(self, docker_compose_basic_ssl): + """Test HTTP to HTTPS redirect for host1.local""" + response = requests.get( + "http://127.0.0.1/", + headers={"Host": "host1.local"}, + allow_redirects=False + ) + assert response.status_code == 301 + assert response.headers.get("location") == "https://host1.local/" + + def test_http_redirect_host2(self, docker_compose_basic_ssl): + """Test HTTP to HTTPS redirect for host2.local""" + response = requests.get( + "http://127.0.0.1/", + headers={"Host": "host2.local"}, + allow_redirects=False + ) + assert response.status_code == 301 + assert response.headers.get("location") == "https://host2.local/" + + def test_haproxy_stats(self, docker_compose_basic_ssl): + """Test HAProxy stats interface""" + response = requests.get( + "http://localhost:1936", + auth=("admin", "password") + ) + assert response.status_code == 200 + assert "Statistics Report for HAProxy" in response.text + + +# ============================================================================= +# Test: docker-compose-jwt-validator.yml - JWT Validator Plugin +# ============================================================================= + +@pytest.mark.jwt +class TestJWTValidator: + """Tests for JWT validator plugin""" + + def test_haproxy_config(self, docker_compose_jwt_validator): + """Test HAProxy configuration has JWT validator rules in the correct backend""" + result = subprocess.run( + ["docker", "exec", "docker-haproxy-1", "cat", "/etc/haproxy/haproxy.cfg"], + capture_output=True, + text=True, + check=True + ) + config = result.stdout + + # Extract the specific backend block + backend_block = extract_backend_block(config, "srv_api_local_80") + assert backend_block, "Backend srv_api_local_80 not found" + + # Verify JWT validator plugin comment + assert "# JWT Validator - Validate JWT tokens" in backend_block + + # Verify JWT validation rules + assert "http-request deny content-type 'text/html' string 'Missing Authorization HTTP header'" in backend_block + assert "http_auth_bearer,jwt_header_query('$.alg')" in backend_block + assert "http_auth_bearer,jwt_payload_query('$.iss')" in backend_block + assert "http_auth_bearer,jwt_payload_query('$.aud')" in backend_block + + # Verify algorithm check + assert "var(txn.alg) -m str RS256" in backend_block + + # Verify issuer and audience checks + assert "var(txn.iss) -m str https://auth.example.com/" in backend_block + assert "var(txn.aud) -m str https://api.example.com" in backend_block + + # Verify JWT signature verification + assert 'jwt_verify(txn.alg,"/etc/haproxy/jwt_keys/api_pubkey.pem")' in backend_block + + # Verify expiration check + assert "JWT has expired" in backend_block + + def test_without_token(self, docker_compose_jwt_validator): + """Test API access without JWT token (should fail)""" + response = requests.get( + "http://127.0.0.1/", + headers={"Host": "api.local"} + ) + assert response.status_code == 403 + assert "Missing Authorization HTTP header" in response.text + + def test_with_valid_token(self, docker_compose_jwt_validator, jwt_token): + """Test API access with valid JWT token (should succeed)""" + response = requests.get( + "http://127.0.0.1/", + headers={ + "Host": "api.local", + "Authorization": f"Bearer {jwt_token}" + } + ) + assert response.status_code == 200 + + def test_haproxy_stats(self, docker_compose_jwt_validator): + """Test HAProxy stats interface""" + response = requests.get( + "http://localhost:1936", + auth=("admin", "password") + ) + assert response.status_code == 200 + assert "Statistics Report for HAProxy" in response.text + + +# ============================================================================= +# Test: docker-compose-multi-containers.yml - Load Balancing +# ============================================================================= + +@pytest.mark.loadbalancing +class TestMultiContainers: + """Tests for load balancing with multiple container replicas""" + + def test_haproxy_config(self, docker_compose_multi_containers): + """Test HAProxy configuration has multiple backend servers for load balancing""" + result = subprocess.run( + ["docker", "exec", "docker-haproxy-1", "cat", "/etc/haproxy/haproxy.cfg"], + capture_output=True, + text=True, + check=True + ) + config = result.stdout + + # Extract the specific backend block + backend_block = extract_backend_block(config, "srv_www_helloworld_com_19901") + assert backend_block, "Backend srv_www_helloworld_com_19901 not found" + + # Verify round-robin load balancing + assert "balance roundrobin" in backend_block + + # Verify multiple servers are configured + server_lines = [line for line in backend_block.split('\n') if line.strip().startswith('server srv-')] + assert len(server_lines) >= 2, f"Expected at least 2 servers, found {len(server_lines)}" + + # Verify both servers have check and weight + for server_line in server_lines: + assert "check" in server_line + assert "weight" in server_line + + def test_load_balancing(self, docker_compose_multi_containers): + """Test round-robin load balancing across replicas""" + container_ids = set() + for _ in range(6): + response = requests.get( + "http://localhost:19901/", + headers={"Host": "www.helloworld.com"} + ) + assert response.status_code == 200 + container_ids.add(response.text.strip()) + + # Should see at least 2 different container IDs + assert len(container_ids) >= 2 + + def test_domain_redirect(self, docker_compose_multi_containers): + """Test domain redirect functionality""" + response = requests.get( + "http://localhost:19901/", + headers={"Host": "google.helloworld.com"}, + allow_redirects=False + ) + assert response.status_code == 301 + assert response.headers.get("location") == "www.google.com/" + + +# ============================================================================= +# Test: docker-compose-php-fpm.yml - PHP-FPM FastCGI Plugin +# ============================================================================= + +@pytest.mark.php +class TestPHPFPM: + """Tests for PHP-FPM FastCGI plugin""" + + def test_haproxy_config(self, docker_compose_php_fpm): + """Test HAProxy configuration has FastCGI plugin configuration""" + result = subprocess.run( + ["docker", "exec", "docker-haproxy-1", "cat", "/etc/haproxy/haproxy.cfg"], + capture_output=True, + text=True, + check=True + ) + config = result.stdout + + # Extract the specific backend block + backend_block = extract_backend_block(config, "srv_phpapp_local_80") + assert backend_block, "Backend srv_phpapp_local_80 not found" + + # Verify FastCGI app is used + assert "use-fcgi-app fcgi_phpapp_local" in backend_block + + # Verify server uses fcgi protocol + assert "proto fcgi" in backend_block + + # Verify port 9000 (PHP-FPM default) + assert ":9000" in backend_block + + # Now check for fcgi-app configuration (not in backend, but in global config) + assert "fcgi-app fcgi_phpapp_local" in config + + # Extract fcgi-app block + fcgi_lines = [] + in_fcgi = False + for line in config.split('\n'): + if line.startswith('fcgi-app fcgi_phpapp_local'): + in_fcgi = True + elif in_fcgi: + if line.startswith(('fcgi-app ', 'frontend ', 'backend ', 'listen ')): + break + fcgi_lines.append(line) + + fcgi_block = '\n'.join(fcgi_lines) + + # Verify FastCGI plugin configuration + assert "docroot /var/www/html" in fcgi_block + assert "index index.php" in fcgi_block + assert "path-info" in fcgi_block + + def test_main_page(self, docker_compose_php_fpm): + """Test main PHP page""" + response = requests.get( + "http://127.0.0.1/", + headers={"Host": "phpapp.local"} + ) + assert response.status_code == 200 + assert "PHP-FPM with EasyHAProxy" in response.text + + def test_phpinfo(self, docker_compose_php_fpm): + """Test PHP info page""" + response = requests.get( + "http://127.0.0.1/info.php", + headers={"Host": "phpapp.local"} + ) + assert response.status_code == 200 + assert "phpinfo()" in response.text + + def test_path_info_routing(self, docker_compose_php_fpm): + """Test PATH_INFO routing for RESTful URLs""" + response = requests.get( + "http://127.0.0.1/test-path-info.php/users/123", + headers={"Host": "phpapp.local"} + ) + assert response.status_code == 200 + assert "PATH_INFO" in response.text + assert "/users/123" in response.text + + def test_haproxy_stats(self, docker_compose_php_fpm): + """Test HAProxy stats interface""" + response = requests.get( + "http://localhost:1936", + auth=("admin", "password") + ) + assert response.status_code == 200 + assert "Statistics Report for HAProxy" in response.text + + +# ============================================================================= +# Test: docker-compose-plugins-combined.yml - Multiple Plugins Combined +# ============================================================================= + +@pytest.mark.plugins +class TestPluginsCombined: + """Tests for multiple plugins combined""" + + def test_haproxy_config(self, docker_compose_plugins_combined): + """Test HAProxy configuration has all plugin configurations in correct backends""" + result = subprocess.run( + ["docker", "exec", "docker-haproxy-1", "cat", "/etc/haproxy/haproxy.cfg"], + capture_output=True, + text=True, + check=True + ) + config = result.stdout + + # Test website backend (Cloudflare + deny_pages) + website_block = extract_backend_block(config, "srv_website_local_80") + assert website_block, "Backend srv_website_local_80 not found" + assert "# Cloudflare - Restore original visitor IP" in website_block + assert "acl from_cloudflare src -f /etc/haproxy/cloudflare_ips.lst" in website_block + assert "# Deny Pages - Block specific paths" in website_block + assert "acl denied_path path_beg /admin /wp-admin /wp-login.php /.env /config" in website_block + assert "http-request deny deny_status 404 if denied_path" in website_block + + # Test API backend (JWT validator + deny_pages) + api_block = extract_backend_block(config, "srv_api_local_80") + assert api_block, "Backend srv_api_local_80 not found" + assert "# JWT Validator - Validate JWT tokens" in api_block + assert "Missing Authorization HTTP header" in api_block + assert "jwt_verify" in api_block + assert "# Deny Pages - Block specific paths" in api_block + assert "acl denied_path path_beg /internal /debug /metrics" in api_block + assert "http-request deny deny_status 403 if denied_path" in api_block + + # Test admin backend (IP whitelist) + admin_block = extract_backend_block(config, "srv_admin_local_80") + assert admin_block, "Backend srv_admin_local_80 not found" + assert "# IP Whitelist - Only allow specific IPs" in admin_block + assert "acl whitelisted_ip src" in admin_block + assert "http-request deny deny_status 403 if !whitelisted_ip" in admin_block + + def test_website_normal_access(self, docker_compose_plugins_combined): + """Test normal access to public website""" + response = requests.get( + "http://127.0.0.1/", + headers={"Host": "website.local"} + ) + assert response.status_code == 200 + + def test_website_blocked_paths(self, docker_compose_plugins_combined): + """Test blocked paths on public website""" + blocked_paths = ["/admin", "/wp-admin", "/.env", "/config"] + for path in blocked_paths: + response = requests.get( + f"http://127.0.0.1{path}", + headers={"Host": "website.local"} + ) + assert response.status_code == 404 + + def test_api_without_token(self, docker_compose_plugins_combined): + """Test API without JWT token""" + response = requests.get( + "http://127.0.0.1/", + headers={"Host": "api.local"} + ) + assert response.status_code == 403 + assert "Missing Authorization HTTP header" in response.text + + def test_api_with_valid_token(self, docker_compose_plugins_combined, jwt_token): + """Test API with valid JWT token""" + response = requests.get( + "http://127.0.0.1/", + headers={ + "Host": "api.local", + "Authorization": f"Bearer {jwt_token}" + } + ) + assert response.status_code == 200 + + def test_api_blocked_paths_with_token(self, docker_compose_plugins_combined, jwt_token): + """Test blocked paths on API even with valid JWT""" + blocked_paths = ["/internal", "/debug", "/metrics"] + for path in blocked_paths: + response = requests.get( + f"http://127.0.0.1{path}", + headers={ + "Host": "api.local", + "Authorization": f"Bearer {jwt_token}" + } + ) + assert response.status_code == 403 + + def test_admin_panel_localhost(self, docker_compose_plugins_combined): + """Test admin panel from localhost (should be allowed)""" + response = requests.get( + "http://127.0.0.1/", + headers={"Host": "admin.local"} + ) + assert response.status_code == 200 + + def test_haproxy_stats(self, docker_compose_plugins_combined): + """Test HAProxy stats interface""" + response = requests.get( + "http://localhost:1936", + auth=("admin", "password") + ) + assert response.status_code == 200 + assert "Statistics Report for HAProxy" in response.text + + +# ============================================================================= +# Test: docker-compose-ip-whitelist.yml - IP Whitelist Plugin +# ============================================================================= + +def extract_backend_block(config: str, backend_name: str) -> str: + """Extract a specific backend block from HAProxy configuration""" + lines = config.split('\n') + backend_lines = [] + in_backend = False + + for line in lines: + if line.startswith(f'backend {backend_name}'): + in_backend = True + backend_lines.append(line) + elif in_backend: + # Stop when we hit another backend, frontend, or global section + if line.startswith(('backend ', 'frontend ', 'global ', 'defaults ')): + break + backend_lines.append(line) + + return '\n'.join(backend_lines) + + +@pytest.mark.security +class TestIPWhitelist: + """Tests for IP whitelist plugin""" + + def test_haproxy_config(self, docker_compose_ip_whitelist): + """Test HAProxy configuration has IP whitelist rules in the correct backend""" + result = subprocess.run( + ["docker", "exec", "docker-haproxy-1", "cat", "/etc/haproxy/haproxy.cfg"], + capture_output=True, + text=True, + check=True + ) + config = result.stdout + + # Extract the specific backend block + backend_block = extract_backend_block(config, "srv_admin_local_80") + assert backend_block, "Backend srv_admin_local_80 not found" + + # Verify IP whitelist plugin comment is in this backend + assert "# IP Whitelist - Only allow specific IPs" in backend_block + + # Verify ACL for whitelisted IPs is in this backend + assert "acl whitelisted_ip src" in backend_block + + # Extract the ACL line to verify IPs + acl_line = [line for line in backend_block.split('\n') if 'acl whitelisted_ip src' in line][0] + assert "127.0.0.1" in acl_line + assert "192.168.0.0/16" in acl_line + assert "10.0.0.0/8" in acl_line + assert "172.16.0.0/12" in acl_line + + # Verify deny rule for non-whitelisted IPs is in this backend + assert "http-request deny deny_status 403 if !whitelisted_ip" in backend_block + + def test_localhost_allowed(self, docker_compose_ip_whitelist): + """Test access from localhost (should be allowed)""" + response = requests.get( + "http://127.0.0.1/", + headers={"Host": "admin.local"} + ) + assert response.status_code == 200 + assert "Admin Panel" in response.text + + def test_haproxy_stats(self, docker_compose_ip_whitelist): + """Test HAProxy stats interface""" + response = requests.get( + "http://localhost:1936", + auth=("admin", "password") + ) + assert response.status_code == 200 + assert "Statistics Report for HAProxy" in response.text + + +# ============================================================================= +# Test: docker-compose-cloudflare.yml - Cloudflare IP Restoration Plugin +# ============================================================================= + +@pytest.mark.cloudflare +class TestCloudflare: + """Tests for Cloudflare IP restoration plugin""" + + def test_haproxy_config(self, docker_compose_cloudflare): + """Test HAProxy configuration has Cloudflare plugin rules in the correct backend""" + result = subprocess.run( + ["docker", "exec", "docker-haproxy-1", "cat", "/etc/haproxy/haproxy.cfg"], + capture_output=True, + text=True, + check=True + ) + config = result.stdout + + # Extract the specific backend block + backend_block = extract_backend_block(config, "srv_myapp_local_80") + assert backend_block, "Backend srv_myapp_local_80 not found" + + # Verify Cloudflare plugin comment + assert "# Cloudflare - Restore original visitor IP" in backend_block + + # Verify ACL for Cloudflare IPs + assert "acl from_cloudflare src -f /etc/haproxy/cloudflare_ips.lst" in backend_block + + # Verify transaction variable for real IP + assert "http-request set-var(txn.real_ip) req.hdr(CF-Connecting-IP) if from_cloudflare" in backend_block + + # Verify X-Forwarded-For header restoration with transaction variable + assert "http-request set-header X-Forwarded-For %[var(txn.real_ip)] if from_cloudflare" in backend_block + + def test_normal_request(self, docker_compose_cloudflare): + """ + Test normal request without CF-Connecting-IP header + + When a request comes from a Cloudflare IP (Docker network is in cloudflare_ips.lst) + but has NO CF-Connecting-IP header, the X-Forwarded-For will be empty because + HAProxy tries to extract from a non-existent header. This is expected behavior. + """ + response = requests.get( + "http://127.0.0.1/", + headers={"Host": "myapp.local"} + ) + assert response.status_code == 200 + data = response.json() + assert 'headers' in data + assert 'x_forwarded_for' in data + + # Verify X-Forwarded-For is empty (not a translated IP) + # Request comes from "Cloudflare IP" (Docker network) but has no CF-Connecting-IP + x_forwarded_for = data['x_forwarded_for'] + assert x_forwarded_for == '', \ + f"Expected X-Forwarded-For to be empty (no CF-Connecting-IP provided), got '{x_forwarded_for}'" + + # Verify client_ip is the HAProxy container IP (backend sees connection from HAProxy) + client_ip = data['client_ip'] + assert client_ip.startswith('172.'), \ + f"Expected client_ip to be HAProxy container IP (172.x.x.x), got '{client_ip}'" + + def test_cloudflare_ip_translation(self, docker_compose_cloudflare): + """ + Test that Cloudflare plugin actually translates CF-Connecting-IP to X-Forwarded-For + + This test verifies the Cloudflare plugin correctly: + 1. Detects requests from Cloudflare IPs (127.0.0.1 is in cloudflare_ips.lst) + 2. Extracts the CF-Connecting-IP header value + 3. Sets X-Forwarded-For header to that value + 4. Backend receives the correct translated IP + """ + test_ip = "203.0.113.50" + response = requests.get( + "http://127.0.0.1/", + headers={ + "Host": "myapp.local", + "CF-Connecting-IP": test_ip + } + ) + assert response.status_code == 200 + + # Parse JSON response from header-echo server + data = response.json() + + # VERIFY: X-Forwarded-For was set to the CF-Connecting-IP value + assert data['x_forwarded_for'] == test_ip, \ + f"Expected X-Forwarded-For to be '{test_ip}', got '{data['x_forwarded_for']}'. " \ + f"Cloudflare IP translation is NOT working!" + + # Verify client_ip is still the HAProxy container IP (connection doesn't change) + client_ip = data['client_ip'] + assert client_ip.startswith('172.'), \ + f"Expected client_ip to be HAProxy container IP (172.x.x.x), got '{client_ip}'" + + def test_haproxy_stats(self, docker_compose_cloudflare): + """Test HAProxy stats interface""" + response = requests.get( + "http://localhost:1936", + auth=("admin", "password") + ) + assert response.status_code == 200 + assert "Statistics Report for HAProxy" in response.text + + +# ============================================================================= +# Test: docker-compose-changed-label.yml - Custom Label Prefix +# ============================================================================= + +@pytest.fixture +def docker_compose_changed_label() -> Generator[None, None, None]: + """Fixture for docker-compose-changed-label.yml""" + fixture = DockerComposeFixture("docker-compose-changed-label.yml") + fixture.up() + yield + fixture.down() + + +@pytest.mark.custom_label +class TestChangedLabel: + """Tests for docker-compose-changed-label.yml - Custom label prefix""" + + def test_haproxy_config(self, docker_compose_changed_label): + """Test HAProxy configuration with custom label prefix""" + result = subprocess.run( + ["docker", "exec", "docker-haproxy-1", "cat", "/etc/haproxy/haproxy.cfg"], + capture_output=True, + text=True, + check=True + ) + config = result.stdout + + # Verify HTTPS backend exists + assert "backend srv_host1_local_443" in config + + # Verify SSL configuration (frontend with SSL) + assert "bind *:443" in config + assert "ssl crt" in config + + # Verify HTTP backend exists + assert "backend srv_host1_local_80" in config + + # Verify HTTP to HTTPS redirect is configured + assert "redirect prefix https://host1.local code 301" in config + + def test_https_access(self, docker_compose_changed_label): + """Test HTTPS access to host1.local""" + response = requests.get( + "https://127.0.0.1/", + headers={"Host": "host1.local"}, + verify=False # Self-signed certificate + ) + assert response.status_code == 200 + # byjg/static-httpserver returns a "Coming Soon" page + assert "soon" in response.text.lower() or "coming" in response.text.lower() + + def test_http_redirect(self, docker_compose_changed_label): + """Test HTTP to HTTPS redirect""" + response = requests.get( + "http://127.0.0.1/", + headers={"Host": "host1.local"}, + allow_redirects=False + ) + # The redirect uses 301 (permanent) as configured in the labels + assert response.status_code == 301 + assert response.headers["Location"] == "https://host1.local/" + + def test_custom_label_prefix(self, docker_compose_changed_label): + """Verify custom label prefix 'haproxy' is being used""" + # Get container ID for static-httpserver + result = subprocess.run( + ["docker", "ps", "-q", "-f", "ancestor=byjg/static-httpserver"], + capture_output=True, + text=True, + check=True + ) + container_id = result.stdout.strip() + assert container_id, "Container not found" + + # Inspect container labels + result = subprocess.run( + ["docker", "inspect", container_id], + capture_output=True, + text=True, + check=True + ) + + # Verify labels start with "haproxy." not "easyhaproxy." + assert '"haproxy.http.host":' in result.stdout or '"haproxy.http.host"' in result.stdout + assert '"haproxy.https.host":' in result.stdout or '"haproxy.https.host"' in result.stdout + + def test_haproxy_stats(self, docker_compose_changed_label): + """Test HAProxy stats interface""" + response = requests.get( + "http://localhost:1936", + auth=("admin", "password") + ) + assert response.status_code == 200 + assert "Statistics Report for HAProxy" in response.text + + +# ============================================================================= +# Helper functions for manual testing +# ============================================================================= + +def run_manual_test(compose_file: str, test_function): + """ + Helper function to run a test manually without pytest + + Example: + def my_test(): + response = requests.get("http://localhost/") + assert response.status_code == 200 + + run_manual_test("docker-compose.yml", my_test) + """ + fixture = DockerComposeFixture(compose_file) + try: + fixture.up() + test_function() + print("✅ Test passed!") + except AssertionError as e: + print(f"❌ Test failed: {e}") + finally: + fixture.down() + + +if __name__ == "__main__": + print("This is a pytest test suite. Run with: pytest test_docker_compose.py -v") + print("\nAvailable test classes:") + print(" - TestBasicSSL: Basic SSL setup tests") + print(" - TestJWTValidator: JWT validator plugin tests") + print(" - TestMultiContainers: Load balancing tests") + print(" - TestPHPFPM: PHP-FPM FastCGI tests") + print(" - TestPluginsCombined: Combined plugins tests") + print(" - TestIPWhitelist: IP whitelist plugin tests") + print(" - TestCloudflare: Cloudflare IP restoration plugin tests") + print(" - TestChangedLabel: Custom label prefix tests") \ No newline at end of file diff --git a/examples/generate-keys.sh b/examples/generate-keys.sh index 96d0cf6..a386c3e 100755 --- a/examples/generate-keys.sh +++ b/examples/generate-keys.sh @@ -7,26 +7,29 @@ set -e echo "Generating SSL certificates and JWT keys for EasyHAProxy examples..." echo "" +# Get the directory where this script is located +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + # Create necessary directories -mkdir -p examples/static -mkdir -p examples/docker -mkdir -p examples/docker/certs/haproxy -mkdir -p examples/swarm/certs +mkdir -p "$SCRIPT_DIR/static" +mkdir -p "$SCRIPT_DIR/docker" +mkdir -p "$SCRIPT_DIR/docker/certs/haproxy" +mkdir -p "$SCRIPT_DIR/swarm/certs" # ============================================================================ # Generate SSL Certificate for host1.local (4096-bit RSA, 10-year validity) # ============================================================================ echo "Generating host1.local certificate (4096-bit RSA, 10-year validity)..." openssl req -x509 -nodes -days 3650 -newkey rsa:4096 \ - -keyout examples/static/host1.local.pem \ - -out examples/static/host1.local.pem \ + -keyout "$SCRIPT_DIR/static/host1.local.pem" \ + -out "$SCRIPT_DIR/static/host1.local.pem" \ -subj "/C=US/ST=State/L=City/O=Organization/CN=host1.local" # Copy to swarm directory -cp examples/static/host1.local.pem examples/swarm/certs/host1.local.pem -echo " Created host1.local.pem (4096-bit, 10 years)" -echo " - examples/static/host1.local.pem" -echo " - examples/swarm/certs/host1.local.pem" +cp "$SCRIPT_DIR/static/host1.local.pem" "$SCRIPT_DIR/swarm/certs/host1.local.pem" +echo "✓ Created host1.local.pem (4096-bit, 10 years)" +echo " - $SCRIPT_DIR/static/host1.local.pem" +echo " - $SCRIPT_DIR/swarm/certs/host1.local.pem" echo "" # ============================================================================ @@ -34,15 +37,15 @@ echo "" # ============================================================================ echo "Generating host2.local certificate (2048-bit RSA, 1-year validity)..." openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ - -keyout examples/docker/host2.local.pem \ - -out examples/docker/host2.local.pem \ + -keyout "$SCRIPT_DIR/docker/host2.local.pem" \ + -out "$SCRIPT_DIR/docker/host2.local.pem" \ -subj "/C=US/ST=State/L=City/O=Organization/CN=host2.local" # Copy to swarm directory -cp examples/docker/host2.local.pem examples/swarm/certs/host2.local.pem -echo " Created host2.local.pem (2048-bit, 1 year)" -echo " - examples/docker/host2.local.pem" -echo " - examples/swarm/certs/host2.local.pem" +cp "$SCRIPT_DIR/docker/host2.local.pem" "$SCRIPT_DIR/swarm/certs/host2.local.pem" +echo "✓ Created host2.local.pem (2048-bit, 1 year)" +echo " - $SCRIPT_DIR/docker/host2.local.pem" +echo " - $SCRIPT_DIR/swarm/certs/host2.local.pem" echo "" # ============================================================================ @@ -51,14 +54,14 @@ echo "" echo "Generating JWT RSA key pair (2048-bit)..." # Generate private key -openssl genrsa -out examples/docker/jwt_private.pem 2048 +openssl genrsa -out "$SCRIPT_DIR/docker/jwt_private.pem" 2048 # Extract public key -openssl rsa -in examples/docker/jwt_private.pem -pubout -out examples/docker/jwt_pubkey.pem +openssl rsa -in "$SCRIPT_DIR/docker/jwt_private.pem" -pubout -out "$SCRIPT_DIR/docker/jwt_pubkey.pem" -echo " Created JWT key pair (2048-bit)" -echo " - examples/docker/jwt_private.pem (private key)" -echo " - examples/docker/jwt_pubkey.pem (public key)" +echo "✓ Created JWT key pair (2048-bit)" +echo " - $SCRIPT_DIR/docker/jwt_private.pem (private key)" +echo " - $SCRIPT_DIR/docker/jwt_pubkey.pem (public key)" echo "" # ============================================================================ @@ -66,12 +69,12 @@ echo "" # ============================================================================ echo "Generating placeholder certificate..." openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ - -keyout examples/docker/certs/haproxy/.place_holder_cert.pem \ - -out examples/docker/certs/haproxy/.place_holder_cert.pem \ + -keyout "$SCRIPT_DIR/docker/certs/haproxy/.place_holder_cert.pem" \ + -out "$SCRIPT_DIR/docker/certs/haproxy/.place_holder_cert.pem" \ -subj "/C=US/ST=State/L=City/O=Organization/CN=placeholder" -echo " Created placeholder certificate" -echo " - examples/docker/certs/haproxy/.place_holder_cert.pem" +echo "✓ Created placeholder certificate" +echo " - $SCRIPT_DIR/docker/certs/haproxy/.place_holder_cert.pem" echo "" # ============================================================================ @@ -94,4 +97,4 @@ echo " - These are self-signed certificates for TESTING ONLY" echo " - DO NOT use these certificates in production" echo " - Browsers will show security warnings for self-signed certificates" echo " - JWT keys should be kept secure and rotated regularly" -echo "" +echo "" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index f0d83d2..bfec772 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,8 @@ dev = [ "pytest>=9.0.2", "pytest-cov>=4.1.0", "ruff>=0.1.0", + "PyJWT>=2.8.0", + "cryptography>=41.0.0", ] [project.scripts] diff --git a/src/easymapping/__init__.py b/src/easymapping/__init__.py index 87da60b..a3934d0 100644 --- a/src/easymapping/__init__.py +++ b/src/easymapping/__init__.py @@ -65,6 +65,7 @@ class HaproxyConfigGenerator: self.certbot_hosts = [] self.serving_hosts = [] self.certs = {} + self.defaults_plugin_configs = [] # Initialize plugin system try: @@ -111,6 +112,13 @@ class HaproxyConfigGenerator: # Extend instead of replace to preserve fcgi-app definitions from domain plugins global_configs = [r.haproxy_config for r in global_results if r.haproxy_config] self.global_plugin_configs.extend(global_configs) + + # Extract defaults-level configs from global plugins + for result in global_results: + if result.metadata and "defaults_config" in result.metadata: + config = result.metadata["defaults_config"] + if config and config not in self.defaults_plugin_configs: + self.defaults_plugin_configs.append(config) except Exception as e: logger_easyhaproxy.warning(f"Failed to execute global plugins: {e}") @@ -121,7 +129,11 @@ class HaproxyConfigGenerator: env.lstrip_blocks = True env.rstrip_blocks = True template = env.get_template('haproxy.cfg.j2') - return template.render(data=self.mapping, global_plugin_configs=self.global_plugin_configs) + return template.render( + data=self.mapping, + global_plugin_configs=self.global_plugin_configs, + defaults_plugin_configs=self.defaults_plugin_configs + ) def parse(self, container_metadata): easymapping = dict() @@ -282,6 +294,13 @@ class HaproxyConfigGenerator: if result.metadata and "fcgi_app_definition" in result.metadata: if result.metadata["fcgi_app_definition"] not in self.global_plugin_configs: self.global_plugin_configs.append(result.metadata["fcgi_app_definition"]) + + # Extract defaults-level config from metadata (e.g., log-format from Cloudflare plugin) + for result in domain_results: + if result.metadata and "defaults_config" in result.metadata: + config = result.metadata["defaults_config"] + if config and config not in self.defaults_plugin_configs: + self.defaults_plugin_configs.append(config) except Exception as e: logger_easyhaproxy.warning(f"Failed to execute domain plugins for {hostname}: {e}") easymapping[port]["hosts"][hostname]["plugin_configs"] = [] diff --git a/src/plugins/builtin/cloudflare.py b/src/plugins/builtin/cloudflare.py index c3c5992..b7722b8 100644 --- a/src/plugins/builtin/cloudflare.py +++ b/src/plugins/builtin/cloudflare.py @@ -10,6 +10,7 @@ updated and written to the IP list file. Configuration: - ip_list_path: Path to file containing Cloudflare IP ranges (default: /etc/haproxy/cloudflare_ips.lst) - use_builtin_ips: Use built-in Cloudflare IP ranges (default: true) + - update_log_format: Update HAProxy log format to show real visitor IP (default: true) Example YAML config: plugins: @@ -17,14 +18,21 @@ Example YAML config: enabled: true ip_list_path: /etc/haproxy/cloudflare_ips.lst use_builtin_ips: true + update_log_format: true Example Container Label: easyhaproxy.http.plugins: "cloudflare" + easyhaproxy.http.plugin.cloudflare.update_log_format: "true" HAProxy Config Generated: # Cloudflare - Restore original visitor IP acl from_cloudflare src -f /etc/haproxy/cloudflare_ips.lst - http-request set-header X-Forwarded-For %[req.hdr(CF-Connecting-IP)] if from_cloudflare + http-request set-var(txn.real_ip) req.hdr(CF-Connecting-IP) if from_cloudflare + http-request set-header X-Forwarded-For %[var(txn.real_ip)] if from_cloudflare + +Log Format (when update_log_format=true): + Shows real visitor IP alongside connection IP for debugging + Format: real_ip/connection_ip [timestamp] request status bytes ... """ import os @@ -73,6 +81,7 @@ class CloudflarePlugin(PluginInterface): self.ip_list_path = "/etc/haproxy/cloudflare_ips.lst" self.enabled = True self.use_builtin_ips = True + self.update_log_format = True @property def name(self) -> str: @@ -91,6 +100,7 @@ class CloudflarePlugin(PluginInterface): - ip_list_path: Path to Cloudflare IP list file - enabled: Whether plugin is enabled - use_builtin_ips: Use built-in Cloudflare IP ranges (default: true) + - update_log_format: Update HAProxy log format to show real IP (default: true) """ if "ip_list_path" in config: self.ip_list_path = config["ip_list_path"] @@ -101,6 +111,9 @@ class CloudflarePlugin(PluginInterface): if "use_builtin_ips" in config: self.use_builtin_ips = str(config["use_builtin_ips"]).lower() in ["true", "1", "yes"] + if "update_log_format" in config: + self.update_log_format = str(config["update_log_format"]).lower() in ["true", "1", "yes"] + def process(self, context: PluginContext) -> PluginResult: """ Generate HAProxy config to restore original IP from Cloudflare @@ -131,10 +144,19 @@ class CloudflarePlugin(PluginInterface): except Exception as e: logger_easyhaproxy.warning(f"Cloudflare plugin: Failed to write IP list to {self.ip_list_path}: {e}") - # Generate HAProxy config snippet + # Generate HAProxy config snippet for backend haproxy_config = f"""# Cloudflare - Restore original visitor IP acl from_cloudflare src -f {self.ip_list_path} -http-request set-header X-Forwarded-For %[req.hdr(CF-Connecting-IP)] if from_cloudflare""" +http-request set-var(txn.real_ip) req.hdr(CF-Connecting-IP) if from_cloudflare +http-request set-header X-Forwarded-For %[var(txn.real_ip)] if from_cloudflare""" + + # Generate log format config (frontend level) + # Industry-standard format: real_ip/proxy_ip [time] request status bytes timings + # Based on HAProxy HTTP log format with real IP shown first + log_format_config = None + if self.update_log_format: + log_format_config = """# Cloudflare - Enhanced log format showing real visitor IP +log-format "%{+Q}[var(txn.real_ip)]:-/%ci:%cp [%tr] %ft %b/%s %TR/%Tw/%Tc/%Tr/%Ta %ST %B %CC %CS %tsc %ac/%fc/%bc/%sc/%rc %sq/%bq %hr %hs %{+Q}r\"""" return PluginResult( haproxy_config=haproxy_config, @@ -143,6 +165,8 @@ http-request set-header X-Forwarded-For %[req.hdr(CF-Connecting-IP)] if from_clo "domain": context.domain, "ip_list_path": self.ip_list_path, "use_builtin_ips": self.use_builtin_ips, + "update_log_format": self.update_log_format, + "defaults_config": log_format_config, "ip_count": len(self.CLOUDFLARE_IPS) if self.use_builtin_ips else None } ) diff --git a/src/templates/haproxy.cfg.j2 b/src/templates/haproxy.cfg.j2 index ca69453..a50ac50 100644 --- a/src/templates/haproxy.cfg.j2 +++ b/src/templates/haproxy.cfg.j2 @@ -39,6 +39,13 @@ defaults errorfile 503 /etc/haproxy/errors-custom/503.http errorfile 504 /etc/haproxy/errors-custom/504.http {% endif %} +{% if defaults_plugin_configs %} + + # Defaults Plugin Configurations +{% for config in defaults_plugin_configs %} + {{ config }} +{% endfor %} +{% endif %} {% if global_plugin_configs %} # Global Plugin Configurations diff --git a/tests/test_plugins.py b/tests/test_plugins.py index f5775da..fd4c7cc 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -86,7 +86,8 @@ class TestCloudflarePlugin: assert result.haproxy_config is not None assert "Cloudflare" in result.haproxy_config assert "acl from_cloudflare src -f /etc/haproxy/cloudflare_ips.lst" in result.haproxy_config - assert "http-request set-header X-Forwarded-For %[req.hdr(CF-Connecting-IP)]" in result.haproxy_config + assert "http-request set-var(txn.real_ip) req.hdr(CF-Connecting-IP)" in result.haproxy_config + assert "http-request set-header X-Forwarded-For %[var(txn.real_ip)]" in result.haproxy_config assert result.metadata["domain"] == "example.com" assert result.metadata["ip_list_path"] == "/etc/haproxy/cloudflare_ips.lst" @@ -123,7 +124,11 @@ class TestCloudflarePlugin: # Verify Cloudflare config is in the output assert "Cloudflare - Restore original visitor IP" in haproxy_config assert "acl from_cloudflare src -f /etc/haproxy/cloudflare_ips.lst" in haproxy_config - assert "http-request set-header X-Forwarded-For %[req.hdr(CF-Connecting-IP)]" in haproxy_config + assert "http-request set-var(txn.real_ip) req.hdr(CF-Connecting-IP)" in haproxy_config + assert "http-request set-header X-Forwarded-For %[var(txn.real_ip)]" in haproxy_config + # Verify log-format is in defaults section (from defaults_config) + assert "log-format" in haproxy_config + assert "%{+Q}[var(txn.real_ip)]" in haproxy_config def test_cloudflare_plugin_builtin_ips_enabled(self): """Test plugin uses built-in Cloudflare IPs and writes to file""" @@ -1066,13 +1071,20 @@ class TestMultiplePluginsCombined: haproxy_config = cfg.generate(line_list) # Find positions of plugin configs - cloudflare_pos = haproxy_config.find("Cloudflare") + # Cloudflare has both defaults-level (log-format) and backend-level (IP restoration) configs + cloudflare_defaults_pos = haproxy_config.find("# Cloudflare - Enhanced log format") + cloudflare_backend_pos = haproxy_config.find("# Cloudflare - Restore original visitor IP") deny_pages_pos = haproxy_config.find("Deny Pages") + backend_pos = haproxy_config.find("backend srv_") - # Both should be present - assert cloudflare_pos != -1 + # All should be present + assert cloudflare_defaults_pos != -1 + assert cloudflare_backend_pos != -1 assert deny_pages_pos != -1 - # They should appear in backend sections (not in global/defaults) - assert cloudflare_pos > haproxy_config.find("backend srv_") - assert deny_pages_pos > haproxy_config.find("backend srv_") + # Cloudflare log-format should be in defaults (before backend) + assert cloudflare_defaults_pos < backend_pos + + # Cloudflare IP restoration and Deny Pages should be in backend sections (after backend) + assert cloudflare_backend_pos > backend_pos + assert deny_pages_pos > backend_pos diff --git a/uv.lock b/uv.lock index f14c15c..c26c943 100644 --- a/uv.lock +++ b/uv.lock @@ -366,6 +366,8 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "cryptography" }, + { name = "pyjwt" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "ruff" }, @@ -385,6 +387,8 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "cryptography", specifier = ">=41.0.0" }, + { name = "pyjwt", specifier = ">=2.8.0" }, { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-cov", specifier = ">=4.1.0" }, { name = "ruff", specifier = ">=0.1.0" }, @@ -596,6 +600,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pyjwt" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, +] + [[package]] name = "pyopenssl" version = "25.3.0"